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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/browser-surface-ws.d.ts +2 -0
  2. package/dist/browser-surface-ws.js +1 -0
  3. package/dist/control-api.d.ts +17 -0
  4. package/dist/control-api.js +809 -9
  5. package/dist/control-web/assets/{highlighted-body-OFNGDK62-e-w61YLy.js → highlighted-body-OFNGDK62-DHu2g-rk.js} +1 -1
  6. package/dist/control-web/assets/index-B6Pb5j9M.js +666 -0
  7. package/dist/control-web/assets/index-BU-_Qud_.css +32 -0
  8. package/dist/control-web/assets/{mermaid-GHXKKRXX-DZn7Hbr2.js → mermaid-GHXKKRXX-CSYzOmBR.js} +3 -3
  9. package/dist/control-web/index.html +2 -2
  10. package/dist/desktop-browser-host.d.ts +20 -3
  11. package/dist/desktop-browser-host.js +126 -44
  12. package/dist/direct-runtime-browser-inspect-server.d.ts +72 -0
  13. package/dist/direct-runtime-browser-inspect-server.js +749 -0
  14. package/dist/direct-runtime-server.d.ts +6 -0
  15. package/dist/direct-runtime-server.js +29 -1
  16. package/dist/emulator-surface-ws.d.ts +2 -0
  17. package/dist/emulator-surface-ws.js +1 -0
  18. package/dist/machine-session-service.d.ts +88 -5
  19. package/dist/machine-session-service.js +283 -20
  20. package/dist/remote-runtime-dispatcher.d.ts +1 -1
  21. package/dist/remote-runtime-dispatcher.js +25 -2
  22. package/dist/remote-runtime-session-projection.js +10 -0
  23. package/dist/runtime-web-auth.d.ts +5 -0
  24. package/dist/runtime-web-auth.js +43 -3
  25. package/dist/server.d.ts +78 -8
  26. package/dist/server.js +42 -17
  27. package/dist/session-browser-service.d.ts +30 -0
  28. package/dist/session-browser-service.js +89 -3
  29. package/dist/session-browser-surface-coordinator.d.ts +4 -3
  30. package/dist/session-browser-surface-coordinator.js +374 -268
  31. package/dist/session-runtime-index.d.ts +6 -0
  32. package/dist/session-runtime-index.js +10 -0
  33. package/dist/session-terminal-host.d.ts +6 -1
  34. package/dist/session-terminal-host.js +18 -0
  35. package/dist/terminal-ws.d.ts +3 -0
  36. package/dist/terminal-ws.js +1 -0
  37. package/package.json +12 -7
  38. package/dist/control-web/assets/index-BQ7XVBKO.css +0 -32
  39. package/dist/control-web/assets/index-ClpzuBJx.js +0 -606
@@ -19,9 +19,11 @@
19
19
  import { timingSafeEqual } from "node:crypto";
20
20
  import Router from "@koa/router";
21
21
  import { agentSpecSchema, newSessionId, skillInstallRecipeSchema, } from "@rynx-ai/core";
22
+ import { DAEMON_BROWSER_ARTIFACT_CLEAN_PATH, DAEMON_BROWSER_ARTIFACT_INSTALL_PATH, DAEMON_BROWSER_ARTIFACT_UPDATE_PATH, DAEMON_BROWSER_ARTIFACT_VERSION_PATH, DAEMON_CHROME_INSPECTION_PATH, DAEMON_CLEANUP_SESSIONS_PATH, DAEMON_SHUTDOWN_IF_IDLE_PATH, parseDaemonBrowserArtifactInstallInput, parseDaemonBrowserArtifactUpdateInput, parseDaemonChromeInspectionConfigureInput, parseDaemonCleanupSessionsInput, } from "@rynx-ai/protocol/control";
22
23
  import { DIRECT_RUNTIME_CONTROL_PATH, encodePairingCode, } from "@rynx-ai/protocol/direct-runtime";
24
+ import { PLUGIN_INSTALL_PREPARATIONS_PATH, parsePluginInstallCommitInput, parsePluginInstallPrepareInput, } from "@rynx-ai/protocol/plugin-management";
23
25
  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";
26
+ import { materializeRemoteRuntimeSessionEvent, materializeRemoteRuntimeSessionRuntimeSnapshot, parseRemoteRuntimeRpcRequest, REMOTE_RUNTIME_RPC_MAX_FRAME_BYTES, SESSION_IMAGE_MEDIA_TYPES, SESSION_RESOURCE_MAX_IMAGE_BYTES, SESSION_RESOURCE_MAX_FILENAME_CHARS, SESSION_RESOURCE_TRANSFER_CHUNK_BYTES, REMOTE_RUNTIME_SESSION_MAX_PAGE_SIZE, } from "@rynx-ai/protocol/remote-runtime-rpc";
25
27
  import { RUNTIME_BROWSER_BOOTSTRAP_PATH, RUNTIME_BROWSER_CAPABILITY_HEADER, RUNTIME_BROWSER_MANAGEMENT_PATH_PREFIX, RUNTIME_BROWSER_RPC_PATH, RUNTIME_BROWSER_SESSION_ID_HEADER, parseRuntimeBrowserBootstrapCredential, } from "@rynx-ai/protocol/runtime-browser-bootstrap";
26
28
  import { parseRuntimeBrowserStateGetParams } from "@rynx-ai/protocol/runtime-browser";
27
29
  import { ensureCodexResumeRollout } from "@rynx-ai/runtime";
@@ -35,6 +37,15 @@ const PLUGIN_CLI_ARGS_TOTAL_MAX_BYTES = 32 * 1024;
35
37
  const PLUGIN_CLI_ARGS_MAX_COUNT = 64;
36
38
  const PLUGIN_CLI_ERROR_MAX_BYTES = 16 * 1024;
37
39
  const PLUGIN_CLI_TIMEOUT_MS = 120_000;
40
+ const PLUGIN_INSTALL_BODY_MAX_BYTES = 16 * 1024;
41
+ const PLUGIN_INSTALL_OPERATION_TIMEOUT_MS = 15 * 60_000;
42
+ const PLUGIN_INSTALL_ERROR_MAX_BYTES = 16 * 1024;
43
+ const BROWSER_ARTIFACT_BODY_MAX_BYTES = 4 * 1024;
44
+ const BROWSER_ARTIFACT_OPERATION_TIMEOUT_MS = 15 * 60_000;
45
+ const BROWSER_ARTIFACT_ERROR_MAX_BYTES = 16 * 1024;
46
+ const CHROME_INSPECTION_BODY_MAX_BYTES = 16 * 1024;
47
+ const CHROME_INSPECTION_ERROR_MAX_BYTES = 4 * 1024;
48
+ const CLEANUP_SESSIONS_BODY_MAX_BYTES = 1 * 1024;
38
49
  const REMOTE_RUNTIME_ADMIN_BODY_MAX_BYTES = 4 * 1024;
39
50
  const RUNTIME_TARGET_PAIR_BODY_MAX_BYTES = 64 * 1024;
