diff --git a/CHANGELOG.md b/CHANGELOG.md index 6806d5e..b589918 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## 0.2.6 - 2026-02-11 + +### Fixed + +- **Gateway shows unhelpful "config required" when plugin has no config key** (#33): The installer writes entries with no `config` key, so `pluginConfig` is `undefined` at startup. The config parser now accepts `undefined`/`null` (treating it as `{}` with all defaults) instead of throwing a generic error. This lets the flow reach `register()` which throws a clear error showing the exact JSON snippet to add to `~/.openclaw/openclaw.json`. + +## 0.2.5 - 2026-02-11 + +### Fixed + +- **`plugins install` still fails — JSON Schema in `openclaw.plugin.json` had `required` fields** (#33): The installer validates against the JSON Schema in `openclaw.plugin.json` (via ajv) *before* the TypeScript config parser runs. Removed `"required": ["spicedb"]` at the top level and `"required": ["token"]` inside the spicedb sub-schema. Added tests that walk the entire JSON Schema tree to prevent regressions. + +## 0.2.4 - 2026-02-11 + +### Fixed + +- **`plugins install` fails with "must have required property 'spicedb'"** (#33): Made the TypeScript config parser accept empty `config: {}` by defaulting all SpiceDB fields. The `spicedb.token` defaults to an empty string so install succeeds; on startup, `register()` checks for an empty token and throws a clear error directing the user to configure it in `~/.openclaw/openclaw.json`. +- **grpc-js unhandled rejection crashes gateway on startup**: The `@grpc/grpc-js` load balancer state machine can emit unhandled promise rejections during initial SpiceDB connection setup, crashing the Node.js process. Added a temporary `process.on('unhandledRejection')` guard that catches grpc-related rejections for the first 10 seconds after client creation, with proper cleanup in `stop()`. +- **Docker Compose Graphiti uses wrong FalkorDB host**: `FALKORDB_URI` in docker-compose.yml pointed to `host.docker.internal` instead of the `falkordb` service name, breaking inter-container connectivity. + ## 0.2.3 - 2026-02-11 ### Fixed diff --git a/config.test.ts b/config.test.ts index c410435..9e46176 100644 --- a/config.test.ts +++ b/config.test.ts @@ -1,4 +1,7 @@ import { describe, test, expect, afterEach } from "vitest"; +import { readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; import { graphitiMemoryConfigSchema } from "./config.js"; describe("graphitiMemoryConfigSchema", () => { @@ -79,18 +82,21 @@ describe("graphitiMemoryConfigSchema", () => { }).toThrow("Environment variable NONEXISTENT_VAR is not set"); }); - test("throws on missing spicedb config", () => { - expect(() => { - graphitiMemoryConfigSchema.parse({}); - }).toThrow("spicedb.token is required"); + test("defaults spicedb config when omitted (installer-friendly)", () => { + const config = graphitiMemoryConfigSchema.parse({}); + + expect(config.spicedb.endpoint).toBe("localhost:50051"); + expect(config.spicedb.token).toBe(""); + expect(config.spicedb.insecure).toBe(true); }); - test("throws on missing spicedb.token", () => { - expect(() => { - graphitiMemoryConfigSchema.parse({ - spicedb: { endpoint: "localhost:50051" }, - }); - }).toThrow("spicedb.token is required"); + test("defaults spicedb.token to empty string when omitted", () => { + const config = graphitiMemoryConfigSchema.parse({ + spicedb: { endpoint: "custom:50051" }, + }); + + expect(config.spicedb.endpoint).toBe("custom:50051"); + expect(config.spicedb.token).toBe(""); }); test("throws on unknown top-level keys", () => { @@ -110,10 +116,22 @@ describe("graphitiMemoryConfigSchema", () => { }).toThrow("unknown keys: badField"); }); - test("throws on non-object input", () => { - expect(() => graphitiMemoryConfigSchema.parse(null)).toThrow("config required"); - expect(() => graphitiMemoryConfigSchema.parse("string")).toThrow("config required"); - expect(() => graphitiMemoryConfigSchema.parse([])).toThrow("config required"); + test("treats null/undefined/string as empty config (all defaults)", () => { + // The installer writes no config key, so pluginConfig is undefined. + // parse() should treat these as {} and return all defaults (empty token). + const fromNull = graphitiMemoryConfigSchema.parse(null); + expect(fromNull.spicedb.token).toBe(""); + expect(fromNull.spicedb.endpoint).toBe("localhost:50051"); + + const fromUndefined = graphitiMemoryConfigSchema.parse(undefined); + expect(fromUndefined.spicedb.token).toBe(""); + + const fromString = graphitiMemoryConfigSchema.parse("string"); + expect(fromString.spicedb.token).toBe(""); + }); + + test("throws on array input", () => { + expect(() => graphitiMemoryConfigSchema.parse([])).toThrow("not an array"); }); // Phase 2: customInstructions and maxCaptureMessages @@ -194,3 +212,73 @@ describe("graphitiMemoryConfigSchema", () => { expect(config.graphiti.uuidPollMaxAttempts).toBe(30); }); }); + +// ============================================================================ +// openclaw.plugin.json — install-time JSON Schema validation +// +// OpenClaw's installer validates plugin config against the JSON Schema in +// openclaw.plugin.json BEFORE the TypeScript parse() runs. These tests ensure +// both layers accept the empty config: {} that the installer writes. +// ============================================================================ + +describe("openclaw.plugin.json configSchema (install-time validation)", () => { + const manifestPath = join(dirname(fileURLToPath(import.meta.url)), "openclaw.plugin.json"); + const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")); + const jsonSchema = manifest.configSchema; + + test("manifest is valid JSON with configSchema", () => { + expect(jsonSchema).toBeDefined(); + expect(jsonSchema.type).toBe("object"); + expect(jsonSchema.properties).toBeDefined(); + }); + + test("top-level configSchema has no required fields", () => { + // The installer writes config: {} — any top-level "required" will reject it + expect(jsonSchema.required).toBeUndefined(); + }); + + test("spicedb sub-schema has no required fields", () => { + // Even if spicedb is provided as {}, no fields within should be required at install time + const spicedbSchema = jsonSchema.properties?.spicedb; + expect(spicedbSchema).toBeDefined(); + expect(spicedbSchema.required).toBeUndefined(); + }); + + test("graphiti sub-schema has no required fields", () => { + const graphitiSchema = jsonSchema.properties?.graphiti; + expect(graphitiSchema).toBeDefined(); + expect(graphitiSchema.required).toBeUndefined(); + }); + + test("no sub-schema anywhere has required fields", () => { + // Walk the entire schema tree to catch any "required" we might miss + const findRequired = (obj: unknown, path: string): string[] => { + if (!obj || typeof obj !== "object") return []; + const o = obj as Record; + const found: string[] = []; + if (Array.isArray(o.required) && o.required.length > 0) { + found.push(`${path}.required = ${JSON.stringify(o.required)}`); + } + if (o.properties && typeof o.properties === "object") { + for (const [key, val] of Object.entries(o.properties as Record)) { + found.push(...findRequired(val, `${path}.properties.${key}`)); + } + } + return found; + }; + + const violations = findRequired(jsonSchema, "configSchema"); + expect(violations).toEqual([]); + }); + + test("TypeScript parse() accepts empty config (what installer writes)", () => { + // This is the exact config the installer creates: + // plugins.entries.openclaw-memory-graphiti.config = {} + expect(() => graphitiMemoryConfigSchema.parse({})).not.toThrow(); + }); + + test("TypeScript parse() accepts config with empty spicedb object", () => { + // Installer might also write { spicedb: {} } if user partially fills it + expect(() => graphitiMemoryConfigSchema.parse({ spicedb: {} })).not.toThrow(); + }); +}); diff --git a/config.ts b/config.ts index f43d25e..4657735 100644 --- a/config.ts +++ b/config.ts @@ -19,6 +19,7 @@ export type GraphitiMemoryConfig = { }; const DEFAULT_SPICEDB_ENDPOINT = "localhost:50051"; +const DEFAULT_SPICEDB_TOKEN = ""; const DEFAULT_GRAPHITI_ENDPOINT = "http://localhost:8000"; const DEFAULT_GROUP_ID = "main"; const DEFAULT_UUID_POLL_INTERVAL_MS = 3000; @@ -54,10 +55,11 @@ function assertAllowedKeys(value: Record, allowed: string[], la export const graphitiMemoryConfigSchema = { parse(value: unknown): GraphitiMemoryConfig { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new Error("openclaw-memory-graphiti config required"); + if (Array.isArray(value)) { + throw new Error("openclaw-memory-graphiti config must be an object, not an array"); } - const cfg = value as Record; + // Accept undefined/null (installer writes no config key) — treat as empty {} + const cfg = (value && typeof value === "object" ? value : {}) as Record; assertAllowedKeys( cfg, [ @@ -68,10 +70,7 @@ export const graphitiMemoryConfigSchema = { ); // SpiceDB config - const spicedb = cfg.spicedb as Record | undefined; - if (!spicedb || typeof spicedb.token !== "string") { - throw new Error("spicedb.token is required"); - } + const spicedb = (cfg.spicedb as Record) ?? {}; assertAllowedKeys(spicedb, ["endpoint", "token", "insecure"], "spicedb config"); // Graphiti config @@ -87,7 +86,7 @@ export const graphitiMemoryConfigSchema = { spicedb: { endpoint: typeof spicedb.endpoint === "string" ? spicedb.endpoint : DEFAULT_SPICEDB_ENDPOINT, - token: resolveEnvVars(spicedb.token), + token: typeof spicedb.token === "string" ? resolveEnvVars(spicedb.token) : DEFAULT_SPICEDB_TOKEN, insecure: spicedb.insecure !== false, }, graphiti: { diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 7205e57..9b720d6 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -81,7 +81,7 @@ services: environment: - OPENAI_API_KEY=${OPENAI_API_KEY} - EPISODE_ID_PREFIX=${EPISODE_ID_PREFIX:-epi-} - - FALKORDB_URI=redis://host.docker.internal:${FALKORDB_PORT:-6379} + - FALKORDB_URI=redis://falkordb:6379 depends_on: falkordb: condition: service_healthy diff --git a/index.test.ts b/index.test.ts index 362161e..494ef60 100644 --- a/index.test.ts +++ b/index.test.ts @@ -568,11 +568,18 @@ describe("openclaw-memory-graphiti plugin", () => { }); }); - test("config rejects missing spicedb token", async () => { + test("register() rejects empty spicedb token with helpful message", async () => { const { default: plugin } = await import("./index.js"); mockApi.pluginConfig = { spicedb: {} }; - expect(() => plugin.register(mockApi)).toThrow("spicedb.token is required"); + expect(() => plugin.register(mockApi)).toThrow("spicedb.token is not configured"); + }); + + test("register() rejects undefined config (what installer creates) with helpful message", async () => { + const { default: plugin } = await import("./index.js"); + + mockApi.pluginConfig = undefined; + expect(() => plugin.register(mockApi)).toThrow("spicedb.token is not configured"); }); test("service start verifies connectivity", async () => { diff --git a/index.ts b/index.ts index 6ae1cad..fdc4f2c 100644 --- a/index.ts +++ b/index.ts @@ -65,8 +65,33 @@ const memoryGraphitiPlugin = { const graphiti = new GraphitiClient(cfg.graphiti.endpoint); graphiti.uuidPollIntervalMs = cfg.graphiti.uuidPollIntervalMs; graphiti.uuidPollMaxAttempts = cfg.graphiti.uuidPollMaxAttempts; + if (!cfg.spicedb.token) { + throw new Error( + 'openclaw-memory-graphiti: spicedb.token is not configured. Add a "config" block to ' + + "plugins.entries.openclaw-memory-graphiti in ~/.openclaw/openclaw.json:\n" + + ' "config": { "spicedb": { "token": "", "insecure": true } }', + ); + } const spicedb = new SpiceDbClient(cfg.spicedb); + // Catch unhandled rejections from @grpc/grpc-js internals during initial + // connection setup. The gRPC load balancer state machine can emit promise + // rejections that bypass our try/catch blocks and crash the process. + const grpcRejectionHandler = (reason: unknown) => { + const msg = String(reason); + if (msg.includes("generateMetadata") || msg.includes("grpc")) { + api.logger.warn(`openclaw-memory-graphiti: suppressed grpc-js rejection: ${msg}`); + } else { + // Re-throw non-grpc rejections so they surface normally + throw reason; + } + }; + process.on("unhandledRejection", grpcRejectionHandler); + const grpcGuardTimer = setTimeout(() => { + process.removeListener("unhandledRejection", grpcRejectionHandler); + }, 10_000); + grpcGuardTimer.unref(); // Don't keep the process alive for this timer + const currentSubject: Subject = { type: cfg.subjectType, id: cfg.subjectId, @@ -735,6 +760,8 @@ const memoryGraphitiPlugin = { ); }, stop() { + clearTimeout(grpcGuardTimer); + process.removeListener("unhandledRejection", grpcRejectionHandler); api.logger.info("openclaw-memory-graphiti: stopped"); }, }); diff --git a/openclaw.plugin.json b/openclaw.plugin.json index 7dee5c8..0c5e513 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -80,8 +80,7 @@ "endpoint": { "type": "string" }, "token": { "type": "string" }, "insecure": { "type": "boolean" } - }, - "required": ["token"] + } }, "graphiti": { "type": "object", @@ -99,7 +98,6 @@ "autoRecall": { "type": "boolean" }, "customInstructions": { "type": "string" }, "maxCaptureMessages": { "type": "integer", "minimum": 1, "maximum": 50 } - }, - "required": ["spicedb"] + } } } diff --git a/package.json b/package.json index 934a35e..aef0eeb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@contextableai/openclaw-memory-graphiti", - "version": "0.2.3", + "version": "0.2.6", "description": "OpenClaw two-layer memory plugin: SpiceDB authorization + Graphiti knowledge graph", "type": "module", "license": "MIT",