@mastra/hono 1.7.21-alpha.2 → 1.7.21-alpha.3

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.
@@ -1,5 +1,45 @@
1
+ import type { Mastra } from '@mastra/core/mastra';
1
2
  import type { BrowserStreamConfig, BrowserStreamResult } from '@mastra/server/browser-stream';
2
- import type { Env, Hono, Schema } from 'hono';
3
+ import type { Context, Env, Hono, Schema } from 'hono';
4
+ /**
5
+ * Allowed `Origin` values for browser WebSocket upgrades. Accepts the same shape
6
+ * as Hono's `cors.origin` option.
7
+ */
8
+ export type BrowserStreamOrigin = string | string[] | ((origin: string, c: Context) => Promise<string | undefined | null> | string | undefined | null);
9
+ /**
10
+ * Hono-specific browser stream configuration.
11
+ */
12
+ export interface HonoBrowserStreamConfig extends BrowserStreamConfig {
13
+ /**
14
+ * Mastra instance used to authenticate requests to the browser stream routes.
15
+ *
16
+ * Every route registered by {@link setupBrowserStream} — the WebSocket upgrade,
17
+ * the session probe, the close endpoint, and the upgrade-ticket endpoint — is
18
+ * gated by the server auth middleware. Without this, an unauthenticated caller
19
+ * could watch and drive an agent's browser. When the instance has no
20
+ * `server.auth` configured the middleware is a no-op.
21
+ */
22
+ mastra: Mastra;
23
+ /**
24
+ * Origin allowlist for the browser WebSocket upgrade.
25
+ *
26
+ * Browsers always send an `Origin` header on a WebSocket handshake and attach
27
+ * the session cookie to it, so without an allowlist a page on any origin could
28
+ * open a cookie-authenticated stream and drive the agent's browser. When this is
29
+ * set, an upgrade from an origin that isn't allowed is rejected with `403`.
30
+ *
31
+ * Values are matched the way Hono's `cors.origin` matches them: array entries
32
+ * literally, and a callback only approves an origin when it returns that same
33
+ * origin (or `'*'`). The server's own origin always passes, because a CORS
34
+ * allowlist names the *other* origins and never includes the server itself.
35
+ *
36
+ * Requests without an `Origin` header (non-browser clients) are not affected,
37
+ * and the check is skipped entirely when this is unset. The deployer passes the
38
+ * explicitly configured `server.cors.origin` here; its permissive default is
39
+ * left as-is so cross-origin Studio deployments keep working.
40
+ */
41
+ allowedOrigins?: BrowserStreamOrigin;
42
+ }
3
43
  /**
4
44
  * Set up WebSocket-based browser stream endpoint for real-time screencast viewing.
5
45
  *
@@ -9,6 +49,17 @@ import type { Env, Hono, Schema } from 'hono';
9
49
  * - Broadcasts frames to all connected viewers
10
50
  * - Stops screencast when last viewer disconnects
11
51
  *
52
+ * All routes are authenticated with the server's auth configuration. Browsers
53
+ * cannot attach an `Authorization` header to a WebSocket upgrade, so
54
+ * session-cookie providers authenticate from the upgrade request's cookies.
55
+ * Clients that authenticate with a header token instead request a short-lived,
56
+ * single-use ticket from `POST ${apiPrefix}/agents/:agentId/browser/stream-ticket`
57
+ * and pass it to the upgrade as `?ticket=`, which keeps the reusable credential
58
+ * out of the URL. Non-browser clients can pass the token as the `apiKey` query
59
+ * parameter — use a short-lived token, since a URL can be retained in proxy and
60
+ * access logs, and prefer the `Authorization` header on the HTTP routes where the
61
+ * client can set one.
62
+ *
12
63
  * **Note**: Requires `ws` package to be installed. If not available, returns null
13
64
  * and logs a warning. Browser streaming will be disabled but everything else works.
14
65
  *
@@ -24,6 +75,7 @@ import type { Env, Hono, Schema } from 'hono';
24
75
  *
25
76
  * const app = new Hono();
26
77
  * const browserStream = await setupBrowserStream(app, {
78
+ * mastra,
27
79
  * getToolset: (agentId) => browserToolsets.get(agentId),
28
80
  * });
29
81
  *
@@ -31,5 +83,5 @@ import type { Env, Hono, Schema } from 'hono';
31
83
  * browserStream?.injectWebSocket(server);
32
84
  * ```
33
85
  */
