Skip to content

feat(runetime-host): sign in to GitHub Copilot with a device grant - #3390

Open
hgaol wants to merge 14 commits into
apache:mainfrom
hgaol:feat/github-copilot-device-flow-login
Open

feat(runetime-host): sign in to GitHub Copilot with a device grant#3390
hgaol wants to merge 14 commits into
apache:mainfrom
hgaol:feat/github-copilot-device-flow-login

Conversation

@hgaol

@hgaol hgaol commented Aug 21, 2026

Copy link
Copy Markdown
Member

Summary

GitHub Copilot was the only registered oauth_token provider without a real sign-in path. Desktop only scavenged an existing COPILOT_GITHUB_TOKEN / GH_TOKEN / GITHUB_TOKEN / gh auth token, rejected classic PATs, and left users minting a fine-grained PAT with Copilot Requests.

This PR adds the missing RFC 8628 device-flow enrollment in @maka/runtime and wires it into Desktop Settings:

  • beginDeviceLogin requests a user code; completeDeviceLogin polls until authorized
  • pending grant state lives in the main process so closing Settings cannot strand a poll holding a credential
  • both device login and existing-gh import adopt an account only after it proves usable models
  • verification code is shown as text (no general openExternal bridge); enrollment pins the URL to github.com and requests only read:user

Importing an existing gh credential remains available as a secondary action.

Fixes #3388

Verification

  • Branch commits include unit coverage for device enrollment and subscription-service device login
  • Manual Desktop check: Settings → Connect GitHub Copilot shows Sign in with GitHub and reports GitHub Copilot subscription account imported after a successful connection (screenshot below)
  • Full local lint / typecheck / workspace suites not re-run in this PR-opening step; rely on CI test check

Screenshot

Sign in

image

Successful GitHub Copilot subscription account import after sign-in:

image

AI use

Select exactly one:

  • No generative tool made a substantive contribution
  • Generative tooling made a substantive contribution

Tool(s) and scope: GitHub Copilot CLI assisted implementation of the device-flow enrollment, Desktop wiring, tests, commit message cleanup, and PR creation. The human contributor remains responsible for the change.

Checklist

  • Tests cover the change and fail without it
  • Lint, format, typecheck and the affected suites pass locally

Does this PR entail a change in behavior?

  • Yes — described under Summary above
  • No

@hgaol
hgaol force-pushed the feat/github-copilot-device-flow-login branch from c7d8924 to 4f133f1 Compare August 21, 2026 08:24

@Astro-Han Astro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for filling the sign-in gap described in #3388. The problem is valid, and the provider-specific RFC 8628 parsing is carefully bounded.

The remaining implementation concern is ownership: the device flow currently runs beside the existing Host OAuth authority, duplicating the attempt lifecycle and producing the race described inline. The simpler final state appears to be extending the existing Host OAuth provider slice for GitHub Copilot and keeping Desktop responsible only for importing credentials that genuinely depend on the local gh installation.

I also left two independent inline notes about token lifetime and the OAuth app identity.

AI-assisted review disclosure: Codex performed exact-head analysis, with independent reviewer passes focused on lifecycle, concurrency, security, compatibility, and OAuth protocol behavior. The findings were revalidated against head 4f133f11b4442ed572379da7040965e9a7699ca2.

Comment thread apps/desktop/src/main/runtime-host-github-copilot-ipc-main.ts Outdated
Comment thread packages/runtime/src/github-copilot-oauth-enrollment.ts Outdated
Comment thread packages/runtime/src/oauth-provider-contracts.ts
@Astro-Han

Copy link
Copy Markdown
Contributor

PR need a rebase before reviewing and approve~

hgaol and others added 4 commits August 21, 2026 20:37
GitHub Copilot is the only registered oauth_token provider without a login
flow. Desktop's connectExistingLogin only scavenges COPILOT_GITHUB_TOKEN,
GH_TOKEN, GITHUB_TOKEN, or `gh auth token`, rejects classic PATs, and needs a
credential carrying Copilot Requests — a permission plain `gh auth login` does
not grant. Users who own a subscription still have no way to sign in.

Add the RFC 8628 device authorization the provider was missing, alongside the
existing Codex and xAI enrollments. The grant yields the same gho_/ghu_ account
token createGitHubCopilotAccountTokens already accepts, so only credential
acquisition changes. GitHub reports authorization_pending and slow_down as HTTP
200 with an error body rather than a 4xx, so the error code is read before the
success shape. The verification URL is pinned to github.com because the
presentation layer opens it in the user's browser, and only read:user is
requested, so the grant cannot reach a user's code.

