fix(menubar): follow the credential's GitHub host for Copilot quota

Both Copilot quota readers hardcoded
https://api.github.com/copilot_internal/user and dropped the host their
credential was read from, so an enterprise on a GitHub Enterprise Cloud
data-residency host (<tenant>.ghe.com) could only ever report
"Temporarily unavailable": its token is not a dotcom token, and dotcom is
not an endpoint it may be sent to.

A discovered credential now carries its host. hosts.json is keyed by
GitHub host and newer apps.json files key by "<host>:<app id>", so the
token and the host always come from the same entry; the rungs that carry
no host of their own (an app-name apps.json key, COPILOT_GITHUB_TOKEN /
GH_TOKEN / GITHUB_TOKEN, gh auth token, a pasted token) mean dotcom. The
endpoint follows the host: api.github.com for github.com, and
api.<tenant>.ghe.com for an Enterprise Cloud host. With several hosts
signed in github.com wins, otherwise the first .ghe.com tenant in sorted
order, which keeps the pick stable across reads; with exactly one, that
host is used.

A host neither rule can address, such as a self-hosted GitHub Enterprise
Server install, fails with a message naming that host instead of falling
back to dotcom, and network and HTTP failures name the host that was
tried. macOS Settings now says which host answered in the Copilot
connection row. Tokens are still never logged and sanitizeForUI is
unchanged.

Closes #1286
This commit is contained in:
ozymandiashh 2026-09-11 17:07:25 +03:00 • committed by iamtoruk
parent cfa0f9fa07
commit 8b5bd3ed4c
11 changed files with 801 additions and 68 deletions

View file

@ -1,5 +1,10 @@
# Changelog
## Unreleased
### Fixed
- **Copilot live quota works for GitHub Enterprise Cloud enterprises on a `*.ghe.com` host.** Both readers hardcoded `https://api.github.com/copilot_internal/user` and threw away the host their credential came from, so a data-residency enterprise signed in on `<tenant>.ghe.com` could only ever report `available: false` with "Temporarily unavailable". A discovered credential now carries its host — `hosts.json` is keyed by host and newer `apps.json` files key by `<host>:<app id>` — and the request follows it: `api.github.com` for `github.com` and for any rung that carries no host of its own (an app-name `apps.json` key, `COPILOT_GITHUB_TOKEN` / `GH_TOKEN` / `GITHUB_TOKEN`, `gh auth token`, a pasted token), and `https://api.<tenant>.ghe.com/copilot_internal/user` for an enterprise host. The token and the host always come from the same entry, with `github.com` preferred when several hosts are signed in and otherwise the first `.ghe.com` tenant in sorted order; a host neither rule can address, such as a self-hosted GitHub Enterprise Server install, fails with a message naming that host instead of sending the credential to dotcom, and unreachable-host and HTTP failures name the host that was tried. The macOS Settings connection row now says which host answered. (#1286)
## 0.9.24 - 2026-09-04
### Added

View file

@ -315,8 +315,9 @@ surfaces, add a reader with a captured fixture.)
## Live quota (Copilot subscription)
Separate from the log parser above: the macOS menubar reads live quota from
`GET https://api.github.com/copilot_internal/user` with a GitHub token. Usage
tracking never needs this token; only the quota bars do.
`GET https://api.github.com/copilot_internal/user` with a GitHub token — or from
the tenant's own host on GitHub Enterprise Cloud, see below. Usage tracking
never needs this token; only the quota bars do.
`mac/Sources/CodeBurnMenubar/Data/CopilotSubscriptionService.swift` walks an
ordered, read-only chain and takes the first token it finds. Every rung
@ -355,6 +356,30 @@ approximates rung 4 with an installed `gh` binary.
The Electron surface (`app/electron/quota/copilot.ts`) still implements only
rung 1.
### GitHub Enterprise Cloud hosts (`*.ghe.com`)
The endpoint is not fixed to dotcom. GitHub Enterprise Cloud with data
residency puts an enterprise on its own hostname, `<tenant>.ghe.com`, whose API
lives at `api.<tenant>.ghe.com`, and a token minted there is not valid on
api.github.com. So a credential carries the host it was read from —
`hosts.json` is keyed by host, and newer `apps.json` files key by
`<host>:<app id>` — and the request goes to
`https://api.<tenant>.ghe.com/copilot_internal/user` for an enterprise host and
to `https://api.github.com/copilot_internal/user` for `github.com` or for any
rung that carries no host of its own (an app-name `apps.json` key, the
environment variables, `gh auth token`, a pasted token). The token and the host
always come from the same entry: with several hosts signed in, `github.com`
wins, otherwise the first `.ghe.com` tenant in sorted order, so the pick is
stable across reads; with exactly one, that host is used. A host neither rule
can address — a self-hosted GitHub Enterprise Server install, which CodeBurn
does not support — fails with a message naming that host rather than falling
back to api.github.com, because dotcom would reject the credential anyway and
must never receive it. Unreachable-host and HTTP failures name the host tried,
so the symptom is no longer a bare "Temporarily unavailable". Settings shows
which host answered in the Copilot connection row. `CopilotHostEndpoint`
(macOS) and the exported helpers in `src/quota/copilot.ts` (CLI) hold the
derivation and the host pick.
## Caching
None for the JSONL sources. The OTel and session-store sources use the durable cache (see above).

View file

