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,325 @@
1
+ // pair.mjs — the consent doors, and the one that hands a document to a page.
2
+ //
3
+ // A port of spike/serve.R's `/pair`, `/pair/approve`, `/published/authorize`
4
+ // and `/open`, plus the `CARMAR_PUBLISHED_ORIGIN` seed. Nothing here opens a
5
+ // socket; it decides which exact Origin STRING the upgrade gate in
6
+ // host/server.mjs will admit this session (`ctx.approvals`), and it is the
7
+ // only writer of that set.
8
+ //
9
+ // The shape, and why it is this shape (serve.R, lines 686–880 and 1575–1730):
10
+ //
11
+ // · Merely OPENING /pair grants nothing. It renders a LOCAL, non-frameable
12
+ // consent page (X-Frame-Options: DENY and `frame-ancestors 'none'` come
13
+ // from the host's SAFE_HEADERS) carrying a 48-character challenge minted
14
+ // from the OS CSPRNG. A foreign page can navigate to this route, but
15
+ // same-origin policy keeps it from READING the challenge, so it cannot
16
+ // manufacture the approval navigation without the reader pressing the
17
+ // button. Only that button can spend it, it is spent ONCE, and it dies
18
+ // after 300 seconds.
19
+ // · There are THREE consent texts, not one, because the three cases are
20
+ // genuinely different and a person has to be able to tell them apart: a
21
+ // published site (an exact https origin), a page opened from disk (the
22
+ // literal four-character Origin "null", which is not a web origin and
23
+ // never becomes one), and a notebook another loopback kernel served that
24
+ // is re-attaching after its own session stopped. Nothing is granted for
25
+ // being loopback — the local case asks too — but once approved it is
26
+ // classed `local`, which is in PAGE_ONLY_CLASSES.
27
+ // · A PUBLISHED origin gets the bridge page afterwards: a published reader's
28
+ // page never holds this socket itself, the local bridge window does, and
29
+ // relays only messages from the exact approved origin. A file or local
30
+ // page dials the kernel directly the moment its retry fires, so all its
31
+ // window has left to do is say so.
32
+ // · /published/authorize is the NATIVE door (the carmar:// handler). It is
33
+ // gated by controlRejection, so a browser page cannot grant itself R
34
+ // execution by visiting a URL; `publishedOriginValid` stays strict
35
+ // (^https?://…) because it also guards the environment seed, and widening
36
+ // it there would let one variable pre-approve every file on disk.
37
+ // · /open is the double-click door: it hands a path to a page that is
38
+ // ALREADY RUNNING instead of starting a fourth R. No extension list, on
39
+ // purpose — the authority on what may be read is `readfile` in the worker,
40
+ // and a second weaker copy of that judgment only ever produced false
41
+ // rejections. Origin-gated exactly like /shutdown.
42
+
43
+ import fs from "node:fs";
44
+ import { FILE_ORIGIN, LOCAL_ORIGIN_RE, secureToken } from "../server.mjs";
45
+
46
+ /** How long an unspent consent challenge lives. serve.R: 300 seconds. */
47
+ export const PAIR_CHALLENGE_TTL_S = 300;
48
+ /** The consent challenge is 48 bytes of CSPRNG, not 32: it is read off a page. */
49
+ export const PAIR_CHALLENGE_CHARS = 48;
50
+
51
+ /**
52
+ * Is this an exact web origin (scheme + authority, no path or credentials)?
53
+ *
54
+ * Deliberately a small parser: the value is only ever used as an opaque key
55
+ * compared with the browser-supplied Origin header. Keeping paths, fragments
56
+ * and control characters out is also what makes it safe to show on the
57
+ * consent page.
58
+ *
59
+ * @param {unknown} origin
60
+ * @returns {boolean}
61
+ */
62
+ export function publishedOriginValid(origin) {
63
+ return typeof origin === "string" && Buffer.byteLength(origin) <= 512
64
+ && /^https?:\/\/[A-Za-z0-9._~-]+(:[0-9]{1,5})?$/.test(origin);
65
+ }
66
+
67
+ /** A notebook page another loopback kernel served (serve.R: local_origin). */
68
+ export function localOrigin(origin) {
69
+ return typeof origin === "string" && LOCAL_ORIGIN_RE.test(origin);
70
+ }
71
+
72
+ /** The five characters that can change the meaning of the markup around them. */
73
+ export function htmlEscape(x) {
74
+ return String(x)
75
+ .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
76
+ .replace(/"/g, "&quot;").replace(/'/g, "&#39;");
77
+ }
78
+
79
+ const PAGE_CSS = "body{font:16px/1.5 system-ui,sans-serif;max-width:42rem;margin:10vh auto;padding:0 1.25rem;color:#172033}"
80
+ + ".site{padding:.75rem 1rem;background:#f3f6fb;border-radius:.55rem;overflow-wrap:anywhere}"
81
+ + "button{border:0;border-radius:.5rem;background:#2357d9;color:white;padding:.7rem 1rem;font:inherit;font-weight:650;cursor:pointer}"
82
+ + "small{color:#59657a}";
83
+ const NARROW_CSS = "body{font:16px/1.5 system-ui,sans-serif;max-width:38rem;margin:10vh auto;padding:0 1.25rem;color:#172033}"
84
+ + ".site{overflow-wrap:anywhere;color:#2357d9}";
85
+
86
+ /**
87
+ * The consent page for one origin, and the challenge it carries.
88
+ * @returns {{html: string, challenge: string}}
89
+ */
90
+ export function pairingPage(origin, nonce, mint) {
91
+ const isFile = origin === FILE_ORIGIN;
92
+ const isLocal = localOrigin(origin);
93
+ const challenge = mint(origin, String(nonce ?? "").slice(0, 200));
94
+ const title = isFile ? "Allow this notebook file to use CarmaR?"
95
+ : isLocal ? "Attach this notebook to this CarmaR session?"
96
+ : "Allow this book to use CarmaR?";
97
+ const body = isFile
98
+ ? '<h1>Run this notebook file with your R?</h1>'
99
+ + '<p>A notebook opened from a file on this computer</p>'
100
+ + '<p class="site"><strong>a page opened from disk (file://)</strong></p>'
101
+ + '<p>wants to use this CarmaR session. The code will run as your user '
102
+ + 'and can access your files and network. A page opened from a file '
103
+ + 'cannot be identified any more exactly than this, so approve it only '
104
+ + 'if you just opened a notebook of your own.</p>'
105
+ : isLocal
106
+ ? '<h1>Attach this notebook to this R session?</h1>'
107
+ + '<p>A CarmaR notebook page served at</p>'
108
+ + `<p class="site"><strong>${htmlEscape(origin)}</strong></p>`
109
+ + '<p>wants to use this CarmaR session instead of its own &mdash; usually '
110
+ + 'because the session that served it has stopped. It will be treated as '
111
+ + 'your notebook: its chunks run as your user, and it may open a terminal '
112
+ + 'and use your saved AI settings. Approve it only if you just asked a '
113
+ + 'notebook of your own to attach.</p>'
114
+ : '<h1>Run this book with your R?</h1><p>The published site</p>'
115
+ + `<p class="site"><strong>${htmlEscape(origin)}</strong></p>`
116
+ + '<p>wants to send R chunks to this CarmaR session on your computer. '
117
+ + 'The code will run as your user and can access your files and network.</p>';
118
+ const html = '<!doctype html><html><head><meta charset="utf-8">'
119
+ + '<meta name="viewport" content="width=device-width,initial-scale=1">'
120
+ + '<meta http-equiv="Content-Security-Policy" content="default-src \'none\'; '
121
+ + "style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'\">"
122
+ + `<title>${title}</title><style>${PAGE_CSS}</style></head><body>`
123
+ + body
124
+ + '<form method="get" action="/pair/approve"><input type="hidden" name="challenge" value="'
125
+ + challenge + '"><button type="submit">'
126
+ + (isLocal ? "Attach for this session" : "Allow for this session")
127
+ + '</button></form>'
128
+ + "<p><small>Nothing runs until you press a chunk's Run button. "
129
+ + 'Approval disappears when this CarmaR session stops.</small></p></body></html>';
130
+ return { html, challenge };
131
+ }
132
+
133
+ /**
134
+ * The window that HOLDS the socket for a published site and relays its frames.
135
+ *
136
+ * The reader's page never connects here; this local window does, and it only
137
+ * ever forwards messages whose origin and nonce match the pairing it was
138
+ * opened for.
139
+ */
140
+ export function pairingBridgePage(rec, port) {
141
+ const target = JSON.stringify(rec.origin);
142
+ const nonce = JSON.stringify(rec.nonce ?? "");
143
+ const socketUrl = JSON.stringify(`ws://127.0.0.1:${port}/ws`);
144
+ return '<!doctype html><html><head><meta charset="utf-8">'
145
+ + '<meta name="viewport" content="width=device-width,initial-scale=1">'
146
+ + '<meta http-equiv="Content-Security-Policy" content="default-src \'none\'; '
147
+ + "script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self' ws://127.0.0.1:"
148
+ + port + "; base-uri 'none'\">"
149
+ + '<title>CarmaR connected</title><style>' + NARROW_CSS + '</style></head><body>'
150
+ + '<h1>CarmaR is connected</h1><p>This session now runs chunks pressed on</p>'
151
+ + `<p class="site"><strong>${htmlEscape(rec.origin)}</strong></p>`
152
+ + '<p>The page connects by itself and closes this window; if it stays open, you can close it. '
153
+ + 'The approval lasts until this R session stops.</p><script>(function(){'
154
+ + 'const target=' + target + ',nonce=' + nonce + ',socketUrl=' + socketUrl + ';let ws=null,wsGeneration=0;'
155
+ + 'const tell=(type,extra)=>{if(window.opener)window.opener.postMessage('
156
+ + 'Object.assign({type,nonce,origin:target,port:' + port + '},extra||{}),target)};'
157
+ + 'addEventListener("message",event=>{const data=event.data||{};'
158
+ + 'if(event.source!==window.opener||event.origin!==target||data.nonce!==nonce)return;'
159
+ + 'if(data.type==="carmar:bridge-attach"){const generation=++wsGeneration,old=ws;'
160
+ + 'if(old){old.onmessage=old.onerror=old.onclose=null;try{old.close()}catch(e){}}'
161
+ + 'ws=new WebSocket(socketUrl);const current=ws;'
162
+ + 'current.onmessage=message=>{if(generation===wsGeneration)tell("carmar:bridge-frame",{data:message.data})};'
163
+ + 'current.onerror=()=>{if(generation===wsGeneration)tell("carmar:bridge-error")};'
164
+ + 'current.onclose=()=>{if(generation===wsGeneration)tell("carmar:bridge-close")};return}'
165
+ + 'if(data.type==="carmar:bridge-send"&&ws&&ws.readyState===1)ws.send(data.data);'
166
+ + 'if(data.type==="carmar:bridge-close"&&ws)ws.close()});'
167
+ + 'tell("carmar:paired");})();</script></body></html>';
168
+ }
169
+
170
+ /** The last word for a file or local page: it dials the kernel by itself. */
171
+ export function pairingDonePage(rec) {
172
+ return '<!doctype html><html><head><meta charset="utf-8">'
173
+ + '<meta name="viewport" content="width=device-width,initial-scale=1">'
174
+ + '<meta http-equiv="Content-Security-Policy" content="default-src \'none\'; '
175
+ + "style-src 'unsafe-inline'; base-uri 'none'\">"
176
+ + '<title>CarmaR attached</title><style>' + NARROW_CSS + '</style></head><body>'
177
+ + '<h1>Attached</h1><p>The notebook</p>'
178
+ + '<p class="site"><strong>'
179
+ + (rec.origin === FILE_ORIGIN ? "a page opened from disk (file://)" : htmlEscape(rec.origin))
180
+ + '</strong></p>'
181
+ + '<p>may use this CarmaR session until it stops. It reconnects by itself within '
182
+ + 'a few seconds; you can close this window.</p></body></html>';
183
+ }
184
+
185
+ export function createPlane({ env, audit }) {
186
+ /** challenge → {origin, nonce, created} — unspent consent requests. */
187
+ const requests = new Map();
188
+
189
+ const prune = (now = Date.now()) => {
190
+ for (const [key, rec] of requests) {
191
+ if (!(rec.created > 0) || (now - rec.created) / 1000 > PAIR_CHALLENGE_TTL_S) requests.delete(key);
192
+ }
193
+ };
194
+ const mint = (origin, nonce) => {
195
+ prune();
196
+ const challenge = secureToken(PAIR_CHALLENGE_CHARS);
197
+ requests.set(challenge, { origin, nonce, created: Date.now() });
198
+ return challenge;
199
+ };
200
+
201
+ /**
202
+ * The page an /open should land on: the one the user most recently focused
203
+ * (WP5's MCP plane claims it), else the most recently opened page. `null`
204
+ * when no notebook window is connected at all — which is an ANSWER (the
205
+ * caller is told "parked"), never silence.
206
+ */
207
+ const targetPage = (ctx) => {
208
+ const active = ctx.state.activePage;
209
+ if (active && ctx.sockets.includes(active)) return active;
210
+ const pages = ctx.pageRecs();
211
+ return pages.length ? pages[pages.length - 1] : null;
212
+ };
213
+
214
+ return {
215
+ name: "pair",
216
+ ops: [], // every door here is HTTP; none is a socket frame
217
+ commands: [],
218
+ capabilities: [],
219
+
220
+ init(ctx) {
221
+ // A native CarmaR launcher may start the kernel for one published page.
222
+ // The URL-scheme handoff is the user's consent gesture; carrying only
223
+ // the exact origin into this process keeps the permission session-scoped
224
+ // and stops a second site borrowing it.
225
+ const seed = env("CARMAR_PUBLISHED_ORIGIN", "");
226
+ if (publishedOriginValid(seed)) {
227
+ ctx.approvals.add(seed);
228
+ audit("published-origin-authorized", { origin: seed, source: "launcher" });
229
+ }
230
+ if (ctx.state.pendingOpen) audit("open-file-parked", {});
231
+ },
232
+
233
+ http(req, res, pathname, ctx) {
234
+ if (pathname === "/published/authorize") {
235
+ const blocked = ctx.controlRejection(req);
236
+ if (blocked) { ctx.reject(res, blocked.reason, blocked.detail); return true; }
237
+ const origin = ctx.queryParam(req, "origin");
238
+ if (!publishedOriginValid(origin)) {
239
+ audit("published-origin-rejected", { detail: origin });
240
+ ctx.respond(res, 400, "application/json", JSON.stringify({ ok: false, error: "invalid origin" }));
241
+ return true;
242
+ }
243
+ ctx.approvals.add(origin);
244
+ audit("published-origin-authorized", { origin, source: "native" });
245
+ ctx.respond(res, 200, "application/json", JSON.stringify({ ok: true, origin }));
246
+ return true;
247
+ }
248
+
249
+ if (pathname === "/pair") {
250
+ const origin = ctx.queryParam(req, "origin");
251
+ const nonce = ctx.queryParam(req, "nonce");
252
+ // FILE_ORIGIN is accepted HERE and only here.
253
+ if (origin !== FILE_ORIGIN && !publishedOriginValid(origin)) {
254
+ audit("pair-rejected", { detail: origin });
255
+ ctx.respond(res, 400, "text/plain", "A valid http(s) publishing origin is required.");
256
+ return true;
257
+ }
258
+ if (ctx.approvals.has(origin)) {
259
+ audit("pair-reconnected", { origin });
260
+ ctx.respond(res, 200, "text/html", pairingBridgePage({ origin, nonce }, ctx.port()));
261
+ return true;
262
+ }
263
+ audit("pair-offered", { origin });
264
+ ctx.respond(res, 200, "text/html", pairingPage(origin, nonce, mint).html);
265
+ return true;
266
+ }
267
+
268
+ if (pathname === "/pair/approve") {
269
+ prune();
270
+ const challenge = ctx.queryParam(req, "challenge");
271
+ const rec = challenge ? requests.get(challenge) : null;
272
+ if (!rec) {
273
+ audit("pair-rejected", { detail: "missing or expired challenge" });
274
+ ctx.respond(res, 400, "text/plain", "This CarmaR approval request has expired.");
275
+ return true;
276
+ }
277
+ requests.delete(challenge); // consume-once: no replay
278
+ ctx.approvals.add(rec.origin);
279
+ audit("published-origin-approved", { origin: rec.origin });
280
+ const page = (rec.origin === FILE_ORIGIN || localOrigin(rec.origin))
281
+ ? pairingDonePage(rec) : pairingBridgePage(rec, ctx.port());
282
+ ctx.respond(res, 200, "text/html", page);
283
+ return true;
284
+ }
285
+
286
+ if (pathname === "/open") {
287
+ const blocked = ctx.controlRejection(req);
288
+ if (blocked) { ctx.reject(res, blocked.reason, blocked.detail); return true; }
289
+ const asked = ctx.queryParam(req, "file");
290
+ let file = "";
291
+ if (asked) {
292
+ try {
293
+ const st = fs.statSync(asked);
294
+ if (st.isFile()) file = fs.realpathSync(asked);
295
+ } catch { file = ""; } // no such path: the refusal below says so
296
+ }
297
+ if (!file) {
298
+ audit("open-rejected", { detail: asked });
299
+ ctx.respond(res, 400, "text/plain", "no such file");
300
+ return true;
301
+ }
302
+ const page = targetPage(ctx);
303
+ const park = () => {
304
+ ctx.state.pendingOpen = file;
305
+ audit("open-parked", { detail: file });
306
+ ctx.respond(res, 200, "application/json", '{"ok":true,"delivered":"parked"}');
307
+ };
308
+ // No page is connected yet — this kernel was started for this very
309
+ // document and its notebook is still loading. Park it exactly as the
310
+ // environment channel does; the first page to ask collects it.
311
+ if (!page) { park(); return true; }
312
+ audit("open", { detail: file });
313
+ let sent = false;
314
+ try { sent = page.ws.send(JSON.stringify({ type: "open-file", path: file })) !== false; } catch { sent = false; }
315
+ // The page we picked died between the roster read and the send. Park
316
+ // rather than report a success nobody acted on.
317
+ if (!sent) { park(); return true; }
318
+ ctx.respond(res, 200, "application/json", '{"ok":true,"delivered":"page"}');
319
+ return true;
320
+ }
321
+
322
+ return false;
323
+ },
324
+ };
325
+ }
@@ -0,0 +1,122 @@
1
+ // pipe-term.mjs — a terminal without a pty, for a kernel that has no node-pty.
2
+ //
3
+ // node-pty is a NATIVE module. The carmar R package is source-only and
4
+ // CRAN-shaped (tools/ship/build-r-pkg.sh), so an installed Beatrix has never
5
+ // had it: every term_open from an installed package answered "node-pty is not
6
+ // installed on this kernel" (measured 2026-09-15, tools/beatrina/acceptance.mjs
7
+ // against build/carmar_0.8.4.tar.gz). The terminal pane then had nothing.
8
+ //
9
+ // This is the honest second rung: the user's shell on plain pipes, wearing the
10
+ // same shape node-pty's IPty has (pid · onData · onExit · write · resize ·
11
+ // kill), so host/planes/terminal.mjs keeps one code path. What a pipe CAN do is
12
+ // what lib/terminal.js already promises — "Line programs run here: shell, git,
13
+ // make, python" — and what it cannot is said once, in the pane, when it opens:
14
+ // no full-screen programs, no password prompts, no job control.
15
+ //
16
+ // Two keys a pty would translate are translated here instead, because a pipe
17
+ // carries them as bytes the shell would never see as signals:
18
+ // · ^C interrupts what the SHELL started, never the shell itself: the
19
+ // shell's descendants get SIGINT (Unix), or a console Ctrl+C goes to the
20
+ // shell's own console through processx's interrupt.exe (Windows), which
21
+ // cmd.exe survives and the running program does not.
22
+ // · ^D closes the shell's stdin, which ends it, as at a prompt.
23
+
24
+ import { spawn } from "node:child_process";
25
+ import path from "node:path";
26
+ import { descendantsOf } from "./proc-tree.mjs";
27
+ import { findInterruptTool, windowsInterruptPlan, windowsKillPlan } from "../windows-runtime.mjs";
28
+
29
+ /** What the pane is told when the terminal is a pipe. */
30
+ export const PIPE_NOTICE = "[This terminal has no pty: line programs work (git, R, python scripts); "
31
+ + "full-screen programs and password prompts do not.]\r\n";
32
+
33
+ /**
34
+ * The shell and arguments for a pipe terminal. A login shell where the shell
35
+ * supports it, so PATH is the user's; never `-i`, which on a pipe prints job
36
+ * control warnings and prompts to stderr.
37
+ */
38
+ export function pipeShell(env = process.env, platform = process.platform) {
39
+ if (platform === "win32") {
40
+ return { cmd: env.ComSpec || env.COMSPEC || "cmd.exe", args: ["/Q"] };
41
+ }
42
+ const shell = env.SHELL || "/bin/sh";
43
+ const base = path.basename(shell);
44
+ return { cmd: shell, args: ["bash", "zsh", "fish"].includes(base) ? ["-l"] : [] };
45
+ }
46
+
47
+ /**
48
+ * Spawn a shell on pipes, returning an object shaped like node-pty's IPty.
49
+ * @param {string} file
50
+ * @param {string[]} args
51
+ * @param {{cwd?: string, env?: object, platform?: string, spawnImpl?: Function, signal?: Function, tree?: Function}} opts
52
+ */
53
+ export function pipeSpawn(file, args, opts = {}) {
54
+ const platform = opts.platform || process.platform;
55
+ const spawnImpl = opts.spawnImpl || spawn;
56
+ const signal = opts.signal || ((pid, sig) => process.kill(pid, sig));
57
+ const tree = opts.tree || descendantsOf;
58
+ const win = platform === "win32";
59
+ const child = spawnImpl(file, args, {
60
+ cwd: opts.cwd, env: opts.env, stdio: ["pipe", "pipe", "pipe"],
61
+ // Unix: its own group, so nothing aimed at the host's group reaches it.
62
+ // Windows: its own (hidden) console, so interrupt.exe can target it.
63
+ detached: !win, windowsHide: true,
64
+ });
65
+ const dataFns = [];
66
+ const exitFns = [];
67
+ const emit = (d) => dataFns.forEach((fn) => { try { fn(d); } catch { /* a listener must not break the stream */ } });
68
+ // A pty turns "\n" into "\r\n"; lib/terminal.js reads either, but a bare
69
+ // "\r" is a carriage return to it, so the output is normalised the pty's way.
70
+ const cooked = (buf) => String(buf).replace(/\r?\n/g, "\r\n");
71
+ child.stdout.setEncoding("utf8");
72
+ child.stderr.setEncoding("utf8");
73
+ child.stdout.on("data", (d) => emit(cooked(d)));
74
+ child.stderr.on("data", (d) => emit(cooked(d)));
75
+ child.stdin.on("error", () => { /* the shell has gone; exit says so */ });
76
+ child.on("exit", (code) => exitFns.forEach((fn) => fn({ exitCode: code })));
77
+ child.on("error", (e) => { emit(`[the shell could not start: ${e.message}]\r\n`); exitFns.forEach((fn) => fn({ exitCode: null })); });
78
+ const interruptTool = win ? findInterruptTool({ env: opts.env || process.env }) : "";
79
+
80
+ const api = {
81
+ pid: child.pid,
82
+ pty: false,
83
+ onData: (fn) => { dataFns.push(fn); },
84
+ onExit: (fn) => { exitFns.push(fn); },
85
+ write(text) {
86
+ // Split on the two control keys; everything between them is typed text.
87
+ let rest = String(text);
88
+ for (;;) {
89
+ const at = rest.search(/[\x03\x04]/);
90
+ const plain = at < 0 ? rest : rest.slice(0, at);
91
+ // A pipe has no line discipline: Enter arrives as "\r" from a pane
92
+ // that talks to ptys, and the shell reads lines ending in "\n".
93
+ if (plain) child.stdin.write(plain.replace(/\r\n?/g, "\n"));
94
+ if (at < 0) return;
95
+ if (rest[at] === "\x03") api.interrupt();
96
+ else child.stdin.end();
97
+ rest = rest.slice(at + 1);
98
+ }
99
+ },
100
+ /** ^C: what the shell is running, never the shell. */
101
+ interrupt() {
102
+ if (win) {
103
+ const plan = windowsInterruptPlan(child.pid, interruptTool);
104
+ if (!plan) { emit("[^C needs processx's interrupt helper on Windows; it was not found.]\r\n"); return; }
105
+ try { spawnImpl(plan.bin, plan.args, { stdio: "ignore", windowsHide: true }); } catch { /* gone */ }
106
+ return;
107
+ }
108
+ for (const pid of tree(child.pid)) { try { signal(pid, "SIGINT"); } catch { /* gone */ } }
109
+ },
110
+ resize() { /* a pipe has no size */ },
111
+ kill() {
112
+ if (win) {
113
+ const plan = windowsKillPlan(child.pid);
114
+ try { spawnImpl(plan.bin, plan.args, { stdio: "ignore", windowsHide: true }); } catch { /* gone */ }
115
+ return;
116
+ }
117
+ try { signal(-child.pid, "SIGKILL"); } catch { /* the group is gone */ }
118
+ try { signal(child.pid, "SIGKILL"); } catch { /* gone */ }
119
+ },
120
+ };
121
+ return api;
122
+ }
@@ -0,0 +1,112 @@
1
+ // plugins.mjs — the five plugin ops, and who may send each.
2
+ //
3
+ // The store itself is host/plugin-store.mjs (a port of spike/plugins.R); this
4
+ // file is only the door, which is serve.R's half of the same decision:
5
+ //
6
+ // plugin_list · plugin_read · plugin_policy open to ANY socket. An agent
7
+ // that can see which styles exist writes `csl: apa.csl` instead of
8
+ // guessing, and a page consumer (the editor's themes) reads what it needs.
9
+ // plugin_install · plugin_remove page-only in BOTH senses —
10
+ // PAGE_ONLY_CLASSES here, agentRefused at the seam — and AUDITED. They
11
+ // write the user layer and (install) fetch from the network, and changing
12
+ // what a machine renders with is the person's decision, made visibly.
13
+ //
14
+ // The policy is ENFORCEMENT, unlike the AI policy, because every fetch and
15
+ // every write is the supervisor's. It keeps the three-state rule: a policy that
16
+ // names only junk REFUSES TO START. That refusal lives in `init()`, which
17
+ // host/main.mjs runs before the server listens, so a misconfigured deployment
18
+ // never announces a url — the same shape as serve.R joining `plugin_policy$errors`
19
+ // to its startup gate.
20
+
21
+ import {
22
+ pluginInstall, pluginKindOk, pluginKinds, pluginPolicyDisclosure, pluginPolicyOnce,
23
+ pluginRead, pluginRemove, pluginRows,
24
+ } from "../plugin-store.mjs";
25
+
26
+ const PAGE_ONLY_CLASSES = ["served", "file", "local"];
27
+ const OPS = ["plugin_list", "plugin_read", "plugin_policy", "plugin_install", "plugin_remove"];
28
+ const AGENT_REFUSED = ["plugin_install", "plugin_remove"];
29
+ const AGENT_WHY = {
30
+ plugin_install: "Agents cannot install a plugin; ask the user to, in Settings ▸ Plugins.",
31
+ plugin_remove: "Agents cannot remove a plugin.",
32
+ };
33
+
34
+ export function createPlane({ spike, env, audit }) {
35
+ const here = spike;
36
+ const policy = pluginPolicyOnce(env);
37
+ const kinds = pluginKinds(env);
38
+
39
+ return {
40
+ name: "plugins",
41
+ ops: OPS,
42
+ agentRefused: AGENT_REFUSED,
43
+ agentReason: (op) => AGENT_WHY[op],
44
+ commands: OPS,
45
+ capabilities: ["plugins-v1"],
46
+
47
+ /** A policy that permits nothing does not come up degraded; it does not come up. */
48
+ init() {
49
+ if (policy.errors.length) throw new Error(policy.errors.join(" "));
50
+ },
51
+
52
+ handle(cmd, rec, ctx) {
53
+ const { enc, scalarChr } = ctx;
54
+ if (!scalarChr(cmd.id)) return undefined;
55
+ const reply = (payload) => rec.ws.send(enc({ type: cmd.type, id: cmd.id, ...payload }));
56
+
57
+ if (cmd.type === "plugin_policy") {
58
+ reply(pluginPolicyDisclosure(here, policy, env));
59
+ return undefined;
60
+ }
61
+ if (cmd.type === "plugin_list") {
62
+ const kind = scalarChr(cmd.kind) && cmd.kind ? cmd.kind : null;
63
+ if (kind != null && !pluginKindOk(kinds, kind)) {
64
+ reply({ error: "That is not a plugin kind this kernel has." });
65
+ return undefined;
66
+ }
67
+ reply({ rows: pluginRows(here, policy, env, kind).map((r) => ({
68
+ kind: r.kind, id: r.id, name: r.name, version: r.version,
69
+ layer: r.layer, file: r.file, requires: r.requires.filter(Boolean),
70
+ source: r.source, license: r.license, installed: r.installed,
71
+ bytes: r.bytes, status: r.status, reason: r.reason,
72
+ verified: r.verified, active: r.active,
73
+ })) });
74
+ return undefined;
75
+ }
76
+ if (cmd.type === "plugin_read") {
77
+ const r = pluginRead(here, policy, env, cmd.kind, cmd.plugin,
78
+ scalarChr(cmd.file) && cmd.file ? cmd.file : null);
79
+ if (r.ok !== true) reply({ error: r.error });
80
+ else reply({ ok: true, kind: r.kind, plugin: r.id, file: r.file, name: r.name, text: r.text });
81
+ return undefined;
82
+ }
83
+
84
+ // Writes, from here down. A declared agent was answered by name at the
85
+ // seam; this is the CLASS half, which is what the upgrade gate proved.
86
+ if (rec.role !== "page") return undefined;
87
+ if (!PAGE_ONLY_CLASSES.includes(rec.class)) {
88
+ audit("plugin-refused", { op: cmd.type, reason: "class", class: rec.class || "unknown" });
89
+ reply({ error: "Only the local notebook page may change plugins." });
90
+ return undefined;
91
+ }
92
+
93
+ if (cmd.type === "plugin_install") {
94
+ const from = (cmd.from && typeof cmd.from === "object" && !Array.isArray(cmd.from)) ? cmd.from : {};
95
+ return pluginInstall(here, policy, env, cmd.kind, from).then((r) => {
96
+ audit("plugin-install", {
97
+ kind: cmd.kind ?? "", plugin: r.id ?? "", door: Object.keys(from)[0] ?? "",
98
+ ok: r.ok === true, reason: r.ok === true ? "" : r.error,
99
+ });
100
+ if (r.ok !== true) reply({ error: r.error });
101
+ else reply({ ok: true, kind: r.kind, plugin: r.id, name: r.name, version: r.version,
102
+ file: r.file, requires: [].concat(r.requires ?? []), layer: r.layer, already: r.already === true });
103
+ });
104
+ }
105
+
106
+ const r = pluginRemove(here, policy, env, cmd.kind, cmd.plugin);
107
+ audit("plugin-remove", { kind: cmd.kind ?? "", plugin: cmd.plugin ?? "", ok: r.ok === true });
108
+ if (r.ok !== true) reply({ error: r.error }); else reply({ ok: true, kind: r.kind, plugin: r.id });
109
+ return undefined;
110
+ },
111
+ };
112
+ }
@@ -0,0 +1,76 @@
1
+ // proc-tree.mjs — "kill everything this child started", for the two planes
2
+ // that own children of their own (jobs, terminal).
3
+ //
4
+ // There is no ONE mechanism that does it, which is why this is a file and not
5
+ // a line. Measured on 2026-09-14, both failures in the same afternoon:
6
+ //
7
+ // · signalling the process GROUP alone misses a child in a group of its
8
+ // own — processx puts its children in a new one, and an interactive shell
9
+ // with job control puts every background job in one (`sleep 600 &`
10
+ // survived a group kill of the shell);
11
+ // · walking the DESCENDANTS alone misses a child that double-forked and was
12
+ // reparented to init — `system(..., wait = FALSE)` does exactly that, and
13
+ // the orphan is still in the group.
14
+ //
15
+ // So callers do both: `descendantsOf()` first, then the group, then the
16
+ // leader. spike/kernel.R's `kernel_kill_tree()` does only the first half
17
+ // (processx's `kill_tree()`, which finds children by its own environment
18
+ // marker and therefore misses a grandchild another processx started).
19
+
20
+ import { execFileSync } from "node:child_process";
21
+
22
+ /**
23
+ * Every descendant of `pid`, nearest first — one `ps` and a breadth-first walk.
24
+ *
25
+ * Synchronous on purpose: it runs on a user gesture (Stop, a closed page, the
26
+ * kernel shutting down), it costs about ten milliseconds, and a kill that
27
+ * raced the read would be worse than one turn of the event loop. Bounded, so
28
+ * a pathological table — or a ppid cycle, which cannot happen but is cheap to
29
+ * refuse — cannot spin the host.
30
+ *
31
+ * @param {number} pid
32
+ * @param {number} [max] most descendants to return
33
+ * @returns {number[]}
34
+ */
35
+ export function descendantsOf(pid, max = 2000) {
36
+ if (!pid || pid <= 0 || process.platform === "win32") return [];
37
+ let table = "";
38
+ try { table = execFileSync("/bin/ps", ["-Ao", "pid=,ppid="], { encoding: "utf8", timeout: 5000 }); } catch { return []; }
39
+ const children = new Map();
40
+ for (const line of table.split("\n")) {
41
+ const m = line.trim().match(/^(\d+)\s+(\d+)$/);
42
+ if (!m) continue;
43
+ const kid = Number(m[1]);
44
+ const parent = Number(m[2]);
45
+ if (!children.has(parent)) children.set(parent, []);
46
+ children.get(parent).push(kid);
47
+ }
48
+ const out = [];
49
+ const seen = new Set([pid]);
50
+ const queue = [pid];
51
+ while (queue.length && out.length < max) {
52
+ const next = queue.shift();
53
+ for (const kid of children.get(next) || []) {
54
+ if (seen.has(kid)) continue;
55
+ seen.add(kid);
56
+ out.push(kid);
57
+ queue.push(kid);
58
+ }
59
+ }
60
+ return out;
61
+ }
62
+
63
+ /**
64
+ * SIGKILL a process and everything it started: descendants, then its process
65
+ * group, then the leader itself.
66
+ *
67
+ * @param {number} pid a process this host spawned DETACHED (so it leads its
68
+ * own group) or that owns a pty session.
69
+ */
70
+ export function killProcessTree(pid) {
71
+ if (!pid || pid <= 0) return;
72
+ for (const child of descendantsOf(pid)) { try { process.kill(child, "SIGKILL"); } catch { /* gone */ } }
73
+ if (process.platform === "win32") return; // callers use taskkill /T there
74
+ try { process.kill(-pid, "SIGKILL"); } catch { /* the group is gone */ }
75
+ try { process.kill(pid, "SIGKILL"); } catch { /* the leader is gone */ }
76
+ }