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,656 @@
1
+ # fileio.R — the file system, in one place, for BOTH processes.
2
+ #
3
+ # Sourced by serve.R (the supervisor) and by worker.R. Every function here is
4
+ # PURE in the sense that matters for the split: it takes a request id and
5
+ # arguments, touches the file system, and RETURNS the frame to send — it never
6
+ # emits. The worker wraps each one in `emit(fs_x(...))`; the supervisor sends
7
+ # the returned frame to the socket that asked. One body, two callers, so the
8
+ # two processes cannot drift on what a path may be.
9
+ #
10
+ # Why the supervisor answers these at all (2026-09-09, docs/document-model-plan.md
11
+ # D3): in the worker they queued behind a running cell — a Save waited for a
12
+ # 40-second fit — and the document model that follows (the kernel holding the
13
+ # open documents) cannot sit behind evaluation. The supervisor evaluates no
14
+ # user code; listing a folder and writing bytes is not evaluation.
15
+ #
16
+ # `wd`: the WORKER owns the working directory (setwd() in a chunk moves it),
17
+ # so a relative path is resolved against the wd the caller passes — the worker
18
+ # passes getwd(), the supervisor passes the cwd the worker last reported. Every
19
+ # path is made absolute FIRST (fs_abs), then confined (within_root), then used.
20
+
21
+ #' Make a path absolute against `wd`, expanding `~`. An absolute path is
22
+ #' returned expanded and unchanged.
23
+ fs_abs <- function(p, wd = getwd()) {
24
+ p <- path.expand(p)
25
+ if (grepl("^(/|[A-Za-z]:[/\\]|\\\\)", p)) return(p)
26
+ file.path(wd, p)
27
+ }
28
+
29
+ # ── the confinement root ────────────────────────────────────────────────────
30
+ # CARMAR_ROOT restricts every FILE command — browse, read, write, import, and
31
+ # changing the working directory — to one subtree. It exists for managed
32
+ # deployments where a notebook should see the project share and nothing else.
33
+ #
34
+ # It is a GUARDRAIL, not a sandbox, and the difference must be stated plainly
35
+ # rather than implied: this session evaluates arbitrary R, so a determined user
36
+ # can call file.remove() on anything their account can reach and no in-process
37
+ # check can stop them. What the root does buy is real all the same — it stops
38
+ # the UI (and a mis-click, a stray path, a shared notebook someone else wrote)
39
+ # from wandering out of the project, and it makes "the notebook only touches
40
+ # /srv/projects/x" an enforceable default rather than a hope. Containment is
41
+ # the operating system's job: run the kernel as a user who cannot read the
42
+ # rest, or in a container.
43
+ confine_root <- local({
44
+ raw <- Sys.getenv("CARMAR_ROOT", "")
45
+ if (!nzchar(raw)) NULL
46
+ else normalizePath(path.expand(raw), mustWork = FALSE)
47
+ })
48
+
49
+ #' Canonicalise a path that may not exist yet.
50
+ #'
51
+ #' `normalizePath()` resolves symlinks by ASKING THE FILE SYSTEM, so it can
52
+ #' only do it for a path that is already there; handed a path that does not
53
+ #' exist it returns the string unchanged. That is fatal for a confinement
54
+ #' check on a CREATE: on macOS `/var` is a symlink to `/private/var`, so a
55
+ #' root under `tempdir()` canonicalises to `/private/var/...` while the
56
+ #' not-yet-created file inside it stays `/var/...`, and the prefix comparison
57
+ #' says "outside" about a path that is plainly inside.
58
+ #'
59
+ #' So: normalise the deepest ancestor that DOES exist, then re-append the
60
+ #' components below it. The existing part gets real symlink resolution; the
61
+ #' part that does not exist yet cannot contain a symlink, because there is
62
+ #' nothing there to be one.
63
+ #'
64
+ #' @param p A path, absolute or relative.
65
+ #' @return The canonical absolute path.
66
+ canonical_path <- function(p) {
67
+ full <- path.expand(p)
68
+ if (file.exists(full)) return(normalizePath(full, mustWork = FALSE))
69
+ parts <- character(0)
70
+ at <- full
71
+ repeat {
72
+ up <- dirname(at)
73
+ parts <- c(basename(at), parts)
74
+ if (identical(up, at)) break # reached the filesystem root
75
+ if (file.exists(up)) {
76
+ return(do.call(file.path, as.list(c(normalizePath(up, mustWork = FALSE), parts))))
77
+ }
78
+ at <- up
79
+ }
80
+ normalizePath(full, mustWork = FALSE)
81
+ }
82
+
83
+ #' Is `p` inside the confinement root (when there is one)?
84
+ #'
85
+ #' Compared after canonicalisation, so `..` and symlinks are resolved before
86
+ #' the comparison rather than after — string-prefix checks on un-normalised
87
+ #' paths are how confinement bugs happen. The trailing separator matters too:
88
+ #' without it, /srv/project would also admit /srv/project-secrets.
89
+ within_root <- function(p) {
90
+ if (is.null(confine_root)) return(TRUE)
91
+ if (!is.character(p) || length(p) != 1L || is.na(p)) return(FALSE)
92
+ full <- canonical_path(p)
93
+ identical(full, confine_root) ||
94
+ startsWith(full, paste0(confine_root, .Platform$file.sep))
95
+ }
96
+
97
+ #' The standard refusal, so every file command says the same thing.
98
+ outside_root_msg <- function() {
99
+ sprintf("outside the permitted folder (%s)", confine_root)
100
+ }
101
+
102
+ #' List a directory for the Files pane.
103
+ #'
104
+ #' Directories first because that is how every file browser reads. A bad path
105
+ #' answers with an `error` FIELD: the pane holding a stale bookmark must see
106
+ #' "not a directory", not take the worker down.
107
+ #'
108
+ #' @param id Request id.
109
+ #' @param path Directory to list; NULL or "" means the working directory. `~`
110
+ #' is expanded.
111
+ #' @param all TRUE also lists dotfiles (never `.` / `..`). Off by default —
112
+ #' the same default `list.files()` has — and a client-side toggle.
113
+ #' @return Invisibly NULL. Emits one `files` frame.
114
+ fs_files <- function(id, path = NULL, all = FALSE, wd = getwd()) {
115
+ p <- if (is.null(path) || !is.character(path) || !nzchar(path[1L])) wd
116
+ else fs_abs(path[1L], wd)
117
+ p <- normalizePath(p, mustWork = FALSE)
118
+ if (!within_root(p)) {
119
+ return(list(type = "files", id = id, path = p, error = outside_root_msg()))
120
+ return(invisible(NULL))
121
+ }
122
+ if (!dir.exists(p)) {
123
+ return(list(type = "files", id = id, path = p, error = "not a directory"))
124
+ return(invisible(NULL))
125
+ }
126
+ if (file.access(p, mode = 4L) != 0L) {
127
+ return(list(type = "files", id = id, path = p, error = "not readable"))
128
+ return(invisible(NULL))
129
+ }
130
+ nms <- list.files(p, all.files = isTRUE(all), no.. = TRUE)
131
+ info <- file.info(file.path(p, nms))
132
+ isdir <- info$isdir %in% TRUE # a broken symlink reports NA
133
+ ord <- order(!isdir, tolower(nms))
134
+ entries <- lapply(ord, function(i) list(
135
+ name = nms[i], size = as.numeric(info$size[i]),
136
+ mtime = as.numeric(info$mtime[i]), isdir = isdir[i]))
137
+ return(list(type = "files", id = id, path = p, parent = dirname(p),
138
+ entries = entries))
139
+ }
140
+
141
+ #' Create a folder — the file tree's New Folder.
142
+ #'
143
+ #' `recursive = FALSE` on purpose: the tree only ever creates a child of a
144
+ #' folder it is already showing, so a missing intermediate means the tree is
145
+ #' stale, and silently manufacturing the whole chain would hide that. Refuses
146
+ #' an existing name rather than succeeding quietly, because "New Folder" that
147
+ #' returns ok on a name already taken reads as "created" and is not.
148
+ #'
149
+ #' @param id Request id.
150
+ #' @param path Absolute path of the folder to create.
151
+ #' @return Invisibly NULL. Emits one `mkdir` frame with `path` or `error`.
152
+ fs_mkdir <- function(id, path = NULL, wd = getwd()) {
153
+ fail <- function(msg) {
154
+ return(list(type = "mkdir", id = id, path = path, error = msg))
155
+ invisible(NULL)
156
+ }
157
+ if (!is.character(path) || length(path) != 1L || !nzchar(path)) return(fail("no path"))
158
+ p <- fs_abs(path, wd)
159
+ if (!within_root(p)) return(fail(outside_root_msg()))
160
+ if (file.exists(p)) return(fail("something with that name is already here"))
161
+ if (!dir.exists(dirname(p))) return(fail(paste0("no such folder: ", dirname(p))))
162
+ ok <- tryCatch(dir.create(p, recursive = FALSE), warning = function(w) FALSE,
163
+ error = function(e) FALSE)
164
+ if (!isTRUE(ok)) return(fail("the folder could not be created"))
165
+ return(list(type = "mkdir", id = id, path = normalizePath(p, mustWork = FALSE)))
166
+ invisible(NULL)
167
+ }
168
+
169
+ #' Rename or move one entry — the tree's inline rename.
170
+ #'
171
+ #' BOTH ends are checked against the root. Checking only the source would let
172
+ #' a rename carry a file OUT of the permitted folder, which is the same
173
+ #' capability the root exists to deny; a guard that reads one end of a
174
+ #' two-ended operation is not a guard.
175
+ #'
176
+ #' Refuses to clobber. An overwrite here is indistinguishable from a typo, and
177
+ #' the tree has no undo.
178
+ #'
179
+ #' @param id Request id.
180
+ #' @param path Existing absolute path.
181
+ #' @param to Absolute destination path.
182
+ #' @return Invisibly NULL. Emits one `renamepath` frame with `from`/`to`, or `error`.
183
+ fs_renamepath <- function(id, path = NULL, to = NULL, wd = getwd()) {
184
+ fail <- function(msg) {
185
+ return(list(type = "renamepath", id = id, path = path, error = msg))
186
+ invisible(NULL)
187
+ }
188
+ if (!is.character(path) || length(path) != 1L || !nzchar(path)) return(fail("no path"))
189
+ if (!is.character(to) || length(to) != 1L || !nzchar(to)) return(fail("no new name"))
190
+ from <- fs_abs(path, wd)
191
+ dest <- fs_abs(to, wd)
192
+ if (!within_root(from) || !within_root(dest)) return(fail(outside_root_msg()))
193
+ if (!file.exists(from)) return(fail("that file is no longer here"))
194
+ if (file.exists(dest)) return(fail("something with that name is already here"))
195
+ if (!dir.exists(dirname(dest))) return(fail(paste0("no such folder: ", dirname(dest))))
196
+ ok <- tryCatch(file.rename(from, dest), warning = function(w) FALSE,
197
+ error = function(e) FALSE)
198
+ if (!isTRUE(ok)) return(fail("the rename failed"))
199
+ return(list(type = "renamepath", id = id,
200
+ from = normalizePath(from, mustWork = FALSE),
201
+ to = normalizePath(dest, mustWork = FALSE)))
202
+ invisible(NULL)
203
+ }
204
+
205
+ #' Delete files and folders — the tree's Delete.
206
+ #'
207
+ #' Takes a VECTOR, because the tree deletes a selection and N separate round
208
+ #' trips would leave a half-deleted selection on any failure. Each path is
209
+ #' root-checked individually; one refusal does not cancel the others, and the
210
+ #' frame reports exactly what went and what did not, so the tree never has to
211
+ #' guess which rows to drop.
212
+ #'
213
+ #' A non-empty folder needs `recursive = TRUE` — asked for explicitly by the
214
+ #' caller, which is the client that showed the confirmation naming the folder.
215
+ #'
216
+ #' @param id Request id.
217
+ #' @param paths Character vector of absolute paths.
218
+ #' @param recursive TRUE to remove non-empty folders.
219
+ #' @return Invisibly NULL. Emits one `deletepath` frame: `deleted`, `failed`.
220
+ fs_deletepath <- function(id, paths = NULL, recursive = FALSE, wd = getwd()) {
221
+ if (!is.character(paths) || !length(paths)) {
222
+ return(list(type = "deletepath", id = id, error = "no paths"))
223
+ return(invisible(NULL))
224
+ }
225
+ deleted <- character(0)
226
+ failed <- list()
227
+ note <- function(p, why) failed[[length(failed) + 1L]] <<- list(path = p, error = why)
228
+ for (raw in paths) {
229
+ if (!is.character(raw) || is.na(raw) || !nzchar(raw)) { note(raw, "no path"); next }
230
+ p <- fs_abs(raw, wd)
231
+ if (!within_root(p)) { note(p, outside_root_msg()); next }
232
+ if (!file.exists(p)) { note(p, "already gone"); next }
233
+ is_dir <- dir.exists(p)
234
+ if (is_dir && !isTRUE(recursive) && length(list.files(p, all.files = TRUE, no.. = TRUE))) {
235
+ note(p, "the folder is not empty")
236
+ next
237
+ }
238
+ ok <- tryCatch(unlink(p, recursive = is_dir, force = FALSE) == 0L,
239
+ warning = function(w) FALSE, error = function(e) FALSE)
240
+ # unlink() reports 0 for "nothing to do" as well as success, so the file
241
+ # system is asked again rather than trusting the status code.
242
+ if (isTRUE(ok) && !file.exists(p)) deleted <- c(deleted, p) else note(p, "could not be deleted")
243
+ }
244
+ return(list(type = "deletepath", id = id, deleted = I(as.list(deleted)),
245
+ failed = I(failed)))
246
+ invisible(NULL)
247
+ }
248
+
249
+ #' Copy a file or folder — the tree's Duplicate.
250
+ #'
251
+ #' Both ends root-checked, for the same reason rename checks both: a copy is a
252
+ #' two-ended operation, and a guard that reads one end lets data OUT.
253
+ #'
254
+ #' Refuses to clobber. `file.copy(overwrite = FALSE)` returns FALSE rather than
255
+ #' erroring on a collision, so the check is explicit and the message says which
256
+ #' of the two things went wrong.
257
+ #'
258
+ #' @param id Request id.
259
+ #' @param path Existing absolute path.
260
+ #' @param to Absolute destination path.
261
+ #' @return Invisibly NULL. Emits one `copypath` frame with `from`/`to`, or `error`.
262
+ fs_copypath <- function(id, path = NULL, to = NULL, wd = getwd()) {
263
+ fail <- function(msg) {
264
+ return(list(type = "copypath", id = id, path = path, error = msg))
265
+ invisible(NULL)
266
+ }
267
+ if (!is.character(path) || length(path) != 1L || !nzchar(path)) return(fail("no path"))
268
+ if (!is.character(to) || length(to) != 1L || !nzchar(to)) return(fail("no destination"))
269
+ from <- fs_abs(path, wd)
270
+ dest <- fs_abs(to, wd)
271
+ if (!within_root(from) || !within_root(dest)) return(fail(outside_root_msg()))
272
+ if (!file.exists(from)) return(fail("that file is no longer here"))
273
+ if (file.exists(dest)) return(fail("something with that name is already here"))
274
+ if (!dir.exists(dirname(dest))) return(fail(paste0("no such folder: ", dirname(dest))))
275
+ ok <- tryCatch({
276
+ if (dir.exists(from)) {
277
+ # file.copy(recursive=) copies the DIRECTORY INTO the target, so the
278
+ # destination must exist first and is named by its parent, not itself.
279
+ dir.create(dest, recursive = FALSE)
280
+ kids <- list.files(from, all.files = TRUE, no.. = TRUE, full.names = TRUE)
281
+ all(file.copy(kids, dest, recursive = TRUE, copy.date = TRUE))
282
+ } else {
283
+ file.copy(from, dest, overwrite = FALSE, copy.date = TRUE)
284
+ }
285
+ }, warning = function(w) FALSE, error = function(e) FALSE)
286
+ if (!isTRUE(ok) || !file.exists(dest)) return(fail("the copy failed"))
287
+ return(list(type = "copypath", id = id,
288
+ from = normalizePath(from, mustWork = FALSE),
289
+ to = normalizePath(dest, mustWork = FALSE)))
290
+ invisible(NULL)
291
+ }
292
+
293
+ #' Show one entry in the desktop file manager.
294
+ #'
295
+ #' The path is root-checked and handed to the platform opener as an ARGUMENT
296
+ #' VECTOR, never interpolated into a shell string: a file called
297
+ #' `; rm -rf ~` is a legal filename, and `system()` on a composed string would
298
+ #' run it. `system2()` with a character vector does not go through a shell.
299
+ #'
300
+ #' @param id Request id.
301
+ #' @param path Absolute path to reveal.
302
+ #' @return Invisibly NULL. Emits one `revealpath` frame.
303
+ fs_revealpath <- function(id, path = NULL, wd = getwd()) {
304
+ fail <- function(msg) {
305
+ return(list(type = "revealpath", id = id, path = path, error = msg))
306
+ invisible(NULL)
307
+ }
308
+ if (!is.character(path) || length(path) != 1L || !nzchar(path)) return(fail("no path"))
309
+ p <- fs_abs(path, wd)
310
+ if (!within_root(p)) return(fail(outside_root_msg()))
311
+ if (!file.exists(p)) return(fail("that file is no longer here"))
312
+ p <- normalizePath(p, mustWork = TRUE)
313
+ sysname <- unname(Sys.info()[["sysname"]])
314
+ status <- tryCatch(switch(sysname,
315
+ Darwin = system2("/usr/bin/open", c("-R", p), stdout = FALSE, stderr = FALSE),
316
+ Windows = system2("explorer", sprintf("/select,%s", p), stdout = FALSE, stderr = FALSE),
317
+ system2("xdg-open", dirname(p), stdout = FALSE, stderr = FALSE)),
318
+ warning = function(w) 0L, error = function(e) 1L)
319
+ # Windows explorer returns 1 on success; only a hard failure is reported.
320
+ if (!identical(sysname, "Windows") && !identical(as.integer(status), 0L)) {
321
+ return(fail("the file manager could not be opened"))
322
+ }
323
+ return(list(type = "revealpath", id = id, path = p))
324
+ invisible(NULL)
325
+ }
326
+
327
+ #' Read a text file — the script editor's Open.
328
+ #'
329
+ #' Text only, and capped: the editor is for .R scripts, and handing a 400 MB
330
+ #' CSV to a textarea is not an editing session, it is a hung tab. A binary
331
+ #' file is refused by the NUL byte rather than rendered as mojibake.
332
+ #'
333
+ #' @param id Request id.
334
+ #' @param path File to read; `~` is expanded.
335
+ #' @return Invisibly NULL. Emits one `readfile` frame with `text`, or `error`.
336
+ MAX_TEXT_BYTES <- 4e6
337
+
338
+ #' Decode file bytes into the ONE text the page edits, and say how.
339
+ #'
340
+ #' Until 7.18 every byte that was not valid UTF-8 was silently dropped between
341
+ #' `rawToChar` and the JSON frame: a Latin-1 `.tex` (`\usepackage[latin1]
342
+ #' {inputenc}`, still common) opened as "Caf" for "Café" with no error, and a
343
+ #' save was then refused with the wrong message. The page edits UTF-8; the
344
+ #' file keeps its own encoding; this pair is the boundary. Only two encodings
345
+ #' are named because only two are decidable from bytes alone — valid UTF-8 is
346
+ #' UTF-8, and anything else is read as Latin-1, which every byte sequence is.
347
+ #'
348
+ #' @param raw_bytes Raw vector.
349
+ #' @return list(text = UTF-8 string, encoding = "utf-8" | "latin1"), or NULL
350
+ #' when the bytes cannot be made into a string at all.
351
+ text_from_bytes <- function(raw_bytes) {
352
+ text <- tryCatch(rawToChar(raw_bytes), error = function(e) NULL)
353
+ if (is.null(text)) return(NULL)
354
+ if (validUTF8(text)) {
355
+ Encoding(text) <- "UTF-8"
356
+ return(list(text = text, encoding = "utf-8"))
357
+ }
358
+ latin <- iconv(text, from = "latin1", to = "UTF-8")
359
+ if (is.na(latin)) return(NULL)
360
+ list(text = latin, encoding = "latin1")
361
+ }
362
+
363
+ #' Encode the page's UTF-8 text back into the file's bytes.
364
+ #' @param text UTF-8 string.
365
+ #' @param encoding "utf-8" (default) or "latin1", as `text_from_bytes` reported.
366
+ #' @return Raw vector, or a character error message when `text` holds a
367
+ #' character the encoding cannot carry (a `€` in a Latin-1 file): the save
368
+ #' fails closed and says which, rather than writing a `?`.
369
+ bytes_from_text <- function(text, encoding = "utf-8") {
370
+ text <- enc2utf8(text)
371
+ if (identical(encoding, "latin1")) {
372
+ out <- iconv(text, from = "UTF-8", to = "latin1")
373
+ if (is.na(out)) {
374
+ return("the file is Latin-1 and the text now holds a character Latin-1 cannot store; use Save As to write it as UTF-8")
375
+ }
376
+ return(charToRaw(out))
377
+ }
378
+ if (!identical(encoding, "utf-8")) return(sprintf("unknown file encoding '%s'", encoding))
379
+ charToRaw(text)
380
+ }
381
+
382
+ file_encoding_arg <- function(encoding) {
383
+ if (is.character(encoding) && length(encoding) == 1L && nzchar(encoding)) encoding else "utf-8"
384
+ }
385
+
386
+ fs_readfile <- function(id, path = NULL, wd = getwd()) {
387
+ fail <- function(msg) {
388
+ return(list(type = "readfile", id = id, path = path, error = msg))
389
+ invisible(NULL)
390
+ }
391
+ if (!is.character(path) || length(path) != 1L || !nzchar(path)) return(fail("no path"))
392
+ p <- fs_abs(path, wd)
393
+ if (!within_root(p)) return(fail(outside_root_msg()))
394
+ if (!file.exists(p) || dir.exists(p)) return(fail("not a readable file"))
395
+ size <- file.info(p)$size
396
+ if (isTRUE(size > MAX_TEXT_BYTES)) {
397
+ return(fail(sprintf("too large to edit (%.1f MB)", size / 1e6)))
398
+ }
399
+ raw_bytes <- tryCatch(readBin(p, "raw", n = size),
400
+ error = function(e) conditionMessage(e))
401
+ if (is.character(raw_bytes)) return(fail(raw_bytes))
402
+ if (any(raw_bytes == as.raw(0L))) return(fail("not a text file"))
403
+ decoded <- text_from_bytes(raw_bytes)
404
+ if (is.null(decoded)) return(fail("could not decode as text"))
405
+ return(list(type = "readfile", id = id, path = normalizePath(p), text = decoded$text,
406
+ encoding = decoded$encoding))
407
+ }
408
+
409
+ #' Replace (or create) `p`: write a temp file beside it, then rename OVER the
410
+ #' target in one filesystem operation. Never move the original away first:
411
+ #' readers must see either the old file or the new one, not a missing path.
412
+ #' R uses rename(2) on POSIX and replacement-enabled MoveFileExW on Windows.
413
+ #' This is namespace atomicity on supported local filesystems, not an fsync /
414
+ #' power-loss durability guarantee. The mode of an existing file is kept.
415
+ #' Returns NULL on success or the sentence to report.
416
+ fs_replace_atomic <- function(p, body_bytes) {
417
+ temp <- tempfile(pattern = ".carmar-save-", tmpdir = dirname(p))
418
+ existed <- file.exists(p)
419
+ old_mode <- if (existed) file.info(p)$mode else NULL
420
+ cleanup_save <- function() {
421
+ if (file.exists(temp)) unlink(temp)
422
+ }
423
+ # Error replies are observable by the browser immediately. Clean first so a
424
+ # completed failure reply also means recovery has finished on disk.
425
+ fail_after_cleanup <- function(msg) {
426
+ cleanup_save()
427
+ msg
428
+ }
429
+ on.exit(cleanup_save(), add = TRUE)
430
+ ok <- tryCatch({
431
+ con <- file(temp, open = "wb")
432
+ writeBin(body_bytes, con)
433
+ close(con)
434
+ con <- NULL
435
+ if (!is.null(old_mode)) Sys.chmod(temp, mode = old_mode)
436
+ TRUE
437
+ }, error = function(e) conditionMessage(e),
438
+ finally = if (exists("con", inherits = FALSE) && !is.null(con)) try(close(con), silent = TRUE))
439
+ if (is.character(ok)) return(fail_after_cleanup(ok))
440
+ # Keep the existing test knob as an alias for the pre-replacement boundary.
441
+ # The original is still at its path; a failure needs no restoration rename.
442
+ if (identical(Sys.getenv("CARMAR_TEST_SAVE_FAILURE", ""), "after-backup")) {
443
+ return(fail_after_cleanup("simulated failure before replacement"))
444
+ }
445
+ replaced <- tryCatch(suppressWarnings(file.rename(temp, p)), error = function(e) FALSE)
446
+ if (!isTRUE(replaced)) {
447
+ return(fail_after_cleanup("could not atomically replace the file; the original was left unchanged"))
448
+ }
449
+ NULL
450
+ }
451
+
452
+ #' Write a text file — the script editor's Save.
453
+ #'
454
+ #' Writes exactly the bytes given. The directory must already exist: a Save
455
+ #' that silently creates a tree is a Save that puts the file somewhere else than
456
+ #' the user believes. The replacement is a same-directory rename, and an
457
+ #' optional exact previous value makes an editor Save fail closed if another
458
+ #' program changed the file since it was opened.
459
+ #'
460
+ #' Binary travels as `base64`: the page's blob exports (xlsx, png, a plot)
461
+ #' had no way onto the disk from a webview with no download delegate, and R
462
+ #' is the one process here that can write a file where the person asked. The
463
+ #' decoded size is capped at the same MAX_TEXT_BYTES — 4 MB decoded is a
464
+ #' 5.4 MB frame, under the supervisor's 8 MB cap — and the exact-content
465
+ #' check is refused because it is defined over TEXT (re-encoded the way the
466
+ #' file was decoded); a binary file has no such previous value to compare.
467
+ #'
468
+ #' @param id Request id.
469
+ #' @param path Destination; `~` is expanded.
470
+ #' @param text Contents.
471
+ #' @param base64 Binary contents, base64-encoded; when given, `text` is ignored.
472
+ #' @return Invisibly NULL. Emits one `writefile` frame with `path`, or `error`.
473
+ fs_writefile <- function(id, path = NULL, text = "", expected = NULL, encoding = NULL,
474
+ base64 = NULL, wd = getwd()) {
475
+ fail <- function(msg) {
476
+ return(list(type = "writefile", id = id, path = path, error = msg))
477
+ invisible(NULL)
478
+ }
479
+ if (!is.character(path) || length(path) != 1L || !nzchar(path)) return(fail("no path"))
480
+ p <- fs_abs(path, wd)
481
+ if (!within_root(p)) return(fail(outside_root_msg()))
482
+ if (dir.exists(p)) return(fail("that is a directory"))
483
+ if (!dir.exists(dirname(p))) return(fail(paste0("no such folder: ", dirname(p))))
484
+ binary <- is.character(base64) && length(base64) == 1L && nzchar(base64)
485
+ if (binary) {
486
+ if (!is.null(expected)) return(fail("the exact-content check applies to text saves"))
487
+ body_bytes <- tryCatch(jsonlite::base64_dec(base64), error = function(e) NULL)
488
+ if (!is.raw(body_bytes)) return(fail("not valid base64"))
489
+ if (length(body_bytes) > MAX_TEXT_BYTES) return(fail("too large to write through R"))
490
+ } else {
491
+ if (!is.null(text) && !is.character(text)) return(fail("text must be a string"))
492
+ if (is.character(text) && sum(nchar(text, type = "bytes")) > MAX_TEXT_BYTES) {
493
+ return(fail("too large to save from the editor"))
494
+ }
495
+ body <- if (is.character(text) && length(text)) paste(text, collapse = "\n") else ""
496
+ encoding <- file_encoding_arg(encoding)
497
+ body_bytes <- bytes_from_text(body, encoding)
498
+ if (is.character(body_bytes)) return(fail(body_bytes))
499
+ }
500
+ if (!is.null(expected)) {
501
+ if (!is.character(expected)) return(fail("expected text must be a string"))
502
+ before <- paste(expected, collapse = "\n")
503
+ if (!file.exists(p)) return(fail("the file was removed after it was opened; nothing was written"))
504
+ size <- file.info(p)$size
505
+ raw <- tryCatch(readBin(p, "raw", n = size), error = function(e) NULL)
506
+ # Compare BYTES to bytes: the page's expectation is re-encoded the way
507
+ # the file was decoded, so a Latin-1 file matches its own bytes.
508
+ before_bytes <- bytes_from_text(before, encoding)
509
+ if (is.null(raw) || is.character(before_bytes) || !identical(raw, before_bytes)) {
510
+ return(fail("the file changed on disk after it was opened; nothing was written"))
511
+ }
512
+ }
513
+ err <- fs_replace_atomic(p, body_bytes)
514
+ if (!is.null(err)) return(fail(err))
515
+ # Built field by field: a `encoding = NULL` inside list() is an element the
516
+ # supervisor's JSON would render as `{}`, and a binary file has no encoding.
517
+ reply <- list(type = "writefile", id = id, path = normalizePath(p), bytes = length(body_bytes))
518
+ if (binary) reply$binary <- TRUE else reply$encoding <- encoding
519
+ return(reply)
520
+ }
521
+
522
+ #' Atomically replace several existing text files after exact-content checks.
523
+ #'
524
+ #' Every temporary and backup lives beside its target, so rename stays on one
525
+ #' filesystem. All preconditions are checked before the first rename; a later
526
+ #' failure restores every target already swapped.
527
+ fs_writefiles_atomic <- function(id, files = NULL, wd = getwd()) {
528
+ fail <- function(msg) {
529
+ return(list(type = "writefiles_atomic", id = id, error = msg))
530
+ invisible(NULL)
531
+ }
532
+ records <- if (is.data.frame(files)) {
533
+ lapply(seq_len(nrow(files)), function(i) as.list(files[i, , drop = FALSE]))
534
+ } else if (is.list(files)) files else list()
535
+ if (!length(records)) return(fail("no files"))
536
+ if (length(records) > 50L) return(fail("too many files in one change"))
537
+
538
+ plans <- list()
539
+ for (item in records) {
540
+ p <- if (is.list(item) && is.character(item$path) && length(item$path)) fs_abs(item$path[[1L]], wd) else ""
541
+ before <- if (is.list(item) && is.character(item$before)) paste(item$before, collapse = "\n") else NULL
542
+ after <- if (is.list(item) && is.character(item$after)) paste(item$after, collapse = "\n") else NULL
543
+ if (!nzchar(p) || is.null(before) || is.null(after)) return(fail("each file needs path, before and after"))
544
+ if (!within_root(p)) return(fail(outside_root_msg()))
545
+ if (!file.exists(p) || dir.exists(p)) return(fail(paste0("not an existing text file: ", p)))
546
+ size <- file.info(p)$size
547
+ raw <- tryCatch(readBin(p, "raw", n = size), error = function(e) NULL)
548
+ # The file's own encoding decides the comparison and the write, exactly
549
+ # as emit_writefile does; a rename across a Latin-1 project stays exact.
550
+ encoding <- file_encoding_arg(if (is.list(item)) item$encoding else NULL)
551
+ before_bytes <- bytes_from_text(before, encoding)
552
+ after_bytes <- bytes_from_text(after, encoding)
553
+ if (is.character(after_bytes)) return(fail(paste0(basename(p), ": ", after_bytes)))
554
+ if (is.null(raw) || is.character(before_bytes) || !identical(raw, before_bytes)) {
555
+ return(fail(paste0(basename(p), " changed after the preview; nothing was written")))
556
+ }
557
+ if (length(after_bytes) > MAX_TEXT_BYTES) return(fail(paste0(basename(p), " is too large")))
558
+ plans[[length(plans) + 1L]] <- list(path = p, before = before, after = after, bytes = after_bytes,
559
+ mode = file.info(p)$mode,
560
+ temp = tempfile(pattern = ".carmar-new-", tmpdir = dirname(p)),
561
+ backup = tempfile(pattern = ".carmar-old-", tmpdir = dirname(p)))
562
+ }
563
+
564
+ cleanup <- function() {
565
+ for (plan in plans) {
566
+ if (file.exists(plan$temp)) unlink(plan$temp)
567
+ if (file.exists(plan$backup) && file.exists(plan$path)) unlink(plan$backup)
568
+ }
569
+ }
570
+ on.exit(cleanup(), add = TRUE)
571
+ for (plan in plans) {
572
+ con <- NULL
573
+ ok <- tryCatch({
574
+ con <- file(plan$temp, open = "wb")
575
+ writeBin(plan$bytes, con)
576
+ close(con)
577
+ con <- NULL
578
+ Sys.chmod(plan$temp, mode = plan$mode)
579
+ TRUE
580
+ }, error = function(e) conditionMessage(e),
581
+ finally = if (!is.null(con)) try(close(con), silent = TRUE))
582
+ if (!identical(ok, TRUE)) return(fail(as.character(ok)))
583
+ }
584
+
585
+ swapped <- integer(0)
586
+ for (i in seq_along(plans)) {
587
+ plan <- plans[[i]]
588
+ ok <- file.rename(plan$path, plan$backup) && file.rename(plan$temp, plan$path)
589
+ if (!ok) {
590
+ if (!file.exists(plan$path) && file.exists(plan$backup)) file.rename(plan$backup, plan$path)
591
+ for (j in rev(swapped)) {
592
+ prior <- plans[[j]]
593
+ if (file.exists(prior$path)) unlink(prior$path)
594
+ if (file.exists(prior$backup)) file.rename(prior$backup, prior$path)
595
+ }
596
+ return(fail("could not replace every file; all completed replacements were restored"))
597
+ }
598
+ swapped <- c(swapped, i)
599
+ }
600
+ for (plan in plans) if (file.exists(plan$backup)) unlink(plan$backup)
601
+ return(list(type = "writefiles_atomic", id = id,
602
+ paths = I(vapply(plans, function(plan) normalizePath(plan$path), character(1)))))
603
+ }
604
+
605
+
606
+ #' Write raw bytes to `path` (absolute or relative to `wd`), atomically and
607
+ #' confined — the body of `PUT /files`. Returns the frame to report.
608
+ fs_write_raw <- function(id, path = NULL, bytes = raw(0), wd = getwd(), expected = NULL) {
609
+ fail <- function(msg) list(type = "writefile", id = id, path = path, error = msg)
610
+ if (!is.character(path) || length(path) != 1L || !nzchar(path)) return(fail("no path"))
611
+ if (!is.raw(bytes)) return(fail("no bytes"))
612
+ if (!is.null(expected) && !is.raw(expected)) return(fail("expected content must be bytes"))
613
+ p <- fs_abs(path, wd)
614
+ if (!within_root(p)) return(fail(outside_root_msg()))
615
+ if (dir.exists(p)) return(fail("that is a directory"))
616
+ if (!dir.exists(dirname(p))) return(fail(paste0("no such folder: ", dirname(p))))
617
+ if (!is.null(expected)) {
618
+ if (!file.exists(p)) return(fail("the file was removed after it was opened; nothing was written"))
619
+ size <- file.info(p)$size
620
+ before <- if (is.finite(size) && size == length(expected))
621
+ tryCatch(readBin(p, "raw", n = size), error = function(e) NULL) else NULL
622
+ if (is.null(before) || !identical(before, expected)) {
623
+ return(fail("the file changed on disk after it was opened; nothing was written"))
624
+ }
625
+ }
626
+ err <- fs_replace_atomic(p, bytes)
627
+ if (!is.null(err)) return(fail(err))
628
+ list(type = "writefile", id = id, path = normalizePath(p), bytes = length(bytes), binary = TRUE)
629
+ }
630
+
631
+ #' A Content-Type for `GET /files`, by extension; anything else is opaque.
632
+ fs_content_type <- function(path) {
633
+ base <- basename(path)
634
+ ext <- if (grepl("\\.", base)) tolower(sub("^.*\\.", "", base)) else ""
635
+ types <- c(png = "image/png", jpg = "image/jpeg", jpeg = "image/jpeg", gif = "image/gif",
636
+ svg = "image/svg+xml", pdf = "application/pdf", html = "text/html; charset=utf-8",
637
+ htm = "text/html; charset=utf-8", csv = "text/csv; charset=utf-8",
638
+ tsv = "text/tab-separated-values; charset=utf-8", txt = "text/plain; charset=utf-8",
639
+ md = "text/markdown; charset=utf-8", qmd = "text/markdown; charset=utf-8",
640
+ rmd = "text/markdown; charset=utf-8", carmd = "text/markdown; charset=utf-8",
641
+ r = "text/plain; charset=utf-8", tex = "application/x-tex", bib = "text/plain; charset=utf-8",
642
+ json = "application/json", xlsx = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
643
+ docx = "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
644
+ zip = "application/zip", rdata = "application/octet-stream", rds = "application/octet-stream")
645
+ hit <- if (nzchar(ext) && ext %in% names(types)) types[[ext]] else "application/octet-stream"
646
+ hit
647
+ }
648
+
649
+ #' A file name safe inside a Content-Disposition header: ASCII, no quotes,
650
+ #' no control characters, never empty.
651
+ fs_disposition_name <- function(name) {
652
+ n <- iconv(as.character(name), to = "ASCII", sub = "_")
653
+ n <- gsub('[^A-Za-z0-9._ -]', "_", n)
654
+ n <- trimws(n)
655
+ if (!nzchar(n)) "download" else n
656
+ }