diff --git a/src/infra/outbound/current-conversation-bindings.test.ts b/src/infra/outbound/current-conversation-bindings.test.ts index 4261faa53603..5119744be81e 100644 --- a/src/infra/outbound/current-conversation-bindings.test.ts +++ b/src/infra/outbound/current-conversation-bindings.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; import { createTestRegistry } from "../../test-utils/channel-plugins.js"; import { @@ -76,6 +76,7 @@ describe("generic current-conversation bindings", () => { }); afterEach(async () => { + vi.useRealTimers(); testing.resetCurrentConversationBindingsForTests({ deletePersistedFile: true, }); @@ -324,6 +325,64 @@ describe("generic current-conversation bindings", () => { ).toBeNull(); }); + it("drops persisted bindings with invalid expiration timestamps", async () => { + const filePath = testing.resolveBindingsFilePath(); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile( + filePath, + JSON.stringify({ + version: 1, + bindings: [ + { + bindingId: "generic:workspace\u241fdefault\u241f\u241fuser:U123", + targetSessionKey: "agent:codex:acp:workspace-dm", + targetKind: "session", + conversation: { + channel: "workspace", + accountId: "default", + conversationId: "user:U123", + }, + status: "active", + boundAt: 1234, + expiresAt: 8_640_000_000_000_001, + }, + ], + }), + ); + + expect( + resolveGenericCurrentConversationBinding({ + channel: "workspace", + accountId: "default", + conversationId: "user:U123", + }), + ).toBeNull(); + }); + + it("does not bind generic current conversations when ttl expiry overflows", async () => { + vi.setSystemTime(new Date(8_640_000_000_000_000)); + + await expect( + bindGenericCurrentConversation({ + targetSessionKey: "agent:codex:acp:workspace-dm", + targetKind: "session", + conversation: { + channel: "workspace", + accountId: "default", + conversationId: "user:U123", + }, + ttlMs: 1, + }), + ).resolves.toBeNull(); + expect( + resolveGenericCurrentConversationBinding({ + channel: "workspace", + accountId: "default", + conversationId: "user:U123", + }), + ).toBeNull(); + }); + it("persists touched activity across reloads", async () => { const bound = await bindGenericCurrentConversation({ targetSessionKey: "agent:codex:acp:workspace-dm", diff --git a/src/infra/outbound/current-conversation-bindings.ts b/src/infra/outbound/current-conversation-bindings.ts index ee65ec276cd0..29292bd178e7 100644 --- a/src/infra/outbound/current-conversation-bindings.ts +++ b/src/infra/outbound/current-conversation-bindings.ts @@ -6,6 +6,10 @@ import { resolveStateDir } from "../../config/paths.js"; import { loadJsonFile } from "../../infra/json-file.js"; import { saveJsonFile } from "../../plugin-sdk/json-store.js"; import { getActivePluginChannelRegistryFromState } from "../../plugins/runtime-channel-state.js"; +import { + asDateTimestampMs, + resolveExpiresAtMsFromDurationMs, +} from "../../shared/number-coercion.js"; import { normalizeOptionalLowercaseString } from "../../shared/string-coerce.js"; import { normalizeConversationRef } from "./session-binding-normalization.js"; import type { @@ -46,9 +50,15 @@ function resolveBindingsFilePath(env: NodeJS.ProcessEnv = process.env): string { } function isBindingExpired(record: SessionBindingRecord, now = Date.now()): boolean { - return typeof record.expiresAt === "number" && Number.isFinite(record.expiresAt) - ? record.expiresAt <= now - : false; + if (record.expiresAt === undefined) { + return false; + } + const expiresAt = asDateTimestampMs(record.expiresAt); + if (expiresAt === undefined) { + return true; + } + const nowMs = asDateTimestampMs(now); + return nowMs !== undefined && expiresAt <= nowMs; } function toPersistedFile(): PersistedCurrentConversationBindingsFile { @@ -159,11 +169,24 @@ export async function bindGenericCurrentConversation( return null; } loadBindingsIntoMemory(); - const now = Date.now(); + const rawNow = Date.now(); + const now = asDateTimestampMs(rawNow); + if (now === undefined) { + return null; + } const ttlMs = typeof input.ttlMs === "number" && Number.isFinite(input.ttlMs) ? Math.max(0, Math.floor(input.ttlMs)) : undefined; + const expiresAt = + ttlMs === undefined + ? undefined + : ttlMs === 0 + ? now + : resolveExpiresAtMsFromDurationMs(ttlMs, { nowMs: rawNow }); + if (ttlMs !== undefined && expiresAt === undefined) { + return null; + } const key = buildConversationKey(conversation); const existing = pruneExpiredBinding(key); const record: SessionBindingRecord = { @@ -173,7 +196,7 @@ export async function bindGenericCurrentConversation( conversation, status: "active", boundAt: now, - ...(ttlMs != null ? { expiresAt: now + ttlMs } : {}), + ...(expiresAt !== undefined ? { expiresAt } : {}), metadata: { ...existing?.metadata, ...input.metadata,