From 75fcb8c56d297d95ff3a050c2a69cb53a0053041 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 19:23:34 +0100 Subject: [PATCH] perf: lazy-load heavy test imports --- src/acp/control-plane/manager.test.ts | 2 - src/acp/translator.ts | 21 +++- src/media/mime.ts | 5 +- src/security/audit-extra.async.ts | 137 ++++++++++++++++++++++---- src/security/audit-extra.sync.ts | 15 ++- 5 files changed, 144 insertions(+), 36 deletions(-) diff --git a/src/acp/control-plane/manager.test.ts b/src/acp/control-plane/manager.test.ts index f78c6e6cd9c..9130d405cf2 100644 --- a/src/acp/control-plane/manager.test.ts +++ b/src/acp/control-plane/manager.test.ts @@ -4,7 +4,6 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vite import type { OpenClawConfig } from "../../config/config.js"; import type { AcpSessionRuntimeOptions, SessionAcpMeta } from "../../config/sessions/types.js"; import { resetHeartbeatWakeStateForTests } from "../../infra/heartbeat-wake.js"; -import { resetSystemEventsForTest } from "../../infra/system-events.js"; import { withTempDir } from "../../test-helpers/temp-dir.js"; import type { AcpRuntime, AcpRuntimeCapabilities } from "../runtime/types.js"; @@ -237,7 +236,6 @@ describe("AcpSessionManager", () => { } else { process.env.OPENCLAW_STATE_DIR = ORIGINAL_STATE_DIR; } - resetSystemEventsForTest(); resetHeartbeatWakeStateForTests(); resetTaskRegistryForTests({ persist: false }); resetTaskFlowRegistryForTests({ persist: false }); diff --git a/src/acp/translator.ts b/src/acp/translator.ts index 0cc512d7d87..8420c835f03 100644 --- a/src/acp/translator.ts +++ b/src/acp/translator.ts @@ -26,7 +26,6 @@ import type { ToolCallLocation, ToolKind, } from "@agentclientprotocol/sdk"; -import { PROTOCOL_VERSION } from "@agentclientprotocol/sdk"; import { listThinkingLevels } from "../auto-reply/thinking.js"; import type { GatewayClient } from "../gateway/client.js"; import type { EventFrame } from "../gateway/protocol/index.js"; @@ -37,7 +36,6 @@ import { } from "../infra/fixed-window-rate-limit.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; import { shortenHomePath } from "../utils.js"; -import { getAvailableCommands } from "./commands.js"; import { extractAttachmentsFromPrompt, extractToolCallContent, @@ -63,6 +61,21 @@ const ACP_ELEVATED_LEVEL_CONFIG_ID = "elevated_level"; const ACP_LOAD_SESSION_REPLAY_LIMIT = 1_000_000; const ACP_GATEWAY_DISCONNECT_GRACE_MS = 5_000; +let acpCommandsModulePromise: Promise | undefined; +let acpSdkModulePromise: Promise | undefined; + +async function getAvailableCommandsForAcp() { + acpCommandsModulePromise ??= import("./commands.js"); + const { getAvailableCommands } = await acpCommandsModulePromise; + return getAvailableCommands(); +} + +async function getAcpProtocolVersion() { + acpSdkModulePromise ??= import("@agentclientprotocol/sdk"); + const { PROTOCOL_VERSION } = await acpSdkModulePromise; + return PROTOCOL_VERSION; +} + type DisconnectContext = { generation: number; reason: string; @@ -502,7 +515,7 @@ export class AcpGatewayAgent implements Agent { async initialize(_params: InitializeRequest): Promise { return { - protocolVersion: PROTOCOL_VERSION, + protocolVersion: await getAcpProtocolVersion(), agentCapabilities: { loadSession: true, promptCapabilities: { @@ -1212,7 +1225,7 @@ export class AcpGatewayAgent implements Agent { sessionId, update: { sessionUpdate: "available_commands_update", - availableCommands: getAvailableCommands(), + availableCommands: await getAvailableCommandsForAcp(), }, }); } diff --git a/src/media/mime.ts b/src/media/mime.ts index 712121fd8f6..31fc3016ce0 100644 --- a/src/media/mime.ts +++ b/src/media/mime.ts @@ -1,5 +1,4 @@ import path from "node:path"; -import { fileTypeFromBuffer } from "file-type"; import { type MediaKind, mediaKindFromMime } from "./constants.js"; /** @internal */ @@ -66,6 +65,8 @@ const AUDIO_FILE_EXTENSIONS = new Set([ ".wav", ]); +let fileTypeModulePromise: Promise | undefined; + export function normalizeMimeType(mime?: string | null): string | undefined { if (!mime) { return undefined; @@ -87,6 +88,8 @@ async function sniffMime(buffer?: Buffer): Promise { return undefined; } try { + fileTypeModulePromise ??= import("file-type"); + const { fileTypeFromBuffer } = await fileTypeModulePromise; const type = await fileTypeFromBuffer(sliceMimeSniffBuffer(buffer)); return type?.mime ?? undefined; } catch { diff --git a/src/security/audit-extra.async.ts b/src/security/audit-extra.async.ts index 16f453d1b06..f8c611ed3f7 100644 --- a/src/security/audit-extra.async.ts +++ b/src/security/audit-extra.async.ts @@ -5,11 +5,6 @@ */ import fs from "node:fs/promises"; import path from "node:path"; -import { resolveDefaultAgentId } from "../agents/agent-scope.js"; -import { SANDBOX_BROWSER_SECURITY_HASH_EPOCH } from "../agents/sandbox/constants.js"; -import { execDockerRaw, type ExecDockerRawResult } from "../agents/sandbox/docker.js"; -import { resolveSkillSource } from "../agents/skills/source.js"; -import { listAgentWorkspaceDirs } from "../agents/workspace-dirs.js"; import { formatCliCommand } from "../cli/command-format.js"; import { MANIFEST_KEY } from "../compat/legacy-names.js"; import type { OpenClawConfig, ConfigFileSnapshot } from "../config/config.js"; @@ -20,19 +15,10 @@ import { normalizeOptionalLowercaseString, normalizeOptionalString, } from "../shared/string-coerce.js"; -import { - formatPermissionDetail, - formatPermissionRemediation, - inspectPathPermissions, - safeStat, -} from "./audit-fs.js"; import { extensionUsesSkippedScannerPath, isPathInside } from "./scan-paths.js"; import type { SkillScanFinding } from "./skill-scanner.js"; -import * as skillScanner from "./skill-scanner.js"; import type { ExecFn } from "./windows-acl.js"; -export { collectPluginsTrustFindings } from "./audit-plugins-trust.js"; - export type SecurityAuditFinding = { checkId: string; severity: "info" | "warn" | "critical"; @@ -41,10 +27,16 @@ export type SecurityAuditFinding = { remediation?: string; }; +type CollectPluginsTrustFindingsParams = Parameters< + typeof import("./audit-plugins-trust.js").collectPluginsTrustFindings +>[0]; +type SkillScanSummary = Awaited< + ReturnType +>; type ExecDockerRawFn = ( args: string[], opts?: { allowFailure?: boolean; input?: Buffer | string; signal?: AbortSignal }, -) => Promise; +) => Promise; type CodeSafetySummaryCache = Map>; type WorkspaceSkillScanLimits = { @@ -91,6 +83,18 @@ function realpathWithTimeout(p: string, timeoutMs = 2000): Promise | undefined; let configModulePromise: Promise | undefined; +let agentScopeModulePromise: Promise | undefined; +let agentWorkspaceDirsModulePromise: + | Promise + | undefined; +let skillSourceModulePromise: Promise | undefined; +let sandboxDockerModulePromise: Promise | undefined; +let sandboxConstantsModulePromise: + | Promise + | undefined; +let auditPluginsTrustModulePromise: Promise | undefined; +let auditFsModulePromise: Promise | undefined; +let skillScannerModulePromise: Promise | undefined; function loadSkillsModule() { skillsModulePromise ??= import("../agents/skills.js"); @@ -102,10 +106,87 @@ function loadConfigModule() { return configModulePromise; } +function loadAuditFsModule() { + auditFsModulePromise ??= import("./audit-fs.js"); + return auditFsModulePromise; +} + +function loadAgentScopeModule() { + agentScopeModulePromise ??= import("../agents/agent-scope.js"); + return agentScopeModulePromise; +} + +function loadAgentWorkspaceDirsModule() { + agentWorkspaceDirsModulePromise ??= import("../agents/workspace-dirs.js"); + return agentWorkspaceDirsModulePromise; +} + +function loadSkillSourceModule() { + skillSourceModulePromise ??= import("../agents/skills/source.js"); + return skillSourceModulePromise; +} + +function loadSkillScannerModule() { + skillScannerModulePromise ??= import("./skill-scanner.js"); + return skillScannerModulePromise; +} + +async function loadExecDockerRaw(): Promise { + sandboxDockerModulePromise ??= import("../agents/sandbox/docker.js"); + const { execDockerRaw } = await sandboxDockerModulePromise; + return execDockerRaw; +} + +async function loadSandboxBrowserSecurityHashEpoch(): Promise { + sandboxConstantsModulePromise ??= import("../agents/sandbox/constants.js"); + const { SANDBOX_BROWSER_SECURITY_HASH_EPOCH } = await sandboxConstantsModulePromise; + return SANDBOX_BROWSER_SECURITY_HASH_EPOCH; +} + +export async function collectPluginsTrustFindings( + params: CollectPluginsTrustFindingsParams, +): Promise { + auditPluginsTrustModulePromise ??= import("./audit-plugins-trust.js"); + const { collectPluginsTrustFindings: collect } = await auditPluginsTrustModulePromise; + return await collect(params); +} + // -------------------------------------------------------------------------- // Helpers // -------------------------------------------------------------------------- +async function safeStat(targetPath: string): Promise<{ + ok: boolean; + isSymlink: boolean; + isDir: boolean; + mode: number | null; + uid: number | null; + gid: number | null; + error?: string; +}> { + try { + const lst = await fs.lstat(targetPath); + return { + ok: true, + isSymlink: lst.isSymbolicLink(), + isDir: lst.isDirectory(), + mode: typeof lst.mode === "number" ? lst.mode : null, + uid: typeof lst.uid === "number" ? lst.uid : null, + gid: typeof lst.gid === "number" ? lst.gid : null, + }; + } catch (err) { + return { + ok: false, + isSymlink: false, + isDir: false, + mode: null, + uid: null, + gid: null, + error: String(err), + }; + } +} + function expandTilde(p: string, env: NodeJS.ProcessEnv): string | null { if (!p.startsWith("~")) { return p; @@ -197,7 +278,7 @@ async function getCodeSafetySummary(params: { dirPath: string; includeFiles?: string[]; summaryCache?: CodeSafetySummaryCache; -}): Promise>> { +}): Promise { const cacheKey = buildCodeSafetySummaryCacheKey({ dirPath: params.dirPath, includeFiles: params.includeFiles, @@ -206,14 +287,16 @@ async function getCodeSafetySummary(params: { if (cache) { const hit = cache.get(cacheKey); if (hit) { - return (await hit) as Awaited>; + return (await hit) as SkillScanSummary; } + const skillScanner = await loadSkillScannerModule(); const pending = skillScanner.scanDirectoryWithSummary(params.dirPath, { includeFiles: params.includeFiles, }); cache.set(cacheKey, pending); return await pending; } + const skillScanner = await loadSkillScannerModule(); return await skillScanner.scanDirectoryWithSummary(params.dirPath, { includeFiles: params.includeFiles, }); @@ -388,7 +471,10 @@ export async function collectSandboxBrowserHashLabelFindings(params?: { execDockerRawFn?: ExecDockerRawFn; }): Promise { const findings: SecurityAuditFinding[] = []; - const execFn = params?.execDockerRawFn ?? execDockerRaw; + const [execFn, browserHashEpoch] = await Promise.all([ + params?.execDockerRawFn ? Promise.resolve(params.execDockerRawFn) : loadExecDockerRaw(), + loadSandboxBrowserSecurityHashEpoch(), + ]); const containers = await listSandboxBrowserContainers(execFn); if (!containers || containers.length === 0) { return findings; @@ -406,7 +492,7 @@ export async function collectSandboxBrowserHashLabelFindings(params?: { if (!labels.configHash) { missingHash.push(containerName); } - if (labels.epoch !== SANDBOX_BROWSER_SECURITY_HASH_EPOCH) { + if (labels.epoch !== browserHashEpoch) { staleEpoch.push(containerName); } const portMappings = await readSandboxBrowserPortMappings({ @@ -444,7 +530,7 @@ export async function collectSandboxBrowserHashLabelFindings(params?: { title: "Sandbox browser container hash epoch is stale", detail: `Containers: ${staleEpoch.join(", ")}. ` + - `Expected openclaw.browserConfigEpoch=${SANDBOX_BROWSER_SECURITY_HASH_EPOCH}.`, + `Expected openclaw.browserConfigEpoch=${browserHashEpoch}.`, remediation: `${formatCliCommand("openclaw sandbox recreate --browser --all")} (add --force to skip prompt).`, }); } @@ -471,6 +557,7 @@ export async function collectWorkspaceSkillSymlinkEscapeFindings(params: { skillScanLimits?: WorkspaceSkillScanLimits; }): Promise { const findings: SecurityAuditFinding[] = []; + const { listAgentWorkspaceDirs } = await loadAgentWorkspaceDirsModule(); const workspaceDirs = listAgentWorkspaceDirs(params.cfg); if (workspaceDirs.length === 0) { return findings; @@ -589,6 +676,9 @@ export async function collectIncludeFilePermFindings(params: { return findings; } + const { formatPermissionDetail, formatPermissionRemediation, inspectPathPermissions } = + await loadAuditFsModule(); + for (const p of includePaths) { const perms = await inspectPathPermissions(p, { env: params.env, @@ -655,6 +745,8 @@ export async function collectStateDeepFilesystemFindings(params: { }): Promise { const findings: SecurityAuditFinding[] = []; const oauthDir = resolveOAuthDir(params.env, params.stateDir); + const { formatPermissionDetail, formatPermissionRemediation, inspectPathPermissions } = + await loadAuditFsModule(); const oauthPerms = await inspectPathPermissions(oauthDir, { env: params.env, @@ -703,6 +795,7 @@ export async function collectStateDeepFilesystemFindings(params: { ) .filter(Boolean) : []; + const { resolveDefaultAgentId } = await loadAgentScopeModule(); const defaultAgentId = resolveDefaultAgentId(params.cfg); const ids = Array.from(new Set([defaultAgentId, ...agentIds])).map((id) => normalizeAgentId(id)); @@ -943,6 +1036,10 @@ export async function collectInstalledSkillsCodeSafetyFindings(params: { const findings: SecurityAuditFinding[] = []; const pluginExtensionsDir = path.join(params.stateDir, "extensions"); const scannedSkillDirs = new Set(); + const [{ listAgentWorkspaceDirs }, { resolveSkillSource }] = await Promise.all([ + loadAgentWorkspaceDirsModule(), + loadSkillSourceModule(), + ]); const workspaceDirs = listAgentWorkspaceDirs(params.cfg); const { loadWorkspaceSkillEntries } = await loadSkillsModule(); diff --git a/src/security/audit-extra.sync.ts b/src/security/audit-extra.sync.ts index 7200d846c04..85264a90497 100644 --- a/src/security/audit-extra.sync.ts +++ b/src/security/audit-extra.sync.ts @@ -1,14 +1,5 @@ import { resolveSandboxConfigForAgent } from "../agents/sandbox/config.js"; import { isDangerousNetworkMode, normalizeNetworkMode } from "../agents/sandbox/network-mode.js"; -/** - * Synchronous security audit collector functions. - * - * These functions analyze config-based security properties without I/O. - */ -export { - collectAttackSurfaceSummaryFindings, - collectSmallModelRiskFindings, -} from "./audit-extra.summary.js"; import { resolveSandboxToolPolicyForAgent } from "../agents/sandbox/tool-policy.js"; import type { SandboxToolPolicy } from "../agents/sandbox/types.js"; import { getBlockedBindReason } from "../agents/sandbox/validate-sandbox-security.js"; @@ -34,6 +25,12 @@ import { } from "../shared/string-coerce.js"; import { pickSandboxToolPolicy } from "./audit-tool-policy.js"; +/** + * Synchronous security audit collector functions. + * + * These functions analyze config-based security properties without I/O. + */ + export type SecurityAuditFinding = { checkId: string; severity: "info" | "warn" | "critical";