@iloveagents/foundry-agent 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -0
- package/dist/client/agui-runner.d.ts +53 -0
- package/dist/client/agui-runner.js +320 -0
- package/dist/client/runner-events.d.ts +54 -0
- package/dist/client/runner-events.js +1 -0
- package/dist/client/service-fetch.d.ts +112 -0
- package/dist/client/service-fetch.js +244 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +10 -0
- package/dist/msal/auth-config.d.ts +91 -0
- package/dist/msal/auth-config.js +70 -0
- package/dist/msal/auth-store.d.ts +95 -0
- package/dist/msal/auth-store.js +372 -0
- package/dist/msal/index.d.ts +3 -0
- package/dist/msal/index.js +3 -0
- package/dist/msal/token-fetch.d.ts +16 -0
- package/dist/msal/token-fetch.js +57 -0
- package/dist/store/citation-store.d.ts +42 -0
- package/dist/store/citation-store.js +14 -0
- package/dist/store/link-store.d.ts +29 -0
- package/dist/store/link-store.js +28 -0
- package/dist/store/streaming-status-store.d.ts +15 -0
- package/dist/store/streaming-status-store.js +9 -0
- package/dist/tools/registry.d.ts +48 -0
- package/dist/tools/registry.js +50 -0
- package/package.json +23 -9
- package/AGENTS.md +0 -91
- package/CHANGELOG.md +0 -180
- package/CLAUDE.md +0 -1
- package/src/__tests__/agui-runner.test.ts +0 -404
- package/src/__tests__/auth-store.test.ts +0 -596
- package/src/__tests__/citation-store.test.ts +0 -52
- package/src/__tests__/client-tool-registry.test.ts +0 -84
- package/src/__tests__/link-store.test.ts +0 -48
- package/src/__tests__/service-fetch.test.ts +0 -525
- package/src/__tests__/streaming-status-store.test.ts +0 -22
- package/src/__tests__/token-fetch.test.ts +0 -134
- package/src/client/agui-runner.ts +0 -382
- package/src/client/runner-events.ts +0 -27
- package/src/client/service-fetch.ts +0 -318
- package/src/index.ts +0 -27
- package/src/msal/auth-config.ts +0 -150
- package/src/msal/auth-store.ts +0 -517
- package/src/msal/index.ts +0 -14
- package/src/msal/token-fetch.ts +0 -68
- package/src/store/citation-store.ts +0 -52
- package/src/store/link-store.ts +0 -53
- package/src/store/streaming-status-store.ts +0 -21
- package/src/tools/registry.ts +0 -112
- package/tsconfig.json +0 -15
- package/vitest.config.ts +0 -8
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
import { createStore } from "zustand/vanilla";
|
|
2
|
+
import { getMsalInstance, getMsalConfig } from "./auth-config.js";
|
|
3
|
+
export class AuthInteractionRequiredError extends Error {
|
|
4
|
+
constructor(message, options = {}) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "AuthInteractionRequiredError";
|
|
7
|
+
this.code = options.code;
|
|
8
|
+
this.cause = options.cause;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
/** Recoverable error codes per MSAL.js docs — every one of these has the
|
|
12
|
+
* documented remedy "invoke an interactive API". */
|
|
13
|
+
const RECOVERABLE_ERROR_CODES = new Set([
|
|
14
|
+
// InteractionRequiredAuthError — canonical fallback case.
|
|
15
|
+
"interaction_required",
|
|
16
|
+
"login_required",
|
|
17
|
+
"consent_required",
|
|
18
|
+
// BrowserAuthError: monitor_window_timeout. Documented remedy includes
|
|
19
|
+
// "Invoke an interactive API" (Microsoft Learn → "Common errors in
|
|
20
|
+
// MSAL JS" → monitor_window_timeout → "Throttling" + "X-Frame-Options
|
|
21
|
+
// Deny"). Real-world trigger on Chrome 120+ is third-party-iframe
|
|
22
|
+
// storage partitioning blocking the silent SSO frame.
|
|
23
|
+
"monitor_window_timeout",
|
|
24
|
+
]);
|
|
25
|
+
/** `InteractionRequiredAuthError` always triggers the redirect — match by
|
|
26
|
+
* class name as a fallback when the error code isn't set. */
|
|
27
|
+
const INTERACTION_REQUIRED_NAME = "InteractionRequiredAuthError";
|
|
28
|
+
/** Module-level deduplication: when N parallel API calls all trip the
|
|
29
|
+
* recovery path simultaneously, the second-onwards must reuse the first
|
|
30
|
+
* one's promise rather than each issuing their own redirect. Without
|
|
31
|
+
* this, MSAL throws ``interaction_in_progress`` on N-1 of them and the
|
|
32
|
+
* cascading errors mask the actual recovery state. Mirrors the pattern
|
|
33
|
+
* in the msal-react ``useMsalAuthentication`` hook + the production
|
|
34
|
+
* patterns linked from Microsoft Learn's "Common errors" guide. */
|
|
35
|
+
let activeRecoveryPromise = null;
|
|
36
|
+
function isRecoverableAuthError(err) {
|
|
37
|
+
if (!err || typeof err !== "object")
|
|
38
|
+
return false;
|
|
39
|
+
const name = err.name ?? "";
|
|
40
|
+
const code = err.errorCode ?? "";
|
|
41
|
+
return name === INTERACTION_REQUIRED_NAME || RECOVERABLE_ERROR_CODES.has(code);
|
|
42
|
+
}
|
|
43
|
+
function authErrorName(err) {
|
|
44
|
+
return err && typeof err === "object" ? err.name : undefined;
|
|
45
|
+
}
|
|
46
|
+
function authErrorCode(err) {
|
|
47
|
+
return err && typeof err === "object" ? err.errorCode : undefined;
|
|
48
|
+
}
|
|
49
|
+
function authErrorMessage(err) {
|
|
50
|
+
if (err instanceof Error)
|
|
51
|
+
return err.message;
|
|
52
|
+
return err && typeof err === "object" ? err.message : undefined;
|
|
53
|
+
}
|
|
54
|
+
function describeAuthError(err) {
|
|
55
|
+
const parts = [
|
|
56
|
+
authErrorName(err) ? `name=${authErrorName(err)}` : undefined,
|
|
57
|
+
authErrorCode(err) ? `code=${authErrorCode(err)}` : undefined,
|
|
58
|
+
authErrorMessage(err) ? `message=${authErrorMessage(err)}` : undefined,
|
|
59
|
+
].filter(Boolean);
|
|
60
|
+
return parts.join(", ");
|
|
61
|
+
}
|
|
62
|
+
function createInteractionRequiredError(tokenError, redirectError) {
|
|
63
|
+
const tokenDetail = describeAuthError(tokenError) || "unknown token acquisition error";
|
|
64
|
+
const redirectDetail = redirectError ? describeAuthError(redirectError) : "";
|
|
65
|
+
if (redirectError) {
|
|
66
|
+
return new AuthInteractionRequiredError(`Authentication interaction required, but redirect failed (${redirectDetail}). Original token error: ${tokenDetail}.`, {
|
|
67
|
+
code: authErrorCode(redirectError) ?? authErrorCode(tokenError),
|
|
68
|
+
cause: redirectError,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
return new AuthInteractionRequiredError(`Authentication interaction required after token acquisition failed (${tokenDetail}).`, {
|
|
72
|
+
code: authErrorCode(tokenError),
|
|
73
|
+
cause: tokenError,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
/** Storage probe: does the browser have orphaned MSAL token entries with
|
|
77
|
+
* no matching account record? That state is the "stuck on 401 forever"
|
|
78
|
+
* signature — getAllAccounts() returns [] but the SPA still has tokens
|
|
79
|
+
* lingering from a previous session that recovery half-cleaned. Returns
|
|
80
|
+
* the list of MSAL keys to nuke; empty array means cache is coherent. */
|
|
81
|
+
function findOrphanedMsalKeys() {
|
|
82
|
+
if (typeof window === "undefined" || !window.localStorage)
|
|
83
|
+
return [];
|
|
84
|
+
const allKeys = [];
|
|
85
|
+
for (let i = 0; i < window.localStorage.length; i++) {
|
|
86
|
+
const k = window.localStorage.key(i);
|
|
87
|
+
if (k && k.startsWith("msal."))
|
|
88
|
+
allKeys.push(k);
|
|
89
|
+
}
|
|
90
|
+
const tokenKeys = allKeys.filter((k) => /\|(?:access|refresh|id)token\|/.test(k));
|
|
91
|
+
// No tokens = clean (or never logged in). Tokens AND empty
|
|
92
|
+
// getAllAccounts means orphaned — caller is the only one who knows
|
|
93
|
+
// the accounts state, so caller decides whether to nuke.
|
|
94
|
+
return tokenKeys.length > 0 ? allKeys : [];
|
|
95
|
+
}
|
|
96
|
+
function nukeMsalLocalStorage() {
|
|
97
|
+
if (typeof window === "undefined")
|
|
98
|
+
return;
|
|
99
|
+
for (const store of [window.localStorage, window.sessionStorage]) {
|
|
100
|
+
if (!store)
|
|
101
|
+
continue;
|
|
102
|
+
const toRemove = [];
|
|
103
|
+
for (let i = 0; i < store.length; i++) {
|
|
104
|
+
const k = store.key(i);
|
|
105
|
+
if (k && k.startsWith("msal."))
|
|
106
|
+
toRemove.push(k);
|
|
107
|
+
}
|
|
108
|
+
toRemove.forEach((k) => store.removeItem(k));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
async function startInteractiveRecovery(msal, account, scope, reason) {
|
|
112
|
+
// Deduplicate concurrent recovery attempts. Multiple in-flight API
|
|
113
|
+
// calls can simultaneously detect a stale token and trip recovery;
|
|
114
|
+
// without this, MSAL throws ``interaction_in_progress`` on every
|
|
115
|
+
// call after the first.
|
|
116
|
+
if (activeRecoveryPromise) {
|
|
117
|
+
return activeRecoveryPromise;
|
|
118
|
+
}
|
|
119
|
+
// Extract a claims challenge from the failure reason (if any).
|
|
120
|
+
// Production scenarios that surface here with a payload:
|
|
121
|
+
// * Conditional Access re-eval (MFA step-up required).
|
|
122
|
+
// * Continuous Access Evaluation revocation.
|
|
123
|
+
// * Device-compliance change mid-session.
|
|
124
|
+
// The service-fetch interceptor parses WWW-Authenticate when it
|
|
125
|
+
// can and stamps the payload onto the recovery reason — we pass
|
|
126
|
+
// it through to MSAL so the next token explicitly satisfies the
|
|
127
|
+
// challenge. Without this, Entra would silently re-issue the same
|
|
128
|
+
// already-rejected claims set and we'd loop right back.
|
|
129
|
+
const claims = extractClaimsChallenge(reason);
|
|
130
|
+
activeRecoveryPromise = (async () => {
|
|
131
|
+
let redirectError;
|
|
132
|
+
authStore.setState({ user: null, isAuthenticated: false });
|
|
133
|
+
// Step 0: strip a lingering MSAL error hash from the URL before
|
|
134
|
+
// attempting any redirect. MSAL.js's redirect primitives refuse
|
|
135
|
+
// to navigate with ``BrowserAuthError: block_iframe_reload`` when
|
|
136
|
+
// the current page URL still carries a previous silent-auth
|
|
137
|
+
// failure in its fragment (e.g.
|
|
138
|
+
// ``#error=interaction_required&error_description=AADSTS160021…``
|
|
139
|
+
// from an earlier ``handleRedirectPromise`` that surfaced
|
|
140
|
+
// "user session does not exist"). The SDK's own anti-loop guard
|
|
141
|
+
// sees the unconsumed error and aborts — leaving the user
|
|
142
|
+
// permanently stuck unless they manually clear browser state.
|
|
143
|
+
//
|
|
144
|
+
// We can defuse that guard cleanly by replacing the URL with a
|
|
145
|
+
// fragment-less copy via ``history.replaceState`` before the
|
|
146
|
+
// redirect call. MSAL then sees a "clean" page and proceeds.
|
|
147
|
+
// The original page state (path + search) is preserved so the
|
|
148
|
+
// user lands back where they started after re-auth.
|
|
149
|
+
//
|
|
150
|
+
// Reference:
|
|
151
|
+
// https://aka.ms/msal.js.errors#block_iframe_reload
|
|
152
|
+
// https://github.com/AzureAD/microsoft-authentication-library-for-js/issues/5623
|
|
153
|
+
stripStaleMsalErrorHash();
|
|
154
|
+
// Step 1: surgically evict the bad account record from MSAL's
|
|
155
|
+
// own token cache BEFORE redirecting. Two reasons:
|
|
156
|
+
//
|
|
157
|
+
// (a) The bad refresh token / claims set is what's causing the
|
|
158
|
+
// hard-auth failure. Leaving it in cache means MSAL's
|
|
159
|
+
// acquireTokenSilent (called by every other in-flight or
|
|
160
|
+
// post-redirect request) hands out the SAME poisoned token
|
|
161
|
+
// until the entry naturally expires.
|
|
162
|
+
// (b) Microsoft's "orphaned-state" anti-pattern is about
|
|
163
|
+
// clearing AFTER initiating a redirect (i.e. clearing while
|
|
164
|
+
// MSAL is mid-flight). Clearing the SPECIFIC account
|
|
165
|
+
// BEFORE the redirect is the documented pattern — see
|
|
166
|
+
// https://github.com/AzureAD/microsoft-authentication-library-for-js/issues/6840
|
|
167
|
+
// and the SDK's own ``IPublicClientApplication.clearCache``
|
|
168
|
+
// contract which accepts ``{ account }``.
|
|
169
|
+
//
|
|
170
|
+
// Wrapped in a try/catch so a cache-removal hiccup doesn't
|
|
171
|
+
// strand the user without ever attempting the redirect.
|
|
172
|
+
if (account) {
|
|
173
|
+
try {
|
|
174
|
+
await msal.clearCache({ account });
|
|
175
|
+
}
|
|
176
|
+
catch (err) {
|
|
177
|
+
// Telemetry only — keep flowing into the redirect path.
|
|
178
|
+
// eslint-disable-next-line no-console
|
|
179
|
+
console.warn("[msal-recovery] clearCache(account) failed; continuing with redirect", err);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// Step 2: ``acquireTokenRedirect`` with ``prompt: "login"`` —
|
|
183
|
+
// forces a fresh interactive authentication even if Entra still
|
|
184
|
+
// has a live SSO session for the user. Without ``prompt: "login"``
|
|
185
|
+
// the redirect silently round-trips through Entra and returns a
|
|
186
|
+
// token built from the SAME state we just tried to escape from
|
|
187
|
+
// (this is the "silent redirect loop" users actually experience:
|
|
188
|
+
// the page bounces to login.microsoftonline.com and back, still
|
|
189
|
+
// broken). The full re-auth path produces a fresh refresh-token
|
|
190
|
+
// + fresh claims bound to current server policy.
|
|
191
|
+
if (account) {
|
|
192
|
+
try {
|
|
193
|
+
await msal.acquireTokenRedirect({
|
|
194
|
+
scopes: [scope],
|
|
195
|
+
account,
|
|
196
|
+
prompt: "login",
|
|
197
|
+
...(claims ? { claims } : {}),
|
|
198
|
+
});
|
|
199
|
+
// If we reach here without navigating, MSAL settled the
|
|
200
|
+
// promise before browser navigation kicked in (test envs,
|
|
201
|
+
// blocked redirects, etc.). Fall through to loginRedirect.
|
|
202
|
+
}
|
|
203
|
+
catch (err) {
|
|
204
|
+
redirectError = err;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
// Step 3: full ``loginRedirect`` fallback. Same ``prompt: "login"``
|
|
208
|
+
// contract so even this path can't loop back into broken state.
|
|
209
|
+
// Used when:
|
|
210
|
+
// * No account was passed (orphaned-state caller already
|
|
211
|
+
// wiped localStorage upstream).
|
|
212
|
+
// * acquireTokenRedirect threw a non-navigation error.
|
|
213
|
+
// * acquireTokenRedirect settled without navigating.
|
|
214
|
+
try {
|
|
215
|
+
await msal.loginRedirect({
|
|
216
|
+
scopes: [scope],
|
|
217
|
+
prompt: "login",
|
|
218
|
+
...(claims ? { claims } : {}),
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
catch (err) {
|
|
222
|
+
redirectError = err;
|
|
223
|
+
}
|
|
224
|
+
throw createInteractionRequiredError(reason, redirectError);
|
|
225
|
+
})();
|
|
226
|
+
try {
|
|
227
|
+
return await activeRecoveryPromise;
|
|
228
|
+
}
|
|
229
|
+
finally {
|
|
230
|
+
activeRecoveryPromise = null;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Pull a CAE / Conditional-Access claims-challenge payload out of a
|
|
235
|
+
* recovery-trigger error. Two shapes are produced upstream:
|
|
236
|
+
*
|
|
237
|
+
* * The fetch interceptor (``service-fetch``) parses the
|
|
238
|
+
* ``WWW-Authenticate`` header on a 401 response and constructs
|
|
239
|
+
* an error whose ``claims`` (or ``cause.claims``) field carries
|
|
240
|
+
* the base64 payload it found.
|
|
241
|
+
* * MSAL.js's own ``InteractionRequiredAuthError`` instances expose
|
|
242
|
+
* ``claims`` directly when Azure returned one.
|
|
243
|
+
*
|
|
244
|
+
* Return ``undefined`` when none is present — callers should NOT
|
|
245
|
+
* pass an empty string to MSAL (the SDK treats that as "ignore" but
|
|
246
|
+
* older versions choke on the empty key).
|
|
247
|
+
*/
|
|
248
|
+
/**
|
|
249
|
+
* Replace the page's URL fragment with an empty one when it carries
|
|
250
|
+
* a leftover MSAL/Entra error response (``#error=…``,
|
|
251
|
+
* ``#error_description=…``, ``#error_code=…``, or
|
|
252
|
+
* ``#error_uri=…``). Preserves path + query string so the user
|
|
253
|
+
* lands back on the same page after the imminent recovery redirect.
|
|
254
|
+
*
|
|
255
|
+
* Why this is necessary:
|
|
256
|
+
* MSAL.js's redirect primitives include an anti-loop guard that
|
|
257
|
+
* refuses to navigate when the current URL fragment encodes a
|
|
258
|
+
* prior auth error — the SDK raises ``block_iframe_reload``
|
|
259
|
+
* instead of redirecting. In production we hit this when an
|
|
260
|
+
* earlier ``acquireTokenSilent`` had its hidden iframe receive an
|
|
261
|
+
* ``AADSTS160021: Application requested a user session which does
|
|
262
|
+
* not exist`` response, MSAL surfaced it via
|
|
263
|
+
* ``handleRedirectPromise`` but left the fragment intact.
|
|
264
|
+
* Subsequent recovery redirects all aborted with
|
|
265
|
+
* ``block_iframe_reload`` and the user was stuck until they
|
|
266
|
+
* manually cleared browser storage.
|
|
267
|
+
*
|
|
268
|
+
* This helper is a no-op on non-browser environments and a no-op
|
|
269
|
+
* when no error fragment is present.
|
|
270
|
+
*/
|
|
271
|
+
function stripStaleMsalErrorHash() {
|
|
272
|
+
if (typeof window === "undefined" || typeof history === "undefined")
|
|
273
|
+
return;
|
|
274
|
+
const hash = window.location.hash || "";
|
|
275
|
+
if (!hash)
|
|
276
|
+
return;
|
|
277
|
+
// ``hash`` is e.g. ``#error=interaction_required&error_description=…``.
|
|
278
|
+
// We look for any of the error-shaped keys MSAL emits.
|
|
279
|
+
if (!/(?:^|[#&])error(?:_description|_code|_uri)?=/.test(hash))
|
|
280
|
+
return;
|
|
281
|
+
try {
|
|
282
|
+
const cleanUrl = window.location.pathname + window.location.search;
|
|
283
|
+
history.replaceState(null, "", cleanUrl);
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
// Some embedded environments lock down history.replaceState
|
|
287
|
+
// (sandboxed iframes etc.). If that happens we still try the
|
|
288
|
+
// redirect — MSAL may or may not succeed; nothing we can do.
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
function extractClaimsChallenge(reason) {
|
|
292
|
+
if (!reason || typeof reason !== "object")
|
|
293
|
+
return undefined;
|
|
294
|
+
const direct = reason.claims;
|
|
295
|
+
if (typeof direct === "string" && direct.length > 0)
|
|
296
|
+
return direct;
|
|
297
|
+
const cause = reason.cause;
|
|
298
|
+
if (cause && typeof cause === "object") {
|
|
299
|
+
const fromCause = cause.claims;
|
|
300
|
+
if (typeof fromCause === "string" && fromCause.length > 0) {
|
|
301
|
+
return fromCause;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return undefined;
|
|
305
|
+
}
|
|
306
|
+
export const authStore = createStore((set) => ({
|
|
307
|
+
user: null,
|
|
308
|
+
isAuthenticated: false,
|
|
309
|
+
signIn: (user) => set({ user, isAuthenticated: true }),
|
|
310
|
+
signOut: () => {
|
|
311
|
+
set({ user: null, isAuthenticated: false });
|
|
312
|
+
const msal = getMsalInstance();
|
|
313
|
+
if (msal) {
|
|
314
|
+
msal.logoutRedirect();
|
|
315
|
+
}
|
|
316
|
+
},
|
|
317
|
+
getAccessToken: async (_audience = "api", options) => {
|
|
318
|
+
const msal = getMsalInstance();
|
|
319
|
+
const config = getMsalConfig();
|
|
320
|
+
if (!msal || !config)
|
|
321
|
+
return null;
|
|
322
|
+
const accounts = msal.getAllAccounts();
|
|
323
|
+
const scope = config.apiScope;
|
|
324
|
+
// Orphaned-state recovery: ``getAllAccounts()`` returns [] BUT
|
|
325
|
+
// localStorage still holds MSAL token entries. That happens when a
|
|
326
|
+
// previous recovery attempt cleared the account record but its
|
|
327
|
+
// redirect didn't navigate (browser policy, popup blocker, async
|
|
328
|
+
// race). The SPA renders "logged in" but every API call goes
|
|
329
|
+
// anonymous. Nuke the orphans + force a fresh redirect so the
|
|
330
|
+
// user gets unstuck without manually clearing browser storage.
|
|
331
|
+
if (accounts.length === 0) {
|
|
332
|
+
const orphans = findOrphanedMsalKeys();
|
|
333
|
+
if (orphans.length > 0) {
|
|
334
|
+
nukeMsalLocalStorage();
|
|
335
|
+
return startInteractiveRecovery(msal, null, scope, new Error(`Detected orphaned MSAL state (${orphans.length} cache entries with no account record); cleaned up + redirecting`));
|
|
336
|
+
}
|
|
337
|
+
// Truly logged out — ``AuthGuard`` will call ``loginRedirect`` on
|
|
338
|
+
// its next render. Don't double-redirect from here.
|
|
339
|
+
return null;
|
|
340
|
+
}
|
|
341
|
+
const forceRefresh = options?.forceRefresh === true;
|
|
342
|
+
try {
|
|
343
|
+
const result = await msal.acquireTokenSilent({
|
|
344
|
+
scopes: [scope],
|
|
345
|
+
account: accounts[0],
|
|
346
|
+
forceRefresh,
|
|
347
|
+
});
|
|
348
|
+
return result.accessToken;
|
|
349
|
+
}
|
|
350
|
+
catch (err) {
|
|
351
|
+
if (isRecoverableAuthError(err)) {
|
|
352
|
+
return startInteractiveRecovery(msal, accounts[0], scope, err);
|
|
353
|
+
}
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
},
|
|
357
|
+
recoverFromHardAuthFailure: async (reason) => {
|
|
358
|
+
// Fetch interceptor's escape hatch: silent path produced a token
|
|
359
|
+
// but the resource server rejected it (audience drift, conditional-
|
|
360
|
+
// access re-eval, claims challenge, tenant-policy change, etc.).
|
|
361
|
+
// Same recovery primitive as the silent-failure path —
|
|
362
|
+
// acquireTokenRedirect → loginRedirect — invoked from the
|
|
363
|
+
// resource-server signal rather than an MSAL exception.
|
|
364
|
+
const msal = getMsalInstance();
|
|
365
|
+
const config = getMsalConfig();
|
|
366
|
+
if (!msal || !config) {
|
|
367
|
+
throw new AuthInteractionRequiredError("Authentication interaction required (no MSAL instance configured).", { cause: reason });
|
|
368
|
+
}
|
|
369
|
+
const accounts = msal.getAllAccounts();
|
|
370
|
+
return startInteractiveRecovery(msal, accounts[0] ?? null, config.apiScope, reason);
|
|
371
|
+
},
|
|
372
|
+
}));
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { AuthInteractionRequiredError, authStore, type AuthUser, } from "./auth-store.js";
|
|
2
|
+
export { type MsalAuthConfig, type MsalAccountInfo, type MsalClientApplication, initializeMsal, getMsalInstance, getMsalConfig, } from "./auth-config.js";
|
|
3
|
+
export { tokenFetch } from "./token-fetch.js";
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token-aware fetch wrapper.
|
|
3
|
+
*
|
|
4
|
+
* Acquires a Bearer token from MSAL (if configured) and attaches it to
|
|
5
|
+
* every outgoing request. All requests use the API scope — the API backend
|
|
6
|
+
* acts as a gateway and exchanges tokens server-side via OBO when calling
|
|
7
|
+
* downstream services (e.g., Spaces).
|
|
8
|
+
*
|
|
9
|
+
* When MSAL is not configured, behaves identically to native `fetch()`.
|
|
10
|
+
*
|
|
11
|
+
* Long-lived tab recovery: on 401 the wrapper retries once with
|
|
12
|
+
* ``forceRefresh: true`` so the resource server doesn't keep seeing a
|
|
13
|
+
* stale-but-cached access token. See ``service-fetch.ts`` for the same
|
|
14
|
+
* pattern with URL rewriting + the deeper rationale.
|
|
15
|
+
*/
|
|
16
|
+
export declare function tokenFetch(input: string | URL | Request, init?: RequestInit): Promise<Response>;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token-aware fetch wrapper.
|
|
3
|
+
*
|
|
4
|
+
* Acquires a Bearer token from MSAL (if configured) and attaches it to
|
|
5
|
+
* every outgoing request. All requests use the API scope — the API backend
|
|
6
|
+
* acts as a gateway and exchanges tokens server-side via OBO when calling
|
|
7
|
+
* downstream services (e.g., Spaces).
|
|
8
|
+
*
|
|
9
|
+
* When MSAL is not configured, behaves identically to native `fetch()`.
|
|
10
|
+
*
|
|
11
|
+
* Long-lived tab recovery: on 401 the wrapper retries once with
|
|
12
|
+
* ``forceRefresh: true`` so the resource server doesn't keep seeing a
|
|
13
|
+
* stale-but-cached access token. See ``service-fetch.ts`` for the same
|
|
14
|
+
* pattern with URL rewriting + the deeper rationale.
|
|
15
|
+
*/
|
|
16
|
+
import { authStore } from "./auth-store.js";
|
|
17
|
+
export async function tokenFetch(input, init) {
|
|
18
|
+
async function dispatch(forceRefreshToken) {
|
|
19
|
+
const token = await authStore
|
|
20
|
+
.getState()
|
|
21
|
+
.getAccessToken("api", forceRefreshToken ? { forceRefresh: true } : undefined);
|
|
22
|
+
// Clone Request inputs per-attempt: ReadableStream bodies are single-
|
|
23
|
+
// consume, so the 401 retry would otherwise see an empty body.
|
|
24
|
+
// ``input.clone()`` returns a fresh Request whose body stream is
|
|
25
|
+
// independent of the original.
|
|
26
|
+
const target = input instanceof Request ? input.clone() : input;
|
|
27
|
+
if (!token) {
|
|
28
|
+
return fetch(target, init);
|
|
29
|
+
}
|
|
30
|
+
const headers = new Headers(input instanceof Request ? input.headers : undefined);
|
|
31
|
+
if (init?.headers) {
|
|
32
|
+
new Headers(init.headers).forEach((value, key) => headers.set(key, value));
|
|
33
|
+
}
|
|
34
|
+
headers.set("Authorization", `Bearer ${token}`);
|
|
35
|
+
return fetch(target, { ...init, headers });
|
|
36
|
+
}
|
|
37
|
+
const response = await dispatch(false);
|
|
38
|
+
if (response.status !== 401) {
|
|
39
|
+
return response;
|
|
40
|
+
}
|
|
41
|
+
response.body?.cancel().catch(() => undefined);
|
|
42
|
+
const retried = await dispatch(true);
|
|
43
|
+
if (retried.status === 401) {
|
|
44
|
+
// Second 401 after a force-refresh retry — the silent token path
|
|
45
|
+
// can't recover this (refresh-token grant produces the same
|
|
46
|
+
// identity claims that just got rejected). Kick off interactive
|
|
47
|
+
// recovery so a fresh ``loginRedirect`` mints a token bound to
|
|
48
|
+
// current server policy. ``recoverFromHardAuthFailure`` throws
|
|
49
|
+
// once the redirect is in flight so we don't return a stale 401
|
|
50
|
+
// the caller might handle as a real failure.
|
|
51
|
+
retried.body?.cancel().catch(() => undefined);
|
|
52
|
+
await authStore
|
|
53
|
+
.getState()
|
|
54
|
+
.recoverFromHardAuthFailure(new Error("API returned 401 after force-refresh retry"));
|
|
55
|
+
}
|
|
56
|
+
return retried;
|
|
57
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** Citation result shape — matches the search result format. */
|
|
2
|
+
export interface CitationResult {
|
|
3
|
+
chunk_id: string;
|
|
4
|
+
entity_id: string;
|
|
5
|
+
entity_name: string;
|
|
6
|
+
content: string;
|
|
7
|
+
page_number: number;
|
|
8
|
+
bounding_regions: string;
|
|
9
|
+
/** Semantic reranker score (0-4) or RRF score (~0.03) */
|
|
10
|
+
score: number;
|
|
11
|
+
}
|
|
12
|
+
/** Handler for citation clicks — registered by feature modules (e.g., SPACES). */
|
|
13
|
+
export interface CitationHandler {
|
|
14
|
+
openCitation: (result: CitationResult) => void;
|
|
15
|
+
}
|
|
16
|
+
interface CitationState {
|
|
17
|
+
/** Current search results (from the most recent search tool call) */
|
|
18
|
+
results: CitationResult[];
|
|
19
|
+
/** Optional handler for opening citations (registered by feature modules) */
|
|
20
|
+
handler: CitationHandler | null;
|
|
21
|
+
/** Store search results for citation resolution */
|
|
22
|
+
setResults: (results: CitationResult[]) => void;
|
|
23
|
+
/** Register a handler for citation click actions */
|
|
24
|
+
setHandler: (handler: CitationHandler) => void;
|
|
25
|
+
/**
|
|
26
|
+
* Reset per-conversation state. Clears ``results`` only — the handler
|
|
27
|
+
* is module-level state registered once at app boot by feature modules
|
|
28
|
+
* (e.g. ``registerSpacesCitationHandler``) and must persist across
|
|
29
|
+
* conversations. Wiping it on "New Thread" produced inert ``[n]``
|
|
30
|
+
* markers for the rest of the session, since the registration guard
|
|
31
|
+
* (``registerOnce`` in spaces ``use-spaces-init``) suppresses re-binding.
|
|
32
|
+
*/
|
|
33
|
+
clear: () => void;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Vanilla store for the citation cache so `[n]` markers in chat messages
|
|
37
|
+
* resolve to clickable deep links. Search tool calls populate results; the
|
|
38
|
+
* markdown renderer looks them up by index. A `CitationHandler` (registered
|
|
39
|
+
* by feature modules) handles the click action.
|
|
40
|
+
*/
|
|
41
|
+
export declare const citationStore: import("zustand/vanilla").StoreApi<CitationState>;
|
|
42
|
+
export {};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { createStore } from "zustand/vanilla";
|
|
2
|
+
/**
|
|
3
|
+
* Vanilla store for the citation cache so `[n]` markers in chat messages
|
|
4
|
+
* resolve to clickable deep links. Search tool calls populate results; the
|
|
5
|
+
* markdown renderer looks them up by index. A `CitationHandler` (registered
|
|
6
|
+
* by feature modules) handles the click action.
|
|
7
|
+
*/
|
|
8
|
+
export const citationStore = createStore((set) => ({
|
|
9
|
+
results: [],
|
|
10
|
+
handler: null,
|
|
11
|
+
setResults: (results) => set({ results }),
|
|
12
|
+
setHandler: (handler) => set({ handler }),
|
|
13
|
+
clear: () => set({ results: [] }),
|
|
14
|
+
}));
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export interface LinkHandler {
|
|
2
|
+
canHandle: (href: string) => boolean;
|
|
3
|
+
normalizeHref?: (href: string) => string | null;
|
|
4
|
+
openLink: (href: string) => void;
|
|
5
|
+
}
|
|
6
|
+
export interface ResolvedLinkHandler {
|
|
7
|
+
handler: LinkHandler;
|
|
8
|
+
href: string;
|
|
9
|
+
}
|
|
10
|
+
interface LinkState {
|
|
11
|
+
/**
|
|
12
|
+
* Latest registered handler kept for backwards-compatible consumers.
|
|
13
|
+
* New code should use `handlers` or `resolveLinkHandler`.
|
|
14
|
+
*/
|
|
15
|
+
handler: LinkHandler | null;
|
|
16
|
+
handlers: LinkHandler[];
|
|
17
|
+
setHandler: (handler: LinkHandler) => void;
|
|
18
|
+
clear: () => void;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Generic markdown-link extension point.
|
|
22
|
+
*
|
|
23
|
+
* Feature modules register a handler for their own deep-link namespace
|
|
24
|
+
* (for example Spaces handles `/spaces/...`). The chat renderer stays
|
|
25
|
+
* module-agnostic while still letting app-local links navigate in-place.
|
|
26
|
+
*/
|
|
27
|
+
export declare const linkStore: import("zustand/vanilla").StoreApi<LinkState>;
|
|
28
|
+
export declare function resolveLinkHandler(rawHref: string): ResolvedLinkHandler | null;
|
|
29
|
+
export {};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { createStore } from "zustand/vanilla";
|
|
2
|
+
/**
|
|
3
|
+
* Generic markdown-link extension point.
|
|
4
|
+
*
|
|
5
|
+
* Feature modules register a handler for their own deep-link namespace
|
|
6
|
+
* (for example Spaces handles `/spaces/...`). The chat renderer stays
|
|
7
|
+
* module-agnostic while still letting app-local links navigate in-place.
|
|
8
|
+
*/
|
|
9
|
+
export const linkStore = createStore((set) => ({
|
|
10
|
+
handler: null,
|
|
11
|
+
handlers: [],
|
|
12
|
+
setHandler: (handler) => set((state) => ({
|
|
13
|
+
handler,
|
|
14
|
+
handlers: [...state.handlers.filter((item) => item !== handler), handler],
|
|
15
|
+
})),
|
|
16
|
+
clear: () => set({ handler: null, handlers: [] }),
|
|
17
|
+
}));
|
|
18
|
+
export function resolveLinkHandler(rawHref) {
|
|
19
|
+
const handlers = linkStore.getState().handlers;
|
|
20
|
+
for (let i = handlers.length - 1; i >= 0; i -= 1) {
|
|
21
|
+
const handler = handlers[i];
|
|
22
|
+
const href = handler.normalizeHref ? handler.normalizeHref(rawHref) : rawHref;
|
|
23
|
+
if (href && handler.canHandle(href)) {
|
|
24
|
+
return { handler, href };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Streaming status emitted by the AG-UI runner. */
|
|
2
|
+
export interface StreamingStatus {
|
|
3
|
+
status: "thinking" | "calling" | "streaming" | "idle";
|
|
4
|
+
toolName?: string;
|
|
5
|
+
}
|
|
6
|
+
interface StreamingStatusState {
|
|
7
|
+
streamingStatus: StreamingStatus;
|
|
8
|
+
setStreamingStatus: (status: StreamingStatus) => void;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Vanilla store for streaming status. Use `useStore(streamingStatusStore, ...)`
|
|
12
|
+
* from the React entry of zustand for hook-style subscriptions.
|
|
13
|
+
*/
|
|
14
|
+
export declare const streamingStatusStore: import("zustand/vanilla").StoreApi<StreamingStatusState>;
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { createStore } from "zustand/vanilla";
|
|
2
|
+
/**
|
|
3
|
+
* Vanilla store for streaming status. Use `useStore(streamingStatusStore, ...)`
|
|
4
|
+
* from the React entry of zustand for hook-style subscriptions.
|
|
5
|
+
*/
|
|
6
|
+
export const streamingStatusStore = createStore((set) => ({
|
|
7
|
+
streamingStatus: { status: "idle" },
|
|
8
|
+
setStreamingStatus: (streamingStatus) => set({ streamingStatus }),
|
|
9
|
+
}));
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Tool as AGUITool } from "@ag-ui/core";
|
|
2
|
+
/**
|
|
3
|
+
* Dynamic Client Tool Registry — vanilla zustand store for client-side AG-UI
|
|
4
|
+
* tools.
|
|
5
|
+
*
|
|
6
|
+
* Tools are split into two categories:
|
|
7
|
+
* - **Global tools**: always available (navigate, set_theme, open_panel, ...)
|
|
8
|
+
* - **Page tools**: registered by the active page via the host shell, removed
|
|
9
|
+
* on unmount.
|
|
10
|
+
*
|
|
11
|
+
* The runner reads from this store on every request to get the current tool
|
|
12
|
+
* schemas and executors. Pages declare their capabilities by calling
|
|
13
|
+
* `registerPageTools()` — no need to edit core files.
|
|
14
|
+
*/
|
|
15
|
+
export interface ClientToolEntry {
|
|
16
|
+
/** Tool name (must be unique across global + page tools) */
|
|
17
|
+
name: string;
|
|
18
|
+
/** Tool description for the LLM */
|
|
19
|
+
description: string;
|
|
20
|
+
/** JSON Schema for tool parameters */
|
|
21
|
+
parameters: Record<string, unknown>;
|
|
22
|
+
/** Execution function — called by the runner at TOOL_CALL_END */
|
|
23
|
+
execute: (argsJson: string) => Promise<string>;
|
|
24
|
+
}
|
|
25
|
+
interface ClientToolRegistryState {
|
|
26
|
+
globalTools: Map<string, ClientToolEntry>;
|
|
27
|
+
pageTools: Map<string, ClientToolEntry>;
|
|
28
|
+
/** Register a global tool (called once at app init) */
|
|
29
|
+
registerGlobal: (tool: ClientToolEntry) => void;
|
|
30
|
+
/** Register page-scoped tools (called by the host on mount) */
|
|
31
|
+
registerPageTools: (tools: ClientToolEntry[]) => void;
|
|
32
|
+
/** Remove page-scoped tools (called by the host on unmount) */
|
|
33
|
+
clearPageTools: () => void;
|
|
34
|
+
/** Get all active tool schemas for the runner */
|
|
35
|
+
getActiveSchemas: () => AGUITool[];
|
|
36
|
+
/** Check if a tool name is registered */
|
|
37
|
+
isRegistered: (name: string) => boolean;
|
|
38
|
+
/** Execute a registered tool by name */
|
|
39
|
+
executeTool: (name: string, argsJson: string) => Promise<string>;
|
|
40
|
+
}
|
|
41
|
+
export declare const clientToolRegistry: import("zustand/vanilla").StoreApi<ClientToolRegistryState>;
|
|
42
|
+
/** Read-only registry view — what the runner consumes. */
|
|
43
|
+
export interface ToolRegistry {
|
|
44
|
+
isRegistered: (name: string) => boolean;
|
|
45
|
+
executeTool: (name: string, argsJson: string) => Promise<string>;
|
|
46
|
+
getActiveSchemas: () => AGUITool[];
|
|
47
|
+
}
|
|
48
|
+
export {};
|