diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index ad5ef5d..9ce8033 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -12,7 +12,7 @@ "name": "claude-code", "source": "./plugins/claude-code", "description": "Persistent semantic memory for Claude Code - user preferences, project context, prior decisions, and codebase facts that survive across sessions.", - "version": "0.2.0", + "version": "0.2.1", "category": "productivity", "homepage": "https://docs.atomicstrata.ai/integrations/coding-agents/claude-code", "license": "Apache-2.0" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6fa8ea1..4b30bff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,6 +78,15 @@ jobs: run: node scripts/ci/release-policy.mjs - name: Run release-policy unit tests run: node --test scripts/ci/__tests__/release-policy.test.mjs + # Every surface that can write a verbatim memory must be able to stamp + # content_class. The 0.2.0 release fixed Hermes for Core's raw-content + # policy and missed OpenClaw, whose published plugin could not perform a + # verbatim ingest at all. Tests run first: a conformance check whose own + # logic is unverified is not evidence of anything. + - name: Run ingest contract conformance tests + run: node --test scripts/ci/__tests__/ingest-contract-conformance.test.mjs + - name: Check ingest contract conformance + run: node scripts/check-ingest-contract-conformance.mjs - name: Run guard unit tests run: node --test scripts/guards/__tests__/*.test.mjs diff --git a/package.json b/package.json index d275e9c..9baabc1 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,8 @@ "ci:code-health": "node scripts/ci/code-health.mjs --verify && (turbo run code-health --affected || turbo run code-health)", "ci:pack-dry-run": "turbo run build && node scripts/ci/pack-dry-run.mjs", "ci:docs-contract": "turbo run docs-contract", - "ci:public-smoke": "turbo run public-integration-smoke" + "ci:public-smoke": "turbo run public-integration-smoke", + "ci:contract-conformance": "node scripts/check-ingest-contract-conformance.mjs" }, "devDependencies": { "@typescript-eslint/parser": "^8.59.3", diff --git a/packages/llmwiki/src/__tests__/live-provider.test.ts b/packages/llmwiki/src/__tests__/live-provider.test.ts index b6be224..3c2343a 100644 --- a/packages/llmwiki/src/__tests__/live-provider.test.ts +++ b/packages/llmwiki/src/__tests__/live-provider.test.ts @@ -6,17 +6,26 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { mkdtemp } from "node:fs/promises"; +import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { LiveLLMWikiProvider } from "../live/provider.ts"; +// A real, private directory. These constructor tests previously hardcoded +// TMP_ROOT, which is a shared path any process on the host can occupy. When it +// exists as a FILE, createWiki() fails with "root exists but is not a +// directory" and the positive cases fail, while the negative cases still pass +// because scope validation throws before touching the filesystem. That made the +// suite dependent on ambient host state rather than hermetic. +const TMP_ROOT = mkdtempSync(path.join(tmpdir(), "llmwiki-provider-")); + const scope = { user: "u1" }; const mk = (root: string) => new LiveLLMWikiProvider({ root, scope, projectId: "proj-1" }); describe("LiveLLMWikiProvider", () => { it("requires projectId", () => { assert.throws( - () => new (LiveLLMWikiProvider as any)({ root: "/tmp/x", scope }), + () => new (LiveLLMWikiProvider as any)({ root: TMP_ROOT, scope }), (e: any) => e?.code === "E_LLMWIKI_PROJECT_ID_REQUIRED", ); }); @@ -64,7 +73,7 @@ describe("LiveLLMWikiProvider", () => { }); it("capabilities advertises text/messages/verbatim + package", () => { - const c = mk("/tmp/whatever").capabilities(); + const c = mk(TMP_ROOT).capabilities(); assert.deepEqual(c.ingestModes, ["text", "messages", "verbatim"]); assert.equal(c.extensions.package, true); }); @@ -260,28 +269,28 @@ describe("LiveLLMWikiProvider scope is copied at the boundary (no reference leak describe("LiveLLMWikiProvider construction scope validation (FIX G)", () => { it("throws E_LLMWIKI_PROVIDER_SCOPE_MISMATCH when scope is empty {}", () => { assert.throws( - () => new LiveLLMWikiProvider({ root: "/tmp/x", scope: {}, projectId: "proj-1" }), + () => new LiveLLMWikiProvider({ root: TMP_ROOT, scope: {}, projectId: "proj-1" }), (e: any) => e?.code === "E_LLMWIKI_PROVIDER_SCOPE_MISMATCH", ); }); it("throws E_LLMWIKI_PROVIDER_SCOPE_MISMATCH when required field 'user' is missing", () => { assert.throws( - () => new LiveLLMWikiProvider({ root: "/tmp/x", scope: { namespace: "ns" }, projectId: "proj-1" }), + () => new LiveLLMWikiProvider({ root: TMP_ROOT, scope: { namespace: "ns" }, projectId: "proj-1" }), (e: any) => e?.code === "E_LLMWIKI_PROVIDER_SCOPE_MISMATCH", ); }); it("throws E_LLMWIKI_PROVIDER_SCOPE_MISMATCH when 'user' is empty string", () => { assert.throws( - () => new LiveLLMWikiProvider({ root: "/tmp/x", scope: { user: "" }, projectId: "proj-1" }), + () => new LiveLLMWikiProvider({ root: TMP_ROOT, scope: { user: "" }, projectId: "proj-1" }), (e: any) => e?.code === "E_LLMWIKI_PROVIDER_SCOPE_MISMATCH", ); }); it("valid scope { user: 'u1' } constructs without throwing", () => { assert.doesNotThrow( - () => new LiveLLMWikiProvider({ root: "/tmp/x", scope: { user: "u1" }, projectId: "proj-1" }), + () => new LiveLLMWikiProvider({ root: TMP_ROOT, scope: { user: "u1" }, projectId: "proj-1" }), ); }); }); diff --git a/packages/llmwiki/src/__tests__/live-registration.test.ts b/packages/llmwiki/src/__tests__/live-registration.test.ts index 5caf893..ccca34e 100644 --- a/packages/llmwiki/src/__tests__/live-registration.test.ts +++ b/packages/llmwiki/src/__tests__/live-registration.test.ts @@ -6,11 +6,17 @@ */ import { describe, it } from "node:test"; import assert from "node:assert/strict"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; import { liveLlmwikiProviderFactory, LiveLLMWikiProvider } from "../live.ts"; +// Private directory rather than the shared TMP_ROOT: see live-provider.test.ts. +const TMP_ROOT = mkdtempSync(path.join(tmpdir(), "llmwiki-registration-")); + describe("live registration + barrel", () => { it("factory returns a LiveLLMWikiProvider", () => { - const { provider } = liveLlmwikiProviderFactory({ root: "/tmp/x", scope: { user: "u1" }, projectId: "proj-1" }); + const { provider } = liveLlmwikiProviderFactory({ root: TMP_ROOT, scope: { user: "u1" }, projectId: "proj-1" }); assert.ok(provider instanceof LiveLLMWikiProvider); }); }); diff --git a/plugins/claude-code/.claude-plugin/plugin.json b/plugins/claude-code/.claude-plugin/plugin.json index 6101152..248f226 100644 --- a/plugins/claude-code/.claude-plugin/plugin.json +++ b/plugins/claude-code/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "atomicmemory", - "version": "0.2.0", + "version": "0.2.1", "description": "Persistent semantic memory for Claude Code — user preferences, project context, prior decisions, and codebase facts that survive across sessions.", "author": { "name": "AtomicMemory", diff --git a/plugins/claude-code/package.json b/plugins/claude-code/package.json index 19a9eb5..bc5c8c1 100644 --- a/plugins/claude-code/package.json +++ b/plugins/claude-code/package.json @@ -1,6 +1,6 @@ { "name": "@atomicmemory/claude-code-plugin", - "version": "0.2.0", + "version": "0.2.1", "description": "AtomicMemory plugin for Claude Code — persistent semantic memory across sessions.", "private": false, "license": "Apache-2.0", diff --git a/plugins/codex/.codex-plugin/plugin.json b/plugins/codex/.codex-plugin/plugin.json index 613fbb2..8882b27 100644 --- a/plugins/codex/.codex-plugin/plugin.json +++ b/plugins/codex/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "atomicmemory", - "version": "0.2.0", + "version": "0.2.1", "description": "AtomicMemory memory layer for Codex. Pluggable semantic memory — swap backends through the SDK's MemoryProvider model by config, not code change.", "author": { "name": "AtomicMemory", diff --git a/plugins/codex/package.json b/plugins/codex/package.json index 9ec87d1..348aa0d 100644 --- a/plugins/codex/package.json +++ b/plugins/codex/package.json @@ -1,6 +1,6 @@ { "name": "@atomicmemory/codex-plugin", - "version": "0.2.0", + "version": "0.2.1", "description": "AtomicMemory plugin for OpenAI Codex — plugin manifest, MCP server config, and memory protocol skill.", "private": true, "license": "Apache-2.0", diff --git a/plugins/codex/skills/atomicmemory/SKILL.md b/plugins/codex/skills/atomicmemory/SKILL.md index bbcb29c..c1e43ab 100644 --- a/plugins/codex/skills/atomicmemory/SKILL.md +++ b/plugins/codex/skills/atomicmemory/SKILL.md @@ -10,7 +10,7 @@ description: > license: Apache-2.0 metadata: author: AtomicMemory - version: "0.2.0" + version: "0.2.1" category: ai-memory tags: "memory, semantic-search, codex, pluggable" --- diff --git a/plugins/cursor/package.json b/plugins/cursor/package.json index f681d33..274e771 100644 --- a/plugins/cursor/package.json +++ b/plugins/cursor/package.json @@ -1,6 +1,6 @@ { "name": "@atomicmemory/cursor-plugin", - "version": "0.2.0", + "version": "0.2.1", "description": "AtomicMemory integration for Cursor - MCP configuration and project rules for persistent semantic memory.", "private": true, "license": "Apache-2.0", diff --git a/plugins/hermes/package.json b/plugins/hermes/package.json index a28ab45..6c16ccd 100644 --- a/plugins/hermes/package.json +++ b/plugins/hermes/package.json @@ -1,6 +1,6 @@ { "name": "@atomicmemory/hermes-plugin", - "version": "0.2.0", + "version": "0.2.1", "description": "AtomicMemory native Hermes memory provider — Python SDK-backed, cross-tool memory by default.", "publishConfig": { "access": "public", diff --git a/plugins/hermes/plugin.yaml b/plugins/hermes/plugin.yaml index 8e240b2..bebfc0b 100644 --- a/plugins/hermes/plugin.yaml +++ b/plugins/hermes/plugin.yaml @@ -1,5 +1,5 @@ name: atomicmemory -version: 0.2.0 +version: 0.2.1 description: "AtomicMemory native Hermes memory provider — Python SDK-backed, cross-tool memory by default." pip_dependencies: - "atomicmemory>=1.1.2,<2.0.0" diff --git a/plugins/hermes/pyproject.toml b/plugins/hermes/pyproject.toml index daacf9b..3430434 100644 --- a/plugins/hermes/pyproject.toml +++ b/plugins/hermes/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "atomicmemory-hermes" -version = "0.2.0" +version = "0.2.1" description = "AtomicMemory native Hermes memory provider." readme = "README.md" requires-python = ">=3.10" diff --git a/plugins/openclaw/openclaw.plugin.json b/plugins/openclaw/openclaw.plugin.json index d32d72a..502d682 100644 --- a/plugins/openclaw/openclaw.plugin.json +++ b/plugins/openclaw/openclaw.plugin.json @@ -1,7 +1,7 @@ { "id": "atomicmemory", "name": "AtomicMemory", - "version": "0.2.0", + "version": "0.2.1", "description": "Persistent semantic memory for OpenClaw agents — cross-channel user memory and deterministic session snapshots via the AtomicMemory SDK's pluggable MemoryProvider model.", "kind": "memory", "skills": ["./skills/atomicmemory"], diff --git a/plugins/openclaw/package.json b/plugins/openclaw/package.json index 81cdcd5..af88beb 100644 --- a/plugins/openclaw/package.json +++ b/plugins/openclaw/package.json @@ -1,6 +1,6 @@ { "name": "@atomicmemory/openclaw-plugin", - "version": "0.2.0", + "version": "0.2.1", "description": "AtomicMemory plugin for OpenClaw — persistent semantic memory and deterministic session snapshots across channels.", "type": "module", "main": "dist/index.js", diff --git a/plugins/openclaw/skills/atomicmemory/skill.yaml b/plugins/openclaw/skills/atomicmemory/skill.yaml index 91ee9a4..fae4a8c 100644 --- a/plugins/openclaw/skills/atomicmemory/skill.yaml +++ b/plugins/openclaw/skills/atomicmemory/skill.yaml @@ -1,5 +1,5 @@ name: atomicmemory -version: 0.2.0 +version: 0.2.1 author: name: AtomicMemory url: https://atomicmem.ai diff --git a/plugins/openclaw/src/index.test.ts b/plugins/openclaw/src/index.test.ts index 0dd7bc7..72e7436 100644 --- a/plugins/openclaw/src/index.test.ts +++ b/plugins/openclaw/src/index.test.ts @@ -74,6 +74,77 @@ test('execute lazily creates one MCP caller and parses result details', async () assert.deepEqual(first.content, [{ type: 'text', text: '{"ok":true,"call":1}' }]); }); +test('memory_ingest schema accepts contentClass as a top-level property', () => { + const tools = registerWithConfig(plugin); + const ingest = tools.find((tool) => tool.name === 'memory_ingest'); + assert.ok(ingest, 'memory_ingest must be registered'); + + const schema = ingest.parameters as { + additionalProperties?: boolean; + properties?: Record; + }; + + // The schema forbids unknown properties, so a missing declaration does not + // degrade to "passed through" - it is rejected before reaching MCP. That is + // what made verbatim ingest impossible against a default-policy core while + // the shipped skill instructions told the agent to send it. + assert.equal( + schema.additionalProperties, + false, + 'schema is closed, so contentClass must be declared explicitly', + ); + assert.ok( + schema.properties?.contentClass, + 'memory_ingest must declare contentClass; the skill instructions require it', + ); + assert.deepEqual(schema.properties?.contentClass?.enum, ['summary', 'redacted', 'raw']); +}); + +test('memory_ingest forwards contentClass to MCP as a top-level argument', async () => { + const toolCalls: Array<{ name: string; arguments?: Record }> = []; + const testPlugin = createOpenClawPlugin(async () => ({ + async callTool(input) { + toolCalls.push(input); + return { content: [{ type: 'text', text: JSON.stringify({ ok: true }) }] }; + }, + })); + + const tools = registerWithConfig(testPlugin); + const ingest = tools.find((tool) => tool.name === 'memory_ingest'); + assert.ok(ingest, 'memory_ingest must be registered'); + + const params = { + mode: 'verbatim', + content: 'session snapshot', + contentClass: 'summary', + }; + + // execute() does not itself validate, so tie the payload to the declared + // schema: the host rejects undeclared top-level keys before execute is ever + // reached. Without this, the test would pass against a schema missing + // contentClass and prove nothing about the bug it guards. + const declared = (ingest.parameters as { properties?: Record }).properties ?? {}; + for (const key of Object.keys(params)) { + assert.ok( + key in declared, + `memory_ingest schema must declare '${key}'; the schema is closed so the host would reject this call`, + ); + } + + await ingest.execute('call-1', params); + + assert.equal(toolCalls.length, 1); + // Top-level, NOT nested under metadata. Core reads content_class from the + // top level; a metadata-nested copy is ignored and still 422s, which is + // exactly what the model fell back to when the property was undeclared. + assert.equal(toolCalls[0]?.arguments?.contentClass, 'summary'); + assert.equal( + (toolCalls[0]?.arguments?.metadata as Record | undefined)?.contentClass, + undefined, + 'contentClass must not be smuggled through metadata', + ); +}); + function registerWithConfig(testPlugin: typeof plugin) { const tools: Array[0]['registerTool']>[0]> = []; testPlugin.register({ diff --git a/plugins/openclaw/src/index.ts b/plugins/openclaw/src/index.ts index 130693f..11151db 100644 --- a/plugins/openclaw/src/index.ts +++ b/plugins/openclaw/src/index.ts @@ -145,6 +145,14 @@ function schemaFor(name: (typeof TOOL_NAMES)[number]): Record { mode: enumSchema(['text', 'messages', 'verbatim']), content: stringSchema(), messages: { type: 'array' }, + // Required by a core running the default RAW_CONTENT_POLICY=reject for + // mode='verbatim': an unstamped verbatim ingest is refused with + // 422 raw_content_rejected. The skill instructions and README already + // tell the agent to set this, but objectSchema pins + // additionalProperties:false, so without the property declared here the + // call was rejected before it left the plugin and verbatim ingest was + // impossible. Forwarded to MCP as a top-level argument. + contentClass: enumSchema(['summary', 'redacted', 'raw']), scope: scopeSchema(), metadata: { type: 'object', additionalProperties: true }, provenance: { type: 'object', additionalProperties: true }, diff --git a/scripts/check-ingest-contract-conformance.mjs b/scripts/check-ingest-contract-conformance.mjs new file mode 100644 index 0000000..51ef818 --- /dev/null +++ b/scripts/check-ingest-contract-conformance.mjs @@ -0,0 +1,258 @@ +#!/usr/bin/env node +/** + * Every surface that can write a verbatim memory must be able to stamp + * `content_class`. + * + * Core 1.2.0 defaults to RAW_CONTENT_POLICY=reject: an unstamped verbatim + * ingest is refused with 422 raw_content_rejected. The plugin family was moved + * to 0.2.0 to satisfy that, but the fix was applied per surface. Hermes was + * fixed; OpenClaw's docs and skill were updated while its tool schema was not, + * and because `objectSchema` pins `additionalProperties: false` the parameter + * was rejected before the call ever left the plugin. The published plugin could + * not perform any verbatim ingest, and no test compared the surfaces to each + * other or to the contract. + * + * Checks are STRUCTURAL, not textual. An earlier version matched any quoted + * "content_class" anywhere in the file, so a mention in a handler body + * satisfied it just as well as a schema declaration, and a commented-out + * declaration satisfied it too. Both are the closed-schema defect this guard + * exists to prevent, so each surface now extracts the region that actually + * governs the wire format, strips comments, and asserts within it. + * + * Usage: node scripts/check-ingest-contract-conformance.mjs + */ +import { readFileSync, existsSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { families } from "./version-families.mjs"; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const CONTRACT = "packages/sdk/schema/v1/provider-contract.schema.json"; + +const read = (rel) => readFileSync(resolve(ROOT, rel), "utf8"); + +/** Remove comments, so a commented-out declaration cannot satisfy a check. */ +export function stripComments(text, style) { + if (style === "c") { + return text.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/(^|[^:])\/\/.*$/gm, "$1"); + } + return text.replace(/(^|\s)#.*$/gm, "$1"); // shell / python +} + +/** Slice the region that actually governs the wire format. */ +function region(text, startRe, endRe) { + const start = text.search(startRe); + if (start === -1) return null; + const rest = text.slice(start); + const end = rest.search(endRe); + return end === -1 ? rest : rest.slice(0, end + 1); +} + +/** + * How each surface satisfies the contract. + * + * declares - owns an ingest parameter schema and must name the property in it, + * because a closed schema rejects anything undeclared + * stamps - builds the request body itself and must set the field, because + * nothing downstream can add it + * delegates - has no ingest surface of its own and must demonstrably invoke + * @atomicmemory/mcp-server, which is checked in its own right + */ +export const SURFACES = [ + { + id: "mcp-server", + kind: "declares", + file: "packages/mcp-server/src/tools.ts", + why: "canonical MCP tool schema; every delegating plugin inherits it", + verify(text) { + const clean = stripComments(text, "c"); + if (!/contentClass\s*:\s*z\s*\.enum\(\s*\[\s*['"]summary['"]/.test(clean)) { + return "does not declare contentClass as a z.enum starting with 'summary'"; + } + return null; + }, + }, + { + id: "openclaw", + kind: "declares", + file: "plugins/openclaw/src/index.ts", + why: "owns a closed tool schema (additionalProperties: false)", + verify(text) { + const schema = region(stripComments(text, "c"), /case\s+['"]memory_ingest['"]\s*:/, /\]\s*\)\s*;/); + if (!schema) return "could not locate the memory_ingest schema block"; + if (!/contentClass\s*:\s*enumSchema\(\s*\[\s*['"]summary['"]/.test(schema)) { + return "the memory_ingest schema block does not declare contentClass as an enumSchema"; + } + return null; + }, + }, + { + id: "hermes", + kind: "declares", + file: "plugins/hermes/tools.py", + why: "owns the atomicmemory_conclude tool schema", + verify(text) { + const schema = region(stripComments(text, "py"), /CONCLUDE_SCHEMA\s*=\s*\{/, /\n\}/); + if (!schema) return "could not locate CONCLUDE_SCHEMA"; + // A mention in the handler body is not a declaration: a closed schema + // rejects the argument before any handler runs. + const props = region(schema, /["']properties["']\s*:\s*\{/, /\n\s{8}\},?\n/); + if (!props || !/["']content_class["']\s*:/.test(props)) { + return "CONCLUDE_SCHEMA.properties does not declare content_class"; + } + const required = region(schema, /["']required["']\s*:\s*\[/, /\]/); + if (!required || !/["']content_class["']/.test(required)) { + return "content_class is declared but not listed in CONCLUDE_SCHEMA.required"; + } + return null; + }, + }, + { + id: "claude-code", + kind: "stamps", + file: "plugins/claude-code/scripts/lib/atomicmemory.sh", + why: "hook scripts build the ingest body directly", + verify(text) { + const body = region(stripComments(text, "sh"), /am_quick_ingest_body\s*\(\)/, /\n\}/); + if (!body) return "could not locate am_quick_ingest_body"; + + // The function builds the payload in more than one `jq -n` branch, and + // EVERY branch must stamp. Requiring a single match anywhere in the + // function let one branch lose its stamp while the check stayed green, + // which is the same "one path fixed, another missed" shape this whole + // check exists to catch. + const branches = body.split(/jq\s+-n/).slice(1); + if (branches.length === 0) return "am_quick_ingest_body builds no jq payload"; + const unstamped = branches.filter((b) => !/content_class:\s*["']summary["']/.test(b)).length; + if (unstamped > 0) { + return `${unstamped} of ${branches.length} jq payload branch(es) in am_quick_ingest_body do not set content_class`; + } + return null; + }, + }, + { + id: "codex", + kind: "delegates", + file: "plugins/codex/.codex-mcp.json", + why: "invokes @atomicmemory/mcp-server rather than owning a schema", + verify(text) { + return /@atomicmemory\/mcp-server/.test(text) + ? null + : "delegation config no longer references @atomicmemory/mcp-server, so the claim that it owns no schema is unsupported"; + }, + }, + { + id: "cursor", + kind: "delegates", + file: "plugins/cursor/.cursor/mcp.json", + why: "invokes @atomicmemory/mcp-server rather than owning a schema", + verify(text) { + return /@atomicmemory\/mcp-server/.test(text) + ? null + : "delegation config no longer references @atomicmemory/mcp-server, so the claim that it owns no schema is unsupported"; + }, + }, +]; + +/** + * Family members, derived from the release definition rather than restated. + * + * Also returns targets this function could not map. Dropping an unrecognised + * target shape would reintroduce the gap this check exists to close: a member + * could join the family and never be required to classify itself. + */ +export function familyMembers(targets = families.plugin) { + const members = new Set(); + const unmapped = []; + for (const target of targets) { + const path = target.file ?? target.path ?? ""; + const m = /^plugins\/([^/]+)\//.exec(path); + if (m) members.add(m[1]); + else if (/(^|\/)marketplace\.json$/.test(path)) { + // No fallback. Defaulting an unidentified marketplace target to + // "claude-code" made a SECOND plugin in the same marketplace file collapse + // into the first, so it never had to classify itself - the same + // enumeration gap this check exists to close. + if (target.pluginId) members.add(target.pluginId); + else unmapped.push(`${path} (marketplace target without pluginId)`); + } else unmapped.push(path || JSON.stringify(target)); + } + return { members: [...members].sort(), unmapped }; +} + +/** Pure decision logic, so every branch can be exercised on synthetic inputs. */ +export function evaluate({ surfaces, family, contract, readFile, exists }) { + const problems = []; + + const defs = contract.$defs ?? contract.definitions ?? {}; + if (!("content_class" in (defs.VerbatimIngest?.properties ?? {}))) { + problems.push( + `${CONTRACT}: VerbatimIngest no longer declares content_class. ` + + `Either the contract regressed or this check is obsolete; resolve deliberately.`, + ); + } + + for (const path of family.unmapped) { + problems.push( + `version-families.mjs target '${path}' could not be mapped to a plugin id. ` + + `Unmapped targets are dropped before conformance runs, so a member could ` + + `join the family without ever being classified. Teach familyMembers() this ` + + `shape, or give the target an explicit pluginId.`, + ); + } + + const known = new Set(surfaces.map((s) => s.id)); + for (const member of family.members) { + if (!known.has(member)) { + problems.push( + `plugins/${member} is in the lockstep plugin family but has no entry in ` + + `SURFACES. Add one: it either declares an ingest schema, stamps the body ` + + `itself, or delegates to @atomicmemory/mcp-server. This is the gap that ` + + `let OpenClaw ship unable to perform a verbatim ingest.`, + ); + } + } + + for (const surface of surfaces) { + if (!exists(surface.file)) { + // Never skip, including delegating surfaces: a deleted delegation config + // is unverified, not satisfied. + problems.push(`${surface.id}: ${surface.file} not found; conformance is unverified, not satisfied.`); + continue; + } + const failure = surface.verify(readFile(surface.file)); + if (failure) { + problems.push( + `${surface.id}: ${failure} (${surface.file}; ${surface.why}). A verbatim ` + + `ingest from this surface will be refused by a core running the default ` + + `RAW_CONTENT_POLICY=reject.`, + ); + } + } + + return problems; +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + const family = familyMembers(); + const problems = evaluate({ + surfaces: SURFACES, + family, + contract: JSON.parse(read(CONTRACT)), + readFile: read, + exists: (rel) => existsSync(resolve(ROOT, rel)), + }); + + if (problems.length > 0) { + console.error("ingest contract conformance: FAILED\n"); + for (const p of problems) console.error(` - ${p}\n`); + process.exit(1); + } + + const owning = SURFACES.filter((s) => s.kind !== "delegates").length; + const delegating = SURFACES.length - owning; + console.log( + `ingest contract conformance: ok (${owning} owning surface(s) verified, ` + + `${delegating} delegation(s) verified, ${family.members.length} family member(s) accounted for)`, + ); +} diff --git a/scripts/ci/__tests__/ingest-contract-conformance.test.mjs b/scripts/ci/__tests__/ingest-contract-conformance.test.mjs new file mode 100644 index 0000000..de3b549 --- /dev/null +++ b/scripts/ci/__tests__/ingest-contract-conformance.test.mjs @@ -0,0 +1,156 @@ +/** + * @file Tests for the ingest contract conformance check. + * + * The check exists because a per-surface fix silently missed a surface: the + * plugin family moved to 0.2.0 for Core's raw-content policy, Hermes was fixed, + * and OpenClaw shipped with updated docs but an unchanged tool schema that + * rejected the parameter its own instructions told the agent to send. + * + * A guard nobody has watched fail is worth nothing, so each case below is the + * failure it is supposed to catch. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { evaluate, familyMembers, stripComments, SURFACES } from "../../check-ingest-contract-conformance.mjs"; + +const CONTRACT_OK = { $defs: { VerbatimIngest: { properties: { content_class: {} } } } }; + +const surface = (over = {}) => ({ + id: "openclaw", + kind: "declares", + file: "plugins/openclaw/src/index.ts", + why: "owns a closed tool schema", + verify: (text) => (/contentClass/.test(text) ? null : "does not declare contentClass"), + ...over, +}); + +const run = (over = {}) => + evaluate({ + surfaces: [surface()], + family: { members: ["openclaw"], unmapped: [] }, + contract: CONTRACT_OK, + readFile: () => "contentClass: enumSchema(['summary'])", + exists: () => true, + ...over, + }); + +test("passes when the surface declares the property", () => { + assert.deepEqual(run(), []); +}); + +test("fails when a declaring surface omits the property (the OpenClaw bug)", () => { + const problems = run({ readFile: () => "mode: enumSchema(['verbatim'])" }); + assert.equal(problems.length, 1); + assert.match(problems[0], /does not declare contentClass/); +}); + +test("fails when a stamping surface omits the field", () => { + const problems = evaluate({ + surfaces: [ + surface({ + id: "claude-code", + kind: "stamps", + file: "lib.sh", + verify: (t) => (/content_class/.test(t) ? null : "does not stamp content_class"), + }), + ], + family: { members: ["claude-code"], unmapped: [] }, + contract: CONTRACT_OK, + readFile: () => 'jq -n --arg u "$U" \'{user_id: $u}\'', + exists: () => true, + }); + assert.equal(problems.length, 1); + assert.match(problems[0], /does not stamp content_class/); +}); + +test("a missing file is unverified, never satisfied", () => { + // The dangerous shape: a moved file must not let the check quietly stop + // covering that surface while still reporting success. + const problems = run({ exists: () => false }); + assert.equal(problems.length, 1); + assert.match(problems[0], /not found; conformance is unverified/); +}); + +test("a new family member with no conformance entry fails", () => { + // This is the case that would have caught OpenClaw: the family grew, and + // nothing forced the new member to be classified. + const problems = run({ family: { members: ["openclaw", "brandnew"], unmapped: [] } }); + assert.equal(problems.length, 1); + assert.match(problems[0], /plugins\/brandnew .* no entry in SURFACES/s); +}); + +test("a delegating surface is verified, not merely asserted", () => { + const problems = evaluate({ + surfaces: [ + { id: "codex", kind: "delegates", file: ".codex-mcp.json", why: "invokes mcp-server", verify: () => null }, + ], + family: { members: ["codex"], unmapped: [] }, + contract: CONTRACT_OK, + readFile: () => '{"command":"npx","args":["@atomicmemory/mcp-server"]}', + exists: () => true, + }); + assert.deepEqual(problems, []); +}); + +test("fails if the contract itself stops requiring content_class", () => { + // Otherwise this check would keep enforcing a rule that no longer exists. + const problems = run({ contract: { $defs: { VerbatimIngest: { properties: {} } } } }); + assert.equal(problems.length, 1); + assert.match(problems[0], /no longer declares content_class/); +}); + +test("an unmapped family target fails instead of being dropped", () => { + // Dropping an unrecognised target shape would let a member join the family + // and never be required to classify itself. + const problems = run({ family: { members: ["openclaw"], unmapped: ["integrations/brandnew/package.json"] } }); + assert.equal(problems.length, 1); + assert.match(problems[0], /could not be mapped to a plugin id/); +}); + +test("familyMembers reports unmapped targets rather than discarding them", () => { + const { members, unmapped } = familyMembers([ + { file: "plugins/openclaw/package.json" }, + { file: "integrations/brandnew/package.json" }, + ]); + assert.deepEqual(members, ["openclaw"]); + assert.deepEqual(unmapped, ["integrations/brandnew/package.json"]); +}); + +test("a commented-out declaration does not satisfy a check", () => { + // The bypass that made the first version of this check worthless. + assert.equal(/contentClass/.test(stripComments("// contentClass: enumSchema(['summary'])", "c")), false); + assert.equal(/content_class/.test(stripComments('# "content_class": {}', "py")), false); +}); + +test("every real surface declares a verify() and a file", () => { + for (const s of SURFACES) { + assert.equal(typeof s.verify, "function", `${s.id} must define verify()`); + assert.ok(s.file, `${s.id} must name a file, including delegating surfaces`); + } +}); + +test("two marketplace plugins map to two distinct members", () => { + // The bypass: a `?? "claude-code"` fallback collapsed every marketplace + // target onto the first plugin, so a SECOND plugin sharing the marketplace + // file never had to classify itself. + const { members, unmapped } = familyMembers([ + { file: ".claude-plugin/marketplace.json", pluginId: "claude-code" }, + { file: ".claude-plugin/marketplace.json", pluginId: "brandnew" }, + ]); + assert.deepEqual(members, ["brandnew", "claude-code"]); + assert.deepEqual(unmapped, []); +}); + +test("a marketplace target without an identity is unmapped, not assumed", () => { + const { members, unmapped } = familyMembers([{ file: ".claude-plugin/marketplace.json" }]); + assert.deepEqual(members, []); + assert.equal(unmapped.length, 1); + assert.match(unmapped[0], /marketplace target without pluginId/); +}); + +test("the real marketplace targets carry a pluginId", () => { + // Guards the coupling: if marketplacePluginTarget stops exposing pluginId, + // the identity check above silently starts failing closed on real targets. + const { unmapped } = familyMembers(); + assert.deepEqual(unmapped, []); +}); diff --git a/scripts/version-families.mjs b/scripts/version-families.mjs index a0f0ac8..0e98e37 100644 --- a/scripts/version-families.mjs +++ b/scripts/version-families.mjs @@ -10,6 +10,7 @@ */ import { readFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -24,7 +25,10 @@ const knownFlags = new Set(["--check"]); const positional = args.filter((arg) => !arg.startsWith("--")); const unknownFlag = args.find((arg) => arg.startsWith("--") && !knownFlags.has(arg)); -const families = { +// Exported so other checks can enumerate the release families instead of +// restating them. A second hand-written copy of this list is exactly how a +// family member gets missed. +export const families = { plugin: [ marketplacePluginTarget(".claude-plugin/marketplace.json", "claude-code"), jsonPathTarget("plugins/claude-code/.claude-plugin/plugin.json", ["version"]), @@ -54,7 +58,11 @@ const families = { ], }; -main(); +// Only run the CLI when invoked directly, so importing this module for its +// family definitions does not execute argument parsing and exit. +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + main(); +} function main() { if (!families[familyName]) usage(); @@ -94,6 +102,9 @@ function jsonPathTarget(file, path) { function marketplacePluginTarget(file, pluginName) { return { file, + // Identity of the plugin this target governs. Several plugins can share one + // marketplace file, so consumers cannot infer it from the path. + pluginId: pluginName, label: `plugins[${pluginName}].version`, read() { const plugin = findMarketplacePlugin(readJson(file), pluginName, file);