test: trim doctor and auth choice hotspots

This commit is contained in:
Peter Steinberger
2026-04-17 08:53:46 +01:00
parent 675eb38ad0
commit f7f88e52e4
4 changed files with 205 additions and 235 deletions

View File

@@ -1,6 +1,7 @@
import fs from "node:fs/promises";
import path from "node:path";
import type { OAuthCredentials } from "@mariozechner/pi-ai";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { resolveAgentDir } from "../agents/agent-scope.js";
import type { OpenClawConfig } from "../config/config.js";
import { resolveAgentModelPrimaryValue } from "../config/model-input.js";
@@ -9,11 +10,9 @@ import type { ProviderAuthMethod, ProviderAuthResult, ProviderPlugin } from "../
import type { WizardPrompter } from "../wizard/prompts.js";
import { applyAuthChoice } from "./auth-choice.apply.js";
import {
authProfilePathForAgent,
createAuthTestLifecycle,
createExitThrowingRuntime,
createWizardPrompter,
readAuthProfilesForAgent,
requireOpenClawAgentDir,
setupAuthTestEnv,
} from "./test-wizard-helpers.js";
@@ -83,12 +82,49 @@ type StoredAuthProfile = {
keyRef?: { source: string; provider: string; id: string };
access?: string;
refresh?: string;
expires?: number;
provider?: string;
type?: string;
email?: string;
metadata?: Record<string, string>;
};
const testAuthProfileStores = vi.hoisted(
() => new Map<string, { profiles: Record<string, StoredAuthProfile> }>(),
);
// These tests verify profile payloads, not file locking; keep auth stores in memory.
function resolveTestAuthStoreKey(agentDir?: string): string {
return agentDir?.trim() || process.env.OPENCLAW_AGENT_DIR || "__main__";
}
function readTestAuthProfileStore(agentDir?: string): {
profiles: Record<string, StoredAuthProfile>;
} {
return testAuthProfileStores.get(resolveTestAuthStoreKey(agentDir)) ?? { profiles: {} };
}
function seedTestAuthProfile(params: {
profileId: string;
credential: StoredAuthProfile;
agentDir?: string;
}): void {
const key = resolveTestAuthStoreKey(params.agentDir);
const store = testAuthProfileStores.get(key) ?? { profiles: {} };
store.profiles[params.profileId] = params.credential;
testAuthProfileStores.set(key, store);
}
vi.mock("../agents/auth-profiles.js", () => ({
upsertAuthProfile: (params: {
profileId: string;
credential: StoredAuthProfile;
agentDir?: string;
}) => {
seedTestAuthProfile(params);
},
}));
function normalizeText(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
@@ -723,14 +759,18 @@ describe("applyAuthChoice", () => {
"SSH_TTY",
"CHUTES_CLIENT_ID",
]);
let activeStateDir: string | null = null;
let authTestRoot: string | null = null;
let authStateCounter = 0;
async function setupTempState() {
if (activeStateDir) {
await fs.rm(activeStateDir, { recursive: true, force: true });
if (!authTestRoot) {
throw new Error("auth test root not initialized");
}
const env = await setupAuthTestEnv("openclaw-auth-");
activeStateDir = env.stateDir;
lifecycle.setStateDir(env.stateDir);
testAuthProfileStores.clear();
const stateDir = path.join(authTestRoot, `state-${++authStateCounter}`);
const agentDir = path.join(stateDir, "agent");
process.env.OPENCLAW_STATE_DIR = stateDir;
process.env.OPENCLAW_AGENT_DIR = agentDir;
process.env.PI_CODING_AGENT_DIR = agentDir;
}
function createPrompter(overrides: Partial<WizardPrompter>): WizardPrompter {
return createWizardPrompter(overrides, { defaultSelect: "" });
@@ -759,9 +799,10 @@ describe("applyAuthChoice", () => {
};
}
async function readAuthProfiles() {
return await readAuthProfilesForAgent<{
profiles?: Record<string, StoredAuthProfile>;
}>(requireOpenClawAgentDir());
return readTestAuthProfileStore(requireOpenClawAgentDir());
}
async function readAuthProfilesForAgentDir(agentDir: string) {
return readTestAuthProfileStore(agentDir);
}
async function readAuthProfile(profileId: string) {
return (await readAuthProfiles()).profiles?.[profileId];
@@ -770,10 +811,17 @@ describe("applyAuthChoice", () => {
let defaultProviderPlugins: ProviderPlugin[] = [];
beforeAll(async () => {
authTestRoot = (await setupAuthTestEnv("openclaw-auth-")).stateDir;
defaultProviderPlugins = await createDefaultProviderPlugins();
resolvePluginProviders.mockReturnValue(defaultProviderPlugins);
});
afterAll(async () => {
if (authTestRoot) {
await fs.rm(authTestRoot, { recursive: true, force: true });
}
});
afterEach(async () => {
vi.unstubAllGlobals();
resolvePluginProviders.mockReset();
@@ -783,8 +831,8 @@ describe("applyAuthChoice", () => {
detectZaiEndpoint.mockResolvedValue(null);
loginOpenAICodexOAuth.mockReset();
loginOpenAICodexOAuth.mockResolvedValue(null);
testAuthProfileStores.clear();
await lifecycle.cleanup();
activeStateDir = null;
});
it("applies Anthropic setup-token auth when the provider exposes the setup flow", async () => {
@@ -1464,18 +1512,14 @@ describe("applyAuthChoice", () => {
});
const profileStore =
scenario.agentId && scenario.agentId !== "default"
? await readAuthProfilesForAgent<{ profiles?: Record<string, StoredAuthProfile> }>(
resolveAgentDir(result.config, scenario.agentId),
)
? await readAuthProfilesForAgentDir(resolveAgentDir(result.config, scenario.agentId))
: await readAuthProfiles();
expect(profileStore.profiles?.[scenario.profileId]?.key).toBe(scenario.token);
}
if (scenario.extraProfileId) {
const profileStore =
scenario.agentId && scenario.agentId !== "default"
? await readAuthProfilesForAgent<{ profiles?: Record<string, StoredAuthProfile> }>(
resolveAgentDir(result.config, scenario.agentId),
)
? await readAuthProfilesForAgentDir(resolveAgentDir(result.config, scenario.agentId))
: await readAuthProfiles();
expect(profileStore.profiles?.[scenario.extraProfileId]?.key).toBe(scenario.token);
}
@@ -1580,27 +1624,17 @@ describe("applyAuthChoice", () => {
await setupTempState();
process.env.LITELLM_API_KEY = "sk-litellm-test"; // pragma: allowlist secret
const authProfilePath = authProfilePathForAgent(requireOpenClawAgentDir());
await fs.writeFile(
authProfilePath,
JSON.stringify(
{
version: 1,
profiles: {
"litellm:legacy": {
type: "oauth",
provider: "litellm",
access: "access-token",
refresh: "refresh-token",
expires: Date.now() + 60_000,
},
},
},
null,
2,
),
"utf8",
);
seedTestAuthProfile({
profileId: "litellm:legacy",
credential: {
type: "oauth",
provider: "litellm",
access: "access-token",
refresh: "refresh-token",
expires: Date.now() + 60_000,
},
agentDir: requireOpenClawAgentDir(),
});
const text = vi.fn();
const confirm = vi.fn(async () => true);

View File

@@ -1145,29 +1145,13 @@ describe("doctor config flow", () => {
).toBe(true);
});
it("drops unknown keys on repair", async () => {
it("repairs generic legacy config surfaces in one pass", async () => {
const result = await runDoctorConfigWithInput({
repair: true,
config: {
bridge: { bind: "auto" },
gateway: { auth: { mode: "token", token: "ok", extra: true } },
agents: { list: [{ id: "pi" }] },
},
run: loadAndMaybeMigrateDoctorConfig,
});
const cfg = result.cfg as Record<string, unknown>;
expect(cfg.bridge).toBeUndefined();
expect((cfg.gateway as Record<string, unknown>)?.auth).toEqual({
mode: "token",
token: "ok",
});
});
it("migrates legacy browser extension profiles to existing-session on repair", async () => {
const result = await runDoctorConfigWithInput({
repair: true,
config: {
browser: {
relayBindHost: "0.0.0.0",
profiles: {
@@ -1177,21 +1161,6 @@ describe("doctor config flow", () => {
},
},
},
},
run: loadAndMaybeMigrateDoctorConfig,
});
const browser = (result.cfg as { browser?: Record<string, unknown> }).browser ?? {};
expect(browser.relayBindHost).toBeUndefined();
expect(
((browser.profiles as Record<string, { driver?: string }>)?.chromeLive ?? {}).driver,
).toBe("existing-session");
});
it("repairs restrictive plugins.allow when browser is referenced via tools.alsoAllow", async () => {
const result = await runDoctorConfigWithInput({
repair: true,
config: {
tools: {
alsoAllow: ["browser"],
},
@@ -1202,6 +1171,17 @@ describe("doctor config flow", () => {
run: loadAndMaybeMigrateDoctorConfig,
});
const cfg = result.cfg as Record<string, unknown>;
expect(cfg.bridge).toBeUndefined();
expect((cfg.gateway as Record<string, unknown>)?.auth).toEqual({
mode: "token",
token: "ok",
});
const browser = (result.cfg as { browser?: Record<string, unknown> }).browser ?? {};
expect(browser.relayBindHost).toBeUndefined();
expect(
((browser.profiles as Record<string, { driver?: string }>)?.chromeLive ?? {}).driver,
).toBe("existing-session");
expect(result.cfg.plugins?.allow).toEqual(["telegram", "browser"]);
expect(result.cfg.plugins?.entries?.browser?.enabled).toBe(true);
});
@@ -1735,7 +1715,7 @@ describe("doctor config flow", () => {
expect(cfg.channels.discord.accounts.default.allowFrom).toEqual(["123"]);
});
it('adds allowFrom ["*"] when dmPolicy="open" and allowFrom is missing on repair', async () => {
it('repairs open dmPolicy allowFrom variants with ["*"] in one pass', async () => {
const result = await runDoctorConfigWithInput({
repair: true,
config: {
@@ -1745,16 +1725,40 @@ describe("doctor config flow", () => {
dmPolicy: "open",
groupPolicy: "open",
},
googlechat: {
accounts: {
work: {
dm: {
policy: "open",
},
},
},
},
},
},
run: loadAndMaybeMigrateDoctorConfig,
});
const cfg = result.cfg as unknown as {
channels: { discord: { allowFrom: string[]; dmPolicy: string } };
channels: {
discord: { allowFrom: string[]; dmPolicy: string };
googlechat: {
accounts: {
work: {
dm: {
policy: string;
allowFrom: string[];
};
allowFrom?: string[];
};
};
};
};
};
expect(cfg.channels.discord.allowFrom).toEqual(["*"]);
expect(cfg.channels.discord.dmPolicy).toBe("open");
expect(cfg.channels.googlechat.accounts.work.dm.allowFrom).toEqual(["*"]);
expect(cfg.channels.googlechat.accounts.work.allowFrom).toBeUndefined();
});
it('repairs dmPolicy="allowlist" by restoring allowFrom from pairing store on repair', async () => {
@@ -1847,13 +1851,37 @@ describe("doctor config flow", () => {
expect(toolsBySender["*"]).toEqual({ deny: ["exec"] });
});
it("migrates top-level heartbeat into agents.defaults.heartbeat on repair", async () => {
it("repairs legacy root runtime config surfaces in one pass", async () => {
const result = await runDoctorConfigWithInput({
repair: true,
config: {
heartbeat: {
model: "anthropic/claude-3-5-haiku-20241022",
every: "30m",
showOk: true,
showAlerts: false,
},
gateway: {
bind: "0.0.0.0",
},
session: {
threadBindings: {
ttlHours: 24,
},
},
channels: {
discord: {
threadBindings: {
ttlHours: 12,
},
accounts: {
alpha: {
threadBindings: {
ttlHours: 6,
},
},
},
},
},
},
run: loadAndMaybeMigrateDoctorConfig,
@@ -1861,6 +1889,15 @@ describe("doctor config flow", () => {
const cfg = result.cfg as {
heartbeat?: unknown;
gateway?: {
bind?: string;
};
session?: {
threadBindings?: {
idleHours?: number;
ttlHours?: number;
};
};
agents?: {
defaults?: {
heartbeat?: {
@@ -1869,12 +1906,53 @@ describe("doctor config flow", () => {
};
};
};
channels?: {
defaults?: {
heartbeat?: {
showOk?: boolean;
showAlerts?: boolean;
useIndicator?: boolean;
};
};
discord?: {
threadBindings?: {
idleHours?: number;
ttlHours?: number;
};
accounts?: Record<
string,
{
threadBindings?: {
idleHours?: number;
ttlHours?: number;
};
}
>;
};
};
};
expect(cfg.heartbeat).toBeUndefined();
expect(cfg.agents?.defaults?.heartbeat).toMatchObject({
model: "anthropic/claude-3-5-haiku-20241022",
every: "30m",
});
expect(cfg.gateway?.bind).toBe("lan");
expect(cfg.session?.threadBindings).toMatchObject({
idleHours: 24,
});
expect(cfg.channels?.discord?.threadBindings).toMatchObject({
idleHours: 12,
});
expect(cfg.channels?.discord?.accounts?.alpha?.threadBindings).toMatchObject({
idleHours: 6,
});
expect(cfg.session?.threadBindings?.ttlHours).toBeUndefined();
expect(cfg.channels?.discord?.threadBindings?.ttlHours).toBeUndefined();
expect(cfg.channels?.discord?.accounts?.alpha?.threadBindings?.ttlHours).toBeUndefined();
expect(cfg.channels?.defaults?.heartbeat).toMatchObject({
showOk: true,
showAlerts: false,
});
});
it("warns clearly about legacy config surfaces and points to doctor --fix", async () => {
@@ -1985,161 +2063,6 @@ describe("doctor config flow", () => {
}
});
it("repairs legacy gateway.bind host aliases on repair", async () => {
const result = await runDoctorConfigWithInput({
repair: true,
config: {
gateway: {
bind: "0.0.0.0",
},
},
run: loadAndMaybeMigrateDoctorConfig,
});
const cfg = result.cfg as {
gateway?: {
bind?: string;
};
};
expect(cfg.gateway?.bind).toBe("lan");
});
it("repairs legacy thread binding ttlHours config on repair", async () => {
const result = await runDoctorConfigWithInput({
repair: true,
config: {
session: {
threadBindings: {
ttlHours: 24,
},
},
channels: {
discord: {
threadBindings: {
ttlHours: 12,
},
accounts: {
alpha: {
threadBindings: {
ttlHours: 6,
},
},
},
},
},
},
run: loadAndMaybeMigrateDoctorConfig,
});
const cfg = result.cfg as {
session?: {
threadBindings?: {
idleHours?: number;
ttlHours?: number;
};
};
channels?: {
discord?: {
threadBindings?: {
idleHours?: number;
ttlHours?: number;
};
accounts?: Record<
string,
{
threadBindings?: {
idleHours?: number;
ttlHours?: number;
};
}
>;
};
};
};
expect(cfg.session?.threadBindings).toMatchObject({
idleHours: 24,
});
expect(cfg.channels?.discord?.threadBindings).toMatchObject({
idleHours: 12,
});
expect(cfg.channels?.discord?.accounts?.alpha?.threadBindings).toMatchObject({
idleHours: 6,
});
expect(cfg.session?.threadBindings?.ttlHours).toBeUndefined();
expect(cfg.channels?.discord?.threadBindings?.ttlHours).toBeUndefined();
expect(cfg.channels?.discord?.accounts?.alpha?.threadBindings?.ttlHours).toBeUndefined();
});
it("migrates top-level heartbeat visibility into channels.defaults.heartbeat on repair", async () => {
const result = await runDoctorConfigWithInput({
repair: true,
config: {
heartbeat: {
showOk: true,
showAlerts: false,
},
},
run: loadAndMaybeMigrateDoctorConfig,
});
const cfg = result.cfg as {
heartbeat?: unknown;
channels?: {
defaults?: {
heartbeat?: {
showOk?: boolean;
showAlerts?: boolean;
useIndicator?: boolean;
};
};
};
};
expect(cfg.heartbeat).toBeUndefined();
expect(cfg.channels?.defaults?.heartbeat).toMatchObject({
showOk: true,
showAlerts: false,
});
});
it("repairs googlechat account dm.policy open by setting dm.allowFrom on repair", async () => {
const result = await runDoctorConfigWithInput({
repair: true,
config: {
channels: {
googlechat: {
accounts: {
work: {
dm: {
policy: "open",
},
},
},
},
},
},
run: loadAndMaybeMigrateDoctorConfig,
});
const cfg = result.cfg as unknown as {
channels: {
googlechat: {
accounts: {
work: {
dm: {
policy: string;
allowFrom: string[];
};
allowFrom?: string[];
};
};
};
};
};
expect(cfg.channels.googlechat.accounts.work.dm.allowFrom).toEqual(["*"]);
expect(cfg.channels.googlechat.accounts.work.allowFrom).toBeUndefined();
});
it("recovers from stale googlechat top-level allowFrom by repairing dm.allowFrom", async () => {
const result = await runDoctorConfigWithInput({
repair: true,

View File

@@ -81,12 +81,12 @@ export function maybeRepairLegacyToolsBySenderKeys(cfg: OpenClawConfig): {
config: OpenClawConfig;
changes: string[];
} {
const next = structuredClone(cfg);
const hits = scanLegacyToolsBySenderKeys(next);
const hits = scanLegacyToolsBySenderKeys(cfg);
if (hits.length === 0) {
return { config: cfg, changes: [] };
}
const next = structuredClone(cfg);
const summary = new Map<string, { migrated: number; dropped: number; examples: string[] }>();
let changed = false;

View File

@@ -50,7 +50,14 @@ export function scanStalePluginConfig(
return [];
}
const { knownIds } = collectPluginRegistryState(cfg, env);
return scanStalePluginConfigWithState(plugins, collectPluginRegistryState(cfg, env));
}
function scanStalePluginConfigWithState(
plugins: Record<string, unknown>,
registryState: StalePluginRegistryState,
): StalePluginConfigHit[] {
const { knownIds } = registryState;
const hits: StalePluginConfigHit[] = [];
const allow = Array.isArray(plugins.allow) ? plugins.allow : [];
@@ -117,11 +124,17 @@ export function maybeRepairStalePluginConfig(
config: OpenClawConfig;
changes: string[];
} {
if (isStalePluginAutoRepairBlocked(cfg, env)) {
const plugins = asObjectRecord(cfg.plugins);
if (!plugins) {
return { config: cfg, changes: [] };
}
const hits = scanStalePluginConfig(cfg, env);
const registryState = collectPluginRegistryState(cfg, env);
if (registryState.hasDiscoveryErrors) {
return { config: cfg, changes: [] };
}
const hits = scanStalePluginConfigWithState(plugins, registryState);
if (hits.length === 0) {
return { config: cfg, changes: [] };
}