test(auth): add codex oauth red-blue coverage

This commit is contained in:
Vincent Koc
2026-04-17 14:04:50 -07:00
parent 1e7c7dd02f
commit 5edf876a5e
20 changed files with 606 additions and 502 deletions

View File

@@ -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",

View File

@@ -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 &&

View File

@@ -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(

View File

@@ -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();
}
});
});

View File

@@ -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", () => ({

View File

@@ -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",
});
});
});

View File

@@ -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;

View File

@@ -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<string, ProviderExternalAuthProfile>;
@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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");

View File

@@ -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: {

View File

@@ -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 {

View File

@@ -10,7 +10,8 @@ vi.mock("../plugins/provider-runtime.js", () => ({
}));
vi.mock("./auth-profiles/external-cli-sync.js", () => ({
readExternalCliBootstrapCredential: () => null,
resolveExternalCliAuthProfiles: () => [],
readManagedExternalCliCredential: () => null,
resolveExternalCliAuthProfiles: () => [],
}));

View File

@@ -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<ChannelRemovalSelectValue>
>[0]["options"][number];
type ChannelRemovalChoiceOption = Extract<
ChannelRemovalOption,
{ value: { kind: "channel"; id: string } }
>;
type ChannelRemovalDoneOption = Extract<ChannelRemovalOption, { value: { kind: "done" } }>;
const RESERVED_CHANNEL_CONFIG_KEYS = new Set(["defaults", "modelByChannel"]);
const DONE_VALUE = { kind: "done" } as const;
const DONE_VALUE: Extract<ChannelRemovalSelectValue, { kind: "done" }> = { 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<ChannelRemovalChoiceOption>((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<ChannelRemovalSelectValue>({
message: "Remove which channel config?",

View File

@@ -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 = [

View File

@@ -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<unknown, unknown>;
installableCatalogById: Map<unknown, unknown>;
};
function makeMeta(id: string, label: string, overrides: Partial<ChannelMeta> = {}): 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> = {},
): 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<ListChatChannels>(() => [
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<ResolveChannelSetupEntries>(() => ({
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<FormatChannelPrimerLine>((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<FormatChannelSelectionLine>((meta) => `${meta.label}${meta.blurb}`),
);
const isChannelConfigured = vi.hoisted(() => vi.fn((_cfg?: unknown, _channelId?: string) => false));
const isChannelConfigured = vi.hoisted(() => vi.fn<IsChannelConfigured>(() => 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<FormatChannelPrimerLine>[0]) =>
formatChannelPrimerLine(meta),
formatChannelSelectionLine: (
meta: Parameters<FormatChannelSelectionLine>[0],
docsLink: Parameters<FormatChannelSelectionLine>[1],
) => formatChannelSelectionLine(meta, docsLink),
}));
vi.mock("../commands/channel-setup/discovery.js", () => ({
resolveChannelSetupEntries: (params: unknown) => resolveChannelSetupEntries(params),
resolveChannelSetupEntries: (params: Parameters<ResolveChannelSetupEntries>[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<IsChannelConfigured>[0],
channelId: Parameters<IsChannelConfigured>[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(

View File

@@ -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<LoadChannelSetupPluginRegistrySnapshotForChannel>;
function makeMeta(id: string, label: string, overrides: Partial<ChannelMeta> = {}): 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> = {},
): 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> = {}): 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<LoadChannelSetupPluginRegistrySnapshotForChannel>((_params) => makePluginRegistry()),
);
const resolveChannelSetupEntries = vi.hoisted(() =>
vi.fn(
(
_params?: unknown,
): {
entries: unknown[];
installedCatalogEntries: unknown[];
installableCatalogEntries: unknown[];
installedCatalogById: Map<unknown, unknown>;
installableCatalogById: Map<unknown, unknown>;
} => ({
entries: [],
installedCatalogEntries: [],
installableCatalogEntries: [],
installedCatalogById: new Map(),
installableCatalogById: new Map(),
}),
),
vi.fn<ResolveChannelSetupEntries>((_params) => ({
entries: [],
installedCatalogEntries: [],
installableCatalogEntries: [],
installedCatalogById: new Map(),
installableCatalogById: new Map(),
})),
);
const collectChannelStatus = vi.hoisted(() =>
vi.fn(async (_params?: unknown) => ({
vi.fn<CollectChannelStatus>(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<ResolveChannelSetupEntries>[0]) =>
resolveChannelSetupEntries(params),
shouldShowChannelInSetup: () => true,
}));
vi.mock("../commands/channel-setup/plugin-install.js", () => ({
ensureChannelSetupPluginInstalled: vi.fn(),
loadChannelSetupPluginRegistrySnapshotForChannel: (params?: unknown) =>
loadChannelSetupPluginRegistrySnapshotForChannel(params),
loadChannelSetupPluginRegistrySnapshotForChannel: (
params: Parameters<LoadChannelSetupPluginRegistrySnapshotForChannel>[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<CollectChannelStatus>[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<string, unknown> }) => ({
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<string, unknown> }) => ({
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: [],