@rynx-ai/server 0.1.0 → 0.1.9

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 (63) 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 +14 -0
  4. package/dist/browser-surface-ws.js +355 -0
  5. package/dist/control-api.d.ts +59 -4
  6. package/dist/control-api.js +2213 -132
  7. package/dist/control-web/assets/highlighted-body-OFNGDK62-e-w61YLy.js +1 -0
  8. package/dist/control-web/assets/index-BQ7XVBKO.css +32 -0
  9. package/dist/control-web/assets/index-ClpzuBJx.js +606 -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-DZn7Hbr2.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 +153 -0
  30. package/dist/desktop-browser-host.js +936 -0
  31. package/dist/direct-runtime-browser-surface-server.d.ts +74 -0
  32. package/dist/direct-runtime-browser-surface-server.js +821 -0
  33. package/dist/direct-runtime-server.d.ts +137 -0
  34. package/dist/direct-runtime-server.js +1931 -0
  35. package/dist/emulator-surface-ws.d.ts +6 -0
  36. package/dist/emulator-surface-ws.js +197 -0
  37. package/dist/machine-session-service.d.ts +182 -0
  38. package/dist/machine-session-service.js +559 -0
  39. package/dist/provider-cli-host.d.ts +10 -0
  40. package/dist/provider-cli-host.js +1 -0
  41. package/dist/remote-runtime-dispatcher.d.ts +94 -0
  42. package/dist/remote-runtime-dispatcher.js +380 -0
  43. package/dist/remote-runtime-session-projection.d.ts +19 -0
  44. package/dist/remote-runtime-session-projection.js +556 -0
  45. package/dist/remote-runtime.d.ts +22 -0
  46. package/dist/remote-runtime.js +32 -0
  47. package/dist/runtime-web-auth.d.ts +21 -0
  48. package/dist/runtime-web-auth.js +92 -0
  49. package/dist/server.d.ts +292 -10
  50. package/dist/server.js +314 -98
  51. package/dist/session-browser-service.d.ts +218 -0
  52. package/dist/session-browser-service.js +686 -0
  53. package/dist/session-browser-surface-coordinator.d.ts +23 -0
  54. package/dist/session-browser-surface-coordinator.js +563 -0
  55. package/dist/session-emulator-service.d.ts +167 -0
  56. package/dist/session-emulator-service.js +464 -0
  57. package/dist/session-runtime-index.d.ts +17 -0
  58. package/dist/session-runtime-index.js +86 -0
  59. package/dist/session-terminal-host.d.ts +46 -0
  60. package/dist/session-terminal-host.js +252 -0
  61. package/dist/terminal-ws.d.ts +9 -20
  62. package/dist/terminal-ws.js +420 -97
  63. package/package.json +9 -7
@@ -16,25 +16,1507 @@
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 { DIRECT_RUNTIME_CONTROL_PATH, encodePairingCode, } from "@rynx-ai/protocol/direct-runtime";
23
+ import { parseDaemonStatus } from "@rynx-ai/protocol/remote-runtime";
24
+ import { materializeRemoteRuntimeSessionEvent, materializeRemoteRuntimeSessionRuntimeSnapshot, parseRemoteRuntimeRpcRequest, REMOTE_RUNTIME_RPC_MAX_FRAME_BYTES, REMOTE_RUNTIME_SESSION_MAX_PAGE_SIZE, } from "@rynx-ai/protocol/remote-runtime-rpc";
25
+ 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";
26
+ import { parseRuntimeBrowserStateGetParams } from "@rynx-ai/protocol/runtime-browser";
22
27
  import { ensureCodexResumeRollout } from "@rynx-ai/runtime";
