Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
116 changes: 102 additions & 14 deletions config.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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", () => {
Expand All @@ -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
Expand Down Expand Up @@ -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<string, unknown>;
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<string, unknown>)) {
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();
});
});
15 changes: 7 additions & 8 deletions config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -54,10 +55,11 @@ function assertAllowedKeys(value: Record<string, unknown>, 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<string, unknown>;
// Accept undefined/null (installer writes no config key) — treat as empty {}
const cfg = (value && typeof value === "object" ? value : {}) as Record<string, unknown>;
assertAllowedKeys(
cfg,
[
Expand All @@ -68,10 +70,7 @@ export const graphitiMemoryConfigSchema = {
);

// SpiceDB config
const spicedb = cfg.spicedb as Record<string, unknown> | undefined;
if (!spicedb || typeof spicedb.token !== "string") {
throw new Error("spicedb.token is required");
}
const spicedb = (cfg.spicedb as Record<string, unknown>) ?? {};
assertAllowedKeys(spicedb, ["endpoint", "token", "insecure"], "spicedb config");

// Graphiti config
Expand All @@ -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: {
Expand Down
2 changes: 1 addition & 1 deletion docker/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 9 additions & 2 deletions index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
27 changes: 27 additions & 0 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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": "<your-preshared-key>", "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,
Expand Down Expand Up @@ -735,6 +760,8 @@ const memoryGraphitiPlugin = {
);
},
stop() {
clearTimeout(grpcGuardTimer);
process.removeListener("unhandledRejection", grpcRejectionHandler);
api.logger.info("openclaw-memory-graphiti: stopped");
},
});
Expand Down
6 changes: 2 additions & 4 deletions openclaw.plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,7 @@
"endpoint": { "type": "string" },
"token": { "type": "string" },
"insecure": { "type": "boolean" }
},
"required": ["token"]
}
},
"graphiti": {
"type": "object",
Expand All @@ -99,7 +98,6 @@
"autoRecall": { "type": "boolean" },
"customInstructions": { "type": "string" },
"maxCaptureMessages": { "type": "integer", "minimum": 1, "maximum": 50 }
},
"required": ["spicedb"]
}
}
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down