The provider already sends the vscode Copilot Chat editor headers
(GITHUB_COPILOT_COMPAT_HEADERS), so the client id presented here does not
change who Maka claims to be.

Fixes apache#3388

Generated-by: GitHub Copilot CLI
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Wire the device enrollment into Settings so a user with a Copilot
subscription can sign in, instead of first minting a fine-grained PAT with
Copilot Requests or having a `gh` login that happens to carry it.

`beginDeviceLogin` asks GitHub for a code and `completeDeviceLogin` waits for
the user to authorize it; both ride the existing direct-account login flow, so
the pending guard, snapshot refresh, and unmount safety stay in one place. The
pending grant lives in the main process, so closing Settings cannot strand a
poll holding a credential, and the secret still reaches only the Host vault.

Importing an existing `gh` credential remains available as a secondary action
for machines that are already authenticated, and both paths now adopt an
account through one place that refuses to store a credential before the
account proves it returns usable models.

The verification code is shown as text rather than opening a browser: the
renderer has no general openExternal bridge by design, and the device URL is
pinned to github.com by the enrollment.

Fixes apache#3388

Generated-by: GitHub Copilot CLI
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Route the GitHub Copilot device grant through `HostOAuthCoordinator` instead
of a second login-session authority in Desktop. Assigning `deviceLogin` only
after the begin request resolved let a reopened Settings pane or a second IPC
client overlap attempts, so an older completion could replace the credential
a newer login had just committed.

`github-copilot` joins `OAUTH_LOGIN_PROVIDERS` and the interactive login
providers, and the coordinator grows a provider slice beside xAI and Codex.
It therefore inherits one serialized start, supersede and cancellation
boundaries, Host residency across polling, the configured network transport,
and an atomic credential commit. Desktop keeps only the local `gh` credential
import; account state, refresh, and sign-out ride the shared channels the
coordinator's adapter already registers. Older peers cannot start a login for
the new provider, so the compatibility epoch moves to 32.

Preserve the lifetime GitHub returns. Every account token was recorded with
`expires_at = Number.MAX_SAFE_INTEGER` while the refresh path returned it
unchanged, so an expiring token would stop working at expiry and require
another interactive login. The enrollment now carries `expires_in` and
`refresh_token` through, refresh performs the refresh grant for an expiring
record, and an expiring response with no refresh token is rejected rather
than stored. A token with no declared lifetime keeps the sentinel and still
refreshes without provider I/O.

Record the OAuth app identity. The client id is GitHub's Copilot editor app:
GitHub's consent screen names that application while Maka receives and stores
the credential, and no published authorization for third-party reuse exists.
Provenance, consent identity, and the open authorization question are written
down in `docs/github-copilot-oauth-identity.md`, and the device flow is now
opt-in behind `MAKA_GITHUB_COPILOT_DEVICE_LOGIN_EXPERIMENTAL=1` so it is not
a public sign-in path before that basis is established. Importing a
credential the user already holds stays the shipped route.

Generated-by: Claude Code
The device flow was gated opt-in, which left Settings with no sign-in button
at all — only the local credential import. Recording the app identity is what
the review asked for; hiding the feature was not.

Enrollment now ships on with a kill switch, the same shape Codex already uses:
`MAKA_GITHUB_COPILOT_DEVICE_LOGIN_EXPERIMENTAL=0` refuses the login at the
Host. The panel stops probing that flag and always renders the button, because
the Host owns the decision and answers a disabled start with
`experimental_disabled`; a second copy of the gate in the renderer could only
disagree with it.

The consent identity question in `docs/github-copilot-oauth-identity.md` is
unchanged and still open.

Generated-by: Claude Code
@hgaol
hgaol force-pushed the feat/github-copilot-device-flow-login branch from a3dcc93 to 16f6520 Compare August 21, 2026 12:57
@hgaol

hgaol commented Aug 21, 2026

Copy link
Copy Markdown
Member Author

PR need a rebase before reviewing and approve~

Rebased

…-device-flow-login

# Conflicts:
#	packages/runtime-host/src/__tests__/protocol.test.ts
#	packages/runtime-host/src/protocol/index.ts

@Astro-Han Astro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for moving GitHub Copilot enrollment onto HostOAuthCoordinator. That resolves the original lifecycle and stale-completion problem, and the token lifetime/refresh work is now on the right authority.

