mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -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:
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user