From 9ad7f5bbdecfe5d607094f8d7ff8bcf6ee672fdb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 30 May 2026 14:07:32 -0400 Subject: [PATCH] fix(agents): bound sqlite cache expiry --- .../cache/agent-cache-store.sqlite.test.ts | 120 ++++++++++++++++++ src/agents/cache/agent-cache-store.sqlite.ts | 46 +++++-- 2 files changed, 158 insertions(+), 8 deletions(-) diff --git a/src/agents/cache/agent-cache-store.sqlite.test.ts b/src/agents/cache/agent-cache-store.sqlite.test.ts index ab75eabbfa8..86ad7df31a5 100644 --- a/src/agents/cache/agent-cache-store.sqlite.test.ts +++ b/src/agents/cache/agent-cache-store.sqlite.test.ts @@ -2,9 +2,11 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { MAX_DATE_TIMESTAMP_MS } from "../../shared/number-coercion.js"; import { closeOpenClawAgentDatabasesForTest, listOpenClawRegisteredAgentDatabases, + openOpenClawAgentDatabase, } from "../../state/openclaw-agent-db.js"; import { closeOpenClawStateDatabaseForTest } from "../../state/openclaw-state-db.js"; import { @@ -147,6 +149,124 @@ describe("SQLite agent cache store", () => { ).toBe(1); }); + it("rejects cache expiries outside the valid Date range", () => { + const env = { OPENCLAW_STATE_DIR: createTempStateDir() }; + + expect(() => + writeSqliteAgentCacheEntry({ + env, + agentId: "main", + scope: "runtime", + key: "explicit-overflow", + value: "bad", + expiresAt: Number.MAX_SAFE_INTEGER, + }), + ).toThrow("SQLite agent cache expiresAt must be a valid Date timestamp."); + expect(() => + writeSqliteAgentCacheEntry({ + env, + agentId: "main", + scope: "runtime", + key: "ttl-overflow", + value: "bad", + ttlMs: 1000, + now: () => MAX_DATE_TIMESTAMP_MS, + }), + ).toThrow("SQLite agent cache ttlMs must resolve to a valid Date timestamp."); + }); + + it("preserves explicit null cache expiry as non-expiring", () => { + const env = { OPENCLAW_STATE_DIR: createTempStateDir() }; + + expect( + writeSqliteAgentCacheEntry({ + env, + agentId: "main", + scope: "runtime", + key: "no-expiry", + value: "ok", + expiresAt: null, + now: () => 1000, + }), + ).toEqual( + expect.objectContaining({ + key: "no-expiry", + value: "ok", + expiresAt: null, + updatedAt: 1000, + }), + ); + expect( + readSqliteAgentCacheEntry({ + env, + agentId: "main", + scope: "runtime", + key: "no-expiry", + now: () => MAX_DATE_TIMESTAMP_MS, + }), + ).toEqual(expect.objectContaining({ key: "no-expiry", expiresAt: null })); + }); + + it("hides invalid persisted expiries and ignores invalid clear clocks", () => { + const env = { OPENCLAW_STATE_DIR: createTempStateDir() }; + + writeSqliteAgentCacheEntry({ + env, + agentId: "main", + scope: "runtime", + key: "valid", + value: "ok", + ttlMs: 1000, + now: () => 1000, + }); + writeSqliteAgentCacheEntry({ + env, + agentId: "main", + scope: "runtime", + key: "invalid", + value: "bad", + now: () => 1000, + }); + const database = openOpenClawAgentDatabase({ agentId: "main", env }); + database.db + .prepare("update cache_entries set expires_at = ? where scope = ? and key = ?") + .run(Number.MAX_SAFE_INTEGER, "runtime", "invalid"); + + expect( + readSqliteAgentCacheEntry({ + env, + agentId: "main", + scope: "runtime", + key: "invalid", + now: () => 1500, + }), + ).toBeNull(); + expect( + listSqliteAgentCacheEntries({ + env, + agentId: "main", + scope: "runtime", + now: () => 1500, + }).map((entry) => entry.key), + ).toEqual(["valid"]); + expect( + clearExpiredSqliteAgentCacheEntries({ + env, + agentId: "main", + scope: "runtime", + currentTime: Number.NaN, + }), + ).toBe(0); + expect( + clearExpiredSqliteAgentCacheEntries({ + env, + agentId: "main", + scope: "runtime", + currentTime: 1500, + }), + ).toBe(1); + }); + it("exposes a scoped runtime cache adapter", () => { const env = { OPENCLAW_STATE_DIR: createTempStateDir() }; const cache = createSqliteAgentCacheStore({ diff --git a/src/agents/cache/agent-cache-store.sqlite.ts b/src/agents/cache/agent-cache-store.sqlite.ts index bfda4117eda..05abd8d002f 100644 --- a/src/agents/cache/agent-cache-store.sqlite.ts +++ b/src/agents/cache/agent-cache-store.sqlite.ts @@ -5,6 +5,13 @@ import { getNodeSqliteKysely, } from "../../infra/kysely-sync.js"; import { normalizeAgentId } from "../../routing/session-key.js"; +import { + MAX_DATE_TIMESTAMP_MS, + asDateTimestampMs, + isFutureDateTimestampMs, + resolveDateTimestampMs, + resolveExpiresAtMsFromDurationMs, +} from "../../shared/number-coercion.js"; import type { DB as OpenClawAgentKyselyDatabase } from "../../state/openclaw-agent-db.generated.js"; import { openOpenClawAgentDatabase, @@ -89,7 +96,7 @@ function parseValue(raw: string | null): unknown { function isExpired(row: AgentCacheRow, now: number): boolean { const expiresAt = asNumber(row.expires_at); - return expiresAt !== null && expiresAt <= now; + return expiresAt !== null && !isFutureDateTimestampMs(expiresAt, { nowMs: now }); } function rowToCacheValue( @@ -112,9 +119,23 @@ function resolveExpiresAt(options: AgentRuntimeCacheWriteOptions, now: number): if (!Number.isFinite(options.ttlMs) || options.ttlMs <= 0) { throw new Error("SQLite agent cache ttlMs must be a positive finite number."); } - return now + options.ttlMs; + const expiresAt = resolveExpiresAtMsFromDurationMs(options.ttlMs, { nowMs: now }); + if (expiresAt === undefined) { + throw new Error("SQLite agent cache ttlMs must resolve to a valid Date timestamp."); + } + return expiresAt; } - return options.expiresAt ?? null; + if (options.expiresAt !== undefined) { + if (options.expiresAt === null) { + return null; + } + const expiresAt = asDateTimestampMs(options.expiresAt); + if (expiresAt === undefined) { + throw new Error("SQLite agent cache expiresAt must be a valid Date timestamp."); + } + return expiresAt; + } + return null; } export function writeSqliteAgentCacheEntry( @@ -122,7 +143,7 @@ export function writeSqliteAgentCacheEntry( ): AgentRuntimeCacheValue { const scope = normalizeScope(options); const key = normalizeKey(options.key); - const updatedAt = options.now?.() ?? Date.now(); + const updatedAt = resolveDateTimestampMs(options.now?.()); const expiresAt = resolveExpiresAt(options, updatedAt); const valueJson = options.value === undefined ? null : JSON.stringify(options.value); const blob = @@ -182,7 +203,7 @@ export function readSqliteAgentCacheEntry( .where("scope", "=", scope.scope) .where("key", "=", key), ) ?? null; - if (!row || isExpired(row, options.now?.() ?? Date.now())) { + if (!row || isExpired(row, resolveDateTimestampMs(options.now?.()))) { return null; } return rowToCacheValue(row, scope); @@ -192,7 +213,7 @@ export function listSqliteAgentCacheEntries( options: SqliteAgentCacheStoreOptions, ): AgentRuntimeCacheValue[] { const scope = normalizeScope(options); - const now = options.now?.() ?? Date.now(); + const now = resolveDateTimestampMs(options.now?.()); const database = openOpenClawAgentDatabase(toDatabaseOptions(options)); const db = getNodeSqliteKysely(database.db); return executeSqliteQuerySync( @@ -238,7 +259,10 @@ export function clearExpiredSqliteAgentCacheEntries( options: SqliteAgentCacheStoreOptions & { currentTime?: number }, ): number { const scope = normalizeScope(options); - const currentTime = options.currentTime ?? options.now?.() ?? Date.now(); + const currentTime = asDateTimestampMs(options.currentTime ?? options.now?.() ?? Date.now()); + if (currentTime === undefined) { + return 0; + } return runOpenClawAgentWriteTransaction((database) => { const db = getNodeSqliteKysely(database.db); const result = executeSqliteQuerySync( @@ -247,7 +271,13 @@ export function clearExpiredSqliteAgentCacheEntries( .deleteFrom("cache_entries") .where("scope", "=", scope.scope) .where("expires_at", "is not", null) - .where("expires_at", "<=", currentTime), + .where((eb) => + eb.or([ + eb("expires_at", "<=", currentTime), + eb("expires_at", ">", MAX_DATE_TIMESTAMP_MS), + eb("expires_at", "<", -MAX_DATE_TIMESTAMP_MS), + ]), + ), ); return Number(result.numAffectedRows ?? 0); }, toDatabaseOptions(options));