diff --git a/CHANGELOG.md b/CHANGELOG.md index 60ede232c..792771815 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 carries, so an exported run imported into another install keeps its place in the run history instead of reading as just run. A report naming no finish time is dated on arrival, and a malformed one is refused. +- **GitHub App webhooks** (`actionagent`). `POST /api/github_app/webhooks` + receives the App's deliveries, verified with `X-Hub-Signature-256` under + `ActionAgent.github_app_webhook_secret` (falling back to + `GITHUB_APP_WEBHOOK_SECRET`; unset, the endpoint answers 404), and applies + them in `ActionAgent::GithubWebhookJob`: an uninstall marks the linked + installation removed, suspend and unsuspend set and clear `suspended_at`, + accepted permissions are recorded, a repository removed from the + installation leaves the owner's selection, and a revoked App authorization + deletes the user's stored connection when connections authorize through the + App. Each `X-GitHub-Delivery` is recorded for seven days, so a redelivery + is a no-op. Ships a new migration, + `create_active_agent_github_webhook_deliveries`; run + `rails generate action_agent:install` and `rails db:migrate` to add it. ### Fixed diff --git a/actionagent/app/controllers/action_agent/api/github_app_webhooks_controller.rb b/actionagent/app/controllers/action_agent/api/github_app_webhooks_controller.rb new file mode 100644 index 000000000..fe1405632 --- /dev/null +++ b/actionagent/app/controllers/action_agent/api/github_app_webhooks_controller.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require "openssl" + +module ActionAgent + module Api + # POST /api/github_app/webhooks: the GitHub App's webhook + # deliveries (installation, installation_repositories, + # github_app_authorization), applied by GithubWebhookJob. + # + # GitHub's signature authenticates a delivery on its own, so this is not a + # dashboard controller and sees no session, no forgery protection and none + # of the host's controller concerns. A delivery is accepted only when its + # X-Hub-Signature-256 is the HMAC-SHA256 of the raw body under + # ActionAgent.github_app_webhook_secret; with no secret configured there + # is no endpoint (404). + # + # The answer is 202 as soon as a delivery is recorded and enqueued, or + # recognized as one already recorded (GithubWebhookDelivery), or found to + # be an event the dashboard does not handle. Only a failure to enqueue + # answers otherwise, and leaves the delivery unrecorded so a redelivery + # of it is processed. + # + # The body may be JSON, or a form whose +payload+ field holds the JSON, + # as GitHub sends it when the App's content type is + # application/x-www-form-urlencoded. The signature covers the raw body + # either way. + class GithubAppWebhooksController < ActionController::API + wrap_parameters false + + SIGNATURE_HEADER = "X-Hub-Signature-256" + EVENT_HEADER = "X-GitHub-Event" + DELIVERY_HEADER = "X-GitHub-Delivery" + + def create + secret = ActionAgent.github_app_webhook_secret + return head(:not_found) if secret.blank? + + body = request.raw_post.to_s + unless signed?(body, secret) + return render(json: { error: "Invalid or missing #{SIGNATURE_HEADER}" }, status: :unauthorized) + end + + event = request.headers[EVENT_HEADER].to_s + return accepted("ignored") unless event.in?(GithubWebhookJob::EVENTS) + + delivery_id = request.headers[DELIVERY_HEADER].to_s + unless delivery_id.match?(/\A[A-Za-z0-9-]{1,64}\z/) + return render(json: { error: "#{DELIVERY_HEADER} is required" }, status: :bad_request) + end + + payload = parse(body) + return render(json: { error: "The delivery's body is not a JSON object" }, status: :bad_request) unless payload + + job_payload = GithubWebhookJob.payload_for(event, payload) + recorded = GithubWebhookDelivery.record!( + delivery_id: delivery_id, event: event, + event_action: job_payload["action"].presence&.first(64), + installation_id: job_payload.dig("installation", "id").presence&.to_i + ) + return accepted("duplicate") unless recorded + + begin + GithubWebhookJob.perform_later(event, job_payload) + rescue StandardError + # Not left recorded, so a redelivery of it is processed. + GithubWebhookDelivery.forget!(delivery_id) + raise + end + + accepted("queued") + end + + private + + # Constant time, whatever the header holds. + def signed?(body, secret) + given = request.headers[SIGNATURE_HEADER] + return false unless given.is_a?(String) && given.start_with?("sha256=") + + expected = "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', secret, body)}" + ActiveSupport::SecurityUtils.secure_compare(expected, given) + end + + def parse(body) + body = Rack::Utils.parse_query(body)["payload"].to_s if request.media_type == "application/x-www-form-urlencoded" + data = JSON.parse(body) + data if data.is_a?(Hash) + rescue JSON::ParserError + nil + end + + def accepted(status) + render json: { status: status }, status: :accepted + end + end + end +end diff --git a/actionagent/app/jobs/action_agent/github_webhook_job.rb b/actionagent/app/jobs/action_agent/github_webhook_job.rb new file mode 100644 index 000000000..87accb23d --- /dev/null +++ b/actionagent/app/jobs/action_agent/github_webhook_job.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +module ActionAgent + # Applies a GitHub App webhook delivery that Api::GithubAppWebhooksController + # verified and recorded, so Settings -> Integrations learns of a change on + # GitHub when it happens rather than when a token mint is next refused. + # + # +payload+ is the slice of the delivery GithubWebhookJob.payload_for keeps. + # A delivery about an installation no owner linked changes nothing, and so + # does an event or action not handled here. + # + # Each handler sets state from the payload rather than toggling it, so + # running one twice leaves the same result; the delivery record is what + # keeps an old delivery from being replayed over a newer one. + class GithubWebhookJob < ApplicationJob + queue_as :default + + EVENTS = %w[installation installation_repositories github_app_authorization].freeze + + # What the job needs from a delivery's +payload+, so the job's arguments + # carry neither the sender's profile nor every repository's details. + def self.payload_for(event, payload) + installation = payload["installation"].is_a?(Hash) ? payload["installation"] : {} + repositories = ->(key) { Array(payload[key]).filter_map { |repo| repo.slice("id", "full_name") if repo.is_a?(Hash) } } + sender = payload["sender"].is_a?(Hash) ? payload["sender"] : {} + + { + "action" => payload["action"].to_s, + "installation" => installation.slice("id", "permissions", "repository_selection", "suspended_at"), + "repositories_added" => event == "installation_repositories" ? repositories.call("repositories_added") : [], + "repositories_removed" => event == "installation_repositories" ? repositories.call("repositories_removed") : [], + "sender_id" => sender["id"] + } + end + + def perform(event, payload) + case event + when "installation" then installation(payload) + when "installation_repositories" then installation_repositories(payload) + when "github_app_authorization" then authorization(payload) + end + end + + private + + def installation(payload) + record = linked_installation(payload) or return + reported = payload["installation"] + + case payload["action"] + when "deleted" + record.update!(removed_at: Time.current) if record.removed_at.nil? + when "suspend" + record.update!(suspended_at: parse_time(reported["suspended_at"]) || Time.current) + when "unsuspend" + record.update!(suspended_at: nil) + when "new_permissions_accepted" + record.update!(permissions: reported["permissions"]) if reported["permissions"].is_a?(Hash) + end + end + + # The repository picker lists what the installation reaches from GitHub + # each time it opens, so an added repository needs nothing stored; a + # removed one leaves the owner's selection, which is what a checkout + # trusts. + def installation_repositories(payload) + record = linked_installation(payload) or return + return unless payload["action"].in?(%w[added removed]) + + removed = payload["repositories_removed"].map { |repo| repo["id"].to_i }.to_set + attributes = { repositories: record.repositories.reject { |repo| removed.include?(repo["id"].to_i) } } + selection = payload.dig("installation", "repository_selection") + attributes[:repository_selection] = selection if selection.present? + record.update!(attributes) + end + + # GitHub revoked the user's authorization of the App. The App's own user + # tokens are never stored (see Api::GithubInstallationsController), so + # this reaches a stored GithubConnection only when the OAuth connection + # flow authorizes through this same App (github_client_id is the App's + # client id). A connection made through a separate OAuth App is a grant + # this revocation does not touch. + def authorization(payload) + return unless payload["action"] == "revoked" + return if payload["sender_id"].blank? + return unless ActionAgent.github_client_id.present? && ActionAgent.github_client_id == ActionAgent.github_app_client_id + + GithubConnection.where(github_user_id: payload["sender_id"].to_i).destroy_all + end + + def linked_installation(payload) + id = payload.dig("installation", "id") + GithubInstallation.find_by(installation_id: id.to_i) if id.present? + end + + def parse_time(value) + Time.iso8601(value) if value.is_a?(String) + rescue ArgumentError + nil + end + end +end diff --git a/actionagent/app/models/action_agent/github_installation.rb b/actionagent/app/models/action_agent/github_installation.rb index 1af88d1d7..2d681eb58 100644 --- a/actionagent/app/models/action_agent/github_installation.rb +++ b/actionagent/app/models/action_agent/github_installation.rb @@ -49,7 +49,8 @@ def repository(full_name) # Whether GitHub will still mint tokens for this installation, as far as # the dashboard last heard: false once a mint found it removed or - # suspended, until a later mint succeeds or it is linked again. + # suspended, or a webhook delivery reported it (GithubWebhookJob), until + # a later mint succeeds, an unsuspend is delivered, or it is linked again. def usable? removed_at.nil? && suspended_at.nil? end diff --git a/actionagent/app/models/action_agent/github_webhook_delivery.rb b/actionagent/app/models/action_agent/github_webhook_delivery.rb new file mode 100644 index 000000000..da1b410aa --- /dev/null +++ b/actionagent/app/models/action_agent/github_webhook_delivery.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module ActionAgent + # A GitHub App webhook delivery the dashboard accepted, by its + # X-GitHub-Delivery id. Api::GithubAppWebhooksController records one before + # it enqueues the delivery's GithubWebhookJob, so a redelivery of the same + # id (from GitHub's retry or its Redeliver button) finds it and is a no-op: + # a replayed +suspend+ cannot undo a later +unsuspend+. + # + # A row instead of Rails.cache: the unique index makes the check and the + # record one atomic insert across every web process, whatever cache store + # the host runs. Rows hold no payload and are pruned after RETENTION, a + # little longer than GitHub keeps a delivery redeliverable. + # + # Not owned: a delivery is about an installation, whichever owner (if any) + # linked it. + class GithubWebhookDelivery < ApplicationRecord + RETENTION = 7.days + + validates :delivery_id, :event, presence: true + + # Records the delivery, and answers whether it is the first time this id + # was seen. Rows older than RETENTION are pruned on the way. + # + # @return [Boolean] false for a redelivery + def self.record!(delivery_id:, event:, event_action: nil, installation_id: nil) + where(created_at: ...RETENTION.ago).delete_all + + # A savepoint, so a duplicate inside a caller's transaction does not + # abort it on PostgreSQL. + transaction(requires_new: true) do + create!(delivery_id: delivery_id, event: event, event_action: event_action, installation_id: installation_id) + end + true + rescue ActiveRecord::RecordNotUnique + false + end + + # Forgets the delivery, so GitHub's redelivery of it is processed. For a + # delivery that was recorded but could not be enqueued. + def self.forget!(delivery_id) + where(delivery_id: delivery_id).delete_all + end + end +end diff --git a/actionagent/config/routes.rb b/actionagent/config/routes.rb index 56d43882e..b72693a22 100644 --- a/actionagent/config/routes.rb +++ b/actionagent/config/routes.rb @@ -318,6 +318,11 @@ end end + # The GitHub App's webhook deliveries. Authenticated by GitHub's + # signature (github_app_webhook_secret), not a session; the App's + # Webhook URL is /api/github_app/webhooks. + post "github_app/webhooks", to: "github_app_webhooks#create", as: :github_app_webhooks + # Creating the GitHub App from a manifest on a self-hosted dashboard. resource :github_app_manifest, only: [ :create ] do get :callback diff --git a/actionagent/lib/action_agent.rb b/actionagent/lib/action_agent.rb index 565fe4b96..504f5eb6a 100644 --- a/actionagent/lib/action_agent.rb +++ b/actionagent/lib/action_agent.rb @@ -867,6 +867,16 @@ def claude_code_auth=(value) attr_writer :github_app_id, :github_app_private_key, :github_app_slug, :github_app_client_id, :github_app_client_secret + # The secret GitHub signs the App's webhook deliveries with, which + # POST /api/github_app/webhooks verifies. Falls back to + # GITHUB_APP_WEBHOOK_SECRET. Not one of the settings + # github_app_configured? needs: without it the endpoint answers 404 and + # the dashboard learns of installation changes only when a token mint is + # refused. Set the App's Webhook URL to /api/github_app/webhooks + # and subscribe it to Installation and Installation repositories. + # @return [String, nil] + attr_writer :github_app_webhook_secret + # MCP servers the host app itself serves or connects, appended to the # built-in catalog (MCPCatalog) so the MCP Services view lists them and # telemetry traffic attributes to them. Each entry is a hash shaped like @@ -971,6 +981,10 @@ def github_app_client_secret @github_app_client_secret.presence || ENV["GITHUB_APP_CLIENT_SECRET"].presence end + def github_app_webhook_secret + @github_app_webhook_secret.presence || ENV["GITHUB_APP_WEBHOOK_SECRET"].presence + end + # Whether the GitHub App installation flow can run on this install: every # github_app_* setting is present. # @return [Boolean] @@ -1334,6 +1348,7 @@ def reset! @github_app_slug = nil @github_app_client_id = nil @github_app_client_secret = nil + @github_app_webhook_secret = nil @trace_retention = nil @trace_owner_resolver = nil @usage_recorder = nil diff --git a/actionagent/lib/generators/action_agent/templates/action_agent.rb.erb b/actionagent/lib/generators/action_agent/templates/action_agent.rb.erb index f99d01ee7..f602e4718 100644 --- a/actionagent/lib/generators/action_agent/templates/action_agent.rb.erb +++ b/actionagent/lib/generators/action_agent/templates/action_agent.rb.erb @@ -215,6 +215,12 @@ ActionAgent.configure do |config| # config.github_app_client_secret = Rails.application.credentials.dig(:github_app, :client_secret) # config.github_app_private_key = Rails.application.credentials.dig(:github_app, :private_key) # + # With the App's webhook pointed at /api/github_app/webhooks, the + # secret its deliveries are signed with (GITHUB_APP_WEBHOOK_SECRET when + # unset). Settings then learns of an uninstall or suspension when it happens. + # + # config.github_app_webhook_secret = Rails.application.credentials.dig(:github_app, :webhook_secret) + # # Or an OAuth App, connected per owner with the callback URL # /api/github_connection/callback (GITHUB_CLIENT_ID and # GITHUB_CLIENT_SECRET when unset). diff --git a/actionagent/lib/generators/action_agent/templates/migrations/029_create_active_agent_github_webhook_deliveries.rb.erb b/actionagent/lib/generators/action_agent/templates/migrations/029_create_active_agent_github_webhook_deliveries.rb.erb new file mode 100644 index 000000000..7e442d933 --- /dev/null +++ b/actionagent/lib/generators/action_agent/templates/migrations/029_create_active_agent_github_webhook_deliveries.rb.erb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +# The GitHub App webhook deliveries the dashboard accepted, kept briefly so a +# redelivery of the same X-GitHub-Delivery is a no-op (see +# ActionAgent::GithubWebhookDelivery). Nothing here is a credential or a +# payload. +class CreateActiveAgentGithubWebhookDeliveries < ActiveRecord::Migration<%= migration_version %> + def change + prefix = ActionAgent.table_name_prefix + + create_table "#{prefix}github_webhook_deliveries" do |t| + # GitHub's X-GitHub-Delivery, a GUID. Unique: a second insert of the + # same delivery is how a redelivery is recognized. + t.string :delivery_id, null: false, limit: 64 + t.string :event, null: false, limit: 64 + t.string :event_action, limit: 64 + t.bigint :installation_id + t.datetime :created_at, null: false + t.index :delivery_id, unique: true, name: "index_#{prefix}webhook_deliveries_on_delivery" + t.index :created_at, name: "index_#{prefix}webhook_deliveries_on_created_at" + end + end +end diff --git a/actionagent/test/fixtures/files/github_webhooks/github_app_authorization_revoked.json b/actionagent/test/fixtures/files/github_webhooks/github_app_authorization_revoked.json new file mode 100644 index 000000000..38aa20b70 --- /dev/null +++ b/actionagent/test/fixtures/files/github_webhooks/github_app_authorization_revoked.json @@ -0,0 +1,10 @@ +{ + "action": "revoked", + "sender": { + "login": "octocat", + "id": 9001, + "type": "User", + "site_admin": false, + "html_url": "https://github.com/octocat" + } +} diff --git a/actionagent/test/fixtures/files/github_webhooks/installation_created.json b/actionagent/test/fixtures/files/github_webhooks/installation_created.json new file mode 100644 index 000000000..741a5ddfc --- /dev/null +++ b/actionagent/test/fixtures/files/github_webhooks/installation_created.json @@ -0,0 +1,53 @@ +{ + "action": "created", + "installation": { + "id": 78, + "client_id": "Iv1.testclientid", + "account": { + "login": "acme", + "id": 555, + "type": "Organization", + "site_admin": false, + "html_url": "https://github.com/acme" + }, + "repository_selection": "selected", + "access_tokens_url": "https://api.github.com/app/installations/77/access_tokens", + "repositories_url": "https://api.github.com/installation/repositories", + "html_url": "https://github.com/organizations/acme/settings/installations/77", + "app_id": 424242, + "app_slug": "acme-dashboard", + "target_id": 555, + "target_type": "Organization", + "permissions": { + "contents": "write", + "metadata": "read", + "pull_requests": "write", + "members": "read" + }, + "events": [], + "created_at": "2026-10-01T12:00:00.000Z", + "updated_at": "2026-10-27T12:00:00.000Z", + "single_file_name": null, + "has_multiple_single_files": false, + "single_file_paths": [], + "suspended_by": null, + "suspended_at": null + }, + "repositories": [ + { + "id": 101, + "node_id": "R_kgDO101", + "name": "api", + "full_name": "acme/api", + "private": true + } + ], + "requester": null, + "sender": { + "login": "octocat", + "id": 9001, + "type": "User", + "site_admin": false, + "html_url": "https://github.com/octocat" + } +} diff --git a/actionagent/test/fixtures/files/github_webhooks/installation_deleted.json b/actionagent/test/fixtures/files/github_webhooks/installation_deleted.json new file mode 100644 index 000000000..a81419767 --- /dev/null +++ b/actionagent/test/fixtures/files/github_webhooks/installation_deleted.json @@ -0,0 +1,59 @@ +{ + "action": "deleted", + "installation": { + "id": 77, + "client_id": "Iv1.testclientid", + "account": { + "login": "acme", + "id": 555, + "type": "Organization", + "site_admin": false, + "html_url": "https://github.com/acme" + }, + "repository_selection": "selected", + "access_tokens_url": "https://api.github.com/app/installations/77/access_tokens", + "repositories_url": "https://api.github.com/installation/repositories", + "html_url": "https://github.com/organizations/acme/settings/installations/77", + "app_id": 424242, + "app_slug": "acme-dashboard", + "target_id": 555, + "target_type": "Organization", + "permissions": { + "contents": "write", + "metadata": "read", + "pull_requests": "write", + "members": "read" + }, + "events": [], + "created_at": "2026-10-01T12:00:00.000Z", + "updated_at": "2026-10-27T12:00:00.000Z", + "single_file_name": null, + "has_multiple_single_files": false, + "single_file_paths": [], + "suspended_by": null, + "suspended_at": null + }, + "repositories": [ + { + "id": 101, + "node_id": "R_kgDO101", + "name": "api", + "full_name": "acme/api", + "private": true + }, + { + "id": 102, + "node_id": "R_kgDO102", + "name": "web", + "full_name": "acme/web", + "private": true + } + ], + "sender": { + "login": "octocat", + "id": 9001, + "type": "User", + "site_admin": false, + "html_url": "https://github.com/octocat" + } +} diff --git a/actionagent/test/fixtures/files/github_webhooks/installation_new_permissions_accepted.json b/actionagent/test/fixtures/files/github_webhooks/installation_new_permissions_accepted.json new file mode 100644 index 000000000..ff832202c --- /dev/null +++ b/actionagent/test/fixtures/files/github_webhooks/installation_new_permissions_accepted.json @@ -0,0 +1,53 @@ +{ + "action": "new_permissions_accepted", + "installation": { + "id": 77, + "client_id": "Iv1.testclientid", + "account": { + "login": "acme", + "id": 555, + "type": "Organization", + "site_admin": false, + "html_url": "https://github.com/acme" + }, + "repository_selection": "selected", + "access_tokens_url": "https://api.github.com/app/installations/77/access_tokens", + "repositories_url": "https://api.github.com/installation/repositories", + "html_url": "https://github.com/organizations/acme/settings/installations/77", + "app_id": 424242, + "app_slug": "acme-dashboard", + "target_id": 555, + "target_type": "Organization", + "permissions": { + "contents": "write", + "metadata": "read", + "pull_requests": "write", + "members": "read", + "checks": "read" + }, + "events": [], + "created_at": "2026-10-01T12:00:00.000Z", + "updated_at": "2026-10-27T12:00:00.000Z", + "single_file_name": null, + "has_multiple_single_files": false, + "single_file_paths": [], + "suspended_by": null, + "suspended_at": null + }, + "repositories": [ + { + "id": 101, + "node_id": "R_kgDO101", + "name": "api", + "full_name": "acme/api", + "private": true + } + ], + "sender": { + "login": "octocat", + "id": 9001, + "type": "User", + "site_admin": false, + "html_url": "https://github.com/octocat" + } +} diff --git a/actionagent/test/fixtures/files/github_webhooks/installation_repositories_added.json b/actionagent/test/fixtures/files/github_webhooks/installation_repositories_added.json new file mode 100644 index 000000000..241dd9de3 --- /dev/null +++ b/actionagent/test/fixtures/files/github_webhooks/installation_repositories_added.json @@ -0,0 +1,55 @@ +{ + "action": "added", + "installation": { + "id": 77, + "client_id": "Iv1.testclientid", + "account": { + "login": "acme", + "id": 555, + "type": "Organization", + "site_admin": false, + "html_url": "https://github.com/acme" + }, + "repository_selection": "selected", + "access_tokens_url": "https://api.github.com/app/installations/77/access_tokens", + "repositories_url": "https://api.github.com/installation/repositories", + "html_url": "https://github.com/organizations/acme/settings/installations/77", + "app_id": 424242, + "app_slug": "acme-dashboard", + "target_id": 555, + "target_type": "Organization", + "permissions": { + "contents": "write", + "metadata": "read", + "pull_requests": "write", + "members": "read" + }, + "events": [], + "created_at": "2026-10-01T12:00:00.000Z", + "updated_at": "2026-10-27T12:00:00.000Z", + "single_file_name": null, + "has_multiple_single_files": false, + "single_file_paths": [], + "suspended_by": null, + "suspended_at": null + }, + "repository_selection": "selected", + "repositories_added": [ + { + "id": 103, + "node_id": "R_kgDO103", + "name": "docs", + "full_name": "acme/docs", + "private": true + } + ], + "repositories_removed": [], + "requester": null, + "sender": { + "login": "octocat", + "id": 9001, + "type": "User", + "site_admin": false, + "html_url": "https://github.com/octocat" + } +} diff --git a/actionagent/test/fixtures/files/github_webhooks/installation_repositories_removed.json b/actionagent/test/fixtures/files/github_webhooks/installation_repositories_removed.json new file mode 100644 index 000000000..83902bea0 --- /dev/null +++ b/actionagent/test/fixtures/files/github_webhooks/installation_repositories_removed.json @@ -0,0 +1,55 @@ +{ + "action": "removed", + "installation": { + "id": 77, + "client_id": "Iv1.testclientid", + "account": { + "login": "acme", + "id": 555, + "type": "Organization", + "site_admin": false, + "html_url": "https://github.com/acme" + }, + "repository_selection": "selected", + "access_tokens_url": "https://api.github.com/app/installations/77/access_tokens", + "repositories_url": "https://api.github.com/installation/repositories", + "html_url": "https://github.com/organizations/acme/settings/installations/77", + "app_id": 424242, + "app_slug": "acme-dashboard", + "target_id": 555, + "target_type": "Organization", + "permissions": { + "contents": "write", + "metadata": "read", + "pull_requests": "write", + "members": "read" + }, + "events": [], + "created_at": "2026-10-01T12:00:00.000Z", + "updated_at": "2026-10-27T12:00:00.000Z", + "single_file_name": null, + "has_multiple_single_files": false, + "single_file_paths": [], + "suspended_by": null, + "suspended_at": null + }, + "repository_selection": "selected", + "repositories_added": [], + "repositories_removed": [ + { + "id": 102, + "node_id": "R_kgDO102", + "name": "web", + "full_name": "acme/web", + "private": true + } + ], + "requester": null, + "sender": { + "login": "octocat", + "id": 9001, + "type": "User", + "site_admin": false, + "html_url": "https://github.com/octocat" + } +} diff --git a/actionagent/test/fixtures/files/github_webhooks/installation_suspend.json b/actionagent/test/fixtures/files/github_webhooks/installation_suspend.json new file mode 100644 index 000000000..a8e2512ba --- /dev/null +++ b/actionagent/test/fixtures/files/github_webhooks/installation_suspend.json @@ -0,0 +1,58 @@ +{ + "action": "suspend", + "installation": { + "id": 77, + "client_id": "Iv1.testclientid", + "account": { + "login": "acme", + "id": 555, + "type": "Organization", + "site_admin": false, + "html_url": "https://github.com/acme" + }, + "repository_selection": "selected", + "access_tokens_url": "https://api.github.com/app/installations/77/access_tokens", + "repositories_url": "https://api.github.com/installation/repositories", + "html_url": "https://github.com/organizations/acme/settings/installations/77", + "app_id": 424242, + "app_slug": "acme-dashboard", + "target_id": 555, + "target_type": "Organization", + "permissions": { + "contents": "write", + "metadata": "read", + "pull_requests": "write", + "members": "read" + }, + "events": [], + "created_at": "2026-10-01T12:00:00.000Z", + "updated_at": "2026-10-27T12:00:00.000Z", + "single_file_name": null, + "has_multiple_single_files": false, + "single_file_paths": [], + "suspended_by": { + "login": "octocat", + "id": 9001, + "type": "User", + "site_admin": false, + "html_url": "https://github.com/octocat" + }, + "suspended_at": "2026-10-27T15:04:05Z" + }, + "repositories": [ + { + "id": 101, + "node_id": "R_kgDO101", + "name": "api", + "full_name": "acme/api", + "private": true + } + ], + "sender": { + "login": "octocat", + "id": 9001, + "type": "User", + "site_admin": false, + "html_url": "https://github.com/octocat" + } +} diff --git a/actionagent/test/fixtures/files/github_webhooks/installation_unsuspend.json b/actionagent/test/fixtures/files/github_webhooks/installation_unsuspend.json new file mode 100644 index 000000000..79fbfa7f1 --- /dev/null +++ b/actionagent/test/fixtures/files/github_webhooks/installation_unsuspend.json @@ -0,0 +1,52 @@ +{ + "action": "unsuspend", + "installation": { + "id": 77, + "client_id": "Iv1.testclientid", + "account": { + "login": "acme", + "id": 555, + "type": "Organization", + "site_admin": false, + "html_url": "https://github.com/acme" + }, + "repository_selection": "selected", + "access_tokens_url": "https://api.github.com/app/installations/77/access_tokens", + "repositories_url": "https://api.github.com/installation/repositories", + "html_url": "https://github.com/organizations/acme/settings/installations/77", + "app_id": 424242, + "app_slug": "acme-dashboard", + "target_id": 555, + "target_type": "Organization", + "permissions": { + "contents": "write", + "metadata": "read", + "pull_requests": "write", + "members": "read" + }, + "events": [], + "created_at": "2026-10-01T12:00:00.000Z", + "updated_at": "2026-10-27T12:00:00.000Z", + "single_file_name": null, + "has_multiple_single_files": false, + "single_file_paths": [], + "suspended_by": null, + "suspended_at": null + }, + "repositories": [ + { + "id": 101, + "node_id": "R_kgDO101", + "name": "api", + "full_name": "acme/api", + "private": true + } + ], + "sender": { + "login": "octocat", + "id": 9001, + "type": "User", + "site_admin": false, + "html_url": "https://github.com/octocat" + } +} diff --git a/actionagent/test/fixtures/files/github_webhooks/ping.json b/actionagent/test/fixtures/files/github_webhooks/ping.json new file mode 100644 index 000000000..5a2d6dccc --- /dev/null +++ b/actionagent/test/fixtures/files/github_webhooks/ping.json @@ -0,0 +1,20 @@ +{ + "zen": "Keep it logically awesome.", + "hook_id": 1234, + "hook": { + "type": "App", + "id": 1234, + "active": true, + "events": [ + "installation_repositories" + ], + "app_id": 424242 + }, + "sender": { + "login": "octocat", + "id": 9001, + "type": "User", + "site_admin": false, + "html_url": "https://github.com/octocat" + } +} diff --git a/actionagent/test/github_app_webhooks_test.rb b/actionagent/test/github_app_webhooks_test.rb new file mode 100644 index 000000000..680659a71 --- /dev/null +++ b/actionagent/test/github_app_webhooks_test.rb @@ -0,0 +1,318 @@ +# frozen_string_literal: true + +require "test_helper" +require_relative "support/github_app" + +# POST /api/github_app/webhooks: GitHub App deliveries, verified by +# their signature, recorded once by X-GitHub-Delivery, and applied to the +# linked installation (or the user's connection) by GithubWebhookJob. The +# payloads are GitHub's, as recorded in test/fixtures/files/github_webhooks, +# and nothing here reaches the network. +class GithubAppWebhooksTest < ActionDispatch::IntegrationTest + include ActiveJob::TestHelper + include GithubAppTestHelper + + ENDPOINT = "/activeagents/api/github_app/webhooks" + SECRET = "webhook-secret-for-tests" + FIXTURES = File.expand_path("fixtures/files/github_webhooks", __dir__) + + def setup + ActionAgent::GithubInstallation.delete_all + ActionAgent::GithubConnection.delete_all + ActionAgent::GithubWebhookDelivery.delete_all + configure_github_app! + ActionAgent.github_app_webhook_secret = SECRET + end + + def teardown + reset_github_app! + ActionAgent.github_app_webhook_secret = nil + ActionAgent.github_client_id = nil + end + + test "a delivery with a valid signature is accepted and queued" do + link_installation! + + assert_enqueued_with(job: ActionAgent::GithubWebhookJob) do + deliver "installation", "installation_deleted" + end + + assert_response :accepted + assert_equal "queued", response.parsed_body["status"] + delivery = ActionAgent::GithubWebhookDelivery.sole + assert_equal [ "installation", "deleted", INSTALLATION_ID ], [ delivery.event, delivery.event_action, delivery.installation_id ] + end + + test "a bad, malformed or missing signature is refused with a 401 and nothing is recorded" do + installation = link_installation! + body = fixture("installation_deleted") + signatures = [ + sign(body, "some-other-secret"), + sign(body.sub("deleted", "suspend"), SECRET), # signed for a different body + OpenSSL::HMAC.hexdigest("SHA256", SECRET, body), # no sha256= prefix + "sha1=#{OpenSSL::HMAC.hexdigest('SHA1', SECRET, body)}", + "", + nil + ] + + signatures.each do |signature| + post ENDPOINT, params: body, headers: headers("installation", signature: signature) + + assert_response :unauthorized, signature.inspect + end + assert_no_enqueued_jobs + assert_equal 0, ActionAgent::GithubWebhookDelivery.count + assert_nil installation.reload.removed_at + end + + test "with no webhook secret configured there is no endpoint" do + ActionAgent.github_app_webhook_secret = nil + skip "GITHUB_APP_WEBHOOK_SECRET is set in this environment" if ActionAgent.github_app_webhook_secret + + body = fixture("installation_deleted") + post ENDPOINT, params: body, headers: headers("installation", signature: sign(body, SECRET)) + + assert_response :not_found + assert_no_enqueued_jobs + end + + test "the secret falls back to GITHUB_APP_WEBHOOK_SECRET" do + ActionAgent.github_app_webhook_secret = nil + with_env("GITHUB_APP_WEBHOOK_SECRET" => "from-the-environment") do + assert_equal "from-the-environment", ActionAgent.github_app_webhook_secret + + body = fixture("ping") + post ENDPOINT, params: body, headers: headers("ping", signature: sign(body, "from-the-environment")) + assert_response :accepted + end + end + + test "is exempt from forgery protection, like the other signed endpoints" do + original = ActionController::Base.allow_forgery_protection + ActionController::Base.allow_forgery_protection = true + link_installation! + + deliver "installation", "installation_deleted", extra_headers: { "Sec-Fetch-Site" => "cross-site" } + + assert_response :accepted + ensure + ActionController::Base.allow_forgery_protection = original + end + + test "redelivering the same X-GitHub-Delivery changes nothing" do + installation = link_installation! + + perform_enqueued_jobs do + deliver "installation", "installation_suspend", delivery: "delivery-1" + deliver "installation", "installation_unsuspend", delivery: "delivery-2" + # GitHub's Redeliver on the earlier suspend, after the unsuspend. + deliver "installation", "installation_suspend", delivery: "delivery-1" + end + + assert_response :accepted + assert_equal "duplicate", response.parsed_body["status"] + assert_nil installation.reload.suspended_at + assert_equal 2, ActionAgent::GithubWebhookDelivery.count + end + + test "a delivery that cannot be enqueued is not recorded, so its redelivery is processed" do + link_installation! + body = fixture("installation_deleted") + + ActionAgent::GithubWebhookJob.stub(:perform_later, ->(*) { raise "queue unavailable" }) do + assert_raises(RuntimeError) do + post ENDPOINT, params: body, headers: headers("installation", signature: sign(body, SECRET), delivery: "delivery-1") + end + end + assert_equal 0, ActionAgent::GithubWebhookDelivery.count + + deliver "installation", "installation_deleted", delivery: "delivery-1" + assert_equal "queued", response.parsed_body["status"] + end + + test "deliveries older than the retention window are pruned" do + ActionAgent::GithubWebhookDelivery.create!(delivery_id: "old", event: "installation", created_at: 8.days.ago) + ActionAgent::GithubWebhookDelivery.create!(delivery_id: "recent", event: "installation", created_at: 1.day.ago) + link_installation! + + deliver "installation", "installation_deleted", delivery: "new" + + assert_equal %w[new recent], ActionAgent::GithubWebhookDelivery.order(:delivery_id).pluck(:delivery_id) + end + + test "uninstalling the App marks the installation removed without a token mint" do + installation = link_installation!(repositories: [ repo_row(101, "acme/api") ]) + + perform_enqueued_jobs { deliver "installation", "installation_deleted" } + + installation.reload + assert_not_nil installation.removed_at + assert_equal "removed", installation.status + assert_not installation.usable? + assert_equal [ "acme/api" ], installation.repository_names, "the selection is kept for the owner to see" + assert_not_requested :post, mint_url + + get "/activeagents/api/github_installations" + assert_equal "removed", response.parsed_body["installations"].sole["status"] + end + + test "suspending and unsuspending the App toggles suspended_at" do + installation = link_installation! + + perform_enqueued_jobs { deliver "installation", "installation_suspend" } + assert_equal Time.iso8601("2026-10-27T15:04:05Z"), installation.reload.suspended_at + assert_equal "suspended", installation.status + + perform_enqueued_jobs { deliver "installation", "installation_unsuspend" } + assert_nil installation.reload.suspended_at + assert_equal "active", installation.status + end + + test "accepting new permissions records them" do + installation = link_installation!(permissions: { "contents" => "write" }) + + perform_enqueued_jobs { deliver "installation", "installation_new_permissions_accepted" } + + assert_equal "read", installation.reload.permissions["checks"] + assert_equal "write", installation.permissions["pull_requests"] + end + + test "removing repositories on GitHub drops them from the owner's selection" do + installation = link_installation!(repositories: [ repo_row(101, "acme/api"), repo_row(102, "acme/web") ]) + + perform_enqueued_jobs { deliver "installation_repositories", "installation_repositories_removed" } + + assert_equal [ "acme/api" ], installation.reload.repository_names + end + + test "adding repositories on GitHub keeps the selection, and the picker lists the new one" do + installation = link_installation!(repositories: [ repo_row(101, "acme/api") ]) + + perform_enqueued_jobs { deliver "installation_repositories", "installation_repositories_added" } + + assert_equal [ "acme/api" ], installation.reload.repository_names, "nothing is selected for the owner" + assert_equal "selected", installation.repository_selection + + # The picker asks GitHub what the installation reaches when it opens. + stub_mint + stub_installation_repositories([ repo_payload(101, "acme/api"), repo_payload(103, "acme/docs") ]) + get "/activeagents/api/github_installations/#{installation.id}/repositories" + listed = response.parsed_body["repositories"].to_h { |repo| [ repo["full_name"], repo["selected"] ] } + assert_equal({ "acme/api" => true, "acme/docs" => false }, listed) + end + + test "a delivery for an installation no owner linked changes nothing" do + installation = link_installation!(installation_id: INSTALLATION_ID + 100) + + perform_enqueued_jobs do + deliver "installation", "installation_deleted" + deliver "installation", "installation_created" + deliver "installation_repositories", "installation_repositories_removed" + end + + assert_response :accepted + assert_nil installation.reload.removed_at + assert_equal 1, ActionAgent::GithubInstallation.count, "a new installation is linked only from Settings" + end + + test "events the dashboard does not handle are accepted and ignored" do + link_installation! + + deliver "ping", "ping" + assert_response :accepted + assert_equal "ignored", response.parsed_body["status"] + + deliver "push", "installation_deleted" + assert_equal "ignored", response.parsed_body["status"] + + assert_no_enqueued_jobs + assert_equal 0, ActionAgent::GithubWebhookDelivery.count + end + + test "a handled event without a delivery id or a JSON object body is refused" do + body = fixture("installation_deleted") + post ENDPOINT, params: body, headers: headers("installation", signature: sign(body, SECRET), delivery: nil) + assert_response :bad_request + + post ENDPOINT, params: "[1]", headers: headers("installation", signature: sign("[1]", SECRET)) + assert_response :bad_request + + assert_no_enqueued_jobs + end + + test "a form-encoded delivery is read from its payload field" do + installation = link_installation! + body = URI.encode_www_form(payload: fixture("installation_deleted")) + + perform_enqueued_jobs do + post ENDPOINT, params: body, headers: headers("installation", signature: sign(body, SECRET)) + .merge("Content-Type" => "application/x-www-form-urlencoded") + end + + assert_response :accepted + assert_not_nil installation.reload.removed_at + end + + test "revoking the App's authorization deletes the user's connection token when connections go through the App" do + ActionAgent.github_client_id = CLIENT_ID # the OAuth connection flow authorizes through the App + connection = connect!(github_user_id: GITHUB_USER_ID) + other = connect!(github_user_id: GITHUB_USER_ID + 1, user_id: 2) + + perform_enqueued_jobs { deliver "github_app_authorization", "github_app_authorization_revoked" } + + assert_not ActionAgent::GithubConnection.exists?(connection.id) + assert ActionAgent::GithubConnection.exists?(other.id) + end + + test "revoking the App's authorization leaves a connection made through a separate OAuth App" do + ActionAgent.github_client_id = "Ov23.separate-oauth-app" + connection = connect!(github_user_id: GITHUB_USER_ID) + + perform_enqueued_jobs { deliver "github_app_authorization", "github_app_authorization_revoked" } + + assert ActionAgent::GithubConnection.exists?(connection.id) + end + + private + + def fixture(name) + File.read(File.join(FIXTURES, "#{name}.json")) + end + + def sign(body, secret) + "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', secret, body)}" + end + + def headers(event, signature:, delivery: SecureRandom.uuid) + { + "Content-Type" => "application/json", + "X-GitHub-Event" => event, + "X-GitHub-Delivery" => delivery, + "X-GitHub-Hook-Installation-Target-Type" => "integration", + "X-Hub-Signature-256" => signature + }.compact + end + + def deliver(event, name, delivery: SecureRandom.uuid, extra_headers: {}) + body = fixture(name) + post ENDPOINT, params: body, headers: headers(event, signature: sign(body, SECRET), delivery: delivery).merge(extra_headers) + end + + # Without validation: a single-user dashboard allows one connection, and + # this stands in for two owners' connections. + def connect!(github_user_id:, user_id: nil) + connection = ActionAgent::GithubConnection.new( + access_token: "gho_#{SecureRandom.hex(18)}", github_user_id: github_user_id, login: "octocat", user_id: user_id + ) + connection.save!(validate: false) + connection + end + + def with_env(values) + previous = values.keys.to_h { |key| [ key, ENV[key] ] } + values.each { |key, value| ENV[key] = value } + yield + ensure + previous.each { |key, value| ENV[key] = value } + end +end diff --git a/actionagent/test/install_generator_test.rb b/actionagent/test/install_generator_test.rb index 89ae9f845..f4a22c08d 100644 --- a/actionagent/test/install_generator_test.rb +++ b/actionagent/test/install_generator_test.rb @@ -439,6 +439,29 @@ class ActionAgentInstallGeneratorTest < Rails::Generators::TestCase connection&.drop_table("#{prefix}draft_pull_requests", if_exists: true) end + # Under a probe prefix, so the result can be compared with the dummy's own + # table. + test "the webhook deliveries migration builds the table the dummy schema has, with a unique delivery id" do + run_generator [ "--skip-routes" ] + assert_migration "db/migrate/create_active_agent_github_webhook_deliveries.rb" + connection = ActiveRecord::Base.connection + prefix = "wh_probe_" + ActionAgent.table_name_prefix = prefix + + run_migration("create_active_agent_github_webhook_deliveries", :CreateActiveAgentGithubWebhookDeliveries) + + shape = ->(table) { connection.columns(table).to_h { |column| [ column.name, [ column.sql_type, column.null, column.default ] ] } } + assert_equal shape.call("active_agent_github_webhook_deliveries"), shape.call("#{prefix}github_webhook_deliveries") + assert connection.index_exists?("#{prefix}github_webhook_deliveries", :delivery_id, unique: true) + assert connection.index_exists?("#{prefix}github_webhook_deliveries", :created_at) + + run_migration("create_active_agent_github_webhook_deliveries", :CreateActiveAgentGithubWebhookDeliveries, :down) + assert_not connection.table_exists?("#{prefix}github_webhook_deliveries") + ensure + ActionAgent.table_name_prefix = "active_agent_" + connection&.drop_table("#{prefix}github_webhook_deliveries", if_exists: true) + end + test "a missing numbered template directory emits nothing" do ActionAgent::InstallGenerator.numbered_migrations_path = File.join(destination_root, "no-such-directory") diff --git a/docs/framework/dashboard.md b/docs/framework/dashboard.md index b0866aee6..f03675613 100644 --- a/docs/framework/dashboard.md +++ b/docs/framework/dashboard.md @@ -1084,7 +1084,8 @@ with: installation that already exists is reconfigured - repository permissions Contents (read and write), Pull requests (read and write) and Metadata (read), and the organization permission Members (read) -- no webhook, and no Workflows, Administration or Secrets permission +- no Workflows, Administration or Secrets permission +- optionally, a webhook (see [GitHub App webhooks](#github-app-webhooks)) Then configure it, and restart the dashboard: @@ -1137,6 +1138,44 @@ as it does again once a suspended installation is unsuspended. An App uninstalled from an account comes back as a new installation when it is installed again; unlink the old one. +#### GitHub App webhooks + +Without a webhook, the dashboard learns that an installation was removed or +suspended only when GitHub next refuses a token. With one, it learns when it +happens. On the App's settings page on GitHub, set the **Webhook URL** to +`/api/github_app/webhooks` (for example +`https://example.com/activeagents/api/github_app/webhooks`), give it a +**Webhook secret**, check **Active**, and subscribe it to the +**Installation** and **Installation repositories** events (GitHub also +sends the App's authorization revocations). Then configure the secret: + +```ruby +ActionAgent.configure do |config| + config.github_app_webhook_secret = Rails.application.credentials.dig(:github_app, :webhook_secret) +end +``` + +Unset, it falls back to `GITHUB_APP_WEBHOOK_SECRET`, and with neither the +endpoint answers 404. A delivery is accepted only when its +`X-Hub-Signature-256` is the HMAC-SHA256 of its body under that secret (a +401 otherwise). The endpoint answers 202 straight away and applies the +delivery in `ActionAgent::GithubWebhookJob`, so the host app needs a running +Active Job backend: + +| Event | Action | Effect | +|-------|--------|--------| +| `installation` | `deleted` | the installation is marked removed | +| `installation` | `suspend` / `unsuspend` | it is marked suspended, and the mark is cleared | +| `installation` | `new_permissions_accepted` | its permissions are recorded | +| `installation_repositories` | `removed` | the removed repositories leave the owner's selection | +| `installation_repositories` | `added` | nothing to store: the repository picker lists them the next time it opens | +| `github_app_authorization` | `revoked` | the user's stored OAuth connection is deleted, when `github_client_id` is the App's client id (a connection made through a separate OAuth App is not this grant) | + +A delivery about an installation no owner linked changes nothing, and other +events are answered 202 and ignored. Each `X-GitHub-Delivery` is recorded +(in `github_webhook_deliveries`, for seven days) before its job is enqueued, +so a redelivery of the same delivery is a no-op. + ### An OAuth connection Register a [GitHub OAuth app](https://github.com/settings/developers) whose diff --git a/test/dummy/db/migrate/029_create_active_agent_github_webhook_deliveries.rb b/test/dummy/db/migrate/029_create_active_agent_github_webhook_deliveries.rb new file mode 100644 index 000000000..ee2cd45ee --- /dev/null +++ b/test/dummy/db/migrate/029_create_active_agent_github_webhook_deliveries.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +# The GitHub App webhook deliveries the dashboard accepted, kept briefly so a +# redelivery of the same X-GitHub-Delivery is a no-op (see +# ActionAgent::GithubWebhookDelivery). Nothing here is a credential or a +# payload. +class CreateActiveAgentGithubWebhookDeliveries < ActiveRecord::Migration[7.2] + def change + prefix = ActionAgent.table_name_prefix + + create_table "#{prefix}github_webhook_deliveries" do |t| + # GitHub's X-GitHub-Delivery, a GUID. Unique: a second insert of the + # same delivery is how a redelivery is recognized. + t.string :delivery_id, null: false, limit: 64 + t.string :event, null: false, limit: 64 + t.string :event_action, limit: 64 + t.bigint :installation_id + t.datetime :created_at, null: false + t.index :delivery_id, unique: true, name: "index_#{prefix}webhook_deliveries_on_delivery" + t.index :created_at, name: "index_#{prefix}webhook_deliveries_on_created_at" + end + end +end diff --git a/test/dummy/db/schema.rb b/test/dummy/db/schema.rb index acb2e4721..0bf38d458 100644 --- a/test/dummy/db/schema.rb +++ b/test/dummy/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 28) do +ActiveRecord::Schema[8.0].define(version: 29) do create_table "active_agent_agent_contexts", force: :cascade do |t| t.string "action_name", null: false t.string "agent_name", null: false @@ -419,6 +419,16 @@ t.index [ "user_id" ], name: "index_active_agent_github_installations_on_user" end + create_table "active_agent_github_webhook_deliveries", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "delivery_id", limit: 64, null: false + t.string "event", limit: 64, null: false + t.string "event_action", limit: 64 + t.bigint "installation_id" + t.index [ "created_at" ], name: "index_active_agent_webhook_deliveries_on_created_at" + t.index [ "delivery_id" ], name: "index_active_agent_webhook_deliveries_on_delivery", unique: true + end + create_table "active_agent_input_requests", force: :cascade do |t| t.bigint "account_id" t.text "answer"