mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
refactor(commands): trim internal dead exports (#107621)
* refactor(commands): trim internal dead exports * chore(deadcode): refresh commands export baseline
This commit is contained in:
committed by
GitHub
parent
5087804bc8
commit
d092feaf63
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>");
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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).`,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user