beatrina 0.8.6 → 0.9.43
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/NOTICES +1 -1
- package/README.md +6 -0
- package/beatrina_V0.9.43.html +1885 -0
- package/beatrina_V0.9.43.html.inputs.json +1 -0
- package/bin/beatrina.mjs +156 -26
- package/bin/browser.mjs +31 -0
- package/bin/cli.mjs +64 -5
- package/bin/doctor-rows.mjs +31 -0
- package/bin/failsafe.mjs +8 -3
- package/bin/finder.mjs +74 -0
- package/bin/lsquery.swift +55 -0
- package/bin/pages.mjs +81 -0
- package/bin/python-setup.mjs +207 -0
- package/bin/runtime-dirs.mjs +27 -0
- package/bin/sessions.mjs +30 -10
- package/bin/shortcut.mjs +271 -35
- package/bin/update-check.mjs +2 -2
- package/build-info.json +1 -1
- package/engines/js/worker.mjs +6 -3
- package/engines/python/adapter.py +14 -13
- package/engines/python/analyze.py +4 -2
- package/engines/python/bootstrap.py +32 -31
- package/engines/python/debugger.py +10 -6
- package/engines/python/engine.json +1 -1
- package/engines/python/handoff.py +4 -2
- package/engines/python/worker.py +39 -10
- package/engines/r/engine.json +1 -1
- package/engines/r/handoff.R +4 -3
- package/failsafe/ai-policy.R +16 -16
- package/failsafe/ai-store.R +6 -6
- package/failsafe/cite.R +11 -11
- package/failsafe/journal.R +11 -11
- package/failsafe/plugins.R +27 -27
- package/failsafe/serve.R +364 -239
- package/failsafe/session-documents.R +104 -0
- package/host/ai-authority.mjs +55 -0
- package/host/ai-policy.mjs +48 -26
- package/host/bundle.mjs +194 -0
- package/host/deployment.mjs +25 -18
- package/host/engine-js.mjs +16 -17
- package/host/engine-pool.mjs +56 -8
- package/host/engine-python.mjs +80 -31
- package/host/engine-r.mjs +38 -32
- package/host/engine-stdio.mjs +45 -12
- package/host/env-names.mjs +148 -0
- package/host/gateway-token.mjs +51 -0
- package/host/journal-store.mjs +10 -9
- package/host/main.mjs +216 -76
- package/host/payload.mjs +131 -0
- package/host/planes/README.md +1 -1
- package/host/planes/ai-store.mjs +6 -5
- package/host/planes/ai.mjs +49 -29
- package/host/planes/analyze.mjs +6 -5
- package/host/planes/bundle.mjs +344 -0
- package/host/planes/choose.mjs +404 -0
- package/host/planes/cite.mjs +18 -17
- package/host/planes/files.mjs +0 -0
- package/host/planes/jobs.mjs +43 -20
- package/host/planes/latex.mjs +41 -5
- package/host/planes/mcp.mjs +80 -91
- package/host/planes/pair.mjs +24 -24
- package/host/planes/pipe-term.mjs +2 -2
- package/host/planes/plugins.mjs +1 -1
- package/host/planes/recent-documents.mjs +170 -0
- package/host/planes/sessions.mjs +230 -82
- package/host/planes/settings.mjs +4 -4
- package/host/planes/terminal.mjs +13 -11
- package/host/planes/test-file.mjs +2 -2
- package/host/planes/update.mjs +6 -13
- package/host/plugin-store.mjs +35 -52
- package/host/recent-documents.mjs +124 -0
- package/host/runtime-dir.mjs +47 -0
- package/host/server.mjs +123 -21
- package/host/session-keep.mjs +70 -0
- package/host/settings.mjs +85 -37
- package/host/update-record.mjs +3 -2
- package/host/user-dirs.mjs +60 -18
- package/host/which.mjs +39 -0
- package/host/windows-runtime.mjs +6 -3
- package/host/worker-plane.mjs +80 -7
- package/host/ws.mjs +9 -2
- package/host/zip.mjs +237 -0
- package/kernel/analyze.R +1 -1
- package/{check → kernel/check}/acceptance.mjs +1 -1
- package/kernel/check/knit-file.mjs +19639 -0
- package/{check → kernel/check}/session.mjs +49 -8
- package/kernel/deployment.R +20 -20
- package/kernel/examples/NOTICE.md +1 -1
- package/kernel/fileio.R +6 -6
- package/kernel/index.html +2 -2
- package/kernel/job-run.R +83 -22
- package/kernel/jobs.R +28 -12
- package/kernel/kernel-version +1 -1
- package/kernel/kernel.R +24 -21
- package/kernel/knitr-run.R +50 -9
- package/kernel/latex.R +429 -30
- package/kernel/mcp/{carmar-mcp.mjs → beatrina-mcp.mjs} +249 -59
- package/kernel/notebook-page.R +7 -7
- package/kernel/project.R +10 -10
- package/kernel/settings.R +105 -56
- package/kernel/sniff.R +4 -4
- package/kernel/typst.R +121 -0
- package/kernel/worker.R +602 -123
- package/kernel/workspace-keep.R +188 -0
- package/lib/agent-authoring-contract.js +28 -19
- package/lib/cell-kinds.js +3 -3
- package/lib/engine-labels.js +4 -4
- package/menu/Beatrina Menu.app/Contents/Info.plist +14 -0
- package/menu/Beatrina Menu.app/Contents/MacOS/Beatrina Menu +0 -0
- package/menu/Beatrina Menu.app/Contents/_CodeSignature/CodeResources +115 -0
- package/package.json +4 -3
- package/carmar_V0.8.6.html +0 -1310
package/host/user-dirs.mjs
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// data the console history, the appearance carry, the document journal,
|
|
8
8
|
// the AI conversation store
|
|
9
9
|
//
|
|
10
|
-
// serve.R reaches them through `tools::R_user_dir("
|
|
10
|
+
// serve.R reaches them through `tools::R_user_dir("beatrina", which)`, and every
|
|
11
11
|
// suite in the battery relocates them for a test by setting `R_USER_CONFIG_DIR`
|
|
12
12
|
// / `R_USER_DATA_DIR` — the variables R itself honours. So the host cannot
|
|
13
13
|
// invent its own layout: a JavaScript supervisor writing to `~/.beatrix` would
|
|
@@ -20,21 +20,30 @@
|
|
|
20
20
|
// leaving one arm out is how the next store to need it quietly grows a second
|
|
21
21
|
// layout.
|
|
22
22
|
|
|
23
|
+
import crypto from "node:crypto";
|
|
23
24
|
import fs from "node:fs";
|
|
24
25
|
import os from "node:os";
|
|
25
26
|
import path from "node:path";
|
|
27
|
+
import { envReader } from "./env-names.mjs";
|
|
26
28
|
|
|
27
|
-
const envOf = (
|
|
29
|
+
const envOf = envReader(process.env); // dual-read: BEATRINA_STATE or its older spelling (host/env-names.mjs)
|
|
28
30
|
|
|
29
31
|
/**
|
|
30
32
|
* Which R package's folders this host keeps its data, settings and caches in.
|
|
31
33
|
*
|
|
32
|
-
* "
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
34
|
+
* "beatrina" unless BEATRIX_APP_ID names another (the suites do). Every door
|
|
35
|
+
* of the product — the `beatrina` command, the pip package, the R package, a
|
|
36
|
+
* bare `node host/main.mjs` — therefore lands in the same folders.
|
|
37
|
+
* beatrina-names: keep-start — the OTHER product is named here on purpose. It is
|
|
38
|
+
* the whole subject of the sentence, and renaming it left this comment saying
|
|
39
|
+
* that Beatrina does not read Beatrina's own folders.
|
|
40
|
+
* The other product's folders (`R/carmar`, CarmaR's own, frozen at 0.8.6) are
|
|
41
|
+
* never read, moved or written: a Beatrina installed beside CarmaR keeps its OWN
|
|
42
|
+
* settings, AI key, console history, document history, plugins and caches.
|
|
43
|
+
* beatrina-names: keep-end
|
|
44
|
+
* The kernel runtime directory (~/.beatrina/run, host/runtime-dir.mjs) is
|
|
45
|
+
* deliberately NOT keyed on it: it is how the Sessions pane and the MCP bridge
|
|
46
|
+
* find every running kernel.
|
|
38
47
|
* A value that is not an R package name is refused, loudly — a typo must not
|
|
39
48
|
* quietly become a third data folder.
|
|
40
49
|
*
|
|
@@ -43,7 +52,7 @@ const envOf = (name) => (process.env[name] == null ? "" : String(process.env[nam
|
|
|
43
52
|
*/
|
|
44
53
|
export function appId(env = envOf) {
|
|
45
54
|
const id = String(env("BEATRIX_APP_ID") || "").trim();
|
|
46
|
-
if (!id) return "
|
|
55
|
+
if (!id) return "beatrina";
|
|
47
56
|
if (!/^[A-Za-z][A-Za-z0-9.]*[A-Za-z0-9]$/.test(id)) {
|
|
48
57
|
throw new Error(`BEATRIX_APP_ID must be an R package name (letters, digits and dots); it is ${JSON.stringify(id)}.`);
|
|
49
58
|
}
|
|
@@ -53,28 +62,31 @@ export function appId(env = envOf) {
|
|
|
53
62
|
/**
|
|
54
63
|
* `tools::R_user_dir(package, which)`.
|
|
55
64
|
*
|
|
56
|
-
* @param {string} pkg the package name ("
|
|
65
|
+
* @param {string} pkg the package name ("beatrina")
|
|
57
66
|
* @param {"data"|"config"|"cache"} which
|
|
67
|
+
* @param {(name: string) => string} [env] an env reader, for callers that
|
|
68
|
+
* inject one (the plugin store's layer paths are computed per request, and
|
|
69
|
+
* its suites relocate them with R_USER_DATA_DIR). Defaults to this module's.
|
|
58
70
|
* @returns {string} an absolute directory path (NOT created)
|
|
59
71
|
*/
|
|
60
|
-
export function rUserDir(pkg, which = "data") {
|
|
72
|
+
export function rUserDir(pkg, which = "data", env = envOf) {
|
|
61
73
|
const home = os.homedir();
|
|
62
74
|
const windows = process.platform === "win32";
|
|
63
75
|
const darwin = process.platform === "darwin";
|
|
64
76
|
let base = "";
|
|
65
77
|
if (which === "data") {
|
|
66
|
-
base =
|
|
67
|
-
|| (windows ? path.join(
|
|
78
|
+
base = env("R_USER_DATA_DIR") || env("XDG_DATA_HOME")
|
|
79
|
+
|| (windows ? path.join(env("APPDATA"), "R", "data")
|
|
68
80
|
: darwin ? path.join(home, "Library", "Application Support", "org.R-project.R")
|
|
69
81
|
: path.join(home, ".local", "share"));
|
|
70
82
|
} else if (which === "config") {
|
|
71
|
-
base =
|
|
72
|
-
|| (windows ? path.join(
|
|
83
|
+
base = env("R_USER_CONFIG_DIR") || env("XDG_CONFIG_HOME")
|
|
84
|
+
|| (windows ? path.join(env("APPDATA"), "R", "config")
|
|
73
85
|
: darwin ? path.join(home, "Library", "Preferences", "org.R-project.R")
|
|
74
86
|
: path.join(home, ".config"));
|
|
75
87
|
} else {
|
|
76
|
-
base =
|
|
77
|
-
|| (windows ? path.join(
|
|
88
|
+
base = env("R_USER_CACHE_DIR") || env("XDG_CACHE_HOME")
|
|
89
|
+
|| (windows ? path.join(env("LOCALAPPDATA"), "R", "cache")
|
|
78
90
|
: darwin ? path.join(home, "Library", "Caches", "org.R-project.R")
|
|
79
91
|
: path.join(home, ".cache"));
|
|
80
92
|
}
|
|
@@ -94,7 +106,7 @@ export function ensureUserDir(pkg, which) {
|
|
|
94
106
|
* variable is unset.
|
|
95
107
|
*/
|
|
96
108
|
export function stateDir() {
|
|
97
|
-
return envOf("
|
|
109
|
+
return envOf("BEATRINA_STATE") || rUserDir(appId(), "data");
|
|
98
110
|
}
|
|
99
111
|
|
|
100
112
|
/**
|
|
@@ -115,3 +127,33 @@ export function privateModeHeld(file, platform = process.platform) {
|
|
|
115
127
|
if (platform === "win32") return true;
|
|
116
128
|
return (fs.statSync(file).mode & 0o777) === 0o600;
|
|
117
129
|
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Replace `file` with `text`, privately and atomically.
|
|
133
|
+
*
|
|
134
|
+
* A sibling is written 0600, checked (`privateModeHeld`), and renamed over the
|
|
135
|
+
* file — so an interrupted write leaves the PREVIOUS file intact rather than a
|
|
136
|
+
* half-written one, and no reader ever sees the file mid-write. The AI store's
|
|
137
|
+
* rule (host/planes/ai-store.mjs), lifted here for the small per-user records
|
|
138
|
+
* that are rewritten whole: the recent-documents list first. The directory is
|
|
139
|
+
* created on the way; a failure at any step throws with that step's sentence
|
|
140
|
+
* and takes the sibling with it. No fsync: safe against the process dying,
|
|
141
|
+
* not the machine.
|
|
142
|
+
*
|
|
143
|
+
* @param {string} file absolute path of the record
|
|
144
|
+
* @param {string} text its whole new content
|
|
145
|
+
*/
|
|
146
|
+
export function writePrivateAtomic(file, text) {
|
|
147
|
+
const dir = path.dirname(file);
|
|
148
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
149
|
+
const temp = path.join(dir, `.${path.basename(file)}.${process.pid}-${crypto.randomBytes(6).toString("hex")}`);
|
|
150
|
+
try {
|
|
151
|
+
fs.writeFileSync(temp, Buffer.from(String(text), "utf8"), { mode: 0o600 });
|
|
152
|
+
fs.chmodSync(temp, 0o600);
|
|
153
|
+
if (!privateModeHeld(temp)) throw new Error(`could not make ${path.basename(file)} private (0600)`);
|
|
154
|
+
fs.renameSync(temp, file);
|
|
155
|
+
} catch (e) {
|
|
156
|
+
try { fs.unlinkSync(temp); } catch { /* never written, or already renamed into place */ }
|
|
157
|
+
throw e;
|
|
158
|
+
}
|
|
159
|
+
}
|
package/host/which.mjs
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// which.mjs — find an executable on PATH, synchronously, with no shell.
|
|
2
|
+
//
|
|
3
|
+
// `Sys.which()` for the host. Lifted out of planes/update.mjs the day a second
|
|
4
|
+
// plane (planes/choose.mjs, the OS file panel) needed the same answer, so the
|
|
5
|
+
// two cannot drift on what "on PATH" means. Nothing here spawns anything: a
|
|
6
|
+
// candidate is a file the current user may execute, found by walking PATH in
|
|
7
|
+
// order, exactly as the shell would resolve the bare name.
|
|
8
|
+
//
|
|
9
|
+
// Windows resolves a bare name through PATHEXT (`powershell` → `powershell.exe`),
|
|
10
|
+
// so a name without an extension is tried with each of those too; a name that
|
|
11
|
+
// already carries one is tried as given first.
|
|
12
|
+
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {string} name a bare command name ("osascript", "powershell.exe")
|
|
18
|
+
* @param {Record<string,string|undefined>} [env] the environment whose PATH is walked
|
|
19
|
+
* @returns {string} the first executable found, or "" when there is none
|
|
20
|
+
*/
|
|
21
|
+
export function whichSync(name, env = process.env) {
|
|
22
|
+
if (typeof name !== "string" || !name) return "";
|
|
23
|
+
const dirs = String(env.PATH || env.Path || "").split(path.delimiter).filter(Boolean);
|
|
24
|
+
const exts = process.platform === "win32" && !path.extname(name)
|
|
25
|
+
? ["", ...String(env.PATHEXT || ".EXE;.CMD;.BAT;.COM").split(";").filter(Boolean)]
|
|
26
|
+
: [""];
|
|
27
|
+
for (const dir of dirs) {
|
|
28
|
+
for (const ext of exts) {
|
|
29
|
+
const cand = path.join(dir, name + ext);
|
|
30
|
+
try {
|
|
31
|
+
fs.accessSync(cand, fs.constants.X_OK);
|
|
32
|
+
if (fs.statSync(cand).isFile()) return cand;
|
|
33
|
+
} catch { /* not here; the next candidate */ }
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return "";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export default whichSync;
|
package/host/windows-runtime.mjs
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
// processx ships exactly that program, `interrupt.exe <pid> c`
|
|
22
22
|
// (processx/src/tools/interrupt.c: FreeConsole → AttachConsole(pid) →
|
|
23
23
|
// ignore Ctrl+C itself → GenerateConsoleCtrlEvent(CTRL_C_EVENT, 0)), and
|
|
24
|
-
// processx is already a dependency of the
|
|
24
|
+
// processx is already a dependency of the beatrina package, so the helper is
|
|
25
25
|
// on every machine that installed it. For the event to reach the worker
|
|
26
26
|
// and not the host, the worker needs a console of its own: it is spawned
|
|
27
27
|
// with `windowsHide` (CREATE_NO_WINDOW), the flag processx uses too.
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
|
|
39
39
|
import fs from "node:fs";
|
|
40
40
|
import path from "node:path";
|
|
41
|
+
import { readEnv } from "./env-names.mjs";
|
|
41
42
|
|
|
42
43
|
const defaultFs = {
|
|
43
44
|
exists: (p) => { try { return fs.existsSync(p); } catch { return false; } },
|
|
@@ -104,7 +105,8 @@ export function detectRterm(rscript, io = defaultFs) {
|
|
|
104
105
|
export const RTERM_INTERACTIVE_ARGS = Object.freeze(["--ess", "--no-echo", "--no-save", "--no-restore", "--no-site-file"]);
|
|
105
106
|
|
|
106
107
|
/**
|
|
107
|
-
* Where processx's `interrupt.exe` is. `
|
|
108
|
+
* Where processx's `interrupt.exe` is. `BEATRINA_WIN_INTERRUPT` (or the old
|
|
109
|
+
* `BEATRINA_WIN_INTERRUPT`) first (the R
|
|
108
110
|
* launcher sets it from `system.file(package = "processx")`, which knows every
|
|
109
111
|
* library path), then a helper shipped beside the host, then the libraries a
|
|
110
112
|
* default install writes to: the R installation's own, R_LIBS_USER, and
|
|
@@ -115,7 +117,8 @@ export function findInterruptTool({ env = process.env, rscript = "", hostRoot =
|
|
|
115
117
|
const w = path.win32;
|
|
116
118
|
const tail = ["processx", "bin", "x64", "interrupt.exe"];
|
|
117
119
|
const cands = [];
|
|
118
|
-
|
|
120
|
+
const told = readEnv(env, "WIN_INTERRUPT");
|
|
121
|
+
if (told) cands.push(told);
|
|
119
122
|
if (hostRoot) cands.push(w.join(hostRoot, "bin", "interrupt.exe"));
|
|
120
123
|
if (rscript) cands.push(w.join(w.dirname(w.dirname(rscript)), "library", ...tail));
|
|
121
124
|
if (env.R_HOME) cands.push(w.join(env.R_HOME, "library", ...tail));
|
package/host/worker-plane.mjs
CHANGED
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
// an explicit `restart` is the one door out of it.
|
|
36
36
|
|
|
37
37
|
import { EventEmitter } from "node:events";
|
|
38
|
+
import { RESUME_REPORT_MS, keepTokenOk } from "./session-keep.mjs";
|
|
38
39
|
|
|
39
40
|
export const WORKER_DEADLINE_FAST = 120;
|
|
40
41
|
export const WORKER_DEADLINE_SLOW = 600;
|
|
@@ -45,7 +46,8 @@ export const WORKER_DEADLINES = Object.freeze({
|
|
|
45
46
|
package_help: WORKER_DEADLINE_FAST, project_status: WORKER_DEADLINE_FAST, rm: WORKER_DEADLINE_FAST,
|
|
46
47
|
debug_breaks: WORKER_DEADLINE_FAST, files: WORKER_DEADLINE_FAST, mkdir: WORKER_DEADLINE_FAST,
|
|
47
48
|
renamepath: WORKER_DEADLINE_FAST, sniff: WORKER_DEADLINE_FAST,
|
|
48
|
-
view: WORKER_DEADLINE_SLOW, colstats: WORKER_DEADLINE_SLOW,
|
|
49
|
+
view: WORKER_DEADLINE_SLOW, colstats: WORKER_DEADLINE_SLOW, column_values: WORKER_DEADLINE_SLOW,
|
|
50
|
+
import: WORKER_DEADLINE_SLOW,
|
|
49
51
|
format: WORKER_DEADLINE_SLOW, doctor: WORKER_DEADLINE_SLOW, readfile: WORKER_DEADLINE_SLOW,
|
|
50
52
|
writefile: WORKER_DEADLINE_SLOW, writefiles_atomic: WORKER_DEADLINE_SLOW,
|
|
51
53
|
copypath: WORKER_DEADLINE_SLOW, deletepath: WORKER_DEADLINE_SLOW, revealpath: WORKER_DEADLINE_SLOW,
|
|
@@ -119,6 +121,10 @@ export class WorkerPlane extends EventEmitter {
|
|
|
119
121
|
this.terminal = null; // {wireId, route, frame, timer}
|
|
120
122
|
this.running = new Set(); // exec wire ids in flight (the busy guard's evidence)
|
|
121
123
|
this.hello = null; // the decorated ready payload, replayed to late pages
|
|
124
|
+
// "Restart, keep variables" (host/session-keep.mjs): a token for the NEXT ready worker, consumed
|
|
125
|
+
// once; and the restore's report, replayed to a page that connects within RESUME_REPORT_MS.
|
|
126
|
+
this.resumePending = null;
|
|
127
|
+
this.resumeReport = null;
|
|
122
128
|
this.notice = null; // a kept give-up notice
|
|
123
129
|
this.readyFrame = null;
|
|
124
130
|
this.workerWd = process.cwd();
|
|
@@ -314,8 +320,8 @@ export class WorkerPlane extends EventEmitter {
|
|
|
314
320
|
if (this.alive) this.engine.kill();
|
|
315
321
|
}
|
|
316
322
|
|
|
317
|
-
async restart() {
|
|
318
|
-
this.audit("restart", {});
|
|
323
|
+
async restart({ resume } = {}) {
|
|
324
|
+
this.audit("restart", { keep: keepTokenOk(resume) });
|
|
319
325
|
this.failRoutes(`${this.label} was restarted — this request was abandoned.`);
|
|
320
326
|
this.expectedExit = true;
|
|
321
327
|
if (this.engine) await this.engine.stop(1000);
|
|
@@ -325,7 +331,65 @@ export class WorkerPlane extends EventEmitter {
|
|
|
325
331
|
this.restartAttempts = 0;
|
|
326
332
|
this.gaveUp = false;
|
|
327
333
|
this.notice = null;
|
|
328
|
-
|
|
334
|
+
this.resumePending = keepTokenOk(resume) ? resume : null;
|
|
335
|
+
try { await this.start(); } catch (e) { this.refuseStart(e); throw e; }
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* A start that could not happen, said to every page instead of left as a spinner. Restart R used
|
|
340
|
+
* to reject into server.mjs's audit line and nothing else, so the page waited for a ready that
|
|
341
|
+
* would never come. A payload that drifted under this session (host/payload.mjs) lands here too,
|
|
342
|
+
* and its sentence already names the door out ("Restart into").
|
|
343
|
+
*/
|
|
344
|
+
refuseStart(e) {
|
|
345
|
+
const drift = e && e.code === "BEATRINA_PAYLOAD_DRIFT";
|
|
346
|
+
this.recovering = false;
|
|
347
|
+
this.gaveUp = true;
|
|
348
|
+
const message = drift ? e.message
|
|
349
|
+
: `${this.label} would not start: ${e && e.message ? e.message : e}. Beatrina is still running — the notebook, `
|
|
350
|
+
+ "your text and Save all work. Restart to try again.";
|
|
351
|
+
this.audit(drift ? "worker-start-refused" : "worker-start-failed", { detail: e && e.message ? e.message : String(e) });
|
|
352
|
+
this.notice = enc({ type: "worker-died", engine: this.name, recovering: false, terminal: true,
|
|
353
|
+
attempts: this.restartAttempts, message, reason: drift ? "payload-drift" : "start-failed" });
|
|
354
|
+
this.emit("broadcast", this.notice);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** A token for the next ready worker (a handoff successor's BEATRINA_RESUME_TOKEN). */
|
|
358
|
+
setResumePending(token) { this.resumePending = keepTokenOk(token) ? token : null; }
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Ask the fresh worker to restore a kept workspace, and tell every page what came back. Queued like
|
|
362
|
+
* any command, so a chunk run pressed meanwhile waits for the restore rather than racing it.
|
|
363
|
+
*/
|
|
364
|
+
startResume() {
|
|
365
|
+
const token = this.resumePending;
|
|
366
|
+
this.resumePending = null;
|
|
367
|
+
if (!keepTokenOk(token)) return;
|
|
368
|
+
this.audit("resume-start", {});
|
|
369
|
+
this.internal({ type: "resume", token }, (frame) => {
|
|
370
|
+
if (scalarChr(frame.cwd)) this.workerWd = frame.cwd;
|
|
371
|
+
const report = { type: "workspace_restored", restored: frame.restored ?? 0, failed: frame.failed ?? [],
|
|
372
|
+
skipped: frame.skipped ?? [], wd_missing: frame.wd_missing ?? null, error: frame.error ?? null };
|
|
373
|
+
const payload = enc(report);
|
|
374
|
+
this.resumeReport = { at: Date.now(), payload };
|
|
375
|
+
this.audit("resume-done", { restored: report.restored, failed: Array.isArray(report.failed) ? report.failed.length : 0, error: report.error != null });
|
|
376
|
+
this.emit("broadcast-pages", payload);
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** The report of a restore that finished in the last RESUME_REPORT_MS, or null. */
|
|
381
|
+
freshResumeReport(now = Date.now()) {
|
|
382
|
+
return this.resumeReport && now - this.resumeReport.at < RESUME_REPORT_MS ? this.resumeReport.payload : null;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Cancel a `suspend` — only one THIS page asked for, never someone else's run. */
|
|
386
|
+
cancelSuspend(rec) {
|
|
387
|
+
const route = this.active != null ? this.routes.get(this.active) : null;
|
|
388
|
+
if (this.activeType !== "suspend" || !route || route.rec !== rec) return false;
|
|
389
|
+
this.audit("suspend-cancel", {});
|
|
390
|
+
const wireId = this.active;
|
|
391
|
+
this.engine.interrupt(() => wireId === this.active);
|
|
392
|
+
return true;
|
|
329
393
|
}
|
|
330
394
|
|
|
331
395
|
resetSession() {
|
|
@@ -456,12 +520,12 @@ export class WorkerPlane extends EventEmitter {
|
|
|
456
520
|
try { this.engine.debugCommand(action, expr); } catch { /* the exit event reports it */ }
|
|
457
521
|
return undefined;
|
|
458
522
|
}
|
|
459
|
-
const line = { continue: "c", over: "n", into: "s", out: "f", where: ".
|
|
460
|
-
abort: 'invokeRestart("
|
|
523
|
+
const line = { continue: "c", over: "n", into: "s", out: "f", where: ".beatrina_debug_where()",
|
|
524
|
+
abort: 'invokeRestart("beatrina_abort_cell")', eval: scalarChr(cmd.expr) ? cmd.expr : null }[action] ?? null;
|
|
461
525
|
if (line == null || /[\n\r]/.test(line) || line.length > 4000) return err("bad debug command");
|
|
462
526
|
this.audit("debug_cmd", { action });
|
|
463
527
|
try { this.engine.console(line); } catch { /* the exit event reports it */ }
|
|
464
|
-
if (["over", "into", "out"].includes(action)) { try { this.engine.console(".
|
|
528
|
+
if (["over", "into", "out"].includes(action)) { try { this.engine.console(".beatrina_debug_where()"); } catch { /* same */ } }
|
|
465
529
|
return undefined;
|
|
466
530
|
}
|
|
467
531
|
|
|
@@ -535,6 +599,7 @@ export class WorkerPlane extends EventEmitter {
|
|
|
535
599
|
this.hello = payload;
|
|
536
600
|
this.emit("broadcast", payload);
|
|
537
601
|
this.emit("ready", decorated);
|
|
602
|
+
if (this.resumePending) this.startResume();
|
|
538
603
|
return;
|
|
539
604
|
}
|
|
540
605
|
this.emit("broadcast", relayFrame(e));
|
|
@@ -606,6 +671,14 @@ export class WorkerPlane extends EventEmitter {
|
|
|
606
671
|
let started = null;
|
|
607
672
|
try { started = this.start(); } catch (e) { this.audit("worker-respawn-failed", { attempt, detail: e.message }); }
|
|
608
673
|
if (!started) return;
|
|
674
|
+
// start() is async: a factory or spawnPlan throwing rejects its promise.
|
|
675
|
+
// Keep recovery's bounded retry loop alive instead of terminating Node
|
|
676
|
+
// with an unhandled rejection when an installation moves mid-session.
|
|
677
|
+
started.catch((e) => {
|
|
678
|
+
this.audit("worker-respawn-failed", { attempt, detail: e.message });
|
|
679
|
+
// Retrying cannot un-replace the files: say so now rather than after six attempts.
|
|
680
|
+
if (e && e.code === "BEATRINA_PAYLOAD_DRIFT") this.refuseStart(e);
|
|
681
|
+
});
|
|
609
682
|
this.audit("worker-respawn", { attempt });
|
|
610
683
|
this.emit("broadcast", enc({ type: "worker-restarting", engine: this.name, attempt }));
|
|
611
684
|
}
|
package/host/ws.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// ws.mjs — a WebSocket SERVER, RFC 6455, over Node's http upgrade event.
|
|
2
2
|
//
|
|
3
3
|
// Node ships a WebSocket client (>= 22) and no server, and the host has no
|
|
4
|
-
// dependencies by decision (tools/mcp/
|
|
4
|
+
// dependencies by decision (tools/mcp/beatrina-mcp.mjs and test/fixtures/
|
|
5
5
|
// ws-origin.mjs set the precedent: a few hundred lines of protocol beat a
|
|
6
6
|
// package the security review must then read). What this implements is the
|
|
7
7
|
// subset a notebook page uses: text frames, masked client → unmasked server,
|
|
@@ -29,6 +29,8 @@ const MAX_MESSAGE_BYTES = 16 * 1024 * 1024;
|
|
|
29
29
|
* @property {(code?: number, reason?: string) => void} close
|
|
30
30
|
* @property {(fn: (text: string) => void) => void} onMessage
|
|
31
31
|
* @property {(fn: () => void) => void} onClose
|
|
32
|
+
* @property {() => boolean} ping a protocol ping (opcode 9); the peer's network stack answers it
|
|
33
|
+
* @property {(fn: () => void) => void} onPong called for each pong the peer sends back
|
|
32
34
|
* @property {import("node:http").IncomingMessage} request the upgrade request, kept —
|
|
33
35
|
* the gate's reasoning is recomputed from it (socket_class in serve.R)
|
|
34
36
|
* @property {boolean} open
|
|
@@ -74,6 +76,7 @@ function wrap(raw, request, head) {
|
|
|
74
76
|
let open = true;
|
|
75
77
|
let closeSent = false;
|
|
76
78
|
const messageHandlers = [];
|
|
79
|
+
const pongHandlers = [];
|
|
77
80
|
const closeHandlers = [];
|
|
78
81
|
|
|
79
82
|
const finish = () => {
|
|
@@ -138,7 +141,9 @@ function wrap(raw, request, head) {
|
|
|
138
141
|
return;
|
|
139
142
|
}
|
|
140
143
|
if (opcode === 0x9) { sendFrame(0xa, payload); continue; } // ping → pong
|
|
141
|
-
|
|
144
|
+
// A pong answers the reaper's ping (server.mjs): the peer's network stack sent it, so the
|
|
145
|
+
// transport is alive even when the page's own thread is busy or throttled and sends nothing.
|
|
146
|
+
if (opcode === 0xa) { for (const fn of pongHandlers) fn(); continue; }
|
|
142
147
|
if (opcode === 0x1 || opcode === 0x2) {
|
|
143
148
|
if (partial) return closeWith(1002, "interleaved message");
|
|
144
149
|
partial = { opcode, chunks: [payload], bytes: payload.length };
|
|
@@ -169,6 +174,8 @@ function wrap(raw, request, head) {
|
|
|
169
174
|
close: (code = 1000, reason = "") => closeWith(code, reason),
|
|
170
175
|
onMessage: (fn) => { messageHandlers.push(fn); },
|
|
171
176
|
onClose: (fn) => { closeHandlers.push(fn); },
|
|
177
|
+
ping: () => sendFrame(0x9, Buffer.alloc(0)),
|
|
178
|
+
onPong: (fn) => { pongHandlers.push(fn); },
|
|
172
179
|
};
|
|
173
180
|
}
|
|
174
181
|
|
package/host/zip.mjs
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// zip.mjs — write and read standard ZIP archives, with Node's zlib and nothing else.
|
|
2
|
+
//
|
|
3
|
+
// The container under `.btrn` (docs/results-plan.md R2). A .btrn must open in
|
|
4
|
+
// every unzip there is — macOS Archive Utility, `unzip`, R's utils::unzip() —
|
|
5
|
+
// so this writes the plain, universal subset of the format and nothing clever:
|
|
6
|
+
// one local header + data per entry, a central directory, the end record.
|
|
7
|
+
// Method 0 (stored) or 8 (deflated); UTF-8 names (flag bit 11); no ZIP64 (a
|
|
8
|
+
// .btrn holds ≤ 100 MB of data by design — past that the data lives beside
|
|
9
|
+
// it), no encryption, no data descriptors. zlib.crc32 is Node ≥ 22.2.
|
|
10
|
+
//
|
|
11
|
+
// READING IS READING SOMETHING FROM ELSEWHERE, so the reader refuses before it
|
|
12
|
+
// trusts:
|
|
13
|
+
// · a name that is absolute, has a `..` segment, a drive letter, a
|
|
14
|
+
// backslash, a NUL, or is empty — the zip-slip family, refused by name;
|
|
15
|
+
// · the same name twice;
|
|
16
|
+
// · an encrypted entry, or a method other than 0/8;
|
|
17
|
+
// · more than `maxTotal` bytes uncompressed in all — the zip-bomb family,
|
|
18
|
+
// refused with its numbers BEFORE inflating (a per-entry ratio is
|
|
19
|
+
// optional: deflate tops out near 1032×, so it guards nothing by default);
|
|
20
|
+
// · an entry that inflates to more than it declared (zlib's
|
|
21
|
+
// maxOutputLength enforces the declared size), or whose CRC-32 differs.
|
|
22
|
+
// Every refusal is a thrown Error with `code` set, so callers can say which.
|
|
23
|
+
|
|
24
|
+
import zlib from "node:zlib";
|
|
25
|
+
|
|
26
|
+
const SIG_LOCAL = 0x04034b50;
|
|
27
|
+
const SIG_CENTRAL = 0x02014b50;
|
|
28
|
+
const SIG_END = 0x06054b50;
|
|
29
|
+
const FLAG_UTF8 = 0x0800;
|
|
30
|
+
const U32 = 0xffffffff;
|
|
31
|
+
|
|
32
|
+
/** Date → the two 16-bit MS-DOS fields (2-second resolution, 1980..2107). */
|
|
33
|
+
function dosTime(date) {
|
|
34
|
+
const d = date instanceof Date && !Number.isNaN(date.getTime()) ? date : new Date(1980, 0, 1);
|
|
35
|
+
const year = Math.min(2107, Math.max(1980, d.getFullYear()));
|
|
36
|
+
return {
|
|
37
|
+
time: (d.getHours() << 11) | (d.getMinutes() << 5) | Math.floor(d.getSeconds() / 2),
|
|
38
|
+
date: ((year - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate(),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const fail = (code, message) => Object.assign(new Error(message), { code });
|
|
43
|
+
|
|
44
|
+
/** The Info-ZIP Unicode Path extra field: version 1, CRC-32 of the header name, the UTF-8 name. */
|
|
45
|
+
function unicodePathExtra(nameBytes) {
|
|
46
|
+
const field = Buffer.alloc(4 + 5 + nameBytes.length);
|
|
47
|
+
field.writeUInt16LE(0x7075, 0);
|
|
48
|
+
field.writeUInt16LE(5 + nameBytes.length, 2);
|
|
49
|
+
field.writeUInt8(1, 4);
|
|
50
|
+
field.writeUInt32LE(zlib.crc32(nameBytes) >>> 0, 5);
|
|
51
|
+
nameBytes.copy(field, 9);
|
|
52
|
+
return field;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Is this an entry name a reader may use? Relative, forward slashes, no
|
|
57
|
+
* `..`/`.` segment, no drive, no NUL, not empty. A directory entry ends in `/`.
|
|
58
|
+
*/
|
|
59
|
+
export function safeEntryName(name) {
|
|
60
|
+
const n = String(name ?? "");
|
|
61
|
+
if (!n || n.length > 1024 || /[\u0000\\]/.test(n)) return false;
|
|
62
|
+
if (n.startsWith("/") || /^[A-Za-z]:/.test(n)) return false;
|
|
63
|
+
const parts = n.replace(/\/$/, "").split("/");
|
|
64
|
+
return parts.every((p) => p !== "" && p !== "." && p !== "..");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Build a ZIP in memory.
|
|
69
|
+
*
|
|
70
|
+
* @param {Array<{name:string, data:Buffer|Uint8Array|string, compress?:boolean, date?:Date}>} entries
|
|
71
|
+
* `compress` false stores the bytes (right for PNG/JPEG, already compressed);
|
|
72
|
+
* the default deflates, and keeps the stored form when deflate does not help.
|
|
73
|
+
* @returns {Buffer}
|
|
74
|
+
*/
|
|
75
|
+
export function writeZip(entries, { date = new Date() } = {}) {
|
|
76
|
+
if (!Array.isArray(entries)) throw fail("zip_input", "writeZip needs a list of entries");
|
|
77
|
+
if (entries.length > 0xfffe) throw fail("zip_too_many", `${entries.length} entries is more than a plain ZIP holds`);
|
|
78
|
+
const seen = new Set();
|
|
79
|
+
const locals = [];
|
|
80
|
+
const centrals = [];
|
|
81
|
+
let offset = 0;
|
|
82
|
+
for (const entry of entries) {
|
|
83
|
+
const name = String(entry.name ?? "");
|
|
84
|
+
if (!safeEntryName(name)) throw fail("zip_bad_name", `unsafe entry name: ${JSON.stringify(name)}`);
|
|
85
|
+
if (seen.has(name)) throw fail("zip_duplicate", `the entry ${name} appears twice`);
|
|
86
|
+
seen.add(name);
|
|
87
|
+
const raw = Buffer.isBuffer(entry.data) ? entry.data
|
|
88
|
+
: typeof entry.data === "string" ? Buffer.from(entry.data, "utf8") : Buffer.from(entry.data ?? []);
|
|
89
|
+
if (raw.length >= U32) throw fail("zip_too_large", `${name} is over 4 GB; a plain ZIP cannot hold it`);
|
|
90
|
+
const crc = zlib.crc32(raw) >>> 0;
|
|
91
|
+
let method = 0;
|
|
92
|
+
let body = raw;
|
|
93
|
+
if (entry.compress !== false && raw.length) {
|
|
94
|
+
const deflated = zlib.deflateRawSync(raw, { level: 6 });
|
|
95
|
+
if (deflated.length < raw.length) { method = 8; body = deflated; }
|
|
96
|
+
}
|
|
97
|
+
const nameBytes = Buffer.from(name, "utf8");
|
|
98
|
+
// A non-ASCII name also travels in the Info-ZIP Unicode Path field
|
|
99
|
+
// (0x7075): macOS's bundled unzip ignores the UTF-8 flag and mangles
|
|
100
|
+
// "ünïcode.csv" without it; readers that honour the flag ignore this.
|
|
101
|
+
const extra = /[^\x00-\x7f]/.test(name) ? unicodePathExtra(nameBytes) : Buffer.alloc(0);
|
|
102
|
+
const { time, date: dd } = dosTime(entry.date || date);
|
|
103
|
+
const local = Buffer.alloc(30);
|
|
104
|
+
local.writeUInt32LE(SIG_LOCAL, 0);
|
|
105
|
+
local.writeUInt16LE(20, 4);
|
|
106
|
+
local.writeUInt16LE(FLAG_UTF8, 6);
|
|
107
|
+
local.writeUInt16LE(method, 8);
|
|
108
|
+
local.writeUInt16LE(time, 10);
|
|
109
|
+
local.writeUInt16LE(dd, 12);
|
|
110
|
+
local.writeUInt32LE(crc, 14);
|
|
111
|
+
local.writeUInt32LE(body.length, 18);
|
|
112
|
+
local.writeUInt32LE(raw.length, 22);
|
|
113
|
+
local.writeUInt16LE(nameBytes.length, 26);
|
|
114
|
+
local.writeUInt16LE(extra.length, 28);
|
|
115
|
+
const central = Buffer.alloc(46);
|
|
116
|
+
central.writeUInt32LE(SIG_CENTRAL, 0);
|
|
117
|
+
// Made by UNIX (3), spec 2.0: a reader then takes the name's bytes as they
|
|
118
|
+
// are. Marked MS-DOS (0), macOS's bundled unzip (6.00, no Unicode support)
|
|
119
|
+
// translated every non-ASCII name from code page 437 and wrote garbage.
|
|
120
|
+
central.writeUInt16LE((3 << 8) | 20, 4);
|
|
121
|
+
central.writeUInt16LE(20, 6);
|
|
122
|
+
central.writeUInt16LE(FLAG_UTF8, 8);
|
|
123
|
+
central.writeUInt16LE(method, 10);
|
|
124
|
+
central.writeUInt16LE(time, 12);
|
|
125
|
+
central.writeUInt16LE(dd, 14);
|
|
126
|
+
central.writeUInt32LE(crc, 16);
|
|
127
|
+
central.writeUInt32LE(body.length, 20);
|
|
128
|
+
central.writeUInt32LE(raw.length, 24);
|
|
129
|
+
central.writeUInt16LE(nameBytes.length, 28);
|
|
130
|
+
central.writeUInt16LE(extra.length, 30);
|
|
131
|
+
central.writeUInt32LE(((name.endsWith("/") ? 0o40755 : 0o100644) << 16) >>> 0, 38); // rw-r--r-- (a dir rwxr-xr-x)
|
|
132
|
+
central.writeUInt32LE(offset, 42); // comment, disk, internal attrs: 0
|
|
133
|
+
locals.push(local, nameBytes, extra, body);
|
|
134
|
+
centrals.push(central, nameBytes, extra);
|
|
135
|
+
offset += local.length + nameBytes.length + extra.length + body.length;
|
|
136
|
+
if (offset >= U32) throw fail("zip_too_large", "the archive would pass 4 GB; a plain ZIP cannot hold it");
|
|
137
|
+
}
|
|
138
|
+
const cd = Buffer.concat(centrals);
|
|
139
|
+
const end = Buffer.alloc(22);
|
|
140
|
+
end.writeUInt32LE(SIG_END, 0);
|
|
141
|
+
end.writeUInt16LE(entries.length, 8);
|
|
142
|
+
end.writeUInt16LE(entries.length, 10);
|
|
143
|
+
end.writeUInt32LE(cd.length, 12);
|
|
144
|
+
end.writeUInt32LE(offset, 16);
|
|
145
|
+
return Buffer.concat([...locals, cd, end]);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The central directory: every entry's name, sizes, method and where its
|
|
150
|
+
* bytes are — nothing inflated. What `bundle_list` shows, and what readZip
|
|
151
|
+
* checks the caps against before touching any data.
|
|
152
|
+
*
|
|
153
|
+
* @param {Buffer} buf
|
|
154
|
+
* @returns {Array<{name:string, method:number, crc:number, csize:number, usize:number, offset:number, dir:boolean}>}
|
|
155
|
+
*/
|
|
156
|
+
export function listZip(buf) {
|
|
157
|
+
if (!Buffer.isBuffer(buf) || buf.length < 22) throw fail("zip_not_zip", "not a ZIP archive (too short)");
|
|
158
|
+
let end = -1;
|
|
159
|
+
for (let i = buf.length - 22; i >= Math.max(0, buf.length - 22 - 0xffff); i -= 1) {
|
|
160
|
+
if (buf.readUInt32LE(i) === SIG_END) { end = i; break; }
|
|
161
|
+
}
|
|
162
|
+
if (end < 0) throw fail("zip_not_zip", "not a ZIP archive (no end record)");
|
|
163
|
+
const count = buf.readUInt16LE(end + 10);
|
|
164
|
+
const cdSize = buf.readUInt32LE(end + 12);
|
|
165
|
+
const cdOffset = buf.readUInt32LE(end + 16);
|
|
166
|
+
if (count === 0xffff || cdOffset === U32) throw fail("zip_zip64", "a ZIP64 archive — not a .btrn this version wrote");
|
|
167
|
+
if (cdOffset + cdSize > end) throw fail("zip_corrupt", "the central directory runs past the end record");
|
|
168
|
+
const out = [];
|
|
169
|
+
const names = new Set();
|
|
170
|
+
let p = cdOffset;
|
|
171
|
+
for (let k = 0; k < count; k += 1) {
|
|
172
|
+
if (p + 46 > end || buf.readUInt32LE(p) !== SIG_CENTRAL) throw fail("zip_corrupt", `central directory entry ${k + 1} is damaged`);
|
|
173
|
+
const flags = buf.readUInt16LE(p + 8);
|
|
174
|
+
const method = buf.readUInt16LE(p + 10);
|
|
175
|
+
const crc = buf.readUInt32LE(p + 16) >>> 0;
|
|
176
|
+
const csize = buf.readUInt32LE(p + 20);
|
|
177
|
+
const usize = buf.readUInt32LE(p + 24);
|
|
178
|
+
const nlen = buf.readUInt16LE(p + 28);
|
|
179
|
+
const xlen = buf.readUInt16LE(p + 30);
|
|
180
|
+
const clen = buf.readUInt16LE(p + 32);
|
|
181
|
+
const offset = buf.readUInt32LE(p + 42);
|
|
182
|
+
const name = buf.subarray(p + 46, p + 46 + nlen).toString((flags & FLAG_UTF8) ? "utf8" : "latin1");
|
|
183
|
+
if (!safeEntryName(name)) throw fail("zip_bad_name", `unsafe entry name in the archive: ${JSON.stringify(name)}`);
|
|
184
|
+
if (names.has(name)) throw fail("zip_duplicate", `the entry ${name} appears twice`);
|
|
185
|
+
names.add(name);
|
|
186
|
+
if (flags & 0x0001) throw fail("zip_encrypted", `${name} is encrypted`);
|
|
187
|
+
if (method !== 0 && method !== 8) throw fail("zip_method", `${name} uses compression method ${method}; only stored and deflated are read`);
|
|
188
|
+
out.push({ name, method, crc, csize, usize, offset, dir: name.endsWith("/") });
|
|
189
|
+
p += 46 + nlen + xlen + clen;
|
|
190
|
+
}
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Read a ZIP: every file entry's bytes, verified.
|
|
196
|
+
*
|
|
197
|
+
* @param {Buffer} buf
|
|
198
|
+
* @param {{maxTotal?:number, maxRatio?:number, only?:(name:string)=>boolean}} [opts]
|
|
199
|
+
* the cap on the whole archive's uncompressed bytes (default 2 GB); an
|
|
200
|
+
* optional per-entry expansion cap (none by default: one layer of deflate
|
|
201
|
+
* cannot pass ~1032×, and an honest file of zeros reaches 1026×, so a ratio
|
|
202
|
+
* refuses real data while the total cap and the declared-size inflate are
|
|
203
|
+
* what actually stop a bomb); `only` reads a subset
|
|
204
|
+
* @returns {Map<string, Buffer>}
|
|
205
|
+
*/
|
|
206
|
+
export function readZip(buf, { maxTotal = 2 * 1024 ** 3, maxRatio = Infinity, only = null } = {}) {
|
|
207
|
+
const entries = listZip(buf).filter((e) => !e.dir && (!only || only(e.name)));
|
|
208
|
+
const total = entries.reduce((a, e) => a + e.usize, 0);
|
|
209
|
+
if (total > maxTotal) throw fail("zip_bomb", `the archive expands to ${total} bytes, over the ${maxTotal}-byte cap`);
|
|
210
|
+
for (const e of entries) {
|
|
211
|
+
if (e.method === 8 && e.usize > Math.max(1024, e.csize * maxRatio)) {
|
|
212
|
+
throw fail("zip_bomb", `${e.name} claims to expand ${e.csize} → ${e.usize} bytes, over ${maxRatio}×`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const files = new Map();
|
|
216
|
+
for (const e of entries) {
|
|
217
|
+
const p = e.offset;
|
|
218
|
+
if (p + 30 > buf.length || buf.readUInt32LE(p) !== SIG_LOCAL) throw fail("zip_corrupt", `${e.name}: its local header is damaged`);
|
|
219
|
+
const start = p + 30 + buf.readUInt16LE(p + 26) + buf.readUInt16LE(p + 28);
|
|
220
|
+
if (start + e.csize > buf.length) throw fail("zip_corrupt", `${e.name}: its data runs past the end`);
|
|
221
|
+
const body = buf.subarray(start, start + e.csize);
|
|
222
|
+
let data;
|
|
223
|
+
if (e.method === 0) {
|
|
224
|
+
if (e.csize !== e.usize) throw fail("zip_corrupt", `${e.name}: stored entry with two different sizes`);
|
|
225
|
+
data = Buffer.from(body);
|
|
226
|
+
} else {
|
|
227
|
+
try { data = zlib.inflateRawSync(body, { maxOutputLength: Math.max(1, e.usize) }); }
|
|
228
|
+
catch (err) { throw fail("zip_corrupt", `${e.name}: ${err.code === "ERR_BUFFER_TOO_LARGE" ? "inflates past its declared size" : "does not inflate"}`); }
|
|
229
|
+
if (data.length !== e.usize) throw fail("zip_corrupt", `${e.name}: inflated to ${data.length} bytes, declared ${e.usize}`);
|
|
230
|
+
}
|
|
231
|
+
if ((zlib.crc32(data) >>> 0) !== e.crc) throw fail("zip_crc", `${e.name}: CRC-32 does not match — the archive is damaged`);
|
|
232
|
+
files.set(e.name, data);
|
|
233
|
+
}
|
|
234
|
+
return files;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export default { writeZip, readZip, listZip, safeEntryName };
|
package/kernel/analyze.R
CHANGED
|
@@ -44,7 +44,7 @@ suppressPackageStartupMessages(stopifnot(requireNamespace("jsonlite", quietly =
|
|
|
44
44
|
# raise it knowingly. Clamped to [0, 60] so a typo cannot remove the bound —
|
|
45
45
|
# and 0 is a real setting, meaning "scan nothing and report it truncated".
|
|
46
46
|
ANALYZE_BUDGET <- local({
|
|
47
|
-
v <- suppressWarnings(as.numeric(Sys.getenv("
|
|
47
|
+
v <- suppressWarnings(as.numeric(Sys.getenv("BEATRINA_ANALYZE_BUDGET", "3")))
|
|
48
48
|
if (!is.finite(v)) v <- 3
|
|
49
49
|
max(0, min(v, 60))
|
|
50
50
|
})
|