@rynx-ai/server 0.1.0 → 0.1.10-beta.2

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.
Files changed (65) hide show
  1. package/dist/browser-surface-control-queue.d.ts +42 -0
  2. package/dist/browser-surface-control-queue.js +187 -0
  3. package/dist/browser-surface-ws.d.ts +16 -0
  4. package/dist/browser-surface-ws.js +356 -0
  5. package/dist/control-api.d.ts +76 -4
  6. package/dist/control-api.js +3014 -133
  7. package/dist/control-web/assets/highlighted-body-OFNGDK62-DHu2g-rk.js +1 -0
  8. package/dist/control-web/assets/index-B6Pb5j9M.js +666 -0
  9. package/dist/control-web/assets/index-BU-_Qud_.css +32 -0
  10. package/dist/control-web/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  11. package/dist/control-web/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  12. package/dist/control-web/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  13. package/dist/control-web/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  14. package/dist/control-web/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  15. package/dist/control-web/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  16. package/dist/control-web/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  17. package/dist/control-web/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  18. package/dist/control-web/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  19. package/dist/control-web/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  20. package/dist/control-web/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  21. package/dist/control-web/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  22. package/dist/control-web/assets/mermaid-GHXKKRXX-CSYzOmBR.js +131 -0
  23. package/dist/control-web/assets/rynx-wordmark-LL1QRYHD.png +0 -0
  24. package/dist/control-web/favicon.png +0 -0
  25. package/dist/control-web/favicon.svg +38 -0
  26. package/dist/control-web/index.html +16 -0
  27. package/dist/control-web-dist.d.ts +1 -1
  28. package/dist/control-web-dist.js +19 -14
  29. package/dist/desktop-browser-host.d.ts +170 -0
  30. package/dist/desktop-browser-host.js +1018 -0
  31. package/dist/direct-runtime-browser-inspect-server.d.ts +72 -0
  32. package/dist/direct-runtime-browser-inspect-server.js +749 -0
  33. package/dist/direct-runtime-browser-surface-server.d.ts +74 -0
  34. package/dist/direct-runtime-browser-surface-server.js +821 -0
  35. package/dist/direct-runtime-server.d.ts +143 -0
  36. package/dist/direct-runtime-server.js +1959 -0
  37. package/dist/emulator-surface-ws.d.ts +8 -0
  38. package/dist/emulator-surface-ws.js +198 -0
  39. package/dist/machine-session-service.d.ts +265 -0
  40. package/dist/machine-session-service.js +822 -0
  41. package/dist/provider-cli-host.d.ts +10 -0
  42. package/dist/provider-cli-host.js +1 -0
  43. package/dist/remote-runtime-dispatcher.d.ts +94 -0
  44. package/dist/remote-runtime-dispatcher.js +403 -0
  45. package/dist/remote-runtime-session-projection.d.ts +19 -0
  46. package/dist/remote-runtime-session-projection.js +566 -0
  47. package/dist/remote-runtime.d.ts +22 -0
  48. package/dist/remote-runtime.js +32 -0
  49. package/dist/runtime-web-auth.d.ts +26 -0
  50. package/dist/runtime-web-auth.js +132 -0
  51. package/dist/server.d.ts +364 -12
  52. package/dist/server.js +341 -100
  53. package/dist/session-browser-service.d.ts +248 -0
  54. package/dist/session-browser-service.js +772 -0
  55. package/dist/session-browser-surface-coordinator.d.ts +24 -0
  56. package/dist/session-browser-surface-coordinator.js +669 -0
  57. package/dist/session-emulator-service.d.ts +167 -0
  58. package/dist/session-emulator-service.js +464 -0
  59. package/dist/session-runtime-index.d.ts +23 -0
  60. package/dist/session-runtime-index.js +96 -0
  61. package/dist/session-terminal-host.d.ts +51 -0
  62. package/dist/session-terminal-host.js +270 -0
  63. package/dist/terminal-ws.d.ts +12 -20
  64. package/dist/terminal-ws.js +421 -97
  65. package/package.json +14 -7
@@ -16,25 +16,2267 @@
16
16
  * via the CLI, not here. The daemon owns the db-backed mutators (injected as
17
17
  * {@link ControlPlaneDeps}); the server owns the {@link ChannelManager}.
18
18
  */
19
- import { randomUUID } from "node:crypto";
19
+ import { timingSafeEqual } from "node:crypto";
20
20
  import Router from "@koa/router";
21
- import { agentSpecSchema, newSessionId, persistSessionEvent, skillInstallRecipeSchema, } from "@rynx-ai/core";
21
+ import { agentSpecSchema, newSessionId, skillInstallRecipeSchema, } from "@rynx-ai/core";
22
+ import { DAEMON_BROWSER_ARTIFACT_CLEAN_PATH, DAEMON_BROWSER_ARTIFACT_INSTALL_PATH, DAEMON_BROWSER_ARTIFACT_UPDATE_PATH, DAEMON_BROWSER_ARTIFACT_VERSION_PATH, DAEMON_CHROME_INSPECTION_PATH, DAEMON_CLEANUP_SESSIONS_PATH, DAEMON_SHUTDOWN_IF_IDLE_PATH, parseDaemonBrowserArtifactInstallInput, parseDaemonBrowserArtifactUpdateInput, parseDaemonChromeInspectionConfigureInput, parseDaemonCleanupSessionsInput, } from "@rynx-ai/protocol/control";
23
+ import { DIRECT_RUNTIME_CONTROL_PATH, encodePairingCode, } from "@rynx-ai/protocol/direct-runtime";
24
+ import { PLUGIN_INSTALL_PREPARATIONS_PATH, parsePluginInstallCommitInput, parsePluginInstallPrepareInput, } from "@rynx-ai/protocol/plugin-management";
25
+ import { parseDaemonStatus } from "@rynx-ai/protocol/remote-runtime";
26
+ import { materializeRemoteRuntimeSessionEvent, materializeRemoteRuntimeSessionRuntimeSnapshot, parseRemoteRuntimeRpcRequest, REMOTE_RUNTIME_RPC_MAX_FRAME_BYTES, SESSION_IMAGE_MEDIA_TYPES, SESSION_RESOURCE_MAX_IMAGE_BYTES, SESSION_RESOURCE_MAX_FILENAME_CHARS, SESSION_RESOURCE_TRANSFER_CHUNK_BYTES, REMOTE_RUNTIME_SESSION_MAX_PAGE_SIZE, } from "@rynx-ai/protocol/remote-runtime-rpc";
27
+ import { RUNTIME_BROWSER_BOOTSTRAP_PATH, RUNTIME_BROWSER_CAPABILITY_HEADER, RUNTIME_BROWSER_MANAGEMENT_PATH_PREFIX, RUNTIME_BROWSER_RPC_PATH, RUNTIME_BROWSER_SESSION_ID_HEADER, parseRuntimeBrowserBootstrapCredential, } from "@rynx-ai/protocol/runtime-browser-bootstrap";
28
+ import { parseRuntimeBrowserStateGetParams } from "@rynx-ai/protocol/runtime-browser";
22
29
  import { ensureCodexResumeRollout } from "@rynx-ai/runtime";
