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