From cf8578a9dd1419f123e50a250bee81ff887012bb Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 6 Oct 2026 22:15:57 -0500 Subject: [PATCH] feat(connections): simplify dedicated GitHub App setup Co-Authored-By: Paperclip --- doc/connections/GITHUB-REVIEW-BOT.md | 49 +- doc/plans/2026-10-06-github-app-wizard.md | 143 ++ .../db/src/migrations/0312_curvy_mordo.sql | 4 + ...{0307_snapshot.json => 0312_snapshot.json} | 718 +++++++++- packages/db/src/migrations/meta/_journal.json | 9 +- packages/db/src/schema/chat_github.ts | 21 + packages/shared/src/index.ts | 1 + packages/shared/src/types/chat-channels.ts | 7 +- packages/shared/src/types/github-app-setup.ts | 52 + .../chat-channels.integration.test.ts | 503 +++++++ server/src/app.ts | 16 +- server/src/routes/chat-channels.ts | 28 +- server/src/routes/openapi.ts | 6 +- server/src/services/chat-channels.ts | 55 +- server/src/services/chat-github-client.ts | 3 + .../src/services/chat-github-cloud-ingress.ts | 81 ++ .../src/services/chat-github-cloud-payload.ts | 100 ++ server/src/services/chat-github-management.ts | 17 +- .../src/services/chat-github-registration.ts | 141 +- server/src/services/chat-github-wizard.ts | 1146 +++++++++++++++ .../src/services/github-connection-events.ts | 16 +- .../src/services/paperclip-cloud-connector.ts | 23 +- ui/src/api/githubChat.ts | 13 +- ui/src/components/GitHubAgentTrustWarning.tsx | 10 + .../pages/apps/chat/GitHubChatSetup.test.tsx | 421 ++++++ ui/src/pages/apps/chat/GitHubChatSetup.tsx | 1260 +++++++---------- ui/storybook/prototypes/github-chat/README.md | 4 + ui/storybook/stories/github-chat.stories.tsx | 2 +- 28 files changed, 4025 insertions(+), 824 deletions(-) create mode 100644 doc/plans/2026-10-06-github-app-wizard.md create mode 100644 packages/db/src/migrations/0312_curvy_mordo.sql rename packages/db/src/migrations/meta/{0307_snapshot.json => 0312_snapshot.json} (98%) create mode 100644 packages/shared/src/types/github-app-setup.ts create mode 100644 server/src/services/chat-github-cloud-ingress.ts create mode 100644 server/src/services/chat-github-cloud-payload.ts create mode 100644 server/src/services/chat-github-wizard.ts create mode 100644 ui/src/pages/apps/chat/GitHubChatSetup.test.tsx diff --git a/doc/connections/GITHUB-REVIEW-BOT.md b/doc/connections/GITHUB-REVIEW-BOT.md index 00757ffe0d6..617d2940955 100644 --- a/doc/connections/GITHUB-REVIEW-BOT.md +++ b/doc/connections/GITHUB-REVIEW-BOT.md @@ -11,26 +11,37 @@ required GitHub checks, read ## Set up a bot -1. Choose the permanent agent assignment. Prefer a +1. Choose the bot agent. Prefer a [low-trust review agent](https://docs.paperclip.ing/administration/trust-and-low-trust-review/) with an isolated sandbox and a scoped task boundary. Standard-trust agents - show a warning; choosing one does not silently reduce their permissions. -2. Make the instance reachable through public HTTPS, then create an App with - manifest registration or connect an existing App. Credentials are vaulted. -3. Install the App on GitHub. Grant access only to the intended repositories. -4. Refresh the repository list in Paperclip and enable the repositories this bot - should handle. GitHub installation access and Paperclip enablement are - separate controls. Use **Configure on GitHub** to change installation access, - then refresh again. -5. Verify signed delivery, App identity, repository permissions, and the assigned - agent's effective tools/runtime separately. For existing Apps, add Contents - read, Pull requests write, and Checks write alongside chat permissions and - subscribe to pull-request events. Approve any installation permission upgrade. -6. Choose your existing personal GitHub connection and explicitly confirm the - verified account identity. That connection links your identity; the bot uses - its own App credentials for agent tools and publication. -7. Configure access, event prompts, review behavior, and publication permissions. - Save progress to resume later. The final mention test is optional. + show a warning with **Change to a low trust agent**. This explicitly + saves the low-trust preset; it does not configure a sandbox or task boundary. +2. Choose **My account** or **An organization**. Enter the organization when + needed and review the App name suggested from your agent. Click + **Continue to GitHub**. Paperclip fills permissions, events, and callbacks. +3. Confirm creation on GitHub, then select repositories and approve installation. + Paperclip stores the credentials securely and imports the initial selection, + including an explicit All repositories choice. Organization policies may + require administrator approval; resume the same draft when it is granted. +4. Paperclip reuses your verified GitHub identity when available. Otherwise, + authorize the dedicated App and confirm the observed account once. Bot work + uses installation credentials, never your personal GitHub token. +5. Connection verification and completion happen automatically. The connected + page shows the actual App identity and repositories; a mention test is + optional. Runtime and isolation readiness remain separate prerequisites. + +New connections default to authorized mentions, advisory reviews, linked-member +access, guests off, and enabled bot GitHub tools. Existing instructions, explicit +behavior, and narrower saved repository restrictions remain intact. Later +repository additions require enablement in connection settings. Advanced review +rules and prompts also live in settings. + +Local instances receive public callbacks and signed events through an enrolled +Paperclip Cloud connector, using outbound requests instead of a public tunnel. +The Cloud capability must be deployed and enabled before localhost onboarding. +Direct public-HTTPS webhook connections and manual existing-App credential +recovery remain supported. Setup tasks and copied prompts are not part of this +wizard. Normal agent API keys cannot call its board-only management APIs. GitHub review bots use the existing agent runtime; this connector does not add provider software to the Cloud server image. Codex with managed MCP tools and @@ -42,7 +53,7 @@ does not supply one. A pack installed in the sandbox alone does not satisfy that existing runtime requirement. Treat that provider setup as a separate Runner prerequisite, not an automatic connector installation step. -Setup verification checks tool/runtime support and isolation; an actual test +Connection setup reports tool/runtime support and isolation separately; an actual test task is still required to prove that the chosen provider can execute in the selected environment. diff --git a/doc/plans/2026-10-06-github-app-wizard.md b/doc/plans/2026-10-06-github-app-wizard.md new file mode 100644 index 00000000000..948be252662 --- /dev/null +++ b/doc/plans/2026-10-06-github-app-wizard.md @@ -0,0 +1,143 @@ +# Minimal dedicated GitHub App wizard + +Date: 2026-10-06 +Status: Implemented. Automated and local UI qualification in progress; live provider qualification pending. + +## Accepted scope + +Support personal accounts and organizations equally through a private, +user-owned GitHub App. Its identity belongs to the selected review agent. +GitHub owns the creation confirmation and repository installation consent. +Paperclip owns configuration, credential storage, delivery, and verification. +The normal journey has two Paperclip screens: + +1. Choose agent, with an inline low-trust conversion when needed. Assigned + drafts resume directly into Connect GitHub. +2. Connect GitHub: My account or An organization, optional organization login, + and an editable name suggested from the agent. Continue submits the manifest + directly to GitHub. Creation returns securely to Paperclip and then goes + straight to installation. Installation returns to automatic verification + and completion, with conditional identity confirmation when necessary. + +There is no setup-method selection, delegated setup task, copied prompt, +mandatory guidance form, second repository selection, manual refresh gate, +manual verification gate, or Finish action. Settings retain advanced behavior +and existing-App recovery. Setup-agent assignment remains deferred. + +## Defaults and constraints + +- Mentions-only, advisory review, linked members, guests off, no merge-rule + changes. Preserve existing agent instructions and explicit bot configuration. +- Import the installation's initial repository access, including an explicit + All repositories choice. Preserve saved narrower access on resumed drafts. + Later additions remain disabled until selected in existing settings. +- Disclose that connecting enables the new bot's GitHub tools. Reconnect + preserves existing tool restrictions. +- Use GitHub's verified App identity, including renames during registration. +- Keep runtime and isolation readiness separate from GitHub connection health. + An optional test mention proves execution; connection health alone does not. +- Private Apps install on their owning account. Preserve existing public-App + connections and manual credential recovery. +- Human-only steps: sign-in/MFA, App creation, repository consent, required org + administrator approval, initial Cloud enrollment, unverified identity consent. + +## Implementation + +### Instance + +Extend existing manager-only APIs with owner selection, draft persistence, +redacted wizard progress, dedicated-App identity authorization/confirmation, +and automatic completion. Bind callback state and sealed claims to the exact +company, draft, connection, agent, configuring member, and enrolled origin. +Retain the legacy personal registration request and direct webhook flow. + +Serialize registration creation against the draft. Retry Cloud creation with +its existing registration identifier and immutable binding after a lost +response. Recover an uncertain manifest exchange or expired claim through the +existing App; never silently create another App. Vault credentials before +acknowledging receipt or exposing installation as the next step. + +Use existing verified linked identities when available. Otherwise use the +new App's OAuth flow, separate from its installation callback, with bound +state, PKCE, one explicit identity confirmation, and transient user tokens. +All bot execution continues through installation credentials. + +Re-enter the existing durable webhook admission/review path from an +independently bounded, authenticated Cloud event. Preserve resource access, +trust boundaries, revocation handling, and duplicate protection. + +### Paperclip Cloud + +Extend the existing connector broker, durable registry, enrollment, signed +requests, sealed claims, secret store, webhook inbox, event leases and acks, +and coalesced Cloud stack wakes. No per-user tunnel or separate relay service. + +Add dedicated App registrations with compare-and-swap progress, per-App +webhook secrets, fixed provider URLs, distinct public manifest/install/OAuth +callbacks, and normalized bot events. Store only the webhook verification +secret in Cloud's secret store; seal private keys and OAuth client credentials +to the enrolled instance. Installation query parameters are hints, never proof. + +The additive capability is controlled by `CLOUD_HARNESS_GITHUB_APPS_ENABLED=1`. +Production requires the durable provider secret store. Deploy the additive +Cloud migration/capability before enabling the synchronized instance wizard. + +## Qualification + +Automated coverage must exercise both owner URLs; private manifests; bound +callbacks and claims; retries after lost responses; failed and expired +exchanges without duplicate Apps; hostile signatures/owner bindings; +duplicate deliveries; preserved configuration and repository restrictions; +identity consent/expiry; company isolation; and legacy registration/recovery. + +Run focused backend/UI tests and token gates, then typecheck, Vitest, and +build. Walk through the actual local UI for agent selection, account choice, +save/resume, enrollment and recovery. Separately qualify real personal and +organization App creation/installation and a response under each dedicated +identity, with human consent. Mocked provider tests cannot establish that live +qualification or runtime execution succeeded. + +## Evidence and remaining rollout gates + +The production wizard was exercised in an isolated local test drive. Agent +selection, inline low-trust conversion, organization choice, name editing, +Save & exit, resume without a reload, full reload, and the conditional Cloud +enrollment handoff passed. The test did not authorize Cloud enrollment or +create a provider App. + +The focused UI suite has ten passing tests. Backend coverage includes both +owner types, interrupted registration and vault storage, initial repository +import, preserved settings, tenant binding, legacy callbacks, Cloud webhook +repair, and automatic completion with runtime warnings. Typecheck, build, +and UI token gates pass. Full Vitest qualification is running on the review +branch. + +Cloud qualification passed its current service suite (200 tests) and the +remaining current test files (2,352 tests, 73 environment-dependent skips). +Further focused cases cover replacement installation and dedicated identity +consent with PKCE, denial, and single-use state. The Postgres concurrency case +requires a disposable database and was not run here. Generated test output +must be cleaned before the suite: stale files from earlier builds caused two +false failures against deleted upstream tests. + +Before rollout, apply the additive Cloud migration, enable the dedicated-App +capability, then deploy the synchronized instance migration and wizard through +the existing connector rollout control. A real personal and organization +registration, installation, consent, signed delivery and bot response remain +required. No deployment, real App creation, installation, runtime provisioning, +or model execution was performed by the local UI walkthrough. + +## Prior findings retained + +Earlier setup research found repeated interviews, missing manager APIs, tunnel +requirements, runtime provisioning, and image-delivery limitations. This wizard supersedes +its onboarding approach. Setup agents, runtime provisioning, and screenshot +publication are deferred; GitHub connection completion must not claim any of +those are working. + +## Provider references + +- [Manifest registration](https://docs.github.com/en/apps/sharing-github-apps/registering-a-github-app-from-a-manifest) +- [App registration and ownership](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/registering-a-github-app) +- [Installation](https://docs.github.com/en/apps/using-github-apps/installing-your-own-github-app) +- [Setup callback verification](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/about-the-setup-url) diff --git a/packages/db/src/migrations/0312_curvy_mordo.sql b/packages/db/src/migrations/0312_curvy_mordo.sql new file mode 100644 index 00000000000..0e62b0015bd --- /dev/null +++ b/packages/db/src/migrations/0312_curvy_mordo.sql @@ -0,0 +1,4 @@ +ALTER TABLE "chat_github_registrations" ADD COLUMN IF NOT EXISTS "owner_type" text DEFAULT 'personal' NOT NULL;--> statement-breakpoint +ALTER TABLE "chat_github_registrations" ADD COLUMN IF NOT EXISTS "owner_login" text;--> statement-breakpoint +ALTER TABLE "chat_github_registrations" ADD COLUMN IF NOT EXISTS "app_name" text;--> statement-breakpoint +ALTER TABLE "chat_github_registrations" ADD COLUMN IF NOT EXISTS "handoff" jsonb; \ No newline at end of file diff --git a/packages/db/src/migrations/meta/0307_snapshot.json b/packages/db/src/migrations/meta/0312_snapshot.json similarity index 98% rename from packages/db/src/migrations/meta/0307_snapshot.json rename to packages/db/src/migrations/meta/0312_snapshot.json index 327a2ff5674..8a3c2713a36 100644 --- a/packages/db/src/migrations/meta/0307_snapshot.json +++ b/packages/db/src/migrations/meta/0312_snapshot.json @@ -1,6 +1,6 @@ { - "id": "ff1eeb0a-fea3-4cd1-b45c-ce712c4667ff", - "prevId": "164deda4-7a03-45fb-8236-d6ea0c43da11", + "id": "d805fb93-7f1c-4c39-b08c-08c387678a38", + "prevId": "53940222-21d7-4ccc-8b24-d8db300f4ec3", "version": "7", "dialect": "postgresql", "tables": { @@ -657,6 +657,194 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.agent_commentary": { + "name": "agent_commentary", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload_hash": { + "name": "payload_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_commentary_run_key_uq": { + "name": "agent_commentary_run_key_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_commentary_company_kind_created_idx": { + "name": "agent_commentary_company_kind_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_commentary_company_id_companies_id_fk": { + "name": "agent_commentary_company_id_companies_id_fk", + "tableFrom": "agent_commentary", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_commentary_agent_id_agents_id_fk": { + "name": "agent_commentary_agent_id_agents_id_fk", + "tableFrom": "agent_commentary", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_commentary_run_id_heartbeat_runs_id_fk": { + "name": "agent_commentary_run_id_heartbeat_runs_id_fk", + "tableFrom": "agent_commentary", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_commentary_issue_id_issues_id_fk": { + "name": "agent_commentary_issue_id_issues_id_fk", + "tableFrom": "agent_commentary", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "agent_commentary_kind_check": { + "name": "agent_commentary_kind_check", + "value": "\"agent_commentary\".\"kind\" in ('complaint', 'suggestion')" + } + }, + "isRLSEnabled": false + }, "public.agent_config_revisions": { "name": "agent_config_revisions", "schema": "", @@ -829,6 +1017,83 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.agent_identity_keys": { + "name": "agent_identity_keys", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "algorithm": { + "name": "algorithm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_key_pem": { + "name": "public_key_pem", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key_material": { + "name": "private_key_material", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "agent_identity_keys_owner_fk": { + "name": "agent_identity_keys_owner_fk", + "tableFrom": "agent_identity_keys", + "tableTo": "agents", + "columnsFrom": [ + "company_id", + "agent_id" + ], + "columnsTo": [ + "company_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "agent_identity_keys_algorithm_check": { + "name": "agent_identity_keys_algorithm_check", + "value": "\"agent_identity_keys\".\"algorithm\" = 'Ed25519'" + } + }, + "isRLSEnabled": false + }, "public.agent_instruction_heads": { "name": "agent_instruction_heads", "schema": "", @@ -9978,6 +10243,31 @@ "primaryKey": false, "notNull": true }, + "owner_type": { + "name": "owner_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'personal'" + }, + "owner_login": { + "name": "owner_login", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "app_name": { + "name": "app_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "handoff": { + "name": "handoff", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, "status": { "name": "status", "type": "text", @@ -37125,6 +37415,89 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.plugin_lifecycle_acknowledgments": { + "name": "plugin_lifecycle_acknowledgments", + "schema": "", + "columns": { + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "acknowledged_at": { + "name": "acknowledged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_lifecycle_acknowledgments_event_idx": { + "name": "plugin_lifecycle_acknowledgments_event_idx", + "columns": [ + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_lifecycle_acknowledgments_plugin_id_plugins_id_fk": { + "name": "plugin_lifecycle_acknowledgments_plugin_id_plugins_id_fk", + "tableFrom": "plugin_lifecycle_acknowledgments", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "plugin_lifecycle_acknowledgments_event_id_resource_lifecycle_events_id_fk": { + "name": "plugin_lifecycle_acknowledgments_event_id_resource_lifecycle_events_id_fk", + "tableFrom": "plugin_lifecycle_acknowledgments", + "tableTo": "resource_lifecycle_events", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "plugin_lifecycle_acknowledgments_plugin_id_event_id_pk": { + "name": "plugin_lifecycle_acknowledgments_plugin_id_event_id_pk", + "columns": [ + "plugin_id", + "event_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.plugin_logs": { "name": "plugin_logs", "schema": "", @@ -39383,6 +39756,39 @@ "concurrently": false, "method": "btree", "with": {} + }, + "resource_lifecycle_events_resource_order_idx": { + "name": "resource_lifecycle_events_resource_order_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} } }, "foreignKeys": { @@ -51112,6 +51518,187 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.mcp_attachment_uploads": { + "name": "mcp_attachment_uploads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "arguments_hash": { + "name": "arguments_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_filename": { + "name": "original_filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attachment_id": { + "name": "attachment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_provider": { + "name": "storage_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "cleaned_at": { + "name": "cleaned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_attachment_upload_request_uq": { + "name": "mcp_attachment_upload_request_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_attachment_upload_expiry_idx": { + "name": "mcp_attachment_upload_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_attachment_uploads_company_id_companies_id_fk": { + "name": "mcp_attachment_uploads_company_id_companies_id_fk", + "tableFrom": "mcp_attachment_uploads", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_attachment_uploads_user_id_user_id_fk": { + "name": "mcp_attachment_uploads_user_id_user_id_fk", + "tableFrom": "mcp_attachment_uploads", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.mcp_event_admissions": { "name": "mcp_event_admissions", "schema": "", @@ -51508,6 +52095,133 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.mcp_file_tickets": { + "name": "mcp_file_tickets", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "upload_id": { + "name": "upload_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "attachment_id": { + "name": "attachment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_file_ticket_expiry_idx": { + "name": "mcp_file_ticket_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_file_ticket_grant_idx": { + "name": "mcp_file_ticket_grant_idx", + "columns": [ + { + "expression": "grant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_file_tickets_company_id_companies_id_fk": { + "name": "mcp_file_tickets_company_id_companies_id_fk", + "tableFrom": "mcp_file_tickets", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_file_tickets_grant_id_mcp_oauth_grants_id_fk": { + "name": "mcp_file_tickets_grant_id_mcp_oauth_grants_id_fk", + "tableFrom": "mcp_file_tickets", + "tableTo": "mcp_oauth_grants", + "columnsFrom": [ + "grant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_file_tickets_upload_id_mcp_attachment_uploads_id_fk": { + "name": "mcp_file_tickets_upload_id_mcp_attachment_uploads_id_fk", + "tableFrom": "mcp_file_tickets", + "tableTo": "mcp_attachment_uploads", + "columnsFrom": [ + "upload_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.mcp_mutation_receipts": { "name": "mcp_mutation_receipts", "schema": "", diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 07d73d0a9ca..b57f99d10da 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -2171,6 +2171,13 @@ "when": 1791321362665, "tag": "0311_mcp_file_transfers", "breakpoints": true + }, + { + "idx": 312, + "version": "7", + "when": 1791341932149, + "tag": "0312_curvy_mordo", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/packages/db/src/schema/chat_github.ts b/packages/db/src/schema/chat_github.ts index dfed1e9d668..2e567736a35 100644 --- a/packages/db/src/schema/chat_github.ts +++ b/packages/db/src/schema/chat_github.ts @@ -60,6 +60,27 @@ export const chatGitHubRegistrations = pgTable( userId: text("user_id").notNull(), stateHash: text("state_hash").notNull(), trustedOrigin: text("trusted_origin").notNull(), + ownerType: text("owner_type") + .$type<"personal" | "organization">() + .notNull() + .default("personal"), + ownerLogin: text("owner_login"), + appName: text("app_name"), + handoff: jsonb("handoff").$type<{ + cloudId: string; + redemptionId: string; + returnState: string; + webhookSecretHash?: string; + identityStateHash?: string; + identityExpiresAt?: string; + identityRedemptionId?: string; + identity?: { + githubUserId: string; + login: string; + avatarUrl: string | null; + expiresAt: string; + }; + }>(), status: text("status") .$type<"pending" | "exchanging" | "completed" | "failed">() .notNull() diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 0e40261c5af..e8d7a8faa93 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2833,3 +2833,4 @@ export { isAppAggregator, aggregatorManagementUrl, aggregatorAppsSyncSchema, agg export * from "./connection-instructions.js"; export * from "./customer-success.js"; +export type { GitHubAppOwner, GitHubAppRegistrationInput, GitHubAppCloudState, GitHubAppWizardState } from "./types/github-app-setup.js"; diff --git a/packages/shared/src/types/chat-channels.ts b/packages/shared/src/types/chat-channels.ts index ece947c42d9..e5509318802 100644 --- a/packages/shared/src/types/chat-channels.ts +++ b/packages/shared/src/types/chat-channels.ts @@ -189,11 +189,16 @@ export interface SlackAppConfiguration { export interface ChatEndpointSetupState { github?: { - stage: "connect" | "install" | "repositories" | "verify" | "identity" | "behavior" | "test"; + stage: "setup" | "connect" | "install" | "repositories" | "verify" | "identity" | "behavior" | "test"; appSlug?: string; installationUrl?: string; managementUrl?: string; registrationStatus?: "pending" | "completed" | "failed"; + ownerType?: "personal" | "organization"; + ownerLogin?: string; + appName?: string; + cloudRegistrationId?: string; + initialRepositoriesImported?: boolean; }; step: "choose_agent" | "provider_setup" | "test" | "complete"; /** Server-generated boundary; only provider events at or after this time can complete setup. */ diff --git a/packages/shared/src/types/github-app-setup.ts b/packages/shared/src/types/github-app-setup.ts new file mode 100644 index 00000000000..9b156b61ca8 --- /dev/null +++ b/packages/shared/src/types/github-app-setup.ts @@ -0,0 +1,52 @@ +export type GitHubAppOwner = { + ownerType: "personal" | "organization"; + ownerLogin?: string; +}; +export type GitHubAppRegistrationInput = GitHubAppOwner & { name: string }; +export type GitHubAppCloudState = GitHubAppOwner & { + id: string; + status: "pending" | "exchanging" | "credentials" | "installed" | "failed"; + expiresAt: string; + appId?: string; + slug?: string; + claimId?: string; + claimExpired?: boolean; + registrationUrl?: string; + manifest?: Record; + webhookUrl?: string; + installationUrl?: string; + installationId?: string; + signedDeliveryAt?: string; + authorizationUrl?: string; +}; +export type GitHubAppWizardState = { + endpointId: string; + state: + | "create" + | "install" + | "identity" + | "verify" + | "connected" + | "recovery" + | "enrollment"; + registration?: { + registrationUrl: string; + manifest: Record; + expiresAt: string; + }; + installationUrl?: string; + identity?: { githubUserId: string; login: string; avatarUrl: string | null }; + identityLinked?: boolean; + identityMethod?: "dedicated_app" | "existing_connection"; + verification?: { + ready: boolean; + checks: Array<{ key: string; label: string; ok: boolean; detail: string }>; + }; + runtimeChecks?: Array<{ + key: string; + label: string; + ok: boolean; + detail: string; + }>; + message?: string; +}; diff --git a/server/src/__tests__/chat-channels.integration.test.ts b/server/src/__tests__/chat-channels.integration.test.ts index 0581be664b7..f0366a9c598 100644 --- a/server/src/__tests__/chat-channels.integration.test.ts +++ b/server/src/__tests__/chat-channels.integration.test.ts @@ -1,5 +1,8 @@ import { toolActionRequests, toolInvocations } from "@paperclipai/db"; import { GitHubPublicationLeaseLost, withGitHubPublicationLease } from "../services/chat-github-publication-lease.js"; +import { githubChatWizardService } from "../services/chat-github-wizard.js"; +import type { PaperclipCloudConnector } from "../services/paperclip-cloud-connector.js"; +import { dispatchGitHubBotCloudEvent } from "../services/chat-github-cloud-ingress.js"; import { githubChatManagementService } from "../services/chat-github-management.js"; import { githubChatReviewService } from "../services/chat-github-reviews.js"; import { githubReviewCheckService } from "../services/chat-github-checks.js"; @@ -2147,6 +2150,506 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { ); return { ...fixture, ...context, endpoint, principal, management }; } + it.each(["personal", "organization"] as const)( + "wizard persists a %s owner and resumes the same bound registration after a lost Cloud response", + async (ownerType) => { + const f = await seedCompany(); + const { service } = createService(); + const bot = await service.create( + f.companyId, + { provider: "github", assignedAgentId: f.assignedAgentId }, + "owner-user", + ); + let cloudRecord: Record | undefined, + loseResponse = true; + const githubApp = vi.fn( + async ({ binding }: { binding: Record }) => { + if (!cloudRecord) cloudRecord = binding; + expect(binding).toEqual(cloudRecord); + if (loseResponse) { + loseResponse = false; + throw new Error("Response lost"); + } + return { + id: binding.id, + status: "pending", + expiresAt: new Date(Date.now() + 60_000).toISOString(), + registrationUrl: + ownerType === "personal" + ? "https://github.com/settings/apps/new" + : "https://github.com/organizations/acme/settings/apps/new", + manifest: { public: false }, + }; + }, + ); + const wizard = githubChatWizardService(db, { + origin: () => "http://127.0.0.1:3104", + connector: () => + ({ + githubAppsAvailable: async () => true, + githubApp, + }) as unknown as PaperclipCloudConnector, + startDirect: service.startGitHubRegistration, + storeApp: service.storeGitHubApp, + storeCredentials: async () => {}, + refreshRepositories: service.refreshGitHubRepositories, + resources: service.listResources, + replaceResources: service.replaceResources, + configure: async () => {}, + finish: async () => {}, + }); + const input = { + name: "My Reviewer", + ownerType, + ...(ownerType === "organization" ? { ownerLogin: "acme" } : {}), + }; + await expect(wizard.start(bot.id, "owner-user", input)).rejects.toThrow( + "Response lost", + ); + const resumed = await wizard.start(bot.id, "owner-user", input); + expect(resumed.state).toBe("create"); + const sessions = await db + .select() + .from(chatGitHubRegistrations) + .where(eq(chatGitHubRegistrations.endpointId, bot.id)); + expect(sessions).toHaveLength(1); + expect(sessions[0].ownerType).toBe(ownerType); + expect(cloudRecord).toMatchObject({ + endpointId: bot.id, + connectionId: bot.connectionId, + agentId: bot.assignedAgentId, + returnUri: "http://127.0.0.1:3104/api/chat-github/cloud/callback", + }); + await expect( + wizard.saveDraft(bot.id, "owner-user", { + ...input, + name: "Different App", + }), + ).rejects.toThrow("existing App"); + await expect(wizard.advance(bot.id, "unrelated-user")).rejects.toThrow( + "active company manager", + ); + const current = await service.get(bot.id); + expect(current.id).toBe(bot.id); + expect(current.assignedAgentId).toBe(f.assignedAgentId); + }, + ); + it("wizard resumes the same sealed claim after App identity was saved but vault storage was interrupted", async () => { + const f = await seedCompany(); + const { service } = createService(); + const bot = await service.create( + f.companyId, + { provider: "github", assignedAgentId: f.assignedAgentId }, + "owner-user", + ); + await db + .insert(chatGitHubRegistrations) + .values({ + companyId: f.companyId, + endpointId: bot.id, + userId: "owner-user", + stateHash: randomUUID(), + trustedOrigin: "http://127.0.0.1:3104", + ownerType: "personal", + status: "exchanging", + expiresAt: new Date(Date.now() + 60_000), + handoff: { + cloudId: "claim-recovery", + returnState: "bound", + redemptionId: "same-receipt", + }, + }); + const claimGitHubApp = vi.fn(async () => ({ + kind: "github_app", + registrationId: "claim-recovery", + endpointId: bot.id, + connectionId: bot.connectionId, + agentId: bot.assignedAgentId, + appId: "1234", + privateKey: "test-key", + webhookSecret: "test-webhook-secret", + clientId: "test-client", + clientSecret: "test-client-secret", + })); + const storeApp = vi.fn(async () => { + await db + .update(chatEndpoints) + .set({ botExternalId: "1234" }) + .where(eq(chatEndpoints.id, bot.id)); + throw new Error("Vault storage interrupted"); + }); + const createWizard = () => + githubChatWizardService(db, { + origin: () => "http://127.0.0.1:3104", + connector: () => + ({ + githubApp: async () => ({ + id: "claim-recovery", + appId: "1234", + claimId: "same-claim", + status: "credentials", + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }), + claimGitHubApp, + }) as unknown as PaperclipCloudConnector, + startDirect: service.startGitHubRegistration, + storeApp, + storeCredentials: async () => {}, + refreshRepositories: service.refreshGitHubRepositories, + resources: service.listResources, + replaceResources: service.replaceResources, + configure: async () => {}, + finish: async () => {}, + }); + await expect(createWizard().advance(bot.id, "owner-user")).rejects.toThrow( + "Vault storage interrupted", + ); + await expect(createWizard().advance(bot.id, "owner-user")).rejects.toThrow( + "Vault storage interrupted", + ); + expect(storeApp).toHaveBeenCalledTimes(2); + expect(claimGitHubApp).toHaveBeenCalledTimes(2); + expect(claimGitHubApp.mock.calls[0]).toEqual(claimGitHubApp.mock.calls[1]); + expect( + await db + .select() + .from(chatGitHubRegistrations) + .where(eq(chatGitHubRegistrations.endpointId, bot.id)), + ).toHaveLength(1); + }); + it("wizard keeps localhost draft choices while waiting for Cloud enrollment", async () => { + const f = await seedCompany(); + const { service } = createService(); + const bot = await service.create( + f.companyId, + { provider: "github", assignedAgentId: f.assignedAgentId }, + "owner-user", + ); + const wizard = githubChatWizardService(db, { + origin: () => "http://127.0.0.1:3104", + connector: () => null, + startDirect: service.startGitHubRegistration, + storeApp: service.storeGitHubApp, + storeCredentials: async () => {}, + refreshRepositories: service.refreshGitHubRepositories, + resources: service.listResources, + replaceResources: service.replaceResources, + configure: async () => {}, + finish: async () => {}, + }); + expect( + ( + await wizard.start(bot.id, "owner-user", { + name: "My Reviewer", + ownerType: "organization", + ownerLogin: "acme", + }) + ).state, + ).toBe("enrollment"); + expect((await service.get(bot.id)).setup.github).toMatchObject({ + ownerType: "organization", + ownerLogin: "acme", + appName: "My Reviewer", + }); + expect( + await db + .select() + .from(chatGitHubRegistrations) + .where(eq(chatGitHubRegistrations.endpointId, bot.id)), + ).toHaveLength(0); + }); + it("wizard repairs Cloud after manual credential recovery and secret rotation without changing App identity", async () => { + const f = await seedCompany(); + const context = await configuredGitHubEndpoint(f, {}, true); + const bot = await context.service.get(context.endpoint.id), + cloudId = "registration-repair"; + await db + .insert(chatGitHubRegistrations) + .values({ + companyId: f.companyId, + endpointId: bot.id, + userId: "owner-user", + stateHash: randomUUID(), + trustedOrigin: "https://reviews.example.test", + ownerType: "organization", + ownerLogin: "paperclipai", + appName: "Reviewer", + status: "failed", + expiresAt: new Date(), + handoff: { cloudId, returnState: "bound", redemptionId: "receipt" }, + }); + const webhookUrl = `https://cloud.example.test/v1/connector/webhooks/github/apps/${cloudId}`; + let repaired = false; + const githubApp = vi.fn( + async ({ binding }: { binding: Record }) => { + if (binding.action === "repair") { + expect(binding.appToken).toBeTypeOf("string"); + expect(binding.webhookSecret).toBeTypeOf("string"); + repaired = true; + } + return { + id: cloudId, + status: repaired ? "credentials" : "failed", + expiresAt: new Date().toISOString(), + appId: bot.botExternalId, + webhookUrl, + }; + }, + ); + const providerFetch = vi.fn( + async ( + input: Parameters[0], + init?: Parameters[1], + ) => { + if ( + String(input) === "https://api.github.com/app/hook/config" && + init?.method === "PATCH" + ) { + const body = JSON.parse(String(init.body)); + expect(body.url).toBe(webhookUrl); + return Response.json({ + url: webhookUrl, + content_type: "json", + insecure_ssl: "0", + }); + } + return context.providerFetch(input, init); + }, + ) as typeof fetch; + const wizard = githubChatWizardService(db, { + origin: () => "https://reviews.example.test", + fetch: providerFetch, + connector: () => ({ githubApp }) as unknown as PaperclipCloudConnector, + startDirect: context.service.startGitHubRegistration, + storeApp: context.service.storeGitHubApp, + storeCredentials: async () => {}, + refreshRepositories: context.service.refreshGitHubRepositories, + resources: context.service.listResources, + replaceResources: context.service.replaceResources, + configure: async () => {}, + finish: async () => {}, + }); + await expect(wizard.repairCloud(bot.id, "unrelated-user")).rejects.toThrow( + "active company manager", + ); + const before = await context.service.listResources(bot.id); + await wizard.repairCloud(bot.id, "owner-user"); + const current = await context.service.get(bot.id); + expect(current.botExternalId).toBe(bot.botExternalId); + expect(current.assignedAgentId).toBe(bot.assignedAgentId); + expect(current.setup.github?.cloudRegistrationId).toBe(cloudId); + expect(current.setup.webhookVerifiedAt).toBeNull(); + expect(await context.service.listResources(bot.id)).toEqual(before); + const [session] = await db + .select() + .from(chatGitHubRegistrations) + .where(eq(chatGitHubRegistrations.endpointId, bot.id)); + expect(session.status).toBe("completed"); + expect(session.handoff?.webhookSecretHash).toMatch(/^[a-f0-9]{64}$/); + await context.service.generateSetupSecret(bot.id, "owner-user"); + await wizard.repairCloud(bot.id, "owner-user"); + const repairCalls = githubApp.mock.calls.filter( + ([input]) => input.binding.action === "repair", + ); + expect(repairCalls).toHaveLength(2); + expect(repairCalls[0][0].binding.webhookSecret).not.toBe( + repairCalls[1][0].binding.webhookSecret, + ); + expect((await context.service.get(bot.id)).status).toBe("attention"); + }); + it("wizard preserves explicit review behavior and repository restrictions on existing drafts", async () => { + const f = await reviewBotFixture(); + f.setSupplementalProviderFetch(async (input) => + String(input) === "https://api.github.com/app/installations/2468" + ? Response.json({ + id: 2468, + app_id: Number(f.endpoint.botExternalId), + account: { login: "paperclipai" }, + permissions: { + contents: "read", + issues: "write", + metadata: "read", + pull_requests: "write", + checks: "write", + }, + }) + : undefined, + ); + const before = await f.management.configuration(f.endpoint.id, "owner-user"); + const resources = await f.service.listResources(f.endpoint.id); + await db + .update(chatEndpoints) + .set({ + setup: { + ...f.endpoint.setup, + github: { stage: "repositories" }, + webhookVerifiedAt: new Date().toISOString(), + }, + }) + .where(eq(chatEndpoints.id, f.endpoint.id)); + const progress = await f.service.githubWizard.advance( + f.endpoint.id, + "owner-user", + ); + expect(progress.identityLinked).toBe(true); + expect(await f.management.configuration(f.endpoint.id, "owner-user")).toEqual( + before, + ); + expect(await f.service.listResources(f.endpoint.id)).toEqual(resources); + }); + it("wizard completes a verified connection automatically without claiming runtime execution", async () => { + const f = await reviewBotFixture(); + const permissions = { + contents: "read", + issues: "write", + metadata: "read", + pull_requests: "write", + checks: "write", + }; + f.setAppAccess({ + permissions, + events: ["pull_request", "issue_comment", "pull_request_review_comment"], + }); + f.setInstallationAccess(permissions); + f.setSupplementalProviderFetch(async (input) => + String(input) === "https://api.github.com/app/installations/2468" + ? Response.json({ + id: 2468, + app_id: Number(f.endpoint.botExternalId), + account: { login: "paperclipai" }, + permissions, + }) + : undefined, + ); + await db + .update(agents) + .set({ adapterType: "process" }) + .where(eq(agents.id, f.assignedAgentId)); + const progress = await f.service.githubWizard.advance( + f.endpoint.id, + "owner-user", + ); + expect(progress.state).toBe("connected"); + expect(progress.verification?.ready).toBe(true); + expect( + progress.runtimeChecks?.find((check) => check.key === "runtime")?.ok, + ).toBe(false); + expect((await f.service.get(f.endpoint.id)).status).toBe("active"); + expect(f.wakeup).not.toHaveBeenCalled(); + expect( + (await f.service.githubWizard.advance(f.endpoint.id, "owner-user")).state, + ).toBe("connected"); + }); + it("imports initial GitHub access without a second picker and leaves later additions disabled", async () => { + const f = await seedCompany(); + const context = await configuredGitHubEndpoint(f, {}, true); + const current = await context.service.get(context.endpoint.id); + const first = await context.service.listResources(current.id); + await context.service.replaceResources( + current.id, + first.map((resource) => ({ id: resource.id, enabled: false })), + "owner-user", + ); + await db + .insert(chatGitHubRegistrations) + .values({ + companyId: f.companyId, + endpointId: current.id, + userId: "owner-user", + stateHash: randomUUID(), + trustedOrigin: "https://reviews.example.test", + ownerType: "organization", + ownerLogin: "paperclipai", + status: "completed", + expiresAt: new Date(Date.now() + 60_000), + }); + const result = await context.service.githubWizard.advance( + current.id, + "owner-user", + ); + expect(result.state).toBe("identity"); + expect( + (await context.service.listResources(current.id)).map( + (resource) => resource.enabled, + ), + ).toEqual([true]); + expect( + ( + await githubChatManagementService( + db, + context.providerFetch, + ).configuration(current.id, "owner-user") + ).configuration, + ).toMatchObject({ + toolsEnabled: true, + defaults: { invocation: "mentions_only", events: [] }, + }); + context.setRepositories([ + { + id: 97531, + full_name: "paperclipai/paperclip", + html_url: "https://github.com/paperclipai/paperclip", + owner: { id: 1357, login: "paperclipai" }, + private: false, + }, + { + id: 97532, + full_name: "paperclipai/later", + html_url: "https://github.com/paperclipai/later", + owner: { id: 1357, login: "paperclipai" }, + private: true, + }, + ]); + await context.service.refreshGitHubRepositories(current.id, "owner-user"); + const later = (await context.service.listResources(current.id)).find( + (resource) => resource.label === "paperclipai/later", + ); + expect(later?.enabled).toBe(false); + await context.service.githubWizard.advance(current.id, "owner-user"); + expect( + (await context.service.listResources(current.id)).find( + (resource) => resource.id === later!.id, + )?.enabled, + ).toBe(false); + }); + it("Cloud bot delivery cannot enter another company's endpoint or a different agent binding", async () => { + const f = await reviewBotFixture(); + const event = { + id: "event-a", + provider: "github" as const, + event: "issue_comment", + action: "created", + installationId: "2468", + repositoryId: "97531", + createdAt: new Date().toISOString(), + bindingIds: ["github-app:registration-a"], + payload: { + githubApp: { + endpointId: f.endpoint.id, + companyId: f.companyId, + registrationId: "registration-a", + connectionId: f.endpoint.connectionId, + agentId: "different-agent", + appId: f.endpoint.botExternalId, + deliveryId: "delivery-123", + body: {}, + }, + }, + }; + await db + .update(chatEndpoints) + .set({ + setup: { + ...f.endpoint.setup, + github: { cloudRegistrationId: "registration-a" }, + }, + }) + .where(eq(chatEndpoints.id, f.endpoint.id)); + await expect(dispatchGitHubBotCloudEvent(db, event)).rejects.toThrow( + "does not belong", + ); + expect(f.wakeup).not.toHaveBeenCalled(); + }); it("uses the manifest state parameter and rejects expired, reused, and wrong-origin registrations", async () => { const fixture = await seedCompany(); const { service } = createService( diff --git a/server/src/app.ts b/server/src/app.ts index b9054137ce9..c078d8f328f 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -615,6 +615,10 @@ export async function createApp( deferWebhookProcessing: true, heartbeat: connectionIntentHeartbeat, publicBaseUrl: opts.authPublicBaseUrl, + githubWizardOrigin: opts.deploymentMode === "local_trusted" + && ["127.0.0.1", "localhost", "::1"].includes(opts.bindHost ?? "") + && Number.isInteger(opts.serverPort) && opts.serverPort! > 0 + ? `http://${opts.bindHost === "::1" ? "[::1]" : opts.bindHost}:${opts.serverPort}` : null, webhookPublicBaseUrl: opts.chatWebhookPublicBaseUrl, resolveNativeQuestion: (interaction) => deliverNativeQuestionResponse(db, interaction), @@ -632,13 +636,21 @@ export async function createApp( app.use(emailWebhookRoutes(emailChannels)); app.use(chatWebhookRoutes(chatChannels)); // The instance validates single-use registration state and its trusted - // current origin. This exact GET is the only public setup return. + // current origin. These exact callback routes are the public setup returns. app.get("/api/chat-github/manifest/callback", async (req, res) => { res.set("Cache-Control", "no-store"); res.set("Referrer-Policy", "no-referrer"); - const redirect = await chatChannels.completeGitHubRegistration(String(req.query.state ?? ""), String(req.query.code ?? "")); + const redirect = await chatChannels.githubWizard.directCallback(String(req.query.state ?? ""), String(req.query.code ?? "")); res.redirect(303, redirect); }); + app.get("/api/chat-github/cloud/callback", async (req, res) => { + res.set("Cache-Control", "no-store"); res.set("Referrer-Policy", "no-referrer"); + res.redirect(303, await chatChannels.githubWizard.cloudCallback(String(req.query.state ?? ""), String(req.query.registration ?? ""), typeof req.query.claim === "string" ? req.query.claim : undefined)); + }); + app.get("/api/chat-github/identity/callback", async (req, res) => { + res.set("Cache-Control", "no-store"); res.set("Referrer-Policy", "no-referrer"); + res.redirect(303, await chatChannels.githubWizard.identityCallback(String(req.query.state ?? ""), String(req.query.code ?? ""))); + }); const managedAutoInstallKeys = opts.managedPluginAutoInstall ?? null; const bundledCatalogRoot = opts.bundledPluginCatalogRoot ?? resolveBundledCatalogRoot(process.env); diff --git a/server/src/routes/chat-channels.ts b/server/src/routes/chat-channels.ts index 61497688d9e..8b525a856b6 100644 --- a/server/src/routes/chat-channels.ts +++ b/server/src/routes/chat-channels.ts @@ -177,7 +177,7 @@ export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) { if (!(await assertEndpointManagementAccess(req, res))) return; res.json(await github.verification(endpointId(req))); }); - router.put("/chat-endpoints/:endpointId/github/progress", validate(z.object({ stage: z.enum(["connect", "install", "repositories", "verify", "identity", "behavior", "test"]) }).strict()), async (req, res) => { + router.put("/chat-endpoints/:endpointId/github/progress", validate(z.object({ stage: z.enum(["setup", "connect", "install", "repositories", "verify", "identity", "behavior", "test"]) }).strict()), async (req, res) => { if (!(await assertEndpointManagementAccess(req, res))) return; res.json(await service.saveGitHubSetupProgress(endpointId(req), req.body.stage)); }); @@ -197,10 +197,32 @@ export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) { if (!(await assertEndpointManagementAccess(req, res))) return; res.json(await github.lookupPerson(req.body.login)); }); - router.post("/chat-endpoints/:endpointId/github/registration", validate(z.object({ name: z.string().trim().min(1).max(34) }).strict()), async (req, res) => { + router.post("/chat-endpoints/:endpointId/github/registration", validate(z.object({ name: z.string().trim().min(1).max(34), ownerType: z.enum(["personal", "organization"]).optional(), ownerLogin: z.string().regex(/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/).optional() }).strict().refine(value => value.ownerType !== "organization" || !!value.ownerLogin, "Enter the GitHub organization")), async (req, res) => { if (!(await assertEndpointManagementAccess(req, res))) return; res.set("Cache-Control", "no-store"); - res.json(await service.startGitHubRegistration(endpointId(req), githubUser(req), req.body.name)); + res.json(req.body.ownerType === undefined + ? await service.startGitHubRegistration(endpointId(req), githubUser(req), req.body.name) + : await service.githubWizard.start(endpointId(req), githubUser(req), req.body)); + }); + router.put("/chat-endpoints/:endpointId/github/draft", validate(z.object({ name: z.string().trim().min(1).max(34), ownerType: z.enum(["personal", "organization"]), ownerLogin: z.string().regex(/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/).optional() }).strict()), async (req, res) => { + if (!(await assertEndpointManagementAccess(req, res))) return; + res.set("Cache-Control", "no-store"); + res.json(await service.githubWizard.saveDraft(endpointId(req), githubUser(req), req.body)); + }); + router.post("/chat-endpoints/:endpointId/github/setup", async (req, res) => { + if (!(await assertEndpointManagementAccess(req, res))) return; + res.set("Cache-Control", "no-store"); + res.json(await service.githubWizard.advance(endpointId(req), githubUser(req))); + }); + router.post("/chat-endpoints/:endpointId/github/identity/start", async (req, res) => { + if (!(await assertEndpointManagementAccess(req, res))) return; + res.set("Cache-Control", "no-store"); + res.json(await service.githubWizard.startIdentity(endpointId(req), githubUser(req))); + }); + router.post("/chat-endpoints/:endpointId/github/identity/confirm", validate(z.object({ githubUserId: z.string().regex(/^[1-9][0-9]*$/) }).strict()), async (req, res) => { + if (!(await assertEndpointManagementAccess(req, res))) return; + res.set("Cache-Control", "no-store"); + res.json(await service.githubWizard.confirmIdentity(endpointId(req), githubUser(req), req.body.githubUserId)); }); router.post("/chat-endpoints/:endpointId/github/app", validate(z.object({ appId: z.string().regex(/^[1-9][0-9]*$/), privateKey: z.string().min(1).max(32000), webhookSecret: z.string().min(16).max(1024) }).strict()), async (req, res) => { if (!(await assertEndpointManagementAccess(req, res))) return; diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index a5bd3f0ad7d..982f916f067 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -1559,6 +1559,10 @@ const BOARD_ONLY_OPERATIONS = new Set([ "POST /api/chat-endpoints/{endpointId}/github/identity", "POST /api/chat-endpoints/{endpointId}/github/people/lookup", "POST /api/chat-endpoints/{endpointId}/github/registration", + "PUT /api/chat-endpoints/{endpointId}/github/draft", + "POST /api/chat-endpoints/{endpointId}/github/setup", + "POST /api/chat-endpoints/{endpointId}/github/identity/start", + "POST /api/chat-endpoints/{endpointId}/github/identity/confirm", "POST /api/chat-endpoints/{endpointId}/github/app", "POST /api/chat-endpoints/{endpointId}/github/repositories/refresh", "PATCH /api/chat-endpoints/{endpointId}", @@ -2310,7 +2314,7 @@ const githubBotOperations: Array<{ { method: "put", suffix: "progress", summary: "Save GitHub setup progress", description: "Saves the resumable wizard stage without granting access or bypassing verification.", - body: z.object({ stage: z.enum(["connect", "install", "repositories", "verify", "identity", "behavior", "test"]) }).strict(), + body: z.object({ stage: z.enum(["setup", "connect", "install", "repositories", "verify", "identity", "behavior", "test"]) }).strict(), response: chatEndpointResponseSchema, }, { diff --git a/server/src/services/chat-channels.ts b/server/src/services/chat-channels.ts index 3dd8aeb3682..08bd5066610 100644 --- a/server/src/services/chat-channels.ts +++ b/server/src/services/chat-channels.ts @@ -18,6 +18,8 @@ import { githubReviewPrompt } from "./chat-github-review-policy.js"; import { chatGitHubConfigurations, chatGitHubReviews } from "@paperclipai/db"; import type { GitHubReviewEventContext, GitHubReviewPolicy } from "@paperclipai/shared"; import { githubChatReviewService } from "./chat-github-reviews.js"; +import { githubChatWizardService } from "./chat-github-wizard.js"; +import { registerGitHubBotCloudIngress } from "./chat-github-cloud-ingress.js"; import { githubChatRegistrationService } from "./chat-github-registration.js"; import { githubChatPrincipalAccess } from "./chat-github-access.js"; import { githubAppJwt } from "./chat-github-client.js"; @@ -1463,6 +1465,8 @@ export interface ChatChannelServiceOptions { /** Production bridge for resuming a native run that owns the question. */ resolveNativeQuestion?: QuestionResponseDeliveryServiceOptions["resolveNativeQuestion"]; publicBaseUrl?: string | null; + /** Canonical loopback browser return for Cloud-assisted GitHub registration. */ + githubWizardOrigin?: string | null; /** Optional verified ingress origin; never used for board or identity links. */ webhookPublicBaseUrl?: string | null; runtime?: ChatSdkRuntime; @@ -9815,7 +9819,13 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { const next = await endpointRecord(endpoint.id); if (!next) throw notFound("Chat endpoint not found"); await invalidateRuntime(endpoint.id); - if (endpoint.provider === "github" && input.action === "reconnect") { + if (endpoint.provider === "github" && input.action === "reconnect" && endpoint.setup.github?.cloudRegistrationId) { + if (!actorUserId) throw forbidden("A configuring member is required to recover this App"); + await credentialLease.assertOwned(); + await githubWizard.repairCloud(endpoint.id, actorUserId); + await credentialLease.assertOwned(); + } + if (endpoint.provider === "github" && input.action === "reconnect" && !endpoint.setup.github?.cloudRegistrationId) { // The immutable App and its installation were verified above. Repair // only its callback using the stored secret; never reinstall it or // change repository access. Keep historical signed-ping evidence, but @@ -10035,7 +10045,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { } if (endpoint.provider === "github" && endpoint.setup.github) { const verified = await githubChatManagementService(db, fetchImpl).verification(endpoint.id); - if (!verified.ready) throw conflict("Finish verifying the App, repositories, and assigned agent's tools before activating this bot", { checks: verified.checks }); + if (!verified.ready && !(finishOptions?.optionalSlackTestForUser && verified.connectionReady)) throw conflict("Finish verifying the App, repositories, and assigned agent's tools before activating this bot", { checks: verified.checks }); } const testStartedAtValue = endpoint.setup.testStartedAt; const testStartedAt = testStartedAtValue @@ -38225,7 +38235,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { await tx.update(chatEndpoints).set({ status: "attention", botExternalId: identity.botExternalId, botUsername: identity.botUsername, botDisplayName: identity.botLabel, providerAccountId: identity.providerAccountId, providerAccountLabel: identity.providerAccountLabel, - setup: { ...record.endpoint.setup, github: { stage: "install", appSlug: slug, installationUrl: `https://github.com/apps/${slug}/installations/new`, registrationStatus: "completed" } }, + setup: { ...record.endpoint.setup, github: { ...record.endpoint.setup.github, stage: "install", appSlug: slug, installationUrl: `https://github.com/apps/${slug}/installations/new`, registrationStatus: "completed" } }, healthMessage: "Install the App on GitHub to continue", updatedAt: new Date(), }).where(eq(chatEndpoints.id, endpointId)); await logActivity(tx as unknown as Db, { companyId: record.endpoint.companyId, actorType: "user", actorId: userId, action: "chat_github.app_connected", entityType: "tool_connection", entityId: record.endpoint.connectionId, details: { endpointId, appId: identity.botExternalId } }); @@ -38249,7 +38259,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { const githubRegistration = githubChatRegistrationService(db, { publicOrigin: () => getPublicBaseUrl(), webhookOrigin: () => getWebhookPublicBaseUrl(), fetch: fetchImpl, - storeApp: async (endpointId, userId, app) => { await storeGitHubApp(endpointId, userId, { appId: app.appId, privateKey: app.privateKey, webhookSecret: app.webhookSecret }); }, + storeApp: async (endpointId, userId, app) => { await storeGitHubApp(endpointId, userId, { appId: app.appId, privateKey: app.privateKey, webhookSecret: app.webhookSecret, ...(app.clientId ? { clientId: app.clientId } : {}), ...(app.clientSecret ? { clientSecret: app.clientSecret } : {}) }); }, }); async function refreshGitHubRepositories(endpointId: string, userId: string) { @@ -38268,6 +38278,41 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { return listResources(endpointId); } + const githubWizard = githubChatWizardService(db, { + origin: () => getPublicBaseUrl() ?? options.githubWizardOrigin ?? null, fetch: fetchImpl, startDirect: githubRegistration.start, completeDirect: githubRegistration.complete, resumeDirect: githubRegistration.resumeRegistration, + storeApp: storeGitHubApp, + storeCredentials: async (id, userId, additional) => { + const record = await endpointRecord(id); + if (!record || record.endpoint.provider !== "github") throw notFound("GitHub bot not found"); + await withCredentialMutationLease(record.endpoint, async lease => { + const current = await endpointRecord(id); + if (!current || ["archived", "revoked", "paused"].includes(current.endpoint.status)) throw conflict("GitHub App is unavailable"); + const credentials = await resolveCredentials(current.endpoint); + await persistCredentials(current.endpoint, { ...credentials, ...additional }, lease, userId); + }); + }, + refreshRepositories: refreshGitHubRepositories, resources: listResources, replaceResources, + configure: async (id, userId) => { + await update(id, { allowGroupChats: true }, userId); + return configure(id, { action: "configure" }, userId); + }, + finish: (id, userId) => test(id, { optionalSlackTestForUser: userId }), + }); + const unregisterGitHubCloudIngress = registerGitHubBotCloudIngress(db, async (endpoint, event, body) => { + const record = await endpointRecord(endpoint.id); + if (!record || record.endpoint.companyId !== endpoint.companyId || record.endpoint.connectionId !== endpoint.connectionId + || record.endpoint.assignedAgentId !== endpoint.assignedAgentId || record.endpoint.setup.github?.cloudRegistrationId !== endpoint.setup.github?.cloudRegistrationId) throw forbidden("GitHub Cloud event binding changed"); + const credentials = await resolveCredentials(record.endpoint); + // Cloud authenticated the provider's original bytes. Re-enter the canonical + // durable ingress with a local authenticator over the bounded normalized payload. + const normalized = JSON.stringify(body); + const response = await handleWebhook(record.endpoint.publicId, "github", new Request("https://paperclip.invalid/github-cloud-ingress", { + method: "POST", headers: { "content-type": "application/json", "x-github-event": event.event, + "x-github-delivery": event.deliveryId, "x-hub-signature-256": `sha256=${createHmac("sha256", credentials.webhookSecret).update(normalized).digest("hex")}` }, body: normalized, + })); + if (!response.ok) throw conflict("GitHub Cloud ingress could not be durably accepted"); + }); + const unregisterSlackTaskAuthority = registerSlackTaskAuthority(db, async (binding) => { if (!(await instanceSettingsService(db).getExperimental()).enableChatConnectors) throw forbidden("Chat connectors are disabled"); @@ -38377,6 +38422,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { await db.update(chatEndpoints).set({ setup: sql`jsonb_set(${chatEndpoints.setup}, '{github}', coalesce(${chatEndpoints.setup}->'github', '{}'::jsonb) || ${JSON.stringify({ stage })}::jsonb)`, updatedAt: new Date() }).where(eq(chatEndpoints.id, endpointId)); return get(endpointId); }, + githubWizard, startGitHubRegistration: githubRegistration.start, completeGitHubRegistration: githubRegistration.complete, storeGitHubApp, @@ -38433,6 +38479,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { await Promise.allSettled([...failedRetryTasks.values()]); unregisterFailedRetryAuthority(); unregisterSlackTaskAuthority(); + unregisterGitHubCloudIngress(); unregisterCommittedResponseAuthority(); await Promise.allSettled([...publicationEndpointTasks.values()]); await Promise.allSettled([...backgroundMessageTasks]); diff --git a/server/src/services/chat-github-client.ts b/server/src/services/chat-github-client.ts index 36b9afb49f5..e6ad3c8bd62 100644 --- a/server/src/services/chat-github-client.ts +++ b/server/src/services/chat-github-client.ts @@ -119,6 +119,9 @@ export async function githubBotCredentials( "privateKey", "installationId", "webhookSecret", + "clientId", + "clientSecret", + "identityCodeVerifier", ]) { const ref = row.connection.credentialSecretRefs.find( (r) => r.configPath === `credentials.${key}`, diff --git a/server/src/services/chat-github-cloud-ingress.ts b/server/src/services/chat-github-cloud-ingress.ts new file mode 100644 index 00000000000..b386f5ccb11 --- /dev/null +++ b/server/src/services/chat-github-cloud-ingress.ts @@ -0,0 +1,81 @@ +import { normalizeGitHubBotCloudPayload } from "./chat-github-cloud-payload.js"; +import type { Db, chatEndpoints } from "@paperclipai/db"; +import { chatEndpoints as endpoints } from "@paperclipai/db"; +import { and, eq } from "drizzle-orm"; +import { forbidden } from "../errors.js"; +import type { SealedConnectorEvents } from "./paperclip-cloud-connector.js"; + +type Event = SealedConnectorEvents["events"][number]; +type Endpoint = typeof chatEndpoints.$inferSelect; +type Handler = ( + endpoint: Endpoint, + event: { event: string; deliveryId: string }, + body: Record, +) => Promise; +const handlers = new WeakMap(); +export function registerGitHubBotCloudIngress(db: Db, handler: Handler) { + handlers.set(db, handler); + return () => { + if (handlers.get(db) === handler) handlers.delete(db); + }; +} +/** Called only for an authenticated, decrypted broker lease, never from a board request body. */ +export async function dispatchGitHubBotCloudEvent( + db: Db, + event: Event, +): Promise { + const value = event.payload.githubApp; + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const packet = value as Record; + if ( + typeof packet.endpointId !== "string" || + typeof packet.companyId !== "string" || + typeof packet.registrationId !== "string" || + typeof packet.deliveryId !== "string" || + !/^[A-Za-z0-9_-]{8,200}$/.test(packet.deliveryId) || + !event.bindingIds.includes(`github-app:${packet.registrationId}`) || + !packet.body || + typeof packet.body !== "object" || + Array.isArray(packet.body) || + JSON.stringify(packet.body).length > 1_048_576 + ) + throw forbidden("Invalid GitHub Cloud event"); + const [endpoint] = await db + .select() + .from(endpoints) + .where( + and( + eq(endpoints.companyId, packet.companyId), + eq(endpoints.id, packet.endpointId), + eq(endpoints.provider, "github"), + ), + ); + if (!endpoint || endpoint.status === "archived") return true; + if ( + endpoint.connectionId !== packet.connectionId || + endpoint.assignedAgentId !== packet.agentId || + endpoint.botExternalId !== packet.appId || + endpoint.setup.github?.cloudRegistrationId !== packet.registrationId + ) + throw forbidden("GitHub Cloud event does not belong to this bot"); + if ( + ![ + "ping", + "issue_comment", + "pull_request_review_comment", + "pull_request", + "installation", + "installation_repositories", + "github_app_authorization", + ].includes(event.event) + ) + throw forbidden("Unsupported GitHub Cloud event"); + const handler = handlers.get(db); + if (!handler) throw new Error("GitHub bot Cloud ingress is not ready"); + await handler( + endpoint, + { event: event.event, deliveryId: packet.deliveryId }, + normalizeGitHubBotCloudPayload(packet.body as Record), + ); + return true; +} diff --git a/server/src/services/chat-github-cloud-payload.ts b/server/src/services/chat-github-cloud-payload.ts new file mode 100644 index 00000000000..65abd7db540 --- /dev/null +++ b/server/src/services/chat-github-cloud-payload.ts @@ -0,0 +1,100 @@ +const record = (value: unknown): Record => + value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; + +/** Explicit, bounded provider fields; never persist the original webhook body. */ +export function normalizeGitHubBotCloudPayload( + body: Record, +): Record { + const schemas: Record = { + action: 100, + before: 64, + zen: 300, + sender: { id: true, login: 100, type: 40 }, + repository: { + id: true, + full_name: 300, + name: 100, + owner: { id: true, login: 100, type: 40 }, + }, + installation: { + id: true, + app_id: true, + account: { id: true, login: 100, type: 40 }, + repository_selection: 40, + }, + issue: { + id: true, + number: true, + title: 1_000, + body: 24_000, + html_url: 2_000, + user: { id: true, login: 100, type: 40 }, + pull_request: { url: 2_000, html_url: 2_000 }, + }, + comment: { + id: true, + body: 24_000, + html_url: 2_000, + created_at: 100, + updated_at: 100, + user: { id: true, login: 100, type: 40 }, + in_reply_to_id: true, + path: 1_000, + line: true, + original_line: true, + diff_hunk: 8_000, + }, + pull_request: { + id: true, + number: true, + title: 1_000, + body: 24_000, + html_url: 2_000, + state: 40, + draft: true, + merged: true, + merged_at: 100, + updated_at: 100, + user: { id: true, login: 100, type: 40 }, + head: { sha: 64, ref: 300 }, + base: { sha: 64, ref: 300 }, + labels: [{ name: 100 }], + }, + repositories_added: [{ id: true, full_name: 300, name: 100 }], + repositories_removed: [{ id: true, full_name: 300, name: 100 }], + repositories: [{ id: true, full_name: 300, name: 100 }], + }; + function select(value: unknown, shape: unknown): unknown { + if (shape === true) + return typeof value === "boolean" || + (typeof value === "number" && Number.isSafeInteger(value)) || + (typeof value === "string" && /^[1-9][0-9]{0,30}$/.test(value)) + ? value + : undefined; + if (typeof shape === "number") + return value === null + ? null + : typeof value === "string" + ? value.slice(0, shape) + : undefined; + if (Array.isArray(shape)) + return Array.isArray(value) + ? value + .slice(0, 1_000) + .map((item) => select(item, shape[0])) + .filter((item) => item !== undefined) + : undefined; + if (!value || typeof value !== "object" || Array.isArray(value)) + return undefined; + const source = record(value); + const output: Record = {}; + for (const [key, child] of Object.entries(record(shape))) { + const result = select(source[key], child); + if (result !== undefined) output[key] = result; + } + return output; + } + return record(select(body, schemas)); +} diff --git a/server/src/services/chat-github-management.ts b/server/src/services/chat-github-management.ts index 0717046e273..4bd1f38f0d2 100644 --- a/server/src/services/chat-github-management.ts +++ b/server/src/services/chat-github-management.ts @@ -249,7 +249,16 @@ export function githubChatManagementService(db: Db, fetchImpl = fetch) { throw conflict( "The connected GitHub account changed. Check and confirm its identity again.", ); + await linkObservedIdentity(endpointId, userId, verified); + return verified; + } + async function linkObservedIdentity(endpointId: string, userId: string, verified: { githubUserId: string; login: string; avatarUrl?: string | null; grantId?: string; connectionId?: string }) { + const bot = await endpoint(endpointId); + await member(bot.companyId, userId); + if (!bot.providerAccountId) throw conflict("Verify the App installation first"); await db.transaction(async (tx) => { + const [membership] = await tx.select().from(companyMemberships).where(and(eq(companyMemberships.companyId, bot.companyId), eq(companyMemberships.principalType, "user"), eq(companyMemberships.principalId, userId))).for("update"); + if (!membership || membership.status !== "active" || membership.membershipRole === "viewer") throw forbidden("An active company manager is required"); const [principal] = await tx .insert(chatExternalPrincipals) .values({ @@ -288,6 +297,7 @@ export function githubChatManagementService(db: Db, fetchImpl = fetch) { throw conflict( "This GitHub account is already linked to another Paperclip member", ); + if (verified.grantId) { const [grant] = await tx .select() .from(connectionGrants) @@ -302,6 +312,7 @@ export function githubChatManagementService(db: Db, fetchImpl = fetch) { .for("update"); if (!grant) throw forbidden("Your personal GitHub connection was revoked"); + } const values = { companyId: bot.companyId, endpointId, @@ -330,14 +341,14 @@ export function githubChatManagementService(db: Db, fetchImpl = fetch) { entityId: bot.connectionId, details: { endpointId, - connectionId, + connectionId: verified.connectionId ?? bot.connectionId, githubUserId: verified.githubUserId, }, }); }); - return verified; } return { + linkObservedIdentity, verification: async (endpointId: string) => { const bot = await endpoint(endpointId); const checks: Array<{ @@ -588,7 +599,7 @@ export function githubChatManagementService(db: Db, fetchImpl = fetch) { ? `Repair tool policy for: ${denied.join(", ")}.` : "Assign the bot's GitHub tools to this agent, then verify again.", }); - return { checks, ready: checks.every((check) => check.ok) }; + return { checks, ready: checks.every((check) => check.ok), connectionReady: checks.filter(check => !["runtime", "isolation"].includes(check.key)).every(check => check.ok) }; }, configuration, saveConfiguration, diff --git a/server/src/services/chat-github-registration.ts b/server/src/services/chat-github-registration.ts index c544c03eb0c..e11a1fd09ae 100644 --- a/server/src/services/chat-github-registration.ts +++ b/server/src/services/chat-github-registration.ts @@ -7,6 +7,7 @@ import { companyMemberships, type Db, } from "@paperclipai/db"; +import type { GitHubAppRegistrationInput } from "@paperclipai/shared"; import { badRequest, conflict, forbidden, notFound } from "../errors.js"; import { githubBotRequest } from "./chat-github-client.js"; import { logActivity } from "./activity-log.js"; @@ -47,11 +48,30 @@ export function githubChatRegistrationService( privateKey: string; webhookSecret: string; slug: string; + clientId?: string; + clientSecret?: string; }, ) => Promise; }, ) { - async function start(endpointId: string, userId: string, name: string) { + async function start( + endpointId: string, + userId: string, + input: string | GitHubAppRegistrationInput, + ) { + const { + name, + ownerType = "personal", + ownerLogin, + } = typeof input === "string" ? { name: input } : input; + if (ownerType !== "personal" && ownerType !== "organization") + throw badRequest("Choose a GitHub account type"); + if ( + ownerType === "organization" && + (!ownerLogin || + !/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/.test(ownerLogin)) + ) + throw badRequest("Enter the GitHub organization name"); const origin = httpsOrigin(options.publicOrigin()); const ingress = httpsOrigin(options.webhookOrigin()); if (!name.trim() || name.length > 34) @@ -111,6 +131,14 @@ export function githubChatRegistrationService( userId, stateHash: digest(state), trustedOrigin: origin, + ownerType, + ownerLogin, + appName: name.trim(), + handoff: { + cloudId: "", + redemptionId: randomBytes(32).toString("base64url"), + returnState: state, + }, expiresAt, }); await logActivity(tx as unknown as Db, { @@ -123,23 +151,56 @@ export function githubChatRegistrationService( details: { endpointId, expiresAt: expiresAt.toISOString() }, }); }); - const resume = `${origin}/${company!.issuePrefix}/apps/chat/connect?provider=github&resume=${endpointId}`; - const callback = new URL("/api/chat-github/manifest/callback", origin); - const registrationUrl = new URL("https://github.com/settings/apps/new"); + return manifest(endpoint, company!.issuePrefix, { + trustedOrigin: origin, + state, + ownerType, + ownerLogin, + name: name.trim(), + expiresAt, + }); + } + function manifest( + endpoint: typeof chatEndpoints.$inferSelect, + prefix: string, + values: { + trustedOrigin: string; + state: string; + ownerType: "personal" | "organization"; + ownerLogin?: string; + name: string; + expiresAt: Date; + }, + ) { + const { + trustedOrigin: origin, + state, + ownerType, + ownerLogin, + name, + expiresAt, + } = values; + const ingress = httpsOrigin(options.webhookOrigin()); + const registrationUrl = new URL( + ownerType === "organization" + ? `https://github.com/organizations/${encodeURIComponent(ownerLogin!)}/settings/apps/new` + : "https://github.com/settings/apps/new", + ); registrationUrl.searchParams.set("state", state); return { expiresAt: expiresAt.toISOString(), registrationUrl: registrationUrl.toString(), manifest: { - name: name.trim(), + name, url: origin, public: false, hook_attributes: { url: `${ingress}/api/chat-webhooks/${endpoint.publicId}/github`, active: true, }, - redirect_url: callback.toString(), - setup_url: resume, + redirect_url: `${origin}/api/chat-github/manifest/callback`, + setup_url: `${origin}/${prefix}/apps/chat/connect?provider=github&resume=${endpoint.id}`, + callback_urls: [`${origin}/api/chat-github/identity/callback`], setup_on_update: true, default_permissions: { contents: "read", @@ -156,6 +217,49 @@ export function githubChatRegistrationService( }, }; } + async function resumeRegistration(endpointId: string, userId: string) { + const [session] = await db + .select() + .from(chatGitHubRegistrations) + .where( + and( + eq(chatGitHubRegistrations.endpointId, endpointId), + eq(chatGitHubRegistrations.status, "pending"), + ), + ) + .limit(1); + if ( + !session?.handoff?.returnState || + session.handoff.cloudId || + session.userId !== userId || + session.expiresAt <= new Date() || + session.trustedOrigin !== httpsOrigin(options.publicOrigin()) + ) + throw conflict("Resume or recover the existing App registration"); + const [endpoint] = await db + .select() + .from(chatEndpoints) + .where( + and( + eq(chatEndpoints.id, endpointId), + eq(chatEndpoints.companyId, session.companyId), + ), + ); + const [company] = await db + .select({ prefix: companies.issuePrefix }) + .from(companies) + .where(eq(companies.id, session.companyId)); + if (!endpoint || !company || !session.appName) + throw notFound("GitHub registration not found"); + return manifest(endpoint, company.prefix, { + trustedOrigin: session.trustedOrigin, + state: session.handoff.returnState, + ownerType: session.ownerType, + ownerLogin: session.ownerLogin ?? undefined, + name: session.appName, + expiresAt: session.expiresAt, + }); + } async function complete(state: string, code: string) { if ( !/^[A-Za-z0-9_-]{43}$/.test(state) || @@ -164,7 +268,7 @@ export function githubChatRegistrationService( throw badRequest("Invalid GitHub registration return"); const origin = httpsOrigin(options.publicOrigin()); // Claim before exchange. A timeout is intentionally not retried with a - // new exchange: the recovery UI must start a new single-use registration. + // new exchange: recovery must reconnect the App already created on GitHub. const [session] = await db .update(chatGitHubRegistrations) .set({ status: "exchanging", consumedAt: new Date() }) @@ -179,7 +283,7 @@ export function githubChatRegistrationService( .returning(); if (!session) throw conflict( - "This GitHub registration expired or was already used. Resume setup and start registration again.", + "This GitHub registration expired or was already used. Resume setup to recover the existing App.", ); try { const [member] = await db @@ -200,6 +304,9 @@ export function githubChatRegistrationService( pem?: string; webhook_secret?: string; slug?: string; + client_id?: string; + client_secret?: string; + owner?: { login?: string; type?: string }; }>( options.fetch ?? fetch, null, @@ -217,11 +324,25 @@ export function githubChatRegistrationService( throw badRequest( "GitHub registration returned incomplete App credentials", ); + if ( + session.ownerType === "personal" && + app.owner?.type && + app.owner.type !== "User" + ) + throw badRequest("GitHub returned an App owned by a different account"); + if ( + session.ownerType === "organization" && + (app.owner?.type !== "Organization" || + app.owner?.login?.toLowerCase() !== session.ownerLogin?.toLowerCase()) + ) + throw badRequest("GitHub returned an App owned by a different account"); await options.storeApp(session.endpointId, session.userId, { appId: String(app.id), privateKey: app.pem, webhookSecret: app.webhook_secret, slug: app.slug, + clientId: app.client_id, + clientSecret: app.client_secret, }); await db .update(chatGitHubRegistrations) @@ -240,5 +361,5 @@ export function githubChatRegistrationService( throw error; } } - return { start, complete }; + return { start, complete, resumeRegistration }; } diff --git a/server/src/services/chat-github-wizard.ts b/server/src/services/chat-github-wizard.ts new file mode 100644 index 00000000000..94de7f23051 --- /dev/null +++ b/server/src/services/chat-github-wizard.ts @@ -0,0 +1,1146 @@ +import { createHash, randomBytes } from "node:crypto"; +import { and, desc, eq, sql } from "drizzle-orm"; +import { + chatEndpoints, + chatGitHubRegistrations, + chatExternalPrincipals, + chatIdentityLinks, + companies, + companyMemberships, + toolConnections, + type Db, +} from "@paperclipai/db"; +import type { + GitHubAppRegistrationInput, + GitHubAppWizardState, +} from "@paperclipai/shared"; +import { badRequest, conflict, forbidden, notFound } from "../errors.js"; +import { logActivity } from "./activity-log.js"; +import { + githubBotCredentials, + githubBotRequest, +} from "./chat-github-client.js"; +import { + resyncGitHubAppWebhook, + listGitHubAppWebhookDeliveries, + requestGitHubAppWebhookRedelivery, +} from "./chat-github-webhook-config.js"; +import { githubChatManagementService } from "./chat-github-management.js"; +import { + createPaperclipCloudConnector, + paperclipCloudConnectorConfigFromEnv, + type PaperclipCloudConnector, +} from "./paperclip-cloud-connector.js"; + +const hash = (value: string) => + createHash("sha256").update(value).digest("hex"); +const nonce = () => randomBytes(32).toString("base64url"); +type Endpoint = typeof chatEndpoints.$inferSelect; +type Resource = { id: string; enabled: boolean; availability: string }; +/** Manager-only orchestration. Provider callbacks resume this same durable draft. */ +export function githubChatWizardService( + db: Db, + options: { + origin: () => string | null; + fetch?: typeof fetch; + connector?: () => PaperclipCloudConnector | null; + startDirect: ( + endpointId: string, + userId: string, + input: GitHubAppRegistrationInput, + ) => Promise<{ + registrationUrl: string; + manifest: Record; + expiresAt: string; + }>; + resumeDirect?: ( + endpointId: string, + userId: string, + ) => Promise<{ + registrationUrl: string; + manifest: Record; + expiresAt: string; + }>; + completeDirect?: (state: string, code: string) => Promise; + storeApp: ( + endpointId: string, + userId: string, + credentials: Record, + ) => Promise; + storeCredentials: ( + endpointId: string, + userId: string, + credentials: Record, + ) => Promise; + refreshRepositories: ( + endpointId: string, + userId: string, + ) => Promise; + resources: (endpointId: string) => Promise; + replaceResources: ( + endpointId: string, + updates: Array<{ id: string; enabled: boolean }>, + userId: string, + ) => Promise; + configure: (endpointId: string, userId: string) => Promise; + finish: (endpointId: string, userId: string) => Promise; + }, +) { + const fetchImpl = options.fetch ?? fetch; + const management = githubChatManagementService(db, fetchImpl); + const connector = + options.connector ?? + (() => { + const config = paperclipCloudConnectorConfigFromEnv(); + return config + ? createPaperclipCloudConnector({ config, request: fetchImpl }) + : null; + }); + const pending = new Map>(); + async function endpoint(id: string, userId: string): Promise { + const [bot] = await db + .select() + .from(chatEndpoints) + .where( + and(eq(chatEndpoints.id, id), eq(chatEndpoints.provider, "github")), + ); + if (!bot || bot.status === "archived") + throw notFound("GitHub bot not found"); + const [member] = await db + .select() + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, bot.companyId), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.principalId, userId), + eq(companyMemberships.status, "active"), + ), + ); + if (!member || member.membershipRole === "viewer") + throw forbidden("An active company manager is required"); + return bot; + } + async function registration(bot: Endpoint) { + const [session] = await db + .select() + .from(chatGitHubRegistrations) + .where( + and( + eq(chatGitHubRegistrations.companyId, bot.companyId), + eq(chatGitHubRegistrations.endpointId, bot.id), + ), + ) + .orderBy(desc(chatGitHubRegistrations.createdAt)) + .limit(1); + return session; + } + async function setup( + bot: Endpoint, + values: Partial>, + ) { + await db + .update(chatEndpoints) + .set({ + setup: sql`jsonb_set(${chatEndpoints.setup}, '{github}', coalesce(${chatEndpoints.setup}->'github','{}'::jsonb) || ${JSON.stringify(values)}::jsonb)`, + updatedAt: new Date(), + }) + .where( + and( + eq(chatEndpoints.id, bot.id), + eq(chatEndpoints.companyId, bot.companyId), + ), + ); + } + async function resume(bot: Endpoint) { + const [company] = await db + .select({ prefix: companies.issuePrefix }) + .from(companies) + .where(eq(companies.id, bot.companyId)); + return `${options.origin()}/${company!.prefix}/apps/chat/connect?provider=github&purpose=chat&resume=${bot.id}`; + } + function origin() { + const value = options.origin(); + if (!value) + throw badRequest( + "Configure this instance's browser address before connecting GitHub", + ); + const url = new URL(value); + if ( + url.username || + url.password || + url.search || + url.hash || + url.pathname !== "/" || + (url.protocol !== "https:" && + !( + url.protocol === "http:" && + ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname) + )) + ) + throw badRequest("Use an enrolled HTTPS or local browser address"); + return url.origin; + } + async function start( + id: string, + userId: string, + input: GitHubAppRegistrationInput, + ) { + const bot = await endpoint(id, userId); + if (bot.botExternalId) + throw conflict( + "This draft already has an App. Continue its installation or recover its credentials.", + ); + const prior = await registration(bot); + if (prior && ["pending", "exchanging", "failed"].includes(prior.status)) { + if (prior.userId !== userId) + throw conflict("The configuring member must resume this registration"); + if (prior.status !== "pending" || prior.expiresAt <= new Date()) + throw conflict( + "Recover the existing App from GitHub settings; its registration cannot safely be repeated.", + ); + if (prior.handoff) return advance(id, userId); + // The direct flow's hashed state cannot be reconstructed. Do not create a duplicate App. + throw conflict( + "An App registration is already pending. Complete its open GitHub page, or use existing-App recovery.", + ); + } + await saveDraft(id, userId, input); + const cloud = connector(); + if (!cloud) { + if (origin().startsWith("https://")) + return { + endpointId: id, + state: "create", + registration: await options.startDirect(id, userId, input), + } satisfies GitHubAppWizardState; + return { + endpointId: id, + state: "enrollment", + message: + "Use Paperclip Cloud to receive GitHub events for this local instance.", + } satisfies GitHubAppWizardState; + } + if (!(await cloud.githubAppsAvailable())) + throw conflict( + "GitHub App setup is not enabled on your Paperclip Cloud connector yet.", + ); + const trustedOrigin = origin(); + const session = await db.transaction(async (tx) => { + const [locked] = await tx + .select() + .from(chatEndpoints) + .where(eq(chatEndpoints.id, id)) + .for("update"); + if (!locked || locked.botExternalId || locked.status === "archived") + throw conflict("This draft changed. Resume its existing App."); + const [prior] = await tx + .select() + .from(chatGitHubRegistrations) + .where(eq(chatGitHubRegistrations.endpointId, id)) + .orderBy(desc(chatGitHubRegistrations.createdAt)) + .limit(1); + if (prior) { + if ( + prior.userId !== userId || + !prior.handoff?.cloudId || + prior.status !== "pending" || + prior.expiresAt <= new Date() + ) + throw conflict( + "Resume or recover the App already registered for this draft.", + ); + return prior; + } + const returnState = nonce(); + const [created] = await tx + .insert(chatGitHubRegistrations) + .values({ + companyId: bot.companyId, + endpointId: id, + userId, + stateHash: hash(returnState), + trustedOrigin, + ownerType: input.ownerType, + ownerLogin: input.ownerLogin, + appName: input.name, + expiresAt: new Date(Date.now() + 30 * 60_000), + handoff: { cloudId: nonce(), returnState, redemptionId: nonce() }, + }) + .returning(); + await logActivity(tx as unknown as Db, { + companyId: bot.companyId, + actorType: "user", + actorId: userId, + action: "chat_github.registration_started", + entityType: "tool_connection", + entityId: bot.connectionId, + details: { + endpointId: id, + registrationId: created!.id, + ownerType: input.ownerType, + transport: "paperclip_cloud", + }, + }); + return created!; + }); + await setup(bot, { + stage: "connect", + ownerType: session.ownerType, + ownerLogin: session.ownerLogin ?? undefined, + appName: session.appName ?? undefined, + cloudRegistrationId: session.handoff!.cloudId, + registrationStatus: "pending", + }); + const state = await cloud.githubApp({ + subject: userId, + companyId: bot.companyId, + binding: cloudStart(bot, session), + }); + if (!state.registrationUrl || !state.manifest) + throw conflict( + "GitHub App registration could not be prepared. Resume the existing draft.", + ); + return { + endpointId: id, + state: "create", + registration: { + registrationUrl: state.registrationUrl, + manifest: state.manifest, + expiresAt: state.expiresAt, + }, + } satisfies GitHubAppWizardState; + } + function cloudStart( + bot: Endpoint, + session: typeof chatGitHubRegistrations.$inferSelect, + ) { + return { + action: "start", + id: session.handoff!.cloudId, + endpointId: bot.id, + connectionId: bot.connectionId, + agentId: bot.assignedAgentId, + name: session.appName!, + ownerType: session.ownerType, + ...(session.ownerLogin ? { ownerLogin: session.ownerLogin } : {}), + returnUri: `${session.trustedOrigin}/api/chat-github/cloud/callback`, + returnState: session.handoff!.returnState, + }; + } + async function saveDraft( + id: string, + userId: string, + input: GitHubAppRegistrationInput, + ) { + const bot = await endpoint(id, userId); + await db.transaction(async (tx) => { + await tx + .select() + .from(chatEndpoints) + .where(eq(chatEndpoints.id, id)) + .for("update"); + const [session] = await tx + .select() + .from(chatGitHubRegistrations) + .where(eq(chatGitHubRegistrations.endpointId, id)) + .limit(1); + if (bot.botExternalId || session) + throw conflict( + "Resume the existing App registration before changing its account.", + ); + await tx + .update(chatEndpoints) + .set({ + setup: sql`jsonb_set(${chatEndpoints.setup}, '{github}', coalesce(${chatEndpoints.setup}->'github','{}'::jsonb) || ${JSON.stringify({ ownerType: input.ownerType, ownerLogin: input.ownerType === "organization" ? input.ownerLogin : null, appName: input.name })}::jsonb)`, + updatedAt: new Date(), + }) + .where(eq(chatEndpoints.id, id)); + await logActivity(tx as unknown as Db, { + companyId: bot.companyId, + actorType: "user", + actorId: userId, + action: "chat_github.draft_saved", + entityType: "tool_connection", + entityId: bot.connectionId, + details: { endpointId: id }, + }); + }); + return { saved: true as const }; + } + async function repairCloud(id: string, userId: string) { + const bot = await endpoint(id, userId), + session = await registration(bot), + cloud = connector(); + if (!session?.handoff?.cloudId) return; + if (session.userId !== userId) + throw forbidden("The configuring member must recover this registration"); + if (!cloud) + throw conflict( + "Restore Paperclip Cloud enrollment before reconnecting this App", + ); + const binding = { subject: userId, companyId: bot.companyId }; + const state = await cloud.githubApp({ + ...binding, + binding: cloudStart(bot, session), + }); + const { credentials, appJwt } = await githubBotCredentials( + db, + bot.companyId, + id, + ); + if (!credentials.webhookSecret) + throw conflict("Recover this App's webhook secret before continuing"); + if (state.appId && state.appId !== credentials.appId) + throw conflict("Recovery must use the original GitHub App"); + if (!state.webhookUrl) + throw conflict( + "Paperclip Cloud does not support recovering this App yet", + ); + await logActivity(db, { + companyId: bot.companyId, + actorType: "user", + actorId: userId, + action: "chat_github.webhook_repair_started", + entityType: "tool_connection", + entityId: bot.connectionId, + details: { endpointId: id, appId: credentials.appId }, + }); + await resyncGitHubAppWebhook({ + fetch: fetchImpl, + appToken: appJwt, + webhookUrl: state.webhookUrl, + webhookSecret: credentials.webhookSecret, + }); + const repaired = await cloud.githubApp({ + ...binding, + binding: { + action: "repair", + id: session.handoff.cloudId, + appToken: appJwt, + webhookSecret: credentials.webhookSecret, + }, + }); + if (repaired.appId !== credentials.appId) + throw conflict("GitHub App recovery binding did not match"); + await db + .update(chatGitHubRegistrations) + .set({ + handoff: sql`jsonb_set(${chatGitHubRegistrations.handoff}, '{webhookSecretHash}', ${JSON.stringify(hash(credentials.webhookSecret))}::jsonb)`, + status: "completed", + }) + .where(eq(chatGitHubRegistrations.id, session.id)); + await setup(bot, { + cloudRegistrationId: session.handoff.cloudId, + registrationStatus: "completed", + }); + await db + .update(chatEndpoints) + .set({ + setup: sql`jsonb_set(${chatEndpoints.setup}, '{webhookVerifiedAt}', 'null'::jsonb)`, + updatedAt: new Date(), + }) + .where(eq(chatEndpoints.id, id)); + try { + const history = await listGitHubAppWebhookDeliveries({ + fetch: fetchImpl, + appToken: appJwt, + }); + const ping = history.deliveries.find( + (delivery) => delivery.event === "ping", + ); + if (ping) + await requestGitHubAppWebhookRedelivery({ + fetch: fetchImpl, + appToken: appJwt, + deliveryId: ping.id, + }); + } catch { + /* Keep verification pending until a fresh provider-signed delivery arrives. */ + } + await logActivity(db, { + companyId: bot.companyId, + actorType: "user", + actorId: userId, + action: "chat_github.webhook_repaired", + entityType: "tool_connection", + entityId: bot.connectionId, + details: { endpointId: id, appId: credentials.appId }, + }); + } + async function advance( + id: string, + userId: string, + ): Promise { + // Multiple open tabs for this manager share one operation, not concurrent vault writes. + const key = `${id}:${userId}`; + if (pending.has(key)) return pending.get(key)!; + const work = advanceOnce(id, userId); + pending.set(key, work); + try { + return await work; + } finally { + if (pending.get(key) === work) pending.delete(key); + } + } + async function advanceOnce( + id: string, + userId: string, + ): Promise { + let bot = await endpoint(id, userId); + if (["paused", "revoked"].includes(bot.status)) + return { + endpointId: id, + state: "recovery", + message: "Reconnect this App from connection settings.", + }; + const session = await registration(bot), + cloud = connector(); + if (session?.handoff?.cloudId) { + if (session.userId !== userId) + throw forbidden( + "The configuring member must complete this registration", + ); + if (!cloud) + return { + endpointId: id, + state: "enrollment", + message: + "Restore this instance's Paperclip Cloud enrollment to resume.", + }; + const [connection] = await db + .select({ refs: toolConnections.credentialSecretRefs }) + .from(toolConnections) + .where( + and( + eq(toolConnections.id, bot.connectionId), + eq(toolConnections.companyId, bot.companyId), + ), + ); + const vaulted = + !!bot.botExternalId && + [ + "appId", + "privateKey", + "webhookSecret", + "clientId", + "clientSecret", + ].every((key) => + connection?.refs.some( + (ref) => ref.configPath === `credentials.${key}`, + ), + ); + const hasAppCredentials = + !!bot.botExternalId && + ["appId", "privateKey", "webhookSecret"].every((key) => + connection?.refs.some( + (ref) => ref.configPath === `credentials.${key}`, + ), + ); + const binding = { subject: userId, companyId: bot.companyId }; + let state = await cloud.githubApp({ + ...binding, + binding: cloudStart(bot, session), + }); + if (hasAppCredentials) { + const { credentials } = await githubBotCredentials( + db, + bot.companyId, + id, + ); + if ( + ["pending", "exchanging", "failed"].includes(state.status) || + (session.handoff.webhookSecretHash && + session.handoff.webhookSecretHash !== + hash(credentials.webhookSecret ?? "")) + ) { + await repairCloud(id, userId); + state = await cloud.githubApp({ + ...binding, + binding: { action: "status", id: session.handoff.cloudId }, + }); + bot = await endpoint(id, userId); + } + } + if (state.status === "failed" || state.status === "exchanging") + return { + endpointId: id, + state: "recovery", + message: + state.status === "exchanging" + ? "GitHub registration is still being processed. If it was interrupted, recover the App already created on GitHub." + : "Recover the App already created on GitHub. Registration was interrupted and cannot safely be repeated.", + }; + if (state.status === "pending") { + if (Date.parse(state.expiresAt) <= Date.now()) + return { + endpointId: id, + state: "recovery", + message: + "Registration expired. Check GitHub for the App before using existing-App recovery.", + }; + return { + endpointId: id, + state: "create", + registration: + state.registrationUrl && state.manifest + ? { + registrationUrl: state.registrationUrl, + manifest: state.manifest, + expiresAt: state.expiresAt, + } + : undefined, + }; + } + if (state.claimExpired && !vaulted) + return { + endpointId: id, + state: "recovery", + message: + "Credential delivery expired. Recover the App already created on GitHub; do not create another App.", + }; + if (state.claimId && !vaulted) { + const material = await cloud.claimGitHubApp({ + ...binding, + claimId: state.claimId, + redemptionId: session.handoff.redemptionId, + }); + if ( + material.kind !== "github_app" || + material.registrationId !== session.handoff.cloudId || + material.endpointId !== bot.id || + material.connectionId !== bot.connectionId || + material.agentId !== bot.assignedAgentId + ) + throw forbidden( + "GitHub App credential binding did not match this draft", + ); + const credentials: Record = {}; + for (const key of [ + "appId", + "privateKey", + "webhookSecret", + "clientId", + "clientSecret", + ]) { + if (typeof material[key] !== "string" || !material[key]) + throw conflict("GitHub returned incomplete App credentials"); + credentials[key] = material[key] as string; + } + await options.storeApp(id, userId, credentials); + } + bot = await endpoint(id, userId); + if (!bot.botExternalId || bot.botExternalId !== state.appId) + throw conflict("Recover this App's credentials before continuing."); + await cloud.githubApp({ + ...binding, + binding: { action: "stored", id: session.handoff.cloudId }, + }); + const { credentials: storedCredentials } = await githubBotCredentials( + db, + bot.companyId, + id, + ); + if (!session.handoff.webhookSecretHash) + await db + .update(chatGitHubRegistrations) + .set({ + handoff: sql`jsonb_set(${chatGitHubRegistrations.handoff}, '{webhookSecretHash}', ${JSON.stringify(hash(storedCredentials.webhookSecret))}::jsonb)`, + }) + .where(eq(chatGitHubRegistrations.id, session.id)); + if (session.status !== "completed") + await db + .update(chatGitHubRegistrations) + .set({ status: "completed" }) + .where(eq(chatGitHubRegistrations.id, session.id)); + await setup(bot, { + cloudRegistrationId: session.handoff.cloudId, + ownerType: session.ownerType, + ownerLogin: state.ownerLogin ?? session.ownerLogin ?? undefined, + appName: session.appName ?? undefined, + registrationStatus: "completed", + }); + if (state.signedDeliveryAt && !bot.setup.webhookVerifiedAt) { + await db + .update(chatEndpoints) + .set({ + setup: sql`jsonb_set(${chatEndpoints.setup}, '{webhookVerifiedAt}', ${JSON.stringify(state.signedDeliveryAt)}::jsonb)`, + updatedAt: new Date(), + }) + .where(eq(chatEndpoints.id, id)); + } + } + bot = await endpoint(id, userId); + if (!bot.botExternalId) { + if (session && !session.handoff?.cloudId) { + if ( + session.status === "pending" && + session.expiresAt > new Date() && + session.handoff?.returnState && + options.resumeDirect + ) + return { + endpointId: id, + state: "create", + registration: await options.resumeDirect(id, userId), + }; + return { + endpointId: id, + state: "recovery", + message: + "Resume the open GitHub registration page or recover the existing App from GitHub settings.", + }; + } + return { endpointId: id, state: "create" }; + } + if (!bot.providerAccountId || bot.setup.github?.stage === "install") { + try { + await options.refreshRepositories(id, userId); + } catch (error) { + return { + endpointId: id, + state: "install", + installationUrl: bot.setup.github?.installationUrl, + message: + error instanceof Error + ? error.message + : "Waiting for installation or administrator approval on GitHub.", + }; + } + } + bot = await endpoint(id, userId); + const saved = await management.configuration(id, userId); + if (!bot.setup.github?.initialRepositoriesImported) { + const resources = await options.resources(id); + // Existing saved selections/behavior belong to the user. Only a fresh registration imports GitHub's choices. + if ( + session && + bot.status !== "active" && + saved.revision === 0 && + !resources.some((resource) => resource.enabled) + ) { + await options.replaceResources( + id, + resources + .filter((resource) => resource.availability === "available") + .map((resource) => ({ id: resource.id, enabled: true })), + userId, + ); + } + if (session && saved.revision === 0 && bot.status !== "active") + await management.saveConfiguration( + id, + { + expectedRevision: 0, + configuration: { + ...saved.configuration, + toolsEnabled: true, + defaults: { + ...saved.configuration.defaults, + invocation: "mentions_only", + events: [], + }, + }, + }, + userId, + ); + await setup(bot, { initialRepositoriesImported: true }); + } + if (bot.status !== "active" && bot.status !== "verifying") + await options.configure(id, userId); + bot = await endpoint(id, userId); + let linked = await linkedIdentity(bot, userId); + if (!linked) { + const [prior] = await db + .select({ + githubUserId: chatExternalPrincipals.externalId, + login: chatExternalPrincipals.handle, + }) + .from(chatIdentityLinks) + .innerJoin( + chatExternalPrincipals, + eq(chatExternalPrincipals.id, chatIdentityLinks.principalId), + ) + .where( + and( + eq(chatIdentityLinks.companyId, bot.companyId), + eq(chatIdentityLinks.paperclipUserId, userId), + eq(chatIdentityLinks.status, "linked"), + eq(chatExternalPrincipals.provider, "github"), + ), + ) + .limit(1); + if (prior?.login) { + await management.linkObservedIdentity(id, userId, { + githubUserId: prior.githubUserId, + login: prior.login, + avatarUrl: null, + }); + linked = true; + } + } + if (!linked) { + const { credentials } = await githubBotCredentials(db, bot.companyId, id); + return { + endpointId: id, + state: "identity", + identityMethod: + session && credentials.clientId && credentials.clientSecret + ? "dedicated_app" + : "existing_connection", + identity: + session?.handoff?.identity && + Date.parse(session.handoff.identity.expiresAt) > Date.now() + ? session.handoff.identity + : undefined, + }; + } + const verification = await management.verification(id); + const connectionChecks = verification.checks.filter( + (check) => !["runtime", "isolation"].includes(check.key), + ); + const runtimeChecks = verification.checks.filter((check) => + ["runtime", "isolation"].includes(check.key), + ); + const connectionReady = connectionChecks.every((check) => check.ok); + if (connectionReady && bot.status !== "active") + await options.finish(id, userId); + return { + endpointId: id, + state: connectionReady ? "connected" : "verify", + identityLinked: true, + verification: { ready: connectionReady, checks: connectionChecks }, + runtimeChecks, + }; + } + async function linkedIdentity(bot: Endpoint, userId: string) { + const [link] = await db + .select({ id: chatIdentityLinks.id }) + .from(chatIdentityLinks) + .where( + and( + eq(chatIdentityLinks.companyId, bot.companyId), + eq(chatIdentityLinks.endpointId, bot.id), + eq(chatIdentityLinks.paperclipUserId, userId), + eq(chatIdentityLinks.status, "linked"), + ), + ) + .limit(1); + return !!link; + } + async function startIdentity(id: string, userId: string) { + const bot = await endpoint(id, userId), + session = await registration(bot); + if (!session || session.userId !== userId) + throw forbidden("The configuring member must link their GitHub account"); + const credentials = await githubBotCredentials(db, bot.companyId, id); + if ( + !credentials.credentials.clientId || + !credentials.credentials.clientSecret + ) + throw conflict( + "This existing App needs a personal GitHub connection to verify your account.", + ); + const verifier = nonce(), + state = nonce(); + await options.storeCredentials(id, userId, { + identityCodeVerifier: verifier, + }); + await db + .update(chatGitHubRegistrations) + .set({ + handoff: { + ...(session.handoff ?? { + cloudId: "", + returnState: "", + redemptionId: nonce(), + }), + identityStateHash: hash(state), + identityExpiresAt: new Date(Date.now() + 10 * 60_000).toISOString(), + identityRedemptionId: nonce(), + identity: undefined, + }, + }) + .where(eq(chatGitHubRegistrations.id, session.id)); + const challenge = createHash("sha256").update(verifier).digest("base64url"); + if (session.handoff?.cloudId) { + const cloud = connector(); + if (!cloud) throw conflict("Restore Paperclip Cloud enrollment"); + const result = await cloud.githubApp({ + subject: userId, + companyId: bot.companyId, + binding: { + action: "identity", + id: session.handoff.cloudId, + codeChallenge: challenge, + returnState: state, + }, + }); + if (!result.authorizationUrl) + throw conflict("GitHub account authorization is unavailable"); + return { authorizationUrl: result.authorizationUrl }; + } + const url = new URL("https://github.com/login/oauth/authorize"); + url.searchParams.set("client_id", credentials.credentials.clientId); + url.searchParams.set( + "redirect_uri", + `${origin()}/api/chat-github/identity/callback`, + ); + url.searchParams.set("state", state); + url.searchParams.set("code_challenge", challenge); + url.searchParams.set("code_challenge_method", "S256"); + return { authorizationUrl: url.toString() }; + } + async function completeIdentity( + session: typeof chatGitHubRegistrations.$inferSelect, + code: string, + redirectUri: string, + ) { + const bot = await endpoint(session.endpointId, session.userId); + if ( + !session.handoff?.identityExpiresAt || + Date.parse(session.handoff.identityExpiresAt) <= Date.now() + ) + throw conflict( + "GitHub identity authorization expired. Connect your account again.", + ); + const [consumed] = await db + .update(chatGitHubRegistrations) + .set({ + handoff: sql`${chatGitHubRegistrations.handoff} - 'identityStateHash'`, + }) + .where( + and( + eq(chatGitHubRegistrations.id, session.id), + sql`${chatGitHubRegistrations.handoff}->>'identityStateHash' = ${session.handoff?.identityStateHash ?? ""}`, + ), + ) + .returning(); + if (!consumed) return resume(bot); + const material = await githubBotCredentials(db, bot.companyId, bot.id); + const response = await fetchImpl( + "https://github.com/login/oauth/access_token", + { + method: "POST", + redirect: "error", + signal: AbortSignal.timeout(25_000), + headers: { + accept: "application/json", + "content-type": "application/json", + }, + body: JSON.stringify({ + client_id: material.credentials.clientId, + client_secret: material.credentials.clientSecret, + code, + redirect_uri: redirectUri, + code_verifier: material.credentials.identityCodeVerifier, + }), + }, + ); + if (!response.ok) { + await response.body?.cancel(); + throw conflict( + "GitHub authorization failed. Connect your account again.", + ); + } + const token = (await response.json()) as { access_token?: string }; + if (!token.access_token) + throw conflict( + "GitHub authorization was denied. Connect your account again.", + ); + const user = await githubBotRequest<{ + id: number; + login: string; + avatar_url?: string; + }>(fetchImpl, token.access_token, "/user"); + let allowed = false; + for (let page = 1; page <= 100; page++) { + const result = await githubBotRequest<{ + installations: Array<{ id: number; app_id: number }>; + }>( + fetchImpl, + token.access_token, + `/user/installations?per_page=100&page=${page}`, + ); + allowed = result.installations.some( + (installation) => + String(installation.app_id) === bot.botExternalId && + String(installation.id) === material.credentials.installationId, + ); + if (allowed || result.installations.length < 100) break; + } + if (!allowed) + throw forbidden( + "Your GitHub account cannot access this App installation", + ); + if ( + !Number.isSafeInteger(user.id) || + user.id <= 0 || + !/^[A-Za-z0-9-]{1,39}$/.test(user.login) + ) + throw conflict("GitHub returned an invalid account"); + // OAuth token remains transient; only an expiring observed identity is saved. + await db + .update(chatGitHubRegistrations) + .set({ + handoff: { + ...session.handoff!, + identityStateHash: undefined, + identity: { + githubUserId: String(user.id), + login: user.login, + avatarUrl: user.avatar_url ?? null, + expiresAt: new Date(Date.now() + 10 * 60_000).toISOString(), + }, + }, + }) + .where(eq(chatGitHubRegistrations.id, session.id)); + await options.storeCredentials(bot.id, session.userId, { + identityCodeVerifier: nonce(), + }); + return resume(bot); + } + async function directCallback(state: string, code: string) { + const [session] = await db + .select() + .from(chatGitHubRegistrations) + .where(eq(chatGitHubRegistrations.stateHash, hash(state))) + .limit(1); + if ( + !session || + session.handoff?.cloudId || + session.trustedOrigin !== origin() + ) + throw forbidden("GitHub return did not match this instance"); + const bot = await endpoint(session.endpointId, session.userId); + if (session.status === "failed" || session.status === "exchanging") + return resume(bot); + if (session.status === "pending") { + if (!options.completeDirect) + throw conflict("GitHub registration is unavailable"); + await options.completeDirect(state, code); + } + const progress = await advance(bot.id, session.userId); + return progress.state === "install" && progress.installationUrl + ? progress.installationUrl + : resume(bot); + } + async function cloudCallback( + state: string, + registrationId: string, + claimId?: string, + ) { + const [session] = await db + .select() + .from(chatGitHubRegistrations) + .where( + sql`${chatGitHubRegistrations.handoff}->>'cloudId' = ${registrationId}`, + ) + .limit(1); + if (!session?.handoff || session.trustedOrigin !== origin()) + throw forbidden("GitHub return did not match this instance"); + const bot = await endpoint(session.endpointId, session.userId); + if (session.handoff.identityStateHash === hash(state)) { + if (!claimId) { + await db + .update(chatGitHubRegistrations) + .set({ + handoff: sql`${chatGitHubRegistrations.handoff} - 'identityStateHash'`, + }) + .where(eq(chatGitHubRegistrations.id, session.id)); + return resume(bot); + } + const cloud = connector(); + if (!cloud) throw conflict("Restore Paperclip Cloud enrollment"); + const opened = await cloud.claimGitHubApp({ + subject: session.userId, + companyId: session.companyId, + claimId, + redemptionId: session.handoff.identityRedemptionId!, + }); + if ( + opened.kind !== "github_app_identity" || + opened.registrationId !== registrationId || + opened.endpointId !== bot.id || + opened.connectionId !== bot.connectionId || + opened.agentId !== bot.assignedAgentId || + typeof opened.code !== "string" || + typeof opened.redirectUri !== "string" + ) + throw forbidden("GitHub identity return did not match this draft"); + return completeIdentity(session, opened.code, opened.redirectUri); + } + if (hash(state) !== session.stateHash) + throw forbidden("GitHub return state did not match"); + const progress = await advance(session.endpointId, session.userId); + return progress.state === "install" && progress.installationUrl + ? progress.installationUrl + : resume(bot); + } + async function identityCallback(state: string, code: string) { + const [session] = await db + .select() + .from(chatGitHubRegistrations) + .where( + sql`${chatGitHubRegistrations.handoff}->>'identityStateHash' = ${hash(state)}`, + ) + .limit(1); + if (!session || session.trustedOrigin !== origin()) + throw forbidden("GitHub identity return did not match"); + await endpoint(session.endpointId, session.userId); + if (!code) { + await db + .update(chatGitHubRegistrations) + .set({ + handoff: sql`${chatGitHubRegistrations.handoff} - 'identityStateHash'`, + }) + .where( + and( + eq(chatGitHubRegistrations.id, session.id), + sql`${chatGitHubRegistrations.handoff}->>'identityStateHash' = ${hash(state)}`, + ), + ); + return resume(await endpoint(session.endpointId, session.userId)); + } + return completeIdentity( + session, + code, + `${origin()}/api/chat-github/identity/callback`, + ); + } + async function confirmIdentity( + id: string, + userId: string, + githubUserId: string, + ) { + const bot = await endpoint(id, userId), + session = await registration(bot), + candidate = session?.handoff?.identity; + if ( + session?.userId !== userId || + !candidate || + candidate.githubUserId !== githubUserId || + Date.parse(candidate.expiresAt) <= Date.now() + ) + throw conflict( + "Connect your GitHub account again before confirming its identity", + ); + await management.linkObservedIdentity(id, userId, candidate); + await db + .update(chatGitHubRegistrations) + .set({ handoff: { ...session.handoff!, identity: undefined } }) + .where(eq(chatGitHubRegistrations.id, session.id)); + return advance(id, userId); + } + return { + saveDraft, + repairCloud, + start, + advance, + startIdentity, + directCallback, + cloudCallback, + identityCallback, + confirmIdentity, + }; +} diff --git a/server/src/services/github-connection-events.ts b/server/src/services/github-connection-events.ts index 177492ea694..4e345109f32 100644 --- a/server/src/services/github-connection-events.ts +++ b/server/src/services/github-connection-events.ts @@ -1,3 +1,5 @@ +import { dispatchGitHubBotCloudEvent } from "./chat-github-cloud-ingress.js"; +import { chatEndpoints } from "@paperclipai/db"; import { randomUUID } from "node:crypto"; import { connectionEventDeliveries, @@ -462,8 +464,9 @@ export function githubConnectionEventService( return { leased: 0, processed: 0, duplicate: 0, ignored: 0, failed: 0 }; } const bindings = await activeBindings(); - if (bindings.length === 0) { - nextPollAt = now().getTime() + 5 * 60_000; + const bots = await db.select().from(chatEndpoints).where(and(eq(chatEndpoints.provider, "github"), sql`${chatEndpoints.setup}->'github'->>'cloudRegistrationId' is not null`, sql`${chatEndpoints.status} <> 'archived'`)); + if (bindings.length === 0 && bots.length === 0) { + nextPollAt = now().getTime() + 5_000; return { leased: 0, processed: 0, duplicate: 0, ignored: 0, failed: 0 }; } const config = options.connector ? null : paperclipCloudConnectorConfigFromEnv(options.env); @@ -472,11 +475,11 @@ export function githubConnectionEventService( nextPollAt = now().getTime() + 5 * 60_000; return { leased: 0, processed: 0, duplicate: 0, ignored: 0, failed: 0 }; } - const first = bindings[0]!; + const first = bindings[0] ?? { subject: "github-bot-events", companyId: bots[0]!.companyId }; const lease = await connector.leaseEvents({ subject: first.subject, companyId: first.companyId }); if (!lease) { emptyPolls += 1; - nextPollAt = now().getTime() + Math.min(5 * 60_000, 5_000 * (2 ** Math.min(emptyPolls, 6))); + nextPollAt = now().getTime() + Math.min(bots.length ? 15_000 : 5 * 60_000, 5_000 * (2 ** Math.min(emptyPolls, 6))); return { leased: 0, processed: 0, duplicate: 0, ignored: 0, failed: 0 }; } emptyPolls = 0; @@ -490,6 +493,11 @@ export function githubConnectionEventService( }; const acknowledge: string[] = []; for (const event of lease.events) { + if (event.payload.githubApp !== undefined) { + try { await dispatchGitHubBotCloudEvent(db, event); acknowledge.push(event.id); result.processed += 1; } + catch { result.failed += 1; } + continue; + } const matched = bindings.filter((binding) => event.bindingIds.includes(binding.id)); if (matched.length === 0) { result.ignored += 1; diff --git a/server/src/services/paperclip-cloud-connector.ts b/server/src/services/paperclip-cloud-connector.ts index fb52bed38ca..49806ba6f94 100644 --- a/server/src/services/paperclip-cloud-connector.ts +++ b/server/src/services/paperclip-cloud-connector.ts @@ -33,8 +33,8 @@ export const GMAIL_CONNECTOR_SCOPES = [ export { GOOGLE_WORKSPACE_CONNECTOR_PROFILES }; export type PaperclipCloudConnectorEnvironment = "development" | "staging" | "production"; -export type PaperclipCloudConnectorOperation = "status" | "session" | "claim" | "refresh" | "revoke" | "webhook-bind" | "event-lease" | "event-ack"; -export type PaperclipCloudConnectorProfileId = GoogleWorkspaceConnectorProfileId | GitHubConnectorProfileId | AsanaConnectorProfileId; +export type PaperclipCloudConnectorOperation = "status" | "session" | "claim" | "refresh" | "revoke" | "webhook-bind" | "event-lease" | "event-ack" | "github-app"; +export type PaperclipCloudConnectorProfileId = GoogleWorkspaceConnectorProfileId | GitHubConnectorProfileId | AsanaConnectorProfileId | "github.bot"; export type PaperclipCloudConnectorProvider = "google" | "github" | "asana"; export type PaperclipCloudConnectorConfig = { @@ -110,9 +110,11 @@ type ConnectorResponse = { leaseId?: unknown; events?: unknown; acknowledged?: unknown; + githubApps?: { version: number }; }; const ENDPOINTS: Record = { + "github-app": "/v1/connector/github-apps", status: "/v1/connector/instance-status", session: "/v1/connector/sessions", claim: "/v1/connector/claims", @@ -364,6 +366,22 @@ export function createPaperclipCloudConnector(input: { } return { + async githubAppsAvailable() { + const response = await call("status", { subject: "instance-capabilities", companyId: "instance-capabilities" }); + return response.active === true && response.githubApps?.version === 1; + }, + async githubApp(values: { subject: string; companyId: string; binding: Record }) { + return await call("github-app", values, { field: "binding", value: JSON.stringify(values.binding) }) as unknown as import("@paperclipai/shared").GitHubAppCloudState; + }, + async claimGitHubApp(values: { subject: string; companyId: string; claimId: string; redemptionId: string }) { + const response = await call("claim", { ...values, profile: "github.bot" }); + const envelope = parseEnvelope(response.sealed, "initial", "github", "github.bot"); + const opened = decryptEnvelope(envelope, sealKey, config.instanceId, config.environment, "github", "github.bot", []) as Record; + if (opened.v !== 1 || opened.instanceId !== config.instanceId || opened.environment !== config.environment + || opened.subject !== values.subject || opened.companyId !== values.companyId || opened.provider !== "github" + || opened.profile !== "github.bot" || !sameStringSet(opened.scopes, [])) throw badEnvelope(); + return opened; + }, async getInstanceStatus(): Promise<"active" | "suspended" | "removed"> { let response: ConnectorResponse; try { @@ -755,6 +773,7 @@ function connectorProfileDefinition(profile: PaperclipCloudConnectorProfileId): provider: PaperclipCloudConnectorProvider; scopes: readonly string[]; } { + if (profile === "github.bot") return { provider: "github", scopes: [] }; if (isAsanaConnectorProfileId(profile)) { return { provider: "asana", scopes: ASANA_CONNECTOR_SCOPES }; } diff --git a/ui/src/api/githubChat.ts b/ui/src/api/githubChat.ts index 07bd37fcf8f..9181a398b17 100644 --- a/ui/src/api/githubChat.ts +++ b/ui/src/api/githubChat.ts @@ -2,6 +2,7 @@ import type { ChatEndpointSetupState, GitHubChatConfiguration, GitHubTaskReview, + GitHubAppRegistrationInput, GitHubAppWizardState, } from "@paperclipai/shared"; import { api } from "./client"; import type { ChatEndpoint, ChatEndpointResource } from "./chatEndpoints"; @@ -32,12 +33,12 @@ export const githubChatApi = { expectedRevision, configuration, }), - registration: (id: string, name: string) => - api.post<{ - registrationUrl: string; - manifest: Record; - expiresAt: string; - }>(`${path(id)}/registration`, { name }), + registration: (id: string, input: GitHubAppRegistrationInput) => + api.post(`${path(id)}/registration`, input), + saveDraft: (id: string, input: GitHubAppRegistrationInput) => api.put<{ saved: true }>(`${path(id)}/draft`, input), + advance: (id: string) => api.post(`${path(id)}/setup`, {}), + startIdentity: (id: string) => api.post<{ authorizationUrl: string }>(`${path(id)}/identity/start`, {}), + confirmIdentity: (id: string, githubUserId: string) => api.post(`${path(id)}/identity/confirm`, { githubUserId }), connectApp: ( id: string, credentials: { appId: string; privateKey: string; webhookSecret: string }, diff --git a/ui/src/components/GitHubAgentTrustWarning.tsx b/ui/src/components/GitHubAgentTrustWarning.tsx index 8673047c248..2343d82b1eb 100644 --- a/ui/src/components/GitHubAgentTrustWarning.tsx +++ b/ui/src/components/GitHubAgentTrustWarning.tsx @@ -1,15 +1,20 @@ import { AlertTriangle, ExternalLink } from "lucide-react"; import type { AgentPermissions } from "@paperclipai/shared"; import { getTrustPreset } from "@/lib/trust-policy-ui"; +import { Button } from "@/components/ui/button"; export const LOW_TRUST_AGENT_GUIDE = "https://docs.paperclip.ing/administration/trust-and-low-trust-review/"; export function GitHubAgentTrustWarning({ agent, + onChangeToLowTrust, + disabled, }: { agent: { name: string; permissions: Partial } | null | undefined; + onChangeToLowTrust?: () => void; + disabled?: boolean; }) { if (!agent || getTrustPreset(agent.permissions) === "low_trust_review") return null; @@ -31,6 +36,11 @@ export function GitHubAgentTrustWarning({ Continuing keeps this agent’s current permissions. A restricted guest profile does not replace the agent’s trust and runtime settings.

