From 46fba1d8145e145ec82e79c779c10476d129ba92 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 22 Apr 2026 23:43:41 +0100 Subject: [PATCH] docs(config): clarify symlinked config support --- docs/cli/config.md | 5 +- docs/gateway/configuration.md | 4 ++ docs/gateway/security/index.md | 2 +- src/config/io.write-config.test.ts | 108 ++++++++++++++++++++++++++++- 4 files changed, 115 insertions(+), 4 deletions(-) diff --git a/docs/cli/config.md b/docs/cli/config.md index 99a69189659..15e5923a84e 100644 --- a/docs/cli/config.md +++ b/docs/cli/config.md @@ -359,6 +359,9 @@ If dry-run fails: post-change config before committing it to disk. If the new payload fails schema validation or looks like a destructive clobber, the active config is left alone and the rejected payload is saved beside it as `openclaw.json.rejected.*`. +The active config path must be a regular file. Symlinked `openclaw.json` +layouts are unsupported for writes; use `OPENCLAW_CONFIG_PATH` to point directly +at the real file instead. Prefer CLI writes for small edits: @@ -383,7 +386,7 @@ last-known-good backup during startup or hot reload. See ## Subcommands -- `config file`: Print the active config file path (resolved from `OPENCLAW_CONFIG_PATH` or default location). +- `config file`: Print the active config file path (resolved from `OPENCLAW_CONFIG_PATH` or default location). The path should name a regular file, not a symlink. Restart the gateway after edits. diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 7b6409652e9..72a4d54c898 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -10,6 +10,10 @@ title: "Configuration" # Configuration OpenClaw reads an optional **JSON5** config from `~/.openclaw/openclaw.json`. +The active config path must be a regular file. Symlinked `openclaw.json` +layouts are unsupported for OpenClaw-owned writes; an atomic write may replace +the path instead of preserving the symlink. If you keep config outside the +default state directory, point `OPENCLAW_CONFIG_PATH` directly at the real file. If the file is missing, OpenClaw uses safe defaults. Common reasons to add a config: diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index 71f47217283..2b202b24a64 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -247,7 +247,7 @@ High-signal `checkId` values you will most likely see in real deployments (not e | `fs.state_dir.perms_readable` | warn | State dir is readable by others | filesystem perms on `~/.openclaw` | yes | | `fs.state_dir.symlink` | warn | State dir target becomes another trust boundary | state dir filesystem layout | no | | `fs.config.perms_writable` | critical | Others can change auth/tool policy/config | filesystem perms on `~/.openclaw/openclaw.json` | yes | -| `fs.config.symlink` | warn | Config target becomes another trust boundary | config file filesystem layout | no | +| `fs.config.symlink` | warn | Symlinked config files are unsupported for writes and add another trust boundary | replace with a regular config file or point `OPENCLAW_CONFIG_PATH` at the real file | no | | `fs.config.perms_group_readable` | warn | Group users can read config tokens/settings | filesystem perms on config file | yes | | `fs.config.perms_world_readable` | critical | Config can expose tokens/settings | filesystem perms on config file | yes | | `fs.config_include.perms_writable` | critical | Config include file can be modified by others | include-file perms referenced from `openclaw.json` | yes | diff --git a/src/config/io.write-config.test.ts b/src/config/io.write-config.test.ts index 44e26ea57e9..853891084da 100644 --- a/src/config/io.write-config.test.ts +++ b/src/config/io.write-config.test.ts @@ -1,9 +1,14 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import type { PluginManifestRegistry } from "../plugins/manifest-registry.js"; import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js"; -import { createConfigIO } from "./io.js"; +import { + createConfigIO, + resetConfigRuntimeState, + setRuntimeConfigSnapshot, + writeConfigFile, +} from "./io.js"; import type { ConfigFileSnapshot } from "./types.openclaw.js"; // Mock the plugin manifest registry so we can register a fake channel whose @@ -66,7 +71,12 @@ describe("config io write", () => { } satisfies PluginManifestRegistry); }); + afterEach(() => { + resetConfigRuntimeState(); + }); + afterAll(async () => { + resetConfigRuntimeState(); await suiteRootTracker.cleanup(); }); @@ -428,4 +438,98 @@ describe("config io write", () => { plugins: [], } satisfies PluginManifestRegistry); }); + + it("writes runtime-derived edits back to source SecretRef markers", async () => { + await withSuiteHome(async (home) => { + const configPath = path.join(home, ".openclaw", "openclaw.json"); + const previousConfigPath = process.env.OPENCLAW_CONFIG_PATH; + process.env.OPENCLAW_CONFIG_PATH = configPath; + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile( + configPath, + `${JSON.stringify( + { + gateway: { mode: "local" }, + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, + models: [], + }, + }, + }, + }, + null, + 2, + )}\n`, + "utf-8", + ); + + try { + setRuntimeConfigSnapshot( + { + gateway: { mode: "local" }, + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: "sk-runtime-resolved", + models: [], + }, + }, + }, + }, + { + gateway: { mode: "local" }, + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, + models: [], + }, + }, + }, + }, + ); + + await writeConfigFile({ + gateway: { mode: "local", port: 18789 }, + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: "sk-runtime-resolved", + models: [], + }, + }, + }, + }); + + expect(JSON.parse(await fs.readFile(configPath, "utf-8"))).toEqual({ + gateway: { mode: "local", port: 18789 }, + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, + models: [], + }, + }, + }, + meta: { + lastTouchedAt: expect.any(String), + lastTouchedVersion: expect.any(String), + }, + }); + } finally { + if (previousConfigPath === undefined) { + delete process.env.OPENCLAW_CONFIG_PATH; + } else { + process.env.OPENCLAW_CONFIG_PATH = previousConfigPath; + } + } + }); + }); });