Files
openclaw/src/plugins/contracts/auth.contract.test.ts
T
pomelo dad68d319b Remove Qwen OAuth integration (qwen-portal-auth) (#52709)
* Remove Qwen OAuth integration (qwen-portal-auth)

Qwen OAuth via portal.qwen.ai is being deprecated by the Qwen team due
to traffic impact on their primary Qwen Code user base. Users should
migrate to the officially supported Model Studio (Alibaba Cloud Coding
Plan) provider instead.

Ref: https://github.com/openclaw/openclaw/issues/49557

- Delete extensions/qwen-portal-auth/ plugin entirely
- Remove qwen-portal from onboarding auth choices, provider aliases,
  auto-enable list, bundled plugin defaults, and pricing cache
- Remove Qwen CLI credential sync (external-cli-sync, cli-credentials)
- Remove QWEN_OAUTH_MARKER from model auth markers
- Update docs/providers/qwen.md to redirect to Model Studio
- Update model-providers docs (EN + zh-CN) to remove Qwen OAuth section
- Regenerate config and plugin-sdk baselines
- Update all affected tests

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* Clean up residual qwen-portal references after OAuth removal

* Add migration hint for deprecated qwen-portal OAuth provider

* fix: finish qwen oauth removal follow-up

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: Frank Yang <frank.ekn@gmail.com>
2026-03-26 16:32:34 +08:00

441 lines
14 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { clearRuntimeAuthProfileStoreSnapshots } from "../../agents/auth-profiles/store.js";
import type { AuthProfileStore } from "../../agents/auth-profiles/types.js";
import { createNonExitingRuntime } from "../../runtime.js";
import type {
WizardMultiSelectParams,
WizardPrompter,
WizardProgress,
WizardSelectParams,
} from "../../wizard/prompts.js";
import { registerProviders, requireProvider } from "./testkit.js";
type LoginOpenAICodexOAuth =
(typeof import("openclaw/plugin-sdk/provider-auth-login"))["loginOpenAICodexOAuth"];
type GithubCopilotLoginCommand =
(typeof import("openclaw/plugin-sdk/provider-auth-login"))["githubCopilotLoginCommand"];
type CreateVpsAwareHandlers =
(typeof import("../provider-oauth-flow.js"))["createVpsAwareOAuthHandlers"];
type EnsureAuthProfileStore =
typeof import("openclaw/plugin-sdk/agent-runtime").ensureAuthProfileStore;
type ListProfilesForProvider =
typeof import("openclaw/plugin-sdk/agent-runtime").listProfilesForProvider;
const loginOpenAICodexOAuthMock = vi.hoisted(() => vi.fn<LoginOpenAICodexOAuth>());
const githubCopilotLoginCommandMock = vi.hoisted(() => vi.fn<GithubCopilotLoginCommand>());
const ensureAuthProfileStoreMock = vi.hoisted(() => vi.fn<EnsureAuthProfileStore>());
const listProfilesForProviderMock = vi.hoisted(() => vi.fn<ListProfilesForProvider>());
vi.mock("openclaw/plugin-sdk/provider-auth-login", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/provider-auth-login")>();
return {
...actual,
loginOpenAICodexOAuth: loginOpenAICodexOAuthMock,
githubCopilotLoginCommand: githubCopilotLoginCommandMock,
};
});
vi.mock("openclaw/plugin-sdk/agent-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/agent-runtime")>();
return {
...actual,
ensureAuthProfileStore: ensureAuthProfileStoreMock,
listProfilesForProvider: listProfilesForProviderMock,
};
});
import githubCopilotPlugin from "../../../extensions/github-copilot/index.js";
import openAIPlugin from "../../../extensions/openai/index.js";
function buildPrompter(): WizardPrompter {
const progress: WizardProgress = {
update() {},
stop() {},
};
return {
intro: async () => {},
outro: async () => {},
note: async () => {},
select: async <T>(params: WizardSelectParams<T>) => {
const option = params.options[0];
if (!option) {
throw new Error("missing select option");
}
return option.value;
},
multiselect: async <T>(params: WizardMultiSelectParams<T>) => params.initialValues ?? [],
text: async () => "",
confirm: async () => false,
progress: () => progress,
};
}
function buildAuthContext() {
return {
config: {},
prompter: buildPrompter(),
runtime: createNonExitingRuntime(),
isRemote: false,
openUrl: async () => {},
oauth: {
createVpsAwareHandlers: vi.fn<CreateVpsAwareHandlers>(),
},
};
}
function createJwt(payload: Record<string, unknown>): string {
const header = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url");
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
return `${header}.${body}.signature`;
}
describe("provider auth contract", () => {
let authStore: AuthProfileStore;
beforeEach(() => {
authStore = { version: 1, profiles: {} };
ensureAuthProfileStoreMock.mockReset();
ensureAuthProfileStoreMock.mockImplementation(() => authStore);
listProfilesForProviderMock.mockReset();
listProfilesForProviderMock.mockImplementation((store, providerId) =>
Object.entries(store.profiles)
.filter(([, credential]) => credential?.provider === providerId)
.map(([profileId]) => profileId),
);
});
afterEach(() => {
loginOpenAICodexOAuthMock.mockReset();
githubCopilotLoginCommandMock.mockReset();
ensureAuthProfileStoreMock.mockReset();
listProfilesForProviderMock.mockReset();
clearRuntimeAuthProfileStoreSnapshots();
});
it("keeps OpenAI Codex OAuth auth results provider-owned", async () => {
const provider = requireProvider(registerProviders(openAIPlugin), "openai-codex");
loginOpenAICodexOAuthMock.mockResolvedValueOnce({
email: "user@example.com",
refresh: "refresh-token",
access: "access-token",
expires: 1_700_000_000_000,
});
const result = await provider.auth[0]?.run(buildAuthContext() as never);
expect(result).toEqual({
profiles: [
{
profileId: "openai-codex:user@example.com",
credential: {
type: "oauth",
provider: "openai-codex",
access: "access-token",
refresh: "refresh-token",
expires: 1_700_000_000_000,
email: "user@example.com",
},
},
],
configPatch: {
agents: {
defaults: {
models: {
"openai-codex/gpt-5.4": {},
},
},
},
},
defaultModel: "openai-codex/gpt-5.4",
notes: undefined,
});
});
it("backfills OpenAI Codex OAuth email from the JWT profile claim", async () => {
const provider = requireProvider(registerProviders(openAIPlugin), "openai-codex");
const access = createJwt({
"https://api.openai.com/profile": {
email: "jwt-user@example.com",
},
});
loginOpenAICodexOAuthMock.mockResolvedValueOnce({
refresh: "refresh-token",
access,
expires: 1_700_000_000_000,
});
const result = await provider.auth[0]?.run(buildAuthContext() as never);
expect(result).toEqual({
profiles: [
{
profileId: "openai-codex:jwt-user@example.com",
credential: {
type: "oauth",
provider: "openai-codex",
access,
refresh: "refresh-token",
expires: 1_700_000_000_000,
email: "jwt-user@example.com",
},
},
],
configPatch: {
agents: {
defaults: {
models: {
"openai-codex/gpt-5.4": {},
},
},
},
},
defaultModel: "openai-codex/gpt-5.4",
notes: undefined,
});
});
it("uses a stable fallback id when OpenAI Codex JWT email is missing", async () => {
const provider = requireProvider(registerProviders(openAIPlugin), "openai-codex");
const access = createJwt({
"https://api.openai.com/auth": {
chatgpt_account_user_id: "user-123__acct-456",
},
});
const expectedStableId = Buffer.from("user-123__acct-456", "utf8").toString("base64url");
loginOpenAICodexOAuthMock.mockResolvedValueOnce({
refresh: "refresh-token",
access,
expires: 1_700_000_000_000,
});
const result = await provider.auth[0]?.run(buildAuthContext() as never);
expect(result).toEqual({
profiles: [
{
profileId: `openai-codex:id-${expectedStableId}`,
credential: {
type: "oauth",
provider: "openai-codex",
access,
refresh: "refresh-token",
expires: 1_700_000_000_000,
},
},
],
configPatch: {
agents: {
defaults: {
models: {
"openai-codex/gpt-5.4": {},
},
},
},
},
defaultModel: "openai-codex/gpt-5.4",
notes: undefined,
});
});
it("uses iss and sub to build a stable fallback id when auth claims are missing", async () => {
const provider = requireProvider(registerProviders(openAIPlugin), "openai-codex");
const access = createJwt({
iss: "https://accounts.openai.com",
sub: "user-abc",
});
const expectedStableId = Buffer.from("https://accounts.openai.com|user-abc").toString(
"base64url",
);
loginOpenAICodexOAuthMock.mockResolvedValueOnce({
refresh: "refresh-token",
access,
expires: 1_700_000_000_000,
});
const result = await provider.auth[0]?.run(buildAuthContext() as never);
expect(result).toEqual({
profiles: [
{
profileId: `openai-codex:id-${expectedStableId}`,
credential: {
type: "oauth",
provider: "openai-codex",
access,
refresh: "refresh-token",
expires: 1_700_000_000_000,
},
},
],
configPatch: {
agents: {
defaults: {
models: {
"openai-codex/gpt-5.4": {},
},
},
},
},
defaultModel: "openai-codex/gpt-5.4",
notes: undefined,
});
});
it("uses sub alone to build a stable fallback id when iss is missing", async () => {
const provider = requireProvider(registerProviders(openAIPlugin), "openai-codex");
const access = createJwt({
sub: "user-abc",
});
const expectedStableId = Buffer.from("user-abc").toString("base64url");
loginOpenAICodexOAuthMock.mockResolvedValueOnce({
refresh: "refresh-token",
access,
expires: 1_700_000_000_000,
});
const result = await provider.auth[0]?.run(buildAuthContext() as never);
expect(result).toEqual({
profiles: [
{
profileId: `openai-codex:id-${expectedStableId}`,
credential: {
type: "oauth",
provider: "openai-codex",
access,
refresh: "refresh-token",
expires: 1_700_000_000_000,
},
},
],
configPatch: {
agents: {
defaults: {
models: {
"openai-codex/gpt-5.4": {},
},
},
},
},
defaultModel: "openai-codex/gpt-5.4",
notes: undefined,
});
});
it("falls back to the default OpenAI Codex profile when JWT parsing yields no identity", async () => {
const provider = requireProvider(registerProviders(openAIPlugin), "openai-codex");
loginOpenAICodexOAuthMock.mockResolvedValueOnce({
refresh: "refresh-token",
access: "not-a-jwt-token",
expires: 1_700_000_000_000,
});
const result = await provider.auth[0]?.run(buildAuthContext() as never);
expect(result).toEqual({
profiles: [
{
profileId: "openai-codex:default",
credential: {
type: "oauth",
provider: "openai-codex",
access: "not-a-jwt-token",
refresh: "refresh-token",
expires: 1_700_000_000_000,
},
},
],
configPatch: {
agents: {
defaults: {
models: {
"openai-codex/gpt-5.4": {},
},
},
},
},
defaultModel: "openai-codex/gpt-5.4",
notes: undefined,
});
});
it("keeps OpenAI Codex OAuth failures non-fatal at the provider layer", async () => {
const provider = requireProvider(registerProviders(openAIPlugin), "openai-codex");
loginOpenAICodexOAuthMock.mockRejectedValueOnce(new Error("oauth failed"));
await expect(provider.auth[0]?.run(buildAuthContext() as never)).resolves.toEqual({
profiles: [],
});
});
it("keeps GitHub Copilot device auth results provider-owned", async () => {
const provider = requireProvider(registerProviders(githubCopilotPlugin), "github-copilot");
authStore.profiles["github-copilot:github"] = {
type: "token" as const,
provider: "github-copilot",
token: "github-device-token",
};
const stdin = process.stdin as NodeJS.ReadStream & { isTTY?: boolean };
const hadOwnIsTTY = Object.prototype.hasOwnProperty.call(stdin, "isTTY");
const previousIsTTYDescriptor = Object.getOwnPropertyDescriptor(stdin, "isTTY");
Object.defineProperty(stdin, "isTTY", {
configurable: true,
enumerable: true,
get: () => true,
});
try {
const result = await provider.auth[0]?.run(buildAuthContext() as never);
expect(githubCopilotLoginCommandMock).toHaveBeenCalledWith(
{ yes: true, profileId: "github-copilot:github" },
expect.any(Object),
);
expect(result).toEqual({
profiles: [
{
profileId: "github-copilot:github",
credential: {
type: "token",
provider: "github-copilot",
token: "github-device-token",
},
},
],
defaultModel: "github-copilot/gpt-4o",
});
} finally {
if (previousIsTTYDescriptor) {
Object.defineProperty(stdin, "isTTY", previousIsTTYDescriptor);
} else if (!hadOwnIsTTY) {
delete (stdin as { isTTY?: boolean }).isTTY;
}
}
});
it("keeps GitHub Copilot auth gated on interactive TTYs", async () => {
const provider = requireProvider(registerProviders(githubCopilotPlugin), "github-copilot");
const stdin = process.stdin as NodeJS.ReadStream & { isTTY?: boolean };
const hadOwnIsTTY = Object.prototype.hasOwnProperty.call(stdin, "isTTY");
const previousIsTTYDescriptor = Object.getOwnPropertyDescriptor(stdin, "isTTY");
Object.defineProperty(stdin, "isTTY", {
configurable: true,
enumerable: true,
get: () => false,
});
try {
await expect(provider.auth[0]?.run(buildAuthContext() as never)).resolves.toEqual({
profiles: [],
});
expect(githubCopilotLoginCommandMock).not.toHaveBeenCalled();
} finally {
if (previousIsTTYDescriptor) {
Object.defineProperty(stdin, "isTTY", previousIsTTYDescriptor);
} else if (!hadOwnIsTTY) {
delete (stdin as { isTTY?: boolean }).isTTY;
}
}
});
});