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/payload.mjs
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// payload.mjs — the files a supervisor starts its workers from, and whether
|
|
2
|
+
// they are still the files it booted with.
|
|
3
|
+
//
|
|
4
|
+
// The fault this exists for (docs/runplan.md R6): an install replaces the app's
|
|
5
|
+
// files IN PLACE under a running session. The supervisor process keeps running
|
|
6
|
+
// the code it loaded, but every worker it starts afterwards — a Restart R, a
|
|
7
|
+
// respawn after a crash, the first {python} chunk — is spawned from the NEW
|
|
8
|
+
// files. An old supervisor then drives a new worker over a wire neither was
|
|
9
|
+
// tested against, and nothing says so. `/health` could already tell
|
|
10
|
+
// (`installed_build` differs from `kernel_build`); nothing acted on it.
|
|
11
|
+
//
|
|
12
|
+
// So the supervisor fingerprints its payload at boot and, before it starts any
|
|
13
|
+
// worker, fingerprints it again. A difference is refused with a sentence that
|
|
14
|
+
// names the build now installed and the door that moves the session onto it
|
|
15
|
+
// ("Restart into"). This is the DETECTION half of S1b; retaining a complete
|
|
16
|
+
// payload per live build is the other half and is not built here.
|
|
17
|
+
//
|
|
18
|
+
// A source checkout (`spike/`) is exempt: there, editing worker.R and pressing
|
|
19
|
+
// Restart R is the development loop, not a fault. BEATRINA_PAYLOAD_GUARD=1/0
|
|
20
|
+
// forces the answer either way (tests, and an operator who wants it off).
|
|
21
|
+
|
|
22
|
+
import crypto from "node:crypto";
|
|
23
|
+
import fs from "node:fs";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
|
|
26
|
+
/** Names that change while a payload is running without the payload changing. */
|
|
27
|
+
const VOLATILE = /^(\.|__pycache__$)|\.pyc$/;
|
|
28
|
+
|
|
29
|
+
/** Every regular file under `dir` (recursively when `deep`), sorted, relative to `base`. */
|
|
30
|
+
function listFiles(dir, base, deep) {
|
|
31
|
+
let names;
|
|
32
|
+
try { names = fs.readdirSync(dir, { withFileTypes: true }); } catch { return []; }
|
|
33
|
+
const out = [];
|
|
34
|
+
for (const d of names) {
|
|
35
|
+
if (VOLATILE.test(d.name)) continue;
|
|
36
|
+
const full = path.join(dir, d.name);
|
|
37
|
+
if (d.isFile()) out.push(path.relative(base, full));
|
|
38
|
+
else if (deep && d.isDirectory()) out.push(...listFiles(full, base, deep));
|
|
39
|
+
}
|
|
40
|
+
return out.sort();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A digest of the files a worker is started from.
|
|
45
|
+
*
|
|
46
|
+
* The kernel directory's own files (not its subfolders — plugins and examples
|
|
47
|
+
* are data a worker reads on request, not code it boots from) and everything
|
|
48
|
+
* under `engines/`. Content, not mtimes: a same-version rebuild that changed a
|
|
49
|
+
* byte is a different payload, and a copy that preserved every byte is not.
|
|
50
|
+
*
|
|
51
|
+
* @param {{kernelDir: string, root: string}} where
|
|
52
|
+
* @returns {{digest: string, files: number, build: string}}
|
|
53
|
+
*/
|
|
54
|
+
export function payloadFingerprint({ kernelDir, root }) {
|
|
55
|
+
const hash = crypto.createHash("sha256");
|
|
56
|
+
const entries = [
|
|
57
|
+
...listFiles(kernelDir, kernelDir, false).map((rel) => ({ tag: "kernel", rel, full: path.join(kernelDir, rel) })),
|
|
58
|
+
...listFiles(path.join(root, "engines"), path.join(root, "engines"), true)
|
|
59
|
+
.map((rel) => ({ tag: "engines", rel, full: path.join(root, "engines", rel) })),
|
|
60
|
+
];
|
|
61
|
+
for (const e of entries) {
|
|
62
|
+
hash.update(`${e.tag}/${e.rel}\0`);
|
|
63
|
+
try { hash.update(fs.readFileSync(e.full)); } catch { hash.update("<unreadable>"); }
|
|
64
|
+
hash.update("\0");
|
|
65
|
+
}
|
|
66
|
+
let build = "";
|
|
67
|
+
try { build = fs.readFileSync(path.join(kernelDir, "kernel-version"), "utf8").trim(); } catch { build = ""; }
|
|
68
|
+
return { digest: hash.digest("hex"), files: entries.length, build };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Is the guard on for this kernel directory?
|
|
73
|
+
* @param {string} kernelDir
|
|
74
|
+
* @param {string} [override] BEATRINA_PAYLOAD_GUARD: "1" on, "0" off, "" decide by shape
|
|
75
|
+
*/
|
|
76
|
+
export function payloadGuardOn(kernelDir, override = "") {
|
|
77
|
+
if (override === "1") return true;
|
|
78
|
+
if (override === "0") return false;
|
|
79
|
+
return path.basename(path.resolve(kernelDir)) !== "spike";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Why a worker may not start from the payload as it is now, or null.
|
|
84
|
+
*
|
|
85
|
+
* Pure: the two fingerprints in, a sentence out. `bootBuild` is the build the
|
|
86
|
+
* supervisor announced at boot (what it runs), which may be dictated by the
|
|
87
|
+
* environment and so need not equal the boot fingerprint's stamp.
|
|
88
|
+
*
|
|
89
|
+
* @param {{digest:string, build:string}} boot
|
|
90
|
+
* @param {{digest:string, build:string}} now
|
|
91
|
+
* @param {string} bootBuild
|
|
92
|
+
* @returns {string|null}
|
|
93
|
+
*/
|
|
94
|
+
export function payloadDrift(boot, now, bootBuild) {
|
|
95
|
+
if (!boot || !now || boot.digest === now.digest) return null;
|
|
96
|
+
const was = bootBuild || boot.build || "this build";
|
|
97
|
+
const installed = now.build && now.build !== boot.build
|
|
98
|
+
? `Beatrina ${now.build} was installed over this session's files`
|
|
99
|
+
: "This session's files were replaced while it was running";
|
|
100
|
+
return `${installed}, so a worker cannot be started from them: this session runs Beatrina ${was}, `
|
|
101
|
+
+ `and mixing it with newer files is how an old supervisor ends up driving a new worker. `
|
|
102
|
+
+ `Use "Restart into ${now.build || "the installed build"}" (Session menu) to move the session onto the installed files; `
|
|
103
|
+
+ `your documents come with it.`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The error a refused start throws, so callers can tell it from a spawn failure. */
|
|
107
|
+
export class PayloadDriftError extends Error {
|
|
108
|
+
constructor(message) {
|
|
109
|
+
super(message);
|
|
110
|
+
this.name = "PayloadDriftError";
|
|
111
|
+
this.code = "BEATRINA_PAYLOAD_DRIFT";
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Wrap an engine factory so it refuses to build from a drifted payload.
|
|
117
|
+
* @param {() => object} make
|
|
118
|
+
* @param {{enabled:boolean, boot:object, bootBuild:string, measure:() => object, audit?:Function, engine:string}} opts
|
|
119
|
+
*/
|
|
120
|
+
export function guardFactory(make, { enabled, boot, bootBuild, measure, audit = () => {}, engine }) {
|
|
121
|
+
if (!enabled) return make;
|
|
122
|
+
return () => {
|
|
123
|
+
const now = measure();
|
|
124
|
+
const why = payloadDrift(boot, now, bootBuild);
|
|
125
|
+
if (why) {
|
|
126
|
+
audit("payload-drift", { engine, boot_build: bootBuild, now_build: now.build, boot_digest: boot.digest.slice(0, 12), now_digest: now.digest.slice(0, 12) });
|
|
127
|
+
throw new PayloadDriftError(why);
|
|
128
|
+
}
|
|
129
|
+
return make();
|
|
130
|
+
};
|
|
131
|
+
}
|
package/host/planes/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# host/planes — one supervisor-answered family per module
|
|
2
2
|
|
|
3
|
-
A plane is
|
|
3
|
+
A plane is an allow-list family — the name the retired R supervisor used for it: the file ops, the
|
|
4
4
|
journal, the AI store, jobs, the terminal, citations, plugins, sessions, the
|
|
5
5
|
MCP plane. Each is one module here, registered by one line in `host/main.mjs`
|
|
6
6
|
(`PLANE_MODULES`), and consulted by `host/server.mjs` before its not-yet
|
package/host/planes/ai-store.mjs
CHANGED
|
@@ -35,6 +35,7 @@ import fs from "node:fs";
|
|
|
35
35
|
import path from "node:path";
|
|
36
36
|
import { privateModeHeld, stateDir } from "../user-dirs.mjs";
|
|
37
37
|
import { PAGE_ONLY_CLASSES } from "../server.mjs";
|
|
38
|
+
import { envReader } from "../env-names.mjs";
|
|
38
39
|
|
|
39
40
|
export const AI_STORE_OPS = Object.freeze(["ai_list", "ai_read", "ai_write", "ai_delete", "ai_purge", "ai_keys"]);
|
|
40
41
|
|
|
@@ -57,13 +58,13 @@ const AI_MAX_BYTES = 2 * 1024 * 1024; // one conversation
|
|
|
57
58
|
const AI_MAX_PER_DOCUMENT = 200; // conversations kept per document
|
|
58
59
|
const AI_MAX_REPLY_BYTES = 6e6; // under the 8 MB frame cap, with room
|
|
59
60
|
|
|
60
|
-
const envOf = (
|
|
61
|
+
const envOf = envReader(process.env); // dual-read (host/env-names.mjs)
|
|
61
62
|
const bytesOf = (text) => Buffer.byteLength(text, "utf8");
|
|
62
63
|
const withCommas = (n) => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
63
64
|
|
|
64
65
|
export function createAiConversationStore() {
|
|
65
|
-
const enabled = envOf("
|
|
66
|
-
const root = () => envOf("
|
|
66
|
+
const enabled = envOf("BEATRINA_NO_AI_STORE") !== "1";
|
|
67
|
+
const root = () => envOf("BEATRINA_AI_STORE_DIR") || path.join(stateDir(), "ai");
|
|
67
68
|
const validKey = (key) => typeof key === "string" && AI_KEY_RE.test(key) && !AI_DOTS_RE.test(key.replace(/^[a-z]+:/, ""));
|
|
68
69
|
const validId = (id) => typeof id === "string" && AI_ID_RE.test(id) && !AI_DOTS_RE.test(id);
|
|
69
70
|
const dirOf = (key) => path.join(root(), key.replace(":", "__"));
|
|
@@ -117,7 +118,7 @@ export function createAiConversationStore() {
|
|
|
117
118
|
};
|
|
118
119
|
/** Atomic replace within the same directory: write a sibling, rename over. */
|
|
119
120
|
const replace = (file, text) => {
|
|
120
|
-
const temp = path.join(path.dirname(file), `.
|
|
121
|
+
const temp = path.join(path.dirname(file), `.beatrina-ai-${crypto.randomBytes(8).toString("hex")}`);
|
|
121
122
|
writePrivate(temp, text);
|
|
122
123
|
try { fs.renameSync(temp, file); } catch {
|
|
123
124
|
try { fs.unlinkSync(temp); } catch { /* gone */ }
|
|
@@ -191,7 +192,7 @@ export function createAiConversationStore() {
|
|
|
191
192
|
const answer = (cmd) => {
|
|
192
193
|
const { type, id } = cmd;
|
|
193
194
|
if (!enabled) {
|
|
194
|
-
return err(type, id, "disabled", "Conversation storage is switched off on this machine (
|
|
195
|
+
return err(type, id, "disabled", "Conversation storage is switched off on this machine (BEATRINA_NO_AI_STORE=1).");
|
|
195
196
|
}
|
|
196
197
|
// Every op but `ai_keys` names a document, and the key is checked BEFORE
|
|
197
198
|
// the file system is touched — the journal's rule, for the same reason.
|
package/host/planes/ai.mjs
CHANGED
|
@@ -25,9 +25,10 @@ import fs from "node:fs";
|
|
|
25
25
|
import path from "node:path";
|
|
26
26
|
import { PAGE_ONLY_CLASSES } from "../server.mjs";
|
|
27
27
|
import { appId, ensureUserDir, privateModeHeld } from "../user-dirs.mjs";
|
|
28
|
-
import {
|
|
28
|
+
import { beatrinaAiPolicy, aiPolicyReason } from "../ai-policy.mjs";
|
|
29
|
+
import { envReader } from "../env-names.mjs";
|
|
29
30
|
|
|
30
|
-
const envOf = (
|
|
31
|
+
const envOf = envReader(process.env); // dual-read (host/env-names.mjs)
|
|
31
32
|
const isStr = (v) => typeof v === "string" && v.length > 0;
|
|
32
33
|
|
|
33
34
|
// ── what the page may write into the audit stream ───────────────────────────
|
|
@@ -53,7 +54,7 @@ export const AI_AUDIT_FIELDS = Object.freeze(["provider", "model", "lane", "tool
|
|
|
53
54
|
// The prompt and the response themselves. OFF unless an administrator asks,
|
|
54
55
|
// because a notebook that silently recorded everything its user typed at an AI
|
|
55
56
|
// would be a worse thing to ship than no audit at all — the same judgement
|
|
56
|
-
//
|
|
57
|
+
// BEATRINA_LOG itself is off by default for.
|
|
57
58
|
export const AI_AUDIT_TEXT_FIELDS = Object.freeze(["prompt", "response"]);
|
|
58
59
|
/** How many ai-* records the reviewer export may read back at once. */
|
|
59
60
|
export const AI_AUDIT_READ_MAX = 5000;
|
|
@@ -65,7 +66,7 @@ export const AI_AUDIT_READ_MAX = 5000;
|
|
|
65
66
|
// same reason: this is MACHINE state, not notebook content, so it belongs in
|
|
66
67
|
// the R user directory and never in the notebook file, an export, or
|
|
67
68
|
// localStorage — where any local page sharing the file:// origin could read the
|
|
68
|
-
// user's code. Off entirely with
|
|
69
|
+
// user's code. Off entirely with BEATRINA_NO_HISTORY=1 (and from Settings),
|
|
69
70
|
// because "record everything I type" must be refusable.
|
|
70
71
|
export const HISTORY_MAX = 2000;
|
|
71
72
|
|
|
@@ -85,27 +86,27 @@ export const HISTORY_MAX = 2000;
|
|
|
85
86
|
// local HTML file shares the file:// origin, so without it `prefs` is a
|
|
86
87
|
// free-form key/value bag any of them could fill.
|
|
87
88
|
//
|
|
88
|
-
// The values travel as the STRINGS localStorage holds.
|
|
89
|
+
// The values travel as the STRINGS localStorage holds. beatrina-chunk-custom is
|
|
89
90
|
// itself JSON; re-parsing it here would make the kernel a party to a format
|
|
90
91
|
// only the page understands.
|
|
91
92
|
export const PREF_MAX_VALUE = 4096;
|
|
92
93
|
export const PREF_KEYS = Object.freeze([
|
|
93
|
-
"
|
|
94
|
-
"
|
|
95
|
-
"
|
|
96
|
-
"
|
|
97
|
-
"
|
|
98
|
-
"
|
|
99
|
-
"
|
|
100
|
-
"
|
|
101
|
-
"
|
|
102
|
-
"
|
|
103
|
-
"
|
|
104
|
-
"
|
|
105
|
-
"
|
|
106
|
-
"
|
|
107
|
-
"
|
|
108
|
-
"
|
|
94
|
+
"beatrina-app-theme", // lib/app-themes.js standard/flat/plain/graphite/onyx
|
|
95
|
+
"beatrina-editor-theme", // lib/editor-themes.js one of 37
|
|
96
|
+
"beatrina-chunk-style", // lib/chunk-style.js the pre-7.8 key, kept in step
|
|
97
|
+
"beatrina-chunk-style-v2", // lib/chunk-style.js the explicit choice
|
|
98
|
+
"beatrina-chunk-custom", // lib/chunk-style.js the designer's token overrides
|
|
99
|
+
"beatrina-writing-chunk", // lib/writing-chunk.js the Writing surface's own card
|
|
100
|
+
"beatrina-run-style", // lib/run-style.js
|
|
101
|
+
"beatrina-table-style-v2", // lib/table-render.js
|
|
102
|
+
"beatrina-line-numbering", // lib/line-numbering.js
|
|
103
|
+
"beatrina-density", // lib/density.js
|
|
104
|
+
"beatrina-add-strip-v3", // lib/add-strip.js
|
|
105
|
+
"beatrina-soft-wrap", // lib/soft-wrap.js
|
|
106
|
+
"beatrina-content-width-v1", // src/notebook-entry.js Standard / Wide / Full
|
|
107
|
+
"beatrina-brand-v1", // lib/brand.js the organisation's accent
|
|
108
|
+
"beatrina:rich-text", // lib/rtf-editor.js
|
|
109
|
+
"beatrina:latex-template", // lib/latex-templates.js
|
|
109
110
|
]);
|
|
110
111
|
|
|
111
112
|
const AGENT_WHY = Object.freeze({
|
|
@@ -223,11 +224,21 @@ export function prefsClean(values) {
|
|
|
223
224
|
return out;
|
|
224
225
|
}
|
|
225
226
|
|
|
227
|
+
// The file a Beatrina 0.8.x kernel wrote names its keys the old way; they are
|
|
228
|
+
// read under the current names (the page's lib/legacy-keys.js does the same for
|
|
229
|
+
// its storage), and the next write puts the current names on disk.
|
|
230
|
+
const LEGACY_PREF_KEY = /^carmar(?=[-:])/; // beatrina-names: keep
|
|
231
|
+
// The current prefix, as a token the rename script moves with PREF_KEYS (lib/legacy-keys.js does the same).
|
|
232
|
+
const CURRENT_PREF_PREFIX = "beatrina-x".slice(0, -2);
|
|
233
|
+
export const modernPrefs = (values) => (values && typeof values === "object" && !Array.isArray(values))
|
|
234
|
+
? Object.fromEntries(Object.entries(values).map(([k, v]) => [k.replace(LEGACY_PREF_KEY, CURRENT_PREF_PREFIX), v]))
|
|
235
|
+
: values;
|
|
236
|
+
|
|
226
237
|
export function prefsRead(enabled) {
|
|
227
238
|
if (!enabled) return {};
|
|
228
239
|
const file = prefsPath();
|
|
229
240
|
if (!fs.existsSync(file)) return {};
|
|
230
|
-
try { return prefsClean(JSON.parse(fs.readFileSync(file, "utf8"))); } catch { return {}; }
|
|
241
|
+
try { return prefsClean(modernPrefs(JSON.parse(fs.readFileSync(file, "utf8")))); } catch { return {}; }
|
|
231
242
|
}
|
|
232
243
|
|
|
233
244
|
export function prefsWrite(enabled, values) {
|
|
@@ -252,11 +263,11 @@ export function prefsWrite(enabled, values) {
|
|
|
252
263
|
const OPS = Object.freeze(["ai-key", "ai-policy", "ai-audit", "ai-audit-read", "console_history", "prefs"]);
|
|
253
264
|
|
|
254
265
|
export function createPlane({ audit, settings } = {}) {
|
|
255
|
-
const policy =
|
|
256
|
-
const logPath = envOf("
|
|
257
|
-
const auditText = envOf("
|
|
258
|
-
const prefsEnabled = envOf("
|
|
259
|
-
const historyEnabled = () => (settings ? settings.historyEnabled() : envOf("
|
|
266
|
+
const policy = beatrinaAiPolicy();
|
|
267
|
+
const logPath = envOf("BEATRINA_LOG");
|
|
268
|
+
const auditText = envOf("BEATRINA_LOG_AI_TEXT") === "1";
|
|
269
|
+
const prefsEnabled = envOf("BEATRINA_NO_PREFS") !== "1";
|
|
270
|
+
const historyEnabled = () => (settings ? settings.historyEnabled() : envOf("BEATRINA_NO_HISTORY") !== "1");
|
|
260
271
|
|
|
261
272
|
return {
|
|
262
273
|
name: "ai",
|
|
@@ -343,12 +354,21 @@ export function createPlane({ audit, settings } = {}) {
|
|
|
343
354
|
// the attempt is in the audit log. The refusal covers `set` as much as
|
|
344
355
|
// `get`, because a key held under a local-only policy is a key that
|
|
345
356
|
// should never have been accepted.
|
|
346
|
-
|
|
357
|
+
const action = isStr(cmd.action) ? cmd.action : "get";
|
|
358
|
+
// DELETING IS NOT USING. The refusal above covers `get` and `set` —
|
|
359
|
+
// "we will not hand this out, and we will not start keeping a new one"
|
|
360
|
+
// — and it used to be checked before the action was even read, so it
|
|
361
|
+
// covered `clear` as well. That is the one outcome the policy cannot
|
|
362
|
+
// want: a key already on disk under a policy permitting no key-based
|
|
363
|
+
// provider is exactly the key that should be removable, and refusing
|
|
364
|
+
// the removal KEEPS the secret. An authorized deletion always works;
|
|
365
|
+
// the reads stay denied on either side of it.
|
|
366
|
+
const clearing = action === "clear" || (action === "set" && cmd.key === "");
|
|
367
|
+
if (!clearing && policy.set === true && policy.allows_key !== true) {
|
|
347
368
|
audit?.("ai-key-refused", { reason: "policy", class: rec.class || "unknown" });
|
|
348
369
|
reply({ error: aiPolicyReason(policy), policy: true });
|
|
349
370
|
return undefined;
|
|
350
371
|
}
|
|
351
|
-
const action = isStr(cmd.action) ? cmd.action : "get";
|
|
352
372
|
if (action === "get") {
|
|
353
373
|
audit?.("ai-key", { detail: "get", class: rec.class });
|
|
354
374
|
reply({ key: aiKeyRead() });
|
package/host/planes/analyze.mjs
CHANGED
|
@@ -55,6 +55,8 @@ import path from "node:path";
|
|
|
55
55
|
import { REngine } from "../engine-r.mjs";
|
|
56
56
|
import { StdioEngine } from "../engine-stdio.mjs";
|
|
57
57
|
import { relayFrame } from "../worker-plane.mjs";
|
|
58
|
+
import { mirrorEnv } from "../env-names.mjs";
|
|
59
|
+
import { STRIP_PY_ENV } from "../engine-python.mjs";
|
|
58
60
|
|
|
59
61
|
/** The four ops the browser may send. An allow-list, deliberately tiny. */
|
|
60
62
|
export const ANALYZE_FORWARDED = Object.freeze(["analyze", "analyze_ping", "analyze_workspace",
|
|
@@ -74,7 +76,7 @@ export const ANALYZE_REPLY_TYPE = Object.freeze({
|
|
|
74
76
|
*
|
|
75
77
|
* serve.R has no such bound: analysis is milliseconds and the queue is nearly
|
|
76
78
|
* always empty, so a wedged child simply wedged the plane. 120 s is above the
|
|
77
|
-
* widest legitimate answer — `
|
|
79
|
+
* widest legitimate answer — `BEATRINA_ANALYZE_BUDGET` clamps a workspace scan to
|
|
78
80
|
* 60 s — and far below "the editor has no diagnostics and nobody said why".
|
|
79
81
|
* Expiry fails the route AND kills the child, because a parse that did not
|
|
80
82
|
* return in two minutes will not return; the next request spawns a fresh one.
|
|
@@ -83,7 +85,6 @@ export const ANALYZE_DEADLINE_S = 120;
|
|
|
83
85
|
|
|
84
86
|
const scalarChr = (x) => typeof x === "string" && x.length > 0;
|
|
85
87
|
/** What a Python child must not inherit (host/engine-python.mjs's list, restated). */
|
|
86
|
-
const STRIP_PY_ENV = /^(PYTHONHOME|PYTHONPATH|PYTHONSTARTUP|PYTHONEXECUTABLE)$/;
|
|
87
88
|
|
|
88
89
|
/**
|
|
89
90
|
* The Python analysis child: `python -I engines/python/analyze.py <sentinel>`.
|
|
@@ -113,7 +114,7 @@ export class PythonAnalysisEngine extends StdioEngine {
|
|
|
113
114
|
|
|
114
115
|
spawnPlan() {
|
|
115
116
|
return { bin: this.python, args: ["-I", path.resolve(this.workerPath), this.sentinel],
|
|
116
|
-
env: {
|
|
117
|
+
env: mirrorEnv({ BEATRINA_WORKER_MODE: "batch" }) };
|
|
117
118
|
}
|
|
118
119
|
|
|
119
120
|
/** Plain JSON lines, as analyze.R's batch reader takes them: no console to tag into. */
|
|
@@ -136,7 +137,7 @@ export function analyzeEngineOf(cmd) {
|
|
|
136
137
|
|
|
137
138
|
export function createPlane({ root, spike, env, audit }) {
|
|
138
139
|
const deadlineS = (() => {
|
|
139
|
-
const raw = Number(env("
|
|
140
|
+
const raw = Number(env("BEATRINA_ANALYZE_DEADLINE", String(ANALYZE_DEADLINE_S)));
|
|
140
141
|
return Number.isFinite(raw) && raw >= 0 ? raw : ANALYZE_DEADLINE_S;
|
|
141
142
|
})();
|
|
142
143
|
const hostRoot = root || path.resolve(spike, "..");
|
|
@@ -321,7 +322,7 @@ export function createPlane({ root, spike, env, audit }) {
|
|
|
321
322
|
name: "python", label: "Python",
|
|
322
323
|
makeEngine: () => {
|
|
323
324
|
// The SAME interpreter the Python engine runs on — the pool's own
|
|
324
|
-
// discovery, including
|
|
325
|
+
// discovery, including BEATRINA_PYTHON and BEATRINA_NO_PYTHON — so "Python
|
|
325
326
|
// is available" means one thing on this host, and its reason is the
|
|
326
327
|
// one Doctor shows.
|
|
327
328
|
const spec = pool && Array.isArray(pool.specs) ? pool.specs.find((x) => x.name === "python") : null;
|