fix(doctor): report managed plugin version drift

Fixes #90891.

Doctor now reports official managed plugin version drift from the daemon-local status path, using the probed running gateway version and suppressing the advisory when probe auth is skipped or unsafe. The status probe also avoids re-entering config-backed exec SecretRef credential resolution when exec refs are disabled.

Verification:
- `node scripts/run-vitest.mjs src/commands/agent-via-gateway.test.ts src/cli/daemon-cli/probe.test.ts src/cli/daemon-cli/status.gather.test.ts src/flows/doctor-health-contributions.test.ts src/commands/doctor-workspace-status.test.ts src/gateway/probe-auth.test.ts`
- `.agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main`
- Crabbox delegated Blacksmith Testbox `tbx_01ktmwa5q0c2eb688dkbkw8v2b`: `OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1 OPENCLAW_CHANGED_LANES_RAW_SYNC=1 corepack pnpm check:changed`
This commit is contained in:
brokemac79
2026-06-09 01:44:01 +01:00
committed by GitHub
parent 5b76436c45
commit 72e40833ba
11 changed files with 788 additions and 51 deletions
+5 -3
View File
@@ -12,7 +12,7 @@ const { callGateway } = vi.hoisted(() => ({
vi.mock("../gateway/call.js", () => ({ callGateway }));
vi.mock("../media/media-services.js", () => ({
buildImageResizeSideGrid: vi.fn(() => [1200]),
buildImageResizeSideGrid: vi.fn(() => [1600]),
getImageMetadata: vi.fn(async () => ({ width: 1, height: 1 })),
IMAGE_REDUCE_QUALITY_STEPS: [85],
isImageProcessorUnavailableError: vi.fn(() => false),
@@ -22,9 +22,11 @@ vi.mock("../media/media-services.js", () => ({
}));
const NODE_ID = "mac-1";
const TINY_JPEG_BASE64 =
"/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAP//////////////////////////////////////////////////////////////////////////////////////2wBDAf//////////////////////////////////////////////////////////////////////////////////////wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAX/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIQAxAAAAH/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/9oACAEBAAEFAqf/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oACAEDAQE/ASP/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oACAECAQE/ASP/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/9oACAEBAAY/Aqf/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/9oACAEBAAE/IV//2gAMAwEAAgADAAAAEP/EFBQRAQAAAAAAAAAAAAAAAAAAABD/2gAIAQMBAT8QH//EFBQRAQAAAAAAAAAAAAAAAAAAABD/2gAIAQIBAT8QH//EFBABAQAAAAAAAAAAAAAAAAAAABD/2gAIAQEAAT8QH//Z";
const JPG_PAYLOAD = {
format: "jpg",
base64: "aGVsbG8=",
base64: TINY_JPEG_BASE64,
width: 1,
height: 1,
} as const;
@@ -38,7 +40,7 @@ const PHOTOS_LATEST_PAYLOAD = {
photos: [
{
format: "jpeg",
base64: "aGVsbG8=",
base64: TINY_JPEG_BASE64,
width: 1,
height: 1,
createdAt: "2026-03-04T00:00:00Z",
+79
View File
@@ -213,6 +213,85 @@ describe("probeGatewayStatus", () => {
});
});
it("omits config-backed credentials from the status RPC when disabled", async () => {
callGatewayMock.mockReset();
probeGatewayMock.mockReset();
callGatewayMock.mockResolvedValueOnce({ status: "ok" });
probeGatewayMock.mockResolvedValueOnce({
ok: true,
auth: {
role: "operator",
scopes: ["operator.admin"],
capability: "admin_capable",
},
});
const config = {
gateway: {
auth: {
mode: "token",
token: { source: "exec", provider: "vault", id: "gateway/token" },
},
},
secrets: {
providers: {
vault: { source: "exec", command: "/bin/false" },
},
},
} as const;
await probeGatewayStatus({
url: "ws://127.0.0.1:19191",
token: "temp-token",
config,
timeoutMs: 5_000,
requireRpc: true,
allowRpcConfigCredentials: false,
});
expect(callGatewayMock).toHaveBeenCalledWith({
url: "ws://127.0.0.1:19191",
token: "temp-token",
password: undefined,
tlsFingerprint: undefined,
method: "status",
timeoutMs: 5_000,
});
});
it("fails before the status RPC when config credentials are disabled without explicit auth", async () => {
callGatewayMock.mockReset();
probeGatewayMock.mockReset();
const result = await probeGatewayStatus({
url: "ws://127.0.0.1:19191",
config: {
gateway: {
auth: {
mode: "token",
token: { source: "exec", provider: "vault", id: "gateway/token" },
},
},
secrets: {
providers: {
vault: { source: "exec", command: "/bin/false" },
},
},
},
timeoutMs: 5_000,
requireRpc: true,
allowRpcConfigCredentials: false,
});
expect(result).toEqual({
ok: false,
kind: "read",
error:
"gateway status RPC skipped because configured gateway credentials are disabled for this status request",
});
expect(callGatewayMock).not.toHaveBeenCalled();
expect(probeGatewayMock).not.toHaveBeenCalled();
});
it("falls back to read-only when the status RPC succeeds but the auth probe is inconclusive", async () => {
callGatewayMock.mockReset();
probeGatewayMock.mockReset();
+8 -1
View File
@@ -56,6 +56,7 @@ export async function probeGatewayStatus(opts: {
preauthHandshakeTimeoutMs?: number;
json?: boolean;
requireRpc?: boolean;
allowRpcConfigCredentials?: boolean;
configPath?: string;
}) {
const kind = (opts.requireRpc ? "read" : "connect") satisfies GatewayStatusProbeKind;
@@ -83,13 +84,19 @@ export async function probeGatewayStatus(opts: {
includeDetails: false,
};
if (opts.requireRpc) {
const allowRpcConfigCredentials = opts.allowRpcConfigCredentials !== false;
if (!allowRpcConfigCredentials && !opts.token && !opts.password) {
throw new Error(
"gateway status RPC skipped because configured gateway credentials are disabled for this status request",
);
}
const { callGateway } = await import("../../gateway/call.js");
const statusPayload = await callGateway({
url: opts.url,
token: opts.token,
password: opts.password,
tlsFingerprint: opts.tlsFingerprint,
...(opts.config ? { config: opts.config } : {}),
...(allowRpcConfigCredentials && opts.config ? { config: opts.config } : {}),
method: "status",
timeoutMs: opts.timeoutMs,
...(opts.configPath ? { configPath: opts.configPath } : {}),
+118
View File
@@ -26,6 +26,7 @@ const callGatewayStatusProbe = vi.fn<
error: null,
server: { version: "2026.5.6", connId: "conn-1" },
}));
const resolveGatewayProbeAuthSafeWithSecretInputsCalls = vi.fn<(opts?: unknown) => void>();
const loadGatewayTlsRuntime = vi.fn(async (_cfg?: unknown) => ({
enabled: true,
required: true,
@@ -182,6 +183,19 @@ vi.mock("../../gateway/net.js", () => ({
resolveGatewayBindHost(bindMode, customBindHost),
}));
vi.mock("../../gateway/probe-auth.js", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
resolveGatewayProbeAuthSafeWithSecretInputs: async (opts: unknown) => {
resolveGatewayProbeAuthSafeWithSecretInputsCalls(opts);
return await (
actual.resolveGatewayProbeAuthSafeWithSecretInputs as (opts: unknown) => Promise<unknown>
)(opts);
},
};
});
vi.mock("../../infra/ports.js", () => ({
inspectPortConnections: (port: number) => inspectPortConnections(port),
inspectPortUsage: (port: number) => inspectPortUsage(port),
@@ -243,6 +257,7 @@ describe("gatherDaemonStatus", () => {
delete process.env.DAEMON_GATEWAY_TOKEN;
delete process.env.DAEMON_GATEWAY_PASSWORD;
callGatewayStatusProbe.mockClear();
resolveGatewayProbeAuthSafeWithSecretInputsCalls.mockClear();
createConfigIOCalls.mockClear();
findStaleOpenClawUpdateLaunchdJobs.mockReset();
findStaleOpenClawUpdateLaunchdJobs.mockResolvedValue([]);
@@ -758,6 +773,109 @@ describe("gatherDaemonStatus", () => {
);
});
it("skips daemon exec SecretRef probe auth when exec refs are disabled", async () => {
daemonLoadedConfig = {
gateway: {
bind: "lan",
tls: { enabled: true },
auth: {
mode: "token",
token: { source: "exec", provider: "vault", id: "gateway/token" },
},
},
secrets: {
providers: {
vault: { source: "exec", command: "/bin/false" },
},
},
};
const status = await gatherDaemonStatus({
rpc: {},
probe: true,
deep: false,
allowExecSecretRefs: false,
});
expect(resolveGatewayProbeAuthSafeWithSecretInputsCalls).not.toHaveBeenCalled();
const probeInput = callArg(callGatewayStatusProbe) as {
token?: string;
password?: string;
allowRpcConfigCredentials?: boolean;
};
expect(probeInput.token).toBeUndefined();
expect(probeInput.password).toBeUndefined();
expect(probeInput.allowRpcConfigCredentials).toBe(false);
expect(status.rpc?.authWarning).toContain(
"gateway credentials use an exec SecretRef and exec SecretRefs are disabled",
);
});
it("ignores remote exec SecretRefs for local probes when exec refs are disabled", async () => {
daemonLoadedConfig = {
gateway: {
mode: "local",
bind: "lan",
tls: { enabled: true },
auth: { token: "daemon-token" },
remote: {
url: "wss://gateway.example",
token: { source: "exec", provider: "vault", id: "gateway/remote-token" },
},
},
secrets: {
providers: {
vault: { source: "exec", command: "/bin/false" },
},
},
};
await gatherDaemonStatus({
rpc: {},
probe: true,
deep: false,
allowExecSecretRefs: false,
});
expect(resolveGatewayProbeAuthSafeWithSecretInputsCalls).toHaveBeenCalledTimes(1);
const probeInput = callArg(callGatewayStatusProbe) as { token?: string; password?: string };
expect(probeInput.token).toBe("daemon-token");
expect(probeInput.password).toBeUndefined();
});
it("ignores local exec SecretRefs for remote probes when exec refs are disabled", async () => {
daemonLoadedConfig = {
gateway: {
mode: "remote",
remote: {
url: "wss://gateway.example",
},
auth: {
mode: "token",
token: { source: "exec", provider: "vault", id: "gateway/token" },
},
},
secrets: {
providers: {
vault: { source: "exec", command: "/bin/false" },
},
},
};
const status = await gatherDaemonStatus({
rpc: {},
probe: true,
deep: false,
allowExecSecretRefs: false,
});
expect(status.rpc?.authWarning).toBeUndefined();
expect(resolveGatewayProbeAuthSafeWithSecretInputsCalls).toHaveBeenCalledTimes(1);
const probeInput = callArg(callGatewayStatusProbe) as { token?: string; password?: string };
expect(probeInput.token).toBeUndefined();
expect(probeInput.password).toBeUndefined();
});
it("does not resolve daemon password SecretRef when token auth is configured", async () => {
daemonLoadedConfig = {
gateway: {
+73 -15
View File
@@ -14,13 +14,20 @@ import type {
GatewayBindMode,
GatewayControlUiConfig,
} from "../../config/types.js";
import { resolveSecretInputRef } from "../../config/types.secrets.js";
import { readLastGatewayErrorLine } from "../../daemon/diagnostics.js";
import type { FindExtraGatewayServicesOptions } from "../../daemon/inspect.js";
import type { StaleOpenClawUpdateLaunchdJob } from "../../daemon/launchd.js";
import type { ServiceConfigAudit } from "../../daemon/service-audit.js";
import type { GatewayServiceRuntime } from "../../daemon/service-runtime.js";
import { resolveGatewayService } from "../../daemon/service.js";
import { gatewaySecretInputPathCanWin } from "../../gateway/credentials-secret-inputs.js";
import { trimToUndefined } from "../../gateway/credentials.js";
import { resolveGatewayProbeCredentialConfig } from "../../gateway/probe-auth.js";
import {
ALL_GATEWAY_SECRET_INPUT_PATHS,
readGatewaySecretInputValue,
} from "../../gateway/secret-input-paths.js";
import {
inspectBestEffortPrimaryTailnetIPv4,
resolveBestEffortGatewayBindHostForDisplay,
@@ -501,12 +508,44 @@ async function inspectEstablishedGatewayClients(params: {
};
}
function hasActiveGatewayExecProbeCredential(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
explicitAuth: { token?: string; password?: string };
mode: "local" | "remote";
}): boolean {
const cfg = resolveGatewayProbeCredentialConfig({
cfg: params.cfg,
mode: params.mode,
});
return ALL_GATEWAY_SECRET_INPUT_PATHS.some((path) => {
if (
!gatewaySecretInputPathCanWin({
config: cfg,
env: params.env,
explicitAuth: params.explicitAuth,
modeOverride: params.mode,
path,
remoteTokenFallback: "remote-only",
})
) {
return false;
}
const ref = resolveSecretInputRef({
value: readGatewaySecretInputValue(cfg, path),
defaults: cfg.secrets?.defaults,
}).ref;
return ref?.source === "exec";
});
}
export async function gatherDaemonStatus(
opts: {
rpc: GatewayRpcOpts;
probe: boolean;
requireRpc?: boolean;
deep?: boolean;
allowExecSecretRefs?: boolean;
} & FindExtraGatewayServicesOptions,
): Promise<DaemonStatus> {
const service = resolveGatewayService();
@@ -588,22 +627,40 @@ export async function gatherDaemonStatus(
: undefined;
let daemonProbeAuth: { token?: string; password?: string } | undefined;
let rpcAuthWarning: string | undefined;
let allowRpcConfigCredentials = true;
let skippedProbeAuthForDisabledExecSecretRef = false;
if (opts.probe) {
const probeMode = daemonCfg.gateway?.mode === "remote" ? "remote" : "local";
const probeAuthResolution = await loadGatewayProbeAuthModule().then(
({ resolveGatewayProbeAuthSafeWithSecretInputs }) =>
resolveGatewayProbeAuthSafeWithSecretInputs({
cfg: daemonCfg,
mode: probeMode,
env: mergedDaemonEnv as NodeJS.ProcessEnv,
explicitAuth: {
token: opts.rpc.token,
password: opts.rpc.password,
},
}),
);
daemonProbeAuth = probeAuthResolution.auth;
rpcAuthWarning = probeAuthResolution.warning;
const explicitAuth = {
token: opts.rpc.token,
password: opts.rpc.password,
};
const canResolveProbeAuth =
opts.allowExecSecretRefs !== false ||
!hasActiveGatewayExecProbeCredential({
cfg: daemonCfg,
env: mergedDaemonEnv as NodeJS.ProcessEnv,
explicitAuth,
mode: probeMode,
});
if (canResolveProbeAuth) {
const probeAuthResolution = await loadGatewayProbeAuthModule().then(
({ resolveGatewayProbeAuthSafeWithSecretInputs }) =>
resolveGatewayProbeAuthSafeWithSecretInputs({
cfg: daemonCfg,
mode: probeMode,
env: mergedDaemonEnv as NodeJS.ProcessEnv,
explicitAuth,
}),
);
daemonProbeAuth = probeAuthResolution.auth;
rpcAuthWarning = probeAuthResolution.warning;
} else {
allowRpcConfigCredentials = false;
skippedProbeAuthForDisabledExecSecretRef = true;
rpcAuthWarning =
"Gateway probe auth skipped because gateway credentials use an exec SecretRef and exec SecretRefs are disabled for this status request.";
}
}
const rpc = opts.probe
@@ -621,11 +678,12 @@ export async function gatherDaemonStatus(
timeoutMs,
json: opts.rpc.json,
requireRpc: opts.requireRpc,
allowRpcConfigCredentials,
configPath: daemonConfigSummary.path,
}),
)
: undefined;
if (rpc?.ok) {
if (rpc?.ok && !skippedProbeAuthForDisabledExecSecretRef) {
rpcAuthWarning = undefined;
}
const health =
+34 -4
View File
@@ -155,8 +155,25 @@ function createSignalProcess() {
};
}
async function waitForAgentCommandCall(expectedCalls = 1) {
await vi.waitFor(() => expect(agentCommand).toHaveBeenCalledTimes(expectedCalls));
async function waitForAgentCommandCall(expectedAdditionalCalls = 1) {
const initialCalls = agentCommand.mock.calls.length;
const expectedCalls = initialCalls + expectedAdditionalCalls;
await vi.waitFor(() => {
expect(agentCommand.mock.calls.length).toBeGreaterThanOrEqual(expectedCalls);
});
}
async function waitForSignalListener(
signals: ReturnType<typeof createSignalProcess>,
signal: "SIGINT" | "SIGTERM",
) {
const deadline = Date.now() + 10_000;
while (signals.listenerCount(signal) === 0 && Date.now() < deadline) {
await new Promise<void>((resolve) => {
setTimeout(resolve, 10);
});
}
expect(signals.listenerCount(signal)).toBeGreaterThan(0);
}
function runAbortHandlerWhenReady(signal: AbortSignal | undefined, onAbort: () => void): void {
@@ -1117,6 +1134,11 @@ describe("agentCliCommand", () => {
const abortListenerAttached = createDeferredVoid();
agentCommand.mockImplementationOnce(async (opts: { abortSignal?: AbortSignal }) => {
expect(opts.abortSignal).toBeInstanceOf(AbortSignal);
if (opts.abortSignal?.aborted) {
const err = new Error("local agent aborted");
err.name = "AbortError";
throw err;
}
return await new Promise((_, reject) => {
runAbortHandlerWhenReady(opts.abortSignal, () => {
const err = new Error("local agent aborted");
@@ -1130,11 +1152,12 @@ describe("agentCliCommand", () => {
const run = agentCliCommand({ message: "hi", to: "+1555", local: true }, runtime, {
process: signals.processLike,
});
await waitForAgentCommandCall();
await waitForSignalListener(signals, "SIGTERM");
await abortListenerAttached.promise;
signals.emit("SIGTERM");
await run;
expect(agentCommand).toHaveBeenCalledTimes(1);
expect(callGateway).not.toHaveBeenCalled();
expect(runtime.exit).toHaveBeenCalledWith(143);
expect(signals.listenerCount("SIGTERM")).toBe(0);
@@ -1147,6 +1170,12 @@ describe("agentCliCommand", () => {
const signals = createSignalProcess();
const abortListenerAttached = createDeferredVoid();
agentCommand.mockImplementationOnce(async (opts: { abortSignal?: AbortSignal }) => {
if (opts.abortSignal?.aborted) {
return {
payloads: [],
meta: { aborted: true },
} as unknown as Awaited<ReturnType<typeof AgentCommand>>;
}
return await new Promise((resolve) => {
runAbortHandlerWhenReady(opts.abortSignal, () => {
resolve({
@@ -1161,11 +1190,12 @@ describe("agentCliCommand", () => {
const run = agentCliCommand({ message: "hi", to: "+1555", local: true }, runtime, {
process: signals.processLike,
});
await waitForAgentCommandCall();
await waitForSignalListener(signals, "SIGTERM");
await abortListenerAttached.promise;
signals.emit("SIGTERM");
await expect(run).resolves.toBeUndefined();
expect(agentCommand).toHaveBeenCalledTimes(1);
expect(callGateway).not.toHaveBeenCalled();
expect(runtime.exit).toHaveBeenCalledWith(143);
});
+88 -1
View File
@@ -1,6 +1,8 @@
// Doctor workspace status tests cover workspace inspection and status output.
import { describe, expect, it, vi } from "vitest";
import * as noteModule from "../../packages/terminal-core/src/note.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { PluginVersionDriftReport } from "../plugins/plugin-version-drift.js";
import {
createPluginLoadResult,
createPluginRecord,
@@ -46,10 +48,13 @@ async function runNoteWorkspaceStatusForTest(
loadResult: ReturnType<typeof createPluginLoadResult>,
compatibilityWarnings: string[] = [],
opts?: {
cfg?: OpenClawConfig;
pluginVersionDrift?: PluginVersionDriftReport;
flows?: unknown[];
tasksByFlowId?: (flowId: string) => unknown[];
},
) {
const cfg: OpenClawConfig = opts?.cfg ?? {};
mocks.resolveDefaultAgentId.mockReturnValue("default");
mocks.resolveAgentWorkspaceDir.mockReturnValue("/workspace");
mocks.buildWorkspaceSkillStatus.mockReturnValue({
@@ -66,7 +71,9 @@ async function runNoteWorkspaceStatusForTest(
);
const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {});
noteWorkspaceStatus({});
noteWorkspaceStatus(cfg, {
pluginVersionDrift: opts?.pluginVersionDrift,
});
return noteSpy;
}
@@ -151,6 +158,86 @@ describe("noteWorkspaceStatus", () => {
}
});
it("surfaces active official managed plugin version drift", async () => {
const noteSpy = await runNoteWorkspaceStatusForTest(
createPluginLoadResult({
plugins: [
createPluginRecord({
id: "codex",
name: "Codex",
origin: "global",
source: "/tmp/codex/index.js",
}),
],
}),
[],
{
cfg: {
plugins: {
entries: {
codex: { enabled: true },
},
},
},
pluginVersionDrift: {
gatewayVersion: "2026.6.1",
drifts: [
{
pluginId: "codex",
installedVersion: "2026.5.30-beta.1",
gatewayVersion: "2026.6.1",
source: "npm",
},
],
},
},
);
try {
const driftCalls = noteSpy.mock.calls.filter(([, title]) => title === "Plugin version drift");
expect(driftCalls).toHaveLength(1);
const [[body]] = driftCalls;
expect(body).toContain("1 active official plugin not on OpenClaw 2026.6.1");
expect(body).toContain("codex: 2026.5.30-beta.1 (npm) -> expected 2026.6.1");
expect(body).toContain("openclaw plugins update codex");
expect(body).toContain("openclaw gateway restart");
} finally {
noteSpy.mockRestore();
}
});
it("omits plugin version drift when no daemon status report is supplied", async () => {
const noteSpy = await runNoteWorkspaceStatusForTest(
createPluginLoadResult({
plugins: [
createPluginRecord({
id: "codex",
name: "Codex",
origin: "global",
source: "/tmp/codex/index.js",
}),
],
}),
[],
{
cfg: {
gateway: {
mode: "remote",
},
plugins: {
entries: {
codex: { enabled: true },
},
},
},
},
);
try {
expect(noteSpy.mock.calls.map(([, title]) => title)).not.toContain("Plugin version drift");
} finally {
noteSpy.mockRestore();
}
});
it("omits plugin compatibility note when no legacy compatibility paths are present", async () => {
const noteSpy = await runNoteWorkspaceStatusForTest(
createPluginLoadResult({
+31 -1
View File
@@ -3,6 +3,7 @@ import { note } from "../../packages/terminal-core/src/note.js";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { formatCliCommand } from "../cli/command-format.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { PluginVersionDriftReport } from "../plugins/plugin-version-drift.js";
import {
buildPluginCompatibilityWarnings,
buildPluginRegistrySnapshotReport,
@@ -12,6 +13,10 @@ import { listTasksForFlowId } from "../tasks/runtime-internal.js";
import { listTaskFlowRecords } from "../tasks/task-flow-runtime-internal.js";
import { detectLegacyWorkspaceDirs, formatLegacyWorkspaceWarning } from "./doctor-workspace.js";
export type NoteWorkspaceStatusOptions = {
pluginVersionDrift?: PluginVersionDriftReport;
};
function noteFlowRecoveryHints() {
const suspicious = listTaskFlowRecords().flatMap((flow) => {
const tasks = listTasksForFlowId(flow.flowId);
@@ -53,8 +58,32 @@ function noteFlowRecoveryHints() {
);
}
function notePluginVersionDrift(drift: PluginVersionDriftReport | undefined) {
if (!drift || drift.drifts.length === 0) {
return;
}
const singleDrift = drift.drifts.length === 1 ? drift.drifts[0] : undefined;
const lines = [
`${drift.drifts.length} active official plugin${
drift.drifts.length === 1 ? "" : "s"
} not on OpenClaw ${drift.gatewayVersion}`,
...drift.drifts.map((entry) => {
const sourceLabel = entry.source === "clawhub" ? "clawhub" : "npm";
return `- ${entry.pluginId}: ${entry.installedVersion} (${sourceLabel}) -> expected ${drift.gatewayVersion}`;
}),
singleDrift
? `Fix: ${formatCliCommand(
`openclaw plugins update ${singleDrift.pluginId}`,
)} && ${formatCliCommand("openclaw gateway restart")}.`
: `Fix: ${formatCliCommand(
"openclaw plugins update <plugin-id>",
)} for each drifted plugin, then ${formatCliCommand("openclaw gateway restart")}.`,
];
note(lines.join("\n"), "Plugin version drift");
}
/** Emits workspace, skills, plugin, and TaskFlow recovery status notes for doctor. */
export function noteWorkspaceStatus(cfg: OpenClawConfig) {
export function noteWorkspaceStatus(cfg: OpenClawConfig, options: NoteWorkspaceStatusOptions = {}) {
const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg));
const legacyWorkspace = detectLegacyWorkspaceDirs({ workspaceDir });
if (legacyWorkspace.legacyDirs.length > 0) {
@@ -107,6 +136,7 @@ export function noteWorkspaceStatus(cfg: OpenClawConfig) {
note(lines.join("\n"), "Plugins");
}
notePluginVersionDrift(options.pluginVersionDrift);
const compatibilityWarnings = buildPluginCompatibilityWarnings({
config: cfg,
workspaceDir,
@@ -30,6 +30,8 @@ const mocks = vi.hoisted(() => ({
}),
checkGatewayHealth: vi.fn(),
probeGatewayMemoryStatus: vi.fn(),
gatherDaemonStatus: vi.fn(),
noteWorkspaceStatus: vi.fn(),
applyWizardMetadata: vi.fn((cfg: unknown) => cfg),
logConfigUpdated: vi.fn(),
isRecord: vi.fn(
@@ -98,6 +100,14 @@ vi.mock("../commands/doctor-gateway-health.js", () => ({
probeGatewayMemoryStatus: mocks.probeGatewayMemoryStatus,
}));
vi.mock("../cli/daemon-cli/status.gather.js", () => ({
gatherDaemonStatus: mocks.gatherDaemonStatus,
}));
vi.mock("../commands/doctor-workspace-status.js", () => ({
noteWorkspaceStatus: mocks.noteWorkspaceStatus,
}));
vi.mock("../commands/onboard-helpers.js", () => ({
applyWizardMetadata: mocks.applyWizardMetadata,
}));
@@ -194,6 +204,9 @@ describe("doctor health contributions", () => {
});
mocks.checkGatewayHealth.mockReset();
mocks.probeGatewayMemoryStatus.mockReset();
mocks.gatherDaemonStatus.mockReset();
mocks.gatherDaemonStatus.mockResolvedValue({});
mocks.noteWorkspaceStatus.mockReset();
});
afterEach(() => {
@@ -237,6 +250,93 @@ describe("doctor health contributions", () => {
expect(mocks.probeGatewayMemoryStatus).not.toHaveBeenCalled();
});
it("skips remote gateway health probes for local fallback exec SecretRefs", async () => {
mocks.checkGatewayHealth.mockResolvedValue({
authenticated: false,
healthOk: true,
});
const contribution = requireDoctorContribution(DOCTOR_GATEWAY_HEALTH_ID);
const cfg = {
gateway: {
mode: "remote",
remote: {
url: "wss://gateway.example",
},
auth: {
mode: "token",
token: { source: "exec", provider: "vault", id: "gateway/token" },
},
},
secrets: {
providers: {
vault: { source: "exec", command: "/bin/false" },
},
},
};
const ctx = {
cfg,
configResult: { cfg },
sourceConfigValid: true,
prompter: buildDoctorPrompter(false),
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
options: {},
cfgForPersistence: cfg,
configPath: "/tmp/fake-openclaw.json",
env: {},
} as Parameters<(typeof contribution)["run"]>[0];
await contribution.run(ctx);
expect(mocks.checkGatewayHealth).not.toHaveBeenCalled();
expect(mocks.note).toHaveBeenCalledWith(
expect.stringContaining("Gateway health probes skipped"),
"Gateway",
);
expect(ctx.gatewayHealthSkipped).toBe(true);
expect(ctx.gatewayMemoryProbe).toEqual({ checked: false, ready: false, skipped: true });
});
it("skips local gateway health probes for remote fallback exec SecretRefs", async () => {
const contribution = requireDoctorContribution(DOCTOR_GATEWAY_HEALTH_ID);
const cfg = {
gateway: {
mode: "local",
auth: {
mode: "token",
},
remote: {
token: { source: "exec", provider: "vault", id: "gateway/remote-token" },
},
},
secrets: {
providers: {
vault: { source: "exec", command: "/bin/false" },
},
},
};
const ctx = {
cfg,
configResult: { cfg },
sourceConfigValid: true,
prompter: buildDoctorPrompter(false),
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
options: {},
cfgForPersistence: cfg,
configPath: "/tmp/fake-openclaw.json",
env: {},
} as Parameters<(typeof contribution)["run"]>[0];
await contribution.run(ctx);
expect(mocks.checkGatewayHealth).not.toHaveBeenCalled();
expect(mocks.note).toHaveBeenCalledWith(
expect.stringContaining("Gateway health probes skipped"),
"Gateway",
);
expect(ctx.gatewayHealthSkipped).toBe(true);
expect(ctx.gatewayMemoryProbe).toEqual({ checked: false, ready: false, skipped: true });
});
it("keeps release configured plugin installs repair-only", async () => {
const contribution = requireDoctorContribution("doctor:release-configured-plugin-installs");
const ctx = {
@@ -324,6 +424,202 @@ describe("doctor health contributions", () => {
expect(ids.indexOf("doctor:skills")).toBeLessThan(ids.indexOf("doctor:write-config"));
});
it("passes daemon-context plugin drift into the workspace status note", async () => {
const contribution = requireDoctorContribution("doctor:workspace-status");
const pluginVersionDrift = {
gatewayVersion: "2026.6.1",
drifts: [
{
pluginId: "codex",
installedVersion: "2026.5.30-beta.1",
gatewayVersion: "2026.6.1",
source: "npm",
},
],
};
mocks.gatherDaemonStatus.mockResolvedValueOnce({
gateway: { version: "2026.6.1" },
pluginVersionDrift,
});
const cfg = { plugins: { entries: { codex: { enabled: true } } } };
await contribution.run({
cfg,
options: { nonInteractive: true },
} as unknown as Parameters<(typeof contribution)["run"]>[0]);
expect(mocks.gatherDaemonStatus).toHaveBeenCalledWith({
rpc: {
timeout: "3000",
json: true,
},
probe: true,
requireRpc: false,
deep: false,
allowExecSecretRefs: false,
});
expect(mocks.noteWorkspaceStatus).toHaveBeenCalledWith(cfg, { pluginVersionDrift });
});
it("omits daemon-context plugin drift when gateway version used the fallback", async () => {
const contribution = requireDoctorContribution("doctor:workspace-status");
const pluginVersionDrift = {
gatewayVersion: "2026.5.2-test",
drifts: [
{
pluginId: "codex",
installedVersion: "2026.5.30-beta.1",
gatewayVersion: "2026.5.2-test",
source: "npm",
},
],
};
mocks.gatherDaemonStatus.mockResolvedValueOnce({
gateway: { version: null },
pluginVersionDrift,
});
const cfg = { plugins: { entries: { codex: { enabled: true } } } };
await contribution.run({
cfg,
options: { nonInteractive: true },
} as unknown as Parameters<(typeof contribution)["run"]>[0]);
expect(mocks.noteWorkspaceStatus).toHaveBeenCalledWith(cfg, {
pluginVersionDrift: undefined,
});
});
it("omits daemon-context plugin drift when probe auth was skipped", async () => {
const contribution = requireDoctorContribution("doctor:workspace-status");
const pluginVersionDrift = {
gatewayVersion: "2026.6.1",
drifts: [
{
pluginId: "codex",
installedVersion: "2026.5.30-beta.1",
gatewayVersion: "2026.6.1",
source: "npm",
},
],
};
mocks.gatherDaemonStatus.mockResolvedValueOnce({
gateway: {},
rpc: { authWarning: "exec SecretRef probe auth skipped" },
pluginVersionDrift,
});
const cfg = { plugins: { entries: { codex: { enabled: true } } } };
await contribution.run({
cfg,
options: { nonInteractive: true },
} as unknown as Parameters<(typeof contribution)["run"]>[0]);
expect(mocks.noteWorkspaceStatus).toHaveBeenCalledWith(cfg, {
pluginVersionDrift: undefined,
});
});
it("skips daemon-context plugin drift probes for remote gateway mode", async () => {
const contribution = requireDoctorContribution("doctor:workspace-status");
const cfg = {
gateway: { mode: "remote" },
plugins: { entries: { codex: { enabled: true } } },
};
await contribution.run({
cfg,
options: { nonInteractive: true },
} as unknown as Parameters<(typeof contribution)["run"]>[0]);
expect(mocks.gatherDaemonStatus).not.toHaveBeenCalled();
expect(mocks.noteWorkspaceStatus).toHaveBeenCalledWith(cfg, {
pluginVersionDrift: undefined,
});
});
it("lets daemon status decide exec SecretRef probing from daemon config", async () => {
const contribution = requireDoctorContribution("doctor:workspace-status");
const pluginVersionDrift = {
gatewayVersion: "2026.6.1",
drifts: [
{
pluginId: "codex",
installedVersion: "2026.5.30-beta.1",
gatewayVersion: "2026.6.1",
source: "npm",
},
],
};
mocks.gatherDaemonStatus.mockResolvedValueOnce({
gateway: { version: "2026.6.1" },
pluginVersionDrift,
});
const cfg = {
gateway: {
auth: {
mode: "token",
token: {
source: "exec",
provider: "vault",
id: "gateway/token",
},
},
},
};
await contribution.run({
cfg,
options: { nonInteractive: true },
} as unknown as Parameters<(typeof contribution)["run"]>[0]);
expect(mocks.gatherDaemonStatus).toHaveBeenCalledWith({
rpc: {
timeout: "3000",
json: true,
},
probe: true,
requireRpc: false,
deep: false,
allowExecSecretRefs: false,
});
expect(mocks.noteWorkspaceStatus).toHaveBeenCalledWith(cfg, { pluginVersionDrift });
});
it("ignores remote-only exec SecretRefs for local daemon-context plugin drift probes", async () => {
const contribution = requireDoctorContribution("doctor:workspace-status");
const cfg = {
gateway: {
auth: {
mode: "token",
},
remote: {
token: {
source: "exec",
provider: "vault",
id: "gateway/remote-token",
},
},
},
};
await contribution.run({
cfg,
options: { nonInteractive: true },
} as unknown as Parameters<(typeof contribution)["run"]>[0]);
expect(mocks.gatherDaemonStatus).toHaveBeenCalledWith({
rpc: {
timeout: "3000",
json: true,
},
probe: true,
requireRpc: false,
deep: false,
allowExecSecretRefs: false,
});
});
it("uses the read-only model catalog for hooks.gmail.model warnings", async () => {
const contribution = requireDoctorContribution("doctor:hooks-model");
const cfg = {
+55 -25
View File
@@ -727,9 +727,62 @@ async function runSystemdLingerHealth(ctx: DoctorHealthFlowContext): Promise<voi
});
}
async function hasActiveGatewayExecCredential(
ctx: DoctorHealthFlowContext,
mode: DoctorFlowMode = resolveDoctorMode(ctx.cfg),
): Promise<boolean> {
const { resolveSecretInputRef } = await loadSecretTypesModule();
const { gatewaySecretInputPathCanWin } = await import("../gateway/credentials-secret-inputs.js");
const { ALL_GATEWAY_SECRET_INPUT_PATHS, readGatewaySecretInputValue } =
await import("../gateway/secret-input-paths.js");
return ALL_GATEWAY_SECRET_INPUT_PATHS.some((path) => {
if (
!gatewaySecretInputPathCanWin({
config: ctx.cfg,
env: process.env,
modeOverride: mode,
path,
})
) {
return false;
}
const ref = resolveSecretInputRef({
value: readGatewaySecretInputValue(ctx.cfg, path),
defaults: ctx.cfg.secrets?.defaults,
}).ref;
return ref?.source === "exec";
});
}
async function runWorkspaceStatusHealth(ctx: DoctorHealthFlowContext): Promise<void> {
let pluginVersionDrift:
| import("../plugins/plugin-version-drift.js").PluginVersionDriftReport
| undefined;
if (ctx.cfg.gateway?.mode !== "remote") {
try {
const { gatherDaemonStatus } = await import("../cli/daemon-cli/status.gather.js");
const allowExecSecretRefs = ctx.options.allowExec === true;
const status = await gatherDaemonStatus({
rpc: {
timeout: ctx.options.nonInteractive === true ? "3000" : "10000",
json: true,
},
probe: true,
requireRpc: false,
deep: ctx.options.deep === true,
allowExecSecretRefs,
});
const hasProbedGatewayVersion =
typeof status.gateway?.version === "string" && status.gateway.version.trim() !== "";
if (status.pluginVersionDrift && hasProbedGatewayVersion && !status.rpc?.authWarning) {
pluginVersionDrift = status.pluginVersionDrift;
}
} catch {
// Best-effort diagnostic: doctor should keep running if daemon status is unavailable.
}
}
const { noteWorkspaceStatus } = await import("../commands/doctor-workspace-status.js");
noteWorkspaceStatus(ctx.cfg);
noteWorkspaceStatus(ctx.cfg, { pluginVersionDrift });
}
async function runSkillsHealth(ctx: DoctorHealthFlowContext): Promise<void> {
@@ -762,31 +815,8 @@ async function runShellCompletionHealth(ctx: DoctorHealthFlowContext): Promise<v
}
async function runGatewayHealthChecks(ctx: DoctorHealthFlowContext): Promise<void> {
const { resolveSecretInputRef } = await loadSecretTypesModule();
const { gatewaySecretInputPathCanWin } = await import("../gateway/credentials-secret-inputs.js");
const { readGatewaySecretInputValue } = await import("../gateway/secret-input-paths.js");
const { note } = await loadNoteModule();
const credentialPaths = [
"gateway.auth.token",
"gateway.auth.password",
"gateway.remote.token",
"gateway.remote.password",
] as const;
const activeSecretRefPaths = credentialPaths.filter((path) =>
gatewaySecretInputPathCanWin({
config: ctx.cfg,
env: process.env,
path,
}),
);
const hasActiveExecCredential = activeSecretRefPaths.some((path) => {
const ref = resolveSecretInputRef({
value: readGatewaySecretInputValue(ctx.cfg, path),
defaults: ctx.cfg.secrets?.defaults,
}).ref;
return ref?.source === "exec";
});
if (hasActiveExecCredential && ctx.options.allowExec !== true) {
if ((await hasActiveGatewayExecCredential(ctx)) && ctx.options.allowExec !== true) {
note(
"Gateway health probes skipped because gateway credentials use an exec SecretRef. Run `openclaw doctor --allow-exec` to verify Gateway health with exec SecretRefs.",
"Gateway",
+1 -1
View File
@@ -32,7 +32,7 @@ function buildGatewayProbeCredentialPolicy(params: {
};
}
function resolveGatewayProbeCredentialConfig(params: {
export function resolveGatewayProbeCredentialConfig(params: {
cfg: OpenClawConfig;
mode: "local" | "remote";
}): OpenClawConfig {