From 548604a7967bc129049fab5771711dd0b6973d65 Mon Sep 17 00:00:00 2001 From: Govind Kamtamneni Date: Thu, 22 Oct 2026 07:02:10 -0700 Subject: [PATCH 01/12] feat(hub): create Copilot-only App installation tokens Hub's GitHub App can now create an installation token that holds only the read-only organization permission for Copilot requests. Each call creates a new token, and Hub refuses one that GitHub reports with any other permission, so it never hands out a token that can change repositories. A missing permission raises its own error. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- hub/app/models/github_app_configuration.rb | 6 +++ .../models/github_app_configuration/tokens.rb | 45 +++++++++++++++++++ hub/app/models/github_client/installations.rb | 8 ++-- .../github_app_configuration/tokens_test.rb | 18 ++++++++ hub/test/support/github_request_stubs.rb | 19 ++++++++ 5 files changed, 93 insertions(+), 3 deletions(-) diff --git a/hub/app/models/github_app_configuration.rb b/hub/app/models/github_app_configuration.rb index 2d90a4a2..5a4a7506 100644 --- a/hub/app/models/github_app_configuration.rb +++ b/hub/app/models/github_app_configuration.rb @@ -125,6 +125,12 @@ def credential_snapshot { configuration_id: id, credential_revision: } end + # Whether the App has an active installation on an organization, the only kind of installation + # whose own tokens Copilot accepts and bills. + def organization_installation? + owner_type == "Organization" && installation_state == "active" + end + # Refreshes the installation and its repositories from GitHub. def synchronize_repositories! return if LocalDevelopment.enabled? diff --git a/hub/app/models/github_app_configuration/tokens.rb b/hub/app/models/github_app_configuration/tokens.rb index 29361849..f0b437b0 100644 --- a/hub/app/models/github_app_configuration/tokens.rb +++ b/hub/app/models/github_app_configuration/tokens.rb @@ -1,5 +1,15 @@ # Creates the short-lived tokens used for GitHub App requests. class GithubAppConfiguration::Tokens < ActiveRecord::AssociatedObject + # GitHub refused to create a Copilot token because the installation lacks the App's Copilot + # Requests organization permission. + class CopilotPermissionMissing < GithubClient::Error; end + + # The only permission a Copilot token asks for. + COPILOT_PERMISSIONS = { organization_copilot_requests: "read" }.freeze + # What GitHub may report a Copilot token holds: the Copilot permission, and the read-only metadata + # access that GitHub can add to any installation token. + COPILOT_TOKEN_PERMISSIONS = [ %w[organization_copilot_requests read], %w[metadata read] ].freeze + # Creates a short-lived token that authenticates as the GitHub App. def app now = Time.current.to_i @@ -43,4 +53,39 @@ def for_installation(installation_id) rescue KeyError, TypeError, ArgumentError raise GithubClient::InvalidResponse, "GitHub returned an incomplete installation token" end + + # Creates a new one-hour token for one replica-learning command. It can only make Copilot requests, + # which Copilot bills to the organization that installed the App. Hub never reuses it, never uses it + # for other GitHub requests, and never stores it. Raises CopilotPermissionMissing when the + # installation has not granted the App's Copilot Requests organization permission. + def copilot + raise GithubClient::InvalidResponse, "The GitHub App installation is not active" if record.installation_state != "active" + + payload = record.github_client.installations.create_token \ + record.installation_id, + token: app, + permissions: COPILOT_PERMISSIONS + raise KeyError if !payload.is_a?(Hash) + + token = payload.fetch("token") + expires_at = Time.iso8601(payload.fetch("expires_at")) + raise KeyError if !token.is_a?(String) || token.blank? || expires_at <= Time.current + + # GitHub grants only the requested permissions. A token with more must not reach Copilot's runtime. + granted = payload.fetch("permissions") + if !granted.is_a?(Hash) || granted.any? { |name, access| !COPILOT_TOKEN_PERMISSIONS.include?([ name, access ]) } || + granted["organization_copilot_requests"] != "read" + raise GithubClient::InvalidResponse, "GitHub returned a Copilot token with other permissions" + end + + token + rescue GithubClient::Error => error + raise if error.status != 422 + + raise CopilotPermissionMissing.new \ + "The GitHub App installation has not granted the Copilot Requests organization permission", + status: error.status + rescue KeyError, TypeError, ArgumentError + raise GithubClient::InvalidResponse, "GitHub returned an incomplete installation token" + end end diff --git a/hub/app/models/github_client/installations.rb b/hub/app/models/github_client/installations.rb index ef3b5bbc..f36a53fa 100644 --- a/hub/app/models/github_client/installations.rb +++ b/hub/app/models/github_client/installations.rb @@ -10,9 +10,11 @@ def get(id, token:) transport.request(:get, "/app/installations/#{id.to_i}", token:) end - # Creates one access token for a GitHub App installation. - def create_token(id, token:) - transport.request(:post, "/app/installations/#{id.to_i}/access_tokens", token:) + # Creates one access token for a GitHub App installation, limited to the given permissions when + # there are any. + def create_token(id, token:, permissions: nil) + body = { permissions: } if permissions + transport.request(:post, "/app/installations/#{id.to_i}/access_tokens", token:, body:) end # Lists every repository available to a GitHub App installation. diff --git a/hub/test/models/github_app_configuration/tokens_test.rb b/hub/test/models/github_app_configuration/tokens_test.rb index bb7abfbf..77bea22c 100644 --- a/hub/test/models/github_app_configuration/tokens_test.rb +++ b/hub/test/models/github_app_configuration/tokens_test.rb @@ -62,4 +62,22 @@ class GithubAppConfiguration::TokensTest < ActiveSupport::TestCase assert_raises(GithubClient::InvalidResponse) { configuration.tokens.installation } end end + + test "creates a new Copilot token for every request with only the Copilot permission, never the cached installation token" do + configuration = github_app_configurations.create_installed + stub_github_installation_token + configuration.tokens.installation + stub_github_copilot_token("copilot-token-1", "copilot-token-2") + + assert_equal %w[copilot-token-1 copilot-token-2], [ configuration.tokens.copilot, configuration.tokens.copilot ] + end + + test "rejects a Copilot token that GitHub reports with another permission or without the Copilot permission" do + configuration = github_app_configurations.create_installed + [ { organization_copilot_requests: "read", contents: "read" }, { metadata: "read" } ].each do |permissions| + stub_github_copilot_token("copilot-token", permissions:) + + assert_raises(GithubClient::InvalidResponse, permissions.inspect) { configuration.tokens.copilot } + end + end end diff --git a/hub/test/support/github_request_stubs.rb b/hub/test/support/github_request_stubs.rb index 1e5ea5f5..838a2c2e 100644 --- a/hub/test/support/github_request_stubs.rb +++ b/hub/test/support/github_request_stubs.rb @@ -27,6 +27,25 @@ def stub_github_installation_token(installation_id: 456) headers: { "Content-Type" => "application/json" } end + # Stubs the new token Hub's GitHub App creates for each Copilot request, which may ask only for + # the Copilot Requests organization permission. GitHub answers with each token in turn and repeats + # the last, or fails every request with error_status. + def stub_github_copilot_token(*tokens, installation_id: 456, error_status: nil, + permissions: { organization_copilot_requests: "read", metadata: "read" }) + request = stub_request(:post, "#{GITHUB_API_URL}/app/installations/#{installation_id}/access_tokens") + .with(body: { permissions: { organization_copilot_requests: "read" } }) + return request.to_return(status: error_status, body: JSON.generate(message: "Validation Failed")) if error_status + + responses = tokens.map do |token| + { + status: 201, + body: JSON.generate(token:, expires_at: 1.hour.from_now.iso8601, permissions:), + headers: { "Content-Type" => "application/json" } + } + end + request.to_return(*responses) + end + # Stubs GitHub's OAuth exchange and stable user profile response. def stub_github_oauth_user(github_id: 1001, login: "octocat") stub_request(:post, "#{GITHUB_URL}/login/oauth/access_token") From ebefe9eec1e75af90c2748b044db5a5b886a3808 Mon Sep 17 00:00:00 2001 From: Govind Kamtamneni Date: Thu, 22 Oct 2026 07:02:24 -0700 Subject: [PATCH 02/12] feat(hub): bill Learnings' Copilot use to the organization The person who runs Hub can set REPLICA_COPILOT_BILLING=organization. Hub then ignores REPLICA_COPILOT_TOKEN and, for each Copilot request, passes replica-learning a new one-hour token from its GitHub App that can only make Copilot requests, so Copilot bills the organization that installed the App instead of a person. The default, token_owner, keeps today's behavior, and Hub never falls back from one to the other. The connection now records the credential's identity: the operator's token, or the App installation. Hub bills an organization only after a Hub administrator connects Copilot for that installation, so turning on organization billing never moves an existing connection to the organization's bill. A missing permission or a Copilot refusal is recorded against the installation and stops the daily runs until an administrator checks again. The Copilot and Learnings pages say who Copilot bills and what Hub needs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../copilot_connections_controller.rb | 13 +- hub/app/helpers/copilot_connections_helper.rb | 10 + .../copilot-connection-page/index.test.tsx | 5 + .../pages/copilot-connection-page/index.tsx | 5 +- .../pages/copilot-connection-page/stories.tsx | 74 +++++++- .../pages/copilot-connection-page/types.ts | 3 +- hub/app/models/copilot_connection.rb | 174 +++++++++++++----- hub/app/models/learning/run.rb | 17 +- .../views/copilot_connections/show.json.props | 27 ++- hub/app/views/learnings/show.json.props | 8 +- hub/config/application.rb | 5 +- .../initializers/deployment_configuration.rb | 5 + hub/config/locales/copilot_connection.en.yml | 24 ++- hub/config/locales/learnings.en.yml | 5 + .../integration/copilot_connections_test.rb | 77 +++++++- hub/test/integration/learnings_test.rb | 16 ++ hub/test/models/copilot_connection_test.rb | 122 ++++++++++-- hub/test/models/learning/run_test.rb | 38 ++++ hub/test/models/learning/test_helpers_test.rb | 10 +- hub/test/support/learning_test_helpers.rb | 14 ++ 20 files changed, 552 insertions(+), 100 deletions(-) create mode 100644 hub/app/helpers/copilot_connections_helper.rb diff --git a/hub/app/controllers/copilot_connections_controller.rb b/hub/app/controllers/copilot_connections_controller.rb index dfbbb1c0..cca31b1b 100644 --- a/hub/app/controllers/copilot_connections_controller.rb +++ b/hub/app/controllers/copilot_connections_controller.rb @@ -1,6 +1,6 @@ -# Lets Hub administrators connect Copilot with the operator's token, see who connected it, and -# disconnect it. Both changes need fresh GitHub proof, like Hub's other administrator settings. -# Hub keeps no Copilot credential; see CopilotConnection. +# Lets Hub administrators connect Copilot with the credential the operator configured, see who +# connected it, and disconnect it. Both changes need fresh GitHub proof, like Hub's other +# administrator settings. Hub keeps no Copilot credential; see CopilotConnection. class CopilotConnectionsController < SuperglueController include FreshAuthorizationRequired @@ -26,11 +26,12 @@ def create redirect_to copilot_connection_path, notice: t("copilot_connection.connected"), status: :see_other rescue CopilotConnection::Refused, Learning::Error => error reason, outcome = case error + when CopilotConnection::PermissionMissing then [ :permission_missing, "denied" ] when CopilotConnection::Refused then [ :refused, "denied" ] else [ error.code == "not_installed" ? :not_installed : :unavailable, "failed" ] end record_security_audit(outcome:) - redirect_to copilot_connection_path, alert: t("copilot_connection.errors.#{reason}"), status: :see_other + redirect_to copilot_connection_path, alert: helpers.copilot_billing_t("errors.#{reason}"), status: :see_other end # @route DELETE /copilot (copilot_connection) @@ -42,7 +43,7 @@ def destroy private - # Local development has no Copilot token, so it uses the developer's own Copilot sign-in instead. + # Local development has no Copilot credential, so it uses the developer's own Copilot sign-in instead. def require_github_hub head :not_found if LocalDevelopment.enabled? end @@ -85,7 +86,7 @@ def change_denied(error) else redirect_to \ copilot_connection_path, - alert: t("copilot_connection.errors.#{error.reason}"), + alert: helpers.copilot_billing_t("errors.#{error.reason}"), status: :see_other end end diff --git a/hub/app/helpers/copilot_connections_helper.rb b/hub/app/helpers/copilot_connections_helper.rb new file mode 100644 index 00000000..8f3347a5 --- /dev/null +++ b/hub/app/helpers/copilot_connections_helper.rb @@ -0,0 +1,10 @@ +# Describes the Copilot credential the operator configured, in the words that fit who Copilot bills. +module CopilotConnectionsHelper + # Translates key under scope. When Copilot bills the organization that installed Hub's GitHub App, + # the organization's wording under scope.organization replaces the token owner's where it differs. + def copilot_billing_t(key, scope: "copilot_connection", **options) + return t("#{scope}.#{key}", **options) if !CopilotConnection.organization_billing? + + t("#{scope}.organization.#{key}", default: :"#{scope}.#{key}", **options) + end +end diff --git a/hub/app/javascript/pages/copilot-connection-page/index.test.tsx b/hub/app/javascript/pages/copilot-connection-page/index.test.tsx index 2b49eada..99b16680 100644 --- a/hub/app/javascript/pages/copilot-connection-page/index.test.tsx +++ b/hub/app/javascript/pages/copilot-connection-page/index.test.tsx @@ -18,6 +18,11 @@ describe("CopilotConnectionPage()", () => { it("asks for confirmation before connecting and says who pays for the usage", () => { renderPage(story.args.data) expect(screen.getByRole("heading", { name: "Copilot is not connected" })).toBeInTheDocument() + expect( + screen.getByText( + "Copilot counts the usage toward the Copilot plan of the person who owns the token in REPLICA_COPILOT_TOKEN.", + ), + ).toBeInTheDocument() expect( screen.getByText(/The usage counts toward the Copilot plan of the token's owner/), ).toBeInTheDocument() diff --git a/hub/app/javascript/pages/copilot-connection-page/index.tsx b/hub/app/javascript/pages/copilot-connection-page/index.tsx index a2704911..c0e24a54 100644 --- a/hub/app/javascript/pages/copilot-connection-page/index.tsx +++ b/hub/app/javascript/pages/copilot-connection-page/index.tsx @@ -6,7 +6,7 @@ import { HubAdministrationPage } from "@features/hub-administration-page" import type { CopilotConnectionPageData } from "./types" import "./styles.css" -/** Shows who connected Copilot, whether Hub has a token, and each change's consequence beside its confirmation. */ +/** Shows who connected Copilot, who Copilot bills, and each change's consequence beside its confirmation. */ export function CopilotConnectionPage({ data }: { data: CopilotConnectionPageData }) { return ( @@ -27,7 +27,8 @@ export function CopilotConnectionPage({ data }: { data: CopilotConnectionPageDat ) : ( {data.emptyLabel} )} - {data.tokenLabel ? : null} + {data.billingLabel ? {data.billingLabel} : null} + {data.setupLabel ? : null} {data.refusedLabel ? : null} diff --git a/hub/app/javascript/pages/copilot-connection-page/stories.tsx b/hub/app/javascript/pages/copilot-connection-page/stories.tsx index 894aa7c1..ebaf5c63 100644 --- a/hub/app/javascript/pages/copilot-connection-page/stories.tsx +++ b/hub/app/javascript/pages/copilot-connection-page/stories.tsx @@ -21,7 +21,9 @@ const shared = { "A Hub administrator connects Copilot once for the whole Hub. Hub then asks Copilot to suggest learnings from prototype review comments, with a token that the person who runs Hub provides, so it works while everyone is signed out.", emptyLabel: "Learnings stays off until a Hub administrator connects Copilot.", submitPath: "/copilot", - tokenLabel: null, + billingLabel: + "Copilot counts the usage toward the Copilot plan of the person who owns the token in REPLICA_COPILOT_TOKEN.", + setupLabel: null, } const verification = @@ -79,7 +81,8 @@ export const NoToken: Story = { args: { data: { ...data, - tokenLabel: + billingLabel: null, + setupLabel: "Hub has no Copilot token. The person who runs Hub sets REPLICA_COPILOT_TOKEN to a fine-grained personal access token whose only permission is Copilot requests, then restarts Hub.", connect: null, }, @@ -98,6 +101,73 @@ export const Refused: Story = { }, }, } +const organizationDescription = + "A Hub administrator connects Copilot once for the whole Hub. Hub then asks Copilot to suggest learnings from prototype review comments, with tokens from Hub's GitHub App, so it works while everyone is signed out." + +const organizationBilling = + "Copilot bills the usage to the github organization, which installed Hub's GitHub App, and applies its Copilot policies and budgets." + +const organizationConnect = { + ...connect, + consequence: + "Hub will ask Copilot to read new review comments from every repository on this Hub and suggest learnings, including while you are signed out. Copilot never sees who wrote a comment. Copilot bills the usage to the github organization. For each request, Hub's GitHub App creates a new one-hour token that can only make Copilot requests. Hub checks with Copilot first, and never stores or shows a token.", + confirmLabel: "I understand that Hub will bill this Hub's Copilot usage to github.", +} + +const organizationDisconnect = { + ...disconnect, + consequence: + "Hub stops looking for learnings and turns off Learnings for every repository. Hub keeps every learning and decision, so connecting again restores them. Hub's GitHub App keeps its Copilot Requests permission until someone removes it in the App's settings.", +} + +export const OrganizationBilling: Story = { + args: { + data: { + ...data, + description: organizationDescription, + billingLabel: organizationBilling, + connect: organizationConnect, + }, + }, +} +export const OrganizationRefused: Story = { + args: { + data: { + ...connected, + description: organizationDescription, + billingLabel: organizationBilling, + refusedLabel: + "Copilot refused Hub's GitHub App on October 22, 2026, so Hub stopped looking for learnings. Check that the App has the Copilot Requests organization permission and that github allows Copilot requests from GitHub Apps, then check Copilot again.", + connect: { ...organizationConnect, label: "Check Copilot again" }, + disconnect: organizationDisconnect, + }, + }, +} +export const OrganizationUnconfirmed: Story = { + args: { + data: { + ...connected, + description: organizationDescription, + billingLabel: organizationBilling, + setupLabel: + "Hub bills Copilot to an organization only after a Hub administrator confirms it, so Hub stopped looking for learnings. Connect Copilot to let Copilot bill github.", + connect: organizationConnect, + disconnect: organizationDisconnect, + }, + }, +} +export const NoOrganizationInstallation: Story = { + args: { + data: { + ...data, + description: organizationDescription, + billingLabel: null, + setupLabel: + "Hub's GitHub App has no active installation on an organization, so Hub cannot bill Copilot to one. Install the App on an organization, or the person who runs Hub can remove REPLICA_COPILOT_BILLING and give Hub a token instead.", + connect: null, + }, + }, +} export const AccessRemoved: Story = { args: { data: { diff --git a/hub/app/javascript/pages/copilot-connection-page/types.ts b/hub/app/javascript/pages/copilot-connection-page/types.ts index 7001ad86..4665723d 100644 --- a/hub/app/javascript/pages/copilot-connection-page/types.ts +++ b/hub/app/javascript/pages/copilot-connection-page/types.ts @@ -19,7 +19,8 @@ export type CopilotConnectionPageData = connected: boolean statusLabel: string submitPath: string - tokenLabel: string | null + billingLabel: string | null + setupLabel: string | null connectedBy?: string verifiedLabel?: string usageLabel?: string diff --git a/hub/app/models/copilot_connection.rb b/hub/app/models/copilot_connection.rb index d69ef008..2ab791ab 100644 --- a/hub/app/models/copilot_connection.rb +++ b/hub/app/models/copilot_connection.rb @@ -27,20 +27,29 @@ # copilot_connections_token_digest (length(token_digest) = 64 AND token_digest NOT GLOB '*[^0-9a-f]*') # # Records that a Hub administrator connected Copilot, which turns on Learnings for this Hub, and -# what Copilot last said about the token Hub uses. +# what Copilot last said about the credential Hub uses. # -# Hub keeps no Copilot credential. The operator provides REPLICA_COPILOT_TOKEN, a fine-grained -# personal access token whose only permission is Copilot requests, the way they provide Hub's other -# secrets. Hub passes it only to replica-learning, and Copilot counts the usage toward the token -# owner's plan. The connection keeps the token's SHA-256 digest, so a refusal applies only to the -# token Copilot refused, and a replaced token starts fresh. The connection ends when an +# Hub keeps no Copilot credential. The operator chooses who Copilot bills with +# REPLICA_COPILOT_BILLING. By default Hub uses REPLICA_COPILOT_TOKEN, a fine-grained personal access +# token whose only permission is Copilot requests, and Copilot counts the usage toward the token +# owner's plan. With organization billing, Hub's GitHub App creates a new one-hour token for each +# Copilot request, which can make only Copilot requests, and Copilot bills the organization that +# installed the App. Hub passes either token only to replica-learning. +# +# The connection keeps the SHA-256 digest of the credential: the operator's token, or for +# organization billing the App installation that creates tokens. A refusal applies only to the +# credential Copilot refused. A replaced token starts fresh, but Hub bills an organization only after +# an administrator connects Copilot for that App installation. The connection ends when an # administrator disconnects it or when the person who connected it stops being a Hub administrator. class CopilotConnection < ApplicationRecord # The Copilot check that connecting starts usually takes about a second. CHECK_TIME = 20.seconds - # Copilot refused the token, or Hub has none, so Hub cannot make Copilot requests. + # Copilot refused the credential, or Hub has none, so Hub cannot make Copilot requests. class Refused < StandardError; end + # GitHub will not create Copilot tokens for Hub's GitHub App, because the installation has not + # granted the App's Copilot Requests organization permission. + class PermissionMissing < Refused; end class ChangeDenied < StandardError attr_reader :reason @@ -62,33 +71,56 @@ def current find_by(singleton_key: 1) end - # The Copilot token the operator gave Hub in REPLICA_COPILOT_TOKEN, or nil. + # Whether Copilot bills the organization that installed Hub's GitHub App, as the operator chose + # with REPLICA_COPILOT_BILLING, instead of the owner of the operator's token. + def organization_billing? + Rails.configuration.x.copilot_billing == "organization" + end + + # The Copilot token the operator gave Hub in REPLICA_COPILOT_TOKEN, or nil. Organization billing + # ignores it. def token - Rails.configuration.x.copilot_token.presence + Rails.configuration.x.copilot_token.presence if !organization_billing? + end + + # The organization Copilot bills, when the operator chose organization billing and Hub's GitHub + # App has an active installation on an organization, or nil. + def organization + configuration = GithubAppConfiguration.current + configuration.owner_login if organization_billing? && configuration&.organization_installation? + end + + # What Hub makes Copilot requests with, or nil when it has nothing: the operator's token, or the + # organization installation of Hub's GitHub App, which creates a token for each request. + def credential + return token if !organization_billing? + + configuration = GithubAppConfiguration.current + "github-app-installation:#{configuration.installation_id}" if configuration&.organization_installation? end - # A token's SHA-256 digest, which tells tokens apart without keeping them. - def digest(token) - Digest::SHA256.hexdigest(token) + # A credential's SHA-256 digest, which tells credentials apart without keeping them. + def digest(credential) + Digest::SHA256.hexdigest(credential) end # Connects Copilot, or checks it again, for a current Hub administrator with fresh GitHub - # proof, once Copilot answers with the operator's token. Raises Refused when Copilot rejects the - # token, and records the refusal on an existing connection. Connecting again clears an earlier - # refusal and names the administrator who connected. A disconnect or replacement while Copilot - # answers cancels this check rather than restoring the earlier enablement. + # proof, once Copilot answers with the configured credential. Raises Refused when Copilot + # rejects it, and records the refusal on an existing connection. Connecting again clears an + # earlier refusal and names the administrator who connected. A disconnect or replacement while + # Copilot answers cancels this check rather than restoring the earlier enablement. def connect!(actor:, browser_session:) live_actor, = authorized!(actor:, browser_session:) - token = self.token - raise ChangeDenied, :no_token if token.nil? + credential = self.credential + raise ChangeDenied, :no_token if credential.nil? earlier, generation = uncached do transaction { [ current, CopilotConnectionState.current.generation ] } end begin - check!(token) + check!(copilot_token(credential)) rescue Refused - earlier&.refuse!(token:) + earlier&.refuse!(credential:) raise end uncached do @@ -101,7 +133,7 @@ def connect!(actor:, browser_session:) where(singleton_key: 1).delete_all connection = create! \ user: live_actor, - token_digest: digest(token), + token_digest: digest(credential), verified_at: Time.current CopilotConnectionState.advance! SecurityAuditEvent.record! \ @@ -114,8 +146,8 @@ def connect!(actor:, browser_session:) end end - # Disconnects Copilot for a current Hub administrator with fresh GitHub proof. The token stays - # where the operator keeps it. + # Disconnects Copilot for a current Hub administrator with fresh GitHub proof. The operator's + # token, or the App's Copilot permission, stays as it is. def disconnect!(actor:, browser_session:) uncached do transaction do @@ -134,6 +166,23 @@ def disconnect!(actor:, browser_session:) end end + # The token for one Copilot request with a credential: the operator's token itself, or a new + # token from Hub's GitHub App that can only make Copilot requests. Raises PermissionMissing when + # GitHub will not create one, and Learning::Error when GitHub cannot be reached. + def copilot_token(credential) + return credential if !organization_billing? + + configuration = GithubAppConfiguration.current + raise Refused, "Hub's GitHub App is not installed on an organization." if configuration.nil? + + configuration.tokens.copilot + rescue GithubAppConfiguration::Tokens::CopilotPermissionMissing + raise PermissionMissing, "Hub's GitHub App does not have the Copilot Requests organization permission." + rescue GithubClient::Error => error + raise Learning::Error.new("GitHub did not create a Copilot token for Hub's GitHub App: #{error.message}", + code: "unavailable") + end + private # Asks Copilot to answer with the token, without labeling anything, or raises Refused. @@ -166,39 +215,58 @@ def audit_context(session_id) end end - # Whether Hub may make Copilot requests now: it has a token, Copilot has not refused that token, - # and the person who connected Copilot is still a Hub administrator. + # Whether Hub may make Copilot requests now: it has a credential, an administrator confirmed who + # Copilot bills, Copilot has not refused that credential, and the person who connected Copilot is + # still a Hub administrator. def usable? - self.class.token.present? && !refused? && User.where(id: user_id, hub_admin: true).exists? + self.class.credential.present? && confirmed? && !refused? && + User.where(id: user_id, hub_admin: true).exists? end - # Whether Copilot refused the token Hub has now. A refusal of an earlier token does not count. + # Whether an administrator confirmed who Copilot bills. The operator may replace a token without a + # new confirmation, but Hub bills an organization only after an administrator connects Copilot for + # that App installation, so turning on organization billing never moves an existing connection to + # the organization's bill. + def confirmed? + return true if !self.class.organization_billing? + + credential = self.class.credential + credential.present? && token_digest == self.class.digest(credential) + end + + # Whether Copilot refused the credential Hub has now. A refusal of an earlier one does not count. def refused? - token = self.class.token - refused_at.present? && token.present? && token_digest == self.class.digest(token) + credential = self.class.credential + refused_at.present? && credential.present? && token_digest == self.class.digest(credential) end - # The operator's token for one Copilot request, or Refused when Hub cannot use it. Hub reads the - # connection again first, so an instance loaded earlier never hands out a token that an - # administrator has since disconnected or Copilot has since refused. + # The token for one Copilot request, or Refused when Hub cannot use Copilot. Hub reads the + # connection again first, so an instance loaded earlier never hands out a token after an + # administrator disconnects Copilot or Copilot refuses the credential. With organization billing, + # each call creates a new token. def access_token! reload - token = self.class.token - raise Refused, "Hub has no Copilot token. Set REPLICA_COPILOT_TOKEN and restart Hub." if token.nil? - raise Refused, "Copilot refused this token on #{refused_at.to_date}." if refused? + credential = self.class.credential + if credential.nil? + raise Refused, self.class.organization_billing? ? + "Hub's GitHub App has no active installation on an organization, which organization billing needs." : + "Hub has no Copilot token. Set REPLICA_COPILOT_TOKEN and restart Hub." + end + raise Refused, "Copilot refused this credential on #{refused_at.to_date}." if refused? + raise Refused, "A Hub administrator must connect Copilot again before Hub bills the organization." if !confirmed? raise Refused, "#{user.login} is no longer a Hub administrator." if !usable? - token + self.class.copilot_token(credential) rescue ActiveRecord::RecordNotFound raise Refused, "An administrator disconnected Copilot." end - # Records that Copilot refused a token, so Hub stops using it until an administrator checks it - # again or the operator replaces it. Results that this worker can identify as superseded are + # Records that Copilot refused a credential, so Hub stops using it until an administrator checks + # it again or the operator replaces it. Results that this worker can identify as superseded are # ignored; token replacement still requires the non-overlapping deployment in docs/copilot.md. # Returns whether it recorded a new refusal. - def refuse!(token: self.class.token) - with_current_token(token) do |digest| + def refuse!(credential: self.class.credential) + with_current_credential(credential) do |digest| next false if refused_at && token_digest == digest update!(refused_at: Time.current, token_digest: digest) @@ -207,11 +275,14 @@ def refuse!(token: self.class.token) end end - # Records that Copilot answered with a still-current token. A request already in flight cannot - # clear that token's refusal; only an administrator's explicit recheck can. Returns whether saved. - def verified!(token: self.class.token) - with_current_token(token) do |digest| + # Records that Copilot answered with a still-current credential. A request already in flight + # cannot clear that credential's refusal; only an administrator's explicit recheck can. Nor can it + # confirm organization billing for a connection made with another credential. Returns whether + # saved. + def verified!(credential: self.class.credential) + with_current_credential(credential) do |digest| next false if refused_at && token_digest == digest + next false if self.class.organization_billing? && token_digest != digest update!(verified_at: Time.current, refused_at: nil, token_digest: digest) true @@ -220,17 +291,18 @@ def verified!(token: self.class.token) private - # Serializes a result with disconnect and other results. A rotated token may replace the state - # this request read, but cannot overwrite a different token's intervening result. A digest does - # not order configurations across processes; rolling token replacement is not supported. - def with_current_token(token) - return false if token.blank? + # Serializes a result with disconnect and other results. A replaced credential may replace the + # state this request read, but cannot overwrite a different credential's intervening result. A + # digest does not order configurations across processes; rolling token replacement is not + # supported. + def with_current_credential(credential) + return false if credential.blank? previous_digest = token_digest_in_database transaction do reload - digest = self.class.digest(token) - next false if self.class.token != token || (token_digest != previous_digest && token_digest != digest) + digest = self.class.digest(credential) + next false if self.class.credential != credential || (token_digest != previous_digest && token_digest != digest) yield digest end diff --git a/hub/app/models/learning/run.rb b/hub/app/models/learning/run.rb index 749920f6..4a63a829 100644 --- a/hub/app/models/learning/run.rb +++ b/hub/app/models/learning/run.rb @@ -222,7 +222,7 @@ def find_learnings_in_store! captured_at = Time.current Learning::Collection.new(store, captured_at:).import!(store.grant_ids!(by: grantor, basis:)) label(store, since: (captured_at - WINDOW).utc.iso8601(6)) - @connection&.verified!(token: @copilot_token) if copilot_requests.positive? + @connection&.verified!(credential: @copilot_credential) if copilot_requests.positive? consolidation = store.call \ "consolidate", "--from", (captured_at - WINDOW).utc.iso8601(6), @@ -230,10 +230,13 @@ def find_learnings_in_store! "--actor", ACTOR, "--max", MAX_SUGGESTIONS.to_s succeed!(consolidation) + rescue CopilotConnection::PermissionMissing => error + @connection&.refuse!(credential: @copilot_credential) + fail!("copilot_refused", error.message) rescue CopilotConnection::Refused => error fail!("copilot_refused", error.message) rescue Learning::Error => error - @connection&.refuse!(token: @copilot_token) if error.code == "unauthorized" + @connection&.refuse!(credential: @copilot_credential) if error.code == "unauthorized" fail!(error.code, error.message) rescue StandardError fail!("failed", "Hub could not finish this run.") @@ -273,16 +276,18 @@ def claim! claimed == 1 end - # The operator's Copilot token while the connection allows Hub to use it, or none in local - # development, where Copilot uses whoever is signed in. Remembers both, so a refusal applies only - # to the token Copilot actually refused. + # A token for one Copilot request while the connection allows Hub to use Copilot, or none in local + # development, where Copilot uses whoever is signed in. With organization billing, each request gets + # a new token. Remembers the connection and the credential behind the token, so a refusal applies + # only to the credential Copilot actually refused. def copilot_token return nil if LocalDevelopment.enabled? @connection = CopilotConnection.current raise CopilotConnection::Refused, "Copilot is not connected." if @connection.nil? - @copilot_token = @connection.access_token! + @copilot_credential = CopilotConnection.credential + @connection.access_token! end # Who allowed Hub to use this repository's comments: the person who connected Copilot, or the diff --git a/hub/app/views/copilot_connections/show.json.props b/hub/app/views/copilot_connections/show.json.props index 6027df89..791684eb 100644 --- a/hub/app/views/copilot_connections/show.json.props +++ b/hub/app/views/copilot_connections/show.json.props @@ -1,17 +1,26 @@ json.partial! partial: "hub_admins/navigation" json.pageTitle t("copilot_connection.heading") json.heading t("copilot_connection.heading") -json.description t("copilot_connection.description") +json.description copilot_billing_t("description") json.accessDenied !!@access_denied json.emptyLabel t(@access_denied ? "copilot_connection.no_access" : "copilot_connection.empty") if !@access_denied connection = @connection - token = CopilotConnection.token.present? + credential = CopilotConnection.credential.present? + organization = CopilotConnection.organization refused = connection&.refused? + confirmed = connection.nil? || connection.confirmed? json.connected connection.present? json.statusLabel t("copilot_connection.#{connection ? 'connected' : 'disconnected'}_status") json.submitPath copilot_connection_path - json.tokenLabel(token ? nil : t("copilot_connection.no_token")) + json.billingLabel(credential ? copilot_billing_t("billing", organization:) : nil) + json.setupLabel( + if !credential + copilot_billing_t("no_token") + elsif !confirmed + t("copilot_connection.organization.unconfirmed", organization:) + end + ) if connection login = "@#{connection.user.login}" json.connectedBy t("copilot_connection.connected_by", login:, date: connection.created_at.to_date.to_fs(:long)) @@ -21,19 +30,19 @@ if !@access_denied credits: number_with_precision(recent.sum(:nano_aiu).to_d / 1_000_000_000, precision: 3, significant: true, strip_insignificant_zeros: true), requests: t("copilot_connection.requests", count: recent.sum(:copilot_requests))) - json.refusedLabel(refused ? t("copilot_connection.refused", date: connection.refused_at.to_date.to_fs(:long)) : nil) + json.refusedLabel(refused ? copilot_billing_t("refused", date: connection.refused_at.to_date.to_fs(:long), organization:) : nil) json.disconnect do json.label t("copilot_connection.disconnect.label") - json.consequence t("copilot_connection.disconnect.consequence") + json.consequence copilot_billing_t("disconnect.consequence") json.confirmLabel t("copilot_connection.disconnect.confirm") json.verification t("copilot_connection.verification") end end - if token && (connection.nil? || refused) + if credential && (connection.nil? || refused || !confirmed) json.connect do - json.label t("copilot_connection.#{connection ? 'reconnect' : 'connect'}.label") - json.consequence t("copilot_connection.connect.consequence") - json.confirmLabel t("copilot_connection.connect.confirm") + json.label copilot_billing_t("#{connection && confirmed ? 'reconnect' : 'connect'}.label") + json.consequence copilot_billing_t("connect.consequence", organization:) + json.confirmLabel copilot_billing_t("connect.confirm", organization:) json.verification t("copilot_connection.verification") end else diff --git a/hub/app/views/learnings/show.json.props b/hub/app/views/learnings/show.json.props index 0f9a5f87..3bf367dc 100644 --- a/hub/app/views/learnings/show.json.props +++ b/hub/app/views/learnings/show.json.props @@ -80,10 +80,12 @@ json.status do json.message( if LocalDevelopment.enabled? t("learnings.status.local") - elsif CopilotConnection.token.nil? - t("learnings.status.no_token") + elsif CopilotConnection.credential.nil? + copilot_billing_t("status.no_token", scope: "learnings") elsif @connection&.refused? - t("learnings.status.refused", date: @connection.refused_at.to_date.to_fs(:long)) + copilot_billing_t("status.refused", scope: "learnings", date: @connection.refused_at.to_date.to_fs(:long)) + elsif @connection && !@connection.confirmed? + t("learnings.organization.status.unconfirmed") else t("learnings.status.scheduled", login: @connection&.user&.login) end diff --git a/hub/config/application.rb b/hub/config/application.rb index ac8fcab9..0fbba14f 100644 --- a/hub/config/application.rb +++ b/hub/config/application.rb @@ -81,8 +81,11 @@ class Application < Rails::Application config.x.azure_storage_account = ENV["REPLICA_AZURE_STORAGE_ACCOUNT"] config.x.azure_storage_container = ENV["REPLICA_AZURE_STORAGE_CONTAINER"] config.x.azure_managed_identity_client_id = ENV["REPLICA_AZURE_MANAGED_IDENTITY_CLIENT_ID"] + # Who Copilot bills for Learnings: token_owner, the owner of REPLICA_COPILOT_TOKEN, or + # organization, the organization that installed Hub's GitHub App. See docs/copilot.md. + config.x.copilot_billing = ENV["REPLICA_COPILOT_BILLING"].presence || "token_owner" # A fine-grained personal access token whose only permission is Copilot requests. Hub passes it - # only to replica-learning and never stores it. See docs/copilot.md. + # only to replica-learning and never stores it. Organization billing ignores it. config.x.copilot_token = ENV["REPLICA_COPILOT_TOKEN"].presence end end diff --git a/hub/config/initializers/deployment_configuration.rb b/hub/config/initializers/deployment_configuration.rb index e2032ca8..6022b28e 100644 --- a/hub/config/initializers/deployment_configuration.rb +++ b/hub/config/initializers/deployment_configuration.rb @@ -27,6 +27,11 @@ raise "REPLICA_LOCAL_MODE is available only in development" end +if !%w[token_owner organization].include?(Rails.configuration.x.copilot_billing) + raise \ + "REPLICA_COPILOT_BILLING must be token_owner or organization: #{Rails.configuration.x.copilot_billing.inspect}" +end + azure_uuid_format = /\A[0-9a-fA-F]{8}-(?:[0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}\z/ build_action_format = \ %r{\A[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)*@[0-9a-fA-F]{40}\z} diff --git a/hub/config/locales/copilot_connection.en.yml b/hub/config/locales/copilot_connection.en.yml index e301196f..7305e549 100644 --- a/hub/config/locales/copilot_connection.en.yml +++ b/hub/config/locales/copilot_connection.en.yml @@ -12,6 +12,7 @@ en: other: "%{count} Copilot requests" refused: "Copilot refused the token on %{date}, so Hub stopped looking for learnings. Replace REPLICA_COPILOT_TOKEN and restart Hub, or check the token again." no_token: Hub has no Copilot token. The person who runs Hub sets REPLICA_COPILOT_TOKEN to a fine-grained personal access token whose only permission is Copilot requests, then restarts Hub. + billing: Copilot counts the usage toward the Copilot plan of the person who owns the token in REPLICA_COPILOT_TOKEN. empty: Learnings stays off until a Hub administrator connects Copilot. verification: This change requires GitHub verification within the last five minutes on this browser. If asked to verify, return and confirm again. Verification never submits this change. connect: @@ -31,8 +32,27 @@ en: not_authorized: Your account is not a current Hub administrator. Copilot was not changed. confirmation_required: Confirm the Copilot change explicitly. Copilot was not changed. not_connected: Copilot is not connected, so there is nothing to disconnect. - connection_changed: The Copilot connection changed while Hub checked the token. Hub did not change it. Review the current connection before trying again. + connection_changed: The Copilot connection changed while Hub checked Copilot. Hub did not change it. Review the current connection before trying again. no_token: Hub has no Copilot token, so Copilot was not connected. Set REPLICA_COPILOT_TOKEN and restart Hub. refused: Copilot refused the token, so Copilot was not connected. Check that the token's owner has Copilot and that the token has the Copilot requests permission. unavailable: Hub could not reach Copilot to check the token, so Copilot was not connected. Try again later. - not_installed: Hub could not check the token because replica-learning or a package it needs is not installed here. Run bin/setup, then try again. + not_installed: Hub could not check Copilot because replica-learning or a package it needs is not installed here. Run bin/setup, then try again. + permission_missing: Hub's GitHub App does not have the Copilot Requests organization permission, so Copilot was not connected. Add the permission in the App's settings, have an organization owner approve it, then try again. + # Replaces the wording above when the person who runs Hub sets REPLICA_COPILOT_BILLING to organization. + organization: + description: A Hub administrator connects Copilot once for the whole Hub. Hub then asks Copilot to suggest learnings from prototype review comments, with tokens from Hub's GitHub App, so it works while everyone is signed out. + billing: "Copilot bills the usage to the %{organization} organization, which installed Hub's GitHub App, and applies its Copilot policies and budgets." + no_token: Hub's GitHub App has no active installation on an organization, so Hub cannot bill Copilot to one. Install the App on an organization, or the person who runs Hub can remove REPLICA_COPILOT_BILLING and give Hub a token instead. + unconfirmed: "Hub bills Copilot to an organization only after a Hub administrator confirms it, so Hub stopped looking for learnings. Connect Copilot to let Copilot bill %{organization}." + refused: "Copilot refused Hub's GitHub App on %{date}, so Hub stopped looking for learnings. Check that the App has the Copilot Requests organization permission and that %{organization} allows Copilot requests from GitHub Apps, then check Copilot again." + connect: + consequence: "Hub will ask Copilot to read new review comments from every repository on this Hub and suggest learnings, including while you are signed out. Copilot never sees who wrote a comment. Copilot bills the usage to the %{organization} organization. For each request, Hub's GitHub App creates a new one-hour token that can only make Copilot requests. Hub checks with Copilot first, and never stores or shows a token." + confirm: "I understand that Hub will bill this Hub's Copilot usage to %{organization}." + reconnect: + label: Check Copilot again + disconnect: + consequence: Hub stops looking for learnings and turns off Learnings for every repository. Hub keeps every learning and decision, so connecting again restores them. Hub's GitHub App keeps its Copilot Requests permission until someone removes it in the App's settings. + errors: + no_token: Hub's GitHub App has no active installation on an organization, so Copilot was not connected. + refused: Copilot refused Hub's GitHub App, so Copilot was not connected. Check that the organization has Copilot Business or Copilot Enterprise and allows Copilot requests from GitHub Apps. + unavailable: Hub could not reach GitHub or Copilot to check the connection, so Copilot was not connected. Try again later. diff --git a/hub/config/locales/learnings.en.yml b/hub/config/locales/learnings.en.yml index b50c3e02..2fa2aba1 100644 --- a/hub/config/locales/learnings.en.yml +++ b/hub/config/locales/learnings.en.yml @@ -10,6 +10,11 @@ en: queued: Hub will look for learnings shortly. running: Hub is looking for learnings now. usage: "Copilot used %{credits} AI credits for this repository in the last 30 days." + organization: + status: + refused: "Copilot refused Hub's GitHub App on %{date}, so Hub stopped looking for learnings until a Hub administrator checks Copilot again on the Copilot page. You can still decide on suggestions, and agents still get accepted learnings." + no_token: Organization billing needs Hub's GitHub App installed on an organization, so Hub stopped looking for learnings. You can still decide on suggestions, and agents still get accepted learnings. + unconfirmed: Hub stopped looking for learnings until a Hub administrator connects Copilot again on the Copilot page to confirm organization billing. You can still decide on suggestions, and agents still get accepted learnings. run: label: Find learnings now runs: diff --git a/hub/test/integration/copilot_connections_test.rb b/hub/test/integration/copilot_connections_test.rb index d21bdca6..cc215876 100644 --- a/hub/test/integration/copilot_connections_test.rb +++ b/hub/test/integration/copilot_connections_test.rb @@ -16,6 +16,7 @@ class CopilotConnectionsTest < ActionDispatch::IntegrationTest @session = @actor.sessions.sole @previous_cli = ENV["REPLICA_LEARNING_CLI"] @previous_token = Rails.configuration.x.copilot_token + @previous_billing = Rails.configuration.x.copilot_billing ENV["REPLICA_LEARNING_CLI"] = CLI Rails.configuration.x.copilot_token = "copilot-user-token" end @@ -23,11 +24,14 @@ class CopilotConnectionsTest < ActionDispatch::IntegrationTest teardown do ENV["REPLICA_LEARNING_CLI"] = @previous_cli Rails.configuration.x.copilot_token = @previous_token + Rails.configuration.x.copilot_billing = @previous_billing end test "connecting checks the operator's token with Copilot and never shows it" do get copilot_connection_path, as: :json - assert_equal [ false, nil ], [ data.fetch("connected"), data.fetch("tokenLabel") ] + assert_equal \ + [ false, nil, I18n.t("copilot_connection.billing") ], + [ data.fetch("connected"), data.fetch("setupLabel"), data.fetch("billingLabel") ] assert_equal I18n.t("copilot_connection.connect.label"), data.dig("connect", "label") post copilot_connection_path, params: { confirmed: "1" } @@ -69,7 +73,7 @@ class CopilotConnectionsTest < ActionDispatch::IntegrationTest Rails.configuration.x.copilot_token = nil get copilot_connection_path, as: :json - assert_equal I18n.t("copilot_connection.no_token"), data.fetch("tokenLabel") + assert_equal [ I18n.t("copilot_connection.no_token"), nil ], [ data.fetch("setupLabel"), data.fetch("billingLabel") ] assert_nil data.fetch("connect") post copilot_connection_path, params: { confirmed: "1" } @@ -111,6 +115,75 @@ class CopilotConnectionsTest < ActionDispatch::IntegrationTest assert_not CopilotConnection.current.refused? end + test "with organization billing, the page names the organization Copilot bills, also once Copilot refuses Hub's App" do + Rails.configuration.x.copilot_billing = "organization" + Rails.configuration.x.copilot_token = nil + stub_github_copilot_token("copilot-user-token") + + get copilot_connection_path, as: :json + assert_equal \ + [ I18n.t("copilot_connection.organization.billing", organization: "github"), + I18n.t("copilot_connection.organization.connect.consequence", organization: "github"), + I18n.t("copilot_connection.organization.connect.confirm", organization: "github") ], + [ data.fetch("billingLabel"), data.dig("connect", "consequence"), data.dig("connect", "confirmLabel") ] + + post copilot_connection_path, params: { confirmed: "1" } + assert_equal I18n.t("copilot_connection.connected"), flash[:notice] + CopilotConnection.current.refuse! + + get copilot_connection_path, as: :json + assert_equal \ + [ I18n.t("copilot_connection.organization.refused", date: Date.current.to_fs(:long), organization: "github"), + I18n.t("copilot_connection.organization.reconnect.label") ], + [ data.fetch("refusedLabel"), data.dig("connect", "label") ] + end + + test "after organization billing is turned on, the page asks an administrator to connect Copilot again" do + post copilot_connection_path, params: { confirmed: "1" } + Rails.configuration.x.copilot_billing = "organization" + stub_github_copilot_token("copilot-user-token") + + get copilot_connection_path, as: :json + assert_equal \ + [ I18n.t("copilot_connection.organization.unconfirmed", organization: "github"), + I18n.t("copilot_connection.connect.label") ], + [ data.fetch("setupLabel"), data.dig("connect", "label") ] + + post copilot_connection_path, params: { confirmed: "1" } + get copilot_connection_path, as: :json + assert_equal [ nil, nil ], [ data.fetch("setupLabel"), data.fetch("connect") ] + end + + test "with organization billing, connects nothing when the App lacks the Copilot permission or GitHub fails" do + Rails.configuration.x.copilot_billing = "organization" + { + 422 => [ "copilot_connection.errors.permission_missing", "denied" ], + 500 => [ "copilot_connection.organization.errors.unavailable", "failed" ] + }.each do |status, (alert, outcome)| + stub_github_copilot_token(error_status: status) + + post copilot_connection_path, params: { confirmed: "1" } + + assert_redirected_to copilot_connection_path + assert_equal I18n.t(alert), flash[:alert], "HTTP #{status}" + assert_nil CopilotConnection.current + assert_equal outcome, SecurityAuditEvent.where(action: "copilot_connection.connect").last.outcome, "HTTP #{status}" + end + end + + test "with organization billing, the page and a connection attempt say the App needs an organization installation" do + Rails.configuration.x.copilot_billing = "organization" + @configuration.update!(owner_type: "User") + + get copilot_connection_path, as: :json + assert_equal I18n.t("copilot_connection.organization.no_token"), data.fetch("setupLabel") + + post copilot_connection_path, params: { confirmed: "1" } + + assert_redirected_to copilot_connection_path + assert_equal I18n.t("copilot_connection.organization.errors.no_token"), flash[:alert] + end + test "a successful check reports cancellation instead of restoring a disconnected connection" do post copilot_connection_path, params: { confirmed: "1" } check = CopilotConnection.method(:check!) diff --git a/hub/test/integration/learnings_test.rb b/hub/test/integration/learnings_test.rb index e74bd36c..3d201b42 100644 --- a/hub/test/integration/learnings_test.rb +++ b/hub/test/integration/learnings_test.rb @@ -307,6 +307,22 @@ class LearningsIntegrationTest < ActionDispatch::IntegrationTest assert_no_difference("Learning::Run.count") { post repository_learnings_runs_path(@repository.route_id) } end + test "with organization billing, the status says what Hub needs before it looks for learnings again" do + Rails.configuration.x.copilot_billing = "organization" + get @path, as: :json + assert_equal I18n.t("learnings.organization.status.unconfirmed"), data.dig("status", "message") + + connect_copilot_for_organization(@connection.user).refuse! + get @path, as: :json + assert_equal \ + I18n.t("learnings.organization.status.refused", date: Date.current.to_fs(:long)), + data.dig("status", "message") + + @configuration.update!(owner_type: "User") + get @path, as: :json + assert_equal I18n.t("learnings.organization.status.no_token"), data.dig("status", "message") + end + private def data diff --git a/hub/test/models/copilot_connection_test.rb b/hub/test/models/copilot_connection_test.rb index da4e2e09..c88723ce 100644 --- a/hub/test/models/copilot_connection_test.rb +++ b/hub/test/models/copilot_connection_test.rb @@ -8,6 +8,7 @@ class CopilotConnectionTest < ActiveSupport::TestCase @session = @admin.sessions.create!(fresh_authorized_at: Time.current) @previous_cli = ENV["REPLICA_LEARNING_CLI"] @previous_token = Rails.configuration.x.copilot_token + @previous_billing = Rails.configuration.x.copilot_billing ENV["REPLICA_LEARNING_CLI"] = CLI Rails.configuration.x.copilot_token = "copilot-user-token" end @@ -15,6 +16,7 @@ class CopilotConnectionTest < ActiveSupport::TestCase teardown do ENV["REPLICA_LEARNING_CLI"] = @previous_cli Rails.configuration.x.copilot_token = @previous_token + Rails.configuration.x.copilot_billing = @previous_billing end test "connects for a current administrator with fresh proof and keeps only the token's digest" do @@ -104,10 +106,10 @@ class CopilotConnectionTest < ActiveSupport::TestCase connection = CopilotConnection.connect!(actor: @admin, browser_session: @session) earlier = CopilotConnection.find(connection.id) Rails.configuration.x.copilot_token = "renewed-user-token" - connection.refuse!(token: "renewed-user-token") + connection.refuse!(credential: "renewed-user-token") before = connection.reload.attributes - recorded = earlier.refuse!(token: "copilot-user-token") + recorded = earlier.refuse!(credential: "copilot-user-token") assert_equal before, connection.reload.attributes assert_raises(CopilotConnection::Refused) { connection.access_token! } assert_not recorded @@ -118,12 +120,12 @@ class CopilotConnectionTest < ActiveSupport::TestCase earlier_failure = CopilotConnection.find(connection.id) earlier_success = CopilotConnection.find(connection.id) Rails.configuration.x.copilot_token = "renewed-user-token" - connection.refuse!(token: "renewed-user-token") + connection.refuse!(credential: "renewed-user-token") before = connection.reload.attributes Rails.configuration.x.copilot_token = "copilot-user-token" - recorded_failure = earlier_failure.refuse!(token: "copilot-user-token") - recorded_success = earlier_success.verified!(token: "copilot-user-token") + recorded_failure = earlier_failure.refuse!(credential: "copilot-user-token") + recorded_success = earlier_success.verified!(credential: "copilot-user-token") assert_equal before, connection.reload.attributes assert_not recorded_failure assert_not recorded_success @@ -136,10 +138,10 @@ class CopilotConnectionTest < ActiveSupport::TestCase earlier = CopilotConnection.find(connection.id) assert_equal "renewed-user-token", earlier.access_token! Rails.configuration.x.copilot_token = "third-fixture-token" - connection.refuse!(token: "third-fixture-token") + connection.refuse!(credential: "third-fixture-token") before = connection.reload.attributes - recorded = earlier.verified!(token: "renewed-user-token") + recorded = earlier.verified!(credential: "renewed-user-token") assert_equal before, connection.reload.attributes assert_raises(CopilotConnection::Refused) { connection.access_token! } assert_not recorded @@ -150,7 +152,7 @@ class CopilotConnectionTest < ActiveSupport::TestCase earlier = CopilotConnection.find(connection.id) connection.refuse! - earlier.verified!(token: "copilot-user-token") + earlier.verified!(credential: "copilot-user-token") assert connection.reload.refused? checked = CopilotConnection.connect!(actor: @admin, browser_session: @session) @@ -162,11 +164,11 @@ class CopilotConnectionTest < ActiveSupport::TestCase connection = CopilotConnection.connect!(actor: @admin, browser_session: @session) Rails.configuration.x.copilot_token = "renewed-user-token" - assert connection.refuse!(token: "renewed-user-token") + assert connection.refuse!(credential: "renewed-user-token") assert connection.reload.refused? Rails.configuration.x.copilot_token = "third-fixture-token" - assert connection.verified!(token: "third-fixture-token") + assert connection.verified!(credential: "third-fixture-token") assert_not connection.reload.refused? assert_equal Digest::SHA256.hexdigest("third-fixture-token"), connection.token_digest end @@ -176,14 +178,14 @@ class CopilotConnectionTest < ActiveSupport::TestCase earlier = CopilotConnection.find(connection.id) CopilotConnection.disconnect!(actor: @admin, browser_session: @session) - assert_not earlier.refuse!(token: "copilot-user-token") - assert_not earlier.verified!(token: "copilot-user-token") + assert_not earlier.refuse!(credential: "copilot-user-token") + assert_not earlier.verified!(credential: "copilot-user-token") assert_nil CopilotConnection.current replacement = CopilotConnection.connect!(actor: @admin, browser_session: @session) replacement.refuse! - assert_not earlier.refuse!(token: "copilot-user-token") - assert_not earlier.verified!(token: "copilot-user-token") + assert_not earlier.refuse!(credential: "copilot-user-token") + assert_not earlier.verified!(credential: "copilot-user-token") assert replacement.reload.refused? end @@ -229,6 +231,98 @@ class CopilotConnectionTest < ActiveSupport::TestCase assert_raises(CopilotConnection::Refused) { loaded.access_token! } end + test "with organization billing, connects with a new App token, ignores the operator's token, and keeps only the installation's digest" do + github_app_configurations.create_installed + Rails.configuration.x.copilot_billing = "organization" + Rails.configuration.x.copilot_token = "no-copilot-token" + stub_github_copilot_token("copilot-user-token", "app-token-2", "app-token-3") + + connection = CopilotConnection.connect!(actor: @admin, browser_session: @session) + + assert_equal Digest::SHA256.hexdigest("github-app-installation:456"), connection.token_digest + assert_equal %w[app-token-2 app-token-3], [ connection.access_token!, connection.access_token! ] + end + + test "with organization billing, Hub has no credential without an active organization installation of its App" do + Rails.configuration.x.copilot_billing = "organization" + + [ nil, { owner_type: "User" }, { installation_state: "suspended" } ].each do |installation| + GithubAppConfiguration.delete_all + github_app_configurations.create_installed(**installation) if installation + + error = assert_raises(CopilotConnection::ChangeDenied, installation.inspect) do + CopilotConnection.connect!(actor: @admin, browser_session: @session) + end + assert_equal :no_token, error.reason, installation.inspect + end + end + + test "with organization billing, a recheck refuses the connection when the App lacks the Copilot permission, but not when GitHub fails" do + github_app_configurations.create_installed + Rails.configuration.x.copilot_billing = "organization" + stub_github_copilot_token("copilot-user-token") + connection = CopilotConnection.connect!(actor: @admin, browser_session: @session) + + stub_github_copilot_token(error_status: 500) + assert_raises(Learning::Error) { CopilotConnection.connect!(actor: @admin, browser_session: @session) } + assert_not connection.reload.refused? + + stub_github_copilot_token(error_status: 422) + assert_raises(CopilotConnection::PermissionMissing) do + CopilotConnection.connect!(actor: @admin, browser_session: @session) + end + assert connection.reload.refused? + end + + test "turning on organization billing leaves a connection made with a token unusable until an administrator connects again" do + github_app_configurations.create_installed + connection = CopilotConnection.connect!(actor: @admin, browser_session: @session) + Rails.configuration.x.copilot_billing = "organization" + mint = stub_github_copilot_token("copilot-user-token") + + assert_not connection.usable? + error = assert_raises(CopilotConnection::Refused) { connection.access_token! } + assert_equal "A Hub administrator must connect Copilot again before Hub bills the organization.", error.message + assert_not_requested mint + + assert CopilotConnection.connect!(actor: @admin, browser_session: @session).usable? + end + + test "an in-flight success cannot confirm organization billing for a connection made with a token" do + github_app_configurations.create_installed + connection = CopilotConnection.connect!(actor: @admin, browser_session: @session) + Rails.configuration.x.copilot_billing = "organization" + + assert_not connection.verified! + assert_not connection.reload.usable? + end + + test "with organization billing, a refusal belongs to the App installation, and a new installation waits for an administrator to connect it" do + configuration = github_app_configurations.create_installed + Rails.configuration.x.copilot_billing = "organization" + stub_github_copilot_token("copilot-user-token") + connection = CopilotConnection.connect!(actor: @admin, browser_session: @session) + assert connection.refuse! + + configuration.update!(installation_id: 789) + stub_github_copilot_token("renewed-user-token", installation_id: 789) + assert_not connection.refused? + assert_not connection.usable? + + reconnected = CopilotConnection.connect!(actor: @admin, browser_session: @session) + assert_equal "renewed-user-token", reconnected.access_token! + end + + test "Hub starts with organization billing and refuses a billing choice it does not know" do + initializer = Rails.root.join("config/initializers/deployment_configuration.rb") + Rails.configuration.x.copilot_billing = "organization" + assert_nothing_raised { load initializer } + + Rails.configuration.x.copilot_billing = "organisation" + error = assert_raises(RuntimeError) { load initializer } + assert_match(/\AREPLICA_COPILOT_BILLING must be token_owner or organization: .*organisation/, error.message) + end + test "disconnects for a current administrator with fresh proof, and needs a connection to disconnect" do CopilotConnection.connect!(actor: @admin, browser_session: @session) diff --git a/hub/test/models/learning/run_test.rb b/hub/test/models/learning/run_test.rb index 201f4325..d0021ea2 100644 --- a/hub/test/models/learning/run_test.rb +++ b/hub/test/models/learning/run_test.rb @@ -867,6 +867,44 @@ class Learning::RunTest < ActiveSupport::TestCase assert SecurityAuditEvent.exists?(action: "copilot_connection.use", outcome: "failed") end + test "with organization billing, every labeling command gets a new token from Hub's GitHub App" do + @connection = connect_copilot_for_organization(@admin) + learning_comment(@repository, @mona, "prototype/signup", "The email field has no label.") + learning_comment(@repository, @hubot, "prototype/checkout", "The card number needs a visible label.") + mint = stub_github_copilot_token("app-token-1", "app-token-2") + run = Learning::Run.request!(repository: @repository, user: @admin) + + with_constants(LABEL_BATCH: 1) { run.find_learnings! } + + assert_equal [ "succeeded", 2 ], [ run.reload.state, run.copilot_requests ], run.failure_summary + assert_requested mint, times: 2 + assert_operator @connection.reload.verified_at, :>, 1.minute.ago + end + + test "with organization billing, an App that lacks the Copilot permission fails the run and stops Hub from asking again" do + @connection = connect_copilot_for_organization(@admin) + learning_comment(@repository, @mona, "prototype/signup", "The email field has no label.") + stub_github_copilot_token(error_status: 422) + run = Learning::Run.request!(repository: @repository, user: @admin) + + run.find_learnings! + + assert_equal %w[failed copilot_refused], [ run.reload.state, run.failure_code ] + assert @connection.reload.refused? + end + + test "with organization billing, GitHub failing to create a token fails the run without refusing the connection" do + @connection = connect_copilot_for_organization(@admin) + learning_comment(@repository, @mona, "prototype/signup", "The email field has no label.") + stub_github_copilot_token(error_status: 500) + run = Learning::Run.request!(repository: @repository, user: @admin) + + run.find_learnings! + + assert_equal %w[failed unavailable], [ run.reload.state, run.failure_code ] + assert_nil @connection.reload.refused_at + end + test "a run that a restart stopped is ended so the repository can run again" do stopped = Learning::Run.request!(repository: @repository, user: @admin) stopped.update_columns(state: "running", started_at: 4.hours.ago) diff --git a/hub/test/models/learning/test_helpers_test.rb b/hub/test/models/learning/test_helpers_test.rb index a5b2d189..796f217d 100644 --- a/hub/test/models/learning/test_helpers_test.rb +++ b/hub/test/models/learning/test_helpers_test.rb @@ -3,7 +3,7 @@ class LearningTestHelpersTest < ActiveSupport::TestCase # Exercise real Minitest callbacks explicitly without adding a failing discoverable test. class Probe < ActiveSupport::TestCase - attr_accessor :fail_before_helper, :fail_in_body, :replacement_root, :replacement_token + attr_accessor :fail_before_helper, :fail_in_body, :replacement_root, :replacement_token, :replacement_billing attr_reader :created_root setup { raise "failure before learning setup" if fail_before_helper } @@ -13,6 +13,7 @@ def exercise @created_root = Rails.configuration.x.learning_root Rails.configuration.x.learning_root = replacement_root if replacement_root Rails.configuration.x.copilot_token = replacement_token if replacement_token + Rails.configuration.x.copilot_billing = replacement_billing if replacement_billing raise "failure after learning setup" if fail_in_body end end @@ -31,6 +32,7 @@ def exercise assert_equal root, Rails.configuration.x.learning_root assert_equal "existing-test-cli", ENV["REPLICA_LEARNING_CLI"] assert_equal "preserved-synthetic-token", Rails.configuration.x.copilot_token + assert_equal "organization", Rails.configuration.x.copilot_billing assert_equal original_environment, Learning::Command.instance_method(:environment) end end @@ -53,6 +55,7 @@ def exercise assert_equal root, Rails.configuration.x.learning_root assert_equal "existing-test-cli", ENV["REPLICA_LEARNING_CLI"] assert_equal "preserved-synthetic-token", Rails.configuration.x.copilot_token + assert_equal "organization", Rails.configuration.x.copilot_billing assert_equal original_environment, Learning::Command.instance_method(:environment) ensure Dir.define_singleton_method(:mktmpdir, original_directory) @@ -68,6 +71,7 @@ def exercise probe = Probe.new("exercise") probe.replacement_root = replacement probe.replacement_token = "different-synthetic-token" + probe.replacement_billing = "token_owner" probe.fail_in_body = true result = probe.run @@ -80,6 +84,7 @@ def exercise assert_equal root, Rails.configuration.x.learning_root assert_equal "existing-test-cli", ENV["REPLICA_LEARNING_CLI"] assert_equal "preserved-synthetic-token", Rails.configuration.x.copilot_token + assert_equal "organization", Rails.configuration.x.copilot_billing assert_equal original_environment, Learning::Command.instance_method(:environment) end end @@ -107,6 +112,7 @@ def with_preserved_root original_root = Rails.configuration.x.learning_root original_cli = ENV["REPLICA_LEARNING_CLI"] original_token = Rails.configuration.x.copilot_token + original_billing = Rails.configuration.x.copilot_billing original_environment = Learning::Command.instance_method(:environment) Dir.mktmpdir("preserved-learning-root-") do |directory| root = Pathname(directory) @@ -114,11 +120,13 @@ def with_preserved_root Rails.configuration.x.learning_root = root ENV["REPLICA_LEARNING_CLI"] = "existing-test-cli" Rails.configuration.x.copilot_token = "preserved-synthetic-token" + Rails.configuration.x.copilot_billing = "organization" yield root ensure Rails.configuration.x.learning_root = original_root ENV["REPLICA_LEARNING_CLI"] = original_cli Rails.configuration.x.copilot_token = original_token + Rails.configuration.x.copilot_billing = original_billing Learning::Command.define_method(:environment, original_environment) Learning::Command.send(:private, :environment) end diff --git a/hub/test/support/learning_test_helpers.rb b/hub/test/support/learning_test_helpers.rb index e3a9c485..746f8245 100644 --- a/hub/test/support/learning_test_helpers.rb +++ b/hub/test/support/learning_test_helpers.rb @@ -23,6 +23,7 @@ module LearningTestHelpers setup do @previous_learning_root = Rails.configuration.x.learning_root @previous_copilot_token = Rails.configuration.x.copilot_token + @previous_copilot_billing = Rails.configuration.x.copilot_billing @previous_learning_cli = ENV["REPLICA_LEARNING_CLI"] @previous_learning_environment = Learning::Command.instance_method(:environment) @learning_test_root = Dir.mktmpdir("replica-learning-") @@ -41,6 +42,7 @@ module LearningTestHelpers ensure Rails.configuration.x.learning_root = @previous_learning_root if @learning_test_root Rails.configuration.x.copilot_token = @previous_copilot_token if defined?(@previous_copilot_token) + Rails.configuration.x.copilot_billing = @previous_copilot_billing if defined?(@previous_copilot_billing) ENV["REPLICA_LEARNING_CLI"] = @previous_learning_cli if defined?(@previous_learning_cli) if @previous_learning_environment Learning::Command.define_method(:environment, @previous_learning_environment) @@ -58,6 +60,18 @@ def connect_copilot(user, token: "copilot-user-token", verified_at: 1.day.ago) CopilotConnection.create!(user:, token_digest: CopilotConnection.digest(token), verified_at:) end + # Connects Copilot for the current installation of Hub's GitHub App, replacing any connection, as a + # Hub administrator does with organization billing, which confirms that Copilot bills the + # organization. That App needs an active organization installation. + def connect_copilot_for_organization(user, verified_at: 1.day.ago) + Rails.configuration.x.copilot_billing = "organization" + CopilotConnection.where(singleton_key: 1).delete_all + CopilotConnection.create! \ + user:, + token_digest: CopilotConnection.digest(CopilotConnection.credential), + verified_at: + end + def use_test_labeler(repository) directory = Learning::Store.new(repository).directory FileUtils.mkdir_p(directory, mode: 0o700) From 75a7ed9f737b909c90d29565196923dcfaeac0c4 Mon Sep 17 00:00:00 2001 From: Govind Kamtamneni Date: Thu, 22 Oct 2026 07:02:31 -0700 Subject: [PATCH 03/12] docs: describe organization billing for Copilot The Copilot guide explains how to give Hub's App the Copilot requests permission, turn on organization billing, and connect Copilot again. The deployment, security, architecture, App setup and Learnings guides, the environment example, ADR 0001 and the learning package now describe both ways Hub can pay for Copilot. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../0001-learning-extension-architecture.md | 6 +- hub/.env.example | 3 + hub/docs/architecture.md | 2 +- hub/docs/copilot.md | 55 +++++++++++++------ hub/docs/deployment.md | 2 +- hub/docs/github-app-setup.md | 2 + hub/docs/learnings.md | 8 +-- hub/docs/security.md | 2 +- packages/learning/README.md | 2 +- packages/learning/src/labelers/copilot.mjs | 3 +- 10 files changed, 56 insertions(+), 29 deletions(-) diff --git a/docs/adr/0001-learning-extension-architecture.md b/docs/adr/0001-learning-extension-architecture.md index 51c3781b..dd4bb6ff 100644 --- a/docs/adr/0001-learning-extension-architecture.md +++ b/docs/adr/0001-learning-extension-architecture.md @@ -96,7 +96,7 @@ Each import records whether every list page and reply page was present. The impo Hub hosts the loop and runs the `replica-learning` command for every step, so the store's rules stay the only rules. Hub keeps one store per repository in its own storage, outside any checkout. A background job imports the repository's review comments from Hub's database as one complete collection, removes the text of comments deleted on Hub, asks Copilot to label each new or edited comment, and consolidates the labels into proposals. When someone deletes a comment, another job removes its text by its Hub comment ID, which imports nothing, so it works without an active grant and adds no other comments. Repository administrators accept, edit, or reject proposals on a Learnings page. The Replica prototype plugin adds accepted learnings to the prompts a person submits in that repository. [Learnings](../../hub/docs/learnings.md) describes the product. -Hub asks Copilot through the [Copilot SDK](https://github.com/github/copilot-sdk), with a token that the person who runs Hub provides and whose only permission is Copilot requests, as [Copilot](../../hub/docs/copilot.md) describes. The session has no tools, refuses every permission request, and never sees who wrote a comment. Copilot chooses the model unless the store's configuration names one, and each run records the usage Copilot reports. +Hub asks Copilot through the [Copilot SDK](https://github.com/github/copilot-sdk), with a token whose only permission is Copilot requests: one that the person who runs Hub provides, or, with organization billing, a one-hour token that Hub's GitHub App creates for each request, as [Copilot](../../hub/docs/copilot.md) describes. The session has no tools, refuses every permission request, and never sees who wrote a comment. Copilot chooses the model unless the store's configuration names one, and each run records the usage Copilot reports. The command still runs in a checkout, with data under `.replica/learning/`, for contributors and for other hosts. This complements #230: its discovery output can become labels, and its judge can become a consolidator. @@ -114,7 +114,7 @@ Learning happens in explicit, inspectable memory. A training-purpose export is t - The generated validator file is large, as in `packages/controls`. - Hub keeps one SQLite file per repository. A team that needs a shared database server brings its own provider and runs the conformance suite against it. - Hub's production image includes Node.js and the command, which adds about 260 MB. -- Copilot usage counts toward the token owner's plan, not necessarily the administrator who enabled the connection. The operator supplies a token intended to have only the Copilot requests permission; Hub keeps it in process configuration, not its database. The operator must verify its actual scope and manage expiry, rotation and revocation. Token replacement requires a full stop/start with no old/new process overlap, including learning and SDK children; rolling replacement is unsupported. Credential custody, subprocess isolation and deployed authentication still need security review. +- By default, Copilot usage counts toward the token owner's plan, not necessarily the administrator who enabled the connection. With organization billing, the organization that installed Hub's GitHub App pays, and anyone who controls the App's private key, which Hub already stores, can spend that organization's Copilot budget. The operator supplies a token intended to have only the Copilot requests permission; Hub keeps it in process configuration, not its database. The operator must verify its actual scope and manage expiry, rotation and revocation. Token replacement requires a full stop/start with no old/new process overlap, including learning and SDK children; rolling replacement is unsupported. Credential custody, subprocess isolation and deployed authentication still need security review. ## Alternatives considered @@ -124,7 +124,7 @@ Learning happens in explicit, inspectable memory. A training-purpose export is t - Dropping records that fail their checks and continuing. Rejected because dropping a damaged withdrawal or retraction silently restores a permission. - A knowledge graph or vector store as the default. Rejected; the default should be simple and local, and graph or semantic search can arrive as provider capabilities. - An agent collects comments through the canvas, and a starter skill runs each step. Replaced: a person had to start every step, and every comment passed through an agent session, which cost tokens and could introduce copying errors. -- Billing Copilot to the organization through the App's installation token. Deferred: it needs the App's Copilot requests permission and an installation on every repository in the organization. +- Organization billing through the App's installation tokens as the only option. Rejected: it needs Copilot Business or Copilot Enterprise and an App installed on an organization, and GitHub offers it as a preview. Hub offers it alongside the operator's token. - PostgreSQL or Neo4j providers in this version. Deferred: SQLite meets one Hub's needs, and the provider interface and conformance suite let a team add one later. - Model-only consolidation. Kept as a plug-in option; the reference stays deterministic so tests and reviews are reproducible. diff --git a/hub/.env.example b/hub/.env.example index 80e5ecb6..52e27a01 100644 --- a/hub/.env.example +++ b/hub/.env.example @@ -14,6 +14,9 @@ REPLICA_ARTIFACT_ROOT=storage/artifacts # Learnings asks Copilot with this token: a fine-grained personal access token whose only # permission is Copilot requests. Hub never stores it. See docs/copilot.md. # REPLICA_COPILOT_TOKEN= +# To bill the organization that installed Hub's GitHub App instead of the token's owner, give the +# App the Copilot requests organization permission and set this. Hub then ignores the token. +# REPLICA_COPILOT_BILLING=organization # Use a non-empty secret. A long random value is recommended for public deployments. REPLICA_BOOTSTRAP_SECRET= # Production deployments should set independent random values for these keys. diff --git a/hub/docs/architecture.md b/hub/docs/architecture.md index b46ea545..a19186a5 100644 --- a/hub/docs/architecture.md +++ b/hub/docs/architecture.md @@ -63,7 +63,7 @@ Builds do not change after Hub stores them successfully. Conversations belong to Share links expose one successful Build without GitHub sign-in. The public request uses only saved Hub data and never opens repository pages or review conversations. -A Hub administrator can [connect Copilot](copilot.md) with a token the person who runs Hub provides in `REPLICA_COPILOT_TOKEN`. Hub never stores the token and passes it only to `replica-learning`, a Node.js command from [`packages/learning/`](../../packages/learning/) that Hub runs with an allowlisted environment and a timeout. [Learnings](learnings.md) runs that command in a background job: it imports a repository's review comments into the repository's own learning store, asks Copilot to label them, and consolidates the labels into suggestions for repository administrators. +A Hub administrator can [connect Copilot](copilot.md) with a token the person who runs Hub provides in `REPLICA_COPILOT_TOKEN`, or with one-hour tokens that Hub's GitHub App creates and Copilot bills to the App's organization. Hub never stores a token and passes it only to `replica-learning`, a Node.js command from [`packages/learning/`](../../packages/learning/) that Hub runs with an allowlisted environment and a timeout. [Learnings](learnings.md) runs that command in a background job: it imports a repository's review comments into the repository's own learning store, asks Copilot to label them, and consolidates the labels into suggestions for repository administrators. [Development-only local mode](local-development.md) replaces GitHub sign-in and discovery with one local identity and committed branches from local repositories. It uses the same stored Build files, preview origins, and review UI, but does not change the GitHub-backed production flow. diff --git a/hub/docs/copilot.md b/hub/docs/copilot.md index 4e574af7..65776e07 100644 --- a/hub/docs/copilot.md +++ b/hub/docs/copilot.md @@ -1,12 +1,19 @@ # Copilot -A Hub administrator connects Copilot once for the whole Hub, which turns on [Learnings](learnings.md): Hub asks Copilot to suggest learnings from prototype review comments. Hub makes its Copilot requests with a token that the person who runs Hub provides, so they work while everyone is signed out and nobody has to copy prompts into a Copilot session. Hub never stores the token. +A Hub administrator connects Copilot once for the whole Hub, which turns on [Learnings](learnings.md): Hub asks Copilot to suggest learnings from prototype review comments. Hub makes these Copilot requests itself, so they work while everyone is signed out and nobody has to copy prompts into a Copilot session. Hub never stores a Copilot token. + +The person who runs Hub chooses who Copilot bills with `REPLICA_COPILOT_BILLING`: + +| `REPLICA_COPILOT_BILLING` | Hub asks Copilot with | Copilot bills | +| --- | --- | --- | +| `token_owner`, the default | The token in `REPLICA_COPILOT_TOKEN` | The Copilot plan of the token's owner | +| `organization` | A new one-hour token from Hub's GitHub App for each request | The organization that installed Hub's GitHub App | ```text -The person who runs Hub creates a token with only the Copilot requests permission - -> puts it in REPLICA_COPILOT_TOKEN and restarts Hub +The person who runs Hub gives Hub a token, or turns on organization billing + -> restarts Hub A Hub administrator chooses Connect Copilot - -> Hub checks that Copilot answers with the token + -> Hub checks that Copilot answers -> Hub records who connected Copilot ``` @@ -22,42 +29,56 @@ A Hub administrator chooses Connect Copilot Hub reads one environment variable, so it needs no code for any secret store. Hub checks that Copilot accepts the token; it does not inspect its permissions. The operator must verify that the configured token has only the intended permission. The secret remains in the deployment's secret store or private environment file and in Hub's running processes, not in its database. +## Bill your organization + +With organization billing, Hub's GitHub App makes Copilot requests with its own installation token, and Copilot bills the organization that installed the App instead of a person. GitHub offers this as a [public preview](https://docs.github.com/en/copilot/how-tos/copilot-sdk/auth/server-to-server-tokens). The organization needs Copilot Business or Copilot Enterprise, and Hub's App must be installed on that organization; an App installed on a personal account cannot use it. + +1. The App's owner opens the App's settings on GitHub, under **Permissions & events**, and sets the **Copilot requests** organization permission to **Read-only**. Read-only still lets the App spend the organization's AI credits. +2. An organization owner reviews and approves the updated permissions. GitHub's guide to [managing GitHub Apps that use your organization's Copilot](https://docs.github.com/en/copilot/how-tos/administer-copilot/manage-for-organization/manage-app-copilot-access) explains how to find the App's usage and how to block it. The App's usage counts against the organization's Copilot budget; GitHub has no budget for one App, and only a budget with **Stop usage when budget limit is reached** stops spending. +3. The person who runs Hub sets `REPLICA_COPILOT_BILLING=organization` where they keep Hub's other settings, then restarts Hub. Hub then ignores `REPLICA_COPILOT_TOKEN`. +4. A Hub administrator connects Copilot on the **Copilot** page, whose confirmation names the organization. Hub never moves an existing connection to an organization's bill. After the person who runs Hub turns on organization billing, or when Hub's App gets a new installation, Hub stops making Copilot requests until an administrator connects Copilot again. + +GitHub also requires the organization to be enabled for Copilot requests from GitHub App installations. Until it is, Copilot refuses Hub's App, and the **Copilot** page says so. + +For each Copilot request, Hub's App creates a new installation token that holds only the Copilot requests permission and expires after an hour. Hub refuses a token that GitHub reports with any other permission, passes it only to `replica-learning` for that one command, and never stores it. The token cannot read or change repositories. Anyone who can read Hub's database and its encryption keys holds the App's private key, so they can also create these tokens and spend the organization's Copilot budget until an organization owner removes the permission or suspends the App. + ## Connect Copilot -1. Open **Copilot** in the sidebar's lower group, or go to `/copilot`. Without a token, the page says how to give Hub one. -2. Read what Hub will do with Copilot, check the confirmation, and choose **Connect Copilot**. Like Hub's other administrator settings, this needs a [fresh GitHub authorization](fresh-authorization.md) on the same browser Session within the last five minutes. If GitHub asks you to verify, return and confirm again; verification never submits the change. -3. Hub checks the token with a Copilot request that lists the models it may use. If Copilot answers, Hub connects Copilot and records who connected it. +1. Open **Copilot** in the sidebar's lower group, or go to `/copilot`. When Hub has no token, or its App is not installed on an organization for organization billing, the page says what to set up. +2. Read what Hub will do with Copilot and who Copilot will bill, check the confirmation, and choose **Connect Copilot**. Like Hub's other administrator settings, this needs a [fresh GitHub authorization](fresh-authorization.md) on the same browser Session within the last five minutes. If GitHub asks you to verify, return and confirm again; verification never submits the change. +3. Hub checks the token, or a new App token, with a Copilot request that lists the models it may use. If Copilot answers, Hub connects Copilot and records who connected it. If another administrator disconnects or replaces the connection while Hub checks the token, Hub cancels the earlier check and leaves that change intact. -Copilot counts the usage toward the plan of the token's owner and applies their organization's Copilot policies, including which models they may use. +With a token, Copilot counts the usage toward the plan of the token's owner and applies their organization's Copilot policies, including which models they may use. With organization billing, Copilot bills the organization that installed Hub's App and applies that organization's policies. ## What Hub keeps | Item | Where | Who can see it | | --- | --- | --- | -| The token | Only in Hub's environment, never in its database | Nobody. Hub never shows, exports, or logs it | +| The operator's token | Only in Hub's environment, never in its database | Nobody. Hub never shows, exports, or logs it | +| A token from Hub's App | Only in memory, for the one command that uses it | Nobody. Hub never shows, exports, or logs it | | Who connected Copilot, when, and when Copilot last answered or refused | Hub's database | Hub administrators | -| The token's SHA-256 digest | Hub's database | Nobody. It tells Hub whether a refusal belongs to the token it has now | +| The SHA-256 digest of the operator's token, or of the App installation's identity | Hub's database | Nobody. It tells Hub whether a refusal belongs to the token or installation it uses now | | Copilot usage that Learnings runs reported over the last 30 days | Hub's database | Hub administrators, on the **Copilot** page | | Connections, disconnections, and refusals | [Security audit](security-audit.md) | Hub administrators | -Hub passes the token only to `replica-learning`, as `COPILOT_GITHUB_TOKEN`, when it makes a Copilot request. That command runs without Hub's other secrets, keeps Copilot's state in a new private directory, and gives Copilot no tools. +Hub passes a token only to `replica-learning`, as `COPILOT_GITHUB_TOKEN`, when it makes a Copilot request. That command runs without Hub's other secrets, keeps Copilot's state in a new private directory, and gives Copilot no tools. -A token with only the Copilot requests permission cannot read or change repositories. Someone who copies it can make Copilot requests that count toward its owner's plan, until it expires or its owner deletes it on GitHub. +A token with only the Copilot requests permission cannot read or change repositories. Someone who copies the operator's token can make Copilot requests that count toward its owner's plan, until it expires or its owner deletes it on GitHub. Someone who copies a token from Hub's App can make Copilot requests billed to the organization until it expires, within an hour. ## When the connection ends -- **An administrator disconnects it.** **Disconnect Copilot** needs confirmation and fresh GitHub verification on the same browser Session. Hub stops making Copilot requests. The token stays where it is kept; to end it, its owner deletes it on GitHub. +- **An administrator disconnects it.** **Disconnect Copilot** needs confirmation and fresh GitHub verification on the same browser Session. Hub stops making Copilot requests. The operator's token stays where it is kept, and Hub's App keeps its Copilot requests permission. To end them, the token's owner deletes the token on GitHub, or the App's owner removes the permission. - **The person who connected it stops being a Hub administrator.** Hub disconnects Copilot in the same change as the role. Another administrator can connect it again. -- **Copilot refuses the token.** For example, it expired, its owner deleted it, or its owner lost Copilot. Hub records the refusal in the security audit, stops looking for learnings, and offers **Check the token again**. People can still decide on suggestions, and agents still get accepted learnings. A refusal belongs to the token Copilot refused. Replacing it using the procedure below lets Hub use Copilot again. -- **The token is removed.** Hub stops making Copilot requests, and the page says Hub has no token. +- **Copilot refuses the token or Hub's App.** For example, the token expired, its owner deleted it, or its owner lost Copilot. With organization billing, GitHub may not create a token because the App lacks the Copilot requests permission, or Copilot may refuse the App because the organization does not allow its requests. Hub records the refusal in the security audit, stops looking for learnings, and offers **Check the token again**, or **Check Copilot again** with organization billing. People can still decide on suggestions, and agents still get accepted learnings. A refusal belongs to the token or App installation Copilot refused. Replacing the token using the procedure below, or fixing the App's permission or the organization's settings and checking again, lets Hub use Copilot again. +- **The token is removed, or the App's organization installation ends.** Hub stops making Copilot requests, and the page says what Hub needs. [Ending all Hub sessions](session-revocation.md) and resetting the GitHub App leave the Copilot connection as it is, because the token is neither a browser credential nor the App's. ## Replace the token -Token changes require a full stop/start, not a rolling restart. Before starting any process with a replacement token, stop **all** Hub web and job-worker processes using the old token and wait for them, their learning commands and their Copilot SDK/runtime children to exit. A lost Ruby worker does not prove its child command stopped; confirm no old-token learning writer or SDK child remains. Pause any automatic restart or scaling that could bring an old configuration back. This also applies when adding or removing the token on a running deployment. +Token changes require a full stop/start, not a rolling restart. Before starting any process with a replacement token, stop **all** Hub web and job-worker processes using the old token and wait for them, their learning commands and their Copilot SDK/runtime children to exit. A lost Ruby worker does not prove its child command stopped; confirm no old-token learning writer or SDK child remains. Pause any automatic restart or scaling that could bring an old configuration back. This also applies when adding or removing the token, or changing `REPLICA_COPILOT_BILLING`, on a running deployment. Tokens from Hub's App need no replacement, because Hub creates a new one for each request. The default Compose bundle runs Hub and its job workers in one container with local persistent storage. Run `./replica down` and confirm that container and its children have stopped before changing the token, then run `./replica up` after updating the configuration. The named data volume is retained. On another platform, use its equivalent full-stop procedure; if old and new token processes cannot be kept from overlapping, do not rotate this way. @@ -77,7 +98,7 @@ This cleanup applies only to databases that ran the earlier pre-release credenti ## Local development -[Local development](local-development.md) has no Copilot page and ignores `REPLICA_COPILOT_TOKEN`. Learnings there asks Copilot as whoever is signed in to the GitHub CLI or Copilot CLI where Hub runs; see [Learnings](learnings.md#local-development). +[Local development](local-development.md) has no Copilot page and ignores `REPLICA_COPILOT_TOKEN` and `REPLICA_COPILOT_BILLING`. Learnings there asks Copilot as whoever is signed in to the GitHub CLI or Copilot CLI where Hub runs; see [Learnings](learnings.md#local-development). ## Operations diff --git a/hub/docs/deployment.md b/hub/docs/deployment.md index f6851d96..057ebdd6 100644 --- a/hub/docs/deployment.md +++ b/hub/docs/deployment.md @@ -39,7 +39,7 @@ This setting is not generated by `./replica init`. Add it to the private deploym ## Copilot for Learnings -To turn on Learnings, create a fine-grained personal access token whose only permission is Copilot requests, add it to the private deployment `.env` or platform environment as `REPLICA_COPILOT_TOKEN`, and restart Hub. A Hub administrator then connects Copilot. `./replica init` does not generate this setting. Adding, removing or replacing the token on a running deployment requires stopping every old-configuration web process, job worker, learning command and surviving Copilot SDK/runtime child before starting any new-configuration process; rolling token changes are not supported. Follow [token replacement](copilot.md#replace-the-token). +To turn on Learnings, create a fine-grained personal access token whose only permission is Copilot requests and add it as `REPLICA_COPILOT_TOKEN`. To bill the organization that installed Hub's GitHub App instead, add the App's Copilot requests permission and set `REPLICA_COPILOT_BILLING=organization`, as [organization billing](copilot.md#bill-your-organization) describes. Add the setting to the private deployment `.env` or platform environment, and restart Hub. A Hub administrator then connects Copilot. `./replica init` does not generate these settings. Adding, removing or replacing the token, or changing `REPLICA_COPILOT_BILLING`, on a running deployment requires stopping every old-configuration web process, job worker, learning command and surviving Copilot SDK/runtime child before starting any new-configuration process; rolling token changes are not supported. Follow [token replacement](copilot.md#replace-the-token). ## Welcome video diff --git a/hub/docs/github-app-setup.md b/hub/docs/github-app-setup.md index d69e0c72..b7f67b35 100644 --- a/hub/docs/github-app-setup.md +++ b/hub/docs/github-app-setup.md @@ -74,6 +74,8 @@ The App requests: An upgraded Hub does not automatically grant these write permissions to an existing App installation. The App owner must open the App's **Permissions & events** settings, grant read and write access for Contents, Pull requests, and Workflows, then save the change. Each installation owner must approve the new permissions before Hub can open workflow installation pull requests. Existing repository discovery and review access continues with the previously approved permissions. +[Organization billing](copilot.md#bill-your-organization) for Learnings also needs the **Copilot requests** organization permission, set to **Read-only**. Hub does not request it when it creates the App, so an App that does not bill Copilot cannot spend the organization's AI credits. Add it only when you turn on organization billing. + Production usually receives GitHub notifications for pushes, workflows, and installation changes. Both environments also poll GitHub so Hub can recover missed changes; local development relies on polling because it has no public tunnel. ## Routine App administration diff --git a/hub/docs/learnings.md b/hub/docs/learnings.md index c770a0fc..902a439a 100644 --- a/hub/docs/learnings.md +++ b/hub/docs/learnings.md @@ -10,7 +10,7 @@ Reviewers comment on prototypes -> Agents get the accepted learnings with their task ``` -Learnings is off until a Hub administrator [connects Copilot](copilot.md), with a token the person who runs Hub provides in `REPLICA_COPILOT_TOKEN`. Connecting turns it on for every repository on the Hub. +Learnings is off until a Hub administrator [connects Copilot](copilot.md), after the person who runs Hub gives Hub a token or turns on organization billing. Connecting turns it on for every repository on the Hub. ## Who can do what @@ -57,7 +57,7 @@ When a person works with Copilot in a repository that has a Hub connection, the ## Usage -Hub asks Copilot with the token in `REPLICA_COPILOT_TOKEN`, so the usage counts toward the Copilot plan of the token's owner. Labeling is the only step that uses Copilot. A run with no new, edited, or still-unprocessed comments makes no model requests. A request also sends the other comments in each thread it asks about, as context. Hub lets Copilot choose the model unless the store's configuration names one. +Copilot bills the usage as the person who runs Hub chose: the Copilot plan of the owner of the token in `REPLICA_COPILOT_TOKEN`, or, with [organization billing](copilot.md#bill-your-organization), the organization that installed Hub's GitHub App. Labeling is the only step that uses Copilot. A run with no new, edited, or still-unprocessed comments makes no model requests. A request also sends the other comments in each thread it asks about, as context. Hub lets Copilot choose the model unless the store's configuration names one. A run records the requests and AI credits Copilot reported after each request, using the learning command's [whole-unit usage counters](../../packages/learning/README.md#label-with-copilot). A failed request or storage operation can still report known usage; Hub records it before showing the failure. Usage the service did not report is unknown, not estimated. Retrying comments whose result was not recorded can incur another charge. If the store also cannot confirm which comments remain pending, the failure says so. The **Learnings** page shows a repository's runs and its usage over the last 30 days, and the [Copilot](copilot.md) page shows the Hub's total. @@ -67,7 +67,7 @@ Usage ranges follow when a run started, not when someone queued it, so a long wa | What you see | What to do | | --- | --- | -| Copilot refused the token | The person who runs Hub replaces `REPLICA_COPILOT_TOKEN` and restarts Hub, or a Hub administrator checks the token again on the **Copilot** page. Decisions and accepted learnings keep working meanwhile | +| Copilot refused the token or Hub's App | The person who runs Hub replaces `REPLICA_COPILOT_TOKEN` and restarts Hub, or, with organization billing, fixes the App's Copilot requests permission or the organization's settings. A Hub administrator can check again on the **Copilot** page. Decisions and accepted learnings keep working meanwhile | | A failed run | Its result says what went wrong. Hub tries again the next day if comments changed, or choose **Find learnings now** | | The learning execution lock stays busy | After 121 lock attempts, about an hour of retries plus queue delays, the job fails visibly in Solid Queue. A waiting run also shows a failed result. An operator must check for a stuck writer before retrying; see [Operations](#operations) | | Consent to use review comments ended | An operator must explicitly renew the required purposes with `replica-learning grants add`. A run never replaces a withdrawn or expired grant automatically; see the [learning package's consent commands](../../packages/learning/README.md#run-it-yourself) | @@ -82,7 +82,7 @@ Hub identifies GitHub reviewers by immutable account IDs, not their current logi ## Local development -[Local development](local-development.md) has no Copilot connection and ignores `REPLICA_COPILOT_TOKEN`. Learnings is on for every local repository and asks Copilot as whoever is signed in to the GitHub CLI or Copilot CLI where Hub runs. In the dev container, sign in with `bin/devcontainer exec gh auth login`. Hub looks for learnings only when you choose **Find learnings now**. A theme needs one reviewer instead of two, because local development signs everyone in as one identity. +[Local development](local-development.md) has no Copilot connection and ignores `REPLICA_COPILOT_TOKEN` and `REPLICA_COPILOT_BILLING`. Learnings is on for every local repository and asks Copilot as whoever is signed in to the GitHub CLI or Copilot CLI where Hub runs. In the dev container, sign in with `bin/devcontainer exec gh auth login`. Hub looks for learnings only when you choose **Find learnings now**. A theme needs one reviewer instead of two, because local development signs everyone in as one identity. ## Operations diff --git a/hub/docs/security.md b/hub/docs/security.md index a6a701ab..12a36c01 100644 --- a/hub/docs/security.md +++ b/hub/docs/security.md @@ -18,7 +18,7 @@ Other boundaries: ## Repository access - Hub stores each person's GitHub ID, but not their OAuth token. -- Learnings makes Copilot requests with `REPLICA_COPILOT_TOKEN`, a token the person who runs Hub provides with only the Copilot requests permission. Hub passes it only to `replica-learning` and never stores it. See [Copilot](copilot.md). +- Learnings makes Copilot requests with `REPLICA_COPILOT_TOKEN`, a token the person who runs Hub provides with only the Copilot requests permission. With organization billing, Hub's GitHub App instead creates a one-hour token for each request that holds only that permission. Hub passes either token only to `replica-learning` and never stores it. See [Copilot](copilot.md). - Every repository page checks the person's current GitHub access. - Signed-out pages reveal no setup, installation, or repository details. - If GitHub cannot confirm access, Hub denies that repository request. diff --git a/packages/learning/README.md b/packages/learning/README.md index fa15fced..525072d0 100644 --- a/packages/learning/README.md +++ b/packages/learning/README.md @@ -153,7 +153,7 @@ Provider queries may filter audit entries with `operations`, together with scope - The session has no tools, refuses every permission request, and keeps its state in a new private directory that the command deletes, so a comment cannot make Copilot run anything. - Copilot chooses the model (`auto`) unless `labeling.options.model` or `--model` names one. Each label records the model that wrote it. - The store checks every label against the contract and refuses the rest. Labels are an agent's judgment: a consolidator turns recurring themes into proposals, and only a person accepts one. -- The credential is `COPILOT_GITHUB_TOKEN`, a GitHub user token, such as a fine-grained personal access token whose only permission is Copilot requests. Hub passes the one its operator sets in `REPLICA_COPILOT_TOKEN`. Without it, Copilot uses whoever is signed in to the Copilot CLI or the GitHub CLI on this machine. The config file never holds a credential. +- The credential is `COPILOT_GITHUB_TOKEN`: a GitHub user token, such as a fine-grained personal access token whose only permission is Copilot requests, or a GitHub App installation token that holds only the organization Copilot requests permission, whose usage Copilot bills to the organization. Hub passes the token its operator sets in `REPLICA_COPILOT_TOKEN`, or, with organization billing, a new App token for each command. Without it, Copilot uses whoever is signed in to the Copilot CLI or the GitHub CLI on this machine. The config file never holds a credential. - Usage counts each model request, its tokens, and its AI credits in nano-AI units. The built-in labeler rounds positive fractional SDK counts up to whole tokens and nano-AI units per usage event, adding less than one unit per field, so known nonzero usage does not disappear. Custom labelers must return nonnegative safe integers; their reports are not rounded. GitHub's billing pages are authoritative. Hosts that re-check authorization between requests can use `label --max-requests 1 --limit 400 --json`. The optional positive integer cap limits calls to the configured labeler, not the number of comments in a request or model-usage events. Omit it to retain normal multi-group labeling. Comments in groups beyond the cap remain unprocessed and are included in `remaining`; `considered` counts only comments actually sent. Call again after re-checking access to process the next group. Thread grouping and the 16,000-character group target are unchanged, so the cap does not promise a token or billing limit. diff --git a/packages/learning/src/labelers/copilot.mjs b/packages/learning/src/labelers/copilot.mjs index 95edc741..20f73c61 100644 --- a/packages/learning/src/labelers/copilot.mjs +++ b/packages/learning/src/labelers/copilot.mjs @@ -70,7 +70,8 @@ const RESPONSE = Object.freeze({ * who wrote them. Copilot chooses the model unless options name one. * * The credential is a GitHub user token, such as a fine-grained personal access token whose only - * permission is Copilot requests. Without one, Copilot uses the person signed in to the Copilot + * permission is Copilot requests, or a GitHub App installation token that holds only the organization + * Copilot requests permission. Without one, Copilot uses the person signed in to the Copilot * CLI or the GitHub CLI on this machine. * * @param {{ model?: string, token?: string, timeoutMs?: number, connect?: typeof connectCopilot }} [options] From 7e62906164916ae962b32b1c04bee9361670fb52 Mon Sep 17 00:00:00 2001 From: Govind Kamtamneni Date: Thu, 22 Oct 2026 07:52:00 -0700 Subject: [PATCH 04/12] fix(hub): accept only the Copilot permission on App tokens A Copilot token with read-only repository metadata could still read collaborators, commit comments and other details of every repository the App's installation covers. Hub now refuses a token unless GitHub reports exactly the Copilot requests permission, so the token can only make Copilot requests, as the Copilot page and guide say. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- hub/app/models/github_app_configuration/tokens.rb | 11 ++++------- .../models/github_app_configuration/tokens_test.rb | 8 ++++++-- hub/test/support/github_request_stubs.rb | 4 ++-- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/hub/app/models/github_app_configuration/tokens.rb b/hub/app/models/github_app_configuration/tokens.rb index f0b437b0..55fd75b7 100644 --- a/hub/app/models/github_app_configuration/tokens.rb +++ b/hub/app/models/github_app_configuration/tokens.rb @@ -4,11 +4,10 @@ class GithubAppConfiguration::Tokens < ActiveRecord::AssociatedObject # Requests organization permission. class CopilotPermissionMissing < GithubClient::Error; end - # The only permission a Copilot token asks for. + # The only permission a Copilot token asks for, and the only one Hub accepts on it. A token with any + # other permission, even read-only repository metadata, could read the repositories the App's + # installation covers. COPILOT_PERMISSIONS = { organization_copilot_requests: "read" }.freeze - # What GitHub may report a Copilot token holds: the Copilot permission, and the read-only metadata - # access that GitHub can add to any installation token. - COPILOT_TOKEN_PERMISSIONS = [ %w[organization_copilot_requests read], %w[metadata read] ].freeze # Creates a short-lived token that authenticates as the GitHub App. def app @@ -72,9 +71,7 @@ def copilot raise KeyError if !token.is_a?(String) || token.blank? || expires_at <= Time.current # GitHub grants only the requested permissions. A token with more must not reach Copilot's runtime. - granted = payload.fetch("permissions") - if !granted.is_a?(Hash) || granted.any? { |name, access| !COPILOT_TOKEN_PERMISSIONS.include?([ name, access ]) } || - granted["organization_copilot_requests"] != "read" + if payload.fetch("permissions") != COPILOT_PERMISSIONS.stringify_keys raise GithubClient::InvalidResponse, "GitHub returned a Copilot token with other permissions" end diff --git a/hub/test/models/github_app_configuration/tokens_test.rb b/hub/test/models/github_app_configuration/tokens_test.rb index 77bea22c..b7aad49f 100644 --- a/hub/test/models/github_app_configuration/tokens_test.rb +++ b/hub/test/models/github_app_configuration/tokens_test.rb @@ -72,9 +72,13 @@ class GithubAppConfiguration::TokensTest < ActiveSupport::TestCase assert_equal %w[copilot-token-1 copilot-token-2], [ configuration.tokens.copilot, configuration.tokens.copilot ] end - test "rejects a Copilot token that GitHub reports with another permission or without the Copilot permission" do + test "rejects a Copilot token that GitHub reports with any other permission, even metadata, or without the Copilot permission" do configuration = github_app_configurations.create_installed - [ { organization_copilot_requests: "read", contents: "read" }, { metadata: "read" } ].each do |permissions| + [ + { organization_copilot_requests: "read", metadata: "read" }, + { organization_copilot_requests: "read", contents: "read" }, + { metadata: "read" } + ].each do |permissions| stub_github_copilot_token("copilot-token", permissions:) assert_raises(GithubClient::InvalidResponse, permissions.inspect) { configuration.tokens.copilot } diff --git a/hub/test/support/github_request_stubs.rb b/hub/test/support/github_request_stubs.rb index 838a2c2e..d16be57e 100644 --- a/hub/test/support/github_request_stubs.rb +++ b/hub/test/support/github_request_stubs.rb @@ -29,9 +29,9 @@ def stub_github_installation_token(installation_id: 456) # Stubs the new token Hub's GitHub App creates for each Copilot request, which may ask only for # the Copilot Requests organization permission. GitHub answers with each token in turn and repeats - # the last, or fails every request with error_status. + # the last, reporting the given permissions, or fails every request with error_status. def stub_github_copilot_token(*tokens, installation_id: 456, error_status: nil, - permissions: { organization_copilot_requests: "read", metadata: "read" }) + permissions: { organization_copilot_requests: "read" }) request = stub_request(:post, "#{GITHUB_API_URL}/app/installations/#{installation_id}/access_tokens") .with(body: { permissions: { organization_copilot_requests: "read" } }) return request.to_return(status: error_status, body: JSON.generate(message: "Validation Failed")) if error_status From e39bc6643e28378670785da54f68d0e3c3c201ef Mon Sep 17 00:00:00 2001 From: Govind Kamtamneni Date: Thu, 22 Oct 2026 07:52:05 -0700 Subject: [PATCH 05/12] fix(hub): guide organization billing when Hub's App changes Hub creates its GitHub App as a private App, which only its owner can install, so an App that belongs to a personal account can never bill an organization. The Copilot page now says to create the App again under the organization or keep using a token, instead of asking to install it on an organization. The Copilot guide also no longer says that resetting the App leaves Copilot connected with organization billing: Hub stops until the new App is installed and an administrator connects Copilot again. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- hub/app/views/copilot_connections/show.json.props | 4 +++- hub/config/locales/copilot_connection.en.yml | 3 ++- hub/docs/copilot.md | 4 ++-- hub/test/integration/copilot_connections_test.rb | 8 ++++++-- 4 files changed, 13 insertions(+), 6 deletions(-) diff --git a/hub/app/views/copilot_connections/show.json.props b/hub/app/views/copilot_connections/show.json.props index 791684eb..4a34dcad 100644 --- a/hub/app/views/copilot_connections/show.json.props +++ b/hub/app/views/copilot_connections/show.json.props @@ -15,7 +15,9 @@ if !@access_denied json.submitPath copilot_connection_path json.billingLabel(credential ? copilot_billing_t("billing", organization:) : nil) json.setupLabel( - if !credential + if !credential && CopilotConnection.organization_billing? && GithubAppConfiguration.current&.owner_type == "User" + t("copilot_connection.organization.personal_app") + elsif !credential copilot_billing_t("no_token") elsif !confirmed t("copilot_connection.organization.unconfirmed", organization:) diff --git a/hub/config/locales/copilot_connection.en.yml b/hub/config/locales/copilot_connection.en.yml index 7305e549..fd782acf 100644 --- a/hub/config/locales/copilot_connection.en.yml +++ b/hub/config/locales/copilot_connection.en.yml @@ -42,7 +42,8 @@ en: organization: description: A Hub administrator connects Copilot once for the whole Hub. Hub then asks Copilot to suggest learnings from prototype review comments, with tokens from Hub's GitHub App, so it works while everyone is signed out. billing: "Copilot bills the usage to the %{organization} organization, which installed Hub's GitHub App, and applies its Copilot policies and budgets." - no_token: Hub's GitHub App has no active installation on an organization, so Hub cannot bill Copilot to one. Install the App on an organization, or the person who runs Hub can remove REPLICA_COPILOT_BILLING and give Hub a token instead. + no_token: Hub's GitHub App has no active installation on its organization, so Hub cannot bill Copilot to it. Install the App on its organization, or the person who runs Hub can remove REPLICA_COPILOT_BILLING and give Hub a token instead. + personal_app: Hub's GitHub App belongs to a personal account, and Copilot bills only organizations for an App's requests. Reset the GitHub App and create it again under the organization, or the person who runs Hub can remove REPLICA_COPILOT_BILLING and give Hub a token instead. unconfirmed: "Hub bills Copilot to an organization only after a Hub administrator confirms it, so Hub stopped looking for learnings. Connect Copilot to let Copilot bill %{organization}." refused: "Copilot refused Hub's GitHub App on %{date}, so Hub stopped looking for learnings. Check that the App has the Copilot Requests organization permission and that %{organization} allows Copilot requests from GitHub Apps, then check Copilot again." connect: diff --git a/hub/docs/copilot.md b/hub/docs/copilot.md index 65776e07..bbfb7ee0 100644 --- a/hub/docs/copilot.md +++ b/hub/docs/copilot.md @@ -31,7 +31,7 @@ Hub reads one environment variable, so it needs no code for any secret store. Hu ## Bill your organization -With organization billing, Hub's GitHub App makes Copilot requests with its own installation token, and Copilot bills the organization that installed the App instead of a person. GitHub offers this as a [public preview](https://docs.github.com/en/copilot/how-tos/copilot-sdk/auth/server-to-server-tokens). The organization needs Copilot Business or Copilot Enterprise, and Hub's App must be installed on that organization; an App installed on a personal account cannot use it. +With organization billing, Hub's GitHub App makes Copilot requests with its own installation token, and Copilot bills the organization that installed the App instead of a person. GitHub offers this as a [public preview](https://docs.github.com/en/copilot/how-tos/copilot-sdk/auth/server-to-server-tokens). The organization needs Copilot Business or Copilot Enterprise, and Hub's App must belong to that organization and be installed on it. Hub creates its App as a private App, which only its owner can install, so a Hub whose App belongs to a personal account must [reset the App](github-app-setup.md#local-reset) and create it again under the organization, or keep using a token. 1. The App's owner opens the App's settings on GitHub, under **Permissions & events**, and sets the **Copilot requests** organization permission to **Read-only**. Read-only still lets the App spend the organization's AI credits. 2. An organization owner reviews and approves the updated permissions. GitHub's guide to [managing GitHub Apps that use your organization's Copilot](https://docs.github.com/en/copilot/how-tos/administer-copilot/manage-for-organization/manage-app-copilot-access) explains how to find the App's usage and how to block it. The App's usage counts against the organization's Copilot budget; GitHub has no budget for one App, and only a budget with **Stop usage when budget limit is reached** stops spending. @@ -74,7 +74,7 @@ A token with only the Copilot requests permission cannot read or change reposito - **Copilot refuses the token or Hub's App.** For example, the token expired, its owner deleted it, or its owner lost Copilot. With organization billing, GitHub may not create a token because the App lacks the Copilot requests permission, or Copilot may refuse the App because the organization does not allow its requests. Hub records the refusal in the security audit, stops looking for learnings, and offers **Check the token again**, or **Check Copilot again** with organization billing. People can still decide on suggestions, and agents still get accepted learnings. A refusal belongs to the token or App installation Copilot refused. Replacing the token using the procedure below, or fixing the App's permission or the organization's settings and checking again, lets Hub use Copilot again. - **The token is removed, or the App's organization installation ends.** Hub stops making Copilot requests, and the page says what Hub needs. -[Ending all Hub sessions](session-revocation.md) and resetting the GitHub App leave the Copilot connection as it is, because the token is neither a browser credential nor the App's. +[Ending all Hub sessions](session-revocation.md) leaves the Copilot connection as it is, because no Copilot token is a browser credential. With a token, resetting the GitHub App also leaves it as it is, because the token is not the App's. With organization billing, resetting the App stops Copilot requests until the new App is installed on the organization and an administrator connects Copilot again. ## Replace the token diff --git a/hub/test/integration/copilot_connections_test.rb b/hub/test/integration/copilot_connections_test.rb index cc215876..b49af37d 100644 --- a/hub/test/integration/copilot_connections_test.rb +++ b/hub/test/integration/copilot_connections_test.rb @@ -171,17 +171,21 @@ class CopilotConnectionsTest < ActionDispatch::IntegrationTest end end - test "with organization billing, the page and a connection attempt say the App needs an organization installation" do + test "with organization billing, the page tells a personal-account App from an organization App without an active installation" do Rails.configuration.x.copilot_billing = "organization" @configuration.update!(owner_type: "User") get copilot_connection_path, as: :json - assert_equal I18n.t("copilot_connection.organization.no_token"), data.fetch("setupLabel") + assert_equal I18n.t("copilot_connection.organization.personal_app"), data.fetch("setupLabel") post copilot_connection_path, params: { confirmed: "1" } assert_redirected_to copilot_connection_path assert_equal I18n.t("copilot_connection.organization.errors.no_token"), flash[:alert] + + @configuration.update!(owner_type: "Organization", installation_state: "suspended", suspended_at: Time.current) + get copilot_connection_path, as: :json + assert_equal I18n.t("copilot_connection.organization.no_token"), data.fetch("setupLabel") end test "a successful check reports cancellation instead of restoring a disconnected connection" do From 9586d08984fd19e60455cd98dc1202eab2bfdf71 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Thu, 22 Oct 2026 10:25:55 -0700 Subject: [PATCH 06/12] fix(hub): bind Copilot billing tokens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/app/models/copilot_connection.rb | 20 +++++-- hub/app/models/github_app_configuration.rb | 57 ++++++++++++++++++ .../models/github_app_configuration/tokens.rb | 18 ++++-- hub/app/models/github_client.rb | 7 ++- hub/app/models/github_client/transport.rb | 13 ++++- hub/docs/copilot.md | 8 ++- hub/docs/security.md | 2 +- .../integration/copilot_connections_test.rb | 39 +++++++++++-- hub/test/models/copilot_connection_test.rb | 58 ++++++++++++++++++- .../github_app_configuration/tokens_test.rb | 44 ++++++++++++++ hub/test/models/github_client_test.rb | 17 ++++++ hub/test/models/learning/run_test.rb | 45 +++++++++++++- hub/test/support/github_request_stubs.rb | 12 +++- 13 files changed, 312 insertions(+), 28 deletions(-) diff --git a/hub/app/models/copilot_connection.rb b/hub/app/models/copilot_connection.rb index 2ab791ab..c60c7b35 100644 --- a/hub/app/models/copilot_connection.rb +++ b/hub/app/models/copilot_connection.rb @@ -96,7 +96,7 @@ def credential return token if !organization_billing? configuration = GithubAppConfiguration.current - "github-app-installation:#{configuration.installation_id}" if configuration&.organization_installation? + configuration.copilot_credential if configuration&.organization_installation? end # A credential's SHA-256 digest, which tells credentials apart without keeping them. @@ -129,6 +129,7 @@ def connect!(actor:, browser_session:) if current&.id != earlier&.id || CopilotConnectionState.current.generation != generation raise ChangeDenied, :connection_changed end + raise ChangeDenied, :connection_changed if self.credential != credential where(singleton_key: 1).delete_all connection = create! \ @@ -173,12 +174,14 @@ def copilot_token(credential) return credential if !organization_billing? configuration = GithubAppConfiguration.current - raise Refused, "Hub's GitHub App is not installed on an organization." if configuration.nil? + if configuration.nil? || configuration.copilot_credential != credential + raise GithubAppConfiguration::Superseded, "GitHub App installation changed; start again" + end - configuration.tokens.copilot + configuration.tokens.copilot(snapshot: configuration.copilot_snapshot) rescue GithubAppConfiguration::Tokens::CopilotPermissionMissing raise PermissionMissing, "Hub's GitHub App does not have the Copilot Requests organization permission." - rescue GithubClient::Error => error + rescue GithubAppConfiguration::Superseded, GithubClient::Error => error raise Learning::Error.new("GitHub did not create a Copilot token for Hub's GitHub App: #{error.message}", code: "unavailable") end @@ -256,7 +259,14 @@ def access_token! raise Refused, "A Hub administrator must connect Copilot again before Hub bills the organization." if !confirmed? raise Refused, "#{user.login} is no longer a Hub administrator." if !usable? - self.class.copilot_token(credential) + token = self.class.copilot_token(credential) + reload + if self.class.credential != credential || refused? || !confirmed? || + !User.where(id: user_id, hub_admin: true).exists? + raise Refused, "The Copilot connection changed while Hub created a token." + end + + token rescue ActiveRecord::RecordNotFound raise Refused, "An administrator disconnected Copilot." end diff --git a/hub/app/models/github_app_configuration.rb b/hub/app/models/github_app_configuration.rb index 5a4a7506..dffbd296 100644 --- a/hub/app/models/github_app_configuration.rb +++ b/hub/app/models/github_app_configuration.rb @@ -118,6 +118,43 @@ def with_credentials!(configuration_id:, credential_revision:) yield end end + + # Rechecks the exact organization installation that a Copilot billing decision used. + def with_copilot_installation!(snapshot) + transaction do + attributes = copilot_installation_attributes(snapshot) + valid = !LocalDevelopment.enabled? && attributes && + uncached { where(attributes).exists? } + raise Superseded, "GitHub App installation changed; start again" if !valid + + yield + end + end + + private + + # Converts one trusted in-process snapshot into the exact active organization installation query. + def copilot_installation_attributes(snapshot) + return if !snapshot.is_a?(Hash) || snapshot[:owner_type] != "Organization" || + snapshot[:installation_state] != "active" + + attributes = { + id: snapshot.fetch(:configuration_id), + credential_revision: snapshot.fetch(:credential_revision), + lock_version: snapshot.fetch(:lock_version), + github_app_id: snapshot.fetch(:github_app_id), + client_id: snapshot.fetch(:client_id), + installation_id: snapshot.fetch(:installation_id), + installation_state: snapshot.fetch(:installation_state), + owner_github_id: snapshot.fetch(:owner_github_id), + owner_type: snapshot.fetch(:owner_type) + } + return if attributes.values.any?(&:nil?) + + attributes + rescue KeyError + nil + end end # Carries only nonsecret configuration identity across GitHub network work. @@ -125,6 +162,26 @@ def credential_snapshot { configuration_id: id, credential_revision: } end + # Carries the exact nonsecret App and organization installation identity used for Copilot billing. + def copilot_snapshot + { + configuration_id: id, + credential_revision:, + lock_version:, + github_app_id:, + client_id:, + installation_id:, + installation_state:, + owner_github_id:, + owner_type: + } + end + + # Identifies one exact App credential revision and organization installation without storing a secret. + def copilot_credential + JSON.generate(copilot_snapshot.except(:lock_version)) + end + # Whether the App has an active installation on an organization, the only kind of installation # whose own tokens Copilot accepts and bills. def organization_installation? diff --git a/hub/app/models/github_app_configuration/tokens.rb b/hub/app/models/github_app_configuration/tokens.rb index 55fd75b7..e7a16afb 100644 --- a/hub/app/models/github_app_configuration/tokens.rb +++ b/hub/app/models/github_app_configuration/tokens.rb @@ -8,6 +8,7 @@ class CopilotPermissionMissing < GithubClient::Error; end # other permission, even read-only repository metadata, could read the repositories the App's # installation covers. COPILOT_PERMISSIONS = { organization_copilot_requests: "read" }.freeze + COPILOT_PERMISSION_ERROR = "The permissions requested are not granted to this installation.".freeze # Creates a short-lived token that authenticates as the GitHub App. def app @@ -57,13 +58,14 @@ def for_installation(installation_id) # which Copilot bills to the organization that installed the App. Hub never reuses it, never uses it # for other GitHub requests, and never stores it. Raises CopilotPermissionMissing when the # installation has not granted the App's Copilot Requests organization permission. - def copilot - raise GithubClient::InvalidResponse, "The GitHub App installation is not active" if record.installation_state != "active" + def copilot(snapshot: record.copilot_snapshot) + GithubAppConfiguration.with_copilot_installation!(snapshot) { } payload = record.github_client.installations.create_token \ - record.installation_id, + snapshot.fetch(:installation_id), token: app, permissions: COPILOT_PERMISSIONS + GithubAppConfiguration.with_copilot_installation!(snapshot) { } raise KeyError if !payload.is_a?(Hash) token = payload.fetch("token") @@ -77,7 +79,7 @@ def copilot token rescue GithubClient::Error => error - raise if error.status != 422 + raise if !copilot_permission_missing?(error) raise CopilotPermissionMissing.new \ "The GitHub App installation has not granted the Copilot Requests organization permission", @@ -85,4 +87,12 @@ def copilot rescue KeyError, TypeError, ArgumentError raise GithubClient::InvalidResponse, "GitHub returned an incomplete installation token" end + + private + + # Recognizes the exact observed GitHub validation response, not every possible HTTP 422. + def copilot_permission_missing?(error) + error.status == 422 && error.response.is_a?(Hash) && + error.response["message"] == COPILOT_PERMISSION_ERROR + end end diff --git a/hub/app/models/github_client.rb b/hub/app/models/github_client.rb index 076b9061..5690065f 100644 --- a/hub/app/models/github_client.rb +++ b/hub/app/models/github_client.rb @@ -1,11 +1,12 @@ # Provides grouped access to GitHub's APIs through shared HTTP transports. class GithubClient class Error < StandardError - attr_reader :status + attr_reader :response, :status - # Records GitHub's HTTP status when one is available. - def initialize(message, status: nil) + # Records GitHub's HTTP status and decoded error response when they are available. + def initialize(message, status: nil, response: nil) @status = status + @response = response super(message) end end diff --git a/hub/app/models/github_client/transport.rb b/hub/app/models/github_client/transport.rb index b67f8ca8..dcf553ee 100644 --- a/hub/app/models/github_client/transport.rb +++ b/hub/app/models/github_client/transport.rb @@ -140,7 +140,10 @@ def raise_response_error(response) when 410 then GithubClient::Gone else GithubClient::Error end - raise error_class.new("GitHub returned HTTP #{status}", status:) + raise error_class.new \ + "GitHub returned HTTP #{status}", + status:, + response: error_response(response) end # Reports whether GitHub identified a 403 response as a temporary rate limit. @@ -149,4 +152,12 @@ def rate_limited?(response) response.body.to_s.downcase.include?("rate limit") end + + # Keeps a structured GitHub error available for exact classification without exposing it in messages. + def error_response(response) + parsed = JSON.parse(response.body) + parsed if parsed.is_a?(Hash) + rescue JSON::ParserError, TypeError + nil + end end diff --git a/hub/docs/copilot.md b/hub/docs/copilot.md index bbfb7ee0..a6f9eb36 100644 --- a/hub/docs/copilot.md +++ b/hub/docs/copilot.md @@ -40,7 +40,9 @@ With organization billing, Hub's GitHub App makes Copilot requests with its own GitHub also requires the organization to be enabled for Copilot requests from GitHub App installations. Until it is, Copilot refuses Hub's App, and the **Copilot** page says so. -For each Copilot request, Hub's App creates a new installation token that holds only the Copilot requests permission and expires after an hour. Hub refuses a token that GitHub reports with any other permission, passes it only to `replica-learning` for that one command, and never stores it. The token cannot read or change repositories. Anyone who can read Hub's database and its encryption keys holds the App's private key, so they can also create these tokens and spend the organization's Copilot budget until an organization owner removes the permission or suspends the App. +For each Copilot request, Hub's App creates a new installation token that holds only the Copilot requests permission and expires after an hour. Hub binds the request to the exact saved App configuration, credential revision, installation ID, and organization identity that the administrator confirmed. It checks that snapshot immediately before and after GitHub creates the token. If the App is reset, its credentials or installation change, or the installation is suspended or removed meanwhile, Hub discards the token and does not send it to Copilot. + +GitHub's installation-token documentation says the response reports the token's permissions, but it does not document an implicit permission that must accompany `organization_copilot_requests`. Hub therefore remains fail-closed: it accepts exactly that one permission, refuses a token that GitHub reports with any other permission, passes it only to `replica-learning` for that one command, and never stores it. A real request from an organization with the public preview enabled still needs to confirm the returned payload. The token cannot read or change repositories. Anyone who can read Hub's database and its encryption keys holds the App's private key, so they can also create these tokens and spend the organization's Copilot budget until an organization owner removes the permission or suspends the App. ## Connect Copilot @@ -59,7 +61,7 @@ With a token, Copilot counts the usage toward the plan of the token's owner and | The operator's token | Only in Hub's environment, never in its database | Nobody. Hub never shows, exports, or logs it | | A token from Hub's App | Only in memory, for the one command that uses it | Nobody. Hub never shows, exports, or logs it | | Who connected Copilot, when, and when Copilot last answered or refused | Hub's database | Hub administrators | -| The SHA-256 digest of the operator's token, or of the App installation's identity | Hub's database | Nobody. It tells Hub whether a refusal belongs to the token or installation it uses now | +| The SHA-256 digest of the operator's token, or of the App configuration, credential revision, installation, and organization identity | Hub's database | Nobody. It tells Hub whether a confirmation or refusal belongs to the exact billing credential it uses now | | Copilot usage that Learnings runs reported over the last 30 days | Hub's database | Hub administrators, on the **Copilot** page | | Connections, disconnections, and refusals | [Security audit](security-audit.md) | Hub administrators | @@ -71,7 +73,7 @@ A token with only the Copilot requests permission cannot read or change reposito - **An administrator disconnects it.** **Disconnect Copilot** needs confirmation and fresh GitHub verification on the same browser Session. Hub stops making Copilot requests. The operator's token stays where it is kept, and Hub's App keeps its Copilot requests permission. To end them, the token's owner deletes the token on GitHub, or the App's owner removes the permission. - **The person who connected it stops being a Hub administrator.** Hub disconnects Copilot in the same change as the role. Another administrator can connect it again. -- **Copilot refuses the token or Hub's App.** For example, the token expired, its owner deleted it, or its owner lost Copilot. With organization billing, GitHub may not create a token because the App lacks the Copilot requests permission, or Copilot may refuse the App because the organization does not allow its requests. Hub records the refusal in the security audit, stops looking for learnings, and offers **Check the token again**, or **Check Copilot again** with organization billing. People can still decide on suggestions, and agents still get accepted learnings. A refusal belongs to the token or App installation Copilot refused. Replacing the token using the procedure below, or fixing the App's permission or the organization's settings and checking again, lets Hub use Copilot again. +- **Copilot refuses the token or Hub's App.** For example, the token expired, its owner deleted it, or its owner lost Copilot. With organization billing, GitHub may return the specific installation-token validation response observed when the installation has not granted the requested permission, or Copilot may refuse the App because the organization does not allow its requests. Hub records those refusals in the security audit, stops looking for learnings, and offers **Check the token again**, or **Check Copilot again** with organization billing. Other GitHub validation or abuse responses, including other HTTP 422 responses, are temporary GitHub failures and do not record a durable Copilot refusal. People can still decide on suggestions, and agents still get accepted learnings. A refusal belongs to the token or exact App configuration and organization installation Copilot refused. Replacing the token using the procedure below, or fixing the App's permission or the organization's settings and checking again, lets Hub use Copilot again. - **The token is removed, or the App's organization installation ends.** Hub stops making Copilot requests, and the page says what Hub needs. [Ending all Hub sessions](session-revocation.md) leaves the Copilot connection as it is, because no Copilot token is a browser credential. With a token, resetting the GitHub App also leaves it as it is, because the token is not the App's. With organization billing, resetting the App stops Copilot requests until the new App is installed on the organization and an administrator connects Copilot again. diff --git a/hub/docs/security.md b/hub/docs/security.md index 12a36c01..59fdaf61 100644 --- a/hub/docs/security.md +++ b/hub/docs/security.md @@ -18,7 +18,7 @@ Other boundaries: ## Repository access - Hub stores each person's GitHub ID, but not their OAuth token. -- Learnings makes Copilot requests with `REPLICA_COPILOT_TOKEN`, a token the person who runs Hub provides with only the Copilot requests permission. With organization billing, Hub's GitHub App instead creates a one-hour token for each request that holds only that permission. Hub passes either token only to `replica-learning` and never stores it. See [Copilot](copilot.md). +- Learnings makes Copilot requests with `REPLICA_COPILOT_TOKEN`, a token the person who runs Hub provides with only the Copilot requests permission. With organization billing, Hub's GitHub App instead creates a one-hour token for each request that holds only that permission. Hub verifies the exact App credential revision, organization identity, installation, and active state before and after minting, so a concurrent reset, replacement, transfer, suspension, or removal discards the token. Hub passes either token only to `replica-learning` and never stores it. See [Copilot](copilot.md). - Every repository page checks the person's current GitHub access. - Signed-out pages reveal no setup, installation, or repository details. - If GitHub cannot confirm access, Hub denies that repository request. diff --git a/hub/test/integration/copilot_connections_test.rb b/hub/test/integration/copilot_connections_test.rb index b49af37d..3fc21419 100644 --- a/hub/test/integration/copilot_connections_test.rb +++ b/hub/test/integration/copilot_connections_test.rb @@ -156,11 +156,14 @@ class CopilotConnectionsTest < ActionDispatch::IntegrationTest test "with organization billing, connects nothing when the App lacks the Copilot permission or GitHub fails" do Rails.configuration.x.copilot_billing = "organization" - { - 422 => [ "copilot_connection.errors.permission_missing", "denied" ], - 500 => [ "copilot_connection.organization.errors.unavailable", "failed" ] - }.each do |status, (alert, outcome)| - stub_github_copilot_token(error_status: status) + failures = [ + [ 422, github_copilot_permission_missing_error, "copilot_connection.errors.permission_missing", "denied" ], + [ 422, { message: "Validation Failed", errors: [ { code: "abuse" } ] }, + "copilot_connection.organization.errors.unavailable", "failed" ], + [ 500, nil, "copilot_connection.organization.errors.unavailable", "failed" ] + ] + failures.each do |status, body, alert, outcome| + stub_github_copilot_token(error_status: status, error_body: body) post copilot_connection_path, params: { confirmed: "1" } @@ -171,6 +174,32 @@ class CopilotConnectionsTest < ActionDispatch::IntegrationTest end end + test "with organization billing, a changed App configuration during token minting connects nothing" do + Rails.configuration.x.copilot_billing = "organization" + request = stub_request(:post, "#{GITHUB_API_URL}/app/installations/456/access_tokens") + .with(body: { permissions: { organization_copilot_requests: "read" } }) + .to_return do + @configuration.update_columns(credential_revision: @configuration.credential_revision + 1) + { + status: 201, + body: JSON.generate( + token: "copilot-user-token", + expires_at: 1.hour.from_now.iso8601, + permissions: { organization_copilot_requests: "read" } + ), + headers: { "Content-Type" => "application/json" } + } + end + + post copilot_connection_path, params: { confirmed: "1" } + + assert_redirected_to copilot_connection_path + assert_nil CopilotConnection.current + assert_not_equal I18n.t("copilot_connection.connected"), flash[:notice] + assert_empty SecurityAuditEvent.where(action: "copilot_connection.connect", outcome: "succeeded") + assert_requested request + end + test "with organization billing, the page tells a personal-account App from an organization App without an active installation" do Rails.configuration.x.copilot_billing = "organization" @configuration.update!(owner_type: "User") diff --git a/hub/test/models/copilot_connection_test.rb b/hub/test/models/copilot_connection_test.rb index c88723ce..3fb3bdb2 100644 --- a/hub/test/models/copilot_connection_test.rb +++ b/hub/test/models/copilot_connection_test.rb @@ -232,14 +232,14 @@ class CopilotConnectionTest < ActiveSupport::TestCase end test "with organization billing, connects with a new App token, ignores the operator's token, and keeps only the installation's digest" do - github_app_configurations.create_installed + configuration = github_app_configurations.create_installed Rails.configuration.x.copilot_billing = "organization" Rails.configuration.x.copilot_token = "no-copilot-token" stub_github_copilot_token("copilot-user-token", "app-token-2", "app-token-3") connection = CopilotConnection.connect!(actor: @admin, browser_session: @session) - assert_equal Digest::SHA256.hexdigest("github-app-installation:456"), connection.token_digest + assert_equal CopilotConnection.digest(configuration.copilot_credential), connection.token_digest assert_equal %w[app-token-2 app-token-3], [ connection.access_token!, connection.access_token! ] end @@ -267,11 +267,18 @@ class CopilotConnectionTest < ActiveSupport::TestCase assert_raises(Learning::Error) { CopilotConnection.connect!(actor: @admin, browser_session: @session) } assert_not connection.reload.refused? - stub_github_copilot_token(error_status: 422) + stub_github_copilot_token(error_status: 422, error_body: github_copilot_permission_missing_error) assert_raises(CopilotConnection::PermissionMissing) do CopilotConnection.connect!(actor: @admin, browser_session: @session) end assert connection.reload.refused? + + connection.update!(refused_at: nil) + stub_github_copilot_token \ + error_status: 422, + error_body: { message: "Validation Failed", errors: [ { code: "abuse" } ] } + assert_raises(Learning::Error) { CopilotConnection.connect!(actor: @admin, browser_session: @session) } + assert_not connection.reload.refused? end test "turning on organization billing leaves a connection made with a token unusable until an administrator connects again" do @@ -313,6 +320,51 @@ class CopilotConnectionTest < ActiveSupport::TestCase assert_equal "renewed-user-token", reconnected.access_token! end + test "organization billing stays bound to the exact App configuration an administrator confirmed" do + changes = { + "replacement configuration" => lambda do |configuration| + id = configuration.id + configuration.delete + github_app_configurations.create_installed(id: id + 100) + end, + "App identity" => ->(configuration) { configuration.update_columns(github_app_id: 999) }, + "App client identity" => ->(configuration) { configuration.update_columns(client_id: "Iv1.replacement") }, + "credential revision" => lambda do |configuration| + configuration.update_columns(credential_revision: configuration.credential_revision + 1) + end, + "installation" => ->(configuration) { configuration.update_columns(installation_id: 789) }, + "organization owner" => ->(configuration) { configuration.update_columns(owner_github_id: 654) }, + "organization owner type" => ->(configuration) { configuration.update_columns(owner_type: "User") }, + "suspended installation" => ->(configuration) { configuration.update_columns(installation_state: "suspended") }, + "removed installation" => ->(configuration) { configuration.update_columns(installation_state: "removed") } + } + + changes.each do |label, change| + CopilotConnection.delete_all + GithubAppConfiguration.delete_all + configuration = github_app_configurations.create_installed + connection = connect_copilot_for_organization(@admin) + + change.call(configuration) + installation_id = GithubAppConfiguration.current.installation_id + mint = stub_github_copilot_token("unconfirmed-token", installation_id:) + + assert_not connection.reload.usable?, label + assert_raises(CopilotConnection::Refused, label) { connection.access_token! } + assert_not_requested mint + end + end + + test "organization billing confirmation survives a non-billing configuration write" do + configuration = github_app_configurations.create_installed + connection = connect_copilot_for_organization(@admin) + configuration.touch + stub_github_copilot_token("still-confirmed-token") + + assert connection.reload.usable? + assert_equal "still-confirmed-token", connection.access_token! + end + test "Hub starts with organization billing and refuses a billing choice it does not know" do initializer = Rails.root.join("config/initializers/deployment_configuration.rb") Rails.configuration.x.copilot_billing = "organization" diff --git a/hub/test/models/github_app_configuration/tokens_test.rb b/hub/test/models/github_app_configuration/tokens_test.rb index b7aad49f..e914d363 100644 --- a/hub/test/models/github_app_configuration/tokens_test.rb +++ b/hub/test/models/github_app_configuration/tokens_test.rb @@ -84,4 +84,48 @@ class GithubAppConfiguration::TokensTest < ActiveSupport::TestCase assert_raises(GithubClient::InvalidResponse, permissions.inspect) { configuration.tokens.copilot } end end + + test "maps only GitHub's permission-not-granted response to a missing Copilot permission" do + configuration = github_app_configurations.create_installed + permission_missing = { + message: "The permissions requested are not granted to this installation." + } + stub_github_copilot_token(error_status: 422, error_body: permission_missing) + + assert_raises(GithubAppConfiguration::Tokens::CopilotPermissionMissing) do + configuration.tokens.copilot + end + + stub_github_copilot_token \ + error_status: 422, + error_body: { + message: "Validation Failed", + errors: [ { resource: "InstallationToken", code: "abuse" } ] + } + + error = assert_raises(GithubClient::Error) { configuration.tokens.copilot } + assert_equal 422, error.status + end + + test "returns no Copilot token after a transient installation suspension while GitHub creates it" do + configuration = github_app_configurations.create_installed + request = stub_request(:post, "#{GITHUB_API_URL}/app/installations/456/access_tokens") + .with(body: { permissions: { organization_copilot_requests: "read" } }) + .to_return do + configuration.update!(installation_state: "suspended") + configuration.update!(installation_state: "active") + { + status: 201, + body: JSON.generate( + token: "copilot-token", + expires_at: 1.hour.from_now.iso8601, + permissions: { organization_copilot_requests: "read" } + ), + headers: { "Content-Type" => "application/json" } + } + end + + assert_raises(GithubAppConfiguration::Superseded) { configuration.tokens.copilot } + assert_requested request + end end diff --git a/hub/test/models/github_client_test.rb b/hub/test/models/github_client_test.rb index 4edd92c1..d60c076c 100644 --- a/hub/test/models/github_client_test.rb +++ b/hub/test/models/github_client_test.rb @@ -82,6 +82,23 @@ class GithubClientTest < ActiveSupport::TestCase assert_includes error.message, "execution expired" end + test "retains a structured GitHub error response without exposing it in the message" do + response = { + message: "Validation Failed", + errors: [ { resource: "InstallationToken", code: "custom", field: "permissions" } ] + } + stub_request(:get, "#{GITHUB_API_URL}/app") + .to_return(status: 422, body: JSON.generate(response)) + + error = assert_raises(GithubClient::Error) do + GithubClient.new.apps.get(token: "app-token") + end + + assert_equal 422, error.status + assert_equal response.deep_stringify_keys, error.response + assert_equal "GitHub returned HTTP 422", error.message + end + test "OAuth rejection types never expose the provider response or supplied credentials" do [ "bad_verification_code", "access_denied", "incorrect_client_credentials", "unknown_error" ].each do |code| stub_request(:post, "#{GITHUB_URL}/login/oauth/access_token") diff --git a/hub/test/models/learning/run_test.rb b/hub/test/models/learning/run_test.rb index d0021ea2..43074357 100644 --- a/hub/test/models/learning/run_test.rb +++ b/hub/test/models/learning/run_test.rb @@ -881,10 +881,39 @@ class Learning::RunTest < ActiveSupport::TestCase assert_operator @connection.reload.verified_at, :>, 1.minute.ago end + test "with organization billing, a changed App configuration during token minting reaches no labeling command" do + @connection = connect_copilot_for_organization(@admin) + learning_comment(@repository, @mona, "prototype/signup", "The email field has no label.") + request = stub_request(:post, "#{GITHUB_API_URL}/app/installations/456/access_tokens") + .with(body: { permissions: { organization_copilot_requests: "read" } }) + .to_return do + @configuration.update_columns(credential_revision: @configuration.credential_revision + 1) + { + status: 201, + body: JSON.generate( + token: "app-token", + expires_at: 1.hour.from_now.iso8601, + permissions: { organization_copilot_requests: "read" } + ), + headers: { "Content-Type" => "application/json" } + } + end + labeled = false + run = Learning::Run.request!(repository: @repository, user: @admin) + + intercepting("label", with: ->(command) { labeled = true; command.call }) { run.find_learnings! } + + assert_equal %w[failed unavailable], [ run.reload.state, run.failure_code ] + assert_equal 0, run.copilot_requests + assert_not labeled + assert_nil @connection.reload.refused_at + assert_requested request + end + test "with organization billing, an App that lacks the Copilot permission fails the run and stops Hub from asking again" do @connection = connect_copilot_for_organization(@admin) learning_comment(@repository, @mona, "prototype/signup", "The email field has no label.") - stub_github_copilot_token(error_status: 422) + stub_github_copilot_token(error_status: 422, error_body: github_copilot_permission_missing_error) run = Learning::Run.request!(repository: @repository, user: @admin) run.find_learnings! @@ -893,6 +922,20 @@ class Learning::RunTest < ActiveSupport::TestCase assert @connection.reload.refused? end + test "with organization billing, an unrelated GitHub 422 fails the run without refusing the connection" do + @connection = connect_copilot_for_organization(@admin) + learning_comment(@repository, @mona, "prototype/signup", "The email field has no label.") + stub_github_copilot_token \ + error_status: 422, + error_body: { message: "Validation Failed", errors: [ { code: "abuse" } ] } + run = Learning::Run.request!(repository: @repository, user: @admin) + + run.find_learnings! + + assert_equal %w[failed unavailable], [ run.reload.state, run.failure_code ] + assert_nil @connection.reload.refused_at + end + test "with organization billing, GitHub failing to create a token fails the run without refusing the connection" do @connection = connect_copilot_for_organization(@admin) learning_comment(@repository, @mona, "prototype/signup", "The email field has no label.") diff --git a/hub/test/support/github_request_stubs.rb b/hub/test/support/github_request_stubs.rb index d16be57e..2bd873eb 100644 --- a/hub/test/support/github_request_stubs.rb +++ b/hub/test/support/github_request_stubs.rb @@ -30,11 +30,14 @@ def stub_github_installation_token(installation_id: 456) # Stubs the new token Hub's GitHub App creates for each Copilot request, which may ask only for # the Copilot Requests organization permission. GitHub answers with each token in turn and repeats # the last, reporting the given permissions, or fails every request with error_status. - def stub_github_copilot_token(*tokens, installation_id: 456, error_status: nil, + def stub_github_copilot_token(*tokens, installation_id: 456, error_status: nil, error_body: nil, permissions: { organization_copilot_requests: "read" }) request = stub_request(:post, "#{GITHUB_API_URL}/app/installations/#{installation_id}/access_tokens") .with(body: { permissions: { organization_copilot_requests: "read" } }) - return request.to_return(status: error_status, body: JSON.generate(message: "Validation Failed")) if error_status + if error_status + body = error_body || { message: "Validation Failed" } + return request.to_return(status: error_status, body: JSON.generate(body)) + end responses = tokens.map do |token| { @@ -46,6 +49,11 @@ def stub_github_copilot_token(*tokens, installation_id: 456, error_status: nil, request.to_return(*responses) end + # The observed installation-token refusal when requested permissions were not granted. + def github_copilot_permission_missing_error + { message: "The permissions requested are not granted to this installation." } + end + # Stubs GitHub's OAuth exchange and stable user profile response. def stub_github_oauth_user(github_id: 1001, login: "octocat") stub_request(:post, "#{GITHUB_URL}/login/oauth/access_token") From 01e2a638639f87a0ffad3f23cad7826c05f11c95 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Thu, 22 Oct 2026 10:29:55 -0700 Subject: [PATCH 07/12] test(hub): exercise billing snapshots directly Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/test/models/copilot_connection_test.rb | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/hub/test/models/copilot_connection_test.rb b/hub/test/models/copilot_connection_test.rb index 3fb3bdb2..3b5654e9 100644 --- a/hub/test/models/copilot_connection_test.rb +++ b/hub/test/models/copilot_connection_test.rb @@ -343,7 +343,9 @@ class CopilotConnectionTest < ActiveSupport::TestCase CopilotConnection.delete_all GithubAppConfiguration.delete_all configuration = github_app_configurations.create_installed - connection = connect_copilot_for_organization(@admin) + Rails.configuration.x.copilot_billing = "organization" + stub_github_copilot_token("confirmed-token") + connection = CopilotConnection.connect!(actor: @admin, browser_session: @session) change.call(configuration) installation_id = GithubAppConfiguration.current.installation_id @@ -357,7 +359,9 @@ class CopilotConnectionTest < ActiveSupport::TestCase test "organization billing confirmation survives a non-billing configuration write" do configuration = github_app_configurations.create_installed - connection = connect_copilot_for_organization(@admin) + Rails.configuration.x.copilot_billing = "organization" + stub_github_copilot_token("confirmed-token") + connection = CopilotConnection.connect!(actor: @admin, browser_session: @session) configuration.touch stub_github_copilot_token("still-confirmed-token") From 7235a24cab43a3b2b8ed990b8893c6b1e0bbfca0 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Thu, 22 Oct 2026 10:33:32 -0700 Subject: [PATCH 08/12] test(hub): use accepted Copilot fixture token Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/test/models/copilot_connection_test.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/hub/test/models/copilot_connection_test.rb b/hub/test/models/copilot_connection_test.rb index 3b5654e9..b51bf835 100644 --- a/hub/test/models/copilot_connection_test.rb +++ b/hub/test/models/copilot_connection_test.rb @@ -344,7 +344,7 @@ class CopilotConnectionTest < ActiveSupport::TestCase GithubAppConfiguration.delete_all configuration = github_app_configurations.create_installed Rails.configuration.x.copilot_billing = "organization" - stub_github_copilot_token("confirmed-token") + stub_github_copilot_token("copilot-user-token") connection = CopilotConnection.connect!(actor: @admin, browser_session: @session) change.call(configuration) @@ -360,7 +360,7 @@ class CopilotConnectionTest < ActiveSupport::TestCase test "organization billing confirmation survives a non-billing configuration write" do configuration = github_app_configurations.create_installed Rails.configuration.x.copilot_billing = "organization" - stub_github_copilot_token("confirmed-token") + stub_github_copilot_token("copilot-user-token") connection = CopilotConnection.connect!(actor: @admin, browser_session: @session) configuration.touch stub_github_copilot_token("still-confirmed-token") From 0b40cb17a25e5a4ce3fc5b917b5bb3a88efad3b2 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Thu, 22 Oct 2026 10:40:42 -0700 Subject: [PATCH 09/12] test(hub): distinguish confirmed token request Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/test/models/copilot_connection_test.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hub/test/models/copilot_connection_test.rb b/hub/test/models/copilot_connection_test.rb index b51bf835..0d89b77f 100644 --- a/hub/test/models/copilot_connection_test.rb +++ b/hub/test/models/copilot_connection_test.rb @@ -353,7 +353,7 @@ class CopilotConnectionTest < ActiveSupport::TestCase assert_not connection.reload.usable?, label assert_raises(CopilotConnection::Refused, label) { connection.access_token! } - assert_not_requested mint + assert_requested mint, times: 1 end end From 828e57b844ffd50be43bda205b123a39498a4506 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Thu, 22 Oct 2026 10:44:30 -0700 Subject: [PATCH 10/12] test(hub): isolate billing request counts Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/test/models/copilot_connection_test.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/hub/test/models/copilot_connection_test.rb b/hub/test/models/copilot_connection_test.rb index 0d89b77f..18280b18 100644 --- a/hub/test/models/copilot_connection_test.rb +++ b/hub/test/models/copilot_connection_test.rb @@ -346,6 +346,7 @@ class CopilotConnectionTest < ActiveSupport::TestCase Rails.configuration.x.copilot_billing = "organization" stub_github_copilot_token("copilot-user-token") connection = CopilotConnection.connect!(actor: @admin, browser_session: @session) + WebMock.reset_executed_requests! change.call(configuration) installation_id = GithubAppConfiguration.current.installation_id @@ -353,7 +354,7 @@ class CopilotConnectionTest < ActiveSupport::TestCase assert_not connection.reload.usable?, label assert_raises(CopilotConnection::Refused, label) { connection.access_token! } - assert_requested mint, times: 1 + assert_not_requested mint end end From 9e68e2c0cf7f23f661b47deacf77535d95135a28 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Thu, 22 Oct 2026 12:08:13 -0700 Subject: [PATCH 11/12] fix(hub): bind Copilot billing confirmation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../copilot_connections_controller.rb | 16 ++- .../copilot-connection-page/index.test.tsx | 3 + .../pages/copilot-connection-page/index.tsx | 5 + .../pages/copilot-connection-page/stories.tsx | 1 + .../pages/copilot-connection-page/types.ts | 1 + hub/app/models/copilot_connection.rb | 56 +++++++++- .../views/copilot_connections/show.json.props | 1 + hub/config/locales/copilot_connection.en.yml | 1 + hub/docs/copilot.md | 2 +- .../integration/copilot_connections_test.rb | 103 ++++++++++++++---- hub/test/models/copilot_connection_test.rb | 27 +++++ .../github_app_configuration/tokens_test.rb | 1 + 12 files changed, 190 insertions(+), 27 deletions(-) diff --git a/hub/app/controllers/copilot_connections_controller.rb b/hub/app/controllers/copilot_connections_controller.rb index cca31b1b..a70483fc 100644 --- a/hub/app/controllers/copilot_connections_controller.rb +++ b/hub/app/controllers/copilot_connections_controller.rb @@ -6,7 +6,8 @@ class CopilotConnectionsController < SuperglueController before_action :require_github_hub before_action :authorize_admin - before_action :confirm_change, only: %i[create destroy] + before_action :confirm_connect, only: :create + before_action :confirm_disconnect, only: :destroy before_action :authorize_connect, only: :create before_action :authorize_disconnect, only: :destroy @@ -21,7 +22,10 @@ def show # @route POST /copilot (copilot_connection) def create - CopilotConnection.connect!(actor: Current.user, browser_session: Current.session) + CopilotConnection.connect! \ + actor: Current.user, + browser_session: Current.session, + credential: @confirmed_credential @security_audit_recorded = true redirect_to copilot_connection_path, notice: t("copilot_connection.connected"), status: :see_other rescue CopilotConnection::Refused, Learning::Error => error @@ -58,7 +62,13 @@ def authorize_admin render :show, status: :forbidden end - def confirm_change + def confirm_connect + return change_denied(CopilotConnection::ChangeDenied.new(:confirmation_required)) if params[:confirmed] != "1" + + @confirmed_credential = CopilotConnection.confirmed_credential!(params[:billing_confirmation]) + end + + def confirm_disconnect return if params[:confirmed] == "1" change_denied(CopilotConnection::ChangeDenied.new(:confirmation_required)) diff --git a/hub/app/javascript/pages/copilot-connection-page/index.test.tsx b/hub/app/javascript/pages/copilot-connection-page/index.test.tsx index 99b16680..3b294f50 100644 --- a/hub/app/javascript/pages/copilot-connection-page/index.test.tsx +++ b/hub/app/javascript/pages/copilot-connection-page/index.test.tsx @@ -36,6 +36,9 @@ describe("CopilotConnectionPage()", () => { expect(form).toHaveAttribute("method", "post") expect(form?.querySelector('[name="_method"]')).toBeNull() expect(form?.querySelector('[name="authenticity_token"]')).toHaveValue("storybook-token") + expect(form?.querySelector('[name="billing_confirmation"]')).toHaveValue( + "signed-billing-confirmation", + ) expect(screen.queryByRole("button", { name: "Disconnect Copilot" })).not.toBeInTheDocument() }) diff --git a/hub/app/javascript/pages/copilot-connection-page/index.tsx b/hub/app/javascript/pages/copilot-connection-page/index.tsx index c0e24a54..f349e8fe 100644 --- a/hub/app/javascript/pages/copilot-connection-page/index.tsx +++ b/hub/app/javascript/pages/copilot-connection-page/index.tsx @@ -42,6 +42,11 @@ export function CopilotConnectionPage({ data }: { data: CopilotConnectionPageDat className="copilot-connection-page__form" > +