diff --git a/src/agent_manager/api/static/widget.js b/src/agent_manager/api/static/widget.js index e9614007..9ad731af 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 55e2cf37..9c9eee4c 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({ @@ -1461,3 +1469,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"); +});