diff --git a/src/commands/auth-choice.test.ts b/src/commands/auth-choice.test.ts index ebe6fc3323d..4f87a874619 100644 --- a/src/commands/auth-choice.test.ts +++ b/src/commands/auth-choice.test.ts @@ -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; }; +const testAuthProfileStores = vi.hoisted( + () => new Map }>(), +); + +// 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; +} { + 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 { return createWizardPrompter(overrides, { defaultSelect: "" }); @@ -759,9 +799,10 @@ describe("applyAuthChoice", () => { }; } async function readAuthProfiles() { - return await readAuthProfilesForAgent<{ - profiles?: Record; - }>(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 }>( - 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 }>( - 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); diff --git a/src/commands/doctor-config-flow.test.ts b/src/commands/doctor-config-flow.test.ts index e494cb18ca6..140bad79260 100644 --- a/src/commands/doctor-config-flow.test.ts +++ b/src/commands/doctor-config-flow.test.ts @@ -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; - expect(cfg.bridge).toBeUndefined(); - expect((cfg.gateway as Record)?.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 }).browser ?? {}; - expect(browser.relayBindHost).toBeUndefined(); - expect( - ((browser.profiles as Record)?.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; + expect(cfg.bridge).toBeUndefined(); + expect((cfg.gateway as Record)?.auth).toEqual({ + mode: "token", + token: "ok", + }); + const browser = (result.cfg as { browser?: Record }).browser ?? {}; + expect(browser.relayBindHost).toBeUndefined(); + expect( + ((browser.profiles as Record)?.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, diff --git a/src/commands/doctor/shared/legacy-tools-by-sender.ts b/src/commands/doctor/shared/legacy-tools-by-sender.ts index 7837b06fa0b..470e6fc80ad 100644 --- a/src/commands/doctor/shared/legacy-tools-by-sender.ts +++ b/src/commands/doctor/shared/legacy-tools-by-sender.ts @@ -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(); let changed = false; diff --git a/src/commands/doctor/shared/stale-plugin-config.ts b/src/commands/doctor/shared/stale-plugin-config.ts index 6bf2e968d13..360dbf0300b 100644 --- a/src/commands/doctor/shared/stale-plugin-config.ts +++ b/src/commands/doctor/shared/stale-plugin-config.ts @@ -50,7 +50,14 @@ export function scanStalePluginConfig( return []; } - const { knownIds } = collectPluginRegistryState(cfg, env); + return scanStalePluginConfigWithState(plugins, collectPluginRegistryState(cfg, env)); +} + +function scanStalePluginConfigWithState( + plugins: Record, + 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: [] }; }