@guuey/agent-client 0.1.0 → 0.2.1
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/dist/block-ui.d.ts +6 -1
- package/dist/block-ui.d.ts.map +1 -1
- package/dist/block-ui.js +21 -3
- package/dist/card-mount.d.ts +69 -0
- package/dist/card-mount.d.ts.map +1 -0
- package/dist/card-mount.js +83 -0
- package/dist/ggui-render.d.ts +161 -0
- package/dist/ggui-render.d.ts.map +1 -0
- package/dist/ggui-render.js +164 -0
- package/dist/history.d.ts +12 -0
- package/dist/history.d.ts.map +1 -1
- package/dist/history.js +17 -0
- package/dist/index.d.ts +6 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -2
- package/dist/link-prompt.d.ts +22 -0
- package/dist/link-prompt.d.ts.map +1 -0
- package/dist/link-prompt.js +29 -0
- package/dist/sse.d.ts +19 -0
- package/dist/sse.d.ts.map +1 -1
- package/dist/sse.js +38 -0
- package/dist/types.d.ts +82 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +4 -1
- package/dist/useAgentInvoke.d.ts.map +1 -1
- package/dist/useAgentInvoke.js +99 -12
- package/dist/web-adapters.d.ts +78 -14
- package/dist/web-adapters.d.ts.map +1 -1
- package/dist/web-adapters.js +115 -21
- package/package.json +2 -2
- package/src/block-ui.ts +19 -3
- package/src/card-mount.ts +101 -0
- package/src/ggui-render.ts +270 -0
- package/src/history.ts +17 -0
- package/src/index.ts +27 -0
- package/src/link-prompt.ts +34 -0
- package/src/sse.ts +36 -0
- package/src/types.ts +85 -3
- package/src/useAgentInvoke.ts +106 -12
- package/src/web-adapters.ts +169 -24
package/src/web-adapters.ts
CHANGED
|
@@ -12,7 +12,7 @@ import type {
|
|
|
12
12
|
InvokeTransport,
|
|
13
13
|
ThreadIdStore,
|
|
14
14
|
} from "./types";
|
|
15
|
-
import { fetchThreadHistory } from "./history";
|
|
15
|
+
import { fetchThreadHistory, HistoryUnauthorizedError } from "./history";
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
18
|
* Thrown when the pod returns a non-2xx status on `/agent/invoke` (before any
|
|
@@ -61,16 +61,66 @@ export function webGenerateId(): string {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
/**
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
64
|
+
* Header carrying a caller-owned anonymous guest secret. A LOCAL MIRROR of the
|
|
65
|
+
* two server-side constants — the pod's `GUEST_HEADER_NAME`
|
|
66
|
+
* (`backend/services/nocode-runtime/src/identity.ts`) and the read plane's
|
|
67
|
+
* `GUEST_HEADER` (`backend/amplify/functions/publicApi/identity.ts`) — because
|
|
68
|
+
* this is a published npm package and cannot take a `@guuey-private` dep (same
|
|
69
|
+
* arrangement as `@guuey/host`'s mirrored fs-contract constants). The string is
|
|
70
|
+
* a wire contract: both planes already advertise it in
|
|
71
|
+
* `Access-Control-Allow-Headers`, so changing it is a breaking protocol change,
|
|
72
|
+
* not a rename.
|
|
73
|
+
*/
|
|
74
|
+
const GUEST_HEADER = "x-guuey-guest";
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A well-formed guest secret: exactly 32 bytes as 64 LOWERCASE hex chars —
|
|
78
|
+
* the shape `crypto.getRandomValues` + hex-encoding mints.
|
|
79
|
+
*
|
|
80
|
+
* Deliberately stricter than the server's `/^[a-f0-9]{64}$/i` (pod
|
|
81
|
+
* `identity.ts`, publicApi `identity.ts`): both sides lowercase before
|
|
82
|
+
* hashing, so an uppercase secret would in fact be accepted, but the only
|
|
83
|
+
* supported mint path emits lowercase and a non-canonical value means the
|
|
84
|
+
* caller's storage is not what this adapter expects. Anything that fails is
|
|
85
|
+
* IGNORED — the request falls through to cookie mode rather than sending a
|
|
86
|
+
* secret the two identity planes might key differently.
|
|
87
|
+
*/
|
|
88
|
+
const GUEST_SECRET_RE = /^[0-9a-f]{64}$/;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Narrow a caller-supplied guest secret to a value that is safe to put on the
|
|
92
|
+
* wire, or `null`. The single gate for the header: every write of
|
|
93
|
+
* {@link GUEST_HEADER} in this module goes through it, so a malformed secret
|
|
94
|
+
* can never reach a request. The value is never logged (here or anywhere on
|
|
95
|
+
* this path) — it IS the anonymous identity, so a leak is an impersonation.
|
|
96
|
+
*/
|
|
97
|
+
function sendableGuestSecret(secret: string | null | undefined): string | null {
|
|
98
|
+
return typeof secret === "string" && GUEST_SECRET_RE.test(secret) ? secret : null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Web SSE transport. Exactly ONE identity carrier per request, in order:
|
|
103
|
+
*
|
|
104
|
+
* 1. `accessToken` → `Authorization: Bearer` — the pod identifies the caller
|
|
105
|
+
* by their verified access token (the same identity the history read
|
|
106
|
+
* plane uses, so persisted threads round-trip on reload).
|
|
107
|
+
* 2. a well-formed `guestSecret` → `x-guuey-guest` — the caller owns and
|
|
108
|
+
* persists its own anonymous secret. The path for hosts with no usable
|
|
109
|
+
* cookie jar: React-Native, and the embedded widget, whose third-party
|
|
110
|
+
* iframe cannot rely on the pod's cookie surviving browser partitioning.
|
|
111
|
+
* The pod never mints a cookie for a header client.
|
|
112
|
+
* 3. neither → `credentials: "include"`, which round-trips the HttpOnly
|
|
113
|
+
* `guuey_guest` cookie the pod mints for anonymous browser callers.
|
|
114
|
+
*
|
|
115
|
+
* Never two at once: a bearer wins over a guest secret, and a request that
|
|
116
|
+
* carries either header does NOT also send cookie credentials.
|
|
117
|
+
*
|
|
69
118
|
* Reads the body via `ReadableStream.getReader()` (browser).
|
|
70
119
|
*/
|
|
71
120
|
export async function* fetchStreamTransport(
|
|
72
121
|
req: InvokeRequest,
|
|
73
122
|
accessToken?: string | null,
|
|
123
|
+
guestSecret?: string | null,
|
|
74
124
|
): AsyncGenerator<string> {
|
|
75
125
|
const headers: Record<string, string> = {
|
|
76
126
|
"Content-Type": "application/json",
|
|
@@ -82,8 +132,11 @@ export async function* fetchStreamTransport(
|
|
|
82
132
|
headers,
|
|
83
133
|
body: JSON.stringify(req.body),
|
|
84
134
|
};
|
|
135
|
+
const guest = sendableGuestSecret(guestSecret);
|
|
85
136
|
if (accessToken) {
|
|
86
137
|
headers.Authorization = `Bearer ${accessToken}`;
|
|
138
|
+
} else if (guest) {
|
|
139
|
+
headers[GUEST_HEADER] = guest;
|
|
87
140
|
} else {
|
|
88
141
|
init.credentials = "include";
|
|
89
142
|
}
|
|
@@ -124,28 +177,81 @@ export interface CreateWebAdaptersOptions {
|
|
|
124
177
|
* Resolve the caller's Cognito access token (fresh), or `null` when signed
|
|
125
178
|
* out. When a token is present the chat transport AND the history read
|
|
126
179
|
* authenticate as that user, so a reload restores the transcript. Without
|
|
127
|
-
* a token
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
180
|
+
* a token, identity falls to {@link getGuestSecret} (if supplied) and then
|
|
181
|
+
* to the guest cookie.
|
|
182
|
+
*
|
|
183
|
+
* Called with `{ forceRefresh: true }` exactly once: when the history read
|
|
184
|
+
* gets a 401 on a token this resolver already returned (a token cached
|
|
185
|
+
* before the mount-time history read fired can be stale by the time it
|
|
186
|
+
* runs — the same window the send path's own 401-retry closes). A resolver
|
|
187
|
+
* that caches (Amplify's `fetchAuthSession` does, and so does the widget's
|
|
188
|
+
* `createHostTokenProvider`) MUST bypass that cache for a forced call and
|
|
189
|
+
* obtain a genuinely fresh token — returning the SAME stale value would
|
|
190
|
+
* make the retry indistinguishable from not retrying at all. A resolver
|
|
191
|
+
* with nothing fresher to offer returns `null`, and the read surfaces the
|
|
192
|
+
* ORIGINAL 401 rather than replaying the value that just failed.
|
|
193
|
+
*/
|
|
194
|
+
getAccessToken?: (opts?: { forceRefresh?: boolean }) => Promise<string | null>;
|
|
195
|
+
/**
|
|
196
|
+
* Resolve the caller's own persisted anonymous guest secret (64 lowercase
|
|
197
|
+
* hex chars), or `null` when there is none. Supply this on hosts whose
|
|
198
|
+
* cookie jar can't carry the pod's HttpOnly `guuey_guest` — notably the
|
|
199
|
+
* embedded widget, a third-party iframe whose cookies browsers partition
|
|
200
|
+
* or block outright.
|
|
201
|
+
*
|
|
202
|
+
* With a secret, BOTH the chat transport and the history read send
|
|
203
|
+
* `x-guuey-guest`, so an anonymous transcript replays on reload the same
|
|
204
|
+
* way a signed-in one does — the read plane identifies a guest by that
|
|
205
|
+
* header (it cannot see the HttpOnly cookie, which is why a cookie-only
|
|
206
|
+
* caller still gets no history).
|
|
207
|
+
*
|
|
208
|
+
* Called once per request, so a rotated secret takes effect immediately.
|
|
209
|
+
* A value that isn't 64 lowercase hex is ignored (never sent) and the
|
|
210
|
+
* request falls through to cookie mode.
|
|
211
|
+
*
|
|
212
|
+
* **Supply at most ONE identity resolver per mode.** Anonymous hosts pass
|
|
213
|
+
* this one; identified hosts pass {@link getAccessToken} and surface a token
|
|
214
|
+
* failure rather than continuing. Passing BOTH is a hazard, not a fallback
|
|
215
|
+
* chain: `getAccessToken` resolving `null` is indistinguishable here from
|
|
216
|
+
* "signed out on purpose", so a merely *expired or unavailable* token
|
|
217
|
+
* silently downgrades the caller to the anonymous identity. The request then
|
|
218
|
+
* SUCCEEDS — the pod accepts anonymous invokes unconditionally — but the
|
|
219
|
+
* turns land in a different thread (the pod forks on an owner mismatch
|
|
220
|
+
* rather than appending), unreachable from the identified session, which
|
|
221
|
+
* gets its own transcript back minus those turns on the next good load. A
|
|
222
|
+
* 401-then-re-request-token retry loop is exactly this window.
|
|
223
|
+
*
|
|
224
|
+
* MUST be synchronous and MUST NOT throw: a throw propagates and fails the
|
|
225
|
+
* invoke. This is a real hazard for the widget, not a formality —
|
|
226
|
+
* `localStorage` access raises `SecurityError` in a third-party iframe with
|
|
227
|
+
* storage blocked (Safari's default for embedded content), which is normal
|
|
228
|
+
* operation here. A host reading storage owns that handling and MUST return
|
|
229
|
+
* `null` on a blocked read, the way {@link localStorageThreadStore} does for
|
|
230
|
+
* the threadId; `null` degrades to cookie mode, whereas a throw takes the
|
|
231
|
+
* chat down. Deliberately NOT caught at this seam: catching a host-supplied
|
|
232
|
+
* callback would also swallow ordinary host bugs into a silent anonymous
|
|
233
|
+
* downgrade — the same failure this docblock warns about above.
|
|
131
234
|
*/
|
|
132
|
-
|
|
235
|
+
getGuestSecret?: () => string | null;
|
|
133
236
|
}
|
|
134
237
|
|
|
135
238
|
/**
|
|
136
239
|
* Build the web host-adapter bundle for {@link useAgentInvoke}. Pass an
|
|
137
|
-
* access-token resolver
|
|
138
|
-
*
|
|
139
|
-
*
|
|
240
|
+
* access-token resolver and/or a guest-secret resolver (plus the read-plane
|
|
241
|
+
* base) to give the chat transport an identity the read plane can also see,
|
|
242
|
+
* which is what enables transcript restore on reload; omit both for a
|
|
243
|
+
* cookie-only, history-less bundle.
|
|
140
244
|
*/
|
|
141
245
|
export function createWebAdapters(
|
|
142
246
|
opts: CreateWebAdaptersOptions = {},
|
|
143
247
|
): AgentInvokeAdapters {
|
|
144
|
-
const { apiBaseUrl, getAccessToken } = opts;
|
|
248
|
+
const { apiBaseUrl, getAccessToken, getGuestSecret } = opts;
|
|
145
249
|
|
|
146
250
|
const transport: InvokeTransport = async function* (req) {
|
|
147
251
|
const token = getAccessToken ? await getAccessToken() : null;
|
|
148
|
-
|
|
252
|
+
// Both candidates go to the transport; it owns the precedence (and the
|
|
253
|
+
// never-two-carriers rule) so there is exactly one place that decides.
|
|
254
|
+
yield* fetchStreamTransport(req, token, getGuestSecret ? getGuestSecret() : null);
|
|
149
255
|
};
|
|
150
256
|
|
|
151
257
|
const adapters: AgentInvokeAdapters = {
|
|
@@ -154,18 +260,57 @@ export function createWebAdapters(
|
|
|
154
260
|
transport,
|
|
155
261
|
};
|
|
156
262
|
|
|
157
|
-
|
|
263
|
+
// History needs an identity the READ plane can resolve: a Bearer or the
|
|
264
|
+
// `x-guuey-guest` header. Either resolver can supply one, so either one
|
|
265
|
+
// installs the adapter; a cookie-only caller is unidentifiable there and
|
|
266
|
+
// gets no adapter at all.
|
|
267
|
+
if (apiBaseUrl && (getAccessToken || getGuestSecret)) {
|
|
158
268
|
adapters.history = {
|
|
159
269
|
load: async (threadId) => {
|
|
160
|
-
|
|
270
|
+
// Same precedence, and the same one-carrier rule, as the transport:
|
|
271
|
+
// a bearer wins over the guest header, and the two never combine.
|
|
272
|
+
if (getAccessToken) {
|
|
273
|
+
const token = await getAccessToken();
|
|
274
|
+
if (token) {
|
|
275
|
+
try {
|
|
276
|
+
return await fetchThreadHistory({
|
|
277
|
+
baseUrl: apiBaseUrl,
|
|
278
|
+
threadId,
|
|
279
|
+
includeCards: true,
|
|
280
|
+
requestInit: { headers: { Authorization: `Bearer ${token}` } },
|
|
281
|
+
});
|
|
282
|
+
} catch (err) {
|
|
283
|
+
if (!(err instanceof HistoryUnauthorizedError)) throw err;
|
|
284
|
+
// The one retry the send path already gets on a 401
|
|
285
|
+
// (`withIdentifiedToken`): this read runs from a mount effect,
|
|
286
|
+
// before the send path has asked anyone for anything, so a
|
|
287
|
+
// token cached earlier can be the exact stale value that just
|
|
288
|
+
// failed. `forceRefresh` is the signal that asks past whatever
|
|
289
|
+
// cache the resolver keeps instead of returning that same dead
|
|
290
|
+
// value.
|
|
291
|
+
const fresh = await getAccessToken({ forceRefresh: true });
|
|
292
|
+
if (!fresh) throw err; // nothing fresher to retry with — the ORIGINAL 401 is the honest cause
|
|
293
|
+
return fetchThreadHistory({
|
|
294
|
+
baseUrl: apiBaseUrl,
|
|
295
|
+
threadId,
|
|
296
|
+
includeCards: true,
|
|
297
|
+
requestInit: { headers: { Authorization: `Bearer ${fresh}` } },
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
const guest = sendableGuestSecret(getGuestSecret?.());
|
|
303
|
+
if (guest) {
|
|
304
|
+
return fetchThreadHistory({
|
|
305
|
+
baseUrl: apiBaseUrl,
|
|
306
|
+
threadId,
|
|
307
|
+
includeCards: true,
|
|
308
|
+
requestInit: { headers: { [GUEST_HEADER]: guest } },
|
|
309
|
+
});
|
|
310
|
+
}
|
|
161
311
|
// No readable identity → leave the chat empty (skip) rather than
|
|
162
312
|
// `gone`, which would clear the persisted threadId.
|
|
163
|
-
|
|
164
|
-
return fetchThreadHistory({
|
|
165
|
-
baseUrl: apiBaseUrl,
|
|
166
|
-
threadId,
|
|
167
|
-
requestInit: { headers: { Authorization: `Bearer ${token}` } },
|
|
168
|
-
});
|
|
313
|
+
return { messages: [] };
|
|
169
314
|
},
|
|
170
315
|
};
|
|
171
316
|
}
|