beatrina 0.8.6 → 0.9.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICES +1 -1
- package/README.md +6 -0
- package/beatrina_V0.9.43.html +1885 -0
- package/beatrina_V0.9.43.html.inputs.json +1 -0
- package/bin/beatrina.mjs +156 -26
- package/bin/browser.mjs +31 -0
- package/bin/cli.mjs +64 -5
- package/bin/doctor-rows.mjs +31 -0
- package/bin/failsafe.mjs +8 -3
- package/bin/finder.mjs +74 -0
- package/bin/lsquery.swift +55 -0
- package/bin/pages.mjs +81 -0
- package/bin/python-setup.mjs +207 -0
- package/bin/runtime-dirs.mjs +27 -0
- package/bin/sessions.mjs +30 -10
- package/bin/shortcut.mjs +271 -35
- package/bin/update-check.mjs +2 -2
- package/build-info.json +1 -1
- package/engines/js/worker.mjs +6 -3
- package/engines/python/adapter.py +14 -13
- package/engines/python/analyze.py +4 -2
- package/engines/python/bootstrap.py +32 -31
- package/engines/python/debugger.py +10 -6
- package/engines/python/engine.json +1 -1
- package/engines/python/handoff.py +4 -2
- package/engines/python/worker.py +39 -10
- package/engines/r/engine.json +1 -1
- package/engines/r/handoff.R +4 -3
- package/failsafe/ai-policy.R +16 -16
- package/failsafe/ai-store.R +6 -6
- package/failsafe/cite.R +11 -11
- package/failsafe/journal.R +11 -11
- package/failsafe/plugins.R +27 -27
- package/failsafe/serve.R +364 -239
- package/failsafe/session-documents.R +104 -0
- package/host/ai-authority.mjs +55 -0
- package/host/ai-policy.mjs +48 -26
- package/host/bundle.mjs +194 -0
- package/host/deployment.mjs +25 -18
- package/host/engine-js.mjs +16 -17
- package/host/engine-pool.mjs +56 -8
- package/host/engine-python.mjs +80 -31
- package/host/engine-r.mjs +38 -32
- package/host/engine-stdio.mjs +45 -12
- package/host/env-names.mjs +148 -0
- package/host/gateway-token.mjs +51 -0
- package/host/journal-store.mjs +10 -9
- package/host/main.mjs +216 -76
- package/host/payload.mjs +131 -0
- package/host/planes/README.md +1 -1
- package/host/planes/ai-store.mjs +6 -5
- package/host/planes/ai.mjs +49 -29
- package/host/planes/analyze.mjs +6 -5
- package/host/planes/bundle.mjs +344 -0
- package/host/planes/choose.mjs +404 -0
- package/host/planes/cite.mjs +18 -17
- package/host/planes/files.mjs +0 -0
- package/host/planes/jobs.mjs +43 -20
- package/host/planes/latex.mjs +41 -5
- package/host/planes/mcp.mjs +80 -91
- package/host/planes/pair.mjs +24 -24
- package/host/planes/pipe-term.mjs +2 -2
- package/host/planes/plugins.mjs +1 -1
- package/host/planes/recent-documents.mjs +170 -0
- package/host/planes/sessions.mjs +230 -82
- package/host/planes/settings.mjs +4 -4
- package/host/planes/terminal.mjs +13 -11
- package/host/planes/test-file.mjs +2 -2
- package/host/planes/update.mjs +6 -13
- package/host/plugin-store.mjs +35 -52
- package/host/recent-documents.mjs +124 -0
- package/host/runtime-dir.mjs +47 -0
- package/host/server.mjs +123 -21
- package/host/session-keep.mjs +70 -0
- package/host/settings.mjs +85 -37
- package/host/update-record.mjs +3 -2
- package/host/user-dirs.mjs +60 -18
- package/host/which.mjs +39 -0
- package/host/windows-runtime.mjs +6 -3
- package/host/worker-plane.mjs +80 -7
- package/host/ws.mjs +9 -2
- package/host/zip.mjs +237 -0
- package/kernel/analyze.R +1 -1
- package/{check → kernel/check}/acceptance.mjs +1 -1
- package/kernel/check/knit-file.mjs +19639 -0
- package/{check → kernel/check}/session.mjs +49 -8
- package/kernel/deployment.R +20 -20
- package/kernel/examples/NOTICE.md +1 -1
- package/kernel/fileio.R +6 -6
- package/kernel/index.html +2 -2
- package/kernel/job-run.R +83 -22
- package/kernel/jobs.R +28 -12
- package/kernel/kernel-version +1 -1
- package/kernel/kernel.R +24 -21
- package/kernel/knitr-run.R +50 -9
- package/kernel/latex.R +429 -30
- package/kernel/mcp/{carmar-mcp.mjs → beatrina-mcp.mjs} +249 -59
- package/kernel/notebook-page.R +7 -7
- package/kernel/project.R +10 -10
- package/kernel/settings.R +105 -56
- package/kernel/sniff.R +4 -4
- package/kernel/typst.R +121 -0
- package/kernel/worker.R +602 -123
- package/kernel/workspace-keep.R +188 -0
- package/lib/agent-authoring-contract.js +28 -19
- package/lib/cell-kinds.js +3 -3
- package/lib/engine-labels.js +4 -4
- package/menu/Beatrina Menu.app/Contents/Info.plist +14 -0
- package/menu/Beatrina Menu.app/Contents/MacOS/Beatrina Menu +0 -0
- package/menu/Beatrina Menu.app/Contents/_CodeSignature/CodeResources +115 -0
- package/package.json +4 -3
- package/carmar_V0.8.6.html +0 -1310
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# session-documents.R — which documents a session has open, held by the kernel.
|
|
2
|
+
#
|
|
3
|
+
# Why the kernel, and not the page's storage: every session of one build is
|
|
4
|
+
# served by the SAME notebook file, and the page remembers its open documents
|
|
5
|
+
# per file (lib/open-documents.js). So two sessions share one record, and the
|
|
6
|
+
# later one overwrites the earlier — reopening a session from the menu helper
|
|
7
|
+
# brought back its R (40 objects, 1.9 GB) in front of an empty notebook
|
|
8
|
+
# (owner, 2026-09-14: "it has the objects but not the file").
|
|
9
|
+
#
|
|
10
|
+
# The session is the thing that has an identity here, so the session keeps the
|
|
11
|
+
# list. The page reports it (`page-documents`); a page that attaches to a
|
|
12
|
+
# running session with nobody else on it asks for it back (`session-documents`)
|
|
13
|
+
# and reopens each file. Only paths, names and formats travel — never text:
|
|
14
|
+
# unsaved edits stay in the page that made them, and a document that was never
|
|
15
|
+
# saved is listed by name so the page can SAY it cannot come back.
|
|
16
|
+
#
|
|
17
|
+
# Pure functions; spike/serve.R holds the state. Pinned by
|
|
18
|
+
# spike/test-session-documents.R.
|
|
19
|
+
|
|
20
|
+
SESSION_DOCUMENTS_MAX <- 64L
|
|
21
|
+
SESSION_DOCUMENT_FORMATS <- c("qmd", "Rmd", "md", "carmd")
|
|
22
|
+
|
|
23
|
+
#' Validate a page's report into a clean list of documents.
|
|
24
|
+
#'
|
|
25
|
+
#' Anything malformed is dropped row by row, never the whole report: one odd
|
|
26
|
+
#' row must not cost the session the record of the others.
|
|
27
|
+
#'
|
|
28
|
+
#' @param documents What jsonlite made of the frame's `documents` field
|
|
29
|
+
#' (simplifyVector = TRUE: a data.frame, a list, or NULL).
|
|
30
|
+
#' @return A list of `list(path, name, format, active)`; `path` is "" for a
|
|
31
|
+
#' document that has no file yet. At most SESSION_DOCUMENTS_MAX rows.
|
|
32
|
+
session_documents_clean <- function(documents) {
|
|
33
|
+
if (is.null(documents) || !length(documents)) return(list())
|
|
34
|
+
rows <- if (is.data.frame(documents)) {
|
|
35
|
+
lapply(seq_len(nrow(documents)), \(i) as.list(documents[i, , drop = FALSE]))
|
|
36
|
+
} else if (is.list(documents)) {
|
|
37
|
+
documents
|
|
38
|
+
} else {
|
|
39
|
+
return(list())
|
|
40
|
+
}
|
|
41
|
+
one <- function(row) {
|
|
42
|
+
if (!is.list(row)) return(NULL)
|
|
43
|
+
text_of <- function(value, cap) {
|
|
44
|
+
if (!is.character(value) || length(value) != 1L || is.na(value)) return(NULL)
|
|
45
|
+
value <- gsub("[[:cntrl:]]", " ", value)
|
|
46
|
+
if (nchar(value) > cap) return(NULL)
|
|
47
|
+
value
|
|
48
|
+
}
|
|
49
|
+
path <- text_of(row$path %||% "", 4096L)
|
|
50
|
+
name <- text_of(row$name %||% "", 200L)
|
|
51
|
+
format <- text_of(row$format %||% "", 16L)
|
|
52
|
+
if (is.null(path) || is.null(name) || is.null(format)) return(NULL)
|
|
53
|
+
# A path is reopened through the file ops, which resolve it themselves;
|
|
54
|
+
# here it need only be absolute, so a relative string cannot be read
|
|
55
|
+
# against whatever the worker's cwd is by then.
|
|
56
|
+
if (nzchar(path) && !grepl("^(/|[A-Za-z]:[/\\\\])", path)) return(NULL)
|
|
57
|
+
if (!nzchar(path) && !nzchar(trimws(name))) return(NULL)
|
|
58
|
+
if (!format %in% SESSION_DOCUMENT_FORMATS) format <- "qmd"
|
|
59
|
+
list(path = path, name = trimws(name), format = format,
|
|
60
|
+
active = isTRUE(row$active))
|
|
61
|
+
}
|
|
62
|
+
cleaned <- Filter(Negate(is.null), lapply(rows, one))
|
|
63
|
+
# The same file listed twice is one document.
|
|
64
|
+
paths <- vapply(cleaned, \(row) row$path, character(1))
|
|
65
|
+
keep <- !nzchar(paths) | !duplicated(paths)
|
|
66
|
+
head(cleaned[keep], SESSION_DOCUMENTS_MAX)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
#' The name a session goes by: its document, not its generated notebook name.
|
|
70
|
+
#'
|
|
71
|
+
#' Flattened for the runtime record, which helper-sessions.sh reads with a
|
|
72
|
+
#' bounded sed — a quote or a backslash would cut every field after it.
|
|
73
|
+
#'
|
|
74
|
+
#' @param documents A list from session_documents_clean().
|
|
75
|
+
#' @return "" when there are none; else the on-screen document's name (the
|
|
76
|
+
#' first one when none is marked), then " + N more" for the rest.
|
|
77
|
+
session_documents_label <- function(documents) {
|
|
78
|
+
if (!length(documents)) return("")
|
|
79
|
+
active <- Filter(\(row) isTRUE(row$active), documents)
|
|
80
|
+
lead <- if (length(active)) active[[1L]] else documents[[1L]]
|
|
81
|
+
name <- if (nzchar(lead$path)) basename(lead$path) else lead$name
|
|
82
|
+
if (!nzchar(lead$path)) name <- sprintf("%s (unsaved)", name)
|
|
83
|
+
rest <- length(documents) - 1L
|
|
84
|
+
label <- if (rest > 0L) sprintf("%s + %d more", name, rest) else name
|
|
85
|
+
trimws(substr(gsub("[\"\\\\]", "'", gsub("[[:cntrl:]]", " ", label)), 1L, 120L))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
#' Serialise for a successor supervisor's environment (a session handoff).
|
|
89
|
+
#' @param documents A list from session_documents_clean().
|
|
90
|
+
#' @return A JSON string; "" when there are none.
|
|
91
|
+
session_documents_encode <- function(documents) {
|
|
92
|
+
if (!length(documents)) return("")
|
|
93
|
+
as.character(jsonlite::toJSON(documents, auto_unbox = TRUE))
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
#' Read back what session_documents_encode() wrote; junk yields an empty list.
|
|
97
|
+
#' @param text A JSON string (possibly "").
|
|
98
|
+
#' @return A list from session_documents_clean().
|
|
99
|
+
session_documents_decode <- function(text) {
|
|
100
|
+
if (!is.character(text) || length(text) != 1L || !nzchar(text)) return(list())
|
|
101
|
+
parsed <- tryCatch(jsonlite::fromJSON(text, simplifyVector = TRUE),
|
|
102
|
+
error = function(e) NULL)
|
|
103
|
+
session_documents_clean(parsed)
|
|
104
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// ai-authority.mjs — the supervisor's half of the chat authority contract.
|
|
2
|
+
//
|
|
3
|
+
// The page's half is `lib/ai-authority.js`, and the two lists are ONE list in
|
|
4
|
+
// two files for the reason every other pair in this project is (THEME_TOKENS /
|
|
5
|
+
// EDITOR_TOKENS, LATEX_TEMPLATE_KEYS / TEMPLATE_KEYS): the kernel ships `host/`
|
|
6
|
+
// and `spike/` without `lib/`, and no host module imports across that line.
|
|
7
|
+
// `test/ai-chat-authority.test.mjs` reads both files and fails if they drift.
|
|
8
|
+
//
|
|
9
|
+
// WHY THE HOST IS WHERE THIS IS DECIDED. A CLI turn is a process THIS process
|
|
10
|
+
// spawns. The tool list handed to that process is the only gate with no
|
|
11
|
+
// page-side bypass: there is nothing in the browser to work around, because
|
|
12
|
+
// the spawn was never made with those tools in it. The page's copy exists to
|
|
13
|
+
// ASK for a level and to describe the lane honestly; the host's copy is what
|
|
14
|
+
// happens.
|
|
15
|
+
//
|
|
16
|
+
// The level arriving on the wire is a REQUEST, and `normalizeAuthority` fails
|
|
17
|
+
// closed — absent or unrecognised is `read`. An older page that never heard of
|
|
18
|
+
// the field asks for the reading tools and gets them; silence never grants the
|
|
19
|
+
// most (fix.md P1.5: "switching providers never silently increases authority"
|
|
20
|
+
// covers switching anything else, too).
|
|
21
|
+
|
|
22
|
+
export const READ_TOOLS = Object.freeze([
|
|
23
|
+
"beatrina_status", "notebook_read", "chunk_read", "file_list", "file_read",
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
export const WRITE_TOOLS = Object.freeze([
|
|
27
|
+
"chunk_insert", "chunk_update", "chunk_run",
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
export const AUTHOR_TOOLS = Object.freeze([...READ_TOOLS, ...WRITE_TOOLS]);
|
|
31
|
+
|
|
32
|
+
export const AUTHORITY_LEVELS = Object.freeze(["text", "read", "author"]);
|
|
33
|
+
|
|
34
|
+
/** Read a level off the wire, failing closed. */
|
|
35
|
+
export function normalizeAuthority(value) {
|
|
36
|
+
const asked = String(value || "").toLowerCase();
|
|
37
|
+
return AUTHORITY_LEVELS.includes(asked) ? asked : "read";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The tools a level may use. `text` gets none. */
|
|
41
|
+
export function toolsFor(authority) {
|
|
42
|
+
if (authority === "author") return AUTHOR_TOOLS.slice();
|
|
43
|
+
if (authority === "read") return READ_TOOLS.slice();
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The `--allowedTools` value for a spawned Claude Code turn at this level. */
|
|
48
|
+
export function allowedToolsArg(authority) {
|
|
49
|
+
return toolsFor(authority).map((name) => `mcp__beatrina__${name}`).join(",");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export default {
|
|
53
|
+
READ_TOOLS, WRITE_TOOLS, AUTHOR_TOOLS, AUTHORITY_LEVELS,
|
|
54
|
+
normalizeAuthority, toolsFor, allowedToolsArg,
|
|
55
|
+
};
|
package/host/ai-policy.mjs
CHANGED
|
@@ -26,15 +26,16 @@
|
|
|
26
26
|
//
|
|
27
27
|
// ── where a policy comes from ──────────────────────────────────────────────
|
|
28
28
|
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
29
|
+
// BEATRINA_AI_POLICY=/etc/beatrina/ai-policy.json a file the user cannot write
|
|
30
|
+
// BEATRINA_AI_PROVIDERS=anthropic,lmstudio the quick form
|
|
31
|
+
// BEATRINA_AI_LOCAL_ONLY=1 only on-machine providers
|
|
32
32
|
//
|
|
33
33
|
// The file wins where both are set, because a file is the form an administrator
|
|
34
34
|
// can own while an environment variable is inherited from whoever started the
|
|
35
35
|
// process — on a shared host, that may be the user.
|
|
36
36
|
|
|
37
37
|
import fs from "node:fs";
|
|
38
|
+
import { envReader } from "./env-names.mjs";
|
|
38
39
|
|
|
39
40
|
/**
|
|
40
41
|
* The providers whose text never leaves the machine.
|
|
@@ -54,7 +55,7 @@ export const KEY_PROVIDERS = Object.freeze(["openai", "anthropic", "google", "gr
|
|
|
54
55
|
export const CLI_PROVIDERS = Object.freeze(["claudecode", "codex"]);
|
|
55
56
|
export const ALL_PROVIDERS = Object.freeze([...ON_MACHINE_PROVIDERS, ...KEY_PROVIDERS, ...CLI_PROVIDERS]);
|
|
56
57
|
|
|
57
|
-
const sysEnv = (
|
|
58
|
+
const sysEnv = envReader(process.env); // dual-read: BEATRINA_AI_POLICY or its older spelling (host/env-names.mjs)
|
|
58
59
|
const asList = (v) => (v == null ? [] : (Array.isArray(v) ? v : [v]));
|
|
59
60
|
|
|
60
61
|
/**
|
|
@@ -101,6 +102,12 @@ export function normaliseBaseUrls(raw) {
|
|
|
101
102
|
return { values, errors };
|
|
102
103
|
}
|
|
103
104
|
|
|
105
|
+
// Every key this file reads. A policy carrying anything else is a policy
|
|
106
|
+
// whose author meant something the supervisor will not do — `provider` for
|
|
107
|
+
// `providers` was accepted in silence and read as "permit all fifteen"
|
|
108
|
+
// (fix.md P1.4). An unknown key is a refusal, with the key named.
|
|
109
|
+
export const POLICY_KEYS = Object.freeze(["providers", "local_only", "note", "models", "base_urls"]);
|
|
110
|
+
|
|
104
111
|
const policySplit = (value) => String(value || "").split(/[,\s]+/).map((s) => s.trim()).filter((s) => s.length);
|
|
105
112
|
// Control characters are 0x00-0x1f and 0x7f — R's [[:cntrl:]], spelled out.
|
|
106
113
|
// eslint-disable-next-line no-control-regex
|
|
@@ -115,12 +122,13 @@ const stripControl = (s) => String(s).replace(/[\u0000-\u001f\u007f]/g, " ");
|
|
|
115
122
|
* named nothing / named something valid / named only junk — and conflating the
|
|
116
123
|
* last two is the fail-open bug.
|
|
117
124
|
*/
|
|
118
|
-
export function
|
|
119
|
-
const file = String(env("
|
|
125
|
+
export function beatrinaAiPolicy(env = sysEnv) {
|
|
126
|
+
const file = String(env("BEATRINA_AI_POLICY", "")).trim();
|
|
120
127
|
let named = [];
|
|
121
128
|
let models = {};
|
|
122
129
|
let baseUrls = {};
|
|
123
|
-
let localOnly = env("
|
|
130
|
+
let localOnly = env("BEATRINA_AI_LOCAL_ONLY", "") === "1";
|
|
131
|
+
let namedKeyPresent = false;
|
|
124
132
|
let note = "";
|
|
125
133
|
let source = "";
|
|
126
134
|
let errors = [];
|
|
@@ -128,13 +136,25 @@ export function carmarAiPolicy(env = sysEnv) {
|
|
|
128
136
|
if (file.length) {
|
|
129
137
|
source = file;
|
|
130
138
|
if (!fs.existsSync(file)) {
|
|
131
|
-
errors.push(`
|
|
139
|
+
errors.push(`BEATRINA_AI_POLICY names a file that does not exist: ${file}`);
|
|
132
140
|
} else {
|
|
133
141
|
let doc = null;
|
|
134
142
|
let parseError = "";
|
|
135
143
|
try { doc = JSON.parse(fs.readFileSync(file, "utf8")); } catch (e) { parseError = e.message; }
|
|
136
|
-
if (parseError) errors.push(`
|
|
137
|
-
else {
|
|
144
|
+
if (parseError) errors.push(`BEATRINA_AI_POLICY is not valid JSON: ${parseError}`);
|
|
145
|
+
else if (!doc || typeof doc !== "object" || Array.isArray(doc)) {
|
|
146
|
+
errors.push("BEATRINA_AI_POLICY must be a JSON object of policy keys.");
|
|
147
|
+
} else {
|
|
148
|
+
const unknownKeys = Object.keys(doc).filter((k) => !POLICY_KEYS.includes(k));
|
|
149
|
+
if (unknownKeys.length) {
|
|
150
|
+
errors.push(`AI policy: ${unknownKeys.map((k) => `'${k}'`).join(", ")} `
|
|
151
|
+
+ `${unknownKeys.length === 1 ? "is not a policy key" : "are not policy keys"}. `
|
|
152
|
+
+ `A policy may name: ${POLICY_KEYS.join(", ")}. A key nobody reads permits everything, which is never what the file meant.`);
|
|
153
|
+
}
|
|
154
|
+
// "the key is present" and "the key names something" are different
|
|
155
|
+
// facts: `providers: []` used to read as "said nothing", which the
|
|
156
|
+
// default answers with every provider.
|
|
157
|
+
namedKeyPresent = Object.prototype.hasOwnProperty.call(doc, "providers");
|
|
138
158
|
named = asList(doc && doc.providers).flat().map((v) => String(v));
|
|
139
159
|
if (doc && doc.local_only === true) localOnly = true;
|
|
140
160
|
note = String(asList(doc && doc.note).flat()[0] ?? "").trim();
|
|
@@ -148,20 +168,34 @@ export function carmarAiPolicy(env = sysEnv) {
|
|
|
148
168
|
errors = errors.concat(endpoints.errors);
|
|
149
169
|
}
|
|
150
170
|
}
|
|
151
|
-
} else if (String(env("
|
|
171
|
+
} else if (String(env("BEATRINA_AI_PROVIDERS", "")).length) {
|
|
152
172
|
source = "env";
|
|
153
|
-
named = policySplit(env("
|
|
173
|
+
named = policySplit(env("BEATRINA_AI_PROVIDERS", ""));
|
|
154
174
|
} else if (localOnly) {
|
|
155
175
|
source = "env";
|
|
156
176
|
}
|
|
157
177
|
|
|
158
178
|
const unknown = named.filter((n) => !ALL_PROVIDERS.includes(n));
|
|
159
|
-
|
|
179
|
+
// ASKED means the policy spoke about providers at all — including an empty
|
|
180
|
+
// list, which speaks and names nothing. Only silence inherits the default.
|
|
181
|
+
const asked = named.length > 0 || namedKeyPresent;
|
|
160
182
|
named = named.filter((n) => ALL_PROVIDERS.includes(n));
|
|
161
183
|
|
|
162
184
|
let providers = named.length ? named : (asked ? [] : [...ALL_PROVIDERS]);
|
|
163
185
|
if (localOnly) providers = providers.filter((p) => ON_MACHINE_PROVIDERS.includes(p));
|
|
164
186
|
|
|
187
|
+
// local_only is a promise about where the words GO, so an endpoint that
|
|
188
|
+
// leaves the machine contradicts it — and an on-machine provider pointed at
|
|
189
|
+
// a hosted gateway is exactly how that happens without looking wrong.
|
|
190
|
+
if (localOnly) {
|
|
191
|
+
Object.entries(baseUrls).forEach(([provider, url]) => {
|
|
192
|
+
if (!LOOPBACK_RE.test(url)) {
|
|
193
|
+
errors.push(`AI policy: local_only is set, but base_urls.${provider} is ${url}, which leaves this machine. `
|
|
194
|
+
+ "Point it at loopback, or drop local_only.");
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
165
199
|
let set = source.length > 0 && errors.length === 0;
|
|
166
200
|
if (set && !providers.length) {
|
|
167
201
|
errors.push(`The AI policy permits no provider at all${
|
|
@@ -193,18 +227,6 @@ export function carmarAiPolicy(env = sysEnv) {
|
|
|
193
227
|
export const aiProviderAllowed = (provider, policy) =>
|
|
194
228
|
policy.set !== true || (typeof provider === "string" && provider.length > 0 && policy.providers.includes(provider));
|
|
195
229
|
|
|
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
230
|
|
|
209
231
|
/**
|
|
210
232
|
* The sentence the notebook shows when something is refused. Names the
|
|
@@ -213,6 +235,6 @@ export const aiModelAllowed = (provider, model, policy) => {
|
|
|
213
235
|
* message.
|
|
214
236
|
*/
|
|
215
237
|
export const aiPolicyReason = (policy) => {
|
|
216
|
-
const base = `This
|
|
238
|
+
const base = `This Beatrina is configured to allow only: ${policy.providers.join(", ")}.`;
|
|
217
239
|
return policy.note.length ? `${base} ${policy.note}` : base;
|
|
218
240
|
};
|
package/host/bundle.mjs
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// bundle.mjs — `.btrn`, the Beatrina document that travels (docs/results-plan.md R2).
|
|
2
|
+
//
|
|
3
|
+
// A standard ZIP (host/zip.mjs) holding the document, its results and the files
|
|
4
|
+
// it needs — "transfer the whole document like carmnote" (owner, 2026-09-19): // beatrina-names: keep
|
|
5
|
+
//
|
|
6
|
+
// manifest.json format, version, app, created, title, what is inside
|
|
7
|
+
// (every entry: size + SHA-256), and the data: its mode
|
|
8
|
+
// and every file's path, size and SHA-256
|
|
9
|
+
// <name>.qmd the document, plain text (its own .Rmd/.md if it was one)
|
|
10
|
+
// results/results.json the beatrina-results/1 record (lib/document-results.js)
|
|
11
|
+
// results/figures/* the record's figures, stored (PNG is compressed already)
|
|
12
|
+
// data/<relative path> every file the document needs, deflated, at the path it
|
|
13
|
+
// uses: data, bibliography, citation style, images, included
|
|
14
|
+
// files, project settings (lib/bundle.js documentFiles)
|
|
15
|
+
//
|
|
16
|
+
// NO HISTORY, by the owner's decision (2026-09-19): Undo history holds every
|
|
17
|
+
// keystroke, deleted text included — a document sent to someone must not carry
|
|
18
|
+
// what its author removed. A CarmNote carries none either; the author's own
|
|
19
|
+
// history stays in their session's journal.
|
|
20
|
+
//
|
|
21
|
+
// DATA ≤ 100 MB (compressed, in total) travels inside. Past that it goes BESIDE
|
|
22
|
+
// the bundle, in `<name>.btrn-data/` at the same relative paths, and the manifest
|
|
23
|
+
// keeps each file's size and SHA-256 so the receiver can tell a missing or a
|
|
24
|
+
// changed file by name. A folder, not a second zip: big data stays usable as it
|
|
25
|
+
// is, and ZIP's 4 GB ceiling never comes up.
|
|
26
|
+
//
|
|
27
|
+
// Pure where it can be: buildBundle / readBundle take bytes and return bytes or
|
|
28
|
+
// parts; the plane (host/planes/bundle.mjs) does the file system and the doors.
|
|
29
|
+
|
|
30
|
+
import crypto from "node:crypto";
|
|
31
|
+
import path from "node:path";
|
|
32
|
+
import { writeZip, readZip, listZip, safeEntryName } from "./zip.mjs";
|
|
33
|
+
|
|
34
|
+
export const BUNDLE_FORMAT = "beatrina-bundle";
|
|
35
|
+
export const BUNDLE_VERSION = 1;
|
|
36
|
+
export const BUNDLE_EXT = ".btrn";
|
|
37
|
+
/** Data travels inside up to this many compressed bytes; past it, beside. */
|
|
38
|
+
export const DATA_INSIDE_MAX = 100 * 1024 * 1024;
|
|
39
|
+
/** Reading: the whole archive may not expand past this. */
|
|
40
|
+
export const BUNDLE_READ_MAX = 2 * 1024 ** 3;
|
|
41
|
+
|
|
42
|
+
export const sha256 = (buf) => crypto.createHash("sha256").update(buf).digest("hex");
|
|
43
|
+
const fail = (code, message) => Object.assign(new Error(message), { code });
|
|
44
|
+
|
|
45
|
+
/** `/w/paper.btrn` → `/w/paper.btrn-data` — where beside-mode data lives. */
|
|
46
|
+
export const dataFolderFor = (bundlePath) => `${bundlePath}-data`;
|
|
47
|
+
|
|
48
|
+
/** A data path the bundle may carry: relative, safe, a file (not a folder). */
|
|
49
|
+
export function safeDataPath(rel) {
|
|
50
|
+
const r = String(rel ?? "").replace(/\\/g, "/");
|
|
51
|
+
return safeEntryName(r) && !r.endsWith("/") && r.length <= 512;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Build a .btrn.
|
|
56
|
+
*
|
|
57
|
+
* @param {object} parts
|
|
58
|
+
* @param {string} parts.source the document's text
|
|
59
|
+
* @param {string} parts.sourceName its name inside, e.g. "paper.qmd"
|
|
60
|
+
* @param {string} [parts.title]
|
|
61
|
+
* @param {string|null} [parts.results] the results record, JSON text (figures external)
|
|
62
|
+
* @param {Array<{name:string, data:Buffer, mime?:string}>} [parts.figures] results/figures/<name>
|
|
63
|
+
* @param {Array<{path:string, bytes:Buffer}>} [parts.data] files the code reads, by relative path
|
|
64
|
+
* @param {string} [parts.app] the app version that wrote it
|
|
65
|
+
* @param {Date} [parts.created]
|
|
66
|
+
* @param {number} [parts.insideMax] DATA_INSIDE_MAX unless a test says otherwise
|
|
67
|
+
* @param {string} [parts.bundleName] the .btrn's own file name, for the beside folder's name
|
|
68
|
+
* @returns {{zip:Buffer, manifest:object, beside:Array<{path:string, bytes:Buffer}>}}
|
|
69
|
+
* `beside` is the data to write in <bundle>-data/ when it did not fit inside
|
|
70
|
+
*/
|
|
71
|
+
export function buildBundle(parts) {
|
|
72
|
+
const {
|
|
73
|
+
source, sourceName, title = "", results = null, figures = [],
|
|
74
|
+
data = [], app = "", created = new Date(), insideMax = DATA_INSIDE_MAX, bundleName = "",
|
|
75
|
+
} = parts || {};
|
|
76
|
+
if (typeof source !== "string") throw fail("bundle_input", "a bundle needs the document's source");
|
|
77
|
+
if (!/^[^/\\]+\.(qmd|Rmd|rmd|md)$/.test(String(sourceName || ""))) {
|
|
78
|
+
throw fail("bundle_input", `the document inside must be a .qmd, .Rmd or .md file name, not ${JSON.stringify(sourceName)}`);
|
|
79
|
+
}
|
|
80
|
+
const files = [];
|
|
81
|
+
const seen = new Set();
|
|
82
|
+
const add = (name, bytes, compress = true) => {
|
|
83
|
+
if (seen.has(name)) throw fail("bundle_input", `${name} twice`);
|
|
84
|
+
seen.add(name);
|
|
85
|
+
files.push({ name, data: bytes, compress });
|
|
86
|
+
};
|
|
87
|
+
add(sourceName, Buffer.from(source, "utf8"));
|
|
88
|
+
if (results != null) {
|
|
89
|
+
add("results/results.json", Buffer.from(String(results), "utf8"));
|
|
90
|
+
for (const f of figures || []) {
|
|
91
|
+
if (!safeEntryName(`results/figures/${f.name}`) || f.name.includes("/")) throw fail("bundle_input", `bad figure name ${JSON.stringify(f.name)}`);
|
|
92
|
+
add(`results/figures/${f.name}`, f.data, false);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const dataFiles = (data || []).map((d) => { // each: {path, bytes, kind?}
|
|
96
|
+
const rel = String(d.path).replace(/\\/g, "/");
|
|
97
|
+
if (!safeDataPath(rel)) throw fail("bundle_bad_path", `a data path must be relative and stay inside the document's folder: ${JSON.stringify(d.path)}`);
|
|
98
|
+
return { path: rel, bytes: d.bytes, size: d.bytes.length, sha256: sha256(d.bytes), kind: d.kind || "data" };
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const manifestFor = (mode, dataEntries) => ({
|
|
102
|
+
format: BUNDLE_FORMAT, version: BUNDLE_VERSION, app, created: created.toISOString(), title,
|
|
103
|
+
source: sourceName,
|
|
104
|
+
entries: dataEntries.concat(files).map((f) => ({ name: f.name, size: f.data.length, sha256: sha256(f.data) })),
|
|
105
|
+
data: {
|
|
106
|
+
mode, ...(mode === "beside" ? { folder: `${path.basename(bundleName || sourceName.replace(/\.[^.]+$/, ".btrn"))}-data` } : {}),
|
|
107
|
+
files: dataFiles.map(({ path: p, size, sha256: h, kind }) => ({ path: p, size, sha256: h, kind })),
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
const zipWith = (manifest, dataEntries) => writeZip([
|
|
111
|
+
{ name: "manifest.json", data: Buffer.from(JSON.stringify(manifest, null, 1), "utf8") },
|
|
112
|
+
...files, ...dataEntries,
|
|
113
|
+
], { date: created });
|
|
114
|
+
|
|
115
|
+
// Inside first; measure what the data costs compressed, and move it beside
|
|
116
|
+
// only when it passes the cap.
|
|
117
|
+
const inside = dataFiles.map((d) => ({ name: `data/${d.path}`, data: d.bytes, compress: true }));
|
|
118
|
+
let zip = zipWith(manifestFor(dataFiles.length ? "inside" : "none", inside), inside);
|
|
119
|
+
const dataBytes = listZip(zip).filter((e) => e.name.startsWith("data/")).reduce((a, e) => a + e.csize, 0);
|
|
120
|
+
if (dataBytes <= insideMax) {
|
|
121
|
+
return { zip, manifest: manifestFor(dataFiles.length ? "inside" : "none", inside), beside: [] };
|
|
122
|
+
}
|
|
123
|
+
const manifest = manifestFor("beside", []);
|
|
124
|
+
zip = zipWith(manifest, []);
|
|
125
|
+
return { zip, manifest, beside: dataFiles.map((d) => ({ path: d.path, bytes: d.bytes })), dataCompressed: dataBytes };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Read a .btrn: every part, VERIFIED. The manifest's SHA-256 for each entry is
|
|
130
|
+
* checked, and an entry the manifest does not list is refused — nothing is
|
|
131
|
+
* half-loaded.
|
|
132
|
+
*
|
|
133
|
+
* @param {Buffer} buf
|
|
134
|
+
* @returns {{manifest:object, source:string, sourceName:string, results:string|null,
|
|
135
|
+
* figures:Map<string,Buffer>, data:Map<string,Buffer>}}
|
|
136
|
+
*/
|
|
137
|
+
export function readBundle(buf, { maxTotal = BUNDLE_READ_MAX } = {}) {
|
|
138
|
+
const all = readZip(buf, { maxTotal });
|
|
139
|
+
const rawManifest = all.get("manifest.json");
|
|
140
|
+
if (!rawManifest) throw fail("bundle_no_manifest", "not a Beatrina document (no manifest.json)");
|
|
141
|
+
let manifest;
|
|
142
|
+
try { manifest = JSON.parse(rawManifest.toString("utf8")); } catch { throw fail("bundle_bad_manifest", "manifest.json is not JSON"); }
|
|
143
|
+
if (manifest.format !== BUNDLE_FORMAT) throw fail("bundle_bad_manifest", `not a Beatrina document (format ${JSON.stringify(manifest.format)})`);
|
|
144
|
+
if (!(Number(manifest.version) >= 1)) throw fail("bundle_bad_manifest", "the manifest names no version");
|
|
145
|
+
if (Number(manifest.version) > BUNDLE_VERSION) {
|
|
146
|
+
throw fail("bundle_newer", `this document was written by a newer Beatrina (bundle version ${manifest.version}); update Beatrina to open it`);
|
|
147
|
+
}
|
|
148
|
+
const listed = new Map((manifest.entries || []).map((e) => [e.name, e]));
|
|
149
|
+
for (const [name, bytes] of all) {
|
|
150
|
+
if (name === "manifest.json") continue;
|
|
151
|
+
const want = listed.get(name);
|
|
152
|
+
if (!want) throw fail("bundle_unlisted", `${name} is in the archive but not in its manifest`);
|
|
153
|
+
if (want.size !== bytes.length || want.sha256 !== sha256(bytes)) throw fail("bundle_digest", `${name} does not match its manifest — the document is damaged`);
|
|
154
|
+
}
|
|
155
|
+
for (const name of listed.keys()) if (!all.has(name)) throw fail("bundle_missing", `the manifest lists ${name}, which is not in the archive`);
|
|
156
|
+
const sourceName = String(manifest.source || "");
|
|
157
|
+
if (!all.has(sourceName)) throw fail("bundle_missing", "the document itself is missing");
|
|
158
|
+
const figures = new Map();
|
|
159
|
+
const data = new Map();
|
|
160
|
+
for (const [name, bytes] of all) {
|
|
161
|
+
if (name.startsWith("results/figures/")) figures.set(name.slice("results/figures/".length), bytes);
|
|
162
|
+
else if (name.startsWith("data/")) data.set(name.slice("data/".length), bytes);
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
manifest, sourceName, source: all.get(sourceName).toString("utf8"),
|
|
166
|
+
results: all.has("results/results.json") ? all.get("results/results.json").toString("utf8") : null,
|
|
167
|
+
figures, data,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* What opening a bundle would do to each data file, pure: compare the
|
|
173
|
+
* manifest's digest with the file at the document's folder.
|
|
174
|
+
*
|
|
175
|
+
* @param {object} manifest
|
|
176
|
+
* @param {(rel:string) => (Buffer|null)} readLocal the file beside the document, or null
|
|
177
|
+
* @returns {Array<{path:string, size:number, kind:string, state:'absent'|'same'|'differs'}>}
|
|
178
|
+
*/
|
|
179
|
+
export function dataPlan(manifest, readLocal) {
|
|
180
|
+
return ((manifest && manifest.data && manifest.data.files) || []).map((f) => {
|
|
181
|
+
const local = readLocal(f.path);
|
|
182
|
+
const state = !local ? "absent" : sha256(local) === f.sha256 ? "same" : "differs";
|
|
183
|
+
return { path: f.path, size: f.size, kind: f.kind || "data", state };
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** `paper.btrn` → `paper.qmd` — the name the document takes inside. */
|
|
188
|
+
export function innerNameFor(bundlePath, format = "qmd") {
|
|
189
|
+
const stem = path.basename(String(bundlePath || "document")).replace(/\.btrn$/i, "") || "document";
|
|
190
|
+
const ext = { qmd: "qmd", rmd: "Rmd", md: "md" }[String(format).toLowerCase()] || "qmd";
|
|
191
|
+
return `${stem}.${ext}`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export default { buildBundle, readBundle, dataPlan, innerNameFor, dataFolderFor, safeDataPath, sha256 };
|
package/host/deployment.mjs
CHANGED
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
// client is not a browser and therefore is this same OS user; anywhere the port
|
|
14
14
|
// is reachable by someone else the same header proves nothing, so it is refused.
|
|
15
15
|
|
|
16
|
+
import { envReader } from "./env-names.mjs";
|
|
17
|
+
|
|
16
18
|
export const LOOPBACK_HOSTS = ["127.0.0.1", "::1", "[::1]", "localhost"];
|
|
17
19
|
|
|
18
20
|
/** Split a comma/space-separated environment value into trimmed, non-empty parts. */
|
|
@@ -45,8 +47,8 @@ export function isLoopbackBind(bind) {
|
|
|
45
47
|
* `errors` is EMPTY when the posture is safe to serve. A non-empty `errors`
|
|
46
48
|
* must abort startup; it is never a warning.
|
|
47
49
|
*/
|
|
48
|
-
export function
|
|
49
|
-
let bind = env("
|
|
50
|
+
export function beatrinaDeployment(port, env = envFromProcess()) {
|
|
51
|
+
let bind = env("BEATRINA_BIND", "127.0.0.1").trim();
|
|
50
52
|
if (!bind) bind = "127.0.0.1";
|
|
51
53
|
const loopback = isLoopbackBind(bind);
|
|
52
54
|
|
|
@@ -54,9 +56,9 @@ export function carmarDeployment(port, env = envFromProcess()) {
|
|
|
54
56
|
// the same-origin case. Off loopback the server is behind a proxy, so its own
|
|
55
57
|
// page arrives with the PROXY's origin — which the operator must name.
|
|
56
58
|
const selfOrigins = [`http://${bind}:${port}`, `http://127.0.0.1:${port}`, `http://localhost:${port}`];
|
|
57
|
-
const origins = unique([...selfOrigins, ...splitList(env("
|
|
59
|
+
const origins = unique([...selfOrigins, ...splitList(env("BEATRINA_ORIGINS", ""))]);
|
|
58
60
|
|
|
59
|
-
let hostsEnv = splitList(env("
|
|
61
|
+
let hostsEnv = splitList(env("BEATRINA_HOSTS", ""));
|
|
60
62
|
const defaultHosts = [`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`];
|
|
61
63
|
// A configured host may or may not carry a port. Accept both spellings so an
|
|
62
64
|
// operator writing `stats.example.edu` is not silently refused behind a proxy
|
|
@@ -68,31 +70,31 @@ export function carmarDeployment(port, env = envFromProcess()) {
|
|
|
68
70
|
// allowing an Origin-less client. Binding off loopback breaks it, but so does
|
|
69
71
|
// a shared multi-user host or a container whose 127.0.0.1 is forwarded out,
|
|
70
72
|
// so the posture is declarable in its own right.
|
|
71
|
-
const requireOrigin = env("
|
|
73
|
+
const requireOrigin = env("BEATRINA_REQUIRE_ORIGIN", "") === "1" || !loopback;
|
|
72
74
|
|
|
73
|
-
const trustProxy = env("
|
|
74
|
-
const userHeader = env("
|
|
75
|
-
const proxyAddrs = splitList(env("
|
|
76
|
-
const allowOpen = env("
|
|
75
|
+
const trustProxy = env("BEATRINA_TRUST_PROXY", "") === "1";
|
|
76
|
+
const userHeader = env("BEATRINA_USER_HEADER", "X-Forwarded-User").trim();
|
|
77
|
+
const proxyAddrs = splitList(env("BEATRINA_TRUSTED_PROXY", "127.0.0.1 ::1"));
|
|
78
|
+
const allowOpen = env("BEATRINA_ALLOW_UNAUTHENTICATED", "") === "1";
|
|
77
79
|
|
|
78
80
|
const errors = [];
|
|
79
81
|
if (!loopback) {
|
|
80
82
|
if (!hostsEnv.length) {
|
|
81
|
-
errors.push(`
|
|
83
|
+
errors.push(`BEATRINA_BIND is ${bind} but BEATRINA_HOSTS is unset. Off loopback the `
|
|
82
84
|
+ "Host allow-list is the only defence against DNS rebinding, so it must "
|
|
83
|
-
+ "name the hostname readers will use (e.g.
|
|
85
|
+
+ "name the hostname readers will use (e.g. BEATRINA_HOSTS=stats.example.edu).");
|
|
84
86
|
}
|
|
85
87
|
if (!trustProxy && !allowOpen) {
|
|
86
|
-
errors.push(`
|
|
88
|
+
errors.push(`BEATRINA_BIND is ${bind} with no authentication in front of it. Put `
|
|
87
89
|
+ "Beatrina behind a reverse proxy that authenticates and set "
|
|
88
|
-
+ "
|
|
90
|
+
+ "BEATRINA_TRUST_PROXY=1, or set BEATRINA_ALLOW_UNAUTHENTICATED=1 to serve code "
|
|
89
91
|
+ "execution to everyone who can reach this port. See docs/server.md.");
|
|
90
92
|
}
|
|
91
93
|
if (trustProxy && !userHeader) {
|
|
92
|
-
errors.push("
|
|
94
|
+
errors.push("BEATRINA_TRUST_PROXY=1 requires a non-empty BEATRINA_USER_HEADER.");
|
|
93
95
|
}
|
|
94
96
|
if (trustProxy && !proxyAddrs.length) {
|
|
95
|
-
errors.push("
|
|
97
|
+
errors.push("BEATRINA_TRUST_PROXY=1 requires BEATRINA_TRUSTED_PROXY to name the proxy's address.");
|
|
96
98
|
}
|
|
97
99
|
}
|
|
98
100
|
|
|
@@ -127,7 +129,7 @@ export function carmarDeployment(port, env = envFromProcess()) {
|
|
|
127
129
|
*
|
|
128
130
|
* @param {{REMOTE_ADDR?: string} & Record<string, string>} req a Rook-shaped
|
|
129
131
|
* request: `REMOTE_ADDR` plus `HTTP_*` header keys
|
|
130
|
-
* @param {ReturnType<typeof
|
|
132
|
+
* @param {ReturnType<typeof beatrinaDeployment>} deployment
|
|
131
133
|
* @returns {string}
|
|
132
134
|
*/
|
|
133
135
|
export function proxyUser(req, deployment) {
|
|
@@ -150,9 +152,14 @@ export function rookOf(req) {
|
|
|
150
152
|
return out;
|
|
151
153
|
}
|
|
152
154
|
|
|
153
|
-
/**
|
|
155
|
+
/**
|
|
156
|
+
* A getter with `Sys.getenv`'s signature over `process.env` — dual-read: a
|
|
157
|
+
* `CARMAR_*` name asked for here is also answered by its `BEATRINA_*` spelling
|
|
158
|
+
* (host/env-names.mjs), so the call sites above keep the vocabulary the
|
|
159
|
+
* deployment vectors in test/host-deployment.test.mjs use.
|
|
160
|
+
*/
|
|
154
161
|
export function envFromProcess(source = process.env) {
|
|
155
|
-
return (
|
|
162
|
+
return envReader(source);
|
|
156
163
|
}
|
|
157
164
|
|
|
158
165
|
function unique(list) {
|