@guuey/agent-client 0.5.0 → 0.6.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.
@@ -95,6 +95,18 @@ export interface CreateUiResourceReaderOptions {
95
95
  apiBaseUrl: string;
96
96
  /** The thread whose persisted locators this reader may resolve. */
97
97
  threadId: string;
98
+ /**
99
+ * The pod base (or full invoke URL — same normalization as the invoke
100
+ * transport). When set, the reader tries the POD door first
101
+ * (`GET <base>/agent/ui-resource`, guuey#209 C1): the pod is the only
102
+ * party that can vouch for a locator whose turn is still streaming —
103
+ * persisted `kind:'card'` rows land at turn COMPLETION, so the platform
104
+ * door 404s mid-turn by construction. Completed turns 404 on the pod
105
+ * (past its grace window) and resolve on the platform door instead: one
106
+ * authority per lifecycle phase, and this reader tries both in that
107
+ * order. Absent → platform door only (pre-#209 behavior).
108
+ */
109
+ endpointUrl?: string | null;
98
110
  /** Signed-in bearer — wins over the guest secret (same rule as the transport). */
99
111
  getAccessToken?: (opts?: {
100
112
  forceRefresh?: boolean;
@@ -105,18 +117,26 @@ export interface CreateUiResourceReaderOptions {
105
117
  fetchImpl?: typeof fetch;
106
118
  }
107
119
  /**
108
- * Build a `UiResourceReader` over guuey's authenticated resources/read proxy
109
- * (guuey#122 Gap 1: `GET /v1/threads/:threadId/ui-resource?uri=…`).
120
+ * Build a `UiResourceReader` over guuey's authenticated resources/read
121
+ * doors — the pod door for LIVE turns (guuey#209 C1:
122
+ * `GET <pod>/agent/ui-resource?uri=…`, when {@link CreateUiResourceReaderOptions.endpointUrl}
123
+ * is set) and the platform proxy for persisted locators (guuey#122 Gap 1:
124
+ * `GET /v1/threads/:threadId/ui-resource?uri=…`). Both doors answer the
125
+ * same body and speak the same identity (bearer wins, guest header
126
+ * otherwise — the pod's `resolveIdentity` and the proxy's identity chain
127
+ * accept the identical carriers), so one parse serves both.
110
128
  *
111
129
  * This is `@guuey/mcp-apps-host`'s `createMcpUiResourceReader` assembly over
112
130
  * a guuey-platform transport (guuey#127) — channel resolution and payload
113
131
  * narrowing live in the host package; only the transport is guuey-shaped.
114
- * The proxy owns EVERYTHING trust-shaped: caller identity (the same three
115
- * families as the history read), thread ownership, the locator-to-thread
116
- * scope guard, and the per-user federation mint. This transport only carries
117
- * the surface's existing credential and maps EVERY non-OK — 401/403/404/502
118
- * alike to `undefined`: deny is byte-identical to a miss, and a miss
119
- * renders the host's placeholder, never an error surface.
132
+ * The doors own EVERYTHING trust-shaped: caller identity (the same three
133
+ * families as the history read), tenancy (the pod's live-card ledger; the
134
+ * proxy's thread-ownership + locator-to-thread scope guard), and the
135
+ * per-user federation mint. This transport only carries the surface's
136
+ * existing credential and maps EVERY non-OK 401/403/404/502 alike to
137
+ * "try the next door", and a miss on the last door to `undefined`: deny is
138
+ * byte-identical to a miss, and a miss renders the host's placeholder,
139
+ * never an error surface.
120
140
  */
121
141
  export declare function createUiResourceReader(options: CreateUiResourceReaderOptions): (resourceUri: string) => Promise<ResolvedViewMount | undefined>;
122
142
  /** Options for {@link createUiActionRelay} — same credential surface as the reader. */
@@ -1 +1 @@
1
- {"version":3,"file":"web-adapters.d.ts","sourceRoot":"","sources":["../src/web-adapters.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EAIL,KAAK,iBAAiB,EAEtB,KAAK,iBAAiB,EACtB,KAAK,eAAe,EACrB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,KAAK,EAAE,mBAAmB,EAAmB,aAAa,EAAE,MAAM,YAAY,CAAC;AAItF,sEAAsE;AACtE,eAAO,MAAM,uBAAuB,EAAE,aAiBrC,CAAC;AAEF,mEAAmE;AACnE,wBAAgB,aAAa,IAAI,MAAM,CAKtC;AAED,MAAM,WAAW,wBAAwB;IACvC;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;;;;;;;;;;;;;;OAiBG;IACH,cAAc,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE;QAAE,YAAY,CAAC,EAAE,OAAO,CAAA;KAAE,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC/E;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAuCG;IACH,cAAc,CAAC,EAAE,MAAM,MAAM,GAAG,IAAI,CAAC;CACtC;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAC/B,IAAI,GAAE,wBAA6B,GAClC,mBAAmB,CA8ErB;AAED,mGAAmG;AACnG,MAAM,WAAW,6BAA6B;IAC5C,0CAA0C;IAC1C,UAAU,EAAE,MAAM,CAAC;IACnB,mEAAmE;IACnE,QAAQ,EAAE,MAAM,CAAC;IACjB,kFAAkF;IAClF,cAAc,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE;QAAE,YAAY,CAAC,EAAE,OAAO,CAAA;KAAE,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC/E,iEAAiE;IACjE,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,4BAA4B;IAC5B,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;CAC1B;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,sBAAsB,CACpC,OAAO,EAAE,6BAA6B,GACrC,CAAC,WAAW,EAAE,MAAM,KAAK,OAAO,CAAC,iBAAiB,GAAG,SAAS,CAAC,CAoDjE;AAED,uFAAuF;AACvF,MAAM,WAAW,0BAA0B;IACzC,0CAA0C;IAC1C,UAAU,EAAE,MAAM,CAAC;IACnB,+DAA+D;IAC/D,QAAQ,EAAE,MAAM,CAAC;IACjB,kFAAkF;IAClF,cAAc,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE;QAAE,YAAY,CAAC,EAAE,OAAO,CAAA;KAAE,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC/E,iEAAiE;IACjE,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,4BAA4B;IAC5B,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;CAC1B;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,mBAAmB,CACjC,OAAO,EAAE,0BAA0B,GAClC,CAAC,OAAO,EAAE,eAAe,KAAK,OAAO,CAAC,iBAAiB,CAAC,CA+C1D"}
1
+ {"version":3,"file":"web-adapters.d.ts","sourceRoot":"","sources":["../src/web-adapters.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EAIL,KAAK,iBAAiB,EAEtB,KAAK,iBAAiB,EACtB,KAAK,eAAe,EACrB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,KAAK,EAAE,mBAAmB,EAAmB,aAAa,EAAE,MAAM,YAAY,CAAC;AAKtF,sEAAsE;AACtE,eAAO,MAAM,uBAAuB,EAAE,aAiBrC,CAAC;AAEF,mEAAmE;AACnE,wBAAgB,aAAa,IAAI,MAAM,CAKtC;AAED,MAAM,WAAW,wBAAwB;IACvC;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;;;;;;;;;;;;;;OAiBG;IACH,cAAc,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE;QAAE,YAAY,CAAC,EAAE,OAAO,CAAA;KAAE,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC/E;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAuCG;IACH,cAAc,CAAC,EAAE,MAAM,MAAM,GAAG,IAAI,CAAC;CACtC;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAC/B,IAAI,GAAE,wBAA6B,GAClC,mBAAmB,CA8ErB;AAED,mGAAmG;AACnG,MAAM,WAAW,6BAA6B;IAC5C,0CAA0C;IAC1C,UAAU,EAAE,MAAM,CAAC;IACnB,mEAAmE;IACnE,QAAQ,EAAE,MAAM,CAAC;IACjB;;;;;;;;;;OAUG;IACH,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,kFAAkF;IAClF,cAAc,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE;QAAE,YAAY,CAAC,EAAE,OAAO,CAAA;KAAE,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC/E,iEAAiE;IACjE,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,4BAA4B;IAC5B,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;CAC1B;AAOD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,sBAAsB,CACpC,OAAO,EAAE,6BAA6B,GACrC,CAAC,WAAW,EAAE,MAAM,KAAK,OAAO,CAAC,iBAAiB,GAAG,SAAS,CAAC,CA4EjE;AAED,uFAAuF;AACvF,MAAM,WAAW,0BAA0B;IACzC,0CAA0C;IAC1C,UAAU,EAAE,MAAM,CAAC;IACnB,+DAA+D;IAC/D,QAAQ,EAAE,MAAM,CAAC;IACjB,kFAAkF;IAClF,cAAc,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE;QAAE,YAAY,CAAC,EAAE,OAAO,CAAA;KAAE,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC/E,iEAAiE;IACjE,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,4BAA4B;IAC5B,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;CAC1B;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,mBAAmB,CACjC,OAAO,EAAE,0BAA0B,GAClC,CAAC,OAAO,EAAE,eAAe,KAAK,OAAO,CAAC,iBAAiB,CAAC,CA+C1D"}
@@ -9,6 +9,7 @@
9
9
  import { createMcpUiActionRelay, createMcpUiResourceReader, } from "@guuey/mcp-apps-host";
10
10
  import { fetchThreadHistory, HistoryUnauthorizedError } from "./history.js";
11
11
  import { fetchStreamTransport, sendableGuestSecret, GUEST_HEADER } from "./transport.js";
12
+ import { toInvokeUrl } from "./invoke-turn.js";
12
13
  /** Persists the threadId in `window.localStorage` (synchronously). */
13
14
  export const localStorageThreadStore = {
14
15
  load(key) {
@@ -124,24 +125,45 @@ export function createWebAdapters(opts = {}) {
124
125
  }
125
126
  return adapters;
126
127
  }
128
+ /** `<pod base>/agent/ui-resource` from whatever endpoint shape the surface holds. */
129
+ function toUiResourceUrl(endpointUrl) {
130
+ return toInvokeUrl(endpointUrl).replace(/\/agent\/invoke$/, "/agent/ui-resource");
131
+ }
127
132
  /**
128
- * Build a `UiResourceReader` over guuey's authenticated resources/read proxy
129
- * (guuey#122 Gap 1: `GET /v1/threads/:threadId/ui-resource?uri=…`).
133
+ * Build a `UiResourceReader` over guuey's authenticated resources/read
134
+ * doors — the pod door for LIVE turns (guuey#209 C1:
135
+ * `GET <pod>/agent/ui-resource?uri=…`, when {@link CreateUiResourceReaderOptions.endpointUrl}
136
+ * is set) and the platform proxy for persisted locators (guuey#122 Gap 1:
137
+ * `GET /v1/threads/:threadId/ui-resource?uri=…`). Both doors answer the
138
+ * same body and speak the same identity (bearer wins, guest header
139
+ * otherwise — the pod's `resolveIdentity` and the proxy's identity chain
140
+ * accept the identical carriers), so one parse serves both.
130
141
  *
131
142
  * This is `@guuey/mcp-apps-host`'s `createMcpUiResourceReader` assembly over
132
143
  * a guuey-platform transport (guuey#127) — channel resolution and payload
133
144
  * narrowing live in the host package; only the transport is guuey-shaped.
134
- * The proxy owns EVERYTHING trust-shaped: caller identity (the same three
135
- * families as the history read), thread ownership, the locator-to-thread
136
- * scope guard, and the per-user federation mint. This transport only carries
137
- * the surface's existing credential and maps EVERY non-OK — 401/403/404/502
138
- * alike to `undefined`: deny is byte-identical to a miss, and a miss
139
- * renders the host's placeholder, never an error surface.
145
+ * The doors own EVERYTHING trust-shaped: caller identity (the same three
146
+ * families as the history read), tenancy (the pod's live-card ledger; the
147
+ * proxy's thread-ownership + locator-to-thread scope guard), and the
148
+ * per-user federation mint. This transport only carries the surface's
149
+ * existing credential and maps EVERY non-OK 401/403/404/502 alike to
150
+ * "try the next door", and a miss on the last door to `undefined`: deny is
151
+ * byte-identical to a miss, and a miss renders the host's placeholder,
152
+ * never an error surface.
140
153
  */
141
154
  export function createUiResourceReader(options) {
142
155
  const fetchImpl = options.fetchImpl ?? fetch;
143
- const readResource = async (resourceUri) => {
156
+ /** One door: fetch + the history adapter's 401-forceRefresh recovery + parse. */
157
+ const readDoor = async (requestUrl) => {
158
+ // Exactly ONE identity carrier per read, the invoke transport's rule
159
+ // (`streamInvokeOnce`): bearer → guest header → else cookie credentials,
160
+ // which round-trip the HttpOnly `guuey_guest` cookie the pod mints for
161
+ // anonymous browser callers. Without the third arm a cookie-mode guest
162
+ // sent an identity-less read and every locator rendered as expired
163
+ // (guuey#221). Never two at once: a request carrying either header does
164
+ // NOT also send cookies.
144
165
  const headers = {};
166
+ const init = { headers };
145
167
  const token = options.getAccessToken ? await options.getAccessToken() : null;
146
168
  const guest = sendableGuestSecret(options.guestSecret);
147
169
  if (token) {
@@ -150,17 +172,20 @@ export function createUiResourceReader(options) {
150
172
  else if (guest) {
151
173
  headers[GUEST_HEADER] = guest;
152
174
  }
153
- const requestUrl = `${options.apiBaseUrl}/threads/${encodeURIComponent(options.threadId)}/ui-resource?uri=${encodeURIComponent(resourceUri)}`;
175
+ else {
176
+ init.credentials = "include";
177
+ }
154
178
  let res;
155
179
  try {
156
- res = await fetchImpl(requestUrl, { headers });
180
+ res = await fetchImpl(requestUrl, init);
157
181
  }
158
182
  catch {
159
- return undefined; // transport failure == miss == placeholder
183
+ return undefined; // transport failure == miss (the next door may still answer)
160
184
  }
161
185
  // One forceRefresh retry on 401 with a bearer in play — the same
162
186
  // expired-but-refreshable recovery the history adapter performs;
163
- // without it a stale token degrades to a permanent placeholder.
187
+ // without it a stale token degrades to a permanent placeholder. The
188
+ // retry carries the fresh bearer and nothing else (same one-carrier rule).
164
189
  if (res.status === 401 && options.getAccessToken) {
165
190
  const fresh = await options.getAccessToken({ forceRefresh: true }).catch(() => null);
166
191
  if (fresh) {
@@ -183,8 +208,8 @@ export function createUiResourceReader(options) {
183
208
  catch {
184
209
  return undefined;
185
210
  }
186
- // The proxy passes the blob arm through (a blob-only resource is not
187
- // silently a miss — its route contract); mirror that here.
211
+ // Both doors pass the blob arm through (a blob-only resource is not
212
+ // silently a miss — the route contract); mirror that here.
188
213
  if (typeof body.uri !== "string")
189
214
  return undefined;
190
215
  if (typeof body.text !== "string" && typeof body.blob !== "string")
@@ -196,6 +221,16 @@ export function createUiResourceReader(options) {
196
221
  ...(typeof body.blob === "string" ? { blob: body.blob } : {}),
197
222
  };
198
223
  };
224
+ const podUrl = options.endpointUrl ? toUiResourceUrl(options.endpointUrl) : null;
225
+ const readResource = async (resourceUri) => {
226
+ const query = `?uri=${encodeURIComponent(resourceUri)}`;
227
+ if (podUrl !== null) {
228
+ const live = await readDoor(`${podUrl}${query}`);
229
+ if (live !== undefined)
230
+ return live;
231
+ }
232
+ return readDoor(`${options.apiBaseUrl}/threads/${encodeURIComponent(options.threadId)}/ui-resource${query}`);
233
+ };
199
234
  return createMcpUiResourceReader({ readResource });
200
235
  }
201
236
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@guuey/agent-client",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "description": "Client SDK for Guuey's agent runtime: the `useAgentInvoke` React hook + pure SSE helpers that speak the /agent/invoke streaming contract, plus the paginated thread-history read plane. Host adapters (storage / id / transport) are injected, so it runs on web (Next) and React Native alike.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -34,8 +34,8 @@
34
34
  }
35
35
  },
36
36
  "dependencies": {
37
- "@silverprotocol/core": "0.4.1",
38
- "@guuey/mcp-apps-host": "0.5.0"
37
+ "@silverprotocol/core": "0.5.0",
38
+ "@guuey/mcp-apps-host": "0.6.1"
39
39
  },
40
40
  "peerDependencies": {
41
41
  "react": ">=18"
@@ -18,6 +18,7 @@ import {
18
18
  import type { AgentInvokeAdapters, InvokeTransport, ThreadIdStore } from "./types.js";
19
19
  import { fetchThreadHistory, HistoryUnauthorizedError } from "./history.js";
20
20
  import { fetchStreamTransport, sendableGuestSecret, GUEST_HEADER } from "./transport.js";
21
+ import { toInvokeUrl } from "./invoke-turn.js";
21
22
 
22
23
  /** Persists the threadId in `window.localStorage` (synchronously). */
23
24
  export const localStorageThreadStore: ThreadIdStore = {
@@ -210,6 +211,18 @@ export interface CreateUiResourceReaderOptions {
210
211
  apiBaseUrl: string;
211
212
  /** The thread whose persisted locators this reader may resolve. */
212
213
  threadId: string;
214
+ /**
215
+ * The pod base (or full invoke URL — same normalization as the invoke
216
+ * transport). When set, the reader tries the POD door first
217
+ * (`GET <base>/agent/ui-resource`, guuey#209 C1): the pod is the only
218
+ * party that can vouch for a locator whose turn is still streaming —
219
+ * persisted `kind:'card'` rows land at turn COMPLETION, so the platform
220
+ * door 404s mid-turn by construction. Completed turns 404 on the pod
221
+ * (past its grace window) and resolve on the platform door instead: one
222
+ * authority per lifecycle phase, and this reader tries both in that
223
+ * order. Absent → platform door only (pre-#209 behavior).
224
+ */
225
+ endpointUrl?: string | null;
213
226
  /** Signed-in bearer — wins over the guest secret (same rule as the transport). */
214
227
  getAccessToken?: (opts?: { forceRefresh?: boolean }) => Promise<string | null>;
215
228
  /** Caller-owned anonymous guest secret (widget / guest chat). */
@@ -218,43 +231,68 @@ export interface CreateUiResourceReaderOptions {
218
231
  fetchImpl?: typeof fetch;
219
232
  }
220
233
 
234
+ /** `<pod base>/agent/ui-resource` from whatever endpoint shape the surface holds. */
235
+ function toUiResourceUrl(endpointUrl: string): string {
236
+ return toInvokeUrl(endpointUrl).replace(/\/agent\/invoke$/, "/agent/ui-resource");
237
+ }
238
+
221
239
  /**
222
- * Build a `UiResourceReader` over guuey's authenticated resources/read proxy
223
- * (guuey#122 Gap 1: `GET /v1/threads/:threadId/ui-resource?uri=…`).
240
+ * Build a `UiResourceReader` over guuey's authenticated resources/read
241
+ * doors — the pod door for LIVE turns (guuey#209 C1:
242
+ * `GET <pod>/agent/ui-resource?uri=…`, when {@link CreateUiResourceReaderOptions.endpointUrl}
243
+ * is set) and the platform proxy for persisted locators (guuey#122 Gap 1:
244
+ * `GET /v1/threads/:threadId/ui-resource?uri=…`). Both doors answer the
245
+ * same body and speak the same identity (bearer wins, guest header
246
+ * otherwise — the pod's `resolveIdentity` and the proxy's identity chain
247
+ * accept the identical carriers), so one parse serves both.
224
248
  *
225
249
  * This is `@guuey/mcp-apps-host`'s `createMcpUiResourceReader` assembly over
226
250
  * a guuey-platform transport (guuey#127) — channel resolution and payload
227
251
  * narrowing live in the host package; only the transport is guuey-shaped.
228
- * The proxy owns EVERYTHING trust-shaped: caller identity (the same three
229
- * families as the history read), thread ownership, the locator-to-thread
230
- * scope guard, and the per-user federation mint. This transport only carries
231
- * the surface's existing credential and maps EVERY non-OK — 401/403/404/502
232
- * alike to `undefined`: deny is byte-identical to a miss, and a miss
233
- * renders the host's placeholder, never an error surface.
252
+ * The doors own EVERYTHING trust-shaped: caller identity (the same three
253
+ * families as the history read), tenancy (the pod's live-card ledger; the
254
+ * proxy's thread-ownership + locator-to-thread scope guard), and the
255
+ * per-user federation mint. This transport only carries the surface's
256
+ * existing credential and maps EVERY non-OK 401/403/404/502 alike to
257
+ * "try the next door", and a miss on the last door to `undefined`: deny is
258
+ * byte-identical to a miss, and a miss renders the host's placeholder,
259
+ * never an error surface.
234
260
  */
235
261
  export function createUiResourceReader(
236
262
  options: CreateUiResourceReaderOptions,
237
263
  ): (resourceUri: string) => Promise<ResolvedViewMount | undefined> {
238
264
  const fetchImpl = options.fetchImpl ?? fetch;
239
- const readResource = async (resourceUri: string): Promise<McpResourceReadResult | undefined> => {
265
+
266
+ /** One door: fetch + the history adapter's 401-forceRefresh recovery + parse. */
267
+ const readDoor = async (requestUrl: string): Promise<McpResourceReadResult | undefined> => {
268
+ // Exactly ONE identity carrier per read, the invoke transport's rule
269
+ // (`streamInvokeOnce`): bearer → guest header → else cookie credentials,
270
+ // which round-trip the HttpOnly `guuey_guest` cookie the pod mints for
271
+ // anonymous browser callers. Without the third arm a cookie-mode guest
272
+ // sent an identity-less read and every locator rendered as expired
273
+ // (guuey#221). Never two at once: a request carrying either header does
274
+ // NOT also send cookies.
240
275
  const headers: Record<string, string> = {};
276
+ const init: RequestInit = { headers };
241
277
  const token = options.getAccessToken ? await options.getAccessToken() : null;
242
278
  const guest = sendableGuestSecret(options.guestSecret);
243
279
  if (token) {
244
280
  headers["authorization"] = `Bearer ${token}`;
245
281
  } else if (guest) {
246
282
  headers[GUEST_HEADER] = guest;
283
+ } else {
284
+ init.credentials = "include";
247
285
  }
248
- const requestUrl = `${options.apiBaseUrl}/threads/${encodeURIComponent(options.threadId)}/ui-resource?uri=${encodeURIComponent(resourceUri)}`;
249
286
  let res: Response;
250
287
  try {
251
- res = await fetchImpl(requestUrl, { headers });
288
+ res = await fetchImpl(requestUrl, init);
252
289
  } catch {
253
- return undefined; // transport failure == miss == placeholder
290
+ return undefined; // transport failure == miss (the next door may still answer)
254
291
  }
255
292
  // One forceRefresh retry on 401 with a bearer in play — the same
256
293
  // expired-but-refreshable recovery the history adapter performs;
257
- // without it a stale token degrades to a permanent placeholder.
294
+ // without it a stale token degrades to a permanent placeholder. The
295
+ // retry carries the fresh bearer and nothing else (same one-carrier rule).
258
296
  if (res.status === 401 && options.getAccessToken) {
259
297
  const fresh = await options.getAccessToken({ forceRefresh: true }).catch(() => null);
260
298
  if (fresh) {
@@ -274,8 +312,8 @@ export function createUiResourceReader(
274
312
  } catch {
275
313
  return undefined;
276
314
  }
277
- // The proxy passes the blob arm through (a blob-only resource is not
278
- // silently a miss — its route contract); mirror that here.
315
+ // Both doors pass the blob arm through (a blob-only resource is not
316
+ // silently a miss — the route contract); mirror that here.
279
317
  if (typeof body.uri !== "string") return undefined;
280
318
  if (typeof body.text !== "string" && typeof body.blob !== "string") return undefined;
281
319
  return {
@@ -285,6 +323,18 @@ export function createUiResourceReader(
285
323
  ...(typeof body.blob === "string" ? { blob: body.blob } : {}),
286
324
  };
287
325
  };
326
+
327
+ const podUrl = options.endpointUrl ? toUiResourceUrl(options.endpointUrl) : null;
328
+ const readResource = async (resourceUri: string): Promise<McpResourceReadResult | undefined> => {
329
+ const query = `?uri=${encodeURIComponent(resourceUri)}`;
330
+ if (podUrl !== null) {
331
+ const live = await readDoor(`${podUrl}${query}`);
332
+ if (live !== undefined) return live;
333
+ }
334
+ return readDoor(
335
+ `${options.apiBaseUrl}/threads/${encodeURIComponent(options.threadId)}/ui-resource${query}`,
336
+ );
337
+ };
288
338
  return createMcpUiResourceReader({ readResource });
289
339
  }
290
340