I do not think another round of isolated patches would be fair or productive here. Could we converge on one final state instead?

[P1] Shipping boundary: the current provenance document explicitly says the authorization basis for reusing the Copilot editor OAuth identity is not established, while the feature is enabled by default. Until GitHub provides a public compatibility basis or a Maka-authorized app identity, this path should be explicit opt-in/default-off. The kill switch can remain for operations, but it cannot serve as the authorization basis.

[P2] Account adoption: device authorization should not commit a credential until the GitHub provider slice has verified that the account exposes at least one usable Copilot model. That validation belongs before the coordinator’s atomic commit, using the same Host transport; the Desktop post-login synchronization should not be responsible for deciding whether authentication succeeded.

[P2] Refresh completion: GitHub Copilot should join the existing single-401 refresh/replay seam. An access token can be rejected before its local expires_at; in that case the Host should force-refresh once, CAS-persist the new generation, replace the Authorization header, and replay the request once.

I would also like this revision to remove the Desktop lifecycle that the Host path has replaced, rather than adding the fixes on top:

  • fold GitHubCopilotSubscriptionService into a stateless “import existing local credential” adapter;
  • remove the unused direct mode from useOAuthLoginFlow;
  • route connection-detail re-login through the shared Host OAuth service;
  • keep local gh import only as a secondary provider-catalog action;
  • remove the unused isExperimentalEnabled preload/IPC query surface.

The provider-specific device parsing and its security/error tests should stay. The goal is not to make OAuth artificially tiny; it is to leave one lifecycle authority and remove the roughly 280–330 lines whose responsibilities have moved to the Host.

A clean final shape would be: Runtime owns the GitHub device codec, Host owns attempt/entitlement/credential/refresh/401 recovery, and Desktop owns only generic presentation plus local credential discovery. Once that state is in one exact head with epoch 35 and focused entitlement/401 regressions, we can review the final result as a whole instead of asking for another sequence of patches.

AI-assisted review disclosure: OpenAI Codex coordinated independent exact-head reviews of the OAuth lifecycle, consent/provenance boundary, refresh behavior, and simplification opportunities. I reviewed and approved this final-state recommendation before posting.

hgaol added 3 commits August 21, 2026 16:30
Leave one lifecycle authority for Copilot accounts. The Host owns the attempt,
the entitlement check, the credential, refresh, and 401 recovery; Desktop keeps
only generic presentation and discovery of a credential this machine already
holds.

The device sign-in ships off. Maka has no published authorization for the editor
OAuth identity the grant presents, so an install opts in explicitly with
`MAKA_GITHUB_COPILOT_DEVICE_LOGIN_EXPERIMENTAL=1`. This reverses the default
chosen in "restore the GitHub Copilot sign-in in Settings": the flag records an
operator accepting the consent mismatch for their own install, and cannot stand
in for a basis that does not exist.

A GitHub account is not a Copilot subscription. The provider slice now asks the
Copilot API what the authorized account reaches, on the Host transport, before
the coordinator's atomic commit; an account with no usable model fails the
attempt as `provider_rejected` instead of committing a credential that breaks on
first use.

Copilot joins the single-401 refresh seam: force-refresh once, CAS-persist the
new generation, replace the Authorization header, replay once. A record GitHub
declared no lifetime for resolves to the token just rejected, so that replay is
skipped rather than spent.

Removed with their responsibilities: the Desktop subscription service, the
`direct` mode in the login controller, the Copilot-specific re-login notice, and
the unused `isExperimentalEnabled` query surface.

Generated-by: Claude Code
An account Connection is created before anyone can ask the account what it
has — the OAuth login path holds no credential at that point — so its enabled
ids start as the provider's curated fallback list. Discovery then ran without
ever reconciling them, leaving every guessed id selectable, testable, and
sendable on an account that may never have exposed it.

GitHub Copilot surfaced this: a connection test picks the first enabled id,
the Copilot models endpoint does not list it, and the test fails on a model
the user never chose while chat works on one they did. The local credential
import was unaffected because it passes discovered ids at creation, so only
the shared OAuth path carried the gap.

Once a fetched inventory exists it is the authority: ids it does not list are
dropped. Ids are never added, so a narrower selection the user made survives,
and the discovered list is adopted whole only when nothing they kept remains.

Generated-by: Claude Code
The notice opened the provider's device page and showed nothing else, but that
page accepts nothing until the user types the one-time code the Host received.
Re-login was therefore a button that led to a dead end.

