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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/bundled-plugins/plugins/lark/.rynx-plugin/plugin.json +0 -12
  2. package/bundled-plugins/plugins/lark/dist/cli-mirror/mirror-index.js +3 -1
  3. package/bundled-plugins/plugins/lark/dist/host-adapters.d.ts +3 -3
  4. package/bundled-plugins/plugins/lark/dist/host-adapters.js +20 -18
  5. package/bundled-plugins/plugins/lark/dist/lark.js +18 -12
  6. package/bundled-plugins/plugins/lark/dist/runtime.js +6046 -6022
  7. package/bundled-plugins/plugins/lark/dist/settings.js +6006 -5988
  8. package/bundled-plugins/plugins/lark/package.json +1 -1
  9. package/bundled-plugins/plugins/lark/ui/assets/index-CGUVAMsv.css +1 -0
  10. package/bundled-plugins/plugins/lark/ui/index.html +2 -2
  11. package/dist/agent-file.js +0 -1
  12. package/dist/browser-artifact-management.d.ts +4 -1
  13. package/dist/browser-artifact-management.js +22 -11
  14. package/dist/browser-artifacts.d.ts +1 -0
  15. package/dist/browser-artifacts.js +1 -0
  16. package/dist/chrome-for-testing-runtime.d.ts +27 -0
  17. package/dist/chrome-for-testing-runtime.js +194 -0
  18. package/dist/chrome-for-testing-store.d.ts +2 -0
  19. package/dist/chrome-for-testing-store.js +37 -6
  20. package/dist/control-deps.d.ts +6 -2
  21. package/dist/control-deps.js +87 -4
  22. package/dist/control-link-store.js +4 -3
  23. package/dist/daemon-owner.js +4 -7
  24. package/dist/daemon-server.d.ts +4 -1
  25. package/dist/daemon-server.js +158 -45
  26. package/dist/db.d.ts +2 -2
  27. package/dist/db.js +55 -20
  28. package/dist/entry-path.d.ts +3 -0
  29. package/dist/entry-path.js +12 -0
  30. package/dist/index-daemon.js +3 -87
  31. package/dist/lifecycle.d.ts +13 -0
  32. package/dist/lifecycle.js +19 -0
  33. package/dist/maintenance-lease.d.ts +39 -0
  34. package/dist/maintenance-lease.js +250 -0
  35. package/dist/migrations/cleanup-legacy-sessions.js +4 -3
  36. package/dist/plugin-cli.d.ts +10 -0
  37. package/dist/plugin-cli.js +19 -2
  38. package/dist/plugin-host-rpc.d.ts +35 -6
  39. package/dist/plugin-host-rpc.js +222 -59
  40. package/dist/plugin-installer.js +62 -2
  41. package/dist/plugin-management-service.js +0 -2
  42. package/dist/plugin-marketplace.js +2 -1
  43. package/dist/plugin-store.js +2 -4
  44. package/dist/plugin-supervisor.d.ts +5 -0
  45. package/dist/plugin-supervisor.js +218 -33
  46. package/dist/pm2.d.ts +11 -2
  47. package/dist/pm2.js +116 -19
  48. package/dist/process.d.ts +12 -0
  49. package/dist/process.js +91 -0
  50. package/dist/remote-runtime-access-store.d.ts +2 -2
  51. package/dist/remote-runtime-access-store.js +6 -9
  52. package/dist/remote-runtime-target-store.d.ts +2 -2
  53. package/dist/remote-runtime-target-store.js +6 -6
  54. package/dist/session-emulator-binding-store.js +4 -6
  55. package/dist/session-fork-store.js +24 -8
  56. package/dist/session-launch-operations.d.ts +10 -1
  57. package/dist/session-launch-operations.js +21 -11
  58. package/dist/session-log-store.js +2 -1
  59. package/dist/session-meta-store.d.ts +5 -0
  60. package/dist/session-meta-store.js +25 -2
  61. package/dist/session-portal-grant-store.d.ts +29 -0
  62. package/dist/session-portal-grant-store.js +85 -0
  63. package/dist/session-resource-store.js +3 -2
  64. package/dist/setup-service.d.ts +21 -0
  65. package/dist/setup-service.js +39 -0
  66. package/dist/skills-catalog.d.ts +11 -2
  67. package/dist/skills-catalog.js +307 -10
  68. package/dist/sqlite.d.ts +22 -0
  69. package/dist/sqlite.js +57 -0
  70. package/dist/system-dependencies.d.ts +22 -0
  71. package/dist/system-dependencies.js +79 -0
  72. package/package.json +37 -21
  73. package/bundled-plugins/plugins/lark/ui/assets/index-B2dhMlMU.css +0 -1
  74. package/dist/browser-cli-args.d.ts +0 -20
  75. package/dist/browser-cli-args.js +0 -100
  76. package/dist/cli.d.ts +0 -2
  77. package/dist/cli.js +0 -550
  78. package/dist/control-client.d.ts +0 -122
  79. package/dist/control-client.js +0 -855
  80. package/dist/control-endpoint.d.ts +0 -14
  81. package/dist/control-endpoint.js +0 -124
  82. package/dist/desktop-browser-host-client.d.ts +0 -35
  83. package/dist/desktop-browser-host-client.js +0 -415
  84. package/dist/setup.d.ts +0 -20
  85. package/dist/setup.js +0 -386
  86. package/dist/update.d.ts +0 -25
  87. package/dist/update.js +0 -133
  88. /package/bundled-plugins/plugins/lark/ui/assets/{index-D9N-AkLX.js → index-D8Amoe6a.js} +0 -0
