diff --git a/src/commands/status-json.test.ts b/src/commands/status-json.test.ts new file mode 100644 index 00000000000..c51f073d062 --- /dev/null +++ b/src/commands/status-json.test.ts @@ -0,0 +1,118 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { RuntimeEnv } from "../runtime.js"; + +const mocks = vi.hoisted(() => ({ + scanStatusJsonFast: vi.fn(), + runSecurityAudit: vi.fn(), + loadProviderUsageSummary: vi.fn(), + callGateway: vi.fn(), + getDaemonStatusSummary: vi.fn(), + getNodeDaemonStatusSummary: vi.fn(), + normalizeUpdateChannel: vi.fn((value?: string | null) => value ?? null), + resolveUpdateChannelDisplay: vi.fn(() => ({ + channel: "stable", + source: "config", + })), +})); + +vi.mock("./status.scan.fast-json.js", () => ({ + scanStatusJsonFast: mocks.scanStatusJsonFast, +})); + +vi.mock("../security/audit.runtime.js", () => ({ + runSecurityAudit: mocks.runSecurityAudit, +})); + +vi.mock("../infra/provider-usage.js", () => ({ + loadProviderUsageSummary: mocks.loadProviderUsageSummary, +})); + +vi.mock("../gateway/call.js", () => ({ + callGateway: mocks.callGateway, +})); + +vi.mock("./status.daemon.js", () => ({ + getDaemonStatusSummary: mocks.getDaemonStatusSummary, + getNodeDaemonStatusSummary: mocks.getNodeDaemonStatusSummary, +})); + +vi.mock("../infra/update-channels.js", () => ({ + normalizeUpdateChannel: mocks.normalizeUpdateChannel, + resolveUpdateChannelDisplay: mocks.resolveUpdateChannelDisplay, +})); + +const { statusJsonCommand } = await import("./status-json.js"); + +function createRuntimeCapture() { + const logs: string[] = []; + const runtime: RuntimeEnv = { + log: vi.fn((value: unknown) => { + logs.push(String(value)); + }), + error: vi.fn(), + exit: vi.fn() as unknown as RuntimeEnv["exit"], + }; + return { runtime, logs }; +} + +function createScanResult() { + return { + cfg: { update: { channel: "stable" } }, + sourceConfig: {}, + summary: { ok: true, configuredChannels: [] }, + osSummary: { platform: "linux" }, + update: { installKind: "npm", git: { tag: null, branch: null } }, + memory: null, + memoryPlugin: null, + gatewayMode: "local", + gatewayConnection: { url: "ws://127.0.0.1:18789", urlSource: "config" }, + remoteUrlMissing: false, + gatewayReachable: false, + gatewayProbe: null, + gatewaySelf: null, + gatewayProbeAuthWarning: null, + agentStatus: [], + secretDiagnostics: [], + }; +} + +describe("statusJsonCommand", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.scanStatusJsonFast.mockResolvedValue(createScanResult()); + mocks.runSecurityAudit.mockResolvedValue({ + summary: { critical: 1, warn: 0, info: 0 }, + findings: [], + }); + mocks.getDaemonStatusSummary.mockResolvedValue({ installed: false }); + mocks.getNodeDaemonStatusSummary.mockResolvedValue({ installed: false }); + mocks.loadProviderUsageSummary.mockResolvedValue({ providers: [] }); + mocks.callGateway.mockResolvedValue({}); + }); + + it("keeps plain status --json off the security audit fast path", async () => { + const { runtime, logs } = createRuntimeCapture(); + + await statusJsonCommand({}, runtime); + + expect(mocks.runSecurityAudit).not.toHaveBeenCalled(); + expect(logs).toHaveLength(1); + expect(JSON.parse(logs[0] ?? "{}")).not.toHaveProperty("securityAudit"); + }); + + it("includes security audit details only when --all is requested", async () => { + const { runtime, logs } = createRuntimeCapture(); + + await statusJsonCommand({ all: true }, runtime); + + expect(mocks.runSecurityAudit).toHaveBeenCalledWith({ + config: expect.any(Object), + sourceConfig: expect.any(Object), + deep: false, + includeFilesystem: true, + includeChannelSecurity: true, + }); + expect(logs).toHaveLength(1); + expect(JSON.parse(logs[0] ?? "{}")).toHaveProperty("securityAudit.summary.critical", 1); + }); +}); diff --git a/src/commands/status-json.ts b/src/commands/status-json.ts index e9221226665..2a004f4a231 100644 --- a/src/commands/status-json.ts +++ b/src/commands/status-json.ts @@ -33,15 +33,17 @@ export async function statusJsonCommand( runtime: RuntimeEnv, ) { const scan = await scanStatusJsonFast({ timeoutMs: opts.timeoutMs, all: opts.all }, runtime); - const securityAudit = await loadSecurityAuditModule().then(({ runSecurityAudit }) => - runSecurityAudit({ - config: scan.cfg, - sourceConfig: scan.sourceConfig, - deep: false, - includeFilesystem: true, - includeChannelSecurity: true, - }), - ); + const securityAudit = opts.all + ? await loadSecurityAuditModule().then(({ runSecurityAudit }) => + runSecurityAudit({ + config: scan.cfg, + sourceConfig: scan.sourceConfig, + deep: false, + includeFilesystem: true, + includeChannelSecurity: true, + }), + ) + : undefined; const usage = opts.usage ? await loadProviderUsage().then(({ loadProviderUsageSummary }) => @@ -105,8 +107,8 @@ export async function statusJsonCommand( gatewayService: daemon, nodeService: nodeDaemon, agents: scan.agentStatus, - securityAudit, secretDiagnostics: scan.secretDiagnostics, + ...(securityAudit ? { securityAudit } : {}), ...(health || usage || lastHeartbeat ? { health, usage, lastHeartbeat } : {}), }, null, diff --git a/src/commands/status.scan.fast-json.test.ts b/src/commands/status.scan.fast-json.test.ts new file mode 100644 index 00000000000..83bc1bd5341 --- /dev/null +++ b/src/commands/status.scan.fast-json.test.ts @@ -0,0 +1,190 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + hasPotentialConfiguredChannels: vi.fn(), + readBestEffortConfig: vi.fn(), + resolveCommandSecretRefsViaGateway: vi.fn(), + getStatusCommandSecretTargetIds: vi.fn(() => []), + getUpdateCheckResult: vi.fn(), + getAgentLocalStatuses: vi.fn(), + getStatusSummary: vi.fn(), + resolveMemorySearchConfig: vi.fn(), + getMemorySearchManager: vi.fn(), + buildGatewayConnectionDetails: vi.fn(), + probeGateway: vi.fn(), + resolveGatewayProbeAuthResolution: vi.fn(), + ensurePluginRegistryLoaded: vi.fn(), + buildPluginCompatibilityNotices: vi.fn(() => []), +})); + +beforeEach(() => { + vi.clearAllMocks(); + mocks.hasPotentialConfiguredChannels.mockReturnValue(false); + mocks.readBestEffortConfig.mockResolvedValue({ + session: {}, + gateway: {}, + agents: { + defaults: { + memorySearch: { + provider: "local", + local: { modelPath: "/tmp/model.gguf" }, + fallback: "none", + }, + }, + }, + }); + mocks.resolveCommandSecretRefsViaGateway.mockResolvedValue({ + resolvedConfig: { + session: {}, + gateway: {}, + agents: { + defaults: { + memorySearch: { + provider: "local", + local: { modelPath: "/tmp/model.gguf" }, + fallback: "none", + }, + }, + }, + }, + diagnostics: [], + }); + mocks.getUpdateCheckResult.mockResolvedValue({ + installKind: "git", + git: null, + registry: null, + }); + mocks.getAgentLocalStatuses.mockResolvedValue({ + defaultId: "main", + agents: [], + }); + mocks.getStatusSummary.mockResolvedValue({ + linkChannel: undefined, + sessions: { count: 0, paths: [], defaults: {}, recent: [], byAgent: [] }, + }); + mocks.buildGatewayConnectionDetails.mockReturnValue({ + url: "ws://127.0.0.1:18789", + urlSource: "default", + }); + mocks.resolveGatewayProbeAuthResolution.mockReturnValue({ + auth: {}, + warning: undefined, + }); + mocks.probeGateway.mockResolvedValue({ + ok: false, + url: "ws://127.0.0.1:18789", + connectLatencyMs: null, + error: "timeout", + close: null, + health: null, + status: null, + presence: null, + configSnapshot: null, + }); + mocks.resolveMemorySearchConfig.mockReturnValue({ + store: { path: "/tmp/main.sqlite" }, + }); + mocks.getMemorySearchManager.mockResolvedValue({ + manager: { + probeVectorAvailability: vi.fn(async () => true), + status: vi.fn(() => ({ files: 0, chunks: 0, dirty: false })), + close: vi.fn(async () => {}), + }, + }); +}); + +vi.mock("../channels/config-presence.js", () => ({ + hasPotentialConfiguredChannels: mocks.hasPotentialConfiguredChannels, +})); + +vi.mock("../config/io.js", () => ({ + readBestEffortConfig: mocks.readBestEffortConfig, +})); + +vi.mock("../cli/command-secret-gateway.js", () => ({ + resolveCommandSecretRefsViaGateway: mocks.resolveCommandSecretRefsViaGateway, +})); + +vi.mock("../cli/command-secret-targets.js", () => ({ + getStatusCommandSecretTargetIds: mocks.getStatusCommandSecretTargetIds, +})); + +vi.mock("./status.update.js", () => ({ + getUpdateCheckResult: mocks.getUpdateCheckResult, +})); + +vi.mock("./status.agent-local.js", () => ({ + getAgentLocalStatuses: mocks.getAgentLocalStatuses, +})); + +vi.mock("./status.summary.js", () => ({ + getStatusSummary: mocks.getStatusSummary, +})); + +vi.mock("../infra/os-summary.js", () => ({ + resolveOsSummary: vi.fn(() => ({ label: "test-os" })), +})); + +vi.mock("./status.scan.deps.runtime.js", () => ({ + getTailnetHostname: vi.fn(), + getMemorySearchManager: mocks.getMemorySearchManager, +})); + +vi.mock("../agents/memory-search.js", () => ({ + resolveMemorySearchConfig: mocks.resolveMemorySearchConfig, +})); + +vi.mock("../gateway/call.js", () => ({ + buildGatewayConnectionDetails: mocks.buildGatewayConnectionDetails, +})); + +vi.mock("../gateway/probe.js", () => ({ + probeGateway: mocks.probeGateway, +})); + +vi.mock("./status.gateway-probe.js", () => ({ + pickGatewaySelfPresence: vi.fn(() => null), + resolveGatewayProbeAuthResolution: mocks.resolveGatewayProbeAuthResolution, +})); + +vi.mock("../process/exec.js", () => ({ + runExec: vi.fn(), +})); + +vi.mock("../cli/plugin-registry.js", () => ({ + ensurePluginRegistryLoaded: mocks.ensurePluginRegistryLoaded, +})); + +vi.mock("../plugins/status.js", () => ({ + buildPluginCompatibilityNotices: mocks.buildPluginCompatibilityNotices, +})); + +const { scanStatusJsonFast } = await import("./status.scan.fast-json.js"); + +describe("scanStatusJsonFast", () => { + it("skips memory inspection for the lean status --json fast path", async () => { + const result = await scanStatusJsonFast({}, {} as never); + + expect(result.memory).toBeNull(); + expect(mocks.resolveMemorySearchConfig).not.toHaveBeenCalled(); + expect(mocks.getMemorySearchManager).not.toHaveBeenCalled(); + }); + + it("restores memory inspection when --all is requested", async () => { + const result = await scanStatusJsonFast({ all: true }, {} as never); + + expect(result.memory).toEqual(expect.objectContaining({ agentId: "main" })); + expect(mocks.resolveMemorySearchConfig).toHaveBeenCalled(); + expect(mocks.getMemorySearchManager).toHaveBeenCalledWith({ + cfg: expect.objectContaining({ + agents: expect.objectContaining({ + defaults: expect.objectContaining({ + memorySearch: expect.any(Object), + }), + }), + }), + agentId: "main", + purpose: "status", + }); + }); +}); diff --git a/src/commands/status.scan.fast-json.ts b/src/commands/status.scan.fast-json.ts index 267a7200739..2e1788b2b16 100644 --- a/src/commands/status.scan.fast-json.ts +++ b/src/commands/status.scan.fast-json.ts @@ -197,7 +197,11 @@ export async function scanStatusJsonFast( ? pickGatewaySelfPresence(gatewayProbe.presence) : null; const memoryPlugin = resolveMemoryPluginStatus(cfg); - const memory = await resolveMemoryStatusSnapshot({ cfg, agentStatus, memoryPlugin }); + // Keep the lean `status --json` route off the memory manager/runtime graph. + // Deep memory inspection is still available on the explicit `--all` path. + const memory = opts.all + ? await resolveMemoryStatusSnapshot({ cfg, agentStatus, memoryPlugin }) + : null; const pluginCompatibility = shouldCollectPluginCompatibility(cfg) ? await loadPluginStatusModule().then(({ buildPluginCompatibilityNotices }) => // Keep plugin status loading off the empty-config `status --json` fast path. diff --git a/src/commands/status.summary.runtime.ts b/src/commands/status.summary.runtime.ts index e4b08a49856..1c18b907b00 100644 --- a/src/commands/status.summary.runtime.ts +++ b/src/commands/status.summary.runtime.ts @@ -1,5 +1,226 @@ -import { resolveContextTokensForModel } from "../agents/context.js"; -import { classifySessionKey, resolveSessionModelRef } from "../gateway/session-utils.js"; +import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; +import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; +import type { SessionEntry } from "../config/sessions/types.js"; +import type { OpenClawConfig } from "../config/types.js"; + +function parseStatusModelRef( + raw: string, + defaultProvider: string, +): { provider: string; model: string } | null { + const trimmed = raw.trim(); + if (!trimmed) { + return null; + } + const slash = trimmed.indexOf("/"); + if (slash === -1) { + return { provider: defaultProvider, model: trimmed }; + } + const provider = trimmed.slice(0, slash).trim(); + const model = trimmed.slice(slash + 1).trim(); + if (!provider || !model) { + return null; + } + return { provider, model }; +} + +function resolveStatusModelRefFromRaw(params: { + cfg: OpenClawConfig; + rawModel: string; + defaultProvider: string; +}): { provider: string; model: string } | null { + const trimmed = params.rawModel.trim(); + if (!trimmed) { + return null; + } + const configuredModels = params.cfg.agents?.defaults?.models ?? {}; + if (!trimmed.includes("/")) { + const aliasKey = trimmed.toLowerCase(); + for (const [modelKey, entry] of Object.entries(configuredModels)) { + const aliasValue = (entry as { alias?: unknown } | undefined)?.alias; + const alias = typeof aliasValue === "string" ? aliasValue.trim() : ""; + if (!alias || alias.toLowerCase() !== aliasKey) { + continue; + } + const parsed = parseStatusModelRef(modelKey, params.defaultProvider); + if (parsed) { + return parsed; + } + } + return { provider: "anthropic", model: trimmed }; + } + return parseStatusModelRef(trimmed, params.defaultProvider); +} + +function resolveConfiguredStatusModelRef(params: { + cfg: OpenClawConfig; + defaultProvider: string; + defaultModel: string; + agentId?: string; +}): { provider: string; model: string } { + const agentRawModel = params.agentId + ? resolveAgentModelPrimaryValue( + params.cfg.agents?.list?.find((entry) => entry?.id === params.agentId)?.model, + ) + : undefined; + if (agentRawModel) { + const parsed = resolveStatusModelRefFromRaw({ + cfg: params.cfg, + rawModel: agentRawModel, + defaultProvider: params.defaultProvider, + }); + if (parsed) { + return parsed; + } + } + + const defaultsRawModel = resolveAgentModelPrimaryValue(params.cfg.agents?.defaults?.model); + if (defaultsRawModel) { + const parsed = resolveStatusModelRefFromRaw({ + cfg: params.cfg, + rawModel: defaultsRawModel, + defaultProvider: params.defaultProvider, + }); + if (parsed) { + return parsed; + } + } + + const configuredProviders = params.cfg.models?.providers; + if (configuredProviders && typeof configuredProviders === "object") { + const hasDefaultProvider = Boolean(configuredProviders[params.defaultProvider]); + if (!hasDefaultProvider) { + const availableProvider = Object.entries(configuredProviders).find( + ([, providerCfg]) => + providerCfg && + Array.isArray(providerCfg.models) && + providerCfg.models.length > 0 && + providerCfg.models[0]?.id, + ); + if (availableProvider) { + const [providerName, providerCfg] = availableProvider; + return { provider: providerName, model: providerCfg.models[0].id }; + } + } + } + + return { provider: params.defaultProvider, model: params.defaultModel }; +} + +function resolveConfiguredProviderContextWindow( + cfg: OpenClawConfig | undefined, + provider: string, + model: string, +): number | undefined { + const providers = cfg?.models?.providers; + if (!providers || typeof providers !== "object") { + return undefined; + } + const providerKey = provider.trim().toLowerCase(); + for (const [id, providerConfig] of Object.entries(providers)) { + if (id.trim().toLowerCase() !== providerKey || !Array.isArray(providerConfig?.models)) { + continue; + } + for (const entry of providerConfig.models) { + if ( + typeof entry?.id === "string" && + entry.id === model && + typeof entry.contextWindow === "number" && + entry.contextWindow > 0 + ) { + return entry.contextWindow; + } + } + } + return undefined; +} + +function classifySessionKey(key: string, entry?: SessionEntry) { + if (key === "global") { + return "global"; + } + if (key === "unknown") { + return "unknown"; + } + if (entry?.chatType === "group" || entry?.chatType === "channel") { + return "group"; + } + if (key.includes(":group:") || key.includes(":channel:")) { + return "group"; + } + return "direct"; +} + +function resolveSessionModelRef( + cfg: OpenClawConfig, + entry?: + | SessionEntry + | Pick, + agentId?: string, +): { provider: string; model: string } { + const resolved = resolveConfiguredStatusModelRef({ + cfg, + defaultProvider: DEFAULT_PROVIDER, + defaultModel: DEFAULT_MODEL, + agentId, + }); + + let provider = resolved.provider; + let model = resolved.model; + const runtimeModel = entry?.model?.trim(); + const runtimeProvider = entry?.modelProvider?.trim(); + if (runtimeModel) { + if (runtimeProvider) { + return { provider: runtimeProvider, model: runtimeModel }; + } + const parsedRuntime = parseStatusModelRef(runtimeModel, provider || DEFAULT_PROVIDER); + if (parsedRuntime) { + provider = parsedRuntime.provider; + model = parsedRuntime.model; + } else { + model = runtimeModel; + } + return { provider, model }; + } + + const storedModelOverride = entry?.modelOverride?.trim(); + if (storedModelOverride) { + const overrideProvider = entry?.providerOverride?.trim() || provider || DEFAULT_PROVIDER; + const parsedOverride = parseStatusModelRef(storedModelOverride, overrideProvider); + if (parsedOverride) { + provider = parsedOverride.provider; + model = parsedOverride.model; + } else { + provider = overrideProvider; + model = storedModelOverride; + } + } + return { provider, model }; +} + +function resolveContextTokensForModel(params: { + cfg?: OpenClawConfig; + provider?: string; + model?: string; + contextTokensOverride?: number; + fallbackContextTokens?: number; + allowAsyncLoad?: boolean; +}): number | undefined { + void params.allowAsyncLoad; + if (typeof params.contextTokensOverride === "number" && params.contextTokensOverride > 0) { + return params.contextTokensOverride; + } + if (params.provider && params.model) { + const configuredWindow = resolveConfiguredProviderContextWindow( + params.cfg, + params.provider, + params.model, + ); + if (configuredWindow !== undefined) { + return configuredWindow; + } + } + return params.fallbackContextTokens ?? DEFAULT_CONTEXT_TOKENS; +} export const statusSummaryRuntime = { resolveContextTokensForModel, diff --git a/src/commands/status.summary.test.ts b/src/commands/status.summary.test.ts index c441ce1d879..15ed07afc9f 100644 --- a/src/commands/status.summary.test.ts +++ b/src/commands/status.summary.test.ts @@ -4,8 +4,15 @@ vi.mock("../channels/config-presence.js", () => ({ hasPotentialConfiguredChannels: vi.fn(() => true), })); -vi.mock("../agents/context.js", () => ({ - resolveContextTokensForModel: vi.fn(() => 200_000), +vi.mock("./status.summary.runtime.js", () => ({ + statusSummaryRuntime: { + classifySessionKey: vi.fn(() => "direct"), + resolveSessionModelRef: vi.fn(() => ({ + provider: "openai", + model: "gpt-5.2", + })), + resolveContextTokensForModel: vi.fn(() => 200_000), + }, })); vi.mock("../agents/defaults.js", () => ({ @@ -14,13 +21,6 @@ vi.mock("../agents/defaults.js", () => ({ DEFAULT_PROVIDER: "openai", })); -vi.mock("../agents/model-selection.js", () => ({ - resolveConfiguredModelRef: vi.fn(() => ({ - provider: "openai", - model: "gpt-5.2", - })), -})); - vi.mock("../config/config.js", () => ({ loadConfig: vi.fn(() => ({})), })); @@ -39,14 +39,6 @@ vi.mock("../gateway/agent-list.js", () => ({ })), })); -vi.mock("../gateway/session-utils.js", () => ({ - classifySessionKey: vi.fn(() => "direct"), - resolveSessionModelRef: vi.fn(() => ({ - provider: "openai", - model: "gpt-5.2", - })), -})); - vi.mock("../infra/channel-summary.js", () => ({ buildChannelSummary: vi.fn(async () => ["ok"]), })); @@ -78,9 +70,9 @@ vi.mock("./status.link-channel.js", () => ({ })); const { hasPotentialConfiguredChannels } = await import("../channels/config-presence.js"); -const { resolveContextTokensForModel } = await import("../agents/context.js"); const { buildChannelSummary } = await import("../infra/channel-summary.js"); const { resolveLinkChannelContext } = await import("./status.link-channel.js"); +const { statusSummaryRuntime } = await import("./status.summary.runtime.js"); const { getStatusSummary } = await import("./status.summary.js"); describe("getStatusSummary", () => { @@ -110,7 +102,7 @@ describe("getStatusSummary", () => { it("does not trigger async context warmup while building status summaries", async () => { await getStatusSummary(); - expect(vi.mocked(resolveContextTokensForModel)).toHaveBeenCalledWith( + expect(vi.mocked(statusSummaryRuntime.resolveContextTokensForModel)).toHaveBeenCalledWith( expect.objectContaining({ allowAsyncLoad: false }), ); });