diff --git a/src/agents/openclaw-tools.camera.test.ts b/src/agents/openclaw-tools.camera.test.ts index 259dc09a91c1..05eafe4e61e5 100644 --- a/src/agents/openclaw-tools.camera.test.ts +++ b/src/agents/openclaw-tools.camera.test.ts @@ -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", diff --git a/src/cli/daemon-cli/probe.test.ts b/src/cli/daemon-cli/probe.test.ts index 9817cad0d754..67c960daa521 100644 --- a/src/cli/daemon-cli/probe.test.ts +++ b/src/cli/daemon-cli/probe.test.ts @@ -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(); diff --git a/src/cli/daemon-cli/probe.ts b/src/cli/daemon-cli/probe.ts index f43e015276d5..328f30789c4e 100644 --- a/src/cli/daemon-cli/probe.ts +++ b/src/cli/daemon-cli/probe.ts @@ -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 } : {}), diff --git a/src/cli/daemon-cli/status.gather.test.ts b/src/cli/daemon-cli/status.gather.test.ts index ea7365658a2f..9257bed56ef3 100644 --- a/src/cli/daemon-cli/status.gather.test.ts +++ b/src/cli/daemon-cli/status.gather.test.ts @@ -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>(); + return { + ...actual, + resolveGatewayProbeAuthSafeWithSecretInputs: async (opts: unknown) => { + resolveGatewayProbeAuthSafeWithSecretInputsCalls(opts); + return await ( + actual.resolveGatewayProbeAuthSafeWithSecretInputs as (opts: unknown) => Promise + )(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: { diff --git a/src/cli/daemon-cli/status.gather.ts b/src/cli/daemon-cli/status.gather.ts index 12fff20b1506..e8f48a5edf9e 100644 --- a/src/cli/daemon-cli/status.gather.ts +++ b/src/cli/daemon-cli/status.gather.ts @@ -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 { 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 = diff --git a/src/commands/agent-via-gateway.test.ts b/src/commands/agent-via-gateway.test.ts index 4668d3f6957a..05dc3c52d4d2 100644 --- a/src/commands/agent-via-gateway.test.ts +++ b/src/commands/agent-via-gateway.test.ts @@ -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, + signal: "SIGINT" | "SIGTERM", +) { + const deadline = Date.now() + 10_000; + while (signals.listenerCount(signal) === 0 && Date.now() < deadline) { + await new Promise((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>; + } 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); }); diff --git a/src/commands/doctor-workspace-status.test.ts b/src/commands/doctor-workspace-status.test.ts index 88ab026435af..68e0f1b08578 100644 --- a/src/commands/doctor-workspace-status.test.ts +++ b/src/commands/doctor-workspace-status.test.ts @@ -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, 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({ diff --git a/src/commands/doctor-workspace-status.ts b/src/commands/doctor-workspace-status.ts index ef886a7546f3..f0a2b0d0ed81 100644 --- a/src/commands/doctor-workspace-status.ts +++ b/src/commands/doctor-workspace-status.ts @@ -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 ", + )} 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, diff --git a/src/flows/doctor-health-contributions.test.ts b/src/flows/doctor-health-contributions.test.ts index cbefc0b998a6..04dca62ada61 100644 --- a/src/flows/doctor-health-contributions.test.ts +++ b/src/flows/doctor-health-contributions.test.ts @@ -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 = { diff --git a/src/flows/doctor-health-contributions.ts b/src/flows/doctor-health-contributions.ts index 5fb9bf310772..25170a537518 100644 --- a/src/flows/doctor-health-contributions.ts +++ b/src/flows/doctor-health-contributions.ts @@ -727,9 +727,62 @@ async function runSystemdLingerHealth(ctx: DoctorHealthFlowContext): Promise { + 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 { + 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 { @@ -762,31 +815,8 @@ async function runShellCompletionHealth(ctx: DoctorHealthFlowContext): Promise { - 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", diff --git a/src/gateway/probe-auth.ts b/src/gateway/probe-auth.ts index 0bab2a246622..aa268d26d983 100644 --- a/src/gateway/probe-auth.ts +++ b/src/gateway/probe-auth.ts @@ -32,7 +32,7 @@ function buildGatewayProbeCredentialPolicy(params: { }; } -function resolveGatewayProbeCredentialConfig(params: { +export function resolveGatewayProbeCredentialConfig(params: { cfg: OpenClawConfig; mode: "local" | "remote"; }): OpenClawConfig {