The code now appears beside the notice's own copy, for the flows whose device
page asks for it. xAI is excluded because its verification URL carries the code
itself, which is why the catalog panel has always hidden it there too.

Generated-by: Claude Code
@hgaol hgaol changed the title feat(desktop): sign in to GitHub Copilot with a device grant feat(runetime-host): sign in to GitHub Copilot with a device grant Aug 22, 2026
hgaol added 2 commits August 22, 2026 02:13
…b-copilot-device-flow-login

# Conflicts:
#	packages/runtime-host/src/__tests__/protocol.test.ts
#	packages/runtime-host/src/protocol/index.ts
An account Connection is created before anyone can ask the account what it
has — the OAuth login path holds no credential at that point — so its enabled
ids start as the provider's curated fallback list, in the order this build
ships them. Taking the first of those as the account's initial default names a
model the account may never serve, which is how a GitHub Copilot connection
test came to fail on a model the user never chose while chat worked on one
they did.

The live response now picks the order that first default is chosen from. It
does not pick the set: an id the response omitted stays enabled, because a
`/models` answer that cannot see a model is not evidence the account cannot
run it — the rule `authorizeConnectionModel` states for every other reader of
an inventory.

This replaces the pruning added in "adopt the account's own model inventory
after sign-in", which reached the same connection test by treating the live
list as an allowlist over ids only the user writes.

Generated-by: Claude Code

@Astro-Han Astro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for taking this through to a much cleaner final state. I re-reviewed exact head b5da959.

The original blocking concerns are resolved: interactive enrollment now runs through HostOAuthCoordinator, Copilot entitlement is verified before the atomic credential commit, rejected access tokens join the shared single-401 refresh/replay path, and the borrowed OAuth identity is default-off behind an explicit per-install opt-in. The replaced Desktop lifecycle, direct login mode, special re-login path, and unused experimental query have also been removed.

I left two non-blocking P2 comments. They are two remaining expressions of the same ownership invariant: the selected Runtime Host should be the sole authority for enrollment availability, entitlement, and credential adoption, while Desktop should only present the flow and discover credentials that exist locally. Neither warrants another broad redesign or a Request Changes review; the clean final direction is described inline.

The provenance document correctly states that authorization to reuse the editor OAuth identity has not been established. This approval accepts the dormant, explicit-opt-in shipping boundary for this change; it does not assert or create authorization from GitHub.

AI-assisted review disclosure: OpenAI Codex coordinated independent exact-head reviews of OAuth lifecycle and concurrency, identity and shipping boundaries, refresh behavior, remote-Host compatibility, and simplification. I verified the final diff, relevant source paths, prior findings, current head, CI, review threads, and the approval recommendation before posting.

Comment thread apps/desktop/src/main/runtime-host-oauth-ipc-main.ts Outdated
* Desktop owns exactly one thing for GitHub Copilot: importing a credential
* that already exists on this machine (`gh` / a compatible PAT). Interactive
* enrollment is not here — the device grant runs through the Host's OAuth
* coordinator like every other account login, so there is one authority that

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Keep local credential import discovery-only

Reachability: ② reasonable concurrent or multi-client path. The local adapter currently performs entitlement discovery in Desktop, then this handler reads the Host's current credential revision and commits the imported secret. If import A is waiting on model discovery while an interactive login B commits through HostOAuthCoordinator, A can resume, adopt B's latest revision as its expected value, and overwrite the newer login. A remote Host also validates the import through the Desktop network environment rather than the Host transport.

The clean final state is for Desktop to return only the locally discovered raw credential and for a Host adoption operation to own entitlement, generation/supersede, and the atomic credential commit alongside interactive login. That closes the race and preserves one network and credential authority without adding another Desktop guard.

@hgaol

hgaol commented Aug 22, 2026

Copy link
Copy Markdown
Member Author

Thanks for moving GitHub Copilot enrollment onto HostOAuthCoordinator. That resolves the original lifecycle and stale-completion problem, and the token lifetime/refresh work is now on the right authority.

I do not think another round of isolated patches would be fair or productive here. Could we converge on one final state instead?

[P1] Shipping boundary: the current provenance document explicitly says the authorization basis for reusing the Copilot editor OAuth identity is not established, while the feature is enabled by default. Until GitHub provides a public compatibility basis or a Maka-authorized app identity, this path should be explicit opt-in/default-off. The kill switch can remain for operations, but it cannot serve as the authorization basis.

