Skip to content
Open
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
2 changes: 1 addition & 1 deletion examples/starter/agents.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ system:
defaults:
model:
provider: openai
name: qwen2.5:14b
name: openai/gpt-oss-20b
temperature: 0.0

execution:
Expand Down
51 changes: 49 additions & 2 deletions src/agent_manager/api/deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

BEARER_PREFIX = "Bearer "
UNAUTHENTICATED_DETAIL = "a verified identity is required"
VISITOR_PASS_HEADER = "X-Extra-Visitor-Pass"


@dataclass(frozen=True)
Expand All @@ -35,25 +36,71 @@ def get_caller_identity(request: Request) -> CallerIdentity:
return request.app.state.caller_identity


def get_principal(request: Request) -> Principal:
async def get_principal(request: Request) -> Principal:
"""The proven caller every conversation route authorizes against.

A bearer token where the caller supplied one, otherwise the host's session
cookie. Trusting that cookie is safe because it only reaches us from the
host's own origin: cross-site requests cannot read a JSON response, and the
widget's `application/json` writes are preflighted against a CORS allowlist
that denies by default.

After resolving an authenticated (non-anonymous) principal, the dependency
opportunistically adopts any anonymous history the widget attached via the
X-Extra-Visitor-Pass header. Failures are logged and silently swallowed so
an adoption hiccup never blocks the actual request.
"""
identity = get_caller_identity(request)
token = _select_token(request, identity)
if token is None:
raise HTTPException(status_code=401, detail=UNAUTHENTICATED_DETAIL)
try:
return identity.resolver.resolve(token)
principal = identity.resolver.resolve(token)
except TokenError as exc:
logger.warning("token verification failed: %s", exc)
raise HTTPException(status_code=401, detail=str(exc)) from None

# Server-side opportunistic hand-off.
# When both an authenticated principal AND a visitor pass arrive on the same
# request, adopt the anonymous history before the route runs. This removes
# the need for the frontend to repeatedly probe /auth/link in cookie mode.
if not principal.is_anonymous:
raw_pass = request.headers.get(VISITOR_PASS_HEADER)
if raw_pass:
await _try_adopt_visitor_history(request, raw_pass, principal, identity)

return principal


async def _try_adopt_visitor_history(
request: Request,
raw_pass: str,
principal: Principal,
identity: CallerIdentity,
) -> None:
"""Opportunistically adopt anonymous history. Failures never block the request.

Invalid/expired pass: TokenError is caught and logged at DEBUG — the
authenticated request continues normally and the stale pass is effectively
discarded.

Temporary DB failure: Exception is caught and logged at WARNING — the pass
is NOT marked consumed so the next request will retry automatically.

Already-adopted pass: link_anonymous_user runs an atomic UPDATE WHERE
linked_to_user_id IS NULL, which affects 0 rows and returns cleanly.
"""
try:
visitor = identity.resolver.anonymous.resolve(raw_pass)
except TokenError:
logger.debug("X-Extra-Visitor-Pass is invalid or expired; ignoring")
return
try:
service = get_service(request)
await service.link_anonymous(visitor, principal)
except Exception:
logger.warning("opportunistic anonymous history adoption failed", exc_info=True)


Service = Annotated[ConversationService, Depends(get_service)]
Caller = Annotated[Principal, Depends(get_principal)]
Expand Down
10 changes: 7 additions & 3 deletions src/agent_manager/api/static/widget/api/AgentChatClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, options?: { forceCheck?: boolean }): Promise<Response> {
let response = await this.send(path, init, await this.tokens.current({ forceCheck: options?.forceCheck }));
private async request(path: string, init?: RequestInit): Promise<Response> {
let response = await this.send(path, init, await this.tokens.current());
if (response.status === 401) {
response = await this.send(path, init, await this.tokens.renew());
}
Expand All @@ -57,6 +57,10 @@ export class AgentChatClient {
private send(path: string, init: RequestInit | undefined, token: string | null) {
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (token) headers.Authorization = `Bearer ${token}`;
// Carry the visitor pass on every request so the server can opportunistically
// adopt anonymous history the moment it sees an authenticated principal.
const pass = this.tokens.visitorPass;
if (pass) headers["X-Extra-Visitor-Pass"] = pass;
// `include` lets a same-origin deployment authenticate by the host's own
// cookie, which the widget can never read.
return fetch(`${this.endpoint}${path}`, { ...init, headers, credentials: "include" });
Expand All @@ -71,7 +75,7 @@ export class AgentChatClient {
async listConversations(limit = 20, cursor?: string | null): Promise<PaginatedThreads> {
const params = new URLSearchParams({ limit: String(limit) });
if (cursor) params.set("cursor", cursor);
const response = await this.request(`/conversations?${params.toString()}`, undefined, { forceCheck: true });
const response = await this.request(`/conversations?${params.toString()}`);

const data = await response.json();
const rawItems: Array<{ conversation_id: string; title?: string | null; last_message_at?: string | null }> =
Expand Down
104 changes: 16 additions & 88 deletions src/agent_manager/api/static/widget/auth/tokenSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ export interface TokenSourceOptions {
}

const PASS_ENDPOINT = "/auth/anonymous";
const LINK_ENDPOINT = "/auth/link";

export function visitorPassKey(endpoint: string): string {
return `agent-chat:pass:${endpoint}`;
Expand All @@ -46,10 +45,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 lastCookieSnapshot = typeof document !== "undefined" ? document.cookie : "";
private identityCheckDirty = true;
private readonly tokenUrl: string;
private readonly provider: TokenProvider | null;
private readonly storage: Storage;
Expand All @@ -65,43 +60,28 @@ 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(options?: { forceCheck?: boolean }): Promise<string | null> {
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);
/** The stored anonymous visitor pass, if one exists.
*
* Sent on every request as `X-Extra-Visitor-Pass` so the server can
* opportunistically adopt anonymous history the moment it sees an
* authenticated principal alongside it. The server validates and consumes it;
* the frontend just carries it passively until cleared post-adoption.
*/
get visitorPass(): string | null {
return this.storedPass();
}

if (!this.cached || shouldClaim) {
async current(): Promise<string | null> {
if (!this.cached) {
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<string | null> {
this.identityCheckDirty = true;
return this.resolve(() => this.issuePass());
}

Expand All @@ -116,9 +96,6 @@ 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. */
Expand All @@ -127,17 +104,6 @@ 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<string | null>): Promise<string | null> {
Expand All @@ -160,49 +126,11 @@ 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. */
/** A host token only. In cookie mode this returns null — the session cookie
* speaks for the caller directly. Anonymous history adoption is now handled
* server-side via the X-Extra-Visitor-Pass header. */
private async hostToken(): Promise<string | null> {
const token = await this.fromHost();
if (token || (this.isCookieMode() && this.storedPass())) {
await this.claimVisitorHistory(token);
}
return token;
}

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,
credentials: "include",
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();
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.
}
return this.fromHost();
}

private clearPass(): void {
Expand Down
Loading