34
- export declare function setupBrowserStream<E extends Env, S extends Schema, B extends string>(app: Hono<E, S, B>, config: BrowserStreamConfig): Promise<BrowserStreamResult | null>;
86
+ export declare function setupBrowserStream<E extends Env, S extends Schema, B extends string>(app: Hono<E, S, B>, config: HonoBrowserStreamConfig): Promise<BrowserStreamResult | null>;
35
87
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/browser-stream/index.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,mBAAmB,EAAE,mBAAmB,EAAE,MAAM,+BAA+B,CAAC;AAC9F,OAAO,KAAK,EAAE,GAAG,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,MAAM,CAAC;AAE9C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,wBAAsB,kBAAkB,CAAC,CAAC,SAAS,GAAG,EAAE,CAAC,SAAS,MAAM,EAAE,CAAC,SAAS,MAAM,EACxF,GAAG,EAAE,IAAI,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,EAClB,MAAM,EAAE,mBAAmB,GAC1B,OAAO,CAAC,mBAAmB,GAAG,IAAI,CAAC,CAuIrC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/browser-stream/index.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAC;AAElD,OAAO,KAAK,EAAE,mBAAmB,EAAE,mBAAmB,EAAE,MAAM,+BAA+B,CAAC;AAC9F,OAAO,KAAK,EAAE,OAAO,EAAE,GAAG,EAAE,IAAI,EAAqB,MAAM,EAAE,MAAM,MAAM,CAAC;AAQ1E;;;GAGG;AACH,MAAM,MAAM,mBAAmB,GAC3B,MAAM,GACN,MAAM,EAAE,GACR,CAAC,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,EAAE,OAAO,KAAK,OAAO,CAAC,MAAM,GAAG,SAAS,GAAG,IAAI,CAAC,GAAG,MAAM,GAAG,SAAS,GAAG,IAAI,CAAC,CAAC;AAErG;;GAEG;AACH,MAAM,WAAW,uBAAwB,SAAQ,mBAAmB;IAClE;;;;;;;;OAQG;IACH,MAAM,EAAE,MAAM,CAAC;IAEf;;;;;;;;;;;;;;;;;OAiBG;IACH,cAAc,CAAC,EAAE,mBAAmB,CAAC;CACtC;AA6CD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0CG;AACH,wBAAsB,kBAAkB,CAAC,CAAC,SAAS,GAAG,EAAE,CAAC,SAAS,MAAM,EAAE,CAAC,SAAS,MAAM,EACxF,GAAG,EAAE,IAAI,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,EAClB,MAAM,EAAE,uBAAuB,GAC9B,OAAO,CAAC,mBAAmB,GAAG,IAAI,CAAC,CAuPrC"}
@@ -0,0 +1,66 @@
1
+ /**
2
+ * How long an issued ticket stays valid. A ticket is minted immediately before
3
+ * the socket is opened, so this only has to cover the round trip plus the
4
+ * handshake — short enough that a ticket leaked from a log is useless by the
5
+ * time anyone can act on it.
6
+ */
7
+ export declare const UPGRADE_TICKET_TTL_MS = 30000;
8
+ /**
9
+ * Ceiling on tickets held in memory at once. A ticket is only useful during the
10
+ * round trip between minting and the upgrade, so a store this full is a client
11
+ * asking for tickets it never spends — reject rather than keep allocating.
12
+ */
13
+ export declare const MAX_OUTSTANDING_TICKETS = 500;
14
+ /** How many tickets one agent may be issued inside {@link TICKET_ISSUANCE_WINDOW_MS}. */
15
+ export declare const TICKET_ISSUANCE_LIMIT = 30;
16
+ /** Length of the per-agent issuance window. */
17
+ export declare const TICKET_ISSUANCE_WINDOW_MS = 60000;
18
+ export interface IssuedUpgradeTicket {
19
+ ticket: string;
20
+ /** Lifetime in seconds, for the client's `expiresIn` field. */
21
+ expiresIn: number;
22
+ }
23
+ export type UpgradeTicketIssueResult = ({
24
+ ok: true;
25
+ } & IssuedUpgradeTicket) | {
26
+ ok: false;
27
+ reason: 'rate-limited' | 'capacity';
28
+ };
29
+ /**
30
+ * Short-lived, single-use credentials for browser WebSocket upgrades.
31
+ *
32
+ * A browser cannot set an `Authorization` header on a WebSocket handshake, so a
33
+ * deployment that authenticates with a header token would otherwise have to put
34
+ * that reusable token in the handshake URL, where proxies and access logs can
35
+ * retain it. Instead the client asks an authenticated HTTP route for a ticket,
36
+ * and the ticket — worthless once spent, and scoped to one agent and thread —
37
+ * travels in the URL instead.
38
+ *
39
+ * In-process, like the `ViewerRegistry` and the browser sessions it serves: a
40
+ * screencast is bound to the process holding the browser, so a ticket store
41
+ * shared across replicas would buy nothing. A ticket presented to a different
42
+ * replica fails for the same reason the stream itself would.
43
+ *
44
+ * Issuance is bounded so the mint endpoint can't be turned into memory or CPU
45
+ * exhaustion: tickets that are never spent live at most one TTL, a full store
46
+ * rejects instead of growing, and each agent has a request allowance. Expiry is
47
+ * swept from the front of the insertion-ordered map, so cleanup costs the
48
+ * number of entries actually expired rather than a scan of every outstanding
49
+ * ticket per request.
50
+ */
51
+ export declare class UpgradeTicketStore {
52
+ #private;
53
+ constructor(now?: () => number);
54
+ /**
55
+ * Issue a ticket bound to one agent and thread. Returns `{ ok: false }` when
56
+ * the agent exceeded its allowance or the store is at capacity.
57
+ */
58
+ issue(agentId: string, threadId?: string, ttlMs?: number): UpgradeTicketIssueResult;
59
+ /**
60
+ * Spend a ticket. Returns `true` only when the ticket is unexpired and was
61
+ * issued for this exact agent and thread, in which case it is consumed and a
62
+ * second presentation fails.
63
+ */
64
+ consume(ticket: string, agentId: string, threadId?: string): boolean;
65
+ }
66
+ //# sourceMappingURL=upgrade-tickets.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"upgrade-tickets.d.ts","sourceRoot":"","sources":["../../src/browser-stream/upgrade-tickets.ts"],"names":[],"mappings":"AAEA;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,QAAS,CAAC;AAE5C;;;;GAIG;AACH,eAAO,MAAM,uBAAuB,MAAM,CAAC;AAE3C,yFAAyF;AACzF,eAAO,MAAM,qBAAqB,KAAK,CAAC;AAExC,+CAA+C;AAC/C,eAAO,MAAM,yBAAyB,QAAS,CAAC;AAmBhD,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE,MAAM,CAAC;IACf,+DAA+D;IAC/D,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,MAAM,wBAAwB,GAChC,CAAC;IAAE,EAAE,EAAE,IAAI,CAAA;CAAE,GAAG,mBAAmB,CAAC,GACpC;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,cAAc,GAAG,UAAU,CAAA;CAAE,CAAC;AAYvD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,qBAAa,kBAAkB;;IAK7B,YAAY,GAAG,GAAE,MAAM,MAAyB,EAE/C;IAED;;;OAGG;IACH,KAAK,CAAC,OAAO,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,MAAM,EAAE,KAAK,GAAE,MAA8B,GAAG,wBAAwB,CAoBzG;IAED;;;;OAIG;IACH,OAAO,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,MAAM,GAAG,OAAO,CASnE;CAuBF"}
package/dist/index.cjs CHANGED
@@ -90,7 +90,162 @@ function createAuthMiddleware({ mastra, requiresAuth = true }) {
90
90
  };
