fix: keep Nix OpenClaw config immutable
Some checks failed
CI / linux (push) Has been cancelled
CI / macos (push) Has been cancelled

What:
- make the downstream Nix-mode auto-enable patch runtime-only and remove broken degraded-state references
- allow plugin-owned channels.<id> 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
This commit is contained in:
joshp123 2026-05-05 22:26:40 +02:00
parent 3abd2d14cb
commit e93384082a
5 changed files with 51 additions and 25 deletions

View File

@ -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.<plugin-id>`, 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.

View File

@ -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 = [ "*" ];
};
};
};
};
};

View File

@ -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 {

View File

@ -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,

View File

@ -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<string, JsonSchema>) || {};
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})`