From 5edf876a5e08eb57ac9a17e91ec2bfe0a46f7a9e Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 17 Apr 2026 14:04:50 -0700 Subject: [PATCH] test(auth): add codex oauth red-blue coverage --- .../openai/openai-codex-cli-auth.test.ts | 43 +++ extensions/openai/openai-codex-cli-auth.ts | 1 - extensions/qa-lab/src/gateway-child.test.ts | 65 ++-- .../src/providers/aimock/server.test.ts | 28 ++ src/agents/auth-health.test.ts | 4 +- .../auth-profiles.external-cli-sync.test.ts | 356 ++++++++++-------- src/agents/auth-profiles.store-cache.test.ts | 80 ++-- src/agents/auth-profiles/external-auth.ts | 5 +- src/agents/auth-profiles/external-cli-sync.ts | 39 +- .../auth-profiles/external-oauth.test.ts | 6 +- .../auth-profiles/oauth-refresh-queue.test.ts | 12 +- .../oauth.adopt-identity.test.ts | 12 +- .../oauth.concurrent-agents.test.ts | 27 +- .../oauth.mirror-refresh.test.ts | 12 +- ...s-writing-models-json-no-env-token.test.ts | 5 + src/agents/pi-auth-json.test.ts | 3 +- src/commands/configure.channels.ts | 36 +- src/docs/clawhub-plugin-docs.test.ts | 2 +- src/flows/channel-setup.status.test.ts | 161 ++++---- src/flows/channel-setup.test.ts | 211 +++++++---- 20 files changed, 606 insertions(+), 502 deletions(-) diff --git a/extensions/openai/openai-codex-cli-auth.test.ts b/extensions/openai/openai-codex-cli-auth.test.ts index a8bcd103fad..267b3202f0d 100644 --- a/extensions/openai/openai-codex-cli-auth.test.ts +++ b/extensions/openai/openai-codex-cli-auth.test.ts @@ -96,6 +96,49 @@ describe("readOpenAICodexCliOAuthProfile", () => { expect(parsed).toBeNull(); }); + it("allows the runtime-only Codex CLI profile when the stored default already matches", () => { + const accessToken = buildJwt({ + exp: Math.floor(Date.now() / 1000) + 600, + "https://api.openai.com/profile": { + email: "codex@example.com", + }, + }); + vi.spyOn(fs, "readFileSync").mockReturnValue( + JSON.stringify({ + auth_mode: "chatgpt", + tokens: { + access_token: accessToken, + refresh_token: "refresh-token", + account_id: "acct_123", + }, + }), + ); + + const firstParse = readOpenAICodexCliOAuthProfile({ + store: { version: 1, profiles: {} }, + }); + expect(firstParse).not.toBeNull(); + + const parsed = readOpenAICodexCliOAuthProfile({ + store: { + version: 1, + profiles: { + [OPENAI_CODEX_DEFAULT_PROFILE_ID]: firstParse!.credential, + }, + }, + }); + + expect(parsed).toMatchObject({ + profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID, + credential: { + access: accessToken, + refresh: "refresh-token", + accountId: "acct_123", + email: "codex@example.com", + }, + }); + }); + it("returns null without logging when the Codex CLI auth file is missing", () => { const error = Object.assign(new Error("missing"), { code: "ENOENT", diff --git a/extensions/openai/openai-codex-cli-auth.ts b/extensions/openai/openai-codex-cli-auth.ts index 314d7560063..63c5fd111e5 100644 --- a/extensions/openai/openai-codex-cli-auth.ts +++ b/extensions/openai/openai-codex-cli-auth.ts @@ -67,7 +67,6 @@ function oauthCredentialMatches(a: OAuthCredential, b: OAuthCredential): boolean a.provider === b.provider && a.access === b.access && a.refresh === b.refresh && - a.expires === b.expires && a.clientId === b.clientId && a.email === b.email && a.displayName === b.displayName && diff --git a/extensions/qa-lab/src/gateway-child.test.ts b/extensions/qa-lab/src/gateway-child.test.ts index 820f60c2774..465f7138b62 100644 --- a/extensions/qa-lab/src/gateway-child.test.ts +++ b/extensions/qa-lab/src/gateway-child.test.ts @@ -268,38 +268,41 @@ describe("buildQaRuntimeEnv", () => { expect(env.CODEX_HOME).toBe("/custom/codex-home"); }); - it("scrubs direct and live provider keys in mock mode", () => { - const env = buildQaRuntimeEnv({ - ...createParams({ - ANTHROPIC_API_KEY: "anthropic-live", - ANTHROPIC_OAUTH_TOKEN: "anthropic-oauth", - GEMINI_API_KEY: "gemini-live", - GEMINI_API_KEYS: "gemini-a gemini-b", - GOOGLE_API_KEY: "google-live", - OPENAI_API_KEY: "openai-live", - OPENAI_API_KEYS: "openai-a,openai-b", - CODEX_HOME: "/host/.codex", - OPENCLAW_LIVE_ANTHROPIC_KEY: "anthropic-live", - OPENCLAW_LIVE_ANTHROPIC_KEYS: "anthropic-a,anthropic-b", - OPENCLAW_LIVE_GEMINI_KEY: "gemini-live", - OPENCLAW_LIVE_OPENAI_KEY: "openai-live", - }), - providerMode: "mock-openai", - }); + it.each(["mock-openai", "aimock"] as const)( + "scrubs direct and live provider keys in %s mode", + (providerMode) => { + const env = buildQaRuntimeEnv({ + ...createParams({ + ANTHROPIC_API_KEY: "anthropic-live", + ANTHROPIC_OAUTH_TOKEN: "anthropic-oauth", + GEMINI_API_KEY: "gemini-live", + GEMINI_API_KEYS: "gemini-a gemini-b", + GOOGLE_API_KEY: "google-live", + OPENAI_API_KEY: "openai-live", + OPENAI_API_KEYS: "openai-a,openai-b", + CODEX_HOME: "/host/.codex", + OPENCLAW_LIVE_ANTHROPIC_KEY: "anthropic-live", + OPENCLAW_LIVE_ANTHROPIC_KEYS: "anthropic-a,anthropic-b", + OPENCLAW_LIVE_GEMINI_KEY: "gemini-live", + OPENCLAW_LIVE_OPENAI_KEY: "openai-live", + }), + providerMode, + }); - expect(env.OPENAI_API_KEY).toBeUndefined(); - expect(env.OPENAI_API_KEYS).toBeUndefined(); - expect(env.CODEX_HOME).toBeUndefined(); - expect(env.ANTHROPIC_API_KEY).toBeUndefined(); - expect(env.ANTHROPIC_OAUTH_TOKEN).toBeUndefined(); - expect(env.GEMINI_API_KEY).toBeUndefined(); - expect(env.GEMINI_API_KEYS).toBeUndefined(); - expect(env.GOOGLE_API_KEY).toBeUndefined(); - expect(env.OPENCLAW_LIVE_OPENAI_KEY).toBeUndefined(); - expect(env.OPENCLAW_LIVE_ANTHROPIC_KEY).toBeUndefined(); - expect(env.OPENCLAW_LIVE_ANTHROPIC_KEYS).toBeUndefined(); - expect(env.OPENCLAW_LIVE_GEMINI_KEY).toBeUndefined(); - }); + expect(env.OPENAI_API_KEY).toBeUndefined(); + expect(env.OPENAI_API_KEYS).toBeUndefined(); + expect(env.CODEX_HOME).toBeUndefined(); + expect(env.ANTHROPIC_API_KEY).toBeUndefined(); + expect(env.ANTHROPIC_OAUTH_TOKEN).toBeUndefined(); + expect(env.GEMINI_API_KEY).toBeUndefined(); + expect(env.GEMINI_API_KEYS).toBeUndefined(); + expect(env.GOOGLE_API_KEY).toBeUndefined(); + expect(env.OPENCLAW_LIVE_OPENAI_KEY).toBeUndefined(); + expect(env.OPENCLAW_LIVE_ANTHROPIC_KEY).toBeUndefined(); + expect(env.OPENCLAW_LIVE_ANTHROPIC_KEYS).toBeUndefined(); + expect(env.OPENCLAW_LIVE_GEMINI_KEY).toBeUndefined(); + }, + ); it("treats restart socket closures as retryable gateway call errors", () => { expect(__testing.isRetryableGatewayCallError("gateway closed (1006 abnormal closure)")).toBe( diff --git a/extensions/qa-lab/src/providers/aimock/server.test.ts b/extensions/qa-lab/src/providers/aimock/server.test.ts index 936e64daca0..57e181c13f1 100644 --- a/extensions/qa-lab/src/providers/aimock/server.test.ts +++ b/extensions/qa-lab/src/providers/aimock/server.test.ts @@ -79,4 +79,32 @@ describe("qa aimock server", () => { await server.stop(); } }); + + it("treats OpenAI Codex model refs as OpenAI-compatible snapshots", async () => { + const server = await startQaAimockServer({ + host: "127.0.0.1", + port: 0, + }); + try { + const response = await fetch(`${server.baseUrl}/v1/responses`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "openai-codex/gpt-5.4", + stream: false, + input: [makeResponsesInput("hello codex-compatible aimock")], + }), + }); + expect(response.status).toBe(200); + + const debug = await fetch(`${server.baseUrl}/debug/last-request`); + expect(debug.status).toBe(200); + expect(await debug.json()).toMatchObject({ + model: "openai-codex/gpt-5.4", + providerVariant: "openai", + }); + } finally { + await server.stop(); + } + }); }); diff --git a/src/agents/auth-health.test.ts b/src/agents/auth-health.test.ts index bd7420ed540..076580ff433 100644 --- a/src/agents/auth-health.test.ts +++ b/src/agents/auth-health.test.ts @@ -1,8 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { CodexCliCredential } from "./cli-credentials.js"; +import type { OAuthCredential } from "./auth-profiles/types.js"; const { readCodexCliCredentialsCachedMock } = vi.hoisted(() => ({ - readCodexCliCredentialsCachedMock: vi.fn((): CodexCliCredential | null => null), + readCodexCliCredentialsCachedMock: vi.fn<() => OAuthCredential | null>(() => null), })); vi.mock("./cli-credentials.js", () => ({ diff --git a/src/agents/auth-profiles.external-cli-sync.test.ts b/src/agents/auth-profiles.external-cli-sync.test.ts index 4e738eef80d..63d815abeea 100644 --- a/src/agents/auth-profiles.external-cli-sync.test.ts +++ b/src/agents/auth-profiles.external-cli-sync.test.ts @@ -6,11 +6,10 @@ const mocks = vi.hoisted(() => ({ readMiniMaxCliCredentialsCached: vi.fn<() => OAuthCredential | null>(() => null), })); -let readExternalCliBootstrapCredential: typeof import("./auth-profiles/external-cli-sync.js").readExternalCliBootstrapCredential; -let resolveExternalCliAuthProfiles: typeof import("./auth-profiles/external-cli-sync.js").resolveExternalCliAuthProfiles; -let hasUsableOAuthCredential: typeof import("./auth-profiles/external-cli-sync.js").hasUsableOAuthCredential; -let shouldBootstrapFromExternalCliCredential: typeof import("./auth-profiles/external-cli-sync.js").shouldBootstrapFromExternalCliCredential; +let syncExternalCliCredentials: typeof import("./auth-profiles/external-cli-sync.js").syncExternalCliCredentials; let shouldReplaceStoredOAuthCredential: typeof import("./auth-profiles/external-cli-sync.js").shouldReplaceStoredOAuthCredential; +let resolveExternalCliAuthProfiles: typeof import("./auth-profiles/external-cli-sync.js").resolveExternalCliAuthProfiles; +let CODEX_CLI_PROFILE_ID: typeof import("./auth-profiles/constants.js").CODEX_CLI_PROFILE_ID; let OPENAI_CODEX_DEFAULT_PROFILE_ID: typeof import("./auth-profiles/constants.js").OPENAI_CODEX_DEFAULT_PROFILE_ID; let MINIMAX_CLI_PROFILE_ID: typeof import("./auth-profiles/constants.js").MINIMAX_CLI_PROFILE_ID; @@ -37,23 +36,40 @@ function makeStore(profileId?: string, credential?: OAuthCredential): AuthProfil }; } -describe("external cli oauth resolution", () => { +function getProviderCases() { + return [ + { + label: "Codex", + profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID, + provider: "openai-codex" as const, + readMock: mocks.readCodexCliCredentialsCached, + legacyProfileId: CODEX_CLI_PROFILE_ID, + }, + { + label: "MiniMax", + profileId: MINIMAX_CLI_PROFILE_ID, + provider: "minimax-portal" as const, + readMock: mocks.readMiniMaxCliCredentialsCached, + }, + ]; +} + +describe("syncExternalCliCredentials", () => { beforeEach(async () => { vi.resetModules(); + vi.doUnmock("./auth-profiles/external-cli-sync.js"); + mocks.readCodexCliCredentialsCached.mockReset().mockReturnValue(null); + mocks.readMiniMaxCliCredentialsCached.mockReset().mockReturnValue(null); vi.doMock("./cli-credentials.js", () => ({ readCodexCliCredentialsCached: mocks.readCodexCliCredentialsCached, readMiniMaxCliCredentialsCached: mocks.readMiniMaxCliCredentialsCached, })); - mocks.readCodexCliCredentialsCached.mockReset().mockReturnValue(null); - mocks.readMiniMaxCliCredentialsCached.mockReset().mockReturnValue(null); ({ - hasUsableOAuthCredential, - readExternalCliBootstrapCredential, - resolveExternalCliAuthProfiles, - shouldBootstrapFromExternalCliCredential, + syncExternalCliCredentials, shouldReplaceStoredOAuthCredential, + resolveExternalCliAuthProfiles, } = await import("./auth-profiles/external-cli-sync.js")); - ({ OPENAI_CODEX_DEFAULT_PROFILE_ID, MINIMAX_CLI_PROFILE_ID } = + ({ CODEX_CLI_PROFILE_ID, OPENAI_CODEX_DEFAULT_PROFILE_ID, MINIMAX_CLI_PROFILE_ID } = await import("./auth-profiles/constants.js")); }); @@ -108,159 +124,43 @@ describe("external cli oauth resolution", () => { }); }); - describe("external cli bootstrap policy", () => { - it("treats only non-expired access tokens as usable local oauth", () => { - expect( - hasUsableOAuthCredential( - makeOAuthCredential({ - provider: "openai-codex", - access: "live-access", - expires: Date.now() + 60_000, - }), - ), - ).toBe(true); - expect( - hasUsableOAuthCredential( - makeOAuthCredential({ - provider: "openai-codex", - access: "expired-access", - expires: Date.now() - 60_000, - }), - ), - ).toBe(false); - expect( - hasUsableOAuthCredential( - makeOAuthCredential({ - provider: "openai-codex", - access: "", - expires: Date.now() + 60_000, - }), - ), - ).toBe(false); - }); - - it("only bootstraps from external cli when the stored oauth is not usable", () => { - const imported = makeOAuthCredential({ - provider: "openai-codex", - access: "fresh-cli-access", - refresh: "fresh-cli-refresh", - expires: Date.now() + 5 * 24 * 60 * 60_000, - }); - - expect( - shouldBootstrapFromExternalCliCredential({ - existing: makeOAuthCredential({ - provider: "openai-codex", - access: "healthy-local-access", - refresh: "healthy-local-refresh", - expires: Date.now() + 60_000, - }), - imported, - }), - ).toBe(false); - expect( - shouldBootstrapFromExternalCliCredential({ - existing: makeOAuthCredential({ - provider: "openai-codex", - access: "expired-local-access", - refresh: "expired-local-refresh", - expires: Date.now() - 60_000, - }), - imported, - }), - ).toBe(true); - }); - }); - - it("reads codex external cli credentials by profile id", () => { + it("resolves runtime-only CLI auth overlays without persisting external ownership metadata", () => { + const expires = Date.now() + 60_000; mocks.readCodexCliCredentialsCached.mockReturnValue( makeOAuthCredential({ provider: "openai-codex", access: "codex-access-token", refresh: "codex-refresh-token", + expires, }), ); - const credential = readExternalCliBootstrapCredential({ - profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID, - credential: makeOAuthCredential({ provider: "openai-codex" }), - }); + const profiles = resolveExternalCliAuthProfiles(makeStore()); - expect(credential).toMatchObject({ - access: "codex-access-token", - refresh: "codex-refresh-token", - }); - }); - - it("returns null when the profile id/provider do not map to the same external source", () => { - mocks.readCodexCliCredentialsCached.mockReturnValue( - makeOAuthCredential({ provider: "openai-codex" }), - ); - - const credential = readExternalCliBootstrapCredential({ - profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID, - credential: makeOAuthCredential({ provider: "anthropic" }), - }); - - expect(credential).toBeNull(); - }); - - it("resolves fresher codex and minimax external oauth profiles as runtime overlays", () => { - mocks.readCodexCliCredentialsCached.mockReturnValue( - makeOAuthCredential({ - provider: "openai-codex", - access: "codex-fresh-access", - refresh: "codex-fresh-refresh", - expires: Date.now() + 5 * 24 * 60 * 60_000, - }), - ); - mocks.readMiniMaxCliCredentialsCached.mockReturnValue( - makeOAuthCredential({ - provider: "minimax-portal", - access: "minimax-fresh-access", - refresh: "minimax-fresh-refresh", - expires: Date.now() + 5 * 24 * 60 * 60_000, - }), - ); - - const profiles = resolveExternalCliAuthProfiles({ - version: 1, - profiles: { - [OPENAI_CODEX_DEFAULT_PROFILE_ID]: makeOAuthCredential({ + expect(profiles).toEqual([ + expect.objectContaining({ + profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID, + credential: expect.objectContaining({ + type: "oauth", provider: "openai-codex", - access: "codex-stale-access", - refresh: "codex-stale-refresh", - expires: Date.now() - 5_000, + access: "codex-access-token", + refresh: "codex-refresh-token", + expires, }), - [MINIMAX_CLI_PROFILE_ID]: makeOAuthCredential({ - provider: "minimax-portal", - access: "minimax-stale-access", - refresh: "minimax-stale-refresh", - expires: Date.now() - 5_000, - }), - }, - }); - - const profilesById = new Map( - profiles.map((profile) => [profile.profileId, profile.credential]), - ); - expect(profilesById.get(OPENAI_CODEX_DEFAULT_PROFILE_ID)).toMatchObject({ - access: "codex-fresh-access", - refresh: "codex-fresh-refresh", - }); - expect(profilesById.get(MINIMAX_CLI_PROFILE_ID)).toMatchObject({ - access: "minimax-fresh-access", - refresh: "minimax-fresh-refresh", - }); + }), + ]); + expect(profiles[0]?.credential.managedBy).toBeUndefined(); }); - it("does not emit runtime overlays when the stored credential is newer", () => { + it("skips runtime-only overlays when the stored credential is fresher", () => { + const staleExpiry = Date.now() + 30 * 60_000; + const freshExpiry = Date.now() + 5 * 24 * 60 * 60_000; mocks.readCodexCliCredentialsCached.mockReturnValue( makeOAuthCredential({ provider: "openai-codex", - access: "stale-external-access", - refresh: "stale-external-refresh", - expires: Date.now() - 5_000, + access: "stale-access-token", + refresh: "stale-refresh-token", + expires: staleExpiry, }), ); @@ -269,9 +169,9 @@ describe("external cli oauth resolution", () => { OPENAI_CODEX_DEFAULT_PROFILE_ID, makeOAuthCredential({ provider: "openai-codex", - access: "fresh-store-access", - refresh: "fresh-store-refresh", - expires: Date.now() + 5 * 24 * 60 * 60_000, + access: "fresh-access-token", + refresh: "fresh-refresh-token", + expires: freshExpiry, }), ), ); @@ -279,28 +179,150 @@ describe("external cli oauth resolution", () => { expect(profiles).toEqual([]); }); - it("does not overlay fresh external cli oauth over a still-usable local credential", () => { + it.each([{ providerLabel: "Codex" }, { providerLabel: "MiniMax" }])( + "syncs $providerLabel CLI credentials into the target auth profile", + ({ providerLabel }) => { + const providerCase = getProviderCases().find((entry) => entry.label === providerLabel); + expect(providerCase).toBeDefined(); + const current = providerCase!; + const expires = Date.now() + 60_000; + current.readMock.mockReturnValue( + makeOAuthCredential({ + provider: current.provider, + access: `${current.provider}-access-token`, + refresh: `${current.provider}-refresh-token`, + expires, + accountId: "acct_123", + }), + ); + + const store = makeStore(); + + const mutated = syncExternalCliCredentials(store); + + expect(mutated).toBe(true); + expect(current.readMock).toHaveBeenCalledWith( + expect.objectContaining({ ttlMs: expect.any(Number) }), + ); + expect(store.profiles[current.profileId]).toMatchObject({ + type: "oauth", + provider: current.provider, + access: `${current.provider}-access-token`, + refresh: `${current.provider}-refresh-token`, + expires, + accountId: "acct_123", + managedBy: current.provider === "openai-codex" ? "codex-cli" : ("minimax-cli" as const), + }); + if (current.legacyProfileId) { + expect(store.profiles[current.legacyProfileId]).toBeUndefined(); + } + }, + ); + + it("refreshes stored Codex expiry from external CLI even when the cached profile looks fresh", () => { + const staleExpiry = Date.now() + 30 * 60_000; + const freshExpiry = Date.now() + 5 * 24 * 60 * 60_000; mocks.readCodexCliCredentialsCached.mockReturnValue( makeOAuthCredential({ provider: "openai-codex", - access: "fresh-cli-access", - refresh: "fresh-cli-refresh", - expires: Date.now() + 5 * 24 * 60 * 60_000, + access: "new-access-token", + refresh: "new-refresh-token", + expires: freshExpiry, + accountId: "acct_456", }), ); - const profiles = resolveExternalCliAuthProfiles( - makeStore( - OPENAI_CODEX_DEFAULT_PROFILE_ID, - makeOAuthCredential({ - provider: "openai-codex", - access: "healthy-local-access", - refresh: "healthy-local-refresh", - expires: Date.now() + 60_000, - }), - ), + const store = makeStore( + OPENAI_CODEX_DEFAULT_PROFILE_ID, + makeOAuthCredential({ + provider: "openai-codex", + access: "old-access-token", + refresh: "old-refresh-token", + expires: staleExpiry, + accountId: "acct_456", + }), ); - expect(profiles).toEqual([]); + const mutated = syncExternalCliCredentials(store); + + expect(mutated).toBe(true); + expect(store.profiles[OPENAI_CODEX_DEFAULT_PROFILE_ID]).toMatchObject({ + access: "new-access-token", + refresh: "new-refresh-token", + expires: freshExpiry, + managedBy: "codex-cli", + }); + }); + + it.each([{ providerLabel: "Codex" }, { providerLabel: "MiniMax" }])( + "does not overwrite newer stored $providerLabel credentials", + ({ providerLabel }) => { + const providerCase = getProviderCases().find((entry) => entry.label === providerLabel); + expect(providerCase).toBeDefined(); + const current = providerCase!; + const staleExpiry = Date.now() + 30 * 60_000; + const freshExpiry = Date.now() + 5 * 24 * 60 * 60_000; + current.readMock.mockReturnValue( + makeOAuthCredential({ + provider: current.provider, + access: `stale-${current.provider}-access-token`, + refresh: `stale-${current.provider}-refresh-token`, + expires: staleExpiry, + accountId: "acct_789", + }), + ); + + const store = makeStore( + current.profileId, + makeOAuthCredential({ + provider: current.provider, + access: `fresh-${current.provider}-access-token`, + refresh: `fresh-${current.provider}-refresh-token`, + expires: freshExpiry, + accountId: "acct_789", + }), + ); + + const mutated = syncExternalCliCredentials(store); + + expect(mutated).toBe(false); + expect(store.profiles[current.profileId]).toMatchObject({ + access: `fresh-${current.provider}-access-token`, + refresh: `fresh-${current.provider}-refresh-token`, + expires: freshExpiry, + }); + }, + ); + + it("upgrades matching Codex CLI credentials with external ownership metadata", () => { + const expires = Date.now() + 60_000; + mocks.readCodexCliCredentialsCached.mockReturnValue( + makeOAuthCredential({ + provider: "openai-codex", + access: "same-access-token", + refresh: "same-refresh-token", + expires, + }), + ); + + const store = makeStore( + OPENAI_CODEX_DEFAULT_PROFILE_ID, + makeOAuthCredential({ + provider: "openai-codex", + access: "same-access-token", + refresh: "same-refresh-token", + expires, + }), + ); + + const mutated = syncExternalCliCredentials(store); + + expect(mutated).toBe(true); + expect(store.profiles[OPENAI_CODEX_DEFAULT_PROFILE_ID]).toMatchObject({ + access: "same-access-token", + refresh: "same-refresh-token", + expires, + managedBy: "codex-cli", + }); }); }); diff --git a/src/agents/auth-profiles.store-cache.test.ts b/src/agents/auth-profiles.store-cache.test.ts index ca801543780..8f03bf2fae7 100644 --- a/src/agents/auth-profiles.store-cache.test.ts +++ b/src/agents/auth-profiles.store-cache.test.ts @@ -3,15 +3,20 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { AUTH_STORE_VERSION } from "./auth-profiles/constants.js"; +import type { OAuthCredential } from "./auth-profiles/types.js"; -type ExternalAuthProfiles = ReturnType< - typeof import("../plugins/provider-runtime.js").resolveExternalAuthProfilesWithPlugins ->; +type RuntimeOnlyOverlay = { profileId: string; credential: OAuthCredential }; -const resolveExternalAuthProfilesWithPluginsMock = vi.fn<() => ExternalAuthProfiles>(() => []); +const mocks = vi.hoisted(() => ({ + resolveExternalCliAuthProfiles: vi.fn<() => RuntimeOnlyOverlay[]>(() => []), +})); + +vi.mock("./auth-profiles/external-cli-sync.js", () => ({ + resolveExternalCliAuthProfiles: mocks.resolveExternalCliAuthProfiles, +})); vi.mock("../plugins/provider-runtime.js", () => ({ - resolveExternalAuthProfilesWithPlugins: resolveExternalAuthProfilesWithPluginsMock, + resolveExternalAuthProfilesWithPlugins: () => [], })); let clearRuntimeAuthProfileStoreSnapshots: typeof import("./auth-profiles.js").clearRuntimeAuthProfileStoreSnapshots; @@ -77,29 +82,41 @@ describe("auth profile store cache", () => { afterEach(() => { vi.useRealTimers(); clearRuntimeAuthProfileStoreSnapshots(); - resolveExternalAuthProfilesWithPluginsMock.mockReset(); - resolveExternalAuthProfilesWithPluginsMock.mockReturnValue([]); vi.clearAllMocks(); }); - it("reuses the cached auth store while auth-profiles.json is unchanged", async () => { + function createRuntimeOnlyOverlay(access: string): RuntimeOnlyOverlay { + return { + profileId: "openai-codex:default", + credential: { + type: "oauth", + provider: "openai-codex", + access, + refresh: `refresh-${access}`, + expires: Date.now() + 60_000, + }, + }; + } + + it("recomputes runtime-only external auth overlays even while the base store is cached", async () => { await withAgentDirEnv("openclaw-auth-store-cache-", (agentDir) => { - const authPath = writeAuthStore(agentDir, "sk-test"); - const readFileSyncSpy = vi.spyOn(fs, "readFileSync"); + writeAuthStore(agentDir, "sk-test"); + mocks.resolveExternalCliAuthProfiles + .mockReturnValueOnce([createRuntimeOnlyOverlay("access-1")]) + .mockReturnValueOnce([createRuntimeOnlyOverlay("access-2")]); - ensureAuthProfileStore(agentDir); - ensureAuthProfileStore(agentDir); + const first = ensureAuthProfileStore(agentDir); + const second = ensureAuthProfileStore(agentDir); - expect( - readFileSyncSpy.mock.calls.filter(([target]) => String(target) === authPath), - ).toHaveLength(1); + expect(first.profiles["openai-codex:default"]).toMatchObject({ access: "access-1" }); + expect(second.profiles["openai-codex:default"]).toMatchObject({ access: "access-2" }); + expect(mocks.resolveExternalCliAuthProfiles).toHaveBeenCalledTimes(2); }); }); it("refreshes the cached auth store after auth-profiles.json changes", async () => { await withAgentDirEnv("openclaw-auth-store-refresh-", async (agentDir) => { const authPath = writeAuthStore(agentDir, "sk-test-1"); - const readFileSyncSpy = vi.spyOn(fs, "readFileSync"); ensureAuthProfileStore(agentDir); @@ -109,46 +126,25 @@ describe("auth profile store cache", () => { const reloaded = ensureAuthProfileStore(agentDir); - expect( - readFileSyncSpy.mock.calls.filter(([target]) => String(target) === authPath), - ).toHaveLength(2); expect(reloaded.profiles["openai:default"]).toMatchObject({ key: "sk-test-2", }); }); }); - it("reapplies runtime-only external auth overlays over a cached missing auth store", () => { + it("keeps runtime-only external auth out of persisted auth-profiles.json files", () => { const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-store-missing-")); const previousAgentDir = process.env.OPENCLAW_AGENT_DIR; const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR; - let overlayCount = 0; - resolveExternalAuthProfilesWithPluginsMock.mockImplementation(() => { - overlayCount += 1; - return [ - { - profileId: "openai-codex:default", - credential: { - type: "oauth" as const, - provider: "openai-codex", - access: `access-${overlayCount}`, - refresh: `refresh-${overlayCount}`, - expires: Date.now() + 60_000, - }, - persistence: "runtime-only" as const, - }, - ]; - }); + mocks.resolveExternalCliAuthProfiles.mockReturnValue([createRuntimeOnlyOverlay("access-1")]); try { process.env.OPENCLAW_AGENT_DIR = agentDir; process.env.PI_CODING_AGENT_DIR = agentDir; - const first = ensureAuthProfileStore(agentDir); - const second = ensureAuthProfileStore(agentDir); + const store = ensureAuthProfileStore(agentDir); - expect(first.profiles["openai-codex:default"]).toMatchObject({ access: "access-1" }); - expect(second.profiles["openai-codex:default"]).toMatchObject({ access: "access-2" }); - expect(resolveExternalAuthProfilesWithPluginsMock).toHaveBeenCalledTimes(2); + expect(store.profiles["openai-codex:default"]).toMatchObject({ access: "access-1" }); + expect(fs.existsSync(path.join(agentDir, "auth-profiles.json"))).toBe(false); } finally { if (previousAgentDir === undefined) { delete process.env.OPENCLAW_AGENT_DIR; diff --git a/src/agents/auth-profiles/external-auth.ts b/src/agents/auth-profiles/external-auth.ts index 14004b269f9..bd46682f9ce 100644 --- a/src/agents/auth-profiles/external-auth.ts +++ b/src/agents/auth-profiles/external-auth.ts @@ -1,6 +1,6 @@ import type { ProviderExternalAuthProfile } from "../../plugins/provider-external-auth.types.js"; import { resolveExternalAuthProfilesWithPlugins } from "../../plugins/provider-runtime.js"; -import { resolveExternalCliAuthProfiles } from "./external-cli-sync.js"; +import * as externalCliSync from "./external-cli-sync.js"; import type { AuthProfileStore, OAuthCredential } from "./types.js"; type ExternalAuthProfileMap = Map; @@ -49,7 +49,8 @@ function resolveExternalAuthProfileMap(params: { }); const resolved: ExternalAuthProfileMap = new Map(); - for (const profile of resolveExternalCliAuthProfiles(params.store)) { + const cliProfiles = externalCliSync.resolveExternalCliAuthProfiles?.(params.store) ?? []; + for (const profile of cliProfiles) { resolved.set(profile.profileId, { profileId: profile.profileId, credential: profile.credential, diff --git a/src/agents/auth-profiles/external-cli-sync.ts b/src/agents/auth-profiles/external-cli-sync.ts index 0ce36dcbe3a..1b8108f8384 100644 --- a/src/agents/auth-profiles/external-cli-sync.ts +++ b/src/agents/auth-profiles/external-cli-sync.ts @@ -6,7 +6,6 @@ import { EXTERNAL_CLI_SYNC_TTL_MS, MINIMAX_CLI_PROFILE_ID, OPENAI_CODEX_DEFAULT_PROFILE_ID, - log, } from "./constants.js"; import { resolveTokenExpiryState } from "./credential-state.js"; import type { AuthProfileStore, OAuthCredential } from "./types.js"; @@ -26,10 +25,7 @@ export function areOAuthCredentialsEquivalent( a: OAuthCredential | undefined, b: OAuthCredential, ): boolean { - if (!a) { - return false; - } - if (a.type !== "oauth") { + if (!a || a.type !== "oauth") { return false; } return ( @@ -50,9 +46,9 @@ function hasNewerStoredOAuthCredential( ): boolean { return Boolean( existing && - existing.provider === incoming.provider && - Number.isFinite(existing.expires) && - (!Number.isFinite(incoming.expires) || existing.expires > incoming.expires), + existing.provider === incoming.provider && + Number.isFinite(existing.expires) && + (!Number.isFinite(incoming.expires) || existing.expires > incoming.expires), ); } @@ -123,7 +119,7 @@ function resolveExternalCliSyncProvider(params: { return provider; } -export function readExternalCliBootstrapCredential(params: { +export function readManagedExternalCliCredential(params: { profileId: string; credential: OAuthCredential; }): OAuthCredential | null { @@ -146,29 +142,18 @@ export function resolveExternalCliAuthProfiles( } const existing = store.profiles[providerConfig.profileId]; const existingOAuth = existing?.type === "oauth" ? existing : undefined; - if ( - !shouldBootstrapFromExternalCliCredential({ + const shouldOverlay = + shouldBootstrapFromExternalCliCredential({ existing: existingOAuth, imported: creds, now, - }) - ) { - if (existingOAuth) { - log.debug("kept usable local oauth over external cli bootstrap", { - profileId: providerConfig.profileId, - provider: providerConfig.provider, - localExpires: existingOAuth.expires, - externalExpires: creds.expires, - }); - } + }) || + !existingOAuth || + shouldReplaceStoredOAuthCredential(existingOAuth, creds) || + areOAuthCredentialsEquivalent(existingOAuth, creds); + if (!shouldOverlay) { continue; } - log.debug("used external cli oauth bootstrap because local oauth was missing or unusable", { - profileId: providerConfig.profileId, - provider: providerConfig.provider, - localExpires: existingOAuth?.expires, - externalExpires: creds.expires, - }); profiles.push({ profileId: providerConfig.profileId, credential: creds, diff --git a/src/agents/auth-profiles/external-oauth.test.ts b/src/agents/auth-profiles/external-oauth.test.ts index 568ae2f8031..3f907f376a5 100644 --- a/src/agents/auth-profiles/external-oauth.test.ts +++ b/src/agents/auth-profiles/external-oauth.test.ts @@ -10,9 +10,9 @@ import type { AuthProfileStore, OAuthCredential } from "./types.js"; const resolveExternalAuthProfilesWithPluginsMock = vi.fn< (params: unknown) => ProviderExternalAuthProfile[] >(() => []); -const { readCodexCliCredentialsCachedMock } = vi.hoisted(() => ({ - readCodexCliCredentialsCachedMock: vi.fn<() => OAuthCredential | null>(() => null), -})); +const readCodexCliCredentialsCachedMock = vi.hoisted(() => + vi.fn<() => OAuthCredential | null>(() => null), +); vi.mock("../cli-credentials.js", () => ({ readCodexCliCredentialsCached: readCodexCliCredentialsCachedMock, diff --git a/src/agents/auth-profiles/oauth-refresh-queue.test.ts b/src/agents/auth-profiles/oauth-refresh-queue.test.ts index 65f8f2c09dd..1d6eccf4ef7 100644 --- a/src/agents/auth-profiles/oauth-refresh-queue.test.ts +++ b/src/agents/auth-profiles/oauth-refresh-queue.test.ts @@ -72,18 +72,10 @@ vi.mock("./external-auth.js", () => ({ })); vi.mock("./external-cli-sync.js", () => ({ - readExternalCliBootstrapCredential: () => null, resolveExternalCliAuthProfiles: () => [], + syncExternalCliCredentials: () => false, + readManagedExternalCliCredential: () => null, areOAuthCredentialsEquivalent: (a: unknown, b: unknown) => a === b, - hasUsableOAuthCredential: (credential: { access?: string; expires?: number } | undefined) => - Boolean( - credential && - typeof credential.access === "string" && - credential.access.length > 0 && - typeof credential.expires === "number" && - Number.isFinite(credential.expires) && - Date.now() < credential.expires, - ), })); function createExpiredOauthStore(params: { diff --git a/src/agents/auth-profiles/oauth.adopt-identity.test.ts b/src/agents/auth-profiles/oauth.adopt-identity.test.ts index 5f5e2e4d269..57f283797e5 100644 --- a/src/agents/auth-profiles/oauth.adopt-identity.test.ts +++ b/src/agents/auth-profiles/oauth.adopt-identity.test.ts @@ -79,18 +79,10 @@ vi.mock("./doctor.js", () => ({ })); vi.mock("./external-cli-sync.js", () => ({ - readExternalCliBootstrapCredential: () => null, resolveExternalCliAuthProfiles: () => [], + syncExternalCliCredentials: () => false, + readManagedExternalCliCredential: () => null, areOAuthCredentialsEquivalent: (a: unknown, b: unknown) => a === b, - hasUsableOAuthCredential: (credential: { access?: string; expires?: number } | undefined) => - Boolean( - credential && - typeof credential.access === "string" && - credential.access.length > 0 && - typeof credential.expires === "number" && - Number.isFinite(credential.expires) && - Date.now() < credential.expires, - ), })); function oauthCred(params: { diff --git a/src/agents/auth-profiles/oauth.concurrent-agents.test.ts b/src/agents/auth-profiles/oauth.concurrent-agents.test.ts index 5ed1b40ad9d..f074fb18b90 100644 --- a/src/agents/auth-profiles/oauth.concurrent-agents.test.ts +++ b/src/agents/auth-profiles/oauth.concurrent-agents.test.ts @@ -65,18 +65,10 @@ vi.mock("./doctor.js", () => ({ // credential files; it is slow and can pollute test state. Stub it to a no-op // so the suite only exercises in-repo auth-profile logic. vi.mock("./external-cli-sync.js", () => ({ - readExternalCliBootstrapCredential: () => null, resolveExternalCliAuthProfiles: () => [], + syncExternalCliCredentials: () => false, + readManagedExternalCliCredential: () => null, areOAuthCredentialsEquivalent: (a: unknown, b: unknown) => a === b, - hasUsableOAuthCredential: (credential: { access?: string; expires?: number } | undefined) => - Boolean( - credential && - typeof credential.access === "string" && - credential.access.length > 0 && - typeof credential.expires === "number" && - Number.isFinite(credential.expires) && - Date.now() < credential.expires, - ), })); function createExpiredOauthStore(params: { @@ -142,17 +134,16 @@ describe("resolveApiKeyForProfile cross-agent refresh coordination (#26322)", () } }); - it("refreshes exactly once when agents share one OAuth profile and race on expiry", async () => { - const agentCount = 6; + it("refreshes exactly once when 20 agents share one OAuth profile and all race on expiry", async () => { const profileId = "openai-codex:default"; const provider = "openai-codex"; const accountId = "acct-shared"; const freshExpiry = Date.now() + 60 * 60 * 1000; - // Seed sub-agents + main with the SAME stale OAuth credential. Main is + // Seed 20 sub-agents + main with the SAME stale OAuth credential. Main is // also expired so it cannot short-circuit via adoptNewerMainOAuthCredential. const subAgents = await Promise.all( - Array.from({ length: agentCount }, async (_, i) => { + Array.from({ length: 20 }, async (_, i) => { const dir = path.join(tempRoot, "agents", `sub-${i}`, "agent"); await fs.mkdir(dir, { recursive: true }); saveAuthProfileStore(createExpiredOauthStore({ profileId, provider, accountId }), dir); @@ -176,10 +167,10 @@ describe("resolveApiKeyForProfile cross-agent refresh coordination (#26322)", () } as never; }); - // Fire all agents concurrently. With the old per-agentDir lock this - // would produce N concurrent refresh calls and N-1 refresh_token_reused + // Fire all 20 agents concurrently. With the old per-agentDir lock this + // would produce ~20 concurrent refresh calls and 19 refresh_token_reused // 401s. With the new global per-profile lock, only the first refresh is - // performed; the remaining agents adopt the resulting fresh credentials. + // performed; the remaining 19 adopt the resulting fresh credentials. const results = await Promise.all( subAgents.map((agentDir) => resolveApiKeyForProfileInTest({ @@ -191,7 +182,7 @@ describe("resolveApiKeyForProfile cross-agent refresh coordination (#26322)", () ); expect(callCount).toBe(1); - expect(results).toHaveLength(agentCount); + expect(results).toHaveLength(20); for (const result of results) { expect(result).not.toBeNull(); expect(result?.apiKey).toBe("cross-agent-refreshed-access"); diff --git a/src/agents/auth-profiles/oauth.mirror-refresh.test.ts b/src/agents/auth-profiles/oauth.mirror-refresh.test.ts index 181badeadce..105455e3f16 100644 --- a/src/agents/auth-profiles/oauth.mirror-refresh.test.ts +++ b/src/agents/auth-profiles/oauth.mirror-refresh.test.ts @@ -76,18 +76,10 @@ vi.mock("./doctor.js", () => ({ })); vi.mock("./external-cli-sync.js", () => ({ - readExternalCliBootstrapCredential: () => null, resolveExternalCliAuthProfiles: () => [], + syncExternalCliCredentials: () => false, + readManagedExternalCliCredential: () => null, areOAuthCredentialsEquivalent: (a: unknown, b: unknown) => a === b, - hasUsableOAuthCredential: (credential: { access?: string; expires?: number } | undefined) => - Boolean( - credential && - typeof credential.access === "string" && - credential.access.length > 0 && - typeof credential.expires === "number" && - Number.isFinite(credential.expires) && - Date.now() < credential.expires, - ), })); function createExpiredOauthStore(params: { diff --git a/src/agents/models-config.skips-writing-models-json-no-env-token.test.ts b/src/agents/models-config.skips-writing-models-json-no-env-token.test.ts index 3a65dc17db1..b5ab75083be 100644 --- a/src/agents/models-config.skips-writing-models-json-no-env-token.test.ts +++ b/src/agents/models-config.skips-writing-models-json-no-env-token.test.ts @@ -12,6 +12,11 @@ import { } from "./models-config.e2e-harness.js"; import type { ProviderConfig as ModelsProviderConfig } from "./models-config.providers.secrets.js"; +vi.mock("./auth-profiles/external-cli-sync.js", () => ({ + resolveExternalCliAuthProfiles: () => [], + syncExternalCliCredentials: () => false, +})); + vi.mock("./models-config.providers.js", async () => { function createImplicitProvider(baseUrl: string): ModelsProviderConfig { return { diff --git a/src/agents/pi-auth-json.test.ts b/src/agents/pi-auth-json.test.ts index dd9642c81a6..dc4e6bd26fc 100644 --- a/src/agents/pi-auth-json.test.ts +++ b/src/agents/pi-auth-json.test.ts @@ -10,7 +10,8 @@ vi.mock("../plugins/provider-runtime.js", () => ({ })); vi.mock("./auth-profiles/external-cli-sync.js", () => ({ - readExternalCliBootstrapCredential: () => null, + resolveExternalCliAuthProfiles: () => [], + readManagedExternalCliCredential: () => null, resolveExternalCliAuthProfiles: () => [], })); diff --git a/src/commands/configure.channels.ts b/src/commands/configure.channels.ts index cbd849b9f1a..dd3deb063da 100644 --- a/src/commands/configure.channels.ts +++ b/src/commands/configure.channels.ts @@ -16,20 +16,17 @@ type ConfiguredChannelRemovalChoice = { }; type ChannelRemovalSelectValue = { kind: "channel"; id: string } | { kind: "done" }; -type ChannelRemovalSelectOption = - | { - value: { kind: "channel"; id: string }; - label: string; - hint?: string; - } - | { - value: { kind: "done" }; - label: string; - hint?: string; - }; +type ChannelRemovalOption = Parameters< + typeof select +>[0]["options"][number]; +type ChannelRemovalChoiceOption = Extract< + ChannelRemovalOption, + { value: { kind: "channel"; id: string } } +>; +type ChannelRemovalDoneOption = Extract; const RESERVED_CHANNEL_CONFIG_KEYS = new Set(["defaults", "modelByChannel"]); -const DONE_VALUE = { kind: "done" } as const; +const DONE_VALUE: Extract = { kind: "done" }; function listConfiguredChannelRemovalChoices( cfg: OpenClawConfig, @@ -88,14 +85,13 @@ export async function removeChannelConfigWizard( return next; } - const options: ChannelRemovalSelectOption[] = [ - ...configured.map((meta) => ({ - value: { kind: "channel" as const, id: meta.id }, - label: meta.label, - hint: "Deletes tokens + settings from config (credentials stay on disk)", - })), - { value: DONE_VALUE, label: "Done" }, - ]; + const channelOptions = configured.map((meta) => ({ + value: { kind: "channel" as const, id: meta.id }, + label: meta.label, + hint: "Deletes tokens + settings from config (credentials stay on disk)", + })); + const doneOption: ChannelRemovalDoneOption = { value: DONE_VALUE, label: "Done" }; + const options: ChannelRemovalOption[] = [...channelOptions, doneOption]; const choice = guardCancel( await select({ message: "Remove which channel config?", diff --git a/src/docs/clawhub-plugin-docs.test.ts b/src/docs/clawhub-plugin-docs.test.ts index 0e53f5ea2ce..130b4918537 100644 --- a/src/docs/clawhub-plugin-docs.test.ts +++ b/src/docs/clawhub-plugin-docs.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { validateExternalCodePluginPackageJson } from "../../packages/plugin-package-contract/src/index.js"; +import { validateExternalCodePluginPackageJson } from "../../packages/plugin-package-contract/src/index.ts"; const DOCS_ROOT = path.join(process.cwd(), "docs"); const pluginDocs = [ diff --git a/src/flows/channel-setup.status.test.ts b/src/flows/channel-setup.status.test.ts index 2e4e59ed9bc..82604b8a576 100644 --- a/src/flows/channel-setup.status.test.ts +++ b/src/flows/channel-setup.status.test.ts @@ -1,80 +1,91 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -type MockChannelSetupEntry = { - id: string; - pluginId?: string; - meta: { - id: string; - label: string; - selectionLabel?: string; - docsPath?: string; - docsLabel?: string; - blurb?: string; - selectionDocsPrefix?: string; - selectionExtras?: readonly string[]; - exposure?: { setup?: boolean }; - showInSetup?: boolean; - quickstartAllowFrom?: boolean; - }; -}; +type ChannelMeta = import("../channels/plugins/types.core.js").ChannelMeta; +type ChannelPluginCatalogEntry = import("../channels/plugins/catalog.js").ChannelPluginCatalogEntry; +type ListChatChannels = typeof import("../channels/chat-meta.js").listChatChannels; +type ResolveChannelSetupEntries = + typeof import("../commands/channel-setup/discovery.js").resolveChannelSetupEntries; +type FormatChannelPrimerLine = typeof import("../channels/registry.js").formatChannelPrimerLine; +type FormatChannelSelectionLine = + typeof import("../channels/registry.js").formatChannelSelectionLine; +type IsChannelConfigured = typeof import("../config/channel-configured.js").isChannelConfigured; +type NoteChannelPrimerChannels = Parameters< + typeof import("./channel-setup.status.js").noteChannelPrimer +>[1]; -type MockChannelSetupEntries = { - entries: MockChannelSetupEntry[]; - installedCatalogEntries: MockChannelSetupEntry[]; - installableCatalogEntries: MockChannelSetupEntry[]; - installedCatalogById: Map; - installableCatalogById: Map; -}; +function makeMeta(id: string, label: string, overrides: Partial = {}): ChannelMeta { + return { + id: id as ChannelMeta["id"], + label, + selectionLabel: overrides.selectionLabel ?? label, + docsPath: overrides.docsPath ?? `/channels/${id}`, + blurb: overrides.blurb ?? "", + ...overrides, + }; +} + +function makeCatalogEntry( + id: string, + label: string, + overrides: Partial = {}, +): ChannelPluginCatalogEntry { + return { + id, + pluginId: overrides.pluginId ?? id, + meta: makeMeta(id, label, overrides.meta), + install: overrides.install ?? { npmSpec: `@openclaw/${id}` }, + ...overrides, + }; +} const listChatChannels = vi.hoisted(() => - vi.fn(() => [ - { id: "discord", label: "Discord" }, - { id: "bluebubbles", label: "BlueBubbles" }, + vi.fn(() => [ + makeMeta("discord", "Discord"), + makeMeta("bluebubbles", "BlueBubbles"), ]), ); const resolveChannelSetupEntries = vi.hoisted(() => - vi.fn( - (_params?: unknown): MockChannelSetupEntries => ({ - entries: [], - installedCatalogEntries: [], - installableCatalogEntries: [], - installedCatalogById: new Map(), - installableCatalogById: new Map(), - }), - ), + vi.fn(() => ({ + entries: [], + installedCatalogEntries: [], + installableCatalogEntries: [], + installedCatalogById: new Map(), + installableCatalogById: new Map(), + })), ); const formatChannelPrimerLine = vi.hoisted(() => - vi.fn((meta: unknown) => { - const channel = meta as { label: string; blurb: string }; - return `${channel.label}: ${channel.blurb}`; - }), + vi.fn((meta) => `${meta.label}: ${meta.blurb}`), ); const formatChannelSelectionLine = vi.hoisted(() => - vi.fn((meta: unknown, _docsLink?: unknown) => { - const channel = meta as { label: string; blurb: string }; - return `${channel.label} — ${channel.blurb}`; - }), + vi.fn((meta) => `${meta.label} — ${meta.blurb}`), ); -const isChannelConfigured = vi.hoisted(() => vi.fn((_cfg?: unknown, _channelId?: string) => false)); +const isChannelConfigured = vi.hoisted(() => vi.fn(() => false)); vi.mock("../channels/chat-meta.js", () => ({ listChatChannels: () => listChatChannels(), })); vi.mock("../channels/registry.js", () => ({ - formatChannelPrimerLine: (meta: unknown) => formatChannelPrimerLine(meta), - formatChannelSelectionLine: (meta: unknown, docsLink: unknown) => - formatChannelSelectionLine(meta, docsLink), + formatChannelPrimerLine: (meta: Parameters[0]) => + formatChannelPrimerLine(meta), + formatChannelSelectionLine: ( + meta: Parameters[0], + docsLink: Parameters[1], + ) => formatChannelSelectionLine(meta, docsLink), })); vi.mock("../commands/channel-setup/discovery.js", () => ({ - resolveChannelSetupEntries: (params: unknown) => resolveChannelSetupEntries(params), + resolveChannelSetupEntries: (params: Parameters[0]) => + resolveChannelSetupEntries(params), shouldShowChannelInSetup: (meta: { exposure?: { setup?: boolean }; showInSetup?: boolean }) => meta.showInSetup !== false && meta.exposure?.setup !== false, })); vi.mock("../config/channel-configured.js", () => ({ - isChannelConfigured: (cfg: unknown, channelId: string) => isChannelConfigured(cfg, channelId), + isChannelConfigured: ( + cfg: Parameters[0], + channelId: Parameters[1], + ) => isChannelConfigured(cfg, channelId), })); import { @@ -88,8 +99,8 @@ describe("resolveChannelSetupSelectionContributions", () => { beforeEach(() => { vi.clearAllMocks(); listChatChannels.mockReturnValue([ - { id: "discord", label: "Discord" }, - { id: "bluebubbles", label: "BlueBubbles" }, + makeMeta("discord", "Discord"), + makeMeta("bluebubbles", "BlueBubbles"), ]); resolveChannelSetupEntries.mockReturnValue({ entries: [], @@ -98,14 +109,10 @@ describe("resolveChannelSetupSelectionContributions", () => { installedCatalogById: new Map(), installableCatalogById: new Map(), }); - formatChannelPrimerLine.mockImplementation((meta: unknown) => { - const channel = meta as { label: string; blurb: string }; - return `${channel.label}: ${channel.blurb}`; - }); - formatChannelSelectionLine.mockImplementation((meta: unknown) => { - const channel = meta as { label: string; blurb: string }; - return `${channel.label} — ${channel.blurb}`; - }); + formatChannelPrimerLine.mockImplementation( + (meta: { label: string; blurb: string }) => `${meta.label}: ${meta.blurb}`, + ); + formatChannelSelectionLine.mockImplementation((meta) => `${meta.label} — ${meta.blurb}`); isChannelConfigured.mockReturnValue(false); }); @@ -136,7 +143,7 @@ describe("resolveChannelSetupSelectionContributions", () => { selectionLabel: "BlueBubbles (macOS app)", }, }, - ] as never, + ], statusByChannel: new Map(), resolveDisabledHint: () => undefined, }); @@ -157,10 +164,9 @@ describe("resolveChannelSetupSelectionContributions", () => { id: "zalo", label: "Zalo", selectionLabel: "Zalo (Bot API)", - quickstartAllowFrom: true, }, }, - ] as never, + ], statusByChannel: new Map(), resolveDisabledHint: () => undefined, }); @@ -182,10 +188,9 @@ describe("resolveChannelSetupSelectionContributions", () => { id: "zalo", label: "Zalo", selectionLabel: "Zalo (Bot API)", - quickstartAllowFrom: true, }, }, - ] as never, + ], statusByChannel: new Map([["zalo", { selectionHint: "configured" }]]), resolveDisabledHint: () => "disabled", }); @@ -207,7 +212,7 @@ describe("resolveChannelSetupSelectionContributions", () => { label: "Zalo\u001B[31m\nBot\u0007", }, }, - ] as never, + ], statusByChannel: new Map([["zalo", { selectionHint: "configured\u001B[2K\nnow" }]]), resolveDisabledHint: () => "disabled\u0007", }); @@ -229,7 +234,7 @@ describe("resolveChannelSetupSelectionContributions", () => { label: "\u001B[31m\u0007", }, }, - ] as never, + ], statusByChannel: new Map(), resolveDisabledHint: () => undefined, }); @@ -241,23 +246,11 @@ describe("resolveChannelSetupSelectionContributions", () => { }); it("sanitizes channel labels in status note lines", async () => { - listChatChannels.mockReturnValue([{ id: "discord", label: "Discord\u001B[31m\nCore\u0007" }]); + listChatChannels.mockReturnValue([makeMeta("discord", "Discord\u001B[31m\nCore\u0007")]); resolveChannelSetupEntries.mockReturnValue({ entries: [], - installedCatalogEntries: [ - { - id: "matrix", - pluginId: "matrix", - meta: { id: "matrix", label: "Matrix\u001B[2K\nPlugin\u0007" }, - }, - ], - installableCatalogEntries: [ - { - id: "zalo", - pluginId: "zalo", - meta: { id: "zalo", label: "Zalo\u001B[2K\nPlugin\u0007" }, - }, - ], + installedCatalogEntries: [makeCatalogEntry("matrix", "Matrix\u001B[2K\nPlugin\u0007")], + installableCatalogEntries: [makeCatalogEntry("zalo", "Zalo\u001B[2K\nPlugin\u0007")], installedCatalogById: new Map(), installableCatalogById: new Map(), }); @@ -285,8 +278,8 @@ describe("resolveChannelSetupSelectionContributions", () => { id: "bad\u001B[31m\nid", label: "\u001B[31m\u0007", blurb: "Blurb\u001B[2K\nline\u0007", - }, - ] as never, + } satisfies NoteChannelPrimerChannels[number], + ] as NoteChannelPrimerChannels, ); expect(formatChannelPrimerLine).toHaveBeenCalledWith( diff --git a/src/flows/channel-setup.test.ts b/src/flows/channel-setup.test.ts index 0678da39c22..7b4ef145fee 100644 --- a/src/flows/channel-setup.test.ts +++ b/src/flows/channel-setup.test.ts @@ -1,5 +1,90 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +type ChannelMeta = import("../channels/plugins/types.core.js").ChannelMeta; +type ChannelPluginCatalogEntry = import("../channels/plugins/catalog.js").ChannelPluginCatalogEntry; +type ChannelSetupPlugin = import("../channels/plugins/setup-wizard-types.js").ChannelSetupPlugin; +type ResolveChannelSetupEntries = + typeof import("../commands/channel-setup/discovery.js").resolveChannelSetupEntries; +type CollectChannelStatus = typeof import("./channel-setup.status.js").collectChannelStatus; +type LoadChannelSetupPluginRegistrySnapshotForChannel = + typeof import("../commands/channel-setup/plugin-install.js").loadChannelSetupPluginRegistrySnapshotForChannel; +type PluginRegistry = ReturnType; + +function makeMeta(id: string, label: string, overrides: Partial = {}): ChannelMeta { + return { + id: id as ChannelMeta["id"], + label, + selectionLabel: overrides.selectionLabel ?? label, + docsPath: overrides.docsPath ?? `/channels/${id}`, + blurb: overrides.blurb ?? "", + ...overrides, + }; +} + +function makeCatalogEntry( + id: string, + label: string, + overrides: Partial = {}, +): ChannelPluginCatalogEntry { + return { + id, + pluginId: overrides.pluginId ?? id, + origin: overrides.origin, + meta: makeMeta(id, label, overrides.meta), + install: overrides.install ?? { npmSpec: `@openclaw/${id}` }, + }; +} + +function makeSetupPlugin(params: { + id: string; + label: string; + setupWizard?: ChannelSetupPlugin["setupWizard"]; +}): ChannelSetupPlugin { + return { + id: params.id as ChannelSetupPlugin["id"], + meta: makeMeta(params.id, params.label), + capabilities: { chatTypes: [] }, + config: { + resolveAccount: vi.fn(() => ({})), + } as unknown as ChannelSetupPlugin["config"], + ...(params.setupWizard ? { setupWizard: params.setupWizard } : {}), + }; +} + +function makePluginRegistry(overrides: Partial = {}): PluginRegistry { + return { + plugins: [], + channels: [], + channelSetups: [], + providers: [], + authProviders: [], + authRequirements: [], + webSearchProviders: [], + webFetchProviders: [], + mediaUnderstandingProviders: [], + imageGenerationProviders: [], + videoGenerationProviders: [], + musicGenerationProviders: [], + speechProviders: [], + realtimeTranscriptionProviders: [], + realtimeVoiceProviders: [], + cliBackends: [], + tools: [], + hooks: [], + typedHooks: [], + bundledExtensionDescriptors: [], + doctorChecks: [], + flowContributions: [], + flowContributionResolvers: [], + providerExtensions: [], + toolsets: [], + toolDisplayEntries: [], + textTransforms: [], + diagnostics: [], + ...overrides, + } as unknown as PluginRegistry; +} + const resolveAgentWorkspaceDir = vi.hoisted(() => vi.fn((_cfg?: unknown, _agentId?: unknown) => "/tmp/openclaw-workspace"), ); @@ -11,36 +96,19 @@ const getChannelSetupPlugin = vi.hoisted(() => vi.fn((_channel?: unknown) => und const listChannelSetupPlugins = vi.hoisted(() => vi.fn((): unknown[] => [])); const listActiveChannelSetupPlugins = vi.hoisted(() => vi.fn((): unknown[] => [])); const loadChannelSetupPluginRegistrySnapshotForChannel = vi.hoisted(() => - vi.fn( - ( - _params?: unknown, - ): { - channels: unknown[]; - channelSetups: unknown[]; - } => ({ channels: [], channelSetups: [] }), - ), + vi.fn((_params) => makePluginRegistry()), ); const resolveChannelSetupEntries = vi.hoisted(() => - vi.fn( - ( - _params?: unknown, - ): { - entries: unknown[]; - installedCatalogEntries: unknown[]; - installableCatalogEntries: unknown[]; - installedCatalogById: Map; - installableCatalogById: Map; - } => ({ - entries: [], - installedCatalogEntries: [], - installableCatalogEntries: [], - installedCatalogById: new Map(), - installableCatalogById: new Map(), - }), - ), + vi.fn((_params) => ({ + entries: [], + installedCatalogEntries: [], + installableCatalogEntries: [], + installedCatalogById: new Map(), + installableCatalogById: new Map(), + })), ); const collectChannelStatus = vi.hoisted(() => - vi.fn(async (_params?: unknown) => ({ + vi.fn(async (_params) => ({ installedPlugins: [], catalogEntries: [], installedCatalogEntries: [], @@ -70,14 +138,16 @@ vi.mock("../channels/registry.js", () => ({ })); vi.mock("../commands/channel-setup/discovery.js", () => ({ - resolveChannelSetupEntries: (params?: unknown) => resolveChannelSetupEntries(params), + resolveChannelSetupEntries: (params: Parameters[0]) => + resolveChannelSetupEntries(params), shouldShowChannelInSetup: () => true, })); vi.mock("../commands/channel-setup/plugin-install.js", () => ({ ensureChannelSetupPluginInstalled: vi.fn(), - loadChannelSetupPluginRegistrySnapshotForChannel: (params?: unknown) => - loadChannelSetupPluginRegistrySnapshotForChannel(params), + loadChannelSetupPluginRegistrySnapshotForChannel: ( + params: Parameters[0], + ) => loadChannelSetupPluginRegistrySnapshotForChannel(params), })); vi.mock("../commands/channel-setup/registry.js", () => ({ @@ -102,7 +172,8 @@ vi.mock("./channel-setup.prompts.js", () => ({ })); vi.mock("./channel-setup.status.js", () => ({ - collectChannelStatus: (params?: unknown) => collectChannelStatus(params), + collectChannelStatus: (params: Parameters[0]) => + collectChannelStatus(params), noteChannelPrimer: vi.fn(), noteChannelStatus: vi.fn(), resolveChannelSelectionNoteLines: vi.fn(() => []), @@ -127,10 +198,7 @@ describe("setupChannels workspace shadow exclusion", () => { getChannelSetupPlugin.mockReturnValue(undefined); listActiveChannelSetupPlugins.mockReturnValue([]); listChannelSetupPlugins.mockReturnValue([]); - loadChannelSetupPluginRegistrySnapshotForChannel.mockReturnValue({ - channels: [], - channelSetups: [], - }); + loadChannelSetupPluginRegistrySnapshotForChannel.mockReturnValue(makePluginRegistry()); resolveChannelSetupEntries.mockReturnValue({ entries: [], installedCatalogEntries: [], @@ -206,7 +274,7 @@ describe("setupChannels workspace shadow exclusion", () => { entries: [ { id: "telegram", - meta: { id: "telegram", label: "Telegram", blurb: "" }, + meta: makeMeta("telegram", "Telegram"), }, ], installedCatalogEntries: [], @@ -240,8 +308,7 @@ describe("setupChannels workspace shadow exclusion", () => { it("keeps already-active setup plugins in the deferred picker without registry fallback", async () => { const activePlugin = { - id: "custom-chat", - meta: { id: "custom-chat", label: "Custom Chat", blurb: "" }, + ...makeSetupPlugin({ id: "custom-chat", label: "Custom Chat" }), }; listActiveChannelSetupPlugins.mockReturnValue([activePlugin]); resolveChannelSetupEntries.mockImplementation(() => ({ @@ -293,21 +360,17 @@ describe("setupChannels workspace shadow exclusion", () => { }, })), }; - const activePlugin = { + const activePlugin = makeSetupPlugin({ id: "custom-chat", - meta: { id: "custom-chat", label: "Custom Chat", blurb: "" }, - capabilities: {}, - config: { - resolveAccount: vi.fn(() => ({})), - }, + label: "Custom Chat", setupWizard, - }; + }); listActiveChannelSetupPlugins.mockReturnValue([activePlugin]); resolveChannelSetupEntries.mockReturnValue({ entries: [ { id: "custom-chat", - meta: { id: "custom-chat", label: "Custom Chat", blurb: "" }, + meta: makeMeta("custom-chat", "Custom Chat"), }, ], installedCatalogEntries: [], @@ -346,6 +409,14 @@ describe("setupChannels workspace shadow exclusion", () => { }); it("loads the selected bundled catalog plugin without writing explicit plugin enablement", async () => { + const configure = vi.fn(async ({ cfg }: { cfg: Record }) => ({ + cfg: { + ...cfg, + channels: { + telegram: { token: "secret" }, + }, + } as never, + })); const setupWizard = { channel: "telegram", getStatus: vi.fn(async () => ({ @@ -353,35 +424,22 @@ describe("setupChannels workspace shadow exclusion", () => { configured: false, statusLines: [], })), - configure: vi.fn(async ({ cfg }: { cfg: Record }) => ({ - cfg: { - ...cfg, - channels: { - telegram: { token: "secret" }, - }, - }, - })), - }; - const telegramPlugin = { + configure, + } as ChannelSetupPlugin["setupWizard"]; + const telegramPlugin = makeSetupPlugin({ id: "telegram", - meta: { id: "telegram", label: "Telegram", blurb: "" }, - capabilities: {}, - config: { - resolveAccount: vi.fn(() => ({})), - }, + label: "Telegram", setupWizard, - }; - const installedCatalogEntry = { - id: "telegram", + }); + const installedCatalogEntry = makeCatalogEntry("telegram", "Telegram", { pluginId: "telegram", origin: "bundled", - meta: { id: "telegram", label: "Telegram", blurb: "" }, - }; + }); resolveChannelSetupEntries.mockReturnValue({ entries: [ { id: "telegram", - meta: { id: "telegram", label: "Telegram", blurb: "" }, + meta: makeMeta("telegram", "Telegram"), }, ], installedCatalogEntries: [installedCatalogEntry], @@ -389,10 +447,17 @@ describe("setupChannels workspace shadow exclusion", () => { installedCatalogById: new Map([["telegram", installedCatalogEntry]]), installableCatalogById: new Map(), }); - loadChannelSetupPluginRegistrySnapshotForChannel.mockReturnValue({ - channels: [{ plugin: telegramPlugin }], - channelSetups: [], - }); + loadChannelSetupPluginRegistrySnapshotForChannel.mockReturnValue( + makePluginRegistry({ + channels: [ + { + pluginId: "telegram", + source: "bundled", + plugin: telegramPlugin, + }, + ], + }), + ); const select = vi.fn().mockResolvedValueOnce("telegram").mockResolvedValueOnce("__done__"); const next = await setupChannels( @@ -420,7 +485,7 @@ describe("setupChannels workspace shadow exclusion", () => { ); expect(getChannelSetupPlugin).not.toHaveBeenCalled(); expect(collectChannelStatus).not.toHaveBeenCalled(); - expect(setupWizard.configure).toHaveBeenCalledWith( + expect(configure).toHaveBeenCalledWith( expect.objectContaining({ cfg: {}, }), @@ -446,7 +511,7 @@ describe("setupChannels workspace shadow exclusion", () => { entries: [ { id: "telegram", - meta: { id: "telegram", label: "Telegram", blurb: "" }, + meta: makeMeta("telegram", "Telegram"), }, ], installedCatalogEntries: [], @@ -495,7 +560,7 @@ describe("setupChannels workspace shadow exclusion", () => { entries: [ { id: "telegram", - meta: { id: "telegram", label: "Telegram", blurb: "" }, + meta: makeMeta("telegram", "Telegram"), }, ], installedCatalogEntries: [],