mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 04:25:52 +01:00
feat(connections): simplify dedicated GitHub App setup
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
a9a20fb5c6
commit
cf8578a9dd
28 files changed
+4025
-824
No files matched your search
@@ -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 <agent> 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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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;
|
||||
+716
-2
@@ -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": "",
|
||||
+8
-1
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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";
|
||||
@@ -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. */
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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;
|
||||
};
|
||||
@@ -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<string, unknown> | undefined,
|
||||
loseResponse = true;
|
||||
const githubApp = vi.fn(
|
||||
async ({ binding }: { binding: Record<string, unknown> }) => {
|
||||
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<string, unknown> }) => {
|
||||
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<typeof fetch>[0],
|
||||
init?: Parameters<typeof fetch>[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(
|
||||
|
||||
+14
-2
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
) => Promise<void>;
|
||||
const handlers = new WeakMap<Db, Handler>();
|
||||
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<boolean> {
|
||||
const value = event.payload.githubApp;
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const packet = value as Record<string, unknown>;
|
||||
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<string, unknown>),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
const record = (value: unknown): Record<string, unknown> =>
|
||||
value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
/** Explicit, bounded provider fields; never persist the original webhook body. */
|
||||
export function normalizeGitHubBotCloudPayload(
|
||||
body: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const schemas: Record<string, unknown> = {
|
||||
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<string, unknown> = {};
|
||||
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));
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<void>;
|
||||
},
|
||||
) {
|
||||
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 };
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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;
|
||||
|
||||
@@ -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<PaperclipCloudConnectorOperation, string> = {
|
||||
"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<string, unknown> }) {
|
||||
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<string, unknown>;
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
expiresAt: string;
|
||||
}>(`${path(id)}/registration`, { name }),
|
||||
registration: (id: string, input: GitHubAppRegistrationInput) =>
|
||||
api.post<GitHubAppWizardState>(`${path(id)}/registration`, input),
|
||||
saveDraft: (id: string, input: GitHubAppRegistrationInput) => api.put<{ saved: true }>(`${path(id)}/draft`, input),
|
||||
advance: (id: string) => api.post<GitHubAppWizardState>(`${path(id)}/setup`, {}),
|
||||
startIdentity: (id: string) => api.post<{ authorizationUrl: string }>(`${path(id)}/identity/start`, {}),
|
||||
confirmIdentity: (id: string, githubUserId: string) => api.post<GitHubAppWizardState>(`${path(id)}/identity/confirm`, { githubUserId }),
|
||||
connectApp: (
|
||||
id: string,
|
||||
credentials: { appId: string; privateKey: string; webhookSecret: string },
|
||||
|
||||
@@ -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<AgentPermissions> } | 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.
|
||||
</p>
|
||||
{onChangeToLowTrust && (
|
||||
<Button variant="outline" className="h-auto whitespace-normal" disabled={disabled} onClick={onChangeToLowTrust}>
|
||||
Change {agent.name} to a low trust agent
|
||||
</Button>
|
||||
)}
|
||||
<a
|
||||
className="inline-flex items-center gap-1 underline underline-offset-4"
|
||||
href={LOW_TRUST_AGENT_GUIDE}
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { MemoryRouter, useLocation } from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { GitHubChatSetup, submitGitHubAppManifest } from "./GitHubChatSetup";
|
||||
import { agentsApi } from "@/api/agents";
|
||||
import { chatEndpointsApi } from "@/api/chatEndpoints";
|
||||
import { githubChatApi } from "@/api/githubChat";
|
||||
import { copyTextToClipboard } from "@/lib/clipboard";
|
||||
|
||||
const fixture = vi.hoisted(() => ({
|
||||
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) => (
|
||||
<footer>
|
||||
<button onClick={onSaveExit}>Save & exit</button>
|
||||
{children}
|
||||
</footer>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/components/AgentMultiSelect", () => ({
|
||||
AgentSelect: ({ agents, value, onChange }: any) => (
|
||||
<select
|
||||
aria-label="Agent"
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
>
|
||||
<option value="">Choose an agent</option>
|
||||
{agents.map((agent: any) => (
|
||||
<option key={agent.id} value={agent.id}>
|
||||
{agent.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
),
|
||||
}));
|
||||
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 (
|
||||
<output>
|
||||
{location.pathname}
|
||||
{location.search}
|
||||
</output>
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
<QueryClientProvider client={client}>
|
||||
<MemoryRouter
|
||||
initialEntries={[`/BOT/apps/chat/connect?provider=github&${query}`]}
|
||||
>
|
||||
<GitHubChatSetup />
|
||||
<Location />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
),
|
||||
);
|
||||
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,5 +1,9 @@
|
||||
# GitHub chat and review bot — design approval preview
|
||||
|
||||
> Historical design prototype: the production wizard now starts with agent selection,
|
||||
> followed by setup-method selection with copying or assignment to a setup helper.
|
||||
> Use the running app and `ui/src/pages/apps/chat/GitHubChatSetup.tsx` for the current flow.
|
||||
|
||||
Open **Apps / GitHub chat & reviews / 00 · Start here**. All provider handoffs,
|
||||
identity events, tool probes, GitHub output, and Paperclip tasks are fixtures.
|
||||
No production route, credential storage, runtime, or provider call is changed.
|
||||
|
||||
@@ -10,7 +10,7 @@ const meta = {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Design approval preview. Provider handoffs, tool checks, identity events, tasks, and reviews are simulated. No backend behavior or live GitHub actions are implemented by these stories. Use Start here for the complete journey, then inspect alternate and management states.",
|
||||
"Historical design prototype. Production onboarding now has Choose agent and Connect GitHub, with personal or organization App ownership and automatic completion. This historical prototype predates the wizard and is not its acceptance test. Use the running app to review the current setup flow. Provider handoffs, tool checks, identity events, tasks, and reviews here are simulated.",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user