beatrina 0.8.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +92 -0
- package/NOTICES +72 -0
- package/README.md +124 -0
- package/bin/beatrina.mjs +223 -0
- package/bin/cli.mjs +80 -0
- package/bin/failsafe.mjs +74 -0
- package/bin/identity.mjs +45 -0
- package/bin/prova-post.mjs +51 -0
- package/bin/sessions.mjs +95 -0
- package/bin/shortcut.mjs +151 -0
- package/bin/update-check.mjs +55 -0
- package/bin/upgrade.mjs +76 -0
- package/build-info.json +1 -0
- package/carmar_V0.8.6.html +1310 -0
- package/check/acceptance.mjs +278 -0
- package/check/session.mjs +215 -0
- package/engines/js/document-exec.mjs +82 -0
- package/engines/js/persist.mjs +214 -0
- package/engines/js/worker.mjs +424 -0
- package/engines/python/adapter.py +577 -0
- package/engines/python/analyze.py +814 -0
- package/engines/python/bootstrap.py +309 -0
- package/engines/python/dataview.py +735 -0
- package/engines/python/debugger.py +346 -0
- package/engines/python/document_exec.py +158 -0
- package/engines/python/engine.json +28 -0
- package/engines/python/handoff.py +118 -0
- package/engines/python/worker.py +564 -0
- package/engines/r/engine.json +25 -0
- package/engines/r/handoff.R +92 -0
- package/failsafe/ai-policy.R +255 -0
- package/failsafe/ai-store.R +373 -0
- package/failsafe/cite.R +418 -0
- package/failsafe/journal.R +684 -0
- package/failsafe/plugins.R +809 -0
- package/failsafe/serve.R +5500 -0
- package/host/ai-policy.mjs +218 -0
- package/host/deployment.mjs +160 -0
- package/host/engine-js.mjs +98 -0
- package/host/engine-pool.mjs +383 -0
- package/host/engine-python.mjs +228 -0
- package/host/engine-r.mjs +206 -0
- package/host/engine-stdio.mjs +401 -0
- package/host/journal-store.mjs +749 -0
- package/host/main.mjs +503 -0
- package/host/planes/README.md +41 -0
- package/host/planes/ai-store.mjs +327 -0
- package/host/planes/ai.mjs +467 -0
- package/host/planes/analyze.mjs +397 -0
- package/host/planes/cite.mjs +517 -0
- package/host/planes/files.mjs +0 -0
- package/host/planes/jobs.mjs +704 -0
- package/host/planes/journal.mjs +53 -0
- package/host/planes/latex.mjs +201 -0
- package/host/planes/mcp.mjs +493 -0
- package/host/planes/pair.mjs +325 -0
- package/host/planes/pipe-term.mjs +122 -0
- package/host/planes/plugins.mjs +112 -0
- package/host/planes/proc-tree.mjs +76 -0
- package/host/planes/sessions.mjs +434 -0
- package/host/planes/settings.mjs +164 -0
- package/host/planes/terminal.mjs +286 -0
- package/host/planes/test-file.mjs +80 -0
- package/host/planes/update.mjs +214 -0
- package/host/plugin-store.mjs +838 -0
- package/host/server.mjs +441 -0
- package/host/settings.mjs +379 -0
- package/host/update-record.mjs +59 -0
- package/host/user-dirs.mjs +117 -0
- package/host/windows-runtime.mjs +145 -0
- package/host/worker-plane.mjs +713 -0
- package/host/ws.mjs +190 -0
- package/kernel/analyze.R +668 -0
- package/kernel/deployment.R +165 -0
- package/kernel/examples/NOTICE.md +38 -0
- package/kernel/examples/tna-complete-tutorial.Rmd +210 -0
- package/kernel/fileio.R +656 -0
- package/kernel/index.html +96 -0
- package/kernel/job-run.R +391 -0
- package/kernel/jobs.R +276 -0
- package/kernel/kernel-protocol +1 -0
- package/kernel/kernel-version +1 -0
- package/kernel/kernel.R +671 -0
- package/kernel/knitr-run.R +245 -0
- package/kernel/latex.R +609 -0
- package/kernel/mcp/carmar-mcp.mjs +516 -0
- package/kernel/notebook-page.R +67 -0
- package/kernel/plugins/csl/apa/apa.csl +2273 -0
- package/kernel/plugins/csl/apa/plugin.json +19 -0
- package/kernel/plugins/csl/chicago-author-date/chicago-author-date.csl +4216 -0
- package/kernel/plugins/csl/chicago-author-date/plugin.json +19 -0
- package/kernel/plugins/csl/harvard-cite-them-right/harvard-cite-them-right.csl +316 -0
- package/kernel/plugins/csl/harvard-cite-them-right/plugin.json +19 -0
- package/kernel/plugins/csl/ieee/ieee.csl +519 -0
- package/kernel/plugins/csl/ieee/plugin.json +19 -0
- package/kernel/plugins/csl/modern-language-association/modern-language-association.csl +1184 -0
- package/kernel/plugins/csl/modern-language-association/plugin.json +19 -0
- package/kernel/plugins/csl/nature/nature.csl +189 -0
- package/kernel/plugins/csl/nature/plugin.json +19 -0
- package/kernel/plugins/latex/apa7/apa7.json +14 -0
- package/kernel/plugins/latex/apa7/plugin.json +19 -0
- package/kernel/plugins/latex/elsarticle/elsarticle.json +14 -0
- package/kernel/plugins/latex/elsarticle/plugin.json +19 -0
- package/kernel/plugins/latex/ieeetran/ieeetran.json +10 -0
- package/kernel/plugins/latex/ieeetran/plugin.json +19 -0
- package/kernel/project.R +131 -0
- package/kernel/settings.R +410 -0
- package/kernel/sniff.R +769 -0
- package/kernel/worker-boot.R +22 -0
- package/kernel/worker.R +3496 -0
- package/lib/agent-authoring-contract.js +547 -0
- package/lib/cell-kinds.js +108 -0
- package/lib/engine-labels.js +324 -0
- package/package.json +32 -0
|
@@ -0,0 +1,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
|
+
}
|