@rynx-ai/server 0.1.11-beta.44 → 0.1.11-beta.48

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.
@@ -226,6 +226,6 @@ export declare function createControlRouter(opts: {
226
226
  providerClis?: ProviderCliManagementHost;
227
227
  }): Router;
228
228
  /** Derive a one-line session title from the first user message: collapse
229
- * whitespace, trim, and truncate to `limit` chars + an ellipsis (reference implementation's
230
- * rule no LLM call). */
229
+ * whitespace, trim, and fit within `limit` characters, including an ellipsis
230
+ * when truncated. No LLM call is made. */
231
231
  export declare function synthesizeSessionTitle(message: string, limit?: number): string;
@@ -15,7 +15,7 @@ import { DIRECT_RUNTIME_CONTROL_PATH, encodePairingCode, } from "@rynx-ai/protoc
15
15
  import { PLUGIN_CONSOLE_INSTALL_PREPARATIONS_PATH, PLUGIN_CONSOLE_MARKETPLACES_PATH, PLUGIN_INSTALL_PREPARATIONS_PATH, PLUGIN_MARKETPLACES_PATH, parsePluginInstallCommitInput, parsePluginInstallPrepareInput, parsePluginMarketplaceAddInput, } from "@rynx-ai/protocol/plugin-management";
16
16
  import { parseDaemonStatus } from "@rynx-ai/protocol/remote-runtime";
17
17
  import { materializeRemoteRuntimeSessionEvent, materializeRemoteRuntimeSessionRuntimeSnapshot, parseRemoteRuntimeRpcRequest, REMOTE_RUNTIME_RPC_MAX_FRAME_BYTES, SESSION_IMAGE_MEDIA_TYPES, SESSION_RESOURCE_MAX_IMAGE_BYTES, SESSION_RESOURCE_MAX_FILENAME_CHARS, SESSION_RESOURCE_TRANSFER_CHUNK_BYTES, REMOTE_RUNTIME_SESSION_MAX_PAGE_SIZE, } from "@rynx-ai/protocol/remote-runtime-rpc";
18
- 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";
18
+ import { RUNTIME_BROWSER_BOOTSTRAP_PATH, RUNTIME_BROWSER_CAPABILITY_HEADER, RUNTIME_BROWSER_MANAGEMENT_PATH_PREFIX, RUNTIME_BROWSER_RPC_PATH, RUNTIME_BROWSER_EXECUTE_PATH, RUNTIME_BROWSER_SESSION_ID_HEADER, parseRuntimeBrowserBootstrapCredential, parseRuntimeBrowserAutomationCommand, } from "@rynx-ai/protocol/runtime-browser-bootstrap";
19
19
  import { parseRuntimeBrowserStateGetParams } from "@rynx-ai/protocol/runtime-browser";
20
20
  import { encodeDaemonStatus } from "./remote-runtime.js";
21
21
  import { SessionRuntimeIndex } from "./session-runtime-index.js";
