Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 4 additions & 23 deletions src/agent_manager/api/static/widget.js
Original file line number Diff line number Diff line change
Expand Up @@ -52299,8 +52299,6 @@ 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 @@ -52309,9 +52307,7 @@ var TokenSource = class {
});
}
async current() {
if (!this.cached || this.isCookieMode() && this.storedPass() !== null) {
await this.resolve(() => this.storedPass());
}
if (!this.cached) await this.resolve(() => this.storedPass());
return this.cached;
}
/** After a 401: whatever we sent is no good, so get another. */
Expand Down Expand Up @@ -52348,38 +52344,23 @@ 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 || this.isCookieMode() && this.storedPass()) {
await this.claimVisitorHistory(token);
}
if (token) 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,
credentials: "include",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${hostToken}` },
body: JSON.stringify({ anonymous_token: pass })
});
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();
}
if (response.status < 500) this.clearPass();
} catch {
}
}
Expand Down
38 changes: 0 additions & 38 deletions src/agent_manager/api/static/widget.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -747,44 +747,6 @@ 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: 7 additions & 25 deletions src/agent_manager/api/static/widget/auth/tokenSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,6 @@ 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 @@ -66,9 +64,7 @@ export class TokenSource {
}

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

Expand Down Expand Up @@ -118,40 +114,26 @@ 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 || (this.isCookieMode() && this.storedPass())) {
await this.claimVisitorHistory(token);
}
if (token) await this.claimVisitorHistory(token);
return token;
}

private async claimVisitorHistory(hostToken: string | null): Promise<void> {
private async claimVisitorHistory(hostToken: string): 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,
credentials: "include",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${hostToken}` },
body: JSON.stringify({ anonymous_token: pass }),
});
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();
}
// 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();
} catch {
// Offline: keep the pass so the next page load retries the hand-off.
}
Expand Down
43 changes: 0 additions & 43 deletions tests/agent_manager/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -393,49 +393,6 @@ 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: 0 additions & 45 deletions tests/e2e/widget.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,6 @@ 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 @@ -1469,40 +1461,3 @@ 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