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
+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 =