mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-26 16:06:16 +00:00
refactor(config): pin runtime snapshot and drop ttl cache
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
23
docs/internal/steipete/2026-03-29-config-runtime-snapshot.md
Normal file
23
docs/internal/steipete/2026-03-29-config-runtime-snapshot.md
Normal 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`
|
||||
@@ -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))
|
||||
: [];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ export {
|
||||
parseConfigJson5,
|
||||
readConfigFileSnapshot,
|
||||
readConfigFileSnapshotForWrite,
|
||||
resetConfigRuntimeState,
|
||||
resolveConfigSnapshotHash,
|
||||
setRuntimeConfigSnapshotRefreshHandler,
|
||||
setRuntimeConfigSnapshot,
|
||||
|
||||
@@ -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,
|
||||
|
||||
68
src/config/io.runtime-snapshot-load.test.ts
Normal file
68
src/config/io.runtime-snapshot-load.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user