beatrina 0.8.6
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/LICENSE +92 -0
- package/NOTICES +72 -0
- package/README.md +124 -0
- package/bin/beatrina.mjs +223 -0
- package/bin/cli.mjs +80 -0
- package/bin/failsafe.mjs +74 -0
- package/bin/identity.mjs +45 -0
- package/bin/prova-post.mjs +51 -0
- package/bin/sessions.mjs +95 -0
- package/bin/shortcut.mjs +151 -0
- package/bin/update-check.mjs +55 -0
- package/bin/upgrade.mjs +76 -0
- package/build-info.json +1 -0
- package/carmar_V0.8.6.html +1310 -0
- package/check/acceptance.mjs +278 -0
- package/check/session.mjs +215 -0
- package/engines/js/document-exec.mjs +82 -0
- package/engines/js/persist.mjs +214 -0
- package/engines/js/worker.mjs +424 -0
- package/engines/python/adapter.py +577 -0
- package/engines/python/analyze.py +814 -0
- package/engines/python/bootstrap.py +309 -0
- package/engines/python/dataview.py +735 -0
- package/engines/python/debugger.py +346 -0
- package/engines/python/document_exec.py +158 -0
- package/engines/python/engine.json +28 -0
- package/engines/python/handoff.py +118 -0
- package/engines/python/worker.py +564 -0
- package/engines/r/engine.json +25 -0
- package/engines/r/handoff.R +92 -0
- package/failsafe/ai-policy.R +255 -0
- package/failsafe/ai-store.R +373 -0
- package/failsafe/cite.R +418 -0
- package/failsafe/journal.R +684 -0
- package/failsafe/plugins.R +809 -0
- package/failsafe/serve.R +5500 -0
- package/host/ai-policy.mjs +218 -0
- package/host/deployment.mjs +160 -0
- package/host/engine-js.mjs +98 -0
- package/host/engine-pool.mjs +383 -0
- package/host/engine-python.mjs +228 -0
- package/host/engine-r.mjs +206 -0
- package/host/engine-stdio.mjs +401 -0
- package/host/journal-store.mjs +749 -0
- package/host/main.mjs +503 -0
- package/host/planes/README.md +41 -0
- package/host/planes/ai-store.mjs +327 -0
- package/host/planes/ai.mjs +467 -0
- package/host/planes/analyze.mjs +397 -0
- package/host/planes/cite.mjs +517 -0
- package/host/planes/files.mjs +0 -0
- package/host/planes/jobs.mjs +704 -0
- package/host/planes/journal.mjs +53 -0
- package/host/planes/latex.mjs +201 -0
- package/host/planes/mcp.mjs +493 -0
- package/host/planes/pair.mjs +325 -0
- package/host/planes/pipe-term.mjs +122 -0
- package/host/planes/plugins.mjs +112 -0
- package/host/planes/proc-tree.mjs +76 -0
- package/host/planes/sessions.mjs +434 -0
- package/host/planes/settings.mjs +164 -0
- package/host/planes/terminal.mjs +286 -0
- package/host/planes/test-file.mjs +80 -0
- package/host/planes/update.mjs +214 -0
- package/host/plugin-store.mjs +838 -0
- package/host/server.mjs +441 -0
- package/host/settings.mjs +379 -0
- package/host/update-record.mjs +59 -0
- package/host/user-dirs.mjs +117 -0
- package/host/windows-runtime.mjs +145 -0
- package/host/worker-plane.mjs +713 -0
- package/host/ws.mjs +190 -0
- package/kernel/analyze.R +668 -0
- package/kernel/deployment.R +165 -0
- package/kernel/examples/NOTICE.md +38 -0
- package/kernel/examples/tna-complete-tutorial.Rmd +210 -0
- package/kernel/fileio.R +656 -0
- package/kernel/index.html +96 -0
- package/kernel/job-run.R +391 -0
- package/kernel/jobs.R +276 -0
- package/kernel/kernel-protocol +1 -0
- package/kernel/kernel-version +1 -0
- package/kernel/kernel.R +671 -0
- package/kernel/knitr-run.R +245 -0
- package/kernel/latex.R +609 -0
- package/kernel/mcp/carmar-mcp.mjs +516 -0
- package/kernel/notebook-page.R +67 -0
- package/kernel/plugins/csl/apa/apa.csl +2273 -0
- package/kernel/plugins/csl/apa/plugin.json +19 -0
- package/kernel/plugins/csl/chicago-author-date/chicago-author-date.csl +4216 -0
- package/kernel/plugins/csl/chicago-author-date/plugin.json +19 -0
- package/kernel/plugins/csl/harvard-cite-them-right/harvard-cite-them-right.csl +316 -0
- package/kernel/plugins/csl/harvard-cite-them-right/plugin.json +19 -0
- package/kernel/plugins/csl/ieee/ieee.csl +519 -0
- package/kernel/plugins/csl/ieee/plugin.json +19 -0
- package/kernel/plugins/csl/modern-language-association/modern-language-association.csl +1184 -0
- package/kernel/plugins/csl/modern-language-association/plugin.json +19 -0
- package/kernel/plugins/csl/nature/nature.csl +189 -0
- package/kernel/plugins/csl/nature/plugin.json +19 -0
- package/kernel/plugins/latex/apa7/apa7.json +14 -0
- package/kernel/plugins/latex/apa7/plugin.json +19 -0
- package/kernel/plugins/latex/elsarticle/elsarticle.json +14 -0
- package/kernel/plugins/latex/elsarticle/plugin.json +19 -0
- package/kernel/plugins/latex/ieeetran/ieeetran.json +10 -0
- package/kernel/plugins/latex/ieeetran/plugin.json +19 -0
- package/kernel/project.R +131 -0
- package/kernel/settings.R +410 -0
- package/kernel/sniff.R +769 -0
- package/kernel/worker-boot.R +22 -0
- package/kernel/worker.R +3496 -0
- package/lib/agent-authoring-contract.js +547 -0
- package/lib/cell-kinds.js +108 -0
- package/lib/engine-labels.js +324 -0
- package/package.json +32 -0
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
// sessions.mjs — the sessions plane: which kernels this user is running, which
|
|
2
|
+
// R installations the machine has, and starting another session.
|
|
3
|
+
//
|
|
4
|
+
// A port of serve.R's `session_list`, `r_versions` / `current_rscript`,
|
|
5
|
+
// `live_session_ports` / `start_sibling_session` and the `session-new` op.
|
|
6
|
+
// Page-only: a list of the user's running kernels and of the R installations
|
|
7
|
+
// on the machine is a map of the desk, not something a native client or an
|
|
8
|
+
// agent needs the kernel to draw for it (serve.R keeps `session_list` and
|
|
9
|
+
// `r_versions` in AGENT_REFUSED for exactly that reason).
|
|
10
|
+
//
|
|
11
|
+
// The runtime RECORD itself is main.mjs's (it owns the file's lifetime and
|
|
12
|
+
// the `page-title` write); this plane only READS the directory, so a session
|
|
13
|
+
// started by a launcher or by the R package counts too. A killed kernel leaves
|
|
14
|
+
// its file behind, which is why every row is health-checked before it is
|
|
15
|
+
// believed.
|
|
16
|
+
//
|
|
17
|
+
// THE HANDOFF: `session_upgrade` and `session_restart`, plus their native doors
|
|
18
|
+
// POST /session/upgrade and POST /session/restart. A port of serve.R's
|
|
19
|
+
// handoff_refusal / session_handoff_begin / session_handoff_spawn: a successor
|
|
20
|
+
// supervisor is started on the SAME port from the files beside this one (after
|
|
21
|
+
// an in-place upgrade, the installed build's), every page is told where its
|
|
22
|
+
// successor's notebook is, the worker's whole tree is killed, and this process
|
|
23
|
+
// exits. The successor's R is FRESH — no save.image() bridge, by decision
|
|
24
|
+
// (docs/upgrade.md); a restart says so and does not pretend otherwise.
|
|
25
|
+
//
|
|
26
|
+
// `CARMAR_SESSION_BASE` is Beatrix's only addition to the wire's behaviour
|
|
27
|
+
// here: serve.R hardcodes the 4747..4947 scan, and a TEST may not bind 4747
|
|
28
|
+
// (FORK.md), so the base is readable from the environment and defaults to
|
|
29
|
+
// 4747 — the published-page port, fixed because browsers key storage to the
|
|
30
|
+
// ORIGIN and a session that moved ports would greet its reader with an empty
|
|
31
|
+
// notebook.
|
|
32
|
+
|
|
33
|
+
import fs from "node:fs";
|
|
34
|
+
import os from "node:os";
|
|
35
|
+
import path from "node:path";
|
|
36
|
+
import { spawn, execFileSync } from "node:child_process";
|
|
37
|
+
import { fileURLToPath } from "node:url";
|
|
38
|
+
import { PAGE_ONLY_CLASSES, secureToken } from "../server.mjs";
|
|
39
|
+
import { stateDir } from "../user-dirs.mjs";
|
|
40
|
+
|
|
41
|
+
export const SESSION_OPS = Object.freeze(["session_list", "r_versions", "session-new"]);
|
|
42
|
+
export const HANDOFF_OPS = Object.freeze(["session_upgrade", "session_restart"]);
|
|
43
|
+
/** How many ports above the base a new session may take (serve.R: 4747..4947). */
|
|
44
|
+
export const SESSION_BAND = 201;
|
|
45
|
+
|
|
46
|
+
const RUNTIME_NAME = /^kernel-([0-9]+)\.json$/;
|
|
47
|
+
|
|
48
|
+
/** The runtime directory both supervisors write their records into. */
|
|
49
|
+
export function runtimeDir(env) {
|
|
50
|
+
return env("CARMAR_RUNTIME_DIR") || path.join(os.homedir(), ".carmar", "run");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Every `kernel-<port>.json` in the runtime directory, parsed; unreadable ones dropped. */
|
|
54
|
+
export function runtimeRecords(env) {
|
|
55
|
+
const dir = runtimeDir(env);
|
|
56
|
+
let names = [];
|
|
57
|
+
try { names = fs.readdirSync(dir); } catch { return []; }
|
|
58
|
+
const out = [];
|
|
59
|
+
for (const name of names) {
|
|
60
|
+
const m = RUNTIME_NAME.exec(name);
|
|
61
|
+
if (!m) continue;
|
|
62
|
+
let rec = null;
|
|
63
|
+
try { rec = JSON.parse(fs.readFileSync(path.join(dir, name), "utf8")); } catch { rec = null; }
|
|
64
|
+
if (!rec || typeof rec !== "object" || rec.port == null || rec.url == null) continue;
|
|
65
|
+
const port = Number.parseInt(rec.port, 10);
|
|
66
|
+
if (!Number.isInteger(port)) continue;
|
|
67
|
+
out.push({ ...rec, port });
|
|
68
|
+
}
|
|
69
|
+
return out.sort((a, b) => a.port - b.port);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Is a kernel actually answering on `url`?
|
|
74
|
+
*
|
|
75
|
+
* ONE SECOND, not sixty. serve.R learned this the hard way: a port squatted by
|
|
76
|
+
* something that accepts TCP and never answers froze routing for every
|
|
77
|
+
* connected page for a full minute per port.
|
|
78
|
+
*/
|
|
79
|
+
export async function healthy(url, timeoutMs = 1000) {
|
|
80
|
+
const target = `${String(url).replace(/\/+$/, "")}/health`;
|
|
81
|
+
const ctrl = new AbortController();
|
|
82
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
83
|
+
try {
|
|
84
|
+
const res = await fetch(target, { signal: ctrl.signal, cache: "no-store" });
|
|
85
|
+
if (!res.ok) return false;
|
|
86
|
+
return (await res.text()).includes('"ok":true');
|
|
87
|
+
} catch {
|
|
88
|
+
return false;
|
|
89
|
+
} finally {
|
|
90
|
+
clearTimeout(timer);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The ports of kernels that are answering right now (ourselves, definitionally). */
|
|
95
|
+
export async function liveSessionPorts(env, ownPort) {
|
|
96
|
+
const recs = runtimeRecords(env);
|
|
97
|
+
const checked = await Promise.all(recs.map(async (rec) => ({
|
|
98
|
+
port: rec.port,
|
|
99
|
+
alive: rec.port === ownPort ? true : await healthy(rec.url),
|
|
100
|
+
})));
|
|
101
|
+
return checked.filter((r) => r.alive).map((r) => r.port);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── R installations ────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
/** Every path under one child level of `dir` that ends in `tail` — the one glob shape the ladder needs. */
|
|
107
|
+
function globOneLevel(dir, tail) {
|
|
108
|
+
let kids = [];
|
|
109
|
+
try { kids = fs.readdirSync(dir); } catch { return []; }
|
|
110
|
+
return kids.map((k) => path.join(dir, k, tail)).filter((p) => fs.existsSync(p));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** `Sys.which("Rscript")`: the first Rscript on PATH. */
|
|
114
|
+
function whichRscript(env) {
|
|
115
|
+
const exe = process.platform === "win32" ? "Rscript.exe" : "Rscript";
|
|
116
|
+
for (const dir of String(env("PATH") || "").split(path.delimiter)) {
|
|
117
|
+
if (!dir) continue;
|
|
118
|
+
const cand = path.join(dir, exe);
|
|
119
|
+
try { fs.accessSync(cand, fs.constants.X_OK); return cand; } catch { /* next */ }
|
|
120
|
+
}
|
|
121
|
+
return "";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Every R installation on this machine, with its version — serve.R's ladder,
|
|
126
|
+
* kept in its order. Read once per kernel life: each probe spawns a process.
|
|
127
|
+
* @returns {Array<{path: string, version: string, current: boolean}>} newest first
|
|
128
|
+
*/
|
|
129
|
+
export function rVersions(env, currentPath, execFileSync) {
|
|
130
|
+
const cands = [
|
|
131
|
+
...globOneLevel("/Library/Frameworks/R.framework/Versions", "Resources/bin/Rscript"),
|
|
132
|
+
...globOneLevel("/opt/R", "bin/Rscript"),
|
|
133
|
+
...globOneLevel("/opt/homebrew/Cellar/r", "bin/Rscript"),
|
|
134
|
+
"/opt/homebrew/bin/Rscript", "/usr/local/bin/Rscript", "/opt/local/bin/Rscript",
|
|
135
|
+
"/usr/bin/Rscript", "/usr/lib/R/bin/Rscript",
|
|
136
|
+
...globOneLevel("/usr/lib", "bin/Rscript").filter((p) => /\/R-[^/]*\/bin\/Rscript$/.test(p)),
|
|
137
|
+
whichRscript(env),
|
|
138
|
+
].filter((p) => p && fs.existsSync(p));
|
|
139
|
+
const seenPath = new Set();
|
|
140
|
+
const unique = cands.filter((p) => (seenPath.has(p) ? false : (seenPath.add(p), true)));
|
|
141
|
+
// `Versions/Current` and a Homebrew symlink point at a versioned install
|
|
142
|
+
// already in the list: one entry per REAL binary, preferring the versioned
|
|
143
|
+
// path because it says which R it is.
|
|
144
|
+
const seenReal = new Set();
|
|
145
|
+
const rows = [];
|
|
146
|
+
for (const p of unique) {
|
|
147
|
+
let real = p;
|
|
148
|
+
try { real = fs.realpathSync(p); } catch { real = p; }
|
|
149
|
+
if (seenReal.has(real)) continue;
|
|
150
|
+
seenReal.add(real);
|
|
151
|
+
let version = "";
|
|
152
|
+
try {
|
|
153
|
+
const out = String(execFileSync(p, ["--version"], { encoding: "utf8", timeout: 10000, stdio: ["ignore", "pipe", "pipe"] }));
|
|
154
|
+
const hit = /[0-9]+\.[0-9]+\.[0-9]+/.exec(out);
|
|
155
|
+
version = hit ? hit[0] : "";
|
|
156
|
+
} catch (e) {
|
|
157
|
+
// R < 3.3 printed --version to stderr and exited non-zero; read what came.
|
|
158
|
+
const out = `${(e && e.stdout) || ""}${(e && e.stderr) || ""}`;
|
|
159
|
+
const hit = /[0-9]+\.[0-9]+\.[0-9]+/.exec(out);
|
|
160
|
+
version = hit ? hit[0] : "";
|
|
161
|
+
}
|
|
162
|
+
rows.push({ path: p, version, current: real === currentPath });
|
|
163
|
+
}
|
|
164
|
+
rows.sort((a, b) => (a.version < b.version ? 1 : a.version > b.version ? -1 : (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)));
|
|
165
|
+
return rows;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ── the plane ──────────────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
export function createPlane({ root, env, audit }) {
|
|
171
|
+
// How to start ANOTHER host. A source checkout or an installed package runs
|
|
172
|
+
// `node host/main.mjs`; a packaged single executable has no module path
|
|
173
|
+
// (the cjs bundle empties import.meta, and fileURLToPath(undefined) threw at
|
|
174
|
+
// boot — every SEA build since this plane landed failed to start, found
|
|
175
|
+
// 2026-09-15 running tools/beatrina/acceptance.mjs --binary) and IS the host,
|
|
176
|
+
// so it starts itself with no script.
|
|
177
|
+
const metaUrl = import.meta.url;
|
|
178
|
+
const mainPath = typeof metaUrl === "string" && metaUrl.startsWith("file:")
|
|
179
|
+
? path.join(path.dirname(path.dirname(fileURLToPath(metaUrl))), "main.mjs") : "";
|
|
180
|
+
const siblingArgs = mainPath ? [mainPath] : [];
|
|
181
|
+
let versionsCache = null;
|
|
182
|
+
|
|
183
|
+
const sessionBase = (() => {
|
|
184
|
+
const given = Number.parseInt(env("CARMAR_SESSION_BASE", "4747"), 10);
|
|
185
|
+
return Number.isInteger(given) && given >= 1024 && given <= 65000 ? given : 4747;
|
|
186
|
+
})();
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Start another supervisor on the next free adjacent port.
|
|
190
|
+
*
|
|
191
|
+
* Adjacent and stable on purpose (browsers key storage to the origin), and
|
|
192
|
+
* DETACHED — closing this session must not close that one. The port is a
|
|
193
|
+
* PREFERENCE, not a promise: between the probe and the child's own bind the
|
|
194
|
+
* port can go, so what is waited for is a NEW live port, whichever appears.
|
|
195
|
+
* serve.R learned that too: waiting for the exact port reported failure for
|
|
196
|
+
* a session that had started perfectly well, and orphaned it.
|
|
197
|
+
*/
|
|
198
|
+
const startSibling = async (ctx) => {
|
|
199
|
+
const own = ctx.port();
|
|
200
|
+
const used = new Set(await liveSessionPorts(env, own));
|
|
201
|
+
let chosen = null;
|
|
202
|
+
for (let p = sessionBase; p <= sessionBase + SESSION_BAND - 1; p += 1) {
|
|
203
|
+
if (!used.has(p)) { chosen = p; break; }
|
|
204
|
+
}
|
|
205
|
+
if (chosen == null) return null;
|
|
206
|
+
const before = new Set(await liveSessionPorts(env, own));
|
|
207
|
+
// The pipes go to a FILE, not to a pipe nobody reads: a detached sibling
|
|
208
|
+
// wedges once ~64 KB of stderr accumulates in an unread buffer.
|
|
209
|
+
let logFd = "ignore";
|
|
210
|
+
try {
|
|
211
|
+
logFd = fs.openSync(path.join(os.tmpdir(), `beatrix-session-${process.pid}-${chosen}.log`), "a");
|
|
212
|
+
} catch { logFd = "ignore"; }
|
|
213
|
+
let child;
|
|
214
|
+
try {
|
|
215
|
+
child = spawn(process.execPath, siblingArgs, {
|
|
216
|
+
cwd: root,
|
|
217
|
+
env: { ...process.env, CARMAR_PORT: String(chosen) },
|
|
218
|
+
detached: true,
|
|
219
|
+
stdio: ["ignore", logFd, logFd],
|
|
220
|
+
});
|
|
221
|
+
child.unref();
|
|
222
|
+
} catch { return null; }
|
|
223
|
+
const deadline = Date.now() + 60000;
|
|
224
|
+
while (Date.now() < deadline) {
|
|
225
|
+
const fresh = (await liveSessionPorts(env, own)).filter((p) => !before.has(p));
|
|
226
|
+
if (fresh.length) {
|
|
227
|
+
const got = fresh[0];
|
|
228
|
+
const rec = runtimeRecords(env).find((r) => r.port === got);
|
|
229
|
+
return { port: got, url: rec ? rec.url : `http://127.0.0.1:${got}/` };
|
|
230
|
+
}
|
|
231
|
+
if (child.exitCode !== null || child.signalCode !== null) return null;
|
|
232
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
233
|
+
}
|
|
234
|
+
return null;
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
// ── the handoff ─────────────────────────────────────────────────────────
|
|
238
|
+
let handoff = null;
|
|
239
|
+
|
|
240
|
+
/** Why a handoff may not begin, or null. serve.R's table, reason words included. */
|
|
241
|
+
const handoffRefusal = (ctx, mode, force) => {
|
|
242
|
+
const refuse = (error, reason) => ({ error, reason });
|
|
243
|
+
const installed = ctx.installedBuild();
|
|
244
|
+
if (!installed || installed === "unknown") return refuse("This kernel cannot tell which CarmaR is installed.", "unknown-install");
|
|
245
|
+
if (mode === "upgrade" && installed === ctx.kernelBuild) {
|
|
246
|
+
return refuse(`This session already runs the installed CarmaR ${installed}.`, "same-build");
|
|
247
|
+
}
|
|
248
|
+
if (!ctx.notebookPage(installed)) {
|
|
249
|
+
return refuse(`The installed CarmaR ${installed} has no notebook page beside this kernel.`, "no-page");
|
|
250
|
+
}
|
|
251
|
+
// An attempt that has spawned owns the port until this process exits.
|
|
252
|
+
if (handoff) return refuse("A session restart is already under way.", "under-way");
|
|
253
|
+
if (force && mode === "restart") return null;
|
|
254
|
+
const pl = ctx.plane;
|
|
255
|
+
if ((pl.running && pl.running.size > 0) || (pl.primary ? pl.primary.active != null : pl.active != null)) {
|
|
256
|
+
return refuse("R is busy — wait for the running chunk, or interrupt it, then try again.", "busy");
|
|
257
|
+
}
|
|
258
|
+
const planes = pl.planes ? [...pl.planes.values()] : [pl];
|
|
259
|
+
if (planes.some((p) => p.debugPaused || p.inputWaiting)) {
|
|
260
|
+
return refuse("R is waiting at a prompt — finish it first.", "prompt");
|
|
261
|
+
}
|
|
262
|
+
return null;
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
/** The command that starts a supervisor of THIS kind from the files beside this one. */
|
|
266
|
+
const successorCommand = () => {
|
|
267
|
+
const script = process.argv[1] && /main\.mjs$/.test(process.argv[1]) ? process.argv[1] : "";
|
|
268
|
+
return script ? { cmd: process.execPath, args: [script] } : { cmd: process.execPath, args: [] };
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const handoffBegin = (ctx, mode = "upgrade", force = false) => {
|
|
272
|
+
mode = mode === "restart" ? "restart" : "upgrade";
|
|
273
|
+
force = force === true && mode === "restart";
|
|
274
|
+
const auditName = mode === "restart" ? "session-restart" : "session-upgrade";
|
|
275
|
+
const why = handoffRefusal(ctx, mode, force);
|
|
276
|
+
if (why) {
|
|
277
|
+
audit(`${auditName}-refused`, { reason: why.reason, force });
|
|
278
|
+
return { ok: false, error: why.error, reason: why.reason };
|
|
279
|
+
}
|
|
280
|
+
const installed = ctx.installedBuild();
|
|
281
|
+
const cap = secureToken(32);
|
|
282
|
+
const page = ctx.notebookFileUrl(ctx.notebookPage(installed), ctx.port(), cap);
|
|
283
|
+
const started = ctx.startedSeconds;
|
|
284
|
+
handoff = { to: installed, cap, page, mode, force };
|
|
285
|
+
audit(auditName, { from: ctx.kernelBuild, to: installed, force });
|
|
286
|
+
// Every page learns where its successor's notebook is — over the gated
|
|
287
|
+
// socket only; the capability never appears in /health. `started` is this
|
|
288
|
+
// kernel's boot time: a page recognises its successor by a LATER one, the
|
|
289
|
+
// only test that also works when the build does not change.
|
|
290
|
+
const told = ctx.enc({ type: "session-upgrade", from: ctx.kernelBuild, to: installed, page, started, mode, force });
|
|
291
|
+
ctx.pageRecs().forEach((r) => { try { r.ws.send(told); } catch { /* gone */ } });
|
|
292
|
+
const spawned = handoffSpawn(ctx);
|
|
293
|
+
if (!spawned) return { ok: false, error: "The installed CarmaR could not be started.", reason: "spawn" };
|
|
294
|
+
return { ok: true, from: ctx.kernelBuild, to: installed, page, started, mode, force };
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
const handoffAbandon = (ctx, reason) => {
|
|
298
|
+
audit("session-upgrade-failed", { reason });
|
|
299
|
+
handoff = null;
|
|
300
|
+
const told = ctx.enc({ type: "session-upgrade-failed", error: reason });
|
|
301
|
+
ctx.pageRecs().forEach((r) => { try { r.ws.send(told); } catch { /* gone */ } });
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
/** Start the successor on this port, kill the worker tree, and leave. */
|
|
305
|
+
const handoffSpawn = (ctx) => {
|
|
306
|
+
const h = handoff;
|
|
307
|
+
const dir = stateDir();
|
|
308
|
+
try { fs.mkdirSync(dir, { recursive: true }); } catch { /* the log is a courtesy */ }
|
|
309
|
+
const port = ctx.port();
|
|
310
|
+
const record = ctx.runtimeRecord();
|
|
311
|
+
const childEnv = { ...process.env,
|
|
312
|
+
CARMAR_PORT: String(port), CARMAR_WAIT_PORT: "30",
|
|
313
|
+
// A successor that quietly moved to a free port would leave the page
|
|
314
|
+
// polling a dead one; strict means it refuses and its log says why.
|
|
315
|
+
CARMAR_PORT_STRICT: "1",
|
|
316
|
+
CARMAR_FILE_LAUNCH_CAP: h.cap, CARMAR_HANDOFF_FROM: ctx.kernelBuild,
|
|
317
|
+
CARMAR_SESSION_TITLE: record.title || "" };
|
|
318
|
+
if (record.listen) childEnv.CARMAR_LISTEN = "1"; else delete childEnv.CARMAR_LISTEN;
|
|
319
|
+
let out = "ignore";
|
|
320
|
+
try { out = fs.openSync(path.join(dir, `kernel-handoff-${port}.log`), "a"); } catch { out = "ignore"; }
|
|
321
|
+
let child = null;
|
|
322
|
+
try {
|
|
323
|
+
const { cmd, args } = successorCommand();
|
|
324
|
+
child = spawn(cmd, args, { env: childEnv, stdio: ["ignore", out, out], detached: true });
|
|
325
|
+
child.unref();
|
|
326
|
+
} catch { child = null; }
|
|
327
|
+
if (typeof out === "number") { try { fs.closeSync(out); } catch { /* closed */ } }
|
|
328
|
+
if (!child || !child.pid) { handoffAbandon(ctx, "The installed CarmaR could not be started."); return false; }
|
|
329
|
+
audit("session-upgrade-spawned", { to: h.to, pid: child.pid, mode: h.mode });
|
|
330
|
+
// Spawn first, kill second: the worker's whole process GROUP goes down only
|
|
331
|
+
// once the successor exists, so a failed spawn leaves the session untouched.
|
|
332
|
+
// The group kill reaches the shell grandchildren a hung R never signals.
|
|
333
|
+
const planes = ctx.plane.planes ? [...ctx.plane.planes.values()] : [ctx.plane];
|
|
334
|
+
for (const pl of planes) {
|
|
335
|
+
pl.expectedExit = true;
|
|
336
|
+
try { pl.failRoutes("R was stopped for a session restart — this run was abandoned."); } catch { /* none */ }
|
|
337
|
+
try { if (pl.engine && pl.alive) pl.engine.kill(); } catch { /* gone */ }
|
|
338
|
+
}
|
|
339
|
+
// Deferred, so the reply and the broadcast flush before the sockets close.
|
|
340
|
+
setTimeout(() => ctx.shutdown("session handoff"), 300);
|
|
341
|
+
return true;
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
return {
|
|
345
|
+
name: "sessions",
|
|
346
|
+
ops: [...SESSION_OPS, ...HANDOFF_OPS],
|
|
347
|
+
// Page-only in both senses, exactly as serve.R has them — with ONE
|
|
348
|
+
// deliberate divergence, stated rather than inherited: serve.R's
|
|
349
|
+
// `session-new` handler has no role check at all, so a declared agent can
|
|
350
|
+
// start a kernel there. That reads as an oversight beside the four ops
|
|
351
|
+
// around it, and starting processes is not something an agent does through
|
|
352
|
+
// a door the user cannot see, so Beatrix refuses it. Recorded in
|
|
353
|
+
// docs/wp/wp3a-files-sessions.md as a divergence, not a port.
|
|
354
|
+
agentRefused: [...SESSION_OPS, ...HANDOFF_OPS],
|
|
355
|
+
/** serve.R's own sentences, op by op (its AGENT_REFUSED reply table). */
|
|
356
|
+
agentRefusalFor: (op) => ({
|
|
357
|
+
session_list: "Agents cannot list the user's sessions.",
|
|
358
|
+
r_versions: "Agents cannot list or choose R installations.",
|
|
359
|
+
session_upgrade: "Agents cannot restart the user's session into another CarmaR.",
|
|
360
|
+
session_restart: "Agents cannot restart the user's session.",
|
|
361
|
+
"session-new": "Agents cannot start a session for the user.",
|
|
362
|
+
})[op] || null,
|
|
363
|
+
// The handoff IS advertised: a page decides "this kernel can restart into
|
|
364
|
+
// the installed build" from `ready.commands`, exactly as serve.R's ready
|
|
365
|
+
// frame lets it. The session list verbs are called and read, not advertised.
|
|
366
|
+
commands: [...HANDOFF_OPS],
|
|
367
|
+
capabilities: ["session-list-v1"],
|
|
368
|
+
|
|
369
|
+
handle(cmd, rec, ctx) {
|
|
370
|
+
if (typeof cmd.id !== "string" || !cmd.id.length) return undefined;
|
|
371
|
+
const reply = (fields) => rec.ws.send(ctx.enc({ type: cmd.type, id: cmd.id, ...fields }));
|
|
372
|
+
if (rec.role !== "page") return undefined; // as serve.R: not this door's business
|
|
373
|
+
if (!PAGE_ONLY_CLASSES.includes(rec.class)) {
|
|
374
|
+
audit(`${cmd.type}-refused`, { class: rec.class || "unknown" });
|
|
375
|
+
return reply({ error: "Only the local notebook page may ask this." });
|
|
376
|
+
}
|
|
377
|
+
if (HANDOFF_OPS.includes(cmd.type)) {
|
|
378
|
+
return reply(handoffBegin(ctx, cmd.type === "session_restart" ? "restart" : "upgrade", cmd.force === true));
|
|
379
|
+
}
|
|
380
|
+
if (cmd.type === "session_list") {
|
|
381
|
+
const own = ctx.port();
|
|
382
|
+
const recs = runtimeRecords(env);
|
|
383
|
+
return Promise.all(recs.map(async (r) => {
|
|
384
|
+
const isThis = r.port === own;
|
|
385
|
+
return { port: r.port, url: String(r.url), title: r.title ?? "",
|
|
386
|
+
pid: r.pid ?? null, alive: isThis ? true : await healthy(r.url), this: isThis };
|
|
387
|
+
})).then((sessions) => reply({ sessions }))
|
|
388
|
+
.catch((e) => reply({ error: e.message }));
|
|
389
|
+
}
|
|
390
|
+
if (cmd.type === "r_versions") {
|
|
391
|
+
const current = (() => {
|
|
392
|
+
const p = ctx.plane.engine && ctx.plane.engine.rscript ? ctx.plane.engine.rscript : "";
|
|
393
|
+
try { return p ? fs.realpathSync(p) : ""; } catch { return p; }
|
|
394
|
+
})();
|
|
395
|
+
// Spawning N processes is the whole cost here; once per kernel life,
|
|
396
|
+
// exactly as serve.R caches it.
|
|
397
|
+
if (!versionsCache) versionsCache = rVersions(env, current, execFileSync);
|
|
398
|
+
const shown = ctx.plane.engine && ctx.plane.engine.rscript ? ctx.plane.engine.rscript : "";
|
|
399
|
+
return reply({ versions: versionsCache, current: shown });
|
|
400
|
+
}
|
|
401
|
+
if (cmd.type === "session-new") {
|
|
402
|
+
audit("session-new", { id: cmd.id });
|
|
403
|
+
return startSibling(ctx).then((started) => {
|
|
404
|
+
if (!started) return reply({ error: "Could not start another session." });
|
|
405
|
+
return reply({ port: started.port, url: started.url });
|
|
406
|
+
}).catch((e) => {
|
|
407
|
+
audit("session-new-failed", { detail: e.message });
|
|
408
|
+
reply({ error: "Could not start another session." });
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
return undefined;
|
|
412
|
+
},
|
|
413
|
+
|
|
414
|
+
/** The native doors of the same handoff (the menu helper, launch.sh --restart). */
|
|
415
|
+
http(req, res, pathname, ctx) {
|
|
416
|
+
if (pathname !== "/session/upgrade" && pathname !== "/session/restart") return false;
|
|
417
|
+
const blocked = ctx.controlRejection(req, { allowFile: true });
|
|
418
|
+
if (blocked) { ctx.reject(res, blocked.reason, blocked.detail); return true; }
|
|
419
|
+
if (req.method !== "POST") {
|
|
420
|
+
ctx.respond(res, 405, "application/json", ctx.enc({ ok: false, error: "POST only" }));
|
|
421
|
+
return true;
|
|
422
|
+
}
|
|
423
|
+
const restart = pathname === "/session/restart";
|
|
424
|
+
const begun = handoffBegin(ctx, restart ? "restart" : "upgrade", restart && ctx.queryParam(req, "force") === "1");
|
|
425
|
+
ctx.respond(res, begun.ok === true ? 200 : 409, "application/json", ctx.enc(begun));
|
|
426
|
+
return true;
|
|
427
|
+
},
|
|
428
|
+
|
|
429
|
+
/** Busy while a successor is being handed the port: the idle clock must not race it. */
|
|
430
|
+
busy: () => handoff != null,
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
export default { createPlane };
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// planes/settings.mjs — the user's own settings, over the wire.
|
|
2
|
+
//
|
|
3
|
+
// In NEITHER of the evaluating allow-lists, and that is the decision rather
|
|
4
|
+
// than an omission. FORWARDED routes to the evaluating worker and
|
|
5
|
+
// ANALYZE_FORWARDED to the analyzer; these read a file the SUPERVISOR owns and
|
|
6
|
+
// mutate SUPERVISOR state (the idle clock, whether history records), which the
|
|
7
|
+
// worker could not change at all — and routing a config-file WRITER into the
|
|
8
|
+
// process that evaluates user code is precisely where such an op is most
|
|
9
|
+
// dangerous.
|
|
10
|
+
//
|
|
11
|
+
// Page-only for the ai-key reason exactly: a native client could edit
|
|
12
|
+
// settings.json itself as this user, but the kernel does not do it for it, so
|
|
13
|
+
// the promise holds for every client rather than only the polite ones. A
|
|
14
|
+
// published origin is refused because its reader approved that site to run the
|
|
15
|
+
// chunks they press, not to reconfigure their kernel.
|
|
16
|
+
//
|
|
17
|
+
// NOT advertised in `ready.commands`: serve.R does not advertise it either, and
|
|
18
|
+
// `commands` means "what this kernel serves that a page may have to detect by
|
|
19
|
+
// name". Nothing in the page gates on these.
|
|
20
|
+
|
|
21
|
+
import { PAGE_ONLY_CLASSES } from "../server.mjs";
|
|
22
|
+
import { SETTINGS_ADMIN, settingsEntry, validateSetting, writeSettingsFile, settingsPath } from "../settings.mjs";
|
|
23
|
+
import fs from "node:fs";
|
|
24
|
+
|
|
25
|
+
const OPS = Object.freeze(["settings_get", "settings_set", "settings_reset"]);
|
|
26
|
+
const AGENT_WHY = Object.freeze({
|
|
27
|
+
// settings_GET is refused as firmly as the writes. Not because the values are
|
|
28
|
+
// secret, but because handing back the confinement root, the audit-log path
|
|
29
|
+
// and whether AI-text logging is on is a MAP OF THE DEPLOYMENT'S CONTROLS,
|
|
30
|
+
// given to a process whose entire premise is that it is not the user.
|
|
31
|
+
settings_get: "Agents cannot read CarmaR's settings.",
|
|
32
|
+
settings_set: "Agents cannot change CarmaR's settings.",
|
|
33
|
+
settings_reset: "Agents cannot reset CarmaR's settings.",
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export function createPlane({ audit, settings } = {}) {
|
|
37
|
+
return {
|
|
38
|
+
name: "settings",
|
|
39
|
+
ops: OPS,
|
|
40
|
+
commands: [],
|
|
41
|
+
handle(cmd, rec, ctx) {
|
|
42
|
+
if (typeof cmd.id !== "string" || !cmd.id.length) return undefined;
|
|
43
|
+
const reply = (extra) => rec.ws.send(ctx.enc({ type: cmd.type, id: cmd.id, ...extra }));
|
|
44
|
+
if (rec.role === "mcp") {
|
|
45
|
+
audit?.("mcp-refused", { reason: `agent asked for ${cmd.type}` });
|
|
46
|
+
reply({ error: AGENT_WHY[cmd.type] });
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
if (!PAGE_ONLY_CLASSES.includes(rec.class)) {
|
|
50
|
+
audit?.("settings-refused", { reason: "class", detail: cmd.type, class: rec.class || "unknown" });
|
|
51
|
+
reply({ ok: false, reason: "class", error: "Only the notebook page may read or change CarmaR's settings." });
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
// An unauthenticated shared kernel has no "the user" whose home directory
|
|
55
|
+
// this is, so there is nobody to save a preference for.
|
|
56
|
+
if (ctx.deployment.loopback !== true && !(rec.user || "").length) {
|
|
57
|
+
audit?.("settings-refused", { reason: "posture", detail: cmd.type });
|
|
58
|
+
reply({ ok: false, reason: "posture", error: "This kernel serves unauthenticated clients, so it keeps no per-user settings." });
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const sendState = (extra = {}) => {
|
|
63
|
+
const st = settings.state;
|
|
64
|
+
const rows = Object.keys(st.settings).map((k) => {
|
|
65
|
+
const e = st.settings[k];
|
|
66
|
+
const stored = st.file.values[k];
|
|
67
|
+
return { key: e.key, label: e.label, kind: e.kind, effect: e.effect,
|
|
68
|
+
value: e.value, source: e.source, locked: e.locked === true, writable: e.writable === true,
|
|
69
|
+
env: e.env, min: e.min ?? null, max: e.max ?? null,
|
|
70
|
+
// "the stored value differs from the one in force" — which is how a
|
|
71
|
+
// restart_r / next_launch setting shows a pending badge instead of
|
|
72
|
+
// pretending it already applied.
|
|
73
|
+
pending: stored !== undefined && stored !== e.value };
|
|
74
|
+
});
|
|
75
|
+
reply({ settings: rows, admin: st.admin,
|
|
76
|
+
file: { status: st.file.status, path: st.file.path, error: st.file.error,
|
|
77
|
+
ignored: st.file.ignored, rejected: st.file.rejected },
|
|
78
|
+
...extra });
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
if (cmd.type === "settings_get") {
|
|
82
|
+
// RE-RESOLVE, never report the cache. The file can change under a
|
|
83
|
+
// running kernel — a hand edit, another session, a restored backup —
|
|
84
|
+
// and a page told the cached value would be shown a setting the kernel
|
|
85
|
+
// is not actually using. The live ones are back in force at the same
|
|
86
|
+
// moment, because reporting a new linger while running on the old one
|
|
87
|
+
// is the lie the whole `effect` field exists to prevent (the host reads
|
|
88
|
+
// the idle clock straight off this object every tick, so there is
|
|
89
|
+
// nothing else to re-apply).
|
|
90
|
+
settings.reresolve();
|
|
91
|
+
audit?.("settings", { detail: "get", status: settings.state.file.status });
|
|
92
|
+
sendState();
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (cmd.type === "settings_reset") {
|
|
97
|
+
try { fs.unlinkSync(settingsPath()); } catch { /* already gone */ }
|
|
98
|
+
settings.reresolve();
|
|
99
|
+
audit?.("settings", { detail: "reset", ok: true });
|
|
100
|
+
sendState({ ok: true });
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// settings_set — one key per frame. A batch has to answer "three landed,
|
|
105
|
+
// one did not", and the honest answer is per-key anyway.
|
|
106
|
+
const key = typeof cmd.key === "string" ? cmd.key : "";
|
|
107
|
+
const entry = settingsEntry(key);
|
|
108
|
+
if (!entry) {
|
|
109
|
+
audit?.("settings-refused", { reason: "unknown", key });
|
|
110
|
+
reply({ ok: false, key, reason: SETTINGS_ADMIN.includes(key) ? "admin" : "unknown",
|
|
111
|
+
error: SETTINGS_ADMIN.includes(key)
|
|
112
|
+
? "That setting belongs to this deployment's administrator."
|
|
113
|
+
: "No such setting." });
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
if (settings.state.settings[key].locked === true) {
|
|
117
|
+
audit?.("settings-refused", { reason: "env", key });
|
|
118
|
+
reply({ ok: false, key, reason: "env", error: `This deployment sets ${entry.env}, so it cannot be changed here.` });
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
const status = settings.state.file.status;
|
|
122
|
+
if (status !== "ok" && status !== "missing") {
|
|
123
|
+
// Writing over a file a human hand-edited, because of one stray byte,
|
|
124
|
+
// would destroy their config. Reset is the documented way out.
|
|
125
|
+
audit?.("settings-refused", { reason: "file", key });
|
|
126
|
+
reply({ ok: false, key, reason: "file", error: settings.state.file.error });
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const values = { ...settings.state.file.values };
|
|
131
|
+
if (cmd.value === null || cmd.value === undefined) {
|
|
132
|
+
delete values[key]; // unset: back to env/default
|
|
133
|
+
} else {
|
|
134
|
+
const verdict = validateSetting(key, cmd.value);
|
|
135
|
+
if (!verdict.ok) {
|
|
136
|
+
// Refused, never coerced, and the file is NOT written — the ai-key
|
|
137
|
+
// scar, where a payload of the wrong type fell through to "" and
|
|
138
|
+
// deleted the key while replying ok:true.
|
|
139
|
+
audit?.("settings-refused", { reason: "value", key });
|
|
140
|
+
reply({ ok: false, key, reason: entry.kind === "number" ? "range" : "type",
|
|
141
|
+
error: `${entry.label} ${verdict.reason}.` });
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
values[key] = verdict.value;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (!writeSettingsFile(values)) {
|
|
148
|
+
audit?.("settings", { detail: "set", key, ok: false });
|
|
149
|
+
reply({ ok: false, key, reason: "write", error: "The settings file could not be written." });
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
settings.reresolve();
|
|
153
|
+
// A `live` setting is in force the moment the resolve lands: the idle
|
|
154
|
+
// clock and the console history both read this object per use, exactly as
|
|
155
|
+
// serve.R re-reads its two globals. `quarto_path` is live because the job
|
|
156
|
+
// child is spawned per job.
|
|
157
|
+
const applied = entry.effect === "live"
|
|
158
|
+
&& ["linger_seconds", "history_enabled", "quarto_path"].includes(key);
|
|
159
|
+
audit?.("settings", { detail: "set", key, ok: true });
|
|
160
|
+
sendState({ ok: true, key, effect: entry.effect, applied, pending: !applied });
|
|
161
|
+
return undefined;
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|