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.
Files changed (114) hide show
  1. package/LICENSE +92 -0
  2. package/NOTICES +72 -0
  3. package/README.md +124 -0
  4. package/bin/beatrina.mjs +223 -0
  5. package/bin/cli.mjs +80 -0
  6. package/bin/failsafe.mjs +74 -0
  7. package/bin/identity.mjs +45 -0
  8. package/bin/prova-post.mjs +51 -0
  9. package/bin/sessions.mjs +95 -0
  10. package/bin/shortcut.mjs +151 -0
  11. package/bin/update-check.mjs +55 -0
  12. package/bin/upgrade.mjs +76 -0
  13. package/build-info.json +1 -0
  14. package/carmar_V0.8.6.html +1310 -0
  15. package/check/acceptance.mjs +278 -0
  16. package/check/session.mjs +215 -0
  17. package/engines/js/document-exec.mjs +82 -0
  18. package/engines/js/persist.mjs +214 -0
  19. package/engines/js/worker.mjs +424 -0
  20. package/engines/python/adapter.py +577 -0
  21. package/engines/python/analyze.py +814 -0
  22. package/engines/python/bootstrap.py +309 -0
  23. package/engines/python/dataview.py +735 -0
  24. package/engines/python/debugger.py +346 -0
  25. package/engines/python/document_exec.py +158 -0
  26. package/engines/python/engine.json +28 -0
  27. package/engines/python/handoff.py +118 -0
  28. package/engines/python/worker.py +564 -0
  29. package/engines/r/engine.json +25 -0
  30. package/engines/r/handoff.R +92 -0
  31. package/failsafe/ai-policy.R +255 -0
  32. package/failsafe/ai-store.R +373 -0
  33. package/failsafe/cite.R +418 -0
  34. package/failsafe/journal.R +684 -0
  35. package/failsafe/plugins.R +809 -0
  36. package/failsafe/serve.R +5500 -0
  37. package/host/ai-policy.mjs +218 -0
  38. package/host/deployment.mjs +160 -0
  39. package/host/engine-js.mjs +98 -0
  40. package/host/engine-pool.mjs +383 -0
  41. package/host/engine-python.mjs +228 -0
  42. package/host/engine-r.mjs +206 -0
  43. package/host/engine-stdio.mjs +401 -0
  44. package/host/journal-store.mjs +749 -0
  45. package/host/main.mjs +503 -0
  46. package/host/planes/README.md +41 -0
  47. package/host/planes/ai-store.mjs +327 -0
  48. package/host/planes/ai.mjs +467 -0
  49. package/host/planes/analyze.mjs +397 -0
  50. package/host/planes/cite.mjs +517 -0
  51. package/host/planes/files.mjs +0 -0
  52. package/host/planes/jobs.mjs +704 -0
  53. package/host/planes/journal.mjs +53 -0
  54. package/host/planes/latex.mjs +201 -0
  55. package/host/planes/mcp.mjs +493 -0
  56. package/host/planes/pair.mjs +325 -0
  57. package/host/planes/pipe-term.mjs +122 -0
  58. package/host/planes/plugins.mjs +112 -0
  59. package/host/planes/proc-tree.mjs +76 -0
  60. package/host/planes/sessions.mjs +434 -0
  61. package/host/planes/settings.mjs +164 -0
  62. package/host/planes/terminal.mjs +286 -0
  63. package/host/planes/test-file.mjs +80 -0
  64. package/host/planes/update.mjs +214 -0
  65. package/host/plugin-store.mjs +838 -0
  66. package/host/server.mjs +441 -0
  67. package/host/settings.mjs +379 -0
  68. package/host/update-record.mjs +59 -0
  69. package/host/user-dirs.mjs +117 -0
  70. package/host/windows-runtime.mjs +145 -0
  71. package/host/worker-plane.mjs +713 -0
  72. package/host/ws.mjs +190 -0
  73. package/kernel/analyze.R +668 -0
  74. package/kernel/deployment.R +165 -0
  75. package/kernel/examples/NOTICE.md +38 -0
  76. package/kernel/examples/tna-complete-tutorial.Rmd +210 -0
  77. package/kernel/fileio.R +656 -0
  78. package/kernel/index.html +96 -0
  79. package/kernel/job-run.R +391 -0
  80. package/kernel/jobs.R +276 -0
  81. package/kernel/kernel-protocol +1 -0
  82. package/kernel/kernel-version +1 -0
  83. package/kernel/kernel.R +671 -0
  84. package/kernel/knitr-run.R +245 -0
  85. package/kernel/latex.R +609 -0
  86. package/kernel/mcp/carmar-mcp.mjs +516 -0
  87. package/kernel/notebook-page.R +67 -0
  88. package/kernel/plugins/csl/apa/apa.csl +2273 -0
  89. package/kernel/plugins/csl/apa/plugin.json +19 -0
  90. package/kernel/plugins/csl/chicago-author-date/chicago-author-date.csl +4216 -0
  91. package/kernel/plugins/csl/chicago-author-date/plugin.json +19 -0
  92. package/kernel/plugins/csl/harvard-cite-them-right/harvard-cite-them-right.csl +316 -0
  93. package/kernel/plugins/csl/harvard-cite-them-right/plugin.json +19 -0
  94. package/kernel/plugins/csl/ieee/ieee.csl +519 -0
  95. package/kernel/plugins/csl/ieee/plugin.json +19 -0
  96. package/kernel/plugins/csl/modern-language-association/modern-language-association.csl +1184 -0
  97. package/kernel/plugins/csl/modern-language-association/plugin.json +19 -0
  98. package/kernel/plugins/csl/nature/nature.csl +189 -0
  99. package/kernel/plugins/csl/nature/plugin.json +19 -0
  100. package/kernel/plugins/latex/apa7/apa7.json +14 -0
  101. package/kernel/plugins/latex/apa7/plugin.json +19 -0
  102. package/kernel/plugins/latex/elsarticle/elsarticle.json +14 -0
  103. package/kernel/plugins/latex/elsarticle/plugin.json +19 -0
  104. package/kernel/plugins/latex/ieeetran/ieeetran.json +10 -0
  105. package/kernel/plugins/latex/ieeetran/plugin.json +19 -0
  106. package/kernel/project.R +131 -0
  107. package/kernel/settings.R +410 -0
  108. package/kernel/sniff.R +769 -0
  109. package/kernel/worker-boot.R +22 -0
  110. package/kernel/worker.R +3496 -0
  111. package/lib/agent-authoring-contract.js +547 -0
  112. package/lib/cell-kinds.js +108 -0
  113. package/lib/engine-labels.js +324 -0
  114. package/package.json +32 -0
