clearotron 0.3.2-beta.12 → 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/CONTRIBUTING.md +6 -5
- package/INSTALL.md +3 -4
- package/README.md +2 -1
- package/bin/example.mjs +12 -1
- package/bin/onboard.mjs +16 -2
- package/bin/start.mjs +6 -3
- 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 +13 -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/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/openai-agent.mjs +2 -2
- package/driver/findings-model.mjs +21 -6
- package/driver/gateway.mjs +1 -1
- package/driver/package.json +1 -1
- package/driver/pipeline-knockout.mjs +1 -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 +4 -1
- package/driver/portal-service.mjs +17 -5
- package/driver/predelivery-lint.mjs +9 -4
- package/driver/progress.mjs +37 -1
- package/driver/publish/render.mjs +6 -6
- package/driver/record-discard.mjs +1 -1
- package/driver/register-count.mjs +1 -1
- package/driver/register-digest-record.mjs +1 -1
- package/driver/report-card-record.mjs +2 -2
- package/driver/roster-verdict.mjs +2 -2
- package/driver/search-policy.mjs +1 -1
- package/driver/skeptic-record.mjs +1 -1
- package/driver/stages.mjs +1 -1
- package/driver/suite-census.json +27 -9
- package/driver/systemd/README.md +1 -1
- package/driver/unit-inventory.mjs +2 -2
- package/driver/verify.mjs +1 -1
- package/mcp-server/CHANGELOG.md +4 -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/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 +4 -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/citation-anchor-report.mjs +1 -1
- package/scripts/citation-line-check.mjs +3 -3
- package/scripts/e2e.mjs +1 -1
- package/scripts/env-audit.mjs +1 -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/settings-render-check.mjs +5 -1
- package/scripts/strip-tracker-citations.mjs +4 -4
- package/scripts/test-run.mjs +46 -3
- package/shared/browser-temp-root.mjs +10 -3
- package/shared/client-door.mjs +18 -6
- package/shared/connect-clients.mjs +2 -0
- package/shared/identifier-scan.mjs +2 -2
- package/shared/invocation.mjs +1 -1
- package/shared/reference-guard-classes.mjs +5 -3
- package/shared/stdio-connect.mjs +39 -18
- package/shared/writing-standard-classes.mjs +2 -3
|
@@ -113,7 +113,7 @@ export function anchorRows(citations, linesOf) {
|
|
|
113
113
|
if (!others.length) { rows.push({ ...c, verdict: "silent", anchor }); continue; }
|
|
114
114
|
let elsewhere = null;
|
|
115
115
|
for (const n of others) {
|
|
116
|
-
const decl = new RegExp(`^\\s*(?:export\\s+)?(?:default\\s+)?(?:async\\s+)?(?:function|const|let|var|class)\\s+${n.replace(
|
|
116
|
+
const decl = new RegExp(`^\\s*(?:export\\s+)?(?:default\\s+)?(?:async\\s+)?(?:function|const|let|var|class)\\s+${n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
|
|
117
117
|
for (let i = 0; i < target.length; i++) {
|
|
118
118
|
if (!decl.test(target[i])) continue;
|
|
119
119
|
// THE CITATION POINTING INSIDE WHAT IT NAMES IS CORRECT, not drifted — see the header.
|
|
@@ -528,7 +528,7 @@ function main() {
|
|
|
528
528
|
// WATCHLIST_OWNERS_MAX declared in variant-manifest-model.mjs anything else — `declared` marks it
|
|
529
529
|
//
|
|
530
530
|
// A BARE WORD BEFORE `in <file>` IS NOT A SYMBOL CITATION, deliberately, and this is the difference
|
|
531
|
-
// between this arm and
|
|
531
|
+
// between this arm and the line arm, which reads the token beside a number SPECULATIVELY and drops it when it
|
|
532
532
|
// is not declared, because "ALREADY computes (coverage-ledger.mjs:180)" would otherwise manufacture a
|
|
533
533
|
// defect out of emphatic prose. This arm cannot do that: an undeclared symbol has to FAIL or the check
|
|
534
534
|
// has no teeth at all. So the form is opted into. `the field in scope-ledger.mjs` and `checked in
|
|
@@ -923,8 +923,8 @@ export function symbolMisses(citations, readLines) {
|
|
|
923
923
|
// WHAT IT CANNOT SEE, and why this stays a slice. A citation that lands on the WRONG NON-BLANK LINE is
|
|
924
924
|
// invisible to it — that looks identical to a correct one. Three of the eleven citations repointed in
|
|
925
925
|
// `stages.mjs` under this issue were exactly that: `stages.mjs:1474` pointed at a transliteration `why:`
|
|
926
|
-
// row, `pipeline.mjs
|
|
927
|
-
// arm can decide those, and only where the citation names a symbol. A clean run here is evidence about
|
|
926
|
+
// row, line 2908 of `pipeline.mjs` at a different function entirely, and neither line was blank. Only the
|
|
927
|
+
// symbol arm can decide those, and only where the citation names a symbol. A clean run here is evidence about
|
|
928
928
|
// punctuation, not about correctness.
|
|
929
929
|
//
|
|
930
930
|
// AN OVERRUN IS NOT THIS FINDING. A span running past the file's last line is already reported as
|
package/scripts/e2e.mjs
CHANGED
|
@@ -2370,7 +2370,7 @@ const TERMINAL_BY_SUFFIX_RAN = { failed: "failed", done: "delivered", cancelled:
|
|
|
2370
2370
|
|
|
2371
2371
|
// REPAIR — the live-state vocabulary is IMPORTED, not retyped. This file's first draft spelled it
|
|
2372
2372
|
// out twice more (an in-flight suffix set and a claim-lock regex), which is exactly the failure
|
|
2373
|
-
// queue-markers.mjs was created for: "written down three times and the copies disagreed
|
|
2373
|
+
// queue-markers.mjs was created for: "written down three times and the copies disagreed". The
|
|
2374
2374
|
// claim lock in particular is a NORMAL in-flight state — a live token is a claim in progress, a dead one
|
|
2375
2375
|
// is restored by sweepAbandonedTakeovers — and one more private copy of that rule is one more chance to
|
|
2376
2376
|
// report a claim race as a stranded job.
|
package/scripts/env-audit.mjs
CHANGED
|
@@ -624,7 +624,7 @@ const EXTERNAL_RE = /^#\s*external:\s*(\S.*)$/;
|
|
|
624
624
|
//
|
|
625
625
|
// THE TWO MARKERS DO NOT CLEAR EACH OTHER. `pending` was a single slot, and any comment line that was
|
|
626
626
|
// not an `# external:` reset it. Adding a second marker to that shape would mean an `# effect:` line
|
|
627
|
-
// silently DELETED the `# external:` declaration above it — the row would go orphaned and
|
|
627
|
+
// silently DELETED the `# external:` declaration above it — the row would go orphaned and the orphan
|
|
628
628
|
// guard would red, with the cause three lines away and invisible. Each marker carries its own slot.
|
|
629
629
|
const EFFECT_RE = /^#\s*effect:\s*(\S.*)$/;
|
|
630
630
|
// A COMMENTED-OUT ROW STILL COUNTS, the same rule `assigned` uses above and for the same reason: `# X=`
|
package/scripts/env-classify.mjs
CHANGED
|
@@ -248,7 +248,7 @@ function namesMergedIntoCandidate(src) {
|
|
|
248
248
|
const helpers = new Set([...src.matchAll(/Object\.assign\(\s*candidate\s*,\s*(?:await\s+)?([A-Za-z_$][\w$]*)\s*\(/g)]
|
|
249
249
|
.map((m) => m[1]));
|
|
250
250
|
for (const fn of helpers) {
|
|
251
|
-
const at = src.search(new RegExp(`\\bfunction\\s+${fn.replace(
|
|
251
|
+
const at = src.search(new RegExp(`\\bfunction\\s+${fn.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*\\(`));
|
|
252
252
|
const refuse = (why) => new Error(`env-classify: bin/onboard.mjs writes what ${fn}() returns, and ${why}, so `
|
|
253
253
|
+ "the names it writes cannot be read. Read as none, they would land in `tuning` and on the deletion "
|
|
254
254
|
+ "population; that is this script failing to look, not a finding about the wizard.");
|
|
@@ -123,7 +123,7 @@ async function main() {
|
|
|
123
123
|
try {
|
|
124
124
|
({ reconcileAndScan: gate } = await import("../cut/packed-artifact.mjs"));
|
|
125
125
|
} catch (e) {
|
|
126
|
-
console.error(" REFUSING (exit 2, could
|
|
126
|
+
console.error(" REFUSING (exit 2, could not look): cut/packed-artifact.mjs did not load —"
|
|
127
127
|
+ ` ${String(e?.message ?? e)}.\n`
|
|
128
128
|
+ " That module carries the only rule that says which files may leave this repository, and a\n"
|
|
129
129
|
+ " pack that cannot ask it produces an artifact nobody has checked. An exported tree does not\n"
|
|
@@ -106,10 +106,10 @@ function main() {
|
|
|
106
106
|
console.error("usage: node scripts/release-artifact-seal.mjs --tarball <path>");
|
|
107
107
|
process.exit(2);
|
|
108
108
|
}
|
|
109
|
-
// A path that is not there is a could
|
|
109
|
+
// A path that is not there is a check that could not look, never a seal that found nothing to do. Exit 2 is
|
|
110
110
|
// the house meaning and it keeps this distinguishable from a tarball that was sealed and was clean.
|
|
111
111
|
if (!existsSync(tarball)) {
|
|
112
|
-
console.error(` REFUSING (exit 2, could
|
|
112
|
+
console.error(` REFUSING (exit 2, could not look): ${tarball} does not exist, so nothing was sealed.`);
|
|
113
113
|
process.exit(2);
|
|
114
114
|
}
|
|
115
115
|
|
|
@@ -148,6 +148,10 @@ const sessionId = sess.sessionId
|
|
|
148
148
|
const cmd = (method, params = {}) => new Promise((r) => { const i = ++id; pending.set(i, r); ws.send(JSON.stringify({ id: i, sessionId, method, params })) })
|
|
149
149
|
await cmd('Page.enable')
|
|
150
150
|
// A probe that throws says so, rather than returning nothing for the assertions after it to misread.
|
|
151
|
+
// A value pasted into code the page evaluates, as a JavaScript string or object literal. JSON.stringify
|
|
152
|
+
// alone leaves `<`, `>`, `/` and the two line separators as they are; escaped, they read the same once
|
|
153
|
+
// parsed and cannot close or break the code they are pasted into.
|
|
154
|
+
const jsLiteral = (v) => JSON.stringify(v).replace(/[<>\/\u2028\u2029]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`)
|
|
151
155
|
const evalIn = async (expr) => {
|
|
152
156
|
const r = (await cmd('Runtime.evaluate', { expression: expr, awaitPromise: true, returnByValue: true })).result
|
|
153
157
|
if (r?.exceptionDetails) console.error(` ! a probe threw: ${r.exceptionDetails.exception?.description ?? r.exceptionDetails.text}`)
|
|
@@ -184,7 +188,7 @@ async function reload(ready, what) {
|
|
|
184
188
|
}
|
|
185
189
|
|
|
186
190
|
async function setTheme(theme) {
|
|
187
|
-
await evalIn(`(() => { document.documentElement.setAttribute('data-theme', ${
|
|
191
|
+
await evalIn(`(() => { document.documentElement.setAttribute('data-theme', ${jsLiteral(theme)}); try { localStorage.setItem('cordillera-theme', ${jsLiteral(theme)}) } catch {} return true })()`)
|
|
188
192
|
await sleep(250)
|
|
189
193
|
}
|
|
190
194
|
|
|
@@ -2,15 +2,15 @@
|
|
|
2
2
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
3
3
|
// Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
|
|
4
4
|
//
|
|
5
|
-
// Removes the internal citation OPENER from comments and prose in the tree
|
|
5
|
+
// Removes the internal citation OPENER from comments and prose in the tree.
|
|
6
6
|
//
|
|
7
7
|
// The form is `tracker issue NNN — ` standing at the head of a sentence, where the citation is not part
|
|
8
8
|
// of what the sentence says but a label in front of it. Stripping the opener leaves the sentence intact:
|
|
9
9
|
//
|
|
10
|
-
// // tracker issue
|
|
10
|
+
// // tracker issue NNNN — the walk must start at the repository root
|
|
11
11
|
// // the walk must start at the repository root
|
|
12
12
|
//
|
|
13
|
-
// assert.ok(x, "Refs tracker issue
|
|
13
|
+
// assert.ok(x, "Refs tracker issue NNNN — an absent file is a finding")
|
|
14
14
|
// assert.ok(x, "an absent file is a finding")
|
|
15
15
|
//
|
|
16
16
|
// WHY THE PATTERN LOOKS OVER-SPECIFIED. Three parts of it are load-bearing and each was measured, not
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
// and the replacement is `$1`, which puts that opener back. Without the group the sweep deletes the
|
|
21
21
|
// opening quote of every test name it touches, and a broken string literal is a syntax error in the
|
|
22
22
|
// lucky cases and a changed assertion in the unlucky ones.
|
|
23
|
-
// · `\s+` AFTER THE SEPARATOR, never `\s*`. `tracker issue
|
|
23
|
+
// · `\s+` AFTER THE SEPARATOR, never `\s*`. `tracker issue NNNN-12` is an ITEM suffix, not a citation
|
|
24
24
|
// followed by prose: there is no space after its hyphen. `\s*` eats the item number.
|
|
25
25
|
// · THE `i` FLAG. `Refs tracker issue NNN` is capitalised at the head of a commit-style line and is a
|
|
26
26
|
// fifth of the corpus.
|
package/scripts/test-run.mjs
CHANGED
|
@@ -271,8 +271,8 @@ process.env.CLEAROTRON_DEMO_PROFILES ??= "1";
|
|
|
271
271
|
// driver/test/*.test.mjs. They are indistinguishable from code defects. An agent who runs the suite on a
|
|
272
272
|
// branch, sees 295 red, and diffs the failing NAMES against a baseline taken the same way sees zero
|
|
273
273
|
// regressions and calls the branch clean — and it is, but roughly 190 tests never executed, and a real
|
|
274
|
-
// regression inside any of them is invisible by exactly that arithmetic. It has already happened:
|
|
275
|
-
//
|
|
274
|
+
// regression inside any of them is invisible by exactly that arithmetic. It has already happened: a
|
|
275
|
+
// full-suite comparison was taken against a 295-fail baseline.
|
|
276
276
|
//
|
|
277
277
|
// So: refuse, name what is missing, and name the command. REFUSE RATHER THAN INSTALL — this wrapper is
|
|
278
278
|
// what CI and scripts/publication-scan.mjs run the suite through, and a wrapper that can start a network
|
|
@@ -751,6 +751,37 @@ const repoBefore = snapshotRepo(REPO_ROOT);
|
|
|
751
751
|
const RUN_HOME = String(process.env.HOME ?? "").trim() || homedir();
|
|
752
752
|
const homeBefore = snapshotHome(RUN_HOME);
|
|
753
753
|
|
|
754
|
+
// ── AND NOTHING THE RUN MADE IS LEFT IN THE MACHINE'S TEMP DIRECTORY ────────────────────────────────
|
|
755
|
+
//
|
|
756
|
+
// The run's own root is removed on every exit. What escapes it is a child handed an environment without
|
|
757
|
+
// TMPDIR: it falls back to the machine's temp directory, and nothing removes what it made there. Measured
|
|
758
|
+
// 2026-09-19: fifteen `clearotron-demo-*` directories per `npm test`, from the demo's temporary sample
|
|
759
|
+
// copies, and 36 GB accumulated on one box. Named by the product prefix, so the guard reads only what
|
|
760
|
+
// this product makes; owned by this account, so another user's run is not ours to count.
|
|
761
|
+
//
|
|
762
|
+
// THE OUTERMOST RUN WATCHES THE MACHINE'S TEMP ROOTS. A nested run watches only its own base, and only
|
|
763
|
+
// when a test arms it (CT_TEMP_LEAK_GUARD=1): the runner's own tests drive nested runs while the rest of
|
|
764
|
+
// the suite is working, and a nested guard reading the shared root would count its neighbours' files.
|
|
765
|
+
const LEAK_PREFIXES = Object.freeze(["clearotron-demo-"]);
|
|
766
|
+
const OUTERMOST = !String(process.env.CT_TEST_MACHINE_TMP ?? "").trim();
|
|
767
|
+
const LEAK_ROOTS = OUTERMOST
|
|
768
|
+
? [...new Set([MACHINE_TMP, REAL_TMP, ...PLATFORM_TMP].map((p) => resolve(p)))]
|
|
769
|
+
: String(process.env.CT_TEMP_LEAK_GUARD ?? "") === "1" ? [resolve(REAL_TMP)] : [];
|
|
770
|
+
function tempLeftovers() {
|
|
771
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : null;
|
|
772
|
+
const out = new Set();
|
|
773
|
+
for (const r of LEAK_ROOTS) {
|
|
774
|
+
let names;
|
|
775
|
+
try { names = readdirSync(r); } catch { continue; }
|
|
776
|
+
for (const n of names) {
|
|
777
|
+
if (!LEAK_PREFIXES.some((p) => n.startsWith(p))) continue;
|
|
778
|
+
try { if (uid == null || statSync(join(r, n)).uid === uid) out.add(join(r, n)); } catch { /* gone already */ }
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
return out;
|
|
782
|
+
}
|
|
783
|
+
const tempBefore = tempLeftovers();
|
|
784
|
+
|
|
754
785
|
|
|
755
786
|
child = spawn(argv[0], argv.slice(1), {
|
|
756
787
|
stdio: "inherit",
|
|
@@ -808,8 +839,20 @@ child.on("close", (code, signal) => {
|
|
|
808
839
|
if (wrote.length) for (const line of explainRepoWrites(wrote)) console.error(line);
|
|
809
840
|
const wroteHome = repoWrites(homeBefore, snapshotHome(RUN_HOME), RUN_HOME);
|
|
810
841
|
if (wroteHome.length) for (const line of explainHomeWrites(wroteHome, RUN_HOME)) console.error(line);
|
|
842
|
+
const leftInTemp = [...tempLeftovers()].filter((p) => !tempBefore.has(p)).sort();
|
|
843
|
+
if (leftInTemp.length) {
|
|
844
|
+
console.error("");
|
|
845
|
+
console.error(`[test-run] THIS RUN LEFT ${leftInTemp.length} DIRECTOR${leftInTemp.length === 1 ? "Y" : "IES"} IN THE MACHINE'S TEMP DIRECTORY:`);
|
|
846
|
+
for (const p of leftInTemp) console.error(` + ${p}`);
|
|
847
|
+
console.error("");
|
|
848
|
+
console.error(" A child handed an environment without TMPDIR puts its temporary files in the machine's temp");
|
|
849
|
+
console.error(" directory, where nothing removes them. Pass `TMPDIR: tmpdir()` in that child's env, so they land");
|
|
850
|
+
console.error(" in this run's own root, which is removed on every exit.");
|
|
851
|
+
console.error("");
|
|
852
|
+
console.error(" (Another run on this account making the same directories at the same time prints this too.)");
|
|
853
|
+
}
|
|
811
854
|
// THIS MAY TURN A GREEN RUN RED. IT MUST NEVER TURN A RED RUN GREEN — a failing suite keeps its own
|
|
812
855
|
// exit code, because what the tests found matters more than what they wrote while finding it.
|
|
813
856
|
const childCode = code ?? 1;
|
|
814
|
-
process.exit(childCode !== 0 ? childCode : (wrote.length || wroteHome.length ? 1 : 0));
|
|
857
|
+
process.exit(childCode !== 0 ? childCode : (wrote.length || wroteHome.length || leftInTemp.length ? 1 : 0));
|
|
815
858
|
});
|
|
@@ -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
|
@@ -214,7 +214,7 @@ export const clientDoorAddress = (env = {}) => `http://127.0.0.1:${clientDoorPor
|
|
|
214
214
|
* the fence off accepts no account key, and the fence on with nothing listening is a setting with no
|
|
215
215
|
* server. Reporting "standing" on half of it would send a reader to paste an address at nothing.
|
|
216
216
|
*/
|
|
217
|
-
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 } = {}) {
|
|
218
218
|
const fenceOn = String(env.CLIENT_MCP_ACCOUNT_ACCESS ?? "").trim() === "1";
|
|
219
219
|
const unitInstalled = Boolean(exists(join(unitDir, CLIENT_DOOR_UNIT)));
|
|
220
220
|
// `standing` IS UNCHANGED AND STILL MEANS CONFIGURED — a file on disk and a fence flag. Two callers
|
|
@@ -238,7 +238,7 @@ export function clientDoorState({ env = {}, unitDir, exists, active = null, list
|
|
|
238
238
|
// so a crash loop (`activating/auto-restart`) and a unit that was never started (`inactive/dead`)
|
|
239
239
|
// reduce to the same `false` and printed the same sentence — one is a fault to read the journal for,
|
|
240
240
|
// the other is a connect that stopped half-way.
|
|
241
|
-
listening, activeState, subState, standing, fenceOn, unitInstalled, active, serving: standing && active === true };
|
|
241
|
+
listening, ownListener, activeState, subState, standing, fenceOn, unitInstalled, active, serving: standing && active === true };
|
|
242
242
|
}
|
|
243
243
|
|
|
244
244
|
/**
|
|
@@ -300,10 +300,17 @@ export function describeDoorState(door, {
|
|
|
300
300
|
// reader whose foreground door was up to run the command they had just run — and it is NOT enough
|
|
301
301
|
// to call it theirs: the product's ports are fixed defaults, so on a shared box the answer may be
|
|
302
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.` };
|
|
303
312
|
return { level: "info",
|
|
304
|
-
text:
|
|
305
|
-
+ "installed, so either this install is running in the foreground (it stops when that terminal "
|
|
306
|
-
+ `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." };
|
|
307
314
|
}
|
|
308
315
|
return { level: "info",
|
|
309
316
|
text: `the client door is not set up here — ${missing}. \`${startCmd}\` writes both. Since the `
|
|
@@ -313,11 +320,16 @@ export function describeDoorState(door, {
|
|
|
313
320
|
if (door.active === null) {
|
|
314
321
|
// THE PROBE STILL COUNTS HERE. Not asking systemd is not the same as knowing nothing: if the port
|
|
315
322
|
// answers, the door is serving whatever systemd would have said.
|
|
316
|
-
if (door.listening === true) {
|
|
323
|
+
if (door.listening === true && door.ownListener === true) {
|
|
317
324
|
return { level: "ok",
|
|
318
325
|
text: `${unit} is installed and account access is enabled, and the client door's port is `
|
|
319
326
|
+ "answering — systemd was not asked, so this is the port's word rather than the unit's" };
|
|
320
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
|
+
}
|
|
321
333
|
return { level: "info",
|
|
322
334
|
text: `${unit} is installed and account access is enabled — whether it is RUNNING was not checked, `
|
|
323
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
|
*
|
|
@@ -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/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
|
|