Merge branch 'main' into dashboard-v2-ui-utils

This commit is contained in:
Val Alexander 2026-03-09 19:54:08 -05:00 committed by GitHub
commit 7bdf866403
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 504 additions and 103 deletions

View File

@ -37,6 +37,10 @@ Docs: https://docs.openclaw.ai
- Agents/fallback observability: add structured, sanitized model-fallback decision and auth-profile failure-state events with correlated run IDs so cooldown probes and failover paths are easier to trace in logs. (#41337) thanks @altaywtf.
- Protocol/Swift model sync: regenerate pending node work Swift bindings after the landed `node.pending.*` schema additions so generated protocol artifacts are consistent again. (#41477) Thanks @mbelinky.
- Discord/reply chunking: resolve the effective `maxLinesPerMessage` config across live reply paths and preserve `chunkMode` in the fast send path so long Discord replies no longer split unexpectedly at the default 17-line limit. (#40133) thanks @rbutera.
- Logging/probe observations: suppress structured embedded and model-fallback probe warnings on the console without hiding error or fatal output. (#41338) thanks @altaywtf.
- Agents/fallback: treat HTTP 499 responses as transient in both raw-text and structured failover paths so Anthropic-style client-closed overload responses trigger model fallback reliably. (#41468) thanks @zeroasterisk.
- Plugins/context-engine model auth: expose `runtime.modelAuth` and plugin-sdk auth helpers so plugins can resolve provider/model API keys through the normal auth pipeline. (#41090) thanks @xinhuagu.
- CLI/memory teardown: close cached memory search/index managers in the one-shot CLI shutdown path so watcher-backed memory caches no longer keep completed CLI runs alive after output finishes. (#40389) thanks @Julbarth.
## 2026.3.8

View File

@ -253,6 +253,11 @@ export function createPluginRuntimeMock(overrides: DeepPartial<PluginRuntime> =
state: {
resolveStateDir: vi.fn(() => "/tmp/openclaw"),
},
modelAuth: {
getApiKeyForModel: vi.fn() as unknown as PluginRuntime["modelAuth"]["getApiKeyForModel"],
resolveApiKeyForProvider:
vi.fn() as unknown as PluginRuntime["modelAuth"]["resolveApiKeyForProvider"],
},
subagent: {
run: vi.fn(),
waitForRun: vi.fn(),

View File

@ -67,6 +67,7 @@ describe("failover-error", () => {
expect(resolveFailoverReasonFromError({ statusCode: "429" })).toBe("rate_limit");
expect(resolveFailoverReasonFromError({ status: 403 })).toBe("auth");
expect(resolveFailoverReasonFromError({ status: 408 })).toBe("timeout");
expect(resolveFailoverReasonFromError({ status: 499 })).toBe("timeout");
expect(resolveFailoverReasonFromError({ status: 400 })).toBe("format");
// Keep the status-only path behavior-preserving and conservative.
expect(resolveFailoverReasonFromError({ status: 500 })).toBeNull();
@ -93,6 +94,12 @@ describe("failover-error", () => {
message: ANTHROPIC_OVERLOADED_PAYLOAD,
}),
).toBe("overloaded");
expect(
resolveFailoverReasonFromError({
status: 499,
message: ANTHROPIC_OVERLOADED_PAYLOAD,
}),
).toBe("overloaded");
expect(
resolveFailoverReasonFromError({
status: 429,

View File

@ -443,6 +443,7 @@ describe("isLikelyContextOverflowError", () => {
describe("isTransientHttpError", () => {
it("returns true for retryable 5xx status codes", () => {
expect(isTransientHttpError("499 Client Closed Request")).toBe(true);
expect(isTransientHttpError("500 Internal Server Error")).toBe(true);
expect(isTransientHttpError("502 Bad Gateway")).toBe(true);
expect(isTransientHttpError("503 Service Unavailable")).toBe(true);
@ -457,6 +458,19 @@ describe("isTransientHttpError", () => {
});
});
describe("classifyFailoverReasonFromHttpStatus", () => {
it("treats HTTP 499 as transient for structured errors", () => {
expect(classifyFailoverReasonFromHttpStatus(499)).toBe("timeout");
expect(classifyFailoverReasonFromHttpStatus(499, "499 Client Closed Request")).toBe("timeout");
expect(
classifyFailoverReasonFromHttpStatus(
499,
'{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}',
),
).toBe("overloaded");
});
});
describe("isFailoverErrorMessage", () => {
it("matches auth/rate/billing/timeout", () => {
const samples = [

View File

@ -189,7 +189,7 @@ const HTTP_STATUS_PREFIX_RE = /^(?:http\s*)?(\d{3})\s+(.+)$/i;
const HTTP_STATUS_CODE_PREFIX_RE = /^(?:http\s*)?(\d{3})(?:\s+([\s\S]+))?$/i;
const HTML_ERROR_PREFIX_RE = /^\s*(?:<!doctype\s+html\b|<html\b)/i;
const CLOUDFLARE_HTML_ERROR_CODES = new Set([521, 522, 523, 524, 525, 526, 530]);
const TRANSIENT_HTTP_ERROR_CODES = new Set([500, 502, 503, 504, 521, 522, 523, 524, 529]);
const TRANSIENT_HTTP_ERROR_CODES = new Set([499, 500, 502, 503, 504, 521, 522, 523, 524, 529]);
const HTTP_ERROR_HINTS = [
"error",
"bad request",
@ -375,6 +375,12 @@ export function classifyFailoverReasonFromHttpStatus(
}
return "timeout";
}
if (status === 499) {
if (message && isOverloadedErrorMessage(message)) {
return "overloaded";
}
return "timeout";
}
if (status === 502 || status === 504) {
return "timeout";
}

View File

@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { OPENCLAW_CLI_ENV_VALUE } from "../infra/openclaw-exec-env.js";
import { buildSandboxCreateArgs } from "./sandbox/docker.js";
import type { SandboxDockerConfig } from "./sandbox/types.js";
@ -113,7 +114,14 @@ describe("buildSandboxCreateArgs", () => {
"1.5",
]),
);
expect(args).toEqual(expect.arrayContaining(["--env", "LANG=C.UTF-8"]));
expect(args).toEqual(
expect.arrayContaining([
"--env",
"LANG=C.UTF-8",
"--env",
`OPENCLAW_CLI=${OPENCLAW_CLI_ENV_VALUE}`,
]),
);
const ulimitValues: string[] = [];
for (let i = 0; i < args.length; i += 1) {

View File

@ -162,6 +162,7 @@ export function execDockerRaw(
}
import { formatCliCommand } from "../../cli/command-format.js";
import { markOpenClawExecEnv } from "../../infra/openclaw-exec-env.js";
import { defaultRuntime } from "../../runtime.js";
import { computeSandboxConfigHash } from "./config-hash.js";
import { DEFAULT_SANDBOX_IMAGE } from "./constants.js";
@ -365,7 +366,7 @@ export function buildSandboxCreateArgs(params: {
if (params.cfg.user) {
args.push("--user", params.cfg.user);
}
const envSanitization = sanitizeEnvVars(params.cfg.env ?? {});
const envSanitization = sanitizeEnvVars(markOpenClawExecEnv(params.cfg.env ?? {}));
if (envSanitization.blocked.length > 0) {
log.warn(`Blocked sensitive environment variables: ${envSanitization.blocked.join(", ")}`);
}

View File

@ -6,6 +6,7 @@ const loadDotEnvMock = vi.hoisted(() => vi.fn());
const normalizeEnvMock = vi.hoisted(() => vi.fn());
const ensurePathMock = vi.hoisted(() => vi.fn());
const assertRuntimeMock = vi.hoisted(() => vi.fn());
const closeAllMemorySearchManagersMock = vi.hoisted(() => vi.fn(async () => {}));
vi.mock("./route.js", () => ({
tryRouteCli: tryRouteCliMock,
@ -27,6 +28,10 @@ vi.mock("../infra/runtime-guard.js", () => ({
assertSupportedRuntime: assertRuntimeMock,
}));
vi.mock("../memory/search-manager.js", () => ({
closeAllMemorySearchManagers: closeAllMemorySearchManagersMock,
}));
const { runCli } = await import("./run-main.js");
describe("runCli exit behavior", () => {
@ -43,6 +48,7 @@ describe("runCli exit behavior", () => {
await runCli(["node", "openclaw", "status"]);
expect(tryRouteCliMock).toHaveBeenCalledWith(["node", "openclaw", "status"]);
expect(closeAllMemorySearchManagersMock).toHaveBeenCalledTimes(1);
expect(exitSpy).not.toHaveBeenCalled();
exitSpy.mockRestore();
});

View File

@ -13,6 +13,15 @@ import { applyCliProfileEnv, parseCliProfileArgs } from "./profile.js";
import { tryRouteCli } from "./route.js";
import { normalizeWindowsArgv } from "./windows-argv.js";
async function closeCliMemoryManagers(): Promise<void> {
try {
const { closeAllMemorySearchManagers } = await import("../memory/search-manager.js");
await closeAllMemorySearchManagers();
} catch {
// Best-effort teardown for short-lived CLI processes.
}
}
export function rewriteUpdateFlagArgv(argv: string[]): string[] {
const index = argv.indexOf("--update");
if (index === -1) {
@ -82,59 +91,63 @@ export async function runCli(argv: string[] = process.argv) {
// Enforce the minimum supported runtime before doing any work.
assertSupportedRuntime();
if (await tryRouteCli(normalizedArgv)) {
return;
}
// Capture all console output into structured logs while keeping stdout/stderr behavior.
enableConsoleCapture();
const { buildProgram } = await import("./program.js");
const program = buildProgram();
// Global error handlers to prevent silent crashes from unhandled rejections/exceptions.
// These log the error and exit gracefully instead of crashing without trace.
installUnhandledRejectionHandler();
process.on("uncaughtException", (error) => {
console.error("[openclaw] Uncaught exception:", formatUncaughtError(error));
process.exit(1);
});
const parseArgv = rewriteUpdateFlagArgv(normalizedArgv);
// Register the primary command (builtin or subcli) so help and command parsing
// are correct even with lazy command registration.
const primary = getPrimaryCommand(parseArgv);
if (primary) {
const { getProgramContext } = await import("./program/program-context.js");
const ctx = getProgramContext(program);
if (ctx) {
const { registerCoreCliByName } = await import("./program/command-registry.js");
await registerCoreCliByName(program, ctx, primary, parseArgv);
try {
if (await tryRouteCli(normalizedArgv)) {
return;
}
const { registerSubCliByName } = await import("./program/register.subclis.js");
await registerSubCliByName(program, primary);
}
const hasBuiltinPrimary =
primary !== null && program.commands.some((command) => command.name() === primary);
const shouldSkipPluginRegistration = shouldSkipPluginCommandRegistration({
argv: parseArgv,
primary,
hasBuiltinPrimary,
});
if (!shouldSkipPluginRegistration) {
// Register plugin CLI commands before parsing
const { registerPluginCliCommands } = await import("../plugins/cli.js");
const { loadValidatedConfigForPluginRegistration } =
await import("./program/register.subclis.js");
const config = await loadValidatedConfigForPluginRegistration();
if (config) {
registerPluginCliCommands(program, config);
// Capture all console output into structured logs while keeping stdout/stderr behavior.
enableConsoleCapture();
const { buildProgram } = await import("./program.js");
const program = buildProgram();
// Global error handlers to prevent silent crashes from unhandled rejections/exceptions.
// These log the error and exit gracefully instead of crashing without trace.
installUnhandledRejectionHandler();
process.on("uncaughtException", (error) => {
console.error("[openclaw] Uncaught exception:", formatUncaughtError(error));
process.exit(1);
});
const parseArgv = rewriteUpdateFlagArgv(normalizedArgv);
// Register the primary command (builtin or subcli) so help and command parsing
// are correct even with lazy command registration.
const primary = getPrimaryCommand(parseArgv);
if (primary) {
const { getProgramContext } = await import("./program/program-context.js");
const ctx = getProgramContext(program);
if (ctx) {
const { registerCoreCliByName } = await import("./program/command-registry.js");
await registerCoreCliByName(program, ctx, primary, parseArgv);
}
const { registerSubCliByName } = await import("./program/register.subclis.js");
await registerSubCliByName(program, primary);
}
}
await program.parseAsync(parseArgv);
const hasBuiltinPrimary =
primary !== null && program.commands.some((command) => command.name() === primary);
const shouldSkipPluginRegistration = shouldSkipPluginCommandRegistration({
argv: parseArgv,
primary,
hasBuiltinPrimary,
});
if (!shouldSkipPluginRegistration) {
// Register plugin CLI commands before parsing
const { registerPluginCliCommands } = await import("../plugins/cli.js");
const { loadValidatedConfigForPluginRegistration } =
await import("./program/register.subclis.js");
const config = await loadValidatedConfigForPluginRegistration();
if (config) {
registerPluginCliCommands(program, config);
}
}
await program.parseAsync(parseArgv);
} finally {
await closeCliMemoryManagers();
}
}
export function isCliMainModule(): boolean {

View File

@ -9,6 +9,7 @@ import { shouldSkipRespawnForArgv } from "./cli/respawn-policy.js";
import { normalizeWindowsArgv } from "./cli/windows-argv.js";
import { isTruthyEnvValue, normalizeEnv } from "./infra/env.js";
import { isMainModule } from "./infra/is-main.js";
import { ensureOpenClawExecMarkerOnProcess } from "./infra/openclaw-exec-env.js";
import { installProcessWarningFilter } from "./infra/warning-filter.js";
import { attachChildProcessBridge } from "./process/child-process-bridge.js";
@ -41,6 +42,7 @@ if (
// Imported as a dependency — skip all entry-point side effects.
} else {
process.title = "openclaw";
ensureOpenClawExecMarkerOnProcess();
installProcessWarningFilter();
normalizeEnv();
if (!isTruthyEnvValue(process.env.NODE_DISABLE_COMPILE_CACHE)) {

View File

@ -10,6 +10,7 @@ import {
sanitizeHostExecEnv,
sanitizeSystemRunEnvOverrides,
} from "./host-env-security.js";
import { OPENCLAW_CLI_ENV_VALUE } from "./openclaw-exec-env.js";
describe("isDangerousHostEnvVarName", () => {
it("matches dangerous keys and prefixes case-insensitively", () => {
@ -40,6 +41,7 @@ describe("sanitizeHostExecEnv", () => {
});
expect(env).toEqual({
OPENCLAW_CLI: OPENCLAW_CLI_ENV_VALUE,
PATH: "/usr/bin:/bin",
OK: "1",
});
@ -68,6 +70,7 @@ describe("sanitizeHostExecEnv", () => {
});
expect(env.PATH).toBe("/usr/bin:/bin");
expect(env.OPENCLAW_CLI).toBe(OPENCLAW_CLI_ENV_VALUE);
expect(env.BASH_ENV).toBeUndefined();
expect(env.GIT_SSH_COMMAND).toBeUndefined();
expect(env.EDITOR).toBeUndefined();
@ -91,6 +94,7 @@ describe("sanitizeHostExecEnv", () => {
});
expect(env.PATH).toBe("/usr/bin:/bin");
expect(env.OPENCLAW_CLI).toBe(OPENCLAW_CLI_ENV_VALUE);
expect(env.OK).toBe("1");
expect(env.SHELLOPTS).toBeUndefined();
expect(env.PS4).toBeUndefined();
@ -109,6 +113,7 @@ describe("sanitizeHostExecEnv", () => {
});
expect(env.GOOD_KEY).toBe("ok");
expect(env.OPENCLAW_CLI).toBe(OPENCLAW_CLI_ENV_VALUE);
expect(env[" BAD KEY"]).toBeUndefined();
expect(env["NOT-PORTABLE"]).toBeUndefined();
});

View File

@ -1,4 +1,5 @@
import HOST_ENV_SECURITY_POLICY_JSON from "./host-env-security-policy.json" with { type: "json" };
import { markOpenClawExecEnv } from "./openclaw-exec-env.js";
const PORTABLE_ENV_VAR_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
@ -101,7 +102,7 @@ export function sanitizeHostExecEnv(params?: {
}
if (!overrides) {
return merged;
return markOpenClawExecEnv(merged);
}
for (const [rawKey, value] of Object.entries(overrides)) {
@ -124,7 +125,7 @@ export function sanitizeHostExecEnv(params?: {
merged[key] = value;
}
return merged;
return markOpenClawExecEnv(merged);
}
export function sanitizeSystemRunEnvOverrides(params?: {

View File

@ -0,0 +1,16 @@
export const OPENCLAW_CLI_ENV_VAR = "OPENCLAW_CLI";
export const OPENCLAW_CLI_ENV_VALUE = "1";
export function markOpenClawExecEnv<T extends Record<string, string | undefined>>(env: T): T {
return {
...env,
[OPENCLAW_CLI_ENV_VAR]: OPENCLAW_CLI_ENV_VALUE,
};
}
export function ensureOpenClawExecMarkerOnProcess(
env: NodeJS.ProcessEnv = process.env,
): NodeJS.ProcessEnv {
env[OPENCLAW_CLI_ENV_VAR] = OPENCLAW_CLI_ENV_VALUE;
return env;
}

View File

@ -1,11 +1,13 @@
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { setConsoleSubsystemFilter } from "./console.js";
import { resetLogger, setLoggerOverride } from "./logger.js";
import { loggingState } from "./state.js";
import { createSubsystemLogger } from "./subsystem.js";
afterEach(() => {
setConsoleSubsystemFilter(null);
setLoggerOverride(null);
loggingState.rawConsole = null;
resetLogger();
});
@ -53,4 +55,118 @@ describe("createSubsystemLogger().isEnabled", () => {
expect(log.isEnabled("info", "file")).toBe(true);
expect(log.isEnabled("info")).toBe(true);
});
it("suppresses probe warnings for embedded subsystems based on structured run metadata", () => {
setLoggerOverride({ level: "silent", consoleLevel: "warn" });
const warn = vi.fn();
loggingState.rawConsole = {
log: vi.fn(),
info: vi.fn(),
warn,
error: vi.fn(),
};
const log = createSubsystemLogger("agent/embedded").child("failover");
log.warn("embedded run failover decision", {
runId: "probe-test-run",
consoleMessage: "embedded run failover decision",
});
expect(warn).not.toHaveBeenCalled();
});
it("does not suppress probe errors for embedded subsystems", () => {
setLoggerOverride({ level: "silent", consoleLevel: "error" });
const error = vi.fn();
loggingState.rawConsole = {
log: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error,
};
const log = createSubsystemLogger("agent/embedded").child("failover");
log.error("embedded run failover decision", {
runId: "probe-test-run",
consoleMessage: "embedded run failover decision",
});
expect(error).toHaveBeenCalledTimes(1);
});
it("suppresses probe warnings for model-fallback child subsystems based on structured run metadata", () => {
setLoggerOverride({ level: "silent", consoleLevel: "warn" });
const warn = vi.fn();
loggingState.rawConsole = {
log: vi.fn(),
info: vi.fn(),
warn,
error: vi.fn(),
};
const log = createSubsystemLogger("model-fallback").child("decision");
log.warn("model fallback decision", {
runId: "probe-test-run",
consoleMessage: "model fallback decision",
});
expect(warn).not.toHaveBeenCalled();
});
it("does not suppress probe errors for model-fallback child subsystems", () => {
setLoggerOverride({ level: "silent", consoleLevel: "error" });
const error = vi.fn();
loggingState.rawConsole = {
log: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error,
};
const log = createSubsystemLogger("model-fallback").child("decision");
log.error("model fallback decision", {
runId: "probe-test-run",
consoleMessage: "model fallback decision",
});
expect(error).toHaveBeenCalledTimes(1);
});
it("still emits non-probe warnings for embedded subsystems", () => {
setLoggerOverride({ level: "silent", consoleLevel: "warn" });
const warn = vi.fn();
loggingState.rawConsole = {
log: vi.fn(),
info: vi.fn(),
warn,
error: vi.fn(),
};
const log = createSubsystemLogger("agent/embedded").child("auth-profiles");
log.warn("auth profile failure state updated", {
runId: "run-123",
consoleMessage: "auth profile failure state updated",
});
expect(warn).toHaveBeenCalledTimes(1);
});
it("still emits non-probe model-fallback child warnings", () => {
setLoggerOverride({ level: "silent", consoleLevel: "warn" });
const warn = vi.fn();
loggingState.rawConsole = {
log: vi.fn(),
info: vi.fn(),
warn,
error: vi.fn(),
};
const log = createSubsystemLogger("model-fallback").child("decision");
log.warn("model fallback decision", {
runId: "run-123",
consoleMessage: "model fallback decision",
});
expect(warn).toHaveBeenCalledTimes(1);
});
});

View File

@ -250,6 +250,38 @@ function writeConsoleLine(level: LogLevel, line: string) {
}
}
function shouldSuppressProbeConsoleLine(params: {
level: LogLevel;
subsystem: string;
message: string;
meta?: Record<string, unknown>;
}): boolean {
if (isVerbose()) {
return false;
}
if (params.level === "error" || params.level === "fatal") {
return false;
}
const isProbeSuppressedSubsystem =
params.subsystem === "agent/embedded" ||
params.subsystem.startsWith("agent/embedded/") ||
params.subsystem === "model-fallback" ||
params.subsystem.startsWith("model-fallback/");
if (!isProbeSuppressedSubsystem) {
return false;
}
const runLikeId =
typeof params.meta?.runId === "string"
? params.meta.runId
: typeof params.meta?.sessionId === "string"
? params.meta.sessionId
: undefined;
if (runLikeId?.startsWith("probe-")) {
return true;
}
return /(sessionId|runId)=probe-/.test(params.message);
}
function logToFile(
fileLogger: TsLogger<LogObj>,
level: LogLevel,
@ -309,9 +341,12 @@ export function createSubsystemLogger(subsystem: string): SubsystemLogger {
}
const consoleMessage = consoleMessageOverride ?? message;
if (
!isVerbose() &&
subsystem === "agent/embedded" &&
/(sessionId|runId)=probe-/.test(consoleMessage)
shouldSuppressProbeConsoleLine({
level,
subsystem,
message: consoleMessage,
meta: fileMeta,
})
) {
return;
}
@ -355,11 +390,7 @@ export function createSubsystemLogger(subsystem: string): SubsystemLogger {
logToFile(getFileLogger(), "info", message, { raw: true });
}
if (isConsoleEnabled("info")) {
if (
!isVerbose() &&
subsystem === "agent/embedded" &&
/(sessionId|runId)=probe-/.test(message)
) {
if (shouldSuppressProbeConsoleLine({ level: "info", subsystem, message })) {
return;
}
writeConsoleLine("info", message);

View File

@ -4,4 +4,8 @@ export type {
MemorySearchManager,
MemorySearchResult,
} from "./types.js";
export { getMemorySearchManager, type MemorySearchManagerResult } from "./search-manager.js";
export {
closeAllMemorySearchManagers,
getMemorySearchManager,
type MemorySearchManagerResult,
} from "./search-manager.js";

View File

@ -1 +1 @@
export { MemoryIndexManager } from "./manager.js";
export { closeAllMemoryIndexManagers, MemoryIndexManager } from "./manager.js";

View File

@ -4,6 +4,10 @@ import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { getMemorySearchManager, type MemoryIndexManager } from "./index.js";
import {
closeAllMemoryIndexManagers,
MemoryIndexManager as RawMemoryIndexManager,
} from "./manager.js";
import "./test-runtime-mocks.js";
const hoisted = vi.hoisted(() => ({
@ -78,4 +82,37 @@ describe("memory manager cache hydration", () => {
await managers[0].close();
});
it("drains in-flight manager creation during global teardown", async () => {
const indexPath = path.join(workspaceDir, "index.sqlite");
const cfg = {
agents: {
defaults: {
workspace: workspaceDir,
memorySearch: {
provider: "openai",
model: "mock-embed",
store: { path: indexPath, vector: { enabled: false } },
sync: { watch: false, onSessionStart: false, onSearch: false },
},
},
list: [{ id: "main", default: true }],
},
} as OpenClawConfig;
hoisted.providerDelayMs = 100;
const pendingResult = RawMemoryIndexManager.get({ cfg, agentId: "main" });
await closeAllMemoryIndexManagers();
const firstManager = await pendingResult;
const secondManager = await RawMemoryIndexManager.get({ cfg, agentId: "main" });
expect(firstManager).toBeTruthy();
expect(secondManager).toBeTruthy();
expect(Object.is(secondManager, firstManager)).toBe(false);
expect(hoisted.providerCreateCalls).toBe(2);
await secondManager?.close?.();
});
});

View File

@ -42,6 +42,22 @@ const log = createSubsystemLogger("memory");
const INDEX_CACHE = new Map<string, MemoryIndexManager>();
const INDEX_CACHE_PENDING = new Map<string, Promise<MemoryIndexManager>>();
export async function closeAllMemoryIndexManagers(): Promise<void> {
const pending = Array.from(INDEX_CACHE_PENDING.values());
if (pending.length > 0) {
await Promise.allSettled(pending);
}
const managers = Array.from(INDEX_CACHE.values());
INDEX_CACHE.clear();
for (const manager of managers) {
try {
await manager.close();
} catch (err) {
log.warn(`failed to close memory index manager: ${String(err)}`);
}
}
}
export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements MemorySearchManager {
private readonly cacheKey: string;
protected readonly cfg: OpenClawConfig;

View File

@ -29,53 +29,53 @@ function createManagerStatus(params: {
};
}
const qmdManagerStatus = createManagerStatus({
backend: "qmd",
provider: "qmd",
model: "qmd",
requestedProvider: "qmd",
withMemorySourceCounts: true,
});
const fallbackManagerStatus = createManagerStatus({
backend: "builtin",
provider: "openai",
model: "text-embedding-3-small",
requestedProvider: "openai",
});
const mockPrimary = {
const mockPrimary = vi.hoisted(() => ({
search: vi.fn(async () => []),
readFile: vi.fn(async () => ({ text: "", path: "MEMORY.md" })),
status: vi.fn(() => qmdManagerStatus),
status: vi.fn(() =>
createManagerStatus({
backend: "qmd",
provider: "qmd",
model: "qmd",
requestedProvider: "qmd",
withMemorySourceCounts: true,
}),
),
sync: vi.fn(async () => {}),
probeEmbeddingAvailability: vi.fn(async () => ({ ok: true })),
probeVectorAvailability: vi.fn(async () => true),
close: vi.fn(async () => {}),
};
}));
const fallbackSearch = vi.fn(async () => [
{
path: "MEMORY.md",
startLine: 1,
endLine: 1,
score: 1,
snippet: "fallback",
source: "memory" as const,
},
]);
const fallbackManager = {
search: fallbackSearch,
const fallbackManager = vi.hoisted(() => ({
search: vi.fn(async () => [
{
path: "MEMORY.md",
startLine: 1,
endLine: 1,
score: 1,
snippet: "fallback",
source: "memory" as const,
},
]),
readFile: vi.fn(async () => ({ text: "", path: "MEMORY.md" })),
status: vi.fn(() => fallbackManagerStatus),
status: vi.fn(() =>
createManagerStatus({
backend: "builtin",
provider: "openai",
model: "text-embedding-3-small",
requestedProvider: "openai",
}),
),
sync: vi.fn(async () => {}),
probeEmbeddingAvailability: vi.fn(async () => ({ ok: true })),
probeVectorAvailability: vi.fn(async () => true),
close: vi.fn(async () => {}),
};
}));
const mockMemoryIndexGet = vi.fn(async () => fallbackManager);
const fallbackSearch = fallbackManager.search;
const mockMemoryIndexGet = vi.hoisted(() => vi.fn(async () => fallbackManager));
const mockCloseAllMemoryIndexManagers = vi.hoisted(() => vi.fn(async () => {}));
vi.mock("./qmd-manager.js", () => ({
QmdMemoryManager: {
@ -83,14 +83,15 @@ vi.mock("./qmd-manager.js", () => ({
},
}));
vi.mock("./manager.js", () => ({
vi.mock("./manager-runtime.js", () => ({
MemoryIndexManager: {
get: mockMemoryIndexGet,
},
closeAllMemoryIndexManagers: mockCloseAllMemoryIndexManagers,
}));
import { QmdMemoryManager } from "./qmd-manager.js";
import { getMemorySearchManager } from "./search-manager.js";
import { closeAllMemorySearchManagers, getMemorySearchManager } from "./search-manager.js";
// eslint-disable-next-line @typescript-eslint/unbound-method -- mocked static function
const createQmdManagerMock = vi.mocked(QmdMemoryManager.create);
@ -119,7 +120,8 @@ async function createFailedQmdSearchHarness(params: { agentId: string; errorMess
return { cfg, manager: requireManager(first), firstResult: first };
}
beforeEach(() => {
beforeEach(async () => {
await closeAllMemorySearchManagers();
mockPrimary.search.mockClear();
mockPrimary.readFile.mockClear();
mockPrimary.status.mockClear();
@ -134,6 +136,7 @@ beforeEach(() => {
fallbackManager.probeEmbeddingAvailability.mockClear();
fallbackManager.probeVectorAvailability.mockClear();
fallbackManager.close.mockClear();
mockCloseAllMemoryIndexManagers.mockClear();
mockMemoryIndexGet.mockClear();
mockMemoryIndexGet.mockResolvedValue(fallbackManager);
createQmdManagerMock.mockClear();
@ -243,4 +246,34 @@ describe("getMemorySearchManager caching", () => {
await expect(firstManager.search("hello")).rejects.toThrow("qmd query failed");
});
it("closes cached managers on global teardown", async () => {
const cfg = createQmdCfg("teardown-agent");
const first = await getMemorySearchManager({ cfg, agentId: "teardown-agent" });
const firstManager = requireManager(first);
await closeAllMemorySearchManagers();
expect(mockPrimary.close).toHaveBeenCalledTimes(1);
expect(mockCloseAllMemoryIndexManagers).toHaveBeenCalledTimes(1);
const second = await getMemorySearchManager({ cfg, agentId: "teardown-agent" });
expect(second.manager).toBeTruthy();
expect(second.manager).not.toBe(firstManager);
// eslint-disable-next-line @typescript-eslint/unbound-method
expect(createQmdManagerMock).toHaveBeenCalledTimes(2);
});
it("closes builtin index managers on teardown after runtime is loaded", async () => {
const retryAgentId = "teardown-with-fallback";
const { manager } = await createFailedQmdSearchHarness({
agentId: retryAgentId,
errorMessage: "qmd query failed",
});
await manager.search("hello");
await closeAllMemorySearchManagers();
expect(mockCloseAllMemoryIndexManagers).toHaveBeenCalledTimes(1);
});
});

View File

@ -85,6 +85,22 @@ export async function getMemorySearchManager(params: {
}
}
export async function closeAllMemorySearchManagers(): Promise<void> {
const managers = Array.from(QMD_MANAGER_CACHE.values());
QMD_MANAGER_CACHE.clear();
for (const manager of managers) {
try {
await manager.close?.();
} catch (err) {
log.warn(`failed to close qmd memory manager: ${String(err)}`);
}
}
if (managerRuntimePromise !== null) {
const { closeAllMemoryIndexManagers } = await loadManagerRuntime();
await closeAllMemoryIndexManagers();
}
}
class FallbackMemoryManager implements MemorySearchManager {
private fallback: MemorySearchManager | null = null;
private primaryFailed = false;

View File

@ -801,5 +801,11 @@ export type {
export { registerContextEngine } from "../context-engine/registry.js";
export type { ContextEngineFactory } from "../context-engine/registry.js";
// Model authentication types for plugins.
// Plugins should use runtime.modelAuth (which strips unsafe overrides like
// agentDir/store) rather than importing raw helpers directly.
export { requireApiKey } from "../agents/model-auth.js";
export type { ResolvedProviderAuth } from "../agents/model-auth.js";
// Security utilities
export { redactSensitiveText } from "../logging/redact.js";

View File

@ -53,4 +53,21 @@ describe("plugin runtime command execution", () => {
const runtime = createPluginRuntime();
expect(runtime.system.requestHeartbeatNow).toBe(requestHeartbeatNow);
});
it("exposes runtime.modelAuth with getApiKeyForModel and resolveApiKeyForProvider", () => {
const runtime = createPluginRuntime();
expect(runtime.modelAuth).toBeDefined();
expect(typeof runtime.modelAuth.getApiKeyForModel).toBe("function");
expect(typeof runtime.modelAuth.resolveApiKeyForProvider).toBe("function");
});
it("modelAuth wrappers strip agentDir and store to prevent credential steering", async () => {
// The wrappers should not forward agentDir or store from plugin callers.
// We verify this by checking the wrapper functions exist and are not the
// raw implementations (they are wrapped, not direct references).
const { getApiKeyForModel: rawGetApiKey } = await import("../../agents/model-auth.js");
const runtime = createPluginRuntime();
// Wrappers should NOT be the same reference as the raw functions
expect(runtime.modelAuth.getApiKeyForModel).not.toBe(rawGetApiKey);
});
});

View File

@ -1,4 +1,8 @@
import { createRequire } from "node:module";
import {
getApiKeyForModel as getApiKeyForModelRaw,
resolveApiKeyForProvider as resolveApiKeyForProviderRaw,
} from "../../agents/model-auth.js";
import { resolveStateDir } from "../../config/paths.js";
import { transcribeAudioFile } from "../../media-understanding/transcribe-audio.js";
import { textToSpeechTelephony } from "../../tts/tts.js";
@ -59,6 +63,24 @@ export function createPluginRuntime(_options: CreatePluginRuntimeOptions = {}):
events: createRuntimeEvents(),
logging: createRuntimeLogging(),
state: { resolveStateDir },
modelAuth: {
// Wrap model-auth helpers so plugins cannot steer credential lookups:
// - agentDir / store: stripped (prevents reading other agents' stores)
// - profileId / preferredProfile: stripped (prevents cross-provider
// credential access via profile steering)
// Plugins only specify provider/model; the core auth pipeline picks
// the appropriate credential automatically.
getApiKeyForModel: (params) =>
getApiKeyForModelRaw({
model: params.model,
cfg: params.cfg,
}),
resolveApiKeyForProvider: (params) =>
resolveApiKeyForProviderRaw({
provider: params.provider,
cfg: params.cfg,
}),
},
} satisfies PluginRuntime;
return runtime;

View File

@ -52,4 +52,16 @@ export type PluginRuntimeCore = {
state: {
resolveStateDir: typeof import("../../config/paths.js").resolveStateDir;
};
modelAuth: {
/** Resolve auth for a model. Only provider/model and optional cfg are used. */
getApiKeyForModel: (params: {
model: import("@mariozechner/pi-ai").Model<import("@mariozechner/pi-ai").Api>;
cfg?: import("../../config/config.js").OpenClawConfig;
}) => Promise<import("../../agents/model-auth.js").ResolvedProviderAuth>;
/** Resolve auth for a provider by name. Only provider and optional cfg are used. */
resolveApiKeyForProvider: (params: {
provider: string;
cfg?: import("../../config/config.js").OpenClawConfig;
}) => Promise<import("../../agents/model-auth.js").ResolvedProviderAuth>;
};
};

View File

@ -3,6 +3,7 @@ import { EventEmitter } from "node:events";
import fs from "node:fs";
import process from "node:process";
import { describe, expect, it, vi } from "vitest";
import { OPENCLAW_CLI_ENV_VALUE } from "../infra/openclaw-exec-env.js";
import { attachChildProcessBridge } from "./child-process-bridge.js";
import { resolveCommandEnv, runCommandWithTimeout, shouldSpawnWithShell } from "./exec.js";
@ -31,6 +32,7 @@ describe("runCommandWithTimeout", () => {
expect(resolved.OPENCLAW_BASE_ENV).toBe("base");
expect(resolved.OPENCLAW_TEST_ENV).toBe("ok");
expect(resolved.OPENCLAW_TO_REMOVE).toBeUndefined();
expect(resolved.OPENCLAW_CLI).toBe(OPENCLAW_CLI_ENV_VALUE);
});
it("suppresses npm fund prompts for npm argv", async () => {

View File

@ -4,6 +4,7 @@ import path from "node:path";
import process from "node:process";
import { promisify } from "node:util";
import { danger, shouldLogVerbose } from "../globals.js";
import { markOpenClawExecEnv } from "../infra/openclaw-exec-env.js";
import { logDebug, logError } from "../logger.js";
import { resolveCommandStdio } from "./spawn-utils.js";
@ -213,7 +214,7 @@ export function resolveCommandEnv(params: {
resolvedEnv.npm_config_fund = "false";
}
}
return resolvedEnv;
return markOpenClawExecEnv(resolvedEnv);
}
export async function runCommandWithTimeout(