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,467 @@
|
|
|
1
|
+
// planes/ai.mjs — the provider key, the administrator's policy, the audit
|
|
2
|
+
// stream, the console's history and the appearance carry.
|
|
3
|
+
//
|
|
4
|
+
// Five small stores that share one rule: they are the USER'S OWN MACHINE STATE,
|
|
5
|
+
// kept by the supervisor because the browser is the wrong place for them, and
|
|
6
|
+
// answered for the NOTEBOOK PAGE and nobody else.
|
|
7
|
+
//
|
|
8
|
+
// ── who may ask, and why it is TWO questions ────────────────────────────────
|
|
9
|
+
//
|
|
10
|
+
// AGENT_REFUSED asks "did this client DECLARE itself an agent?" — an honour
|
|
11
|
+
// system: a socket's role is "page" until the client volunteers `mcp-hello`, so
|
|
12
|
+
// an agent CLI that simply never declared was handed the user's provider key
|
|
13
|
+
// until 0.60.89. PAGE_ONLY_CLASSES asks what the UPGRADE GATE already proved
|
|
14
|
+
// about the connection, which nothing the client says afterwards can change.
|
|
15
|
+
// Two lists rather than one flagged one, for the reason FORWARDED and
|
|
16
|
+
// ANALYZE_FORWARDED stay two.
|
|
17
|
+
//
|
|
18
|
+
// A native client is refused even though it could read the 0600 file itself as
|
|
19
|
+
// this user: the point is not that the kernel can stop it, but that the kernel
|
|
20
|
+
// does not hand it over — so the promise holds for every agent instead of only
|
|
21
|
+
// the polite ones. A published site is refused because its reader approved it
|
|
22
|
+
// to RUN THE CHUNKS THEY PRESS, not to collect a credential on the way past.
|
|
23
|
+
|
|
24
|
+
import fs from "node:fs";
|
|
25
|
+
import path from "node:path";
|
|
26
|
+
import { PAGE_ONLY_CLASSES } from "../server.mjs";
|
|
27
|
+
import { appId, ensureUserDir, privateModeHeld } from "../user-dirs.mjs";
|
|
28
|
+
import { carmarAiPolicy, aiPolicyReason } from "../ai-policy.mjs";
|
|
29
|
+
|
|
30
|
+
const envOf = (name, unset = "") => (process.env[name] == null ? unset : String(process.env[name]));
|
|
31
|
+
const isStr = (v) => typeof v === "string" && v.length > 0;
|
|
32
|
+
|
|
33
|
+
// ── what the page may write into the audit stream ───────────────────────────
|
|
34
|
+
//
|
|
35
|
+
// The AI conversation happens in the BROWSER — a key-based turn is a fetch from
|
|
36
|
+
// the page — so the kernel cannot observe it and the page has to report. That
|
|
37
|
+
// makes this the one op whose log content comes from OUTSIDE, which is a
|
|
38
|
+
// log-injection surface if it is a free-text channel. It is not one:
|
|
39
|
+
//
|
|
40
|
+
// · `event` must be one of a fixed vocabulary,
|
|
41
|
+
// · every other field is an ALLOW-LIST of scalars, bounded and stripped of
|
|
42
|
+
// control characters (a newline in a JSON-lines log is a forged record),
|
|
43
|
+
// · and the fields that ESTABLISH the record — the timestamp, the pid, which
|
|
44
|
+
// connection sent it and whether that connection is a page or a declared
|
|
45
|
+
// agent — are stamped by the supervisor and cannot be supplied at all.
|
|
46
|
+
//
|
|
47
|
+
// An allow-list rather than a deny-list, for the reason FORWARDED is one: a
|
|
48
|
+
// field added to the page later is absent from the log until someone puts it
|
|
49
|
+
// here on purpose.
|
|
50
|
+
export const AI_AUDIT_EVENTS = Object.freeze(["turn", "tool", "insert", "patch", "accept", "reject", "error"]);
|
|
51
|
+
export const AI_AUDIT_FIELDS = Object.freeze(["provider", "model", "lane", "tool", "chunk", "decision",
|
|
52
|
+
"kind", "by", "reason", "ms", "input_tokens", "output_tokens", "prompt_chars", "response_chars", "error"]);
|
|
53
|
+
// The prompt and the response themselves. OFF unless an administrator asks,
|
|
54
|
+
// because a notebook that silently recorded everything its user typed at an AI
|
|
55
|
+
// would be a worse thing to ship than no audit at all — the same judgement
|
|
56
|
+
// CARMAR_LOG itself is off by default for.
|
|
57
|
+
export const AI_AUDIT_TEXT_FIELDS = Object.freeze(["prompt", "response"]);
|
|
58
|
+
/** How many ai-* records the reviewer export may read back at once. */
|
|
59
|
+
export const AI_AUDIT_READ_MAX = 5000;
|
|
60
|
+
|
|
61
|
+
// ── the console's history, across sessions ───────────────────────────────────
|
|
62
|
+
//
|
|
63
|
+
// RStudio keeps `.Rhistory`; a console whose ↑ forgets everything the moment
|
|
64
|
+
// the tab reloads is a demo, not a tool. It lives beside the key and for the
|
|
65
|
+
// same reason: this is MACHINE state, not notebook content, so it belongs in
|
|
66
|
+
// the R user directory and never in the notebook file, an export, or
|
|
67
|
+
// localStorage — where any local page sharing the file:// origin could read the
|
|
68
|
+
// user's code. Off entirely with CARMAR_NO_HISTORY=1 (and from Settings),
|
|
69
|
+
// because "record everything I type" must be refusable.
|
|
70
|
+
export const HISTORY_MAX = 2000;
|
|
71
|
+
|
|
72
|
+
// ── appearance, across the two origins the notebook is delivered from ────────
|
|
73
|
+
//
|
|
74
|
+
// The same notebook is delivered from a file:// page and from this kernel's own
|
|
75
|
+
// http://127.0.0.1:PORT, and browsers partition storage by origin — so a theme
|
|
76
|
+
// chosen at one door is invisible at the other. This is a small named store so
|
|
77
|
+
// a choice already made at either door is not thrown away when the other opens.
|
|
78
|
+
// It is a CARRY, not a sync: the page adopts only keys its own origin does not
|
|
79
|
+
// already hold (lib/prefs-mirror.js), so the kernel can never overwrite a live
|
|
80
|
+
// choice, and localStorage still decides what gets painted.
|
|
81
|
+
//
|
|
82
|
+
// PREF_KEYS is CLOSED, here as well as in the page, because the two lists
|
|
83
|
+
// answer different questions. The page's asks "what should I carry?"; this one
|
|
84
|
+
// asks "what may anything store in the user's data directory?" — and every
|
|
85
|
+
// local HTML file shares the file:// origin, so without it `prefs` is a
|
|
86
|
+
// free-form key/value bag any of them could fill.
|
|
87
|
+
//
|
|
88
|
+
// The values travel as the STRINGS localStorage holds. carmar-chunk-custom is
|
|
89
|
+
// itself JSON; re-parsing it here would make the kernel a party to a format
|
|
90
|
+
// only the page understands.
|
|
91
|
+
export const PREF_MAX_VALUE = 4096;
|
|
92
|
+
export const PREF_KEYS = Object.freeze([
|
|
93
|
+
"carmar-app-theme", // lib/app-themes.js standard/flat/plain/graphite/onyx
|
|
94
|
+
"carmar-editor-theme", // lib/editor-themes.js one of 37
|
|
95
|
+
"carmar-chunk-style", // lib/chunk-style.js the pre-7.8 key, kept in step
|
|
96
|
+
"carmar-chunk-style-v2", // lib/chunk-style.js the explicit choice
|
|
97
|
+
"carmar-chunk-custom", // lib/chunk-style.js the designer's token overrides
|
|
98
|
+
"carmar-writing-chunk", // lib/writing-chunk.js the Writing surface's own card
|
|
99
|
+
"carmar-run-style", // lib/run-style.js
|
|
100
|
+
"carmar-table-style-v2", // lib/table-render.js
|
|
101
|
+
"carmar-line-numbering", // lib/line-numbering.js
|
|
102
|
+
"carmar-density", // lib/density.js
|
|
103
|
+
"carmar-add-strip-v3", // lib/add-strip.js
|
|
104
|
+
"carmar-soft-wrap", // lib/soft-wrap.js
|
|
105
|
+
"carmar-content-width-v1", // src/notebook-entry.js Standard / Wide / Full
|
|
106
|
+
"carmar-brand-v1", // lib/brand.js the organisation's accent
|
|
107
|
+
"carmar:rich-text", // lib/rtf-editor.js
|
|
108
|
+
"carmar:latex-template", // lib/latex-templates.js
|
|
109
|
+
]);
|
|
110
|
+
|
|
111
|
+
const AGENT_WHY = Object.freeze({
|
|
112
|
+
"ai-key": "Agents cannot read or write the stored key.",
|
|
113
|
+
"ai-audit-read": "Agents cannot read the audit stream.",
|
|
114
|
+
console_history: "Agents cannot read the console history.",
|
|
115
|
+
prefs: "Agents have no appearance; the notebook page keeps that.",
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
/** One field of an audit record, made safe to write on one line. */
|
|
119
|
+
export function aiAuditScalar(value, limit) {
|
|
120
|
+
if (value == null || Array.isArray(value) || (typeof value === "object")) return null;
|
|
121
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : null;
|
|
122
|
+
if (typeof value === "boolean") return value;
|
|
123
|
+
if (typeof value !== "string") return null;
|
|
124
|
+
// R's [[:cntrl:]] — 0x00-0x1f and 0x7f — spelled out. A newline inside a
|
|
125
|
+
// JSON-lines log is a forged record, not a formatting problem.
|
|
126
|
+
// eslint-disable-next-line no-control-regex
|
|
127
|
+
return value.replace(/[\u0000-\u001f\u007f]/g, " ").slice(0, limit).trim();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── the provider key ────────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
export const aiKeyPath = () => path.join(ensureUserDir(appId(), "config"), "ai-key");
|
|
133
|
+
|
|
134
|
+
export function aiKeyRead() {
|
|
135
|
+
const file = aiKeyPath();
|
|
136
|
+
if (!fs.existsSync(file)) return "";
|
|
137
|
+
try { return (fs.readFileSync(file, "utf8").split("\n")[0] || "").trim(); } catch { return ""; }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function aiKeyWrite(value) {
|
|
141
|
+
const file = aiKeyPath();
|
|
142
|
+
if (!value.length) {
|
|
143
|
+
// Removing a file REPORTS failure rather than raising, so an unwritable
|
|
144
|
+
// config directory used to answer {ok:true} while the key sat there.
|
|
145
|
+
// "Forget the key" reporting success over a key still on disk is the one
|
|
146
|
+
// lie this function must not tell.
|
|
147
|
+
if (!fs.existsSync(file)) return true;
|
|
148
|
+
try { fs.unlinkSync(file); } catch { return false; }
|
|
149
|
+
return !fs.existsSync(file);
|
|
150
|
+
}
|
|
151
|
+
// Lock the file down BEFORE the secret goes into it. Creating it with the key
|
|
152
|
+
// already inside leaves a window where it is world-readable, and a key that
|
|
153
|
+
// was briefly readable has already leaked.
|
|
154
|
+
try { if (!fs.existsSync(file)) fs.writeFileSync(file, "", { mode: 0o600 }); } catch { return false; }
|
|
155
|
+
// The chmod runs before the secret exists in the file, and its result is
|
|
156
|
+
// CHECKED: on a filesystem that allows writing but not mode changes the key
|
|
157
|
+
// would otherwise be written world-readable under a promise of 0600.
|
|
158
|
+
try {
|
|
159
|
+
fs.chmodSync(file, 0o600);
|
|
160
|
+
if (!privateModeHeld(file)) throw new Error("mode");
|
|
161
|
+
} catch { try { fs.unlinkSync(file); } catch { /* gone */ } return false; }
|
|
162
|
+
try { fs.writeFileSync(file, `${value}\n`); return true; }
|
|
163
|
+
catch { try { fs.unlinkSync(file); } catch { /* gone */ } return false; }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── console history ─────────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
export const historyPath = () => path.join(ensureUserDir(appId(), "data"), "console-history");
|
|
169
|
+
|
|
170
|
+
const readLines = (file) => {
|
|
171
|
+
if (!fs.existsSync(file)) return [];
|
|
172
|
+
try { return fs.readFileSync(file, "utf8").split("\n").filter((l) => l.length > 0); } catch { return []; }
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
export function historyRead(enabled) {
|
|
176
|
+
if (!enabled) return [];
|
|
177
|
+
// ONE JSON STRING PER LINE, not raw text. A history entry can be a whole
|
|
178
|
+
// multi-line function definition, and the file is line-oriented, so the entry
|
|
179
|
+
// has to survive a newline inside it — hand-rolled backslash escaping got
|
|
180
|
+
// this wrong in both directions on the first attempt. A malformed line is
|
|
181
|
+
// dropped rather than allowed to poison the rest.
|
|
182
|
+
const out = [];
|
|
183
|
+
for (const raw of readLines(historyPath())) {
|
|
184
|
+
let v = null;
|
|
185
|
+
try { v = JSON.parse(raw); } catch { v = null; }
|
|
186
|
+
if (typeof v === "string") out.push(v);
|
|
187
|
+
}
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function historyAdd(enabled, line) {
|
|
192
|
+
if (!enabled || !line.trim().length) return true;
|
|
193
|
+
const file = historyPath();
|
|
194
|
+
const flat = JSON.stringify(line);
|
|
195
|
+
const prior = readLines(file);
|
|
196
|
+
// Consecutive duplicates are noise in a history: pressing ↑ twice should
|
|
197
|
+
// reach the command BEFORE the one just run, not the same one again.
|
|
198
|
+
let kept = prior.length && prior[prior.length - 1] === flat ? prior : [...prior, flat];
|
|
199
|
+
if (kept.length > HISTORY_MAX) kept = kept.slice(kept.length - HISTORY_MAX);
|
|
200
|
+
// Same order as the key: create, lock, then write. History is a record of
|
|
201
|
+
// everything the user has typed and is nobody else's business.
|
|
202
|
+
try { if (!fs.existsSync(file)) fs.writeFileSync(file, "", { mode: 0o600 }); } catch { return false; }
|
|
203
|
+
try {
|
|
204
|
+
fs.chmodSync(file, 0o600);
|
|
205
|
+
if (!privateModeHeld(file)) return false;
|
|
206
|
+
} catch { return false; }
|
|
207
|
+
try { fs.writeFileSync(file, `${kept.join("\n")}\n`); return true; } catch { return false; }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ── appearance ──────────────────────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
export const prefsPath = () => path.join(ensureUserDir(appId(), "data"), "appearance.json");
|
|
213
|
+
|
|
214
|
+
/** Known keys only, one bounded string each, in a stable order. */
|
|
215
|
+
export function prefsClean(values) {
|
|
216
|
+
if (!values || typeof values !== "object" || Array.isArray(values)) return {};
|
|
217
|
+
const out = {};
|
|
218
|
+
for (const k of PREF_KEYS) { // PREF_KEYS first: fixes the order
|
|
219
|
+
if (!Object.prototype.hasOwnProperty.call(values, k)) continue;
|
|
220
|
+
const v = values[k];
|
|
221
|
+
if (typeof v === "string" && Buffer.byteLength(v, "utf8") <= PREF_MAX_VALUE) out[k] = v;
|
|
222
|
+
}
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function prefsRead(enabled) {
|
|
227
|
+
if (!enabled) return {};
|
|
228
|
+
const file = prefsPath();
|
|
229
|
+
if (!fs.existsSync(file)) return {};
|
|
230
|
+
try { return prefsClean(JSON.parse(fs.readFileSync(file, "utf8"))); } catch { return {}; }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function prefsWrite(enabled, values) {
|
|
234
|
+
if (!enabled) return false;
|
|
235
|
+
const clean = prefsClean(values);
|
|
236
|
+
const file = prefsPath();
|
|
237
|
+
// Create, then lock, then write — the order the key and the history beside it
|
|
238
|
+
// use. A theme is not a secret; the point is that everything the kernel puts
|
|
239
|
+
// in the user's data directory is the user's, and judging file by file which
|
|
240
|
+
// ones are harmless is how one of them eventually gets it wrong.
|
|
241
|
+
try { if (!fs.existsSync(file)) fs.writeFileSync(file, "", { mode: 0o600 }); } catch { return false; }
|
|
242
|
+
try {
|
|
243
|
+
fs.chmodSync(file, 0o600);
|
|
244
|
+
if (!privateModeHeld(file)) return false;
|
|
245
|
+
} catch { return false; }
|
|
246
|
+
// An empty store has to be {} and not []: the page asked for an object.
|
|
247
|
+
try { fs.writeFileSync(file, `${JSON.stringify(clean)}\n`); return true; } catch { return false; }
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ── the plane ───────────────────────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
const OPS = Object.freeze(["ai-key", "ai-policy", "ai-audit", "ai-audit-read", "console_history", "prefs"]);
|
|
253
|
+
|
|
254
|
+
export function createPlane({ audit, settings } = {}) {
|
|
255
|
+
const policy = carmarAiPolicy();
|
|
256
|
+
const logPath = envOf("CARMAR_LOG");
|
|
257
|
+
const auditText = envOf("CARMAR_LOG_AI_TEXT") === "1";
|
|
258
|
+
const prefsEnabled = envOf("CARMAR_NO_PREFS") !== "1";
|
|
259
|
+
const historyEnabled = () => (settings ? settings.historyEnabled() : envOf("CARMAR_NO_HISTORY") !== "1");
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
name: "ai",
|
|
263
|
+
ops: OPS,
|
|
264
|
+
commands: [],
|
|
265
|
+
audits: true, // this plane logs what it means to log, itself
|
|
266
|
+
policy,
|
|
267
|
+
init() {
|
|
268
|
+
// A governance control that silently failed to apply is worse than one
|
|
269
|
+
// that refuses to start, because nobody finds out until the data has
|
|
270
|
+
// already gone to the wrong vendor. A policy that permits NOTHING is an
|
|
271
|
+
// error, never an empty allow-list.
|
|
272
|
+
if (policy.errors.length) {
|
|
273
|
+
for (const m of policy.errors) console.error(`Beatrina refuses to start: ${m}`);
|
|
274
|
+
throw new Error(policy.errors.join(" "));
|
|
275
|
+
}
|
|
276
|
+
},
|
|
277
|
+
handle(cmd, rec, ctx) {
|
|
278
|
+
const type = cmd.type;
|
|
279
|
+
const reply = (extra) => rec.ws.send(ctx.enc({ type, id: cmd.id, ...extra }));
|
|
280
|
+
|
|
281
|
+
// ── the AI conversation, into the audit stream ───────────────────────
|
|
282
|
+
// Fire-and-forget: there is no reply, because a turn must never wait on
|
|
283
|
+
// its own logging, and a page that could tell whether the write landed
|
|
284
|
+
// would be a probe for whether auditing is on. A DECLARED AGENT IS NOT
|
|
285
|
+
// REFUSED here — an agent reporting its own work is exactly what should
|
|
286
|
+
// be in the log, labelled as an agent, which is a thing it cannot say
|
|
287
|
+
// otherwise.
|
|
288
|
+
if (type === "ai-audit") {
|
|
289
|
+
const event = aiAuditScalar(cmd.event, 32);
|
|
290
|
+
if (typeof event !== "string" || !AI_AUDIT_EVENTS.includes(event)) return undefined;
|
|
291
|
+
const fields = {};
|
|
292
|
+
for (const f of AI_AUDIT_FIELDS) {
|
|
293
|
+
const v = aiAuditScalar(cmd[f], 200);
|
|
294
|
+
if (v != null && v !== "") fields[f] = v;
|
|
295
|
+
}
|
|
296
|
+
if (auditText) {
|
|
297
|
+
for (const f of AI_AUDIT_TEXT_FIELDS) {
|
|
298
|
+
const v = aiAuditScalar(cmd[f], 4000);
|
|
299
|
+
if (v != null && v !== "") fields[f] = v;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
// Stamped, never accepted: who this is, and what kind of client it is.
|
|
303
|
+
audit?.(`ai-${event}`, { ...fields, class: rec.class || "unknown", role: rec.role || "page", user: rec.user || "" });
|
|
304
|
+
return undefined;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// What the administrator permits. Deliberately readable by ANY socket,
|
|
308
|
+
// including a declared agent: it discloses provider names and an
|
|
309
|
+
// administrator's own sentence, never a credential, and an agent that
|
|
310
|
+
// knows the constraint can respect it instead of failing into it.
|
|
311
|
+
if (type === "ai-policy") {
|
|
312
|
+
if (!isStr(cmd.id)) return undefined;
|
|
313
|
+
reply({ set: policy.set === true,
|
|
314
|
+
providers: policy.providers, models: policy.models, base_urls: policy.base_urls,
|
|
315
|
+
local_only: policy.local_only === true, note: policy.note,
|
|
316
|
+
// Is anything listening? The page asks once, here, rather than
|
|
317
|
+
// sending audit frames into a kernel that discards them.
|
|
318
|
+
audit: logPath.length > 0, audit_text: auditText,
|
|
319
|
+
source: policy.source === "env" ? "environment" : (policy.source.length ? "file" : "") });
|
|
320
|
+
return undefined;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (!isStr(cmd.id)) return undefined;
|
|
324
|
+
if (rec.role === "mcp" && AGENT_WHY[type]) {
|
|
325
|
+
audit?.("mcp-refused", { reason: `agent asked for ${type}` });
|
|
326
|
+
reply({ error: AGENT_WHY[type] });
|
|
327
|
+
return undefined;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ── the key: get / set / clear ───────────────────────────────────────
|
|
331
|
+
if (type === "ai-key") {
|
|
332
|
+
// See PAGE_ONLY_CLASSES: the key is for the notebook page, and "is this
|
|
333
|
+
// the notebook page?" is answered by the connection, not by the sender.
|
|
334
|
+
if (!PAGE_ONLY_CLASSES.includes(rec.class)) {
|
|
335
|
+
audit?.("ai-key-refused", { class: rec.class || "unknown" });
|
|
336
|
+
reply({ error: "Only the notebook page may read or write the stored key." });
|
|
337
|
+
return undefined;
|
|
338
|
+
}
|
|
339
|
+
// ONE OF THE TWO DOORS THE SUPERVISOR ACTUALLY OWNS. A policy that
|
|
340
|
+
// permits no key-based provider means no provider key is kept at all:
|
|
341
|
+
// not handed out, not stored. The user can still paste one into the tab
|
|
342
|
+
// — this is their own browser — but nothing persists it for them and
|
|
343
|
+
// the attempt is in the audit log. The refusal covers `set` as much as
|
|
344
|
+
// `get`, because a key held under a local-only policy is a key that
|
|
345
|
+
// should never have been accepted.
|
|
346
|
+
if (policy.set === true && policy.allows_key !== true) {
|
|
347
|
+
audit?.("ai-key-refused", { reason: "policy", class: rec.class || "unknown" });
|
|
348
|
+
reply({ error: aiPolicyReason(policy), policy: true });
|
|
349
|
+
return undefined;
|
|
350
|
+
}
|
|
351
|
+
const action = isStr(cmd.action) ? cmd.action : "get";
|
|
352
|
+
if (action === "get") {
|
|
353
|
+
audit?.("ai-key", { detail: "get", class: rec.class });
|
|
354
|
+
reply({ key: aiKeyRead() });
|
|
355
|
+
} else if (action === "set" || action === "clear") {
|
|
356
|
+
// A `set` whose key is not a string used to fall through to value =
|
|
357
|
+
// "", DELETE the stored key, and answer ok:true. A malformed request
|
|
358
|
+
// must not be answered by destroying the thing it named. (`set ""` is
|
|
359
|
+
// still a clear — that one is deliberate.)
|
|
360
|
+
if (action === "set" && !isStr(cmd.key)) {
|
|
361
|
+
reply({ error: "ai-key: `set` needs `key` to be a string." });
|
|
362
|
+
return undefined;
|
|
363
|
+
}
|
|
364
|
+
const value = action === "set" ? cmd.key : "";
|
|
365
|
+
let ok = false;
|
|
366
|
+
try { ok = aiKeyWrite(value); } catch { ok = false; }
|
|
367
|
+
// `ok` belongs in the log. Recording only the INTENT left the one
|
|
368
|
+
// question an audit trail exists to answer — did the write land? —
|
|
369
|
+
// unanswerable.
|
|
370
|
+
audit?.("ai-key", { detail: value.length ? "set" : "clear", class: rec.class, ok: ok === true });
|
|
371
|
+
reply({ ok: ok === true });
|
|
372
|
+
} else {
|
|
373
|
+
reply({ error: "ai-key: action must be get, set or clear." });
|
|
374
|
+
}
|
|
375
|
+
return undefined;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ── reading the audit stream BACK, for the reviewer export ───────────
|
|
379
|
+
//
|
|
380
|
+
// Only `ai-*` records are returned. The same file also holds socket
|
|
381
|
+
// opens, refusals and the startup posture — operational security detail
|
|
382
|
+
// that a notebook page has no business reading back, and that the
|
|
383
|
+
// reviewer export would have no use for. The narrower answer is the whole
|
|
384
|
+
// point of having a separate op rather than "read me the log".
|
|
385
|
+
if (type === "ai-audit-read") {
|
|
386
|
+
if (!PAGE_ONLY_CLASSES.includes(rec.class)) {
|
|
387
|
+
audit?.("ai-audit-read-refused", { class: rec.class || "unknown" });
|
|
388
|
+
reply({ error: "Only the notebook page may read the audit stream." });
|
|
389
|
+
return undefined;
|
|
390
|
+
}
|
|
391
|
+
if (!logPath.length || !fs.existsSync(logPath)) {
|
|
392
|
+
// Not an error: no log configured is the ordinary single-user state,
|
|
393
|
+
// and the export says so in words rather than showing an empty table
|
|
394
|
+
// that would read as "the AI did nothing".
|
|
395
|
+
reply({ records: [], configured: logPath.length > 0 });
|
|
396
|
+
return undefined;
|
|
397
|
+
}
|
|
398
|
+
let lines = [];
|
|
399
|
+
try { lines = fs.readFileSync(logPath, "utf8").split("\n").filter((l) => l.length > 0); } catch { lines = []; }
|
|
400
|
+
lines = lines.filter((l) => l.includes('"event":"ai-'));
|
|
401
|
+
// Bounded: a long-lived kernel accumulates, and one frame must not
|
|
402
|
+
// become megabytes. The TAIL, because a reviewer wants this session.
|
|
403
|
+
if (lines.length > AI_AUDIT_READ_MAX) lines = lines.slice(lines.length - AI_AUDIT_READ_MAX);
|
|
404
|
+
// Forwarded as the BYTES they were written as, parsed once by the
|
|
405
|
+
// browser: re-encoding here would round numbers and collapse fields.
|
|
406
|
+
reply({ records: lines, configured: true, truncated: lines.length >= AI_AUDIT_READ_MAX });
|
|
407
|
+
return undefined;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ── the console's cross-session history ──────────────────────────────
|
|
411
|
+
// Page-only on the same grounds as the key: it is a record of everything
|
|
412
|
+
// this user has typed at their own machine.
|
|
413
|
+
if (type === "console_history") {
|
|
414
|
+
if (!PAGE_ONLY_CLASSES.includes(rec.class)) {
|
|
415
|
+
audit?.("console-history-refused", { class: rec.class || "unknown" });
|
|
416
|
+
reply({ error: "Only the notebook page may read or write the console history." });
|
|
417
|
+
return undefined;
|
|
418
|
+
}
|
|
419
|
+
const enabled = historyEnabled();
|
|
420
|
+
const action = isStr(cmd.action) ? cmd.action : "get";
|
|
421
|
+
if (action === "get") {
|
|
422
|
+
reply({ lines: historyRead(enabled), enabled });
|
|
423
|
+
} else if (action === "add") {
|
|
424
|
+
if (!isStr(cmd.line)) { reply({ error: "console_history: `line` must be a string." }); return undefined; }
|
|
425
|
+
let ok = false;
|
|
426
|
+
try { ok = historyAdd(enabled, cmd.line); } catch { ok = false; }
|
|
427
|
+
reply({ ok: ok === true });
|
|
428
|
+
} else if (action === "clear") {
|
|
429
|
+
const file = historyPath();
|
|
430
|
+
try { fs.unlinkSync(file); } catch { /* already gone */ }
|
|
431
|
+
reply({ ok: !fs.existsSync(file) });
|
|
432
|
+
} else {
|
|
433
|
+
reply({ error: "console_history: action must be get, add or clear." });
|
|
434
|
+
}
|
|
435
|
+
return undefined;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ── appearance carried between the two origins ───────────────────────
|
|
439
|
+
if (type === "prefs") {
|
|
440
|
+
if (!PAGE_ONLY_CLASSES.includes(rec.class)) {
|
|
441
|
+
audit?.("prefs-refused", { class: rec.class || "unknown" });
|
|
442
|
+
reply({ error: "Only the notebook page may read or write appearance preferences." });
|
|
443
|
+
return undefined;
|
|
444
|
+
}
|
|
445
|
+
const action = isStr(cmd.action) ? cmd.action : "get";
|
|
446
|
+
if (action === "get") {
|
|
447
|
+
reply({ values: prefsRead(prefsEnabled), keys: PREF_KEYS, enabled: prefsEnabled });
|
|
448
|
+
} else if (action === "put") {
|
|
449
|
+
// A put MERGES. The page sends what ITS origin holds, and a page that
|
|
450
|
+
// has never heard of a key must not be able to delete it for the
|
|
451
|
+
// other door — which is what a replacing write would do the first
|
|
452
|
+
// time an older build shares this store with a newer one.
|
|
453
|
+
const merged = { ...prefsRead(prefsEnabled), ...prefsClean(cmd.values) };
|
|
454
|
+
reply({ ok: prefsWrite(prefsEnabled, merged) === true });
|
|
455
|
+
} else if (action === "clear") {
|
|
456
|
+
const file = prefsPath();
|
|
457
|
+
try { fs.unlinkSync(file); } catch { /* already gone */ }
|
|
458
|
+
reply({ ok: !fs.existsSync(file) });
|
|
459
|
+
} else {
|
|
460
|
+
reply({ error: "prefs: action must be get, put or clear." });
|
|
461
|
+
}
|
|
462
|
+
return undefined;
|
|
463
|
+
}
|
|
464
|
+
return undefined;
|
|
465
|
+
},
|
|
466
|
+
};
|
|
467
|
+
}
|