+ {onChangeToLowTrust && ( + + )} ({ + agents: [] as any[], + endpoint: null as any, + setBreadcrumbs: vi.fn(), +})); +vi.mock("@/lib/router", async () => import("react-router-dom")); +vi.mock("@/context/CompanyContext", () => ({ + useCompany: () => ({ + selectedCompanyId: "company-1", + selectedCompany: { + id: "company-1", + name: "Bot Company", + issuePrefix: "BOT", + }, + }), +})); +vi.mock("@/context/BreadcrumbContext", () => ({ + useBreadcrumbs: () => ({ setBreadcrumbs: fixture.setBreadcrumbs }), +})); +vi.mock("@/context/ToastContext", () => ({ + useToast: () => ({ pushToast: vi.fn() }), +})); +vi.mock("@/components/SetupWizard", () => ({ + SetupWizardNavigation: () => null, + SetupWizardFooter: ({ children, onSaveExit }: any) => ( +
+ + {children} +
+ ), +})); +vi.mock("@/components/AgentMultiSelect", () => ({ + AgentSelect: ({ agents, value, onChange }: any) => ( + + ), +})); +vi.mock("@/api/agents", () => ({ + agentsApi: { list: vi.fn(), get: vi.fn(), updatePermissions: vi.fn() }, +})); +vi.mock("@/api/chatEndpoints", () => ({ + chatEndpointsApi: { create: vi.fn(), get: vi.fn(), listResources: vi.fn() }, +})); +vi.mock("@/api/githubChat", () => ({ + githubChatApi: { + advance: vi.fn(), + registration: vi.fn(), + saveDraft: vi.fn(), + startIdentity: vi.fn(), + confirmIdentity: vi.fn(), + identity: vi.fn(), + connectApp: vi.fn(), + personalConnections: vi.fn(), + }, +})); +vi.mock("@/api/tools", () => ({ + toolsApi: { startCloudConnectorEnrollment: vi.fn() }, +})); +vi.mock("@/lib/clipboard", () => ({ copyTextToClipboard: vi.fn() })); + +function Location() { + const location = useLocation(); + return ( + + {location.pathname} + {location.search} + + ); +} + +describe("GitHub App wizard", () => { + let root: Root; + let container: HTMLDivElement; + let client: QueryClient; + beforeEach(() => { + vi.clearAllMocks(); + fixture.agents = [ + { + id: "reviewer", + name: "Reviewer", + status: "idle", + role: "engineer", + permissions: { canCreateAgents: true, canCreateSkills: false }, + access: { canAssignTasks: true }, + createdAt: "2026-01-01", + }, + { + id: "helper", + name: "Setup helper", + status: "idle", + role: "ceo", + permissions: {}, + createdAt: "2026-01-01", + }, + { + id: "low", + name: "Low-trust helper", + status: "idle", + role: "engineer", + permissions: { trustPreset: "low_trust_review" }, + createdAt: "2026-01-01", + }, + ]; + fixture.endpoint = { + id: "draft-1", + companyId: "company-1", + provider: "github", + assignedAgentId: "reviewer", + assignedAgentName: "Reviewer", + status: "draft", + setup: { github: { stage: "setup" } }, + }; + vi.mocked(agentsApi.list).mockImplementation(async () => fixture.agents); + vi.mocked(agentsApi.get).mockImplementation(async (id) => + fixture.agents.find((agent) => agent.id === id), + ); + vi.mocked(agentsApi.updatePermissions).mockImplementation( + async (id, permissions) => { + fixture.agents = fixture.agents.map((agent) => + agent.id === id ? { ...agent, permissions } : agent, + ); + return fixture.agents.find((agent) => agent.id === id); + }, + ); + vi.mocked(chatEndpointsApi.get).mockImplementation( + async () => fixture.endpoint, + ); + vi.mocked(chatEndpointsApi.create).mockResolvedValue(fixture.endpoint); + vi.mocked(chatEndpointsApi.listResources).mockResolvedValue([]); + vi.mocked(githubChatApi.advance).mockResolvedValue({ + endpointId: "draft-1", + state: "create", + }); + vi.mocked(githubChatApi.saveDraft).mockResolvedValue({ saved: true }); + vi.mocked(githubChatApi.personalConnections).mockResolvedValue([]); + vi.mocked(copyTextToClipboard).mockResolvedValue(); + client = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + }); + afterEach(async () => { + await act(async () => root.unmount()); + client.clear(); + container.remove(); + }); + async function settle() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + }); + } + async function render(query = "agentId=reviewer") { + await act(async () => + root.render( + + + + + + , + ), + ); + await settle(); + } + async function click(label: string) { + const button = Array.from(document.querySelectorAll("button")).find( + (button) => + button.textContent === label || + button.getAttribute("aria-label") === label, + ); + expect(button, label).toBeTruthy(); + await act(async () => button!.click()); + await settle(); + } + it("starts with agent selection and saves the low-trust repair before removing its warning", async () => { + await render(); + expect(container.querySelector("h1")?.textContent).toBe("Choose agent"); + expect(container.textContent).not.toContain("permanent"); + expect(container.textContent).not.toContain("Copy setup prompt"); + await click("Change Reviewer to a low trust agent"); + expect(agentsApi.updatePermissions).toHaveBeenCalledWith( + "reviewer", + expect.objectContaining({ + trustPreset: "low_trust_review", + canCreateAgents: false, + canCreateSkills: false, + canAssignTasks: true, + }), + "company-1", + ); + expect(container.querySelector('[role="alert"]')).toBeNull(); + await click("Continue"); + expect(chatEndpointsApi.create).toHaveBeenCalledWith("company-1", { + provider: "github", + assignedAgentId: "reviewer", + }); + expect(container.querySelector("h1")?.textContent).toBe("Connect GitHub"); + expect(container.querySelector("output")?.textContent).toContain( + "resume=draft-1", + ); + }); + it("does not offer a new connection while a saved draft cannot be loaded", async () => { + vi.mocked(chatEndpointsApi.get).mockRejectedValue(new Error("Not found")); + await render("resume=missing-draft&agentId=reviewer"); + expect(container.textContent).toContain("Could not load this connection"); + expect(container.textContent).not.toContain("Choose agent"); + expect(chatEndpointsApi.create).not.toHaveBeenCalled(); + }); + it("keeps the warning and shows the failure when changing trust fails", async () => { + vi.mocked(agentsApi.updatePermissions).mockRejectedValueOnce( + new Error("Permission denied"), + ); + await render(); + await click("Change Reviewer to a low trust agent"); + expect(container.textContent).toContain("Permission denied"); + expect(container.textContent).toContain( + "Reviewer is not configured for low-trust review", + ); + }); + it("resumes the assigned draft with only account and App name choices", async () => { + await render("resume=draft-1"); + expect(container.querySelector("h1")?.textContent).toBe("Connect GitHub"); + expect(container.textContent).toContain("My account"); + expect(container.textContent).toContain("An organization"); + expect(container.textContent).not.toContain("Assign setup task"); + expect(container.textContent).not.toContain("Copy setup prompt"); + expect(container.textContent).not.toContain("Choose setup method"); + expect( + container.querySelector("input#github-app-name")?.getAttribute("value"), + ).toBe("Reviewer"); + expect(chatEndpointsApi.create).not.toHaveBeenCalled(); + }); + it("saves ownership and the suggested name when exiting", async () => { + await render("resume=draft-1"); + const owner = container.querySelector( + "#github-owner-type", + ) as HTMLSelectElement; + await act(async () => { + owner.value = "organization"; + owner.dispatchEvent(new Event("change", { bubbles: true })); + }); + const input = container.querySelector( + "#github-owner-login", + ) as HTMLInputElement; + await act(async () => { + Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + "value", + )!.set!.call(input, "acme"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + await click("Save & exit"); + expect(githubChatApi.saveDraft).toHaveBeenCalledWith("draft-1", { + name: "Reviewer", + ownerType: "organization", + ownerLogin: "acme", + }); + expect(container.querySelector("output")?.textContent).toBe("/apps"); + }); + it("replaces stale cached form defaults when the saved draft refreshes", async () => { + await render("resume=draft-1"); + expect( + (container.querySelector("#github-app-name") as HTMLInputElement).value, + ).toBe("Reviewer"); + fixture.endpoint = { + ...fixture.endpoint, + setup: { + github: { + ownerType: "organization", + ownerLogin: "acme", + appName: "Saved App", + }, + }, + }; + await act(async () => + client.invalidateQueries({ queryKey: ["github-setup", "draft-1"] }), + ); + await settle(); + expect( + (container.querySelector("#github-owner-type") as HTMLSelectElement) + .value, + ).toBe("organization"); + expect( + (container.querySelector("#github-owner-login") as HTMLInputElement) + .value, + ).toBe("acme"); + expect( + (container.querySelector("#github-app-name") as HTMLInputElement).value, + ).toBe("Saved App"); + }); + it("submits the manifest directly, preserving the bound registration on retry", async () => { + const registration = { + registrationUrl: + "https://github.com/organizations/acme/settings/apps/new?state=bound", + manifest: { name: "Reviewer", public: false }, + expiresAt: "2026-10-06T20:00:00Z", + }; + vi.mocked(githubChatApi.advance).mockResolvedValue({ + endpointId: "draft-1", + state: "create", + registration, + }); + const submitted: HTMLFormElement[] = []; + const submit = vi + .spyOn(HTMLFormElement.prototype, "submit") + .mockImplementation(function (this: HTMLFormElement) { + submitted.push(this.cloneNode(true) as HTMLFormElement); + }); + try { + await render("resume=draft-1"); + await click("Continue to GitHub"); + expect(githubChatApi.registration).not.toHaveBeenCalled(); + expect(submitted[0].method).toBe("post"); + expect(submitted[0].action).toBe(registration.registrationUrl); + expect( + (submitted[0].querySelector('[name="manifest"]') as HTMLInputElement) + .value, + ).toBe(JSON.stringify(registration.manifest)); + expect( + (container.querySelector("#github-owner-type") as HTMLSelectElement) + .disabled, + ).toBe(true); + expect(() => + submitGitHubAppManifest({ + ...registration, + registrationUrl: "https://evil.example/apps/new", + }), + ).toThrow("invalid registration"); + } finally { + submit.mockRestore(); + } + }); + it("shows passive installation progress without a duplicate repository picker or refresh gate", async () => { + vi.mocked(githubChatApi.advance).mockResolvedValue({ + endpointId: "draft-1", + state: "install", + installationUrl: "https://github.com/apps/reviewer/installations/new", + }); + await render("resume=draft-1"); + expect(container.textContent).toContain("GitHub"); + expect(container.textContent).not.toContain("I’ve installed it"); + expect(container.textContent).not.toContain("Refresh repositories"); + expect(container.textContent).not.toContain("Prepare registration"); + expect( + container.querySelector( + 'a[href="https://github.com/apps/reviewer/installations/new"]', + ), + ).toBeTruthy(); + }); + it("requires an explicit identity confirmation only for an unlinked account", async () => { + vi.mocked(githubChatApi.advance).mockResolvedValue({ + endpointId: "draft-1", + state: "identity", + identity: { githubUserId: "42", login: "octocat", avatarUrl: null }, + }); + vi.mocked(githubChatApi.confirmIdentity).mockResolvedValue({ + endpointId: "draft-1", + state: "connected", + }); + await render("resume=draft-1"); + await click("Confirm my account"); + expect(githubChatApi.confirmIdentity).toHaveBeenCalledWith("draft-1", "42"); + expect(githubChatApi.startIdentity).not.toHaveBeenCalled(); + }); + it("completes automatically while reporting runtime readiness separately", async () => { + fixture.endpoint = { + ...fixture.endpoint, + botLabel: "Actual GitHub Name", + botUsername: "actual-agent[bot]", + resources: [{ enabled: true, label: "acme/repo" }], + }; + vi.mocked(githubChatApi.advance).mockResolvedValue({ + endpointId: "draft-1", + state: "connected", + runtimeChecks: [ + { + key: "runtime", + label: "Runtime", + ok: false, + detail: "Configure a runtime before the first review.", + }, + ], + }); + await render("resume=draft-1"); + expect(container.querySelector("h1")?.textContent).toBe("GitHub connected"); + expect(container.textContent).toContain("Actual GitHub Name"); + expect(container.textContent).toContain("acme/repo"); + expect(container.textContent).toContain("Configure a runtime"); + expect(container.textContent).not.toContain("Finish"); + await click("Copy mention"); + expect(copyTextToClipboard).toHaveBeenCalledWith( + "@actual-agent review this pull request", + ); + }); +}); diff --git a/ui/src/pages/apps/chat/GitHubChatSetup.tsx b/ui/src/pages/apps/chat/GitHubChatSetup.tsx index ba8bbbf7d1e..9a71344161a 100644 --- a/ui/src/pages/apps/chat/GitHubChatSetup.tsx +++ b/ui/src/pages/apps/chat/GitHubChatSetup.tsx @@ -1,29 +1,13 @@ import { useEffect, useState } from "react"; -import { useQuery } from "@tanstack/react-query"; -import { - CheckCircle2, - Copy, - ExternalLink, - Loader2, - RefreshCw, - XCircle, -} from "lucide-react"; -import type { ChatEndpointSetupState } from "@paperclipai/shared"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { CheckCircle2, ExternalLink, Loader2 } from "lucide-react"; +import type { GitHubAppWizardState } from "@paperclipai/shared"; import { agentsApi } from "@/api/agents"; -import { - chatEndpointsApi, - type ChatEndpoint, - type ChatEndpointResource, -} from "@/api/chatEndpoints"; -import { - githubChatApi, - type GitHubConfigurationRecord, - type GitHubIdentity, - type GitHubVerification, -} from "@/api/githubChat"; +import { chatEndpointsApi } from "@/api/chatEndpoints"; +import { githubChatApi } from "@/api/githubChat"; +import { toolsApi } from "@/api/tools"; import { AgentSelect } from "@/components/AgentMultiSelect"; import { GitHubAgentTrustWarning } from "@/components/GitHubAgentTrustWarning"; -import { GitHubSetupPrompt } from "./GitHubSetupPrompt"; import { SetupWizardNavigation, SetupWizardFooter, @@ -32,96 +16,97 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Label } from "@/components/ui/label"; -import { useToast } from "@/context/ToastContext"; import { useCompany } from "@/context/CompanyContext"; import { useBreadcrumbs } from "@/context/BreadcrumbContext"; import { useNavigate, useSearchParams, Link } from "@/lib/router"; +import { buildPermissionsForTrustPreset } from "@/lib/trust-policy-ui"; import { copyTextToClipboard } from "@/lib/clipboard"; -import { - GitHubAccessEditor, - GitHubPolicyEditor, - GitHubToggle, - githubSelectClass, -} from "./GitHubBotConfiguration"; -const steps = [ - "Choose agent", - "Connect GitHub App", - "Install GitHub App", - "Select repositories", - "Verify connection & tools", - "Connect your account", - "Configure behavior", - "Try it", -]; -const stages: NonNullable["stage"][] = [ - "connect", - "connect", - "install", - "repositories", - "verify", - "identity", - "behavior", - "test", -]; +/** Submit the supported manifest form directly, with no intermediate preparation screen. */ +export function submitGitHubAppManifest( + registration: NonNullable, +) { + const url = new URL(registration.registrationUrl); + if ( + url.origin !== "https://github.com" || + !/^\/(settings|organizations\/[A-Za-z0-9-]+\/settings)\/apps\/new$/.test( + url.pathname, + ) + ) + throw new Error("GitHub returned an invalid registration address"); + const form = document.createElement("form"); + form.method = "POST"; + form.action = url.toString(); + const manifest = document.createElement("input"); + manifest.type = "hidden"; + manifest.name = "manifest"; + manifest.value = JSON.stringify(registration.manifest); + form.append(manifest); + document.body.append(form); + form.submit(); + form.remove(); +} export function GitHubChatSetup() { const [params, setParams] = useSearchParams(); const navigate = useNavigate(); - const { pushToast } = useToast(); - const [secretCopy, setSecretCopy] = useState<"idle" | "copied" | "failed">("idle"); - const identityOnly = params.get("stage") === "identity"; - const reconnecting = params.get("reconnect") === "1"; const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); - const [endpoint, setEndpoint] = useState(null); + const queryClient = useQueryClient(); + const resume = params.get("resume"); + const identityOnly = params.get("stage") === "identity"; const [agentId, setAgentId] = useState(params.get("agentId") ?? ""); - const [step, setStep] = useState(0); - const [availableStep, setAvailableStep] = useState(0); + const [ownerType, setOwnerType] = useState<"personal" | "organization">( + "personal", + ); + const [ownerLogin, setOwnerLogin] = useState(""); + const [name, setName] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); - const [name, setName] = useState("Paperclip Review"); - const [existing, setExisting] = useState(reconnecting); + const [existing, setExisting] = useState(params.get("reconnect") === "1"); const [credentials, setCredentials] = useState({ appId: "", privateKey: "", webhookSecret: "", }); - const [registration, setRegistration] = useState - > | null>(null); - const [resources, setResources] = useState([]); - const [record, setRecord] = useState(null); - const [verification, setVerification] = useState( - null, - ); - const [personalConnectionId, setPersonalConnectionId] = useState(""); - const [identity, setIdentity] = useState(null); const [copied, setCopied] = useState(false); - const resume = params.get("resume"); + const [legacyAccount, setLegacyAccount] = useState(""); + const [legacyIdentity, setLegacyIdentity] = useState + > | null>(null); const agents = useQuery({ queryKey: ["github-setup-agents", selectedCompanyId], queryFn: () => agentsApi.list(selectedCompanyId!), - enabled: !!selectedCompanyId && !identityOnly, + enabled: !!selectedCompanyId, }); const current = useQuery({ queryKey: ["github-setup", resume], queryFn: () => chatEndpointsApi.get(resume!), enabled: !!resume, refetchInterval: 3000, - refetchIntervalInBackground: false, + }); + const bot = current.data; + const progress = useQuery({ + queryKey: ["github-wizard", resume], + queryFn: () => githubChatApi.advance(resume!), + enabled: !!bot && !existing, + refetchInterval: (query) => + ["connected", "create", "identity", "enrollment", "recovery"].includes( + query.state.data?.state ?? "", + ) + ? false + : 5000, + retry: false, }); const accounts = useQuery({ - queryKey: ["github-personal-connections", endpoint?.id], - queryFn: () => githubChatApi.personalConnections(endpoint!.id), - enabled: !!endpoint && step === 5, + queryKey: ["github-personal-connections", resume], + queryFn: () => githubChatApi.personalConnections(resume!), + enabled: !!bot && (identityOnly || progress.data?.state === "identity"), }); - const test = useQuery({ - queryKey: ["github-test-status", endpoint?.id], - queryFn: () => chatEndpointsApi.setupTestStatus(endpoint!.id), - enabled: !!endpoint && step === 7, - refetchInterval: 3000, - }); - const selectedAgent = agents.data?.find((agent) => agent.id === agentId); + const selectedAgent = agents.data?.find( + (agent) => agent.id === (bot?.assignedAgentId ?? agentId), + ); + const state = progress.data; + const connected = state?.state === "connected"; useEffect(() => { setBreadcrumbs([ { label: "Connectors", href: "/apps" }, @@ -130,37 +115,19 @@ export function GitHubChatSetup() { return () => setBreadcrumbs([]); }, [setBreadcrumbs]); useEffect(() => { - if (!current.data) return; - setEndpoint(current.data); - setAgentId(current.data.assignedAgentId); - if (availableStep === 0) { - const next = identityOnly - ? 5 - : reconnecting - ? 1 - : current.data.setup?.github?.stage - ? Math.max(1, stages.indexOf(current.data.setup.github.stage)) - : current.data.status === "active" - ? 6 - : 1; - setStep(next); - setAvailableStep(next); - if (identityOnly) return; - void Promise.all([ - chatEndpointsApi.listResources(current.data.id), - githubChatApi.configuration(current.data.id), - ]) - .then(([resources, config]) => { - setResources(resources); - setRecord(config); - }) - .catch((error) => - setError( - error instanceof Error ? error.message : "Could not resume setup", - ), - ); + if (bot) { + setAgentId(bot.assignedAgentId); + if (bot.setup?.github?.ownerType) + setOwnerType(bot.setup.github.ownerType); + setOwnerLogin(bot.setup?.github?.ownerLogin ?? ""); + if (bot.setup?.github?.appName) setName(bot.setup.github.appName); } - }, [current.data, availableStep, params]); + }, [ + bot?.id, + bot?.setup?.github?.ownerType, + bot?.setup?.github?.ownerLogin, + bot?.setup?.github?.appName, + ]); async function run(action: () => Promise) { setBusy(true); setError(""); @@ -170,718 +137,481 @@ export function GitHubChatSetup() { setError( error instanceof Error ? error.message - : "Could not save this step. Try again.", + : "Could not connect GitHub. Try again.", ); } finally { setBusy(false); } } - async function go(next: number, bot = endpoint) { - if (bot && next > 0) - setEndpoint(await githubChatApi.progress(bot.id, stages[next]!)); - setStep(next); - setAvailableStep((value) => Math.max(value, next)); - } - async function saveConfiguration() { - if (!endpoint || !record) return; - const saved = await githubChatApi.save( - endpoint.id, - record.revision, - record.configuration, - ); - setRecord(saved); + async function refresh() { + await current.refetch(); + await progress.refetch(); } + const appInput = () => ({ + name: ( + name || + selectedAgent?.name || + bot?.assignedAgentName || + "GitHub Reviewer" + ).slice(0, 34), + ownerType, + ...(ownerType === "organization" ? { ownerLogin: ownerLogin.trim() } : {}), + }); const exit = () => void run(async () => { - if (step === 6) await saveConfiguration(); - if (endpoint && step > 0 && !identityOnly) - await githubChatApi.progress(endpoint.id, stages[step]!); + if ( + bot && + !bot.botExternalId && + !state?.registration && + state?.state !== "recovery" + ) { + await githubChatApi.saveDraft(bot.id, appInput()); + await current.refetch(); + } navigate("/apps"); }); const footer = ( - label: string, - action: () => Promise, + label?: string, + action?: () => Promise, disabled = false, - extra?: React.ReactNode, ) => ( - -
- {step > 0 && !identityOnly && ( - - )} - {extra} + + {label && action && ( -
+ )}
); - const publicHttps = !!endpoint?.setup?.webhookUrl?.startsWith("https://"); - const mention = `@${endpoint?.botUsername?.replace(/\[bot\]$/, "") ?? "your-bot"} review this pull request`; + if (resume && !bot) + return ( +
+

Resume GitHub setup

+ {current.error ? ( +

+ Could not load this connection. Try again to resume your draft. +

+ ) : ( +

+ Loading your draft… +

+ )} + {footer(current.error ? "Try again" : undefined, async () => { + await current.refetch(); + })} +
+ ); + const mention = `@${bot?.botUsername?.replace(/\[bot\]$/, "") ?? "your-bot"} review this pull request`; return (
- {} : setStep} - disabled={busy} - takeover - /> -
-

- {identityOnly - ? "GitHub account linking" - : `GitHub bot · Step ${step + 1} of ${steps.length}`} -

-

{steps[step]}

-

- GitHub conversations run as Paperclip tasks on one assigned agent. -

-
- {(error || current.error || agents.error) && ( -

- {error || "Could not load this setup. Refresh to try again."} + {!identityOnly && !connected && ( + {}} + disabled={busy} + takeover + /> + )} +

+ {!bot + ? "Choose agent" + : connected + ? "GitHub connected" + : identityOnly + ? "Connect your account" + : "Connect GitHub"} +

+ {(error || progress.error) && ( +

+ {error || + (progress.error instanceof Error + ? progress.error.message + : "Could not check GitHub setup. Try again.")}

)} - {step === 0 && ( + {!bot ? ( <> - -

- This assignment is permanent. The agent works through its existing - permissions, budgets, and tools. -

- {endpoint ? ( - + {selectedAgent && ( + + void run(async () => { + const permissions = buildPermissionsForTrustPreset( + selectedAgent.permissions, + "low_trust_review", + ); + await agentsApi.updatePermissions( + selectedAgent.id, + { + ...permissions, + canCreateAgents: false, + canCreateSkills: false, + canAssignTasks: + typeof selectedAgent.permissions.canAssignTasks === + "boolean" + ? selectedAgent.permissions.canAssignTasks + : true, + }, + selectedCompanyId!, + ); + await queryClient.invalidateQueries({ + queryKey: ["github-setup-agents", selectedCompanyId], + }); + }) } - readOnly - /> - ) : ( - !["terminated", "archived"].includes(agent.status), - )} - value={agentId} - onChange={setAgentId} - placeholder="Choose an agent" - emptyMessage="No agents available" /> )} - {footer( "Continue", async () => { - const bot = - endpoint ?? - (await chatEndpointsApi.create(selectedCompanyId!, { - provider: "github", - assignedAgentId: agentId, - })); - setEndpoint(bot); - setParams( - { provider: "github", resume: bot.id }, - { replace: true }, + const created = await chatEndpointsApi.create( + selectedCompanyId!, + { provider: "github", assignedAgentId: agentId }, ); - setRecord(await githubChatApi.configuration(bot.id)); - await go(1, bot); + setName((selectedAgent?.name ?? "GitHub Reviewer").slice(0, 34)); + const next = new URLSearchParams(params); + next.set("resume", created.id); + setParams(next, { replace: true }); + queryClient.setQueryData(["github-setup", created.id], created); }, !agentId || !selectedCompanyId, )} - )} - {step === 1 && endpoint && ( + ) : connected ? ( <> - {!publicHttps && ( -
- )} -

- Create a dedicated GitHub App for{" "} - {selectedAgent?.name ?? endpoint.assignedAgentName}. Paperclip - stores the credentials in its vault and uses this same App for the - agent’s GitHub tools. -

- {reconnecting && ( -

- Verify the saved App and refresh its webhook configuration. Leave - the credentials blank to keep them, or enter a replacement private - key for the same App. -

- )} -
- - +
+ + + {bot.botLabel ?? bot.botUsername} · {bot.assignedAgentName} +
- {existing ? ( -
-
- - - setCredentials({ ...credentials, appId: e.target.value }) - } - /> -

- Find this in your GitHub App’s settings. -

-
-
- -