package/dist/db.js CHANGED
@@ -1,9 +1,11 @@
1
1
  /**
2
2
  * `~/.rynx/rynx.db` — the daemon's SQLite store. Concerns kept apart:
3
- * - `plugin_installations` — source-qualified manifests, capabilities, and immutable packages.
3
+ * - `plugin_installations` — source-qualified manifests and immutable packages.
4
4
  * - `plugin_marketplaces` — catalog registrations; catalog data is never executable.
5
5
  * - `plugin_session_launch_operations` — durable idempotency/tombstones.
6
6
  * - `control_links` — generic, one-use links to the local session console.
7
+ * - `session_portal_tickets` / `session_portal_grants` — hashed, scoped,
8
+ * browser Session Portal authorization.
7
9
  * - `runtime_pairing_claims` / `runtime_client_grants` — one-use enrollment
8
10
  * claims and the durable client identities they issue for direct runtimes.
9
11
  * - `runtime_targets` / `runtime_direct_routes` / `runtime_pending_enrollments`
@@ -16,8 +18,9 @@
16
18
  */
17
19
  import { chmodSync, mkdirSync } from "node:fs";
18
20
  import { dirname, isAbsolute, join, resolve } from "node:path";
21
+ import { DatabaseSync } from "node:sqlite";
19
22
  import { rynxHome } from "@rynx-ai/core";
20
- import Database from "better-sqlite3";
23
+ import { transaction } from "./sqlite.js";
21
24
  const connections = new Map();
22
25
  /** Absolute path to the SQLite file (override via `RYNX_DB`, else `~/.rynx/rynx.db`). */
