docs(config): clarify symlinked config support

This commit is contained in:
Peter Steinberger
2026-04-22 23:43:41 +01:00
parent 95119017c8
commit 46fba1d814
4 changed files with 115 additions and 4 deletions

View File

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

View File

@@ -10,6 +10,10 @@ title: "Configuration"
# Configuration
OpenClaw reads an optional <Tooltip tip="JSON5 supports comments and trailing commas">**JSON5**</Tooltip> 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:

View File

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

View File

@@ -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;
}
}
});
});
});