beatrina 0.8.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/LICENSE +92 -0
  2. package/NOTICES +72 -0
  3. package/README.md +124 -0
  4. package/bin/beatrina.mjs +223 -0
  5. package/bin/cli.mjs +80 -0
  6. package/bin/failsafe.mjs +74 -0
  7. package/bin/identity.mjs +45 -0
  8. package/bin/prova-post.mjs +51 -0
  9. package/bin/sessions.mjs +95 -0
  10. package/bin/shortcut.mjs +151 -0
  11. package/bin/update-check.mjs +55 -0
  12. package/bin/upgrade.mjs +76 -0
  13. package/build-info.json +1 -0
  14. package/carmar_V0.8.6.html +1310 -0
  15. package/check/acceptance.mjs +278 -0
  16. package/check/session.mjs +215 -0
  17. package/engines/js/document-exec.mjs +82 -0
  18. package/engines/js/persist.mjs +214 -0
  19. package/engines/js/worker.mjs +424 -0
  20. package/engines/python/adapter.py +577 -0
  21. package/engines/python/analyze.py +814 -0
  22. package/engines/python/bootstrap.py +309 -0
  23. package/engines/python/dataview.py +735 -0
  24. package/engines/python/debugger.py +346 -0
  25. package/engines/python/document_exec.py +158 -0
  26. package/engines/python/engine.json +28 -0
  27. package/engines/python/handoff.py +118 -0
  28. package/engines/python/worker.py +564 -0
  29. package/engines/r/engine.json +25 -0
  30. package/engines/r/handoff.R +92 -0
  31. package/failsafe/ai-policy.R +255 -0
  32. package/failsafe/ai-store.R +373 -0
  33. package/failsafe/cite.R +418 -0
  34. package/failsafe/journal.R +684 -0
  35. package/failsafe/plugins.R +809 -0
  36. package/failsafe/serve.R +5500 -0
  37. package/host/ai-policy.mjs +218 -0
  38. package/host/deployment.mjs +160 -0
  39. package/host/engine-js.mjs +98 -0
  40. package/host/engine-pool.mjs +383 -0
  41. package/host/engine-python.mjs +228 -0
  42. package/host/engine-r.mjs +206 -0
  43. package/host/engine-stdio.mjs +401 -0
  44. package/host/journal-store.mjs +749 -0
  45. package/host/main.mjs +503 -0
  46. package/host/planes/README.md +41 -0
  47. package/host/planes/ai-store.mjs +327 -0
  48. package/host/planes/ai.mjs +467 -0
  49. package/host/planes/analyze.mjs +397 -0
  50. package/host/planes/cite.mjs +517 -0
  51. package/host/planes/files.mjs +0 -0
  52. package/host/planes/jobs.mjs +704 -0
  53. package/host/planes/journal.mjs +53 -0
  54. package/host/planes/latex.mjs +201 -0
  55. package/host/planes/mcp.mjs +493 -0
  56. package/host/planes/pair.mjs +325 -0
  57. package/host/planes/pipe-term.mjs +122 -0
  58. package/host/planes/plugins.mjs +112 -0
  59. package/host/planes/proc-tree.mjs +76 -0
  60. package/host/planes/sessions.mjs +434 -0
  61. package/host/planes/settings.mjs +164 -0
  62. package/host/planes/terminal.mjs +286 -0
  63. package/host/planes/test-file.mjs +80 -0
  64. package/host/planes/update.mjs +214 -0
  65. package/host/plugin-store.mjs +838 -0
  66. package/host/server.mjs +441 -0
  67. package/host/settings.mjs +379 -0
  68. package/host/update-record.mjs +59 -0
  69. package/host/user-dirs.mjs +117 -0
  70. package/host/windows-runtime.mjs +145 -0
  71. package/host/worker-plane.mjs +713 -0
  72. package/host/ws.mjs +190 -0
  73. package/kernel/analyze.R +668 -0
  74. package/kernel/deployment.R +165 -0
  75. package/kernel/examples/NOTICE.md +38 -0
  76. package/kernel/examples/tna-complete-tutorial.Rmd +210 -0
  77. package/kernel/fileio.R +656 -0
  78. package/kernel/index.html +96 -0
  79. package/kernel/job-run.R +391 -0
  80. package/kernel/jobs.R +276 -0
  81. package/kernel/kernel-protocol +1 -0
  82. package/kernel/kernel-version +1 -0
  83. package/kernel/kernel.R +671 -0
  84. package/kernel/knitr-run.R +245 -0
  85. package/kernel/latex.R +609 -0
  86. package/kernel/mcp/carmar-mcp.mjs +516 -0
  87. package/kernel/notebook-page.R +67 -0
  88. package/kernel/plugins/csl/apa/apa.csl +2273 -0
  89. package/kernel/plugins/csl/apa/plugin.json +19 -0
  90. package/kernel/plugins/csl/chicago-author-date/chicago-author-date.csl +4216 -0
  91. package/kernel/plugins/csl/chicago-author-date/plugin.json +19 -0
  92. package/kernel/plugins/csl/harvard-cite-them-right/harvard-cite-them-right.csl +316 -0
  93. package/kernel/plugins/csl/harvard-cite-them-right/plugin.json +19 -0
  94. package/kernel/plugins/csl/ieee/ieee.csl +519 -0
  95. package/kernel/plugins/csl/ieee/plugin.json +19 -0
  96. package/kernel/plugins/csl/modern-language-association/modern-language-association.csl +1184 -0
  97. package/kernel/plugins/csl/modern-language-association/plugin.json +19 -0
  98. package/kernel/plugins/csl/nature/nature.csl +189 -0
  99. package/kernel/plugins/csl/nature/plugin.json +19 -0
  100. package/kernel/plugins/latex/apa7/apa7.json +14 -0
  101. package/kernel/plugins/latex/apa7/plugin.json +19 -0
  102. package/kernel/plugins/latex/elsarticle/elsarticle.json +14 -0
  103. package/kernel/plugins/latex/elsarticle/plugin.json +19 -0
  104. package/kernel/plugins/latex/ieeetran/ieeetran.json +10 -0
  105. package/kernel/plugins/latex/ieeetran/plugin.json +19 -0
  106. package/kernel/project.R +131 -0
  107. package/kernel/settings.R +410 -0
  108. package/kernel/sniff.R +769 -0
  109. package/kernel/worker-boot.R +22 -0
  110. package/kernel/worker.R +3496 -0
  111. package/lib/agent-authoring-contract.js +547 -0
  112. package/lib/cell-kinds.js +108 -0
  113. package/lib/engine-labels.js +324 -0
  114. package/package.json +32 -0
