mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:50:43 +00:00
test: dedupe fast lane imports
This commit is contained in:
@@ -6,6 +6,33 @@ import {
|
||||
import { resolveAuthProfileOrder } from "./auth-profiles/order.js";
|
||||
import type { AuthProfileStore } from "./auth-profiles/types.js";
|
||||
|
||||
function makeApiKeyStore(provider: string, profileIds: string[]): AuthProfileStore {
|
||||
return {
|
||||
version: 1,
|
||||
profiles: Object.fromEntries(
|
||||
profileIds.map((profileId) => [
|
||||
profileId,
|
||||
{
|
||||
type: "api_key",
|
||||
provider,
|
||||
key: profileId.endsWith(":work") ? "sk-work" : "sk-default",
|
||||
},
|
||||
]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function makeApiKeyProfilesByProviderProvider(
|
||||
providerByProfileId: Record<string, string>,
|
||||
): Record<string, { provider: string; mode: "api_key" }> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(providerByProfileId).map(([profileId, provider]) => [
|
||||
profileId,
|
||||
{ provider, mode: "api_key" },
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
describe("resolveAuthProfileOrder", () => {
|
||||
const store = ANTHROPIC_STORE;
|
||||
const cfg = ANTHROPIC_CFG;
|
||||
@@ -33,6 +60,33 @@ describe("resolveAuthProfileOrder", () => {
|
||||
});
|
||||
}
|
||||
|
||||
function resolveMinimaxOrderWithProfile(profile: {
|
||||
type: "token";
|
||||
provider: "minimax";
|
||||
token?: string;
|
||||
tokenRef?: { source: "env" | "file" | "exec"; provider: string; id: string };
|
||||
expires?: number;
|
||||
}) {
|
||||
return resolveAuthProfileOrder({
|
||||
cfg: {
|
||||
auth: {
|
||||
order: {
|
||||
minimax: ["minimax:default"],
|
||||
},
|
||||
},
|
||||
},
|
||||
store: {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"minimax:default": {
|
||||
...profile,
|
||||
},
|
||||
},
|
||||
},
|
||||
provider: "minimax",
|
||||
});
|
||||
}
|
||||
|
||||
it("does not prioritize lastGood over round-robin ordering", () => {
|
||||
const order = resolveAuthProfileOrder({
|
||||
cfg,
|
||||
@@ -48,6 +102,77 @@ describe("resolveAuthProfileOrder", () => {
|
||||
});
|
||||
expect(order[0]).toBe("anthropic:default");
|
||||
});
|
||||
it("normalizes z.ai aliases in auth.order", () => {
|
||||
const order = resolveAuthProfileOrder({
|
||||
cfg: {
|
||||
auth: {
|
||||
order: { "z.ai": ["zai:work", "zai:default"] },
|
||||
profiles: makeApiKeyProfilesByProviderProvider({
|
||||
"zai:default": "zai",
|
||||
"zai:work": "zai",
|
||||
}),
|
||||
},
|
||||
},
|
||||
store: makeApiKeyStore("zai", ["zai:default", "zai:work"]),
|
||||
provider: "zai",
|
||||
});
|
||||
expect(order).toEqual(["zai:work", "zai:default"]);
|
||||
});
|
||||
it("normalizes provider casing in auth.order keys", () => {
|
||||
const order = resolveAuthProfileOrder({
|
||||
cfg: {
|
||||
auth: {
|
||||
order: { OpenAI: ["openai:work", "openai:default"] },
|
||||
profiles: makeApiKeyProfilesByProviderProvider({
|
||||
"openai:default": "openai",
|
||||
"openai:work": "openai",
|
||||
}),
|
||||
},
|
||||
},
|
||||
store: makeApiKeyStore("openai", ["openai:default", "openai:work"]),
|
||||
provider: "openai",
|
||||
});
|
||||
expect(order).toEqual(["openai:work", "openai:default"]);
|
||||
});
|
||||
it("normalizes z.ai aliases in auth.profiles", () => {
|
||||
const order = resolveAuthProfileOrder({
|
||||
cfg: {
|
||||
auth: {
|
||||
profiles: makeApiKeyProfilesByProviderProvider({
|
||||
"zai:default": "z.ai",
|
||||
"zai:work": "Z.AI",
|
||||
}),
|
||||
},
|
||||
},
|
||||
store: makeApiKeyStore("zai", ["zai:default", "zai:work"]),
|
||||
provider: "zai",
|
||||
});
|
||||
expect(order).toEqual(["zai:default", "zai:work"]);
|
||||
});
|
||||
it("prioritizes oauth profiles when order missing", () => {
|
||||
const mixedStore: AuthProfileStore = {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"anthropic:default": {
|
||||
type: "api_key",
|
||||
provider: "anthropic",
|
||||
key: "sk-default",
|
||||
},
|
||||
"anthropic:oauth": {
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: Date.now() + 60_000,
|
||||
},
|
||||
},
|
||||
};
|
||||
const order = resolveAuthProfileOrder({
|
||||
store: mixedStore,
|
||||
provider: "anthropic",
|
||||
});
|
||||
expect(order).toEqual(["anthropic:oauth", "anthropic:default"]);
|
||||
});
|
||||
it("uses explicit profiles when order is missing", () => {
|
||||
const order = resolveAuthProfileOrder({
|
||||
cfg,
|
||||
@@ -56,6 +181,23 @@ describe("resolveAuthProfileOrder", () => {
|
||||
});
|
||||
expect(order).toEqual(["anthropic:default", "anthropic:work"]);
|
||||
});
|
||||
it("uses stored profiles when no config exists", () => {
|
||||
const order = resolveAuthProfileOrder({
|
||||
store,
|
||||
provider: "anthropic",
|
||||
});
|
||||
expect(order).toEqual(["anthropic:default", "anthropic:work"]);
|
||||
});
|
||||
it("prioritizes preferred profiles", () => {
|
||||
const order = resolveAuthProfileOrder({
|
||||
cfg,
|
||||
store,
|
||||
provider: "anthropic",
|
||||
preferredProfile: "anthropic:work",
|
||||
});
|
||||
expect(order[0]).toBe("anthropic:work");
|
||||
expect(order).toContain("anthropic:default");
|
||||
});
|
||||
it("uses configured order when provided", () => {
|
||||
const order = resolveAuthProfileOrder({
|
||||
cfg: {
|
||||
@@ -69,6 +211,192 @@ describe("resolveAuthProfileOrder", () => {
|
||||
});
|
||||
expect(order).toEqual(["anthropic:work", "anthropic:default"]);
|
||||
});
|
||||
it("drops explicit order entries that are missing from the store", () => {
|
||||
const order = resolveAuthProfileOrder({
|
||||
cfg: {
|
||||
auth: {
|
||||
order: {
|
||||
minimax: ["minimax:default", "minimax:prod"],
|
||||
},
|
||||
},
|
||||
},
|
||||
store: {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"minimax:prod": {
|
||||
type: "api_key",
|
||||
provider: "minimax",
|
||||
key: "sk-prod",
|
||||
},
|
||||
},
|
||||
},
|
||||
provider: "minimax",
|
||||
});
|
||||
expect(order).toEqual(["minimax:prod"]);
|
||||
});
|
||||
it("falls back to stored provider profiles when config profile ids drift", () => {
|
||||
const order = resolveAuthProfileOrder({
|
||||
cfg: {
|
||||
auth: {
|
||||
profiles: {
|
||||
"openai-codex:default": {
|
||||
provider: "openai-codex",
|
||||
mode: "oauth",
|
||||
},
|
||||
},
|
||||
order: {
|
||||
"openai-codex": ["openai-codex:default"],
|
||||
},
|
||||
},
|
||||
},
|
||||
store: {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"openai-codex:user@example.com": {
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: Date.now() + 60_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
provider: "openai-codex",
|
||||
});
|
||||
expect(order).toEqual(["openai-codex:user@example.com"]);
|
||||
});
|
||||
it("does not bypass explicit ids when the configured profile exists but is invalid", () => {
|
||||
const order = resolveAuthProfileOrder({
|
||||
cfg: {
|
||||
auth: {
|
||||
profiles: {
|
||||
"openai-codex:default": {
|
||||
provider: "openai-codex",
|
||||
mode: "token",
|
||||
},
|
||||
},
|
||||
order: {
|
||||
"openai-codex": ["openai-codex:default"],
|
||||
},
|
||||
},
|
||||
},
|
||||
store: {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"openai-codex:default": {
|
||||
type: "token",
|
||||
provider: "openai-codex",
|
||||
token: "expired-token",
|
||||
expires: Date.now() - 1_000,
|
||||
},
|
||||
"openai-codex:user@example.com": {
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: Date.now() + 60_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
provider: "openai-codex",
|
||||
});
|
||||
expect(order).toEqual([]);
|
||||
});
|
||||
it("drops explicit order entries that belong to another provider", () => {
|
||||
const order = resolveAuthProfileOrder({
|
||||
cfg: {
|
||||
auth: {
|
||||
order: {
|
||||
minimax: ["openai:default", "minimax:prod"],
|
||||
},
|
||||
},
|
||||
},
|
||||
store: {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"openai:default": {
|
||||
type: "api_key",
|
||||
provider: "openai",
|
||||
key: "sk-openai",
|
||||
},
|
||||
"minimax:prod": {
|
||||
type: "api_key",
|
||||
provider: "minimax",
|
||||
key: "sk-mini",
|
||||
},
|
||||
},
|
||||
},
|
||||
provider: "minimax",
|
||||
});
|
||||
expect(order).toEqual(["minimax:prod"]);
|
||||
});
|
||||
it("orders by lastUsed when no explicit order exists", () => {
|
||||
const order = resolveAuthProfileOrder({
|
||||
store: {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"anthropic:a": {
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: Date.now() + 60_000,
|
||||
},
|
||||
"anthropic:b": {
|
||||
type: "api_key",
|
||||
provider: "anthropic",
|
||||
key: "sk-b",
|
||||
},
|
||||
"anthropic:c": {
|
||||
type: "api_key",
|
||||
provider: "anthropic",
|
||||
key: "sk-c",
|
||||
},
|
||||
},
|
||||
usageStats: {
|
||||
"anthropic:a": { lastUsed: 200 },
|
||||
"anthropic:b": { lastUsed: 100 },
|
||||
"anthropic:c": { lastUsed: 300 },
|
||||
},
|
||||
},
|
||||
provider: "anthropic",
|
||||
});
|
||||
expect(order).toEqual(["anthropic:a", "anthropic:b", "anthropic:c"]);
|
||||
});
|
||||
it("pushes cooldown profiles to the end, ordered by cooldown expiry", () => {
|
||||
const now = Date.now();
|
||||
const order = resolveAuthProfileOrder({
|
||||
store: {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"anthropic:ready": {
|
||||
type: "api_key",
|
||||
provider: "anthropic",
|
||||
key: "sk-ready",
|
||||
},
|
||||
"anthropic:cool1": {
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: now + 60_000,
|
||||
},
|
||||
"anthropic:cool2": {
|
||||
type: "api_key",
|
||||
provider: "anthropic",
|
||||
key: "sk-cool",
|
||||
},
|
||||
},
|
||||
usageStats: {
|
||||
"anthropic:ready": { lastUsed: 50 },
|
||||
"anthropic:cool1": { cooldownUntil: now + 120_000 },
|
||||
"anthropic:cool2": { cooldownUntil: now + 60_000 },
|
||||
},
|
||||
},
|
||||
provider: "anthropic",
|
||||
});
|
||||
expect(order).toEqual(["anthropic:ready", "anthropic:cool2", "anthropic:cool1"]);
|
||||
});
|
||||
it("prefers store order over config order", () => {
|
||||
const order = resolveAuthProfileOrder({
|
||||
cfg: {
|
||||
@@ -238,4 +566,120 @@ describe("resolveAuthProfileOrder", () => {
|
||||
});
|
||||
expect(order).not.toContain("anthropic:oauth-cred");
|
||||
});
|
||||
it.each([
|
||||
{
|
||||
caseName: "drops token profiles with empty credentials",
|
||||
profile: {
|
||||
type: "token" as const,
|
||||
provider: "minimax" as const,
|
||||
token: " ",
|
||||
},
|
||||
},
|
||||
{
|
||||
caseName: "drops token profiles that are already expired",
|
||||
profile: {
|
||||
type: "token" as const,
|
||||
provider: "minimax" as const,
|
||||
token: "sk-minimax",
|
||||
expires: Date.now() - 1000,
|
||||
},
|
||||
},
|
||||
{
|
||||
caseName: "drops token profiles with invalid expires metadata",
|
||||
profile: {
|
||||
type: "token" as const,
|
||||
provider: "minimax" as const,
|
||||
token: "sk-minimax",
|
||||
expires: 0,
|
||||
},
|
||||
},
|
||||
])("$caseName", ({ profile }) => {
|
||||
const order = resolveMinimaxOrderWithProfile(profile);
|
||||
expect(order).toEqual([]);
|
||||
});
|
||||
it("keeps api_key profiles backed by keyRef when plaintext key is absent", () => {
|
||||
const order = resolveAuthProfileOrder({
|
||||
cfg: {
|
||||
auth: {
|
||||
order: {
|
||||
anthropic: ["anthropic:default"],
|
||||
},
|
||||
},
|
||||
},
|
||||
store: {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"anthropic:default": {
|
||||
type: "api_key",
|
||||
provider: "anthropic",
|
||||
keyRef: {
|
||||
source: "exec",
|
||||
provider: "vault_local",
|
||||
id: "anthropic/default",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
provider: "anthropic",
|
||||
});
|
||||
expect(order).toEqual(["anthropic:default"]);
|
||||
});
|
||||
it("keeps token profiles backed by tokenRef when expires is absent", () => {
|
||||
const order = resolveMinimaxOrderWithProfile({
|
||||
type: "token",
|
||||
provider: "minimax",
|
||||
tokenRef: {
|
||||
source: "exec",
|
||||
provider: "keychain",
|
||||
id: "minimax/default",
|
||||
},
|
||||
});
|
||||
expect(order).toEqual(["minimax:default"]);
|
||||
});
|
||||
it("drops tokenRef profiles when expires is invalid", () => {
|
||||
const order = resolveMinimaxOrderWithProfile({
|
||||
type: "token",
|
||||
provider: "minimax",
|
||||
tokenRef: {
|
||||
source: "exec",
|
||||
provider: "keychain",
|
||||
id: "minimax/default",
|
||||
},
|
||||
expires: 0,
|
||||
});
|
||||
expect(order).toEqual([]);
|
||||
});
|
||||
it("keeps token profiles with inline token when no expires is set", () => {
|
||||
const order = resolveMinimaxOrderWithProfile({
|
||||
type: "token",
|
||||
provider: "minimax",
|
||||
token: "sk-minimax",
|
||||
});
|
||||
expect(order).toEqual(["minimax:default"]);
|
||||
});
|
||||
it("keeps oauth profiles that can refresh", () => {
|
||||
const order = resolveAuthProfileOrder({
|
||||
cfg: {
|
||||
auth: {
|
||||
order: {
|
||||
anthropic: ["anthropic:oauth"],
|
||||
},
|
||||
},
|
||||
},
|
||||
store: {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"anthropic:oauth": {
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: "",
|
||||
refresh: "refresh-token",
|
||||
expires: Date.now() - 1000,
|
||||
},
|
||||
},
|
||||
},
|
||||
provider: "anthropic",
|
||||
});
|
||||
expect(order).toEqual(["anthropic:oauth"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveAuthProfileOrder } from "./auth-profiles/order.js";
|
||||
import type { AuthProfileStore } from "./auth-profiles/types.js";
|
||||
|
||||
function makeApiKeyStore(provider: string, profileIds: string[]): AuthProfileStore {
|
||||
return {
|
||||
version: 1,
|
||||
profiles: Object.fromEntries(
|
||||
profileIds.map((profileId) => [
|
||||
profileId,
|
||||
{
|
||||
type: "api_key",
|
||||
provider,
|
||||
key: profileId.endsWith(":work") ? "sk-work" : "sk-default",
|
||||
},
|
||||
]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function makeApiKeyProfilesByProviderProvider(
|
||||
providerByProfileId: Record<string, string>,
|
||||
): Record<string, { provider: string; mode: "api_key" }> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(providerByProfileId).map(([profileId, provider]) => [
|
||||
profileId,
|
||||
{ provider, mode: "api_key" },
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
describe("resolveAuthProfileOrder", () => {
|
||||
it("normalizes z.ai aliases in auth.order", () => {
|
||||
const order = resolveAuthProfileOrder({
|
||||
cfg: {
|
||||
auth: {
|
||||
order: { "z.ai": ["zai:work", "zai:default"] },
|
||||
profiles: makeApiKeyProfilesByProviderProvider({
|
||||
"zai:default": "zai",
|
||||
"zai:work": "zai",
|
||||
}),
|
||||
},
|
||||
},
|
||||
store: makeApiKeyStore("zai", ["zai:default", "zai:work"]),
|
||||
provider: "zai",
|
||||
});
|
||||
expect(order).toEqual(["zai:work", "zai:default"]);
|
||||
});
|
||||
it("normalizes provider casing in auth.order keys", () => {
|
||||
const order = resolveAuthProfileOrder({
|
||||
cfg: {
|
||||
auth: {
|
||||
order: { OpenAI: ["openai:work", "openai:default"] },
|
||||
profiles: makeApiKeyProfilesByProviderProvider({
|
||||
"openai:default": "openai",
|
||||
"openai:work": "openai",
|
||||
}),
|
||||
},
|
||||
},
|
||||
store: makeApiKeyStore("openai", ["openai:default", "openai:work"]),
|
||||
provider: "openai",
|
||||
});
|
||||
expect(order).toEqual(["openai:work", "openai:default"]);
|
||||
});
|
||||
it("normalizes z.ai aliases in auth.profiles", () => {
|
||||
const order = resolveAuthProfileOrder({
|
||||
cfg: {
|
||||
auth: {
|
||||
profiles: makeApiKeyProfilesByProviderProvider({
|
||||
"zai:default": "z.ai",
|
||||
"zai:work": "Z.AI",
|
||||
}),
|
||||
},
|
||||
},
|
||||
store: makeApiKeyStore("zai", ["zai:default", "zai:work"]),
|
||||
provider: "zai",
|
||||
});
|
||||
expect(order).toEqual(["zai:default", "zai:work"]);
|
||||
});
|
||||
it("prioritizes oauth profiles when order missing", () => {
|
||||
const mixedStore: AuthProfileStore = {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"anthropic:default": {
|
||||
type: "api_key",
|
||||
provider: "anthropic",
|
||||
key: "sk-default",
|
||||
},
|
||||
"anthropic:oauth": {
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: Date.now() + 60_000,
|
||||
},
|
||||
},
|
||||
};
|
||||
const order = resolveAuthProfileOrder({
|
||||
store: mixedStore,
|
||||
provider: "anthropic",
|
||||
});
|
||||
expect(order).toEqual(["anthropic:oauth", "anthropic:default"]);
|
||||
});
|
||||
});
|
||||
@@ -1,72 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveAuthProfileOrder } from "./auth-profiles/order.js";
|
||||
|
||||
describe("resolveAuthProfileOrder", () => {
|
||||
it("orders by lastUsed when no explicit order exists", () => {
|
||||
const order = resolveAuthProfileOrder({
|
||||
store: {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"anthropic:a": {
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: Date.now() + 60_000,
|
||||
},
|
||||
"anthropic:b": {
|
||||
type: "api_key",
|
||||
provider: "anthropic",
|
||||
key: "sk-b",
|
||||
},
|
||||
"anthropic:c": {
|
||||
type: "api_key",
|
||||
provider: "anthropic",
|
||||
key: "sk-c",
|
||||
},
|
||||
},
|
||||
usageStats: {
|
||||
"anthropic:a": { lastUsed: 200 },
|
||||
"anthropic:b": { lastUsed: 100 },
|
||||
"anthropic:c": { lastUsed: 300 },
|
||||
},
|
||||
},
|
||||
provider: "anthropic",
|
||||
});
|
||||
expect(order).toEqual(["anthropic:a", "anthropic:b", "anthropic:c"]);
|
||||
});
|
||||
it("pushes cooldown profiles to the end, ordered by cooldown expiry", () => {
|
||||
const now = Date.now();
|
||||
const order = resolveAuthProfileOrder({
|
||||
store: {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"anthropic:ready": {
|
||||
type: "api_key",
|
||||
provider: "anthropic",
|
||||
key: "sk-ready",
|
||||
},
|
||||
"anthropic:cool1": {
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: now + 60_000,
|
||||
},
|
||||
"anthropic:cool2": {
|
||||
type: "api_key",
|
||||
provider: "anthropic",
|
||||
key: "sk-cool",
|
||||
},
|
||||
},
|
||||
usageStats: {
|
||||
"anthropic:ready": { lastUsed: 50 },
|
||||
"anthropic:cool1": { cooldownUntil: now + 120_000 },
|
||||
"anthropic:cool2": { cooldownUntil: now + 60_000 },
|
||||
},
|
||||
},
|
||||
provider: "anthropic",
|
||||
});
|
||||
expect(order).toEqual(["anthropic:ready", "anthropic:cool2", "anthropic:cool1"]);
|
||||
});
|
||||
});
|
||||
@@ -1,291 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ANTHROPIC_CFG,
|
||||
ANTHROPIC_STORE,
|
||||
} from "./auth-profiles.resolve-auth-profile-order.fixtures.js";
|
||||
import { resolveAuthProfileOrder } from "./auth-profiles/order.js";
|
||||
|
||||
describe("resolveAuthProfileOrder", () => {
|
||||
const store = ANTHROPIC_STORE;
|
||||
const cfg = ANTHROPIC_CFG;
|
||||
|
||||
function resolveMinimaxOrderWithProfile(profile: {
|
||||
type: "token";
|
||||
provider: "minimax";
|
||||
token?: string;
|
||||
tokenRef?: { source: "env" | "file" | "exec"; provider: string; id: string };
|
||||
expires?: number;
|
||||
}) {
|
||||
return resolveAuthProfileOrder({
|
||||
cfg: {
|
||||
auth: {
|
||||
order: {
|
||||
minimax: ["minimax:default"],
|
||||
},
|
||||
},
|
||||
},
|
||||
store: {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"minimax:default": {
|
||||
...profile,
|
||||
},
|
||||
},
|
||||
},
|
||||
provider: "minimax",
|
||||
});
|
||||
}
|
||||
|
||||
it("uses stored profiles when no config exists", () => {
|
||||
const order = resolveAuthProfileOrder({
|
||||
store,
|
||||
provider: "anthropic",
|
||||
});
|
||||
expect(order).toEqual(["anthropic:default", "anthropic:work"]);
|
||||
});
|
||||
it("prioritizes preferred profiles", () => {
|
||||
const order = resolveAuthProfileOrder({
|
||||
cfg,
|
||||
store,
|
||||
provider: "anthropic",
|
||||
preferredProfile: "anthropic:work",
|
||||
});
|
||||
expect(order[0]).toBe("anthropic:work");
|
||||
expect(order).toContain("anthropic:default");
|
||||
});
|
||||
it("drops explicit order entries that are missing from the store", () => {
|
||||
const order = resolveAuthProfileOrder({
|
||||
cfg: {
|
||||
auth: {
|
||||
order: {
|
||||
minimax: ["minimax:default", "minimax:prod"],
|
||||
},
|
||||
},
|
||||
},
|
||||
store: {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"minimax:prod": {
|
||||
type: "api_key",
|
||||
provider: "minimax",
|
||||
key: "sk-prod",
|
||||
},
|
||||
},
|
||||
},
|
||||
provider: "minimax",
|
||||
});
|
||||
expect(order).toEqual(["minimax:prod"]);
|
||||
});
|
||||
it("falls back to stored provider profiles when config profile ids drift", () => {
|
||||
const order = resolveAuthProfileOrder({
|
||||
cfg: {
|
||||
auth: {
|
||||
profiles: {
|
||||
"openai-codex:default": {
|
||||
provider: "openai-codex",
|
||||
mode: "oauth",
|
||||
},
|
||||
},
|
||||
order: {
|
||||
"openai-codex": ["openai-codex:default"],
|
||||
},
|
||||
},
|
||||
},
|
||||
store: {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"openai-codex:user@example.com": {
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: Date.now() + 60_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
provider: "openai-codex",
|
||||
});
|
||||
expect(order).toEqual(["openai-codex:user@example.com"]);
|
||||
});
|
||||
it("does not bypass explicit ids when the configured profile exists but is invalid", () => {
|
||||
const order = resolveAuthProfileOrder({
|
||||
cfg: {
|
||||
auth: {
|
||||
profiles: {
|
||||
"openai-codex:default": {
|
||||
provider: "openai-codex",
|
||||
mode: "token",
|
||||
},
|
||||
},
|
||||
order: {
|
||||
"openai-codex": ["openai-codex:default"],
|
||||
},
|
||||
},
|
||||
},
|
||||
store: {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"openai-codex:default": {
|
||||
type: "token",
|
||||
provider: "openai-codex",
|
||||
token: "expired-token",
|
||||
expires: Date.now() - 1_000,
|
||||
},
|
||||
"openai-codex:user@example.com": {
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: Date.now() + 60_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
provider: "openai-codex",
|
||||
});
|
||||
expect(order).toEqual([]);
|
||||
});
|
||||
it("drops explicit order entries that belong to another provider", () => {
|
||||
const order = resolveAuthProfileOrder({
|
||||
cfg: {
|
||||
auth: {
|
||||
order: {
|
||||
minimax: ["openai:default", "minimax:prod"],
|
||||
},
|
||||
},
|
||||
},
|
||||
store: {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"openai:default": {
|
||||
type: "api_key",
|
||||
provider: "openai",
|
||||
key: "sk-openai",
|
||||
},
|
||||
"minimax:prod": {
|
||||
type: "api_key",
|
||||
provider: "minimax",
|
||||
key: "sk-mini",
|
||||
},
|
||||
},
|
||||
},
|
||||
provider: "minimax",
|
||||
});
|
||||
expect(order).toEqual(["minimax:prod"]);
|
||||
});
|
||||
it.each([
|
||||
{
|
||||
caseName: "drops token profiles with empty credentials",
|
||||
profile: {
|
||||
type: "token" as const,
|
||||
provider: "minimax" as const,
|
||||
token: " ",
|
||||
},
|
||||
},
|
||||
{
|
||||
caseName: "drops token profiles that are already expired",
|
||||
profile: {
|
||||
type: "token" as const,
|
||||
provider: "minimax" as const,
|
||||
token: "sk-minimax",
|
||||
expires: Date.now() - 1000,
|
||||
},
|
||||
},
|
||||
{
|
||||
caseName: "drops token profiles with invalid expires metadata",
|
||||
profile: {
|
||||
type: "token" as const,
|
||||
provider: "minimax" as const,
|
||||
token: "sk-minimax",
|
||||
expires: 0,
|
||||
},
|
||||
},
|
||||
])("$caseName", ({ profile }) => {
|
||||
const order = resolveMinimaxOrderWithProfile(profile);
|
||||
expect(order).toEqual([]);
|
||||
});
|
||||
it("keeps api_key profiles backed by keyRef when plaintext key is absent", () => {
|
||||
const order = resolveAuthProfileOrder({
|
||||
cfg: {
|
||||
auth: {
|
||||
order: {
|
||||
anthropic: ["anthropic:default"],
|
||||
},
|
||||
},
|
||||
},
|
||||
store: {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"anthropic:default": {
|
||||
type: "api_key",
|
||||
provider: "anthropic",
|
||||
keyRef: {
|
||||
source: "exec",
|
||||
provider: "vault_local",
|
||||
id: "anthropic/default",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
provider: "anthropic",
|
||||
});
|
||||
expect(order).toEqual(["anthropic:default"]);
|
||||
});
|
||||
it("keeps token profiles backed by tokenRef when expires is absent", () => {
|
||||
const order = resolveMinimaxOrderWithProfile({
|
||||
type: "token",
|
||||
provider: "minimax",
|
||||
tokenRef: {
|
||||
source: "exec",
|
||||
provider: "keychain",
|
||||
id: "minimax/default",
|
||||
},
|
||||
});
|
||||
expect(order).toEqual(["minimax:default"]);
|
||||
});
|
||||
it("drops tokenRef profiles when expires is invalid", () => {
|
||||
const order = resolveMinimaxOrderWithProfile({
|
||||
type: "token",
|
||||
provider: "minimax",
|
||||
tokenRef: {
|
||||
source: "exec",
|
||||
provider: "keychain",
|
||||
id: "minimax/default",
|
||||
},
|
||||
expires: 0,
|
||||
});
|
||||
expect(order).toEqual([]);
|
||||
});
|
||||
it("keeps token profiles with inline token when no expires is set", () => {
|
||||
const order = resolveMinimaxOrderWithProfile({
|
||||
type: "token",
|
||||
provider: "minimax",
|
||||
token: "sk-minimax",
|
||||
});
|
||||
expect(order).toEqual(["minimax:default"]);
|
||||
});
|
||||
it("keeps oauth profiles that can refresh", () => {
|
||||
const order = resolveAuthProfileOrder({
|
||||
cfg: {
|
||||
auth: {
|
||||
order: {
|
||||
anthropic: ["anthropic:oauth"],
|
||||
},
|
||||
},
|
||||
},
|
||||
store: {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"anthropic:oauth": {
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: "",
|
||||
refresh: "refresh-token",
|
||||
expires: Date.now() - 1000,
|
||||
},
|
||||
},
|
||||
},
|
||||
provider: "anthropic",
|
||||
});
|
||||
expect(order).toEqual(["anthropic:oauth"]);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
import { resetProcessRegistryForTests } from "./bash-process-registry.js";
|
||||
import { markBackgrounded, resetProcessRegistryForTests } from "./bash-process-registry.js";
|
||||
import { runExecProcess } from "./bash-tools.exec-runtime.js";
|
||||
import { createProcessTool } from "./bash-tools.process.js";
|
||||
|
||||
afterEach(() => {
|
||||
resetProcessRegistryForTests();
|
||||
@@ -37,6 +38,51 @@ async function runPtyCommand(command: string) {
|
||||
return await handle.promise;
|
||||
}
|
||||
|
||||
async function startPtySession(command: string) {
|
||||
const processTool = createProcessTool();
|
||||
const run = await runExecProcess({
|
||||
command,
|
||||
workdir: process.cwd(),
|
||||
env: currentEnv(),
|
||||
usePty: true,
|
||||
warnings: [],
|
||||
maxOutput: 20_000,
|
||||
pendingMaxOutput: 20_000,
|
||||
notifyOnExit: false,
|
||||
timeoutSec: 5,
|
||||
});
|
||||
markBackgrounded(run.session);
|
||||
return { processTool, sessionId: run.session.id };
|
||||
}
|
||||
|
||||
async function waitForSessionCompletion(params: {
|
||||
processTool: ReturnType<typeof createProcessTool>;
|
||||
sessionId: string;
|
||||
expectedText: string;
|
||||
}) {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const poll = await params.processTool.execute("toolcall", {
|
||||
action: "poll",
|
||||
sessionId: params.sessionId,
|
||||
});
|
||||
const details = poll.details as { status?: string; aggregated?: string };
|
||||
if (details.status === "running") {
|
||||
return false;
|
||||
}
|
||||
expect(details.status).toBe("completed");
|
||||
expect(details.aggregated ?? "").toContain(params.expectedText);
|
||||
return true;
|
||||
},
|
||||
{
|
||||
timeout: process.platform === "win32" ? 12_000 : 8_000,
|
||||
interval: 30,
|
||||
},
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
test("exec supports pty output", async () => {
|
||||
const result = await runPtyCommand(
|
||||
currentNodeEvalCommand("process.stdout.write(String.fromCharCode(111,107))"),
|
||||
@@ -54,3 +100,34 @@ test("exec sets OPENCLAW_SHELL in pty mode", async () => {
|
||||
expect(result.status).toBe("completed");
|
||||
expect(result.aggregated).toContain("exec");
|
||||
});
|
||||
|
||||
test("process send-keys encodes Enter for pty sessions", async () => {
|
||||
const { processTool, sessionId } = await startPtySession(
|
||||
currentNodeEvalCommand(
|
||||
"const dataEvent=String.fromCharCode(100,97,116,97);process.stdin.on(dataEvent,d=>{process.stdout.write(d);if(d.includes(10)||d.includes(13))process.exit(0);});",
|
||||
),
|
||||
);
|
||||
|
||||
await processTool.execute("toolcall", {
|
||||
action: "send-keys",
|
||||
sessionId,
|
||||
keys: ["h", "i", "Enter"],
|
||||
});
|
||||
|
||||
await waitForSessionCompletion({ processTool, sessionId, expectedText: "hi" });
|
||||
});
|
||||
|
||||
test("process submit sends Enter for pty sessions", async () => {
|
||||
const { processTool, sessionId } = await startPtySession(
|
||||
currentNodeEvalCommand(
|
||||
"const dataEvent=String.fromCharCode(100,97,116,97);const submitted=String.fromCharCode(115,117,98,109,105,116,116,101,100);process.stdin.on(dataEvent,d=>{if(d.includes(10)||d.includes(13)){process.stdout.write(submitted);process.exit(0);}});",
|
||||
),
|
||||
);
|
||||
|
||||
await processTool.execute("toolcall", {
|
||||
action: "submit",
|
||||
sessionId,
|
||||
});
|
||||
|
||||
await waitForSessionCompletion({ processTool, sessionId, expectedText: "submitted" });
|
||||
});
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
import { markBackgrounded, resetProcessRegistryForTests } from "./bash-process-registry.js";
|
||||
import { runExecProcess } from "./bash-tools.exec-runtime.js";
|
||||
import { createProcessTool } from "./bash-tools.process.js";
|
||||
|
||||
afterEach(() => {
|
||||
resetProcessRegistryForTests();
|
||||
});
|
||||
|
||||
function currentEnv(): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (typeof value === "string") {
|
||||
env[key] = value;
|
||||
}
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replaceAll("'", process.platform === "win32" ? "''" : "'\\''")}'`;
|
||||
}
|
||||
|
||||
function currentNodeEvalCommand(source: string): string {
|
||||
const node = shellQuote(process.execPath);
|
||||
const script = shellQuote(source);
|
||||
return process.platform === "win32" ? `& ${node} -e ${script}` : `${node} -e ${script}`;
|
||||
}
|
||||
|
||||
async function startPtySession(command: string) {
|
||||
const processTool = createProcessTool();
|
||||
const run = await runExecProcess({
|
||||
command,
|
||||
workdir: process.cwd(),
|
||||
env: currentEnv(),
|
||||
usePty: true,
|
||||
warnings: [],
|
||||
maxOutput: 20_000,
|
||||
pendingMaxOutput: 20_000,
|
||||
notifyOnExit: false,
|
||||
timeoutSec: 5,
|
||||
});
|
||||
markBackgrounded(run.session);
|
||||
return { processTool, sessionId: run.session.id };
|
||||
}
|
||||
|
||||
async function waitForSessionCompletion(params: {
|
||||
processTool: ReturnType<typeof createProcessTool>;
|
||||
sessionId: string;
|
||||
expectedText: string;
|
||||
}) {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const poll = await params.processTool.execute("toolcall", {
|
||||
action: "poll",
|
||||
sessionId: params.sessionId,
|
||||
});
|
||||
const details = poll.details as { status?: string; aggregated?: string };
|
||||
if (details.status === "running") {
|
||||
return false;
|
||||
}
|
||||
expect(details.status).toBe("completed");
|
||||
expect(details.aggregated ?? "").toContain(params.expectedText);
|
||||
return true;
|
||||
},
|
||||
{
|
||||
timeout: process.platform === "win32" ? 12_000 : 8_000,
|
||||
interval: 30,
|
||||
},
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
test("process send-keys encodes Enter for pty sessions", async () => {
|
||||
const { processTool, sessionId } = await startPtySession(
|
||||
currentNodeEvalCommand(
|
||||
"const dataEvent=String.fromCharCode(100,97,116,97);process.stdin.on(dataEvent,d=>{process.stdout.write(d);if(d.includes(10)||d.includes(13))process.exit(0);});",
|
||||
),
|
||||
);
|
||||
|
||||
await processTool.execute("toolcall", {
|
||||
action: "send-keys",
|
||||
sessionId,
|
||||
keys: ["h", "i", "Enter"],
|
||||
});
|
||||
|
||||
await waitForSessionCompletion({ processTool, sessionId, expectedText: "hi" });
|
||||
});
|
||||
|
||||
test("process submit sends Enter for pty sessions", async () => {
|
||||
const { processTool, sessionId } = await startPtySession(
|
||||
currentNodeEvalCommand(
|
||||
"const dataEvent=String.fromCharCode(100,97,116,97);const submitted=String.fromCharCode(115,117,98,109,105,116,116,101,100);process.stdin.on(dataEvent,d=>{if(d.includes(10)||d.includes(13)){process.stdout.write(submitted);process.exit(0);}});",
|
||||
),
|
||||
);
|
||||
|
||||
await processTool.execute("toolcall", {
|
||||
action: "submit",
|
||||
sessionId,
|
||||
});
|
||||
|
||||
await waitForSessionCompletion({ processTool, sessionId, expectedText: "submitted" });
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { normalizeConfiguredMcpServers } from "../config/mcp-config.js";
|
||||
import { normalizeConfiguredMcpServers } from "../config/mcp-config-normalize.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import {
|
||||
loadEnabledBundleMcpConfig,
|
||||
|
||||
14
src/config/mcp-config-normalize.ts
Normal file
14
src/config/mcp-config-normalize.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { isRecord } from "../utils.js";
|
||||
|
||||
export type ConfigMcpServers = Record<string, Record<string, unknown>>;
|
||||
|
||||
export function normalizeConfiguredMcpServers(value: unknown): ConfigMcpServers {
|
||||
if (!isRecord(value)) {
|
||||
return {};
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.filter(([, server]) => isRecord(server))
|
||||
.map(([name, server]) => [name, { ...(server as Record<string, unknown>) }]),
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { isRecord } from "../utils.js";
|
||||
import { readSourceConfigSnapshot } from "./io.js";
|
||||
import { normalizeConfiguredMcpServers, type ConfigMcpServers } from "./mcp-config-normalize.js";
|
||||
import { replaceConfigFile } from "./mutate.js";
|
||||
import type { OpenClawConfig } from "./types.openclaw.js";
|
||||
import { validateConfigObjectWithPlugins } from "./validation.js";
|
||||
|
||||
export type ConfigMcpServers = Record<string, Record<string, unknown>>;
|
||||
export { normalizeConfiguredMcpServers, type ConfigMcpServers } from "./mcp-config-normalize.js";
|
||||
|
||||
type ConfigMcpReadResult =
|
||||
| {
|
||||
@@ -26,17 +27,6 @@ type ConfigMcpWriteResult =
|
||||
}
|
||||
| { ok: false; path: string; error: string };
|
||||
|
||||
export function normalizeConfiguredMcpServers(value: unknown): ConfigMcpServers {
|
||||
if (!isRecord(value)) {
|
||||
return {};
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.filter(([, server]) => isRecord(server))
|
||||
.map(([name, server]) => [name, { ...(server as Record<string, unknown>) }]),
|
||||
);
|
||||
}
|
||||
|
||||
export async function listConfiguredMcpServers(): Promise<ConfigMcpReadResult> {
|
||||
const snapshot = await readSourceConfigSnapshot();
|
||||
if (!snapshot.valid) {
|
||||
|
||||
Reference in New Issue
Block a user