diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 264ea6cf6c9f..35896bbd2f8b 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -6,7 +6,7 @@ e5e67ddf3cab38fcbf9220bc3160715897e2709d9a9ff6ff36f1ecc9453c2367 module/agent-c 74daa746deb548379d3f0d6eac3c4d082df1034c4360cc03bf51fee0f10a2e4d module/agent-harness 95a907e1c33305b9473be64cc8d723e1a12b909eda86b15b94cee91879fb6a89 module/agent-harness-runtime 5168648cd946abad8a92822889f13ceacc87ed502314a66190d0b1eb8ebe76ea module/agent-media-payload -2dcb4d62d90e5d71594f6b843c97534509a154784e378fb1c75bd86b5122b710 module/agent-runtime +fd54eb654443d646d6430d2be99d1f25c32701f1c45aaa870ffe74aefc7d7f00 module/agent-runtime 56b6d5fb6af3d95af1200065aca2e7d4f59e5fa59740505fe6ff433077ef6646 module/allow-from 55cea5390d68839ca7768b4a0cc570b17b65fa0fa3bc4d76130ef0f16cb79ede module/allowlist-config-edit 7ddd81bd5f55de9adf64bf4d92d012f24b37b6da0a72805a3a220d8feff24ca3 module/approval-auth-runtime diff --git a/docs/plugins/sdk-provider-plugins.md b/docs/plugins/sdk-provider-plugins.md index fcf890e2ec17..ebc559085c27 100644 --- a/docs/plugins/sdk-provider-plugins.md +++ b/docs/plugins/sdk-provider-plugins.md @@ -690,6 +690,7 @@ catalog, API-key auth, and dynamic model resolution. | `resolveTransportTurnState` | Native per-turn headers/metadata | | `resolveWebSocketSessionPolicy` | Native WS session headers/cool-down | | `formatApiKey` | Custom runtime token shape | + | `loginOAuth` | Callback-based OAuth login for the session SDK `AuthStorage` API | | `refreshOAuth` | Custom OAuth refresh | | `buildAuthDoctorHint` | Auth repair guidance | | `matchesContextOverflowError` | Provider-owned overflow detection | diff --git a/extensions/github-copilot/domain.ts b/extensions/github-copilot/domain.ts index fe5e87e3d9ac..1a8d1971cb96 100644 --- a/extensions/github-copilot/domain.ts +++ b/extensions/github-copilot/domain.ts @@ -1,14 +1,30 @@ // GitHub Copilot data-residency domain resolution. // -// Lives inside the provider so the shared plugin SDK only needs to export the -// security-critical host allowlist (`normalizeGithubCopilotDomain`). The -// env/config precedence below is GitHub Copilot provider policy, not a -// plugin-SDK contract, so it is intentionally not part of the SDK surface. +// The allowlist and env/config precedence are provider policy. Deprecated SDK +// facades keep their dated compatibility copy until its removal window closes. import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { normalizeGithubCopilotDomain } from "openclaw/plugin-sdk/provider-auth"; /** Public GitHub Copilot host used when no data-residency domain is configured. */ export const PUBLIC_GITHUB_COPILOT_DOMAIN = "github.com"; +const GHE_DATA_RESIDENCY_HOST = /^[a-z0-9-]+\.ghe\.com$/; + +export function isSupportedGithubCopilotDomain(raw: string | undefined | null): boolean { + const trimmed = (raw ?? "").trim().toLowerCase(); + if (!trimmed) { + return true; + } + return ( + /^[a-z0-9.-]+$/.test(trimmed) && + (trimmed === PUBLIC_GITHUB_COPILOT_DOMAIN || GHE_DATA_RESIDENCY_HOST.test(trimmed)) + ); +} + +export function normalizeGithubCopilotDomain(raw: string | undefined | null): string { + const trimmed = (raw ?? "").trim().toLowerCase(); + return trimmed && isSupportedGithubCopilotDomain(trimmed) + ? trimmed + : PUBLIC_GITHUB_COPILOT_DOMAIN; +} function readConfiguredGithubCopilotDomain(config?: OpenClawConfig): string | undefined { const params = config?.models?.providers?.["github-copilot"]?.params; diff --git a/extensions/github-copilot/index.test.ts b/extensions/github-copilot/index.test.ts index 39189054f782..f50c93acdcc6 100644 --- a/extensions/github-copilot/index.test.ts +++ b/extensions/github-copilot/index.test.ts @@ -190,6 +190,80 @@ describe("github-copilot plugin", () => { ).toBe("durable-github-token"); }); + it("normalizes legacy OAuth profiles without losing tenant metadata", async () => { + const provider = registerProviderWithPluginConfig({}); + const credential = { + type: "oauth" as const, + provider: "github-copilot", + access: "short-lived-copilot-token", + refresh: "durable-github-token", + expires: 1, + enterpriseUrl: "acme.ghe.com", + }; + + await expect(provider.refreshOAuth?.(credential)).resolves.toEqual({ + ...credential, + access: "durable-github-token", + expires: MAX_DATE_TIMESTAMP_MS, + }); + expect(credential).toEqual({ + type: "oauth", + provider: "github-copilot", + access: "short-lived-copilot-token", + refresh: "durable-github-token", + expires: 1, + enterpriseUrl: "acme.ghe.com", + }); + }); + + it("rejects unsafe legacy OAuth tenants before formatting or refresh", async () => { + const provider = registerProviderWithPluginConfig({}); + const credential = { + type: "oauth" as const, + provider: "github-copilot", + access: "short-lived-copilot-token", + refresh: "durable-github-token", + expires: 1, + enterpriseUrl: "attacker.example", + }; + + expect(() => provider.formatApiKey?.(credential)).toThrow(/attacker\.example/); + await expect(provider.refreshOAuth?.(credential)).rejects.toThrow(/attacker\.example/); + }); + + it("moves unsupported legacy OAuth doctor guidance into the provider", async () => { + const provider = registerProviderWithPluginConfig({}); + const store = { + version: 1, + profiles: { + "github-copilot:default": { + type: "oauth" as const, + provider: "github-copilot", + access: "fake", + refresh: "fake", + expires: 0, + enterpriseUrl: "attacker.example", + }, + }, + }; + + expect( + await provider.buildAuthDoctorHint?.({ + store, + provider: "github-copilot", + profileId: "github-copilot:default", + }), + ).toContain("unsupported enterprise domain"); + store.profiles["github-copilot:default"].enterpriseUrl = "acme.ghe.com"; + expect( + await provider.buildAuthDoctorHint?.({ + store, + provider: "github-copilot", + profileId: "github-copilot:default", + }), + ).toBeUndefined(); + }); + it("preserves the source token supplied by the auth layer for runtime auth", async () => { mocks.resolveCopilotRuntimeAuth.mockResolvedValueOnce({ apiKey: "github-source-token", @@ -228,6 +302,38 @@ describe("github-copilot plugin", () => { }); }); + it("carries a legacy OAuth tenant into request-time routing", async () => { + mocks.resolveCopilotRuntimeAuth.mockResolvedValueOnce({ + apiKey: "durable-github-token", + baseUrl: "https://copilot-api.acme.ghe.com", + }); + const provider = registerProviderWithPluginConfig({}); + const apiKey = provider.formatApiKey?.({ + type: "oauth", + provider: "github-copilot", + access: "short-lived-copilot-token", + refresh: "durable-github-token", + expires: MAX_DATE_TIMESTAMP_MS, + enterpriseUrl: "acme.ghe.com", + }); + + await provider.prepareRuntimeAuth({ + config: {}, + env: {}, + provider: "github-copilot", + modelId: "gpt-5-mini", + model: { id: "gpt-5-mini", provider: "github-copilot" }, + apiKey, + authMode: "oauth", + } as never); + + expect(mocks.resolveCopilotRuntimeAuth).toHaveBeenCalledWith({ + githubToken: "durable-github-token", + env: {}, + githubDomain: "acme.ghe.com", + }); + }); + it("owns Claude replay thinking cleanup", () => { const provider = registerProviderWithPluginConfig({}); const messages = [ diff --git a/extensions/github-copilot/index.ts b/extensions/github-copilot/index.ts index b769ae6d4301..830fd01b10c5 100644 --- a/extensions/github-copilot/index.ts +++ b/extensions/github-copilot/index.ts @@ -14,17 +14,27 @@ import { coerceSecretRef, ensureAuthProfileStore, listProfilesForProvider, - normalizeGithubCopilotDomain, normalizeOptionalSecretInput, resolveDefaultSecretProviderAlias, upsertAuthProfileWithLock, } from "openclaw/plugin-sdk/provider-auth"; import { resolveFirstGithubToken } from "./auth.js"; -import { PUBLIC_GITHUB_COPILOT_DOMAIN, resolveGithubCopilotDomain } from "./domain.js"; +import { + normalizeGithubCopilotDomain, + PUBLIC_GITHUB_COPILOT_DOMAIN, + resolveGithubCopilotDomain, +} from "./domain.js"; import { createGithubCopilotDynamicModelHooks } from "./dynamic-models.js"; import { githubCopilotMemoryEmbeddingProviderAdapter } from "./embeddings.js"; import { DEFAULT_COPILOT_MODEL, resolveCopilotExtendedThinkingLevels } from "./model-metadata.js"; import { PROVIDER_ID } from "./models.js"; +import { + buildGithubCopilotAuthDoctorHint, + formatGithubCopilotApiKey, + loginGithubCopilotOAuth, + parseGithubCopilotApiKey, + refreshGithubCopilotOAuth, +} from "./oauth.js"; import { buildGithubCopilotReplayPolicy, sanitizeGithubCopilotReplayHistory, @@ -684,7 +694,10 @@ export default definePluginEntry({ prepareDynamicModel: dynamicModels.prepareDynamicModel, resolveDynamicModel: dynamicModels.resolveDynamicModel, preferRuntimeResolvedModel: dynamicModels.preferRuntimeResolvedModel, - formatApiKey: (credential) => (credential.type === "oauth" ? credential.refresh.trim() : ""), + formatApiKey: formatGithubCopilotApiKey, + loginOAuth: loginGithubCopilotOAuth, + refreshOAuth: async (credential) => refreshGithubCopilotOAuth(credential), + buildAuthDoctorHint: buildGithubCopilotAuthDoctorHint, wrapStreamFn: wrapCopilotProviderStream, buildReplayPolicy: ({ modelId }) => buildGithubCopilotReplayPolicy(modelId), sanitizeReplayHistory: sanitizeGithubCopilotReplayHistory, @@ -702,11 +715,16 @@ export default definePluginEntry({ }; }, prepareRuntimeAuth: async (ctx) => { + const source = parseGithubCopilotApiKey(ctx.apiKey); const { resolveCopilotRuntimeAuth } = await loadGithubCopilotRuntime(); const auth = await resolveCopilotRuntimeAuth({ - githubToken: ctx.apiKey, + githubToken: source.githubToken, env: ctx.env, - githubDomain: resolveGithubCopilotDomain({ env: ctx.env, config: ctx.config }), + githubDomain: resolveGithubCopilotDomain({ + env: ctx.env, + explicit: source.githubDomain, + config: ctx.config, + }), }); return { apiKey: auth.apiKey, diff --git a/extensions/github-copilot/oauth.test.ts b/extensions/github-copilot/oauth.test.ts new file mode 100644 index 000000000000..87979c809f25 --- /dev/null +++ b/extensions/github-copilot/oauth.test.ts @@ -0,0 +1,75 @@ +import { MAX_DATE_TIMESTAMP_MS } from "openclaw/plugin-sdk/number-runtime"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { runGitHubCopilotDeviceFlow } from "./login.js"; + +const runDeviceFlow = vi.hoisted(() => vi.fn()); + +vi.mock("./login.js", () => ({ runGitHubCopilotDeviceFlow: runDeviceFlow })); + +import { loginGithubCopilotOAuth } from "./oauth.js"; + +describe("github-copilot session OAuth adapter", () => { + beforeEach(() => { + runDeviceFlow.mockReset(); + }); + + it("adapts the provider device flow to callback-based AuthStorage login", async () => { + runDeviceFlow.mockImplementationOnce(async (io) => { + await io.showCode({ + verificationUrl: "https://github.com/login/device", + userCode: "ABCD-1234", + expiresInMs: 60_000, + }); + return { status: "authorized", accessToken: "durable-github-token" }; + }); + const onAuth = vi.fn(); + const onProgress = vi.fn(); + + await expect( + loginGithubCopilotOAuth({ + onAuth, + onProgress, + onPrompt: vi.fn(async () => ""), + }), + ).resolves.toEqual({ + access: "durable-github-token", + refresh: "durable-github-token", + expires: MAX_DATE_TIMESTAMP_MS, + }); + expect(runDeviceFlow).toHaveBeenCalledWith(expect.any(Object), "github.com"); + expect(onAuth).toHaveBeenCalledWith({ + url: "https://github.com/login/device", + instructions: "Enter code: ABCD-1234", + }); + expect(onProgress).toHaveBeenCalledWith("Waiting for GitHub authorization..."); + }); + + it("preserves a validated enterprise tenant on the returned credential", async () => { + runDeviceFlow.mockResolvedValueOnce({ + status: "authorized", + accessToken: "tenant-github-token", + }); + + await expect( + loginGithubCopilotOAuth({ + onAuth: vi.fn(), + onPrompt: vi.fn(async () => "https://acme.ghe.com"), + }), + ).resolves.toMatchObject({ + access: "tenant-github-token", + refresh: "tenant-github-token", + enterpriseUrl: "acme.ghe.com", + }); + expect(runDeviceFlow).toHaveBeenCalledWith(expect.any(Object), "acme.ghe.com"); + }); + + it("rejects an unsafe enterprise origin before starting the device flow", async () => { + await expect( + loginGithubCopilotOAuth({ + onAuth: vi.fn(), + onPrompt: vi.fn(async () => "https://attacker.example"), + }), + ).rejects.toThrow("Unsupported GitHub Enterprise domain"); + expect(runDeviceFlow).not.toHaveBeenCalled(); + }); +}); diff --git a/extensions/github-copilot/oauth.ts b/extensions/github-copilot/oauth.ts new file mode 100644 index 000000000000..579b913c043a --- /dev/null +++ b/extensions/github-copilot/oauth.ts @@ -0,0 +1,150 @@ +import { MAX_DATE_TIMESTAMP_MS } from "openclaw/plugin-sdk/number-runtime"; +import type { ProviderAuthDoctorHintContext } from "openclaw/plugin-sdk/plugin-entry"; +import type { OAuthCredential } from "openclaw/plugin-sdk/provider-auth"; +import type { + OAuthCredentials, + OAuthLoginCallbacks, +} from "openclaw/plugin-sdk/provider-oauth-runtime"; +import { + isSupportedGithubCopilotDomain, + normalizeGithubCopilotDomain, + PUBLIC_GITHUB_COPILOT_DOMAIN, +} from "./domain.js"; +import { runGitHubCopilotDeviceFlow } from "./login.js"; + +const LEGACY_OAUTH_KEY_PREFIX = "openclaw-github-copilot-oauth:v1:"; + +function parseLegacyEnterpriseInput(raw: string): string | null { + const trimmed = raw.trim(); + if (!trimmed) { + return null; + } + try { + const parsed = trimmed.includes("://") ? new URL(trimmed) : new URL(`https://${trimmed}`); + return parsed.hostname.toLowerCase(); + } catch { + return null; + } +} + +function requireSupportedEnterpriseDomain(raw: string): string { + const domain = parseLegacyEnterpriseInput(raw); + if (!domain || !isSupportedGithubCopilotDomain(domain)) { + throw new Error( + `Unsupported GitHub Enterprise domain "${raw.trim()}". Use github.com or a *.ghe.com data-residency tenant.`, + ); + } + return normalizeGithubCopilotDomain(domain); +} + +export async function loginGithubCopilotOAuth( + callbacks: OAuthLoginCallbacks, +): Promise { + const input = await callbacks.onPrompt({ + message: "GitHub Enterprise URL/domain (blank for github.com)", + placeholder: "company.ghe.com", + allowEmpty: true, + }); + if (callbacks.signal?.aborted) { + throw new Error("GitHub Copilot login cancelled"); + } + const enterpriseUrl = input.trim() ? requireSupportedEnterpriseDomain(input) : undefined; + const domain = enterpriseUrl ?? PUBLIC_GITHUB_COPILOT_DOMAIN; + callbacks.onProgress?.("Waiting for GitHub authorization..."); + const result = await runGitHubCopilotDeviceFlow( + { + showCode: async ({ verificationUrl, userCode }) => { + callbacks.onAuth({ url: verificationUrl, instructions: `Enter code: ${userCode}` }); + }, + ...(callbacks.signal ? { signal: callbacks.signal } : {}), + }, + domain, + ); + if (result.status === "access_denied") { + throw new Error("GitHub Copilot login cancelled"); + } + if (result.status === "expired") { + throw new Error("GitHub Copilot device code expired; retry login"); + } + return { + refresh: result.accessToken, + access: result.accessToken, + expires: MAX_DATE_TIMESTAMP_MS, + ...(enterpriseUrl ? { enterpriseUrl } : {}), + }; +} + +export function refreshGithubCopilotOAuth(credential: OAuthCredential) { + if (credential.enterpriseUrl && !isSupportedGithubCopilotDomain(credential.enterpriseUrl)) { + throw new Error( + `Refusing to refresh GitHub Copilot OAuth for unsupported enterprise domain "${credential.enterpriseUrl}". Re-authenticate with github.com or a *.ghe.com tenant.`, + ); + } + return { + ...credential, + access: credential.refresh, + expires: MAX_DATE_TIMESTAMP_MS, + }; +} + +export function formatGithubCopilotApiKey(credential: { + type: string; + refresh?: string; + enterpriseUrl?: string; +}): string { + if (credential.type !== "oauth" || typeof credential.refresh !== "string") { + return ""; + } + const token = credential.refresh.trim(); + if (!credential.enterpriseUrl) { + return token; + } + const githubDomain = requireSupportedEnterpriseDomain(credential.enterpriseUrl); + return `${LEGACY_OAUTH_KEY_PREFIX}${JSON.stringify({ token, githubDomain })}`; +} + +export function parseGithubCopilotApiKey(value: string): { + githubToken: string; + githubDomain?: string; +} { + if (!value.startsWith(LEGACY_OAUTH_KEY_PREFIX)) { + return { githubToken: value }; + } + let parsed: unknown; + try { + parsed = JSON.parse(value.slice(LEGACY_OAUTH_KEY_PREFIX.length)); + } catch { + throw new Error("Invalid GitHub Copilot legacy OAuth credential metadata"); + } + if (!parsed || typeof parsed !== "object") { + throw new Error("Invalid GitHub Copilot legacy OAuth credential metadata"); + } + const { token, githubDomain } = parsed as Record; + if ( + typeof token !== "string" || + !token.trim() || + typeof githubDomain !== "string" || + !isSupportedGithubCopilotDomain(githubDomain) + ) { + throw new Error("Invalid GitHub Copilot legacy OAuth credential metadata"); + } + return { githubToken: token, githubDomain: normalizeGithubCopilotDomain(githubDomain) }; +} + +export function buildGithubCopilotAuthDoctorHint( + context: ProviderAuthDoctorHintContext, +): string | undefined { + const profiles = context.profileId + ? [context.store.profiles[context.profileId]] + : Object.values(context.store.profiles); + const unsupported = profiles.some( + (profile) => + profile?.type === "oauth" && + profile.provider.trim().toLowerCase() === "github-copilot" && + !isSupportedGithubCopilotDomain(profile.enterpriseUrl), + ); + if (!unsupported) { + return undefined; + } + return "This GitHub Copilot OAuth profile has an unsupported enterprise domain and can no longer refresh. Remove the legacy profile before re-authenticating with a supported host (github.com or a *.ghe.com tenant): openclaw models auth login --provider github-copilot --force."; +} diff --git a/src/agents/auth-profiles.chutes.test.ts b/src/agents/auth-profiles.chutes.test.ts index f5c7cb72b514..f927333e5e81 100644 --- a/src/agents/auth-profiles.chutes.test.ts +++ b/src/agents/auth-profiles.chutes.test.ts @@ -10,8 +10,9 @@ import type { AuthProfileStore } from "./auth-profiles.js"; const CHUTES_TOKEN_ENDPOINT = "https://api.chutes.ai/idp/token"; vi.mock("../plugins/provider-runtime.runtime.js", () => ({ + buildProviderAuthDoctorHintWithPlugin: async () => undefined, formatProviderAuthProfileApiKeyWithPlugin: async () => undefined, - refreshProviderOAuthCredentialWithPlugin: async () => null, + resolveProviderOAuthCredentialWithPlugin: async () => ({ status: "unowned" }), })); vi.mock("../plugins/provider-runtime.js", () => ({ diff --git a/src/agents/auth-profiles.doctor.test.ts b/src/agents/auth-profiles.doctor.test.ts index 6ae0e68d7b97..5a5d597f32f2 100644 --- a/src/agents/auth-profiles.doctor.test.ts +++ b/src/agents/auth-profiles.doctor.test.ts @@ -42,72 +42,15 @@ describe("formatAuthDoctorHint", () => { expect(buildProviderAuthDoctorHintWithPluginMock).not.toHaveBeenCalled(); }); - it("guides an unsupported github-copilot enterprise profile to login again", async () => { - const hint = await formatAuthDoctorHint({ - store: { - version: 1, - profiles: { - "github-copilot:default": { - type: "oauth", - provider: "github-copilot", - access: "fake", - refresh: "fake", - expires: 0, - enterpriseUrl: "attacker.example", - }, - }, - }, - provider: "github-copilot", - profileId: "github-copilot:default", - }); + it("delegates other provider hints to the provider plugin", async () => { + buildProviderAuthDoctorHintWithPluginMock.mockResolvedValueOnce("Provider-owned repair"); - expect(hint).toContain("unsupported enterprise domain"); - expect(hint).toContain("openclaw models auth login --provider github-copilot --force"); - expect(buildProviderAuthDoctorHintWithPluginMock).not.toHaveBeenCalled(); - }); - - it("accepts a github-copilot profile on a ghe.com tenant", async () => { - const hint = await formatAuthDoctorHint({ - store: { - version: 1, - profiles: { - "github-copilot:default": { - type: "oauth", - provider: "github-copilot", - access: "fake", - refresh: "fake", - expires: 0, - enterpriseUrl: "acme.ghe.com", - }, - }, - }, - provider: "github-copilot", - profileId: "github-copilot:default", - }); - - expect(hint).not.toContain("unsupported enterprise domain"); - expect(buildProviderAuthDoctorHintWithPluginMock).toHaveBeenCalledOnce(); - }); - - it("accepts a public github.com profile", async () => { - const hint = await formatAuthDoctorHint({ - store: { - version: 1, - profiles: { - "github-copilot:default": { - type: "oauth", - provider: "github-copilot", - access: "fake", - refresh: "fake", - expires: 0, - }, - }, - }, - provider: "github-copilot", - profileId: "github-copilot:default", - }); - - expect(hint).not.toContain("unsupported enterprise domain"); + await expect( + formatAuthDoctorHint({ + store: { version: 1, profiles: {} }, + provider: "demo", + }), + ).resolves.toBe("Provider-owned repair"); expect(buildProviderAuthDoctorHintWithPluginMock).toHaveBeenCalledOnce(); }); }); diff --git a/src/agents/auth-profiles/doctor.ts b/src/agents/auth-profiles/doctor.ts index 500c65146f15..de6ef5ba383f 100644 --- a/src/agents/auth-profiles/doctor.ts +++ b/src/agents/auth-profiles/doctor.ts @@ -5,26 +5,12 @@ */ import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; -import { isSupportedGithubCopilotDomain } from "../../plugin-sdk/github-copilot-domain.js"; import { buildProviderAuthDoctorHintWithPlugin } from "../../plugins/provider-runtime.runtime.js"; import type { AuthProfileStore } from "./types.js"; const QWEN_PORTAL_OAUTH_MIGRATION_HINT = "Legacy Qwen Portal OAuth profiles are not refreshable. Re-authenticate with a current Qwen API key: openclaw onboard --auth-choice qwen-api-key."; -function hasUnsupportedGithubCopilotEnterpriseDomain( - store: AuthProfileStore, - profileId?: string, -): boolean { - const profiles = profileId ? [store.profiles[profileId]] : Object.values(store.profiles); - return profiles.some( - (profile) => - profile?.type === "oauth" && - normalizeProviderId(profile.provider) === "github-copilot" && - !isSupportedGithubCopilotDomain(profile.enterpriseUrl), - ); -} - // Qwen Portal OAuth changed credential behavior; old profiles need an explicit // local hint before falling back to provider plugin doctor hints. function hasLegacyQwenPortalOAuthProfile(store: AuthProfileStore, profileId?: string): boolean { @@ -55,13 +41,6 @@ async function formatAuthDoctorHintWithPluginBuilder( ) { return QWEN_PORTAL_OAUTH_MIGRATION_HINT; } - if ( - normalizedProvider === "github-copilot" && - hasUnsupportedGithubCopilotEnterpriseDomain(params.store, params.profileId) - ) { - return "This GitHub Copilot OAuth profile has an unsupported enterprise domain and can no longer refresh. Remove the legacy profile before re-authenticating with a supported host (github.com or a *.ghe.com tenant): openclaw models auth login --provider github-copilot --force."; - } - const pluginHint = await buildPluginHint({ provider: normalizedProvider, context: { diff --git a/src/agents/auth-profiles/oauth-common-mocks.test-support.ts b/src/agents/auth-profiles/oauth-common-mocks.test-support.ts index 0c9fe223e090..fb86f97b2207 100644 --- a/src/agents/auth-profiles/oauth-common-mocks.test-support.ts +++ b/src/agents/auth-profiles/oauth-common-mocks.test-support.ts @@ -9,9 +9,9 @@ import type { OAuthCredential } from "./types.js"; const oauthProviderRuntimeMocks = vi.hoisted(() => { vi.resetModules(); return { - refreshProviderOAuthCredentialWithPluginMock: vi.fn( - async (_params?: { context?: unknown }) => undefined, - ), + refreshProviderOAuthCredentialWithPluginMock: vi.fn< + (_params?: { context?: unknown }) => Promise + >(async () => undefined), formatProviderAuthProfileApiKeyWithPluginMock: vi.fn(() => undefined), }; }); @@ -32,8 +32,14 @@ vi.mock("../../plugins/provider-runtime.runtime.js", () => ({ formatProviderAuthProfileApiKeyWithPlugin: (params: { context?: { access?: string } }) => oauthProviderRuntimeMocks.formatProviderAuthProfileApiKeyWithPluginMock() ?? params?.context?.access, - refreshProviderOAuthCredentialWithPlugin: - oauthProviderRuntimeMocks.refreshProviderOAuthCredentialWithPluginMock, + resolveProviderOAuthCredentialWithPlugin: async (params: { credential: OAuthCredential }) => { + const credential = await oauthProviderRuntimeMocks.refreshProviderOAuthCredentialWithPluginMock( + { context: params.credential }, + ); + return credential + ? { status: "available", credential, apiKey: credential.access } + : { status: "unhandled" }; + }, })); vi.mock("./doctor.js", () => ({ diff --git a/src/agents/auth-profiles/oauth-manager.ts b/src/agents/auth-profiles/oauth-manager.ts index 8c165ca6eb81..776f07abe43d 100644 --- a/src/agents/auth-profiles/oauth-manager.ts +++ b/src/agents/auth-profiles/oauth-manager.ts @@ -42,7 +42,10 @@ type OAuthManagerAdapter = { credentials: OAuthCredential, context: { cfg?: OpenClawConfig; agentDir?: string }, ) => Promise; - refreshCredential: (credential: OAuthCredential) => Promise; + refreshCredential: ( + credential: OAuthCredential, + context: { cfg?: OpenClawConfig; agentDir?: string }, + ) => Promise; readBootstrapCredential: (params: { store: AuthProfileStore; profileId: string; @@ -608,7 +611,10 @@ export function createOAuthManager(adapter: OAuthManagerAdapter) { OAUTH_REFRESH_CALL_TIMEOUT_MS, async () => { params.attemptedCredentials?.push(credentialToRefresh); - const refreshed = await adapter.refreshCredential(credentialToRefresh); + const refreshed = await adapter.refreshCredential(credentialToRefresh, { + cfg: params.cfg, + agentDir: params.agentDir, + }); return refreshed ? ({ ...credentialToRefresh, diff --git a/src/agents/auth-profiles/oauth.fallback-to-main-agent.test.ts b/src/agents/auth-profiles/oauth.fallback-to-main-agent.test.ts index 3e09d98d42cc..0e5200b61a3f 100644 --- a/src/agents/auth-profiles/oauth.fallback-to-main-agent.test.ts +++ b/src/agents/auth-profiles/oauth.fallback-to-main-agent.test.ts @@ -43,7 +43,7 @@ vi.mock("../../plugins/provider-runtime.runtime.js", () => ({ buildProviderAuthDoctorHintWithPlugin: async () => null, formatProviderAuthProfileApiKeyWithPlugin: async (params: { context?: { access?: string } }) => params.context?.access, - refreshProviderOAuthCredentialWithPlugin: async () => null, + resolveProviderOAuthCredentialWithPlugin: async () => ({ status: "unhandled" }), })); vi.mock("../../plugins/provider-runtime.js", () => ({ diff --git a/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts b/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts index 7c26f7803193..6054752d1656 100644 --- a/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts +++ b/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts @@ -73,7 +73,14 @@ vi.mock("../../llm/oauth.js", () => ({ })); vi.mock("../../plugins/provider-runtime.runtime.js", () => ({ - refreshProviderOAuthCredentialWithPlugin: refreshProviderOAuthCredentialWithPluginMock, + resolveProviderOAuthCredentialWithPlugin: async (params: { credential: OAuthCredential }) => { + const credential = await refreshProviderOAuthCredentialWithPluginMock({ + context: params.credential, + }); + return credential + ? { status: "available", credential, apiKey: credential.access } + : { status: "unhandled" }; + }, formatProviderAuthProfileApiKeyWithPlugin: formatProviderAuthProfileApiKeyWithPluginMock, buildProviderAuthDoctorHintWithPlugin: buildProviderAuthDoctorHintWithPluginMock, })); diff --git a/src/agents/auth-profiles/oauth.test.ts b/src/agents/auth-profiles/oauth.test.ts index d4460df385b0..32216c385e80 100644 --- a/src/agents/auth-profiles/oauth.test.ts +++ b/src/agents/auth-profiles/oauth.test.ts @@ -22,9 +22,10 @@ vi.mock("../cli-credentials.js", () => ({ })); vi.mock("../../plugins/provider-runtime.runtime.js", () => ({ + buildProviderAuthDoctorHintWithPlugin: async () => undefined, formatProviderAuthProfileApiKeyWithPlugin: async (params: { context?: { access?: string } }) => params.context?.access, - refreshProviderOAuthCredentialWithPlugin: async () => null, + resolveProviderOAuthCredentialWithPlugin: async () => ({ status: "unhandled" }), })); let resolveApiKeyForProfile: typeof import("./oauth.js").resolveApiKeyForProfile; diff --git a/src/agents/auth-profiles/oauth.ts b/src/agents/auth-profiles/oauth.ts index 85a125d0491a..85faf0d7d1b1 100644 --- a/src/agents/auth-profiles/oauth.ts +++ b/src/agents/auth-profiles/oauth.ts @@ -15,9 +15,10 @@ import { type OAuthCredentials, type OAuthProviderId, } from "../../llm/oauth.js"; +import { OAuthProviderConfiguredUnavailableError } from "../../plugins/provider-runtime.errors.js"; import { formatProviderAuthProfileApiKeyWithPlugin, - refreshProviderOAuthCredentialWithPlugin, + resolveProviderOAuthCredentialWithPlugin, } from "../../plugins/provider-runtime.runtime.js"; import { secretRefKey } from "../../secrets/ref-contract.js"; import { resolveAuthProfileSecretOwnerId } from "../../secrets/runtime-auth-profile-owner.js"; @@ -188,13 +189,19 @@ type SecretDefaults = NonNullable["defaults"]; async function refreshOAuthCredential( credential: OAuthCredential, + context: { cfg?: OpenClawConfig } = {}, ): Promise { - const pluginRefreshed = await refreshProviderOAuthCredentialWithPlugin({ + const pluginResult = await resolveProviderOAuthCredentialWithPlugin({ provider: credential.provider, - context: credential, + config: context.cfg, + credential, + refresh: true, }); - if (pluginRefreshed) { - return pluginRefreshed; + if (pluginResult.status === "available") { + return pluginResult.credential; + } + if (pluginResult.status === "configured-unavailable") { + throw new OAuthProviderConfiguredUnavailableError(credential.provider); } if (credential.provider === "chutes") { @@ -216,8 +223,9 @@ async function refreshOAuthCredential( /** Refresh one OAuth credential and merge provider-returned token fields. */ export async function refreshOAuthCredentialForRuntime(params: { credential: OAuthCredential; + cfg?: OpenClawConfig; }): Promise { - const refreshed = await refreshOAuthCredential(params.credential); + const refreshed = await refreshOAuthCredential(params.credential, { cfg: params.cfg }); return refreshed ? { ...params.credential, diff --git a/src/agents/sessions/auth-storage-oauth-registry.ts b/src/agents/sessions/auth-storage-oauth-registry.ts index 88799b7a0132..9d109b68e1c6 100644 --- a/src/agents/sessions/auth-storage-oauth-registry.ts +++ b/src/agents/sessions/auth-storage-oauth-registry.ts @@ -1,4 +1,14 @@ import { OAuthProviderRegistry } from "../../llm/utils/oauth/index.js"; +import type { + OAuthCredentials, + OAuthLoginCallbacks, + OAuthProviderId, +} from "../../llm/utils/oauth/types.js"; +import { OAuthProviderConfiguredUnavailableError } from "../../plugins/provider-runtime.errors.js"; +import { + loginProviderOAuthWithPlugin, + resolveProviderOAuthCredentialWithPlugin, +} from "../../plugins/provider-runtime.runtime.js"; // Values belong to one AuthStorage object. The weak attachment keeps ModelRegistry // on the same registry without adding lifecycle methods to the public SDK class. @@ -12,3 +22,40 @@ export function getAuthStorageOAuthProviderRegistry(authStorage: object): OAuthP } return registry; } + +export async function loginAuthStorageOAuthProvider( + authStorage: object, + providerId: OAuthProviderId, + callbacks: OAuthLoginCallbacks, +): Promise { + const provider = getAuthStorageOAuthProviderRegistry(authStorage).get(providerId); + if (provider) { + return await provider.login(callbacks); + } + const resolved = await loginProviderOAuthWithPlugin({ provider: providerId, context: callbacks }); + if (resolved.status === "unowned") { + throw new Error(`Unknown OAuth provider: ${providerId}`); + } + if (resolved.status !== "available") { + throw new OAuthProviderConfiguredUnavailableError(providerId); + } + return resolved.credentials; +} + +export async function resolveAuthStoragePluginOAuthCredential( + providerId: OAuthProviderId, + credential: OAuthCredentials, + refresh: boolean, +): Promise<{ apiKey: string; newCredentials: OAuthCredentials } | null> { + const resolved = await resolveProviderOAuthCredentialWithPlugin({ + provider: providerId, + credential: { ...credential, type: "oauth", provider: providerId }, + refresh, + }); + if (resolved.status === "configured-unavailable") { + throw new OAuthProviderConfiguredUnavailableError(providerId); + } + return resolved.status === "available" + ? { apiKey: resolved.apiKey, newCredentials: resolved.credential } + : null; +} diff --git a/src/agents/sessions/auth-storage.test.ts b/src/agents/sessions/auth-storage.test.ts index 45a97a108eac..238de9172130 100644 --- a/src/agents/sessions/auth-storage.test.ts +++ b/src/agents/sessions/auth-storage.test.ts @@ -2,7 +2,23 @@ import fs from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const providerOAuthMocks = vi.hoisted(() => ({ + login: vi.fn(), + resolveCredential: vi.fn(), +})); + +vi.mock("../../plugins/provider-runtime.runtime.js", async () => { + const actual = await vi.importActual( + "../../plugins/provider-runtime.runtime.js", + ); + return { + ...actual, + loginProviderOAuthWithPlugin: providerOAuthMocks.login, + resolveProviderOAuthCredentialWithPlugin: providerOAuthMocks.resolveCredential, + }; +}); import { closeOpenClawAgentDatabasesForTest } from "../../state/openclaw-agent-db.js"; import { clearAuthProfileMigrationDiagnostics } from "../auth-profiles/legacy-source-diagnostic.js"; import { loadPersistedAuthProfileStore } from "../auth-profiles/persisted.js"; @@ -16,11 +32,23 @@ import { writePersistedAuthProfileStoreRaw, } from "../auth-profiles/sqlite.js"; import { getAuthStorageOAuthProviderRegistry } from "./auth-storage-oauth-registry.js"; -import { AuthStorage, FileAuthStorageBackend, type AuthStorageBackend } from "./auth-storage.js"; +import { + AuthStorage, + FileAuthStorageBackend, + OAuthProviderConfiguredUnavailableError, + type AuthStorageBackend, +} from "./auth-storage.js"; describe("SQLite auth storage", () => { const tempDirs: string[] = []; + beforeEach(() => { + providerOAuthMocks.login.mockReset(); + providerOAuthMocks.login.mockResolvedValue({ status: "unowned" }); + providerOAuthMocks.resolveCredential.mockReset(); + providerOAuthMocks.resolveCredential.mockResolvedValue({ status: "unowned" }); + }); + afterEach(() => { clearAuthProfileMigrationDiagnostics(); clearRuntimeAuthProfileStoreSnapshots(); @@ -37,6 +65,55 @@ describe("SQLite auth storage", () => { return agentDir; } + it("dispatches callback-based login to the owning provider plugin", async () => { + const agentDir = makeAgentDir(); + const storage = AuthStorage.forAgent(agentDir); + const callbacks = { + onAuth: vi.fn(), + onPrompt: vi.fn(async () => ""), + }; + providerOAuthMocks.login.mockResolvedValueOnce({ + status: "available", + credentials: { + access: "fake-access", + refresh: "fake-refresh", + expires: Date.now() + 60_000, + }, + }); + + await storage.login("plugin-oauth", callbacks); + + expect(providerOAuthMocks.login).toHaveBeenCalledWith({ + provider: "plugin-oauth", + context: callbacks, + }); + expect(loadPersistedAuthProfileStore(agentDir)?.profiles["plugin-oauth:default"]).toMatchObject( + { + type: "oauth", + provider: "plugin-oauth", + access: "fake-access", + refresh: "fake-refresh", + }, + ); + }); + + it("returns a typed actionable error when an owned OAuth plugin is unavailable", async () => { + const storage = AuthStorage.forAgent(makeAgentDir()); + providerOAuthMocks.login.mockResolvedValueOnce({ status: "configured-unavailable" }); + + const login = storage.login("plugin-oauth", { + onAuth: vi.fn(), + onPrompt: vi.fn(async () => ""), + }); + await expect(login).rejects.toMatchObject({ + name: "OAuthProviderConfiguredUnavailableError", + code: "OAUTH_PROVIDER_CONFIGURED_UNAVAILABLE", + state: "configured-unavailable", + providerId: "plugin-oauth", + }); + await expect(login).rejects.toBeInstanceOf(OAuthProviderConfiguredUnavailableError); + }); + it("persists provider defaults in the canonical agent database", async () => { const agentDir = makeAgentDir(); const storage = AuthStorage.forAgent(agentDir); diff --git a/src/agents/sessions/auth-storage.ts b/src/agents/sessions/auth-storage.ts index 97fa0f7175fd..70bd37963e67 100644 --- a/src/agents/sessions/auth-storage.ts +++ b/src/agents/sessions/auth-storage.ts @@ -16,6 +16,7 @@ import type { OAuthLoginCallbacks, OAuthProviderId, } from "../../llm/utils/oauth/types.js"; +import { OAuthProviderConfiguredUnavailableError } from "../../plugins/provider-runtime.errors.js"; import type { OpenClawAgentDatabase } from "../../state/openclaw-agent-db.js"; import { AUTH_STORE_VERSION, OAUTH_REFRESH_LOCK_OPTIONS } from "../auth-profiles/constants.js"; import { @@ -39,7 +40,11 @@ import { } from "../auth-profiles/store.js"; import type { AuthProfileStore } from "../auth-profiles/types.js"; import { getAgentDir } from "../config.js"; -import { getAuthStorageOAuthProviderRegistry } from "./auth-storage-oauth-registry.js"; +import { + getAuthStorageOAuthProviderRegistry, + loginAuthStorageOAuthProvider, + resolveAuthStoragePluginOAuthCredential, +} from "./auth-storage-oauth-registry.js"; import { resolveConfigValue } from "./resolve-config-value.js"; export type ApiKeyCredential = { @@ -60,6 +65,7 @@ export type TokenCredential = { export type AuthCredential = ApiKeyCredential | OAuthCredential | TokenCredential; export type AuthStorageData = Record; +export { OAuthProviderConfiguredUnavailableError }; export const AUTH_STORAGE_CREATE_DEPRECATION_CODE = "AUTH_STORAGE_CREATE_DEPRECATED" as const; export const FILE_AUTH_STORAGE_BACKEND_DEPRECATION_CODE = "FILE_AUTH_STORAGE_BACKEND_DEPRECATED" as const; @@ -674,12 +680,7 @@ export class AuthStorage { * Login to an OAuth provider. */ async login(providerId: OAuthProviderId, callbacks: OAuthLoginCallbacks): Promise { - const provider = getAuthStorageOAuthProviderRegistry(this).get(providerId); - if (!provider) { - throw new Error(`Unknown OAuth provider: ${providerId}`); - } - - const credentials = await provider.login(callbacks); + const credentials = await loginAuthStorageOAuthProvider(this, providerId, callbacks); this.set(providerId, { type: "oauth", ...credentials }); } @@ -698,9 +699,6 @@ export class AuthStorage { providerId: OAuthProviderId, ): Promise<{ apiKey: string; newCredentials: OAuthCredentials } | null> { const provider = getAuthStorageOAuthProviderRegistry(this).get(providerId); - if (!provider) { - return null; - } const refresh = async () => await this.storage.withLockAsync(async (current) => { @@ -714,7 +712,10 @@ export class AuthStorage { } if (Date.now() < cred.expires) { - return { result: { apiKey: provider.getApiKey(cred), newCredentials: cred } }; + if (provider) { + return { result: { apiKey: provider.getApiKey(cred), newCredentials: cred } }; + } + return { result: await resolveAuthStoragePluginOAuthCredential(providerId, cred, false) }; } const oauthCreds: Record = {}; @@ -724,10 +725,9 @@ export class AuthStorage { } } - const refreshed = await getAuthStorageOAuthProviderRegistry(this).getApiKey( - providerId, - oauthCreds, - ); + const refreshed = provider + ? await getAuthStorageOAuthProviderRegistry(this).getApiKey(providerId, oauthCreds) + : await resolveAuthStoragePluginOAuthCredential(providerId, cred, true); if (!refreshed) { return { result: null }; } @@ -800,10 +800,6 @@ export class AuthStorage { if (cred?.type === "oauth") { const provider = getAuthStorageOAuthProviderRegistry(this).get(providerId); - if (!provider) { - // Unknown OAuth provider, can't get API key - return undefined; - } // Check if token needs refresh const needsRefresh = Date.now() >= cred.expires; @@ -816,6 +812,9 @@ export class AuthStorage { return result.apiKey; } } catch (error) { + if (error instanceof OAuthProviderConfiguredUnavailableError) { + throw error; + } this.recordError(error); // Refresh failed - re-read file to check if another instance succeeded this.reload(); @@ -831,7 +830,11 @@ export class AuthStorage { if (updatedCred?.type === "oauth" && Date.now() < updatedCred.expires) { // Another instance refreshed successfully, use those credentials - return provider.getApiKey(updatedCred); + if (provider) { + return provider.getApiKey(updatedCred); + } + return (await resolveAuthStoragePluginOAuthCredential(providerId, updatedCred, false)) + ?.apiKey; } // Refresh truly failed - return undefined so model discovery skips this provider @@ -839,8 +842,10 @@ export class AuthStorage { return undefined; } } else { - // Token not expired, use current access token - return provider.getApiKey(cred); + if (provider) { + return provider.getApiKey(cred); + } + return (await resolveAuthStoragePluginOAuthCredential(providerId, cred, false))?.apiKey; } } diff --git a/src/llm/github-copilot-oauth-types.ts b/src/llm/github-copilot-oauth-types.ts deleted file mode 100644 index 94a80a97706b..000000000000 --- a/src/llm/github-copilot-oauth-types.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * GitHub Copilot OAuth wire and option types. - */ - -export type DeviceCodeResponse = { - device_code: string; - user_code: string; - verification_uri: string; - intervalMs: number; - expiresAt: number; -}; - -export type DeviceTokenSuccessResponse = { - access_token: string; - token_type?: string; - scope?: string; -}; - -export type DeviceTokenErrorResponse = { - error: string; - error_description?: string; - interval?: number; -}; - -export type CopilotModelListEntry = { - id?: unknown; - object?: unknown; - capabilities?: { - type?: unknown; - }; -}; - -export type CopilotRequestOptions = { - signal?: AbortSignal; - timeoutMs?: number; -}; diff --git a/src/llm/utils/oauth/github-copilot.test.ts b/src/llm/utils/oauth/github-copilot.test.ts deleted file mode 100644 index 495f3049ed92..000000000000 --- a/src/llm/utils/oauth/github-copilot.test.ts +++ /dev/null @@ -1,628 +0,0 @@ -// GitHub Copilot OAuth tests cover device flow polling and timeout behavior. -import { getEventListeners } from "node:events"; -import { MAX_DATE_TIMESTAMP_MS } from "@openclaw/normalization-core/number-coercion"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import type { Model } from "../../types.js"; -import { githubCopilotOAuthProvider } from "./github-copilot.js"; -import type { OAuthCredentials } from "./types.js"; - -type FetchImplementation = (...args: Parameters) => Promise; - -function startGitHubCopilotLogin(enterpriseUrl = "", signal?: AbortSignal) { - return githubCopilotOAuthProvider.login({ - onAuth: vi.fn(), - onPrompt: vi.fn(async () => enterpriseUrl), - signal, - }); -} - -function deviceCodeResponse(overrides: Record = {}): Response { - return new Response( - JSON.stringify({ - device_code: "device-code", - user_code: "ABCD-1234", - verification_uri: "https://github.com/login/device", - interval: 0, - expires_in: 300, - ...overrides, - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ); -} - -function deviceTokenResponse(): Response { - return new Response(JSON.stringify({ access_token: "github-access-token" }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); -} - -function copilotTokenResponse(): Response { - return new Response( - JSON.stringify({ - token: "copilot-token", - expires_at: Math.floor(Date.now() / 1000) + 3600, - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ); -} - -async function finishGitHubCopilotLogin(login: Promise) { - const outcome = login.then( - (credentials) => ({ credentials }) as const, - (error: unknown) => ({ error }) as const, - ); - await vi.advanceTimersByTimeAsync(1_200); - const settled = await outcome; - if ("error" in settled) { - throw settled.error; - } - return settled.credentials; -} - -function startGitHubCopilotLoginAtTokenExchange( - tokenExchangeFetch: FetchImplementation, - enterpriseUrl = "", -): { - fetchMock: ReturnType; - login: Promise; -} { - vi.useFakeTimers(); - const fetchMock = vi - .fn(tokenExchangeFetch) - .mockResolvedValueOnce(deviceCodeResponse()) - .mockResolvedValueOnce(deviceTokenResponse()); - vi.stubGlobal("fetch", fetchMock); - return { - fetchMock, - login: finishGitHubCopilotLogin(startGitHubCopilotLogin(enterpriseUrl)), - }; -} - -function abortListenerCount(signal: AbortSignal): number { - return getEventListeners(signal, "abort").length; -} - -function createHangingFetch(timeoutMs: number): FetchImplementation { - vi.spyOn(AbortSignal, "timeout").mockImplementation((actualTimeoutMs) => { - expect(actualTimeoutMs).toBe(timeoutMs); - const controller = new AbortController(); - queueMicrotask(() => { - controller.abort(new DOMException("timed out", "TimeoutError")); - }); - return controller.signal; - }); - - return vi.fn( - (_input: Parameters[0], init?: Parameters[1]) => - new Promise((_resolve, reject) => { - const signal = init?.signal; - if (!signal) { - reject(new Error("missing abort signal")); - return; - } - - const abort = () => { - reject( - signal.reason instanceof Error - ? signal.reason - : new DOMException("aborted", "AbortError"), - ); - }; - if (signal.aborted) { - abort(); - return; - } - signal.addEventListener("abort", abort, { once: true }); - }), - ); -} - -afterEach(() => { - vi.useRealTimers(); - vi.restoreAllMocks(); - vi.unstubAllGlobals(); -}); - -describe("GitHub Copilot OAuth model policy", () => { - it("enables only eligible model ids returned by Copilot", async () => { - vi.useFakeTimers(); - const fetchMock = vi - .fn() - .mockResolvedValueOnce(deviceCodeResponse()) - .mockResolvedValueOnce(deviceTokenResponse()) - .mockResolvedValueOnce(copilotTokenResponse()) - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - data: [ - { id: "claude-sonnet-4.6" }, - { id: " gpt-5.5 " }, - { id: "embedding-model", capabilities: { type: "embeddings" } }, - { id: "accounts/example/router" }, - { id: "not-a-model", object: "assistant" }, - { id: "" }, - ], - }), - { status: 200 }, - ), - ) - .mockResolvedValue(new Response(null, { status: 200 })); - vi.stubGlobal("fetch", fetchMock); - - await expect(finishGitHubCopilotLogin(startGitHubCopilotLogin())).resolves.toMatchObject({ - access: "copilot-token", - }); - const urls = fetchMock.mock.calls.map(([input]) => String(input)); - expect(urls).toContain("https://api.individual.githubcopilot.com/models"); - expect(urls).toContain( - "https://api.individual.githubcopilot.com/models/claude-sonnet-4.6/policy", - ); - expect(urls).toContain("https://api.individual.githubcopilot.com/models/gpt-5.5/policy"); - expect(urls.some((url) => url.includes("embedding-model"))).toBe(false); - expect(urls.some((url) => url.includes("accounts/example/router"))).toBe(false); - }); - - it("treats model listing failures as optional policy setup", async () => { - vi.useFakeTimers(); - const fetchMock = vi - .fn() - .mockResolvedValueOnce(deviceCodeResponse()) - .mockResolvedValueOnce(deviceTokenResponse()) - .mockResolvedValueOnce(copilotTokenResponse()) - .mockResolvedValueOnce(new Response("nope", { status: 503 })); - vi.stubGlobal("fetch", fetchMock); - - await expect(finishGitHubCopilotLogin(startGitHubCopilotLogin())).resolves.toMatchObject({ - access: "copilot-token", - }); - expect(fetchMock).toHaveBeenCalledTimes(4); - }); - - it("times out device code requests", async () => { - vi.stubGlobal("fetch", createHangingFetch(30_000)); - - await expect(startGitHubCopilotLogin()).rejects.toThrow( - "GitHub Copilot device code request timed out after 30000ms", - ); - }); - - it("rejects unsafe device code lifetimes", async () => { - vi.stubGlobal( - "fetch", - vi.fn( - async () => - new Response( - '{"device_code":"device-code","user_code":"ABCD-1234","verification_uri":"https://github.com/login/device","interval":0,"expires_in":1e309}', - { status: 200, headers: { "Content-Type": "application/json" } }, - ), - ), - ); - - await expect(startGitHubCopilotLogin()).rejects.toThrow("Invalid device code response fields"); - }); - - it("times out token refresh requests", async () => { - const { login } = startGitHubCopilotLoginAtTokenExchange(createHangingFetch(30_000)); - - await expect(login).rejects.toThrow( - "GitHub Copilot token refresh request timed out after 30000ms", - ); - }); - - it("rejects unsafe Copilot token expiry values", async () => { - const { login } = startGitHubCopilotLoginAtTokenExchange( - async () => - new Response('{"token":"copilot-token","expires_at":1e309}', { - status: 200, - headers: { "Content-Type": "application/json" }, - }), - ); - - await expect(login).rejects.toThrow("Invalid Copilot token response fields"); - }); - - it("cancels model enablement response bodies", async () => { - vi.useFakeTimers(); - const cancel = vi.fn(async () => undefined); - const fetchMock = vi - .fn() - .mockResolvedValueOnce(deviceCodeResponse()) - .mockResolvedValueOnce(deviceTokenResponse()) - .mockResolvedValueOnce(copilotTokenResponse()) - .mockResolvedValueOnce( - new Response(JSON.stringify({ data: [{ id: "claude-sonnet-4.6" }] }), { status: 200 }), - ) - .mockResolvedValueOnce({ ok: true, body: { cancel } } as unknown as Response); - vi.stubGlobal("fetch", fetchMock); - - await expect(finishGitHubCopilotLogin(startGitHubCopilotLogin())).resolves.toMatchObject({ - access: "copilot-token", - }); - - expect(cancel).toHaveBeenCalledTimes(1); - }); -}); - -describe("GitHub Copilot OAuth enterprise domain allowlist", () => { - function fetchToken(): Promise { - return Promise.resolve(copilotTokenResponse()); - } - - it("rejects an unlisted enterprise domain without sending any request", async () => { - const fetchMock = vi.fn(); - vi.stubGlobal("fetch", fetchMock); - - await expect(startGitHubCopilotLogin("attacker.example")).rejects.toThrow( - 'Unsupported GitHub Enterprise domain "attacker.example"', - ); - - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it("keeps a data-residency ghe.com tenant for the refresh endpoint", async () => { - const { fetchMock, login } = startGitHubCopilotLoginAtTokenExchange(fetchToken, "acme.ghe.com"); - - await login; - - expect(fetchMock).toHaveBeenCalledWith( - "https://api.acme.ghe.com/copilot_internal/v2/token", - expect.anything(), - ); - }); - - it("defaults to public github.com when no enterprise domain is set", async () => { - const { fetchMock, login } = startGitHubCopilotLoginAtTokenExchange(fetchToken); - - await login; - - expect(fetchMock).toHaveBeenCalledWith( - "https://api.github.com/copilot_internal/v2/token", - expect.anything(), - ); - }); -}); - -describe("GitHub Copilot OAuth model routing", () => { - const models: Model[] = [ - { id: "gpt-5", provider: "github-copilot" } as Model, - { id: "claude-sonnet-5", provider: "anthropic" } as Model, - ]; - - function credential(overrides: Partial) { - return { - access: "access-token", - refresh: "refresh-token", - expires: Date.now() + 3_600_000, - ...overrides, - } as OAuthCredentials; - } - - it("exposes the durable GitHub token to provider runtime auth", () => { - expect(githubCopilotOAuthProvider.getApiKey(credential({}))).toBe("refresh-token"); - }); - - it("normalizes an expired legacy access token without exchanging it", async () => { - const expired = credential({ expires: 1 }); - const fetchMock = vi.fn(); - vi.stubGlobal("fetch", fetchMock); - - await expect(githubCopilotOAuthProvider.refreshToken(expired)).resolves.toEqual({ - ...expired, - access: "refresh-token", - expires: MAX_DATE_TIMESTAMP_MS, - }); - expect(expired).toMatchObject({ access: "access-token", expires: 1 }); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it("drops github-copilot models for an unsupported persisted enterprise domain", () => { - const result = githubCopilotOAuthProvider.modifyModels?.( - models, - credential({ enterpriseUrl: "attacker.example" }), - ); - - expect(result?.map((m) => m.provider)).toEqual(["anthropic"]); - }); - - it("does not trust an off-allowlist proxy-ep without an enterprise domain", () => { - const result = githubCopilotOAuthProvider.modifyModels?.( - models, - credential({ - access: "tid=x;proxy-ep=proxy.attacker.example;exp=1", - }), - ); - - expect(result?.some((m) => m.provider === "github-copilot")).toBe(false); - expect(JSON.stringify(result)).not.toContain("attacker.example"); - }); - - it("routes a data-residency ghe.com tenant to its copilot proxy", () => { - const result = githubCopilotOAuthProvider.modifyModels?.( - models, - credential({ enterpriseUrl: "acme.ghe.com" }), - ); - - expect(result?.find((m) => m.provider === "github-copilot")?.baseUrl).toBe( - "https://copilot-api.acme.ghe.com", - ); - }); - - it("quarantines unsupported credentials without affecting supported credentials", () => { - const quarantined = githubCopilotOAuthProvider.modifyModels?.( - models, - credential({ enterpriseUrl: "attacker.example" }), - ); - expect(quarantined?.some((m) => m.provider === "github-copilot")).toBe(false); - - const recovered = githubCopilotOAuthProvider.modifyModels?.(models, credential({})); - - expect(recovered?.find((m) => m.provider === "github-copilot")?.baseUrl).toBe( - "https://api.individual.githubcopilot.com", - ); - }); -}); - -describe("GitHub Copilot OAuth bounded reads", () => { - it("caps oversized OAuth JSON responses instead of buffering the full body", async () => { - // 18 MiB body in 1 MiB chunks exceeds the 16 MiB default cap on - // the shared readProviderJsonResponse reader. - const CHUNK = 1024 * 1024; - const CHUNK_COUNT = 18; - let pulls = 0; - const encoder = new TextEncoder(); - const stream = new ReadableStream({ - pull(controller) { - if (pulls >= CHUNK_COUNT) { - controller.close(); - return; - } - pulls += 1; - controller.enqueue(encoder.encode("a".repeat(CHUNK))); - }, - }); - const { login } = startGitHubCopilotLoginAtTokenExchange( - async () => - new Response(stream, { - status: 200, - headers: { "Content-Type": "application/json" }, - }), - ); - - await expect(login).rejects.toThrow( - "GitHub Copilot token refresh request: JSON response exceeds 16777216 bytes", - ); - }); - - it("parses normal-size OAuth JSON responses under the byte cap", async () => { - const { login } = startGitHubCopilotLoginAtTokenExchange(async () => copilotTokenResponse()); - - const result = await login; - expect(result.access).toBe("copilot-token"); - expect(typeof result.expires).toBe("number"); - }); - - it("cancels the upstream body when the bounded reader overflows", async () => { - const cancel = vi.fn(async () => undefined); - const encoder = new TextEncoder(); - const source = new ReadableStream({ - pull(controller) { - controller.enqueue(encoder.encode("a".repeat(1024 * 1024))); - }, - cancel, - }); - const { login } = startGitHubCopilotLoginAtTokenExchange( - async () => - new Response(source, { - status: 200, - headers: { "Content-Type": "application/json" }, - }), - ); - - await expect(login).rejects.toThrow("GitHub Copilot token refresh request"); - - expect(cancel).toHaveBeenCalled(); - }); -}); - -describe("GitHub Copilot OAuth error responses", () => { - const githubToken = `ghr_${"s".repeat(40)}`; - - function createOversizedOAuthErrorResponse(): { - response: Response; - cancel: ReturnType; - } { - const cancel = vi.fn(); - const payload = - JSON.stringify({ - error: "invalid_grant", - error_description: `refresh_token=${githubToken} was rejected`, - refresh_token: githubToken, - }) + " ".repeat(32 * 1024); - const body = new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode(payload)); - }, - cancel, - }); - return { - response: new Response(body, { - status: 400, - headers: { - "Content-Type": "application/json", - "x-request-id": "copilot-request-id", - }, - }), - cancel, - }; - } - - async function captureError(promise: Promise): Promise { - try { - await promise; - } catch (error) { - if (error instanceof Error) { - return error; - } - throw error; - } - throw new Error("Expected request to fail"); - } - - function expectBoundedRedactedError(error: Error, label: string): void { - expect(error).toMatchObject({ - name: "ProviderHttpError", - status: 400, - code: "invalid_grant", - requestId: "copilot-request-id", - }); - expect(error.message).toContain(`${label} (400):`); - expect(error.message).toContain("[code=invalid_grant]"); - expect(error.message).toContain("[request_id=copilot-request-id]"); - expect(error.message).not.toContain("error_description"); - expect(error.message).not.toContain(githubToken); - const errorBody = (error as Error & { errorBody?: string }).errorBody; - expect(errorBody).toBeDefined(); - expect(errorBody).not.toContain(githubToken); - expect(errorBody?.length).toBeLessThanOrEqual(500); - } - - it("bounds and redacts device-code HTTP failures", async () => { - const { response, cancel } = createOversizedOAuthErrorResponse(); - vi.stubGlobal( - "fetch", - vi.fn(async () => response), - ); - - const error = await captureError(startGitHubCopilotLogin()); - - expectBoundedRedactedError(error, "GitHub Copilot device code request"); - expect(cancel).toHaveBeenCalledOnce(); - }); - - it("bounds and redacts device-token HTTP failures", async () => { - vi.useFakeTimers(); - const { response, cancel } = createOversizedOAuthErrorResponse(); - const fetchMock = vi - .fn() - .mockResolvedValueOnce(deviceCodeResponse()) - .mockResolvedValueOnce(response); - vi.stubGlobal("fetch", fetchMock); - - const pending = captureError(startGitHubCopilotLogin()); - await vi.advanceTimersByTimeAsync(1_200); - const error = await pending; - - expectBoundedRedactedError(error, "GitHub Copilot device token request"); - expect(cancel).toHaveBeenCalledOnce(); - }); - - it("bounds and redacts Copilot-token HTTP failures", async () => { - const { response, cancel } = createOversizedOAuthErrorResponse(); - const { login } = startGitHubCopilotLoginAtTokenExchange(async () => response); - - const error = await captureError(login); - - expectBoundedRedactedError(error, "GitHub Copilot token refresh request"); - expect(cancel).toHaveBeenCalledOnce(); - }); - - it("bounds model-list HTTP failures before treating discovery as optional", async () => { - vi.useFakeTimers(); - const { response, cancel } = createOversizedOAuthErrorResponse(); - const fetchMock = vi - .fn() - .mockResolvedValueOnce(deviceCodeResponse()) - .mockResolvedValueOnce(deviceTokenResponse()) - .mockResolvedValueOnce(copilotTokenResponse()) - .mockResolvedValueOnce(response); - vi.stubGlobal("fetch", fetchMock); - - await expect(finishGitHubCopilotLogin(startGitHubCopilotLogin())).resolves.toMatchObject({ - access: "copilot-token", - }); - - expect(fetchMock).toHaveBeenCalledTimes(4); - expect(cancel).toHaveBeenCalledOnce(); - }); -}); - -describe("GitHub Copilot OAuth abortable polling sleep", () => { - it("does not accumulate abort listeners across authorization_pending rounds", async () => { - vi.useFakeTimers(); - const controller = new AbortController(); - const pendingResponse = () => - new Response(JSON.stringify({ error: "authorization_pending" }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - vi.stubGlobal( - "fetch", - vi - .fn() - .mockResolvedValueOnce(deviceCodeResponse()) - .mockResolvedValueOnce(pendingResponse()) - .mockResolvedValueOnce(pendingResponse()) - .mockResolvedValueOnce(pendingResponse()) - .mockResolvedValueOnce(deviceTokenResponse()) - .mockResolvedValueOnce(copilotTokenResponse()) - .mockResolvedValueOnce(new Response("nope", { status: 503 })), - ); - - const addSpy = vi.spyOn(controller.signal, "addEventListener"); - const removeSpy = vi.spyOn(controller.signal, "removeEventListener"); - const pending = startGitHubCopilotLogin("", controller.signal); - await vi.advanceTimersByTimeAsync(0); - - const listenerCounts: number[] = []; - for (let round = 0; round < 4; round += 1) { - listenerCounts.push(abortListenerCount(controller.signal)); - await vi.advanceTimersByTimeAsync(1_200); - } - - await expect(pending).resolves.toMatchObject({ access: "copilot-token" }); - expect(abortListenerCount(controller.signal)).toBe(0); - expect(Math.max(...listenerCounts)).toBe(1); - const abortAdds = addSpy.mock.calls.filter((call) => call[0] === "abort").length; - const abortRemoves = removeSpy.mock.calls.filter((call) => call[0] === "abort").length; - expect(abortAdds).toBeGreaterThanOrEqual(4); - expect(abortRemoves).toBe(abortAdds); - }); - - it("removes the abort listener when cancelled during sleep", async () => { - vi.useFakeTimers(); - const controller = new AbortController(); - const fetchMock = vi - .fn() - .mockResolvedValueOnce(deviceCodeResponse()) - .mockRejectedValue(new Error("poll fetch should not run after abort")); - vi.stubGlobal("fetch", fetchMock); - - const pending = startGitHubCopilotLogin("", controller.signal); - await vi.advanceTimersByTimeAsync(0); - - expect(abortListenerCount(controller.signal)).toBe(1); - controller.abort(); - await expect(pending).rejects.toThrow("Login cancelled"); - expect(abortListenerCount(controller.signal)).toBe(0); - expect(fetchMock).toHaveBeenCalledOnce(); - }); - - it("rejects an already-aborted signal without registering a listener", async () => { - vi.useFakeTimers(); - const controller = new AbortController(); - controller.abort(); - const addSpy = vi.spyOn(controller.signal, "addEventListener"); - const fetchMock = vi.fn(async () => { - throw new Error("poll fetch should not run for aborted signal"); - }); - vi.stubGlobal("fetch", fetchMock); - - const pending = startGitHubCopilotLogin("", controller.signal); - - await expect(pending).rejects.toThrow("Login cancelled"); - expect(addSpy.mock.calls.filter((call) => call[0] === "abort")).toHaveLength(0); - expect(abortListenerCount(controller.signal)).toBe(0); - expect(fetchMock).not.toHaveBeenCalled(); - }); -}); diff --git a/src/llm/utils/oauth/github-copilot.ts b/src/llm/utils/oauth/github-copilot.ts deleted file mode 100644 index 779279ea7aec..000000000000 --- a/src/llm/utils/oauth/github-copilot.ts +++ /dev/null @@ -1,604 +0,0 @@ -/** - * GitHub Copilot OAuth flow - */ - -import { - MAX_DATE_TIMESTAMP_MS, - resolveTimerTimeoutMs, -} from "@openclaw/normalization-core/number-coercion"; -import { - assertOkOrThrowProviderError, - readProviderJsonResponse, -} from "../../../agents/provider-http-errors.js"; -import { - nonNegativeSecondsToSafeMilliseconds, - positiveSecondsToSafeMilliseconds, - resolveExpiresAtMsFromDurationSeconds, - resolveExpiresAtMsFromEpochSeconds, -} from "../../../infra/parse-finite-number.js"; -import { - isSupportedGithubCopilotDomain, - normalizeGithubCopilotDomain, -} from "../../../plugin-sdk/github-copilot-domain.js"; -import { resolveGithubCopilotTokenEndpoint } from "../../../plugin-sdk/github-copilot-token-endpoint.js"; -import type { - CopilotModelListEntry, - CopilotRequestOptions, - DeviceCodeResponse, - DeviceTokenErrorResponse, - DeviceTokenSuccessResponse, -} from "../../github-copilot-oauth-types.js"; -import type { Model } from "../../types.js"; -import type { OAuthCredentials, OAuthLoginCallbacks, OAuthProviderInterface } from "./types.js"; - -type CopilotCredentials = OAuthCredentials & { - enterpriseUrl?: string; -}; - -const CLIENT_ID = "Iv1.b507a08c87ecfe98"; - -const COPILOT_HEADERS = { - "User-Agent": "GitHubCopilotChat/0.35.0", - "Editor-Version": "vscode/1.107.0", - "Editor-Plugin-Version": "copilot-chat/0.35.0", - "Copilot-Integration-Id": "vscode-chat", -} as const; - -const INITIAL_POLL_INTERVAL_MULTIPLIER = 1.2; -const SLOW_DOWN_POLL_INTERVAL_MULTIPLIER = 1.4; -const COPILOT_ROUTER_ID_PREFIX = "accounts/"; -const COPILOT_REQUEST_TIMEOUT_MS = 30_000; -const COPILOT_SOURCE_CREDENTIAL_EXPIRES_AT_MS = MAX_DATE_TIMESTAMP_MS; - -function resolveExpiresAtFromDurationSeconds(value: unknown): number | undefined { - return resolveExpiresAtMsFromDurationSeconds(value); -} - -function resolveExpiresAtFromEpochSeconds(value: unknown): number | undefined { - return resolveExpiresAtMsFromEpochSeconds(value, { bufferMs: 5 * 60 * 1000 }); -} - -function normalizeDomain(input: string): string | null { - const trimmed = input.trim(); - if (!trimmed) { - return null; - } - try { - const url = trimmed.includes("://") ? new URL(trimmed) : new URL(`https://${trimmed}`); - return url.hostname; - } catch { - return null; - } -} - -function getUrls(domain: string): { - deviceCodeUrl: string; - accessTokenUrl: string; - copilotTokenUrl: string; -} { - const safeDomain = normalizeGithubCopilotDomain(domain); - return { - deviceCodeUrl: `https://${safeDomain}/login/device/code`, - accessTokenUrl: `https://${safeDomain}/login/oauth/access_token`, - copilotTokenUrl: `https://api.${safeDomain}/copilot_internal/v2/token`, - }; -} - -function getGitHubCopilotBaseUrl(token?: string, enterpriseDomain?: string): string { - if (enterpriseDomain && !isSupportedGithubCopilotDomain(enterpriseDomain)) { - throw new Error( - `Refusing to route GitHub Copilot requests for unsupported enterprise domain "${enterpriseDomain}". Re-authenticate with a supported host (github.com or a *.ghe.com tenant).`, - ); - } - // If we have a token, extract the base URL from proxy-ep - if (token) { - const tokenEndpoint = resolveGithubCopilotTokenEndpoint(token, enterpriseDomain); - if (tokenEndpoint.hasProxyEndpoint && !tokenEndpoint.baseUrl) { - throw new Error( - "Refusing to route GitHub Copilot requests to an unsupported proxy endpoint.", - ); - } - if (tokenEndpoint.baseUrl) { - return tokenEndpoint.baseUrl; - } - } - // Fallback for enterprise or if token parsing fails - if (enterpriseDomain) { - return `https://copilot-api.${normalizeGithubCopilotDomain(enterpriseDomain)}`; - } - return "https://api.individual.githubcopilot.com"; -} - -function formatCopilotRequestError( - operation: string, - error: unknown, - options: Required> & { - signal?: AbortSignal; - }, -): Error { - if (options.signal?.aborted) { - return new Error("Login cancelled"); - } - if (error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError")) { - return new Error(`GitHub Copilot ${operation} timed out after ${options.timeoutMs}ms`); - } - return error instanceof Error - ? error - : new Error(`GitHub Copilot ${operation} failed: ${String(error)}`); -} - -function buildCopilotRequestSignal(options: CopilotRequestOptions): AbortSignal { - const timeoutSignal = AbortSignal.timeout( - resolveTimerTimeoutMs(options.timeoutMs, COPILOT_REQUEST_TIMEOUT_MS), - ); - if (!options.signal) { - return timeoutSignal; - } - return AbortSignal.any([options.signal, timeoutSignal]); -} - -async function fetchResponse( - url: string, - init: RequestInit, - operation: string, - options: CopilotRequestOptions = {}, -): Promise { - const timeoutMs = resolveTimerTimeoutMs(options.timeoutMs, COPILOT_REQUEST_TIMEOUT_MS); - try { - return await fetch(url, { - ...init, - signal: buildCopilotRequestSignal({ ...options, timeoutMs }), - }); - } catch (error) { - throw formatCopilotRequestError(operation, error, { - signal: options.signal, - timeoutMs, - }); - } -} - -// Shared 16 MiB bounded reader — a hostile OAuth endpoint cannot force the -// runtime to buffer an unbounded body through `.text()` / `.json()`. -async function fetchJson( - url: string, - init: RequestInit, - operation: string, - options: CopilotRequestOptions = {}, -): Promise { - const response = await fetchResponse(url, init, operation, options); - const label = `GitHub Copilot ${operation}`; - await assertOkOrThrowProviderError(response, label); - return readProviderJsonResponse(response, label); -} - -async function startDeviceFlow( - domain: string, - options: CopilotRequestOptions = {}, -): Promise { - const urls = getUrls(domain); - const data = await fetchJson( - urls.deviceCodeUrl, - { - method: "POST", - headers: { - Accept: "application/json", - "Content-Type": "application/x-www-form-urlencoded", - "User-Agent": "GitHubCopilotChat/0.35.0", - }, - body: new URLSearchParams({ - client_id: CLIENT_ID, - scope: "read:user", - }), - }, - "device code request", - options, - ); - - if (!data || typeof data !== "object") { - throw new Error("Invalid device code response"); - } - - const deviceCode = (data as Record).device_code; - const userCode = (data as Record).user_code; - const verificationUri = (data as Record).verification_uri; - const interval = (data as Record).interval; - const intervalMs = nonNegativeSecondsToSafeMilliseconds(interval); - const expiresAt = resolveExpiresAtFromDurationSeconds( - (data as Record).expires_in, - ); - - if ( - typeof deviceCode !== "string" || - typeof userCode !== "string" || - typeof verificationUri !== "string" || - intervalMs === undefined || - expiresAt === undefined - ) { - throw new Error("Invalid device code response fields"); - } - - return { - device_code: deviceCode, - user_code: userCode, - verification_uri: verificationUri, - intervalMs, - expiresAt, - }; -} - -/** - * Sleep that can be interrupted by an AbortSignal. - * Resolve and abort both settle once and remove the abort listener so - * multi-round device polling cannot accumulate listeners on a shared signal. - */ -function abortableSleep(ms: number, signal?: AbortSignal): Promise { - return new Promise((resolve, reject) => { - if (signal?.aborted) { - reject(new Error("Login cancelled")); - return; - } - - let settled = false; - const timeout = setTimeout(() => { - settle(resolve); - }, ms); - - const onAbort = () => { - settle(() => { - reject(new Error("Login cancelled")); - }); - }; - - function settle(action: () => void) { - if (settled) { - return; - } - settled = true; - clearTimeout(timeout); - signal?.removeEventListener("abort", onAbort); - action(); - } - - signal?.addEventListener("abort", onAbort); - }); -} - -async function pollForGitHubAccessToken( - domain: string, - deviceCode: string, - intervalMs: number, - deadline: number, - signal?: AbortSignal, -) { - const urls = getUrls(domain); - let pollingIntervalMs = Math.max(1000, intervalMs); - let intervalMultiplier = INITIAL_POLL_INTERVAL_MULTIPLIER; - let slowDownResponses = 0; - - while (Date.now() < deadline) { - if (signal?.aborted) { - throw new Error("Login cancelled"); - } - - const remainingMs = deadline - Date.now(); - const waitMs = Math.min(Math.ceil(pollingIntervalMs * intervalMultiplier), remainingMs); - await abortableSleep(waitMs, signal); - - const raw = await fetchJson( - urls.accessTokenUrl, - { - method: "POST", - headers: { - Accept: "application/json", - "Content-Type": "application/x-www-form-urlencoded", - "User-Agent": "GitHubCopilotChat/0.35.0", - }, - body: new URLSearchParams({ - client_id: CLIENT_ID, - device_code: deviceCode, - grant_type: "urn:ietf:params:oauth:grant-type:device_code", - }), - }, - "device token request", - { signal }, - ); - - if ( - raw && - typeof raw === "object" && - typeof (raw as DeviceTokenSuccessResponse).access_token === "string" - ) { - return (raw as DeviceTokenSuccessResponse).access_token; - } - - if ( - raw && - typeof raw === "object" && - typeof (raw as DeviceTokenErrorResponse).error === "string" - ) { - const { error, error_description: description, interval } = raw as DeviceTokenErrorResponse; - if (error === "authorization_pending") { - continue; - } - - if (error === "slow_down") { - slowDownResponses += 1; - const slowDownIntervalMs = positiveSecondsToSafeMilliseconds(interval); - pollingIntervalMs = - slowDownIntervalMs === undefined - ? Math.max(1000, pollingIntervalMs + 5000) - : Math.max(1000, slowDownIntervalMs); - intervalMultiplier = SLOW_DOWN_POLL_INTERVAL_MULTIPLIER; - continue; - } - - const descriptionSuffix = description ? `: ${description}` : ""; - throw new Error(`Device flow failed: ${error}${descriptionSuffix}`); - } - } - - if (slowDownResponses > 0) { - throw new Error( - "Device flow timed out after one or more slow_down responses. This is often caused by clock drift in WSL or VM environments. Please sync or restart the VM clock and try again.", - ); - } - - throw new Error("Device flow timed out"); -} - -/** Exchange a GitHub credential for the legacy Copilot access token used during login setup. */ -async function exchangeGitHubTokenForCopilotAccess( - refreshToken: string, - enterpriseDomain?: string, - options: CopilotRequestOptions = {}, -): Promise { - if (enterpriseDomain && !isSupportedGithubCopilotDomain(enterpriseDomain)) { - throw new Error( - `Refusing to refresh GitHub Copilot token for unsupported enterprise domain "${enterpriseDomain}". Re-authenticate with a supported host (github.com or a *.ghe.com tenant).`, - ); - } - const domain = enterpriseDomain || "github.com"; - const urls = getUrls(domain); - - const raw = await fetchJson( - urls.copilotTokenUrl, - { - headers: { - Accept: "application/json", - Authorization: `Bearer ${refreshToken}`, - ...COPILOT_HEADERS, - }, - }, - "token refresh request", - options, - ); - - if (!raw || typeof raw !== "object") { - throw new Error("Invalid Copilot token response"); - } - - const token = (raw as Record).token; - const expires = resolveExpiresAtFromEpochSeconds((raw as Record).expires_at); - - if (typeof token !== "string" || expires === undefined) { - throw new Error("Invalid Copilot token response fields"); - } - - return { - refresh: refreshToken, - access: token, - expires, - enterpriseUrl: enterpriseDomain, - }; -} - -/** - * Enable a model for the user's GitHub Copilot account. - * This is required for some models (like Claude, Grok) before they can be used. - */ -async function enableGitHubCopilotModel( - token: string, - modelId: string, - enterpriseDomain?: string, - options: CopilotRequestOptions = {}, -): Promise { - const baseUrl = getGitHubCopilotBaseUrl(token, enterpriseDomain); - const url = `${baseUrl}/models/${modelId}/policy`; - let response: Response | undefined; - - try { - response = await fetchResponse( - url, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - ...COPILOT_HEADERS, - "openai-intent": "chat-policy", - "x-interaction-type": "chat-policy", - }, - body: JSON.stringify({ state: "enabled" }), - }, - "model policy request", - options, - ); - return response.ok; - } catch { - return false; - } finally { - await response?.body?.cancel().catch(() => undefined); - } -} - -async function listGitHubCopilotModelIds( - token: string, - enterpriseDomain?: string, - options: CopilotRequestOptions = {}, -): Promise { - const baseUrl = getGitHubCopilotBaseUrl(token, enterpriseDomain); - const url = `${baseUrl}/models`; - try { - const raw = await fetchJson( - url, - { - headers: { - Accept: "application/json", - Authorization: `Bearer ${token}`, - ...COPILOT_HEADERS, - }, - }, - "model list request", - options, - ); - const data = raw && typeof raw === "object" ? (raw as { data?: unknown }).data : undefined; - if (!Array.isArray(data)) { - return []; - } - return data.flatMap((entry) => { - if (!entry || typeof entry !== "object") { - return []; - } - const model = entry as CopilotModelListEntry; - const id = typeof model.id === "string" ? model.id.trim() : ""; - if (!id || id.startsWith(COPILOT_ROUTER_ID_PREFIX)) { - return []; - } - if (model.object && model.object !== "model") { - return []; - } - if (model.capabilities?.type && model.capabilities.type !== "chat") { - return []; - } - return [id]; - }); - } catch { - return []; - } -} - -/** - * Enable GitHub Copilot models visible to this account. - * Called after successful login to ensure available models are policy-enabled. - */ -async function enableAllGitHubCopilotModels( - token: string, - enterpriseDomain?: string, - onProgress?: (model: string, success: boolean) => void, -): Promise { - const modelIds = await listGitHubCopilotModelIds(token, enterpriseDomain); - await Promise.all( - modelIds.map(async (modelId) => { - const success = await enableGitHubCopilotModel(token, modelId, enterpriseDomain); - onProgress?.(modelId, success); - }), - ); -} - -/** - * Login with GitHub Copilot OAuth (device code flow) - * - * @param options.onAuth - Callback with URL and optional instructions (user code) - * @param options.onPrompt - Callback to prompt user for input - * @param options.onProgress - Optional progress callback - * @param options.signal - Optional AbortSignal for cancellation - */ -async function loginGitHubCopilot(options: { - onAuth: (url: string, instructions?: string) => void; - onPrompt: (prompt: { - message: string; - placeholder?: string; - allowEmpty?: boolean; - }) => Promise; - onProgress?: (message: string) => void; - signal?: AbortSignal; -}): Promise { - const input = await options.onPrompt({ - message: "GitHub Enterprise URL/domain (blank for github.com)", - placeholder: "company.ghe.com", - allowEmpty: true, - }); - - if (options.signal?.aborted) { - throw new Error("Login cancelled"); - } - - const trimmed = input.trim(); - const enterpriseDomain = normalizeDomain(input); - if (trimmed && !enterpriseDomain) { - throw new Error("Invalid GitHub Enterprise URL/domain"); - } - if (!isSupportedGithubCopilotDomain(enterpriseDomain)) { - throw new Error( - `Unsupported GitHub Enterprise domain "${trimmed}". Use github.com or a *.ghe.com data-residency tenant.`, - ); - } - const domain = enterpriseDomain || "github.com"; - - const device = await startDeviceFlow(domain, { signal: options.signal }); - options.onAuth(device.verification_uri, `Enter code: ${device.user_code}`); - - const githubAccessToken = await pollForGitHubAccessToken( - domain, - device.device_code, - device.intervalMs, - device.expiresAt, - options.signal, - ); - const credentials = await exchangeGitHubTokenForCopilotAccess( - githubAccessToken, - enterpriseDomain ?? undefined, - ); - - // Enable all models after successful login - options.onProgress?.("Enabling models..."); - await enableAllGitHubCopilotModels(credentials.access, enterpriseDomain ?? undefined); - return credentials; -} - -export const githubCopilotOAuthProvider: OAuthProviderInterface = { - id: "github-copilot", - name: "GitHub Copilot", - - async login(callbacks: OAuthLoginCallbacks): Promise { - return loginGitHubCopilot({ - onAuth: (url, instructions) => callbacks.onAuth({ url, instructions }), - onPrompt: callbacks.onPrompt, - onProgress: callbacks.onProgress, - signal: callbacks.signal, - }); - }, - - async refreshToken(credentials: OAuthCredentials): Promise { - // Legacy profiles expire the retired exchanged token, not the durable - // GitHub credential. Normalize once so generic OAuth persistence records - // the source token as active and never infers a successful no-op refresh. - return { - ...credentials, - access: credentials.refresh, - expires: COPILOT_SOURCE_CREDENTIAL_EXPIRES_AT_MS, - }; - }, - - getApiKey(credentials: OAuthCredentials): string { - // The provider runtime now authenticates directly with the durable GitHub - // credential; the short-lived Copilot access token is legacy profile state. - return credentials.refresh; - }, - - modifyModels(models: Model[], credentials: OAuthCredentials): Model[] { - const creds = credentials as CopilotCredentials; - const domain = creds.enterpriseUrl - ? (normalizeDomain(creds.enterpriseUrl) ?? undefined) - : undefined; - const tokenEndpoint = resolveGithubCopilotTokenEndpoint(creds.access, domain); - if ( - !isSupportedGithubCopilotDomain(creds.enterpriseUrl) || - (tokenEndpoint.hasProxyEndpoint && !tokenEndpoint.baseUrl) - ) { - return models.filter((m) => m.provider !== "github-copilot"); - } - const baseUrl = tokenEndpoint.baseUrl ?? getGitHubCopilotBaseUrl(undefined, domain); - return models.map((m) => (m.provider === "github-copilot" ? { ...m, baseUrl } : m)); - }, -}; diff --git a/src/llm/utils/oauth/index.ts b/src/llm/utils/oauth/index.ts index b9db1a6e8084..fb198e2f4ce2 100644 --- a/src/llm/utils/oauth/index.ts +++ b/src/llm/utils/oauth/index.ts @@ -4,11 +4,10 @@ * This module handles login, token refresh, and credential storage * for OAuth-based providers: * - Anthropic (Claude Pro/Max) - * - GitHub Copilot + * - provider plugins through their runtime auth hooks */ // Anthropic -// GitHub Copilot // OpenAI Codex (ChatGPT OAuth) export * from "./types.js"; @@ -18,13 +17,11 @@ export * from "./types.js"; // ============================================================================ import { anthropicOAuthProvider } from "./anthropic.js"; -import { githubCopilotOAuthProvider } from "./github-copilot.js"; import { openaiCodexOAuthProvider } from "./openai-chatgpt.js"; import type { OAuthCredentials, OAuthProviderId, OAuthProviderInterface } from "./types.js"; const BUILT_IN_OAUTH_PROVIDERS: OAuthProviderInterface[] = [ anthropicOAuthProvider, - githubCopilotOAuthProvider, openaiCodexOAuthProvider, ]; diff --git a/src/plugin-sdk/agent-sessions.ts b/src/plugin-sdk/agent-sessions.ts index 6b6e303f0ba3..b79924b1a30d 100644 --- a/src/plugin-sdk/agent-sessions.ts +++ b/src/plugin-sdk/agent-sessions.ts @@ -13,6 +13,7 @@ export { parseSessionEntries, CURRENT_SESSION_VERSION, AuthStorage, + OAuthProviderConfiguredUnavailableError, ExtensionRunner, ModelRegistry, SessionManager, diff --git a/src/plugins/provider-plugin.types.ts b/src/plugins/provider-plugin.types.ts index 12c446c10fb0..95bc98da790f 100644 --- a/src/plugins/provider-plugin.types.ts +++ b/src/plugins/provider-plugin.types.ts @@ -7,6 +7,10 @@ import type { AnyAgentTool } from "../agents/tools/common.js"; import type { ModelProviderConfig } from "../config/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { ProviderUsageSnapshot } from "../infra/provider-usage.types.js"; +import type { + OAuthCredentials as SessionOAuthCredentials, + OAuthLoginCallbacks, +} from "../plugin-sdk/provider-oauth-runtime.js"; import type { PluginTextTransforms } from "./cli-backend.types.js"; import type { ProviderAuthMethod, @@ -540,6 +544,13 @@ export type ProviderPlugin = { * bearer token (for example Gemini CLI's `{ token, projectId }` payload). */ formatApiKey?: (cred: AuthProfileCredential) => string; + /** + * Provider-owned OAuth login adapter for the session SDK AuthStorage API. + * + * This keeps the public callback-based login contract usable without seeding + * provider implementations into core. Modern setup flows should use `auth`. + */ + loginOAuth?: (callbacks: OAuthLoginCallbacks) => Promise; /** * Legacy auth-profile ids that should be retired by `openclaw doctor`. * diff --git a/src/plugins/provider-runtime.errors.ts b/src/plugins/provider-runtime.errors.ts new file mode 100644 index 000000000000..068671f84970 --- /dev/null +++ b/src/plugins/provider-runtime.errors.ts @@ -0,0 +1,16 @@ +const OAUTH_PROVIDER_CONFIGURED_UNAVAILABLE = "OAUTH_PROVIDER_CONFIGURED_UNAVAILABLE" as const; + +/** A known OAuth provider could not load its owning plugin or required auth hooks. */ +export class OAuthProviderConfiguredUnavailableError extends Error { + readonly code = OAUTH_PROVIDER_CONFIGURED_UNAVAILABLE; + readonly state = "configured-unavailable" as const; + readonly providerId: string; + + constructor(providerId: string) { + super( + `OAuth provider "${providerId}" is configured but unavailable. Install or enable its owning plugin, then retry; run openclaw doctor for diagnostics.`, + ); + this.name = "OAuthProviderConfiguredUnavailableError"; + this.providerId = providerId; + } +} diff --git a/src/plugins/provider-runtime.runtime.ts b/src/plugins/provider-runtime.runtime.ts index 4fdf70702c8f..bb1b8bec6a05 100644 --- a/src/plugins/provider-runtime.runtime.ts +++ b/src/plugins/provider-runtime.runtime.ts @@ -9,6 +9,9 @@ type BuildProviderAuthDoctorHintWithPlugin = ProviderRuntimeModule["buildProviderAuthDoctorHintWithPlugin"]; type FormatProviderAuthProfileApiKeyWithPlugin = ProviderRuntimeModule["formatProviderAuthProfileApiKeyWithPlugin"]; +type LoginProviderOAuthWithPlugin = ProviderRuntimeModule["loginProviderOAuthWithPlugin"]; +type ResolveProviderOAuthCredentialWithPlugin = + ProviderRuntimeModule["resolveProviderOAuthCredentialWithPlugin"]; type PrepareProviderRuntimeAuth = ProviderRuntimeModule["prepareProviderRuntimeAuth"]; type RefreshProviderOAuthCredentialWithPlugin = ProviderRuntimeModule["refreshProviderOAuthCredentialWithPlugin"]; @@ -47,6 +50,22 @@ export async function formatProviderAuthProfileApiKeyWithPlugin( return runtime.formatProviderAuthProfileApiKeyWithPlugin(...args); } +/** Lazily runs the callback-based OAuth login owned by a provider plugin. */ +export async function loginProviderOAuthWithPlugin( + ...args: Parameters +): Promise>> { + const runtime = await loadProviderRuntime(); + return runtime.loginProviderOAuthWithPlugin(...args); +} + +/** Lazily resolves or refreshes a session OAuth credential through its provider plugin. */ +export async function resolveProviderOAuthCredentialWithPlugin( + ...args: Parameters +): Promise>> { + const runtime = await loadProviderRuntime(); + return runtime.resolveProviderOAuthCredentialWithPlugin(...args); +} + /** Lazily prepares provider runtime auth for model execution. */ export async function prepareProviderRuntimeAuth( ...args: Parameters diff --git a/src/plugins/provider-runtime.test.ts b/src/plugins/provider-runtime.test.ts index 68a760feaac4..e1f574b023a1 100644 --- a/src/plugins/provider-runtime.test.ts +++ b/src/plugins/provider-runtime.test.ts @@ -58,6 +58,7 @@ let buildProviderUnknownModelHintWithPlugin: typeof import("./provider-runtime.j let applyProviderNativeStreamingUsageCompatWithPlugin: typeof import("./provider-runtime.js").applyProviderNativeStreamingUsageCompatWithPlugin; let applyProviderConfigDefaultsWithPlugin: typeof import("./provider-runtime.js").applyProviderConfigDefaultsWithPlugin; let formatProviderAuthProfileApiKeyWithPlugin: typeof import("./provider-runtime.js").formatProviderAuthProfileApiKeyWithPlugin; +let loginProviderOAuthWithPlugin: typeof import("./provider-runtime.js").loginProviderOAuthWithPlugin; let classifyProviderFailoverReasonWithPlugin: typeof import("./provider-runtime.js").classifyProviderFailoverReasonWithPlugin; let matchesProviderContextOverflowWithPlugin: typeof import("./provider-runtime.js").matchesProviderContextOverflowWithPlugin; let normalizeProviderConfigWithPlugin: typeof import("./provider-runtime.js").normalizeProviderConfigWithPlugin; @@ -91,6 +92,7 @@ let normalizeProviderResolvedModelWithPlugin: typeof import("./provider-runtime. let prepareProviderDynamicModel: typeof import("./provider-runtime.js").prepareProviderDynamicModel; let prepareProviderRuntimeAuth: typeof import("./provider-runtime.js").prepareProviderRuntimeAuth; let refreshProviderOAuthCredentialWithPlugin: typeof import("./provider-runtime.js").refreshProviderOAuthCredentialWithPlugin; +let resolveProviderOAuthCredentialWithPlugin: typeof import("./provider-runtime.js").resolveProviderOAuthCredentialWithPlugin; let resolveProviderRuntimePlugin: typeof import("./provider-runtime.js").resolveProviderRuntimePlugin; let providerRuntimeTesting: typeof import("./provider-runtime.js").testing; let runProviderDynamicModel: typeof import("./provider-runtime.js").runProviderDynamicModel; @@ -291,6 +293,10 @@ describe("provider-runtime", () => { resolveOwningPluginIdsForProviderMock(params as never), resolveOwningPluginIdsForProviderRef: (params: unknown) => resolveOwningPluginIdsForProviderMock(params as never), + resolveProviderRefOwnership: (params: unknown) => { + const pluginIds = resolveOwningPluginIdsForProviderMock(params as never); + return pluginIds?.length ? { status: "owned", pluginIds } : { status: "unowned" }; + }, })); vi.doMock("./providers.runtime.js", () => ({ resolvePluginProviders: (params: unknown) => resolvePluginProvidersMock(params as never), @@ -315,6 +321,7 @@ describe("provider-runtime", () => { applyProviderResolvedTransportWithPlugin, classifyProviderFailoverReasonWithPlugin, formatProviderAuthProfileApiKeyWithPlugin, + loginProviderOAuthWithPlugin, matchesProviderContextOverflowWithPlugin, normalizeProviderConfigWithPlugin, normalizeProviderModelIdWithPlugin, @@ -346,6 +353,7 @@ describe("provider-runtime", () => { prepareProviderDynamicModel, prepareProviderRuntimeAuth, refreshProviderOAuthCredentialWithPlugin, + resolveProviderOAuthCredentialWithPlugin, resolveProviderRuntimePlugin, testing: providerRuntimeTesting, runProviderDynamicModel, @@ -395,6 +403,83 @@ describe("provider-runtime", () => { }); }); + it("dispatches session OAuth operations to the owning provider", async () => { + const loginOAuth = vi.fn(async () => ({ + access: "login-access", + refresh: "login-refresh", + expires: 123, + })); + const refreshOAuth = vi.fn(async (credential) => ({ + ...credential, + access: "refreshed-access", + })); + resolvePluginProvidersMock.mockReturnValue([ + { + id: "plugin-oauth", + label: "Plugin OAuth", + auth: [], + loginOAuth, + refreshOAuth, + formatApiKey: (credential) => + credential.type === "oauth" ? `formatted:${credential.access}` : "", + }, + ]); + + await expect( + loginProviderOAuthWithPlugin({ + provider: "plugin-oauth", + context: { onAuth: vi.fn(), onPrompt: vi.fn(async () => "") }, + }), + ).resolves.toMatchObject({ + status: "available", + credentials: { access: "login-access", refresh: "login-refresh" }, + }); + await expect( + resolveProviderOAuthCredentialWithPlugin({ + provider: "plugin-oauth", + credential: { + type: "oauth", + provider: "plugin-oauth", + access: "old-access", + refresh: "refresh", + expires: 1, + }, + refresh: true, + }), + ).resolves.toMatchObject({ + status: "available", + apiKey: "formatted:refreshed-access", + credential: { access: "refreshed-access" }, + }); + expect(loginOAuth).toHaveBeenCalledOnce(); + expect(refreshOAuth).toHaveBeenCalledOnce(); + }); + + it("distinguishes an owned but unavailable OAuth provider", async () => { + resolveOwningPluginIdsForProviderMock.mockReturnValue(["plugin-oauth"]); + resolvePluginProvidersMock.mockReturnValue([]); + + await expect( + loginProviderOAuthWithPlugin({ + provider: "plugin-oauth", + context: { onAuth: vi.fn(), onPrompt: vi.fn(async () => "") }, + }), + ).resolves.toEqual({ status: "configured-unavailable" }); + await expect( + resolveProviderOAuthCredentialWithPlugin({ + provider: "plugin-oauth", + credential: { + type: "oauth", + provider: "plugin-oauth", + access: "old-access", + refresh: "refresh", + expires: 1, + }, + refresh: true, + }), + ).resolves.toEqual({ status: "configured-unavailable" }); + }); + it("auto-discovers only usage providers declared by their owning plugin", () => { resolveUsageHookProviderPluginContractsMock.mockReturnValue([ { pluginId: "multi-provider", providerIds: ["declared"] }, diff --git a/src/plugins/provider-runtime.ts b/src/plugins/provider-runtime.ts index 5bafd0b0956a..7b2e84f23c28 100644 --- a/src/plugins/provider-runtime.ts +++ b/src/plugins/provider-runtime.ts @@ -50,6 +50,7 @@ import { resolveExternalAuthProfileProviderPluginIds, resolveOwningPluginIdsForProvider, resolveOwningPluginIdsForProviderRef, + resolveProviderRefOwnership, resolveUsageHookProviderPluginContracts, } from "./providers.js"; import { getActivePluginRegistryWorkspaceDirFromState } from "./runtime-state.js"; @@ -827,6 +828,59 @@ export function formatProviderAuthProfileApiKeyWithPlugin(params: { return resolveProviderRuntimePlugin(params)?.formatApiKey?.(params.context); } +export async function loginProviderOAuthWithPlugin(params: { + provider: string; + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + context: Parameters>[0]; +}) { + const ownership = resolveProviderRefOwnership(params); + const loginOAuth = resolveProviderRuntimePlugin(params)?.loginOAuth; + if (!loginOAuth) { + return { + status: ownership.status === "unowned" ? "unowned" : "configured-unavailable", + } as const; + } + return { + status: "available" as const, + credentials: await loginOAuth(params.context), + }; +} + +export async function resolveProviderOAuthCredentialWithPlugin(params: { + provider: string; + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + credential: OAuthCredential; + refresh: boolean; +}) { + const ownership = resolveProviderRefOwnership(params); + const plugin = resolveProviderRuntimePlugin(params); + if (!plugin) { + return { + status: ownership.status === "unowned" ? "unowned" : "configured-unavailable", + } as const; + } + let credential = params.credential; + if (params.refresh) { + const refreshOAuth = plugin.refreshOAuth; + if (!refreshOAuth) { + return { status: "unhandled" } as const; + } + credential = await refreshOAuth(params.credential); + } + if (!credential) { + return { status: "unhandled" } as const; + } + const apiKey = plugin.formatApiKey?.(credential) ?? credential.access; + if (typeof apiKey !== "string" || !apiKey) { + return { status: "unhandled" } as const; + } + return { status: "available" as const, credential, apiKey }; +} + export async function refreshProviderOAuthCredentialWithPlugin(params: { provider: string; config?: OpenClawConfig;