From 2677f7cf14466a2c5f6485bcb263b48ecfce914a Mon Sep 17 00:00:00 2001 From: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> Date: Mon, 13 Apr 2026 08:39:34 -0600 Subject: [PATCH] fix: validate resolved context engine contracts (#63222) Merged via squash. Prepared head SHA: 5f3a15c670ad27898cb83944e485ae002fd9ee49 Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com> Reviewed-by: @jalehman --- CHANGELOG.md | 2 + src/context-engine/context-engine.test.ts | 139 +++++++++++++++++++--- src/context-engine/registry.ts | 51 +++++++- 3 files changed, 173 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e7d4c0e5536..187603b3280 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai - Agents/context engines: run opt-in turn maintenance as idle-aware background work so the next foreground turn no longer waits on proactive maintenance. (#65233) thanks @100yenadmin - Plugins/status: report the registered context-engine IDs in `plugins inspect` instead of the owning plugin ID, so non-matching engine IDs and multi-engine plugins are classified correctly. (#58766) thanks @zhuisDEV +- Context engines: reject resolved plugin engines whose reported `info.id` does not match their registered slot id, so malformed engines fail fast before id-based runtime branches can misbehave. (#63222) Thanks @fuller-stack-dev. ## 2026.4.12 ### Changes @@ -312,6 +313,7 @@ Docs: https://docs.openclaw.ai - Control UI/models: preserve provider-qualified refs for OpenRouter catalog models whose ids already contain slashes so picker selections submit allowlist-compatible model refs instead of dropping the `openrouter/` prefix. (#63416) Thanks @sallyom. - Plugin SDK/command auth: split command status builders onto the lightweight `openclaw/plugin-sdk/command-status` subpath while preserving deprecated `command-auth` compatibility exports, so auth-only plugin imports no longer pull status/context warmup into CLI onboarding paths. (#63174) Thanks @hxy91819. - Wizard/plugin config: coerce integer-typed plugin config fields from interactive text input so integer schema values persist as numbers instead of failing validation. (#63346) Thanks @jalehman. +- npm packaging: derive required root runtime mirrors from bundled plugin manifests and built root chunks, then install packed release tarballs without the repo `node_modules` so release checks catch missing plugin deps before publish. ## 2026.4.8 diff --git a/src/context-engine/context-engine.test.ts b/src/context-engine/context-engine.test.ts index 5fe7cc37c4d..d3434d3104f 100644 --- a/src/context-engine/context-engine.test.ts +++ b/src/context-engine/context-engine.test.ts @@ -140,16 +140,20 @@ class MockContextEngine implements ContextEngine { } class LegacySessionKeyStrictEngine implements ContextEngine { - readonly info: ContextEngineInfo = { - id: "legacy-sessionkey-strict", - name: "Legacy SessionKey Strict Engine", - }; + readonly info: ContextEngineInfo; readonly ingestCalls: Array> = []; readonly assembleCalls: Array> = []; readonly compactCalls: Array> = []; readonly maintainCalls: Array> = []; readonly ingestedMessages: AgentMessage[] = []; + constructor(engineId = "legacy-sessionkey-strict") { + this.info = { + id: engineId, + name: "Legacy SessionKey Strict Engine", + }; + } + private rejectSessionKey(params: { sessionKey?: string }): void { if (Object.prototype.hasOwnProperty.call(params, "sessionKey")) { throw new Error("Unrecognized key(s) in object: 'sessionKey'"); @@ -223,12 +227,17 @@ class LegacySessionKeyStrictEngine implements ContextEngine { } class SessionKeyRuntimeErrorEngine implements ContextEngine { - readonly info: ContextEngineInfo = { - id: "sessionkey-runtime-error", - name: "SessionKey Runtime Error Engine", - }; + readonly info: ContextEngineInfo; assembleCalls = 0; - constructor(private readonly errorMessage = "sessionKey lookup failed") {} + constructor( + engineId = "sessionkey-runtime-error", + private readonly errorMessage = "sessionKey lookup failed", + ) { + this.info = { + id: engineId, + name: "SessionKey Runtime Error Engine", + }; + } async ingest(_params: { sessionId: string; @@ -266,12 +275,16 @@ class SessionKeyRuntimeErrorEngine implements ContextEngine { } class LegacyAssembleStrictEngine implements ContextEngine { - readonly info: ContextEngineInfo = { - id: "legacy-assemble-strict", - name: "Legacy Assemble Strict Engine", - }; + readonly info: ContextEngineInfo; readonly assembleCalls: Array> = []; + constructor(engineId = "legacy-assemble-strict") { + this.info = { + id: engineId, + name: "Legacy Assemble Strict Engine", + }; + } + async ingest(_params: { sessionId: string; sessionKey?: string; @@ -541,7 +554,7 @@ describe("Registry tests", () => { describe("Legacy sessionKey compatibility", () => { it("memoizes legacy mode after the first strict compatibility retry", async () => { const engineId = `legacy-sessionkey-${Date.now().toString(36)}`; - const strictEngine = new LegacySessionKeyStrictEngine(); + const strictEngine = new LegacySessionKeyStrictEngine(engineId); registerContextEngine(engineId, () => strictEngine); const engine = await resolveContextEngine(configWithSlot(engineId)); @@ -567,7 +580,7 @@ describe("Legacy sessionKey compatibility", () => { it("retries strict ingest once and ingests each message only once", async () => { const engineId = `legacy-sessionkey-ingest-${Date.now().toString(36)}`; - const strictEngine = new LegacySessionKeyStrictEngine(); + const strictEngine = new LegacySessionKeyStrictEngine(engineId); registerContextEngine(engineId, () => strictEngine); const engine = await resolveContextEngine(configWithSlot(engineId)); @@ -594,7 +607,7 @@ describe("Legacy sessionKey compatibility", () => { it("retries strict maintain once and memoizes legacy mode there too", async () => { const engineId = `legacy-sessionkey-maintain-${Date.now().toString(36)}`; - const strictEngine = new LegacySessionKeyStrictEngine(); + const strictEngine = new LegacySessionKeyStrictEngine(engineId); registerContextEngine(engineId, () => strictEngine); const engine = await resolveContextEngine(configWithSlot(engineId)); @@ -612,7 +625,7 @@ describe("Legacy sessionKey compatibility", () => { it("does not retry non-compat runtime errors", async () => { const engineId = `sessionkey-runtime-${Date.now().toString(36)}`; - const runtimeErrorEngine = new SessionKeyRuntimeErrorEngine(); + const runtimeErrorEngine = new SessionKeyRuntimeErrorEngine(engineId); registerContextEngine(engineId, () => runtimeErrorEngine); const engine = await resolveContextEngine(configWithSlot(engineId)); @@ -630,6 +643,7 @@ describe("Legacy sessionKey compatibility", () => { it("does not treat 'Unknown sessionKey' runtime failures as schema-compat errors", async () => { const engineId = `sessionkey-unknown-runtime-${Date.now().toString(36)}`; const runtimeErrorEngine = new SessionKeyRuntimeErrorEngine( + engineId, 'Unknown sessionKey "agent:main:missing"', ); registerContextEngine(engineId, () => runtimeErrorEngine); @@ -709,6 +723,95 @@ describe("Invalid engine fallback", () => { expect(message).toContain("legacy"); } }); + + it("rejects resolved engines that omit info metadata", async () => { + const engineId = `invalid-info-${Date.now().toString(36)}`; + registerContextEngine( + engineId, + () => + ({ + async ingest() { + return { ingested: false }; + }, + async assemble({ messages }: { messages: AgentMessage[] }) { + return { messages, estimatedTokens: 0 }; + }, + async compact() { + return { ok: true, compacted: false }; + }, + }) as unknown as ContextEngine, + ); + + await expect(resolveContextEngine(configWithSlot(engineId))).rejects.toThrow( + `Context engine "${engineId}" factory returned an invalid ContextEngine: missing info.`, + ); + }); + + it("rejects resolved engines that omit required info fields", async () => { + const engineId = `invalid-info-fields-${Date.now().toString(36)}`; + registerContextEngine( + engineId, + () => + ({ + info: { id: engineId }, + async ingest() { + return { ingested: false }; + }, + async assemble({ messages }: { messages: AgentMessage[] }) { + return { messages, estimatedTokens: 0 }; + }, + async compact() { + return { ok: true, compacted: false }; + }, + }) as unknown as ContextEngine, + ); + + await expect(resolveContextEngine(configWithSlot(engineId))).rejects.toThrow( + `Context engine "${engineId}" factory returned an invalid ContextEngine: missing info.name.`, + ); + }); + + it("rejects resolved engines whose info.id mismatches the registered id", async () => { + const engineId = `mismatched-info-id-${Date.now().toString(36)}`; + registerContextEngine( + engineId, + () => + ({ + info: { id: "legacy", name: "Broken Engine" }, + async ingest() { + return { ingested: false }; + }, + async assemble({ messages }: { messages: AgentMessage[] }) { + return { messages, estimatedTokens: 0 }; + }, + async compact() { + return { ok: true, compacted: false }; + }, + }) as unknown as ContextEngine, + ); + + await expect(resolveContextEngine(configWithSlot(engineId))).rejects.toThrow( + `Context engine "${engineId}" factory returned an invalid ContextEngine: info.id must match registered id "${engineId}".`, + ); + }); + + it("rejects resolved engines that omit required lifecycle methods", async () => { + const engineId = `invalid-methods-${Date.now().toString(36)}`; + registerContextEngine( + engineId, + () => + ({ + info: { id: engineId, name: "Broken Engine" }, + async ingest() { + return { ingested: false }; + }, + }) as unknown as ContextEngine, + ); + + await expect(resolveContextEngine(configWithSlot(engineId))).rejects.toThrow( + `Context engine "${engineId}" factory returned an invalid ContextEngine: missing assemble(), missing compact().`, + ); + }); }); // ═══════════════════════════════════════════════════════════════════════════ @@ -809,7 +912,7 @@ describe("assemble() prompt forwarding", () => { it("retries strict legacy assemble without sessionKey and prompt", async () => { const engineId = `prompt-legacy-${Date.now().toString(36)}`; - const strictEngine = new LegacyAssembleStrictEngine(); + const strictEngine = new LegacyAssembleStrictEngine(engineId); registerContextEngine(engineId, () => strictEngine); const engine = await resolveContextEngine(configWithSlot(engineId)); diff --git a/src/context-engine/registry.ts b/src/context-engine/registry.ts index 23e5c783c30..f2d65373f9f 100644 --- a/src/context-engine/registry.ts +++ b/src/context-engine/registry.ts @@ -395,6 +395,49 @@ export function listContextEngineIds(): string[] { return [...getContextEngineRegistryState().engines.keys()]; } +function describeResolvedContextEngineContractError( + engineId: string, + engine: unknown, +): string | null { + if (!engine || typeof engine !== "object") { + return `Context engine "${engineId}" factory returned ${JSON.stringify(engine)} instead of a ContextEngine object.`; + } + + const candidate = engine as Record; + const issues: string[] = []; + const info = candidate.info; + if (!info || typeof info !== "object") { + issues.push("missing info"); + } else { + const infoRecord = info as Record; + const infoId = typeof infoRecord.id === "string" ? infoRecord.id.trim() : ""; + if (!infoId) { + issues.push("missing info.id"); + } else if (infoId !== engineId) { + issues.push(`info.id must match registered id "${engineId}"`); + } + if (typeof infoRecord.name !== "string" || !infoRecord.name.trim()) { + issues.push("missing info.name"); + } + } + + if (typeof candidate.ingest !== "function") { + issues.push("missing ingest()"); + } + if (typeof candidate.assemble !== "function") { + issues.push("missing assemble()"); + } + if (typeof candidate.compact !== "function") { + issues.push("missing compact()"); + } + + if (issues.length === 0) { + return null; + } + + return `Context engine "${engineId}" factory returned an invalid ContextEngine: ${issues.join(", ")}.`; +} + // --------------------------------------------------------------------------- // Resolution // --------------------------------------------------------------------------- @@ -423,5 +466,11 @@ export async function resolveContextEngine(config?: OpenClawConfig): Promise