From 3be8e68898d75c9860ad7099cee29faa48388c6b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Apr 2026 09:35:35 +0100 Subject: [PATCH] test: dedupe fast lane imports --- ...tize-lastgood-round-robin-ordering.test.ts | 444 ++++++++++++++++++ ...normalizes-z-ai-aliases-auth-order.test.ts | 104 ---- ...-lastused-no-explicit-order-exists.test.ts | 72 --- ...s-stored-profiles-no-config-exists.test.ts | 291 ------------ src/agents/bash-tools.exec.pty.test.ts | 79 +++- .../bash-tools.process.send-keys.test.ts | 104 ---- src/agents/bundle-mcp-config.ts | 2 +- src/config/mcp-config-normalize.ts | 14 + src/config/mcp-config.ts | 14 +- 9 files changed, 539 insertions(+), 585 deletions(-) delete mode 100644 src/agents/auth-profiles.resolve-auth-profile-order.normalizes-z-ai-aliases-auth-order.test.ts delete mode 100644 src/agents/auth-profiles.resolve-auth-profile-order.orders-by-lastused-no-explicit-order-exists.test.ts delete mode 100644 src/agents/auth-profiles.resolve-auth-profile-order.uses-stored-profiles-no-config-exists.test.ts delete mode 100644 src/agents/bash-tools.process.send-keys.test.ts create mode 100644 src/config/mcp-config-normalize.ts diff --git a/src/agents/auth-profiles.resolve-auth-profile-order.does-not-prioritize-lastgood-round-robin-ordering.test.ts b/src/agents/auth-profiles.resolve-auth-profile-order.does-not-prioritize-lastgood-round-robin-ordering.test.ts index 5e50e6350bd..d8fcb094e21 100644 --- a/src/agents/auth-profiles.resolve-auth-profile-order.does-not-prioritize-lastgood-round-robin-ordering.test.ts +++ b/src/agents/auth-profiles.resolve-auth-profile-order.does-not-prioritize-lastgood-round-robin-ordering.test.ts @@ -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, +): Record { + 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"]); + }); }); diff --git a/src/agents/auth-profiles.resolve-auth-profile-order.normalizes-z-ai-aliases-auth-order.test.ts b/src/agents/auth-profiles.resolve-auth-profile-order.normalizes-z-ai-aliases-auth-order.test.ts deleted file mode 100644 index ce903f1bfb7..00000000000 --- a/src/agents/auth-profiles.resolve-auth-profile-order.normalizes-z-ai-aliases-auth-order.test.ts +++ /dev/null @@ -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, -): Record { - 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"]); - }); -}); diff --git a/src/agents/auth-profiles.resolve-auth-profile-order.orders-by-lastused-no-explicit-order-exists.test.ts b/src/agents/auth-profiles.resolve-auth-profile-order.orders-by-lastused-no-explicit-order-exists.test.ts deleted file mode 100644 index d569d072b9f..00000000000 --- a/src/agents/auth-profiles.resolve-auth-profile-order.orders-by-lastused-no-explicit-order-exists.test.ts +++ /dev/null @@ -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"]); - }); -}); diff --git a/src/agents/auth-profiles.resolve-auth-profile-order.uses-stored-profiles-no-config-exists.test.ts b/src/agents/auth-profiles.resolve-auth-profile-order.uses-stored-profiles-no-config-exists.test.ts deleted file mode 100644 index 5c8b44bf6d5..00000000000 --- a/src/agents/auth-profiles.resolve-auth-profile-order.uses-stored-profiles-no-config-exists.test.ts +++ /dev/null @@ -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"]); - }); -}); diff --git a/src/agents/bash-tools.exec.pty.test.ts b/src/agents/bash-tools.exec.pty.test.ts index 368bc970a7f..88238ae1592 100644 --- a/src/agents/bash-tools.exec.pty.test.ts +++ b/src/agents/bash-tools.exec.pty.test.ts @@ -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; + 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" }); +}); diff --git a/src/agents/bash-tools.process.send-keys.test.ts b/src/agents/bash-tools.process.send-keys.test.ts deleted file mode 100644 index cb60e129187..00000000000 --- a/src/agents/bash-tools.process.send-keys.test.ts +++ /dev/null @@ -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 { - const env: Record = {}; - 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; - 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" }); -}); diff --git a/src/agents/bundle-mcp-config.ts b/src/agents/bundle-mcp-config.ts index fa8fbea4e28..cb0eda024ad 100644 --- a/src/agents/bundle-mcp-config.ts +++ b/src/agents/bundle-mcp-config.ts @@ -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, diff --git a/src/config/mcp-config-normalize.ts b/src/config/mcp-config-normalize.ts new file mode 100644 index 00000000000..290b006dc4a --- /dev/null +++ b/src/config/mcp-config-normalize.ts @@ -0,0 +1,14 @@ +import { isRecord } from "../utils.js"; + +export type ConfigMcpServers = Record>; + +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) }]), + ); +} diff --git a/src/config/mcp-config.ts b/src/config/mcp-config.ts index 5517ecba051..8c45757e919 100644 --- a/src/config/mcp-config.ts +++ b/src/config/mcp-config.ts @@ -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>; +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) }]), - ); -} - export async function listConfiguredMcpServers(): Promise { const snapshot = await readSourceConfigSnapshot(); if (!snapshot.valid) {