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