refactor(auth): move Copilot OAuth ownership to plugin (#118063)

* refactor(auth): move Copilot OAuth ownership to plugin

* test(auth): cover plugin-owned Copilot OAuth

* test(auth): align OAuth runtime mocks

* refactor(auth): extract session OAuth adapter

* refactor(auth): centralize session OAuth dispatch

* test(auth): type OAuth refresh mock

* chore(plugin-sdk): regenerate API baseline for provider oauth dispatch
This commit is contained in:
Peter Steinberger
2026-08-02 10:59:28 -07:00
committed by GitHub
parent eef59b572b
commit c28b524f38
29 changed files with 771 additions and 1410 deletions
@@ -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
+1
View File
@@ -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 |
+21 -5
View File
@@ -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;
+106
View File
@@ -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 = [
+23 -5
View File
@@ -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,
+75
View File
@@ -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<typeof runGitHubCopilotDeviceFlow>());
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();
});
});
+150
View File
@@ -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<OAuthCredentials> {
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<string, unknown>;
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.";
}
+2 -1
View File
@@ -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", () => ({
+8 -65
View File
@@ -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();
});
});
-21
View File
@@ -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: {
@@ -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<OAuthCredential | undefined>
>(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", () => ({
+8 -2
View File
@@ -42,7 +42,10 @@ type OAuthManagerAdapter = {
credentials: OAuthCredential,
context: { cfg?: OpenClawConfig; agentDir?: string },
) => Promise<string>;
refreshCredential: (credential: OAuthCredential) => Promise<OAuthCredentials | null>;
refreshCredential: (
credential: OAuthCredential,
context: { cfg?: OpenClawConfig; agentDir?: string },
) => Promise<OAuthCredentials | null>;
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,
@@ -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", () => ({
@@ -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,
}));
+2 -1
View File
@@ -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;
+14 -6
View File
@@ -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<OpenClawConfig["secrets"]>["defaults"];
async function refreshOAuthCredential(
credential: OAuthCredential,
context: { cfg?: OpenClawConfig } = {},
): Promise<OAuthCredentials | null> {
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<OAuthCredential | null> {
const refreshed = await refreshOAuthCredential(params.credential);
const refreshed = await refreshOAuthCredential(params.credential, { cfg: params.cfg });
return refreshed
? {
...params.credential,
@@ -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<OAuthCredentials> {
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;
}
+79 -2
View File
@@ -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<typeof import("../../plugins/provider-runtime.runtime.js")>(
"../../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);
+27 -22
View File
@@ -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<string, AuthCredential>;
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<void> {
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<string, OAuthCredentials> = {};
@@ -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;
}
}
-36
View File
@@ -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;
};
-628
View File
@@ -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<typeof fetch>) => Promise<Response>;
function startGitHubCopilotLogin(enterpriseUrl = "", signal?: AbortSignal) {
return githubCopilotOAuthProvider.login({
onAuth: vi.fn(),
onPrompt: vi.fn(async () => enterpriseUrl),
signal,
});
}
function deviceCodeResponse(overrides: Record<string, unknown> = {}): 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<OAuthCredentials>) {
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<typeof vi.fn>;
login: Promise<OAuthCredentials>;
} {
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<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) =>
new Promise<Response>((_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<Response> {
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<OAuthCredentials & { enterpriseUrl: string }>) {
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<Uint8Array>({
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<Uint8Array>({
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<typeof vi.fn>;
} {
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<Uint8Array>({
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<unknown>): Promise<Error> {
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();
});
});
-604
View File
@@ -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<Pick<CopilotRequestOptions, "timeoutMs">> & {
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<Response> {
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<unknown> {
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<DeviceCodeResponse> {
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<string, unknown>).device_code;
const userCode = (data as Record<string, unknown>).user_code;
const verificationUri = (data as Record<string, unknown>).verification_uri;
const interval = (data as Record<string, unknown>).interval;
const intervalMs = nonNegativeSecondsToSafeMilliseconds(interval);
const expiresAt = resolveExpiresAtFromDurationSeconds(
(data as Record<string, unknown>).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<void> {
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<OAuthCredentials> {
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<string, unknown>).token;
const expires = resolveExpiresAtFromEpochSeconds((raw as Record<string, unknown>).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<boolean> {
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<string[]> {
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<void> {
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<string>;
onProgress?: (message: string) => void;
signal?: AbortSignal;
}): Promise<OAuthCredentials> {
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<OAuthCredentials> {
return loginGitHubCopilot({
onAuth: (url, instructions) => callbacks.onAuth({ url, instructions }),
onPrompt: callbacks.onPrompt,
onProgress: callbacks.onProgress,
signal: callbacks.signal,
});
},
async refreshToken(credentials: OAuthCredentials): Promise<OAuthCredentials> {
// 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));
},
};
+1 -4
View File
@@ -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,
];
+1
View File
@@ -13,6 +13,7 @@ export {
parseSessionEntries,
CURRENT_SESSION_VERSION,
AuthStorage,
OAuthProviderConfiguredUnavailableError,
ExtensionRunner,
ModelRegistry,
SessionManager,
+11
View File
@@ -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<SessionOAuthCredentials>;
/**
* Legacy auth-profile ids that should be retired by `openclaw doctor`.
*
+16
View File
@@ -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;
}
}
+19
View File
@@ -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<LoginProviderOAuthWithPlugin>
): Promise<Awaited<ReturnType<LoginProviderOAuthWithPlugin>>> {
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<ResolveProviderOAuthCredentialWithPlugin>
): Promise<Awaited<ReturnType<ResolveProviderOAuthCredentialWithPlugin>>> {
const runtime = await loadProviderRuntime();
return runtime.resolveProviderOAuthCredentialWithPlugin(...args);
}
/** Lazily prepares provider runtime auth for model execution. */
export async function prepareProviderRuntimeAuth(
...args: Parameters<PrepareProviderRuntimeAuth>
+85
View File
@@ -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"] },
+54
View File
@@ -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<NonNullable<ProviderPlugin["loginOAuth"]>>[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;