91
91
  }
92
92
  //#endregion
93
+ //#region src/browser-stream/upgrade-tickets.ts
94
+ const TICKET_BYTES = 32;
95
+ /**
96
+ * How long an issued ticket stays valid. A ticket is minted immediately before
97
+ * the socket is opened, so this only has to cover the round trip plus the
98
+ * handshake — short enough that a ticket leaked from a log is useless by the
99
+ * time anyone can act on it.
100
+ */
101
+ const UPGRADE_TICKET_TTL_MS = 3e4;
102
+ /**
103
+ * Ceiling on tracked per-agent issuance counters, so the limiter can't grow
104
+ * without bound as agent ids are invented. Counters are evicted oldest first.
105
+ */
106
+ const MAX_TRACKED_AGENTS = 500;
107
+ /** 256 bits of base64url, so the ticket can't be guessed or enumerated. */
108
+ function createTicketValue() {
109
+ const bytes = new Uint8Array(TICKET_BYTES);
110
+ globalThis.crypto.getRandomValues(bytes);
111
+ return btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
112
+ }
113
+ /**
114
+ * Short-lived, single-use credentials for browser WebSocket upgrades.
115
+ *
116
+ * A browser cannot set an `Authorization` header on a WebSocket handshake, so a
117
+ * deployment that authenticates with a header token would otherwise have to put
118
+ * that reusable token in the handshake URL, where proxies and access logs can
119
+ * retain it. Instead the client asks an authenticated HTTP route for a ticket,
120
+ * and the ticket — worthless once spent, and scoped to one agent and thread —
121
+ * travels in the URL instead.
122
+ *
123
+ * In-process, like the `ViewerRegistry` and the browser sessions it serves: a
124
+ * screencast is bound to the process holding the browser, so a ticket store
125
+ * shared across replicas would buy nothing. A ticket presented to a different
126
+ * replica fails for the same reason the stream itself would.
127
+ *
128
+ * Issuance is bounded so the mint endpoint can't be turned into memory or CPU
129
+ * exhaustion: tickets that are never spent live at most one TTL, a full store
130
+ * rejects instead of growing, and each agent has a request allowance. Expiry is
131
+ * swept from the front of the insertion-ordered map, so cleanup costs the
132
+ * number of entries actually expired rather than a scan of every outstanding
133
+ * ticket per request.
134
+ */
135
+ var UpgradeTicketStore = class {
136
+ #tickets = /* @__PURE__ */ new Map();
137
+ #issuance = /* @__PURE__ */ new Map();
138
+ #now;
139
+ constructor(now = () => Date.now()) {
140
+ this.#now = now;
141
+ }
142
+ /**
143
+ * Issue a ticket bound to one agent and thread. Returns `{ ok: false }` when
144
+ * the agent exceeded its allowance or the store is at capacity.
145
+ */
146
+ issue(agentId, threadId, ttlMs = UPGRADE_TICKET_TTL_MS) {
147
+ const now = this.#now();
148
+ this.#prune(now);
149
+ if (this.#tickets.size >= 500) return {
150
+ ok: false,
151
+ reason: "capacity"
152
+ };
153
+ const window = this.#issuance.get(agentId);
154
+ if (!window || now - window.windowStart >= 6e4) this.#trackIssuance(agentId, {
155
+ windowStart: now,
156
+ count: 0
157
+ });
158
+ else if (window.count >= 30) return {
159
+ ok: false,
160
+ reason: "rate-limited"
161
+ };
162
+ this.#issuance.get(agentId).count += 1;
163
+ const ticket = createTicketValue();
164
+ this.#tickets.set(ticket, {
165
+ agentId,
166
+ threadId,
167
+ expiresAt: now + ttlMs
168
+ });
169
+ return {
170
+ ok: true,
171
+ ticket,
172
+ expiresIn: Math.ceil(ttlMs / 1e3)
173
+ };
174
+ }
175
+ /**
176
+ * Spend a ticket. Returns `true` only when the ticket is unexpired and was
177
+ * issued for this exact agent and thread, in which case it is consumed and a
178
+ * second presentation fails.
179
+ */
180
+ consume(ticket, agentId, threadId) {
181
+ const entry = this.#tickets.get(ticket);
182
+ if (!entry) return false;
183
+ if (entry.expiresAt <= this.#now()) return false;
184
+ if (entry.agentId !== agentId) return false;
185
+ if ((entry.threadId ?? void 0) !== (threadId ?? void 0)) return false;
186
+ this.#tickets.delete(ticket);
187
+ return true;
188
+ }
189
+ /**
190
+ * Drop expired tickets from the front of the insertion-ordered map. Tickets
191
+ * are inserted in issue order and share a TTL, so the entries after the first
192
+ * live one are live too and the sweep can stop there.
193
+ */
194
+ #prune(now) {
195
+ for (const [ticket, entry] of this.#tickets) {
196
+ if (entry.expiresAt > now) break;
197
+ this.#tickets.delete(ticket);
198
+ }
199
+ }
200
+ /** Record an issuance window, evicting the oldest counter when at the cap. */
201
+ #trackIssuance(agentId, window) {
202
+ this.#issuance.delete(agentId);
203
+ this.#issuance.set(agentId, window);
204
+ if (this.#issuance.size > MAX_TRACKED_AGENTS) {
205
+ const oldest = this.#issuance.keys().next().value;
206
+ if (oldest !== void 0) this.#issuance.delete(oldest);
207
+ }
208
+ }
209
+ };
210
+ //#endregion
93
211
  //#region src/browser-stream/index.ts
