test: dedupe fast lane imports

This commit is contained in:
Peter Steinberger
2026-04-27 09:35:35 +01:00
parent 56ca4e2269
commit 3be8e68898
9 changed files with 539 additions and 585 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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>) }]),
);
}

View File

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