mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 18:40:44 +00:00
config: keep startup-derived config runtime-only
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
113
src/config/io.ts
113
src/config/io.ts
@@ -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();
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
37
src/config/nix-mode-write-guard.ts
Normal file
37
src/config/nix-mode-write-guard.ts
Normal 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 });
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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."
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user