30
+ import { encodeDaemonStatus } from "./remote-runtime.js";
31
+ import { SessionRuntimeIndex } from "./session-runtime-index.js";
32
+ import { authorizeRuntimeWebRequest, RUNTIME_WEB_CAPABILITY, RUNTIME_WEB_CAPABILITY_HEADER, } from "./runtime-web-auth.js";
33
+ const PLUGIN_CLI_BODY_MAX_BYTES = 512 * 1024;
34
+ const PLUGIN_CLI_STDIN_MAX_BYTES = 256 * 1024;
35
+ const PLUGIN_CLI_ARG_MAX_BYTES = 4 * 1024;
36
+ const PLUGIN_CLI_ARGS_TOTAL_MAX_BYTES = 32 * 1024;
37
+ const PLUGIN_CLI_ARGS_MAX_COUNT = 64;
38
+ const PLUGIN_CLI_ERROR_MAX_BYTES = 16 * 1024;
39
+ const PLUGIN_CLI_TIMEOUT_MS = 120_000;
40
+ const PLUGIN_INSTALL_BODY_MAX_BYTES = 16 * 1024;
41
+ const PLUGIN_INSTALL_OPERATION_TIMEOUT_MS = 15 * 60_000;
42
+ const PLUGIN_INSTALL_ERROR_MAX_BYTES = 16 * 1024;
43
+ const BROWSER_ARTIFACT_BODY_MAX_BYTES = 4 * 1024;
44
+ const BROWSER_ARTIFACT_OPERATION_TIMEOUT_MS = 15 * 60_000;
45
+ const BROWSER_ARTIFACT_ERROR_MAX_BYTES = 16 * 1024;
46
+ const CHROME_INSPECTION_BODY_MAX_BYTES = 16 * 1024;
47
+ const CHROME_INSPECTION_ERROR_MAX_BYTES = 4 * 1024;
48
+ const CLEANUP_SESSIONS_BODY_MAX_BYTES = 1 * 1024;
49
+ const REMOTE_RUNTIME_ADMIN_BODY_MAX_BYTES = 4 * 1024;
50
+ const RUNTIME_TARGET_PAIR_BODY_MAX_BYTES = 64 * 1024;
51
+ const REMOTE_RUNTIME_EMULATOR_CONTROL_TIMEOUT_MS = 30_000;
52
+ const REMOTE_RUNTIME_EMULATOR_ATTACH_TIMEOUT_MS = 120_000;
53
+ const REMOTE_RUNTIME_BROWSER_CONTROL_TIMEOUT_MS = 30_000;
54
+ const REMOTE_RUNTIME_BROWSER_OPEN_TIMEOUT_MS = 120_000;
55
+ const REMOTE_RUNTIME_SESSION_TIMEOUT_MS = 30_000;
56
+ const REMOTE_RUNTIME_SESSION_BFF_MAX_PAGES = 128;
57
+ const REMOTE_RUNTIME_SESSION_BFF_MAX_SESSIONS = 20_000;
58
+ const REMOTE_RUNTIME_SESSION_BFF_MAX_ITEMS = 20_000;
59
+ const REMOTE_RUNTIME_SESSION_BFF_MAX_SNAPSHOT_BYTES = 32 * 1024 * 1024;
60
+ const RUNTIME_TARGET_SELECTOR_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._~-]{0,255}$/;
61
+ // A resolved interaction is duplicated in the canonical event and its durable
62
+ // item. Keep the accepted body comfortably below the 512 KiB plugin event cap.
63
+ const SESSION_INTERACTION_RESOLUTION_MAX_BYTES = 128 * 1024;
64
+ const RUNTIME_BROWSER_RPC_METHODS = new Set([
65
+ "browser.state.get",
66
+ "browser.open",
67
+ "browser.close",
68
+ "browser.page.create",
69
+ "browser.page.close",
70
+ "browser.page.activate",
71
+ "browser.page.navigate",
72
+ "browser.page.back",
73
+ "browser.page.forward",
74
+ "browser.page.reload",
75
+ ]);
23
76
  export function createControlRouter(opts) {
24
- const { manager, deps, runtime, sessionBus, sessionLog, runnerManager, sessionStore } = opts;
77
+ const { manager, deps, runtime, sessionBus, sessionLog, runnerManager, sessionStore, pluginRuntimeStatus, pluginRuntimeReload, pluginRuntimeEnsure, daemonManagement, remoteRuntime, remoteRuntimeAdmin, runtimeTargetControl, runtimeWebAccess, runtimeConnectionResolver, localSessionResources, sessionEmulators, shutdownSignal, sessionRuntimeIndex = new SessionRuntimeIndex(), sessionLifecycle, runtimeLocalBrowser, providerClis, } = opts;
25
78
  const router = new Router();
26
- // claude-native PermissionRequest long-polls: approvalId resolve the held
27
- // hook response with claude's verdict. The hook POST blocks until the web user
28
- // answers via `/approvals/:approvalId`, or the hook client disconnects.
29
- const claudeApprovals = new Map();
79
+ const mutateBrowserArtifact = async (ctx, operation) => {
80
+ const host = daemonManagement?.browserArtifacts;
81
+ if (!host || !isConfiguredDaemonManagement(daemonManagement)) {
82
+ return notFound(ctx, "Browser artifact management is not configured");
83
+ }
84
+ if (!authorizeLoopbackManagementRequest(ctx, daemonManagement.managementToken))
85
+ return;
86
+ if (!ctx.is("application/json")) {
87
+ ctx.status = 415;
88
+ ctx.body = { error: `Browser ${operation} requires application/json` };
89
+ return;
90
+ }
91
+ let body;
92
+ try {
93
+ body = await readBoundedJson(ctx, BROWSER_ARTIFACT_BODY_MAX_BYTES);
94
+ }
95
+ catch (error) {
96
+ if (error instanceof RequestBodyTooLargeError) {
97
+ ctx.status = 413;
98
+ ctx.body = { error: error.message };
99
+ return;
100
+ }
101
+ return bad(ctx, error instanceof Error ? error.message : String(error));
102
+ }
103
+ let installInput;
104
+ let updateInput;
105
+ try {
106
+ if (operation === "install") {
107
+ installInput = parseDaemonBrowserArtifactInstallInput(body);
108
+ }
109
+ else {
110
+ updateInput = parseDaemonBrowserArtifactUpdateInput(body);
111
+ }
112
+ }
113
+ catch (error) {
114
+ return bad(ctx, error instanceof Error ? error.message : String(error));
115
+ }
116
+ const invocation = browserArtifactInvocationAbortScope(ctx, shutdownSignal);
117
+ try {
118
+ ctx.set("Cache-Control", "no-store");
119
+ ctx.body = operation === "install"
120
+ ? await host.install(installInput, { signal: invocation.signal })
121
+ : await host.update(updateInput, { signal: invocation.signal });
122
+ }
123
+ catch (error) {
124
+ if (!ctx.res.destroyed) {
125
+ ctx.status = invocation.signal.aborted
126
+ ? invocation.signal.reason instanceof Error &&
127
+ invocation.signal.reason.name === "TimeoutError"
128
+ ? 504
129
+ : 499
130
+ : 409;
131
+ ctx.body = {
132
+ error: boundedUtf8(error instanceof Error ? error.message : String(error), BROWSER_ARTIFACT_ERROR_MAX_BYTES),
133
+ };
134
+ }
135
+ }
136
+ finally {
137
+ invocation.dispose();
138
+ }
139
+ };
30
140
  /** The instance (if any) bound to a channel — used to reload after a config change. */
31
141
  const instanceForChannel = (channelId) => deps.listInstancesConfig().find((i) => i.channelId === channelId);
142
+ const listRuntimeTargets = async (ctx) => {
143
+ if (!runtimeTargetControl)
144
+ return notFound(ctx, "Runtime target control is not configured");
145
+ try {
146
+ const targets = await runtimeTargetControl.list();
147
+ ctx.set("Cache-Control", "no-store");
148
+ ctx.body = { runtimes: targets.map(redactRuntimeTargetView) };
149
+ }
150
+ catch (error) {
151
+ runtimeTargetHostError(ctx, error);
152
+ }
153
+ };
154
+ const pairRuntimeTarget = async (ctx) => {
155
+ if (!runtimeTargetControl)
156
+ return notFound(ctx, "Runtime target control is not configured");
157
+ if (!ctx.is("application/json")) {
158
+ ctx.status = 415;
159
+ ctx.body = { error: "unsupported_media_type" };
160
+ return;
161
+ }
162
+ let body;
163
+ try {
164
+ body = await readBoundedJson(ctx, RUNTIME_TARGET_PAIR_BODY_MAX_BYTES);
165
+ }
166
+ catch (error) {
167
+ if (error instanceof RequestBodyTooLargeError) {
168
+ ctx.status = 413;
169
+ ctx.body = { error: "request_too_large" };
170
+ }
171
+ else {
172
+ ctx.status = 400;
173
+ ctx.body = { error: "invalid_offer" };
174
+ }
175
+ return;
176
+ }
177
+ if (!Object.hasOwn(body, "offer") ||
178
+ Object.keys(body).some((key) => key !== "offer" &&
179
+ key !== "displayName" &&
180
+ key !== "replaceExisting" &&
181
+ key !== "expectedDaemonId")) {
182
+ ctx.status = 400;
183
+ ctx.body = { error: "invalid_offer" };
184
+ return;
185
+ }
186
+ const displayName = body.displayName;
187
+ if (displayName !== undefined && !isRuntimeDisplayName(displayName)) {
188
+ ctx.status = 400;
189
+ ctx.body = { error: "invalid_target" };
190
+ return;
191
+ }
192
+ const replaceExisting = body.replaceExisting;
193
+ if (replaceExisting !== undefined && typeof replaceExisting !== "boolean") {
194
+ ctx.status = 400;
195
+ ctx.body = { error: "invalid_target" };
196
+ return;
197
+ }
198
+ const expectedDaemonId = body.expectedDaemonId;
199
+ if (replaceExisting === true
200
+ ? !isRuntimeTargetSelector(expectedDaemonId) || expectedDaemonId === "local"
201
+ : expectedDaemonId !== undefined) {
202
+ ctx.status = 400;
203
+ ctx.body = { error: "invalid_target" };
204
+ return;
205
+ }
206
+ try {
207
+ const result = await runtimeTargetControl.pair({
208
+ offer: body.offer,
209
+ ...(displayName === undefined ? {} : { displayName }),
210
+ ...(replaceExisting === undefined ? {} : { replaceExisting }),
211
+ ...(replaceExisting === true
212
+ ? { expectedDaemonId: expectedDaemonId }
213
+ : {}),
214
+ });
215
+ ctx.set("Cache-Control", "no-store");
216
+ ctx.body = redactRuntimeTargetPairResult(result);
217
+ }
218
+ catch (error) {
219
+ runtimeTargetHostError(ctx, error);
220
+ }
221
+ };
222
+ const testRuntimeTarget = async (ctx) => {
223
+ if (!runtimeTargetControl)
224
+ return notFound(ctx, "Runtime target control is not configured");
225
+ const selector = ctx.params.selector;
226
+ if (!isRuntimeTargetSelector(selector)) {
227
+ ctx.status = 400;
228
+ ctx.body = { error: "invalid_target" };
229
+ return;
230
+ }
231
+ try {
232
+ const result = await runtimeTargetControl.test(selector);
233
+ ctx.set("Cache-Control", "no-store");
234
+ ctx.body = redactRuntimeTargetTestResult(result, selector);
235
+ }
236
+ catch (error) {
237
+ runtimeTargetHostError(ctx, error);
238
+ }
239
+ };
240
+ const forgetRuntimeTarget = async (ctx) => {
241
+ if (!runtimeTargetControl)
242
+ return notFound(ctx, "Runtime target control is not configured");
243
+ const daemonId = ctx.params.daemonId;
244
+ if (!isRuntimeTargetSelector(daemonId)) {
245
+ ctx.status = 400;
246
+ ctx.body = { error: "invalid_target" };
247
+ return;
248
+ }
249
+ try {
250
+ ctx.set("Cache-Control", "no-store");
251
+ const result = await runtimeTargetControl.forget(daemonId);
252
+ if (result.forgotten !== true)
253
+ throw new Error("Runtime target host returned an invalid result");
254
+ ctx.body = { forgotten: true };
255
+ }
256
+ catch (error) {
257
+ runtimeTargetHostError(ctx, error);
258
+ }
259
+ };
260
+ const createRuntimePairingOffer = async (ctx, response) => {
261
+ ctx.set("Cache-Control", "no-store");
262
+ if (!remoteRuntimeAdmin) {
263
+ return notFound(ctx, "Remote Runtime administration is not configured");
264
+ }
265
+ if (!ctx.is("application/json")) {
266
+ ctx.status = 415;
267
+ ctx.body = { error: "pairing offer creation requires application/json" };
268
+ return;
269
+ }
270
+ try {
271
+ const body = await readBoundedJson(ctx, REMOTE_RUNTIME_ADMIN_BODY_MAX_BYTES);
272
+ if (Object.keys(body).some((key) => key !== "clientLabel" && key !== "address")) {
273
+ throw new Error("pairing offer request contains unknown fields");
274
+ }
275
+ const clientLabel = optionalBoundedLabel(body.clientLabel);
276
+ const address = optionalAdvertiseAddress(body.address);
277
+ const offer = remoteRuntimeAdmin.createPairingOffer({
278
+ ...(clientLabel ? { clientLabel } : {}),
279
+ ...(address ? { address } : {}),
280
+ });
281
+ ctx.body = response === "offer"
282
+ ? offer
283
+ : { pairingCode: encodePairingCode(offer) };
284
+ }
285
+ catch (error) {
286
+ if (error instanceof RequestBodyTooLargeError) {
287
+ ctx.status = 413;
288
+ }
289
+ else {
290
+ ctx.status = 409;
291
+ }
292
+ ctx.body = { error: error instanceof Error ? error.message : String(error) };
293
+ }
294
+ };
295
+ const listRuntimeShareAddresses = (ctx) => {
296
+ ctx.set("Cache-Control", "no-store");
297
+ if (!remoteRuntimeAdmin) {
298
+ return notFound(ctx, "Remote Runtime administration is not configured");
299
+ }
300
+ try {
301
+ ctx.body = { addresses: remoteRuntimeAdmin.listShareAddresses() };
302
+ }
303
+ catch (error) {
304
+ ctx.status = 500;
305
+ ctx.body = { error: error instanceof Error ? error.message : String(error) };
306
+ }
307
+ };
308
+ const listRuntimeAccess = (ctx) => {
309
+ ctx.set("Cache-Control", "no-store");
310
+ if (!remoteRuntimeAdmin) {
311
+ return notFound(ctx, "Remote Runtime administration is not configured");
312
+ }
313
+ try {
314
+ ctx.body = { clients: remoteRuntimeAdmin.listClientGrants() };
315
+ }
316
+ catch (error) {
317
+ ctx.status = 500;
318
+ ctx.body = { error: error instanceof Error ? error.message : String(error) };
319
+ }
320
+ };
321
+ const revokeRuntimeAccess = (ctx) => {
322
+ ctx.set("Cache-Control", "no-store");
323
+ if (!remoteRuntimeAdmin) {
324
+ return notFound(ctx, "Remote Runtime administration is not configured");
325
+ }
326
+ if (!/^[A-Za-z0-9][A-Za-z0-9._~-]{0,255}$/.test(ctx.params.grantId)) {
327
+ return bad(ctx, "invalid Remote Runtime grant id");
328
+ }
329
+ try {
330
+ const result = remoteRuntimeAdmin.revokeClientGrant(ctx.params.grantId);
331
+ if (!result.revoked) {
332
+ return notFound(ctx, "Remote Runtime client grant not found or already revoked");
333
+ }
334
+ ctx.body = result;
335
+ }
336
+ catch (error) {
337
+ ctx.status = 409;
338
+ ctx.body = { error: error instanceof Error ? error.message : String(error) };
339
+ }
340
+ };
341
+ const deleteRevokedRuntimeAccess = (ctx) => {
342
+ ctx.set("Cache-Control", "no-store");
343
+ if (!remoteRuntimeAdmin) {
344
+ return notFound(ctx, "Remote Runtime administration is not configured");
345
+ }
346
+ if (!/^[A-Za-z0-9][A-Za-z0-9._~-]{0,255}$/.test(ctx.params.grantId)) {
347
+ return bad(ctx, "invalid Remote Runtime grant id");
348
+ }
349
+ try {
350
+ const result = remoteRuntimeAdmin.deleteRevokedClientGrant(ctx.params.grantId);
351
+ if (!result.deleted) {
352
+ return notFound(ctx, "Remote Runtime client grant not found or still active");
353
+ }
354
+ ctx.body = result;
355
+ }
356
+ catch (error) {
357
+ ctx.status = 409;
358
+ ctx.body = { error: error instanceof Error ? error.message : String(error) };
359
+ }
360
+ };
361
+ const authorizePublicRuntimeTargetRequest = (ctx, options = {}) => {
362
+ ctx.set("Cache-Control", "no-store");
363
+ const failure = authorizeRuntimeWebRequest(ctx.req, {
364
+ mutation: options.mutation,
365
+ capability: ctx.get(RUNTIME_WEB_CAPABILITY_HEADER),
366
+ access: runtimeWebAccess,
367
+ });
368
+ if (!failure)
369
+ return true;
370
+ ctx.status = failure.status;
371
+ ctx.body = { error: failure.error };
372
+ return false;
373
+ };
374
+ const authorizeInternalRuntimeTargetRequest = (ctx) => {
375
+ if (!runtimeTargetControl || !isConfiguredDaemonManagement(daemonManagement)) {
376
+ notFound(ctx, "Runtime target control is not configured");
377
+ return false;
378
+ }
379
+ return authorizeLoopbackManagementRequest(ctx, daemonManagement.managementToken);
380
+ };
381
+ router.get("/api/runtimes/capability", (ctx) => {
382
+ if (!authorizePublicRuntimeTargetRequest(ctx))
383
+ return;
384
+ if (!runtimeTargetControl)
385
+ return notFound(ctx, "Runtime target control is not configured");
386
+ ctx.set("Cross-Origin-Resource-Policy", "same-origin");
387
+ ctx.body = { capability: RUNTIME_WEB_CAPABILITY };
388
+ });
389
+ router.get("/api/runtimes", async (ctx) => {
390
+ if (authorizePublicRuntimeTargetRequest(ctx))
391
+ await listRuntimeTargets(ctx);
392
+ });
393
+ router.post("/api/runtimes/pair", async (ctx) => {
394
+ if (authorizePublicRuntimeTargetRequest(ctx, { mutation: true }))
395
+ await pairRuntimeTarget(ctx);
396
+ });
397
+ router.post("/api/runtimes/:selector/test", async (ctx) => {
398
+ if (authorizePublicRuntimeTargetRequest(ctx, { mutation: true }))
399
+ await testRuntimeTarget(ctx);
400
+ });
401
+ router.delete("/api/runtimes/:daemonId", async (ctx) => {
402
+ if (authorizePublicRuntimeTargetRequest(ctx, { mutation: true }))
403
+ await forgetRuntimeTarget(ctx);
404
+ });
405
+ router.get("/api/runtime-server/share-addresses", (ctx) => {
406
+ if (authorizePublicRuntimeTargetRequest(ctx))
407
+ listRuntimeShareAddresses(ctx);
408
+ });
409
+ router.post("/api/runtime-server/share", async (ctx) => {
410
+ if (authorizePublicRuntimeTargetRequest(ctx, { mutation: true })) {
411
+ await createRuntimePairingOffer(ctx, "pairing_code");
412
+ }
413
+ });
414
+ router.get("/api/runtime-server/access", (ctx) => {
415
+ if (authorizePublicRuntimeTargetRequest(ctx))
416
+ listRuntimeAccess(ctx);
417
+ });
418
+ router.delete("/api/runtime-server/access/:grantId", (ctx) => {
419
+ if (authorizePublicRuntimeTargetRequest(ctx, { mutation: true })) {
420
+ revokeRuntimeAccess(ctx);
421
+ }
422
+ });
423
+ router.delete("/api/runtime-server/access/:grantId/record", (ctx) => {
424
+ if (authorizePublicRuntimeTargetRequest(ctx, { mutation: true })) {
425
+ deleteRevokedRuntimeAccess(ctx);
426
+ }
427
+ });
428
+ router.get("/api/internal/runtimes", async (ctx) => {
429
+ if (authorizeInternalRuntimeTargetRequest(ctx))
430
+ await listRuntimeTargets(ctx);
431
+ });
432
+ router.post("/api/internal/runtimes/pair", async (ctx) => {
433
+ if (authorizeInternalRuntimeTargetRequest(ctx))
434
+ await pairRuntimeTarget(ctx);
435
+ });
436
+ router.post("/api/internal/runtimes/:selector/test", async (ctx) => {
437
+ if (authorizeInternalRuntimeTargetRequest(ctx))
438
+ await testRuntimeTarget(ctx);
439
+ });
440
+ router.delete("/api/internal/runtimes/:daemonId", async (ctx) => {
441
+ if (authorizeInternalRuntimeTargetRequest(ctx))
442
+ await forgetRuntimeTarget(ctx);
443
+ });
444
+ const emulatorSessionOf = (ctx) => ctx.get("x-rynx-session")?.trim() || undefined;
445
+ // ── Runtime-scoped emulator unary control ───────────────────────────────
446
+ // Media, screenshots, low-latency touch, and raw exec deliberately remain on
447
+ // the local-only /api/emulator surface until their stream/security contracts
448
+ // exist. Every route below carries an explicit selector and never falls back.
449
+ const authorizeInternalRuntimeCall = (ctx) => {
450
+ if (!runtimeConnectionResolver || !isConfiguredDaemonManagement(daemonManagement)) {
451
+ notFound(ctx, "Runtime connection resolver is not configured");
452
+ return false;
453
+ }
454
+ return authorizeLoopbackManagementRequest(ctx, daemonManagement.managementToken);
455
+ };
456
+ const runtimeCall = async (ctx, method, params) => {
457
+ if (!runtimeConnectionResolver) {
458
+ notFound(ctx, "Runtime connection resolver is not configured");
459
+ return { ok: false };
460
+ }
461
+ const selector = ctx.params.selector;
462
+ if (!isRuntimeTargetSelector(selector)) {
463
+ ctx.status = 400;
464
+ ctx.body = { error: "invalid_target" };
465
+ return { ok: false };
466
+ }
467
+ let validatedParams;
468
+ try {
469
+ validatedParams = parseRemoteRuntimeRpcRequest({
470
+ type: "rpc.request",
471
+ id: "control-plane-bff",
472
+ method,
473
+ params,
474
+ }).params;
475
+ }
476
+ catch {
477
+ ctx.status = 400;
478
+ ctx.body = { error: "invalid_request" };
479
+ return { ok: false };
480
+ }
481
+ let lease;
482
+ try {
483
+ lease = shutdownSignal
484
+ ? await runtimeConnectionResolver.acquire(selector, { signal: shutdownSignal })
485
+ : await runtimeConnectionResolver.acquire(selector);
486
+ if (lease.target.selector !== selector ||
487
+ !isRuntimeTargetSelector(lease.target.daemonId)) {
488
+ throw new Error("Runtime resolver returned a mismatched target");
489
+ }
490
+ }
491
+ catch (error) {
492
+ lease?.release();
493
+ runtimeTargetHostError(ctx, error, "not_started");
494
+ return { ok: false };
495
+ }
496
+ if (!lease) {
497
+ runtimeTargetHostError(ctx, new Error("Runtime resolver did not return a lease"), "not_started");
498
+ return { ok: false };
499
+ }
500
+ try {
501
+ const result = await lease.call(method, validatedParams, {
502
+ ...(shutdownSignal ? { signal: shutdownSignal } : {}),
503
+ timeoutMs: remoteRuntimeRpcTimeoutMs(method, validatedParams),
504
+ });
505
+ ctx.set("Cache-Control", "no-store");
506
+ return { ok: true, result };
507
+ }
508
+ catch (error) {
509
+ runtimeTargetHostError(ctx, error);
510
+ return { ok: false };
511
+ }
512
+ finally {
513
+ lease.release();
514
+ }
515
+ };
516
+ /**
517
+ * Management-only typed Runtime gateway for short-lived local clients.
518
+ * Target resolution, protocol validation, timeout policy, lease ownership,
519
+ * and no-fallback behavior remain centralized in runtimeCall.
520
+ */
521
+ const internalRuntimeRpc = async (ctx) => {
522
+ if (!ctx.is("application/json")) {
523
+ ctx.status = 415;
524
+ ctx.body = { error: "unsupported_media_type" };
525
+ return;
526
+ }
527
+ let body;
528
+ try {
529
+ body = await readBoundedJson(ctx, REMOTE_RUNTIME_RPC_MAX_FRAME_BYTES);
530
+ }
531
+ catch (error) {
532
+ ctx.status = error instanceof RequestBodyTooLargeError ? 413 : 400;
533
+ ctx.body = {
534
+ error: error instanceof RequestBodyTooLargeError ? "request_too_large" : "invalid_request",
535
+ };
536
+ return;
537
+ }
538
+ if (!Object.hasOwn(body, "method") ||
539
+ !Object.hasOwn(body, "params") ||
540
+ Object.keys(body).some((key) => key !== "method" && key !== "params")) {
541
+ ctx.status = 400;
542
+ ctx.body = { error: "invalid_request" };
543
+ return;
544
+ }
545
+ let request;
546
+ try {
547
+ request = parseRemoteRuntimeRpcRequest({
548
+ type: "rpc.request",
549
+ id: "control-plane-bff",
550
+ method: body.method,
551
+ params: body.params,
552
+ });
553
+ }
554
+ catch {
555
+ ctx.status = 400;
556
+ ctx.body = { error: "invalid_request" };
557
+ return;
558
+ }
559
+ const outcome = await runtimeCall(ctx, request.method, request.params);
560
+ if (outcome.ok)
561
+ ctx.body = { result: outcome.result };
562
+ };
563
+ const acquireRuntimeLease = async (ctx, signal) => {
564
+ if (!runtimeConnectionResolver) {
565
+ notFound(ctx, "Runtime connection resolver is not configured");
566
+ return undefined;
567
+ }
568
+ const selector = ctx.params.selector;
569
+ if (!isRuntimeTargetSelector(selector)) {
570
+ ctx.status = 400;
571
+ ctx.body = { error: "invalid_target" };
572
+ return undefined;
573
+ }
574
+ try {
575
+ const lease = signal === undefined
576
+ ? await runtimeConnectionResolver.acquire(selector)
577
+ : await runtimeConnectionResolver.acquire(selector, { signal });
578
+ if (lease.target.selector !== selector || !isRuntimeTargetSelector(lease.target.daemonId)) {
579
+ lease.release();
580
+ throw runtimeProtocolError("Runtime resolver returned a mismatched target");
581
+ }
582
+ return lease;
583
+ }
584
+ catch (error) {
585
+ runtimeTargetHostError(ctx, error, "not_started");
586
+ return undefined;
587
+ }
588
+ };
589
+ const runtimeSessions = async (ctx) => {
590
+ const lease = await acquireRuntimeLease(ctx);
591
+ if (!lease)
592
+ return;
593
+ const sessions = [];
594
+ const ids = new Set();
595
+ const cursors = new Set();
596
+ let cursor;
597
+ try {
598
+ for (let pageIndex = 0; pageIndex < REMOTE_RUNTIME_SESSION_BFF_MAX_PAGES; pageIndex += 1) {
599
+ const page = await lease.call("session.list", {
600
+ ...(cursor === undefined ? {} : { cursor }),
601
+ limit: REMOTE_RUNTIME_SESSION_MAX_PAGE_SIZE,
602
+ }, { timeoutMs: REMOTE_RUNTIME_SESSION_TIMEOUT_MS });
603
+ for (const session of page.sessions) {
604
+ if (ids.has(session.id)) {
605
+ throw runtimeProtocolError("Runtime Session list repeated a session");
606
+ }
607
+ ids.add(session.id);
608
+ sessions.push(session);
609
+ if (sessions.length > REMOTE_RUNTIME_SESSION_BFF_MAX_SESSIONS) {
610
+ throw runtimeCapacityError("Runtime Session list exceeds the Control Plane bound");
611
+ }
612
+ }
613
+ if (!page.hasMore) {
614
+ ctx.set("Cache-Control", "no-store");
615
+ ctx.body = { sessions };
616
+ return;
617
+ }
618
+ const nextCursor = page.nextCursor;
619
+ if (!nextCursor || cursors.has(nextCursor)) {
620
+ throw runtimeProtocolError("Runtime Session list cursor did not advance");
621
+ }
622
+ cursors.add(nextCursor);
623
+ cursor = nextCursor;
624
+ }
625
+ throw runtimeCapacityError("Runtime Session list exceeds the page bound");
626
+ }
627
+ catch (error) {
628
+ runtimeTargetHostError(ctx, error);
629
+ }
630
+ finally {
631
+ lease.release();
632
+ }
633
+ };
634
+ const runtimeSessionSnapshot = async (ctx) => {
635
+ const lease = await acquireRuntimeLease(ctx);
636
+ if (!lease)
637
+ return;
638
+ const sessionId = ctx.params.id;
639
+ const items = [];
640
+ const ids = new Set();
641
+ let afterId;
642
+ let bytes = 0;
643
+ let runtime;
644
+ try {
645
+ for (let pageIndex = 0; pageIndex < REMOTE_RUNTIME_SESSION_BFF_MAX_PAGES; pageIndex += 1) {
646
+ const page = await lease.call("session.snapshot.get", {
647
+ sessionId,
648
+ ...(afterId === undefined ? {} : { afterId }),
649
+ limit: REMOTE_RUNTIME_SESSION_MAX_PAGE_SIZE,
650
+ }, { timeoutMs: REMOTE_RUNTIME_SESSION_TIMEOUT_MS });
651
+ if (page.sessionId !== sessionId) {
652
+ throw runtimeProtocolError("Runtime Session snapshot identity changed");
653
+ }
654
+ runtime = page.runtime;
655
+ for (const item of page.items) {
656
+ if (ids.has(item.id)) {
657
+ throw runtimeProtocolError("Runtime Session snapshot repeated an item");
658
+ }
659
+ ids.add(item.id);
660
+ items.push(item);
661
+ bytes += Buffer.byteLength(JSON.stringify(item), "utf8");
662
+ if (items.length > REMOTE_RUNTIME_SESSION_BFF_MAX_ITEMS ||
663
+ bytes > REMOTE_RUNTIME_SESSION_BFF_MAX_SNAPSHOT_BYTES) {
664
+ throw runtimeCapacityError("Runtime Session snapshot exceeds the Control Plane bound");
665
+ }
666
+ }
667
+ if (!page.hasMore) {
668
+ if (!runtime)
669
+ throw runtimeProtocolError("Runtime Session snapshot omitted runtime state");
670
+ ctx.set("Cache-Control", "no-store");
671
+ ctx.body = {
672
+ sessionId,
673
+ items,
674
+ runtime: materializeRemoteRuntimeSessionRuntimeSnapshot(runtime, items),
675
+ };
676
+ return;
677
+ }
678
+ if (!page.nextAfterId || page.nextAfterId === afterId) {
679
+ throw runtimeProtocolError("Runtime Session snapshot cursor did not advance");
680
+ }
681
+ afterId = page.nextAfterId;
682
+ }
683
+ throw runtimeCapacityError("Runtime Session snapshot exceeds the page bound");
684
+ }
685
+ catch (error) {
686
+ runtimeTargetHostError(ctx, error);
687
+ }
688
+ finally {
689
+ lease.release();
690
+ }
691
+ };
692
+ const runtimeSessionInterrupt = async (ctx) => {
693
+ const outcome = await runtimeCall(ctx, "session.interrupt", { sessionId: ctx.params.id });
694
+ if (outcome.ok)
695
+ ctx.body = { ok: true, interrupted: outcome.result.interrupted };
696
+ };
697
+ const runtimeSessionAgentOptions = async (ctx) => {
698
+ const outcome = await runtimeCall(ctx, "session.agentOptions.list", {});
699
+ if (outcome.ok)
700
+ ctx.body = outcome.result;
701
+ };
702
+ const runtimeSessionLaunchOptions = async (ctx) => {
703
+ const outcome = await runtimeCall(ctx, "session.launchOptions.list", {});
704
+ if (outcome.ok)
705
+ ctx.body = outcome.result;
706
+ };
707
+ const runtimeSessionCreate = async (ctx) => {
708
+ const body = await readRuntimeBody(ctx);
709
+ if (!body)
710
+ return;
711
+ const outcome = await runtimeCall(ctx, "session.create", body);
712
+ if (outcome.ok)
713
+ ctx.body = outcome.result;
714
+ };
715
+ const runtimeSessionMessage = async (ctx) => {
716
+ const body = await readRuntimeBody(ctx);
717
+ if (!body)
718
+ return;
719
+ if (!acceptRuntimeBodyKeys(ctx, body, ["message", "content", "clientMessageId"]))
720
+ return;
721
+ const outcome = await runtimeCall(ctx, "session.message.send", {
722
+ sessionId: ctx.params.id,
723
+ ...(body.message === undefined ? {} : { message: body.message }),
724
+ ...(body.content === undefined
725
+ ? {}
726
+ : {
727
+ content: body.content,
728
+ }),
729
+ ...(body.clientMessageId === undefined
730
+ ? {}
731
+ : { clientMessageId: body.clientMessageId }),
732
+ });
733
+ if (outcome.ok)
734
+ ctx.body = outcome.result;
735
+ };
736
+ const runtimeSessionResourcePolicy = async (ctx) => {
737
+ if (ctx.params.selector === "local" && localSessionResources) {
738
+ try {
739
+ ctx.set("Cache-Control", "no-store");
740
+ ctx.body = await localSessionResources.resourcePolicy(ctx.params.id);
741
+ }
742
+ catch (error) {
743
+ runtimeTargetHostError(ctx, error);
744
+ }
745
+ return;
746
+ }
747
+ const outcome = await runtimeCall(ctx, "session.resource.policy.get", {
748
+ sessionId: ctx.params.id,
749
+ });
750
+ if (outcome.ok)
751
+ ctx.body = outcome.result;
752
+ };
753
+ const runtimeSessionResourceUpload = async (ctx) => {
754
+ const uploadStarted = performance.now();
755
+ const mediaType = ctx.get("content-type").split(";", 1)[0]?.trim();
756
+ const clientUploadId = ctx.get("x-rynx-upload-id").trim();
757
+ const encodedFilename = ctx.get("x-rynx-filename").trim();
758
+ let filename = "";
759
+ try {
760
+ filename = decodeURIComponent(encodedFilename);
761
+ }
762
+ catch {
763
+ ctx.status = 400;
764
+ ctx.body = { error: "invalid_filename" };
765
+ return;
766
+ }
767
+ if (!mediaType ||
768
+ !SESSION_IMAGE_MEDIA_TYPES.includes(mediaType) ||
769
+ !clientUploadId ||
770
+ !filename ||
771
+ filename.length > SESSION_RESOURCE_MAX_FILENAME_CHARS) {
772
+ ctx.status = 400;
773
+ ctx.body = { error: "invalid_resource_upload" };
774
+ return;
775
+ }
776
+ let bytes;
777
+ const bodyReadStarted = performance.now();
778
+ try {
779
+ bytes = await readBoundedBinary(ctx, SESSION_RESOURCE_MAX_IMAGE_BYTES);
780
+ }
781
+ catch (error) {
782
+ ctx.status = error instanceof RequestBodyTooLargeError ? 413 : 400;
783
+ ctx.body = {
784
+ error: error instanceof RequestBodyTooLargeError
785
+ ? "request_too_large"
786
+ : "invalid_resource_upload",
787
+ };
788
+ return;
789
+ }
790
+ if (bytes.byteLength === 0) {
791
+ ctx.status = 400;
792
+ ctx.body = { error: "empty_resource" };
793
+ return;
794
+ }
795
+ const sessionId = ctx.params.id;
796
+ const local = ctx.params.selector === "local" ? localSessionResources : undefined;
797
+ const lease = local ? undefined : await acquireRuntimeLease(ctx);
798
+ if (!local && !lease)
799
+ return;
800
+ const timings = new Map([
801
+ ["body", performance.now() - bodyReadStarted],
802
+ ]);
803
+ const timed = async (name, operation) => {
804
+ const started = performance.now();
805
+ try {
806
+ return await operation();
807
+ }
808
+ finally {
809
+ timings.set(name, (timings.get(name) ?? 0) + performance.now() - started);
810
+ }
811
+ };
812
+ const policy = () => timed("policy", () => local
813
+ ? local.resourcePolicy(sessionId)
814
+ : lease.call("session.resource.policy.get", { sessionId }, { timeoutMs: REMOTE_RUNTIME_SESSION_TIMEOUT_MS }));
815
+ const begin = () => timed("begin", () => local
816
+ ? local.beginResourceUpload({
817
+ sessionId,
818
+ clientUploadId,
819
+ filename,
820
+ mediaType,
821
+ byteLength: bytes.byteLength,
822
+ })
823
+ : lease.call("session.resource.upload.begin", {
824
+ sessionId,
825
+ clientUploadId,
826
+ filename,
827
+ mediaType,
828
+ byteLength: bytes.byteLength,
829
+ }, { timeoutMs: REMOTE_RUNTIME_SESSION_TIMEOUT_MS }));
830
+ const writeChunk = (uploadId, offset, data) => timed("chunks", () => local
831
+ ? local.writeResourceUploadChunk({ sessionId, uploadId, offset, data })
832
+ : lease.call("session.resource.upload.chunk", { sessionId, uploadId, offset, data }, { timeoutMs: REMOTE_RUNTIME_SESSION_TIMEOUT_MS }));
833
+ const commit = (uploadId) => timed("commit", () => local
834
+ ? local.commitResourceUpload({ sessionId, uploadId })
835
+ : lease.call("session.resource.upload.commit", { sessionId, uploadId }, { timeoutMs: REMOTE_RUNTIME_SESSION_TIMEOUT_MS }));
836
+ try {
837
+ const resourcePolicy = await policy();
838
+ if (!resourcePolicy.enabled ||
839
+ !resourcePolicy.mediaTypes.includes(mediaType) ||
840
+ bytes.byteLength > resourcePolicy.maxImageBytes) {
841
+ ctx.status = 413;
842
+ ctx.body = { error: "resource_policy_rejected" };
843
+ return;
844
+ }
845
+ const begun = await begin();
846
+ if (begun.complete && begun.resource) {
847
+ ctx.set("Cache-Control", "no-store");
848
+ ctx.body = { resource: begun.resource };
849
+ return;
850
+ }
851
+ if (begun.offset < 0 || begun.offset > bytes.byteLength) {
852
+ throw runtimeProtocolError("Runtime Session resource upload offset is invalid");
853
+ }
854
+ let offset = begun.offset;
855
+ while (offset < bytes.byteLength) {
856
+ const chunk = bytes.subarray(offset, Math.min(offset + SESSION_RESOURCE_TRANSFER_CHUNK_BYTES, bytes.byteLength));
857
+ const written = await writeChunk(begun.uploadId, offset, chunk.toString("base64"));
858
+ if (written.offset <= offset || written.offset > bytes.byteLength) {
859
+ throw runtimeProtocolError("Runtime Session resource upload did not advance");
860
+ }
861
+ offset = written.offset;
862
+ }
863
+ const committed = await commit(begun.uploadId);
864
+ if (committed.resource.resourceId !== begun.resourceId ||
865
+ committed.resource.mediaType !== mediaType ||
866
+ committed.resource.byteLength !== bytes.byteLength) {
867
+ throw runtimeProtocolError("Runtime Session resource upload identity changed");
868
+ }
869
+ ctx.status = 201;
870
+ ctx.set("Cache-Control", "no-store");
871
+ ctx.body = committed;
872
+ }
873
+ catch (error) {
874
+ runtimeTargetHostError(ctx, error);
875
+ }
876
+ finally {
877
+ lease?.release();
878
+ const totalDuration = performance.now() - uploadStarted;
879
+ timings.set("total", totalDuration);
880
+ ctx.set("Server-Timing", [...timings.entries()]
881
+ .map(([name, duration]) => `${name};dur=${duration.toFixed(1)}`)
882
+ .join(", "));
883
+ if (totalDuration >= 1_000) {
884
+ console.warn(JSON.stringify({
885
+ level: "warn",
886
+ event: "session.resource.upload.slow",
887
+ selector: ctx.params.selector,
888
+ sessionId,
889
+ byteLength: bytes.byteLength,
890
+ status: ctx.status,
891
+ timings: Object.fromEntries([...timings.entries()].map(([name, duration]) => [
892
+ name,
893
+ Math.round(duration),
894
+ ])),
895
+ }));
896
+ }
897
+ }
898
+ };
899
+ const runtimeSessionResourceMetadata = async (ctx) => {
900
+ if (ctx.params.selector === "local" && localSessionResources) {
901
+ try {
902
+ const result = await localSessionResources.getResource({
903
+ sessionId: ctx.params.id,
904
+ resourceId: ctx.params.resourceId,
905
+ });
906
+ if (result.resource.resourceId !== ctx.params.resourceId) {
907
+ throw runtimeProtocolError("Runtime Session resource identity changed");
908
+ }
909
+ ctx.set("Cache-Control", "no-store");
910
+ ctx.body = result;
911
+ }
912
+ catch (error) {
913
+ runtimeTargetHostError(ctx, error);
914
+ }
915
+ return;
916
+ }
917
+ const outcome = await runtimeCall(ctx, "session.resource.get", {
918
+ sessionId: ctx.params.id,
919
+ resourceId: ctx.params.resourceId,
920
+ });
921
+ if (!outcome.ok)
922
+ return;
923
+ if (outcome.result.resource.resourceId !== ctx.params.resourceId) {
924
+ runtimeTargetHostError(ctx, runtimeProtocolError("Runtime Session resource identity changed"));
925
+ return;
926
+ }
927
+ ctx.body = outcome.result;
928
+ };
929
+ const runtimeSessionResourceContent = async (ctx) => {
930
+ const sessionId = ctx.params.id;
931
+ const resourceId = ctx.params.resourceId;
932
+ const local = ctx.params.selector === "local" ? localSessionResources : undefined;
933
+ const lease = local ? undefined : await acquireRuntimeLease(ctx);
934
+ if (!local && !lease)
935
+ return;
936
+ try {
937
+ const { resource } = local
938
+ ? await local.getResource({ sessionId, resourceId })
939
+ : await lease.call("session.resource.get", { sessionId, resourceId }, { timeoutMs: REMOTE_RUNTIME_SESSION_TIMEOUT_MS });
940
+ if (resource.resourceId !== resourceId) {
941
+ throw runtimeProtocolError("Runtime Session resource identity changed");
942
+ }
943
+ const etag = `"sha256-${resource.sha256}"`;
944
+ ctx.set("Cache-Control", "private, max-age=0, must-revalidate");
945
+ ctx.set("ETag", etag);
946
+ if (ctx.get("if-none-match").split(",").map((value) => value.trim()).includes(etag)) {
947
+ ctx.status = 304;
948
+ return;
949
+ }
950
+ const chunks = [];
951
+ let offset = 0;
952
+ while (offset < (resource.byteLength ?? 0)) {
953
+ const result = local
954
+ ? await local.readResource({
955
+ sessionId,
956
+ resourceId,
957
+ offset,
958
+ length: SESSION_RESOURCE_TRANSFER_CHUNK_BYTES,
959
+ })
960
+ : await lease.call("session.resource.read", {
961
+ sessionId,
962
+ resourceId,
963
+ offset,
964
+ length: SESSION_RESOURCE_TRANSFER_CHUNK_BYTES,
965
+ }, { timeoutMs: REMOTE_RUNTIME_SESSION_TIMEOUT_MS });
966
+ const chunk = Buffer.from(result.data, "base64");
967
+ if (result.offset !== offset + chunk.byteLength || (!result.eof && chunk.byteLength === 0)) {
968
+ throw runtimeProtocolError("Runtime Session resource read did not advance");
969
+ }
970
+ chunks.push(chunk);
971
+ offset = result.offset;
972
+ if (result.eof)
973
+ break;
974
+ }
975
+ const body = Buffer.concat(chunks);
976
+ if (body.byteLength !== resource.byteLength) {
977
+ throw runtimeProtocolError("Runtime Session resource length changed");
978
+ }
979
+ ctx.set("Content-Security-Policy", "default-src 'none'; sandbox");
980
+ ctx.set("Cross-Origin-Resource-Policy", "same-origin");
981
+ ctx.set("X-Content-Type-Options", "nosniff");
982
+ ctx.set("Content-Disposition", "inline");
983
+ ctx.type = resource.mediaType;
984
+ ctx.length = body.byteLength;
985
+ ctx.body = body;
986
+ }
987
+ catch (error) {
988
+ runtimeTargetHostError(ctx, error);
989
+ }
990
+ finally {
991
+ lease?.release();
992
+ }
993
+ };
994
+ const runtimeSessionResourceDelete = async (ctx) => {
995
+ if (ctx.params.selector === "local" && localSessionResources) {
996
+ try {
997
+ ctx.set("Cache-Control", "no-store");
998
+ ctx.body = await localSessionResources.deleteResource({
999
+ sessionId: ctx.params.id,
1000
+ resourceId: ctx.params.resourceId,
1001
+ });
1002
+ }
1003
+ catch (error) {
1004
+ runtimeTargetHostError(ctx, error);
1005
+ }
1006
+ return;
1007
+ }
1008
+ const outcome = await runtimeCall(ctx, "session.resource.delete", {
1009
+ sessionId: ctx.params.id,
1010
+ resourceId: ctx.params.resourceId,
1011
+ });
1012
+ if (outcome.ok)
1013
+ ctx.body = outcome.result;
1014
+ };
1015
+ const runtimeSessionMessageEnqueue = async (ctx) => {
1016
+ const body = await readRuntimeBody(ctx);
1017
+ if (!body)
1018
+ return;
1019
+ if (!acceptRuntimeBodyKeys(ctx, body, ["message"]))
1020
+ return;
1021
+ const outcome = await runtimeCall(ctx, "session.message.enqueue", {
1022
+ sessionId: ctx.params.id,
1023
+ message: body.message,
1024
+ });
1025
+ if (outcome.ok)
1026
+ ctx.body = outcome.result;
1027
+ };
1028
+ const runtimeSessionTerminalStart = async (ctx) => {
1029
+ const outcome = await runtimeCall(ctx, "session.terminal.start", {
1030
+ sessionId: ctx.params.id,
1031
+ });
1032
+ if (outcome.ok)
1033
+ ctx.body = outcome.result;
1034
+ };
1035
+ const runtimeSessionDelete = async (ctx) => {
1036
+ const outcome = await runtimeCall(ctx, "session.delete", { sessionId: ctx.params.id });
1037
+ if (outcome.ok)
1038
+ ctx.body = outcome.result;
1039
+ };
1040
+ const runtimeSessionResolveInteraction = async (ctx) => {
1041
+ const body = await readRuntimeBody(ctx);
1042
+ if (!body)
1043
+ return;
1044
+ if (!acceptRuntimeBodyKeys(ctx, body, ["resolution"]))
1045
+ return;
1046
+ const outcome = await runtimeCall(ctx, "session.interaction.resolve", {
1047
+ sessionId: ctx.params.id,
1048
+ interactionId: ctx.params.interactionId,
1049
+ resolution: body.resolution,
1050
+ });
1051
+ if (!outcome.ok)
1052
+ return;
1053
+ const result = outcome.result;
1054
+ if (result.disposition === "invalid") {
1055
+ return bad(ctx, "interaction resolution is invalid");
1056
+ }
1057
+ if (result.disposition === "not_found") {
1058
+ return notFound(ctx, "interaction is not pending");
1059
+ }
1060
+ if (result.disposition === "already_resolved") {
1061
+ ctx.status = 409;
1062
+ ctx.body = { error: "interaction was already resolved", ...result };
1063
+ return;
1064
+ }
1065
+ ctx.body = result;
1066
+ };
1067
+ const runtimeBrowserState = async (ctx) => {
1068
+ const outcome = await runtimeCall(ctx, "browser.state.get", {
1069
+ sessionId: ctx.params.id,
1070
+ });
1071
+ if (outcome.ok)
1072
+ ctx.body = outcome.result;
1073
+ };
1074
+ const runtimeBrowserOpen = async (ctx) => {
1075
+ const body = await readRuntimeBody(ctx);
1076
+ if (!body)
1077
+ return;
1078
+ if (!acceptRuntimeBodyKeys(ctx, body, ["url"]))
1079
+ return;
1080
+ const outcome = await runtimeCall(ctx, "browser.open", {
1081
+ sessionId: ctx.params.id,
1082
+ ...(body.url === undefined ? {} : { url: body.url }),
1083
+ });
1084
+ if (outcome.ok)
1085
+ ctx.body = outcome.result;
1086
+ };
1087
+ const runtimeBrowserClose = async (ctx) => {
1088
+ const body = await readRuntimeBody(ctx);
1089
+ if (!body)
1090
+ return;
1091
+ if (!acceptRuntimeBodyKeys(ctx, body, ["browserGeneration"]))
1092
+ return;
1093
+ const outcome = await runtimeCall(ctx, "browser.close", {
1094
+ sessionId: ctx.params.id,
1095
+ browserGeneration: body.browserGeneration,
1096
+ });
1097
+ if (outcome.ok)
1098
+ ctx.body = outcome.result;
1099
+ };
1100
+ const runtimeBrowserPageCreate = async (ctx) => {
1101
+ const body = await readRuntimeBody(ctx);
1102
+ if (!body)
1103
+ return;
1104
+ if (!acceptRuntimeBodyKeys(ctx, body, ["browserGeneration", "url"]))
1105
+ return;
1106
+ const outcome = await runtimeCall(ctx, "browser.page.create", {
1107
+ sessionId: ctx.params.id,
1108
+ browserGeneration: body.browserGeneration,
1109
+ ...(body.url === undefined ? {} : { url: body.url }),
1110
+ });
1111
+ if (outcome.ok)
1112
+ ctx.body = outcome.result;
1113
+ };
1114
+ const runtimeBrowserPageMutation = async (ctx, method) => {
1115
+ const body = await readRuntimeBody(ctx);
1116
+ if (!body)
1117
+ return;
1118
+ if (!acceptRuntimeBodyKeys(ctx, body, ["browserGeneration"]))
1119
+ return;
1120
+ const params = {
1121
+ sessionId: ctx.params.id,
1122
+ browserGeneration: body.browserGeneration,
1123
+ pageId: ctx.params.pageId,
1124
+ };
1125
+ const outcome = await runtimeCall(ctx, method, params);
1126
+ if (outcome.ok)
1127
+ ctx.body = outcome.result;
1128
+ };
1129
+ const runtimeBrowserPageNavigate = async (ctx) => {
1130
+ const body = await readRuntimeBody(ctx);
1131
+ if (!body)
1132
+ return;
1133
+ if (!acceptRuntimeBodyKeys(ctx, body, ["browserGeneration", "url"]))
1134
+ return;
1135
+ const outcome = await runtimeCall(ctx, "browser.page.navigate", {
1136
+ sessionId: ctx.params.id,
1137
+ browserGeneration: body.browserGeneration,
1138
+ pageId: ctx.params.pageId,
1139
+ url: body.url,
1140
+ });
1141
+ if (outcome.ok)
1142
+ ctx.body = outcome.result;
1143
+ };
1144
+ const streamRuntimeSession = async (ctx, selector, sessionId) => {
1145
+ if (!runtimeConnectionResolver) {
1146
+ return notFound(ctx, "Runtime connection resolver is not configured");
1147
+ }
1148
+ if (!isRuntimeTargetSelector(selector)) {
1149
+ ctx.status = 400;
1150
+ ctx.body = { error: "invalid_target" };
1151
+ return;
1152
+ }
1153
+ await streamEvents(ctx, async (send, signal) => {
1154
+ let lease;
1155
+ let subscription;
1156
+ try {
1157
+ lease = await runtimeConnectionResolver.acquire(selector, { signal });
1158
+ if (lease.target.selector !== selector || !isRuntimeTargetSelector(lease.target.daemonId)) {
1159
+ throw runtimeProtocolError("Runtime resolver returned a mismatched target");
1160
+ }
1161
+ subscription = await lease.subscribeSessionEvents(sessionId, {
1162
+ signal,
1163
+ timeoutMs: REMOTE_RUNTIME_SESSION_TIMEOUT_MS,
1164
+ });
1165
+ if (subscription.sessionId !== sessionId) {
1166
+ throw runtimeProtocolError("Runtime Session subscription identity changed");
1167
+ }
1168
+ if (!send("ready", { sessionId, daemonInstanceId: subscription.daemonInstanceId })) {
1169
+ if (!(await waitForSseDrain(ctx, signal)))
1170
+ return;
1171
+ }
1172
+ const iterator = subscription[Symbol.asyncIterator]();
1173
+ const disconnected = abortAsNull(signal);
1174
+ for (;;) {
1175
+ const next = await Promise.race([iterator.next(), disconnected]);
1176
+ if (next === null || next.done)
1177
+ return;
1178
+ const event = materializeRemoteRuntimeSessionEvent(next.value);
1179
+ if (!send("event", event) && !(await waitForSseDrain(ctx, signal)))
1180
+ return;
1181
+ }
1182
+ }
1183
+ catch (error) {
1184
+ if (!signal.aborted) {
1185
+ send("error", { message: publicRuntimeStreamError(error) });
1186
+ }
1187
+ }
1188
+ finally {
1189
+ await subscription?.close().catch(() => undefined);
1190
+ lease?.release();
1191
+ }
1192
+ }, shutdownSignal);
1193
+ };
1194
+ const runtimeSessionStream = async (ctx) => streamRuntimeSession(ctx, ctx.params.selector, ctx.params.id);
1195
+ const readRuntimeBody = async (ctx) => {
1196
+ if (!ctx.is("application/json")) {
1197
+ ctx.status = 415;
1198
+ ctx.body = { error: "unsupported_media_type" };
1199
+ return undefined;
1200
+ }
1201
+ try {
1202
+ return await readBoundedJson(ctx, REMOTE_RUNTIME_RPC_MAX_FRAME_BYTES);
1203
+ }
1204
+ catch (error) {
1205
+ ctx.status = error instanceof RequestBodyTooLargeError ? 413 : 400;
1206
+ ctx.body = {
1207
+ error: error instanceof RequestBodyTooLargeError ? "request_too_large" : "invalid_request",
1208
+ };
1209
+ return undefined;
1210
+ }
1211
+ };
1212
+ const acceptRuntimeBodyKeys = (ctx, body, allowed) => {
1213
+ const allowedSet = new Set(allowed);
1214
+ if (Object.keys(body).every((key) => allowedSet.has(key)))
1215
+ return true;
1216
+ ctx.status = 400;
1217
+ ctx.body = { error: "invalid_request" };
1218
+ return false;
1219
+ };
1220
+ const runtimeScopedDevice = (_ctx, body) => optionalRuntimeDevice(body);
1221
+ const runtimeDevices = async (ctx) => {
1222
+ const outcome = await runtimeCall(ctx, "emulator.devices.list", {});
1223
+ if (outcome.ok)
1224
+ ctx.body = { devices: outcome.result };
1225
+ };
1226
+ const runtimeActiveEmulator = async (ctx) => {
1227
+ const outcome = await runtimeCall(ctx, "emulator.active.get", runtimeScopedDevice(ctx, {}));
1228
+ if (outcome.ok)
1229
+ ctx.body = { active: outcome.result };
1230
+ };
1231
+ const runtimeAttachEmulator = async (ctx) => {
1232
+ const body = await readRuntimeBody(ctx);
1233
+ if (!body)
1234
+ return;
1235
+ if (!acceptRuntimeBodyKeys(ctx, body, ["device"]))
1236
+ return;
1237
+ const outcome = await runtimeCall(ctx, "emulator.attach", optionalRuntimeDevice(body));
1238
+ if (outcome.ok)
1239
+ ctx.body = outcome.result;
1240
+ };
1241
+ const runtimeTapEmulator = async (ctx) => {
1242
+ const body = await readRuntimeBody(ctx);
1243
+ if (!body)
1244
+ return;
1245
+ if (!acceptRuntimeBodyKeys(ctx, body, ["x", "y", "device"]))
1246
+ return;
1247
+ const x = asNumber(body.x);
1248
+ const y = asNumber(body.y);
1249
+ if (x == null || y == null)
1250
+ return bad(ctx, "tap requires numeric x and y");
1251
+ const outcome = await runtimeCall(ctx, "emulator.tap", {
1252
+ x,
1253
+ y,
1254
+ ...runtimeScopedDevice(ctx, body),
1255
+ });
1256
+ if (outcome.ok)
1257
+ ctx.body = outcome.result;
1258
+ };
1259
+ const runtimeTypeEmulator = async (ctx) => {
1260
+ const body = await readRuntimeBody(ctx);
1261
+ if (!body)
1262
+ return;
1263
+ if (!acceptRuntimeBodyKeys(ctx, body, ["text", "mode", "device"]))
1264
+ return;
1265
+ if (typeof body.text !== "string" || body.text.length === 0) {
1266
+ return bad(ctx, "type requires text");
1267
+ }
1268
+ const mode = body.mode;
1269
+ if (mode !== undefined && mode !== "paste" && mode !== "keys") {
1270
+ return bad(ctx, "type mode must be paste or keys");
1271
+ }
1272
+ const outcome = await runtimeCall(ctx, "emulator.type", {
1273
+ text: body.text,
1274
+ ...(mode === undefined ? {} : { mode }),
1275
+ ...runtimeScopedDevice(ctx, body),
1276
+ });
1277
+ if (outcome.ok)
1278
+ ctx.body = outcome.result;
1279
+ };
1280
+ const runtimeButtonEmulator = async (ctx) => {
1281
+ const body = await readRuntimeBody(ctx);
1282
+ if (!body)
1283
+ return;
1284
+ if (!acceptRuntimeBodyKeys(ctx, body, ["name", "device"]))
1285
+ return;
1286
+ const name = body.name;
1287
+ const outcome = await runtimeCall(ctx, "emulator.button", {
1288
+ ...(name === undefined ? {} : { name: name }),
1289
+ ...runtimeScopedDevice(ctx, body),
1290
+ });
1291
+ if (outcome.ok)
1292
+ ctx.body = outcome.result;
1293
+ };
1294
+ const runtimeRotateEmulator = async (ctx) => {
1295
+ const body = await readRuntimeBody(ctx);
1296
+ if (!body)
1297
+ return;
1298
+ if (!acceptRuntimeBodyKeys(ctx, body, ["orientation", "device"]))
1299
+ return;
1300
+ const orientation = optionalRuntimeString(body.orientation);
1301
+ if (!orientation)
1302
+ return bad(ctx, "rotate requires orientation");
1303
+ const outcome = await runtimeCall(ctx, "emulator.rotate", {
1304
+ orientation,
1305
+ ...runtimeScopedDevice(ctx, body),
1306
+ });
1307
+ if (outcome.ok)
1308
+ ctx.body = outcome.result;
1309
+ };
1310
+ const runtimeLaunchEmulator = async (ctx) => {
1311
+ const body = await readRuntimeBody(ctx);
1312
+ if (!body)
1313
+ return;
1314
+ if (!acceptRuntimeBodyKeys(ctx, body, ["appId", "device"]))
1315
+ return;
1316
+ const appId = optionalRuntimeString(body.appId);
1317
+ if (!appId)
1318
+ return bad(ctx, "launch requires appId");
1319
+ const outcome = await runtimeCall(ctx, "emulator.launch", {
1320
+ appId,
1321
+ ...runtimeScopedDevice(ctx, body),
1322
+ });
1323
+ if (outcome.ok)
1324
+ ctx.body = outcome.result;
1325
+ };
1326
+ const runtimeGestureEmulator = async (ctx) => {
1327
+ const body = await readRuntimeBody(ctx);
1328
+ if (!body)
1329
+ return;
1330
+ if (!acceptRuntimeBodyKeys(ctx, body, ["points", "device"]))
1331
+ return;
1332
+ if (!Array.isArray(body.points))
1333
+ return bad(ctx, "gesture requires points");
1334
+ const outcome = await runtimeCall(ctx, "emulator.gesture", {
1335
+ points: body.points,
1336
+ ...runtimeScopedDevice(ctx, body),
1337
+ });
1338
+ if (outcome.ok)
1339
+ ctx.body = outcome.result;
1340
+ };
1341
+ const runtimeDeviceCommand = async (ctx, method) => {
1342
+ const body = await readRuntimeBody(ctx);
1343
+ if (!body)
1344
+ return;
1345
+ if (!acceptRuntimeBodyKeys(ctx, body, ["device"]))
1346
+ return;
1347
+ const params = runtimeScopedDevice(ctx, body);
1348
+ const outcome = method === "emulator.kill"
1349
+ ? await runtimeCall(ctx, "emulator.kill", params)
1350
+ : await runtimeCall(ctx, "emulator.shutdown", params);
1351
+ if (outcome.ok)
1352
+ ctx.body = outcome.result;
1353
+ };
1354
+ const runtimeSessionEmulatorState = async (ctx) => {
1355
+ const outcome = await runtimeCall(ctx, "session.emulator.state.get", {
1356
+ sessionId: ctx.params.id,
1357
+ });
1358
+ if (outcome.ok)
1359
+ ctx.body = { binding: outcome.result };
1360
+ };
1361
+ const runtimeSessionEmulatorDevices = async (ctx) => {
1362
+ const outcome = await runtimeCall(ctx, "session.emulator.devices.list", {
1363
+ sessionId: ctx.params.id,
1364
+ });
1365
+ if (outcome.ok)
1366
+ ctx.body = { devices: outcome.result };
1367
+ };
1368
+ const runtimeSessionEmulatorAttach = async (ctx) => {
1369
+ const body = await readRuntimeBody(ctx);
1370
+ if (!body)
1371
+ return;
1372
+ if (!acceptRuntimeBodyKeys(ctx, body, ["device"]))
1373
+ return;
1374
+ const device = optionalRuntimeString(body.device);
1375
+ if (!device)
1376
+ return bad(ctx, "attach requires device");
1377
+ const outcome = await runtimeCall(ctx, "session.emulator.attach", {
1378
+ sessionId: ctx.params.id,
1379
+ device,
1380
+ });
1381
+ if (outcome.ok)
1382
+ ctx.body = outcome.result;
1383
+ };
1384
+ const runtimeSessionEmulatorDetach = async (ctx) => {
1385
+ const body = await readRuntimeBody(ctx);
1386
+ if (!body)
1387
+ return;
1388
+ if (!acceptRuntimeBodyKeys(ctx, body, ["bindingId"]))
1389
+ return;
1390
+ const bindingId = optionalRuntimeString(body.bindingId);
1391
+ if (!bindingId)
1392
+ return bad(ctx, "detach requires bindingId");
1393
+ const outcome = await runtimeCall(ctx, "session.emulator.detach", {
1394
+ sessionId: ctx.params.id,
1395
+ bindingId,
1396
+ });
1397
+ if (outcome.ok)
1398
+ ctx.body = outcome.result;
1399
+ };
1400
+ const runtimeSessionEmulatorTap = async (ctx) => {
1401
+ const body = await readRuntimeBody(ctx);
1402
+ if (!body)
1403
+ return;
1404
+ if (!acceptRuntimeBodyKeys(ctx, body, ["bindingId", "x", "y"]))
1405
+ return;
1406
+ const bindingId = optionalRuntimeString(body.bindingId);
1407
+ const x = asNumber(body.x);
1408
+ const y = asNumber(body.y);
1409
+ if (!bindingId || x == null || y == null)
1410
+ return bad(ctx, "tap requires bindingId, x, and y");
1411
+ const outcome = await runtimeCall(ctx, "session.emulator.tap", {
1412
+ sessionId: ctx.params.id,
1413
+ bindingId,
1414
+ x,
1415
+ y,
1416
+ });
1417
+ if (outcome.ok)
1418
+ ctx.body = outcome.result;
1419
+ };
1420
+ const runtimeSessionEmulatorButton = async (ctx) => {
1421
+ const body = await readRuntimeBody(ctx);
1422
+ if (!body)
1423
+ return;
1424
+ if (!acceptRuntimeBodyKeys(ctx, body, ["bindingId", "name"]))
1425
+ return;
1426
+ const bindingId = optionalRuntimeString(body.bindingId);
1427
+ if (!bindingId)
1428
+ return bad(ctx, "button requires bindingId");
1429
+ const name = optionalRuntimeString(body.name);
1430
+ const outcome = await runtimeCall(ctx, "session.emulator.button", {
1431
+ sessionId: ctx.params.id,
1432
+ bindingId,
1433
+ ...(name ? { name } : {}),
1434
+ });
1435
+ if (outcome.ok)
1436
+ ctx.body = outcome.result;
1437
+ };
1438
+ const runtimeSessionEmulatorType = async (ctx) => {
1439
+ const body = await readRuntimeBody(ctx);
1440
+ if (!body)
1441
+ return;
1442
+ if (!acceptRuntimeBodyKeys(ctx, body, ["bindingId", "text", "mode"]))
1443
+ return;
1444
+ const bindingId = optionalRuntimeString(body.bindingId);
1445
+ const text = typeof body.text === "string" ? body.text : undefined;
1446
+ const mode = body.mode;
1447
+ if (!bindingId || !text)
1448
+ return bad(ctx, "type requires bindingId and text");
1449
+ if (mode !== undefined && mode !== "paste" && mode !== "keys") {
1450
+ return bad(ctx, "type mode must be paste or keys");
1451
+ }
1452
+ const outcome = await runtimeCall(ctx, "session.emulator.type", {
1453
+ sessionId: ctx.params.id,
1454
+ bindingId,
1455
+ text,
1456
+ ...(mode === undefined ? {} : { mode }),
1457
+ });
1458
+ if (outcome.ok)
1459
+ ctx.body = outcome.result;
1460
+ };
1461
+ const runtimeSessionEmulatorRotate = async (ctx) => {
1462
+ const body = await readRuntimeBody(ctx);
1463
+ if (!body)
1464
+ return;
1465
+ if (!acceptRuntimeBodyKeys(ctx, body, ["bindingId", "orientation"]))
1466
+ return;
1467
+ const bindingId = optionalRuntimeString(body.bindingId);
1468
+ const orientation = optionalRuntimeString(body.orientation);
1469
+ if (!bindingId || !orientation)
1470
+ return bad(ctx, "rotate requires bindingId and orientation");
1471
+ const outcome = await runtimeCall(ctx, "session.emulator.rotate", {
1472
+ sessionId: ctx.params.id,
1473
+ bindingId,
1474
+ orientation,
1475
+ });
1476
+ if (outcome.ok)
1477
+ ctx.body = outcome.result;
1478
+ };
1479
+ const runtimeSessionEmulatorLaunch = async (ctx) => {
1480
+ const body = await readRuntimeBody(ctx);
1481
+ if (!body)
1482
+ return;
1483
+ if (!acceptRuntimeBodyKeys(ctx, body, ["bindingId", "appId"]))
1484
+ return;
1485
+ const bindingId = optionalRuntimeString(body.bindingId);
1486
+ const appId = optionalRuntimeString(body.appId);
1487
+ if (!bindingId || !appId)
1488
+ return bad(ctx, "launch requires bindingId and appId");
1489
+ const outcome = await runtimeCall(ctx, "session.emulator.launch", {
1490
+ sessionId: ctx.params.id,
1491
+ bindingId,
1492
+ appId,
1493
+ });
1494
+ if (outcome.ok)
1495
+ ctx.body = outcome.result;
1496
+ };
1497
+ const runtimeSessionEmulatorGesture = async (ctx) => {
1498
+ const body = await readRuntimeBody(ctx);
1499
+ if (!body)
1500
+ return;
1501
+ if (!acceptRuntimeBodyKeys(ctx, body, ["bindingId", "points"]))
1502
+ return;
1503
+ const bindingId = optionalRuntimeString(body.bindingId);
1504
+ if (!bindingId || !Array.isArray(body.points)) {
1505
+ return bad(ctx, "gesture requires bindingId and points");
1506
+ }
1507
+ const outcome = await runtimeCall(ctx, "session.emulator.gesture", {
1508
+ sessionId: ctx.params.id,
1509
+ bindingId,
1510
+ points: body.points,
1511
+ });
1512
+ if (outcome.ok)
1513
+ ctx.body = outcome.result;
1514
+ };
1515
+ const runtimeSessionEmulatorFrame = async (ctx) => {
1516
+ if (ctx.params.selector !== "local") {
1517
+ ctx.status = 426;
1518
+ ctx.body = { error: "emulator_surface_upgrade_required" };
1519
+ return;
1520
+ }
1521
+ if (!sessionEmulators)
1522
+ return notFound(ctx, "Session Emulator service is unavailable");
1523
+ const bindingId = optionalRuntimeString(ctx.query.bindingId);
1524
+ if (!bindingId)
1525
+ return bad(ctx, "frame requires bindingId");
1526
+ try {
1527
+ const frame = await sessionEmulators.frame({
1528
+ sessionId: ctx.params.id,
1529
+ bindingId,
1530
+ });
1531
+ ctx.type = frame.contentType;
1532
+ ctx.set("Cache-Control", "no-store");
1533
+ ctx.set("X-Content-Type-Options", "nosniff");
1534
+ ctx.body = frame.data;
1535
+ }
1536
+ catch (error) {
1537
+ bad(ctx, error instanceof Error ? error.message : String(error));
1538
+ }
1539
+ };
1540
+ const runtimeProviderCliStatuses = async (ctx) => {
1541
+ const outcome = await runtimeCall(ctx, "provider.cli.status.list", {});
1542
+ if (!outcome.ok)
1543
+ return;
1544
+ ctx.body = {
1545
+ ...outcome.result,
1546
+ management: ctx.params.selector === "local" ? "read_write" : "read_only",
1547
+ };
1548
+ };
1549
+ const runtimeProviderCliOperation = async (ctx) => {
1550
+ if (ctx.params.selector !== "local") {
1551
+ ctx.status = 403;
1552
+ ctx.body = { error: "remote_provider_cli_management_disabled" };
1553
+ return;
1554
+ }
1555
+ if (!providerClis)
1556
+ return notFound(ctx, "Provider CLI management is unavailable");
1557
+ const provider = parseProviderCliId(ctx.params.provider);
1558
+ const operation = parseProviderCliOperation(ctx.params.operation);
1559
+ if (!provider || !operation)
1560
+ return bad(ctx, "invalid Provider CLI operation");
1561
+ try {
1562
+ const job = await providerClis.startOperation(provider, operation);
1563
+ ctx.status = 202;
1564
+ ctx.body = { job };
1565
+ }
1566
+ catch (error) {
1567
+ ctx.status = providerCliOperationErrorCode(error) === "failed_precondition" ? 409 : 500;
1568
+ ctx.body = { error: error instanceof Error ? error.message : String(error) };
1569
+ }
1570
+ };
1571
+ const runtimeProviderCliJob = async (ctx) => {
1572
+ if (ctx.params.selector !== "local") {
1573
+ ctx.status = 403;
1574
+ ctx.body = { error: "remote_provider_cli_management_disabled" };
1575
+ return;
1576
+ }
1577
+ if (!providerClis)
1578
+ return notFound(ctx, "Provider CLI management is unavailable");
1579
+ const job = providerClis.getJob(ctx.params.jobId);
1580
+ if (!job)
1581
+ return notFound(ctx, "Provider CLI job not found");
1582
+ ctx.set("Cache-Control", "no-store");
1583
+ ctx.body = { job };
1584
+ };
1585
+ const publicRuntimeRead = (handler) => async (ctx) => {
1586
+ if (authorizePublicRuntimeTargetRequest(ctx))
1587
+ await handler(ctx);
1588
+ };
1589
+ const publicRuntimeMutation = (handler) => async (ctx) => {
1590
+ if (authorizePublicRuntimeTargetRequest(ctx, { mutation: true }))
1591
+ await handler(ctx);
1592
+ };
1593
+ const internalRuntimeCall = (handler) => async (ctx) => {
1594
+ if (authorizeInternalRuntimeCall(ctx))
1595
+ await handler(ctx);
1596
+ };
1597
+ router.get("/api/runtimes/:selector/emulator/devices", publicRuntimeRead(runtimeDevices));
1598
+ router.get("/api/runtimes/:selector/provider-clis", publicRuntimeRead(runtimeProviderCliStatuses));
1599
+ router.post("/api/runtimes/:selector/provider-clis/:provider/:operation", publicRuntimeMutation(runtimeProviderCliOperation));
1600
+ router.get("/api/runtimes/:selector/provider-clis/jobs/:jobId", publicRuntimeRead(runtimeProviderCliJob));
1601
+ router.get("/api/runtimes/:selector/emulator/active", publicRuntimeRead(runtimeActiveEmulator));
1602
+ router.post("/api/runtimes/:selector/emulator/attach", publicRuntimeMutation(runtimeAttachEmulator));
1603
+ router.post("/api/runtimes/:selector/emulator/tap", publicRuntimeMutation(runtimeTapEmulator));
1604
+ router.post("/api/runtimes/:selector/emulator/type", publicRuntimeMutation(runtimeTypeEmulator));
1605
+ router.post("/api/runtimes/:selector/emulator/button", publicRuntimeMutation(runtimeButtonEmulator));
1606
+ router.post("/api/runtimes/:selector/emulator/rotate", publicRuntimeMutation(runtimeRotateEmulator));
1607
+ router.post("/api/runtimes/:selector/emulator/launch", publicRuntimeMutation(runtimeLaunchEmulator));
1608
+ router.post("/api/runtimes/:selector/emulator/gesture", publicRuntimeMutation(runtimeGestureEmulator));
1609
+ router.post("/api/runtimes/:selector/emulator/kill", publicRuntimeMutation((ctx) => runtimeDeviceCommand(ctx, "emulator.kill")));
1610
+ router.post("/api/runtimes/:selector/emulator/shutdown", publicRuntimeMutation((ctx) => runtimeDeviceCommand(ctx, "emulator.shutdown")));
1611
+ router.get("/api/runtimes/:selector/sessions/:id/emulator", publicRuntimeRead(runtimeSessionEmulatorState));
1612
+ router.get("/api/runtimes/:selector/sessions/:id/emulator/devices", publicRuntimeRead(runtimeSessionEmulatorDevices));
1613
+ router.post("/api/runtimes/:selector/sessions/:id/emulator/attach", publicRuntimeMutation(runtimeSessionEmulatorAttach));
1614
+ router.post("/api/runtimes/:selector/sessions/:id/emulator/detach", publicRuntimeMutation(runtimeSessionEmulatorDetach));
1615
+ router.post("/api/runtimes/:selector/sessions/:id/emulator/tap", publicRuntimeMutation(runtimeSessionEmulatorTap));
1616
+ router.post("/api/runtimes/:selector/sessions/:id/emulator/button", publicRuntimeMutation(runtimeSessionEmulatorButton));
1617
+ router.post("/api/runtimes/:selector/sessions/:id/emulator/type", publicRuntimeMutation(runtimeSessionEmulatorType));
1618
+ router.post("/api/runtimes/:selector/sessions/:id/emulator/rotate", publicRuntimeMutation(runtimeSessionEmulatorRotate));
1619
+ router.post("/api/runtimes/:selector/sessions/:id/emulator/launch", publicRuntimeMutation(runtimeSessionEmulatorLaunch));
1620
+ router.post("/api/runtimes/:selector/sessions/:id/emulator/gesture", publicRuntimeMutation(runtimeSessionEmulatorGesture));
1621
+ router.get("/api/runtimes/:selector/sessions/:id/emulator/frame", publicRuntimeRead(runtimeSessionEmulatorFrame));
1622
+ // Session workbench routes always target the explicitly selected Runtime.
1623
+ // The browser talks only to this loopback BFF; Local and Direct both enter the
1624
+ // target daemon through the same typed application service.
1625
+ router.get("/api/runtimes/:selector/sessions", publicRuntimeRead(runtimeSessions));
1626
+ router.get("/api/runtimes/:selector/sessions/agent-options", publicRuntimeRead(runtimeSessionAgentOptions));
1627
+ router.get("/api/runtimes/:selector/sessions/launch-options", publicRuntimeRead(runtimeSessionLaunchOptions));
1628
+ router.post("/api/runtimes/:selector/sessions", publicRuntimeMutation(runtimeSessionCreate));
1629
+ router.get("/api/runtimes/:selector/sessions/:id/snapshot", publicRuntimeRead(runtimeSessionSnapshot));
1630
+ router.get("/api/runtimes/:selector/sessions/:id/stream", publicRuntimeRead(runtimeSessionStream));
1631
+ router.post("/api/runtimes/:selector/sessions/:id/interrupt", publicRuntimeMutation(runtimeSessionInterrupt));
1632
+ router.post("/api/runtimes/:selector/sessions/:id/messages", publicRuntimeMutation(runtimeSessionMessage));
1633
+ router.get("/api/runtimes/:selector/sessions/:id/resources/policy", publicRuntimeRead(runtimeSessionResourcePolicy));
1634
+ router.post("/api/runtimes/:selector/sessions/:id/resources", publicRuntimeMutation(runtimeSessionResourceUpload));
1635
+ router.get("/api/runtimes/:selector/sessions/:id/resources/:resourceId", publicRuntimeRead(runtimeSessionResourceMetadata));
1636
+ router.get("/api/runtimes/:selector/sessions/:id/resources/:resourceId/content", publicRuntimeRead(runtimeSessionResourceContent));
1637
+ router.delete("/api/runtimes/:selector/sessions/:id/resources/:resourceId", publicRuntimeMutation(runtimeSessionResourceDelete));
1638
+ router.post("/api/runtimes/:selector/sessions/:id/messages/queue", publicRuntimeMutation(runtimeSessionMessageEnqueue));
1639
+ router.post("/api/runtimes/:selector/sessions/:id/terminal/start", publicRuntimeMutation(runtimeSessionTerminalStart));
1640
+ router.post("/api/runtimes/:selector/sessions/:id/interactions/:interactionId/resolve", publicRuntimeMutation(runtimeSessionResolveInteraction));
1641
+ router.delete("/api/runtimes/:selector/sessions/:id", publicRuntimeMutation(runtimeSessionDelete));
1642
+ router.get("/api/runtimes/:selector/sessions/:id/browser", publicRuntimeRead(runtimeBrowserState));
1643
+ router.post("/api/runtimes/:selector/sessions/:id/browser/open", publicRuntimeMutation(runtimeBrowserOpen));
1644
+ router.post("/api/runtimes/:selector/sessions/:id/browser/close", publicRuntimeMutation(runtimeBrowserClose));
1645
+ router.post("/api/runtimes/:selector/sessions/:id/browser/pages", publicRuntimeMutation(runtimeBrowserPageCreate));
1646
+ for (const action of ["close", "activate", "back", "forward", "reload"]) {
1647
+ router.post(`/api/runtimes/:selector/sessions/:id/browser/pages/:pageId/${action}`, publicRuntimeMutation((ctx) => runtimeBrowserPageMutation(ctx, `browser.page.${action}`)));
1648
+ }
1649
+ router.post("/api/runtimes/:selector/sessions/:id/browser/pages/:pageId/navigate", publicRuntimeMutation(runtimeBrowserPageNavigate));
1650
+ router.post("/api/internal/runtimes/:selector/rpc", internalRuntimeCall(internalRuntimeRpc));
1651
+ router.get("/api/internal/runtimes/:selector/emulator/devices", internalRuntimeCall(runtimeDevices));
1652
+ router.get("/api/internal/runtimes/:selector/emulator/active", internalRuntimeCall(runtimeActiveEmulator));
1653
+ router.post("/api/internal/runtimes/:selector/emulator/attach", internalRuntimeCall(runtimeAttachEmulator));
1654
+ router.post("/api/internal/runtimes/:selector/emulator/tap", internalRuntimeCall(runtimeTapEmulator));
1655
+ router.post("/api/internal/runtimes/:selector/emulator/type", internalRuntimeCall(runtimeTypeEmulator));
1656
+ router.post("/api/internal/runtimes/:selector/emulator/button", internalRuntimeCall(runtimeButtonEmulator));
1657
+ router.post("/api/internal/runtimes/:selector/emulator/rotate", internalRuntimeCall(runtimeRotateEmulator));
1658
+ router.post("/api/internal/runtimes/:selector/emulator/launch", internalRuntimeCall(runtimeLaunchEmulator));
1659
+ router.post("/api/internal/runtimes/:selector/emulator/gesture", internalRuntimeCall(runtimeGestureEmulator));
1660
+ router.post("/api/internal/runtimes/:selector/emulator/kill", internalRuntimeCall((ctx) => runtimeDeviceCommand(ctx, "emulator.kill")));
1661
+ router.post("/api/internal/runtimes/:selector/emulator/shutdown", internalRuntimeCall((ctx) => runtimeDeviceCommand(ctx, "emulator.shutdown")));
1662
+ router.get(RUNTIME_BROWSER_BOOTSTRAP_PATH, async (ctx) => {
1663
+ if (!runtimeLocalBrowser) {
1664
+ return notFound(ctx, "Runtime-local Browser automation is not configured");
1665
+ }
1666
+ if (!isLoopbackRequest(ctx)) {
1667
+ ctx.status = 403;
1668
+ ctx.body = { error: "Runtime-local Browser bootstrap is loopback-only" };
1669
+ return;
1670
+ }
1671
+ let credential;
1672
+ try {
1673
+ credential = parseRuntimeBrowserBootstrapCredential({
1674
+ sessionId: ctx.get(RUNTIME_BROWSER_SESSION_ID_HEADER),
1675
+ capability: ctx.get(RUNTIME_BROWSER_CAPABILITY_HEADER),
1676
+ });
1677
+ }
1678
+ catch {
1679
+ ctx.status = 401;
1680
+ ctx.body = { error: "invalid Runtime Browser Session capability" };
1681
+ return;
1682
+ }
1683
+ try {
1684
+ const descriptor = await runtimeLocalBrowser.endpointForCredential(credential);
1685
+ if (!descriptor) {
1686
+ ctx.status = 401;
1687
+ ctx.body = { error: "invalid Runtime Browser Session capability" };
1688
+ return;
1689
+ }
1690
+ ctx.set("Cache-Control", "no-store");
1691
+ ctx.body = descriptor;
1692
+ }
1693
+ catch (error) {
1694
+ runtimeLocalBrowserError(ctx, error);
1695
+ }
1696
+ });
1697
+ router.post(RUNTIME_BROWSER_RPC_PATH, async (ctx) => {
1698
+ if (!runtimeLocalBrowser || !runtimeConnectionResolver) {
1699
+ return notFound(ctx, "Runtime-local Browser automation is not configured");
1700
+ }
1701
+ if (!isLoopbackRequest(ctx)) {
1702
+ ctx.status = 403;
1703
+ ctx.body = { error: "Runtime-local Browser control is loopback-only" };
1704
+ return;
1705
+ }
1706
+ let credential;
1707
+ try {
1708
+ credential = parseRuntimeBrowserBootstrapCredential({
1709
+ sessionId: ctx.get(RUNTIME_BROWSER_SESSION_ID_HEADER),
1710
+ capability: ctx.get(RUNTIME_BROWSER_CAPABILITY_HEADER),
1711
+ });
1712
+ }
1713
+ catch {
1714
+ ctx.status = 401;
1715
+ ctx.body = { error: "invalid Runtime Browser Session capability" };
1716
+ return;
1717
+ }
1718
+ let authorized = false;
1719
+ try {
1720
+ authorized = await runtimeLocalBrowser.authorizeCredential(credential);
1721
+ }
1722
+ catch {
1723
+ authorized = false;
1724
+ }
1725
+ if (!authorized) {
1726
+ ctx.status = 401;
1727
+ ctx.body = { error: "invalid Runtime Browser Session capability" };
1728
+ return;
1729
+ }
1730
+ if (!ctx.is("application/json")) {
1731
+ ctx.status = 415;
1732
+ ctx.body = { error: "unsupported_media_type" };
1733
+ return;
1734
+ }
1735
+ let body;
1736
+ try {
1737
+ body = await readBoundedJson(ctx, REMOTE_RUNTIME_RPC_MAX_FRAME_BYTES);
1738
+ }
1739
+ catch (error) {
1740
+ ctx.status = error instanceof RequestBodyTooLargeError ? 413 : 400;
1741
+ ctx.body = {
1742
+ error: error instanceof RequestBodyTooLargeError ? "request_too_large" : "invalid_request",
1743
+ };
1744
+ return;
1745
+ }
1746
+ if (!Object.hasOwn(body, "method") ||
1747
+ !Object.hasOwn(body, "params") ||
1748
+ Object.keys(body).some((key) => key !== "method" && key !== "params")) {
1749
+ ctx.status = 400;
1750
+ ctx.body = { error: "invalid_request" };
1751
+ return;
1752
+ }
1753
+ let request;
1754
+ try {
1755
+ request = parseRemoteRuntimeRpcRequest({
1756
+ type: "rpc.request",
1757
+ id: "runtime-local-browser",
1758
+ method: body.method,
1759
+ params: body.params,
1760
+ });
1761
+ }
1762
+ catch {
1763
+ ctx.status = 400;
1764
+ ctx.body = { error: "invalid_request" };
1765
+ return;
1766
+ }
1767
+ if (!RUNTIME_BROWSER_RPC_METHODS.has(request.method) ||
1768
+ !("sessionId" in request.params) ||
1769
+ request.params.sessionId !== credential.sessionId) {
1770
+ ctx.status = 403;
1771
+ ctx.body = { error: "forbidden" };
1772
+ return;
1773
+ }
1774
+ ctx.params.selector = "local";
1775
+ const outcome = await runtimeCall(ctx, request.method, request.params);
1776
+ if (outcome.ok)
1777
+ ctx.body = { result: outcome.result };
1778
+ });
1779
+ router.get(`${RUNTIME_BROWSER_MANAGEMENT_PATH_PREFIX}/:sessionId/endpoint`, async (ctx) => {
1780
+ if (!runtimeLocalBrowser || !isConfiguredDaemonManagement(daemonManagement)) {
1781
+ return notFound(ctx, "Runtime-local Browser automation is not configured");
1782
+ }
1783
+ if (!authorizeLoopbackManagementRequest(ctx, daemonManagement.managementToken))
1784
+ return;
1785
+ let sessionId;
1786
+ try {
1787
+ sessionId = parseRuntimeBrowserStateGetParams({
1788
+ sessionId: ctx.params.sessionId,
1789
+ }).sessionId;
1790
+ }
1791
+ catch {
1792
+ return bad(ctx, "invalid Session id");
1793
+ }
1794
+ try {
1795
+ ctx.set("Cache-Control", "no-store");
1796
+ ctx.body = await runtimeLocalBrowser.endpointForSession(sessionId);
1797
+ }
1798
+ catch (error) {
1799
+ runtimeLocalBrowserError(ctx, error);
1800
+ }
1801
+ });
32
1802
  router.get("/api/meta", (ctx) => {
33
1803
  const meta = {
34
1804
  channelTypes: deps.listChannelTypes().map((t) => t.type),
1805
+ ...(daemonManagement?.buildStatus
1806
+ ? { daemonBuild: daemonManagement.buildStatus() }
1807
+ : {}),
35
1808
  };
1809
+ ctx.set("Cache-Control", "no-store");
36
1810
  ctx.body = meta;
37
1811
  });
1812
+ router.get("/api/plugin-runtime", (ctx) => {
1813
+ if (!pluginRuntimeStatus)
1814
+ return notFound(ctx, "plugin runtime is not configured");
1815
+ ctx.body = pluginRuntimeStatus();
1816
+ });
1817
+ router.get("/api/internal/daemon/identity", (ctx) => {
1818
+ if (!isConfiguredDaemonManagement(daemonManagement)) {
1819
+ return notFound(ctx, "daemon management is not configured");
1820
+ }
1821
+ if (!authorizeLoopbackManagementRequest(ctx, daemonManagement.managementToken))
1822
+ return;
1823
+ ctx.body = daemonManagement.identity();
1824
+ });
1825
+ router.get("/api/internal/daemon/runtime-status", (ctx) => {
1826
+ if (!remoteRuntime || !isConfiguredDaemonManagement(daemonManagement)) {
1827
+ return notFound(ctx, "Remote Runtime status is not configured");
1828
+ }
1829
+ if (!authorizeLoopbackManagementRequest(ctx, daemonManagement.managementToken))
1830
+ return;
1831
+ ctx.body = encodeDaemonStatus(remoteRuntime.status());
1832
+ });
1833
+ router.post(DAEMON_SHUTDOWN_IF_IDLE_PATH, (ctx) => {
1834
+ if (!isConfiguredDaemonManagement(daemonManagement) ||
1835
+ !daemonManagement.shutdownIfIdle) {
1836
+ return notFound(ctx, "daemon shutdown management is not configured");
1837
+ }
1838
+ if (!authorizeLoopbackManagementRequest(ctx, daemonManagement.managementToken))
1839
+ return;
1840
+ const result = daemonManagement.shutdownIfIdle();
1841
+ ctx.set("Cache-Control", "no-store");
1842
+ ctx.status = result.outcome === "accepted" ? 202 : 409;
1843
+ ctx.body = result;
1844
+ });
1845
+ router.post(DAEMON_BROWSER_ARTIFACT_INSTALL_PATH, async (ctx) => {
1846
+ await mutateBrowserArtifact(ctx, "install");
1847
+ });
1848
+ router.post(DAEMON_BROWSER_ARTIFACT_UPDATE_PATH, async (ctx) => {
1849
+ await mutateBrowserArtifact(ctx, "update");
1850
+ });
1851
+ router.get(DAEMON_BROWSER_ARTIFACT_VERSION_PATH, (ctx) => {
1852
+ const host = daemonManagement?.browserArtifacts;
1853
+ if (!host || !isConfiguredDaemonManagement(daemonManagement)) {
1854
+ return notFound(ctx, "Browser artifact management is not configured");
1855
+ }
1856
+ if (!authorizeLoopbackManagementRequest(ctx, daemonManagement.managementToken))
1857
+ return;
1858
+ try {
1859
+ ctx.set("Cache-Control", "no-store");
1860
+ ctx.body = host.version();
1861
+ }
1862
+ catch (error) {
1863
+ ctx.status = 409;
1864
+ ctx.body = {
1865
+ error: boundedUtf8(error instanceof Error ? error.message : String(error), BROWSER_ARTIFACT_ERROR_MAX_BYTES),
1866
+ };
1867
+ }
1868
+ });
1869
+ router.post(DAEMON_BROWSER_ARTIFACT_CLEAN_PATH, (ctx) => {
1870
+ const host = daemonManagement?.browserArtifacts;
1871
+ if (!host || !isConfiguredDaemonManagement(daemonManagement)) {
1872
+ return notFound(ctx, "Browser artifact management is not configured");
1873
+ }
1874
+ if (!authorizeLoopbackManagementRequest(ctx, daemonManagement.managementToken))
1875
+ return;
1876
+ try {
1877
+ ctx.set("Cache-Control", "no-store");
1878
+ ctx.body = host.clean();
1879
+ }
1880
+ catch (error) {
1881
+ ctx.status = 409;
1882
+ ctx.body = {
1883
+ error: boundedUtf8(error instanceof Error ? error.message : String(error), BROWSER_ARTIFACT_ERROR_MAX_BYTES),
1884
+ };
1885
+ }
1886
+ });
1887
+ router.get(DAEMON_CHROME_INSPECTION_PATH, async (ctx) => {
1888
+ const host = daemonManagement?.chromeInspection;
1889
+ if (!host || !isConfiguredDaemonManagement(daemonManagement)) {
1890
+ return notFound(ctx, "Chrome inspection management is not configured");
1891
+ }
1892
+ if (!authorizeLoopbackManagementRequest(ctx, daemonManagement.managementToken))
1893
+ return;
1894
+ try {
1895
+ ctx.set("Cache-Control", "no-store");
1896
+ ctx.body = await host.status();
1897
+ }
1898
+ catch (error) {
1899
+ ctx.status = 409;
1900
+ ctx.body = {
1901
+ error: boundedUtf8(error instanceof Error ? error.message : String(error), CHROME_INSPECTION_ERROR_MAX_BYTES),
1902
+ };
1903
+ }
1904
+ });
1905
+ router.put(DAEMON_CHROME_INSPECTION_PATH, async (ctx) => {
1906
+ const host = daemonManagement?.chromeInspection;
1907
+ if (!host || !isConfiguredDaemonManagement(daemonManagement)) {
1908
+ return notFound(ctx, "Chrome inspection management is not configured");
1909
+ }
1910
+ if (!authorizeLoopbackManagementRequest(ctx, daemonManagement.managementToken))
1911
+ return;
1912
+ if (!ctx.is("application/json")) {
1913
+ ctx.status = 415;
1914
+ ctx.body = { error: "Chrome inspection configuration requires application/json" };
1915
+ return;
1916
+ }
1917
+ let input;
1918
+ try {
1919
+ input = parseDaemonChromeInspectionConfigureInput(await readBoundedJson(ctx, CHROME_INSPECTION_BODY_MAX_BYTES));
1920
+ }
1921
+ catch (error) {
1922
+ if (error instanceof RequestBodyTooLargeError) {
1923
+ ctx.status = 413;
1924
+ ctx.body = { error: error.message };
1925
+ return;
1926
+ }
1927
+ return bad(ctx, error instanceof Error ? error.message : String(error));
1928
+ }
1929
+ try {
1930
+ ctx.set("Cache-Control", "no-store");
1931
+ ctx.body = await host.configure(input);
1932
+ }
1933
+ catch (error) {
1934
+ ctx.status = 409;
1935
+ ctx.body = {
1936
+ error: boundedUtf8(error instanceof Error ? error.message : String(error), CHROME_INSPECTION_ERROR_MAX_BYTES),
1937
+ };
1938
+ }
1939
+ });
1940
+ router.post(DAEMON_CLEANUP_SESSIONS_PATH, async (ctx) => {
1941
+ const host = daemonManagement?.maintenance;
1942
+ if (!host || !isConfiguredDaemonManagement(daemonManagement)) {
1943
+ return notFound(ctx, "daemon maintenance is not configured");
1944
+ }
1945
+ if (!authorizeLoopbackManagementRequest(ctx, daemonManagement.managementToken))
1946
+ return;
1947
+ if (!ctx.is("application/json")) {
1948
+ ctx.status = 415;
1949
+ ctx.body = { error: "Session cleanup requires application/json" };
1950
+ return;
1951
+ }
1952
+ let input;
1953
+ try {
1954
+ input = parseDaemonCleanupSessionsInput(await readBoundedJson(ctx, CLEANUP_SESSIONS_BODY_MAX_BYTES));
1955
+ }
1956
+ catch (error) {
1957
+ if (error instanceof RequestBodyTooLargeError) {
1958
+ ctx.status = 413;
1959
+ ctx.body = { error: error.message };
1960
+ return;
1961
+ }
1962
+ return bad(ctx, error instanceof Error ? error.message : String(error));
1963
+ }
1964
+ try {
1965
+ ctx.set("Cache-Control", "no-store");
1966
+ ctx.body = host.cleanupSessions(input);
1967
+ }
1968
+ catch (error) {
1969
+ ctx.status = 409;
1970
+ ctx.body = {
1971
+ error: boundedUtf8(error instanceof Error ? error.message : String(error), BROWSER_ARTIFACT_ERROR_MAX_BYTES),
1972
+ };
1973
+ }
1974
+ });
1975
+ router.post("/api/internal/remote-runtime/pairing-offers", async (ctx) => {
1976
+ if (!remoteRuntimeAdmin || !isConfiguredDaemonManagement(daemonManagement)) {
1977
+ return notFound(ctx, "Remote Runtime administration is not configured");
1978
+ }
1979
+ if (!authorizeLoopbackManagementRequest(ctx, daemonManagement.managementToken))
1980
+ return;
1981
+ await createRuntimePairingOffer(ctx, "offer");
1982
+ });
1983
+ router.get("/api/internal/remote-runtime/clients", (ctx) => {
1984
+ if (!remoteRuntimeAdmin || !isConfiguredDaemonManagement(daemonManagement)) {
1985
+ return notFound(ctx, "Remote Runtime administration is not configured");
1986
+ }
1987
+ if (!authorizeLoopbackManagementRequest(ctx, daemonManagement.managementToken))
1988
+ return;
1989
+ listRuntimeAccess(ctx);
1990
+ });
1991
+ router.delete("/api/internal/remote-runtime/clients/:grantId", (ctx) => {
1992
+ if (!remoteRuntimeAdmin || !isConfiguredDaemonManagement(daemonManagement)) {
1993
+ return notFound(ctx, "Remote Runtime administration is not configured");
1994
+ }
1995
+ if (!authorizeLoopbackManagementRequest(ctx, daemonManagement.managementToken))
1996
+ return;
1997
+ revokeRuntimeAccess(ctx);
1998
+ });
1999
+ router.delete("/api/internal/remote-runtime/clients/:grantId/record", (ctx) => {
2000
+ if (!remoteRuntimeAdmin || !isConfiguredDaemonManagement(daemonManagement)) {
2001
+ return notFound(ctx, "Remote Runtime administration is not configured");
2002
+ }
2003
+ if (!authorizeLoopbackManagementRequest(ctx, daemonManagement.managementToken))
2004
+ return;
2005
+ deleteRevokedRuntimeAccess(ctx);
2006
+ });
2007
+ router.get("/api/internal/plugins", async (ctx) => {
2008
+ if (!isConfiguredDaemonManagement(daemonManagement) ||
2009
+ !daemonManagement.pluginManagement) {
2010
+ return notFound(ctx, "plugin management is not configured");
2011
+ }
2012
+ if (!authorizeLoopbackManagementRequest(ctx, daemonManagement.managementToken))
2013
+ return;
2014
+ ctx.set("Cache-Control", "no-store");
2015
+ ctx.body = await daemonManagement.pluginManagement.listPlugins();
2016
+ });
2017
+ router.post(PLUGIN_INSTALL_PREPARATIONS_PATH, async (ctx) => {
2018
+ if (!isConfiguredDaemonManagement(daemonManagement) ||
2019
+ !daemonManagement.pluginManagement) {
2020
+ return notFound(ctx, "plugin management is not configured");
2021
+ }
2022
+ if (!authorizeLoopbackManagementRequest(ctx, daemonManagement.managementToken))
2023
+ return;
2024
+ if (!ctx.is("application/json")) {
2025
+ ctx.status = 415;
2026
+ ctx.body = { error: "plugin installation preparation requires application/json" };
2027
+ return;
2028
+ }
2029
+ let input;
2030
+ try {
2031
+ input = parsePluginInstallPrepareInput(await readBoundedJson(ctx, PLUGIN_INSTALL_BODY_MAX_BYTES));
2032
+ }
2033
+ catch (error) {
2034
+ if (error instanceof RequestBodyTooLargeError) {
2035
+ ctx.status = 413;
2036
+ ctx.body = { error: error.message };
2037
+ return;
2038
+ }
2039
+ return bad(ctx, error instanceof Error ? error.message : String(error));
2040
+ }
2041
+ const invocation = pluginInstallInvocationAbortScope(ctx, shutdownSignal);
2042
+ try {
2043
+ ctx.set("Cache-Control", "no-store");
2044
+ ctx.status = 201;
2045
+ ctx.body = await daemonManagement.pluginManagement.prepareInstallation(input, { signal: invocation.signal });
2046
+ }
2047
+ catch (error) {
2048
+ if (!ctx.res.destroyed) {
2049
+ ctx.status = invocation.signal.aborted
2050
+ ? invocation.signal.reason instanceof Error &&
2051
+ invocation.signal.reason.name === "TimeoutError"
2052
+ ? 504
2053
+ : 499
2054
+ : 409;
2055
+ ctx.body = {
2056
+ error: boundedUtf8(error instanceof Error ? error.message : String(error), PLUGIN_INSTALL_ERROR_MAX_BYTES),
2057
+ };
2058
+ }
2059
+ }
2060
+ finally {
2061
+ invocation.dispose();
2062
+ }
2063
+ });
2064
+ router.post(`${PLUGIN_INSTALL_PREPARATIONS_PATH}/:token/commit`, async (ctx) => {
2065
+ if (!isConfiguredDaemonManagement(daemonManagement) ||
2066
+ !daemonManagement.pluginManagement) {
2067
+ return notFound(ctx, "plugin management is not configured");
2068
+ }
2069
+ if (!authorizeLoopbackManagementRequest(ctx, daemonManagement.managementToken))
2070
+ return;
2071
+ const token = ctx.params.token;
2072
+ if (!/^[A-Za-z0-9_-]{32,128}$/.test(token)) {
2073
+ return bad(ctx, "invalid plugin preparation token");
2074
+ }
2075
+ if (!ctx.is("application/json")) {
2076
+ ctx.status = 415;
2077
+ ctx.body = { error: "plugin installation commit requires application/json" };
2078
+ return;
2079
+ }
2080
+ let input;
2081
+ try {
2082
+ input = parsePluginInstallCommitInput(await readBoundedJson(ctx, PLUGIN_INSTALL_BODY_MAX_BYTES));
2083
+ }
2084
+ catch (error) {
2085
+ if (error instanceof RequestBodyTooLargeError) {
2086
+ ctx.status = 413;
2087
+ ctx.body = { error: error.message };
2088
+ return;
2089
+ }
2090
+ return bad(ctx, error instanceof Error ? error.message : String(error));
2091
+ }
2092
+ const invocation = pluginInstallInvocationAbortScope(ctx, shutdownSignal);
2093
+ try {
2094
+ ctx.set("Cache-Control", "no-store");
2095
+ ctx.body = await daemonManagement.pluginManagement.commitInstallation(token, input, { signal: invocation.signal });
2096
+ }
2097
+ catch (error) {
2098
+ if (!ctx.res.destroyed) {
2099
+ ctx.status = invocation.signal.aborted
2100
+ ? invocation.signal.reason instanceof Error &&
2101
+ invocation.signal.reason.name === "TimeoutError"
2102
+ ? 504
2103
+ : 499
2104
+ : 409;
2105
+ ctx.body = {
2106
+ error: boundedUtf8(error instanceof Error ? error.message : String(error), PLUGIN_INSTALL_ERROR_MAX_BYTES),
2107
+ };
2108
+ }
2109
+ }
2110
+ finally {
2111
+ invocation.dispose();
2112
+ }
2113
+ });
2114
+ router.delete(`${PLUGIN_INSTALL_PREPARATIONS_PATH}/:token`, async (ctx) => {
2115
+ if (!isConfiguredDaemonManagement(daemonManagement) ||
2116
+ !daemonManagement.pluginManagement) {
2117
+ return notFound(ctx, "plugin management is not configured");
2118
+ }
2119
+ if (!authorizeLoopbackManagementRequest(ctx, daemonManagement.managementToken))
2120
+ return;
2121
+ const token = ctx.params.token;
2122
+ if (!/^[A-Za-z0-9_-]{32,128}$/.test(token)) {
2123
+ return bad(ctx, "invalid plugin preparation token");
2124
+ }
2125
+ const cancelled = await daemonManagement.pluginManagement.cancelPreparation(token);
2126
+ ctx.set("Cache-Control", "no-store");
2127
+ ctx.body = { cancelled };
2128
+ });
2129
+ router.post("/api/internal/plugins/:pluginId/enable", async (ctx) => {
2130
+ await updateManagedPluginState(ctx, true);
2131
+ });
2132
+ router.post("/api/internal/plugins/:pluginId/disable", async (ctx) => {
2133
+ await updateManagedPluginState(ctx, false);
2134
+ });
2135
+ router.delete("/api/internal/plugins/:pluginId", async (ctx) => {
2136
+ if (!isConfiguredDaemonManagement(daemonManagement) ||
2137
+ !daemonManagement.pluginManagement) {
2138
+ return notFound(ctx, "plugin management is not configured");
2139
+ }
2140
+ if (!authorizeLoopbackManagementRequest(ctx, daemonManagement.managementToken))
2141
+ return;
2142
+ const pluginId = ctx.params.pluginId;
2143
+ if (!/^[a-z][a-z0-9-]{0,62}$/.test(pluginId)) {
2144
+ return bad(ctx, "invalid plugin id");
2145
+ }
2146
+ try {
2147
+ const uninstalled = await daemonManagement.pluginManagement.uninstallPlugin(pluginId);
2148
+ if (!uninstalled)
2149
+ return notFound(ctx, `plugin "${pluginId}" not found`);
2150
+ ctx.set("Cache-Control", "no-store");
2151
+ ctx.body = { uninstalled: true, pluginId };
2152
+ }
2153
+ catch (error) {
2154
+ ctx.status = 409;
2155
+ ctx.body = { error: error instanceof Error ? error.message : String(error) };
2156
+ }
2157
+ });
2158
+ router.post("/api/internal/plugins/:pluginId/cli", async (ctx) => {
2159
+ if (!isConfiguredDaemonManagement(daemonManagement)) {
2160
+ return notFound(ctx, "daemon management is not configured");
2161
+ }
2162
+ if (!authorizeLoopbackManagementRequest(ctx, daemonManagement.managementToken))
2163
+ return;
2164
+ const pluginId = ctx.params.pluginId;
2165
+ if (!/^[a-z][a-z0-9-]{0,62}$/.test(pluginId)) {
2166
+ return bad(ctx, "invalid plugin id");
2167
+ }
2168
+ if (!ctx.is("application/json")) {
2169
+ ctx.status = 415;
2170
+ ctx.body = { error: "plugin CLI invocation requires application/json" };
2171
+ return;
2172
+ }
2173
+ let body;
2174
+ try {
2175
+ body = await readBoundedJson(ctx, PLUGIN_CLI_BODY_MAX_BYTES);
2176
+ }
2177
+ catch (error) {
2178
+ if (error instanceof RequestBodyTooLargeError) {
2179
+ ctx.status = 413;
2180
+ ctx.body = { error: error.message };
2181
+ return;
2182
+ }
2183
+ return bad(ctx, error instanceof Error ? error.message : String(error));
2184
+ }
2185
+ const input = parsePluginCliInvocation(pluginId, body);
2186
+ if (typeof input === "string")
2187
+ return bad(ctx, input);
2188
+ const invocation = pluginCliInvocationAbortScope(ctx, shutdownSignal);
2189
+ try {
2190
+ ctx.body = await daemonManagement.invokePluginCli(input, { signal: invocation.signal });
2191
+ }
2192
+ catch (error) {
2193
+ if (!ctx.res.destroyed) {
2194
+ ctx.status = invocation.signal.aborted
2195
+ ? invocation.signal.reason instanceof Error && invocation.signal.reason.name === "TimeoutError"
2196
+ ? 504
2197
+ : 499
2198
+ : 409;
2199
+ ctx.body = {
2200
+ error: boundedUtf8(error instanceof Error ? error.message : String(error), PLUGIN_CLI_ERROR_MAX_BYTES),
2201
+ };
2202
+ }
2203
+ }
2204
+ finally {
2205
+ invocation.dispose();
2206
+ }
2207
+ });
2208
+ async function updateManagedPluginState(ctx, enabled) {
2209
+ if (!isConfiguredDaemonManagement(daemonManagement) ||
2210
+ !daemonManagement.pluginManagement) {
2211
+ notFound(ctx, "plugin management is not configured");
2212
+ return;
2213
+ }
2214
+ if (!authorizeLoopbackManagementRequest(ctx, daemonManagement.managementToken))
2215
+ return;
2216
+ const pluginId = ctx.params.pluginId;
2217
+ if (!/^[a-z][a-z0-9-]{0,62}$/.test(pluginId)) {
2218
+ bad(ctx, "invalid plugin id");
2219
+ return;
2220
+ }
2221
+ try {
2222
+ const plugin = await daemonManagement.pluginManagement.setPluginEnabled(pluginId, enabled);
2223
+ if (!plugin) {
2224
+ notFound(ctx, `plugin "${pluginId}" not found`);
2225
+ return;
2226
+ }
2227
+ ctx.set("Cache-Control", "no-store");
2228
+ ctx.body = { plugin };
2229
+ }
2230
+ catch (error) {
2231
+ ctx.status = 409;
2232
+ ctx.body = { error: error instanceof Error ? error.message : String(error) };
2233
+ }
2234
+ }
2235
+ router.post("/api/internal/plugin-runtimes/:pluginId/reload", async (ctx) => {
2236
+ if (!pluginRuntimeReload || !isConfiguredDaemonManagement(daemonManagement)) {
2237
+ return notFound(ctx, "plugin runtime management is not configured");
2238
+ }
2239
+ if (!authorizeLoopbackManagementRequest(ctx, daemonManagement.managementToken))
2240
+ return;
2241
+ const pluginId = ctx.params.pluginId;
2242
+ if (!/^[a-z][a-z0-9-]{0,62}$/.test(pluginId)) {
2243
+ return bad(ctx, "invalid plugin id");
2244
+ }
2245
+ try {
2246
+ ctx.body = await pluginRuntimeReload(pluginId);
2247
+ }
2248
+ catch (error) {
2249
+ ctx.status = 409;
2250
+ ctx.body = { error: error instanceof Error ? error.message : String(error) };
2251
+ }
2252
+ });
2253
+ router.post("/api/internal/plugin-runtimes/:pluginId/ensure", async (ctx) => {
2254
+ if (!pluginRuntimeEnsure || !isConfiguredDaemonManagement(daemonManagement)) {
2255
+ return notFound(ctx, "plugin runtime management is not configured");
2256
+ }
2257
+ if (!authorizeLoopbackManagementRequest(ctx, daemonManagement.managementToken))
2258
+ return;
2259
+ const pluginId = ctx.params.pluginId;
2260
+ if (!/^[a-z][a-z0-9-]{0,62}$/.test(pluginId)) {
2261
+ return bad(ctx, "invalid plugin id");
2262
+ }
2263
+ try {
2264
+ ctx.body = await pluginRuntimeEnsure(pluginId);
2265
+ }
2266
+ catch (error) {
2267
+ ctx.status = 409;
2268
+ ctx.body = { error: error instanceof Error ? error.message : String(error) };
2269
+ }
2270
+ });
2271
+ router.get("/control/link", (ctx) => {
2272
+ const token = asString(ctx.query.token);
2273
+ if (!token || !deps.consumeControlLink)
2274
+ return notFound(ctx, "control link not found");
2275
+ const link = deps.consumeControlLink(token);
2276
+ if (!link)
2277
+ return notFound(ctx, "control link expired or already used");
2278
+ ctx.redirect(`/sessions/${encodeURIComponent(link.sessionId)}`);
2279
+ });
38
2280
  router.get("/api/channel-types", (ctx) => {
39
2281
  ctx.body = { channelTypes: deps.listChannelTypes() };
40
2282
  });
@@ -57,16 +2299,27 @@ export function createControlRouter(opts) {
57
2299
  return bad(ctx, "missing channel name");
58
2300
  if (!type)
59
2301
  return bad(ctx, "missing channel type");
60
- const { id } = deps.createChannel({ name, type, options: asRecord(body.options) });
2302
+ let id;
2303
+ try {
2304
+ ({ id } = deps.createChannel({ name, type, options: asRecord(body.options) }));
2305
+ }
2306
+ catch (error) {
2307
+ return bad(ctx, error instanceof Error ? error.message : String(error));
2308
+ }
61
2309
  ctx.body = { ok: true, id };
62
2310
  });
