@rynx-ai/cli 0.1.11-beta.5 → 0.1.11-beta.50

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.
@@ -3,10 +3,15 @@ import { chmodSync, existsSync, mkdirSync, openSync, closeSync, readFileSync, re
3
3
  import path from "node:path";
4
4
  import * as prompts from "@clack/prompts";
5
5
  import { AGENT_RUNTIME_IDS, getRuntimeProfile, loadConfig, rynxConfigFile, } from "@rynx-ai/core";
6
- import { inspectDaemonDiagnostics, prepareBundledPlugins, } from "@rynx-ai/daemon/setup-service";
6
+ import { inspectDaemonDiagnostics, inspectSystemDependencies, prepareBundledPlugins, } from "@rynx-ai/daemon/setup-service";
7
+ import { BUNDLED_TMUX_UNAVAILABLE_MESSAGE, resolveBundledTmux, } from "@rynx-ai/tmux";
8
+ import { enableAutostart, inspectAutostart, } from "../autostart.js";
9
+ import { createProgressDisplay } from "../progress-display.js";
7
10
  import { fail } from "./errors.js";
11
+ import { runLifecycleCommand } from "./lifecycle.js";
8
12
  export async function runSetupCommand(args) {
9
13
  const options = parseSetupOptions(args);
14
+ const progress = options.json ? undefined : createProgressDisplay();
10
15
  const interactive = !options.nonInteractive &&
11
16
  !options.hasConfigurationArguments &&
12
17
  Boolean(process.stdin.isTTY && process.stdout.isTTY);
@@ -50,23 +55,30 @@ export async function runSetupCommand(args) {
50
55
  },
51
56
  };
52
57
  let failed = false;
58
+ progress?.start("正在准备 Rynx 内置插件");
53
59
  try {
54
60
  const plugins = await prepareBundledPlugins();
55
61
  result.plugins = {
56
62
  status: plugins.status,
57
63
  changed: [...plugins.installed, ...plugins.updated],
58
64
  };
65
+ progress?.succeed("Rynx 内置插件已就绪");
59
66
  }
60
67
  catch (error) {
61
68
  failed = true;
69
+ progress?.clear();
70
+ const detail = errorMessage(error);
62
71
  result.plugins = {
63
72
  status: "error",
64
- detail: errorMessage(error),
73
+ detail,
65
74
  };
75
+ console.error(`Rynx 内置插件准备失败:${detail}`);
66
76
  }
77
+ progress?.start("正在检查 Rynx Browser");
67
78
  const diagnostics = await inspectDaemonDiagnostics();
68
79
  if (!diagnostics.browser.supported) {
69
80
  result.browser = { action: options.browser, status: "unsupported" };
81
+ progress?.succeed("当前平台不支持 Rynx Browser");
70
82
  }
71
83
  else if (diagnostics.browser.installedVersion) {
72
84
  result.browser = {
@@ -74,29 +86,42 @@ export async function runSetupCommand(args) {
74
86
  status: "ready",
75
87
  version: diagnostics.browser.installedVersion,
76
88
  };
89
+ progress?.succeed(`Rynx Browser 已就绪:${diagnostics.browser.installedVersion}`);
77
90
  }
78
91
  else if (options.browser !== "skip") {
92
+ progress?.update("正在安装 Rynx Browser");
79
93
  try {
80
94
  const { createBrowserArtifactManagementService } = await import("@rynx-ai/daemon/browser-artifacts");
81
- const installed = await createBrowserArtifactManagementService().install({ channel: "stable" }, options.json
82
- ? {}
83
- : { onProgress: (message) => console.error(message) });
95
+ const installed = await createBrowserArtifactManagementService().install({ channel: "stable" }, progress ? { onProgress: (message) => progress.update(message) } : {});
84
96
  result.browser = {
85
97
  action: options.browser,
86
98
  status: "ready",
87
99
  version: installed.installed.version,
88
100
  };
101
+ progress?.succeed(`Rynx Browser 已就绪:${installed.installed.version}`);
89
102
  }
90
103
  catch (error) {
91
104
  failed = true;
105
+ progress?.clear();
106
+ const detail = errorMessage(error);
92
107
  result.browser = {
93
108
  action: options.browser,
94
109
  status: "error",
95
- detail: errorMessage(error),
110
+ detail,
96
111
  };
112
+ console.error(`Rynx Browser 安装失败:${detail}`);
97
113
  }
98
114
  }
99
- if (options.json) {
115
+ else {
116
+ progress?.succeed("已跳过 Rynx Browser 安装");
117
+ }
118
+ if (interactive && process.env.RYNX_DISTRIBUTION !== "app") {
119
+ failed = await offerBackgroundServiceSetup() || failed;
120
+ }
121
+ if (options.resultFile) {
122
+ writeJsonAtomically(options.resultFile, result);
123
+ }
124
+ else if (options.json) {
100
125
  console.log(JSON.stringify(result, null, 2));
101
126
  }
102
127
  else if (interactive) {
@@ -140,13 +165,25 @@ export async function runDoctorCommand(args) {
140
165
  return { id, installed: !probe.error && probe.status === 0 };
141
166
  });
142
167
  const daemon = await inspectDaemonDiagnostics();
168
+ const tmuxBin = resolveBundledTmux();
169
+ const systemDependencies = tmuxBin
170
+ ? await inspectSystemDependencies({ tmuxBin })
171
+ : {
172
+ tmux: {
173
+ installed: false,
174
+ detail: BUNDLED_TMUX_UNAVAILABLE_MESSAGE,
175
+ },
176
+ };
143
177
  const result = {
144
- ok: configError === undefined && runtimes.some((runtime) => runtime.installed),
178
+ ok: configError === undefined &&
179
+ runtimes.some((runtime) => runtime.installed) &&
180
+ systemDependencies.tmux.installed,
145
181
  configPath: rynxConfigFile(),
146
182
  config,
147
183
  configError,
148
184
  runtimes,
149
185
  daemon,
186
+ systemDependencies,
150
187
  };
151
188
  if (json)
152
189
  console.log(JSON.stringify(result, null, 2));
@@ -159,6 +196,7 @@ export async function runDoctorCommand(args) {
159
196
  for (const runtime of runtimes) {
160
197
  console.log(`${runtime.id}: ${runtime.installed ? "installed" : "not installed"}`);
161
198
  }
199
+ console.log(`tmux: ${systemDependencies.tmux.installed ? "installed" : "not installed"}`);
162
200
  if (daemon.browser.supported) {
163
201
  console.log(`browser: ${daemon.browser.installedVersion ?? daemon.browser.error ?? "not installed"}`);
164
202
  }
@@ -186,6 +224,17 @@ function parseSetupOptions(args) {
186
224
  options.nonInteractive = true;
187
225
  continue;
188
226
  }
227
+ if (arg === "--result-file") {
228
+ if (options.resultFile !== undefined)
229
+ fail("setup: duplicate option --result-file");
230
+ const value = args[index + 1];
231
+ if (!value || value.startsWith("--"))
232
+ fail("setup: --result-file requires a value");
233
+ options.resultFile = path.resolve(value);
234
+ options.nonInteractive = true;
235
+ index += 1;
236
+ continue;
237
+ }
189
238
  if (arg === "--install-browser" || arg === "--skip-browser") {
190
239
  if (options.browser !== "auto")
191
240
  fail("setup: choose only one Browser action");
@@ -225,6 +274,9 @@ function parseSetupOptions(args) {
225
274
  fail(`setup: unknown option ${arg}`);
226
275
  }
227
276
  }
277
+ if (options.json && options.resultFile) {
278
+ fail("setup: choose either --json or --result-file");
279
+ }
228
280
  return options;
229
281
  }
230
282
  async function collectInteractiveSetup(config) {
@@ -257,19 +309,55 @@ async function collectInteractiveSetup(config) {
257
309
  if (prompts.isCancel(host))
258
310
  return cancelled();
259
311
  config.HOST = host;
260
- const logLevel = await prompts.text({
261
- message: "Log level",
262
- initialValue: String(config.LOG_LEVEL),
263
- });
264
- if (prompts.isCancel(logLevel))
265
- return cancelled();
266
- config.LOG_LEVEL = logLevel;
267
312
  return true;
268
313
  }
269
314
  function cancelled() {
270
315
  prompts.cancel("Cancelled");
271
316
  return false;
272
317
  }
318
+ async function offerBackgroundServiceSetup() {
319
+ const state = inspectAutostart();
320
+ if (!state.supported) {
321
+ prompts.log.warn(`Background service setup is unavailable${state.detail ? `: ${state.detail}` : ""}`);
322
+ return false;
323
+ }
324
+ const confirmed = await prompts.confirm({
325
+ message: state.registered
326
+ ? "Start the Rynx background service now? (Autostart is already enabled)"
327
+ : "Start Rynx now and automatically at login?",
328
+ initialValue: true,
329
+ });
330
+ if (prompts.isCancel(confirmed) || !confirmed) {
331
+ prompts.log.info("Skipped background service setup");
332
+ return false;
333
+ }
334
+ let failed = false;
335
+ if (!state.registered) {
336
+ try {
337
+ const enabled = enableAutostart();
338
+ prompts.log.success(`Autostart enabled${enabled.registrationPath ? `: ${enabled.registrationPath}` : ""}`);
339
+ if (enabled.manager === "systemd-user" && enabled.lingerEnabled === false) {
340
+ prompts.log.warn("Linger is disabled; Rynx stops when this user logs out");
341
+ if (enabled.lingerEnableCommand) {
342
+ prompts.log.info(`Optional, requires sudo and is never run by setup: ${enabled.lingerEnableCommand}`);
343
+ }
344
+ }
345
+ }
346
+ catch (error) {
347
+ failed = true;
348
+ prompts.log.warn(`Could not enable autostart: ${errorMessage(error)}`);
349
+ }
350
+ }
351
+ const startStatus = await runLifecycleCommand("start", [], { quiet: true });
352
+ if (startStatus === 0) {
353
+ prompts.log.success("Rynx background service is running");
354
+ }
355
+ else {
356
+ failed = true;
357
+ prompts.log.warn("Rynx background service failed to start; run `rynx start` for details");
358
+ }
359
+ return failed;
360
+ }
273
361
  function validateSetupConfig(config) {
274
362
  loadConfig({
275
363
  HOST: String(config.HOST),
@@ -290,14 +378,16 @@ function readRawConfig() {
290
378
  }
291
379
  }
292
380
  function writeConfigAtomically(config) {
293
- const file = rynxConfigFile();
381
+ writeJsonAtomically(rynxConfigFile(), config);
382
+ }
383
+ function writeJsonAtomically(file, value) {
294
384
  const directory = path.dirname(file);
295
385
  mkdirSync(directory, { recursive: true, mode: 0o700 });
296
386
  const temporary = path.join(directory, `.${path.basename(file)}.${process.pid}.tmp`);
297
387
  let descriptor;
298
388
  try {
299
389
  descriptor = openSync(temporary, "wx", 0o600);
300
- writeFileSync(descriptor, `${JSON.stringify(config, null, 2)}\n`, "utf8");
390
+ writeFileSync(descriptor, `${JSON.stringify(value, null, 2)}\n`, "utf8");
301
391
  closeSync(descriptor);
302
392
  descriptor = undefined;
303
393
  renameSync(temporary, file);
@@ -1,8 +1,8 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
+ import { isSkillPathComponent } from "@rynx-ai/core";
4
5
  import { fail } from "./errors.js";
5
- const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
6
6
  const MAX_SKILL_BYTES = 256 * 1024;
7
7
  const BUILTIN_SKILL_NAMES = ["browser", "emulator"];
8
8
  export async function runSkillsCommand(args) {
@@ -55,7 +55,7 @@ export async function listBuiltinSkills() {
55
55
  return guides.filter((guide) => guide !== null);
56
56
  }
57
57
  export async function readBuiltinSkill(name, full = false) {
58
- if (!SKILL_NAME_PATTERN.test(name))
58
+ if (!isSkillPathComponent(name))
59
59
  return null;
60
60
  if (!BUILTIN_SKILL_NAMES.includes(name))
61
61
  return null;
@@ -3,7 +3,7 @@ import { closeSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync
3
3
  import { createRequire } from "node:module";
4
4
  import { dirname, join, sep } from "node:path";
5
5
  import { rynxHome } from "@rynx-ai/core";
6
- import { stopStandaloneDaemon } from "../standalone.js";
6
+ import { runLifecycleCommand } from "./lifecycle.js";
7
7
  function selfPackage() {
8
8
  const require = createRequire(import.meta.url);
9
9
  const manifest = require("../../package.json");
@@ -72,7 +72,7 @@ export async function runUpdate(options) {
72
72
  if (!releaseLock)
73
73
  return 1;
74
74
  try {
75
- if (await stopStandaloneDaemon() !== 0) {
75
+ if (await runLifecycleCommand("stop", []) !== 0) {
76
76
  console.error("update: could not stop the standalone daemon");
77
77
  return 1;
78
78
  }
@@ -2,8 +2,9 @@ import { type DaemonStatus } from "@rynx-ai/protocol/remote-runtime";
2
2
  import { type DaemonCleanupSessionsInput, type DaemonCleanupSessionsResult, type DaemonChromeInspectionConfigureInput, type DaemonChromeInspectionStatus, type DaemonShutdownIfIdleResult } from "@rynx-ai/protocol/control";
3
3
  import { type RemoteRuntimeRpcMethod, type RemoteRuntimeRpcParams, type RemoteRuntimeRpcResultFor } from "@rynx-ai/protocol/remote-runtime-rpc";
4
4
  import { type PairingOffer } from "@rynx-ai/protocol/direct-runtime";
5
- import { type RuntimeBrowserBootstrapCredential, type RuntimeBrowserEndpointDescriptor } from "@rynx-ai/protocol/runtime-browser-bootstrap";
5
+ import { type RuntimeBrowserAutomationCommand, type RuntimeBrowserAutomationResult, type RuntimeBrowserBootstrapCredential, type RuntimeBrowserEndpointDescriptor } from "@rynx-ai/protocol/runtime-browser-bootstrap";
6
6
  import { type PluginInstallCommitInput, type PluginInstallCommitResult, type PluginInstallPreparation, type PluginInstallPrepareInput, type PluginManagementItem, type PluginManagementState, type PluginMarketplaceAddInput, type PluginMarketplaceItem } from "@rynx-ai/protocol/plugin-management";
7
+ import { type DaemonControlEndpoint } from "./control-endpoint.js";
7
8
  export { connectResidentDesktopBrowserHost, type ResidentDesktopBrowserHostConnection, type ResidentDesktopBrowserHostCommandRequest, type ResidentDesktopBrowserHostConnectOptions, type ResidentDesktopBrowserHostFailure, } from "./desktop-browser-host-client.js";
8
9
  export interface ResidentDaemonIdentity {
9
10
  installationId: string;
@@ -65,7 +66,9 @@ export declare function getResidentDaemonRuntimeStatus(): Promise<DaemonStatus>;
65
66
  * when idle. Busy is a normal result so the caller can ask the user to resolve
66
67
  * the listed work and explicitly retry.
67
68
  */
68
- export declare function shutdownResidentDaemonIfIdle(): Promise<DaemonShutdownIfIdleResult>;
69
+ export declare function shutdownResidentDaemonIfIdle(endpoint?: DaemonControlEndpoint): Promise<DaemonShutdownIfIdleResult>;
70
+ /** Wait for accepted graceful shutdown before the lifecycle owner is stopped. */
71
+ export declare function waitForResidentDaemonExit(pid: number, timeoutMs?: number): Promise<boolean>;
69
72
  export declare function getResidentChromeInspectionStatus(): Promise<DaemonChromeInspectionStatus>;
70
73
  export declare function configureResidentChromeInspection(input: DaemonChromeInspectionConfigureInput): Promise<DaemonChromeInspectionStatus>;
71
74
  export declare function cleanupResidentSessions(input?: DaemonCleanupSessionsInput): Promise<DaemonCleanupSessionsResult>;
@@ -111,6 +114,24 @@ export declare function readOptionalManagedRuntimeBrowserCredential(env?: NodeJS
111
114
  export declare function callManagedRuntimeBrowser<M extends RemoteRuntimeRpcMethod>(credential: RuntimeBrowserBootstrapCredential, method: M, params: RemoteRuntimeRpcParams<M>, options?: {
112
115
  signal?: AbortSignal;
113
116
  }): Promise<RemoteRuntimeRpcResultFor<M>>;
117
+ /** Execute native page-command argv within the caller's Session on this Runtime. */
118
+ export declare function executeResidentBrowserCommand(command: RuntimeBrowserAutomationCommand, options?: {
119
+ sessionId?: string;
120
+ credential?: RuntimeBrowserBootstrapCredential;
121
+ }): Promise<RuntimeBrowserAutomationResult>;
122
+ /** Keep the daemon's bounded public diagnostics, including no-replay guidance. */
123
+ export declare class ResidentBrowserCommandError extends Error {
124
+ readonly status: number;
125
+ readonly code: string;
126
+ readonly details: {
127
+ error: string;
128
+ code?: string;
129
+ message?: string;
130
+ hint?: string;
131
+ outcome?: "unknown" | "not_started";
132
+ };
133
+ constructor(status: number, body: string);
134
+ }
114
135
  /** Resolve native CDP for the caller's own Session on this Runtime only. */
115
136
  export declare function getResidentRuntimeLocalBrowserEndpoint(options?: {
116
137
  env?: NodeJS.ProcessEnv;
@@ -4,10 +4,10 @@ import { isDaemonCoreCompatible, parseDaemonStatus, } from "@rynx-ai/protocol/re
4
4
  import { DAEMON_CHROME_INSPECTION_PATH, DAEMON_CLEANUP_SESSIONS_PATH, DAEMON_SHUTDOWN_IF_IDLE_PATH, parseDaemonCleanupSessionsResult, parseDaemonChromeInspectionConfigureInput, parseDaemonChromeInspectionStatus, parseDaemonShutdownIfIdleResult, } from "@rynx-ai/protocol/control";
5
5
  import { parseRemoteRuntimeRpcRequest, parseRemoteRuntimeRpcResponseForMethod, REMOTE_RUNTIME_RPC_MAX_FRAME_BYTES, REMOTE_RUNTIME_RPC_METHOD_METADATA, } from "@rynx-ai/protocol/remote-runtime-rpc";
6
6
  import { parsePairingOffer, } from "@rynx-ai/protocol/direct-runtime";
7
- import { RUNTIME_BROWSER_BOOTSTRAP_PATH, RUNTIME_BROWSER_CAPABILITY_ENV, RUNTIME_BROWSER_CAPABILITY_HEADER, RUNTIME_BROWSER_CONTEXT_FILE_ENV, RUNTIME_BROWSER_MANAGEMENT_PATH_PREFIX, RUNTIME_BROWSER_RPC_PATH, RUNTIME_BROWSER_SESSION_ID_ENV, RUNTIME_BROWSER_SESSION_ID_HEADER, parseRuntimeBrowserBootstrapCredential, parseRuntimeBrowserEndpointDescriptor, } from "@rynx-ai/protocol/runtime-browser-bootstrap";
7
+ import { RUNTIME_BROWSER_BOOTSTRAP_PATH, RUNTIME_BROWSER_CAPABILITY_ENV, RUNTIME_BROWSER_CAPABILITY_HEADER, RUNTIME_BROWSER_CONTEXT_FILE_ENV, RUNTIME_BROWSER_MANAGEMENT_PATH_PREFIX, RUNTIME_BROWSER_RPC_PATH, RUNTIME_BROWSER_EXECUTE_PATH, RUNTIME_BROWSER_SESSION_ID_ENV, RUNTIME_BROWSER_SESSION_ID_HEADER, parseRuntimeBrowserBootstrapCredential, parseRuntimeBrowserAutomationCommand, parseRuntimeBrowserEndpointDescriptor, } from "@rynx-ai/protocol/runtime-browser-bootstrap";
8
8
  import { parseRuntimeBrowserStateGetParams } from "@rynx-ai/protocol/runtime-browser";
9
9
  import { PLUGIN_INSTALL_PREPARATIONS_PATH, PLUGIN_MARKETPLACES_PATH, parsePluginInstallCommitInput, parsePluginInstallCommitResult, parsePluginInstallPreparation, parsePluginInstallPrepareInput, parsePluginManagementItem, parsePluginMarketplaceAddInput, parsePluginMarketplaceItem, pluginInstallCommitPath, pluginInstallPreparationPath, } from "@rynx-ai/protocol/plugin-management";
10
- import { ensureDaemonControlEndpoint } from "./control-endpoint.js";
10
+ import { ensureDaemonControlEndpoint, } from "./control-endpoint.js";
11
11
  export { connectResidentDesktopBrowserHost, } from "./desktop-browser-host-client.js";
12
12
  const IDENTITY_TIMEOUT_MS = 5_000;
13
13
  const RUNTIME_STATUS_TIMEOUT_MS = 5_000;
@@ -108,15 +108,36 @@ export async function getResidentDaemonRuntimeStatus() {
108
108
  * when idle. Busy is a normal result so the caller can ask the user to resolve
109
109
  * the listed work and explicitly retry.
110
110
  */
111
- export async function shutdownResidentDaemonIfIdle() {
112
- const endpoint = await ensureDaemonControlEndpoint();
113
- assertLoopbackOrigin(endpoint.origin);
114
- const response = await fetch(`${endpoint.origin}${DAEMON_SHUTDOWN_IF_IDLE_PATH}`, {
115
- method: "POST",
116
- headers: managementHeaders(endpoint.managementToken),
117
- signal: AbortSignal.timeout(DAEMON_SHUTDOWN_TIMEOUT_MS),
118
- });
119
- const body = await readBoundedResponse(response, DAEMON_SHUTDOWN_RESPONSE_MAX_BYTES);
111
+ export async function shutdownResidentDaemonIfIdle(endpoint) {
112
+ const resident = endpoint ?? await ensureDaemonControlEndpoint();
113
+ assertLoopbackOrigin(resident.origin);
114
+ let response;
115
+ try {
116
+ response = await fetch(`${resident.origin}${DAEMON_SHUTDOWN_IF_IDLE_PATH}`, {
117
+ method: "POST",
118
+ headers: managementHeaders(resident.managementToken),
119
+ signal: AbortSignal.timeout(DAEMON_SHUTDOWN_TIMEOUT_MS),
120
+ });
121
+ }
122
+ catch (error) {
123
+ // The daemon may close its listener before the 202 reaches the client.
124
+ // The original PID exiting proves that the requested lifecycle boundary
125
+ // completed, so the caller can continue with supervisor cleanup.
126
+ if (await waitForResidentDaemonExit(resident.pid)) {
127
+ return { outcome: "accepted" };
128
+ }
129
+ throw error;
130
+ }
131
+ let body;
132
+ try {
133
+ body = await readBoundedResponse(response, DAEMON_SHUTDOWN_RESPONSE_MAX_BYTES);
134
+ }
135
+ catch (error) {
136
+ if (response.status === 202 && await waitForResidentDaemonExit(resident.pid)) {
137
+ return { outcome: "accepted" };
138
+ }
139
+ throw error;
140
+ }
120
141
  if (response.status !== 202 && response.status !== 409) {
121
142
  throw daemonRequestError("daemon shutdown-if-idle request", response.status, body);
122
143
  }
@@ -125,6 +146,9 @@ export async function shutdownResidentDaemonIfIdle() {
125
146
  result = parseDaemonShutdownIfIdleResult(JSON.parse(body));
126
147
  }
127
148
  catch (error) {
149
+ if (response.status === 202 && await waitForResidentDaemonExit(resident.pid)) {
150
+ return { outcome: "accepted" };
151
+ }
128
152
  throw new Error(`daemon returned an invalid shutdown-if-idle response: ${messageOf(error)}`);
129
153
  }
130
154
  if (response.status === 202 && result.outcome !== "accepted" ||
@@ -133,6 +157,25 @@ export async function shutdownResidentDaemonIfIdle() {
133
157
  }
134
158
  return result;
135
159
  }
160
+ /** Wait for accepted graceful shutdown before the lifecycle owner is stopped. */
161
+ export async function waitForResidentDaemonExit(pid, timeoutMs = 120_000) {
162
+ const deadline = Date.now() + timeoutMs;
163
+ while (Date.now() <= deadline) {
164
+ if (!processIsAlive(pid))
165
+ return true;
166
+ await new Promise((resolve) => setTimeout(resolve, 100));
167
+ }
168
+ return !processIsAlive(pid);
169
+ }
170
+ function processIsAlive(pid) {
171
+ try {
172
+ process.kill(pid, 0);
173
+ return true;
174
+ }
175
+ catch (error) {
176
+ return error.code === "EPERM";
177
+ }
178
+ }
136
179
  export async function getResidentChromeInspectionStatus() {
137
180
  const result = await localManagementJsonRequest(DAEMON_CHROME_INSPECTION_PATH, {
138
181
  method: "GET",
@@ -437,6 +480,8 @@ const MANAGED_BROWSER_RPC_METHODS = new Set([
437
480
  "browser.page.back",
438
481
  "browser.page.forward",
439
482
  "browser.page.reload",
483
+ "browser.request-headers.get",
484
+ "browser.request-headers.set",
440
485
  ]);
441
486
  /**
442
487
  * Invoke Browser control for the caller's own managed Session. Unlike the
@@ -521,6 +566,59 @@ export async function callManagedRuntimeBrowser(credential, method, params, opti
521
566
  : "daemon returned an invalid Runtime call result", response.status, { cause: error, ...(neverRetry ? { outcome: "unknown" } : {}) });
522
567
  }
523
568
  }
569
+ /** Execute native page-command argv within the caller's Session on this Runtime. */
570
+ export async function executeResidentBrowserCommand(command, options = {}) {
571
+ const body = parseRuntimeBrowserAutomationCommand(command);
572
+ const credential = options.credential;
573
+ const sessionId = credential?.sessionId ?? options.sessionId;
574
+ if (!sessionId)
575
+ throw new Error("Browser automation requires a Session");
576
+ const signal = AbortSignal.timeout(180_000);
577
+ const endpoint = await ensureDaemonControlEndpoint({ signal });
578
+ assertLoopbackOrigin(endpoint.origin);
579
+ const route = credential ? RUNTIME_BROWSER_EXECUTE_PATH : `${RUNTIME_BROWSER_MANAGEMENT_PATH_PREFIX}/${encodeURIComponent(sessionId)}/execute`;
580
+ const response = await fetch(`${endpoint.origin}${route}`, {
581
+ method: "POST", signal,
582
+ headers: {
583
+ "content-type": "application/json",
584
+ ...(credential ? { [RUNTIME_BROWSER_SESSION_ID_HEADER]: credential.sessionId, [RUNTIME_BROWSER_CAPABILITY_HEADER]: credential.capability } : managementHeaders(endpoint.managementToken)),
585
+ },
586
+ body: JSON.stringify(body),
587
+ });
588
+ const raw = await readBoundedResponse(response, 8 * 1024 * 1024);
589
+ if (!response.ok)
590
+ throw new ResidentBrowserCommandError(response.status, raw);
591
+ const result = JSON.parse(raw);
592
+ if (!result || result.schemaVersion !== 2 || result.sessionId !== sessionId || typeof result.pageId !== "string" ||
593
+ !Number.isSafeInteger(result.browserGeneration) || result.browserGeneration < 1 || result.completed !== true ||
594
+ !result.data || typeof result.data !== "object" || Array.isArray(result.data))
595
+ throw new Error("daemon returned an invalid Browser execution result");
596
+ return result;
597
+ }
598
+ /** Keep the daemon's bounded public diagnostics, including no-replay guidance. */
599
+ export class ResidentBrowserCommandError extends Error {
600
+ status;
601
+ code;
602
+ details;
603
+ constructor(status, body) {
604
+ let parsed;
605
+ try {
606
+ parsed = parseJsonObject(body, "invalid Browser error");
607
+ }
608
+ catch { /* Plain-text errors remain useful. */ }
609
+ const bounded = (value, max = 2048) => typeof value === "string" && value.length > 0 ? value.slice(0, max) : undefined;
610
+ const error = bounded(parsed?.error, 128) ?? "browser_execution_failed";
611
+ const code = bounded(parsed?.code, 128);
612
+ const message = bounded(parsed?.message) ?? (parsed === undefined ? bounded(body.trim()) : undefined);
613
+ const hint = bounded(parsed?.hint);
614
+ super(`Runtime Browser execution failed (${status}): ${error}${message ? `: ${message}` : ""}${hint ? `\n${hint}` : ""}`);
615
+ this.status = status;
616
+ this.name = "ResidentBrowserCommandError";
617
+ this.code = code ?? error;
618
+ this.details = { error, ...(code ? { code } : {}), ...(message ? { message } : {}), ...(hint ? { hint } : {}),
619
+ ...(parsed?.outcome === "unknown" || parsed?.outcome === "not_started" ? { outcome: parsed.outcome } : {}) };
620
+ }
621
+ }
524
622
  /** Resolve native CDP for the caller's own Session on this Runtime only. */
525
623
  export async function getResidentRuntimeLocalBrowserEndpoint(options = {}) {
526
624
  return (await getResidentRuntimeLocalBrowserAutomationAccess(options)).descriptor;
@@ -1,14 +1,36 @@
1
- import { DESKTOP_BROWSER_HOST_MAX_BINARY_FRAME_BYTES, DESKTOP_BROWSER_HOST_MAX_CONTROL_FRAME_BYTES, DESKTOP_BROWSER_HOST_MAX_PENDING_COMMANDS, DESKTOP_BROWSER_HOST_PATH, DESKTOP_BROWSER_HOST_PROTOCOL_VERSION, encodeDesktopBrowserHostSurfaceFrame, parseDesktopBrowserHostClientFrame, parseDesktopBrowserHostServerFrame, } from "@rynx-ai/protocol/desktop-browser-host";
1
+ import { DESKTOP_BROWSER_HOST_MAX_BINARY_FRAME_BYTES, DESKTOP_BROWSER_HOST_MAX_CONTROL_FRAME_BYTES, DESKTOP_BROWSER_HOST_MAX_PENDING_COMMANDS, DESKTOP_BROWSER_HOST_MINIMUM_PROTOCOL_VERSION, DESKTOP_BROWSER_HOST_PATH, DESKTOP_BROWSER_HOST_PROTOCOL_VERSION, encodeDesktopBrowserHostSurfaceFrame, parseDesktopBrowserHostClientFrame, parseDesktopBrowserHostServerFrame, } from "@rynx-ai/protocol/desktop-browser-host";
2
2
  import { WebSocket } from "ws";
3
3
  import { ensureDaemonControlEndpoint } from "./control-endpoint.js";
4
4
  const CONNECT_TIMEOUT_MS = 5_000;
5
5
  const MAX_BUFFERED_BYTES = DESKTOP_BROWSER_HOST_MAX_BINARY_FRAME_BYTES + DESKTOP_BROWSER_HOST_MAX_CONTROL_FRAME_BYTES;
6
6
  /** Open the sole resident daemon's authenticated loopback Desktop Host lease. */
7
7
  export async function connectResidentDesktopBrowserHost(options) {
8
+ options.signal?.throwIfAborted();
9
+ const endpoint = options.endpoint
10
+ ?? await ensureDaemonControlEndpoint({ signal: options.signal });
11
+ assertDesktopBrowserHostEndpoint(endpoint);
12
+ try {
13
+ return await connectResidentDesktopBrowserHostVersion(options, endpoint, DESKTOP_BROWSER_HOST_PROTOCOL_VERSION);
14
+ }
15
+ catch (error) {
16
+ options.signal?.throwIfAborted();
17
+ if (!isLegacyProtocolRejection(error))
18
+ throw error;
19
+ // V1 daemons reject a V2 hello before issuing a lease. Reconnect once
20
+ // with their protocol so App and daemon can roll independently. Any
21
+ // failure after a lease is returned stays on that negotiated connection.
22
+ return connectResidentDesktopBrowserHostVersion(options, endpoint, DESKTOP_BROWSER_HOST_MINIMUM_PROTOCOL_VERSION).catch((legacyError) => {
23
+ throw new Error("Desktop Browser Host could not negotiate with the daemon", {
24
+ cause: new AggregateError([error, legacyError]),
25
+ });
26
+ });
27
+ }
28
+ }
29
+ async function connectResidentDesktopBrowserHostVersion(options, endpoint, protocolVersion) {
8
30
  options.signal?.throwIfAborted();
9
31
  const hello = parseDesktopBrowserHostClientFrame({
10
32
  type: "desktop.browser.host.hello",
11
- protocolVersion: DESKTOP_BROWSER_HOST_PROTOCOL_VERSION,
33
+ protocolVersion,
12
34
  hostInstanceId: options.hostInstanceId,
13
35
  capabilities: {
14
36
  semanticPageBinding: options.capabilities.semanticPageBinding,
@@ -19,9 +41,6 @@ export async function connectResidentDesktopBrowserHost(options) {
19
41
  if (hello.type !== "desktop.browser.host.hello") {
20
42
  throw new Error("Desktop Browser Host hello parser returned the wrong frame");
21
43
  }
22
- const endpoint = options.endpoint
23
- ?? await ensureDaemonControlEndpoint({ signal: options.signal });
24
- assertDesktopBrowserHostEndpoint(endpoint);
25
44
  const url = new URL(DESKTOP_BROWSER_HOST_PATH, `${endpoint.origin}/`);
26
45
  url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
27
46
  const socket = new WebSocket(url, {
@@ -139,7 +158,7 @@ export async function connectResidentDesktopBrowserHost(options) {
139
158
  socket.once("close", (code, reason) => {
140
159
  options.signal?.removeEventListener("abort", onAbort);
141
160
  if (!terminalError && !closing) {
142
- terminalError = new Error(`Desktop Browser Host lease closed (${code})${reason.length > 0 ? `: ${reason.toString("utf8")}` : ""}`);
161
+ terminalError = new DesktopBrowserHostLeaseClosedError(code, `Desktop Browser Host lease closed (${code})${reason.length > 0 ? `: ${reason.toString("utf8")}` : ""}`);
143
162
  }
144
163
  if (!lease && terminalError)
145
164
  leaseReject(terminalError);
@@ -224,7 +243,7 @@ export async function connectResidentDesktopBrowserHost(options) {
224
243
  signal: controller.signal,
225
244
  reply: (result) => settle(command, {
226
245
  type: "desktop.browser.host.reply",
227
- protocolVersion: DESKTOP_BROWSER_HOST_PROTOCOL_VERSION,
246
+ protocolVersion: activeLease.protocolVersion,
228
247
  leaseId: activeLease.leaseId,
229
248
  commandId: command.commandId,
230
249
  ok: true,
@@ -232,7 +251,7 @@ export async function connectResidentDesktopBrowserHost(options) {
232
251
  }),
233
252
  reject: (failure) => settle(command, {
234
253
  type: "desktop.browser.host.reply",
235
- protocolVersion: DESKTOP_BROWSER_HOST_PROTOCOL_VERSION,
254
+ protocolVersion: activeLease.protocolVersion,
236
255
  leaseId: activeLease.leaseId,
237
256
  commandId: command.commandId,
238
257
  ok: false,
@@ -250,7 +269,7 @@ export async function connectResidentDesktopBrowserHost(options) {
250
269
  const nextSequence = eventSequence + 1;
251
270
  const frame = parseDesktopBrowserHostClientFrame({
252
271
  type: "desktop.browser.host.event",
253
- protocolVersion: DESKTOP_BROWSER_HOST_PROTOCOL_VERSION,
272
+ protocolVersion: activeLease.protocolVersion,
254
273
  leaseId: activeLease.leaseId,
255
274
  eventSequence: nextSequence,
256
275
  event,
@@ -268,7 +287,7 @@ export async function connectResidentDesktopBrowserHost(options) {
268
287
  const encoded = encodeDesktopBrowserHostSurfaceFrame({
269
288
  ...frame,
270
289
  leaseId: activeLease.leaseId,
271
- });
290
+ }, activeLease.protocolVersion);
272
291
  await sendBinary(socket, encoded);
273
292
  }),
274
293
  close: async () => {
@@ -285,6 +304,20 @@ export async function connectResidentDesktopBrowserHost(options) {
285
304
  },
286
305
  };
287
306
  }
307
+ class DesktopBrowserHostLeaseClosedError extends Error {
308
+ closeCode;
309
+ constructor(closeCode, message) {
310
+ super(message);
311
+ this.closeCode = closeCode;
312
+ this.name = "DesktopBrowserHostLeaseClosedError";
313
+ }
314
+ }
315
+ function isLegacyProtocolRejection(error) {
316
+ return error instanceof DesktopBrowserHostLeaseClosedError &&
317
+ // 4400 is the pre-V2 daemon's generic invalid-frame response. 1002 covers
318
+ // standards-based V1 peers; 4406 is the explicit unsupported-version code.
319
+ (error.closeCode === 1002 || error.closeCode === 4400 || error.closeCode === 4406);
320
+ }
288
321
  async function sendBinary(socket, bytes) {
289
322
  if (socket.readyState !== WebSocket.OPEN)
290
323
  throw new Error("Desktop Browser Host lease is closed");
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Remove the retired cross-process replacement fence.
3
+ *
4
+ * Older daemons may have left this file behind after an updater crash. The
5
+ * standalone stop path makes a best-effort attempt once lifecycle ownership is
6
+ * gone. This compatibility detail is not part of the public lifecycle result.
7
+ */
8
+ export declare function clearLegacyMaintenanceState(): void;
@@ -0,0 +1,13 @@
1
+ import { rmSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { rynxHome } from "@rynx-ai/core";
4
+ /**
5
+ * Remove the retired cross-process replacement fence.
6
+ *
7
+ * Older daemons may have left this file behind after an updater crash. The
8
+ * standalone stop path makes a best-effort attempt once lifecycle ownership is
9
+ * gone. This compatibility detail is not part of the public lifecycle result.
10
+ */
11
+ export function clearLegacyMaintenanceState() {
12
+ rmSync(join(rynxHome(), "maintenance-lease.json"), { force: true });
13
+ }
@@ -0,0 +1,7 @@
1
+ export interface ProgressDisplay {
2
+ start(message: string): void;
3
+ update(message: string): void;
4
+ succeed(message: string): void;
5
+ clear(): void;
6
+ }
7
+ export declare function createProgressDisplay(): ProgressDisplay;