beatrina 0.8.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +92 -0
- package/NOTICES +72 -0
- package/README.md +124 -0
- package/bin/beatrina.mjs +223 -0
- package/bin/cli.mjs +80 -0
- package/bin/failsafe.mjs +74 -0
- package/bin/identity.mjs +45 -0
- package/bin/prova-post.mjs +51 -0
- package/bin/sessions.mjs +95 -0
- package/bin/shortcut.mjs +151 -0
- package/bin/update-check.mjs +55 -0
- package/bin/upgrade.mjs +76 -0
- package/build-info.json +1 -0
- package/carmar_V0.8.6.html +1310 -0
- package/check/acceptance.mjs +278 -0
- package/check/session.mjs +215 -0
- package/engines/js/document-exec.mjs +82 -0
- package/engines/js/persist.mjs +214 -0
- package/engines/js/worker.mjs +424 -0
- package/engines/python/adapter.py +577 -0
- package/engines/python/analyze.py +814 -0
- package/engines/python/bootstrap.py +309 -0
- package/engines/python/dataview.py +735 -0
- package/engines/python/debugger.py +346 -0
- package/engines/python/document_exec.py +158 -0
- package/engines/python/engine.json +28 -0
- package/engines/python/handoff.py +118 -0
- package/engines/python/worker.py +564 -0
- package/engines/r/engine.json +25 -0
- package/engines/r/handoff.R +92 -0
- package/failsafe/ai-policy.R +255 -0
- package/failsafe/ai-store.R +373 -0
- package/failsafe/cite.R +418 -0
- package/failsafe/journal.R +684 -0
- package/failsafe/plugins.R +809 -0
- package/failsafe/serve.R +5500 -0
- package/host/ai-policy.mjs +218 -0
- package/host/deployment.mjs +160 -0
- package/host/engine-js.mjs +98 -0
- package/host/engine-pool.mjs +383 -0
- package/host/engine-python.mjs +228 -0
- package/host/engine-r.mjs +206 -0
- package/host/engine-stdio.mjs +401 -0
- package/host/journal-store.mjs +749 -0
- package/host/main.mjs +503 -0
- package/host/planes/README.md +41 -0
- package/host/planes/ai-store.mjs +327 -0
- package/host/planes/ai.mjs +467 -0
- package/host/planes/analyze.mjs +397 -0
- package/host/planes/cite.mjs +517 -0
- package/host/planes/files.mjs +0 -0
- package/host/planes/jobs.mjs +704 -0
- package/host/planes/journal.mjs +53 -0
- package/host/planes/latex.mjs +201 -0
- package/host/planes/mcp.mjs +493 -0
- package/host/planes/pair.mjs +325 -0
- package/host/planes/pipe-term.mjs +122 -0
- package/host/planes/plugins.mjs +112 -0
- package/host/planes/proc-tree.mjs +76 -0
- package/host/planes/sessions.mjs +434 -0
- package/host/planes/settings.mjs +164 -0
- package/host/planes/terminal.mjs +286 -0
- package/host/planes/test-file.mjs +80 -0
- package/host/planes/update.mjs +214 -0
- package/host/plugin-store.mjs +838 -0
- package/host/server.mjs +441 -0
- package/host/settings.mjs +379 -0
- package/host/update-record.mjs +59 -0
- package/host/user-dirs.mjs +117 -0
- package/host/windows-runtime.mjs +145 -0
- package/host/worker-plane.mjs +713 -0
- package/host/ws.mjs +190 -0
- package/kernel/analyze.R +668 -0
- package/kernel/deployment.R +165 -0
- package/kernel/examples/NOTICE.md +38 -0
- package/kernel/examples/tna-complete-tutorial.Rmd +210 -0
- package/kernel/fileio.R +656 -0
- package/kernel/index.html +96 -0
- package/kernel/job-run.R +391 -0
- package/kernel/jobs.R +276 -0
- package/kernel/kernel-protocol +1 -0
- package/kernel/kernel-version +1 -0
- package/kernel/kernel.R +671 -0
- package/kernel/knitr-run.R +245 -0
- package/kernel/latex.R +609 -0
- package/kernel/mcp/carmar-mcp.mjs +516 -0
- package/kernel/notebook-page.R +67 -0
- package/kernel/plugins/csl/apa/apa.csl +2273 -0
- package/kernel/plugins/csl/apa/plugin.json +19 -0
- package/kernel/plugins/csl/chicago-author-date/chicago-author-date.csl +4216 -0
- package/kernel/plugins/csl/chicago-author-date/plugin.json +19 -0
- package/kernel/plugins/csl/harvard-cite-them-right/harvard-cite-them-right.csl +316 -0
- package/kernel/plugins/csl/harvard-cite-them-right/plugin.json +19 -0
- package/kernel/plugins/csl/ieee/ieee.csl +519 -0
- package/kernel/plugins/csl/ieee/plugin.json +19 -0
- package/kernel/plugins/csl/modern-language-association/modern-language-association.csl +1184 -0
- package/kernel/plugins/csl/modern-language-association/plugin.json +19 -0
- package/kernel/plugins/csl/nature/nature.csl +189 -0
- package/kernel/plugins/csl/nature/plugin.json +19 -0
- package/kernel/plugins/latex/apa7/apa7.json +14 -0
- package/kernel/plugins/latex/apa7/plugin.json +19 -0
- package/kernel/plugins/latex/elsarticle/elsarticle.json +14 -0
- package/kernel/plugins/latex/elsarticle/plugin.json +19 -0
- package/kernel/plugins/latex/ieeetran/ieeetran.json +10 -0
- package/kernel/plugins/latex/ieeetran/plugin.json +19 -0
- package/kernel/project.R +131 -0
- package/kernel/settings.R +410 -0
- package/kernel/sniff.R +769 -0
- package/kernel/worker-boot.R +22 -0
- package/kernel/worker.R +3496 -0
- package/lib/agent-authoring-contract.js +547 -0
- package/lib/cell-kinds.js +108 -0
- package/lib/engine-labels.js +324 -0
- package/package.json +32 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# handoff.R — share() and use(): the explicit bridge between a session's languages.
|
|
2
|
+
#
|
|
3
|
+
# Beatrix runs R, Python and JavaScript as separate processes with separate
|
|
4
|
+
# memories (docs/worker-protocol.md §6.1). A value crosses only when a chunk
|
|
5
|
+
# names it: share(df) writes a column table to the host's hand-off directory
|
|
6
|
+
# (CARMAR_HANDOFF_DIR) and use("df") in another language reads it back.
|
|
7
|
+
# The JavaScript half is engines/js/worker.mjs; the format is shared:
|
|
8
|
+
#
|
|
9
|
+
# {"format": "beatrix-columns-v1", "name": ..., "from": "r",
|
|
10
|
+
# "columns": [...], "data": {"<column>": [values]}}
|
|
11
|
+
#
|
|
12
|
+
# Numbers travel as JSON doubles with 17 significant digits (exact round trip),
|
|
13
|
+
# NA and non-finite values as null. Factors travel as their
|
|
14
|
+
# labels and dates as ISO strings — a table, not an R object.
|
|
15
|
+
#
|
|
16
|
+
# Attached by host/engine-r.mjs's boot line as the search-path entry
|
|
17
|
+
# "beatrix:handoff"; nothing in spike/worker.R knows about it.
|
|
18
|
+
|
|
19
|
+
#' Share a data frame with the other languages of this session.
|
|
20
|
+
#'
|
|
21
|
+
#' @param x A data frame (or anything `as.data.frame()` accepts).
|
|
22
|
+
#' @param name The name another language asks for; defaults to the name of `x`.
|
|
23
|
+
#' @return `x`, invisibly.
|
|
24
|
+
share <- function(x, name = deparse1(substitute(x))) {
|
|
25
|
+
# share("name", df) is accepted too — the order JavaScript and Python use.
|
|
26
|
+
if (is.character(x) && length(x) == 1L && is.data.frame(name)) {
|
|
27
|
+
swapped <- name
|
|
28
|
+
name <- x
|
|
29
|
+
x <- swapped
|
|
30
|
+
}
|
|
31
|
+
dir <- Sys.getenv("CARMAR_HANDOFF_DIR", "")
|
|
32
|
+
stopifnot(
|
|
33
|
+
"share() needs the Beatrina host's hand-off directory" = nzchar(dir) && dir.exists(dir),
|
|
34
|
+
"`name` must be letters, digits, dots and underscores, starting with a letter or underscore" =
|
|
35
|
+
is.character(name) && length(name) == 1L && grepl("^[A-Za-z_][A-Za-z0-9_.]*$", name)
|
|
36
|
+
)
|
|
37
|
+
df <- as.data.frame(x, stringsAsFactors = FALSE)
|
|
38
|
+
df[] <- lapply(df, function(col) {
|
|
39
|
+
if (is.factor(col)) as.character(col)
|
|
40
|
+
else if (inherits(col, c("Date", "POSIXt"))) format(col, "%Y-%m-%dT%H:%M:%OS3%z")
|
|
41
|
+
else if (is.numeric(col)) replace(col, !is.finite(col), NA)
|
|
42
|
+
else col
|
|
43
|
+
})
|
|
44
|
+
payload <- list(format = "beatrix-columns-v1", name = name, from = "r",
|
|
45
|
+
columns = I(names(df)), data = df)
|
|
46
|
+
file <- file.path(dir, paste0(name, ".json"))
|
|
47
|
+
tmp <- paste0(file, ".", Sys.getpid(), ".tmp")
|
|
48
|
+
# digits = I(17), NOT digits = NA: jsonlite's "NA = max precision" writes 15
|
|
49
|
+
# significant digits, and 0.51033364749975385 came back as ...75396
|
|
50
|
+
# (test/host-js.test.mjs caught it). 17 significant digits is what a double
|
|
51
|
+
# needs to round-trip exactly; JavaScript's JSON.stringify already writes that.
|
|
52
|
+
writeLines(jsonlite::toJSON(payload, dataframe = "columns", digits = I(17),
|
|
53
|
+
na = "null", auto_unbox = TRUE), tmp, useBytes = TRUE)
|
|
54
|
+
Sys.chmod(tmp, "0600")
|
|
55
|
+
stopifnot("the hand-off file could not be written" = file.rename(tmp, file))
|
|
56
|
+
invisible(x)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
#' Use a table another language of this session shared.
|
|
60
|
+
#'
|
|
61
|
+
#' @param name The name it was shared under.
|
|
62
|
+
#' @return A data frame, one row per shared row, columns in the shared order.
|
|
63
|
+
use <- function(name) {
|
|
64
|
+
dir <- Sys.getenv("CARMAR_HANDOFF_DIR", "")
|
|
65
|
+
stopifnot(
|
|
66
|
+
"use() needs the Beatrina host's hand-off directory" = nzchar(dir) && dir.exists(dir),
|
|
67
|
+
"`name` must be a single string" = is.character(name) && length(name) == 1L
|
|
68
|
+
)
|
|
69
|
+
file <- file.path(dir, paste0(name, ".json"))
|
|
70
|
+
if (!file.exists(file)) {
|
|
71
|
+
stop(errorCondition(sprintf(
|
|
72
|
+
"Nothing has been shared as \"%s\" in this session. Share it first with share(\"%s\", ...) in JavaScript or share(%s) in R.",
|
|
73
|
+
name, name, name), class = "beatrix_not_shared", call = NULL))
|
|
74
|
+
}
|
|
75
|
+
rec <- jsonlite::fromJSON(file, simplifyVector = FALSE)
|
|
76
|
+
if (!identical(rec$format, "beatrix-columns-v1")) {
|
|
77
|
+
stop(errorCondition(sprintf("\"%s\" is not a Beatrina hand-off table.", name),
|
|
78
|
+
class = "beatrix_bad_handoff", call = NULL))
|
|
79
|
+
}
|
|
80
|
+
cols <- vapply(rec$columns, as.character, character(1))
|
|
81
|
+
column <- function(values) {
|
|
82
|
+
missing <- vapply(values, is.null, logical(1))
|
|
83
|
+
filled <- values
|
|
84
|
+
filled[missing] <- NA
|
|
85
|
+
simplified <- unlist(filled, use.names = FALSE)
|
|
86
|
+
if (length(simplified) != length(values)) simplified <- vapply(filled, function(v) paste(format(v), collapse = ","), character(1))
|
|
87
|
+
simplified
|
|
88
|
+
}
|
|
89
|
+
out <- lapply(cols, function(k) column(rec$data[[k]]))
|
|
90
|
+
names(out) <- cols
|
|
91
|
+
as.data.frame(out, stringsAsFactors = FALSE, check.names = FALSE)
|
|
92
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# ai-policy.R — which AI providers an administrator permits, and what CarmaR
|
|
2
|
+
# can actually do about it.
|
|
3
|
+
#
|
|
4
|
+
# ── the honest scope, first, because everything else depends on it ──────────
|
|
5
|
+
#
|
|
6
|
+
# CarmaR runs in a browser the user controls, and the AI request for a
|
|
7
|
+
# key-based provider is a `fetch()` from that page. No kernel can prevent a
|
|
8
|
+
# determined user with devtools and their own API key from calling any endpoint
|
|
9
|
+
# they like. Any document promising otherwise would be lying.
|
|
10
|
+
#
|
|
11
|
+
# What an administrator policy DOES buy, and what procurement actually asks
|
|
12
|
+
# for, is three things:
|
|
13
|
+
#
|
|
14
|
+
# 1. a default the user did not choose and cannot casually change,
|
|
15
|
+
# 2. the constraint made VISIBLE in the notebook rather than discovered by
|
|
16
|
+
# hitting it, and
|
|
17
|
+
# 3. the two doors the kernel genuinely owns, closed.
|
|
18
|
+
#
|
|
19
|
+
# Those two doors are real enforcement, not theatre:
|
|
20
|
+
#
|
|
21
|
+
# · `ai-key` — the provider key lives in the user's R config directory and is
|
|
22
|
+
# handed out by the supervisor. Under a local-only policy the supervisor
|
|
23
|
+
# stops handing it out at all. A user can still paste a key into the tab,
|
|
24
|
+
# but CarmaR will not keep one for them, and the refusal is audited.
|
|
25
|
+
# · `agent-chat` — Claude Code and Codex turns are processes the SUPERVISOR
|
|
26
|
+
# spawns. A policy that does not permit them means they are never spawned.
|
|
27
|
+
# There is no page-side bypass, because there is no page-side spawn.
|
|
28
|
+
#
|
|
29
|
+
# Everything else is disclosure and audit. That is stated in docs/server.md in
|
|
30
|
+
# the same words, so nobody buys this expecting a sandbox.
|
|
31
|
+
#
|
|
32
|
+
# ── where a policy comes from ──────────────────────────────────────────────
|
|
33
|
+
#
|
|
34
|
+
# CARMAR_AI_POLICY=/etc/carmar/ai-policy.json a file the user cannot write
|
|
35
|
+
# CARMAR_AI_PROVIDERS=anthropic,lmstudio the quick form
|
|
36
|
+
# CARMAR_AI_LOCAL_ONLY=1 only on-machine providers
|
|
37
|
+
#
|
|
38
|
+
# The file wins where both are set, because a file is the form an administrator
|
|
39
|
+
# can own (root-owned, 0644) while an environment variable is inherited from
|
|
40
|
+
# whoever started the process — on a shared host, that may be the user.
|
|
41
|
+
|
|
42
|
+
`%||%` <- if (exists("%||%")) `%||%` else function(a, b) if (is.null(a)) b else a
|
|
43
|
+
|
|
44
|
+
# The providers whose text never leaves the machine.
|
|
45
|
+
#
|
|
46
|
+
# THIS LIST IS A CROSS-LANGUAGE CONTRACT. It must agree with
|
|
47
|
+
# `staysOnMachine()` in lib/llm.js, which is the browser's answer to the same
|
|
48
|
+
# question; test/ai-policy.test.mjs reads both and fails if they diverge.
|
|
49
|
+
# Note what is NOT here: `claudecode` and `codex` carry `local: true` in
|
|
50
|
+
# lib/llm.js because they cost no API key, but they hand the prompt to a CLI
|
|
51
|
+
# that sends it to a vendor. Using `local` to mean "stays here" would quietly
|
|
52
|
+
# ship the user's code out.
|
|
53
|
+
ON_MACHINE_PROVIDERS <- c("chrome", "webgpu", "ollama", "lmstudio", "jan")
|
|
54
|
+
|
|
55
|
+
# Providers reached with the user's API key — the ones the `ai-key` door serves.
|
|
56
|
+
KEY_PROVIDERS <- c("openai", "anthropic", "google", "groq", "together",
|
|
57
|
+
"openrouter", "azure", "custom")
|
|
58
|
+
|
|
59
|
+
# Providers that are a CLI process the SUPERVISOR spawns. The door it owns.
|
|
60
|
+
CLI_PROVIDERS <- c("claudecode", "codex")
|
|
61
|
+
|
|
62
|
+
ALL_PROVIDERS <- c(ON_MACHINE_PROVIDERS, KEY_PROVIDERS, CLI_PROVIDERS)
|
|
63
|
+
|
|
64
|
+
#' Normalise a policy file's `models` map into provider -> character vector.
|
|
65
|
+
#'
|
|
66
|
+
#' Accepts a single string as a one-element vector, because
|
|
67
|
+
#' `"anthropic": "claude-sonnet-4"` is what an administrator will write and
|
|
68
|
+
#' refusing it over a missing pair of brackets would be pedantry with a
|
|
69
|
+
#' startup failure attached.
|
|
70
|
+
normalise_models <- function(raw) {
|
|
71
|
+
if (is.null(raw) || !length(raw)) return(list())
|
|
72
|
+
keys <- names(raw)
|
|
73
|
+
if (is.null(keys)) return(list())
|
|
74
|
+
out <- list()
|
|
75
|
+
for (key in keys[nzchar(keys)]) {
|
|
76
|
+
ids <- unique(trimws(as.character(unlist(raw[[key]]))))
|
|
77
|
+
ids <- ids[nzchar(ids) & !is.na(ids)]
|
|
78
|
+
if (length(ids)) out[[key]] <- ids
|
|
79
|
+
}
|
|
80
|
+
out
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
#' Normalise administrator-pinned provider endpoints.
|
|
84
|
+
#'
|
|
85
|
+
#' These values are returned to the page, so credentials, query strings and
|
|
86
|
+
#' fragments are forbidden. Hosted gateways must use HTTPS. Loopback HTTP is
|
|
87
|
+
#' allowed only for the providers that genuinely run on this machine.
|
|
88
|
+
normalise_base_urls <- function(raw) {
|
|
89
|
+
if (is.null(raw) || !length(raw) || is.null(names(raw))) {
|
|
90
|
+
return(list(values = list(), errors = character(0)))
|
|
91
|
+
}
|
|
92
|
+
out <- list()
|
|
93
|
+
errors <- character(0)
|
|
94
|
+
for (provider in names(raw)[nzchar(names(raw))]) {
|
|
95
|
+
values <- as.character(unlist(raw[[provider]]))
|
|
96
|
+
value <- if (length(values)) trimws(values[[1L]]) else ""
|
|
97
|
+
hosted <- grepl("^https://[^/@?#[:space:]]+(?::[0-9]+)?(?:/[^?#[:space:]]*)?/?$",
|
|
98
|
+
value, perl = TRUE, ignore.case = TRUE)
|
|
99
|
+
loopback <- provider %in% ON_MACHINE_PROVIDERS &&
|
|
100
|
+
grepl("^http://(?:localhost|127[.]0[.]0[.]1|\\[::1\\])(?::[0-9]+)?(?:/[^?#[:space:]]*)?/?$",
|
|
101
|
+
value, perl = TRUE, ignore.case = TRUE)
|
|
102
|
+
if (!nzchar(value) || length(values) != 1L || (!hosted && !loopback)) {
|
|
103
|
+
errors <- c(errors, paste0(
|
|
104
|
+
"AI policy base_urls.", provider,
|
|
105
|
+
" must be one credential-free HTTPS URL (or loopback HTTP for an on-machine provider)."))
|
|
106
|
+
} else {
|
|
107
|
+
out[[provider]] <- value
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
list(values = out, errors = errors)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
policy_split <- function(value) {
|
|
114
|
+
parts <- trimws(unlist(strsplit(value %||% "", "[,[:space:]]+")))
|
|
115
|
+
parts[nzchar(parts)]
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
#' Read the administrator's AI policy.
|
|
119
|
+
#'
|
|
120
|
+
#' @param env a getter with `Sys.getenv`'s signature; injected for tests.
|
|
121
|
+
#' @return a list with `set` (is there a policy at all?), `source`,
|
|
122
|
+
#' `providers` (the permitted ids — every id when unset), `local_only`,
|
|
123
|
+
#' `note` (an administrator's sentence for the UI), `unknown` (ids named by
|
|
124
|
+
#' the policy that CarmaR does not have, reported rather than silently
|
|
125
|
+
#' dropped) and `errors`.
|
|
126
|
+
#'
|
|
127
|
+
#' A policy that names NOTHING valid is an error, never an empty allow-list:
|
|
128
|
+
#' silently permitting no provider would look identical to CarmaR's AI being
|
|
129
|
+
#' broken, and an administrator would have no way to tell which it was.
|
|
130
|
+
carmar_ai_policy <- function(env = Sys.getenv) {
|
|
131
|
+
path <- trimws(env("CARMAR_AI_POLICY", ""))
|
|
132
|
+
named <- character(0)
|
|
133
|
+
models <- list()
|
|
134
|
+
base_urls <- list()
|
|
135
|
+
local_only <- identical(env("CARMAR_AI_LOCAL_ONLY", ""), "1")
|
|
136
|
+
note <- ""
|
|
137
|
+
source <- ""
|
|
138
|
+
errors <- character(0)
|
|
139
|
+
|
|
140
|
+
if (nzchar(path)) {
|
|
141
|
+
source <- path
|
|
142
|
+
if (!file.exists(path)) {
|
|
143
|
+
errors <- c(errors, paste0("CARMAR_AI_POLICY names a file that does not exist: ", path))
|
|
144
|
+
} else {
|
|
145
|
+
doc <- tryCatch(jsonlite::fromJSON(path, simplifyVector = TRUE),
|
|
146
|
+
error = function(e) e)
|
|
147
|
+
if (inherits(doc, "error")) {
|
|
148
|
+
errors <- c(errors, paste0("CARMAR_AI_POLICY is not valid JSON: ", conditionMessage(doc)))
|
|
149
|
+
} else {
|
|
150
|
+
named <- as.character(doc$providers %||% character(0))
|
|
151
|
+
if (isTRUE(doc$local_only)) local_only <- TRUE
|
|
152
|
+
note <- trimws(as.character(doc$note %||% "")[1] %||% "")
|
|
153
|
+
if (is.na(note)) note <- ""
|
|
154
|
+
# `models` is FILE-ONLY, deliberately. A provider list flattens to a
|
|
155
|
+
# comma-separated environment variable without losing anything; a map
|
|
156
|
+
# of provider to permitted model ids does not, and every syntax that
|
|
157
|
+
# crams one into a single string ("anthropic:a|b;openai:c") is a syntax
|
|
158
|
+
# a typo hides in. An administrator pinning models is already writing a
|
|
159
|
+
# file.
|
|
160
|
+
models <- normalise_models(doc$models)
|
|
161
|
+
endpoints <- normalise_base_urls(doc$base_urls)
|
|
162
|
+
base_urls <- endpoints$values
|
|
163
|
+
errors <- c(errors, endpoints$errors)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
} else if (nzchar(env("CARMAR_AI_PROVIDERS", ""))) {
|
|
167
|
+
source <- "env"
|
|
168
|
+
named <- policy_split(env("CARMAR_AI_PROVIDERS", ""))
|
|
169
|
+
} else if (local_only) {
|
|
170
|
+
source <- "env"
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
unknown <- setdiff(named, ALL_PROVIDERS)
|
|
174
|
+
asked <- length(named) > 0L # did the policy NAME anything at all?
|
|
175
|
+
named <- intersect(named, ALL_PROVIDERS)
|
|
176
|
+
|
|
177
|
+
# Three states, not two, and conflating the last two fails OPEN — which is
|
|
178
|
+
# how a policy of `providers: ["anthropc"]` (one typo) used to permit every
|
|
179
|
+
# provider on the machine instead of refusing to start:
|
|
180
|
+
# named nothing → no constraint, everything is permitted
|
|
181
|
+
# named something valid → exactly that
|
|
182
|
+
# named ONLY junk → an empty allow-list, which the check below turns
|
|
183
|
+
# into a startup error rather than a silent "all".
|
|
184
|
+
providers <- if (length(named)) named else if (asked) character(0) else ALL_PROVIDERS
|
|
185
|
+
if (local_only) providers <- intersect(providers, ON_MACHINE_PROVIDERS)
|
|
186
|
+
|
|
187
|
+
set <- nzchar(source) && !length(errors)
|
|
188
|
+
if (set && !length(providers)) {
|
|
189
|
+
errors <- c(errors, paste0(
|
|
190
|
+
"The AI policy permits no provider at all",
|
|
191
|
+
if (length(unknown)) paste0(" (unrecognised: ", paste(unknown, collapse = ", "), ")") else "",
|
|
192
|
+
if (local_only) " — local_only was combined with providers that all send text off the machine" else "",
|
|
193
|
+
". Name at least one, or remove the policy."))
|
|
194
|
+
}
|
|
195
|
+
if (length(errors)) { set <- FALSE; providers <- ALL_PROVIDERS }
|
|
196
|
+
|
|
197
|
+
# Control characters out: this string is rendered in the notebook and written
|
|
198
|
+
# to the audit log, and an administrator's file is still a file.
|
|
199
|
+
note <- gsub("[[:cntrl:]]", " ", note)
|
|
200
|
+
note <- trimws(substr(note, 1L, 400L))
|
|
201
|
+
|
|
202
|
+
# A models entry for a provider that is not permitted is dead weight, and
|
|
203
|
+
# leaving it in would make the page render a constraint on a control the user
|
|
204
|
+
# cannot reach anyway.
|
|
205
|
+
models <- models[intersect(names(models), providers)]
|
|
206
|
+
base_urls <- base_urls[intersect(names(base_urls), providers)]
|
|
207
|
+
if (length(errors)) {
|
|
208
|
+
models <- list()
|
|
209
|
+
base_urls <- list()
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
list(set = set, source = source, providers = providers,
|
|
213
|
+
models = models, base_urls = base_urls,
|
|
214
|
+
local_only = local_only, note = note, unknown = unknown,
|
|
215
|
+
errors = errors,
|
|
216
|
+
# The two doors the supervisor actually owns, precomputed so the call
|
|
217
|
+
# sites read as the questions they are asking.
|
|
218
|
+
allows_key = length(intersect(providers, KEY_PROVIDERS)) > 0L,
|
|
219
|
+
allows_cli = intersect(providers, CLI_PROVIDERS))
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
#' May this model be used with this provider?
|
|
223
|
+
#'
|
|
224
|
+
#' TRUE when no models are pinned for that provider — pinning is opt-in per
|
|
225
|
+
#' provider, so an administrator who names models for `anthropic` has not
|
|
226
|
+
#' thereby said anything about `lmstudio`.
|
|
227
|
+
#'
|
|
228
|
+
#' DISCLOSURE ONLY, and the difference from `ai_provider_allowed()` matters:
|
|
229
|
+
#' the provider half closes two doors the supervisor owns (it will not hand out
|
|
230
|
+
#' a key, it will not spawn a CLI). A model id is a field in a request body the
|
|
231
|
+
#' page builds and sends itself, so there is no door here to close — the kernel
|
|
232
|
+
#' states the constraint and the settings dialog offers only what is permitted.
|
|
233
|
+
#' Nothing stops a user with devtools, and nothing here pretends to.
|
|
234
|
+
ai_model_allowed <- function(provider, model, policy) {
|
|
235
|
+
if (!isTRUE(policy$set)) return(TRUE)
|
|
236
|
+
allowed <- policy$models[[provider %||% ""]]
|
|
237
|
+
if (is.null(allowed) || !length(allowed)) return(TRUE)
|
|
238
|
+
nzchar(model %||% "") && model %in% allowed
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
#' May this provider be used at all?
|
|
242
|
+
ai_provider_allowed <- function(provider, policy) {
|
|
243
|
+
!isTRUE(policy$set) || (nzchar(provider %||% "") && provider %in% policy$providers)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
#' The sentence the notebook shows when something is refused.
|
|
247
|
+
#'
|
|
248
|
+
#' Names the administrator's own note when there is one, because "your
|
|
249
|
+
#' administrator has limited this" without saying who or why is the worst
|
|
250
|
+
#' version of a policy message.
|
|
251
|
+
ai_policy_reason <- function(policy, what = "provider") {
|
|
252
|
+
base <- sprintf("This CarmaR is configured to allow only: %s.",
|
|
253
|
+
paste(policy$providers, collapse = ", "))
|
|
254
|
+
if (nzchar(policy$note)) paste(base, policy$note) else base
|
|
255
|
+
}
|