63
2311
  router.put("/api/channels/:id", async (ctx) => {
64
2312
  const id = ctx.params.id;
65
2313
  const body = await readJson(ctx);
66
- deps.setChannel(id, {
67
- ...(body.name !== undefined ? { name: asString(body.name) } : {}),
68
- ...(body.options !== undefined ? { options: asRecord(body.options) } : {}),
69
- });
2314
+ try {
2315
+ deps.setChannel(id, {
2316
+ ...(body.name !== undefined ? { name: asString(body.name) } : {}),
2317
+ ...(body.options !== undefined ? { options: asRecord(body.options) } : {}),
2318
+ });
2319
+ }
2320
+ catch (error) {
2321
+ return bad(ctx, error instanceof Error ? error.message : String(error));
2322
+ }
70
2323
  const bound = instanceForChannel(id);
71
2324
  if (bound)
72
2325
  await manager.reloadInstance(bound.id);
@@ -89,13 +2342,16 @@ export function createControlRouter(opts) {
89
2342
  return notFound(ctx, "channel not found");
90
2343
  await streamEvents(ctx, async (send, signal) => {
91
2344
  send("status", { text: `authorizing ${channel.type}…` });
92
- const options = await deps.authorizeChannel(channel.type, (m) => send(m.kind, m), signal);
93
- deps.setChannel(id, { options });
94
2345
  const bound = instanceForChannel(id);
2346
+ const options = await deps.authorizeChannel(channel.type, {
2347
+ ...(bound ? { instanceId: bound.id } : {}),
2348
+ config: channel.options ?? {},
2349
+ }, (m) => send(m.kind, m), signal);
2350
+ deps.setChannel(id, { options });
95
2351
  if (bound)
96
2352
  await manager.reloadInstance(bound.id);
97
2353
  send("done", { id });
98
- });
2354
+ }, shutdownSignal);
99
2355
  });