28
+ import { encodeDaemonStatus } from "./remote-runtime.js";
29
+ import { SessionRuntimeIndex } from "./session-runtime-index.js";
30
+ import { authorizeRuntimeWebRequest, RUNTIME_WEB_CAPABILITY, RUNTIME_WEB_CAPABILITY_HEADER, } from "./runtime-web-auth.js";
31
+ const PLUGIN_CLI_BODY_MAX_BYTES = 512 * 1024;
32
+ const PLUGIN_CLI_STDIN_MAX_BYTES = 256 * 1024;
33
+ const PLUGIN_CLI_ARG_MAX_BYTES = 4 * 1024;
34
+ const PLUGIN_CLI_ARGS_TOTAL_MAX_BYTES = 32 * 1024;
35
+ const PLUGIN_CLI_ARGS_MAX_COUNT = 64;
36
+ const PLUGIN_CLI_ERROR_MAX_BYTES = 16 * 1024;
37
+ const PLUGIN_CLI_TIMEOUT_MS = 120_000;
38
+ const REMOTE_RUNTIME_ADMIN_BODY_MAX_BYTES = 4 * 1024;
39
+ const RUNTIME_TARGET_PAIR_BODY_MAX_BYTES = 64 * 1024;
40
+ const REMOTE_RUNTIME_EMULATOR_CONTROL_TIMEOUT_MS = 30_000;
41
+ const REMOTE_RUNTIME_EMULATOR_ATTACH_TIMEOUT_MS = 120_000;
42
+ const REMOTE_RUNTIME_BROWSER_CONTROL_TIMEOUT_MS = 30_000;
43
+ const REMOTE_RUNTIME_BROWSER_OPEN_TIMEOUT_MS = 120_000;
44
+ const REMOTE_RUNTIME_SESSION_TIMEOUT_MS = 30_000;
45
+ const REMOTE_RUNTIME_SESSION_BFF_MAX_PAGES = 128;
46
+ const REMOTE_RUNTIME_SESSION_BFF_MAX_SESSIONS = 20_000;
47
+ const REMOTE_RUNTIME_SESSION_BFF_MAX_ITEMS = 20_000;
48
+ const REMOTE_RUNTIME_SESSION_BFF_MAX_SNAPSHOT_BYTES = 32 * 1024 * 1024;
49
+ const RUNTIME_TARGET_SELECTOR_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._~-]{0,255}$/;
50
+ // A resolved interaction is duplicated in the canonical event and its durable
51
+ // item. Keep the accepted body comfortably below the 512 KiB plugin event cap.
52
+ const SESSION_INTERACTION_RESOLUTION_MAX_BYTES = 128 * 1024;
53
+ const RUNTIME_BROWSER_RPC_METHODS = new Set([
54
+ "browser.state.get",
55
+ "browser.open",
56
+ "browser.close",
57
+ "browser.page.create",
58
+ "browser.page.close",
59
+ "browser.page.activate",
60
+ "browser.page.navigate",
61
+ "browser.page.back",
62
+ "browser.page.forward",
63
+ "browser.page.reload",
64
+ ]);
23
65
  export function createControlRouter(opts) {
24
- const { manager, deps, runtime, sessionBus, sessionLog, runnerManager, sessionStore } = opts;
66
+ const { manager, deps, runtime, sessionBus, sessionLog, runnerManager, sessionStore, pluginRuntimeStatus, pluginRuntimeReload, pluginRuntimeEnsure, daemonManagement, remoteRuntime, remoteRuntimeAdmin, runtimeTargetControl, runtimeConnectionResolver, sessionEmulators, shutdownSignal, sessionRuntimeIndex = new SessionRuntimeIndex(), sessionLifecycle, runtimeLocalBrowser, providerClis, } = opts;
25
67
  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();
30
68
  /** The instance (if any) bound to a channel — used to reload after a config change. */
31
69
  const instanceForChannel = (channelId) => deps.listInstancesConfig().find((i) => i.channelId === channelId);
70
+ const listRuntimeTargets = async (ctx) => {
71
+ if (!runtimeTargetControl)
72
+ return notFound(ctx, "Runtime target control is not configured");
73
+ try {
74
+ const targets = await runtimeTargetControl.list();
75
+ ctx.set("Cache-Control", "no-store");
76
+ ctx.body = { runtimes: targets.map(redactRuntimeTargetView) };
77
+ }
78
+ catch (error) {
79
+ runtimeTargetHostError(ctx, error);
80
+ }
81
+ };
82
+ const pairRuntimeTarget = async (ctx) => {
83
+ if (!runtimeTargetControl)
84
+ return notFound(ctx, "Runtime target control is not configured");
85
+ if (!ctx.is("application/json")) {
86
+ ctx.status = 415;
87
+ ctx.body = { error: "unsupported_media_type" };
88
+ return;
89
+ }
90
+ let body;
91
+ try {
92
+ body = await readBoundedJson(ctx, RUNTIME_TARGET_PAIR_BODY_MAX_BYTES);
93
+ }
94
+ catch (error) {
95
+ if (error instanceof RequestBodyTooLargeError) {
96
+ ctx.status = 413;
97
+ ctx.body = { error: "request_too_large" };
98
+ }
99
+ else {
100
+ ctx.status = 400;
101
+ ctx.body = { error: "invalid_offer" };
102
+ }
103
+ return;
104
+ }
105
+ if (!Object.hasOwn(body, "offer") ||
106
+ Object.keys(body).some((key) => key !== "offer" && key !== "displayName")) {
107
+ ctx.status = 400;
108
+ ctx.body = { error: "invalid_offer" };
109
+ return;
110
+ }
111
+ const displayName = body.displayName;
112
+ if (displayName !== undefined && !isRuntimeDisplayName(displayName)) {
113
+ ctx.status = 400;
114
+ ctx.body = { error: "invalid_target" };
115
+ return;
116
+ }
117
+ try {
118
+ const result = await runtimeTargetControl.pair({
119
+ offer: body.offer,
120
+ ...(displayName === undefined ? {} : { displayName }),
121
+ });
122
+ ctx.set("Cache-Control", "no-store");
123
+ ctx.body = redactRuntimeTargetPairResult(result);
124
+ }
125
+ catch (error) {
126
+ runtimeTargetHostError(ctx, error);
127
+ }
128
+ };
129
+ const testRuntimeTarget = async (ctx) => {
130
+ if (!runtimeTargetControl)
131
+ return notFound(ctx, "Runtime target control is not configured");
132
+ const selector = ctx.params.selector;
133
+ if (!isRuntimeTargetSelector(selector)) {
134
+ ctx.status = 400;
135
+ ctx.body = { error: "invalid_target" };
136
+ return;
137
+ }
138
+ try {
139
+ const result = await runtimeTargetControl.test(selector);
140
+ ctx.set("Cache-Control", "no-store");
141
+ ctx.body = redactRuntimeTargetTestResult(result, selector);
142
+ }
143
+ catch (error) {
144
+ runtimeTargetHostError(ctx, error);
145
+ }
146
+ };
147
+ const forgetRuntimeTarget = async (ctx) => {
148
+ if (!runtimeTargetControl)
149
+ return notFound(ctx, "Runtime target control is not configured");
150
+ const daemonId = ctx.params.daemonId;
151
+ if (!isRuntimeTargetSelector(daemonId)) {
152
+ ctx.status = 400;
153
+ ctx.body = { error: "invalid_target" };
154
+ return;
155
+ }
156
+ try {
157
+ ctx.set("Cache-Control", "no-store");
158
+ const result = await runtimeTargetControl.forget(daemonId);
159
+ if (result.forgotten !== true)
160
+ throw new Error("Runtime target host returned an invalid result");
161
+ ctx.body = { forgotten: true };
162
+ }
163
+ catch (error) {
164
+ runtimeTargetHostError(ctx, error);
165
+ }
166
+ };
167
+ const createRuntimePairingOffer = async (ctx, response) => {
168
+ ctx.set("Cache-Control", "no-store");
169
+ if (!remoteRuntimeAdmin) {
170
+ return notFound(ctx, "Remote Runtime administration is not configured");
171
+ }
172
+ if (!ctx.is("application/json")) {
173
+ ctx.status = 415;
174
+ ctx.body = { error: "pairing offer creation requires application/json" };
175
+ return;
176
+ }
177
+ try {
178
+ const body = await readBoundedJson(ctx, REMOTE_RUNTIME_ADMIN_BODY_MAX_BYTES);
179
+ if (Object.keys(body).some((key) => key !== "clientLabel" && key !== "address")) {
180
+ throw new Error("pairing offer request contains unknown fields");
181
+ }
182
+ const clientLabel = optionalBoundedLabel(body.clientLabel);
183
+ const address = optionalAdvertiseAddress(body.address);
184
+ const offer = remoteRuntimeAdmin.createPairingOffer({
185
+ ...(clientLabel ? { clientLabel } : {}),
186
+ ...(address ? { address } : {}),
187
+ });
188
+ ctx.body = response === "offer"
189
+ ? offer
190
+ : { pairingCode: encodePairingCode(offer) };
191
+ }
192
+ catch (error) {
193
+ if (error instanceof RequestBodyTooLargeError) {
194
+ ctx.status = 413;
195
+ }
196
+ else {
197
+ ctx.status = 409;
198
+ }
199
+ ctx.body = { error: error instanceof Error ? error.message : String(error) };
200
+ }
201
+ };
202
+ const listRuntimeShareAddresses = (ctx) => {
203
+ ctx.set("Cache-Control", "no-store");
204
+ if (!remoteRuntimeAdmin) {
205
+ return notFound(ctx, "Remote Runtime administration is not configured");
206
+ }
207
+ try {
208
+ ctx.body = { addresses: remoteRuntimeAdmin.listShareAddresses() };
209
+ }
210
+ catch (error) {
211
+ ctx.status = 500;
212
+ ctx.body = { error: error instanceof Error ? error.message : String(error) };
213
+ }
214
+ };
215
+ const listRuntimeAccess = (ctx) => {
216
+ ctx.set("Cache-Control", "no-store");
217
+ if (!remoteRuntimeAdmin) {
218
+ return notFound(ctx, "Remote Runtime administration is not configured");
219
+ }
220
+ try {
221
+ ctx.body = { clients: remoteRuntimeAdmin.listClientGrants() };
222
+ }
223
+ catch (error) {
224
+ ctx.status = 500;
225
+ ctx.body = { error: error instanceof Error ? error.message : String(error) };
226
+ }
227
+ };
228
+ const revokeRuntimeAccess = (ctx) => {
229
+ ctx.set("Cache-Control", "no-store");
230
+ if (!remoteRuntimeAdmin) {
231
+ return notFound(ctx, "Remote Runtime administration is not configured");
232
+ }
233
+ if (!/^[A-Za-z0-9][A-Za-z0-9._~-]{0,255}$/.test(ctx.params.grantId)) {
234
+ return bad(ctx, "invalid Remote Runtime grant id");
235
+ }
236
+ try {
237
+ const result = remoteRuntimeAdmin.revokeClientGrant(ctx.params.grantId);
238
+ if (!result.revoked) {
239
+ return notFound(ctx, "Remote Runtime client grant not found or already revoked");
240
+ }
241
+ ctx.body = result;
242
+ }
243
+ catch (error) {
244
+ ctx.status = 409;
245
+ ctx.body = { error: error instanceof Error ? error.message : String(error) };
246
+ }
247
+ };
248
+ const authorizePublicRuntimeTargetRequest = (ctx, options = {}) => {
249
+ ctx.set("Cache-Control", "no-store");
250
+ const failure = authorizeRuntimeWebRequest(ctx.req, {
251
+ mutation: options.mutation,
252
+ capability: ctx.get(RUNTIME_WEB_CAPABILITY_HEADER),
253
+ });
254
+ if (!failure)
255
+ return true;
256
+ ctx.status = failure.status;
257
+ ctx.body = { error: failure.error };
258
+ return false;
259
+ };
260
+ const authorizeInternalRuntimeTargetRequest = (ctx) => {
261
+ if (!runtimeTargetControl || !isConfiguredDaemonManagement(daemonManagement)) {
262
+ notFound(ctx, "Runtime target control is not configured");
263
+ return false;
264
+ }
265
+ return authorizeLoopbackManagementRequest(ctx, daemonManagement.managementToken);
266
+ };
267
+ router.get("/api/runtimes/capability", (ctx) => {
268
+ if (!authorizePublicRuntimeTargetRequest(ctx))
269
+ return;
270
+ if (!runtimeTargetControl)
271
+ return notFound(ctx, "Runtime target control is not configured");
272
+ ctx.set("Cross-Origin-Resource-Policy", "same-origin");
273
+ ctx.body = { capability: RUNTIME_WEB_CAPABILITY };
274
+ });
275
+ router.get("/api/runtimes", async (ctx) => {
276
+ if (authorizePublicRuntimeTargetRequest(ctx))
277
+ await listRuntimeTargets(ctx);
278
+ });
279
+ router.post("/api/runtimes/pair", async (ctx) => {
280
+ if (authorizePublicRuntimeTargetRequest(ctx, { mutation: true }))
281
+ await pairRuntimeTarget(ctx);
282
+ });
283
+ router.post("/api/runtimes/:selector/test", async (ctx) => {
284
+ if (authorizePublicRuntimeTargetRequest(ctx, { mutation: true }))
285
+ await testRuntimeTarget(ctx);
286
+ });
287
+ router.delete("/api/runtimes/:daemonId", async (ctx) => {
288
+ if (authorizePublicRuntimeTargetRequest(ctx, { mutation: true }))
289
+ await forgetRuntimeTarget(ctx);
290
+ });
291
+ router.get("/api/runtime-server/share-addresses", (ctx) => {
292
+ if (authorizePublicRuntimeTargetRequest(ctx))
293
+ listRuntimeShareAddresses(ctx);
294
+ });
295
+ router.post("/api/runtime-server/share", async (ctx) => {
296
+ if (authorizePublicRuntimeTargetRequest(ctx, { mutation: true })) {
297
+ await createRuntimePairingOffer(ctx, "pairing_code");
298
+ }
299
+ });
300
+ router.get("/api/runtime-server/access", (ctx) => {
301
+ if (authorizePublicRuntimeTargetRequest(ctx))
302
+ listRuntimeAccess(ctx);
303
+ });
304
+ router.delete("/api/runtime-server/access/:grantId", (ctx) => {
305
+ if (authorizePublicRuntimeTargetRequest(ctx, { mutation: true })) {
306
+ revokeRuntimeAccess(ctx);
307
+ }
308
+ });
309
+ router.get("/api/internal/runtimes", async (ctx) => {
310
+ if (authorizeInternalRuntimeTargetRequest(ctx))
311
+ await listRuntimeTargets(ctx);
312
+ });
313
+ router.post("/api/internal/runtimes/pair", async (ctx) => {
314
+ if (authorizeInternalRuntimeTargetRequest(ctx))
315
+ await pairRuntimeTarget(ctx);
316
+ });
317
+ router.post("/api/internal/runtimes/:selector/test", async (ctx) => {
318
+ if (authorizeInternalRuntimeTargetRequest(ctx))
319
+ await testRuntimeTarget(ctx);
320
+ });
321
+ router.delete("/api/internal/runtimes/:daemonId", async (ctx) => {
322
+ if (authorizeInternalRuntimeTargetRequest(ctx))
323
+ await forgetRuntimeTarget(ctx);
324
+ });
325
+ const emulatorSessionOf = (ctx) => ctx.get("x-rynx-session")?.trim() || undefined;
326
+ // ── Runtime-scoped emulator unary control ───────────────────────────────
327
+ // Media, screenshots, low-latency touch, and raw exec deliberately remain on
328
+ // the local-only /api/emulator surface until their stream/security contracts
329
+ // exist. Every route below carries an explicit selector and never falls back.
330
+ const authorizeInternalRuntimeCall = (ctx) => {
331
+ if (!runtimeConnectionResolver || !isConfiguredDaemonManagement(daemonManagement)) {
332
+ notFound(ctx, "Runtime connection resolver is not configured");
333
+ return false;
334
+ }
335
+ return authorizeLoopbackManagementRequest(ctx, daemonManagement.managementToken);
336
+ };
337
+ const runtimeCall = async (ctx, method, params) => {
338
+ if (!runtimeConnectionResolver) {
339
+ notFound(ctx, "Runtime connection resolver is not configured");
340
+ return { ok: false };
341
+ }
342
+ const selector = ctx.params.selector;
343
+ if (!isRuntimeTargetSelector(selector)) {
344
+ ctx.status = 400;
345
+ ctx.body = { error: "invalid_target" };
346
+ return { ok: false };
347
+ }
348
+ let validatedParams;
349
+ try {
350
+ validatedParams = parseRemoteRuntimeRpcRequest({
351
+ type: "rpc.request",
352
+ id: "control-plane-bff",
353
+ method,
354
+ params,
355
+ }).params;
356
+ }
357
+ catch {
358
+ ctx.status = 400;
359
+ ctx.body = { error: "invalid_request" };
360
+ return { ok: false };
361
+ }
362
+ let lease;
363
+ try {
364
+ lease = await runtimeConnectionResolver.acquire(selector);
365
+ if (lease.target.selector !== selector ||
366
+ !isRuntimeTargetSelector(lease.target.daemonId)) {
367
+ throw new Error("Runtime resolver returned a mismatched target");
368
+ }
369
+ }
370
+ catch (error) {
371
+ lease?.release();
372
+ runtimeTargetHostError(ctx, error, "not_started");
373
+ return { ok: false };
374
+ }
375
+ if (!lease) {
376
+ runtimeTargetHostError(ctx, new Error("Runtime resolver did not return a lease"), "not_started");
377
+ return { ok: false };
378
+ }
379
+ try {
380
+ const result = await lease.call(method, validatedParams, {
381
+ timeoutMs: remoteRuntimeRpcTimeoutMs(method, validatedParams),
382
+ });
383
+ ctx.set("Cache-Control", "no-store");
384
+ return { ok: true, result };
385
+ }
386
+ catch (error) {
387
+ runtimeTargetHostError(ctx, error);
388
+ return { ok: false };
389
+ }
390
+ finally {
391
+ lease.release();
392
+ }
393
+ };
394
+ /**
395
+ * Management-only typed Runtime gateway for short-lived local clients.
396
+ * Target resolution, protocol validation, timeout policy, lease ownership,
397
+ * and no-fallback behavior remain centralized in runtimeCall.
398
+ */
399
+ const internalRuntimeRpc = async (ctx) => {
400
+ if (!ctx.is("application/json")) {
401
+ ctx.status = 415;
402
+ ctx.body = { error: "unsupported_media_type" };
403
+ return;
404
+ }
405
+ let body;
406
+ try {
407
+ body = await readBoundedJson(ctx, REMOTE_RUNTIME_RPC_MAX_FRAME_BYTES);
408
+ }
409
+ catch (error) {
410
+ ctx.status = error instanceof RequestBodyTooLargeError ? 413 : 400;
411
+ ctx.body = {
412
+ error: error instanceof RequestBodyTooLargeError ? "request_too_large" : "invalid_request",
413
+ };
414
+ return;
415
+ }
416
+ if (!Object.hasOwn(body, "method") ||
417
+ !Object.hasOwn(body, "params") ||
418
+ Object.keys(body).some((key) => key !== "method" && key !== "params")) {
419
+ ctx.status = 400;
420
+ ctx.body = { error: "invalid_request" };
421
+ return;
422
+ }
423
+ let request;
424
+ try {
425
+ request = parseRemoteRuntimeRpcRequest({
426
+ type: "rpc.request",
427
+ id: "control-plane-bff",
428
+ method: body.method,
429
+ params: body.params,
430
+ });
431
+ }
432
+ catch {
433
+ ctx.status = 400;
434
+ ctx.body = { error: "invalid_request" };
435
+ return;
436
+ }
437
+ const outcome = await runtimeCall(ctx, request.method, request.params);
438
+ if (outcome.ok)
439
+ ctx.body = { result: outcome.result };
440
+ };
441
+ const acquireRuntimeLease = async (ctx, signal) => {
442
+ if (!runtimeConnectionResolver) {
443
+ notFound(ctx, "Runtime connection resolver is not configured");
444
+ return undefined;
445
+ }
446
+ const selector = ctx.params.selector;
447
+ if (!isRuntimeTargetSelector(selector)) {
448
+ ctx.status = 400;
449
+ ctx.body = { error: "invalid_target" };
450
+ return undefined;
451
+ }
452
+ try {
453
+ const lease = signal === undefined
454
+ ? await runtimeConnectionResolver.acquire(selector)
455
+ : await runtimeConnectionResolver.acquire(selector, { signal });
456
+ if (lease.target.selector !== selector || !isRuntimeTargetSelector(lease.target.daemonId)) {
457
+ lease.release();
458
+ throw runtimeProtocolError("Runtime resolver returned a mismatched target");
459
+ }
460
+ return lease;
461
+ }
462
+ catch (error) {
463
+ runtimeTargetHostError(ctx, error, "not_started");
464
+ return undefined;
465
+ }
466
+ };
467
+ const runtimeSessions = async (ctx) => {
468
+ const lease = await acquireRuntimeLease(ctx);
469
+ if (!lease)
470
+ return;
471
+ const sessions = [];
472
+ const ids = new Set();
473
+ const cursors = new Set();
474
+ let cursor;
475
+ try {
476
+ for (let pageIndex = 0; pageIndex < REMOTE_RUNTIME_SESSION_BFF_MAX_PAGES; pageIndex += 1) {
477
+ const page = await lease.call("session.list", {
478
+ ...(cursor === undefined ? {} : { cursor }),
479
+ limit: REMOTE_RUNTIME_SESSION_MAX_PAGE_SIZE,
480
+ }, { timeoutMs: REMOTE_RUNTIME_SESSION_TIMEOUT_MS });
481
+ for (const session of page.sessions) {
482
+ if (ids.has(session.id)) {
483
+ throw runtimeProtocolError("Runtime Session list repeated a session");
484
+ }
485
+ ids.add(session.id);
486
+ sessions.push(session);
487
+ if (sessions.length > REMOTE_RUNTIME_SESSION_BFF_MAX_SESSIONS) {
488
+ throw runtimeCapacityError("Runtime Session list exceeds the Control Plane bound");
489
+ }
490
+ }
491
+ if (!page.hasMore) {
492
+ ctx.set("Cache-Control", "no-store");
493
+ ctx.body = { sessions };
494
+ return;
495
+ }
496
+ const nextCursor = page.nextCursor;
497
+ if (!nextCursor || cursors.has(nextCursor)) {
498
+ throw runtimeProtocolError("Runtime Session list cursor did not advance");
499
+ }
500
+ cursors.add(nextCursor);
501
+ cursor = nextCursor;
502
+ }
503
+ throw runtimeCapacityError("Runtime Session list exceeds the page bound");
504
+ }
505
+ catch (error) {
506
+ runtimeTargetHostError(ctx, error);
507
+ }
508
+ finally {
509
+ lease.release();
510
+ }
511
+ };
512
+ const runtimeSessionSnapshot = async (ctx) => {
513
+ const lease = await acquireRuntimeLease(ctx);
514
+ if (!lease)
515
+ return;
516
+ const sessionId = ctx.params.id;
517
+ const items = [];
518
+ const ids = new Set();
519
+ let afterId;
520
+ let bytes = 0;
521
+ let runtime;
522
+ try {
523
+ for (let pageIndex = 0; pageIndex < REMOTE_RUNTIME_SESSION_BFF_MAX_PAGES; pageIndex += 1) {
524
+ const page = await lease.call("session.snapshot.get", {
525
+ sessionId,
526
+ ...(afterId === undefined ? {} : { afterId }),
527
+ limit: REMOTE_RUNTIME_SESSION_MAX_PAGE_SIZE,
528
+ }, { timeoutMs: REMOTE_RUNTIME_SESSION_TIMEOUT_MS });
529
+ if (page.sessionId !== sessionId) {
530
+ throw runtimeProtocolError("Runtime Session snapshot identity changed");
531
+ }
532
+ runtime = page.runtime;
533
+ for (const item of page.items) {
534
+ if (ids.has(item.id)) {
535
+ throw runtimeProtocolError("Runtime Session snapshot repeated an item");
536
+ }
537
+ ids.add(item.id);
538
+ items.push(item);
539
+ bytes += Buffer.byteLength(JSON.stringify(item), "utf8");
540
+ if (items.length > REMOTE_RUNTIME_SESSION_BFF_MAX_ITEMS ||
541
+ bytes > REMOTE_RUNTIME_SESSION_BFF_MAX_SNAPSHOT_BYTES) {
542
+ throw runtimeCapacityError("Runtime Session snapshot exceeds the Control Plane bound");
543
+ }
544
+ }
545
+ if (!page.hasMore) {
546
+ if (!runtime)
547
+ throw runtimeProtocolError("Runtime Session snapshot omitted runtime state");
548
+ ctx.set("Cache-Control", "no-store");
549
+ ctx.body = {
550
+ sessionId,
551
+ items,
552
+ runtime: materializeRemoteRuntimeSessionRuntimeSnapshot(runtime, items),
553
+ };
554
+ return;
555
+ }
556
+ if (!page.nextAfterId || page.nextAfterId === afterId) {
557
+ throw runtimeProtocolError("Runtime Session snapshot cursor did not advance");
558
+ }
559
+ afterId = page.nextAfterId;
560
+ }
561
+ throw runtimeCapacityError("Runtime Session snapshot exceeds the page bound");
562
+ }
563
+ catch (error) {
564
+ runtimeTargetHostError(ctx, error);
565
+ }
566
+ finally {
567
+ lease.release();
568
+ }
569
+ };
570
+ const runtimeSessionInterrupt = async (ctx) => {
571
+ const outcome = await runtimeCall(ctx, "session.interrupt", { sessionId: ctx.params.id });
572
+ if (outcome.ok)
573
+ ctx.body = { ok: true, interrupted: outcome.result.interrupted };
574
+ };
575
+ const runtimeSessionAgentOptions = async (ctx) => {
576
+ const outcome = await runtimeCall(ctx, "session.agentOptions.list", {});
577
+ if (outcome.ok)
578
+ ctx.body = outcome.result;
579
+ };
580
+ const runtimeSessionLaunchOptions = async (ctx) => {
581
+ const outcome = await runtimeCall(ctx, "session.launchOptions.list", {});
582
+ if (outcome.ok)
583
+ ctx.body = outcome.result;
584
+ };
585
+ const runtimeSessionCreate = async (ctx) => {
586
+ const body = await readRuntimeBody(ctx);
587
+ if (!body)
588
+ return;
589
+ const outcome = await runtimeCall(ctx, "session.create", body);
590
+ if (outcome.ok)
591
+ ctx.body = outcome.result;
592
+ };
593
+ const runtimeSessionMessage = async (ctx) => {
594
+ const body = await readRuntimeBody(ctx);
595
+ if (!body)
596
+ return;
597
+ if (!acceptRuntimeBodyKeys(ctx, body, ["message"]))
598
+ return;
599
+ const outcome = await runtimeCall(ctx, "session.message.send", {
600
+ sessionId: ctx.params.id,
601
+ message: body.message,
602
+ });
603
+ if (outcome.ok)
604
+ ctx.body = outcome.result;
605
+ };
606
+ const runtimeSessionTerminalStart = async (ctx) => {
607
+ const outcome = await runtimeCall(ctx, "session.terminal.start", {
608
+ sessionId: ctx.params.id,
609
+ });
610
+ if (outcome.ok)
611
+ ctx.body = outcome.result;
612
+ };
613
+ const runtimeSessionDelete = async (ctx) => {
614
+ const outcome = await runtimeCall(ctx, "session.delete", { sessionId: ctx.params.id });
615
+ if (outcome.ok)
616
+ ctx.body = outcome.result;
617
+ };
618
+ const runtimeSessionResolveInteraction = async (ctx) => {
619
+ const body = await readRuntimeBody(ctx);
620
+ if (!body)
621
+ return;
622
+ if (!acceptRuntimeBodyKeys(ctx, body, ["resolution"]))
623
+ return;
624
+ const outcome = await runtimeCall(ctx, "session.interaction.resolve", {
625
+ sessionId: ctx.params.id,
626
+ interactionId: ctx.params.interactionId,
627
+ resolution: body.resolution,
628
+ });
629
+ if (!outcome.ok)
630
+ return;
631
+ const result = outcome.result;
632
+ if (result.disposition === "invalid") {
633
+ return bad(ctx, "interaction resolution is invalid");
634
+ }
635
+ if (result.disposition === "not_found") {
636
+ return notFound(ctx, "interaction is not pending");
637
+ }
638
+ if (result.disposition === "already_resolved") {
639
+ ctx.status = 409;
640
+ ctx.body = { error: "interaction was already resolved", ...result };
641
+ return;
642
+ }
643
+ ctx.body = result;
644
+ };
645
+ const runtimeBrowserState = async (ctx) => {
646
+ const outcome = await runtimeCall(ctx, "browser.state.get", {
647
+ sessionId: ctx.params.id,
648
+ });
649
+ if (outcome.ok)
650
+ ctx.body = outcome.result;
651
+ };
652
+ const runtimeBrowserOpen = async (ctx) => {
653
+ const body = await readRuntimeBody(ctx);
654
+ if (!body)
655
+ return;
656
+ if (!acceptRuntimeBodyKeys(ctx, body, ["url"]))
657
+ return;
658
+ const outcome = await runtimeCall(ctx, "browser.open", {
659
+ sessionId: ctx.params.id,
660
+ ...(body.url === undefined ? {} : { url: body.url }),
661
+ });
662
+ if (outcome.ok)
663
+ ctx.body = outcome.result;
664
+ };
665
+ const runtimeBrowserClose = async (ctx) => {
666
+ const body = await readRuntimeBody(ctx);
667
+ if (!body)
668
+ return;
669
+ if (!acceptRuntimeBodyKeys(ctx, body, ["browserGeneration"]))
670
+ return;
671
+ const outcome = await runtimeCall(ctx, "browser.close", {
672
+ sessionId: ctx.params.id,
673
+ browserGeneration: body.browserGeneration,
674
+ });
675
+ if (outcome.ok)
676
+ ctx.body = outcome.result;
677
+ };
678
+ const runtimeBrowserPageCreate = async (ctx) => {
679
+ const body = await readRuntimeBody(ctx);
680
+ if (!body)
681
+ return;
682
+ if (!acceptRuntimeBodyKeys(ctx, body, ["browserGeneration", "url"]))
683
+ return;
684
+ const outcome = await runtimeCall(ctx, "browser.page.create", {
685
+ sessionId: ctx.params.id,
686
+ browserGeneration: body.browserGeneration,
687
+ ...(body.url === undefined ? {} : { url: body.url }),
688
+ });
689
+ if (outcome.ok)
690
+ ctx.body = outcome.result;
691
+ };
692
+ const runtimeBrowserPageMutation = async (ctx, method) => {
693
+ const body = await readRuntimeBody(ctx);
694
+ if (!body)
695
+ return;
696
+ if (!acceptRuntimeBodyKeys(ctx, body, ["browserGeneration"]))
697
+ return;
698
+ const params = {
699
+ sessionId: ctx.params.id,
700
+ browserGeneration: body.browserGeneration,
701
+ pageId: ctx.params.pageId,
702
+ };
703
+ const outcome = await runtimeCall(ctx, method, params);
704
+ if (outcome.ok)
705
+ ctx.body = outcome.result;
706
+ };
707
+ const runtimeBrowserPageNavigate = async (ctx) => {
708
+ const body = await readRuntimeBody(ctx);
709
+ if (!body)
710
+ return;
711
+ if (!acceptRuntimeBodyKeys(ctx, body, ["browserGeneration", "url"]))
712
+ return;
713
+ const outcome = await runtimeCall(ctx, "browser.page.navigate", {
714
+ sessionId: ctx.params.id,
715
+ browserGeneration: body.browserGeneration,
716
+ pageId: ctx.params.pageId,
717
+ url: body.url,
718
+ });
719
+ if (outcome.ok)
720
+ ctx.body = outcome.result;
721
+ };
722
+ const streamRuntimeSession = async (ctx, selector, sessionId) => {
723
+ if (!runtimeConnectionResolver) {
724
+ return notFound(ctx, "Runtime connection resolver is not configured");
725
+ }
726
+ if (!isRuntimeTargetSelector(selector)) {
727
+ ctx.status = 400;
728
+ ctx.body = { error: "invalid_target" };
729
+ return;
730
+ }
731
+ await streamEvents(ctx, async (send, signal) => {
732
+ let lease;
733
+ let subscription;
734
+ try {
735
+ lease = await runtimeConnectionResolver.acquire(selector, { signal });
736
+ if (lease.target.selector !== selector || !isRuntimeTargetSelector(lease.target.daemonId)) {
737
+ throw runtimeProtocolError("Runtime resolver returned a mismatched target");
738
+ }
739
+ subscription = await lease.subscribeSessionEvents(sessionId, {
740
+ signal,
741
+ timeoutMs: REMOTE_RUNTIME_SESSION_TIMEOUT_MS,
742
+ });
743
+ if (subscription.sessionId !== sessionId) {
744
+ throw runtimeProtocolError("Runtime Session subscription identity changed");
745
+ }
746
+ if (!send("ready", { sessionId, daemonInstanceId: subscription.daemonInstanceId })) {
747
+ if (!(await waitForSseDrain(ctx, signal)))
748
+ return;
749
+ }
750
+ const iterator = subscription[Symbol.asyncIterator]();
751
+ const disconnected = abortAsNull(signal);
752
+ for (;;) {
753
+ const next = await Promise.race([iterator.next(), disconnected]);
754
+ if (next === null || next.done)
755
+ return;
756
+ const event = materializeRemoteRuntimeSessionEvent(next.value);
757
+ if (!send("event", event) && !(await waitForSseDrain(ctx, signal)))
758
+ return;
759
+ }
760
+ }
761
+ catch (error) {
762
+ if (!signal.aborted) {
763
+ send("error", { message: publicRuntimeStreamError(error) });
764
+ }
765
+ }
766
+ finally {
767
+ await subscription?.close().catch(() => undefined);
768
+ lease?.release();
769
+ }
770
+ }, shutdownSignal);
771
+ };
772
+ const runtimeSessionStream = async (ctx) => streamRuntimeSession(ctx, ctx.params.selector, ctx.params.id);
773
+ const readRuntimeBody = async (ctx) => {
774
+ if (!ctx.is("application/json")) {
775
+ ctx.status = 415;
776
+ ctx.body = { error: "unsupported_media_type" };
777
+ return undefined;
778
+ }
779
+ try {
780
+ return await readBoundedJson(ctx, REMOTE_RUNTIME_RPC_MAX_FRAME_BYTES);
781
+ }
782
+ catch (error) {
783
+ ctx.status = error instanceof RequestBodyTooLargeError ? 413 : 400;
784
+ ctx.body = {
785
+ error: error instanceof RequestBodyTooLargeError ? "request_too_large" : "invalid_request",
786
+ };
787
+ return undefined;
788
+ }
789
+ };
790
+ const acceptRuntimeBodyKeys = (ctx, body, allowed) => {
791
+ const allowedSet = new Set(allowed);
792
+ if (Object.keys(body).every((key) => allowedSet.has(key)))
793
+ return true;
794
+ ctx.status = 400;
795
+ ctx.body = { error: "invalid_request" };
796
+ return false;
797
+ };
798
+ const runtimeScopedDevice = (_ctx, body) => optionalRuntimeDevice(body);
799
+ const runtimeDevices = async (ctx) => {
800
+ const outcome = await runtimeCall(ctx, "emulator.devices.list", {});
801
+ if (outcome.ok)
802
+ ctx.body = { devices: outcome.result };
803
+ };
804
+ const runtimeActiveEmulator = async (ctx) => {
805
+ const outcome = await runtimeCall(ctx, "emulator.active.get", runtimeScopedDevice(ctx, {}));
806
+ if (outcome.ok)
807
+ ctx.body = { active: outcome.result };
808
+ };
809
+ const runtimeAttachEmulator = async (ctx) => {
810
+ const body = await readRuntimeBody(ctx);
811
+ if (!body)
812
+ return;
813
+ if (!acceptRuntimeBodyKeys(ctx, body, ["device"]))
814
+ return;
815
+ const outcome = await runtimeCall(ctx, "emulator.attach", optionalRuntimeDevice(body));
816
+ if (outcome.ok)
817
+ ctx.body = outcome.result;
818
+ };
819
+ const runtimeTapEmulator = async (ctx) => {
820
+ const body = await readRuntimeBody(ctx);
821
+ if (!body)
822
+ return;
823
+ if (!acceptRuntimeBodyKeys(ctx, body, ["x", "y", "device"]))
824
+ return;
825
+ const x = asNumber(body.x);
826
+ const y = asNumber(body.y);
827
+ if (x == null || y == null)
828
+ return bad(ctx, "tap requires numeric x and y");
829
+ const outcome = await runtimeCall(ctx, "emulator.tap", {
830
+ x,
831
+ y,
832
+ ...runtimeScopedDevice(ctx, body),
833
+ });
834
+ if (outcome.ok)
835
+ ctx.body = outcome.result;
836
+ };
837
+ const runtimeTypeEmulator = async (ctx) => {
838
+ const body = await readRuntimeBody(ctx);
839
+ if (!body)
840
+ return;
841
+ if (!acceptRuntimeBodyKeys(ctx, body, ["text", "mode", "device"]))
842
+ return;
843
+ if (typeof body.text !== "string" || body.text.length === 0) {
844
+ return bad(ctx, "type requires text");
845
+ }
846
+ const mode = body.mode;
847
+ if (mode !== undefined && mode !== "paste" && mode !== "keys") {
848
+ return bad(ctx, "type mode must be paste or keys");
849
+ }
850
+ const outcome = await runtimeCall(ctx, "emulator.type", {
851
+ text: body.text,
852
+ ...(mode === undefined ? {} : { mode }),
853
+ ...runtimeScopedDevice(ctx, body),
854
+ });
855
+ if (outcome.ok)
856
+ ctx.body = outcome.result;
857
+ };
858
+ const runtimeButtonEmulator = async (ctx) => {
859
+ const body = await readRuntimeBody(ctx);
860
+ if (!body)
861
+ return;
862
+ if (!acceptRuntimeBodyKeys(ctx, body, ["name", "device"]))
863
+ return;
864
+ const name = body.name;
865
+ const outcome = await runtimeCall(ctx, "emulator.button", {
866
+ ...(name === undefined ? {} : { name: name }),
867
+ ...runtimeScopedDevice(ctx, body),
868
+ });
869
+ if (outcome.ok)
870
+ ctx.body = outcome.result;
871
+ };
872
+ const runtimeRotateEmulator = async (ctx) => {
873
+ const body = await readRuntimeBody(ctx);
874
+ if (!body)
875
+ return;
876
+ if (!acceptRuntimeBodyKeys(ctx, body, ["orientation", "device"]))
877
+ return;
878
+ const orientation = optionalRuntimeString(body.orientation);
879
+ if (!orientation)
880
+ return bad(ctx, "rotate requires orientation");
881
+ const outcome = await runtimeCall(ctx, "emulator.rotate", {
882
+ orientation,
883
+ ...runtimeScopedDevice(ctx, body),
884
+ });
885
+ if (outcome.ok)
886
+ ctx.body = outcome.result;
887
+ };
888
+ const runtimeLaunchEmulator = async (ctx) => {
889
+ const body = await readRuntimeBody(ctx);
890
+ if (!body)
891
+ return;
892
+ if (!acceptRuntimeBodyKeys(ctx, body, ["appId", "device"]))
893
+ return;
894
+ const appId = optionalRuntimeString(body.appId);
895
+ if (!appId)
896
+ return bad(ctx, "launch requires appId");
897
+ const outcome = await runtimeCall(ctx, "emulator.launch", {
898
+ appId,
899
+ ...runtimeScopedDevice(ctx, body),
900
+ });
901
+ if (outcome.ok)
902
+ ctx.body = outcome.result;
903
+ };
904
+ const runtimeGestureEmulator = async (ctx) => {
905
+ const body = await readRuntimeBody(ctx);
906
+ if (!body)
907
+ return;
908
+ if (!acceptRuntimeBodyKeys(ctx, body, ["points", "device"]))
909
+ return;
910
+ if (!Array.isArray(body.points))
911
+ return bad(ctx, "gesture requires points");
912
+ const outcome = await runtimeCall(ctx, "emulator.gesture", {
913
+ points: body.points,
914
+ ...runtimeScopedDevice(ctx, body),
915
+ });
916
+ if (outcome.ok)
917
+ ctx.body = outcome.result;
918
+ };
919
+ const runtimeDeviceCommand = async (ctx, method) => {
920
+ const body = await readRuntimeBody(ctx);
921
+ if (!body)
922
+ return;
923
+ if (!acceptRuntimeBodyKeys(ctx, body, ["device"]))
924
+ return;
925
+ const params = runtimeScopedDevice(ctx, body);
926
+ const outcome = method === "emulator.kill"
927
+ ? await runtimeCall(ctx, "emulator.kill", params)
928
+ : await runtimeCall(ctx, "emulator.shutdown", params);
929
+ if (outcome.ok)
930
+ ctx.body = outcome.result;
931
+ };
932
+ const runtimeSessionEmulatorState = async (ctx) => {
933
+ const outcome = await runtimeCall(ctx, "session.emulator.state.get", {
934
+ sessionId: ctx.params.id,
935
+ });
936
+ if (outcome.ok)
937
+ ctx.body = { binding: outcome.result };
938
+ };
939
+ const runtimeSessionEmulatorDevices = async (ctx) => {
940
+ const outcome = await runtimeCall(ctx, "session.emulator.devices.list", {
941
+ sessionId: ctx.params.id,
942
+ });
943
+ if (outcome.ok)
944
+ ctx.body = { devices: outcome.result };
945
+ };
946
+ const runtimeSessionEmulatorAttach = async (ctx) => {
947
+ const body = await readRuntimeBody(ctx);
948
+ if (!body)
949
+ return;
950
+ if (!acceptRuntimeBodyKeys(ctx, body, ["device"]))
951
+ return;
952
+ const device = optionalRuntimeString(body.device);
953
+ if (!device)
954
+ return bad(ctx, "attach requires device");
955
+ const outcome = await runtimeCall(ctx, "session.emulator.attach", {
956
+ sessionId: ctx.params.id,
957
+ device,
958
+ });
959
+ if (outcome.ok)
960
+ ctx.body = outcome.result;
961
+ };
962
+ const runtimeSessionEmulatorDetach = async (ctx) => {
963
+ const body = await readRuntimeBody(ctx);
964
+ if (!body)
965
+ return;
966
+ if (!acceptRuntimeBodyKeys(ctx, body, ["bindingId"]))
967
+ return;
968
+ const bindingId = optionalRuntimeString(body.bindingId);
969
+ if (!bindingId)
970
+ return bad(ctx, "detach requires bindingId");
971
+ const outcome = await runtimeCall(ctx, "session.emulator.detach", {
972
+ sessionId: ctx.params.id,
973
+ bindingId,
974
+ });
975
+ if (outcome.ok)
976
+ ctx.body = outcome.result;
977
+ };
978
+ const runtimeSessionEmulatorTap = async (ctx) => {
979
+ const body = await readRuntimeBody(ctx);
980
+ if (!body)
981
+ return;
982
+ if (!acceptRuntimeBodyKeys(ctx, body, ["bindingId", "x", "y"]))
983
+ return;
984
+ const bindingId = optionalRuntimeString(body.bindingId);
985
+ const x = asNumber(body.x);
986
+ const y = asNumber(body.y);
987
+ if (!bindingId || x == null || y == null)
988
+ return bad(ctx, "tap requires bindingId, x, and y");
989
+ const outcome = await runtimeCall(ctx, "session.emulator.tap", {
990
+ sessionId: ctx.params.id,
991
+ bindingId,
992
+ x,
993
+ y,
994
+ });
995
+ if (outcome.ok)
996
+ ctx.body = outcome.result;
997
+ };
998
+ const runtimeSessionEmulatorButton = async (ctx) => {
999
+ const body = await readRuntimeBody(ctx);
1000
+ if (!body)
1001
+ return;
1002
+ if (!acceptRuntimeBodyKeys(ctx, body, ["bindingId", "name"]))
1003
+ return;
1004
+ const bindingId = optionalRuntimeString(body.bindingId);
1005
+ if (!bindingId)
1006
+ return bad(ctx, "button requires bindingId");
1007
+ const name = optionalRuntimeString(body.name);
1008
+ const outcome = await runtimeCall(ctx, "session.emulator.button", {
1009
+ sessionId: ctx.params.id,
1010
+ bindingId,
1011
+ ...(name ? { name } : {}),
1012
+ });
1013
+ if (outcome.ok)
1014
+ ctx.body = outcome.result;
1015
+ };
1016
+ const runtimeSessionEmulatorType = async (ctx) => {
1017
+ const body = await readRuntimeBody(ctx);
1018
+ if (!body)
1019
+ return;
1020
+ if (!acceptRuntimeBodyKeys(ctx, body, ["bindingId", "text", "mode"]))
1021
+ return;
1022
+ const bindingId = optionalRuntimeString(body.bindingId);
1023
+ const text = typeof body.text === "string" ? body.text : undefined;
1024
+ const mode = body.mode;
1025
+ if (!bindingId || !text)
1026
+ return bad(ctx, "type requires bindingId and text");
1027
+ if (mode !== undefined && mode !== "paste" && mode !== "keys") {
1028
+ return bad(ctx, "type mode must be paste or keys");
1029
+ }
1030
+ const outcome = await runtimeCall(ctx, "session.emulator.type", {
1031
+ sessionId: ctx.params.id,
1032
+ bindingId,
1033
+ text,
1034
+ ...(mode === undefined ? {} : { mode }),
1035
+ });
1036
+ if (outcome.ok)
1037
+ ctx.body = outcome.result;
1038
+ };
1039
+ const runtimeSessionEmulatorRotate = async (ctx) => {
1040
+ const body = await readRuntimeBody(ctx);
1041
+ if (!body)
1042
+ return;
1043
+ if (!acceptRuntimeBodyKeys(ctx, body, ["bindingId", "orientation"]))
1044
+ return;
1045
+ const bindingId = optionalRuntimeString(body.bindingId);
1046
+ const orientation = optionalRuntimeString(body.orientation);
1047
+ if (!bindingId || !orientation)
1048
+ return bad(ctx, "rotate requires bindingId and orientation");
1049
+ const outcome = await runtimeCall(ctx, "session.emulator.rotate", {
1050
+ sessionId: ctx.params.id,
1051
+ bindingId,
1052
+ orientation,
1053
+ });
1054
+ if (outcome.ok)
1055
+ ctx.body = outcome.result;
1056
+ };
1057
+ const runtimeSessionEmulatorLaunch = async (ctx) => {
1058
+ const body = await readRuntimeBody(ctx);
1059
+ if (!body)
1060
+ return;
1061
+ if (!acceptRuntimeBodyKeys(ctx, body, ["bindingId", "appId"]))
1062
+ return;
1063
+ const bindingId = optionalRuntimeString(body.bindingId);
1064
+ const appId = optionalRuntimeString(body.appId);
1065
+ if (!bindingId || !appId)
1066
+ return bad(ctx, "launch requires bindingId and appId");
1067
+ const outcome = await runtimeCall(ctx, "session.emulator.launch", {
1068
+ sessionId: ctx.params.id,
1069
+ bindingId,
1070
+ appId,
1071
+ });
1072
+ if (outcome.ok)
1073
+ ctx.body = outcome.result;
1074
+ };
1075
+ const runtimeSessionEmulatorGesture = async (ctx) => {
1076
+ const body = await readRuntimeBody(ctx);
1077
+ if (!body)
1078
+ return;
1079
+ if (!acceptRuntimeBodyKeys(ctx, body, ["bindingId", "points"]))
1080
+ return;
1081
+ const bindingId = optionalRuntimeString(body.bindingId);
1082
+ if (!bindingId || !Array.isArray(body.points)) {
1083
+ return bad(ctx, "gesture requires bindingId and points");
1084
+ }
1085
+ const outcome = await runtimeCall(ctx, "session.emulator.gesture", {
1086
+ sessionId: ctx.params.id,
1087
+ bindingId,
1088
+ points: body.points,
1089
+ });
1090
+ if (outcome.ok)
1091
+ ctx.body = outcome.result;
1092
+ };
1093
+ const runtimeSessionEmulatorFrame = async (ctx) => {
1094
+ if (ctx.params.selector !== "local") {
1095
+ ctx.status = 426;
1096
+ ctx.body = { error: "emulator_surface_upgrade_required" };
1097
+ return;
1098
+ }
1099
+ if (!sessionEmulators)
1100
+ return notFound(ctx, "Session Emulator service is unavailable");
1101
+ const bindingId = optionalRuntimeString(ctx.query.bindingId);
1102
+ if (!bindingId)
1103
+ return bad(ctx, "frame requires bindingId");
1104
+ try {
1105
+ const frame = await sessionEmulators.frame({
1106
+ sessionId: ctx.params.id,
1107
+ bindingId,
1108
+ });
1109
+ ctx.type = frame.contentType;
1110
+ ctx.set("Cache-Control", "no-store");
1111
+ ctx.set("X-Content-Type-Options", "nosniff");
1112
+ ctx.body = frame.data;
1113
+ }
1114
+ catch (error) {
1115
+ bad(ctx, error instanceof Error ? error.message : String(error));
1116
+ }
1117
+ };
1118
+ const runtimeProviderCliStatuses = async (ctx) => {
1119
+ const outcome = await runtimeCall(ctx, "provider.cli.status.list", {});
1120
+ if (!outcome.ok)
1121
+ return;
1122
+ ctx.body = {
1123
+ ...outcome.result,
1124
+ management: ctx.params.selector === "local" ? "read_write" : "read_only",
1125
+ };
1126
+ };
1127
+ const runtimeProviderCliOperation = async (ctx) => {
1128
+ if (ctx.params.selector !== "local") {
1129
+ ctx.status = 403;
1130
+ ctx.body = { error: "remote_provider_cli_management_disabled" };
1131
+ return;
1132
+ }
1133
+ if (!providerClis)
1134
+ return notFound(ctx, "Provider CLI management is unavailable");
1135
+ const provider = parseProviderCliId(ctx.params.provider);
1136
+ const operation = parseProviderCliOperation(ctx.params.operation);
1137
+ if (!provider || !operation)
1138
+ return bad(ctx, "invalid Provider CLI operation");
1139
+ try {
1140
+ const job = await providerClis.startOperation(provider, operation);
1141
+ ctx.status = 202;
1142
+ ctx.body = { job };
1143
+ }
1144
+ catch (error) {
1145
+ ctx.status = providerCliOperationErrorCode(error) === "failed_precondition" ? 409 : 500;
1146
+ ctx.body = { error: error instanceof Error ? error.message : String(error) };
1147
+ }
1148
+ };
1149
+ const runtimeProviderCliJob = async (ctx) => {
1150
+ if (ctx.params.selector !== "local") {
1151
+ ctx.status = 403;
1152
+ ctx.body = { error: "remote_provider_cli_management_disabled" };
1153
+ return;
1154
+ }
1155
+ if (!providerClis)
1156
+ return notFound(ctx, "Provider CLI management is unavailable");
1157
+ const job = providerClis.getJob(ctx.params.jobId);
1158
+ if (!job)
1159
+ return notFound(ctx, "Provider CLI job not found");
1160
+ ctx.set("Cache-Control", "no-store");
1161
+ ctx.body = { job };
1162
+ };
1163
+ const publicRuntimeRead = (handler) => async (ctx) => {
1164
+ if (authorizePublicRuntimeTargetRequest(ctx))
1165
+ await handler(ctx);
1166
+ };
1167
+ const publicRuntimeMutation = (handler) => async (ctx) => {
1168
+ if (authorizePublicRuntimeTargetRequest(ctx, { mutation: true }))
1169
+ await handler(ctx);
1170
+ };
1171
+ const internalRuntimeCall = (handler) => async (ctx) => {
1172
+ if (authorizeInternalRuntimeCall(ctx))
1173
+ await handler(ctx);
1174
+ };
1175
+ router.get("/api/runtimes/:selector/emulator/devices", publicRuntimeRead(runtimeDevices));
1176
+ router.get("/api/runtimes/:selector/provider-clis", publicRuntimeRead(runtimeProviderCliStatuses));
1177
+ router.post("/api/runtimes/:selector/provider-clis/:provider/:operation", publicRuntimeMutation(runtimeProviderCliOperation));
1178
+ router.get("/api/runtimes/:selector/provider-clis/jobs/:jobId", publicRuntimeRead(runtimeProviderCliJob));
1179
+ router.get("/api/runtimes/:selector/emulator/active", publicRuntimeRead(runtimeActiveEmulator));
1180
+ router.post("/api/runtimes/:selector/emulator/attach", publicRuntimeMutation(runtimeAttachEmulator));
1181
+ router.post("/api/runtimes/:selector/emulator/tap", publicRuntimeMutation(runtimeTapEmulator));
1182
+ router.post("/api/runtimes/:selector/emulator/type", publicRuntimeMutation(runtimeTypeEmulator));
1183
+ router.post("/api/runtimes/:selector/emulator/button", publicRuntimeMutation(runtimeButtonEmulator));
1184
+ router.post("/api/runtimes/:selector/emulator/rotate", publicRuntimeMutation(runtimeRotateEmulator));
1185
+ router.post("/api/runtimes/:selector/emulator/launch", publicRuntimeMutation(runtimeLaunchEmulator));
1186
+ router.post("/api/runtimes/:selector/emulator/gesture", publicRuntimeMutation(runtimeGestureEmulator));
1187
+ router.post("/api/runtimes/:selector/emulator/kill", publicRuntimeMutation((ctx) => runtimeDeviceCommand(ctx, "emulator.kill")));
1188
+ router.post("/api/runtimes/:selector/emulator/shutdown", publicRuntimeMutation((ctx) => runtimeDeviceCommand(ctx, "emulator.shutdown")));
1189
+ router.get("/api/runtimes/:selector/sessions/:id/emulator", publicRuntimeRead(runtimeSessionEmulatorState));
1190
+ router.get("/api/runtimes/:selector/sessions/:id/emulator/devices", publicRuntimeRead(runtimeSessionEmulatorDevices));
1191
+ router.post("/api/runtimes/:selector/sessions/:id/emulator/attach", publicRuntimeMutation(runtimeSessionEmulatorAttach));
1192
+ router.post("/api/runtimes/:selector/sessions/:id/emulator/detach", publicRuntimeMutation(runtimeSessionEmulatorDetach));
1193
+ router.post("/api/runtimes/:selector/sessions/:id/emulator/tap", publicRuntimeMutation(runtimeSessionEmulatorTap));
1194
+ router.post("/api/runtimes/:selector/sessions/:id/emulator/button", publicRuntimeMutation(runtimeSessionEmulatorButton));
1195
+ router.post("/api/runtimes/:selector/sessions/:id/emulator/type", publicRuntimeMutation(runtimeSessionEmulatorType));
1196
+ router.post("/api/runtimes/:selector/sessions/:id/emulator/rotate", publicRuntimeMutation(runtimeSessionEmulatorRotate));
1197
+ router.post("/api/runtimes/:selector/sessions/:id/emulator/launch", publicRuntimeMutation(runtimeSessionEmulatorLaunch));
1198
+ router.post("/api/runtimes/:selector/sessions/:id/emulator/gesture", publicRuntimeMutation(runtimeSessionEmulatorGesture));
1199
+ router.get("/api/runtimes/:selector/sessions/:id/emulator/frame", publicRuntimeRead(runtimeSessionEmulatorFrame));
1200
+ // Session workbench routes always target the explicitly selected Runtime.
1201
+ // The browser talks only to this loopback BFF; Local and Direct both enter the
1202
+ // target daemon through the same typed application service.
1203
+ router.get("/api/runtimes/:selector/sessions", publicRuntimeRead(runtimeSessions));
1204
+ router.get("/api/runtimes/:selector/sessions/agent-options", publicRuntimeRead(runtimeSessionAgentOptions));
1205
+ router.get("/api/runtimes/:selector/sessions/launch-options", publicRuntimeRead(runtimeSessionLaunchOptions));
1206
+ router.post("/api/runtimes/:selector/sessions", publicRuntimeMutation(runtimeSessionCreate));
1207
+ router.get("/api/runtimes/:selector/sessions/:id/snapshot", publicRuntimeRead(runtimeSessionSnapshot));
1208
+ router.get("/api/runtimes/:selector/sessions/:id/stream", publicRuntimeRead(runtimeSessionStream));
1209
+ router.post("/api/runtimes/:selector/sessions/:id/interrupt", publicRuntimeMutation(runtimeSessionInterrupt));
1210
+ router.post("/api/runtimes/:selector/sessions/:id/messages", publicRuntimeMutation(runtimeSessionMessage));
1211
+ router.post("/api/runtimes/:selector/sessions/:id/terminal/start", publicRuntimeMutation(runtimeSessionTerminalStart));
1212
+ router.post("/api/runtimes/:selector/sessions/:id/interactions/:interactionId/resolve", publicRuntimeMutation(runtimeSessionResolveInteraction));
1213
+ router.delete("/api/runtimes/:selector/sessions/:id", publicRuntimeMutation(runtimeSessionDelete));
1214
+ router.get("/api/runtimes/:selector/sessions/:id/browser", publicRuntimeRead(runtimeBrowserState));
1215
+ router.post("/api/runtimes/:selector/sessions/:id/browser/open", publicRuntimeMutation(runtimeBrowserOpen));
1216
+ router.post("/api/runtimes/:selector/sessions/:id/browser/close", publicRuntimeMutation(runtimeBrowserClose));
1217
+ router.post("/api/runtimes/:selector/sessions/:id/browser/pages", publicRuntimeMutation(runtimeBrowserPageCreate));
1218
+ for (const action of ["close", "activate", "back", "forward", "reload"]) {
1219
+ router.post(`/api/runtimes/:selector/sessions/:id/browser/pages/:pageId/${action}`, publicRuntimeMutation((ctx) => runtimeBrowserPageMutation(ctx, `browser.page.${action}`)));
1220
+ }
1221
+ router.post("/api/runtimes/:selector/sessions/:id/browser/pages/:pageId/navigate", publicRuntimeMutation(runtimeBrowserPageNavigate));
1222
+ router.post("/api/internal/runtimes/:selector/rpc", internalRuntimeCall(internalRuntimeRpc));
1223
+ router.get("/api/internal/runtimes/:selector/emulator/devices", internalRuntimeCall(runtimeDevices));
1224
+ router.get("/api/internal/runtimes/:selector/emulator/active", internalRuntimeCall(runtimeActiveEmulator));
1225
+ router.post("/api/internal/runtimes/:selector/emulator/attach", internalRuntimeCall(runtimeAttachEmulator));
1226
+ router.post("/api/internal/runtimes/:selector/emulator/tap", internalRuntimeCall(runtimeTapEmulator));
1227
+ router.post("/api/internal/runtimes/:selector/emulator/type", internalRuntimeCall(runtimeTypeEmulator));
1228
+ router.post("/api/internal/runtimes/:selector/emulator/button", internalRuntimeCall(runtimeButtonEmulator));
1229
+ router.post("/api/internal/runtimes/:selector/emulator/rotate", internalRuntimeCall(runtimeRotateEmulator));
1230
+ router.post("/api/internal/runtimes/:selector/emulator/launch", internalRuntimeCall(runtimeLaunchEmulator));
1231
+ router.post("/api/internal/runtimes/:selector/emulator/gesture", internalRuntimeCall(runtimeGestureEmulator));
1232
+ router.post("/api/internal/runtimes/:selector/emulator/kill", internalRuntimeCall((ctx) => runtimeDeviceCommand(ctx, "emulator.kill")));
1233
+ router.post("/api/internal/runtimes/:selector/emulator/shutdown", internalRuntimeCall((ctx) => runtimeDeviceCommand(ctx, "emulator.shutdown")));
1234
+ router.get(RUNTIME_BROWSER_BOOTSTRAP_PATH, async (ctx) => {
1235
+ if (!runtimeLocalBrowser) {
1236
+ return notFound(ctx, "Runtime-local Browser automation is not configured");
1237
+ }
1238
+ if (!isLoopbackRequest(ctx)) {
1239
+ ctx.status = 403;
1240
+ ctx.body = { error: "Runtime-local Browser bootstrap is loopback-only" };
1241
+ return;
1242
+ }
1243
+ let credential;
1244
+ try {
1245
+ credential = parseRuntimeBrowserBootstrapCredential({
1246
+ sessionId: ctx.get(RUNTIME_BROWSER_SESSION_ID_HEADER),
1247
+ capability: ctx.get(RUNTIME_BROWSER_CAPABILITY_HEADER),
1248
+ });
1249
+ }
1250
+ catch {
1251
+ ctx.status = 401;
1252
+ ctx.body = { error: "invalid Runtime Browser Session capability" };
1253
+ return;
1254
+ }
1255
+ try {
1256
+ const descriptor = await runtimeLocalBrowser.endpointForCredential(credential);
1257
+ if (!descriptor) {
1258
+ ctx.status = 401;
1259
+ ctx.body = { error: "invalid Runtime Browser Session capability" };
1260
+ return;
1261
+ }
1262
+ ctx.set("Cache-Control", "no-store");
1263
+ ctx.body = descriptor;
1264
+ }
1265
+ catch (error) {
1266
+ runtimeLocalBrowserError(ctx, error);
1267
+ }
1268
+ });
1269
+ router.post(RUNTIME_BROWSER_RPC_PATH, async (ctx) => {
1270
+ if (!runtimeLocalBrowser || !runtimeConnectionResolver) {
1271
+ return notFound(ctx, "Runtime-local Browser automation is not configured");
1272
+ }
1273
+ if (!isLoopbackRequest(ctx)) {
1274
+ ctx.status = 403;
1275
+ ctx.body = { error: "Runtime-local Browser control is loopback-only" };
1276
+ return;
1277
+ }
1278
+ let credential;
1279
+ try {
1280
+ credential = parseRuntimeBrowserBootstrapCredential({
1281
+ sessionId: ctx.get(RUNTIME_BROWSER_SESSION_ID_HEADER),
1282
+ capability: ctx.get(RUNTIME_BROWSER_CAPABILITY_HEADER),
1283
+ });
1284
+ }
1285
+ catch {
1286
+ ctx.status = 401;
1287
+ ctx.body = { error: "invalid Runtime Browser Session capability" };
1288
+ return;
1289
+ }
1290
+ let authorized = false;
1291
+ try {
1292
+ authorized = await runtimeLocalBrowser.authorizeCredential(credential);
1293
+ }
1294
+ catch {
1295
+ authorized = false;
1296
+ }
1297
+ if (!authorized) {
1298
+ ctx.status = 401;
1299
+ ctx.body = { error: "invalid Runtime Browser Session capability" };
1300
+ return;
1301
+ }
1302
+ if (!ctx.is("application/json")) {
1303
+ ctx.status = 415;
1304
+ ctx.body = { error: "unsupported_media_type" };
1305
+ return;
1306
+ }
1307
+ let body;
1308
+ try {
1309
+ body = await readBoundedJson(ctx, REMOTE_RUNTIME_RPC_MAX_FRAME_BYTES);
1310
+ }
1311
+ catch (error) {
1312
+ ctx.status = error instanceof RequestBodyTooLargeError ? 413 : 400;
1313
+ ctx.body = {
1314
+ error: error instanceof RequestBodyTooLargeError ? "request_too_large" : "invalid_request",
1315
+ };
1316
+ return;
1317
+ }
1318
+ if (!Object.hasOwn(body, "method") ||
1319
+ !Object.hasOwn(body, "params") ||
1320
+ Object.keys(body).some((key) => key !== "method" && key !== "params")) {
1321
+ ctx.status = 400;
1322
+ ctx.body = { error: "invalid_request" };
1323
+ return;
1324
+ }
1325
+ let request;
1326
+ try {
1327
+ request = parseRemoteRuntimeRpcRequest({
1328
+ type: "rpc.request",
1329
+ id: "runtime-local-browser",
1330
+ method: body.method,
1331
+ params: body.params,
1332
+ });
1333
+ }
1334
+ catch {
1335
+ ctx.status = 400;
1336
+ ctx.body = { error: "invalid_request" };
1337
+ return;
1338
+ }
1339
+ if (!RUNTIME_BROWSER_RPC_METHODS.has(request.method) ||
1340
+ !("sessionId" in request.params) ||
1341
+ request.params.sessionId !== credential.sessionId) {
1342
+ ctx.status = 403;
1343
+ ctx.body = { error: "forbidden" };
1344
+ return;
1345
+ }
1346
+ ctx.params.selector = "local";
1347
+ const outcome = await runtimeCall(ctx, request.method, request.params);
1348
+ if (outcome.ok)
1349
+ ctx.body = { result: outcome.result };
1350
+ });
1351
+ router.get(`${RUNTIME_BROWSER_MANAGEMENT_PATH_PREFIX}/:sessionId/endpoint`, async (ctx) => {
1352
+ if (!runtimeLocalBrowser || !isConfiguredDaemonManagement(daemonManagement)) {
1353
+ return notFound(ctx, "Runtime-local Browser automation is not configured");
1354
+ }
1355
+ if (!authorizeLoopbackManagementRequest(ctx, daemonManagement.managementToken))
1356
+ return;
1357
+ let sessionId;
1358
+ try {
1359
+ sessionId = parseRuntimeBrowserStateGetParams({
1360
+ sessionId: ctx.params.sessionId,
1361
+ }).sessionId;
1362
+ }
1363
+ catch {
1364
+ return bad(ctx, "invalid Session id");
1365
+ }
1366
+ try {
1367
+ ctx.set("Cache-Control", "no-store");
1368
+ ctx.body = await runtimeLocalBrowser.endpointForSession(sessionId);
1369
+ }
1370
+ catch (error) {
1371
+ runtimeLocalBrowserError(ctx, error);
1372
+ }
1373
+ });
32
1374
  router.get("/api/meta", (ctx) => {
33
1375
  const meta = {
34
1376
  channelTypes: deps.listChannelTypes().map((t) => t.type),
35
1377
  };
36
1378
  ctx.body = meta;
37
1379
  });
1380
+ router.get("/api/plugin-runtime", (ctx) => {
1381
+ if (!pluginRuntimeStatus)
1382
+ return notFound(ctx, "plugin runtime is not configured");
1383
+ ctx.body = pluginRuntimeStatus();
1384
+ });
1385
+ router.get("/api/internal/daemon/identity", (ctx) => {
1386
+ if (!isConfiguredDaemonManagement(daemonManagement)) {
1387
+ return notFound(ctx, "daemon management is not configured");
1388
+ }
1389
+ if (!authorizeLoopbackManagementRequest(ctx, daemonManagement.managementToken))
1390
+ return;
1391
+ ctx.body = daemonManagement.identity();
1392
+ });
1393
+ router.get("/api/internal/daemon/runtime-status", (ctx) => {
1394
+ if (!remoteRuntime || !isConfiguredDaemonManagement(daemonManagement)) {
1395
+ return notFound(ctx, "Remote Runtime status is not configured");
1396
+ }
1397
+ if (!authorizeLoopbackManagementRequest(ctx, daemonManagement.managementToken))
1398
+ return;
1399
+ ctx.body = encodeDaemonStatus(remoteRuntime.status());
1400
+ });
1401
+ router.post("/api/internal/remote-runtime/pairing-offers", async (ctx) => {
1402
+ if (!remoteRuntimeAdmin || !isConfiguredDaemonManagement(daemonManagement)) {
1403
+ return notFound(ctx, "Remote Runtime administration is not configured");
1404
+ }
1405
+ if (!authorizeLoopbackManagementRequest(ctx, daemonManagement.managementToken))
1406
+ return;
1407
+ await createRuntimePairingOffer(ctx, "offer");
1408
+ });
1409
+ router.get("/api/internal/remote-runtime/clients", (ctx) => {
1410
+ if (!remoteRuntimeAdmin || !isConfiguredDaemonManagement(daemonManagement)) {
1411
+ return notFound(ctx, "Remote Runtime administration is not configured");
1412
+ }
1413
+ if (!authorizeLoopbackManagementRequest(ctx, daemonManagement.managementToken))
1414
+ return;
1415
+ listRuntimeAccess(ctx);
1416
+ });
1417
+ router.delete("/api/internal/remote-runtime/clients/:grantId", (ctx) => {
1418
+ if (!remoteRuntimeAdmin || !isConfiguredDaemonManagement(daemonManagement)) {
1419
+ return notFound(ctx, "Remote Runtime administration is not configured");
1420
+ }
1421
+ if (!authorizeLoopbackManagementRequest(ctx, daemonManagement.managementToken))
1422
+ return;
1423
+ revokeRuntimeAccess(ctx);
1424
+ });
1425
+ router.post("/api/internal/plugins/:pluginId/cli", async (ctx) => {
1426
+ if (!isConfiguredDaemonManagement(daemonManagement)) {
1427
+ return notFound(ctx, "daemon management is not configured");
1428
+ }
1429
+ if (!authorizeLoopbackManagementRequest(ctx, daemonManagement.managementToken))
1430
+ return;
1431
+ const pluginId = ctx.params.pluginId;
1432
+ if (!/^[a-z][a-z0-9-]{0,62}$/.test(pluginId)) {
1433
+ return bad(ctx, "invalid plugin id");
1434
+ }
1435
+ if (!ctx.is("application/json")) {
1436
+ ctx.status = 415;
1437
+ ctx.body = { error: "plugin CLI invocation requires application/json" };
1438
+ return;
1439
+ }
1440
+ let body;
1441
+ try {
1442
+ body = await readBoundedJson(ctx, PLUGIN_CLI_BODY_MAX_BYTES);
1443
+ }
1444
+ catch (error) {
1445
+ if (error instanceof RequestBodyTooLargeError) {
1446
+ ctx.status = 413;
1447
+ ctx.body = { error: error.message };
1448
+ return;
1449
+ }
1450
+ return bad(ctx, error instanceof Error ? error.message : String(error));
1451
+ }
1452
+ const input = parsePluginCliInvocation(pluginId, body);
1453
+ if (typeof input === "string")
1454
+ return bad(ctx, input);
1455
+ const invocation = pluginCliInvocationAbortScope(ctx, shutdownSignal);
1456
+ try {
1457
+ ctx.body = await daemonManagement.invokePluginCli(input, { signal: invocation.signal });
1458
+ }
1459
+ catch (error) {
1460
+ if (!ctx.res.destroyed) {
1461
+ ctx.status = invocation.signal.aborted
1462
+ ? invocation.signal.reason instanceof Error && invocation.signal.reason.name === "TimeoutError"
1463
+ ? 504
1464
+ : 499
1465
+ : 409;
1466
+ ctx.body = {
1467
+ error: boundedUtf8(error instanceof Error ? error.message : String(error), PLUGIN_CLI_ERROR_MAX_BYTES),
1468
+ };
1469
+ }
1470
+ }
1471
+ finally {
1472
+ invocation.dispose();
1473
+ }
1474
+ });
1475
+ router.post("/api/internal/plugin-runtimes/:pluginId/reload", async (ctx) => {
1476
+ if (!pluginRuntimeReload || !isConfiguredDaemonManagement(daemonManagement)) {
1477
+ return notFound(ctx, "plugin runtime management is not configured");
1478
+ }
1479
+ if (!authorizeLoopbackManagementRequest(ctx, daemonManagement.managementToken))
1480
+ return;
1481
+ const pluginId = ctx.params.pluginId;
1482
+ if (!/^[a-z][a-z0-9-]{0,62}$/.test(pluginId)) {
1483
+ return bad(ctx, "invalid plugin id");
1484
+ }
1485
+ try {
1486
+ ctx.body = await pluginRuntimeReload(pluginId);
1487
+ }
1488
+ catch (error) {
1489
+ ctx.status = 409;
1490
+ ctx.body = { error: error instanceof Error ? error.message : String(error) };
1491
+ }
1492
+ });
1493
+ router.post("/api/internal/plugin-runtimes/:pluginId/ensure", async (ctx) => {
1494
+ if (!pluginRuntimeEnsure || !isConfiguredDaemonManagement(daemonManagement)) {
1495
+ return notFound(ctx, "plugin runtime management is not configured");
1496
+ }
1497
+ if (!authorizeLoopbackManagementRequest(ctx, daemonManagement.managementToken))
1498
+ return;
1499
+ const pluginId = ctx.params.pluginId;
1500
+ if (!/^[a-z][a-z0-9-]{0,62}$/.test(pluginId)) {
1501
+ return bad(ctx, "invalid plugin id");
1502
+ }
1503
+ try {
1504
+ ctx.body = await pluginRuntimeEnsure(pluginId);
1505
+ }
1506
+ catch (error) {
1507
+ ctx.status = 409;
1508
+ ctx.body = { error: error instanceof Error ? error.message : String(error) };
1509
+ }
1510
+ });
1511
+ router.get("/control/link", (ctx) => {
1512
+ const token = asString(ctx.query.token);
1513
+ if (!token || !deps.consumeControlLink)
1514
+ return notFound(ctx, "control link not found");
1515
+ const link = deps.consumeControlLink(token);
1516
+ if (!link)
1517
+ return notFound(ctx, "control link expired or already used");
1518
+ ctx.redirect(`/sessions/${encodeURIComponent(link.sessionId)}`);
1519
+ });
38
1520
  router.get("/api/channel-types", (ctx) => {
39
1521
  ctx.body = { channelTypes: deps.listChannelTypes() };
40
1522
  });
@@ -57,16 +1539,27 @@ export function createControlRouter(opts) {
57
1539
  return bad(ctx, "missing channel name");
58
1540
  if (!type)
59
1541
  return bad(ctx, "missing channel type");
60
- const { id } = deps.createChannel({ name, type, options: asRecord(body.options) });
1542
+ let id;
1543
+ try {
1544
+ ({ id } = deps.createChannel({ name, type, options: asRecord(body.options) }));
1545
+ }
1546
+ catch (error) {
1547
+ return bad(ctx, error instanceof Error ? error.message : String(error));
1548
+ }
61
1549
  ctx.body = { ok: true, id };
62
1550
  });
