Skip to content

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

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

feat(runtime-host): sign in to GitHub Copilot with a device grant#3390
hgaol wants to merge 34 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
// subscription has no other client to present. The provider already ships
// the matching editor headers (GITHUB_COPILOT_COMPAT_HEADERS), so this
// changes how the credential is obtained, not who Maka claims to be.
clientId: 'Iv1.b507a08c87ecfe98',

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.

[P1] Establish the OAuth app identity before shipping

Thanks for documenting why this client ID is needed for Copilot access. Could we also link the public authorization or compatibility basis that permits Maka to use the GitHub Copilot editor/CLI OAuth identity?

GitHub’s consent screen identifies that application while Maka receives and stores the resulting credential. If GitHub has not explicitly approved third-party reuse, this should use an app identity authorized for Maka. Either way, the source, consent identity, and authorization basis should be recorded before this becomes a public sign-in path.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Agreed on the provenance question. I recorded the client identity, consent model, and the open authorization basis in docs/github-copilot-oauth-identity.md, and called the same out next to the client ID in packages/runtime/src/oauth-provider-contracts.ts.

The interactive sign-in ships on, with the same style of kill switch as Codex: MAKA_GITHUB_COPILOT_DEVICE_LOGIN_EXPERIMENTAL=0 refuses enrollment at the Host without a release. Local gh / fine-grained PAT import stays available beside it as the fallback that does not reuse this identity.

Open decision (reviewer call): either a published GitHub authorization/compatibility basis for reusing this editor OAuth identity (linked from that doc), or a Maka-owned app identity that replaces Iv1.b507a08c87ecfe98. I did not invent a basis that does not exist.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

English

The current exact head 0685beb21aada31cfedde4ab92a18a8c10ebb03a still leaves this [P1] open. Making the route opt-in does not establish permission to use the borrowed OAuth identity: an operator can enable the flow, but cannot authorize Maka to act as GitHub's Copilot editor application or make the consent screen identify the software that will receive and store the token.

This is executable, not documentation-only. Setting MAKA_GITHUB_COPILOT_DEVICE_LOGIN_EXPERIMENTAL=1 admits the Host flow through this contract, requests the device code with the borrowed clientId, polls for the user token, verifies entitlement, and commits the credential to Maka's vault. The exact-head identity document also says no published authorization or compatibility basis has been established.

Please keep local credential import as the supported path and remove the executable borrowed-identity enrollment until Maka has either its own authorized application identity or a published GitHub authorization/compatibility basis. Default-off is a useful rollout guard, but it is not a consent or authorization guard.

中文

当前 exact head 0685beb21aada31cfedde4ab92a18a8c10ebb03a 仍未关闭这条 [P1]。把入口改成显式启用,并不能建立复用该 OAuth 身份的授权依据:操作者可以开启流程,但不能授权 Maka 冒用 GitHub Copilot 编辑器应用的身份,也不能让同意页准确说明最终接收并保存 token 的软件。

这条路径可以真实执行,并非只有文档。设置 MAKA_GITHUB_COPILOT_DEVICE_LOGIN_EXPERIMENTAL=1 后,Host 会通过这里的契约放行流程,使用借来的 clientId 申请设备码、轮询用户 token、校验订阅资格,并把凭证写入 Maka 的 vault。当前 head 的身份说明文档也明确记录:尚未建立公开的授权或兼容性依据。

建议继续保留本地凭证导入作为受支持路径,并在 Maka 获得自有、已授权的应用身份,或 GitHub 发布明确的授权/兼容性依据之前,移除这条可执行的借用身份登录路径。默认关闭是有用的发布守卫,但不是同意或授权守卫。

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

English

Revalidated on exact head 0b9ebaf938178cf640309d44064021a8eaa1500c: this [P1] is still open. The new head is a merge of the previously reviewed head with upstream 5525be38f; the OAuth contract, identity document, and Host device-enrollment path are byte-identical to 0685beb21aada31cfedde4ab92a18a8c10ebb03a. Setting MAKA_GITHUB_COPILOT_DEVICE_LOGIN_EXPERIMENTAL=1 therefore still executes the borrowed-identity grant and commits the resulting token without an established authorization basis.

The merge correctly moved this PR's compatibility epoch past upstream's then-current epoch, but it does not change the consent/identity boundary. The requested resolution remains: keep local credential import, and do not ship executable borrowed-identity enrollment until Maka has its own authorized app identity or a published GitHub authorization/compatibility basis.

中文

