mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
eef59b572b
commit
c28b524f38
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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.";
|
||||
}
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ export {
|
||||
parseSessionEntries,
|
||||
CURRENT_SESSION_VERSION,
|
||||
AuthStorage,
|
||||
OAuthProviderConfiguredUnavailableError,
|
||||
ExtensionRunner,
|
||||
ModelRegistry,
|
||||
SessionManager,
|
||||
|
||||
@@ -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`.
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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"] },
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user