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.
- package/LICENSE +92 -0
- package/NOTICES +72 -0
- package/README.md +124 -0
- package/bin/beatrina.mjs +223 -0
- package/bin/cli.mjs +80 -0
- package/bin/failsafe.mjs +74 -0
- package/bin/identity.mjs +45 -0
- package/bin/prova-post.mjs +51 -0
- package/bin/sessions.mjs +95 -0
- package/bin/shortcut.mjs +151 -0
- package/bin/update-check.mjs +55 -0
- package/bin/upgrade.mjs +76 -0
- package/build-info.json +1 -0
- package/carmar_V0.8.6.html +1310 -0
- package/check/acceptance.mjs +278 -0
- package/check/session.mjs +215 -0
- package/engines/js/document-exec.mjs +82 -0
- package/engines/js/persist.mjs +214 -0
- package/engines/js/worker.mjs +424 -0
- package/engines/python/adapter.py +577 -0
- package/engines/python/analyze.py +814 -0
- package/engines/python/bootstrap.py +309 -0
- package/engines/python/dataview.py +735 -0
- package/engines/python/debugger.py +346 -0
- package/engines/python/document_exec.py +158 -0
- package/engines/python/engine.json +28 -0
- package/engines/python/handoff.py +118 -0
- package/engines/python/worker.py +564 -0
- package/engines/r/engine.json +25 -0
- package/engines/r/handoff.R +92 -0
- package/failsafe/ai-policy.R +255 -0
- package/failsafe/ai-store.R +373 -0
- package/failsafe/cite.R +418 -0
- package/failsafe/journal.R +684 -0
- package/failsafe/plugins.R +809 -0
- package/failsafe/serve.R +5500 -0
- package/host/ai-policy.mjs +218 -0
- package/host/deployment.mjs +160 -0
- package/host/engine-js.mjs +98 -0
- package/host/engine-pool.mjs +383 -0
- package/host/engine-python.mjs +228 -0
- package/host/engine-r.mjs +206 -0
- package/host/engine-stdio.mjs +401 -0
- package/host/journal-store.mjs +749 -0
- package/host/main.mjs +503 -0
- package/host/planes/README.md +41 -0
- package/host/planes/ai-store.mjs +327 -0
- package/host/planes/ai.mjs +467 -0
- package/host/planes/analyze.mjs +397 -0
- package/host/planes/cite.mjs +517 -0
- package/host/planes/files.mjs +0 -0
- package/host/planes/jobs.mjs +704 -0
- package/host/planes/journal.mjs +53 -0
- package/host/planes/latex.mjs +201 -0
- package/host/planes/mcp.mjs +493 -0
- package/host/planes/pair.mjs +325 -0
- package/host/planes/pipe-term.mjs +122 -0
- package/host/planes/plugins.mjs +112 -0
- package/host/planes/proc-tree.mjs +76 -0
- package/host/planes/sessions.mjs +434 -0
- package/host/planes/settings.mjs +164 -0
- package/host/planes/terminal.mjs +286 -0
- package/host/planes/test-file.mjs +80 -0
- package/host/planes/update.mjs +214 -0
- package/host/plugin-store.mjs +838 -0
- package/host/server.mjs +441 -0
- package/host/settings.mjs +379 -0
- package/host/update-record.mjs +59 -0
- package/host/user-dirs.mjs +117 -0
- package/host/windows-runtime.mjs +145 -0
- package/host/worker-plane.mjs +713 -0
- package/host/ws.mjs +190 -0
- package/kernel/analyze.R +668 -0
- package/kernel/deployment.R +165 -0
- package/kernel/examples/NOTICE.md +38 -0
- package/kernel/examples/tna-complete-tutorial.Rmd +210 -0
- package/kernel/fileio.R +656 -0
- package/kernel/index.html +96 -0
- package/kernel/job-run.R +391 -0
- package/kernel/jobs.R +276 -0
- package/kernel/kernel-protocol +1 -0
- package/kernel/kernel-version +1 -0
- package/kernel/kernel.R +671 -0
- package/kernel/knitr-run.R +245 -0
- package/kernel/latex.R +609 -0
- package/kernel/mcp/carmar-mcp.mjs +516 -0
- package/kernel/notebook-page.R +67 -0
- package/kernel/plugins/csl/apa/apa.csl +2273 -0
- package/kernel/plugins/csl/apa/plugin.json +19 -0
- package/kernel/plugins/csl/chicago-author-date/chicago-author-date.csl +4216 -0
- package/kernel/plugins/csl/chicago-author-date/plugin.json +19 -0
- package/kernel/plugins/csl/harvard-cite-them-right/harvard-cite-them-right.csl +316 -0
- package/kernel/plugins/csl/harvard-cite-them-right/plugin.json +19 -0
- package/kernel/plugins/csl/ieee/ieee.csl +519 -0
- package/kernel/plugins/csl/ieee/plugin.json +19 -0
- package/kernel/plugins/csl/modern-language-association/modern-language-association.csl +1184 -0
- package/kernel/plugins/csl/modern-language-association/plugin.json +19 -0
- package/kernel/plugins/csl/nature/nature.csl +189 -0
- package/kernel/plugins/csl/nature/plugin.json +19 -0
- package/kernel/plugins/latex/apa7/apa7.json +14 -0
- package/kernel/plugins/latex/apa7/plugin.json +19 -0
- package/kernel/plugins/latex/elsarticle/elsarticle.json +14 -0
- package/kernel/plugins/latex/elsarticle/plugin.json +19 -0
- package/kernel/plugins/latex/ieeetran/ieeetran.json +10 -0
- package/kernel/plugins/latex/ieeetran/plugin.json +19 -0
- package/kernel/project.R +131 -0
- package/kernel/settings.R +410 -0
- package/kernel/sniff.R +769 -0
- package/kernel/worker-boot.R +22 -0
- package/kernel/worker.R +3496 -0
- package/lib/agent-authoring-contract.js +547 -0
- package/lib/cell-kinds.js +108 -0
- package/lib/engine-labels.js +324 -0
- 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
|
+
}
|