beatrina 0.8.6 → 0.9.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICES +1 -1
- package/README.md +6 -0
- package/beatrina_V0.9.43.html +1885 -0
- package/beatrina_V0.9.43.html.inputs.json +1 -0
- package/bin/beatrina.mjs +156 -26
- package/bin/browser.mjs +31 -0
- package/bin/cli.mjs +64 -5
- package/bin/doctor-rows.mjs +31 -0
- package/bin/failsafe.mjs +8 -3
- package/bin/finder.mjs +74 -0
- package/bin/lsquery.swift +55 -0
- package/bin/pages.mjs +81 -0
- package/bin/python-setup.mjs +207 -0
- package/bin/runtime-dirs.mjs +27 -0
- package/bin/sessions.mjs +30 -10
- package/bin/shortcut.mjs +271 -35
- package/bin/update-check.mjs +2 -2
- package/build-info.json +1 -1
- package/engines/js/worker.mjs +6 -3
- package/engines/python/adapter.py +14 -13
- package/engines/python/analyze.py +4 -2
- package/engines/python/bootstrap.py +32 -31
- package/engines/python/debugger.py +10 -6
- package/engines/python/engine.json +1 -1
- package/engines/python/handoff.py +4 -2
- package/engines/python/worker.py +39 -10
- package/engines/r/engine.json +1 -1
- package/engines/r/handoff.R +4 -3
- package/failsafe/ai-policy.R +16 -16
- package/failsafe/ai-store.R +6 -6
- package/failsafe/cite.R +11 -11
- package/failsafe/journal.R +11 -11
- package/failsafe/plugins.R +27 -27
- package/failsafe/serve.R +364 -239
- package/failsafe/session-documents.R +104 -0
- package/host/ai-authority.mjs +55 -0
- package/host/ai-policy.mjs +48 -26
- package/host/bundle.mjs +194 -0
- package/host/deployment.mjs +25 -18
- package/host/engine-js.mjs +16 -17
- package/host/engine-pool.mjs +56 -8
- package/host/engine-python.mjs +80 -31
- package/host/engine-r.mjs +38 -32
- package/host/engine-stdio.mjs +45 -12
- package/host/env-names.mjs +148 -0
- package/host/gateway-token.mjs +51 -0
- package/host/journal-store.mjs +10 -9
- package/host/main.mjs +216 -76
- package/host/payload.mjs +131 -0
- package/host/planes/README.md +1 -1
- package/host/planes/ai-store.mjs +6 -5
- package/host/planes/ai.mjs +49 -29
- package/host/planes/analyze.mjs +6 -5
- package/host/planes/bundle.mjs +344 -0
- package/host/planes/choose.mjs +404 -0
- package/host/planes/cite.mjs +18 -17
- package/host/planes/files.mjs +0 -0
- package/host/planes/jobs.mjs +43 -20
- package/host/planes/latex.mjs +41 -5
- package/host/planes/mcp.mjs +80 -91
- package/host/planes/pair.mjs +24 -24
- package/host/planes/pipe-term.mjs +2 -2
- package/host/planes/plugins.mjs +1 -1
- package/host/planes/recent-documents.mjs +170 -0
- package/host/planes/sessions.mjs +230 -82
- package/host/planes/settings.mjs +4 -4
- package/host/planes/terminal.mjs +13 -11
- package/host/planes/test-file.mjs +2 -2
- package/host/planes/update.mjs +6 -13
- package/host/plugin-store.mjs +35 -52
- package/host/recent-documents.mjs +124 -0
- package/host/runtime-dir.mjs +47 -0
- package/host/server.mjs +123 -21
- package/host/session-keep.mjs +70 -0
- package/host/settings.mjs +85 -37
- package/host/update-record.mjs +3 -2
- package/host/user-dirs.mjs +60 -18
- package/host/which.mjs +39 -0
- package/host/windows-runtime.mjs +6 -3
- package/host/worker-plane.mjs +80 -7
- package/host/ws.mjs +9 -2
- package/host/zip.mjs +237 -0
- package/kernel/analyze.R +1 -1
- package/{check → kernel/check}/acceptance.mjs +1 -1
- package/kernel/check/knit-file.mjs +19639 -0
- package/{check → kernel/check}/session.mjs +49 -8
- package/kernel/deployment.R +20 -20
- package/kernel/examples/NOTICE.md +1 -1
- package/kernel/fileio.R +6 -6
- package/kernel/index.html +2 -2
- package/kernel/job-run.R +83 -22
- package/kernel/jobs.R +28 -12
- package/kernel/kernel-version +1 -1
- package/kernel/kernel.R +24 -21
- package/kernel/knitr-run.R +50 -9
- package/kernel/latex.R +429 -30
- package/kernel/mcp/{carmar-mcp.mjs → beatrina-mcp.mjs} +249 -59
- package/kernel/notebook-page.R +7 -7
- package/kernel/project.R +10 -10
- package/kernel/settings.R +105 -56
- package/kernel/sniff.R +4 -4
- package/kernel/typst.R +121 -0
- package/kernel/worker.R +602 -123
- package/kernel/workspace-keep.R +188 -0
- package/lib/agent-authoring-contract.js +28 -19
- package/lib/cell-kinds.js +3 -3
- package/lib/engine-labels.js +4 -4
- package/menu/Beatrina Menu.app/Contents/Info.plist +14 -0
- package/menu/Beatrina Menu.app/Contents/MacOS/Beatrina Menu +0 -0
- package/menu/Beatrina Menu.app/Contents/_CodeSignature/CodeResources +115 -0
- package/package.json +4 -3
- package/carmar_V0.8.6.html +0 -1310
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,15 +52,20 @@ 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"))
|
|
59
|
+
# "Restart, keep variables": the supervisor never reads a kept workspace; it
|
|
60
|
+
# only checks the token (keep_token_ok) and carries it to the next worker.
|
|
61
|
+
source(file.path(here, "workspace-keep.R"))
|
|
62
|
+
# Which documents this session has open — the session's record, not the page's.
|
|
63
|
+
source(file.path(here, "session-documents.R"))
|
|
59
64
|
|
|
60
65
|
# Apply an administrator-owned desktop policy before any subsystem reads its
|
|
61
66
|
# environment. Invalid or user-writable policy fails closed at the shared
|
|
62
67
|
# startup gate below; no partial values are applied.
|
|
63
|
-
managed_config <-
|
|
68
|
+
managed_config <- beatrina_managed_environment()
|
|
64
69
|
|
|
65
70
|
# Before anything reads or writes a frame. The supervisor parses every browser
|
|
66
71
|
# message and re-encodes every worker reply, so its own character locale
|
|
@@ -80,16 +85,16 @@ read_kernel_protocol <- function() {
|
|
|
80
85
|
candidates <- c(file.path(here, "kernel-protocol"),
|
|
81
86
|
file.path(here, "..", "kernel.protocol"))
|
|
82
87
|
hit <- candidates[file.exists(candidates)][1L]
|
|
83
|
-
if (!length(hit) || is.na(hit)) stop("
|
|
88
|
+
if (!length(hit) || is.na(hit)) stop("Beatrina kernel protocol stamp is missing.")
|
|
84
89
|
value <- suppressWarnings(as.integer(trimws(readLines(hit, warn = FALSE, n = 1L))))
|
|
85
90
|
if (length(value) != 1L || is.na(value) || value < 1L) {
|
|
86
|
-
stop("
|
|
91
|
+
stop("Beatrina kernel protocol stamp is invalid.")
|
|
87
92
|
}
|
|
88
93
|
value
|
|
89
94
|
}
|
|
90
|
-
|
|
95
|
+
BEATRINA_PROTOCOL_VERSION <- read_kernel_protocol()
|
|
91
96
|
read_kernel_build <- function() {
|
|
92
|
-
from_env <- trimws(Sys.getenv("
|
|
97
|
+
from_env <- trimws(Sys.getenv("BEATRINA_KERNEL_BUILD", ""))
|
|
93
98
|
if (nzchar(from_env)) return(from_env)
|
|
94
99
|
stamp <- file.path(here, "kernel-version")
|
|
95
100
|
if (file.exists(stamp)) {
|
|
@@ -104,25 +109,25 @@ read_kernel_build <- function() {
|
|
|
104
109
|
}
|
|
105
110
|
"unknown"
|
|
106
111
|
}
|
|
107
|
-
|
|
112
|
+
BEATRINA_KERNEL_BUILD <- read_kernel_build()
|
|
108
113
|
|
|
109
|
-
#' What
|
|
114
|
+
#' What Beatrina is INSTALLED as, right now — re-read on every call.
|
|
110
115
|
#'
|
|
111
|
-
#' `
|
|
116
|
+
#' `BEATRINA_KERNEL_BUILD` is what this process was started as. The bundle it
|
|
112
117
|
#' lives in is swapped IN PLACE by an upgrade, so the stamp beside this file
|
|
113
118
|
#' can name a newer (or, after a rollback, an older) build than the one
|
|
114
119
|
#' running. Reading it fresh every time is what lets a session that predates
|
|
115
120
|
#' an install say so — in /health, in the ready frame and in every heartbeat
|
|
116
121
|
#' — instead of serving the old page in silence until someone quits it.
|
|
117
122
|
#'
|
|
118
|
-
#' When the build was dictated by
|
|
119
|
-
#' 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
|
|
120
125
|
#' the same value — a test kernel must never read "newer installed" off a
|
|
121
126
|
#' source checkout's notebook.version.cjs.
|
|
122
127
|
installed_build <- function() {
|
|
123
|
-
dictated <- trimws(Sys.getenv("
|
|
128
|
+
dictated <- trimws(Sys.getenv("BEATRINA_KERNEL_BUILD", ""))
|
|
124
129
|
if (nzchar(dictated)) {
|
|
125
|
-
inst <- trimws(Sys.getenv("
|
|
130
|
+
inst <- trimws(Sys.getenv("BEATRINA_INSTALLED_BUILD", ""))
|
|
126
131
|
return(if (nzchar(inst)) inst else dictated)
|
|
127
132
|
}
|
|
128
133
|
stamp <- file.path(here, "kernel-version")
|
|
@@ -141,13 +146,13 @@ installed_build <- function() {
|
|
|
141
146
|
# The build this kernel REPLACED, when it was started by a "Restart into"
|
|
142
147
|
# handoff (session_handoff_spawn below). Told to /health for the first
|
|
143
148
|
# minute and a half so the page that asked can recognise its successor.
|
|
144
|
-
HANDOFF_FROM <- trimws(Sys.getenv("
|
|
149
|
+
HANDOFF_FROM <- trimws(Sys.getenv("BEATRINA_HANDOFF_FROM", ""))
|
|
145
150
|
|
|
146
151
|
# Where to listen. 127.0.0.1 unless an operator says otherwise, and saying
|
|
147
152
|
# otherwise inverts the trust model — see spike/deployment.R, which owns that
|
|
148
153
|
# decision and refuses the unsafe spellings of it outright.
|
|
149
154
|
host <- local({
|
|
150
|
-
h <- trimws(Sys.getenv("
|
|
155
|
+
h <- trimws(Sys.getenv("BEATRINA_BIND", "127.0.0.1"))
|
|
151
156
|
if (nzchar(h)) h else "127.0.0.1"
|
|
152
157
|
})
|
|
153
158
|
|
|
@@ -157,7 +162,7 @@ host <- local({
|
|
|
157
162
|
#' here. localStorage and sessionStorage are keyed by scheme://host:PORT, so a
|
|
158
163
|
#' new port on every start is a new, empty storage on every start: the saved
|
|
159
164
|
#' notebooks invisible, the chat history gone, the AI key asked for again. A
|
|
160
|
-
#' user restarting the kernel three times has three separate
|
|
165
|
+
#' user restarting the kernel three times has three separate Beatrinas and no way
|
|
161
166
|
#' to know why the last one forgot everything.
|
|
162
167
|
#'
|
|
163
168
|
#' A fixed loopback port is not a weaker position than a random one — the
|
|
@@ -172,7 +177,7 @@ port_taken <- function(p) {
|
|
|
172
177
|
error = function(e) NULL))
|
|
173
178
|
if (is.null(con)) FALSE else { close(con); TRUE }
|
|
174
179
|
}
|
|
175
|
-
#
|
|
180
|
+
# BEATRINA_PORT is a PREFERENCE by default and a PROMISE when asked.
|
|
176
181
|
#
|
|
177
182
|
# Moving to a random port when the wanted one is taken is right for a person
|
|
178
183
|
# double-clicking the app: they get a session instead of an error, and the
|
|
@@ -181,23 +186,23 @@ port_taken <- function(p) {
|
|
|
181
186
|
# is already written into a registry record and about to be handed to Caddy
|
|
182
187
|
# as an upstream, so a kernel that quietly moves leaves BOTH pointing at
|
|
183
188
|
# nothing and the reader gets a connection refused with no explanation
|
|
184
|
-
# anywhere in the system.
|
|
189
|
+
# anywhere in the system. BEATRINA_PORT_STRICT=1 turns the silent move into a
|
|
185
190
|
# refusal, which is the only honest answer when the caller cannot be told.
|
|
186
191
|
port <- local({
|
|
187
|
-
wanted <- suppressWarnings(as.integer(Sys.getenv("
|
|
192
|
+
wanted <- suppressWarnings(as.integer(Sys.getenv("BEATRINA_PORT", "4747")))
|
|
188
193
|
if (is.na(wanted) || wanted < 1024L || wanted > 65535L) wanted <- 4747L
|
|
189
194
|
if (!port_taken(wanted)) return(wanted)
|
|
190
195
|
# A successor started by a handoff is spawned while its predecessor still
|
|
191
196
|
# holds the port; it waits for the port to free rather than moving.
|
|
192
|
-
wait_s <- suppressWarnings(as.numeric(Sys.getenv("
|
|
197
|
+
wait_s <- suppressWarnings(as.numeric(Sys.getenv("BEATRINA_WAIT_PORT", "0")))
|
|
193
198
|
if (is.finite(wait_s) && wait_s > 0) {
|
|
194
199
|
deadline <- Sys.time() + wait_s
|
|
195
200
|
while (port_taken(wanted) && Sys.time() < deadline) Sys.sleep(0.25) # a wait is a loop
|
|
196
201
|
if (!port_taken(wanted)) return(wanted)
|
|
197
202
|
}
|
|
198
|
-
if (identical(Sys.getenv("
|
|
199
|
-
message("
|
|
200
|
-
"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 ",
|
|
201
206
|
"chose this port is expecting to find the kernel on it.")
|
|
202
207
|
quit(status = 2L, save = "no")
|
|
203
208
|
}
|
|
@@ -207,27 +212,27 @@ port <- local({
|
|
|
207
212
|
# The posture: bind, Origin allow-list, Host allow-list, and whether an
|
|
208
213
|
# Origin-less client is a same-user native process or an unauthenticated
|
|
209
214
|
# stranger. Computed once; every gate below reads a decided answer.
|
|
210
|
-
deployment <-
|
|
215
|
+
deployment <- beatrina_deployment(port)
|
|
211
216
|
|
|
212
217
|
# The administrator's AI policy, read once. Its errors join the deployment's
|
|
213
218
|
# in the same startup gate and for the same reason: a governance control that
|
|
214
219
|
# silently failed to apply is worse than one that refuses to start, because
|
|
215
220
|
# nobody finds out until the data has already gone to the wrong vendor.
|
|
216
|
-
ai_policy <-
|
|
221
|
+
ai_policy <- beatrina_ai_policy()
|
|
217
222
|
|
|
218
223
|
# The plugin policy (spike/plugins.R): which kinds exist, whether the person
|
|
219
224
|
# may install, which hosts a fetch may reach, where the administrator's set
|
|
220
225
|
# is. Unlike the AI policy this one is enforcement — every fetch and every
|
|
221
226
|
# write is the supervisor's — and it fails closed the same way: a policy
|
|
222
227
|
# that names only junk refuses to start rather than permitting everything.
|
|
223
|
-
plugin_policy <-
|
|
228
|
+
plugin_policy <- beatrina_plugin_policy()
|
|
224
229
|
|
|
225
230
|
startup_errors <- c(managed_config$errors, deployment$errors, ai_policy$errors, plugin_policy$errors)
|
|
226
231
|
if (length(startup_errors)) {
|
|
227
232
|
# Refusing to start is the correct outcome. A server that binds to the
|
|
228
|
-
# 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
|
|
229
234
|
# remote-execution service, and there is no safe way to serve it "for now".
|
|
230
|
-
for (msg in startup_errors) message("
|
|
235
|
+
for (msg in startup_errors) message("Beatrina refuses to start: ", msg)
|
|
231
236
|
quit(status = 2L, save = "no")
|
|
232
237
|
}
|
|
233
238
|
|
|
@@ -246,10 +251,10 @@ origins_ok <- deployment$origins
|
|
|
246
251
|
hosts_ok <- deployment$hosts
|
|
247
252
|
|
|
248
253
|
# Optional audit log — one JSON object per line, for deployments that must be
|
|
249
|
-
# 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,
|
|
250
255
|
# because a notebook that silently records the user's code would be worse than
|
|
251
256
|
# no logging at all.
|
|
252
|
-
log_path <- Sys.getenv("
|
|
257
|
+
log_path <- Sys.getenv("BEATRINA_LOG", "")
|
|
253
258
|
# pandoc, found once and kept. Absent stays absent for the session — a
|
|
254
259
|
# pandoc installed mid-session shows up after a restart, which is the same
|
|
255
260
|
# rule R itself applies to a package installed under a running session.
|
|
@@ -278,28 +283,28 @@ audit <- function(event, ...) {
|
|
|
278
283
|
# successful settings_set so a later read reports the value that is actually
|
|
279
284
|
# in force, and `linger_s` / `HISTORY_ENABLED` below are the two the running
|
|
280
285
|
# kernel can honour immediately (see each one's `effect`).
|
|
281
|
-
settings_state <-
|
|
282
|
-
settings_value <- function(key)
|
|
286
|
+
settings_state <- beatrina_settings_resolve()
|
|
287
|
+
settings_value <- function(key) beatrina_settings_value(settings_state, key)
|
|
283
288
|
audit("settings", detail = "startup", status = settings_state$file$status,
|
|
284
289
|
keys = length(settings_state$file$values))
|
|
285
290
|
|
|
286
291
|
# What every child inherits from the user's settings, and which R to run.
|
|
287
292
|
# Defined here so the three spawn sites cannot drift: a setting that reaches
|
|
288
293
|
# the worker but not the restarted worker is the worst kind of half-applied.
|
|
289
|
-
settings_child_env <- function()
|
|
294
|
+
settings_child_env <- function() beatrina_settings_child_env(settings_state)
|
|
290
295
|
settings_rscript <- function() {
|
|
291
296
|
chosen <- settings_value("rscript_path")
|
|
292
297
|
detect_rscript(if (is.character(chosen) && nzchar(chosen)) chosen
|
|
293
|
-
else Sys.getenv("
|
|
298
|
+
else Sys.getenv("BEATRINA_RSCRIPT", ""))
|
|
294
299
|
}
|
|
295
300
|
|
|
296
|
-
# The launcher's state dir (
|
|
301
|
+
# The launcher's state dir (BEATRINA_STATE) or the package's: where this
|
|
297
302
|
# supervisor keeps the handoff log for a successor it spawns. Nothing of the
|
|
298
303
|
# R session is ever written here — a worker starts fresh, always (see the
|
|
299
304
|
# handoff section below).
|
|
300
305
|
state_dir <- function() {
|
|
301
|
-
state <- Sys.getenv("
|
|
302
|
-
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")
|
|
303
308
|
state
|
|
304
309
|
}
|
|
305
310
|
start_execution_worker <- function() {
|
|
@@ -319,6 +324,17 @@ sockets$open <- list()
|
|
|
319
324
|
# connects. Without replaying it, every page that opens later sits on
|
|
320
325
|
# "connecting…" forever waiting for a frame that was broadcast to nobody.
|
|
321
326
|
sockets$hello <- NULL
|
|
327
|
+
# A kept workspace for the NEXT ready worker: set by `restart {resume}` or, in a
|
|
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
|
+
# The restore's report, kept for 120 s so a page that follows a handoff — and
|
|
332
|
+
# connects after the restore finished — still hears what came back.
|
|
333
|
+
sockets$resume_report <- NULL
|
|
334
|
+
# The documents open in this session (spike/session-documents.R), as the pages
|
|
335
|
+
# last reported them. A handoff successor starts with its predecessor's list.
|
|
336
|
+
sockets$session_documents <- session_documents_decode(Sys.getenv("BEATRINA_SESSION_DOCUMENTS"))
|
|
337
|
+
Sys.unsetenv("BEATRINA_SESSION_DOCUMENTS")
|
|
322
338
|
sockets$worker_recovering <- FALSE
|
|
323
339
|
sockets$worker_restart_attempts <- 0L
|
|
324
340
|
sockets$worker_restart_after <- 0
|
|
@@ -349,7 +365,7 @@ sockets$terms <- new.env(parent = emptyenv())
|
|
|
349
365
|
sockets$term_seq <- 0L
|
|
350
366
|
# Connection lifetime and R lifetime are separate. By default a session that
|
|
351
367
|
# has been attached survives closed tabs, browser suspension and lost links.
|
|
352
|
-
# Positive
|
|
368
|
+
# Positive BEATRINA_LINGER / linger_seconds explicitly opts into idle shutdown.
|
|
353
369
|
# A running task blocks that optional clock; reconnection resets it.
|
|
354
370
|
# How long /health?hold=1 postpones an explicitly configured idle timeout.
|
|
355
371
|
# Default retention does not depend on holds or their timing.
|
|
@@ -357,8 +373,8 @@ HOLD_GRACE_S <- 30
|
|
|
357
373
|
# How long a kernel that has never been connected to waits for its browser
|
|
358
374
|
# before an unopened launch is cleaned up. This is NOT a session idle timer:
|
|
359
375
|
# the first connection or hold permanently cancels default boot cleanup.
|
|
360
|
-
# Explicit
|
|
361
|
-
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"))
|
|
362
378
|
if (!is.finite(BOOT_GRACE_S) || BOOT_GRACE_S < 0) BOOT_GRACE_S <- 180
|
|
363
379
|
# The same safe default applies locally and behind a proxy. Explicit user or
|
|
364
380
|
# administrator settings remain authoritative, including an explicit zero.
|
|
@@ -382,7 +398,7 @@ linger_s <- resolve_linger()
|
|
|
382
398
|
# Only a socket that has beaten at least once is ever reaped — a notebook built
|
|
383
399
|
# before heartbeats exists on people's disks and must keep today's behaviour
|
|
384
400
|
# rather than be disconnected while its reader is looking at it.
|
|
385
|
-
socket_silence_s <- suppressWarnings(as.numeric(Sys.getenv("
|
|
401
|
+
socket_silence_s <- suppressWarnings(as.numeric(Sys.getenv("BEATRINA_SOCKET_SILENCE", "90")))
|
|
386
402
|
if (!length(socket_silence_s) || !is.finite(socket_silence_s)) socket_silence_s <- 90
|
|
387
403
|
|
|
388
404
|
# ── the dispatched-command deadline, PER COMMAND CLASS ─────────────────────
|
|
@@ -444,7 +460,7 @@ WORKER_DEADLINES <- c(
|
|
|
444
460
|
# Tests need the same branch in seconds rather than minutes. Only a test sets
|
|
445
461
|
# it; the numbers above ARE the contract.
|
|
446
462
|
worker_deadline_scale <- suppressWarnings(
|
|
447
|
-
as.numeric(Sys.getenv("
|
|
463
|
+
as.numeric(Sys.getenv("BEATRINA_WORKER_DEADLINE_SCALE", "1")))
|
|
448
464
|
if (!length(worker_deadline_scale) || !is.finite(worker_deadline_scale) ||
|
|
449
465
|
worker_deadline_scale <= 0) worker_deadline_scale <- 1
|
|
450
466
|
|
|
@@ -456,14 +472,14 @@ if (!length(worker_deadline_scale) || !is.finite(worker_deadline_scale) ||
|
|
|
456
472
|
# than an honest terminal state, because the attach plug is hidden while
|
|
457
473
|
# recovering and the user cannot reach another session either.
|
|
458
474
|
worker_respawn_max <- suppressWarnings(
|
|
459
|
-
as.integer(Sys.getenv("
|
|
475
|
+
as.integer(Sys.getenv("BEATRINA_WORKER_RESPAWN_MAX", "10")))
|
|
460
476
|
if (!length(worker_respawn_max) || is.na(worker_respawn_max) ||
|
|
461
477
|
worker_respawn_max < 1L) worker_respawn_max <- 10L
|
|
462
478
|
|
|
463
479
|
# An agent's tool call parked on a page that then died. The page half is the
|
|
464
480
|
# precise test — a rec no longer in `sockets$open` can never answer — and this
|
|
465
481
|
# is the backstop for the case where the page is alive and simply never
|
|
466
|
-
# replies. It matches the ceiling tools/mcp/
|
|
482
|
+
# replies. It matches the ceiling tools/mcp/beatrina-mcp.mjs allows a caller to
|
|
467
483
|
# ask for (chunk_run's timeout_s caps at 3600), so the supervisor never gives
|
|
468
484
|
# up on a call the agent is still legitimately waiting for.
|
|
469
485
|
MCP_PENDING_MAX_S <- 3600
|
|
@@ -482,7 +498,7 @@ sockets$worker_queue <- list()
|
|
|
482
498
|
# explicitly pairs one page in a loopback consent window; that LOCAL window
|
|
483
499
|
# owns the socket and relays only messages from the exact approved origin.
|
|
484
500
|
sockets$pairing_requests <- new.env(parent = emptyenv())
|
|
485
|
-
# Consent belongs to this local
|
|
501
|
+
# Consent belongs to this local Beatrina session, not to one popup document.
|
|
486
502
|
# Once an origin is approved, a replacement bridge window for that exact
|
|
487
503
|
# origin may reconnect without asking again. The environment disappears when
|
|
488
504
|
# the kernel stops, so approval is still session-scoped.
|
|
@@ -520,7 +536,7 @@ sockets$boot_at <- Sys.time()
|
|
|
520
536
|
|
|
521
537
|
`%||%` <- function(a, b) if (is.null(a)) b else a
|
|
522
538
|
|
|
523
|
-
#' The page to serve at `/`: the built
|
|
539
|
+
#' The page to serve at `/`: the built Beatrina notebook when there is one,
|
|
524
540
|
#' otherwise the bare spike page. The page is PINNED to this kernel's build
|
|
525
541
|
#' (never the highest version present), `.beta.min` ignored so
|
|
526
542
|
#' a debuggable build is served during development. The repo keeps builds in
|
|
@@ -531,7 +547,7 @@ notebook_page <- function() {
|
|
|
531
547
|
# One implementation, in notebook-page.R, because tools/app/launch.sh has to
|
|
532
548
|
# ask the same question at open time — the announcement below is computed
|
|
533
549
|
# once at startup and goes stale the moment a new build lands.
|
|
534
|
-
page <-
|
|
550
|
+
page <- beatrina_notebook_page(here, BEATRINA_KERNEL_BUILD)
|
|
535
551
|
if (nzchar(page)) return(page)
|
|
536
552
|
file.path(here, "index.html")
|
|
537
553
|
}
|
|
@@ -543,7 +559,7 @@ notebook_page <- function() {
|
|
|
543
559
|
#' does that by comparing ONE string — the boot time exactly as /health
|
|
544
560
|
#' reports `started`, rendered by the same jsonlite call so the two can never
|
|
545
561
|
#' differ in their digits. Served pages get it as
|
|
546
|
-
#' `<meta name="
|
|
562
|
+
#' `<meta name="beatrina-session">`; a file page gets the same value in its
|
|
547
563
|
#' fragment from the launcher.
|
|
548
564
|
session_stamp <- function() {
|
|
549
565
|
as.character(toJSON(as.numeric(sockets$boot_at), auto_unbox = TRUE))
|
|
@@ -558,7 +574,7 @@ session_stamp <- function() {
|
|
|
558
574
|
#' @param html the page's text.
|
|
559
575
|
#' @return the page with the meta in it.
|
|
560
576
|
stamp_session <- function(html) {
|
|
561
|
-
meta <- sprintf('<meta name="
|
|
577
|
+
meta <- sprintf('<meta name="beatrina-session" content="%s">', session_stamp())
|
|
562
578
|
at <- regexpr("<head\\b[^>]*>", html, perl = TRUE)
|
|
563
579
|
if (at < 0) at <- regexpr("<html\\b[^>]*>", html, perl = TRUE)
|
|
564
580
|
if (at < 0) return(paste0(meta, html))
|
|
@@ -566,13 +582,13 @@ stamp_session <- function(html) {
|
|
|
566
582
|
paste0(substr(html, 1L, cut), meta, substr(html, cut + 1L, nchar(html)))
|
|
567
583
|
}
|
|
568
584
|
|
|
569
|
-
#' Where the
|
|
585
|
+
#' Where the beatrina-mcp stdio server lives on THIS machine, so the setup
|
|
570
586
|
#' dialog can show a command that works verbatim. Repo layout first
|
|
571
587
|
#' (tools/mcp beside spike/), then a distribution copy beside serve.R.
|
|
572
588
|
#' Empty when neither exists — the dialog then shows a placeholder path.
|
|
573
589
|
mcp_server_path <- function() {
|
|
574
|
-
for (p in c(file.path(here, "..", "tools", "mcp", "
|
|
575
|
-
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"))) {
|
|
576
592
|
if (file.exists(p)) return(normalizePath(p))
|
|
577
593
|
}
|
|
578
594
|
""
|
|
@@ -591,17 +607,17 @@ find_binary <- function(name, extra = character()) {
|
|
|
591
607
|
""
|
|
592
608
|
}
|
|
593
609
|
|
|
594
|
-
#' The Claude Code CLI, if this machine has one.
|
|
610
|
+
#' The Claude Code CLI, if this machine has one. BEATRINA_CLAUDE_BIN overrides —
|
|
595
611
|
#' that is also how tests substitute a deterministic stub.
|
|
596
612
|
claude_bin <- function() {
|
|
597
|
-
override <- Sys.getenv("
|
|
613
|
+
override <- Sys.getenv("BEATRINA_CLAUDE_BIN", "")
|
|
598
614
|
if (nzchar(override) && file.exists(override)) return(override)
|
|
599
615
|
find_binary("claude")
|
|
600
616
|
}
|
|
601
617
|
|
|
602
|
-
#' The Codex CLI, if this machine has one.
|
|
618
|
+
#' The Codex CLI, if this machine has one. BEATRINA_CODEX_BIN overrides (tests).
|
|
603
619
|
codex_bin <- function() {
|
|
604
|
-
override <- Sys.getenv("
|
|
620
|
+
override <- Sys.getenv("BEATRINA_CODEX_BIN", "")
|
|
605
621
|
if (nzchar(override) && file.exists(override)) return(override)
|
|
606
622
|
find_binary("codex")
|
|
607
623
|
}
|
|
@@ -625,11 +641,11 @@ published_origin_valid <- function(origin) {
|
|
|
625
641
|
grepl("^https?://[A-Za-z0-9._~-]+(:[0-9]{1,5})?$", origin)
|
|
626
642
|
}
|
|
627
643
|
|
|
628
|
-
# A native
|
|
644
|
+
# A native Beatrina launcher may start the kernel for one published page. The
|
|
629
645
|
# URL-scheme handoff is the user's consent gesture; carrying only the exact
|
|
630
646
|
# origin into this process keeps the permission session-scoped and prevents a
|
|
631
647
|
# second site from borrowing it.
|
|
632
|
-
initial_published_origin <- Sys.getenv("
|
|
648
|
+
initial_published_origin <- Sys.getenv("BEATRINA_PUBLISHED_ORIGIN", "")
|
|
633
649
|
if (published_origin_valid(initial_published_origin)) {
|
|
634
650
|
sockets$published_approvals[[initial_published_origin]] <- TRUE
|
|
635
651
|
audit("published-origin-authorized", origin = initial_published_origin,
|
|
@@ -654,8 +670,8 @@ if (published_origin_valid(initial_published_origin)) {
|
|
|
654
670
|
# looking for one. Any HTML file the user opened could therefore sweep for a
|
|
655
671
|
# kernel and send `exec` — the same shape as the Chrome extension deleted in
|
|
656
672
|
# 0.38.0 ("no token and no page identity"), which is documented there as
|
|
657
|
-
# unacceptable. A file page cannot hold a secret
|
|
658
|
-
#
|
|
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
|
|
659
675
|
# .html, so a double-clicked notebook goes straight to the browser). What is
|
|
660
676
|
# left is to ASK — once per session, on a local page that names what is
|
|
661
677
|
# asking. Absent Origin (native same-user clients) is deliberately unchanged:
|
|
@@ -665,7 +681,7 @@ if (published_origin_valid(initial_published_origin)) {
|
|
|
665
681
|
#' `sample()` draws from the Mersenne Twister, which is seeded from the clock
|
|
666
682
|
#' and the pid and is trivially reconstructible — fine for shuffling a vector,
|
|
667
683
|
#' not for a value that decides whether a page may run R. /dev/urandom is the
|
|
668
|
-
#' OS CSPRNG on every platform
|
|
684
|
+
#' OS CSPRNG on every platform Beatrina ships a kernel for except Windows, where
|
|
669
685
|
#' the RNG fallback is documented rather than hidden.
|
|
670
686
|
secure_token <- function(n = 32L) {
|
|
671
687
|
alphabet <- c(letters, LETTERS, 0:9)
|
|
@@ -685,7 +701,7 @@ secure_token <- function(n = 32L) {
|
|
|
685
701
|
|
|
686
702
|
FILE_ORIGIN <- "null"
|
|
687
703
|
|
|
688
|
-
# A notebook page SERVED BY ANOTHER
|
|
704
|
+
# A notebook page SERVED BY ANOTHER Beatrina KERNEL on this machine. When a
|
|
689
705
|
# page's own kernel dies the page stays open (it is HTML the browser already
|
|
690
706
|
# holds) and can attach to a different session through the badge's plug icon;
|
|
691
707
|
# it arrives here with the Origin of the kernel that served it,
|
|
@@ -705,7 +721,7 @@ local_origin <- function(origin) {
|
|
|
705
721
|
# consent click, while a file page that merely found the port cannot.
|
|
706
722
|
#
|
|
707
723
|
# serve.R prints the exact file:// URL it expects to be opened, and that URL
|
|
708
|
-
# is the one channel
|
|
724
|
+
# is the one channel Beatrina controls: it already carries `#kernel=<port>`, so
|
|
709
725
|
# it can carry a secret too. The page reads it out of its own fragment and
|
|
710
726
|
# presents it on the WebSocket URL. A drive-by html file probing 4747..65535
|
|
711
727
|
# can reach /health and learn a kernel is there, but it was never handed this,
|
|
@@ -723,12 +739,12 @@ local_origin <- function(origin) {
|
|
|
723
739
|
# so the page can be told where to go BEFORE this process exits. Only the exact
|
|
724
740
|
# shape secure_token() makes is accepted; anything else is minted afresh.
|
|
725
741
|
FILE_LAUNCH_CAP <- local({
|
|
726
|
-
given <- Sys.getenv("
|
|
727
|
-
Sys.unsetenv("
|
|
742
|
+
given <- Sys.getenv("BEATRINA_FILE_LAUNCH_CAP", "")
|
|
743
|
+
Sys.unsetenv("BEATRINA_FILE_LAUNCH_CAP")
|
|
728
744
|
if (grepl("^[A-Za-z0-9]{32}$", given)) given else secure_token(32L)
|
|
729
745
|
})
|
|
730
746
|
|
|
731
|
-
open_file_env <- Sys.getenv("
|
|
747
|
+
open_file_env <- Sys.getenv("BEATRINA_OPEN_FILE", "")
|
|
732
748
|
sockets$pending_open <- if (nzchar(open_file_env)) open_file_env else NULL
|
|
733
749
|
if (!is.null(sockets$pending_open)) audit("open-file-parked")
|
|
734
750
|
|
|
@@ -767,9 +783,9 @@ pairing_page <- function(origin, nonce = "") {
|
|
|
767
783
|
'<meta name="viewport" content="width=device-width,initial-scale=1">',
|
|
768
784
|
'<meta http-equiv="Content-Security-Policy" content="default-src \'none\'; ',
|
|
769
785
|
"style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'\">",
|
|
770
|
-
'<title>', if (is_file) 'Allow this notebook file to use
|
|
771
|
-
else if (is_local) 'Attach this notebook to this
|
|
772
|
-
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>',
|
|
773
789
|
'body{font:16px/1.5 system-ui,sans-serif;max-width:42rem;margin:10vh auto;padding:0 1.25rem;color:#172033}',
|
|
774
790
|
'.site{padding:.75rem 1rem;background:#f3f6fb;border-radius:.55rem;overflow-wrap:anywhere}',
|
|
775
791
|
'button{border:0;border-radius:.5rem;background:#2357d9;color:white;padding:.7rem 1rem;font:inherit;font-weight:650;cursor:pointer}',
|
|
@@ -778,15 +794,15 @@ pairing_page <- function(origin, nonce = "") {
|
|
|
778
794
|
paste0('<h1>Run this notebook file with your R?</h1>',
|
|
779
795
|
'<p>A notebook opened from a file on this computer</p>',
|
|
780
796
|
'<p class="site"><strong>a page opened from disk (file://)</strong></p>',
|
|
781
|
-
'<p>wants to use this
|
|
797
|
+
'<p>wants to use this Beatrina session. The code will run as your user ',
|
|
782
798
|
'and can access your files and network. A page opened from a file ',
|
|
783
799
|
'cannot be identified any more exactly than this, so approve it only ',
|
|
784
800
|
'if you just opened a notebook of your own.</p>')
|
|
785
801
|
else if (is_local)
|
|
786
802
|
paste0('<h1>Attach this notebook to this R session?</h1>',
|
|
787
|
-
'<p>A
|
|
803
|
+
'<p>A Beatrina notebook page served at</p>',
|
|
788
804
|
'<p class="site"><strong>', html_escape(origin), '</strong></p>',
|
|
789
|
-
'<p>wants to use this
|
|
805
|
+
'<p>wants to use this Beatrina session instead of its own — usually ',
|
|
790
806
|
'because the session that served it has stopped. It will be treated as ',
|
|
791
807
|
'your notebook: its chunks run as your user, and it may open a terminal ',
|
|
792
808
|
'and use your saved AI settings. Approve it only if you just asked a ',
|
|
@@ -794,14 +810,14 @@ pairing_page <- function(origin, nonce = "") {
|
|
|
794
810
|
else
|
|
795
811
|
paste0('<h1>Run this book with your R?</h1><p>The published site</p>',
|
|
796
812
|
'<p class="site"><strong>', html_escape(origin), '</strong></p>',
|
|
797
|
-
'<p>wants to send R chunks to this
|
|
813
|
+
'<p>wants to send R chunks to this Beatrina session on your computer. ',
|
|
798
814
|
'The code will run as your user and can access your files and network.</p>'),
|
|
799
815
|
'<form method="get" action="/pair/approve"><input type="hidden" name="challenge" value="',
|
|
800
816
|
challenge, '"><button type="submit">',
|
|
801
817
|
if (is_local) 'Attach for this session' else 'Allow for this session',
|
|
802
818
|
'</button></form>',
|
|
803
819
|
'<p><small>Nothing runs until you press a chunk\'s Run button. ',
|
|
804
|
-
'Approval disappears when this
|
|
820
|
+
'Approval disappears when this Beatrina session stops.</small></p></body></html>')
|
|
805
821
|
}
|
|
806
822
|
|
|
807
823
|
pairing_bridge_page <- function(rec) {
|
|
@@ -813,10 +829,10 @@ pairing_bridge_page <- function(rec) {
|
|
|
813
829
|
'<meta http-equiv="Content-Security-Policy" content="default-src \'none\'; ',
|
|
814
830
|
"script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self' ws://127.0.0.1:",
|
|
815
831
|
port, "; base-uri 'none'\">",
|
|
816
|
-
'<title>
|
|
832
|
+
'<title>Beatrina connected</title><style>',
|
|
817
833
|
'body{font:16px/1.5 system-ui,sans-serif;max-width:38rem;margin:10vh auto;padding:0 1.25rem;color:#172033}',
|
|
818
834
|
'.site{overflow-wrap:anywhere;color:#2357d9}</style></head><body>',
|
|
819
|
-
'<h1>
|
|
835
|
+
'<h1>Beatrina is connected</h1><p>This session now runs chunks pressed on</p>',
|
|
820
836
|
'<p class="site"><strong>', html_escape(rec$origin), '</strong></p>',
|
|
821
837
|
'<p>The page connects by itself and closes this window; if it stays open, you can close it. ',
|
|
822
838
|
'The approval lasts until this R session stops.</p><script>(function(){',
|
|
@@ -825,15 +841,15 @@ pairing_bridge_page <- function(rec) {
|
|
|
825
841
|
'Object.assign({type,nonce,origin:target,port:', port, '},extra||{}),target)};',
|
|
826
842
|
'addEventListener("message",event=>{const data=event.data||{};',
|
|
827
843
|
'if(event.source!==window.opener||event.origin!==target||data.nonce!==nonce)return;',
|
|
828
|
-
'if(data.type==="
|
|
844
|
+
'if(data.type==="beatrina:bridge-attach"){const generation=++wsGeneration,old=ws;',
|
|
829
845
|
'if(old){old.onmessage=old.onerror=old.onclose=null;try{old.close()}catch(e){}}',
|
|
830
846
|
'ws=new WebSocket(socketUrl);const current=ws;',
|
|
831
|
-
'current.onmessage=message=>{if(generation===wsGeneration)tell("
|
|
832
|
-
'current.onerror=()=>{if(generation===wsGeneration)tell("
|
|
833
|
-
'current.onclose=()=>{if(generation===wsGeneration)tell("
|
|
834
|
-
'if(data.type==="
|
|
835
|
-
'if(data.type==="
|
|
836
|
-
'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>')
|
|
837
853
|
}
|
|
838
854
|
|
|
839
855
|
pairing_approval_page <- function(challenge) {
|
|
@@ -860,14 +876,14 @@ pairing_done_page <- function(rec) {
|
|
|
860
876
|
'<meta name="viewport" content="width=device-width,initial-scale=1">',
|
|
861
877
|
'<meta http-equiv="Content-Security-Policy" content="default-src \'none\'; ',
|
|
862
878
|
"style-src 'unsafe-inline'; base-uri 'none'\">",
|
|
863
|
-
'<title>
|
|
879
|
+
'<title>Beatrina attached</title><style>',
|
|
864
880
|
'body{font:16px/1.5 system-ui,sans-serif;max-width:38rem;margin:10vh auto;padding:0 1.25rem;color:#172033}',
|
|
865
881
|
'.site{overflow-wrap:anywhere;color:#2357d9}</style></head><body>',
|
|
866
882
|
'<h1>Attached</h1><p>The notebook</p>',
|
|
867
883
|
'<p class="site"><strong>',
|
|
868
884
|
if (identical(rec$origin, FILE_ORIGIN)) 'a page opened from disk (file://)' else html_escape(rec$origin),
|
|
869
885
|
'</strong></p>',
|
|
870
|
-
'<p>may use this
|
|
886
|
+
'<p>may use this Beatrina session until it stops. It reconnects by itself within ',
|
|
871
887
|
'a few seconds; you can close this window.</p></body></html>')
|
|
872
888
|
}
|
|
873
889
|
|
|
@@ -899,7 +915,7 @@ host_ok <- function(req) {
|
|
|
899
915
|
#'
|
|
900
916
|
#' Native same-user clients (the app launcher, R, curl, the MCP bridge) do not
|
|
901
917
|
#' send Origin or Sec-Fetch-Site and remain valid. Browsers do: a foreign page
|
|
902
|
-
#' 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,
|
|
903
919
|
#' stable localhost URL users expect.
|
|
904
920
|
control_rejection <- function(req, allow_file = FALSE, allow_approved = FALSE) {
|
|
905
921
|
origin <- req$HTTP_ORIGIN %||% ""
|
|
@@ -970,14 +986,14 @@ socket_class <- function(req) {
|
|
|
970
986
|
# returns quantiles, level counts and a histogram for ONE named column. It is
|
|
971
987
|
# strictly narrower than the `view` beside it — same resolution, less surface,
|
|
972
988
|
# no mutation — which is the bar a new entry has to clear, not "it is useful".
|
|
973
|
-
#' The ports of
|
|
989
|
+
#' The ports of Beatrina kernels that are actually answering right now.
|
|
974
990
|
#'
|
|
975
991
|
#' Read from the runtime directory rather than remembered, so sessions started
|
|
976
992
|
#' by the R package launcher count too — and health-checked, because a killed
|
|
977
993
|
#' kernel leaves its file behind and a dead session must not hold a slot.
|
|
978
994
|
live_session_ports <- function() {
|
|
979
|
-
dir <- Sys.getenv("
|
|
980
|
-
file.path(path.expand("~"), ".
|
|
995
|
+
dir <- Sys.getenv("BEATRINA_RUNTIME_DIR",
|
|
996
|
+
file.path(path.expand("~"), ".beatrina", "run"))
|
|
981
997
|
files <- list.files(dir, pattern = "^kernel-[0-9]+\\.json$", full.names = TRUE)
|
|
982
998
|
ports <- suppressWarnings(as.integer(sub("^kernel-([0-9]+)\\.json$", "\\1", basename(files))))
|
|
983
999
|
ports <- ports[!is.na(ports)]
|
|
@@ -1021,19 +1037,19 @@ start_sibling_session <- function() {
|
|
|
1021
1037
|
if (!(candidate %in% used)) { chosen <- candidate; break }
|
|
1022
1038
|
}
|
|
1023
1039
|
if (is.na(chosen)) return(NULL)
|
|
1024
|
-
# `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
|
|
1025
1041
|
# the port is still free when IT looks — between our probe and its start the
|
|
1026
1042
|
# port can go, and it then falls back to a random one. Waiting for the exact
|
|
1027
1043
|
# port we picked therefore reported failure for a session that had started
|
|
1028
1044
|
# perfectly well, and left it orphaned. So: remember what was live before,
|
|
1029
1045
|
# and accept whichever new port appears.
|
|
1030
1046
|
before <- live_session_ports()
|
|
1031
|
-
sib_log <- tryCatch(tempfile("
|
|
1047
|
+
sib_log <- tryCatch(tempfile("beatrina-session-", fileext = ".log"),
|
|
1032
1048
|
error = function(e) nullfile())
|
|
1033
1049
|
started <- tryCatch({
|
|
1034
1050
|
processx::process$new(file.path(R.home("bin"), "Rscript"),
|
|
1035
1051
|
c(file.path(here, "serve.R")),
|
|
1036
|
-
env = c(Sys.getenv(),
|
|
1052
|
+
env = c(Sys.getenv(), BEATRINA_PORT = as.character(chosen)),
|
|
1037
1053
|
# cleanup = FALSE or processx kills this child when
|
|
1038
1054
|
# `started` is garbage-collected, which is immediately:
|
|
1039
1055
|
# it is a local that dies with this function. The
|
|
@@ -1066,7 +1082,7 @@ start_sibling_session <- function() {
|
|
|
1066
1082
|
|
|
1067
1083
|
# ── the AI key lives HERE, not in the browser ────────────────────────────────
|
|
1068
1084
|
#
|
|
1069
|
-
# It used to sit in localStorage under `
|
|
1085
|
+
# It used to sit in localStorage under `beatrina-ai-key-kept`: in the browser's
|
|
1070
1086
|
# storage for this origin, readable by anything that can run script in the page,
|
|
1071
1087
|
# and one careless export away from being inside a notebook file. A provider key
|
|
1072
1088
|
# is not notebook content — it is a machine credential — so it belongs where the
|
|
@@ -1078,6 +1094,8 @@ start_sibling_session <- function() {
|
|
|
1078
1094
|
# Ops a declared MCP agent is refused outright: raw evaluation (it must go
|
|
1079
1095
|
# through a visible chunk_run) and the user's credentials.
|
|
1080
1096
|
AGENT_REFUSED <- c("exec", "interrupt", "force_stop", "restart", "session_upgrade", "session_restart", "debug_cmd", "ai-key",
|
|
1097
|
+
# A copy of every object in the session, written to disk.
|
|
1098
|
+
"suspend", "suspend_cancel",
|
|
1081
1099
|
# The user's AI conversations. An agent reading them would be
|
|
1082
1100
|
# reading every question the user has asked about their data,
|
|
1083
1101
|
# including the ones they asked about a different agent.
|
|
@@ -1123,8 +1141,8 @@ AGENT_REFUSED <- c("exec", "interrupt", "force_stop", "restart", "session_upgrad
|
|
|
1123
1141
|
# for the same reason FORWARDED and ANALYZE_FORWARDED stay two.
|
|
1124
1142
|
#
|
|
1125
1143
|
# Only a notebook page qualifies. A native client is refused even though it
|
|
1126
|
-
# could read the 0600 file itself as this user: the point is not that
|
|
1127
|
-
# 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
|
|
1128
1146
|
# holds for every agent instead of only the polite ones. A published site is
|
|
1129
1147
|
# refused because its reader approved it to RUN THE CHUNKS THEY PRESS, not to
|
|
1130
1148
|
# collect a credential on the way past.
|
|
@@ -1155,10 +1173,10 @@ AI_AUDIT_FIELDS <- c("provider", "model", "lane", "tool", "chunk", "decision",
|
|
|
1155
1173
|
# The prompt and the response themselves. OFF unless an administrator asks,
|
|
1156
1174
|
# because a notebook that silently recorded everything its user typed at an AI
|
|
1157
1175
|
# would be a worse thing to ship than no audit at all — the same judgement
|
|
1158
|
-
#
|
|
1176
|
+
# BEATRINA_LOG itself is off by default for. A compliance deployment turns it on
|
|
1159
1177
|
# knowingly; a laptop never does.
|
|
1160
1178
|
AI_AUDIT_TEXT_FIELDS <- c("prompt", "response")
|
|
1161
|
-
ai_audit_text <- identical(Sys.getenv("
|
|
1179
|
+
ai_audit_text <- identical(Sys.getenv("BEATRINA_LOG_AI_TEXT", ""), "1")
|
|
1162
1180
|
|
|
1163
1181
|
# How many ai-* records the reviewer export may read back at once.
|
|
1164
1182
|
AI_AUDIT_READ_MAX <- 5000L
|
|
@@ -1174,7 +1192,7 @@ ai_audit_scalar <- function(value, limit) {
|
|
|
1174
1192
|
}
|
|
1175
1193
|
|
|
1176
1194
|
ai_key_path <- function() {
|
|
1177
|
-
dir <- tools::R_user_dir("
|
|
1195
|
+
dir <- tools::R_user_dir("beatrina", "config")
|
|
1178
1196
|
dir.create(dir, recursive = TRUE, showWarnings = FALSE)
|
|
1179
1197
|
file.path(dir, "ai-key")
|
|
1180
1198
|
}
|
|
@@ -1221,7 +1239,7 @@ ai_key_write <- function(value) {
|
|
|
1221
1239
|
# user directory and never in the notebook file, an export, or localStorage —
|
|
1222
1240
|
# where any local page sharing the file:// origin could read the user's code.
|
|
1223
1241
|
#
|
|
1224
|
-
# Off entirely with
|
|
1242
|
+
# Off entirely with BEATRINA_NO_HISTORY=1, because "record everything I type"
|
|
1225
1243
|
# must be refusable.
|
|
1226
1244
|
HISTORY_MAX <- 2000L
|
|
1227
1245
|
# Consulted INSIDE history_read()/history_add() per call, never captured at the
|
|
@@ -1230,7 +1248,7 @@ HISTORY_MAX <- 2000L
|
|
|
1230
1248
|
HISTORY_ENABLED <- isTRUE(settings_value("history_enabled"))
|
|
1231
1249
|
|
|
1232
1250
|
history_path <- function() {
|
|
1233
|
-
dir <- tools::R_user_dir("
|
|
1251
|
+
dir <- tools::R_user_dir("beatrina", "data")
|
|
1234
1252
|
dir.create(dir, recursive = TRUE, showWarnings = FALSE)
|
|
1235
1253
|
file.path(dir, "console-history")
|
|
1236
1254
|
}
|
|
@@ -1275,7 +1293,7 @@ history_add <- function(line) {
|
|
|
1275
1293
|
|
|
1276
1294
|
# ── appearance, across the two origins the notebook is delivered from ────────
|
|
1277
1295
|
#
|
|
1278
|
-
#
|
|
1296
|
+
# Beatrina delivers the SAME notebook from a file:// page and from this kernel's
|
|
1279
1297
|
# own http://127.0.0.1:PORT, and browsers partition storage by origin — so a
|
|
1280
1298
|
# theme chosen at one door is invisible at the other. That is not a theory: it
|
|
1281
1299
|
# cost an afternoon on 2026-09-10, when an owner's Flat theme turned out to be
|
|
@@ -1298,41 +1316,41 @@ history_add <- function(line) {
|
|
|
1298
1316
|
# local HTML file shares the file:// origin, so without it `prefs` is a
|
|
1299
1317
|
# free-form key/value bag any of them could fill.
|
|
1300
1318
|
#
|
|
1301
|
-
# The values travel as the STRINGS localStorage holds.
|
|
1319
|
+
# The values travel as the STRINGS localStorage holds. beatrina-chunk-custom is
|
|
1302
1320
|
# itself JSON; re-parsing it here would make the kernel a party to a format
|
|
1303
1321
|
# only the page understands, and jsonlite would round its numbers on the way
|
|
1304
1322
|
# back out — the frame-fidelity lesson, one store further in.
|
|
1305
1323
|
#
|
|
1306
|
-
# Off entirely with
|
|
1324
|
+
# Off entirely with BEATRINA_NO_PREFS=1, for the same reason BEATRINA_NO_HISTORY
|
|
1307
1325
|
# exists: "keep a record of my choices on this machine" must be refusable. It
|
|
1308
1326
|
# is also what keeps the TEST BATTERY out of the real store — unlike the
|
|
1309
1327
|
# history, which only fills when somebody types, this publishes on every page
|
|
1310
1328
|
# connect, so any suite that boots a real page would otherwise seed the user's
|
|
1311
1329
|
# appearance with whatever its scratch page happened to hold. Set by
|
|
1312
1330
|
# tools/run-test-suite.mjs for every suite it runs.
|
|
1313
|
-
PREFS_ENABLED <- !identical(Sys.getenv("
|
|
1331
|
+
PREFS_ENABLED <- !identical(Sys.getenv("BEATRINA_NO_PREFS", ""), "1")
|
|
1314
1332
|
PREF_MAX_VALUE <- 4096L
|
|
1315
1333
|
PREF_KEYS <- c(
|
|
1316
|
-
"
|
|
1317
|
-
"
|
|
1318
|
-
"
|
|
1319
|
-
"
|
|
1320
|
-
"
|
|
1321
|
-
"
|
|
1322
|
-
"
|
|
1323
|
-
"
|
|
1324
|
-
"
|
|
1325
|
-
"
|
|
1326
|
-
"
|
|
1327
|
-
"
|
|
1328
|
-
"
|
|
1329
|
-
"
|
|
1330
|
-
"
|
|
1331
|
-
"
|
|
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
|
|
1332
1350
|
)
|
|
1333
1351
|
|
|
1334
1352
|
prefs_path <- function() {
|
|
1335
|
-
dir <- tools::R_user_dir("
|
|
1353
|
+
dir <- tools::R_user_dir("beatrina", "data")
|
|
1336
1354
|
dir.create(dir, recursive = TRUE, showWarnings = FALSE)
|
|
1337
1355
|
file.path(dir, "appearance.json")
|
|
1338
1356
|
}
|
|
@@ -1368,7 +1386,7 @@ prefs_write <- function(values) {
|
|
|
1368
1386
|
clean <- prefs_clean(values)
|
|
1369
1387
|
path <- prefs_path()
|
|
1370
1388
|
# Create, then lock, then write — the order the key and the history beside
|
|
1371
|
-
# 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
|
|
1372
1390
|
# in the user's data directory is the user's, and judging file by file which
|
|
1373
1391
|
# ones are harmless is how one of them eventually gets it wrong.
|
|
1374
1392
|
if (!file.exists(path) && !file.create(path)) return(FALSE)
|
|
@@ -1383,6 +1401,11 @@ FORWARDED <- c("env", "obj", "struct", "view", "colstats", "rm", "packages", "do
|
|
|
1383
1401
|
"package_action", "package_help", "project_status", "project_action", "help", "wd",
|
|
1384
1402
|
"parse", "complete", "files", "import", "readfile", "writefile", "writefiles_atomic",
|
|
1385
1403
|
"hover", "format", "sniff",
|
|
1404
|
+
# "Restart, keep variables", step one (spike/workspace-keep.R).
|
|
1405
|
+
# Page-only in both senses: AGENT_REFUSED above, the class gate
|
|
1406
|
+
# in handle_frame. Step two, `resume`, is deliberately NOT here —
|
|
1407
|
+
# only the supervisor asks it, after a restart it performed.
|
|
1408
|
+
"suspend",
|
|
1386
1409
|
# The file tree's New Folder, Rename and Delete. These MUTATE
|
|
1387
1410
|
# the filesystem, which is a wider door than the read-only ops
|
|
1388
1411
|
# around them, so the bar they clear is stated rather than
|
|
@@ -1416,7 +1439,7 @@ FORWARDED <- c("env", "obj", "struct", "view", "colstats", "rm", "packages", "do
|
|
|
1416
1439
|
# or reaches a process that cannot do it. Adding to EITHER list is a security
|
|
1417
1440
|
# decision (CLAUDE.md, "FORWARDED in serve.R is an allow-list").
|
|
1418
1441
|
# The third allow-list. These are answered by THIS process (fileio.R) and never
|
|
1419
|
-
# reach the worker's queue, unless
|
|
1442
|
+
# reach the worker's queue, unless BEATRINA_FILE_OPS=worker asks for the old
|
|
1420
1443
|
# routing. Kept apart from FORWARDED for the reason the first two are apart:
|
|
1421
1444
|
# "may the browser ask for this?" and "which process answers?" are different
|
|
1422
1445
|
# questions, and a file op's answer is "the one that is never busy".
|
|
@@ -1440,7 +1463,7 @@ SUPERVISOR_JOURNAL_OPS <- c("journal_open", "journal_append", "journal_checkpoin
|
|
|
1440
1463
|
# a conversation holds the questions you asked about your data plus the chunk
|
|
1441
1464
|
# sources those questions carried.
|
|
1442
1465
|
SUPERVISOR_AI_OPS <- c("ai_list", "ai_read", "ai_write", "ai_delete", "ai_purge", "ai_keys")
|
|
1443
|
-
FILE_OPS_IN_WORKER <- identical(Sys.getenv("
|
|
1466
|
+
FILE_OPS_IN_WORKER <- identical(Sys.getenv("BEATRINA_FILE_OPS", ""), "worker")
|
|
1444
1467
|
# The worker's working directory, as it last reported it (ready, wd and done
|
|
1445
1468
|
# frames). setwd() in a chunk moves it; a relative path from the page is
|
|
1446
1469
|
# resolved against THIS, never against the supervisor's own cwd.
|
|
@@ -1550,8 +1573,8 @@ app <- list(
|
|
|
1550
1573
|
# unauthenticated and CORS-open on purpose.
|
|
1551
1574
|
r = paste(R.version$major, R.version$minor, sep = "."),
|
|
1552
1575
|
started = as.numeric(sockets$boot_at),
|
|
1553
|
-
protocol =
|
|
1554
|
-
kernel_build =
|
|
1576
|
+
protocol = BEATRINA_PROTOCOL_VERSION,
|
|
1577
|
+
kernel_build = BEATRINA_KERNEL_BUILD,
|
|
1555
1578
|
# What is installed beside this kernel NOW, and
|
|
1556
1579
|
# how many pages hold it — the two facts an
|
|
1557
1580
|
# upgrade needs from a running session.
|
|
@@ -1568,7 +1591,7 @@ app <- list(
|
|
|
1568
1591
|
auto_unbox = TRUE),
|
|
1569
1592
|
extra = list("Access-Control-Allow-Origin" = "*")))
|
|
1570
1593
|
}
|
|
1571
|
-
# The custom
|
|
1594
|
+
# The custom beatrina:// handler calls this endpoint as a native same-user
|
|
1572
1595
|
# client. Browser requests carry Origin/Sec-Fetch-Site and are refused by
|
|
1573
1596
|
# control_rejection(), so visiting a site cannot silently grant itself R
|
|
1574
1597
|
# execution. One launch authorizes one exact origin for this kernel only.
|
|
@@ -1595,7 +1618,7 @@ app <- list(
|
|
|
1595
1618
|
nonce <- query_param(req$QUERY_STRING, "nonce")
|
|
1596
1619
|
# FILE_ORIGIN is accepted HERE and only here. `published_origin_valid()`
|
|
1597
1620
|
# stays strict (`^https?://…`) because it also guards
|
|
1598
|
-
# /published/authorize and the
|
|
1621
|
+
# /published/authorize and the BEATRINA_PUBLISHED_ORIGIN seed — widening it
|
|
1599
1622
|
# would let an environment variable or a native launch silently
|
|
1600
1623
|
# pre-approve every file on disk, which is the opposite of the point.
|
|
1601
1624
|
if (!identical(origin, FILE_ORIGIN) && !published_origin_valid(origin)) {
|
|
@@ -1614,7 +1637,7 @@ app <- list(
|
|
|
1614
1637
|
page <- pairing_approval_page(query_param(req$QUERY_STRING, "challenge"))
|
|
1615
1638
|
if (is.null(page)) {
|
|
1616
1639
|
audit("pair-rejected", detail = "missing or expired challenge")
|
|
1617
|
-
return(resp(400L, "text/plain", "This
|
|
1640
|
+
return(resp(400L, "text/plain", "This Beatrina approval request has expired."))
|
|
1618
1641
|
}
|
|
1619
1642
|
return(resp(200L, "text/html", page))
|
|
1620
1643
|
}
|
|
@@ -1625,12 +1648,12 @@ app <- list(
|
|
|
1625
1648
|
blocked <- control_rejection(req, allow_file = TRUE)
|
|
1626
1649
|
if (!is.null(blocked)) return(blocked)
|
|
1627
1650
|
audit("shutdown")
|
|
1628
|
-
message("
|
|
1651
|
+
message("Beatrina stopping: explicit Quit request.")
|
|
1629
1652
|
sockets$quit <- TRUE
|
|
1630
1653
|
return(resp(200L, "application/json",
|
|
1631
1654
|
toJSON(list(ok = TRUE, stopping = TRUE), auto_unbox = TRUE)))
|
|
1632
1655
|
}
|
|
1633
|
-
# The native door of "Restart into the installed
|
|
1656
|
+
# The native door of "Restart into the installed Beatrina" — the menu
|
|
1634
1657
|
# helper's row and the upgrade script use it (launch.sh --restart). Gated
|
|
1635
1658
|
# exactly like /shutdown; the page's own door is the session_upgrade op.
|
|
1636
1659
|
if (identical(req$PATH_INFO, "/session/upgrade")) {
|
|
@@ -1678,7 +1701,7 @@ app <- list(
|
|
|
1678
1701
|
# document reaches a kernel that is ALREADY RUNNING, and it is the reason
|
|
1679
1702
|
# opening a file no longer costs an R session.
|
|
1680
1703
|
#
|
|
1681
|
-
# The other channel —
|
|
1704
|
+
# The other channel — BEATRINA_OPEN_FILE in the supervisor's environment —
|
|
1682
1705
|
# is read once at startup, so the only way to deliver a document through
|
|
1683
1706
|
# it is to start a new process. That is precisely what the launcher used
|
|
1684
1707
|
# to do: every double-click was a fresh supervisor, worker and analyzer,
|
|
@@ -1777,7 +1800,7 @@ app <- list(
|
|
|
1777
1800
|
if (identical(method, "OPTIONS")) {
|
|
1778
1801
|
return(resp(204L, "text/plain", "", extra = c(cors, list(
|
|
1779
1802
|
"Access-Control-Allow-Methods" = "GET, PUT, OPTIONS",
|
|
1780
|
-
"Access-Control-Allow-Headers" = "X-
|
|
1803
|
+
"Access-Control-Allow-Headers" = "X-Beatrina-Cap, Content-Type, X-Beatrina-Expected-Bytes",
|
|
1781
1804
|
"Access-Control-Max-Age" = "60"))))
|
|
1782
1805
|
}
|
|
1783
1806
|
if (identical(method, "GET")) {
|
|
@@ -1795,7 +1818,7 @@ app <- list(
|
|
|
1795
1818
|
"Content-Length" = as.character(length(bytes))), cors)))
|
|
1796
1819
|
}
|
|
1797
1820
|
if (identical(method, "PUT")) {
|
|
1798
|
-
grant <- file_cap_take(req$
|
|
1821
|
+
grant <- file_cap_take(req$HTTP_X_BEATRINA_CAP %||% "", "write")
|
|
1799
1822
|
if (is.null(grant) || !within_root(grant$path)) {
|
|
1800
1823
|
audit("files-refused", detail = "put")
|
|
1801
1824
|
return(resp(403L, "text/plain", "no capability for that write", extra = cors))
|
|
@@ -1813,7 +1836,7 @@ app <- list(
|
|
|
1813
1836
|
}
|
|
1814
1837
|
expected <- NULL
|
|
1815
1838
|
if (identical(grant$compare, "utf8-prefix-v1")) {
|
|
1816
|
-
prefix <- req$
|
|
1839
|
+
prefix <- req$HTTP_X_BEATRINA_EXPECTED_BYTES %||% ""
|
|
1817
1840
|
n <- suppressWarnings(as.numeric(prefix))
|
|
1818
1841
|
if (!grepl("^[0-9]+$", prefix) || !is.finite(n) || n > length(body)) {
|
|
1819
1842
|
return(resp(400L, "text/plain", "missing or invalid expected-content prefix", extra = cors))
|
|
@@ -1850,7 +1873,7 @@ app <- list(
|
|
|
1850
1873
|
# the Rook environment belongs to the request that opened this socket and
|
|
1851
1874
|
# cannot change under it.
|
|
1852
1875
|
rec$class <- socket_class(ws$request)
|
|
1853
|
-
# Who the reverse proxy says this is. "" on loopback, always —
|
|
1876
|
+
# Who the reverse proxy says this is. "" on loopback, always — Beatrina has
|
|
1854
1877
|
# no login of its own and must not grow one here: a header is an identity
|
|
1855
1878
|
# only for as long as nobody but the proxy can reach the port, which is a
|
|
1856
1879
|
# property of the deployment, not of this process.
|
|
@@ -1865,6 +1888,10 @@ app <- list(
|
|
|
1865
1888
|
audit("socket-open", sockets = length(sockets$open), class = rec$class,
|
|
1866
1889
|
user = rec$user)
|
|
1867
1890
|
if (!is.null(sockets$hello)) try(ws$send(sockets$hello), silent = TRUE)
|
|
1891
|
+
if (!is.null(sockets$resume_report) && isTRUE(rec$class %in% PAGE_ONLY_CLASSES)
|
|
1892
|
+
&& as.numeric(difftime(Sys.time(), sockets$resume_report$at, units = "secs")) < 120) {
|
|
1893
|
+
try(ws$send(sockets$resume_report$payload), silent = TRUE)
|
|
1894
|
+
}
|
|
1868
1895
|
# …and the give-up, when there is one. A page that arrives after the
|
|
1869
1896
|
# respawn ceiling has no ready frame coming and would otherwise sit on
|
|
1870
1897
|
# "connecting…" forever with nothing on screen saying why.
|
|
@@ -1964,17 +1991,17 @@ target_page <- function() {
|
|
|
1964
1991
|
# the CLI's stream-json lines back to the asking page verbatim, and that is
|
|
1965
1992
|
# the whole exchange. Credentials are never read, stored, or proxied.
|
|
1966
1993
|
#
|
|
1967
|
-
# The spawned session gets the
|
|
1994
|
+
# The spawned session gets the beatrina MCP tools, pinned to THIS kernel via a
|
|
1968
1995
|
# 0600 config file (never argv — argv is visible to every local user in ps),
|
|
1969
1996
|
# and the document tools. The embedded Claude process is a document agent, not
|
|
1970
1997
|
# a chatbot that prints a draft for the user to move by hand. Inserts and
|
|
1971
1998
|
# replacements cross the page's revision/contract gateway and remain
|
|
1972
1999
|
# provisional with Keep/Reject; runs use the ordinary visible chunk path.
|
|
1973
2000
|
AGENT_CHAT_TOOLS <- paste(
|
|
1974
|
-
"
|
|
1975
|
-
"
|
|
1976
|
-
"
|
|
1977
|
-
"
|
|
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",
|
|
1978
2005
|
sep = ",")
|
|
1979
2006
|
MAX_CHATS <- 3L
|
|
1980
2007
|
|
|
@@ -2029,7 +2056,7 @@ chat_start <- function(rec, cmd) {
|
|
|
2029
2056
|
server <- mcp_server_path()
|
|
2030
2057
|
node <- find_binary("node")
|
|
2031
2058
|
if (identical(client, "codex")) {
|
|
2032
|
-
cfg <- tempfile("
|
|
2059
|
+
cfg <- tempfile("beatrina-codex-")
|
|
2033
2060
|
dir.create(cfg, mode = "0700")
|
|
2034
2061
|
toml_str <- function(x) as.character(toJSON(x, auto_unbox = TRUE))
|
|
2035
2062
|
args <- c("exec", "--json", "--ephemeral", "--skip-git-repo-check",
|
|
@@ -2044,9 +2071,9 @@ chat_start <- function(rec, cmd) {
|
|
|
2044
2071
|
}
|
|
2045
2072
|
if (!text_only && nzchar(server) && nzchar(node)) {
|
|
2046
2073
|
args <- c(args,
|
|
2047
|
-
"-c", paste0("mcp_servers.
|
|
2048
|
-
"-c", paste0("mcp_servers.
|
|
2049
|
-
"-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, "/")), "}"))
|
|
2050
2077
|
}
|
|
2051
2078
|
args <- c(args, "-") # the prompt arrives on stdin
|
|
2052
2079
|
} else {
|
|
@@ -2062,10 +2089,10 @@ chat_start <- function(rec, cmd) {
|
|
|
2062
2089
|
args <- c(args, "--tools", "", "--mcp-config", '{"mcpServers":{}}',
|
|
2063
2090
|
"--strict-mcp-config", "--setting-sources", "")
|
|
2064
2091
|
} else if (nzchar(server) && nzchar(node)) {
|
|
2065
|
-
cfg <- tempfile("
|
|
2066
|
-
writeLines(toJSON(list(mcpServers = list(
|
|
2092
|
+
cfg <- tempfile("beatrina-chat-", fileext = ".json")
|
|
2093
|
+
writeLines(toJSON(list(mcpServers = list(beatrina = list(
|
|
2067
2094
|
command = node, args = I(server),
|
|
2068
|
-
env = list(
|
|
2095
|
+
env = list(BEATRINA_MCP_URL = paste0(origin_ok, "/"))
|
|
2069
2096
|
))), auto_unbox = TRUE), cfg)
|
|
2070
2097
|
Sys.chmod(cfg, mode = "0600")
|
|
2071
2098
|
args <- c(args, "--mcp-config", cfg, "--strict-mcp-config",
|
|
@@ -2150,7 +2177,7 @@ pump_chats <- function() {
|
|
|
2150
2177
|
#' Every kernel this user is running, from the runtime records, health-checked.
|
|
2151
2178
|
#' @return An unnamed list of `list(port, url, title, pid, alive, this)`.
|
|
2152
2179
|
session_list <- function() {
|
|
2153
|
-
dir <- Sys.getenv("
|
|
2180
|
+
dir <- Sys.getenv("BEATRINA_RUNTIME_DIR", file.path(path.expand("~"), ".beatrina", "run"))
|
|
2154
2181
|
files <- list.files(dir, pattern = "^kernel-[0-9]+\\.json$", full.names = TRUE)
|
|
2155
2182
|
out <- lapply(files, function(f) {
|
|
2156
2183
|
rec <- tryCatch(jsonlite::fromJSON(f, simplifyVector = TRUE), error = function(e) NULL)
|
|
@@ -2229,7 +2256,7 @@ r_versions <- function() {
|
|
|
2229
2256
|
# the MCP plane refuses, wearing a prompt.
|
|
2230
2257
|
#
|
|
2231
2258
|
# What the shell can do is what the user can do; this grants nothing new and
|
|
2232
|
-
# is audited like every other door. `
|
|
2259
|
+
# is audited like every other door. `BEATRINA_NO_TERMINAL=1` removes it (a
|
|
2233
2260
|
# deployment that must not offer a shell); Windows has no pty here and says so.
|
|
2234
2261
|
MAX_TERMS <- 4L
|
|
2235
2262
|
TERM_INPUT_MAX <- 65536L
|
|
@@ -2256,11 +2283,11 @@ term_open <- function(rec, cmd) {
|
|
|
2256
2283
|
term_send(rec, "", event = "refused", error = why)
|
|
2257
2284
|
invisible(NULL)
|
|
2258
2285
|
}
|
|
2259
|
-
if (identical(Sys.getenv("
|
|
2260
|
-
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)."))
|
|
2261
2288
|
}
|
|
2262
2289
|
if (.Platform$OS.type == "windows") {
|
|
2263
|
-
return(refuse("The terminal needs a pty, which
|
|
2290
|
+
return(refuse("The terminal needs a pty, which Beatrina does not provide on Windows yet."))
|
|
2264
2291
|
}
|
|
2265
2292
|
mine <- Filter(function(t) identical(t$rec, rec), mget(ls(sockets$terms), envir = sockets$terms))
|
|
2266
2293
|
if (length(mine) >= MAX_TERMS) {
|
|
@@ -2281,7 +2308,7 @@ term_open <- function(rec, cmd) {
|
|
|
2281
2308
|
env = c("current", TERM = "xterm-256color",
|
|
2282
2309
|
COLORTERM = "truecolor",
|
|
2283
2310
|
COLUMNS = as.character(cols), LINES = as.character(rows),
|
|
2284
|
-
|
|
2311
|
+
BEATRINA_PORT = as.character(port))),
|
|
2285
2312
|
error = function(e) conditionMessage(e))
|
|
2286
2313
|
if (is.character(proc)) return(refuse(paste("the shell could not start:", proc)))
|
|
2287
2314
|
sockets$term_seq <- sockets$term_seq + 1L
|
|
@@ -2374,7 +2401,7 @@ JOB_LOG_CHARS <- 1000000L
|
|
|
2374
2401
|
# handed the token, and short enough that a token in a stray log is dead by
|
|
2375
2402
|
# the time anyone reads it. Tests shorten it.
|
|
2376
2403
|
OUTPUT_CAP_TTL <- local({
|
|
2377
|
-
given <- suppressWarnings(as.numeric(Sys.getenv("
|
|
2404
|
+
given <- suppressWarnings(as.numeric(Sys.getenv("BEATRINA_OUTPUT_CAP_TTL", "")))
|
|
2378
2405
|
if (is.finite(given) && given >= 1 && given <= 3600) given else 60
|
|
2379
2406
|
})
|
|
2380
2407
|
OUTPUT_CAP_MAX_BYTES <- 128 * 1024 * 1024
|
|
@@ -2412,7 +2439,7 @@ output_cap_sweep <- function() {
|
|
|
2412
2439
|
# anything /health discloses; for PUT it travels in a header, never the query.
|
|
2413
2440
|
FILE_CAP_TTL <- OUTPUT_CAP_TTL
|
|
2414
2441
|
FILE_UPLOAD_MAX <- local({
|
|
2415
|
-
given <- suppressWarnings(as.numeric(Sys.getenv("
|
|
2442
|
+
given <- suppressWarnings(as.numeric(Sys.getenv("BEATRINA_UPLOAD_MAX", "")))
|
|
2416
2443
|
if (is.finite(given) && given >= 1e6 && given <= 2e9) given else 64 * 1024 * 1024
|
|
2417
2444
|
})
|
|
2418
2445
|
sockets$file_caps <- list()
|
|
@@ -2549,7 +2576,7 @@ job_start <- function(rec, cmd) {
|
|
|
2549
2576
|
# settings under both, so a quarto_path set in Settings reaches the render
|
|
2550
2577
|
# while a job that names its own still wins.
|
|
2551
2578
|
kernel_start(file.path(here, "job-run.R"), rscript = settings_rscript(),
|
|
2552
|
-
env_extra = c(settings_child_env(), spec$env,
|
|
2579
|
+
env_extra = c(settings_child_env(), spec$env, BEATRINA_JOB_ID = id)),
|
|
2553
2580
|
error = function(e) conditionMessage(e))
|
|
2554
2581
|
if (is.character(started)) {
|
|
2555
2582
|
audit("job-start-failed", detail = started)
|
|
@@ -2563,7 +2590,7 @@ job_start <- function(rec, cmd) {
|
|
|
2563
2590
|
job$task <- spec$task
|
|
2564
2591
|
job$root <- spec$root
|
|
2565
2592
|
job$target <- spec$target
|
|
2566
|
-
identity_text <- spec$env["
|
|
2593
|
+
identity_text <- spec$env["BEATRINA_LATEX_IDENTITY"]
|
|
2567
2594
|
job$identity <- if (!is.na(identity_text) && nzchar(identity_text)) fromJSON(identity_text, simplifyVector = FALSE) else NULL
|
|
2568
2595
|
job$output <- NA_character_
|
|
2569
2596
|
job$title <- job_title(spec)
|
|
@@ -2737,7 +2764,7 @@ update_runner_spec <- function(script, args = character(),
|
|
|
2737
2764
|
}
|
|
2738
2765
|
|
|
2739
2766
|
update_script_path <- function() {
|
|
2740
|
-
script <- trimws(Sys.getenv("
|
|
2767
|
+
script <- trimws(Sys.getenv("BEATRINA_UPDATE_SCRIPT", ""))
|
|
2741
2768
|
if (!isTRUE(deployment$loopback) || !nzchar(script) || !file.exists(script)) return("")
|
|
2742
2769
|
script <- normalizePath(script, winslash = "/", mustWork = TRUE)
|
|
2743
2770
|
if (is.null(update_runner_spec(script))) return("")
|
|
@@ -2780,7 +2807,7 @@ update_job_busy <- function() {
|
|
|
2780
2807
|
update_status_state <- function() {
|
|
2781
2808
|
script <- update_script_path()
|
|
2782
2809
|
if (!nzchar(script)) return(list(
|
|
2783
|
-
supported = FALSE, state = "unavailable", current =
|
|
2810
|
+
supported = FALSE, state = "unavailable", current = BEATRINA_KERNEL_BUILD,
|
|
2784
2811
|
offered = "", previous = "", busy = FALSE, action = "",
|
|
2785
2812
|
can_check = FALSE, can_update = FALSE, can_defer = FALSE,
|
|
2786
2813
|
can_rollback = FALSE,
|
|
@@ -2789,7 +2816,7 @@ update_status_state <- function() {
|
|
|
2789
2816
|
|
|
2790
2817
|
busy <- update_job_busy()
|
|
2791
2818
|
if (busy) return(list(
|
|
2792
|
-
supported = TRUE, state = "checking", current =
|
|
2819
|
+
supported = TRUE, state = "checking", current = BEATRINA_KERNEL_BUILD,
|
|
2793
2820
|
offered = "", previous = "", busy = TRUE, action = update_job$action,
|
|
2794
2821
|
can_check = FALSE, can_update = FALSE, can_defer = FALSE,
|
|
2795
2822
|
can_rollback = FALSE,
|
|
@@ -3072,7 +3099,7 @@ worker_deadline_check <- function() {
|
|
|
3072
3099
|
sockets$worker_routes[[wire_id]] else NULL
|
|
3073
3100
|
reason <- sprintf(
|
|
3074
3101
|
paste("R did not answer `%s` within %d seconds. The session and its variables are",
|
|
3075
|
-
"intact; only this request was abandoned. If it keeps happening, this
|
|
3102
|
+
"intact; only this request was abandoned. If it keeps happening, this Beatrina",
|
|
3076
3103
|
"may be newer than the R session it is talking to — restart R."),
|
|
3077
3104
|
type, as.integer(round(limit)))
|
|
3078
3105
|
audit("worker-deadline", detail = type, id = wire_id,
|
|
@@ -3092,7 +3119,7 @@ worker_deadline_check <- function() {
|
|
|
3092
3119
|
# ── restart into the installed build ───────────────────────────────────────
|
|
3093
3120
|
#
|
|
3094
3121
|
# An upgrade swaps the bundle IN PLACE, so the kernel-version stamp beside
|
|
3095
|
-
# this script names the installed build while
|
|
3122
|
+
# this script names the installed build while BEATRINA_KERNEL_BUILD names the
|
|
3096
3123
|
# one running. The two disagreeing is the one honest signal that a session
|
|
3097
3124
|
# is stale, and this is the one door through it: start a SUCCESSOR
|
|
3098
3125
|
# supervisor from the installed files on this same port, tell every page
|
|
@@ -3128,13 +3155,13 @@ handoff_refusal <- function(mode = "upgrade", force = FALSE) {
|
|
|
3128
3155
|
refuse <- function(error, reason) list(error = error, reason = reason)
|
|
3129
3156
|
installed <- installed_build()
|
|
3130
3157
|
if (identical(installed, "unknown")) {
|
|
3131
|
-
return(refuse("This kernel cannot tell which
|
|
3158
|
+
return(refuse("This kernel cannot tell which Beatrina is installed.", "unknown-install"))
|
|
3132
3159
|
}
|
|
3133
|
-
if (identical(mode, "upgrade") && identical(installed,
|
|
3134
|
-
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"))
|
|
3135
3162
|
}
|
|
3136
|
-
if (!nzchar(
|
|
3137
|
-
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"))
|
|
3138
3165
|
}
|
|
3139
3166
|
# Force never spawns a second successor for one port: an attempt that has
|
|
3140
3167
|
# spawned owns the port until this process exits.
|
|
@@ -3153,7 +3180,7 @@ handoff_refusal <- function(mode = "upgrade", force = FALSE) {
|
|
|
3153
3180
|
#' kernel, with this port and a capability minted here so it can be told
|
|
3154
3181
|
#' to the page NOW and honoured by the successor later.
|
|
3155
3182
|
handoff_page_url <- function(installed, cap) {
|
|
3156
|
-
page_file <- normalizePath(
|
|
3183
|
+
page_file <- normalizePath(beatrina_notebook_page(here, installed))
|
|
3157
3184
|
paste0("file://", utils::URLencode(page_file, reserved = FALSE),
|
|
3158
3185
|
"#kernel=", port, "&pair=", cap)
|
|
3159
3186
|
}
|
|
@@ -3166,9 +3193,11 @@ handoff_page_url <- function(installed, cap) {
|
|
|
3166
3193
|
#' only ever mean "spawned, exiting".
|
|
3167
3194
|
#' @param mode "upgrade" or "restart".
|
|
3168
3195
|
#' @param force Kill a busy worker rather than refuse (restart only).
|
|
3196
|
+
#' @param resume A kept-workspace token (spike/workspace-keep.R) the successor's
|
|
3197
|
+
#' first worker restores, or NULL for a fresh R.
|
|
3169
3198
|
#' @return The reply the caller sends: ok/from/to/page/started/mode/force,
|
|
3170
3199
|
#' or ok = FALSE with error and reason.
|
|
3171
|
-
session_handoff_begin <- function(mode = "upgrade", force = FALSE) {
|
|
3200
|
+
session_handoff_begin <- function(mode = "upgrade", force = FALSE, resume = NULL) {
|
|
3172
3201
|
mode <- if (identical(mode, "restart")) "restart" else "upgrade"
|
|
3173
3202
|
force <- isTRUE(force) && identical(mode, "restart")
|
|
3174
3203
|
audit_name <- if (identical(mode, "restart")) "session-restart" else "session-upgrade"
|
|
@@ -3181,18 +3210,20 @@ session_handoff_begin <- function(mode = "upgrade", force = FALSE) {
|
|
|
3181
3210
|
cap <- secure_token(32L)
|
|
3182
3211
|
page <- handoff_page_url(installed, cap)
|
|
3183
3212
|
sockets$handoff <- list(to = installed, cap = cap, page = page,
|
|
3184
|
-
started = Sys.time(), mode = mode, force = force
|
|
3185
|
-
|
|
3213
|
+
started = Sys.time(), mode = mode, force = force,
|
|
3214
|
+
resume = if (keep_token_ok(resume)) resume else NULL)
|
|
3215
|
+
audit(audit_name, from = BEATRINA_KERNEL_BUILD, to = installed, force = force)
|
|
3186
3216
|
# Every page learns where its successor's notebook is — over the gated
|
|
3187
3217
|
# socket only; the capability never appears in /health. `started` is this
|
|
3188
3218
|
# kernel's boot time: the page recognises its successor by a LATER one,
|
|
3189
3219
|
# which is the only test that also works when the build does not change.
|
|
3190
|
-
told <- toJSON(list(type = "session-upgrade", from =
|
|
3220
|
+
told <- toJSON(list(type = "session-upgrade", from = BEATRINA_KERNEL_BUILD,
|
|
3191
3221
|
to = installed, page = page, started = as.numeric(sockets$boot_at),
|
|
3192
|
-
mode = mode, force = force
|
|
3222
|
+
mode = mode, force = force,
|
|
3223
|
+
keep = !is.null(sockets$handoff$resume)), auto_unbox = TRUE)
|
|
3193
3224
|
lapply(page_recs(), function(r) try(r$ws$send(told), silent = TRUE))
|
|
3194
3225
|
session_handoff_spawn()
|
|
3195
|
-
list(ok = TRUE, from =
|
|
3226
|
+
list(ok = TRUE, from = BEATRINA_KERNEL_BUILD, to = installed, page = page,
|
|
3196
3227
|
started = as.numeric(sockets$boot_at), mode = mode, force = force)
|
|
3197
3228
|
}
|
|
3198
3229
|
|
|
@@ -3216,15 +3247,18 @@ session_handoff_spawn <- function() {
|
|
|
3216
3247
|
state <- state_dir()
|
|
3217
3248
|
dir.create(state, recursive = TRUE, showWarnings = FALSE)
|
|
3218
3249
|
env <- Sys.getenv()
|
|
3219
|
-
env[["
|
|
3220
|
-
env[["
|
|
3250
|
+
env[["BEATRINA_PORT"]] <- as.character(port)
|
|
3251
|
+
env[["BEATRINA_WAIT_PORT"]] <- "30"
|
|
3221
3252
|
# A successor that quietly moved to a free port would leave the page
|
|
3222
3253
|
# polling a dead one for 90 s; strict means it refuses and its log says why.
|
|
3223
|
-
env[["
|
|
3224
|
-
env[["
|
|
3225
|
-
env[["
|
|
3226
|
-
env[["
|
|
3227
|
-
|
|
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
|
+
# Only a token travels, never a path; the successor re-checks it.
|
|
3260
|
+
env[["BEATRINA_RESUME_TOKEN"]] <- h$resume %||% ""
|
|
3261
|
+
if (isTRUE(runtime_record$listen)) env[["BEATRINA_LISTEN"]] <- "1"
|
|
3228
3262
|
# The same R this supervisor runs on, and the serve.R beside THIS file —
|
|
3229
3263
|
# which, after the in-place swap, is the installed build's.
|
|
3230
3264
|
rscript <- file.path(R.home("bin"), "Rscript")
|
|
@@ -3233,7 +3267,7 @@ session_handoff_spawn <- function() {
|
|
|
3233
3267
|
rscript, file.path(here, "serve.R"), env = env,
|
|
3234
3268
|
stdout = log, stderr = "2>&1", cleanup = FALSE), error = function(e) NULL)
|
|
3235
3269
|
if (is.null(started) || !started$is_alive()) {
|
|
3236
|
-
return(handoff_abandon("The installed
|
|
3270
|
+
return(handoff_abandon("The installed Beatrina could not be started."))
|
|
3237
3271
|
}
|
|
3238
3272
|
audit("session-upgrade-spawned", to = h$to, pid = started$get_pid(), mode = h$mode %||% "upgrade")
|
|
3239
3273
|
# Spawn first, kill second: the old worker's whole tree goes down only once
|
|
@@ -3480,6 +3514,32 @@ route_internal <- function(cmd, on_reply) {
|
|
|
3480
3514
|
list(cmd = cmd, wire_id = wire_id)
|
|
3481
3515
|
}
|
|
3482
3516
|
|
|
3517
|
+
#' Ask the fresh worker to restore a kept workspace, and tell every page what
|
|
3518
|
+
#' came back. Queued like any command, so a chunk run pressed meanwhile waits
|
|
3519
|
+
#' for the restore rather than racing it.
|
|
3520
|
+
#' @return Invisibly NULL.
|
|
3521
|
+
start_resume <- function() {
|
|
3522
|
+
token <- sockets$resume_pending
|
|
3523
|
+
sockets$resume_pending <- NULL
|
|
3524
|
+
if (!keep_token_ok(token)) return(invisible(NULL))
|
|
3525
|
+
audit("resume-start")
|
|
3526
|
+
routed <- route_internal(list(type = "resume", token = token), on_reply = function(frame) {
|
|
3527
|
+
if (scalar_chr(frame$cwd)) sockets$worker_wd <- frame$cwd
|
|
3528
|
+
report <- list(type = "workspace_restored",
|
|
3529
|
+
restored = frame$restored %||% 0L,
|
|
3530
|
+
failed = frame$failed %||% list(), skipped = frame$skipped %||% list(),
|
|
3531
|
+
wd_missing = frame$wd_missing %||% NULL, error = frame$error %||% NULL)
|
|
3532
|
+
payload <- toJSON(report, auto_unbox = TRUE, null = "null")
|
|
3533
|
+
sockets$resume_report <- list(at = Sys.time(), payload = payload)
|
|
3534
|
+
audit("resume-done", restored = report$restored, failed = length(report$failed),
|
|
3535
|
+
error = !is.null(report$error))
|
|
3536
|
+
pages <- Filter(function(r) isTRUE(r$class %in% PAGE_ONLY_CLASSES), page_recs())
|
|
3537
|
+
lapply(pages, function(r) try(r$ws$send(payload), silent = TRUE))
|
|
3538
|
+
})
|
|
3539
|
+
enqueue_worker_command(routed$cmd, routed$wire_id)
|
|
3540
|
+
invisible(NULL)
|
|
3541
|
+
}
|
|
3542
|
+
|
|
3483
3543
|
#' Tell every page, and REMEMBER it for the pages that are not here yet.
|
|
3484
3544
|
#'
|
|
3485
3545
|
#' `sockets$hello` exists because the worker announces itself once, long
|
|
@@ -3503,8 +3563,8 @@ recover_execution_worker <- function() {
|
|
|
3503
3563
|
sockets$worker_restart_after <- 0
|
|
3504
3564
|
sockets$hello <- NULL
|
|
3505
3565
|
fail_worker_routes(if (isTRUE(sockets$force_stop_asked))
|
|
3506
|
-
"R was force-stopped. Its variables and loaded packages are gone;
|
|
3507
|
-
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.")
|
|
3508
3568
|
rm(list = ls(sockets$running), envir = sockets$running)
|
|
3509
3569
|
rm(list = ls(sockets$worker_routes), envir = sockets$worker_routes)
|
|
3510
3570
|
sockets$worker_queue <- list()
|
|
@@ -3531,7 +3591,7 @@ recover_execution_worker <- function() {
|
|
|
3531
3591
|
sockets$worker_gave_up <- TRUE
|
|
3532
3592
|
sockets$worker_recovering <- FALSE
|
|
3533
3593
|
reason <- sprintf(
|
|
3534
|
-
paste("R would not start after %d attempts.
|
|
3594
|
+
paste("R would not start after %d attempts. Beatrina is still running — the notebook,",
|
|
3535
3595
|
"your text and Save all work — but nothing can be run until R is fixed.",
|
|
3536
3596
|
"A broken .Rprofile or a moved R installation is the usual cause; the kernel",
|
|
3537
3597
|
"log beside this session has R's own message. Restart R to try again."),
|
|
@@ -3670,8 +3730,36 @@ handle_frame <- function(message, rec) {
|
|
|
3670
3730
|
return(invisible(NULL))
|
|
3671
3731
|
}
|
|
3672
3732
|
|
|
3733
|
+
# The documents this session has open (spike/session-documents.R). Page-only
|
|
3734
|
+
# in BOTH senses: the list is file paths, and a page that could not open a
|
|
3735
|
+
# terminal must not be able to rewrite what the session reopens either.
|
|
3736
|
+
# Replies nothing; the helper's label follows through the runtime record.
|
|
3737
|
+
if (identical(cmd$type, "page-documents")) {
|
|
3738
|
+
if (identical(rec$role, "page") && isTRUE(rec$class %in% PAGE_ONLY_CLASSES)) {
|
|
3739
|
+
sockets$session_documents <- session_documents_clean(cmd$documents)
|
|
3740
|
+
set_runtime_documents(sockets$session_documents)
|
|
3741
|
+
}
|
|
3742
|
+
return(invisible(NULL))
|
|
3743
|
+
}
|
|
3744
|
+
|
|
3745
|
+
# A page asks what this session had open, to reopen it. `others` counts the
|
|
3746
|
+
# OTHER pages attached: a page joining a session another window is showing
|
|
3747
|
+
# must not open the same documents a second time (that window holds their
|
|
3748
|
+
# history leases), so the page reopens only when it is alone.
|
|
3749
|
+
if (identical(cmd$type, "session-documents")) {
|
|
3750
|
+
if (!scalar_chr(cmd$id)) return(invisible(NULL))
|
|
3751
|
+
allowed <- identical(rec$role, "page") && isTRUE(rec$class %in% PAGE_ONLY_CLASSES)
|
|
3752
|
+
others <- Filter(function(r) !identical(r$ws, rec$ws), page_recs())
|
|
3753
|
+
try(rec$ws$send(toJSON(list(
|
|
3754
|
+
type = "session-documents", id = cmd$id,
|
|
3755
|
+
documents = if (allowed) sockets$session_documents else list(),
|
|
3756
|
+
others = length(others),
|
|
3757
|
+
refused = !allowed), auto_unbox = TRUE)), silent = TRUE)
|
|
3758
|
+
return(invisible(NULL))
|
|
3759
|
+
}
|
|
3760
|
+
|
|
3673
3761
|
# ── the MCP plane ─────────────────────────────────────────────────────────
|
|
3674
|
-
# A local agent (Claude Code / Codex via tools/mcp/
|
|
3762
|
+
# A local agent (Claude Code / Codex via tools/mcp/beatrina-mcp.mjs) connects
|
|
3675
3763
|
# through the same loopback/Origin/Host gates as a page, then DECLARES itself.
|
|
3676
3764
|
# From then on it may only ASK (mcp-request, routed to the active notebook
|
|
3677
3765
|
# window) and use the read-side FORWARDED ops; pages may only ANSWER.
|
|
@@ -3694,7 +3782,7 @@ handle_frame <- function(message, rec) {
|
|
|
3694
3782
|
return(invisible(NULL))
|
|
3695
3783
|
}
|
|
3696
3784
|
if (identical(cmd$type, "open-request")) {
|
|
3697
|
-
# The double-clicked document parked by the launcher (
|
|
3785
|
+
# The double-clicked document parked by the launcher (BEATRINA_OPEN_FILE).
|
|
3698
3786
|
# Pages only — an agent must never consume it — and consume-once: the
|
|
3699
3787
|
# first page to ask gets the path, everyone after gets "". An empty
|
|
3700
3788
|
# string, not a JSON null, because jsonlite renders NULL as a truthy {}.
|
|
@@ -3719,7 +3807,7 @@ handle_frame <- function(message, rec) {
|
|
|
3719
3807
|
audit("mcp-request", id = cmd$id, detail = cmd$tool)
|
|
3720
3808
|
if (is.null(page)) {
|
|
3721
3809
|
reply <- toJSON(list(type = "mcp-response", id = cmd$id, ok = FALSE,
|
|
3722
|
-
error = "No
|
|
3810
|
+
error = "No Beatrina notebook window is connected to this kernel. Open the notebook first."),
|
|
3723
3811
|
auto_unbox = TRUE)
|
|
3724
3812
|
try(rec$ws$send(reply), silent = TRUE)
|
|
3725
3813
|
return(invisible(NULL))
|
|
@@ -3778,7 +3866,7 @@ handle_frame <- function(message, rec) {
|
|
|
3778
3866
|
else reply(versions = I(r_versions()), current = current_rscript())
|
|
3779
3867
|
return(invisible(NULL))
|
|
3780
3868
|
}
|
|
3781
|
-
# "Restart into the installed
|
|
3869
|
+
# "Restart into the installed Beatrina": the page's door. Page-only in both
|
|
3782
3870
|
# senses (AGENT_REFUSED has it too) — a session handoff is the person's
|
|
3783
3871
|
# decision about their own session, never an agent's.
|
|
3784
3872
|
if (cmd$type %in% c("session_upgrade", "session_restart") && identical(rec$role, "page")) {
|
|
@@ -3791,7 +3879,8 @@ handle_frame <- function(message, rec) {
|
|
|
3791
3879
|
return(invisible(NULL))
|
|
3792
3880
|
}
|
|
3793
3881
|
reply(session_handoff_begin(mode = if (identical(cmd$type, "session_restart")) "restart" else "upgrade",
|
|
3794
|
-
force = isTRUE(cmd$force)
|
|
3882
|
+
force = isTRUE(cmd$force),
|
|
3883
|
+
resume = if (keep_token_ok(cmd$resume)) cmd$resume else NULL))
|
|
3795
3884
|
return(invisible(NULL))
|
|
3796
3885
|
}
|
|
3797
3886
|
# ── terminals ─────────────────────────────────────────────────────────────
|
|
@@ -4112,18 +4201,18 @@ handle_frame <- function(message, rec) {
|
|
|
4112
4201
|
"term_close" = "Agents cannot close the user's terminal.",
|
|
4113
4202
|
"session_list" = "Agents cannot list the user's sessions.",
|
|
4114
4203
|
"r_versions" = "Agents cannot list or choose R installations.",
|
|
4115
|
-
"session_upgrade" = "Agents cannot restart the user's session into another
|
|
4204
|
+
"session_upgrade" = "Agents cannot restart the user's session into another Beatrina.",
|
|
4116
4205
|
"session_restart" = "Agents cannot restart the user's session.",
|
|
4117
4206
|
# settings_GET is refused as firmly as the writes. Not because the
|
|
4118
4207
|
# values are secret, but because handing back the confinement root,
|
|
4119
4208
|
# the audit-log path and whether AI-text logging is on is a MAP OF THE
|
|
4120
4209
|
# DEPLOYMENT'S CONTROLS, given to a process whose entire premise is
|
|
4121
4210
|
# that it is not the user.
|
|
4122
|
-
"settings_get" = "Agents cannot read
|
|
4123
|
-
"settings_set" = "Agents cannot change
|
|
4124
|
-
"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.",
|
|
4125
4214
|
"update_status" = "Agents cannot inspect desktop update state.",
|
|
4126
|
-
"update_action" = "Agents cannot install, defer, or roll back
|
|
4215
|
+
"update_action" = "Agents cannot install, defer, or roll back Beatrina.",
|
|
4127
4216
|
"project_action" = "Agents cannot install or restore project packages.",
|
|
4128
4217
|
"job_open" = "Agents cannot open files on the user's desktop.",
|
|
4129
4218
|
"Agents run code through notebook chunks (chunk_run), not raw exec.")
|
|
@@ -4163,6 +4252,25 @@ handle_frame <- function(message, rec) {
|
|
|
4163
4252
|
return(invisible(NULL))
|
|
4164
4253
|
}
|
|
4165
4254
|
|
|
4255
|
+
# "Restart, keep variables": the save is a copy of the whole session, so it
|
|
4256
|
+
# is page-only in BOTH senses, and its cancel interrupts only a save THIS
|
|
4257
|
+
# page asked for — Interrupt's own gate matches exec routes, never this.
|
|
4258
|
+
if (cmd$type %in% c("suspend", "suspend_cancel") && !isTRUE(rec$class %in% PAGE_ONLY_CLASSES)) {
|
|
4259
|
+
if (scalar_chr(cmd$id)) try(rec$ws$send(toJSON(list(
|
|
4260
|
+
type = cmd$type, id = cmd$id, error = "Only the local notebook page may keep the workspace."
|
|
4261
|
+
), auto_unbox = TRUE)), silent = TRUE)
|
|
4262
|
+
audit("suspend-refused", reason = "class", class = rec$class %||% "unknown")
|
|
4263
|
+
return(invisible(NULL))
|
|
4264
|
+
}
|
|
4265
|
+
if (identical(cmd$type, "suspend_cancel")) {
|
|
4266
|
+
active <- sockets$worker_active
|
|
4267
|
+
route <- if (!is.null(active)) sockets$worker_routes[[active]] else NULL
|
|
4268
|
+
if (identical(sockets$worker_active_type, "suspend") && !is.null(route) && identical(route$rec, rec)) {
|
|
4269
|
+
audit("suspend-cancel")
|
|
4270
|
+
kernel_interrupt(k)
|
|
4271
|
+
}
|
|
4272
|
+
return(invisible(NULL))
|
|
4273
|
+
}
|
|
4166
4274
|
if (identical(cmd$type, "project_action") && !isTRUE(rec$class %in% PAGE_ONLY_CLASSES)) {
|
|
4167
4275
|
if (scalar_chr(cmd$id)) try(rec$ws$send(toJSON(list(
|
|
4168
4276
|
type = "project_action", id = cmd$id, ok = FALSE, reason = "class",
|
|
@@ -4274,7 +4382,7 @@ handle_frame <- function(message, rec) {
|
|
|
4274
4382
|
# a gate that silently drops the line. reply_err is this handler's own
|
|
4275
4383
|
# door back to the asking page and carries cmd$id, so the console can
|
|
4276
4384
|
# attribute the refusal to the line it just sent.
|
|
4277
|
-
reply_err("
|
|
4385
|
+
reply_err("Beatrina could not deliver the answer to R. The question is still open; try again.")
|
|
4278
4386
|
return(invisible(NULL))
|
|
4279
4387
|
}
|
|
4280
4388
|
sockets$input_waiting <- FALSE
|
|
@@ -4305,8 +4413,8 @@ handle_frame <- function(message, rec) {
|
|
|
4305
4413
|
action <- if (scalar_chr(cmd$action)) cmd$action else ""
|
|
4306
4414
|
line <- switch(action,
|
|
4307
4415
|
continue = "c", over = "n", into = "s", out = "f",
|
|
4308
|
-
where = ".
|
|
4309
|
-
abort = 'invokeRestart("
|
|
4416
|
+
where = ".beatrina_debug_where()",
|
|
4417
|
+
abort = 'invokeRestart("beatrina_abort_cell")',
|
|
4310
4418
|
eval = if (scalar_chr(cmd$expr)) cmd$expr else NULL)
|
|
4311
4419
|
if (is.null(line) || grepl("[\n\r]", line) || nchar(line) > 4000L) {
|
|
4312
4420
|
reply_err("bad debug command")
|
|
@@ -4319,7 +4427,7 @@ handle_frame <- function(message, rec) {
|
|
|
4319
4427
|
# end of the debuggable code the where line reaches the dispatch loop,
|
|
4320
4428
|
# which ignores what it cannot parse — harmless by construction.
|
|
4321
4429
|
if (action %in% c("over", "into", "out")) {
|
|
4322
|
-
try(kernel_console(k, ".
|
|
4430
|
+
try(kernel_console(k, ".beatrina_debug_where()"), silent = TRUE)
|
|
4323
4431
|
}
|
|
4324
4432
|
return(invisible(NULL))
|
|
4325
4433
|
}
|
|
@@ -4339,11 +4447,12 @@ handle_frame <- function(message, rec) {
|
|
|
4339
4447
|
# hello is stale the moment the old worker dies; the new worker's ready frame
|
|
4340
4448
|
# replaces it via pump() and reaches every open socket.
|
|
4341
4449
|
if (identical(cmd$type, "restart")) {
|
|
4342
|
-
audit("restart")
|
|
4450
|
+
audit("restart", keep = keep_token_ok(cmd$resume))
|
|
4343
4451
|
fail_worker_routes("R was restarted — this request was abandoned.")
|
|
4344
4452
|
try(kernel_stop(k, grace = 1), silent = TRUE)
|
|
4345
4453
|
k <<- start_execution_worker()
|
|
4346
4454
|
sockets$hello <- NULL
|
|
4455
|
+
sockets$resume_pending <- if (keep_token_ok(cmd$resume)) cmd$resume else NULL
|
|
4347
4456
|
sockets$worker_recovering <- FALSE
|
|
4348
4457
|
sockets$worker_restart_attempts <- 0L
|
|
4349
4458
|
# An explicit Restart R is the ONE door out of a give-up: the user has
|
|
@@ -4398,12 +4507,12 @@ handle_frame <- function(message, rec) {
|
|
|
4398
4507
|
return(invisible(NULL))
|
|
4399
4508
|
}
|
|
4400
4509
|
# ONE OF THE TWO DOORS THE SUPERVISOR ACTUALLY OWNS. A policy that permits
|
|
4401
|
-
# no key-based provider means
|
|
4510
|
+
# no key-based provider means Beatrina does not keep a provider key at all:
|
|
4402
4511
|
# not handed out, not stored. The user can still paste one into the tab —
|
|
4403
4512
|
# this is their own browser and no kernel can prevent that — but nothing
|
|
4404
4513
|
# persists it for them and the attempt is in the audit log. The refusal
|
|
4405
|
-
# covers `set` as much as `get`, because a key
|
|
4406
|
-
# 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.
|
|
4407
4516
|
if (isTRUE(ai_policy$set) && !isTRUE(ai_policy$allows_key)) {
|
|
4408
4517
|
audit("ai-key-refused", reason = "policy", class = rec$class %||% "unknown")
|
|
4409
4518
|
reply(error = ai_policy_reason(ai_policy), policy = TRUE)
|
|
@@ -4633,7 +4742,7 @@ handle_frame <- function(message, rec) {
|
|
|
4633
4742
|
}
|
|
4634
4743
|
# csl_place: the style beside the document, as a .bib sits beside a
|
|
4635
4744
|
# .tex — what makes `csl: apa.csl` in the front matter render the same
|
|
4636
|
-
# style with no
|
|
4745
|
+
# style with no Beatrina present. A dependent style's parent goes too, or
|
|
4637
4746
|
# pandoc would go looking for it. Never overwrites: a different file of
|
|
4638
4747
|
# that name beside the document is the author's, and is reported.
|
|
4639
4748
|
style <- csl_resolve(here, plugin_policy, cmd$name)
|
|
@@ -4661,7 +4770,7 @@ handle_frame <- function(message, rec) {
|
|
|
4661
4770
|
# The AI conversation, into the audit stream. Fire-and-forget: there is no
|
|
4662
4771
|
# reply, because a turn must never wait on its own logging, and a page that
|
|
4663
4772
|
# could tell whether the write landed would be a probe for whether auditing
|
|
4664
|
-
# is on. `audit()` is already a no-op without
|
|
4773
|
+
# is on. `audit()` is already a no-op without BEATRINA_LOG, so an ordinary
|
|
4665
4774
|
# laptop pays nothing here beyond the parse it already did.
|
|
4666
4775
|
if (identical(cmd$type, "ai-audit")) {
|
|
4667
4776
|
event <- ai_audit_scalar(cmd$event, 32L)
|
|
@@ -4690,7 +4799,7 @@ handle_frame <- function(message, rec) {
|
|
|
4690
4799
|
# Reading the audit stream BACK, for the reviewer export.
|
|
4691
4800
|
#
|
|
4692
4801
|
# Page-only on the same grounds as the console history, and more so: with
|
|
4693
|
-
#
|
|
4802
|
+
# BEATRINA_LOG_AI_TEXT on, these lines contain the prompts the user typed. A
|
|
4694
4803
|
# declared agent is refused (AGENT_REFUSED); a published origin and a native
|
|
4695
4804
|
# client are refused by class.
|
|
4696
4805
|
#
|
|
@@ -4760,7 +4869,7 @@ handle_frame <- function(message, rec) {
|
|
|
4760
4869
|
|
|
4761
4870
|
# Appearance carried between the two origins the notebook is delivered from
|
|
4762
4871
|
# (see PREF_KEYS). Page-only on the console-history rule exactly: this is the
|
|
4763
|
-
# 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
|
|
4764
4873
|
# of the origin they opened it at. A declared agent has no appearance.
|
|
4765
4874
|
if (identical(cmd$type, "prefs")) {
|
|
4766
4875
|
if (!scalar_chr(cmd$id)) return(invisible(NULL))
|
|
@@ -4806,7 +4915,7 @@ handle_frame <- function(message, rec) {
|
|
|
4806
4915
|
# same reason as ai-key: the supervisor answers it itself.
|
|
4807
4916
|
#
|
|
4808
4917
|
# Page-only for the ai-key reason exactly: a native client could edit
|
|
4809
|
-
# ~/.config/R/
|
|
4918
|
+
# ~/.config/R/beatrina/settings.json itself as this user, but Beatrina does not
|
|
4810
4919
|
# do it for it, so the promise holds for every client rather than only the
|
|
4811
4920
|
# polite ones. A published origin is refused because its reader approved that
|
|
4812
4921
|
# site to run the chunks they press, not to reconfigure their kernel.
|
|
@@ -4818,7 +4927,7 @@ handle_frame <- function(message, rec) {
|
|
|
4818
4927
|
audit("settings-refused", reason = "class", detail = cmd$type,
|
|
4819
4928
|
class = rec$class %||% "unknown")
|
|
4820
4929
|
reply(ok = FALSE, reason = "class",
|
|
4821
|
-
error = "Only the notebook page may read or change
|
|
4930
|
+
error = "Only the notebook page may read or change Beatrina's settings.")
|
|
4822
4931
|
return(invisible(NULL))
|
|
4823
4932
|
}
|
|
4824
4933
|
# An unauthenticated shared kernel has no "the user" whose home directory
|
|
@@ -4865,7 +4974,7 @@ handle_frame <- function(message, rec) {
|
|
|
4865
4974
|
# because those are precisely the ones that can be honoured now, and
|
|
4866
4975
|
# reporting a new linger while running on the old one is the lie the
|
|
4867
4976
|
# whole `effect` field exists to prevent.
|
|
4868
|
-
settings_state <<-
|
|
4977
|
+
settings_state <<- beatrina_settings_resolve()
|
|
4869
4978
|
linger_s <<- resolve_linger()
|
|
4870
4979
|
HISTORY_ENABLED <<- isTRUE(settings_value("history_enabled"))
|
|
4871
4980
|
audit("settings", detail = "get", status = settings_state$file$status)
|
|
@@ -4874,8 +4983,8 @@ handle_frame <- function(message, rec) {
|
|
|
4874
4983
|
}
|
|
4875
4984
|
|
|
4876
4985
|
if (identical(cmd$type, "settings_reset")) {
|
|
4877
|
-
unlink(
|
|
4878
|
-
settings_state <<-
|
|
4986
|
+
unlink(beatrina_settings_path())
|
|
4987
|
+
settings_state <<- beatrina_settings_resolve()
|
|
4879
4988
|
linger_s <<- resolve_linger()
|
|
4880
4989
|
HISTORY_ENABLED <<- isTRUE(settings_value("history_enabled"))
|
|
4881
4990
|
audit("settings", detail = "reset", ok = TRUE)
|
|
@@ -4915,7 +5024,7 @@ handle_frame <- function(message, rec) {
|
|
|
4915
5024
|
if (is.null(cmd$value)) {
|
|
4916
5025
|
values[[key]] <- NULL # unset: back to env/default
|
|
4917
5026
|
} else {
|
|
4918
|
-
verdict <-
|
|
5027
|
+
verdict <- beatrina_settings_validate(key, cmd$value)
|
|
4919
5028
|
if (!isTRUE(verdict$ok)) {
|
|
4920
5029
|
# Refused, never coerced, and the file is NOT written — the ai-key
|
|
4921
5030
|
# scar, where a payload of the wrong type fell through to "" and
|
|
@@ -4929,13 +5038,13 @@ handle_frame <- function(message, rec) {
|
|
|
4929
5038
|
values[[key]] <- verdict$value
|
|
4930
5039
|
}
|
|
4931
5040
|
|
|
4932
|
-
if (!isTRUE(
|
|
5041
|
+
if (!isTRUE(beatrina_settings_write(values))) {
|
|
4933
5042
|
audit("settings", detail = "set", key = key, ok = FALSE)
|
|
4934
5043
|
reply(ok = FALSE, key = key, reason = "write",
|
|
4935
5044
|
error = "The settings file could not be written.")
|
|
4936
5045
|
return(invisible(NULL))
|
|
4937
5046
|
}
|
|
4938
|
-
settings_state <<-
|
|
5047
|
+
settings_state <<- beatrina_settings_resolve()
|
|
4939
5048
|
applied <- FALSE
|
|
4940
5049
|
if (identical(entry$effect, "live")) {
|
|
4941
5050
|
if (identical(key, "linger_seconds")) {
|
|
@@ -5139,8 +5248,8 @@ pump <- function() {
|
|
|
5139
5248
|
# Session-wide lifecycle frames have no owner and must reach every tab —
|
|
5140
5249
|
# and no id to rewrite, so they forward exactly as the kernel wrote them.
|
|
5141
5250
|
if (identical(e$type, "ready")) {
|
|
5142
|
-
e$protocol <-
|
|
5143
|
-
e$kernel_build <-
|
|
5251
|
+
e$protocol <- BEATRINA_PROTOCOL_VERSION
|
|
5252
|
+
e$kernel_build <- BEATRINA_KERNEL_BUILD
|
|
5144
5253
|
e$installed_build <- installed_build()
|
|
5145
5254
|
# session_upgrade is the SUPERVISOR's verb; it joins the worker's
|
|
5146
5255
|
# vocabulary here so a page can tell, without probing, that this
|
|
@@ -5167,6 +5276,7 @@ pump <- function() {
|
|
|
5167
5276
|
payload <- relay_frame(e)
|
|
5168
5277
|
if (identical(e$type, "ready")) sockets$hello <- payload
|
|
5169
5278
|
lapply(sockets$open, function(r) try(r$ws$send(payload), silent = TRUE))
|
|
5279
|
+
if (identical(e$type, "ready") && !is.null(sockets$resume_pending)) start_resume()
|
|
5170
5280
|
invisible(NULL)
|
|
5171
5281
|
})
|
|
5172
5282
|
invisible(NULL)
|
|
@@ -5186,7 +5296,7 @@ if (deployment$require_origin) {
|
|
|
5186
5296
|
# first line. An operator who has bound to the network gets the posture
|
|
5187
5297
|
# stated back to them, because the two spellings differ enormously and the
|
|
5188
5298
|
# difference is invisible from the outside.
|
|
5189
|
-
message("
|
|
5299
|
+
message("Beatrina listening on ", host, ":", port,
|
|
5190
5300
|
" — strict: a client with no Origin is refused")
|
|
5191
5301
|
message(if (deployment$trust_proxy)
|
|
5192
5302
|
paste0(" identity: ", deployment$user_header, " from ",
|
|
@@ -5217,33 +5327,35 @@ file_url <- notebook_file_url(notebook_file, port, FILE_LAUNCH_CAP)
|
|
|
5217
5327
|
|
|
5218
5328
|
#' Discovery for local agents — the Jupyter runtime-dir pattern.
|
|
5219
5329
|
#'
|
|
5220
|
-
#' `tools/mcp/
|
|
5330
|
+
#' `tools/mcp/beatrina-mcp.mjs` (the stdio server Claude Code / Codex spawn) has
|
|
5221
5331
|
#' no stdout to read the URL from, so the kernel writes one small file a local
|
|
5222
|
-
#' process owned by the SAME user can find: ~/.
|
|
5332
|
+
#' process owned by the SAME user can find: ~/.beatrina/run/kernel-<port>.json,
|
|
5223
5333
|
#' mode 0600. This is discovery metadata, not a browser credential. A same-user
|
|
5224
5334
|
#' local process was never outside the trust line — it could already exec
|
|
5225
5335
|
#' anything. Removed on clean shutdown;
|
|
5226
5336
|
#' consumers must health-check before trusting a file (kills leave litter).
|
|
5227
|
-
#'
|
|
5228
|
-
#'
|
|
5337
|
+
#' BEATRINA_RUNTIME_DIR relocates it (tests use a scratch dir);
|
|
5338
|
+
#' BEATRINA_NO_RUNTIME_FILE=1 turns it off.
|
|
5229
5339
|
runtime_file <- ""
|
|
5230
5340
|
runtime_record <- list(url = url, file = file_url, port = port, pid = Sys.getpid(),
|
|
5231
5341
|
started = format(Sys.time(), "%Y-%m-%dT%H:%M:%S%z"))
|
|
5232
5342
|
# A successor started by a handoff keeps the session's name, so the menu
|
|
5233
5343
|
# row does not flicker to "Untitled" between the two processes.
|
|
5234
5344
|
local({
|
|
5235
|
-
title <- Sys.getenv("
|
|
5236
|
-
Sys.unsetenv("
|
|
5345
|
+
title <- Sys.getenv("BEATRINA_SESSION_TITLE", "")
|
|
5346
|
+
Sys.unsetenv("BEATRINA_SESSION_TITLE")
|
|
5237
5347
|
title <- trimws(substr(gsub("[\"\\\\]", "'", gsub("[[:cntrl:]]", " ", title)), 1L, 120L))
|
|
5238
5348
|
if (nzchar(title)) runtime_record$title <<- title
|
|
5349
|
+
label <- session_documents_label(sockets$session_documents)
|
|
5350
|
+
if (nzchar(label)) runtime_record$documents <<- label
|
|
5239
5351
|
})
|
|
5240
|
-
#
|
|
5352
|
+
# BEATRINA_LISTEN=1 marks a kernel started headless for published pages (the
|
|
5241
5353
|
# menu's Listen for Web Pages, the keep-ready daemon). The menu helper shows
|
|
5242
5354
|
# such a kernel as its Listen checkbox, not as a session row — there is no
|
|
5243
5355
|
# document behind it to name. The mark is HOW IT STARTED, so the first page
|
|
5244
5356
|
# that actually attaches clears it (see the upgrade handler): from then on it
|
|
5245
5357
|
# is an ordinary session.
|
|
5246
|
-
if (identical(Sys.getenv("
|
|
5358
|
+
if (identical(Sys.getenv("BEATRINA_LISTEN"), "1")) runtime_record$listen <- TRUE
|
|
5247
5359
|
# The failsafe's session is listed by `beatrina status` and stopped by `beatrina stop` like any other:
|
|
5248
5360
|
# the same app, a host name, the boot instant /health reports, and the document it opened.
|
|
5249
5361
|
if (nzchar(FAILSAFE_REASON)) {
|
|
@@ -5280,6 +5392,19 @@ set_runtime_title <- function(title) {
|
|
|
5280
5392
|
write_runtime()
|
|
5281
5393
|
}
|
|
5282
5394
|
|
|
5395
|
+
#' The session's documents, as a label, into the runtime record.
|
|
5396
|
+
#'
|
|
5397
|
+
#' `documents` is what the menu helper shows in place of the notebook's
|
|
5398
|
+
#' generated name ("SezerProfiles.qmd", not "Mellow Yarrow 7Z6"). A flat
|
|
5399
|
+
#' string on purpose — helper-sessions.sh reads the record with a bounded sed.
|
|
5400
|
+
set_runtime_documents <- function(documents) {
|
|
5401
|
+
label <- session_documents_label(documents)
|
|
5402
|
+
if (identical(runtime_record$documents %||% "", label)) return(invisible(NULL))
|
|
5403
|
+
if (nzchar(label)) runtime_record$documents <<- label
|
|
5404
|
+
else runtime_record$documents <<- NULL
|
|
5405
|
+
write_runtime()
|
|
5406
|
+
}
|
|
5407
|
+
|
|
5283
5408
|
#' A page attached: this kernel is now a session, not a bare listener.
|
|
5284
5409
|
clear_runtime_listen <- function() {
|
|
5285
5410
|
if (is.null(runtime_record$listen)) return(invisible(NULL))
|
|
@@ -5287,9 +5412,9 @@ clear_runtime_listen <- function() {
|
|
|
5287
5412
|
write_runtime()
|
|
5288
5413
|
}
|
|
5289
5414
|
|
|
5290
|
-
if (!identical(Sys.getenv("
|
|
5291
|
-
runtime_dir <- Sys.getenv("
|
|
5292
|
-
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"))
|
|
5293
5418
|
made <- dir.exists(runtime_dir) ||
|
|
5294
5419
|
dir.create(runtime_dir, recursive = TRUE, showWarnings = FALSE)
|
|
5295
5420
|
if (made) {
|
|
@@ -5298,7 +5423,7 @@ if (!identical(Sys.getenv("CARMAR_NO_RUNTIME_FILE"), "1")) {
|
|
|
5298
5423
|
}
|
|
5299
5424
|
}
|
|
5300
5425
|
|
|
5301
|
-
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", ""))
|
|
5302
5427
|
cat(toJSON(list(url = url, file = file_url), auto_unbox = TRUE), "\n")
|
|
5303
5428
|
flush(stdout())
|
|
5304
5429
|
|
|
@@ -5379,7 +5504,7 @@ mcp_pending_sweep <- function() {
|
|
|
5379
5504
|
as.numeric(difftime(now, entry$at, units = "secs")) >= MCP_PENDING_MAX_S
|
|
5380
5505
|
if (!page_gone && !stale) next
|
|
5381
5506
|
why <- if (page_gone)
|
|
5382
|
-
"The
|
|
5507
|
+
"The Beatrina notebook window that was answering this request closed before it replied."
|
|
5383
5508
|
else sprintf("The notebook did not answer within %d seconds.",
|
|
5384
5509
|
as.integer(MCP_PENDING_MAX_S))
|
|
5385
5510
|
audit("mcp-abandoned", id = id, reason = if (page_gone) "page closed" else "timeout")
|
|
@@ -5429,7 +5554,7 @@ linger_check <- function() {
|
|
|
5429
5554
|
if (as.numeric(difftime(now, sockets$idle_since, units = "secs")) >= grace) {
|
|
5430
5555
|
reason <- if (unopened_default) "unopened-launch" else "configured-idle-timeout"
|
|
5431
5556
|
audit("linger-shutdown", grace = grace, reason = reason)
|
|
5432
|
-
message(sprintf("
|
|
5557
|
+
message(sprintf("Beatrina stopping: %s (grace %s seconds).", reason, grace))
|
|
5433
5558
|
sockets$quit <- TRUE
|
|
5434
5559
|
}
|
|
5435
5560
|
invisible(NULL)
|