diff --git a/CHANGELOG.md b/CHANGELOG.md index 6dd5f9259e1..59cae311e01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ Docs: https://docs.openclaw.ai - Providers/Amazon Bedrock: use known context-window metadata for discovered models while keeping the unknown-model fallback conservative, so compaction and overflow handling improve for newer Bedrock models without overstating unlisted model limits. Thanks @wirjo. - Providers/Amazon Bedrock Mantle: refresh IAM-backed bearer tokens at runtime instead of baking discovery-time tokens into provider config, so long-lived Mantle sessions keep working after the initial token ages out. Thanks @wirjo. - Config/includes: write through single-file top-level includes for isolated OpenClaw-owned mutations, so `plugins install` and `plugins update` update an included `plugins.json5` file instead of flattening modular `$include` configs. Fixes #41050 and #66048. +- Config/reload: plan gateway reloads from source-authored config instead of runtime-materialized snapshots, so plugin update writes no longer trigger false restarts from derived provider/plugin config paths. Fixes #68732. - Codex harness: rotate the shared app-server websocket client when the configured bearer token changes, so auth-token refreshes reconnect with the new `Authorization` header instead of reusing a stale socket. (#70328) Thanks @Lucenx9. - Telegram/sandbox: keep Telegram bot DMs on per-account sender session keys even when `session.dmScope=main`, so sandbox/tool policy can distinguish Telegram-originated direct chats from the agent main session. - Config/models: merge provider-scoped model allowlist updates and protect model/provider map writes from accidental full replacement, adding `config set --merge` for additive updates and `--replace` for intentional clobbers. Fixes #65920, #68392, and #68653. diff --git a/scripts/e2e/config-reload-source-docker.sh b/scripts/e2e/config-reload-source-docker.sh new file mode 100755 index 00000000000..3260848f30e --- /dev/null +++ b/scripts/e2e/config-reload-source-docker.sh @@ -0,0 +1,176 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +source "$ROOT_DIR/scripts/lib/docker-e2e-logs.sh" + +IMAGE_NAME="${OPENCLAW_CONFIG_RELOAD_E2E_IMAGE:-openclaw-config-reload-e2e}" +SKIP_BUILD="${OPENCLAW_CONFIG_RELOAD_E2E_SKIP_BUILD:-0}" +PORT="18789" +TOKEN="reload-e2e-token" +CONTAINER_NAME="openclaw-config-reload-e2e-$$" + +cleanup() { + docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +if [ "$SKIP_BUILD" = "1" ]; then + echo "Reusing Docker image: $IMAGE_NAME" +else + echo "Building Docker image..." + run_logged config-reload-build docker build -t "$IMAGE_NAME" -f "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" +fi + +echo "Starting gateway container..." +docker run -d \ + --name "$CONTAINER_NAME" \ + -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ + -e GATEWAY_AUTH_TOKEN_REF="$TOKEN" \ + -e OPENCLAW_SKIP_CHANNELS=1 \ + -e OPENCLAW_SKIP_PROVIDERS=1 \ + -e OPENCLAW_SKIP_GMAIL_WATCHER=1 \ + -e OPENCLAW_SKIP_CRON=1 \ + -e OPENCLAW_SKIP_CANVAS_HOST=1 \ + "$IMAGE_NAME" \ + bash -lc "set -euo pipefail +entry=dist/index.mjs +[ -f \"\$entry\" ] || entry=dist/index.js +mkdir -p \"\$HOME/.openclaw\" +cat > \"\$HOME/.openclaw/openclaw.json\" <<'JSON' +{ + \"gateway\": { + \"port\": $PORT, + \"auth\": { + \"mode\": \"token\", + \"token\": { + \"source\": \"env\", + \"provider\": \"default\", + \"id\": \"GATEWAY_AUTH_TOKEN_REF\" + } + }, + \"controlUi\": { + \"enabled\": false + }, + \"reload\": { + \"mode\": \"hybrid\", + \"debounceMs\": 0 + } + }, + \"plugins\": { + \"installs\": { + \"lossless-claw\": { + \"source\": \"npm\", + \"spec\": \"@martian-engineering/lossless-claw\", + \"installPath\": \"/tmp/lossless-claw\", + \"installedAt\": \"2026-04-22T00:00:00.000Z\", + \"resolvedAt\": \"2026-04-22T00:00:00.000Z\" + } + } + } +} +JSON +node \"\$entry\" gateway --port $PORT --bind loopback --allow-unconfigured > /tmp/config-reload-e2e.log 2>&1" >/dev/null + +echo "Waiting for gateway..." +ready=0 +for _ in $(seq 1 180); do + if [ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null || echo false)" != "true" ]; then + break + fi + if docker exec "$CONTAINER_NAME" bash -lc "node --input-type=module -e ' + import net from \"node:net\"; + const socket = net.createConnection({ host: \"127.0.0.1\", port: $PORT }); + const timeout = setTimeout(() => { + socket.destroy(); + process.exit(1); + }, 400); + socket.on(\"connect\", () => { + clearTimeout(timeout); + socket.end(); + process.exit(0); + }); + socket.on(\"error\", () => { + clearTimeout(timeout); + process.exit(1); + }); + ' >/dev/null 2>&1"; then + ready=1 + break + fi + sleep 0.5 +done + +if [ "$ready" -ne 1 ]; then + echo "Gateway failed to start" + docker logs "$CONTAINER_NAME" 2>&1 | tail -n 120 || true + docker exec "$CONTAINER_NAME" bash -lc "tail -n 120 /tmp/config-reload-e2e.log" || true + exit 1 +fi + +echo "Checking initial RPC status..." +docker exec "$CONTAINER_NAME" bash -lc " +entry=dist/index.mjs +[ -f \"\$entry\" ] || entry=dist/index.js +node \"\$entry\" gateway status --url ws://127.0.0.1:$PORT --token '$TOKEN' --require-rpc --timeout 5000 >/tmp/config-reload-status-before.log +" + +echo "Mutating plugin install timestamp metadata..." +docker exec "$CONTAINER_NAME" bash -lc "node --input-type=module - <<'NODE' +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const configPath = path.join(os.homedir(), '.openclaw', 'openclaw.json'); +const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); +config.plugins.installs['lossless-claw'].installedAt = '2026-04-22T00:01:00.000Z'; +config.plugins.installs['lossless-claw'].resolvedAt = '2026-04-22T00:01:00.000Z'; +fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf8'); +NODE" + +sleep 2 + +if [ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null || echo false)" != "true" ]; then + echo "Gateway container exited after config metadata write" + docker logs "$CONTAINER_NAME" 2>&1 | tail -n 120 || true + exit 1 +fi + +echo "Checking post-write RPC status..." +docker exec "$CONTAINER_NAME" bash -lc " +entry=dist/index.mjs +[ -f \"\$entry\" ] || entry=dist/index.js +node \"\$entry\" gateway status --url ws://127.0.0.1:$PORT --token '$TOKEN' --require-rpc --timeout 5000 >/tmp/config-reload-status-after.log +" + +echo "Checking reload log..." +docker exec "$CONTAINER_NAME" bash -lc "node --input-type=module - <<'NODE' +import fs from 'node:fs'; + +const log = fs.readFileSync('/tmp/config-reload-e2e.log', 'utf8'); +const reloadLines = log + .split('\n') + .filter((line) => line.includes('config change detected; evaluating reload')); +const restartLines = log + .split('\n') + .filter((line) => line.includes('config change requires gateway restart')); +if (restartLines.length > 0) { + console.error(log.split('\n').slice(-160).join('\n')); + throw new Error('unexpected restart-required reload line found'); +} +for (const line of reloadLines) { + for (const needle of ['gateway.auth.token', 'plugins.entries.firecrawl.config.webFetch']) { + if (line.includes(needle)) { + console.error(log.split('\n').slice(-160).join('\n')); + throw new Error('runtime-only path appeared in reload diff: ' + needle); + } + } +} +if (reloadLines.length === 0) { + console.error(log.split('\n').slice(-160).join('\n')); + throw new Error('expected config reload detection log after metadata write'); +} +console.log('ok'); +NODE" + +echo "Config reload Docker E2E passed." diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 27b101e3b17..432350b5026 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -1307,6 +1307,65 @@ describe("update-cli", () => { expect(lastWrite?.nextConfig?.update?.channel).toBe("beta"); }); + it("uses source config, not runtime-materialized config, for post-update plugin sync", async () => { + const tempDir = createCaseDir("openclaw-update"); + mockPackageInstallStatus(tempDir); + const sourceConfig = { + plugins: { + installs: { + "lossless-claw": { + source: "npm", + spec: "@martian-engineering/lossless-claw", + installPath: "/tmp/lossless-claw", + }, + }, + }, + } as OpenClawConfig; + vi.mocked(readConfigFileSnapshot).mockResolvedValue({ + ...baseSnapshot, + sourceConfig, + config: { + ...sourceConfig, + gateway: { auth: { mode: "token", token: "runtime" } }, + plugins: { + ...sourceConfig.plugins, + entries: { + firecrawl: { + config: { + webFetch: { provider: "firecrawl" }, + }, + }, + }, + }, + } as OpenClawConfig, + }); + syncPluginsForUpdateChannel.mockResolvedValue({ + changed: false, + config: sourceConfig, + summary: { + switchedToBundled: [], + switchedToNpm: [], + warnings: [], + errors: [], + }, + }); + updateNpmInstalledPlugins.mockResolvedValue({ + changed: false, + config: sourceConfig, + outcomes: [], + }); + + await updateCommand({ channel: "beta", yes: true }); + + const syncConfig = vi.mocked(syncPluginsForUpdateChannel).mock.calls[0]?.[0]?.config as + | OpenClawConfig + | undefined; + expect(syncConfig?.plugins?.installs).toEqual(sourceConfig.plugins?.installs); + expect(syncConfig?.update?.channel).toBe("beta"); + expect(syncConfig?.gateway?.auth).toBeUndefined(); + expect(syncConfig?.plugins?.entries).toBeUndefined(); + }); + it("skips plugin sync in the old process after switching from package to git", async () => { const tempDir = createCaseDir("openclaw-update"); const completionCacheSpy = vi diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index 962513f6476..6fbe9c927bf 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -576,7 +576,7 @@ async function updatePluginsAfterCoreUpdate(params: { } const syncResult = await syncPluginsForUpdateChannel({ - config: params.configSnapshot.config, + config: params.configSnapshot.sourceConfig, channel: params.channel, workspaceDir: params.root, logger: pluginLogger, @@ -1324,9 +1324,9 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { } } else { const next = { - ...configSnapshot.config, + ...configSnapshot.sourceConfig, update: { - ...configSnapshot.config.update, + ...configSnapshot.sourceConfig.update, channel: requestedChannel, }, }; diff --git a/src/config/io.ts b/src/config/io.ts index 38a68fc8ebd..9409faf1f1b 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -1603,7 +1603,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { async function writeConfigFile( cfg: OpenClawConfig, options: ConfigWriteOptions = {}, - ): Promise<{ persistedHash: string }> { + ): Promise<{ persistedHash: string; persistedConfig: OpenClawConfig }> { clearConfigCache(); let persistCandidate: unknown = cfg; const snapshot = options.baseSnapshot ?? (await readConfigFileSnapshotInternal()).snapshot; @@ -1857,7 +1857,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { undefined, await deps.fs.promises.stat(configPath).catch(() => null), ); - return { persistedHash: nextHash }; + return { persistedHash: nextHash, persistedConfig: stampedOutputConfig }; } await deps.fs.promises.unlink(tmp).catch(() => { // best-effort @@ -1871,7 +1871,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { undefined, await deps.fs.promises.stat(configPath).catch(() => null), ); - return { persistedHash: nextHash }; + return { persistedHash: nextHash, persistedConfig: stampedOutputConfig }; } catch (err) { await appendWriteAudit("failed", err); throw err; @@ -2050,6 +2050,7 @@ export async function writeConfigFile( ) { return; } + const committedSourceConfig = writeResult.persistedConfig ?? nextCfg; const notifyCommittedWrite = () => { const currentRuntimeConfig = getRuntimeConfigSnapshotState(); if (!currentRuntimeConfig) { @@ -2057,7 +2058,7 @@ export async function writeConfigFile( } notifyRuntimeConfigWriteListeners({ configPath: io.configPath, - sourceConfig: nextCfg, + sourceConfig: committedSourceConfig, runtimeConfig: currentRuntimeConfig, persistedHash: writeResult.persistedHash, writtenAtMs: Date.now(), @@ -2066,7 +2067,7 @@ export async function writeConfigFile( // Keep the last-known-good runtime snapshot active until the specialized refresh path // succeeds, so concurrent readers do not observe unresolved SecretRefs mid-refresh. await finalizeRuntimeSnapshotWrite({ - nextSourceConfig: nextCfg, + nextSourceConfig: committedSourceConfig, hadRuntimeSnapshot, hadBothSnapshots, loadFreshConfig: () => io.loadConfig(), diff --git a/src/config/io.write-config.test.ts b/src/config/io.write-config.test.ts index 853891084da..c7dd0f3ed22 100644 --- a/src/config/io.write-config.test.ts +++ b/src/config/io.write-config.test.ts @@ -430,7 +430,7 @@ describe("config io write", () => { }, }, }), - ).resolves.toEqual({ persistedHash: expect.any(String) }); + ).resolves.toMatchObject({ persistedHash: expect.any(String) }); }); mockLoadPluginManifestRegistry.mockReturnValue({ diff --git a/src/gateway/config-reload-plan.ts b/src/gateway/config-reload-plan.ts index 10353432768..d2a7ef2f2d0 100644 --- a/src/gateway/config-reload-plan.ts +++ b/src/gateway/config-reload-plan.ts @@ -1,5 +1,6 @@ import { type ChannelId, listChannelPlugins } from "../channels/plugins/index.js"; import { getActivePluginRegistry } from "../plugins/runtime.js"; +import { isPlainObject } from "../utils.js"; export type ChannelKind = ChannelId; @@ -31,6 +32,11 @@ type ReloadAction = | "restart-health-monitor" | `restart-channel:${ChannelId}`; +export type GatewayReloadPlanOptions = { + noopPaths?: Iterable; + forceChangedPaths?: Iterable; +}; + const BASE_RELOAD_RULES: ReloadRule[] = [ { prefix: "gateway.remote", kind: "none" }, { prefix: "gateway.reload", kind: "none" }, @@ -178,7 +184,73 @@ function matchRule(path: string): ReloadRule | null { return null; } -export function buildGatewayReloadPlan(changedPaths: string[]): GatewayReloadPlan { +function isPluginInstallTimestampPath(path: string): boolean { + return /^plugins\.installs\..+\.(installedAt|resolvedAt)$/.test(path); +} + +function getPluginInstallRecords(config: unknown): Record { + if (!isPlainObject(config)) { + return {}; + } + const plugins = config.plugins; + if (!isPlainObject(plugins)) { + return {}; + } + const installs = plugins.installs; + return isPlainObject(installs) ? installs : {}; +} + +export function listPluginInstallTimestampMetadataPaths( + prevConfig: unknown, + nextConfig: unknown, +): string[] { + const prevInstalls = getPluginInstallRecords(prevConfig); + const nextInstalls = getPluginInstallRecords(nextConfig); + const ids = new Set([...Object.keys(prevInstalls), ...Object.keys(nextInstalls)]); + const paths: string[] = []; + + for (const id of ids) { + const prevRecord = prevInstalls[id]; + const nextRecord = nextInstalls[id]; + if (!isPlainObject(prevRecord) || !isPlainObject(nextRecord)) { + continue; + } + for (const key of ["installedAt", "resolvedAt"] as const) { + if (prevRecord[key] !== nextRecord[key]) { + paths.push(`plugins.installs.${id}.${key}`); + } + } + } + + return paths; +} + +export function listPluginInstallWholeRecordPaths( + prevConfig: unknown, + nextConfig: unknown, +): string[] { + const prevInstalls = getPluginInstallRecords(prevConfig); + const nextInstalls = getPluginInstallRecords(nextConfig); + const ids = new Set([...Object.keys(prevInstalls), ...Object.keys(nextInstalls)]); + const paths: string[] = []; + + for (const id of ids) { + const prevRecord = prevInstalls[id]; + const nextRecord = nextInstalls[id]; + if (!isPlainObject(prevRecord) || !isPlainObject(nextRecord)) { + paths.push(`plugins.installs.${id}`); + } + } + + return paths; +} + +export function buildGatewayReloadPlan( + changedPaths: string[], + options: GatewayReloadPlanOptions = {}, +): GatewayReloadPlan { + const noopPaths = new Set(options.noopPaths); + const forceChangedPaths = new Set(options.forceChangedPaths); const plan: GatewayReloadPlan = { changedPaths, restartGateway: false, @@ -221,6 +293,13 @@ export function buildGatewayReloadPlan(changedPaths: string[]): GatewayReloadPla }; for (const path of changedPaths) { + const isTimestampNoop = + !forceChangedPaths.has(path) && + (noopPaths.size > 0 ? noopPaths.has(path) : isPluginInstallTimestampPath(path)); + if (isTimestampNoop) { + plan.noopPaths.push(path); + continue; + } const rule = matchRule(path); if (!rule) { plan.restartGateway = true; diff --git a/src/gateway/config-reload.test.ts b/src/gateway/config-reload.test.ts index 8cd146d4a4f..73b2918e253 100644 --- a/src/gateway/config-reload.test.ts +++ b/src/gateway/config-reload.test.ts @@ -6,12 +6,18 @@ import { } from "../agents/skills/refresh-state.js"; import { listChannelPlugins } from "../channels/plugins/index.js"; import type { ChannelPlugin } from "../channels/plugins/types.js"; -import type { ConfigFileSnapshot, ConfigWriteNotification } from "../config/config.js"; +import type { + ConfigFileSnapshot, + ConfigWriteNotification, + OpenClawConfig, +} from "../config/config.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import { createTestRegistry } from "../test-utils/channel-plugins.js"; import { buildGatewayReloadPlan, diffConfigPaths, + listPluginInstallTimestampMetadataPaths, + listPluginInstallWholeRecordPaths, resolveGatewayReloadSettings, shouldInvalidateSkillsSnapshotForPaths, startGatewayConfigReloader, @@ -88,6 +94,29 @@ describe("diffConfigPaths", () => { expect(diffConfigPaths(prev, next)).toEqual(["agents.list"]); }); + + it("can emit duplicate path strings for install timestamp and dotted install id add", () => { + const prev = { + plugins: { + installs: { + lossless: { source: "npm", resolvedAt: "2026-04-22T00:00:00.000Z" }, + }, + }, + }; + const next = { + plugins: { + installs: { + lossless: { source: "npm", resolvedAt: "2026-04-22T00:01:00.000Z" }, + "lossless.resolvedAt": { source: "npm" }, + }, + }, + }; + + expect(diffConfigPaths(prev, next)).toEqual([ + "plugins.installs.lossless.resolvedAt", + "plugins.installs.lossless.resolvedAt", + ]); + }); }); describe("buildGatewayReloadPlan", () => { @@ -210,6 +239,60 @@ describe("buildGatewayReloadPlan", () => { expect(plan.noopPaths).toEqual([]); }); + it("treats plugin install timestamp-only changes as no-op", () => { + const plan = buildGatewayReloadPlan([ + "plugins.installs.lossless-claw.resolvedAt", + "plugins.installs.lossless-claw.installedAt", + ]); + expect(plan.restartGateway).toBe(false); + expect(plan.noopPaths).toEqual([ + "plugins.installs.lossless-claw.resolvedAt", + "plugins.installs.lossless-claw.installedAt", + ]); + }); + + it("keeps colliding whole-record plugin install changes as restart reasons", () => { + const plan = buildGatewayReloadPlan( + ["plugins.installs.lossless.resolvedAt", "plugins.installs.lossless.resolvedAt"], + { + noopPaths: ["plugins.installs.lossless.resolvedAt"], + forceChangedPaths: ["plugins.installs.lossless.resolvedAt"], + }, + ); + + expect(plan.restartGateway).toBe(true); + expect(plan.restartReasons).toEqual([ + "plugins.installs.lossless.resolvedAt", + "plugins.installs.lossless.resolvedAt", + ]); + expect(plan.noopPaths).toEqual([]); + }); + + it("lists plugin install metadata and whole-record paths structurally", () => { + const prev = { + plugins: { + installs: { + lossless: { source: "npm", resolvedAt: "2026-04-22T00:00:00.000Z" }, + }, + }, + }; + const next = { + plugins: { + installs: { + lossless: { source: "npm", resolvedAt: "2026-04-22T00:01:00.000Z" }, + "lossless.resolvedAt": { source: "npm" }, + }, + }, + }; + + expect(listPluginInstallTimestampMetadataPaths(prev, next)).toEqual([ + "plugins.installs.lossless.resolvedAt", + ]); + expect(listPluginInstallWholeRecordPaths(prev, next)).toEqual([ + "plugins.installs.lossless.resolvedAt", + ]); + }); + it("hot-reloads health monitor when channelHealthCheckMinutes changes", () => { const plan = buildGatewayReloadPlan(["gateway.channelHealthCheckMinutes"]); expect(plan.restartGateway).toBe(false); @@ -349,16 +432,21 @@ function createWatcherMock() { } function makeSnapshot(partial: Partial = {}): ConfigFileSnapshot { + const config = partial.config ?? {}; + const sourceConfig = (partial.sourceConfig ?? + partial.config ?? + {}) as ConfigFileSnapshot["sourceConfig"]; + const runtimeConfig = partial.runtimeConfig ?? partial.config ?? {}; return { path: "/tmp/openclaw.json", exists: true, raw: "{}", parsed: {}, - sourceConfig: {}, - resolved: {}, + sourceConfig, + resolved: sourceConfig, valid: true, - runtimeConfig: {}, - config: {}, + runtimeConfig, + config, issues: [], warnings: [], legacyIssues: [], @@ -370,6 +458,7 @@ function makeZeroDebounceHookSnapshot(hash: string): ConfigFileSnapshot { return makeSnapshot({ sourceConfig: { gateway: { reload: { debounceMs: 0 } }, + hooks: { enabled: true }, }, runtimeConfig: { gateway: { reload: { debounceMs: 0 } }, @@ -386,7 +475,7 @@ function makeZeroDebounceHookSnapshot(hash: string): ConfigFileSnapshot { function makeZeroDebounceHookWrite(persistedHash: string): ConfigWriteNotification { return { configPath: "/tmp/openclaw.json", - sourceConfig: { gateway: { reload: { debounceMs: 0 } } }, + sourceConfig: { gateway: { reload: { debounceMs: 0 } }, hooks: { enabled: true } }, runtimeConfig: { gateway: { reload: { debounceMs: 0 } }, hooks: { enabled: true }, @@ -399,6 +488,7 @@ function makeZeroDebounceHookWrite(persistedHash: string): ConfigWriteNotificati function createReloaderHarness( readSnapshot: () => Promise, options: { + initialCompareConfig?: OpenClawConfig; initialInternalWriteHash?: string | null; recoverSnapshot?: (snapshot: ConfigFileSnapshot, reason: string) => Promise; promoteSnapshot?: (snapshot: ConfigFileSnapshot, reason: string) => Promise; @@ -429,6 +519,7 @@ function createReloaderHarness( }; const reloader = startGatewayConfigReloader({ initialConfig: { gateway: { reload: { debounceMs: 0 } } }, + initialCompareConfig: options.initialCompareConfig, initialInternalWriteHash: options.initialInternalWriteHash, readSnapshot, recoverSnapshot: options.recoverSnapshot, @@ -745,6 +836,141 @@ describe("startGatewayConfigReloader", () => { await harness.reloader.stop(); }); + it("plans in-process reloads from source config and ignores runtime materialized paths", async () => { + const baseInstall = { + source: "npm" as const, + spec: "@martian-engineering/lossless-claw", + installPath: "/tmp/lossless-claw", + installedAt: "2026-04-22T00:00:00.000Z", + resolvedAt: "2026-04-22T00:00:00.000Z", + }; + const sourceConfig: OpenClawConfig = { + gateway: { reload: { debounceMs: 0 }, auth: { mode: "token" } }, + plugins: { + installs: { + "lossless-claw": baseInstall, + }, + }, + }; + const readSnapshot = vi.fn<() => Promise>().mockResolvedValueOnce( + makeSnapshot({ + sourceConfig: { + ...sourceConfig, + plugins: { + installs: { + "lossless-claw": { + ...baseInstall, + installedAt: "2026-04-22T00:01:00.000Z", + resolvedAt: "2026-04-22T00:01:00.000Z", + }, + }, + }, + }, + hash: "plugin-timestamps-1", + }), + ); + const harness = createReloaderHarness(readSnapshot, { initialCompareConfig: sourceConfig }); + + harness.emitWrite({ + configPath: "/tmp/openclaw.json", + sourceConfig: { + ...sourceConfig, + plugins: { + installs: { + "lossless-claw": { + ...baseInstall, + installedAt: "2026-04-22T00:01:00.000Z", + resolvedAt: "2026-04-22T00:01:00.000Z", + }, + }, + }, + }, + runtimeConfig: { + ...sourceConfig, + gateway: { reload: { debounceMs: 0 }, auth: { mode: "token", token: "runtime" } }, + plugins: { + ...sourceConfig.plugins, + entries: { + firecrawl: { + config: { + webFetch: { provider: "firecrawl" }, + }, + }, + }, + installs: { + "lossless-claw": { + ...baseInstall, + installedAt: "2026-04-22T00:01:00.000Z", + resolvedAt: "2026-04-22T00:01:00.000Z", + }, + }, + }, + }, + persistedHash: "plugin-timestamps-1", + writtenAtMs: Date.now(), + }); + await vi.runOnlyPendingTimersAsync(); + + expect(harness.onHotReload).not.toHaveBeenCalled(); + expect(harness.onRestart).not.toHaveBeenCalled(); + expect(harness.log.info).not.toHaveBeenCalledWith( + expect.stringContaining("gateway.auth.token"), + ); + + await harness.reloader.stop(); + }); + + it("does not suppress functional install changes that collide with timestamp paths", async () => { + const sourceConfig: OpenClawConfig = { + gateway: { reload: { debounceMs: 0 } }, + plugins: { + installs: { + lossless: { + source: "npm", + resolvedAt: "2026-04-22T00:00:00.000Z", + }, + }, + }, + }; + const nextSourceConfig: OpenClawConfig = { + gateway: { reload: { debounceMs: 0 } }, + plugins: { + installs: { + lossless: { + source: "npm", + resolvedAt: "2026-04-22T00:01:00.000Z", + }, + "lossless.resolvedAt": { + source: "npm", + }, + }, + }, + }; + const readSnapshot = vi.fn<() => Promise>().mockResolvedValueOnce( + makeSnapshot({ + sourceConfig: nextSourceConfig, + runtimeConfig: nextSourceConfig, + config: nextSourceConfig, + hash: "plugin-collision-1", + }), + ); + const harness = createReloaderHarness(readSnapshot, { initialCompareConfig: sourceConfig }); + + harness.emitWrite({ + configPath: "/tmp/openclaw.json", + sourceConfig: nextSourceConfig, + runtimeConfig: nextSourceConfig, + persistedHash: "plugin-collision-1", + writtenAtMs: Date.now(), + }); + await vi.runOnlyPendingTimersAsync(); + + expect(harness.onHotReload).not.toHaveBeenCalled(); + expect(harness.onRestart).toHaveBeenCalledTimes(1); + + await harness.reloader.stop(); + }); + it("skips in-process promotion when the persisted file hash no longer matches the write", async () => { const readSnapshot = vi.fn<() => Promise>().mockResolvedValueOnce( makeSnapshot({ diff --git a/src/gateway/config-reload.ts b/src/gateway/config-reload.ts index 3e236b6bb95..33c6295196e 100644 --- a/src/gateway/config-reload.ts +++ b/src/gateway/config-reload.ts @@ -9,9 +9,18 @@ import type { } from "../config/config.js"; import { formatConfigIssueLines } from "../config/issue-format.js"; import { isPlainObject } from "../utils.js"; -import { buildGatewayReloadPlan, type GatewayReloadPlan } from "./config-reload-plan.js"; +import { + buildGatewayReloadPlan, + listPluginInstallTimestampMetadataPaths, + listPluginInstallWholeRecordPaths, + type GatewayReloadPlan, +} from "./config-reload-plan.js"; -export { buildGatewayReloadPlan }; +export { + buildGatewayReloadPlan, + listPluginInstallTimestampMetadataPaths, + listPluginInstallWholeRecordPaths, +}; export type { ChannelKind, GatewayReloadPlan } from "./config-reload-plan.js"; export type GatewayReloadSettings = { @@ -49,6 +58,19 @@ export function shouldInvalidateSkillsSnapshotForPaths(changedPaths: string[]): return firstSkillsChangedPath(changedPaths) !== undefined; } +function isNoopReloadPlan(plan: GatewayReloadPlan): boolean { + return ( + !plan.restartGateway && + plan.hotReasons.length === 0 && + !plan.reloadHooks && + !plan.restartGmailWatcher && + !plan.restartCron && + !plan.restartHeartbeat && + !plan.restartHealthMonitor && + plan.restartChannels.size === 0 + ); +} + export function diffConfigPaths(prev: unknown, next: unknown, prefix = ""): string[] { if (prev === next) { return []; @@ -100,6 +122,7 @@ export type GatewayConfigReloader = { export function startGatewayConfigReloader(opts: { initialConfig: OpenClawConfig; + initialCompareConfig?: OpenClawConfig; initialInternalWriteHash?: string | null; readSnapshot: () => Promise; onHotReload: (plan: GatewayReloadPlan, nextConfig: OpenClawConfig) => Promise; @@ -120,6 +143,7 @@ export function startGatewayConfigReloader(opts: { watchPath: string; }): GatewayConfigReloader { let currentConfig = opts.initialConfig; + let currentCompareConfig = opts.initialCompareConfig ?? opts.initialConfig; let settings = resolveGatewayReloadSettings(currentConfig); let debounceTimer: ReturnType | null = null; let pending = false; @@ -127,7 +151,11 @@ export function startGatewayConfigReloader(opts: { let stopped = false; let restartQueued = false; let missingConfigRetries = 0; - let pendingInProcessConfig: { config: OpenClawConfig; persistedHash: string } | null = null; + let pendingInProcessConfig: { + config: OpenClawConfig; + compareConfig: OpenClawConfig; + persistedHash: string; + } | null = null; let lastAppliedWriteHash = opts.initialInternalWriteHash ?? null; const scheduleAfter = (wait: number) => { @@ -213,9 +241,18 @@ export function startGatewayConfigReloader(opts: { return nextSnapshot; }; - const applySnapshot = async (nextConfig: OpenClawConfig) => { - const changedPaths = diffConfigPaths(currentConfig, nextConfig); + const applySnapshot = async (nextConfig: OpenClawConfig, nextCompareConfig: OpenClawConfig) => { + const changedPaths = diffConfigPaths(currentCompareConfig, nextCompareConfig); + const pluginInstallTimestampNoopPaths = listPluginInstallTimestampMetadataPaths( + currentCompareConfig, + nextCompareConfig, + ); + const pluginInstallWholeRecordPaths = listPluginInstallWholeRecordPaths( + currentCompareConfig, + nextCompareConfig, + ); currentConfig = nextConfig; + currentCompareConfig = nextCompareConfig; settings = resolveGatewayReloadSettings(nextConfig); if (changedPaths.length === 0) { return; @@ -232,7 +269,13 @@ export function startGatewayConfigReloader(opts: { } opts.log.info(`config change detected; evaluating reload (${changedPaths.join(", ")})`); - const plan = buildGatewayReloadPlan(changedPaths); + const plan = buildGatewayReloadPlan(changedPaths, { + noopPaths: pluginInstallTimestampNoopPaths, + forceChangedPaths: pluginInstallWholeRecordPaths, + }); + if (isNoopReloadPlan(plan)) { + return; + } if (settings.mode === "off") { opts.log.info("config reload disabled (gateway.reload.mode=off)"); return; @@ -301,7 +344,7 @@ export function startGatewayConfigReloader(opts: { const pendingWrite = pendingInProcessConfig; pendingInProcessConfig = null; missingConfigRetries = 0; - await applySnapshot(pendingWrite.config); + await applySnapshot(pendingWrite.config, pendingWrite.compareConfig); await promoteAcceptedInProcessWrite(pendingWrite.persistedHash); return; } @@ -323,7 +366,7 @@ export function startGatewayConfigReloader(opts: { } snapshot = recoveredSnapshot; } - await applySnapshot(snapshot.config); + await applySnapshot(snapshot.config, snapshot.sourceConfig); await promoteAcceptedSnapshot(snapshot, "valid-config"); } catch (err) { opts.log.error(`config reload failed: ${String(err)}`); @@ -353,6 +396,7 @@ export function startGatewayConfigReloader(opts: { } pendingInProcessConfig = { config: event.runtimeConfig, + compareConfig: event.sourceConfig, persistedHash: event.persistedHash, }; lastAppliedWriteHash = event.persistedHash; diff --git a/src/gateway/server-reload-handlers.ts b/src/gateway/server-reload-handlers.ts index fa13459bc02..919710aa790 100644 --- a/src/gateway/server-reload-handlers.ts +++ b/src/gateway/server-reload-handlers.ts @@ -81,6 +81,7 @@ type ManagedGatewayConfigReloaderParams = Omit< > & { minimalTestGateway: boolean; initialConfig: OpenClawConfig; + initialCompareConfig?: OpenClawConfig; initialInternalWriteHash: string | null; watchPath: string; readSnapshot: typeof import("../config/config.js").readConfigFileSnapshot; @@ -316,6 +317,7 @@ export function startManagedGatewayConfigReloader(params: ManagedGatewayConfigRe return startGatewayConfigReloader({ initialConfig: params.initialConfig, + initialCompareConfig: params.initialCompareConfig, initialInternalWriteHash: params.initialInternalWriteHash, readSnapshot: params.readSnapshot, recoverSnapshot: async (snapshot, reason) => diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index f463bc2e30e..0a9fdba2aef 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -855,6 +855,7 @@ export async function startGatewayServer( runtimeState.configReloader = startManagedGatewayConfigReloader({ minimalTestGateway, initialConfig: cfgAtStart, + initialCompareConfig: startupLastGoodSnapshot.sourceConfig, initialInternalWriteHash: startupInternalWriteHash, watchPath: configSnapshot.path, readSnapshot: readConfigFileSnapshot,