@@ -2124,6 +2124,71 @@ export function createControlRouter(opts) {
2124
2124
  router.post("/api/internal/runtimes/:selector/emulator/gesture", internalRuntimeCall(runtimeGestureEmulator));
2125
2125
  router.post("/api/internal/runtimes/:selector/emulator/kill", internalRuntimeCall((ctx) => runtimeDeviceCommand(ctx, "emulator.kill")));
2126
2126
  router.post("/api/internal/runtimes/:selector/emulator/shutdown", internalRuntimeCall((ctx) => runtimeDeviceCommand(ctx, "emulator.shutdown")));
2127
+ const executeLocalBrowser = async (ctx, management) => {
2128
+ if (!runtimeLocalBrowser?.executeForSession) {
2129
+ notFound(ctx, "Page automation requires a Runtime upgrade");
2130
+ return;
2131
+ }
2132
+ if (!isLoopbackRequest(ctx)) {
2133
+ ctx.status = 403;
2134
+ ctx.body = { error: "loopback_only" };
2135
+ return;
2136
+ }
2137
+ let sessionId;
2138
+ if (management) {
2139
+ if (!isConfiguredDaemonManagement(daemonManagement)) {
2140
+ notFound(ctx, "Daemon management is unavailable");
2141
+ return;
2142
+ }
2143
+ if (!authorizeLoopbackManagementRequest(ctx, daemonManagement.managementToken))
2144
+ return;
2145
+ try {
2146
+ sessionId = parseRuntimeBrowserStateGetParams({ sessionId: ctx.params.sessionId }).sessionId;
2147
+ }
2148
+ catch {
2149
+ bad(ctx, "invalid Session id");
2150
+ return;
2151
+ }
2152
+ }
2153
+ else {
2154
+ try {
2155
+ const credential = parseRuntimeBrowserBootstrapCredential({
2156
+ sessionId: ctx.get(RUNTIME_BROWSER_SESSION_ID_HEADER), capability: ctx.get(RUNTIME_BROWSER_CAPABILITY_HEADER),
2157
+ });
2158
+ if (!await runtimeLocalBrowser.authorizeCredential(credential))
2159
+ throw new Error("unauthorized");
2160
+ sessionId = credential.sessionId;
2161
+ }
2162
+ catch {
2163
+ ctx.status = 401;
2164
+ ctx.body = { error: "invalid Runtime Browser Session capability" };
2165
+ return;
2166
+ }
2167
+ }
2168
+ if (!ctx.is("application/json")) {
2169
+ ctx.status = 415;
2170
+ ctx.body = { error: "unsupported_media_type" };
2171
+ return;
2172
+ }
2173
+ let command;
2174
+ try {
2175
+ command = parseRuntimeBrowserAutomationCommand(await readBoundedJson(ctx, 128 * 1024));
2176
+ }
2177
+ catch (error) {
2178
+ ctx.status = error instanceof RequestBodyTooLargeError ? 413 : 400;
2179
+ ctx.body = { error: "invalid_request" };
2180
+ return;
2181
+ }
2182
+ try {
2183
+ ctx.set("Cache-Control", "no-store");
2184
+ ctx.body = await runtimeLocalBrowser.executeForSession(sessionId, command);
2185
+ }
2186
+ catch (error) {
2187
+ runtimeLocalBrowserError(ctx, error);
2188
+ }
2189
+ };
2190
+ router.post(RUNTIME_BROWSER_EXECUTE_PATH, (ctx) => executeLocalBrowser(ctx, false));
2191
+ router.post(`${RUNTIME_BROWSER_MANAGEMENT_PATH_PREFIX}/:sessionId/execute`, (ctx) => executeLocalBrowser(ctx, true));
2127
2192
  router.get(RUNTIME_BROWSER_BOOTSTRAP_PATH, async (ctx) => {
2128
2193
  if (!runtimeLocalBrowser) {
2129
2194
  return notFound(ctx, "Runtime-local Browser automation is not configured");
@@ -3539,7 +3604,7 @@ export function createControlRouter(opts) {
3539
3604
  // ── sessions (channel-agnostic agent runs) ───────────────────────────────
3540
3605
  // Create a session bound to a preset agent (`agent`) or an inline spec
3541
3606
  // (`config`, the config-driven / remote-ready path), then drive it with
3542
- // messages over SSE. reference implementation-style: create carries no message.
3607
+ // messages over SSE. Creating the session does not submit a message.
3543
3608
  // One unified list: every session is a machine-session record. The registry
3544
3609
  // (console + every channel) supplies identity — `source` is a plain field, not
3545
3610
  // parsed from the id — and the canonical log supplies recency. A legacy session
@@ -3891,8 +3956,8 @@ function abortAsNull(signal) {
3891
3956
  });
3892
3957
  }
3893
3958
  /** Derive a one-line session title from the first user message: collapse
3894
- * whitespace, trim, and truncate to `limit` chars + an ellipsis (reference implementation's
3895
- * rule no LLM call). */
3959
+ * whitespace, trim, and fit within `limit` characters, including an ellipsis
3960
+ * when truncated. No LLM call is made. */
3896
3961
  export function synthesizeSessionTitle(message, limit = 60) {
3897
3962
  const collapsed = message.replace(/\s+/g, " ").trim();
3898
3963
  if (collapsed.length <= limit)
@@ -4341,9 +4406,19 @@ function runtimeLocalBrowserError(ctx, error) {
4341
4406
  ? error.code
4342
4407
  : undefined;
4343
4408
  switch (code) {
4409
+ case "human_control_active":
4410
+ ctx.status = 409;
4411
+ ctx.body = { error: code, hint: "Release Browser Control before Agent actions." };
4412
+ return;
4413
+ case "command_timeout":
4414
+ ctx.status = 504;
4415
+ ctx.body = { error: "command_timeout", outcome: "unknown" };
4416
+ return;
4344
4417
  case "invalid_request":
4345
4418
  ctx.status = 400;
4346
- ctx.body = { error: "invalid_request" };
4419
+ ctx.body = { error: "invalid_request", ...(error instanceof Error && error.name === "PageAutomationError"
4420
+ ? { message: error.message.slice(0, 2048), hint: "Run rynx skills get browser for Browser resources; after opening a Browser, use rynx browser exec -- --help for page commands." }
4421
+ : {}) };
4347
4422
  return;
4348
4423
  case "not_found":
4349
4424
  ctx.status = 404;
@@ -4355,10 +4430,30 @@ function runtimeLocalBrowserError(ctx, error) {
4355
4430
  ctx.body = { error: "unavailable" };
4356
4431
  return;
4357
4432
  case "host_failure":
4358
- case "outcome_unknown":
4359
4433
  ctx.status = 502;
4360
4434
  ctx.body = { error: "operation_failed" };
4361
4435
  return;
4436
+ case "outcome_unknown":
4437
+ ctx.status = 502;
4438
+ ctx.body = { error: "outcome_unknown", outcome: "unknown", hint: "Do not replay this action. Run rynx browser exec -- snapshot -i to inspect its outcome." };
4439
+ return;
4440
+ case "driver_restarted":
4441
+ case "generation_mismatch":
4442
+ ctx.status = 409;
4443
+ ctx.body = { error: code, hint: "Run rynx browser exec -- snapshot -i for fresh Page references." };
4444
+ return;
4445
+ case "capacity":
4446
+ ctx.status = 429;
4447
+ ctx.body = { error: "capacity" };
4448
+ return;
4449
+ case "unsupported":
4450
+ ctx.status = 501;
4451
+ ctx.body = { error: "unsupported", hint: "Upgrade the Runtime and Desktop Host together." };
4452
+ return;
4453
+ case "command_failed":
4454
+ ctx.status = 422;
4455
+ ctx.body = { error: "command_failed", message: error instanceof Error ? error.message : "agent-browser command failed", hint: "Run rynx browser exec -- --help, or inspect a fresh snapshot before another action." };
4456
+ return;
4362
4457
  default:
4363
4458
  ctx.status = 500;
4364
4459
  ctx.body = { error: "internal_error" };
@@ -32,6 +32,7 @@ interface DesktopBrowserLease {
32
32
  surface: DesktopBrowserHostSurfaceCapability;
33
33
  socket: WebSocket;
34
34
  pending: Map<string, PendingCommand>;
35
+ expiredCommands: Set<string>;
35
36
  nextEventSequence: number;
36
37
  detached: boolean;
37
38
  }
@@ -120,6 +121,14 @@ declare class DesktopBrowserHandle implements SessionBrowserHostHandle {
120
121
  endpoint: string;
121
122
  engineVersion: string;
122
123
  }>;
124
+ getPageAutomationEndpoint(hostPageId: string): Promise<{
125
+ pageTargetId: string;
126
+ endpoint: string;
127
+ engineVersion: string;
128
+ }>;
129
+ acquireAutomationVisibility(hostPageId: string): Promise<{
130
+ release: () => Promise<void>;
131
+ }>;
123
132
  openSurface(input: SessionBrowserHostSurfaceOpenInput): Promise<SessionBrowserHostSurfaceSource>;
124
133
  snapshot(): Promise<SessionBrowserHostSnapshot>;
125
134
  createPage(url?: string): Promise<void>;
@@ -133,6 +133,7 @@ export class DesktopBrowserHostRegistry {
133
133
  surface: hello.capabilities.surface,
134
134
  socket,
135
135
  pending: new Map(),
136
+ expiredCommands: new Set(),
136
137
  nextEventSequence: 1,
137
138
  detached: false,
138
139
  };
@@ -253,8 +254,13 @@ export class DesktopBrowserHostRegistry {
253
254
  if (!pending)
254
255
  return;
255
256
  lease.pending.delete(commandId);
256
- reject(commandOutcomeError(command, `Desktop Browser Host ${command.method} timed out`));
257
- if (!isReadOnlyCommand(command)) {
257
+ reject(commandDeadlineError(command, `Desktop Browser Host ${command.method} timed out`));
258
+ if (isRecoverableDeadline(command)) {
259
+ lease.expiredCommands.add(commandId);
260
+ if (lease.expiredCommands.size > 256)
261
+ lease.expiredCommands.delete(lease.expiredCommands.values().next().value);
262
+ }
263
+ else {
258
264
  this.failLease(lease, CLOSE_REPLACED_OR_STALE, `Desktop Browser Host ${command.method} outcome is unknown`);
259
265
  }
260
266
  }, this.commandTimeoutMilliseconds);
@@ -290,13 +296,15 @@ export class DesktopBrowserHostRegistry {
290
296
  receiveReply(lease, frame) {
291
297
  const pending = lease.pending.get(frame.commandId);
292
298
  if (!pending) {
299
+ if (lease.expiredCommands.delete(frame.commandId))
300
+ return;
293
301
  throw new DesktopBrowserHostProtocolError("reply commandId is unknown or expired", CLOSE_REPLACED_OR_STALE);
294
302
  }
295
303
  lease.pending.delete(frame.commandId);
296
304
  clearTimeout(pending.timer);
297
305
  if (this.now() >= pending.deadlineUnixMilliseconds) {
298
- pending.reject(commandOutcomeError(pending.command, `Desktop Browser Host ${pending.command.method} replied after its deadline`));
299
- if (!isReadOnlyCommand(pending.command)) {
306
+ pending.reject(commandDeadlineError(pending.command, `Desktop Browser Host ${pending.command.method} replied after its deadline`));
307
+ if (!isRecoverableDeadline(pending.command)) {
300
308
  this.failLease(lease, CLOSE_REPLACED_OR_STALE, `Desktop Browser Host ${pending.command.method} outcome is unknown`);
301
309
  }
302
310
  return;
@@ -536,6 +544,36 @@ class DesktopBrowserHandle {
536
544
  getPageCdpEndpoint(hostPageId) {
537
545
  return this.command({ method: "page.cdp-endpoint", ...this.pageScope(hostPageId) }, parseDesktopBrowserHostPageCdpEndpointResult);
538
546
  }
547
+ async getPageAutomationEndpoint(hostPageId) {
548
+ if (!this.capabilities.includes("page-automation-v1"))
549
+ throw new SessionBrowserHostError("unsupported", "Upgrade the Desktop Host for page automation");
550
+ const result = await this.command({ method: "page.automation-endpoint", ...this.pageScope(hostPageId) }, parseDesktopBrowserHostCdpEndpointResult);
551
+ if (!result.pageTargetId)
552
+ throw new SessionBrowserHostError("unsupported", "Desktop Host does not support page automation");
553
+ return { ...result, pageTargetId: result.pageTargetId };
554
+ }
555
+ async acquireAutomationVisibility(hostPageId) {
556
+ if (!this.capabilities.includes("page-automation-visibility-v1")) {
557
+ throw new SessionBrowserHostError("unsupported", "Upgrade the Desktop Host for automation rendering");
558
+ }
559
+ const scope = { ...this.pageScope(hostPageId), visibilityId: this.registry.createId("visibility") };
560
+ const release = async () => {
561
+ if (this.available && !this.closed)
562
+ await this.completed({ method: "page.automation-visibility.release", ...scope });
563
+ };
564
+ try {
565
+ await this.completed({ method: "page.automation-visibility.acquire", ...scope });
566
+ }
567
+ catch (error) {
568
+ await release().catch(() => undefined);
569
+ throw error;
570
+ }
571
+ let released = false;
572
+ return { release: async () => { if (!released) {
573
+ released = true;
574
+ await release();
575
+ } } };
576
+ }
539
577
  async openSurface(input) {
540
578
  if (this.lease.surface !== "binary-v1") {
541
579
  throw new SessionBrowserSurfaceUnsupportedError();
@@ -930,10 +968,14 @@ function mapReplyError(frame, command) {
930
968
  return new SessionBrowserHostError("capacity", "Desktop Browser Host capacity was reached");
931
969
  }
932
970
  if (frame.error.code === "unavailable" ||
933
- frame.error.code === "stale_generation" ||
934
- frame.error.code === "deadline_exceeded")
971
+ frame.error.code === "stale_generation")
935
972
  return hostUnavailable("Desktop Browser Host is unavailable");
973
+ if (frame.error.code === "deadline_exceeded") {
974
+ return new SessionBrowserHostError("command_timeout", "Desktop Browser command deadline exceeded");
975
+ }
936
976
  if (frame.error.code === "internal") {
977
+ if (isReadOnlyCommand(command))
978
+ return new SessionBrowserHostError("operation_failed", "Desktop Browser observation failed temporarily");
937
979
  return commandOutcomeError(command, "Desktop Browser Host command outcome is unknown");
938
980
  }
939
981
  return new Error("Desktop Browser Host command failed");
@@ -942,9 +984,16 @@ function commandOutcomeError(command, message, cause) {
942
984
  const readOnly = isReadOnlyCommand(command);
943
985
  return new SessionBrowserHostError(readOnly ? "unavailable" : "outcome_unknown", message, cause === undefined ? undefined : { cause });
944
986
  }
987
+ function commandDeadlineError(command, message) {
988
+ return new SessionBrowserHostError(isReadOnlyCommand(command) ? "command_timeout" : "outcome_unknown", message);
989
+ }
990
+ function isRecoverableDeadline(command) {
991
+ return isReadOnlyCommand(command) || ["page.navigate", "page.reload", "page.back", "page.forward", "page.activate"].includes(command.method);
992
+ }
945
993
  function isReadOnlyCommand(command) {
946
994
  return command.method === "browser.snapshot" ||
947
995
  command.method === "browser.cdp-endpoint" ||
996
+ command.method === "page.automation-endpoint" ||
948
997
  command.method === "page.cdp-endpoint";
949
998
  }
950
999
  class BoundedAsyncQueue {
@@ -352,6 +352,7 @@ function mapHostError(request, error) {
352
352
  case "shutting_down":
353
353
  return { code: "failed_precondition", message: "Browser operation is unavailable" };
354
354
  case "outcome_unknown":
355
+ case "command_timeout":
355
356
  return { code: "outcome_unknown", message: "Browser operation may have been applied" };
356
357
  case "host_failure":
357
358
  return { code: "internal", message: "Browser Host request failed" };
package/dist/server.d.ts CHANGED
@@ -27,13 +27,14 @@ import { type SessionTerminalHost } from "./session-terminal-host.js";
27
27
  import type { ProviderCliManagementHost } from "./provider-cli-host.js";
28
28
  import { type RuntimeWebAccessPolicy } from "./runtime-web-auth.js";
29
29
  import type { SessionBrowserRequestHeadersPort } from "./session-browser-service.js";
30
+ import type { RuntimeBrowserAutomationCommand, RuntimeBrowserAutomationResult } from "@rynx-ai/protocol/runtime-browser-bootstrap";
30
31
  export type { ProviderCliManagementHost, ProviderCliStatusHost, } from "./provider-cli-host.js";
31
32
  export { MachineSessionService, MachineSessionServiceFailure, MachineSessionServiceInputError, synthesizeSessionTitle, } from "./machine-session-service.js";
32
33
  export { SessionPendingInputIndex } from "./session-pending-inputs.js";
33
34
  export type { MachineSessionAgentCatalogPort, MachineSessionCreateInput, MachineSessionCreateResult, MachineSessionDeleteResult, MachineSessionForkInput, MachineSessionForkOperationRecord, MachineSessionForkOperationState, MachineSessionForkResult, MachineSessionForkStorePort, MachineSessionNativeRotationInput, MachineSessionInterruptPort, MachineSessionInterruptResult, MachineSessionInteractionResult, MachineSessionListInput, MachineSessionListPage, MachineSessionMessageResult, MachineSessionMessageEnqueueResult, MachineSessionMessageInput, MachineSessionMessageOperationState, MachineSessionPendingMessage, MachineSessionPendingMessagePort, MachineSessionPreparedInput, MachineSessionResourcePort, MachineSessionRunExecutionSelection, MachineSessionRunExecutionSnapshot, MachineSessionRunnerPort, MachineSessionRuntimeStatePort, MachineSessionServiceFailureCode, MachineSessionServiceOptions, MachineSessionServicePorts, MachineSessionSnapshotInput, MachineSessionSnapshotPage, MachineSessionWatch, StartedMachineSessionRun, } from "./machine-session-service.js";
34
35
  export type { MachineSessionPendingInput, MachineSessionPendingInputPort, } from "./session-pending-inputs.js";
35
36
  export { SessionBrowserHostError, SessionBrowserService, SessionBrowserServiceError, SessionBrowserSurfaceUnsupportedError, } from "./session-browser-service.js";
36
- export type { SessionBrowserHost, SessionBrowserHostCreateInput, SessionBrowserHostErrorCode, SessionBrowserHostEvent, SessionBrowserHostHandle, SessionBrowserHostPage, SessionBrowserHostSurfaceFrame, SessionBrowserHostSurfaceOpenInput, SessionBrowserHostSurfaceSource, SessionBrowserHostSurfaceTrustedInput, SessionBrowserHostSurfaceViewport, SessionBrowserHostSnapshot, SessionBrowserInspectionEndpoint, SessionBrowserInspectionTarget, SessionBrowserRegistryPort, SessionBrowserRequestHeadersPort, SessionBrowserServiceErrorCode, SessionBrowserServiceOptions, SessionBrowserServicePorts, SessionBrowserSurfaceOpenInput, SessionBrowserSurfaceSource, } from "./session-browser-service.js";
37
+ export type { SessionBrowserHost, SessionBrowserAutomationBinding, SessionBrowserAutomationVisibility, SessionBrowserHostCreateInput, SessionBrowserHostErrorCode, SessionBrowserHostEvent, SessionBrowserHostHandle, SessionBrowserHostPage, SessionBrowserHostSurfaceFrame, SessionBrowserHostSurfaceOpenInput, SessionBrowserHostSurfaceSource, SessionBrowserHostSurfaceTrustedInput, SessionBrowserHostSurfaceViewport, SessionBrowserHostSnapshot, SessionBrowserInspectionEndpoint, SessionBrowserInspectionTarget, SessionBrowserRegistryPort, SessionBrowserRequestHeadersPort, SessionBrowserServiceErrorCode, SessionBrowserServiceOptions, SessionBrowserServicePorts, SessionBrowserSurfaceOpenInput, SessionBrowserSurfaceSource, } from "./session-browser-service.js";
37
38
  export { SessionEmulatorBindingConflictError, SessionEmulatorService, SessionEmulatorServiceError, } from "./session-emulator-service.js";
38
39
  export type { SessionEmulatorBindingRecord, SessionEmulatorBindingStore, SessionEmulatorServiceErrorCode, SessionEmulatorServiceOptions, SessionEmulatorSurface, } from "./session-emulator-service.js";
39
40
  export { SessionBrowserSurfaceCoordinator } from "./session-browser-surface-coordinator.js";
@@ -286,6 +287,7 @@ export interface DaemonManagementHost {
286
287
  * half of the credential was wrong.
287
288
  */
288
289
  export interface RuntimeLocalBrowserAutomationHost {
290
+ executeForSession?(sessionId: string, command: RuntimeBrowserAutomationCommand): Promise<RuntimeBrowserAutomationResult>;
289
291
  authorizeCredential(credential: RuntimeBrowserBootstrapCredential): boolean | Promise<boolean>;
290
292
  endpointForCredential(credential: RuntimeBrowserBootstrapCredential): Promise<RuntimeBrowserEndpointDescriptor | undefined>;
291
293
  endpointForSession(sessionId: string): Promise<RuntimeBrowserEndpointDescriptor>;
@@ -19,6 +19,7 @@ export interface SessionBrowserHostPage {
19
19
  loading: boolean;
20
20
  canGoBack: boolean;
21
21
  canGoForward: boolean;
22
+ observationStale?: boolean;
22
23
  }
23
24
  export interface SessionBrowserHostSnapshot {
24
25
  pages: SessionBrowserHostPage[];
@@ -128,6 +129,12 @@ export interface SessionBrowserHostHandle {
128
129
  }>;
129
130
  /** Optional exact physical Page handoff for the daemon-local inspection gateway. */
130
131
  getPageCdpEndpoint?(hostPageId: string): Promise<SessionBrowserInspectionEndpoint>;
132
+ /** Automation facade, distinct from raw DevTools inspection; binds exactly one existing Page. */
133
+ getPageAutomationEndpoint?(hostPageId: string): Promise<SessionBrowserInspectionEndpoint & {
134
+ pageTargetId: string;
135
+ }>;
136
+ /** Temporary renderability, never semantic Page selection or human window focus. */
137
+ acquireAutomationVisibility?(hostPageId: string, canActivate?: () => boolean): Promise<SessionBrowserAutomationVisibility>;
131
138
  /** Optional physical presentation primitive; it never projects native CDP. */
132
139
  openSurface?(input: SessionBrowserHostSurfaceOpenInput): Promise<SessionBrowserHostSurfaceSource>;
133
140
  snapshot(): Promise<SessionBrowserHostSnapshot>;
@@ -158,6 +165,10 @@ export interface SessionBrowserServicePorts {
158
165
  sessions?: SessionBrowserRegistryPort;
159
166
  }
160
167
  export interface SessionBrowserServiceOptions {
168
+ onObservationError?: (error: unknown, target: {
169
+ sessionId: string;
170
+ browserGeneration: number;
171
+ }) => void;
161
172
  /** Injectable only so opaque identity behavior is deterministic in tests. */
162
173
  pageIdFactory?: () => string;
163
174
  /** Injectable boot nonce; production randomization fences stale pre-restart mutations. */
@@ -170,7 +181,18 @@ export interface SessionBrowserServiceOptions {
170
181
  */
171
182
  admissionReserve?: () => AdmissionReservation | undefined;
172
183
  }
173
- export type SessionBrowserHostErrorCode = "unavailable" | "unsupported" | "page_not_found" | "capacity" | "outcome_unknown";
184
+ export interface SessionBrowserAutomationVisibility {
185
+ /** Always releases resources; false suppresses physical focus restoration after human takeover. */
186
+ release(restoreFocus?: boolean): Promise<void>;
187
+ }
188
+ export interface SessionBrowserAutomationBinding extends RuntimeBrowserEndpointDescriptor {
189
+ pageId: string;
190
+ pageTargetId: string;
191
+ /** Synchronous physical identity fence; long driver commands never hold the Session lock. */
192
+ isCurrent(): boolean;
193
+ acquireAutomationVisibility(canActivate?: () => boolean): Promise<SessionBrowserAutomationVisibility>;
194
+ }
195
+ export type SessionBrowserHostErrorCode = "unavailable" | "command_timeout" | "operation_failed" | "unsupported" | "page_not_found" | "capacity" | "outcome_unknown";
174
196
  /** Expected failure reported by a Browser Host implementation. */
175
197
  export declare class SessionBrowserHostError extends Error {
176
198
  readonly code: SessionBrowserHostErrorCode;
@@ -181,7 +203,7 @@ export interface SessionBrowserRequestHeadersPort {
181
203
  getRequestHeaders(input: RuntimeBrowserRequestHeadersGetParams): Promise<RuntimeBrowserRequestHeadersPolicy>;
182
204
  setRequestHeaders(input: RuntimeBrowserRequestHeadersSetParams): Promise<RuntimeBrowserRequestHeadersPolicy>;
183
205
  }
184
- export type SessionBrowserServiceErrorCode = "invalid_request" | "not_found" | "generation_mismatch" | "revision_mismatch" | "unsupported" | "unavailable" | "capacity" | "host_failure" | "outcome_unknown" | "shutting_down";
206
+ export type SessionBrowserServiceErrorCode = "invalid_request" | "command_timeout" | "not_found" | "generation_mismatch" | "revision_mismatch" | "unsupported" | "unavailable" | "capacity" | "host_failure" | "outcome_unknown" | "shutting_down";
185
207
  /** Stable application error; transports map the code without exposing Host diagnostics. */
186
208
  export declare class SessionBrowserServiceError extends Error {
187
209
  readonly code: SessionBrowserServiceErrorCode;
@@ -193,11 +215,13 @@ export declare class SessionBrowserServiceError extends Error {
193
215
  */
194
216
  export declare class SessionBrowserService implements SessionBrowserRequestHeadersPort {
195
217
  private readonly ports;
218
+ private readonly options;
196
219
  private readonly records;
197
220
  private readonly generations;
198
221
  private readonly requestHeaderPolicies;
199
222
  private readonly queues;
200
223
  private readonly deletingSessions;
224
+ private readonly closingAutomationPages;
201
225
  private readonly observers;
202
226
  private readonly pageIdFactory;
203
227
  private readonly bootGenerationSeed;
@@ -229,6 +253,7 @@ export declare class SessionBrowserService implements SessionBrowserRequestHeade
229
253
  * Runtime boundary.
230
254
  */
231
255
  getRuntimeLocalEndpoint(sessionId: string): Promise<RuntimeBrowserEndpointDescriptor>;
256
+ resolveAutomationPage(sessionId: string, pageId?: string): Promise<SessionBrowserAutomationBinding>;
232
257
  /**
233
258
  * Resolve one opaque Page to its generation-local Host source. This is the
234
259
  * only presentation path allowed to cross the semantic/physical Page seam.
@@ -260,6 +285,7 @@ export declare class SessionBrowserService implements SessionBrowserRequestHeade
260
285
  private reconcileForRead;
261
286
  private reconcileForMutation;
262
287
  private tryReconcileAfterRejectedMutation;
288
+ private retainRecoverableObservation;
263
289
  private reconcile;
264
290
  private createPageId;
265
291
  private project;
@@ -1,4 +1,6 @@
1
1
  import { randomBytes, randomUUID } from "node:crypto";
2
+ import { join } from "node:path";
3
+ import { diagnosticEvents, sessionDiagnosticDir } from "@rynx-ai/core";
2
4
  import { RUNTIME_BROWSER_MAX_PAGES, parseRuntimeBrowserRequestHeadersGetParams, parseRuntimeBrowserRequestHeadersSetParams, parseRuntimeBrowserCloseParams, parseRuntimeBrowserOpenParams, parseRuntimeBrowserPageCreateParams, parseRuntimeBrowserPageMutationParams, parseRuntimeBrowserPageNavigateParams, parseRuntimeBrowserState, parseRuntimeBrowserStateGetParams, } from "@rynx-ai/protocol/runtime-browser";
3
5
  import { parseRuntimeBrowserEndpointDescriptor, } from "@rynx-ai/protocol/runtime-browser-bootstrap";
4
6
  const MAX_HOST_PAGE_ID_CHARS = 2_048;
@@ -35,11 +37,13 @@ export class SessionBrowserServiceError extends Error {
35
37
  */
36
38
  export class SessionBrowserService {
37
39
  ports;
40
+ options;
38
41
  records = new Map();
39
42
  generations = new Map();
40
43
  requestHeaderPolicies = new Map();
41
44
  queues = new Map();
42
45
  deletingSessions = new Set();
46
+ closingAutomationPages = new Map();
43
47
  observers = new Set();
44
48
  pageIdFactory;
45
49
  bootGenerationSeed;
@@ -50,6 +54,7 @@ export class SessionBrowserService {
50
54
  closeAllPromise;
51
55
  constructor(ports, options = {}) {
52
56
  this.ports = ports;
57
+ this.options = options;
53
58
  this.pageIdFactory = options.pageIdFactory ?? (() => `page_${randomUUID()}`);
54
59
  this.bootGenerationSeed = options.bootGenerationSeed ?? randomBootGenerationSeed();
55
60
  this.requestHeaderRevisionSeed = options.requestHeaderRevisionSeed ??
@@ -132,7 +137,7 @@ export class SessionBrowserService {
132
137
  }
133
138
  catch (error) {
134
139
  if (error instanceof SessionBrowserHostError &&
135
- (error.code === "unavailable" || error.code === "outcome_unknown")) {
140
+ error.code === "unavailable") {
136
141
  record.status = "unavailable";
137
142
  }
138
143
  throw mapHostError(error, "Browser inspection endpoint is unavailable");
@@ -242,11 +247,53 @@ export class SessionBrowserService {
242
247
  });
243
248
  }
244
249
  catch (error) {
245
- record.status = "unavailable";
250
+ if (!this.retainRecoverableObservation(record, error))
251
+ record.status = "unavailable";
246
252
  throw mapHostError(error, "Browser automation endpoint is unavailable");
247
253
  }
248
254
  });
249
255
  }
256
+ async resolveAutomationPage(sessionId, pageId) {
257
+ parseInput(() => parseRuntimeBrowserStateGetParams({ sessionId }), "Browser Session is invalid");
258
+ return this.exclusive(sessionId, async () => {
259
+ this.ensureRunning();
260
+ await this.ensureSessionExists(sessionId);
261
+ const record = this.requireRecord(sessionId);
262
+ if (record.status !== "ready")
263
+ throw new SessionBrowserServiceError("unavailable", "Browser is not available");
264
+ const selected = pageId ?? record.activePageId;
265
+ const page = selected ? record.pagesById.get(selected) : undefined;
266
+ if (!page)
267
+ throw new SessionBrowserServiceError("not_found", "Browser Page was not found; run browser pages");
268
+ if (!record.handle.getPageAutomationEndpoint) {
269
+ throw new SessionBrowserServiceError("unsupported", "Browser Host needs an upgrade for page automation");
270
+ }
271
+ const endpoint = await record.handle.getPageAutomationEndpoint(page.hostPageId);
272
+ const descriptor = parseRuntimeBrowserEndpointDescriptor({
273
+ sessionId, browserGeneration: record.browserGeneration,
274
+ executionBackend: record.executionBackend, ...endpoint,
275
+ });
276
+ const isCurrent = () => !this.shuttingDown && !this.deletingSessions.has(sessionId) && this.records.get(sessionId) === record &&
277
+ record.status === "ready" && !this.closingAutomationPages.has(JSON.stringify([sessionId, record.browserGeneration, page.pageId])) &&
278
+ record.pagesById.get(page.pageId)?.hostPageId === page.hostPageId;
279
+ return {
280
+ ...descriptor, pageId: page.pageId, pageTargetId: endpoint.pageTargetId, isCurrent,
281
+ acquireAutomationVisibility: async (canActivate) => {
282
+ if (!isCurrent())
283
+ throw new SessionBrowserServiceError("not_found", "Browser Page closed before render admission");
284
+ if (!record.handle.acquireAutomationVisibility) {
285
+ throw new SessionBrowserServiceError("unsupported", "Upgrade the Browser Host for automation rendering");
286
+ }
287
+ const visibility = await record.handle.acquireAutomationVisibility(page.hostPageId, canActivate);
288
+ if (!isCurrent()) {
289
+ await visibility.release(false).catch(() => undefined);
290
+ throw new SessionBrowserServiceError("not_found", "Browser Page closed during render admission");
291
+ }
292
+ return visibility;
293
+ },
294
+ };
295
+ });
296
+ }
250
297
  /**
251
298
  * Resolve one opaque Page to its generation-local Host source. This is the
252
299
  * only presentation path allowed to cross the semantic/physical Page seam.
@@ -361,8 +408,20 @@ export class SessionBrowserService {
361
408
  return this.runMutation(record, () => record.handle.createPage(params.url));
362
409
  });
363
410
  }
364
- closePage(input) {
365
- return this.pageMutation(input, "Browser page close request is invalid", (record, hostPageId) => record.handle.closePage(hostPageId));
411
+ async closePage(input) {
412
+ const params = parseInput(() => parseRuntimeBrowserPageMutationParams(input), "Browser page close request is invalid");
413
+ const key = JSON.stringify([params.sessionId, params.browserGeneration, params.pageId]);
414
+ this.closingAutomationPages.set(key, (this.closingAutomationPages.get(key) ?? 0) + 1);
415
+ try {
416
+ return await this.pageMutation(params, "Browser page close request is invalid", (record, hostPageId) => record.handle.closePage(hostPageId));
417
+ }
418
+ finally {
419
+ const remaining = this.closingAutomationPages.get(key) - 1;
420
+ if (remaining)
421
+ this.closingAutomationPages.set(key, remaining);
422
+ else
423
+ this.closingAutomationPages.delete(key);
424
+ }
366
425
  }
367
426
  activatePage(input) {
368
427
  return this.pageMutation(input, "Browser page activation request is invalid", (record, hostPageId) => record.handle.activatePage(hostPageId));
@@ -583,7 +642,7 @@ export class SessionBrowserService {
583
642
  }
584
643
  catch (error) {
585
644
  if (error instanceof SessionBrowserHostError &&
586
- (error.code === "unavailable" || error.code === "outcome_unknown")) {
645
+ error.code === "unavailable") {
587
646
  record.status = "unavailable";
588
647
  }
589
648
  if (error instanceof SessionBrowserHostError && error.code === "page_not_found") {
@@ -596,7 +655,8 @@ export class SessionBrowserService {
596
655
  return this.project(record);
597
656
  }
598
657
  catch (error) {
599
- record.status = "unavailable";
658
+ if (!this.retainRecoverableObservation(record, error))
659
+ record.status = "unavailable";
600
660
  throw new SessionBrowserServiceError("outcome_unknown", "Browser operation completed but its outcome could not be reconciled", { cause: error });
601
661
  }
602
662
  }
@@ -605,6 +665,8 @@ export class SessionBrowserService {
605
665
  await this.reconcile(record);
606
666
  }
607
667
  catch (error) {
668
+ if (this.retainRecoverableObservation(record, error))
669
+ return;
608
670
  record.status = "unavailable";
609
671
  throw new SessionBrowserServiceError("host_failure", "Browser Host state could not be reconciled", { cause: error });
610
672
  }
@@ -614,6 +676,9 @@ export class SessionBrowserService {
614
676
  await this.reconcile(record);
615
677
  }
616
678
  catch (error) {
679
+ if (this.retainRecoverableObservation(record, error)) {
680
+ throw mapHostError(error, "Browser state observation is temporarily unavailable");
681
+ }
617
682
  record.status = "unavailable";
618
683
  throw new SessionBrowserServiceError("unavailable", "Browser became unavailable while reconciling Host state", { cause: error });
619
684
  }
@@ -622,9 +687,24 @@ export class SessionBrowserService {
622
687
  try {
623
688
  await this.reconcile(record);
624
689
  }
625
- catch {
626
- record.status = "unavailable";
690
+ catch (error) {
691
+ if (!this.retainRecoverableObservation(record, error))
692
+ record.status = "unavailable";
693
+ }
694
+ }
695
+ retainRecoverableObservation(record, error) {
696
+ if (!(error instanceof SessionBrowserHostError) ||
697
+ !["command_timeout", "operation_failed", "outcome_unknown"].includes(error.code))
698
+ return false;
699
+ record.observationStale = true;
700
+ try {
701
+ this.options.onObservationError?.(error, {
702
+ sessionId: record.sessionId,
703
+ browserGeneration: record.browserGeneration,
704
+ });
627
705
  }
706
+ catch { /* A diagnostic sink cannot change Browser readiness. */ }
707
+ return true;
628
708
  }
629
709
  async reconcile(record) {
630
710
  const snapshot = await record.handle.snapshot();
@@ -652,6 +732,7 @@ export class SessionBrowserService {
652
732
  loading: hostPage.loading,
653
733
  canGoBack: hostPage.canGoBack,
654
734
  canGoForward: hostPage.canGoForward,
735
+ ...(hostPage.observationStale === undefined ? {} : { observationStale: hostPage.observationStale }),
655
736
  });
656
737
  }
657
738
  if (snapshot.activeHostPageId !== null &&
@@ -704,6 +785,7 @@ export class SessionBrowserService {
704
785
  record.pageOrder = parsed.pages.map((page) => page.pageId);
705
786
  record.activePageId = parsed.activePageId;
706
787
  record.capabilities = parsed.capabilities;
788
+ record.observationStale = parsed.pages.some((page) => page.observationStale);
707
789
  }
708
790
  createPageId(record, pending) {
709
791
  for (let attempt = 0; attempt < MAX_PAGE_ID_FACTORY_ATTEMPTS; attempt += 1) {
@@ -724,6 +806,7 @@ export class SessionBrowserService {
724
806
  activePageId: record.activePageId,
725
807
  pages: record.pageOrder.map((pageId) => record.pagesById.get(pageId)),
726
808
  capabilities: record.capabilities,
809
+ ...(record.observationStale ? { observationStale: true } : {}),
727
810
  });
728
811
  if (!parsed)
729
812
  throw new Error("Browser record unexpectedly projected to absence");
@@ -734,9 +817,13 @@ export class SessionBrowserService {
734
817
  this.observers.add(observer);
735
818
  }
736
819
  async consumeEvents(record) {
820
+ const log = diagnosticEvents(join(sessionDiagnosticDir("browser", record.sessionId), "page-events.jsonl"), { sessionId: record.sessionId });
821
+ const lifecycle = diagnosticEvents(join(sessionDiagnosticDir("browser", record.sessionId), "browser-host.log"), { sessionId: record.sessionId, browserGeneration: record.browserGeneration });
822
+ lifecycle("browser.observer.started");
737
823
  let failed;
738
824
  try {
739
825
  for await (const event of record.handle.events) {
826
+ log("browser.observation", { ...event });
740
827
  await this.exclusive(record.sessionId, async () => {
741
828
  if (!this.isCurrent(record, event.browserGeneration))
742
829
  return;
@@ -751,8 +838,9 @@ export class SessionBrowserService {
751
838
  try {
752
839
  await this.reconcile(record);
753
840
  }
754
- catch {
755
- record.status = "unavailable";
841
+ catch (error) {
842
+ if (!this.retainRecoverableObservation(record, error))
843
+ record.status = "unavailable";
756
844
  }
757
845
  }, true);
758
846
  }
@@ -760,6 +848,7 @@ export class SessionBrowserService {
760
848
  catch (error) {
761
849
  failed = error;
762
850
  }
851
+ lifecycle("browser.observer.ended", { ...(failed ? { error: String(failed) } : {}) });
763
852
  await this.exclusive(record.sessionId, async () => {
764
853
  if (!this.isCurrent(record, record.browserGeneration))
765
854
  return;
@@ -848,6 +937,12 @@ function mapHostError(error, fallbackMessage) {
848
937
  if (error instanceof SessionBrowserServiceError)
849
938
  return error;
850
939
  if (error instanceof SessionBrowserHostError) {
940
+ if (error.code === "command_timeout") {
941
+ return new SessionBrowserServiceError("command_timeout", "Browser command timed out; do not automatically repeat an accepted action", { cause: error });
942
+ }
943
+ if (error.code === "operation_failed") {
944
+ return new SessionBrowserServiceError("host_failure", fallbackMessage, { cause: error });
945
+ }
851
946
  if (error.code === "page_not_found") {
852
947
  return new SessionBrowserServiceError("not_found", "Browser Page was not found", {
853
948
  cause: error,
@@ -20,5 +20,7 @@ export declare class SessionBrowserSurfaceCoordinator implements DirectRuntimeBr
20
20
  private readonly maxPendingEventBytes;
21
21
  constructor(browsers: SessionBrowserService, options?: SessionBrowserSurfaceCoordinatorOptions);
22
22
  open(frame: RuntimeBrowserSurfaceOpenFrame, context: Parameters<DirectRuntimeBrowserSurfaceHost["open"]>[1]): Promise<DirectRuntimeBrowserSurfaceAttachment>;
23
+ isHumanControlled(sessionId: string, browserGeneration: number, pageId: string): boolean;
24
+ isBrowserHumanControlled(sessionId: string, browserGeneration: number): boolean;
23
25
  private exclusivePage;
24
26
  }
@@ -95,6 +95,17 @@ export class SessionBrowserSurfaceCoordinator {
95
95
  return attachment;
96
96
  });
97
97
  }
98
+ isHumanControlled(sessionId, browserGeneration, pageId) {
99
+ const page = this.pages.get(`${sessionId}\u0000${pageId}`);
100
+ return page?.browserGeneration === browserGeneration && page.hasHumanControl();
101
+ }
102
+ isBrowserHumanControlled(sessionId, browserGeneration) {
103
+ for (const [key, page] of this.pages) {
104
+ if (key.startsWith(`${sessionId}\u0000`) && page.browserGeneration === browserGeneration && page.hasHumanControl())
105
+ return true;
106
+ }
107
+ return false;
108
+ }
98
109
  exclusivePage(pageKey, operation) {
99
110
  const previous = this.pageQueues.get(pageKey) ?? Promise.resolve();
100
111
  const result = previous.then(operation, operation);
@@ -173,6 +184,9 @@ class SharedPageSurface {
173
184
  getViewport() {
174
185
  return this.page.source.getViewport();
175
186
  }
187
+ hasHumanControl() {
188
+ return Boolean(this.owner || this.desiredOwner);
189
+ }
176
190
  driverState() {
177
191
  return this.owner
178
192
  ? { driver: "stream", driverSubscriptionId: this.owner.subscriptionId }
@@ -647,6 +661,7 @@ function mapSurfaceOpenError(error) {
647
661
  case "shutting_down":
648
662
  return new DirectRuntimeBrowserSurfaceHostError("browser_closed", error.message, { cause: error });
649
663
  case "host_failure":
664
+ case "command_timeout":
650
665
  case "outcome_unknown":
651
666
  return new DirectRuntimeBrowserSurfaceHostError("internal_error", error.message, { cause: error });
652
667
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rynx-ai/server",
3
- "version": "0.1.11-beta.44",
3
+ "version": "0.1.11-beta.48",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/rynx-ai/rynx.git",
@@ -27,15 +27,15 @@
27
27
  "@koa/router": "^15.4.0",
28
28
  "koa": "^3.2.0",
29
29
  "ws": "^8.21.0",
30
- "@rynx-ai/core": "0.1.11-beta.44",
31
- "@rynx-ai/plugin-sdk": "0.1.11-beta.44",
32
- "@rynx-ai/protocol": "0.1.11-beta.44",
33
- "@rynx-ai/remote-runtime-e2ee": "0.1.11-beta.44",
34
- "@rynx-ai/runtime": "0.1.11-beta.44"
30
+ "@rynx-ai/core": "0.1.11-beta.48",
31
+ "@rynx-ai/plugin-sdk": "0.1.11-beta.48",
32
+ "@rynx-ai/protocol": "0.1.11-beta.48",
33
+ "@rynx-ai/remote-runtime-e2ee": "0.1.11-beta.48",
34
+ "@rynx-ai/runtime": "0.1.11-beta.48"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/ws": "^8.18.1",
38
- "@rynx-ai/control-web": "0.1.11-beta.44"
38
+ "@rynx-ai/control-web": "0.1.11-beta.48"
39
39
  },
40
40
  "scripts": {
41
41
  "build": "rm -rf dist && tsc -p tsconfig.json && mkdir -p dist/control-web && cp -R ../control-web/dist/. dist/control-web/",