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,379 @@
|
|
|
1
|
+
// settings.mjs — the settings a USER may change, and the ones only an
|
|
2
|
+
// administrator may. A transcription of spike/settings.R, rule for rule.
|
|
3
|
+
//
|
|
4
|
+
// ── THE PRECEDENCE RULE ─────────────────────────────────────────────────────
|
|
5
|
+
//
|
|
6
|
+
// environment variable (set and non-empty) ▸ config file ▸ built-in default
|
|
7
|
+
//
|
|
8
|
+
// and the two consequences that matter more than the ordering:
|
|
9
|
+
//
|
|
10
|
+
// · A setting whose env var is set is READ-ONLY for the life of this kernel.
|
|
11
|
+
// `settings_set` REFUSES it and does not write the file, because a value
|
|
12
|
+
// silently stored and never applied is a worse lie than a refusal.
|
|
13
|
+
// · An administrator's setting is NOT IN THIS FILE'S VOCABULARY AT ALL. It is
|
|
14
|
+
// rejected on read and refused on write, always — never "env wins if
|
|
15
|
+
// present", because that only guarantees anything when the env IS set, and
|
|
16
|
+
// a deployment that never set CARMAR_ROOT is still one where a user must
|
|
17
|
+
// not invent one.
|
|
18
|
+
//
|
|
19
|
+
// The tempting alternative — one layered getter handed to carmarDeployment()
|
|
20
|
+
// and the AI policy — is deliberately NOT built here either. It would put every
|
|
21
|
+
// administrator variable one argument-passing mistake away from being
|
|
22
|
+
// file-settable. Those read process.env directly; only the user-writable call
|
|
23
|
+
// sites read this module.
|
|
24
|
+
//
|
|
25
|
+
// Managed configuration (Jamf/Intune) is applied to process.env BEFORE any of
|
|
26
|
+
// that resolves, for the reason settings.R gives: a managed desktop cannot
|
|
27
|
+
// depend on shell startup files, so the administrator's values have to become
|
|
28
|
+
// ordinary environment variables early enough that the env-wins-and-locks rule
|
|
29
|
+
// stays the single source of truth.
|
|
30
|
+
|
|
31
|
+
import fs from "node:fs";
|
|
32
|
+
|
|
33
|
+
import path from "node:path";
|
|
34
|
+
import { appId, ensureUserDir, privateModeHeld } from "./user-dirs.mjs";
|
|
35
|
+
|
|
36
|
+
export const SETTINGS_VERSION = 1;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The whole vocabulary. A key that is not here does not exist: there is no
|
|
40
|
+
* pass-through, so the file cannot grow a control by accident.
|
|
41
|
+
*
|
|
42
|
+
* `effect` is a FIELD, not a sentence the page composes, so a setting added
|
|
43
|
+
* later cannot arrive without an honest answer to "when does this take
|
|
44
|
+
* effect?". Its three values: live · restart_r · next_launch.
|
|
45
|
+
*/
|
|
46
|
+
export const SETTINGS = Object.freeze([
|
|
47
|
+
// Connection loss is not permission to discard the R workspace. Timed
|
|
48
|
+
// shutdown is opt-in; 0 retains an attached session until an explicit Quit.
|
|
49
|
+
{ key: "linger_seconds", env: "CARMAR_LINGER", kind: "number", default: 0, min: 0, max: 86400, effect: "live", label: "Keep R running after all pages disconnect" },
|
|
50
|
+
{ key: "history_enabled", env: "CARMAR_NO_HISTORY", kind: "bool", default: true, effect: "live", inverted: true, label: "Remember console history between sessions" },
|
|
51
|
+
{ key: "analyze_budget_s", env: "CARMAR_ANALYZE_BUDGET", kind: "number", default: 3, min: 0, max: 60, effect: "next_launch", label: "Code-intelligence time budget" },
|
|
52
|
+
{ key: "quarto_path", env: "CARMAR_QUARTO", kind: "path", default: "", effect: "live", label: "Quarto binary" },
|
|
53
|
+
{ key: "rscript_path", env: "CARMAR_RSCRIPT", kind: "path", default: "", effect: "restart_r", label: "R binary" },
|
|
54
|
+
{ key: "port", env: "CARMAR_PORT", kind: "number", default: 4747, min: 1024, max: 65535, effect: "next_launch", label: "Preferred port" },
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Reported to the page as read-only facts, never settable. Everything an
|
|
59
|
+
* administrator owns lives here and NOWHERE in SETTINGS, which is what makes
|
|
60
|
+
* the refusal structural rather than a lookup someone can forget.
|
|
61
|
+
*/
|
|
62
|
+
export const SETTINGS_ADMIN = Object.freeze([
|
|
63
|
+
"CARMAR_MANAGED_CONFIG",
|
|
64
|
+
"CARMAR_ROOT", "CARMAR_LOG", "CARMAR_LOG_AI_TEXT",
|
|
65
|
+
"CARMAR_CRAN_MIRROR",
|
|
66
|
+
"CARMAR_AI_LOCAL_ONLY", "CARMAR_AI_POLICY", "CARMAR_AI_PROVIDERS",
|
|
67
|
+
"CARMAR_BIND", "CARMAR_HOSTS", "CARMAR_ORIGINS", "CARMAR_REQUIRE_ORIGIN",
|
|
68
|
+
"CARMAR_TRUST_PROXY", "CARMAR_USER_HEADER", "CARMAR_TRUSTED_PROXY",
|
|
69
|
+
"CARMAR_ALLOW_UNAUTHENTICATED", "CARMAR_PORT_STRICT",
|
|
70
|
+
"CARMAR_UPDATE_FEED", "CARMAR_UPDATE_MIRROR", "CARMAR_UPDATE_OFFLINE_DIR",
|
|
71
|
+
"CARMAR_UPDATE_MANAGED",
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
export const MANAGED_SETTINGS_SCHEMA = "carmar-managed-settings-v1";
|
|
75
|
+
export const MANAGED_SETTINGS_ENV = Object.freeze([...new Set([
|
|
76
|
+
...SETTINGS.map((s) => s.env),
|
|
77
|
+
...SETTINGS_ADMIN.filter((n) => n !== "CARMAR_MANAGED_CONFIG"),
|
|
78
|
+
"HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY",
|
|
79
|
+
])]);
|
|
80
|
+
|
|
81
|
+
export const settingsEntry = (key) => SETTINGS.find((s) => s.key === key) || null;
|
|
82
|
+
|
|
83
|
+
const sysEnv = (name, unset = "") => (process.env[name] == null ? unset : String(process.env[name]));
|
|
84
|
+
|
|
85
|
+
// ── managed configuration ───────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
export function managedDefaultPath(env = sysEnv, platform = process.platform) {
|
|
88
|
+
if (platform === "darwin") return "/Library/Application Support/CarmaR/managed-settings.json";
|
|
89
|
+
if (platform === "win32") {
|
|
90
|
+
const base = String(env("ProgramData", "")).trim() || "C:/ProgramData";
|
|
91
|
+
return path.join(base, "CarmaR", "managed-settings.json");
|
|
92
|
+
}
|
|
93
|
+
return "/etc/carmar/managed-settings.json";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Protection is checked from the standard user's point of view. Unix also
|
|
98
|
+
* requires root ownership, because a user-owned 0400 file can simply be
|
|
99
|
+
* chmod'd and replaced. Neither the file nor its parent may be writable by the
|
|
100
|
+
* running account.
|
|
101
|
+
*/
|
|
102
|
+
export function managedProtected(file, platform = process.platform) {
|
|
103
|
+
let st;
|
|
104
|
+
try { st = fs.lstatSync(file); } catch { return false; }
|
|
105
|
+
if (st.isSymbolicLink()) return false;
|
|
106
|
+
const writable = (p) => { try { fs.accessSync(p, fs.constants.W_OK); return true; } catch { return false; } };
|
|
107
|
+
if (writable(file) || writable(path.dirname(file))) return false;
|
|
108
|
+
if (platform !== "win32") {
|
|
109
|
+
if (st.uid !== 0) return false;
|
|
110
|
+
if ((st.mode & 0o077) !== 0) return false;
|
|
111
|
+
}
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Read one administrator policy atomically. Unlike the user settings file,
|
|
117
|
+
* unknown or malformed entries fail the WHOLE policy closed: partial policy is
|
|
118
|
+
* not a safe enterprise fallback.
|
|
119
|
+
*/
|
|
120
|
+
export function managedRead(file, { required = false, protection = managedProtected } = {}) {
|
|
121
|
+
const empty = { status: "missing", path: file, values: {}, errors: [] };
|
|
122
|
+
if (!fs.existsSync(file)) {
|
|
123
|
+
if (required) return { ...empty, status: "invalid", errors: ["CARMAR_MANAGED_CONFIG names a file that does not exist."] };
|
|
124
|
+
return empty;
|
|
125
|
+
}
|
|
126
|
+
if (protection(file) !== true) {
|
|
127
|
+
return { ...empty, status: "insecure",
|
|
128
|
+
errors: ["The managed settings file is writable by the current account, is a symlink, or is not administrator-owned; CarmaR refused it."] };
|
|
129
|
+
}
|
|
130
|
+
let size = NaN;
|
|
131
|
+
try { size = fs.statSync(file).size; } catch { size = NaN; }
|
|
132
|
+
if (!Number.isFinite(size) || size > 1024 * 1024) {
|
|
133
|
+
return { ...empty, status: "invalid", errors: ["The managed settings file exceeds the 1 MB limit."] };
|
|
134
|
+
}
|
|
135
|
+
let raw = null;
|
|
136
|
+
try { raw = JSON.parse(fs.readFileSync(file, "utf8")); } catch { raw = null; }
|
|
137
|
+
const plain = raw && typeof raw === "object" && !Array.isArray(raw);
|
|
138
|
+
const rootNames = plain ? Object.keys(raw) : null;
|
|
139
|
+
const settingsPlain = plain && raw.settings && typeof raw.settings === "object" && !Array.isArray(raw.settings);
|
|
140
|
+
if (!rootNames || rootNames.length !== 2 || !rootNames.includes("schema") || !rootNames.includes("settings")
|
|
141
|
+
|| raw.schema !== MANAGED_SETTINGS_SCHEMA || !settingsPlain) {
|
|
142
|
+
return { ...empty, status: "invalid",
|
|
143
|
+
errors: [`The managed settings file must use schema ${MANAGED_SETTINGS_SCHEMA} with one settings object.`] };
|
|
144
|
+
}
|
|
145
|
+
const keys = Object.keys(raw.settings);
|
|
146
|
+
const unknown = keys.filter((k) => !MANAGED_SETTINGS_ENV.includes(k));
|
|
147
|
+
const bad = keys.filter((k) => {
|
|
148
|
+
const v = raw.settings[k];
|
|
149
|
+
return !(typeof v === "string" && v.length > 0 && !/[\r\n\t]/.test(v));
|
|
150
|
+
});
|
|
151
|
+
const errors = [];
|
|
152
|
+
if (unknown.length) errors.push(`Unknown managed setting: ${unknown.join(", ")}.`);
|
|
153
|
+
if (bad.length) errors.push(`Managed settings must be non-empty single-line strings: ${bad.join(", ")}.`);
|
|
154
|
+
if (errors.length) return { ...empty, status: "invalid", errors };
|
|
155
|
+
const values = {};
|
|
156
|
+
for (const k of keys) values[k] = raw.settings[k];
|
|
157
|
+
return { status: "ok", path: file, values, errors: [] };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Read the administrator's policy and put its values into the environment, so
|
|
162
|
+
* every subsystem that follows resolves under the ordinary env-wins rule.
|
|
163
|
+
*/
|
|
164
|
+
export function managedEnvironment({ env = sysEnv, file = null, protection = managedProtected, apply = true } = {}) {
|
|
165
|
+
const explicit = String(env("CARMAR_MANAGED_CONFIG", "")).trim();
|
|
166
|
+
const target = file || (explicit || managedDefaultPath(env));
|
|
167
|
+
const result = managedRead(target, { required: Boolean(explicit), protection });
|
|
168
|
+
if (apply && result.status === "ok") {
|
|
169
|
+
for (const [k, v] of Object.entries(result.values)) process.env[k] = v;
|
|
170
|
+
}
|
|
171
|
+
return result;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── the user's own file ─────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
/** Where the user's settings live — beside the AI key, same directory. */
|
|
177
|
+
export function settingsPath() {
|
|
178
|
+
return path.join(ensureUserDir(appId(), "config"), "settings.json");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Is the file writable by anyone but its owner?
|
|
183
|
+
*
|
|
184
|
+
* This file is an INTEGRITY surface rather than a secret: another local account
|
|
185
|
+
* that can write it turns a knob in this user's next kernel. So a group- or
|
|
186
|
+
* other-writable file is IGNORED rather than repaired — silently chmod-ing
|
|
187
|
+
* someone else's file back to 0600 would hide that it had been changed at all.
|
|
188
|
+
*/
|
|
189
|
+
export function settingsInsecure(file, platform = process.platform) {
|
|
190
|
+
// Windows has no group/other bits: stat reports 0o666 for every writable
|
|
191
|
+
// file, so this read every settings.json there as tampered and ignored it.
|
|
192
|
+
if (platform === "win32") return false;
|
|
193
|
+
let st;
|
|
194
|
+
try { st = fs.statSync(file); } catch { return false; }
|
|
195
|
+
return (st.mode & 0o077) !== 0;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Validate one value against its entry.
|
|
200
|
+
* @returns {{ok: boolean, value?: any, reason?: string}}
|
|
201
|
+
*/
|
|
202
|
+
export function validateSetting(key, value) {
|
|
203
|
+
const entry = settingsEntry(key);
|
|
204
|
+
if (!entry) {
|
|
205
|
+
if (SETTINGS_ADMIN.includes(key)) return { ok: false, reason: "administrator setting" };
|
|
206
|
+
return { ok: false, reason: "unknown setting" };
|
|
207
|
+
}
|
|
208
|
+
if (entry.kind === "bool") {
|
|
209
|
+
if (typeof value !== "boolean") return { ok: false, reason: "must be true or false" };
|
|
210
|
+
return { ok: true, value };
|
|
211
|
+
}
|
|
212
|
+
if (entry.kind === "number") {
|
|
213
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return { ok: false, reason: "must be a number" };
|
|
214
|
+
if (entry.min != null && value < entry.min) return { ok: false, reason: `must be at least ${entry.min}` };
|
|
215
|
+
if (entry.max != null && value > entry.max) return { ok: false, reason: `must be at most ${entry.max}` };
|
|
216
|
+
return { ok: true, value: Number(value) };
|
|
217
|
+
}
|
|
218
|
+
// path
|
|
219
|
+
if (typeof value !== "string") return { ok: false, reason: "must be a string" };
|
|
220
|
+
// An empty path means "use the ladder" — a legitimate value, not a missing
|
|
221
|
+
// one, and how a user clears an override.
|
|
222
|
+
if (value.length > 0 && !fs.existsSync(value)) return { ok: false, reason: "no such file" };
|
|
223
|
+
return { ok: true, value };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Read the config file. FOUR states, kept distinct — folding failure modes
|
|
228
|
+
* together is the fail-open bug the AI policy already paid for:
|
|
229
|
+
* missing no file. The ordinary single-user state, NOT an error.
|
|
230
|
+
* ok parsed, an object, the right version.
|
|
231
|
+
* invalid unparseable, not an object, wrong version → all defaults.
|
|
232
|
+
* insecure group/other writable → all defaults, different words.
|
|
233
|
+
*
|
|
234
|
+
* Per-key failure stays per-key: an unknown key joins `ignored`, a bad value
|
|
235
|
+
* joins `rejected`, and the rest of the file still applies.
|
|
236
|
+
*/
|
|
237
|
+
export function readSettingsFile(file = settingsPath()) {
|
|
238
|
+
const empty = { status: "missing", path: file, values: {}, ignored: [], rejected: [], error: "" };
|
|
239
|
+
if (!fs.existsSync(file)) return empty;
|
|
240
|
+
if (settingsInsecure(file)) {
|
|
241
|
+
return { ...empty, status: "insecure",
|
|
242
|
+
error: "The settings file is writable by other accounts on this machine, so it was ignored." };
|
|
243
|
+
}
|
|
244
|
+
let raw = null;
|
|
245
|
+
try { raw = JSON.parse(fs.readFileSync(file, "utf8")); } catch { raw = null; }
|
|
246
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
247
|
+
return { ...empty, status: "invalid", error: "The settings file could not be read as JSON." };
|
|
248
|
+
}
|
|
249
|
+
if (Math.trunc(Number(raw.version || 0)) !== SETTINGS_VERSION) {
|
|
250
|
+
return { ...empty, status: "invalid", error: "The settings file is a version this CarmaR does not know." };
|
|
251
|
+
}
|
|
252
|
+
const block = raw.settings == null ? {} : raw.settings;
|
|
253
|
+
if (typeof block !== "object" || Array.isArray(block)) {
|
|
254
|
+
return { ...empty, status: "invalid", error: "The settings file's `settings` is not an object." };
|
|
255
|
+
}
|
|
256
|
+
const values = {};
|
|
257
|
+
const ignored = [];
|
|
258
|
+
const rejected = [];
|
|
259
|
+
for (const key of Object.keys(block)) {
|
|
260
|
+
const verdict = validateSetting(key, block[key]);
|
|
261
|
+
if (verdict.ok) values[key] = verdict.value;
|
|
262
|
+
else if (verdict.reason === "unknown setting") ignored.push(key);
|
|
263
|
+
else rejected.push({ key, reason: verdict.reason });
|
|
264
|
+
}
|
|
265
|
+
return { status: "ok", path: file, values, ignored, rejected, error: "" };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Write the whole settings block. Create → chmod 0600 → CHECK → write, the
|
|
270
|
+
* ordering the AI key uses and for the same reason: a file that was briefly
|
|
271
|
+
* group-writable was briefly tamperable.
|
|
272
|
+
*/
|
|
273
|
+
export function writeSettingsFile(values, file = settingsPath()) {
|
|
274
|
+
try {
|
|
275
|
+
if (!fs.existsSync(file)) fs.writeFileSync(file, "", { mode: 0o600 });
|
|
276
|
+
} catch { return false; }
|
|
277
|
+
try {
|
|
278
|
+
fs.chmodSync(file, 0o600);
|
|
279
|
+
if (!privateModeHeld(file)) { try { fs.unlinkSync(file); } catch { /* gone */ } return false; }
|
|
280
|
+
} catch { try { fs.unlinkSync(file); } catch { /* gone */ } return false; }
|
|
281
|
+
const body = { version: SETTINGS_VERSION, settings: values && Object.keys(values).length ? values : {} };
|
|
282
|
+
try { fs.writeFileSync(file, `${JSON.stringify(body, null, 2)}\n`); return true; } catch { return false; }
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* The whole decision: what every setting resolves to, and from where.
|
|
287
|
+
* @returns {{settings: Record<string, object>, admin: object[], file: object}}
|
|
288
|
+
*/
|
|
289
|
+
export function resolveSettings({ env = sysEnv, file = null } = {}) {
|
|
290
|
+
const read = file || readSettingsFile();
|
|
291
|
+
const usable = read.status === "ok";
|
|
292
|
+
const settings = {};
|
|
293
|
+
for (const s of SETTINGS) {
|
|
294
|
+
const raw = String(env(s.env, ""));
|
|
295
|
+
const fromEnv = raw.length > 0;
|
|
296
|
+
let value = s.default;
|
|
297
|
+
let source = "default";
|
|
298
|
+
if (fromEnv) {
|
|
299
|
+
source = "env";
|
|
300
|
+
if (s.kind === "bool") {
|
|
301
|
+
// CARMAR_NO_HISTORY is a NEGATIVE flag — any value means "off" — while
|
|
302
|
+
// the file and the UI hold the positive. Translating happens here, once.
|
|
303
|
+
value = s.inverted ? false : true;
|
|
304
|
+
} else if (s.kind === "number") {
|
|
305
|
+
const n = Number(raw);
|
|
306
|
+
value = Number.isFinite(n) ? n : s.default;
|
|
307
|
+
} else value = raw;
|
|
308
|
+
} else if (usable && read.values[s.key] !== undefined) {
|
|
309
|
+
source = "file";
|
|
310
|
+
value = read.values[s.key];
|
|
311
|
+
}
|
|
312
|
+
settings[s.key] = { key: s.key, label: s.label, kind: s.kind, effect: s.effect,
|
|
313
|
+
value, source, locked: fromEnv, writable: !fromEnv, min: s.min, max: s.max, env: s.env };
|
|
314
|
+
}
|
|
315
|
+
const admin = SETTINGS_ADMIN.map((name) => {
|
|
316
|
+
const raw = String(env(name, ""));
|
|
317
|
+
return { key: name, value: raw, source: raw.length ? "env" : "default" };
|
|
318
|
+
});
|
|
319
|
+
return { settings, admin, file: read };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** The resolved scalar for one key. */
|
|
323
|
+
export const settingsValue = (resolved, key) => (resolved.settings[key] ? resolved.settings[key].value : undefined);
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* What a CHILD process should inherit — the settings that only take effect in
|
|
327
|
+
* a freshly spawned worker, job or analyzer, expressed back in the environment
|
|
328
|
+
* vocabulary those processes read.
|
|
329
|
+
*/
|
|
330
|
+
export function settingsChildEnv(resolved) {
|
|
331
|
+
const out = {};
|
|
332
|
+
const q = settingsValue(resolved, "quarto_path");
|
|
333
|
+
if (typeof q === "string" && q.length) out.CARMAR_QUARTO = q;
|
|
334
|
+
const b = settingsValue(resolved, "analyze_budget_s");
|
|
335
|
+
if (typeof b === "number") out.CARMAR_ANALYZE_BUDGET = String(b);
|
|
336
|
+
return out;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* The live settings object the host holds: one resolve, re-resolved after a
|
|
341
|
+
* successful `settings_set`, so a later read reports the value actually in
|
|
342
|
+
* force. `errors` is non-empty only when the MANAGED policy failed — and that
|
|
343
|
+
* refuses startup, exactly as it does in serve.R.
|
|
344
|
+
*
|
|
345
|
+
* @param {{env?: Function, applyManaged?: boolean}} [opts]
|
|
346
|
+
*/
|
|
347
|
+
export function createSettings({ env = sysEnv, applyManaged = true } = {}) {
|
|
348
|
+
const managed = managedEnvironment({ env, apply: applyManaged });
|
|
349
|
+
let state = resolveSettings({ env });
|
|
350
|
+
const api = {
|
|
351
|
+
managed,
|
|
352
|
+
errors: managed.errors.slice(),
|
|
353
|
+
get state() { return state; },
|
|
354
|
+
reresolve() { state = resolveSettings({ env }); return state; },
|
|
355
|
+
value: (key) => settingsValue(state, key),
|
|
356
|
+
source: (key) => (state.settings[key] ? state.settings[key].source : "default"),
|
|
357
|
+
childEnv: () => settingsChildEnv(state),
|
|
358
|
+
path: () => settingsPath(),
|
|
359
|
+
/** Which R the worker, the analyzer and every job child should run. */
|
|
360
|
+
rscriptPreference() {
|
|
361
|
+
const chosen = api.value("rscript_path");
|
|
362
|
+
return typeof chosen === "string" && chosen.length ? chosen : String(env("CARMAR_RSCRIPT", ""));
|
|
363
|
+
},
|
|
364
|
+
/**
|
|
365
|
+
* The idle clock. The same safe default applies locally and behind a proxy:
|
|
366
|
+
* a setting still at its DEFAULT means "never", and only an explicit user
|
|
367
|
+
* or administrator value opts into timed shutdown — including an explicit 0.
|
|
368
|
+
*/
|
|
369
|
+
lingerSeconds() {
|
|
370
|
+
const entry = state.settings.linger_seconds;
|
|
371
|
+
const value = Number(entry.value);
|
|
372
|
+
if (entry.source === "default" || !Number.isFinite(value)) return 0;
|
|
373
|
+
return value;
|
|
374
|
+
},
|
|
375
|
+
lingerIsDefault() { return state.settings.linger_seconds.source === "default"; },
|
|
376
|
+
historyEnabled() { return api.value("history_enabled") === true; },
|
|
377
|
+
};
|
|
378
|
+
return api;
|
|
379
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// update-record.mjs — what the `beatrina` command learned about the npm registry, read back by the host.
|
|
2
|
+
//
|
|
3
|
+
// The host never reaches the network for this (host/planes/update.mjs says why: no feed, no clock of
|
|
4
|
+
// its own). The `beatrina` command asks the registry at most once a day, at a door the person opened,
|
|
5
|
+
// and writes a small record — {checked_at, latest} — under the app's cache folder
|
|
6
|
+
// (tools/npm/bin/update-check.mjs). This module is the ONE definition of where that record lives and
|
|
7
|
+
// what it means, so the command that writes it and the host that reads it cannot disagree: the host
|
|
8
|
+
// puts `update_available` on /health and on the ready frame only when the recorded version is newer
|
|
9
|
+
// than the build that is running, and the page says it (lib/info-pane.js, lib/doctor.js).
|
|
10
|
+
//
|
|
11
|
+
// CARMAR_NO_UPDATE_CHECK=1 or BEATRINA_NO_UPDATE_CHECK=1 turns the reading off as well as the asking
|
|
12
|
+
// (the battery runner sets the first), so no test kernel reports a version some real run recorded.
|
|
13
|
+
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { rUserDir } from "./user-dirs.mjs";
|
|
17
|
+
|
|
18
|
+
/** -1, 0, 1 for semver-shaped versions; a pre-release sorts before its release; nonsense compares equal. */
|
|
19
|
+
export function compareVersions(a, b) {
|
|
20
|
+
const parse = (v) => {
|
|
21
|
+
const m = String(v).trim().replace(/^v/, "").match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/);
|
|
22
|
+
return m ? { nums: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] || "" } : null;
|
|
23
|
+
};
|
|
24
|
+
const x = parse(a);
|
|
25
|
+
const y = parse(b);
|
|
26
|
+
if (!x || !y) return 0;
|
|
27
|
+
for (let i = 0; i < 3; i += 1) if (x.nums[i] !== y.nums[i]) return x.nums[i] < y.nums[i] ? -1 : 1;
|
|
28
|
+
if (x.pre === y.pre) return 0;
|
|
29
|
+
if (!x.pre) return 1;
|
|
30
|
+
if (!y.pre) return -1;
|
|
31
|
+
return x.pre < y.pre ? -1 : 1;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Where the command keeps its record for this app (R_user_dir(app, "cache")/update-check.json). */
|
|
35
|
+
export function updateRecordFile(app) {
|
|
36
|
+
return path.join(rUserDir(app, "cache"), "update-check.json");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The check is off: nothing is asked and nothing is read. */
|
|
40
|
+
export function updateCheckOff(env = process.env) {
|
|
41
|
+
return env.BEATRINA_NO_UPDATE_CHECK === "1" || env.CARMAR_NO_UPDATE_CHECK === "1";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The newer version the record names, or null: no record, a garbled one, the check switched off, or
|
|
46
|
+
* a recorded version that is not newer than `current`. A version is a short semver-shaped string
|
|
47
|
+
* or it is nothing — the record is a file another process wrote, so its content is checked, never echoed.
|
|
48
|
+
*
|
|
49
|
+
* @param {{file: string, current: string, env?: NodeJS.ProcessEnv}} o
|
|
50
|
+
* @returns {string|null}
|
|
51
|
+
*/
|
|
52
|
+
export function updateAvailable({ file, current, env = process.env }) {
|
|
53
|
+
if (updateCheckOff(env)) return null;
|
|
54
|
+
let record;
|
|
55
|
+
try { record = JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; }
|
|
56
|
+
const latest = record && typeof record.latest === "string" ? record.latest.trim() : "";
|
|
57
|
+
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]{1,32})?$/.test(latest)) return null;
|
|
58
|
+
return compareVersions(latest, current) > 0 ? latest : null;
|
|
59
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// user-dirs.mjs — where the user's own machine state lives, decided exactly as
|
|
2
|
+
// R decides it.
|
|
3
|
+
//
|
|
4
|
+
// Five stores in this work package sit under one of two directories:
|
|
5
|
+
//
|
|
6
|
+
// config the provider key (`ai-key`), the user's settings (`settings.json`)
|
|
7
|
+
// data the console history, the appearance carry, the document journal,
|
|
8
|
+
// the AI conversation store
|
|
9
|
+
//
|
|
10
|
+
// serve.R reaches them through `tools::R_user_dir("carmar", which)`, and every
|
|
11
|
+
// suite in the battery relocates them for a test by setting `R_USER_CONFIG_DIR`
|
|
12
|
+
// / `R_USER_DATA_DIR` — the variables R itself honours. So the host cannot
|
|
13
|
+
// invent its own layout: a JavaScript supervisor writing to `~/.beatrix` would
|
|
14
|
+
// read none of the user's existing state AND would write into the real home
|
|
15
|
+
// directory from every test that thought it had redirected it. This is
|
|
16
|
+
// `tools::R_user_dir`, transcribed from R 4.5.2's source, with the same
|
|
17
|
+
// precedence and the same platform branches.
|
|
18
|
+
//
|
|
19
|
+
// The `cache` branch is transcribed too even though nothing here uses it yet:
|
|
20
|
+
// leaving one arm out is how the next store to need it quietly grows a second
|
|
21
|
+
// layout.
|
|
22
|
+
|
|
23
|
+
import fs from "node:fs";
|
|
24
|
+
import os from "node:os";
|
|
25
|
+
import path from "node:path";
|
|
26
|
+
|
|
27
|
+
const envOf = (name) => (process.env[name] == null ? "" : String(process.env[name]));
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Which R package's folders this host keeps its data, settings and caches in.
|
|
31
|
+
*
|
|
32
|
+
* "carmar" unless BEATRIX_APP_ID names another: the `beatrina` command sets
|
|
33
|
+
* it to "beatrina" (tools/npm/bin/beatrina.mjs), so a Beatrina installed
|
|
34
|
+
* beside CarmaR keeps its OWN settings, AI key, console history, document
|
|
35
|
+
* history, plugins and caches instead of reading and writing CarmaR's. The
|
|
36
|
+
* kernel runtime directory (~/.carmar/run) is deliberately NOT keyed on it:
|
|
37
|
+
* it is how the Sessions pane and the MCP bridge find every running kernel.
|
|
38
|
+
* A value that is not an R package name is refused, loudly — a typo must not
|
|
39
|
+
* quietly become a third data folder.
|
|
40
|
+
*
|
|
41
|
+
* @param {(name: string) => string} [env]
|
|
42
|
+
* @returns {string}
|
|
43
|
+
*/
|
|
44
|
+
export function appId(env = envOf) {
|
|
45
|
+
const id = String(env("BEATRIX_APP_ID") || "").trim();
|
|
46
|
+
if (!id) return "carmar";
|
|
47
|
+
if (!/^[A-Za-z][A-Za-z0-9.]*[A-Za-z0-9]$/.test(id)) {
|
|
48
|
+
throw new Error(`BEATRIX_APP_ID must be an R package name (letters, digits and dots); it is ${JSON.stringify(id)}.`);
|
|
49
|
+
}
|
|
50
|
+
return id;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* `tools::R_user_dir(package, which)`.
|
|
55
|
+
*
|
|
56
|
+
* @param {string} pkg the package name ("carmar")
|
|
57
|
+
* @param {"data"|"config"|"cache"} which
|
|
58
|
+
* @returns {string} an absolute directory path (NOT created)
|
|
59
|
+
*/
|
|
60
|
+
export function rUserDir(pkg, which = "data") {
|
|
61
|
+
const home = os.homedir();
|
|
62
|
+
const windows = process.platform === "win32";
|
|
63
|
+
const darwin = process.platform === "darwin";
|
|
64
|
+
let base = "";
|
|
65
|
+
if (which === "data") {
|
|
66
|
+
base = envOf("R_USER_DATA_DIR") || envOf("XDG_DATA_HOME")
|
|
67
|
+
|| (windows ? path.join(envOf("APPDATA"), "R", "data")
|
|
68
|
+
: darwin ? path.join(home, "Library", "Application Support", "org.R-project.R")
|
|
69
|
+
: path.join(home, ".local", "share"));
|
|
70
|
+
} else if (which === "config") {
|
|
71
|
+
base = envOf("R_USER_CONFIG_DIR") || envOf("XDG_CONFIG_HOME")
|
|
72
|
+
|| (windows ? path.join(envOf("APPDATA"), "R", "config")
|
|
73
|
+
: darwin ? path.join(home, "Library", "Preferences", "org.R-project.R")
|
|
74
|
+
: path.join(home, ".config"));
|
|
75
|
+
} else {
|
|
76
|
+
base = envOf("R_USER_CACHE_DIR") || envOf("XDG_CACHE_HOME")
|
|
77
|
+
|| (windows ? path.join(envOf("LOCALAPPDATA"), "R", "cache")
|
|
78
|
+
: darwin ? path.join(home, "Library", "Caches", "org.R-project.R")
|
|
79
|
+
: path.join(home, ".cache"));
|
|
80
|
+
}
|
|
81
|
+
return path.join(base, "R", pkg);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The same, created on the way out — what every serve.R call site does. */
|
|
85
|
+
export function ensureUserDir(pkg, which) {
|
|
86
|
+
const dir = rUserDir(pkg, which);
|
|
87
|
+
try { fs.mkdirSync(dir, { recursive: true }); } catch { /* the caller reports the failure it hits */ }
|
|
88
|
+
return dir;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The launcher's state directory, or the package's (`state_dir()` in serve.R).
|
|
93
|
+
* The journal and the AI conversation store hang off this when their own
|
|
94
|
+
* variable is unset.
|
|
95
|
+
*/
|
|
96
|
+
export function stateDir() {
|
|
97
|
+
return envOf("CARMAR_STATE") || rUserDir(appId(), "data");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Did a chmod to 0600 take? Asked after every create → chmod → CHECK of a file
|
|
102
|
+
* in these directories (the key, settings, console history, appearance, the
|
|
103
|
+
* journal, the AI conversations).
|
|
104
|
+
*
|
|
105
|
+
* POSIX modes mean something only off Windows. There Node's chmod toggles the
|
|
106
|
+
* read-only attribute and nothing else, and stat reports 0o666 for every
|
|
107
|
+
* writable file, so `(mode & 0o777) === 0o600` can never hold: every one of
|
|
108
|
+
* those writes was refused on Windows and the page said "The writer lease could
|
|
109
|
+
* not be written" (first Windows tester, 2026-09-15). The privacy these files
|
|
110
|
+
* need on Windows comes from where they live — rUserDir resolves under
|
|
111
|
+
* %LOCALAPPDATA%, whose ACL admits only this account — which is also all R's
|
|
112
|
+
* Sys.chmod gave the retired R supervisor there.
|
|
113
|
+
*/
|
|
114
|
+
export function privateModeHeld(file, platform = process.platform) {
|
|
115
|
+
if (platform === "win32") return true;
|
|
116
|
+
return (fs.statSync(file).mode & 0o777) === 0o600;
|
|
117
|
+
}
|