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,286 @@
1
+ // terminal.mjs — a pty shell in the kernel, owned by the PAGE.
2
+ //
3
+ // A port of `term_open`, `term_input`, `term_close`, `term_owned`,
4
+ // `term_shell` and `pump_terms` in spike/serve.R, plus `term_resize`, which
5
+ // carmapy added (`../carmar-python/docs/kernel-protocol.md` §3: "R only sizes
6
+ // at open"). Both are served here; a page that never resizes behaves exactly
7
+ // as it does against serve.R.
8
+ //
9
+ // The terminal is the FOURTH child, and it is the opposite of a job on
10
+ // purpose:
11
+ // · it BELONGS to the socket that opened it — bytes stream to that socket
12
+ // alone, and `ws.onClose` kills the shell. A shell nobody can see is not
13
+ // a detached task, it is an orphan with the user's credentials.
14
+ // · it is page-only AND agent-refused. An agent runs code through
15
+ // chunk_run, visibly, or not at all; a terminal would be exactly the raw
16
+ // `exec` door the MCP plane refuses, wearing a prompt.
17
+ //
18
+ // What the shell can do is what the user can do; this grants nothing new and
19
+ // is audited like every other door. `CARMAR_NO_TERMINAL=1` refuses it BY
20
+ // NAME, with the reason, rather than dropping the frame — a dropped frame
21
+ // reads as a hang, and lib/terminal.js already renders `event: "refused"`.
22
+ //
23
+ // The one dependency this package adds is `node-pty` (1.1.0), and it is the
24
+ // reason there is no `script`/`expect` ladder here: a login shell without a
25
+ // controlling terminal has no job control, no ^C, no prompt and no `pwd`
26
+ // that a user would recognise. processx gave R the same thing.
27
+
28
+ import fs from "node:fs";
29
+ import path from "node:path";
30
+ import { createRequire } from "node:module";
31
+ import { killProcessTree } from "./proc-tree.mjs";
32
+ import { PIPE_NOTICE, pipeShell, pipeSpawn } from "./pipe-term.mjs";
33
+
34
+ const MAX_TERMS = 4;
35
+ const TERM_INPUT_MAX = 65536;
36
+ const PAGE_ONLY = Object.freeze(["served", "file", "local"]);
37
+
38
+ const scalarChr = (x) => typeof x === "string";
39
+ const clamp = (x, lo, hi, fallback) => {
40
+ const v = Number.parseInt(x, 10);
41
+ return Number.isFinite(v) ? Math.max(lo, Math.min(hi, v)) : fallback;
42
+ };
43
+
44
+ /**
45
+ * The shell to run and how to make it interactive. bash and zsh get a login
46
+ * shell so PATH matches the user's own terminal; sh gets -i.
47
+ */
48
+ export function termShell(env = process.env, platform = process.platform) {
49
+ // Windows has no SHELL and no /bin/sh: ComSpec is the shell a user has.
50
+ if (platform === "win32") return { cmd: env.ComSpec || env.COMSPEC || "cmd.exe", args: [] };
51
+ let shell = env.SHELL || "";
52
+ if (!shell || !fs.existsSync(shell)) shell = "/bin/sh";
53
+ const base = path.basename(shell);
54
+ const args = ["bash", "zsh", "fish"].includes(base) ? ["-i", "-l"] : ["-i"];
55
+ return { cmd: shell, args };
56
+ }
57
+
58
+ /**
59
+ * How to start that shell on a pty with ECHO OFF.
60
+ *
61
+ * serve.R gets this from processx (`pty_options`, whose `echo` defaults to
62
+ * FALSE); node-pty has no such option and no termios binding, so the slave's
63
+ * mode is set by the one process that has the slave as its stdin — a `/bin/sh
64
+ * -c` that turns echo off and immediately `exec`s the real shell, keeping the
65
+ * pid, the controlling terminal and the argument vector.
66
+ *
67
+ * It is not cosmetic. `lib/terminal.js` has no terminal library: it draws the
68
+ * line being typed ITSELF, from its own input element, and renders what the
69
+ * shell emits underneath. With echo on, every keystroke appears twice.
70
+ *
71
+ * The wrapper is always `/bin/sh`, never the user's shell, because `$@` and
72
+ * `exec "$@"` are POSIX and fish is not; and the shell and its arguments
73
+ * travel as POSITIONAL PARAMETERS, so a path with a space or a quote in it is
74
+ * one argument and nothing is ever pasted into a shell string.
75
+ */
76
+ export function termArgv(sh) {
77
+ if (process.platform === "win32") return { file: sh.cmd, args: sh.args };
78
+ return { file: "/bin/sh", args: ["-c", 'stty -echo 2>/dev/null; exec "$@"', "beatrina-terminal", sh.cmd, ...sh.args] };
79
+ }
80
+
81
+ /**
82
+ * Load node-pty, repairing the one packaging fault measured on this machine.
83
+ *
84
+ * node-pty 1.1.0 ships its darwin prebuild's `spawn-helper` WITHOUT the
85
+ * executable bit after `npm install` (measured 2026-09-14: mode 0644, and
86
+ * every `spawn` fails with "posix_spawnp failed" until it is 0755). The
87
+ * repair is one chmod on a file inside our own node_modules, done once, said
88
+ * out loud in the audit stream — not a swallowed retry: a terminal that fails
89
+ * for a reason nobody can see is worse than one that refuses in words.
90
+ */
91
+ function loadPty(audit) {
92
+ // A packaged single executable has no module path and can load no native
93
+ // module, so it has no node-pty: the pipe terminal answers instead. Asking
94
+ // createRequire(undefined) here threw inside term_open and the page waited
95
+ // for an answer that never came.
96
+ if (!(typeof import.meta.url === "string" && import.meta.url.startsWith("file:"))) {
97
+ return { pty: null, error: "a packaged Beatrina executable cannot load node-pty" };
98
+ }
99
+ const require = createRequire(import.meta.url);
100
+ let entry;
101
+ try { entry = require.resolve("node-pty"); } catch { return { pty: null, error: "node-pty is not installed on this kernel." }; }
102
+ const root = path.dirname(path.dirname(entry)); // …/node-pty
103
+ const arch = `${process.platform}-${process.arch}`;
104
+ const helper = path.join(root, "prebuilds", arch, "spawn-helper");
105
+ try {
106
+ const st = fs.statSync(helper);
107
+ if (!(st.mode & 0o111)) { fs.chmodSync(helper, 0o755); audit("term-helper-chmod", { detail: helper }); }
108
+ } catch { /* no prebuild helper on this platform: node-pty will say so */ }
109
+ try { return { pty: require("node-pty"), error: "" }; } catch (e) { return { pty: null, error: `node-pty could not load: ${e.message}` }; }
110
+ }
111
+
112
+ // What a declared agent may never do here, in the op's own words (serve.R).
113
+ const AGENT_REFUSED = Object.freeze({
114
+ term_open: "Agents cannot open a terminal; run code through chunk_run.",
115
+ term_input: "Agents cannot type into the user's terminal.",
116
+ term_resize: "Agents cannot resize the user's terminal.",
117
+ term_close: "Agents cannot close the user's terminal.",
118
+ });
119
+
120
+ export function createPlane({ env, audit }) {
121
+ /** @type {Map<string, {id: string, term: object, rec: object, pid: number, started: number, closing: boolean}>} */
122
+ const terms = new Map();
123
+ let seq = 0;
124
+ let loaded = null; // lazy: a kernel that never opens one pays nothing
125
+ let ctxRef = null;
126
+ const ctxPort = () => (ctxRef ? ctxRef.port() : 0);
127
+
128
+ const enc = (o) => JSON.stringify(o);
129
+ const send = (rec, id, fields) => { try { rec.ws.send(enc({ type: "term", id, ...fields })); } catch { /* the page is gone */ } };
130
+ const turnedOff = () => env("CARMAR_NO_TERMINAL") === "1";
131
+
132
+ /** Only the page that opened a terminal may type into it, resize it or close it. */
133
+ const owned = (rec, id) => {
134
+ const t = scalarChr(id) ? terms.get(id) : null;
135
+ return t && t.rec === rec ? t : null;
136
+ };
137
+
138
+ /**
139
+ * Kill the shell AND everything it started, and say so once.
140
+ *
141
+ * node-pty's own `kill()` signals the leader only, and the leader's group is
142
+ * not enough either: an interactive shell has JOB CONTROL, so `sleep 600 &`
143
+ * lives in a process group of its own and survived a group kill of the shell
144
+ * (measured 2026-09-14). `killProcessTree` walks the descendants first for
145
+ * exactly that case. serve.R kills the shell process and leaves the
146
+ * background child running.
147
+ */
148
+ const closeTerm = (id, why = "closed") => {
149
+ const t = terms.get(id);
150
+ if (!t || t.closing) return;
151
+ t.closing = true;
152
+ terms.delete(id);
153
+ killProcessTree(t.pid);
154
+ try { t.term.kill("SIGKILL"); } catch { /* already reaped */ }
155
+ audit("term-close", { id, detail: why });
156
+ send(t.rec, id, { event: "exit", code: null, reason: why });
157
+ };
158
+
159
+ function termOpen(cmd, rec) {
160
+ const refuse = (why) => {
161
+ audit("term-refused", { detail: why });
162
+ send(rec, "", { event: "refused", error: why });
163
+ };
164
+ if (turnedOff()) return refuse("The terminal is turned off on this kernel (CARMAR_NO_TERMINAL=1).");
165
+ // serve.R refuses Windows outright ("CarmaR does not provide a pty on
166
+ // Windows yet"), because processx has none there. node-pty ships ConPTY
167
+ // prebuilds, so the refusal is no longer true by construction — the
168
+ // platform is not asked, the loader is, and it says so if the prebuild
169
+ // for this platform is missing. (docs/host-plan.md §1, "Windows".)
170
+ const mine = [...terms.values()].filter((t) => t.rec === rec);
171
+ if (mine.length >= MAX_TERMS) return refuse(`${mine.length} terminals are already open on this page.`);
172
+ // node-pty first; with none (every installed R package: it is a native
173
+ // module) the pipe terminal in pipe-term.mjs. CARMAR_TERMINAL_PTY=0 forces
174
+ // the pipe, which is how a machine that HAS node-pty tests the other rung.
175
+ if (!loaded) loaded = env("CARMAR_TERMINAL_PTY") === "0" ? { pty: null, error: "CARMAR_TERMINAL_PTY=0" } : loadPty(audit);
176
+
177
+ const cols = clamp(cmd.cols, 20, 400, 100);
178
+ const rows = clamp(cmd.rows, 5, 200, 30);
179
+ let wd = process.cwd();
180
+ if (scalarChr(cmd.cwd) && cmd.cwd) {
181
+ try { if (fs.statSync(cmd.cwd).isDirectory()) wd = cmd.cwd; } catch { /* keep the kernel's cwd */ }
182
+ }
183
+ const usePipe = !loaded.pty;
184
+ const sh = usePipe ? pipeShell(process.env) : termShell(process.env);
185
+ const argv = usePipe ? { file: sh.cmd, args: sh.args } : termArgv(sh);
186
+ // The shell runs in the PHYSICAL directory (processx resolves the wd for
187
+ // serve.R, and `pwd` in the shell must agree with what the page shows);
188
+ // the frame reports the path the page ASKED for, which is what serve.R
189
+ // echoes back and what lib/terminal.js labels the tab with.
190
+ let real = wd;
191
+ try { real = fs.realpathSync(wd); } catch { real = wd; }
192
+ let term;
193
+ try {
194
+ term = usePipe
195
+ ? pipeSpawn(argv.file, argv.args, { cwd: real,
196
+ env: { ...process.env, TERM: "dumb", COLUMNS: String(cols), LINES: String(rows), CARMAR_PORT: String(ctxPort()) } })
197
+ : loaded.pty.spawn(argv.file, argv.args, {
198
+ name: "xterm-256color", cols, rows, cwd: real,
199
+ env: { ...process.env, TERM: "xterm-256color", COLORTERM: "truecolor",
200
+ COLUMNS: String(cols), LINES: String(rows), CARMAR_PORT: String(ctxPort()) },
201
+ });
202
+ } catch (e) { return refuse(`the shell could not start: ${e.message}`); }
203
+
204
+ seq += 1;
205
+ const id = `term-${seq}`;
206
+ const t = { id, term, rec, pid: term.pid, started: Date.now() / 1000, closing: false };
207
+ terms.set(id, t);
208
+ // Streamed straight to the OWNING socket. No pump: node-pty is
209
+ // event-driven, so the R supervisor's `pump_terms()` turn has no
210
+ // counterpart here — the bytes arrive when the shell writes them.
211
+ term.onData((data) => { if (!t.closing) send(rec, id, { event: "data", text: data }); });
212
+ term.onExit(({ exitCode }) => {
213
+ if (t.closing) return;
214
+ t.closing = true;
215
+ terms.delete(id);
216
+ audit("term-exit", { id, detail: String(exitCode) });
217
+ send(rec, id, { event: "exit", code: exitCode ?? null, reason: "exited" });
218
+ });
219
+ audit("term-open", { id, detail: sh.cmd, class: rec.class ?? "unknown", pty: !usePipe });
220
+ send(rec, id, { event: "open", shell: sh.cmd, pid: term.pid, cols, rows, cwd: wd, pty: !usePipe });
221
+ if (usePipe) {
222
+ if (loaded.error) audit("term-pipe", { id, detail: loaded.error });
223
+ send(rec, id, { event: "data", text: PIPE_NOTICE });
224
+ }
225
+ }
226
+
227
+ return {
228
+ name: "terminal",
229
+ ops: ["term_open", "term_input", "term_resize", "term_close"],
230
+ // Answered here, not through the seam's list: the R replies in the op's
231
+ // own words and a suite pins them (test/terminal.test.mjs).
232
+ agentRefused: [],
233
+ // serve.R advertises no terminal verb in ready.commands; neither does this.
234
+ // A kernel with CARMAR_NO_TERMINAL=1 still answers the op — with a refusal
235
+ // that names the reason, which is what the pane renders.
236
+ commands: [],
237
+ capabilities: [],
238
+
239
+ init(ctx) { ctxRef = ctx; },
240
+
241
+ handle(cmd, rec, ctx) {
242
+ ctxRef = ctx;
243
+ const type = cmd.type;
244
+ if (rec.role === "mcp") {
245
+ audit("mcp-refused", { reason: `agent asked for ${type}` });
246
+ if (scalarChr(cmd.id)) rec.ws.send(enc({ type, id: cmd.id, error: AGENT_REFUSED[type] }));
247
+ return;
248
+ }
249
+ if (rec.role !== "page") return;
250
+ // Page-only in BOTH senses: a declared agent is refused above, and a
251
+ // client that merely never declared is refused here by the CLASS the
252
+ // upgrade gate proved.
253
+ if (!PAGE_ONLY.includes(rec.class)) {
254
+ audit("term-refused", { reason: "class", class: rec.class ?? "unknown" });
255
+ send(rec, cmd.id ?? "", { event: "refused", error: "Only the local notebook page may open a terminal." });
256
+ return;
257
+ }
258
+ if (type === "term_open") return termOpen(cmd, rec);
259
+ const t = owned(rec, cmd.id);
260
+ if (!t) return;
261
+ if (type === "term_input") {
262
+ if (!scalarChr(cmd.text) || !cmd.text) return;
263
+ if (Buffer.byteLength(cmd.text) > TERM_INPUT_MAX) return;
264
+ try { t.term.write(cmd.text); } catch { /* the shell has gone; onExit says so */ }
265
+ return;
266
+ }
267
+ if (type === "term_resize") {
268
+ const cols = clamp(cmd.cols, 20, 400, 100);
269
+ const rows = clamp(cmd.rows, 5, 200, 30);
270
+ // The ioctl is what makes the kernel deliver SIGWINCH to the
271
+ // foreground group, so nothing is signalled by hand here.
272
+ try { t.term.resize(cols, rows); } catch { /* the shell has gone */ }
273
+ return;
274
+ }
275
+ if (type === "term_close") closeTerm(cmd.id, "closed");
276
+ },
277
+
278
+ /** A terminal dies with the socket that opened it. */
279
+ onClose(rec) {
280
+ for (const [id, t] of [...terms]) if (t.rec === rec) closeTerm(id, "page closed");
281
+ },
282
+
283
+ /** The kernel is going away; no shell may outlive it. */
284
+ shutdown() { for (const id of [...terms.keys()]) closeTerm(id, "kernel stopping"); },
285
+ };
286
+ }
@@ -0,0 +1,80 @@
1
+ // test-file.mjs — File ▾ ▸ Open Test File: a known document, one click away.
2
+ //
3
+ // The product ships one real-world R Markdown document beside the kernel files
4
+ // (`<kernel>/examples/tna-complete-tutorial.Rmd`, the tna package's complete
5
+ // tutorial — spike/examples/NOTICE.md carries its source and MIT license). This
6
+ // plane answers `test_file` with a path the page opens like any other document.
7
+ //
8
+ // THE PATH IS A COPY, NEVER THE SHIPPED FILE. The shipped copy lives inside the
9
+ // app bundle or the installed package, which a Save must not write into (and on
10
+ // an installed Mac cannot). So the first ask copies it to
11
+ // `R_user_dir("carmar", "data")/test-files/` and every later ask returns that
12
+ // copy UNTOUCHED — edits a person made to it are theirs, and a menu row that
13
+ // silently reverted them would be the wrong kind of "always there". A copy that
14
+ // was deleted comes back on the next click. `fresh: true` asks for a new copy
15
+ // beside it rather than over it.
16
+ //
17
+ // Page-only in both senses: it writes a file, and an agent that wants to read
18
+ // the tutorial can ask the page to open it.
19
+
20
+ import fs from "node:fs";
21
+ import path from "node:path";
22
+ import { appId, ensureUserDir } from "../user-dirs.mjs";
23
+ import { PAGE_ONLY_CLASSES } from "../server.mjs";
24
+
25
+ export const TEST_FILE_NAME = "tna-complete-tutorial.Rmd";
26
+ const OPS = ["test_file"];
27
+
28
+ /**
29
+ * Resolve (and if needed create) the user's copy of the shipped test file.
30
+ * @param {string} kernelDir the directory holding examples/
31
+ * @param {{fresh?: boolean}} [opts]
32
+ * @returns {{ok: true, path: string, created: boolean} | {ok: false, error: string}}
33
+ */
34
+ export function testFileCopy(kernelDir, { fresh = false } = {}) {
35
+ const source = path.join(kernelDir, "examples", TEST_FILE_NAME);
36
+ if (!fs.existsSync(source)) {
37
+ return { ok: false, error: "This CarmaR does not carry its test file (examples/tna-complete-tutorial.Rmd is missing). Reinstall CarmaR." };
38
+ }
39
+ const dir = path.join(ensureUserDir(appId(), "data"), "test-files");
40
+ try { fs.mkdirSync(dir, { recursive: true }); } catch (e) { return { ok: false, error: `Could not create ${dir}: ${e.message}` }; }
41
+ let dest = path.join(dir, TEST_FILE_NAME);
42
+ if (fresh && fs.existsSync(dest)) {
43
+ const stem = TEST_FILE_NAME.replace(/\.Rmd$/, "");
44
+ let n = 2;
45
+ while (fs.existsSync(path.join(dir, `${stem}-${n}.Rmd`))) n += 1; // a wait for a free name is a loop
46
+ dest = path.join(dir, `${stem}-${n}.Rmd`);
47
+ }
48
+ if (fs.existsSync(dest)) return { ok: true, path: dest, created: false };
49
+ try {
50
+ fs.copyFileSync(source, dest, fs.constants.COPYFILE_EXCL);
51
+ } catch (e) {
52
+ return { ok: false, error: `Could not copy the test file to ${dir}: ${e.message}` };
53
+ }
54
+ return { ok: true, path: dest, created: true };
55
+ }
56
+
57
+ export function createPlane({ spike, audit }) {
58
+ return {
59
+ name: "test-file",
60
+ ops: OPS,
61
+ agentRefused: OPS,
62
+ agentReason: () => "Agents cannot write the test file into the user's data folder; ask the page to open it.",
63
+ commands: OPS,
64
+ capabilities: ["test-file-v1"],
65
+ handle(cmd, rec, ctx) {
66
+ if (rec.role !== "page" || !ctx.scalarChr(cmd.id)) return undefined;
67
+ const reply = (fields) => rec.ws.send(ctx.enc({ type: "test_file", id: cmd.id, ...fields }));
68
+ if (!PAGE_ONLY_CLASSES.includes(rec.class)) {
69
+ audit("test-file-refused", { reason: "class", class: rec.class || "unknown" });
70
+ return reply({ error: "Only the local notebook page may ask this." });
71
+ }
72
+ const out = testFileCopy(spike, { fresh: cmd.fresh === true });
73
+ if (out.ok !== true) return reply({ error: out.error });
74
+ audit("test-file", { created: out.created });
75
+ return reply({ path: out.path, created: out.created });
76
+ },
77
+ };
78
+ }
79
+
80
+ export default { createPlane };
@@ -0,0 +1,214 @@
1
+ // update.mjs — signed product updates: two ops, and a very narrow API.
2
+ //
3
+ // A port of the `update_*` half of spike/serve.R. The updater is a
4
+ // PRODUCT-OWNED SCRIPT injected by the launcher (`CARMAR_UPDATE_SCRIPT`). The
5
+ // browser never supplies a path or a command line: it may name one action from
6
+ // a fixed four-word vocabulary, and the supervisor starts that exact script
7
+ // directly, as an argument vector.
8
+ //
9
+ // Nothing here reaches the network. The host does not check for updates, does
10
+ // not poll a feed and has no clock of its own — that belongs to the R package's
11
+ // `check_upgrade()`, once a day, at a door the person opened. All this plane
12
+ // can do is read the script's six-field status record and ask it to act, which
13
+ // is why `CARMAR_NO_UPDATE_CHECK=1` holds here trivially: with no script there
14
+ // is no process, and with a script there is still no feed.
15
+ //
16
+ // Who may ask: page-only in BOTH senses (agentRefused at the seam,
17
+ // PAGE_ONLY_CLASSES here) and loopback-only. A remote or shared kernel is
18
+ // updated by its fleet operator, and a native socket does not get a UI gesture
19
+ // by pretending to be the person looking at the settings pane.
20
+ //
21
+ // One transcription note. `processx::run(timeout = 5000)` in serve.R is FIVE
22
+ // THOUSAND SECONDS, not five: processx counts seconds. That is effectively no
23
+ // timeout at all, and it blocks the R loop while it runs. The same number is
24
+ // used here (as milliseconds × 1000) rather than quietly tightened, because a
25
+ // status read that takes ninety seconds on one machine and is killed on the
26
+ // other is a difference between the two backends that nobody asked for.
27
+
28
+ import fs from "node:fs";
29
+ import path from "node:path";
30
+ import { spawn, spawnSync } from "node:child_process";
31
+
32
+ const PAGE_ONLY_CLASSES = ["served", "file", "local"];
33
+ const OPS = ["update_status", "update_action"];
34
+ const AGENT_WHY = {
35
+ update_status: "Agents cannot inspect desktop update state.",
36
+ update_action: "Agents cannot install, defer, or roll back CarmaR.",
37
+ };
38
+ const ACTIONS = ["check", "defer", "install", "rollback"];
39
+ const STATES = ["unknown", "current", "available", "deferred", "installing",
40
+ "rolling-back", "error", "offline", "refused", "unconfigured"];
41
+ /** serve.R's `timeout = 5000` is processx seconds. */
42
+ const STATUS_TIMEOUT_MS = 5000 * 1000;
43
+ /** A running action older than this is killed; serve.R's own ceiling. */
44
+ const ACTION_MAX_S = 360;
45
+
46
+ /** The interpreter that runs the updater, or null when this platform has none. */
47
+ export function updateRunnerSpec(script, args = [], platform = process.platform) {
48
+ if (platform === "win32") {
49
+ const found = ["powershell.exe", "powershell"].map((name) => whichSync(name)).find(Boolean);
50
+ if (!found || !/\.ps1$/i.test(script)) return null;
51
+ return { command: found, args: ["-NoLogo", "-NoProfile", "-NonInteractive",
52
+ "-ExecutionPolicy", "Bypass", "-File", script, ...args] };
53
+ }
54
+ if (!fs.existsSync("/bin/sh")) return null;
55
+ return { command: "/bin/sh", args: [script, ...args] };
56
+ }
57
+
58
+ function whichSync(name) {
59
+ for (const dir of String(process.env.PATH || "").split(path.delimiter).filter(Boolean)) {
60
+ const cand = path.join(dir, name);
61
+ try { fs.accessSync(cand, fs.constants.X_OK); return cand; } catch { /* next */ }
62
+ }
63
+ return "";
64
+ }
65
+
66
+ // eslint-disable-next-line no-control-regex
67
+ const updateText = (x, max = 240) => String(x ?? "").replace(/[\x00-\x1f\x7f]/g, " ").slice(0, max).trim();
68
+ const updateVersion = (x) => {
69
+ const value = updateText(x, 64);
70
+ return /^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$/.test(value) ? value : "";
71
+ };
72
+
73
+ export function createPlane({ env, audit }) {
74
+ const job = { proc: null, action: "", started: 0 };
75
+
76
+ const scriptPath = (deployment) => {
77
+ const script = env("CARMAR_UPDATE_SCRIPT", "").trim();
78
+ if (deployment.loopback !== true || !script || !fs.existsSync(script)) return "";
79
+ const real = fs.realpathSync(script);
80
+ return updateRunnerSpec(real) ? real : "";
81
+ };
82
+
83
+ const busy = () => {
84
+ if (!job.proc) return false;
85
+ let alive = job.proc.exitCode === null && job.proc.signalCode === null;
86
+ if (alive && job.started && (Date.now() - job.started) / 1000 > ACTION_MAX_S) {
87
+ try { job.proc.kill("SIGKILL"); } catch { /* gone */ }
88
+ alive = false;
89
+ audit("update-timeout", { detail: job.action });
90
+ }
91
+ if (!alive) { job.proc = null; job.action = ""; }
92
+ return alive;
93
+ };
94
+
95
+ /**
96
+ * Read the updater's deliberately small status record.
97
+ *
98
+ * The FOURTH tab-separated field is an installer PATH, and it is discarded on
99
+ * purpose: the settings page needs state and versions, not a map of the
100
+ * user's disk.
101
+ */
102
+ const statusState = (deployment, kernelBuild) => {
103
+ const script = scriptPath(deployment);
104
+ if (!script) {
105
+ return { supported: false, state: "unavailable", current: kernelBuild,
106
+ offered: "", previous: "", busy: false, action: "",
107
+ can_check: false, can_update: false, can_defer: false, can_rollback: false,
108
+ message: "Signed desktop updates are not available in this deployment." };
109
+ }
110
+ if (busy()) {
111
+ return { supported: true, state: "checking", current: kernelBuild,
112
+ offered: "", previous: "", busy: true, action: job.action,
113
+ can_check: false, can_update: false, can_defer: false, can_rollback: false,
114
+ message: {
115
+ check: "Checking and verifying the signed update…",
116
+ install: "Opening the verified installer…",
117
+ rollback: "Opening the last-known-good installer…",
118
+ defer: "Saving the defer choice…",
119
+ }[job.action] || "Working…" };
120
+ }
121
+ const spec = updateRunnerSpec(script, ["status"]);
122
+ let ran = null;
123
+ try { ran = spawnSync(spec.command, spec.args, { encoding: "utf8", timeout: STATUS_TIMEOUT_MS }); }
124
+ catch { ran = null; }
125
+ const line = ran && ran.status === 0 ? String(ran.stdout || "").split("\n")[0] : "";
126
+ const fields = line ? line.split("\t") : [];
127
+ const at = (i) => (fields.length >= i ? fields[i - 1] : "");
128
+ let state = updateText(at(1), 32);
129
+ if (!STATES.includes(state)) state = "error";
130
+ const current = updateVersion(at(2));
131
+ const offered = updateVersion(at(3));
132
+ const previous = updateVersion(at(5));
133
+ let message = updateText(at(6));
134
+ if (!message) {
135
+ message = state === "error" ? "The signed updater did not return a valid status."
136
+ : "Update status is available.";
137
+ }
138
+ return { supported: true, state, current, offered, previous, busy: false, action: "",
139
+ can_check: state !== "unconfigured",
140
+ can_update: state === "available" && Boolean(offered),
141
+ can_defer: ["current", "available", "deferred", "offline"].includes(state),
142
+ can_rollback: state !== "unconfigured" && Boolean(previous),
143
+ message };
144
+ };
145
+
146
+ const startAction = (deployment, kernelBuild, action, daysRaw) => {
147
+ const script = scriptPath(deployment);
148
+ if (!script) {
149
+ return { ok: false, reason: "unavailable", error: "Signed desktop updates are not available in this deployment." };
150
+ }
151
+ if (!ACTIONS.includes(action)) return { ok: false, reason: "action", error: "No such update action." };
152
+ if (busy()) return { ok: false, reason: "busy", error: "Another update action is still running." };
153
+
154
+ const status = statusState(deployment, kernelBuild);
155
+ if (action === "check" && status.can_check !== true) return { ok: false, reason: "state", error: status.message };
156
+ if (action === "defer" && status.can_defer !== true) {
157
+ return { ok: false, reason: "state", error: "Update checks cannot be deferred in the current state." };
158
+ }
159
+ if (action === "install" && status.can_update !== true) return { ok: false, reason: "state", error: "No verified update is ready." };
160
+ if (action === "rollback" && status.can_rollback !== true) {
161
+ return { ok: false, reason: "state", error: "No last-known-good version is available." };
162
+ }
163
+ let days = null;
164
+ if (action === "defer") {
165
+ days = Number.parseInt(daysRaw, 10);
166
+ if (!Number.isInteger(days) || days < 1 || days > 30) {
167
+ return { ok: false, reason: "days", error: "Defer days must be from 1 to 30." };
168
+ }
169
+ }
170
+ const spec = updateRunnerSpec(script, action === "defer" ? [action, String(days)] : [action]);
171
+ let proc = null;
172
+ try {
173
+ proc = spawn(spec.command, spec.args, { stdio: ["ignore", "ignore", "ignore"], detached: false });
174
+ } catch { proc = null; }
175
+ if (!proc) return { ok: false, reason: "start", error: "The signed updater could not be started." };
176
+ proc.on("error", () => { /* a failed spawn ends the job like any other exit */ });
177
+ job.proc = proc;
178
+ job.action = action;
179
+ job.started = Date.now();
180
+ audit("update-action", { detail: action });
181
+ return { ok: true, accepted: true, action };
182
+ };
183
+
184
+ return {
185
+ name: "update",
186
+ ops: OPS,
187
+ agentRefused: OPS,
188
+ agentReason: (op) => AGENT_WHY[op],
189
+ commands: OPS,
190
+ capabilities: ["signed-updates-v1"],
191
+
192
+ handle(cmd, rec, ctx) {
193
+ const { enc, scalarChr, deployment } = ctx;
194
+ if (!scalarChr(cmd.id)) return undefined;
195
+ const reply = (payload) => rec.ws.send(enc({ type: cmd.type, id: cmd.id, ...payload }));
196
+ if (!PAGE_ONLY_CLASSES.includes(rec.class)) {
197
+ audit("update-refused", { reason: "class", class: rec.class || "unknown" });
198
+ reply({ ok: false, reason: "class", error: "Only the local notebook page may manage desktop updates." });
199
+ return undefined;
200
+ }
201
+ if (deployment.loopback !== true) {
202
+ audit("update-refused", { reason: "posture", class: rec.class || "unknown" });
203
+ reply({ ok: false, reason: "posture", error: "This deployment is updated by its administrator." });
204
+ return undefined;
205
+ }
206
+ if (cmd.type === "update_status") {
207
+ reply(statusState(deployment, ctx.kernelBuild));
208
+ } else {
209
+ reply(startAction(deployment, ctx.kernelBuild, scalarChr(cmd.action) ? cmd.action : "", cmd.days ?? null));
210
+ }
211
+ return undefined;
212
+ },
213
+ };
214
+ }