@rynx-ai/server 0.1.9 → 0.1.10-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/browser-surface-ws.d.ts +2 -0
- package/dist/browser-surface-ws.js +1 -0
- package/dist/control-api.d.ts +17 -0
- package/dist/control-api.js +809 -9
- package/dist/control-web/assets/{highlighted-body-OFNGDK62-e-w61YLy.js → highlighted-body-OFNGDK62-D-FhYROw.js} +1 -1
- package/dist/control-web/assets/index-BKzSsGxH.js +671 -0
- package/dist/control-web/assets/index-DykWY_Vk.css +32 -0
- package/dist/control-web/assets/{mermaid-GHXKKRXX-DZn7Hbr2.js → mermaid-GHXKKRXX-DE-Ovg9T.js} +3 -3
- package/dist/control-web/index.html +2 -2
- package/dist/desktop-browser-host.d.ts +20 -3
- package/dist/desktop-browser-host.js +126 -44
- package/dist/direct-runtime-browser-inspect-server.d.ts +72 -0
- package/dist/direct-runtime-browser-inspect-server.js +749 -0
- package/dist/direct-runtime-server.d.ts +6 -0
- package/dist/direct-runtime-server.js +29 -1
- package/dist/emulator-surface-ws.d.ts +2 -0
- package/dist/emulator-surface-ws.js +1 -0
- package/dist/machine-session-service.d.ts +88 -5
- package/dist/machine-session-service.js +283 -20
- package/dist/remote-runtime-dispatcher.d.ts +1 -1
- package/dist/remote-runtime-dispatcher.js +25 -2
- package/dist/remote-runtime-session-projection.js +10 -0
- package/dist/runtime-web-auth.d.ts +5 -0
- package/dist/runtime-web-auth.js +43 -3
- package/dist/server.d.ts +78 -8
- package/dist/server.js +42 -17
- package/dist/session-browser-service.d.ts +30 -0
- package/dist/session-browser-service.js +89 -3
- package/dist/session-browser-surface-coordinator.d.ts +4 -3
- package/dist/session-browser-surface-coordinator.js +374 -268
- package/dist/session-runtime-index.d.ts +6 -0
- package/dist/session-runtime-index.js +10 -0
- package/dist/session-terminal-host.d.ts +6 -1
- package/dist/session-terminal-host.js +18 -0
- package/dist/terminal-ws.d.ts +3 -0
- package/dist/terminal-ws.js +1 -0
- package/package.json +12 -7
- package/dist/control-web/assets/index-BQ7XVBKO.css +0 -32
- package/dist/control-web/assets/index-ClpzuBJx.js +0 -606
package/dist/control-api.js
CHANGED
|
@@ -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" &&
|
|
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 =
|
|
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(
|
|
3331
|
+
const onRequestAborted = () => abort(namedError(`${label} request aborted`, "AbortError"));
|
|
2562
3332
|
const onResponseClose = () => {
|
|
2563
3333
|
if (!ctx.res.writableEnded)
|
|
2564
|
-
abort(namedError(
|
|
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(
|
|
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) {
|