@@ -0,0 +1,218 @@
1
+ // ai-policy.mjs — which AI providers an administrator permits, and what the
2
+ // kernel can actually do about it. A transcription of spike/ai-policy.R.
3
+ //
4
+ // ── the honest scope, first, because everything else depends on it ──────────
5
+ //
6
+ // The notebook runs in a browser the user controls, and the AI request for a
7
+ // key-based provider is a `fetch()` from that page. No kernel can prevent a
8
+ // determined user with devtools and their own API key from calling any endpoint
9
+ // they like. Any document promising otherwise would be lying.
10
+ //
11
+ // What an administrator policy DOES buy is three things: a default the user did
12
+ // not choose and cannot casually change, the constraint made VISIBLE rather
13
+ // than discovered by hitting it, and the two doors the supervisor genuinely
14
+ // owns, closed:
15
+ //
16
+ // · `ai-key` — the provider key is handed out by the supervisor. Under a
17
+ // policy permitting no key-based provider it stops handing it out AND stops
18
+ // accepting one. The user can still paste a key into the tab; the kernel
19
+ // will not keep one for them, and the refusal is audited.
20
+ // · `agent-chat` — a CLI turn is a process the SUPERVISOR spawns. A policy
21
+ // that does not permit it means it is never spawned, with no page-side
22
+ // bypass because there is no page-side spawn. (That door is WP5b's; this
23
+ // module answers the question for it.)
24
+ //
25
+ // Everything else is disclosure and audit.
26
+ //
27
+ // ── where a policy comes from ──────────────────────────────────────────────
28
+ //
29
+ // CARMAR_AI_POLICY=/etc/carmar/ai-policy.json a file the user cannot write
30
+ // CARMAR_AI_PROVIDERS=anthropic,lmstudio the quick form
31
+ // CARMAR_AI_LOCAL_ONLY=1 only on-machine providers
32
+ //
33
+ // The file wins where both are set, because a file is the form an administrator
34
+ // can own while an environment variable is inherited from whoever started the
35
+ // process — on a shared host, that may be the user.
36
+
37
+ import fs from "node:fs";
38
+
39
+ /**
40
+ * The providers whose text never leaves the machine.
41
+ *
42
+ * THIS LIST IS A CROSS-LANGUAGE CONTRACT. It must agree with `staysOnMachine()`
43
+ * in lib/llm.js, which is the browser's answer to the same question;
44
+ * test/ai-policy.test.mjs reads both and fails if they diverge. Note what is
45
+ * NOT here: `claudecode` and `codex` carry `local: true` in lib/llm.js because
46
+ * they cost no API key, but they hand the prompt to a CLI that sends it to a
47
+ * vendor. Using `local` to mean "stays here" would quietly ship the user's code
48
+ * out.
49
+ */
50
+ export const ON_MACHINE_PROVIDERS = Object.freeze(["chrome", "webgpu", "ollama", "lmstudio", "jan"]);
51
+ /** Providers reached with the user's API key — the ones the `ai-key` door serves. */
52
+ export const KEY_PROVIDERS = Object.freeze(["openai", "anthropic", "google", "groq", "together", "openrouter", "azure", "custom"]);
53
+ /** Providers that are a CLI process the SUPERVISOR spawns. The door it owns. */
54
+ export const CLI_PROVIDERS = Object.freeze(["claudecode", "codex"]);
55
+ export const ALL_PROVIDERS = Object.freeze([...ON_MACHINE_PROVIDERS, ...KEY_PROVIDERS, ...CLI_PROVIDERS]);
56
+
57
+ const sysEnv = (name, unset = "") => (process.env[name] == null ? unset : String(process.env[name]));
58
+ const asList = (v) => (v == null ? [] : (Array.isArray(v) ? v : [v]));
59
+
60
+ /**
61
+ * Normalise a policy file's `models` map into provider → array of ids.
62
+ * Accepts a single string as a one-element list, because
63
+ * `"anthropic": "claude-sonnet-4"` is what an administrator will write and
64
+ * refusing it over a missing pair of brackets would be pedantry with a startup
65
+ * failure attached.
66
+ */
67
+ export function normaliseModels(raw) {
68
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
69
+ const out = {};
70
+ for (const key of Object.keys(raw)) {
71
+ if (!key.length) continue;
72
+ const ids = [...new Set(asList(raw[key]).flat().map((v) => String(v).trim()))].filter((v) => v.length);
73
+ if (ids.length) out[key] = ids;
74
+ }
75
+ return out;
76
+ }
77
+
78
+ const HOSTED_RE = /^https:\/\/[^/@?#\s]+(?::[0-9]+)?(?:\/[^?#\s]*)?\/?$/i;
79
+ const LOOPBACK_RE = /^http:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::[0-9]+)?(?:\/[^?#\s]*)?\/?$/i;
80
+
81
+ /**
82
+ * Normalise administrator-pinned provider endpoints. These values are returned
83
+ * to the page, so credentials, query strings and fragments are forbidden.
84
+ * Hosted gateways must use HTTPS. Loopback HTTP is allowed only for the
85
+ * providers that genuinely run on this machine.
86
+ */
87
+ export function normaliseBaseUrls(raw) {
88
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { values: {}, errors: [] };
89
+ const values = {};
90
+ const errors = [];
91
+ for (const provider of Object.keys(raw)) {
92
+ if (!provider.length) continue;
93
+ const list = asList(raw[provider]).flat().map((v) => String(v));
94
+ const value = list.length ? list[0].trim() : "";
95
+ const hosted = HOSTED_RE.test(value);
96
+ const loopback = ON_MACHINE_PROVIDERS.includes(provider) && LOOPBACK_RE.test(value);
97
+ if (!value.length || list.length !== 1 || (!hosted && !loopback)) {
98
+ errors.push(`AI policy base_urls.${provider} must be one credential-free HTTPS URL (or loopback HTTP for an on-machine provider).`);
99
+ } else values[provider] = value;
100
+ }
101
+ return { values, errors };
102
+ }
103
+
104
+ const policySplit = (value) => String(value || "").split(/[,\s]+/).map((s) => s.trim()).filter((s) => s.length);
105
+ // Control characters are 0x00-0x1f and 0x7f — R's [[:cntrl:]], spelled out.
106
+ // eslint-disable-next-line no-control-regex
107
+ const stripControl = (s) => String(s).replace(/[\u0000-\u001f\u007f]/g, " ");
108
+
109
+ /**
110
+ * Read the administrator's AI policy.
111
+ *
112
+ * A policy that names NOTHING valid is an ERROR, never an empty allow-list:
113
+ * silently permitting no provider would look identical to the AI being broken,
114
+ * and an administrator would have no way to tell which it was. Three states —
115
+ * named nothing / named something valid / named only junk — and conflating the
116
+ * last two is the fail-open bug.
117
+ */
118
+ export function carmarAiPolicy(env = sysEnv) {
119
+ const file = String(env("CARMAR_AI_POLICY", "")).trim();
120
+ let named = [];
121
+ let models = {};
122
+ let baseUrls = {};
123
+ let localOnly = env("CARMAR_AI_LOCAL_ONLY", "") === "1";
124
+ let note = "";
125
+ let source = "";
126
+ let errors = [];
127
+
128
+ if (file.length) {
129
+ source = file;
130
+ if (!fs.existsSync(file)) {
131
+ errors.push(`CARMAR_AI_POLICY names a file that does not exist: ${file}`);
132
+ } else {
133
+ let doc = null;
134
+ let parseError = "";
135
+ try { doc = JSON.parse(fs.readFileSync(file, "utf8")); } catch (e) { parseError = e.message; }
136
+ if (parseError) errors.push(`CARMAR_AI_POLICY is not valid JSON: ${parseError}`);
137
+ else {
138
+ named = asList(doc && doc.providers).flat().map((v) => String(v));
139
+ if (doc && doc.local_only === true) localOnly = true;
140
+ note = String(asList(doc && doc.note).flat()[0] ?? "").trim();
141
+ // `models` is FILE-ONLY, deliberately. A provider list flattens to a
142
+ // comma-separated environment variable without losing anything; a map
143
+ // of provider to permitted model ids does not, and every syntax that
144
+ // crams one into a single string is a syntax a typo hides in.
145
+ models = normaliseModels(doc && doc.models);
146
+ const endpoints = normaliseBaseUrls(doc && doc.base_urls);
147
+ baseUrls = endpoints.values;
148
+ errors = errors.concat(endpoints.errors);
149
+ }
150
+ }
151
+ } else if (String(env("CARMAR_AI_PROVIDERS", "")).length) {
152
+ source = "env";
153
+ named = policySplit(env("CARMAR_AI_PROVIDERS", ""));
154
+ } else if (localOnly) {
155
+ source = "env";
156
+ }
157
+
158
+ const unknown = named.filter((n) => !ALL_PROVIDERS.includes(n));
159
+ const asked = named.length > 0; // did the policy NAME anything at all?
160
+ named = named.filter((n) => ALL_PROVIDERS.includes(n));
161
+
162
+ let providers = named.length ? named : (asked ? [] : [...ALL_PROVIDERS]);
163
+ if (localOnly) providers = providers.filter((p) => ON_MACHINE_PROVIDERS.includes(p));
164
+
165
+ let set = source.length > 0 && errors.length === 0;
166
+ if (set && !providers.length) {
167
+ errors.push(`The AI policy permits no provider at all${
168
+ unknown.length ? ` (unrecognised: ${unknown.join(", ")})` : ""}${
169
+ localOnly ? " — local_only was combined with providers that all send text off the machine" : ""
170
+ }. Name at least one, or remove the policy.`);
171
+ }
172
+ if (errors.length) { set = false; providers = [...ALL_PROVIDERS]; }
173
+
174
+ // Control characters out: this string is rendered in the notebook and written
175
+ // to the audit log, and an administrator's file is still a file.
176
+ note = stripControl(note).slice(0, 400).trim();
177
+
178
+ // A models entry for a provider that is not permitted is dead weight, and
179
+ // leaving it in would make the page render a constraint on a control the user
180
+ // cannot reach anyway.
181
+ const keep = (map) => Object.fromEntries(Object.entries(map).filter(([k]) => providers.includes(k)));
182
+ models = errors.length ? {} : keep(models);
183
+ baseUrls = errors.length ? {} : keep(baseUrls);
184
+
185
+ return { set, source, providers, models, base_urls: baseUrls, local_only: localOnly, note, unknown, errors,
186
+ // The two doors the supervisor actually owns, precomputed so the call sites
187
+ // read as the questions they are asking.
188
+ allows_key: providers.some((p) => KEY_PROVIDERS.includes(p)),
189
+ allows_cli: providers.filter((p) => CLI_PROVIDERS.includes(p)) };
190
+ }
191
+
192
+ /** May this provider be used at all? */
193
+ export const aiProviderAllowed = (provider, policy) =>
194
+ policy.set !== true || (typeof provider === "string" && provider.length > 0 && policy.providers.includes(provider));
195
+
196
+ /**
197
+ * May this model be used with this provider? TRUE when no models are pinned for
198
+ * that provider — pinning is opt-in per provider. DISCLOSURE ONLY: a model id
199
+ * is a field in a request body the page builds and sends itself, so there is no
200
+ * door here to close.
201
+ */
202
+ export const aiModelAllowed = (provider, model, policy) => {
203
+ if (policy.set !== true) return true;
204
+ const allowed = policy.models[provider || ""];
205
+ if (!allowed || !allowed.length) return true;
206
+ return typeof model === "string" && model.length > 0 && allowed.includes(model);
207
+ };
208
+
209
+ /**
210
+ * The sentence the notebook shows when something is refused. Names the
211
+ * administrator's own note when there is one, because "your administrator has
212
+ * limited this" without saying who or why is the worst version of a policy
213
+ * message.
214
+ */
215
+ export const aiPolicyReason = (policy) => {
216
+ const base = `This CarmaR is configured to allow only: ${policy.providers.join(", ")}.`;
217
+ return policy.note.length ? `${base} ${policy.note}` : base;
218
+ };
@@ -0,0 +1,160 @@
1
+ // deployment.mjs — where the host listens, and who is allowed to reach it.
2
+ //
3
+ // A pure port of spike/deployment.R. The posture is computed ONCE from the
4
+ // environment and handed to the gates as a decided answer; every gate reads a
5
+ // field of it rather than re-interpreting an environment variable at each call
6
+ // site. `env` is injected so the whole policy is testable without a server —
7
+ // test/host-deployment.test.mjs runs the same vectors spike/test-deployment.R
8
+ // runs against the R implementation, which is what keeps two implementations of
9
+ // one trust model from drifting.
10
+ //
11
+ // The rule that INVERTS off loopback (and why this file exists at all): a
12
+ // request with no Origin header is allowed on 127.0.0.1 because it proves the
13
+ // client is not a browser and therefore is this same OS user; anywhere the port
14
+ // is reachable by someone else the same header proves nothing, so it is refused.
15
+
16
+ export const LOOPBACK_HOSTS = ["127.0.0.1", "::1", "[::1]", "localhost"];
17
+
18
+ /** Split a comma/space-separated environment value into trimmed, non-empty parts. */
19
+ export function splitList(value) {
20
+ return String(value ?? "").split(/[,\s]+/).map((s) => s.trim()).filter(Boolean);
21
+ }
22
+
23
+ /**
24
+ * Is this bind address one of the loopback literals?
25
+ *
26
+ * Deliberately a literal comparison and not a resolve: a NAME that currently
27
+ * resolves to 127.0.0.1 is not the same promise as an address that cannot leave
28
+ * the machine, and the whole point of the flag is that it cannot be talked into
29
+ * being true.
30
+ */
31
+ export function isLoopbackBind(bind) {
32
+ return LOOPBACK_HOSTS.includes(String(bind ?? "").toLowerCase());
33
+ }
34
+
35
+ /**
36
+ * Compute the deployment posture from the environment.
37
+ *
38
+ * @param {number} port the port the server will listen on
39
+ * @param {(name: string, unset?: string) => string} [env] a getter with
40
+ * `Sys.getenv`'s signature; defaults to `process.env`
41
+ * @returns {{bind:string, port:number, loopback:boolean, origins:string[],
42
+ * hosts:string[], allow_native:boolean, require_origin:boolean,
43
+ * trust_proxy:boolean, user_header:string, user_header_key:string,
44
+ * proxy_addrs:string[], allow_unauthenticated:boolean, errors:string[]}}
45
+ * `errors` is EMPTY when the posture is safe to serve. A non-empty `errors`
46
+ * must abort startup; it is never a warning.
47
+ */
48
+ export function carmarDeployment(port, env = envFromProcess()) {
49
+ let bind = env("CARMAR_BIND", "127.0.0.1").trim();
50
+ if (!bind) bind = "127.0.0.1";
51
+ const loopback = isLoopbackBind(bind);
52
+
53
+ // The origin this host serves its own page from is always allowed: that is
54
+ // the same-origin case. Off loopback the server is behind a proxy, so its own
55
+ // page arrives with the PROXY's origin — which the operator must name.
56
+ const selfOrigins = [`http://${bind}:${port}`, `http://127.0.0.1:${port}`, `http://localhost:${port}`];
57
+ const origins = unique([...selfOrigins, ...splitList(env("CARMAR_ORIGINS", ""))]);
58
+
59
+ let hostsEnv = splitList(env("CARMAR_HOSTS", ""));
60
+ const defaultHosts = [`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`];
61
+ // A configured host may or may not carry a port. Accept both spellings so an
62
+ // operator writing `stats.example.edu` is not silently refused behind a proxy
63
+ // that forwards the default 80/443 and therefore sends no port at all.
64
+ hostsEnv = unique([...hostsEnv, ...hostsEnv.map((h) => (h.includes(":") ? h : `${h}:${port}`))]);
65
+ const hosts = unique([...defaultHosts, ...hostsEnv].map((h) => h.toLowerCase()));
66
+
67
+ // "Anyone who reaches this port is this same OS user" is the premise behind
68
+ // allowing an Origin-less client. Binding off loopback breaks it, but so does
69
+ // a shared multi-user host or a container whose 127.0.0.1 is forwarded out,
70
+ // so the posture is declarable in its own right.
71
+ const requireOrigin = env("CARMAR_REQUIRE_ORIGIN", "") === "1" || !loopback;
72
+
73
+ const trustProxy = env("CARMAR_TRUST_PROXY", "") === "1";
74
+ const userHeader = env("CARMAR_USER_HEADER", "X-Forwarded-User").trim();
75
+ const proxyAddrs = splitList(env("CARMAR_TRUSTED_PROXY", "127.0.0.1 ::1"));
76
+ const allowOpen = env("CARMAR_ALLOW_UNAUTHENTICATED", "") === "1";
77
+
78
+ const errors = [];
79
+ if (!loopback) {
80
+ if (!hostsEnv.length) {
81
+ errors.push(`CARMAR_BIND is ${bind} but CARMAR_HOSTS is unset. Off loopback the `
82
+ + "Host allow-list is the only defence against DNS rebinding, so it must "
83
+ + "name the hostname readers will use (e.g. CARMAR_HOSTS=stats.example.edu).");
84
+ }
85
+ if (!trustProxy && !allowOpen) {
86
+ errors.push(`CARMAR_BIND is ${bind} with no authentication in front of it. Put `
87
+ + "Beatrina behind a reverse proxy that authenticates and set "
88
+ + "CARMAR_TRUST_PROXY=1, or set CARMAR_ALLOW_UNAUTHENTICATED=1 to serve code "
89
+ + "execution to everyone who can reach this port. See docs/server.md.");
90
+ }
91
+ if (trustProxy && !userHeader) {
92
+ errors.push("CARMAR_TRUST_PROXY=1 requires a non-empty CARMAR_USER_HEADER.");
93
+ }
94
+ if (trustProxy && !proxyAddrs.length) {
95
+ errors.push("CARMAR_TRUST_PROXY=1 requires CARMAR_TRUSTED_PROXY to name the proxy's address.");
96
+ }
97
+ }
98
+
99
+ return {
100
+ bind,
101
+ port,
102
+ loopback,
103
+ origins,
104
+ hosts,
105
+ // THE INVERSION. On a private loopback an absent Origin is a same-user
106
+ // native client and is allowed; anywhere the port is reachable by someone
107
+ // else it is an unauthenticated stranger and is not.
108
+ allow_native: !requireOrigin,
109
+ require_origin: requireOrigin,
110
+ trust_proxy: trustProxy && requireOrigin,
111
+ user_header: userHeader,
112
+ // The Rook spelling R's gates read: `HTTP_` + uppercase, `-` → `_`. Kept
113
+ // so the vectors stay word-for-word comparable across the two hosts.
114
+ user_header_key: `HTTP_${userHeader.toUpperCase().replace(/-/g, "_")}`,
115
+ proxy_addrs: proxyAddrs,
116
+ allow_unauthenticated: allowOpen,
117
+ errors,
118
+ };
119
+ }
120
+
121
+ /**
122
+ * Who is this request, according to the proxy in front of us?
123
+ *
124
+ * Returns "" unless the posture trusts a proxy AND the request actually came
125
+ * from one of the named proxy addresses. That second half is what stops the
126
+ * header being a self-service login.
127
+ *
128
+ * @param {{REMOTE_ADDR?: string} & Record<string, string>} req a Rook-shaped
129
+ * request: `REMOTE_ADDR` plus `HTTP_*` header keys
130
+ * @param {ReturnType<typeof carmarDeployment>} deployment
131
+ * @returns {string}
132
+ */
133
+ export function proxyUser(req, deployment) {
134
+ if (!deployment.trust_proxy) return "";
135
+ const peer = String(req.REMOTE_ADDR ?? "").toLowerCase();
136
+ if (!peer || !deployment.proxy_addrs.map((a) => a.toLowerCase()).includes(peer)) return "";
137
+ let name = String(req[deployment.user_header_key] ?? "");
138
+ // One line, printable, bounded — it reaches the audit log and the page title.
139
+ // eslint-disable-next-line no-control-regex
140
+ name = name.replace(/[\x00-\x1f\x7f]/g, "");
141
+ return name.slice(0, 128).trim();
142
+ }
143
+
144
+ /** Node's request headers, spelled the way `proxyUser` reads them. */
145
+ export function rookOf(req) {
146
+ const out = { REMOTE_ADDR: req.socket?.remoteAddress ?? "" };
147
+ for (const [k, v] of Object.entries(req.headers || {})) {
148
+ out[`HTTP_${k.toUpperCase().replace(/-/g, "_")}`] = Array.isArray(v) ? v.join(", ") : String(v);
149
+ }
150
+ return out;
151
+ }
152
+
153
+ /** A getter with `Sys.getenv`'s signature over `process.env`. */
154
+ export function envFromProcess(source = process.env) {
155
+ return (name, unset = "") => (source[name] == null ? unset : String(source[name]));
156
+ }
157
+
158
+ function unique(list) {
159
+ return [...new Set(list)];
160
+ }
@@ -0,0 +1,98 @@
1
+ // engine-js.mjs — the JavaScript engine's adapter (docs/beatrix-alternative-plan.md Stage 5).
2
+ //
3
+ // The process behind it is `engines/js/worker.mjs`, run by a Node the host
4
+ // can name. It speaks the wire worker.R and worker.py speak, so everything but
5
+ // three answers is StdioEngine's:
6
+ //
7
+ // · WHICH NODE. The host's own `process.execPath` when the host is a plain
8
+ // `node host/main.mjs` — the Node the R launcher already found and
9
+ // version-checked. A packaged single-executable host (node:sea) cannot run
10
+ // a script, so there the engine needs `BEATRIX_NODE` / `CARMAR_NODE`, and
11
+ // says so on the ready frame rather than appearing and failing.
12
+ // · A CONSOLE LINE MEANS NOTHING. The worker reads commands only; there is
13
+ // no prompt, so canInput and canDebug are false and the plane refuses
14
+ // input_reply and debug_cmd for this engine by those capabilities.
15
+ // · STOP is the same group SIGINT: the worker leads its own process group and
16
+ // runs every chunk with vm's `breakOnSigint`, so a tight loop is interrupted
17
+ // with the session's variables kept.
18
+
19
+ import fs from "node:fs";
20
+ import { StdioEngine } from "./engine-stdio.mjs";
21
+ import { findInterruptTool } from "./windows-runtime.mjs";
22
+
23
+ export const JS_MIN_NODE = 22;
24
+
25
+ /**
26
+ * Find a Node for the JavaScript engine, and SAY WHAT WAS DECIDED.
27
+ * @returns {{available: boolean, path: string, version: string, detail: string}}
28
+ */
29
+ export function detectNode({ env = process.env, packaged = false, execPath = process.execPath, versions = process.versions } = {}) {
30
+ const explicit = env.BEATRIX_NODE || env.CARMAR_NODE || "";
31
+ if (explicit) {
32
+ if (!fs.existsSync(explicit)) return { available: false, path: "", version: "", detail: `BEATRIX_NODE points at ${explicit}, which does not exist.` };
33
+ return { available: true, path: explicit, version: "", detail: `Node at ${explicit} (BEATRIX_NODE)` };
34
+ }
35
+ if (packaged) {
36
+ // A single executable IS a Node: it runs the worker bundled inside it when
37
+ // started with BEATRIX_ENGINE_ROLE=js (the retired executable build; kept for any old binary).
38
+ return { available: true, path: execPath, version: versions.node, detail: `Node ${versions.node} (inside this executable)`, selfHosted: true };
39
+ }
40
+ const major = Number(String(versions.node || "").split(".")[0]);
41
+ if (!(major >= JS_MIN_NODE)) return { available: false, path: "", version: versions.node || "", detail: `JavaScript chunks need Node ${JS_MIN_NODE} or newer; this host runs on ${versions.node}.` };
42
+ return { available: true, path: execPath, version: versions.node, detail: `Node ${versions.node}` };
43
+ }
44
+
45
+ /** One `engines/js/worker.mjs` process. */
46
+ export class JsEngine extends StdioEngine {
47
+ /**
48
+ * @param {Object} opts
49
+ * @param {string} opts.workerPath engines/js/worker.mjs
50
+ * @param {string} opts.node the Node detectNode chose
51
+ * @param {Record<string,string>} [opts.env]
52
+ * @param {string} [opts.cwd]
53
+ */
54
+ constructor({ workerPath, node, env = {}, cwd, platform = process.platform, selfHosted = false } = {}) {
55
+ super({ language: "js", workerPath, env, cwd, platform });
56
+ this.selfHosted = selfHosted;
57
+ if (!selfHosted && (!workerPath || !fs.existsSync(workerPath))) throw new Error(`JsEngine: no worker at ${workerPath}`);
58
+ if (!node) throw new Error("JsEngine: no Node found (set BEATRIX_NODE)");
59
+ this.node = node;
60
+ if (platform === "win32") this.interruptTool = findInterruptTool({ env: process.env });
61
+ }
62
+
63
+ get label() { return "JavaScript"; }
64
+ get canInput() { return false; }
65
+ get canDebug() { return false; }
66
+
67
+ spawnPlan() {
68
+ // The worker's dynamic import() goes through vm's main-context loader,
69
+ // which Node still labels experimental and announces on stderr — into the
70
+ // first chunk that imports anything. The label is Node's; the output is not
71
+ // the user's, so it is not printed.
72
+ if (this.selfHosted) return { bin: this.node, args: [], env: { CARMAR_WORKER_MODE: "batch", BEATRIX_ENGINE_ROLE: "js" } };
73
+ return { bin: this.node, args: ["--disable-warning=ExperimentalWarning", this.workerPath], env: { CARMAR_WORKER_MODE: "batch" } };
74
+ }
75
+
76
+ /**
77
+ * ONE SIGINT, to the worker's process group — never the pair the base class sends.
78
+ *
79
+ * The second signal of that pair exists for R's system() (engine-stdio.mjs). A Node worker does
80
+ * not need it: vm's `breakOnSigint` watchdog catches the group signal even while a blocking call
81
+ * such as execSync runs, and the child that call started gets the same group signal. And the
82
+ * second signal is harmful: it lands while `breakOnSigint` is tearing its watchdog down, when no
83
+ * handler is installed, and the default action ends the process — the session's variables with
84
+ * it. Measured 2026-09-16 with the pair sent exactly as the base class sends it: Linux (Node
85
+ * 22.23.2) 19/20 workers died; macOS (Node 25.5.0) 1/20. test/js-interrupt.test.mjs pins it.
86
+ */
87
+ interrupt(stillWanted = () => true) {
88
+ if (this.platform === "win32") return super.interrupt(stillWanted);
89
+ if (!this.alive || !this.pid) return false;
90
+ try { this.killImpl(-this.pid, "SIGINT"); } catch {
91
+ try { this.killImpl(this.pid, "SIGINT"); } catch { return false; } // not a group leader after all
92
+ }
93
+ return true;
94
+ }
95
+
96
+ /** There is no prompt behind this worker: a console line is refused, not typed. */
97
+ console() { throw new Error("The JavaScript engine has no console prompt."); }
98
+ }