From 912a276ca1802a2ef6f72252d17afa2e9be08946 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 30 May 2026 13:41:06 -0400 Subject: [PATCH] fix(gateway): bound talk handoff expiry --- src/gateway/talk-handoff.test.ts | 39 ++++++++++++++++++++++++++++++++ src/gateway/talk-handoff.ts | 20 ++++++++++++---- 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/src/gateway/talk-handoff.test.ts b/src/gateway/talk-handoff.test.ts index 5782fbc42f6..a627b847ca6 100644 --- a/src/gateway/talk-handoff.test.ts +++ b/src/gateway/talk-handoff.test.ts @@ -107,6 +107,45 @@ describe("talk handoff store", () => { vi.useRealTimers(); }); + it("expires handoffs immediately when the creation clock is invalid", () => { + clearTalkHandoffsForTest(); + const dateNow = vi.spyOn(Date, "now").mockReturnValue(Number.NaN); + try { + const handoff = createTalkHandoff({ + sessionKey: "session:main", + ttlMs: 5000, + }); + + expect(handoff.createdAt).toBe(0); + expect(handoff.expiresAt).toBe(0); + expect(joinTalkHandoff(handoff.id, handoff.token)).toEqual({ + ok: false, + reason: "expired", + }); + } finally { + dateNow.mockRestore(); + } + }); + + it("expires handoffs immediately when expiry would exceed Date bounds", () => { + clearTalkHandoffsForTest(); + vi.useFakeTimers(); + vi.setSystemTime(new Date(8_640_000_000_000_000)); + + const handoff = createTalkHandoff({ + sessionKey: "session:main", + ttlMs: 5000, + }); + + expect(handoff.expiresAt).toBe(0); + expect(joinTalkHandoff(handoff.id, handoff.token)).toEqual({ + ok: false, + reason: "expired", + }); + + vi.useRealTimers(); + }); + it("joins and revokes handoffs with only the bearer token", () => { clearTalkHandoffsForTest(); const handoff = createTalkHandoff({ sessionKey: "session:main" }); diff --git a/src/gateway/talk-handoff.ts b/src/gateway/talk-handoff.ts index 0f520fe7efe..fac646ff14b 100644 --- a/src/gateway/talk-handoff.ts +++ b/src/gateway/talk-handoff.ts @@ -1,4 +1,10 @@ import { createHash, randomBytes, randomUUID } from "node:crypto"; +import { + asDateTimestampMs, + isFutureDateTimestampMs, + resolveDateTimestampMs, + resolveExpiresAtMsFromDurationMs, +} from "../shared/number-coercion.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; import { recordTalkObservabilityEvent } from "../talk/observability.js"; import { @@ -99,8 +105,10 @@ const handoffs = new Map(); export function createTalkHandoff(params: TalkHandoffCreateParams): TalkHandoffCreateResult { pruneExpiredTalkHandoffs(); - const createdAt = Date.now(); + const rawCreatedAt = Date.now(); + const createdAt = resolveDateTimestampMs(rawCreatedAt); const ttlMs = normalizeTtlMs(params.ttlMs); + const expiresAt = resolveExpiresAtMsFromDurationMs(ttlMs, { nowMs: rawCreatedAt }) ?? 0; const id = randomUUID(); const roomId = `talk_${id}`; const token = randomBytes(32).toString("base64url"); @@ -127,7 +135,7 @@ export function createTalkHandoff(params: TalkHandoffCreateParams): TalkHandoffC transport: params.transport ?? "managed-room", brain: params.brain ?? "agent-consult", createdAt, - expiresAt: createdAt + ttlMs, + expiresAt, room, }; appendTalkHandoffRoomEvent(record, { @@ -285,8 +293,12 @@ function normalizeTtlMs(value: number | undefined): number { } function pruneExpiredTalkHandoffs(now = Date.now()): void { + const validNow = asDateTimestampMs(now); + if (validNow === undefined) { + return; + } for (const [id, record] of handoffs) { - if (record.expiresAt <= now) { + if (!isFutureDateTimestampMs(record.expiresAt, { nowMs: validNow })) { appendTalkHandoffRoomEvent(record, { type: "session.closed", payload: { reason: "expired", handoffId: id, roomId: record.roomId }, @@ -344,7 +356,7 @@ function resolveTalkHandoffAccess( if (!record) { return { ok: false, reason: "not_found" }; } - if (record.expiresAt <= Date.now()) { + if (!isFutureDateTimestampMs(record.expiresAt)) { appendTalkHandoffRoomEvent(record, { type: "session.closed", payload: { reason: "expired", handoffId: id, roomId: record.roomId },