212
+ const TICKET_BODY_MAX_BYTES = 4 * 1024;
213
+ /**
214
+ * Resolve an `allowedOrigins` value against the request's `Origin` header.
215
+ *
216
+ * Mirrors Hono's `cors.origin` semantics so the same config value means the same
217
+ * thing on the upgrade as it does on the HTTP routes: `'*'` allows everything,
218
+ * array entries match literally, and a callback is only an approval when it
219
+ * returns the request's own origin (the value Hono would echo back) or `'*'`.
220
+ */
221
+ async function isOriginAllowed(allowedOrigins, origin, c) {
222
+ if (Array.isArray(allowedOrigins)) return allowedOrigins.includes(origin);
223
+ if (typeof allowedOrigins === "function") {
224
+ const allowed = await allowedOrigins(origin, c);
225
+ return allowed === origin || allowed === "*";
226
+ }
227
+ return allowedOrigins === "*" || allowedOrigins === origin;
228
+ }
229
+ /**
230
+ * Is the `Origin` header the server's own origin?
231
+ *
232
+ * A Studio served by this server connects back to the same host, and operators
233
+ * don't add their own origin to a CORS allowlist because same-origin requests
234
+ * never needed CORS. Comparing hosts (rather than scheme + host) keeps that
235
+ * working behind TLS-terminating proxies, where the server sees `http` while the
236
+ * browser sends `https`.
237
+ */
238
+ function isSameOrigin(origin, requestHost) {
239
+ if (!requestHost) return false;
240
+ let originHost;
241
+ try {
242
+ originHost = new URL(origin).host;
243
+ } catch {
244
+ return false;
245
+ }
246
+ const host = (requestHost.split(",")[0] ?? "").trim();
247
+ return originHost !== "" && originHost.toLowerCase() === host.toLowerCase();
248
+ }
94
249
  /**
95
250
  * Set up WebSocket-based browser stream endpoint for real-time screencast viewing.
96
251
  *
@@ -100,6 +255,17 @@ function createAuthMiddleware({ mastra, requiresAuth = true }) {
100
255
  * - Broadcasts frames to all connected viewers
101
256
  * - Stops screencast when last viewer disconnects
102
257
  *
258
+ * All routes are authenticated with the server's auth configuration. Browsers
259
+ * cannot attach an `Authorization` header to a WebSocket upgrade, so
260
+ * session-cookie providers authenticate from the upgrade request's cookies.
261
+ * Clients that authenticate with a header token instead request a short-lived,
262
+ * single-use ticket from `POST ${apiPrefix}/agents/:agentId/browser/stream-ticket`
263
+ * and pass it to the upgrade as `?ticket=`, which keeps the reusable credential
264
+ * out of the URL. Non-browser clients can pass the token as the `apiKey` query
265
+ * parameter — use a short-lived token, since a URL can be retained in proxy and
266
+ * access logs, and prefer the `Authorization` header on the HTTP routes where the
267
+ * client can set one.
268
+ *
103
269
  * **Note**: Requires `ws` package to be installed. If not available, returns null
104
270
  * and logs a warning. Browser streaming will be disabled but everything else works.
105
271
  *
@@ -115,6 +281,7 @@ function createAuthMiddleware({ mastra, requiresAuth = true }) {
115
281
  *
116
282
  * const app = new Hono();
117
283
  * const browserStream = await setupBrowserStream(app, {
284
+ * mastra,
118
285
  * getToolset: (agentId) => browserToolsets.get(agentId),
119
286
  * });
120
287
  *
@@ -137,7 +304,24 @@ async function setupBrowserStream(app, config) {
137
304
  const registry = new _mastra_server_browser_stream.ViewerRegistry();
138
305
  const rawPrefix = config.apiPrefix ?? "/api";
139
306
  const apiPrefix = (rawPrefix.endsWith("/") ? rawPrefix.slice(0, -1) : rawPrefix) || "/api";
140
- app.get("/browser/:agentId/stream", upgradeWebSocket((c) => {
307
+ const authenticate = createAuthMiddleware({ mastra: config.mastra });
308
+ const upgradeTickets = new UpgradeTicketStore();
309
+ const authorizeUpgrade = async (c, next) => {
310
+ const ticket = c.req.query("ticket");
311
+ if (!ticket) return authenticate(c, next);
312
+ const agentId = c.req.param("agentId");
313
+ if (!upgradeTickets.consume(ticket, agentId, c.req.query("threadId"))) return c.json({ error: "Invalid or expired upgrade ticket" }, 401);
314
+ return next();
315
+ };
316
+ const allowedOrigins = config.allowedOrigins;
317
+ const checkOrigin = async (c, next) => {
318
+ const origin = c.req.header("origin");
319
+ if (!allowedOrigins || !origin) return next();
320
+ if (isSameOrigin(origin, c.req.header("x-forwarded-host") ?? c.req.header("host"))) return next();
321
+ if (!await isOriginAllowed(allowedOrigins, origin, c)) return c.text("Forbidden", 403);
322
+ return next();
323
+ };
324
+ app.get("/browser/:agentId/stream", checkOrigin, authorizeUpgrade, upgradeWebSocket((c) => {
141
325
  const agentId = c.req.param("agentId");
142
326
  const threadId = c.req.query("threadId");
143
327
  const viewerKey = threadId ? `${agentId}:${threadId}` : agentId;
@@ -159,7 +343,29 @@ async function setupBrowserStream(app, config) {
159
343
  }
160
344
  };
161
345
  }));
162
- app.get(`${apiPrefix}/agents/:agentId/browser/session`, async (c) => {
346
+ const ticketBodyLimit = (0, hono_body_limit.bodyLimit)({
347
+ maxSize: TICKET_BODY_MAX_BYTES,
348
+ onError: (c) => c.json({ error: "Request body too large" }, 413)
349
+ });
350
+ app.post(`${apiPrefix}/agents/:agentId/browser/stream-ticket`, authenticate, ticketBodyLimit, async (c) => {
351
+ const agentId = c.req.param("agentId");
352
+ if (!agentId) return c.json({ error: "Agent ID is required" }, 400);
353
+ let threadId = c.req.query("threadId");
354
+ try {
355
+ const body = await c.req.json();
356
+ if (!threadId && typeof body?.threadId === "string") threadId = body.threadId;
357
+ } catch {}
358
+ const issued = upgradeTickets.issue(agentId, threadId);
359
+ if (!issued.ok) {
360
+ const error = issued.reason === "capacity" ? "Too many outstanding browser stream upgrade tickets" : "Too many browser stream upgrade tickets requested";
361
+ return c.json({ error }, 429);
362
+ }
363
+ return c.json({
364
+ ticket: issued.ticket,
365
+ expiresIn: issued.expiresIn
366
+ });
367
+ });
368
+ app.get(`${apiPrefix}/agents/:agentId/browser/session`, authenticate, async (c) => {
163
369
  const agentId = c.req.param("agentId");
164
370
  if (!agentId) return c.json({ error: "Agent ID is required" }, 400);
165
371
  const threadId = c.req.query("threadId");
@@ -174,7 +380,7 @@ async function setupBrowserStream(app, config) {
174
380
  screencastAvailable: true
175
381
  });
176
382
  });
177
- app.post(`${apiPrefix}/agents/:agentId/browser/close`, async (c) => {
383
+ app.post(`${apiPrefix}/agents/:agentId/browser/close`, authenticate, ticketBodyLimit, async (c) => {
178
384
  const agentId = c.req.param("agentId");
179
385
  if (!agentId) return c.json({ error: "Agent ID is required" }, 400);
180
386
  const toolset = await config.getToolset(agentId);