mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 13:26:04 -06:00
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:
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -32,7 +32,7 @@ function buildGatewayProbeCredentialPolicy(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function resolveGatewayProbeCredentialConfig(params: {
|
||||
export function resolveGatewayProbeCredentialConfig(params: {
|
||||
cfg: OpenClawConfig;
|
||||
mode: "local" | "remote";
|
||||
}): OpenClawConfig {
|
||||
|
||||
Reference in New Issue
Block a user