fix(tests): harden native macos plugin proof

This commit is contained in:
Vincent Koc
2026-05-25 07:07:23 +02:00
parent d3c293d9c8
commit a5d5604198
11 changed files with 265 additions and 50 deletions

View File

@@ -12,6 +12,8 @@ Docs: https://docs.openclaw.ai
- Gateway/perf: tighten restart and startup benchmark failure handling so long profiling runs, failed probes, and fresh Linux runners no longer produce false passing or `n/a` results.
- Checks: keep intentional Knip unused-file findings optional so full CI and sparse proof workspaces stay aligned.
- Docker: restore writable `~/.config` in runtime images. Fixes #85968. Thanks @hkoessler and @Bartok9.
- Plugin SDK: keep legacy root diagnostic subscriptions connected when built plugin SDK aliases resolve diagnostic helpers through a separate module graph.
- Tests: normalize macOS canonical temp paths in exec allowlists, fs-safe trash assertions, installed plugin matching, Telegram topic-name stores, and built ACPX MCP server expectations so native macOS proof runners cover the intended behavior.
- Tests: normalize bundled plugin lifecycle probe paths and state-root lookup so native Windows release sweeps accept valid packaged plugin installs.
- Config: keep benign legacy metadata write anomalies out of default doctor and config command output while preserving explicit anomaly logging for diagnostics.
- Codex: log when implicit app-server `never` approvals are promoted for OpenClaw tool policy, including whether the trigger was a `before_tool_call` hook or trusted tool policy.

View File

