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) => (
+
+ ),
+}));
+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 (
+
+ );
+}
+
+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 && (
-
- A publicly reachable HTTPS address is required before App
- registration. Configure the instance’s public URL or an explicit
- webhook ingress URL, then refresh. {" "}
-
Learn how to set up HTTPS
-
- )}
-
- 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.
-
-
-
-
-
-
- setCredentials({
- ...credentials,
- webhookSecret: e.target.value,
- })
- }
- />
-
- Use the same secret in GitHub’s webhook settings.
-
- {!reconnecting &&
-
- {credentials.webhookSecret && }
-
}
-
-
-
- ) : (
-
-
-
{
- setName(e.target.value);
- setRegistration(null);
- }}
- />
-
- GitHub requires a unique name. Continue on GitHub, then return
- automatically to finish setup.
-
- {registration && (
-
- )}
-
- )}
-
-
- App permissions and callback details
-
-
- Contents: read. Issues, Pull requests, Checks: write. Events:
- issue_comment, pull_request_review_comment, pull_request.
-
-
- {endpoint.setup?.webhookUrl ?? "Public webhook URL unavailable"}
-
- {endpoint.setup?.webhookUrl && }
- {registration && (
-
- {JSON.stringify(registration.manifest, null, 2)}
-
- )}
-
- {footer(
- reconnecting
- ? "Reconnect App"
- : endpoint.setup?.github?.appSlug
- ? "Continue to installation"
- : existing
- ? "Connect App"
- : "Prepare registration",
- async () => {
- if (reconnecting) {
- const saved = await chatEndpointsApi.setup(endpoint.id, {
- action: "reconnect",
- ...(credentials.privateKey
- ? {
- credentials: {
- appId: credentials.appId || endpoint.botExternalId!,
- privateKey: credentials.privateKey,
- },
- }
- : {}),
- });
- setEndpoint(saved);
- setCredentials({
- appId: "",
- privateKey: "",
- webhookSecret: "",
- });
- await go(4, saved);
- } else if (endpoint.setup?.github?.appSlug) await go(2);
- else if (existing) {
- const saved = await githubChatApi.connectApp(
- endpoint.id,
- credentials,
- );
- setEndpoint(saved);
- setCredentials({
- appId: "",
- privateKey: "",
- webhookSecret: "",
- });
- await go(2, saved);
- } else
- setRegistration(
- await githubChatApi.registration(endpoint.id, name),
- );
- },
- !publicHttps ||
- (existing &&
- !reconnecting &&
- (!credentials.appId ||
- !credentials.privateKey ||
- !credentials.webhookSecret) &&
- !endpoint.setup?.github?.appSlug),
- )}
- >
- )}
- {step === 2 && endpoint && (
- <>
- Install the bot’s App into your GitHub account or organization. On
- GitHub, choose the repositories the installation can access.
+ {bot.resources
+ ?.filter((resource) => resource.enabled)
+ .map((resource) => resource.label)
+ .join(", ")}
-
- You will choose the subset enabled in Paperclip in the next step.
- Changing the installation does not automatically enable repositories
- here.
-
- {endpoint.setup?.github?.installationUrl && (
-
- )}
- {footer("I’ve installed the App", async () => {
- setResources(await githubChatApi.refreshRepositories(endpoint.id));
- await go(3);
- })}
- >
- )}
- {step === 3 && endpoint && (
- <>
+ {state.runtimeChecks
+ ?.filter((check) => !check.ok)
+ .map((check) => (
+
+ {check.detail}
+
+ ))}
+
Try a mention on a pull request:
+
{mention}
-
-
- This list comes from the bot App’s installation. Enable the
- repositories where it should respond and review.
-
-
- {resources.length === 0 && (
-
- No repositories found. Configure installation access on GitHub,
- then refresh.
-
- )}
- {resources.map((resource) => (
-
-
- setResources(
- resources.map((row) =>
- row.id === resource.id
- ? {
- ...row,
- enabled:
- enabled && row.availability === "available",
- }
- : row,
- ),
- )
- }
- />
-
- ))}
-
- {footer(
- "Save repositories",
- async () => {
- await chatEndpointsApi.updateResources(
- endpoint.id,
- resources.map((resource) => ({
- id: resource.id,
- enabled: resource.enabled,
- })),
- );
- await chatEndpointsApi.update(endpoint.id, {
- allowGroupChats: true,
- });
- const configured = await chatEndpointsApi.setup(endpoint.id, {
- action:
- endpoint.status === "draft" || endpoint.status === "attention"
- ? "configure"
- : "reconnect",
- });
- setEndpoint(configured);
- await go(4, configured);
- },
- !resources.some(
- (resource) =>
- resource.enabled && resource.availability === "available",
- ),
- )}
- >
- )}
- {step === 4 && endpoint && (
- <>
-
- Verify signed delivery, App identity, repository access, and the
- assigned agent’s effective tools separately.
-
-
-
-
-
-
- Tool assignment grants this agent the bot App’s task-scoped tools
- through its Paperclip tool profile. Existing deny and approval
- policies still apply.
-
-
- {verification?.checks.map((check) => (
-
- {check.ok ? (
-
- ) : (
-
- )}
-
-
{check.label}
-
- {check.detail}
-
-
-
- ))}
-
-
- {footer("Continue", () => go(5), !verification?.ready)}
- >
- )}
- {step === 5 && endpoint && (
- <>
-
- Choose your existing personal GitHub connection. Paperclip verifies
- the account, then asks you to confirm ownership. This link
- identifies your requests; the bot still uses its own App
- credentials.
-
-
-
-
-
- Shared and agent connections cannot prove your identity.
-
-
-
-
-
-
- Connect GitHub
-
-
- {accounts.error && (
-
- Could not load personal connections.
-
- )}
- {identity && (
-
-
@{identity.login}
-
- GitHub ID {identity.githubUserId}. Confirm that this is your
- account.
-
-
- )}
- {footer(
- "Confirm this is my account",
- async () => {
- await githubChatApi.identity(
- endpoint.id,
- personalConnectionId,
- identity!.githubUserId,
- );
- if (identityOnly) navigate("/apps");
- else await go(6);
- },
- !identity,
- )}
- >
- )}
- {step === 6 && endpoint && record && (
- <>
-
- setRecord({ ...record, configuration })
- }
- />
-
- setRecord({
- ...record,
- configuration: { ...record.configuration, defaults },
- })
- }
- />
- {footer("Save behavior", async () => {
- await saveConfiguration();
- await go(7);
- })}
- >
- )}
- {step === 7 && endpoint && (
- <>
-
- Mention the bot in an enabled repository. The request should create
- a real Paperclip task on{" "}
- {selectedAgent?.name ?? endpoint.assignedAgentName} and reply on
- GitHub.
-
-
- {mention}
-
-
- {copied && (
-
- Mention copied
-
- )}
-
- {test.data?.messageReceivedAt
- ? "Message received. Wait for the agent’s response, then finish."
- : "Waiting for your test message…"}
-
- Open underlying tasks
+ Connection settings
+ {footer()}
+ >
+ ) : existing ? (
+ <>
+
+ Use the credentials from this App’s GitHub settings.
+
+
+
+ setCredentials({ ...credentials, appId: event.target.value })
+ }
+ />
+
+