@ -0,0 +1,80 @@
import Foundation
/// Which GitHub host a discovered Copilot credential belongs to, and the
/// Copilot quota endpoint that host answers on.
///
/// GitHub Enterprise Cloud with data residency puts an enterprise on its own
/// hostname (`<tenant>.ghe.com`), whose API lives at `api.<tenant>.ghe.com`. A
/// token minted there is not a dotcom token, so the endpoint has to follow the
/// credential's host instead of being hardcoded, and a host this build does
/// not know how to address must fail rather than fall back: sending an
/// enterprise credential to `api.github.com` would both leak it to the wrong
/// endpoint and report "temporarily unavailable" forever (#1286).
///
/// Self-hosted GitHub Enterprise Server (`https://<host>/api/v3/...`) is
/// deliberately out of scope here: nothing in CodeBurn reads or documents a
/// GHES install today, and guessing that shape for any unrecognized host would
/// send credentials to a host we never verified serves this endpoint.
enum CopilotHostEndpoint {
/// Assumed for every credential source that carries no host of its own
/// (`apps.json` entries keyed by app name, the environment variables,
/// `gh auth token`, and a token pasted into Settings).
static let defaultHost = "github.com"
static let defaultAPIHost = "api.github.com"
/// GitHub Enterprise Cloud with data residency.
static let enterpriseCloudSuffix = ".ghe.com"
private static let usagePath = "/copilot_internal/user"
/// Bare lowercased hostname. Tolerates surrounding whitespace, a scheme, a
/// trailing slash or path, and a port, because `hosts.json` keys are
/// written by several different clients. nil when nothing usable is left.
static func normalize(_ raw: String?) -> String? {
guard var host = raw?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), !host.isEmpty else {
return nil
}
if let schemeEnd = host.range(of: "://") { host = String(host[schemeEnd.upperBound...]) }
if let slash = host.firstIndex(of: "/") { host = String(host[..<slash]) }
if let at = host.lastIndex(of: "@") { host = String(host[host.index(after: at)...]) }
if let colon = host.firstIndex(of: ":") { host = String(host[..<colon]) }
return host.isEmpty ? nil : host
}
/// The API host that serves Copilot quota for a credential's GitHub host,
/// or nil for a host this build cannot address. A nil `host` means the
/// source carried none, which is dotcom.
static func apiHost(for host: String?) -> String? {
guard let host = normalize(host) else { return defaultAPIHost }
if host == defaultHost || host == defaultAPIHost { return defaultAPIHost }
// `api.<tenant>.ghe.com` is already the API host; a bare tenant host
// gains the `api.` label.
if host.hasSuffix(enterpriseCloudSuffix), host.count > enterpriseCloudSuffix.count {
return host.hasPrefix("api.") ? host : "api." + host
}
return nil
}
/// The quota endpoint for a credential's host, or nil when the host is not
/// one this build knows how to reach.
static func usageURL(for host: String?) -> URL? {
guard let apiHost = apiHost(for: host) else { return nil }
return URL(string: "https://\(apiHost)\(usagePath)")
}
/// Picks the host to query out of the hosts a credential file lists. A nil
/// entry stands for a source with no host of its own, i.e. dotcom.
///
/// A single entry is unambiguous and is used as-is, even when it is a host
/// this build cannot address, so the failure names the host the user
/// actually signed in to rather than silently trying dotcom. With several
/// entries dotcom wins, because that is what every non-enterprise client
/// writes; otherwise the first `.ghe.com` tenant in sorted order, so the
/// pick is stable from one read to the next.
static func preferredHost(among hosts: [String?]) -> String? {
let normalized = hosts.map { normalize($0) ?? defaultHost }
guard let first = normalized.first else { return nil }
if normalized.count == 1 { return first }
if normalized.contains(defaultHost) { return defaultHost }
let enterprise = normalized.filter { $0.hasSuffix(enterpriseCloudSuffix) }.sorted()
return enterprise.first ?? normalized.sorted().first
}
}

View file

@ -65,6 +65,15 @@ enum CopilotQuotaPresentation {
}
}
/// Settings connection-row detail for a loaded snapshot. It always names
/// the host that answered, so a GitHub Enterprise Cloud tenant can see its
/// own `api.<tenant>.ghe.com` endpoint rather than a dotcom claim (#1286).
static func connectedSettingsDetail(plan: String?, apiHost: String) -> String {
let host = apiHost.isEmpty ? CopilotHostEndpoint.defaultAPIHost : apiHost
guard let plan, !plan.isEmpty else { return "Live quota tracked from \(host)." }
return "Plan: \(plan). Live quota tracked from \(host)."
}
static func settingsNotConnectedDetail(explicitlyDisconnected: Bool) -> String {
explicitlyDisconnected ? disconnectedSettingsDetail : noCredentialsSettingsDetail
}

View file

