diff --git a/scripts/deadcode-exports.baseline.mjs b/scripts/deadcode-exports.baseline.mjs index 985153775022..fc12c9a5b9d6 100644 --- a/scripts/deadcode-exports.baseline.mjs +++ b/scripts/deadcode-exports.baseline.mjs @@ -474,46 +474,21 @@ export const KNIP_UNUSED_EXPORT_BASELINE = [ "src/cli/update-cli/update-command-service.ts: recoverLaunchAgentAndRecheckGatewayHealth", "src/cli/update-cli/update-command-service.ts: shouldUseLegacyProcessRestartAfterUpdate", "src/commands/audit.ts: testApi", - "src/commands/auth-choice-options.ts: buildAuthChoiceOptions", - "src/commands/backup-shared.ts: encodeAbsolutePathForBackupArchive", - "src/commands/backup-shared.ts: formatBackupArchiveTimestamp", "src/commands/backup-shared.ts: resolveBackupPlanFromPaths", - "src/commands/daemon-install-helpers.ts: collectPreservedExistingServiceEnvVars", - "src/commands/daemon-install-plan.shared.ts: resolveDaemonOpenClawBinDir", - "src/commands/daemon-install-plan.shared.ts: resolveGatewayDevMode", "src/commands/doctor-auth-oauth-sidecar.ts: testing", - "src/commands/doctor-auth.ts: formatOAuthRefreshFailureDoctorLine", "src/commands/doctor-auth.ts: legacyCodexProviderOverrideToHealthFinding", - "src/commands/doctor-auth.ts: resolveUnusableProfileHint", - "src/commands/doctor-config-analysis.ts: collectImplicitFallbackClobberWarnings", "src/commands/doctor-heartbeat-main-session-repair.ts: clearTuiLastSessionPointers", "src/commands/doctor-heartbeat-main-session-repair.ts: moveHeartbeatMainSessionEntry", "src/commands/doctor-heartbeat-main-session-repair.ts: resolveHeartbeatMainSessionRepairCandidate", - "src/commands/doctor-install-policy.ts: collectInstallPolicyHealthLines", "src/commands/doctor-sandbox.ts: resolveSandboxScript", "src/commands/doctor-session-snapshots.ts: resolveSessionSnapshotBundledSkillsDir", "src/commands/doctor-session-snapshots.ts: scanSessionStoreForStaleRuntimeSnapshotPaths", - "src/commands/doctor-session-state-providers.ts: applySessionRouteStateRepair", - "src/commands/doctor-session-state-providers.ts: resolveConfiguredDoctorSessionStateRoute", - "src/commands/doctor-session-state-providers.ts: scanSessionRouteStateOwners", - "src/commands/doctor-session-state-providers.ts: storeMayContainPluginSessionRouteState", - "src/commands/doctor-session-transcripts.ts: repairBrokenSessionTranscriptFile", - "src/commands/doctor-skills.ts: describeGhConfigDirHintFromDiscovery", - "src/commands/doctor-skills.ts: formatUnavailableSkillDoctorLines", "src/commands/doctor-whatsapp-responsiveness.ts: listLocalTuiProcesses", "src/commands/doctor-whatsapp-responsiveness.ts: terminateLocalTuiProcesses", - "src/commands/doctor/cron/warnings.ts: collectCronDeliveryTargetAdvisory", "src/commands/doctor/shared/codex-native-assets.ts: scanCodexNativeAssets", "src/commands/doctor/shared/codex-route-warnings.ts: repairCodexSessionStoreRoutes", - "src/commands/doctor/shared/context-engine-host-compat.ts: collectConfiguredContextEngineAgentRunHosts", "src/commands/doctor/shared/legacy-oauth-sidecar.ts: legacyOAuthSidecarInternalTestUtils", "src/commands/doctor/shared/plugin-dependency-cleanup.ts: testing", - "src/commands/doctor/shared/plugin-registry-migration.ts: FORCE_PLUGIN_REGISTRY_MIGRATION_ENV", - "src/commands/doctor/shared/preview-warnings.ts: collectChannelBoundMessageToolPolicyWarnings", - "src/commands/doctor/shared/preview-warnings.ts: collectProfileConfiguredToolSectionWarnings", - "src/commands/doctor/shared/preview-warnings.ts: collectVisibleReplyToolPolicyWarnings", - "src/commands/doctor/shared/release-configured-plugin-installs.ts: collectReleaseConfiguredPluginIds", - "src/commands/doctor/shared/release-configured-plugin-installs.ts: shouldRunConfiguredPluginInstallReleaseStep", "src/commands/doctor/shared/stale-auth-order.ts: repairStaleConfiguredAuthOrders", "src/commands/doctor/shared/stale-oauth-profile-shadows.ts: testing", "src/commands/onboard-inference.ts: detectNativeCodexAppServer", diff --git a/src/commands/auth-choice-options.test.ts b/src/commands/auth-choice-options.test.ts index b1e83c0a1dfa..3a8b593b0833 100644 --- a/src/commands/auth-choice-options.test.ts +++ b/src/commands/auth-choice-options.test.ts @@ -4,7 +4,6 @@ import type { AuthProfileStore } from "../agents/auth-profiles.js"; import type { ProviderAuthChoiceMetadata } from "../plugins/provider-auth-choices.js"; import { buildAuthChoiceGroups, - buildAuthChoiceOptions, formatAuthChoiceChoicesForCli, isFeaturedAuthChoiceGroup, } from "./auth-choice-options.js"; @@ -94,10 +93,12 @@ vi.mock("../flows/provider-flow.js", () => ({ const EMPTY_STORE: AuthProfileStore = { version: 1, profiles: {} }; function getOptions(includeSkip = false) { - return buildAuthChoiceOptions({ + const { groups, skipOption } = buildAuthChoiceGroups({ store: EMPTY_STORE, includeSkip, + assistantVisibleOnly: false, }); + return [...groups.flatMap((group) => group.options), ...(skipOption ? [skipOption] : [])]; } function requireChoiceGroup( diff --git a/src/commands/auth-choice-options.ts b/src/commands/auth-choice-options.ts index 785fdb5df738..a95fd4951665 100644 --- a/src/commands/auth-choice-options.ts +++ b/src/commands/auth-choice-options.ts @@ -103,7 +103,7 @@ export function formatAuthChoiceChoicesForCli(params?: { } /** Build flat auth-choice options from core choices plus provider setup flows. */ -export function buildAuthChoiceOptions(params: { +function buildAuthChoiceOptions(params: { store: AuthProfileStore; includeSkip: boolean; assistantVisibleOnly?: boolean; diff --git a/src/commands/backup-shared.ts b/src/commands/backup-shared.ts index 7bb4a5f6cc77..aafc8dc9f866 100644 --- a/src/commands/backup-shared.ts +++ b/src/commands/backup-shared.ts @@ -59,7 +59,7 @@ function backupAssetPriority(kind: BackupAssetKind): number { } /** Format a filesystem-safe local timestamp with explicit UTC offset for backup names. */ -export function formatBackupArchiveTimestamp( +function formatBackupArchiveTimestamp( nowMs = Date.now(), offsetMinutes = -new Date(nowMs).getTimezoneOffset(), ): string { @@ -90,7 +90,7 @@ export function buildBackupArchiveBasename(nowMs = Date.now()): string { } /** Encode an absolute or relative source path into a traversal-safe archive payload path. */ -export function encodeAbsolutePathForBackupArchive(sourcePath: string): string { +function encodeAbsolutePathForBackupArchive(sourcePath: string): string { const normalized = sourcePath.replaceAll("\\", "/"); const windowsMatch = normalized.match(/^([A-Za-z]):\/(.*)$/); if (windowsMatch) { diff --git a/src/commands/backup.test.ts b/src/commands/backup.test.ts index 97a0cec1af6e..eccde8d6cd55 100644 --- a/src/commands/backup.test.ts +++ b/src/commands/backup.test.ts @@ -9,9 +9,8 @@ import { captureEnv, setTestEnvValue } from "../test-utils/env.js"; import { createTempHomeEnv, type TempHomeEnv } from "../test-utils/temp-home.js"; import * as backupShared from "./backup-shared.js"; import { + buildBackupArchivePath, buildBackupArchiveRoot, - encodeAbsolutePathForBackupArchive, - formatBackupArchiveTimestamp, type BackupAsset, resolveBackupPlanFromPaths, resolveBackupPlanFromDisk, @@ -130,11 +129,7 @@ describe("backup commands", () => { kind: "state", sourcePath: stateSourcePath, displayPath: included.displayPath, - archivePath: path.posix.join( - buildBackupArchiveRoot(123), - "payload", - encodeAbsolutePathForBackupArchive(stateSourcePath), - ), + archivePath: buildBackupArchivePath(buildBackupArchiveRoot(123), stateSourcePath), }, ]); const workspaceSourcePath = path.join(included.sourcePath, "workspace"); @@ -165,13 +160,20 @@ describe("backup commands", () => { ]); } - it("formats backup archive timestamps in local time with an explicit offset", () => { - expect(formatBackupArchiveTimestamp(Date.UTC(2026, 2, 14, 1, 2, 3, 456), 8 * 60)).toBe( - "2026-03-14T09-02-03.456+08-00", - ); - expect(formatBackupArchiveTimestamp(Date.UTC(2026, 2, 14, 1, 2, 3, 456), -5 * 60)).toBe( - "2026-03-13T20-02-03.456-05-00", - ); + it("formats backup archive timestamps in local time", () => { + const envSnapshot = captureEnv(["TZ"]); + try { + setTestEnvValue("TZ", "Asia/Shanghai"); + expect(buildBackupArchiveRoot(Date.UTC(2026, 2, 14, 1, 2, 3, 456))).toBe( + "2026-03-14T09-02-03.456+08-00-openclaw-backup", + ); + setTestEnvValue("TZ", "America/New_York"); + expect(buildBackupArchiveRoot(Date.UTC(2026, 2, 14, 1, 2, 3, 456))).toBe( + "2026-03-13T21-02-03.456-04-00-openclaw-backup", + ); + } finally { + envSnapshot.restore(); + } }); it("collapses default config, credentials, and workspace into the state backup root", async () => { @@ -339,21 +341,13 @@ describe("backup commands", () => { const remappedStateEntry = { path: stateAsset.sourcePath }; onWriteEntry(remappedStateEntry); expect(remappedStateEntry.path).toBe( - path.posix.join( - buildBackupArchiveRoot(nowMs), - "payload", - encodeAbsolutePathForBackupArchive(stateAsset.sourcePath), - ), + buildBackupArchivePath(buildBackupArchiveRoot(nowMs), stateAsset.sourcePath), ); const remappedWorkspaceEntry = { path: workspaceAsset.sourcePath }; onWriteEntry(remappedWorkspaceEntry); expect(remappedWorkspaceEntry.path).toBe( - path.posix.join( - buildBackupArchiveRoot(nowMs), - "payload", - encodeAbsolutePathForBackupArchive(workspaceAsset.sourcePath), - ), + buildBackupArchivePath(buildBackupArchiveRoot(nowMs), workspaceAsset.sourcePath), ); } finally { envSnapshot.restore(); diff --git a/src/commands/daemon-install-helpers.test.ts b/src/commands/daemon-install-helpers.test.ts index 10a8becf9985..554f114cb427 100644 --- a/src/commands/daemon-install-helpers.test.ts +++ b/src/commands/daemon-install-helpers.test.ts @@ -5,7 +5,6 @@ import path from "node:path"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { writeStateDirDotEnv } from "../config/test-helpers.js"; import type { OpenClawConfig } from "../config/types.js"; -import { collectPreservedExistingServiceEnvVars } from "./daemon-install-helpers.js"; const mocks = vi.hoisted(() => ({ hasAnyAuthProfileStoreSource: vi.fn(() => true), @@ -85,7 +84,6 @@ vi.mock("../plugins/plugin-registry.js", async (importActual) => { }); import { buildGatewayInstallPlan, gatewayInstallErrorHint } from "./daemon-install-helpers.js"; -import { resolveGatewayDevMode } from "./daemon-install-plan.shared.js"; afterEach(() => { vi.resetAllMocks(); @@ -113,16 +111,6 @@ function createSecurePluginRoot(pathname: string): void { fs.chmodSync(pathname, 0o755); } -describe("resolveGatewayDevMode", () => { - it("detects dev mode for src ts entrypoints", () => { - expect(resolveGatewayDevMode(["node", "/Users/me/openclaw/src/cli/index.ts"])).toBe(true); - expect(resolveGatewayDevMode(["node", "C:\\Users\\me\\openclaw\\src\\cli\\index.ts"])).toBe( - true, - ); - expect(resolveGatewayDevMode(["node", "/Users/me/openclaw/dist/cli/index.js"])).toBe(false); - }); -}); - function mockNodeGatewayPlanFixture( params: { workingDirectory?: string; @@ -1780,42 +1768,43 @@ describe("gatewayInstallErrorHint", () => { }); describe("collectPreservedExistingServiceEnvVars — operator opt-in allowlist", () => { - const managedKeys = new Set(); + async function buildEnvironment(existingEnvironment: Record) { + mockNodeGatewayPlanFixture(); + return ( + await buildGatewayInstallPlan({ + env: { HOME: "/tmp" }, + port: 3000, + runtime: "node", + existingEnvironment, + }) + ).environment; + } - it("continues to drop stale OPENCLAW_ALLOW_ROOT", () => { - const result = collectPreservedExistingServiceEnvVars( - { OPENCLAW_ALLOW_ROOT: "1" }, - managedKeys, - ); + it("continues to drop stale OPENCLAW_ALLOW_ROOT", async () => { + const result = await buildEnvironment({ OPENCLAW_ALLOW_ROOT: "1" }); expect(result.OPENCLAW_ALLOW_ROOT).toBeUndefined(); }); - it("preserves OPENCLAW_CLI_CONTAINER_BYPASS and OPENCLAW_CONTAINER_HINT", () => { - const result = collectPreservedExistingServiceEnvVars( - { - OPENCLAW_CLI_CONTAINER_BYPASS: "1", - OPENCLAW_CONTAINER_HINT: "ci", - }, - managedKeys, - ); + it("preserves OPENCLAW_CLI_CONTAINER_BYPASS and OPENCLAW_CONTAINER_HINT", async () => { + const result = await buildEnvironment({ + OPENCLAW_CLI_CONTAINER_BYPASS: "1", + OPENCLAW_CONTAINER_HINT: "ci", + }); expect(result.OPENCLAW_CLI_CONTAINER_BYPASS).toBe("1"); expect(result.OPENCLAW_CONTAINER_HINT).toBe("ci"); }); - it("still drops arbitrary OPENCLAW_FOO", () => { - const result = collectPreservedExistingServiceEnvVars({ OPENCLAW_FOO: "bar" }, managedKeys); + it("still drops arbitrary OPENCLAW_FOO", async () => { + const result = await buildEnvironment({ OPENCLAW_FOO: "bar" }); expect(result.OPENCLAW_FOO).toBeUndefined(); }); - it("preserves container opt-ins while dropping unrelated OPENCLAW_* keys", () => { - const result = collectPreservedExistingServiceEnvVars( - { - OPENCLAW_CLI_CONTAINER_BYPASS: "1", - OPENCLAW_CONTAINER_HINT: "ci", - OPENCLAW_BAZ: "qux", - }, - managedKeys, - ); + it("preserves container opt-ins while dropping unrelated OPENCLAW_* keys", async () => { + const result = await buildEnvironment({ + OPENCLAW_CLI_CONTAINER_BYPASS: "1", + OPENCLAW_CONTAINER_HINT: "ci", + OPENCLAW_BAZ: "qux", + }); expect(result.OPENCLAW_CLI_CONTAINER_BYPASS).toBe("1"); expect(result.OPENCLAW_CONTAINER_HINT).toBe("ci"); expect(result.OPENCLAW_BAZ).toBeUndefined(); diff --git a/src/commands/daemon-install-helpers.ts b/src/commands/daemon-install-helpers.ts index 33091ae8ac47..e16207465d30 100644 --- a/src/commands/daemon-install-helpers.ts +++ b/src/commands/daemon-install-helpers.ts @@ -463,7 +463,7 @@ const PRESERVED_OPENCLAW_OPERATOR_OPT_IN_ENV_KEYS = new Set([ ]); /** Preserve safe operator-owned env vars from an existing service definition. */ -export function collectPreservedExistingServiceEnvVars( +function collectPreservedExistingServiceEnvVars( existingEnvironment: Record | undefined, managedServiceEnvKeys: Set, ): Record { diff --git a/src/commands/daemon-install-plan.shared.test.ts b/src/commands/daemon-install-plan.shared.test.ts index 74656c66bd67..8b79393f06f5 100644 --- a/src/commands/daemon-install-plan.shared.test.ts +++ b/src/commands/daemon-install-plan.shared.test.ts @@ -1,24 +1,37 @@ // Daemon install plan tests cover shared install plan validation and platform warning helpers. +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { describe, expect, it } from "vitest"; import { resolveDaemonInstallRuntimeInputs, resolveDaemonNodeBinDir, - resolveDaemonOpenClawBinDir, resolveDaemonServicePathDirs, - resolveGatewayDevMode, } from "./daemon-install-plan.shared.js"; -describe("resolveGatewayDevMode", () => { - it("detects src ts entrypoints", () => { - expect(resolveGatewayDevMode(["node", "/Users/me/openclaw/src/cli/index.ts"])).toBe(true); - expect(resolveGatewayDevMode(["node", "C:\\Users\\me\\openclaw\\src\\cli\\index.ts"])).toBe( - true, - ); - expect(resolveGatewayDevMode(["node", "/Users/me/openclaw/dist/cli/index.js"])).toBe(false); - }); -}); - describe("resolveDaemonInstallRuntimeInputs", () => { + it("detects src ts entrypoints when devMode is not overridden", async () => { + const originalArgv = process.argv; + try { + for (const [entrypoint, expected] of [ + ["/Users/me/openclaw/src/cli/index.ts", true], + ["C:\\Users\\me\\openclaw\\src\\cli\\index.ts", true], + ["/Users/me/openclaw/dist/cli/index.js", false], + ] as const) { + process.argv = ["node", entrypoint]; + await expect( + resolveDaemonInstallRuntimeInputs({ + env: {}, + runtime: "node", + nodePath: "/custom/node", + }), + ).resolves.toMatchObject({ devMode: expected }); + } + } finally { + process.argv = originalArgv; + } + }); + it("keeps explicit devMode and nodePath overrides", async () => { await expect( resolveDaemonInstallRuntimeInputs({ @@ -44,10 +57,10 @@ describe("resolveDaemonNodeBinDir", () => { }); }); -describe("resolveDaemonOpenClawBinDir", () => { +describe("resolveDaemonServicePathDirs openclaw discovery", () => { it("uses the active openclaw command directory", () => { expect( - resolveDaemonOpenClawBinDir({ + resolveDaemonServicePathDirs({ argv: ["node", "/Users/testuser/.npm-global/bin/openclaw", "gateway", "install"], env: { PATH: "" }, platform: "darwin", @@ -55,45 +68,59 @@ describe("resolveDaemonOpenClawBinDir", () => { ).toEqual(["/Users/testuser/.npm-global/bin"]); }); - it("finds the PATH shim that resolves to the active package entrypoint", () => { - const realpaths = new Map([ - ["/Users/testuser/.npm-global/bin/openclaw", "/pkg/openclaw/openclaw.mjs"], - [ - "/Users/testuser/.npm-global/lib/node_modules/openclaw/openclaw.mjs", - "/pkg/openclaw/openclaw.mjs", - ], - ]); + it.skipIf(process.platform === "win32")( + "finds the PATH shim that resolves to the active package entrypoint", + () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-daemon-path-")); + try { + const binDir = path.join(root, "bin"); + const packageDir = path.join(root, "lib", "node_modules", "openclaw"); + const entrypoint = path.join(packageDir, "openclaw.mjs"); + fs.mkdirSync(binDir, { recursive: true }); + fs.mkdirSync(packageDir, { recursive: true }); + fs.writeFileSync(entrypoint, ""); + fs.symlinkSync(entrypoint, path.join(binDir, "openclaw")); - expect( - resolveDaemonOpenClawBinDir({ - argv: [ - "node", - "/Users/testuser/.npm-global/lib/node_modules/openclaw/openclaw.mjs", - "gateway", - "install", - ], - env: { PATH: "/Users/testuser/.npm-global/bin:/usr/bin" }, - platform: "darwin", - existsSync: (candidate) => candidate === "/Users/testuser/.npm-global/bin/openclaw", - realpathSync: (candidate) => realpaths.get(candidate) ?? candidate, - }), - ).toEqual(["/Users/testuser/.npm-global/bin"]); - }); + expect( + resolveDaemonServicePathDirs({ + argv: ["node", entrypoint, "gateway", "install"], + env: { PATH: binDir }, + platform: "darwin", + }), + ).toEqual([binDir]); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }, + ); - it("ignores unrelated openclaw commands elsewhere on PATH", () => { - expect( - resolveDaemonOpenClawBinDir({ - argv: ["node", "/opt/openclaw/openclaw.mjs", "gateway", "install"], - env: { PATH: "/Users/testuser/.npm-global/bin" }, - platform: "darwin", - existsSync: () => true, - realpathSync: (candidate) => - candidate === "/Users/testuser/.npm-global/bin/openclaw" - ? "/other/openclaw.mjs" - : candidate, - }), - ).toBeUndefined(); - }); + it.skipIf(process.platform === "win32")( + "ignores unrelated openclaw commands elsewhere on PATH", + () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-daemon-path-")); + try { + const binDir = path.join(root, "bin"); + const activeEntrypoint = path.join(root, "active", "openclaw.mjs"); + const otherEntrypoint = path.join(root, "other", "openclaw.mjs"); + fs.mkdirSync(binDir, { recursive: true }); + fs.mkdirSync(path.dirname(activeEntrypoint), { recursive: true }); + fs.mkdirSync(path.dirname(otherEntrypoint), { recursive: true }); + fs.writeFileSync(activeEntrypoint, ""); + fs.writeFileSync(otherEntrypoint, ""); + fs.symlinkSync(otherEntrypoint, path.join(binDir, "openclaw")); + + expect( + resolveDaemonServicePathDirs({ + argv: ["node", activeEntrypoint, "gateway", "install"], + env: { PATH: binDir }, + platform: "darwin", + }), + ).toBeUndefined(); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }, + ); }); describe("resolveDaemonServicePathDirs", () => { diff --git a/src/commands/daemon-install-plan.shared.ts b/src/commands/daemon-install-plan.shared.ts index e9231f408810..11d78696a59b 100644 --- a/src/commands/daemon-install-plan.shared.ts +++ b/src/commands/daemon-install-plan.shared.ts @@ -9,7 +9,7 @@ import { import type { GatewayDaemonRuntime } from "./daemon-runtime.js"; /** Detect source-checkout dev mode from the current CLI entrypoint. */ -export function resolveGatewayDevMode(argv: string[] = process.argv): boolean { +function resolveGatewayDevMode(argv: string[] = process.argv): boolean { const entry = argv[1]; const normalizedEntry = entry?.replaceAll("\\", "/"); return ( @@ -96,7 +96,7 @@ function addUniquePathDir(dirs: string[], dir: string | undefined): void { } /** Resolve the OpenClaw CLI binary directory from argv/PATH for daemon PATH. */ -export function resolveDaemonOpenClawBinDir( +function resolveDaemonOpenClawBinDir( params: { argv?: string[]; env?: Record; diff --git a/src/commands/doctor-auth.hints.test.ts b/src/commands/doctor-auth.hints.test.ts index b28599255d83..c487b9d757a7 100644 --- a/src/commands/doctor-auth.hints.test.ts +++ b/src/commands/doctor-auth.hints.test.ts @@ -3,10 +3,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { collectAuthProfileHealthFindings, - formatOAuthRefreshFailureDoctorLine, legacyCodexProviderOverrideToHealthFinding, noteLegacyCodexProviderOverride, - resolveUnusableProfileHint, } from "./doctor-auth.js"; const mocks = vi.hoisted(() => ({ @@ -32,88 +30,12 @@ function doctorFixtureConfig(config: unknown): OpenClawConfig { return config as OpenClawConfig; } -describe("resolveUnusableProfileHint", () => { +describe("doctor auth hints", () => { beforeEach(() => { mocks.ensureAuthProfileStore.mockReset().mockReturnValue({ version: 1, profiles: {} }); mocks.note.mockClear(); }); - it("returns billing guidance for disabled billing profiles", () => { - expect(resolveUnusableProfileHint({ kind: "disabled", reason: "billing" })).toBe( - "Top up credits (provider billing) or switch provider.", - ); - }); - - it("returns credential guidance for permanent auth disables", () => { - expect(resolveUnusableProfileHint({ kind: "disabled", reason: "auth_permanent" })).toBe( - "Refresh or replace credentials, then retry.", - ); - }); - - it("falls back to cooldown guidance for non-billing disable reasons", () => { - expect(resolveUnusableProfileHint({ kind: "disabled", reason: "unknown" })).toBe( - "Wait for cooldown or switch provider.", - ); - }); - - it("returns cooldown guidance for cooldown windows", () => { - expect(resolveUnusableProfileHint({ kind: "cooldown" })).toBe( - "Wait for cooldown or switch provider.", - ); - }); - - it("formats permanent OAuth refresh failures as reauth-required", () => { - expect( - formatOAuthRefreshFailureDoctorLine({ - profileId: "openai-codex:default", - provider: "openai-codex", - message: - "OAuth token refresh failed for openai-codex: refresh_token_reused. Please try again or re-authenticate.", - }), - ).toBe( - "- openai-codex:default: re-auth required [refresh_token_reused] — Run `openclaw models auth login --provider openai`.", - ); - }); - - it("formats non-permanent OAuth refresh failures as retry-then-reauth guidance", () => { - expect( - formatOAuthRefreshFailureDoctorLine({ - profileId: "openai-codex:default", - provider: "openai-codex", - message: - "OAuth token refresh failed for openai-codex: temporary upstream issue. Please try again or re-authenticate.", - }), - ).toBe( - "- openai-codex:default: OAuth refresh failed — Try again; if this persists, run `openclaw models auth login --provider openai`.", - ); - }); - - it("quotes exact current profile ids in OAuth reauth guidance", () => { - expect( - formatOAuthRefreshFailureDoctorLine({ - profileId: "OpenAI Work Profile", - provider: "openai", - message: - "OAuth token refresh failed for openai: invalid_grant. Please try again or re-authenticate.", - }), - ).toBe( - "- OpenAI Work Profile: re-auth required [invalid_grant] — Run `openclaw models auth login --provider openai --profile-id 'OpenAI Work Profile'`.", - ); - }); - - it("drops the provider-specific command when the parsed provider is unsafe", () => { - expect( - formatOAuthRefreshFailureDoctorLine({ - profileId: "openai-codex:default", - provider: "openai-codex", - message: - "OAuth token refresh failed for openai-codex`\nrm -rf /: invalid_grant. Please try again or re-authenticate.", - }), - ).toBe( - "- openai-codex:default: re-auth required [invalid_grant] — Run `openclaw models auth login --provider openai`.", - ); - }); - it("warns when a legacy Codex override shadows canonical OpenAI OAuth config", () => { noteLegacyCodexProviderOverride( doctorFixtureConfig({ diff --git a/src/commands/doctor-auth.profile-health.test.ts b/src/commands/doctor-auth.profile-health.test.ts index 271138a22194..79a6a14bd405 100644 --- a/src/commands/doctor-auth.profile-health.test.ts +++ b/src/commands/doctor-auth.profile-health.test.ts @@ -3,7 +3,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { AuthProfileStore } from "../agents/auth-profiles/types.js"; +import type { AuthProfileFailureReason, AuthProfileStore } from "../agents/auth-profiles/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { DoctorPrompter } from "./doctor-prompter.js"; @@ -146,6 +146,61 @@ describe("noteAuthProfileHealth", () => { ]); }); + it.each([ + ["auth_permanent", "Refresh or replace credentials, then retry."], + ["unknown", "Wait for cooldown or switch provider."], + ] satisfies Array<[AuthProfileFailureReason, string]>)( + "maps disabled %s profiles to their production health hint", + async (reason, expectedHint) => { + const now = 1_700_000_000_000; + vi.spyOn(Date, "now").mockReturnValue(now); + const mainDir = path.join(tempDir, "main-agent"); + authProfileMocks.hasAnyAuthProfileStoreSource.mockReturnValue(true); + authProfileMocks.resolveProfileUnusableUntilForDisplay.mockReturnValue(now + 5 * 60_000); + authProfileMocks.ensureAuthProfileStore.mockReturnValue({ + version: 1, + profiles: {}, + usageStats: { + "openai:disabled": { + disabledUntil: now + 5 * 60_000, + disabledReason: reason, + }, + }, + } satisfies AuthProfileStore); + + const findings = await collectAuthProfileHealthFindings({ + cfg: { + agents: { list: [{ id: "main", default: true, agentDir: mainDir }] }, + } as OpenClawConfig, + }); + + expect(findings).toEqual([expect.objectContaining({ fixHint: expectedHint })]); + }, + ); + + it("maps cooldown profiles to cooldown guidance", async () => { + const now = 1_700_000_000_000; + vi.spyOn(Date, "now").mockReturnValue(now); + const mainDir = path.join(tempDir, "main-agent"); + authProfileMocks.hasAnyAuthProfileStoreSource.mockReturnValue(true); + authProfileMocks.resolveProfileUnusableUntilForDisplay.mockReturnValue(now + 5 * 60_000); + authProfileMocks.ensureAuthProfileStore.mockReturnValue({ + version: 1, + profiles: {}, + usageStats: { "openai:cooldown": { cooldownUntil: now + 5 * 60_000 } }, + } satisfies AuthProfileStore); + + const findings = await collectAuthProfileHealthFindings({ + cfg: { + agents: { list: [{ id: "main", default: true, agentDir: mainDir }] }, + } as OpenClawConfig, + }); + + expect(findings).toEqual([ + expect.objectContaining({ fixHint: "Wait for cooldown or switch provider." }), + ]); + }); + it("maps malformed API-key auth profiles to structured findings", async () => { const mainDir = path.join(tempDir, "main-agent"); authProfileMocks.hasAnyAuthProfileStoreSource.mockReturnValue(true); @@ -450,4 +505,49 @@ describe("noteAuthProfileHealth", () => { }), ); }); + + it.each([ + [ + "openai-codex:default", + "OAuth token refresh failed for openai-codex: refresh_token_reused. Please try again or re-authenticate.", + "- openai-codex:default: re-auth required [refresh_token_reused] — Run `openclaw models auth login --provider openai`.", + ], + [ + "openai-codex:default", + "OAuth token refresh failed for openai-codex: temporary upstream issue. Please try again or re-authenticate.", + "- openai-codex:default: OAuth refresh failed — Try again; if this persists, run `openclaw models auth login --provider openai`.", + ], + [ + "OpenAI Work Profile", + "OAuth token refresh failed for openai: invalid_grant. Please try again or re-authenticate.", + "- OpenAI Work Profile: re-auth required [invalid_grant] — Run `openclaw models auth login --provider openai --profile-id 'OpenAI Work Profile'`.", + ], + [ + "openai-codex:default", + "OAuth token refresh failed for openai-codex`\nrm -rf /: invalid_grant. Please try again or re-authenticate.", + "- openai-codex:default: re-auth required [invalid_grant] — Run `openclaw models auth login --provider openai`.", + ], + ])( + "formats OAuth refresh failures through the doctor command path", + async (profileId, message, expected) => { + const now = 1_700_000_000_000; + vi.spyOn(Date, "now").mockReturnValue(now); + const agentDir = path.join(tempDir, "main-agent"); + authProfileMocks.hasAnyAuthProfileStoreSource.mockReturnValue(true); + authProfileMocks.ensureAuthProfileStore.mockReturnValue( + expiredStore(profileId, now - 60_000), + ); + authProfileMocks.resolveApiKeyForProfile.mockRejectedValue(new Error(message)); + + await noteAuthProfileHealth({ + cfg: { + agents: { list: [{ id: "main", default: true, agentDir }] }, + } as OpenClawConfig, + prompter: { confirmAutoFix: vi.fn(async () => true) } as unknown as DoctorPrompter, + allowKeychainPrompt: false, + }); + + expect(noteMock).toHaveBeenCalledWith(expected, "OAuth refresh errors"); + }, + ); }); diff --git a/src/commands/doctor-auth.ts b/src/commands/doctor-auth.ts index 9b77c3d5d45f..9dbb0ffe923f 100644 --- a/src/commands/doctor-auth.ts +++ b/src/commands/doctor-auth.ts @@ -194,7 +194,7 @@ function listAuthProfileHealthTargets(cfg: OpenClawConfig): AuthProfileHealthTar } /** Returns the short doctor hint for disabled or cooldown auth profiles. */ -export function resolveUnusableProfileHint(params: { +function resolveUnusableProfileHint(params: { kind: "cooldown" | "disabled"; reason?: string; }): string { @@ -227,7 +227,7 @@ function formatOAuthRefreshFailureReason(reason: OAuthRefreshFailureReason | nul } /** Formats provider OAuth refresh failures as actionable doctor note lines. */ -export function formatOAuthRefreshFailureDoctorLine(params: { +function formatOAuthRefreshFailureDoctorLine(params: { profileId: string; provider: string; message: string; diff --git a/src/commands/doctor-config-analysis.test.ts b/src/commands/doctor-config-analysis.test.ts index 498a434644e7..d4287f3b3cbf 100644 --- a/src/commands/doctor-config-analysis.test.ts +++ b/src/commands/doctor-config-analysis.test.ts @@ -1,15 +1,26 @@ // Doctor config analysis tests cover schema analysis, model fallback values, and issue generation. -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { resolveAgentModelFallbackValues } from "../config/model-input.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { OpenClawSchema } from "../config/zod-schema.js"; import { - collectImplicitFallbackClobberWarnings, formatConfigPath, + noteImplicitFallbackClobberWarnings, resolveConfigPathTarget, stripUnknownConfigKeys, } from "./doctor-config-analysis.js"; +const noteMock = vi.hoisted(() => vi.fn()); + +vi.mock("../../packages/terminal-core/src/note.js", () => ({ note: noteMock })); + +function collectImplicitFallbackClobberWarnings(cfg: OpenClawConfig): string[] { + noteMock.mockClear(); + noteImplicitFallbackClobberWarnings(cfg); + const body = noteMock.mock.calls.at(-1)?.[0]; + return typeof body === "string" ? body.split(/\n(?=- )/) : []; +} + describe("doctor config analysis helpers", () => { it("formats config paths predictably", () => { expect(formatConfigPath([])).toBe(""); diff --git a/src/commands/doctor-config-analysis.ts b/src/commands/doctor-config-analysis.ts index 3bdcd4cdbd3a..0f2cfb30349f 100644 --- a/src/commands/doctor-config-analysis.ts +++ b/src/commands/doctor-config-analysis.ts @@ -185,7 +185,7 @@ function isImplicitFallbackClobber(model: unknown): boolean { } /** Collects warnings for agent model shapes that unintentionally drop default fallbacks. */ -export function collectImplicitFallbackClobberWarnings(cfg: OpenClawConfig): string[] { +function collectImplicitFallbackClobberWarnings(cfg: OpenClawConfig): string[] { const defaultFallbacks = resolveAgentModelFallbackValues(cfg.agents?.defaults?.model); if (defaultFallbacks.length === 0) { return []; diff --git a/src/commands/doctor-install-policy.test.ts b/src/commands/doctor-install-policy.test.ts index 1bada098095b..ed0b72b6c8b8 100644 --- a/src/commands/doctor-install-policy.test.ts +++ b/src/commands/doctor-install-policy.test.ts @@ -2,9 +2,23 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { collectInstallPolicyHealthLines } from "./doctor-install-policy.js"; +import { noteInstallPolicyHealth } from "./doctor-install-policy.js"; + +const noteMock = vi.hoisted(() => vi.fn()); + +vi.mock("../../packages/terminal-core/src/note.js", () => ({ note: noteMock })); + +async function collectInstallPolicyHealthLines( + cfg: OpenClawConfig, + options: { deep?: boolean; env?: NodeJS.ProcessEnv } = {}, +): Promise { + noteMock.mockClear(); + await noteInstallPolicyHealth(cfg, options); + const body = noteMock.mock.calls.at(-1)?.[0]; + return typeof body === "string" ? body.split("\n") : []; +} const tempDirs: string[] = []; diff --git a/src/commands/doctor-install-policy.ts b/src/commands/doctor-install-policy.ts index c92776cd242d..e6bfb0bd55d3 100644 --- a/src/commands/doctor-install-policy.ts +++ b/src/commands/doctor-install-policy.ts @@ -22,7 +22,7 @@ function formatTargets(validation: InstallPolicyStaticValidation): string { } /** Builds doctor note lines for static install policy validation and optional deep probing. */ -export async function collectInstallPolicyHealthLines( +async function collectInstallPolicyHealthLines( cfg: OpenClawConfig, options: InstallPolicyHealthOptions = {}, ): Promise { diff --git a/src/commands/doctor-session-state-providers.test.ts b/src/commands/doctor-session-state-providers.test.ts index ef5e13bdcd75..11a46784d170 100644 --- a/src/commands/doctor-session-state-providers.test.ts +++ b/src/commands/doctor-session-state-providers.test.ts @@ -1,32 +1,11 @@ -// Doctor session state provider tests cover route-state repair and configured provider resolution. -import { expectDefined } from "@openclaw/normalization-core"; -import { describe, expect, it, vi } from "vitest"; -import { - applySessionRouteStateRepair, - resolveConfiguredDoctorSessionStateRoute, - runPluginSessionStateDoctorRepairs, - scanSessionRouteStateOwners, - storeMayContainPluginSessionRouteState, -} from "./doctor-session-state-providers.js"; - -vi.mock("../plugins/doctor-contract-registry.js", async () => { - const actual = await vi.importActual( - "../plugins/doctor-contract-registry.js", - ); - return { - ...actual, - listPluginDoctorSessionRouteStateOwners: vi.fn(() => [ - { - id: "codex", - label: "Codex", - providerIds: ["codex", "codex-cli", "openai-codex"], - runtimeIds: ["codex", "codex-cli"], - cliSessionKeys: ["codex-cli"], - authProfilePrefixes: ["codex:", "codex-cli:", "openai-codex:"], - }, - ]), - }; -}); +// Doctor session state provider tests cover route-state repair through the public doctor path. +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { SessionEntry } from "../config/sessions.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { runPluginSessionStateDoctorRepairs } from "./doctor-session-state-providers.js"; const codexOwner = { id: "codex", @@ -36,634 +15,289 @@ const codexOwner = { cliSessionKeys: ["codex-cli"], authProfilePrefixes: ["codex:", "codex-cli:", "openai-codex:"], }; +const anthropicOwner = { + id: "anthropic", + label: "Anthropic", + providerIds: ["anthropic"], + runtimeIds: ["claude-cli"], + cliSessionKeys: ["claude-cli"], + authProfilePrefixes: ["anthropic:", "claude-cli:"], +}; +const ownerState = vi.hoisted(() => ({ owners: [] as Array> })); -describe("doctor session state provider routes", () => { - it("skips plugin route-state scans for unrelated recovery metadata", () => { - expect( - storeMayContainPluginSessionRouteState({ - "agent:main:subagent:wedged-child": { - sessionId: "session-wedged-child", - updatedAt: 1, - abortedLastRun: true, - subagentRecovery: { - automaticAttempts: 2, - lastAttemptAt: 1, - wedgedAt: 2, - wedgedReason: "blocked", - }, - }, - }), - ).toBe(false); +vi.mock("../plugins/doctor-contract-registry.js", async () => { + const actual = await vi.importActual( + "../plugins/doctor-contract-registry.js", + ); + return { + ...actual, + listPluginDoctorSessionRouteStateOwners: vi.fn(() => ownerState.owners), + }; +}); - expect( - storeMayContainPluginSessionRouteState({ - "agent:main:telegram:direct:1": { - sessionId: "session-codex", - updatedAt: 1, - modelProvider: "openai-codex", - model: "gpt-5.4", - }, - }), - ).toBe(true); - - expect( - storeMayContainPluginSessionRouteState({ - "agent:main:telegram:direct:2": { - sessionId: "session-claude-cli", - updatedAt: 1, - agentRuntimeOverride: "claude-cli", - }, - }), - ).toBe(true); - }); - - it("skips valid locked agent-harness rows during scan and repair", async () => { - const sessionKey = "agent:main:ordinary-locked"; - const entry: Record = { - sessionId: "sess-supervised-codex", - updatedAt: 1, - modelSelectionLocked: true, - agentHarnessId: "codex", - modelProvider: "openai-codex", - model: "gpt-5.5", - providerOverride: "openai-codex", - modelOverride: "gpt-5.5", - modelOverrideSource: "auto", - cliSessionBindings: { - "codex-cli": { sessionId: "native-codex-session" }, - }, - }; - const original = structuredClone(entry); - const store = { [sessionKey]: entry }; - const route = { - defaultProvider: "github-copilot", - configuredModelRefs: ["github-copilot/gpt-5-mini"], - runtime: "openclaw", - }; - - expect( - storeMayContainPluginSessionRouteState( - store as unknown as Parameters[0], - ), - ).toBe(false); - expect( - scanSessionRouteStateOwners({ - owners: [codexOwner], - store, - routes: { [sessionKey]: route }, - }), - ).toEqual({ repairs: [], manualReview: [] }); - expect( - applySessionRouteStateRepair({ - sessionKey, - entry, - repair: { - key: sessionKey, - ownerId: "codex", - ownerLabel: "Codex", - reasons: ["auto model override", "pinned runtime", "runtime model state"], - pinnedRuntimeKeys: ["agentHarnessId"], - cliSessionKeys: ["codex-cli"], - }, - now: 123, - }), - ).toBe(false); - - const warnings: string[] = []; - const changes: string[] = []; - const prompter: Parameters[0]["prompter"] = { - confirmRuntimeRepair: vi.fn(async () => true), - note: vi.fn(), - }; +async function runDoctor(params: { + cfg: OpenClawConfig; + store: Record; + confirm?: boolean; + env?: NodeJS.ProcessEnv; +}) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-route-doctor-")); + const storePath = path.join(root, "sessions.json"); + await fs.writeFile(storePath, JSON.stringify(params.store), "utf8"); + const warnings: string[] = []; + const changes: string[] = []; + const confirmRuntimeRepair = vi.fn(async () => params.confirm ?? true); + try { await runPluginSessionStateDoctorRepairs({ - cfg: {}, - store: store as unknown as Parameters[0]["store"], - absoluteStorePath: "/tmp/nonexistent-supervised-store.json", - prompter, - env: {}, + cfg: params.cfg, + store: structuredClone(params.store), + absoluteStorePath: storePath, + prompter: { confirmRuntimeRepair, note: vi.fn() }, + env: params.env ?? {}, warnings, changes, }); + return { + store: JSON.parse(await fs.readFile(storePath, "utf8")) as Record, + warnings, + changes, + confirmRuntimeRepair, + }; + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +} - expect(entry).toEqual(original); - expect(warnings).toStrictEqual([]); - expect(changes).toStrictEqual([]); - expect(prompter.confirmRuntimeRepair).not.toHaveBeenCalled(); +function entry(patch: Record): SessionEntry { + return { + sessionId: "session-1", + updatedAt: 1, + ...patch, + } as SessionEntry; +} + +describe("doctor session state provider routes", () => { + beforeEach(() => { + ownerState.owners = [codexOwner]; }); - it("preserves configured provider CLI runtimes before harness policy normalization", () => { - const route = resolveConfiguredDoctorSessionStateRoute({ - cfg: { - agents: { - defaults: { - model: { primary: "openai/gpt-5.5" }, - }, + it("skips unrelated recovery metadata and valid locked harness rows", async () => { + const store = { + "agent:main:subagent:wedged-child": entry({ + abortedLastRun: true, + subagentRecovery: { + automaticAttempts: 2, + lastAttemptAt: 1, + wedgedAt: 2, + wedgedReason: "blocked", }, - models: { - providers: { - openai: { - baseUrl: "https://api.openai.com/v1", - agentRuntime: { id: "codex-cli" }, - models: [], - }, - }, - }, - }, - sessionKey: "agent:main:telegram:direct:1", - env: {}, - }); - expect(route.defaultProvider).toBe("openai"); - expect(route.configuredModelRefs).toStrictEqual(["openai/gpt-5.5"]); - expect(route.runtime).toBe("codex-cli"); - }); - - it("ignores legacy environment runtime overrides before plugin-owned scans", () => { - const route = resolveConfiguredDoctorSessionStateRoute({ - cfg: { - agents: { - defaults: { - model: { primary: "openai/gpt-5.5" }, - agentRuntime: { id: "openclaw" }, - }, - }, - }, - sessionKey: "agent:main:telegram:direct:1", - env: { OPENCLAW_AGENT_RUNTIME: "codex-cli" }, - }); - expect(route.runtime).toBe("codex"); - }); - - it("clears auto-created route state when current route no longer uses the owner", () => { - const sessionKey = "agent:main:telegram:direct:1"; - const entry: Record = { - sessionId: "sess-stale-codex", - updatedAt: 1, - providerOverride: "openai-codex", - modelOverride: "gpt-5.4", - modelOverrideSource: "auto", - modelProvider: "openai-codex", - model: "gpt-5.4", - contextTokens: 1_050_000, - systemPromptReport: { source: "run" }, - fallbackNoticeSelectedModel: "github-copilot/gpt-5-mini", - fallbackNoticeActiveModel: "openai-codex/gpt-5.4", - fallbackNoticeReason: "rate-limit", - agentHarnessId: "codex", - authProfileOverride: "openai-codex:default", - authProfileOverrideSource: "auto", - authProfileOverrideCompactionCount: 2, - cliSessionBindings: { - "codex-cli": { sessionId: "codex-session-1" }, - "claude-cli": { sessionId: "claude-session-1" }, - }, - cliSessionIds: { - "codex-cli": "codex-session-1", - "claude-cli": "claude-session-1", - }, + }), + "agent:main:ordinary-locked": entry({ + modelSelectionLocked: true, + agentHarnessId: "codex", + modelProvider: "openai-codex", + model: "gpt-5.5", + providerOverride: "openai-codex", + modelOverride: "gpt-5.5", + modelOverrideSource: "auto", + cliSessionBindings: { "codex-cli": { sessionId: "native-codex-session" } }, + }), }; - const scan = scanSessionRouteStateOwners({ - owners: [codexOwner], - store: { [sessionKey]: entry }, - routes: { - [sessionKey]: { - defaultProvider: "github-copilot", - configuredModelRefs: ["github-copilot/gpt-5-mini"], - runtime: "openclaw", - }, - }, - }); + const result = await runDoctor({ cfg: {}, store }); - expect(scan.manualReview).toStrictEqual([]); - expect(scan.repairs).toEqual([ - { - key: sessionKey, - ownerId: "codex", - ownerLabel: "Codex", - cliSessionKeys: ["codex-cli"], - pinnedRuntimeKeys: ["agentHarnessId"], - reasons: [ - "auto model override", - "pinned runtime", - "runtime model state", - "CLI session binding", - "auto auth profile override", - ], - }, - ]); - - expect( - applySessionRouteStateRepair({ - sessionKey, - entry, - repair: expectDefined(scan.repairs[0], "scan.repairs[0] test invariant"), - now: 123, - }), - ).toBe(true); - expect(entry.sessionId).toBe("sess-stale-codex"); - expect(entry.updatedAt).toBe(123); - expect(entry.cliSessionBindings).toStrictEqual({ - "claude-cli": { sessionId: "claude-session-1" }, - }); - expect(entry.cliSessionIds).toStrictEqual({ - "claude-cli": "claude-session-1", - }); - expect(entry.providerOverride).toBeUndefined(); - expect(entry.modelOverride).toBeUndefined(); - expect(entry.modelOverrideSource).toBeUndefined(); - expect(entry.modelProvider).toBeUndefined(); - expect(entry.model).toBeUndefined(); - expect(entry.contextTokens).toBeUndefined(); - expect(entry.systemPromptReport).toBeUndefined(); - expect(entry.agentHarnessId).toBeUndefined(); - expect(entry.authProfileOverride).toBeUndefined(); - expect(entry.authProfileOverrideSource).toBeUndefined(); - expect(entry.authProfileOverrideCompactionCount).toBeUndefined(); - expect(entry.fallbackNoticeActiveModel).toBeUndefined(); + expect(result.store).toEqual(store); + expect(result.warnings).toStrictEqual([]); + expect(result.changes).toStrictEqual([]); + expect(result.confirmRuntimeRepair).not.toHaveBeenCalled(); }); - it("leaves explicit user owner model choices for manual review", () => { + it("keeps owner state when the configured provider selects the owner runtime", async () => { + const store = { + "agent:main:telegram:direct:1": entry({ + modelProvider: "codex-cli", + model: "gpt-5.5", + cliSessionBindings: { "codex-cli": { sessionId: "codex-cli-session" } }, + }), + }; + const cfg = { + agents: { defaults: { model: { primary: "openai/gpt-5.5" } } }, + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + agentRuntime: { id: "codex-cli" }, + models: [], + }, + }, + }, + } satisfies OpenClawConfig; + + const result = await runDoctor({ cfg, store }); + + expect(result.store).toEqual(store); + expect(result.confirmRuntimeRepair).not.toHaveBeenCalled(); + }); + + it("clears stale automatic owner state through the doctor repair boundary", async () => { const sessionKey = "agent:main:telegram:direct:2"; - const entry: Record = { - sessionId: "sess-user-codex", - updatedAt: 1, - providerOverride: "openai-codex", - modelOverride: "gpt-5.4", - modelOverrideSource: "user", - modelProvider: "openai-codex", - model: "gpt-5.4", - agentHarnessId: "codex", - cliSessionBindings: { - "codex-cli": { sessionId: "codex-session-2" }, - }, - }; - - const scan = scanSessionRouteStateOwners({ - owners: [codexOwner], - store: { [sessionKey]: entry }, - routes: { - [sessionKey]: { - defaultProvider: "github-copilot", - configuredModelRefs: ["github-copilot/gpt-5-mini"], - runtime: "openclaw", + const store = { + [sessionKey]: entry({ + providerOverride: "openai-codex", + modelOverride: "gpt-5.4", + modelOverrideSource: "auto", + modelProvider: "openai-codex", + model: "gpt-5.4", + contextTokens: 1_050_000, + systemPromptReport: { source: "run" }, + agentHarnessId: "codex", + authProfileOverride: "openai-codex:default", + authProfileOverrideSource: "auto", + cliSessionBindings: { + "codex-cli": { sessionId: "codex-session" }, + "claude-cli": { sessionId: "claude-session" }, }, - }, - }); + }), + }; + const cfg = { + agents: { defaults: { model: { primary: "github-copilot/gpt-5-mini" } } }, + } satisfies OpenClawConfig; - expect(scan.repairs).toStrictEqual([]); - expect(scan.manualReview).toEqual([ - { - key: sessionKey, - ownerLabel: "Codex", - message: `${sessionKey} (openai-codex/gpt-5.4, user)`, - }, - ]); + const result = await runDoctor({ cfg, store }); + const repaired = result.store[sessionKey] as unknown as Record; + + expect(result.confirmRuntimeRepair).toHaveBeenCalledOnce(); + expect(result.warnings.join("\n")).toContain("stale Codex session routing state"); + expect(result.changes.join("\n")).toContain("Cleared stale Codex session routing state"); + expect(repaired.providerOverride).toBeUndefined(); + expect(repaired.modelOverride).toBeUndefined(); + expect(repaired.modelProvider).toBeUndefined(); + expect(repaired.agentHarnessId).toBeUndefined(); + expect(repaired.authProfileOverride).toBeUndefined(); + expect(repaired.cliSessionBindings).toEqual({ + "claude-cli": { sessionId: "claude-session" }, + }); }); - it("clears stale runtime pins while preserving configured owner model state", () => { + it("leaves explicit user owner choices for manual review", async () => { const sessionKey = "agent:main:telegram:direct:3"; - const entry: Record = { - sessionId: "sess-configured-codex", - updatedAt: 1, - providerOverride: "openai-codex", - modelOverride: "gpt-5.4", - modelOverrideSource: "auto", - modelProvider: "openai-codex", - model: "gpt-5.4", - agentHarnessId: "codex", - cliSessionBindings: { - "codex-cli": { sessionId: "codex-session-3" }, - }, - }; - - const scan = scanSessionRouteStateOwners({ - owners: [codexOwner], - store: { [sessionKey]: entry }, - routes: { - [sessionKey]: { - defaultProvider: "github-copilot", - configuredModelRefs: ["github-copilot/gpt-5-mini", "openai-codex/gpt-5.4"], - runtime: "openclaw", - }, - }, - }); - - expect(scan.manualReview).toStrictEqual([]); - expect(scan.repairs).toEqual([ - { - key: sessionKey, - ownerId: "codex", - ownerLabel: "Codex", - cliSessionKeys: ["codex-cli"], - pinnedRuntimeKeys: ["agentHarnessId"], - reasons: ["pinned runtime"], - }, - ]); - - expect( - applySessionRouteStateRepair({ - sessionKey, - entry, - repair: expectDefined(scan.repairs[0], "scan.repairs[0] test invariant"), - now: 123, + const store = { + [sessionKey]: entry({ + providerOverride: "openai-codex", + modelOverride: "gpt-5.4", + modelOverrideSource: "user", + modelProvider: "openai-codex", + model: "gpt-5.4", + agentHarnessId: "codex", }), - ).toBe(true); - expect(entry.updatedAt).toBe(123); - expect(entry.providerOverride).toBe("openai-codex"); - expect(entry.modelOverride).toBe("gpt-5.4"); - expect(entry.modelProvider).toBe("openai-codex"); - expect(entry.model).toBe("gpt-5.4"); - expect(entry.agentHarnessId).toBeUndefined(); - expect(entry.cliSessionBindings).toStrictEqual({ - "codex-cli": { sessionId: "codex-session-3" }, - }); + }; + const cfg = { + agents: { defaults: { model: { primary: "github-copilot/gpt-5-mini" } } }, + } satisfies OpenClawConfig; + + const result = await runDoctor({ cfg, store }); + + expect(result.store).toEqual(store); + expect(result.warnings.join("\n")).toContain("explicit Codex model overrides"); + expect(result.confirmRuntimeRepair).not.toHaveBeenCalled(); }); - it("keeps owner CLI state when owner runtime is still configured", () => { + it("keeps configured owner model state while clearing a stale runtime pin", async () => { const sessionKey = "agent:main:telegram:direct:4"; - const entry: Record = { - sessionId: "sess-codex-cli", - updatedAt: 1, - modelProvider: "codex-cli", - model: "gpt-5.5", - cliSessionBindings: { - "codex-cli": { sessionId: "codex-cli-session" }, - }, - }; - - const scan = scanSessionRouteStateOwners({ - owners: [codexOwner], - store: { [sessionKey]: entry }, - routes: { - [sessionKey]: { - defaultProvider: "openai", - configuredModelRefs: ["openai/gpt-5.5"], - runtime: "codex-cli", - }, - }, - }); - - expect(scan).toEqual({ repairs: [], manualReview: [] }); - }); - - it("clears stale agentRuntimeOverride-only pins when current route no longer uses the owner", () => { - const sessionKey = "agent:main:telegram:direct:5"; - const entry: Record = { - sessionId: "sess-stale-claude-cli", - updatedAt: 1, - agentRuntimeOverride: "claude-cli", - }; - - const scan = scanSessionRouteStateOwners({ - owners: [ - { - id: "anthropic", - label: "Anthropic", - providerIds: ["anthropic"], - runtimeIds: ["claude-cli"], - cliSessionKeys: ["claude-cli"], - authProfilePrefixes: ["anthropic:", "claude-cli:"], - }, - ], - store: { [sessionKey]: entry }, - routes: { - [sessionKey]: { - defaultProvider: "openai", - configuredModelRefs: ["openai/gpt-5.5"], - runtime: "openclaw", - }, - }, - }); - - expect(scan.manualReview).toStrictEqual([]); - expect(scan.repairs).toEqual([ - { - key: sessionKey, - ownerId: "anthropic", - ownerLabel: "Anthropic", - cliSessionKeys: ["claude-cli"], - pinnedRuntimeKeys: ["agentRuntimeOverride"], - reasons: ["pinned runtime"], - }, - ]); - - expect( - applySessionRouteStateRepair({ - sessionKey, - entry, - repair: expectDefined(scan.repairs[0], "scan.repairs[0] test invariant"), - now: 123, + const store = { + [sessionKey]: entry({ + providerOverride: "openai-codex", + modelOverride: "gpt-5.4", + modelOverrideSource: "auto", + modelProvider: "openai-codex", + model: "gpt-5.4", + agentHarnessId: "codex", }), - ).toBe(true); - expect(entry.sessionId).toBe("sess-stale-claude-cli"); - expect(entry.updatedAt).toBe(123); - expect(entry.agentRuntimeOverride).toBeUndefined(); - }); - - it("keeps agentRuntimeOverride pins when owner runtime remains configured", () => { - const sessionKey = "agent:main:telegram:direct:6"; - const entry: Record = { - sessionId: "sess-active-claude-cli", - updatedAt: 1, - agentRuntimeOverride: "claude-cli", }; - - const scan = scanSessionRouteStateOwners({ - owners: [ - { - id: "anthropic", - label: "Anthropic", - providerIds: ["anthropic"], - runtimeIds: ["claude-cli"], - cliSessionKeys: ["claude-cli"], - authProfilePrefixes: ["anthropic:", "claude-cli:"], - }, - ], - store: { [sessionKey]: entry }, - routes: { - [sessionKey]: { - defaultProvider: "anthropic", - configuredModelRefs: ["anthropic/claude-opus-4.7"], - runtime: "claude-cli", - }, - }, - }); - - expect(scan).toEqual({ repairs: [], manualReview: [] }); - }); - - it("clears stale owner runtime pins when owner provider remains configured", () => { - const sessionKey = "agent:main:telegram:direct:7"; - const entry: Record = { - sessionId: "sess-provider-active-runtime-stale", - updatedAt: 1, - agentRuntimeOverride: "claude-cli", - }; - - const scan = scanSessionRouteStateOwners({ - owners: [ - { - id: "anthropic", - label: "Anthropic", - providerIds: ["anthropic"], - runtimeIds: ["claude-cli"], - cliSessionKeys: ["claude-cli"], - authProfilePrefixes: ["anthropic:", "claude-cli:"], - }, - ], - store: { [sessionKey]: entry }, - routes: { - [sessionKey]: { - defaultProvider: "anthropic", - configuredModelRefs: ["anthropic/claude-opus-4.7"], - runtime: "openclaw", - }, - }, - }); - - expect(scan.manualReview).toStrictEqual([]); - expect(scan.repairs).toEqual([ - { - key: sessionKey, - ownerId: "anthropic", - ownerLabel: "Anthropic", - cliSessionKeys: ["claude-cli"], - pinnedRuntimeKeys: ["agentRuntimeOverride"], - reasons: ["pinned runtime"], - }, - ]); - - expect( - applySessionRouteStateRepair({ - sessionKey, - entry, - repair: expectDefined(scan.repairs[0], "scan.repairs[0] test invariant"), - now: 123, - }), - ).toBe(true); - expect(entry.updatedAt).toBe(123); - expect(entry.agentRuntimeOverride).toBeUndefined(); - }); - - it("preserves non-owner runtime overrides when clearing owner harness pins", () => { - const sessionKey = "agent:main:telegram:direct:8"; - const entry: Record = { - sessionId: "sess-mixed-runtime-pins", - updatedAt: 1, - agentHarnessId: "codex-cli", - agentRuntimeOverride: "claude-cli", - }; - - const scan = scanSessionRouteStateOwners({ - owners: [codexOwner], - store: { [sessionKey]: entry }, - routes: { - [sessionKey]: { - defaultProvider: "openai", - configuredModelRefs: ["openai/gpt-5.5"], - runtime: "openclaw", - }, - }, - }); - - expect(scan.manualReview).toStrictEqual([]); - expect(scan.repairs).toEqual([ - { - key: sessionKey, - ownerId: "codex", - ownerLabel: "Codex", - cliSessionKeys: ["codex-cli"], - pinnedRuntimeKeys: ["agentHarnessId"], - reasons: ["pinned runtime"], - }, - ]); - - expect( - applySessionRouteStateRepair({ - sessionKey, - entry, - repair: expectDefined(scan.repairs[0], "scan.repairs[0] test invariant"), - now: 123, - }), - ).toBe(true); - expect(entry.updatedAt).toBe(123); - expect(entry.agentHarnessId).toBeUndefined(); - expect(entry.agentRuntimeOverride).toBe("claude-cli"); - }); - - it("skips entries without plugin route state and memoizes routes per agentId", async () => { - // Sentinel cfg makes resolveConfiguredDoctorSessionStateRoute cheap and - // deterministic. The important assertions are observable through the - // resulting scan: entries with no route-state fields contribute no - // repairs/manual-review and the run completes immediately. const cfg = { agents: { defaults: { - model: { primary: "anthropic/claude-sonnet-4" }, + model: { + primary: "github-copilot/gpt-5-mini", + fallbacks: ["openai-codex/gpt-5.4"], + }, }, }, + } satisfies OpenClawConfig; + + const result = await runDoctor({ cfg, store }); + const repaired = result.store[sessionKey] as unknown as Record; + + expect(repaired.providerOverride).toBe("openai-codex"); + expect(repaired.modelOverride).toBe("gpt-5.4"); + expect(repaired.modelProvider).toBe("openai-codex"); + expect(repaired.agentHarnessId).toBeUndefined(); + }); + + it("skips bare entries and only prompts for route-state rows", async () => { + const store: Record = {}; + for (let index = 0; index < 100; index += 1) { + store[`agent:main:bare-${index}`] = entry({}); + } + store["agent:main:codex"] = entry({ agentHarnessId: "codex-cli" }); + + const result = await runDoctor({ + cfg: { agents: { defaults: { model: "anthropic/claude-sonnet-4" } } }, + store, + confirm: false, + }); + + expect(result.confirmRuntimeRepair).toHaveBeenCalledOnce(); + expect(result.changes).toStrictEqual([]); + expect(result.store).toEqual(store); + }); + + it("preserves a provider-owned runtime pin when that runtime remains configured", async () => { + ownerState.owners = [codexOwner, anthropicOwner]; + const store = { + "agent:main:telegram:direct:5": entry({ agentRuntimeOverride: "claude-cli" }), + }; + const cfg = { + agents: { defaults: { model: { primary: "anthropic/claude-opus-4.7" } } }, models: { providers: { - anthropic: {}, + anthropic: { + baseUrl: "https://api.anthropic.com", + agentRuntime: { id: "claude-cli" }, + models: [], + }, }, }, - } as unknown as Parameters[0]["cfg"]; + } satisfies OpenClawConfig; - // Build a store with 200 entries belonging to one agent. Two carry route - // state that the codex owner cares about; the rest are bare. The old - // implementation resolved a route for all 200; the new one only resolves - // for the 2 that matter, deduplicated by agentId. - const store: Record> = {}; - for (let i = 0; i < 198; i += 1) { - store[`agent:main:bare-${i}`] = { - sessionId: `sess-bare-${i}`, - updatedAt: i, - // No providerOverride/model/agentHarnessId/etc. — must be skipped. - }; - } - store["agent:main:codex-1"] = { - sessionId: "sess-codex-1", - updatedAt: 1, - agentHarnessId: "codex-cli", - }; - store["agent:main:codex-2"] = { - sessionId: "sess-codex-2", - updatedAt: 2, - agentHarnessId: "codex-cli", + const result = await runDoctor({ cfg, store }); + + expect(result.store).toEqual(store); + expect(result.confirmRuntimeRepair).not.toHaveBeenCalled(); + }); + + it("applies independent multi-owner repairs and records each owner", async () => { + ownerState.owners = [codexOwner, anthropicOwner]; + const sessionKey = "agent:main:telegram:direct:6"; + const store = { + [sessionKey]: entry({ + agentHarnessId: "codex", + agentRuntimeOverride: "claude-cli", + }), }; - const warnings: string[] = []; - const changes: string[] = []; - const prompter: Parameters[0]["prompter"] = { - confirmRuntimeRepair: vi.fn(async () => false), - note: vi.fn(), - }; - - const start = Date.now(); - await runPluginSessionStateDoctorRepairs({ - cfg, - store: store as unknown as Parameters[0]["store"], - absoluteStorePath: "/tmp/nonexistent-store.json", - prompter, - env: {}, - warnings, - changes, + const result = await runDoctor({ + cfg: { agents: { defaults: { model: "github-copilot/gpt-5-mini" } } }, + store, }); - const elapsedMs = Date.now() - start; + const repaired = result.store[sessionKey] as unknown as Record; - // Two entries flagged for pinned-runtime repair; warning emitted once. - expect(warnings).toHaveLength(1); - expect(warnings[0]).toMatch(/Codex/); - expect(warnings[0]).toMatch(/2 sessions?/); - - // User declined the repair so no changes applied. - expect(changes).toHaveLength(0); - expect(prompter.confirmRuntimeRepair).toHaveBeenCalledOnce(); - - // Sanity check: even with 200 entries, this should complete near- - // instantly because route resolution is bounded by unique agentIds, not - // by store size. A 200-entry x 1.6s-per-call pre-fix run would exceed - // 5 minutes; the fixed code should run in well under a second. - expect(elapsedMs).toBeLessThan(2000); + expect(result.confirmRuntimeRepair).toHaveBeenCalledTimes(2); + expect(result.warnings.join("\n")).toContain("stale Codex session routing state"); + expect(result.warnings.join("\n")).toContain("stale Anthropic session routing state"); + expect(result.changes.join("\n")).toContain("Cleared stale Codex session routing state"); + expect(result.changes.join("\n")).toContain("Cleared stale Anthropic session routing state"); + expect(repaired.agentHarnessId).toBeUndefined(); + expect(repaired.agentRuntimeOverride).toBeUndefined(); }); }); diff --git a/src/commands/doctor-session-state-providers.ts b/src/commands/doctor-session-state-providers.ts index 8999696844d7..4082db5eb464 100644 --- a/src/commands/doctor-session-state-providers.ts +++ b/src/commands/doctor-session-state-providers.ts @@ -61,7 +61,7 @@ function resolveSessionAgentId(cfg: OpenClawConfig, sessionKey: string): string } /** Resolves the currently configured provider/model/runtime route for a session key. */ -export function resolveConfiguredDoctorSessionStateRoute(params: { +function resolveConfiguredDoctorSessionStateRoute(params: { cfg: OpenClawConfig; sessionKey: string; env?: NodeJS.ProcessEnv; @@ -128,9 +128,7 @@ function entryMayContainPluginSessionRouteState(sessionKey: string, entry: Sessi } /** Fast prefilter for session stores that might contain plugin-owned routing state. */ -export function storeMayContainPluginSessionRouteState( - store: Record, -): boolean { +function storeMayContainPluginSessionRouteState(store: Record): boolean { return Object.entries(store).some(([sessionKey, entry]) => entryMayContainPluginSessionRouteState(sessionKey, entry), ); @@ -338,7 +336,7 @@ function scanEntryForOwner(params: { } /** Scans session entries for state owned by plugins that no longer match the configured route. */ -export function scanSessionRouteStateOwners(params: { +function scanSessionRouteStateOwners(params: { owners: readonly DoctorSessionRouteStateOwner[]; store: Record>; routes: Record; @@ -397,7 +395,7 @@ function clearRecordKeys( } /** Clears stale plugin-owned routing fields from a session entry and refreshes updatedAt. */ -export function applySessionRouteStateRepair(params: { +function applySessionRouteStateRepair(params: { sessionKey: string; entry: Record; repair: DoctorSessionRouteStateRepair; diff --git a/src/commands/doctor-session-transcripts.test.ts b/src/commands/doctor-session-transcripts.test.ts index babedf6697a2..6bfbbe0c7c4b 100644 --- a/src/commands/doctor-session-transcripts.test.ts +++ b/src/commands/doctor-session-transcripts.test.ts @@ -25,11 +25,46 @@ vi.mock("./doctor-sqlite-maintenance-lock.js", () => ({ import { detectSessionTranscriptHealthIssues, noteSessionTranscriptHealth, - repairBrokenSessionTranscriptFile, sessionTranscriptIssueToHealthFinding, sessionTranscriptIssueToRepairEffect, } from "./doctor-session-transcripts.js"; +async function repairBrokenSessionTranscriptFile(params: { + filePath: string; + shouldRepair: boolean; +}) { + const [issue] = await detectSessionTranscriptHealthIssues({ + sessionDirs: [path.dirname(params.filePath)], + }); + if (!issue) { + return { + filePath: params.filePath, + broken: false, + repaired: false, + originalEntries: 0, + activeEntries: 0, + legacyOpenAICodexEntries: 0, + }; + } + if (!params.shouldRepair) { + return issue; + } + + await noteSessionTranscriptHealth({ + sessionDirs: [path.dirname(params.filePath)], + shouldRepair: true, + }); + const backupPrefix = `${path.basename(params.filePath)}.pre-doctor-`; + const backupName = (await fs.readdir(path.dirname(params.filePath))).find( + (entry) => entry.startsWith(backupPrefix) && entry.endsWith(".bak"), + ); + return { + ...issue, + repaired: true, + ...(backupName ? { backupPath: path.join(path.dirname(params.filePath), backupName) } : {}), + }; +} + function countNonEmptyLines(value: string): number { let count = 0; for (const line of value.split(/\r?\n/)) { @@ -122,7 +157,7 @@ describe("doctor session transcript repair", () => { expect(result.repaired).toBe(true); expect(result.originalEntries).toBe(6); expect(result.activeEntries).toBe(3); - if (result.backupPath === undefined) { + if (!("backupPath" in result) || result.backupPath === undefined) { throw new Error("expected transcript backup path"); } await expect(fs.access(result.backupPath)).resolves.toBeUndefined(); diff --git a/src/commands/doctor-session-transcripts.ts b/src/commands/doctor-session-transcripts.ts index 13dd96348392..7717faf42236 100644 --- a/src/commands/doctor-session-transcripts.ts +++ b/src/commands/doctor-session-transcripts.ts @@ -279,7 +279,7 @@ async function writeTranscriptEntries(params: { } /** Repairs one transcript file by keeping the active branch and backing up the original file. */ -export async function repairBrokenSessionTranscriptFile(params: { +async function repairBrokenSessionTranscriptFile(params: { filePath: string; shouldRepair: boolean; }): Promise { diff --git a/src/commands/doctor-skills.test.ts b/src/commands/doctor-skills.test.ts index e1a192392a83..3982e5d595bd 100644 --- a/src/commands/doctor-skills.test.ts +++ b/src/commands/doctor-skills.test.ts @@ -1,17 +1,30 @@ // Doctor skills tests cover skill install checks, status summaries, and repair guidance. -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { createEmptyInstallChecks } from "../cli/requirements-test-fixtures.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { SkillStatusEntry, SkillStatusReport } from "../skills/discovery/status.js"; -import type { GhConfigDiscoveryInput } from "../skills/lifecycle/gh-config-discovery.js"; +import type { DoctorPrompter } from "./doctor-prompter.js"; import { collectUnavailableAgentSkills, disableUnavailableSkillsInConfig, } from "./doctor-skills-core.js"; -import { - describeGhConfigDirHintFromDiscovery, - formatUnavailableSkillDoctorLines, -} from "./doctor-skills.js"; +import { maybeRepairSkillReadiness } from "./doctor-skills.js"; + +const mocks = vi.hoisted(() => ({ + buildWorkspaceSkillStatus: vi.fn(), + detectGhConfigDirMismatch: vi.fn(), + note: vi.fn(), +})); + +vi.mock("../skills/discovery/status.js", async (importActual) => ({ + ...(await importActual()), + buildWorkspaceSkillStatus: mocks.buildWorkspaceSkillStatus, +})); +vi.mock("../skills/lifecycle/gh-config-discovery.js", async (importActual) => ({ + ...(await importActual()), + detectGhConfigDirMismatch: mocks.detectGhConfigDirMismatch, +})); +vi.mock("../../packages/terminal-core/src/note.js", () => ({ note: mocks.note })); function createSkill(overrides: Partial): SkillStatusEntry { return { @@ -45,6 +58,35 @@ function createReport(skills: SkillStatusEntry[]): SkillStatusReport { }; } +function createPrompter(): DoctorPrompter { + return { + confirm: vi.fn(async () => false), + confirmAutoFix: vi.fn(async () => false), + confirmAggressiveAutoFix: vi.fn(async () => false), + confirmRuntimeRepair: vi.fn(async () => false), + select: vi.fn(async (_params, fallback) => fallback), + shouldRepair: false, + shouldForce: false, + repairMode: { + shouldRepair: false, + shouldForce: false, + nonInteractive: false, + canPrompt: true, + updateInProgress: false, + }, + }; +} + +async function runSkillDoctor(skills: SkillStatusEntry[]) { + mocks.note.mockClear(); + mocks.buildWorkspaceSkillStatus.mockReturnValue(createReport(skills)); + await maybeRepairSkillReadiness({ + cfg: {}, + prompter: createPrompter(), + }); + return mocks.note.mock.calls; +} + describe("doctor skills", () => { it("collects only unavailable skills that this agent is allowed to use", () => { const unavailable = createSkill({ @@ -66,8 +108,8 @@ describe("doctor skills", () => { expect(collectUnavailableAgentSkills(report)).toEqual([unavailable]); }); - it("formats unavailable skill names compactly and alphabetically", () => { - const lines = formatUnavailableSkillDoctorLines([ + it("formats unavailable skill names compactly and alphabetically", async () => { + const calls = await runSkillDoctor([ createSkill({ name: "places", eligible: false, @@ -95,7 +137,8 @@ describe("doctor skills", () => { }), ]); - expect(lines).toEqual([ + const body = calls.find((call) => call[1] === "Skills")?.[0]; + expect(typeof body === "string" ? body.split("\n") : []).toEqual([ "2 allowed skills are not usable in this environment (missing binaries, env vars, or config).", "- calendar, places", "Disable unused skills: openclaw doctor --fix", @@ -103,13 +146,15 @@ describe("doctor skills", () => { ]); }); - it("uses singular grammar for one unavailable skill", () => { - expect(formatUnavailableSkillDoctorLines([createSkill({ name: "places" })])[0]).toBe( + it("uses singular grammar for one unavailable skill", async () => { + const calls = await runSkillDoctor([createSkill({ name: "places", eligible: false })]); + const body = calls.find((call) => call[1] === "Skills")?.[0]; + expect(typeof body === "string" ? body.split("\n")[0] : undefined).toBe( "1 allowed skill is not usable in this environment (missing binaries, env vars, or config).", ); }); - it("surfaces a GH_CONFIG_DIR hint when the github skill is eligible but auth lives at a different HOME", () => { + it("surfaces a GH_CONFIG_DIR hint through the doctor path", async () => { const githubSkill = createSkill({ name: "github", skillKey: "github", @@ -117,20 +162,22 @@ describe("doctor skills", () => { platformIncompatible: false, missing: { bins: [], anyBins: [], env: [], config: [], os: [] }, }); - const discovery: GhConfigDiscoveryInput = { - platform: "linux", - env: { HOME: "/root/.openclaw/agents/main/agent/codex-home/home" }, - fileExists: (p) => p === "/root/.config/gh/hosts.yml", - }; - - const lines = describeGhConfigDirHintFromDiscovery([githubSkill], discovery); - const output = lines.join("\n"); + mocks.detectGhConfigDirMismatch.mockReturnValue({ + kind: "mismatch", + effectiveConfigDir: "/agent/home/.config/gh", + alternateConfigDir: "/root/.config/gh", + alternateHostsFile: "/root/.config/gh/hosts.yml", + alternateHomeHint: "/root", + suggestedEnvValue: "/root/.config/gh", + }); + const calls = await runSkillDoctor([githubSkill]); + const output = String(calls.find((call) => call[1] === "GitHub CLI")?.[0] ?? ""); expect(output).toContain("/root/.config/gh"); expect(output).toContain("GH_CONFIG_DIR=/root/.config/gh"); }); - it("does not surface the GH_CONFIG_DIR hint when the github skill is missing the gh binary", () => { + it("does not surface the GH_CONFIG_DIR hint for an ineligible skill", async () => { const githubSkill = createSkill({ name: "github", skillKey: "github", @@ -138,76 +185,8 @@ describe("doctor skills", () => { platformIncompatible: false, missing: { bins: ["gh"], anyBins: [], env: [], config: [], os: [] }, }); - const discovery: GhConfigDiscoveryInput = { - platform: "linux", - env: { HOME: "/agent/home" }, - fileExists: (p) => p === "/root/.config/gh/hosts.yml", - }; - - expect(describeGhConfigDirHintFromDiscovery([githubSkill], discovery)).toEqual([]); - }); - - it("does not surface the GH_CONFIG_DIR hint when the github skill is disabled", () => { - const githubSkill = createSkill({ - name: "github", - skillKey: "github", - eligible: false, - platformIncompatible: false, - disabled: true, - missing: { bins: [], anyBins: [], env: [], config: [], os: [] }, - }); - const discovery: GhConfigDiscoveryInput = { - platform: "linux", - env: { HOME: "/agent/home" }, - fileExists: (p) => p === "/root/.config/gh/hosts.yml", - }; - - expect(describeGhConfigDirHintFromDiscovery([githubSkill], discovery)).toEqual([]); - }); - - it("does not surface the GH_CONFIG_DIR hint when the github skill is filtered out for the agent", () => { - const githubSkill = createSkill({ - name: "github", - skillKey: "github", - eligible: true, - platformIncompatible: false, - blockedByAgentFilter: true, - missing: { bins: [], anyBins: [], env: [], config: [], os: [] }, - }); - const discovery: GhConfigDiscoveryInput = { - platform: "linux", - env: { HOME: "/agent/home" }, - fileExists: (p) => p === "/root/.config/gh/hosts.yml", - }; - - expect(describeGhConfigDirHintFromDiscovery([githubSkill], discovery)).toEqual([]); - }); - - it("does not surface the GH_CONFIG_DIR hint when GH_CONFIG_DIR is already set", () => { - const githubSkill = createSkill({ - name: "github", - skillKey: "github", - eligible: true, - platformIncompatible: false, - missing: { bins: [], anyBins: [], env: [], config: [], os: [] }, - }); - const discovery: GhConfigDiscoveryInput = { - platform: "linux", - env: { HOME: "/agent/home", GH_CONFIG_DIR: "/etc/openclaw/gh" }, - fileExists: () => true, - }; - - expect(describeGhConfigDirHintFromDiscovery([githubSkill], discovery)).toEqual([]); - }); - - it("does not surface the GH_CONFIG_DIR hint when the github skill is not present in the report", () => { - const discovery: GhConfigDiscoveryInput = { - platform: "linux", - env: { HOME: "/agent/home" }, - fileExists: (p) => p === "/root/.config/gh/hosts.yml", - }; - - expect(describeGhConfigDirHintFromDiscovery([], discovery)).toEqual([]); + const calls = await runSkillDoctor([githubSkill]); + expect(calls.some((call) => call[1] === "GitHub CLI")).toBe(false); }); it("disables unavailable skills through skills.entries without dropping existing config", () => { diff --git a/src/commands/doctor-skills.ts b/src/commands/doctor-skills.ts index c6d4d5ac4843..97cce0aaa843 100644 --- a/src/commands/doctor-skills.ts +++ b/src/commands/doctor-skills.ts @@ -32,7 +32,7 @@ function describeGhConfigDirHint(skills: SkillStatusEntry[]): string[] { } /** Builds a GitHub CLI config-dir hint from injected discovery inputs for tests. */ -export function describeGhConfigDirHintFromDiscovery( +function describeGhConfigDirHintFromDiscovery( skills: SkillStatusEntry[], discoveryInput: GhConfigDiscoveryInput, ): string[] { @@ -56,7 +56,7 @@ export function describeGhConfigDirHintFromDiscovery( } /** Formats doctor note lines for skills that are allowed but unavailable. */ -export function formatUnavailableSkillDoctorLines(skills: SkillStatusEntry[]): string[] { +function formatUnavailableSkillDoctorLines(skills: SkillStatusEntry[]): string[] { const count = skills.length; const lines = [ `${count} allowed skill${count === 1 ? " is" : "s are"} not usable in this environment (missing binaries, env vars, or config).`, diff --git a/src/commands/doctor/cron/warnings.test.ts b/src/commands/doctor/cron/warnings.test.ts index 120a971f1e3c..cc27f2a73f04 100644 --- a/src/commands/doctor/cron/warnings.test.ts +++ b/src/commands/doctor/cron/warnings.test.ts @@ -1,6 +1,16 @@ // Doctor cron delivery-target advisory tests cover concrete-vs-pseudo channel detection. import { describe, expect, it, vi } from "vitest"; -import { collectCronDeliveryTargetAdvisory } from "./warnings.js"; +import { noteCronDeliveryTargetAdvisory } from "./warnings.js"; + +const mocks = vi.hoisted(() => ({ + listReadOnlyChannelPluginsForConfig: vi.fn(), + note: vi.fn(), +})); + +vi.mock("../../../channels/plugins/read-only.js", () => ({ + listReadOnlyChannelPluginsForConfig: mocks.listReadOnlyChannelPluginsForConfig, +})); +vi.mock("../../../../packages/terminal-core/src/note.js", () => ({ note: mocks.note })); const STORE_PATH = "/tmp/openclaw/cron/jobs.sqlite"; @@ -13,6 +23,24 @@ function availableChannels(...ids: string[]) { return vi.fn(() => ids); } +function collectCronDeliveryTargetAdvisory(params: { + jobs: Array>; + storePath: string; + resolveAvailableChannelIds: () => string[]; +}): string | null { + mocks.note.mockClear(); + mocks.listReadOnlyChannelPluginsForConfig.mockImplementation(() => + params.resolveAvailableChannelIds().map((id) => ({ id })), + ); + noteCronDeliveryTargetAdvisory({ + cfg: {}, + jobs: params.jobs, + storePath: params.storePath, + }); + const body = mocks.note.mock.calls.at(-1)?.[0]; + return typeof body === "string" ? body : null; +} + describe("collectCronDeliveryTargetAdvisory", () => { it("advises when a concrete delivery channel has no active plugin", () => { const advisory = collectCronDeliveryTargetAdvisory({ diff --git a/src/commands/doctor/cron/warnings.ts b/src/commands/doctor/cron/warnings.ts index 72ad18697ec9..fc9ea368985e 100644 --- a/src/commands/doctor/cron/warnings.ts +++ b/src/commands/doctor/cron/warnings.ts @@ -165,7 +165,7 @@ function listConcreteCronDeliveryTargets( * list is resolved lazily so doctor skips the read-only channel snapshot when no job can drift. * Returns `null` when no job pins a concrete target or every concrete target is active. */ -export function collectCronDeliveryTargetAdvisory(params: { +function collectCronDeliveryTargetAdvisory(params: { jobs: Array>; storePath: string; resolveAvailableChannelIds: () => Iterable; diff --git a/src/commands/doctor/shared/context-engine-host-compat.test.ts b/src/commands/doctor/shared/context-engine-host-compat.test.ts index 202975312b5c..9990fe32d9bd 100644 --- a/src/commands/doctor/shared/context-engine-host-compat.test.ts +++ b/src/commands/doctor/shared/context-engine-host-compat.test.ts @@ -9,7 +9,6 @@ import { } from "../../../context-engine/registry.js"; import type { ContextEngine, ContextEngineHostCapability } from "../../../context-engine/types.js"; import { - collectConfiguredContextEngineAgentRunHosts, collectContextEngineHostCompatibilityWarnings, maybeRepairContextEngineHostCompatibility, } from "./context-engine-host-compat.js"; @@ -82,9 +81,10 @@ describe("doctor context-engine host compatibility", () => { expect(getContextEngineFactory(id)).toBeUndefined(); }); - it("collects native Codex and OpenClaw as compatible agent-run hosts", () => { - const hosts = collectConfiguredContextEngineAgentRunHosts({ - cfg: { + it("evaluates native Codex and OpenClaw agent-run hosts", async () => { + const engineId = registerEngine(["thread-bootstrap-projection"]); + const warnings = await collectContextEngineHostCompatibilityWarnings({ + cfg: configWithEngine(engineId, { agents: { defaults: { models: { @@ -93,13 +93,13 @@ describe("doctor context-engine host compatibility", () => { }, }, }, - }, + }), + doctorFixCommand: "openclaw doctor --fix", }); - expect(hosts.map((host) => host.host.id).toSorted()).toEqual([ - "codex-app-server", - "openclaw-embedded", - ]); + expect(warnings.join("\n")).toContain("OpenClaw embedded runner"); + expect(warnings.join("\n")).toContain("Some configured runtimes support"); + expect(warnings.join("\n")).not.toContain("Codex app-server harness ("); }); it("does not warn for context engines without host requirements", async () => { diff --git a/src/commands/doctor/shared/context-engine-host-compat.ts b/src/commands/doctor/shared/context-engine-host-compat.ts index 735778e1a2c5..5a7849195503 100644 --- a/src/commands/doctor/shared/context-engine-host-compat.ts +++ b/src/commands/doctor/shared/context-engine-host-compat.ts @@ -191,7 +191,7 @@ function runtimeHostCandidate(params: { } /** Collect effective agent-run host candidates from provider/model runtime policy. */ -export function collectConfiguredContextEngineAgentRunHosts(params: { +function collectConfiguredContextEngineAgentRunHosts(params: { cfg: OpenClawConfig; env?: NodeJS.ProcessEnv; }): HostCandidate[] { diff --git a/src/commands/doctor/shared/plugin-registry-migration.test.ts b/src/commands/doctor/shared/plugin-registry-migration.test.ts index e057f55069b7..69c47ae1e09c 100644 --- a/src/commands/doctor/shared/plugin-registry-migration.test.ts +++ b/src/commands/doctor/shared/plugin-registry-migration.test.ts @@ -16,11 +16,11 @@ import { import { runOpenClawStateWriteTransaction } from "../../../state/openclaw-state-db.js"; import { DISABLE_PLUGIN_REGISTRY_MIGRATION_ENV, - FORCE_PLUGIN_REGISTRY_MIGRATION_ENV, migratePluginRegistryForInstall, preflightPluginRegistryInstallMigration, } from "./plugin-registry-migration.js"; +const FORCE_PLUGIN_REGISTRY_MIGRATION_ENV = "OPENCLAW_FORCE_PLUGIN_REGISTRY_MIGRATION"; const tempDirs: string[] = []; afterEach(() => { diff --git a/src/commands/doctor/shared/plugin-registry-migration.ts b/src/commands/doctor/shared/plugin-registry-migration.ts index a39aa619afb7..de7a95d76b0f 100644 --- a/src/commands/doctor/shared/plugin-registry-migration.ts +++ b/src/commands/doctor/shared/plugin-registry-migration.ts @@ -25,7 +25,7 @@ import { loadPluginManifestRegistryForInstalledIndex } from "../../../plugins/ma import type { PluginManifestRecord } from "../../../plugins/manifest-registry.js"; export const DISABLE_PLUGIN_REGISTRY_MIGRATION_ENV = "OPENCLAW_DISABLE_PLUGIN_REGISTRY_MIGRATION"; -export const FORCE_PLUGIN_REGISTRY_MIGRATION_ENV = "OPENCLAW_FORCE_PLUGIN_REGISTRY_MIGRATION"; +const FORCE_PLUGIN_REGISTRY_MIGRATION_ENV = "OPENCLAW_FORCE_PLUGIN_REGISTRY_MIGRATION"; const DOCTOR_PLUGIN_ID_ALIASES: Readonly> = { openai: ["openai-codex"], }; diff --git a/src/commands/doctor/shared/preview-warnings.test.ts b/src/commands/doctor/shared/preview-warnings.test.ts index 5468d274891c..b7acb5ecc5d0 100644 --- a/src/commands/doctor/shared/preview-warnings.test.ts +++ b/src/commands/doctor/shared/preview-warnings.test.ts @@ -5,12 +5,7 @@ import path from "node:path"; import { expectDefined } from "@openclaw/normalization-core"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../../config/config.js"; -import { - collectDoctorPreviewNotes, - collectChannelBoundMessageToolPolicyWarnings, - collectProfileConfiguredToolSectionWarnings, - collectVisibleReplyToolPolicyWarnings, -} from "./preview-warnings.js"; +import { collectDoctorPreviewNotes } from "./preview-warnings.js"; async function collectDoctorPreviewWarnings( params: Parameters[0], @@ -18,6 +13,36 @@ async function collectDoctorPreviewWarnings( return (await collectDoctorPreviewNotes(params)).warningNotes; } +async function collectProfileConfiguredToolSectionWarningsThroughDoctor( + cfg: OpenClawConfig, +): Promise { + const warnings = await collectDoctorPreviewWarnings({ + cfg, + doctorFixCommand: "openclaw doctor --fix", + }); + return warnings.filter((warning) => warning.includes("is configured, but configured sections")); +} + +async function collectVisibleReplyToolPolicyWarningsThroughDoctor( + cfg: OpenClawConfig, +): Promise { + const warnings = await collectDoctorPreviewWarnings({ + cfg, + doctorFixCommand: "openclaw doctor --fix", + }); + return warnings.filter((warning) => warning.includes("visibleReplies is set")); +} + +async function collectChannelBoundMessageToolPolicyWarningsThroughDoctor( + cfg: OpenClawConfig, +): Promise { + const warnings = await collectDoctorPreviewWarnings({ + cfg, + doctorFixCommand: "openclaw doctor --fix", + }); + return warnings.filter((warning) => warning.includes("is routed from channel")); +} + type TestManifestRecord = { id: string; channels: string[]; @@ -861,8 +886,8 @@ describe("doctor preview warnings", () => { expect(warning).not.toContain("doctor --fix"); }); - it("does not suggest alsoAllow when configured section warnings already have allow", () => { - const warnings = collectProfileConfiguredToolSectionWarnings({ + it("does not suggest alsoAllow when configured section warnings already have allow", async () => { + const warnings = await collectProfileConfiguredToolSectionWarningsThroughDoctor({ tools: { profile: "messaging", }, @@ -887,8 +912,8 @@ describe("doctor preview warnings", () => { expect(warning).not.toContain("agents.list[0].tools.alsoAllow"); }); - it("warns when an agent tool section inherits a restrictive provider profile", () => { - const warnings = collectProfileConfiguredToolSectionWarnings({ + it("warns when an agent tool section inherits a restrictive provider profile", async () => { + const warnings = await collectProfileConfiguredToolSectionWarningsThroughDoctor({ tools: { byProvider: { openai: { @@ -920,8 +945,8 @@ describe("doctor preview warnings", () => { ); }); - it("uses inherited provider alsoAllow for agent provider profile warnings", () => { - const warnings = collectProfileConfiguredToolSectionWarnings({ + it("uses inherited provider alsoAllow for agent provider profile warnings", async () => { + const warnings = await collectProfileConfiguredToolSectionWarningsThroughDoctor({ tools: { byProvider: { openai: { @@ -951,8 +976,8 @@ describe("doctor preview warnings", () => { expect(warnings).toStrictEqual([]); }); - it("uses model-scoped agent provider overrides for inherited provider warnings", () => { - const warnings = collectProfileConfiguredToolSectionWarnings({ + it("uses model-scoped agent provider overrides for inherited provider warnings", async () => { + const warnings = await collectProfileConfiguredToolSectionWarningsThroughDoctor({ tools: { byProvider: { openai: { @@ -985,8 +1010,8 @@ describe("doctor preview warnings", () => { expect(warnings).toStrictEqual([]); }); - it("treats empty provider alsoAllow as an explicit inherited-profile override", () => { - const warnings = collectProfileConfiguredToolSectionWarnings({ + it("treats empty provider alsoAllow as an explicit inherited-profile override", async () => { + const warnings = await collectProfileConfiguredToolSectionWarningsThroughDoctor({ tools: { byProvider: { openai: { @@ -1023,8 +1048,8 @@ describe("doctor preview warnings", () => { ); }); - it("does not warn for configured tool sections already granted by explicit alsoAllow", () => { - const warnings = collectProfileConfiguredToolSectionWarnings({ + it("does not warn for configured tool sections already granted by explicit alsoAllow", async () => { + const warnings = await collectProfileConfiguredToolSectionWarningsThroughDoctor({ tools: { profile: "messaging", alsoAllow: ["exec", "process"], @@ -1037,7 +1062,7 @@ describe("doctor preview warnings", () => { expect(warnings).toStrictEqual([]); }); - it("does not warn for configured tool sections when the profile id is unknown", () => { + it("does not warn for configured tool sections when the profile id is unknown", async () => { const malformedConfig = { tools: { profile: "custom-profile", @@ -1067,15 +1092,16 @@ describe("doctor preview warnings", () => { }, ], }, - } as unknown as Parameters[0]; + } as unknown as OpenClawConfig; - const warnings = collectProfileConfiguredToolSectionWarnings(malformedConfig); + const warnings = + await collectProfileConfiguredToolSectionWarningsThroughDoctor(malformedConfig); expect(warnings).toStrictEqual([]); }); - it("does not warn when default group visible replies are automatic", () => { - const warnings = collectVisibleReplyToolPolicyWarnings({ + it("does not warn when default group visible replies are automatic", async () => { + const warnings = await collectVisibleReplyToolPolicyWarningsThroughDoctor({ channels: { slack: {}, }, @@ -1087,8 +1113,8 @@ describe("doctor preview warnings", () => { expect(warnings).toStrictEqual([]); }); - it("warns strongly when explicit group visible replies require an unavailable message tool", () => { - const warnings = collectVisibleReplyToolPolicyWarnings({ + it("warns strongly when explicit group visible replies require an unavailable message tool", async () => { + const warnings = await collectVisibleReplyToolPolicyWarningsThroughDoctor({ messages: { groupChat: { visibleReplies: "message_tool", @@ -1107,7 +1133,7 @@ describe("doctor preview warnings", () => { expect(warning).toContain('set messages.groupChat.visibleReplies to "automatic"'); }); - it("does not warn when source reply delivery grants message at runtime", () => { + it("does not warn when source reply delivery grants message at runtime", async () => { const cfg = { agents: { defaults: { @@ -1135,11 +1161,11 @@ describe("doctor preview warnings", () => { }, } satisfies OpenClawConfig; - expect(collectVisibleReplyToolPolicyWarnings(cfg)).toStrictEqual([]); - expect(collectChannelBoundMessageToolPolicyWarnings(cfg)).toStrictEqual([]); + expect(await collectVisibleReplyToolPolicyWarningsThroughDoctor(cfg)).toStrictEqual([]); + expect(await collectChannelBoundMessageToolPolicyWarningsThroughDoctor(cfg)).toStrictEqual([]); }); - it("still warns when provider policy blocks the runtime message grant", () => { + it("still warns when provider policy blocks the runtime message grant", async () => { const cfg = { agents: { defaults: { @@ -1171,15 +1197,15 @@ describe("doctor preview warnings", () => { }, } satisfies OpenClawConfig; - expectWarningsContaining(collectVisibleReplyToolPolicyWarnings(cfg), [ + expectWarningsContaining(await collectVisibleReplyToolPolicyWarningsThroughDoctor(cfg), [ 'messages.groupChat.visibleReplies is set to "message_tool"', ]); - expect(collectChannelBoundMessageToolPolicyWarnings(cfg)).toEqual([ + expect(await collectChannelBoundMessageToolPolicyWarningsThroughDoctor(cfg)).toEqual([ '- Agent "main" is routed from channel "discord", but the message tool is unavailable for that agent; explicit channel actions such as sendAttachment, upload-file, thread-reply, or reply can fail. Add "message" to the agent tool allowlist, add "group:messaging", or switch the agent to a profile that includes messaging tools.', ]); }); - it("keeps provider-specific message grants when checking provider policy", () => { + it("keeps provider-specific message grants when checking provider policy", async () => { const cfg = { agents: { defaults: { @@ -1211,12 +1237,12 @@ describe("doctor preview warnings", () => { }, } satisfies OpenClawConfig; - expect(collectVisibleReplyToolPolicyWarnings(cfg)).toStrictEqual([]); - expect(collectChannelBoundMessageToolPolicyWarnings(cfg)).toStrictEqual([]); + expect(await collectVisibleReplyToolPolicyWarningsThroughDoctor(cfg)).toStrictEqual([]); + expect(await collectChannelBoundMessageToolPolicyWarningsThroughDoctor(cfg)).toStrictEqual([]); }); - it("warns for direct chats when global visible replies are tool-only but groups override automatic", () => { - const warnings = collectVisibleReplyToolPolicyWarnings({ + it("warns for direct chats when global visible replies are tool-only but groups override automatic", async () => { + const warnings = await collectVisibleReplyToolPolicyWarningsThroughDoctor({ messages: { visibleReplies: "message_tool", groupChat: { @@ -1235,8 +1261,8 @@ describe("doctor preview warnings", () => { expect(warning).toContain("automatic direct-chat replies"); }); - it("warns separately for explicit global and group visible reply policy mismatches", () => { - const warnings = collectVisibleReplyToolPolicyWarnings({ + it("warns separately for explicit global and group visible reply policy mismatches", async () => { + const warnings = await collectVisibleReplyToolPolicyWarningsThroughDoctor({ messages: { visibleReplies: "message_tool", groupChat: { @@ -1254,9 +1280,9 @@ describe("doctor preview warnings", () => { ]); }); - it("skips visible reply tool warnings when the message tool is available or default groups are unused", () => { + it("skips visible reply tool warnings when the message tool is available or default groups are unused", async () => { expect( - collectVisibleReplyToolPolicyWarnings({ + await collectVisibleReplyToolPolicyWarningsThroughDoctor({ channels: { slack: {}, }, @@ -1266,7 +1292,7 @@ describe("doctor preview warnings", () => { }), ).toStrictEqual([]); expect( - collectVisibleReplyToolPolicyWarnings({ + await collectVisibleReplyToolPolicyWarningsThroughDoctor({ tools: { allow: ["read"], }, @@ -1274,8 +1300,8 @@ describe("doctor preview warnings", () => { ).toStrictEqual([]); }); - it("warns when a channel route targets an agent without the message tool", () => { - const warnings = collectChannelBoundMessageToolPolicyWarnings({ + it("warns when a channel route targets an agent without the message tool", async () => { + const warnings = await collectChannelBoundMessageToolPolicyWarningsThroughDoctor({ agents: { list: [ { @@ -1314,8 +1340,8 @@ describe("doctor preview warnings", () => { expect(warnings.join("\n")).not.toContain("support"); }); - it("warns for the default agent when configured channels have no explicit routes", () => { - const warnings = collectChannelBoundMessageToolPolicyWarnings({ + it("warns for the default agent when configured channels have no explicit routes", async () => { + const warnings = await collectChannelBoundMessageToolPolicyWarningsThroughDoctor({ channels: { defaults: { groupPolicy: "allowlist", @@ -1338,8 +1364,8 @@ describe("doctor preview warnings", () => { expect(warnings.join("\n")).not.toContain("defaults"); }); - it("warns only for configured channels not covered by channel routes", () => { - const warnings = collectChannelBoundMessageToolPolicyWarnings({ + it("warns only for configured channels not covered by channel routes", async () => { + const warnings = await collectChannelBoundMessageToolPolicyWarningsThroughDoctor({ channels: { discord: {}, telegram: {}, @@ -1378,8 +1404,8 @@ describe("doctor preview warnings", () => { expect(warnings.join("\n")).not.toContain("commander"); }); - it("warns for default-routed traffic when a channel only has scoped routes", () => { - const warnings = collectChannelBoundMessageToolPolicyWarnings({ + it("warns for default-routed traffic when a channel only has scoped routes", async () => { + const warnings = await collectChannelBoundMessageToolPolicyWarningsThroughDoctor({ channels: { discord: {}, }, @@ -1417,8 +1443,8 @@ describe("doctor preview warnings", () => { expect(warnings.join("\n")).not.toContain("commander"); }); - it("skips the default-agent warning when a wildcard account route covers the channel", () => { - const warnings = collectChannelBoundMessageToolPolicyWarnings({ + it("skips the default-agent warning when a wildcard account route covers the channel", async () => { + const warnings = await collectChannelBoundMessageToolPolicyWarningsThroughDoctor({ channels: { discord: {}, }, @@ -1453,8 +1479,8 @@ describe("doctor preview warnings", () => { expect(warnings).toStrictEqual([]); }); - it("skips the default-agent warning when configured accounts are fully covered", () => { - const warnings = collectChannelBoundMessageToolPolicyWarnings({ + it("skips the default-agent warning when configured accounts are fully covered", async () => { + const warnings = await collectChannelBoundMessageToolPolicyWarningsThroughDoctor({ channels: { discord: { accounts: { @@ -1507,8 +1533,8 @@ describe("doctor preview warnings", () => { expect(warnings).toStrictEqual([]); }); - it("does not treat channel aliases as route coverage when runtime would not match them", () => { - const warnings = collectChannelBoundMessageToolPolicyWarnings({ + it("does not treat channel aliases as route coverage when runtime would not match them", async () => { + const warnings = await collectChannelBoundMessageToolPolicyWarningsThroughDoctor({ channels: { imessage: {}, }, @@ -1546,8 +1572,8 @@ describe("doctor preview warnings", () => { expect(warnings.join("\n")).not.toContain("imsg"); }); - it("warns for the default agent when configured account routes are incomplete", () => { - const warnings = collectChannelBoundMessageToolPolicyWarnings({ + it("warns for the default agent when configured account routes are incomplete", async () => { + const warnings = await collectChannelBoundMessageToolPolicyWarningsThroughDoctor({ channels: { discord: { accounts: { diff --git a/src/commands/doctor/shared/preview-warnings.ts b/src/commands/doctor/shared/preview-warnings.ts index 8782c5286d6c..048939132068 100644 --- a/src/commands/doctor/shared/preview-warnings.ts +++ b/src/commands/doctor/shared/preview-warnings.ts @@ -234,7 +234,7 @@ function formatTargets(targets: string[]): string { } /** Warn when visible-reply policy selects message_tool but message is unavailable. */ -export function collectVisibleReplyToolPolicyWarnings(cfg: OpenClawConfig): string[] { +function collectVisibleReplyToolPolicyWarnings(cfg: OpenClawConfig): string[] { const groupPolicy = resolveGroupVisibleReplyProvenance(cfg); const warnings: string[] = []; if (groupPolicy.value === "message_tool") { @@ -275,7 +275,7 @@ function formatChannelList(channels: string[]): string { } /** Warn when routed channel agents lack the message tool required for channel actions. */ -export function collectChannelBoundMessageToolPolicyWarnings(cfg: OpenClawConfig): string[] { +function collectChannelBoundMessageToolPolicyWarnings(cfg: OpenClawConfig): string[] { return collectChannelRouteTargets(cfg).flatMap((target) => { const agentTools = resolveAgentConfig(cfg, target.agentId)?.tools; const runtimeMayAllowMessage = sourceReplyRuntimeMayAllowMessageTool(cfg); @@ -601,7 +601,7 @@ function collectInheritedByProviderConfiguredToolSectionWarnings(params: { } /** Warn when configured tool sections no longer widen restrictive tool profiles. */ -export function collectProfileConfiguredToolSectionWarnings(cfg: OpenClawConfig): string[] { +function collectProfileConfiguredToolSectionWarnings(cfg: OpenClawConfig): string[] { const warnings: string[] = []; const globalTools = hasRecord(cfg.tools) ? cfg.tools : undefined; const globalAlsoAllow = Array.isArray(globalTools?.alsoAllow) diff --git a/src/commands/doctor/shared/release-configured-plugin-installs.test.ts b/src/commands/doctor/shared/release-configured-plugin-installs.test.ts index 065910656eaa..ea333027a984 100644 --- a/src/commands/doctor/shared/release-configured-plugin-installs.test.ts +++ b/src/commands/doctor/shared/release-configured-plugin-installs.test.ts @@ -1,5 +1,6 @@ // Release configured plugin install tests cover doctor checks for release-time plugin installs. import { beforeEach, describe, expect, it, vi } from "vitest"; +import { maybeRunConfiguredPluginInstallReleaseStep } from "./release-configured-plugin-installs.js"; const mocks = vi.hoisted(() => ({ detectPluginAutoEnableCandidates: vi.fn(), @@ -49,6 +50,38 @@ function readOnlyMissingPluginInstallRepairCall(): MissingPluginInstallRepairCal return call; } +async function shouldRunConfiguredPluginInstallReleaseStepThroughDoctor(params: { + currentVersion?: string | null; + touchedVersion?: string | null; +}): Promise { + const result = await maybeRunConfiguredPluginInstallReleaseStep({ + cfg: {}, + env: {}, + ...params, + }); + return result.completed; +} + +async function collectReleaseConfiguredPluginIdsThroughDoctor(params: { + cfg: Parameters[0]["cfg"]; + env?: NodeJS.ProcessEnv; +}): Promise<{ pluginIds: string[]; channelIds: string[] }> { + mocks.repairMissingPluginInstallsForIds.mockClear(); + await maybeRunConfiguredPluginInstallReleaseStep({ + ...params, + currentVersion: "2026.5.2", + touchedVersion: "2026.5.1", + }); + const calls = mocks.repairMissingPluginInstallsForIds.mock.calls as unknown as Array< + [MissingPluginInstallRepairCall] + >; + const call = calls[0]?.[0]; + return { + pluginIds: call?.pluginIds ?? [], + channelIds: call?.channelIds ?? [], + }; +} + vi.mock("../../../config/plugin-auto-enable.js", () => ({ detectPluginAutoEnableCandidates: mocks.detectPluginAutoEnableCandidates, })); @@ -82,41 +115,38 @@ describe("configured plugin install release step", () => { }); it("runs only for configs last touched before 2026.5.2", async () => { - const { shouldRunConfiguredPluginInstallReleaseStep } = - await import("./release-configured-plugin-installs.js"); - expect( - shouldRunConfiguredPluginInstallReleaseStep({ + await shouldRunConfiguredPluginInstallReleaseStepThroughDoctor({ currentVersion: "2026.5.1", touchedVersion: "2026.4.30", }), ).toBe(false); expect( - shouldRunConfiguredPluginInstallReleaseStep({ + await shouldRunConfiguredPluginInstallReleaseStepThroughDoctor({ currentVersion: "2026.5.2-beta.1", touchedVersion: "2026.5.1", }), ).toBe(true); expect( - shouldRunConfiguredPluginInstallReleaseStep({ + await shouldRunConfiguredPluginInstallReleaseStepThroughDoctor({ currentVersion: "2026.5.2", touchedVersion: "2026.5.1", }), ).toBe(true); expect( - shouldRunConfiguredPluginInstallReleaseStep({ + await shouldRunConfiguredPluginInstallReleaseStepThroughDoctor({ currentVersion: "2026.5.2", touchedVersion: "2026.5.2", }), ).toBe(false); expect( - shouldRunConfiguredPluginInstallReleaseStep({ + await shouldRunConfiguredPluginInstallReleaseStepThroughDoctor({ currentVersion: "2026.5.3", touchedVersion: "2026.5.3", }), ).toBe(false); expect( - shouldRunConfiguredPluginInstallReleaseStep({ + await shouldRunConfiguredPluginInstallReleaseStepThroughDoctor({ currentVersion: "2026.5.2", touchedVersion: "not-a-version", }), @@ -139,10 +169,7 @@ describe("configured plugin install release step", () => { providerId: "unused", }, ]); - - const { collectReleaseConfiguredPluginIds } = - await import("./release-configured-plugin-installs.js"); - const result = collectReleaseConfiguredPluginIds({ + const result = await collectReleaseConfiguredPluginIdsThroughDoctor({ cfg: { auth: { profiles: { @@ -182,9 +209,7 @@ describe("configured plugin install release step", () => { }); it("collects Codex from the configured agent runtime even without integration discovery", async () => { - const { collectReleaseConfiguredPluginIds } = - await import("./release-configured-plugin-installs.js"); - const result = collectReleaseConfiguredPluginIds({ + const result = await collectReleaseConfiguredPluginIdsThroughDoctor({ cfg: { agents: { defaults: { @@ -210,10 +235,7 @@ describe("configured plugin install release step", () => { providerId: "anthropic", }, ]); - - const { collectReleaseConfiguredPluginIds } = - await import("./release-configured-plugin-installs.js"); - const result = collectReleaseConfiguredPluginIds({ + const result = await collectReleaseConfiguredPluginIdsThroughDoctor({ cfg: { channels: { modelByChannel: { @@ -237,10 +259,7 @@ describe("configured plugin install release step", () => { providerId: "anthropic", }, ]); - - const { collectReleaseConfiguredPluginIds } = - await import("./release-configured-plugin-installs.js"); - const result = collectReleaseConfiguredPluginIds({ + const result = await collectReleaseConfiguredPluginIdsThroughDoctor({ cfg: { channels: { modelByChannel: { @@ -258,9 +277,7 @@ describe("configured plugin install release step", () => { }); it("collects external speech and web-fetch plugins selected by config", async () => { - const { collectReleaseConfiguredPluginIds } = - await import("./release-configured-plugin-installs.js"); - const result = collectReleaseConfiguredPluginIds({ + const result = await collectReleaseConfiguredPluginIdsThroughDoctor({ cfg: { agents: { defaults: { @@ -291,9 +308,7 @@ describe("configured plugin install release step", () => { }); it("collects an external media-understanding plugin selected only by media config", async () => { - const { collectReleaseConfiguredPluginIds } = - await import("./release-configured-plugin-installs.js"); - const result = collectReleaseConfiguredPluginIds({ + const result = await collectReleaseConfiguredPluginIdsThroughDoctor({ cfg: { tools: { media: { @@ -311,9 +326,7 @@ describe("configured plugin install release step", () => { }); it("collects an external speech plugin selected only by voiceModel", async () => { - const { collectReleaseConfiguredPluginIds } = - await import("./release-configured-plugin-installs.js"); - const result = collectReleaseConfiguredPluginIds({ + const result = await collectReleaseConfiguredPluginIdsThroughDoctor({ cfg: { agents: { defaults: { @@ -329,9 +342,7 @@ describe("configured plugin install release step", () => { }); it("collects env-only web provider plugins before auto-detection", async () => { - const { collectReleaseConfiguredPluginIds } = - await import("./release-configured-plugin-installs.js"); - const result = collectReleaseConfiguredPluginIds({ + const result = await collectReleaseConfiguredPluginIdsThroughDoctor({ cfg: {}, env: { EXA_API_KEY: "exa-key", @@ -344,9 +355,7 @@ describe("configured plugin install release step", () => { }); it("does not collect env-only web provider plugins when search is disabled", async () => { - const { collectReleaseConfiguredPluginIds } = - await import("./release-configured-plugin-installs.js"); - const result = collectReleaseConfiguredPluginIds({ + const result = await collectReleaseConfiguredPluginIdsThroughDoctor({ cfg: { tools: { web: { @@ -370,9 +379,7 @@ describe("configured plugin install release step", () => { }); it("collects Firecrawl for env-only web fetch when search is disabled", async () => { - const { collectReleaseConfiguredPluginIds } = - await import("./release-configured-plugin-installs.js"); - const result = collectReleaseConfiguredPluginIds({ + const result = await collectReleaseConfiguredPluginIdsThroughDoctor({ cfg: { tools: { web: { @@ -392,9 +399,7 @@ describe("configured plugin install release step", () => { }); it("collects env-only external provider plugins before model discovery", async () => { - const { collectReleaseConfiguredPluginIds } = - await import("./release-configured-plugin-installs.js"); - const result = collectReleaseConfiguredPluginIds({ + const result = await collectReleaseConfiguredPluginIdsThroughDoctor({ cfg: {}, env: { GROQ_API_KEY: "groq-key", @@ -414,10 +419,7 @@ describe("configured plugin install release step", () => { providerAliases: ["gmi-cloud", "gmicloud"], }, ]); - - const { collectReleaseConfiguredPluginIds } = - await import("./release-configured-plugin-installs.js"); - const result = collectReleaseConfiguredPluginIds({ + const result = await collectReleaseConfiguredPluginIdsThroughDoctor({ cfg: { agents: { defaults: { @@ -449,9 +451,7 @@ describe("configured plugin install release step", () => { }); it("collects Codex from selectable OpenAI agent models even without integration discovery", async () => { - const { collectReleaseConfiguredPluginIds } = - await import("./release-configured-plugin-installs.js"); - const result = collectReleaseConfiguredPluginIds({ + const result = await collectReleaseConfiguredPluginIdsThroughDoctor({ cfg: { agents: { defaults: { @@ -470,9 +470,7 @@ describe("configured plugin install release step", () => { }); it("collects external web search and ACP runtime plugins from config-only usage", async () => { - const { collectReleaseConfiguredPluginIds } = - await import("./release-configured-plugin-installs.js"); - const result = collectReleaseConfiguredPluginIds({ + const result = await collectReleaseConfiguredPluginIdsThroughDoctor({ cfg: { acp: { enabled: true, @@ -494,43 +492,42 @@ describe("configured plugin install release step", () => { }); it("does not collect channel ids when the matching plugin id is blocked", async () => { - const { collectReleaseConfiguredPluginIds } = - await import("./release-configured-plugin-installs.js"); - expect( - collectReleaseConfiguredPluginIds({ - cfg: { - channels: { - matrix: { accessToken: "test" }, + ( + await collectReleaseConfiguredPluginIdsThroughDoctor({ + cfg: { + channels: { + matrix: { accessToken: "test" }, + }, + plugins: { + deny: ["matrix"], + }, }, - plugins: { - deny: ["matrix"], - }, - }, - env: {}, - }).channelIds, + env: {}, + }) + ).channelIds, ).toStrictEqual([]); expect( - collectReleaseConfiguredPluginIds({ - cfg: { - channels: { - matrix: { accessToken: "test" }, - }, - plugins: { - entries: { - matrix: { enabled: false }, + ( + await collectReleaseConfiguredPluginIdsThroughDoctor({ + cfg: { + channels: { + matrix: { accessToken: "test" }, + }, + plugins: { + entries: { + matrix: { enabled: false }, + }, }, }, - }, - env: {}, - }).channelIds, + env: {}, + }) + ).channelIds, ).toStrictEqual([]); }); it("marks the release step complete when there is nothing to install", async () => { - const { maybeRunConfiguredPluginInstallReleaseStep } = - await import("./release-configured-plugin-installs.js"); const result = await maybeRunConfiguredPluginInstallReleaseStep({ cfg: {}, currentVersion: "2026.5.2", @@ -552,9 +549,6 @@ describe("configured plugin install release step", () => { changes: ['Installed missing configured plugin "codex".'], warnings: [], }); - - const { maybeRunConfiguredPluginInstallReleaseStep } = - await import("./release-configured-plugin-installs.js"); const result = await maybeRunConfiguredPluginInstallReleaseStep({ cfg: { agents: { @@ -584,9 +578,6 @@ describe("configured plugin install release step", () => { warnings: [], notices: [reviewNotice], }); - - const { maybeRunConfiguredPluginInstallReleaseStep } = - await import("./release-configured-plugin-installs.js"); const result = await maybeRunConfiguredPluginInstallReleaseStep({ cfg: { agents: { @@ -619,9 +610,6 @@ describe("configured plugin install release step", () => { 'Skipped package-manager repair for configured plugin "codex" during package update; rerun "openclaw doctor --fix" after the update completes.', ], }); - - const { maybeRunConfiguredPluginInstallReleaseStep } = - await import("./release-configured-plugin-installs.js"); const result = await maybeRunConfiguredPluginInstallReleaseStep({ cfg: { agents: { @@ -670,9 +658,6 @@ describe("configured plugin install release step", () => { changes: ['Removed stale managed install record for bundled plugin "matrix".'], warnings: [], }); - - const { maybeRunConfiguredPluginInstallReleaseStep } = - await import("./release-configured-plugin-installs.js"); const result = await maybeRunConfiguredPluginInstallReleaseStep({ cfg: { plugins: { @@ -707,9 +692,6 @@ describe("configured plugin install release step", () => { 'Skipped package-manager repair for configured plugin "discord" during package update; rerun "openclaw doctor --fix" after the update completes.', ], }); - - const { maybeRunConfiguredPluginInstallReleaseStep } = - await import("./release-configured-plugin-installs.js"); const result = await maybeRunConfiguredPluginInstallReleaseStep({ cfg: { plugins: { @@ -755,9 +737,6 @@ describe("configured plugin install release step", () => { changes: ['Installed missing configured plugin "discord".'], warnings: [], }); - - const { maybeRunConfiguredPluginInstallReleaseStep } = - await import("./release-configured-plugin-installs.js"); const result = await maybeRunConfiguredPluginInstallReleaseStep({ cfg: { plugins: { @@ -788,9 +767,6 @@ describe("configured plugin install release step", () => { changes: ['Installed missing configured channel plugin "whatsapp".'], warnings: [], }); - - const { maybeRunConfiguredPluginInstallReleaseStep } = - await import("./release-configured-plugin-installs.js"); const result = await maybeRunConfiguredPluginInstallReleaseStep({ cfg: { channels: { @@ -823,9 +799,6 @@ describe("configured plugin install release step", () => { changes: [], warnings: ["install failed"], }); - - const { maybeRunConfiguredPluginInstallReleaseStep } = - await import("./release-configured-plugin-installs.js"); const result = await maybeRunConfiguredPluginInstallReleaseStep({ cfg: {}, currentVersion: "2026.5.2", @@ -848,10 +821,7 @@ describe("configured plugin install release step", () => { } return undefined; }); - - const { collectReleaseConfiguredPluginIds } = - await import("./release-configured-plugin-installs.js"); - const result = collectReleaseConfiguredPluginIds({ + const result = await collectReleaseConfiguredPluginIdsThroughDoctor({ cfg: { plugins: { allow: ["lobster", "unofficial-custom"], @@ -871,10 +841,7 @@ describe("configured plugin install release step", () => { } return undefined; }); - - const { collectReleaseConfiguredPluginIds } = - await import("./release-configured-plugin-installs.js"); - const result = collectReleaseConfiguredPluginIds({ + const result = await collectReleaseConfiguredPluginIdsThroughDoctor({ cfg: { plugins: { allow: ["lobster"], diff --git a/src/commands/doctor/shared/release-configured-plugin-installs.ts b/src/commands/doctor/shared/release-configured-plugin-installs.ts index f7aa1ba88344..7c28e94ad933 100644 --- a/src/commands/doctor/shared/release-configured-plugin-installs.ts +++ b/src/commands/doctor/shared/release-configured-plugin-installs.ts @@ -255,7 +255,7 @@ function addEligiblePluginId(cfg: OpenClawConfig, pluginIds: Set, plugin } /** Return true when this config has not yet crossed the configured-plugin install release gate. */ -export function shouldRunConfiguredPluginInstallReleaseStep(params: { +function shouldRunConfiguredPluginInstallReleaseStep(params: { currentVersion?: string | null; touchedVersion?: string | null; releaseVersion?: string; @@ -273,7 +273,7 @@ export function shouldRunConfiguredPluginInstallReleaseStep(params: { } /** Collect plugin/channel ids implied by config for the release install backfill step. */ -export function collectReleaseConfiguredPluginIds(params: { +function collectReleaseConfiguredPluginIds(params: { cfg: OpenClawConfig; env?: NodeJS.ProcessEnv; }): ReleaseConfiguredPluginIds { diff --git a/src/commands/plugin-control-plane-cold-imports.test.ts b/src/commands/plugin-control-plane-cold-imports.test.ts index 9f00ce2c11d3..f3707d3ae796 100644 --- a/src/commands/plugin-control-plane-cold-imports.test.ts +++ b/src/commands/plugin-control-plane-cold-imports.test.ts @@ -8,7 +8,7 @@ import { isColdPluginRuntimeLoaded, } from "../plugins/test-helpers/cold-plugin-fixtures.js"; import { cleanupTrackedTempDirs, makeTrackedTempDir } from "../plugins/test-helpers/fs-fixtures.js"; -import { buildAuthChoiceOptions, formatAuthChoiceChoicesForCli } from "./auth-choice-options.js"; +import { buildAuthChoiceGroups, formatAuthChoiceChoicesForCli } from "./auth-choice-options.js"; import { listManifestInstalledChannelIds } from "./channel-setup/discovery.js"; import { resolveProviderCatalogPluginIdsForFilter } from "./models/list.provider-catalog.js"; @@ -45,13 +45,15 @@ describe("command control-plane plugin discovery", () => { const cfg = createColdPluginConfig(plugin.rootDir, plugin.pluginId); const env = createColdPluginHermeticEnv(workspaceDir); - const authChoice = buildAuthChoiceOptions({ + const authChoice = buildAuthChoiceGroups({ store: {} as never, includeSkip: false, config: cfg, workspaceDir, env, - }).find((choice) => choice.value === plugin.authChoiceId); + }) + .groups.flatMap((group) => group.options) + .find((choice) => choice.value === plugin.authChoiceId); expect(authChoice?.label).toBe("Cold Provider API key"); expect(authChoice?.groupId).toBe(plugin.providerId); expect(