已在 exact head 0b9ebaf938178cf640309d44064021a8eaa1500c 上重新验证:这条 [P1] 仍然成立。新 head 只是把上一轮已审 head 与 upstream 5525be38f 合并;OAuth 契约、身份说明文档和 Host 设备登录路径与 0685beb21aada31cfedde4ab92a18a8c10ebb03a 字节一致。因此设置 MAKA_GITHUB_COPILOT_DEVICE_LOGIN_EXPERIMENTAL=1 后,仍会执行借用身份的 grant 并在没有既定授权依据的情况下提交所得 token。

这次 merge 正确地把本 PR 的 compatibility epoch 提升到当时 upstream epoch 之后,但没有改变 consent/identity 边界。修复方向不变:保留本地凭证导入;在 Maka 获得自有且已授权的应用身份,或 GitHub 发布明确的授权/兼容性依据之前,不要交付可执行的借用身份登录路径。

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Keeping the device sign-in, and proposing this be settled across providers rather than per-branch.

In current main: openai-codex ships OpenAI's CLI client id enabled by default (its flag is a kill switch), xai-oauth ships grok-cli's unconditionally with no flag, and GITHUB_COPILOT_COMPAT_HEADERS already presents Maka as VS Code's Copilot Chat on every Copilot call — including the local-import path this thread asks me to keep. The standard here isn't one the tree currently meets, and this flow is the strictest of the three: off unless an operator opts in.

Your distinction holds and I won't pretend otherwise — the other two never drive a consent screen naming a different application. What I'm disputing is settling that one branch at a time while two default-on borrowed identities sit in main unexamined.

Proposal: I file one issue covering all three identities — provenance, consent identity, authorization basis, what would settle each. This branch keeps the strictest posture meanwhile. If borrowed identities go, they should go consistently.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[P1] Revalidated on exact head 02ee0bf874912102984991b31ff64c588649506e: this remains open.

The OAuth contract and identity record are byte-identical to the last reviewed head. Enabling MAKA_GITHUB_COPILOT_DEVICE_LOGIN_EXPERIMENTAL=1 still makes Maka request a device grant with GitHub's Copilot editor application identity, show a consent page naming that other application, receive the resulting token, and store it in Maka's vault. The repository still states that no GitHub authorization or compatibility basis for this reuse has been established.

Handling other borrowed identities in a separate issue would be useful, but it does not close this path. As the author notes above, those other paths do not present a consent screen that names a different application; this one does. The repair remains to keep local credential import, and remove the executable borrowed-identity flow until Maka has its own authorized application identity or a published GitHub authorization/compatibility basis.


Posted by an automated review agent operated by @M4n5ter. This is not an
independent human review and does not satisfy the committer review required by
CONTRIBUTING.md. A human is accountable for this comment — please push back if
anything here is wrong.

简体中文

本条评论由 @M4n5ter 运行的自动化审查程序发出。它不构成 CONTRIBUTING.md
所要求的独立人类审查,也不能替代人类审查。有人类对本条评论负责,如有错误请直接指出。

@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 previously approved these changes Aug 22, 2026

@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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[P2] Reproduced on exact head 02ee0bf874912102984991b31ff64c588649506e: the local import can still replace a newer interactive login.

I paused local import A during its Desktop-side model discovery, committed interactive login B through the Host, and then resumed A. The handler queried B's current credential revision, used that revision as the expected value, and successfully committed A's local secret over B. The observed result was ok: true; the commit expected B's revision 2, so the compare-and-swap guard accepted the overwrite rather than rejecting stale work.

The current test only covers a local import with no overlapping login. Please keep Desktop limited to discovering the local raw credential, and let a Host adoption operation own entitlement, supersession/generation, network transport, and the atomic commit alongside interactive login. That gives both paths one ordering authority.


Posted by an automated review agent operated by @M4n5ter. This is not an
independent human review and does not satisfy the committer review required by
CONTRIBUTING.md. A human is accountable for this comment — please push back if
anything here is wrong.

简体中文

本条评论由 @M4n5ter 运行的自动化审查程序发出。它不构成 CONTRIBUTING.md
所要求的独立人类审查,也不能替代人类审查。有人类对本条评论负责,如有错误请直接指出。

@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
Astro-Han
Astro-Han previously approved these changes Aug 22, 2026

@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 — reviewed fresh against this head rather than relying on the earlier approval, since that one predates real work on the branch.

What convinced me, in the order I checked it:

The problem is framed correctly. A device authorization grant proves a GitHub account, and a GitHub account is not a Copilot subscription. Calling /models before completeInteractiveOAuthLogin and only committing credentials when reachable models come back is the distinction that keeps a successful sign-in from being mistaken for an entitlement. Conflating those two is the usual way this feature goes wrong, and it is handled here.