[P2] Account adoption: device authorization should not commit a credential until the GitHub provider slice has verified that the account exposes at least one usable Copilot model. That validation belongs before the coordinator’s atomic commit, using the same Host transport; the Desktop post-login synchronization should not be responsible for deciding whether authentication succeeded.

[P2] Refresh completion: GitHub Copilot should join the existing single-401 refresh/replay seam. An access token can be rejected before its local expires_at; in that case the Host should force-refresh once, CAS-persist the new generation, replace the Authorization header, and replay the request once.

I would also like this revision to remove the Desktop lifecycle that the Host path has replaced, rather than adding the fixes on top:

  • fold GitHubCopilotSubscriptionService into a stateless “import existing local credential” adapter;
  • remove the unused direct mode from useOAuthLoginFlow;
  • route connection-detail re-login through the shared Host OAuth service;
  • keep local gh import only as a secondary provider-catalog action;
  • remove the unused isExperimentalEnabled preload/IPC query surface.

The provider-specific device parsing and its security/error tests should stay. The goal is not to make OAuth artificially tiny; it is to leave one lifecycle authority and remove the roughly 280–330 lines whose responsibilities have moved to the Host.

A clean final shape would be: Runtime owns the GitHub device codec, Host owns attempt/entitlement/credential/refresh/401 recovery, and Desktop owns only generic presentation plus local credential discovery. Once that state is in one exact head with epoch 35 and focused entitlement/401 regressions, we can review the final result as a whole instead of asking for another sequence of patches.

AI-assisted review disclosure: OpenAI Codex coordinated independent exact-head reviews of the OAuth lifecycle, consent/provenance boundary, refresh behavior, and simplification opportunities. I reviewed and approved this final-state recommendation before posting.

resolved.

hgaol added 3 commits August 22, 2026 06:08
…b-copilot-device-flow-login

# Conflicts:
#	apps/desktop/src/renderer/settings/provider-connection-detail.tsx
#	apps/desktop/src/renderer/settings/use-oauth-login-flow.ts
The IPC adapter read this Desktop process's environment before calling the
Host. Against a remote Runtime Host that is the wrong authority: a Host whose
operator enabled GitHub Copilot enrollment was still refused whenever the
Desktop process happened not to set the same variable, and no local answer
could have been right, because the environment that governs enrollment is the
Host's.

The start now always reaches the Host, and its `operation_unavailable` refusal
is what the renderer reports — kept as `experimental_disabled` so the user is
told the enrollment is off rather than that authorization failed.

Generated-by: Claude Code

@Astro-Han Astro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for moving the interactive device flow onto HostOAuthCoordinator; the previous stale-completion root cause is now closed, and token lifetime, refresh, entitlement-before-commit, supersede, and atomic credential commit all follow the existing Host seam. The local-import authority thread remains open, and I found one additional recoverability issue in entitlement classification inline.

AI-assisted review disclosure: OpenAI Codex coordinated three independent exact-head review passes. I verified the retained lifecycle and error-classification paths, existing threads, current-head checks, mergeability, and review state, and I made the final review decision.

Comment thread packages/runtime/src/github-copilot-oauth-enrollment.ts Outdated

@Astro-Han Astro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — one final cancellation edge surfaced in the Copilot 401 replay path. It is bounded and does not corrupt the credential, so I am classifying it as P3 rather than another merge-blocking P2; the two existing P2 items remain the actual readiness gate.

AI-assisted review disclosure: OpenAI Codex delegated an independent protocol and cancellation review. I verified the retained path and severity against this exact head.

Comment thread packages/runtime/src/subscription-model-fetch.ts
…able

The entitlement gate turned every `/models` failure into "this account has no
Copilot". A timeout, a dropped connection, a 429, a 5xx, or an unreadable body
told a paying subscriber they were ineligible and sent them back through a
device login that was never the problem — while no credential had been
committed and the next attempt would usually have worked.

Only a proven refusal is an entitlement answer now: a catalog the account could
read that lists nothing, or a deterministic 401/403. Everything else keeps the
provider status and surfaces as an authorization failure the user simply
retries.

The shared 401 replay seam also gains the request's own signal. A caller who
cancels while the forced refresh is in flight is released with its cancellation
instead of waiting out the refresh timeout and then receiving the stale 401 as
though the credential had been rejected; the refresh itself is left to settle,
because a spent grant is worth persisting even once nobody wants the replay.

Generated-by: Claude Code
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(desktop): sign in to GitHub Copilot with an account besides PAT

2 participants