Authorize background project Git with explicit Connector consent (#17798)

This commit is contained in:
Nicky Leach authored and GitHub committed 2026-10-26 17:22:49 -07:00
1 parent 6874fed05c
commit baec3abd67
37 files changed
+1265 -156

No files matched your search

@@ -98,13 +98,13 @@ rl.on("line", (line) => {
id: message.id,
result: {
ok: true,
supportedMethods: ["getData", "performAction"],
supportedMethods: ["getData", "performAction", "resourceLifecycle", "workspaceOperation"],
},
});
return;
}
if (method === "getData" || method === "performAction") {
if (["getData", "performAction", "resourceLifecycle", "workspaceOperation"].includes(method)) {
sendNestedHostRequest(message, message.paperclipInvocation?.id);
return;
}
@@ -1,3 +1,4 @@
import { githubCredentialsFixture } from "./helpers/github-credentials-fixture.js";
import { createNativeGitHubAccess } from "../services/native-runtime/native-github-access.js";
import express from "express";
import request from "supertest";
@@ -71,129 +72,7 @@ const support = await getEmbeddedPostgresTestSupport();
await database?.cleanup();
vi.unstubAllEnvs();
}, 60_000);
async function seed() {
const companyId = randomUUID(),
agentId = randomUUID(),
runId = randomUUID(),
issueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: companyId,
issuePrefix: companyId.slice(0, 8),
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "Shared",
role: "engineer",
adapterType: "codex_local",
});
await db
.insert(issues)
.values({ id: issueId, companyId, title: "Identity test" });
await db.insert(heartbeatRuns).values({
id: runId,
companyId,
agentId,
status: "running",
contextSnapshot: { issueId },
});
await db.insert(companyMemberships).values(
["A", "B"].map((principalId) => ({
companyId,
principalType: "user",
principalId,
status: "active",
membershipRole: "member",
})),
);
await initializeRunIdentity(db, {
companyId,
runId,
responsibleUserId: "A",
cause: "instruction",
});
return { companyId, agentId, runId, issueId };
}
async function grant(
input: Awaited<ReturnType<typeof seed>>,
user: string,
dedicated = false,
) {
const applicationId = randomUUID(),
connectionId = randomUUID(),
secretId = randomUUID(),
definitionId = randomUUID(),
id = randomUUID();
await db.insert(toolApplications).values({
id: applicationId,
companyId: input.companyId,
name: applicationId,
type: "mcp_http",
});
await db.insert(toolConnections).values({
id: connectionId,
companyId: input.companyId,
applicationId,
name: connectionId,
uid: connectionId,
transport: "mcp_remote",
status: "active",
enabled: true,
credentialPolicy: dedicated ? "per_agent" : "per_user",
config: { sourceTemplateKey: "github" },
});
await db.insert(toolConnectionInstalls).values({
companyId: input.companyId,
connectionId,
targetType: "agent",
targetId: input.agentId,
});
if (!dedicated)
await db.insert(userSecretDefinitions).values({
id: definitionId,
companyId: input.companyId,
key: definitionId,
name: "Test GitHub",
});
await db.insert(companySecrets).values({
id: secretId,
companyId: input.companyId,
key: secretId,
name: `Test token ${secretId}`,
scope: dedicated ? "company" : "user",
ownerUserId: dedicated ? null : user,
userSecretDefinitionId: dedicated ? null : definitionId,
});
await db.insert(connectionGrants).values({
id,
companyId: input.companyId,
connectionId,
kind: dedicated ? "agent" : "user",
subjectUserId: dedicated ? null : user,
subjectAgentId: dedicated ? input.agentId : null,
status: "active",
credentialSecretRefs: [
{
secretId,
configPath: "oauth.access_token",
versionSelector: "latest",
},
],
providerTenant: {
github: {
userId: user,
login: user,
installationCount: 1,
repositoryCount: 1,
repositorySelection: "selected",
installationIds: ["1"],
installationOwnerLogins: [user],
},
},
});
return { id, connectionId, secretId, definitionId };
}
const { seed, grant, switchTo } = githubCredentialsFixture(() => db);
it.each(["config", "transportConfig"] as const)("reads only current GitHub identity connections via %s", async location => {
const input = await seed(), selected = await grant(input, "A");
const [connection] = await db.select().from(toolConnections).where(eq(toolConnections.id, selected.connectionId));
@@ -260,24 +139,6 @@ const support = await getEmbeddedPostgresTestSupport();
} finally { capture.flush(); await observed.$client.end(); }
});
async function switchTo(
input: Awaited<ReturnType<typeof seed>>,
user: string,
) {
const id = randomUUID();
await db.insert(issueComments).values({
id,
companyId: input.companyId,
issueId: input.issueId,
authorUserId: user,
body: "Next instruction",
});
const context = await reserveSteeredIdentity(db, {
...input,
messageId: id,
});
await acceptSteeredIdentity(db, context!);
}
it("resolves A → B → A without retaining tokens, and records only redacted diagnostics", async () => {
const input = await seed();
await grant(input, "A");
@@ -0,0 +1,151 @@
import { randomUUID } from "node:crypto";
import { agents, companies, companyMemberships, companySecrets, connectionGrants, heartbeatRuns, issueComments, issues, toolApplications, toolConnectionInstalls, toolConnections, userSecretDefinitions, type Db } from "@paperclipai/db";
import { initializeRunIdentity, reserveSteeredIdentity, acceptSteeredIdentity } from "../../services/run-identity.js";
export function githubCredentialsFixture(getDb: () => Db) {
async function seed() {
const db = getDb();
const companyId = randomUUID(),
agentId = randomUUID(),
runId = randomUUID(),
issueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: companyId,
issuePrefix: companyId.slice(0, 8),
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "Shared",
role: "engineer",
adapterType: "codex_local",
});
await db
.insert(issues)
.values({ id: issueId, companyId, title: "Identity test" });
await db.insert(heartbeatRuns).values({
id: runId,
companyId,
agentId,
status: "running",
contextSnapshot: { issueId },
});
await db.insert(companyMemberships).values(
["A", "B"].map((principalId) => ({
companyId,
principalType: "user",
principalId,
status: "active",
membershipRole: "member",
})),
);
await initializeRunIdentity(db, {
companyId,
runId,
responsibleUserId: "A",
cause: "instruction",
});
return { companyId, agentId, runId, issueId };
}
async function grant(
input: Awaited<ReturnType<typeof seed>>,
user: string,
dedicated = false,
) {
const db = getDb();
const applicationId = randomUUID(),
connectionId = randomUUID(),
secretId = randomUUID(),
definitionId = randomUUID(),
id = randomUUID();
await db.insert(toolApplications).values({
id: applicationId,
companyId: input.companyId,
name: applicationId,
type: "mcp_http",
});
await db.insert(toolConnections).values({
id: connectionId,
companyId: input.companyId,
applicationId,
name: connectionId,
uid: connectionId,
transport: "mcp_remote",
status: "active",
enabled: true,
credentialPolicy: dedicated ? "per_agent" : "per_user",
config: { sourceTemplateKey: "github" },
});
await db.insert(toolConnectionInstalls).values({
companyId: input.companyId,
connectionId,
targetType: "agent",
targetId: input.agentId,
});
if (!dedicated)
await db.insert(userSecretDefinitions).values({
id: definitionId,
companyId: input.companyId,
key: definitionId,
name: "Test GitHub",
});
await db.insert(companySecrets).values({
id: secretId,
companyId: input.companyId,
key: secretId,
name: `Test token ${secretId}`,
scope: dedicated ? "company" : "user",
ownerUserId: dedicated ? null : user,
userSecretDefinitionId: dedicated ? null : definitionId,
});
await db.insert(connectionGrants).values({
id,
companyId: input.companyId,
connectionId,
kind: dedicated ? "agent" : "user",
subjectUserId: dedicated ? null : user,
subjectAgentId: dedicated ? input.agentId : null,
status: "active",
credentialSecretRefs: [
{
secretId,
configPath: "oauth.access_token",
versionSelector: "latest",
},
],
providerTenant: {
github: {
userId: user,
login: user,
installationCount: 1,
repositoryCount: 1,
repositorySelection: "selected",
installationIds: ["1"],
installationOwnerLogins: [user],
},
},
});
return { id, connectionId, secretId, definitionId };
}
async function switchTo(
input: Awaited<ReturnType<typeof seed>>,
user: string,
) {
const db = getDb();
const id = randomUUID();
await db.insert(issueComments).values({
id,
companyId: input.companyId,
issueId: input.issueId,
authorUserId: user,
body: "Next instruction",
});
const context = await reserveSteeredIdentity(db, {
...input,
messageId: id,
});
await acceptSteeredIdentity(db, context!);
}
return { seed, grant, switchTo };
}
@@ -280,6 +280,33 @@ describe("plugin-worker-manager stderr failure context", () => {
}
});
it("mints project Git scope only for project lifecycle and workspace operations", async () => {
const handle = createPluginWorkerHandle("test.plugin", {
entrypointPath: INVOCATION_SCOPE_WORKER_ENTRYPOINT, manifest: TEST_MANIFEST, config: {},
instanceInfo: { instanceId: "instance-1", hostVersion: "1.0.0" }, apiVersion: 1,
hostHandlers: { "companies.get": (async (_params, context) => context?.invocationScope) as never },
});
const common = { companyId: "company-a", params: { mode: "echo", requestedCompanyId: "company-a" } };
try {
await handle.start();
await expect(handle.call("resourceLifecycle", { ...common, resourceType: "project", resourceId: "project-a" } as never))
.resolves.toEqual({ companyId: "company-a", projectGit: { projectId: "project-a" } });
await expect(handle.call("resourceLifecycle", { ...common, resourceType: "agent", resourceId: "agent-a" } as never))
.resolves.toEqual({ companyId: "company-a" });
for (const kind of ["prepare", "inspect", "remove", "finalize"]) {
const params = { ...common, projectId: "project-a", runId: "run-a", agentId: "agent-a", operation: {
kind, source: { id: "workspace-a" }, binding: { projectWorkspaceId: "workspace-a" } } };
await expect(handle.call("workspaceOperation", params as never)).resolves.toEqual({ companyId: "company-a",
...(kind === "finalize" ? {} : { projectGit: { projectId: "project-a", workspaceId: "workspace-a", operation: kind,
...(kind === "prepare" ? { runId: "run-a", agentId: "agent-a" } : {}) } }) });
}
await expect(handle.call("workspaceOperation", { ...common, projectId: "project-a", runId: null, agentId: null,
operation: { kind: "prepare", source: { id: "workspace-a" } } } as never)).resolves.toEqual({ companyId: "company-a", projectGit: { projectId: "project-a", workspaceId: "workspace-a", operation: "prepare" } });
await expect(handle.call("workspaceOperation", { ...common, projectId: "project-a", runId: null, agentId: "agent-a",
operation: { kind: "prepare", source: { id: "workspace-a" } } } as never)).resolves.toEqual({ companyId: "company-a" });
} finally { await handle.stop().catch(() => undefined); }
});
it("passes performAction invocation scope to nested worker host calls", async () => {
const companiesGet = vi.fn(async (
params: { companyId: string },
@@ -0,0 +1,320 @@
import express from "express";
import request from "supertest";
import { eq } from "drizzle-orm";
import { randomUUID } from "node:crypto";
import { beforeAll, afterAll, describe, expect, it, vi } from "vitest";
import { createDb, authUsers, companyMemberships, connectionGrants, executionWorkspaces, heartbeatRuns, instanceUserRoles, issues, projectAccessMembers, projects, projectWorkspaces, resourceLifecycleEvents, toolConnectionInstalls, toolConnections } from "@paperclipai/db";
import { LOW_TRUST_REVIEW_PRESET } from "@paperclipai/shared";
import { ghFetch } from "../services/github-fetch.js";
import { projectRoutes } from "../routes/projects.js";
import { projectGitAuthorizationService } from "../services/project-git-authorization.js";
import { errorHandler } from "../middleware/index.js";
import { githubCredentialsFixture } from "./helpers/github-credentials-fixture.js";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
const vault = vi.hoisted(() => ({
resolveUserSecretValue: vi.fn(async (_company: string, input: { responsibleUserId: string }) => ({ value: `test-token-${input.responsibleUserId}` })),
resolveSecretValue: vi.fn(async () => "test-dedicated-token"),
}));
vi.mock("../services/secrets.js", () => ({ secretService: () => vault }));
vi.mock("../services/github-fetch.js", async importOriginal => ({
...await importOriginal<typeof import("../services/github-fetch.js")>(),
ghFetch: vi.fn(async () => new Response(null, { status: 404 })),
}));
const support = await getEmbeddedPostgresTestSupport();
(support.supported ? describe : describe.skip)("explicit project Git authorization", () => {
let db: ReturnType<typeof createDb>, database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
const { seed, grant, switchTo } = githubCredentialsFixture(() => db);
beforeAll(async () => {
database = await startEmbeddedPostgresTestDatabase("paperclip-project-git-authorization-");
db = createDb(database.connectionString);
await db.insert(authUsers).values(["A", "B"].map(id => ({
id, name: id, email: `${id}@example.invalid`, createdAt: new Date(), updatedAt: new Date(),
})));
}, 30_000);
afterAll(async () => { await database?.cleanup(); }, 60_000);
async function projectAuthorizationFixture() {
const input = await seed();
const selected = await grant(input, "A");
const [project] = await db.insert(projects).values({ companyId: input.companyId, name: "Authorized project" }).returning();
const [workspace] = await db.insert(projectWorkspaces).values({ companyId: input.companyId, projectId: project.id,
name: "Repository", repoUrl: "https://github.com/fixture/project.git" }).returning();
await db.update(issues).set({ projectId: project.id, projectWorkspaceId: workspace.id }).where(eq(issues.id, input.issueId));
await db.insert(toolConnectionInstalls).values({ companyId: input.companyId, connectionId: selected.connectionId, targetType: "company", targetId: input.companyId });
const [saved] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, selected.id));
await db.update(connectionGrants).set({ providerTenant: { ...saved.providerTenant, github: { ...saved.providerTenant!.github!,
lastAccessRefreshAt: "2020-01-01T00:00:00Z", repositories: [{ id: "1", fullName: "fixture/project", installationId: "1", private: true }] } } }).where(eq(connectionGrants.id, selected.id));
const service = projectGitAuthorizationService(db);
const request = { companyId: input.companyId, projectId: project.id, workspaceId: workspace.id };
return { input, selected, project, workspace, service, request, scope: { projectId: project.id } };
}
it("uses explicit project consent without a task and journals consent changes", async () => {
const f = await projectAuthorizationFixture();
await expect(f.service.resolve(f.request, f.scope)).rejects.toThrow("Authorize this project");
expect(await f.service.read(f.input.companyId, f.project.id, "B")).toMatchObject({ options: [] });
await expect(f.service.set(f.input.companyId, f.project.id, "B", f.selected.id)).rejects.toThrow("own active");
const saved = await f.service.set(f.input.companyId, f.project.id, "A", f.selected.id);
expect(saved).toMatchObject({ grantId: f.selected.id, authorizedByUserId: "A" });
expect(JSON.stringify(saved)).not.toContain("test-token");
await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, f.input.runId));
await db.update(issues).set({ responsibleUserId: "B" }).where(eq(issues.id, f.input.issueId));
expect(await f.service.resolve(f.request, f.scope)).toBe("test-token-A");
expect((await db.select().from(resourceLifecycleEvents).where(eq(resourceLifecycleEvents.resourceId, f.project.id))).length).toBeGreaterThan(0);
await f.service.set(f.input.companyId, f.project.id, "A", null);
await expect(f.service.resolve(f.request, f.scope)).rejects.toThrow("Authorize this project");
});
it("rechecks the consenting user's project access before background credential export", async () => {
const f = await projectAuthorizationFixture();
await f.service.set(f.input.companyId, f.project.id, "A", f.selected.id);
await db.update(projects).set({ visibility: "private" }).where(eq(projects.id, f.project.id));
await db.insert(projectAccessMembers).values({ companyId: f.input.companyId, projectId: f.project.id, subjectType: "user", subjectId: "A" });
expect(await f.service.resolve(f.request, f.scope)).toBe("test-token-A");
await db.delete(projectAccessMembers).where(eq(projectAccessMembers.projectId, f.project.id));
vault.resolveUserSecretValue.mockClear();
await expect(f.service.resolve(f.request, f.scope)).rejects.toThrow("private");
expect(vault.resolveUserSecretValue).not.toHaveBeenCalled();
});
it("rechecks current Cloud owner elevation for consent on another user's private project", async () => {
const f = await projectAuthorizationFixture();
await db.update(projects).set({ visibility: "private", privacyOwnerUserId: "B" }).where(eq(projects.id, f.project.id));
vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", "fixture-tenant-token");
vi.stubEnv("PAPERCLIP_CLOUD_API_ORIGIN", "https://cloud.example.invalid");
vi.stubEnv("PAPERCLIP_CLOUD_STACK_ID", "fixture-stack");
vi.stubEnv("PAPERCLIP_STACK_SLUG", "fixture-stack");
const managed = (enabled: boolean) => vi.stubEnv("PAPERCLIP_MANAGED_CONFIG", JSON.stringify({
v: 1, mode: "cloud", catalogVersion: "test", features: { enableOwnerInstanceAdmin: enabled }, plugins: { autoInstall: [] },
}));
const portfolio = (role = "owner", stackSlug = "fixture-stack") => Response.json({ stacks: [{ stackSlug, role, isCurrent: true }] });
const fetch = vi.fn(async () => portfolio());
vi.stubGlobal("fetch", fetch); managed(true);
try {
const app = express(); app.use(express.json());
app.use((req, _res, next) => { req.actor = { type: "board", source: "cloud_tenant", userId: "A",
companyIds: [f.input.companyId], isInstanceAdmin: true }; next(); });
app.use("/api", projectRoutes(db)); app.use(errorHandler);
expect((await request(app).put(`/api/projects/${f.project.id}/git-authorization`).send({ grantId: f.selected.id })).status).toBe(200);
expect(await f.service.resolve(f.request, f.scope)).toBe("test-token-A");
const cleanup = { ...f.scope, workspaceId: f.workspace.id, operation: "remove" as const };
expect(await f.service.resolve(f.request, cleanup)).toBe("test-token-A");
expect(fetch).toHaveBeenCalledWith(new URL("https://cloud.example.invalid/v1/tenant/portfolio"), expect.objectContaining({
headers: expect.objectContaining({ "x-paperclip-cloud-user-id": "A", "x-paperclip-cloud-stack-id": "fixture-stack" }),
}));
// Stale local roles must never replace current Cloud authority.
await db.insert(instanceUserRoles).values({ userId: "A", role: "instance_admin" });
for (const denied of ["role", "setting", "unavailable", "stack"] as const) {
managed(denied !== "setting");
fetch.mockImplementation(async () => denied === "unavailable" ? new Response(null, { status: 503 })
: portfolio(denied === "role" ? "admin" : "owner", denied === "stack" ? "different-stack" : "fixture-stack"));
vault.resolveUserSecretValue.mockClear();
await expect(f.service.resolve(f.request, cleanup)).rejects.toThrow("private");
expect(vault.resolveUserSecretValue).not.toHaveBeenCalled();
}
await db.insert(projectAccessMembers).values({ companyId: f.input.companyId, projectId: f.project.id, subjectType: "user", subjectId: "A" });
fetch.mockImplementation(async () => new Response(null, { status: 503 }));
expect(await f.service.resolve(f.request, cleanup)).toBe("test-token-A");
} finally {
await db.delete(instanceUserRoles).where(eq(instanceUserRoles.userId, "A"));
vi.unstubAllGlobals(); vi.unstubAllEnvs();
}
});
it("does not lend background consent to a low-trust project", async () => {
const f = await projectAuthorizationFixture();
await f.service.set(f.input.companyId, f.project.id, "A", f.selected.id);
await db.update(projects).set({ executionWorkspacePolicy: { authorizationPolicy: { trustBoundary: {
mode: LOW_TRUST_REVIEW_PRESET, companyId: f.input.companyId, projectIds: [f.project.id],
} } } }).where(eq(projects.id, f.project.id));
vault.resolveUserSecretValue.mockClear();
await expect(f.service.resolve(f.request, f.scope)).rejects.toThrow("low-trust");
expect(vault.resolveUserSecretValue).not.toHaveBeenCalled();
});
it("uses the connection's current repository access for sources added after project authorization", async () => {
const f = await projectAuthorizationFixture();
await f.service.set(f.input.companyId, f.project.id, "A", f.selected.id);
const [workspace] = await db.insert(projectWorkspaces).values({ companyId: f.input.companyId,
projectId: f.project.id, name: "Added repository", repoUrl: "https://github.com/fixture/added",
metadata: { githubRepositoryUrl: "https://github.com/fixture/added", githubRepositoryPrivate: true },
}).returning();
const request = { ...f.request, workspaceId: workspace.id };
vault.resolveUserSecretValue.mockClear();
await expect(f.service.resolve(request, f.scope)).rejects.toThrow("does not include this repository");
expect(vault.resolveUserSecretValue).not.toHaveBeenCalled();
const [saved] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, f.selected.id));
await db.update(connectionGrants).set({ providerTenant: { ...saved.providerTenant, github: { ...saved.providerTenant!.github!,
repositoryCount: 2, repositories: [...saved.providerTenant!.github!.repositories!,
{ id: "2", fullName: "fixture/added", installationId: "1", private: true }],
} } }).where(eq(connectionGrants.id, f.selected.id));
expect(await f.service.resolve(request, f.scope)).toBe("test-token-A");
await db.update(connectionGrants).set({ providerTenant: saved.providerTenant }).where(eq(connectionGrants.id, f.selected.id));
vault.resolveUserSecretValue.mockClear();
await expect(f.service.resolve(request, f.scope)).rejects.toThrow("does not include this repository");
expect(vault.resolveUserSecretValue).not.toHaveBeenCalled();
});
it("never requests credentials or requires a connection for a known public repository", async () => {
const f = await projectAuthorizationFixture();
await db.update(projectWorkspaces).set({ metadata: { githubRepositoryUrl: "https://github.com/fixture/project", githubRepositoryPrivate: false } }).where(eq(projectWorkspaces.id, f.workspace.id));
await db.update(connectionGrants).set({ providerTenant: null, status: "revoked" }).where(eq(connectionGrants.id, f.selected.id));
vi.mocked(ghFetch).mockClear(); vault.resolveUserSecretValue.mockClear();
expect(await f.service.resolve(f.request, f.scope)).toBeNull();
expect(ghFetch).not.toHaveBeenCalled(); expect(vault.resolveUserSecretValue).not.toHaveBeenCalled();
});
it.each(["inspect", "remove"] as const)("allows authorized %s after archive while denying provisioning and revoked consent", async operation => {
const f = await projectAuthorizationFixture();
await db.update(projectWorkspaces).set({ metadata: { githubRepositoryUrl: "https://github.com/fixture/project", githubRepositoryPrivate: true } })
.where(eq(projectWorkspaces.id, f.workspace.id));
await f.service.set(f.input.companyId, f.project.id, "A", f.selected.id);
await db.update(projects).set({ archivedAt: new Date() }).where(eq(projects.id, f.project.id));
const scope = { ...f.scope, workspaceId: f.workspace.id, operation };
expect(await f.service.resolve(f.request, scope)).toBe("test-token-A");
vault.resolveUserSecretValue.mockClear();
for (const deniedScope of [f.scope, { ...f.scope, operation }, { ...scope, operation: "prepare" as const },
{ ...scope, workspaceId: randomUUID() }, { ...scope, runId: f.input.runId, agentId: f.input.agentId }]) {
await expect(f.service.resolve(f.request, deniedScope)).rejects.toThrow();
}
await expect(f.service.set(f.input.companyId, f.project.id, "A", f.selected.id)).rejects.toThrow("Active project");
await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, f.selected.id));
await expect(f.service.resolve(f.request, scope)).rejects.toThrow("authorization is unavailable");
await f.service.set(f.input.companyId, f.project.id, "A", null);
await expect(f.service.resolve(f.request, scope)).rejects.toThrow("Authorize this project");
expect(vault.resolveUserSecretValue).not.toHaveBeenCalled();
});
it.each(["revoked", "disabled"])("ignores %s private catalog entries when the current repository is public", async stale => {
const f = await projectAuthorizationFixture();
if (stale === "revoked") await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, f.selected.id));
else await db.update(toolConnections).set({ enabled: false }).where(eq(toolConnections.id, f.selected.connectionId));
const current = await grant(f.input, "A");
const [saved] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, current.id));
await db.update(connectionGrants).set({ providerTenant: { ...saved.providerTenant, github: { ...saved.providerTenant!.github!,
repositories: [{ id: "1", fullName: "fixture/project", installationId: "1", private: false }] } } }).where(eq(connectionGrants.id, current.id));
vault.resolveUserSecretValue.mockClear(); vi.mocked(ghFetch).mockClear();
expect(await f.service.resolve(f.request, f.scope)).toBeNull();
expect(vault.resolveUserSecretValue).not.toHaveBeenCalled(); expect(ghFetch).not.toHaveBeenCalled();
});
it.each([true, false])("uses refreshed catalog visibility after a repository becomes private=%s", async isPrivate => {
const f = await projectAuthorizationFixture();
await db.update(projectWorkspaces).set({ metadata: { githubRepositoryUrl: "https://github.com/fixture/project", githubRepositoryPrivate: !isPrivate } }).where(eq(projectWorkspaces.id, f.workspace.id));
const [saved] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, f.selected.id));
await db.update(connectionGrants).set({ providerTenant: { ...saved.providerTenant, github: { ...saved.providerTenant!.github!,
repositories: [{ id: "1", fullName: "fixture/project", installationId: "1", private: isPrivate }] } } }).where(eq(connectionGrants.id, f.selected.id));
await f.service.set(f.input.companyId, f.project.id, "A", f.selected.id);
expect(await f.service.resolve(f.request, f.scope)).toBe(isPrivate ? "test-token-A" : null);
});
it("uses repository access refresh time rather than unrelated grant updates for conflicting active catalogs", async () => {
const f = await projectAuthorizationFixture(), current = await grant(f.input, "A");
for (const [id, privateValue, refreshedAt] of [[f.selected.id, true, "2020-01-01T00:00:00Z"], [current.id, false, "2021-01-01T00:00:00Z"]] as const) {
const [saved] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, id));
await db.update(connectionGrants).set({ providerTenant: { ...saved.providerTenant, github: { ...saved.providerTenant!.github!,
lastAccessRefreshAt: refreshedAt, repositories: [{ id: "1", fullName: "fixture/project", installationId: "1", private: privateValue }] } } }).where(eq(connectionGrants.id, id));
}
await db.update(connectionGrants).set({ updatedAt: new Date() }).where(eq(connectionGrants.id, f.selected.id));
expect(await f.service.resolve(f.request, f.scope)).toBeNull();
});
it("never lets a legacy catalog's OAuth update outrank an observed repository refresh", async () => {
const f = await projectAuthorizationFixture(), legacy = await grant(f.input, "A");
const [saved] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, legacy.id));
await db.update(connectionGrants).set({ updatedAt: new Date(), providerTenant: { ...saved.providerTenant, github: { ...saved.providerTenant!.github!,
repositories: [{ id: "1", fullName: "fixture/project", installationId: "1", private: false }] } } }).where(eq(connectionGrants.id, legacy.id));
await f.service.set(f.input.companyId, f.project.id, "A", f.selected.id);
expect(await f.service.resolve(f.request, f.scope)).toBe("test-token-A");
});
it("discovers legacy URL-only public sources anonymously and defers unknown private sources", async () => {
const f = await projectAuthorizationFixture();
await db.update(projectWorkspaces).set({ repoUrl: "https://github.com/fixture/legacy" }).where(eq(projectWorkspaces.id, f.workspace.id));
vi.mocked(ghFetch).mockResolvedValueOnce(Response.json({ full_name: "fixture/legacy", private: false }));
expect(await f.service.resolve(f.request, f.scope)).toBeNull();
const [, options] = vi.mocked(ghFetch).mock.calls.at(-1)!;
expect(new Headers(options?.headers).has("Authorization")).toBe(false);
await expect(f.service.resolve(f.request, f.scope)).rejects.toThrow("visibility is unavailable");
});
it.each(["grant", "connection", "membership", "repository", "archive"])("rechecks %s and never falls back to another grant", async change => {
const f = await projectAuthorizationFixture();
await f.service.set(f.input.companyId, f.project.id, "A", f.selected.id);
await grant(f.input, "A");
if (change === "grant") await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, f.selected.id));
if (change === "connection") await db.update(toolConnections).set({ enabled: false }).where(eq(toolConnections.id, f.selected.connectionId));
if (change === "membership") await db.update(companyMemberships).set({ status: "suspended" }).where(eq(companyMemberships.companyId, f.input.companyId));
if (change === "repository") await db.update(projectWorkspaces).set({ repoUrl: "https://github.com/fixture/other" }).where(eq(projectWorkspaces.id, f.workspace.id));
if (change === "archive") await db.update(projects).set({ archivedAt: new Date() }).where(eq(projects.id, f.project.id));
vault.resolveUserSecretValue.mockClear();
await expect(f.service.resolve(f.request, f.scope)).rejects.toThrow();
expect(vault.resolveUserSecretValue).not.toHaveBeenCalled();
});
it("uses the live foreground run rather than project consent and denies mismatched scopes", async () => {
const f = await projectAuthorizationFixture();
await f.service.set(f.input.companyId, f.project.id, "A", f.selected.id);
await grant(f.input, "B");
await switchTo(f.input, "B");
expect(await f.service.resolve(f.request, { ...f.scope, runId: f.input.runId, agentId: f.input.agentId })).toBe("test-token-B");
for (const scope of [undefined, { projectId: randomUUID() }, { ...f.scope, workspaceId: randomUUID() }]) {
await expect(f.service.resolve(f.request, scope)).rejects.toThrow();
}
await expect(f.service.resolve({ ...f.request, workspaceId: randomUUID() }, f.scope)).rejects.toThrow();
await expect(f.service.resolve({ ...f.request, companyId: randomUUID() }, f.scope)).rejects.toThrow();
});
it.each(["configured", "bound"])("authorizes a %s workspace selected from another project, but not an unselected source", async selection => {
const f = await projectAuthorizationFixture();
const [taskProject] = await db.insert(projects).values({ companyId: f.input.companyId, name: "Task project" }).returning();
const [bound] = selection === "bound" ? await db.insert(executionWorkspaces).values({
companyId: f.input.companyId, projectId: f.project.id, projectWorkspaceId: f.workspace.id,
sourceIssueId: f.input.issueId, name: "Selected workspace", mode: "isolated_workspace", strategyType: "git_worktree", status: "idle",
}).returning() : [];
await db.update(issues).set({ projectId: taskProject.id,
projectWorkspaceId: selection === "configured" ? f.workspace.id : null,
executionWorkspaceId: bound?.id ?? null,
}).where(eq(issues.id, f.input.issueId));
const scope = { ...f.scope, workspaceId: f.workspace.id, runId: f.input.runId, agentId: f.input.agentId };
expect(await f.service.resolve(f.request, scope)).toBe("test-token-A");
const [unselected] = await db.insert(projectWorkspaces).values({ companyId: f.input.companyId,
projectId: f.project.id, name: "Unselected source", repoUrl: f.workspace.repoUrl,
}).returning();
vault.resolveUserSecretValue.mockClear();
await expect(f.service.resolve({ ...f.request, workspaceId: unselected.id }, { ...scope, workspaceId: unselected.id })).rejects.toThrow();
expect(vault.resolveUserSecretValue).not.toHaveBeenCalled();
await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, f.input.runId));
await expect(f.service.resolve(f.request, scope)).rejects.toThrow();
expect(vault.resolveUserSecretValue).not.toHaveBeenCalled();
});
it.each(["", "secret://fixture", "invalid\ntoken"])("rejects invalid transient credential material", async token => {
const f = await projectAuthorizationFixture();
await f.service.set(f.input.companyId, f.project.id, "A", f.selected.id);
vault.resolveUserSecretValue.mockResolvedValueOnce({ value: token });
await expect(f.service.resolve(f.request, f.scope)).rejects.toThrow("credential unavailable");
});
it.each(["task", "source"])("checks access to the %s project before exporting foreground credentials", async privateProject => {
const f = await projectAuthorizationFixture();
const [taskProject] = await db.insert(projects).values({ companyId: f.input.companyId, name: "Task project" }).returning();
await db.update(issues).set({ projectId: taskProject.id }).where(eq(issues.id, f.input.issueId));
await db.update(projects).set({ visibility: "private" })
.where(eq(projects.id, privateProject === "task" ? taskProject.id : f.project.id));
vault.resolveUserSecretValue.mockClear();
vi.stubEnv("PAPERCLIP_ISSUE_PRIVACY_MODE", "enforce");
try {
await expect(f.service.resolve(f.request, { ...f.scope, runId: f.input.runId, agentId: f.input.agentId })).rejects.toThrow();
expect(vault.resolveUserSecretValue).not.toHaveBeenCalled();
} finally { vi.unstubAllEnvs(); }
});
it("never lends project consent to low-trust foreground preparation", async () => {
const f = await projectAuthorizationFixture();
await f.service.set(f.input.companyId, f.project.id, "A", f.selected.id);
await db.update(issues).set({ sourceTrust: { preset: LOW_TRUST_REVIEW_PRESET, disposition: "quarantined", sourceIssueId: f.input.issueId } })
.where(eq(issues.id, f.input.issueId));
vault.resolveUserSecretValue.mockClear();
await expect(f.service.resolve(f.request, { ...f.scope, runId: f.input.runId, agentId: f.input.agentId })).rejects.toThrow("low-trust");
expect(vault.resolveUserSecretValue).not.toHaveBeenCalled();
});
it("requires a board project writer and grant owner on the consent route", async () => {
const f = await projectAuthorizationFixture();
function app(userId: string, agent = false) {
const app = express(); app.use(express.json());
app.use((req, _res, next) => { req.actor = agent ? { type: "agent", agentId: f.input.agentId, companyId: f.input.companyId }
: { type: "board", source: "session", userId, companyIds: [f.input.companyId], isInstanceAdmin: true }; next(); });
app.use("/api", projectRoutes(db)); app.use(errorHandler); return app;
}
const url = `/api/projects/${f.project.id}/git-authorization`;
expect((await request(app("B")).put(url).send({ grantId: f.selected.id })).status).toBe(403);
expect((await request(app("A", true)).put(url).send({ grantId: f.selected.id })).status).toBe(403);
expect((await request(app("A")).put(url).send({ grantId: f.selected.id })).status).toBe(200);
const read = await request(app("A")).get(url);
expect(read.status).toBe(200); expect(JSON.stringify(read.body)).not.toContain("test-token");
expect((await request(app("A")).put(url).send({ grantId: null })).status).toBe(200);
});
});
@@ -33,6 +33,14 @@ const support = await getEmbeddedPostgresTestSupport();
expect(fetched?.workspaces.filter((workspace) => workspace.isPrimary)).toHaveLength(1);
expect(fetched?.workspaces.every((workspace) => workspace.companyId === companyId)).toBe(true);
});
it("retains and refreshes repository visibility without replacing the workspace", async () => {
const svc = projectService(db);
const source = { ...repo("30"), private: true };
const created = await svc.createWithRepositories(companyId, { name: "Visibility" }, [source]);
expect(created.workspaces[0].metadata).toMatchObject({ githubRepositoryPrivate: true, githubRepositoryUrl: source.url });
const updated = await svc.replaceRepositories(created.id, [{ ...source, private: false }]);
expect(updated?.workspaces[0]).toMatchObject({ id: created.workspaces[0].id, metadata: { githubRepositoryPrivate: false, githubRepositoryUrl: source.url } });
});
it("adds/removes repos, promotes the remaining primary, and preserves legacy and local config", async () => {
const svc = projectService(db);
const created = await svc.createWithRepositories(companyId, { name: "Edit" }, [repo("3"), repo("4")]);
+21
View File
@@ -1430,6 +1430,8 @@ const BOARD_ONLY_OPERATIONS = new Set([
"GET /api/companies/{companyId}/project-repositories",
"PUT /api/projects/{id}/repositories",
"GET /api/projects/{id}/git-authorization",
"PUT /api/projects/{id}/git-authorization",
"DELETE /api/issues/{id}/documents/{key}",
"GET /api/companies/{companyId}/decisions",
"GET /api/cloud/stacks",
@@ -5004,6 +5006,25 @@ registry.registerPath({
},
});
const projectGitAuthorizationResponse = z.object({
grantId: z.string().uuid().nullable(),
authorizedByUserId: z.string().nullable(),
options: z.array(z.object({ grantId: z.string().uuid(), connectionName: z.string(), login: z.string() })),
});
registry.registerPath({
method: "get", path: "/api/projects/{id}/git-authorization", tags: ["projects"],
summary: "Read background project GitHub authorization and the viewer's eligible grants",
request: { params: z.object({ id: z.string() }) },
responses: { 200: r.ok(projectGitAuthorizationResponse), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound },
});
registry.registerPath({
method: "put", path: "/api/projects/{id}/git-authorization", tags: ["projects"],
summary: "Authorize or revoke a personal GitHub grant for background project work",
description: "Board project workspace writers can select only their own active company-installed grant. No task authority or token is stored.",
request: { params: z.object({ id: z.string() }), body: jsonBody(z.object({ grantId: z.string().uuid().nullable() }).strict()) },
responses: { 200: r.ok(projectGitAuthorizationResponse), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound },
});
registry.registerPath({
method: "put",
path: "/api/projects/{id}/repositories",
+17
View File
@@ -1,3 +1,4 @@
import { projectGitAuthorizationService } from "../services/project-git-authorization.js";
import { skillAccessRequestContext } from "../services/skill-access.js";
import { createHash } from "node:crypto";
import { and, eq, sql } from "drizzle-orm";
@@ -296,6 +297,22 @@ export function projectRoutes(db: Db) {
res.json(await toolAccessService(db).listProjectRepositories(companyId, viewer.userId, viewer.localTrusted));
});
router.get("/projects/:id/git-authorization", async (req, res) => {
assertBoard(req);
const project = await getAccessibleResource(req, res, svc.getById(req.params.id as string), "Project not found");
if (!project || !(await assertProjectReadAllowed(req, res, project))) return;
if (!req.actor.userId) throw forbidden("A signed-in user is required");
res.json(await projectGitAuthorizationService(db).read(project.companyId, project.id, req.actor.userId));
});
router.put("/projects/:id/git-authorization", validate(z.object({ grantId: z.string().uuid().nullable() }).strict()), async (req, res) => {
assertBoard(req);
const project = await getAccessibleResource(req, res, svc.getById(req.params.id as string), "Project not found");
if (!project || !(await assertProjectReadAllowed(req, res, project)) || !(await assertProjectWorkspaceWriteAllowed(req, res, project))) return;
if (!req.actor.userId) throw forbidden("A signed-in user is required");
res.json(await projectGitAuthorizationService(db).set(project.companyId, project.id, req.actor.userId, req.body.grantId));
});
router.put("/projects/:id/repositories", validate(z.object({ repositoryIds: z.array(z.string().regex(/^\d+$/)) })), async (req, res) => {
assertBoard(req);
const project = await getAccessibleResource(req, res, svc.getById(req.params.id as string), "Project not found");
+4
View File
@@ -363,6 +363,7 @@ export async function resolveManagedGitHubIdentitySelection(
agentId?: string | null;
allowStandingDelegation?: boolean;
excludeGrantId?: string;
explicitGrantId?: string;
},
): Promise<{
configured: boolean;
@@ -405,6 +406,7 @@ export async function resolveManagedGitHubIdentitySelection(
const grants = await db.select().from(connectionGrants).where(and(
eq(connectionGrants.companyId, companyId),
inArray(connectionGrants.connectionId, [...eligibleConnectionIds]),
context.explicitGrantId ? eq(connectionGrants.id, context.explicitGrantId) : undefined,
or(eq(connectionGrants.kind, "agent"), eq(connectionGrants.kind, "user")),
));
const dedicated = context.agentId
@@ -537,6 +539,7 @@ export async function resolveManagedGitHubCredential(
responsibleUserId?: string | null;
agentId?: string | null;
allowStandingDelegation?: boolean;
explicitGrantId?: string;
},
): Promise<{ configured: boolean; identitySource?: "personal" | "dedicated"; credential?: GitCredential; error?: string }> {
const selection = await resolveManagedGitHubIdentitySelection(db, companyId, context);
@@ -631,6 +634,7 @@ export async function resolveManagedGitHubCredential(
} catch {
failure = { configured: true, identitySource: selection.identitySource, error: "GitHub credentials are temporarily unavailable" };
}
if (context.explicitGrantId) return failure;
// Retry credential acquisition, never the GitHub operation. An alternate
// authorization must still belong to this exact principal and account.
const alternate = await resolveManagedGitHubIdentitySelection(db, companyId, {
@@ -47,6 +47,7 @@ const OPERATION_CAPABILITIES: Record<string, readonly PluginCapability[]> = {
"companies.get": ["companies.read"],
"projects.list": ["projects.read"],
"projects.get": ["projects.read"],
"projects.resolveGitCredential": ["projects.git-credentials"],
"projects.getInstanceSecrets": ["projects.instance-secrets"],
"projects.managed.get": ["projects.managed"],
"projects.managed.reconcile": ["projects.managed"],
@@ -1,3 +1,4 @@
import { projectGitAuthorizationService } from "./project-git-authorization.js";
import { projectInstanceSecretService } from "./project-instance-secrets.js";
import { pluginAgentRuntimeState, pluginAgentRuntimeIdentity } from "./plugin-agent-runtime-state.js";
import { createAgentLifecycle } from "./agent-lifecycle.js";
@@ -1863,6 +1864,11 @@ export function buildHostServices(
},
projects: {
async resolveGitCredential(params, context) {
const companyId = ensureCompanyId(params.companyId);
await ensurePluginAvailableForCompany(companyId);
return projectGitAuthorizationService(db).resolve(params, context.invocationScope?.projectGit);
},
async getInstanceSecrets(params) {
const companyId = ensureCompanyId(params.companyId);
await ensurePluginAvailableForCompany(companyId);
+20 -1
View File
@@ -1158,7 +1158,26 @@ export function createPluginWorkerHandle(
if (!isRecord(params)) return null;
const directCompanyId = readNonEmptyString(params.companyId);
if (directCompanyId) return { companyId: directCompanyId };
if (directCompanyId) {
if (method === "resourceLifecycle" && params.resourceType === "project" && readNonEmptyString(params.resourceId)) {
return { companyId: directCompanyId, projectGit: { projectId: params.resourceId as string } };
}
if (method === "workspaceOperation" && isRecord(params.operation)) {
const operation = params.operation;
const source = operation.kind === "prepare" ? operation.source : operation.binding;
const workspaceId = isRecord(source) ? readNonEmptyString(operation.kind === "prepare" ? source.id : source.projectWorkspaceId) : null;
const projectId = readNonEmptyString(params.projectId);
if (projectId && workspaceId && (operation.kind === "prepare" || operation.kind === "inspect" || operation.kind === "remove")) {
// Only foreground preparation may use a run. Maintenance uses project consent.
const runId = operation.kind === "prepare" ? readNonEmptyString(params.runId) : null;
const agentId = operation.kind === "prepare" ? readNonEmptyString(params.agentId) : null;
if (Boolean(runId) !== Boolean(agentId)) return { companyId: directCompanyId };
return { companyId: directCompanyId, projectGit: { projectId, workspaceId, operation: operation.kind,
...(runId && agentId ? { runId, agentId } : {}) } };
}
}
return { companyId: directCompanyId };
}
if (method === "performAction" && isRecord(params.actorContext)) {
const companyId = readNonEmptyString(params.actorContext.companyId);
@@ -0,0 +1,251 @@
import { and, eq, sql } from "drizzle-orm";
import {
companyMemberships,
connectionGrants,
heartbeatRuns,
issues,
projects,
projectWorkspaces,
toolConnections,
toolConnectionInstalls,
type Db,
} from "@paperclipai/db";
import type { ProjectGitAuthorization } from "@paperclipai/shared";
import { forbidden, notFound } from "../errors.js";
import { resolveManagedGitHubCredential } from "./git-credentials.js";
import { resolveGitHubOperationCredentials } from "./github-operation-credentials.js";
import { normalizeProjectRepositoryUrl, projectRepositoryIsPrivate } from "./project-repositories.js";
import { recordProjectLifecycleEvent } from "./resource-lifecycle-events.js";
import { persistActivity, publishActivity } from "./activity-log.js";
import { secretService } from "./secrets.js";
import { assertTaskWorkspaceExecutionAccess, assertTaskWorkspaceSourceProjectAccess } from "./task-workspace-access.js";
import { resolveCoreTrustPreset } from "./trust-preset-resolver.js";
import { isCloudManagedInstance } from "./cloud-instance.js";
import { isCurrentCloudOwnerInstanceAdmin, loadCurrentCloudOwnerAuthority } from "./cloud-tenant-authority.js";
function transientGitToken(token: string | undefined): string {
if (!token || token.length > 4096 || /[\0\r\n]/.test(token) || token.startsWith("secret://")) {
throw forbidden("GitHub credential unavailable");
}
return token;
}
/** Consent is for this exact personal grant, independent of tasks and agent delegation. */
export function projectGitAuthorizationService(db: Db) {
async function project(companyId: string, projectId: string, allowArchived = false) {
const [row] = await db
.select()
.from(projects)
.where(and(eq(projects.companyId, companyId), eq(projects.id, projectId)));
if (!row || (!allowArchived && row.archivedAt)) throw notFound("Active project not found");
return row;
}
async function eligible(companyId: string, userId: string) {
const [member] = await db
.select()
.from(companyMemberships)
.where(
and(
eq(companyMemberships.companyId, companyId),
eq(companyMemberships.principalType, "user"),
eq(companyMemberships.principalId, userId),
eq(companyMemberships.status, "active"),
),
);
if (!member || member.membershipRole === "viewer") return [];
const rows = await db
.select({ grant: connectionGrants, connection: toolConnections })
.from(connectionGrants)
.innerJoin(
toolConnections,
and(eq(toolConnections.id, connectionGrants.connectionId), eq(toolConnections.companyId, companyId)),
)
.innerJoin(
toolConnectionInstalls,
and(
eq(toolConnectionInstalls.connectionId, toolConnections.id),
eq(toolConnectionInstalls.companyId, companyId),
eq(toolConnectionInstalls.targetType, "company"),
eq(toolConnectionInstalls.targetId, companyId),
),
)
.where(
and(
eq(connectionGrants.companyId, companyId),
eq(connectionGrants.kind, "user"),
eq(connectionGrants.subjectUserId, userId),
eq(connectionGrants.status, "active"),
eq(toolConnections.enabled, true),
eq(toolConnections.status, "active"),
),
);
return rows.filter(
({ grant, connection }) =>
(connection.config?.sourceTemplateKey === "github" ||
connection.transportConfig?.sourceTemplateKey === "github") &&
grant.providerTenant?.github?.repositoryCount &&
grant.providerTenant.github.installationCount > 0,
);
}
async function read(companyId: string, projectId: string, userId: string): Promise<ProjectGitAuthorization> {
const row = await project(companyId, projectId, true);
return {
grantId: row.gitAuthorizationGrantId,
authorizedByUserId: row.gitAuthorizedByUserId,
options: (await eligible(companyId, userId)).map(({ grant, connection }) => ({
grantId: grant.id,
connectionName: connection.name,
login: grant.providerTenant!.github!.login,
})),
};
}
return {
read,
async set(companyId: string, projectId: string, userId: string, grantId: string | null) {
const publication = await db.transaction(async (tx) => {
const [row] = await tx
.select()
.from(projects)
.where(and(eq(projects.companyId, companyId), eq(projects.id, projectId)))
.for("update");
if (!row || (row.archivedAt && grantId)) throw notFound("Active project not found");
if (grantId && !(await eligible(companyId, userId)).some(({ grant }) => grant.id === grantId)) {
throw forbidden("Select your own active company-installed GitHub authorization");
}
await tx
.update(projects)
.set({
gitAuthorizationGrantId: grantId,
gitAuthorizedByUserId: grantId ? userId : null,
updatedAt: new Date(),
})
.where(eq(projects.id, row.id));
await recordProjectLifecycleEvent(tx as unknown as Db, companyId, projectId);
const { publication } = await persistActivity(tx as unknown as Db, {
companyId,
actorType: "user",
actorId: userId,
action: grantId ? "project.git_authorized" : "project.git_authorization_revoked",
entityType: "project",
entityId: projectId,
details: { grantId },
});
return publication;
});
publishActivity(publication);
return read(companyId, projectId, userId);
},
async resolve(
input: { companyId: string; projectId: string; workspaceId: string },
scope?: {
projectId: string;
workspaceId?: string;
runId?: string;
agentId?: string;
operation?: "prepare" | "inspect" | "remove";
},
) {
if (
!scope ||
scope.projectId !== input.projectId ||
(scope.workspaceId && scope.workspaceId !== input.workspaceId)
)
throw forbidden("Git credentials require a matching host project operation");
const cleanup = scope.workspaceId === input.workspaceId && !scope.runId && !scope.agentId &&
(scope.operation === "inspect" || scope.operation === "remove");
const row = await project(input.companyId, input.projectId, cleanup);
const [workspace] = await db
.select()
.from(projectWorkspaces)
.where(
and(
eq(projectWorkspaces.companyId, input.companyId),
eq(projectWorkspaces.projectId, input.projectId),
eq(projectWorkspaces.id, input.workspaceId),
),
);
if (!workspace?.repoUrl) throw forbidden("Git credentials require a configured project repository");
const repo = normalizeProjectRepositoryUrl(workspace.repoUrl);
const isPrivate = await projectRepositoryIsPrivate(db, input.companyId, workspace);
if (scope.runId || scope.agentId) {
if (!scope.runId || !scope.agentId) throw forbidden("Incomplete foreground Git identity");
const [run] = await db
.select({
status: heartbeatRuns.status,
issueId: heartbeatRuns.issueId,
contextIssueId: sql<string | null>`${heartbeatRuns.contextSnapshot}->>'issueId'`,
taskId: sql<string | null>`${heartbeatRuns.contextSnapshot}->>'taskId'`,
responsibleUserId: heartbeatRuns.responsibleUserId,
nativeIssueId: heartbeatRuns.nativeIssueId,
})
.from(heartbeatRuns)
.where(
and(
eq(heartbeatRuns.id, scope.runId),
eq(heartbeatRuns.companyId, input.companyId),
eq(heartbeatRuns.agentId, scope.agentId),
),
);
const issueId = run?.issueId ?? run?.nativeIssueId ?? run?.contextIssueId ?? run?.taskId;
const [issue] =
typeof issueId === "string"
? await db
.select({ executionWorkspaceId: issues.executionWorkspaceId })
.from(issues)
.where(and(eq(issues.id, issueId), eq(issues.companyId, input.companyId)))
: [];
if (!run || run.status !== "running" || !issue || typeof issueId !== "string")
throw forbidden("Active task run is unavailable");
await assertTaskWorkspaceExecutionAccess(db, {
type: "agent", source: "agent_jwt", companyId: input.companyId,
agentId: scope.agentId, runId: scope.runId,
onBehalfOfUserId: run.responsibleUserId === "local-board" ? null : run.responsibleUserId,
}, input.companyId, {
issueId, workspaceId: issue.executionWorkspaceId, projectWorkspaceId: workspace.id,
});
if (!isPrivate) return null;
const result = await resolveGitHubOperationCredentials(db, {
companyId: input.companyId,
runId: scope.runId,
agentId: scope.agentId,
});
if (result.status !== "available") throw forbidden(result.reason ?? "Run GitHub authorization unavailable");
return transientGitToken(result.env.PAPERCLIP_GIT_TOKEN);
}
if (!isPrivate) return null;
if (!row.gitAuthorizationGrantId)
throw forbidden("Authorize this project's GitHub connection before provisioning private repositories");
const selected =
row.gitAuthorizedByUserId &&
(await eligible(input.companyId, row.gitAuthorizedByUserId)).find(
({ grant }) => grant.id === row.gitAuthorizationGrantId,
);
if (!row.gitAuthorizedByUserId || !selected)
throw forbidden("Project GitHub authorization is unavailable; reconnect or select an authorization");
const cloud = isCloudManagedInstance();
const isInstanceAdmin = cloud && await isCurrentCloudOwnerInstanceAdmin(db, row.gitAuthorizedByUserId,
await loadCurrentCloudOwnerAuthority(row.gitAuthorizedByUserId));
await assertTaskWorkspaceSourceProjectAccess(db, {
type: "board", source: cloud ? "cloud_tenant" : "session", userId: row.gitAuthorizedByUserId, isInstanceAdmin,
}, input.companyId, row.id);
if (resolveCoreTrustPreset({ companyId: input.companyId, workspaceSourceProject: row }).kind !== "standard")
throw forbidden("GitHub credentials are not available to low-trust or unverified projects");
// Project consent follows this connection's repository access, including future project sources.
if (
!selected.grant.providerTenant?.github?.repositories?.some(
(r) => r.fullName.toLowerCase() === repo.fullName.toLowerCase(),
)
) {
throw forbidden("Project GitHub authorization does not include this repository; refresh Connector access");
}
const resolved = await resolveManagedGitHubCredential(db, secretService(db), input.companyId, {
responsibleUserId: row.gitAuthorizedByUserId,
explicitGrantId: row.gitAuthorizationGrantId,
allowStandingDelegation: false,
});
if (!resolved.credential || resolved.credential.grantId !== row.gitAuthorizationGrantId)
throw forbidden("Project GitHub credential unavailable; reconnect the selected authorization");
return transientGitToken(resolved.credential.token);
},
};
}
+42 -1
View File
@@ -1,3 +1,6 @@
import { connectionGrants, toolConnections } from "@paperclipai/db";
import { and, eq, sql } from "drizzle-orm";
import { ghFetch } from "./github-fetch.js";
import type { ProjectRepository, ProjectWorkspace } from "@paperclipai/shared";
import { unprocessable } from "../errors.js";
import { isConnectionGrantAudienceAllowed } from "./tool-gateway.js";
@@ -38,7 +41,8 @@ export function resolveProjectRepositorySelection(
const current = available.find((repo) => repo.id === id);
if (current) return current;
const retained = existing.find((workspace) => workspace.metadata?.githubRepositoryId === id && workspace.repoUrl);
if (retained) return { id, fullName: retained.name, url: retained.repoUrl!, connections: [] };
if (retained) return { id, fullName: retained.name, url: retained.repoUrl!, connections: [],
...(typeof retained.metadata?.githubRepositoryPrivate === "boolean" ? { private: retained.metadata.githubRepositoryPrivate } : {}) };
throw unprocessable("A selected GitHub repository is no longer available. Refresh repositories and try again.");
});
}
@@ -58,3 +62,40 @@ export function normalizeProjectRepositoryUrl(value: string): { fullName: string
}
return { fullName: path.slice(1), url: `https://github.com${path}` };
}
/** Use the freshest active catalog visibility (the UI lock), then retained selections. Legacy URL-only
* sources need anonymous discovery; unknown visibility must never start a private clone. */
export async function projectRepositoryIsPrivate(
db: import("@paperclipai/db").Db,
companyId: string,
workspace: Pick<ProjectWorkspace, "repoUrl" | "metadata">,
): Promise<boolean> {
const repo = normalizeProjectRepositoryUrl(workspace.repoUrl!);
const visibility = await db.select({
refreshedAt: sql<string | null>`${connectionGrants.providerTenant}->'github'->>'lastAccessRefreshAt'`,
private: sql<boolean | null>`(
select bool_or((repository->>'private')::boolean)
from jsonb_array_elements(coalesce(${connectionGrants.providerTenant}->'github'->'repositories', '[]'::jsonb)) repository
where lower(repository->>'fullName') = ${repo.fullName.toLowerCase()}
)` }).from(connectionGrants).innerJoin(toolConnections, and(
eq(toolConnections.id, connectionGrants.connectionId), eq(toolConnections.companyId, companyId),
)).where(and(eq(connectionGrants.companyId, companyId), eq(connectionGrants.status, "active"),
eq(toolConnections.status, "active"), eq(toolConnections.enabled, true)));
// OAuth refreshes change updatedAt without observing repository visibility.
const current = visibility.filter(row => typeof row.private === "boolean" && Number.isFinite(Date.parse(row.refreshedAt ?? "")))
.sort((a, b) => Date.parse(b.refreshedAt!) - Date.parse(a.refreshedAt!))[0];
if (current) return current.private!;
if (workspace.metadata?.githubRepositoryUrl === repo.url && typeof workspace.metadata.githubRepositoryPrivate === "boolean") {
return workspace.metadata.githubRepositoryPrivate;
}
const legacy = visibility.filter(row => typeof row.private === "boolean");
if (legacy.length && legacy.every(row => row.private === legacy[0]!.private)) return legacy[0]!.private!;
const response = await ghFetch(`https://api.github.com/repos/${repo.fullName}`, {
headers: { Accept: "application/vnd.github+json" }, redirect: "error", signal: AbortSignal.timeout(10_000),
});
if (response.ok) {
const result = await response.json() as { full_name?: string; private?: boolean };
if (result.full_name?.toLowerCase() === repo.fullName.toLowerCase() && result.private === false) return false;
}
throw unprocessable("Repository visibility is unavailable; retry discovery or refresh the GitHub repository selection before provisioning.");
}
+5 -5
View File
@@ -920,7 +920,7 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool
const service = projectService(tx as unknown as Db, { captureWorkspaceUpdates: false });
const project = await service.create(companyId, data);
for (const repo of repositories) {
await service.createWorkspace(project.id, { name: repo.fullName, repoUrl: repo.url, metadata: { githubRepositoryId: repo.id } });
await service.createWorkspace(project.id, { name: repo.fullName, repoUrl: repo.url, metadata: { githubRepositoryId: repo.id, githubRepositoryUrl: repo.url, githubRepositoryPrivate: repo.private } });
}
return (await service.getById(project.id))!;
});
@@ -946,15 +946,15 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool
for (const repo of repositories) {
const retained = existing.find((workspace) => workspace.metadata?.githubRepositoryId === repo.id);
if (retained) {
if (retained.repoUrl !== repo.url || retained.name !== repo.fullName) {
await service.updateWorkspace(projectId, retained.id, { name: repo.fullName, repoUrl: repo.url });
if (retained.repoUrl !== repo.url || retained.name !== repo.fullName || retained.metadata?.githubRepositoryPrivate !== repo.private || retained.metadata?.githubRepositoryUrl !== repo.url) {
await service.updateWorkspace(projectId, retained.id, { name: repo.fullName, repoUrl: repo.url, metadata: { ...retained.metadata, githubRepositoryUrl: repo.url, githubRepositoryPrivate: repo.private } });
}
continue;
}
const legacy = existing.find((workspace) => !workspace.metadata?.githubRepositoryId && workspace.repoUrl?.replace(/\.git$/, "").replace(/\/$/, "").toLowerCase() === repo.url.toLowerCase());
if (legacy) {
await service.updateWorkspace(projectId, legacy.id, { metadata: { ...legacy.metadata, githubRepositoryId: repo.id } });
} else await service.createWorkspace(projectId, { name: repo.fullName, repoUrl: repo.url, metadata: { githubRepositoryId: repo.id } });
await service.updateWorkspace(projectId, legacy.id, { metadata: { ...legacy.metadata, githubRepositoryId: repo.id, githubRepositoryUrl: repo.url, githubRepositoryPrivate: repo.private } });
} else await service.createWorkspace(projectId, { name: repo.fullName, repoUrl: repo.url, metadata: { githubRepositoryId: repo.id, githubRepositoryUrl: repo.url, githubRepositoryPrivate: repo.private } });
}
await tx.update(projects).set({ updatedAt: new Date() }).where(eq(projects.id, projectId));
await recordProjectLifecycleEvent(tx as unknown as Db, project.companyId, projectId);