63
1551
  router.put("/api/channels/:id", async (ctx) => {
64
1552
  const id = ctx.params.id;
65
1553
  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
- });
1554
+ try {
1555
+ deps.setChannel(id, {
1556
+ ...(body.name !== undefined ? { name: asString(body.name) } : {}),
1557
+ ...(body.options !== undefined ? { options: asRecord(body.options) } : {}),
1558
+ });
1559
+ }
1560
+ catch (error) {
1561
+ return bad(ctx, error instanceof Error ? error.message : String(error));
1562
+ }
70
1563
  const bound = instanceForChannel(id);
71
1564
  if (bound)
72
1565
  await manager.reloadInstance(bound.id);
@@ -89,13 +1582,16 @@ export function createControlRouter(opts) {
89
1582
  return notFound(ctx, "channel not found");
90
1583
  await streamEvents(ctx, async (send, signal) => {
91
1584
  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
1585
  const bound = instanceForChannel(id);
1586
+ const options = await deps.authorizeChannel(channel.type, {
1587
+ ...(bound ? { instanceId: bound.id } : {}),
1588
+ config: channel.options ?? {},
1589
+ }, (m) => send(m.kind, m), signal);
1590
+ deps.setChannel(id, { options });
95
1591
  if (bound)
96
1592
  await manager.reloadInstance(bound.id);
97
1593
  send("done", { id });
98
- });
1594
+ }, shutdownSignal);
99
1595
  });
