@rynx-ai/daemon 0.1.0 → 0.1.10-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app-browser-host-supervisor.d.ts +97 -0
- package/dist/app-browser-host-supervisor.js +529 -0
- package/dist/browser-artifact-management.d.ts +20 -0
- package/dist/browser-artifact-management.js +61 -0
- package/dist/browser-cli-args.d.ts +20 -0
- package/dist/browser-cli-args.js +100 -0
- package/dist/browser-runner-session-context.d.ts +24 -0
- package/dist/browser-runner-session-context.js +113 -0
- package/dist/cdp-keyboard-input.d.ts +6 -0
- package/dist/cdp-keyboard-input.js +209 -0
- package/dist/channel-store.d.ts +0 -6
- package/dist/channel-store.js +2 -18
- package/dist/chrome-for-testing-release-resolver.d.ts +34 -0
- package/dist/chrome-for-testing-release-resolver.js +214 -0
- package/dist/chrome-for-testing-store.d.ts +85 -0
- package/dist/chrome-for-testing-store.js +836 -0
- package/dist/chrome-inspection-gateway.d.ts +58 -0
- package/dist/chrome-inspection-gateway.js +806 -0
- package/dist/chrome-inspection-manager.d.ts +94 -0
- package/dist/chrome-inspection-manager.js +573 -0
- package/dist/cli.js +478 -30
- package/dist/control-client.d.ts +122 -0
- package/dist/control-client.js +855 -0
- package/dist/control-deps.js +37 -6
- package/dist/control-endpoint.d.ts +14 -0
- package/dist/control-endpoint.js +124 -0
- package/dist/control-link-store.d.ts +16 -0
- package/dist/control-link-store.js +53 -0
- package/dist/daemon-build-status.d.ts +8 -0
- package/dist/daemon-build-status.js +18 -0
- package/dist/daemon-owner.d.ts +14 -0
- package/dist/daemon-owner.js +104 -0
- package/dist/daemon-server.d.ts +29 -2
- package/dist/daemon-server.js +1113 -26
- package/dist/db.js +196 -10
- package/dist/desktop-browser-host-client.d.ts +35 -0
- package/dist/desktop-browser-host-client.js +415 -0
- package/dist/direct-runtime-authenticator.d.ts +13 -0
- package/dist/direct-runtime-authenticator.js +132 -0
- package/dist/direct-runtime-config.d.ts +20 -0
- package/dist/direct-runtime-config.js +101 -0
- package/dist/embedded-browser-profile-cleanup.d.ts +58 -0
- package/dist/embedded-browser-profile-cleanup.js +338 -0
- package/dist/headless-browser-host.d.ts +14 -0
- package/dist/headless-browser-host.js +2173 -0
- package/dist/index-daemon.js +55 -5
- package/dist/installation-id.d.ts +33 -0
- package/dist/installation-id.js +692 -0
- package/dist/maintenance-management.d.ts +11 -0
- package/dist/maintenance-management.js +13 -0
- package/dist/migrations/cleanup-legacy-sessions.d.ts +0 -3
- package/dist/migrations/cleanup-legacy-sessions.js +10 -28
- package/dist/plugin-cli.d.ts +30 -0
- package/dist/plugin-cli.js +270 -0
- package/dist/plugin-host-rpc.d.ts +85 -0
- package/dist/plugin-host-rpc.js +878 -0
- package/dist/plugin-install-restart.d.ts +13 -0
- package/dist/plugin-install-restart.js +24 -0
- package/dist/plugin-installer.d.ts +71 -15
- package/dist/plugin-installer.js +521 -155
- package/dist/plugin-management-service.d.ts +58 -0
- package/dist/plugin-management-service.js +240 -0
- package/dist/plugin-package.d.ts +61 -0
- package/dist/plugin-package.js +732 -0
- package/dist/plugin-runtime-control.d.ts +2 -0
- package/dist/plugin-runtime-control.js +37 -0
- package/dist/plugin-store.d.ts +37 -8
- package/dist/plugin-store.js +80 -15
- package/dist/plugin-supervisor.d.ts +160 -0
- package/dist/plugin-supervisor.js +1143 -0
- package/dist/pm2.d.ts +3 -1
- package/dist/pm2.js +92 -18
- package/dist/provider-cli-service.d.ts +46 -0
- package/dist/provider-cli-service.js +510 -0
- package/dist/registry.d.ts +6 -19
- package/dist/registry.js +22 -87
- package/dist/remote-runtime-access-store.d.ts +62 -0
- package/dist/remote-runtime-access-store.js +393 -0
- package/dist/remote-runtime-admin.d.ts +35 -0
- package/dist/remote-runtime-admin.js +110 -0
- package/dist/remote-runtime-connection-manager.d.ts +103 -0
- package/dist/remote-runtime-connection-manager.js +1575 -0
- package/dist/remote-runtime-credential-store.d.ts +45 -0
- package/dist/remote-runtime-credential-store.js +639 -0
- package/dist/remote-runtime-target-control.d.ts +51 -0
- package/dist/remote-runtime-target-control.js +488 -0
- package/dist/remote-runtime-target-store.d.ts +56 -0
- package/dist/remote-runtime-target-store.js +326 -0
- package/dist/runtime-share-addresses.d.ts +21 -0
- package/dist/runtime-share-addresses.js +50 -0
- package/dist/session-browser-capability-store.d.ts +13 -0
- package/dist/session-browser-capability-store.js +58 -0
- package/dist/session-emulator-binding-store.d.ts +10 -0
- package/dist/session-emulator-binding-store.js +61 -0
- package/dist/session-launch-operations.d.ts +41 -0
- package/dist/session-launch-operations.js +124 -0
- package/dist/session-log-store.js +29 -0
- package/dist/session-meta-store.d.ts +3 -2
- package/dist/session-meta-store.js +59 -7
- package/dist/session-pending-message-store.d.ts +2 -0
- package/dist/session-pending-message-store.js +41 -0
- package/dist/session-resource-store.d.ts +32 -0
- package/dist/session-resource-store.js +700 -0
- package/dist/setup.d.ts +16 -0
- package/dist/setup.js +144 -5
- package/dist/skills-catalog.js +8 -4
- package/dist/update.d.ts +11 -2
- package/dist/update.js +0 -27
- package/package.json +23 -10
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
import { DESKTOP_BROWSER_HOST_MAX_BINARY_FRAME_BYTES, DESKTOP_BROWSER_HOST_MAX_CONTROL_FRAME_BYTES, DESKTOP_BROWSER_HOST_MAX_PENDING_COMMANDS, DESKTOP_BROWSER_HOST_PATH, DESKTOP_BROWSER_HOST_PROTOCOL_VERSION, encodeDesktopBrowserHostSurfaceFrame, parseDesktopBrowserHostClientFrame, parseDesktopBrowserHostServerFrame, } from "@rynx-ai/protocol/desktop-browser-host";
|
|
2
|
+
import { WebSocket } from "ws";
|
|
3
|
+
import { ensureDaemonControlEndpoint } from "./control-endpoint.js";
|
|
4
|
+
const CONNECT_TIMEOUT_MS = 5_000;
|
|
5
|
+
const MAX_BUFFERED_BYTES = DESKTOP_BROWSER_HOST_MAX_BINARY_FRAME_BYTES + DESKTOP_BROWSER_HOST_MAX_CONTROL_FRAME_BYTES;
|
|
6
|
+
/** Open the sole resident daemon's authenticated loopback Desktop Host lease. */
|
|
7
|
+
export async function connectResidentDesktopBrowserHost(options) {
|
|
8
|
+
options.signal?.throwIfAborted();
|
|
9
|
+
const hello = parseDesktopBrowserHostClientFrame({
|
|
10
|
+
type: "desktop.browser.host.hello",
|
|
11
|
+
protocolVersion: DESKTOP_BROWSER_HOST_PROTOCOL_VERSION,
|
|
12
|
+
hostInstanceId: options.hostInstanceId,
|
|
13
|
+
capabilities: {
|
|
14
|
+
semanticPageBinding: options.capabilities.semanticPageBinding,
|
|
15
|
+
presentation: options.capabilities.presentation,
|
|
16
|
+
surface: options.capabilities.surface,
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
if (hello.type !== "desktop.browser.host.hello") {
|
|
20
|
+
throw new Error("Desktop Browser Host hello parser returned the wrong frame");
|
|
21
|
+
}
|
|
22
|
+
const endpoint = await ensureDaemonControlEndpoint({ signal: options.signal });
|
|
23
|
+
assertLoopbackOrigin(endpoint.origin);
|
|
24
|
+
const url = new URL(DESKTOP_BROWSER_HOST_PATH, `${endpoint.origin}/`);
|
|
25
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
26
|
+
const socket = new WebSocket(url, {
|
|
27
|
+
headers: { "x-rynx-management-token": endpoint.managementToken },
|
|
28
|
+
handshakeTimeout: CONNECT_TIMEOUT_MS,
|
|
29
|
+
maxPayload: DESKTOP_BROWSER_HOST_MAX_CONTROL_FRAME_BYTES,
|
|
30
|
+
perMessageDeflate: false,
|
|
31
|
+
});
|
|
32
|
+
const queue = new BoundedCommandQueue(DESKTOP_BROWSER_HOST_MAX_PENDING_COMMANDS);
|
|
33
|
+
let lease;
|
|
34
|
+
let leaseResolve;
|
|
35
|
+
let leaseReject;
|
|
36
|
+
const leaseReady = new Promise((resolve, reject) => {
|
|
37
|
+
leaseResolve = resolve;
|
|
38
|
+
leaseReject = reject;
|
|
39
|
+
});
|
|
40
|
+
let closedResolve;
|
|
41
|
+
const closed = new Promise((resolve) => {
|
|
42
|
+
closedResolve = resolve;
|
|
43
|
+
});
|
|
44
|
+
let terminalError;
|
|
45
|
+
let eventSequence = 0;
|
|
46
|
+
let closing = false;
|
|
47
|
+
let writerTail = Promise.resolve();
|
|
48
|
+
const activeRequests = new Map();
|
|
49
|
+
const abortActiveRequests = (error) => {
|
|
50
|
+
for (const request of activeRequests.values()) {
|
|
51
|
+
if (request.timer)
|
|
52
|
+
clearTimeout(request.timer);
|
|
53
|
+
request.settled = true;
|
|
54
|
+
request.controller.abort(error);
|
|
55
|
+
}
|
|
56
|
+
activeRequests.clear();
|
|
57
|
+
};
|
|
58
|
+
const fail = (error) => {
|
|
59
|
+
terminalError ??= error;
|
|
60
|
+
if (!lease)
|
|
61
|
+
leaseReject(error);
|
|
62
|
+
queue.close(error);
|
|
63
|
+
abortActiveRequests(error);
|
|
64
|
+
};
|
|
65
|
+
const failConnection = (error, code, reason) => {
|
|
66
|
+
fail(error);
|
|
67
|
+
if (socket.readyState < WebSocket.CLOSING)
|
|
68
|
+
socket.close(code, reason);
|
|
69
|
+
};
|
|
70
|
+
const enqueueWrite = (operation) => {
|
|
71
|
+
const current = writerTail.then(async () => {
|
|
72
|
+
if (terminalError)
|
|
73
|
+
throw terminalError;
|
|
74
|
+
await operation();
|
|
75
|
+
});
|
|
76
|
+
writerTail = current.catch(() => undefined);
|
|
77
|
+
return current.catch((error) => {
|
|
78
|
+
const failure = asError(error, "Desktop Browser Host output failed");
|
|
79
|
+
failConnection(failure, 1011, "host output failed");
|
|
80
|
+
throw failure;
|
|
81
|
+
});
|
|
82
|
+
};
|
|
83
|
+
const onAbort = () => {
|
|
84
|
+
const error = abortError(options.signal?.reason);
|
|
85
|
+
failConnection(error, 1000, "aborted");
|
|
86
|
+
};
|
|
87
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
88
|
+
socket.on("open", () => {
|
|
89
|
+
void enqueueWrite(() => sendJson(socket, hello));
|
|
90
|
+
});
|
|
91
|
+
socket.on("message", (raw, isBinary) => {
|
|
92
|
+
if (isBinary) {
|
|
93
|
+
failConnection(new Error("Desktop Browser Host daemon sent an unexpected binary frame"), 1003, "binary frame unsupported");
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
let frame;
|
|
97
|
+
try {
|
|
98
|
+
const bytes = rawDataBytes(raw);
|
|
99
|
+
if (bytes.byteLength === 0 || bytes.byteLength > DESKTOP_BROWSER_HOST_MAX_CONTROL_FRAME_BYTES) {
|
|
100
|
+
throw new Error("Desktop Browser Host daemon frame exceeded its bound");
|
|
101
|
+
}
|
|
102
|
+
frame = parseDesktopBrowserHostServerFrame(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)));
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
failConnection(asError(error, "Desktop Browser Host daemon frame was invalid"), 1007, "invalid daemon frame");
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (!lease) {
|
|
109
|
+
if (frame.type !== "desktop.browser.host.lease") {
|
|
110
|
+
failConnection(new Error("Desktop Browser Host daemon did not begin with a lease"), 1002, "lease required");
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (frame.capabilities.semanticPageBinding &&
|
|
114
|
+
!options.capabilities.semanticPageBinding) {
|
|
115
|
+
failConnection(new Error("Desktop Browser Host daemon enabled an unadvertised Page binding capability"), 1002, "invalid lease capabilities");
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (frame.capabilities.surface === "binary-v1" &&
|
|
119
|
+
options.capabilities.surface !== "binary-v1") {
|
|
120
|
+
failConnection(new Error("Desktop Browser Host daemon enabled an unadvertised Surface capability"), 1002, "invalid lease capabilities");
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
lease = frame;
|
|
124
|
+
leaseResolve(frame);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (frame.type !== "desktop.browser.host.command" || frame.leaseId !== lease.leaseId) {
|
|
128
|
+
failConnection(new Error("Desktop Browser Host daemon sent a frame outside the active lease"), 1002, "lease mismatch");
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (!queue.push(frame)) {
|
|
132
|
+
failConnection(new Error("Desktop Browser Host command capacity exceeded"), 1013, "command capacity exceeded");
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
socket.on("error", (error) => {
|
|
136
|
+
failConnection(new Error("Desktop Browser Host connection failed", { cause: error }), 1011, "connection failed");
|
|
137
|
+
});
|
|
138
|
+
socket.once("close", (code, reason) => {
|
|
139
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
140
|
+
if (!terminalError && !closing) {
|
|
141
|
+
terminalError = new Error(`Desktop Browser Host lease closed (${code})${reason.length > 0 ? `: ${reason.toString("utf8")}` : ""}`);
|
|
142
|
+
}
|
|
143
|
+
if (!lease && terminalError)
|
|
144
|
+
leaseReject(terminalError);
|
|
145
|
+
queue.close(terminalError);
|
|
146
|
+
abortActiveRequests(terminalError ?? new Error("Desktop Browser Host lease closed"));
|
|
147
|
+
closedResolve();
|
|
148
|
+
});
|
|
149
|
+
try {
|
|
150
|
+
lease = await abortableTimeout(leaseReady, CONNECT_TIMEOUT_MS, options.signal);
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
if (socket.readyState < WebSocket.CLOSING)
|
|
154
|
+
socket.close(1000, "lease failed");
|
|
155
|
+
throw error;
|
|
156
|
+
}
|
|
157
|
+
const activeLease = lease;
|
|
158
|
+
const settle = async (command, value) => {
|
|
159
|
+
const request = activeRequests.get(command.commandId);
|
|
160
|
+
if (!request || request.settled) {
|
|
161
|
+
throw new Error("Desktop Browser Host command is already settled or stale");
|
|
162
|
+
}
|
|
163
|
+
if (command.leaseId !== activeLease.leaseId ||
|
|
164
|
+
request.controller.signal.aborted ||
|
|
165
|
+
Date.now() >= command.deadlineUnixMilliseconds) {
|
|
166
|
+
expireRequest(command, request);
|
|
167
|
+
throw new Error("Desktop Browser Host command deadline expired before settlement");
|
|
168
|
+
}
|
|
169
|
+
request.settled = true;
|
|
170
|
+
if (request.timer)
|
|
171
|
+
clearTimeout(request.timer);
|
|
172
|
+
activeRequests.delete(command.commandId);
|
|
173
|
+
await enqueueWrite(async () => {
|
|
174
|
+
const frame = parseDesktopBrowserHostClientFrame(value);
|
|
175
|
+
if (frame.type !== "desktop.browser.host.reply") {
|
|
176
|
+
throw new Error("Desktop Browser Host reply parser returned the wrong frame");
|
|
177
|
+
}
|
|
178
|
+
await sendJson(socket, frame);
|
|
179
|
+
});
|
|
180
|
+
};
|
|
181
|
+
const expireRequest = (command, request) => {
|
|
182
|
+
if (request.settled)
|
|
183
|
+
return;
|
|
184
|
+
request.settled = true;
|
|
185
|
+
if (request.timer)
|
|
186
|
+
clearTimeout(request.timer);
|
|
187
|
+
activeRequests.delete(command.commandId);
|
|
188
|
+
const error = new Error(`Desktop Browser Host ${command.command.method} deadline expired`);
|
|
189
|
+
request.controller.abort(error);
|
|
190
|
+
if (isMutatingCommand(command)) {
|
|
191
|
+
failConnection(error, 1000, "command deadline expired");
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
const requestIterator = queue[Symbol.asyncIterator]();
|
|
195
|
+
const claimedIterator = {
|
|
196
|
+
next: async () => {
|
|
197
|
+
const next = await requestIterator.next();
|
|
198
|
+
if (next.done)
|
|
199
|
+
return { done: true, value: undefined };
|
|
200
|
+
const command = next.value;
|
|
201
|
+
if (activeRequests.has(command.commandId)) {
|
|
202
|
+
const error = new Error("Desktop Browser Host daemon repeated a commandId");
|
|
203
|
+
failConnection(error, 1002, "duplicate command");
|
|
204
|
+
throw error;
|
|
205
|
+
}
|
|
206
|
+
const controller = new AbortController();
|
|
207
|
+
const remaining = command.deadlineUnixMilliseconds - Date.now();
|
|
208
|
+
const request = {
|
|
209
|
+
controller,
|
|
210
|
+
settled: false,
|
|
211
|
+
};
|
|
212
|
+
request.timer = setTimeout(() => expireRequest(command, request), Math.max(1, remaining));
|
|
213
|
+
request.timer.unref?.();
|
|
214
|
+
activeRequests.set(command.commandId, request);
|
|
215
|
+
if (remaining <= 0) {
|
|
216
|
+
expireRequest(command, request);
|
|
217
|
+
throw new Error("Desktop Browser Host received an expired command");
|
|
218
|
+
}
|
|
219
|
+
return {
|
|
220
|
+
done: false,
|
|
221
|
+
value: {
|
|
222
|
+
frame: command,
|
|
223
|
+
signal: controller.signal,
|
|
224
|
+
reply: (result) => settle(command, {
|
|
225
|
+
type: "desktop.browser.host.reply",
|
|
226
|
+
protocolVersion: DESKTOP_BROWSER_HOST_PROTOCOL_VERSION,
|
|
227
|
+
leaseId: activeLease.leaseId,
|
|
228
|
+
commandId: command.commandId,
|
|
229
|
+
ok: true,
|
|
230
|
+
result,
|
|
231
|
+
}),
|
|
232
|
+
reject: (failure) => settle(command, {
|
|
233
|
+
type: "desktop.browser.host.reply",
|
|
234
|
+
protocolVersion: DESKTOP_BROWSER_HOST_PROTOCOL_VERSION,
|
|
235
|
+
leaseId: activeLease.leaseId,
|
|
236
|
+
commandId: command.commandId,
|
|
237
|
+
ok: false,
|
|
238
|
+
error: failure,
|
|
239
|
+
}),
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
return {
|
|
245
|
+
lease: activeLease,
|
|
246
|
+
closed,
|
|
247
|
+
[Symbol.asyncIterator]: () => claimedIterator,
|
|
248
|
+
emit: (event) => enqueueWrite(async () => {
|
|
249
|
+
const nextSequence = eventSequence + 1;
|
|
250
|
+
const frame = parseDesktopBrowserHostClientFrame({
|
|
251
|
+
type: "desktop.browser.host.event",
|
|
252
|
+
protocolVersion: DESKTOP_BROWSER_HOST_PROTOCOL_VERSION,
|
|
253
|
+
leaseId: activeLease.leaseId,
|
|
254
|
+
eventSequence: nextSequence,
|
|
255
|
+
event,
|
|
256
|
+
});
|
|
257
|
+
if (frame.type !== "desktop.browser.host.event") {
|
|
258
|
+
throw new Error("Desktop Browser Host event parser returned the wrong frame");
|
|
259
|
+
}
|
|
260
|
+
await sendJson(socket, frame);
|
|
261
|
+
eventSequence = nextSequence;
|
|
262
|
+
}),
|
|
263
|
+
emitSurfaceFrame: (frame) => enqueueWrite(async () => {
|
|
264
|
+
if (activeLease.capabilities.surface !== "binary-v1") {
|
|
265
|
+
throw new Error("Desktop Browser Host Surface was not negotiated");
|
|
266
|
+
}
|
|
267
|
+
const encoded = encodeDesktopBrowserHostSurfaceFrame({
|
|
268
|
+
...frame,
|
|
269
|
+
leaseId: activeLease.leaseId,
|
|
270
|
+
});
|
|
271
|
+
await sendBinary(socket, encoded);
|
|
272
|
+
}),
|
|
273
|
+
close: async () => {
|
|
274
|
+
if (closing)
|
|
275
|
+
return closed;
|
|
276
|
+
closing = true;
|
|
277
|
+
queue.close();
|
|
278
|
+
abortActiveRequests(new Error("Desktop Browser Host stopped"));
|
|
279
|
+
if (socket.readyState < WebSocket.CLOSING)
|
|
280
|
+
socket.close(1000, "host stopped");
|
|
281
|
+
if (socket.readyState === WebSocket.CLOSED)
|
|
282
|
+
closedResolve();
|
|
283
|
+
await closed;
|
|
284
|
+
},
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
async function sendBinary(socket, bytes) {
|
|
288
|
+
if (socket.readyState !== WebSocket.OPEN)
|
|
289
|
+
throw new Error("Desktop Browser Host lease is closed");
|
|
290
|
+
if (bytes.byteLength === 0 || bytes.byteLength > DESKTOP_BROWSER_HOST_MAX_BINARY_FRAME_BYTES) {
|
|
291
|
+
throw new Error("Desktop Browser Host binary frame exceeded its bound");
|
|
292
|
+
}
|
|
293
|
+
if (socket.bufferedAmount + bytes.byteLength > MAX_BUFFERED_BYTES) {
|
|
294
|
+
throw new Error("Desktop Browser Host output buffer exceeded its bound");
|
|
295
|
+
}
|
|
296
|
+
await new Promise((resolve, reject) => {
|
|
297
|
+
socket.send(bytes, { binary: true, compress: false }, (error) => {
|
|
298
|
+
if (error)
|
|
299
|
+
reject(error);
|
|
300
|
+
else
|
|
301
|
+
resolve();
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
async function sendJson(socket, value) {
|
|
306
|
+
if (socket.readyState !== WebSocket.OPEN)
|
|
307
|
+
throw new Error("Desktop Browser Host lease is closed");
|
|
308
|
+
const bytes = Buffer.from(JSON.stringify(value), "utf8");
|
|
309
|
+
if (bytes.byteLength === 0 || bytes.byteLength > DESKTOP_BROWSER_HOST_MAX_CONTROL_FRAME_BYTES) {
|
|
310
|
+
throw new Error("Desktop Browser Host client frame exceeded its bound");
|
|
311
|
+
}
|
|
312
|
+
if (socket.bufferedAmount + bytes.byteLength > MAX_BUFFERED_BYTES) {
|
|
313
|
+
throw new Error("Desktop Browser Host output buffer exceeded its bound");
|
|
314
|
+
}
|
|
315
|
+
await new Promise((resolve, reject) => {
|
|
316
|
+
socket.send(bytes, { binary: false, compress: false }, (error) => {
|
|
317
|
+
if (error)
|
|
318
|
+
reject(error);
|
|
319
|
+
else
|
|
320
|
+
resolve();
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
class BoundedCommandQueue {
|
|
325
|
+
capacity;
|
|
326
|
+
values = [];
|
|
327
|
+
waiters = [];
|
|
328
|
+
ended = false;
|
|
329
|
+
error;
|
|
330
|
+
constructor(capacity) {
|
|
331
|
+
this.capacity = capacity;
|
|
332
|
+
}
|
|
333
|
+
push(value) {
|
|
334
|
+
if (this.ended)
|
|
335
|
+
return false;
|
|
336
|
+
const waiter = this.waiters.shift();
|
|
337
|
+
if (waiter) {
|
|
338
|
+
waiter.resolve({ done: false, value });
|
|
339
|
+
return true;
|
|
340
|
+
}
|
|
341
|
+
if (this.values.length >= this.capacity)
|
|
342
|
+
return false;
|
|
343
|
+
this.values.push(value);
|
|
344
|
+
return true;
|
|
345
|
+
}
|
|
346
|
+
close(error) {
|
|
347
|
+
if (this.ended)
|
|
348
|
+
return;
|
|
349
|
+
this.ended = true;
|
|
350
|
+
this.error = error;
|
|
351
|
+
// Commands are capabilities of this exact socket lease. Never drain a
|
|
352
|
+
// buffered command after abort/disconnect, even if it was parsed earlier.
|
|
353
|
+
this.values.length = 0;
|
|
354
|
+
for (const waiter of this.waiters.splice(0)) {
|
|
355
|
+
if (error)
|
|
356
|
+
waiter.reject(error);
|
|
357
|
+
else
|
|
358
|
+
waiter.resolve({ done: true, value: undefined });
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
[Symbol.asyncIterator]() {
|
|
362
|
+
return {
|
|
363
|
+
next: () => {
|
|
364
|
+
const value = this.values.shift();
|
|
365
|
+
if (value)
|
|
366
|
+
return Promise.resolve({ done: false, value });
|
|
367
|
+
if (this.ended) {
|
|
368
|
+
return this.error
|
|
369
|
+
? Promise.reject(this.error)
|
|
370
|
+
: Promise.resolve({ done: true, value: undefined });
|
|
371
|
+
}
|
|
372
|
+
return new Promise((resolve, reject) => this.waiters.push({ resolve, reject }));
|
|
373
|
+
},
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
function rawDataBytes(data) {
|
|
378
|
+
if (Buffer.isBuffer(data))
|
|
379
|
+
return data;
|
|
380
|
+
if (data instanceof ArrayBuffer)
|
|
381
|
+
return new Uint8Array(data);
|
|
382
|
+
if (Array.isArray(data))
|
|
383
|
+
return Buffer.concat(data);
|
|
384
|
+
throw new Error("Desktop Browser Host received an unsupported WebSocket payload");
|
|
385
|
+
}
|
|
386
|
+
function isMutatingCommand(command) {
|
|
387
|
+
return command.command.method !== "browser.snapshot" &&
|
|
388
|
+
command.command.method !== "browser.cdp-endpoint" &&
|
|
389
|
+
command.command.method !== "page.cdp-endpoint";
|
|
390
|
+
}
|
|
391
|
+
async function abortableTimeout(promise, timeoutMs, signal) {
|
|
392
|
+
signal?.throwIfAborted();
|
|
393
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
394
|
+
const combined = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
395
|
+
return await new Promise((resolve, reject) => {
|
|
396
|
+
const onAbort = () => reject(abortError(combined.reason));
|
|
397
|
+
combined.addEventListener("abort", onAbort, { once: true });
|
|
398
|
+
promise.then(resolve, reject).finally(() => combined.removeEventListener("abort", onAbort));
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
function abortError(reason) {
|
|
402
|
+
if (reason instanceof Error)
|
|
403
|
+
return reason;
|
|
404
|
+
return new Error("Desktop Browser Host connection was aborted", { cause: reason });
|
|
405
|
+
}
|
|
406
|
+
function asError(error, message) {
|
|
407
|
+
return error instanceof Error ? error : new Error(message, { cause: error });
|
|
408
|
+
}
|
|
409
|
+
function assertLoopbackOrigin(origin) {
|
|
410
|
+
const url = new URL(origin);
|
|
411
|
+
const host = url.hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
412
|
+
if (host !== "localhost" && host !== "127.0.0.1" && host !== "::1") {
|
|
413
|
+
throw new Error("resident daemon Desktop Browser Host endpoint is not loopback");
|
|
414
|
+
}
|
|
415
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type DirectRuntimeServerHello } from "@rynx-ai/protocol/direct-runtime";
|
|
2
|
+
import type { DaemonRuntimeHost, DirectRuntimeAuthenticationResult, DirectRuntimeAuthenticator } from "@rynx-ai/server";
|
|
3
|
+
import type { DaemonInstallationIdentity } from "./installation-id.js";
|
|
4
|
+
import { RemoteRuntimeAccessStore } from "./remote-runtime-access-store.js";
|
|
5
|
+
/** Daemon-side proof verification shared by Direct control and Browser Surface bindings. */
|
|
6
|
+
export declare class DaemonDirectRuntimeAuthenticator implements DirectRuntimeAuthenticator {
|
|
7
|
+
private readonly identity;
|
|
8
|
+
private readonly runtime;
|
|
9
|
+
private readonly access;
|
|
10
|
+
constructor(identity: DaemonInstallationIdentity, runtime: DaemonRuntimeHost, access: RemoteRuntimeAccessStore);
|
|
11
|
+
createServerHello({ channelBinding, role, signal, }: Parameters<DirectRuntimeAuthenticator["createServerHello"]>[0]): DirectRuntimeServerHello;
|
|
12
|
+
authenticate({ channelBinding, role, serverHello, clientAuthenticate, signal, }: Parameters<DirectRuntimeAuthenticator["authenticate"]>[0]): DirectRuntimeAuthenticationResult;
|
|
13
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { createHash, createPublicKey, randomBytes, randomUUID, verify, } from "node:crypto";
|
|
2
|
+
import { DIRECT_RUNTIME_BINDING_PROTOCOL, DIRECT_RUNTIME_BROWSER_INSPECT_BINDING_PROTOCOL, DIRECT_RUNTIME_BROWSER_SURFACE_BINDING_PROTOCOL, encodeClientAuthenticateCanonical, encodeClientAuthenticateProofPayload, encodeServerHelloCanonical, parseServerAccepted, parseServerHello, } from "@rynx-ai/protocol/direct-runtime";
|
|
3
|
+
/** Daemon-side proof verification shared by Direct control and Browser Surface bindings. */
|
|
4
|
+
export class DaemonDirectRuntimeAuthenticator {
|
|
5
|
+
identity;
|
|
6
|
+
runtime;
|
|
7
|
+
access;
|
|
8
|
+
constructor(identity, runtime, access) {
|
|
9
|
+
this.identity = identity;
|
|
10
|
+
this.runtime = runtime;
|
|
11
|
+
this.access = access;
|
|
12
|
+
}
|
|
13
|
+
createServerHello({ channelBinding, role, signal, }) {
|
|
14
|
+
signal.throwIfAborted();
|
|
15
|
+
const status = this.runtime.status();
|
|
16
|
+
if (status.daemonId !== this.identity.installationId) {
|
|
17
|
+
throw new Error("daemon Runtime identity does not match its installation identity");
|
|
18
|
+
}
|
|
19
|
+
const fields = {
|
|
20
|
+
type: "server.hello",
|
|
21
|
+
role,
|
|
22
|
+
daemonId: status.daemonId,
|
|
23
|
+
daemonInstanceId: status.daemonInstanceId,
|
|
24
|
+
identityPublicKey: this.identity.publicKey,
|
|
25
|
+
nonce: randomBytes(32).toString("base64url"),
|
|
26
|
+
bindingProtocolVersion: bindingProtocol(role).currentVersion,
|
|
27
|
+
minimumCompatibleBindingVersion: bindingProtocol(role).minimumCompatibleVersion,
|
|
28
|
+
coreProtocolVersion: status.coreProtocolVersion,
|
|
29
|
+
minimumCompatibleCoreVersion: status.minimumCompatibleCoreVersion,
|
|
30
|
+
};
|
|
31
|
+
return parseServerHello({
|
|
32
|
+
...fields,
|
|
33
|
+
signature: this.identity.signDirectRuntimeServerHello(channelBinding, fields),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
authenticate({ channelBinding, role, serverHello, clientAuthenticate, signal, }) {
|
|
37
|
+
signal.throwIfAborted();
|
|
38
|
+
if (clientAuthenticate.role !== role || serverHello.role !== role) {
|
|
39
|
+
throw new Error("unsupported Direct Runtime channel role");
|
|
40
|
+
}
|
|
41
|
+
if (role !== "control" && clientAuthenticate.mode !== "connect") {
|
|
42
|
+
throw new Error(`${role === "browser-surface" ? "Browser Surface" : "Browser Inspect"} channels require an existing Direct Runtime grant`);
|
|
43
|
+
}
|
|
44
|
+
if (clientAuthenticate.daemonId !== serverHello.daemonId ||
|
|
45
|
+
clientAuthenticate.daemonInstanceId !== serverHello.daemonInstanceId ||
|
|
46
|
+
clientAuthenticate.bindingProtocolVersion <
|
|
47
|
+
serverHello.minimumCompatibleBindingVersion ||
|
|
48
|
+
clientAuthenticate.bindingProtocolVersion > serverHello.bindingProtocolVersion ||
|
|
49
|
+
clientAuthenticate.coreProtocolVersion < serverHello.minimumCompatibleCoreVersion ||
|
|
50
|
+
clientAuthenticate.coreProtocolVersion > serverHello.coreProtocolVersion) {
|
|
51
|
+
throw new Error("Direct Runtime authentication negotiation does not match server hello");
|
|
52
|
+
}
|
|
53
|
+
const helloFields = withoutSignature(serverHello);
|
|
54
|
+
const authenticateFields = withoutSignature(clientAuthenticate);
|
|
55
|
+
const helloHash = sha256(encodeServerHelloCanonical(helloFields));
|
|
56
|
+
const authenticateHash = sha256(encodeClientAuthenticateCanonical(authenticateFields));
|
|
57
|
+
const clientPublicKey = createEd25519PublicKey(clientAuthenticate.clientPublicKey);
|
|
58
|
+
const clientProof = encodeClientAuthenticateProofPayload(channelBinding, helloHash, authenticateFields);
|
|
59
|
+
if (!verify(null, clientProof, clientPublicKey, Buffer.from(clientAuthenticate.signature, "base64url"))) {
|
|
60
|
+
throw new Error("Direct Runtime client proof is invalid");
|
|
61
|
+
}
|
|
62
|
+
signal.throwIfAborted();
|
|
63
|
+
const grant = clientAuthenticate.mode === "enroll"
|
|
64
|
+
? this.access.redeemPairingClaim({
|
|
65
|
+
claimId: clientAuthenticate.claimId,
|
|
66
|
+
claimSecret: clientAuthenticate.claimSecret,
|
|
67
|
+
clientId: clientAuthenticate.clientId,
|
|
68
|
+
clientPublicKey: clientAuthenticate.clientPublicKey,
|
|
69
|
+
})
|
|
70
|
+
: this.access.resolveActiveGrant({
|
|
71
|
+
grantId: clientAuthenticate.grantId,
|
|
72
|
+
clientId: clientAuthenticate.clientId,
|
|
73
|
+
clientPublicKey: clientAuthenticate.clientPublicKey,
|
|
74
|
+
});
|
|
75
|
+
signal.throwIfAborted();
|
|
76
|
+
const acceptedFields = {
|
|
77
|
+
type: "server.accepted",
|
|
78
|
+
role,
|
|
79
|
+
daemonId: serverHello.daemonId,
|
|
80
|
+
daemonInstanceId: serverHello.daemonInstanceId,
|
|
81
|
+
bindingProtocolVersion: clientAuthenticate.bindingProtocolVersion,
|
|
82
|
+
coreProtocolVersion: clientAuthenticate.coreProtocolVersion,
|
|
83
|
+
channelId: randomUUID(),
|
|
84
|
+
grantId: grant.grantId,
|
|
85
|
+
clientId: grant.clientId,
|
|
86
|
+
authorities: ["daemon.full"],
|
|
87
|
+
};
|
|
88
|
+
const accepted = parseServerAccepted({
|
|
89
|
+
...acceptedFields,
|
|
90
|
+
signature: this.identity.signDirectRuntimeServerAccepted(channelBinding, helloHash, authenticateHash, acceptedFields),
|
|
91
|
+
});
|
|
92
|
+
return {
|
|
93
|
+
peer: {
|
|
94
|
+
clientId: grant.clientId,
|
|
95
|
+
grantId: grant.grantId,
|
|
96
|
+
subject: {
|
|
97
|
+
subjectId: `client:${grant.clientId}`,
|
|
98
|
+
effectiveAuthorities: [...grant.authorities],
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
accepted,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function bindingProtocol(role) {
|
|
106
|
+
switch (role) {
|
|
107
|
+
case "control":
|
|
108
|
+
return DIRECT_RUNTIME_BINDING_PROTOCOL;
|
|
109
|
+
case "browser-surface":
|
|
110
|
+
return DIRECT_RUNTIME_BROWSER_SURFACE_BINDING_PROTOCOL;
|
|
111
|
+
case "browser-inspect":
|
|
112
|
+
return DIRECT_RUNTIME_BROWSER_INSPECT_BINDING_PROTOCOL;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
function withoutSignature(value) {
|
|
116
|
+
const { signature: _signature, ...fields } = value;
|
|
117
|
+
return fields;
|
|
118
|
+
}
|
|
119
|
+
function sha256(value) {
|
|
120
|
+
return Uint8Array.from(createHash("sha256").update(value).digest());
|
|
121
|
+
}
|
|
122
|
+
function createEd25519PublicKey(encoded) {
|
|
123
|
+
const key = createPublicKey({
|
|
124
|
+
key: Buffer.from(encoded, "base64url"),
|
|
125
|
+
format: "der",
|
|
126
|
+
type: "spki",
|
|
127
|
+
});
|
|
128
|
+
if (key.asymmetricKeyType !== "ed25519") {
|
|
129
|
+
throw new Error("Direct Runtime client key must use Ed25519");
|
|
130
|
+
}
|
|
131
|
+
return key;
|
|
132
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface DirectRuntimeConfig {
|
|
2
|
+
bindHost: string;
|
|
3
|
+
/** Preferred port. The daemon replaces this with the actual bound port. */
|
|
4
|
+
port: number;
|
|
5
|
+
/** Present only in automatic mode. Candidates are tried after `port`. */
|
|
6
|
+
fallbackPorts?: readonly number[];
|
|
7
|
+
/** The preferred port came from advisory persisted state, so any bind failure may skip it. */
|
|
8
|
+
preferredPortIsPersistedFallback?: boolean;
|
|
9
|
+
/** Present only in automatic mode. Records the successfully bound port. */
|
|
10
|
+
recordListeningPort?: (port: number) => Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Resolve the daemon's always-available Direct Runtime listener.
|
|
14
|
+
*
|
|
15
|
+
* Normal operation has no certificate, advertised-route, or enablement
|
|
16
|
+
* configuration. The two RYNX_RUNTIME_* inputs are intentionally narrow
|
|
17
|
+
* deployment/test seams for overriding the socket bind only.
|
|
18
|
+
*/
|
|
19
|
+
export declare function loadDirectRuntimeConfig(env?: NodeJS.ProcessEnv): Promise<DirectRuntimeConfig>;
|
|
20
|
+
export declare function directRuntimeFallbackPortFile(env?: NodeJS.ProcessEnv): string;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { rynxHome } from "@rynx-ai/core";
|
|
5
|
+
const DEFAULT_BIND_HOST = "0.0.0.0";
|
|
6
|
+
const DEFAULT_PORT = 7443;
|
|
7
|
+
const FALLBACK_PORT_FILE = "direct-runtime-port";
|
|
8
|
+
/**
|
|
9
|
+
* Resolve the daemon's always-available Direct Runtime listener.
|
|
10
|
+
*
|
|
11
|
+
* Normal operation has no certificate, advertised-route, or enablement
|
|
12
|
+
* configuration. The two RYNX_RUNTIME_* inputs are intentionally narrow
|
|
13
|
+
* deployment/test seams for overriding the socket bind only.
|
|
14
|
+
*/
|
|
15
|
+
export async function loadDirectRuntimeConfig(env = process.env) {
|
|
16
|
+
const bindHost = parseBindHost(env.RYNX_RUNTIME_BIND_HOST);
|
|
17
|
+
const explicitPort = parseExplicitPort(env.RYNX_RUNTIME_PORT);
|
|
18
|
+
if (explicitPort !== undefined)
|
|
19
|
+
return { bindHost, port: explicitPort };
|
|
20
|
+
const persistedPort = await readPersistedFallbackPort(env);
|
|
21
|
+
return {
|
|
22
|
+
bindHost,
|
|
23
|
+
port: persistedPort ?? DEFAULT_PORT,
|
|
24
|
+
fallbackPorts: persistedPort === undefined
|
|
25
|
+
? [0]
|
|
26
|
+
: [DEFAULT_PORT, 0],
|
|
27
|
+
...(persistedPort === undefined
|
|
28
|
+
? {}
|
|
29
|
+
: { preferredPortIsPersistedFallback: true }),
|
|
30
|
+
recordListeningPort: (port) => persistFallbackPort(port, env),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
export function directRuntimeFallbackPortFile(env = process.env) {
|
|
34
|
+
return join(rynxHome(env), FALLBACK_PORT_FILE);
|
|
35
|
+
}
|
|
36
|
+
function parseBindHost(value) {
|
|
37
|
+
const host = value?.trim() || DEFAULT_BIND_HOST;
|
|
38
|
+
if (/\s|\//.test(host)) {
|
|
39
|
+
throw new Error("RYNX_RUNTIME_BIND_HOST is invalid");
|
|
40
|
+
}
|
|
41
|
+
return host;
|
|
42
|
+
}
|
|
43
|
+
function parseExplicitPort(value) {
|
|
44
|
+
if (value === undefined || value.trim() === "")
|
|
45
|
+
return undefined;
|
|
46
|
+
if (!/^\d{1,5}$/.test(value)) {
|
|
47
|
+
throw new Error("RYNX_RUNTIME_PORT must be an integer from 1 to 65535");
|
|
48
|
+
}
|
|
49
|
+
const port = Number(value);
|
|
50
|
+
if (port < 1 || port > 65_535) {
|
|
51
|
+
throw new Error("RYNX_RUNTIME_PORT must be an integer from 1 to 65535");
|
|
52
|
+
}
|
|
53
|
+
return port;
|
|
54
|
+
}
|
|
55
|
+
async function readPersistedFallbackPort(env) {
|
|
56
|
+
let value;
|
|
57
|
+
try {
|
|
58
|
+
value = (await readFile(directRuntimeFallbackPortFile(env), "utf8")).trim();
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
if (hasCode(error, "ENOENT"))
|
|
62
|
+
return undefined;
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
if (!/^\d{1,5}$/.test(value))
|
|
66
|
+
return undefined;
|
|
67
|
+
const port = Number(value);
|
|
68
|
+
return port >= 1 && port <= 65_535 && port !== DEFAULT_PORT
|
|
69
|
+
? port
|
|
70
|
+
: undefined;
|
|
71
|
+
}
|
|
72
|
+
async function persistFallbackPort(port, env) {
|
|
73
|
+
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
|
|
74
|
+
throw new Error("Direct Runtime listening port must be an integer from 1 to 65535");
|
|
75
|
+
}
|
|
76
|
+
const file = directRuntimeFallbackPortFile(env);
|
|
77
|
+
if (port === DEFAULT_PORT) {
|
|
78
|
+
await rm(file, { force: true });
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const directory = rynxHome(env);
|
|
82
|
+
const temporary = `${file}.tmp-${process.pid}-${randomUUID()}`;
|
|
83
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
84
|
+
try {
|
|
85
|
+
await writeFile(temporary, `${port}\n`, {
|
|
86
|
+
encoding: "utf8",
|
|
87
|
+
flag: "wx",
|
|
88
|
+
mode: 0o600,
|
|
89
|
+
});
|
|
90
|
+
await rename(temporary, file);
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
await rm(temporary, { force: true });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function hasCode(error, code) {
|
|
97
|
+
return Boolean(error &&
|
|
98
|
+
typeof error === "object" &&
|
|
99
|
+
"code" in error &&
|
|
100
|
+
error.code === code);
|
|
101
|
+
}
|