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.
Files changed (111) hide show
  1. package/NOTICES +1 -1
  2. package/README.md +6 -0
  3. package/beatrina_V0.9.43.html +1885 -0
  4. package/beatrina_V0.9.43.html.inputs.json +1 -0
  5. package/bin/beatrina.mjs +152 -24
  6. package/bin/browser.mjs +31 -0
  7. package/bin/cli.mjs +64 -5
  8. package/bin/doctor-rows.mjs +31 -0
  9. package/bin/failsafe.mjs +8 -3
  10. package/bin/finder.mjs +74 -0
  11. package/bin/lsquery.swift +55 -0
  12. package/bin/pages.mjs +81 -0
  13. package/bin/python-setup.mjs +207 -0
  14. package/bin/runtime-dirs.mjs +27 -0
  15. package/bin/sessions.mjs +30 -10
  16. package/bin/shortcut.mjs +250 -42
  17. package/bin/update-check.mjs +2 -2
  18. package/build-info.json +1 -1
  19. package/engines/js/worker.mjs +6 -3
  20. package/engines/python/adapter.py +14 -13
  21. package/engines/python/analyze.py +4 -2
  22. package/engines/python/bootstrap.py +32 -31
  23. package/engines/python/debugger.py +10 -6
  24. package/engines/python/engine.json +1 -1
  25. package/engines/python/handoff.py +4 -2
  26. package/engines/python/worker.py +39 -10
  27. package/engines/r/engine.json +1 -1
  28. package/engines/r/handoff.R +4 -3
  29. package/failsafe/ai-policy.R +16 -16
  30. package/failsafe/ai-store.R +6 -6
  31. package/failsafe/cite.R +11 -11
  32. package/failsafe/journal.R +11 -11
  33. package/failsafe/plugins.R +27 -27
  34. package/failsafe/serve.R +241 -241
  35. package/host/ai-authority.mjs +55 -0
  36. package/host/ai-policy.mjs +48 -26
  37. package/host/bundle.mjs +194 -0
  38. package/host/deployment.mjs +25 -18
  39. package/host/engine-js.mjs +12 -17
  40. package/host/engine-pool.mjs +48 -6
  41. package/host/engine-python.mjs +80 -31
  42. package/host/engine-r.mjs +20 -21
  43. package/host/engine-stdio.mjs +35 -10
  44. package/host/env-names.mjs +148 -0
  45. package/host/gateway-token.mjs +51 -0
  46. package/host/journal-store.mjs +10 -9
  47. package/host/main.mjs +193 -77
  48. package/host/payload.mjs +131 -0
  49. package/host/planes/README.md +1 -1
  50. package/host/planes/ai-store.mjs +6 -5
  51. package/host/planes/ai.mjs +49 -29
  52. package/host/planes/analyze.mjs +6 -5
  53. package/host/planes/bundle.mjs +344 -0
  54. package/host/planes/choose.mjs +404 -0
  55. package/host/planes/cite.mjs +18 -17
  56. package/host/planes/files.mjs +0 -0
  57. package/host/planes/jobs.mjs +43 -20
  58. package/host/planes/latex.mjs +15 -5
  59. package/host/planes/mcp.mjs +80 -91
  60. package/host/planes/pair.mjs +24 -24
  61. package/host/planes/pipe-term.mjs +2 -2
  62. package/host/planes/plugins.mjs +1 -1
  63. package/host/planes/recent-documents.mjs +170 -0
  64. package/host/planes/sessions.mjs +226 -82
  65. package/host/planes/settings.mjs +4 -4
  66. package/host/planes/terminal.mjs +13 -11
  67. package/host/planes/test-file.mjs +2 -2
  68. package/host/planes/update.mjs +6 -13
  69. package/host/plugin-store.mjs +35 -52
  70. package/host/recent-documents.mjs +124 -0
  71. package/host/runtime-dir.mjs +47 -0
  72. package/host/server.mjs +75 -18
  73. package/host/session-keep.mjs +1 -1
  74. package/host/settings.mjs +85 -37
  75. package/host/update-record.mjs +3 -2
  76. package/host/user-dirs.mjs +60 -18
  77. package/host/which.mjs +39 -0
  78. package/host/windows-runtime.mjs +6 -3
  79. package/host/worker-plane.mjs +34 -6
  80. package/host/ws.mjs +9 -2
  81. package/host/zip.mjs +237 -0
  82. package/kernel/analyze.R +1 -1
  83. package/{check → kernel/check}/acceptance.mjs +1 -1
  84. package/kernel/check/knit-file.mjs +19639 -0
  85. package/{check → kernel/check}/session.mjs +49 -8
  86. package/kernel/deployment.R +20 -20
  87. package/kernel/examples/NOTICE.md +1 -1
  88. package/kernel/fileio.R +6 -6
  89. package/kernel/index.html +2 -2
  90. package/kernel/job-run.R +83 -22
  91. package/kernel/jobs.R +28 -12
  92. package/kernel/kernel-version +1 -1
  93. package/kernel/kernel.R +18 -18
  94. package/kernel/knitr-run.R +50 -9
  95. package/kernel/latex.R +128 -30
  96. package/kernel/mcp/{carmar-mcp.mjs → beatrina-mcp.mjs} +249 -59
  97. package/kernel/notebook-page.R +7 -7
  98. package/kernel/project.R +10 -10
  99. package/kernel/settings.R +105 -56
  100. package/kernel/sniff.R +4 -4
  101. package/kernel/typst.R +121 -0
  102. package/kernel/worker.R +522 -119
  103. package/kernel/workspace-keep.R +3 -3
  104. package/lib/agent-authoring-contract.js +28 -19
  105. package/lib/cell-kinds.js +3 -3
  106. package/lib/engine-labels.js +4 -4
  107. package/menu/Beatrina Menu.app/Contents/Info.plist +14 -0
  108. package/menu/Beatrina Menu.app/Contents/MacOS/Beatrina Menu +0 -0
  109. package/menu/Beatrina Menu.app/Contents/_CodeSignature/CodeResources +115 -0
  110. package/package.json +4 -3
  111. package/carmar_V0.8.7.html +0 -1522
