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.<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:
parent
3abd2d14cb
commit
e93384082a
@ -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.
|
||||
|
||||
@ -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 = [ "*" ];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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})`
|
||||
|
||||
Loading…
Reference in New Issue
Block a user