beatrina 0.8.6 → 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 +156 -26
- 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 +271 -35
- 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 +364 -239
- package/failsafe/session-documents.R +104 -0
- 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 +16 -17
- package/host/engine-pool.mjs +56 -8
- package/host/engine-python.mjs +80 -31
- package/host/engine-r.mjs +38 -32
- package/host/engine-stdio.mjs +45 -12
- 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 +216 -76
- 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 +41 -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 +230 -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 +123 -21
- package/host/session-keep.mjs +70 -0
- 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 +80 -7
- 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 +24 -21
- package/kernel/knitr-run.R +50 -9
- package/kernel/latex.R +429 -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 +602 -123
- package/kernel/workspace-keep.R +188 -0
- 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.6.html +0 -1310
package/host/server.mjs
CHANGED
|
@@ -30,13 +30,18 @@ import http from "node:http";
|
|
|
30
30
|
import path from "node:path";
|
|
31
31
|
import { attachWebSocket } from "./ws.mjs";
|
|
32
32
|
import { rookOf, proxyUser } from "./deployment.mjs";
|
|
33
|
+
import { cleanSessionDocuments } from "./session-keep.mjs";
|
|
34
|
+
import { readEnv } from "./env-names.mjs";
|
|
35
|
+
import { gatewayCommands, gatewayTokenGate, GATEWAY_MANAGED_COMMANDS, GATEWAY_MANAGED_PATHS, GATEWAY_HIDDEN_CAPABILITIES } from "./gateway-token.mjs";
|
|
33
36
|
|
|
34
|
-
export const FORWARDED = Object.freeze(["env", "obj", "struct", "view", "colstats", "rm", "packages", "doctor",
|
|
37
|
+
export const FORWARDED = Object.freeze(["env", "obj", "struct", "view", "colstats", "column_values", "rm", "packages", "doctor",
|
|
35
38
|
"package_action", "package_help", "project_status", "project_action", "help", "wd",
|
|
36
39
|
"parse", "complete", "files", "import", "readfile", "writefile", "writefiles_atomic",
|
|
37
|
-
"hover", "format", "sniff", "mkdir", "renamepath", "deletepath", "copypath", "revealpath", "debug_breaks"
|
|
38
|
-
|
|
39
|
-
|
|
40
|
+
"hover", "format", "sniff", "mkdir", "renamepath", "deletepath", "copypath", "revealpath", "debug_breaks",
|
|
41
|
+
// "Restart, keep variables", step one (spike/workspace-keep.R). Page-only in both senses: AGENT_REFUSED
|
|
42
|
+
// below and the class gate in handleFrame. Step two, `resume`, is deliberately NOT here — only the
|
|
43
|
+
// host asks it, after a restart it performed (WorkerPlane.startResume).
|
|
44
|
+
"suspend"]);
|
|
40
45
|
/** Answered by serve.R itself, not yet by this host. Refused by NAME so the page never hangs. */
|
|
41
46
|
export const NOT_YET = Object.freeze({
|
|
42
47
|
files: "the file system", journal: "the document journal", ai: "the AI conversation store",
|
|
@@ -48,12 +53,19 @@ export const NOT_YET = Object.freeze({
|
|
|
48
53
|
"open-request": "the open-file door", "page-title": "", "session-new": "session management",
|
|
49
54
|
cite_styles: "citations",
|
|
50
55
|
});
|
|
51
|
-
export const AGENT_REFUSED = Object.freeze(["exec", "interrupt", "force_stop", "restart", "debug_cmd", "input_reply", "project_action"
|
|
56
|
+
export const AGENT_REFUSED = Object.freeze(["exec", "interrupt", "force_stop", "restart", "debug_cmd", "input_reply", "project_action",
|
|
57
|
+
// A copy of every object in the session, written to disk.
|
|
58
|
+
"suspend", "suspend_cancel",
|
|
59
|
+
// The grid's clipboard door (a whole column, up to a million values); an agent reads data through a chunk.
|
|
60
|
+
"column_values"]);
|
|
52
61
|
/** Why, in the sentence serve.R uses. A plane supplies its own via `agentReason`. */
|
|
53
62
|
export const AGENT_WHY_DEFAULT = "Agents run code through notebook chunks (chunk_run), not raw exec.";
|
|
54
63
|
export const AGENT_WHY = Object.freeze({
|
|
55
64
|
input_reply: "Agents cannot answer a prompt on the user's behalf.",
|
|
56
65
|
project_action: "Agents cannot install or restore project packages.",
|
|
66
|
+
suspend: "Agents cannot keep the user's workspace.",
|
|
67
|
+
suspend_cancel: "Agents cannot keep the user's workspace.",
|
|
68
|
+
column_values: "Agents read data through notebook chunks (chunk_run), not the grid's copy door.",
|
|
57
69
|
});
|
|
58
70
|
export const PAGE_ONLY_CLASSES = Object.freeze(["served", "file", "local"]);
|
|
59
71
|
export const FILE_ORIGIN = "null";
|
|
@@ -79,9 +91,16 @@ export function secureToken(n = 32) {
|
|
|
79
91
|
return out;
|
|
80
92
|
}
|
|
81
93
|
|
|
82
|
-
/**
|
|
83
|
-
|
|
84
|
-
|
|
94
|
+
/**
|
|
95
|
+
* Stamp the served HTML with this session's identity, once per load — and, for a product other
|
|
96
|
+
* than Beatrina (`app`, BEATRIX_APP_ID: "beatrina"), with which product served it, so the page's
|
|
97
|
+
* Start R can name the door this product has (docs/launch-menu-plan.md L6). A Beatrina page is the
|
|
98
|
+
* installed file plus the ONE session meta, exactly as before (test/host-launch.test.mjs pins that
|
|
99
|
+
* byte for byte); a page opened from disk carries neither, and its Start R stays Beatrina's.
|
|
100
|
+
*/
|
|
101
|
+
export function stampSession(html, stamp, app = "") {
|
|
102
|
+
const product = String(app || "").replace(/[^A-Za-z0-9.]/g, "");
|
|
103
|
+
const meta = `<meta name="beatrina-session" content="${stamp}">${product && product !== "beatrina" ? `<meta name="beatrina-app" content="${product}">` : ""}`;
|
|
85
104
|
let m = html.match(/<head\b[^>]*>/);
|
|
86
105
|
if (!m) m = html.match(/<html\b[^>]*>/);
|
|
87
106
|
if (!m) return meta + html;
|
|
@@ -91,7 +110,7 @@ export function stampSession(html, stamp) {
|
|
|
91
110
|
|
|
92
111
|
/**
|
|
93
112
|
* @param {Object} opts
|
|
94
|
-
* @param {ReturnType<import("./deployment.mjs").
|
|
113
|
+
* @param {ReturnType<import("./deployment.mjs").beatrinaDeployment>} opts.deployment
|
|
95
114
|
* @param {import("./engine-pool.mjs").EnginePool} opts.plane the engine
|
|
96
115
|
* router; it presents one WorkerPlane's surface, so everything here reads as
|
|
97
116
|
* it did when there was one engine (WP4, docs/wp/wp4-python.md)
|
|
@@ -104,16 +123,38 @@ export function stampSession(html, stamp) {
|
|
|
104
123
|
* @param {(event: string, fields?: object) => void} opts.audit
|
|
105
124
|
* @param {() => void} opts.onShutdown
|
|
106
125
|
* @param {string} opts.here the directory holding the fallback spike page
|
|
126
|
+
* @param {string} [opts.gatewayToken] private gateway credential, required on HTTP and WS
|
|
107
127
|
*/
|
|
108
128
|
export function createHostServer(opts) {
|
|
109
129
|
const { deployment, plane, audit, onShutdown, planes = [] } = opts;
|
|
130
|
+
const gatewayOk = gatewayTokenGate(opts.gatewayToken);
|
|
131
|
+
const gatewayMode = opts.gatewayToken !== undefined;
|
|
132
|
+
const commands = gatewayCommands(planes.flatMap((pl) => pl.commands || []), gatewayMode);
|
|
133
|
+
const capabilities = planes.flatMap((pl) => pl.capabilities || []).filter((capability) => !gatewayMode || !GATEWAY_HIDDEN_CAPABILITIES.includes(capability));
|
|
134
|
+
const managedError = "Sessions and software updates are managed by the Beatrina server. Use the server workspace controls.";
|
|
110
135
|
const originsOk = deployment.origins;
|
|
111
136
|
const hostsOk = deployment.hosts;
|
|
112
137
|
const sockets = []; // records: {ws, role, name, class, user, lastSeen, beats}
|
|
113
138
|
const approvals = new Set(); // exact origins the user approved this session
|
|
114
|
-
const state = { everConnected: false, heldAt: null, pendingOpen: opts.pendingOpen || null, quit: false
|
|
139
|
+
const state = { everConnected: false, heldAt: null, pendingOpen: opts.pendingOpen || null, quit: false,
|
|
140
|
+
// The documents open in this session, as the pages last reported them (host/session-keep.mjs).
|
|
141
|
+
sessionDocuments: Array.isArray(opts.sessionDocuments) ? opts.sessionDocuments : [] };
|
|
115
142
|
plane.sockets = () => sockets;
|
|
116
|
-
|
|
143
|
+
// A TERMINAL worker death leaves the page stranded: no R, but a supervisor that still answers the
|
|
144
|
+
// handoff (src/r-kernel.js `stranded`). A page that connects AFTER the death — the page's own
|
|
145
|
+
// reconnect does exactly that — never saw a ready frame to learn this session's build or its
|
|
146
|
+
// doors from, so the notice carries them. Our own frame, re-encoded; never a worker's bytes.
|
|
147
|
+
const strandedNotice = (payload) => {
|
|
148
|
+
if (typeof payload !== "string" || !payload.includes('"worker-died"') || !payload.includes('"terminal":true')) return payload;
|
|
149
|
+
try {
|
|
150
|
+
const frame = JSON.parse(payload);
|
|
151
|
+
const doors = commands.filter((c) => c === "session_upgrade" || c === "session_restart");
|
|
152
|
+
return enc({ ...frame, session: { kernel_build: opts.kernelBuild, installed_build: opts.installedBuild(), commands: doors } });
|
|
153
|
+
} catch { return payload; }
|
|
154
|
+
};
|
|
155
|
+
plane.on("broadcast", (payload) => { const out = strandedNotice(payload); sockets.forEach((r) => r.ws.send(out)); });
|
|
156
|
+
// A restore's report is about every object in the session: pages only, never an agent or a native client.
|
|
157
|
+
plane.on("broadcast-pages", (payload) => sockets.filter((r) => PAGE_ONLY_CLASSES.includes(r.class) && r.role === "page").forEach((r) => r.ws.send(payload)));
|
|
117
158
|
|
|
118
159
|
// The session stamp: ONE string, rendered once, that the page's meta and
|
|
119
160
|
// /health's `started` share so they can never differ in their digits.
|
|
@@ -187,6 +228,7 @@ export function createHostServer(opts) {
|
|
|
187
228
|
const ctx = {
|
|
188
229
|
deployment, plane, audit, sockets, approvals, state, opts,
|
|
189
230
|
enc, scalarChr, pageRecs, socketClass,
|
|
231
|
+
sessionDocuments: () => state.sessionDocuments,
|
|
190
232
|
port: () => (server.address() ? server.address().port : deployment.port),
|
|
191
233
|
respond: (res, status, type, body, extra) => respond(res, status, type, body, extra),
|
|
192
234
|
reject: (res, reason, detail) => reject(res, reason, detail),
|
|
@@ -212,8 +254,13 @@ export function createHostServer(opts) {
|
|
|
212
254
|
}
|
|
213
255
|
|
|
214
256
|
const server = http.createServer((req, res) => {
|
|
257
|
+
if (!gatewayOk(req)) return reject(res, "gateway authentication required");
|
|
215
258
|
if (!hostOk(req)) return reject(res, "bad host", req.headers.host || "(none)");
|
|
216
259
|
const pathname = new URL(req.url, "http://x").pathname;
|
|
260
|
+
if (gatewayMode && GATEWAY_MANAGED_PATHS.includes(pathname)) {
|
|
261
|
+
audit("server-managed-refused", { path: pathname });
|
|
262
|
+
return respond(res, 403, "application/json", enc({ ok: false, reason: "server-managed", error: managedError }));
|
|
263
|
+
}
|
|
217
264
|
if (pathname === "/health") {
|
|
218
265
|
if (queryParam(req, "hold") === "1") state.heldAt = Date.now();
|
|
219
266
|
const newerOnNpm = opts.updateAvailable ? opts.updateAvailable() : null;
|
|
@@ -229,14 +276,14 @@ export function createHostServer(opts) {
|
|
|
229
276
|
// Which build this kernel replaced, for the first 90 s of its life —
|
|
230
277
|
// how a page recognises its successor. Never the page or its capability.
|
|
231
278
|
...(opts.handoffFrom && Date.now() - opts.bootAt <= 90000 ? { handoff_from: opts.handoffFrom } : {}),
|
|
232
|
-
capabilities: ["published-direct-v1", "published-pairing-v3", "session-restart-v1", ...
|
|
279
|
+
capabilities: [...(gatewayMode ? [] : ["published-direct-v1", "published-pairing-v3", "session-restart-v1"]), ...capabilities],
|
|
233
280
|
// `engines` is what a chunk may be RUN on, so a file page probing for
|
|
234
281
|
// a kernel can tell a two-engine host from a one-engine one before it
|
|
235
282
|
// opens a socket. It names every engine a runtime was found for, not
|
|
236
283
|
// only the ones that have started (WP4; docs/wp/wp4-python.md).
|
|
237
284
|
host: "beatrix",
|
|
238
285
|
// Which product this host is (BEATRIX_APP_ID): a report from the page files under it.
|
|
239
|
-
app: opts.app || "
|
|
286
|
+
app: opts.app || "beatrina",
|
|
240
287
|
engines: typeof plane.engineNames !== "undefined" ? plane.engineNames : (plane.language ? [plane.language] : []),
|
|
241
288
|
engine_status: typeof plane.engineRows === "function" ? plane.engineRows() : undefined,
|
|
242
289
|
}), { "Access-Control-Allow-Origin": "*" });
|
|
@@ -257,13 +304,17 @@ export function createHostServer(opts) {
|
|
|
257
304
|
const page = pathname === "/spike" ? path.join(opts.here, "index.html") : (opts.pagePath() || path.join(opts.here, "index.html"));
|
|
258
305
|
let html;
|
|
259
306
|
try { html = fs.readFileSync(page, "utf8"); } catch { return respond(res, 404, "text/plain", "no notebook page is built for this kernel"); }
|
|
260
|
-
return respond(res, 200, "text/html", stampSession(html, sessionStamp));
|
|
307
|
+
return respond(res, 200, "text/html", stampSession(html, sessionStamp, opts.app || "beatrina"));
|
|
261
308
|
}
|
|
262
309
|
return respond(res, 404, "text/plain", "not found");
|
|
263
310
|
});
|
|
264
311
|
|
|
265
312
|
attachWebSocket(server, {
|
|
266
313
|
gate: (req) => {
|
|
314
|
+
if (!gatewayOk(req)) {
|
|
315
|
+
audit("rejected", { reason: "gateway authentication required" });
|
|
316
|
+
return { status: 403, body: "forbidden: gateway authentication required" };
|
|
317
|
+
}
|
|
267
318
|
if (!hostOk(req)) { audit("rejected", { reason: "bad host", detail: req.headers.host || "(none)" }); return { status: 403, body: "forbidden: bad host" }; }
|
|
268
319
|
const origin = req.headers.origin;
|
|
269
320
|
const approved = origin != null && approvals.has(origin);
|
|
@@ -277,14 +328,22 @@ export function createHostServer(opts) {
|
|
|
277
328
|
const rec = { ws, role: "page", name: "", class: socketClass(ws.request),
|
|
278
329
|
user: proxyUser(rookOf(ws.request), deployment), lastSeen: Date.now(), beats: false };
|
|
279
330
|
sockets.push(rec);
|
|
331
|
+
// A pong is the transport answering: the browser's network process sends it even while the
|
|
332
|
+
// page's thread is busy rendering or throttled behind another window, so it counts as life.
|
|
333
|
+
ws.onPong(() => { rec.lastSeen = Date.now(); });
|
|
280
334
|
state.everConnected = true;
|
|
281
335
|
audit("socket-open", { sockets: sockets.length, class: rec.class, user: rec.user });
|
|
282
336
|
// The engine announced itself long before this page connected; replay
|
|
283
337
|
// it — and every other engine that has since started, each under its own
|
|
284
338
|
// `engine-ready` (host/engine-pool.mjs replay()).
|
|
285
339
|
if (plane.hello) ws.send(plane.hello);
|
|
286
|
-
if (plane.notice) ws.send(plane.notice);
|
|
340
|
+
if (plane.notice) ws.send(strandedNotice(plane.notice));
|
|
287
341
|
if (typeof plane.replay === "function") plane.replay(ws);
|
|
342
|
+
// …and a restore that finished before this page arrived (a page following a handoff).
|
|
343
|
+
if (PAGE_ONLY_CLASSES.includes(rec.class) && typeof plane.freshResumeReport === "function") {
|
|
344
|
+
const report = plane.freshResumeReport();
|
|
345
|
+
if (report) ws.send(report);
|
|
346
|
+
}
|
|
288
347
|
for (const pl of planes) { try { pl.onOpen?.(rec, ctx); } catch (e) { audit("plane-error", { plane: pl.name, detail: e.message }); } }
|
|
289
348
|
ws.onMessage((message) => {
|
|
290
349
|
try { handleFrame(message, rec); } catch (e) { audit("frame-error", { detail: e.message }); }
|
|
@@ -321,6 +380,11 @@ export function createHostServer(opts) {
|
|
|
321
380
|
if (!cmd || typeof cmd !== "object" || Array.isArray(cmd) || !scalarChr(cmd.type)) return undefined;
|
|
322
381
|
rec.lastSeen = Date.now();
|
|
323
382
|
const type = cmd.type;
|
|
383
|
+
if (gatewayMode && GATEWAY_MANAGED_COMMANDS.includes(type)) {
|
|
384
|
+
audit("server-managed-refused", { type });
|
|
385
|
+
rec.ws.send(enc({ type, ...(scalarChr(cmd.id) ? { id: cmd.id } : {}), ok: false, reason: "server-managed", error: managedError }));
|
|
386
|
+
return undefined;
|
|
387
|
+
}
|
|
324
388
|
|
|
325
389
|
if (type === "hb") {
|
|
326
390
|
rec.beats = true;
|
|
@@ -346,6 +410,24 @@ export function createHostServer(opts) {
|
|
|
346
410
|
rec.ws.send(enc({ type: "open-request", id: scalarChr(cmd.id) ? cmd.id : "open", path: p || "" }));
|
|
347
411
|
return undefined;
|
|
348
412
|
}
|
|
413
|
+
// The documents this session has open. Page-only in BOTH senses: the list is file paths, and a page
|
|
414
|
+
// that could not open a terminal must not be able to rewrite what the session reopens either.
|
|
415
|
+
if (type === "page-documents") {
|
|
416
|
+
if (rec.role === "page" && PAGE_ONLY_CLASSES.includes(rec.class)) {
|
|
417
|
+
state.sessionDocuments = cleanSessionDocuments(cmd.documents);
|
|
418
|
+
opts.onSessionDocuments?.(state.sessionDocuments);
|
|
419
|
+
}
|
|
420
|
+
return undefined;
|
|
421
|
+
}
|
|
422
|
+
// A page asks what this session had open, to reopen it. `others` counts the OTHER pages attached: a
|
|
423
|
+
// page joining a session another window shows must not open the same documents a second time.
|
|
424
|
+
if (type === "session-documents") {
|
|
425
|
+
if (!scalarChr(cmd.id)) return undefined;
|
|
426
|
+
const allowed = rec.role === "page" && PAGE_ONLY_CLASSES.includes(rec.class);
|
|
427
|
+
const others = pageRecs().filter((r) => r !== rec).length;
|
|
428
|
+
rec.ws.send(enc({ type, id: cmd.id, documents: allowed ? state.sessionDocuments : [], others, refused: !allowed }));
|
|
429
|
+
return undefined;
|
|
430
|
+
}
|
|
349
431
|
if (AGENT_REFUSED.includes(type) && rec.role === "mcp") {
|
|
350
432
|
audit("mcp-refused", { reason: `agent asked for ${type}` });
|
|
351
433
|
// serve.R answers IN THE ASKED TYPE with an `error`, never a `done`: the
|
|
@@ -363,14 +445,22 @@ export function createHostServer(opts) {
|
|
|
363
445
|
audit("project-action-refused", { reason: "class", class: rec.class || "unknown" });
|
|
364
446
|
return undefined;
|
|
365
447
|
}
|
|
448
|
+
// "Restart, keep variables": the save is a copy of the whole session, so it is page-only in BOTH
|
|
449
|
+
// senses, and its cancel interrupts only a save THIS page asked for.
|
|
450
|
+
if ((type === "suspend" || type === "suspend_cancel") && !PAGE_ONLY_CLASSES.includes(rec.class)) {
|
|
451
|
+
if (scalarChr(cmd.id)) rec.ws.send(enc({ type, id: cmd.id, error: "Only the local notebook page may keep the workspace." }));
|
|
452
|
+
audit("suspend-refused", { reason: "class", class: rec.class || "unknown" });
|
|
453
|
+
return undefined;
|
|
454
|
+
}
|
|
366
455
|
switch (type) {
|
|
456
|
+
case "suspend_cancel": if (typeof plane.cancelSuspend === "function") plane.cancelSuspend(rec); return undefined;
|
|
367
457
|
case "exec": return plane.exec(cmd, rec);
|
|
368
458
|
case "interrupt": return plane.interrupt(cmd, rec);
|
|
369
459
|
// Both carry an OPTIONAL engine. `restart` with none is the primary
|
|
370
460
|
// engine's — which is what "Restart R" in the page means and what every
|
|
371
461
|
// client built before there was a second engine sends (§6.5).
|
|
372
462
|
case "force_stop": return plane.forceStop(rec, cmd);
|
|
373
|
-
case "restart": Promise.resolve(plane.restart(cmd.engine)).catch((e) => audit("restart-failed", { detail: e.message })); return undefined;
|
|
463
|
+
case "restart": Promise.resolve(plane.restart(cmd.engine, { resume: cmd.resume })).catch((e) => audit("restart-failed", { detail: e.message })); return undefined;
|
|
374
464
|
case "runstate": return plane.runstate(cmd, rec);
|
|
375
465
|
case "runs": return plane.runs(cmd, rec);
|
|
376
466
|
case "adopt": return plane.adopt(cmd, rec);
|
|
@@ -410,13 +500,25 @@ export function createHostServer(opts) {
|
|
|
410
500
|
return undefined;
|
|
411
501
|
}
|
|
412
502
|
|
|
413
|
-
/** Close and forget sockets that have stopped answering (only those that beat).
|
|
414
|
-
|
|
503
|
+
/** Close and forget sockets that have stopped answering (only those that beat).
|
|
504
|
+
*
|
|
505
|
+
* Silence alone used to be the verdict, and a page whose OWN thread was busy — rendering a
|
|
506
|
+
* large result, or throttled by the browser behind another window during a long chunk — sent
|
|
507
|
+
* no `hb` for 90 s and was cut off while R worked; the page then printed "Connection to the R
|
|
508
|
+
* kernel closed", redialled and adopted the run back. Misleading, and the owner said so
|
|
509
|
+
* (2026-09-17). So a page silent for half the threshold is PINGED at the protocol level: the
|
|
510
|
+
* browser answers a ping from its network process whatever the page's thread is doing, the
|
|
511
|
+
* pong refreshes `lastSeen` (onPong above), and only a socket that answers nothing — a
|
|
512
|
+
* sleeping laptop, a dropped link, a killed browser — reaches the threshold and is closed. */
|
|
513
|
+
const silenceMs = Math.max(0, Number(readEnv(process.env, "SOCKET_SILENCE", "90"))) * 1000;
|
|
415
514
|
const reaper = setInterval(() => {
|
|
416
515
|
if (!silenceMs || !sockets.length) return;
|
|
417
516
|
const now = Date.now();
|
|
418
517
|
for (const rec of [...sockets]) {
|
|
419
|
-
if (rec.beats
|
|
518
|
+
if (!rec.beats) continue;
|
|
519
|
+
const silent = now - rec.lastSeen;
|
|
520
|
+
if (silent >= silenceMs / 2 && silent < silenceMs) { rec.ws.ping(); continue; }
|
|
521
|
+
if (silent >= silenceMs) {
|
|
420
522
|
audit("socket-silent", { role: rec.role, silence: silenceMs / 1000 });
|
|
421
523
|
const at = sockets.indexOf(rec);
|
|
422
524
|
if (at >= 0) sockets.splice(at, 1);
|
|
@@ -434,8 +536,8 @@ export function createHostServer(opts) {
|
|
|
434
536
|
sessionStamp,
|
|
435
537
|
ctx,
|
|
436
538
|
/** Every verb the registered planes serve — merged into ready.commands by main.mjs. */
|
|
437
|
-
commands
|
|
438
|
-
capabilities
|
|
539
|
+
commands,
|
|
540
|
+
capabilities,
|
|
439
541
|
close: () => { clearInterval(reaper); sockets.forEach((r) => r.ws.close(1001, "shutdown")); server.close(); },
|
|
440
542
|
};
|
|
441
543
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// session-keep.mjs — the pure rules behind two CarmaR 0.8.5 features, as the host answers them.
|
|
2
|
+
//
|
|
3
|
+
// · "Restart, keep variables": the worker saves the session (`suspend`, spike/workspace-keep.R) and
|
|
4
|
+
// answers a TOKEN; the host never reads a kept workspace, it only checks that a token is one the
|
|
5
|
+
// worker could have minted and carries it to the next worker (`resume`). A path is never a token.
|
|
6
|
+
// · "A session keeps its documents": the page reports what it has open (`page-documents`), the host
|
|
7
|
+
// keeps the list, labels the runtime record with it, and hands it back (`session-documents`).
|
|
8
|
+
//
|
|
9
|
+
// Transcribed rule for rule from spike/workspace-keep.R (keep_token_ok) and spike/session-documents.R
|
|
10
|
+
// (session_documents_clean/label/encode/decode), which Beatrina's R supervisor sources; pinned against
|
|
11
|
+
// the same cases by test/session-keep.test.mjs.
|
|
12
|
+
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
|
|
15
|
+
export const KEEP_TOKEN_RE = /^[0-9]{14}-[0-9a-f]{16}$/;
|
|
16
|
+
export const SESSION_DOCUMENTS_MAX = 64;
|
|
17
|
+
export const SESSION_DOCUMENT_FORMATS = Object.freeze(["qmd", "Rmd", "md", "carmd"]);
|
|
18
|
+
/** How long a restore's report is replayed to a page that connects late (serve.R: 120 s). */
|
|
19
|
+
export const RESUME_REPORT_MS = 120000;
|
|
20
|
+
|
|
21
|
+
/** Is `x` a token the worker could have minted? */
|
|
22
|
+
export const keepTokenOk = (x) => typeof x === "string" && KEEP_TOKEN_RE.test(x);
|
|
23
|
+
|
|
24
|
+
// eslint-disable-next-line no-control-regex
|
|
25
|
+
const CONTROL = /[\x00-\x1f\x7f]/g;
|
|
26
|
+
const textOf = (value, cap) => {
|
|
27
|
+
if (typeof value !== "string") return null;
|
|
28
|
+
const v = value.replace(CONTROL, " ");
|
|
29
|
+
return v.length > cap ? null : v;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** The documents a page reported, cleaned: absolute paths, bounded names, known formats, no duplicates. */
|
|
33
|
+
export function cleanSessionDocuments(documents) {
|
|
34
|
+
if (!Array.isArray(documents) || !documents.length) return [];
|
|
35
|
+
const rows = [];
|
|
36
|
+
const seen = new Set();
|
|
37
|
+
for (const row of documents) {
|
|
38
|
+
if (!row || typeof row !== "object" || Array.isArray(row)) continue;
|
|
39
|
+
const p = textOf(row.path ?? "", 4096);
|
|
40
|
+
const name = textOf(row.name ?? "", 200);
|
|
41
|
+
let format = textOf(row.format ?? "", 16);
|
|
42
|
+
if (p == null || name == null || format == null) continue;
|
|
43
|
+
// Reopened through the file ops, which resolve it themselves; here it need only be absolute.
|
|
44
|
+
if (p && !/^(\/|[A-Za-z]:[/\\])/.test(p)) continue;
|
|
45
|
+
if (!p && !name.trim()) continue;
|
|
46
|
+
if (!SESSION_DOCUMENT_FORMATS.includes(format)) format = "qmd";
|
|
47
|
+
if (p) { if (seen.has(p)) continue; seen.add(p); }
|
|
48
|
+
rows.push({ path: p, name: name.trim(), format, active: row.active === true });
|
|
49
|
+
if (rows.length >= SESSION_DOCUMENTS_MAX) break;
|
|
50
|
+
}
|
|
51
|
+
return rows;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** "SezerProfiles.qmd + 1 more": what the menu helper shows for a session. */
|
|
55
|
+
export function sessionDocumentsLabel(documents) {
|
|
56
|
+
if (!Array.isArray(documents) || !documents.length) return "";
|
|
57
|
+
const lead = documents.find((d) => d.active === true) || documents[0];
|
|
58
|
+
let name = lead.path ? path.basename(lead.path.replace(/\\/g, "/")) : lead.name;
|
|
59
|
+
if (!lead.path) name = `${name} (unsaved)`;
|
|
60
|
+
const rest = documents.length - 1;
|
|
61
|
+
const label = rest > 0 ? `${name} + ${rest} more` : name;
|
|
62
|
+
return label.replace(CONTROL, " ").replace(/["\\]/g, "'").slice(0, 120).trim();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const encodeSessionDocuments = (documents) => (Array.isArray(documents) && documents.length ? JSON.stringify(documents) : "");
|
|
66
|
+
|
|
67
|
+
export function decodeSessionDocuments(text) {
|
|
68
|
+
if (typeof text !== "string" || !text) return [];
|
|
69
|
+
try { return cleanSessionDocuments(JSON.parse(text)); } catch { return []; }
|
|
70
|
+
}
|
package/host/settings.mjs
CHANGED
|
@@ -13,10 +13,10 @@
|
|
|
13
13
|
// · An administrator's setting is NOT IN THIS FILE'S VOCABULARY AT ALL. It is
|
|
14
14
|
// rejected on read and refused on write, always — never "env wins if
|
|
15
15
|
// present", because that only guarantees anything when the env IS set, and
|
|
16
|
-
// a deployment that never set
|
|
16
|
+
// a deployment that never set BEATRINA_ROOT is still one where a user must
|
|
17
17
|
// not invent one.
|
|
18
18
|
//
|
|
19
|
-
// The tempting alternative — one layered getter handed to
|
|
19
|
+
// The tempting alternative — one layered getter handed to beatrinaDeployment()
|
|
20
20
|
// and the AI policy — is deliberately NOT built here either. It would put every
|
|
21
21
|
// administrator variable one argument-passing mistake away from being
|
|
22
22
|
// file-settable. Those read process.env directly; only the user-writable call
|
|
@@ -32,6 +32,7 @@ import fs from "node:fs";
|
|
|
32
32
|
|
|
33
33
|
import path from "node:path";
|
|
34
34
|
import { appId, ensureUserDir, privateModeHeld } from "./user-dirs.mjs";
|
|
35
|
+
import { envReader, mirrorEnv, setEnvName } from "./env-names.mjs";
|
|
35
36
|
|
|
36
37
|
export const SETTINGS_VERSION = 1;
|
|
37
38
|
|
|
@@ -46,12 +47,19 @@ export const SETTINGS_VERSION = 1;
|
|
|
46
47
|
export const SETTINGS = Object.freeze([
|
|
47
48
|
// Connection loss is not permission to discard the R workspace. Timed
|
|
48
49
|
// shutdown is opt-in; 0 retains an attached session until an explicit Quit.
|
|
49
|
-
{ key: "linger_seconds", env: "
|
|
50
|
-
{ key: "history_enabled", env: "
|
|
51
|
-
{ key: "analyze_budget_s", env: "
|
|
52
|
-
{ key: "quarto_path", env: "
|
|
53
|
-
{ key: "rscript_path", env: "
|
|
54
|
-
{ key: "port", env: "
|
|
50
|
+
{ key: "linger_seconds", env: "BEATRINA_LINGER", kind: "number", default: 0, min: 0, max: 86400, effect: "live", label: "Keep R running after all pages disconnect" },
|
|
51
|
+
{ key: "history_enabled", env: "BEATRINA_NO_HISTORY", kind: "bool", default: true, effect: "live", inverted: true, label: "Remember console history between sessions" },
|
|
52
|
+
{ key: "analyze_budget_s", env: "BEATRINA_ANALYZE_BUDGET", kind: "number", default: 3, min: 0, max: 60, effect: "next_launch", label: "Code-intelligence time budget" },
|
|
53
|
+
{ key: "quarto_path", env: "BEATRINA_QUARTO", kind: "path", default: "", effect: "live", label: "Quarto binary" },
|
|
54
|
+
{ key: "rscript_path", env: "BEATRINA_RSCRIPT", kind: "path", default: "", effect: "restart_r", label: "R binary" },
|
|
55
|
+
{ key: "port", env: "BEATRINA_PORT", kind: "number", default: 4747, min: 1024, max: 65535, effect: "next_launch", label: "Preferred port" },
|
|
56
|
+
// WHO IS WRITING. Machine scope, beside console history and the AI store,
|
|
57
|
+
// and deliberately NOT localStorage: every file:// page shares one origin,
|
|
58
|
+
// so a name kept there would be readable by any local HTML the user opens
|
|
59
|
+
// and would not follow them to a served page either. With no kernel there
|
|
60
|
+
// is no name, which is consistent — with no kernel there is no comment
|
|
61
|
+
// store to attribute anything in.
|
|
62
|
+
{ key: "person_name", env: "BEATRINA_NAME", kind: "text", max: 120, default: "", effect: "live", label: "Your name" },
|
|
55
63
|
]);
|
|
56
64
|
|
|
57
65
|
/**
|
|
@@ -60,37 +68,63 @@ export const SETTINGS = Object.freeze([
|
|
|
60
68
|
* the refusal structural rather than a lookup someone can forget.
|
|
61
69
|
*/
|
|
62
70
|
export const SETTINGS_ADMIN = Object.freeze([
|
|
63
|
-
"
|
|
64
|
-
"
|
|
65
|
-
"
|
|
66
|
-
"
|
|
67
|
-
"
|
|
68
|
-
"
|
|
69
|
-
"
|
|
70
|
-
"
|
|
71
|
-
"
|
|
71
|
+
"BEATRINA_MANAGED_CONFIG",
|
|
72
|
+
"BEATRINA_ROOT", "BEATRINA_LOG", "BEATRINA_LOG_AI_TEXT",
|
|
73
|
+
"BEATRINA_CRAN_MIRROR",
|
|
74
|
+
"BEATRINA_AI_LOCAL_ONLY", "BEATRINA_AI_POLICY", "BEATRINA_AI_PROVIDERS",
|
|
75
|
+
"BEATRINA_BIND", "BEATRINA_HOSTS", "BEATRINA_ORIGINS", "BEATRINA_REQUIRE_ORIGIN",
|
|
76
|
+
"BEATRINA_TRUST_PROXY", "BEATRINA_USER_HEADER", "BEATRINA_TRUSTED_PROXY",
|
|
77
|
+
"BEATRINA_ALLOW_UNAUTHENTICATED", "BEATRINA_PORT_STRICT",
|
|
78
|
+
"BEATRINA_UPDATE_FEED", "BEATRINA_UPDATE_MIRROR", "BEATRINA_UPDATE_OFFLINE_DIR",
|
|
79
|
+
"BEATRINA_UPDATE_MANAGED",
|
|
72
80
|
]);
|
|
73
81
|
|
|
74
|
-
export const MANAGED_SETTINGS_SCHEMA = "
|
|
82
|
+
export const MANAGED_SETTINGS_SCHEMA = "beatrina-managed-settings-v1";
|
|
75
83
|
export const MANAGED_SETTINGS_ENV = Object.freeze([...new Set([
|
|
76
84
|
...SETTINGS.map((s) => s.env),
|
|
77
|
-
...SETTINGS_ADMIN.filter((n) => n !== "
|
|
85
|
+
...SETTINGS_ADMIN.filter((n) => n !== "BEATRINA_MANAGED_CONFIG"),
|
|
78
86
|
"HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY",
|
|
79
87
|
])]);
|
|
80
88
|
|
|
89
|
+
// An administrator's file written for Beatrina 0.8.x: its schema string and
|
|
90
|
+
// its keys spell the old product name. Both are read; `managedRead` reports
|
|
91
|
+
// the values under the current names, and nothing writes the old ones.
|
|
92
|
+
// beatrina-names: keep-start
|
|
93
|
+
const LEGACY_MANAGED_SETTINGS_SCHEMA = "carmar-managed-settings-v1";
|
|
94
|
+
const LEGACY_MANAGED_KEY = /^CARMAR_/;
|
|
95
|
+
// beatrina-names: keep-end
|
|
96
|
+
// A key is reported as itself when the vocabulary knows it, else under its current spelling.
|
|
97
|
+
const currentKey = (k) => (MANAGED_SETTINGS_ENV.includes(k) ? k : k.replace(LEGACY_MANAGED_KEY, "BEATRINA_"));
|
|
98
|
+
|
|
81
99
|
export const settingsEntry = (key) => SETTINGS.find((s) => s.key === key) || null;
|
|
82
100
|
|
|
83
|
-
|
|
101
|
+
// Dual-read (host/env-names.mjs): every `env:` name in SETTINGS is answered
|
|
102
|
+
// under its older spelling too; the page (lib/settings/kernel-pane.js) keys its
|
|
103
|
+
// admin rows on the same names, in the same spelling.
|
|
104
|
+
const sysEnv = envReader(process.env);
|
|
84
105
|
|
|
85
106
|
// ── managed configuration ───────────────────────────────────────────────────
|
|
86
107
|
|
|
87
|
-
|
|
88
|
-
|
|
108
|
+
/** The places an administrator's file is looked for on this platform, the current one first. */
|
|
109
|
+
export function managedDefaultPaths(env = sysEnv, platform = process.platform) {
|
|
110
|
+
if (platform === "darwin") {
|
|
111
|
+
return ["/Library/Application Support/Beatrina/managed-settings.json",
|
|
112
|
+
"/Library/Application Support/CarmaR/managed-settings.json"]; // beatrina-names: keep — a file deployed for 0.8.x
|
|
113
|
+
}
|
|
89
114
|
if (platform === "win32") {
|
|
90
115
|
const base = String(env("ProgramData", "")).trim() || "C:/ProgramData";
|
|
91
|
-
return path.join(base, "
|
|
116
|
+
return [path.join(base, "Beatrina", "managed-settings.json"),
|
|
117
|
+
path.join(base, "CarmaR", "managed-settings.json")]; // beatrina-names: keep — a file deployed for 0.8.x
|
|
92
118
|
}
|
|
93
|
-
return "/etc/
|
|
119
|
+
return ["/etc/beatrina/managed-settings.json",
|
|
120
|
+
"/etc/carmar/managed-settings.json"]; // beatrina-names: keep — a file deployed for 0.8.x
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The administrator's file: the current path, unless only the older one exists. */
|
|
124
|
+
export function managedDefaultPath(env = sysEnv, platform = process.platform) {
|
|
125
|
+
const [current, ...older] = managedDefaultPaths(env, platform);
|
|
126
|
+
if (fs.existsSync(current)) return current;
|
|
127
|
+
return older.find((p) => p !== current && fs.existsSync(p)) || current;
|
|
94
128
|
}
|
|
95
129
|
|
|
96
130
|
/**
|
|
@@ -120,12 +154,12 @@ export function managedProtected(file, platform = process.platform) {
|
|
|
120
154
|
export function managedRead(file, { required = false, protection = managedProtected } = {}) {
|
|
121
155
|
const empty = { status: "missing", path: file, values: {}, errors: [] };
|
|
122
156
|
if (!fs.existsSync(file)) {
|
|
123
|
-
if (required) return { ...empty, status: "invalid", errors: ["
|
|
157
|
+
if (required) return { ...empty, status: "invalid", errors: ["BEATRINA_MANAGED_CONFIG names a file that does not exist."] };
|
|
124
158
|
return empty;
|
|
125
159
|
}
|
|
126
160
|
if (protection(file) !== true) {
|
|
127
161
|
return { ...empty, status: "insecure",
|
|
128
|
-
errors: ["The managed settings file is writable by the current account, is a symlink, or is not administrator-owned;
|
|
162
|
+
errors: ["The managed settings file is writable by the current account, is a symlink, or is not administrator-owned; Beatrina refused it."] };
|
|
129
163
|
}
|
|
130
164
|
let size = NaN;
|
|
131
165
|
try { size = fs.statSync(file).size; } catch { size = NaN; }
|
|
@@ -138,12 +172,12 @@ export function managedRead(file, { required = false, protection = managedProtec
|
|
|
138
172
|
const rootNames = plain ? Object.keys(raw) : null;
|
|
139
173
|
const settingsPlain = plain && raw.settings && typeof raw.settings === "object" && !Array.isArray(raw.settings);
|
|
140
174
|
if (!rootNames || rootNames.length !== 2 || !rootNames.includes("schema") || !rootNames.includes("settings")
|
|
141
|
-
|| raw.schema
|
|
175
|
+
|| ![MANAGED_SETTINGS_SCHEMA, LEGACY_MANAGED_SETTINGS_SCHEMA].includes(raw.schema) || !settingsPlain) {
|
|
142
176
|
return { ...empty, status: "invalid",
|
|
143
177
|
errors: [`The managed settings file must use schema ${MANAGED_SETTINGS_SCHEMA} with one settings object.`] };
|
|
144
178
|
}
|
|
145
179
|
const keys = Object.keys(raw.settings);
|
|
146
|
-
const unknown = keys.filter((k) => !MANAGED_SETTINGS_ENV.includes(k));
|
|
180
|
+
const unknown = keys.filter((k) => !MANAGED_SETTINGS_ENV.includes(currentKey(k)));
|
|
147
181
|
const bad = keys.filter((k) => {
|
|
148
182
|
const v = raw.settings[k];
|
|
149
183
|
return !(typeof v === "string" && v.length > 0 && !/[\r\n\t]/.test(v));
|
|
@@ -153,7 +187,7 @@ export function managedRead(file, { required = false, protection = managedProtec
|
|
|
153
187
|
if (bad.length) errors.push(`Managed settings must be non-empty single-line strings: ${bad.join(", ")}.`);
|
|
154
188
|
if (errors.length) return { ...empty, status: "invalid", errors };
|
|
155
189
|
const values = {};
|
|
156
|
-
for (const k of keys) values[k] = raw.settings[k];
|
|
190
|
+
for (const k of keys) values[currentKey(k)] = raw.settings[k];
|
|
157
191
|
return { status: "ok", path: file, values, errors: [] };
|
|
158
192
|
}
|
|
159
193
|
|
|
@@ -162,11 +196,13 @@ export function managedRead(file, { required = false, protection = managedProtec
|
|
|
162
196
|
* every subsystem that follows resolves under the ordinary env-wins rule.
|
|
163
197
|
*/
|
|
164
198
|
export function managedEnvironment({ env = sysEnv, file = null, protection = managedProtected, apply = true } = {}) {
|
|
165
|
-
const explicit = String(env("
|
|
199
|
+
const explicit = String(env("BEATRINA_MANAGED_CONFIG", "")).trim();
|
|
166
200
|
const target = file || (explicit || managedDefaultPath(env));
|
|
167
201
|
const result = managedRead(target, { required: Boolean(explicit), protection });
|
|
168
202
|
if (apply && result.status === "ok") {
|
|
169
|
-
|
|
203
|
+
// Under BOTH spellings: a managed value must be what every reader sees,
|
|
204
|
+
// whichever prefix it asks for.
|
|
205
|
+
for (const [k, v] of Object.entries(result.values)) setEnvName(process.env, k, v);
|
|
170
206
|
}
|
|
171
207
|
return result;
|
|
172
208
|
}
|
|
@@ -215,6 +251,18 @@ export function validateSetting(key, value) {
|
|
|
215
251
|
if (entry.max != null && value > entry.max) return { ok: false, reason: `must be at most ${entry.max}` };
|
|
216
252
|
return { ok: true, value: Number(value) };
|
|
217
253
|
}
|
|
254
|
+
if (entry.kind === "text") {
|
|
255
|
+
if (typeof value !== "string") return { ok: false, reason: "must be a string" };
|
|
256
|
+
const text = value.trim();
|
|
257
|
+
const cap = entry.max == null ? 200 : entry.max;
|
|
258
|
+
if (text.length > cap) return { ok: false, reason: `must be at most ${cap} characters` };
|
|
259
|
+
// A control character is not a typo here: this value is written into
|
|
260
|
+
// JSON-lines logs (a comment's author), and a newline in one of those
|
|
261
|
+
// forges a record. `ai-audit` strips them for the same reason; a setting
|
|
262
|
+
// can refuse instead, because a person typed it and can be told.
|
|
263
|
+
if (/[\u0000-\u001f\u007f]/.test(text)) return { ok: false, reason: "must not contain control characters" };
|
|
264
|
+
return { ok: true, value: text };
|
|
265
|
+
}
|
|
218
266
|
// path
|
|
219
267
|
if (typeof value !== "string") return { ok: false, reason: "must be a string" };
|
|
220
268
|
// An empty path means "use the ladder" — a legitimate value, not a missing
|
|
@@ -247,7 +295,7 @@ export function readSettingsFile(file = settingsPath()) {
|
|
|
247
295
|
return { ...empty, status: "invalid", error: "The settings file could not be read as JSON." };
|
|
248
296
|
}
|
|
249
297
|
if (Math.trunc(Number(raw.version || 0)) !== SETTINGS_VERSION) {
|
|
250
|
-
return { ...empty, status: "invalid", error: "The settings file is a version this
|
|
298
|
+
return { ...empty, status: "invalid", error: "The settings file is a version this Beatrina does not know." };
|
|
251
299
|
}
|
|
252
300
|
const block = raw.settings == null ? {} : raw.settings;
|
|
253
301
|
if (typeof block !== "object" || Array.isArray(block)) {
|
|
@@ -298,7 +346,7 @@ export function resolveSettings({ env = sysEnv, file = null } = {}) {
|
|
|
298
346
|
if (fromEnv) {
|
|
299
347
|
source = "env";
|
|
300
348
|
if (s.kind === "bool") {
|
|
301
|
-
//
|
|
349
|
+
// BEATRINA_NO_HISTORY is a NEGATIVE flag — any value means "off" — while
|
|
302
350
|
// the file and the UI hold the positive. Translating happens here, once.
|
|
303
351
|
value = s.inverted ? false : true;
|
|
304
352
|
} else if (s.kind === "number") {
|
|
@@ -330,10 +378,10 @@ export const settingsValue = (resolved, key) => (resolved.settings[key] ? resolv
|
|
|
330
378
|
export function settingsChildEnv(resolved) {
|
|
331
379
|
const out = {};
|
|
332
380
|
const q = settingsValue(resolved, "quarto_path");
|
|
333
|
-
if (typeof q === "string" && q.length) out.
|
|
381
|
+
if (typeof q === "string" && q.length) out.BEATRINA_QUARTO = q;
|
|
334
382
|
const b = settingsValue(resolved, "analyze_budget_s");
|
|
335
|
-
if (typeof b === "number") out.
|
|
336
|
-
return out;
|
|
383
|
+
if (typeof b === "number") out.BEATRINA_ANALYZE_BUDGET = String(b);
|
|
384
|
+
return mirrorEnv(out); // a child that still reads BEATRINA_QUARTO sees it
|
|
337
385
|
}
|
|
338
386
|
|
|
339
387
|
/**
|
|
@@ -359,7 +407,7 @@ export function createSettings({ env = sysEnv, applyManaged = true } = {}) {
|
|
|
359
407
|
/** Which R the worker, the analyzer and every job child should run. */
|
|
360
408
|
rscriptPreference() {
|
|
361
409
|
const chosen = api.value("rscript_path");
|
|
362
|
-
return typeof chosen === "string" && chosen.length ? chosen : String(env("
|
|
410
|
+
return typeof chosen === "string" && chosen.length ? chosen : String(env("BEATRINA_RSCRIPT", ""));
|
|
363
411
|
},
|
|
364
412
|
/**
|
|
365
413
|
* The idle clock. The same safe default applies locally and behind a proxy:
|
package/host/update-record.mjs
CHANGED
|
@@ -8,12 +8,13 @@
|
|
|
8
8
|
// puts `update_available` on /health and on the ready frame only when the recorded version is newer
|
|
9
9
|
// than the build that is running, and the page says it (lib/info-pane.js, lib/doctor.js).
|
|
10
10
|
//
|
|
11
|
-
//
|
|
11
|
+
// BEATRINA_NO_UPDATE_CHECK=1 (or its older spelling) turns the reading off as well as the asking
|
|
12
12
|
// (the battery runner sets the first), so no test kernel reports a version some real run recorded.
|
|
13
13
|
|
|
14
14
|
import fs from "node:fs";
|
|
15
15
|
import path from "node:path";
|
|
16
16
|
import { rUserDir } from "./user-dirs.mjs";
|
|
17
|
+
import { readEnv } from "./env-names.mjs";
|
|
17
18
|
|
|
18
19
|
/** -1, 0, 1 for semver-shaped versions; a pre-release sorts before its release; nonsense compares equal. */
|
|
19
20
|
export function compareVersions(a, b) {
|
|
@@ -38,7 +39,7 @@ export function updateRecordFile(app) {
|
|
|
38
39
|
|
|
39
40
|
/** The check is off: nothing is asked and nothing is read. */
|
|
40
41
|
export function updateCheckOff(env = process.env) {
|
|
41
|
-
return env
|
|
42
|
+
return readEnv(env, "NO_UPDATE_CHECK") === "1";
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
/**
|