23
26
  export function dbPath() {
@@ -34,9 +37,7 @@ export function db() {
34
37
  if (cached)
35
38
  return cached;
36
39
  mkdirSync(dirname(path), { recursive: true });
37
- const conn = new Database(path);
38
- conn.pragma("busy_timeout = 5000");
39
- conn.pragma("foreign_keys = ON");
40
+ const conn = new DatabaseSync(path, { timeout: 5000, enableForeignKeyConstraints: true });
40
41
  migrate(conn);
41
42
  // Plugin state and daemon credentials are private.
42
43
  try {
@@ -86,7 +87,6 @@ function migrate(conn) {
86
87
  install_path TEXT NOT NULL,
87
88
  manifest TEXT NOT NULL,
88
89
  integrity TEXT NOT NULL,
89
- capabilities TEXT NOT NULL,
90
90
  runtime_state TEXT,
91
91
  runtime_error TEXT,
92
92
  UNIQUE(marketplace_id, plugin_id)
@@ -195,6 +195,7 @@ function migrate(conn) {
195
195
  scope_id TEXT NOT NULL,
196
196
  operation_id TEXT NOT NULL,
197
197
  request_hash TEXT NOT NULL,
198
+ raw_request_hash TEXT,
198
199
  session_id TEXT NOT NULL,
199
200
  state TEXT NOT NULL DEFAULT 'active' CHECK(state IN ('active', 'deleted')),
200
201
  created_at TEXT NOT NULL,
@@ -244,6 +245,39 @@ function migrate(conn) {
244
245
  );
245
246
  CREATE INDEX IF NOT EXISTS idx_control_links_expires
246
247
  ON control_links(expires_at);
248
+ CREATE TABLE IF NOT EXISTS session_portal_tickets (
249
+ ticket_hash TEXT PRIMARY KEY,
250
+ plugin_id TEXT NOT NULL,
251
+ scope TEXT NOT NULL CHECK(scope IN ('plugin_sessions', 'single_session')),
252
+ access TEXT NOT NULL CHECK(access IN ('read_only', 'read_write')),
253
+ session_id TEXT,
254
+ created_at TEXT NOT NULL,
255
+ expires_at TEXT NOT NULL,
256
+ used_at TEXT,
257
+ CHECK(
258
+ (scope = 'plugin_sessions' AND session_id IS NULL)
259
+ OR (scope = 'single_session' AND session_id IS NOT NULL)
260
+ )
261
+ );
262
+ CREATE INDEX IF NOT EXISTS idx_session_portal_tickets_expires
263
+ ON session_portal_tickets(expires_at);
264
+ CREATE TABLE IF NOT EXISTS session_portal_grants (
265
+ portal_id TEXT PRIMARY KEY,
266
+ secret_hash TEXT NOT NULL,
267
+ plugin_id TEXT NOT NULL,
268
+ scope TEXT NOT NULL CHECK(scope IN ('plugin_sessions', 'single_session')),
269
+ access TEXT NOT NULL CHECK(access IN ('read_only', 'read_write')),
270
+ session_id TEXT,
271
+ created_at TEXT NOT NULL,
272
+ expires_at TEXT NOT NULL,
273
+ revoked_at TEXT,
274
+ CHECK(
275
+ (scope = 'plugin_sessions' AND session_id IS NULL)
276
+ OR (scope = 'single_session' AND session_id IS NOT NULL)
277
+ )
278
+ );
279
+ CREATE INDEX IF NOT EXISTS idx_session_portal_grants_expires
280
+ ON session_portal_grants(expires_at);
247
281
  CREATE TABLE IF NOT EXISTS runtime_pairing_claims (
248
282
  claim_id TEXT PRIMARY KEY,
249
283
  secret_hash TEXT NOT NULL,
@@ -307,7 +341,7 @@ function migrate(conn) {
307
341
  CREATE INDEX IF NOT EXISTS idx_runtime_pending_enrollments_expires
308
342
  ON runtime_pending_enrollments(expires_at);
309
343
  `);
310
- migratePluginCapabilities(conn);
344
+ migratePluginCapabilityDeclarations(conn);
311
345
  migrateSessionSnapshots(conn);
312
346
  // Breaking plugin-runtime cutover: remove obsolete core-owned state.
313
347
  conn.exec(`
@@ -316,6 +350,19 @@ function migrate(conn) {
316
350
  `);
317
351
  addColumnIfMissing(conn, "plugin_marketplaces", "built_in", "INTEGER NOT NULL DEFAULT 0");
318
352
  addColumnIfMissing(conn, "sessions", "forked_from_session_id", "TEXT");
353
+ addColumnIfMissing(conn, "plugin_session_launch_operations", "raw_request_hash", "TEXT");
354
+ }
355
+ /** Remove the inert permission-declaration columns left by older installations. */
356
+ function migratePluginCapabilityDeclarations(conn) {
357
+ const columns = conn
358
+ .prepare("PRAGMA table_info(plugin_installations)")
359
+ .all();
360
+ const names = new Set(columns.map((column) => column.name));
361
+ for (const legacyColumn of ["capabilities", "grants"]) {
362
+ if (names.has(legacyColumn)) {
363
+ conn.exec(`ALTER TABLE plugin_installations DROP COLUMN ${legacyColumn}`);
364
+ }
365
+ }
319
366
  }
320
367
  /**
321
368
  * Breaking Session cutover: the previous rows contain mutable Agent/Provider
@@ -328,7 +375,7 @@ function migrateSessionSnapshots(conn) {
328
375
  const names = new Set(columns.map((column) => column.name));
329
376
  if (names.has("workspace") && names.has("execution"))
330
377
  return;
331
- conn.transaction(() => {
378
+ transaction(conn, () => {
332
379
  conn.exec(`
333
380
  INSERT OR IGNORE INTO session_snapshot_cleanup (session_id)
334
381
  SELECT id FROM sessions;
@@ -356,18 +403,6 @@ function migrateSessionSnapshots(conn) {
356
403
  `);
357
404
  }).immediate();
358
405
  }
359
- function migratePluginCapabilities(conn) {
360
- const columns = conn
361
- .prepare("PRAGMA table_info(plugin_installations)")
362
- .all();
363
- if (columns.some((column) => column.name === "capabilities"))
364
- return;
365
- if (columns.some((column) => column.name === "grants")) {
366
- conn.exec("ALTER TABLE plugin_installations RENAME COLUMN grants TO capabilities");
367
- return;
368
- }
369
- conn.exec("ALTER TABLE plugin_installations ADD COLUMN capabilities TEXT NOT NULL DEFAULT '[]'");
370
- }
371
406
  /** Idempotently add a column to an existing table (no-op if already present). */
372
407
  function addColumnIfMissing(conn, table, column, ddl) {
373
408
  const cols = conn.prepare(`PRAGMA table_info(${table})`).all();
@@ -0,0 +1,3 @@
1
+ /** Resolve the executable daemon module without importing its side-effectful entry. */
2
+ export declare function resolveDaemonEntry(moduleUrl?: string): string;
3
+ export declare function installedDaemonVersion(): string;
@@ -0,0 +1,12 @@
1
+ import { createRequire } from "node:module";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ const require = createRequire(import.meta.url);
5
+ /** Resolve the executable daemon module without importing its side-effectful entry. */
6
+ export function resolveDaemonEntry(moduleUrl = import.meta.url) {
7
+ return join(dirname(fileURLToPath(moduleUrl)), "index-daemon.js");
8
+ }
9
+ export function installedDaemonVersion() {
10
+ const manifest = require("../package.json");
11
+ return typeof manifest.version === "string" ? manifest.version : "0.0.0";
12
+ }
@@ -1,91 +1,7 @@
1
1
  #!/usr/bin/env node
2
- /**
3
- * The resident daemon entry — this is what pm2 keeps alive.
4
- *
5
- * Boot order matters: install the EPIPE guard first (we're a pm2 child).
6
- * The composition root reads static plugin manifests and supervises one runtime
7
- * process for each enabled runtime plugin. Installed plugin code is loaded only
8
- * in child processes; plugin-owned channel instances stay inside that process.
9
- */
10
- import { startRynxDaemonServer } from "./daemon-server.js";
11
- import { ensureBundledOfficialPlugins } from "./bundled-plugins.js";
12
- import { installStdioEpipeGuard } from "./stdio-epipe-guard.js";
13
- installStdioEpipeGuard();
14
- async function main() {
15
- const followsApp = process.env.RYNX_DAEMON_LIFECYCLE === "follow-app";
16
- let parentDisconnected = followsApp && !process.connected;
17
- const recordParentDisconnect = () => {
18
- parentDisconnected = true;
19
- };
20
- if (followsApp)
21
- process.once("disconnect", recordParentDisconnect);
22
- const preparePlugins = async () => {
23
- try {
24
- const bundled = await ensureBundledOfficialPlugins({ reclaimHostLeases: true });
25
- if (bundled.status === "ready") {
26
- console.log(JSON.stringify({
27
- level: "info",
28
- type: "bundled_plugins_ready",
29
- installed: bundled.installed,
30
- updated: bundled.updated,
31
- }));
32
- }
33
- }
34
- catch (error) {
35
- console.error(JSON.stringify({
36
- level: "error",
37
- type: "bundled_plugins_failed",
38
- error: error instanceof Error ? error.message : String(error),
39
- }));
40
- }
41
- };
42
- let server;
43
- try {
44
- server = await startRynxDaemonServer({ preparePlugins });
45
- }
46
- catch (error) {
47
- process.off("disconnect", recordParentDisconnect);
48
- throw error;
49
- }
50
- let shutdownPromise;
51
- const shutdown = (signal) => {
52
- if (shutdownPromise)
53
- return shutdownPromise;
54
- process.off("SIGINT", onSigint);
55
- process.off("SIGTERM", onSigterm);
56
- process.off("disconnect", onDisconnect);
57
- shutdownPromise = (async () => {
58
- console.log(JSON.stringify({ level: "info", type: "shutdown", signal }));
59
- await server.shutdown();
60
- })();
61
- shutdownPromise.catch((error) => {
62
- process.exitCode = 1;
63
- console.error(`Shutdown failed: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`);
64
- });
65
- return shutdownPromise;
66
- };
67
- const onSigint = () => {
68
- void shutdown("SIGINT");
69
- };
70
- const onSigterm = () => {
71
- void shutdown("SIGTERM");
72
- };
73
- const onDisconnect = () => {
74
- if (process.env.RYNX_DAEMON_LIFECYCLE === "follow-app") {
75
- void shutdown("APP_DISCONNECT");
76
- }
77
- };
78
- process.once("SIGINT", onSigint);
79
- process.once("SIGTERM", onSigterm);
80
- if (followsApp) {
81
- process.once("disconnect", onDisconnect);
82
- process.off("disconnect", recordParentDisconnect);
83
- if (parentDisconnected || !process.connected) {
84
- void shutdown("APP_DISCONNECT");
85
- }
86
- }
87
- }
88
- main().catch((err) => {
2
+ /** Direct daemon executable retained only for standalone PM2 supervision. */
3
+ import { runDaemonProcess } from "./process.js";
4
+ runDaemonProcess().catch((err) => {
89
5
  console.error(`Fatal error: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);
90
6
  process.exit(1);
91
7
  });
@@ -0,0 +1,13 @@
1
+ export { formatControlUrl, isPm2DaemonOnline, statusDaemon, stopDaemon, streamLogs, } from "./pm2.js";
2
+ export { resolveDaemonEntry } from "./entry-path.js";
3
+ export interface StandaloneDaemonLaunchContext {
4
+ builtinSkillsDir: string;
5
+ cliVersion: string;
6
+ productVersion: string;
7
+ tmuxBin?: string;
8
+ }
9
+ export declare function startDaemon(options: {
10
+ force: boolean;
11
+ quiet?: boolean;
12
+ context: StandaloneDaemonLaunchContext;
13
+ }): number;
@@ -0,0 +1,19 @@
1
+ import { startDaemon as startPm2Daemon, } from "./pm2.js";
2
+ export { formatControlUrl, isPm2DaemonOnline, statusDaemon, stopDaemon, streamLogs, } from "./pm2.js";
3
+ export { resolveDaemonEntry } from "./entry-path.js";
4
+ export function startDaemon(options) {
5
+ return startPm2Daemon({
6
+ force: options.force,
7
+ quiet: options.quiet,
8
+ launchEnvironment: {
9
+ RYNX_BUILTIN_SKILLS_DIR: options.context.builtinSkillsDir,
10
+ RYNX_CLI_VERSION: options.context.cliVersion,
11
+ RYNX_DISTRIBUTION: "standalone",
12
+ RYNX_PRODUCT_VERSION: options.context.productVersion,
13
+ RYNX_DAEMON_LIFECYCLE: "standalone",
14
+ // An empty value explicitly disables PATH fallback when the optional
15
+ // platform package is missing, while keeping the control plane runnable.
16
+ RYNX_TMUX_BIN: options.context.tmuxBin ?? "",
17
+ },
18
+ });
19
+ }
@@ -0,0 +1,39 @@
1
+ export type MaintenanceLeaseReleaseOutcome = "released" | "not_active" | "denied";
2
+ export interface MaintenanceLeaseStoreOptions {
3
+ filePath?: string;
4
+ now?: () => number;
5
+ ttlMs?: number;
6
+ /** Test seam; production fsyncs the lease directory after rename/remove. */
7
+ syncDirectory?: (directory: string) => void;
8
+ /** Process-local recovery hook, invoked once when a live lease is observed expired. */
9
+ onExpired?: () => void;
10
+ }
11
+ export declare function maintenanceLeaseFile(env?: NodeJS.ProcessEnv): string;
12
+ /**
13
+ * Cross-process admission fence used only during daemon replacement.
14
+ *
15
+ * The raw bearer token is never persisted. The bounded expiry means a crashed
16
+ * upgrader cannot leave the daemon permanently unavailable, while atomic rename
17
+ * ensures a replacement daemon sees either the complete lease or no lease.
18
+ */
19
+ export declare class MaintenanceLeaseStore {
20
+ private readonly filePath;
21
+ private readonly now;
22
+ private readonly ttlMs;
23
+ private readonly syncDirectory;
24
+ private readonly onExpired;
25
+ private lease;
26
+ private expiryTimer;
27
+ constructor(options?: MaintenanceLeaseStoreOptions);
28
+ isActive(): boolean;
29
+ isHeldBy(token: string): boolean;
30
+ acquire(token: string): boolean;
31
+ release(token: string): MaintenanceLeaseReleaseOutcome;
32
+ private readPersistedLease;
33
+ private writePersistedLease;
34
+ private clear;
35
+ private clearFile;
36
+ private expire;
37
+ private scheduleExpiry;
38
+ private cancelExpiry;
39
+ }
@@ -0,0 +1,250 @@
1
+ import { createHash, timingSafeEqual } from "node:crypto";
2
+ import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { rynxHome } from "@rynx-ai/core";
5
+ const LEASE_SCHEMA_VERSION = 1;
6
+ // The detached updater can spend 24h waiting for idle before cutover begins.
7
+ // Once accepted, retain a separate bounded recovery window long enough for the
8
+ // package switch and rollback even if the updater is killed at its outer limit.
9
+ const DEFAULT_LEASE_TTL_MS = 30 * 60 * 60 * 1_000;
10
+ const EXPIRY_RETRY_MS = 1_000;
11
+ const MAX_TIMER_DELAY_MS = 2_147_483_647;
12
+ const LEASE_TOKEN_PATTERN = /^[A-Za-z0-9_-]{32,256}$/;
13
+ export function maintenanceLeaseFile(env = process.env) {
14
+ return join(rynxHome(env), "maintenance-lease.json");
15
+ }
16
+ /**
17
+ * Cross-process admission fence used only during daemon replacement.
18
+ *
19
+ * The raw bearer token is never persisted. The bounded expiry means a crashed
20
+ * upgrader cannot leave the daemon permanently unavailable, while atomic rename
21
+ * ensures a replacement daemon sees either the complete lease or no lease.
22
+ */
23
+ export class MaintenanceLeaseStore {
24
+ filePath;
25
+ now;
26
+ ttlMs;
27
+ syncDirectory;
28
+ onExpired;
29
+ lease;
30
+ expiryTimer;
31
+ constructor(options = {}) {
32
+ this.filePath = options.filePath ?? maintenanceLeaseFile();
33
+ this.now = options.now ?? (() => Date.now());
34
+ this.ttlMs = options.ttlMs ?? DEFAULT_LEASE_TTL_MS;
35
+ this.syncDirectory = options.syncDirectory ?? fsyncDirectory;
36
+ this.onExpired = options.onExpired ?? (() => undefined);
37
+ if (!Number.isSafeInteger(this.ttlMs) || this.ttlMs <= 0) {
38
+ throw new Error("maintenance lease TTL must be a positive safe integer");
39
+ }
40
+ this.lease = this.readPersistedLease();
41
+ this.scheduleExpiry();
42
+ }
43
+ isActive() {
44
+ if (!this.lease)
45
+ return false;
46
+ if (this.lease.expiresAt > this.now())
47
+ return true;
48
+ this.expire();
49
+ return false;
50
+ }
51
+ isHeldBy(token) {
52
+ let tokenHash;
53
+ try {
54
+ tokenHash = hashLeaseToken(token);
55
+ }
56
+ catch {
57
+ return false;
58
+ }
59
+ return Boolean(this.isActive()
60
+ && this.lease
61
+ && hashesEqual(this.lease.tokenHash, tokenHash));
62
+ }
63
+ acquire(token) {
64
+ const tokenHash = hashLeaseToken(token);
65
+ if (this.isActive()) {
66
+ if (this.lease && hashesEqual(this.lease.tokenHash, tokenHash)) {
67
+ const createdAt = this.now();
68
+ const renewed = {
69
+ schemaVersion: LEASE_SCHEMA_VERSION,
70
+ tokenHash,
71
+ createdAt,
72
+ expiresAt: createdAt + this.ttlMs,
73
+ };
74
+ // Fail closed in memory if persistence reaches an indeterminate I/O
75
+ // outcome; the caller sees the error, but this process must not reopen
76
+ // admission around a lease file that may already have been renamed.
77
+ this.lease = renewed;
78
+ try {
79
+ this.writePersistedLease(renewed);
80
+ }
81
+ finally {
82
+ this.scheduleExpiry();
83
+ }
84
+ return false;
85
+ }
86
+ throw new Error("a different maintenance lease is already active");
87
+ }
88
+ const createdAt = this.now();
89
+ const lease = {
90
+ schemaVersion: LEASE_SCHEMA_VERSION,
91
+ tokenHash,
92
+ createdAt,
93
+ expiresAt: createdAt + this.ttlMs,
94
+ };
95
+ this.lease = lease;
96
+ try {
97
+ this.writePersistedLease(lease);
98
+ }
99
+ finally {
100
+ this.scheduleExpiry();
101
+ }
102
+ return true;
103
+ }
104
+ release(token) {
105
+ if (!this.isActive())
106
+ return "not_active";
107
+ const lease = this.lease;
108
+ if (!lease)
109
+ return "not_active";
110
+ let tokenHash;
111
+ try {
112
+ tokenHash = hashLeaseToken(token);
113
+ }
114
+ catch {
115
+ return "denied";
116
+ }
117
+ if (!hashesEqual(lease.tokenHash, tokenHash))
118
+ return "denied";
119
+ this.clear();
120
+ return "released";
121
+ }
122
+ readPersistedLease() {
123
+ let parsed;
124
+ try {
125
+ parsed = JSON.parse(readFileSync(this.filePath, "utf8"));
126
+ }
127
+ catch (error) {
128
+ if (error
129
+ && typeof error === "object"
130
+ && "code" in error
131
+ && error.code === "ENOENT")
132
+ return undefined;
133
+ throw new Error("maintenance lease state is unreadable", { cause: error });
134
+ }
135
+ if (!isPersistedLease(parsed, this.ttlMs)) {
136
+ throw new Error("maintenance lease state is invalid");
137
+ }
138
+ if (parsed.expiresAt <= this.now()) {
139
+ this.clearFile();
140
+ return undefined;
141
+ }
142
+ return parsed;
143
+ }
144
+ writePersistedLease(lease) {
145
+ const parent = dirname(this.filePath);
146
+ mkdirSync(parent, { recursive: true, mode: 0o700 });
147
+ const temporary = `${this.filePath}.${process.pid}.${Date.now()}.tmp`;
148
+ let descriptor;
149
+ try {
150
+ descriptor = openSync(temporary, "wx", 0o600);
151
+ writeFileSync(descriptor, `${JSON.stringify(lease)}\n`, "utf8");
152
+ fsyncSync(descriptor);
153
+ closeSync(descriptor);
154
+ descriptor = undefined;
155
+ renameSync(temporary, this.filePath);
156
+ this.syncDirectory(parent);
157
+ }
158
+ finally {
159
+ if (descriptor !== undefined)
160
+ closeSync(descriptor);
161
+ rmSync(temporary, { force: true });
162
+ }
163
+ }
164
+ clear() {
165
+ this.clearFile();
166
+ this.lease = undefined;
167
+ this.cancelExpiry();
168
+ }
169
+ clearFile() {
170
+ rmSync(this.filePath, { force: true });
171
+ this.syncDirectory(dirname(this.filePath));
172
+ }
173
+ expire() {
174
+ this.clear();
175
+ this.onExpired();
176
+ }
177
+ scheduleExpiry(delayOverride) {
178
+ this.cancelExpiry();
179
+ if (!this.lease)
180
+ return;
181
+ const delay = delayOverride ?? Math.max(0, this.lease.expiresAt - this.now());
182
+ this.expiryTimer = setTimeout(() => {
183
+ this.expiryTimer = undefined;
184
+ if (!this.lease)
185
+ return;
186
+ if (this.lease.expiresAt > this.now()) {
187
+ this.scheduleExpiry();
188
+ return;
189
+ }
190
+ try {
191
+ this.expire();
192
+ }
193
+ catch {
194
+ // Keep the in-memory fence and retry cleanup. A failed durability
195
+ // operation must not turn an expired lease into open admission.
196
+ this.scheduleExpiry(EXPIRY_RETRY_MS);
197
+ }
198
+ }, Math.min(delay, MAX_TIMER_DELAY_MS));
199
+ this.expiryTimer.unref?.();
200
+ }
201
+ cancelExpiry() {
202
+ if (this.expiryTimer)
203
+ clearTimeout(this.expiryTimer);
204
+ this.expiryTimer = undefined;
205
+ }
206
+ }
207
+ function fsyncDirectory(directory) {
208
+ let descriptor;
209
+ try {
210
+ descriptor = openSync(directory, "r");
211
+ fsyncSync(descriptor);
212
+ }
213
+ catch (error) {
214
+ if (error
215
+ && typeof error === "object"
216
+ && "code" in error
217
+ && error.code === "ENOENT")
218
+ return;
219
+ throw error;
220
+ }
221
+ finally {
222
+ if (descriptor !== undefined)
223
+ closeSync(descriptor);
224
+ }
225
+ }
226
+ function hashLeaseToken(token) {
227
+ if (!LEASE_TOKEN_PATTERN.test(token)) {
228
+ throw new Error("maintenance lease token must be 32-256 base64url characters");
229
+ }
230
+ return createHash("sha256").update(token, "utf8").digest("hex");
231
+ }
232
+ function hashesEqual(left, right) {
233
+ const leftBuffer = Buffer.from(left, "hex");
234
+ const rightBuffer = Buffer.from(right, "hex");
235
+ return leftBuffer.length === rightBuffer.length
236
+ && timingSafeEqual(leftBuffer, rightBuffer);
237
+ }
238
+ function isPersistedLease(value, maximumTtlMs) {
239
+ if (!value || typeof value !== "object")
240
+ return false;
241
+ const candidate = value;
242
+ return candidate.schemaVersion === LEASE_SCHEMA_VERSION
243
+ && typeof candidate.tokenHash === "string"
244
+ && /^[a-f0-9]{64}$/.test(candidate.tokenHash)
245
+ && Number.isSafeInteger(candidate.createdAt)
246
+ && Number.isSafeInteger(candidate.expiresAt)
247
+ && candidate.createdAt >= 0
248
+ && candidate.expiresAt > candidate.createdAt
249
+ && candidate.expiresAt - candidate.createdAt <= maximumTtlMs;
250
+ }
@@ -20,6 +20,7 @@ import { mkdirSync, readFileSync, renameSync, writeFileSync, } from "node:fs";
20
20
  import path from "node:path";
21
21
  import { rynxHome } from "@rynx-ai/core";
22
22
  import { db } from "../db.js";
23
+ import { transaction } from "../sqlite.js";
23
24
  /** Mirrors the daemon runtime store's default resolution. */
24
25
  function defaultProxyDir() {
25
26
  return path.join(rynxHome(), ".codex-proxy");
@@ -46,9 +47,9 @@ export function cleanupLegacySessions(opts = {}) {
46
47
  let deletedLogRows = 0;
47
48
  let deletedSessions = 0;
48
49
  let prunedStoreEntries = 0;
49
- conn.transaction(() => {
50
- deletedLogRows = conn.prepare(`DELETE FROM session_items WHERE ${LEGACY_LOG}`).run().changes;
51
- deletedSessions = conn.prepare(`DELETE FROM sessions WHERE ${LEGACY_META}`).run().changes;
50
+ transaction(conn, () => {
51
+ deletedLogRows = Number(conn.prepare(`DELETE FROM session_items WHERE ${LEGACY_LOG}`).run().changes);
52
+ deletedSessions = Number(conn.prepare(`DELETE FROM sessions WHERE ${LEGACY_META}`).run().changes);
52
53
  })();
53
54
  // The runtime store is daemon-owned. Plugin routing stores remain private to
54
55
  // their plugins and are never inspected by this migration.
@@ -20,6 +20,16 @@ export interface PluginCliCommandResult {
20
20
  stderrTruncated: boolean;
21
21
  };
22
22
  }
23
+ export interface InstalledPluginListItem {
24
+ id: string;
25
+ version: string;
26
+ source: "local" | "git";
27
+ state: "enabled" | "disabled";
28
+ }
29
+ /** Read the durable installed-plugin inventory without contacting the daemon. */
30
+ export declare function listInstalledPlugins(): InstalledPluginListItem[];
31
+ /** Render manifest-declared command help without executing plugin code. */
32
+ export declare function installedPluginCommandUsage(pluginId: string): string;
23
33
  /** Execute one statically declared, language-neutral plugin command. */
24
34
  export declare function runPluginCliCommand(pluginId: string, args: readonly string[], options?: RunPluginCliOptions): Promise<PluginCliCommandResult>;
25
35
  export declare function pluginCommandUsage(record: RunnablePluginRecord): string;
@@ -3,12 +3,29 @@ import { mkdirSync, realpathSync, statSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { createPluginCliInvocationLeaseId, removePluginRuntimeLease, writePluginRuntimeLease, } from "@rynx-ai/plugin-sdk";
5
5
  import { db } from "./db.js";
6
+ import { transaction } from "./sqlite.js";
6
7
  import { pluginDataDir, pluginRuntimeCacheDir } from "./plugin-installer.js";
7
8
  import { validatePluginPackageSnapshot } from "./plugin-package.js";
8
- import { getPlugin } from "./plugin-store.js";
9
+ import { getPlugin, listPlugins, } from "./plugin-store.js";
9
10
  import { loadOrCreateDaemonInstallationIdentitySync } from "./installation-id.js";
10
11
  const DEFAULT_TERMINATION_GRACE_MS = 2_000;
11
12
  const MAX_TERMINATION_GRACE_MS = 30_000;
13
+ /** Read the durable installed-plugin inventory without contacting the daemon. */
14
+ export function listInstalledPlugins() {
15
+ return listPlugins().map((record) => ({
16
+ id: record.canonicalId,
17
+ version: record.version,
18
+ source: record.source,
19
+ state: record.enabled ? "enabled" : "disabled",
20
+ }));
21
+ }
22
+ /** Render manifest-declared command help without executing plugin code. */
23
+ export function installedPluginCommandUsage(pluginId) {
24
+ const record = getPlugin(pluginId);
25
+ if (!record)
26
+ throw new Error(`plugin "${pluginId}" is not installed`);
27
+ return pluginCommandUsage(record);
28
+ }
12
29
  /** Execute one statically declared, language-neutral plugin command. */
13
30
  export async function runPluginCliCommand(pluginId, args, options = {}) {
14
31
  const hostEnv = options.env ?? process.env;
@@ -74,7 +91,7 @@ export async function runPluginCliCommand(pluginId, args, options = {}) {
74
91
  removePluginRuntimeLease(dataDir, leaseId);
75
92
  };
76
93
  try {
77
- db().transaction(() => {
94
+ transaction(db(), () => {
78
95
  const record = getPlugin(pluginId);
79
96
  if (!record)
80
97
  throw new Error(`plugin "${pluginId}" is not installed`);