The RFC 8628 polling loop covers the cases that matter. authorization_pending, slow_down, access_denied and expired_token all arrive as HTTP 200 and are handled separately, with unknown errors terminating rather than spinning. The loop sleeps by interval first and stops against local expiry rather than trusting the server to say stop. The detail I appreciated most: each token request uses its own signal, because once the grant may already have been consumed, a cancellation that discards the response throws away a credential the user really did authorize — while the sleep and retry boundaries stay cancellable. That distinction is easy to miss.

verification_uri is validated before it is opened: HTTPS only, github.com or a subdomain, no embedded credentials. Handing a server-supplied URL to the system browser unchecked is the classic hole here, and it is closed.

Credentials do not get a private path. Device code, user code, expiry and interval stay in Host memory and never reach the Desktop capability, which only receives the URL and the display user code. Storage goes through the existing canonical vault with the shared refresh/CAS seam, and a GitHub token without a lifetime uses an explicit non-expiring sentinel instead of fabricating a refresh grant. The 401 path reuses the shared refresh-and-replay rather than adding a second one.

On scope, the change removes the duplicated device flow, polling and persistence that lived on the Desktop side instead of adding a parallel implementation, so the authority for this ends up in one place.

On the default being off: gating interactive login behind MAKA_GITHUB_COPILOT_DEVICE_LOGIN_EXPERIMENTAL=1 looks right to me rather than over-cautious. The device flow reuses the GitHub editor OAuth identity, so the party that gave consent and the party holding the credential are not the same — leaving the existing gh/PAT import as the default path while that is true is the honest position, and it is the kind of thing worth stating in the code rather than deciding silently.

No P0–P3 findings. test and audit are both terminal green on this exact head.

Review assisted by AI (Claude Opus 5 and GPT-5.6, reviewed independently). Findings were verified against the files at this head; the reviewers are accountable for them.

…-device-flow-login

# Conflicts:
#	apps/desktop/src/main/__tests__/github-copilot-subscription-service.test.ts
#	apps/desktop/src/main/oauth/github-copilot-subscription-service.ts
#	apps/desktop/src/main/runtime-host-github-copilot-ipc-main.ts
#	packages/runtime-host/src/__tests__/protocol.test.ts
#	packages/runtime-host/src/protocol/index.ts
#	packages/runtime/src/oauth-provider-contracts.ts
@Astro-Han

Copy link
Copy Markdown
Contributor

Hi — this PR conflicts with current main and cannot be merged as-is.

I tested a rebase onto current main locally (in a throwaway worktree — your branch was not touched). It stops on these files:

  • packages/runtime/src/oauth-provider-contracts.ts

These are real source conflicts, so they need your judgement rather than a mechanical rebase — please rebase onto current main and resolve them yourself, then push. Once the branch is conflict-free and CI is green on the new head, I will pick it up for review.

git fetch upstream && git rebase upstream/main
# resolve, then
git push --force-with-lease

Thanks for the contribution — happy to help if any conflict is unclear.


AI-assisted maintenance note, not a review. It does not count as the required human review under CONTRIBUTING.md §Review.

…-device-flow-login

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

hgaol commented Aug 23, 2026

Copy link
Copy Markdown
Member Author

Rebased onto current main — resolved as a merge rather than a rebase to preserve your existing approval, and since the repo squash-merges the branch history won't land on main either way. Happy to redo it as a true rebase if you'd prefer.

main had moved again by the time I got to it, so this is against 6b48ed3. Two conflicts, both in packages/runtime-host: the compatibility epoch (this branch held 40, which upstream has since taken for the message-queue mutation ops — Copilot is now 43, and scripts/protocol-epoch-check.mjs passes), and protocol.test.ts, where I kept your new credential-rotation and provider-capacity tests and re-anchored the Copilot assertion to > 42. I also ran npm run write:asf-headers for the five files this branch added before the header gate existed.

CI shows one failure: host-kernel.test.js "slow domain work preserves multiplexed requests" timed out on goal.query. That file is untouched by this branch and passes locally (506ms); given a2f2a1a it looks like the same flaky area. Could you re-run the job? I don't have permission to.

hgaol added 3 commits August 24, 2026 19:39
…-device-flow-login

# Conflicts:
#	apps/desktop/src/main/oauth/github-copilot-subscription-service.ts
#	packages/runtime-host/src/__tests__/protocol.test.ts
#	packages/runtime-host/src/protocol/index.ts
@Astro-Han

Copy link
Copy Markdown
Contributor

Heads-up on a number collision, not a review of the change itself.

This branch declares RUNTIME_HOST_COMPATIBILITY_EPOCH = 48. That value was taken by #3669, which merged a few minutes ago, so 48 now belongs to a different protocol generation on main. The branch is already conflicting and needs a rebase, and the epoch will have to move as part of it.

