clearotron 0.3.2-beta.11 → 0.3.2-beta.13
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/.env.example +1 -1
- package/CONTRIBUTING.md +6 -5
- package/INSTALL.md +3 -4
- package/README.md +2 -1
- package/bin/clearotron.mjs +14 -0
- package/bin/connect.mjs +68 -3
- package/bin/example.mjs +12 -1
- package/bin/key.mjs +6 -1
- package/bin/onboard.mjs +23 -3
- package/bin/passphrase.mjs +4 -2
- package/bin/start.mjs +18 -6
- package/build-info.json +2 -2
- package/docs/CLIENT-MCP.md +6 -6
- package/docs/DELIVERY.md +3 -3
- package/docs/ONBOARDING.md +1 -1
- package/docs/PORTAL.md +3 -3
- package/docs/RELEASES.md +1 -1
- package/docs/SECURITY.md +1 -1
- package/docs/architecture/04-configuration-reference.md +4 -4
- package/docs/architecture/05-config-governance.md +10 -10
- package/docs/architecture/06-operations-runbook.md +3 -3
- package/docs/architecture/07-quality-and-audit.md +1 -1
- package/docs/architecture/08-development-guide.md +2 -2
- package/docs/architecture/09-security-and-data.md +1 -1
- package/docs/decisions/0002-no-dark-functionality.md +1 -1
- package/docs/decisions/0006-what-the-public-repository-carries.md +3 -3
- package/driver/CHANGELOG.md +29 -0
- package/driver/ask-ledger.mjs +1 -1
- package/driver/band-shape.mjs +1 -1
- package/driver/bundled-demos.mjs +2 -2
- package/driver/card-budget.mjs +2 -2
- package/driver/case-law-ledger.mjs +2 -2
- package/driver/connotation-search.mjs +5 -5
- package/driver/contract-e3-backlog.mjs +13 -13
- package/driver/contract-vocabulary.mjs +5 -5
- package/driver/coverage-form.mjs +2 -2
- package/driver/demo-container.mjs +26 -2
- package/driver/disposition-tool.mjs +2 -2
- package/driver/engine/CONTRACT.md +3 -3
- package/driver/engine/mcp/gather-config.mjs +1 -1
- package/driver/engine/mcp/recording-server.mjs +1 -1
- package/driver/engine/mcp/supplemental.mjs +22 -5
- package/driver/engine/openai-agent.mjs +2 -2
- package/driver/findings-model.mjs +21 -6
- package/driver/gateway.mjs +1 -1
- package/driver/knockout-next-step.mjs +72 -0
- package/driver/named-band.mjs +1 -1
- package/driver/package.json +1 -1
- package/driver/pipeline-knockout.mjs +27 -1
- package/driver/pipeline.mjs +4 -4
- package/driver/placement-union.mjs +1 -1
- package/driver/portal-mcp-client.mjs +1 -1
- package/driver/portal-report.mjs +25 -3
- package/driver/portal-service.mjs +25 -13
- package/driver/predelivery-lint.mjs +9 -4
- package/driver/progress.mjs +37 -1
- package/driver/publish/render-knockout.mjs +14 -9
- package/driver/publish/render.mjs +6 -6
- package/driver/record-discard.mjs +1 -1
- package/driver/register-availability.mjs +1 -1
- package/driver/register-count.mjs +1 -1
- package/driver/register-digest-record.mjs +1 -1
- package/driver/register-plan.mjs +81 -11
- package/driver/report-card-record.mjs +2 -2
- package/driver/result-noun-fields.mjs +3 -1
- package/driver/roster-verdict.mjs +2 -2
- package/driver/search-policy.mjs +1 -1
- package/driver/skeptic-record.mjs +1 -1
- package/driver/stages-knockout.mjs +1 -1
- package/driver/stages.mjs +1 -1
- package/driver/suite-census.json +72 -24
- package/driver/systemd/README.md +1 -1
- package/driver/unit-inventory.mjs +2 -2
- package/driver/verify-knockout.mjs +0 -27
- package/driver/verify.mjs +1 -1
- package/mcp-server/CHANGELOG.md +8 -0
- package/mcp-server/CONNECT.md +8 -8
- package/mcp-server/lib/runs.mjs +1 -1
- package/mcp-server/package.json +1 -1
- package/mcp-server/serve.mjs +27 -0
- package/package.json +1 -1
- package/portal-ui/dist/assets/{index-CtvwLCti.css → index-7Lq-dXDV.css} +12 -9
- package/portal-ui/dist/assets/{index-DXSRxPV_.js → index-w8GFZftk.js} +110 -69
- package/portal-ui/dist/index.html +2 -2
- package/portal-ui/package.json +1 -1
- package/providers/free-tier/src/capabilities.js +2 -2
- package/providers/jx/src/core.js +3 -3
- package/providers/jx/src/turn-envelope.mjs +1 -1
- package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/providers/perplexity/README.md +1 -1
- package/providers/signa/src/capabilities.js +2 -2
- package/providers/signa/src/core.js +2 -2
- package/providers/uspto-local/src/core.js +1 -1
- package/providers/uspto-local/src/sync.js +1 -1
- package/scripts/README.md +2 -6
- package/scripts/ask-ai-render-check.mjs +26 -1
- package/scripts/citation-anchor-report.mjs +1 -1
- package/scripts/citation-line-check.mjs +3 -3
- package/scripts/dead-names.mjs +17 -16
- package/scripts/e2e.mjs +1 -1
- package/scripts/env-audit.mjs +7 -1
- package/scripts/env-classify.mjs +1 -1
- package/scripts/pack-publishable.mjs +1 -1
- package/scripts/release-artifact-seal.mjs +2 -2
- package/scripts/repo-writes.mjs +42 -0
- package/scripts/report-sections-render-check.mjs +245 -0
- package/scripts/report-theme-render-check.mjs +31 -3
- package/scripts/settings-render-check.mjs +15 -8
- package/scripts/strip-tracker-citations.mjs +4 -4
- package/scripts/test-run.mjs +108 -6
- package/shared/browser-temp-root.mjs +10 -3
- package/shared/client-door.mjs +38 -6
- package/shared/connect-clients.mjs +2 -0
- package/shared/identifier-scan.mjs +2 -2
- package/shared/invocation.mjs +1 -1
- package/shared/parent-watch.mjs +33 -0
- package/shared/reference-guard-classes.mjs +5 -3
- package/shared/running-start.mjs +14 -3
- package/shared/scope.mjs +22 -3
- package/shared/stdio-connect.mjs +39 -18
- package/shared/writing-standard-classes.mjs +2 -3
|
@@ -111,13 +111,20 @@ export function browserTempRoot() {
|
|
|
111
111
|
}
|
|
112
112
|
|
|
113
113
|
/**
|
|
114
|
-
* The environment a browser must be spawned with so
|
|
114
|
+
* The environment a browser must be spawned with so everything it writes lands under `root`.
|
|
115
115
|
*
|
|
116
|
-
* `TMPDIR`
|
|
116
|
+
* `TMPDIR` puts its singleton lock there, so this refuses rather than passing a root that cannot work.
|
|
117
|
+
* AND A HOME OF ITS OWN. A profile directory does not hold everything a browser writes: it keeps its
|
|
118
|
+
* font cache, certificate store, desktop settings and crash folder under the user's home, so a suite run
|
|
119
|
+
* left `.cache/fontconfig`, `.local/share/pki`, `.local/share/applications` and
|
|
120
|
+
* `.config/google-chrome/Crash Reports` in the real home of whoever ran it (measured on a fresh home,
|
|
121
|
+
* 2026-09-19). The home and the three XDG folders now sit inside the root, and go with it.
|
|
117
122
|
*/
|
|
118
123
|
export function browserEnv(root, env = process.env) {
|
|
119
124
|
assertRootFits(root);
|
|
120
|
-
|
|
125
|
+
const home = join(root, "home");
|
|
126
|
+
for (const d of [home, join(home, ".config"), join(home, ".cache"), join(home, ".local", "share")]) mkdirSync(d, { recursive: true });
|
|
127
|
+
return { ...env, TMPDIR: root, HOME: home, XDG_CONFIG_HOME: join(home, ".config"), XDG_CACHE_HOME: join(home, ".cache"), XDG_DATA_HOME: join(home, ".local", "share") };
|
|
121
128
|
}
|
|
122
129
|
|
|
123
130
|
/**
|
package/shared/client-door.mjs
CHANGED
|
@@ -134,6 +134,26 @@ export function demoTokenSecret(base, io) {
|
|
|
134
134
|
* cannot type, and it names the demo's base so the verb reads that demo's secret and guest list. An
|
|
135
135
|
* install moved with `--base` is named too, for its guest list; the default install needs neither.
|
|
136
136
|
*/
|
|
137
|
+
/**
|
|
138
|
+
* What an account key is for, in the owner's words (2026-09-19), printed first by every command that
|
|
139
|
+
* hands one over. ONE COPY: `key issue` and `connect --base` both print it, and a second copy of an
|
|
140
|
+
* approved sentence is the one that drifts. `reset` is the passphrase command as the caller composes it.
|
|
141
|
+
*/
|
|
142
|
+
export function keyPurposeLine({ email, brand, reset }) {
|
|
143
|
+
return `This key lets an AI assistant act as ${email} through ${brand}'s client door. `
|
|
144
|
+
+ `It is not a portal sign-in; the portal uses the passphrase (${reset}).`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The command that connects an assistant to a running DEMO: it mints the key and names the client door
|
|
149
|
+
* in one step, so nobody issues a key by hand (owner, 2026-09-19). A demo keeps its own secret and guest
|
|
150
|
+
* list in its folder, which is why the folder is named.
|
|
151
|
+
*/
|
|
152
|
+
export function demoConnectCommand({ prefix = "", base }) {
|
|
153
|
+
const q = (d) => (/\s/.test(d) ? `"${d}"` : d);
|
|
154
|
+
return `${prefix}clearotron connect --base ${q(base)}`;
|
|
155
|
+
}
|
|
156
|
+
|
|
137
157
|
export function keyIssueCommand({ prefix = "", demo = false, user = null, base = null, defaultBase = null } = {}) {
|
|
138
158
|
const q = (d) => (/\s/.test(d) ? `"${d}"` : d);
|
|
139
159
|
const who = demo && user ? user : "<email>";
|
|
@@ -194,7 +214,7 @@ export const clientDoorAddress = (env = {}) => `http://127.0.0.1:${clientDoorPor
|
|
|
194
214
|
* the fence off accepts no account key, and the fence on with nothing listening is a setting with no
|
|
195
215
|
* server. Reporting "standing" on half of it would send a reader to paste an address at nothing.
|
|
196
216
|
*/
|
|
197
|
-
export function clientDoorState({ env = {}, unitDir, exists, active = null, listening = null, activeState = null, subState = null } = {}) {
|
|
217
|
+
export function clientDoorState({ env = {}, unitDir, exists, active = null, listening = null, ownListener = null, activeState = null, subState = null } = {}) {
|
|
198
218
|
const fenceOn = String(env.CLIENT_MCP_ACCOUNT_ACCESS ?? "").trim() === "1";
|
|
199
219
|
const unitInstalled = Boolean(exists(join(unitDir, CLIENT_DOOR_UNIT)));
|
|
200
220
|
// `standing` IS UNCHANGED AND STILL MEANS CONFIGURED — a file on disk and a fence flag. Two callers
|
|
@@ -218,7 +238,7 @@ export function clientDoorState({ env = {}, unitDir, exists, active = null, list
|
|
|
218
238
|
// so a crash loop (`activating/auto-restart`) and a unit that was never started (`inactive/dead`)
|
|
219
239
|
// reduce to the same `false` and printed the same sentence — one is a fault to read the journal for,
|
|
220
240
|
// the other is a connect that stopped half-way.
|
|
221
|
-
listening, activeState, subState, standing, fenceOn, unitInstalled, active, serving: standing && active === true };
|
|
241
|
+
listening, ownListener, activeState, subState, standing, fenceOn, unitInstalled, active, serving: standing && active === true };
|
|
222
242
|
}
|
|
223
243
|
|
|
224
244
|
/**
|
|
@@ -280,10 +300,17 @@ export function describeDoorState(door, {
|
|
|
280
300
|
// reader whose foreground door was up to run the command they had just run — and it is NOT enough
|
|
281
301
|
// to call it theirs: the product's ports are fixed defaults, so on a shared box the answer may be
|
|
282
302
|
// another install's door entirely. Caught by 2145's arm on a machine where exactly that was true.
|
|
303
|
+
//
|
|
304
|
+
// AND WHOSE IT IS IS ASKED, NOT GUESSED. On a shared box the port is often another account's door,
|
|
305
|
+
// and "something is listening on the client door's address for this environment" read as this
|
|
306
|
+
// install's (measured 2026-09-19). `ownListener` is true only when this install's own record says
|
|
307
|
+
// it holds the port; anything else is said as exactly what was measured, a process on the port.
|
|
308
|
+
if (door.ownListener === true)
|
|
309
|
+
return { level: "info",
|
|
310
|
+
text: `this install's client door is running in the foreground — it stops when that terminal does; `
|
|
311
|
+
+ `\`${startCmd} --background\` installs the unit.` };
|
|
283
312
|
return { level: "info",
|
|
284
|
-
text:
|
|
285
|
-
+ "installed, so either this install is running in the foreground (it stops when that terminal "
|
|
286
|
-
+ `does; \`${startCmd} --background\` installs the unit) or another install holds the port.` };
|
|
313
|
+
text: "a process holds the client door's port, and nothing here shows it is this install's door." };
|
|
287
314
|
}
|
|
288
315
|
return { level: "info",
|
|
289
316
|
text: `the client door is not set up here — ${missing}. \`${startCmd}\` writes both. Since the `
|
|
@@ -293,11 +320,16 @@ export function describeDoorState(door, {
|
|
|
293
320
|
if (door.active === null) {
|
|
294
321
|
// THE PROBE STILL COUNTS HERE. Not asking systemd is not the same as knowing nothing: if the port
|
|
295
322
|
// answers, the door is serving whatever systemd would have said.
|
|
296
|
-
if (door.listening === true) {
|
|
323
|
+
if (door.listening === true && door.ownListener === true) {
|
|
297
324
|
return { level: "ok",
|
|
298
325
|
text: `${unit} is installed and account access is enabled, and the client door's port is `
|
|
299
326
|
+ "answering — systemd was not asked, so this is the port's word rather than the unit's" };
|
|
300
327
|
}
|
|
328
|
+
if (door.listening === true) {
|
|
329
|
+
return { level: "info",
|
|
330
|
+
text: `${unit} is installed and account access is enabled — whether it is RUNNING was not checked. `
|
|
331
|
+
+ "A process holds the client door's port, and nothing here shows it is this install's door." };
|
|
332
|
+
}
|
|
301
333
|
return { level: "info",
|
|
302
334
|
text: `${unit} is installed and account access is enabled — whether it is RUNNING was not checked, `
|
|
303
335
|
+ "so this says the door is set up, not that it answers" };
|
|
@@ -429,6 +429,8 @@ const splitSides = (step) => {
|
|
|
429
429
|
return variants.map((v) => ({
|
|
430
430
|
...step,
|
|
431
431
|
text: v.heading,
|
|
432
|
+
// A side that needs something filled in before it is pasted says so under its own copy.
|
|
433
|
+
hint: v.hint ?? step.hint,
|
|
432
434
|
copy: { ...step.copy, text: v.text, stdio: { ...step.copy.stdio, text: v.text, variants: null } },
|
|
433
435
|
}));
|
|
434
436
|
};
|
|
@@ -152,7 +152,7 @@ export function firesOn(name, line, suffixable) {
|
|
|
152
152
|
* @returns {{start: number, end: number}[]}
|
|
153
153
|
*/
|
|
154
154
|
export function matchSpans(name, line, suffixable) {
|
|
155
|
-
const body = name.replace(/[
|
|
155
|
+
const body = name.replace(/[.*+?^${}()|[\]\\&]/g, "\\$&").replace(/ /g, SEPARATOR_CLASS);
|
|
156
156
|
const tail = suffixable.has(name) ? "" : "(?![A-Za-z0-9])";
|
|
157
157
|
const re = new RegExp(`(?<![A-Za-z0-9])${body}${tail}`, "gi");
|
|
158
158
|
const out = [];
|
|
@@ -332,7 +332,7 @@ const COMMENT_LINE = /^\s*(\/\/|#|\*|<!--)/;
|
|
|
332
332
|
// this repository is about to start printing addresses at — an allowlist for those mailboxes would
|
|
333
333
|
// have been inert, because nothing ever examined them.
|
|
334
334
|
//
|
|
335
|
-
// WHY THE RULED MAILBOXES JOIN ROLE_LOCALPART RATHER THAN THE DOMAIN GETTING AN EXEMPTION.
|
|
335
|
+
// WHY THE RULED MAILBOXES JOIN ROLE_LOCALPART RATHER THAN THE DOMAIN GETTING AN EXEMPTION. The
|
|
336
336
|
// ruling says `security` joins ROLE_LOCALPART "in the same PR, never a bypass", and that is the
|
|
337
337
|
// right shape: exempting the whole domain would also pass a named person's address at it, which is
|
|
338
338
|
// exactly the thing nobody should paste into a public README. Measured before widening rather than
|
package/shared/invocation.mjs
CHANGED
|
@@ -312,7 +312,7 @@ export function reachableCommand(verb, { argv1 = process.argv[1] ?? "", env = pr
|
|
|
312
312
|
/**
|
|
313
313
|
* The verb by NAME ONLY — no prefix, no path, no `npx`.
|
|
314
314
|
*
|
|
315
|
-
* ✕ A DELIBERATE EXCEPTION TO "ONE TREATMENT", AND THE ONE SURFACE THAT NEEDS IT.
|
|
315
|
+
* ✕ A DELIBERATE EXCEPTION TO "ONE TREATMENT", AND THE ONE SURFACE THAT NEEDS IT. The
|
|
316
316
|
* second requirement is that the choice is made once rather than per site, so an exception has to be
|
|
317
317
|
* named rather than quietly spelled differently somewhere.
|
|
318
318
|
*
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
|
|
3
|
+
//
|
|
4
|
+
// NOTICE WHEN THE PROCESS THAT STARTED US IS GONE.
|
|
5
|
+
//
|
|
6
|
+
// `npx clearotron demo` runs as npm → `sh -c clearotron demo …` → the launcher → the demo. A TERM to the
|
|
7
|
+
// pid a shell hands back for a backgrounded `npx …` reaches npm, npm passes it to that `sh`, and `sh` exits
|
|
8
|
+
// without passing it on. The launcher is reparented and the demo runs on, holding its three ports, with
|
|
9
|
+
// nothing saying so. Measured on npm 10.9.8, 2026-09-18. Neither npm nor that `sh` is ours to change, and
|
|
10
|
+
// a process is told nothing when its parent dies on Linux or macOS, so the launcher asks: its parent pid
|
|
11
|
+
// changes the moment it is reparented, to init or to the nearest subreaper.
|
|
12
|
+
//
|
|
13
|
+
// A process whose parent is already init when it starts (started under `setsid`, or by a service manager
|
|
14
|
+
// that exits) never sees its parent pid change, so this never fires for it.
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Call `onGone` once, the first time this process's parent pid differs from the one it had when this was
|
|
18
|
+
* called. Returns a function that stops watching. The timer never keeps the process alive on its own.
|
|
19
|
+
*/
|
|
20
|
+
export function watchParent(onGone, { intervalMs = 1000, parentPid = () => process.ppid } = {}) {
|
|
21
|
+
const first = parentPid();
|
|
22
|
+
let fired = false;
|
|
23
|
+
const timer = setInterval(() => {
|
|
24
|
+
if (fired) return;
|
|
25
|
+
const now = parentPid();
|
|
26
|
+
if (now === first) return;
|
|
27
|
+
fired = true;
|
|
28
|
+
clearInterval(timer);
|
|
29
|
+
onGone({ was: first, now });
|
|
30
|
+
}, intervalMs);
|
|
31
|
+
timer.unref?.();
|
|
32
|
+
return () => clearInterval(timer);
|
|
33
|
+
}
|
|
@@ -85,10 +85,12 @@ export const withoutColourValues = (line) => String(line).replace(HEX_COLOUR, (m
|
|
|
85
85
|
});
|
|
86
86
|
|
|
87
87
|
/** Strip the spans where a `#NNN` is an address rather than a reference. */
|
|
88
|
-
export const withoutLinkTargets = (line) => String(line)
|
|
88
|
+
export const withoutLinkTargets = (line) => withoutAngleSpans(String(line)
|
|
89
89
|
.replace(/\]\([^)]*\)/g, "]()") // markdown link targets, anchors included
|
|
90
|
-
.replace(/https?:\/\/\S+/g, "")
|
|
91
|
-
|
|
90
|
+
.replace(/https?:\/\/\S+/g, "")); // bare URLs and their fragments
|
|
91
|
+
|
|
92
|
+
/** Angle-bracket spans (autolinks, tags), removed until none is left: one pass can reassemble one. */
|
|
93
|
+
const withoutAngleSpans = (s) => { for (let prev = null; prev !== s;) { prev = s; s = s.replace(/<[^>]*>/g, ""); } return s; };
|
|
92
94
|
|
|
93
95
|
// A `#` COMMENT IS A COMMENT WHEREVER THE FILE FORMAT SAYS SO, not only in YAML. Extensionless is
|
|
94
96
|
// deliberate: a systemd unit or a dotfile often has no extension worth matching, so the KNOWN
|
package/shared/running-start.mjs
CHANGED
|
@@ -13,9 +13,9 @@
|
|
|
13
13
|
// record whose process is gone is read as absent, and `status` asks the portal itself before saying the
|
|
14
14
|
// product is up. Three answers, all honest: up; started but not answering; not running.
|
|
15
15
|
|
|
16
|
-
import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, rmdirSync, writeFileSync } from "node:fs";
|
|
17
17
|
import { homedir } from "node:os";
|
|
18
|
-
import { join } from "node:path";
|
|
18
|
+
import { dirname, join } from "node:path";
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
21
|
* Where the records live: one file per serving process, beside the settings and the revocation list.
|
|
@@ -37,9 +37,14 @@ export function pidAlive(pid) {
|
|
|
37
37
|
/**
|
|
38
38
|
* Record one serving start. Returns the function that removes the record; calling it twice is harmless,
|
|
39
39
|
* so the caller can hang it on both its own shutdown and the process's `exit`.
|
|
40
|
+
*
|
|
41
|
+
* THE FOLDERS THIS RECORD MADE GO WITH IT, when nothing else is in them. On a home that had none, the
|
|
42
|
+
* demo's record created `~/.config/clearotron/running` and its parent, and a clean stop left both behind
|
|
43
|
+
* under a banner saying nothing of the demo was left (measured 2026-09-19). A folder that was already
|
|
44
|
+
* there, or that holds anything else, stays: `rmdirSync` removes only an empty directory.
|
|
40
45
|
*/
|
|
41
46
|
export function recordRunning(rec, { dir = runningDir() } = {}) {
|
|
42
|
-
mkdirSync(dir, { recursive: true });
|
|
47
|
+
const made = mkdirSync(dir, { recursive: true });
|
|
43
48
|
const file = join(dir, `${rec.pid}.json`);
|
|
44
49
|
const tmp = `${file}.tmp`;
|
|
45
50
|
writeFileSync(tmp, `${JSON.stringify(rec, null, 2)}\n`, { mode: 0o600 });
|
|
@@ -49,6 +54,12 @@ export function recordRunning(rec, { dir = runningDir() } = {}) {
|
|
|
49
54
|
if (gone) return;
|
|
50
55
|
gone = true;
|
|
51
56
|
try { rmSync(file, { force: true }); } catch { /* already gone */ }
|
|
57
|
+
if (made) {
|
|
58
|
+
for (let d = dir; ; d = dirname(d)) {
|
|
59
|
+
try { rmdirSync(d); } catch { break; } // not empty, or gone: either way, not ours to take
|
|
60
|
+
if (d === made) break;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
52
63
|
};
|
|
53
64
|
}
|
|
54
65
|
|
package/shared/scope.mjs
CHANGED
|
@@ -259,7 +259,7 @@ export const TOOL_SCOPES = {
|
|
|
259
259
|
// The ONLY artifact a user (report-link) token may read via read_artifact — THE report (one report;
|
|
260
260
|
// clientSummary was a second version by another name and is retired from client reach — the file
|
|
261
261
|
// remains an internal cover-note source ops tokens may read). Everything else
|
|
262
|
-
// (narrative, audit, run.jsonl, skepticFlags,
|
|
262
|
+
// (narrative, audit, run.jsonl, skepticFlags, seniorEyeReview, matterContext, caseLaw, register axes,
|
|
263
263
|
// status.json, …) is internal and stays sealed from a user token.
|
|
264
264
|
// Exported so the server's Resources surface (ListResources/ReadResource) gates to the SAME set.
|
|
265
265
|
/**
|
|
@@ -833,7 +833,7 @@ export function resolveScope({ local = false, innerToken = null, email = null, f
|
|
|
833
833
|
throw new Error("forbidden: client account access is not enabled on this door");
|
|
834
834
|
return { kind: "account", runId: null, sub: t.sub, verbs: null, ...personScope(t.sub, t.accounts) };
|
|
835
835
|
}
|
|
836
|
-
if (t.scope !== "user") throw new Error(
|
|
836
|
+
if (t.scope !== "user") throw new Error(`forbidden: the client surface accepts only a run-scoped user token or an account key${otherDoor("staff")}`);
|
|
837
837
|
return { kind: "user", runId: t.runId, sub: t.sub, verbs: null, accounts: null }; // run-bound — accounts moot
|
|
838
838
|
}
|
|
839
839
|
if (local) return { kind: "ops", runId: null, sub: "local", verbs: null, accounts: "*" };
|
|
@@ -842,7 +842,7 @@ export function resolveScope({ local = false, innerToken = null, email = null, f
|
|
|
842
842
|
// An account key belongs to the CLIENT door and nowhere else. Falling through would land it in the
|
|
843
843
|
// `user` arm below with runId:null — a "run-bound" scope bound to no run, which every run-pinning check
|
|
844
844
|
// downstream would then wave through. Refuse it here instead.
|
|
845
|
-
if (t.scope === "account") throw new Error(
|
|
845
|
+
if (t.scope === "account") throw new Error(`forbidden: an account key is only accepted on the client surface${otherDoor("client")}`);
|
|
846
846
|
return t.scope === "ops"
|
|
847
847
|
? { kind: "ops", runId: null, sub: t.sub, verbs: t.verbs, accounts: t.accounts ?? "*" }
|
|
848
848
|
: { kind: "user", runId: t.runId, sub: t.sub, verbs: null, accounts: null }; // run-bound — accounts moot
|
|
@@ -866,6 +866,25 @@ export function resolveScope({ local = false, innerToken = null, email = null, f
|
|
|
866
866
|
throw new Error("forbidden: no run-scoped token and not a firm-staff identity — refusing (internal read-all requires proven firm staff)");
|
|
867
867
|
}
|
|
868
868
|
|
|
869
|
+
/**
|
|
870
|
+
* WHERE THE OTHER DOOR IS, for the refusal that turns a key away from the wrong one: " (client surface:
|
|
871
|
+
* <address>)", or nothing when this process was not told. A key refused as "only accepted on the client
|
|
872
|
+
* surface" read as a permissions problem, and the next thing a reader did was the wrong thing; the demo's
|
|
873
|
+
* own output led an assistant to the staff door with an account key (measured on a published beta,
|
|
874
|
+
* 2026-09-19). Each door is handed the other's port and host by `start` and by the shared unit settings,
|
|
875
|
+
* under the names the other door listens on. The client door's public address, when one is set, is the
|
|
876
|
+
* one a remote assistant can reach, so it wins. A wildcard bind is named as loopback, where a reader on
|
|
877
|
+
* this machine reaches it. PURE given its env.
|
|
878
|
+
*/
|
|
879
|
+
export function otherDoor(which, env = process.env) {
|
|
880
|
+
const [url, host, port] = which === "client"
|
|
881
|
+
? [env.CLEAROTRON_CLIENT_MCP_URL, env.CLIENT_MCP_HTTP_HOST, env.CLIENT_MCP_HTTP_PORT]
|
|
882
|
+
: [null, env.TRADEMARK_MCP_HTTP_HOST, env.TRADEMARK_MCP_HTTP_PORT];
|
|
883
|
+
const at = String(url ?? "").trim() || (String(port ?? "").trim()
|
|
884
|
+
? `http://${!host || host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host}:${String(port).trim()}/mcp` : "");
|
|
885
|
+
return at ? ` (${which} surface: ${at})` : "";
|
|
886
|
+
}
|
|
887
|
+
|
|
869
888
|
// The ENFORCEMENT chokepoint. Returns the (possibly run-pinned) args to dispatch, or throws an Error the
|
|
870
889
|
// CallTool handler surfaces as an MCP error. ops ⇒ everything. user/internal ⇒ no write tools. user ⇒ also
|
|
871
890
|
// no cross-run tool, and every run-scoped call is PINNED to the token's bound run (a mismatching explicit
|
package/shared/stdio-connect.mjs
CHANGED
|
@@ -50,8 +50,8 @@ export const STDIO_SERVER_NAME = "trademark-artifacts";
|
|
|
50
50
|
* @param {{ installRoot?: string, workDir?: string|null }} [opts]
|
|
51
51
|
* @returns {string} the exact command to run
|
|
52
52
|
*/
|
|
53
|
-
export function stdioConnectCommand({ installRoot = stableInstallRoot({ installRoot: INSTALL_ROOT }), workDir = null, reportsDir = null, platform = process.platform } = {}) {
|
|
54
|
-
return STDIO_SHAPES["claude-cli"].render({ server: join(installRoot, "mcp-server",
|
|
53
|
+
export function stdioConnectCommand({ installRoot = stableInstallRoot({ installRoot: INSTALL_ROOT }), workDir = null, reportsDir = null, platform = process.platform, node = process.execPath } = {}) {
|
|
54
|
+
return STDIO_SHAPES["claude-cli"].render({ server: join(installRoot, "mcp-server", STDIO_ENTRY), workDir, reportsDir, platform, node });
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
/**
|
|
@@ -125,6 +125,12 @@ const separator = (platform = process.platform) => (platform === "win32" ? '"--"
|
|
|
125
125
|
* -e …`. The distribution is named from `WSL_DISTRO_NAME` when we have it, because a machine with more
|
|
126
126
|
* than one would otherwise get whichever is default — which may be a distribution with no install.
|
|
127
127
|
*
|
|
128
|
+
* AND IT IS NEVER LEFT BLANK (owner, 2026-09-19). With no name to hand the row used to drop `-d` and take
|
|
129
|
+
* the default distribution, which fails on a machine whose default is not this one, with nothing on screen
|
|
130
|
+
* to connect the failure to the choice. The row now carries WSL_DISTRO_PLACEHOLDER in the name's place,
|
|
131
|
+
* and the Windows side's hint says plainly to fill it in. `WSL_DISTRO_NAME` is the one place the name can
|
|
132
|
+
* be read from inside the distribution: `/etc/wsl.conf` holds settings for it, not its name.
|
|
133
|
+
*
|
|
128
134
|
* THE ENVIRONMENT CROSSES THROUGH `env`, NOT THROUGH THE HOST'S OWN env BLOCK. A host on Windows sets
|
|
129
135
|
* variables for the process it starts, which is `wsl.exe`; they do not cross the boundary into the
|
|
130
136
|
* distribution, so a work directory set that way is silently absent on the other side and the server
|
|
@@ -134,17 +140,32 @@ const separator = (platform = process.platform) => (platform === "win32" ? '"--"
|
|
|
134
140
|
* Off WSL the launcher is exactly what it always was, so every row on every other platform is
|
|
135
141
|
* byte-identical to before. PURE.
|
|
136
142
|
*/
|
|
137
|
-
|
|
143
|
+
/**
|
|
144
|
+
* WHICH NODE, AND WHICH FILE (owner, 2026-09-19). A line that said bare `node` ran whatever `node` the
|
|
145
|
+
* assistant's own PATH found. Through `wsl.exe -e`, a non-login shell, that was the distribution's system
|
|
146
|
+
* Node 18 while the demo ran on Node 22, and the server died on a syntax error before saying anything
|
|
147
|
+
* (measured on a Windows laptop). A desktop assistant on any platform starts from a bare PATH the same
|
|
148
|
+
* way. So every line names the absolute Node this install is running on, `process.execPath`, seen from
|
|
149
|
+
* where the line is composed, inside WSL when that is where the install is. It names `serve.mjs`, the
|
|
150
|
+
* entry that refuses an old Node in one plain line before the server loads.
|
|
151
|
+
*/
|
|
152
|
+
export const STDIO_ENTRY = "serve.mjs";
|
|
153
|
+
|
|
154
|
+
/** What stands in the distribution's name when it cannot be read, and the line that says to fill it in. */
|
|
155
|
+
export const WSL_DISTRO_PLACEHOLDER = "YOUR-WSL-DISTRIBUTION";
|
|
156
|
+
export const WSL_DISTRO_FILL_IN = `Replace ${WSL_DISTRO_PLACEHOLDER} with this Linux's name before you paste it: \`wsl -l\` in Windows lists the names.`;
|
|
157
|
+
|
|
158
|
+
export function stdioLauncher({ server, workDir = null, reportsDir = null, wsl = null, node = process.execPath } = {}) {
|
|
138
159
|
const vars = envOf({ workDir, reportsDir });
|
|
139
|
-
if (!wsl) return { command:
|
|
160
|
+
if (!wsl) return { command: node, args: [server], env: vars, crossesIntoWsl: false };
|
|
140
161
|
const distro = String(wsl.distro ?? "").trim();
|
|
141
162
|
return {
|
|
142
163
|
command: "wsl.exe",
|
|
143
164
|
// `-e` runs the command directly rather than through a login shell, so nothing of the reader's
|
|
144
165
|
// profile can rewrite the arguments between Windows and the server.
|
|
145
|
-
args: [
|
|
166
|
+
args: ["-d", distro || WSL_DISTRO_PLACEHOLDER, "-e",
|
|
146
167
|
...(Object.keys(vars).length ? ["env", ...Object.entries(vars).map(([k, v]) => `${k}=${v}`)] : []),
|
|
147
|
-
|
|
168
|
+
node, server],
|
|
148
169
|
// Already carried inside the argument list above; a host-side env block would set them on the
|
|
149
170
|
// Windows process and never reach the server.
|
|
150
171
|
env: {},
|
|
@@ -157,8 +178,8 @@ export const STDIO_SHAPES = Object.freeze({
|
|
|
157
178
|
"claude-cli": {
|
|
158
179
|
kind: "command",
|
|
159
180
|
where: null,
|
|
160
|
-
render: ({ server, workDir, reportsDir, platform, wsl }) => {
|
|
161
|
-
const l = stdioLauncher({ server, workDir, reportsDir, wsl });
|
|
181
|
+
render: ({ server, workDir, reportsDir, platform, wsl, node }) => {
|
|
182
|
+
const l = stdioLauncher({ server, workDir, reportsDir, wsl, node });
|
|
162
183
|
// The host's own `-e` flags set variables for the process IT starts. Off WSL that is the server;
|
|
163
184
|
// through the wrapper it is `wsl.exe`, and they stop at the boundary — so on WSL they ride inside
|
|
164
185
|
// the command instead and this line carries none.
|
|
@@ -170,8 +191,8 @@ export const STDIO_SHAPES = Object.freeze({
|
|
|
170
191
|
"desktop-json": {
|
|
171
192
|
kind: "config",
|
|
172
193
|
where: "Settings → Developer → Edit Config",
|
|
173
|
-
render: ({ server, workDir, reportsDir, wsl }) => {
|
|
174
|
-
const l = stdioLauncher({ server, workDir, reportsDir, wsl });
|
|
194
|
+
render: ({ server, workDir, reportsDir, wsl, node }) => {
|
|
195
|
+
const l = stdioLauncher({ server, workDir, reportsDir, wsl, node });
|
|
175
196
|
return JSON.stringify({
|
|
176
197
|
mcpServers: {
|
|
177
198
|
[STDIO_SERVER_NAME]: {
|
|
@@ -189,8 +210,8 @@ export const STDIO_SHAPES = Object.freeze({
|
|
|
189
210
|
"generic-json": {
|
|
190
211
|
kind: "config",
|
|
191
212
|
where: "your agent's MCP server configuration",
|
|
192
|
-
render: ({ server, workDir, reportsDir, wsl }) => {
|
|
193
|
-
const l = stdioLauncher({ server, workDir, reportsDir, wsl });
|
|
213
|
+
render: ({ server, workDir, reportsDir, wsl, node }) => {
|
|
214
|
+
const l = stdioLauncher({ server, workDir, reportsDir, wsl, node });
|
|
194
215
|
return JSON.stringify({
|
|
195
216
|
command: l.command,
|
|
196
217
|
args: l.args,
|
|
@@ -209,8 +230,8 @@ export const STDIO_SHAPES = Object.freeze({
|
|
|
209
230
|
// Codex does not forward the shell environment, and a credential would have to be forwarded BY NAME
|
|
210
231
|
// rather than written into a file. This server takes no credential — the work directory is a path,
|
|
211
232
|
// not a secret — so `env` is correct here and would not be for a server that wanted a key.
|
|
212
|
-
render: ({ server, workDir, reportsDir, wsl }) => {
|
|
213
|
-
const l = stdioLauncher({ server, workDir, reportsDir, wsl });
|
|
233
|
+
render: ({ server, workDir, reportsDir, wsl, node }) => {
|
|
234
|
+
const l = stdioLauncher({ server, workDir, reportsDir, wsl, node });
|
|
214
235
|
return [
|
|
215
236
|
`[mcp_servers.${STDIO_SERVER_NAME}]`,
|
|
216
237
|
`command = "${l.command}"`,
|
|
@@ -335,11 +356,11 @@ export function remoteConnectFor(shape, { address = null } = {}) {
|
|
|
335
356
|
*/
|
|
336
357
|
export const WSL_ROW_HEADINGS = Object.freeze({ fromWindows: "From Windows", insideWsl: "Inside WSL" });
|
|
337
358
|
|
|
338
|
-
export function stdioConnectFor(shape, { installRoot = stableInstallRoot({ installRoot: INSTALL_ROOT }), workDir = null, reportsDir = null, platform = process.platform, wsl = null } = {}) {
|
|
359
|
+
export function stdioConnectFor(shape, { installRoot = stableInstallRoot({ installRoot: INSTALL_ROOT }), workDir = null, reportsDir = null, platform = process.platform, wsl = null, node = process.execPath } = {}) {
|
|
339
360
|
const spec = Object.hasOwn(STDIO_SHAPES, String(shape ?? "")) ? STDIO_SHAPES[shape] : null;
|
|
340
361
|
if (!spec) return null;
|
|
341
|
-
const server = join(installRoot, "mcp-server",
|
|
342
|
-
const render = (target) => spec.render({ server, workDir, reportsDir, platform, wsl: target });
|
|
362
|
+
const server = join(installRoot, "mcp-server", STDIO_ENTRY);
|
|
363
|
+
const render = (target) => spec.render({ server, workDir, reportsDir, platform, wsl: target, node });
|
|
343
364
|
const base = { shape, kind: spec.kind, where: spec.where, after: spec.after, name: STDIO_SERVER_NAME };
|
|
344
365
|
// OFF WSL NOTHING CHANGES: one launcher, no variants, and `variants: null` rather than an empty array
|
|
345
366
|
// so a consumer cannot read "this install has no sides" as "this install has two sides, both missing".
|
|
@@ -355,7 +376,7 @@ export function stdioConnectFor(shape, { installRoot = stableInstallRoot({ insta
|
|
|
355
376
|
...base,
|
|
356
377
|
text: render(wsl),
|
|
357
378
|
variants: [
|
|
358
|
-
{ heading: WSL_ROW_HEADINGS.fromWindows, text: render(wsl) },
|
|
379
|
+
{ heading: WSL_ROW_HEADINGS.fromWindows, text: render(wsl), hint: String(wsl.distro ?? "").trim() ? null : WSL_DISTRO_FILL_IN },
|
|
359
380
|
{ heading: WSL_ROW_HEADINGS.insideWsl, text: render(null) },
|
|
360
381
|
],
|
|
361
382
|
};
|
|
@@ -443,9 +443,8 @@ const H1_BLOCK = /<h1\b[^>]*>([\s\S]*?)<\/h1>/g
|
|
|
443
443
|
* heading was entirely its subject.
|
|
444
444
|
*/
|
|
445
445
|
const isSubjectHeading = (inner) => {
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
.replace(/<[^>]*>/g, '') // nested tags
|
|
446
|
+
let bare = String(inner).replace(/\{(?:[^{}]|\{[^{}]*\})*\}/g, '') // JSX expressions, one level of nesting
|
|
447
|
+
for (let prev = null; prev !== bare;) { prev = bare; bare = bare.replace(/<[^>]*>/g, '') } // nested tags, until none is left
|
|
449
448
|
return !/[A-Za-z]{2}/.test(bare)
|
|
450
449
|
}
|
|
451
450
|
|