From e12a0a0fbf6ae8502cd39127f2e83c536db1fd61 Mon Sep 17 00:00:00 2001 From: rishu685 Date: Sat, 29 Aug 2026 17:17:14 +0530 Subject: [PATCH 1/3] Revert "Revert "fix(widget): merge anonymous visitor history in cookie authentication mode (#104)"" This reverts commit a14b09bd402d0e28494bf27f13d64414108853fe. --- src/agent_manager/api/static/widget.js | 27 +++++++++-- src/agent_manager/api/static/widget.test.mjs | 38 ++++++++++++++++ .../api/static/widget/auth/tokenSource.ts | 32 ++++++++++--- tests/agent_manager/test_api.py | 43 ++++++++++++++++++ tests/e2e/widget.spec.ts | 45 +++++++++++++++++++ 5 files changed, 174 insertions(+), 11 deletions(-) diff --git a/src/agent_manager/api/static/widget.js b/src/agent_manager/api/static/widget.js index b69fa3b5..a43bb474 100644 --- a/src/agent_manager/api/static/widget.js +++ b/src/agent_manager/api/static/widget.js @@ -52299,6 +52299,8 @@ var TokenSource = class { /** Bumped by `reset()` so a resolution already in flight, once it lands, can * tell it is answering a question nobody is asking anymore. */ this.generation = 0; + /** Avoid repeating unauthenticated claim attempts for the same pass in cookie mode. */ + this.lastClaimAttemptPass = null; this.tokenUrl = options.tokenUrl ?? ""; this.provider = options.provider ?? null; this.storage = options.storage ?? localStorage; @@ -52307,7 +52309,9 @@ var TokenSource = class { }); } async current() { - if (!this.cached) await this.resolve(() => this.storedPass()); + if (!this.cached || this.isCookieMode() && this.storedPass() !== null) { + await this.resolve(() => this.storedPass()); + } return this.cached; } /** After a 401: whatever we sent is no good, so get another. */ @@ -52344,23 +52348,38 @@ var TokenSource = class { })()); return this.pending; } + isCookieMode() { + return !this.tokenUrl && !this.provider; + } /** A host token, plus the one-time hand-off of whatever this browser chatted * about before signing in. */ async hostToken() { const token = await this.fromHost(); - if (token) await this.claimVisitorHistory(token); + if (token || this.isCookieMode() && this.storedPass()) { + await this.claimVisitorHistory(token); + } return token; } async claimVisitorHistory(hostToken) { const pass = this.storedPass(); if (!pass) return; try { + const headers = { "Content-Type": "application/json" }; + if (hostToken) headers.Authorization = `Bearer ${hostToken}`; const response = await fetch(`${this.endpoint}${LINK_ENDPOINT}`, { method: "POST", - headers: { "Content-Type": "application/json", Authorization: `Bearer ${hostToken}` }, + headers, + credentials: "include", body: JSON.stringify({ anonymous_token: pass }) }); - if (response.status < 500) this.clearPass(); + if (response.ok) { + const data = await response.json().catch(() => null); + if (hostToken !== null || (data?.conversations_moved ?? 0) > 0) { + this.clearPass(); + } + } else if (hostToken !== null && response.status >= 400 && response.status < 500 && response.status !== 401) { + this.clearPass(); + } } catch { } } diff --git a/src/agent_manager/api/static/widget.test.mjs b/src/agent_manager/api/static/widget.test.mjs index 2feb6279..d8b87794 100644 --- a/src/agent_manager/api/static/widget.test.mjs +++ b/src/agent_manager/api/static/widget.test.mjs @@ -747,6 +747,44 @@ assert.equal(mintedPass, false, "never asks for a visitor pass"); ); } +// In cookie mode (host_token), a visitor chats anonymously, then signs in via cookie in the same SPA. +// Calling client methods re-evaluates identity, claims history via /auth/link, and sends requests with cookie. +{ + resetPage(); + localStorage.setItem(visitorPassKey("https://api.example"), "old-cookie-pass"); + let loggedInViaCookie = false; + const calls = []; + globalThis.fetch = async (url, options = {}) => { + calls.push({ url, auth: options.headers?.Authorization }); + if (url.endsWith("/auth/link")) { + return loggedInViaCookie ? jsonResponse({ conversations_moved: 1 }) : jsonResponse({}, false, 401); + } + return jsonResponse({ conversation_id: "c1" }); + }; + const tokens = new TokenSource("https://api.example"); + const client = new AgentChatClient("https://api.example", tokens); + + // Before login: claimVisitorHistory gets 401, pass is kept, bearer old-cookie-pass sent + await client.createConversation(); + const sentWhileSignedOut = calls.filter((c) => c.url.endsWith("/conversations")).pop().auth; + assert.equal(sentWhileSignedOut, "Bearer old-cookie-pass", "visitor pass used while signed out"); + + // User logs in via cookie on the host site (zero-code: no refreshIdentity/reset called) + loggedInViaCookie = true; + await client.createConversation(); + const sentAfterCookieLogin = calls.filter((c) => c.url.endsWith("/conversations")).pop().auth; + assert.equal(sentAfterCookieLogin, undefined, "no bearer sent after cookie login"); + assert.ok( + calls.some((c) => c.url.endsWith("/auth/link")), + "cookie mode pre-login conversations are merged", + ); + assert.equal( + localStorage.getItem(visitorPassKey("https://api.example")), + null, + "visitor pass is cleared after successful cookie merge", + ); +} + // reset() keeps the visitor pass; only forget() discards it. Getting this // backwards silently throws away the conversations the merge exists to rescue. { diff --git a/src/agent_manager/api/static/widget/auth/tokenSource.ts b/src/agent_manager/api/static/widget/auth/tokenSource.ts index 573fab22..642d0ad6 100644 --- a/src/agent_manager/api/static/widget/auth/tokenSource.ts +++ b/src/agent_manager/api/static/widget/auth/tokenSource.ts @@ -46,6 +46,8 @@ export class TokenSource { /** Bumped by `reset()` so a resolution already in flight, once it lands, can * tell it is answering a question nobody is asking anymore. */ private generation = 0; + /** Avoid repeating unauthenticated claim attempts for the same pass in cookie mode. */ + private lastClaimAttemptPass: string | null = null; private readonly tokenUrl: string; private readonly provider: TokenProvider | null; private readonly storage: Storage; @@ -64,7 +66,9 @@ export class TokenSource { } async current(): Promise { - if (!this.cached) await this.resolve(() => this.storedPass()); + if (!this.cached || (this.isCookieMode() && this.storedPass() !== null)) { + await this.resolve(() => this.storedPass()); + } return this.cached; } @@ -114,26 +118,40 @@ export class TokenSource { return this.pending; } + private isCookieMode(): boolean { + return !this.tokenUrl && !this.provider; + } + /** A host token, plus the one-time hand-off of whatever this browser chatted * about before signing in. */ private async hostToken(): Promise { const token = await this.fromHost(); - if (token) await this.claimVisitorHistory(token); + if (token || (this.isCookieMode() && this.storedPass())) { + await this.claimVisitorHistory(token); + } return token; } - private async claimVisitorHistory(hostToken: string): Promise { + private async claimVisitorHistory(hostToken: string | null): Promise { const pass = this.storedPass(); if (!pass) return; try { + const headers: Record = { "Content-Type": "application/json" }; + if (hostToken) headers.Authorization = `Bearer ${hostToken}`; const response = await fetch(`${this.endpoint}${LINK_ENDPOINT}`, { method: "POST", - headers: { "Content-Type": "application/json", Authorization: `Bearer ${hostToken}` }, + headers, + credentials: "include", body: JSON.stringify({ anonymous_token: pass }), }); - // Drop the pass on any verdict, including a refusal — only a server that - // never answered is worth asking again. - if (response.status < 500) this.clearPass(); + if (response.ok) { + const data = (await response.json().catch(() => null)) as { conversations_moved?: number } | null; + if (hostToken !== null || (data?.conversations_moved ?? 0) > 0) { + this.clearPass(); + } + } else if (hostToken !== null && response.status >= 400 && response.status < 500 && response.status !== 401) { + this.clearPass(); + } } catch { // Offline: keep the pass so the next page load retries the hand-off. } diff --git a/tests/agent_manager/test_api.py b/tests/agent_manager/test_api.py index 89ff9bee..408abb58 100644 --- a/tests/agent_manager/test_api.py +++ b/tests/agent_manager/test_api.py @@ -393,6 +393,49 @@ def test_signing_in_adopts_the_conversations_a_visitor_already_started() -> None assert client.get(f"/conversations/{cid}/messages", headers=visitor).status_code == 403 +def test_linking_via_cookie_authentication() -> None: + """In host_token (cookie) mode, /auth/link is called with session cookies.""" + app = build_test_app( + ConversationService(RecordingEngine(), MemoryRepository()), + extra_auth_mode=AuthMode.HOST_TOKEN, + extra_auth_cookie=HOST_COOKIE, + extra_auth_claim_user_id="id", + ) + anon_client = TestClient(app) + pass_token = anon_client.post("/auth/anonymous").json()["token"] + visitor = {"Authorization": f"Bearer {pass_token}"} + cid = anon_client.post("/conversations", headers=visitor).json()["conversation_id"] + anon_client.post( + f"/conversations/{cid}/messages", json={"message": "hi from visitor"}, headers=visitor + ) + + alice_client = TestClient(app, cookies=session_cookie(id="alice")) + linked = alice_client.post("/auth/link", json={"anonymous_token": pass_token}) + + assert linked.status_code == 200 + assert linked.json() == {"conversations_moved": 1} + assert [t["conversation_id"] for t in alice_client.get("/conversations").json()["items"]] == [ + cid + ] + assert alice_client.get(f"/conversations/{cid}/messages").status_code == 200 + + +def test_linking_via_cookie_returns_401_when_not_logged_in() -> None: + """Without a session cookie the server must reject the link request, not silently succeed.""" + app = build_test_app( + ConversationService(RecordingEngine(), MemoryRepository()), + extra_auth_mode=AuthMode.HOST_TOKEN, + extra_auth_cookie=HOST_COOKIE, + extra_auth_claim_user_id="id", + ) + client = TestClient(app) + pass_token = client.post("/auth/anonymous").json()["token"] + + # No cookie, no bearer — the server has no way to identify the adopting caller. + result = client.post("/auth/link", json={"anonymous_token": pass_token}) + assert result.status_code == 401 + + def test_linking_refuses_a_pass_that_is_not_ours_or_already_spent() -> None: app = build_test_app(ConversationService(RecordingEngine(), MemoryRepository())) client = TestClient(app) diff --git a/tests/e2e/widget.spec.ts b/tests/e2e/widget.spec.ts index 9f64e599..35a6da56 100644 --- a/tests/e2e/widget.spec.ts +++ b/tests/e2e/widget.spec.ts @@ -26,6 +26,14 @@ async function mockConversationApi( const calls: string[] = []; await pinVisitorPass(page); + await page.route("**/auth/link", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ conversations_moved: 0 }), + }); + }); + await page.route(/\/conversations\?/, async (route) => { calls.push(`GET ${new URL(route.request().url()).pathname}`); await route.fulfill({ @@ -1492,3 +1500,40 @@ test("thread drawer paginates and appends next pages on scroll", async ({ page } await expect.poll(() => shadowText(page, ".thread-drawer")).toContain("Thread Three"); }); + +test("visitor pass cached, cookie login hand-off merges history and first thread list request observes merged threads", async ({ page }) => { + const calls: string[] = []; + await pinVisitorPass(page); + + let linkCalled = false; + await page.route("**/auth/link", async (route) => { + linkCalled = true; + calls.push(`POST ${new URL(route.request().url()).pathname}`); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ conversations_moved: 1 }), + }); + }); + + await page.route(/\/conversations\?/, async (route) => { + calls.push(`GET ${new URL(route.request().url()).pathname}`); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + items: linkCalled + ? [{ conversation_id: "merged-thread", title: "Merged Thread", last_message_at: "2026-06-28T00:00:00Z" }] + : [], + next_cursor: null, + }), + }); + }); + + await page.goto("/widget-demo.html"); + await shadowClick(page, ".launcher"); + await shadowClick(page, '[aria-label="Conversations"]'); + + await expect.poll(() => linkCalled).toBe(true); + await expect.poll(() => shadowText(page, ".thread-drawer")).toContain("Merged Thread"); +}); From 8d05239f78e748d7ccfd249ddae931284e6d64ec Mon Sep 17 00:00:00 2001 From: rishu685 Date: Sat, 29 Aug 2026 23:21:59 +0530 Subject: [PATCH 2/3] fix(widget): throttle unauthenticated claimVisitorHistory retries in cookie mode - Use lastClaimAttemptPass and CLAIM_RETRY_INTERVAL_MS (15s) to avoid repeating blocking POST /auth/link calls on every consecutive anonymous request - Preserves zero-code cookie login hand-off after retry interval or reset - Adds regression unit test verifying multiple consecutive anonymous turns do not repeat /auth/link --- src/agent_manager/api/static/widget.js | 18 +++++++++++++-- src/agent_manager/api/static/widget.test.mjs | 17 +++++++++----- .../api/static/widget/auth/tokenSource.ts | 22 +++++++++++++++++-- 3 files changed, 48 insertions(+), 9 deletions(-) diff --git a/src/agent_manager/api/static/widget.js b/src/agent_manager/api/static/widget.js index a43bb474..5de64c88 100644 --- a/src/agent_manager/api/static/widget.js +++ b/src/agent_manager/api/static/widget.js @@ -52288,6 +52288,7 @@ function parseSseFrame(frame) { // src/agent_manager/api/static/widget/auth/tokenSource.ts var PASS_ENDPOINT = "/auth/anonymous"; var LINK_ENDPOINT = "/auth/link"; +var CLAIM_RETRY_INTERVAL_MS = 15e3; function visitorPassKey(endpoint) { return `agent-chat:pass:${endpoint}`; } @@ -52301,6 +52302,7 @@ var TokenSource = class { this.generation = 0; /** Avoid repeating unauthenticated claim attempts for the same pass in cookie mode. */ this.lastClaimAttemptPass = null; + this.lastClaimAttemptTime = 0; this.tokenUrl = options.tokenUrl ?? ""; this.provider = options.provider ?? null; this.storage = options.storage ?? localStorage; @@ -52309,7 +52311,9 @@ var TokenSource = class { }); } async current() { - if (!this.cached || this.isCookieMode() && this.storedPass() !== null) { + const pass = this.storedPass(); + const shouldRetryClaim = this.isCookieMode() && pass !== null && (pass !== this.lastClaimAttemptPass || Date.now() - this.lastClaimAttemptTime >= CLAIM_RETRY_INTERVAL_MS); + if (!this.cached || shouldRetryClaim) { await this.resolve(() => this.storedPass()); } return this.cached; @@ -52326,6 +52330,8 @@ var TokenSource = class { this.generation += 1; this.cached = null; this.pending = null; + this.lastClaimAttemptPass = null; + this.lastClaimAttemptTime = 0; } /** Drop this browser's identity entirely — a host app signing its user out. */ forget() { @@ -52376,8 +52382,16 @@ var TokenSource = class { const data = await response.json().catch(() => null); if (hostToken !== null || (data?.conversations_moved ?? 0) > 0) { this.clearPass(); + this.lastClaimAttemptPass = null; + this.lastClaimAttemptTime = 0; + } else { + this.lastClaimAttemptPass = pass; + this.lastClaimAttemptTime = Date.now(); } - } else if (hostToken !== null && response.status >= 400 && response.status < 500 && response.status !== 401) { + } else if (response.status === 401) { + this.lastClaimAttemptPass = pass; + this.lastClaimAttemptTime = Date.now(); + } else if (hostToken !== null && response.status >= 400 && response.status < 500) { this.clearPass(); } } catch { diff --git a/src/agent_manager/api/static/widget.test.mjs b/src/agent_manager/api/static/widget.test.mjs index d8b87794..87af6079 100644 --- a/src/agent_manager/api/static/widget.test.mjs +++ b/src/agent_manager/api/static/widget.test.mjs @@ -769,15 +769,22 @@ assert.equal(mintedPass, false, "never asks for a visitor pass"); const sentWhileSignedOut = calls.filter((c) => c.url.endsWith("/conversations")).pop().auth; assert.equal(sentWhileSignedOut, "Bearer old-cookie-pass", "visitor pass used while signed out"); - // User logs in via cookie on the host site (zero-code: no refreshIdentity/reset called) + // 1. Multiple consecutive anonymous requests in cookie mode do NOT repeat /auth/link on every request + const initialLinkCalls = calls.filter((c) => c.url.endsWith("/auth/link")).length; + assert.equal(initialLinkCalls, 1, "/auth/link called once on first request"); + + await client.createConversation(); + await client.createConversation(); + await client.createConversation(); + const linkCallsAfter5Turn = calls.filter((c) => c.url.endsWith("/auth/link")).length; + assert.equal(linkCallsAfter5Turn, 1, "/auth/link is throttled and not repeated on every anonymous request"); + + // 2. User logs in via cookie on the host site and resets identity / retries loggedInViaCookie = true; + tokens.reset(); await client.createConversation(); const sentAfterCookieLogin = calls.filter((c) => c.url.endsWith("/conversations")).pop().auth; assert.equal(sentAfterCookieLogin, undefined, "no bearer sent after cookie login"); - assert.ok( - calls.some((c) => c.url.endsWith("/auth/link")), - "cookie mode pre-login conversations are merged", - ); assert.equal( localStorage.getItem(visitorPassKey("https://api.example")), null, diff --git a/src/agent_manager/api/static/widget/auth/tokenSource.ts b/src/agent_manager/api/static/widget/auth/tokenSource.ts index 642d0ad6..733b27eb 100644 --- a/src/agent_manager/api/static/widget/auth/tokenSource.ts +++ b/src/agent_manager/api/static/widget/auth/tokenSource.ts @@ -35,6 +35,7 @@ export interface TokenSourceOptions { const PASS_ENDPOINT = "/auth/anonymous"; const LINK_ENDPOINT = "/auth/link"; +const CLAIM_RETRY_INTERVAL_MS = 15000; export function visitorPassKey(endpoint: string): string { return `agent-chat:pass:${endpoint}`; @@ -48,6 +49,7 @@ export class TokenSource { private generation = 0; /** Avoid repeating unauthenticated claim attempts for the same pass in cookie mode. */ private lastClaimAttemptPass: string | null = null; + private lastClaimAttemptTime = 0; private readonly tokenUrl: string; private readonly provider: TokenProvider | null; private readonly storage: Storage; @@ -66,7 +68,13 @@ export class TokenSource { } async current(): Promise { - if (!this.cached || (this.isCookieMode() && this.storedPass() !== null)) { + const pass = this.storedPass(); + const shouldRetryClaim = + this.isCookieMode() && + pass !== null && + (pass !== this.lastClaimAttemptPass || Date.now() - this.lastClaimAttemptTime >= CLAIM_RETRY_INTERVAL_MS); + + if (!this.cached || shouldRetryClaim) { await this.resolve(() => this.storedPass()); } return this.cached; @@ -88,6 +96,8 @@ export class TokenSource { // next call starts a fresh one instead of awaiting an answer to a question // that no longer applies (e.g. the old tokenProvider). this.pending = null; + this.lastClaimAttemptPass = null; + this.lastClaimAttemptTime = 0; } /** Drop this browser's identity entirely — a host app signing its user out. */ @@ -148,8 +158,16 @@ export class TokenSource { const data = (await response.json().catch(() => null)) as { conversations_moved?: number } | null; if (hostToken !== null || (data?.conversations_moved ?? 0) > 0) { this.clearPass(); + this.lastClaimAttemptPass = null; + this.lastClaimAttemptTime = 0; + } else { + this.lastClaimAttemptPass = pass; + this.lastClaimAttemptTime = Date.now(); } - } else if (hostToken !== null && response.status >= 400 && response.status < 500 && response.status !== 401) { + } else if (response.status === 401) { + this.lastClaimAttemptPass = pass; + this.lastClaimAttemptTime = Date.now(); + } else if (hostToken !== null && response.status >= 400 && response.status < 500) { this.clearPass(); } } catch { From 852be0ce285c84af4b8040fc72720f29ac7fd2d7 Mon Sep 17 00:00:00 2001 From: rishu685 Date: Mon, 31 Aug 2026 15:27:26 +0530 Subject: [PATCH 3/3] fix(widget): replace magic cooldown with event-driven cookie tracking and deterministic history hand-off - Remove CLAIM_RETRY_INTERVAL_MS magic cooldown delay - Add cookie snapshot tracking (document.cookie) and window focus/visibility listeners to TokenSource - Ensure listConversations passes forceCheck to guarantee POST /auth/link completes before GET /conversations - Add zero-code transition regression test in widget.test.mjs --- src/agent_manager/api/static/widget.js | 50 ++++++++++++++----- src/agent_manager/api/static/widget.test.mjs | 11 ++-- .../api/static/widget/api/AgentChatClient.ts | 6 +-- .../api/static/widget/auth/tokenSource.ts | 50 +++++++++++++++---- 4 files changed, 86 insertions(+), 31 deletions(-) diff --git a/src/agent_manager/api/static/widget.js b/src/agent_manager/api/static/widget.js index 5de64c88..7651b89e 100644 --- a/src/agent_manager/api/static/widget.js +++ b/src/agent_manager/api/static/widget.js @@ -52143,8 +52143,8 @@ var AgentChatClient = class { } /** A 401 usually means the token expired: renew once and retry. The rejected * attempt changed nothing, so replaying is safe. */ - async request(path2, init) { - let response = await this.send(path2, init, await this.tokens.current()); + async request(path2, init, options) { + let response = await this.send(path2, init, await this.tokens.current({ forceCheck: options?.forceCheck })); if (response.status === 401) { response = await this.send(path2, init, await this.tokens.renew()); } @@ -52166,7 +52166,7 @@ var AgentChatClient = class { async listConversations(limit = 20, cursor) { const params = new URLSearchParams({ limit: String(limit) }); if (cursor) params.set("cursor", cursor); - const response = await this.request(`/conversations?${params.toString()}`); + const response = await this.request(`/conversations?${params.toString()}`, void 0, { forceCheck: true }); const data = await response.json(); const rawItems = Array.isArray(data.items) ? data.items : []; const items = rawItems.map((thread) => ({ @@ -52288,7 +52288,6 @@ function parseSseFrame(frame) { // src/agent_manager/api/static/widget/auth/tokenSource.ts var PASS_ENDPOINT = "/auth/anonymous"; var LINK_ENDPOINT = "/auth/link"; -var CLAIM_RETRY_INTERVAL_MS = 15e3; function visitorPassKey(endpoint) { return `agent-chat:pass:${endpoint}`; } @@ -52302,24 +52301,43 @@ var TokenSource = class { this.generation = 0; /** Avoid repeating unauthenticated claim attempts for the same pass in cookie mode. */ this.lastClaimAttemptPass = null; - this.lastClaimAttemptTime = 0; + this.lastCookieSnapshot = typeof document !== "undefined" ? document.cookie : ""; + this.identityCheckDirty = true; this.tokenUrl = options.tokenUrl ?? ""; this.provider = options.provider ?? null; this.storage = options.storage ?? localStorage; this.requireIdentity = options.requireIdentity ?? false; this.onIdentityFailure = options.onIdentityFailure ?? (() => { }); + if (typeof window !== "undefined") { + const markDirty = () => { + this.identityCheckDirty = true; + }; + try { + window.addEventListener("focus", markDirty); + if (typeof document !== "undefined") { + document.addEventListener("visibilitychange", markDirty); + } + window.addEventListener("storage", markDirty); + } catch { + } + } } - async current() { + async current(options) { const pass = this.storedPass(); - const shouldRetryClaim = this.isCookieMode() && pass !== null && (pass !== this.lastClaimAttemptPass || Date.now() - this.lastClaimAttemptTime >= CLAIM_RETRY_INTERVAL_MS); - if (!this.cached || shouldRetryClaim) { + const cookieChanged = this.cookieSnapshotChanged(); + const force = options?.forceCheck ?? false; + const shouldClaim = this.isCookieMode() && pass !== null && (force || this.identityCheckDirty || cookieChanged || pass !== this.lastClaimAttemptPass); + if (!this.cached || shouldClaim) { await this.resolve(() => this.storedPass()); + this.identityCheckDirty = false; + this.updateCookieSnapshot(); } return this.cached; } /** After a 401: whatever we sent is no good, so get another. */ async renew() { + this.identityCheckDirty = true; return this.resolve(() => this.issuePass()); } /** Forget the token in hand, so the next request works out who the caller is @@ -52331,13 +52349,23 @@ var TokenSource = class { this.cached = null; this.pending = null; this.lastClaimAttemptPass = null; - this.lastClaimAttemptTime = 0; + this.identityCheckDirty = true; + this.updateCookieSnapshot(); } /** Drop this browser's identity entirely — a host app signing its user out. */ forget() { this.reset(); this.clearPass(); } + cookieSnapshotChanged() { + if (typeof document === "undefined") return false; + return document.cookie !== this.lastCookieSnapshot; + } + updateCookieSnapshot() { + if (typeof document !== "undefined") { + this.lastCookieSnapshot = document.cookie; + } + } /** Concurrent callers share one resolution. Without this, parallel requests * each fetch a token and each hand over the visitor pass. */ resolve(fallback) { @@ -52383,14 +52411,12 @@ var TokenSource = class { if (hostToken !== null || (data?.conversations_moved ?? 0) > 0) { this.clearPass(); this.lastClaimAttemptPass = null; - this.lastClaimAttemptTime = 0; + this.identityCheckDirty = true; } else { this.lastClaimAttemptPass = pass; - this.lastClaimAttemptTime = Date.now(); } } else if (response.status === 401) { this.lastClaimAttemptPass = pass; - this.lastClaimAttemptTime = Date.now(); } else if (hostToken !== null && response.status >= 400 && response.status < 500) { this.clearPass(); } diff --git a/src/agent_manager/api/static/widget.test.mjs b/src/agent_manager/api/static/widget.test.mjs index 87af6079..a46fb67d 100644 --- a/src/agent_manager/api/static/widget.test.mjs +++ b/src/agent_manager/api/static/widget.test.mjs @@ -779,16 +779,15 @@ assert.equal(mintedPass, false, "never asks for a visitor pass"); const linkCallsAfter5Turn = calls.filter((c) => c.url.endsWith("/auth/link")).length; assert.equal(linkCallsAfter5Turn, 1, "/auth/link is throttled and not repeated on every anonymous request"); - // 2. User logs in via cookie on the host site and resets identity / retries + // 2. User logs in via cookie on the host site (zero-code: no tokens.reset() or refreshIdentity() called) loggedInViaCookie = true; - tokens.reset(); - await client.createConversation(); - const sentAfterCookieLogin = calls.filter((c) => c.url.endsWith("/conversations")).pop().auth; - assert.equal(sentAfterCookieLogin, undefined, "no bearer sent after cookie login"); + await client.listConversations(); + const sentAfterCookieLogin = calls.filter((c) => c.url.includes("/conversations")).pop().auth; + assert.equal(sentAfterCookieLogin, undefined, "no bearer sent after zero-code cookie login"); assert.equal( localStorage.getItem(visitorPassKey("https://api.example")), null, - "visitor pass is cleared after successful cookie merge", + "visitor pass is cleared after successful zero-code cookie merge", ); } diff --git a/src/agent_manager/api/static/widget/api/AgentChatClient.ts b/src/agent_manager/api/static/widget/api/AgentChatClient.ts index 7c5348d4..504d86b8 100644 --- a/src/agent_manager/api/static/widget/api/AgentChatClient.ts +++ b/src/agent_manager/api/static/widget/api/AgentChatClient.ts @@ -43,8 +43,8 @@ export class AgentChatClient { /** A 401 usually means the token expired: renew once and retry. The rejected * attempt changed nothing, so replaying is safe. */ - private async request(path: string, init?: RequestInit): Promise { - let response = await this.send(path, init, await this.tokens.current()); + private async request(path: string, init?: RequestInit, options?: { forceCheck?: boolean }): Promise { + let response = await this.send(path, init, await this.tokens.current({ forceCheck: options?.forceCheck })); if (response.status === 401) { response = await this.send(path, init, await this.tokens.renew()); } @@ -71,7 +71,7 @@ export class AgentChatClient { async listConversations(limit = 20, cursor?: string | null): Promise { const params = new URLSearchParams({ limit: String(limit) }); if (cursor) params.set("cursor", cursor); - const response = await this.request(`/conversations?${params.toString()}`); + const response = await this.request(`/conversations?${params.toString()}`, undefined, { forceCheck: true }); const data = await response.json(); const rawItems: Array<{ conversation_id: string; title?: string | null; last_message_at?: string | null }> = diff --git a/src/agent_manager/api/static/widget/auth/tokenSource.ts b/src/agent_manager/api/static/widget/auth/tokenSource.ts index 733b27eb..71a6c918 100644 --- a/src/agent_manager/api/static/widget/auth/tokenSource.ts +++ b/src/agent_manager/api/static/widget/auth/tokenSource.ts @@ -35,7 +35,6 @@ export interface TokenSourceOptions { const PASS_ENDPOINT = "/auth/anonymous"; const LINK_ENDPOINT = "/auth/link"; -const CLAIM_RETRY_INTERVAL_MS = 15000; export function visitorPassKey(endpoint: string): string { return `agent-chat:pass:${endpoint}`; @@ -49,7 +48,8 @@ export class TokenSource { private generation = 0; /** Avoid repeating unauthenticated claim attempts for the same pass in cookie mode. */ private lastClaimAttemptPass: string | null = null; - private lastClaimAttemptTime = 0; + private lastCookieSnapshot = typeof document !== "undefined" ? document.cookie : ""; + private identityCheckDirty = true; private readonly tokenUrl: string; private readonly provider: TokenProvider | null; private readonly storage: Storage; @@ -65,23 +65,43 @@ export class TokenSource { this.storage = options.storage ?? localStorage; this.requireIdentity = options.requireIdentity ?? false; this.onIdentityFailure = options.onIdentityFailure ?? (() => {}); + + if (typeof window !== "undefined") { + const markDirty = () => { + this.identityCheckDirty = true; + }; + try { + window.addEventListener("focus", markDirty); + if (typeof document !== "undefined") { + document.addEventListener("visibilitychange", markDirty); + } + window.addEventListener("storage", markDirty); + } catch { + // Non-browser or custom environment ignore + } + } } - async current(): Promise { + async current(options?: { forceCheck?: boolean }): Promise { const pass = this.storedPass(); - const shouldRetryClaim = + const cookieChanged = this.cookieSnapshotChanged(); + const force = options?.forceCheck ?? false; + const shouldClaim = this.isCookieMode() && pass !== null && - (pass !== this.lastClaimAttemptPass || Date.now() - this.lastClaimAttemptTime >= CLAIM_RETRY_INTERVAL_MS); + (force || this.identityCheckDirty || cookieChanged || pass !== this.lastClaimAttemptPass); - if (!this.cached || shouldRetryClaim) { + if (!this.cached || shouldClaim) { await this.resolve(() => this.storedPass()); + this.identityCheckDirty = false; + this.updateCookieSnapshot(); } return this.cached; } /** After a 401: whatever we sent is no good, so get another. */ async renew(): Promise { + this.identityCheckDirty = true; return this.resolve(() => this.issuePass()); } @@ -97,7 +117,8 @@ export class TokenSource { // that no longer applies (e.g. the old tokenProvider). this.pending = null; this.lastClaimAttemptPass = null; - this.lastClaimAttemptTime = 0; + this.identityCheckDirty = true; + this.updateCookieSnapshot(); } /** Drop this browser's identity entirely — a host app signing its user out. */ @@ -106,6 +127,17 @@ export class TokenSource { this.clearPass(); } + private cookieSnapshotChanged(): boolean { + if (typeof document === "undefined") return false; + return document.cookie !== this.lastCookieSnapshot; + } + + private updateCookieSnapshot(): void { + if (typeof document !== "undefined") { + this.lastCookieSnapshot = document.cookie; + } + } + /** Concurrent callers share one resolution. Without this, parallel requests * each fetch a token and each hand over the visitor pass. */ private resolve(fallback: () => string | null | Promise): Promise { @@ -159,14 +191,12 @@ export class TokenSource { if (hostToken !== null || (data?.conversations_moved ?? 0) > 0) { this.clearPass(); this.lastClaimAttemptPass = null; - this.lastClaimAttemptTime = 0; + this.identityCheckDirty = true; } else { this.lastClaimAttemptPass = pass; - this.lastClaimAttemptTime = Date.now(); } } else if (response.status === 401) { this.lastClaimAttemptPass = pass; - this.lastClaimAttemptTime = Date.now(); } else if (hostToken !== null && response.status >= 400 && response.status < 500) { this.clearPass(); }