From d05d6797e1508f823182a4c75e0546759b694342 Mon Sep 17 00:00:00 2001 From: Mark Fogle Date: Wed, 11 Feb 2026 17:06:34 +0000 Subject: [PATCH 1/4] fix: allow install with empty config, guard empty spicedb token at startup (#33) Config schema now defaults all SpiceDB fields so `plugins install` can write an empty config entry without validation failure. On startup, register() checks for an empty token and throws a clear error directing the user to configure it. Also adds a temporary unhandledRejection guard for grpc-js load balancer rejections that crash the process, and fixes the Docker Compose FALKORDB_URI to use the correct service name. Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 8 ++++++++ config.test.ts | 23 +++++++++++++---------- config.ts | 8 +++----- docker/docker-compose.yml | 2 +- index.test.ts | 4 ++-- index.ts | 27 +++++++++++++++++++++++++++ package.json | 2 +- 7 files changed, 55 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6806d5e..5c7bbb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 0.2.4 - 2026-02-11 + +### Fixed + +- **`plugins install` fails with "must have required property 'spicedb'"** (#33): Config schema now accepts an empty `config: {}` at install time by defaulting all SpiceDB fields. The `spicedb.token` defaults to an empty string so the config file write 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..8a2f069 100644 --- a/config.test.ts +++ b/config.test.ts @@ -79,18 +79,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", () => { diff --git a/config.ts b/config.ts index f43d25e..0a0d3d7 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; @@ -68,10 +69,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 +85,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..406b0a9 100644 --- a/index.test.ts +++ b/index.test.ts @@ -568,11 +568,11 @@ 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("service start verifies connectivity", async () => { diff --git a/index.ts b/index.ts index 6ae1cad..5003564 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. " + + "Set it in plugins.entries.openclaw-memory-graphiti.config.spicedb.token " + + "in ~/.openclaw/openclaw.json", + ); + } 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/package.json b/package.json index 934a35e..35683f4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@contextableai/openclaw-memory-graphiti", - "version": "0.2.3", + "version": "0.2.4", "description": "OpenClaw two-layer memory plugin: SpiceDB authorization + Graphiti knowledge graph", "type": "module", "license": "MIT", From 298cec94645163e360ef610ed78706d0583b15eb Mon Sep 17 00:00:00 2001 From: Mark Fogle Date: Wed, 11 Feb 2026 17:13:16 +0000 Subject: [PATCH 2/4] fix: remove required fields from plugin.json JSON Schema The installer validates against openclaw.plugin.json's configSchema (ajv) before the TypeScript parse() runs. The "required": ["spicedb"] and "required": ["token"] entries caused install to fail with empty config. Added 7 tests that load the actual openclaw.plugin.json and verify no sub-schema has required fields, plus tests confirming TypeScript parse() accepts the empty config: {} that the installer writes. Co-Authored-By: Claude Opus 4.6 --- config.test.ts | 73 ++++++++++++++++++++++++++++++++++++++++++++ openclaw.plugin.json | 6 ++-- 2 files changed, 75 insertions(+), 4 deletions(-) diff --git a/config.test.ts b/config.test.ts index 8a2f069..eec002f 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", () => { @@ -197,3 +200,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/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"] + } } } From 7f2cdd9493a51ce55d18c6c0c9f1ef3d1538bd71 Mon Sep 17 00:00:00 2001 From: Mark Fogle Date: Wed, 11 Feb 2026 17:24:32 +0000 Subject: [PATCH 3/4] chore: bump to 0.2.5, improve unconfigured token error message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The JSON Schema `required` fields cannot work with the current OpenClaw installer — it validates `entry?.config ?? {}` against the schema, and ajv has no `useDefaults`. The `required` enforcement lives in register() at runtime instead, with an error message that shows the exact JSON snippet to add. Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 8 +++++++- index.ts | 6 +++--- package.json | 2 +- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c7bbb4..0e913f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,16 @@ # Changelog +## 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): Config schema now accepts an empty `config: {}` at install time by defaulting all SpiceDB fields. The `spicedb.token` defaults to an empty string so the config file write succeeds; on startup, `register()` checks for an empty token and throws a clear error directing the user to configure it in `~/.openclaw/openclaw.json`. +- **`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. diff --git a/index.ts b/index.ts index 5003564..fdc4f2c 100644 --- a/index.ts +++ b/index.ts @@ -67,9 +67,9 @@ const memoryGraphitiPlugin = { graphiti.uuidPollMaxAttempts = cfg.graphiti.uuidPollMaxAttempts; if (!cfg.spicedb.token) { throw new Error( - "openclaw-memory-graphiti: spicedb.token is not configured. " + - "Set it in plugins.entries.openclaw-memory-graphiti.config.spicedb.token " + - "in ~/.openclaw/openclaw.json", + '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); diff --git a/package.json b/package.json index 35683f4..8b137be 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@contextableai/openclaw-memory-graphiti", - "version": "0.2.4", + "version": "0.2.5", "description": "OpenClaw two-layer memory plugin: SpiceDB authorization + Graphiti knowledge graph", "type": "module", "license": "MIT", From 3683977ff307a030ebb02b5b2574b4b6c6e23960 Mon Sep 17 00:00:00 2001 From: Mark Fogle Date: Wed, 11 Feb 2026 17:39:07 +0000 Subject: [PATCH 4/4] fix: accept undefined/null config so helpful error reaches user (#33) The installer writes entries with no config key, so pluginConfig is undefined at startup. parse() now treats undefined/null as {} (all defaults with empty token) instead of throwing a generic "config required" error. This lets the flow reach register() which shows the exact JSON snippet to add to ~/.openclaw/openclaw.json. Bumps to 0.2.6. Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 6 ++++++ config.test.ts | 20 ++++++++++++++++---- config.ts | 7 ++++--- index.test.ts | 7 +++++++ package.json | 2 +- 5 files changed, 34 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e913f8..b589918 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # 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 diff --git a/config.test.ts b/config.test.ts index eec002f..9e46176 100644 --- a/config.test.ts +++ b/config.test.ts @@ -116,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 diff --git a/config.ts b/config.ts index 0a0d3d7..4657735 100644 --- a/config.ts +++ b/config.ts @@ -55,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, [ diff --git a/index.test.ts b/index.test.ts index 406b0a9..494ef60 100644 --- a/index.test.ts +++ b/index.test.ts @@ -575,6 +575,13 @@ describe("openclaw-memory-graphiti plugin", () => { 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 () => { const { default: plugin } = await import("./index.js"); plugin.register(mockApi); diff --git a/package.json b/package.json index 8b137be..aef0eeb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@contextableai/openclaw-memory-graphiti", - "version": "0.2.5", + "version": "0.2.6", "description": "OpenClaw two-layer memory plugin: SpiceDB authorization + Graphiti knowledge graph", "type": "module", "license": "MIT",