feat(server): resolve per-project-authorized referenced-project set for run prep (#10380)
## Thinking Path > - Paperclip is the control plane for autonomous AI companies > - Run prep needs to know which referenced projects belong in a run without breaking company boundaries > - The anchor project must keep its existing authorization path, while additional mentioned projects must be checked independently and fail closed if access is denied or unknown > - This pull request adds a helper that computes the deduped, company-scoped referenced-project set for run prep and warns when a project is rejected > - It also introduces an off-by-default kill-switch so downstream consumers can adopt the set safely > - The benefit is safer multi-project run preparation without widening access beyond what the run actor is already allowed to read ## Linked Issues or Issue Description This PR does not rely on a public GitHub issue. The change is self-contained and follows the feature-request style description below so reviewers can evaluate it without leaving the PR. **Problem / motivation** - Run prep needs to assemble a referenced-project set that includes the anchor project plus additional @-mentioned projects. - Additional projects must be authorized independently and rejected projects must not widen access. - The result should be safe to merge inertly behind a default-off kill-switch until downstream consumers opt in. **Proposed solution** - Add `resolveRunReferencedProjects(issueId, anchorProjectId, opts)` in `server/src/services/heartbeat.ts`. - Compute a deduped company-scoped set with the anchor project first and additional mentions admitted only after a fail-closed `project:read` authorization check. - Drop missing, foreign-company, denied, or errored projects and append run warnings when they are rejected. - Keep the feature inert behind a default-off kill-switch until downstream workspace resolution is wired to consume it. **Alternatives considered** - Reusing company membership alone was rejected because it would over-admit projects and widen access. - Including all mentioned projects without per-project authorization was rejected because it would bypass the existing access model. **Roadmap alignment** - This is Phase 1 only: the helper is computed but not yet consumed downstream, so the merge is inert until a later phase turns the flag on. ## What Changed - Added `resolveRunReferencedProjects(issueId, anchorProjectId, opts)` in `server/src/services/heartbeat.ts`. - Enforced company scoping, deduplication, fail-closed authorization, and warning emission for additional referenced projects. - Added a configurable cap for the additional referenced-project set. - Added tests covering allowed, denied, foreign-company, thrown-auth, dedupe, and overflow cases. - Added a default-off kill-switch for downstream consumption of the computed set. ## Verification - `tsc --noEmit` - `server/src/__tests__/issues-service.test.ts` now passes 117/117 - `git log --oneline origin/master..HEAD` shows only the expected single commit on this branch ## Risks - The new helper is computed but not yet consumed by workspace resolution, so behavior only changes once downstream code is wired to it. - The authorization path for additional referenced projects is stricter than before, so any unexpected access gap will surface as a dropped project plus warning. ## Model Used OpenAI Codex, GPT-5, tool-use enabled, 128k context. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes: #` / `Refs: #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
5b34d265a4
commit
11273c18d6
|
|
@ -41,6 +41,16 @@ import {
|
|||
WORKSPACE_WORKTREE_REQUIRES_PROJECT_MESSAGE,
|
||||
WORKSPACE_WORKTREE_REQUIRES_PROJECT_REMEDIATION,
|
||||
} from "../services/execution-workspace-policy.ts";
|
||||
import { projectService } from "../services/projects.ts";
|
||||
import {
|
||||
isMultiProjectWorkspaceSyncEnabled,
|
||||
MAX_RUN_REFERENCED_ADDITIONAL_PROJECTS,
|
||||
MAX_RUN_REFERENCED_CANDIDATE_EVALUATIONS,
|
||||
MULTI_PROJECT_WORKSPACE_SYNC_ENV,
|
||||
resolveRunReferencedProjects,
|
||||
type ResolveRunReferencedProjectsOptions,
|
||||
} from "../services/heartbeat.ts";
|
||||
import type { AuthorizationActor, AuthorizationDecision } from "../services/authorization.ts";
|
||||
import { buildAgentMentionHref, buildProjectMentionHref, MAX_ISSUE_REQUEST_DEPTH, type IssueWorkMode } from "@paperclipai/shared";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
|
|
@ -4843,6 +4853,557 @@ describeEmbeddedPostgres("issueService.findMentionedProjectIds", () => {
|
|||
commentProjectId,
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns multiple same-company mentions in order, deduped", async () => {
|
||||
const companyId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const firstProjectId = randomUUID();
|
||||
const secondProjectId = randomUUID();
|
||||
const thirdProjectId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
|
||||
await db.insert(projects).values([
|
||||
{ id: firstProjectId, companyId, name: "First project", status: "in_progress" },
|
||||
{ id: secondProjectId, companyId, name: "Second project", status: "in_progress" },
|
||||
{ id: thirdProjectId, companyId, name: "Third project", status: "in_progress" },
|
||||
]);
|
||||
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
title:
|
||||
`See [First](${buildProjectMentionHref(firstProjectId)}) and ` +
|
||||
`[Second](${buildProjectMentionHref(secondProjectId)})`,
|
||||
description: null,
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
});
|
||||
|
||||
await db.insert(issueComments).values({
|
||||
companyId,
|
||||
issueId,
|
||||
// Repeats the first mention (deduped) and introduces a third.
|
||||
body:
|
||||
`Also [First again](${buildProjectMentionHref(firstProjectId)}) and ` +
|
||||
`[Third](${buildProjectMentionHref(thirdProjectId)})`,
|
||||
});
|
||||
|
||||
expect(await svc.findMentionedProjectIds(issueId)).toEqual([
|
||||
firstProjectId,
|
||||
secondProjectId,
|
||||
thirdProjectId,
|
||||
]);
|
||||
});
|
||||
|
||||
it("filters out a mention from another company", async () => {
|
||||
const companyId = randomUUID();
|
||||
const foreignCompanyId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const sameCompanyProjectId = randomUUID();
|
||||
const foreignProjectId = randomUUID();
|
||||
|
||||
await db.insert(companies).values([
|
||||
{
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
},
|
||||
{
|
||||
id: foreignCompanyId,
|
||||
name: "Other company",
|
||||
issuePrefix: `F${foreignCompanyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(projects).values([
|
||||
{ id: sameCompanyProjectId, companyId, name: "Same-company project", status: "in_progress" },
|
||||
{ id: foreignProjectId, companyId: foreignCompanyId, name: "Foreign project", status: "in_progress" },
|
||||
]);
|
||||
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
title:
|
||||
`Ours [Same](${buildProjectMentionHref(sameCompanyProjectId)}) and ` +
|
||||
`theirs [Foreign](${buildProjectMentionHref(foreignProjectId)})`,
|
||||
description: null,
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
});
|
||||
|
||||
expect(await svc.findMentionedProjectIds(issueId)).toEqual([sameCompanyProjectId]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("multi-project workspace sync kill-switch", () => {
|
||||
it("is OFF by default and enabled only by truthy env values", () => {
|
||||
expect(isMultiProjectWorkspaceSyncEnabled({})).toBe(false);
|
||||
expect(isMultiProjectWorkspaceSyncEnabled({ [MULTI_PROJECT_WORKSPACE_SYNC_ENV]: "" })).toBe(false);
|
||||
expect(isMultiProjectWorkspaceSyncEnabled({ [MULTI_PROJECT_WORKSPACE_SYNC_ENV]: "false" })).toBe(false);
|
||||
expect(isMultiProjectWorkspaceSyncEnabled({ [MULTI_PROJECT_WORKSPACE_SYNC_ENV]: "0" })).toBe(false);
|
||||
expect(isMultiProjectWorkspaceSyncEnabled({ [MULTI_PROJECT_WORKSPACE_SYNC_ENV]: "true" })).toBe(true);
|
||||
expect(isMultiProjectWorkspaceSyncEnabled({ [MULTI_PROJECT_WORKSPACE_SYNC_ENV]: "1" })).toBe(true);
|
||||
expect(isMultiProjectWorkspaceSyncEnabled({ [MULTI_PROJECT_WORKSPACE_SYNC_ENV]: "on" })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describeEmbeddedPostgres("resolveRunReferencedProjects", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let issuesSvc!: ReturnType<typeof issueService>;
|
||||
let projectsSvc!: ReturnType<typeof projectService>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-run-referenced-projects-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
issuesSvc = issueService(db);
|
||||
projectsSvc = projectService(db);
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(issueComments);
|
||||
await db.delete(activityLog);
|
||||
await db.delete(issues);
|
||||
await db.delete(projectWorkspaces);
|
||||
await db.delete(projects);
|
||||
await db.delete(agents);
|
||||
await db.delete(instanceSettings);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
const buildActor = (companyId: string): AuthorizationActor => ({
|
||||
type: "agent",
|
||||
agentId: randomUUID(),
|
||||
companyId,
|
||||
source: "agent_key",
|
||||
});
|
||||
|
||||
const decision = (allowed: boolean): AuthorizationDecision => ({
|
||||
allowed,
|
||||
action: "project:read",
|
||||
reason: allowed ? "allow_company_agent" : "deny_company_boundary",
|
||||
explanation: "test decision",
|
||||
});
|
||||
|
||||
// Records every project:read authorization call and answers via the supplied resolver.
|
||||
const recordingAccess = (
|
||||
resolve: (projectId: string) => AuthorizationDecision | Promise<AuthorizationDecision>,
|
||||
) => {
|
||||
const decidedProjectIds: string[] = [];
|
||||
const access: ResolveRunReferencedProjectsOptions["access"] = {
|
||||
decide: async (input) => {
|
||||
const resource = input.resource;
|
||||
const projectId = resource.type === "project" ? resource.projectId ?? "" : "";
|
||||
decidedProjectIds.push(projectId);
|
||||
return resolve(projectId);
|
||||
},
|
||||
};
|
||||
return { decidedProjectIds, access };
|
||||
};
|
||||
|
||||
const seedCompany = async (companyId: string) => {
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
};
|
||||
|
||||
const seedIssueWithMentions = async (input: {
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
anchorProjectId: string | null;
|
||||
mentionedProjectIds: string[];
|
||||
}) => {
|
||||
const mentionMarkup = input.mentionedProjectIds
|
||||
.map((projectId, index) => `[Ref${index}](${buildProjectMentionHref(projectId)})`)
|
||||
.join(" ");
|
||||
await db.insert(issues).values({
|
||||
id: input.issueId,
|
||||
companyId: input.companyId,
|
||||
projectId: input.anchorProjectId,
|
||||
title: `Referencing ${mentionMarkup}`,
|
||||
description: null,
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
});
|
||||
};
|
||||
|
||||
const baseOpts = (
|
||||
companyId: string,
|
||||
access: ResolveRunReferencedProjectsOptions["access"],
|
||||
overrides?: Partial<ResolveRunReferencedProjectsOptions>,
|
||||
): ResolveRunReferencedProjectsOptions => ({
|
||||
companyId,
|
||||
actor: buildActor(companyId),
|
||||
issues: issuesSvc,
|
||||
projects: projectsSvc,
|
||||
access,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it("admits a same-company project that passes project:read", async () => {
|
||||
const companyId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const anchorProjectId = randomUUID();
|
||||
const mentionedProjectId = randomUUID();
|
||||
|
||||
await seedCompany(companyId);
|
||||
await db.insert(projects).values([
|
||||
{ id: anchorProjectId, companyId, name: "Anchor", status: "in_progress" },
|
||||
{ id: mentionedProjectId, companyId, name: "Mentioned", status: "in_progress" },
|
||||
]);
|
||||
await seedIssueWithMentions({ companyId, issueId, anchorProjectId, mentionedProjectIds: [mentionedProjectId] });
|
||||
|
||||
const { decidedProjectIds, access } = recordingAccess(() => decision(true));
|
||||
const result = await resolveRunReferencedProjects(issueId, anchorProjectId, baseOpts(companyId, access));
|
||||
|
||||
expect(result.anchor?.projectId).toBe(anchorProjectId);
|
||||
expect(result.additional.map((entry) => entry.projectId)).toEqual([mentionedProjectId]);
|
||||
expect(result.warnings).toEqual([]);
|
||||
// The anchor is never re-authorized; only the additional project is checked.
|
||||
expect(decidedProjectIds).toEqual([mentionedProjectId]);
|
||||
});
|
||||
|
||||
it("dedupes the anchor against the mentioned set (anchor wins)", async () => {
|
||||
const companyId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const anchorProjectId = randomUUID();
|
||||
const mentionedProjectId = randomUUID();
|
||||
|
||||
await seedCompany(companyId);
|
||||
await db.insert(projects).values([
|
||||
{ id: anchorProjectId, companyId, name: "Anchor", status: "in_progress" },
|
||||
{ id: mentionedProjectId, companyId, name: "Mentioned", status: "in_progress" },
|
||||
]);
|
||||
// The anchor is also @-mentioned in the body; it must not appear in `additional`.
|
||||
await seedIssueWithMentions({
|
||||
companyId,
|
||||
issueId,
|
||||
anchorProjectId,
|
||||
mentionedProjectIds: [anchorProjectId, mentionedProjectId],
|
||||
});
|
||||
|
||||
const { decidedProjectIds, access } = recordingAccess(() => decision(true));
|
||||
const result = await resolveRunReferencedProjects(issueId, anchorProjectId, baseOpts(companyId, access));
|
||||
|
||||
expect(result.anchor?.projectId).toBe(anchorProjectId);
|
||||
expect(result.additional.map((entry) => entry.projectId)).toEqual([mentionedProjectId]);
|
||||
expect(decidedProjectIds).not.toContain(anchorProjectId);
|
||||
});
|
||||
|
||||
it("drops a foreign-company project before authorization", async () => {
|
||||
const companyId = randomUUID();
|
||||
const foreignCompanyId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const anchorProjectId = randomUUID();
|
||||
const foreignProjectId = randomUUID();
|
||||
|
||||
await seedCompany(companyId);
|
||||
await seedCompany(foreignCompanyId);
|
||||
await db.insert(projects).values([
|
||||
{ id: anchorProjectId, companyId, name: "Anchor", status: "in_progress" },
|
||||
{ id: foreignProjectId, companyId: foreignCompanyId, name: "Foreign", status: "in_progress" },
|
||||
]);
|
||||
await seedIssueWithMentions({ companyId, issueId, anchorProjectId, mentionedProjectIds: [foreignProjectId] });
|
||||
|
||||
const { decidedProjectIds, access } = recordingAccess(() => decision(true));
|
||||
const result = await resolveRunReferencedProjects(issueId, anchorProjectId, baseOpts(companyId, access));
|
||||
|
||||
expect(result.additional).toEqual([]);
|
||||
// Company scoping drops the foreign project before any authorization call is made.
|
||||
expect(decidedProjectIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("drops and warns on a project that fails per-project authorization", async () => {
|
||||
const companyId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const anchorProjectId = randomUUID();
|
||||
const deniedProjectId = randomUUID();
|
||||
|
||||
await seedCompany(companyId);
|
||||
await db.insert(projects).values([
|
||||
{ id: anchorProjectId, companyId, name: "Anchor", status: "in_progress" },
|
||||
{ id: deniedProjectId, companyId, name: "Denied", status: "in_progress" },
|
||||
]);
|
||||
await seedIssueWithMentions({ companyId, issueId, anchorProjectId, mentionedProjectIds: [deniedProjectId] });
|
||||
|
||||
const { access } = recordingAccess((projectId) => decision(projectId !== deniedProjectId));
|
||||
const result = await resolveRunReferencedProjects(issueId, anchorProjectId, baseOpts(companyId, access));
|
||||
|
||||
expect(result.additional).toEqual([]);
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
expect(result.warnings[0]).toContain(deniedProjectId);
|
||||
expect(result.warnings[0]).toContain("not authorized");
|
||||
});
|
||||
|
||||
it("fail-closed drops a project when the authorization service throws", async () => {
|
||||
const companyId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const anchorProjectId = randomUUID();
|
||||
const explodingProjectId = randomUUID();
|
||||
|
||||
await seedCompany(companyId);
|
||||
await db.insert(projects).values([
|
||||
{ id: anchorProjectId, companyId, name: "Anchor", status: "in_progress" },
|
||||
{ id: explodingProjectId, companyId, name: "Exploding", status: "in_progress" },
|
||||
]);
|
||||
await seedIssueWithMentions({ companyId, issueId, anchorProjectId, mentionedProjectIds: [explodingProjectId] });
|
||||
|
||||
const access: ResolveRunReferencedProjectsOptions["access"] = {
|
||||
decide: async () => {
|
||||
throw new Error("authorization backend unavailable");
|
||||
},
|
||||
};
|
||||
const result = await resolveRunReferencedProjects(issueId, anchorProjectId, baseOpts(companyId, access));
|
||||
|
||||
// The run continues; the un-authorizable project is dropped with a warning rather than throwing.
|
||||
expect(result.anchor?.projectId).toBe(anchorProjectId);
|
||||
expect(result.additional).toEqual([]);
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
expect(result.warnings[0]).toContain(explodingProjectId);
|
||||
expect(result.warnings[0]).toContain("not authorized");
|
||||
});
|
||||
|
||||
it("caps the number of additional projects and warns about the overflow", async () => {
|
||||
const companyId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const anchorProjectId = randomUUID();
|
||||
const mentionedProjectIds = [randomUUID(), randomUUID(), randomUUID()];
|
||||
|
||||
await seedCompany(companyId);
|
||||
await db.insert(projects).values([
|
||||
{ id: anchorProjectId, companyId, name: "Anchor", status: "in_progress" },
|
||||
...mentionedProjectIds.map((id, index) => ({
|
||||
id,
|
||||
companyId,
|
||||
name: `Mentioned ${index}`,
|
||||
status: "in_progress" as const,
|
||||
})),
|
||||
]);
|
||||
await seedIssueWithMentions({ companyId, issueId, anchorProjectId, mentionedProjectIds });
|
||||
|
||||
const { decidedProjectIds, access } = recordingAccess(() => decision(true));
|
||||
const result = await resolveRunReferencedProjects(
|
||||
issueId,
|
||||
anchorProjectId,
|
||||
baseOpts(companyId, access, { maxAdditionalProjects: 2 }),
|
||||
);
|
||||
|
||||
expect(result.additional.map((entry) => entry.projectId)).toEqual(mentionedProjectIds.slice(0, 2));
|
||||
// The cap counts admitted projects, so the third project is never authorized once two are admitted.
|
||||
expect(decidedProjectIds).toEqual(mentionedProjectIds.slice(0, 2));
|
||||
expect(result.warnings.some((warning) => warning.includes("Only the first 2"))).toBe(true);
|
||||
expect(MAX_RUN_REFERENCED_ADDITIONAL_PROJECTS).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("does not let an unauthorized mention consume an additional-project cap slot", async () => {
|
||||
const companyId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const anchorProjectId = randomUUID();
|
||||
const deniedProjectId = randomUUID();
|
||||
const allowedProjectIds = [randomUUID(), randomUUID()];
|
||||
// Mention order: the denied project comes first, ahead of two authorized projects.
|
||||
const mentionedProjectIds = [deniedProjectId, ...allowedProjectIds];
|
||||
|
||||
await seedCompany(companyId);
|
||||
await db.insert(projects).values([
|
||||
{ id: anchorProjectId, companyId, name: "Anchor", status: "in_progress" },
|
||||
{ id: deniedProjectId, companyId, name: "Denied", status: "in_progress" },
|
||||
...allowedProjectIds.map((id, index) => ({
|
||||
id,
|
||||
companyId,
|
||||
name: `Allowed ${index}`,
|
||||
status: "in_progress" as const,
|
||||
})),
|
||||
]);
|
||||
await seedIssueWithMentions({ companyId, issueId, anchorProjectId, mentionedProjectIds });
|
||||
|
||||
const { access } = recordingAccess((projectId) => decision(projectId !== deniedProjectId));
|
||||
const result = await resolveRunReferencedProjects(
|
||||
issueId,
|
||||
anchorProjectId,
|
||||
baseOpts(companyId, access, { maxAdditionalProjects: 2 }),
|
||||
);
|
||||
|
||||
// The denied mention is dropped without using a cap slot, so both authorized projects still fit.
|
||||
expect(result.additional.map((entry) => entry.projectId)).toEqual(allowedProjectIds);
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
expect(result.warnings[0]).toContain(deniedProjectId);
|
||||
expect(result.warnings[0]).toContain("not authorized");
|
||||
// The cap was satisfied by admitted projects, so no overflow warning is emitted.
|
||||
expect(result.warnings.some((warning) => warning.includes("Only the first"))).toBe(false);
|
||||
});
|
||||
|
||||
it("bounds authorization fan-out when a same-company mention flood is denied", async () => {
|
||||
const companyId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const anchorProjectId = randomUUID();
|
||||
// A flood of same-company mentions that every fail authorization. Without a fan-out cap this
|
||||
// would authorize all eight candidates because the admitted cap is never reached.
|
||||
const deniedProjectIds = Array.from({ length: 8 }, () => randomUUID());
|
||||
|
||||
await seedCompany(companyId);
|
||||
await db.insert(projects).values([
|
||||
{ id: anchorProjectId, companyId, name: "Anchor", status: "in_progress" },
|
||||
...deniedProjectIds.map((id, index) => ({
|
||||
id,
|
||||
companyId,
|
||||
name: `Denied ${index}`,
|
||||
status: "in_progress" as const,
|
||||
})),
|
||||
]);
|
||||
await seedIssueWithMentions({ companyId, issueId, anchorProjectId, mentionedProjectIds: deniedProjectIds });
|
||||
|
||||
const { decidedProjectIds, access } = recordingAccess(() => decision(false));
|
||||
const result = await resolveRunReferencedProjects(
|
||||
issueId,
|
||||
anchorProjectId,
|
||||
baseOpts(companyId, access, { maxAdditionalProjects: 2, maxCandidateEvaluations: 3 }),
|
||||
);
|
||||
|
||||
// No project is admitted (all denied), but only the first three candidates are ever authorized —
|
||||
// the remaining five are dropped before any authorization decision is made.
|
||||
expect(result.additional).toEqual([]);
|
||||
expect(decidedProjectIds).toEqual(deniedProjectIds.slice(0, 3));
|
||||
expect(decidedProjectIds).toHaveLength(3);
|
||||
// Each dropped-but-evaluated candidate warns it was unauthorized; the tail warns it was skipped
|
||||
// without evaluation.
|
||||
expect(result.warnings.some((warning) => warning.includes("were evaluated for this run"))).toBe(true);
|
||||
expect(
|
||||
result.warnings.some(
|
||||
(warning) => warning.includes("Only the first 3") && warning.includes("5 additional"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("still admits authorized candidates inside the evaluation window under a flood", async () => {
|
||||
const companyId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const anchorProjectId = randomUUID();
|
||||
const allowedProjectId = randomUUID();
|
||||
// The single authorized project sits ahead of a flood of denied mentions and inside the window.
|
||||
const deniedProjectIds = Array.from({ length: 5 }, () => randomUUID());
|
||||
const mentionedProjectIds = [allowedProjectId, ...deniedProjectIds];
|
||||
|
||||
await seedCompany(companyId);
|
||||
await db.insert(projects).values([
|
||||
{ id: anchorProjectId, companyId, name: "Anchor", status: "in_progress" },
|
||||
{ id: allowedProjectId, companyId, name: "Allowed", status: "in_progress" },
|
||||
...deniedProjectIds.map((id, index) => ({
|
||||
id,
|
||||
companyId,
|
||||
name: `Denied ${index}`,
|
||||
status: "in_progress" as const,
|
||||
})),
|
||||
]);
|
||||
await seedIssueWithMentions({ companyId, issueId, anchorProjectId, mentionedProjectIds });
|
||||
|
||||
const { decidedProjectIds, access } = recordingAccess((projectId) =>
|
||||
decision(projectId === allowedProjectId),
|
||||
);
|
||||
const result = await resolveRunReferencedProjects(
|
||||
issueId,
|
||||
anchorProjectId,
|
||||
baseOpts(companyId, access, { maxAdditionalProjects: 2, maxCandidateEvaluations: 3 }),
|
||||
);
|
||||
|
||||
// The authorized project is admitted; only the first three candidates are ever authorized.
|
||||
expect(result.additional.map((entry) => entry.projectId)).toEqual([allowedProjectId]);
|
||||
expect(decidedProjectIds).toEqual(mentionedProjectIds.slice(0, 3));
|
||||
expect(result.warnings.some((warning) => warning.includes("were evaluated for this run"))).toBe(true);
|
||||
});
|
||||
|
||||
it("does not let unavailable mentions consume the evaluation window", async () => {
|
||||
const companyId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const anchorProjectId = randomUUID();
|
||||
const allowedProjectId = randomUUID();
|
||||
// Two unavailable (foreign-company / deleted / unknown) mentions sit ahead of the authorized
|
||||
// project in mention order. The production mention lookup company-filters these out, so drive the
|
||||
// mention set through a stub to exercise the resolver's own availability filtering directly.
|
||||
const unavailableProjectIds = [randomUUID(), randomUUID()];
|
||||
|
||||
await seedCompany(companyId);
|
||||
await db.insert(projects).values([
|
||||
{ id: anchorProjectId, companyId, name: "Anchor", status: "in_progress" },
|
||||
{ id: allowedProjectId, companyId, name: "Allowed", status: "in_progress" },
|
||||
]);
|
||||
await seedIssueWithMentions({ companyId, issueId, anchorProjectId, mentionedProjectIds: [allowedProjectId] });
|
||||
|
||||
const mentions: ResolveRunReferencedProjectsOptions["issues"] = {
|
||||
findMentionedProjectIds: async () => [...unavailableProjectIds, allowedProjectId],
|
||||
};
|
||||
|
||||
const { decidedProjectIds, access } = recordingAccess(() => decision(true));
|
||||
// The evaluation cap is only two slots; if unavailable mentions consumed them, the authorized
|
||||
// project would be displaced out of the window and the set would underfill.
|
||||
const result = await resolveRunReferencedProjects(
|
||||
issueId,
|
||||
anchorProjectId,
|
||||
baseOpts(companyId, access, { issues: mentions, maxAdditionalProjects: 2, maxCandidateEvaluations: 2 }),
|
||||
);
|
||||
|
||||
// Availability filtering runs before the evaluation cap, so the authorized project still lands
|
||||
// inside the window and is admitted rather than displaced.
|
||||
expect(result.additional.map((entry) => entry.projectId)).toEqual([allowedProjectId]);
|
||||
expect(decidedProjectIds).toEqual([allowedProjectId]);
|
||||
// Each unavailable mention warns, but none of them consumed an evaluation slot.
|
||||
expect(
|
||||
result.warnings.filter((warning) => warning.includes("not available in this company")),
|
||||
).toHaveLength(2);
|
||||
expect(result.warnings.some((warning) => warning.includes("without evaluation"))).toBe(false);
|
||||
});
|
||||
|
||||
it("floors the evaluation cap at the admitted cap so the admitted cap stays reachable", async () => {
|
||||
const companyId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const anchorProjectId = randomUUID();
|
||||
const allowedProjectIds = [randomUUID(), randomUUID()];
|
||||
|
||||
await seedCompany(companyId);
|
||||
await db.insert(projects).values([
|
||||
{ id: anchorProjectId, companyId, name: "Anchor", status: "in_progress" },
|
||||
...allowedProjectIds.map((id, index) => ({
|
||||
id,
|
||||
companyId,
|
||||
name: `Allowed ${index}`,
|
||||
status: "in_progress" as const,
|
||||
})),
|
||||
]);
|
||||
await seedIssueWithMentions({ companyId, issueId, anchorProjectId, mentionedProjectIds: allowedProjectIds });
|
||||
|
||||
const { decidedProjectIds, access } = recordingAccess(() => decision(true));
|
||||
// An evaluation cap below the admitted cap must not starve the admitted cap.
|
||||
const result = await resolveRunReferencedProjects(
|
||||
issueId,
|
||||
anchorProjectId,
|
||||
baseOpts(companyId, access, { maxAdditionalProjects: 2, maxCandidateEvaluations: 0 }),
|
||||
);
|
||||
|
||||
expect(result.additional.map((entry) => entry.projectId)).toEqual(allowedProjectIds);
|
||||
expect(decidedProjectIds).toEqual(allowedProjectIds);
|
||||
expect(result.warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it("defaults the evaluation cap at or above the admitted cap", () => {
|
||||
expect(MAX_RUN_REFERENCED_CANDIDATE_EVALUATIONS).toBeGreaterThanOrEqual(
|
||||
MAX_RUN_REFERENCED_ADDITIONAL_PROJECTS,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describeEmbeddedPostgres("issueService.clearExecutionRunIfTerminal", () => {
|
||||
|
|
|
|||
|
|
@ -133,6 +133,8 @@ import {
|
|||
sanitizeRuntimeServiceBaseEnv,
|
||||
} from "./workspace-runtime.js";
|
||||
import { issueService } from "./issues.js";
|
||||
import { projectService } from "./projects.js";
|
||||
import { authorizationService, type AuthorizationActor } from "./authorization.js";
|
||||
import { createToolGatewayService } from "./tool-gateway.js";
|
||||
import { toolAccessService } from "./tool-access.js";
|
||||
import { visibleIssueCondition } from "./issue-visibility.js";
|
||||
|
|
@ -2120,6 +2122,240 @@ export function prioritizeProjectWorkspaceCandidatesForRun<T extends ProjectWork
|
|||
return [rows[preferredIndex]!, ...rows.slice(0, preferredIndex), ...rows.slice(preferredIndex + 1)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Environment flag (kill-switch, default OFF) that gates whether run preparation
|
||||
* consumes the multi-project referenced-project set produced by
|
||||
* {@link resolveRunReferencedProjects}. While unset/off, a run materializes only the
|
||||
* anchor project's workspace exactly as before — the referenced set is inert.
|
||||
*/
|
||||
export const MULTI_PROJECT_WORKSPACE_SYNC_ENV = "PAPERCLIP_MULTI_PROJECT_WORKSPACE_SYNC";
|
||||
|
||||
export function isMultiProjectWorkspaceSyncEnabled(
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
): boolean {
|
||||
return isTruthyRuntimeEnvValue(env[MULTI_PROJECT_WORKSPACE_SYNC_ENV]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upper bound on how many additional (mentioned) projects a single run may materialize
|
||||
* beyond the anchor. Bounds the fan-out of per-project authorization and workspace prep.
|
||||
*/
|
||||
export const MAX_RUN_REFERENCED_ADDITIONAL_PROJECTS = 10;
|
||||
|
||||
/**
|
||||
* Upper bound on how many *available* (same-company, hydrated) candidate projects a single run will
|
||||
* *authorize* before the admitted-project cap is applied.
|
||||
*
|
||||
* This is a fan-out guard distinct from {@link MAX_RUN_REFERENCED_ADDITIONAL_PROJECTS}:
|
||||
* the admitted cap counts only projects that were successfully authorized, so on its own it
|
||||
* does not bound how many `project:read` decisions a run performs — an adversarial same-company
|
||||
* mention flood in which every candidate is denied would authorize every candidate before the
|
||||
* admitted cap is ever reached. This limit caps the number of authorization decisions regardless of
|
||||
* how many candidates are admitted, so denied mentions cannot force unbounded authorization work.
|
||||
* Only available candidates count against it — unavailable mentions are filtered by the company-scoped
|
||||
* hydration first and never consume an evaluation slot. It is always at least the admitted cap so the
|
||||
* admitted cap remains reachable in the normal (non-flood) case.
|
||||
*/
|
||||
export const MAX_RUN_REFERENCED_CANDIDATE_EVALUATIONS = 50;
|
||||
|
||||
type RunReferencedProjectRecord = Awaited<
|
||||
ReturnType<ReturnType<typeof projectService>["listByIds"]>
|
||||
>[number];
|
||||
|
||||
export interface RunReferencedProject {
|
||||
projectId: string;
|
||||
project: RunReferencedProjectRecord;
|
||||
}
|
||||
|
||||
export interface ResolvedRunReferencedProjects {
|
||||
/** The anchor (primary) project — retains the existing git-worktree run path; never re-authorized here. */
|
||||
anchor: RunReferencedProject | null;
|
||||
/** Additional read-only referenced projects that each passed per-project `project:read` authorization. */
|
||||
additional: RunReferencedProject[];
|
||||
/** Human-readable warnings for every referenced project that was dropped (unavailable, unauthorized, or capped). */
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface ResolveRunReferencedProjectsOptions {
|
||||
companyId: string;
|
||||
/** The run actor; every additional project is authorized against this actor. */
|
||||
actor: AuthorizationActor;
|
||||
issues: Pick<ReturnType<typeof issueService>, "findMentionedProjectIds">;
|
||||
projects: Pick<ReturnType<typeof projectService>, "listByIds">;
|
||||
access: Pick<ReturnType<typeof authorizationService>, "decide">;
|
||||
/** Override the additional-project cap (defaults to {@link MAX_RUN_REFERENCED_ADDITIONAL_PROJECTS}). */
|
||||
maxAdditionalProjects?: number;
|
||||
/**
|
||||
* Override the candidate authorization fan-out cap — the maximum number of *available* candidates
|
||||
* that are authorized (defaults to {@link MAX_RUN_REFERENCED_CANDIDATE_EVALUATIONS}). Always
|
||||
* effectively raised to at least the admitted-project cap so the admitted cap stays reachable.
|
||||
*/
|
||||
maxCandidateEvaluations?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Produce the deduped, company-scoped, per-project-authorized referenced-project set
|
||||
* `[anchor, ...additional]` for a run.
|
||||
*
|
||||
* The anchor keeps its existing issue/run authorization path and is never re-authorized or
|
||||
* inherited by the additional projects. Every additional (mentioned) project must independently
|
||||
* pass a fail-closed `project:read` authorization check against the run actor before it is
|
||||
* admitted — any non-`allowed` decision, company mismatch, missing/unknown project, or thrown
|
||||
* authorization error drops the project and appends a warning (the run always continues).
|
||||
*
|
||||
* Candidate evaluation is bounded twice, independently: at most
|
||||
* {@link ResolveRunReferencedProjectsOptions.maxCandidateEvaluations} *available* candidates are ever
|
||||
* hydrated and authorized (a fan-out guard against an adversarial same-company mention flood of denied
|
||||
* projects), and at most {@link ResolveRunReferencedProjectsOptions.maxAdditionalProjects} of those are
|
||||
* admitted. The evaluation cap bounds hydration as well as authorization: candidates are hydrated and
|
||||
* availability-filtered in mention order in bounded batches, and hydration stops as soon as the
|
||||
* evaluation window is filled with available candidates (or the mention set is exhausted), so hydration
|
||||
* never processes the complete mention set — its cost is bounded by the window, not by mention volume.
|
||||
* Availability filtering still runs before a candidate consumes an evaluation slot, so an unavailable
|
||||
* mention (foreign-company, deleted, or malformed id) never occupies a slot or displaces a later
|
||||
* authorized project. Available candidates beyond the evaluation window are left un-hydrated and dropped
|
||||
* with a warning, never triggering an authorization decision.
|
||||
*/
|
||||
export async function resolveRunReferencedProjects(
|
||||
issueId: string,
|
||||
anchorProjectId: string | null,
|
||||
opts: ResolveRunReferencedProjectsOptions,
|
||||
): Promise<ResolvedRunReferencedProjects> {
|
||||
const { companyId, actor, issues, projects, access } = opts;
|
||||
const warnings: string[] = [];
|
||||
const cap = Math.max(0, opts.maxAdditionalProjects ?? MAX_RUN_REFERENCED_ADDITIONAL_PROJECTS);
|
||||
// The evaluation cap bounds candidate hydration + authorization fan-out. It is always at least the
|
||||
// admitted cap so the admitted cap stays reachable in the normal (non-flood) case.
|
||||
const evaluationCap = Math.max(
|
||||
cap,
|
||||
opts.maxCandidateEvaluations ?? MAX_RUN_REFERENCED_CANDIDATE_EVALUATIONS,
|
||||
);
|
||||
|
||||
// Company-scoped, deduped, order-preserving mention set (title + description + comment bodies).
|
||||
// Run prep counts mentions in comments, so comment bodies are always included.
|
||||
const mentionedIds = await issues.findMentionedProjectIds(issueId, { includeCommentBodies: true });
|
||||
|
||||
// Anchor wins: it keeps the full git-worktree path and is never re-authorized here, so drop it
|
||||
// from the mention set. Preserve mention order while deduping the remaining candidates.
|
||||
const allCandidateIds: string[] = [];
|
||||
const seen = new Set<string>(anchorProjectId ? [anchorProjectId] : []);
|
||||
for (const projectId of mentionedIds) {
|
||||
if (seen.has(projectId)) continue;
|
||||
seen.add(projectId);
|
||||
allCandidateIds.push(projectId);
|
||||
}
|
||||
|
||||
// Hydrate + availability-filter candidates in mention order, but never process more of the mention
|
||||
// set than the evaluation window needs. Candidates are pulled in bounded batches sized to what the
|
||||
// window still needs, and hydration stops as soon as `evaluationCap` *available* candidates are
|
||||
// collected (or the mention set is exhausted). This bounds hydration by the evaluation window rather
|
||||
// than by mention volume: an adversarial same-company mention flood can neither force an unbounded
|
||||
// hydration query nor displace a later authorized project out of the window. `listByIds` filters by
|
||||
// company, so each batch both fetches the records and performs availability filtering — a mention that
|
||||
// did not resolve inside this company (foreign-company, deleted, or malformed id) is dropped here with
|
||||
// a warning and never occupies an evaluation slot. The anchor is co-hydrated with the first batch (it
|
||||
// was excluded from `allCandidateIds` above, so it never double-counts) and is never re-authorized.
|
||||
const availableCandidates: RunReferencedProject[] = [];
|
||||
let hydrationCursor = 0;
|
||||
let anchorRecord: RunReferencedProjectRecord | null = null;
|
||||
let anchorHydrated = false;
|
||||
while (availableCandidates.length < evaluationCap && hydrationCursor < allCandidateIds.length) {
|
||||
const need = evaluationCap - availableCandidates.length;
|
||||
const batchCandidateIds = allCandidateIds.slice(hydrationCursor, hydrationCursor + need);
|
||||
hydrationCursor += batchCandidateIds.length;
|
||||
|
||||
const hydrateIds =
|
||||
!anchorHydrated && anchorProjectId ? [anchorProjectId, ...batchCandidateIds] : batchCandidateIds;
|
||||
const hydrated = await projects.listByIds(companyId, hydrateIds);
|
||||
const byId = new Map(hydrated.map((project) => [project.id, project]));
|
||||
|
||||
if (!anchorHydrated && anchorProjectId) {
|
||||
anchorRecord = byId.get(anchorProjectId) ?? null;
|
||||
anchorHydrated = true;
|
||||
}
|
||||
|
||||
for (const projectId of batchCandidateIds) {
|
||||
const project = byId.get(projectId);
|
||||
if (!project) {
|
||||
warnings.push(`Referenced project ${projectId} was skipped because it is not available in this company.`);
|
||||
continue;
|
||||
}
|
||||
availableCandidates.push({ projectId, project });
|
||||
}
|
||||
}
|
||||
|
||||
// Hydrate the anchor on its own if the candidate loop never ran (no mentions to co-hydrate it with).
|
||||
if (!anchorHydrated && anchorProjectId) {
|
||||
const hydrated = await projects.listByIds(companyId, [anchorProjectId]);
|
||||
anchorRecord = hydrated.find((project) => project.id === anchorProjectId) ?? null;
|
||||
anchorHydrated = true;
|
||||
}
|
||||
|
||||
const anchor: RunReferencedProject | null =
|
||||
anchorRecord && anchorProjectId ? { projectId: anchorProjectId, project: anchorRecord } : null;
|
||||
|
||||
// The loop already bounds `availableCandidates` to at most `evaluationCap` entries. Any mentions left
|
||||
// un-hydrated past the window (the fan-out cap dropped them before hydration/authorization) are
|
||||
// surfaced as a warning after the admit loop below. Denied candidates still consume this window (each
|
||||
// costs exactly one authorization decision, which is what the cap bounds); unavailable mentions,
|
||||
// filtered above, do not.
|
||||
const candidates = availableCandidates;
|
||||
const unevaluatedCandidateCount = allCandidateIds.length - hydrationCursor;
|
||||
|
||||
// Admit candidates in mention order until the cap of successfully-authorized projects is reached.
|
||||
// The cap bounds how many additional projects a run *materializes*, so it is counted against
|
||||
// admitted projects only; denied mentions never use a slot.
|
||||
const additional: RunReferencedProject[] = [];
|
||||
let capReachedAtIndex: number | null = null;
|
||||
for (let index = 0; index < candidates.length; index++) {
|
||||
if (additional.length >= cap) {
|
||||
capReachedAtIndex = index;
|
||||
break;
|
||||
}
|
||||
|
||||
const { projectId, project } = candidates[index]!;
|
||||
|
||||
let allowed = false;
|
||||
try {
|
||||
const decision = await access.decide({
|
||||
actor,
|
||||
action: "project:read",
|
||||
resource: { type: "project", companyId, projectId },
|
||||
scope: { projectId },
|
||||
});
|
||||
allowed = decision.allowed === true;
|
||||
} catch {
|
||||
// Fail-closed: an authorization error never admits a project.
|
||||
allowed = false;
|
||||
}
|
||||
|
||||
if (!allowed) {
|
||||
warnings.push(`Referenced project ${projectId} was skipped because it is not authorized for this run.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
additional.push({ projectId, project });
|
||||
}
|
||||
|
||||
// Warn once if the admitted cap stopped us before every available candidate was considered. The
|
||||
// skipped count includes both the still-unconsidered evaluated candidates and any available
|
||||
// candidates that were dropped before evaluation by the fan-out cap above.
|
||||
if (capReachedAtIndex !== null) {
|
||||
const skipped = candidates.length - capReachedAtIndex + unevaluatedCandidateCount;
|
||||
warnings.push(
|
||||
`Only the first ${cap} referenced project(s) will be synced for this run; ${skipped} additional referenced project(s) were skipped.`,
|
||||
);
|
||||
} else if (unevaluatedCandidateCount > 0) {
|
||||
// The admitted cap was never reached (e.g. a flood of denied mentions), but the evaluation
|
||||
// fan-out cap dropped available candidates before they could be authorized.
|
||||
warnings.push(
|
||||
`Only the first ${evaluationCap} referenced mention(s) were evaluated for this run; ${unevaluatedCandidateCount} additional referenced mention(s) were skipped without evaluation.`,
|
||||
);
|
||||
}
|
||||
|
||||
return { anchor, additional, warnings };
|
||||
}
|
||||
|
||||
function readNonEmptyString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue