mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-30 19:32:27 +00:00
390 lines
13 KiB
TypeScript
390 lines
13 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { clearRuntimeAuthProfileStoreSnapshots } from "../../../src/agents/auth-profiles/store.js";
|
|
import type { AuthProfileStore } from "../../../src/agents/auth-profiles/types.js";
|
|
import { registerProviders, requireProvider } from "../../../src/plugins/contracts/testkit.js";
|
|
import { createNonExitingRuntime } from "../../../src/runtime.js";
|
|
import { loadBundledPluginPublicSurfaceSync } from "../../../src/test-utils/bundled-plugin-public-surface.js";
|
|
import type {
|
|
WizardMultiSelectParams,
|
|
WizardPrompter,
|
|
WizardProgress,
|
|
WizardSelectParams,
|
|
} from "../../../src/wizard/prompts.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("../../../src/plugins/provider-oauth-flow.js"))["createVpsAwareOAuthHandlers"];
|
|
type EnsureAuthProfileStore =
|
|
typeof import("openclaw/plugin-sdk/provider-auth").ensureAuthProfileStore;
|
|
type ListProfilesForProvider =
|
|
typeof import("openclaw/plugin-sdk/provider-auth").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/provider-auth", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/provider-auth")>();
|
|
return {
|
|
...actual,
|
|
ensureAuthProfileStore: ensureAuthProfileStoreMock,
|
|
listProfilesForProvider: listProfilesForProviderMock,
|
|
};
|
|
});
|
|
|
|
const { default: githubCopilotPlugin } = loadBundledPluginPublicSurfaceSync<{
|
|
default: Parameters<typeof registerProviders>[0];
|
|
}>({
|
|
pluginId: "github-copilot",
|
|
artifactBasename: "index.js",
|
|
});
|
|
const { default: openAIPlugin } = loadBundledPluginPublicSurfaceSync<{
|
|
default: Parameters<typeof registerProviders>[0];
|
|
}>({
|
|
pluginId: "openai",
|
|
artifactBasename: "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`;
|
|
}
|
|
|
|
function buildOpenAICodexOAuthResult(params: {
|
|
profileId: string;
|
|
access: string;
|
|
refresh: string;
|
|
expires: number;
|
|
email?: string;
|
|
}) {
|
|
return {
|
|
profiles: [
|
|
{
|
|
profileId: params.profileId,
|
|
credential: {
|
|
type: "oauth" as const,
|
|
provider: "openai-codex",
|
|
access: params.access,
|
|
refresh: params.refresh,
|
|
expires: params.expires,
|
|
...(params.email ? { email: params.email } : {}),
|
|
},
|
|
},
|
|
],
|
|
configPatch: {
|
|
agents: {
|
|
defaults: {
|
|
models: {
|
|
"openai-codex/gpt-5.4": {},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
defaultModel: "openai-codex/gpt-5.4",
|
|
notes: undefined,
|
|
};
|
|
}
|
|
|
|
function installSharedAuthProfileStoreHooks(state: { authStore: AuthProfileStore }) {
|
|
beforeEach(() => {
|
|
state.authStore = { version: 1, profiles: {} };
|
|
ensureAuthProfileStoreMock.mockReset();
|
|
ensureAuthProfileStoreMock.mockImplementation(() => state.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();
|
|
});
|
|
}
|
|
|
|
export function describeOpenAICodexProviderAuthContract() {
|
|
const state = {
|
|
authStore: { version: 1, profiles: {} } as AuthProfileStore,
|
|
};
|
|
|
|
describe("openai-codex provider auth contract", () => {
|
|
installSharedAuthProfileStoreHooks(state);
|
|
|
|
async function expectStableFallbackProfile(params: { access: string; profileId: string }) {
|
|
const provider = requireProvider(registerProviders(openAIPlugin), "openai-codex");
|
|
loginOpenAICodexOAuthMock.mockResolvedValueOnce({
|
|
refresh: "refresh-token",
|
|
access: params.access,
|
|
expires: 1_700_000_000_000,
|
|
});
|
|
const result = await provider.auth[0]?.run(buildAuthContext() as never);
|
|
expect(result).toEqual(
|
|
buildOpenAICodexOAuthResult({
|
|
profileId: params.profileId,
|
|
access: params.access,
|
|
refresh: "refresh-token",
|
|
expires: 1_700_000_000_000,
|
|
}),
|
|
);
|
|
}
|
|
|
|
function getProvider() {
|
|
return requireProvider(registerProviders(openAIPlugin), "openai-codex");
|
|
}
|
|
|
|
it("keeps OAuth auth results provider-owned", async () => {
|
|
const provider = getProvider();
|
|
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(
|
|
buildOpenAICodexOAuthResult({
|
|
profileId: "openai-codex:user@example.com",
|
|
access: "access-token",
|
|
refresh: "refresh-token",
|
|
expires: 1_700_000_000_000,
|
|
email: "user@example.com",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("backfills OAuth email from the JWT profile claim", async () => {
|
|
const provider = getProvider();
|
|
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(
|
|
buildOpenAICodexOAuthResult({
|
|
profileId: "openai-codex:jwt-user@example.com",
|
|
access,
|
|
refresh: "refresh-token",
|
|
expires: 1_700_000_000_000,
|
|
email: "jwt-user@example.com",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("uses a stable fallback id when JWT email is missing", async () => {
|
|
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");
|
|
await expectStableFallbackProfile({
|
|
access,
|
|
profileId: `openai-codex:id-${expectedStableId}`,
|
|
});
|
|
});
|
|
|
|
it("uses iss and sub to build a stable fallback id when auth claims are missing", async () => {
|
|
const access = createJwt({
|
|
iss: "https://accounts.openai.com",
|
|
sub: "user-abc",
|
|
});
|
|
const expectedStableId = Buffer.from("https://accounts.openai.com|user-abc").toString(
|
|
"base64url",
|
|
);
|
|
await expectStableFallbackProfile({
|
|
access,
|
|
profileId: `openai-codex:id-${expectedStableId}`,
|
|
});
|
|
});
|
|
|
|
it("uses sub alone to build a stable fallback id when iss is missing", async () => {
|
|
const access = createJwt({
|
|
sub: "user-abc",
|
|
});
|
|
const expectedStableId = Buffer.from("user-abc").toString("base64url");
|
|
await expectStableFallbackProfile({
|
|
access,
|
|
profileId: `openai-codex:id-${expectedStableId}`,
|
|
});
|
|
});
|
|
|
|
it("falls back to the default profile when JWT parsing yields no identity", async () => {
|
|
const provider = getProvider();
|
|
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(
|
|
buildOpenAICodexOAuthResult({
|
|
profileId: "openai-codex:default",
|
|
access: "not-a-jwt-token",
|
|
refresh: "refresh-token",
|
|
expires: 1_700_000_000_000,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("keeps OAuth failures non-fatal at the provider layer", async () => {
|
|
const provider = getProvider();
|
|
loginOpenAICodexOAuthMock.mockRejectedValueOnce(new Error("oauth failed"));
|
|
|
|
await expect(provider.auth[0]?.run(buildAuthContext() as never)).resolves.toEqual({
|
|
profiles: [],
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
export function describeGithubCopilotProviderAuthContract() {
|
|
const state = {
|
|
authStore: { version: 1, profiles: {} } as AuthProfileStore,
|
|
};
|
|
|
|
describe("github-copilot provider auth contract", () => {
|
|
installSharedAuthProfileStoreHooks(state);
|
|
|
|
function getProvider() {
|
|
return requireProvider(registerProviders(githubCopilotPlugin), "github-copilot");
|
|
}
|
|
|
|
it("keeps device auth results provider-owned", async () => {
|
|
const provider = getProvider();
|
|
state.authStore.profiles["github-copilot:github"] = {
|
|
type: "token",
|
|
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 auth gated on interactive TTYs", async () => {
|
|
const provider = getProvider();
|
|
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;
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}
|