mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:40:44 +00:00
test(auth): add codex oauth red-blue coverage
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -10,7 +10,8 @@ vi.mock("../plugins/provider-runtime.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./auth-profiles/external-cli-sync.js", () => ({
|
||||
readExternalCliBootstrapCredential: () => null,
|
||||
resolveExternalCliAuthProfiles: () => [],
|
||||
readManagedExternalCliCredential: () => null,
|
||||
resolveExternalCliAuthProfiles: () => [],
|
||||
}));
|
||||
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
Reference in New Issue
Block a user