From e93384082a5d0d37ea376583fb1b0be94c355c75 Mon Sep 17 00:00:00 2001 From: joshp123 Date: Tue, 5 May 2026 22:26:40 +0200 Subject: [PATCH] fix: keep Nix OpenClaw config immutable What: - make the downstream Nix-mode auto-enable patch runtime-only and remove broken degraded-state references - allow plugin-owned channels. config in generated Home Manager options - add Telegram channel config coverage to the config validity check - document the Nix/OpenClaw boundary in AGENTS.md Why: - Nix-owned openclaw.json must not be mutated at runtime - plugin channel config should stay valid even when upstream core schema does not type every plugin-owned channel key - future agents need the boundary documented in the packaging repo Tests: - patch -d /tmp/openclaw-v2026.5.4 -p1 --dry-run < nix/patches/skip-plugin-auto-enable-persist-in-nix-mode.patch: passed - generator round-trip against OpenClaw 325df3ef produced no diff: passed - nix eval --accept-flake-config --raw .#checks.aarch64-darwin.config-validity.drvPath: passed - nix eval --accept-flake-config --raw .#checks.x86_64-linux.config-options.drvPath: passed - nix build --accept-flake-config .#checks.aarch64-darwin.config-validity --no-link --print-build-logs: passed --- AGENTS.md | 2 + nix/checks/openclaw-config-validity.nix | 9 ++++ nix/generated/openclaw-config-options.nix | 2 +- ...ugin-auto-enable-persist-in-nix-mode.patch | 21 +++++----- nix/scripts/generate-config-options.ts | 42 ++++++++++++------- 5 files changed, 51 insertions(+), 25 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f0ee8f6..dce601d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,6 +57,8 @@ Git workflow: OpenClaw packaging: - The gateway package must include Control UI assets (run `pnpm ui:build` in the Nix build). +- Nix mode means Nix owns `openclaw.json`. Runtime config mutation belongs upstream in OpenClaw; downstream patches here must be small, temporary, and removed after the pinned upstream release contains the fix. +- Generated config options come from the upstream core schema. Plugin-owned extension surfaces, such as `channels.`, must remain accepted by the Home Manager module even when core does not type every plugin key. - Product intent: ship a working Nix package for OpenClaw users, not just a pin mirror. `openclaw-gateway` is the source-built runnable gateway for Linux and macOS; `openclaw-app` is the Darwin-only desktop app from upstream's signed/notarized app artifact; `openclaw` is the batteries-included bundle. - User-facing docs should lead with one package: `openclaw`. Treat `openclaw-gateway` and `openclaw-app` as advanced/component outputs for checks, modules, and debugging, not separate product tracks. Runtime tools are internal implementation detail, not a public package surface. - QMD is the Nix-supported batteries-included local memory backend. Keep `qmd` internal to the `openclaw` wrapper PATH; users opt in with upstream config (`memory.backend = "qmd"`). Do not make builtin `memorySearch.provider = "local"` / `node-llama-cpp` the primary supported path. diff --git a/nix/checks/openclaw-config-validity.nix b/nix/checks/openclaw-config-validity.nix index eff1f55..b1ebdf6 100644 --- a/nix/checks/openclaw-config-validity.nix +++ b/nix/checks/openclaw-config-validity.nix @@ -75,6 +75,15 @@ let systemd.enable = false; instances.default = { workspaceDir = expectedWorkspace; + config = { + channels.telegram = { + enabled = true; + botToken = "123456:test-token"; + dmPolicy = "open"; + groupPolicy = "disabled"; + allowFrom = [ "*" ]; + }; + }; }; }; }; diff --git a/nix/generated/openclaw-config-options.nix b/nix/generated/openclaw-config-options.nix index 2a516d0..5f32b27 100644 --- a/nix/generated/openclaw-config-options.nix +++ b/nix/generated/openclaw-config-options.nix @@ -3781,7 +3781,7 @@ in }; channels = lib.mkOption { - type = t.nullOr (t.submodule { options = { + type = t.nullOr (t.submodule { freeformType = t.attrsOf t.anything; options = { defaults = lib.mkOption { type = t.nullOr (t.submodule { options = { contextVisibility = lib.mkOption { diff --git a/nix/patches/skip-plugin-auto-enable-persist-in-nix-mode.patch b/nix/patches/skip-plugin-auto-enable-persist-in-nix-mode.patch index f7de260..5033b29 100644 --- a/nix/patches/skip-plugin-auto-enable-persist-in-nix-mode.patch +++ b/nix/patches/skip-plugin-auto-enable-persist-in-nix-mode.patch @@ -1,25 +1,26 @@ diff --git a/src/gateway/server-startup-config.ts b/src/gateway/server-startup-config.ts -index 00d9b3a..3f7ad25 100644 +index e3f1565a00..97feaf2e8c 100644 --- a/src/gateway/server-startup-config.ts +++ b/src/gateway/server-startup-config.ts -@@ -313,6 +313,19 @@ export async function loadGatewayStartupConfigSnapshot(params: { +@@ -99,6 +99,21 @@ export async function loadGatewayStartupConfigSnapshot(params: { }; } -- -+ + + if (isNixMode) { -+ params.log.warn( -+ `gateway: skipped plugin auto-enable persistence in Nix mode:\n${autoEnable.changes.map((entry) => `- ${entry}`).join("\n")}`, ++ params.log.info( ++ `gateway: auto-enabled plugins for this runtime without writing config in Nix mode:\n${autoEnable.changes.map((entry) => `- ${entry}`).join("\n")}`, + ); + return { -+ snapshot: configSnapshot, ++ snapshot: { ++ ...configSnapshot, ++ runtimeConfig: autoEnable.config, ++ config: autoEnable.config, ++ }, + wroteConfig, + ...(pluginMetadataSnapshot ? { pluginMetadataSnapshot } : {}), -+ ...(degradedStartupConfig ? { degradedProviderApi: true } : {}), -+ ...(degradedPluginConfig ? { degradedPluginConfig: true } : {}), + }; + } + try { + const { replaceConfigFile } = await import("../config/mutate.js"); await replaceConfigFile({ - nextConfig: autoEnable.config, diff --git a/nix/scripts/generate-config-options.ts b/nix/scripts/generate-config-options.ts index 3e87ceb..e915ca1 100644 --- a/nix/scripts/generate-config-options.ts +++ b/nix/scripts/generate-config-options.ts @@ -130,16 +130,16 @@ const stripNullable = (schemaObj: JsonSchema): { schema: JsonSchema; nullable: b return { schema, nullable: false }; }; -const typeForSchema = (schemaObj: JsonSchema, indent: string): string => { +const typeForSchema = (schemaObj: JsonSchema, indent: string, pathSegments: string[] = []): string => { const { schema, nullable } = stripNullable(schemaObj); - const typeExpr = baseTypeForSchema(schema, indent); + const typeExpr = baseTypeForSchema(schema, indent, pathSegments); if (nullable) { return `t.nullOr (${typeExpr})`; } return typeExpr; }; -const baseTypeForSchema = (schemaObj: JsonSchema, indent: string): string => { +const baseTypeForSchema = (schemaObj: JsonSchema, indent: string, pathSegments: string[]): string => { const schema = deref(schemaObj, new Set()); if (schema.const !== undefined) { return `t.enum [ ${nixLiteral(schema.const)} ]`; @@ -153,7 +153,7 @@ const baseTypeForSchema = (schemaObj: JsonSchema, indent: string): string => { const entries = schema.anyOf as JsonSchema[]; const objectUnion = objectUnionTypeForSchemas(entries, indent); if (objectUnion) return objectUnion; - const parts = entries.map((entry) => `(${typeForSchema(entry, indent)})`).join(" "); + const parts = entries.map((entry) => `(${typeForSchema(entry, indent, pathSegments)})`).join(" "); return `t.oneOf [ ${parts} ]`; } @@ -161,7 +161,7 @@ const baseTypeForSchema = (schemaObj: JsonSchema, indent: string): string => { const entries = schema.oneOf as JsonSchema[]; const objectUnion = objectUnionTypeForSchemas(entries, indent); if (objectUnion) return objectUnion; - const parts = entries.map((entry) => `(${typeForSchema(entry, indent)})`).join(" "); + const parts = entries.map((entry) => `(${typeForSchema(entry, indent, pathSegments)})`).join(" "); return `t.oneOf [ ${parts} ]`; } @@ -172,7 +172,7 @@ const baseTypeForSchema = (schemaObj: JsonSchema, indent: string): string => { const schemaType = schema.type; if (Array.isArray(schemaType) && schemaType.length > 0) { const parts = schemaType - .map((entry) => `(${typeForSchema({ type: entry }, indent)})`) + .map((entry) => `(${typeForSchema({ type: entry }, indent, pathSegments)})`) .join(" "); return `t.oneOf [ ${parts} ]`; } @@ -188,13 +188,13 @@ const baseTypeForSchema = (schemaObj: JsonSchema, indent: string): string => { return "t.bool"; case "array": { const items = (schema.items as JsonSchema) || {}; - return `t.listOf (${typeForSchema(items, indent)})`; + return `t.listOf (${typeForSchema(items, indent, pathSegments)})`; } case "object": - return objectTypeForSchema(schema, indent); + return objectTypeForSchema(schema, indent, pathSegments); case undefined: if (schema.properties || schema.additionalProperties) { - return objectTypeForSchema(schema, indent); + return objectTypeForSchema(schema, indent, pathSegments); } return "t.anything"; default: @@ -264,7 +264,10 @@ const objectUnionTypeForSchemas = (entries: JsonSchema[], indent: string): strin return `t.submodule { options = {\n${inner}\n${indent}}; }`; }; -const objectTypeForSchema = (schema: JsonSchema, indent: string): string => { +const allowsPluginChannelConfigs = (pathSegments: string[]): boolean => + pathSegments.length === 1 && pathSegments[0] === "channels"; + +const objectTypeForSchema = (schema: JsonSchema, indent: string, pathSegments: string[]): string => { const properties = (schema.properties as Record) || {}; const requiredList = new Set((schema.required as string[]) || []); const keys = Object.keys(properties); @@ -283,18 +286,29 @@ const objectTypeForSchema = (schema: JsonSchema, indent: string): string => { const nextIndent = `${indent} `; const inner = keys .sort() - .map((key) => renderOption(key, properties[key], requiredList.has(key), nextIndent)) + .map((key) => + renderOption(key, properties[key], requiredList.has(key), nextIndent, [...pathSegments, key]) + ) .join("\n"); + const freeform = allowsPluginChannelConfigs(pathSegments) + ? " freeformType = t.attrsOf t.anything;" + : ""; - return `t.submodule { options = {\n${inner}\n${indent}}; }`; + return `t.submodule {${freeform} options = {\n${inner}\n${indent}}; }`; }; -const renderOption = (key: string, schemaObj: JsonSchema, required: boolean, indent: string): string => { +const renderOption = ( + key: string, + schemaObj: JsonSchema, + required: boolean, + indent: string, + pathSegments: string[] = [key] +): string => { const schema = deref(schemaObj, new Set()); const description = typeof schema.description === "string" ? schema.description : null; const hasSchemaDefault = schema.default !== undefined; const effectiveRequired = required && !hasSchemaDefault; - const baseTypeExpr = typeForSchema(schema, indent); + const baseTypeExpr = typeForSchema(schema, indent, pathSegments); const typeExpr = !effectiveRequired && !baseTypeExpr.startsWith("t.nullOr") ? `t.nullOr (${baseTypeExpr})`