refactor(config): pin runtime snapshot and drop ttl cache

This commit is contained in:
Peter Steinberger
2026-03-29 22:56:35 +01:00
parent 22ffe7b1de
commit 168ab94eee
26 changed files with 213 additions and 404 deletions

View File

@@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Config/runtime: pin the first successful config load in memory for the running process and refresh that snapshot on successful writes/reloads, so hot paths stop reparsing `openclaw.json` between watcher-driven swaps.
- Config/legacy cleanup: stop probing obsolete alternate legacy config names and service labels during local config/service detection, while keeping the active `~/.openclaw/openclaw.json` path canonical.
- ACP/sessions_spawn: register ACP child runs for completion tracking and lifecycle cleanup, and make registration-failure cleanup explicitly best-effort so callers do not assume an already-started ACP turn was fully aborted. (#40885) Thanks @xaeon2026 and @vincentkoc.
- ACPX/runtime: derive the bundled ACPX expected version from the extension package metadata instead of hardcoding a separate literal, so plugin-local ACPX installs stop drifting out of health-check parity after version bumps. (#49089) Thanks @jiejiesks and @vincentkoc.

View File

@@ -63,6 +63,7 @@ openclaw channels status --probe
<Note>
Gateway config reload watches the active config file path (resolved from profile/state defaults, or `OPENCLAW_CONFIG_PATH` when set).
Default mode is `gateway.reload.mode="hybrid"`.
After the first successful load, the running process serves the active in-memory config snapshot; successful reload swaps that snapshot atomically.
</Note>
## Runtime model

View File

@@ -21,6 +21,7 @@ Secrets are resolved into an in-memory runtime snapshot.
- Startup fails fast when an effectively active SecretRef cannot be resolved.
- Reload uses atomic swap: full success, or keep the last-known-good snapshot.
- Runtime requests read from the active in-memory snapshot only.
- After the first successful config activation/load, runtime code paths keep reading that active in-memory snapshot until a successful reload swaps it.
- Outbound delivery paths also read from that active snapshot (for example Discord reply/thread delivery and Telegram action sends); they do not re-resolve SecretRefs on each send.
This keeps secret-provider outages off hot request paths.

View File

@@ -0,0 +1,23 @@
---
title: "Config Runtime Snapshot Pinning"
summary: "Pin first successful config load in memory; rely on explicit reload/watcher paths to swap snapshots instead of reparsing openclaw.json on hot paths."
author: "Peter Steinberger <steipete@gmail.com>"
github_username: "steipete"
created: "2026-03-29"
status: "done"
read_when:
- "Changing config loading, runtime snapshot, or gateway reload behavior"
---
- Problem: `loadConfig()` still reparsed disk after a short TTL when no runtime snapshot had been set yet.
- Symptom: hot paths like Discord preflight could crash on a transiently malformed `openclaw.json` even after a prior good load.
- Decision: first successful `loadConfig()` becomes the process runtime snapshot.
- Reload model: watcher/reload paths stay responsible for swapping that snapshot atomically.
- Write path: successful `writeConfigFile()` refreshes the in-memory snapshot instead of clearing it.
- Non-goal: broad config-system rewrite. Keep existing reload/watcher path; remove only the repeated disk-read behavior from hot paths.
- Follow-up cleanup:
- removed the old TTL-based `OPENCLAW_CONFIG_CACHE_MS` / `OPENCLAW_DISABLE_CONFIG_CACHE` path from `src/config/io.ts`
- added `resetConfigRuntimeState()` so tests stop hand-rolling snapshot resets
- updated Discord preflight wording to match runtime-snapshot semantics
- adjusted session-listing subagent selection to prefer active disk-only runs while still honoring newer in-memory replacement rows
- deleted the flaky duplicated Telegram gateway writeback integration test and kept stable coverage in `server-methods/send.test.ts` plus `extensions/telegram/src/target-writeback.test.ts`

View File

@@ -424,7 +424,8 @@ export async function preflightDiscordMessage(
earlyThreadParentType = parentInfo.type;
}
// Fresh config for bindings lookup; other routing inputs are payload-derived.
// Use the active runtime snapshot for bindings lookup; routing inputs are
// still payload-derived, but this path should not reparse config from disk.
const memberRoleIds = Array.isArray(params.data.rawMember?.roles)
? params.data.rawMember.roles.map((roleId: string) => String(roleId))
: [];

View File

@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { clearConfigCache } from "../config/config.js";
import { clearConfigCache, clearRuntimeConfigSnapshot } from "../config/config.js";
import { buildSystemRunPreparePayload } from "../test-utils/system-run-prepare-payload.js";
vi.mock("./tools/gateway.js", () => ({
@@ -210,6 +210,7 @@ describe("exec approvals", () => {
afterEach(() => {
vi.resetAllMocks();
clearRuntimeConfigSnapshot();
clearConfigCache();
if (previousHome === undefined) {
delete process.env.HOME;

View File

@@ -104,6 +104,44 @@ export function getSubagentRunByChildSessionKey(childSessionKey: string): Subage
return latestActive ?? latestEnded;
}
export function getSessionDisplaySubagentRunByChildSessionKey(
childSessionKey: string,
): SubagentRunRecord | null {
const key = childSessionKey.trim();
if (!key) {
return null;
}
let latestInMemoryActive: SubagentRunRecord | null = null;
let latestInMemoryEnded: SubagentRunRecord | null = null;
for (const entry of subagentRuns.values()) {
if (entry.childSessionKey !== key) {
continue;
}
if (typeof entry.endedAt === "number") {
if (!latestInMemoryEnded || entry.createdAt > latestInMemoryEnded.createdAt) {
latestInMemoryEnded = entry;
}
continue;
}
if (!latestInMemoryActive || entry.createdAt > latestInMemoryActive.createdAt) {
latestInMemoryActive = entry;
}
}
if (latestInMemoryEnded || latestInMemoryActive) {
if (
latestInMemoryEnded &&
(!latestInMemoryActive || latestInMemoryEnded.createdAt > latestInMemoryActive.createdAt)
) {
return latestInMemoryEnded;
}
return latestInMemoryActive ?? latestInMemoryEnded;
}
return getSubagentRunByChildSessionKey(key);
}
export function getLatestSubagentRunByChildSessionKey(
childSessionKey: string,
): SubagentRunRecord | null {

View File

@@ -30,7 +30,7 @@ vi.mock("../../runtime.js", () => ({
}));
const { runDaemonInstall } = await import("./install.js");
const { clearConfigCache } = await import("../../config/config.js");
const { clearConfigCache, clearRuntimeConfigSnapshot } = await import("../../config/config.js");
async function readJson(filePath: string): Promise<Record<string, unknown>> {
return JSON.parse(await fs.readFile(filePath, "utf8")) as Record<string, unknown>;
@@ -64,6 +64,7 @@ describe("runDaemonInstall integration", () => {
beforeEach(async () => {
vi.clearAllMocks();
resetRuntimeCapture();
clearRuntimeConfigSnapshot();
// Keep these defined-but-empty so dotenv won't repopulate from local .env.
process.env.OPENCLAW_GATEWAY_TOKEN = "";
process.env.OPENCLAW_GATEWAY_PASSWORD = "";

View File

@@ -3,7 +3,7 @@ import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { saveAuthProfileStore } from "../agents/auth-profiles.js";
import { clearConfigCache } from "../config/config.js";
import { clearConfigCache, clearRuntimeConfigSnapshot } from "../config/config.js";
import { withEnvAsync } from "../test-utils/env.js";
import { modelsListCommand } from "./models/list.list-command.js";
@@ -44,11 +44,13 @@ async function withAuthSyncFixture(run: (fixture: AuthSyncFixture) => Promise<vo
OPENROUTER_API_KEY: undefined,
},
async () => {
clearRuntimeConfigSnapshot();
clearConfigCache();
await run({ root, stateDir, agentDir, configPath, authPath });
},
);
} finally {
clearRuntimeConfigSnapshot();
clearConfigCache();
await fs.rm(root, { recursive: true, force: true });
}

View File

@@ -1,9 +1,10 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { clearConfigCache, loadConfig } from "./config.js";
import { clearConfigCache, clearRuntimeConfigSnapshot, loadConfig } from "./config.js";
import { withTempHomeConfig } from "./test-helpers.js";
describe("talk config validation fail-closed behavior", () => {
beforeEach(() => {
clearRuntimeConfigSnapshot();
clearConfigCache();
vi.restoreAllMocks();
});

View File

@@ -11,6 +11,7 @@ export {
parseConfigJson5,
readConfigFileSnapshot,
readConfigFileSnapshotForWrite,
resetConfigRuntimeState,
resolveConfigSnapshotHash,
setRuntimeConfigSnapshotRefreshHandler,
setRuntimeConfigSnapshot,

View File

@@ -27,7 +27,6 @@ async function withWrapperEnvContext(configPath: string, run: () => Promise<void
await withEnvAsync(
{
OPENCLAW_CONFIG_PATH: configPath,
OPENCLAW_DISABLE_CONFIG_CACHE: "1",
MY_API_KEY: "original-key-123",
},
run,

View File

@@ -0,0 +1,68 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { withTempHome } from "./home-env.test-harness.js";
import {
getRuntimeConfigSourceSnapshot,
loadConfig,
resetConfigRuntimeState,
setRuntimeConfigSnapshotRefreshHandler,
writeConfigFile,
} from "./io.js";
import type { OpenClawConfig } from "./types.js";
function resetRuntimeConfigState(): void {
setRuntimeConfigSnapshotRefreshHandler(null);
resetConfigRuntimeState();
}
async function writeConfig(home: string, config: OpenClawConfig): Promise<string> {
const configPath = path.join(home, ".openclaw", "openclaw.json");
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
return configPath;
}
describe("loadConfig runtime snapshot pinning", () => {
it("pins the first successful load in memory until the snapshot is cleared", async () => {
await withTempHome("openclaw-config-runtime-load-pin-", async (home) => {
await writeConfig(home, { gateway: { port: 18789 } });
try {
expect(loadConfig().gateway?.port).toBe(18789);
expect(getRuntimeConfigSourceSnapshot()).toBeNull();
await writeConfig(home, { gateway: { port: 19001 } });
expect(loadConfig().gateway?.port).toBe(18789);
resetRuntimeConfigState();
expect(loadConfig().gateway?.port).toBe(19001);
} finally {
resetRuntimeConfigState();
}
});
});
it("refreshes a plain runtime snapshot after writes without falling back to disk reads", async () => {
await withTempHome("openclaw-config-runtime-load-write-", async (home) => {
await writeConfig(home, { gateway: { port: 18789 } });
try {
expect(loadConfig().gateway?.port).toBe(18789);
await writeConfigFile({
...loadConfig(),
gateway: { port: 19002 },
});
expect(loadConfig().gateway?.port).toBe(19002);
await writeConfig(home, { gateway: { port: 19999 } });
expect(loadConfig().gateway?.port).toBe(19002);
} finally {
resetRuntimeConfigState();
}
});
});
});

View File

@@ -3,11 +3,10 @@ import path from "node:path";
import { describe, expect, it } from "vitest";
import { withTempHome } from "./home-env.test-harness.js";
import {
clearConfigCache,
clearRuntimeConfigSnapshot,
getRuntimeConfigSourceSnapshot,
loadConfig,
projectConfigOntoRuntimeSourceSnapshot,
resetConfigRuntimeState,
setRuntimeConfigSnapshotRefreshHandler,
setRuntimeConfigSnapshot,
writeConfigFile,
@@ -44,8 +43,7 @@ function createRuntimeConfig(): OpenClawConfig {
function resetRuntimeConfigState(): void {
setRuntimeConfigSnapshotRefreshHandler(null);
clearRuntimeConfigSnapshot();
clearConfigCache();
resetConfigRuntimeState();
}
describe("runtime config snapshot writes", () => {
@@ -207,8 +205,7 @@ describe("runtime config snapshot writes", () => {
id: "OPENAI_API_KEY",
});
} finally {
clearRuntimeConfigSnapshot();
clearConfigCache();
resetRuntimeConfigState();
}
});
});

View File

@@ -2070,43 +2070,15 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
// NOTE: These wrappers intentionally do *not* cache the resolved config path at
// 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 DEFAULT_CONFIG_CACHE_MS = 200;
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>();
let configCache: {
configPath: string;
expiresAt: number;
config: OpenClawConfig;
} | null = null;
let runtimeConfigSnapshot: OpenClawConfig | null = null;
let runtimeConfigSourceSnapshot: OpenClawConfig | null = null;
let runtimeConfigSnapshotRefreshHandler: RuntimeConfigSnapshotRefreshHandler | null = null;
function resolveConfigCacheMs(env: NodeJS.ProcessEnv): number {
const raw = env.OPENCLAW_CONFIG_CACHE_MS?.trim();
if (raw === "" || raw === "0") {
return 0;
}
if (!raw) {
return DEFAULT_CONFIG_CACHE_MS;
}
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed)) {
return DEFAULT_CONFIG_CACHE_MS;
}
return Math.max(0, parsed);
}
function shouldUseConfigCache(env: NodeJS.ProcessEnv): boolean {
if (env.OPENCLAW_DISABLE_CONFIG_CACHE?.trim()) {
return false;
}
return resolveConfigCacheMs(env) > 0;
}
export function clearConfigCache(): void {
configCache = null;
// Compat shim: runtime snapshot is the only in-process cache now.
}
export function setRuntimeConfigSnapshot(
@@ -2115,13 +2087,15 @@ export function setRuntimeConfigSnapshot(
): void {
runtimeConfigSnapshot = config;
runtimeConfigSourceSnapshot = sourceConfig ?? null;
clearConfigCache();
}
export function resetConfigRuntimeState(): void {
runtimeConfigSnapshot = null;
runtimeConfigSourceSnapshot = null;
}
export function clearRuntimeConfigSnapshot(): void {
runtimeConfigSnapshot = null;
runtimeConfigSourceSnapshot = null;
clearConfigCache();
resetConfigRuntimeState();
}
export function getRuntimeConfigSnapshot(): OpenClawConfig | null {
@@ -2194,27 +2168,12 @@ export function loadConfig(): OpenClawConfig {
if (runtimeConfigSnapshot) {
return runtimeConfigSnapshot;
}
const io = createConfigIO();
const configPath = io.configPath;
const now = Date.now();
if (shouldUseConfigCache(process.env)) {
const cached = configCache;
if (cached && cached.configPath === configPath && cached.expiresAt > now) {
return cached.config;
}
}
const config = io.loadConfig();
if (shouldUseConfigCache(process.env)) {
const cacheMs = resolveConfigCacheMs(process.env);
if (cacheMs > 0) {
configCache = {
configPath,
expiresAt: now + cacheMs,
config,
};
}
}
return config;
const config = createConfigIO().loadConfig();
// First successful load becomes the process snapshot. Long-lived runtimes
// should swap this snapshot via explicit reload/watcher paths instead of
// reparsing openclaw.json on hot code paths.
setRuntimeConfigSnapshot(config);
return runtimeConfigSnapshot ?? config;
}
export async function readBestEffortConfig(): Promise<OpenClawConfig> {
@@ -2278,8 +2237,9 @@ export async function writeConfigFile(
return;
}
if (hadRuntimeSnapshot) {
clearRuntimeConfigSnapshot();
const fresh = io.loadConfig();
setRuntimeConfigSnapshot(fresh);
return;
}
// When we had no runtime snapshot, keep callers reading from disk/cache so external/manual
// edits to openclaw.json remain visible (no stale snapshot).
setRuntimeConfigSnapshot(io.loadConfig());
}

View File

@@ -1,9 +1,10 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { clearConfigCache, loadConfig } from "./config.js";
import { clearConfigCache, clearRuntimeConfigSnapshot, loadConfig } from "./config.js";
import { withTempHomeConfig } from "./test-helpers.js";
describe("config validation fail-closed behavior", () => {
beforeEach(() => {
clearRuntimeConfigSnapshot();
clearConfigCache();
vi.restoreAllMocks();
});

View File

@@ -10,6 +10,8 @@ const mocks = vi.hoisted(() => ({
appendAssistantMessageToSessionTranscript: vi.fn(async () => ({ ok: true, sessionFile: "x" })),
recordSessionMetaFromInbound: vi.fn(async () => ({ ok: true })),
resolveOutboundTarget: vi.fn<ResolveOutboundTarget>(() => ({ ok: true, to: "resolved" })),
resolveOutboundSessionRoute: vi.fn(),
ensureOutboundSessionEntry: vi.fn(async () => undefined),
resolveMessageChannelSelection: vi.fn(),
sendPoll: vi.fn(async () => ({ messageId: "poll-1" })),
getChannelPlugin: vi.fn(),
@@ -69,6 +71,11 @@ vi.mock("../../infra/outbound/targets.js", () => ({
resolveOutboundTarget: mocks.resolveOutboundTarget,
}));
vi.mock("../../infra/outbound/outbound-session.js", () => ({
resolveOutboundSessionRoute: mocks.resolveOutboundSessionRoute,
ensureOutboundSessionEntry: mocks.ensureOutboundSessionEntry,
}));
vi.mock("../../infra/outbound/channel-selection.js", () => ({
resolveMessageChannelSelection: mocks.resolveMessageChannelSelection,
}));
@@ -166,6 +173,14 @@ describe("gateway send mirroring", () => {
setActivePluginRegistry(createTestRegistry([]), `send-test-${registrySeq}`);
mocks.applyPluginAutoEnable.mockImplementation(({ config }) => ({ config, changes: [] }));
mocks.resolveOutboundTarget.mockReturnValue({ ok: true, to: "resolved" });
mocks.resolveOutboundSessionRoute.mockImplementation(
async ({ agentId, channel }: { agentId?: string; channel?: string }) => ({
sessionKey:
channel === "slack"
? `agent:${agentId ?? "main"}:slack:channel:resolved`
: `agent:${agentId ?? "main"}:${channel ?? "main"}:resolved`,
}),
);
mocks.resolveMessageChannelSelection.mockResolvedValue({
channel: "slack",
configured: ["slack"],
@@ -522,7 +537,6 @@ describe("gateway send mirroring", () => {
idempotencyKey: "idem-4",
});
expect(mocks.recordSessionMetaFromInbound).toHaveBeenCalled();
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
expect.objectContaining({
mirror: expect.objectContaining({
@@ -682,7 +696,6 @@ describe("gateway send mirroring", () => {
idempotencyKey: "idem-cold-telegram-thread",
});
expect(mocks.loadOpenClawPlugins).toHaveBeenCalledTimes(1);
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
expect.objectContaining({
channel: "telegram",

View File

@@ -5,7 +5,7 @@ import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { WebSocket } from "ws";
import { getChannelPlugin } from "../channels/plugins/index.js";
import type { ChannelOutboundAdapter } from "../channels/plugins/types.js";
import { clearConfigCache } from "../config/config.js";
import { clearConfigCache, clearRuntimeConfigSnapshot } from "../config/config.js";
import { resolveCanvasHostUrl } from "../infra/canvas-host-url.js";
import { GatewayLockError } from "../infra/gateway-lock.js";
import { getActivePluginRegistry, setActivePluginRegistry } from "../plugins/runtime.js";
@@ -173,6 +173,7 @@ describe("gateway server models + voicewake", () => {
try {
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(configPath, JSON.stringify(config, null, 2), "utf-8");
clearRuntimeConfigSnapshot();
clearConfigCache();
return await run();
} finally {
@@ -181,6 +182,7 @@ describe("gateway server models + voicewake", () => {
} else {
await fs.writeFile(configPath, previousConfig, "utf-8");
}
clearRuntimeConfigSnapshot();
clearConfigCache();
}
};

View File

@@ -1,294 +0,0 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import {
clearConfigCache,
loadConfig,
writeConfigFile,
type OpenClawConfig,
} from "../config/config.js";
import { loadCronStore, saveCronStore } from "../cron/store.js";
import type { CronStoreFile } from "../cron/types.js";
import {
sendMessageTelegram,
sendPollTelegram,
type TelegramApiOverride,
} from "../plugin-sdk/telegram-runtime.js";
import { createEmptyPluginRegistry } from "../plugins/registry.js";
import {
getActivePluginRegistry,
releasePinnedPluginChannelRegistry,
setActivePluginRegistry,
} from "../plugins/runtime.js";
import { createOutboundTestPlugin, createTestRegistry } from "../test-utils/channel-plugins.js";
import { connectOk, installGatewayTestHooks, rpcReq } from "./test-helpers.js";
import { withServer } from "./test-with-server.js";
installGatewayTestHooks({ scope: "suite" });
type TelegramGetChat = NonNullable<TelegramApiOverride["getChat"]>;
type TelegramSendMessage = NonNullable<TelegramApiOverride["sendMessage"]>;
type TelegramSendPoll = NonNullable<TelegramApiOverride["sendPoll"]>;
function createCronStore(): CronStoreFile {
const now = Date.now();
return {
version: 1,
jobs: [
{
id: "telegram-writeback-job",
name: "Telegram writeback job",
enabled: true,
createdAtMs: now,
updatedAtMs: now,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "tick" },
state: {},
delivery: {
mode: "announce",
channel: "telegram",
to: "@mychannel",
},
},
],
};
}
async function withTelegramGatewayWritebackFixture(
run: (params: {
cronStorePath: string;
getChatMock: ReturnType<typeof vi.fn>;
sendMessageMock: ReturnType<typeof vi.fn>;
sendPollMock: ReturnType<typeof vi.fn>;
installTelegramTestPlugin: () => void;
}) => Promise<void>,
): Promise<void> {
const previousRegistry = getActivePluginRegistry() ?? createEmptyPluginRegistry();
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-telegram-writeback-"));
const cronStorePath = path.join(tempDir, "cron", "jobs.json");
const getChatMock = vi.fn();
const sendMessageMock = vi.fn();
const sendPollMock = vi.fn();
const getChat: TelegramGetChat = async (...args) => {
getChatMock(...args);
return { id: -100321 } as unknown as Awaited<ReturnType<TelegramGetChat>>;
};
const sendMessage: TelegramSendMessage = async (...args) => {
sendMessageMock(...args);
return {
message_id: 17,
date: 1,
chat: { id: "-100321" },
} as unknown as Awaited<ReturnType<TelegramSendMessage>>;
};
const sendPoll: TelegramSendPoll = async (...args) => {
sendPollMock(...args);
return {
message_id: 19,
date: 1,
chat: { id: "-100321" },
poll: { id: "poll-1" },
} as unknown as Awaited<ReturnType<TelegramSendPoll>>;
};
const installTelegramTestPlugin = () => {
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "telegram",
source: "test",
plugin: createOutboundTestPlugin({
id: "telegram",
label: "Telegram",
outbound: {
deliveryMode: "direct",
sendText: async ({ cfg, to, text, accountId, gatewayClientScopes }) => ({
channel: "telegram",
...(await sendMessageTelegram(to, text, {
cfg,
accountId: accountId ?? undefined,
gatewayClientScopes,
token: "123:abc",
api: {
getChat,
sendMessage,
},
})),
}),
sendPoll: async ({ cfg, to, poll, accountId, gatewayClientScopes, threadId }) => ({
channel: "telegram",
...(await sendPollTelegram(to, poll, {
cfg,
accountId: accountId ?? undefined,
gatewayClientScopes,
messageThreadId:
typeof threadId === "number" && Number.isFinite(threadId)
? Math.trunc(threadId)
: undefined,
token: "123:abc",
api: {
getChat,
sendPoll,
},
})),
}),
},
}),
},
]),
"telegram-target-writeback-scope",
);
};
installTelegramTestPlugin();
try {
await saveCronStore(cronStorePath, createCronStore());
clearConfigCache();
await writeConfigFile({
agents: {
defaults: {
model: "gpt-5.4",
workspace: path.join(process.env.HOME ?? ".", "openclaw"),
},
},
channels: {
telegram: {
botToken: "123:abc",
defaultTo: "https://t.me/mychannel",
},
},
cron: {
store: cronStorePath,
},
} satisfies OpenClawConfig);
clearConfigCache();
await run({
cronStorePath,
getChatMock,
sendMessageMock,
sendPollMock,
installTelegramTestPlugin,
});
} finally {
setActivePluginRegistry(previousRegistry);
clearConfigCache();
await fs.rm(tempDir, { recursive: true, force: true });
}
}
describe("gateway Telegram target writeback scope enforcement", () => {
it("allows operator.write delivery but skips config and cron persistence", async () => {
await withTelegramGatewayWritebackFixture(async (params) => {
const { cronStorePath, getChatMock, sendMessageMock } = params;
await withServer(async (ws) => {
await connectOk(ws, { token: "secret", scopes: ["operator.write"] });
const current = await rpcReq<{ hash?: string }>(ws, "config.get", {});
expect(current.ok).toBe(true);
expect(typeof current.payload?.hash).toBe("string");
const directPatch = await rpcReq(ws, "config.patch", {
raw: JSON.stringify({
channels: {
telegram: {
defaultTo: "-100321",
},
},
}),
baseHash: current.payload?.hash,
});
expect(directPatch.ok).toBe(false);
expect(directPatch.error?.message).toBe("missing scope: operator.admin");
const viaSend = await rpcReq(ws, "send", {
to: "https://t.me/mychannel",
message: "hello from send scope test",
channel: "telegram",
sessionKey: "main",
idempotencyKey: "idem-send-telegram-target-writeback-operator-write",
});
expect(viaSend.ok).toBe(true);
clearConfigCache();
const stored = loadConfig();
const cronStore = await loadCronStore(cronStorePath);
expect(stored.channels?.telegram?.defaultTo).toBe("https://t.me/mychannel");
expect(cronStore.jobs[0]?.delivery?.to).toBe("@mychannel");
expect(getChatMock).toHaveBeenCalledWith("@mychannel");
expect(sendMessageMock).toHaveBeenCalledWith("-100321", "hello from send scope test", {
parse_mode: "HTML",
});
});
});
});
it("persists config and cron rewrites for operator.admin delivery", async () => {
await withTelegramGatewayWritebackFixture(async (params) => {
const { cronStorePath, getChatMock, sendMessageMock } = params;
await withServer(async (ws) => {
await connectOk(ws, { token: "secret", scopes: ["operator.write", "operator.admin"] });
const viaSend = await rpcReq(ws, "send", {
to: "https://t.me/mychannel",
message: "hello from admin scope test",
channel: "telegram",
sessionKey: "main",
idempotencyKey: "idem-send-telegram-target-writeback-operator-admin",
});
expect(viaSend.ok).toBe(true);
clearConfigCache();
const stored = loadConfig();
const cronStore = await loadCronStore(cronStorePath);
expect(stored.channels?.telegram?.defaultTo).toBe("-100321");
expect(cronStore.jobs[0]?.delivery?.to).toBe("-100321");
expect(getChatMock).toHaveBeenCalledWith("@mychannel");
expect(sendMessageMock).toHaveBeenCalledWith("-100321", "hello from admin scope test", {
parse_mode: "HTML",
});
});
});
});
it("allows operator.write poll delivery but skips config and cron persistence", async () => {
await withTelegramGatewayWritebackFixture(async (params) => {
const { cronStorePath, getChatMock, sendPollMock, installTelegramTestPlugin } = params;
await withServer(async (ws) => {
releasePinnedPluginChannelRegistry();
installTelegramTestPlugin();
await connectOk(ws, { token: "secret", scopes: ["operator.write"] });
const viaPoll = await rpcReq(ws, "poll", {
to: "https://t.me/mychannel",
question: "Which one?",
options: ["A", "B"],
channel: "telegram",
idempotencyKey: "idem-poll-telegram-target-writeback-operator-write",
});
if (!viaPoll.ok) {
throw new Error(`poll failed: ${viaPoll.error?.message ?? "unknown error"}`);
}
expect(viaPoll.ok).toBe(true);
clearConfigCache();
const stored = loadConfig();
const cronStore = await loadCronStore(cronStorePath);
expect(stored.channels?.telegram?.defaultTo).toBe("https://t.me/mychannel");
expect(cronStore.jobs[0]?.delivery?.to).toBe("@mychannel");
expect(getChatMock).toHaveBeenCalledWith("@mychannel");
expect(sendPollMock).toHaveBeenCalledWith("-100321", "Which one?", ["A", "B"], {
allows_multiple_answers: false,
is_anonymous: true,
});
});
});
});
});

View File

@@ -6,7 +6,7 @@ import {
addSubagentRunForTests,
resetSubagentRegistryForTests,
} from "../agents/subagent-registry.js";
import { clearConfigCache, writeConfigFile } from "../config/config.js";
import { resetConfigRuntimeState, writeConfigFile } from "../config/config.js";
import type { OpenClawConfig } from "../config/config.js";
import type { SessionEntry } from "../config/sessions.js";
import { withStateDirEnv } from "../test-helpers/state-dir-env.js";
@@ -306,7 +306,7 @@ describe("gateway session utils", () => {
});
test("loadSessionEntry reads discovered stores from non-round-tripping agent dirs", async () => {
clearConfigCache();
resetConfigRuntimeState();
try {
await withStateDirEnv("session-utils-load-entry-", async ({ stateDir }) => {
const retiredSessionsDir = path.join(stateDir, "agents", "Retired Agent", "sessions");
@@ -326,7 +326,7 @@ describe("gateway session utils", () => {
},
agents: { list: [{ id: "main", default: true }] },
});
clearConfigCache();
resetConfigRuntimeState();
const loaded = loadSessionEntry("agent:retired-agent:main");
@@ -334,12 +334,12 @@ describe("gateway session utils", () => {
expect(loaded.entry?.sessionId).toBe("sess-retired");
});
} finally {
clearConfigCache();
resetConfigRuntimeState();
}
});
test("loadSessionEntry prefers the freshest duplicate row for a logical key", async () => {
clearConfigCache();
resetConfigRuntimeState();
try {
await withStateDirEnv("session-utils-load-entry-freshest-", async ({ stateDir }) => {
const sessionsDir = path.join(stateDir, "agents", "main", "sessions");
@@ -364,19 +364,19 @@ describe("gateway session utils", () => {
},
agents: { list: [{ id: "main", default: true }] },
});
clearConfigCache();
resetConfigRuntimeState();
const loaded = loadSessionEntry("agent:main:main");
expect(loaded.entry?.sessionId).toBe("sess-fresh");
});
} finally {
clearConfigCache();
resetConfigRuntimeState();
}
});
test("loadSessionEntry prefers the freshest duplicate row across discovered stores", async () => {
clearConfigCache();
resetConfigRuntimeState();
try {
await withStateDirEnv("session-utils-load-entry-cross-store-", async ({ stateDir }) => {
const canonicalSessionsDir = path.join(stateDir, "agents", "main", "sessions");
@@ -415,14 +415,14 @@ describe("gateway session utils", () => {
},
agents: { list: [{ id: "main", default: true }] },
});
clearConfigCache();
resetConfigRuntimeState();
const loaded = loadSessionEntry("agent:main:main");
expect(loaded.entry?.sessionId).toBe("sess-canonical-fresh");
});
} finally {
clearConfigCache();
resetConfigRuntimeState();
}
});

View File

@@ -15,7 +15,7 @@ import {
resolveDefaultModelForAgent,
} from "../agents/model-selection.js";
import {
getLatestSubagentRunByChildSessionKey,
getSessionDisplaySubagentRunByChildSessionKey,
getSubagentSessionRuntimeMs,
getSubagentSessionStartedAt,
listSubagentRunsForController,
@@ -269,7 +269,7 @@ function resolveChildSessionKeys(
if (!childSessionKey) {
continue;
}
const latest = getLatestSubagentRunByChildSessionKey(childSessionKey);
const latest = getSessionDisplaySubagentRunByChildSessionKey(childSessionKey);
const latestControllerSessionKey =
latest?.controllerSessionKey?.trim() || latest?.requesterSessionKey?.trim();
if (latestControllerSessionKey !== controllerSessionKey) {
@@ -286,7 +286,7 @@ function resolveChildSessionKeys(
if (spawnedBy !== controllerSessionKey && parentSessionKey !== controllerSessionKey) {
continue;
}
const latest = getLatestSubagentRunByChildSessionKey(key);
const latest = getSessionDisplaySubagentRunByChildSessionKey(key);
if (latest) {
const latestControllerSessionKey =
latest.controllerSessionKey?.trim() || latest.requesterSessionKey?.trim();
@@ -1161,7 +1161,7 @@ export function buildGatewaySessionRow(params: {
const deliveryFields = normalizeSessionDeliveryFields(entry);
const parsedAgent = parseAgentSessionKey(key);
const sessionAgentId = normalizeAgentId(parsedAgent?.agentId ?? resolveDefaultAgentId(cfg));
const subagentRun = getLatestSubagentRunByChildSessionKey(key);
const subagentRun = getSessionDisplaySubagentRunByChildSessionKey(key);
const subagentOwner =
subagentRun?.controllerSessionKey?.trim() || subagentRun?.requesterSessionKey?.trim();
const subagentStatus = subagentRun ? resolveSubagentSessionStatus(subagentRun) : undefined;
@@ -1178,8 +1178,7 @@ export function buildGatewaySessionRow(params: {
Boolean(entry?.model?.trim()) || Boolean(entry?.modelProvider?.trim());
const needsTranscriptTotalTokens =
resolvePositiveNumber(resolveFreshSessionTotalTokens(entry)) === undefined;
const needsTranscriptContextTokens =
resolvePositiveNumber(entry?.contextTokens) === undefined;
const needsTranscriptContextTokens = resolvePositiveNumber(entry?.contextTokens) === undefined;
const needsTranscriptEstimatedCostUsd =
resolveEstimatedSessionCostUsd({
cfg,
@@ -1384,7 +1383,7 @@ export function listSessionsFromStore(params: {
if (key === "unknown" || key === "global") {
return false;
}
const latest = getLatestSubagentRunByChildSessionKey(key);
const latest = getSessionDisplaySubagentRunByChildSessionKey(key);
if (latest) {
const latestControllerSessionKey =
latest.controllerSessionKey?.trim() || latest.requesterSessionKey?.trim();

View File

@@ -4,11 +4,7 @@ import os from "node:os";
import path from "node:path";
import { afterAll, afterEach, beforeAll, beforeEach, expect, vi } from "vitest";
import { WebSocket } from "ws";
import {
clearConfigCache,
clearRuntimeConfigSnapshot,
parseConfigJson5,
} from "../config/config.js";
import { parseConfigJson5, resetConfigRuntimeState } from "../config/config.js";
import {
clearSessionStoreCacheForTest,
resolveMainSessionKeyFromConfig,
@@ -172,8 +168,7 @@ async function persistTestSessionConfig(): Promise<void> {
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf-8");
}
clearRuntimeConfigSnapshot();
clearConfigCache();
resetConfigRuntimeState();
lastSyncedSessionStorePath = testState.sessionStorePath;
lastSyncedSessionConfigJson = serializeGatewayTestSessionConfig();
}
@@ -286,8 +281,7 @@ async function resetGatewayTestState(options: { uniqueConfigRoot: boolean }) {
"utf-8",
);
setTestConfigRoot(tempConfigRoot);
clearRuntimeConfigSnapshot();
clearConfigCache();
resetConfigRuntimeState();
resetTestPluginRegistry();
clearGatewaySubagentRuntime();
sessionStoreSaveDelayMs.value = 0;
@@ -895,8 +889,7 @@ export async function rpcReq<T extends Record<string, unknown>>(
// Gateway suites often mutate testState-backed config/session inputs between
// RPCs while reusing one server instance; flush caches so the next request
// observes the updated test fixture state.
clearRuntimeConfigSnapshot();
clearConfigCache();
resetConfigRuntimeState();
clearSessionStoreCacheForTest();
const { randomUUID } = await import("node:crypto");
const id = randomUUID();

View File

@@ -8,13 +8,11 @@ export async function withTempConfig(params: {
prefix?: string;
}): Promise<void> {
const prevConfigPath = process.env.OPENCLAW_CONFIG_PATH;
const prevDisableCache = process.env.OPENCLAW_DISABLE_CONFIG_CACHE;
const dir = await mkdtemp(path.join(os.tmpdir(), params.prefix ?? "openclaw-test-config-"));
const configPath = path.join(dir, "openclaw.json");
process.env.OPENCLAW_CONFIG_PATH = configPath;
process.env.OPENCLAW_DISABLE_CONFIG_CACHE = "1";
try {
await writeFile(configPath, JSON.stringify(params.cfg, null, 2), "utf-8");
@@ -25,11 +23,6 @@ export async function withTempConfig(params: {
} else {
process.env.OPENCLAW_CONFIG_PATH = prevConfigPath;
}
if (prevDisableCache === undefined) {
delete process.env.OPENCLAW_DISABLE_CONFIG_CACHE;
} else {
process.env.OPENCLAW_DISABLE_CONFIG_CACHE = prevDisableCache;
}
await rm(dir, { recursive: true, force: true });
}
}

View File

@@ -190,6 +190,7 @@ vi.mock("../agents/auth-profiles/external-cli-sync.js", () => ({
let resolveProviderAuths: typeof import("./provider-usage.auth.js").resolveProviderAuths;
let clearRuntimeAuthProfileStoreSnapshots: typeof import("../agents/auth-profiles.js").clearRuntimeAuthProfileStoreSnapshots;
let clearConfigCache: typeof import("../config/config.js").clearConfigCache;
let clearRuntimeConfigSnapshot: typeof import("../config/config.js").clearRuntimeConfigSnapshot;
describe("resolveProviderAuths key normalization", () => {
let suiteRoot = "";
@@ -216,7 +217,8 @@ describe("resolveProviderAuths key normalization", () => {
vi.resetModules();
({ resolveProviderAuths } = await import("./provider-usage.auth.js"));
({ clearRuntimeAuthProfileStoreSnapshots } = await import("../agents/auth-profiles.js"));
({ clearConfigCache } = await import("../config/config.js"));
({ clearConfigCache, clearRuntimeConfigSnapshot } = await import("../config/config.js"));
clearRuntimeConfigSnapshot();
clearConfigCache();
clearRuntimeAuthProfileStoreSnapshots();
providerRuntimeMocks.resolveProviderUsageAuthWithPluginMock.mockReset();
@@ -224,6 +226,7 @@ describe("resolveProviderAuths key normalization", () => {
});
afterEach(() => {
clearRuntimeConfigSnapshot();
clearConfigCache();
clearRuntimeAuthProfileStoreSnapshots();
vi.restoreAllMocks();

View File

@@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ensureAuthProfileStore, type AuthProfileStore } from "../agents/auth-profiles.js";
import {
clearConfigCache,
clearRuntimeConfigSnapshot,
loadConfig,
type OpenClawConfig,
writeConfigFile,
@@ -130,6 +131,7 @@ describe("secrets runtime snapshot integration", () => {
vi.restoreAllMocks();
envSnapshot.restore();
clearSecretsRuntimeSnapshot();
clearRuntimeConfigSnapshot();
clearConfigCache();
});

View File

@@ -98,6 +98,7 @@ function buildTestWebSearchProviders(): PluginWebSearchProviderEntry[] {
const OPENAI_ENV_KEY_REF = { source: "env", provider: "default", id: "OPENAI_API_KEY" } as const;
let clearConfigCache: typeof import("../config/config.js").clearConfigCache;
let clearRuntimeConfigSnapshot: typeof import("../config/config.js").clearRuntimeConfigSnapshot;
let activateSecretsRuntimeSnapshot: typeof import("./runtime.js").activateSecretsRuntimeSnapshot;
let clearSecretsRuntimeSnapshot: typeof import("./runtime.js").clearSecretsRuntimeSnapshot;
let getActiveRuntimeWebToolsMetadata: typeof import("./runtime.js").getActiveRuntimeWebToolsMetadata;
@@ -125,7 +126,7 @@ function loadAuthStoreWithProfiles(profiles: AuthProfileStore["profiles"]): Auth
describe("secrets runtime snapshot", () => {
beforeAll(async () => {
vi.resetModules();
({ clearConfigCache } = await import("../config/config.js"));
({ clearConfigCache, clearRuntimeConfigSnapshot } = await import("../config/config.js"));
({
activateSecretsRuntimeSnapshot,
clearSecretsRuntimeSnapshot,
@@ -143,6 +144,7 @@ describe("secrets runtime snapshot", () => {
afterEach(() => {
clearSecretsRuntimeSnapshot();
clearRuntimeConfigSnapshot();
clearConfigCache();
resolveBundledPluginWebSearchProvidersMock.mockReset();
resolvePluginWebSearchProvidersMock.mockReset();