Skip to content
27 changes: 23 additions & 4 deletions src/agent_manager/api/static/widget.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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. */
Expand Down Expand Up @@ -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 {
}
}
Expand Down
38 changes: 38 additions & 0 deletions src/agent_manager/api/static/widget.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
{
Expand Down
32 changes: 25 additions & 7 deletions src/agent_manager/api/static/widget/auth/tokenSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -64,7 +66,9 @@ export class TokenSource {
}

async current(): Promise<string | null> {
if (!this.cached) await this.resolve(() => this.storedPass());
if (!this.cached || (this.isCookieMode() && this.storedPass() !== null)) {
await this.resolve(() => this.storedPass());
}
return this.cached;
}

Expand Down Expand Up @@ -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<string | null> {
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<void> {
private async claimVisitorHistory(hostToken: string | null): Promise<void> {
const pass = this.storedPass();
if (!pass) return;
try {
const headers: Record<string, string> = { "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.
}
Expand Down
43 changes: 43 additions & 0 deletions tests/agent_manager/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
45 changes: 45 additions & 0 deletions tests/e2e/widget.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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");
});
Loading