Rather than a number, the reliable step is to re-derive it at rebase time, because other branches are moving too:

git fetch origin main
git show origin/main:packages/runtime-host/src/protocol/index.ts | grep COMPATIBILITY_EPOCH
gh pr list --repo apache/maka --limit 200 --json number --jq '.[].number' | while read n; do
  gh api repos/apache/maka/pulls/$n/files --paginate \
    --jq '.[]|select(.filename=="packages/runtime-host/src/protocol/index.ts")|.patch' 2>/dev/null \
  | grep -oP '^\+export const RUNTIME_HOST_COMPATIBILITY_EPOCH = \K\d+' | sed "s/^/#$n /"
done

Take the first value above main's that no open pull request claims. Also please match the assertion in packages/runtime-host/src/__tests__/protocol.test.ts to whatever you pick — the convention there is one assertion per generation asserting > previous, so an epoch of 50 pairs with > 49.

简体中文

提醒一个编号冲突,不是对改动本身的评审。

这个分支声明了 RUNTIME_HOST_COMPATIBILITY_EPOCH = 48。这个值几分钟前已被合并的 #3669 占用,所以在 main 上 48 现在属于另一个协议世代。这个分支目前已经冲突、需要 rebase,epoch 也要在这个过程中一并挪走。

与其给一个数字,更可靠的做法是在 rebase 时重新推导一次,因为其它分支也在移动:

git fetch origin main
git show origin/main:packages/runtime-host/src/protocol/index.ts | grep COMPATIBILITY_EPOCH
gh pr list --repo apache/maka --limit 200 --json number --jq '.[].number' | while read n; do
  gh api repos/apache/maka/pulls/$n/files --paginate \
    --jq '.[]|select(.filename=="packages/runtime-host/src/protocol/index.ts")|.patch' 2>/dev/null \
  | grep -oP '^\+export const RUNTIME_HOST_COMPATIBILITY_EPOCH = \K\d+' | sed "s/^/#$n /"
done

取比 main 大、且没有任何开放 PR 占用的第一个值。另外请把 packages/runtime-host/src/__tests__/protocol.test.ts 里的断言与你选定的值对齐——那里的惯例是每个世代一条断言、断言 > 上一代,所以 epoch 选 50 就对应 > 49

@hgaol

hgaol commented Aug 24, 2026

Copy link
Copy Markdown
Member Author