100
1596
  // ── instances ──────────────────────────────────────────────────────────
101
1597
  router.get("/api/instances", (ctx) => {
@@ -260,27 +1756,24 @@ export function createControlRouter(opts) {
260
1756
  bad(ctx, error instanceof Error ? error.message : String(error));
261
1757
  }
262
1758
  };
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;
1759
+ const localSessionEmulatorBinding = async (ctx, body) => {
276
1760
  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);
1761
+ if (!sessionId)
1762
+ return undefined;
1763
+ if (!sessionEmulators)
1764
+ throw new Error("Session Emulator service is unavailable");
1765
+ const binding = await sessionEmulators.getState({ sessionId });
1766
+ if (!binding)
1767
+ throw new Error("Session has no attached Emulator");
1768
+ const explicit = asString(body.device);
1769
+ if (explicit && explicit !== binding.deviceId) {
1770
+ throw new Error("Explicit device does not match the Session Emulator binding");
283
1771
  }
1772
+ return binding;
1773
+ };
1774
+ const localEmulatorDevice = async (ctx, body) => {
1775
+ const binding = await localSessionEmulatorBinding(ctx, body);
1776
+ return binding?.deviceId ?? asString(body.device);
284
1777
  };
285
1778
  router.get("/api/emulator/doctor", async (ctx) => {
286
1779
  await emulatorResult(ctx, (emulator) => emulator.doctor());
@@ -290,7 +1783,16 @@ export function createControlRouter(opts) {
290
1783
  });
291
1784
  router.get("/api/emulator/active", async (ctx) => {
292
1785
  await emulatorResult(ctx, async (emulator) => {
293
- const device = emulatorScopedDevice(ctx, {});
1786
+ const sessionId = emulatorSessionOf(ctx);
1787
+ let device;
1788
+ if (sessionId) {
1789
+ if (!sessionEmulators)
1790
+ throw new Error("Session Emulator service is unavailable");
1791
+ const binding = await sessionEmulators.getState({ sessionId });
1792
+ if (!binding)
1793
+ return { active: null };
1794
+ device = binding.deviceId;
1795
+ }
294
1796
  if (device && emulator.deviceSession) {
295
1797
  return { active: await emulator.deviceSession({ device }) };
296
1798
  }
@@ -301,11 +1803,21 @@ export function createControlRouter(opts) {
301
1803
  const body = await readJson(ctx);
302
1804
  const sessionId = emulatorSessionOf(ctx);
303
1805
  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);
1806
+ if (!sessionId)
1807
+ return emulator.attach(asString(body.device));
1808
+ if (!sessionEmulators)
1809
+ throw new Error("Session Emulator service is unavailable");
1810
+ let device = asString(body.device);
1811
+ if (!device) {
1812
+ const devices = await sessionEmulators.listDevices({ sessionId });
1813
+ device = devices.find((candidate) => candidate.isDefault)?.id ?? devices[0]?.id;
307
1814
  }
308
- return session;
1815
+ if (!device)
1816
+ throw new Error("No Emulator is available for this Session");
1817
+ const binding = await sessionEmulators.attach({ sessionId, device });
1818
+ return emulator.deviceSession
1819
+ ? await emulator.deviceSession({ device: binding.deviceId }) ?? binding
1820
+ : binding;
309
1821
  });
310
1822
  });
311
1823
  router.post("/api/emulator/tap", async (ctx) => {
@@ -314,50 +1826,95 @@ export function createControlRouter(opts) {
314
1826
  const y = asNumber(body.y);
315
1827
  if (x == null || y == null)
316
1828
  return bad(ctx, "tap requires numeric x and y");
317
- await emulatorResult(ctx, (emulator) => emulator.tap({ x, y, device: emulatorScopedDevice(ctx, body) }));
1829
+ await emulatorResult(ctx, async (emulator) => {
1830
+ const binding = await localSessionEmulatorBinding(ctx, body);
1831
+ return binding && sessionEmulators
1832
+ ? sessionEmulators.tap({ sessionId: binding.sessionId, bindingId: binding.bindingId, x, y })
1833
+ : emulator.tap({ x, y, device: asString(body.device) });
1834
+ });
318
1835
  });
319
1836
  router.post("/api/emulator/type", async (ctx) => {
320
1837
  const body = await readJson(ctx);
321
1838
  const text = asString(body.text);
322
1839
  if (!text)
323
1840
  return bad(ctx, "type requires text");
324
- await emulatorResult(ctx, (emulator) => emulator.type({ text, device: emulatorScopedDevice(ctx, body), mode: asString(body.mode) }));
1841
+ await emulatorResult(ctx, async (emulator) => {
1842
+ const binding = await localSessionEmulatorBinding(ctx, body);
1843
+ const mode = asString(body.mode);
1844
+ return binding && sessionEmulators
1845
+ ? sessionEmulators.type({
1846
+ sessionId: binding.sessionId,
1847
+ bindingId: binding.bindingId,
1848
+ text,
1849
+ ...(mode === "paste" || mode === "keys" ? { mode } : {}),
1850
+ })
1851
+ : emulator.type({ text, device: asString(body.device), mode });
1852
+ });
325
1853
  });
326
1854
  router.post("/api/emulator/button", async (ctx) => {
327
1855
  const body = await readJson(ctx);
328
- await emulatorResult(ctx, (emulator) => emulator.button({ name: asString(body.name), device: emulatorScopedDevice(ctx, body) }));
1856
+ await emulatorResult(ctx, async (emulator) => {
1857
+ const binding = await localSessionEmulatorBinding(ctx, body);
1858
+ const name = asString(body.name);
1859
+ return binding && sessionEmulators
1860
+ ? sessionEmulators.button({
1861
+ sessionId: binding.sessionId,
1862
+ bindingId: binding.bindingId,
1863
+ ...(name ? { name } : {}),
1864
+ })
1865
+ : emulator.button({ name, device: asString(body.device) });
1866
+ });
329
1867
  });
330
1868
  router.post("/api/emulator/rotate", async (ctx) => {
331
1869
  const body = await readJson(ctx);
332
1870
  const orientation = asString(body.orientation);
333
1871
  if (!orientation)
334
1872
  return bad(ctx, "rotate requires orientation");
335
- await emulatorResult(ctx, (emulator) => emulator.rotate({ orientation, device: emulatorScopedDevice(ctx, body) }));
1873
+ await emulatorResult(ctx, async (emulator) => {
1874
+ const binding = await localSessionEmulatorBinding(ctx, body);
1875
+ return binding && sessionEmulators
1876
+ ? sessionEmulators.rotate({ sessionId: binding.sessionId, bindingId: binding.bindingId, orientation })
1877
+ : emulator.rotate({ orientation, device: asString(body.device) });
1878
+ });
336
1879
  });
337
1880
  router.post("/api/emulator/launch", async (ctx) => {
338
1881
  const body = await readJson(ctx);
339
1882
  const appId = asString(body.appId) ?? asString(body.bundleId) ?? asString(body.packageName);
340
1883
  if (!appId)
341
1884
  return bad(ctx, "launch requires appId");
342
- await emulatorResult(ctx, (emulator) => emulator.launch({ appId, device: emulatorScopedDevice(ctx, body) }));
1885
+ await emulatorResult(ctx, async (emulator) => {
1886
+ const binding = await localSessionEmulatorBinding(ctx, body);
1887
+ return binding && sessionEmulators
1888
+ ? sessionEmulators.launch({ sessionId: binding.sessionId, bindingId: binding.bindingId, appId })
1889
+ : emulator.launch({ appId, device: asString(body.device) });
1890
+ });
343
1891
  });
344
1892
  router.post("/api/emulator/gesture", async (ctx) => {
345
1893
  const body = await readJson(ctx);
346
1894
  const points = body.points;
347
1895
  if (!Array.isArray(points))
348
1896
  return bad(ctx, "gesture requires points");
349
- await emulatorResult(ctx, (emulator) => emulator.gesture({ points, device: emulatorScopedDevice(ctx, body) }));
1897
+ await emulatorResult(ctx, async (emulator) => {
1898
+ const binding = await localSessionEmulatorBinding(ctx, body);
1899
+ return binding && sessionEmulators
1900
+ ? sessionEmulators.gesture({
1901
+ sessionId: binding.sessionId,
1902
+ bindingId: binding.bindingId,
1903
+ points: points,
1904
+ })
1905
+ : emulator.gesture({ points, device: asString(body.device) });
1906
+ });
350
1907
  });
351
1908
  router.post("/api/emulator/exec", async (ctx) => {
352
1909
  const body = await readJson(ctx);
353
1910
  const command = asString(body.command);
354
1911
  if (!command)
355
1912
  return bad(ctx, "exec requires command");
356
- await emulatorResult(ctx, (emulator) => emulator.exec({ command, device: emulatorScopedDevice(ctx, body) }));
1913
+ await emulatorResult(ctx, async (emulator) => emulator.exec({ command, device: await localEmulatorDevice(ctx, body) }));
357
1914
  });
358
1915
  router.post("/api/emulator/screenshot", async (ctx) => {
359
1916
  const body = await readJson(ctx);
360
- await emulatorResult(ctx, (emulator) => emulator.screenshot({ device: emulatorScopedDevice(ctx, body), out: asString(body.out) }));
1917
+ await emulatorResult(ctx, async (emulator) => emulator.screenshot({ device: await localEmulatorDevice(ctx, body), out: asString(body.out) }));
361
1918
  });
362
1919
  router.get("/api/emulator/frame", async (ctx) => {
363
1920
  const emulator = emulatorOr404(ctx);
@@ -368,7 +1925,7 @@ export function createControlRouter(opts) {
368
1925
  }
369
1926
  const body = { device: asString(ctx.query.device) };
370
1927
  try {
371
- const frame = await emulator.frame({ device: emulatorScopedDevice(ctx, body) });
1928
+ const frame = await emulator.frame({ device: await localEmulatorDevice(ctx, body) });
372
1929
  ctx.type = frame.contentType;
373
1930
  ctx.set("Cache-Control", "no-store");
374
1931
  ctx.body = frame.data;
@@ -386,7 +1943,7 @@ export function createControlRouter(opts) {
386
1943
  }
387
1944
  const body = { device: asString(ctx.query.device) };
388
1945
  try {
389
- const video = await emulator.video({ device: emulatorScopedDevice(ctx, body) });
1946
+ const video = await emulator.video({ device: await localEmulatorDevice(ctx, body) });
390
1947
  let stopped = false;
391
1948
  const stop = () => {
392
1949
  if (stopped)
@@ -401,6 +1958,17 @@ export function createControlRouter(opts) {
401
1958
  res.setHeader("Cache-Control", "no-store");
402
1959
  res.setHeader("X-Content-Type-Options", "nosniff");
403
1960
  res.on("close", stop);
1961
+ const stopForShutdown = () => {
1962
+ stop();
1963
+ if (!res.writableEnded)
1964
+ res.end();
1965
+ };
1966
+ if (shutdownSignal?.aborted) {
1967
+ stopForShutdown();
1968
+ return;
1969
+ }
1970
+ shutdownSignal?.addEventListener("abort", stopForShutdown, { once: true });
1971
+ res.on("close", () => shutdownSignal?.removeEventListener("abort", stopForShutdown));
404
1972
  video.stream.on("end", stop);
405
1973
  video.stream.on("error", (error) => {
406
1974
  stop();
@@ -438,25 +2006,25 @@ export function createControlRouter(opts) {
438
2006
  router.post("/api/emulator/kill", async (ctx) => {
439
2007
  const body = await readJson(ctx);
440
2008
  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;
2009
+ const device = await localEmulatorDevice(ctx, body);
2010
+ return sessionEmulators
2011
+ ? sessionEmulators.stopDevice({ action: "kill", ...(device ? { device } : {}) })
2012
+ : emulator.kill({ device });
445
2013
  });
446
2014
  });
447
2015
  router.post("/api/emulator/shutdown", async (ctx) => {
448
2016
  const body = await readJson(ctx);
449
2017
  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;
2018
+ const device = await localEmulatorDevice(ctx, body);
2019
+ return sessionEmulators
2020
+ ? sessionEmulators.stopDevice({ action: "shutdown", ...(device ? { device } : {}) })
2021
+ : emulator.shutdown({ device });
454
2022
  });
455
2023
  });
456
2024
  // ── sessions (channel-agnostic agent runs) ───────────────────────────────
457
2025
  // Create a session bound to a preset agent (`agent`) or an inline spec
458
2026
  // (`config`, the config-driven / remote-ready path), then drive it with
459
- // messages over SSE. omnigent-style: create carries no message.
2027
+ // messages over SSE. reference implementation-style: create carries no message.
460
2028
  // One unified list: every session is a machine-session record. The registry
461
2029
  // (console + every channel) supplies identity — `source` is a plain field, not
462
2030
  // parsed from the id — and the canonical log supplies recency. A legacy session
@@ -474,9 +2042,10 @@ export function createControlRouter(opts) {
474
2042
  const updatedAt = log ? new Date(log.updatedAt).toISOString() : (meta?.updatedAt ?? createdAt);
475
2043
  return {
476
2044
  id,
2045
+ provider: meta?.provider,
477
2046
  agent: meta?.agent,
478
2047
  title: meta?.title,
479
- status: "idle",
2048
+ status: sessionRuntimeIndex.snapshot(id).status,
480
2049
  createdAt,
481
2050
  updatedAt,
482
2051
  source: meta?.source ?? "unknown",
@@ -531,7 +2100,7 @@ export function createControlRouter(opts) {
531
2100
  const bound = consoleMeta ? null : ((await sessionStore?.get(id)) ?? null);
532
2101
  if (!meta && !bound)
533
2102
  return notFound(ctx, "session not found");
534
- // First message names a console session (omnigent-style: the message,
2103
+ // First message names a console session (reference implementation-style: the message,
535
2104
  // truncated). Channel sessions keep their own title elsewhere — skip.
536
2105
  if (consoleMeta && !consoleMeta.title)
537
2106
  deps.setSessionTitle(id, synthesizeSessionTitle(message));
@@ -548,7 +2117,7 @@ export function createControlRouter(opts) {
548
2117
  const liveRuntime = await runtime.resolveRuntime({
549
2118
  agentName: consoleMeta?.agent,
550
2119
  agentSpec: consoleMeta?.config,
551
- provider: bound?.runtime,
2120
+ provider: meta?.provider ?? bound?.runtime,
552
2121
  });
553
2122
  // codex RESUME with a missing local rollout (fork / cross-machine): synthesize
554
2123
  // one from the session log so the app-server's thread/resume finds it. No-op
@@ -570,8 +2139,13 @@ export function createControlRouter(opts) {
570
2139
  }
571
2140
  }
572
2141
  const live = await runnerManager.ensureLiveSession(id, {
573
- ...(bound?.cwd ? { cwd: bound.cwd } : {}),
2142
+ ...(bound?.cwd
2143
+ ? { cwd: bound.cwd }
2144
+ : consoleMeta?.config?.osEnv?.cwd
2145
+ ? { cwd: consoleMeta.config.osEnv.cwd }
2146
+ : {}),
574
2147
  runtime: liveRuntime,
2148
+ ...(consoleMeta?.reasoningEffort ? { reasoningEffort: consoleMeta.reasoningEffort } : {}),
575
2149
  // Carry the agent identity so the live launch applies the agent spec's
576
2150
  // model / skills / instructions (not just the runtime). A preset agent
577
2151
  // rides its id; an inline-config console session rides its spec.
@@ -580,11 +2154,15 @@ export function createControlRouter(opts) {
580
2154
  });
581
2155
  if (!live) {
582
2156
  // 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
2157
+ // up. reference implementation reports this as a failure and NEVER falls through to a second
584
2158
  // output path — falling through would run+persist the turn while the forwarder
585
2159
  // also mirrors it (the double-write). Report + stop.
2160
+ const detail = runnerManager.lastLiveSessionError?.(id);
586
2161
  ctx.status = 503;
587
- ctx.body = { error: `live session unavailable (${liveRuntime})` };
2162
+ ctx.body = {
2163
+ error: `live session unavailable (${liveRuntime})`,
2164
+ ...(detail ? { detail } : {}),
2165
+ };
588
2166
  return;
589
2167
  }
590
2168
  const outcome = await runnerManager.injectMessage(id, message);
@@ -594,7 +2172,7 @@ export function createControlRouter(opts) {
594
2172
  }
595
2173
  // notLive / notReady / failed → hard error. NEVER fall through to run+stream:
596
2174
  // the second path would double-write the turn alongside the forwarder
597
- // (omnigent: inject failure ⇒ response.failed, no local re-run).
2175
+ // (reference implementation: inject failure ⇒ response.failed, no local re-run).
598
2176
  ctx.status = 503;
599
2177
  ctx.body = { error: `live injection ${outcome}` };
600
2178
  return;
@@ -623,6 +2201,14 @@ export function createControlRouter(opts) {
623
2201
  const id = ctx.params.id;
624
2202
  ctx.body = { sessionId: id, items: sessionLog ? await sessionLog.snapshot(id) : [] };
625
2203
  });
2204
+ router.get("/api/sessions/:id/snapshot", async (ctx) => {
2205
+ const id = ctx.params.id;
2206
+ ctx.body = {
2207
+ sessionId: id,
2208
+ items: sessionLog ? await sessionLog.snapshot(id) : [],
2209
+ runtime: sessionRuntimeIndex.snapshot(id),
2210
+ };
2211
+ });
626
2212
  // Persistent live event stream (codex-native): one SSE per session, held for
627
2213
  // its whole lifetime, tailing the canonical bus. Sees EVERY turn — web-injected
628
2214
  // AND co-driving-TUI-initiated — since the forwarder is the single writer. The
@@ -630,10 +2216,25 @@ export function createControlRouter(opts) {
630
2216
  // + item-id dedupe. Ends when the client disconnects.
631
2217
  router.get("/api/sessions/:id/stream", async (ctx) => {
632
2218
  const id = ctx.params.id;
2219
+ // Production daemon composition owns a binding-neutral Local connection.
2220
+ // Keep this legacy URL compatible while routing it through the same bounded
2221
+ // subscription adapter as `/api/runtimes/local/...`.
2222
+ if (runtimeConnectionResolver) {
2223
+ await streamRuntimeSession(ctx, "local", id);
2224
+ return;
2225
+ }
633
2226
  if (!sessionBus)
634
2227
  return bad(ctx, "session streaming is not available on this server");
635
2228
  await streamEvents(ctx, async (send, signal) => {
636
2229
  const iterator = sessionBus.subscribe(id)[Symbol.asyncIterator]();
2230
+ // Headers are flushed before this callback runs, so a client cannot use
2231
+ // the HTTP response alone as proof that SessionBus subscription exists.
2232
+ // This frame is the ordering barrier for subscribe → snapshot → replay.
2233
+ // This compatibility fallback has no binding adapter queue. End the
2234
+ // subscription as soon as the HTTP consumer applies backpressure instead
2235
+ // of letting either Node or SessionBus accumulate an unbounded tail.
2236
+ if (!send("ready", { sessionId: id }))
2237
+ return;
637
2238
  const disconnected = new Promise((resolve) => {
638
2239
  if (signal.aborted)
639
2240
  resolve(null);
@@ -647,79 +2248,54 @@ export function createControlRouter(opts) {
647
2248
  break; // client went away
648
2249
  if (next.done)
649
2250
  break; // bus closed for this session
650
- send("event", next.value);
2251
+ if (!send("event", next.value))
2252
+ break;
651
2253
  }
652
2254
  }
653
2255
  finally {
654
2256
  await iterator.return?.();
655
2257
  }
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 } };
2258
+ }, shutdownSignal);
694
2259
  });
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) => {
2260
+ // Provider-neutral interaction response. The runtime owns the pending waiter
2261
+ // and first-writer-wins decision; HTTP only validates and routes the generic
2262
+ // resolution. The canonical resolved event is emitted by the runtime after
2263
+ // the provider accepts it, so every observer converges on the same winner.
2264
+ router.post("/api/sessions/:id/interactions/:interactionId/resolve", async (ctx) => {
699
2265
  const id = ctx.params.id;
700
- const approvalId = ctx.params.approvalId;
2266
+ const interactionId = ctx.params.interactionId;
701
2267
  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;
2268
+ const resolution = parseInteractionResolution(body.resolution);
2269
+ if (!resolution) {
2270
+ return bad(ctx, "resolution must contain actionId and bounded string answers");
2271
+ }
2272
+ if (!runnerManager?.resolveInteraction) {
2273
+ return notFound(ctx, "session interaction resolution is not available");
2274
+ }
2275
+ const result = await runnerManager.resolveInteraction(id, interactionId, resolution);
2276
+ if (result.disposition === "invalid") {
2277
+ return bad(ctx, "interaction resolution is invalid");
710
2278
  }
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 };
2279
+ if (result.disposition === "not_found") {
2280
+ return notFound(ctx, "interaction is not pending");
2281
+ }
2282
+ if (result.disposition === "already_resolved") {
2283
+ ctx.status = 409;
2284
+ ctx.body = { error: "interaction was already resolved", ...result };
716
2285
  return;
717
2286
  }
718
- runnerManager?.resolveApproval?.(id, approvalId, decision);
719
- ctx.body = { ok: true };
2287
+ ctx.body = result;
720
2288
  });
721
2289
  router.delete("/api/sessions/:id", async (ctx) => {
722
2290
  const id = ctx.params.id;
2291
+ try {
2292
+ await sessionLifecycle?.beforeDelete?.(id);
2293
+ }
2294
+ catch {
2295
+ ctx.status = 409;
2296
+ ctx.body = { error: "session_resource_cleanup_failed" };
2297
+ return;
2298
+ }
723
2299
  runnerManager?.stopRunner(id);
724
2300
  sessionBus?.close(id);
725
2301
  deps.removeSessionMeta(id);
@@ -727,9 +2303,15 @@ export function createControlRouter(opts) {
727
2303
  // dropping only the meta would leave the session showing as an UNKNOWN
728
2304
  // ghost. Both together make it fully disappear.
729
2305
  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);
2306
+ sessionRuntimeIndex.remove(id);
2307
+ try {
2308
+ await sessionLifecycle?.afterDelete?.(id);
2309
+ }
2310
+ catch {
2311
+ ctx.status = 500;
2312
+ ctx.body = { error: "session_resource_finalize_failed", outcome: "unknown" };
2313
+ return;
2314
+ }
733
2315
  ctx.body = { ok: true };
734
2316
  });
735
2317
  return router;
@@ -739,20 +2321,39 @@ export function createControlRouter(opts) {
739
2321
  * `signal` aborts when the client disconnects. Takes over the raw response so
740
2322
  * frames flush immediately; a thrown error is reported as an `error` event.
741
2323
  */
742
- async function streamEvents(ctx, run) {
2324
+ async function streamEvents(ctx, run, shutdownSignal) {
743
2325
  ctx.status = 200;
744
2326
  ctx.set("Content-Type", "text/event-stream");
745
- ctx.set("Cache-Control", "no-cache, no-transform");
2327
+ // Session streams may contain source code, prompts and tool output. `no-cache`
2328
+ // still permits storage after revalidation; keep transcript bytes out of
2329
+ // browser/proxy caches entirely and prohibit intermediary transformations.
2330
+ ctx.set("Cache-Control", "private, no-store, no-transform");
746
2331
  ctx.set("Connection", "keep-alive");
2332
+ ctx.set("X-Accel-Buffering", "no");
747
2333
  ctx.respond = false;
2334
+ ctx.req.socket.setTimeout(0);
748
2335
  const res = ctx.res;
749
2336
  res.flushHeaders?.();
750
2337
  const controller = new AbortController();
751
2338
  ctx.req.on("close", () => controller.abort());
2339
+ const abortForShutdown = () => controller.abort(shutdownSignal?.reason);
2340
+ if (shutdownSignal?.aborted)
2341
+ abortForShutdown();
2342
+ else
2343
+ shutdownSignal?.addEventListener("abort", abortForShutdown, { once: true });
752
2344
  const send = (event, data) => {
753
- if (!res.writableEnded)
754
- res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
2345
+ if (res.writableEnded)
2346
+ return false;
2347
+ return res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
755
2348
  };
2349
+ // Keep idle chat streams alive through reverse proxies and NATs. Without a
2350
+ // frame, an otherwise healthy session is often closed after a few minutes and
2351
+ // the web console has to rebuild its transcript subscription.
2352
+ const keepalive = setInterval(() => {
2353
+ if (!res.writableEnded && !res.writableNeedDrain)
2354
+ res.write(": keepalive\n\n");
2355
+ }, 25_000);
2356
+ keepalive.unref?.();
756
2357
  try {
757
2358
  await run(send, controller.signal);
758
2359
  }
@@ -760,12 +2361,39 @@ async function streamEvents(ctx, run) {
760
2361
  send("error", { message: error instanceof Error ? error.message : String(error) });
761
2362
  }
762
2363
  finally {
2364
+ clearInterval(keepalive);
2365
+ shutdownSignal?.removeEventListener("abort", abortForShutdown);
763
2366
  if (!res.writableEnded)
764
2367
  res.end();
765
2368
  }
766
2369
  }
2370
+ function waitForSseDrain(ctx, signal) {
2371
+ if (signal.aborted || ctx.res.writableEnded)
2372
+ return Promise.resolve(false);
2373
+ return new Promise((resolve) => {
2374
+ const settle = (drained) => {
2375
+ ctx.res.removeListener("drain", onDrain);
2376
+ ctx.res.removeListener("close", onClose);
2377
+ signal.removeEventListener("abort", onAbort);
2378
+ resolve(drained);
2379
+ };
2380
+ const onDrain = () => settle(true);
2381
+ const onClose = () => settle(false);
2382
+ const onAbort = () => settle(false);
2383
+ ctx.res.once("drain", onDrain);
2384
+ ctx.res.once("close", onClose);
2385
+ signal.addEventListener("abort", onAbort, { once: true });
2386
+ });
2387
+ }
2388
+ function abortAsNull(signal) {
2389
+ if (signal.aborted)
2390
+ return Promise.resolve(null);
2391
+ return new Promise((resolve) => {
2392
+ signal.addEventListener("abort", () => resolve(null), { once: true });
2393
+ });
2394
+ }
767
2395
  /** Derive a one-line session title from the first user message: collapse
768
- * whitespace, trim, and truncate to `limit` chars + an ellipsis (omnigent's
2396
+ * whitespace, trim, and truncate to `limit` chars + an ellipsis (reference implementation's
769
2397
  * rule — no LLM call). */
770
2398
  export function synthesizeSessionTitle(message, limit = 60) {
771
2399
  const collapsed = message.replace(/\s+/g, " ").trim();
@@ -810,6 +2438,459 @@ function redact(deps, type, options) {
810
2438
  function asString(value) {
811
2439
  return typeof value === "string" && value.trim() ? value.trim() : undefined;
812
2440
  }
2441
+ function parseProviderCliId(value) {
2442
+ return value === "codex" || value === "claude" || value === "traex"
2443
+ ? value
2444
+ : undefined;
2445
+ }
2446
+ function parseProviderCliOperation(value) {
2447
+ return value === "install" || value === "update" ? value : undefined;
2448
+ }
2449
+ function providerCliOperationErrorCode(error) {
2450
+ if (!error || typeof error !== "object")
2451
+ return undefined;
2452
+ return "code" in error && typeof error.code === "string" ? error.code : undefined;
2453
+ }
2454
+ function optionalRuntimeString(value) {
2455
+ return typeof value === "string" && value.length > 0 ? value : undefined;
2456
+ }
2457
+ function optionalRuntimeDevice(body) {
2458
+ return body.device === undefined ? {} : { device: body.device };
2459
+ }
2460
+ function remoteRuntimeRpcTimeoutMs(method, params) {
2461
+ if (method === "session.emulator.attach")
2462
+ return REMOTE_RUNTIME_EMULATOR_ATTACH_TIMEOUT_MS;
2463
+ if (method === "session.emulator.type") {
2464
+ const text = params.text;
2465
+ return Math.min(REMOTE_RUNTIME_EMULATOR_ATTACH_TIMEOUT_MS, Math.max(REMOTE_RUNTIME_EMULATOR_CONTROL_TIMEOUT_MS, text.length * 40 + 5_000));
2466
+ }
2467
+ if (method.startsWith("session."))
2468
+ return REMOTE_RUNTIME_SESSION_TIMEOUT_MS;
2469
+ if (method === "browser.open")
2470
+ return REMOTE_RUNTIME_BROWSER_OPEN_TIMEOUT_MS;
2471
+ if (method.startsWith("browser."))
2472
+ return REMOTE_RUNTIME_BROWSER_CONTROL_TIMEOUT_MS;
2473
+ if (method === "emulator.attach")
2474
+ return REMOTE_RUNTIME_EMULATOR_ATTACH_TIMEOUT_MS;
2475
+ if (method === "emulator.type") {
2476
+ const text = params.text;
2477
+ return Math.min(REMOTE_RUNTIME_EMULATOR_ATTACH_TIMEOUT_MS, Math.max(REMOTE_RUNTIME_EMULATOR_CONTROL_TIMEOUT_MS, text.length * 40 + 5_000));
2478
+ }
2479
+ return REMOTE_RUNTIME_EMULATOR_CONTROL_TIMEOUT_MS;
2480
+ }
2481
+ function parseInteractionResolution(value) {
2482
+ if (!value || typeof value !== "object" || Array.isArray(value))
2483
+ return null;
2484
+ const input = value;
2485
+ if (Object.keys(input).some((key) => key !== "actionId" && key !== "answers"))
2486
+ return null;
2487
+ const actionId = asString(input.actionId);
2488
+ if (!actionId || actionId.length > 256)
2489
+ return null;
2490
+ if (input.answers === undefined)
2491
+ return { actionId };
2492
+ if (!input.answers || typeof input.answers !== "object" || Array.isArray(input.answers)) {
2493
+ return null;
2494
+ }
2495
+ const entries = Object.entries(input.answers);
2496
+ if (entries.length > 64)
2497
+ return null;
2498
+ const answers = {};
2499
+ for (const [fieldId, answer] of entries) {
2500
+ if (!fieldId || fieldId.length > 256)
2501
+ return null;
2502
+ if (typeof answer === "string") {
2503
+ if (answer.length > 65_536)
2504
+ return null;
2505
+ answers[fieldId] = answer;
2506
+ continue;
2507
+ }
2508
+ if (!Array.isArray(answer) ||
2509
+ answer.length > 64 ||
2510
+ answer.some((entry) => typeof entry !== "string" || entry.length > 65_536))
2511
+ return null;
2512
+ answers[fieldId] = [...answer];
2513
+ }
2514
+ const resolution = { actionId, answers };
2515
+ if (Buffer.byteLength(JSON.stringify(resolution), "utf8") > SESSION_INTERACTION_RESOLUTION_MAX_BYTES) {
2516
+ return null;
2517
+ }
2518
+ return resolution;
2519
+ }
2520
+ function safeTokenEquals(actual, expected) {
2521
+ const left = Buffer.from(actual, "utf8");
2522
+ const right = Buffer.from(expected, "utf8");
2523
+ return left.byteLength === right.byteLength && timingSafeEqual(left, right);
2524
+ }
2525
+ function optionalBoundedLabel(value) {
2526
+ if (value === undefined)
2527
+ return undefined;
2528
+ if (typeof value !== "string" ||
2529
+ value.length === 0 ||
2530
+ value.length > 128 ||
2531
+ value.trim() !== value ||
2532
+ /[\u0000-\u001f\u007f]/.test(value)) {
2533
+ throw new Error("clientLabel is invalid");
2534
+ }
2535
+ return value;
2536
+ }
2537
+ function optionalAdvertiseAddress(value) {
2538
+ if (value === undefined)
2539
+ return undefined;
2540
+ if (typeof value !== "string" ||
2541
+ value.length === 0 ||
2542
+ value.length > 2_048 ||
2543
+ value.trim() !== value ||
2544
+ /[\u0000-\u0020\u007f]/.test(value)) {
2545
+ throw new Error("address is invalid");
2546
+ }
2547
+ return value;
2548
+ }
2549
+ function isConfiguredDaemonManagement(value) {
2550
+ if (!value || typeof value.managementToken !== "string")
2551
+ return false;
2552
+ const bytes = Buffer.byteLength(value.managementToken, "utf8");
2553
+ return bytes >= 32 && bytes <= 256;
2554
+ }
2555
+ function pluginCliInvocationAbortScope(ctx, shutdownSignal) {
2556
+ const controller = new AbortController();
2557
+ const abort = (reason) => {
2558
+ if (!controller.signal.aborted)
2559
+ controller.abort(reason);
2560
+ };
2561
+ const onRequestAborted = () => abort(namedError("plugin CLI request aborted", "AbortError"));
2562
+ const onResponseClose = () => {
2563
+ if (!ctx.res.writableEnded)
2564
+ abort(namedError("plugin CLI client disconnected", "AbortError"));
2565
+ };
2566
+ const onShutdown = () => abort(shutdownSignal?.reason ?? namedError("daemon is shutting down", "AbortError"));
2567
+ const timeout = setTimeout(() => abort(namedError("plugin CLI invocation timed out", "TimeoutError")), PLUGIN_CLI_TIMEOUT_MS);
2568
+ timeout.unref?.();
2569
+ ctx.req.once("aborted", onRequestAborted);
2570
+ ctx.res.once("close", onResponseClose);
2571
+ if (shutdownSignal?.aborted)
2572
+ onShutdown();
2573
+ else
2574
+ shutdownSignal?.addEventListener("abort", onShutdown, { once: true });
2575
+ if (ctx.req.aborted)
2576
+ onRequestAborted();
2577
+ return {
2578
+ signal: controller.signal,
2579
+ dispose() {
2580
+ clearTimeout(timeout);
2581
+ ctx.req.removeListener("aborted", onRequestAborted);
2582
+ ctx.res.removeListener("close", onResponseClose);
2583
+ shutdownSignal?.removeEventListener("abort", onShutdown);
2584
+ },
2585
+ };
2586
+ }
2587
+ function namedError(message, name) {
2588
+ const error = new Error(message);
2589
+ error.name = name;
2590
+ return error;
2591
+ }
2592
+ function authorizeLoopbackManagementRequest(ctx, expectedToken) {
2593
+ if (!isLoopbackRequest(ctx)) {
2594
+ ctx.status = 403;
2595
+ ctx.body = { error: "daemon management is loopback-only" };
2596
+ return false;
2597
+ }
2598
+ if (!safeTokenEquals(ctx.get("x-rynx-management-token"), expectedToken)) {
2599
+ ctx.status = 401;
2600
+ ctx.body = { error: "invalid daemon management token" };
2601
+ return false;
2602
+ }
2603
+ return true;
2604
+ }
2605
+ function isLoopbackRequest(ctx) {
2606
+ const address = normalizeSocketAddress(ctx.req.socket.remoteAddress);
2607
+ return address === "127.0.0.1" || address === "::1";
2608
+ }
2609
+ function normalizeSocketAddress(value) {
2610
+ if (!value)
2611
+ return undefined;
2612
+ return value.startsWith("::ffff:") ? value.slice("::ffff:".length) : value;
2613
+ }
2614
+ function isRuntimeTargetSelector(value) {
2615
+ return typeof value === "string" && RUNTIME_TARGET_SELECTOR_PATTERN.test(value);
2616
+ }
2617
+ function isRuntimeDisplayName(value) {
2618
+ return (typeof value === "string" &&
2619
+ value.length > 0 &&
2620
+ value.length <= 128 &&
2621
+ value.trim() === value &&
2622
+ !/[\u0000-\u001f\u007f]/.test(value));
2623
+ }
2624
+ /** Whitelist only fields that are explicitly safe for the browser and local CLI. */
2625
+ function redactRuntimeTargetView(target) {
2626
+ if (!target ||
2627
+ !isRuntimeTargetSelector(target.selector) ||
2628
+ !isRuntimeTargetSelector(target.daemonId) ||
2629
+ !isRuntimeDisplayName(target.displayName) ||
2630
+ (target.binding !== "local" && target.binding !== "direct")) {
2631
+ throw new Error("Runtime target host returned an invalid target");
2632
+ }
2633
+ if (target.binding === "local") {
2634
+ if (target.selector !== "local" ||
2635
+ target.endpoint !== undefined ||
2636
+ target.identityKeyFingerprint !== undefined ||
2637
+ target.createdAt !== undefined) {
2638
+ throw new Error("Runtime target host returned an invalid local target binding");
2639
+ }
2640
+ }
2641
+ else if (target.selector !== target.daemonId ||
2642
+ target.selector === "local" ||
2643
+ target.endpoint === undefined ||
2644
+ target.identityKeyFingerprint === undefined ||
2645
+ target.createdAt === undefined) {
2646
+ throw new Error("Runtime target host returned an invalid direct target binding");
2647
+ }
2648
+ if (target.endpoint !== undefined && !isSafeRuntimeEndpoint(target.endpoint)) {
2649
+ throw new Error("Runtime target host returned an invalid endpoint");
2650
+ }
2651
+ if (target.identityKeyFingerprint !== undefined &&
2652
+ !/^SHA256:[A-Za-z0-9_-]{43}$/.test(target.identityKeyFingerprint)) {
2653
+ throw new Error("Runtime target host returned an invalid identity fingerprint");
2654
+ }
2655
+ if (target.createdAt !== undefined &&
2656
+ (!isBoundedPublicString(target.createdAt, 64) || !Number.isFinite(Date.parse(target.createdAt)))) {
2657
+ throw new Error("Runtime target host returned an invalid creation timestamp");
2658
+ }
2659
+ return {
2660
+ selector: target.selector,
2661
+ daemonId: target.daemonId,
2662
+ displayName: target.displayName,
2663
+ binding: target.binding,
2664
+ ...(target.endpoint === undefined ? {} : { endpoint: target.endpoint }),
2665
+ ...(target.identityKeyFingerprint === undefined
2666
+ ? {}
2667
+ : { identityKeyFingerprint: target.identityKeyFingerprint }),
2668
+ ...(target.createdAt === undefined ? {} : { createdAt: target.createdAt }),
2669
+ };
2670
+ }
2671
+ function redactRuntimeTargetTestResult(result, requestedSelector) {
2672
+ const target = redactRuntimeTargetView(result.target);
2673
+ const status = redactDaemonStatus(result.status);
2674
+ if (target.selector !== requestedSelector || target.daemonId !== status.daemonId) {
2675
+ throw new Error("Runtime target host returned a mismatched test result");
2676
+ }
2677
+ return { target, status };
2678
+ }
2679
+ function redactRuntimeTargetPairResult(result) {
2680
+ const target = redactRuntimeTargetView(result.target);
2681
+ const status = redactDaemonStatus(result.status);
2682
+ if (target.binding !== "direct" || target.daemonId !== status.daemonId) {
2683
+ throw new Error("Runtime target host returned a mismatched pairing result");
2684
+ }
2685
+ return { target, status };
2686
+ }
2687
+ function redactDaemonStatus(status) {
2688
+ return parseDaemonStatus(status);
2689
+ }
2690
+ function isSafeRuntimeEndpoint(value) {
2691
+ if (!isBoundedPublicString(value, 2_048))
2692
+ return false;
2693
+ try {
2694
+ const endpoint = new URL(value);
2695
+ return ((endpoint.protocol === "ws:" || endpoint.protocol === "wss:") &&
2696
+ Boolean(endpoint.hostname) &&
2697
+ !endpoint.username &&
2698
+ !endpoint.password &&
2699
+ !endpoint.search &&
2700
+ !endpoint.hash &&
2701
+ endpoint.pathname === DIRECT_RUNTIME_CONTROL_PATH);
2702
+ }
2703
+ catch {
2704
+ return false;
2705
+ }
2706
+ }
2707
+ function isBoundedPublicString(value, maxChars) {
2708
+ return (typeof value === "string" &&
2709
+ value.length > 0 &&
2710
+ value.length <= maxChars &&
2711
+ !/[\u0000-\u001f\u007f]/.test(value));
2712
+ }
2713
+ function runtimeTargetHostError(ctx, error, forcedOutcome) {
2714
+ const code = runtimeTargetErrorCode(error);
2715
+ const status = runtimeTargetHttpStatus(code);
2716
+ ctx.status = status;
2717
+ const outcome = forcedOutcome ?? runtimeTargetErrorOutcome(error);
2718
+ ctx.body = {
2719
+ error: status === 500 ? "internal_error" : code,
2720
+ ...(outcome === undefined ? {} : { outcome }),
2721
+ };
2722
+ }
2723
+ function runtimeLocalBrowserError(ctx, error) {
2724
+ const code = error && typeof error === "object" && "code" in error
2725
+ ? error.code
2726
+ : undefined;
2727
+ switch (code) {
2728
+ case "invalid_request":
2729
+ ctx.status = 400;
2730
+ ctx.body = { error: "invalid_request" };
2731
+ return;
2732
+ case "not_found":
2733
+ ctx.status = 404;
2734
+ ctx.body = { error: "not_found" };
2735
+ return;
2736
+ case "unavailable":
2737
+ case "shutting_down":
2738
+ ctx.status = 503;
2739
+ ctx.body = { error: "unavailable" };
2740
+ return;
2741
+ case "host_failure":
2742
+ case "outcome_unknown":
2743
+ ctx.status = 502;
2744
+ ctx.body = { error: "operation_failed" };
2745
+ return;
2746
+ default:
2747
+ ctx.status = 500;
2748
+ ctx.body = { error: "internal_error" };
2749
+ }
2750
+ }
2751
+ function runtimeTargetHttpStatus(code) {
2752
+ switch (code) {
2753
+ case "invalid_offer":
2754
+ case "invalid_target":
2755
+ case "invalid_request":
2756
+ return 400;
2757
+ case "not_found":
2758
+ return 404;
2759
+ case "conflict":
2760
+ case "failed_precondition":
2761
+ return 409;
2762
+ case "method_not_found":
2763
+ return 501;
2764
+ case "deadline_exceeded":
2765
+ return 504;
2766
+ case "unreachable":
2767
+ case "tls_error":
2768
+ case "identity_mismatch":
2769
+ case "incompatible":
2770
+ case "authentication_failed":
2771
+ case "protocol_error":
2772
+ case "operation_failed":
2773
+ case "outcome_unknown":
2774
+ case "unauthorized":
2775
+ case "closed":
2776
+ case "internal":
2777
+ return 502;
2778
+ default:
2779
+ return 500;
2780
+ }
2781
+ }
2782
+ function runtimeTargetErrorCode(error) {
2783
+ if (!error || typeof error !== "object" || !("code" in error))
2784
+ return undefined;
2785
+ return typeof error.code === "string" ? error.code : undefined;
2786
+ }
2787
+ function runtimeTargetErrorOutcome(error) {
2788
+ if (!error || typeof error !== "object" || !("outcome" in error))
2789
+ return undefined;
2790
+ return error.outcome === "not_started" || error.outcome === "unknown"
2791
+ ? error.outcome
2792
+ : undefined;
2793
+ }
2794
+ function runtimeProtocolError(message) {
2795
+ return Object.assign(new Error(message), { code: "protocol_error" });
2796
+ }
2797
+ function runtimeCapacityError(message) {
2798
+ return Object.assign(new Error(message), { code: "operation_failed" });
2799
+ }
2800
+ function publicRuntimeStreamError(error) {
2801
+ return runtimeTargetErrorCode(error) ?? "internal_error";
2802
+ }
2803
+ class RequestBodyTooLargeError extends Error {
2804
+ }
2805
+ async function readBoundedJson(ctx, maxBytes) {
2806
+ const declaredLength = Number(ctx.get("content-length"));
2807
+ if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
2808
+ throw new RequestBodyTooLargeError(`request body exceeds ${maxBytes} bytes`);
2809
+ }
2810
+ const chunks = [];
2811
+ let bytes = 0;
2812
+ let tooLarge = false;
2813
+ await new Promise((resolve, reject) => {
2814
+ ctx.req.on("data", (chunk) => {
2815
+ const data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
2816
+ bytes += data.byteLength;
2817
+ if (bytes > maxBytes) {
2818
+ tooLarge = true;
2819
+ chunks.length = 0;
2820
+ return;
2821
+ }
2822
+ if (!tooLarge)
2823
+ chunks.push(data);
2824
+ });
2825
+ ctx.req.once("end", resolve);
2826
+ ctx.req.once("error", reject);
2827
+ });
2828
+ if (tooLarge)
2829
+ throw new RequestBodyTooLargeError(`request body exceeds ${maxBytes} bytes`);
2830
+ if (bytes === 0)
2831
+ throw new Error("request body is required");
2832
+ let parsed;
2833
+ try {
2834
+ parsed = JSON.parse(Buffer.concat(chunks, bytes).toString("utf8"));
2835
+ }
2836
+ catch {
2837
+ throw new Error("request body must be valid JSON");
2838
+ }
2839
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
2840
+ throw new Error("request body must be a JSON object");
2841
+ }
2842
+ return parsed;
2843
+ }
2844
+ function parsePluginCliInvocation(pluginId, body) {
2845
+ const unknownFields = Object.keys(body).filter((key) => key !== "args" && key !== "stdin");
2846
+ if (unknownFields.length > 0)
2847
+ return "request body contains unknown fields";
2848
+ if (!Array.isArray(body.args) || body.args.length === 0)
2849
+ return "args must be a non-empty array";
2850
+ if (body.args.length > PLUGIN_CLI_ARGS_MAX_COUNT) {
2851
+ return `args exceeds ${PLUGIN_CLI_ARGS_MAX_COUNT} entries`;
2852
+ }
2853
+ let totalBytes = 0;
2854
+ const args = [];
2855
+ for (const value of body.args) {
2856
+ if (typeof value !== "string" || value.includes("\0")) {
2857
+ return "every arg must be a string without NUL bytes";
2858
+ }
2859
+ const bytes = Buffer.byteLength(value, "utf8");
2860
+ if (bytes > PLUGIN_CLI_ARG_MAX_BYTES) {
2861
+ return `one arg exceeds ${PLUGIN_CLI_ARG_MAX_BYTES} bytes`;
2862
+ }
2863
+ totalBytes += bytes;
2864
+ if (totalBytes > PLUGIN_CLI_ARGS_TOTAL_MAX_BYTES) {
2865
+ return `args exceed ${PLUGIN_CLI_ARGS_TOTAL_MAX_BYTES} bytes`;
2866
+ }
2867
+ args.push(value);
2868
+ }
2869
+ if (body.stdin !== undefined && typeof body.stdin !== "string")
2870
+ return "stdin must be a string";
2871
+ if (typeof body.stdin === "string" && Buffer.byteLength(body.stdin, "utf8") > PLUGIN_CLI_STDIN_MAX_BYTES) {
2872
+ return `stdin exceeds ${PLUGIN_CLI_STDIN_MAX_BYTES} bytes`;
2873
+ }
2874
+ return {
2875
+ pluginId,
2876
+ args,
2877
+ ...(typeof body.stdin === "string" ? { stdin: body.stdin } : {}),
2878
+ };
2879
+ }
2880
+ function boundedUtf8(value, maxBytes) {
2881
+ if (Buffer.byteLength(value, "utf8") <= maxBytes)
2882
+ return value;
2883
+ const chars = [];
2884
+ let bytes = 0;
2885
+ for (const char of value) {
2886
+ const next = Buffer.byteLength(char, "utf8");
2887
+ if (bytes + next > maxBytes)
2888
+ break;
2889
+ chars.push(char);
2890
+ bytes += next;
2891
+ }
2892
+ return chars.join("");
2893
+ }
813
2894
  function asNumber(value) {
814
2895
  if (typeof value === "number" && Number.isFinite(value))
815
2896
  return value;