From baec3abd67a310a2a9ac2cda34154972f374be46 Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Mon, 26 Oct 2026 17:22:49 -0700 Subject: [PATCH] Authorize background project Git with explicit Connector consent (#17798) --- doc/execution-github-identity.md | 52 +++ maps/agent-access-map/plugins.md | 22 ++ ...nnections-v3-schema-core-migration.test.ts | 12 + .../src/migrations/0379_white_giant_girl.sql | 3 + ...{0373_snapshot.json => 0379_snapshot.json} | 48 ++- packages/db/src/migrations/meta/_journal.json | 9 +- packages/db/src/schema/projects.ts | 3 + packages/plugins/sdk/README.md | 29 ++ .../plugins/sdk/src/host-client-factory.ts | 10 + packages/plugins/sdk/src/protocol.ts | 5 + packages/plugins/sdk/src/testing.ts | 4 + packages/plugins/sdk/src/types.ts | 3 + packages/plugins/sdk/src/worker-rpc-host.ts | 3 + .../sdk/tests/host-client-factory.test.ts | 22 ++ packages/shared/src/constants.ts | 1 + packages/shared/src/index.ts | 1 + packages/shared/src/types/index.ts | 1 + packages/shared/src/types/project.ts | 6 + .../plugin-worker-invocation-scope.cjs | 4 +- .../github-operation-credentials.test.ts | 143 +------- .../helpers/github-credentials-fixture.ts | 151 +++++++++ .../__tests__/plugin-worker-manager.test.ts | 27 ++ .../project-git-authorization.test.ts | 320 ++++++++++++++++++ .../project-repositories-persistence.test.ts | 8 + server/src/routes/openapi.ts | 21 ++ server/src/routes/projects.ts | 17 + server/src/services/git-credentials.ts | 4 + .../services/plugin-capability-validator.ts | 1 + server/src/services/plugin-host-services.ts | 6 + server/src/services/plugin-worker-manager.ts | 21 +- .../src/services/project-git-authorization.ts | 251 ++++++++++++++ server/src/services/project-repositories.ts | 43 ++- server/src/services/projects.ts | 10 +- ui/src/api/projects.ts | 3 + .../ProjectGitAuthorization.test.tsx | 79 +++++ ui/src/components/ProjectGitAuthorization.tsx | 76 +++++ ui/src/components/ProjectProperties.tsx | 2 + 37 files changed, 1265 insertions(+), 156 deletions(-) create mode 100644 packages/db/src/migrations/0379_white_giant_girl.sql rename packages/db/src/migrations/meta/{0373_snapshot.json => 0379_snapshot.json} (99%) create mode 100644 server/src/__tests__/helpers/github-credentials-fixture.ts create mode 100644 server/src/__tests__/project-git-authorization.test.ts create mode 100644 server/src/services/project-git-authorization.ts create mode 100644 ui/src/components/ProjectGitAuthorization.test.tsx create mode 100644 ui/src/components/ProjectGitAuthorization.tsx diff --git a/doc/execution-github-identity.md b/doc/execution-github-identity.md index 3d179337f17..f396c12c462 100644 --- a/doc/execution-github-identity.md +++ b/doc/execution-github-identity.md @@ -160,3 +160,55 @@ these company-scoped records when their company is deleted. Run-scoped adapters Native warm sessions retain their token-free launchers and inactive broker until session retirement; environment deletion and orderly controller shutdown close idle owners first. Cleanup failures are logged and do not change the run result. + +## Project authorization for background repository work + +Background provisioning and workspace maintenance have no responsible task user. +They must not retain a task's identity. A signed-in project workspace writer can +select their own active, company-installed GitHub Connector grant in project +settings and explicitly authorize the project. The binding stores the exact grant +ID and consenting user, never a credential. Revocation records a project lifecycle +event so subscribed providers can reconcile the change. + +`GET /api/projects/:id/git-authorization` returns the current binding and only the +viewer's eligible personal grants. `PUT` accepts `{ grantId }` or `{ grantId: null }`. +Agents cannot create this standing consent. Project workspace writers can remove it. +Core rechecks membership, Connector installation/enablement, grant status, and +repository metadata on each background use. Public repositories never require +a connection or credential. Private repositories wait for authorization before +provisioning starts, so missing consent cannot leave a failed anonymous clone. +Repository selections retain the same visibility metadata used by the UI lock. +Legacy workspaces use existing Connector catalog metadata, with anonymous +GitHub discovery only when visibility is unknown. Unknown visibility defers +provisioning; it is never assumed public. It never substitutes another grant +when the selected one fails. Replacing a grant requires explicit reauthorization. +Project consent follows the selected connection's current GitHub repository access, +including repositories added to the project later. It does not create a second +repository approval list. Project repository editors may add sources within that +connection's access; manage permitted repositories in the GitHub connection. +Background export also rechecks the consenting user's project read access and the +project's existing trust policy. Low-trust or unverified projects cannot export a +raw credential through standing consent. +Cloud owner access is rechecked through current Cloud authority and the managed +owner-elevation setting on every private background export; saved local admin +roles cannot replace that authority. An unavailable authority check cannot confer +owner access, though explicit project membership still applies. + +The trusted plugin capability `projects.git-credentials` enables +`ctx.projects.resolveGitCredential(projectId, workspaceId, companyId)`. The host +binds requests to the active project lifecycle or workspace operation; data, UI, +tool, and proactive calls do not carry this authority. Repository URLs come from +stored project workspaces. Foreground preparation uses the live run's accepted +identity and standard trust checks, even when background project consent exists. +Maintenance ignores originating run/agent context and uses project consent only. +After archival, existing consent remains usable only for host-issued workspace +inspection and removal. New provisioning and new consent remain blocked. + +The provider receives a transient GitHub token with its existing provider +permissions. This is not a repository-scoped or read-only token minting API. +Only grant-listed repositories can trigger background export. Plugins must never +save tokens in entities, state, configuration, operation receipts, or logs, and +must send them only to trusted execution infrastructure over HTTPS. Revocation +prevents subsequent credential acquisition; it cannot recall a token already +sent to an in-flight operation. Existing tasks and Git credential fallback rules +outside this plugin API are unchanged. diff --git a/maps/agent-access-map/plugins.md b/maps/agent-access-map/plugins.md index 6ce5beaa9bf..8cecb38e0ee 100644 --- a/maps/agent-access-map/plugins.md +++ b/maps/agent-access-map/plugins.md @@ -123,3 +123,25 @@ own policy and access review. Gaps: Disable/remove and development reload are management workflows with no default company agent access. + +## Project Git authorization + +Project workspace writers can use `PUT /api/projects/{id}/git-authorization` to +consent to their own eligible personal GitHub Connector grant. This route is +board-only; agent requests cannot establish standing consent. `GET` requires +project read access and returns no token. SDK `projects.resolveGitCredential` +requires `projects.git-credentials` and a matching host-issued project operation. +Background work uses the selected project grant. Foreground preparation uses the +active run identity and trust boundary. UI/data/tool invocations have no export +authority. No task identity is retained for maintenance. +Background export rechecks the consenting user's project read access and project +trust policy. Repository access follows the selected connection, including later +project additions; no separate project repository approval list is stored. +Archived projects allow only host-issued workspace inspection and removal using +existing consent; they cannot acquire credentials for provisioning. + +Evidence: owned embedded-database grant/route fixtures in +`server/src/__tests__/project-git-authorization.test.ts`, worker invocation +fixtures in `server/src/__tests__/plugin-worker-manager.test.ts`, and capability +and scope denials in `packages/plugins/sdk/tests/host-client-factory.test.ts`. +These are automated fixture checks, not live provider qualification. diff --git a/packages/db/src/connections-v3-schema-core-migration.test.ts b/packages/db/src/connections-v3-schema-core-migration.test.ts index d2a83da0b44..557ee301acb 100644 --- a/packages/db/src/connections-v3-schema-core-migration.test.ts +++ b/packages/db/src/connections-v3-schema-core-migration.test.ts @@ -34,6 +34,14 @@ describeEmbeddedPostgres("connections v3 schema core migration", () => { `; expect(chatForeignKey?.definition).toBeTruthy(); await sql`ALTER TABLE "chat_endpoints" DROP CONSTRAINT "chat_endpoints_company_connection_fk"`; + // Project consent is another later grant consumer; rewind only its known FK. + const [projectForeignKey] = await sql<{ definition: string }[]>` + SELECT pg_get_constraintdef(oid) AS definition FROM pg_constraint + WHERE conrelid = 'projects'::regclass + AND conname = 'projects_git_authorization_grant_id_connection_grants_id_fk' + `; + expect(projectForeignKey?.definition).toBeTruthy(); + await sql`ALTER TABLE "projects" DROP CONSTRAINT "projects_git_authorization_grant_id_connection_grants_id_fk"`; await sql`DELETE FROM "drizzle"."__drizzle_migrations" WHERE "hash" = ${await migrationHash()}`; // AI defaults arrive in 0273/0277 and depend on the composite grant key @@ -83,6 +91,9 @@ describeEmbeddedPostgres("connections v3 schema core migration", () => { await sql.unsafe( `ALTER TABLE "chat_endpoints" ADD CONSTRAINT "chat_endpoints_company_connection_fk" ${chatForeignKey!.definition}`, ); + await sql.unsafe( + `ALTER TABLE "projects" ADD CONSTRAINT "projects_git_authorization_grant_id_connection_grants_id_fk" ${projectForeignKey!.definition}`, + ); const [connection] = await sql<{ uid: string; ownership: string; transport: string; auth_kind: string }[]>` SELECT "uid", "ownership", "transport", "auth_kind" FROM "tool_connections" WHERE "id" = ${connectionId} @@ -108,6 +119,7 @@ describeEmbeddedPostgres("connections v3 schema core migration", () => { `).rejects.toMatchObject({ code: "23514" }); await sql`ALTER TABLE "chat_endpoints" DROP CONSTRAINT "chat_endpoints_company_connection_fk"`; + await sql`ALTER TABLE "projects" DROP CONSTRAINT "projects_git_authorization_grant_id_connection_grants_id_fk"`; await sql`DROP TABLE IF EXISTS "connection_grant_members"`; await sql`DROP TABLE "connection_grants"`; await sql`DROP INDEX "tool_connections_company_uid_uq"`; diff --git a/packages/db/src/migrations/0379_white_giant_girl.sql b/packages/db/src/migrations/0379_white_giant_girl.sql new file mode 100644 index 00000000000..1c3124f7fbd --- /dev/null +++ b/packages/db/src/migrations/0379_white_giant_girl.sql @@ -0,0 +1,3 @@ +ALTER TABLE "projects" ADD COLUMN "git_authorization_grant_id" uuid;--> statement-breakpoint +ALTER TABLE "projects" ADD COLUMN "git_authorized_by_user_id" text;--> statement-breakpoint +ALTER TABLE "projects" ADD CONSTRAINT "projects_git_authorization_grant_id_connection_grants_id_fk" FOREIGN KEY ("git_authorization_grant_id") REFERENCES "public"."connection_grants"("id") ON DELETE set null ON UPDATE no action; \ No newline at end of file diff --git a/packages/db/src/migrations/meta/0373_snapshot.json b/packages/db/src/migrations/meta/0379_snapshot.json similarity index 99% rename from packages/db/src/migrations/meta/0373_snapshot.json rename to packages/db/src/migrations/meta/0379_snapshot.json index 3019f5fde87..98067609cb0 100644 --- a/packages/db/src/migrations/meta/0373_snapshot.json +++ b/packages/db/src/migrations/meta/0379_snapshot.json @@ -1,6 +1,6 @@ { - "id": "406521f8-5352-415b-805d-786854381e03", - "prevId": "9c1b4cac-52f1-4c6e-89b3-c9a7816016c7", + "id": "2a9439e3-4a2b-4a20-ab2b-2aee3db8cb03", + "prevId": "a0fcd9b8-f125-4985-8795-f567466b9aef", "version": "7", "dialect": "postgresql", "tables": { @@ -28508,6 +28508,13 @@ "primaryKey": false, "notNull": true }, + "checkout_name": { + "name": "checkout_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, "object_key": { "name": "object_key", "type": "text", @@ -28601,8 +28608,8 @@ "method": "btree", "with": {} }, - "execution_workspace_restore_points_live_generation_uniq": { - "name": "execution_workspace_restore_points_live_generation_uniq", + "execution_workspace_restore_points_live_status_uniq": { + "name": "execution_workspace_restore_points_live_status_uniq", "columns": [ { "expression": "execution_workspace_id", @@ -28615,10 +28622,16 @@ "isExpression": false, "asc": true, "nulls": "last" + }, + { + "expression": "checkout_name", + "isExpression": false, + "asc": true, + "nulls": "last" } ], "isUnique": true, - "where": "\"execution_workspace_restore_points\".\"status\" <> 'deleted'", + "where": "\"execution_workspace_restore_points\".\"status\" in ('pending', 'active', 'expiring')", "concurrently": false, "method": "btree", "with": {} @@ -47769,6 +47782,18 @@ "primaryKey": false, "notNull": false }, + "git_authorization_grant_id": { + "name": "git_authorization_grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "git_authorized_by_user_id": { + "name": "git_authorized_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, "pause_reason": { "name": "pause_reason", "type": "text", @@ -47907,6 +47932,19 @@ ], "onDelete": "no action", "onUpdate": "no action" + }, + "projects_git_authorization_grant_id_connection_grants_id_fk": { + "name": "projects_git_authorization_grant_id_connection_grants_id_fk", + "tableFrom": "projects", + "tableTo": "connection_grants", + "columnsFrom": [ + "git_authorization_grant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index e4fe28f439e..97d684cc9ce 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -2640,6 +2640,13 @@ "when": 1793041667443, "tag": "0378_environment_skill_runtime_hooks", "breakpoints": true + }, + { + "idx": 379, + "version": "7", + "when": 1793049353756, + "tag": "0379_white_giant_girl", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/packages/db/src/schema/projects.ts b/packages/db/src/schema/projects.ts index 74cfdb6c3ff..10e320891bb 100644 --- a/packages/db/src/schema/projects.ts +++ b/packages/db/src/schema/projects.ts @@ -4,6 +4,7 @@ import type { AgentEnvConfig } from "@paperclipai/shared"; import { companies } from "./companies.js"; import { goals } from "./goals.js"; import { agents } from "./agents.js"; +import { connectionGrants } from "./tool_access.js"; export const projects = pgTable( "projects", @@ -22,6 +23,8 @@ export const projects = pgTable( color: text("color"), icon: text("icon"), env: jsonb("env").$type(), + gitAuthorizationGrantId: uuid("git_authorization_grant_id").references(() => connectionGrants.id, { onDelete: "set null" }), + gitAuthorizedByUserId: text("git_authorized_by_user_id"), pauseReason: text("pause_reason"), pausedAt: timestamp("paused_at", { withTimezone: true }), executionWorkspacePolicy: jsonb("execution_workspace_policy").$type>(), diff --git a/packages/plugins/sdk/README.md b/packages/plugins/sdk/README.md index b571dda2f52..0d1e269cb82 100644 --- a/packages/plugins/sdk/README.md +++ b/packages/plugins/sdk/README.md @@ -1444,3 +1444,32 @@ lifecycle inbox listings or the company secret catalog. Do not expose values through plugin entities, state, data handlers, UI, or logs. Agent instance secrets retain their existing lifecycle contract. Back up the tenant's existing local secrets master key together with its database. + +### Project Git credentials + +Trusted workspace providers can declare `projects.git-credentials` and call +`ctx.projects.resolveGitCredential(projectId, workspaceId, companyId)` from the +matching host-issued project lifecycle or workspace operation. Other invocations +cannot obtain credentials through this method. The workspace must be a current +repository in that project and company; plugins cannot supply arbitrary URLs. + +Background work uses a project writer's explicit consent to their own personal, +company-installed GitHub Connector grant. It does not retain task context. Core +checks the exact grant and its owner's active company membership on every use. +Foreground preparation uses the active run's identity and trust policy instead. +Public repositories return null and require no connection. Private repositories +without authorization wait before provisioning. Repository visibility comes from +the Connector catalog and retained project selections; legacy URL-only sources +use anonymous discovery when visibility is unknown. A revoked or invalid private +repository authorization fails closed. There is no fallback to another grant. +Project consent follows the connection's current repository access, including +sources added later, rather than a separate project repository approval list. +The consenting user must retain project read access, and background credential +export requires the project's existing trust policy to allow standard execution. +Archived projects keep credential access only for host-issued workspace inspection +and removal with existing consent; provisioning remains blocked. + +The return value is transient raw credential material with the Connector's +existing permissions. This capability is for trusted execution infrastructure, +not agent-facing tools or UI. Never expose or persist it. See +[GitHub identity](../../../doc/execution-github-identity.md#project-authorization-for-background-repository-work). diff --git a/packages/plugins/sdk/src/host-client-factory.ts b/packages/plugins/sdk/src/host-client-factory.ts index 34156211080..9e6a6cf8ce8 100644 --- a/packages/plugins/sdk/src/host-client-factory.ts +++ b/packages/plugins/sdk/src/host-client-factory.ts @@ -202,6 +202,7 @@ export interface HostServices { projects: { list(params: WorkerToHostMethods["projects.list"][0]): Promise; get(params: WorkerToHostMethods["projects.get"][0]): Promise; + resolveGitCredential(params: WorkerToHostMethods["projects.resolveGitCredential"][0], context: WorkerHostCallContext): Promise; getInstanceSecrets(params: WorkerToHostMethods["projects.getInstanceSecrets"][0]): Promise; listWorkspaces(params: WorkerToHostMethods["projects.listWorkspaces"][0]): Promise; getPrimaryWorkspace(params: WorkerToHostMethods["projects.getPrimaryWorkspace"][0]): Promise; @@ -443,6 +444,7 @@ const METHOD_CAPABILITY_MAP: Record { return services.projects.list(params); }), + "projects.resolveGitCredential": gated("projects.resolveGitCredential", async (params, context) => { + const companyId = resolveRequiredCompanyId("projects.resolveGitCredential", params, context); + const scope = context?.invocationScope?.projectGit; + if (!scope || scope.projectId !== params.projectId || (scope.workspaceId && scope.workspaceId !== params.workspaceId)) { + throw new Error("Git credentials require a matching host project operation"); + } + return services.projects.resolveGitCredential({ ...params, companyId }, context!); + }), "projects.getInstanceSecrets": gated("projects.getInstanceSecrets", async (params, context) => { const companyId = resolveRequiredCompanyId("projects.getInstanceSecrets", params, context); return services.projects.getInstanceSecrets({ ...params, companyId }); diff --git a/packages/plugins/sdk/src/protocol.ts b/packages/plugins/sdk/src/protocol.ts index e60187e1945..e76615d1687 100644 --- a/packages/plugins/sdk/src/protocol.ts +++ b/packages/plugins/sdk/src/protocol.ts @@ -289,6 +289,7 @@ export type PluginRpcErrorCode = */ export interface PluginInvocationScope { companyId: string; + projectGit?: { projectId: string; workspaceId?: string; runId?: string; agentId?: string; operation?: "prepare" | "inspect" | "remove" }; } /** @@ -1796,6 +1797,10 @@ export interface WorkerToHostMethods { params: { companyId: string; limit?: number; offset?: number }, result: Project[], ]; + "projects.resolveGitCredential": [ + params: { projectId: string; workspaceId: string; companyId: string }, + result: string | null, + ]; "projects.getInstanceSecrets": [ params: { projectId: string; companyId: string }, result: Record, diff --git a/packages/plugins/sdk/src/testing.ts b/packages/plugins/sdk/src/testing.ts index 7617fa05e23..eb496adf22c 100644 --- a/packages/plugins/sdk/src/testing.ts +++ b/packages/plugins/sdk/src/testing.ts @@ -1032,6 +1032,10 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { }, }, projects: { + async resolveGitCredential() { + requireCapability(manifest, capabilitySet, "projects.git-credentials"); + throw new Error("Git credentials require a matching host project operation"); + }, async getInstanceSecrets(projectId, companyId) { requireCapability(manifest, capabilitySet, "projects.instance-secrets"); requireCapability(manifest, capabilitySet, "events.subscribe"); diff --git a/packages/plugins/sdk/src/types.ts b/packages/plugins/sdk/src/types.ts index 2e5431846c0..9f0ea69b7fe 100644 --- a/packages/plugins/sdk/src/types.ts +++ b/packages/plugins/sdk/src/types.ts @@ -875,6 +875,9 @@ export interface PluginEntitiesClient { * @see PLUGIN_SPEC.md §7 — Project Workspaces */ export interface PluginProjectsClient { + /** Transient GitHub credential for a stored repository in the current host-issued + * lifecycle/workspace invocation. Requires projects.git-credentials. Never persist or log. */ + resolveGitCredential(projectId: string, workspaceId: string, companyId: string): Promise; /** Trusted worker access to this plugin’s native project credentials. * Requires projects.instance-secrets; never expose via UI/data handlers. */ getInstanceSecrets(projectId: string, companyId: string): Promise>; diff --git a/packages/plugins/sdk/src/worker-rpc-host.ts b/packages/plugins/sdk/src/worker-rpc-host.ts index 21a30742ca6..daad66769c0 100644 --- a/packages/plugins/sdk/src/worker-rpc-host.ts +++ b/packages/plugins/sdk/src/worker-rpc-host.ts @@ -720,6 +720,9 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost }, projects: { + async resolveGitCredential(projectId, workspaceId, companyId) { + return callHost("projects.resolveGitCredential", { projectId, workspaceId, companyId }); + }, async list(input) { return callHost("projects.list", { companyId: input.companyId, diff --git a/packages/plugins/sdk/tests/host-client-factory.test.ts b/packages/plugins/sdk/tests/host-client-factory.test.ts index f892a120c9c..b30ffebefc7 100644 --- a/packages/plugins/sdk/tests/host-client-factory.test.ts +++ b/packages/plugins/sdk/tests/host-client-factory.test.ts @@ -452,3 +452,25 @@ describe("project instance secrets", () => { expect(getInstanceSecrets).toHaveBeenCalledWith(params); }); }); + +describe("project Git credentials", () => { + it("requires capability, company, project and repository scope before calling credential services", async () => { + const resolveGitCredential = vi.fn(async () => "transient-token"); + const services = { projects: { resolveGitCredential } } as unknown as HostServices; + const params = { companyId: "company-a", projectId: "project-a", workspaceId: "workspace-a" }; + const context = { invocationScope: { companyId: "company-a", projectGit: { projectId: "project-a", workspaceId: "workspace-a", runId: "run-a", agentId: "agent-a" } } }; + const denied = createHostClientHandlers({ pluginId: "fixture", capabilities: [], services }); + await expect(denied["projects.resolveGitCredential"](params, context)).rejects.toBeInstanceOf(CapabilityDeniedError); + const allowed = createHostClientHandlers({ pluginId: "fixture", capabilities: ["projects.git-credentials"], services }); + for (const invalid of [undefined, { invocationScope: { companyId: "other" } }, + { invocationScope: { companyId: "company-a" } }, + { invocationScope: { companyId: "company-a", projectGit: { projectId: "other" } } }, + { invocationScope: { companyId: "company-a", projectGit: { projectId: "project-a", workspaceId: "other" } } }, + { ...context, invalidInvocationScope: true }]) { + await expect(allowed["projects.resolveGitCredential"](params, invalid)).rejects.toThrow(); + } + expect(resolveGitCredential).not.toHaveBeenCalled(); + await expect(allowed["projects.resolveGitCredential"](params, context)).resolves.toBe("transient-token"); + expect(resolveGitCredential).toHaveBeenCalledWith(params, context); + }); +}); diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 0a3d0d21981..574d033e665 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -1325,6 +1325,7 @@ export const PLUGIN_CAPABILITIES = [ "companies.read", "projects.read", "projects.instance-secrets", + "projects.git-credentials", "project.workspaces.read", "execution.workspaces.read", "workspaces.manage", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 8ed0a8c160c..6dfa3ec841d 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -928,6 +928,7 @@ export type { ProjectBudgetSummary, ProjectRepository, ProjectRepositoryOptions, + ProjectGitAuthorization, ProjectCodebase, ProjectCodebaseOrigin, ProjectGoalRef, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 48f9fe6b6f4..13ee15a438d 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -357,6 +357,7 @@ export type { Project, ProjectRepository, ProjectRepositoryOptions, + ProjectGitAuthorization, ProjectAccessMember, ProjectAccessSubjectType, ProjectDiscoverySummary, diff --git a/packages/shared/src/types/project.ts b/packages/shared/src/types/project.ts index 7cc1fb202d1..346aede02c4 100644 --- a/packages/shared/src/types/project.ts +++ b/packages/shared/src/types/project.ts @@ -6,6 +6,12 @@ import type { } from "./workspace-runtime.js"; import type { AgentEnvConfig } from "./secrets.js"; +export interface ProjectGitAuthorization { + grantId: string | null; + authorizedByUserId: string | null; + options: Array<{ grantId: string; connectionName: string; login: string }>; +} + export type ProjectWorkspaceSourceType = "local_path" | "git_repo" | "remote_managed" | "non_git_path"; export type ProjectWorkspaceVisibility = "default" | "advanced"; export type ProjectVisibility = "open" | "private"; diff --git a/server/src/__tests__/fixtures/plugin-worker-invocation-scope.cjs b/server/src/__tests__/fixtures/plugin-worker-invocation-scope.cjs index 8300a60689f..e927b4681ef 100644 --- a/server/src/__tests__/fixtures/plugin-worker-invocation-scope.cjs +++ b/server/src/__tests__/fixtures/plugin-worker-invocation-scope.cjs @@ -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; } diff --git a/server/src/__tests__/github-operation-credentials.test.ts b/server/src/__tests__/github-operation-credentials.test.ts index 699249b5b58..b916c9b93e5 100644 --- a/server/src/__tests__/github-operation-credentials.test.ts +++ b/server/src/__tests__/github-operation-credentials.test.ts @@ -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>, - 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>, - 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"); diff --git a/server/src/__tests__/helpers/github-credentials-fixture.ts b/server/src/__tests__/helpers/github-credentials-fixture.ts new file mode 100644 index 00000000000..cd18ca64c72 --- /dev/null +++ b/server/src/__tests__/helpers/github-credentials-fixture.ts @@ -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>, + 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>, + 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 }; +} diff --git a/server/src/__tests__/plugin-worker-manager.test.ts b/server/src/__tests__/plugin-worker-manager.test.ts index c97c14a8355..7e6b32d696b 100644 --- a/server/src/__tests__/plugin-worker-manager.test.ts +++ b/server/src/__tests__/plugin-worker-manager.test.ts @@ -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 }, diff --git a/server/src/__tests__/project-git-authorization.test.ts b/server/src/__tests__/project-git-authorization.test.ts new file mode 100644 index 00000000000..940b8a5bc78 --- /dev/null +++ b/server/src/__tests__/project-git-authorization.test.ts @@ -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(), + 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, database: Awaited>; + 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); + }); + +}); diff --git a/server/src/__tests__/project-repositories-persistence.test.ts b/server/src/__tests__/project-repositories-persistence.test.ts index d2e6b3dc4fe..b247b236b30 100644 --- a/server/src/__tests__/project-repositories-persistence.test.ts +++ b/server/src/__tests__/project-repositories-persistence.test.ts @@ -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")]); diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index c29781e352a..c4001282b5c 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -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", diff --git a/server/src/routes/projects.ts b/server/src/routes/projects.ts index 90c8bb6e0af..c6479982335 100644 --- a/server/src/routes/projects.ts +++ b/server/src/routes/projects.ts @@ -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"); diff --git a/server/src/services/git-credentials.ts b/server/src/services/git-credentials.ts index 520c03c7628..7efb61cb903 100644 --- a/server/src/services/git-credentials.ts +++ b/server/src/services/git-credentials.ts @@ -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, { diff --git a/server/src/services/plugin-capability-validator.ts b/server/src/services/plugin-capability-validator.ts index d2053fbd32b..fa2b8915931 100644 --- a/server/src/services/plugin-capability-validator.ts +++ b/server/src/services/plugin-capability-validator.ts @@ -47,6 +47,7 @@ const OPERATION_CAPABILITIES: Record = { "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"], diff --git a/server/src/services/plugin-host-services.ts b/server/src/services/plugin-host-services.ts index a670eaa6d22..7a9b1cbe825 100644 --- a/server/src/services/plugin-host-services.ts +++ b/server/src/services/plugin-host-services.ts @@ -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); diff --git a/server/src/services/plugin-worker-manager.ts b/server/src/services/plugin-worker-manager.ts index dd7173d9dd1..d932f61fd9a 100644 --- a/server/src/services/plugin-worker-manager.ts +++ b/server/src/services/plugin-worker-manager.ts @@ -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); diff --git a/server/src/services/project-git-authorization.ts b/server/src/services/project-git-authorization.ts new file mode 100644 index 00000000000..3124b1f23e0 --- /dev/null +++ b/server/src/services/project-git-authorization.ts @@ -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 { + 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`${heartbeatRuns.contextSnapshot}->>'issueId'`, + taskId: sql`${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); + }, + }; +} diff --git a/server/src/services/project-repositories.ts b/server/src/services/project-repositories.ts index 40bbef85186..d0aa46999e2 100644 --- a/server/src/services/project-repositories.ts +++ b/server/src/services/project-repositories.ts @@ -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, +): Promise { + const repo = normalizeProjectRepositoryUrl(workspace.repoUrl!); + const visibility = await db.select({ + refreshedAt: sql`${connectionGrants.providerTenant}->'github'->>'lastAccessRefreshAt'`, + private: sql`( + 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."); +} diff --git a/server/src/services/projects.ts b/server/src/services/projects.ts index 7f6795d2132..d70b26626d0 100644 --- a/server/src/services/projects.ts +++ b/server/src/services/projects.ts @@ -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); diff --git a/ui/src/api/projects.ts b/ui/src/api/projects.ts index 35b5c33567f..0ff6ecab452 100644 --- a/ui/src/api/projects.ts +++ b/ui/src/api/projects.ts @@ -2,6 +2,7 @@ import type { Project, ProjectAccessMember, ProjectRepositoryOptions, + ProjectGitAuthorization, ProjectWorkspace, WorkspaceOperation, WorkspaceRuntimeControlTarget, @@ -20,6 +21,8 @@ function projectPath(id: string, companyId?: string, suffix = "") { } export const projectsApi = { + gitAuthorization: (id: string) => api.get(projectPath(id, undefined, "/git-authorization")), + setGitAuthorization: (id: string, grantId: string | null) => api.put(projectPath(id, undefined, "/git-authorization"), { grantId }), repositoryOptions: (companyId: string) => api.get(`/companies/${companyId}/project-repositories`), setRepositories: (id: string, repositoryIds: string[]) => api.put(projectPath(id, undefined, "/repositories"), { repositoryIds }), list: (companyId: string, opts: { includeArchived?: boolean } = {}) => { diff --git a/ui/src/components/ProjectGitAuthorization.test.tsx b/ui/src/components/ProjectGitAuthorization.test.tsx new file mode 100644 index 00000000000..c736508f79d --- /dev/null +++ b/ui/src/components/ProjectGitAuthorization.test.tsx @@ -0,0 +1,79 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { Project } from "@paperclipai/shared"; +import { describe, expect, it, vi } from "vitest"; +import { projectsApi } from "@/api/projects"; +import { ProjectGitAuthorization } from "./ProjectGitAuthorization"; + +vi.mock("@/api/projects", () => ({ + projectsApi: { + gitAuthorization: vi.fn(), + setGitAuthorization: vi.fn(), + }, +})); +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +describe("ProjectGitAuthorization", () => { + it("explains standing credential consent and revokes without retaining task context", async () => { + const saved = { + grantId: "grant-a", + authorizedByUserId: "user-a", + options: [ + { + grantId: "grant-a", + connectionName: "Work GitHub", + login: "fixture-login", + }, + ], + }; + vi.mocked(projectsApi.gitAuthorization).mockResolvedValue(saved); + vi.mocked(projectsApi.setGitAuthorization).mockResolvedValue({ + ...saved, + grantId: null, + authorizedByUserId: null, + }); + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + client.setQueryData(["project-git-authorization", "company-a", "project-a"], saved); + const container = document.createElement("div"), + root = createRoot(container); + document.body.appendChild(container); + try { + await act(async () => + root.render( + + + , + ), + ); + expect(container.textContent).toContain("existing GitHub permissions"); + expect(container.textContent).toContain("Project repository editors can add repositories covered by this connection"); + expect(container.textContent).toContain("Manage repository access in the GitHub connection"); + expect(container.textContent).toContain("Task runs continue to use their own identity"); + expect(container.textContent).toContain("fixture-login"); + const authorize = [...container.querySelectorAll("button")].find( + (button) => button.textContent === "Authorize project", + )!; + expect(authorize.disabled).toBe(true); + const revoke = [...container.querySelectorAll("button")].find( + (button) => button.textContent === "Revoke authorization", + )!; + await act(async () => { + revoke.click(); + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + expect(projectsApi.setGitAuthorization).toHaveBeenCalledWith("project-a", null); + expect(client.getQueryData(["project-git-authorization", "company-a", "project-a"])).toMatchObject({ + grantId: null, + }); + expect(container.textContent).not.toContain("Revoke authorization"); + } finally { + await act(async () => root.unmount()); + container.remove(); + client.clear(); + } + }); +}); diff --git a/ui/src/components/ProjectGitAuthorization.tsx b/ui/src/components/ProjectGitAuthorization.tsx new file mode 100644 index 00000000000..966d3a746fc --- /dev/null +++ b/ui/src/components/ProjectGitAuthorization.tsx @@ -0,0 +1,76 @@ +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { Project } from "@paperclipai/shared"; +import { projectsApi } from "@/api/projects"; +import { Button } from "./ui/button"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; + +export function ProjectGitAuthorization({ project }: { project: Project }) { + const client = useQueryClient(); + const key = ["project-git-authorization", project.companyId, project.id]; + const authorization = useQuery({ + queryKey: key, + queryFn: () => projectsApi.gitAuthorization(project.id), + }); + const [selected, setSelected] = useState(""); + const save = useMutation({ + mutationFn: (grantId: string | null) => projectsApi.setGitAuthorization(project.id, grantId), + onSuccess: (data) => { + client.setQueryData(key, data); + setSelected(""); + }, + }); + const data = authorization.data; + return ( +
+

Background GitHub authorization

+

+ Allow this project’s workspace provider to use your GitHub Connector for repository provisioning and maintenance + until revoked. This shares the Connector credential with the provider using its existing GitHub permissions. + Project repository editors can add repositories covered by this connection. Manage repository access in the GitHub connection. + Task runs continue to use their own identity. +

+ {data?.grantId && ( +

+ Authorized:{" "} + {data.options.find((option) => option.grantId === data.grantId)?.login ?? + "selected grant (unavailable or owned by another member)"} +

+ )} + {data && ( +
+ + + {data.grantId && ( + + )} +
+ )} + {data && !data.options.length && ( +

+ Connect your GitHub account and install the Connector for this company to authorize it. +

+ )} + {(authorization.error || save.error) && ( +

+ {(authorization.error || save.error)?.message} +

+ )} +
+ ); +} diff --git a/ui/src/components/ProjectProperties.tsx b/ui/src/components/ProjectProperties.tsx index a1c631c1546..cdaffaa5d5e 100644 --- a/ui/src/components/ProjectProperties.tsx +++ b/ui/src/components/ProjectProperties.tsx @@ -7,6 +7,7 @@ import { environmentDisplayLabel, filterManagedSandboxSelectableEnvironments } f import { Link } from "@/lib/router"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { Project, SharedWorkspaceConcurrency } from "@paperclipai/shared"; +import { ProjectGitAuthorization } from "./ProjectGitAuthorization"; import { ProjectRepositories } from "./ProjectRepositories"; import { cn, formatDate } from "../lib/utils"; import { environmentsApi } from "../api/environments"; @@ -510,6 +511,7 @@ export function ProjectProperties({ project, repositories, onUpdate, onFieldUpda {repositories ?? } + } alignStart