@@ -0,0 +1,809 @@
1
+ # plugins.R — the plugin store: things the notebook can import, and the one
2
+ # door they come through.
3
+ #
4
+ # ── what a plugin is ────────────────────────────────────────────────────────
5
+ #
6
+ # A PLUGIN IS DATA A KNOWN CONSUMER READS. IT IS NEVER CODE. That sentence is
7
+ # the whole trust model, and it is what lets the mechanism be this small. A
8
+ # citation style is XML that pandoc parses; an editor theme is a palette the
9
+ # stylesheet applies. Neither can call anything. The notebook page runs under
10
+ # a CSP whose only script is its own bundle; a plugin kind whose payload was
11
+ # JavaScript would need a second script origin and would be the Chrome
12
+ # extension hole in a new coat (deleted in 0.38.0; CLAUDE.md "One transport").
13
+ # So the table of kinds is CLOSED — each kind names its consumer, its file
14
+ # types, its size cap and its validator — and "js" is not a row. A kind that
15
+ # is not in the table cannot be installed, whatever a manifest claims.
16
+ #
17
+ # ── where plugins live: three layers, like .libPaths() ──────────────────────
18
+ #
19
+ # user R_user_dir("carmar","data")/plugins/<kind>/<id>/ the person's own; the ONLY layer written
20
+ # system $CARMAR_PLUGIN_DIR/<kind>/<id>/ an administrator's set, read-only
21
+ # shipped <kernel>/plugins/<kind>/<id>/ what the kernel came with, read-only
22
+ #
23
+ # Searched in that order; the first OK copy of an id wins ("active"), so a
24
+ # person's copy shadows the department's, which shadows the bundle — exactly
25
+ # as a user library shadows the site library in R. A BROKEN copy never wins:
26
+ # integrity is checked on every listing, and a plugin whose payload no longer
27
+ # matches its manifest is listed with the reason and used by nothing.
28
+ #
29
+ # One plugin is one folder: a manifest and its payload. The manifest is
30
+ # written by the kernel at install (or by tools/plugins/stamp.mjs for the
31
+ # shipped set) and READ BACK, never trusted: id must equal the folder name,
32
+ # kind must equal the folder above, every file must be a bare name inside the
33
+ # folder, exist, sit under the size cap and hash to what the manifest says.
34
+ #
35
+ # ── the one door, and who may open it ───────────────────────────────────────
36
+ #
37
+ # `plugin_install` is the only write. Three sources: a kind's REGISTRY (the
38
+ # CSL repository, by name), a URL on a permitted host, or the TEXT of a file
39
+ # the person picked in the page (the offline door — nothing here needs a
40
+ # native chooser, and no path travels on the wire). Every install stages in a
41
+ # temp folder, validates there, and is renamed into place, so a half-download
42
+ # or a file that fails its kind's check is never a plugin. serve.R makes
43
+ # install/remove page-only in BOTH senses (PAGE_ONLY_CLASSES + AGENT_REFUSED)
44
+ # and audits each: changing what a machine renders with is the person's
45
+ # decision, made visibly. Listing and reading are open to any socket — an
46
+ # agent that knows which styles exist writes `csl: apa.csl` instead of
47
+ # guessing.
48
+ #
49
+ # ── the administrator's policy ───────────────────────────────────────────────
50
+ #
51
+ # CARMAR_PLUGIN_POLICY=/etc/carmar/plugin-policy.json the file (wins where both are set)
52
+ # CARMAR_PLUGIN_DIR=/opt/carmar/plugins the system layer
53
+ # CARMAR_PLUGIN_INSTALL=user|none may the person install? (default user)
54
+ # CARMAR_PLUGIN_SOURCES=host,host|none hosts a URL/registry install may fetch from
55
+ # CARMAR_PLUGIN_KINDS=csl,theme which kinds exist at all
56
+ #
57
+ # Same doctrine as ai-policy.R: the honest scope is the feature. The kernel
58
+ # owns every fetch and every write here, so unlike an AI policy this one is
59
+ # ENFORCEMENT — a host not on the list is never contacted, `install: none`
60
+ # means the user layer is neither written nor read. And the three-state rule
61
+ # holds: a policy that names nothing permits everything; one that names only
62
+ # junk is a startup error, never a silent "all". `carmar_plugin_policy()` is
63
+ # pure; serve.R joins its `errors` to the startup gate.
64
+ #
65
+ # Nothing here runs at start. The kernel lists folders when asked; nothing is
66
+ # read until a consumer asks; the network is touched only inside an install.
67
+
68
+ `%||%` <- if (exists("%||%")) `%||%` else function(a, b) if (is.null(a)) b else a
69
+
70
+ PLUGIN_ID_RE <- "^[a-z0-9][a-z0-9._-]{0,63}$"
71
+ PLUGIN_FILE_RE <- "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$"
72
+ PLUGIN_MANIFEST <- "plugin.json"
73
+ PLUGIN_MANIFEST_MAX_BYTES <- 65536L
74
+ PLUGIN_FETCH_TIMEOUT_S <- 20L
75
+ PLUGIN_POLICY_DEFAULT_FILE <- "/etc/carmar/plugin-policy.json"
76
+ PLUGIN_SYSTEM_DEFAULT_DIR <- "/etc/carmar/plugins"
77
+
78
+ # THIS LIST IS A CROSS-LANGUAGE CONTRACT with `EDITOR_TOKENS` in
79
+ # lib/editor-themes.js; test/plugins.test.mjs reads both and fails if they
80
+ # diverge. A theme plugin may set exactly these.
81
+ THEME_TOKENS <- c(
82
+ "--ed-bg", "--ed-fg", "--ed-gutter", "--ed-gutter-bg", "--ed-line", "--ed-sel",
83
+ "--ed-caret", "--ed-border", "--ed-bar", "--ed-brmatch",
84
+ "--rtok-com", "--rtok-doc", "--rtok-section", "--rtok-tag", "--rtok-str", "--rtok-num",
85
+ "--rtok-kw", "--rtok-const", "--rtok-fn", "--rtok-ns", "--rtok-arg", "--rtok-op",
86
+ "--rtok-infix", "--rtok-paren")
87
+ THEME_WASH_TOKENS <- c("--ed-line", "--ed-sel", "--ed-brmatch")
88
+
89
+ # ── the latex kind's vocabulary ─────────────────────────────────────────────
90
+ #
91
+ # THE SECOND CROSS-LANGUAGE CONTRACT, with `TEMPLATE_KEYS` / `CITE_STYLES` /
92
+ # `TOP_LEVELS` in lib/latex-templates.js; test/plugins.test.mjs reads both.
93
+ #
94
+ # A template plugin may set exactly these keys. It carries no `.cls` — a
95
+ # journal class is on the author's TeX installation or it is not, and shipping
96
+ # someone's class file would be redistribution with a licence question
97
+ # attached. What travels is the NAME of the class and the preamble lines that
98
+ # go with it.
99
+ LATEX_TEMPLATE_KEYS <- c("label", "documentclass", "classOptions", "citeStyle",
100
+ "citePackage", "topLevel", "preamble", "description", "version")
101
+ LATEX_CITE_STYLES <- c("natbib", "biblatex", "plain")
102
+ LATEX_TOP_LEVELS <- c("section", "chapter")
103
+ LATEX_CLASS_RE <- "^[A-Za-z][A-Za-z0-9._-]{0,39}$"
104
+ LATEX_OPTION_RE <- "^[A-Za-z0-9][A-Za-z0-9 =.,%*-]{0,79}$"
105
+ # `citeStyle` and `citePackage` are TWO decisions that look like one.
106
+ # `citeStyle` says which commands the body gets (\\parencite or \\citep or
107
+ # \\cite); `citePackage` says how the engine is loaded. latexDocument fused
108
+ # them until apa7, which needs biblatex's commands AND [style=apa] — with one
109
+ # key it would have loaded biblatex twice with contradictory options.
110
+ LATEX_CITE_PACKAGE_RE <- "^\\\\usepackage(\\[[^]]{0,120}\\])?\\{[A-Za-z0-9,._-]{1,60}\\}$"
111
+ LATEX_PREAMBLE_MAX_LINES <- 200L
112
+ LATEX_PREAMBLE_MAX_CHARS <- 400L
113
+
114
+ # WHY THIS ROW HAS A DENY-LIST AND THE OTHER TWO DO NOT.
115
+ #
116
+ # A CSL style is XML pandoc parses; it cannot call anything. A theme is a map
117
+ # of colours. A LaTeX preamble is CODE — not code CarmaR runs (the kernel
118
+ # never compiles a .tex; latex.js only writes one) but code the AUTHOR'S TeX
119
+ # engine runs when they build the paper. That is a weaker guarantee than the
120
+ # other two rows make, and it is written here rather than left to be inherited
121
+ # by proximity.
122
+ #
123
+ # What is left of it after the deny-list: shell escape is off by default in
124
+ # every current TeX distribution and needs --shell-escape on the command line,
125
+ # and the person compiling is the person who chose the template. What the
126
+ # list buys is that a template cannot ASK for those doors, so an author who
127
+ # does pass --shell-escape for an unrelated reason is not handing a downloaded
128
+ # file a shell. File inclusion is refused for the same reason a plugin's files
129
+ # must be bare names inside its folder: a template is one file, not a foothold
130
+ # for reading the author's disk into their PDF.
131
+ #
132
+ # A NOTE ON THE BOUNDARY, because the obvious one is wrong. A TeX control word
133
+ # ends at the first NON-LETTER, which is not what `\\b` means: in `\\openout1`
134
+ # the `t` and the `1` are both word characters, so `\\bopenout\\b` does not
135
+ # match the commonest way anyone writes it. `(?![A-Za-z])` is the real rule.
136
+ # It also has to cut both ways — `\\inputencoding` and `\\includegraphics` are
137
+ # ordinary and must NOT be caught by the `input`/`include` row, while
138
+ # `\\readline` must be caught by the file row, which is why only that row
139
+ # tolerates a suffix.
140
+ LATEX_FORBIDDEN <- list(
141
+ list(re = "\\\\write[[:space:]]*18", why = "runs a shell command"),
142
+ list(re = "\\\\(ShellEscape|directlua|luadirect|pdfprimitive|javascript)(?![A-Za-z])", why = "runs code outside TeX"),
143
+ list(re = "\\\\(openout|openin|read|write)[a-z]*(?![A-Za-z])", why = "reads or writes files"),
144
+ list(re = "\\\\(input|include|includeonly|subfile|subimport|import)(?![A-Za-z])", why = "pulls in another file"),
145
+ list(re = "shellesc|\\bshell-?escape", why = "asks for shell escape"),
146
+ list(re = "\\\\documentclass|\\\\begin[[:space:]]*\\{document\\}", why = "is the document's, not the preamble's")
147
+ )
148
+
149
+ #' The first forbidden construct in `text`, as a sentence, or "" when clean.
150
+ latex_forbidden_reason <- function(text) {
151
+ for (rule in LATEX_FORBIDDEN) {
152
+ if (any(grepl(rule$re, text, perl = TRUE))) return(rule$why)
153
+ }
154
+ ""
155
+ }
156
+
157
+ # ── the kinds table ─────────────────────────────────────────────────────────
158
+ #
159
+ # Closed, by design. A row is: what the payload is, who reads it, how big it
160
+ # may be, how it is checked, and — for a kind with a public registry — where
161
+ # it is searched and fetched by name. `validate(dir, file)` returns
162
+ # list(ok, error, name, version, requires) where `requires` names OTHER
163
+ # plugins of the same kind this one renders through (a dependent CSL style's
164
+ # parent); an install completes them from the registry.
165
+
166
+ PLUGIN_KINDS <- list(
167
+ csl = list(
168
+ label = "Citation styles",
169
+ consumer = "pandoc renders citations and the reference list with it; it is placed beside a saved document.",
170
+ extension = "csl",
171
+ max_bytes = 2000000,
172
+ license = "CC BY-SA 3.0",
173
+ registry = function() list(base = CSL_STYLE_BASE(), index = CSL_INDEX_URL()),
174
+ validate = function(dir, file) {
175
+ path <- file.path(dir, file)
176
+ if (!csl_looks_like_style(path)) return(list(ok = FALSE, error = "did not arrive as a CSL style"))
177
+ info <- csl_info(path)
178
+ parent <- sub("\\.csl$", "", info$parent)
179
+ if (nzchar(parent) && !grepl(PLUGIN_ID_RE, parent)) {
180
+ return(list(ok = FALSE, error = "names a parent style this kernel will not fetch"))
181
+ }
182
+ head_text <- paste(readLines(path, n = 80L, warn = FALSE, encoding = "UTF-8"), collapse = "\n")
183
+ updated <- regmatches(head_text, regexpr("<updated>[^<]+</updated>", head_text))
184
+ version <- if (length(updated)) substr(gsub("</?updated>", "", updated[[1L]]), 1L, 10L) else ""
185
+ list(ok = TRUE, name = info$title, version = version,
186
+ requires = if (nzchar(parent)) parent else character(0))
187
+ }),
188
+ theme = list(
189
+ label = "Editor themes",
190
+ consumer = "the editor: chunks, console, script editor and R code in prose.",
191
+ extension = "json",
192
+ max_bytes = 65536,
193
+ license = "",
194
+ registry = NULL,
195
+ validate = function(dir, file) {
196
+ doc <- tryCatch(jsonlite::fromJSON(file.path(dir, file), simplifyVector = FALSE), error = function(e) e)
197
+ if (inherits(doc, "error")) return(list(ok = FALSE, error = paste0("is not valid JSON: ", conditionMessage(doc))))
198
+ label <- doc$label
199
+ if (!is.character(label) || length(label) != 1L || !nzchar(trimws(label)) || nchar(label) > 60L) {
200
+ return(list(ok = FALSE, error = "needs a `label` of up to 60 characters"))
201
+ }
202
+ if (!is.logical(doc$dark) || length(doc$dark) != 1L || is.na(doc$dark)) {
203
+ return(list(ok = FALSE, error = "needs `dark`: true or false"))
204
+ }
205
+ vars <- doc$vars
206
+ if (!is.list(vars) || is.null(names(vars)) || !length(vars)) return(list(ok = FALSE, error = "needs a `vars` object of editor tokens"))
207
+ unknown <- setdiff(names(vars), THEME_TOKENS)
208
+ if (length(unknown)) return(list(ok = FALSE, error = paste0("sets tokens the editor does not have: ", paste(head(unknown, 3L), collapse = ", "))))
209
+ if (!all(c("--ed-bg", "--ed-fg") %in% names(vars))) return(list(ok = FALSE, error = "must set at least --ed-bg and --ed-fg"))
210
+ bad <- vapply(names(vars), function(k) {
211
+ v <- vars[[k]]
212
+ if (!is.character(v) || length(v) != 1L) return(TRUE)
213
+ hex <- grepl("^#[0-9a-fA-F]{6}$", v)
214
+ wash <- k %in% THEME_WASH_TOKENS && grepl("^rgba\\(\\s*[0-9]{1,3}\\s*,\\s*[0-9]{1,3}\\s*,\\s*[0-9]{1,3}\\s*,\\s*(0|1|0?\\.[0-9]{1,4})\\s*\\)$", v)
215
+ !(hex || wash)
216
+ }, logical(1))
217
+ if (any(bad)) return(list(ok = FALSE, error = paste0("colour for ", names(vars)[bad][[1L]], " must be #rrggbb (a wash may be rgba())")))
218
+ version <- doc$version
219
+ version <- if (is.character(version) && length(version) == 1L && !is.na(version)) substr(version, 1L, 40L) else ""
220
+ list(ok = TRUE, name = trimws(label), version = version, requires = character(0))
221
+ }),
222
+ latex = list(
223
+ label = "LaTeX templates",
224
+ consumer = "the LaTeX export: it sets the document class, its options and the preamble of the .tex that is written.",
225
+ extension = "json",
226
+ max_bytes = 65536,
227
+ license = "",
228
+ registry = NULL,
229
+ validate = function(dir, file) {
230
+ doc <- tryCatch(jsonlite::fromJSON(file.path(dir, file), simplifyVector = FALSE), error = function(e) e)
231
+ if (inherits(doc, "error")) return(list(ok = FALSE, error = paste0("is not valid JSON: ", conditionMessage(doc))))
232
+ if (!is.list(doc) || is.null(names(doc)) || !length(doc)) return(list(ok = FALSE, error = "is not a template object"))
233
+ unknown <- setdiff(names(doc), LATEX_TEMPLATE_KEYS)
234
+ if (length(unknown)) {
235
+ return(list(ok = FALSE, error = paste0("sets keys a template does not have: ", paste(utils::head(unknown, 3L), collapse = ", "))))
236
+ }
237
+ one <- function(v, max) is.character(v) && length(v) == 1L && !is.na(v) && nzchar(trimws(v)) && nchar(v) <= max
238
+ strings <- function(v) {
239
+ if (is.null(v)) return(character(0))
240
+ if (!is.list(v) && !is.character(v)) return(NULL)
241
+ out <- unlist(v, use.names = FALSE)
242
+ if (!length(out)) return(character(0))
243
+ if (!is.character(out) || anyNA(out)) return(NULL)
244
+ out
245
+ }
246
+
247
+ if (!one(doc$label, 60L)) return(list(ok = FALSE, error = "needs a `label` of up to 60 characters"))
248
+ if (!one(doc$documentclass, 40L) || !grepl(LATEX_CLASS_RE, doc$documentclass)) {
249
+ return(list(ok = FALSE, error = "needs a `documentclass` that is a plain class name (letters, digits, . _ -)"))
250
+ }
251
+
252
+ opts <- strings(doc$classOptions)
253
+ if (is.null(opts)) return(list(ok = FALSE, error = "`classOptions` must be a list of strings"))
254
+ if (length(opts) > 12L) return(list(ok = FALSE, error = "names more class options than a class takes"))
255
+ bad <- opts[!grepl(LATEX_OPTION_RE, opts)]
256
+ if (length(bad)) return(list(ok = FALSE, error = paste0("has a class option that is not a plain option: ", bad[[1L]])))
257
+
258
+ pre <- strings(doc$preamble)
259
+ if (is.null(pre)) return(list(ok = FALSE, error = "`preamble` must be a list of lines"))
260
+ if (length(pre) > LATEX_PREAMBLE_MAX_LINES) {
261
+ return(list(ok = FALSE, error = sprintf("has a preamble longer than %d lines", LATEX_PREAMBLE_MAX_LINES)))
262
+ }
263
+ long <- pre[nchar(pre) > LATEX_PREAMBLE_MAX_CHARS]
264
+ if (length(long)) return(list(ok = FALSE, error = sprintf("has a preamble line longer than %d characters", LATEX_PREAMBLE_MAX_CHARS)))
265
+
266
+ # The deny-list, over everything that reaches the .tex. See LATEX_FORBIDDEN.
267
+ why <- latex_forbidden_reason(c(doc$documentclass, opts, pre, as.character(doc$citePackage %||% "")))
268
+ if (nzchar(why)) return(list(ok = FALSE, error = paste0("contains LaTeX that ", why, ", which a template may not do")))
269
+
270
+ if (!is.null(doc$citeStyle) && !(one(doc$citeStyle, 20L) && doc$citeStyle %in% LATEX_CITE_STYLES)) {
271
+ return(list(ok = FALSE, error = paste0("`citeStyle` must be one of ", paste(LATEX_CITE_STYLES, collapse = ", "))))
272
+ }
273
+ if (!is.null(doc$citePackage) && !(one(doc$citePackage, 200L) && grepl(LATEX_CITE_PACKAGE_RE, doc$citePackage))) {
274
+ return(list(ok = FALSE, error = "`citePackage` must be a single \\usepackage line"))
275
+ }
276
+ if (!is.null(doc$topLevel) && !(one(doc$topLevel, 20L) && doc$topLevel %in% LATEX_TOP_LEVELS)) {
277
+ return(list(ok = FALSE, error = paste0("`topLevel` must be one of ", paste(LATEX_TOP_LEVELS, collapse = ", "))))
278
+ }
279
+ if (!is.null(doc$description) && !one(doc$description, 200L)) {
280
+ return(list(ok = FALSE, error = "`description` must be a sentence of up to 200 characters"))
281
+ }
282
+
283
+ version <- doc$version
284
+ version <- if (is.character(version) && length(version) == 1L && !is.na(version)) substr(version, 1L, 40L) else ""
285
+ list(ok = TRUE, name = trimws(doc$label), version = version, requires = character(0))
286
+ })
287
+ )
288
+
289
+ plugin_kind_ok <- function(kind) is.character(kind) && length(kind) == 1L && !is.na(kind) && kind %in% names(PLUGIN_KINDS)
290
+ plugin_id_ok <- function(id) is.character(id) && length(id) == 1L && !is.na(id) && grepl(PLUGIN_ID_RE, id)
291
+ plugin_file_ok <- function(file) is.character(file) && length(file) == 1L && !is.na(file) && grepl(PLUGIN_FILE_RE, file) &&
292
+ !grepl("\\.\\.", file, fixed = FALSE)
293
+
294
+ policy_split <- if (exists("policy_split")) policy_split else function(value) {
295
+ parts <- trimws(unlist(strsplit(value %||% "", "[,[:space:]]+")))
296
+ parts[nzchar(parts)]
297
+ }
298
+
299
+ #' The host of a URL, lower-cased, or "" when it has none.
300
+ url_host <- function(url) {
301
+ m <- regmatches(url, regexpr("^https?://[^/?#]+", url))
302
+ if (!length(m)) return("")
303
+ tolower(sub("^https?://", "", sub(":[0-9]+$", "", m[[1L]])))
304
+ }
305
+
306
+ #' The hosts an install may fetch from when no policy names any: the
307
+ #' registries of the kinds themselves. Computed, not written down, so a test
308
+ #' pointing the CSL registry at 127.0.0.1 permits 127.0.0.1 without a second
309
+ #' override.
310
+ plugin_default_hosts <- function() {
311
+ hosts <- unlist(lapply(PLUGIN_KINDS, function(k) {
312
+ if (is.null(k$registry)) return(character(0))
313
+ r <- k$registry()
314
+ c(url_host(r$base), url_host(r$index))
315
+ }))
316
+ unique(hosts[nzchar(hosts)])
317
+ }
318
+
319
+ #' Read the administrator's plugin policy.
320
+ #'
321
+ #' @param env a getter with `Sys.getenv`'s signature; injected for tests.
322
+ #' @param default_file the file read when CARMAR_PLUGIN_POLICY is unset, if it exists.
323
+ #' @param default_dir the system layer when CARMAR_PLUGIN_DIR is unset, if it exists.
324
+ #' @return list(set, source, install, sources, network, kinds, unknown, note,
325
+ #' system_dir, errors). `kinds` is the permitted kinds — every kind when
326
+ #' unset. `sources` is the permitted hosts; `network` FALSE when none may
327
+ #' be contacted (`sources: none`, or an empty valid list).
328
+ #'
329
+ #' Three states, as in ai-policy.R: names nothing → everything; names
330
+ #' something valid → exactly that; names ONLY junk → an error, because an
331
+ #' empty allow-list that fails open is the bug the AI policy suite caught.
332
+ carmar_plugin_policy <- function(env = Sys.getenv, default_file = PLUGIN_POLICY_DEFAULT_FILE,
333
+ default_dir = PLUGIN_SYSTEM_DEFAULT_DIR) {
334
+ errors <- character(0)
335
+ source <- ""
336
+ install <- ""
337
+ sources_raw <- NULL
338
+ kinds_raw <- NULL
339
+ note <- ""
340
+ system_dir <- trimws(env("CARMAR_PLUGIN_DIR", ""))
341
+ if (!nzchar(system_dir) && nzchar(default_dir) && dir.exists(default_dir)) system_dir <- default_dir
342
+
343
+ path <- trimws(env("CARMAR_PLUGIN_POLICY", ""))
344
+ if (!nzchar(path) && nzchar(default_file) && file.exists(default_file)) path <- default_file
345
+ if (nzchar(path)) {
346
+ source <- path
347
+ if (!file.exists(path)) {
348
+ errors <- c(errors, paste0("CARMAR_PLUGIN_POLICY names a file that does not exist: ", path))
349
+ } else {
350
+ doc <- tryCatch(jsonlite::fromJSON(path, simplifyVector = TRUE), error = function(e) e)
351
+ if (inherits(doc, "error")) {
352
+ errors <- c(errors, paste0("CARMAR_PLUGIN_POLICY is not valid JSON: ", conditionMessage(doc)))
353
+ } else {
354
+ install <- trimws(as.character(doc$install %||% "")[1] %||% "")
355
+ if (!is.null(doc$sources)) sources_raw <- as.character(unlist(doc$sources))
356
+ if (!is.null(doc$kinds)) kinds_raw <- as.character(unlist(doc$kinds))
357
+ note <- trimws(as.character(doc$note %||% "")[1] %||% "")
358
+ if (is.na(note)) note <- ""
359
+ if (is.na(install)) install <- ""
360
+ d <- trimws(as.character(doc$system_dir %||% "")[1] %||% "")
361
+ if (!is.na(d) && nzchar(d)) system_dir <- d
362
+ }
363
+ }
364
+ } else {
365
+ if (nzchar(env("CARMAR_PLUGIN_INSTALL", ""))) { source <- "env"; install <- trimws(env("CARMAR_PLUGIN_INSTALL", "")) }
366
+ if (nzchar(env("CARMAR_PLUGIN_SOURCES", ""))) { source <- "env"; sources_raw <- policy_split(env("CARMAR_PLUGIN_SOURCES", "")) }
367
+ if (nzchar(env("CARMAR_PLUGIN_KINDS", ""))) { source <- "env"; kinds_raw <- policy_split(env("CARMAR_PLUGIN_KINDS", "")) }
368
+ }
369
+
370
+ if (!nzchar(install)) install <- "user"
371
+ if (!install %in% c("user", "none")) {
372
+ errors <- c(errors, paste0("The plugin policy's `install` must be \"user\" or \"none\", not \"", install, "\"."))
373
+ install <- "user"
374
+ }
375
+
376
+ # Kinds: three states.
377
+ kinds_named <- unique(trimws(kinds_raw %||% character(0)))
378
+ kinds_named <- kinds_named[nzchar(kinds_named)]
379
+ unknown <- setdiff(kinds_named, names(PLUGIN_KINDS))
380
+ kinds <- if (length(kinds_named)) intersect(kinds_named, names(PLUGIN_KINDS)) else names(PLUGIN_KINDS)
381
+ if (length(kinds_named) && !length(kinds)) {
382
+ errors <- c(errors, paste0("The plugin policy permits no plugin kind at all (unrecognised: ",
383
+ paste(unknown, collapse = ", "), "). Name at least one of ",
384
+ paste(names(PLUGIN_KINDS), collapse = ", "), ", or remove the policy."))
385
+ }
386
+
387
+ # Sources: an absent list means the kinds' own registries; "none" means no
388
+ # network at all; anything else is an exact host list (an administrator's
389
+ # mirror). A host is a host — no scheme, no path — and https is required
390
+ # of every fetch regardless of the list.
391
+ network <- TRUE
392
+ sources <- character(0)
393
+ if (is.null(sources_raw)) {
394
+ sources <- plugin_default_hosts()
395
+ } else {
396
+ named <- unique(tolower(trimws(sources_raw)))
397
+ named <- named[nzchar(named)]
398
+ if (identical(named, "none") || !length(named)) {
399
+ network <- FALSE
400
+ } else {
401
+ bad <- named[!grepl("^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$", named)]
402
+ if (length(bad)) errors <- c(errors, paste0("The plugin policy's `sources` must be bare host names: ", paste(bad, collapse = ", ")))
403
+ sources <- setdiff(named, bad)
404
+ if (!length(sources)) network <- FALSE
405
+ }
406
+ }
407
+
408
+ set <- nzchar(source) && !length(errors)
409
+ if (length(errors)) { install <- "user"; kinds <- names(PLUGIN_KINDS); sources <- plugin_default_hosts(); network <- TRUE }
410
+ note <- gsub("[[:cntrl:]]", " ", note)
411
+ note <- trimws(substr(note, 1L, 400L))
412
+
413
+ list(set = set, source = source, install = install, sources = sources, network = network,
414
+ kinds = kinds, unknown = unknown, note = note, system_dir = system_dir, errors = errors)
415
+ }
416
+
417
+ #' The sentence shown when the policy refuses something.
418
+ plugin_policy_reason <- function(policy, what = "install") {
419
+ base <- switch(what,
420
+ install = "This CarmaR is configured so that plugins are not installed by users; the set is fixed by the deployment.",
421
+ network = "This CarmaR is configured to fetch no plugins from the network; a plugin arrives as a file.",
422
+ host = sprintf("This CarmaR is configured to fetch plugins only from: %s.", paste(policy$sources, collapse = ", ")),
423
+ kind = "This CarmaR is configured without that plugin kind.",
424
+ "This CarmaR's plugin policy refuses that.")
425
+ if (nzchar(policy$note)) paste(base, policy$note) else base
426
+ }
427
+
428
+ # ── layers and paths ─────────────────────────────────────────────────────────
429
+
430
+ plugin_user_dir <- function() file.path(tools::R_user_dir("carmar", "data"), "plugins")
431
+
432
+ #' The layers, in precedence order. The user layer is absent under
433
+ #' `install: none` — not read-only, ABSENT, because "the set is fixed by the
434
+ #' deployment" and a person's earlier imports quietly outranking it would
435
+ #' make that sentence false.
436
+ plugin_layers <- function(here, policy) {
437
+ layers <- list()
438
+ if (identical(policy$install, "user")) layers <- c(layers, list(list(name = "user", dir = plugin_user_dir(), writable = TRUE)))
439
+ if (nzchar(policy$system_dir)) layers <- c(layers, list(list(name = "system", dir = policy$system_dir, writable = FALSE)))
440
+ c(layers, list(list(name = "shipped", dir = file.path(here, "plugins"), writable = FALSE)))
441
+ }
442
+
443
+ #' A path INSIDE `base`, or NULL. The regexes on id and file already forbid a
444
+ #' separator and `..`; this is the second lock — resolved paths, compared —
445
+ #' so a symlink or an encoding trick a regex did not foresee still cannot
446
+ #' reach out of the layer. `mustWork = FALSE` because the target may not
447
+ #' exist yet at install.
448
+ plugin_contained <- function(base, ...) {
449
+ if (!dir.exists(base)) return(NULL)
450
+ root <- normalizePath(base, winslash = "/", mustWork = TRUE)
451
+ parts <- c(...)
452
+ if (!length(parts) || !all(vapply(parts, function(x) is.character(x) && length(x) == 1L && !is.na(x) &&
453
+ !grepl("[/\\\\]|^\\.\\.?$", x), logical(1)))) return(NULL)
454
+ path <- file.path(root, paste(parts, collapse = "/"))
455
+ # An existing target is resolved through any symlink before the check;
456
+ # one that does not exist yet is a plain child of the resolved root by
457
+ # construction (no separator, no dot segment — refused above).
458
+ if (file.exists(path)) path <- normalizePath(path, winslash = "/", mustWork = TRUE)
459
+ if (!identical(path, root) && !startsWith(path, paste0(root, "/"))) return(NULL)
460
+ path
461
+ }
462
+
463
+ plugin_digest <- function(path) {
464
+ if (exists("sha256sum", asNamespace("tools"))) {
465
+ list(algo = "sha256", value = unname(tools::sha256sum(path)))
466
+ } else {
467
+ list(algo = "md5", value = unname(tools::md5sum(path)))
468
+ }
469
+ }
470
+
471
+ # ── the manifest ────────────────────────────────────────────────────────────
472
+
473
+ #' Build the manifest for a validated payload sitting in `dir`.
474
+ plugin_manifest_for <- function(dir, kind, id, files, info, source, license = "") {
475
+ digests <- lapply(files, function(f) plugin_digest(file.path(dir, f)))
476
+ names(digests) <- files
477
+ algo <- digests[[1L]]$algo
478
+ list(
479
+ id = id, kind = kind,
480
+ name = substr(info$name %||% id, 1L, 200L),
481
+ version = info$version %||% "",
482
+ file = files[[1L]], files = I(files),
483
+ requires = I(as.character(info$requires %||% character(0))),
484
+ license = license %||% "",
485
+ source = source,
486
+ digest = algo,
487
+ sha256 = if (identical(algo, "sha256")) lapply(digests, function(d) d$value) else NULL,
488
+ md5 = if (identical(algo, "md5")) lapply(digests, function(d) d$value) else NULL,
489
+ bytes = sum(vapply(files, function(f) as.numeric(file.info(file.path(dir, f))$size), numeric(1))),
490
+ installed = format(Sys.time(), "%Y-%m-%dT%H:%M:%SZ", tz = "UTC"))
491
+ }
492
+
493
+ plugin_manifest_write <- function(dir, manifest) {
494
+ manifest <- manifest[!vapply(manifest, is.null, logical(1))]
495
+ writeLines(jsonlite::toJSON(manifest, auto_unbox = TRUE, pretty = TRUE, null = "null"),
496
+ file.path(dir, PLUGIN_MANIFEST), useBytes = TRUE)
497
+ }
498
+
499
+ #' Read and CHECK one plugin folder. Returns a row-shaped list with
500
+ #' `status` "ok" or "broken" and a `reason`; a folder is never trusted for
501
+ #' what its manifest claims.
502
+ plugin_inspect <- function(layer, kind, id) {
503
+ dir <- plugin_contained(file.path(layer$dir, kind), id)
504
+ row <- list(kind = kind, id = id, name = id, version = "", layer = layer$name, dir = dir %||% "",
505
+ file = "", files = character(0), requires = character(0), source = "", license = "",
506
+ installed = "", bytes = 0, status = "broken", reason = "", verified = FALSE)
507
+ if (is.null(dir) || !dir.exists(dir)) { row$reason <- "the folder is outside its layer"; return(row) }
508
+ mpath <- file.path(dir, PLUGIN_MANIFEST)
509
+ if (!file.exists(mpath)) { row$reason <- "no plugin.json"; return(row) }
510
+ if (file.info(mpath)$size > PLUGIN_MANIFEST_MAX_BYTES) { row$reason <- "plugin.json is too large to be one"; return(row) }
511
+ m <- tryCatch(jsonlite::fromJSON(mpath, simplifyVector = TRUE), error = function(e) e)
512
+ if (inherits(m, "error")) { row$reason <- paste0("plugin.json is not valid JSON: ", conditionMessage(m)); return(row) }
513
+ if (!identical(m$id, id)) { row$reason <- "plugin.json names a different id than its folder"; return(row) }
514
+ if (!identical(m$kind, kind)) { row$reason <- "plugin.json names a different kind than its folder"; return(row) }
515
+ files <- as.character(m$files %||% m$file %||% character(0))
516
+ if (!length(files) || !all(vapply(files, plugin_file_ok, logical(1)))) { row$reason <- "plugin.json lists no valid file"; return(row) }
517
+ spec <- PLUGIN_KINDS[[kind]]
518
+ if (!grepl(paste0("\\.", spec$extension, "$"), files[[1L]])) { row$reason <- paste0("the payload is not a .", spec$extension, " file"); return(row) }
519
+ row$name <- substr(as.character(m$name %||% id)[1L], 1L, 200L)
520
+ row$version <- as.character(m$version %||% "")[1L]
521
+ row$file <- files[[1L]]; row$files <- files
522
+ row$requires <- as.character(unlist(m$requires %||% character(0)))
523
+ row$source <- as.character(m$source %||% "")[1L]
524
+ row$license <- as.character(m$license %||% "")[1L]
525
+ row$installed <- as.character(m$installed %||% "")[1L]
526
+ if (is.na(row$name)) row$name <- id
527
+ for (f in files) { # a handful of files at most
528
+ p <- plugin_contained(dir, f)
529
+ if (is.null(p) || !file.exists(p)) { row$reason <- paste0(f, " is missing"); return(row) }
530
+ size <- file.info(p)$size
531
+ if (is.na(size) || size > spec$max_bytes) { row$reason <- paste0(f, " is larger than this kind allows"); return(row) }
532
+ row$bytes <- row$bytes + size
533
+ }
534
+ # Integrity: the recorded digest, recomputed. A manifest carrying sha256
535
+ # on an R that cannot compute it (< 4.5) is listed `verified = FALSE`
536
+ # rather than refused — the file is still the file; only the proof is
537
+ # unavailable here, and the row says so.
538
+ algo <- as.character(m$digest %||% if (!is.null(m$sha256)) "sha256" else if (!is.null(m$md5)) "md5" else "")[1L]
539
+ recorded <- if (identical(algo, "sha256")) m$sha256 else if (identical(algo, "md5")) m$md5 else NULL
540
+ can <- identical(algo, "md5") || (identical(algo, "sha256") && exists("sha256sum", asNamespace("tools")))
541
+ if (nzchar(algo) && !is.null(recorded) && can) {
542
+ for (f in files) {
543
+ want <- as.character(recorded[[f]] %||% "")
544
+ have <- if (identical(algo, "sha256")) unname(tools::sha256sum(file.path(dir, f))) else unname(tools::md5sum(file.path(dir, f)))
545
+ if (!nzchar(want) || !identical(tolower(want), tolower(have))) {
546
+ row$reason <- paste0(f, " has changed since it was installed (", algo, " differs)")
547
+ return(row)
548
+ }
549
+ }
550
+ row$verified <- TRUE
551
+ }
552
+ row$status <- "ok"
553
+ row
554
+ }
555
+
556
+ #' Every plugin in every layer, checked, one row each.
557
+ #'
558
+ #' @return data.frame(kind, id, name, version, layer, dir, file, requires
559
+ #' (";"-joined), files (";"-joined), source, license, installed, bytes, status, reason,
560
+ #' verified, active). `active` marks the copy consumers use: the first OK
561
+ #' copy of an id in layer order. Kinds the policy does not permit are not
562
+ #' listed at all.
563
+ plugin_rows <- function(here, policy, kind = NULL) {
564
+ empty <- data.frame(kind = character(), id = character(), name = character(), version = character(),
565
+ layer = character(), dir = character(), file = character(), files = character(), requires = character(),
566
+ source = character(), license = character(), installed = character(), bytes = numeric(),
567
+ status = character(), reason = character(), verified = logical(), active = logical(),
568
+ stringsAsFactors = FALSE)
569
+ kinds <- if (is.null(kind)) policy$kinds else intersect(kind, policy$kinds)
570
+ rows <- list()
571
+ for (layer in plugin_layers(here, policy)) { # three layers at most
572
+ for (k in kinds) { # a handful of kinds
573
+ kdir <- file.path(layer$dir, k)
574
+ if (!dir.exists(kdir)) next
575
+ ids <- sort(list.files(kdir, full.names = FALSE, no.. = TRUE))
576
+ ids <- ids[!startsWith(ids, ".") & dir.exists(file.path(kdir, ids))]
577
+ ids <- ids[grepl(PLUGIN_ID_RE, ids)]
578
+ rows <- c(rows, lapply(ids, function(id) plugin_inspect(layer, k, id)))
579
+ }
580
+ }
581
+ if (!length(rows)) return(empty)
582
+ out <- data.frame(
583
+ kind = vapply(rows, `[[`, character(1), "kind"), id = vapply(rows, `[[`, character(1), "id"),
584
+ name = vapply(rows, `[[`, character(1), "name"), version = vapply(rows, `[[`, character(1), "version"),
585
+ layer = vapply(rows, `[[`, character(1), "layer"), dir = vapply(rows, `[[`, character(1), "dir"),
586
+ file = vapply(rows, `[[`, character(1), "file"),
587
+ files = vapply(rows, function(r) paste(r$files, collapse = ";"), character(1)),
588
+ requires = vapply(rows, function(r) paste(r$requires, collapse = ";"), character(1)),
589
+ source = vapply(rows, `[[`, character(1), "source"), license = vapply(rows, `[[`, character(1), "license"),
590
+ installed = vapply(rows, `[[`, character(1), "installed"), bytes = vapply(rows, `[[`, numeric(1), "bytes"),
591
+ status = vapply(rows, `[[`, character(1), "status"), reason = vapply(rows, `[[`, character(1), "reason"),
592
+ verified = vapply(rows, `[[`, logical(1), "verified"),
593
+ stringsAsFactors = FALSE)
594
+ key <- paste(out$kind, out$id)
595
+ out$active <- out$status == "ok" & !duplicated(ifelse(out$status == "ok", key, paste0(key, "#", seq_len(nrow(out)))))
596
+ rownames(out) <- NULL
597
+ out
598
+ }
599
+
600
+ #' The active copy of one plugin, or NULL.
601
+ plugin_active <- function(here, policy, kind, id) {
602
+ if (!plugin_kind_ok(kind) || !plugin_id_ok(id)) return(NULL)
603
+ rows <- plugin_rows(here, policy, kind)
604
+ hit <- rows[rows$id == id & rows$active, , drop = FALSE]
605
+ if (!nrow(hit)) return(NULL)
606
+ as.list(hit[1L, ])
607
+ }
608
+
609
+ #' The text of one manifest-listed file of an active plugin.
610
+ plugin_read <- function(here, policy, kind, id, file = NULL) {
611
+ row <- plugin_active(here, policy, kind, id)
612
+ if (is.null(row)) return(list(ok = FALSE, error = "No such plugin."))
613
+ file <- file %||% row$file
614
+ listed <- strsplit(row$files, ";")[[1L]]
615
+ if (!plugin_file_ok(file) || !file %in% listed) return(list(ok = FALSE, error = "That file is not part of the plugin."))
616
+ path <- plugin_contained(row$dir, file)
617
+ if (is.null(path)) return(list(ok = FALSE, error = "That file is not inside the plugin."))
618
+ text <- tryCatch(paste(readLines(path, warn = FALSE, encoding = "UTF-8"), collapse = "\n"), error = function(e) NULL)
619
+ if (is.null(text)) return(list(ok = FALSE, error = "The file could not be read."))
620
+ list(ok = TRUE, kind = kind, id = id, file = file, name = row$name, text = text)
621
+ }
622
+
623
+ # ── fetching ────────────────────────────────────────────────────────────────
624
+
625
+ #' Fetch one URL into `dest`, bounded, from a permitted host only.
626
+ #' https everywhere but loopback (the test registry); the host must be on
627
+ #' the policy's list. Returns "" or the reason.
628
+ plugin_fetch <- function(policy, url, dest, max_bytes) {
629
+ if (!isTRUE(policy$network)) return(plugin_policy_reason(policy, "network"))
630
+ if (!is.character(url) || length(url) != 1L || is.na(url)) return("not a URL")
631
+ host <- url_host(url)
632
+ loopback <- host %in% c("127.0.0.1", "localhost")
633
+ if (!grepl("^https://", url) && !(loopback && grepl("^http://", url))) return("only https is fetched")
634
+ if (!nzchar(host) || !host %in% policy$sources) return(plugin_policy_reason(policy, "host"))
635
+ old <- options(timeout = PLUGIN_FETCH_TIMEOUT_S); on.exit(options(old), add = TRUE)
636
+ status <- tryCatch(
637
+ suppressWarnings(utils::download.file(url, dest, method = "libcurl", quiet = TRUE, mode = "wb")),
638
+ error = function(e) conditionMessage(e))
639
+ if (!identical(status, 0L)) {
640
+ unlink(dest)
641
+ return(if (is.character(status)) status else paste("download returned", status))
642
+ }
643
+ size <- file.info(dest)$size
644
+ if (is.na(size) || size > max_bytes) {
645
+ unlink(dest)
646
+ return(sprintf("the file is larger than this kind allows (%d KB)", as.integer(max_bytes / 1000)))
647
+ }
648
+ ""
649
+ }
650
+
651
+ # ── install and remove ──────────────────────────────────────────────────────
652
+
653
+ plugin_stage_dir <- function(user_dir) {
654
+ staging <- file.path(user_dir, ".staging")
655
+ dir.create(staging, recursive = TRUE, showWarnings = FALSE, mode = "0700")
656
+ d <- tempfile("plugin-", tmpdir = staging)
657
+ dir.create(d, mode = "0700")
658
+ d
659
+ }
660
+
661
+ #' Install one plugin into the user layer.
662
+ #'
663
+ #' @param from one of list(registry = "<name>") · list(url = "<https://…>") ·
664
+ #' list(text = "<contents>", filename = "<name.ext>")
665
+ #' @param complete install the plugins this one `requires` from the registry
666
+ #' (a dependent CSL style's parent). One level: a parent is independent.
667
+ #' @return list(ok = TRUE, kind, id, name, version, file, requires,
668
+ #' already = FALSE) or list(ok = FALSE, error)
669
+ plugin_install <- function(here, policy, kind, from, complete = TRUE) {
670
+ if (!plugin_kind_ok(kind)) return(list(ok = FALSE, error = "That is not a plugin kind this kernel has."))
671
+ if (!kind %in% policy$kinds) return(list(ok = FALSE, error = plugin_policy_reason(policy, "kind")))
672
+ if (!identical(policy$install, "user")) return(list(ok = FALSE, error = plugin_policy_reason(policy, "install")))
673
+ if (!is.list(from)) return(list(ok = FALSE, error = "`from` must say where the plugin comes from."))
674
+ spec <- PLUGIN_KINDS[[kind]]
675
+ ext <- paste0(".", spec$extension)
676
+
677
+ # The id and the source, from whichever door.
678
+ scalar <- function(x) is.character(x) && length(x) == 1L && !is.na(x)
679
+ if (scalar(from$registry)) {
680
+ if (is.null(spec$registry)) return(list(ok = FALSE, error = "That plugin kind has no registry to fetch by name from."))
681
+ id <- from$registry
682
+ if (!plugin_id_ok(id)) return(list(ok = FALSE, error = "That is not a name from the registry."))
683
+ source <- paste0(spec$registry()$base, id)
684
+ door <- "registry"
685
+ } else if (scalar(from$url)) {
686
+ source <- from$url
687
+ id <- sub(paste0("\\", ext, "$"), "", basename(sub("[?#].*$", "", source)))
688
+ if (!plugin_id_ok(id)) return(list(ok = FALSE, error = "The URL does not end in a name a plugin can have (lower-case letters, digits, . _ -)."))
689
+ door <- "url"
690
+ } else if (scalar(from$text) && scalar(from$filename)) {
691
+ fname <- from$filename # a picked file's own name: a path in it is not a name
692
+ if (!plugin_file_ok(fname) || !grepl(paste0("\\", ext, "$"), fname, ignore.case = TRUE)) {
693
+ return(list(ok = FALSE, error = paste0("The file must be a ", ext, " file with a plain name.")))
694
+ }
695
+ id <- tolower(sub(paste0("\\", ext, "$"), "", fname, ignore.case = TRUE))
696
+ if (!plugin_id_ok(id)) return(list(ok = FALSE, error = "The file's name is not one a plugin can have (lower-case letters, digits, . _ -)."))
697
+ if (nchar(from$text, type = "bytes") > spec$max_bytes) return(list(ok = FALSE, error = "The file is larger than this kind allows."))
698
+ source <- paste0("file:", fname)
699
+ door <- "text"
700
+ } else {
701
+ return(list(ok = FALSE, error = "`from` must be a registry name, a URL, or a file's text."))
702
+ }
703
+
704
+ # A shipped or system copy of that id is already the plugin: nothing to
705
+ # write, and the answer says so. (A user copy IS replaced — that is how a
706
+ # style is updated.)
707
+ rows <- plugin_rows(here, policy, kind)
708
+ have <- rows[rows$id == id & rows$active & rows$layer != "user", , drop = FALSE]
709
+ if (nrow(have)) {
710
+ return(list(ok = TRUE, kind = kind, id = id, name = have$name[[1L]], version = have$version[[1L]],
711
+ file = have$file[[1L]], requires = strsplit(have$requires[[1L]], ";")[[1L]], layer = have$layer[[1L]],
712
+ already = TRUE))
713
+ }
714
+
715
+ user_dir <- plugin_user_dir()
716
+ dir.create(file.path(user_dir, kind), recursive = TRUE, showWarnings = FALSE, mode = "0700")
717
+ stage <- plugin_stage_dir(user_dir)
718
+ on.exit(unlink(stage, recursive = TRUE, force = TRUE), add = TRUE)
719
+ file <- paste0(id, ext)
720
+ target_tmp <- file.path(stage, file)
721
+ if (identical(door, "text")) {
722
+ ok <- tryCatch({ writeLines(from$text, target_tmp, useBytes = TRUE, sep = ""); TRUE }, error = function(e) FALSE)
723
+ if (!ok) return(list(ok = FALSE, error = "Could not write the file."))
724
+ } else {
725
+ err <- plugin_fetch(policy, source, target_tmp, spec$max_bytes)
726
+ if (nzchar(err)) return(list(ok = FALSE, error = paste0("Could not fetch ", id, ": ", err)))
727
+ }
728
+ Sys.chmod(target_tmp, "0600")
729
+ v <- spec$validate(stage, file)
730
+ if (!isTRUE(v$ok)) return(list(ok = FALSE, error = paste0(id, " ", v$error, ".")))
731
+
732
+ # What it renders through, completed from the registry — once, before
733
+ # this one lands, so a plugin never sits installed with a missing parent.
734
+ requires <- as.character(v$requires %||% character(0))
735
+ for (req in requires) { # one parent at most in practice
736
+ if (!is.null(plugin_active(here, policy, kind, req))) next
737
+ if (!isTRUE(complete) || is.null(spec$registry)) {
738
+ return(list(ok = FALSE, error = paste0(id, " renders through ", req, ", which is not on this machine.")))
739
+ }
740
+ r <- plugin_install(here, policy, kind, list(registry = req), complete = FALSE)
741
+ if (!isTRUE(r$ok)) return(list(ok = FALSE, error = paste0("Could not complete ", id, ": ", r$error)))
742
+ if (length(r$requires)) {
743
+ plugin_remove(here, policy, kind, req)
744
+ return(list(ok = FALSE, error = paste0(req, " is not an independent plugin; ", id, " was not installed.")))
745
+ }
746
+ }
747
+
748
+ manifest <- plugin_manifest_for(stage, kind, id, file, v, source, spec$license)
749
+ plugin_manifest_write(stage, manifest)
750
+ Sys.chmod(file.path(stage, PLUGIN_MANIFEST), "0600")
751
+
752
+ # Into place, atomically: the staged folder is renamed onto the id. An
753
+ # existing user copy is moved aside first and removed after, so at no
754
+ # instant is there half a plugin at that path.
755
+ dest <- plugin_contained(file.path(user_dir, kind), id)
756
+ if (is.null(dest)) return(list(ok = FALSE, error = "The plugin's folder would fall outside the user layer."))
757
+ old <- NULL
758
+ if (dir.exists(dest)) {
759
+ old <- tempfile("plugin-old-", tmpdir = file.path(user_dir, ".staging"))
760
+ if (!file.rename(dest, old)) return(list(ok = FALSE, error = "Could not replace the existing copy."))
761
+ }
762
+ if (!file.rename(stage, dest)) {
763
+ if (!is.null(old)) file.rename(old, dest)
764
+ return(list(ok = FALSE, error = "Could not move the plugin into place."))
765
+ }
766
+ if (!is.null(old)) unlink(old, recursive = TRUE, force = TRUE)
767
+ on.exit(NULL) # staged folder is now the plugin; nothing to clean
768
+ list(ok = TRUE, kind = kind, id = id, name = manifest$name, version = manifest$version,
769
+ file = file, requires = requires, layer = "user", already = FALSE)
770
+ }
771
+
772
+ #' Remove one plugin from the user layer. A shipped or system plugin is not
773
+ #' the person's to remove; a plugin another user plugin renders through
774
+ #' stays until that one goes.
775
+ plugin_remove <- function(here, policy, kind, id) {
776
+ if (!plugin_kind_ok(kind) || !plugin_id_ok(id)) return(list(ok = FALSE, error = "That is not a plugin."))
777
+ if (!identical(policy$install, "user")) return(list(ok = FALSE, error = plugin_policy_reason(policy, "install")))
778
+ rows <- plugin_rows(here, policy, kind)
779
+ mine <- rows[rows$id == id & rows$layer == "user", , drop = FALSE]
780
+ if (!nrow(mine)) {
781
+ if (any(rows$id == id)) return(list(ok = FALSE, error = "That plugin is part of this deployment; it cannot be removed here."))
782
+ return(list(ok = FALSE, error = "No such plugin."))
783
+ }
784
+ needs <- rows[rows$layer == "user" & rows$id != id &
785
+ vapply(strsplit(rows$requires, ";"), function(r) id %in% r, logical(1)), , drop = FALSE]
786
+ if (nrow(needs)) return(list(ok = FALSE, error = paste0(needs$name[[1L]], " renders through it; remove that one first.")))
787
+ dest <- plugin_contained(file.path(plugin_user_dir(), kind), id)
788
+ if (is.null(dest)) return(list(ok = FALSE, error = "That plugin's folder is outside the user layer."))
789
+ unlink(dest, recursive = TRUE, force = TRUE)
790
+ list(ok = TRUE, kind = kind, id = id)
791
+ }
792
+
793
+ #' What the policy and the layers are, for the page: disclosure, never hidden.
794
+ plugin_policy_disclosure <- function(here, policy) {
795
+ layers <- plugin_layers(here, policy)
796
+ list(
797
+ set = isTRUE(policy$set),
798
+ source = if (identical(policy$source, "env")) "environment" else if (nzchar(policy$source)) "file" else "",
799
+ install = policy$install,
800
+ network = isTRUE(policy$network),
801
+ sources = I(as.character(policy$sources)),
802
+ kinds = I(as.character(policy$kinds)),
803
+ note = policy$note,
804
+ layers = I(lapply(layers, function(l) list(name = l$name, dir = l$dir, writable = isTRUE(l$writable), exists = dir.exists(l$dir)))),
805
+ kind_info = lapply(policy$kinds, function(k) list(
806
+ kind = k, label = PLUGIN_KINDS[[k]]$label, consumer = PLUGIN_KINDS[[k]]$consumer,
807
+ extension = PLUGIN_KINDS[[k]]$extension, max_bytes = PLUGIN_KINDS[[k]]$max_bytes,
808
+ registry = !is.null(PLUGIN_KINDS[[k]]$registry))) |> setNames(policy$kinds))
809
+ }