config: keep startup-derived config runtime-only

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
joshp123
2026-05-06 11:10:22 +02:00
parent 458ce2da94
commit 53757829da
25 changed files with 484 additions and 354 deletions

View File

@@ -662,7 +662,7 @@ Default slash command settings:
</Accordion>
<Accordion title="Live stream preview">
OpenClaw can stream draft replies by sending a temporary message and editing it as text arrives. `channels.discord.streaming` takes `off` (default) | `partial` | `block` | `progress`. `progress` keeps one editable status draft and updates it with tool progress until final delivery; `streamMode` is a legacy alias and is auto-migrated.
OpenClaw can stream draft replies by sending a temporary message and editing it as text arrives. `channels.discord.streaming` takes `off` (default) | `partial` | `block` | `progress`. `progress` keeps one editable status draft and updates it with tool progress until final delivery; `streamMode` is a legacy runtime alias. Run `openclaw doctor --fix` to rewrite persisted config to the canonical key.
Default stays `off` because Discord preview edits hit rate limits quickly when multiple bots or gateways share an account.

View File

@@ -1020,9 +1020,10 @@ Use draft preview instead of Slack native text streaming:
Legacy keys:
- `channels.slack.streamMode` (`replace | status_final | append`) is auto-migrated to `channels.slack.streaming.mode`.
- boolean `channels.slack.streaming` is auto-migrated to `channels.slack.streaming.mode` and `channels.slack.streaming.nativeTransport`.
- legacy `channels.slack.nativeStreaming` is auto-migrated to `channels.slack.streaming.nativeTransport`.
- `channels.slack.streamMode` (`replace | status_final | append`) is a legacy runtime alias for `channels.slack.streaming.mode`.
- boolean `channels.slack.streaming` is a legacy runtime alias for `channels.slack.streaming.mode` and `channels.slack.streaming.nativeTransport`.
- legacy `channels.slack.nativeStreaming` is a runtime alias for `channels.slack.streaming.nativeTransport`.
- Run `openclaw doctor --fix` to rewrite persisted Slack streaming config to the canonical keys.
## Typing reaction fallback

View File

@@ -152,8 +152,8 @@ Slack-only:
Legacy key migration:
- Telegram: legacy `streamMode` and scalar/boolean `streaming` values are detected and migrated by doctor/config compatibility paths to `streaming.mode`.
- Discord: `streamMode` + boolean `streaming` auto-migrate to `streaming` enum.
- Slack: `streamMode` auto-migrates to `streaming.mode`; boolean `streaming` auto-migrates to `streaming.mode` plus `streaming.nativeTransport`; legacy `nativeStreaming` auto-migrates to `streaming.nativeTransport`.
- Discord: `streamMode` + boolean `streaming` remain runtime aliases for the `streaming` enum; run `openclaw doctor --fix` to rewrite persisted config.
- Slack: `streamMode` remains a runtime alias for `streaming.mode`; boolean `streaming` remains a runtime alias for `streaming.mode` plus `streaming.nativeTransport`; legacy `nativeStreaming` remains a runtime alias for `streaming.nativeTransport`. Run `openclaw doctor --fix` to rewrite persisted config.
### Runtime behavior

View File