Re-derived rather than reused: 49 turned out to be independently claimed by two other open PRs (#3299, #3651), not just main. Moved to 50, which no open PR currently claims, and matched the floor test's assertion (already > 48, still correct). node scripts/protocol-epoch-check.mjs --base upstream/main confirms: 48 -> 50. Pushed at 55d1c20.

hgaol added 3 commits August 24, 2026 16:47
The Host now owns Copilot enrollment, so the connection detail offers the
device sign-in and the catalog panel keeps the local import. The generation
story still clicked a `重新导入` button that this branch removed, and upstream
apache#3450 could not see the collision: the story and the panel changed on
different sides of the merge.

Drive the surface that exists. The property under test is unchanged — a
same-key Host replacement retires the action guard without discarding the
detail route, and the previous generation's late success must not report into
the new one — so the story holds the first `completeAuthorization` unsettled
across the replacement instead of the first import.

Generated-by: Claude Code
The merge kept this branch's totals line while taking upstream's rows, so the
document claimed 209 files over a 211-file table and the coverage gate read it
as stale. Regenerated: 211 files, blocker 0, polish 1, aligned 210.

Generated-by: Claude Code
@hgaol
hgaol requested review from Astro-Han and M4n5ter August 25, 2026 10:16
@hgaol

hgaol commented Aug 26, 2026

Copy link
Copy Markdown
Member Author

Hi @Astro-Han @M4n5ter , could you take a look when getting chance? Thx!

@M4n5ter M4n5ter left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

NO-GO on exact head 02ee0bf874912102984991b31ff64c588649506e: 2×P1 and 1×P2 remain open.

  • [P1] The executable device flow still uses another application's GitHub OAuth identity without an established authorization or compatibility basis. The opt-in flag limits reachability but does not make the consent screen identify Maka or grant Maka permission to receive the token. Details
  • [P1] This branch and current main both advertise compatibility epoch 50 for different incompatible protocol changes. The handshake succeeds, then a current-main Host rejects the new provider value mid-session. Details
  • [P2] A local credential import that starts before a newer interactive login can resume afterward, adopt the newer credential revision, and overwrite that login. A production-module probe reproduced the overwrite. Details

Both exact-head hosted checks are successful, and 32 focused enrollment, coordinator, import, and credential tests pass. Those tests do not cover the reproduced overlapping-login window. The PR is currently mergeable=false / dirty; a local merge-tree reports conflicts in the protocol epoch file and the generated inventory.

The change does remove a real duplicate authority: Desktop no longer owns persistent Copilot credential state, refresh, cancellation, and logout. However, if the OAuth identity requirement cannot be satisfied, the simpler result is to remove the executable device path and retain local credential import instead of keeping the new device-flow model behind an opt-in flag. If the feature remains, three bounded reductions are available: remove the vestigial Desktop open-auth-url and unused refresh-tokens operations, share the GitHub/XAI device-poll lifecycle, and use one Copilot entitlement verifier for both enrollment and local import.


Posted by an automated review agent operated by @M4n5ter. This is not an
independent human review and does not satisfy the committer review required by
CONTRIBUTING.md. A human is accountable for this comment — please push back if
anything here is wrong.

简体中文

本条评论由 @M4n5ter 运行的自动化审查程序发出。它不构成 CONTRIBUTING.md
所要求的独立人类审查,也不能替代人类审查。有人类对本条评论负责,如有错误请直接指出。

@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.

Re-reviewed exact head 02ee0bf87 myself rather than carrying the earlier approval forward. My reading differs from the automated NO-GO on two of its three items.

The borrowed OAuth identity does not block this PR. Presenting as GitHub's editor client is the existing baseline, not something this branch introduces: GITHUB_COPILOT_COMPAT_HEADERS on main already sends User-Agent: GitHubCopilotChat/0.35.0 and Copilot-Integration-Id: vscode-chat on every Copilot call, including the existing gh import path, with no opt-in. This branch extends the same posture to the device grant and, unlike the baseline, ships it default-off behind a Host-enforced flag with docs/github-copilot-oauth-identity.md recording exactly what is and is not established. Holding this PR to a stricter bar than the code it extends would be asymmetric. The client ID does appear on a consent screen naming the editor application rather than Maka — that is the one part the headers do not cover, and default-off is the right answer to it for now.

The epoch 50 collision is real but is not a finding to fix here. This branch and main both declare 50 for different protocol changes. The PR cannot merge while packages/runtime-host/src/protocol/index.ts is conflicted, and scripts/protocol-epoch-check.mjs runs on the merge result, so a wrong resolution fails CI rather than shipping. One thing to get right when you rebase: take main's 50 as-is and renumber the github-copilot provider note to 51 — do not fold the copilot note under main's 50 entry.

[P3] A remount re-enables local import while a device grant is still polling. useOAuthLoginFlow derives actionBusy from a local pendingAction state and only refreshes the snapshot on mount; it does not rehydrate an in-flight Host attempt. Close and reopen Settings during the device poll and the Import button is enabled again, so the import can commit over the login the user is in the middle of. setRuntimeHostAccountCredential re-reads the credential immediately before its CAS, so it adopts the newer revision and overwrites rather than failing. Both paths commit an entitlement-verified credential and either action recovers it, which is why this is P3. The narrow fix is to seed pendingAction from the Host snapshot on mount.

(The automated review reached this through a direct main-process probe and graded it P2. Through the UI the shared actionBusy guard does hold for the single-window case — the remount path above is the one that reaches it.)

[P3] The title says runetime-host; it becomes the squash commit subject.

Approving on the code. The rebase for the epoch and the two P3s can follow.

AI use: Claude Code assisted with source investigation; the analysis and conclusions are my own.

简体中文

在 exact head 02ee0bf87 上自己重审了一遍,没有沿用之前的 approval。我的判断与自动化 NO-GO 在其中两条上不同。

借用 OAuth 身份不阻塞本 PR。 以 GitHub 编辑器客户端身份出现是既有基线,不是本分支引入的:main 上的 GITHUB_COPILOT_COMPAT_HEADERS 已经在每次 Copilot 调用上发送 User-Agent: GitHubCopilotChat/0.35.0Copilot-Integration-Id: vscode-chat,包括现有的 gh 导入路径,且无需任何 opt-in。本分支把同一姿态延伸到设备授权,而且与基线不同,它默认关闭、由 Host 强制门禁,并用 docs/github-copilot-oauth-identity.md 如实记录了哪些成立、哪些不成立。对本 PR 采用比它所扩展的代码更严的标准并不对称。client ID 确实会出现在同意页上,且那里显示的是编辑器应用而非 Maka——这是 header 覆盖不到的一点,目前默认关闭就是对它的正确回答。

epoch 50 撞号确实存在,但不是这里要修的 finding。 本分支与 main 都声明 50,语义不同。只要 packages/runtime-host/src/protocol/index.ts 处于冲突状态,PR 就合不进去;而 scripts/protocol-epoch-check.mjs 跑在 merge result 上,错误的解冲突会让 CI 失败而不是被发布出去。rebase 时有一点要注意:原样保留 main 的 50,把 github-copilot 的说明改成 51——不要把 copilot 的说明并进 main 的 50 条目里。

[P3] remount 会在设备授权仍在轮询时重新启用本地导入。 useOAuthLoginFlowactionBusy 来自本地 pendingAction state,挂载时只刷新快照,不回填 Host 上在途的 attempt。在设备轮询期间关掉再打开 Settings,Import 按钮就重新可用,导入因此可能覆盖用户正在进行中的登录。setRuntimeHostAccountCredential 在 CAS 前会重新读取当前凭据,所以它会采用较新的 revision 直接覆盖,而不是失败。两条路径提交的都是经过 entitlement 校验的凭据,重跑任一动作即可恢复,所以定为 P3。最小的修法是在挂载时用 Host 快照初始化 pendingAction

(自动化审查是通过直接调用主进程模块复现的,定为 P2。走 UI 时共享的 actionBusy 守卫在单窗口场景下确实成立——真正能触达的是上面这条 remount 路径。)

[P3] 标题写成了 runetime-host,squash 后会成为 commit subject。

代码层面 approve。epoch 的 rebase 和这两条 P3 可以后续处理。

hgaol added 2 commits August 26, 2026 10:38
…b-copilot-device-flow-login

# Conflicts:
#	docs/astryx-surface-file-inventory.md
#	packages/runtime-host/src/protocol/index.ts
…ling

`useOAuthLoginFlow` derives its guard from a local `pendingAction`, so a mount
knows nothing about an attempt an earlier mount left running in the Host. Close
and reopen Settings during a device poll and the Import button is live again:
`setRuntimeHostAccountCredential` re-reads the credential immediately before its
CAS, so the import adopts the newer revision and commits over the login the user
is still completing rather than failing on it.

Report the unowned attempt instead of adopting it. The account snapshot says an
authorization is running but not which one, so this surface cannot rejoin the
attempt — and seeding `pendingAction` from it would disable the sign-in that
supersedes a stale attempt, which is the only way out when the earlier surface
never got to cancel one. The discovery route stands down; the primary route
stays offered.

Generated-by: Claude Code
@hgaol hgaol changed the title feat(runetime-host): sign in to GitHub Copilot with a device grant feat(runtime-host): sign in to GitHub Copilot with a device grant Aug 26, 2026
@hgaol

hgaol commented Aug 26, 2026

Copy link
Copy Markdown
Member Author

Thanks — rebased and addressed both P3s.

Epoch. Resolved as you asked: main's 50 and 49 kept verbatim, the github-copilot note renumbered to 51, constant at 51. Not folded under main's entry. The merge-result guard reads 50 → 51. I also reworded the note's second line — it previously justified the number by naming the two open PRs that had claimed 49, which reads as a reservation; it now records that the value is re-derived from main on each rebase.

[P3] Import during an in-flight device grant. Confirmed, including the mechanism: activeAttempts lives in the main process, so it outlives a renderer remount, and useOAuthLoginFlow never consults the snapshot it surfaces.

I took a narrower fix than seeding pendingAction, and want to flag the deviation rather than let it pass silently. Seeding disables the sign-in button too — and nothing prunes activeAttempts, so an attempt whose surface died before its cancel IPC landed leaves the snapshot reporting authorizing indefinitely. Seeding would make the panel permanently inert with no way out short of an app restart, since a fresh grant is what supersedes a stale attempt.

So the flow now exposes hostAttemptPending — an authorization the Host is running that this surface does not own — and only the Copilot import button consults it. The clobbering route stands down; the primary route stays offered; labels and the shared guard are unchanged, so no other provider's panel shifts behavior. Three tests cover adoption on mount, settled states, and not double-reporting an attempt this surface started.

Happy to switch to the seeded version if you'd still prefer it — it's a two-line change.

[P3] Title fixed.

Checks on the merged tree: build, typecheck, lint, format, knip, ASF headers, surface inventory, and the epoch guard all clean; Storybook smoke 195/195; desktop 1520, runtime-host 1251, runtime 3114 tests passing.

…b-copilot-device-flow-login

# Conflicts:
#	packages/runtime-host/src/protocol/index.ts
…b-copilot-device-flow-login

# Conflicts:
#	packages/runtime-host/src/protocol/index.ts
@github-actions github-actions Bot added the effort/XL Over 1000 readable lines label Aug 27, 2026

@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.

Adds RFC 8628 device authorization for Copilot in packages/runtime, driven by the Host's HostOAuthCoordinator, and deletes Desktop's 209-line stateful service plus the renderer's direct special case.

Worth more than the title says: the real result is removing a duplicate authentication authority — Copilot account state was living in the Desktop main process, against the Runtime-Host-is-sole-authority boundary. Re-reviewing at a54b4f7e6.

P2 — in a default build the most prominent button always fails. The gate is flag === '1', default off (oauth-provider-contracts.ts:117), but "Sign in with GitHub" renders unconditionally as variant="primary" (provider-oauth-section.tsx:345) while the working import-credentials path is secondary.

P2 — the typed reason has no consumer. experimental_disabled is produced at runtime-host-oauth-ipc-main.ts:125; nothing on the OAuth side reads it, and the renderer actually matches an English error string. Add a language or reword the error and the check silently stops working.

P2 — signed-out copy still describes PATs, unrelated to the primary action.

The daily-review-coordinator concurrency fix is unrelated to Copilot sign-in and belongs in its own PR; the subscription-model-fetch signal work is a separable second intent. Not defects — reviewability.

The split is half-done in one place: the local enable gate was correctly removed, but "ask the Host for the answer" wasn't put in its place.

On the borrowed client identity: main already ships app_EMoamEEZ... (Codex, default on) and the xAI id, so this PR — default off — is more conservative than the status quo. That makes it a project-level governance question for dev@maka.apache.org, not a blocker here. I'll start that thread.


AI-assisted review: Claude Code subagents ran security and architecture passes; I verified the default-off gate, the primary-button rendering, and the two client IDs already on main myself. No tests or typecheck run. AI review is not independent human review.

简体中文

实际成果比标题大:把 Copilot 账户状态机从 Desktop 主进程搬回 Runtime Host,消除了一个重复认证权威。三条 P2 都不影响正确性:默认构建下主按钮必然失败;experimental_disabled 这个结构化原因码无人消费,实际靠英文错误串正则;未登录文案仍只讲 PAT。daily-review-coordinator 的并发修复属夹带,建议单开。借用 client id 的问题 main 上已存在且更激进,是 dev@ 的议题,不卡这个 PR。

@hgaol
hgaol force-pushed the feat/github-copilot-device-flow-login branch from a54b4f7 to 3fa96bf Compare August 27, 2026 10:26
@hgaol

hgaol commented Aug 27, 2026

Copy link
Copy Markdown
Member Author

Thanks for the thorough review — all three P2s addressed in the latest push, and I've split the branch to a single intent.

P2 — primary button always fails in a default build. Fixed by putting "ask the Host for the answer" where the removed local gate used to be. Added an oauth.enrollment.query Host operation; HostOAuthCoordinator answers it from the same isProviderEnabled gate that governs the start, so there's still exactly one authority and it works for remote Hosts. The panel now probes on mount: when the Host reports enrollment disabled, "Sign in with GitHub" is disabled with an explanatory tooltip and Import is promoted to primary, so the prominent action is one that actually succeeds. Unknown-while-probing is treated as enabled, so a slow Host never hides sign-in.

P2 — typed reason had no consumer. subscriptionResultMessage now branches on the typed reason === 'experimental_disabled', threaded through from the start/open/complete failure paths. The English-string match is kept only as a fallback, so rewording the Host message or adding a locale no longer silently breaks the check.

P2 — signed-out copy still described PATs. Reworded copilotSetup (zh + en) to describe device sign-in and gh credential import, and added the tooltip copy.

Tests: added protocol decode, coordinator per-provider gate, and IPC-main get-enrollment-state coverage; runtime-host and desktop typecheck + affected suites pass locally.

Reviewability — daily-review split. Agreed it's unrelated. The daily-review-coordinator flight-claim fix is pulled out of this branch onto its own; this PR is now Copilot sign-in only. I'll open the daily-review PR separately.

On subscription-model-fetch: that signal is entangled with adopting the account's own model inventory after sign-in, so I've left it here rather than risk a messy split — happy to separate it if you'd prefer.

Re: the borrowed client identity — agreed it's a dev@ governance question and not a blocker here; will follow that thread.

@Astro-Han

Copy link
Copy Markdown
Contributor

Two things on the current head, one mechanical and one worth a look before merge.

CI is red on this branch's own stories. use-oauth-login-flow.ts:143 calls bridge.getEnrollmentState() on mount, but the mock bridge in apps/desktop/stories/settings/settings-pages.stories.tsx doesn't define it — the name doesn't appear in that file at all. Two stories (ModelsOAuthAuthorizationHostGenerationRevalidation, ModelsCopilotReloginHostGenerationRevalidation) throw TypeError: e.getEnrollmentState is not a function and render empty, failing smoke:storybook. The .catch(() => undefined) doesn't cover it: the property is undefined, so it throws synchronously before a promise exists.

P2 — the entitlement question is answered twice, and the two answers disagree in a way that misdirects the user.

verifyGitHubCopilotModelEntitlement gets this right, and says why in its own comment: only a 401/403 is an entitlement answer, while a timeout, a dropped connection, a rate limit or a 5xx tell us nothing about the subscription — so those become GitHubCopilotEntitlementUnavailableError, and the coordinator maps the two to provider_rejected and authorization_failed respectively (oauth-coordinator.ts:611,615).

importGitHubCopilotLocalCredential re-derives the same judgement and drops that distinction. It calls fetchGitHubCopilotModels directly, throws a bare Error on an empty catalog, and the whole block sits inside a bare catch {} that returns one token_exchange_failed with one message: 当前 GitHub 凭据类型不受支持;请使用兼容 OAuth 登录或 fine-grained PAT。

So when GitHub is briefly unreachable, a user importing an existing gh credential is told their credential type is unsupported and to go mint a PAT — advice that sends them to fix something that was never broken, which is exactly the outcome the verifier's comment sets out to avoid. A 401/403 lands in the same bucket too, so the one case that is an entitlement answer is indistinguishable from a network blip.

Both files are new in this PR, so this is one fact with two implementations introduced together rather than an inherited seam. Having the import path call verifyGitHubCopilotModelEntitlement and map its two error types the way the coordinator already does would leave one authority and one set of messages.

Non-blocking — the import path still works, and the failure is recoverable by retrying. It's the message that's wrong, not the outcome.

AI-assisted review: I traced both entitlement paths, the story mock gap, and the coordinator's error mapping against head 3fa96bf0a myself. No tests run. AI review is not independent human review.

简体中文

两点,一条机械性,一条建议合并前看一下。

CI 红在本分支自己的 story 上。 use-oauth-login-flow.ts:143 挂载时调 bridge.getEnrollmentState(),但 settings-pages.stories.tsx 里的 mock bridge 没有定义它(整个文件搜不到这个名字),两个 story 抛 TypeError 白屏,smoke:storybook 失败。.catch(() => undefined) 接不住——属性是 undefined,在 promise 产生前就同步抛了。

P2 — entitlement 这个问题被回答了两次,两个答案不一致,且会误导用户。

verifyGitHubCopilotModelEntitlement 处理正确,并在注释里说明了理由:只有 401/403 才是 entitlement 的答案,超时、断连、限流、5xx 说明不了订阅状态,所以归入 GitHubCopilotEntitlementUnavailableError,coordinator 分别映射为 provider_rejectedauthorization_failed

importGitHubCopilotLocalCredential 重新推导了同一判断,并丢掉了这个区分:直接调 fetchGitHubCopilotModels,空目录抛裸 Error,整块又被裸 catch {} 包住,统一返回一个 token_exchange_failed 和一句“当前 GitHub 凭据类型不受支持;请使用兼容 OAuth 登录或 fine-grained PAT”。

于是 GitHub 短暂不可达时,导入既有 gh 凭据的用户会被告知凭据类型不受支持、去申请 PAT——去修一个根本没坏的东西,正是那段注释想避免的结果。401/403 也落进同一个桶,唯一真正是 entitlement 答案的情况反而与网络抖动无法区分。

两个文件都是本 PR 新增的,所以这是同时引入的“一个事实两处实现”,不是继承的接缝。让导入路径调用 verifyGitHubCopilotModelEntitlement 并像 coordinator 那样映射它的两种错误,就只剩一个权威和一套文案。

不阻塞——导入路径仍然可用,重试即可恢复。错的是文案,不是结果。

…sign-in

Address three review findings on the Copilot device-flow sign-in:

- The renderer kept no local enrollment gate but never asked the Host for
  the answer either, so the primary "Sign in with GitHub" button always
  failed in a default (opt-in) build. Add an oauth.enrollment.query Host
  operation; the panel disables sign-in with an explanatory tooltip and
  promotes Import to primary when the Host reports enrollment disabled.
- The typed experimental_disabled reason had no consumer: the renderer
  matched an English error string instead. Branch on the typed reason,
  keeping the string match only as a fallback.
- The signed-out copy still described minting a fine-grained PAT. Reword
  it to describe device sign-in and gh credential import.

Adds protocol, coordinator, IPC-main, and decode tests.
@hgaol
hgaol force-pushed the feat/github-copilot-device-flow-login branch from 3fa96bf to 4096d9d Compare August 27, 2026 14:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/XL Over 1000 readable lines

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

3 participants