beatrina 0.8.7 → 0.9.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +152 -24
- 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 +250 -42
- 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 +241 -241
- 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 +12 -17
- package/host/engine-pool.mjs +48 -6
- package/host/engine-python.mjs +80 -31
- package/host/engine-r.mjs +20 -21
- package/host/engine-stdio.mjs +35 -10
- 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 +193 -77
- 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 +15 -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 +226 -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 +75 -18
- package/host/session-keep.mjs +1 -1
- 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 +34 -6
- 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 +18 -18
- package/kernel/knitr-run.R +50 -9
- package/kernel/latex.R +128 -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 +522 -119
- package/kernel/workspace-keep.R +3 -3
- 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.7.html +0 -1522
package/failsafe/serve.R
CHANGED
|
@@ -19,17 +19,17 @@
|
|
|
19
19
|
# before its event loop starts. The option guards this process's one re-entry.
|
|
20
20
|
# Rscript writes a space in the script path as `~+~` when it builds R's
|
|
21
21
|
# command line, and R decodes it only to open the file: commandArgs(FALSE)
|
|
22
|
-
# still carries the encoded form. "
|
|
22
|
+
# still carries the encoded form. "Beatrina Helper.app" has a space, so its
|
|
23
23
|
# kernel resolved `here` to a path that does not exist and died at source().
|
|
24
24
|
# Decoded once, here, for every reader of --file= in this file.
|
|
25
|
-
|
|
25
|
+
beatrina_file_arg <- function() {
|
|
26
26
|
gsub("~+~", " ", sub("^--file=", "",
|
|
27
27
|
grep("^--file=", commandArgs(FALSE), value = TRUE)[1L]), fixed = TRUE)
|
|
28
28
|
}
|
|
29
|
-
if (!isTRUE(getOption("
|
|
30
|
-
supervisor_entry <-
|
|
29
|
+
if (!isTRUE(getOption("beatrina.supervisor.parsed"))) {
|
|
30
|
+
supervisor_entry <- beatrina_file_arg()
|
|
31
31
|
supervisor_program <- parse(file = supervisor_entry, keep.source = FALSE)
|
|
32
|
-
options(
|
|
32
|
+
options(beatrina.supervisor.parsed = TRUE)
|
|
33
33
|
eval(supervisor_program, envir = globalenv())
|
|
34
34
|
quit(save = "no", status = 0L, runLast = FALSE)
|
|
35
35
|
}
|
|
@@ -39,7 +39,7 @@ suppressPackageStartupMessages({
|
|
|
39
39
|
library(jsonlite)
|
|
40
40
|
})
|
|
41
41
|
|
|
42
|
-
here <- dirname(normalizePath(
|
|
42
|
+
here <- dirname(normalizePath(beatrina_file_arg()))
|
|
43
43
|
source(file.path(here, "kernel.R"))
|
|
44
44
|
source(file.path(here, "notebook-page.R"))
|
|
45
45
|
source(file.path(here, "deployment.R"))
|
|
@@ -52,7 +52,7 @@ source(file.path(here, "settings.R"))
|
|
|
52
52
|
# The file system: one body shared with the worker (docs/document-model-plan.md
|
|
53
53
|
# D3). The supervisor answers the pure file ops itself so a Save never waits
|
|
54
54
|
# behind a running cell; the worker keeps the same functions for the
|
|
55
|
-
# transition flag
|
|
55
|
+
# transition flag BEATRINA_FILE_OPS=worker.
|
|
56
56
|
source(file.path(here, "fileio.R"))
|
|
57
57
|
source(file.path(here, "journal.R"))
|
|
58
58
|
source(file.path(here, "ai-store.R"))
|
|
@@ -65,7 +65,7 @@ source(file.path(here, "session-documents.R"))
|
|
|
65
65
|
# Apply an administrator-owned desktop policy before any subsystem reads its
|
|
66
66
|
# environment. Invalid or user-writable policy fails closed at the shared
|
|
67
67
|
# startup gate below; no partial values are applied.
|
|
68
|
-
managed_config <-
|
|
68
|
+
managed_config <- beatrina_managed_environment()
|
|
69
69
|
|
|
70
70
|
# Before anything reads or writes a frame. The supervisor parses every browser
|
|
71
71
|
# message and re-encodes every worker reply, so its own character locale
|
|
@@ -85,16 +85,16 @@ read_kernel_protocol <- function() {
|
|
|
85
85
|
candidates <- c(file.path(here, "kernel-protocol"),
|
|
86
86
|
file.path(here, "..", "kernel.protocol"))
|
|
87
87
|
hit <- candidates[file.exists(candidates)][1L]
|
|
88
|
-
if (!length(hit) || is.na(hit)) stop("
|
|
88
|
+
if (!length(hit) || is.na(hit)) stop("Beatrina kernel protocol stamp is missing.")
|
|
89
89
|
value <- suppressWarnings(as.integer(trimws(readLines(hit, warn = FALSE, n = 1L))))
|
|
90
90
|
if (length(value) != 1L || is.na(value) || value < 1L) {
|
|
91
|
-
stop("
|
|
91
|
+
stop("Beatrina kernel protocol stamp is invalid.")
|
|
92
92
|
}
|
|
93
93
|
value
|
|
94
94
|
}
|
|
95
|
-
|
|
95
|
+
BEATRINA_PROTOCOL_VERSION <- read_kernel_protocol()
|
|
96
96
|
read_kernel_build <- function() {
|
|
97
|
-
from_env <- trimws(Sys.getenv("
|
|
97
|
+
from_env <- trimws(Sys.getenv("BEATRINA_KERNEL_BUILD", ""))
|
|
98
98
|
if (nzchar(from_env)) return(from_env)
|
|
99
99
|
stamp <- file.path(here, "kernel-version")
|
|
100
100
|
if (file.exists(stamp)) {
|
|
@@ -109,25 +109,25 @@ read_kernel_build <- function() {
|
|
|
109
109
|
}
|
|
110
110
|
"unknown"
|
|
111
111
|
}
|
|
112
|
-
|
|
112
|
+
BEATRINA_KERNEL_BUILD <- read_kernel_build()
|
|
113
113
|
|
|
114
|
-
#' What
|
|
114
|
+
#' What Beatrina is INSTALLED as, right now — re-read on every call.
|
|
115
115
|
#'
|
|
116
|
-
#' `
|
|
116
|
+
#' `BEATRINA_KERNEL_BUILD` is what this process was started as. The bundle it
|
|
117
117
|
#' lives in is swapped IN PLACE by an upgrade, so the stamp beside this file
|
|
118
118
|
#' can name a newer (or, after a rollback, an older) build than the one
|
|
119
119
|
#' running. Reading it fresh every time is what lets a session that predates
|
|
120
120
|
#' an install say so — in /health, in the ready frame and in every heartbeat
|
|
121
121
|
#' — instead of serving the old page in silence until someone quits it.
|
|
122
122
|
#'
|
|
123
|
-
#' When the build was dictated by
|
|
124
|
-
#' the installed build is dictated the same way:
|
|
123
|
+
#' When the build was dictated by BEATRINA_KERNEL_BUILD (tests, the broker),
|
|
124
|
+
#' the installed build is dictated the same way: BEATRINA_INSTALLED_BUILD, or
|
|
125
125
|
#' the same value — a test kernel must never read "newer installed" off a
|
|
126
126
|
#' source checkout's notebook.version.cjs.
|
|
127
127
|
installed_build <- function() {
|
|
128
|
-
dictated <- trimws(Sys.getenv("
|
|
128
|
+
dictated <- trimws(Sys.getenv("BEATRINA_KERNEL_BUILD", ""))
|
|
129
129
|
if (nzchar(dictated)) {
|
|
130
|
-
inst <- trimws(Sys.getenv("
|
|
130
|
+
inst <- trimws(Sys.getenv("BEATRINA_INSTALLED_BUILD", ""))
|
|
131
131
|
return(if (nzchar(inst)) inst else dictated)
|
|
132
132
|
}
|
|
133
133
|
stamp <- file.path(here, "kernel-version")
|
|
@@ -146,13 +146,13 @@ installed_build <- function() {
|
|
|
146
146
|
# The build this kernel REPLACED, when it was started by a "Restart into"
|
|
147
147
|
# handoff (session_handoff_spawn below). Told to /health for the first
|
|
148
148
|
# minute and a half so the page that asked can recognise its successor.
|
|
149
|
-
HANDOFF_FROM <- trimws(Sys.getenv("
|
|
149
|
+
HANDOFF_FROM <- trimws(Sys.getenv("BEATRINA_HANDOFF_FROM", ""))
|
|
150
150
|
|
|
151
151
|
# Where to listen. 127.0.0.1 unless an operator says otherwise, and saying
|
|
152
152
|
# otherwise inverts the trust model — see spike/deployment.R, which owns that
|
|
153
153
|
# decision and refuses the unsafe spellings of it outright.
|
|
154
154
|
host <- local({
|
|
155
|
-
h <- trimws(Sys.getenv("
|
|
155
|
+
h <- trimws(Sys.getenv("BEATRINA_BIND", "127.0.0.1"))
|
|
156
156
|
if (nzchar(h)) h else "127.0.0.1"
|
|
157
157
|
})
|
|
158
158
|
|
|
@@ -162,7 +162,7 @@ host <- local({
|
|
|
162
162
|
#' here. localStorage and sessionStorage are keyed by scheme://host:PORT, so a
|
|
163
163
|
#' new port on every start is a new, empty storage on every start: the saved
|
|
164
164
|
#' notebooks invisible, the chat history gone, the AI key asked for again. A
|
|
165
|
-
#' user restarting the kernel three times has three separate
|
|
165
|
+
#' user restarting the kernel three times has three separate Beatrinas and no way
|
|
166
166
|
#' to know why the last one forgot everything.
|
|
167
167
|
#'
|
|
168
168
|
#' A fixed loopback port is not a weaker position than a random one — the
|
|
@@ -177,7 +177,7 @@ port_taken <- function(p) {
|
|
|
177
177
|
error = function(e) NULL))
|
|
178
178
|
if (is.null(con)) FALSE else { close(con); TRUE }
|
|
179
179
|
}
|
|
180
|
-
#
|
|
180
|
+
# BEATRINA_PORT is a PREFERENCE by default and a PROMISE when asked.
|
|
181
181
|
#
|
|
182
182
|
# Moving to a random port when the wanted one is taken is right for a person
|
|
183
183
|
# double-clicking the app: they get a session instead of an error, and the
|
|
@@ -186,23 +186,23 @@ port_taken <- function(p) {
|
|
|
186
186
|
# is already written into a registry record and about to be handed to Caddy
|
|
187
187
|
# as an upstream, so a kernel that quietly moves leaves BOTH pointing at
|
|
188
188
|
# nothing and the reader gets a connection refused with no explanation
|
|
189
|
-
# anywhere in the system.
|
|
189
|
+
# anywhere in the system. BEATRINA_PORT_STRICT=1 turns the silent move into a
|
|
190
190
|
# refusal, which is the only honest answer when the caller cannot be told.
|
|
191
191
|
port <- local({
|
|
192
|
-
wanted <- suppressWarnings(as.integer(Sys.getenv("
|
|
192
|
+
wanted <- suppressWarnings(as.integer(Sys.getenv("BEATRINA_PORT", "4747")))
|
|
193
193
|
if (is.na(wanted) || wanted < 1024L || wanted > 65535L) wanted <- 4747L
|
|
194
194
|
if (!port_taken(wanted)) return(wanted)
|
|
195
195
|
# A successor started by a handoff is spawned while its predecessor still
|
|
196
196
|
# holds the port; it waits for the port to free rather than moving.
|
|
197
|
-
wait_s <- suppressWarnings(as.numeric(Sys.getenv("
|
|
197
|
+
wait_s <- suppressWarnings(as.numeric(Sys.getenv("BEATRINA_WAIT_PORT", "0")))
|
|
198
198
|
if (is.finite(wait_s) && wait_s > 0) {
|
|
199
199
|
deadline <- Sys.time() + wait_s
|
|
200
200
|
while (port_taken(wanted) && Sys.time() < deadline) Sys.sleep(0.25) # a wait is a loop
|
|
201
201
|
if (!port_taken(wanted)) return(wanted)
|
|
202
202
|
}
|
|
203
|
-
if (identical(Sys.getenv("
|
|
204
|
-
message("
|
|
205
|
-
"and
|
|
203
|
+
if (identical(Sys.getenv("BEATRINA_PORT_STRICT", ""), "1")) {
|
|
204
|
+
message("Beatrina refuses to start: BEATRINA_PORT=", wanted, " is already in use ",
|
|
205
|
+
"and BEATRINA_PORT_STRICT=1 forbids moving to another port. Whoever ",
|
|
206
206
|
"chose this port is expecting to find the kernel on it.")
|
|
207
207
|
quit(status = 2L, save = "no")
|
|
208
208
|
}
|
|
@@ -212,27 +212,27 @@ port <- local({
|
|
|
212
212
|
# The posture: bind, Origin allow-list, Host allow-list, and whether an
|
|
213
213
|
# Origin-less client is a same-user native process or an unauthenticated
|
|
214
214
|
# stranger. Computed once; every gate below reads a decided answer.
|
|
215
|
-
deployment <-
|
|
215
|
+
deployment <- beatrina_deployment(port)
|
|
216
216
|
|
|
217
217
|
# The administrator's AI policy, read once. Its errors join the deployment's
|
|
218
218
|
# in the same startup gate and for the same reason: a governance control that
|
|
219
219
|
# silently failed to apply is worse than one that refuses to start, because
|
|
220
220
|
# nobody finds out until the data has already gone to the wrong vendor.
|
|
221
|
-
ai_policy <-
|
|
221
|
+
ai_policy <- beatrina_ai_policy()
|
|
222
222
|
|
|
223
223
|
# The plugin policy (spike/plugins.R): which kinds exist, whether the person
|
|
224
224
|
# may install, which hosts a fetch may reach, where the administrator's set
|
|
225
225
|
# is. Unlike the AI policy this one is enforcement — every fetch and every
|
|
226
226
|
# write is the supervisor's — and it fails closed the same way: a policy
|
|
227
227
|
# that names only junk refuses to start rather than permitting everything.
|
|
228
|
-
plugin_policy <-
|
|
228
|
+
plugin_policy <- beatrina_plugin_policy()
|
|
229
229
|
|
|
230
230
|
startup_errors <- c(managed_config$errors, deployment$errors, ai_policy$errors, plugin_policy$errors)
|
|
231
231
|
if (length(startup_errors)) {
|
|
232
232
|
# Refusing to start is the correct outcome. A server that binds to the
|
|
233
|
-
# network with the loopback trust model is not a degraded
|
|
233
|
+
# network with the loopback trust model is not a degraded Beatrina, it is a
|
|
234
234
|
# remote-execution service, and there is no safe way to serve it "for now".
|
|
235
|
-
for (msg in startup_errors) message("
|
|
235
|
+
for (msg in startup_errors) message("Beatrina refuses to start: ", msg)
|
|
236
236
|
quit(status = 2L, save = "no")
|
|
237
237
|
}
|
|
238
238
|
|
|
@@ -251,10 +251,10 @@ origins_ok <- deployment$origins
|
|
|
251
251
|
hosts_ok <- deployment$hosts
|
|
252
252
|
|
|
253
253
|
# Optional audit log — one JSON object per line, for deployments that must be
|
|
254
|
-
# able to answer "what did this session do?". Off unless
|
|
254
|
+
# able to answer "what did this session do?". Off unless BEATRINA_LOG is set,
|
|
255
255
|
# because a notebook that silently records the user's code would be worse than
|
|
256
256
|
# no logging at all.
|
|
257
|
-
log_path <- Sys.getenv("
|
|
257
|
+
log_path <- Sys.getenv("BEATRINA_LOG", "")
|
|
258
258
|
# pandoc, found once and kept. Absent stays absent for the session — a
|
|
259
259
|
# pandoc installed mid-session shows up after a restart, which is the same
|
|
260
260
|
# rule R itself applies to a package installed under a running session.
|
|
@@ -283,28 +283,28 @@ audit <- function(event, ...) {
|
|
|
283
283
|
# successful settings_set so a later read reports the value that is actually
|
|
284
284
|
# in force, and `linger_s` / `HISTORY_ENABLED` below are the two the running
|
|
285
285
|
# kernel can honour immediately (see each one's `effect`).
|
|
286
|
-
settings_state <-
|
|
287
|
-
settings_value <- function(key)
|
|
286
|
+
settings_state <- beatrina_settings_resolve()
|
|
287
|
+
settings_value <- function(key) beatrina_settings_value(settings_state, key)
|
|
288
288
|
audit("settings", detail = "startup", status = settings_state$file$status,
|
|
289
289
|
keys = length(settings_state$file$values))
|
|
290
290
|
|
|
291
291
|
# What every child inherits from the user's settings, and which R to run.
|
|
292
292
|
# Defined here so the three spawn sites cannot drift: a setting that reaches
|
|
293
293
|
# the worker but not the restarted worker is the worst kind of half-applied.
|
|
294
|
-
settings_child_env <- function()
|
|
294
|
+
settings_child_env <- function() beatrina_settings_child_env(settings_state)
|
|
295
295
|
settings_rscript <- function() {
|
|
296
296
|
chosen <- settings_value("rscript_path")
|
|
297
297
|
detect_rscript(if (is.character(chosen) && nzchar(chosen)) chosen
|
|
298
|
-
else Sys.getenv("
|
|
298
|
+
else Sys.getenv("BEATRINA_RSCRIPT", ""))
|
|
299
299
|
}
|
|
300
300
|
|
|
301
|
-
# The launcher's state dir (
|
|
301
|
+
# The launcher's state dir (BEATRINA_STATE) or the package's: where this
|
|
302
302
|
# supervisor keeps the handoff log for a successor it spawns. Nothing of the
|
|
303
303
|
# R session is ever written here — a worker starts fresh, always (see the
|
|
304
304
|
# handoff section below).
|
|
305
305
|
state_dir <- function() {
|
|
306
|
-
state <- Sys.getenv("
|
|
307
|
-
if (!nzchar(state)) state <- tools::R_user_dir("
|
|
306
|
+
state <- Sys.getenv("BEATRINA_STATE", "")
|
|
307
|
+
if (!nzchar(state)) state <- tools::R_user_dir("beatrina", "data")
|
|
308
308
|
state
|
|
309
309
|
}
|
|
310
310
|
start_execution_worker <- function() {
|
|
@@ -325,16 +325,16 @@ sockets$open <- list()
|
|
|
325
325
|
# "connecting…" forever waiting for a frame that was broadcast to nobody.
|
|
326
326
|
sockets$hello <- NULL
|
|
327
327
|
# A kept workspace for the NEXT ready worker: set by `restart {resume}` or, in a
|
|
328
|
-
# handoff successor, by
|
|
329
|
-
sockets$resume_pending <- if (keep_token_ok(Sys.getenv("
|
|
330
|
-
Sys.getenv("
|
|
328
|
+
# handoff successor, by BEATRINA_RESUME_TOKEN. Consumed once, on `ready`.
|
|
329
|
+
sockets$resume_pending <- if (keep_token_ok(Sys.getenv("BEATRINA_RESUME_TOKEN")))
|
|
330
|
+
Sys.getenv("BEATRINA_RESUME_TOKEN") else NULL
|
|
331
331
|
# The restore's report, kept for 120 s so a page that follows a handoff — and
|
|
332
332
|
# connects after the restore finished — still hears what came back.
|
|
333
333
|
sockets$resume_report <- NULL
|
|
334
334
|
# The documents open in this session (spike/session-documents.R), as the pages
|
|
335
335
|
# last reported them. A handoff successor starts with its predecessor's list.
|
|
336
|
-
sockets$session_documents <- session_documents_decode(Sys.getenv("
|
|
337
|
-
Sys.unsetenv("
|
|
336
|
+
sockets$session_documents <- session_documents_decode(Sys.getenv("BEATRINA_SESSION_DOCUMENTS"))
|
|
337
|
+
Sys.unsetenv("BEATRINA_SESSION_DOCUMENTS")
|
|
338
338
|
sockets$worker_recovering <- FALSE
|
|
339
339
|
sockets$worker_restart_attempts <- 0L
|
|
340
340
|
sockets$worker_restart_after <- 0
|
|
@@ -365,7 +365,7 @@ sockets$terms <- new.env(parent = emptyenv())
|
|
|
365
365
|
sockets$term_seq <- 0L
|
|
366
366
|
# Connection lifetime and R lifetime are separate. By default a session that
|
|
367
367
|
# has been attached survives closed tabs, browser suspension and lost links.
|
|
368
|
-
# Positive
|
|
368
|
+
# Positive BEATRINA_LINGER / linger_seconds explicitly opts into idle shutdown.
|
|
369
369
|
# A running task blocks that optional clock; reconnection resets it.
|
|
370
370
|
# How long /health?hold=1 postpones an explicitly configured idle timeout.
|
|
371
371
|
# Default retention does not depend on holds or their timing.
|
|
@@ -373,8 +373,8 @@ HOLD_GRACE_S <- 30
|
|
|
373
373
|
# How long a kernel that has never been connected to waits for its browser
|
|
374
374
|
# before an unopened launch is cleaned up. This is NOT a session idle timer:
|
|
375
375
|
# the first connection or hold permanently cancels default boot cleanup.
|
|
376
|
-
# Explicit
|
|
377
|
-
BOOT_GRACE_S <- as.numeric(Sys.getenv("
|
|
376
|
+
# Explicit BEATRINA_LINGER=0 also disables unopened-launch cleanup for headless use.
|
|
377
|
+
BOOT_GRACE_S <- as.numeric(Sys.getenv("BEATRINA_BOOT_GRACE", "180"))
|
|
378
378
|
if (!is.finite(BOOT_GRACE_S) || BOOT_GRACE_S < 0) BOOT_GRACE_S <- 180
|
|
379
379
|
# The same safe default applies locally and behind a proxy. Explicit user or
|
|
380
380
|
# administrator settings remain authoritative, including an explicit zero.
|
|
@@ -398,7 +398,7 @@ linger_s <- resolve_linger()
|
|
|
398
398
|
# Only a socket that has beaten at least once is ever reaped — a notebook built
|
|
399
399
|
# before heartbeats exists on people's disks and must keep today's behaviour
|
|
400
400
|
# rather than be disconnected while its reader is looking at it.
|
|
401
|
-
socket_silence_s <- suppressWarnings(as.numeric(Sys.getenv("
|
|
401
|
+
socket_silence_s <- suppressWarnings(as.numeric(Sys.getenv("BEATRINA_SOCKET_SILENCE", "90")))
|
|
402
402
|
if (!length(socket_silence_s) || !is.finite(socket_silence_s)) socket_silence_s <- 90
|
|
403
403
|
|
|
404
404
|
# ── the dispatched-command deadline, PER COMMAND CLASS ─────────────────────
|
|
@@ -460,7 +460,7 @@ WORKER_DEADLINES <- c(
|
|
|
460
460
|
# Tests need the same branch in seconds rather than minutes. Only a test sets
|
|
461
461
|
# it; the numbers above ARE the contract.
|
|
462
462
|
worker_deadline_scale <- suppressWarnings(
|
|
463
|
-
as.numeric(Sys.getenv("
|
|
463
|
+
as.numeric(Sys.getenv("BEATRINA_WORKER_DEADLINE_SCALE", "1")))
|
|
464
464
|
if (!length(worker_deadline_scale) || !is.finite(worker_deadline_scale) ||
|
|
465
465
|
worker_deadline_scale <= 0) worker_deadline_scale <- 1
|
|
466
466
|
|
|
@@ -472,14 +472,14 @@ if (!length(worker_deadline_scale) || !is.finite(worker_deadline_scale) ||
|
|
|
472
472
|
# than an honest terminal state, because the attach plug is hidden while
|
|
473
473
|
# recovering and the user cannot reach another session either.
|
|
474
474
|
worker_respawn_max <- suppressWarnings(
|
|
475
|
-
as.integer(Sys.getenv("
|
|
475
|
+
as.integer(Sys.getenv("BEATRINA_WORKER_RESPAWN_MAX", "10")))
|
|
476
476
|
if (!length(worker_respawn_max) || is.na(worker_respawn_max) ||
|
|
477
477
|
worker_respawn_max < 1L) worker_respawn_max <- 10L
|
|
478
478
|
|
|
479
479
|
# An agent's tool call parked on a page that then died. The page half is the
|
|
480
480
|
# precise test — a rec no longer in `sockets$open` can never answer — and this
|
|
481
481
|
# is the backstop for the case where the page is alive and simply never
|
|
482
|
-
# replies. It matches the ceiling tools/mcp/
|
|
482
|
+
# replies. It matches the ceiling tools/mcp/beatrina-mcp.mjs allows a caller to
|
|
483
483
|
# ask for (chunk_run's timeout_s caps at 3600), so the supervisor never gives
|
|
484
484
|
# up on a call the agent is still legitimately waiting for.
|
|
485
485
|
MCP_PENDING_MAX_S <- 3600
|
|
@@ -498,7 +498,7 @@ sockets$worker_queue <- list()
|
|
|
498
498
|
# explicitly pairs one page in a loopback consent window; that LOCAL window
|
|
499
499
|
# owns the socket and relays only messages from the exact approved origin.
|
|
500
500
|
sockets$pairing_requests <- new.env(parent = emptyenv())
|
|
501
|
-
# Consent belongs to this local
|
|
501
|
+
# Consent belongs to this local Beatrina session, not to one popup document.
|
|
502
502
|
# Once an origin is approved, a replacement bridge window for that exact
|
|
503
503
|
# origin may reconnect without asking again. The environment disappears when
|
|
504
504
|
# the kernel stops, so approval is still session-scoped.
|
|
@@ -536,7 +536,7 @@ sockets$boot_at <- Sys.time()
|
|
|
536
536
|
|
|
537
537
|
`%||%` <- function(a, b) if (is.null(a)) b else a
|
|
538
538
|
|
|
539
|
-
#' The page to serve at `/`: the built
|
|
539
|
+
#' The page to serve at `/`: the built Beatrina notebook when there is one,
|
|
540
540
|
#' otherwise the bare spike page. The page is PINNED to this kernel's build
|
|
541
541
|
#' (never the highest version present), `.beta.min` ignored so
|
|
542
542
|
#' a debuggable build is served during development. The repo keeps builds in
|
|
@@ -547,7 +547,7 @@ notebook_page <- function() {
|
|
|
547
547
|
# One implementation, in notebook-page.R, because tools/app/launch.sh has to
|
|
548
548
|
# ask the same question at open time — the announcement below is computed
|
|
549
549
|
# once at startup and goes stale the moment a new build lands.
|
|
550
|
-
page <-
|
|
550
|
+
page <- beatrina_notebook_page(here, BEATRINA_KERNEL_BUILD)
|
|
551
551
|
if (nzchar(page)) return(page)
|
|
552
552
|
file.path(here, "index.html")
|
|
553
553
|
}
|
|
@@ -559,7 +559,7 @@ notebook_page <- function() {
|
|
|
559
559
|
#' does that by comparing ONE string — the boot time exactly as /health
|
|
560
560
|
#' reports `started`, rendered by the same jsonlite call so the two can never
|
|
561
561
|
#' differ in their digits. Served pages get it as
|
|
562
|
-
#' `<meta name="
|
|
562
|
+
#' `<meta name="beatrina-session">`; a file page gets the same value in its
|
|
563
563
|
#' fragment from the launcher.
|
|
564
564
|
session_stamp <- function() {
|
|
565
565
|
as.character(toJSON(as.numeric(sockets$boot_at), auto_unbox = TRUE))
|
|
@@ -574,7 +574,7 @@ session_stamp <- function() {
|
|
|
574
574
|
#' @param html the page's text.
|
|
575
575
|
#' @return the page with the meta in it.
|
|
576
576
|
stamp_session <- function(html) {
|
|
577
|
-
meta <- sprintf('<meta name="
|
|
577
|
+
meta <- sprintf('<meta name="beatrina-session" content="%s">', session_stamp())
|
|
578
578
|
at <- regexpr("<head\\b[^>]*>", html, perl = TRUE)
|
|
579
579
|
if (at < 0) at <- regexpr("<html\\b[^>]*>", html, perl = TRUE)
|
|
580
580
|
if (at < 0) return(paste0(meta, html))
|
|
@@ -582,13 +582,13 @@ stamp_session <- function(html) {
|
|
|
582
582
|
paste0(substr(html, 1L, cut), meta, substr(html, cut + 1L, nchar(html)))
|
|
583
583
|
}
|
|
584
584
|
|
|
585
|
-
#' Where the
|
|
585
|
+
#' Where the beatrina-mcp stdio server lives on THIS machine, so the setup
|
|
586
586
|
#' dialog can show a command that works verbatim. Repo layout first
|
|
587
587
|
#' (tools/mcp beside spike/), then a distribution copy beside serve.R.
|
|
588
588
|
#' Empty when neither exists — the dialog then shows a placeholder path.
|
|
589
589
|
mcp_server_path <- function() {
|
|
590
|
-
for (p in c(file.path(here, "..", "tools", "mcp", "
|
|
591
|
-
file.path(here, "mcp", "
|
|
590
|
+
for (p in c(file.path(here, "..", "tools", "mcp", "beatrina-mcp.mjs"),
|
|
591
|
+
file.path(here, "mcp", "beatrina-mcp.mjs"))) {
|
|
592
592
|
if (file.exists(p)) return(normalizePath(p))
|
|
593
593
|
}
|
|
594
594
|
""
|
|
@@ -607,17 +607,17 @@ find_binary <- function(name, extra = character()) {
|
|
|
607
607
|
""
|
|
608
608
|
}
|
|
609
609
|
|
|
610
|
-
#' The Claude Code CLI, if this machine has one.
|
|
610
|
+
#' The Claude Code CLI, if this machine has one. BEATRINA_CLAUDE_BIN overrides —
|
|
611
611
|
#' that is also how tests substitute a deterministic stub.
|
|
612
612
|
claude_bin <- function() {
|
|
613
|
-
override <- Sys.getenv("
|
|
613
|
+
override <- Sys.getenv("BEATRINA_CLAUDE_BIN", "")
|
|
614
614
|
if (nzchar(override) && file.exists(override)) return(override)
|
|
615
615
|
find_binary("claude")
|
|
616
616
|
}
|
|
617
617
|
|
|
618
|
-
#' The Codex CLI, if this machine has one.
|
|
618
|
+
#' The Codex CLI, if this machine has one. BEATRINA_CODEX_BIN overrides (tests).
|
|
619
619
|
codex_bin <- function() {
|
|
620
|
-
override <- Sys.getenv("
|
|
620
|
+
override <- Sys.getenv("BEATRINA_CODEX_BIN", "")
|
|
621
621
|
if (nzchar(override) && file.exists(override)) return(override)
|
|
622
622
|
find_binary("codex")
|
|
623
623
|
}
|
|
@@ -641,11 +641,11 @@ published_origin_valid <- function(origin) {
|
|
|
641
641
|
grepl("^https?://[A-Za-z0-9._~-]+(:[0-9]{1,5})?$", origin)
|
|
642
642
|
}
|
|
643
643
|
|
|
644
|
-
# A native
|
|
644
|
+
# A native Beatrina launcher may start the kernel for one published page. The
|
|
645
645
|
# URL-scheme handoff is the user's consent gesture; carrying only the exact
|
|
646
646
|
# origin into this process keeps the permission session-scoped and prevents a
|
|
647
647
|
# second site from borrowing it.
|
|
648
|
-
initial_published_origin <- Sys.getenv("
|
|
648
|
+
initial_published_origin <- Sys.getenv("BEATRINA_PUBLISHED_ORIGIN", "")
|
|
649
649
|
if (published_origin_valid(initial_published_origin)) {
|
|
650
650
|
sockets$published_approvals[[initial_published_origin]] <- TRUE
|
|
651
651
|
audit("published-origin-authorized", origin = initial_published_origin,
|
|
@@ -670,8 +670,8 @@ if (published_origin_valid(initial_published_origin)) {
|
|
|
670
670
|
# looking for one. Any HTML file the user opened could therefore sweep for a
|
|
671
671
|
# kernel and send `exec` — the same shape as the Chrome extension deleted in
|
|
672
672
|
# 0.38.0 ("no token and no page identity"), which is documented there as
|
|
673
|
-
# unacceptable. A file page cannot hold a secret
|
|
674
|
-
#
|
|
673
|
+
# unacceptable. A file page cannot hold a secret Beatrina never gave it, and
|
|
674
|
+
# Beatrina never sees it open (Beatrina.app claims .qmd/.Rmd/.R/... but not
|
|
675
675
|
# .html, so a double-clicked notebook goes straight to the browser). What is
|
|
676
676
|
# left is to ASK — once per session, on a local page that names what is
|
|
677
677
|
# asking. Absent Origin (native same-user clients) is deliberately unchanged:
|
|
@@ -681,7 +681,7 @@ if (published_origin_valid(initial_published_origin)) {
|
|
|
681
681
|
#' `sample()` draws from the Mersenne Twister, which is seeded from the clock
|
|
682
682
|
#' and the pid and is trivially reconstructible — fine for shuffling a vector,
|
|
683
683
|
#' not for a value that decides whether a page may run R. /dev/urandom is the
|
|
684
|
-
#' OS CSPRNG on every platform
|
|
684
|
+
#' OS CSPRNG on every platform Beatrina ships a kernel for except Windows, where
|
|
685
685
|
#' the RNG fallback is documented rather than hidden.
|
|
686
686
|
secure_token <- function(n = 32L) {
|
|
687
687
|
alphabet <- c(letters, LETTERS, 0:9)
|
|
@@ -701,7 +701,7 @@ secure_token <- function(n = 32L) {
|
|
|
701
701
|
|
|
702
702
|
FILE_ORIGIN <- "null"
|
|
703
703
|
|
|
704
|
-
# A notebook page SERVED BY ANOTHER
|
|
704
|
+
# A notebook page SERVED BY ANOTHER Beatrina KERNEL on this machine. When a
|
|
705
705
|
# page's own kernel dies the page stays open (it is HTML the browser already
|
|
706
706
|
# holds) and can attach to a different session through the badge's plug icon;
|
|
707
707
|
# it arrives here with the Origin of the kernel that served it,
|
|
@@ -721,7 +721,7 @@ local_origin <- function(origin) {
|
|
|
721
721
|
# consent click, while a file page that merely found the port cannot.
|
|
722
722
|
#
|
|
723
723
|
# serve.R prints the exact file:// URL it expects to be opened, and that URL
|
|
724
|
-
# is the one channel
|
|
724
|
+
# is the one channel Beatrina controls: it already carries `#kernel=<port>`, so
|
|
725
725
|
# it can carry a secret too. The page reads it out of its own fragment and
|
|
726
726
|
# presents it on the WebSocket URL. A drive-by html file probing 4747..65535
|
|
727
727
|
# can reach /health and learn a kernel is there, but it was never handed this,
|
|
@@ -739,12 +739,12 @@ local_origin <- function(origin) {
|
|
|
739
739
|
# so the page can be told where to go BEFORE this process exits. Only the exact
|
|
740
740
|
# shape secure_token() makes is accepted; anything else is minted afresh.
|
|
741
741
|
FILE_LAUNCH_CAP <- local({
|
|
742
|
-
given <- Sys.getenv("
|
|
743
|
-
Sys.unsetenv("
|
|
742
|
+
given <- Sys.getenv("BEATRINA_FILE_LAUNCH_CAP", "")
|
|
743
|
+
Sys.unsetenv("BEATRINA_FILE_LAUNCH_CAP")
|
|
744
744
|
if (grepl("^[A-Za-z0-9]{32}$", given)) given else secure_token(32L)
|
|
745
745
|
})
|
|
746
746
|
|
|
747
|
-
open_file_env <- Sys.getenv("
|
|
747
|
+
open_file_env <- Sys.getenv("BEATRINA_OPEN_FILE", "")
|
|
748
748
|
sockets$pending_open <- if (nzchar(open_file_env)) open_file_env else NULL
|
|
749
749
|
if (!is.null(sockets$pending_open)) audit("open-file-parked")
|
|
750
750
|
|
|
@@ -783,9 +783,9 @@ pairing_page <- function(origin, nonce = "") {
|
|
|
783
783
|
'<meta name="viewport" content="width=device-width,initial-scale=1">',
|
|
784
784
|
'<meta http-equiv="Content-Security-Policy" content="default-src \'none\'; ',
|
|
785
785
|
"style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'\">",
|
|
786
|
-
'<title>', if (is_file) 'Allow this notebook file to use
|
|
787
|
-
else if (is_local) 'Attach this notebook to this
|
|
788
|
-
else 'Allow this book to use
|
|
786
|
+
'<title>', if (is_file) 'Allow this notebook file to use Beatrina?'
|
|
787
|
+
else if (is_local) 'Attach this notebook to this Beatrina session?'
|
|
788
|
+
else 'Allow this book to use Beatrina?', '</title><style>',
|
|
789
789
|
'body{font:16px/1.5 system-ui,sans-serif;max-width:42rem;margin:10vh auto;padding:0 1.25rem;color:#172033}',
|
|
790
790
|
'.site{padding:.75rem 1rem;background:#f3f6fb;border-radius:.55rem;overflow-wrap:anywhere}',
|
|
791
791
|
'button{border:0;border-radius:.5rem;background:#2357d9;color:white;padding:.7rem 1rem;font:inherit;font-weight:650;cursor:pointer}',
|
|
@@ -794,15 +794,15 @@ pairing_page <- function(origin, nonce = "") {
|
|
|
794
794
|
paste0('<h1>Run this notebook file with your R?</h1>',
|
|
795
795
|
'<p>A notebook opened from a file on this computer</p>',
|
|
796
796
|
'<p class="site"><strong>a page opened from disk (file://)</strong></p>',
|
|
797
|
-
'<p>wants to use this
|
|
797
|
+
'<p>wants to use this Beatrina session. The code will run as your user ',
|
|
798
798
|
'and can access your files and network. A page opened from a file ',
|
|
799
799
|
'cannot be identified any more exactly than this, so approve it only ',
|
|
800
800
|
'if you just opened a notebook of your own.</p>')
|
|
801
801
|
else if (is_local)
|
|
802
802
|
paste0('<h1>Attach this notebook to this R session?</h1>',
|
|
803
|
-
'<p>A
|
|
803
|
+
'<p>A Beatrina notebook page served at</p>',
|
|
804
804
|
'<p class="site"><strong>', html_escape(origin), '</strong></p>',
|
|
805
|
-
'<p>wants to use this
|
|
805
|
+
'<p>wants to use this Beatrina session instead of its own — usually ',
|
|
806
806
|
'because the session that served it has stopped. It will be treated as ',
|
|
807
807
|
'your notebook: its chunks run as your user, and it may open a terminal ',
|
|
808
808
|
'and use your saved AI settings. Approve it only if you just asked a ',
|
|
@@ -810,14 +810,14 @@ pairing_page <- function(origin, nonce = "") {
|
|
|
810
810
|
else
|
|
811
811
|
paste0('<h1>Run this book with your R?</h1><p>The published site</p>',
|
|
812
812
|
'<p class="site"><strong>', html_escape(origin), '</strong></p>',
|
|
813
|
-
'<p>wants to send R chunks to this
|
|
813
|
+
'<p>wants to send R chunks to this Beatrina session on your computer. ',
|
|
814
814
|
'The code will run as your user and can access your files and network.</p>'),
|
|
815
815
|
'<form method="get" action="/pair/approve"><input type="hidden" name="challenge" value="',
|
|
816
816
|
challenge, '"><button type="submit">',
|
|
817
817
|
if (is_local) 'Attach for this session' else 'Allow for this session',
|
|
818
818
|
'</button></form>',
|
|
819
819
|
'<p><small>Nothing runs until you press a chunk\'s Run button. ',
|
|
820
|
-
'Approval disappears when this
|
|
820
|
+
'Approval disappears when this Beatrina session stops.</small></p></body></html>')
|
|
821
821
|
}
|
|
822
822
|
|
|
823
823
|
pairing_bridge_page <- function(rec) {
|
|
@@ -829,10 +829,10 @@ pairing_bridge_page <- function(rec) {
|
|
|
829
829
|
'<meta http-equiv="Content-Security-Policy" content="default-src \'none\'; ',
|
|
830
830
|
"script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self' ws://127.0.0.1:",
|
|
831
831
|
port, "; base-uri 'none'\">",
|
|
832
|
-
'<title>
|
|
832
|
+
'<title>Beatrina connected</title><style>',
|
|
833
833
|
'body{font:16px/1.5 system-ui,sans-serif;max-width:38rem;margin:10vh auto;padding:0 1.25rem;color:#172033}',
|
|
834
834
|
'.site{overflow-wrap:anywhere;color:#2357d9}</style></head><body>',
|
|
835
|
-
'<h1>
|
|
835
|
+
'<h1>Beatrina is connected</h1><p>This session now runs chunks pressed on</p>',
|
|
836
836
|
'<p class="site"><strong>', html_escape(rec$origin), '</strong></p>',
|
|
837
837
|
'<p>The page connects by itself and closes this window; if it stays open, you can close it. ',
|
|
838
838
|
'The approval lasts until this R session stops.</p><script>(function(){',
|
|
@@ -841,15 +841,15 @@ pairing_bridge_page <- function(rec) {
|
|
|
841
841
|
'Object.assign({type,nonce,origin:target,port:', port, '},extra||{}),target)};',
|
|
842
842
|
'addEventListener("message",event=>{const data=event.data||{};',
|
|
843
843
|
'if(event.source!==window.opener||event.origin!==target||data.nonce!==nonce)return;',
|
|
844
|
-
'if(data.type==="
|
|
844
|
+
'if(data.type==="beatrina:bridge-attach"){const generation=++wsGeneration,old=ws;',
|
|
845
845
|
'if(old){old.onmessage=old.onerror=old.onclose=null;try{old.close()}catch(e){}}',
|
|
846
846
|
'ws=new WebSocket(socketUrl);const current=ws;',
|
|
847
|
-
'current.onmessage=message=>{if(generation===wsGeneration)tell("
|
|
848
|
-
'current.onerror=()=>{if(generation===wsGeneration)tell("
|
|
849
|
-
'current.onclose=()=>{if(generation===wsGeneration)tell("
|
|
850
|
-
'if(data.type==="
|
|
851
|
-
'if(data.type==="
|
|
852
|
-
'tell("
|
|
847
|
+
'current.onmessage=message=>{if(generation===wsGeneration)tell("beatrina:bridge-frame",{data:message.data})};',
|
|
848
|
+
'current.onerror=()=>{if(generation===wsGeneration)tell("beatrina:bridge-error")};',
|
|
849
|
+
'current.onclose=()=>{if(generation===wsGeneration)tell("beatrina:bridge-close")};return}',
|
|
850
|
+
'if(data.type==="beatrina:bridge-send"&&ws&&ws.readyState===1)ws.send(data.data);',
|
|
851
|
+
'if(data.type==="beatrina:bridge-close"&&ws)ws.close()});',
|
|
852
|
+
'tell("beatrina:paired");})();</script></body></html>')
|
|
853
853
|
}
|
|
854
854
|
|
|
855
855
|
pairing_approval_page <- function(challenge) {
|
|
@@ -876,14 +876,14 @@ pairing_done_page <- function(rec) {
|
|
|
876
876
|
'<meta name="viewport" content="width=device-width,initial-scale=1">',
|
|
877
877
|
'<meta http-equiv="Content-Security-Policy" content="default-src \'none\'; ',
|
|
878
878
|
"style-src 'unsafe-inline'; base-uri 'none'\">",
|
|
879
|
-
'<title>
|
|
879
|
+
'<title>Beatrina attached</title><style>',
|
|
880
880
|
'body{font:16px/1.5 system-ui,sans-serif;max-width:38rem;margin:10vh auto;padding:0 1.25rem;color:#172033}',
|
|
881
881
|
'.site{overflow-wrap:anywhere;color:#2357d9}</style></head><body>',
|
|
882
882
|
'<h1>Attached</h1><p>The notebook</p>',
|
|
883
883
|
'<p class="site"><strong>',
|
|
884
884
|
if (identical(rec$origin, FILE_ORIGIN)) 'a page opened from disk (file://)' else html_escape(rec$origin),
|
|
885
885
|
'</strong></p>',
|
|
886
|
-
'<p>may use this
|
|
886
|
+
'<p>may use this Beatrina session until it stops. It reconnects by itself within ',
|
|
887
887
|
'a few seconds; you can close this window.</p></body></html>')
|
|
888
888
|
}
|
|
889
889
|
|
|
@@ -915,7 +915,7 @@ host_ok <- function(req) {
|
|
|
915
915
|
#'
|
|
916
916
|
#' Native same-user clients (the app launcher, R, curl, the MCP bridge) do not
|
|
917
917
|
#' send Origin or Sec-Fetch-Site and remain valid. Browsers do: a foreign page
|
|
918
|
-
#' cannot stop a kernel or ask it to open a file, while
|
|
918
|
+
#' cannot stop a kernel or ask it to open a file, while Beatrina keeps the clean,
|
|
919
919
|
#' stable localhost URL users expect.
|
|
920
920
|
control_rejection <- function(req, allow_file = FALSE, allow_approved = FALSE) {
|
|
921
921
|
origin <- req$HTTP_ORIGIN %||% ""
|
|
@@ -986,14 +986,14 @@ socket_class <- function(req) {
|
|
|
986
986
|
# returns quantiles, level counts and a histogram for ONE named column. It is
|
|
987
987
|
# strictly narrower than the `view` beside it — same resolution, less surface,
|
|
988
988
|
# no mutation — which is the bar a new entry has to clear, not "it is useful".
|
|
989
|
-
#' The ports of
|
|
989
|
+
#' The ports of Beatrina kernels that are actually answering right now.
|
|
990
990
|
#'
|
|
991
991
|
#' Read from the runtime directory rather than remembered, so sessions started
|
|
992
992
|
#' by the R package launcher count too — and health-checked, because a killed
|
|
993
993
|
#' kernel leaves its file behind and a dead session must not hold a slot.
|
|
994
994
|
live_session_ports <- function() {
|
|
995
|
-
dir <- Sys.getenv("
|
|
996
|
-
file.path(path.expand("~"), ".
|
|
995
|
+
dir <- Sys.getenv("BEATRINA_RUNTIME_DIR",
|
|
996
|
+
file.path(path.expand("~"), ".beatrina", "run"))
|
|
997
997
|
files <- list.files(dir, pattern = "^kernel-[0-9]+\\.json$", full.names = TRUE)
|
|
998
998
|
ports <- suppressWarnings(as.integer(sub("^kernel-([0-9]+)\\.json$", "\\1", basename(files))))
|
|
999
999
|
ports <- ports[!is.na(ports)]
|
|
@@ -1037,19 +1037,19 @@ start_sibling_session <- function() {
|
|
|
1037
1037
|
if (!(candidate %in% used)) { chosen <- candidate; break }
|
|
1038
1038
|
}
|
|
1039
1039
|
if (is.na(chosen)) return(NULL)
|
|
1040
|
-
# `chosen` is a PREFERENCE, not a promise. serve.R takes
|
|
1040
|
+
# `chosen` is a PREFERENCE, not a promise. serve.R takes BEATRINA_PORT only if
|
|
1041
1041
|
# the port is still free when IT looks — between our probe and its start the
|
|
1042
1042
|
# port can go, and it then falls back to a random one. Waiting for the exact
|
|
1043
1043
|
# port we picked therefore reported failure for a session that had started
|
|
1044
1044
|
# perfectly well, and left it orphaned. So: remember what was live before,
|
|
1045
1045
|
# and accept whichever new port appears.
|
|
1046
1046
|
before <- live_session_ports()
|
|
1047
|
-
sib_log <- tryCatch(tempfile("
|
|
1047
|
+
sib_log <- tryCatch(tempfile("beatrina-session-", fileext = ".log"),
|
|
1048
1048
|
error = function(e) nullfile())
|
|
1049
1049
|
started <- tryCatch({
|
|
1050
1050
|
processx::process$new(file.path(R.home("bin"), "Rscript"),
|
|
1051
1051
|
c(file.path(here, "serve.R")),
|
|
1052
|
-
env = c(Sys.getenv(),
|
|
1052
|
+
env = c(Sys.getenv(), BEATRINA_PORT = as.character(chosen)),
|
|
1053
1053
|
# cleanup = FALSE or processx kills this child when
|
|
1054
1054
|
# `started` is garbage-collected, which is immediately:
|
|
1055
1055
|
# it is a local that dies with this function. The
|
|
@@ -1082,7 +1082,7 @@ start_sibling_session <- function() {
|
|
|
1082
1082
|
|
|
1083
1083
|
# ── the AI key lives HERE, not in the browser ────────────────────────────────
|
|
1084
1084
|
#
|
|
1085
|
-
# It used to sit in localStorage under `
|
|
1085
|
+
# It used to sit in localStorage under `beatrina-ai-key-kept`: in the browser's
|
|
1086
1086
|
# storage for this origin, readable by anything that can run script in the page,
|
|
1087
1087
|
# and one careless export away from being inside a notebook file. A provider key
|
|
1088
1088
|
# is not notebook content — it is a machine credential — so it belongs where the
|
|
@@ -1141,8 +1141,8 @@ AGENT_REFUSED <- c("exec", "interrupt", "force_stop", "restart", "session_upgrad
|
|
|
1141
1141
|
# for the same reason FORWARDED and ANALYZE_FORWARDED stay two.
|
|
1142
1142
|
#
|
|
1143
1143
|
# Only a notebook page qualifies. A native client is refused even though it
|
|
1144
|
-
# could read the 0600 file itself as this user: the point is not that
|
|
1145
|
-
# can stop it, but that
|
|
1144
|
+
# could read the 0600 file itself as this user: the point is not that Beatrina
|
|
1145
|
+
# can stop it, but that Beatrina does not hand the key over — so the promise
|
|
1146
1146
|
# holds for every agent instead of only the polite ones. A published site is
|
|
1147
1147
|
# refused because its reader approved it to RUN THE CHUNKS THEY PRESS, not to
|
|
1148
1148
|
# collect a credential on the way past.
|
|
@@ -1173,10 +1173,10 @@ AI_AUDIT_FIELDS <- c("provider", "model", "lane", "tool", "chunk", "decision",
|
|
|
1173
1173
|
# The prompt and the response themselves. OFF unless an administrator asks,
|
|
1174
1174
|
# because a notebook that silently recorded everything its user typed at an AI
|
|
1175
1175
|
# would be a worse thing to ship than no audit at all — the same judgement
|
|
1176
|
-
#
|
|
1176
|
+
# BEATRINA_LOG itself is off by default for. A compliance deployment turns it on
|
|
1177
1177
|
# knowingly; a laptop never does.
|
|
1178
1178
|
AI_AUDIT_TEXT_FIELDS <- c("prompt", "response")
|
|
1179
|
-
ai_audit_text <- identical(Sys.getenv("
|
|
1179
|
+
ai_audit_text <- identical(Sys.getenv("BEATRINA_LOG_AI_TEXT", ""), "1")
|
|
1180
1180
|
|
|
1181
1181
|
# How many ai-* records the reviewer export may read back at once.
|
|
1182
1182
|
AI_AUDIT_READ_MAX <- 5000L
|
|
@@ -1192,7 +1192,7 @@ ai_audit_scalar <- function(value, limit) {
|
|
|
1192
1192
|
}
|
|
1193
1193
|
|
|
1194
1194
|
ai_key_path <- function() {
|
|
1195
|
-
dir <- tools::R_user_dir("
|
|
1195
|
+
dir <- tools::R_user_dir("beatrina", "config")
|
|
1196
1196
|
dir.create(dir, recursive = TRUE, showWarnings = FALSE)
|
|
1197
1197
|
file.path(dir, "ai-key")
|
|
1198
1198
|
}
|
|
@@ -1239,7 +1239,7 @@ ai_key_write <- function(value) {
|
|
|
1239
1239
|
# user directory and never in the notebook file, an export, or localStorage —
|
|
1240
1240
|
# where any local page sharing the file:// origin could read the user's code.
|
|
1241
1241
|
#
|
|
1242
|
-
# Off entirely with
|
|
1242
|
+
# Off entirely with BEATRINA_NO_HISTORY=1, because "record everything I type"
|
|
1243
1243
|
# must be refusable.
|
|
1244
1244
|
HISTORY_MAX <- 2000L
|
|
1245
1245
|
# Consulted INSIDE history_read()/history_add() per call, never captured at the
|
|
@@ -1248,7 +1248,7 @@ HISTORY_MAX <- 2000L
|
|
|
1248
1248
|
HISTORY_ENABLED <- isTRUE(settings_value("history_enabled"))
|
|
1249
1249
|
|
|
1250
1250
|
history_path <- function() {
|
|
1251
|
-
dir <- tools::R_user_dir("
|
|
1251
|
+
dir <- tools::R_user_dir("beatrina", "data")
|
|
1252
1252
|
dir.create(dir, recursive = TRUE, showWarnings = FALSE)
|
|
1253
1253
|
file.path(dir, "console-history")
|
|
1254
1254
|
}
|
|
@@ -1293,7 +1293,7 @@ history_add <- function(line) {
|
|
|
1293
1293
|
|
|
1294
1294
|
# ── appearance, across the two origins the notebook is delivered from ────────
|
|
1295
1295
|
#
|
|
1296
|
-
#
|
|
1296
|
+
# Beatrina delivers the SAME notebook from a file:// page and from this kernel's
|
|
1297
1297
|
# own http://127.0.0.1:PORT, and browsers partition storage by origin — so a
|
|
1298
1298
|
# theme chosen at one door is invisible at the other. That is not a theory: it
|
|
1299
1299
|
# cost an afternoon on 2026-09-10, when an owner's Flat theme turned out to be
|
|
@@ -1316,41 +1316,41 @@ history_add <- function(line) {
|
|
|
1316
1316
|
# local HTML file shares the file:// origin, so without it `prefs` is a
|
|
1317
1317
|
# free-form key/value bag any of them could fill.
|
|
1318
1318
|
#
|
|
1319
|
-
# The values travel as the STRINGS localStorage holds.
|
|
1319
|
+
# The values travel as the STRINGS localStorage holds. beatrina-chunk-custom is
|
|
1320
1320
|
# itself JSON; re-parsing it here would make the kernel a party to a format
|
|
1321
1321
|
# only the page understands, and jsonlite would round its numbers on the way
|
|
1322
1322
|
# back out — the frame-fidelity lesson, one store further in.
|
|
1323
1323
|
#
|
|
1324
|
-
# Off entirely with
|
|
1324
|
+
# Off entirely with BEATRINA_NO_PREFS=1, for the same reason BEATRINA_NO_HISTORY
|
|
1325
1325
|
# exists: "keep a record of my choices on this machine" must be refusable. It
|
|
1326
1326
|
# is also what keeps the TEST BATTERY out of the real store — unlike the
|
|
1327
1327
|
# history, which only fills when somebody types, this publishes on every page
|
|
1328
1328
|
# connect, so any suite that boots a real page would otherwise seed the user's
|
|
1329
1329
|
# appearance with whatever its scratch page happened to hold. Set by
|
|
1330
1330
|
# tools/run-test-suite.mjs for every suite it runs.
|
|
1331
|
-
PREFS_ENABLED <- !identical(Sys.getenv("
|
|
1331
|
+
PREFS_ENABLED <- !identical(Sys.getenv("BEATRINA_NO_PREFS", ""), "1")
|
|
1332
1332
|
PREF_MAX_VALUE <- 4096L
|
|
1333
1333
|
PREF_KEYS <- c(
|
|
1334
|
-
"
|
|
1335
|
-
"
|
|
1336
|
-
"
|
|
1337
|
-
"
|
|
1338
|
-
"
|
|
1339
|
-
"
|
|
1340
|
-
"
|
|
1341
|
-
"
|
|
1342
|
-
"
|
|
1343
|
-
"
|
|
1344
|
-
"
|
|
1345
|
-
"
|
|
1346
|
-
"
|
|
1347
|
-
"
|
|
1348
|
-
"
|
|
1349
|
-
"
|
|
1334
|
+
"beatrina-app-theme", # lib/app-themes.js standard/flat/plain/graphite/onyx
|
|
1335
|
+
"beatrina-editor-theme", # lib/editor-themes.js one of 37
|
|
1336
|
+
"beatrina-chunk-style", # lib/chunk-style.js the pre-7.8 key, kept in step
|
|
1337
|
+
"beatrina-chunk-style-v2", # lib/chunk-style.js the explicit choice
|
|
1338
|
+
"beatrina-chunk-custom", # lib/chunk-style.js the designer's token overrides
|
|
1339
|
+
"beatrina-writing-chunk", # lib/writing-chunk.js the Writing surface's own card
|
|
1340
|
+
"beatrina-run-style", # lib/run-style.js
|
|
1341
|
+
"beatrina-table-style-v2", # lib/table-render.js
|
|
1342
|
+
"beatrina-line-numbering", # lib/line-numbering.js
|
|
1343
|
+
"beatrina-density", # lib/density.js
|
|
1344
|
+
"beatrina-add-strip-v3", # lib/add-strip.js
|
|
1345
|
+
"beatrina-soft-wrap", # lib/soft-wrap.js
|
|
1346
|
+
"beatrina-content-width-v1", # src/notebook-entry.js Standard / Wide / Full
|
|
1347
|
+
"beatrina-brand-v1", # lib/brand.js the organisation's accent
|
|
1348
|
+
"beatrina:rich-text", # lib/rtf-editor.js
|
|
1349
|
+
"beatrina:latex-template" # lib/latex-templates.js
|
|
1350
1350
|
)
|
|
1351
1351
|
|
|
1352
1352
|
prefs_path <- function() {
|
|
1353
|
-
dir <- tools::R_user_dir("
|
|
1353
|
+
dir <- tools::R_user_dir("beatrina", "data")
|
|
1354
1354
|
dir.create(dir, recursive = TRUE, showWarnings = FALSE)
|
|
1355
1355
|
file.path(dir, "appearance.json")
|
|
1356
1356
|
}
|
|
@@ -1386,7 +1386,7 @@ prefs_write <- function(values) {
|
|
|
1386
1386
|
clean <- prefs_clean(values)
|
|
1387
1387
|
path <- prefs_path()
|
|
1388
1388
|
# Create, then lock, then write — the order the key and the history beside
|
|
1389
|
-
# it use. A theme is not a secret; the point is that everything
|
|
1389
|
+
# it use. A theme is not a secret; the point is that everything Beatrina puts
|
|
1390
1390
|
# in the user's data directory is the user's, and judging file by file which
|
|
1391
1391
|
# ones are harmless is how one of them eventually gets it wrong.
|
|
1392
1392
|
if (!file.exists(path) && !file.create(path)) return(FALSE)
|
|
@@ -1439,7 +1439,7 @@ FORWARDED <- c("env", "obj", "struct", "view", "colstats", "rm", "packages", "do
|
|
|
1439
1439
|
# or reaches a process that cannot do it. Adding to EITHER list is a security
|
|
1440
1440
|
# decision (CLAUDE.md, "FORWARDED in serve.R is an allow-list").
|
|
1441
1441
|
# The third allow-list. These are answered by THIS process (fileio.R) and never
|
|
1442
|
-
# reach the worker's queue, unless
|
|
1442
|
+
# reach the worker's queue, unless BEATRINA_FILE_OPS=worker asks for the old
|
|
1443
1443
|
# routing. Kept apart from FORWARDED for the reason the first two are apart:
|
|
1444
1444
|
# "may the browser ask for this?" and "which process answers?" are different
|
|
1445
1445
|
# questions, and a file op's answer is "the one that is never busy".
|
|
@@ -1463,7 +1463,7 @@ SUPERVISOR_JOURNAL_OPS <- c("journal_open", "journal_append", "journal_checkpoin
|
|
|
1463
1463
|
# a conversation holds the questions you asked about your data plus the chunk
|
|
1464
1464
|
# sources those questions carried.
|
|
1465
1465
|
SUPERVISOR_AI_OPS <- c("ai_list", "ai_read", "ai_write", "ai_delete", "ai_purge", "ai_keys")
|
|
1466
|
-
FILE_OPS_IN_WORKER <- identical(Sys.getenv("
|
|
1466
|
+
FILE_OPS_IN_WORKER <- identical(Sys.getenv("BEATRINA_FILE_OPS", ""), "worker")
|
|
1467
1467
|
# The worker's working directory, as it last reported it (ready, wd and done
|
|
1468
1468
|
# frames). setwd() in a chunk moves it; a relative path from the page is
|
|
1469
1469
|
# resolved against THIS, never against the supervisor's own cwd.
|
|
@@ -1573,8 +1573,8 @@ app <- list(
|
|
|
1573
1573
|
# unauthenticated and CORS-open on purpose.
|
|
1574
1574
|
r = paste(R.version$major, R.version$minor, sep = "."),
|
|
1575
1575
|
started = as.numeric(sockets$boot_at),
|
|
1576
|
-
protocol =
|
|
1577
|
-
kernel_build =
|
|
1576
|
+
protocol = BEATRINA_PROTOCOL_VERSION,
|
|
1577
|
+
kernel_build = BEATRINA_KERNEL_BUILD,
|
|
1578
1578
|
# What is installed beside this kernel NOW, and
|
|
1579
1579
|
# how many pages hold it — the two facts an
|
|
1580
1580
|
# upgrade needs from a running session.
|
|
@@ -1591,7 +1591,7 @@ app <- list(
|
|
|
1591
1591
|
auto_unbox = TRUE),
|
|
1592
1592
|
extra = list("Access-Control-Allow-Origin" = "*")))
|
|
1593
1593
|
}
|
|
1594
|
-
# The custom
|
|
1594
|
+
# The custom beatrina:// handler calls this endpoint as a native same-user
|
|
1595
1595
|
# client. Browser requests carry Origin/Sec-Fetch-Site and are refused by
|
|
1596
1596
|
# control_rejection(), so visiting a site cannot silently grant itself R
|
|
1597
1597
|
# execution. One launch authorizes one exact origin for this kernel only.
|
|
@@ -1618,7 +1618,7 @@ app <- list(
|
|
|
1618
1618
|
nonce <- query_param(req$QUERY_STRING, "nonce")
|
|
1619
1619
|
# FILE_ORIGIN is accepted HERE and only here. `published_origin_valid()`
|
|
1620
1620
|
# stays strict (`^https?://…`) because it also guards
|
|
1621
|
-
# /published/authorize and the
|
|
1621
|
+
# /published/authorize and the BEATRINA_PUBLISHED_ORIGIN seed — widening it
|
|
1622
1622
|
# would let an environment variable or a native launch silently
|
|
1623
1623
|
# pre-approve every file on disk, which is the opposite of the point.
|
|
1624
1624
|
if (!identical(origin, FILE_ORIGIN) && !published_origin_valid(origin)) {
|
|
@@ -1637,7 +1637,7 @@ app <- list(
|
|
|
1637
1637
|
page <- pairing_approval_page(query_param(req$QUERY_STRING, "challenge"))
|
|
1638
1638
|
if (is.null(page)) {
|
|
1639
1639
|
audit("pair-rejected", detail = "missing or expired challenge")
|
|
1640
|
-
return(resp(400L, "text/plain", "This
|
|
1640
|
+
return(resp(400L, "text/plain", "This Beatrina approval request has expired."))
|
|
1641
1641
|
}
|
|
1642
1642
|
return(resp(200L, "text/html", page))
|
|
1643
1643
|
}
|
|
@@ -1648,12 +1648,12 @@ app <- list(
|
|
|
1648
1648
|
blocked <- control_rejection(req, allow_file = TRUE)
|
|
1649
1649
|
if (!is.null(blocked)) return(blocked)
|
|
1650
1650
|
audit("shutdown")
|
|
1651
|
-
message("
|
|
1651
|
+
message("Beatrina stopping: explicit Quit request.")
|
|
1652
1652
|
sockets$quit <- TRUE
|
|
1653
1653
|
return(resp(200L, "application/json",
|
|
1654
1654
|
toJSON(list(ok = TRUE, stopping = TRUE), auto_unbox = TRUE)))
|
|
1655
1655
|
}
|
|
1656
|
-
# The native door of "Restart into the installed
|
|
1656
|
+
# The native door of "Restart into the installed Beatrina" — the menu
|
|
1657
1657
|
# helper's row and the upgrade script use it (launch.sh --restart). Gated
|
|
1658
1658
|
# exactly like /shutdown; the page's own door is the session_upgrade op.
|
|
1659
1659
|
if (identical(req$PATH_INFO, "/session/upgrade")) {
|
|
@@ -1701,7 +1701,7 @@ app <- list(
|
|
|
1701
1701
|
# document reaches a kernel that is ALREADY RUNNING, and it is the reason
|
|
1702
1702
|
# opening a file no longer costs an R session.
|
|
1703
1703
|
#
|
|
1704
|
-
# The other channel —
|
|
1704
|
+
# The other channel — BEATRINA_OPEN_FILE in the supervisor's environment —
|
|
1705
1705
|
# is read once at startup, so the only way to deliver a document through
|
|
1706
1706
|
# it is to start a new process. That is precisely what the launcher used
|
|
1707
1707
|
# to do: every double-click was a fresh supervisor, worker and analyzer,
|
|
@@ -1800,7 +1800,7 @@ app <- list(
|
|
|
1800
1800
|
if (identical(method, "OPTIONS")) {
|
|
1801
1801
|
return(resp(204L, "text/plain", "", extra = c(cors, list(
|
|
1802
1802
|
"Access-Control-Allow-Methods" = "GET, PUT, OPTIONS",
|
|
1803
|
-
"Access-Control-Allow-Headers" = "X-
|
|
1803
|
+
"Access-Control-Allow-Headers" = "X-Beatrina-Cap, Content-Type, X-Beatrina-Expected-Bytes",
|
|
1804
1804
|
"Access-Control-Max-Age" = "60"))))
|
|
1805
1805
|
}
|
|
1806
1806
|
if (identical(method, "GET")) {
|
|
@@ -1818,7 +1818,7 @@ app <- list(
|
|
|
1818
1818
|
"Content-Length" = as.character(length(bytes))), cors)))
|
|
1819
1819
|
}
|
|
1820
1820
|
if (identical(method, "PUT")) {
|
|
1821
|
-
grant <- file_cap_take(req$
|
|
1821
|
+
grant <- file_cap_take(req$HTTP_X_BEATRINA_CAP %||% "", "write")
|
|
1822
1822
|
if (is.null(grant) || !within_root(grant$path)) {
|
|
1823
1823
|
audit("files-refused", detail = "put")
|
|
1824
1824
|
return(resp(403L, "text/plain", "no capability for that write", extra = cors))
|
|
@@ -1836,7 +1836,7 @@ app <- list(
|
|
|
1836
1836
|
}
|
|
1837
1837
|
expected <- NULL
|
|
1838
1838
|
if (identical(grant$compare, "utf8-prefix-v1")) {
|
|
1839
|
-
prefix <- req$
|
|
1839
|
+
prefix <- req$HTTP_X_BEATRINA_EXPECTED_BYTES %||% ""
|
|
1840
1840
|
n <- suppressWarnings(as.numeric(prefix))
|
|
1841
1841
|
if (!grepl("^[0-9]+$", prefix) || !is.finite(n) || n > length(body)) {
|
|
1842
1842
|
return(resp(400L, "text/plain", "missing or invalid expected-content prefix", extra = cors))
|
|
@@ -1873,7 +1873,7 @@ app <- list(
|
|
|
1873
1873
|
# the Rook environment belongs to the request that opened this socket and
|
|
1874
1874
|
# cannot change under it.
|
|
1875
1875
|
rec$class <- socket_class(ws$request)
|
|
1876
|
-
# Who the reverse proxy says this is. "" on loopback, always —
|
|
1876
|
+
# Who the reverse proxy says this is. "" on loopback, always — Beatrina has
|
|
1877
1877
|
# no login of its own and must not grow one here: a header is an identity
|
|
1878
1878
|
# only for as long as nobody but the proxy can reach the port, which is a
|
|
1879
1879
|
# property of the deployment, not of this process.
|
|
@@ -1991,17 +1991,17 @@ target_page <- function() {
|
|
|
1991
1991
|
# the CLI's stream-json lines back to the asking page verbatim, and that is
|
|
1992
1992
|
# the whole exchange. Credentials are never read, stored, or proxied.
|
|
1993
1993
|
#
|
|
1994
|
-
# The spawned session gets the
|
|
1994
|
+
# The spawned session gets the beatrina MCP tools, pinned to THIS kernel via a
|
|
1995
1995
|
# 0600 config file (never argv — argv is visible to every local user in ps),
|
|
1996
1996
|
# and the document tools. The embedded Claude process is a document agent, not
|
|
1997
1997
|
# a chatbot that prints a draft for the user to move by hand. Inserts and
|
|
1998
1998
|
# replacements cross the page's revision/contract gateway and remain
|
|
1999
1999
|
# provisional with Keep/Reject; runs use the ordinary visible chunk path.
|
|
2000
2000
|
AGENT_CHAT_TOOLS <- paste(
|
|
2001
|
-
"
|
|
2002
|
-
"
|
|
2003
|
-
"
|
|
2004
|
-
"
|
|
2001
|
+
"mcp__beatrina__beatrina_status", "mcp__beatrina__notebook_read",
|
|
2002
|
+
"mcp__beatrina__chunk_read", "mcp__beatrina__chunk_insert",
|
|
2003
|
+
"mcp__beatrina__chunk_update", "mcp__beatrina__chunk_run",
|
|
2004
|
+
"mcp__beatrina__file_list", "mcp__beatrina__file_read",
|
|
2005
2005
|
sep = ",")
|
|
2006
2006
|
MAX_CHATS <- 3L
|
|
2007
2007
|
|
|
@@ -2056,7 +2056,7 @@ chat_start <- function(rec, cmd) {
|
|
|
2056
2056
|
server <- mcp_server_path()
|
|
2057
2057
|
node <- find_binary("node")
|
|
2058
2058
|
if (identical(client, "codex")) {
|
|
2059
|
-
cfg <- tempfile("
|
|
2059
|
+
cfg <- tempfile("beatrina-codex-")
|
|
2060
2060
|
dir.create(cfg, mode = "0700")
|
|
2061
2061
|
toml_str <- function(x) as.character(toJSON(x, auto_unbox = TRUE))
|
|
2062
2062
|
args <- c("exec", "--json", "--ephemeral", "--skip-git-repo-check",
|
|
@@ -2071,9 +2071,9 @@ chat_start <- function(rec, cmd) {
|
|
|
2071
2071
|
}
|
|
2072
2072
|
if (!text_only && nzchar(server) && nzchar(node)) {
|
|
2073
2073
|
args <- c(args,
|
|
2074
|
-
"-c", paste0("mcp_servers.
|
|
2075
|
-
"-c", paste0("mcp_servers.
|
|
2076
|
-
"-c", paste0("mcp_servers.
|
|
2074
|
+
"-c", paste0("mcp_servers.beatrina.command=", toml_str(node)),
|
|
2075
|
+
"-c", paste0("mcp_servers.beatrina.args=[", toml_str(server), "]"),
|
|
2076
|
+
"-c", paste0("mcp_servers.beatrina.env={BEATRINA_MCP_URL=", toml_str(paste0(origin_ok, "/")), "}"))
|
|
2077
2077
|
}
|
|
2078
2078
|
args <- c(args, "-") # the prompt arrives on stdin
|
|
2079
2079
|
} else {
|
|
@@ -2089,10 +2089,10 @@ chat_start <- function(rec, cmd) {
|
|
|
2089
2089
|
args <- c(args, "--tools", "", "--mcp-config", '{"mcpServers":{}}',
|
|
2090
2090
|
"--strict-mcp-config", "--setting-sources", "")
|
|
2091
2091
|
} else if (nzchar(server) && nzchar(node)) {
|
|
2092
|
-
cfg <- tempfile("
|
|
2093
|
-
writeLines(toJSON(list(mcpServers = list(
|
|
2092
|
+
cfg <- tempfile("beatrina-chat-", fileext = ".json")
|
|
2093
|
+
writeLines(toJSON(list(mcpServers = list(beatrina = list(
|
|
2094
2094
|
command = node, args = I(server),
|
|
2095
|
-
env = list(
|
|
2095
|
+
env = list(BEATRINA_MCP_URL = paste0(origin_ok, "/"))
|
|
2096
2096
|
))), auto_unbox = TRUE), cfg)
|
|
2097
2097
|
Sys.chmod(cfg, mode = "0600")
|
|
2098
2098
|
args <- c(args, "--mcp-config", cfg, "--strict-mcp-config",
|
|
@@ -2177,7 +2177,7 @@ pump_chats <- function() {
|
|
|
2177
2177
|
#' Every kernel this user is running, from the runtime records, health-checked.
|
|
2178
2178
|
#' @return An unnamed list of `list(port, url, title, pid, alive, this)`.
|
|
2179
2179
|
session_list <- function() {
|
|
2180
|
-
dir <- Sys.getenv("
|
|
2180
|
+
dir <- Sys.getenv("BEATRINA_RUNTIME_DIR", file.path(path.expand("~"), ".beatrina", "run"))
|
|
2181
2181
|
files <- list.files(dir, pattern = "^kernel-[0-9]+\\.json$", full.names = TRUE)
|
|
2182
2182
|
out <- lapply(files, function(f) {
|
|
2183
2183
|
rec <- tryCatch(jsonlite::fromJSON(f, simplifyVector = TRUE), error = function(e) NULL)
|
|
@@ -2256,7 +2256,7 @@ r_versions <- function() {
|
|
|
2256
2256
|
# the MCP plane refuses, wearing a prompt.
|
|
2257
2257
|
#
|
|
2258
2258
|
# What the shell can do is what the user can do; this grants nothing new and
|
|
2259
|
-
# is audited like every other door. `
|
|
2259
|
+
# is audited like every other door. `BEATRINA_NO_TERMINAL=1` removes it (a
|
|
2260
2260
|
# deployment that must not offer a shell); Windows has no pty here and says so.
|
|
2261
2261
|
MAX_TERMS <- 4L
|
|
2262
2262
|
TERM_INPUT_MAX <- 65536L
|
|
@@ -2283,11 +2283,11 @@ term_open <- function(rec, cmd) {
|
|
|
2283
2283
|
term_send(rec, "", event = "refused", error = why)
|
|
2284
2284
|
invisible(NULL)
|
|
2285
2285
|
}
|
|
2286
|
-
if (identical(Sys.getenv("
|
|
2287
|
-
return(refuse("The terminal is turned off on this kernel (
|
|
2286
|
+
if (identical(Sys.getenv("BEATRINA_NO_TERMINAL"), "1")) {
|
|
2287
|
+
return(refuse("The terminal is turned off on this kernel (BEATRINA_NO_TERMINAL=1)."))
|
|
2288
2288
|
}
|
|
2289
2289
|
if (.Platform$OS.type == "windows") {
|
|
2290
|
-
return(refuse("The terminal needs a pty, which
|
|
2290
|
+
return(refuse("The terminal needs a pty, which Beatrina does not provide on Windows yet."))
|
|
2291
2291
|
}
|
|
2292
2292
|
mine <- Filter(function(t) identical(t$rec, rec), mget(ls(sockets$terms), envir = sockets$terms))
|
|
2293
2293
|
if (length(mine) >= MAX_TERMS) {
|
|
@@ -2308,7 +2308,7 @@ term_open <- function(rec, cmd) {
|
|
|
2308
2308
|
env = c("current", TERM = "xterm-256color",
|
|
2309
2309
|
COLORTERM = "truecolor",
|
|
2310
2310
|
COLUMNS = as.character(cols), LINES = as.character(rows),
|
|
2311
|
-
|
|
2311
|
+
BEATRINA_PORT = as.character(port))),
|
|
2312
2312
|
error = function(e) conditionMessage(e))
|
|
2313
2313
|
if (is.character(proc)) return(refuse(paste("the shell could not start:", proc)))
|
|
2314
2314
|
sockets$term_seq <- sockets$term_seq + 1L
|
|
@@ -2401,7 +2401,7 @@ JOB_LOG_CHARS <- 1000000L
|
|
|
2401
2401
|
# handed the token, and short enough that a token in a stray log is dead by
|
|
2402
2402
|
# the time anyone reads it. Tests shorten it.
|
|
2403
2403
|
OUTPUT_CAP_TTL <- local({
|
|
2404
|
-
given <- suppressWarnings(as.numeric(Sys.getenv("
|
|
2404
|
+
given <- suppressWarnings(as.numeric(Sys.getenv("BEATRINA_OUTPUT_CAP_TTL", "")))
|
|
2405
2405
|
if (is.finite(given) && given >= 1 && given <= 3600) given else 60
|
|
2406
2406
|
})
|
|
2407
2407
|
OUTPUT_CAP_MAX_BYTES <- 128 * 1024 * 1024
|
|
@@ -2439,7 +2439,7 @@ output_cap_sweep <- function() {
|
|
|
2439
2439
|
# anything /health discloses; for PUT it travels in a header, never the query.
|
|
2440
2440
|
FILE_CAP_TTL <- OUTPUT_CAP_TTL
|
|
2441
2441
|
FILE_UPLOAD_MAX <- local({
|
|
2442
|
-
given <- suppressWarnings(as.numeric(Sys.getenv("
|
|
2442
|
+
given <- suppressWarnings(as.numeric(Sys.getenv("BEATRINA_UPLOAD_MAX", "")))
|
|
2443
2443
|
if (is.finite(given) && given >= 1e6 && given <= 2e9) given else 64 * 1024 * 1024
|
|
2444
2444
|
})
|
|
2445
2445
|
sockets$file_caps <- list()
|
|
@@ -2576,7 +2576,7 @@ job_start <- function(rec, cmd) {
|
|
|
2576
2576
|
# settings under both, so a quarto_path set in Settings reaches the render
|
|
2577
2577
|
# while a job that names its own still wins.
|
|
2578
2578
|
kernel_start(file.path(here, "job-run.R"), rscript = settings_rscript(),
|
|
2579
|
-
env_extra = c(settings_child_env(), spec$env,
|
|
2579
|
+
env_extra = c(settings_child_env(), spec$env, BEATRINA_JOB_ID = id)),
|
|
2580
2580
|
error = function(e) conditionMessage(e))
|
|
2581
2581
|
if (is.character(started)) {
|
|
2582
2582
|
audit("job-start-failed", detail = started)
|
|
@@ -2590,7 +2590,7 @@ job_start <- function(rec, cmd) {
|
|
|
2590
2590
|
job$task <- spec$task
|
|
2591
2591
|
job$root <- spec$root
|
|
2592
2592
|
job$target <- spec$target
|
|
2593
|
-
identity_text <- spec$env["
|
|
2593
|
+
identity_text <- spec$env["BEATRINA_LATEX_IDENTITY"]
|
|
2594
2594
|
job$identity <- if (!is.na(identity_text) && nzchar(identity_text)) fromJSON(identity_text, simplifyVector = FALSE) else NULL
|
|
2595
2595
|
job$output <- NA_character_
|
|
2596
2596
|
job$title <- job_title(spec)
|
|
@@ -2764,7 +2764,7 @@ update_runner_spec <- function(script, args = character(),
|
|
|
2764
2764
|
}
|
|
2765
2765
|
|
|
2766
2766
|
update_script_path <- function() {
|
|
2767
|
-
script <- trimws(Sys.getenv("
|
|
2767
|
+
script <- trimws(Sys.getenv("BEATRINA_UPDATE_SCRIPT", ""))
|
|
2768
2768
|
if (!isTRUE(deployment$loopback) || !nzchar(script) || !file.exists(script)) return("")
|
|
2769
2769
|
script <- normalizePath(script, winslash = "/", mustWork = TRUE)
|
|
2770
2770
|
if (is.null(update_runner_spec(script))) return("")
|
|
@@ -2807,7 +2807,7 @@ update_job_busy <- function() {
|
|
|
2807
2807
|
update_status_state <- function() {
|
|
2808
2808
|
script <- update_script_path()
|
|
2809
2809
|
if (!nzchar(script)) return(list(
|
|
2810
|
-
supported = FALSE, state = "unavailable", current =
|
|
2810
|
+
supported = FALSE, state = "unavailable", current = BEATRINA_KERNEL_BUILD,
|
|
2811
2811
|
offered = "", previous = "", busy = FALSE, action = "",
|
|
2812
2812
|
can_check = FALSE, can_update = FALSE, can_defer = FALSE,
|
|
2813
2813
|
can_rollback = FALSE,
|
|
@@ -2816,7 +2816,7 @@ update_status_state <- function() {
|
|
|
2816
2816
|
|
|
2817
2817
|
busy <- update_job_busy()
|
|
2818
2818
|
if (busy) return(list(
|
|
2819
|
-
supported = TRUE, state = "checking", current =
|
|
2819
|
+
supported = TRUE, state = "checking", current = BEATRINA_KERNEL_BUILD,
|
|
2820
2820
|
offered = "", previous = "", busy = TRUE, action = update_job$action,
|
|
2821
2821
|
can_check = FALSE, can_update = FALSE, can_defer = FALSE,
|
|
2822
2822
|
can_rollback = FALSE,
|
|
@@ -3099,7 +3099,7 @@ worker_deadline_check <- function() {
|
|
|
3099
3099
|
sockets$worker_routes[[wire_id]] else NULL
|
|
3100
3100
|
reason <- sprintf(
|
|
3101
3101
|
paste("R did not answer `%s` within %d seconds. The session and its variables are",
|
|
3102
|
-
"intact; only this request was abandoned. If it keeps happening, this
|
|
3102
|
+
"intact; only this request was abandoned. If it keeps happening, this Beatrina",
|
|
3103
3103
|
"may be newer than the R session it is talking to — restart R."),
|
|
3104
3104
|
type, as.integer(round(limit)))
|
|
3105
3105
|
audit("worker-deadline", detail = type, id = wire_id,
|
|
@@ -3119,7 +3119,7 @@ worker_deadline_check <- function() {
|
|
|
3119
3119
|
# ── restart into the installed build ───────────────────────────────────────
|
|
3120
3120
|
#
|
|
3121
3121
|
# An upgrade swaps the bundle IN PLACE, so the kernel-version stamp beside
|
|
3122
|
-
# this script names the installed build while
|
|
3122
|
+
# this script names the installed build while BEATRINA_KERNEL_BUILD names the
|
|
3123
3123
|
# one running. The two disagreeing is the one honest signal that a session
|
|
3124
3124
|
# is stale, and this is the one door through it: start a SUCCESSOR
|
|
3125
3125
|
# supervisor from the installed files on this same port, tell every page
|
|
@@ -3155,13 +3155,13 @@ handoff_refusal <- function(mode = "upgrade", force = FALSE) {
|
|
|
3155
3155
|
refuse <- function(error, reason) list(error = error, reason = reason)
|
|
3156
3156
|
installed <- installed_build()
|
|
3157
3157
|
if (identical(installed, "unknown")) {
|
|
3158
|
-
return(refuse("This kernel cannot tell which
|
|
3158
|
+
return(refuse("This kernel cannot tell which Beatrina is installed.", "unknown-install"))
|
|
3159
3159
|
}
|
|
3160
|
-
if (identical(mode, "upgrade") && identical(installed,
|
|
3161
|
-
return(refuse(sprintf("This session already runs the installed
|
|
3160
|
+
if (identical(mode, "upgrade") && identical(installed, BEATRINA_KERNEL_BUILD)) {
|
|
3161
|
+
return(refuse(sprintf("This session already runs the installed Beatrina %s.", installed), "same-build"))
|
|
3162
3162
|
}
|
|
3163
|
-
if (!nzchar(
|
|
3164
|
-
return(refuse(sprintf("The installed
|
|
3163
|
+
if (!nzchar(beatrina_notebook_page(here, installed))) {
|
|
3164
|
+
return(refuse(sprintf("The installed Beatrina %s has no notebook page beside this kernel.", installed), "no-page"))
|
|
3165
3165
|
}
|
|
3166
3166
|
# Force never spawns a second successor for one port: an attempt that has
|
|
3167
3167
|
# spawned owns the port until this process exits.
|
|
@@ -3180,7 +3180,7 @@ handoff_refusal <- function(mode = "upgrade", force = FALSE) {
|
|
|
3180
3180
|
#' kernel, with this port and a capability minted here so it can be told
|
|
3181
3181
|
#' to the page NOW and honoured by the successor later.
|
|
3182
3182
|
handoff_page_url <- function(installed, cap) {
|
|
3183
|
-
page_file <- normalizePath(
|
|
3183
|
+
page_file <- normalizePath(beatrina_notebook_page(here, installed))
|
|
3184
3184
|
paste0("file://", utils::URLencode(page_file, reserved = FALSE),
|
|
3185
3185
|
"#kernel=", port, "&pair=", cap)
|
|
3186
3186
|
}
|
|
@@ -3212,18 +3212,18 @@ session_handoff_begin <- function(mode = "upgrade", force = FALSE, resume = NULL
|
|
|
3212
3212
|
sockets$handoff <- list(to = installed, cap = cap, page = page,
|
|
3213
3213
|
started = Sys.time(), mode = mode, force = force,
|
|
3214
3214
|
resume = if (keep_token_ok(resume)) resume else NULL)
|
|
3215
|
-
audit(audit_name, from =
|
|
3215
|
+
audit(audit_name, from = BEATRINA_KERNEL_BUILD, to = installed, force = force)
|
|
3216
3216
|
# Every page learns where its successor's notebook is — over the gated
|
|
3217
3217
|
# socket only; the capability never appears in /health. `started` is this
|
|
3218
3218
|
# kernel's boot time: the page recognises its successor by a LATER one,
|
|
3219
3219
|
# which is the only test that also works when the build does not change.
|
|
3220
|
-
told <- toJSON(list(type = "session-upgrade", from =
|
|
3220
|
+
told <- toJSON(list(type = "session-upgrade", from = BEATRINA_KERNEL_BUILD,
|
|
3221
3221
|
to = installed, page = page, started = as.numeric(sockets$boot_at),
|
|
3222
3222
|
mode = mode, force = force,
|
|
3223
3223
|
keep = !is.null(sockets$handoff$resume)), auto_unbox = TRUE)
|
|
3224
3224
|
lapply(page_recs(), function(r) try(r$ws$send(told), silent = TRUE))
|
|
3225
3225
|
session_handoff_spawn()
|
|
3226
|
-
list(ok = TRUE, from =
|
|
3226
|
+
list(ok = TRUE, from = BEATRINA_KERNEL_BUILD, to = installed, page = page,
|
|
3227
3227
|
started = as.numeric(sockets$boot_at), mode = mode, force = force)
|
|
3228
3228
|
}
|
|
3229
3229
|
|
|
@@ -3247,18 +3247,18 @@ session_handoff_spawn <- function() {
|
|
|
3247
3247
|
state <- state_dir()
|
|
3248
3248
|
dir.create(state, recursive = TRUE, showWarnings = FALSE)
|
|
3249
3249
|
env <- Sys.getenv()
|
|
3250
|
-
env[["
|
|
3251
|
-
env[["
|
|
3250
|
+
env[["BEATRINA_PORT"]] <- as.character(port)
|
|
3251
|
+
env[["BEATRINA_WAIT_PORT"]] <- "30"
|
|
3252
3252
|
# A successor that quietly moved to a free port would leave the page
|
|
3253
3253
|
# polling a dead one for 90 s; strict means it refuses and its log says why.
|
|
3254
|
-
env[["
|
|
3255
|
-
env[["
|
|
3256
|
-
env[["
|
|
3257
|
-
env[["
|
|
3258
|
-
env[["
|
|
3254
|
+
env[["BEATRINA_PORT_STRICT"]] <- "1"
|
|
3255
|
+
env[["BEATRINA_FILE_LAUNCH_CAP"]] <- h$cap
|
|
3256
|
+
env[["BEATRINA_HANDOFF_FROM"]] <- BEATRINA_KERNEL_BUILD
|
|
3257
|
+
env[["BEATRINA_SESSION_TITLE"]] <- runtime_record$title %||% ""
|
|
3258
|
+
env[["BEATRINA_SESSION_DOCUMENTS"]] <- session_documents_encode(sockets$session_documents)
|
|
3259
3259
|
# Only a token travels, never a path; the successor re-checks it.
|
|
3260
|
-
env[["
|
|
3261
|
-
if (isTRUE(runtime_record$listen)) env[["
|
|
3260
|
+
env[["BEATRINA_RESUME_TOKEN"]] <- h$resume %||% ""
|
|
3261
|
+
if (isTRUE(runtime_record$listen)) env[["BEATRINA_LISTEN"]] <- "1"
|
|
3262
3262
|
# The same R this supervisor runs on, and the serve.R beside THIS file —
|
|
3263
3263
|
# which, after the in-place swap, is the installed build's.
|
|
3264
3264
|
rscript <- file.path(R.home("bin"), "Rscript")
|
|
@@ -3267,7 +3267,7 @@ session_handoff_spawn <- function() {
|
|
|
3267
3267
|
rscript, file.path(here, "serve.R"), env = env,
|
|
3268
3268
|
stdout = log, stderr = "2>&1", cleanup = FALSE), error = function(e) NULL)
|
|
3269
3269
|
if (is.null(started) || !started$is_alive()) {
|
|
3270
|
-
return(handoff_abandon("The installed
|
|
3270
|
+
return(handoff_abandon("The installed Beatrina could not be started."))
|
|
3271
3271
|
}
|
|
3272
3272
|
audit("session-upgrade-spawned", to = h$to, pid = started$get_pid(), mode = h$mode %||% "upgrade")
|
|
3273
3273
|
# Spawn first, kill second: the old worker's whole tree goes down only once
|
|
@@ -3563,8 +3563,8 @@ recover_execution_worker <- function() {
|
|
|
3563
3563
|
sockets$worker_restart_after <- 0
|
|
3564
3564
|
sockets$hello <- NULL
|
|
3565
3565
|
fail_worker_routes(if (isTRUE(sockets$force_stop_asked))
|
|
3566
|
-
"R was force-stopped. Its variables and loaded packages are gone;
|
|
3567
|
-
else "The R worker stopped unexpectedly. Its session state was lost;
|
|
3566
|
+
"R was force-stopped. Its variables and loaded packages are gone; Beatrina is starting a fresh R session."
|
|
3567
|
+
else "The R worker stopped unexpectedly. Its session state was lost; Beatrina is starting a fresh R session.")
|
|
3568
3568
|
rm(list = ls(sockets$running), envir = sockets$running)
|
|
3569
3569
|
rm(list = ls(sockets$worker_routes), envir = sockets$worker_routes)
|
|
3570
3570
|
sockets$worker_queue <- list()
|
|
@@ -3591,7 +3591,7 @@ recover_execution_worker <- function() {
|
|
|
3591
3591
|
sockets$worker_gave_up <- TRUE
|
|
3592
3592
|
sockets$worker_recovering <- FALSE
|
|
3593
3593
|
reason <- sprintf(
|
|
3594
|
-
paste("R would not start after %d attempts.
|
|
3594
|
+
paste("R would not start after %d attempts. Beatrina is still running — the notebook,",
|
|
3595
3595
|
"your text and Save all work — but nothing can be run until R is fixed.",
|
|
3596
3596
|
"A broken .Rprofile or a moved R installation is the usual cause; the kernel",
|
|
3597
3597
|
"log beside this session has R's own message. Restart R to try again."),
|
|
@@ -3759,7 +3759,7 @@ handle_frame <- function(message, rec) {
|
|
|
3759
3759
|
}
|
|
3760
3760
|
|
|
3761
3761
|
# ── the MCP plane ─────────────────────────────────────────────────────────
|
|
3762
|
-
# A local agent (Claude Code / Codex via tools/mcp/
|
|
3762
|
+
# A local agent (Claude Code / Codex via tools/mcp/beatrina-mcp.mjs) connects
|
|
3763
3763
|
# through the same loopback/Origin/Host gates as a page, then DECLARES itself.
|
|
3764
3764
|
# From then on it may only ASK (mcp-request, routed to the active notebook
|
|
3765
3765
|
# window) and use the read-side FORWARDED ops; pages may only ANSWER.
|
|
@@ -3782,7 +3782,7 @@ handle_frame <- function(message, rec) {
|
|
|
3782
3782
|
return(invisible(NULL))
|
|
3783
3783
|
}
|
|
3784
3784
|
if (identical(cmd$type, "open-request")) {
|
|
3785
|
-
# The double-clicked document parked by the launcher (
|
|
3785
|
+
# The double-clicked document parked by the launcher (BEATRINA_OPEN_FILE).
|
|
3786
3786
|
# Pages only — an agent must never consume it — and consume-once: the
|
|
3787
3787
|
# first page to ask gets the path, everyone after gets "". An empty
|
|
3788
3788
|
# string, not a JSON null, because jsonlite renders NULL as a truthy {}.
|
|
@@ -3807,7 +3807,7 @@ handle_frame <- function(message, rec) {
|
|
|
3807
3807
|
audit("mcp-request", id = cmd$id, detail = cmd$tool)
|
|
3808
3808
|
if (is.null(page)) {
|
|
3809
3809
|
reply <- toJSON(list(type = "mcp-response", id = cmd$id, ok = FALSE,
|
|
3810
|
-
error = "No
|
|
3810
|
+
error = "No Beatrina notebook window is connected to this kernel. Open the notebook first."),
|
|
3811
3811
|
auto_unbox = TRUE)
|
|
3812
3812
|
try(rec$ws$send(reply), silent = TRUE)
|
|
3813
3813
|
return(invisible(NULL))
|
|
@@ -3866,7 +3866,7 @@ handle_frame <- function(message, rec) {
|
|
|
3866
3866
|
else reply(versions = I(r_versions()), current = current_rscript())
|
|
3867
3867
|
return(invisible(NULL))
|
|
3868
3868
|
}
|
|
3869
|
-
# "Restart into the installed
|
|
3869
|
+
# "Restart into the installed Beatrina": the page's door. Page-only in both
|
|
3870
3870
|
# senses (AGENT_REFUSED has it too) — a session handoff is the person's
|
|
3871
3871
|
# decision about their own session, never an agent's.
|
|
3872
3872
|
if (cmd$type %in% c("session_upgrade", "session_restart") && identical(rec$role, "page")) {
|
|
@@ -4201,18 +4201,18 @@ handle_frame <- function(message, rec) {
|
|
|
4201
4201
|
"term_close" = "Agents cannot close the user's terminal.",
|
|
4202
4202
|
"session_list" = "Agents cannot list the user's sessions.",
|
|
4203
4203
|
"r_versions" = "Agents cannot list or choose R installations.",
|
|
4204
|
-
"session_upgrade" = "Agents cannot restart the user's session into another
|
|
4204
|
+
"session_upgrade" = "Agents cannot restart the user's session into another Beatrina.",
|
|
4205
4205
|
"session_restart" = "Agents cannot restart the user's session.",
|
|
4206
4206
|
# settings_GET is refused as firmly as the writes. Not because the
|
|
4207
4207
|
# values are secret, but because handing back the confinement root,
|
|
4208
4208
|
# the audit-log path and whether AI-text logging is on is a MAP OF THE
|
|
4209
4209
|
# DEPLOYMENT'S CONTROLS, given to a process whose entire premise is
|
|
4210
4210
|
# that it is not the user.
|
|
4211
|
-
"settings_get" = "Agents cannot read
|
|
4212
|
-
"settings_set" = "Agents cannot change
|
|
4213
|
-
"settings_reset" = "Agents cannot reset
|
|
4211
|
+
"settings_get" = "Agents cannot read Beatrina's settings.",
|
|
4212
|
+
"settings_set" = "Agents cannot change Beatrina's settings.",
|
|
4213
|
+
"settings_reset" = "Agents cannot reset Beatrina's settings.",
|
|
4214
4214
|
"update_status" = "Agents cannot inspect desktop update state.",
|
|
4215
|
-
"update_action" = "Agents cannot install, defer, or roll back
|
|
4215
|
+
"update_action" = "Agents cannot install, defer, or roll back Beatrina.",
|
|
4216
4216
|
"project_action" = "Agents cannot install or restore project packages.",
|
|
4217
4217
|
"job_open" = "Agents cannot open files on the user's desktop.",
|
|
4218
4218
|
"Agents run code through notebook chunks (chunk_run), not raw exec.")
|
|
@@ -4382,7 +4382,7 @@ handle_frame <- function(message, rec) {
|
|
|
4382
4382
|
# a gate that silently drops the line. reply_err is this handler's own
|
|
4383
4383
|
# door back to the asking page and carries cmd$id, so the console can
|
|
4384
4384
|
# attribute the refusal to the line it just sent.
|
|
4385
|
-
reply_err("
|
|
4385
|
+
reply_err("Beatrina could not deliver the answer to R. The question is still open; try again.")
|
|
4386
4386
|
return(invisible(NULL))
|
|
4387
4387
|
}
|
|
4388
4388
|
sockets$input_waiting <- FALSE
|
|
@@ -4413,8 +4413,8 @@ handle_frame <- function(message, rec) {
|
|
|
4413
4413
|
action <- if (scalar_chr(cmd$action)) cmd$action else ""
|
|
4414
4414
|
line <- switch(action,
|
|
4415
4415
|
continue = "c", over = "n", into = "s", out = "f",
|
|
4416
|
-
where = ".
|
|
4417
|
-
abort = 'invokeRestart("
|
|
4416
|
+
where = ".beatrina_debug_where()",
|
|
4417
|
+
abort = 'invokeRestart("beatrina_abort_cell")',
|
|
4418
4418
|
eval = if (scalar_chr(cmd$expr)) cmd$expr else NULL)
|
|
4419
4419
|
if (is.null(line) || grepl("[\n\r]", line) || nchar(line) > 4000L) {
|
|
4420
4420
|
reply_err("bad debug command")
|
|
@@ -4427,7 +4427,7 @@ handle_frame <- function(message, rec) {
|
|
|
4427
4427
|
# end of the debuggable code the where line reaches the dispatch loop,
|
|
4428
4428
|
# which ignores what it cannot parse — harmless by construction.
|
|
4429
4429
|
if (action %in% c("over", "into", "out")) {
|
|
4430
|
-
try(kernel_console(k, ".
|
|
4430
|
+
try(kernel_console(k, ".beatrina_debug_where()"), silent = TRUE)
|
|
4431
4431
|
}
|
|
4432
4432
|
return(invisible(NULL))
|
|
4433
4433
|
}
|
|
@@ -4507,12 +4507,12 @@ handle_frame <- function(message, rec) {
|
|
|
4507
4507
|
return(invisible(NULL))
|
|
4508
4508
|
}
|
|
4509
4509
|
# ONE OF THE TWO DOORS THE SUPERVISOR ACTUALLY OWNS. A policy that permits
|
|
4510
|
-
# no key-based provider means
|
|
4510
|
+
# no key-based provider means Beatrina does not keep a provider key at all:
|
|
4511
4511
|
# not handed out, not stored. The user can still paste one into the tab —
|
|
4512
4512
|
# this is their own browser and no kernel can prevent that — but nothing
|
|
4513
4513
|
# persists it for them and the attempt is in the audit log. The refusal
|
|
4514
|
-
# covers `set` as much as `get`, because a key
|
|
4515
|
-
# local-only policy is a key
|
|
4514
|
+
# covers `set` as much as `get`, because a key Beatrina holds under a
|
|
4515
|
+
# local-only policy is a key Beatrina should never have accepted.
|
|
4516
4516
|
if (isTRUE(ai_policy$set) && !isTRUE(ai_policy$allows_key)) {
|
|
4517
4517
|
audit("ai-key-refused", reason = "policy", class = rec$class %||% "unknown")
|
|
4518
4518
|
reply(error = ai_policy_reason(ai_policy), policy = TRUE)
|
|
@@ -4742,7 +4742,7 @@ handle_frame <- function(message, rec) {
|
|
|
4742
4742
|
}
|
|
4743
4743
|
# csl_place: the style beside the document, as a .bib sits beside a
|
|
4744
4744
|
# .tex — what makes `csl: apa.csl` in the front matter render the same
|
|
4745
|
-
# style with no
|
|
4745
|
+
# style with no Beatrina present. A dependent style's parent goes too, or
|
|
4746
4746
|
# pandoc would go looking for it. Never overwrites: a different file of
|
|
4747
4747
|
# that name beside the document is the author's, and is reported.
|
|
4748
4748
|
style <- csl_resolve(here, plugin_policy, cmd$name)
|
|
@@ -4770,7 +4770,7 @@ handle_frame <- function(message, rec) {
|
|
|
4770
4770
|
# The AI conversation, into the audit stream. Fire-and-forget: there is no
|
|
4771
4771
|
# reply, because a turn must never wait on its own logging, and a page that
|
|
4772
4772
|
# could tell whether the write landed would be a probe for whether auditing
|
|
4773
|
-
# is on. `audit()` is already a no-op without
|
|
4773
|
+
# is on. `audit()` is already a no-op without BEATRINA_LOG, so an ordinary
|
|
4774
4774
|
# laptop pays nothing here beyond the parse it already did.
|
|
4775
4775
|
if (identical(cmd$type, "ai-audit")) {
|
|
4776
4776
|
event <- ai_audit_scalar(cmd$event, 32L)
|
|
@@ -4799,7 +4799,7 @@ handle_frame <- function(message, rec) {
|
|
|
4799
4799
|
# Reading the audit stream BACK, for the reviewer export.
|
|
4800
4800
|
#
|
|
4801
4801
|
# Page-only on the same grounds as the console history, and more so: with
|
|
4802
|
-
#
|
|
4802
|
+
# BEATRINA_LOG_AI_TEXT on, these lines contain the prompts the user typed. A
|
|
4803
4803
|
# declared agent is refused (AGENT_REFUSED); a published origin and a native
|
|
4804
4804
|
# client are refused by class.
|
|
4805
4805
|
#
|
|
@@ -4869,7 +4869,7 @@ handle_frame <- function(message, rec) {
|
|
|
4869
4869
|
|
|
4870
4870
|
# Appearance carried between the two origins the notebook is delivered from
|
|
4871
4871
|
# (see PREF_KEYS). Page-only on the console-history rule exactly: this is the
|
|
4872
|
-
# user's own machine state, kept for them because
|
|
4872
|
+
# user's own machine state, kept for them because Beatrina put it out of reach
|
|
4873
4873
|
# of the origin they opened it at. A declared agent has no appearance.
|
|
4874
4874
|
if (identical(cmd$type, "prefs")) {
|
|
4875
4875
|
if (!scalar_chr(cmd$id)) return(invisible(NULL))
|
|
@@ -4915,7 +4915,7 @@ handle_frame <- function(message, rec) {
|
|
|
4915
4915
|
# same reason as ai-key: the supervisor answers it itself.
|
|
4916
4916
|
#
|
|
4917
4917
|
# Page-only for the ai-key reason exactly: a native client could edit
|
|
4918
|
-
# ~/.config/R/
|
|
4918
|
+
# ~/.config/R/beatrina/settings.json itself as this user, but Beatrina does not
|
|
4919
4919
|
# do it for it, so the promise holds for every client rather than only the
|
|
4920
4920
|
# polite ones. A published origin is refused because its reader approved that
|
|
4921
4921
|
# site to run the chunks they press, not to reconfigure their kernel.
|
|
@@ -4927,7 +4927,7 @@ handle_frame <- function(message, rec) {
|
|
|
4927
4927
|
audit("settings-refused", reason = "class", detail = cmd$type,
|
|
4928
4928
|
class = rec$class %||% "unknown")
|
|
4929
4929
|
reply(ok = FALSE, reason = "class",
|
|
4930
|
-
error = "Only the notebook page may read or change
|
|
4930
|
+
error = "Only the notebook page may read or change Beatrina's settings.")
|
|
4931
4931
|
return(invisible(NULL))
|
|
4932
4932
|
}
|
|
4933
4933
|
# An unauthenticated shared kernel has no "the user" whose home directory
|
|
@@ -4974,7 +4974,7 @@ handle_frame <- function(message, rec) {
|
|
|
4974
4974
|
# because those are precisely the ones that can be honoured now, and
|
|
4975
4975
|
# reporting a new linger while running on the old one is the lie the
|
|
4976
4976
|
# whole `effect` field exists to prevent.
|
|
4977
|
-
settings_state <<-
|
|
4977
|
+
settings_state <<- beatrina_settings_resolve()
|
|
4978
4978
|
linger_s <<- resolve_linger()
|
|
4979
4979
|
HISTORY_ENABLED <<- isTRUE(settings_value("history_enabled"))
|
|
4980
4980
|
audit("settings", detail = "get", status = settings_state$file$status)
|
|
@@ -4983,8 +4983,8 @@ handle_frame <- function(message, rec) {
|
|
|
4983
4983
|
}
|
|
4984
4984
|
|
|
4985
4985
|
if (identical(cmd$type, "settings_reset")) {
|
|
4986
|
-
unlink(
|
|
4987
|
-
settings_state <<-
|
|
4986
|
+
unlink(beatrina_settings_path())
|
|
4987
|
+
settings_state <<- beatrina_settings_resolve()
|
|
4988
4988
|
linger_s <<- resolve_linger()
|
|
4989
4989
|
HISTORY_ENABLED <<- isTRUE(settings_value("history_enabled"))
|
|
4990
4990
|
audit("settings", detail = "reset", ok = TRUE)
|
|
@@ -5024,7 +5024,7 @@ handle_frame <- function(message, rec) {
|
|
|
5024
5024
|
if (is.null(cmd$value)) {
|
|
5025
5025
|
values[[key]] <- NULL # unset: back to env/default
|
|
5026
5026
|
} else {
|
|
5027
|
-
verdict <-
|
|
5027
|
+
verdict <- beatrina_settings_validate(key, cmd$value)
|
|
5028
5028
|
if (!isTRUE(verdict$ok)) {
|
|
5029
5029
|
# Refused, never coerced, and the file is NOT written — the ai-key
|
|
5030
5030
|
# scar, where a payload of the wrong type fell through to "" and
|
|
@@ -5038,13 +5038,13 @@ handle_frame <- function(message, rec) {
|
|
|
5038
5038
|
values[[key]] <- verdict$value
|
|
5039
5039
|
}
|
|
5040
5040
|
|
|
5041
|
-
if (!isTRUE(
|
|
5041
|
+
if (!isTRUE(beatrina_settings_write(values))) {
|
|
5042
5042
|
audit("settings", detail = "set", key = key, ok = FALSE)
|
|
5043
5043
|
reply(ok = FALSE, key = key, reason = "write",
|
|
5044
5044
|
error = "The settings file could not be written.")
|
|
5045
5045
|
return(invisible(NULL))
|
|
5046
5046
|
}
|
|
5047
|
-
settings_state <<-
|
|
5047
|
+
settings_state <<- beatrina_settings_resolve()
|
|
5048
5048
|
applied <- FALSE
|
|
5049
5049
|
if (identical(entry$effect, "live")) {
|
|
5050
5050
|
if (identical(key, "linger_seconds")) {
|
|
@@ -5248,8 +5248,8 @@ pump <- function() {
|
|
|
5248
5248
|
# Session-wide lifecycle frames have no owner and must reach every tab —
|
|
5249
5249
|
# and no id to rewrite, so they forward exactly as the kernel wrote them.
|
|
5250
5250
|
if (identical(e$type, "ready")) {
|
|
5251
|
-
e$protocol <-
|
|
5252
|
-
e$kernel_build <-
|
|
5251
|
+
e$protocol <- BEATRINA_PROTOCOL_VERSION
|
|
5252
|
+
e$kernel_build <- BEATRINA_KERNEL_BUILD
|
|
5253
5253
|
e$installed_build <- installed_build()
|
|
5254
5254
|
# session_upgrade is the SUPERVISOR's verb; it joins the worker's
|
|
5255
5255
|
# vocabulary here so a page can tell, without probing, that this
|
|
@@ -5296,7 +5296,7 @@ if (deployment$require_origin) {
|
|
|
5296
5296
|
# first line. An operator who has bound to the network gets the posture
|
|
5297
5297
|
# stated back to them, because the two spellings differ enormously and the
|
|
5298
5298
|
# difference is invisible from the outside.
|
|
5299
|
-
message("
|
|
5299
|
+
message("Beatrina listening on ", host, ":", port,
|
|
5300
5300
|
" — strict: a client with no Origin is refused")
|
|
5301
5301
|
message(if (deployment$trust_proxy)
|
|
5302
5302
|
paste0(" identity: ", deployment$user_header, " from ",
|
|
@@ -5327,35 +5327,35 @@ file_url <- notebook_file_url(notebook_file, port, FILE_LAUNCH_CAP)
|
|
|
5327
5327
|
|
|
5328
5328
|
#' Discovery for local agents — the Jupyter runtime-dir pattern.
|
|
5329
5329
|
#'
|
|
5330
|
-
#' `tools/mcp/
|
|
5330
|
+
#' `tools/mcp/beatrina-mcp.mjs` (the stdio server Claude Code / Codex spawn) has
|
|
5331
5331
|
#' no stdout to read the URL from, so the kernel writes one small file a local
|
|
5332
|
-
#' process owned by the SAME user can find: ~/.
|
|
5332
|
+
#' process owned by the SAME user can find: ~/.beatrina/run/kernel-<port>.json,
|
|
5333
5333
|
#' mode 0600. This is discovery metadata, not a browser credential. A same-user
|
|
5334
5334
|
#' local process was never outside the trust line — it could already exec
|
|
5335
5335
|
#' anything. Removed on clean shutdown;
|
|
5336
5336
|
#' consumers must health-check before trusting a file (kills leave litter).
|
|
5337
|
-
#'
|
|
5338
|
-
#'
|
|
5337
|
+
#' BEATRINA_RUNTIME_DIR relocates it (tests use a scratch dir);
|
|
5338
|
+
#' BEATRINA_NO_RUNTIME_FILE=1 turns it off.
|
|
5339
5339
|
runtime_file <- ""
|
|
5340
5340
|
runtime_record <- list(url = url, file = file_url, port = port, pid = Sys.getpid(),
|
|
5341
5341
|
started = format(Sys.time(), "%Y-%m-%dT%H:%M:%S%z"))
|
|
5342
5342
|
# A successor started by a handoff keeps the session's name, so the menu
|
|
5343
5343
|
# row does not flicker to "Untitled" between the two processes.
|
|
5344
5344
|
local({
|
|
5345
|
-
title <- Sys.getenv("
|
|
5346
|
-
Sys.unsetenv("
|
|
5345
|
+
title <- Sys.getenv("BEATRINA_SESSION_TITLE", "")
|
|
5346
|
+
Sys.unsetenv("BEATRINA_SESSION_TITLE")
|
|
5347
5347
|
title <- trimws(substr(gsub("[\"\\\\]", "'", gsub("[[:cntrl:]]", " ", title)), 1L, 120L))
|
|
5348
5348
|
if (nzchar(title)) runtime_record$title <<- title
|
|
5349
5349
|
label <- session_documents_label(sockets$session_documents)
|
|
5350
5350
|
if (nzchar(label)) runtime_record$documents <<- label
|
|
5351
5351
|
})
|
|
5352
|
-
#
|
|
5352
|
+
# BEATRINA_LISTEN=1 marks a kernel started headless for published pages (the
|
|
5353
5353
|
# menu's Listen for Web Pages, the keep-ready daemon). The menu helper shows
|
|
5354
5354
|
# such a kernel as its Listen checkbox, not as a session row — there is no
|
|
5355
5355
|
# document behind it to name. The mark is HOW IT STARTED, so the first page
|
|
5356
5356
|
# that actually attaches clears it (see the upgrade handler): from then on it
|
|
5357
5357
|
# is an ordinary session.
|
|
5358
|
-
if (identical(Sys.getenv("
|
|
5358
|
+
if (identical(Sys.getenv("BEATRINA_LISTEN"), "1")) runtime_record$listen <- TRUE
|
|
5359
5359
|
# The failsafe's session is listed by `beatrina status` and stopped by `beatrina stop` like any other:
|
|
5360
5360
|
# the same app, a host name, the boot instant /health reports, and the document it opened.
|
|
5361
5361
|
if (nzchar(FAILSAFE_REASON)) {
|
|
@@ -5412,9 +5412,9 @@ clear_runtime_listen <- function() {
|
|
|
5412
5412
|
write_runtime()
|
|
5413
5413
|
}
|
|
5414
5414
|
|
|
5415
|
-
if (!identical(Sys.getenv("
|
|
5416
|
-
runtime_dir <- Sys.getenv("
|
|
5417
|
-
file.path(path.expand("~"), ".
|
|
5415
|
+
if (!identical(Sys.getenv("BEATRINA_NO_RUNTIME_FILE"), "1")) {
|
|
5416
|
+
runtime_dir <- Sys.getenv("BEATRINA_RUNTIME_DIR",
|
|
5417
|
+
file.path(path.expand("~"), ".beatrina", "run"))
|
|
5418
5418
|
made <- dir.exists(runtime_dir) ||
|
|
5419
5419
|
dir.create(runtime_dir, recursive = TRUE, showWarnings = FALSE)
|
|
5420
5420
|
if (made) {
|
|
@@ -5423,7 +5423,7 @@ if (!identical(Sys.getenv("CARMAR_NO_RUNTIME_FILE"), "1")) {
|
|
|
5423
5423
|
}
|
|
5424
5424
|
}
|
|
5425
5425
|
|
|
5426
|
-
audit("started", port = port, r = R.version.string, root = Sys.getenv("
|
|
5426
|
+
audit("started", port = port, r = R.version.string, root = Sys.getenv("BEATRINA_ROOT", ""))
|
|
5427
5427
|
cat(toJSON(list(url = url, file = file_url), auto_unbox = TRUE), "\n")
|
|
5428
5428
|
flush(stdout())
|
|
5429
5429
|
|
|
@@ -5504,7 +5504,7 @@ mcp_pending_sweep <- function() {
|
|
|
5504
5504
|
as.numeric(difftime(now, entry$at, units = "secs")) >= MCP_PENDING_MAX_S
|
|
5505
5505
|
if (!page_gone && !stale) next
|
|
5506
5506
|
why <- if (page_gone)
|
|
5507
|
-
"The
|
|
5507
|
+
"The Beatrina notebook window that was answering this request closed before it replied."
|
|
5508
5508
|
else sprintf("The notebook did not answer within %d seconds.",
|
|
5509
5509
|
as.integer(MCP_PENDING_MAX_S))
|
|
5510
5510
|
audit("mcp-abandoned", id = id, reason = if (page_gone) "page closed" else "timeout")
|
|
@@ -5554,7 +5554,7 @@ linger_check <- function() {
|
|
|
5554
5554
|
if (as.numeric(difftime(now, sockets$idle_since, units = "secs")) >= grace) {
|
|
5555
5555
|
reason <- if (unopened_default) "unopened-launch" else "configured-idle-timeout"
|
|
5556
5556
|
audit("linger-shutdown", grace = grace, reason = reason)
|
|
5557
|
-
message(sprintf("
|
|
5557
|
+
message(sprintf("Beatrina stopping: %s (grace %s seconds).", reason, grace))
|
|
5558
5558
|
sockets$quit <- TRUE
|
|
5559
5559
|
}
|
|
5560
5560
|
invisible(NULL)
|