Skip to content

fix(github-copilot): support GUI/RPC wizard auth flow#73290

Merged
shanselman merged 2 commits intoopenclaw:mainfrom
indierawk2k2:fix/github-copilot-wizard-auth
Apr 29, 2026
Merged

fix(github-copilot): support GUI/RPC wizard auth flow#73290
shanselman merged 2 commits intoopenclaw:mainfrom
indierawk2k2:fix/github-copilot-wizard-auth

Conversation

@indierawk2k2
Copy link
Copy Markdown
Contributor

Problem

The interactive GitHub Copilot device-code provider auth ("github-copilot".auth.run) short-circuits with { profiles: [] } whenever process.stdin.isTTY is false, and otherwise delegates to githubCopilotLoginCommand, which drives readline against stdin/stdout. That makes the device flow unusable from the gateway RPC bridge and any GUI wizard frontend (including downstream OpenClaw GUI clients), because there is no controlling TTY and no shared stdin with the gateway process. Users on those clients see an empty result and cannot complete onboarding for Copilot.

This is a complementary follow-up to the non-interactive token path Val added in 461c10bb51 (feat(onboard): support non-interactive GitHub Copilot token auth); that change deliberately did not touch the interactive runGitHubCopilotAuth path.

Fix

A new high-level runGitHubCopilotDeviceFlow() helper in extensions/github-copilot/login.ts:

  • Validates the GitHub verification URL (must be https://github.com/login/device) and trims/length-checks user_code before exposing them to the host UI.
  • Exposes a small io interface (showCode + openUrl) so callers render the code through their own surface (ctx.prompter.note + ctx.openUrl).
  • Returns a typed result { status: "authorized", accessToken } | { status: "access_denied" } | { status: "expired" } instead of throwing for normal terminal states.

runGitHubCopilotAuth() in extensions/github-copilot/index.ts is rewritten to use that helper:

  • Drops the process.stdin.isTTY early-return guard.
  • Drops the call into githubCopilotLoginCommand (which reads from stdin) and the post-hoc ensureAuthProfileStore readback.
  • Emits the URL / code / expiry through ctx.prompter.note and best-effort calls ctx.openUrl, exactly like other provider auth flows.
  • Returns the credential inline via the returned profiles. The framework then persists it under the correct agentDir (same persistence path as the non-interactive flow).

Deliberate non-changes

  • The CLI entry githubCopilotLoginCommand is unchanged and remains the right path for terminal users.
  • requestDeviceCode and pollForAccessToken remain private to login.ts; no api.ts export.
  • extensions/github-copilot/models.ts and provider-discovery logic are not touched. The constants used (DEFAULT_COPILOT_PROFILE_ID, DEFAULT_COPILOT_MODEL, PROVIDER_ID) already live in index.ts / models.ts after #461c10bb51.
  • Unexpected (non-access_denied/expired) errors continue to propagate rather than being swallowed into { profiles: [] }. This is intentional: silent failure was painful to debug from GUI clients in pre-fix testing. Network/HTTP failures will surface through the provider's normal error path.

Tests

src/plugin-sdk/test-helpers/provider-auth-contract.ts – the github-copilot provider-auth contract. The two existing tests asserted the old behavior (auth-store readback after the SDK login command, and TTY-gated short-circuit), which no longer exists. They are replaced with five tests that drive the real flow via vi.stubGlobal("fetch", ...) against the github.com device-code and access-token endpoints:

  1. keeps device auth results provider-owned – credential matches the access token returned by the device flow; githubCopilotLoginCommand is not called and ensureAuthProfileStore is not read.
  2. uses the wizard prompter and openUrl hooks for the device code (no stdin/stdout)ctx.openUrl is invoked with the verification URL and ctx.prompter.note receives the user code and URL.
  3. supports non-interactive (GUI/RPC) auth contexts without a TTY – the flow completes successfully with process.stdin.isTTY forced to false.
  4. returns no profiles and notes cancellation when the user denies accesserror: "access_denied" from the token endpoint produces { profiles: [] } and a "cancelled" note.
  5. returns no profiles and notes expiry when the device code expireserror: "expired_token" produces { profiles: [] } and an "expired" note.

Validation

  • pnpm tsgo:extensions – clean.
  • pnpm test:extension github-copilot62 passing, 3 pre-existing failing. The 3 failures are in extensions/github-copilot/models.test.ts and are present on main independent of this PR (vi.mock for openclaw/plugin-sdk/provider-model-shared is missing the resolveProviderEndpoint export). Confirmed by stashing this PR's edits and re-running against origin/main (3 fail / 59 pass on baseline; 3 fail / 62 pass with this PR).
  • End-to-end manual: ran a downstream GUI onboarding wizard against a local --dev gateway, selected GitHub Copilot, the gateway opened the verification URL in the default browser, the wizard rendered the user code via ctx.prompter.note, GitHub OAuth completed, the credential persisted to the active agent dir as github-copilot:github, and chat through the new credential worked end-to-end.

Out of scope / follow-ups

  • Deeper unit tests for normalizeGitHubDeviceVerificationUrl / normalizeGitHubDeviceUserCode validation can be added later once extensions/github-copilot/login.test.ts exists; this PR keeps test scope to the provider-auth contract.

Contributing.md checklist

  • American English in code, comments, docs.
  • One thing per PR (interactive provider-auth surface only).
  • No CODEOWNERS-restricted paths edited (extensions/github-copilot/** is not in the secops codeowner list); courtesy CC @openclaw/secops because this is auth-flow code.
  • Targeted test lane run: pnpm test:extension github-copilot.
  • Bundled-plugin import-boundary inventories not affected (no imports added across the src/**extensions/** seam).

cc @openclaw/secops

@indierawk2k2
Copy link
Copy Markdown
Contributor Author

Suggested reviewers: @joshavant (Gateway / Security / Agents — primary, experienced) and @shanselman (pairing with a primary reviewer for context). cc @openclaw/secops for awareness — auth-adjacent though �xtensions/github-copilot/** is not in the secops codeowner list.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 28, 2026

Greptile Summary

This PR replaces the TTY-gated, stdin-driven GitHub Copilot device-code auth path with a new runGitHubCopilotDeviceFlow() helper that surfaces the verification URL and user code through caller-provided io hooks (showCode / openUrl), making the flow usable from GUI wizards and RPC bridges. The implementation is clean and the five new contract tests cover the expected outcomes well.

Confidence Score: 4/5

Safe to merge; the two findings are P2 suggestions with no impact on the correctness of the happy path.

Only P2 findings were identified — a minor timing inaccuracy in expiresAt and a test-mock robustness gap. Both are non-blocking and the core auth flow is correct.

No files require special attention.

Prompt To Fix All With AI
This is a comment left during a code review.
Path: extensions/github-copilot/login.ts
Line: 205-210

Comment:
**`expiresAt` computed after blocking UI calls**

`expiresAt` is calculated with `Date.now()` after both `showCode` and `openUrl` complete. If `ctx.prompter.note` is a wizard step that blocks until the user acknowledges (e.g. clicks "Continue"), the client-side expiry window is shifted forward by however long the user takes, causing the polling loop to run past the actual server-side device-code lifetime. The server will still reply with `expired_token` and the error is handled correctly, but the loop may make unnecessary requests after the code has actually expired. Compute `expiresAt` from immediately after `requestDeviceCode` returns:

```ts
const device = await requestDeviceCode({ scope: "read:user" });
const verificationUrl = normalizeGitHubDeviceVerificationUrl(device.verification_uri);
const userCode = normalizeGitHubDeviceUserCode(device.user_code);
const expiresInMs = device.expires_in * 1000;
const expiresAt = Date.now() + expiresInMs;   // <-- anchor to code-issuance time

await io.showCode({ verificationUrl, userCode, expiresInMs });
try { await io.openUrl?.(verificationUrl); } catch {}

const accessToken = await pollForAccessToken({
  deviceCode: device.device_code,
  intervalMs: Math.max(1000, device.interval * 1000),
  expiresAt,   // <-- use pre-computed value
});
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/plugin-sdk/test-helpers/provider-auth-contract.ts
Line: 314-321

Comment:
**`Request` object fallback gives `"[object Request]"` instead of the URL**

The `String(input)` fallback for non-string, non-`URL` inputs (i.e. a `Request` object) returns the unhelpful string `"[object Request]"`, which won't match any of the URL checks and will throw `unexpected fetch`. All current callers in `login.ts` pass plain string URLs, so this won't trigger today, but it's a silent footgun if the fetch calls are ever refactored to use `Request` objects.

```ts
const target =
  typeof input === "string"
    ? input
    : input instanceof URL
      ? input.toString()
      : input instanceof Request
        ? input.url
        : String(input);
```

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "fix(github-copilot): support GUI/RPC wiz..." | Re-trigger Greptile

Comment thread extensions/github-copilot/login.ts
Comment thread src/plugin-sdk/test-helpers/provider-auth-contract.ts
@indierawk2k2 indierawk2k2 force-pushed the fix/github-copilot-wizard-auth branch from 689b656 to 7cc0e63 Compare April 28, 2026 23:27
@openclaw-barnacle openclaw-barnacle Bot added the scripts Repository scripts label Apr 28, 2026
@indierawk2k2
Copy link
Copy Markdown
Contributor Author

Pushed 7cc0e63671 addressing all review feedback:

  • CI fix (lint:tmp:no-raw-channel-fetch): The two pre-existing fetch() callsites in login.ts shifted from lines 48/80 → 69/101 because this PR added typed errors and the runGitHubCopilotDeviceFlow helper above them. Updated the line-pinned allowlist entries in scripts/check-no-raw-channel-fetch.mjs to match. No new raw fetches were introduced; both calls remain on the existing allowlist.
  • Greptile P2 (expiresAt timing): Anchored to requestDeviceCode() return time so wizard prompter delays cannot stretch the local poll window past GitHub''s real TTL.
  • Greptile P2 (test stub Request fallback): Added instanceof Request branch in stubGitHubDeviceFlowFetch before the String(input) fallback.

Local validation: pnpm tsgo:extensions clean, pnpm test:extension github-copilot 62 pass (the 3 failures in models.test.ts reproduce on origin/main and are unrelated to this PR), node scripts/check-no-raw-channel-fetch.mjs green.

@clawsweeper
Copy link
Copy Markdown
Contributor

clawsweeper Bot commented Apr 28, 2026

Codex review: needs maintainer review before merge.

What this changes:

This PR changes GitHub Copilot provider auth to run device-code login through provider prompter/openUrl hooks, return the token profile inline for framework persistence, update provider-auth tests, refresh raw-fetch allowlist line numbers, and add a changelog entry.

Maintainer follow-up before merge:

This is an active, focused auth-adjacent implementation PR; the remaining action is maintainer/security review and CI resolution, not a separate automated repair PR.

Security review:

Security review cleared: The diff is auth-adjacent but keeps GitHub device endpoints fixed, validates the displayed URL/user code, adds no dependencies or workflow permission changes, and only shifts existing raw-fetch allowlist line numbers.

Review details

Best possible solution:

Land a maintainer-reviewed version of this PR once CI is satisfactory, keeping the terminal-only CLI command intact while allowing provider auth callers to complete GitHub Copilot device login through the existing wizard/RPC prompter and URL-opening contract.

Acceptance criteria:

  • pnpm tsgo:extensions
  • pnpm test:extension github-copilot
  • node scripts/check-no-raw-channel-fetch.mjs
  • pnpm check:changed in Testbox before landing if maintainers proceed

What I checked:

  • Current main still gates provider auth on TTY: runGitHubCopilotAuth on current main returns empty profiles when process.stdin.isTTY is false, before any GUI/RPC surface can display a device code. (extensions/github-copilot/index.ts:279, d30b8dccfda3)
  • Current CLI helper remains terminal-oriented: githubCopilotLoginCommand still throws when stdin is not an interactive TTY and renders the code through terminal prompts, which matches the PR's stated terminal-only non-change. (extensions/github-copilot/login.ts:123, d30b8dccfda3)
  • Existing framework can persist returned profiles: runProviderPluginAuthMethod passes prompter/openUrl into provider auth methods and persists every returned profile into the selected agent auth store. (src/plugins/provider-auth-choice.ts:229, d30b8dccfda3)
  • PR head adds a reusable device-flow helper: At PR head, runGitHubCopilotDeviceFlow validates the GitHub verification URL and user code, anchors expiry before UI callbacks, best-effort opens the URL, and returns typed authorized/access_denied/expired results. (extensions/github-copilot/login.ts:185, aea7d6650c80)
  • PR head wires provider auth to wizard/RPC hooks: At PR head, runGitHubCopilotAuth imports the new helper, shows the code via ctx.prompter.note, calls ctx.openUrl, and returns the token profile/default model instead of reading back from the auth store after a stdin-driven command. (extensions/github-copilot/index.ts:280, aea7d6650c80)
  • PR head updates contract coverage: The provider-auth contract now covers returned device credentials, prompter/openUrl rendering, non-TTY completion, access_denied, and expired_token outcomes. (src/plugin-sdk/test-helpers/provider-auth-contract.ts:407, aea7d6650c80)

Likely related people:

  • obviyus: Recent current-main history changed GitHub Copilot auth reuse and the provider-auth contract in the same files this PR updates. (role: recent GitHub Copilot auth maintainer; confidence: high; commits: 22c42b6b309d; files: extensions/github-copilot/index.ts, extensions/github-copilot/login.ts, src/plugin-sdk/test-helpers/provider-auth-contract.ts)
  • BunsDev: Introduced non-interactive GitHub Copilot token onboarding, which the PR body identifies as the complementary path that did not cover interactive wizard/RPC device auth. (role: adjacent feature introducer; confidence: medium; commits: 461c10bb512c; files: extensions/github-copilot/index.ts, extensions/github-copilot/index.test.ts, docs/providers/github-copilot.md)
  • vincentkoc: Moved the GitHub Copilot provider/login implementation into the bundled plugin boundary, including the current login.ts surface this PR extends. (role: provider ownership refactor author; confidence: medium; commits: 213198123042; files: extensions/github-copilot/index.ts, extensions/github-copilot/login.ts)
  • steipete: Recent main history shows plugin-SDK/runtime import refactors and raw-fetch guard maintenance around the affected provider and script surfaces. (role: recent adjacent maintainer; confidence: medium; commits: 4336a7f3a9c6, d8b9ace39cc1, f8faf40a9eab; files: extensions/github-copilot/index.ts, extensions/github-copilot/login.ts, scripts/check-no-raw-channel-fetch.mjs)

Remaining risk / open question:

  • PR head CI was not fully green at inspection time: one check had failed and several checks were still in progress, so logs and final CI state should be reviewed before merge.
  • This is auth-adjacent provider behavior, so maintainer/security review remains appropriate even though the diff did not show a concrete security regression.

Codex review notes: model gpt-5.5, reasoning high; reviewed against d30b8dccfda3.

@indierawk2k2
Copy link
Copy Markdown
Contributor Author

Pre-empting two of the three flags from the Codex review for reviewer convenience:

1. Allowlist shift correspondence (scripts/check-no-raw-channel-fetch.mjs)

The two updated entries point at the exact same fetch() callsites as before — only the line numbers changed because this PR added typed errors and the runGitHubCopilotDeviceFlow helper above them in login.ts.

-  bundledPluginCallsite("github-copilot", "login.ts", 48),
-  bundledPluginCallsite("github-copilot", "login.ts", 80),
+  bundledPluginCallsite("github-copilot", "login.ts", 69),
+  bundledPluginCallsite("github-copilot", "login.ts", 101),

Verification (byte-identical fetches):

Before (origin/main) After (this PR)
Device-code POST login.ts:48await fetch(DEVICE_CODE_URL, { method: "POST", ... login.ts:69await fetch(DEVICE_CODE_URL, { method: "POST", ...
Access-token POST login.ts:80await fetch(ACCESS_TOKEN_URL, { method: "POST", ... login.ts:101await fetch(ACCESS_TOKEN_URL, { method: "POST", ...

No new raw fetches were added, and no other files in the allowlist were modified.

2. models.test.ts baseline failures are not from this PR

This PR's diff against origin/main:

 extensions/github-copilot/index.ts                    |  54 +++---
 extensions/github-copilot/login.ts                    | 114 +++++++++++++-
 scripts/check-no-raw-channel-fetch.mjs                |   4 +-
 src/plugin-sdk/test-helpers/provider-auth-contract.ts | 175 +++++++++++++++---

git diff origin/main HEAD -- extensions/github-copilot/models.test.ts extensions/github-copilot/token.ts returns zero lines — neither the failing test file nor the source it imports (token.ts → resolveProviderEndpoint) is touched here. The 3 failures stem from a missing resolveProviderEndpoint export in the vitest mock for @openclaw/plugin-sdk/provider-model-shared and reproduce on a clean origin/main checkout.

3. Auth-adjacent maintainer/security review

Deferring to @joshavant and @openclaw/secops as appropriate — flagging here so it's on their radar.

@shanselman shanselman force-pushed the fix/github-copilot-wizard-auth branch from 7cc0e63 to a10b05b Compare April 29, 2026 23:31
Harsh Mehta and others added 2 commits April 29, 2026 23:35
The interactive GitHub Copilot device-code provider auth ("github-copilot".auth.run)
short-circuited with { profiles: [] } whenever process.stdin.isTTY was false and
otherwise delegated to githubCopilotLoginCommand, which drives readline against
stdin/stdout. That made the device flow unusable from the gateway RPC bridge
and any GUI wizard frontend (including the WinUI tray app), where there is no
controlling TTY and no shared stdin with the gateway process.

This change adds a high-level runGitHubCopilotDeviceFlow() helper in
extensions/github-copilot/login.ts that:

  - validates the GitHub verification URL (must be https://github.com/login/device)
    and trims/length-checks user_code before exposing them to the host UI;
  - exposes a small io interface (showCode + openUrl) so callers render the
    code through their own surface (ctx.prompter.note + ctx.openUrl);
  - returns a typed result of { authorized | access_denied | expired }
    instead of throwing for normal terminal states.

runGitHubCopilotAuth() in extensions/github-copilot/index.ts is rewritten to
use that helper: it drops the isTTY gate, emits the URL/code/expiry through
ctx.prompter.note, best-effort calls ctx.openUrl, and returns the credential
inline via the returned profiles. The framework then persists it under the
correct agentDir, identical to the existing non-interactive path that Val
introduced in 461c10b.

requestDeviceCode and pollForAccessToken stay private to login.ts (no api.ts
export). The CLI entry githubCopilotLoginCommand is unchanged.

The provider auth contract tests in
src/plugin-sdk/test-helpers/provider-auth-contract.ts are updated to reflect
the new contract: the suite stubs fetch for the github.com device-code and
access-token endpoints and asserts that

  - the credential is sourced from the device flow response (not a post-hoc
    auth-store readback);
  - the provider drives the host UI through ctx.prompter.note + ctx.openUrl
    (no stdin/stdout dependency);
  - auth completes when process.stdin.isTTY is false (GUI/RPC clients).

Validation
- pnpm tsgo:extensions
- pnpm test:extension github-copilot (all github-copilot tests pass; the
  3 failures in models.test.ts are pre-existing on main, unrelated to this
  change: vitest mock for openclaw/plugin-sdk/provider-model-shared is missing
  the resolveProviderEndpoint export)
- End-to-end manual: ran a Windows tray-app onboarding wizard against a local
  gateway, picked GitHub Copilot, browser opened to the verification URL,
  authorization succeeded, token persisted under the correct agentDir as
  github-copilot:github, chat through the new credential worked.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@shanselman shanselman force-pushed the fix/github-copilot-wizard-auth branch from a10b05b to aea7d66 Compare April 29, 2026 23:37
@shanselman shanselman merged commit 36bb723 into openclaw:main Apr 29, 2026
74 of 76 checks passed
@shanselman
Copy link
Copy Markdown
Contributor

Merged via squash.

Thanks @indierawk2k2!

lxe pushed a commit to lxe/openclaw that referenced this pull request May 6, 2026
Merged via squash.

Prepared head SHA: aea7d66
Co-authored-by: indierawk2k2 <18598712+indierawk2k2@users.noreply.github.com>
Co-authored-by: shanselman <2892+shanselman@users.noreply.github.com>
Reviewed-by: @shanselman
github-actions Bot pushed a commit to Desicool/openclaw that referenced this pull request May 9, 2026
Merged via squash.

Prepared head SHA: aea7d66
Co-authored-by: indierawk2k2 <18598712+indierawk2k2@users.noreply.github.com>
Co-authored-by: shanselman <2892+shanselman@users.noreply.github.com>
Reviewed-by: @shanselman
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

scripts Repository scripts size: M

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants