@rynx-ai/daemon 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.
Files changed (89) hide show
  1. package/dist/browser-cli-args.d.ts +20 -0
  2. package/dist/browser-cli-args.js +100 -0
  3. package/dist/browser-runner-session-context.d.ts +24 -0
  4. package/dist/browser-runner-session-context.js +113 -0
  5. package/dist/cdp-keyboard-input.d.ts +6 -0
  6. package/dist/cdp-keyboard-input.js +209 -0
  7. package/dist/channel-store.d.ts +0 -6
  8. package/dist/channel-store.js +2 -18
  9. package/dist/chrome-for-testing-release-resolver.d.ts +34 -0
  10. package/dist/chrome-for-testing-release-resolver.js +214 -0
  11. package/dist/chrome-for-testing-store.d.ts +85 -0
  12. package/dist/chrome-for-testing-store.js +836 -0
  13. package/dist/cli.js +467 -30
  14. package/dist/control-client.d.ts +113 -0
  15. package/dist/control-client.js +792 -0
  16. package/dist/control-deps.js +37 -6
  17. package/dist/control-endpoint.d.ts +14 -0
  18. package/dist/control-endpoint.js +124 -0
  19. package/dist/control-link-store.d.ts +16 -0
  20. package/dist/control-link-store.js +53 -0
  21. package/dist/daemon-owner.d.ts +14 -0
  22. package/dist/daemon-owner.js +104 -0
  23. package/dist/daemon-server.d.ts +12 -2
  24. package/dist/daemon-server.js +559 -28
  25. package/dist/db.js +140 -10
  26. package/dist/desktop-browser-host-client.d.ts +34 -0
  27. package/dist/desktop-browser-host-client.js +413 -0
  28. package/dist/direct-runtime-authenticator.d.ts +13 -0
  29. package/dist/direct-runtime-authenticator.js +127 -0
  30. package/dist/direct-runtime-config.d.ts +20 -0
  31. package/dist/direct-runtime-config.js +101 -0
  32. package/dist/embedded-browser-profile-cleanup.d.ts +58 -0
  33. package/dist/embedded-browser-profile-cleanup.js +338 -0
  34. package/dist/headless-browser-host.d.ts +14 -0
  35. package/dist/headless-browser-host.js +2156 -0
  36. package/dist/index-daemon.js +28 -5
  37. package/dist/installation-id.d.ts +33 -0
  38. package/dist/installation-id.js +692 -0
  39. package/dist/migrations/cleanup-legacy-sessions.d.ts +0 -3
  40. package/dist/migrations/cleanup-legacy-sessions.js +10 -28
  41. package/dist/plugin-cli.d.ts +30 -0
  42. package/dist/plugin-cli.js +270 -0
  43. package/dist/plugin-host-rpc.d.ts +85 -0
  44. package/dist/plugin-host-rpc.js +878 -0
  45. package/dist/plugin-install-restart.d.ts +13 -0
  46. package/dist/plugin-install-restart.js +24 -0
  47. package/dist/plugin-installer.d.ts +36 -15
  48. package/dist/plugin-installer.js +420 -155
  49. package/dist/plugin-package.d.ts +38 -0
  50. package/dist/plugin-package.js +553 -0
  51. package/dist/plugin-runtime-control.d.ts +2 -0
  52. package/dist/plugin-runtime-control.js +37 -0
  53. package/dist/plugin-store.d.ts +37 -8
  54. package/dist/plugin-store.js +80 -15
  55. package/dist/plugin-supervisor.d.ts +160 -0
  56. package/dist/plugin-supervisor.js +1143 -0
  57. package/dist/pm2.d.ts +3 -1
  58. package/dist/pm2.js +55 -13
  59. package/dist/provider-cli-service.d.ts +46 -0
  60. package/dist/provider-cli-service.js +510 -0
  61. package/dist/registry.d.ts +6 -19
  62. package/dist/registry.js +22 -87
  63. package/dist/remote-runtime-access-store.d.ts +61 -0
  64. package/dist/remote-runtime-access-store.js +386 -0
  65. package/dist/remote-runtime-admin.d.ts +32 -0
  66. package/dist/remote-runtime-admin.js +107 -0
  67. package/dist/remote-runtime-connection-manager.d.ts +92 -0
  68. package/dist/remote-runtime-connection-manager.js +1409 -0
  69. package/dist/remote-runtime-credential-store.d.ts +45 -0
  70. package/dist/remote-runtime-credential-store.js +639 -0
  71. package/dist/remote-runtime-target-control.d.ts +47 -0
  72. package/dist/remote-runtime-target-control.js +433 -0
  73. package/dist/remote-runtime-target-store.d.ts +53 -0
  74. package/dist/remote-runtime-target-store.js +307 -0
  75. package/dist/runtime-share-addresses.d.ts +21 -0
  76. package/dist/runtime-share-addresses.js +50 -0
  77. package/dist/session-browser-capability-store.d.ts +13 -0
  78. package/dist/session-browser-capability-store.js +58 -0
  79. package/dist/session-emulator-binding-store.d.ts +10 -0
  80. package/dist/session-emulator-binding-store.js +61 -0
  81. package/dist/session-launch-operations.d.ts +41 -0
  82. package/dist/session-launch-operations.js +124 -0
  83. package/dist/session-meta-store.d.ts +3 -2
  84. package/dist/session-meta-store.js +57 -7
  85. package/dist/setup.js +9 -4
  86. package/dist/skills-catalog.js +8 -4
  87. package/dist/update.d.ts +11 -2
  88. package/dist/update.js +0 -27
  89. package/package.json +18 -10
package/dist/db.js CHANGED
@@ -1,25 +1,30 @@
1
1
  /**
2
2
  * `~/.rynx/rynx.db` — the daemon's SQLite store. Concerns kept apart:
3
- * - `channels` — a channel = a plugin type + its config/credentials (`options`).
4
- * The transport/connection, configured & authorized on its own.
5
- * - `instances` — a runnable binding: a channel + an agent (1:1 with a channel,
3
+ * - `channels` — a chat type + its plugin-owned config/credentials.
4
+ * - `instances` a runnable binding: a chat channel + an agent (1:1 with a channel,
6
5
  * enforced by a UNIQUE channel_id). This is what the daemon mounts.
7
- * - `plugins` — installed plugins (CLI-managed) the loader imports.
8
- * - `session_items` — the canonical, append-only conversation log (one row per
9
- * {@link import("@rynx-ai/core").SessionItem}); see {@link import("./session-log-store.js")}.
6
+ * - `plugins` — static manifests, grants and immutable install locations.
7
+ * - `plugin_session_launch_operations` — durable idempotency/tombstones.
8
+ * - `control_links` — generic, one-use links to the local session console.
9
+ * - `runtime_pairing_claims` / `runtime_client_grants` — one-use enrollment
10
+ * claims and the durable client identities they issue for direct runtimes.
11
+ * - `runtime_targets` / `runtime_direct_routes` / `runtime_pending_enrollments`
12
+ * — client-local daemon trust, replaceable location, and crash recovery.
13
+ * - `session_items` — the canonical, append-only conversation log; see
14
+ * {@link import("./session-log-store.js")}.
10
15
  *
11
16
  * Connections are cached per resolved path so tests can point `RYNX_DB` /
12
17
  * `RYNX_HOME` at a temp dir. Override with `RYNX_DB` (full path) or `RYNX_HOME`.
13
18
  */
14
19
  import { randomUUID } from "node:crypto";
15
20
  import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync } from "node:fs";
21
+ import { homedir } from "node:os";
16
22
  import { dirname, join } from "node:path";
17
23
  import Database from "better-sqlite3";
18
- import { rynxHome } from "@rynx-ai/core";
19
24
  const connections = new Map();
20
25
  /** Absolute path to the SQLite file (override via `RYNX_DB`, else `~/.rynx/rynx.db`). */
21
26
  export function dbPath() {
22
- return process.env.RYNX_DB?.trim() || join(rynxHome(), "rynx.db");
27
+ return process.env.RYNX_DB?.trim() || join(daemonHome(), "rynx.db");
23
28
  }
24
29
  /** Open (or reuse) the database at the current path, migrating + importing on first open. */
25
30
  export function db() {
@@ -50,7 +55,12 @@ function migrate(conn) {
50
55
  source TEXT NOT NULL,
51
56
  version TEXT,
52
57
  enabled INTEGER NOT NULL DEFAULT 1,
53
- installed_at TEXT NOT NULL
58
+ installed_at TEXT NOT NULL,
59
+ updated_at TEXT,
60
+ install_path TEXT,
61
+ manifest TEXT,
62
+ integrity TEXT,
63
+ grants TEXT
54
64
  );
55
65
  CREATE TABLE IF NOT EXISTS channels (
56
66
  id TEXT PRIMARY KEY,
@@ -83,6 +93,7 @@ function migrate(conn) {
83
93
  CREATE INDEX IF NOT EXISTS idx_session_items_session ON session_items(session_id, position);
84
94
  CREATE TABLE IF NOT EXISTS sessions (
85
95
  id TEXT PRIMARY KEY,
96
+ provider TEXT,
86
97
  agent TEXT,
87
98
  config TEXT,
88
99
  model TEXT,
@@ -92,6 +103,111 @@ function migrate(conn) {
92
103
  created_at TEXT NOT NULL,
93
104
  updated_at TEXT
94
105
  );
106
+ CREATE TABLE IF NOT EXISTS session_emulator_bindings (
107
+ session_id TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE,
108
+ binding_id TEXT NOT NULL UNIQUE,
109
+ backend TEXT NOT NULL CHECK(backend IN ('ios', 'android')),
110
+ device_id TEXT NOT NULL,
111
+ device_name TEXT,
112
+ attached_at TEXT NOT NULL,
113
+ UNIQUE(backend, device_id)
114
+ );
115
+ CREATE TABLE IF NOT EXISTS plugin_session_launch_operations (
116
+ plugin_id TEXT NOT NULL,
117
+ scope_id TEXT NOT NULL,
118
+ operation_id TEXT NOT NULL,
119
+ request_hash TEXT NOT NULL,
120
+ session_id TEXT NOT NULL,
121
+ state TEXT NOT NULL DEFAULT 'active' CHECK(state IN ('active', 'deleted')),
122
+ created_at TEXT NOT NULL,
123
+ updated_at TEXT NOT NULL,
124
+ deleted_at TEXT,
125
+ PRIMARY KEY(plugin_id, scope_id, operation_id),
126
+ CHECK(
127
+ (state = 'active' AND deleted_at IS NULL)
128
+ OR
129
+ (state = 'deleted' AND deleted_at IS NOT NULL)
130
+ )
131
+ );
132
+ CREATE INDEX IF NOT EXISTS idx_plugin_session_launch_operations_session
133
+ ON plugin_session_launch_operations(session_id);
134
+ CREATE TABLE IF NOT EXISTS control_links (
135
+ token TEXT PRIMARY KEY,
136
+ plugin_id TEXT NOT NULL,
137
+ session_id TEXT NOT NULL,
138
+ created_at TEXT NOT NULL,
139
+ expires_at TEXT NOT NULL,
140
+ used_at TEXT
141
+ );
142
+ CREATE INDEX IF NOT EXISTS idx_control_links_expires
143
+ ON control_links(expires_at);
144
+ CREATE TABLE IF NOT EXISTS runtime_pairing_claims (
145
+ claim_id TEXT PRIMARY KEY,
146
+ secret_hash TEXT NOT NULL,
147
+ authorities TEXT NOT NULL,
148
+ client_label TEXT,
149
+ created_at TEXT NOT NULL,
150
+ expires_at TEXT NOT NULL,
151
+ consumed_at TEXT,
152
+ consumed_client_id TEXT,
153
+ consumed_client_public_key TEXT,
154
+ consumed_grant_id TEXT,
155
+ CHECK (
156
+ (consumed_at IS NULL
157
+ AND consumed_client_id IS NULL
158
+ AND consumed_client_public_key IS NULL
159
+ AND consumed_grant_id IS NULL)
160
+ OR
161
+ (consumed_at IS NOT NULL
162
+ AND consumed_client_id IS NOT NULL
163
+ AND consumed_client_public_key IS NOT NULL
164
+ AND consumed_grant_id IS NOT NULL)
165
+ )
166
+ );
167
+ CREATE INDEX IF NOT EXISTS idx_runtime_pairing_claims_expires
168
+ ON runtime_pairing_claims(expires_at);
169
+ CREATE TABLE IF NOT EXISTS runtime_client_grants (
170
+ grant_id TEXT PRIMARY KEY,
171
+ client_id TEXT NOT NULL,
172
+ client_public_key TEXT NOT NULL UNIQUE,
173
+ client_label TEXT,
174
+ authorities TEXT NOT NULL,
175
+ created_at TEXT NOT NULL,
176
+ revoked_at TEXT,
177
+ last_used_at TEXT
178
+ );
179
+ CREATE INDEX IF NOT EXISTS idx_runtime_client_grants_client
180
+ ON runtime_client_grants(client_id);
181
+ CREATE TABLE IF NOT EXISTS runtime_targets (
182
+ daemon_id TEXT PRIMARY KEY,
183
+ display_name TEXT NOT NULL,
184
+ identity_public_key TEXT NOT NULL,
185
+ credential_ref TEXT NOT NULL UNIQUE,
186
+ created_at TEXT NOT NULL,
187
+ updated_at TEXT NOT NULL
188
+ );
189
+ CREATE TABLE IF NOT EXISTS runtime_direct_routes (
190
+ daemon_id TEXT PRIMARY KEY REFERENCES runtime_targets(daemon_id) ON DELETE CASCADE,
191
+ endpoint TEXT NOT NULL,
192
+ tls_ca_certificate_pem TEXT,
193
+ updated_at TEXT NOT NULL
194
+ );
195
+ CREATE TABLE IF NOT EXISTS runtime_pending_enrollments (
196
+ claim_id TEXT PRIMARY KEY,
197
+ daemon_id TEXT NOT NULL UNIQUE,
198
+ identity_public_key TEXT NOT NULL,
199
+ credential_ref TEXT NOT NULL UNIQUE,
200
+ offer_fingerprint TEXT NOT NULL,
201
+ expires_at TEXT NOT NULL,
202
+ created_at TEXT NOT NULL
203
+ );
204
+ CREATE INDEX IF NOT EXISTS idx_runtime_pending_enrollments_expires
205
+ ON runtime_pending_enrollments(expires_at);
206
+ `);
207
+ // Breaking plugin-runtime cutover: remove obsolete core-owned state.
208
+ conn.exec(`
209
+ DROP TABLE IF EXISTS control_sessions;
210
+ DROP TABLE IF EXISTS plugin_daemon_worker_settings;
95
211
  `);
96
212
  // `sessions` is the unified machine-session record (console + every channel).
97
213
  // Add the identity columns to a pre-existing table (SQLite has no
@@ -99,7 +215,18 @@ function migrate(conn) {
99
215
  // this change — they were all console sessions.
100
216
  addColumnIfMissing(conn, "sessions", "source", "TEXT");
101
217
  addColumnIfMissing(conn, "sessions", "updated_at", "TEXT");
218
+ addColumnIfMissing(conn, "sessions", "provider", "TEXT");
219
+ const sessionColumns = conn.prepare("PRAGMA table_info(sessions)").all();
220
+ if (sessionColumns.some((column) => column.name === "executor")) {
221
+ conn.exec("UPDATE sessions SET provider = executor WHERE provider IS NULL AND executor IS NOT NULL");
222
+ }
223
+ addColumnIfMissing(conn, "plugins", "updated_at", "TEXT");
224
+ addColumnIfMissing(conn, "plugins", "install_path", "TEXT");
225
+ addColumnIfMissing(conn, "plugins", "manifest", "TEXT");
226
+ addColumnIfMissing(conn, "plugins", "integrity", "TEXT");
227
+ addColumnIfMissing(conn, "plugins", "grants", "TEXT");
102
228
  conn.exec("UPDATE sessions SET source = 'console' WHERE source IS NULL");
229
+ conn.exec("UPDATE plugins SET updated_at = installed_at WHERE updated_at IS NULL");
103
230
  importLegacyConfig(conn);
104
231
  }
105
232
  /** Idempotently add a column to an existing table (no-op if already present). */
@@ -121,7 +248,7 @@ function importLegacyConfig(conn) {
121
248
  const { n } = conn.prepare("SELECT COUNT(*) AS n FROM channels").get();
122
249
  if (n > 0)
123
250
  return;
124
- const legacy = process.env.RYNX_CONFIG?.trim() || join(rynxHome(), "config.json");
251
+ const legacy = process.env.RYNX_CONFIG?.trim() || join(daemonHome(), "config.json");
125
252
  if (!existsSync(legacy))
126
253
  return;
127
254
  let channels;
@@ -168,3 +295,6 @@ function importLegacyConfig(conn) {
168
295
  /* leave the source in place if it can't be renamed */
169
296
  }
170
297
  }
298
+ function daemonHome() {
299
+ return process.env.RYNX_HOME?.trim() || join(homedir(), ".rynx");
300
+ }
@@ -0,0 +1,34 @@
1
+ import { type DesktopBrowserHostCommandFrame, type DesktopBrowserHostErrorCode, type DesktopBrowserHostEvent, type DesktopBrowserHostLeaseFrame, type DesktopBrowserHostSurfaceCapability, type DesktopBrowserHostSurfaceFrame } from "@rynx-ai/protocol/desktop-browser-host";
2
+ export interface ResidentDesktopBrowserHostConnectOptions {
3
+ hostInstanceId: string;
4
+ /** Advertise only handlers that are installed in this main-process Host. */
5
+ capabilities: {
6
+ semanticPageBinding: boolean;
7
+ surface: DesktopBrowserHostSurfaceCapability;
8
+ };
9
+ signal?: AbortSignal;
10
+ }
11
+ export interface ResidentDesktopBrowserHostFailure {
12
+ code: DesktopBrowserHostErrorCode;
13
+ message: string;
14
+ }
15
+ /**
16
+ * Process-private lease used by Electron main. The management credential is
17
+ * resolved and consumed in this package and never returned to the app renderer.
18
+ */
19
+ export interface ResidentDesktopBrowserHostCommandRequest {
20
+ readonly frame: DesktopBrowserHostCommandFrame;
21
+ /** Aborts at the daemon-supplied absolute deadline or on lease loss. */
22
+ readonly signal: AbortSignal;
23
+ reply(result: unknown): Promise<void>;
24
+ reject(failure: ResidentDesktopBrowserHostFailure): Promise<void>;
25
+ }
26
+ export interface ResidentDesktopBrowserHostConnection extends AsyncIterable<ResidentDesktopBrowserHostCommandRequest> {
27
+ readonly lease: DesktopBrowserHostLeaseFrame;
28
+ readonly closed: Promise<void>;
29
+ emit(event: DesktopBrowserHostEvent): Promise<void>;
30
+ emitSurfaceFrame(frame: Omit<DesktopBrowserHostSurfaceFrame, "leaseId">): Promise<void>;
31
+ close(): Promise<void>;
32
+ }
33
+ /** Open the sole resident daemon's authenticated loopback Desktop Host lease. */
34
+ export declare function connectResidentDesktopBrowserHost(options: ResidentDesktopBrowserHostConnectOptions): Promise<ResidentDesktopBrowserHostConnection>;
@@ -0,0 +1,413 @@
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
+ surface: options.capabilities.surface,
16
+ },
17
+ });
18
+ if (hello.type !== "desktop.browser.host.hello") {
19
+ throw new Error("Desktop Browser Host hello parser returned the wrong frame");
20
+ }
21
+ const endpoint = await ensureDaemonControlEndpoint({ signal: options.signal });
22
+ assertLoopbackOrigin(endpoint.origin);
23
+ const url = new URL(DESKTOP_BROWSER_HOST_PATH, `${endpoint.origin}/`);
24
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
25
+ const socket = new WebSocket(url, {
26
+ headers: { "x-rynx-management-token": endpoint.managementToken },
27
+ handshakeTimeout: CONNECT_TIMEOUT_MS,
28
+ maxPayload: DESKTOP_BROWSER_HOST_MAX_CONTROL_FRAME_BYTES,
29
+ perMessageDeflate: false,
30
+ });
31
+ const queue = new BoundedCommandQueue(DESKTOP_BROWSER_HOST_MAX_PENDING_COMMANDS);
32
+ let lease;
33
+ let leaseResolve;
34
+ let leaseReject;
35
+ const leaseReady = new Promise((resolve, reject) => {
36
+ leaseResolve = resolve;
37
+ leaseReject = reject;
38
+ });
39
+ let closedResolve;
40
+ const closed = new Promise((resolve) => {
41
+ closedResolve = resolve;
42
+ });
43
+ let terminalError;
44
+ let eventSequence = 0;
45
+ let closing = false;
46
+ let writerTail = Promise.resolve();
47
+ const activeRequests = new Map();
48
+ const abortActiveRequests = (error) => {
49
+ for (const request of activeRequests.values()) {
50
+ if (request.timer)
51
+ clearTimeout(request.timer);
52
+ request.settled = true;
53
+ request.controller.abort(error);
54
+ }
55
+ activeRequests.clear();
56
+ };
57
+ const fail = (error) => {
58
+ terminalError ??= error;
59
+ if (!lease)
60
+ leaseReject(error);
61
+ queue.close(error);
62
+ abortActiveRequests(error);
63
+ };
64
+ const failConnection = (error, code, reason) => {
65
+ fail(error);
66
+ if (socket.readyState < WebSocket.CLOSING)
67
+ socket.close(code, reason);
68
+ };
69
+ const enqueueWrite = (operation) => {
70
+ const current = writerTail.then(async () => {
71
+ if (terminalError)
72
+ throw terminalError;
73
+ await operation();
74
+ });
75
+ writerTail = current.catch(() => undefined);
76
+ return current.catch((error) => {
77
+ const failure = asError(error, "Desktop Browser Host output failed");
78
+ failConnection(failure, 1011, "host output failed");
79
+ throw failure;
80
+ });
81
+ };
82
+ const onAbort = () => {
83
+ const error = abortError(options.signal?.reason);
84
+ failConnection(error, 1000, "aborted");
85
+ };
86
+ options.signal?.addEventListener("abort", onAbort, { once: true });
87
+ socket.on("open", () => {
88
+ void enqueueWrite(() => sendJson(socket, hello));
89
+ });
90
+ socket.on("message", (raw, isBinary) => {
91
+ if (isBinary) {
92
+ failConnection(new Error("Desktop Browser Host daemon sent an unexpected binary frame"), 1003, "binary frame unsupported");
93
+ return;
94
+ }
95
+ let frame;
96
+ try {
97
+ const bytes = rawDataBytes(raw);
98
+ if (bytes.byteLength === 0 || bytes.byteLength > DESKTOP_BROWSER_HOST_MAX_CONTROL_FRAME_BYTES) {
99
+ throw new Error("Desktop Browser Host daemon frame exceeded its bound");
100
+ }
101
+ frame = parseDesktopBrowserHostServerFrame(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)));
102
+ }
103
+ catch (error) {
104
+ failConnection(asError(error, "Desktop Browser Host daemon frame was invalid"), 1007, "invalid daemon frame");
105
+ return;
106
+ }
107
+ if (!lease) {
108
+ if (frame.type !== "desktop.browser.host.lease") {
109
+ failConnection(new Error("Desktop Browser Host daemon did not begin with a lease"), 1002, "lease required");
110
+ return;
111
+ }
112
+ if (frame.capabilities.semanticPageBinding &&
113
+ !options.capabilities.semanticPageBinding) {
114
+ failConnection(new Error("Desktop Browser Host daemon enabled an unadvertised Page binding capability"), 1002, "invalid lease capabilities");
115
+ return;
116
+ }
117
+ if (frame.capabilities.surface === "binary-v1" &&
118
+ options.capabilities.surface !== "binary-v1") {
119
+ failConnection(new Error("Desktop Browser Host daemon enabled an unadvertised Surface capability"), 1002, "invalid lease capabilities");
120
+ return;
121
+ }
122
+ lease = frame;
123
+ leaseResolve(frame);
124
+ return;
125
+ }
126
+ if (frame.type !== "desktop.browser.host.command" || frame.leaseId !== lease.leaseId) {
127
+ failConnection(new Error("Desktop Browser Host daemon sent a frame outside the active lease"), 1002, "lease mismatch");
128
+ return;
129
+ }
130
+ if (!queue.push(frame)) {
131
+ failConnection(new Error("Desktop Browser Host command capacity exceeded"), 1013, "command capacity exceeded");
132
+ }
133
+ });
134
+ socket.on("error", (error) => {
135
+ failConnection(new Error("Desktop Browser Host connection failed", { cause: error }), 1011, "connection failed");
136
+ });
137
+ socket.once("close", (code, reason) => {
138
+ options.signal?.removeEventListener("abort", onAbort);
139
+ if (!terminalError && !closing) {
140
+ terminalError = new Error(`Desktop Browser Host lease closed (${code})${reason.length > 0 ? `: ${reason.toString("utf8")}` : ""}`);
141
+ }
142
+ if (!lease && terminalError)
143
+ leaseReject(terminalError);
144
+ queue.close(terminalError);
145
+ abortActiveRequests(terminalError ?? new Error("Desktop Browser Host lease closed"));
146
+ closedResolve();
147
+ });
148
+ try {
149
+ lease = await abortableTimeout(leaseReady, CONNECT_TIMEOUT_MS, options.signal);
150
+ }
151
+ catch (error) {
152
+ if (socket.readyState < WebSocket.CLOSING)
153
+ socket.close(1000, "lease failed");
154
+ throw error;
155
+ }
156
+ const activeLease = lease;
157
+ const settle = async (command, value) => {
158
+ const request = activeRequests.get(command.commandId);
159
+ if (!request || request.settled) {
160
+ throw new Error("Desktop Browser Host command is already settled or stale");
161
+ }
162
+ if (command.leaseId !== activeLease.leaseId ||
163
+ request.controller.signal.aborted ||
164
+ Date.now() >= command.deadlineUnixMilliseconds) {
165
+ expireRequest(command, request);
166
+ throw new Error("Desktop Browser Host command deadline expired before settlement");
167
+ }
168
+ request.settled = true;
169
+ if (request.timer)
170
+ clearTimeout(request.timer);
171
+ activeRequests.delete(command.commandId);
172
+ await enqueueWrite(async () => {
173
+ const frame = parseDesktopBrowserHostClientFrame(value);
174
+ if (frame.type !== "desktop.browser.host.reply") {
175
+ throw new Error("Desktop Browser Host reply parser returned the wrong frame");
176
+ }
177
+ await sendJson(socket, frame);
178
+ });
179
+ };
180
+ const expireRequest = (command, request) => {
181
+ if (request.settled)
182
+ return;
183
+ request.settled = true;
184
+ if (request.timer)
185
+ clearTimeout(request.timer);
186
+ activeRequests.delete(command.commandId);
187
+ const error = new Error(`Desktop Browser Host ${command.command.method} deadline expired`);
188
+ request.controller.abort(error);
189
+ if (isMutatingCommand(command)) {
190
+ failConnection(error, 1000, "command deadline expired");
191
+ }
192
+ };
193
+ const requestIterator = queue[Symbol.asyncIterator]();
194
+ const claimedIterator = {
195
+ next: async () => {
196
+ const next = await requestIterator.next();
197
+ if (next.done)
198
+ return { done: true, value: undefined };
199
+ const command = next.value;
200
+ if (activeRequests.has(command.commandId)) {
201
+ const error = new Error("Desktop Browser Host daemon repeated a commandId");
202
+ failConnection(error, 1002, "duplicate command");
203
+ throw error;
204
+ }
205
+ const controller = new AbortController();
206
+ const remaining = command.deadlineUnixMilliseconds - Date.now();
207
+ const request = {
208
+ controller,
209
+ settled: false,
210
+ };
211
+ request.timer = setTimeout(() => expireRequest(command, request), Math.max(1, remaining));
212
+ request.timer.unref?.();
213
+ activeRequests.set(command.commandId, request);
214
+ if (remaining <= 0) {
215
+ expireRequest(command, request);
216
+ throw new Error("Desktop Browser Host received an expired command");
217
+ }
218
+ return {
219
+ done: false,
220
+ value: {
221
+ frame: command,
222
+ signal: controller.signal,
223
+ reply: (result) => settle(command, {
224
+ type: "desktop.browser.host.reply",
225
+ protocolVersion: DESKTOP_BROWSER_HOST_PROTOCOL_VERSION,
226
+ leaseId: activeLease.leaseId,
227
+ commandId: command.commandId,
228
+ ok: true,
229
+ result,
230
+ }),
231
+ reject: (failure) => settle(command, {
232
+ type: "desktop.browser.host.reply",
233
+ protocolVersion: DESKTOP_BROWSER_HOST_PROTOCOL_VERSION,
234
+ leaseId: activeLease.leaseId,
235
+ commandId: command.commandId,
236
+ ok: false,
237
+ error: failure,
238
+ }),
239
+ },
240
+ };
241
+ },
242
+ };
243
+ return {
244
+ lease: activeLease,
245
+ closed,
246
+ [Symbol.asyncIterator]: () => claimedIterator,
247
+ emit: (event) => enqueueWrite(async () => {
248
+ const nextSequence = eventSequence + 1;
249
+ const frame = parseDesktopBrowserHostClientFrame({
250
+ type: "desktop.browser.host.event",
251
+ protocolVersion: DESKTOP_BROWSER_HOST_PROTOCOL_VERSION,
252
+ leaseId: activeLease.leaseId,
253
+ eventSequence: nextSequence,
254
+ event,
255
+ });
256
+ if (frame.type !== "desktop.browser.host.event") {
257
+ throw new Error("Desktop Browser Host event parser returned the wrong frame");
258
+ }
259
+ await sendJson(socket, frame);
260
+ eventSequence = nextSequence;
261
+ }),
262
+ emitSurfaceFrame: (frame) => enqueueWrite(async () => {
263
+ if (activeLease.capabilities.surface !== "binary-v1") {
264
+ throw new Error("Desktop Browser Host Surface was not negotiated");
265
+ }
266
+ const encoded = encodeDesktopBrowserHostSurfaceFrame({
267
+ ...frame,
268
+ leaseId: activeLease.leaseId,
269
+ });
270
+ await sendBinary(socket, encoded);
271
+ }),
272
+ close: async () => {
273
+ if (closing)
274
+ return closed;
275
+ closing = true;
276
+ queue.close();
277
+ abortActiveRequests(new Error("Desktop Browser Host stopped"));
278
+ if (socket.readyState < WebSocket.CLOSING)
279
+ socket.close(1000, "host stopped");
280
+ if (socket.readyState === WebSocket.CLOSED)
281
+ closedResolve();
282
+ await closed;
283
+ },
284
+ };
285
+ }
286
+ async function sendBinary(socket, bytes) {
287
+ if (socket.readyState !== WebSocket.OPEN)
288
+ throw new Error("Desktop Browser Host lease is closed");
289
+ if (bytes.byteLength === 0 || bytes.byteLength > DESKTOP_BROWSER_HOST_MAX_BINARY_FRAME_BYTES) {
290
+ throw new Error("Desktop Browser Host binary frame exceeded its bound");
291
+ }
292
+ if (socket.bufferedAmount + bytes.byteLength > MAX_BUFFERED_BYTES) {
293
+ throw new Error("Desktop Browser Host output buffer exceeded its bound");
294
+ }
295
+ await new Promise((resolve, reject) => {
296
+ socket.send(bytes, { binary: true, compress: false }, (error) => {
297
+ if (error)
298
+ reject(error);
299
+ else
300
+ resolve();
301
+ });
302
+ });
303
+ }
304
+ async function sendJson(socket, value) {
305
+ if (socket.readyState !== WebSocket.OPEN)
306
+ throw new Error("Desktop Browser Host lease is closed");
307
+ const bytes = Buffer.from(JSON.stringify(value), "utf8");
308
+ if (bytes.byteLength === 0 || bytes.byteLength > DESKTOP_BROWSER_HOST_MAX_CONTROL_FRAME_BYTES) {
309
+ throw new Error("Desktop Browser Host client frame exceeded its bound");
310
+ }
311
+ if (socket.bufferedAmount + bytes.byteLength > MAX_BUFFERED_BYTES) {
312
+ throw new Error("Desktop Browser Host output buffer exceeded its bound");
313
+ }
314
+ await new Promise((resolve, reject) => {
315
+ socket.send(bytes, { binary: false, compress: false }, (error) => {
316
+ if (error)
317
+ reject(error);
318
+ else
319
+ resolve();
320
+ });
321
+ });
322
+ }
323
+ class BoundedCommandQueue {
324
+ capacity;
325
+ values = [];
326
+ waiters = [];
327
+ ended = false;
328
+ error;
329
+ constructor(capacity) {
330
+ this.capacity = capacity;
331
+ }
332
+ push(value) {
333
+ if (this.ended)
334
+ return false;
335
+ const waiter = this.waiters.shift();
336
+ if (waiter) {
337
+ waiter.resolve({ done: false, value });
338
+ return true;
339
+ }
340
+ if (this.values.length >= this.capacity)
341
+ return false;
342
+ this.values.push(value);
343
+ return true;
344
+ }
345
+ close(error) {
346
+ if (this.ended)
347
+ return;
348
+ this.ended = true;
349
+ this.error = error;
350
+ // Commands are capabilities of this exact socket lease. Never drain a
351
+ // buffered command after abort/disconnect, even if it was parsed earlier.
352
+ this.values.length = 0;
353
+ for (const waiter of this.waiters.splice(0)) {
354
+ if (error)
355
+ waiter.reject(error);
356
+ else
357
+ waiter.resolve({ done: true, value: undefined });
358
+ }
359
+ }
360
+ [Symbol.asyncIterator]() {
361
+ return {
362
+ next: () => {
363
+ const value = this.values.shift();
364
+ if (value)
365
+ return Promise.resolve({ done: false, value });
366
+ if (this.ended) {
367
+ return this.error
368
+ ? Promise.reject(this.error)
369
+ : Promise.resolve({ done: true, value: undefined });
370
+ }
371
+ return new Promise((resolve, reject) => this.waiters.push({ resolve, reject }));
372
+ },
373
+ };
374
+ }
375
+ }
376
+ function rawDataBytes(data) {
377
+ if (Buffer.isBuffer(data))
378
+ return data;
379
+ if (data instanceof ArrayBuffer)
380
+ return new Uint8Array(data);
381
+ if (Array.isArray(data))
382
+ return Buffer.concat(data);
383
+ throw new Error("Desktop Browser Host received an unsupported WebSocket payload");
384
+ }
385
+ function isMutatingCommand(command) {
386
+ return command.command.method !== "browser.snapshot" &&
387
+ command.command.method !== "browser.cdp-endpoint";
388
+ }
389
+ async function abortableTimeout(promise, timeoutMs, signal) {
390
+ signal?.throwIfAborted();
391
+ const timeout = AbortSignal.timeout(timeoutMs);
392
+ const combined = signal ? AbortSignal.any([signal, timeout]) : timeout;
393
+ return await new Promise((resolve, reject) => {
394
+ const onAbort = () => reject(abortError(combined.reason));
395
+ combined.addEventListener("abort", onAbort, { once: true });
396
+ promise.then(resolve, reject).finally(() => combined.removeEventListener("abort", onAbort));
397
+ });
398
+ }
399
+ function abortError(reason) {
400
+ if (reason instanceof Error)
401
+ return reason;
402
+ return new Error("Desktop Browser Host connection was aborted", { cause: reason });
403
+ }
404
+ function asError(error, message) {
405
+ return error instanceof Error ? error : new Error(message, { cause: error });
406
+ }
407
+ function assertLoopbackOrigin(origin) {
408
+ const url = new URL(origin);
409
+ const host = url.hostname.replace(/^\[|\]$/g, "").toLowerCase();
410
+ if (host !== "localhost" && host !== "127.0.0.1" && host !== "::1") {
411
+ throw new Error("resident daemon Desktop Browser Host endpoint is not loopback");
412
+ }
413
+ }
@@ -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
+ }