@@ -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
+ }
@@ -1,6 +1,6 @@
1
1
  # host/planes — one supervisor-answered family per module
2
2
 
3
- A plane is what `spike/serve.R` calls an allow-list family: the file ops, the
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
@@ -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 = (name, unset = "") => (process.env[name] == null ? unset : String(process.env[name]));
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("CARMAR_NO_AI_STORE") !== "1";
66
- const root = () => envOf("CARMAR_AI_STORE_DIR") || path.join(stateDir(), "ai");
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), `.carmar-ai-${crypto.randomBytes(8).toString("hex")}`);
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 (CARMAR_NO_AI_STORE=1).");
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.
@@ -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 { carmarAiPolicy, aiPolicyReason } from "../ai-policy.mjs";
28
+ import { beatrinaAiPolicy, aiPolicyReason } from "../ai-policy.mjs";
29
+ import { envReader } from "../env-names.mjs";
29
30
 
30
- const envOf = (name, unset = "") => (process.env[name] == null ? unset : String(process.env[name]));
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
- // CARMAR_LOG itself is off by default for.
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 CARMAR_NO_HISTORY=1 (and from Settings),
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. carmar-chunk-custom is
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
- "carmar-app-theme", // lib/app-themes.js standard/flat/plain/graphite/onyx
94
- "carmar-editor-theme", // lib/editor-themes.js one of 37
95
- "carmar-chunk-style", // lib/chunk-style.js the pre-7.8 key, kept in step
96
- "carmar-chunk-style-v2", // lib/chunk-style.js the explicit choice
97
- "carmar-chunk-custom", // lib/chunk-style.js the designer's token overrides
98
- "carmar-writing-chunk", // lib/writing-chunk.js the Writing surface's own card
99
- "carmar-run-style", // lib/run-style.js
100
- "carmar-table-style-v2", // lib/table-render.js
101
- "carmar-line-numbering", // lib/line-numbering.js
102
- "carmar-density", // lib/density.js
103
- "carmar-add-strip-v3", // lib/add-strip.js
104
- "carmar-soft-wrap", // lib/soft-wrap.js
105
- "carmar-content-width-v1", // src/notebook-entry.js Standard / Wide / Full
106
- "carmar-brand-v1", // lib/brand.js the organisation's accent
107
- "carmar:rich-text", // lib/rtf-editor.js
108
- "carmar:latex-template", // lib/latex-templates.js
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 = carmarAiPolicy();
256
- const logPath = envOf("CARMAR_LOG");
257
- const auditText = envOf("CARMAR_LOG_AI_TEXT") === "1";
258
- const prefsEnabled = envOf("CARMAR_NO_PREFS") !== "1";
259
- const historyEnabled = () => (settings ? settings.historyEnabled() : envOf("CARMAR_NO_HISTORY") !== "1");
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
- if (policy.set === true && policy.allows_key !== true) {
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() });
@@ -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 — `CARMAR_ANALYZE_BUDGET` clamps a workspace scan to
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: { CARMAR_WORKER_MODE: "batch" } };
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("CARMAR_ANALYZE_DEADLINE", String(ANALYZE_DEADLINE_S)));
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 CARMAR_PYTHON and CARMAR_NO_PYTHON — so "Python
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;