diff --git a/CHANGELOG.md b/CHANGELOG.md index dbde44c58c7..740938e7446 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Gateway/reload: ignore startup config writes by persisted hash in the config reloader so generated auth tokens and seeded Control UI origins do not trigger a restart loop, while real `gateway.auth.*` edits still require restart. (#58678) Thanks @yelog - Discord/inbound media: pass Discord attachment and sticker downloads through the shared idle-timeout and worker-abort path so slow or stuck inbound media fetches stop hanging message processing. (#58593) Thanks @aquaright1 - Telegram/local Bot API: preserve media MIME types for absolute-path downloads so local audio files still trigger transcription and other MIME-based handling. (#54603) Thanks @jzakirov - Tasks/gateway: re-check the current task record before maintenance marks runs lost or prunes them, so a task heartbeat or cleanup update that lands during a sweep no longer gets overwritten by stale snapshot state. diff --git a/src/gateway/config-reload.test.ts b/src/gateway/config-reload.test.ts index adcd9dac411..12a17567728 100644 --- a/src/gateway/config-reload.test.ts +++ b/src/gateway/config-reload.test.ts @@ -199,6 +199,18 @@ describe("buildGatewayReloadPlan", () => { expect(plan.noopPaths).toContain("diagnostics.stuckSessionWarnMs"); }); + it("restarts for gateway.auth.token changes", () => { + const plan = buildGatewayReloadPlan(["gateway.auth.token"]); + expect(plan.restartGateway).toBe(true); + expect(plan.restartReasons).toContain("gateway.auth.token"); + }); + + it("restarts for gateway.auth.mode changes", () => { + const plan = buildGatewayReloadPlan(["gateway.auth.mode"]); + expect(plan.restartGateway).toBe(true); + expect(plan.restartReasons).toContain("gateway.auth.mode"); + }); + it("defaults unknown paths to restart", () => { const plan = buildGatewayReloadPlan(["unknownField"]); expect(plan.restartGateway).toBe(true); @@ -228,6 +240,11 @@ describe("buildGatewayReloadPlan", () => { expectRestartGateway: false, expectNoopPath: "gateway.remote.url", }, + { + path: "gateway.auth.token", + expectRestartGateway: true, + expectRestartReason: "gateway.auth.token", + }, { path: "unknownField", expectRestartGateway: true, @@ -304,7 +321,10 @@ function makeSnapshot(partial: Partial = {}): ConfigFileSnap }; } -function createReloaderHarness(readSnapshot: () => Promise) { +function createReloaderHarness( + readSnapshot: () => Promise, + options: { initialInternalWriteHash?: string | null } = {}, +) { const watcher = createWatcherMock(); vi.spyOn(chokidar, "watch").mockReturnValue(watcher as unknown as never); const onHotReload = vi.fn(async () => {}); @@ -325,6 +345,7 @@ function createReloaderHarness(readSnapshot: () => Promise) }; const reloader = startGatewayConfigReloader({ initialConfig: { gateway: { reload: { debounceMs: 0 } } }, + initialInternalWriteHash: options.initialInternalWriteHash, readSnapshot, subscribeToWrites, onHotReload, @@ -515,4 +536,43 @@ describe("startGatewayConfigReloader", () => { await harness.reloader.stop(); }); + + it("dedupes the first watcher reread for startup internal writes", async () => { + const readSnapshot = vi + .fn<() => Promise>() + .mockResolvedValueOnce( + makeSnapshot({ + config: { + gateway: { reload: { debounceMs: 0 }, auth: { mode: "token", token: "startup" } }, + }, + hash: "startup-internal-1", + }), + ) + .mockResolvedValueOnce( + makeSnapshot({ + config: { + gateway: { reload: { debounceMs: 0 }, port: 19001 }, + }, + hash: "external-after-startup-1", + }), + ); + const harness = createReloaderHarness(readSnapshot, { + initialInternalWriteHash: "startup-internal-1", + }); + + harness.watcher.emit("change"); + await vi.runOnlyPendingTimersAsync(); + + expect(readSnapshot).toHaveBeenCalledTimes(1); + expect(harness.onHotReload).not.toHaveBeenCalled(); + expect(harness.onRestart).not.toHaveBeenCalled(); + + harness.watcher.emit("change"); + await vi.runOnlyPendingTimersAsync(); + + expect(readSnapshot).toHaveBeenCalledTimes(2); + expect(harness.onRestart).toHaveBeenCalledTimes(1); + + await harness.reloader.stop(); + }); }); diff --git a/src/gateway/config-reload.ts b/src/gateway/config-reload.ts index 331fb6cc304..6ca2742e017 100644 --- a/src/gateway/config-reload.ts +++ b/src/gateway/config-reload.ts @@ -76,6 +76,7 @@ export type GatewayConfigReloader = { export function startGatewayConfigReloader(opts: { initialConfig: OpenClawConfig; + initialInternalWriteHash?: string | null; readSnapshot: () => Promise; onHotReload: (plan: GatewayReloadPlan, nextConfig: OpenClawConfig) => Promise; onRestart: (plan: GatewayReloadPlan, nextConfig: OpenClawConfig) => void | Promise; @@ -96,7 +97,7 @@ export function startGatewayConfigReloader(opts: { let restartQueued = false; let missingConfigRetries = 0; let pendingInProcessConfig: OpenClawConfig | null = null; - let lastAppliedWriteHash: string | null = null; + let lastAppliedWriteHash = opts.initialInternalWriteHash ?? null; const scheduleAfter = (wait: number) => { if (stopped) { diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index a20e37e794d..a4092dbc9df 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -505,6 +505,7 @@ export async function startGatewayServer( }); let cfgAtStart: OpenClawConfig; + let startupInternalWriteHash: string | null = null; const startupRuntimeConfig = applyConfigOverrides(configSnapshot.config); const authBootstrap = await prepareGatewayStartupConfig({ configSnapshot, @@ -539,11 +540,16 @@ 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. - cfgAtStart = await maybeSeedControlUiAllowedOriginsAtStartup({ + const controlUiSeed = await maybeSeedControlUiAllowedOriginsAtStartup({ config: cfgAtStart, writeConfig: writeConfigFile, log, }); + cfgAtStart = controlUiSeed.config; + if (authBootstrap.persistedGeneratedToken || controlUiSeed.persistedAllowedOriginsSeed) { + const startupSnapshot = await readConfigFileSnapshot(); + startupInternalWriteHash = startupSnapshot.hash ?? null; + } await runStartupMatrixMigration({ cfg: cfgAtStart, env: process.env, @@ -1436,6 +1442,7 @@ export async function startGatewayServer( return startGatewayConfigReloader({ initialConfig: cfgAtStart, + initialInternalWriteHash: startupInternalWriteHash, readSnapshot: readConfigFileSnapshot, subscribeToWrites: registerConfigWriteListener, onHotReload: async (plan, nextConfig) => { diff --git a/src/gateway/startup-control-ui-origins.ts b/src/gateway/startup-control-ui-origins.ts index d23f648908c..abbe9774978 100644 --- a/src/gateway/startup-control-ui-origins.ts +++ b/src/gateway/startup-control-ui-origins.ts @@ -8,20 +8,21 @@ export async function maybeSeedControlUiAllowedOriginsAtStartup(params: { config: OpenClawConfig; writeConfig: (config: OpenClawConfig) => Promise; log: { info: (msg: string) => void; warn: (msg: string) => void }; -}): Promise { +}): Promise<{ config: OpenClawConfig; persistedAllowedOriginsSeed: boolean }> { const seeded = ensureControlUiAllowedOriginsForNonLoopbackBind(params.config); if (!seeded.seededOrigins || !seeded.bind) { - return params.config; + return { config: params.config, persistedAllowedOriginsSeed: 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 seeded.config; + return { config: seeded.config, persistedAllowedOriginsSeed: false }; } function buildSeededOriginsInfoLog(origins: string[], bind: GatewayNonLoopbackBindMode): string {