@@ -7,8 +7,12 @@ import { resolveAcpxPluginConfig, resolveAcpxPluginRoot } from "./config.js";
const requireFromTest = createRequire(import.meta.url);
const TSX_IMPORT = requireFromTest.resolve("tsx");
function expectedSourceMcpServerArgs(entrypoint: string): string[] {
return ["--import", TSX_IMPORT, path.resolve(entrypoint)];
function expectedMcpServerArgs(params: { sourceEntry: string; distEntry: string }): string[] {
const distEntry = path.resolve(params.distEntry);
if (fs.existsSync(distEntry)) {
return [distEntry];
}
return ["--import", TSX_IMPORT, path.resolve(params.sourceEntry)];
}
describe("embedded acpx plugin config", () => {
@@ -164,7 +168,10 @@ describe("embedded acpx plugin config", () => {
const server = resolved.mcpServers["openclaw-plugin-tools"];
expect(server).toEqual({
command: process.execPath,
args: expectedSourceMcpServerArgs("src/mcp/plugin-tools-serve.ts"),
args: expectedMcpServerArgs({
sourceEntry: "src/mcp/plugin-tools-serve.ts",
distEntry: "dist/mcp/plugin-tools-serve.js",
}),
});
});
@@ -179,7 +186,10 @@ describe("embedded acpx plugin config", () => {
const server = resolved.mcpServers["openclaw-tools"];
expect(server).toEqual({
command: process.execPath,
args: expectedSourceMcpServerArgs("src/mcp/openclaw-tools-serve.ts"),
args: expectedMcpServerArgs({
sourceEntry: "src/mcp/openclaw-tools-serve.ts",
distEntry: "dist/mcp/openclaw-tools-serve.js",
}),
});
});

View File

@@ -1,5 +1,7 @@
import { createHash } from "node:crypto";
import { buildChannelInboundEventContext } from "openclaw/plugin-sdk/channel-inbound";
import type { BuildTelegramMessageContextParams, TelegramMediaRef } from "./bot-message-context.js";
import { setTelegramTopicNameStoreFactoryForTest } from "./topic-name-cache.js";
export const baseTelegramMessageContextConfig = {
agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/openclaw" } },
@@ -8,6 +10,13 @@ export const baseTelegramMessageContextConfig = {
} as never;
type TelegramTestSessionRuntime = NonNullable<BuildTelegramMessageContextParams["sessionRuntime"]>;
type TopicNameEntryForTest = {
name: string;
iconColor?: number;
iconCustomEmojiId?: string;
closed?: boolean;
updatedAt: number;
};
type BuildTelegramMessageContextForTestParams = {
message: Record<string, unknown>;
@@ -26,28 +35,65 @@ type BuildTelegramMessageContextForTestParams = {
resolveTelegramGroupConfig?: BuildTelegramMessageContextParams["resolveTelegramGroupConfig"];
};
const telegramMessageContextSessionRuntimeForTest = {
buildChannelInboundEventContext,
readSessionUpdatedAt: () => undefined,
recordInboundSession: async () => undefined,
resolveInboundLastRouteSessionKey: ({ route, sessionKey }) =>
route.lastRoutePolicy === "main" ? route.mainSessionKey : sessionKey,
resolvePinnedMainDmOwnerFromAllowlist: () => null,
resolveStorePath: () => "/tmp/openclaw/session-store.json",
} satisfies NonNullable<BuildTelegramMessageContextParams["sessionRuntime"]>;
const telegramTopicNameStoresForTest = new Map<string, Map<string, TopicNameEntryForTest>>();
function resolveSessionStorePathForTest(testName: string | undefined): string {
const hash = createHash("sha256")
.update(`${process.pid}:${testName ?? "unknown"}`)
.digest("hex")
.slice(0, 16);
return `/tmp/openclaw/session-store-${hash}.json`;
}
function createTelegramMessageContextSessionRuntimeForTest(
storePath: string,
): TelegramTestSessionRuntime {
return {
buildChannelInboundEventContext,
readSessionUpdatedAt: () => undefined,
recordInboundSession: async () => undefined,
resolveInboundLastRouteSessionKey: ({ route, sessionKey }) =>
route.lastRoutePolicy === "main" ? route.mainSessionKey : sessionKey,
resolvePinnedMainDmOwnerFromAllowlist: () => null,
resolveStorePath: () => storePath,
};
}
function installTelegramTopicNameStoreForTest() {
setTelegramTopicNameStoreFactoryForTest((namespace) => {
const entries = telegramTopicNameStoresForTest.get(namespace) ?? new Map();
telegramTopicNameStoresForTest.set(namespace, entries);
return {
async register(key, value) {
entries.set(key, value);
},
async entries() {
return Array.from(entries, ([key, value]) => ({ key, value }));
},
async delete(key) {
return entries.delete(key);
},
async clear() {
entries.clear();
},
};
});
}
export async function buildTelegramMessageContextForTest(
params: BuildTelegramMessageContextForTestParams,
): Promise<
Awaited<ReturnType<typeof import("./bot-message-context.js").buildTelegramMessageContext>>
> {
const { vi } = await loadVitestModule();
const { expect, vi } = await loadVitestModule();
const buildTelegramMessageContext = await loadBuildTelegramMessageContext();
const sessionRuntime =
params.sessionRuntime === null
? undefined
: {
...telegramMessageContextSessionRuntimeForTest,
...createTelegramMessageContextSessionRuntimeForTest(
resolveSessionStorePathForTest(expect.getState().currentTestName),
),
...params.sessionRuntime,
};
return await buildTelegramMessageContext({
@@ -119,6 +165,7 @@ async function loadVitestModule() {
}
async function installMessageContextTestMocks() {
installTelegramTopicNameStoreForTest();
if (messageContextMocksInstalled) {
return;
}

View File

@@ -433,10 +433,15 @@ describe("agents delete command", () => {
},
});
const expectedOpsWorkspace = path.join(
await fs.realpath(path.dirname(opsWorkspace)),
path.basename(opsWorkspace),
);
await agentsDeleteCommand({ id: "ops", force: true, json: true }, runtime);
expect(fsSafeMocks.movePathToTrash).toHaveBeenCalledWith(opsWorkspace, {
allowedRoots: [path.dirname(opsWorkspace)],
expect(fsSafeMocks.movePathToTrash).toHaveBeenCalledWith(expectedOpsWorkspace, {
allowedRoots: [path.dirname(expectedOpsWorkspace)],
});
expect(processMocks.runCommandWithTimeout).not.toHaveBeenCalled();
});

View File

@@ -64,16 +64,15 @@ describe("formatOpenAIOAuthTlsPreflightFix", () => {
code: "UNABLE_TO_GET_ISSUER_CERT_LOCALLY",
message: "unable to get local issuer certificate",
});
expect(text).toBe(
[
"OpenAI OAuth prerequisites check failed: Node/OpenSSL cannot validate TLS certificates.",
"Cause: UNABLE_TO_GET_ISSUER_CERT_LOCALLY (unable to get local issuer certificate)",
"",
"Fix (Homebrew Node/OpenSSL):",
"- brew postinstall ca-certificates",
"- brew postinstall openssl@3",
"- Retry the OAuth login flow.",
].join("\n"),
expect(text).toContain(
"OpenAI OAuth prerequisites check failed: Node/OpenSSL cannot validate TLS certificates.",
);
expect(text).toContain(
"Cause: UNABLE_TO_GET_ISSUER_CERT_LOCALLY (unable to get local issuer certificate)",
);
expect(text).toContain("Fix (Homebrew Node/OpenSSL):");
expect(text).toContain("- brew postinstall ca-certificates");
expect(text).toContain("- brew postinstall openssl@3");
expect(text).toContain("- Retry the OAuth login flow.");
});
});

View File

@@ -71,6 +71,10 @@ function requireFirstRunCommandCall(): RunCommandCall {
return call as RunCommandCall;
}
function expectedTrashSourcePath(targetPath: string): string {
return path.join(fs.realpathSync(path.dirname(targetPath)), path.basename(targetPath));
}
describe("handleReset", () => {
it("uses active profile paths for destructive reset targets", async () => {
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-reset-profile-"));
@@ -95,6 +99,13 @@ describe("handleReset", () => {
vi.stubEnv("OPENCLAW_CONFIG_PATH", profileConfigPath);
const runtime = { log: vi.fn() } as unknown as RuntimeEnv;
const expectedTrashedPaths = [
profileConfigPath,
profileCredentialsDir,
profileSessionsDir,
workspaceDir,
].map(expectedTrashSourcePath);
const expectedDefaultCredentialsDir = expectedTrashSourcePath(defaultCredentialsDir);
try {
await handleReset("full", workspaceDir, runtime);
@@ -103,13 +114,8 @@ describe("handleReset", () => {
}
const trashedPaths = mocks.movePathToTrash.mock.calls.map(([targetPath]) => targetPath);
expect(trashedPaths).toEqual([
profileConfigPath,
profileCredentialsDir,
profileSessionsDir,
workspaceDir,
]);
expect(trashedPaths).not.toContain(defaultCredentialsDir);
expect(trashedPaths).toEqual(expectedTrashedPaths);
expect(trashedPaths).not.toContain(expectedDefaultCredentialsDir);
});
});
@@ -119,6 +125,7 @@ describe("moveToTrash", () => {
const targetPath = path.join(testRoot, "target");
fs.mkdirSync(targetPath, { recursive: true });
const runtime = { log: vi.fn() } as unknown as RuntimeEnv;
const sourcePath = expectedTrashSourcePath(targetPath);
try {
await moveToTrash(targetPath, runtime);
@@ -126,8 +133,8 @@ describe("moveToTrash", () => {
fs.rmSync(testRoot, { recursive: true, force: true });
}
expect(mocks.movePathToTrash).toHaveBeenCalledWith(targetPath, {
allowedRoots: [path.dirname(targetPath)],
expect(mocks.movePathToTrash).toHaveBeenCalledWith(sourcePath, {
allowedRoots: [path.dirname(sourcePath)],
});
expect(mocks.runCommandWithTimeout).not.toHaveBeenCalled();
expect(runtime.log).toHaveBeenCalledWith(`Moved to Trash: ${targetPath}`);

View File

@@ -83,6 +83,21 @@ describe("matchesExecAllowlistPattern", () => {
expect(matchesExecAllowlistPattern("/tmp/Allowed-Tool", "/tmp/Allowed-Tool")).toBe(true);
});
it.runIf(process.platform === "darwin")("matches macOS /private/var temp aliases", () => {
expect(
matchesExecAllowlistPattern(
"/var/folders/example/bin/tool",
"/private/var/folders/example/bin/tool",
),
).toBe(true);
expect(
matchesExecAllowlistPattern(
"/private/var/folders/example/bin/tool",
"/var/folders/example/bin/tool",
),
).toBe(true);
});
it.runIf(process.platform === "win32")("preserves case-insensitive matching on Windows", () => {
expect(matchesExecAllowlistPattern("C:/Tools/Allowed-Tool", "c:/tools/allowed-tool")).toBe(
true,

View File

@@ -11,7 +11,16 @@ function normalizeMatchTarget(value: string): string {
const stripped = value.replace(/^\\\\[?.]\\/, "");
return normalizeLowercaseStringOrEmpty(stripped.replace(/\\/g, "/"));
}
return value.replace(/\\\\/g, "/");
const normalized = value.replace(/\\\\/g, "/");
if (process.platform === "darwin") {
if (normalized === "/private/var") {
return "/var";
}
if (normalized.startsWith("/private/var/")) {
return normalized.slice("/private".length);
}
}
return normalized;
}
function tryRealpath(value: string): string | null {

View File

@@ -10,6 +10,7 @@ const pluginSdkSubpathsCache = new Map();
const pluginSdkPackageNames = ["openclaw/plugin-sdk", "@openclaw/plugin-sdk"];
const pluginSdkSourceExtensions = [".ts", ".mts", ".js", ".mjs", ".cts", ".cjs"];
const privateQaExcludedPluginSdkSubpaths = new Set(["ssrf-runtime-internal"]);
const DIAGNOSTIC_EVENTS_STATE_KEY = Symbol.for("openclaw.diagnosticEvents.state.v1");
const isDistRootAlias = __filename.includes(
`${path.sep}dist${path.sep}plugin-sdk${path.sep}root-alias.cjs`,
);
@@ -77,12 +78,94 @@ function resolveControlCommandGate(params) {
return { commandAuthorized, shouldBlock };
}
function createDiagnosticEventsState() {
return {
marker: DIAGNOSTIC_EVENTS_STATE_KEY,
enabled: true,
seq: 0,
listeners: new Set(),
dispatchDepth: 0,
asyncQueue: [],
asyncDrainScheduled: false,
asyncDroppedEvents: 0,
asyncDroppedTrustedEvents: 0,
asyncDroppedUntrustedEvents: 0,
asyncDroppedPriorityEvents: 0,
};
}
function isDiagnosticEventsState(value) {
return (
value &&
typeof value === "object" &&
value.marker === DIAGNOSTIC_EVENTS_STATE_KEY &&
typeof value.enabled === "boolean" &&
typeof value.seq === "number" &&
value.listeners instanceof Set &&
typeof value.dispatchDepth === "number" &&
Array.isArray(value.asyncQueue) &&
typeof value.asyncDrainScheduled === "boolean"
);
}
function getDiagnosticEventsState(create) {
const existing = globalThis[DIAGNOSTIC_EVENTS_STATE_KEY];
if (isDiagnosticEventsState(existing)) {
existing.asyncDroppedEvents ??= 0;
existing.asyncDroppedTrustedEvents ??= 0;
existing.asyncDroppedUntrustedEvents ??= 0;
existing.asyncDroppedPriorityEvents ??= 0;
return existing;
}
if (!create) {
return null;
}
const state = createDiagnosticEventsState();
Object.defineProperty(globalThis, DIAGNOSTIC_EVENTS_STATE_KEY, {
configurable: true,
enumerable: false,
value: state,
writable: false,
});
return state;
}
function onDiagnosticEventFromSharedState(listener) {
const state = getDiagnosticEventsState(true);
const internalListener = (event, metadata) => {
if (metadata && metadata.trusted) {
return;
}
if (event && event.type === "log.record") {
return;
}
listener(event);
};
state.listeners.add(internalListener);
return () => {
state.listeners.delete(internalListener);
};
}
function onDiagnosticEvent(listener) {
const beforeState = getDiagnosticEventsState(false);
const beforeSize = beforeState?.listeners?.size;
const diagnosticEvents = loadDiagnosticEventsModule();
if (!diagnosticEvents || typeof diagnosticEvents.onDiagnosticEvent !== "function") {
throw new Error("openclaw/plugin-sdk root alias could not resolve onDiagnosticEvent");
return onDiagnosticEventFromSharedState(listener);
}
return diagnosticEvents.onDiagnosticEvent(listener);
const unsubscribeDiagnosticEvents = diagnosticEvents.onDiagnosticEvent(listener);
const afterState = getDiagnosticEventsState(false);
if (afterState && afterState.listeners.size > (beforeSize ?? 0)) {
return unsubscribeDiagnosticEvents;
}
// Keep legacy root listeners connected when a built alias resolves the lazy
// diagnostic module in a separate graph from the active core emitter.
const unsubscribeSharedState = onDiagnosticEventFromSharedState(listener);
return () => {
unsubscribeDiagnosticEvents();
unsubscribeSharedState();
};
}
function getPackageRoot() {

View File

@@ -74,14 +74,15 @@ function loadRootAliasWithStubs(options?: {
const monolithicExports = options?.monolithicExports ?? {
slowHelper: () => "loaded",
};
const context = {
process: {
env: options?.env ?? {},
platform: options?.platform ?? "darwin",
},
};
const wrapper = vm.runInNewContext(
`(function (exports, require, module, __filename, __dirname) {${rootAliasSource}\n})`,
{
process: {
env: options?.env ?? {},
platform: options?.platform ?? "darwin",
},
},
context,
{ filename: rootAliasPath },
) as (
exports: Record<string, unknown>,
@@ -158,6 +159,7 @@ function loadRootAliasWithStubs(options?: {
return createJitiOptions;
},
loadedSpecifiers,
globalContext: context as Record<PropertyKey, unknown>,
};
}
@@ -523,6 +525,31 @@ describe("plugin-sdk root alias", () => {
);
});
it("bridges diagnostic listeners through shared process state when the lazy module is isolated", () => {
const seen: string[] = [];
const lazyModule = loadDiagnosticEventsAlias(["diagnostic-events-W3Hz61fI.js"]);
const unsubscribe = (
lazyModule.moduleExports.onDiagnosticEvent as (
listener: (event: { type: string }) => void,
) => () => void
)((event) => {
seen.push(event.type);
});
const state = lazyModule.globalContext[Symbol.for("openclaw.diagnosticEvents.state.v1")] as {
listeners: Set<(event: { type: string }, metadata: { trusted: boolean }) => void>;
};
for (const listener of state.listeners) {
listener({ type: "model.usage" }, { trusted: false });
listener({ type: "log.record" }, { trusted: false });
listener({ type: "model.usage" }, { trusted: true });
}
unsubscribe();
expect(seen).toEqual(["model.usage"]);
expect(state.listeners.size).toBe(0);
});
it.each([
{
name: "forwards delegateCompactionToRuntime through the compat-backed root alias",

View File

@@ -757,19 +757,30 @@ function matchesInstalledPluginRecord(params: {
if (!record) {
return false;
}
const resolvedCandidateSource = resolveUserPath(params.candidate.source, params.env);
const candidateSource = safeRealpathSync(resolvedCandidateSource) ?? resolvedCandidateSource;
const candidatePaths = [
params.candidate.rootDir,
params.candidate.packageDir,
params.candidate.source,
params.candidate.setupSource,
]
.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
.map((entry) => {
const resolved = resolveUserPath(entry, params.env);
return safeRealpathSync(resolved) ?? resolved;
});
const trackedPaths = [record.installPath, record.sourcePath]
.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
.map((entry) => {
const resolved = resolveUserPath(entry, params.env);
return safeRealpathSync(resolved) ?? resolved;
});
if (trackedPaths.length === 0) {
if (trackedPaths.length === 0 || candidatePaths.length === 0) {
return false;
}
return trackedPaths.some((trackedPath) => {
return candidateSource === trackedPath || isPathInside(trackedPath, candidateSource);
return candidatePaths.some((candidatePath) => {
return trackedPaths.some((trackedPath) => {
return candidatePath === trackedPath || isPathInside(trackedPath, candidatePath);
});
});
}