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:
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user