feat(repositories): add agent repository context and relationships

Expose stable secret-free project hints and effective repository sources in project, heartbeat, adapter, and resume context. Add source-aware relationship APIs plus request-time clone credential authorization and focused context/service coverage.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-12 02:34:33 +00:00
1 parent 3d4c484816
commit 01bd270119
13 files changed
+479 -15

No files matched your search

+1
View File
@@ -1076,6 +1076,7 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
pauseReason: null,
pausedAt: null,
executionWorkspacePolicy: null,
repositoryHints: [],
codebase: {
workspaceId: null,
repoUrl: null,
+6
View File
@@ -2407,6 +2407,12 @@ export type {
BeginRepositoryConnectionResult,
CompleteRepositoryConnectionResult,
ImportRepositoriesResult,
RepositoryContext,
ProjectRepositoryHint,
EffectiveRepositoryContext,
RepositoryRelationshipProject,
RepositoryRelationshipAgent,
RepositoryRelationships,
} from "./types/repository.js";
export {
FEATURE_TIERS,
+3
View File
@@ -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;
+46
View File
@@ -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[];
}
@@ -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", () => {
+80 -2
View File
@@ -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);
+30
View File
@@ -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,
+67 -2
View File
@@ -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<typeof agent> => 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;
+79
View File
@@ -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 });
+5 -1
View File
@@ -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,
+36 -9
View File
@@ -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<ProjectRow, "executionWorkspacePolicy">
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<ProjectWithGoals
const projectIds = rows.map((r) => 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<string, ProjectGoalRef[]>();
for (const link of links) {
@@ -108,6 +125,15 @@ async function attachGoals(db: Db, rows: ProjectRow[]): Promise<ProjectWithGoals
}
arr.push({ id: link.goalId, title: link.goalTitle });
}
const repositoriesByProjectId = new Map<string, ProjectRepositoryHint[]>();
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<ProjectWithGoals
urlKey: deriveProjectUrlKey(r.name, r.id),
goalIds: g.map((x) => x.id),
goals: g,
repositoryHints: repositoriesByProjectId.get(r.id) ?? [],
executionWorkspacePolicy: parseProjectExecutionWorkspacePolicy(r.executionWorkspacePolicy),
} as ProjectWithGoals;
});
+37
View File
@@ -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<Repository, "id" | "provider" | "host" | "owner" | "name" | "state" | "unavailableReason" | "cloneUrl" | "webUrl" | "defaultBranch">,
): 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);
+26 -1
View File
@@ -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<boolean>;
},
): Promise<EffectiveRepositoryAccess | null> {
const effective = await repositoryAccessService(db).listEffectiveRepositories(input);
return effective?.find((entry) => entry.repository.id === input.repositoryId) ?? null;
}