diff --git a/packages/plugins/sdk/src/testing.ts b/packages/plugins/sdk/src/testing.ts index af12e9a8c8c..688fe2b10b9 100644 --- a/packages/plugins/sdk/src/testing.ts +++ b/packages/plugins/sdk/src/testing.ts @@ -1076,6 +1076,7 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { pauseReason: null, pausedAt: null, executionWorkspacePolicy: null, + repositoryHints: [], codebase: { workspaceId: null, repoUrl: null, diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 7e0ac23893c..096604d47fa 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2407,6 +2407,12 @@ export type { BeginRepositoryConnectionResult, CompleteRepositoryConnectionResult, ImportRepositoriesResult, + RepositoryContext, + ProjectRepositoryHint, + EffectiveRepositoryContext, + RepositoryRelationshipProject, + RepositoryRelationshipAgent, + RepositoryRelationships, } from "./types/repository.js"; export { FEATURE_TIERS, diff --git a/packages/shared/src/types/project.ts b/packages/shared/src/types/project.ts index 955717a9276..dfe5306c545 100644 --- a/packages/shared/src/types/project.ts +++ b/packages/shared/src/types/project.ts @@ -5,6 +5,7 @@ import type { WorkspaceRuntimeService, } from "./workspace-runtime.js"; import type { AgentEnvConfig } from "./secrets.js"; +import type { ProjectRepositoryHint } from "./repository.js"; export type ProjectWorkspaceSourceType = "local_path" | "git_repo" | "remote_managed" | "non_git_path"; export type ProjectWorkspaceVisibility = "default" | "advanced"; @@ -95,6 +96,8 @@ export interface Project { pauseReason: PauseReason | null; pausedAt: Date | null; executionWorkspacePolicy: ProjectExecutionWorkspacePolicy | null; + /** Non-exclusive guidance about the repositories this project is likely to touch. */ + repositoryHints?: ProjectRepositoryHint[]; codebase: ProjectCodebase; workspaces: ProjectWorkspace[]; primaryWorkspace: ProjectWorkspace | null; diff --git a/packages/shared/src/types/repository.ts b/packages/shared/src/types/repository.ts index 9d4c1a0a098..720e4f29c7d 100644 --- a/packages/shared/src/types/repository.ts +++ b/packages/shared/src/types/repository.ts @@ -139,3 +139,49 @@ export interface ImportRepositoriesResult { imported: number; skipped: string[]; } + +/** Secret-free repository identity sent to agents and embedded in project payloads. */ +export interface RepositoryContext { + id: string; + provider: RepositoryProvider; + host: string; + owner: string; + name: string; + state: RepositoryState; + unavailableReason: string | null; + cloneUrl: string | null; + webUrl: string | null; + defaultBranch: string | null; +} + +export interface ProjectRepositoryHint extends RepositoryContext { + displayOrder: number; +} + +export interface EffectiveRepositoryContext extends RepositoryContext { + sources: { + direct: boolean; + projects: EffectiveRepositoryProjectSource[]; + }; +} + +export interface RepositoryRelationshipProject { + id: string; + name: string; + displayOrder: number; +} + +export interface RepositoryRelationshipAgent { + id: string; + name: string; + title: string | null; + sources: { + direct: boolean; + projects: EffectiveRepositoryProjectSource[]; + }; +} + +export interface RepositoryRelationships { + projects: RepositoryRelationshipProject[]; + agents: RepositoryRelationshipAgent[]; +} diff --git a/server/src/__tests__/heartbeat-context-summary.test.ts b/server/src/__tests__/heartbeat-context-summary.test.ts index 072ae42e319..d2bea34fe27 100644 --- a/server/src/__tests__/heartbeat-context-summary.test.ts +++ b/server/src/__tests__/heartbeat-context-summary.test.ts @@ -155,6 +155,69 @@ describe("buildPaperclipTaskMarkdown", () => { expect(commentWake).toContain("Update the plan only. Do not write code or perform implementation work."); expect(commentWake).not.toContain("Create child issues from the approved plan only"); }); + + it("keeps zero-repository context quiet and emits stable deduplicated repository guidance", () => { + const issue = { + id: "issue-repositories", + identifier: "PAP-17067", + title: "Use repository context", + workMode: "standard", + description: "Repository work", + }; + const quiet = buildPaperclipTaskMarkdown({ issue }); + expect(quiet).not.toContain("Repository hints"); + expect(quiet).not.toContain("Additional direct repository access"); + + const hint = { + id: "repo-hint", + provider: "manual" as const, + host: "github.com", + owner: "paperclipai", + name: "paperclip", + state: "active" as const, + unavailableReason: null, + cloneUrl: "https://github.com/paperclipai/paperclip.git", + webUrl: "https://github.com/paperclipai/paperclip", + defaultBranch: "master", + displayOrder: 0, + }; + const direct = { + id: "repo-direct", + provider: "manual" as const, + host: "github.com", + owner: "paperclipai", + name: "docs", + state: "active" as const, + unavailableReason: null, + cloneUrl: "https://github.com/paperclipai/docs.git", + webUrl: "https://github.com/paperclipai/docs", + defaultBranch: "main", + sources: { direct: true, projects: [] }, + }; + const full = buildPaperclipTaskMarkdown({ + issue, + projectRepositoryHints: [hint, hint], + effectiveRepositories: [ + { ...hint, sources: { direct: true, projects: [{ id: "project-1", name: "Paperclip" }] } }, + direct, + direct, + ], + }); + expect(full?.match(/github\.com\/paperclipai\/paperclip \(default branch: master\)/g)).toHaveLength(1); + expect(full?.match(/github\.com\/paperclipai\/docs/g)).toHaveLength(1); + expect(full).toContain("## Repository hints (not hard boundaries)"); + expect(full).toContain("## Additional direct repository access"); + + const compact = buildPaperclipTaskMarkdown({ + issue, + includeDescription: false, + projectRepositoryHints: [hint], + effectiveRepositories: [direct], + }); + expect(compact).not.toContain("Repository work"); + expect(compact).toContain("github.com/paperclipai/paperclip"); + expect(compact).toContain("github.com/paperclipai/docs"); + }); }); describe("mergeCoalescedContextSnapshot", () => { diff --git a/server/src/__tests__/repositories.test.ts b/server/src/__tests__/repositories.test.ts index b469f998623..2648e9054a4 100644 --- a/server/src/__tests__/repositories.test.ts +++ b/server/src/__tests__/repositories.test.ts @@ -27,7 +27,10 @@ import { import { errorHandler } from "../middleware/index.js"; import { repositoryRoutes } from "../routes/repositories.js"; import { projectService } from "../services/projects.js"; -import { repositoryAccessService } from "../services/repository-access.js"; +import { + authorizeRepositoryCloneCredentialRequest, + repositoryAccessService, +} from "../services/repository-access.js"; import { repositoryConnectionService, repositoryProviderRegistry, @@ -38,7 +41,7 @@ import { sanitizeRepositoryProviderError, sanitizeRepositoryProviderMetadata, } from "../services/repository-normalization.js"; -import { repositoryService } from "../services/repositories.js"; +import { repositoryService, toRepositoryContext } from "../services/repositories.js"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -98,6 +101,21 @@ describe("repository normalization", () => { .toBe("Authorization: [redacted]"); }); + it("omits credential-bearing legacy locators from agent context", () => { + expect(toRepositoryContext({ + id: "legacy-repository", + provider: "manual", + host: "github.com", + owner: "acme", + name: "legacy", + state: "active", + unavailableReason: null, + cloneUrl: "https://user:secret@github.com/acme/legacy.git", + webUrl: "https://user:secret@github.com/acme/legacy", + defaultBranch: "main", + })).toMatchObject({ cloneUrl: null, webUrl: null }); + }); + it("accepts repository ids as project hints and repository-backed workspace inputs", () => { const repositoryId = randomUUID(); expect(createProjectSchema.safeParse({ name: "Cross-repo", repositoryIds: [repositoryId] }).success) @@ -251,6 +269,30 @@ describeEmbeddedPostgres("repository services and routes", () => { })).toEqual([]); }); + it("re-authorizes clone credential requests after access is revoked", async () => { + const seeded = await seed(); + const access = repositoryAccessService(db); + await access.grantAgentRepository({ + companyId: seeded.companyA.id, + agentId: seeded.agent.id, + repositoryId: seeded.repository.id, + }); + expect(await authorizeRepositoryCloneCredentialRequest(db, { + companyId: seeded.companyA.id, + agentId: seeded.agent.id, + repositoryId: seeded.repository.id, + canAccessProject: async () => true, + })).toMatchObject({ repository: { id: seeded.repository.id } }); + + await access.revokeAgentRepository(seeded.companyA.id, seeded.agent.id, seeded.repository.id); + expect(await authorizeRepositoryCloneCredentialRequest(db, { + companyId: seeded.companyA.id, + agentId: seeded.agent.id, + repositoryId: seeded.repository.id, + canAccessProject: async () => true, + })).toBeNull(); + }); + it("uses the authoritative project resolver for effective-access routes", async () => { const seeded = await seed(); await repositoryAccessService(db).attachProjectRepository({ @@ -309,6 +351,10 @@ describeEmbeddedPostgres("repository services and routes", () => { repositoryIds: [seeded.repository.id, secondRepository.id, seeded.repository.id], repositoryAttribution: { createdByUserId: "creator" }, }); + expect(project.repositoryHints.map((repository) => repository.id)).toEqual([ + seeded.repository.id, + secondRepository.id, + ]); const initialLinks = await db .select() .from(projectRepositories) @@ -339,6 +385,38 @@ describeEmbeddedPostgres("repository services and routes", () => { .toHaveLength(2); }); + it("returns source-aware project hints and agent grants for repository detail", async () => { + const seeded = await seed(); + const access = repositoryAccessService(db); + await access.attachProjectRepository({ + companyId: seeded.companyA.id, + projectId: seeded.projectOne.id, + repositoryId: seeded.repository.id, + displayOrder: 0, + }); + await access.grantAgentRepository({ + companyId: seeded.companyA.id, + agentId: seeded.agent.id, + repositoryId: seeded.repository.id, + }); + + const response = await request(createApp(db, seeded.companyA.id)) + .get(`/api/repositories/${seeded.repository.id}/relationships`); + expect(response.status).toBe(200); + expect(response.body.projects).toEqual([ + { id: seeded.projectOne.id, name: "One", displayOrder: 0 }, + ]); + expect(response.body.agents).toEqual([ + expect.objectContaining({ + id: seeded.agent.id, + sources: { + direct: true, + projects: [{ id: seeded.projectOne.id, name: "One" }], + }, + }), + ]); + }); + it("fails closed for cross-company UUIDs and archived repositories", async () => { const seeded = await seed(); const access = repositoryAccessService(db); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index c0295a21869..9be3456ccda 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -133,10 +133,13 @@ import { logActivity, publishActivity, projectService, + repositoryAccessService, + authorizationService, routineService, workProductService, } from "../services/index.js"; import { buildPlanReviewContext } from "../services/plan-review-context.js"; +import { toEffectiveRepositoryContext } from "../services/repository-access.js"; import { decideIssueReviewPathRecovery, ISSUE_REVIEW_PATH_LOST_WAKE_REASON, @@ -2734,6 +2737,8 @@ export function issueRoutes( const instanceSettings = instanceSettingsService(db); const agentsSvc = agentService(db); const projectsSvc = projectService(db); + const repositoryAccessSvc = repositoryAccessService(db); + const authorizationSvc = authorizationService(db); const goalsSvc = goalService(db); const issueApprovalsSvc = issueApprovalService(db); const recoveryActionsSvc = issueRecoveryActionService(db); @@ -5125,6 +5130,7 @@ export function issueRoutes( pauseReason: project.pauseReason, pausedAt: project.pausedAt, executionWorkspacePolicy: project.executionWorkspacePolicy, + repositoryHints: project.repositoryHints, codebase: project.codebase, workspaces: (project.workspaces ?? []).map(compactIssueProjectWorkspace), primaryWorkspace: compactIssueProjectWorkspace(project.primaryWorkspace), @@ -5822,6 +5828,26 @@ export function issueRoutes( issueWorkMode: issue.workMode, includeForIssueComment: wakeCommentId !== null, }); + const effectiveRepositories = issue.assigneeAgentId + ? ((await repositoryAccessSvc.listEffectiveRepositories({ + companyId: issue.companyId, + agentId: issue.assigneeAgentId, + canAccessProject: async (candidate) => { + const decision = await authorizationSvc.decide({ + actor: { + type: "agent", + agentId: issue.assigneeAgentId, + companyId: issue.companyId, + source: "agent_key", + }, + action: "project:read", + resource: { type: "project", companyId: issue.companyId, projectId: candidate.id }, + scope: { projectId: candidate.id }, + }); + return decision.allowed; + }, + })) ?? []).map(toEffectiveRepositoryContext) + : []; const response = { issue: { @@ -5861,8 +5887,12 @@ export function issueRoutes( name: project.name, status: project.status, targetDate: project.targetDate, + repositoryHints: project.repositoryHints, } : null, + agent: issue.assigneeAgentId + ? { id: issue.assigneeAgentId, effectiveRepositories } + : null, goal: goal ? { id: goal.id, diff --git a/server/src/routes/repositories.ts b/server/src/routes/repositories.ts index b42a1ae681a..1cc7d738dc5 100644 --- a/server/src/routes/repositories.ts +++ b/server/src/routes/repositories.ts @@ -1,7 +1,7 @@ -import { eq } from "drizzle-orm"; +import { and, asc, eq } from "drizzle-orm"; import { Router, type Request } from "express"; import type { Db } from "@paperclipai/db"; -import { agents, projects } from "@paperclipai/db"; +import { agentRepositoryGrants, agents, projectRepositories, projects } from "@paperclipai/db"; import { attachProjectRepositorySchema, beginRepositoryConnectionSchema, @@ -107,6 +107,71 @@ export function repositoryRoutes(db: Db) { res.json(repository); }); + router.get("/repositories/:repositoryId/relationships", async (req, res) => { + assertBoard(req); + const repositoryId = req.params.repositoryId as string; + const repository = await getAccessibleResource( + req, + res, + repositories.getById(repositoryId), + "Repository not found", + ); + if (!repository) return; + + const [projectRows, agentRows, directGrantRows] = await Promise.all([ + db + .select({ id: projects.id, name: projects.name, displayOrder: projectRepositories.displayOrder }) + .from(projectRepositories) + .innerJoin(projects, eq(projectRepositories.projectId, projects.id)) + .where(and( + eq(projectRepositories.companyId, repository.companyId), + eq(projectRepositories.repositoryId, repositoryId), + eq(projects.companyId, repository.companyId), + )) + .orderBy(asc(projectRepositories.displayOrder), asc(projects.name), asc(projects.id)), + db + .select({ id: agents.id, name: agents.name, title: agents.title }) + .from(agents) + .where(eq(agents.companyId, repository.companyId)) + .orderBy(asc(agents.name), asc(agents.id)), + db + .select({ agentId: agentRepositoryGrants.agentId }) + .from(agentRepositoryGrants) + .where(and( + eq(agentRepositoryGrants.companyId, repository.companyId), + eq(agentRepositoryGrants.repositoryId, repositoryId), + )), + ]); + + const directAgentIds = new Set(directGrantRows.map((grant) => grant.agentId)); + const relationshipAgents = ( + await Promise.all(agentRows.map(async (agent) => { + const projectAccess = await Promise.all(projectRows.map(async (project) => ({ + project, + allowed: (await access.decide({ + actor: { + type: "agent", + agentId: agent.id, + companyId: repository.companyId, + source: "agent_key", + }, + action: "project:read", + resource: { type: "project", companyId: repository.companyId, projectId: project.id }, + })).allowed, + }))); + const projectSources = projectAccess + .filter((entry) => entry.allowed) + .map((entry) => ({ id: entry.project.id, name: entry.project.name })); + const direct = directAgentIds.has(agent.id); + return direct || projectSources.length > 0 + ? { ...agent, sources: { direct, projects: projectSources } } + : null; + })) + ).filter((agent): agent is NonNullable => Boolean(agent)); + + res.json({ projects: projectRows, agents: relationshipAgents }); + }); + router.patch("/repositories/:repositoryId", validate(updateRepositorySchema), async (req, res) => { assertBoard(req); const repositoryId = req.params.repositoryId as string; diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index ff40d2f4d26..8bb9f570c45 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -18,10 +18,12 @@ import { type ExecutionWorkspace, type ExecutionWorkspaceConfig, type HeartbeatRunStatusPhase, + type EffectiveRepositoryContext, type IssueExecutionMonitorClearReason, type IssueExecutionMonitorPolicy, type IssueExecutionMonitorRecoveryPolicy, type ModelProfileKey, + type ProjectRepositoryHint, type RequestConfirmationResult, type RoutineRevisionSnapshotV1, type RunLivenessState, @@ -152,6 +154,8 @@ import { import { issueService } from "./issues.js"; import { projectService } from "./projects.js"; import { authorizationService, type AuthorizationActor } from "./authorization.js"; +import { repositoryAccessService, toEffectiveRepositoryContext } from "./repository-access.js"; +import { toRepositoryContext } from "./repositories.js"; import { createToolGatewayService } from "./tool-gateway.js"; import { toolAccessService } from "./tool-access.js"; import { visibleIssueCondition } from "./issue-visibility.js"; @@ -6084,6 +6088,8 @@ export function buildPaperclipTaskMarkdown(input: { status?: string | null; } | null; acceptedPlanContinuation?: boolean; + projectRepositoryHints?: ProjectRepositoryHint[] | null; + effectiveRepositories?: EffectiveRepositoryContext[] | null; // false builds the compact variant used for resume deltas, where the session // already received the description with the assignment. includeDescription?: boolean; @@ -6174,6 +6180,40 @@ export function buildPaperclipTaskMarkdown(input: { if (wakeComment?.body.trim()) { lines.push("", "Latest wake comment:", fenceTaskText(wakeComment.body.trim())); } + const repositoryHints = [...new Map( + (input.projectRepositoryHints ?? []).map((repository) => [repository.id, repository] as const), + ).values()].sort((left, right) => + left.displayOrder - right.displayOrder + || left.host.localeCompare(right.host) + || left.owner.localeCompare(right.owner) + || left.name.localeCompare(right.name) + || left.id.localeCompare(right.id), + ); + if (repositoryHints.length > 0) { + lines.push( + "", + "## Repository hints (not hard boundaries)", + "These repositories are likely relevant to this project. They do not limit other authorized repository access.", + ); + for (const repository of repositoryHints) { + const branch = repository.defaultBranch ? ` (default branch: ${repository.defaultBranch})` : ""; + const locator = repository.cloneUrl ? ` — ${repository.cloneUrl}` : ""; + const state = repository.state === "active" ? "" : ` [${repository.state}]`; + lines.push(`- ${repository.host}/${repository.owner}/${repository.name}${state}${branch}${locator}`); + } + } + const hintedRepositoryIds = new Set(repositoryHints.map((repository) => repository.id)); + const additionalDirectRepositories = [...new Map( + (input.effectiveRepositories ?? []) + .filter((repository) => repository.sources.direct && !hintedRepositoryIds.has(repository.id)) + .map((repository) => [repository.id, repository] as const), + ).values()]; + if (additionalDirectRepositories.length > 0) { + lines.push("", "## Additional direct repository access"); + for (const repository of additionalDirectRepositories) { + lines.push(`- ${repository.host}/${repository.owner}/${repository.name}`); + } + } lines.push("", "Use this task context as the current assignment."); return lines.join("\n"); } @@ -6622,6 +6662,8 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) const secretsSvc = secretService(db); const companySkills = companySkillService(db); const issuesSvc = issueService(db); + const repositoryAccess = repositoryAccessService(db); + const authorization = authorizationService(db); const treeControlSvc = issueTreeControlService(db); const executionWorkspacesSvc = executionWorkspaceService(db); const environmentsSvc = environmentService(db); @@ -13691,6 +13733,41 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) .where(and(eq(projects.id, executionProjectId), eq(projects.companyId, agent.companyId))) .then((rows) => rows[0] ?? null) : null; + const projectRepositoryHints: ProjectRepositoryHint[] = executionProjectId + ? ((await repositoryAccess.listProjectRepositories(agent.companyId, executionProjectId)) ?? []) + .map((entry) => ({ + ...toRepositoryContext(entry.repository), + displayOrder: entry.link.displayOrder, + })) + : []; + const effectiveRepositories = ( + await repositoryAccess.listEffectiveRepositories({ + companyId: agent.companyId, + agentId: agent.id, + canAccessProject: async (project) => { + const decision = await authorization.decide({ + actor: { + type: "agent", + agentId: agent.id, + companyId: agent.companyId, + source: "agent_key", + }, + action: "project:read", + resource: { type: "project", companyId: agent.companyId, projectId: project.id }, + scope: { projectId: project.id }, + }); + return decision.allowed; + }, + }) + )?.map(toEffectiveRepositoryContext) ?? []; + context.project = { + id: executionProjectId ?? null, + repositoryHints: projectRepositoryHints, + }; + context.agent = { + id: agent.id, + effectiveRepositories, + }; const acceptedPlanContinuationWake = issueContext ? readNonEmptyString(context.workspaceRefreshReason) === "accepted_plan_confirmation" || ( @@ -13894,6 +13971,8 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) acceptedPlanContinuation: readNonEmptyString(context.workspaceRefreshReason) === "accepted_plan_confirmation" && Object.keys(parseObject(context.acceptedPlanWakeRouting)).length === 0, + projectRepositoryHints, + effectiveRepositories, }; const taskMarkdown = buildPaperclipTaskMarkdown(taskMarkdownInput); const taskMarkdownCompact = buildPaperclipTaskMarkdown({ ...taskMarkdownInput, includeDescription: false }); diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 0d2394b1ef0..0a40517054a 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -40,7 +40,11 @@ export { toRepositoryConnection, type RepositoryProviderAdapter, } from "./repository-connections.js"; -export { repositoryAccessService } from "./repository-access.js"; +export { + authorizeRepositoryCloneCredentialRequest, + repositoryAccessService, + toEffectiveRepositoryContext, +} from "./repository-access.js"; export { clampIssueListLimit, ISSUE_LIST_DEFAULT_LIMIT, diff --git a/server/src/services/projects.ts b/server/src/services/projects.ts index 87b076b9c38..ff23fba037b 100644 --- a/server/src/services/projects.ts +++ b/server/src/services/projects.ts @@ -24,6 +24,7 @@ import { type ProjectExecutionWorkspacePolicy, type ProjectGoalRef, type ProjectManagedByPlugin, + type ProjectRepositoryHint, type ProjectWorkspaceRuntimeConfig, type ProjectWorkspace, type WorkspaceRuntimeService, @@ -35,6 +36,7 @@ import { unprocessable } from "../errors.js"; import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js"; import { mergeProjectWorkspaceRuntimeConfig, readProjectWorkspaceRuntimeConfig } from "./project-workspace-runtime-config.js"; import { resolveManagedProjectWorkspaceDir } from "../home-paths.js"; +import { toRepository, toRepositoryContext } from "./repositories.js"; type ProjectRow = typeof projects.$inferSelect; type ProjectWorkspaceRow = typeof projectWorkspaces.$inferSelect; @@ -65,6 +67,7 @@ interface ProjectWithGoals extends Omit goalIds: string[]; goals: ProjectGoalRef[]; executionWorkspacePolicy: ProjectExecutionWorkspacePolicy | null; + repositoryHints: ProjectRepositoryHint[]; codebase: ProjectCodebase; workspaces: ProjectWorkspace[]; primaryWorkspace: ProjectWorkspace | null; @@ -89,15 +92,29 @@ async function attachGoals(db: Db, rows: ProjectRow[]): Promise r.id); // Fetch join rows + goal titles in one query - const links = await db - .select({ - projectId: projectGoals.projectId, - goalId: projectGoals.goalId, - goalTitle: goals.title, - }) - .from(projectGoals) - .innerJoin(goals, eq(projectGoals.goalId, goals.id)) - .where(inArray(projectGoals.projectId, projectIds)); + const [links, repositoryLinks] = await Promise.all([ + db + .select({ + projectId: projectGoals.projectId, + goalId: projectGoals.goalId, + goalTitle: goals.title, + }) + .from(projectGoals) + .innerJoin(goals, eq(projectGoals.goalId, goals.id)) + .where(inArray(projectGoals.projectId, projectIds)), + db + .select({ link: projectRepositories, repository: repositories }) + .from(projectRepositories) + .innerJoin(repositories, eq(projectRepositories.repositoryId, repositories.id)) + .where(inArray(projectRepositories.projectId, projectIds)) + .orderBy( + asc(projectRepositories.displayOrder), + asc(repositories.host), + asc(repositories.owner), + asc(repositories.name), + asc(repositories.id), + ), + ]); const map = new Map(); for (const link of links) { @@ -108,6 +125,15 @@ async function attachGoals(db: Db, rows: ProjectRow[]): Promise(); + for (const row of repositoryLinks) { + const hints = repositoriesByProjectId.get(row.link.projectId) ?? []; + hints.push({ + ...toRepositoryContext(toRepository(row.repository)), + displayOrder: row.link.displayOrder, + }); + repositoriesByProjectId.set(row.link.projectId, hints); + } return rows.map((r) => { const g = map.get(r.id) ?? []; @@ -116,6 +142,7 @@ async function attachGoals(db: Db, rows: ProjectRow[]): Promise x.id), goals: g, + repositoryHints: repositoriesByProjectId.get(r.id) ?? [], executionWorkspacePolicy: parseProjectExecutionWorkspacePolicy(r.executionWorkspacePolicy), } as ProjectWithGoals; }); diff --git a/server/src/services/repositories.ts b/server/src/services/repositories.ts index 2042e109e08..4bfb1676aba 100644 --- a/server/src/services/repositories.ts +++ b/server/src/services/repositories.ts @@ -11,6 +11,7 @@ import type { CreateManualRepository, Repository, RepositoryCatalogItem, + RepositoryContext, RepositoryVisibility, UpdateRepository, } from "@paperclipai/shared"; @@ -59,6 +60,42 @@ export function toRepository(row: RepositoryRow): Repository { }; } +/** + * Reduce a repository to the stable, secret-free shape that is safe to place in + * project payloads and agent prompts. Legacy rows may predate URL validation, + * so invalid or credential-bearing URLs are omitted instead of echoed. + */ +export function toRepositoryContext( + repository: Pick, +): RepositoryContext { + let cloneUrl: string | null = null; + let webUrl: string | null = null; + try { + cloneUrl = normalizeRepositoryLocator(repository.cloneUrl).cloneUrl; + } catch { + // Fail closed for pre-normalization rows. + } + if (repository.webUrl) { + try { + webUrl = normalizeRepositoryWebUrl(repository.webUrl); + } catch { + // Fail closed for pre-normalization rows. + } + } + return { + id: repository.id, + provider: repository.provider, + host: repository.host, + owner: repository.owner, + name: repository.name, + state: repository.state, + unavailableReason: repository.unavailableReason, + cloneUrl, + webUrl, + defaultBranch: repository.defaultBranch, + }; +} + export function repositoryService(db: Db) { async function getRowById(id: string) { return db.select().from(repositories).where(eq(repositories.id, id)).then((rows) => rows[0] ?? null); diff --git a/server/src/services/repository-access.ts b/server/src/services/repository-access.ts index 4eb94ba95c2..94f8b8561d6 100644 --- a/server/src/services/repository-access.ts +++ b/server/src/services/repository-access.ts @@ -10,14 +10,22 @@ import { import type { AgentRepositoryGrantEntry, EffectiveRepositoryAccess, + EffectiveRepositoryContext, ProjectRepositoryEntry, Repository, } from "@paperclipai/shared"; import { conflict } from "../errors.js"; -import { toRepository } from "./repositories.js"; +import { toRepository, toRepositoryContext } from "./repositories.js"; type ProjectAccessCandidate = { id: string; name: string; companyId: string }; +export function toEffectiveRepositoryContext(entry: EffectiveRepositoryAccess): EffectiveRepositoryContext { + return { + ...toRepositoryContext(entry.repository), + sources: entry.sources, + }; +} + function toProjectEntry(row: { link: typeof projectRepositories.$inferSelect; repository: typeof repositories.$inferSelect; @@ -302,3 +310,20 @@ export function repositoryAccessService(db: Db) { }, }; } + +/** + * Re-resolve access at the moment a clone credential is requested. Callers + * must never treat repository IDs cached in adapter context as authorization. + */ +export async function authorizeRepositoryCloneCredentialRequest( + db: Db, + input: { + companyId: string; + agentId: string; + repositoryId: string; + canAccessProject: (project: ProjectAccessCandidate) => Promise; + }, +): Promise { + const effective = await repositoryAccessService(db).listEffectiveRepositories(input); + return effective?.find((entry) => entry.repository.id === input.repositoryId) ?? null; +}