@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.
- package/bundled-plugins/plugins/lark/.rynx-plugin/plugin.json +0 -12
- package/bundled-plugins/plugins/lark/dist/cli-mirror/mirror-index.js +3 -1
- package/bundled-plugins/plugins/lark/dist/host-adapters.d.ts +3 -3
- package/bundled-plugins/plugins/lark/dist/host-adapters.js +20 -18
- package/bundled-plugins/plugins/lark/dist/lark.js +18 -12
- package/bundled-plugins/plugins/lark/dist/runtime.js +6046 -6022
- package/bundled-plugins/plugins/lark/dist/settings.js +6006 -5988
- package/bundled-plugins/plugins/lark/package.json +1 -1
- package/bundled-plugins/plugins/lark/ui/assets/index-CGUVAMsv.css +1 -0
- package/bundled-plugins/plugins/lark/ui/index.html +2 -2
- package/dist/agent-file.js +0 -1
- package/dist/browser-artifact-management.d.ts +4 -1
- package/dist/browser-artifact-management.js +22 -11
- package/dist/browser-artifacts.d.ts +1 -0
- package/dist/browser-artifacts.js +1 -0
- package/dist/chrome-for-testing-runtime.d.ts +27 -0
- package/dist/chrome-for-testing-runtime.js +194 -0
- package/dist/chrome-for-testing-store.d.ts +2 -0
- package/dist/chrome-for-testing-store.js +37 -6
- package/dist/control-deps.d.ts +6 -2
- package/dist/control-deps.js +87 -4
- package/dist/control-link-store.js +4 -3
- package/dist/daemon-owner.js +4 -7
- package/dist/daemon-server.d.ts +4 -1
- package/dist/daemon-server.js +158 -45
- package/dist/db.d.ts +2 -2
- package/dist/db.js +55 -20
- package/dist/entry-path.d.ts +3 -0
- package/dist/entry-path.js +12 -0
- package/dist/index-daemon.js +3 -87
- package/dist/lifecycle.d.ts +13 -0
- package/dist/lifecycle.js +19 -0
- package/dist/maintenance-lease.d.ts +39 -0
- package/dist/maintenance-lease.js +250 -0
- package/dist/migrations/cleanup-legacy-sessions.js +4 -3
- package/dist/plugin-cli.d.ts +10 -0
- package/dist/plugin-cli.js +19 -2
- package/dist/plugin-host-rpc.d.ts +35 -6
- package/dist/plugin-host-rpc.js +222 -59
- package/dist/plugin-installer.js +62 -2
- package/dist/plugin-management-service.js +0 -2
- package/dist/plugin-marketplace.js +2 -1
- package/dist/plugin-store.js +2 -4
- package/dist/plugin-supervisor.d.ts +5 -0
- package/dist/plugin-supervisor.js +218 -33
- package/dist/pm2.d.ts +11 -2
- package/dist/pm2.js +116 -19
- package/dist/process.d.ts +12 -0
- package/dist/process.js +91 -0
- package/dist/remote-runtime-access-store.d.ts +2 -2
- package/dist/remote-runtime-access-store.js +6 -9
- package/dist/remote-runtime-target-store.d.ts +2 -2
- package/dist/remote-runtime-target-store.js +6 -6
- package/dist/session-emulator-binding-store.js +4 -6
- package/dist/session-fork-store.js +24 -8
- package/dist/session-launch-operations.d.ts +10 -1
- package/dist/session-launch-operations.js +21 -11
- package/dist/session-log-store.js +2 -1
- package/dist/session-meta-store.d.ts +5 -0
- package/dist/session-meta-store.js +25 -2
- package/dist/session-portal-grant-store.d.ts +29 -0
- package/dist/session-portal-grant-store.js +85 -0
- package/dist/session-resource-store.js +3 -2
- package/dist/setup-service.d.ts +21 -0
- package/dist/setup-service.js +39 -0
- package/dist/skills-catalog.d.ts +11 -2
- package/dist/skills-catalog.js +307 -10
- package/dist/sqlite.d.ts +22 -0
- package/dist/sqlite.js +57 -0
- package/dist/system-dependencies.d.ts +22 -0
- package/dist/system-dependencies.js +79 -0
- package/package.json +37 -21
- package/bundled-plugins/plugins/lark/ui/assets/index-B2dhMlMU.css +0 -1
- package/dist/browser-cli-args.d.ts +0 -20
- package/dist/browser-cli-args.js +0 -100
- package/dist/cli.d.ts +0 -2
- package/dist/cli.js +0 -550
- package/dist/control-client.d.ts +0 -122
- package/dist/control-client.js +0 -855
- package/dist/control-endpoint.d.ts +0 -14
- package/dist/control-endpoint.js +0 -124
- package/dist/desktop-browser-host-client.d.ts +0 -35
- package/dist/desktop-browser-host-client.js +0 -415
- package/dist/setup.d.ts +0 -20
- package/dist/setup.js +0 -386
- package/dist/update.d.ts +0 -25
- package/dist/update.js +0 -133
- /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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,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
|
+
}
|
package/dist/index-daemon.js
CHANGED
|
@@ -1,91 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
|
|
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
|
-
|
|
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.
|
package/dist/plugin-cli.d.ts
CHANGED
|
@@ -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;
|
package/dist/plugin-cli.js
CHANGED
|
@@ -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()
|
|
94
|
+
transaction(db(), () => {
|
|
78
95
|
const record = getPlugin(pluginId);
|
|
79
96
|
if (!record)
|
|
80
97
|
throw new Error(`plugin "${pluginId}" is not installed`);
|