refactor(commands): trim internal dead exports (#107621)

* refactor(commands): trim internal dead exports

* chore(deadcode): refresh commands export baseline
This commit is contained in:
Peter Steinberger
2026-07-14 08:36:41 -07:00
committed by GitHub
parent 5087804bc8
commit d092feaf63
33 changed files with 846 additions and 1144 deletions
-25
View File
@@ -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",
+3 -2
View File
@@ -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(
+1 -1
View File
@@ -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;
+2 -2
View File
@@ -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) {
+18 -24
View File
@@ -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();
+26 -37
View File
@@ -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<string>();
async function buildEnvironment(existingEnvironment: Record<string, string>) {
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();
+1 -1
View File
@@ -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<string, string | undefined> | undefined,
managedServiceEnvKeys: Set<string>,
): Record<string, string | undefined> {
+78 -51
View File
@@ -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", () => {
+2 -2
View File
@@ -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<string, string | undefined>;
+1 -79
View File
@@ -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({
+101 -1
View File
@@ -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");
},
);
});
+2 -2
View File
@@ -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;
+13 -2
View File
@@ -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("<root>");
+1 -1
View File
@@ -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 [];
+16 -2
View File
@@ -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<string[]> {
noteMock.mockClear();
await noteInstallPolicyHealth(cfg, options);
const body = noteMock.mock.calls.at(-1)?.[0];
return typeof body === "string" ? body.split("\n") : [];
}
const tempDirs: string[] = [];
+1 -1
View File
@@ -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<string[]> {
@@ -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<typeof import("../plugins/doctor-contract-registry.js")>(
"../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<Record<string, unknown>> }));
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<typeof import("../plugins/doctor-contract-registry.js")>(
"../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<string, unknown> = {
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<typeof storeMayContainPluginSessionRouteState>[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<typeof runPluginSessionStateDoctorRepairs>[0]["prompter"] = {
confirmRuntimeRepair: vi.fn(async () => true),
note: vi.fn(),
};
async function runDoctor(params: {
cfg: OpenClawConfig;
store: Record<string, SessionEntry>;
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<typeof runPluginSessionStateDoctorRepairs>[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<string, SessionEntry>,
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<string, unknown>): 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<string, unknown> = {
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<string, unknown> = {
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<string, unknown>;
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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {
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<string, unknown>;
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<string, SessionEntry> = {};
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<typeof runPluginSessionStateDoctorRepairs>[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<string, Record<string, unknown>> = {};
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<typeof runPluginSessionStateDoctorRepairs>[0]["prompter"] = {
confirmRuntimeRepair: vi.fn(async () => false),
note: vi.fn(),
};
const start = Date.now();
await runPluginSessionStateDoctorRepairs({
cfg,
store: store as unknown as Parameters<typeof runPluginSessionStateDoctorRepairs>[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<string, unknown>;
// 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();
});
});
@@ -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<string, SessionEntry>,
): boolean {
function storeMayContainPluginSessionRouteState(store: Record<string, SessionEntry>): 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<string, Record<string, unknown>>;
routes: Record<string, DoctorSessionRouteState>;
@@ -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<string, unknown>;
repair: DoctorSessionRouteStateRepair;
@@ -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();
+1 -1
View File
@@ -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<TranscriptRepairResult> {
+70 -91
View File
@@ -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<typeof import("../skills/discovery/status.js")>()),
buildWorkspaceSkillStatus: mocks.buildWorkspaceSkillStatus,
}));
vi.mock("../skills/lifecycle/gh-config-discovery.js", async (importActual) => ({
...(await importActual<typeof import("../skills/lifecycle/gh-config-discovery.js")>()),
detectGhConfigDirMismatch: mocks.detectGhConfigDirMismatch,
}));
vi.mock("../../packages/terminal-core/src/note.js", () => ({ note: mocks.note }));
function createSkill(overrides: Partial<SkillStatusEntry>): 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", () => {
+2 -2
View File
@@ -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).`,
+29 -1
View File
@@ -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<Record<string, unknown>>;
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({
+1 -1
View File
@@ -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<Record<string, unknown>>;
storePath: string;
resolveAvailableChannelIds: () => Iterable<string>;
@@ -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 () => {
@@ -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[] {
@@ -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(() => {
@@ -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<Record<string, readonly string[]>> = {
openai: ["openai-codex"],
};
@@ -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<typeof collectDoctorPreviewNotes>[0],
@@ -18,6 +13,36 @@ async function collectDoctorPreviewWarnings(
return (await collectDoctorPreviewNotes(params)).warningNotes;
}
async function collectProfileConfiguredToolSectionWarningsThroughDoctor(
cfg: OpenClawConfig,
): Promise<string[]> {
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<string[]> {
const warnings = await collectDoctorPreviewWarnings({
cfg,
doctorFixCommand: "openclaw doctor --fix",
});
return warnings.filter((warning) => warning.includes("visibleReplies is set"));
}
async function collectChannelBoundMessageToolPolicyWarningsThroughDoctor(
cfg: OpenClawConfig,
): Promise<string[]> {
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<typeof collectProfileConfiguredToolSectionWarnings>[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: {
@@ -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)
@@ -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<boolean> {
const result = await maybeRunConfiguredPluginInstallReleaseStep({
cfg: {},
env: {},
...params,
});
return result.completed;
}
async function collectReleaseConfiguredPluginIdsThroughDoctor(params: {
cfg: Parameters<typeof maybeRunConfiguredPluginInstallReleaseStep>[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"],
@@ -255,7 +255,7 @@ function addEligiblePluginId(cfg: OpenClawConfig, pluginIds: Set<string>, 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 {
@@ -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(