diff --git a/src/agent_manager/api/static/widget.js b/src/agent_manager/api/static/widget.js index b69fa3b5..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) => ({ @@ -52299,19 +52299,45 @@ 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.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() { - if (!this.cached) await this.resolve(() => this.storedPass()); + async current(options) { + const pass = this.storedPass(); + 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 @@ -52322,12 +52348,24 @@ var TokenSource = class { this.generation += 1; this.cached = null; this.pending = null; + this.lastClaimAttemptPass = null; + 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) { @@ -52344,23 +52382,44 @@ 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(); + this.lastClaimAttemptPass = null; + this.identityCheckDirty = true; + } else { + this.lastClaimAttemptPass = pass; + } + } else if (response.status === 401) { + this.lastClaimAttemptPass = pass; + } 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 2feb6279..a46fb67d 100644 --- a/src/agent_manager/api/static/widget.test.mjs +++ b/src/agent_manager/api/static/widget.test.mjs @@ -747,6 +747,50 @@ 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"); + + // 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 (zero-code: no tokens.reset() or refreshIdentity() called) + loggedInViaCookie = true; + 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 zero-code 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/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 573fab22..71a6c918 100644 --- a/src/agent_manager/api/static/widget/auth/tokenSource.ts +++ b/src/agent_manager/api/static/widget/auth/tokenSource.ts @@ -46,6 +46,10 @@ 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 lastCookieSnapshot = typeof document !== "undefined" ? document.cookie : ""; + private identityCheckDirty = true; private readonly tokenUrl: string; private readonly provider: TokenProvider | null; private readonly storage: Storage; @@ -61,15 +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 { - if (!this.cached) await this.resolve(() => this.storedPass()); + async current(options?: { forceCheck?: boolean }): Promise { + const pass = this.storedPass(); + 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(): Promise { + this.identityCheckDirty = true; return this.resolve(() => this.issuePass()); } @@ -84,6 +116,9 @@ 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.identityCheckDirty = true; + this.updateCookieSnapshot(); } /** Drop this browser's identity entirely — a host app signing its user out. */ @@ -92,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 { @@ -114,26 +160,46 @@ 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(); + this.lastClaimAttemptPass = null; + this.identityCheckDirty = true; + } else { + this.lastClaimAttemptPass = pass; + } + } else if (response.status === 401) { + this.lastClaimAttemptPass = pass; + } else if (hostToken !== null && response.status >= 400 && response.status < 500) { + 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"); +});