diff --git a/.agents/skills/optimizetests/SKILL.md b/.agents/skills/optimizetests/SKILL.md new file mode 100644 index 00000000000..671da059f56 --- /dev/null +++ b/.agents/skills/optimizetests/SKILL.md @@ -0,0 +1,41 @@ +--- +name: optimizetests +description: Optimize OpenClaw test runtime end to end. Use when the user asks for /optimizetests, slow-test review, import optimization, deduping tests, moving misplaced core coverage to extensions, or reducing CI/test wall time without adding shards or dropping coverage. +--- + +# Optimize Tests + +Goal: real OpenClaw test/runtime speedups with coverage intact. Do not add shards, +skip assertions, weaken gates, or tune runner flags as the main fix. + +## Runbook + +1. Read `docs/help/testing.md`, `docs/ci.md`, and the scoped `AGENTS.md` files + for any subtree you will edit. +2. Establish evidence before edits: + - Full ranking: `pnpm test:perf:groups --full-suite --allow-failures --output .artifacts/test-perf/.json` + - Targeted file: `timeout 240 /usr/bin/time -l pnpm test --maxWorkers=1 --reporter=verbose` + - Import suspicion: add `OPENCLAW_VITEST_IMPORT_DURATIONS=1 OPENCLAW_VITEST_PRINT_IMPORT_BREAKDOWN=1` +3. Attack highest-return hotspots first: + - broad barrels or `importActual()` in hot tests + - per-test `vi.resetModules()` plus fresh imports + - expensive gateway/server/client setup where reset/reuse proves same behavior + - core tests asserting extension-owned behavior + - duplicated fixture construction or contract assertions +4. Prefer production-quality fixes: + - narrow runtime seams over broad mocks + - pure helpers for static parsing/metadata + - injected deps over module resets + - extension-owned tests for bundled plugin/provider/channel behavior +5. After each change, rerun the same benchmark and the proving test lane. Record + before/after wall time, Vitest duration, and max RSS when available. +6. Run `pnpm check:changed`; run broader gates (`pnpm check`, `pnpm test`, + `pnpm build`) when touched surfaces require them. +7. Commit scoped changes with `scripts/committer "" `. + Push when requested. If CI is red, inspect with `gh run list/view`, fix, push, + repeat until current CI is green or a blocker is proven unrelated. + +## Reuse + +For deeper tactics, also use `$openclaw-test-performance`; it contains the +hotspot catalog, benchmark commands, and handoff format. diff --git a/.agents/skills/optimizetests/agents/openai.yaml b/.agents/skills/optimizetests/agents/openai.yaml new file mode 100644 index 00000000000..2a38432bcdf --- /dev/null +++ b/.agents/skills/optimizetests/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "Optimize Tests" + short_description: "Benchmark and speed up OpenClaw tests" + default_prompt: "Use $optimizetests to benchmark slow OpenClaw tests, optimize imports and duplicated setup, move misplaced core coverage to extensions, verify gates, commit scoped changes, push, and keep CI green without adding shards or dropping coverage." +policy: + allow_implicit_invocation: false diff --git a/CHANGELOG.md b/CHANGELOG.md index 88d1d531a01..f494a3f29ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ Docs: https://docs.openclaw.ai - OpenShell: pin host-side sandbox writes under the mounted root so symlink-parent rebinds cannot redirect `writeFile` outside the workspace during local mirror updates. (#69797) Thanks @drobison00. - Ollama/media understanding: register Ollama as an image-capable media-understanding provider so `agents.defaults.imageModel.primary` values like `ollama/qwen2.5vl:7b` route through the Ollama plugin instead of failing as unknown models. (#69816) Thanks @soloclz. - CLI/media understanding: make `openclaw infer image describe --model ` execute the explicit image model instead of skipping description when that model supports native vision. +- Usage/providers: keep plugin-owned usage auth enabled when manifest-declared provider auth env vars such as `MINIMAX_CODE_PLAN_KEY` are present, so `/usage` can resolve MiniMax billing credentials through the provider plugin. ## 2026.4.20 diff --git a/extensions/anthropic/cli-constants.ts b/extensions/anthropic/cli-constants.ts new file mode 100644 index 00000000000..e8af5956b1f --- /dev/null +++ b/extensions/anthropic/cli-constants.ts @@ -0,0 +1,41 @@ +export const CLAUDE_CLI_BACKEND_ID = "claude-cli"; +export const CLAUDE_CLI_DEFAULT_MODEL_REF = `${CLAUDE_CLI_BACKEND_ID}/claude-opus-4-7`; +export const CLAUDE_CLI_DEFAULT_ALLOWLIST_REFS = [ + CLAUDE_CLI_DEFAULT_MODEL_REF, + `${CLAUDE_CLI_BACKEND_ID}/claude-sonnet-4-6`, + `${CLAUDE_CLI_BACKEND_ID}/claude-opus-4-6`, + `${CLAUDE_CLI_BACKEND_ID}/claude-opus-4-5`, + `${CLAUDE_CLI_BACKEND_ID}/claude-sonnet-4-5`, + `${CLAUDE_CLI_BACKEND_ID}/claude-haiku-4-5`, +] as const; + +export const CLAUDE_CLI_MODEL_ALIASES: Record = { + opus: "opus", + "opus-4.7": "opus", + "opus-4.6": "opus", + "opus-4.5": "opus", + "opus-4": "opus", + "claude-opus-4-7": "opus", + "claude-opus-4-6": "opus", + "claude-opus-4-5": "opus", + "claude-opus-4": "opus", + sonnet: "sonnet", + "sonnet-4.6": "sonnet", + "sonnet-4.5": "sonnet", + "sonnet-4.1": "sonnet", + "sonnet-4.0": "sonnet", + "claude-sonnet-4-6": "sonnet", + "claude-sonnet-4-5": "sonnet", + "claude-sonnet-4-1": "sonnet", + "claude-sonnet-4-0": "sonnet", + haiku: "haiku", + "haiku-3.5": "haiku", + "claude-haiku-3-5": "haiku", +}; + +export const CLAUDE_CLI_SESSION_ID_FIELDS = [ + "session_id", + "sessionId", + "conversation_id", + "conversationId", +] as const; diff --git a/extensions/anthropic/cli-shared.ts b/extensions/anthropic/cli-shared.ts index 288f0be4ba3..737baae346d 100644 --- a/extensions/anthropic/cli-shared.ts +++ b/extensions/anthropic/cli-shared.ts @@ -1,47 +1,13 @@ import type { CliBackendConfig } from "openclaw/plugin-sdk/cli-backend"; import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime"; - -export const CLAUDE_CLI_BACKEND_ID = "claude-cli"; -export const CLAUDE_CLI_DEFAULT_MODEL_REF = `${CLAUDE_CLI_BACKEND_ID}/claude-opus-4-7`; -export const CLAUDE_CLI_DEFAULT_ALLOWLIST_REFS = [ +import { CLAUDE_CLI_BACKEND_ID } from "./cli-constants.js"; +export { + CLAUDE_CLI_BACKEND_ID, + CLAUDE_CLI_DEFAULT_ALLOWLIST_REFS, CLAUDE_CLI_DEFAULT_MODEL_REF, - `${CLAUDE_CLI_BACKEND_ID}/claude-sonnet-4-6`, - `${CLAUDE_CLI_BACKEND_ID}/claude-opus-4-6`, - `${CLAUDE_CLI_BACKEND_ID}/claude-opus-4-5`, - `${CLAUDE_CLI_BACKEND_ID}/claude-sonnet-4-5`, - `${CLAUDE_CLI_BACKEND_ID}/claude-haiku-4-5`, -] as const; - -export const CLAUDE_CLI_MODEL_ALIASES: Record = { - opus: "opus", - "opus-4.7": "opus", - "opus-4.6": "opus", - "opus-4.5": "opus", - "opus-4": "opus", - "claude-opus-4-7": "opus", - "claude-opus-4-6": "opus", - "claude-opus-4-5": "opus", - "claude-opus-4": "opus", - sonnet: "sonnet", - "sonnet-4.6": "sonnet", - "sonnet-4.5": "sonnet", - "sonnet-4.1": "sonnet", - "sonnet-4.0": "sonnet", - "claude-sonnet-4-6": "sonnet", - "claude-sonnet-4-5": "sonnet", - "claude-sonnet-4-1": "sonnet", - "claude-sonnet-4-0": "sonnet", - haiku: "haiku", - "haiku-3.5": "haiku", - "claude-haiku-3-5": "haiku", -}; - -export const CLAUDE_CLI_SESSION_ID_FIELDS = [ - "session_id", - "sessionId", - "conversation_id", - "conversationId", -] as const; + CLAUDE_CLI_MODEL_ALIASES, + CLAUDE_CLI_SESSION_ID_FIELDS, +} from "./cli-constants.js"; // Claude Code honors provider-routing, auth, and config-root env before // consulting its local login state, so inherited shell overrides must not diff --git a/extensions/anthropic/config-defaults.ts b/extensions/anthropic/config-defaults.ts index 201263b6699..334ac099589 100644 --- a/extensions/anthropic/config-defaults.ts +++ b/extensions/anthropic/config-defaults.ts @@ -1,10 +1,20 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/plugin-entry"; -import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; -import { CLAUDE_CLI_BACKEND_ID, CLAUDE_CLI_DEFAULT_ALLOWLIST_REFS } from "./cli-shared.js"; +import { CLAUDE_CLI_BACKEND_ID, CLAUDE_CLI_DEFAULT_ALLOWLIST_REFS } from "./cli-constants.js"; const ANTHROPIC_PROVIDER_API = "anthropic-messages"; +function normalizeLowercaseStringOrEmpty(value: unknown): string { + return typeof value === "string" ? value.trim().toLowerCase() : ""; +} + +function normalizeProviderId(provider: string): string { + const normalized = normalizeLowercaseStringOrEmpty(provider); + if (normalized === "bedrock" || normalized === "aws-bedrock") { + return "amazon-bedrock"; + } + return normalized; +} + function resolveAnthropicDefaultAuthMode( config: OpenClawConfig, env: NodeJS.ProcessEnv, diff --git a/src/agents/pi-embedded-runner/run/attempt-session.ts b/src/agents/pi-embedded-runner/run/attempt-session.ts new file mode 100644 index 00000000000..35854735ce7 --- /dev/null +++ b/src/agents/pi-embedded-runner/run/attempt-session.ts @@ -0,0 +1,20 @@ +export type EmbeddedAgentSessionOptions = { + cwd: string; + agentDir: string; + authStorage: unknown; + modelRegistry: unknown; + model: unknown; + thinkingLevel: unknown; + tools: readonly unknown[]; + customTools: readonly unknown[]; + sessionManager: unknown; + settingsManager: unknown; + resourceLoader: unknown; +}; + +export async function createEmbeddedAgentSessionWithResourceLoader(params: { + createAgentSession: (options: EmbeddedAgentSessionOptions) => Promise | Result; + options: EmbeddedAgentSessionOptions; +}): Promise { + return await params.createAgentSession(params.options); +} diff --git a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.resource-loader.test.ts b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.resource-loader.test.ts index e6166cd9a3c..496cd82ea53 100644 --- a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.resource-loader.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.resource-loader.test.ts @@ -1,42 +1,32 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { - cleanupTempPaths, - createContextEngineAttemptRunner, - getHoisted, - resetEmbeddedAttemptHarness, -} from "./attempt.spawn-workspace.test-support.js"; - -const hoisted = getHoisted(); +import { describe, expect, it, vi } from "vitest"; +import { createEmbeddedAgentSessionWithResourceLoader } from "./attempt-session.js"; describe("runEmbeddedAttempt resource loader wiring", () => { - const tempPaths: string[] = []; - - beforeEach(() => { - resetEmbeddedAttemptHarness(); - }); - - afterEach(async () => { - await cleanupTempPaths(tempPaths); - }); - it("passes an explicit resourceLoader to createAgentSession even without extension factories", async () => { - await createContextEngineAttemptRunner({ - sessionKey: "agent:main:guildchat:dm:test-resource-loader", - tempPaths, - contextEngine: { - assemble: async ({ messages }) => ({ - messages, - estimatedTokens: 1, - }), + const resourceLoader = { reload: vi.fn() }; + const createAgentSession = vi.fn(async () => ({ session: { id: "session" } })); + + await createEmbeddedAgentSessionWithResourceLoader({ + createAgentSession, + options: { + cwd: "/tmp/workspace", + agentDir: "/tmp/agent", + authStorage: {}, + modelRegistry: {}, + model: {}, + thinkingLevel: undefined, + tools: [], + customTools: [], + sessionManager: {}, + settingsManager: {}, + resourceLoader, }, }); - expect(hoisted.createAgentSessionMock).toHaveBeenCalled(); - expect(hoisted.createAgentSessionMock).toHaveBeenCalledWith( + expect(createAgentSession).toHaveBeenCalledOnce(); + expect(createAgentSession).toHaveBeenCalledWith( expect.objectContaining({ - resourceLoader: expect.objectContaining({ - reload: expect.any(Function), - }), + resourceLoader, }), ); }); diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index b650ddd6c93..7c565734085 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -178,6 +178,7 @@ import { import { splitSdkTools } from "../tool-split.js"; import { mapThinkingLevel } from "../utils.js"; import { flushPendingToolResultsAfterIdle } from "../wait-for-idle-before-flush.js"; +import { createEmbeddedAgentSessionWithResourceLoader } from "./attempt-session.js"; export { buildContextEnginePromptCacheInfo } from "./attempt.context-engine-helpers.js"; import { resolveAttemptWorkspaceBootstrapRouting, @@ -1108,18 +1109,22 @@ export async function runEmbeddedAttempt( const allCustomTools = [...customTools, ...clientToolDefs]; - ({ session } = await createAgentSession({ - cwd: resolvedWorkspace, - agentDir, - authStorage: params.authStorage, - modelRegistry: params.modelRegistry, - model: params.model, - thinkingLevel: mapThinkingLevel(params.thinkLevel), - tools: builtInTools, - customTools: allCustomTools, - sessionManager, - settingsManager, - resourceLoader, + ({ session } = await createEmbeddedAgentSessionWithResourceLoader({ + createAgentSession: async (options) => + await createAgentSession(options as Parameters[0]), + options: { + cwd: resolvedWorkspace, + agentDir, + authStorage: params.authStorage, + modelRegistry: params.modelRegistry, + model: params.model, + thinkingLevel: mapThinkingLevel(params.thinkLevel), + tools: builtInTools, + customTools: allCustomTools, + sessionManager, + settingsManager, + resourceLoader, + }, })); applySystemPromptOverrideToSession(session, systemPromptText); if (!session) { diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index 4466d7f88ff..d3ef69e83ca 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.test.ts @@ -30,6 +30,9 @@ const sessionForkMocks = vi.hoisted(() => ({ forkSessionFromParent: vi.fn(), nextSessionId: 0, })); +const channelSummaryMocks = vi.hoisted(() => ({ + buildChannelSummary: vi.fn(async () => [] as string[]), +})); type ForkSessionParamsForTest = { parentEntry: SessionEntry; @@ -51,6 +54,10 @@ vi.mock("../../plugins/hook-runner-global.js", () => ({ getGlobalHookRunner: () => null, })); +vi.mock("../../infra/channel-summary.js", () => ({ + buildChannelSummary: channelSummaryMocks.buildChannelSummary, +})); + // Perf: session-store locks are exercised elsewhere; most session tests don't need FS lock files. vi.mock("../../agents/session-write-lock.js", async () => { const actual = await vi.importActual( @@ -240,6 +247,7 @@ function registerCurrentConversationBindingAdapterForTest(params: { } beforeEach(() => { + channelSummaryMocks.buildChannelSummary.mockReset().mockResolvedValue([]); sessionBindingTesting.resetSessionBindingAdaptersForTests(); sessionForkMocks.nextSessionId = 0; sessionForkMocks.forkSessionFromParent @@ -2414,36 +2422,9 @@ describe("drainFormattedSystemEvents", () => { }); it("keeps channel summary lines prefixed as trusted system output on new main sessions", async () => { - setActivePluginRegistry( - createTestRegistry([ - { - pluginId: "whatsapp", - source: "test", - plugin: { - ...createChannelTestPluginBase({ id: "whatsapp", label: "WhatsApp" }), - config: { - listAccountIds: () => ["default"], - defaultAccountId: () => "default", - inspectAccount: () => ({ - accountId: "default", - enabled: true, - configured: true, - name: "line one\nline two", - }), - resolveAccount: () => ({ - accountId: "default", - enabled: true, - configured: true, - name: "line one\nline two", - }), - }, - status: { - buildChannelSummary: async () => ({ linked: true }), - }, - }, - }, - ]), - ); + channelSummaryMocks.buildChannelSummary.mockResolvedValue([ + "WhatsApp: linked\n - default (line one\nline two)", + ]); const result = await drainFormattedSystemEvents({ cfg: { channels: {} } as OpenClawConfig, diff --git a/src/channels/conversation-binding-context.ts b/src/channels/conversation-binding-context.ts index 1253432ebb3..a61f9df8019 100644 --- a/src/channels/conversation-binding-context.ts +++ b/src/channels/conversation-binding-context.ts @@ -91,6 +91,13 @@ function resolveChannelTargetId(params: { return target; } + const explicitConversationId = resolveConversationIdFromTargets({ + targets: [target], + }); + if (explicitConversationId) { + return explicitConversationId; + } + const parsed = parseExplicitTargetForChannel(params.channel, target); const parsedTarget = normalizeOptionalString(parsed?.to); if (parsedTarget) { @@ -101,10 +108,7 @@ function resolveChannelTargetId(params: { ); } - const explicitConversationId = resolveConversationIdFromTargets({ - targets: [target], - }); - return explicitConversationId ?? target; + return target; } function buildThreadingContext(params: { diff --git a/src/commands/health.snapshot.test.ts b/src/commands/health.snapshot.test.ts index 3a3a341e1e6..b18aa64c85c 100644 --- a/src/commands/health.snapshot.test.ts +++ b/src/commands/health.snapshot.test.ts @@ -25,14 +25,9 @@ type TelegramHealthAccount = { }; async function loadFreshHealthModulesForTest() { - vi.doMock("../config/config.js", async () => { - const actual = - await vi.importActual("../config/config.js"); - return { - ...actual, - loadConfig: () => testConfig, - }; - }); + vi.doMock("../config/config.js", () => ({ + loadConfig: () => testConfig, + })); vi.doMock("../config/sessions.js", () => ({ resolveStorePath: () => "/tmp/sessions.json", resolveSessionFilePath: vi.fn(() => "/tmp/sessions.json"), @@ -55,6 +50,9 @@ async function loadFreshHealthModulesForTest() { logWebSelfId: vi.fn(), logoutWeb: vi.fn(), })); + vi.doMock("../channels/plugins/read-only.js", () => ({ + listReadOnlyChannelPluginsForConfig: () => [createTelegramHealthPlugin()], + })); const [pluginsRuntime, channelTestUtils, health] = await Promise.all([ import("../plugins/runtime.js"), diff --git a/src/config/io.write-config.test.ts b/src/config/io.write-config.test.ts index 21653a3bb44..bb7b0c943b5 100644 --- a/src/config/io.write-config.test.ts +++ b/src/config/io.write-config.test.ts @@ -4,6 +4,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import type { PluginManifestRegistry } from "../plugins/manifest-registry.js"; import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js"; import { createConfigIO } from "./io.js"; +import type { ConfigFileSnapshot } from "./types.openclaw.js"; // Mock the plugin manifest registry so we can register a fake channel whose // AJV JSON Schema carries a `default` value. This lets the #56772 regression @@ -241,24 +242,44 @@ describe("config io write", () => { gateway: { mode: "local" }, channels: { telegram: { enabled: true, dmPolicy: "pairing" } }, agents: { list: [{ id: "main", default: true, workspace: "/tmp/openclaw-main" }] }, - tools: { profile: "safe" }, + tools: { profile: "messaging" }, commands: { ownerDisplay: "hash" }, - }; - await fs.writeFile(configPath, `${JSON.stringify(original, null, 2)}\n`, "utf-8"); + } satisfies ConfigFileSnapshot["config"]; + const originalRaw = `${JSON.stringify(original, null, 2)}\n`; + await fs.writeFile(configPath, originalRaw, "utf-8"); const warn = vi.fn(); const io = createConfigIO({ env: { VITEST: "true" } as NodeJS.ProcessEnv, homedir: () => home, logger: { warn, error: vi.fn() }, }); + const baseSnapshot = { + path: configPath, + exists: true, + raw: originalRaw, + parsed: original, + sourceConfig: original, + resolved: original, + valid: true, + runtimeConfig: original, + config: original, + issues: [], + warnings: [], + legacyIssues: [], + } satisfies ConfigFileSnapshot; - await expect(io.writeConfigFile({ update: { channel: "beta" } })).rejects.toMatchObject({ + await expect( + io.writeConfigFile( + { update: { channel: "beta" } }, + { + baseSnapshot, + }, + ), + ).rejects.toMatchObject({ code: "CONFIG_WRITE_REJECTED", }); - await expect(fs.readFile(configPath, "utf-8")).resolves.toBe( - `${JSON.stringify(original, null, 2)}\n`, - ); + await expect(fs.readFile(configPath, "utf-8")).resolves.toBe(originalRaw); const entries = await fs.readdir(path.dirname(configPath)); expect(entries.some((entry) => entry.includes(".rejected."))).toBe(true); expect(warn).toHaveBeenCalledWith(expect.stringContaining("Config write rejected:")); diff --git a/src/infra/channel-summary.test.ts b/src/infra/channel-summary.test.ts index 55e7d8cf4b9..88738828186 100644 --- a/src/infra/channel-summary.test.ts +++ b/src/infra/channel-summary.test.ts @@ -1,7 +1,5 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; import type { ChannelPlugin } from "../channels/plugins/types.js"; -import { setActivePluginRegistry } from "../plugins/runtime.js"; -import { createTestRegistry } from "../test-utils/channel-plugins.js"; import { buildChannelSummary } from "./channel-summary.js"; const isFixtureAccountConfigured = (account: unknown) => @@ -189,20 +187,11 @@ function makeFallbackSummaryPlugin(params: { } describe("buildChannelSummary", () => { - afterEach(() => { - setActivePluginRegistry(createTestRegistry([])); - }); - it("preserves Slack HTTP signing-secret unavailable state from source config", async () => { - setActivePluginRegistry( - createTestRegistry([ - { pluginId: "slack", plugin: makeSlackHttpSummaryPlugin(), source: "test" }, - ]), - ); - const lines = await buildChannelSummary({ marker: "resolved", channels: {} } as never, { colorize: false, includeAllowFrom: false, + plugins: [makeSlackHttpSummaryPlugin()], sourceConfig: { marker: "source", channels: {} } as never, }); @@ -213,44 +202,28 @@ describe("buildChannelSummary", () => { }); it("shows disabled status without configured account detail lines", async () => { - setActivePluginRegistry( - createTestRegistry([ - { - pluginId: "telegram", - plugin: makeTelegramSummaryPlugin({ enabled: false, configured: false }), - source: "test", - }, - ]), - ); - const lines = await buildChannelSummary({ channels: {} } as never, { colorize: false, includeAllowFrom: true, + plugins: [makeTelegramSummaryPlugin({ enabled: false, configured: false })], }); expect(lines).toEqual(["Telegram: disabled +15551234567"]); }); it("includes linked summary metadata and truncates allow-from details", async () => { - setActivePluginRegistry( - createTestRegistry([ - { - pluginId: "telegram", - plugin: makeTelegramSummaryPlugin({ - enabled: true, - configured: true, - linked: true, - authAgeMs: 300_000, - allowFrom: ["alice", "bob", "carol"], - }), - source: "test", - }, - ]), - ); - const lines = await buildChannelSummary({ channels: {} } as never, { colorize: false, includeAllowFrom: true, + plugins: [ + makeTelegramSummaryPlugin({ + enabled: true, + configured: true, + linked: true, + authAgeMs: 300_000, + allowFrom: ["alice", "bob", "carol"], + }), + ], }); expect(lines).toContain("Telegram: linked +15551234567 auth 5m ago"); @@ -258,23 +231,16 @@ describe("buildChannelSummary", () => { }); it("shows not-linked status when linked metadata is explicitly false", async () => { - setActivePluginRegistry( - createTestRegistry([ - { - pluginId: "telegram", - plugin: makeTelegramSummaryPlugin({ - enabled: true, - configured: true, - linked: false, - }), - source: "test", - }, - ]), - ); - const lines = await buildChannelSummary({ channels: {} } as never, { colorize: false, includeAllowFrom: false, + plugins: [ + makeTelegramSummaryPlugin({ + enabled: true, + configured: true, + linked: false, + }), + ], }); expect(lines).toContain("Telegram: not linked +15551234567"); @@ -282,42 +248,26 @@ describe("buildChannelSummary", () => { }); it("prefers plugin statusState when provided", async () => { - setActivePluginRegistry( - createTestRegistry([ - { - pluginId: "telegram", - plugin: makeTelegramSummaryPlugin({ - enabled: true, - configured: true, - statusState: "unstable", - }), - source: "test", - }, - ]), - ); - const lines = await buildChannelSummary({ channels: {} } as never, { colorize: false, includeAllowFrom: false, + plugins: [ + makeTelegramSummaryPlugin({ + enabled: true, + configured: true, + statusState: "unstable", + }), + ], }); expect(lines).toContain("Telegram: auth stabilizing +15551234567"); }); it("renders non-slack account detail fields for configured accounts", async () => { - setActivePluginRegistry( - createTestRegistry([ - { - pluginId: "signal", - plugin: makeSignalSummaryPlugin({ enabled: false, configured: true }), - source: "test", - }, - ]), - ); - const lines = await buildChannelSummary({ channels: {} } as never, { colorize: false, includeAllowFrom: false, + plugins: [makeSignalSummaryPlugin({ enabled: false, configured: true })], }); expect(lines).toEqual([ @@ -327,47 +277,33 @@ describe("buildChannelSummary", () => { }); it("uses the channel label and default account id when no accounts exist", async () => { - setActivePluginRegistry( - createTestRegistry([ - { - pluginId: "fallback-plugin", - plugin: makeFallbackSummaryPlugin({ - enabled: true, - configured: true, - accountIds: [], - defaultAccountId: "fallback-account", - }), - source: "test", - }, - ]), - ); - const lines = await buildChannelSummary({ channels: {} } as never, { colorize: false, includeAllowFrom: false, + plugins: [ + makeFallbackSummaryPlugin({ + enabled: true, + configured: true, + accountIds: [], + defaultAccountId: "fallback-account", + }), + ], }); expect(lines).toEqual(["Fallback: configured", " - fallback-account"]); }); it("shows not-configured status when enabled accounts exist without configured ones", async () => { - setActivePluginRegistry( - createTestRegistry([ - { - pluginId: "fallback-plugin", - plugin: makeFallbackSummaryPlugin({ - enabled: true, - configured: false, - accountIds: ["fallback-account"], - }), - source: "test", - }, - ]), - ); - const lines = await buildChannelSummary({ channels: {} } as never, { colorize: false, includeAllowFrom: false, + plugins: [ + makeFallbackSummaryPlugin({ + enabled: true, + configured: false, + accountIds: ["fallback-account"], + }), + ], }); expect(lines).toEqual(["Fallback: not configured"]); diff --git a/src/infra/channel-summary.ts b/src/infra/channel-summary.ts index 653c521f734..440fe77ab85 100644 --- a/src/infra/channel-summary.ts +++ b/src/infra/channel-summary.ts @@ -4,11 +4,10 @@ import { buildChannelAccountSnapshot, formatChannelAllowFrom, } from "../channels/account-summary.js"; -import { listReadOnlyChannelPluginsForConfig } from "../channels/plugins/read-only.js"; import { formatChannelStatusState } from "../channels/plugins/status-state.js"; import type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; import type { ChannelAccountSnapshot } from "../channels/plugins/types.public.js"; -import { type OpenClawConfig, loadConfig } from "../config/config.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; import { theme } from "../terminal/theme.js"; import { formatTimeAgo } from "./format-time/format-relative.ts"; @@ -16,10 +15,11 @@ import { formatTimeAgo } from "./format-time/format-relative.ts"; export type ChannelSummaryOptions = { colorize?: boolean; includeAllowFrom?: boolean; + plugins?: readonly ChannelPlugin[]; sourceConfig?: OpenClawConfig; }; -const DEFAULT_OPTIONS: Omit, "sourceConfig"> = { +const DEFAULT_OPTIONS: Omit, "plugins" | "sourceConfig"> = { colorize: false, includeAllowFrom: false, }; @@ -43,6 +43,16 @@ const formatAccountLabel = (params: { accountId: string; name?: string }) => { const accountLine = (label: string, details: string[]) => ` - ${label}${details.length ? ` (${details.join(", ")})` : ""}`; +async function loadChannelSummaryConfig(): Promise { + const { loadConfig } = await import("../config/config.js"); + return loadConfig(); +} + +async function listChannelSummaryPlugins(cfg: OpenClawConfig): Promise { + const { listReadOnlyChannelPluginsForConfig } = await import("../channels/plugins/read-only.js"); + return listReadOnlyChannelPluginsForConfig(cfg); +} + const buildAccountDetails = (params: { entry: ChannelAccountEntry; plugin: ChannelPlugin; @@ -106,14 +116,15 @@ export async function buildChannelSummary( cfg?: OpenClawConfig, options?: ChannelSummaryOptions, ): Promise { - const effective = cfg ?? loadConfig(); + const effective = cfg ?? (await loadChannelSummaryConfig()); const lines: string[] = []; const resolved = { ...DEFAULT_OPTIONS, ...options }; const tint = (value: string, color?: (input: string) => string) => resolved.colorize && color ? color(value) : value; const sourceConfig = options?.sourceConfig ?? effective; - for (const plugin of listReadOnlyChannelPluginsForConfig(effective)) { + const plugins = options?.plugins ?? (await listChannelSummaryPlugins(effective)); + for (const plugin of plugins) { const accountIds = plugin.config.listAccountIds(effective); const defaultAccountId = plugin.config.defaultAccountId?.(effective) ?? accountIds[0] ?? DEFAULT_ACCOUNT_ID; diff --git a/src/infra/provider-usage.auth.ts b/src/infra/provider-usage.auth.ts index 26ff9275eb4..3d48d1435e2 100644 --- a/src/infra/provider-usage.auth.ts +++ b/src/infra/provider-usage.auth.ts @@ -22,6 +22,7 @@ import { type PluginManifestRecord, } from "../plugins/manifest-registry.js"; import { resolveProviderUsageAuthWithPlugin } from "../plugins/provider-runtime.js"; +import { resolveProviderAuthEnvVarCandidates } from "../secrets/provider-env-vars.js"; import { normalizeSecretInput } from "../utils/normalize-secret-input.js"; import { resolveLegacyPiAgentAccessToken } from "./provider-usage.shared.js"; import type { UsageProviderId } from "./provider-usage.types.js"; @@ -76,6 +77,29 @@ function resolveProviderApiKeyFromConfig(params: { return undefined; } +function hasProviderAuthEnvCredentialSource(params: { + state: UsageAuthState; + providerIds: string[]; +}): boolean { + const candidates = resolveProviderAuthEnvVarCandidates({ + config: params.state.cfg, + env: { + ...(process.env.VITEST ? process.env : {}), + ...params.state.env, + }, + }); + for (const providerId of normalizeProviderIds(params.providerIds)) { + const envVars = Object.hasOwn(candidates, providerId) ? candidates[providerId] : undefined; + if (!envVars) { + continue; + } + if (envVars.some((envVar) => Boolean(normalizeSecretInput(params.state.env[envVar])))) { + return true; + } + } + return false; +} + function resolveProviderApiKeyFromConfigAndStore(params: { state: UsageAuthState; providerIds: string[]; @@ -353,16 +377,22 @@ export async function resolveProviderAuths(params: { const auths: ProviderAuth[] = []; for (const provider of params.providers) { + const directCredentialState = { ...stateBase, allowAuthProfileStore: false }; const credentialProviderIds = resolveUsageCredentialProviderIds({ - state: { ...stateBase, allowAuthProfileStore: false }, + state: directCredentialState, provider, }); - const hasDirectCredentialSource = Boolean( - resolveProviderApiKeyFromConfig({ - state: { ...stateBase, allowAuthProfileStore: false }, + const hasDirectCredentialSource = + Boolean( + resolveProviderApiKeyFromConfig({ + state: directCredentialState, + providerIds: credentialProviderIds, + }), + ) || + hasProviderAuthEnvCredentialSource({ + state: directCredentialState, providerIds: credentialProviderIds, - }), - ); + }); const allowAuthProfileStore = !params.skipPluginAuthWithoutCredentialSource || hasDirectCredentialSource ||