100
2356
  // ── instances ──────────────────────────────────────────────────────────
101
2357
  router.get("/api/instances", (ctx) => {
@@ -260,27 +2516,24 @@ export function createControlRouter(opts) {
260
2516
  bad(ctx, error instanceof Error ? error.message : String(error));
261
2517
  }
262
2518
  };
263
- // Per-session device scoping (orca's per-worktree active emulator, keyed by
264
- // rynx session id riding the `x-rynx-session` header): an attach from a
265
- // session binds it to the attached device; later session commands without an
266
- // explicit `device` resolve against the binding. Sharing is allowed (N
267
- // sessions → one device); explicit `device` always wins; sessions without a
268
- // binding fall back to the global active. In-memory by design: on a daemon
269
- // restart agents just re-attach (the skill mandates attach-before-use).
270
- const emulatorSessionDevices = new Map();
271
- const emulatorSessionOf = (ctx) => ctx.get("x-rynx-session")?.trim() || undefined;
272
- const emulatorScopedDevice = (ctx, body) => {
273
- const explicit = asString(body.device);
274
- if (explicit)
275
- return explicit;
2519
+ const localSessionEmulatorBinding = async (ctx, body) => {
276
2520
  const sessionId = emulatorSessionOf(ctx);
277
- return sessionId ? emulatorSessionDevices.get(sessionId) : undefined;
278
- };
279
- const emulatorUnbindDevice = (device) => {
280
- for (const [sessionId, bound] of emulatorSessionDevices) {
281
- if (bound === device)
282
- emulatorSessionDevices.delete(sessionId);
2521
+ if (!sessionId)
2522
+ return undefined;
2523
+ if (!sessionEmulators)
2524
+ throw new Error("Session Emulator service is unavailable");
2525
+ const binding = await sessionEmulators.getState({ sessionId });
2526
+ if (!binding)
2527
+ throw new Error("Session has no attached Emulator");
2528
+ const explicit = asString(body.device);
2529
+ if (explicit && explicit !== binding.deviceId) {
2530
+ throw new Error("Explicit device does not match the Session Emulator binding");
283
2531
  }
2532
+ return binding;
2533
+ };
2534
+ const localEmulatorDevice = async (ctx, body) => {
2535
+ const binding = await localSessionEmulatorBinding(ctx, body);
2536
+ return binding?.deviceId ?? asString(body.device);
284
2537
  };
285
2538
  router.get("/api/emulator/doctor", async (ctx) => {
286
2539
  await emulatorResult(ctx, (emulator) => emulator.doctor());
@@ -290,7 +2543,16 @@ export function createControlRouter(opts) {
290
2543
  });
291
2544
  router.get("/api/emulator/active", async (ctx) => {
292
2545
  await emulatorResult(ctx, async (emulator) => {
293
- const device = emulatorScopedDevice(ctx, {});
2546
+ const sessionId = emulatorSessionOf(ctx);
2547
+ let device;
2548
+ if (sessionId) {
2549
+ if (!sessionEmulators)
2550
+ throw new Error("Session Emulator service is unavailable");
2551
+ const binding = await sessionEmulators.getState({ sessionId });
2552
+ if (!binding)
2553
+ return { active: null };
2554
+ device = binding.deviceId;
2555
+ }
294
2556
  if (device && emulator.deviceSession) {
295
2557
  return { active: await emulator.deviceSession({ device }) };
296
2558
  }
@@ -301,11 +2563,21 @@ export function createControlRouter(opts) {
301
2563
  const body = await readJson(ctx);
302
2564
  const sessionId = emulatorSessionOf(ctx);
303
2565
  await emulatorResult(ctx, async (emulator) => {
304
- const session = (await emulator.attach(asString(body.device)));
305
- if (sessionId && typeof session?.deviceUdid === "string") {
306
- emulatorSessionDevices.set(sessionId, session.deviceUdid);
2566
+ if (!sessionId)
2567
+ return emulator.attach(asString(body.device));
2568
+ if (!sessionEmulators)
2569
+ throw new Error("Session Emulator service is unavailable");
2570
+ let device = asString(body.device);
2571
+ if (!device) {
2572
+ const devices = await sessionEmulators.listDevices({ sessionId });
2573
+ device = devices.find((candidate) => candidate.isDefault)?.id ?? devices[0]?.id;
307
2574
  }
308
- return session;
2575
+ if (!device)
2576
+ throw new Error("No Emulator is available for this Session");
2577
+ const binding = await sessionEmulators.attach({ sessionId, device });
2578
+ return emulator.deviceSession
2579
+ ? await emulator.deviceSession({ device: binding.deviceId }) ?? binding
2580
+ : binding;
309
2581
  });
310
2582
  });
311
2583
  router.post("/api/emulator/tap", async (ctx) => {
@@ -314,50 +2586,95 @@ export function createControlRouter(opts) {
314
2586
  const y = asNumber(body.y);
315
2587
  if (x == null || y == null)
316
2588
  return bad(ctx, "tap requires numeric x and y");
317
- await emulatorResult(ctx, (emulator) => emulator.tap({ x, y, device: emulatorScopedDevice(ctx, body) }));
2589
+ await emulatorResult(ctx, async (emulator) => {
2590
+ const binding = await localSessionEmulatorBinding(ctx, body);
2591
+ return binding && sessionEmulators
2592
+ ? sessionEmulators.tap({ sessionId: binding.sessionId, bindingId: binding.bindingId, x, y })
2593
+ : emulator.tap({ x, y, device: asString(body.device) });
2594
+ });
318
2595
  });
319
2596
  router.post("/api/emulator/type", async (ctx) => {
320
2597
  const body = await readJson(ctx);
321
2598
  const text = asString(body.text);
322
2599
  if (!text)
323
2600
  return bad(ctx, "type requires text");
324
- await emulatorResult(ctx, (emulator) => emulator.type({ text, device: emulatorScopedDevice(ctx, body), mode: asString(body.mode) }));
2601
+ await emulatorResult(ctx, async (emulator) => {
2602
+ const binding = await localSessionEmulatorBinding(ctx, body);
2603
+ const mode = asString(body.mode);
2604
+ return binding && sessionEmulators
2605
+ ? sessionEmulators.type({
2606
+ sessionId: binding.sessionId,
2607
+ bindingId: binding.bindingId,
2608
+ text,
2609
+ ...(mode === "paste" || mode === "keys" ? { mode } : {}),
2610
+ })
2611
+ : emulator.type({ text, device: asString(body.device), mode });
2612
+ });
325
2613
  });
326
2614
  router.post("/api/emulator/button", async (ctx) => {
327
2615
  const body = await readJson(ctx);
328
- await emulatorResult(ctx, (emulator) => emulator.button({ name: asString(body.name), device: emulatorScopedDevice(ctx, body) }));
2616
+ await emulatorResult(ctx, async (emulator) => {
2617
+ const binding = await localSessionEmulatorBinding(ctx, body);
2618
+ const name = asString(body.name);
2619
+ return binding && sessionEmulators
2620
+ ? sessionEmulators.button({
2621
+ sessionId: binding.sessionId,
2622
+ bindingId: binding.bindingId,
2623
+ ...(name ? { name } : {}),
2624
+ })
2625
+ : emulator.button({ name, device: asString(body.device) });
2626
+ });
329
2627
  });
330
2628
  router.post("/api/emulator/rotate", async (ctx) => {
331
2629
  const body = await readJson(ctx);
332
2630
  const orientation = asString(body.orientation);
333
2631
  if (!orientation)
334
2632
  return bad(ctx, "rotate requires orientation");
335
- await emulatorResult(ctx, (emulator) => emulator.rotate({ orientation, device: emulatorScopedDevice(ctx, body) }));
2633
+ await emulatorResult(ctx, async (emulator) => {
2634
+ const binding = await localSessionEmulatorBinding(ctx, body);
2635
+ return binding && sessionEmulators
2636
+ ? sessionEmulators.rotate({ sessionId: binding.sessionId, bindingId: binding.bindingId, orientation })
2637
+ : emulator.rotate({ orientation, device: asString(body.device) });
2638
+ });
336
2639
  });
337
2640
  router.post("/api/emulator/launch", async (ctx) => {
338
2641
  const body = await readJson(ctx);
339
2642
  const appId = asString(body.appId) ?? asString(body.bundleId) ?? asString(body.packageName);
340
2643
  if (!appId)
341
2644
  return bad(ctx, "launch requires appId");
342
- await emulatorResult(ctx, (emulator) => emulator.launch({ appId, device: emulatorScopedDevice(ctx, body) }));
2645
+ await emulatorResult(ctx, async (emulator) => {
2646
+ const binding = await localSessionEmulatorBinding(ctx, body);
2647
+ return binding && sessionEmulators
2648
+ ? sessionEmulators.launch({ sessionId: binding.sessionId, bindingId: binding.bindingId, appId })
2649
+ : emulator.launch({ appId, device: asString(body.device) });
2650
+ });
343
2651
  });
344
2652
  router.post("/api/emulator/gesture", async (ctx) => {
345
2653
  const body = await readJson(ctx);
346
2654
  const points = body.points;
347
2655
  if (!Array.isArray(points))
348
2656
  return bad(ctx, "gesture requires points");
349
- await emulatorResult(ctx, (emulator) => emulator.gesture({ points, device: emulatorScopedDevice(ctx, body) }));
2657
+ await emulatorResult(ctx, async (emulator) => {
2658
+ const binding = await localSessionEmulatorBinding(ctx, body);
2659
+ return binding && sessionEmulators
2660
+ ? sessionEmulators.gesture({
2661
+ sessionId: binding.sessionId,
2662
+ bindingId: binding.bindingId,
2663
+ points: points,
2664
+ })
2665
+ : emulator.gesture({ points, device: asString(body.device) });
2666
+ });
350
2667
  });
351
2668
  router.post("/api/emulator/exec", async (ctx) => {
352
2669
  const body = await readJson(ctx);
353
2670
  const command = asString(body.command);
354
2671
  if (!command)
355
2672
  return bad(ctx, "exec requires command");
356
- await emulatorResult(ctx, (emulator) => emulator.exec({ command, device: emulatorScopedDevice(ctx, body) }));
2673
+ await emulatorResult(ctx, async (emulator) => emulator.exec({ command, device: await localEmulatorDevice(ctx, body) }));
357
2674
  });
358
2675
  router.post("/api/emulator/screenshot", async (ctx) => {
359
2676
  const body = await readJson(ctx);
360
- await emulatorResult(ctx, (emulator) => emulator.screenshot({ device: emulatorScopedDevice(ctx, body), out: asString(body.out) }));
2677
+ await emulatorResult(ctx, async (emulator) => emulator.screenshot({ device: await localEmulatorDevice(ctx, body), out: asString(body.out) }));
361
2678
  });
362
2679
  router.get("/api/emulator/frame", async (ctx) => {
363
2680
  const emulator = emulatorOr404(ctx);
@@ -368,7 +2685,7 @@ export function createControlRouter(opts) {
368
2685
  }
369
2686
  const body = { device: asString(ctx.query.device) };
370
2687
  try {
371
- const frame = await emulator.frame({ device: emulatorScopedDevice(ctx, body) });
2688
+ const frame = await emulator.frame({ device: await localEmulatorDevice(ctx, body) });
372
2689
  ctx.type = frame.contentType;
373
2690
  ctx.set("Cache-Control", "no-store");
374
2691
  ctx.body = frame.data;
@@ -386,7 +2703,7 @@ export function createControlRouter(opts) {
386
2703
  }
387
2704
  const body = { device: asString(ctx.query.device) };
388
2705
  try {
389
- const video = await emulator.video({ device: emulatorScopedDevice(ctx, body) });
2706
+ const video = await emulator.video({ device: await localEmulatorDevice(ctx, body) });
390
2707
  let stopped = false;
391
2708
  const stop = () => {
392
2709
  if (stopped)
@@ -401,6 +2718,17 @@ export function createControlRouter(opts) {
401
2718
  res.setHeader("Cache-Control", "no-store");
402
2719
  res.setHeader("X-Content-Type-Options", "nosniff");
403
2720
  res.on("close", stop);
2721
+ const stopForShutdown = () => {
2722
+ stop();
2723
+ if (!res.writableEnded)
2724
+ res.end();
2725
+ };
2726
+ if (shutdownSignal?.aborted) {
2727
+ stopForShutdown();
2728
+ return;
2729
+ }
2730
+ shutdownSignal?.addEventListener("abort", stopForShutdown, { once: true });
2731
+ res.on("close", () => shutdownSignal?.removeEventListener("abort", stopForShutdown));
404
2732
  video.stream.on("end", stop);
405
2733
  video.stream.on("error", (error) => {
406
2734
  stop();
@@ -438,25 +2766,25 @@ export function createControlRouter(opts) {
438
2766
  router.post("/api/emulator/kill", async (ctx) => {
439
2767
  const body = await readJson(ctx);
440
2768
  await emulatorResult(ctx, async (emulator) => {
441
- const result = (await emulator.kill({ device: emulatorScopedDevice(ctx, body) }));
442
- if (typeof result?.device === "string")
443
- emulatorUnbindDevice(result.device);
444
- return result;
2769
+ const device = await localEmulatorDevice(ctx, body);
2770
+ return sessionEmulators
2771
+ ? sessionEmulators.stopDevice({ action: "kill", ...(device ? { device } : {}) })
2772
+ : emulator.kill({ device });
445
2773
  });
446
2774
  });
447
2775
  router.post("/api/emulator/shutdown", async (ctx) => {
448
2776
  const body = await readJson(ctx);
449
2777
  await emulatorResult(ctx, async (emulator) => {
450
- const result = (await emulator.shutdown({ device: emulatorScopedDevice(ctx, body) }));
451
- if (typeof result?.device === "string")
452
- emulatorUnbindDevice(result.device);
453
- return result;
2778
+ const device = await localEmulatorDevice(ctx, body);
2779
+ return sessionEmulators
2780
+ ? sessionEmulators.stopDevice({ action: "shutdown", ...(device ? { device } : {}) })
2781
+ : emulator.shutdown({ device });
454
2782
  });
455
2783
  });
456
2784
  // ── sessions (channel-agnostic agent runs) ───────────────────────────────
457
2785
  // Create a session bound to a preset agent (`agent`) or an inline spec
458
2786
  // (`config`, the config-driven / remote-ready path), then drive it with
459
- // messages over SSE. omnigent-style: create carries no message.
2787
+ // messages over SSE. reference implementation-style: create carries no message.
460
2788
  // One unified list: every session is a machine-session record. The registry
461
2789
  // (console + every channel) supplies identity — `source` is a plain field, not
462
2790
  // parsed from the id — and the canonical log supplies recency. A legacy session
@@ -474,9 +2802,10 @@ export function createControlRouter(opts) {
474
2802
  const updatedAt = log ? new Date(log.updatedAt).toISOString() : (meta?.updatedAt ?? createdAt);
475
2803
  return {
476
2804
  id,
2805
+ provider: meta?.provider,
477
2806
  agent: meta?.agent,
478
2807
  title: meta?.title,
479
- status: "idle",
2808
+ status: sessionRuntimeIndex.snapshot(id).status,
480
2809
  createdAt,
481
2810
  updatedAt,
482
2811
  source: meta?.source ?? "unknown",
@@ -531,7 +2860,7 @@ export function createControlRouter(opts) {
531
2860
  const bound = consoleMeta ? null : ((await sessionStore?.get(id)) ?? null);
532
2861
  if (!meta && !bound)
533
2862
  return notFound(ctx, "session not found");
534
- // First message names a console session (omnigent-style: the message,
2863
+ // First message names a console session (reference implementation-style: the message,
535
2864
  // truncated). Channel sessions keep their own title elsewhere — skip.
536
2865
  if (consoleMeta && !consoleMeta.title)
537
2866
  deps.setSessionTitle(id, synthesizeSessionTitle(message));
@@ -548,14 +2877,14 @@ export function createControlRouter(opts) {
548
2877
  const liveRuntime = await runtime.resolveRuntime({
549
2878
  agentName: consoleMeta?.agent,
550
2879
  agentSpec: consoleMeta?.config,
551
- provider: bound?.runtime,
2880
+ provider: meta?.provider ?? bound?.runtime,
552
2881
  });
553
2882
  // codex RESUME with a missing local rollout (fork / cross-machine): synthesize
554
2883
  // one from the session log so the app-server's thread/resume finds it. No-op
555
2884
  // when the rollout already exists (the normal single-machine case). The bound
556
2885
  // thread record is fetched directly — `bound` above is null for console
557
2886
  // sessions, but a console session still resumes a stored codex thread.
558
- if (liveRuntime === "codex" && sessionLog) {
2887
+ if ((liveRuntime === "codex" || liveRuntime === "traex") && sessionLog) {
559
2888
  const codexRecord = await sessionStore?.get(id);
560
2889
  if (codexRecord?.codexSessionId) {
561
2890
  ensureCodexResumeRollout({
@@ -563,6 +2892,7 @@ export function createControlRouter(opts) {
563
2892
  // the SAME id the runner child gets via `RYNX_RUNNER_SESSION`, so the
564
2893
  // synthesized rollout lands where that session's app-server reads.
565
2894
  sessionId: id,
2895
+ runtime: liveRuntime,
566
2896
  threadId: codexRecord.codexSessionId,
567
2897
  cwd: codexRecord.cwd ?? process.cwd(),
568
2898
  items: await sessionLog.snapshot(id),
@@ -570,8 +2900,13 @@ export function createControlRouter(opts) {
570
2900
  }
571
2901
  }
572
2902
  const live = await runnerManager.ensureLiveSession(id, {
573
- ...(bound?.cwd ? { cwd: bound.cwd } : {}),
2903
+ ...(bound?.cwd
2904
+ ? { cwd: bound.cwd }
2905
+ : consoleMeta?.config?.osEnv?.cwd
2906
+ ? { cwd: consoleMeta.config.osEnv.cwd }
2907
+ : {}),
574
2908
  runtime: liveRuntime,
2909
+ ...(consoleMeta?.reasoningEffort ? { reasoningEffort: consoleMeta.reasoningEffort } : {}),
575
2910
  // Carry the agent identity so the live launch applies the agent spec's
576
2911
  // model / skills / instructions (not just the runtime). A preset agent
577
2912
  // rides its id; an inline-config console session rides its spec.
@@ -580,11 +2915,15 @@ export function createControlRouter(opts) {
580
2915
  });
581
2916
  if (!live) {
582
2917
  // A live-runtime (codex/claude-native) session whose forwarder couldn't come
583
- // up. omnigent reports this as a failure and NEVER falls through to a second
2918
+ // up. reference implementation reports this as a failure and NEVER falls through to a second
584
2919
  // output path — falling through would run+persist the turn while the forwarder
585
2920
  // also mirrors it (the double-write). Report + stop.
2921
+ const detail = runnerManager.lastLiveSessionError?.(id);
586
2922
  ctx.status = 503;
587
- ctx.body = { error: `live session unavailable (${liveRuntime})` };
2923
+ ctx.body = {
2924
+ error: `live session unavailable (${liveRuntime})`,
2925
+ ...(detail ? { detail } : {}),
2926
+ };
588
2927
  return;
589
2928
  }
590
2929
  const outcome = await runnerManager.injectMessage(id, message);
@@ -594,7 +2933,7 @@ export function createControlRouter(opts) {
594
2933
  }
595
2934
  // notLive / notReady / failed → hard error. NEVER fall through to run+stream:
596
2935
  // the second path would double-write the turn alongside the forwarder
597
- // (omnigent: inject failure ⇒ response.failed, no local re-run).
2936
+ // (reference implementation: inject failure ⇒ response.failed, no local re-run).
598
2937
  ctx.status = 503;
599
2938
  ctx.body = { error: `live injection ${outcome}` };
600
2939
  return;
@@ -623,6 +2962,14 @@ export function createControlRouter(opts) {
623
2962
  const id = ctx.params.id;
624
2963
  ctx.body = { sessionId: id, items: sessionLog ? await sessionLog.snapshot(id) : [] };
625
2964
  });
2965
+ router.get("/api/sessions/:id/snapshot", async (ctx) => {
2966
+ const id = ctx.params.id;
2967
+ ctx.body = {
2968
+ sessionId: id,
2969
+ items: sessionLog ? await sessionLog.snapshot(id) : [],
2970
+ runtime: sessionRuntimeIndex.snapshot(id),
2971
+ };
2972
+ });
626
2973
  // Persistent live event stream (codex-native): one SSE per session, held for
627
2974
  // its whole lifetime, tailing the canonical bus. Sees EVERY turn — web-injected
628
2975
  // AND co-driving-TUI-initiated — since the forwarder is the single writer. The
@@ -630,10 +2977,25 @@ export function createControlRouter(opts) {
630
2977
  // + item-id dedupe. Ends when the client disconnects.
631
2978
  router.get("/api/sessions/:id/stream", async (ctx) => {
632
2979
  const id = ctx.params.id;
2980
+ // Production daemon composition owns a binding-neutral Local connection.
2981
+ // Keep this legacy URL compatible while routing it through the same bounded
2982
+ // subscription adapter as `/api/runtimes/local/...`.
2983
+ if (runtimeConnectionResolver) {
2984
+ await streamRuntimeSession(ctx, "local", id);
2985
+ return;
2986
+ }
633
2987
  if (!sessionBus)
634
2988
  return bad(ctx, "session streaming is not available on this server");
635
2989
  await streamEvents(ctx, async (send, signal) => {
636
2990
  const iterator = sessionBus.subscribe(id)[Symbol.asyncIterator]();
2991
+ // Headers are flushed before this callback runs, so a client cannot use
2992
+ // the HTTP response alone as proof that SessionBus subscription exists.
2993
+ // This frame is the ordering barrier for subscribe → snapshot → replay.
2994
+ // This compatibility fallback has no binding adapter queue. End the
2995
+ // subscription as soon as the HTTP consumer applies backpressure instead
2996
+ // of letting either Node or SessionBus accumulate an unbounded tail.
2997
+ if (!send("ready", { sessionId: id }))
2998
+ return;
637
2999
  const disconnected = new Promise((resolve) => {
638
3000
  if (signal.aborted)
639
3001
  resolve(null);
@@ -647,79 +3009,54 @@ export function createControlRouter(opts) {
647
3009
  break; // client went away
648
3010
  if (next.done)
649
3011
  break; // bus closed for this session
650
- send("event", next.value);
3012
+ if (!send("event", next.value))
3013
+ break;
651
3014
  }
652
3015
  }
653
3016
  finally {
654
3017
  await iterator.return?.();
655
3018
  }
656
- });
657
- });
658
- // claude-native approvals: the PermissionRequest hook POSTs here and BLOCKS.
659
- // Surface a `response.approval.requested` (the same event + ApprovalCard the
660
- // codex path uses), hold the response until the web user answers, then relay
661
- // claude's `{hookSpecificOutput:{decision:{behavior}}}` verdict to the hook.
662
- router.post("/api/sessions/:id/hooks/permission-request", async (ctx) => {
663
- const id = ctx.params.id;
664
- if (!sessionBus)
665
- return bad(ctx, "session streaming is not available on this server");
666
- const payload = await readJson(ctx);
667
- const toolName = asString(payload.tool_name) ?? "tool";
668
- const toolInput = payload.tool_input && typeof payload.tool_input === "object"
669
- ? payload.tool_input
670
- : {};
671
- const cwd = asString(payload.cwd);
672
- const approvalId = `claude_${randomUUID()}`;
673
- const kind = /edit|write|patch|apply|notebook/i.test(toolName)
674
- ? "patch"
675
- : "exec";
676
- const command = asString(toolInput.command) ?? asString(toolInput.file_path) ?? toolName;
677
- const event = {
678
- type: "response.approval.requested",
679
- responseId: `resp_claude_permission_${approvalId}`,
680
- approvalId,
681
- kind,
682
- ...(command ? { command } : {}),
683
- ...(cwd ? { cwd } : {}),
684
- };
685
- await persistSessionEvent(id, event, { sessionLog, sessionBus });
686
- const verdict = await new Promise((resolve) => {
687
- claudeApprovals.set(approvalId, resolve);
688
- ctx.req.on("close", () => {
689
- if (claudeApprovals.delete(approvalId))
690
- resolve({ behavior: "deny" });
691
- });
692
- });
693
- ctx.body = { hookSpecificOutput: { hookEventName: "PermissionRequest", decision: verdict } };
3019
+ }, shutdownSignal);
694
3020
  });
695
- // Resolve an interactive approval (Phase D): the user's decision for a
696
- // `response.approval.requested` event, routed to the session's runner (codex)
697
- // or the held claude PermissionRequest long-poll.
698
- router.post("/api/sessions/:id/approvals/:approvalId", async (ctx) => {
3021
+ // Provider-neutral interaction response. The runtime owns the pending waiter
3022
+ // and first-writer-wins decision; HTTP only validates and routes the generic
3023
+ // resolution. The canonical resolved event is emitted by the runtime after
3024
+ // the provider accepts it, so every observer converges on the same winner.
3025
+ router.post("/api/sessions/:id/interactions/:interactionId/resolve", async (ctx) => {
699
3026
  const id = ctx.params.id;
700
- const approvalId = ctx.params.approvalId;
3027
+ const interactionId = ctx.params.interactionId;
701
3028
  const body = await readJson(ctx);
702
- const decision = asString(body.decision);
703
- if (decision !== "acceptForSession" &&
704
- decision !== "accept" &&
705
- decision !== "decline" &&
706
- decision !== "cancel") {
707
- ctx.status = 400;
708
- ctx.body = { error: "decision must be acceptForSession | accept | decline | cancel" };
709
- return;
3029
+ const resolution = parseInteractionResolution(body.resolution);
3030
+ if (!resolution) {
3031
+ return bad(ctx, "resolution must contain actionId and bounded string answers");
710
3032
  }
711
- const claudeResolve = claudeApprovals.get(approvalId);
712
- if (claudeResolve) {
713
- claudeApprovals.delete(approvalId);
714
- claudeResolve(decision === "decline" || decision === "cancel" ? { behavior: "deny" } : { behavior: "allow" });
715
- ctx.body = { ok: true };
3033
+ if (!runnerManager?.resolveInteraction) {
3034
+ return notFound(ctx, "session interaction resolution is not available");
3035
+ }
3036
+ const result = await runnerManager.resolveInteraction(id, interactionId, resolution);
3037
+ if (result.disposition === "invalid") {
3038
+ return bad(ctx, "interaction resolution is invalid");
3039
+ }
3040
+ if (result.disposition === "not_found") {
3041
+ return notFound(ctx, "interaction is not pending");
3042
+ }
3043
+ if (result.disposition === "already_resolved") {
3044
+ ctx.status = 409;
3045
+ ctx.body = { error: "interaction was already resolved", ...result };
716
3046
  return;
717
3047
  }
718
- runnerManager?.resolveApproval?.(id, approvalId, decision);
719
- ctx.body = { ok: true };
3048
+ ctx.body = result;
720
3049
  });
721
3050
  router.delete("/api/sessions/:id", async (ctx) => {
722
3051
  const id = ctx.params.id;
3052
+ try {
3053
+ await sessionLifecycle?.beforeDelete?.(id);
3054
+ }
3055
+ catch {
3056
+ ctx.status = 409;
3057
+ ctx.body = { error: "session_resource_cleanup_failed" };
3058
+ return;
3059
+ }
723
3060
  runnerManager?.stopRunner(id);
724
3061
  sessionBus?.close(id);
725
3062
  deps.removeSessionMeta(id);
@@ -727,9 +3064,15 @@ export function createControlRouter(opts) {
727
3064
  // dropping only the meta would leave the session showing as an UNKNOWN
728
3065
  // ghost. Both together make it fully disappear.
729
3066
  await sessionLog?.deleteSession(id);
730
- // Unbind only the scope mapping — the device (and its helper) stays attached
731
- // for the web panel / other sessions sharing it (orca's unregisterWorktree).
732
- emulatorSessionDevices.delete(id);
3067
+ sessionRuntimeIndex.remove(id);
3068
+ try {
3069
+ await sessionLifecycle?.afterDelete?.(id);
3070
+ }
3071
+ catch {
3072
+ ctx.status = 500;
3073
+ ctx.body = { error: "session_resource_finalize_failed", outcome: "unknown" };
3074
+ return;
3075
+ }
733
3076
  ctx.body = { ok: true };
734
3077
  });
735
3078
  return router;
@@ -739,20 +3082,39 @@ export function createControlRouter(opts) {
739
3082
  * `signal` aborts when the client disconnects. Takes over the raw response so
740
3083
  * frames flush immediately; a thrown error is reported as an `error` event.
741
3084
  */
742
- async function streamEvents(ctx, run) {
3085
+ async function streamEvents(ctx, run, shutdownSignal) {
743
3086
  ctx.status = 200;
744
3087
  ctx.set("Content-Type", "text/event-stream");
745
- ctx.set("Cache-Control", "no-cache, no-transform");
3088
+ // Session streams may contain source code, prompts and tool output. `no-cache`
3089
+ // still permits storage after revalidation; keep transcript bytes out of
3090
+ // browser/proxy caches entirely and prohibit intermediary transformations.
3091
+ ctx.set("Cache-Control", "private, no-store, no-transform");
746
3092
  ctx.set("Connection", "keep-alive");
3093
+ ctx.set("X-Accel-Buffering", "no");
747
3094
  ctx.respond = false;
3095
+ ctx.req.socket.setTimeout(0);
748
3096
  const res = ctx.res;
749
3097
  res.flushHeaders?.();
750
3098
  const controller = new AbortController();
751
3099
  ctx.req.on("close", () => controller.abort());
3100
+ const abortForShutdown = () => controller.abort(shutdownSignal?.reason);
3101
+ if (shutdownSignal?.aborted)
3102
+ abortForShutdown();
3103
+ else
3104
+ shutdownSignal?.addEventListener("abort", abortForShutdown, { once: true });
752
3105
  const send = (event, data) => {
753
- if (!res.writableEnded)
754
- res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
3106
+ if (res.writableEnded)
3107
+ return false;
3108
+ return res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
755
3109
  };
3110
+ // Keep idle chat streams alive through reverse proxies and NATs. Without a
3111
+ // frame, an otherwise healthy session is often closed after a few minutes and
3112
+ // the web console has to rebuild its transcript subscription.
3113
+ const keepalive = setInterval(() => {
3114
+ if (!res.writableEnded && !res.writableNeedDrain)
3115
+ res.write(": keepalive\n\n");
3116
+ }, 25_000);
3117
+ keepalive.unref?.();
756
3118
  try {
757
3119
  await run(send, controller.signal);
758
3120
  }
@@ -760,12 +3122,39 @@ async function streamEvents(ctx, run) {
760
3122
  send("error", { message: error instanceof Error ? error.message : String(error) });
761
3123
  }
762
3124
  finally {
3125
+ clearInterval(keepalive);
3126
+ shutdownSignal?.removeEventListener("abort", abortForShutdown);
763
3127
  if (!res.writableEnded)
764
3128
  res.end();
765
3129
  }
766
3130
  }
3131
+ function waitForSseDrain(ctx, signal) {
3132
+ if (signal.aborted || ctx.res.writableEnded)
3133
+ return Promise.resolve(false);
3134
+ return new Promise((resolve) => {
3135
+ const settle = (drained) => {
3136
+ ctx.res.removeListener("drain", onDrain);
3137
+ ctx.res.removeListener("close", onClose);
3138
+ signal.removeEventListener("abort", onAbort);
3139
+ resolve(drained);
3140
+ };
3141
+ const onDrain = () => settle(true);
3142
+ const onClose = () => settle(false);
3143
+ const onAbort = () => settle(false);
3144
+ ctx.res.once("drain", onDrain);
3145
+ ctx.res.once("close", onClose);
3146
+ signal.addEventListener("abort", onAbort, { once: true });
3147
+ });
3148
+ }
3149
+ function abortAsNull(signal) {
3150
+ if (signal.aborted)
3151
+ return Promise.resolve(null);
3152
+ return new Promise((resolve) => {
3153
+ signal.addEventListener("abort", () => resolve(null), { once: true });
3154
+ });
3155
+ }
767
3156
  /** Derive a one-line session title from the first user message: collapse
768
- * whitespace, trim, and truncate to `limit` chars + an ellipsis (omnigent's
3157
+ * whitespace, trim, and truncate to `limit` chars + an ellipsis (reference implementation's
769
3158
  * rule — no LLM call). */
770
3159
  export function synthesizeSessionTitle(message, limit = 60) {
771
3160
  const collapsed = message.replace(/\s+/g, " ").trim();
@@ -810,6 +3199,498 @@ function redact(deps, type, options) {
810
3199
  function asString(value) {
811
3200
  return typeof value === "string" && value.trim() ? value.trim() : undefined;
812
3201
  }
3202
+ function parseProviderCliId(value) {
3203
+ return value === "codex" || value === "claude" || value === "traex"
3204
+ ? value
3205
+ : undefined;
3206
+ }
3207
+ function parseProviderCliOperation(value) {
3208
+ return value === "install" || value === "update" ? value : undefined;
3209
+ }
3210
+ function providerCliOperationErrorCode(error) {
3211
+ if (!error || typeof error !== "object")
3212
+ return undefined;
3213
+ return "code" in error && typeof error.code === "string" ? error.code : undefined;
3214
+ }
3215
+ function optionalRuntimeString(value) {
3216
+ return typeof value === "string" && value.length > 0 ? value : undefined;
3217
+ }
3218
+ function optionalRuntimeDevice(body) {
3219
+ return body.device === undefined ? {} : { device: body.device };
3220
+ }
3221
+ function remoteRuntimeRpcTimeoutMs(method, params) {
3222
+ if (method === "session.emulator.attach")
3223
+ return REMOTE_RUNTIME_EMULATOR_ATTACH_TIMEOUT_MS;
3224
+ if (method === "session.emulator.type") {
3225
+ const text = params.text;
3226
+ return Math.min(REMOTE_RUNTIME_EMULATOR_ATTACH_TIMEOUT_MS, Math.max(REMOTE_RUNTIME_EMULATOR_CONTROL_TIMEOUT_MS, text.length * 40 + 5_000));
3227
+ }
3228
+ if (method.startsWith("session."))
3229
+ return REMOTE_RUNTIME_SESSION_TIMEOUT_MS;
3230
+ if (method === "browser.open")
3231
+ return REMOTE_RUNTIME_BROWSER_OPEN_TIMEOUT_MS;
3232
+ if (method.startsWith("browser."))
3233
+ return REMOTE_RUNTIME_BROWSER_CONTROL_TIMEOUT_MS;
3234
+ if (method === "emulator.attach")
3235
+ return REMOTE_RUNTIME_EMULATOR_ATTACH_TIMEOUT_MS;
3236
+ if (method === "emulator.type") {
3237
+ const text = params.text;
3238
+ return Math.min(REMOTE_RUNTIME_EMULATOR_ATTACH_TIMEOUT_MS, Math.max(REMOTE_RUNTIME_EMULATOR_CONTROL_TIMEOUT_MS, text.length * 40 + 5_000));
3239
+ }
3240
+ return REMOTE_RUNTIME_EMULATOR_CONTROL_TIMEOUT_MS;
3241
+ }
3242
+ function parseInteractionResolution(value) {
3243
+ if (!value || typeof value !== "object" || Array.isArray(value))
3244
+ return null;
3245
+ const input = value;
3246
+ if (Object.keys(input).some((key) => key !== "actionId" && key !== "answers"))
3247
+ return null;
3248
+ const actionId = asString(input.actionId);
3249
+ if (!actionId || actionId.length > 256)
3250
+ return null;
3251
+ if (input.answers === undefined)
3252
+ return { actionId };
3253
+ if (!input.answers || typeof input.answers !== "object" || Array.isArray(input.answers)) {
3254
+ return null;
3255
+ }
3256
+ const entries = Object.entries(input.answers);
3257
+ if (entries.length > 64)
3258
+ return null;
3259
+ const answers = {};
3260
+ for (const [fieldId, answer] of entries) {
3261
+ if (!fieldId || fieldId.length > 256)
3262
+ return null;
3263
+ if (typeof answer === "string") {
3264
+ if (answer.length > 65_536)
3265
+ return null;
3266
+ answers[fieldId] = answer;
3267
+ continue;
3268
+ }
3269
+ if (!Array.isArray(answer) ||
3270
+ answer.length > 64 ||
3271
+ answer.some((entry) => typeof entry !== "string" || entry.length > 65_536))
3272
+ return null;
3273
+ answers[fieldId] = [...answer];
3274
+ }
3275
+ const resolution = { actionId, answers };
3276
+ if (Buffer.byteLength(JSON.stringify(resolution), "utf8") > SESSION_INTERACTION_RESOLUTION_MAX_BYTES) {
3277
+ return null;
3278
+ }
3279
+ return resolution;
3280
+ }
3281
+ function safeTokenEquals(actual, expected) {
3282
+ const left = Buffer.from(actual, "utf8");
3283
+ const right = Buffer.from(expected, "utf8");
3284
+ return left.byteLength === right.byteLength && timingSafeEqual(left, right);
3285
+ }
3286
+ function optionalBoundedLabel(value) {
3287
+ if (value === undefined)
3288
+ return undefined;
3289
+ if (typeof value !== "string" ||
3290
+ value.length === 0 ||
3291
+ value.length > 128 ||
3292
+ value.trim() !== value ||
3293
+ /[\u0000-\u001f\u007f]/.test(value)) {
3294
+ throw new Error("clientLabel is invalid");
3295
+ }
3296
+ return value;
3297
+ }
3298
+ function optionalAdvertiseAddress(value) {
3299
+ if (value === undefined)
3300
+ return undefined;
3301
+ if (typeof value !== "string" ||
3302
+ value.length === 0 ||
3303
+ value.length > 2_048 ||
3304
+ value.trim() !== value ||
3305
+ /[\u0000-\u0020\u007f]/.test(value)) {
3306
+ throw new Error("address is invalid");
3307
+ }
3308
+ return value;
3309
+ }
3310
+ function isConfiguredDaemonManagement(value) {
3311
+ if (!value || typeof value.managementToken !== "string")
3312
+ return false;
3313
+ const bytes = Buffer.byteLength(value.managementToken, "utf8");
3314
+ return bytes >= 32 && bytes <= 256;
3315
+ }
3316
+ function pluginCliInvocationAbortScope(ctx, shutdownSignal) {
3317
+ return managementInvocationAbortScope(ctx, shutdownSignal, PLUGIN_CLI_TIMEOUT_MS, "plugin CLI invocation");
3318
+ }
3319
+ function pluginInstallInvocationAbortScope(ctx, shutdownSignal) {
3320
+ return managementInvocationAbortScope(ctx, shutdownSignal, PLUGIN_INSTALL_OPERATION_TIMEOUT_MS, "plugin installation operation");
3321
+ }
3322
+ function browserArtifactInvocationAbortScope(ctx, shutdownSignal) {
3323
+ return managementInvocationAbortScope(ctx, shutdownSignal, BROWSER_ARTIFACT_OPERATION_TIMEOUT_MS, "Browser artifact operation");
3324
+ }
3325
+ function managementInvocationAbortScope(ctx, shutdownSignal, timeoutMs, label) {
3326
+ const controller = new AbortController();
3327
+ const abort = (reason) => {
3328
+ if (!controller.signal.aborted)
3329
+ controller.abort(reason);
3330
+ };
3331
+ const onRequestAborted = () => abort(namedError(`${label} request aborted`, "AbortError"));
3332
+ const onResponseClose = () => {
3333
+ if (!ctx.res.writableEnded)
3334
+ abort(namedError(`${label} client disconnected`, "AbortError"));
3335
+ };
3336
+ const onShutdown = () => abort(shutdownSignal?.reason ?? namedError("daemon is shutting down", "AbortError"));
3337
+ const timeout = setTimeout(() => abort(namedError(`${label} timed out`, "TimeoutError")), timeoutMs);
3338
+ timeout.unref?.();
3339
+ ctx.req.once("aborted", onRequestAborted);
3340
+ ctx.res.once("close", onResponseClose);
3341
+ if (shutdownSignal?.aborted)
3342
+ onShutdown();
3343
+ else
3344
+ shutdownSignal?.addEventListener("abort", onShutdown, { once: true });
3345
+ if (ctx.req.aborted)
3346
+ onRequestAborted();
3347
+ return {
3348
+ signal: controller.signal,
3349
+ dispose() {
3350
+ clearTimeout(timeout);
3351
+ ctx.req.removeListener("aborted", onRequestAborted);
3352
+ ctx.res.removeListener("close", onResponseClose);
3353
+ shutdownSignal?.removeEventListener("abort", onShutdown);
3354
+ },
3355
+ };
3356
+ }
3357
+ function namedError(message, name) {
3358
+ const error = new Error(message);
3359
+ error.name = name;
3360
+ return error;
3361
+ }
3362
+ function authorizeLoopbackManagementRequest(ctx, expectedToken) {
3363
+ if (!isLoopbackRequest(ctx)) {
3364
+ ctx.status = 403;
3365
+ ctx.body = { error: "daemon management is loopback-only" };
3366
+ return false;
3367
+ }
3368
+ if (!safeTokenEquals(ctx.get("x-rynx-management-token"), expectedToken)) {
3369
+ ctx.status = 401;
3370
+ ctx.body = { error: "invalid daemon management token" };
3371
+ return false;
3372
+ }
3373
+ return true;
3374
+ }
3375
+ function isLoopbackRequest(ctx) {
3376
+ const address = normalizeSocketAddress(ctx.req.socket.remoteAddress);
3377
+ return address === "127.0.0.1" || address === "::1";
3378
+ }
3379
+ function normalizeSocketAddress(value) {
3380
+ if (!value)
3381
+ return undefined;
3382
+ return value.startsWith("::ffff:") ? value.slice("::ffff:".length) : value;
3383
+ }
3384
+ function isRuntimeTargetSelector(value) {
3385
+ return typeof value === "string" && RUNTIME_TARGET_SELECTOR_PATTERN.test(value);
3386
+ }
3387
+ function isRuntimeDisplayName(value) {
3388
+ return (typeof value === "string" &&
3389
+ value.length > 0 &&
3390
+ value.length <= 128 &&
3391
+ value.trim() === value &&
3392
+ !/[\u0000-\u001f\u007f]/.test(value));
3393
+ }
3394
+ /** Whitelist only fields that are explicitly safe for the browser and local CLI. */
3395
+ function redactRuntimeTargetView(target) {
3396
+ if (!target ||
3397
+ !isRuntimeTargetSelector(target.selector) ||
3398
+ !isRuntimeTargetSelector(target.daemonId) ||
3399
+ !isRuntimeDisplayName(target.displayName) ||
3400
+ (target.binding !== "local" && target.binding !== "direct")) {
3401
+ throw new Error("Runtime target host returned an invalid target");
3402
+ }
3403
+ if (target.binding === "local") {
3404
+ if (target.selector !== "local" ||
3405
+ target.endpoint !== undefined ||
3406
+ target.identityKeyFingerprint !== undefined ||
3407
+ target.createdAt !== undefined) {
3408
+ throw new Error("Runtime target host returned an invalid local target binding");
3409
+ }
3410
+ }
3411
+ else if (target.selector !== target.daemonId ||
3412
+ target.selector === "local" ||
3413
+ target.endpoint === undefined ||
3414
+ target.identityKeyFingerprint === undefined ||
3415
+ target.createdAt === undefined) {
3416
+ throw new Error("Runtime target host returned an invalid direct target binding");
3417
+ }
3418
+ if (target.endpoint !== undefined && !isSafeRuntimeEndpoint(target.endpoint)) {
3419
+ throw new Error("Runtime target host returned an invalid endpoint");
3420
+ }
3421
+ if (target.identityKeyFingerprint !== undefined &&
3422
+ !/^SHA256:[A-Za-z0-9_-]{43}$/.test(target.identityKeyFingerprint)) {
3423
+ throw new Error("Runtime target host returned an invalid identity fingerprint");
3424
+ }
3425
+ if (target.createdAt !== undefined &&
3426
+ (!isBoundedPublicString(target.createdAt, 64) || !Number.isFinite(Date.parse(target.createdAt)))) {
3427
+ throw new Error("Runtime target host returned an invalid creation timestamp");
3428
+ }
3429
+ return {
3430
+ selector: target.selector,
3431
+ daemonId: target.daemonId,
3432
+ displayName: target.displayName,
3433
+ binding: target.binding,
3434
+ ...(target.endpoint === undefined ? {} : { endpoint: target.endpoint }),
3435
+ ...(target.identityKeyFingerprint === undefined
3436
+ ? {}
3437
+ : { identityKeyFingerprint: target.identityKeyFingerprint }),
3438
+ ...(target.createdAt === undefined ? {} : { createdAt: target.createdAt }),
3439
+ };
3440
+ }
3441
+ function redactRuntimeTargetTestResult(result, requestedSelector) {
3442
+ const target = redactRuntimeTargetView(result.target);
3443
+ const status = redactDaemonStatus(result.status);
3444
+ if (target.selector !== requestedSelector || target.daemonId !== status.daemonId) {
3445
+ throw new Error("Runtime target host returned a mismatched test result");
3446
+ }
3447
+ return { target, status };
3448
+ }
3449
+ function redactRuntimeTargetPairResult(result) {
3450
+ const target = redactRuntimeTargetView(result.target);
3451
+ const status = redactDaemonStatus(result.status);
3452
+ if (target.binding !== "direct" || target.daemonId !== status.daemonId) {
3453
+ throw new Error("Runtime target host returned a mismatched pairing result");
3454
+ }
3455
+ return { target, status };
3456
+ }
3457
+ function redactDaemonStatus(status) {
3458
+ return parseDaemonStatus(status);
3459
+ }
3460
+ function isSafeRuntimeEndpoint(value) {
3461
+ if (!isBoundedPublicString(value, 2_048))
3462
+ return false;
3463
+ try {
3464
+ const endpoint = new URL(value);
3465
+ return ((endpoint.protocol === "ws:" || endpoint.protocol === "wss:") &&
3466
+ Boolean(endpoint.hostname) &&
3467
+ !endpoint.username &&
3468
+ !endpoint.password &&
3469
+ !endpoint.search &&
3470
+ !endpoint.hash &&
3471
+ endpoint.pathname === DIRECT_RUNTIME_CONTROL_PATH);
3472
+ }
3473
+ catch {
3474
+ return false;
3475
+ }
3476
+ }
3477
+ function isBoundedPublicString(value, maxChars) {
3478
+ return (typeof value === "string" &&
3479
+ value.length > 0 &&
3480
+ value.length <= maxChars &&
3481
+ !/[\u0000-\u001f\u007f]/.test(value));
3482
+ }
3483
+ function runtimeTargetHostError(ctx, error, forcedOutcome) {
3484
+ const code = runtimeTargetErrorCode(error);
3485
+ const status = runtimeTargetHttpStatus(code);
3486
+ ctx.status = status;
3487
+ const outcome = forcedOutcome ?? runtimeTargetErrorOutcome(error);
3488
+ ctx.body = {
3489
+ error: status === 500 ? "internal_error" : code,
3490
+ ...(outcome === undefined ? {} : { outcome }),
3491
+ };
3492
+ }
3493
+ function runtimeLocalBrowserError(ctx, error) {
3494
+ const code = error && typeof error === "object" && "code" in error
3495
+ ? error.code
3496
+ : undefined;
3497
+ switch (code) {
3498
+ case "invalid_request":
3499
+ ctx.status = 400;
3500
+ ctx.body = { error: "invalid_request" };
3501
+ return;
3502
+ case "not_found":
3503
+ ctx.status = 404;
3504
+ ctx.body = { error: "not_found" };
3505
+ return;
3506
+ case "unavailable":
3507
+ case "shutting_down":
3508
+ ctx.status = 503;
3509
+ ctx.body = { error: "unavailable" };
3510
+ return;
3511
+ case "host_failure":
3512
+ case "outcome_unknown":
3513
+ ctx.status = 502;
3514
+ ctx.body = { error: "operation_failed" };
3515
+ return;
3516
+ default:
3517
+ ctx.status = 500;
3518
+ ctx.body = { error: "internal_error" };
3519
+ }
3520
+ }
3521
+ function runtimeTargetHttpStatus(code) {
3522
+ switch (code) {
3523
+ case "invalid_offer":
3524
+ case "offer_expired":
3525
+ case "offer_rejected":
3526
+ case "invalid_target":
3527
+ case "invalid_request":
3528
+ return 400;
3529
+ case "not_found":
3530
+ return 404;
3531
+ case "conflict":
3532
+ case "failed_precondition":
3533
+ return 409;
3534
+ case "method_not_found":
3535
+ return 501;
3536
+ case "deadline_exceeded":
3537
+ return 504;
3538
+ case "unreachable":
3539
+ case "tls_error":
3540
+ case "identity_mismatch":
3541
+ case "incompatible":
3542
+ case "authentication_failed":
3543
+ case "protocol_error":
3544
+ case "operation_failed":
3545
+ case "outcome_unknown":
3546
+ case "unauthorized":
3547
+ case "closed":
3548
+ case "internal":
3549
+ return 502;
3550
+ default:
3551
+ return 500;
3552
+ }
3553
+ }
3554
+ function runtimeTargetErrorCode(error) {
3555
+ if (!error || typeof error !== "object" || !("code" in error))
3556
+ return undefined;
3557
+ return typeof error.code === "string" ? error.code : undefined;
3558
+ }
3559
+ function runtimeTargetErrorOutcome(error) {
3560
+ if (!error || typeof error !== "object" || !("outcome" in error))
3561
+ return undefined;
3562
+ return error.outcome === "not_started" || error.outcome === "unknown"
3563
+ ? error.outcome
3564
+ : undefined;
3565
+ }
3566
+ function runtimeProtocolError(message) {
3567
+ return Object.assign(new Error(message), { code: "protocol_error" });
3568
+ }
3569
+ function runtimeCapacityError(message) {
3570
+ return Object.assign(new Error(message), { code: "operation_failed" });
3571
+ }
3572
+ function publicRuntimeStreamError(error) {
3573
+ return runtimeTargetErrorCode(error) ?? "internal_error";
3574
+ }
3575
+ class RequestBodyTooLargeError extends Error {
3576
+ }
3577
+ async function readBoundedBinary(ctx, maxBytes) {
3578
+ const declaredLength = Number(ctx.get("content-length"));
3579
+ if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
3580
+ throw new RequestBodyTooLargeError(`request body exceeds ${maxBytes} bytes`);
3581
+ }
3582
+ const chunks = [];
3583
+ let bytes = 0;
3584
+ let tooLarge = false;
3585
+ await new Promise((resolve, reject) => {
3586
+ ctx.req.on("data", (chunk) => {
3587
+ const data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
3588
+ bytes += data.byteLength;
3589
+ if (bytes > maxBytes) {
3590
+ tooLarge = true;
3591
+ chunks.length = 0;
3592
+ return;
3593
+ }
3594
+ if (!tooLarge)
3595
+ chunks.push(data);
3596
+ });
3597
+ ctx.req.once("end", resolve);
3598
+ ctx.req.once("error", reject);
3599
+ });
3600
+ if (tooLarge) {
3601
+ throw new RequestBodyTooLargeError(`request body exceeds ${maxBytes} bytes`);
3602
+ }
3603
+ return Buffer.concat(chunks, bytes);
3604
+ }
3605
+ async function readBoundedJson(ctx, maxBytes) {
3606
+ const declaredLength = Number(ctx.get("content-length"));
3607
+ if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
3608
+ throw new RequestBodyTooLargeError(`request body exceeds ${maxBytes} bytes`);
3609
+ }
3610
+ const chunks = [];
3611
+ let bytes = 0;
3612
+ let tooLarge = false;
3613
+ await new Promise((resolve, reject) => {
3614
+ ctx.req.on("data", (chunk) => {
3615
+ const data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
3616
+ bytes += data.byteLength;
3617
+ if (bytes > maxBytes) {
3618
+ tooLarge = true;
3619
+ chunks.length = 0;
3620
+ return;
3621
+ }
3622
+ if (!tooLarge)
3623
+ chunks.push(data);
3624
+ });
3625
+ ctx.req.once("end", resolve);
3626
+ ctx.req.once("error", reject);
3627
+ });
3628
+ if (tooLarge)
3629
+ throw new RequestBodyTooLargeError(`request body exceeds ${maxBytes} bytes`);
3630
+ if (bytes === 0)
3631
+ throw new Error("request body is required");
3632
+ let parsed;
3633
+ try {
3634
+ parsed = JSON.parse(Buffer.concat(chunks, bytes).toString("utf8"));
3635
+ }
3636
+ catch {
3637
+ throw new Error("request body must be valid JSON");
3638
+ }
3639
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
3640
+ throw new Error("request body must be a JSON object");
3641
+ }
3642
+ return parsed;
3643
+ }
3644
+ function parsePluginCliInvocation(pluginId, body) {
3645
+ const unknownFields = Object.keys(body).filter((key) => key !== "args" && key !== "stdin");
3646
+ if (unknownFields.length > 0)
3647
+ return "request body contains unknown fields";
3648
+ if (!Array.isArray(body.args) || body.args.length === 0)
3649
+ return "args must be a non-empty array";
3650
+ if (body.args.length > PLUGIN_CLI_ARGS_MAX_COUNT) {
3651
+ return `args exceeds ${PLUGIN_CLI_ARGS_MAX_COUNT} entries`;
3652
+ }
3653
+ let totalBytes = 0;
3654
+ const args = [];
3655
+ for (const value of body.args) {
3656
+ if (typeof value !== "string" || value.includes("\0")) {
3657
+ return "every arg must be a string without NUL bytes";
3658
+ }
3659
+ const bytes = Buffer.byteLength(value, "utf8");
3660
+ if (bytes > PLUGIN_CLI_ARG_MAX_BYTES) {
3661
+ return `one arg exceeds ${PLUGIN_CLI_ARG_MAX_BYTES} bytes`;
3662
+ }
3663
+ totalBytes += bytes;
3664
+ if (totalBytes > PLUGIN_CLI_ARGS_TOTAL_MAX_BYTES) {
3665
+ return `args exceed ${PLUGIN_CLI_ARGS_TOTAL_MAX_BYTES} bytes`;
3666
+ }
3667
+ args.push(value);
3668
+ }
3669
+ if (body.stdin !== undefined && typeof body.stdin !== "string")
3670
+ return "stdin must be a string";
3671
+ if (typeof body.stdin === "string" && Buffer.byteLength(body.stdin, "utf8") > PLUGIN_CLI_STDIN_MAX_BYTES) {
3672
+ return `stdin exceeds ${PLUGIN_CLI_STDIN_MAX_BYTES} bytes`;
3673
+ }
3674
+ return {
3675
+ pluginId,
3676
+ args,
3677
+ ...(typeof body.stdin === "string" ? { stdin: body.stdin } : {}),
3678
+ };
3679
+ }
3680
+ function boundedUtf8(value, maxBytes) {
3681
+ if (Buffer.byteLength(value, "utf8") <= maxBytes)
3682
+ return value;
3683
+ const chars = [];
3684
+ let bytes = 0;
3685
+ for (const char of value) {
3686
+ const next = Buffer.byteLength(char, "utf8");
3687
+ if (bytes + next > maxBytes)
3688
+ break;
3689
+ chars.push(char);
3690
+ bytes += next;
3691
+ }
3692
+ return chars.join("");
3693
+ }
813
3694
  function asNumber(value) {
814
3695
  if (typeof value === "number" && Number.isFinite(value))
815
3696
  return value;