@@ -1401,7 +1401,7 @@ Defaults for Talk mode (macOS/iOS/Android).
```
- `talk.provider` must match a key in `talk.providers` when multiple Talk providers are configured.
- Legacy flat Talk keys (`talk.voiceId`, `talk.voiceAliases`, `talk.modelId`, `talk.outputFormat`, `talk.apiKey`) are compatibility-only and are auto-migrated into `talk.providers.<provider>`.
- Legacy flat Talk keys (`talk.voiceId`, `talk.voiceAliases`, `talk.modelId`, `talk.outputFormat`, `talk.apiKey`) are compatibility-only. Run `openclaw doctor --fix` to rewrite persisted config into `talk.providers.<provider>`.
- Voice IDs fall back to `ELEVENLABS_VOICE_ID` or `SAG_VOICE_ID`.
- `providers.*.apiKey` accepts plaintext strings or SecretRef objects.
- `ELEVENLABS_API_KEY` fallback applies only when no Talk API key is configured.

View File

@@ -345,7 +345,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
- `channels.discord.voice.connectTimeoutMs` controls the initial `@discordjs/voice` Ready wait for `/vc join` and auto-join attempts (`30000` by default).
- `channels.discord.voice.reconnectGraceMs` controls how long a disconnected voice session may take to enter reconnect signalling before OpenClaw destroys it (`15000` by default).
- OpenClaw additionally attempts voice receive recovery by leaving/rejoining a voice session after repeated decrypt failures.
- `channels.discord.streaming` is the canonical stream mode key. Legacy `streamMode` and boolean `streaming` values are auto-migrated.
- `channels.discord.streaming` is the canonical stream mode key. Legacy `streamMode` and boolean `streaming` values remain runtime aliases; run `openclaw doctor --fix` to rewrite persisted config.
- `channels.discord.autoPresence` maps runtime availability to bot presence (healthy => online, degraded => idle, exhausted => dnd) and allows optional status text overrides.
- `channels.discord.dangerouslyAllowNameMatching` re-enables mutable name/tag matching (break-glass compatibility mode).
- `channels.discord.execApprovals`: Discord-native exec approval delivery and approver authorization.
@@ -475,7 +475,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
resolve the secret value.
- `configWrites: false` blocks Slack-initiated config writes.
- Optional `channels.slack.defaultAccount` overrides default account selection when it matches a configured account id.
- `channels.slack.streaming.mode` is the canonical Slack stream mode key. `channels.slack.streaming.nativeTransport` controls Slack's native streaming transport. Legacy `streamMode`, boolean `streaming`, and `nativeStreaming` values are auto-migrated.
- `channels.slack.streaming.mode` is the canonical Slack stream mode key. `channels.slack.streaming.nativeTransport` controls Slack's native streaming transport. Legacy `streamMode`, boolean `streaming`, and `nativeStreaming` values remain runtime aliases; run `openclaw doctor --fix` to rewrite persisted config.
- Use `user:<id>` (DM) or `channel:<id>` for delivery targets.
**Reaction notification modes:** `off`, `own` (default), `all`, `allowlist` (from `reactionAllowlist`).

View File

@@ -185,7 +185,7 @@ That stages grounded durable candidates into the short-term dreaming store while
- Show the migration it applied.
- Rewrite `~/.openclaw/openclaw.json` with the updated schema.
The Gateway also auto-runs doctor migrations on startup when it detects a legacy config format, so stale configs are repaired without manual intervention. Cron job store migrations are handled by `openclaw doctor --fix`.
Gateway startup refuses legacy config formats and asks you to run `openclaw doctor --fix`; it does not rewrite `openclaw.json` on startup. Cron job store migrations are also handled by `openclaw doctor --fix`.
Current migrations:

View File

@@ -695,7 +695,7 @@ lives on the [First-run FAQ](/help/faq-first-run).
</Accordion>
<Accordion title="Why do I need a token on localhost now?">
OpenClaw enforces gateway auth by default, including loopback. In the normal default path that means token auth: if no explicit auth path is configured, gateway startup resolves to token mode and auto-generates one, saving it to `gateway.auth.token`, so **local WS clients must authenticate**. This blocks other local processes from calling the Gateway.
OpenClaw enforces gateway auth by default, including loopback. In the normal default path that means token auth: if no explicit auth path is configured, gateway startup resolves to token mode and generates a runtime-only token for that startup, so **local WS clients must authenticate**. Configure `gateway.auth.token`, `gateway.auth.password`, `OPENCLAW_GATEWAY_TOKEN`, or `OPENCLAW_GATEWAY_PASSWORD` explicitly when clients need a stable secret across restarts. This blocks other local processes from calling the Gateway.
If you prefer a different auth path, you can explicitly choose password mode (or, for identity-aware reverse proxies, `trusted-proxy`). If you **really** want open loopback, set `gateway.auth.mode: "none"` explicitly in your config. Doctor can generate a token for you any time: `openclaw doctor --generate-gateway-token`.

View File

@@ -220,10 +220,11 @@ For the generic Docker flow, see [Docker](/install/docker).
XDG_CONFIG_HOME=/home/node/.openclaw
```
Leave `OPENCLAW_GATEWAY_TOKEN` blank unless you explicitly want to
manage it through `.env`; OpenClaw writes a random gateway token to
config on first start. Generate a keyring password and paste it into
`GOG_KEYRING_PASSWORD`:
Set `OPENCLAW_GATEWAY_TOKEN` when you want to manage the stable gateway
token through `.env`; otherwise configure `gateway.auth.token` before
relying on clients across restarts. If neither source exists, OpenClaw uses
a runtime-only token for that startup. Generate a keyring password and paste
it into `GOG_KEYRING_PASSWORD`:
```bash
openssl rand -hex 32

View File

@@ -145,10 +145,11 @@ For the generic Docker flow, see [Docker](/install/docker).
XDG_CONFIG_HOME=/home/node/.openclaw
```
Leave `OPENCLAW_GATEWAY_TOKEN` blank unless you explicitly want to
manage it through `.env`; OpenClaw writes a random gateway token to
config on first start. Generate a keyring password and paste it into
`GOG_KEYRING_PASSWORD`:
Set `OPENCLAW_GATEWAY_TOKEN` when you want to manage the stable gateway
token through `.env`; otherwise configure `gateway.auth.token` before
relying on clients across restarts. If neither source exists, OpenClaw uses
a runtime-only token for that startup. Generate a keyring password and paste
it into `GOG_KEYRING_PASSWORD`:
```bash
openssl rand -hex 32

View File

@@ -488,7 +488,10 @@ Key ideas:
- Tailscale Serve identity headers and `gateway.auth.mode: "trusted-proxy"` do
**not** authenticate this standalone loopback browser API.
- If browser control is enabled and no shared-secret auth is configured, OpenClaw
auto-generates `gateway.auth.token` on startup and persists it to config.
generates a runtime-only gateway token for that startup. Configure
`gateway.auth.token`, `gateway.auth.password`, `OPENCLAW_GATEWAY_TOKEN`, or
`OPENCLAW_GATEWAY_PASSWORD` explicitly if clients need a stable secret across
restarts.
- OpenClaw does **not** auto-generate that token when `gateway.auth.mode` is
already `password`, `none`, or `trusted-proxy`.
- Keep the Gateway and any node hosts on a private network (Tailscale); avoid public exposure.

View File

@@ -44,6 +44,10 @@ export type {
ReadConfigFileSnapshotWithPluginMetadataResult,
} from "./io.js";
export { ConfigMutationConflictError, mutateConfigFile, replaceConfigFile } from "./mutate.js";
export {
assertConfigWriteAllowedInCurrentMode,
NixModeConfigMutationError,
} from "./nix-mode-write-guard.js";
export * from "./paths.js";
export * from "./recovery-policy.js";
export * from "./runtime-overrides.js";

View File

@@ -1,29 +1,18 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import {
type OwnerDisplaySecretPersistState,
persistGeneratedOwnerDisplaySecret,
type OwnerDisplaySecretRuntimeState,
retainGeneratedOwnerDisplaySecret,
} from "./io.owner-display-secret.js";
import type { OpenClawConfig } from "./types.openclaw.js";
function createState(): OwnerDisplaySecretPersistState {
function createState(): OwnerDisplaySecretRuntimeState {
return {
pendingByPath: new Map<string, string>(),
persistInFlight: new Set<string>(),
persistWarned: new Set<string>(),
};
}
async function flushAsyncWork(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
}
describe("persistGeneratedOwnerDisplaySecret", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("persists generated owner display secrets once and clears state on success", async () => {
describe("retainGeneratedOwnerDisplaySecret", () => {
it("keeps generated owner display secrets in runtime state without persisting config", () => {
const state = createState();
const configPath = "/tmp/openclaw.json";
const config = {
@@ -32,84 +21,22 @@ describe("persistGeneratedOwnerDisplaySecret", () => {
ownerDisplaySecret: "generated-owner-secret",
},
} as OpenClawConfig;
const persistConfig = vi.fn(async () => undefined);
const result = persistGeneratedOwnerDisplaySecret({
const result = retainGeneratedOwnerDisplaySecret({
config,
configPath,
generatedSecret: "generated-owner-secret",
logger: { warn: vi.fn() },
state,
persistConfig,
});
expect(result).toBe(config);
expect(state.pendingByPath.get(configPath)).toBe("generated-owner-secret");
expect(state.persistInFlight.has(configPath)).toBe(true);
expect(persistConfig).toHaveBeenCalledTimes(1);
expect(persistConfig).toHaveBeenCalledWith(config, {
expectedConfigPath: configPath,
});
await flushAsyncWork();
expect(state.pendingByPath.has(configPath)).toBe(false);
expect(state.persistInFlight.has(configPath)).toBe(false);
expect(state.persistWarned.has(configPath)).toBe(false);
});
it("warns once and keeps the generated secret pending when persistence fails", async () => {
const state = createState();
const configPath = "/tmp/openclaw.json";
const config = {
commands: {
ownerDisplay: "hash",
ownerDisplaySecret: "generated-owner-secret",
},
} as OpenClawConfig;
const warn = vi.fn();
const persistConfig = vi.fn(async () => {
throw new Error("disk full");
});
persistGeneratedOwnerDisplaySecret({
config,
configPath,
generatedSecret: "generated-owner-secret",
logger: { warn },
state,
persistConfig,
});
await flushAsyncWork();
expect(warn).toHaveBeenCalledTimes(1);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining("Failed to persist auto-generated commands.ownerDisplaySecret"),
);
expect(state.pendingByPath.get(configPath)).toBe("generated-owner-secret");
expect(state.persistInFlight.has(configPath)).toBe(false);
expect(state.persistWarned.has(configPath)).toBe(true);
persistGeneratedOwnerDisplaySecret({
config,
configPath,
generatedSecret: "generated-owner-secret",
logger: { warn },
state,
persistConfig,
});
await flushAsyncWork();
expect(warn).toHaveBeenCalledTimes(1);
});
it("clears pending state when no generated secret is present", () => {
const state = createState();
const configPath = "/tmp/openclaw.json";
state.pendingByPath.set(configPath, "stale-secret");
state.persistWarned.add(configPath);
const config = {
commands: {
ownerDisplay: "hash",
@@ -117,16 +44,13 @@ describe("persistGeneratedOwnerDisplaySecret", () => {
},
} as OpenClawConfig;
const result = persistGeneratedOwnerDisplaySecret({
const result = retainGeneratedOwnerDisplaySecret({
config,
configPath,
logger: { warn: vi.fn() },
state,
persistConfig: vi.fn(async () => undefined),
});
expect(result).toBe(config);
expect(state.pendingByPath.has(configPath)).toBe(false);
expect(state.persistWarned.has(configPath)).toBe(false);
});
});

View File

@@ -1,49 +1,21 @@
import type { OpenClawConfig } from "./types.openclaw.js";
export type OwnerDisplaySecretPersistState = {
export type OwnerDisplaySecretRuntimeState = {
pendingByPath: Map<string, string>;
persistInFlight: Set<string>;
persistWarned: Set<string>;
};
export function persistGeneratedOwnerDisplaySecret(params: {
export function retainGeneratedOwnerDisplaySecret(params: {
config: OpenClawConfig;
configPath: string;
generatedSecret?: string;
logger: Pick<typeof console, "warn">;
state: OwnerDisplaySecretPersistState;
persistConfig: (
config: OpenClawConfig,
options: { expectedConfigPath: string },
) => Promise<unknown>;
state: OwnerDisplaySecretRuntimeState;
}): OpenClawConfig {
const { config, configPath, generatedSecret, logger, state, persistConfig } = params;
const { config, configPath, generatedSecret, state } = params;
if (!generatedSecret) {
state.pendingByPath.delete(configPath);
state.persistWarned.delete(configPath);
return config;
}
state.pendingByPath.set(configPath, generatedSecret);
if (!state.persistInFlight.has(configPath)) {
state.persistInFlight.add(configPath);
void persistConfig(config, { expectedConfigPath: configPath })
.then(() => {
state.pendingByPath.delete(configPath);
state.persistWarned.delete(configPath);
})
.catch((err) => {
if (!state.persistWarned.has(configPath)) {
state.persistWarned.add(configPath);
logger.warn(
`Failed to persist auto-generated commands.ownerDisplaySecret at ${configPath}: ${String(err)}`,
);
}
})
.finally(() => {
state.persistInFlight.delete(configPath);
});
}
return config;
}

View File

@@ -57,7 +57,7 @@ import {
promoteConfigSnapshotToLastKnownGood as promoteConfigSnapshotToLastKnownGoodWithDeps,
recoverConfigFromLastKnownGood as recoverConfigFromLastKnownGoodWithDeps,
} from "./io.observe-recovery.js";
import { persistGeneratedOwnerDisplaySecret } from "./io.owner-display-secret.js";
import { retainGeneratedOwnerDisplaySecret } from "./io.owner-display-secret.js";
import {
collectChangedPaths,
createMergePatch,
@@ -75,6 +75,7 @@ import {
materializeRuntimeConfig,
} from "./materialize.js";
import { applyMergePatch } from "./merge-patch.js";
import { assertConfigWriteAllowedInCurrentMode } from "./nix-mode-write-guard.js";
import { resolveConfigPath, resolveIncludeRoots, resolveStateDir } from "./paths.js";
import {
extractShippedPluginInstallConfigRecords,
@@ -144,6 +145,7 @@ type ShippedPluginInstallConfigWriteMigration =
type ShippedPluginInstallConfigReadMigration = {
config: unknown;
validationConfig?: unknown;
persistedRootParsed?: unknown;
persistedRootRaw?: string;
};
@@ -1254,17 +1256,13 @@ export function createConfigIO(
cfg,
() => pendingSecret ?? crypto.randomBytes(32).toString("hex"),
);
const cfgWithOwnerDisplaySecret = persistGeneratedOwnerDisplaySecret({
const cfgWithOwnerDisplaySecret = retainGeneratedOwnerDisplaySecret({
config: ownerDisplaySecretResolution.config,
configPath,
generatedSecret: ownerDisplaySecretResolution.generatedSecret,
logger: deps.logger,
state: {
pendingByPath: AUTO_OWNER_DISPLAY_SECRET_BY_PATH,
persistInFlight: AUTO_OWNER_DISPLAY_SECRET_PERSIST_IN_FLIGHT,
persistWarned: AUTO_OWNER_DISPLAY_SECRET_PERSIST_WARNED,
},
persistConfig: (nextConfig, options) => writeConfigFile(nextConfig, options),
});
return applyConfigOverrides(cfgWithOwnerDisplaySecret);
@@ -1335,7 +1333,7 @@ export function createConfigIO(
return { config: stripped };
}
if (options.persist === false) {
return { config: stripped };
return { config: configRaw, validationConfig: stripped };
}
try {
@@ -1389,6 +1387,23 @@ export function createConfigIO(
return { config: stripped };
}
function retainRuntimeOnlyShippedPluginInstallConfigRecords(
config: OpenClawConfig,
sourceRaw: unknown,
): OpenClawConfig {
const installRecords = extractShippedPluginInstallConfigRecords(sourceRaw);
if (Object.keys(installRecords).length === 0) {
return config;
}
return {
...config,
plugins: {
...config.plugins,
installs: installRecords,
},
};
}
function ensureShippedPluginInstallConfigRecordsMigratedForWrite(
snapshot: ConfigFileSnapshot,
): ShippedPluginInstallConfigWriteMigration {
@@ -1491,9 +1506,11 @@ export function createConfigIO(
);
const resolvedConfig = readResolution.resolvedConfigRaw;
const installMigration = migrateAndStripShippedPluginInstallConfigRecords(resolvedConfig, {
persist: false,
rootConfigRaw: parsed,
});
const effectiveConfigRaw = installMigration.config;
const validationConfigRaw = installMigration.validationConfig ?? effectiveConfigRaw;
const snapshotRaw = installMigration.persistedRootRaw ?? raw;
const snapshotParsed = installMigration.persistedRootParsed ?? parsed;
const hash = hashConfigRaw(snapshotRaw);
@@ -1502,8 +1519,8 @@ export function createConfigIO(
`Config (${configPath}): missing env var "${w.varName}" at ${w.configPath} - feature using this value will be unavailable`,
);
}
warnOnConfigMiskeys(effectiveConfigRaw, deps.logger);
if (typeof effectiveConfigRaw !== "object" || effectiveConfigRaw === null) {
warnOnConfigMiskeys(validationConfigRaw, deps.logger);
if (typeof validationConfigRaw !== "object" || validationConfigRaw === null) {
observeLoadConfigSnapshot({
...createConfigFileSnapshot({
path: configPath,
@@ -1521,10 +1538,13 @@ export function createConfigIO(
});
return {};
}
const preValidationDuplicates = findDuplicateAgentDirs(effectiveConfigRaw as OpenClawConfig, {
env: deps.env,
homedir: deps.homedir,
});
const preValidationDuplicates = findDuplicateAgentDirs(
validationConfigRaw as OpenClawConfig,
{
env: deps.env,
homedir: deps.homedir,
},
);
if (preValidationDuplicates.length > 0) {
throw new DuplicateAgentDirError(preValidationDuplicates);
}
@@ -1533,15 +1553,19 @@ export function createConfigIO(
if (pluginMetadataSnapshot) {
return pluginMetadataSnapshot;
}
const defaultAgentId = resolveDefaultAgentId(config);
pluginMetadataSnapshot = loadPluginMetadataSnapshot({
const metadataConfig = retainRuntimeOnlyShippedPluginInstallConfigRecords(
config,
workspaceDir: resolveAgentWorkspaceDir(config, defaultAgentId),
effectiveConfigRaw,
);
const defaultAgentId = resolveDefaultAgentId(metadataConfig);
pluginMetadataSnapshot = loadPluginMetadataSnapshot({
config: metadataConfig,
workspaceDir: resolveAgentWorkspaceDir(metadataConfig, defaultAgentId),
env: deps.env,
});
return pluginMetadataSnapshot;
};
const validated = validateConfigObjectWithPlugins(effectiveConfigRaw, {
const validated = validateConfigObjectWithPlugins(validationConfigRaw, {
env: deps.env,
pluginValidation: overrides.pluginValidation,
loadPluginMetadataSnapshot: loadValidationPluginMetadataSnapshot,
@@ -1580,9 +1604,12 @@ export function createConfigIO(
deps.logger.warn(`Config warnings:\n${details}`);
}
warnIfConfigFromFuture(validated.config, deps.logger);
const cfg = materializeRuntimeConfig(validated.config, "load", {
manifestRegistry: pluginMetadataSnapshot?.manifestRegistry,
});
const cfg = retainRuntimeOnlyShippedPluginInstallConfigRecords(
materializeRuntimeConfig(validated.config, "load", {
manifestRegistry: pluginMetadataSnapshot?.manifestRegistry,
}),
effectiveConfigRaw,
);
observeLoadConfigSnapshot({
...createConfigFileSnapshot({
path: configPath,
@@ -1614,11 +1641,7 @@ export function createConfigIO(
}
}
async function readConfigFileSnapshotInternal(
options: {
persistShippedPluginInstallMigration?: boolean;
} = {},
): Promise<ReadConfigFileSnapshotInternalResult> {
async function readConfigFileSnapshotInternal(): Promise<ReadConfigFileSnapshotInternalResult> {
maybeLoadDotEnvForConfig(deps.env);
const exists = deps.fs.existsSync(configPath);
if (!exists) {
@@ -1729,11 +1752,12 @@ export function createConfigIO(
"config.snapshot.read.plugin-install-migration",
() =>
migrateAndStripShippedPluginInstallConfigRecords(resolvedConfigRaw, {
persist: options.persistShippedPluginInstallMigration !== false,
persist: false,
rootConfigRaw: effectiveParsed,
}),
);
const effectiveConfigRaw = installMigration.config;
const validationConfigRaw = installMigration.validationConfig ?? effectiveConfigRaw;
const snapshotRaw = installMigration.persistedRootRaw ?? raw;
const snapshotParsed = installMigration.persistedRootParsed ?? effectiveParsed;
const snapshotHash = installMigration.persistedRootRaw
@@ -1745,16 +1769,20 @@ export function createConfigIO(
if (pluginMetadataSnapshot) {
return pluginMetadataSnapshot;
}
const defaultAgentId = resolveDefaultAgentId(config);
pluginMetadataSnapshot = loadPluginMetadataSnapshot({
const metadataConfig = retainRuntimeOnlyShippedPluginInstallConfigRecords(
config,
workspaceDir: resolveAgentWorkspaceDir(config, defaultAgentId),
effectiveConfigRaw,
);
const defaultAgentId = resolveDefaultAgentId(metadataConfig);
pluginMetadataSnapshot = loadPluginMetadataSnapshot({
config: metadataConfig,
workspaceDir: resolveAgentWorkspaceDir(metadataConfig, defaultAgentId),
env: deps.env,
});
return pluginMetadataSnapshot;
};
const validated = await deps.measure("config.snapshot.read.validate", () =>
validateConfigObjectWithPlugins(effectiveConfigRaw, {
validateConfigObjectWithPlugins(validationConfigRaw, {
env: deps.env,
pluginValidation: overrides.pluginValidation,
loadPluginMetadataSnapshot: loadValidationPluginMetadataSnapshot,
@@ -1784,9 +1812,12 @@ export function createConfigIO(
warnIfConfigFromFuture(validated.config, deps.logger);
const snapshotConfig = await deps.measure("config.snapshot.read.materialize", () =>
materializeRuntimeConfig(validated.config, "snapshot", {
manifestRegistry: pluginMetadataSnapshot?.manifestRegistry,
}),
retainRuntimeOnlyShippedPluginInstallConfigRecords(
materializeRuntimeConfig(validated.config, "snapshot", {
manifestRegistry: pluginMetadataSnapshot?.manifestRegistry,
}),
effectiveConfigRaw,
),
);
return await deps.measure("config.snapshot.read.observe", () =>
finalizeReadConfigSnapshotInternalResult(deps, {
@@ -1892,9 +1923,7 @@ export function createConfigIO(
}
async function readConfigFileSnapshotForWrite(): Promise<ReadConfigFileSnapshotForWriteResult> {
const result = await readConfigFileSnapshotInternal({
persistShippedPluginInstallMigration: false,
});
const result = await readConfigFileSnapshotInternal();
return {
snapshot: result.snapshot,
writeOptions: {
@@ -1949,16 +1978,11 @@ export function createConfigIO(
cfg: OpenClawConfig,
options: ConfigWriteOptions = {},
): Promise<{ persistedHash: string; persistedConfig: OpenClawConfig }> {
assertConfigWriteAllowedInCurrentMode({ configPath, env: deps.env });
clearConfigCache();
const unsetPaths = resolveManagedUnsetPathsForWrite(options.unsetPaths);
let persistCandidate: unknown = cfg;
const snapshot =
options.baseSnapshot ??
(
await readConfigFileSnapshotInternal({
persistShippedPluginInstallMigration: false,
})
).snapshot;
const snapshot = options.baseSnapshot ?? (await readConfigFileSnapshotInternal()).snapshot;
let envRefMap: Map<string, string> | null = null;
let changedPaths: Set<string> | null = null;
if (snapshot.valid && snapshot.exists) {
@@ -2241,8 +2265,6 @@ export function createConfigIO(
// module scope. `OPENCLAW_CONFIG_PATH` (and friends) are expected to work even
// when set after the module has been imported (tests, one-off scripts, etc.).
const AUTO_OWNER_DISPLAY_SECRET_BY_PATH = new Map<string, string>();
const AUTO_OWNER_DISPLAY_SECRET_PERSIST_IN_FLIGHT = new Set<string>();
const AUTO_OWNER_DISPLAY_SECRET_PERSIST_WARNED = new Set<string>();
export function clearConfigCache(): void {
// Compat shim: runtime snapshot is the only in-process cache now.
}
@@ -2381,6 +2403,7 @@ export async function writeConfigFile(
options: ConfigWriteOptions = {},
): Promise<void> {
const io = createConfigIO(options.skipPluginValidation ? { pluginValidation: "skip" } : {});
assertConfigWriteAllowedInCurrentMode({ configPath: io.configPath });
let nextCfg = cfg;
const runtimeConfigSnapshot = getRuntimeConfigSnapshotState();
const runtimeConfigSourceSnapshot = getRuntimeConfigSourceSnapshotState();

View File

@@ -172,7 +172,31 @@ describe("config io write", () => {
});
});
it("migrates shipped plugin install config records into the plugin index", async () => {
it("refuses direct config writes in Nix mode without changing the file", async () => {
await withSuiteHome(async (home) => {
const configPath = path.join(home, ".openclaw", "openclaw.json");
await fs.mkdir(path.dirname(configPath), { recursive: true });
const initialRaw = `${JSON.stringify({ gateway: { mode: "local" } }, null, 2)}\n`;
await fs.writeFile(configPath, initialRaw, "utf-8");
const io = createConfigIO({
configPath,
env: {
OPENCLAW_NIX_MODE: "1",
OPENCLAW_TEST_FAST: "1",
} as NodeJS.ProcessEnv,
homedir: () => home,
logger: silentLogger,
});
await expect(io.writeConfigFile({ gateway: { mode: "local", port: 19001 } })).rejects.toThrow(
"Agent-first Nix setup: https://github.com/openclaw/nix-openclaw#quick-start",
);
await expect(fs.readFile(configPath, "utf-8")).resolves.toBe(initialRaw);
});
});
it("loads shipped plugin install config records without mutating config or plugin index", async () => {
await withSuiteHome(async (home) => {
const configPath = path.join(home, ".openclaw", "openclaw.json");
const pluginDir = path.join(home, ".openclaw", "plugins", "demo");
@@ -229,9 +253,103 @@ describe("config io write", () => {
const io = createFastConfigIO(home);
try {
const initialRaw = await fs.readFile(configPath, "utf-8");
const cfg = io.loadConfig();
expect(cfg.plugins?.installs).toBeUndefined();
expect(cfg.plugins?.installs?.demo).toMatchObject({
source: "npm",
spec: "demo@1.0.0",
installPath: pluginDir,
});
const snapshot = await io.readConfigFileSnapshot();
expect(snapshot.sourceConfig.plugins?.installs?.demo).toMatchObject({
source: "npm",
spec: "demo@1.0.0",
installPath: pluginDir,
});
expect(snapshot.runtimeConfig.plugins?.installs?.demo).toMatchObject({
source: "npm",
spec: "demo@1.0.0",
installPath: pluginDir,
});
await expect(
readPersistedInstalledPluginIndex({
stateDir: path.join(home, ".openclaw"),
}),
).resolves.toBeNull();
await expect(fs.readFile(configPath, "utf-8")).resolves.toBe(initialRaw);
} finally {
mockLoadPluginManifestRegistry.mockReturnValue({
diagnostics: [],
plugins: [],
} satisfies PluginManifestRegistry);
}
});
});
it("migrates shipped plugin install config records into the plugin index during explicit writes", async () => {
await withSuiteHome(async (home) => {
const configPath = path.join(home, ".openclaw", "openclaw.json");
const pluginDir = path.join(home, ".openclaw", "plugins", "demo");
const manifestPath = path.join(pluginDir, "openclaw.plugin.json");
const source = path.join(pluginDir, "index.ts");
await fs.mkdir(pluginDir, { recursive: true });
await fs.writeFile(source, "export function register() {}\n", "utf-8");
await fs.writeFile(
manifestPath,
`${JSON.stringify({ id: "demo", configSchema: { type: "object" } }, null, 2)}\n`,
"utf-8",
);
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(
configPath,
`${JSON.stringify(
{
plugins: {
entries: { demo: { enabled: true } },
installs: {
demo: {
source: "npm",
spec: "demo@1.0.0",
installPath: pluginDir,
},
},
},
},
null,
2,
)}\n`,
"utf-8",
);
mockLoadPluginManifestRegistry.mockReturnValue({
diagnostics: [],
plugins: [
{
id: "demo",
origin: "global",
channels: [],
providers: [],
cliBackends: [],
skills: [],
hooks: [],
rootDir: pluginDir,
source,
manifestPath,
configSchema: {
type: "object",
},
},
],
} satisfies PluginManifestRegistry);
const io = createFastConfigIO(home);
try {
await io.writeConfigFile({
plugins: {
entries: { demo: { enabled: true } },
},
});
await expect(
readPersistedInstalledPluginIndex({
stateDir: path.join(home, ".openclaw"),
@@ -264,7 +382,7 @@ describe("config io write", () => {
});
});
it("migrates shipped plugin install config records even when the manifest is missing", async () => {
it("migrates shipped plugin install config records during explicit writes even when the manifest is missing", async () => {
await withSuiteHome(async (home) => {
const configPath = path.join(home, ".openclaw", "openclaw.json");
const pluginDir = path.join(home, ".openclaw", "plugins", "missing");
@@ -291,9 +409,12 @@ describe("config io write", () => {
);
const io = createFastConfigIO(home);
const cfg = io.loadConfig();
await io.writeConfigFile({
plugins: {
entries: { missing: { enabled: true } },
},
});
expect(cfg.plugins?.installs).toBeUndefined();
await expect(
readPersistedInstalledPluginIndex({
stateDir: path.join(home, ".openclaw"),
@@ -342,8 +463,16 @@ describe("config io write", () => {
});
await fs.writeFile(path.join(unwritableStatePath, "plugins"), "not a directory", "utf-8");
expect(() => io.loadConfig()).toThrow('Unrecognized key: "installs"');
expect(warn).toHaveBeenCalledWith(
let loadedConfig: ReturnType<typeof io.loadConfig> | undefined;
expect(() => {
loadedConfig = io.loadConfig();
}).not.toThrow();
expect(loadedConfig?.plugins?.installs?.demo).toMatchObject({
source: "npm",
spec: "demo@1.0.0",
installPath: pluginDir,
});
expect(warn).not.toHaveBeenCalledWith(
expect.stringContaining("could not migrate shipped plugins.installs records"),
);

View File

@@ -43,12 +43,18 @@ function createSnapshot(params: {
describe("config mutate helpers", () => {
const suiteRootTracker = createSuiteTempRootTracker({ prefix: "openclaw-config-mutate-" });
const originalNixMode = process.env.OPENCLAW_NIX_MODE;
beforeAll(async () => {
await suiteRootTracker.setup();
});
afterAll(async () => {
if (originalNixMode === undefined) {
delete process.env.OPENCLAW_NIX_MODE;
} else {
process.env.OPENCLAW_NIX_MODE = originalNixMode;
}
await suiteRootTracker.cleanup();
});
@@ -58,6 +64,7 @@ describe("config mutate helpers", () => {
ioMocks.resolveConfigSnapshotHash.mockImplementation(
(snapshot: { hash?: string }) => snapshot.hash ?? null,
);
delete process.env.OPENCLAW_NIX_MODE;
});
it("mutates source config with optimistic hash protection", async () => {
@@ -118,6 +125,50 @@ describe("config mutate helpers", () => {
expect(ioMocks.writeConfigFile).not.toHaveBeenCalled();
});
it("refuses replace writes in Nix mode before touching disk", async () => {
process.env.OPENCLAW_NIX_MODE = "1";
const snapshot = createSnapshot({
hash: "hash-1",
sourceConfig: { gateway: { port: 18789 } },
});
ioMocks.readConfigFileSnapshotForWrite.mockResolvedValue({
snapshot,
writeOptions: { expectedConfigPath: snapshot.path },
});
await expect(
replaceConfigFile({
nextConfig: { gateway: { port: 19001 } },
}),
).rejects.toThrow(
"Agent-first Nix setup: https://github.com/openclaw/nix-openclaw#quick-start",
);
expect(ioMocks.writeConfigFile).not.toHaveBeenCalled();
});
it("refuses mutate writes in Nix mode before touching disk", async () => {
process.env.OPENCLAW_NIX_MODE = "1";
const snapshot = createSnapshot({
hash: "hash-1",
sourceConfig: { gateway: { port: 18789 } },
});
ioMocks.readConfigFileSnapshotForWrite.mockResolvedValue({
snapshot,
writeOptions: { expectedConfigPath: snapshot.path },
});
await expect(
mutateConfigFile({
mutate(draft) {
draft.gateway = { ...draft.gateway, port: 19001 };
},
}),
).rejects.toThrow("OpenClaw Nix overview: https://docs.openclaw.ai/install/nix");
expect(ioMocks.writeConfigFile).not.toHaveBeenCalled();
});
it("reuses a provided snapshot and write options for replace", async () => {
const snapshot = createSnapshot({
hash: "hash-1",

View File

@@ -15,6 +15,7 @@ import {
type ConfigWriteOptions,
} from "./io.js";
import { applyUnsetPathsForWrite, resolveManagedUnsetPathsForWrite } from "./io.write-prepare.js";
import { assertConfigWriteAllowedInCurrentMode } from "./nix-mode-write-guard.js";
import {
createRuntimeConfigWriteNotification,
finalizeRuntimeSnapshotWrite,
@@ -228,6 +229,7 @@ export async function replaceConfigFile(params: {
? { snapshot: params.snapshot, writeOptions: params.writeOptions }
: await (params.io?.readConfigFileSnapshotForWrite ?? readConfigFileSnapshotForWrite)();
const { snapshot, writeOptions } = prepared;
assertConfigWriteAllowedInCurrentMode({ configPath: snapshot.path });
const previousHash = assertBaseHashMatches(snapshot, params.baseHash);
const afterWrite = resolveConfigWriteAfterWrite(
params.afterWrite ?? params.writeOptions?.afterWrite,
@@ -271,6 +273,7 @@ export async function mutateConfigFile<T = void>(params: {
const { snapshot, writeOptions } = await (
params.io?.readConfigFileSnapshotForWrite ?? readConfigFileSnapshotForWrite
)();
assertConfigWriteAllowedInCurrentMode({ configPath: snapshot.path });
const previousHash = assertBaseHashMatches(snapshot, params.baseHash);
const baseConfig = params.base === "runtime" ? snapshot.runtimeConfig : snapshot.sourceConfig;
const draft = structuredClone(baseConfig) as OpenClawConfig;

View File

@@ -0,0 +1,37 @@
import { resolveIsNixMode } from "./paths.js";
export const NIX_OPENCLAW_AGENT_FIRST_URL = "https://github.com/openclaw/nix-openclaw#quick-start";
export const OPENCLAW_NIX_OVERVIEW_URL = "https://docs.openclaw.ai/install/nix";
export class NixModeConfigMutationError extends Error {
readonly code = "OPENCLAW_NIX_MODE_CONFIG_IMMUTABLE";
constructor(params: { configPath?: string } = {}) {
super(formatNixModeConfigMutationMessage(params));
this.name = "NixModeConfigMutationError";
}
}
export function formatNixModeConfigMutationMessage(params: { configPath?: string } = {}): string {
return [
"Config is managed by Nix (`OPENCLAW_NIX_MODE=1`), so OpenClaw treats openclaw.json as immutable.",
"This usually means nix-openclaw, the first-party Nix distribution, or another Nix-managed package set this mode.",
...(params.configPath ? [`Config path: ${params.configPath}`] : []),
"Do not run setup, onboarding, openclaw update, plugin install/update/uninstall/enable, doctor repair/token-generation, or config set against this file.",
"Edit the Nix source for this install instead. For nix-openclaw, edit `programs.openclaw.config` or `instances.<name>.config`, then rebuild with Home Manager or NixOS.",
`Agent-first Nix setup: ${NIX_OPENCLAW_AGENT_FIRST_URL}`,
`OpenClaw Nix overview: ${OPENCLAW_NIX_OVERVIEW_URL}`,
].join("\n");
}
export function assertConfigWriteAllowedInCurrentMode(
params: {
configPath?: string;
env?: NodeJS.ProcessEnv;
} = {},
): void {
if (!resolveIsNixMode(params.env)) {
return;
}
throw new NixModeConfigMutationError({ configPath: params.configPath });
}

View File

@@ -277,7 +277,7 @@ describe("gateway startup config validation", () => {
});
});
it("preserves empty model allowlist entries through startup auto-enable writes", async () => {
it("preserves empty model allowlist entries through runtime-only startup auto-enable", async () => {
const sourceConfig = {
agents: {
defaults: {
@@ -323,31 +323,10 @@ describe("gateway startup config validation", () => {
runtimeConfig: sourceConfig,
config: sourceConfig,
} satisfies ConfigFileSnapshot;
const postWriteSnapshot = {
...buildTestConfigSnapshot({
path: configPath,
exists: true,
raw: `${JSON.stringify(autoEnabledConfig)}\n`,
parsed: autoEnabledConfig,
valid: true,
config: autoEnabledConfig,
issues: [],
legacyIssues: [],
}),
sourceConfig: autoEnabledConfig,
resolved: autoEnabledConfig,
runtimeConfig: autoEnabledConfig,
config: autoEnabledConfig,
} satisfies ConfigFileSnapshot;
vi.mocked(configIo.readConfigFileSnapshotWithPluginMetadata)
.mockResolvedValueOnce({
snapshot: initialSnapshot,
pluginMetadataSnapshot,
})
.mockResolvedValueOnce({
snapshot: postWriteSnapshot,
pluginMetadataSnapshot,
});
vi.mocked(configIo.readConfigFileSnapshotWithPluginMetadata).mockResolvedValueOnce({
snapshot: initialSnapshot,
pluginMetadataSnapshot,
});
applyPluginAutoEnable.mockReturnValueOnce({
config: autoEnabledConfig,
changes: ["Telegram configured, enabled automatically."],
@@ -361,8 +340,12 @@ describe("gateway startup config validation", () => {
log,
}),
).resolves.toEqual({
snapshot: postWriteSnapshot,
wroteConfig: true,
snapshot: {
...initialSnapshot,
runtimeConfig: autoEnabledConfig,
config: autoEnabledConfig,
},
wroteConfig: false,
pluginMetadataSnapshot,
});
@@ -371,27 +354,90 @@ describe("gateway startup config validation", () => {
env: process.env,
manifestRegistry: pluginManifestRegistry,
});
expect(configMutate.replaceConfigFile).toHaveBeenCalledWith({
nextConfig: expect.objectContaining({
agents: expect.objectContaining({
defaults: expect.objectContaining({
models: {
"dos-ai/dos-ai": {},
"dos-ai/dos-auto": {},
},
}),
}),
expect(configMutate.replaceConfigFile).not.toHaveBeenCalled();
expect(configIo.readConfigFileSnapshotWithPluginMetadata).toHaveBeenCalledTimes(1);
expect(initialSnapshot.sourceConfig.agents?.defaults?.models).toEqual({
"dos-ai/dos-ai": {},
"dos-ai/dos-auto": {},
});
expect(initialSnapshot.sourceConfig.channels?.telegram).toBeUndefined();
expect(autoEnabledConfig.agents?.defaults?.models).toEqual({
"dos-ai/dos-ai": {},
"dos-ai/dos-auto": {},
});
expect(autoEnabledConfig.channels?.telegram).toEqual({
enabled: true,
});
expect(log.info).toHaveBeenCalledWith(
"gateway: auto-enabled plugins for this runtime without writing config:\n- Telegram configured, enabled automatically.",
);
expect(log.warn).not.toHaveBeenCalled();
});
it("keeps plugin auto-enable runtime-only in Nix mode", async () => {
const sourceConfig = {
channels: {
telegram: {
botToken: "test-token",
},
},
gateway: { mode: "local" },
} as unknown as OpenClawConfig;
const autoEnabledConfig = {
...sourceConfig,
plugins: {
allow: ["telegram"],
},
} as unknown as OpenClawConfig;
const snapshot = {
...buildTestConfigSnapshot({
path: configPath,
exists: true,
raw: `${JSON.stringify(sourceConfig)}\n`,
parsed: sourceConfig,
valid: true,
config: sourceConfig,
issues: [],
legacyIssues: [],
}),
afterWrite: { mode: "auto" },
sourceConfig,
resolved: sourceConfig,
runtimeConfig: sourceConfig,
config: sourceConfig,
} satisfies ConfigFileSnapshot;
vi.mocked(configIo.readConfigFileSnapshotWithPluginMetadata).mockResolvedValueOnce({
snapshot,
pluginMetadataSnapshot,
});
expect(postWriteSnapshot.sourceConfig.agents?.defaults?.models).toEqual({
"dos-ai/dos-ai": {},
"dos-ai/dos-auto": {},
applyPluginAutoEnable.mockReturnValueOnce({
config: autoEnabledConfig,
changes: ["Telegram configured, enabled automatically."],
autoEnabledReasons: {},
});
expect(postWriteSnapshot.config.agents?.defaults?.models).toEqual({
"dos-ai/dos-ai": {},
"dos-ai/dos-auto": {},
configMocks.isNixMode.value = true;
const log = { info: vi.fn(), warn: vi.fn() };
await expect(
loadGatewayStartupConfigSnapshot({
minimalTestGateway: false,
log,
}),
).resolves.toEqual({
snapshot: {
...snapshot,
runtimeConfig: autoEnabledConfig,
config: autoEnabledConfig,
},
wroteConfig: false,
pluginMetadataSnapshot,
});
expect(configMutate.replaceConfigFile).not.toHaveBeenCalled();
expect(configIo.readConfigFileSnapshotWithPluginMetadata).toHaveBeenCalledTimes(1);
expect(log.info).toHaveBeenCalledWith(
"gateway: auto-enabled plugins for this runtime without writing config:\n- Telegram configured, enabled automatically.",
);
expect(log.warn).not.toHaveBeenCalled();
});
it("rejects invalid config before startup without automatic recovery", async () => {

View File

@@ -63,14 +63,14 @@ export async function loadGatewayStartupConfigSnapshot(params: {
initialSnapshotRead?: ReadConfigFileSnapshotWithPluginMetadataResult;
}): Promise<GatewayStartupConfigSnapshotLoadResult> {
const measure = params.measure ?? (async (_name, run) => await run());
let snapshotRead =
const snapshotRead =
params.initialSnapshotRead ??
(await measure("config.snapshot.read", () =>
readConfigFileSnapshotWithPluginMetadata({ measure }),
));
let configSnapshot = snapshotRead.snapshot;
let pluginMetadataSnapshot = snapshotRead.pluginMetadataSnapshot;
let wroteConfig = false;
const configSnapshot = snapshotRead.snapshot;
const pluginMetadataSnapshot = snapshotRead.pluginMetadataSnapshot;
const wroteConfig = false;
if (configSnapshot.legacyIssues.length > 0 && isNixMode) {
throw new Error(
"Legacy config entries detected while running in Nix mode. Update your Nix config to the latest schema and restart.",
@@ -99,33 +99,27 @@ export async function loadGatewayStartupConfigSnapshot(params: {
};
}
try {
const { replaceConfigFile } = await import("../config/mutate.js");
await replaceConfigFile({
nextConfig: autoEnable.config,
afterWrite: { mode: "auto" },
});
wroteConfig = true;
snapshotRead = await measure("config.snapshot.auto-enable-read", () =>
readConfigFileSnapshotWithPluginMetadata({ measure }),
);
configSnapshot = snapshotRead.snapshot;
pluginMetadataSnapshot = snapshotRead.pluginMetadataSnapshot;
assertValidGatewayStartupConfigSnapshot(configSnapshot);
params.log.info(
`gateway: auto-enabled plugins:\n${autoEnable.changes.map((entry) => `- ${entry}`).join("\n")}`,
);
} catch (err) {
params.log.warn(`gateway: failed to persist plugin auto-enable changes: ${String(err)}`);
}
params.log.info(
`gateway: auto-enabled plugins for this runtime without writing config:\n${autoEnable.changes.map((entry) => `- ${entry}`).join("\n")}`,
);
return {
snapshot: configSnapshot,
snapshot: withRuntimeConfig(configSnapshot, autoEnable.config),
wroteConfig,
...(pluginMetadataSnapshot ? { pluginMetadataSnapshot } : {}),
};
}
function withRuntimeConfig(
snapshot: ConfigFileSnapshot,
runtimeConfig: OpenClawConfig,
): ConfigFileSnapshot {
return {
...snapshot,
runtimeConfig,
config: runtimeConfig,
};
}
export function createRuntimeSecretsActivator(params: {
logSecrets: GatewayStartupLog;
emitStateEvent: (
@@ -278,7 +272,7 @@ export async function prepareGatewayStartupConfig(params: {
env: process.env,
authOverride: preflightAuthOverride,
tailscaleOverride: params.tailscaleOverride,
persist: params.persistStartupAuth ?? true,
persist: params.persistStartupAuth ?? false,
baseHash: params.configSnapshot.hash,
});
const runtimeStartupConfig = applyGatewayAuthOverridesForStartupPreflight(authBootstrap.cfg, {

View File

@@ -397,6 +397,19 @@ function createGatewayStartupTrace() {
};
}
function formatRuntimeGatewayAuthTokenWarning(): string {
const base =
"Gateway auth token was missing. Generated a runtime token for this startup without changing config; restart will generate a different token.";
if (!isNixMode) {
return `${base} Persist one with \`openclaw config set gateway.auth.mode token\` and \`openclaw config set gateway.auth.token <token>\`.`;
}
return [
base,
"In Nix mode, set gateway.auth.token in your Nix-managed OpenClaw config and rebuild.",
"For the first-party Nix flow, see https://github.com/openclaw/nix-openclaw#quick-start and https://docs.openclaw.ai/install/nix.",
].join(" ");
}
function collectProcessMemoryUsageMb(): ReadonlyArray<readonly [string, number]> {
const usage = process.memoryUsage();
const toMb = (bytes: number) => bytes / 1024 / 1024;
@@ -577,21 +590,12 @@ export async function startGatewayServer(
authOverride: opts.auth,
tailscaleOverride: opts.tailscale,
activateRuntimeSecrets,
persistStartupAuth: true,
}),
);
cfgAtStart = authBootstrap.cfg;
startupTrace.setConfig(cfgAtStart);
if (authBootstrap.generatedToken) {
if (authBootstrap.persistedGeneratedToken) {
log.info(
"Gateway auth token was missing. Generated a new token and saved it to config (gateway.auth.token).",
);
} else {
log.warn(
"Gateway auth token was missing. Generated a runtime token for this startup without changing config; restart will generate a different token. Persist one with `openclaw config set gateway.auth.mode token` and `openclaw config set gateway.auth.token <token>`.",
);
}
log.warn(formatRuntimeGatewayAuthTokenWarning());
}
const diagnosticsEnabled = isDiagnosticsEnabled(cfgAtStart);
setDiagnosticsEnabledForProcess(diagnosticsEnabled);
@@ -610,31 +614,19 @@ export async function startGatewayServer(
// Unconditional startup migration: seed gateway.controlUi.allowedOrigins for existing
// non-loopback installs that upgraded to v2026.2.26+ without required origins.
const controlUiSeed = minimalTestGateway
? { config: cfgAtStart, persistedAllowedOriginsSeed: false }
? { config: cfgAtStart, seededAllowedOrigins: false }
: await startupTrace.measure("control-ui.seed", () =>
maybeSeedControlUiAllowedOriginsAtStartup({
config: cfgAtStart,
writeConfig: async (nextConfig) => {
const { replaceConfigFile } = await import("../config/mutate.js");
await replaceConfigFile({
nextConfig,
afterWrite: { mode: "auto" },
});
},
log,
runtimeBind: opts.bind,
runtimePort: port,
}),
);
cfgAtStart = controlUiSeed.config;
// Capture the final config hash only after startup writes (plugin auto-enable,
// auth token generation, control-UI origin seeding) so the config reloader can
// suppress its own persistence events without rereading config on every boot.
if (
startupConfigLoad.wroteConfig ||
authBootstrap.persistedGeneratedToken ||
controlUiSeed.persistedAllowedOriginsSeed
) {
// Keep the old startup-write suppression path intact for compatibility with
// callers that may still report a write, but startup itself no longer mutates config.
if (startupConfigLoad.wroteConfig || authBootstrap.persistedGeneratedToken) {
const startupSnapshot = await startupTrace.measure("config.final-snapshot", () =>
readConfigFileSnapshot(),
);

View File

@@ -1,6 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { expectGeneratedTokenPersistedToGatewayAuth } from "../test-utils/auth-token-assertions.js";
import { KNOWN_WEAK_GATEWAY_TOKEN_PLACEHOLDERS } from "./known-weak-gateway-secrets.js";
import {
assertGatewayAuthNotKnownWeak,
@@ -96,7 +95,7 @@ describe("ensureGatewayStartupAuth", () => {
};
}
it("generates and persists a token when startup auth is missing", async () => {
it("generates a runtime token without persisting when startup auth is missing", async () => {
const result = await ensureGatewayStartupAuth({
cfg: {},
env: {} as NodeJS.ProcessEnv,
@@ -104,17 +103,11 @@ describe("ensureGatewayStartupAuth", () => {
});
expect(result.generatedToken).toMatch(/^[0-9a-f]{48}$/);
expect(result.persistedGeneratedToken).toBe(true);
expect(result.persistedGeneratedToken).toBe(false);
expect(result.auth.mode).toBe("token");
expect(mocks.replaceConfigFile).toHaveBeenCalledTimes(1);
const persistedParams = mocks.replaceConfigFile.mock.calls[0]?.[0] as
| { nextConfig: OpenClawConfig }
| undefined;
expectGeneratedTokenPersistedToGatewayAuth({
generatedToken: result.generatedToken,
authToken: result.auth.token,
persistedConfig: persistedParams?.nextConfig,
});
expect(result.auth.token).toBe(result.generatedToken);
expect(result.cfg.gateway?.auth?.token).toBe(result.generatedToken);
expect(mocks.replaceConfigFile).not.toHaveBeenCalled();
});
it("does not generate when token already exists", async () => {

View File

@@ -1,5 +1,4 @@
import crypto from "node:crypto";
import { replaceConfigFile } from "../config/mutate.js";
import type { GatewayAuthConfig, GatewayTailscaleConfig } from "../config/types.gateway.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
@@ -83,23 +82,6 @@ function resolveGatewayAuthFromConfig(params: {
});
}
function shouldPersistGeneratedToken(params: {
persistRequested: boolean;
resolvedAuth: ResolvedGatewayAuth;
}): boolean {
if (!params.persistRequested) {
return false;
}
// Keep CLI/runtime mode overrides ephemeral: startup should not silently
// mutate durable auth policy when mode was chosen by an override flag.
if (params.resolvedAuth.modeSource === "override") {
return false;
}
return true;
}
function hasGatewayTokenCandidate(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
@@ -142,6 +124,10 @@ export async function ensureGatewayStartupAuth(params: {
env?: NodeJS.ProcessEnv;
authOverride?: GatewayAuthConfig;
tailscaleOverride?: GatewayTailscaleConfig;
/**
* Legacy startup option retained for external callers. Startup-generated auth
* is runtime-only; durable auth changes must go through explicit config tools.
*/
persist?: boolean;
baseHash?: string;
}): Promise<{
@@ -152,7 +138,6 @@ export async function ensureGatewayStartupAuth(params: {
}> {
assertExplicitGatewayAuthModeWhenBothConfigured(params.cfg);
const env = params.env ?? process.env;
const persistRequested = params.persist === true;
const explicitMode = params.authOverride?.mode ?? params.cfg.gateway?.auth?.mode;
const [resolvedTokenRefValue, resolvedPasswordRefValue] = await Promise.all([
resolveGatewayTokenSecretRefValue({
@@ -213,17 +198,6 @@ export async function ensureGatewayStartupAuth(params: {
},
},
};
const persist = shouldPersistGeneratedToken({
persistRequested,
resolvedAuth: resolved,
});
if (persist) {
await replaceConfigFile({
nextConfig: nextCfg,
baseHash: params.baseHash,
});
}
const nextAuth = resolveGatewayAuthFromConfig({
cfg: nextCfg,
env,
@@ -240,7 +214,7 @@ export async function ensureGatewayStartupAuth(params: {
cfg: nextCfg,
auth: nextAuth,
generatedToken,
persistedGeneratedToken: persist,
persistedGeneratedToken: false,
};
}

View File

@@ -3,25 +3,19 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
import { maybeSeedControlUiAllowedOriginsAtStartup } from "./startup-control-ui-origins.js";
describe("maybeSeedControlUiAllowedOriginsAtStartup", () => {
it("persists origins seeded from runtime bind and port", async () => {
const written: OpenClawConfig[] = [];
it("applies origins seeded from runtime bind and port without persisting config", async () => {
const log = { info: vi.fn(), warn: vi.fn() };
const result = await maybeSeedControlUiAllowedOriginsAtStartup({
config: { gateway: {} },
writeConfig: async (config) => {
written.push(config);
},
log,
runtimeBind: "lan",
runtimePort: 3000,
});
const expectedOrigins = ["http://localhost:3000", "http://127.0.0.1:3000"];
expect(result.persistedAllowedOriginsSeed).toBe(true);
expect(result.seededAllowedOrigins).toBe(true);
expect(result.config.gateway?.controlUi?.allowedOrigins).toEqual(expectedOrigins);
expect(written).toHaveLength(1);
expect(written[0]?.gateway?.controlUi?.allowedOrigins).toEqual(expectedOrigins);
expect(log.info).toHaveBeenCalledWith(expect.stringContaining("for bind=lan"));
expect(log.warn).not.toHaveBeenCalled();
});
@@ -32,19 +26,16 @@ describe("maybeSeedControlUiAllowedOriginsAtStartup", () => {
controlUi: { allowedOrigins: ["https://control.example.com"] },
},
};
const writeConfig = vi.fn<() => Promise<void>>();
const log = { info: vi.fn(), warn: vi.fn() };
const result = await maybeSeedControlUiAllowedOriginsAtStartup({
config,
writeConfig,
log,
runtimeBind: "lan",
runtimePort: 3000,
});
expect(result).toEqual({ config, persistedAllowedOriginsSeed: false });
expect(writeConfig).not.toHaveBeenCalled();
expect(result).toEqual({ config, seededAllowedOrigins: false });
expect(log.info).not.toHaveBeenCalled();
expect(log.warn).not.toHaveBeenCalled();
});

View File

@@ -7,35 +7,26 @@ import { isContainerEnvironment } from "./net.js";
export async function maybeSeedControlUiAllowedOriginsAtStartup(params: {
config: OpenClawConfig;
writeConfig: (config: OpenClawConfig) => Promise<void>;
log: { info: (msg: string) => void; warn: (msg: string) => void };
runtimeBind?: unknown;
runtimePort?: unknown;
}): Promise<{ config: OpenClawConfig; persistedAllowedOriginsSeed: boolean }> {
}): Promise<{ config: OpenClawConfig; seededAllowedOrigins: boolean }> {
const seeded = ensureControlUiAllowedOriginsForNonLoopbackBind(params.config, {
isContainerEnvironment,
runtimeBind: params.runtimeBind,
runtimePort: params.runtimePort,
});
if (!seeded.seededOrigins || !seeded.bind) {
return { config: params.config, persistedAllowedOriginsSeed: false };
return { config: params.config, seededAllowedOrigins: false };
}
try {
await params.writeConfig(seeded.config);
params.log.info(buildSeededOriginsInfoLog(seeded.seededOrigins, seeded.bind));
return { config: seeded.config, persistedAllowedOriginsSeed: true };
} catch (err) {
params.log.warn(
`gateway: failed to persist gateway.controlUi.allowedOrigins seed: ${String(err)}. The gateway will start with the in-memory value but config was not saved.`,
);
}
return { config: seeded.config, persistedAllowedOriginsSeed: false };
params.log.info(buildSeededOriginsInfoLog(seeded.seededOrigins, seeded.bind));
return { config: seeded.config, seededAllowedOrigins: true };
}
function buildSeededOriginsInfoLog(origins: string[], bind: GatewayNonLoopbackBindMode): string {
return (
`gateway: seeded gateway.controlUi.allowedOrigins ${JSON.stringify(origins)} ` +
`for bind=${bind} (required since v2026.2.26; see issue #29385). ` +
"Add other origins to gateway.controlUi.allowedOrigins if needed."
"Applied for this runtime without writing config; add other origins to gateway.controlUi.allowedOrigins if needed."
);
}