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