40
51
  const REMOTE_RUNTIME_EMULATOR_CONTROL_TIMEOUT_MS = 30_000;
@@ -63,8 +74,69 @@ const RUNTIME_BROWSER_RPC_METHODS = new Set([
63
74
  "browser.page.reload",
64
75
  ]);
65
76
  export function createControlRouter(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;
77
+ const { manager, deps, runtime, sessionBus, sessionLog, runnerManager, sessionStore, pluginRuntimeStatus, pluginRuntimeReload, pluginRuntimeEnsure, daemonManagement, remoteRuntime, remoteRuntimeAdmin, runtimeTargetControl, runtimeWebAccess, runtimeConnectionResolver, localSessionResources, sessionEmulators, shutdownSignal, sessionRuntimeIndex = new SessionRuntimeIndex(), sessionLifecycle, runtimeLocalBrowser, providerClis, } = opts;
67
78
  const router = new Router();
79
+ const mutateBrowserArtifact = async (ctx, operation) => {
80
+ const host = daemonManagement?.browserArtifacts;
81
+ if (!host || !isConfiguredDaemonManagement(daemonManagement)) {
82
+ return notFound(ctx, "Browser artifact management is not configured");
83
+ }
84
+ if (!authorizeLoopbackManagementRequest(ctx, daemonManagement.managementToken))
85
+ return;
86
+ if (!ctx.is("application/json")) {
87
+ ctx.status = 415;
88
+ ctx.body = { error: `Browser ${operation} requires application/json` };
89
+ return;
90
+ }
91
+ let body;
92
+ try {
93
+ body = await readBoundedJson(ctx, BROWSER_ARTIFACT_BODY_MAX_BYTES);
94
+ }
95
+ catch (error) {
96
+ if (error instanceof RequestBodyTooLargeError) {
97
+ ctx.status = 413;
98
+ ctx.body = { error: error.message };
99
+ return;
100
+ }
101
+ return bad(ctx, error instanceof Error ? error.message : String(error));
102
+ }
103
+ let installInput;
104
+ let updateInput;
105
+ try {
106
+ if (operation === "install") {
107
+ installInput = parseDaemonBrowserArtifactInstallInput(body);
108
+ }
109
+ else {
110
+ updateInput = parseDaemonBrowserArtifactUpdateInput(body);
111
+ }
112
+ }
113
+ catch (error) {
114
+ return bad(ctx, error instanceof Error ? error.message : String(error));
115
+ }
116
+ const invocation = browserArtifactInvocationAbortScope(ctx, shutdownSignal);
117
+ try {
118
+ ctx.set("Cache-Control", "no-store");
119
+ ctx.body = operation === "install"
120
+ ? await host.install(installInput, { signal: invocation.signal })
121
+ : await host.update(updateInput, { signal: invocation.signal });
122
+ }
123
+ catch (error) {
124
+ if (!ctx.res.destroyed) {
125
+ ctx.status = invocation.signal.aborted
126
+ ? invocation.signal.reason instanceof Error &&
127
+ invocation.signal.reason.name === "TimeoutError"
128
+ ? 504
129
+ : 499
130
+ : 409;
131
+ ctx.body = {
132
+ error: boundedUtf8(error instanceof Error ? error.message : String(error), BROWSER_ARTIFACT_ERROR_MAX_BYTES),
133
+ };
134
+ }
135
+ }
136
+ finally {
137
+ invocation.dispose();
138
+ }
139
+ };
68
140
  /** The instance (if any) bound to a channel — used to reload after a config change. */
69
141
  const instanceForChannel = (channelId) => deps.listInstancesConfig().find((i) => i.channelId === channelId);
70
142
  const listRuntimeTargets = async (ctx) => {
@@ -103,7 +175,10 @@ export function createControlRouter(opts) {
103
175
  return;
104
176
  }
105
177
  if (!Object.hasOwn(body, "offer") ||
106
- Object.keys(body).some((key) => key !== "offer" && key !== "displayName")) {
178
+ Object.keys(body).some((key) => key !== "offer" &&
179
+ key !== "displayName" &&
180
+ key !== "replaceExisting" &&
181
+ key !== "expectedDaemonId")) {
107
182
  ctx.status = 400;
108
183
  ctx.body = { error: "invalid_offer" };
109
184
  return;
@@ -114,10 +189,28 @@ export function createControlRouter(opts) {
114
189
  ctx.body = { error: "invalid_target" };
115
190
  return;
116
191
  }
192
+ const replaceExisting = body.replaceExisting;
193
+ if (replaceExisting !== undefined && typeof replaceExisting !== "boolean") {
194
+ ctx.status = 400;
195
+ ctx.body = { error: "invalid_target" };
196
+ return;
197
+ }
198
+ const expectedDaemonId = body.expectedDaemonId;
199
+ if (replaceExisting === true
200
+ ? !isRuntimeTargetSelector(expectedDaemonId) || expectedDaemonId === "local"
201
+ : expectedDaemonId !== undefined) {
202
+ ctx.status = 400;
203
+ ctx.body = { error: "invalid_target" };
204
+ return;
205
+ }
117
206
  try {
118
207
  const result = await runtimeTargetControl.pair({
119
208
  offer: body.offer,
120
209
  ...(displayName === undefined ? {} : { displayName }),
210
+ ...(replaceExisting === undefined ? {} : { replaceExisting }),
211
+ ...(replaceExisting === true
212
+ ? { expectedDaemonId: expectedDaemonId }
213
+ : {}),
121
214
  });
122
215
  ctx.set("Cache-Control", "no-store");
123
216
  ctx.body = redactRuntimeTargetPairResult(result);
@@ -245,11 +338,32 @@ export function createControlRouter(opts) {
245
338
  ctx.body = { error: error instanceof Error ? error.message : String(error) };
246
339
  }
247
340
  };
341
+ const deleteRevokedRuntimeAccess = (ctx) => {
342
+ ctx.set("Cache-Control", "no-store");
343
+ if (!remoteRuntimeAdmin) {
344
+ return notFound(ctx, "Remote Runtime administration is not configured");
345
+ }
346
+ if (!/^[A-Za-z0-9][A-Za-z0-9._~-]{0,255}$/.test(ctx.params.grantId)) {
347
+ return bad(ctx, "invalid Remote Runtime grant id");
348
+ }
349
+ try {
350
+ const result = remoteRuntimeAdmin.deleteRevokedClientGrant(ctx.params.grantId);
351
+ if (!result.deleted) {
352
+ return notFound(ctx, "Remote Runtime client grant not found or still active");
353
+ }
354
+ ctx.body = result;
355
+ }
356
+ catch (error) {
357
+ ctx.status = 409;
358
+ ctx.body = { error: error instanceof Error ? error.message : String(error) };
359
+ }
360
+ };
248
361
  const authorizePublicRuntimeTargetRequest = (ctx, options = {}) => {
249
362
  ctx.set("Cache-Control", "no-store");
250
363
  const failure = authorizeRuntimeWebRequest(ctx.req, {
251
364
  mutation: options.mutation,
252
365
  capability: ctx.get(RUNTIME_WEB_CAPABILITY_HEADER),
366
+ access: runtimeWebAccess,
253
367
  });
254
368
  if (!failure)
255
369
  return true;
@@ -306,6 +420,11 @@ export function createControlRouter(opts) {
306
420
  revokeRuntimeAccess(ctx);
307
421
  }
308
422
  });
423
+ router.delete("/api/runtime-server/access/:grantId/record", (ctx) => {
424
+ if (authorizePublicRuntimeTargetRequest(ctx, { mutation: true })) {
425
+ deleteRevokedRuntimeAccess(ctx);
426
+ }
427
+ });
309
428
  router.get("/api/internal/runtimes", async (ctx) => {
310
429
  if (authorizeInternalRuntimeTargetRequest(ctx))
311
430
  await listRuntimeTargets(ctx);
@@ -361,7 +480,9 @@ export function createControlRouter(opts) {
361
480
  }
362
481
  let lease;
363
482
  try {
364
- lease = await runtimeConnectionResolver.acquire(selector);
483
+ lease = shutdownSignal
484
+ ? await runtimeConnectionResolver.acquire(selector, { signal: shutdownSignal })
485
+ : await runtimeConnectionResolver.acquire(selector);
365
486
  if (lease.target.selector !== selector ||
366
487
  !isRuntimeTargetSelector(lease.target.daemonId)) {
367
488
  throw new Error("Runtime resolver returned a mismatched target");
@@ -378,6 +499,7 @@ export function createControlRouter(opts) {
378
499
  }
379
500
  try {
380
501
  const result = await lease.call(method, validatedParams, {
502
+ ...(shutdownSignal ? { signal: shutdownSignal } : {}),
381
503
  timeoutMs: remoteRuntimeRpcTimeoutMs(method, validatedParams),
382
504
  });
383
505
  ctx.set("Cache-Control", "no-store");
@@ -594,9 +716,309 @@ export function createControlRouter(opts) {
594
716
  const body = await readRuntimeBody(ctx);
595
717
  if (!body)
596
718
  return;
597
- if (!acceptRuntimeBodyKeys(ctx, body, ["message"]))
719
+ if (!acceptRuntimeBodyKeys(ctx, body, ["message", "content", "clientMessageId"]))
598
720
  return;
599
721
  const outcome = await runtimeCall(ctx, "session.message.send", {
722
+ sessionId: ctx.params.id,
723
+ ...(body.message === undefined ? {} : { message: body.message }),
724
+ ...(body.content === undefined
725
+ ? {}
726
+ : {
727
+ content: body.content,
728
+ }),
729
+ ...(body.clientMessageId === undefined
730
+ ? {}
731
+ : { clientMessageId: body.clientMessageId }),
732
+ });
733
+ if (outcome.ok)
734
+ ctx.body = outcome.result;
735
+ };
736
+ const runtimeSessionResourcePolicy = async (ctx) => {
737
+ if (ctx.params.selector === "local" && localSessionResources) {
738
+ try {
739
+ ctx.set("Cache-Control", "no-store");
740
+ ctx.body = await localSessionResources.resourcePolicy(ctx.params.id);
741
+ }
742
+ catch (error) {
743
+ runtimeTargetHostError(ctx, error);
744
+ }
745
+ return;
746
+ }
747
+ const outcome = await runtimeCall(ctx, "session.resource.policy.get", {
748
+ sessionId: ctx.params.id,
749
+ });
750
+ if (outcome.ok)
751
+ ctx.body = outcome.result;
752
+ };
753
+ const runtimeSessionResourceUpload = async (ctx) => {
754
+ const uploadStarted = performance.now();
755
+ const mediaType = ctx.get("content-type").split(";", 1)[0]?.trim();
756
+ const clientUploadId = ctx.get("x-rynx-upload-id").trim();
757
+ const encodedFilename = ctx.get("x-rynx-filename").trim();
758
+ let filename = "";
759
+ try {
760
+ filename = decodeURIComponent(encodedFilename);
761
+ }
762
+ catch {
763
+ ctx.status = 400;
764
+ ctx.body = { error: "invalid_filename" };
765
+ return;
766
+ }
767
+ if (!mediaType ||
768
+ !SESSION_IMAGE_MEDIA_TYPES.includes(mediaType) ||
769
+ !clientUploadId ||
770
+ !filename ||
771
+ filename.length > SESSION_RESOURCE_MAX_FILENAME_CHARS) {
772
+ ctx.status = 400;
773
+ ctx.body = { error: "invalid_resource_upload" };
774
+ return;
775
+ }
776
+ let bytes;
777
+ const bodyReadStarted = performance.now();
778
+ try {
779
+ bytes = await readBoundedBinary(ctx, SESSION_RESOURCE_MAX_IMAGE_BYTES);
780
+ }
781
+ catch (error) {
782
+ ctx.status = error instanceof RequestBodyTooLargeError ? 413 : 400;
783
+ ctx.body = {
784
+ error: error instanceof RequestBodyTooLargeError
785
+ ? "request_too_large"
786
+ : "invalid_resource_upload",
787
+ };
788
+ return;
789
+ }
790
+ if (bytes.byteLength === 0) {
791
+ ctx.status = 400;
792
+ ctx.body = { error: "empty_resource" };
793
+ return;
794
+ }
795
+ const sessionId = ctx.params.id;
796
+ const local = ctx.params.selector === "local" ? localSessionResources : undefined;
797
+ const lease = local ? undefined : await acquireRuntimeLease(ctx);
798
+ if (!local && !lease)
799
+ return;
800
+ const timings = new Map([
801
+ ["body", performance.now() - bodyReadStarted],
802
+ ]);
803
+ const timed = async (name, operation) => {
804
+ const started = performance.now();
805
+ try {
806
+ return await operation();
807
+ }
808
+ finally {
809
+ timings.set(name, (timings.get(name) ?? 0) + performance.now() - started);
810
+ }
811
+ };
812
+ const policy = () => timed("policy", () => local
813
+ ? local.resourcePolicy(sessionId)
814
+ : lease.call("session.resource.policy.get", { sessionId }, { timeoutMs: REMOTE_RUNTIME_SESSION_TIMEOUT_MS }));
815
+ const begin = () => timed("begin", () => local
816
+ ? local.beginResourceUpload({
817
+ sessionId,
818
+ clientUploadId,
819
+ filename,
820
+ mediaType,
821
+ byteLength: bytes.byteLength,
822
+ })
823
+ : lease.call("session.resource.upload.begin", {
824
+ sessionId,
825
+ clientUploadId,
826
+ filename,
827
+ mediaType,
828
+ byteLength: bytes.byteLength,
829
+ }, { timeoutMs: REMOTE_RUNTIME_SESSION_TIMEOUT_MS }));
830
+ const writeChunk = (uploadId, offset, data) => timed("chunks", () => local
831
+ ? local.writeResourceUploadChunk({ sessionId, uploadId, offset, data })
832
+ : lease.call("session.resource.upload.chunk", { sessionId, uploadId, offset, data }, { timeoutMs: REMOTE_RUNTIME_SESSION_TIMEOUT_MS }));
833
+ const commit = (uploadId) => timed("commit", () => local
834
+ ? local.commitResourceUpload({ sessionId, uploadId })
835
+ : lease.call("session.resource.upload.commit", { sessionId, uploadId }, { timeoutMs: REMOTE_RUNTIME_SESSION_TIMEOUT_MS }));
836
+ try {
837
+ const resourcePolicy = await policy();
838
+ if (!resourcePolicy.enabled ||
839
+ !resourcePolicy.mediaTypes.includes(mediaType) ||
840
+ bytes.byteLength > resourcePolicy.maxImageBytes) {
841
+ ctx.status = 413;
842
+ ctx.body = { error: "resource_policy_rejected" };
843
+ return;
844
+ }
845
+ const begun = await begin();
846
+ if (begun.complete && begun.resource) {
847
+ ctx.set("Cache-Control", "no-store");
848
+ ctx.body = { resource: begun.resource };
849
+ return;
850
+ }
851
+ if (begun.offset < 0 || begun.offset > bytes.byteLength) {
852
+ throw runtimeProtocolError("Runtime Session resource upload offset is invalid");
853
+ }
854
+ let offset = begun.offset;
855
+ while (offset < bytes.byteLength) {
856
+ const chunk = bytes.subarray(offset, Math.min(offset + SESSION_RESOURCE_TRANSFER_CHUNK_BYTES, bytes.byteLength));
857
+ const written = await writeChunk(begun.uploadId, offset, chunk.toString("base64"));
858
+ if (written.offset <= offset || written.offset > bytes.byteLength) {
859
+ throw runtimeProtocolError("Runtime Session resource upload did not advance");
860
+ }
861
+ offset = written.offset;
862
+ }
863
+ const committed = await commit(begun.uploadId);
864
+ if (committed.resource.resourceId !== begun.resourceId ||
865
+ committed.resource.mediaType !== mediaType ||
866
+ committed.resource.byteLength !== bytes.byteLength) {
867
+ throw runtimeProtocolError("Runtime Session resource upload identity changed");
868
+ }
869
+ ctx.status = 201;
870
+ ctx.set("Cache-Control", "no-store");
871
+ ctx.body = committed;
872
+ }
873
+ catch (error) {
874
+ runtimeTargetHostError(ctx, error);
875
+ }
876
+ finally {
877
+ lease?.release();
878
+ const totalDuration = performance.now() - uploadStarted;
879
+ timings.set("total", totalDuration);
880
+ ctx.set("Server-Timing", [...timings.entries()]
881
+ .map(([name, duration]) => `${name};dur=${duration.toFixed(1)}`)
882
+ .join(", "));
883
+ if (totalDuration >= 1_000) {
884
+ console.warn(JSON.stringify({
885
+ level: "warn",
886
+ event: "session.resource.upload.slow",
887
+ selector: ctx.params.selector,
888
+ sessionId,
889
+ byteLength: bytes.byteLength,
890
+ status: ctx.status,
891
+ timings: Object.fromEntries([...timings.entries()].map(([name, duration]) => [
892
+ name,
893
+ Math.round(duration),
894
+ ])),
895
+ }));
896
+ }
897
+ }
898
+ };
899
+ const runtimeSessionResourceMetadata = async (ctx) => {
900
+ if (ctx.params.selector === "local" && localSessionResources) {
901
+ try {
902
+ const result = await localSessionResources.getResource({
903
+ sessionId: ctx.params.id,
904
+ resourceId: ctx.params.resourceId,
905
+ });
906
+ if (result.resource.resourceId !== ctx.params.resourceId) {
907
+ throw runtimeProtocolError("Runtime Session resource identity changed");
908
+ }
909
+ ctx.set("Cache-Control", "no-store");
910
+ ctx.body = result;
911
+ }
912
+ catch (error) {
913
+ runtimeTargetHostError(ctx, error);
914
+ }
915
+ return;
916
+ }
917
+ const outcome = await runtimeCall(ctx, "session.resource.get", {
918
+ sessionId: ctx.params.id,
919
+ resourceId: ctx.params.resourceId,
920
+ });
921
+ if (!outcome.ok)
922
+ return;
923
+ if (outcome.result.resource.resourceId !== ctx.params.resourceId) {
924
+ runtimeTargetHostError(ctx, runtimeProtocolError("Runtime Session resource identity changed"));
925
+ return;
926
+ }
927
+ ctx.body = outcome.result;
928
+ };
929
+ const runtimeSessionResourceContent = async (ctx) => {
930
+ const sessionId = ctx.params.id;
931
+ const resourceId = ctx.params.resourceId;
932
+ const local = ctx.params.selector === "local" ? localSessionResources : undefined;
933
+ const lease = local ? undefined : await acquireRuntimeLease(ctx);
934
+ if (!local && !lease)
935
+ return;
936
+ try {
937
+ const { resource } = local
938
+ ? await local.getResource({ sessionId, resourceId })
939
+ : await lease.call("session.resource.get", { sessionId, resourceId }, { timeoutMs: REMOTE_RUNTIME_SESSION_TIMEOUT_MS });
940
+ if (resource.resourceId !== resourceId) {
941
+ throw runtimeProtocolError("Runtime Session resource identity changed");
942
+ }
943
+ const etag = `"sha256-${resource.sha256}"`;
944
+ ctx.set("Cache-Control", "private, max-age=0, must-revalidate");
945
+ ctx.set("ETag", etag);
946
+ if (ctx.get("if-none-match").split(",").map((value) => value.trim()).includes(etag)) {
947
+ ctx.status = 304;
948
+ return;
949
+ }
950
+ const chunks = [];
951
+ let offset = 0;
952
+ while (offset < (resource.byteLength ?? 0)) {
953
+ const result = local
954
+ ? await local.readResource({
955
+ sessionId,
956
+ resourceId,
957
+ offset,
958
+ length: SESSION_RESOURCE_TRANSFER_CHUNK_BYTES,
959
+ })
960
+ : await lease.call("session.resource.read", {
961
+ sessionId,
962
+ resourceId,
963
+ offset,
964
+ length: SESSION_RESOURCE_TRANSFER_CHUNK_BYTES,
965
+ }, { timeoutMs: REMOTE_RUNTIME_SESSION_TIMEOUT_MS });
966
+ const chunk = Buffer.from(result.data, "base64");
967
+ if (result.offset !== offset + chunk.byteLength || (!result.eof && chunk.byteLength === 0)) {
968
+ throw runtimeProtocolError("Runtime Session resource read did not advance");
969
+ }
970
+ chunks.push(chunk);
971
+ offset = result.offset;
972
+ if (result.eof)
973
+ break;
974
+ }
975
+ const body = Buffer.concat(chunks);
976
+ if (body.byteLength !== resource.byteLength) {
977
+ throw runtimeProtocolError("Runtime Session resource length changed");
978
+ }
979
+ ctx.set("Content-Security-Policy", "default-src 'none'; sandbox");
980
+ ctx.set("Cross-Origin-Resource-Policy", "same-origin");
981
+ ctx.set("X-Content-Type-Options", "nosniff");
982
+ ctx.set("Content-Disposition", "inline");
983
+ ctx.type = resource.mediaType;
984
+ ctx.length = body.byteLength;
985
+ ctx.body = body;
986
+ }
987
+ catch (error) {
988
+ runtimeTargetHostError(ctx, error);
989
+ }
990
+ finally {
991
+ lease?.release();
992
+ }
993
+ };
994
+ const runtimeSessionResourceDelete = async (ctx) => {
995
+ if (ctx.params.selector === "local" && localSessionResources) {
996
+ try {
997
+ ctx.set("Cache-Control", "no-store");
998
+ ctx.body = await localSessionResources.deleteResource({
999
+ sessionId: ctx.params.id,
1000
+ resourceId: ctx.params.resourceId,
1001
+ });
1002
+ }
1003
+ catch (error) {
1004
+ runtimeTargetHostError(ctx, error);
1005
+ }
1006
+ return;
1007
+ }
1008
+ const outcome = await runtimeCall(ctx, "session.resource.delete", {
1009
+ sessionId: ctx.params.id,
1010
+ resourceId: ctx.params.resourceId,
1011
+ });
1012
+ if (outcome.ok)
1013
+ ctx.body = outcome.result;
1014
+ };
1015
+ const runtimeSessionMessageEnqueue = async (ctx) => {
1016
+ const body = await readRuntimeBody(ctx);
1017
+ if (!body)
1018
+ return;
1019
+ if (!acceptRuntimeBodyKeys(ctx, body, ["message"]))
1020
+ return;
1021
+ const outcome = await runtimeCall(ctx, "session.message.enqueue", {
600
1022
  sessionId: ctx.params.id,
601
1023
  message: body.message,
602
1024
  });
@@ -1208,6 +1630,12 @@ export function createControlRouter(opts) {
1208
1630
  router.get("/api/runtimes/:selector/sessions/:id/stream", publicRuntimeRead(runtimeSessionStream));
1209
1631
  router.post("/api/runtimes/:selector/sessions/:id/interrupt", publicRuntimeMutation(runtimeSessionInterrupt));
1210
1632
  router.post("/api/runtimes/:selector/sessions/:id/messages", publicRuntimeMutation(runtimeSessionMessage));
1633
+ router.get("/api/runtimes/:selector/sessions/:id/resources/policy", publicRuntimeRead(runtimeSessionResourcePolicy));
1634
+ router.post("/api/runtimes/:selector/sessions/:id/resources", publicRuntimeMutation(runtimeSessionResourceUpload));
1635
+ router.get("/api/runtimes/:selector/sessions/:id/resources/:resourceId", publicRuntimeRead(runtimeSessionResourceMetadata));
1636
+ router.get("/api/runtimes/:selector/sessions/:id/resources/:resourceId/content", publicRuntimeRead(runtimeSessionResourceContent));
1637
+ router.delete("/api/runtimes/:selector/sessions/:id/resources/:resourceId", publicRuntimeMutation(runtimeSessionResourceDelete));
1638
+ router.post("/api/runtimes/:selector/sessions/:id/messages/queue", publicRuntimeMutation(runtimeSessionMessageEnqueue));
1211
1639
  router.post("/api/runtimes/:selector/sessions/:id/terminal/start", publicRuntimeMutation(runtimeSessionTerminalStart));
1212
1640
  router.post("/api/runtimes/:selector/sessions/:id/interactions/:interactionId/resolve", publicRuntimeMutation(runtimeSessionResolveInteraction));
1213
1641
  router.delete("/api/runtimes/:selector/sessions/:id", publicRuntimeMutation(runtimeSessionDelete));
@@ -1374,7 +1802,11 @@ export function createControlRouter(opts) {
1374
1802
  router.get("/api/meta", (ctx) => {
1375
1803
  const meta = {
1376
1804
  channelTypes: deps.listChannelTypes().map((t) => t.type),
1805
+ ...(daemonManagement?.buildStatus
1806
+ ? { daemonBuild: daemonManagement.buildStatus() }
1807
+ : {}),
1377
1808
  };
1809
+ ctx.set("Cache-Control", "no-store");
1378
1810
  ctx.body = meta;
1379
1811
  });
1380
1812
  router.get("/api/plugin-runtime", (ctx) => {
@@ -1398,6 +1830,148 @@ export function createControlRouter(opts) {
1398
1830
  return;
1399
1831
  ctx.body = encodeDaemonStatus(remoteRuntime.status());
1400
1832
  });
1833
+ router.post(DAEMON_SHUTDOWN_IF_IDLE_PATH, (ctx) => {
1834
+ if (!isConfiguredDaemonManagement(daemonManagement) ||
1835
+ !daemonManagement.shutdownIfIdle) {
1836
+ return notFound(ctx, "daemon shutdown management is not configured");
1837
+ }
1838
+ if (!authorizeLoopbackManagementRequest(ctx, daemonManagement.managementToken))
1839
+ return;
1840
+ const result = daemonManagement.shutdownIfIdle();
1841
+ ctx.set("Cache-Control", "no-store");
1842
+ ctx.status = result.outcome === "accepted" ? 202 : 409;
1843
+ ctx.body = result;
1844
+ });
1845
+ router.post(DAEMON_BROWSER_ARTIFACT_INSTALL_PATH, async (ctx) => {
1846
+ await mutateBrowserArtifact(ctx, "install");
1847
+ });
1848
+ router.post(DAEMON_BROWSER_ARTIFACT_UPDATE_PATH, async (ctx) => {
1849
+ await mutateBrowserArtifact(ctx, "update");
1850
+ });
1851
+ router.get(DAEMON_BROWSER_ARTIFACT_VERSION_PATH, (ctx) => {
1852
+ const host = daemonManagement?.browserArtifacts;
1853
+ if (!host || !isConfiguredDaemonManagement(daemonManagement)) {
1854
+ return notFound(ctx, "Browser artifact management is not configured");
1855
+ }
1856
+ if (!authorizeLoopbackManagementRequest(ctx, daemonManagement.managementToken))
1857
+ return;
1858
+ try {
1859
+ ctx.set("Cache-Control", "no-store");
1860
+ ctx.body = host.version();
1861
+ }
1862
+ catch (error) {
1863
+ ctx.status = 409;
1864
+ ctx.body = {
1865
+ error: boundedUtf8(error instanceof Error ? error.message : String(error), BROWSER_ARTIFACT_ERROR_MAX_BYTES),
1866
+ };
1867
+ }
1868
+ });
1869
+ router.post(DAEMON_BROWSER_ARTIFACT_CLEAN_PATH, (ctx) => {
1870
+ const host = daemonManagement?.browserArtifacts;
1871
+ if (!host || !isConfiguredDaemonManagement(daemonManagement)) {
1872
+ return notFound(ctx, "Browser artifact management is not configured");
1873
+ }
1874
+ if (!authorizeLoopbackManagementRequest(ctx, daemonManagement.managementToken))
1875
+ return;
1876
+ try {
1877
+ ctx.set("Cache-Control", "no-store");
1878
+ ctx.body = host.clean();
1879
+ }
1880
+ catch (error) {
1881
+ ctx.status = 409;
1882
+ ctx.body = {
1883
+ error: boundedUtf8(error instanceof Error ? error.message : String(error), BROWSER_ARTIFACT_ERROR_MAX_BYTES),
1884
+ };
1885
+ }
1886
+ });
1887
+ router.get(DAEMON_CHROME_INSPECTION_PATH, async (ctx) => {
1888
+ const host = daemonManagement?.chromeInspection;
1889
+ if (!host || !isConfiguredDaemonManagement(daemonManagement)) {
1890
+ return notFound(ctx, "Chrome inspection management is not configured");
1891
+ }
1892
+ if (!authorizeLoopbackManagementRequest(ctx, daemonManagement.managementToken))
1893
+ return;
1894
+ try {
1895
+ ctx.set("Cache-Control", "no-store");
1896
+ ctx.body = await host.status();
1897
+ }
1898
+ catch (error) {
1899
+ ctx.status = 409;
1900
+ ctx.body = {
1901
+ error: boundedUtf8(error instanceof Error ? error.message : String(error), CHROME_INSPECTION_ERROR_MAX_BYTES),
1902
+ };
1903
+ }
1904
+ });
1905
+ router.put(DAEMON_CHROME_INSPECTION_PATH, async (ctx) => {
1906
+ const host = daemonManagement?.chromeInspection;
1907
+ if (!host || !isConfiguredDaemonManagement(daemonManagement)) {
1908
+ return notFound(ctx, "Chrome inspection management is not configured");
1909
+ }
1910
+ if (!authorizeLoopbackManagementRequest(ctx, daemonManagement.managementToken))
1911
+ return;
1912
+ if (!ctx.is("application/json")) {
1913
+ ctx.status = 415;
1914
+ ctx.body = { error: "Chrome inspection configuration requires application/json" };
1915
+ return;
1916
+ }
1917
+ let input;
1918
+ try {
1919
+ input = parseDaemonChromeInspectionConfigureInput(await readBoundedJson(ctx, CHROME_INSPECTION_BODY_MAX_BYTES));
1920
+ }
1921
+ catch (error) {
1922
+ if (error instanceof RequestBodyTooLargeError) {
1923
+ ctx.status = 413;
1924
+ ctx.body = { error: error.message };
1925
+ return;
1926
+ }
1927
+ return bad(ctx, error instanceof Error ? error.message : String(error));
1928
+ }
1929
+ try {
1930
+ ctx.set("Cache-Control", "no-store");
1931
+ ctx.body = await host.configure(input);
1932
+ }
1933
+ catch (error) {
1934
+ ctx.status = 409;
1935
+ ctx.body = {
1936
+ error: boundedUtf8(error instanceof Error ? error.message : String(error), CHROME_INSPECTION_ERROR_MAX_BYTES),
1937
+ };
1938
+ }
1939
+ });
1940
+ router.post(DAEMON_CLEANUP_SESSIONS_PATH, async (ctx) => {
1941
+ const host = daemonManagement?.maintenance;
1942
+ if (!host || !isConfiguredDaemonManagement(daemonManagement)) {
1943
+ return notFound(ctx, "daemon maintenance is not configured");
1944
+ }
1945
+ if (!authorizeLoopbackManagementRequest(ctx, daemonManagement.managementToken))
1946
+ return;
1947
+ if (!ctx.is("application/json")) {
1948
+ ctx.status = 415;
1949
+ ctx.body = { error: "Session cleanup requires application/json" };
1950
+ return;
1951
+ }
1952
+ let input;
1953
+ try {
1954
+ input = parseDaemonCleanupSessionsInput(await readBoundedJson(ctx, CLEANUP_SESSIONS_BODY_MAX_BYTES));
1955
+ }
1956
+ catch (error) {
1957
+ if (error instanceof RequestBodyTooLargeError) {
1958
+ ctx.status = 413;
1959
+ ctx.body = { error: error.message };
1960
+ return;
1961
+ }
1962
+ return bad(ctx, error instanceof Error ? error.message : String(error));
1963
+ }
1964
+ try {
1965
+ ctx.set("Cache-Control", "no-store");
1966
+ ctx.body = host.cleanupSessions(input);
1967
+ }
1968
+ catch (error) {
1969
+ ctx.status = 409;
1970
+ ctx.body = {
1971
+ error: boundedUtf8(error instanceof Error ? error.message : String(error), BROWSER_ARTIFACT_ERROR_MAX_BYTES),
1972
+ };
1973
+ }
1974
+ });
1401
1975
  router.post("/api/internal/remote-runtime/pairing-offers", async (ctx) => {
1402
1976
  if (!remoteRuntimeAdmin || !isConfiguredDaemonManagement(daemonManagement)) {
1403
1977
  return notFound(ctx, "Remote Runtime administration is not configured");
@@ -1422,6 +1996,165 @@ export function createControlRouter(opts) {
1422
1996
  return;
1423
1997
  revokeRuntimeAccess(ctx);
1424
1998
  });
1999
+ router.delete("/api/internal/remote-runtime/clients/:grantId/record", (ctx) => {
2000
+ if (!remoteRuntimeAdmin || !isConfiguredDaemonManagement(daemonManagement)) {
2001
+ return notFound(ctx, "Remote Runtime administration is not configured");
2002
+ }
2003
+ if (!authorizeLoopbackManagementRequest(ctx, daemonManagement.managementToken))
2004
+ return;
2005
+ deleteRevokedRuntimeAccess(ctx);
2006
+ });
2007
+ router.get("/api/internal/plugins", async (ctx) => {
2008
+ if (!isConfiguredDaemonManagement(daemonManagement) ||
2009
+ !daemonManagement.pluginManagement) {
2010
+ return notFound(ctx, "plugin management is not configured");
2011
+ }
2012
+ if (!authorizeLoopbackManagementRequest(ctx, daemonManagement.managementToken))
2013
+ return;
2014
+ ctx.set("Cache-Control", "no-store");
2015
+ ctx.body = await daemonManagement.pluginManagement.listPlugins();
2016
+ });
2017
+ router.post(PLUGIN_INSTALL_PREPARATIONS_PATH, async (ctx) => {
2018
+ if (!isConfiguredDaemonManagement(daemonManagement) ||
2019
+ !daemonManagement.pluginManagement) {
2020
+ return notFound(ctx, "plugin management is not configured");
2021
+ }
2022
+ if (!authorizeLoopbackManagementRequest(ctx, daemonManagement.managementToken))
2023
+ return;
2024
+ if (!ctx.is("application/json")) {
2025
+ ctx.status = 415;
2026
+ ctx.body = { error: "plugin installation preparation requires application/json" };
2027
+ return;
2028
+ }
2029
+ let input;
2030
+ try {
2031
+ input = parsePluginInstallPrepareInput(await readBoundedJson(ctx, PLUGIN_INSTALL_BODY_MAX_BYTES));
2032
+ }
2033
+ catch (error) {
2034
+ if (error instanceof RequestBodyTooLargeError) {
2035
+ ctx.status = 413;
2036
+ ctx.body = { error: error.message };
2037
+ return;
2038
+ }
2039
+ return bad(ctx, error instanceof Error ? error.message : String(error));
2040
+ }
2041
+ const invocation = pluginInstallInvocationAbortScope(ctx, shutdownSignal);
2042
+ try {
2043
+ ctx.set("Cache-Control", "no-store");
2044
+ ctx.status = 201;
2045
+ ctx.body = await daemonManagement.pluginManagement.prepareInstallation(input, { signal: invocation.signal });
2046
+ }
2047
+ catch (error) {
2048
+ if (!ctx.res.destroyed) {
2049
+ ctx.status = invocation.signal.aborted
2050
+ ? invocation.signal.reason instanceof Error &&
2051
+ invocation.signal.reason.name === "TimeoutError"
2052
+ ? 504
2053
+ : 499
2054
+ : 409;
2055
+ ctx.body = {
2056
+ error: boundedUtf8(error instanceof Error ? error.message : String(error), PLUGIN_INSTALL_ERROR_MAX_BYTES),
2057
+ };
2058
+ }
2059
+ }
2060
+ finally {
2061
+ invocation.dispose();
2062
+ }
2063
+ });
2064
+ router.post(`${PLUGIN_INSTALL_PREPARATIONS_PATH}/:token/commit`, async (ctx) => {
2065
+ if (!isConfiguredDaemonManagement(daemonManagement) ||
2066
+ !daemonManagement.pluginManagement) {
2067
+ return notFound(ctx, "plugin management is not configured");
2068
+ }
2069
+ if (!authorizeLoopbackManagementRequest(ctx, daemonManagement.managementToken))
2070
+ return;
2071
+ const token = ctx.params.token;
2072
+ if (!/^[A-Za-z0-9_-]{32,128}$/.test(token)) {
2073
+ return bad(ctx, "invalid plugin preparation token");
2074
+ }
2075
+ if (!ctx.is("application/json")) {
2076
+ ctx.status = 415;
2077
+ ctx.body = { error: "plugin installation commit requires application/json" };
2078
+ return;
2079
+ }
2080
+ let input;
2081
+ try {
2082
+ input = parsePluginInstallCommitInput(await readBoundedJson(ctx, PLUGIN_INSTALL_BODY_MAX_BYTES));
2083
+ }
2084
+ catch (error) {
2085
+ if (error instanceof RequestBodyTooLargeError) {
2086
+ ctx.status = 413;
2087
+ ctx.body = { error: error.message };
2088
+ return;
2089
+ }
2090
+ return bad(ctx, error instanceof Error ? error.message : String(error));
2091
+ }
2092
+ const invocation = pluginInstallInvocationAbortScope(ctx, shutdownSignal);
2093
+ try {
2094
+ ctx.set("Cache-Control", "no-store");
2095
+ ctx.body = await daemonManagement.pluginManagement.commitInstallation(token, input, { signal: invocation.signal });
2096
+ }
2097
+ catch (error) {
2098
+ if (!ctx.res.destroyed) {
2099
+ ctx.status = invocation.signal.aborted
2100
+ ? invocation.signal.reason instanceof Error &&
2101
+ invocation.signal.reason.name === "TimeoutError"
2102
+ ? 504
2103
+ : 499
2104
+ : 409;
2105
+ ctx.body = {
2106
+ error: boundedUtf8(error instanceof Error ? error.message : String(error), PLUGIN_INSTALL_ERROR_MAX_BYTES),
2107
+ };
2108
+ }
2109
+ }
2110
+ finally {
2111
+ invocation.dispose();
2112
+ }
2113
+ });
2114
+ router.delete(`${PLUGIN_INSTALL_PREPARATIONS_PATH}/:token`, async (ctx) => {
2115
+ if (!isConfiguredDaemonManagement(daemonManagement) ||
2116
+ !daemonManagement.pluginManagement) {
2117
+ return notFound(ctx, "plugin management is not configured");
2118
+ }
2119
+ if (!authorizeLoopbackManagementRequest(ctx, daemonManagement.managementToken))
2120
+ return;
2121
+ const token = ctx.params.token;
2122
+ if (!/^[A-Za-z0-9_-]{32,128}$/.test(token)) {
2123
+ return bad(ctx, "invalid plugin preparation token");
2124
+ }
2125
+ const cancelled = await daemonManagement.pluginManagement.cancelPreparation(token);
2126
+ ctx.set("Cache-Control", "no-store");
2127
+ ctx.body = { cancelled };
2128
+ });
2129
+ router.post("/api/internal/plugins/:pluginId/enable", async (ctx) => {
2130
+ await updateManagedPluginState(ctx, true);
2131
+ });
2132
+ router.post("/api/internal/plugins/:pluginId/disable", async (ctx) => {
2133
+ await updateManagedPluginState(ctx, false);
2134
+ });
2135
+ router.delete("/api/internal/plugins/:pluginId", async (ctx) => {
2136
+ if (!isConfiguredDaemonManagement(daemonManagement) ||
2137
+ !daemonManagement.pluginManagement) {
2138
+ return notFound(ctx, "plugin management is not configured");
2139
+ }
2140
+ if (!authorizeLoopbackManagementRequest(ctx, daemonManagement.managementToken))
2141
+ return;
2142
+ const pluginId = ctx.params.pluginId;
2143
+ if (!/^[a-z][a-z0-9-]{0,62}$/.test(pluginId)) {
2144
+ return bad(ctx, "invalid plugin id");
2145
+ }
2146
+ try {
2147
+ const uninstalled = await daemonManagement.pluginManagement.uninstallPlugin(pluginId);
2148
+ if (!uninstalled)
2149
+ return notFound(ctx, `plugin "${pluginId}" not found`);
2150
+ ctx.set("Cache-Control", "no-store");
2151
+ ctx.body = { uninstalled: true, pluginId };
2152
+ }
2153
+ catch (error) {
2154
+ ctx.status = 409;
2155
+ ctx.body = { error: error instanceof Error ? error.message : String(error) };
2156
+ }
2157
+ });
1425
2158
  router.post("/api/internal/plugins/:pluginId/cli", async (ctx) => {
1426
2159
  if (!isConfiguredDaemonManagement(daemonManagement)) {
1427
2160
  return notFound(ctx, "daemon management is not configured");
@@ -1472,6 +2205,33 @@ export function createControlRouter(opts) {
1472
2205
  invocation.dispose();
1473
2206
  }
1474
2207
  });
2208
+ async function updateManagedPluginState(ctx, enabled) {
2209
+ if (!isConfiguredDaemonManagement(daemonManagement) ||
2210
+ !daemonManagement.pluginManagement) {
2211
+ notFound(ctx, "plugin management is not configured");
2212
+ return;
2213
+ }
2214
+ if (!authorizeLoopbackManagementRequest(ctx, daemonManagement.managementToken))
2215
+ return;
2216
+ const pluginId = ctx.params.pluginId;
2217
+ if (!/^[a-z][a-z0-9-]{0,62}$/.test(pluginId)) {
2218
+ bad(ctx, "invalid plugin id");
2219
+ return;
2220
+ }
2221
+ try {
2222
+ const plugin = await daemonManagement.pluginManagement.setPluginEnabled(pluginId, enabled);
2223
+ if (!plugin) {
2224
+ notFound(ctx, `plugin "${pluginId}" not found`);
2225
+ return;
2226
+ }
2227
+ ctx.set("Cache-Control", "no-store");
2228
+ ctx.body = { plugin };
2229
+ }
2230
+ catch (error) {
2231
+ ctx.status = 409;
2232
+ ctx.body = { error: error instanceof Error ? error.message : String(error) };
2233
+ }
2234
+ }
1475
2235
  router.post("/api/internal/plugin-runtimes/:pluginId/reload", async (ctx) => {
1476
2236
  if (!pluginRuntimeReload || !isConfiguredDaemonManagement(daemonManagement)) {
1477
2237
  return notFound(ctx, "plugin runtime management is not configured");
@@ -2124,7 +2884,7 @@ export function createControlRouter(opts) {
2124
2884
  // when the rollout already exists (the normal single-machine case). The bound
2125
2885
  // thread record is fetched directly — `bound` above is null for console
2126
2886
  // sessions, but a console session still resumes a stored codex thread.
2127
- if (liveRuntime === "codex" && sessionLog) {
2887
+ if ((liveRuntime === "codex" || liveRuntime === "traex") && sessionLog) {
2128
2888
  const codexRecord = await sessionStore?.get(id);
2129
2889
  if (codexRecord?.codexSessionId) {
2130
2890
  ensureCodexResumeRollout({
@@ -2132,6 +2892,7 @@ export function createControlRouter(opts) {
2132
2892
  // the SAME id the runner child gets via `RYNX_RUNNER_SESSION`, so the
2133
2893
  // synthesized rollout lands where that session's app-server reads.
2134
2894
  sessionId: id,
2895
+ runtime: liveRuntime,
2135
2896
  threadId: codexRecord.codexSessionId,
2136
2897
  cwd: codexRecord.cwd ?? process.cwd(),
2137
2898
  items: await sessionLog.snapshot(id),
@@ -2553,18 +3314,27 @@ function isConfiguredDaemonManagement(value) {
2553
3314
  return bytes >= 32 && bytes <= 256;
2554
3315
  }
2555
3316
  function pluginCliInvocationAbortScope(ctx, shutdownSignal) {
3317
+ return managementInvocationAbortScope(ctx, shutdownSignal, PLUGIN_CLI_TIMEOUT_MS, "plugin CLI invocation");
3318
+ }
3319
+ function pluginInstallInvocationAbortScope(ctx, shutdownSignal) {
3320
+ return managementInvocationAbortScope(ctx, shutdownSignal, PLUGIN_INSTALL_OPERATION_TIMEOUT_MS, "plugin installation operation");
3321
+ }
3322
+ function browserArtifactInvocationAbortScope(ctx, shutdownSignal) {
3323
+ return managementInvocationAbortScope(ctx, shutdownSignal, BROWSER_ARTIFACT_OPERATION_TIMEOUT_MS, "Browser artifact operation");
3324
+ }
3325
+ function managementInvocationAbortScope(ctx, shutdownSignal, timeoutMs, label) {
2556
3326
  const controller = new AbortController();
2557
3327
  const abort = (reason) => {
2558
3328
  if (!controller.signal.aborted)
2559
3329
  controller.abort(reason);
2560
3330
  };
2561
- const onRequestAborted = () => abort(namedError("plugin CLI request aborted", "AbortError"));
3331
+ const onRequestAborted = () => abort(namedError(`${label} request aborted`, "AbortError"));
2562
3332
  const onResponseClose = () => {
2563
3333
  if (!ctx.res.writableEnded)
2564
- abort(namedError("plugin CLI client disconnected", "AbortError"));
3334
+ abort(namedError(`${label} client disconnected`, "AbortError"));
2565
3335
  };
2566
3336
  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);
3337
+ const timeout = setTimeout(() => abort(namedError(`${label} timed out`, "TimeoutError")), timeoutMs);
2568
3338
  timeout.unref?.();
2569
3339
  ctx.req.once("aborted", onRequestAborted);
2570
3340
  ctx.res.once("close", onResponseClose);
@@ -2751,6 +3521,8 @@ function runtimeLocalBrowserError(ctx, error) {
2751
3521
  function runtimeTargetHttpStatus(code) {
2752
3522
  switch (code) {
2753
3523
  case "invalid_offer":
3524
+ case "offer_expired":
3525
+ case "offer_rejected":
2754
3526
  case "invalid_target":
2755
3527
  case "invalid_request":
2756
3528
  return 400;
@@ -2802,6 +3574,34 @@ function publicRuntimeStreamError(error) {
2802
3574
  }
2803
3575
  class RequestBodyTooLargeError extends Error {
2804
3576
  }
3577
+ async function readBoundedBinary(ctx, maxBytes) {
3578
+ const declaredLength = Number(ctx.get("content-length"));
3579
+ if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
3580
+ throw new RequestBodyTooLargeError(`request body exceeds ${maxBytes} bytes`);
3581
+ }
3582
+ const chunks = [];
3583
+ let bytes = 0;
3584
+ let tooLarge = false;
3585
+ await new Promise((resolve, reject) => {
3586
+ ctx.req.on("data", (chunk) => {
3587
+ const data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
3588
+ bytes += data.byteLength;
3589
+ if (bytes > maxBytes) {
3590
+ tooLarge = true;
3591
+ chunks.length = 0;
3592
+ return;
3593
+ }
3594
+ if (!tooLarge)
3595
+ chunks.push(data);
3596
+ });
3597
+ ctx.req.once("end", resolve);
3598
+ ctx.req.once("error", reject);
3599
+ });
3600
+ if (tooLarge) {
3601
+ throw new RequestBodyTooLargeError(`request body exceeds ${maxBytes} bytes`);
3602
+ }
3603
+ return Buffer.concat(chunks, bytes);
3604
+ }
2805
3605
  async function readBoundedJson(ctx, maxBytes) {
2806
3606
  const declaredLength = Number(ctx.get("content-length"));
2807
3607
  if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {