@rynx-ai/server 0.1.11-beta.2 → 0.1.11-beta.21

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/server.js CHANGED
@@ -10,6 +10,8 @@ import { attachBrowserSurfaceWs } from "./browser-surface-ws.js";
10
10
  import { attachEmulatorSurfaceWs } from "./emulator-surface-ws.js";
11
11
  import { attachDesktopBrowserHostServer, } from "./desktop-browser-host.js";
12
12
  import { resolveControlWebDist, sendSpaFile } from "./control-web-dist.js";
13
+ import { startSessionPortal, } from "./session-portal.js";
14
+ export { createSessionPortalApp, startSessionPortal } from "./session-portal.js";
13
15
  import { SessionRuntimeIndex } from "./session-runtime-index.js";
14
16
  import { createRunnerSessionTerminalHost, } from "./session-terminal-host.js";
15
17
  import { runtimeWebAccessPolicyForBindHost, } from "./runtime-web-auth.js";
@@ -59,6 +61,8 @@ export function createSessionRuntimeServices(input) {
59
61
  ...(input.sessionContextProvider
60
62
  ? { sessionContextProvider: input.sessionContextProvider }
61
63
  : {}),
64
+ ...(input.admissionOpen ? { admissionOpen: input.admissionOpen } : {}),
65
+ ...(input.admissionReserve ? { admissionReserve: input.admissionReserve } : {}),
62
66
  });
63
67
  const sessionBus = new InMemorySessionBus();
64
68
  const sessionRuntimeIndex = new SessionRuntimeIndex();
@@ -135,7 +139,7 @@ export function createApp({ config = loadConfig(), control } = {}) {
135
139
  }
136
140
  return app;
137
141
  }
138
- export async function startServer({ config = loadConfig(), control, sessionLog, sessionRegistry, pluginRuntime, daemonManagement, remoteRuntime, remoteRuntimeAdmin, runtimeTargetControl, runtimeConnectionResolver, localSessionResources, localMachineSessions, sessionEmulators, sessionRuntimeServices, sessionLifecycle, sessionTerminals, runtimeLocalBrowser, providerClis, desktopBrowserHost, } = {}) {
142
+ export async function startServer({ config = loadConfig(), control, sessionLog, sessionRegistry, pluginRuntime, daemonManagement, remoteRuntime, remoteRuntimeAdmin, runtimeTargetControl, runtimeConnectionResolver, localSessionResources, localMachineSessions, sessionEmulators, sessionRuntimeServices, sessionLifecycle, sessionTerminals, runtimeLocalBrowser, providerClis, desktopBrowserHost, sessionPortalAuthorization, } = {}) {
139
143
  const runtimeWebAccess = runtimeWebAccessPolicyForBindHost(config.HOST);
140
144
  const desktopBrowserManagementToken = desktopBrowserHost
141
145
  ? requireDaemonManagementToken(daemonManagement)
@@ -158,6 +162,46 @@ export async function startServer({ config = loadConfig(), control, sessionLog,
158
162
  return { cwd: session.workspace.cwd };
159
163
  },
160
164
  });
165
+ const controlShutdown = new AbortController();
166
+ const controlSurface = control
167
+ ? {
168
+ deps: control,
169
+ runtime: conversationRuntime,
170
+ sessionBus,
171
+ sessionLog,
172
+ runnerManager,
173
+ sessionStore,
174
+ pluginRuntimeStatus: pluginRuntime
175
+ ? () => pluginRuntime.statusSnapshot()
176
+ : undefined,
177
+ daemonManagement,
178
+ remoteRuntime,
179
+ remoteRuntimeAdmin,
180
+ runtimeTargetControl,
181
+ runtimeConnectionResolver,
182
+ localSessionResources,
183
+ localMachineSessions,
184
+ sessionEmulators,
185
+ shutdownSignal: controlShutdown.signal,
186
+ sessionRuntimeIndex,
187
+ sessionLifecycle,
188
+ runtimeLocalBrowser,
189
+ providerClis,
190
+ }
191
+ : undefined;
192
+ const sessionPortalConfig = config.RYNX_SESSION_PORTAL_PORT !== undefined
193
+ ? (() => {
194
+ if (runtimeWebAccess.mode !== "loopback") {
195
+ throw new Error("Session Portal requires the ordinary Control listener to bind loopback only");
196
+ }
197
+ const controlWebDist = resolveControlWebDist();
198
+ if (!sessionPortalAuthorization || !controlSurface || !controlWebDist) {
199
+ throw new Error("Session Portal requires authorization, Control services, and the native Control Web build");
200
+ }
201
+ return { authorization: sessionPortalAuthorization, controlWebDist };
202
+ })()
203
+ : undefined;
204
+ let sessionPortalServer;
161
205
  if (pluginRuntime) {
162
206
  if (!sessionRegistry)
163
207
  throw new Error("plugin runtime requires a machine session registry");
@@ -179,7 +223,17 @@ export async function startServer({ config = loadConfig(), control, sessionLog,
179
223
  }),
180
224
  },
181
225
  interruptSession: (sessionId) => runnerManager.interruptLiveSession(sessionId),
226
+ terminateSession: (sessionId) => runnerManager.terminateLiveSession(sessionId),
182
227
  resolveSessionInteraction: (sessionId, interactionId, resolution) => runnerManager.resolveInteraction(sessionId, interactionId, resolution),
228
+ ...(sessionPortalConfig
229
+ ? {
230
+ sessionPortal: {
231
+ scheme: "http",
232
+ bindHost: config.RYNX_SESSION_PORTAL_HOST,
233
+ port: config.RYNX_SESSION_PORTAL_PORT,
234
+ },
235
+ }
236
+ : {}),
183
237
  });
184
238
  }
185
239
  // Provider TUI `/clear`·`/fork` uses the same publication boundary as an
@@ -195,55 +249,40 @@ export async function startServer({ config = loadConfig(), control, sessionLog,
195
249
  kind: r.kind,
196
250
  });
197
251
  });
198
- const controlShutdown = new AbortController();
199
252
  const app = createApp({
200
253
  config,
201
- control: control
202
- ? {
203
- deps: control,
204
- runtime: conversationRuntime,
205
- sessionBus,
206
- sessionLog,
207
- runnerManager,
208
- sessionStore,
209
- pluginRuntimeStatus: pluginRuntime
210
- ? () => pluginRuntime.statusSnapshot()
211
- : undefined,
212
- daemonManagement,
213
- remoteRuntime,
214
- remoteRuntimeAdmin,
215
- runtimeTargetControl,
216
- runtimeConnectionResolver,
217
- localSessionResources,
218
- localMachineSessions,
219
- sessionEmulators,
220
- shutdownSignal: controlShutdown.signal,
221
- sessionRuntimeIndex,
222
- sessionLifecycle,
223
- runtimeLocalBrowser,
224
- providerClis,
225
- }
226
- : undefined,
254
+ control: controlSurface,
227
255
  });
228
- return new Promise((resolve) => {
256
+ return new Promise((resolve, reject) => {
257
+ let startupWebSocketServers = [];
258
+ const rejectBeforeListening = (error) => reject(error);
229
259
  const server = app.listen(config.PORT, config.HOST, () => {
260
+ server.off("error", rejectBeforeListening);
261
+ void finishStartup(server).catch(async (error) => {
262
+ controlShutdown.abort(asError(error));
263
+ sessionPortalServer?.stopAccepting();
264
+ server.closeAllConnections?.();
265
+ await Promise.allSettled([
266
+ closeWebSocketServers(startupWebSocketServers),
267
+ sessionPortalServer?.shutdown() ?? Promise.resolve(),
268
+ new Promise((done) => server.close(() => done())),
269
+ Promise.resolve(daemonManagement?.pluginManagement?.dispose?.()),
270
+ Promise.resolve(pluginRuntime?.dispose()),
271
+ runnerManager.stop(),
272
+ ]);
273
+ reject(error);
274
+ });
275
+ });
276
+ server.once("error", rejectBeforeListening);
277
+ const finishStartup = async (server) => {
230
278
  console.log(JSON.stringify({
231
279
  level: config.LOG_LEVEL,
232
280
  msg: "Harness agent server listening",
233
281
  host: config.HOST,
234
282
  port: config.PORT,
235
- runtime: config.AGENT_RUNTIME,
236
- model: resolveRuntimeModel(config, config.AGENT_RUNTIME),
283
+ runtime: config.DEFAULT_RUNTIME,
284
+ model: resolveRuntimeModel(config, config.DEFAULT_RUNTIME),
237
285
  }));
238
- const startup = pluginRuntime?.startPluginRuntimes() ?? Promise.resolve();
239
- void startup.catch((error) => {
240
- console.error(JSON.stringify({
241
- level: "error",
242
- type: "plugin-runtime",
243
- event: "startup_failed",
244
- error: error instanceof Error ? error.message : String(error),
245
- }));
246
- });
247
286
  const webSocketServers = [
248
287
  attachTerminalWs(server, {
249
288
  runtimeConnectionResolver,
@@ -262,9 +301,36 @@ export async function startServer({ config = loadConfig(), control, sessionLog,
262
301
  if (control?.emulator) {
263
302
  webSocketServers.push(attachEmulatorTouchWs(server, { emulator: control.emulator }));
264
303
  }
304
+ startupWebSocketServers = webSocketServers;
305
+ if (sessionPortalConfig) {
306
+ sessionPortalServer = await startSessionPortal({
307
+ host: config.RYNX_SESSION_PORTAL_HOST,
308
+ port: config.RYNX_SESSION_PORTAL_PORT,
309
+ scheme: "http",
310
+ authorization: sessionPortalConfig.authorization,
311
+ controlOrigin: listeningHttpOrigin(server),
312
+ controlWebDist: sessionPortalConfig.controlWebDist,
313
+ shutdownSignal: controlShutdown.signal,
314
+ });
315
+ }
316
+ const startup = pluginRuntime?.startPluginRuntimes() ?? Promise.resolve();
317
+ void startup.catch((error) => {
318
+ console.error(JSON.stringify({
319
+ level: "error",
320
+ type: "plugin-runtime",
321
+ event: "startup_failed",
322
+ error: error instanceof Error ? error.message : String(error),
323
+ }));
324
+ });
265
325
  resolve(bindServerLifecycle(server, {
266
- fence: () => controlShutdown.abort(new Error("Rynx server is shutting down")),
267
- closeUpgrades: () => closeWebSocketServers(webSocketServers),
326
+ fence: () => {
327
+ controlShutdown.abort(new Error("Rynx server is shutting down"));
328
+ sessionPortalServer?.stopAccepting();
329
+ },
330
+ closeUpgrades: () => Promise.all([
331
+ closeWebSocketServers(webSocketServers),
332
+ sessionPortalServer?.shutdown() ?? Promise.resolve(),
333
+ ]).then(() => undefined),
268
334
  cleanup: async () => runCleanupSteps([
269
335
  () => startup.catch(() => undefined),
270
336
  () => Promise.resolve(daemonManagement?.pluginManagement?.dispose?.()),
@@ -272,9 +338,17 @@ export async function startServer({ config = loadConfig(), control, sessionLog,
272
338
  () => runnerManager.stop(),
273
339
  ]),
274
340
  }));
275
- });
341
+ };
276
342
  });
277
343
  }
344
+ function listeningHttpOrigin(server) {
345
+ const address = server.address();
346
+ if (!address || typeof address === "string") {
347
+ throw new Error("Control listener did not expose an HTTP address");
348
+ }
349
+ const host = address.address.includes(":") ? `[${address.address}]` : address.address;
350
+ return `http://${host}:${address.port}`;
351
+ }
278
352
  function requireDaemonManagementToken(host) {
279
353
  const token = host?.managementToken;
280
354
  const bytes = typeof token === "string" ? Buffer.byteLength(token, "utf8") : 0;
@@ -1,3 +1,4 @@
1
+ import type { AdmissionReservation } from "@rynx-ai/core";
1
2
  import { type RuntimeBrowserCloseParams, type RuntimeBrowserCloseResult, type RuntimeBrowserExecutionBackend, type RuntimeBrowserOpenParams, type RuntimeBrowserPageCreateParams, type RuntimeBrowserPageMutationParams, type RuntimeBrowserPageNavigateParams, type RuntimeBrowserState, type RuntimeBrowserStateGetParams } from "@rynx-ai/protocol/runtime-browser";
2
3
  import type { RuntimeBrowserSurfaceFormat, RuntimeBrowserSurfaceInputEvent } from "@rynx-ai/protocol/runtime-browser-surface";
3
4
  import { type RuntimeBrowserEndpointDescriptor } from "@rynx-ai/protocol/runtime-browser-bootstrap";
@@ -155,6 +156,11 @@ export interface SessionBrowserServiceOptions {
155
156
  pageIdFactory?: () => string;
156
157
  /** Injectable boot nonce; production randomization fences stale pre-restart mutations. */
157
158
  bootGenerationSeed?: number;
159
+ /**
160
+ * Synchronous daemon admission fence. A successful reservation is held until
161
+ * Browser open has either failed or registered the physical Host handle.
162
+ */
163
+ admissionReserve?: () => AdmissionReservation | undefined;
158
164
  }
159
165
  export type SessionBrowserHostErrorCode = "unavailable" | "page_not_found" | "capacity" | "outcome_unknown";
160
166
  /** Expected failure reported by a Browser Host implementation. */
@@ -181,6 +187,7 @@ export declare class SessionBrowserService {
181
187
  private readonly observers;
182
188
  private readonly pageIdFactory;
183
189
  private readonly bootGenerationSeed;
190
+ private readonly admissionReserve?;
184
191
  private shuttingDown;
185
192
  private closeAllPromise?;
186
193
  constructor(ports: SessionBrowserServicePorts, options?: SessionBrowserServiceOptions);
@@ -225,6 +232,7 @@ export declare class SessionBrowserService {
225
232
  closeAll(): Promise<void>;
226
233
  private closeEveryRecord;
227
234
  private createBrowser;
235
+ private reserveOpenAdmission;
228
236
  private closeUnavailableForRecreate;
229
237
  private pageMutation;
230
238
  private pageMutationParsed;
@@ -42,12 +42,14 @@ export class SessionBrowserService {
42
42
  observers = new Set();
43
43
  pageIdFactory;
44
44
  bootGenerationSeed;
45
+ admissionReserve;
45
46
  shuttingDown = false;
46
47
  closeAllPromise;
47
48
  constructor(ports, options = {}) {
48
49
  this.ports = ports;
49
50
  this.pageIdFactory = options.pageIdFactory ?? (() => `page_${randomUUID()}`);
50
51
  this.bootGenerationSeed = options.bootGenerationSeed ?? randomBootGenerationSeed();
52
+ this.admissionReserve = options.admissionReserve;
51
53
  if (!Number.isSafeInteger(this.bootGenerationSeed) ||
52
54
  this.bootGenerationSeed < 0 ||
53
55
  this.bootGenerationSeed >= Number.MAX_SAFE_INTEGER) {
@@ -224,29 +226,35 @@ export class SessionBrowserService {
224
226
  });
225
227
  }
226
228
  async open(input) {
227
- const params = parseInput(() => parseRuntimeBrowserOpenParams(input), "Browser open request is invalid");
228
- return this.exclusive(params.sessionId, async () => {
229
- this.ensureRunning();
230
- await this.ensureSessionExists(params.sessionId);
231
- const existing = this.records.get(params.sessionId);
232
- if (existing?.status === "opening") {
233
- return this.project(existing);
234
- }
235
- if (existing?.status === "ready") {
236
- try {
237
- await this.reconcileForRead(existing);
229
+ const admission = this.reserveOpenAdmission();
230
+ try {
231
+ const params = parseInput(() => parseRuntimeBrowserOpenParams(input), "Browser open request is invalid");
232
+ return await this.exclusive(params.sessionId, async () => {
233
+ this.ensureRunning();
234
+ await this.ensureSessionExists(params.sessionId);
235
+ const existing = this.records.get(params.sessionId);
236
+ if (existing?.status === "opening") {
238
237
  return this.project(existing);
239
238
  }
240
- catch {
241
- // An App Host may have gone away immediately before this open. The
242
- // failed reconciliation marks the old generation unavailable, so an
243
- // idempotent open can recreate it on the current preferred Host.
239
+ if (existing?.status === "ready") {
240
+ try {
241
+ await this.reconcileForRead(existing);
242
+ return this.project(existing);
243
+ }
244
+ catch {
245
+ // An App Host may have gone away immediately before this open. The
246
+ // failed reconciliation marks the old generation unavailable, so an
247
+ // idempotent open can recreate it on the current preferred Host.
248
+ }
244
249
  }
245
- }
246
- if (existing)
247
- await this.closeUnavailableForRecreate(existing);
248
- return this.createBrowser(params);
249
- });
250
+ if (existing)
251
+ await this.closeUnavailableForRecreate(existing);
252
+ return this.createBrowser(params);
253
+ });
254
+ }
255
+ finally {
256
+ admission?.release();
257
+ }
250
258
  }
251
259
  async close(input) {
252
260
  const params = parseInput(() => parseRuntimeBrowserCloseParams(input), "Browser close request is invalid");
@@ -438,6 +446,15 @@ export class SessionBrowserService {
438
446
  throw new SessionBrowserServiceError("host_failure", "Browser Host state could not be initialized", { cause: error });
439
447
  }
440
448
  }
449
+ reserveOpenAdmission() {
450
+ if (!this.admissionReserve)
451
+ return undefined;
452
+ const admission = this.admissionReserve();
453
+ if (!admission) {
454
+ throw new SessionBrowserServiceError("shutting_down", "Browser service is shutting down");
455
+ }
456
+ return admission;
457
+ }
441
458
  async closeUnavailableForRecreate(record) {
442
459
  record.status = "closing";
443
460
  try {
@@ -0,0 +1,42 @@
1
+ import { type IncomingMessage, type Server as HttpServer } from "node:http";
2
+ import type { Duplex } from "node:stream";
3
+ import Koa from "koa";
4
+ export declare const SESSION_PORTAL_HEADER = "x-rynx-session-portal";
5
+ export declare const SESSION_PORTAL_QUERY = "portal";
6
+ export interface SessionPortalAuthorization {
7
+ portalId: string;
8
+ pluginId: string;
9
+ scope: "plugin_sessions" | "single_session";
10
+ access: "read_only" | "read_write";
11
+ sessionId?: string;
12
+ expiresAt: string;
13
+ }
14
+ export interface SessionPortalAuthorizationPorts {
15
+ redeem(ticket: string): (SessionPortalAuthorization & {
16
+ secret: string;
17
+ }) | undefined;
18
+ authorize(portalId: string, secret: string): SessionPortalAuthorization | undefined;
19
+ ownsSession(pluginId: string, sessionId: string): boolean;
20
+ }
21
+ export interface SessionPortalOptions {
22
+ host: string;
23
+ port: number;
24
+ scheme: "http" | "https";
25
+ authorization: SessionPortalAuthorizationPorts;
26
+ controlOrigin: string;
27
+ controlWebDist: string;
28
+ shutdownSignal?: AbortSignal;
29
+ }
30
+ export interface SessionPortalServer {
31
+ server: HttpServer;
32
+ stopAccepting(): void;
33
+ shutdown(): Promise<void>;
34
+ }
35
+ /** Session Portal is a narrow adapter in front of the one Control service. It
36
+ * serves the native SPA, validates Portal grants, and forwards only authorized
37
+ * Session HTTP calls to Control; it owns no Session or Runtime handlers. */
38
+ export declare function createSessionPortalApp(options: SessionPortalOptions): Koa;
39
+ export declare function startSessionPortal(options: SessionPortalOptions): Promise<SessionPortalServer>;
40
+ export declare function authorizeSessionPortalWebSocket(request: IncomingMessage, ports: SessionPortalAuthorizationPorts, runtimeSelector: string, sessionId: string): SessionPortalAuthorization | undefined;
41
+ export declare function rejectSessionPortalUpgrade(socket: Duplex): void;
42
+ export declare function scheduleSessionPortalExpiry(expiresAt: string, onExpire: () => void): () => void;