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
package/host/server.mjs
ADDED
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
// server.mjs — Beatrix's supervisor: HTTP + WebSocket on one loopback port,
|
|
2
|
+
// the gates, and the frame dispatcher. NEVER evaluates user code.
|
|
3
|
+
//
|
|
4
|
+
// This is spike/serve.R's shape with the language-neutral half kept and the
|
|
5
|
+
// evaluating half behind an engine adapter (host/worker-plane.mjs). Serving the
|
|
6
|
+
// page from the same origin as the socket is deliberate: no CORS, no Private
|
|
7
|
+
// Network Access preflight, a secure context.
|
|
8
|
+
//
|
|
9
|
+
// Who may open a socket is decided by `Origin`, and there are exactly three
|
|
10
|
+
// answers: the origin this host served (allowed), an ABSENT Origin (allowed on
|
|
11
|
+
// a private loopback — a native same-user process could run R itself), and
|
|
12
|
+
// anything else only if the user approved that exact string this session.
|
|
13
|
+
// A file page (`Origin: null`) may also present the launch capability from the
|
|
14
|
+
// URL this host printed, which answers one question only: "is this the file I
|
|
15
|
+
// told you to open, or a file that went looking?"
|
|
16
|
+
//
|
|
17
|
+
// The allow-lists are security decisions (CLAUDE.md, serve.R): an op absent
|
|
18
|
+
// from every list is unreachable from the browser. This milestone routes the
|
|
19
|
+
// evaluating vocabulary and the session controls; every supervisor-answered
|
|
20
|
+
// family serve.R has (files, journal, AI store, analyzer, settings, jobs,
|
|
21
|
+
// terminal, citations, plugins) is REFUSED WITH A REASON rather than dropped,
|
|
22
|
+
// because a silent drop reads as a hang on the other end. Those families are
|
|
23
|
+
// the later work packages and are not advertised in the ready frame's
|
|
24
|
+
// `commands`, so a page decides "this kernel predates X" by name (the rule
|
|
25
|
+
// lib/kernel-capability.js relies on) instead of waiting on nothing.
|
|
26
|
+
|
|
27
|
+
import crypto from "node:crypto";
|
|
28
|
+
import fs from "node:fs";
|
|
29
|
+
import http from "node:http";
|
|
30
|
+
import path from "node:path";
|
|
31
|
+
import { attachWebSocket } from "./ws.mjs";
|
|
32
|
+
import { rookOf, proxyUser } from "./deployment.mjs";
|
|
33
|
+
|
|
34
|
+
export const FORWARDED = Object.freeze(["env", "obj", "struct", "view", "colstats", "rm", "packages", "doctor",
|
|
35
|
+
"package_action", "package_help", "project_status", "project_action", "help", "wd",
|
|
36
|
+
"parse", "complete", "files", "import", "readfile", "writefile", "writefiles_atomic",
|
|
37
|
+
"hover", "format", "sniff", "mkdir", "renamepath", "deletepath", "copypath", "revealpath", "debug_breaks"]);
|
|
38
|
+
export const SESSION_CONTROLS = Object.freeze(["exec", "interrupt", "force_stop", "restart", "runstate", "runs", "adopt",
|
|
39
|
+
"input_reply", "debug_cmd"]);
|
|
40
|
+
/** Answered by serve.R itself, not yet by this host. Refused by NAME so the page never hangs. */
|
|
41
|
+
export const NOT_YET = Object.freeze({
|
|
42
|
+
files: "the file system", journal: "the document journal", ai: "the AI conversation store",
|
|
43
|
+
settings: "settings", job: "development jobs", term: "the terminal",
|
|
44
|
+
cite: "citations", csl: "citation styles", plugin: "plugins", session: "session management",
|
|
45
|
+
update: "the update check", mcp: "the agent plane", agent: "the agent plane", latex: "the LaTeX preview",
|
|
46
|
+
console_history: "console history", prefs: "the appearance carry", "ai-key": "the AI key",
|
|
47
|
+
"ai-policy": "the AI policy", "ai-audit": "the AI audit", "ai-audit-read": "the AI audit",
|
|
48
|
+
"open-request": "the open-file door", "page-title": "", "session-new": "session management",
|
|
49
|
+
cite_styles: "citations",
|
|
50
|
+
});
|
|
51
|
+
export const AGENT_REFUSED = Object.freeze(["exec", "interrupt", "force_stop", "restart", "debug_cmd", "input_reply", "project_action"]);
|
|
52
|
+
/** Why, in the sentence serve.R uses. A plane supplies its own via `agentReason`. */
|
|
53
|
+
export const AGENT_WHY_DEFAULT = "Agents run code through notebook chunks (chunk_run), not raw exec.";
|
|
54
|
+
export const AGENT_WHY = Object.freeze({
|
|
55
|
+
input_reply: "Agents cannot answer a prompt on the user's behalf.",
|
|
56
|
+
project_action: "Agents cannot install or restore project packages.",
|
|
57
|
+
});
|
|
58
|
+
export const PAGE_ONLY_CLASSES = Object.freeze(["served", "file", "local"]);
|
|
59
|
+
export const FILE_ORIGIN = "null";
|
|
60
|
+
export const LOCAL_ORIGIN_RE = /^http:\/\/(127\.0\.0\.1|localhost|\[::1\]):[0-9]{1,5}$/;
|
|
61
|
+
export const MAX_FRAME_BYTES = 8e6;
|
|
62
|
+
export const MAX_SOCKETS = 32;
|
|
63
|
+
export const SAFE_HEADERS = Object.freeze({
|
|
64
|
+
"Cache-Control": "no-store",
|
|
65
|
+
"X-Content-Type-Options": "nosniff",
|
|
66
|
+
"X-Frame-Options": "DENY",
|
|
67
|
+
"Referrer-Policy": "no-referrer",
|
|
68
|
+
"Content-Security-Policy": "frame-ancestors 'none'",
|
|
69
|
+
});
|
|
70
|
+
const scalarChr = (x) => typeof x === "string" && x.length > 0;
|
|
71
|
+
const enc = (obj) => JSON.stringify(obj);
|
|
72
|
+
|
|
73
|
+
/** Bytes no caller can predict — the OS CSPRNG, never Math.random. */
|
|
74
|
+
export function secureToken(n = 32) {
|
|
75
|
+
const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
|
76
|
+
const bytes = crypto.randomBytes(n);
|
|
77
|
+
let out = "";
|
|
78
|
+
for (const b of bytes) out += alphabet[b % alphabet.length];
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Stamp the served HTML with this session's identity, once per load. */
|
|
83
|
+
export function stampSession(html, stamp) {
|
|
84
|
+
const meta = `<meta name="carmar-session" content="${stamp}">`;
|
|
85
|
+
let m = html.match(/<head\b[^>]*>/);
|
|
86
|
+
if (!m) m = html.match(/<html\b[^>]*>/);
|
|
87
|
+
if (!m) return meta + html;
|
|
88
|
+
const cut = m.index + m[0].length;
|
|
89
|
+
return html.slice(0, cut) + meta + html.slice(cut);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @param {Object} opts
|
|
94
|
+
* @param {ReturnType<import("./deployment.mjs").carmarDeployment>} opts.deployment
|
|
95
|
+
* @param {import("./engine-pool.mjs").EnginePool} opts.plane the engine
|
|
96
|
+
* router; it presents one WorkerPlane's surface, so everything here reads as
|
|
97
|
+
* it did when there was one engine (WP4, docs/wp/wp4-python.md)
|
|
98
|
+
* @param {() => string} opts.pagePath the built notebook for this build ("" = none)
|
|
99
|
+
* @param {string} opts.kernelBuild
|
|
100
|
+
* @param {() => string} opts.installedBuild
|
|
101
|
+
* @param {number} opts.protocol
|
|
102
|
+
* @param {number} opts.bootAt ms since epoch
|
|
103
|
+
* @param {string} opts.fileLaunchCap
|
|
104
|
+
* @param {(event: string, fields?: object) => void} opts.audit
|
|
105
|
+
* @param {() => void} opts.onShutdown
|
|
106
|
+
* @param {string} opts.here the directory holding the fallback spike page
|
|
107
|
+
*/
|
|
108
|
+
export function createHostServer(opts) {
|
|
109
|
+
const { deployment, plane, audit, onShutdown, planes = [] } = opts;
|
|
110
|
+
const originsOk = deployment.origins;
|
|
111
|
+
const hostsOk = deployment.hosts;
|
|
112
|
+
const sockets = []; // records: {ws, role, name, class, user, lastSeen, beats}
|
|
113
|
+
const approvals = new Set(); // exact origins the user approved this session
|
|
114
|
+
const state = { everConnected: false, heldAt: null, pendingOpen: opts.pendingOpen || null, quit: false };
|
|
115
|
+
plane.sockets = () => sockets;
|
|
116
|
+
plane.on("broadcast", (payload) => sockets.forEach((r) => r.ws.send(payload)));
|
|
117
|
+
|
|
118
|
+
// The session stamp: ONE string, rendered once, that the page's meta and
|
|
119
|
+
// /health's `started` share so they can never differ in their digits.
|
|
120
|
+
const startedSeconds = Number((opts.bootAt / 1000).toFixed(4));
|
|
121
|
+
const sessionStamp = enc(startedSeconds);
|
|
122
|
+
|
|
123
|
+
const hostOk = (req) => {
|
|
124
|
+
const h = String(req.headers.host || "").toLowerCase();
|
|
125
|
+
return Boolean(h) && hostsOk.includes(h);
|
|
126
|
+
};
|
|
127
|
+
const reject = (res, reason, detail = "") => {
|
|
128
|
+
audit("rejected", { reason, detail });
|
|
129
|
+
res.writeHead(403, { "Content-Type": "text/plain", "Cache-Control": "no-store" });
|
|
130
|
+
res.end(`forbidden: ${reason}`);
|
|
131
|
+
};
|
|
132
|
+
const respond = (res, status, type, body, extra = {}) => {
|
|
133
|
+
res.writeHead(status, { "Content-Type": type, ...SAFE_HEADERS, ...extra });
|
|
134
|
+
res.end(body);
|
|
135
|
+
};
|
|
136
|
+
const queryParam = (req, name) => {
|
|
137
|
+
const u = new URL(req.url, "http://x");
|
|
138
|
+
return u.searchParams.get(name) || "";
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/** Refuse browser-mediated control requests from any other site. */
|
|
142
|
+
const controlRejection = (req, { allowFile = false, allowApproved = false } = {}) => {
|
|
143
|
+
const origin = req.headers.origin || "";
|
|
144
|
+
const approved = allowApproved && Boolean(origin) && approvals.has(origin);
|
|
145
|
+
if (origin && !originsOk.includes(origin) && !approved && !(allowFile && origin === FILE_ORIGIN)) {
|
|
146
|
+
return { reason: "bad origin", detail: origin };
|
|
147
|
+
}
|
|
148
|
+
if (!origin && !deployment.allow_native) return { reason: "origin required", detail: "(none)" };
|
|
149
|
+
const admitted = (allowFile && origin === FILE_ORIGIN) || approved;
|
|
150
|
+
const site = String(req.headers["sec-fetch-site"] || "").toLowerCase();
|
|
151
|
+
if (!admitted && site === "cross-site") return { reason: "cross-site request", detail: site };
|
|
152
|
+
return null;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/** Which of the accepted classes opened this socket? Fails CLOSED. */
|
|
156
|
+
const socketClass = (req) => {
|
|
157
|
+
if (!req) return "unknown";
|
|
158
|
+
const origin = req.headers.origin;
|
|
159
|
+
if (origin == null) return deployment.allow_native ? "native" : "unknown";
|
|
160
|
+
if (originsOk.includes(origin)) return "served";
|
|
161
|
+
if (origin === FILE_ORIGIN) return "file";
|
|
162
|
+
if (LOCAL_ORIGIN_RE.test(origin)) return "local";
|
|
163
|
+
return "published";
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const pageRecs = () => sockets.filter((r) => r.role === "page");
|
|
167
|
+
// serve.R reports its OWN R.version here, available from the first byte;
|
|
168
|
+
// the host is not R, so before the worker's ready frame it answers with the
|
|
169
|
+
// version the chosen Rscript printed at boot (main.mjs's hint), then the
|
|
170
|
+
// worker's own once it has said it.
|
|
171
|
+
const rVersion = () => {
|
|
172
|
+
const r = plane.readyFrame && plane.readyFrame.r;
|
|
173
|
+
const m = typeof r === "string" ? r.match(/R version (\S+)/) : null;
|
|
174
|
+
if (m) return m[1];
|
|
175
|
+
return typeof opts.rVersionHint === "function" ? (opts.rVersionHint() || "") : "";
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
// ── the plane seam ───────────────────────────────────────────────────────
|
|
179
|
+
// A PLANE is one supervisor-answered family (files, journal, jobs, …) in its
|
|
180
|
+
// own module under host/planes/. It declares the ops it answers (`ops`),
|
|
181
|
+
// the verbs it wants advertised in `ready.commands` (`commands`, only what it
|
|
182
|
+
// serves), optional HTTP routes (`http(req, res, pathname, ctx) → true when
|
|
183
|
+
// handled`), and socket hooks (`onOpen`, `onClose`). Planes are consulted
|
|
184
|
+
// BEFORE the not-yet refusals, so serving a family is registering a plane in
|
|
185
|
+
// host/main.mjs and nothing else; two planes claiming one op is a startup
|
|
186
|
+
// error, not a silent shadow.
|
|
187
|
+
const ctx = {
|
|
188
|
+
deployment, plane, audit, sockets, approvals, state, opts,
|
|
189
|
+
enc, scalarChr, pageRecs, socketClass,
|
|
190
|
+
port: () => (server.address() ? server.address().port : deployment.port),
|
|
191
|
+
respond: (res, status, type, body, extra) => respond(res, status, type, body, extra),
|
|
192
|
+
reject: (res, reason, detail) => reject(res, reason, detail),
|
|
193
|
+
controlRejection: (req, o) => controlRejection(req, o),
|
|
194
|
+
queryParam: (req, name) => queryParam(req, name),
|
|
195
|
+
broadcast: (payload) => sockets.forEach((r) => r.ws.send(payload)),
|
|
196
|
+
installedBuild: opts.installedBuild,
|
|
197
|
+
kernelBuild: opts.kernelBuild,
|
|
198
|
+
notebookPage: (build) => (typeof opts.notebookPage === "function" ? opts.notebookPage(build) : ""),
|
|
199
|
+
notebookFileUrl: (p, port, cap) => (typeof opts.notebookFileUrl === "function" ? opts.notebookFileUrl(p, port, cap) : ""),
|
|
200
|
+
runtimeRecord: () => (typeof opts.runtimeRecord === "function" ? opts.runtimeRecord() : {}),
|
|
201
|
+
shutdown: (reason) => opts.onShutdown?.(reason),
|
|
202
|
+
startedSeconds,
|
|
203
|
+
fileLaunchCap: opts.fileLaunchCap,
|
|
204
|
+
sessionStamp: () => sessionStamp,
|
|
205
|
+
};
|
|
206
|
+
const opOwner = new Map();
|
|
207
|
+
for (const pl of planes) {
|
|
208
|
+
for (const op of pl.ops || []) {
|
|
209
|
+
if (opOwner.has(op)) throw new Error(`Beatrina: op "${op}" is claimed by two planes (${opOwner.get(op).name} and ${pl.name})`);
|
|
210
|
+
opOwner.set(op, pl);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const server = http.createServer((req, res) => {
|
|
215
|
+
if (!hostOk(req)) return reject(res, "bad host", req.headers.host || "(none)");
|
|
216
|
+
const pathname = new URL(req.url, "http://x").pathname;
|
|
217
|
+
if (pathname === "/health") {
|
|
218
|
+
if (queryParam(req, "hold") === "1") state.heldAt = Date.now();
|
|
219
|
+
const newerOnNpm = opts.updateAvailable ? opts.updateAvailable() : null;
|
|
220
|
+
// CORS is granted HERE AND ONLY HERE, so a file page probing for its
|
|
221
|
+
// kernel can read the answer. Booleans and versions only.
|
|
222
|
+
return respond(res, 200, "application/json", enc({
|
|
223
|
+
ok: true, worker: plane.alive, pending_open: state.pendingOpen != null,
|
|
224
|
+
r: rVersion(), started: startedSeconds, protocol: opts.protocol,
|
|
225
|
+
kernel_build: opts.kernelBuild, installed_build: opts.installedBuild(),
|
|
226
|
+
pages: pageRecs().length,
|
|
227
|
+
// A newer Beatrina on npm (host/update-record.mjs), only while one is known.
|
|
228
|
+
...(newerOnNpm ? { update_available: newerOnNpm } : {}),
|
|
229
|
+
// Which build this kernel replaced, for the first 90 s of its life —
|
|
230
|
+
// how a page recognises its successor. Never the page or its capability.
|
|
231
|
+
...(opts.handoffFrom && Date.now() - opts.bootAt <= 90000 ? { handoff_from: opts.handoffFrom } : {}),
|
|
232
|
+
capabilities: ["published-direct-v1", "published-pairing-v3", "session-restart-v1", ...planes.flatMap((pl) => pl.capabilities || [])],
|
|
233
|
+
// `engines` is what a chunk may be RUN on, so a file page probing for
|
|
234
|
+
// a kernel can tell a two-engine host from a one-engine one before it
|
|
235
|
+
// opens a socket. It names every engine a runtime was found for, not
|
|
236
|
+
// only the ones that have started (WP4; docs/wp/wp4-python.md).
|
|
237
|
+
host: "beatrix",
|
|
238
|
+
// Which product this host is (BEATRIX_APP_ID): a report from the page files under it.
|
|
239
|
+
app: opts.app || "carmar",
|
|
240
|
+
engines: typeof plane.engineNames !== "undefined" ? plane.engineNames : (plane.language ? [plane.language] : []),
|
|
241
|
+
engine_status: typeof plane.engineRows === "function" ? plane.engineRows() : undefined,
|
|
242
|
+
}), { "Access-Control-Allow-Origin": "*" });
|
|
243
|
+
}
|
|
244
|
+
if (pathname === "/shutdown") {
|
|
245
|
+
const blocked = controlRejection(req, { allowFile: true });
|
|
246
|
+
if (blocked) return reject(res, blocked.reason, blocked.detail);
|
|
247
|
+
audit("shutdown", {});
|
|
248
|
+
state.quit = true;
|
|
249
|
+
respond(res, 200, "application/json", enc({ ok: true, stopping: true }));
|
|
250
|
+
setTimeout(() => onShutdown(), 50);
|
|
251
|
+
return undefined;
|
|
252
|
+
}
|
|
253
|
+
for (const pl of planes) {
|
|
254
|
+
if (typeof pl.http === "function" && pl.http(req, res, pathname, ctx) === true) return undefined;
|
|
255
|
+
}
|
|
256
|
+
if (pathname === "/" || pathname === "/spike") {
|
|
257
|
+
const page = pathname === "/spike" ? path.join(opts.here, "index.html") : (opts.pagePath() || path.join(opts.here, "index.html"));
|
|
258
|
+
let html;
|
|
259
|
+
try { html = fs.readFileSync(page, "utf8"); } catch { return respond(res, 404, "text/plain", "no notebook page is built for this kernel"); }
|
|
260
|
+
return respond(res, 200, "text/html", stampSession(html, sessionStamp));
|
|
261
|
+
}
|
|
262
|
+
return respond(res, 404, "text/plain", "not found");
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
attachWebSocket(server, {
|
|
266
|
+
gate: (req) => {
|
|
267
|
+
if (!hostOk(req)) { audit("rejected", { reason: "bad host", detail: req.headers.host || "(none)" }); return { status: 403, body: "forbidden: bad host" }; }
|
|
268
|
+
const origin = req.headers.origin;
|
|
269
|
+
const approved = origin != null && approvals.has(origin);
|
|
270
|
+
const launched = origin === FILE_ORIGIN && new URL(req.url, "http://x").searchParams.get("pair") === opts.fileLaunchCap;
|
|
271
|
+
if (origin == null && !deployment.allow_native) { audit("rejected", { reason: "origin required", detail: "(none)" }); return { status: 403, body: "forbidden: origin required" }; }
|
|
272
|
+
if (origin != null && !originsOk.includes(origin) && !approved && !launched) { audit("rejected", { reason: "bad origin", detail: origin }); return { status: 403, body: "forbidden: bad origin" }; }
|
|
273
|
+
if (sockets.length >= MAX_SOCKETS) { audit("rejected", { reason: "too many connections" }); return { status: 403, body: "forbidden: too many connections" }; }
|
|
274
|
+
return null;
|
|
275
|
+
},
|
|
276
|
+
onOpen: (ws) => {
|
|
277
|
+
const rec = { ws, role: "page", name: "", class: socketClass(ws.request),
|
|
278
|
+
user: proxyUser(rookOf(ws.request), deployment), lastSeen: Date.now(), beats: false };
|
|
279
|
+
sockets.push(rec);
|
|
280
|
+
state.everConnected = true;
|
|
281
|
+
audit("socket-open", { sockets: sockets.length, class: rec.class, user: rec.user });
|
|
282
|
+
// The engine announced itself long before this page connected; replay
|
|
283
|
+
// it — and every other engine that has since started, each under its own
|
|
284
|
+
// `engine-ready` (host/engine-pool.mjs replay()).
|
|
285
|
+
if (plane.hello) ws.send(plane.hello);
|
|
286
|
+
if (plane.notice) ws.send(plane.notice);
|
|
287
|
+
if (typeof plane.replay === "function") plane.replay(ws);
|
|
288
|
+
for (const pl of planes) { try { pl.onOpen?.(rec, ctx); } catch (e) { audit("plane-error", { plane: pl.name, detail: e.message }); } }
|
|
289
|
+
ws.onMessage((message) => {
|
|
290
|
+
try { handleFrame(message, rec); } catch (e) { audit("frame-error", { detail: e.message }); }
|
|
291
|
+
});
|
|
292
|
+
ws.onClose(() => {
|
|
293
|
+
const at = sockets.indexOf(rec);
|
|
294
|
+
if (at >= 0) sockets.splice(at, 1);
|
|
295
|
+
plane.dropSocket(rec);
|
|
296
|
+
for (const pl of planes) { try { pl.onClose?.(rec, ctx); } catch (e) { audit("plane-error", { plane: pl.name, detail: e.message }); } }
|
|
297
|
+
audit("socket-close", { sockets: sockets.length });
|
|
298
|
+
});
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
/** Best-effort refusal for a frame that cannot be forwarded. */
|
|
303
|
+
const refuseFrame = (message, rec, reason) => {
|
|
304
|
+
const head = message.slice(0, 2048);
|
|
305
|
+
const type = (head.match(/"type"\s*:\s*"([A-Za-z_-]{1,32})"/) || [])[1];
|
|
306
|
+
const id = (head.match(/"id"\s*:\s*"([^"]{1,128})"/) || [])[1];
|
|
307
|
+
if (!type || !id) return;
|
|
308
|
+
rec.ws.send(enc(type === "exec"
|
|
309
|
+
? { type: "done", id, status: "error", message: reason }
|
|
310
|
+
: { type, id, error: reason }));
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
function handleFrame(message, rec) {
|
|
314
|
+
const bytes = Buffer.byteLength(message);
|
|
315
|
+
if (bytes > MAX_FRAME_BYTES) {
|
|
316
|
+
audit("frame-too-large", { bytes });
|
|
317
|
+
return refuseFrame(message, rec, `This request is larger than the kernel accepts (${(bytes / 1e6).toFixed(1)} MB against an 8 MB limit).`);
|
|
318
|
+
}
|
|
319
|
+
let cmd = null;
|
|
320
|
+
try { cmd = JSON.parse(message); } catch { cmd = null; }
|
|
321
|
+
if (!cmd || typeof cmd !== "object" || Array.isArray(cmd) || !scalarChr(cmd.type)) return undefined;
|
|
322
|
+
rec.lastSeen = Date.now();
|
|
323
|
+
const type = cmd.type;
|
|
324
|
+
|
|
325
|
+
if (type === "hb") {
|
|
326
|
+
rec.beats = true;
|
|
327
|
+
rec.ws.send(enc({ type: "hb", installed_build: opts.installedBuild() }));
|
|
328
|
+
return undefined;
|
|
329
|
+
}
|
|
330
|
+
if (type === "page-title") {
|
|
331
|
+
// serve.R: ANY page-title from a page retires the listener mark, and a
|
|
332
|
+
// single string — the EMPTY one included, which is how a page clears
|
|
333
|
+
// its title — or an absent one sets it. `scalarChr` refuses "", which
|
|
334
|
+
// is why an emptied title never left the runtime record.
|
|
335
|
+
if (rec.role === "page") {
|
|
336
|
+
const settable = typeof cmd.title === "string" || cmd.title == null;
|
|
337
|
+
opts.onPageTitle?.(settable ? (cmd.title ?? "") : null);
|
|
338
|
+
}
|
|
339
|
+
return undefined;
|
|
340
|
+
}
|
|
341
|
+
if (type === "open-request") {
|
|
342
|
+
if (rec.role !== "page") return undefined;
|
|
343
|
+
const p = state.pendingOpen;
|
|
344
|
+
state.pendingOpen = null;
|
|
345
|
+
if (p) audit("open-file-delivered", {});
|
|
346
|
+
rec.ws.send(enc({ type: "open-request", id: scalarChr(cmd.id) ? cmd.id : "open", path: p || "" }));
|
|
347
|
+
return undefined;
|
|
348
|
+
}
|
|
349
|
+
if (AGENT_REFUSED.includes(type) && rec.role === "mcp") {
|
|
350
|
+
audit("mcp-refused", { reason: `agent asked for ${type}` });
|
|
351
|
+
// serve.R answers IN THE ASKED TYPE with an `error`, never a `done`: the
|
|
352
|
+
// refusal is of the request, not the outcome of a run that never started.
|
|
353
|
+
if (scalarChr(cmd.id)) rec.ws.send(enc({ type, id: cmd.id, error: AGENT_WHY[type] || AGENT_WHY_DEFAULT }));
|
|
354
|
+
return undefined;
|
|
355
|
+
}
|
|
356
|
+
// Installing or restoring a project's packages downloads and compiles code
|
|
357
|
+
// in the worker. It is a FORWARDED op, so no plane stands in front of it:
|
|
358
|
+
// this is serve.R's class gate, restated. Without it an origin-less native
|
|
359
|
+
// client started an renv restore in the battery.
|
|
360
|
+
if (type === "project_action" && !PAGE_ONLY_CLASSES.includes(rec.class)) {
|
|
361
|
+
if (scalarChr(cmd.id)) rec.ws.send(enc({ type, id: cmd.id, ok: false, reason: "class",
|
|
362
|
+
error: "Only the local notebook page may change the project environment." }));
|
|
363
|
+
audit("project-action-refused", { reason: "class", class: rec.class || "unknown" });
|
|
364
|
+
return undefined;
|
|
365
|
+
}
|
|
366
|
+
switch (type) {
|
|
367
|
+
case "exec": return plane.exec(cmd, rec);
|
|
368
|
+
case "interrupt": return plane.interrupt(cmd, rec);
|
|
369
|
+
// Both carry an OPTIONAL engine. `restart` with none is the primary
|
|
370
|
+
// engine's — which is what "Restart R" in the page means and what every
|
|
371
|
+
// client built before there was a second engine sends (§6.5).
|
|
372
|
+
case "force_stop": return plane.forceStop(rec, cmd);
|
|
373
|
+
case "restart": Promise.resolve(plane.restart(cmd.engine)).catch((e) => audit("restart-failed", { detail: e.message })); return undefined;
|
|
374
|
+
case "runstate": return plane.runstate(cmd, rec);
|
|
375
|
+
case "runs": return plane.runs(cmd, rec);
|
|
376
|
+
case "adopt": return plane.adopt(cmd, rec);
|
|
377
|
+
case "input_reply": return plane.inputReply(cmd, rec);
|
|
378
|
+
case "debug_cmd": return plane.debugCmd(cmd, rec);
|
|
379
|
+
default: break;
|
|
380
|
+
}
|
|
381
|
+
const owner = opOwner.get(type);
|
|
382
|
+
if (owner) {
|
|
383
|
+
if (rec.role === "mcp" && (owner.agentRefused || []).includes(type)) {
|
|
384
|
+
audit("mcp-refused", { reason: `agent asked for ${type}` });
|
|
385
|
+
// serve.R gives each refused op its OWN sentence ("Agents cannot list
|
|
386
|
+
// the user's sessions."), so a plane may name one — through either
|
|
387
|
+
// hook name the planes use — and the generic line is the fallback.
|
|
388
|
+
const named = (typeof owner.agentRefusalFor === "function" && owner.agentRefusalFor(type))
|
|
389
|
+
|| (typeof owner.agentReason === "function" && owner.agentReason(type));
|
|
390
|
+
const why = named || "Agents may not use this op.";
|
|
391
|
+
if (scalarChr(cmd.id)) rec.ws.send(enc({ type, id: cmd.id, error: why }));
|
|
392
|
+
return undefined;
|
|
393
|
+
}
|
|
394
|
+
// A plane may declare `audits: true` and write its own line instead: the
|
|
395
|
+
// journal wants the document KEY on it, and `ai-audit` must write exactly
|
|
396
|
+
// the record the page reported and nothing else — a generic op line there
|
|
397
|
+
// would put a forged-looking `{"event":"ai-audit"}` into the very stream
|
|
398
|
+
// `ai-audit-read` reads back.
|
|
399
|
+
if (owner.audits !== true) audit(type, { id: scalarChr(cmd.id) ? cmd.id : "" });
|
|
400
|
+
return owner.handle(cmd, rec, ctx, message);
|
|
401
|
+
}
|
|
402
|
+
if (FORWARDED.includes(type)) return plane.request(cmd, rec);
|
|
403
|
+
const family = NOT_YET[type] ?? NOT_YET[type.split(/[_-]/)[0]];
|
|
404
|
+
if (family !== undefined) {
|
|
405
|
+
if (family && scalarChr(cmd.id)) rec.ws.send(enc({ type, id: cmd.id, error: `Beatrina does not serve ${family} yet.`, code: "unsupported" }));
|
|
406
|
+
audit("unsupported-command", { detail: type });
|
|
407
|
+
return undefined;
|
|
408
|
+
}
|
|
409
|
+
audit("unknown-command", { detail: type });
|
|
410
|
+
return undefined;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** Close and forget sockets that have stopped answering (only those that beat). */
|
|
414
|
+
const silenceMs = Math.max(0, Number(process.env.CARMAR_SOCKET_SILENCE ?? 90)) * 1000;
|
|
415
|
+
const reaper = setInterval(() => {
|
|
416
|
+
if (!silenceMs || !sockets.length) return;
|
|
417
|
+
const now = Date.now();
|
|
418
|
+
for (const rec of [...sockets]) {
|
|
419
|
+
if (rec.beats && now - rec.lastSeen >= silenceMs) {
|
|
420
|
+
audit("socket-silent", { role: rec.role, silence: silenceMs / 1000 });
|
|
421
|
+
const at = sockets.indexOf(rec);
|
|
422
|
+
if (at >= 0) sockets.splice(at, 1);
|
|
423
|
+
rec.ws.close(1001, "silent");
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}, 5000);
|
|
427
|
+
reaper.unref();
|
|
428
|
+
|
|
429
|
+
return {
|
|
430
|
+
server,
|
|
431
|
+
sockets,
|
|
432
|
+
approvals,
|
|
433
|
+
state,
|
|
434
|
+
sessionStamp,
|
|
435
|
+
ctx,
|
|
436
|
+
/** Every verb the registered planes serve — merged into ready.commands by main.mjs. */
|
|
437
|
+
commands: planes.flatMap((pl) => pl.commands || []),
|
|
438
|
+
capabilities: planes.flatMap((pl) => pl.capabilities || []),
|
|
439
|
+
close: () => { clearInterval(reaper); sockets.forEach((r) => r.ws.close(1001, "shutdown")); server.close(); },
|
|
440
|
+
};
|
|
441
|
+
}
|