beatrina 0.8.7 → 0.9.43
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/NOTICES +1 -1
- package/README.md +6 -0
- package/beatrina_V0.9.43.html +1885 -0
- package/beatrina_V0.9.43.html.inputs.json +1 -0
- package/bin/beatrina.mjs +152 -24
- package/bin/browser.mjs +31 -0
- package/bin/cli.mjs +64 -5
- package/bin/doctor-rows.mjs +31 -0
- package/bin/failsafe.mjs +8 -3
- package/bin/finder.mjs +74 -0
- package/bin/lsquery.swift +55 -0
- package/bin/pages.mjs +81 -0
- package/bin/python-setup.mjs +207 -0
- package/bin/runtime-dirs.mjs +27 -0
- package/bin/sessions.mjs +30 -10
- package/bin/shortcut.mjs +250 -42
- package/bin/update-check.mjs +2 -2
- package/build-info.json +1 -1
- package/engines/js/worker.mjs +6 -3
- package/engines/python/adapter.py +14 -13
- package/engines/python/analyze.py +4 -2
- package/engines/python/bootstrap.py +32 -31
- package/engines/python/debugger.py +10 -6
- package/engines/python/engine.json +1 -1
- package/engines/python/handoff.py +4 -2
- package/engines/python/worker.py +39 -10
- package/engines/r/engine.json +1 -1
- package/engines/r/handoff.R +4 -3
- package/failsafe/ai-policy.R +16 -16
- package/failsafe/ai-store.R +6 -6
- package/failsafe/cite.R +11 -11
- package/failsafe/journal.R +11 -11
- package/failsafe/plugins.R +27 -27
- package/failsafe/serve.R +241 -241
- package/host/ai-authority.mjs +55 -0
- package/host/ai-policy.mjs +48 -26
- package/host/bundle.mjs +194 -0
- package/host/deployment.mjs +25 -18
- package/host/engine-js.mjs +12 -17
- package/host/engine-pool.mjs +48 -6
- package/host/engine-python.mjs +80 -31
- package/host/engine-r.mjs +20 -21
- package/host/engine-stdio.mjs +35 -10
- package/host/env-names.mjs +148 -0
- package/host/gateway-token.mjs +51 -0
- package/host/journal-store.mjs +10 -9
- package/host/main.mjs +193 -77
- package/host/payload.mjs +131 -0
- package/host/planes/README.md +1 -1
- package/host/planes/ai-store.mjs +6 -5
- package/host/planes/ai.mjs +49 -29
- package/host/planes/analyze.mjs +6 -5
- package/host/planes/bundle.mjs +344 -0
- package/host/planes/choose.mjs +404 -0
- package/host/planes/cite.mjs +18 -17
- package/host/planes/files.mjs +0 -0
- package/host/planes/jobs.mjs +43 -20
- package/host/planes/latex.mjs +15 -5
- package/host/planes/mcp.mjs +80 -91
- package/host/planes/pair.mjs +24 -24
- package/host/planes/pipe-term.mjs +2 -2
- package/host/planes/plugins.mjs +1 -1
- package/host/planes/recent-documents.mjs +170 -0
- package/host/planes/sessions.mjs +226 -82
- package/host/planes/settings.mjs +4 -4
- package/host/planes/terminal.mjs +13 -11
- package/host/planes/test-file.mjs +2 -2
- package/host/planes/update.mjs +6 -13
- package/host/plugin-store.mjs +35 -52
- package/host/recent-documents.mjs +124 -0
- package/host/runtime-dir.mjs +47 -0
- package/host/server.mjs +75 -18
- package/host/session-keep.mjs +1 -1
- package/host/settings.mjs +85 -37
- package/host/update-record.mjs +3 -2
- package/host/user-dirs.mjs +60 -18
- package/host/which.mjs +39 -0
- package/host/windows-runtime.mjs +6 -3
- package/host/worker-plane.mjs +34 -6
- package/host/ws.mjs +9 -2
- package/host/zip.mjs +237 -0
- package/kernel/analyze.R +1 -1
- package/{check → kernel/check}/acceptance.mjs +1 -1
- package/kernel/check/knit-file.mjs +19639 -0
- package/{check → kernel/check}/session.mjs +49 -8
- package/kernel/deployment.R +20 -20
- package/kernel/examples/NOTICE.md +1 -1
- package/kernel/fileio.R +6 -6
- package/kernel/index.html +2 -2
- package/kernel/job-run.R +83 -22
- package/kernel/jobs.R +28 -12
- package/kernel/kernel-version +1 -1
- package/kernel/kernel.R +18 -18
- package/kernel/knitr-run.R +50 -9
- package/kernel/latex.R +128 -30
- package/kernel/mcp/{carmar-mcp.mjs → beatrina-mcp.mjs} +249 -59
- package/kernel/notebook-page.R +7 -7
- package/kernel/project.R +10 -10
- package/kernel/settings.R +105 -56
- package/kernel/sniff.R +4 -4
- package/kernel/typst.R +121 -0
- package/kernel/worker.R +522 -119
- package/kernel/workspace-keep.R +3 -3
- package/lib/agent-authoring-contract.js +28 -19
- package/lib/cell-kinds.js +3 -3
- package/lib/engine-labels.js +4 -4
- package/menu/Beatrina Menu.app/Contents/Info.plist +14 -0
- package/menu/Beatrina Menu.app/Contents/MacOS/Beatrina Menu +0 -0
- package/menu/Beatrina Menu.app/Contents/_CodeSignature/CodeResources +115 -0
- package/package.json +4 -3
- package/carmar_V0.8.7.html +0 -1522
package/host/engine-python.mjs
CHANGED
|
@@ -18,42 +18,74 @@ import { execFile } from "node:child_process";
|
|
|
18
18
|
import fs from "node:fs";
|
|
19
19
|
import path from "node:path";
|
|
20
20
|
import { StdioEngine } from "./engine-stdio.mjs";
|
|
21
|
+
import { envSetName, readEnv } from "./env-names.mjs";
|
|
21
22
|
import { findInterruptTool } from "./windows-runtime.mjs";
|
|
22
23
|
|
|
23
24
|
/** How long a probe of a candidate interpreter may take before it is "no". */
|
|
24
25
|
export const PROBE_TIMEOUT_MS = 6000;
|
|
25
26
|
|
|
26
27
|
/** Python's own variables that would aim a child at a different install. */
|
|
27
|
-
const STRIP_PY_ENV = /^(PYTHONHOME|PYTHONPATH|PYTHONSTARTUP|PYTHONEXECUTABLE)$/;
|
|
28
|
+
export const STRIP_PY_ENV = /^(PYTHONHOME|PYTHONPATH|PYTHONSTARTUP|PYTHONEXECUTABLE)$/;
|
|
28
29
|
|
|
29
30
|
const exists = (p) => Boolean(p) && fs.existsSync(p);
|
|
30
31
|
|
|
32
|
+
/**
|
|
33
|
+
* The managed interpreter's home: `~/.beatrina/python`, a venv that
|
|
34
|
+
* `beatrina python setup` creates (tools/npm/bin/python-setup.mjs). No older
|
|
35
|
+
* home is searched: a venv is not relocatable and nobody was set up before this
|
|
36
|
+
* name, so a machine with an older folder runs `beatrina python setup` once.
|
|
37
|
+
*/
|
|
38
|
+
export const MANAGED_PYTHON_DIRS = Object.freeze([
|
|
39
|
+
{ dir: [".beatrina", "python"], why: "the interpreter Beatrina manages (~/.beatrina/python)" },
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
/** The interpreter inside a venv directory, by platform. */
|
|
43
|
+
export function venvInterpreter(dir, platform = process.platform) {
|
|
44
|
+
return platform === "win32" ? path.win32.join(dir, "Scripts", "python.exe") : path.join(dir, "bin", "python3");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The venv directories a project keeps beside its documents, in the order they are tried. */
|
|
48
|
+
export const PROJECT_VENV_DIRS = Object.freeze([".venv", "venv"]);
|
|
49
|
+
|
|
31
50
|
/**
|
|
32
51
|
* Every interpreter worth asking, in the order the answer is preferred, each
|
|
33
52
|
* labelled with WHY it was considered. The label is what Doctor prints, so a
|
|
34
53
|
* user who gets the wrong Python can see which rung chose it.
|
|
35
54
|
*
|
|
36
|
-
* `
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
55
|
+
* `BEATRINA_PYTHON` first (an explicit answer outranks a search; the older
|
|
56
|
+
* spelling of the variable is accepted, host/env-names.mjs), then the
|
|
57
|
+
* interpreter Beatrina manages (`~/.beatrina/python`) — it outranks
|
|
58
|
+
* everything the user did not name because it is the one `beatrina python
|
|
59
|
+
* setup` built for exactly this — then the project's own venv (`.venv` or
|
|
60
|
+
* `venv` under `root`, the session's document directory), then the environment
|
|
61
|
+
* the user is already inside — an active venv, then conda — then pyenv's
|
|
62
|
+
* selected version, then PATH, then the usual installs. PATH is late for the
|
|
63
|
+
* reason `detectRscript` puts it last: a Finder-launched process has
|
|
40
64
|
* `/usr/bin:/bin:/usr/sbin:/sbin` and would miss Homebrew, conda and pyenv
|
|
41
65
|
* entirely, so a shell-launched and a Finder-launched session would silently
|
|
42
66
|
* pick different interpreters.
|
|
43
67
|
*
|
|
68
|
+
* @param {Record<string,string|undefined>} env
|
|
69
|
+
* @param {string} platform
|
|
70
|
+
* @param {{readdir: (p: string) => string[]}|null} io Windows only: a fake fs for tests
|
|
71
|
+
* @param {{root?: string}} [opts] `root`: the directory whose `.venv`/`venv` is a candidate
|
|
44
72
|
* @returns {Array<{path: string, why: string}>}
|
|
45
73
|
*/
|
|
46
|
-
export function pythonCandidates(env = process.env, platform = process.platform, io = null) {
|
|
47
|
-
if (platform === "win32") return windowsPythonCandidates(env, io);
|
|
74
|
+
export function pythonCandidates(env = process.env, platform = process.platform, io = null, { root = "" } = {}) {
|
|
75
|
+
if (platform === "win32") return windowsPythonCandidates(env, io, { root });
|
|
48
76
|
const binName = "python3";
|
|
49
77
|
const rungs = [];
|
|
50
78
|
const add = (p, why) => { if (p && !rungs.some((r) => r.path === p)) rungs.push({ path: p, why }); };
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
if (
|
|
79
|
+
const explicit = readEnv(env, "PYTHON");
|
|
80
|
+
if (explicit) add(explicit, envSetName(env, "PYTHON"));
|
|
81
|
+
if (env.HOME) {
|
|
82
|
+
for (const m of MANAGED_PYTHON_DIRS) add(venvInterpreter(path.join(env.HOME, ...m.dir), platform), m.why);
|
|
83
|
+
}
|
|
84
|
+
if (root) {
|
|
85
|
+
// The task's spelling is `.venv/bin/python`: a venv links python, python3
|
|
86
|
+
// and python3.X to the same file, so `python` is the name that is always there.
|
|
87
|
+
for (const d of PROJECT_VENV_DIRS) add(path.join(root, d, "bin", "python"), `the project's virtualenv (${path.join(root, d)})`);
|
|
88
|
+
}
|
|
57
89
|
if (env.VIRTUAL_ENV) add(path.join(env.VIRTUAL_ENV, "bin", binName), "the active virtualenv (VIRTUAL_ENV)");
|
|
58
90
|
if (env.CONDA_PREFIX) add(path.join(env.CONDA_PREFIX, "bin", binName), "the active conda environment (CONDA_PREFIX)");
|
|
59
91
|
const pyenvRoot = env.PYENV_ROOT || (env.HOME ? path.join(env.HOME, ".pyenv") : "");
|
|
@@ -74,29 +106,36 @@ export function pythonCandidates(env = process.env, platform = process.platform,
|
|
|
74
106
|
}
|
|
75
107
|
|
|
76
108
|
/**
|
|
77
|
-
* The Windows ladder. A venv keeps its interpreter in `Scripts\python.exe
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
109
|
+
* The Windows ladder. A venv keeps its interpreter in `Scripts\python.exe` —
|
|
110
|
+
* the managed `%USERPROFILE%\.beatrina\python` and a project's `.venv` are
|
|
111
|
+
* venvs, so that is where they are looked for — conda at the prefix root, python.org's
|
|
112
|
+
* installer under `%LOCALAPPDATA%\Programs\Python\Python3xx`; `python3.exe` on
|
|
113
|
+
* PATH is often the Microsoft Store STUB, which opens the Store instead of
|
|
114
|
+
* running, so the real installs come before PATH and the stub is skipped by name.
|
|
82
115
|
*/
|
|
83
|
-
export function windowsPythonCandidates(env = process.env, io = null) {
|
|
116
|
+
export function windowsPythonCandidates(env = process.env, io = null, { root = "" } = {}) {
|
|
84
117
|
const w = path.win32;
|
|
85
118
|
const readdir = io ? io.readdir : (p) => { try { return fs.readdirSync(p); } catch { return []; } };
|
|
86
119
|
const rungs = [];
|
|
87
120
|
const add = (p, why) => { if (p && !rungs.some((r) => r.path === p)) rungs.push({ path: p, why }); };
|
|
88
|
-
|
|
89
|
-
if (
|
|
121
|
+
const explicit = readEnv(env, "PYTHON");
|
|
122
|
+
if (explicit) add(explicit, envSetName(env, "PYTHON"));
|
|
123
|
+
if (env.USERPROFILE) {
|
|
124
|
+
add(w.join(env.USERPROFILE, ".beatrina", "python", "Scripts", "python.exe"), MANAGED_PYTHON_DIRS[0].why);
|
|
125
|
+
}
|
|
126
|
+
if (root) {
|
|
127
|
+
for (const d of PROJECT_VENV_DIRS) add(w.join(root, d, "Scripts", "python.exe"), `the project's virtualenv (${w.join(root, d)})`);
|
|
128
|
+
}
|
|
90
129
|
if (env.VIRTUAL_ENV) add(w.join(env.VIRTUAL_ENV, "Scripts", "python.exe"), "the active virtualenv (VIRTUAL_ENV)");
|
|
91
130
|
if (env.CONDA_PREFIX) add(w.join(env.CONDA_PREFIX, "python.exe"), "the active conda environment (CONDA_PREFIX)");
|
|
92
131
|
const versionOf = (d) => Number((String(d).match(/Python(\d+)/) || [0, 0])[1]);
|
|
93
|
-
for (const [
|
|
132
|
+
for (const [root_, why] of [
|
|
94
133
|
[env.LOCALAPPDATA ? w.join(env.LOCALAPPDATA, "Programs", "Python") : "", "python.org installer (per user)"],
|
|
95
134
|
[env.ProgramFiles || "", "python.org installer (all users)"],
|
|
96
135
|
]) {
|
|
97
|
-
if (!
|
|
98
|
-
const dirs = readdir(
|
|
99
|
-
for (const d of dirs) add(w.join(
|
|
136
|
+
if (!root_) continue;
|
|
137
|
+
const dirs = readdir(root_).filter((d) => /^Python3\d+/.test(d)).sort((a, b) => versionOf(b) - versionOf(a));
|
|
138
|
+
for (const d of dirs) add(w.join(root_, d, "python.exe"), `${why} (${d})`);
|
|
100
139
|
}
|
|
101
140
|
for (const dir of String(env.PATH || env.Path || "").split(";")) {
|
|
102
141
|
if (!dir || /\\WindowsApps\\?$/i.test(dir)) continue; // the Store stub
|
|
@@ -133,13 +172,18 @@ function probe(bin) {
|
|
|
133
172
|
* reason it was considered and what it said — a user with three Pythons and
|
|
134
173
|
* ipykernel in the wrong one can see that from the row.
|
|
135
174
|
*
|
|
175
|
+
* @param {Record<string,string|undefined>} env
|
|
176
|
+
* @param {{root?: string}} [opts] `root`: the session's document directory,
|
|
177
|
+
* whose `.venv`/`venv` is the project rung. Defaults to the working
|
|
178
|
+
* directory, which is the project when `beatrina` is run from one; the host
|
|
179
|
+
* passes the open document's directory when it knows it.
|
|
136
180
|
* @returns {Promise<{available: boolean, path: string, version: string, why: string,
|
|
137
181
|
* detail: string, searched: Array<object>}>}
|
|
138
182
|
*/
|
|
139
|
-
export async function detectPython(env = process.env) {
|
|
183
|
+
export async function detectPython(env = process.env, { root = process.cwd() } = {}) {
|
|
140
184
|
const searched = [];
|
|
141
185
|
let firstWorking = null;
|
|
142
|
-
for (const rung of pythonCandidates(env)) {
|
|
186
|
+
for (const rung of pythonCandidates(env, process.platform, null, { root })) {
|
|
143
187
|
if (!exists(rung.path)) continue;
|
|
144
188
|
const info = await probe(rung.path);
|
|
145
189
|
if (!info) {
|
|
@@ -161,10 +205,15 @@ export async function detectPython(env = process.env) {
|
|
|
161
205
|
return { available: true, ...firstWorking, detail: `Python ${firstWorking.version}`, searched };
|
|
162
206
|
}
|
|
163
207
|
const found = searched.filter((s) => s.version);
|
|
208
|
+
// The two phrases "but ipykernel is not" and "No Python interpreter" are
|
|
209
|
+
// read by tools/run-test-suite.mjs (--require-python): a suite that skips
|
|
210
|
+
// with this detail is a Python skip. Change them there too.
|
|
164
211
|
const detail = found.length
|
|
165
212
|
? `Python is installed (${found.map((s) => `${s.version} at ${s.path}`).join("; ")}) but ipykernel is not. `
|
|
166
|
-
+ "
|
|
167
|
-
|
|
213
|
+
+ "Run `beatrina python setup` to create an interpreter Beatrina manages, install it with "
|
|
214
|
+
+ "`python3 -m pip install ipykernel`, or point BEATRINA_PYTHON at an interpreter that has it."
|
|
215
|
+
: "No Python interpreter was found. Run `beatrina python setup` after installing Python 3 from "
|
|
216
|
+
+ "https://www.python.org/downloads/, or set BEATRINA_PYTHON to an interpreter that has ipykernel.";
|
|
168
217
|
return { available: false, path: "", version: "", why: "", detail, searched };
|
|
169
218
|
}
|
|
170
219
|
|
|
@@ -182,7 +231,7 @@ export class PythonEngine extends StdioEngine {
|
|
|
182
231
|
super({ language: "python", workerPath, env, cwd, platform });
|
|
183
232
|
if (platform === "win32") this.interruptTool = findInterruptTool({ env: process.env });
|
|
184
233
|
if (!workerPath || !fs.existsSync(workerPath)) throw new Error(`PythonEngine: no worker at ${workerPath}`);
|
|
185
|
-
if (!python) throw new Error("PythonEngine: no Python found (set
|
|
234
|
+
if (!python) throw new Error("PythonEngine: no Python found (run `beatrina python setup`, or set BEATRINA_PYTHON)");
|
|
186
235
|
this.python = python;
|
|
187
236
|
this.kernelName = kernelName;
|
|
188
237
|
}
|
|
@@ -212,7 +261,7 @@ export class PythonEngine extends StdioEngine {
|
|
|
212
261
|
return {
|
|
213
262
|
bin: this.python,
|
|
214
263
|
args: [path.resolve(this.workerPath)],
|
|
215
|
-
env: {
|
|
264
|
+
env: { BEATRINA_WORKER_MODE: "interactive", BEATRINA_PYTHON_KERNEL: this.kernelName },
|
|
216
265
|
};
|
|
217
266
|
}
|
|
218
267
|
|
package/host/engine-r.mjs
CHANGED
|
@@ -17,27 +17,21 @@
|
|
|
17
17
|
// here: finding an Rscript, the interactive `R` binary beside it, the batch
|
|
18
18
|
// fallback, the `sys.source` boot line, the R_* environment strip, and the two
|
|
19
19
|
// capabilities (R has a readline AND a debugger; Python has only the first).
|
|
20
|
-
// `LineFramer`, `
|
|
21
|
-
//
|
|
20
|
+
// The re-exports this file used to carry for `LineFramer`, `MAX_CONSOLE_LINE`
|
|
21
|
+
// and `workerEnvironment` are gone: the migration they eased finished, nothing
|
|
22
|
+
// imported them from here, and the two suites that want the framer take it from
|
|
23
|
+
// host/engine-stdio.mjs, its real home.
|
|
22
24
|
|
|
23
25
|
import fs from "node:fs";
|
|
24
26
|
import path from "node:path";
|
|
25
27
|
import { fileURLToPath } from "node:url";
|
|
26
|
-
import {
|
|
28
|
+
import { StdioEngine } from "./engine-stdio.mjs";
|
|
29
|
+
import { mirrorEnv, readEnv } from "./env-names.mjs";
|
|
27
30
|
import { RTERM_INTERACTIVE_ARGS, detectRterm, detectWindowsRscript, findInterruptTool } from "./windows-runtime.mjs";
|
|
28
31
|
|
|
29
|
-
export { LineFramer, MAX_CONSOLE_LINE };
|
|
30
|
-
|
|
31
32
|
/** The R_* variables a user's shell may carry that would aim R elsewhere. */
|
|
32
33
|
const STRIP_R_ENV = /^R_(HOME|LIBS|LIBS_USER|LIBS_SITE|PROFILE|ENVIRON|DOC_DIR|INCLUDE_DIR|SHARE_DIR)$/;
|
|
33
34
|
|
|
34
|
-
/** Kept for callers that built a worker environment by hand. */
|
|
35
|
-
export function workerEnvironment({ sentinel, cmdtag, workerDir, extra = {}, base = process.env }) {
|
|
36
|
-
return engineEnvironment({
|
|
37
|
-
sentinel, cmdtag, workerDir, base, strip: STRIP_R_ENV,
|
|
38
|
-
extra: { CARMAR_WORKER_MODE: "interactive", ...extra },
|
|
39
|
-
});
|
|
40
|
-
}
|
|
41
35
|
|
|
42
36
|
const KNOWN_RSCRIPT = [
|
|
43
37
|
"/Library/Frameworks/R.framework/Versions/Current/Resources/bin/Rscript",
|
|
@@ -48,14 +42,18 @@ const KNOWN_RSCRIPT = [
|
|
|
48
42
|
];
|
|
49
43
|
|
|
50
44
|
/**
|
|
51
|
-
* Which R should host the session? An explicit `
|
|
45
|
+
* Which R should host the session? An explicit `BEATRINA_RSCRIPT`
|
|
46
|
+
* (beatrina-names: keep-start — or the old `CARMAR_RSCRIPT`, named here on
|
|
47
|
+
* purpose: the rename rewrote the legacy spelling into the current one and
|
|
48
|
+
* left this sentence offering a variable as its own alternative.
|
|
49
|
+
* beatrina-names: keep-end), then the
|
|
52
50
|
* macOS framework R, then PATH — PATH last on purpose: a Finder-launched
|
|
53
51
|
* process has `/usr/bin:/bin:/usr/sbin:/sbin` and would miss Homebrew, rig,
|
|
54
52
|
* conda and Posit entirely.
|
|
55
53
|
* @param {string} [explicit]
|
|
56
54
|
* @returns {string} path to an Rscript binary, or ""
|
|
57
55
|
*/
|
|
58
|
-
export function detectRscript(explicit = process.env
|
|
56
|
+
export function detectRscript(explicit = readEnv(process.env, "RSCRIPT"), platform = process.platform) {
|
|
59
57
|
if (explicit && fs.existsSync(explicit)) return explicit;
|
|
60
58
|
// Windows R is not on PATH after a default install; its ladder is its own.
|
|
61
59
|
if (platform === "win32") return detectWindowsRscript(process.env);
|
|
@@ -85,7 +83,7 @@ export function detectRBinary(rscript) {
|
|
|
85
83
|
/** readline's screen width for the interactive worker (see spawnPlan). */
|
|
86
84
|
export const READLINE_COLUMNS = 100000;
|
|
87
85
|
/** First on the boot line: give user code back the COLUMNS it started with (or none). */
|
|
88
|
-
export const R_WIDTH_BOOT = 'local({ cols <- Sys.getenv("
|
|
86
|
+
export const R_WIDTH_BOOT = 'local({ cols <- Sys.getenv("BEATRINA_USER_COLUMNS"); Sys.unsetenv("BEATRINA_USER_COLUMNS"); if (nzchar(cols)) Sys.setenv(COLUMNS = cols) else Sys.unsetenv("COLUMNS") }); ';
|
|
89
87
|
|
|
90
88
|
export class REngine extends StdioEngine {
|
|
91
89
|
/**
|
|
@@ -108,15 +106,15 @@ export class REngine extends StdioEngine {
|
|
|
108
106
|
// binary has no module path to derive one from (host/main.mjs, moduleDir).
|
|
109
107
|
this.handoffPath = handoffScript;
|
|
110
108
|
if (!workerPath || !fs.existsSync(workerPath)) throw new Error(`REngine: no worker at ${workerPath}`);
|
|
111
|
-
if (!rscript) throw new Error("REngine: no Rscript found (set
|
|
109
|
+
if (!rscript) throw new Error("REngine: no Rscript found (set BEATRINA_RSCRIPT)");
|
|
112
110
|
this.rscript = rscript;
|
|
113
111
|
this.userColumns = processEnv.COLUMNS == null ? "" : String(processEnv.COLUMNS);
|
|
114
112
|
// Windows: Rterm.exe --ess is the interactive worker (host/windows-runtime.mjs).
|
|
115
|
-
//
|
|
113
|
+
// BEATRINA_WIN_BATCH=1 keeps the old batch worker, as an escape hatch should
|
|
116
114
|
// some R build refuse --ess with a piped stdin.
|
|
117
115
|
if (platform === "win32") {
|
|
118
116
|
const winIo = io || undefined;
|
|
119
|
-
this.rBinary = processEnv
|
|
117
|
+
this.rBinary = readEnv(processEnv, "WIN_BATCH") === "1" ? "" : detectRterm(rscript, winIo);
|
|
120
118
|
this.interruptTool = findInterruptTool({ env: processEnv, rscript, io: winIo });
|
|
121
119
|
} else {
|
|
122
120
|
this.rBinary = detectRBinary(rscript);
|
|
@@ -136,7 +134,7 @@ export class REngine extends StdioEngine {
|
|
|
136
134
|
|
|
137
135
|
spawnPlan() {
|
|
138
136
|
if (this.mode === "interactive" && this.platform === "win32") {
|
|
139
|
-
return { bin: this.rBinary, args: [...RTERM_INTERACTIVE_ARGS], env: {
|
|
137
|
+
return { bin: this.rBinary, args: [...RTERM_INTERACTIVE_ARGS], env: mirrorEnv({ BEATRINA_WORKER_MODE: "interactive" }) };
|
|
140
138
|
}
|
|
141
139
|
if (this.mode === "interactive") {
|
|
142
140
|
return {
|
|
@@ -156,13 +154,14 @@ export class REngine extends StdioEngine {
|
|
|
156
154
|
// and a Stop at an idle prompt left the stream in error — test:aicoding
|
|
157
155
|
// and test:sessionrecovery hung. test/r-echo.test.mjs pins both.
|
|
158
156
|
args: ["--interactive", "--no-echo", "--no-save", "--no-restore", "--no-site-file"],
|
|
159
|
-
|
|
157
|
+
// Both spellings on purpose (mirrorEnv): R_WIDTH_BOOT reads BEATRINA_USER_COLUMNS.
|
|
158
|
+
env: mirrorEnv({ BEATRINA_WORKER_MODE: "interactive", COLUMNS: String(READLINE_COLUMNS), BEATRINA_USER_COLUMNS: this.userColumns }),
|
|
160
159
|
};
|
|
161
160
|
}
|
|
162
161
|
return {
|
|
163
162
|
bin: this.rscript,
|
|
164
163
|
args: ["--no-save", "--no-restore", "--no-site-file", this.workerPath, this.sentinel],
|
|
165
|
-
env: {
|
|
164
|
+
env: mirrorEnv({ BEATRINA_WORKER_MODE: "batch" }),
|
|
166
165
|
};
|
|
167
166
|
}
|
|
168
167
|
|
package/host/engine-stdio.mjs
CHANGED
|
@@ -40,6 +40,7 @@ import fs from "node:fs";
|
|
|
40
40
|
import os from "node:os";
|
|
41
41
|
import path from "node:path";
|
|
42
42
|
import { NO_INTERRUPT_TOOL, windowsInterruptPlan, windowsKillPlan } from "./windows-runtime.mjs";
|
|
43
|
+
import { mirrorEnv } from "./env-names.mjs";
|
|
43
44
|
|
|
44
45
|
export const MAX_CONSOLE_LINE = 32000;
|
|
45
46
|
/** The gap between the group SIGINT and the pid SIGINT: the measured lifetime of the spawned
|
|
@@ -58,6 +59,11 @@ export const token = () => {
|
|
|
58
59
|
* The child's environment: a UTF-8 LC_CTYPE guaranteed, a dumb terminal, and
|
|
59
60
|
* the worker's two tokens. `strip` removes variables a user's shell may carry
|
|
60
61
|
* that would aim the child at a different install of its own runtime.
|
|
62
|
+
*
|
|
63
|
+
* Every `BEATRINA_*` variable goes out under its `CARMAR_*` spelling as well
|
|
64
|
+
* (host/env-names.mjs mirrorEnv): spike/worker.R, job-run.R and analyze.R are
|
|
65
|
+
* upstream code that reads the old names, and a user's own Python or Node may
|
|
66
|
+
* be older than this host.
|
|
61
67
|
*/
|
|
62
68
|
export function engineEnvironment({ sentinel, cmdtag, workerDir, extra = {}, base = process.env, strip = null }) {
|
|
63
69
|
const env = {};
|
|
@@ -75,10 +81,10 @@ export function engineEnvironment({ sentinel, cmdtag, workerDir, extra = {}, bas
|
|
|
75
81
|
// TERM=dumb: readline's terminal probe writes an escape sequence to stdout
|
|
76
82
|
// before anything else, and a dumb terminal keeps it out of the stream.
|
|
77
83
|
env.TERM = "dumb";
|
|
78
|
-
env.
|
|
79
|
-
env.
|
|
80
|
-
env.
|
|
81
|
-
return env;
|
|
84
|
+
env.BEATRINA_SENTINEL = sentinel;
|
|
85
|
+
env.BEATRINA_CMD_TAG = cmdtag;
|
|
86
|
+
env.BEATRINA_WORKER_DIR = workerDir;
|
|
87
|
+
return mirrorEnv(env);
|
|
82
88
|
}
|
|
83
89
|
|
|
84
90
|
/**
|
|
@@ -181,8 +187,8 @@ export class StdioEngine extends EventEmitter {
|
|
|
181
187
|
}
|
|
182
188
|
|
|
183
189
|
/**
|
|
184
|
-
* Does the worker read a stop flag between top-level expressions (
|
|
185
|
-
* (spike/worker.R `
|
|
190
|
+
* Does the worker read a stop flag between top-level expressions (BEATRINA_STOP_DIR)? R's does
|
|
191
|
+
* (spike/worker.R `beatrina_stop_requested`), because a signal cannot always reach R: libc's
|
|
186
192
|
* system() ignores SIGINT in R while its child runs, and whether the signal the host sends after
|
|
187
193
|
* the child dies lands before or after R walks on to the next line is a race — on Linux it lost
|
|
188
194
|
* every time (`beatrina check` on .39, 2026-09-16: 0/3 Stops of system("sleep 60")). The flag is
|
|
@@ -229,7 +235,7 @@ export class StdioEngine extends EventEmitter {
|
|
|
229
235
|
}
|
|
230
236
|
const env = engineEnvironment({
|
|
231
237
|
sentinel: this.sentinel, cmdtag: this.cmdtag, workerDir,
|
|
232
|
-
extra: { ...this.extraEnv, ...(plan.env || {}), ...(this.stopDir ? {
|
|
238
|
+
extra: { ...this.extraEnv, ...(plan.env || {}), ...(this.stopDir ? { BEATRINA_STOP_DIR: this.stopDir } : {}) }, strip: this.stripEnv,
|
|
233
239
|
});
|
|
234
240
|
// `detached`: the worker leads its OWN process group, so an interrupt can
|
|
235
241
|
// be sent to the group and reach a child under system() too — a terminal's
|
|
@@ -253,8 +259,14 @@ export class StdioEngine extends EventEmitter {
|
|
|
253
259
|
this.proc.stdout.on("data", (d) => out.feed(d).forEach((e) => this.emit("event", e)));
|
|
254
260
|
this.proc.stderr.on("data", (d) => err.feed(d).forEach((e) => this.emit("event", e)));
|
|
255
261
|
this.proc.stdin.on("error", () => { /* a dead worker's pipe: the exit event reports it */ });
|
|
256
|
-
|
|
257
|
-
|
|
262
|
+
// A failed spawn emits `error` and `close`, but no `exit`. Give the plane
|
|
263
|
+
// the same terminal event and cleanup in either case, exactly once.
|
|
264
|
+
let finished = false;
|
|
265
|
+
let spawned = false;
|
|
266
|
+
this.proc.once("spawn", () => { spawned = true; });
|
|
267
|
+
const finish = (code, signal) => {
|
|
268
|
+
if (finished) return;
|
|
269
|
+
finished = true;
|
|
258
270
|
this.alive = false;
|
|
259
271
|
out.feed("", { flush: true }).forEach((e) => this.emit("event", e));
|
|
260
272
|
err.feed("", { flush: true }).forEach((e) => this.emit("event", e));
|
|
@@ -262,7 +274,20 @@ export class StdioEngine extends EventEmitter {
|
|
|
262
274
|
this.spilled.clear();
|
|
263
275
|
if (this.stopDir) { try { fs.rmSync(this.stopDir, { recursive: true, force: true }); } catch { /* gone */ } this.stopDir = null; }
|
|
264
276
|
this.emit("exit", { code, signal });
|
|
277
|
+
};
|
|
278
|
+
this.proc.on("error", (e) => {
|
|
279
|
+
if (finished) return;
|
|
280
|
+
// ChildProcess also emits error when kill/send fails after a successful
|
|
281
|
+
// spawn. That says nothing about whether the worker has exited.
|
|
282
|
+
if (spawned) {
|
|
283
|
+
this.emit("event", { type: "stderr", text: `${this.label} process control failed: ${e.message}` });
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
this.alive = false;
|
|
287
|
+
this.emit("spawn-error", e);
|
|
288
|
+
finish(null, null);
|
|
265
289
|
});
|
|
290
|
+
this.proc.on("exit", finish);
|
|
266
291
|
this.afterSpawn();
|
|
267
292
|
return Promise.resolve(this);
|
|
268
293
|
}
|
|
@@ -288,7 +313,7 @@ export class StdioEngine extends EventEmitter {
|
|
|
288
313
|
if (cmd.type === "exec") this.interruptRefusalSaid = false;
|
|
289
314
|
let json = JSON.stringify(cmd);
|
|
290
315
|
if (Buffer.byteLength(json) > MAX_CONSOLE_LINE) {
|
|
291
|
-
const spill = path.join(os.tmpdir(), `
|
|
316
|
+
const spill = path.join(os.tmpdir(), `beatrina-cmd-${crypto.randomBytes(8).toString("hex")}.json`);
|
|
292
317
|
fs.writeFileSync(spill, json, { mode: 0o600 });
|
|
293
318
|
this.spilled.add(spill);
|
|
294
319
|
json = JSON.stringify({ type: "cmdfile", path: spill });
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// env-names.mjs — one place that knows the product's environment-variable names.
|
|
2
|
+
// beatrina-names: keep-file — this module IS the reader of the older spelling.
|
|
3
|
+
//
|
|
4
|
+
// The product is Beatrina. Its environment variables are `BEATRINA_<NAME>`.
|
|
5
|
+
// Every one of them was `CARMAR_<NAME>` until 2026-09-16, and that spelling is
|
|
6
|
+
// still ACCEPTED — launchers, CI, the R package and users' shells set it — so
|
|
7
|
+
// reading a variable means asking for both, new name first. Nothing in the
|
|
8
|
+
// host reads `process.env.CARMAR_…` or `process.env.BEATRINA_…` directly;
|
|
9
|
+
// it calls `readEnv(process.env, "PORT")` and gets whichever is set.
|
|
10
|
+
//
|
|
11
|
+
// Children (engines, jobs, the terminal, a spawned kernel under test) may be
|
|
12
|
+
// older code that knows only the old name, so an environment handed to a child
|
|
13
|
+
// is passed through `mirrorEnv()`, which sets each name under both prefixes.
|
|
14
|
+
// When the last reader of `CARMAR_` is gone, `LEGACY_PREFIX` goes with it and
|
|
15
|
+
// `mirrorEnv` becomes the identity.
|
|
16
|
+
|
|
17
|
+
export const PREFIX = "BEATRINA_";
|
|
18
|
+
export const LEGACY_PREFIX = "CARMAR_";
|
|
19
|
+
|
|
20
|
+
/** The canonical variable name for a suffix: envName("PORT") → "BEATRINA_PORT". */
|
|
21
|
+
export function envName(suffix) {
|
|
22
|
+
return PREFIX + suffix;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The names a reader accepts for a suffix, canonical first. */
|
|
26
|
+
export function envNames(suffix) {
|
|
27
|
+
return [PREFIX + suffix, LEGACY_PREFIX + suffix];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Read a variable under either prefix, canonical first.
|
|
32
|
+
* @param {Record<string, string|undefined>} source usually process.env
|
|
33
|
+
* @param {string} suffix the name without its prefix, e.g. "RUNTIME_DIR"
|
|
34
|
+
* @param {string} unset what an absent variable reads as
|
|
35
|
+
* @returns {string}
|
|
36
|
+
*/
|
|
37
|
+
export function readEnv(source, suffix, unset = "") {
|
|
38
|
+
for (const name of envNames(suffix)) {
|
|
39
|
+
const v = valueOf(source, name);
|
|
40
|
+
if (v != null) return String(v);
|
|
41
|
+
}
|
|
42
|
+
return unset;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** True when the variable is set under either prefix (even to ""). */
|
|
46
|
+
export function hasEnv(source, suffix) {
|
|
47
|
+
return envNames(suffix).some((name) => valueOf(source, name) != null);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The spelling that is actually set, for a message that names a switch:
|
|
52
|
+
* `envSetName(env, "NO_TERMINAL")` is "CARMAR_NO_TERMINAL" on a launcher that
|
|
53
|
+
* still sets the old name and "BEATRINA_NO_TERMINAL" otherwise (also when
|
|
54
|
+
* neither is set — the canonical name is the one to document).
|
|
55
|
+
*/
|
|
56
|
+
export function envSetName(source, suffix) {
|
|
57
|
+
return envNames(suffix).find((name) => valueOf(source, name) != null) || envName(suffix);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Read a FULL variable name the way the host's `Sys.getenv`-style getters do:
|
|
62
|
+
* a name under either prefix is read under both (canonical first); any other
|
|
63
|
+
* name (`PATH`, `BEATRIX_APP_ID`, `ProgramData`) is read as it is. This is
|
|
64
|
+
* what lets every `env("CARMAR_PORT")` call site keep its spelling while a
|
|
65
|
+
* launcher that sets `BEATRINA_PORT` is honoured.
|
|
66
|
+
*/
|
|
67
|
+
export function readEnvName(source, name, unset = "") {
|
|
68
|
+
const suffix = suffixOf(name);
|
|
69
|
+
if (suffix != null) return readEnv(source, suffix, unset);
|
|
70
|
+
const v = valueOf(source, name);
|
|
71
|
+
return v == null ? unset : String(v);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A `(name, unset = "") => string` getter over `source`, dual-read (readEnvName).
|
|
76
|
+
* It carries `source` so that a helper handed the GETTER (the planes get one)
|
|
77
|
+
* can still see which spelling is really set: asked for `BEATRINA_X`, the
|
|
78
|
+
* getter answers from `CARMAR_X` too, which is right for a read and wrong for
|
|
79
|
+
* `envSetName`.
|
|
80
|
+
*/
|
|
81
|
+
export function envReader(source = process.env) {
|
|
82
|
+
const reader = (name, unset = "") => readEnvName(source, name, unset);
|
|
83
|
+
reader.source = source;
|
|
84
|
+
return reader;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Set a variable under BOTH prefixes on `target` (an environment object). */
|
|
88
|
+
export function setEnv(target, suffix, value) {
|
|
89
|
+
for (const name of envNames(suffix)) target[name] = String(value);
|
|
90
|
+
return target;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Set a FULL name on `target`; a prefixed name is set under both prefixes. */
|
|
94
|
+
export function setEnvName(target, name, value) {
|
|
95
|
+
const suffix = suffixOf(name);
|
|
96
|
+
if (suffix != null) return setEnv(target, suffix, value);
|
|
97
|
+
target[name] = String(value);
|
|
98
|
+
return target;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Remove a variable under BOTH prefixes from `target`. */
|
|
102
|
+
export function unsetEnv(target, suffix) {
|
|
103
|
+
for (const name of envNames(suffix)) delete target[name];
|
|
104
|
+
return target;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The suffix of a name under either prefix, or null for any other name. */
|
|
108
|
+
export function suffixOf(name) {
|
|
109
|
+
const n = String(name);
|
|
110
|
+
if (n.startsWith(PREFIX)) return n.slice(PREFIX.length);
|
|
111
|
+
if (n.startsWith(LEGACY_PREFIX)) return n.slice(LEGACY_PREFIX.length);
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// A source is an environment OBJECT (process.env, a child's env) or a
|
|
116
|
+
// `Sys.getenv`-style GETTER (what the planes are handed); a getter answers ""
|
|
117
|
+
// for an unset name, so "" from a getter is read as unset.
|
|
118
|
+
function valueOf(source, name) {
|
|
119
|
+
if (!source) return undefined;
|
|
120
|
+
if (typeof source === "function" && source.source) return valueOf(source.source, name);
|
|
121
|
+
if (typeof source === "function") {
|
|
122
|
+
const v = source(name, undefined);
|
|
123
|
+
return v == null || v === "" ? undefined : v;
|
|
124
|
+
}
|
|
125
|
+
return source[name];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* A copy of `source` in which every `BEATRINA_X` is also present as `CARMAR_X`
|
|
130
|
+
* and vice versa, the canonical spelling winning when both are set and differ.
|
|
131
|
+
* For environments handed to child processes.
|
|
132
|
+
* @param {Record<string, string|undefined>} source
|
|
133
|
+
* @returns {Record<string, string>}
|
|
134
|
+
*/
|
|
135
|
+
export function mirrorEnv(source) {
|
|
136
|
+
const out = {};
|
|
137
|
+
for (const [k, v] of Object.entries(source || {})) if (v != null) out[k] = String(v);
|
|
138
|
+
for (const [k, v] of Object.entries(source || {})) {
|
|
139
|
+
if (v == null) continue;
|
|
140
|
+
if (k.startsWith(PREFIX)) out[LEGACY_PREFIX + k.slice(PREFIX.length)] = String(v);
|
|
141
|
+
}
|
|
142
|
+
for (const [k, v] of Object.entries(source || {})) {
|
|
143
|
+
if (v == null || !k.startsWith(LEGACY_PREFIX)) continue;
|
|
144
|
+
const canonical = PREFIX + k.slice(LEGACY_PREFIX.length);
|
|
145
|
+
if (out[canonical] == null) out[canonical] = String(v);
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
|
|
4
|
+
export const GATEWAY_TOKEN_HEADER = "x-beatrina-gateway-token";
|
|
5
|
+
export const GATEWAY_MANAGED_COMMANDS = Object.freeze(["session-new", "session_upgrade", "session_restart", "update_action"]);
|
|
6
|
+
export const GATEWAY_MANAGED_PATHS = Object.freeze(["/session/upgrade", "/session/restart", "/shutdown", "/pair", "/pair/approve", "/published/authorize"]);
|
|
7
|
+
export const GATEWAY_HIDDEN_CAPABILITIES = Object.freeze(["session-restart-v1", "published-direct-v1", "published-pairing-v3", "signed-updates-v1"]);
|
|
8
|
+
|
|
9
|
+
/** Gateway sessions are launched and upgraded exclusively by their broker. */
|
|
10
|
+
export function gatewayCommands(commands, enabled) {
|
|
11
|
+
return enabled ? commands.filter((command) => !GATEWAY_MANAGED_COMMANDS.includes(command)) : commands;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Validate a gateway credential without ever including its value in errors. */
|
|
15
|
+
export function validateGatewayToken(token) {
|
|
16
|
+
if (typeof token !== "string" || !/^(?:[a-fA-F0-9]{2}){32,256}$/.test(token)) {
|
|
17
|
+
throw new Error("Gateway token must contain 32–256 bytes encoded as hexadecimal.");
|
|
18
|
+
}
|
|
19
|
+
return token;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Read the gateway-only credential from a private regular file at startup. */
|
|
23
|
+
export function loadGatewayToken(file) {
|
|
24
|
+
if (file === undefined || file === null) return undefined;
|
|
25
|
+
if (typeof file !== "string" || !file.trim()) throw new Error("Gateway token file path is empty.");
|
|
26
|
+
let fd;
|
|
27
|
+
try {
|
|
28
|
+
fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0) | fs.constants.O_NONBLOCK);
|
|
29
|
+
const stat = fs.fstatSync(fd);
|
|
30
|
+
if (!stat.isFile() || stat.size > 1024 || (process.platform !== "win32" && (stat.mode & 0o077))) {
|
|
31
|
+
throw new Error("unsafe gateway token file");
|
|
32
|
+
}
|
|
33
|
+
return validateGatewayToken(fs.readFileSync(fd, "utf8").trim());
|
|
34
|
+
} catch {
|
|
35
|
+
throw new Error("Cannot load gateway token: use a private regular file containing 32–256 bytes encoded as hexadecimal.");
|
|
36
|
+
} finally {
|
|
37
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Build a constant-time credential check; only absent configuration disables it. */
|
|
42
|
+
export function gatewayTokenGate(token) {
|
|
43
|
+
if (token === undefined) return () => true;
|
|
44
|
+
const expected = crypto.createHash("sha256").update(validateGatewayToken(token)).digest();
|
|
45
|
+
return (req) => {
|
|
46
|
+
const supplied = req.headers[GATEWAY_TOKEN_HEADER];
|
|
47
|
+
if (typeof supplied !== "string" || supplied.length > 512) return false;
|
|
48
|
+
const actual = crypto.createHash("sha256").update(supplied).digest();
|
|
49
|
+
return crypto.timingSafeEqual(expected, actual);
|
|
50
|
+
};
|
|
51
|
+
}
|