

























@@ -11,6 +11,7 @@ import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
1111const CLIENT_ID = "Iv1.b507a08c87ecfe98";
1212const DEVICE_CODE_URL = "https://github.com/login/device/code";
1313const ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token";
14+const GITHUB_DEVICE_VERIFICATION_URL = "https://github.com/login/device";
14151516type DeviceCodeResponse = {
1617device_code: string;
@@ -32,6 +33,26 @@ type DeviceTokenResponse =
3233error_uri?: string;
3334};
343536+const GITHUB_DEVICE_ACCESS_DENIED = Symbol("github-device-access-denied");
37+const GITHUB_DEVICE_EXPIRED = Symbol("github-device-expired");
38+39+class GitHubDeviceFlowError extends Error {
40+readonly kind: symbol;
41+constructor(kind: symbol, message: string) {
42+super(message);
43+this.kind = kind;
44+this.name = "GitHubDeviceFlowError";
45+}
46+}
47+48+function isGitHubDeviceAccessDeniedError(err: unknown): boolean {
49+return err instanceof GitHubDeviceFlowError && err.kind === GITHUB_DEVICE_ACCESS_DENIED;
50+}
51+52+function isGitHubDeviceExpiredError(err: unknown): boolean {
53+return err instanceof GitHubDeviceFlowError && err.kind === GITHUB_DEVICE_EXPIRED;
54+}
55+3556function parseJsonResponse(value: unknown): Record<string, unknown> {
3657if (!value || typeof value !== "object") {
3758throw new Error("Unexpected response from GitHub");
@@ -105,15 +126,100 @@ async function pollForAccessToken(params: {
105126continue;
106127}
107128if (err === "expired_token") {
108-throw new Error("GitHub device code expired; run login again");
129+throw new GitHubDeviceFlowError(
130+GITHUB_DEVICE_EXPIRED,
131+"GitHub device code expired; run login again",
132+);
109133}
110134if (err === "access_denied") {
111-throw new Error("GitHub login cancelled");
135+throw new GitHubDeviceFlowError(GITHUB_DEVICE_ACCESS_DENIED, "GitHub login cancelled");
112136}
113137throw new Error(`GitHub device flow error: ${err}`);
114138}
115139116-throw new Error("GitHub device code expired; run login again");
140+throw new GitHubDeviceFlowError(
141+GITHUB_DEVICE_EXPIRED,
142+"GitHub device code expired; run login again",
143+);
144+}
145+146+function normalizeGitHubDeviceVerificationUrl(raw: string): string {
147+let parsed: URL;
148+try {
149+parsed = new URL(raw);
150+} catch {
151+throw new Error("GitHub device flow returned an invalid verification URL");
152+}
153+154+if (
155+parsed.protocol !== "https:" ||
156+parsed.hostname !== "github.com" ||
157+parsed.pathname !== "/login/device" ||
158+parsed.username ||
159+parsed.password
160+) {
161+throw new Error("GitHub device flow returned an unexpected verification URL");
162+}
163+164+return GITHUB_DEVICE_VERIFICATION_URL;
165+}
166+167+function normalizeGitHubDeviceUserCode(raw: string): string {
168+const userCode = raw.trim();
169+if (!userCode || userCode.length > 64) {
170+throw new Error("GitHub device flow returned an invalid user code");
171+}
172+return userCode;
173+}
174+175+export type GitHubCopilotDeviceFlowResult =
176+| { status: "authorized"; accessToken: string }
177+| { status: "access_denied" }
178+| { status: "expired" };
179+180+export type GitHubCopilotDeviceFlowIO = {
181+showCode(args: { verificationUrl: string; userCode: string; expiresInMs: number }): Promise<void>;
182+openUrl?: (url: string) => Promise<void>;
183+};
184+185+export async function runGitHubCopilotDeviceFlow(
186+io: GitHubCopilotDeviceFlowIO,
187+): Promise<GitHubCopilotDeviceFlowResult> {
188+const device = await requestDeviceCode({ scope: "read:user" });
189+const verificationUrl = normalizeGitHubDeviceVerificationUrl(device.verification_uri);
190+const userCode = normalizeGitHubDeviceUserCode(device.user_code);
191+const expiresInMs = device.expires_in * 1000;
192+// Anchor expiry to when GitHub issued the code, not when the UI finishes prompting.
193+const expiresAt = Date.now() + expiresInMs;
194+195+await io.showCode({
196+ verificationUrl,
197+ userCode,
198+ expiresInMs,
199+});
200+201+try {
202+await io.openUrl?.(verificationUrl);
203+} catch {
204+// The code and URL have already been shown. Browser launch is best-effort.
205+}
206+207+try {
208+const accessToken = await pollForAccessToken({
209+deviceCode: device.device_code,
210+intervalMs: Math.max(1000, device.interval * 1000),
211+ expiresAt,
212+});
213+return { status: "authorized", accessToken };
214+} catch (err) {
215+if (isGitHubDeviceAccessDeniedError(err)) {
216+return { status: "access_denied" };
217+}
218+if (isGitHubDeviceExpiredError(err)) {
219+return { status: "expired" };
220+}
221+throw err;
222+}
117223}
118224119225export async function githubCopilotLoginCommand(
@@ -166,8 +272,6 @@ export async function githubCopilotLoginCommand(
166272type: "token",
167273provider: "github-copilot",
168274token: accessToken,
169-// GitHub device flow token doesn't reliably include expiry here.
170-// Leave expires unset; we'll exchange into Copilot token plus expiry later.
171275},
172276agentDir: opts.agentDir,
173277});
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。