@ -20,6 +20,30 @@ struct CopilotUsage: Sendable, Equatable {
/// Business / Enterprise / Educators, unknown tiers title-cased).
let plan: String?
let fetchedAt: Date
/// Host that answered, so Settings can name it: `api.github.com`, or the
/// tenant's `api.<tenant>.ghe.com` on GitHub Enterprise Cloud (#1286).
var apiHost: String = CopilotHostEndpoint.defaultAPIHost
}
/// A discovered Copilot token together with the GitHub host it belongs to, so
/// the quota request can be addressed to that host's API instead of assuming
/// dotcom (#1286).
struct CopilotCredential: Sendable, Equatable {
let token: String
/// Host the token was read from, or nil when the source carries none
/// (`apps.json` keyed by app name, the environment variables, `gh`, or a
/// token pasted into Settings), which is dotcom.
let host: String?
init(token: String, host: String? = nil) {
self.token = token
self.host = host
}
/// Normalized host, defaulting to dotcom for a source without one.
var resolvedHost: String {
CopilotHostEndpoint.normalize(host) ?? CopilotHostEndpoint.defaultHost
}
}
/// Live GitHub Copilot quota via the editor plugins' internal usage endpoint
@ -27,18 +51,22 @@ struct CopilotUsage: Sendable, Equatable {
///
/// - GET https://api.github.com/copilot_internal/user → plan + quota snapshots
///
/// The host is not fixed: a credential read for a GitHub Enterprise Cloud
/// tenant is queried on that tenant's own API host
/// (`https://api.<tenant>.ghe.com/copilot_internal/user`) and is never sent to
/// api.github.com. See `CopilotHostEndpoint`.
///
/// This is an INTERNAL, UNDOCUMENTED API that may drift without notice; every
/// failure must degrade to the normal connection states and never crash the
/// panel.
///
/// Credential: any GitHub token already on the machine from a signed-in
/// Copilot client. Modern clients no longer write the legacy plugin files, so
/// discovery walks an ordered chain (see `readToken`). Read-only throughout;
/// discovery walks an ordered chain (see `readCredential`). Read-only throughout;
/// nothing is copied or written, and there is no refresh path: an expired or
/// revoked token stays dead until the user signs in again with whichever
/// client owns it.
enum CopilotSubscriptionService {
private static let usageURL = URL(string: "https://api.github.com/copilot_internal/user")!
private static let usageBlockedUntilKey = "codeburn.copilot.usage.blockedUntil"
/// Capacity Dock provider id, used for the pasted-token rung.
static let providerID = "copilot"
@ -54,7 +82,11 @@ enum CopilotSubscriptionService {
case rateLimited(retryAt: Date)
case usageHTTPError(Int)
case usageDecodeFailed
case network(Error)
case network(Error, host: String)
/// The credential belongs to a GitHub host whose Copilot quota
/// endpoint this build cannot derive (a self-hosted GitHub Enterprise
/// Server install, say). Never retried against api.github.com.
case unsupportedHost(String)
var errorDescription: String? {
switch self {
@ -72,14 +104,17 @@ enum CopilotSubscriptionService {
return "Copilot quota fetch failed (HTTP \(code)). Sign in to Copilot again, then Reconnect."
case .usageDecodeFailed:
return "Copilot quota response was malformed."
case let .network(err):
return "Network error: \(err.localizedDescription)"
case let .network(err, host):
return "Network error reaching \(host): \(err.localizedDescription)"
case let .unsupportedHost(host):
return "Copilot quota is not available for the GitHub host \(host). "
+ "CodeBurn can read github.com and GitHub Enterprise Cloud (*.ghe.com) hosts."
}
}
var isTerminal: Bool {
switch self {
case .noCredentials:
case .noCredentials, .unsupportedHost:
return true
case let .usageHTTPError(code):
return (400..<500).contains(code)
@ -153,7 +188,8 @@ enum CopilotSubscriptionService {
/// Ordered, read-only credential chain; the first rung that yields a token
/// wins and every rung tolerates absent or malformed data:
///
/// 1. legacy editor-plugin files: `hosts.json` then `apps.json`
/// 1. legacy editor-plugin files: `hosts.json` then `apps.json` (both
/// carry the GitHub host the token belongs to)
/// 2. `~/.copilot/config.json` then `~/.copilot/settings.json`
/// 3. `COPILOT_GITHUB_TOKEN`, `GH_TOKEN`, `GITHUB_TOKEN`
/// 4. `gh auth token`
@ -164,29 +200,51 @@ enum CopilotSubscriptionService {
/// `/usr/bin/security` is not in its partition list and every read would
/// raise the "allow access?" dialog. `gh auth token` reaches the same
/// users, because the Copilot CLI itself falls back to gh.
private static func readToken(deps: Deps) -> String? {
for url in [deps.hostsURL, deps.appsURL] {
if let data = deps.readFile(url), let token = tokenFromMap(data) { return token }
}
private static func readCredential(deps: Deps) -> CopilotCredential? {
// hosts.json is keyed by GitHub host; apps.json by "<host>:<app id>"
// (older releases wrote a bare app name, which carries no host).
if let data = deps.readFile(deps.hostsURL),
let credential = credentialFromMap(data, host: { $0 }) { return credential }
if let data = deps.readFile(deps.appsURL),
let credential = credentialFromMap(data, host: { hostFromAppsKey($0) }) { return credential }
for name in ["config.json", "settings.json"] {
if let data = deps.readFile(deps.copilotDirURL.appendingPathComponent(name)),
let token = tokenFromCopilotCLIJSON(data) { return token }
let token = tokenFromCopilotCLIJSON(data) { return CopilotCredential(token: token) }
}
for name in environmentTokenNames {
if let token = nonEmpty(deps.environment(name)) { return token }
if let token = nonEmpty(deps.environment(name)) { return CopilotCredential(token: token) }
}
if let token = cachedGhToken(deps: deps) { return token }
return nonEmpty(deps.savedToken())
if let token = cachedGhToken(deps: deps) { return CopilotCredential(token: token) }
guard let token = nonEmpty(deps.savedToken()) else { return nil }
return CopilotCredential(token: token)
}
/// hosts.json keys by host — prefer github.com; apps.json has no
/// canonical key, so its first entry wins. Both store the token as
/// `oauth_token`.
private static func tokenFromMap(_ data: Data) -> String? {
/// Reads the `oauth_token` entries out of a credential map and picks one
/// with `CopilotHostEndpoint.preferredHost`, so the token we send and the
/// host we send it to always come from the same entry. Keys are visited in
/// sorted order, making the pick stable when several entries qualify.
private static func credentialFromMap(
_ data: Data,
host keyToHost: (String) -> String?
) -> CopilotCredential? {
guard let map = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
let preferred = map["github.com"] ?? map.values.first
guard let token = (preferred as? [String: Any])?["oauth_token"] as? String, !token.isEmpty else { return nil }
return token
let entries: [CopilotCredential] = map.keys.sorted().compactMap { key in
guard let token = (map[key] as? [String: Any])?["oauth_token"] as? String, !token.isEmpty else {
return nil
}
return CopilotCredential(token: token, host: keyToHost(key))
}
guard let preferred = CopilotHostEndpoint.preferredHost(among: entries.map(\.host)) else { return nil }
return entries.first { $0.resolvedHost == preferred }
}
/// apps.json keys look like `github.com:Iv1.<app id>` on the newer
/// plugins and like a bare app name ("Visual Studio Code") on the older
/// ones; only the former carries a host.
private static func hostFromAppsKey(_ key: String) -> String? {
guard let separator = key.firstIndex(of: ":") else { return nil }
let candidate = String(key[..<separator])
return candidate.contains(".") ? candidate : nil
}
/// GitHub access-token prefixes. `ghr_` (refresh) is deliberately absent:
@ -342,19 +400,19 @@ enum CopilotSubscriptionService {
if let until = usageBlockedUntil(), until > deps.now() {
throw FetchError.rateLimited(retryAt: until)
}
guard var token = readToken(deps: deps) else { throw FetchError.noCredentials }
guard var credential = readCredential(deps: deps) else { throw FetchError.noCredentials }
var (data, response) = try await send(request(token: token), deps: deps)
var (data, response) = try await send(request(credential), deps: deps)
if response.statusCode == 401 {
// An active client session rotates this token; re-read once before
// giving up so we don't report a failure the source already fixed.
// The subprocess rungs are cached, so drop those answers first.
resetProbeCache()
guard let reread = readToken(deps: deps), reread != token else {
guard let reread = readCredential(deps: deps), reread != credential else {
throw FetchError.tokenRejected
}
token = reread
(data, response) = try await send(request(token: token), deps: deps)
credential = reread
(data, response) = try await send(request(credential), deps: deps)
}
if response.statusCode == 429 {
throw FetchError.rateLimited(retryAt: recordUsageRateLimit(
@ -363,11 +421,26 @@ enum CopilotSubscriptionService {
guard (200..<300).contains(response.statusCode) else {
throw FetchError.usageHTTPError(response.statusCode)
}
return try decodeUsage(data: data, now: deps.now())
return try decodeUsage(
data: data,
now: deps.now(),
apiHost: apiHost(for: credential))
}
private static func request(token: String) -> URLRequest {
var request = URLRequest(url: usageURL)
/// The API host a credential is queried on. Throws rather than falling
/// back to dotcom, so an enterprise token is never sent to api.github.com.
private static func apiHost(for credential: CopilotCredential) throws -> String {
guard let host = CopilotHostEndpoint.apiHost(for: credential.host) else {
throw FetchError.unsupportedHost(credential.resolvedHost)
}
return host
}
private static func request(_ credential: CopilotCredential) throws -> URLRequest {
guard let url = CopilotHostEndpoint.usageURL(for: credential.host) else {
throw FetchError.unsupportedHost(credential.resolvedHost)
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.timeoutInterval = 30
// Headers mirror the VS Code Copilot Chat plugin.
@ -376,7 +449,7 @@ enum CopilotSubscriptionService {
request.setValue("copilot-chat/0.26.7", forHTTPHeaderField: "Editor-Plugin-Version")
request.setValue("GitHubCopilotChat/0.26.7", forHTTPHeaderField: "User-Agent")
request.setValue("2025-04-01", forHTTPHeaderField: "X-Github-Api-Version")
request.setValue("token \(token)", forHTTPHeaderField: "Authorization")
request.setValue("token \(credential.token)", forHTTPHeaderField: "Authorization")
return request
}
@ -386,13 +459,19 @@ enum CopilotSubscriptionService {
} catch let error as FetchError {
throw error
} catch {
throw FetchError.network(error)
// Name the host: on an unreachable enterprise tenant this is the
// only place the user learns which endpoint was tried.
throw FetchError.network(error, host: request.url?.host ?? CopilotHostEndpoint.defaultAPIHost)
}
}
// MARK: - Decode (internal so tests can drive fixtures)
static func decodeUsage(data: Data, now: Date = Date()) throws -> CopilotUsage {
static func decodeUsage(
data: Data,
now: Date = Date(),
apiHost: String = CopilotHostEndpoint.defaultAPIHost
) throws -> CopilotUsage {
let parsed: Any
do {
parsed = try JSONSerialization.jsonObject(with: data)
@ -410,7 +489,8 @@ enum CopilotSubscriptionService {
return CopilotUsage(
details: [premium, chat].compactMap { $0 },
plan: planLabel(root["copilot_plan"] ?? root["copilotPlan"]),
fetchedAt: now
fetchedAt: now,
apiHost: apiHost
)
}

View file

@ -1380,7 +1380,7 @@ private struct CopilotSettingsTab: View {
}
CopilotTokenSection()
Section {
Text("Copilot live-quota tracking reads a GitHub token that is already on this Mac, read-only. Nothing is copied or stored. CodeBurn looks at the editor plugin files in `~/.config/github-copilot`, the Copilot CLI's `~/.copilot` files, the COPILOT_GITHUB_TOKEN, GH_TOKEN and GITHUB_TOKEN variables, `gh auth token`, and finally a token you paste below. Usage tracking works without any of this; only the live quota bars need a token.")
Text("Copilot live-quota tracking reads a GitHub token that is already on this Mac, read-only. Nothing is copied or stored. CodeBurn looks at the editor plugin files in `~/.config/github-copilot`, the Copilot CLI's `~/.copilot` files, the COPILOT_GITHUB_TOKEN, GH_TOKEN and GITHUB_TOKEN variables, `gh auth token`, and finally a token you paste below. Usage tracking works without any of this; only the live quota bars need a token. A credential found for a GitHub Enterprise Cloud host is queried on that tenant's own API (api.<tenant>.ghe.com) and never sent to api.github.com.")
.font(.system(size: 11))
.foregroundStyle(.secondary)
} header: {
@ -1506,10 +1506,10 @@ private struct CopilotConnectionRow: View {
private var stateDetail: String {
switch store.copilotLoadState {
case .loaded:
if let plan = store.copilotUsage?.plan {
return "Plan: \(plan)"
}
return "Live quota tracked from api.github.com."
return CopilotQuotaPresentation.connectedSettingsDetail(
plan: store.copilotUsage?.plan,
apiHost: store.copilotUsage?.apiHost ?? CopilotHostEndpoint.defaultAPIHost
)
case .terminalFailure:
return "Sign in again with the Copilot CLI, an editor's Copilot plugin, or gh auth login, then click Reconnect."
case .transientFailure: return store.copilotError ?? "GitHub rate-limited; auto-retrying."

View file

@ -0,0 +1,84 @@
import XCTest
@testable import CodeBurnMenubar
/// Pure endpoint derivation and host selection for Copilot credentials
/// (issue #1286): dotcom keeps `api.github.com`, a GitHub Enterprise Cloud
/// tenant gets `api.<tenant>.ghe.com`, and anything else is refused rather
/// than sent to dotcom.
final class CopilotHostEndpointTests: XCTestCase {
// MARK: - Endpoint derivation
func testDotcomAndHostlessSourcesUseTheDotcomAPIHost() {
XCTAssertEqual(CopilotHostEndpoint.apiHost(for: "github.com"), "api.github.com")
// apps.json keyed by app name, env vars, gh and the pasted token carry
// no host at all.
XCTAssertEqual(CopilotHostEndpoint.apiHost(for: nil), "api.github.com")
XCTAssertEqual(CopilotHostEndpoint.apiHost(for: " "), "api.github.com")
XCTAssertEqual(
CopilotHostEndpoint.usageURL(for: "github.com")?.absoluteString,
"https://api.github.com/copilot_internal/user")
XCTAssertEqual(
CopilotHostEndpoint.usageURL(for: nil)?.absoluteString,
"https://api.github.com/copilot_internal/user")
}
func testEnterpriseCloudTenantUsesItsOwnAPIHost() {
XCTAssertEqual(CopilotHostEndpoint.apiHost(for: "acme.ghe.com"), "api.acme.ghe.com")
XCTAssertEqual(
CopilotHostEndpoint.usageURL(for: "acme.ghe.com")?.absoluteString,
"https://api.acme.ghe.com/copilot_internal/user")
// A host already written as the API host is not double-prefixed.
XCTAssertEqual(CopilotHostEndpoint.apiHost(for: "api.acme.ghe.com"), "api.acme.ghe.com")
}
func testHostSpellingsFromDifferentClientsNormalizeToTheSameEndpoint() {
for spelling in ["ACME.ghe.com", " acme.ghe.com ", "https://acme.ghe.com", "https://acme.ghe.com/", "acme.ghe.com:443"] {
XCTAssertEqual(
CopilotHostEndpoint.apiHost(for: spelling), "api.acme.ghe.com",
"unexpected endpoint for \(spelling)")
}
}
/// A self-hosted GitHub Enterprise Server install is not addressed by this
/// build, and guessing dotcom would send an enterprise credential to the
/// wrong endpoint.
func testUnknownHostHasNoDerivableEndpoint() {
XCTAssertNil(CopilotHostEndpoint.apiHost(for: "github.acme-corp.net"))
XCTAssertNil(CopilotHostEndpoint.usageURL(for: "github.acme-corp.net"))
// A bare suffix is not a tenant.
XCTAssertNil(CopilotHostEndpoint.apiHost(for: "ghe.com"))
}
// MARK: - Host selection
func testNoHostsSelectsNothing() {
XCTAssertNil(CopilotHostEndpoint.preferredHost(among: []))
}
func testASingleHostIsUsedAsIsIncludingAnUnsupportedOne() {
XCTAssertEqual(CopilotHostEndpoint.preferredHost(among: ["acme.ghe.com"]), "acme.ghe.com")
XCTAssertEqual(CopilotHostEndpoint.preferredHost(among: ["github.com"]), "github.com")
XCTAssertEqual(CopilotHostEndpoint.preferredHost(among: [nil]), "github.com")
// Reported as-is so the failure can name the host the user signed in to.
XCTAssertEqual(
CopilotHostEndpoint.preferredHost(among: ["github.acme-corp.net"]), "github.acme-corp.net")
}
func testDotcomWinsWhenSeveralHostsArePresent() {
XCTAssertEqual(
CopilotHostEndpoint.preferredHost(among: ["acme.ghe.com", "github.com"]), "github.com")
XCTAssertEqual(
CopilotHostEndpoint.preferredHost(among: ["acme.ghe.com", nil]), "github.com")
}
func testSeveralEnterpriseHostsPickTheFirstInSortedOrderForAStablePick() {
XCTAssertEqual(
CopilotHostEndpoint.preferredHost(among: ["zeta.ghe.com", "acme.ghe.com"]), "acme.ghe.com")
XCTAssertEqual(
CopilotHostEndpoint.preferredHost(among: ["acme.ghe.com", "zeta.ghe.com"]), "acme.ghe.com")
// An enterprise-cloud tenant beats a host we cannot address at all.
XCTAssertEqual(
CopilotHostEndpoint.preferredHost(among: ["github.acme-corp.net", "acme.ghe.com"]), "acme.ghe.com")
}
}

View file

@ -96,6 +96,19 @@ struct CopilotQuotaPresentationTests {
#expect(Presentation.settingsNotConnectedDetail(explicitlyDisconnected: false) == Presentation.noCredentialsSettingsDetail)
}
@Test("the connected detail names the host that answered")
func connectedDetailNamesTheHost() {
#expect(
Presentation.connectedSettingsDetail(plan: "Enterprise", apiHost: "api.acme.ghe.com")
== "Plan: Enterprise. Live quota tracked from api.acme.ghe.com.")
#expect(
Presentation.connectedSettingsDetail(plan: nil, apiHost: "api.github.com")
== "Live quota tracked from api.github.com.")
#expect(
Presentation.connectedSettingsDetail(plan: "Pro", apiHost: "")
== "Plan: Pro. Live quota tracked from api.github.com.")
}
@Test("a fresh snapshot is not stamped stale")
func freshSnapshotIsNotStale() {
let now = Date()

View file

@ -194,6 +194,7 @@ final class CopilotQuotaTests: XCTestCase {
}
let usage = try await CopilotSubscriptionService.refresh(deps: deps)
XCTAssertEqual(usage.plan, "Individual")
XCTAssertEqual(usage.apiHost, "api.github.com")
XCTAssertEqual(recorder.requests.count, 1)
let request = recorder.requests[0]
XCTAssertEqual(request.url?.absoluteString, "https://api.github.com/copilot_internal/user")
@ -535,4 +536,162 @@ final class CopilotQuotaTests: XCTestCase {
XCTFail("unexpected error: \(error)")
}
}
// MARK: - Enterprise Cloud hosts (issue #1286)
/// A data-residency enterprise signs in on `<tenant>.ghe.com`, whose API
/// host is the only one that honours the token.
func testTenantHostIsQueriedOnItsOwnAPIHost() async throws {
let recorder = RequestRecorder()
let deps = Self.makeDeps(
hosts: #"{"acme.ghe.com":{"user":"octocat","oauth_token":"gho_tenant"}}"#,
recorder: recorder
) { request in
Self.okJson(request, Self.usageBody)
}
let usage = try await CopilotSubscriptionService.refresh(deps: deps)
XCTAssertEqual(usage.apiHost, "api.acme.ghe.com")
XCTAssertEqual(recorder.requests.count, 1)
XCTAssertEqual(
recorder.requests[0].url?.absoluteString,
"https://api.acme.ghe.com/copilot_internal/user")
XCTAssertEqual(
recorder.requests[0].value(forHTTPHeaderField: "Authorization"), "token gho_tenant")
}
/// With both hosts signed in, dotcom wins and the enterprise token is
/// never the one sent.
func testDotcomWinsOverATenantAndTheTenantTokenIsNotSent() async throws {
let recorder = RequestRecorder()
let deps = Self.makeDeps(
hosts: #"{"acme.ghe.com":{"oauth_token":"gho_tenant"},"github.com":{"oauth_token":"gho_dotcom"}}"#,
recorder: recorder
) { request in
Self.okJson(request, Self.usageBody)
}
let usage = try await CopilotSubscriptionService.refresh(deps: deps)
XCTAssertEqual(usage.apiHost, "api.github.com")
XCTAssertEqual(
recorder.requests[0].url?.absoluteString,
"https://api.github.com/copilot_internal/user")
XCTAssertEqual(authorization(recorder), "token gho_dotcom")
}
/// The token and the host must come from the same entry: a dotcom key with
/// no token must not lend its host to the tenant's credential.
func testTenantEntryWinsWhenTheDotcomEntryHasNoToken() async throws {
let recorder = RequestRecorder()
let deps = Self.makeDeps(
hosts: #"{"github.com":{"user":"octocat"},"acme.ghe.com":{"oauth_token":"gho_tenant"}}"#,
recorder: recorder
) { request in
Self.okJson(request, Self.usageBody)
}
_ = try await CopilotSubscriptionService.refresh(deps: deps)
XCTAssertEqual(
recorder.requests[0].url?.absoluteString,
"https://api.acme.ghe.com/copilot_internal/user")
XCTAssertEqual(authorization(recorder), "token gho_tenant")
}
/// apps.json keys are `<host>:<app id>` on the newer plugins, so the host
/// is carried there too.
func testAppsJsonHostPrefixedKeyIsQueriedOnTheTenantAPIHost() async throws {
let recorder = RequestRecorder()
let deps = Self.makeDeps(
hosts: "{}",
apps: #"{"acme.ghe.com:Iv1.b507a08c87ecfe98":{"oauth_token":"gho_apps-tenant"}}"#,
recorder: recorder
) { request in
Self.okJson(request, Self.usageBody)
}
_ = try await CopilotSubscriptionService.refresh(deps: deps)
XCTAssertEqual(
recorder.requests[0].url?.absoluteString,
"https://api.acme.ghe.com/copilot_internal/user")
XCTAssertEqual(authorization(recorder), "token gho_apps-tenant")
}
/// An app-name key carries no host, so dotcom stays the assumption.
func testAppsJsonAppNameKeyStillQueriesDotcom() async throws {
let recorder = RequestRecorder()
let deps = Self.makeDeps(
hosts: "{}",
apps: #"{"Visual Studio Code":{"oauth_token":"ghu_apps-token"}}"#,
recorder: recorder
) { request in
Self.okJson(request, Self.usageBody)
}
_ = try await CopilotSubscriptionService.refresh(deps: deps)
XCTAssertEqual(
recorder.requests[0].url?.absoluteString,
"https://api.github.com/copilot_internal/user")
}
/// A host this build cannot address (a self-hosted GHES install) fails
/// naming the host instead of sending the credential to dotcom.
func testUnsupportedHostFailsWithoutFetching() async {
let recorder = RequestRecorder()
let deps = Self.makeDeps(
hosts: #"{"github.acme-corp.net":{"oauth_token":"gho_ghes"}}"#,
recorder: recorder
) { request in
Self.okJson(request, Self.usageBody)
}
do {
_ = try await CopilotSubscriptionService.refresh(deps: deps)
XCTFail("expected unsupportedHost")
} catch let error as CopilotSubscriptionService.FetchError {
guard case let .unsupportedHost(host) = error else {
return XCTFail("expected unsupportedHost, got \(error)")
}
XCTAssertEqual(host, "github.acme-corp.net")
XCTAssertTrue(error.isTerminal)
XCTAssertEqual(
error.localizedDescription,
"Copilot quota is not available for the GitHub host github.acme-corp.net. "
+ "CodeBurn can read github.com and GitHub Enterprise Cloud (*.ghe.com) hosts.")
} catch {
XCTFail("unexpected error: \(error)")
}
XCTAssertTrue(recorder.requests.isEmpty)
}
/// An unreachable tenant is the case the issue reported as a bare
/// "Temporarily unavailable", so the message has to name the host tried.
func testUnreachableTenantHostNamesTheHostInTheError() async {
let recorder = RequestRecorder()
let deps = CopilotSubscriptionService.Deps(
fetch: { request in
recorder.record(request)
throw URLError(.cannotFindHost)
},
readFile: { url in
url.lastPathComponent == "hosts.json"
? #"{"acme.ghe.com":{"oauth_token":"gho_tenant"}}"#.data(using: .utf8)
: nil
},
hostsURL: URL(fileURLWithPath: "/tmp/codeburn-tests/.config/github-copilot/hosts.json"),
appsURL: URL(fileURLWithPath: "/tmp/codeburn-tests/.config/github-copilot/apps.json"),
copilotDirURL: URL(fileURLWithPath: "/tmp/codeburn-tests/.copilot"),
environment: { _ in nil },
ghAuthToken: { nil },
savedToken: { nil },
now: { Self.now }
)
do {
_ = try await CopilotSubscriptionService.refresh(deps: deps)
XCTFail("expected network error")
} catch let error as CopilotSubscriptionService.FetchError {
guard case let .network(_, host) = error else {
return XCTFail("expected network, got \(error)")
}
XCTAssertEqual(host, "api.acme.ghe.com")
XCTAssertTrue(error.localizedDescription.contains("api.acme.ghe.com"))
XCTAssertFalse(error.isTerminal)
} catch {
XCTFail("unexpected error: \(error)")
}
XCTAssertEqual(recorder.requests.count, 1)
}
}

View file

@ -6,6 +6,13 @@
// API that may drift without notice; every failure must degrade to the
// normal connection states and never crash the panel.
//
// The host is not fixed. GitHub Enterprise Cloud with data residency puts an
// enterprise on its own hostname (`<tenant>.ghe.com`) whose API lives at
// `api.<tenant>.ghe.com`, so the credential carries the host it was read from
// and the endpoint follows it. A host we cannot derive an endpoint for fails
// naming that host rather than sending the credential to api.github.com
// (#1286). Self-hosted GitHub Enterprise Server is out of scope.
//
// Credential: the GitHub OAuth token already on disk from a signed-in Copilot
// plugin - hosts.json (keyed by host) falling back to apps.json (keyed by app
// name). The plugins write those under ~/.config/github-copilot on macOS and
@ -17,7 +24,10 @@ import path from 'node:path'
import { fraction, quotaRequestSignal, readSecureFile, sanitizeError } from './security.js'
import type { QuotaProvider, QuotaWindow } from './types.js'
const USAGE_ENDPOINT = 'https://api.github.com/copilot_internal/user'
export const COPILOT_DEFAULT_HOST = 'github.com'
export const COPILOT_DEFAULT_API_HOST = 'api.github.com'
const ENTERPRISE_CLOUD_SUFFIX = '.ghe.com'
const USAGE_PATH = '/copilot_internal/user'
const HEADERS = {
Accept: 'application/json',
'Editor-Version': 'vscode/1.96.2',
@ -28,8 +38,76 @@ const HEADERS = {
type HostRecord = Record<string, any> & { oauth_token?: unknown }
/** A discovered token together with the GitHub host it belongs to. */
type CopilotCredential = { token: string; host: string | null }
/**
* Bare lowercased hostname: tolerates whitespace, a scheme, a path, userinfo
* and a port, because these keys are written by several different clients.
*/
export function normalizeCopilotHost(raw: string | null | undefined): string | null {
let host = (raw ?? '').trim().toLowerCase()
if (!host) return null
const scheme = host.indexOf('://')
if (scheme !== -1) host = host.slice(scheme + 3)
host = host.split('/')[0] ?? ''
const at = host.lastIndexOf('@')
if (at !== -1) host = host.slice(at + 1)
host = host.split(':')[0] ?? ''
return host || null
}
/**
* API host serving Copilot quota for a credential's GitHub host, or null when
* this build cannot address it. A null `host` means the source carried none,
* which is dotcom.
*/
export function copilotAPIHost(host: string | null | undefined): string | null {
const normalized = normalizeCopilotHost(host)
if (!normalized) return COPILOT_DEFAULT_API_HOST
if (normalized === COPILOT_DEFAULT_HOST || normalized === COPILOT_DEFAULT_API_HOST) return COPILOT_DEFAULT_API_HOST
if (normalized.endsWith(ENTERPRISE_CLOUD_SUFFIX) && normalized.length > ENTERPRISE_CLOUD_SUFFIX.length) {
return normalized.startsWith('api.') ? normalized : `api.${normalized}`
}
return null
}
export function copilotUsageEndpoint(host: string | null | undefined): string | null {
const apiHost = copilotAPIHost(host)
return apiHost ? `https://${apiHost}${USAGE_PATH}` : null
}
/**
* Picks the host to query out of the hosts a credential file lists; a null
* entry stands for a source with no host of its own, i.e. dotcom. One entry is
* unambiguous and is used as-is even when unsupported, so the failure can name
* the host the user signed in to. With several, dotcom wins (what every
* non-enterprise client writes), else the first `.ghe.com` tenant in sorted
* order, so the pick is stable from one read to the next.
*/
export function preferredCopilotHost(hosts: (string | null)[]): string | null {
const normalized = hosts.map(host => normalizeCopilotHost(host) ?? COPILOT_DEFAULT_HOST)
if (normalized.length === 0) return null
if (normalized.length === 1) return normalized[0] ?? null
if (normalized.includes(COPILOT_DEFAULT_HOST)) return COPILOT_DEFAULT_HOST
const enterprise = normalized.filter(host => host.endsWith(ENTERPRISE_CLOUD_SUFFIX)).sort()
return enterprise[0] ?? normalized.slice().sort()[0] ?? null
}
const CREDENTIAL_FILES = ['hosts.json', 'apps.json'] as const
/**
* apps.json keys look like `github.com:Iv1.<app id>` on the newer plugins and
* like a bare app name ("Visual Studio Code") on the older ones; only the
* former carries a host.
*/
function hostFromAppsKey(key: string): string | null {
const separator = key.indexOf(':')
if (separator === -1) return null
const candidate = key.slice(0, separator)
return candidate.includes('.') ? candidate : null
}
export type CopilotDeps = {
fetch: typeof fetch
/** Ordered credential directories; the first file that yields a token wins. */
@ -61,27 +139,38 @@ function defaultDeps(): CopilotDeps {
return { fetch: globalThis.fetch, configDirs: copilotConfigDirs(), readFile: readSecureFile }
}
function empty(connection: QuotaProvider['connection']): QuotaProvider {
return { provider: 'copilot', connection, primary: null, details: [], planLabel: null, footerLines: [] }
function empty(connection: QuotaProvider['connection'], footerLines: string[] = []): QuotaProvider {
return { provider: 'copilot', connection, primary: null, details: [], planLabel: null, footerLines }
}
function tokenFromMap(raw: string): string | null {
/**
* Reads the `oauth_token` entries out of a credential map and picks one with
* `preferredCopilotHost`, so the token sent and the host it is sent to always
* come from the same entry. Keys are visited in sorted order, making the pick
* stable when several entries qualify.
*/
function credentialFromMap(raw: string, hostForKey: (key: string) => string | null): CopilotCredential | null {
const map = JSON.parse(raw) as Record<string, HostRecord>
// hosts.json keys by host - prefer github.com; apps.json has no canonical
// key, so its first entry wins. Both store the token as `oauth_token`.
const preferred = map['github.com'] ?? Object.values(map)[0]
const token = preferred?.oauth_token
return typeof token === 'string' && token ? token : null
const entries: CopilotCredential[] = []
for (const key of Object.keys(map).sort()) {
const token = map[key]?.oauth_token
if (typeof token !== 'string' || !token) continue
entries.push({ token, host: hostForKey(key) })
}
const preferred = preferredCopilotHost(entries.map(entry => entry.host))
if (!preferred) return null
return entries.find(entry => (normalizeCopilotHost(entry.host) ?? COPILOT_DEFAULT_HOST) === preferred) ?? null
}
async function credentialFromFiles(deps: CopilotDeps): Promise<string | null> {
async function credentialFromFiles(deps: CopilotDeps): Promise<CopilotCredential | null> {
for (const dir of deps.configDirs) {
for (const name of CREDENTIAL_FILES) {
try {
const raw = await deps.readFile(path.join(dir, name), 64 * 1024)
if (!raw) continue
const token = tokenFromMap(raw)
if (token) return token
// hosts.json is keyed by GitHub host, apps.json by "<host>:<app id>".
const credential = credentialFromMap(raw, name === 'hosts.json' ? key => key : hostFromAppsKey)
if (credential) return credential
} catch {
// A malformed or unreadable file falls through to the next candidate.
}
@ -114,7 +203,7 @@ function planLabel(value: unknown): string | null {
return known[lower] ?? lower.replace(/(^|[_-])\w/g, match => match.replace(/[_-]/, ' ').toUpperCase())
}
export function decodeCopilotUsage(body: unknown): QuotaProvider {
export function decodeCopilotUsage(body: unknown, apiHost: string = COPILOT_DEFAULT_API_HOST): QuotaProvider {
const data = body && typeof body === 'object' ? body as Record<string, any> : {}
// Field names have shipped both camelCase and snake_case; read each alias
// rather than trusting one spelling.
@ -126,14 +215,21 @@ export function decodeCopilotUsage(body: unknown): QuotaProvider {
provider: 'copilot', connection: 'connected', primary: premium ?? chat,
details,
planLabel: planLabel(data.copilot_plan ?? data.copilotPlan),
footerLines: [],
// Name the endpoint when it is not dotcom, so an enterprise tenant can see
// which host answered.
footerLines: apiHost === COPILOT_DEFAULT_API_HOST ? [] : [`Source: ${apiHost}`],
}
}
async function request(token: string, deps: CopilotDeps, parent?: AbortSignal): Promise<Response> {
return deps.fetch(USAGE_ENDPOINT, {
function unsupportedHostFooter(host: string): string {
return `Copilot quota is not available for the GitHub host ${host}. `
+ 'CodeBurn can read github.com and GitHub Enterprise Cloud (*.ghe.com) hosts.'
}
async function request(credential: CopilotCredential, endpoint: string, deps: CopilotDeps, parent?: AbortSignal): Promise<Response> {
return deps.fetch(endpoint, {
method: 'GET', signal: quotaRequestSignal(parent),
headers: { ...HEADERS, Authorization: `token ${token}` },
headers: { ...HEADERS, Authorization: `token ${credential.token}` },
})
}
@ -141,28 +237,52 @@ export type CopilotResult = { quota: QuotaProvider; retryAfterSeconds?: number }
export async function fetchCopilotQuota(options: Partial<CopilotDeps> & { signal?: AbortSignal } = {}): Promise<CopilotResult> {
const deps = { ...defaultDeps(), ...options }
// Named in the failure footers so an unreachable enterprise tenant says which
// host was tried instead of a bare "Temporarily unavailable".
let apiHost = COPILOT_DEFAULT_API_HOST
try {
let token = await credentialFromFiles(deps)
if (!token) return { quota: empty('disconnected') }
let credential = await credentialFromFiles(deps)
if (!credential) return { quota: empty('disconnected') }
const endpointFor = (value: CopilotCredential): string | null => copilotUsageEndpoint(value.host)
let endpoint = endpointFor(credential)
if (!endpoint) {
// Never retried against api.github.com: that endpoint does not honour an
// enterprise credential and must not receive it.
return { quota: empty('terminalFailure', [unsupportedHostFooter(normalizeCopilotHost(credential.host) ?? COPILOT_DEFAULT_HOST)]) }
}
apiHost = copilotAPIHost(credential.host) ?? apiHost
let response = await request(token, deps, options.signal)
let response = await request(credential, endpoint, deps, options.signal)
if (response.status === 401) {
// An active editor session rotates this token; re-read once before
// giving up so we don't report a failure the disk already fixed.
const reread = await credentialFromFiles(deps)
if (!reread || reread === token) return { quota: empty('transientFailure') }
token = reread
response = await request(token, deps, options.signal)
if (!reread || reread.token === credential.token) return { quota: empty('transientFailure') }
credential = reread
const rereadEndpoint = endpointFor(credential)
if (!rereadEndpoint) {
return { quota: empty('terminalFailure', [unsupportedHostFooter(normalizeCopilotHost(credential.host) ?? COPILOT_DEFAULT_HOST)]) }
}
endpoint = rereadEndpoint
apiHost = copilotAPIHost(credential.host) ?? apiHost
response = await request(credential, endpoint, deps, options.signal)
}
if (response.status === 429) {
const raw = response.headers.get('Retry-After')
const seconds = raw === null ? NaN : Number(raw)
return { quota: empty('transientFailure'), retryAfterSeconds: Math.max(Number.isFinite(seconds) ? Math.ceil(seconds) : 300, 60) }
}
if (!response.ok) return { quota: empty(response.status >= 400 && response.status < 500 ? 'terminalFailure' : 'transientFailure') }
return { quota: decodeCopilotUsage(await response.json()) }
if (!response.ok) {
return {
quota: empty(
response.status >= 400 && response.status < 500 ? 'terminalFailure' : 'transientFailure',
[`Copilot quota fetch failed (HTTP ${response.status}) at ${apiHost}.`],
),
}
}
return { quota: decodeCopilotUsage(await response.json(), apiHost) }
} catch (error) {
console.warn(`Copilot quota unavailable: ${sanitizeError(error)}`)
return { quota: empty('transientFailure') }
console.warn(`Copilot quota unavailable from ${apiHost}: ${sanitizeError(error)}`)
return { quota: empty('transientFailure', [`Could not reach ${apiHost}: ${sanitizeError(error)}`]) }
}
}

View file

@ -0,0 +1,158 @@
// Copilot on GitHub Enterprise Cloud with data residency (issue #1286). The
// adapter hardcoded api.github.com and dropped the host its credential came
// from, so a `<tenant>.ghe.com` enterprise could only ever report
// "Temporarily unavailable". These tests pin endpoint derivation, which host is
// picked out of a credential file, and that a host we cannot address fails
// naming that host instead of sending the credential to dotcom.
import { describe, expect, it } from 'vitest'
import {
copilotAPIHost,
copilotUsageEndpoint,
fetchCopilotQuota,
normalizeCopilotHost,
preferredCopilotHost,
} from '../src/quota/copilot.js'
const usageBody = JSON.stringify({
copilot_plan: 'enterprise',
quota_snapshots: { premium_interactions: { entitlement: 300, percent_remaining: 70 } },
})
function usageResponse(): Response {
return new Response(usageBody, { status: 200, headers: { 'Content-Type': 'application/json' } })
}
/** Serves one credential file and records every URL the adapter requests. */
function deps(files: Record<string, string>, respond: (url: string) => Response | Promise<Response> = () => usageResponse()) {
const urls: string[] = []
const tokens: (string | null)[] = []
return {
urls,
tokens,
options: {
configDirs: ['/copilot'],
readFile: async (filePath: string) => {
const name = filePath.split('/').pop() ?? ''
return files[name] ?? null
},
fetch: (async (url: string, init?: RequestInit) => {
urls.push(String(url))
const headers = new Headers(init?.headers)
tokens.push(headers.get('Authorization'))
return respond(String(url))
}) as unknown as typeof fetch,
},
}
}
describe('Copilot quota endpoint derivation', () => {
it('keeps api.github.com for dotcom and for sources with no host', () => {
expect(copilotAPIHost('github.com')).toBe('api.github.com')
expect(copilotAPIHost(null)).toBe('api.github.com')
expect(copilotUsageEndpoint('github.com')).toBe('https://api.github.com/copilot_internal/user')
expect(copilotUsageEndpoint(null)).toBe('https://api.github.com/copilot_internal/user')
})
it('derives the tenant API host for an Enterprise Cloud host', () => {
expect(copilotAPIHost('acme.ghe.com')).toBe('api.acme.ghe.com')
expect(copilotUsageEndpoint('acme.ghe.com')).toBe('https://api.acme.ghe.com/copilot_internal/user')
expect(copilotAPIHost('api.acme.ghe.com')).toBe('api.acme.ghe.com')
})
it('normalizes the spellings different clients write', () => {
for (const spelling of ['ACME.ghe.com', ' acme.ghe.com ', 'https://acme.ghe.com', 'https://acme.ghe.com/', 'acme.ghe.com:443']) {
expect(copilotAPIHost(spelling)).toBe('api.acme.ghe.com')
}
expect(normalizeCopilotHost(' ')).toBeNull()
})
it('refuses to derive an endpoint for an unknown host', () => {
expect(copilotAPIHost('github.acme-corp.net')).toBeNull()
expect(copilotUsageEndpoint('github.acme-corp.net')).toBeNull()
// A bare suffix is not a tenant.
expect(copilotAPIHost('ghe.com')).toBeNull()
})
})
describe('Copilot host selection', () => {
it('selects nothing when the file lists no usable entry', () => {
expect(preferredCopilotHost([])).toBeNull()
})
it('uses a single host as-is, including one it cannot address', () => {
expect(preferredCopilotHost(['acme.ghe.com'])).toBe('acme.ghe.com')
expect(preferredCopilotHost([null])).toBe('github.com')
expect(preferredCopilotHost(['github.acme-corp.net'])).toBe('github.acme-corp.net')
})
it('prefers dotcom when several hosts are signed in, else the first tenant in sorted order', () => {
expect(preferredCopilotHost(['acme.ghe.com', 'github.com'])).toBe('github.com')
expect(preferredCopilotHost(['acme.ghe.com', null])).toBe('github.com')
expect(preferredCopilotHost(['zeta.ghe.com', 'acme.ghe.com'])).toBe('acme.ghe.com')
expect(preferredCopilotHost(['github.acme-corp.net', 'acme.ghe.com'])).toBe('acme.ghe.com')
})
})
describe('Copilot quota on an Enterprise Cloud host', () => {
it('queries the tenant API host with the tenant token', async () => {
const harness = deps({ 'hosts.json': JSON.stringify({ 'acme.ghe.com': { oauth_token: 'gho_tenant' } }) })
const result = await fetchCopilotQuota(harness.options)
expect(harness.urls).toEqual(['https://api.acme.ghe.com/copilot_internal/user'])
expect(harness.tokens).toEqual(['token gho_tenant'])
expect(result.quota.connection).toBe('connected')
expect(result.quota.planLabel).toBe('Enterprise')
expect(result.quota.footerLines).toEqual(['Source: api.acme.ghe.com'])
})
it('prefers dotcom when both hosts are present and never sends the tenant token to it', async () => {
const harness = deps({
'hosts.json': JSON.stringify({
'acme.ghe.com': { oauth_token: 'gho_tenant' },
'github.com': { oauth_token: 'gho_dotcom' },
}),
})
await fetchCopilotQuota(harness.options)
expect(harness.urls).toEqual(['https://api.github.com/copilot_internal/user'])
expect(harness.tokens).toEqual(['token gho_dotcom'])
})
it('keeps the token and the host from the same entry', async () => {
const harness = deps({
'hosts.json': JSON.stringify({
'github.com': { user: 'octocat' },
'acme.ghe.com': { oauth_token: 'gho_tenant' },
}),
})
await fetchCopilotQuota(harness.options)
expect(harness.urls).toEqual(['https://api.acme.ghe.com/copilot_internal/user'])
expect(harness.tokens).toEqual(['token gho_tenant'])
})
it('reads the host out of an apps.json key and assumes dotcom for a bare app name', async () => {
const tenant = deps({ 'apps.json': JSON.stringify({ 'acme.ghe.com:Iv1.b507a08c87ecfe98': { oauth_token: 'gho_apps' } }) })
await fetchCopilotQuota(tenant.options)
expect(tenant.urls).toEqual(['https://api.acme.ghe.com/copilot_internal/user'])
const appName = deps({ 'apps.json': JSON.stringify({ 'Visual Studio Code': { oauth_token: 'ghu_apps' } }) })
await fetchCopilotQuota(appName.options)
expect(appName.urls).toEqual(['https://api.github.com/copilot_internal/user'])
})
it('fails naming the host when no endpoint can be derived, without any request', async () => {
const harness = deps({ 'hosts.json': JSON.stringify({ 'github.acme-corp.net': { oauth_token: 'gho_ghes' } }) })
const result = await fetchCopilotQuota(harness.options)
expect(harness.urls).toEqual([])
expect(result.quota.connection).toBe('terminalFailure')
expect(result.quota.footerLines[0]).toContain('github.acme-corp.net')
})
it('names the tenant host when it is unreachable', async () => {
const harness = deps({ 'hosts.json': JSON.stringify({ 'acme.ghe.com': { oauth_token: 'gho_tenant' } }) }, () => {
throw new TypeError('fetch failed')
})
const result = await fetchCopilotQuota(harness.options)
expect(result.quota.connection).toBe('transientFailure')
expect(result.quota.footerLines[0]).toContain('api.acme.ghe.com')
})
})