mirror of https://github.com/openclaw/openclaw.git
361 lines
11 KiB
TypeScript
361 lines
11 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { OpenClawConfig } from "../../config/config.js";
|
|
import type { ProviderPlugin } from "../../plugins/types.js";
|
|
import type { RuntimeEnv } from "../../runtime.js";
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
clackCancel: vi.fn(),
|
|
clackConfirm: vi.fn(),
|
|
clackIsCancel: vi.fn((value: unknown) => value === Symbol.for("clack:cancel")),
|
|
clackSelect: vi.fn(),
|
|
clackText: vi.fn(),
|
|
resolveDefaultAgentId: vi.fn(),
|
|
resolveAgentDir: vi.fn(),
|
|
resolveAgentWorkspaceDir: vi.fn(),
|
|
resolveDefaultAgentWorkspaceDir: vi.fn(),
|
|
upsertAuthProfile: vi.fn(),
|
|
resolvePluginProviders: vi.fn(),
|
|
createClackPrompter: vi.fn(),
|
|
loadValidConfigOrThrow: vi.fn(),
|
|
updateConfig: vi.fn(),
|
|
logConfigUpdated: vi.fn(),
|
|
openUrl: vi.fn(),
|
|
loadAuthProfileStoreForRuntime: vi.fn(),
|
|
listProfilesForProvider: vi.fn(),
|
|
clearAuthProfileCooldown: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../../agents/auth-profiles.js", () => ({
|
|
loadAuthProfileStoreForRuntime: mocks.loadAuthProfileStoreForRuntime,
|
|
listProfilesForProvider: mocks.listProfilesForProvider,
|
|
clearAuthProfileCooldown: mocks.clearAuthProfileCooldown,
|
|
upsertAuthProfile: mocks.upsertAuthProfile,
|
|
}));
|
|
|
|
vi.mock("@clack/prompts", () => ({
|
|
cancel: mocks.clackCancel,
|
|
confirm: mocks.clackConfirm,
|
|
isCancel: mocks.clackIsCancel,
|
|
select: mocks.clackSelect,
|
|
text: mocks.clackText,
|
|
}));
|
|
|
|
vi.mock("../../agents/agent-scope.js", () => ({
|
|
resolveDefaultAgentId: mocks.resolveDefaultAgentId,
|
|
resolveAgentDir: mocks.resolveAgentDir,
|
|
resolveAgentWorkspaceDir: mocks.resolveAgentWorkspaceDir,
|
|
}));
|
|
|
|
vi.mock("../../agents/workspace.js", () => ({
|
|
resolveDefaultAgentWorkspaceDir: mocks.resolveDefaultAgentWorkspaceDir,
|
|
}));
|
|
|
|
vi.mock("../../plugins/providers.js", () => ({
|
|
resolvePluginProviders: mocks.resolvePluginProviders,
|
|
}));
|
|
|
|
vi.mock("../../wizard/clack-prompter.js", () => ({
|
|
createClackPrompter: mocks.createClackPrompter,
|
|
}));
|
|
|
|
vi.mock("./shared.js", async (importActual) => {
|
|
const actual = await importActual<typeof import("./shared.js")>();
|
|
return {
|
|
...actual,
|
|
loadValidConfigOrThrow: mocks.loadValidConfigOrThrow,
|
|
updateConfig: mocks.updateConfig,
|
|
};
|
|
});
|
|
|
|
vi.mock("../../config/logging.js", () => ({
|
|
logConfigUpdated: mocks.logConfigUpdated,
|
|
}));
|
|
|
|
vi.mock("../onboard-helpers.js", () => ({
|
|
openUrl: mocks.openUrl,
|
|
}));
|
|
|
|
const { modelsAuthLoginCommand, modelsAuthPasteTokenCommand, modelsAuthSetupTokenCommand } =
|
|
await import("./auth.js");
|
|
|
|
function createRuntime(): RuntimeEnv {
|
|
return {
|
|
log: vi.fn(),
|
|
error: vi.fn(),
|
|
exit: vi.fn(),
|
|
};
|
|
}
|
|
|
|
function withInteractiveStdin() {
|
|
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,
|
|
});
|
|
return () => {
|
|
if (previousIsTTYDescriptor) {
|
|
Object.defineProperty(stdin, "isTTY", previousIsTTYDescriptor);
|
|
} else if (!hadOwnIsTTY) {
|
|
delete (stdin as { isTTY?: boolean }).isTTY;
|
|
}
|
|
};
|
|
}
|
|
|
|
function createProvider(params: {
|
|
id: string;
|
|
label?: string;
|
|
run: NonNullable<ProviderPlugin["auth"]>[number]["run"];
|
|
}): ProviderPlugin {
|
|
return {
|
|
id: params.id,
|
|
label: params.label ?? params.id,
|
|
auth: [
|
|
{
|
|
id: "oauth",
|
|
label: "OAuth",
|
|
kind: "oauth",
|
|
run: params.run,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
describe("modelsAuthLoginCommand", () => {
|
|
let restoreStdin: (() => void) | null = null;
|
|
let currentConfig: OpenClawConfig;
|
|
let lastUpdatedConfig: OpenClawConfig | null;
|
|
let runProviderAuth: ReturnType<typeof vi.fn>;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
restoreStdin = withInteractiveStdin();
|
|
currentConfig = {};
|
|
lastUpdatedConfig = null;
|
|
mocks.clackCancel.mockReset();
|
|
mocks.clackConfirm.mockReset();
|
|
mocks.clackIsCancel.mockImplementation(
|
|
(value: unknown) => value === Symbol.for("clack:cancel"),
|
|
);
|
|
mocks.clackSelect.mockReset();
|
|
mocks.clackText.mockReset();
|
|
mocks.upsertAuthProfile.mockReset();
|
|
|
|
mocks.resolveDefaultAgentId.mockReturnValue("main");
|
|
mocks.resolveAgentDir.mockReturnValue("/tmp/openclaw/agents/main");
|
|
mocks.resolveAgentWorkspaceDir.mockReturnValue("/tmp/openclaw/workspace");
|
|
mocks.resolveDefaultAgentWorkspaceDir.mockReturnValue("/tmp/openclaw/workspace");
|
|
mocks.loadValidConfigOrThrow.mockImplementation(async () => currentConfig);
|
|
mocks.updateConfig.mockImplementation(
|
|
async (mutator: (cfg: OpenClawConfig) => OpenClawConfig) => {
|
|
lastUpdatedConfig = mutator(currentConfig);
|
|
currentConfig = lastUpdatedConfig;
|
|
return lastUpdatedConfig;
|
|
},
|
|
);
|
|
mocks.createClackPrompter.mockReturnValue({
|
|
note: vi.fn(async () => {}),
|
|
select: vi.fn(),
|
|
});
|
|
runProviderAuth = vi.fn().mockResolvedValue({
|
|
profiles: [
|
|
{
|
|
profileId: "openai-codex:user@example.com",
|
|
credential: {
|
|
type: "oauth",
|
|
provider: "openai-codex",
|
|
access: "access-token",
|
|
refresh: "refresh-token",
|
|
expires: Date.now() + 60_000,
|
|
email: "user@example.com",
|
|
},
|
|
},
|
|
],
|
|
defaultModel: "openai-codex/gpt-5.4",
|
|
});
|
|
mocks.resolvePluginProviders.mockReturnValue([
|
|
createProvider({
|
|
id: "openai-codex",
|
|
label: "OpenAI Codex",
|
|
run: runProviderAuth as ProviderPlugin["auth"][number]["run"],
|
|
}),
|
|
]);
|
|
mocks.loadAuthProfileStoreForRuntime.mockReturnValue({ profiles: {}, usageStats: {} });
|
|
mocks.listProfilesForProvider.mockReturnValue([]);
|
|
mocks.clearAuthProfileCooldown.mockResolvedValue(undefined);
|
|
});
|
|
|
|
afterEach(() => {
|
|
restoreStdin?.();
|
|
restoreStdin = null;
|
|
});
|
|
|
|
it("runs plugin-owned openai-codex login", async () => {
|
|
const runtime = createRuntime();
|
|
|
|
await modelsAuthLoginCommand({ provider: "openai-codex" }, runtime);
|
|
|
|
expect(runProviderAuth).toHaveBeenCalledOnce();
|
|
expect(mocks.upsertAuthProfile).toHaveBeenCalledWith({
|
|
profileId: "openai-codex:user@example.com",
|
|
credential: expect.objectContaining({
|
|
type: "oauth",
|
|
provider: "openai-codex",
|
|
}),
|
|
agentDir: "/tmp/openclaw/agents/main",
|
|
});
|
|
expect(lastUpdatedConfig?.auth?.profiles?.["openai-codex:user@example.com"]).toMatchObject({
|
|
provider: "openai-codex",
|
|
mode: "oauth",
|
|
});
|
|
expect(runtime.log).toHaveBeenCalledWith(
|
|
"Auth profile: openai-codex:user@example.com (openai-codex/oauth)",
|
|
);
|
|
expect(runtime.log).toHaveBeenCalledWith(
|
|
"Default model available: openai-codex/gpt-5.4 (use --set-default to apply)",
|
|
);
|
|
});
|
|
|
|
it("applies openai-codex default model when --set-default is used", async () => {
|
|
const runtime = createRuntime();
|
|
|
|
await modelsAuthLoginCommand({ provider: "openai-codex", setDefault: true }, runtime);
|
|
|
|
expect(lastUpdatedConfig?.agents?.defaults?.model).toEqual({
|
|
primary: "openai-codex/gpt-5.4",
|
|
});
|
|
expect(runtime.log).toHaveBeenCalledWith("Default model set to openai-codex/gpt-5.4");
|
|
});
|
|
|
|
it("clears stale auth lockouts before attempting openai-codex login", async () => {
|
|
const runtime = createRuntime();
|
|
const fakeStore = {
|
|
profiles: {
|
|
"openai-codex:user@example.com": {
|
|
type: "oauth",
|
|
provider: "openai-codex",
|
|
},
|
|
},
|
|
usageStats: {
|
|
"openai-codex:user@example.com": {
|
|
disabledUntil: Date.now() + 3_600_000,
|
|
disabledReason: "auth_permanent",
|
|
errorCount: 3,
|
|
},
|
|
},
|
|
};
|
|
mocks.loadAuthProfileStoreForRuntime.mockReturnValue(fakeStore);
|
|
mocks.listProfilesForProvider.mockReturnValue(["openai-codex:user@example.com"]);
|
|
|
|
await modelsAuthLoginCommand({ provider: "openai-codex" }, runtime);
|
|
|
|
expect(mocks.clearAuthProfileCooldown).toHaveBeenCalledWith({
|
|
store: fakeStore,
|
|
profileId: "openai-codex:user@example.com",
|
|
agentDir: "/tmp/openclaw/agents/main",
|
|
});
|
|
// Verify clearing happens before login attempt
|
|
const clearOrder = mocks.clearAuthProfileCooldown.mock.invocationCallOrder[0];
|
|
const loginOrder = runProviderAuth.mock.invocationCallOrder[0];
|
|
expect(clearOrder).toBeLessThan(loginOrder);
|
|
});
|
|
|
|
it("survives lockout clearing failure without blocking login", async () => {
|
|
const runtime = createRuntime();
|
|
mocks.loadAuthProfileStoreForRuntime.mockImplementation(() => {
|
|
throw new Error("corrupt auth-profiles.json");
|
|
});
|
|
|
|
await modelsAuthLoginCommand({ provider: "openai-codex" }, runtime);
|
|
|
|
expect(runProviderAuth).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("loads lockout state from the agent-scoped store", async () => {
|
|
const runtime = createRuntime();
|
|
mocks.loadAuthProfileStoreForRuntime.mockReturnValue({ profiles: {}, usageStats: {} });
|
|
mocks.listProfilesForProvider.mockReturnValue([]);
|
|
|
|
await modelsAuthLoginCommand({ provider: "openai-codex" }, runtime);
|
|
|
|
expect(mocks.loadAuthProfileStoreForRuntime).toHaveBeenCalledWith("/tmp/openclaw/agents/main");
|
|
});
|
|
|
|
it("reports loaded plugin providers when requested provider is unavailable", async () => {
|
|
const runtime = createRuntime();
|
|
|
|
await expect(modelsAuthLoginCommand({ provider: "anthropic" }, runtime)).rejects.toThrow(
|
|
'Unknown provider "anthropic". Loaded providers: openai-codex. Verify plugins via `openclaw plugins list --json`.',
|
|
);
|
|
});
|
|
|
|
it("does not persist a cancelled manual token entry", async () => {
|
|
const runtime = createRuntime();
|
|
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((
|
|
code?: string | number | null,
|
|
) => {
|
|
throw new Error(`exit:${String(code ?? "")}`);
|
|
}) as typeof process.exit);
|
|
try {
|
|
const cancelSymbol = Symbol.for("clack:cancel");
|
|
mocks.clackText.mockResolvedValue(cancelSymbol);
|
|
mocks.clackIsCancel.mockImplementation((value: unknown) => value === cancelSymbol);
|
|
|
|
await expect(modelsAuthPasteTokenCommand({ provider: "openai" }, runtime)).rejects.toThrow(
|
|
"exit:0",
|
|
);
|
|
|
|
expect(mocks.upsertAuthProfile).not.toHaveBeenCalled();
|
|
expect(mocks.updateConfig).not.toHaveBeenCalled();
|
|
expect(mocks.logConfigUpdated).not.toHaveBeenCalled();
|
|
} finally {
|
|
exitSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("runs token auth for any token-capable provider plugin", async () => {
|
|
const runtime = createRuntime();
|
|
const runTokenAuth = vi.fn().mockResolvedValue({
|
|
profiles: [
|
|
{
|
|
profileId: "moonshot:token",
|
|
credential: {
|
|
type: "token",
|
|
provider: "moonshot",
|
|
token: "moonshot-token",
|
|
},
|
|
},
|
|
],
|
|
});
|
|
mocks.resolvePluginProviders.mockReturnValue([
|
|
{
|
|
id: "moonshot",
|
|
label: "Moonshot",
|
|
auth: [
|
|
{
|
|
id: "setup-token",
|
|
label: "setup-token",
|
|
kind: "token",
|
|
run: runTokenAuth,
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
|
|
await modelsAuthSetupTokenCommand({ provider: "moonshot", yes: true }, runtime);
|
|
|
|
expect(runTokenAuth).toHaveBeenCalledOnce();
|
|
expect(mocks.upsertAuthProfile).toHaveBeenCalledWith({
|
|
profileId: "moonshot:token",
|
|
credential: {
|
|
type: "token",
|
|
provider: "moonshot",
|
|
token: "moonshot-token",
|
|
},
|
|
agentDir: "/tmp/openclaw/agents/main",
|
|
});
|
|
});
|
|
});
|