@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
@@ -0,0 +1,127 @@
1
+ import { createHash, createPublicKey, randomBytes, randomUUID, verify, } from "node:crypto";
2
+ import { DIRECT_RUNTIME_BINDING_PROTOCOL, DIRECT_RUNTIME_BROWSER_SURFACE_BINDING_PROTOCOL, encodeClientAuthenticateCanonical, encodeClientAuthenticateProofPayload, encodeServerHelloCanonical, parseServerAccepted, parseServerHello, } from "@rynx-ai/protocol/direct-runtime";
3
+ /** Daemon-side proof verification shared by Direct control and Browser Surface bindings. */
4
+ export class DaemonDirectRuntimeAuthenticator {
5
+ identity;
6
+ runtime;
7
+ access;
8
+ constructor(identity, runtime, access) {
9
+ this.identity = identity;
10
+ this.runtime = runtime;
11
+ this.access = access;
12
+ }
13
+ createServerHello({ channelBinding, role, signal, }) {
14
+ signal.throwIfAborted();
15
+ const status = this.runtime.status();
16
+ if (status.daemonId !== this.identity.installationId) {
17
+ throw new Error("daemon Runtime identity does not match its installation identity");
18
+ }
19
+ const fields = {
20
+ type: "server.hello",
21
+ role,
22
+ daemonId: status.daemonId,
23
+ daemonInstanceId: status.daemonInstanceId,
24
+ identityPublicKey: this.identity.publicKey,
25
+ nonce: randomBytes(32).toString("base64url"),
26
+ bindingProtocolVersion: bindingProtocol(role).currentVersion,
27
+ minimumCompatibleBindingVersion: bindingProtocol(role).minimumCompatibleVersion,
28
+ coreProtocolVersion: status.coreProtocolVersion,
29
+ minimumCompatibleCoreVersion: status.minimumCompatibleCoreVersion,
30
+ };
31
+ return parseServerHello({
32
+ ...fields,
33
+ signature: this.identity.signDirectRuntimeServerHello(channelBinding, fields),
34
+ });
35
+ }
36
+ authenticate({ channelBinding, role, serverHello, clientAuthenticate, signal, }) {
37
+ signal.throwIfAborted();
38
+ if (clientAuthenticate.role !== role || serverHello.role !== role) {
39
+ throw new Error("unsupported Direct Runtime channel role");
40
+ }
41
+ if (role === "browser-surface" && clientAuthenticate.mode !== "connect") {
42
+ throw new Error("Browser Surface channels require an existing Direct Runtime grant");
43
+ }
44
+ if (clientAuthenticate.daemonId !== serverHello.daemonId ||
45
+ clientAuthenticate.daemonInstanceId !== serverHello.daemonInstanceId ||
46
+ clientAuthenticate.bindingProtocolVersion <
47
+ serverHello.minimumCompatibleBindingVersion ||
48
+ clientAuthenticate.bindingProtocolVersion > serverHello.bindingProtocolVersion ||
49
+ clientAuthenticate.coreProtocolVersion < serverHello.minimumCompatibleCoreVersion ||
50
+ clientAuthenticate.coreProtocolVersion > serverHello.coreProtocolVersion) {
51
+ throw new Error("Direct Runtime authentication negotiation does not match server hello");
52
+ }
53
+ const helloFields = withoutSignature(serverHello);
54
+ const authenticateFields = withoutSignature(clientAuthenticate);
55
+ const helloHash = sha256(encodeServerHelloCanonical(helloFields));
56
+ const authenticateHash = sha256(encodeClientAuthenticateCanonical(authenticateFields));
57
+ const clientPublicKey = createEd25519PublicKey(clientAuthenticate.clientPublicKey);
58
+ const clientProof = encodeClientAuthenticateProofPayload(channelBinding, helloHash, authenticateFields);
59
+ if (!verify(null, clientProof, clientPublicKey, Buffer.from(clientAuthenticate.signature, "base64url"))) {
60
+ throw new Error("Direct Runtime client proof is invalid");
61
+ }
62
+ signal.throwIfAborted();
63
+ const grant = clientAuthenticate.mode === "enroll"
64
+ ? this.access.redeemPairingClaim({
65
+ claimId: clientAuthenticate.claimId,
66
+ claimSecret: clientAuthenticate.claimSecret,
67
+ clientId: clientAuthenticate.clientId,
68
+ clientPublicKey: clientAuthenticate.clientPublicKey,
69
+ })
70
+ : this.access.resolveActiveGrant({
71
+ grantId: clientAuthenticate.grantId,
72
+ clientId: clientAuthenticate.clientId,
73
+ clientPublicKey: clientAuthenticate.clientPublicKey,
74
+ });
75
+ signal.throwIfAborted();
76
+ const acceptedFields = {
77
+ type: "server.accepted",
78
+ role,
79
+ daemonId: serverHello.daemonId,
80
+ daemonInstanceId: serverHello.daemonInstanceId,
81
+ bindingProtocolVersion: clientAuthenticate.bindingProtocolVersion,
82
+ coreProtocolVersion: clientAuthenticate.coreProtocolVersion,
83
+ channelId: randomUUID(),
84
+ grantId: grant.grantId,
85
+ clientId: grant.clientId,
86
+ authorities: ["daemon.full"],
87
+ };
88
+ const accepted = parseServerAccepted({
89
+ ...acceptedFields,
90
+ signature: this.identity.signDirectRuntimeServerAccepted(channelBinding, helloHash, authenticateHash, acceptedFields),
91
+ });
92
+ return {
93
+ peer: {
94
+ clientId: grant.clientId,
95
+ grantId: grant.grantId,
96
+ subject: {
97
+ subjectId: `client:${grant.clientId}`,
98
+ effectiveAuthorities: [...grant.authorities],
99
+ },
100
+ },
101
+ accepted,
102
+ };
103
+ }
104
+ }
105
+ function bindingProtocol(role) {
106
+ return role === "control"
107
+ ? DIRECT_RUNTIME_BINDING_PROTOCOL
108
+ : DIRECT_RUNTIME_BROWSER_SURFACE_BINDING_PROTOCOL;
109
+ }
110
+ function withoutSignature(value) {
111
+ const { signature: _signature, ...fields } = value;
112
+ return fields;
113
+ }
114
+ function sha256(value) {
115
+ return Uint8Array.from(createHash("sha256").update(value).digest());
116
+ }
117
+ function createEd25519PublicKey(encoded) {
118
+ const key = createPublicKey({
119
+ key: Buffer.from(encoded, "base64url"),
120
+ format: "der",
121
+ type: "spki",
122
+ });
123
+ if (key.asymmetricKeyType !== "ed25519") {
124
+ throw new Error("Direct Runtime client key must use Ed25519");
125
+ }
126
+ return key;
127
+ }
@@ -0,0 +1,20 @@
1
+ export interface DirectRuntimeConfig {
2
+ bindHost: string;
3
+ /** Preferred port. The daemon replaces this with the actual bound port. */
4
+ port: number;
5
+ /** Present only in automatic mode. Candidates are tried after `port`. */
6
+ fallbackPorts?: readonly number[];
7
+ /** The preferred port came from advisory persisted state, so any bind failure may skip it. */
8
+ preferredPortIsPersistedFallback?: boolean;
9
+ /** Present only in automatic mode. Records the successfully bound port. */
10
+ recordListeningPort?: (port: number) => Promise<void>;
11
+ }
12
+ /**
13
+ * Resolve the daemon's always-available Direct Runtime listener.
14
+ *
15
+ * Normal operation has no certificate, advertised-route, or enablement
16
+ * configuration. The two RYNX_RUNTIME_* inputs are intentionally narrow
17
+ * deployment/test seams for overriding the socket bind only.
18
+ */
19
+ export declare function loadDirectRuntimeConfig(env?: NodeJS.ProcessEnv): Promise<DirectRuntimeConfig>;
20
+ export declare function directRuntimeFallbackPortFile(env?: NodeJS.ProcessEnv): string;
@@ -0,0 +1,101 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { rynxHome } from "@rynx-ai/core";
5
+ const DEFAULT_BIND_HOST = "0.0.0.0";
6
+ const DEFAULT_PORT = 7443;
7
+ const FALLBACK_PORT_FILE = "direct-runtime-port";
8
+ /**
9
+ * Resolve the daemon's always-available Direct Runtime listener.
10
+ *
11
+ * Normal operation has no certificate, advertised-route, or enablement
12
+ * configuration. The two RYNX_RUNTIME_* inputs are intentionally narrow
13
+ * deployment/test seams for overriding the socket bind only.
14
+ */
15
+ export async function loadDirectRuntimeConfig(env = process.env) {
16
+ const bindHost = parseBindHost(env.RYNX_RUNTIME_BIND_HOST);
17
+ const explicitPort = parseExplicitPort(env.RYNX_RUNTIME_PORT);
18
+ if (explicitPort !== undefined)
19
+ return { bindHost, port: explicitPort };
20
+ const persistedPort = await readPersistedFallbackPort(env);
21
+ return {
22
+ bindHost,
23
+ port: persistedPort ?? DEFAULT_PORT,
24
+ fallbackPorts: persistedPort === undefined
25
+ ? [0]
26
+ : [DEFAULT_PORT, 0],
27
+ ...(persistedPort === undefined
28
+ ? {}
29
+ : { preferredPortIsPersistedFallback: true }),
30
+ recordListeningPort: (port) => persistFallbackPort(port, env),
31
+ };
32
+ }
33
+ export function directRuntimeFallbackPortFile(env = process.env) {
34
+ return join(rynxHome(env), FALLBACK_PORT_FILE);
35
+ }
36
+ function parseBindHost(value) {
37
+ const host = value?.trim() || DEFAULT_BIND_HOST;
38
+ if (/\s|\//.test(host)) {
39
+ throw new Error("RYNX_RUNTIME_BIND_HOST is invalid");
40
+ }
41
+ return host;
42
+ }
43
+ function parseExplicitPort(value) {
44
+ if (value === undefined || value.trim() === "")
45
+ return undefined;
46
+ if (!/^\d{1,5}$/.test(value)) {
47
+ throw new Error("RYNX_RUNTIME_PORT must be an integer from 1 to 65535");
48
+ }
49
+ const port = Number(value);
50
+ if (port < 1 || port > 65_535) {
51
+ throw new Error("RYNX_RUNTIME_PORT must be an integer from 1 to 65535");
52
+ }
53
+ return port;
54
+ }
55
+ async function readPersistedFallbackPort(env) {
56
+ let value;
57
+ try {
58
+ value = (await readFile(directRuntimeFallbackPortFile(env), "utf8")).trim();
59
+ }
60
+ catch (error) {
61
+ if (hasCode(error, "ENOENT"))
62
+ return undefined;
63
+ throw error;
64
+ }
65
+ if (!/^\d{1,5}$/.test(value))
66
+ return undefined;
67
+ const port = Number(value);
68
+ return port >= 1 && port <= 65_535 && port !== DEFAULT_PORT
69
+ ? port
70
+ : undefined;
71
+ }
72
+ async function persistFallbackPort(port, env) {
73
+ if (!Number.isInteger(port) || port < 1 || port > 65_535) {
74
+ throw new Error("Direct Runtime listening port must be an integer from 1 to 65535");
75
+ }
76
+ const file = directRuntimeFallbackPortFile(env);
77
+ if (port === DEFAULT_PORT) {
78
+ await rm(file, { force: true });
79
+ return;
80
+ }
81
+ const directory = rynxHome(env);
82
+ const temporary = `${file}.tmp-${process.pid}-${randomUUID()}`;
83
+ await mkdir(directory, { recursive: true, mode: 0o700 });
84
+ try {
85
+ await writeFile(temporary, `${port}\n`, {
86
+ encoding: "utf8",
87
+ flag: "wx",
88
+ mode: 0o600,
89
+ });
90
+ await rename(temporary, file);
91
+ }
92
+ finally {
93
+ await rm(temporary, { force: true });
94
+ }
95
+ }
96
+ function hasCode(error, code) {
97
+ return Boolean(error &&
98
+ typeof error === "object" &&
99
+ "code" in error &&
100
+ error.code === code);
101
+ }
@@ -0,0 +1,58 @@
1
+ import { type SessionBrowserHost, type SessionBrowserHostCreateInput, type SessionBrowserHostHandle } from "@rynx-ai/server";
2
+ export interface EmbeddedBrowserProfileStateSnapshot {
3
+ ownedSessionIds: string[];
4
+ pendingDeletionSessionIds: string[];
5
+ }
6
+ export interface EmbeddedBrowserProfileStateStoreOptions {
7
+ /** Defaults to ~/.rynx/browser/profiles/embedded-electron. */
8
+ rootDir?: string;
9
+ }
10
+ /** Protected, atomically replaced ownership/tombstone state for Electron profiles. */
11
+ export declare class EmbeddedBrowserProfileStateStore {
12
+ private readonly rootDir;
13
+ private readonly statePath;
14
+ constructor(options?: EmbeddedBrowserProfileStateStoreOptions);
15
+ snapshot(): EmbeddedBrowserProfileStateSnapshot;
16
+ owns(sessionId: string): boolean;
17
+ /**
18
+ * Conservatively records that this daemon may own an Electron profile.
19
+ * The marker is durable before browser.create is dispatched, so an
20
+ * ambiguous Host outcome can still be cleaned up with the Session.
21
+ * Returns true only when this call introduced the marker.
22
+ */
23
+ markPossibleOwnership(sessionId: string): boolean;
24
+ /** Removes only a create-intent proven not to have produced Host state. */
25
+ discardCreateIntent(sessionId: string): void;
26
+ markPendingDeletion(sessionId: string): boolean;
27
+ confirmDeleted(sessionId: string): void;
28
+ private readState;
29
+ private writeState;
30
+ }
31
+ export interface ConnectedDesktopBrowserHost extends SessionBrowserHost {
32
+ readonly connected: boolean;
33
+ onConnected(listener: () => void | Promise<void>): () => void;
34
+ deleteSessionData(sessionId: string): Promise<void>;
35
+ }
36
+ export interface DurableEmbeddedBrowserProfileHostOptions {
37
+ store?: EmbeddedBrowserProfileStateStore;
38
+ warn?: (message: string) => void;
39
+ }
40
+ /**
41
+ * Adds daemon-durable Electron profile ownership to the ephemeral Desktop Host.
42
+ * Delete failures intentionally become retryable tombstones so logical Session
43
+ * deletion can finish while the desktop app is absent.
44
+ */
45
+ export declare class DurableEmbeddedBrowserProfileHost implements SessionBrowserHost {
46
+ private readonly desktop;
47
+ private readonly store;
48
+ private readonly warn;
49
+ private operations;
50
+ private readonly unsubscribe;
51
+ constructor(desktop: ConnectedDesktopBrowserHost, options?: DurableEmbeddedBrowserProfileHostOptions);
52
+ createBrowser(input: SessionBrowserHostCreateInput): Promise<SessionBrowserHostHandle>;
53
+ deleteSessionData(sessionId: string): Promise<void>;
54
+ flushPendingDeletions(): Promise<void>;
55
+ dispose(): void;
56
+ private tryDelete;
57
+ private exclusive;
58
+ }
@@ -0,0 +1,338 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { chmodSync, closeSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
3
+ import { constants as fsConstants } from "node:fs";
4
+ import { join, resolve } from "node:path";
5
+ import { rynxHome } from "@rynx-ai/core";
6
+ import { DesktopBrowserHostAbsentError, SessionBrowserHostError, } from "@rynx-ai/server";
7
+ const STATE_FILE = "profile-ownership.json";
8
+ const STATE_KIND = "rynx-embedded-browser-profile-ownership";
9
+ const MAX_STATE_BYTES = 64 * 1_024;
10
+ const MAX_SESSION_IDS = 4_096;
11
+ const MAX_SESSION_ID_CHARS = 512;
12
+ const SESSION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._~-]*$/;
13
+ /** Protected, atomically replaced ownership/tombstone state for Electron profiles. */
14
+ export class EmbeddedBrowserProfileStateStore {
15
+ rootDir;
16
+ statePath;
17
+ constructor(options = {}) {
18
+ this.rootDir = options.rootDir === undefined
19
+ ? ensureDefaultStoreRoot()
20
+ : resolve(options.rootDir);
21
+ this.statePath = join(this.rootDir, STATE_FILE);
22
+ ensureProtectedDirectory(this.rootDir);
23
+ this.readState();
24
+ }
25
+ snapshot() {
26
+ const state = this.readState();
27
+ return {
28
+ ownedSessionIds: [...state.ownedSessionIds],
29
+ pendingDeletionSessionIds: [...state.pendingDeletionSessionIds],
30
+ };
31
+ }
32
+ owns(sessionId) {
33
+ requireSessionId(sessionId);
34
+ return this.readState().ownedSessionIds.includes(sessionId);
35
+ }
36
+ /**
37
+ * Conservatively records that this daemon may own an Electron profile.
38
+ * The marker is durable before browser.create is dispatched, so an
39
+ * ambiguous Host outcome can still be cleaned up with the Session.
40
+ * Returns true only when this call introduced the marker.
41
+ */
42
+ markPossibleOwnership(sessionId) {
43
+ requireSessionId(sessionId);
44
+ const state = this.readState();
45
+ if (state.pendingDeletionSessionIds.includes(sessionId)) {
46
+ throw new Error("Embedded Browser profile is pending deletion");
47
+ }
48
+ if (state.ownedSessionIds.includes(sessionId))
49
+ return false;
50
+ state.ownedSessionIds.push(sessionId);
51
+ this.writeState(state);
52
+ return true;
53
+ }
54
+ /** Removes only a create-intent proven not to have produced Host state. */
55
+ discardCreateIntent(sessionId) {
56
+ requireSessionId(sessionId);
57
+ const state = this.readState();
58
+ if (state.pendingDeletionSessionIds.includes(sessionId)) {
59
+ throw new Error("Embedded Browser profile is pending deletion");
60
+ }
61
+ const ownedSessionIds = state.ownedSessionIds.filter((value) => value !== sessionId);
62
+ if (ownedSessionIds.length === state.ownedSessionIds.length)
63
+ return;
64
+ this.writeState({ ...state, ownedSessionIds });
65
+ }
66
+ markPendingDeletion(sessionId) {
67
+ requireSessionId(sessionId);
68
+ const state = this.readState();
69
+ if (!state.ownedSessionIds.includes(sessionId))
70
+ return false;
71
+ if (!state.pendingDeletionSessionIds.includes(sessionId)) {
72
+ state.pendingDeletionSessionIds.push(sessionId);
73
+ this.writeState(state);
74
+ }
75
+ return true;
76
+ }
77
+ confirmDeleted(sessionId) {
78
+ requireSessionId(sessionId);
79
+ const state = this.readState();
80
+ const ownedSessionIds = state.ownedSessionIds.filter((value) => value !== sessionId);
81
+ const pendingDeletionSessionIds = state.pendingDeletionSessionIds.filter((value) => value !== sessionId);
82
+ if (ownedSessionIds.length === state.ownedSessionIds.length &&
83
+ pendingDeletionSessionIds.length === state.pendingDeletionSessionIds.length)
84
+ return;
85
+ this.writeState({ ...state, ownedSessionIds, pendingDeletionSessionIds });
86
+ }
87
+ readState() {
88
+ if (!pathEntryExists(this.statePath))
89
+ return emptyState();
90
+ rejectSymlinkOrNonFile(this.statePath);
91
+ chmodSync(this.statePath, 0o600);
92
+ const descriptor = openNoFollow(this.statePath, fsConstants.O_RDONLY);
93
+ try {
94
+ const stat = fstatSync(descriptor);
95
+ if (!stat.isFile() || stat.size > MAX_STATE_BYTES) {
96
+ throw new Error("Embedded Browser profile state is not a bounded regular file");
97
+ }
98
+ let value;
99
+ try {
100
+ value = JSON.parse(readFileSync(descriptor, "utf8"));
101
+ }
102
+ catch {
103
+ throw new Error("Embedded Browser profile state is invalid JSON");
104
+ }
105
+ return parseState(value);
106
+ }
107
+ finally {
108
+ closeSync(descriptor);
109
+ }
110
+ }
111
+ writeState(value) {
112
+ const state = parseState({
113
+ ...value,
114
+ ownedSessionIds: [...value.ownedSessionIds].sort(),
115
+ pendingDeletionSessionIds: [...value.pendingDeletionSessionIds].sort(),
116
+ });
117
+ const encoded = Buffer.from(`${JSON.stringify(state)}\n`, "utf8");
118
+ if (encoded.byteLength > MAX_STATE_BYTES) {
119
+ throw new Error("Embedded Browser profile state exceeds its durable size bound");
120
+ }
121
+ ensureProtectedDirectory(this.rootDir);
122
+ rejectSymlinkOrNonFile(this.statePath);
123
+ const temporaryPath = join(this.rootDir, `.${STATE_FILE}.${process.pid}.${randomBytes(12).toString("hex")}.tmp`);
124
+ let descriptor;
125
+ try {
126
+ descriptor = openNoFollow(temporaryPath, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL, 0o600);
127
+ writeFileSync(descriptor, encoded);
128
+ fsyncSync(descriptor);
129
+ closeSync(descriptor);
130
+ descriptor = undefined;
131
+ renameSync(temporaryPath, this.statePath);
132
+ chmodSync(this.statePath, 0o600);
133
+ fsyncDirectory(this.rootDir);
134
+ }
135
+ finally {
136
+ if (descriptor !== undefined)
137
+ closeSync(descriptor);
138
+ rmSync(temporaryPath, { force: true });
139
+ }
140
+ }
141
+ }
142
+ /**
143
+ * Adds daemon-durable Electron profile ownership to the ephemeral Desktop Host.
144
+ * Delete failures intentionally become retryable tombstones so logical Session
145
+ * deletion can finish while the desktop app is absent.
146
+ */
147
+ export class DurableEmbeddedBrowserProfileHost {
148
+ desktop;
149
+ store;
150
+ warn;
151
+ operations = Promise.resolve();
152
+ unsubscribe;
153
+ constructor(desktop, options = {}) {
154
+ this.desktop = desktop;
155
+ this.store = options.store ?? new EmbeddedBrowserProfileStateStore();
156
+ this.warn = options.warn ?? (() => undefined);
157
+ this.unsubscribe = desktop.onConnected(() => this.flushPendingDeletions());
158
+ if (desktop.connected)
159
+ queueMicrotask(() => void this.flushPendingDeletions());
160
+ }
161
+ createBrowser(input) {
162
+ return this.exclusive(async () => {
163
+ if (this.store.snapshot().pendingDeletionSessionIds.includes(input.sessionId)) {
164
+ throw new SessionBrowserHostError("unavailable", "Embedded Browser profile deletion is still pending");
165
+ }
166
+ const introducedCreateIntent = this.store.markPossibleOwnership(input.sessionId);
167
+ try {
168
+ return await this.desktop.createBrowser(input);
169
+ }
170
+ catch (error) {
171
+ if (introducedCreateIntent && isDefinitiveCreateRejection(error)) {
172
+ this.store.discardCreateIntent(input.sessionId);
173
+ }
174
+ throw error;
175
+ }
176
+ });
177
+ }
178
+ deleteSessionData(sessionId) {
179
+ return this.exclusive(async () => {
180
+ if (!this.store.markPendingDeletion(sessionId))
181
+ return;
182
+ if (!this.desktop.connected)
183
+ return;
184
+ await this.tryDelete(sessionId);
185
+ });
186
+ }
187
+ flushPendingDeletions() {
188
+ return this.exclusive(async () => {
189
+ if (!this.desktop.connected)
190
+ return;
191
+ const pending = this.store.snapshot().pendingDeletionSessionIds;
192
+ for (const sessionId of pending) {
193
+ if (!this.desktop.connected)
194
+ return;
195
+ await this.tryDelete(sessionId);
196
+ }
197
+ });
198
+ }
199
+ dispose() {
200
+ this.unsubscribe();
201
+ }
202
+ async tryDelete(sessionId) {
203
+ try {
204
+ await this.desktop.deleteSessionData(sessionId);
205
+ this.store.confirmDeleted(sessionId);
206
+ }
207
+ catch (error) {
208
+ try {
209
+ this.warn(`Embedded Browser profile deletion will retry for ${sessionId}: ${errorMessage(error)}`);
210
+ }
211
+ catch {
212
+ // Diagnostics must not make a durable deletion tombstone non-retryable.
213
+ }
214
+ }
215
+ }
216
+ exclusive(operation) {
217
+ const result = this.operations.then(operation, operation);
218
+ this.operations = result.then(() => undefined, () => undefined);
219
+ return result;
220
+ }
221
+ }
222
+ function emptyState() {
223
+ return {
224
+ schemaVersion: 1,
225
+ kind: STATE_KIND,
226
+ ownedSessionIds: [],
227
+ pendingDeletionSessionIds: [],
228
+ };
229
+ }
230
+ function parseState(value) {
231
+ if (!isRecord(value))
232
+ throw new Error("Embedded Browser profile state must be an object");
233
+ const keys = Object.keys(value).sort();
234
+ const expected = ["kind", "ownedSessionIds", "pendingDeletionSessionIds", "schemaVersion"];
235
+ if (keys.length !== expected.length || keys.some((key, index) => key !== expected[index])) {
236
+ throw new Error("Embedded Browser profile state contains unknown fields");
237
+ }
238
+ if (value.schemaVersion !== 1 || value.kind !== STATE_KIND) {
239
+ throw new Error("Embedded Browser profile state identity is invalid");
240
+ }
241
+ const ownedSessionIds = sessionIds(value.ownedSessionIds, "ownedSessionIds");
242
+ const pendingDeletionSessionIds = sessionIds(value.pendingDeletionSessionIds, "pendingDeletionSessionIds");
243
+ const owned = new Set(ownedSessionIds);
244
+ if (pendingDeletionSessionIds.some((sessionId) => !owned.has(sessionId))) {
245
+ throw new Error("Embedded Browser deletion tombstone has no owned profile");
246
+ }
247
+ return {
248
+ schemaVersion: 1,
249
+ kind: STATE_KIND,
250
+ ownedSessionIds,
251
+ pendingDeletionSessionIds,
252
+ };
253
+ }
254
+ function sessionIds(value, label) {
255
+ if (!Array.isArray(value) || value.length > MAX_SESSION_IDS) {
256
+ throw new Error(`Embedded Browser ${label} is invalid`);
257
+ }
258
+ const result = value.map((sessionId) => {
259
+ requireSessionId(sessionId);
260
+ return sessionId;
261
+ });
262
+ if (new Set(result).size !== result.length) {
263
+ throw new Error(`Embedded Browser ${label} contains duplicates`);
264
+ }
265
+ return result;
266
+ }
267
+ function requireSessionId(value) {
268
+ if (typeof value !== "string" ||
269
+ value.length < 1 ||
270
+ value.length > MAX_SESSION_ID_CHARS ||
271
+ !SESSION_ID_PATTERN.test(value))
272
+ throw new Error("Embedded Browser Session identity is invalid");
273
+ }
274
+ function ensureProtectedDirectory(path) {
275
+ mkdirSync(path, { recursive: true, mode: 0o700 });
276
+ const stat = lstatSync(path);
277
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
278
+ throw new Error("Embedded Browser profile state root must be a direct directory");
279
+ }
280
+ chmodSync(path, 0o700);
281
+ }
282
+ function ensureDefaultStoreRoot() {
283
+ let current = resolve(rynxHome());
284
+ ensureProtectedDirectory(current);
285
+ for (const child of ["browser", "profiles", "embedded-electron"]) {
286
+ current = join(current, child);
287
+ if (!pathEntryExists(current))
288
+ mkdirSync(current, { mode: 0o700 });
289
+ ensureProtectedDirectory(current);
290
+ }
291
+ return current;
292
+ }
293
+ function rejectSymlinkOrNonFile(path) {
294
+ if (!pathEntryExists(path))
295
+ return;
296
+ const stat = lstatSync(path);
297
+ if (!stat.isFile() || stat.isSymbolicLink()) {
298
+ throw new Error("Embedded Browser profile state path must be a direct regular file");
299
+ }
300
+ }
301
+ function pathEntryExists(path) {
302
+ try {
303
+ lstatSync(path);
304
+ return true;
305
+ }
306
+ catch (error) {
307
+ if (isNodeError(error) && error.code === "ENOENT")
308
+ return false;
309
+ throw error;
310
+ }
311
+ }
312
+ function openNoFollow(path, flags, mode) {
313
+ return openSync(path, flags | (fsConstants.O_NOFOLLOW ?? 0), mode);
314
+ }
315
+ function fsyncDirectory(path) {
316
+ const descriptor = openSync(path, fsConstants.O_RDONLY);
317
+ try {
318
+ fsyncSync(descriptor);
319
+ }
320
+ finally {
321
+ closeSync(descriptor);
322
+ }
323
+ }
324
+ function isRecord(value) {
325
+ return typeof value === "object" && value !== null && !Array.isArray(value);
326
+ }
327
+ function isNodeError(error) {
328
+ return error instanceof Error && "code" in error;
329
+ }
330
+ function errorMessage(error) {
331
+ return error instanceof Error ? error.message : String(error);
332
+ }
333
+ function isDefinitiveCreateRejection(error) {
334
+ // `capacity` is deliberately not definitive: the Desktop Host also uses it
335
+ // when this Session already owns a Browser generation. In that recovery
336
+ // case, discarding the marker would make the existing profile uncollectable.
337
+ return error instanceof DesktopBrowserHostAbsentError;
338
+ }
@@ -0,0 +1,14 @@
1
+ import { type SessionBrowserHost } from "@rynx-ai/server";
2
+ import { type ChromeForTestingArtifactStore } from "./chrome-for-testing-store.js";
3
+ export interface HeadlessBrowserHostOptions {
4
+ /** Defaults to the immutable managed Chrome for Testing artifact store. */
5
+ artifactStore?: Pick<ChromeForTestingArtifactStore, "reserveActiveForLaunch">;
6
+ /** Defaults to ~/.rynx/browser/profiles/headless-chromium. */
7
+ profileRoot?: string;
8
+ startupTimeoutMs?: number;
9
+ cdpCommandTimeoutMs?: number;
10
+ terminationGraceMs?: number;
11
+ activePortPollMs?: number;
12
+ }
13
+ export declare function createHeadlessBrowserHost(options?: HeadlessBrowserHostOptions): SessionBrowserHost;
14
+ export declare function headlessBrowserProfilePath(profileRoot: string, sessionId: string): string;