clearotron 0.3.0-beta.6 → 0.3.0-beta.8
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 +4 -3
- package/README.md +28 -21
- package/bin/example.mjs +14 -2
- package/bin/onboard.mjs +49 -16
- package/bin/start.mjs +23 -5
- package/build-info.json +2 -2
- package/demo/full-country-search/run/status.json +1 -1
- package/demo/global-preliminary-search/run/status.json +1 -1
- package/demo/knockout-search/run/email-body.md +1 -1
- package/demo/knockout-search/run/status.json +2 -2
- package/demo/multi-country-focus-search/run/status.json +1 -1
- package/docs/architecture/04-configuration-reference.md +1 -1
- package/driver/CHANGELOG.md +18 -0
- package/driver/demo-container.mjs +42 -7
- package/driver/package.json +1 -1
- package/driver/portal-service.mjs +6 -3
- package/driver/suite-census.json +24 -6
- package/mcp-server/CHANGELOG.md +8 -0
- package/mcp-server/package.json +1 -1
- package/package.json +1 -1
- package/portal-ui/dist/assets/{index-DBUVdQT-.js → index-qes7gLpM.js} +2 -1
- package/portal-ui/dist/index.html +1 -1
- package/portal-ui/package.json +1 -1
- package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/shared/access-audience.mjs +7 -6
- package/shared/brand.mjs +7 -3
- package/shared/invocation.mjs +31 -2
- package/shared/permanent-install.mjs +71 -1
package/.env.example
CHANGED
|
@@ -561,8 +561,9 @@ CLEAROTRON_DEMO_PROFILES=
|
|
|
561
561
|
# Your organisation's name. `npm run setup` asks for it (and, on a local install, not for a sign-in
|
|
562
562
|
# address) and writes it here, quoted, so a name holding a `#` survives. The first `clearotron start` files it as the first
|
|
563
563
|
# organisation in the grants file (CLEAROTRON_ACCESS_FILE) when that file holds none; from then on the
|
|
564
|
-
# grants file holds the name
|
|
565
|
-
# organisation
|
|
566
|
-
#
|
|
564
|
+
# grants file holds the name and renaming is an edit there. The portal also reads it, to name the
|
|
565
|
+
# organisation on its top bar and on its sign-in refusal page. Unset or empty, no organisation is
|
|
566
|
+
# invented. `clearotron start --organisation <name>` supplies it for one start, and a demo never reads
|
|
567
|
+
# it. Read by bin/start.mjs and shared/brand.mjs.
|
|
567
568
|
# effect: deployment
|
|
568
569
|
CLEAROTRON_ORGANISATION_NAME=
|
package/README.md
CHANGED
|
@@ -19,33 +19,41 @@ no gateway, no platform, and nothing about your matters reaches us.
|
|
|
19
19
|
|
|
20
20
|
## Install
|
|
21
21
|
|
|
22
|
-
**See it work first, with nothing
|
|
22
|
+
**See it work first, with nothing installed.**
|
|
23
23
|
|
|
24
24
|
```bash
|
|
25
25
|
npx clearotron demo
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
-
That fetches the published package — it will ask once before downloading — then replays
|
|
29
|
-
|
|
30
|
-
calls to us.
|
|
28
|
+
That fetches the published package — it will ask once before downloading — then replays finished
|
|
29
|
+
clearances into a local portal and prints the portal's address and the passphrase to sign in with. Open
|
|
30
|
+
the address in your browser. No account, no credentials, no network calls to us.
|
|
31
31
|
|
|
32
32
|
**Then install it.**
|
|
33
33
|
|
|
34
34
|
```bash
|
|
35
|
-
|
|
35
|
+
npx clearotron install
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
-
Node 22.13 or newer, on macOS or Linux.
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
38
|
+
Node 22.13 or newer, on macOS or Linux. It needs no root: it puts the program under `~/.local`, with the
|
|
39
|
+
`clearotron` command in `~/.local/bin`, then asks one question at a time and checks each credential
|
|
40
|
+
before it saves it. With `~/.local/bin` on your `PATH`, every command below works in the short form;
|
|
41
|
+
otherwise use the full path `install` prints at the end. **On Windows the demo above runs natively; a
|
|
42
|
+
real clearance needs WSL2.** Native Windows clearances are planned for a later release. Until then the
|
|
43
|
+
engine does not run on native Windows: it resolves the reasoning CLI the POSIX way, and a clearance
|
|
44
|
+
started there refuses at preflight.
|
|
45
|
+
|
|
46
|
+
`npm install -g clearotron` also works where npm's global directory is yours to write. On a Linux Node
|
|
47
|
+
from the distribution or NodeSource that directory is `/usr`, owned by root, and npm refuses with
|
|
48
|
+
`EACCES`. Do not answer that with `sudo`; install under your home instead:
|
|
49
|
+
`npm install -g clearotron --prefix ~/.local`.
|
|
42
50
|
|
|
43
|
-
|
|
44
|
-
was published. If you want the newest code instead, a beta is published whenever there is
|
|
45
|
-
testing — deliberately, days apart, not on every merge:
|
|
51
|
+
`npx clearotron install` gives you the **stable** release — the one that has run a real clearance end to
|
|
52
|
+
end before it was published. If you want the newest code instead, a beta is published whenever there is
|
|
53
|
+
something worth testing — deliberately, days apart, not on every merge:
|
|
46
54
|
|
|
47
55
|
```bash
|
|
48
|
-
|
|
56
|
+
npx clearotron@beta install
|
|
49
57
|
```
|
|
50
58
|
|
|
51
59
|
What each channel promises, and when a stable is cut: [docs/RELEASES.md](docs/RELEASES.md). If you are not
|
|
@@ -53,17 +61,17 @@ sure, the first command is the one you want.
|
|
|
53
61
|
|
|
54
62
|
## Quick start
|
|
55
63
|
|
|
56
|
-
|
|
57
|
-
names whatever is still missing:
|
|
64
|
+
With it installed, check what it found before it does anything. `doctor` only reads — it writes nothing,
|
|
65
|
+
calls nobody, and names whatever is still missing:
|
|
58
66
|
|
|
59
67
|
```bash
|
|
60
|
-
|
|
68
|
+
clearotron doctor
|
|
61
69
|
```
|
|
62
70
|
|
|
63
|
-
Then start the product and open the portal it prints:
|
|
71
|
+
Then start the product and open the portal address it prints:
|
|
64
72
|
|
|
65
73
|
```bash
|
|
66
|
-
|
|
74
|
+
clearotron start
|
|
67
75
|
```
|
|
68
76
|
|
|
69
77
|
That is the portal a brand owner uses. Ordering a clearance is the same screen — describe it in a
|
|
@@ -82,11 +90,10 @@ rights-holders behind them by jurisdiction:
|
|
|
82
90
|
|
|
83
91
|

|
|
84
92
|
|
|
85
|
-
Then run your own
|
|
93
|
+
Then run your own: order it in the portal, or hand the engine a job file:
|
|
86
94
|
|
|
87
95
|
```bash
|
|
88
|
-
|
|
89
|
-
npx clearotron run --job my-job.json
|
|
96
|
+
clearotron run --job my-job.json
|
|
90
97
|
```
|
|
91
98
|
|
|
92
99
|
## How it fits together
|
package/bin/example.mjs
CHANGED
|
@@ -47,6 +47,7 @@ import { spawn } from "node:child_process";
|
|
|
47
47
|
import { BRAND } from "../shared/brand.mjs"; // — the installer's own name, from the tenant seam
|
|
48
48
|
import { envFrom } from "../shared/env-aliases.mjs"; // — resolves EITHER spelling; names the retired one because that is the live-writable half
|
|
49
49
|
import { isFrozen, demoChildren, publishSource } from "../driver/demo-container.mjs"; // — one definition of what a frozen demo is, for the player AND the gate
|
|
50
|
+
import { ensureDemoProgram, demoProgramEnv } from "../shared/permanent-install.mjs";
|
|
50
51
|
|
|
51
52
|
const REPO = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
52
53
|
|
|
@@ -369,8 +370,19 @@ console.log(` Removing this demo later is one directory: ${removeDirectory(dem
|
|
|
369
370
|
strayFromAnOlderDemo();
|
|
370
371
|
console.log("");
|
|
371
372
|
|
|
372
|
-
|
|
373
|
-
|
|
373
|
+
// ── RUN FROM NPX, THE SERVICES RUN FROM THE DEMO'S OWN COPY ─────────────────────────────────────────
|
|
374
|
+
//
|
|
375
|
+
// Started from npm's cache, the supervisor printed every command it gives the reader as `cd <npm's cache>
|
|
376
|
+
// && npx clearotron …`: the passphrase reset, the key, the free-port hint, `start --background`. Each one
|
|
377
|
+
// failed once npm cleaned that cache (measured on a published beta, 2026-09-11). So the copy the demo
|
|
378
|
+
// keeps in `<base>/program` is laid down first and the supervisor is started from it, which also puts the
|
|
379
|
+
// portal and the doors on a program that outlives the cache. Everywhere else, and when the copy cannot be
|
|
380
|
+
// made, it starts from here as before.
|
|
381
|
+
const programRoot = ensureDemoProgram({ base: demoBase, say: (line) => console.log(line) });
|
|
382
|
+
const startFrom = programRoot ?? REPO;
|
|
383
|
+
const child = spawn(process.execPath, [join(startFrom, "bin", "start.mjs"), ...startArgs], {
|
|
384
|
+
cwd: startFrom, stdio: ["ignore", "inherit", "inherit"],
|
|
385
|
+
env: programRoot ? demoProgramEnv(process.env) : process.env,
|
|
374
386
|
});
|
|
375
387
|
child.on("error", (e) => die(`demo: could not start the portal: ${String(e?.message ?? e)}`));
|
|
376
388
|
// Its exit code is the demo's. A supervisor that swallowed a child's refusal would report a demo that
|
package/bin/onboard.mjs
CHANGED
|
@@ -63,7 +63,7 @@ import { createInterface } from "node:readline/promises";
|
|
|
63
63
|
import { stdin as input, stdout as output } from "node:process";
|
|
64
64
|
import { accessSync, constants, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync, chmodSync } from "node:fs"; // read the process table here; moved that to shared/process-table.mjs
|
|
65
65
|
import { homedir, userInfo } from "node:os";
|
|
66
|
-
import { invocationPrefix } from "../shared/invocation.mjs"; // — one rule for how the reader invokes us
|
|
66
|
+
import { invocationPrefix, installRoute, reachableCommand } from "../shared/invocation.mjs"; // — one rule for how the reader invokes us
|
|
67
67
|
import { nodeFloorVerdict } from "../shared/node-floor.mjs"; // — the floor is package.json engines, not a constant here
|
|
68
68
|
import { invocationForm } from "../shared/invocation.mjs"; // — and WHY that form
|
|
69
69
|
import { standFrom } from "../shared/invocation.mjs"; // is this tree one npm replaces?
|
|
@@ -130,6 +130,12 @@ function readIfPresent(path) {
|
|
|
130
130
|
}
|
|
131
131
|
|
|
132
132
|
const REPO = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
133
|
+
// A PACKAGED INSTALL IS NOT A CHECKOUT. doctor says which one it is talking to, and names only commands
|
|
134
|
+
// that run there: npm's scripts and a checkout's tree are not where a package's reader stands.
|
|
135
|
+
const PACKAGED = installRoute(REPO) === "packaged";
|
|
136
|
+
const THIS_TREE = PACKAGED ? "the installed package" : "this checkout";
|
|
137
|
+
// The example job, by the path it has in THIS install, so the printed command runs from any directory.
|
|
138
|
+
const EXAMPLE_JOB = join(REPO, "examples", "job.euipo.json");
|
|
133
139
|
const ENV_PATH = envLocalPath({ repoRoot: REPO }); // resolved, never composed: one resolver, so moving this file later is one line
|
|
134
140
|
// WHAT IS READ IS NOT ALWAYS WHERE THE NEXT WRITE GOES. An install configured before the move still
|
|
135
141
|
// has its file at the old path, and the loader still reads it — so every READ here asks
|
|
@@ -1105,7 +1111,10 @@ export async function runCheck() {
|
|
|
1105
1111
|
say(" Restart whatever supervises them; this command does not, because which supervisor owns");
|
|
1106
1112
|
say(" them is a property of your deployment and not of this checkout.");
|
|
1107
1113
|
} else if (running.state === "unknown") {
|
|
1108
|
-
|
|
1114
|
+
// A PACKAGED INSTALL HAS NO TREE TO BE BEHIND. npm replaces the whole package on an update, so this
|
|
1115
|
+
// question belongs to a checkout, and on a package it is a `!` nothing can ever clear.
|
|
1116
|
+
if (PACKAGED) info("running programs against a checkout's tree: not applicable to a packaged install");
|
|
1117
|
+
else warn(`could not tell whether running programs are on the current tree: ${running.detail}`);
|
|
1109
1118
|
}
|
|
1110
1119
|
|
|
1111
1120
|
// ── AND THE MORE DANGEROUS ANSWER: A DIFFERENT TREE, NOT AN OLDER ONE ──────────────────────────────
|
|
@@ -1137,7 +1146,8 @@ export async function runCheck() {
|
|
|
1137
1146
|
if (elsewhere.programs.length > 6) say(` … and ${elsewhere.programs.length - 6} more`);
|
|
1138
1147
|
say(" Either point the install back at the tree they run from, or restart them onto this one.");
|
|
1139
1148
|
} else if (elsewhere.state === "unknown") {
|
|
1140
|
-
|
|
1149
|
+
if (PACKAGED && !configuredTree) info("running programs on a different checkout: not applicable to a packaged install");
|
|
1150
|
+
else warn(`could not tell whether running programs are on a different checkout: ${elsewhere.detail}`);
|
|
1141
1151
|
} else if (elsewhere.state === "unplaced") {
|
|
1142
1152
|
// PRINTED, because the alternative is silence that reads as a clean answer. Nothing here was placed
|
|
1143
1153
|
// on a tree, which is not the same as everything being on the right one, and the reader is the only
|
|
@@ -1343,7 +1353,7 @@ export async function runCheck() {
|
|
|
1343
1353
|
const installMode = engineMode(engineInventory(invEnv));
|
|
1344
1354
|
if (installMode === ENGINE_MODES.DEMO) {
|
|
1345
1355
|
info("MODE: demo — everything works except starting a NEW search. The example report, its audit trail "
|
|
1346
|
-
+
|
|
1356
|
+
+ `and the MCP connection are live right now; \`${reachableCommand("demo")}\` needs no engine.`);
|
|
1347
1357
|
// NAMES THE COMMAND, AND THE RESTART. The settings page says both now, and a doctor that named
|
|
1348
1358
|
// the program but not how to install it — or that left out the restart, which is what actually
|
|
1349
1359
|
// unsticks a reader who has just installed it — would be the third opinion this issue exists to
|
|
@@ -1548,9 +1558,9 @@ export async function runCheck() {
|
|
|
1548
1558
|
{
|
|
1549
1559
|
const inCheckout = (p) => isInsideCheckout(p, REPO);
|
|
1550
1560
|
for (const [name, what] of [
|
|
1551
|
-
["CLEAROTRON_CUSTOMERS_DIR",
|
|
1552
|
-
["CLEAROTRON_INSTRUCTIONS_DIR",
|
|
1553
|
-
["PROFILE_REPO_ROOT",
|
|
1561
|
+
["CLEAROTRON_CUSTOMERS_DIR", `customers resolve to the bundled demo roster IN ${THIS_TREE}`],
|
|
1562
|
+
["CLEAROTRON_INSTRUCTIONS_DIR", `doctrine resolves to the bundled files IN ${THIS_TREE}`],
|
|
1563
|
+
["PROFILE_REPO_ROOT", `the portal's profile editor commits INTO ${THIS_TREE}`],
|
|
1554
1564
|
]) {
|
|
1555
1565
|
// — WHICH NAME THE READER IS TOLD IS NOT ONE QUESTION, IT IS TWO.
|
|
1556
1566
|
//
|
|
@@ -1891,7 +1901,8 @@ export async function runCheck() {
|
|
|
1891
1901
|
info(`the units are installed but their environment could not be read (${unitEnv?.why ?? "no reason given"}) — `
|
|
1892
1902
|
+ "whether the services read an overlay is not judged here");
|
|
1893
1903
|
}
|
|
1894
|
-
if (report.ok && report.overlayConfigured)
|
|
1904
|
+
if (report.ok && report.overlayConfigured)
|
|
1905
|
+
say(`\n Full detail: ${PACKAGED ? `node ${join(REPO, "scripts", "doctrine-report.mjs")}` : "npm run doctrine-report"}`);
|
|
1895
1906
|
} catch (e) {
|
|
1896
1907
|
// An unreadable overlay THROWS by design (config.resolveSkillPath refuses rather than falling back
|
|
1897
1908
|
// to the product's copy). Surfaced here rather than allowed to abort the whole check: the doctor's
|
|
@@ -2224,15 +2235,26 @@ export async function runCheck() {
|
|
|
2224
2235
|
say("\n Portal sign-in");
|
|
2225
2236
|
{
|
|
2226
2237
|
const { authView } = await import("../driver/portal-config-view.mjs");
|
|
2238
|
+
// A BOX WITH NO UNITS RUNS THE PORTAL `clearotron start` LAUNCHES, as the line below says, and start
|
|
2239
|
+
// hands that portal `PORTAL_AUTH_MODE=local` and refuses any other declared mode (bin/start.mjs). So on
|
|
2240
|
+
// such a box the door is start's, not the bare service's fronted default. Reading the bare default told
|
|
2241
|
+
// a fresh home that the portal would refuse to start and that nobody could use it; `start` then came
|
|
2242
|
+
// up on the local sign-in and created the grants file (measured on a published beta, 2026-09-11).
|
|
2243
|
+
const startLaunched = !hosted;
|
|
2244
|
+
const declaredAuth = String(effectiveForService("PORTAL_AUTH_MODE")?.v ?? "").trim();
|
|
2227
2245
|
const door = authView({
|
|
2228
|
-
mode:
|
|
2246
|
+
mode: startLaunched ? "local" : declaredAuth,
|
|
2229
2247
|
oidcIssuer: effectiveForService("PORTAL_OIDC_ISSUER")?.v ?? "",
|
|
2230
2248
|
team: effectiveForService("CF_ACCESS_TEAM")?.v ?? "",
|
|
2231
2249
|
jwksUrl: effectiveForService("PORTAL_JWKS_URL")?.v ?? "",
|
|
2232
2250
|
emailClaim: effectiveForService("PORTAL_EMAIL_CLAIM")?.v ?? "",
|
|
2233
2251
|
authHeader: effectiveForService("PORTAL_AUTH_HEADER")?.v ?? "",
|
|
2234
2252
|
});
|
|
2235
|
-
const typed =
|
|
2253
|
+
const typed = declaredAuth ? `PORTAL_AUTH_MODE=${declaredAuth}`
|
|
2254
|
+
: startLaunched ? "PORTAL_AUTH_MODE is unset, and `start` runs the local sign-in" : "PORTAL_AUTH_MODE is unset";
|
|
2255
|
+
if (startLaunched && declaredAuth && declaredAuth.toLowerCase() !== "local")
|
|
2256
|
+
problem(`${typed}: \`${reachableCommand("start")}\` refuses it, because start is the local install `
|
|
2257
|
+
+ "and runs the local sign-in; that mode belongs to a hosted deployment's units");
|
|
2236
2258
|
// NOT A TICK, AND THAT IS THE POINT. This reads the environment THIS command is
|
|
2237
2259
|
// typed in. `bin/start.mjs` INJECTS `PORTAL_AUTH_MODE: "local"` into the portal's own environment,
|
|
2238
2260
|
// and a systemd unit's EnvironmentFile can name a third thing — so a green tick here was a
|
|
@@ -2297,7 +2319,13 @@ export async function runCheck() {
|
|
|
2297
2319
|
const { makePrincipal } = await import("../driver/portal-access.mjs");
|
|
2298
2320
|
// ASKED OF THE SERVICE'S OWN ENVIRONMENT. Reading this command's file here is what produced a hard
|
|
2299
2321
|
// ✗ claiming nobody could use a portal that was admitting its operator on every request.
|
|
2300
|
-
|
|
2322
|
+
// ON A BOX `start` RUNS, an unset grants file is the one start creates on its first run, admitting
|
|
2323
|
+
// the person who started it (`paths.grants` in bin/start.mjs). Before that run it does not exist,
|
|
2324
|
+
// which is a first start still to come, not a portal nobody can use.
|
|
2325
|
+
const namedGrants = effectiveForService("CLEAROTRON_ACCESS_FILE")?.v ?? "";
|
|
2326
|
+
const startGrants = startLaunched && !namedGrants ? startPaths({ env: {} }).grants : null;
|
|
2327
|
+
const beforeFirstStart = Boolean(startGrants) && !existsSync(startGrants);
|
|
2328
|
+
const grantsFile = namedGrants || (beforeFirstStart ? "" : startGrants ?? "");
|
|
2301
2329
|
let grants = null, unreadable = null;
|
|
2302
2330
|
if (grantsFile) {
|
|
2303
2331
|
try { grants = JSON.parse(readFileSync(grantsFile, "utf8")); }
|
|
@@ -2310,7 +2338,10 @@ export async function runCheck() {
|
|
|
2310
2338
|
// A FAILURE TO LOOK IS NOT A LOCKOUT. On a hosted box whose unit environment
|
|
2311
2339
|
// could not be read, every name above resolves empty — which is indistinguishable from a box that
|
|
2312
2340
|
// has genuinely configured nothing, and would print the loudest ✗ in this command on no evidence.
|
|
2313
|
-
if (
|
|
2341
|
+
if (beforeFirstStart) {
|
|
2342
|
+
info(`no grants file yet: the first \`${reachableCommand("start")}\` creates ${startGrants} and gives the person `
|
|
2343
|
+
+ "who runs it access to everything");
|
|
2344
|
+
} else if (!serviceKnown) {
|
|
2314
2345
|
info("who may use this portal is not judged here: the units' environment could not be read, so a "
|
|
2315
2346
|
+ "grants file configured there would be invisible to this check");
|
|
2316
2347
|
} else if (unreadable) {
|
|
@@ -3945,11 +3976,13 @@ try {
|
|
|
3945
3976
|
|
|
3946
3977
|
say(`\n ${style.bold("Start here:")}\n`);
|
|
3947
3978
|
say(` ${invocationPrefix()}clearotron start\n`);
|
|
3948
|
-
say(" Starts the portal and the engine door
|
|
3949
|
-
say(" the product: you order a clearance from it and read the report there.\n");
|
|
3979
|
+
say(" Starts the portal and the engine door and prints one address to open in your browser. That");
|
|
3980
|
+
say(" address is the product: you order a clearance from it and read the report there.\n");
|
|
3950
3981
|
say(` ${style.dim(`Also: \`${invocationPrefix()}clearotron demo\` replays a finished report with no keys and no model calls;`)}`);
|
|
3951
|
-
say(` ${style.dim(`\`${invocationPrefix()}clearotron run --job
|
|
3952
|
-
|
|
3982
|
+
say(` ${style.dim(`\`${invocationPrefix()}clearotron run --job ${/\s/.test(EXAMPLE_JOB) ? `"${EXAMPLE_JOB}"` : EXAMPLE_JOB}\` runs a first real clearance on the EU register.`)}`);
|
|
3983
|
+
// THE OLD WAY IS A CHECKOUT'S. A package has no npm scripts where its reader stands.
|
|
3984
|
+
if (!PACKAGED) say(` ${style.dim("Each still works the old way too — `npm start`, `npm run example`, `node driver/pipeline.mjs`.")}`);
|
|
3985
|
+
say("");
|
|
3953
3986
|
|
|
3954
3987
|
// WHY THOSE LINES LOOK THE WAY THEY DO, when they are not the bare verb.
|
|
3955
3988
|
//
|
package/bin/start.mjs
CHANGED
|
@@ -111,6 +111,7 @@ async function runTables() {
|
|
|
111
111
|
import { spawn, spawnSync, execFileSync } from "node:child_process";
|
|
112
112
|
import { storeInRepo, storeOutsideRepoMessage, storeCommitRefusal } from "../shared/store-in-repo.mjs"; //
|
|
113
113
|
import { stdioConnectOffer } from "../shared/stdio-connect.mjs";
|
|
114
|
+
import { ensureDemoProgram } from "../shared/permanent-install.mjs"; // — a demo from npx keeps its own copy
|
|
114
115
|
import { mergeEnvFile } from "../shared/env-file-merge.mjs";
|
|
115
116
|
import { mcpOriginFor } from "../shared/lane-address.mjs"; // — one author for the origin
|
|
116
117
|
import { SERVER_INSTALL_SET, unitsToRestartOnRefresh, unitHealthVerdict } from "../shared/server-units.mjs"; // — one authority, two callers
|
|
@@ -122,7 +123,7 @@ import { createServer } from "node:net";
|
|
|
122
123
|
import { listenErrorMessage, nextFreePort } from "../shared/listen.mjs";
|
|
123
124
|
import { chmodSync, copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
124
125
|
import { randomBytes } from "node:crypto";
|
|
125
|
-
import { invocationPrefix, invoke } from "../shared/invocation.mjs"; // — the banner names the verb
|
|
126
|
+
import { invocationPrefix, invoke, reachableCommand } from "../shared/invocation.mjs"; // — the banner names the verb
|
|
126
127
|
import { unitEnvPath } from "../shared/env-local.mjs"; // — the file the units read, named once
|
|
127
128
|
import { homedir, userInfo } from "node:os";
|
|
128
129
|
import { dirname, join } from "node:path";
|
|
@@ -756,6 +757,10 @@ export function childEnv({ ports, paths, user, portalSecret, tokenSecret, opsTok
|
|
|
756
757
|
// already wrong for a process that is not the portal.
|
|
757
758
|
...(demo ? {
|
|
758
759
|
CLEAROTRON_DEMO: "1",
|
|
760
|
+
// A DEMO NAMES NO ORGANISATION. The portal reads this to name the one running the install, on its
|
|
761
|
+
// top bar and its sign-in refusal page, and an exported value is the reader's real organisation.
|
|
762
|
+
// The portal's alone: no door names an organisation, and the doors stay as a live install's.
|
|
763
|
+
CLEAROTRON_ORGANISATION_NAME: "",
|
|
759
764
|
// AND THE SIGN-IN CREDENTIAL LIVES IN THE DEMO'S OWN BASE. Without this it defaults to
|
|
760
765
|
// ~/.cordillera/portal-local-credential.json — shared with every install on the box — and the
|
|
761
766
|
// demo then inherits a digest minted for somebody else's address: the portal prints "the
|
|
@@ -1248,7 +1253,7 @@ if (isMain) {
|
|
|
1248
1253
|
say(` grants ${paths.grants} named nobody, so ${user} was added: Run, Manage, access to everything`);
|
|
1249
1254
|
if (seeded.changed.includes("organisation")) say(` organisation "${organisation}", filed there — rename it there too`);
|
|
1250
1255
|
else if (seeded.changed.includes("person") && !Object.keys(seeded.grants.tenants ?? {}).length)
|
|
1251
|
-
say(` no organisation named yet — \`${
|
|
1256
|
+
say(` no organisation named yet — \`${reachableCommand('start --organisation "<name>"')}\` files the first one`);
|
|
1252
1257
|
// REPORTED, NOT REPAIRED. A file that names other people is somebody's decision, and adding this
|
|
1253
1258
|
// address to it would give access to everything to an address nobody enrolled.
|
|
1254
1259
|
if (seeded.unadmitted)
|
|
@@ -1389,8 +1394,9 @@ if (isMain) {
|
|
|
1389
1394
|
const { publishSource, seedDemoRuns } = await import("../driver/demo-container.mjs");
|
|
1390
1395
|
const seed = await seedPool({ pool: paths.pool, examplesDir: publishSource(join(REPO, "demo"), { repoRoot: REPO }), republish: republishRun });
|
|
1391
1396
|
// AND AS RUNS, so the assistant this demo's connect line wires has them to list, brief and open. Under
|
|
1392
|
-
// the demo's own workspace only: nothing of it reaches an install started afterwards.
|
|
1393
|
-
|
|
1397
|
+
// the demo's own workspace only: nothing of it reaches an install started afterwards. Their report
|
|
1398
|
+
// links are stamped with this portal's address, the one the Open line prints.
|
|
1399
|
+
const runs = seedDemoRuns({ workspace: paths.workspace, examplesDir: join(REPO, "demo"), portalOrigin: `http://${HOST}:${ports.portal}` });
|
|
1394
1400
|
if (runs.seeded.length) say(` runs ${runs.seeded.length} sample run(s) your assistant can list, brief and open`);
|
|
1395
1401
|
// WHAT WAS ALREADY THERE IS SAID TOO. This branch used to run only when the pool
|
|
1396
1402
|
// was empty; it now tops a stale pool up to the package's set, so "seeded 1" on an upgrade is a fact
|
|
@@ -1451,6 +1457,17 @@ if (isMain) {
|
|
|
1451
1457
|
// the file the verb resets is the file the portal reads. A demo keeps its own, by layout (childEnv).
|
|
1452
1458
|
const { installCredential } = await import("../driver/portal-local-auth.mjs");
|
|
1453
1459
|
const signIn = DEMO ? null : installCredential({ base: paths.base, env: process.env, firstStart: firstStartOfThisInstall });
|
|
1460
|
+
// ── A DEMO RUN FROM NPX KEEPS ITS OWN COPY OF THE PROGRAM ───────────────────────────────────────────
|
|
1461
|
+
//
|
|
1462
|
+
// Its connect line launched the connector from npm's cache, which npm deletes when it cleans up, so an
|
|
1463
|
+
// assistant registered with it lost the demo without a word (measured on a published beta, 2026-09-11).
|
|
1464
|
+
// The same version goes into `<base>/program` (shared/permanent-install.mjs), BEFORE the services start
|
|
1465
|
+
// so the portal's connect rows name it too, and only when it is not there already. It never stops the
|
|
1466
|
+
// demo: if npm fails, the demo runs as before and says the line will not survive a cache clean.
|
|
1467
|
+
// `clearotron demo` makes the copy before it starts this file, and starts this file FROM the copy, so
|
|
1468
|
+
// there this finds nothing to do; it acts for a `start --demo` typed at npx directly.
|
|
1469
|
+
const demoProgramRoot = DEMO ? ensureDemoProgram({ base: paths.base, say }) : null;
|
|
1470
|
+
|
|
1454
1471
|
const envs = childEnv({ ports, paths, user, portalSecret, tokenSecret, opsToken,
|
|
1455
1472
|
localWorker: wantWorker, demo: DEMO, clientFence: declaredFence || null,
|
|
1456
1473
|
credential: signIn && signIn.source !== "shared" ? signIn.path : null });
|
|
@@ -2233,7 +2250,8 @@ if (isMain) {
|
|
|
2233
2250
|
// and a line of instruction with more than one author drifts silently.
|
|
2234
2251
|
// THE WORKSPACE AND POOL THE SERVICES WERE HANDED, not this process's environment: a demo reads no env
|
|
2235
2252
|
// file, so its own line named no workspace and the connector fell back to the real install's.
|
|
2236
|
-
|
|
2253
|
+
// A demo run from npx names its own copy of the program, which a cache clean does not remove.
|
|
2254
|
+
const connect = stdioConnectOffer({ workDir: paths.workspace, reportsDir: paths.pool, ...(demoProgramRoot ? { installRoot: demoProgramRoot } : {}) });
|
|
2237
2255
|
say(" Connect your assistant to this install — one line, no address and no sign-in:");
|
|
2238
2256
|
say("");
|
|
2239
2257
|
say(` ${connect.command}`);
|
package/build-info.json
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"stepTotal": 9,
|
|
28
28
|
"lastStage": "publish",
|
|
29
29
|
"verdict": "CONDITIONAL",
|
|
30
|
-
"url": "
|
|
30
|
+
"url": "/portal/report/tmpdemo2014fullcountrysearch-venqori-2026-09-03-sample-capture/",
|
|
31
31
|
"failedStage": null,
|
|
32
32
|
"reason": null,
|
|
33
33
|
"deliveredAt": "2026-09-03T18:06:17.281Z",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"stepTotal": 9,
|
|
28
28
|
"lastStage": "publish",
|
|
29
29
|
"verdict": "CONDITIONAL",
|
|
30
|
-
"url": "
|
|
30
|
+
"url": "/portal/report/tmpdemo2014globalpreliminarysearch-venqori-2026-09-02-sample-capture/",
|
|
31
31
|
"failedStage": null,
|
|
32
32
|
"reason": null,
|
|
33
33
|
"deliveredAt": "2026-09-02T17:33:15.625Z",
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
<p style="font-family:Calibri,sans-serif;font-size:11pt">Knockout trademark review — DEMO-2014-knockout-search: <b>1 mark</b>, worst band <b style="color:#2f55a4">Low</b>. <a href="
|
|
1
|
+
<p style="font-family:Calibri,sans-serif;font-size:11pt">Knockout trademark review — DEMO-2014-knockout-search: <b>1 mark</b>, worst band <b style="color:#2f55a4">Low</b>. <a href="/portal/report/tmpdemo2014knockoutsearch-venqori-2026-09-02-sample-capture/">Open the full report</a> · <a href="/portal/report/tmpdemo2014knockoutsearch-venqori-2026-09-02-sample-capture/audit.xlsx">audit workbook</a>.</p>
|
|
2
2
|
<ul style="font-family:Calibri,sans-serif;font-size:11pt"><li><b>VENQORI</b>: <b style="color:#2f55a4">Low</b> — Classes 9, 42, 41 — Register filings (classes 9, 42, 41): 0 identical, 0 containing, 0 on close variations.</li></ul>
|
|
3
3
|
<p style="font-family:Calibri,sans-serif;font-size:11pt"><i>Ratings reflect our common law assessment. Register analysis may adjust ratings in either direction.</i></p>
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"stepN": 6,
|
|
34
34
|
"stepTotal": 6,
|
|
35
35
|
"verdict": "Low",
|
|
36
|
-
"url": "
|
|
36
|
+
"url": "/portal/report/tmpdemo2014knockoutsearch-venqori-2026-09-02-sample-capture/",
|
|
37
37
|
"failedStage": null,
|
|
38
38
|
"reason": null,
|
|
39
39
|
"deliveredAt": "2026-09-02T21:46:53.238Z",
|
|
@@ -254,7 +254,7 @@
|
|
|
254
254
|
"reports": [
|
|
255
255
|
{
|
|
256
256
|
"mark": "VENQORI",
|
|
257
|
-
"url": "
|
|
257
|
+
"url": "/portal/report/tmpdemo2014knockoutsearch-venqori-2026-09-02-sample-capture/"
|
|
258
258
|
}
|
|
259
259
|
],
|
|
260
260
|
"sendPending": true,
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"stepTotal": 9,
|
|
28
28
|
"lastStage": "publish",
|
|
29
29
|
"verdict": "CONDITIONAL",
|
|
30
|
-
"url": "
|
|
30
|
+
"url": "/portal/report/tmpdemo2014multicountryfocussearch-venqori-2026-09-02-sample-capture/",
|
|
31
31
|
"failedStage": null,
|
|
32
32
|
"reason": null,
|
|
33
33
|
"deliveredAt": "2026-09-02T19:51:48.801Z",
|
|
@@ -285,7 +285,7 @@ because the rule is about what PRODUCT CODE reads, not about what a run reads.
|
|
|
285
285
|
| `CLEAROTRON_DEMO` | unset | `1` puts this install in the DEMO posture. **Ordering is real**: the four products are listed and orderable, the form, the plan and the confirmation are the product's own, and the confirmation resolves to a finished report that already exists rather than dispatching — no engine turn, no register call, no queue entry, no run directory (ruling 2026-08-31, superseding the greyed-control ruling of the same day). A product the demo carries no finished report for refuses and names which one. It also re-aims two boot warnings written for an operator of a real deployment at the visitor who is not one, from one place (`driver/demo-posture.mjs`). **Set by `npx clearotron demo`, not by an operator** — it is passed explicitly to the two processes that have a reason to know (the portal and the MCP door; the worker is not told, because a demo never queues anything for it to drain), and those run with `CLEAROTRON_NO_ENV_FILE=1`, so a stray `.env` can neither put a live install into demo mode nor take a demo out of one. Anything but the literal `1` is not a demo. Replaces `PORTAL_DEMO`, which named only one of the processes that has to know. |
|
|
286
286
|
| `CLEAROTRON_TEST_FIXTURE_PROFILES` | unset | `1` makes the profile loader return the three suite fixtures, which are refused from every roster otherwise. Set by `scripts/test-run.mjs`, never by an operator; an explicit `includeTestFixtures` argument beats it. Effect class `harness`; the full contract is its row in `.env.example`. |
|
|
287
287
|
| `CLEAROTRON_DEMO_PROFILES` | unset | `1` makes the profile loader return the bundled demo account, which a fresh install does not resolve (ruling 2026-09-08). Set by `clearotron demo`, `start --demo` and the suite runner, never by an operator. The gate is on the bundled layer, so a deployment's own configured store keeps its `demoData` accounts either way. Effect class `harness`; the full contract is its row in `.env.example`. |
|
|
288
|
-
| `CLEAROTRON_ORGANISATION_NAME` | unset | Your organisation's name, written quoted by `npx clearotron install`, which asks for it and not for a sign-in address. The first `clearotron start` files it as the first organisation in the grants file (`CLEAROTRON_ACCESS_FILE`) when that file holds none; from then on the grants file holds the name
|
|
288
|
+
| `CLEAROTRON_ORGANISATION_NAME` | unset | Your organisation's name, written quoted by `npx clearotron install`, which asks for it and not for a sign-in address. The first `clearotron start` files it as the first organisation in the grants file (`CLEAROTRON_ACCESS_FILE`) when that file holds none; from then on the grants file holds the name and renaming is an edit there. The portal also reads it, to name the organisation on its top bar and on its sign-in refusal page. Unset ⇒ no organisation is invented. `clearotron start --organisation <name>` supplies it for one start, and a demo never reads it. Effect class `deployment`; the full contract is its row in `.env.example`. |
|
|
289
289
|
| `PORTAL_LOCAL_CREDENTIAL` | `~/.cordillera/portal-local-credential.json` | Where local sign-in keeps its passphrase DIGEST. `clearotron start` points it inside the install's own base directory for a new install, so it mints its own passphrase instead of adopting a digest another install left; an install that has been signing in with the shared file keeps it. `npx clearotron demo` always points it inside the demo's own base directory and mints a new passphrase there on every start, so a demo never inherits a digest minted for another address, and removing the demo stays one `rm -rf`. |
|
|
290
290
|
| `PORTAL_LOCAL_PASSPHRASE` | unset | **NEVER set this in a file.** An internal one-shot handoff, not an operator control: on a first FOREGROUND start the supervisor mints the passphrase and hands it to the portal it spawns *at the spawn call*, so the closing summary can print the value beside the address rather than sending a first-time reader back into eleven startup log lines for the one value in this product that cannot be read back. It is deliberately absent from the composed child environments, because that composition is what `--background` writes into the units' env file — a passphrase there would be a permanent plaintext copy on disk and the product's own sentence, "it is stored only as a digest", would stop being true. Setting it in any env file recreates exactly that. Lost passphrase: `clearotron passphrase --reset`. |
|
|
291
291
|
| `PORTAL_URL` | `http://127.0.0.1:18802`, or built from `PORTAL_SERVICE_HOST`/`PORTAL_SERVICE_PORT` | Where the deploy tick's live-surface check expects to reach the portal. |
|
package/driver/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# clearotron-driver
|
|
2
2
|
|
|
3
|
+
## 0.3.0-beta.8
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 9181d6a: Fixed: Commands the install, start and the New company screen print now run as printed, from any directory and after npm cleans its cache.
|
|
8
|
+
- 0d75b4a: Fixed: A demo started with npx prints commands that keep working after npm cleans its cache.
|
|
9
|
+
- 9181d6a: Fixed: Doctor, run before the first start, describes the local sign-in that start brings up, and drops checkout-only warnings on a packaged install.
|
|
10
|
+
- 91888b2: Fixed: The package no longer names the hosted service's own hostnames; a deployment names its own.
|
|
11
|
+
- 9181d6a: Fixed: The README installs with `npx clearotron install`, which needs no root, and no longer says the demo opens a browser.
|
|
12
|
+
|
|
13
|
+
## 0.3.0-beta.7
|
|
14
|
+
|
|
15
|
+
### Patch Changes
|
|
16
|
+
|
|
17
|
+
- c5744bf: Fixed: A demo started with npx keeps its assistant connection working after npm cleans its cache.
|
|
18
|
+
- c5744bf: Fixed: An assistant connected to the demo now opens each sample report on the demo's own portal, not on another server.
|
|
19
|
+
- c5744bf: Fixed: The portal names the organisation setup recorded, and the product keeps its own name beside it.
|
|
20
|
+
|
|
3
21
|
## 0.3.0-beta.6
|
|
4
22
|
|
|
5
23
|
### Patch Changes
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
// stage apart. This module is the single answer. `cut/` cannot import it (that directory does not travel
|
|
24
24
|
// and this one does), so the pack gate restates the disjunction and its own test pins the two together.
|
|
25
25
|
|
|
26
|
-
import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync } from "node:fs";
|
|
26
|
+
import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
27
27
|
import { tmpdir } from "node:os";
|
|
28
28
|
import { dirname, join, resolve, sep } from "node:path";
|
|
29
29
|
|
|
@@ -89,9 +89,17 @@ export function publishSource(dir, { repoRoot, tmp = tmpdir() } = {}) {
|
|
|
89
89
|
* anywhere else, so a real install started afterwards sees none of it (the demo is its own install).
|
|
90
90
|
*
|
|
91
91
|
* Copied, never linked: a connector reading a run may write beside it, and `demo/` is tracked. A run
|
|
92
|
-
* already in place is
|
|
92
|
+
* already in place is not copied again, so a visitor's second start changes nothing but its links.
|
|
93
|
+
*
|
|
94
|
+
* ITS REPORT LINKS POINT AT THIS DEMO'S PORTAL. Each sample's `status.json` carried the report URL it was
|
|
95
|
+
* stamped with where it was captured, a test instance's address, and the connector hands a run's `url` to
|
|
96
|
+
* the assistant as-is, so an assistant asked to open a demo report sent the person to that host (measured
|
|
97
|
+
* on a published beta, 2026-09-11). The tracked samples now carry the portal's own route,
|
|
98
|
+
* `/portal/report/<runId>/`, with no host, and each copy here is stamped with `portalOrigin`, the demo
|
|
99
|
+
* portal's address. Stamped on EVERY start, the copies already in place included: `--port` moves the
|
|
100
|
+
* portal, and a copy laid down by an earlier version still carries the old host.
|
|
93
101
|
*/
|
|
94
|
-
export function seedDemoRuns({ workspace, examplesDir }) {
|
|
102
|
+
export function seedDemoRuns({ workspace, examplesDir, portalOrigin = null }) {
|
|
95
103
|
const seeded = [], already = [];
|
|
96
104
|
for (const name of demoChildren(examplesDir)) {
|
|
97
105
|
const run = join(examplesDir, name, "run");
|
|
@@ -99,10 +107,37 @@ export function seedDemoRuns({ workspace, examplesDir }) {
|
|
|
99
107
|
try { s = JSON.parse(readFileSync(join(run, "status.json"), "utf8")); } catch { continue; }
|
|
100
108
|
if (!s?.slug || !s?.codename || !s?.date) continue;
|
|
101
109
|
const dir = join(workspace, `workspace-${s.agent || "clawdi"}`, "studio", "prelim-search", s.slug, `${s.date}-${s.codename}`);
|
|
102
|
-
if (existsSync(join(dir, "status.json")))
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
110
|
+
if (existsSync(join(dir, "status.json"))) already.push(s.runId);
|
|
111
|
+
else {
|
|
112
|
+
mkdirSync(dirname(dir), { recursive: true });
|
|
113
|
+
cpSync(run, dir, { recursive: true });
|
|
114
|
+
seeded.push(s.runId);
|
|
115
|
+
}
|
|
116
|
+
if (portalOrigin) stampReportLinks(join(dir, "status.json"), portalOrigin);
|
|
106
117
|
}
|
|
107
118
|
return { seeded, already };
|
|
108
119
|
}
|
|
120
|
+
|
|
121
|
+
/** The portal route that serves a run's report: the one `scanAccountRuns` hands the portal's own list. */
|
|
122
|
+
export const reportRoute = (runId) => `/portal/report/${encodeURIComponent(runId)}/`;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Point a seeded run's report links at `origin`. A link already on the portal's route keeps its path; any
|
|
126
|
+
* other (the host an older sample carried) becomes the run's own route. Written only when something moved.
|
|
127
|
+
*/
|
|
128
|
+
export function stampReportLinks(statusFile, origin) {
|
|
129
|
+
let s;
|
|
130
|
+
try { s = JSON.parse(readFileSync(statusFile, "utf8")); } catch { return false; }
|
|
131
|
+
if (!s?.runId) return false;
|
|
132
|
+
const base = String(origin).replace(/\/+$/, "");
|
|
133
|
+
const local = (url) => {
|
|
134
|
+
const path = String(url ?? "").replace(/^https?:\/\/[^/]+/, "");
|
|
135
|
+
return `${base}${path.startsWith("/portal/report/") ? path : reportRoute(s.runId)}`;
|
|
136
|
+
};
|
|
137
|
+
const next = { ...s, url: local(s.url) };
|
|
138
|
+
if (Array.isArray(s.reports)) next.reports = s.reports.map((r) => (r && typeof r === "object" ? { ...r, url: local(r.url) } : r));
|
|
139
|
+
const text = `${JSON.stringify(next, null, 2)}\n`;
|
|
140
|
+
if (text === `${JSON.stringify(s, null, 2)}\n`) return false;
|
|
141
|
+
writeFileSync(statusFile, text);
|
|
142
|
+
return true;
|
|
143
|
+
}
|
package/driver/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "clearotron-driver",
|
|
3
3
|
"private": true,
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.3.0-beta.
|
|
5
|
+
"version": "0.3.0-beta.8",
|
|
6
6
|
"license": "AGPL-3.0-only",
|
|
7
7
|
"description": "Deterministic driver for the trademark clearance workflow: orchestration in code (fan-out, fan-in barrier, gating, retries); the model does judgment leaves only, through a reasoning CLI spawned per stage.",
|
|
8
8
|
"engines": {
|
|
@@ -38,10 +38,9 @@ import { readFileSync, existsSync, readdirSync, statSync } from "node:fs";
|
|
|
38
38
|
import { storeInRepo, storeOutsideRepoMessage, makeCommittableAudit, resolveStoreRepoRoot, makeStoreCommit } from "../shared/store-in-repo.mjs"; //,
|
|
39
39
|
import { customerStoreDir, customerStoreLine } from "../shared/customer-store.mjs"; // — one store for the surface and the runs
|
|
40
40
|
import { clientFailureNote } from "../shared/client-failure-note.mjs"; // — one sentence, three surfaces
|
|
41
|
-
import { bareInvocation, invocationPrefix, installRoute } from "../shared/invocation.mjs"; // — and why this one surface is by NAME
|
|
41
|
+
import { bareInvocation, browserCommand, invocationPrefix, installRoute, npxVersionOf } from "../shared/invocation.mjs"; // — and why this one surface is by NAME
|
|
42
42
|
import { stdioConnectOffer, stdioConnectFor, STDIO_SHAPES } from "../shared/stdio-connect.mjs"; // — ONE author for the connect route
|
|
43
43
|
import { isWsl } from "../shared/wsl.mjs"; // — on WSL, the connect lines say where they run
|
|
44
|
-
import { inNpxCache, ownVersion } from "../shared/permanent-install.mjs"; // — a demo from npx is reset through npx
|
|
45
44
|
import { connectOffers, offersForWire } from "../shared/connect-clients.mjs"; // — ONE table, resolved server-side
|
|
46
45
|
|
|
47
46
|
/**
|
|
@@ -1534,6 +1533,10 @@ export function makePortalService({
|
|
|
1534
1533
|
// half its readers. A WORD, never a command line and never a prefix: `invocationForm` can
|
|
1535
1534
|
// answer with this machine's absolute path, and this value is rendered in a browser.
|
|
1536
1535
|
setupRoute: installRoute(),
|
|
1536
|
+
// THE COMMAND THAT FILES AN ORGANISATION, for the screen that cannot create a company without one.
|
|
1537
|
+
// `browserCommand`'s rule: no path on this machine, and from npx's cache the published version
|
|
1538
|
+
// rather than a bare name that reader does not have.
|
|
1539
|
+
organisationCommand: browserCommand('start --organisation "<name>"'),
|
|
1537
1540
|
// — a button that always fails must not render as available. The reason is
|
|
1538
1541
|
// operator-shaped and staff-only; a client reads the generic sentence the button carries.
|
|
1539
1542
|
controls: { stop: { available: stopControl.available !== false,
|
|
@@ -4838,7 +4841,7 @@ const PORT = PORT_CHOICE.port;
|
|
|
4838
4841
|
attempts: makeAttemptLimiter({ max: 10, windowMs: 5 * 60 * 1000 }),
|
|
4839
4842
|
// The sign-in page's recovery line. It resets the file this portal reads, and names no path on this
|
|
4840
4843
|
// machine, because the page is read before anyone has signed in (signInResetCommand says how).
|
|
4841
|
-
resetCommand: signInResetCommand({ credentialPath, npxVersion:
|
|
4844
|
+
resetCommand: signInResetCommand({ credentialPath, npxVersion: npxVersionOf() }),
|
|
4842
4845
|
};
|
|
4843
4846
|
})();
|
|
4844
4847
|
|
package/driver/suite-census.json
CHANGED
|
@@ -970,9 +970,9 @@
|
|
|
970
970
|
"todos": 0
|
|
971
971
|
},
|
|
972
972
|
"an-install-from-npx-moves-out-of-the-cache.test.mjs": {
|
|
973
|
-
"tests":
|
|
974
|
-
"asserts":
|
|
975
|
-
"skips":
|
|
973
|
+
"tests": 13,
|
|
974
|
+
"asserts": 78,
|
|
975
|
+
"skips": 7,
|
|
976
976
|
"todos": 0
|
|
977
977
|
},
|
|
978
978
|
"an-mcp-route-says-which-challenge-it-answers-with.test.mjs": {
|
|
@@ -2745,6 +2745,12 @@
|
|
|
2745
2745
|
"skips": 0,
|
|
2746
2746
|
"todos": 0
|
|
2747
2747
|
},
|
|
2748
|
+
"no-internal-hostname-ships-in-the-package.test.mjs": {
|
|
2749
|
+
"tests": 1,
|
|
2750
|
+
"asserts": 4,
|
|
2751
|
+
"skips": 0,
|
|
2752
|
+
"todos": 0
|
|
2753
|
+
},
|
|
2748
2754
|
"no-nul-bytes.test.mjs": {
|
|
2749
2755
|
"tests": 3,
|
|
2750
2756
|
"asserts": 7,
|
|
@@ -3089,7 +3095,7 @@
|
|
|
3089
3095
|
},
|
|
3090
3096
|
"portal-service.test.mjs": {
|
|
3091
3097
|
"tests": 124,
|
|
3092
|
-
"asserts":
|
|
3098
|
+
"asserts": 671,
|
|
3093
3099
|
"skips": 1,
|
|
3094
3100
|
"todos": 0
|
|
3095
3101
|
},
|
|
@@ -4408,8 +4414,8 @@
|
|
|
4408
4414
|
"todos": 0
|
|
4409
4415
|
},
|
|
4410
4416
|
"the-bar-names-no-organisation-nobody-named.test.mjs": {
|
|
4411
|
-
"tests":
|
|
4412
|
-
"asserts":
|
|
4417
|
+
"tests": 6,
|
|
4418
|
+
"asserts": 13,
|
|
4413
4419
|
"skips": 0,
|
|
4414
4420
|
"todos": 0
|
|
4415
4421
|
},
|
|
@@ -4503,6 +4509,12 @@
|
|
|
4503
4509
|
"skips": 0,
|
|
4504
4510
|
"todos": 0
|
|
4505
4511
|
},
|
|
4512
|
+
"the-demo-links-its-reports-to-its-own-portal.test.mjs": {
|
|
4513
|
+
"tests": 3,
|
|
4514
|
+
"asserts": 13,
|
|
4515
|
+
"skips": 0,
|
|
4516
|
+
"todos": 0
|
|
4517
|
+
},
|
|
4506
4518
|
"the-door-answers-the-address-it-advertises.test.mjs": {
|
|
4507
4519
|
"tests": 9,
|
|
4508
4520
|
"asserts": 37,
|
|
@@ -4575,6 +4587,12 @@
|
|
|
4575
4587
|
"skips": 0,
|
|
4576
4588
|
"todos": 0
|
|
4577
4589
|
},
|
|
4590
|
+
"the-first-hour-reads-true.test.mjs": {
|
|
4591
|
+
"tests": 8,
|
|
4592
|
+
"asserts": 41,
|
|
4593
|
+
"skips": 0,
|
|
4594
|
+
"todos": 0
|
|
4595
|
+
},
|
|
4578
4596
|
"the-fold-receipt-names-the-model.test.mjs": {
|
|
4579
4597
|
"tests": 5,
|
|
4580
4598
|
"asserts": 11,
|
package/mcp-server/CHANGELOG.md
CHANGED
package/mcp-server/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trademark-artifacts-mcp",
|
|
3
|
-
"version": "0.3.0-beta.
|
|
3
|
+
"version": "0.3.0-beta.8",
|
|
4
4
|
"license": "AGPL-3.0-only",
|
|
5
5
|
"private": true,
|
|
6
6
|
"description": "MCP server to interrogate clearotron trademark-clearance runs — list/read artifacts, trace the full decision flow, telemetry/cost, coverage, single-run search, and a gated single-step what-if. Imports the clearotron-driver read-only; touches no driver/template/deploy files.",
|
package/package.json
CHANGED
|
@@ -10508,6 +10508,7 @@ var api = {
|
|
|
10508
10508
|
}),
|
|
10509
10509
|
accountOrgs: Object.fromEntries(Object.entries(asRecord(b["accountOrgs"])).filter(([, v]) => typeof v === "string" && v)),
|
|
10510
10510
|
genericOrgs: asArray(b["genericOrgs"]).filter((o) => typeof o === "string" && o.length > 0),
|
|
10511
|
+
organisationCommand: asString(b["organisationCommand"]) || "clearotron start --organisation \"<name>\"",
|
|
10511
10512
|
email: asString(b["email"]) ?? "",
|
|
10512
10513
|
accounts: asArray(b["accounts"]).filter((a) => typeof a === "string"),
|
|
10513
10514
|
allAccounts: b["accounts"] === "*",
|
|
@@ -19106,7 +19107,7 @@ function NewCompany({ ctx }) {
|
|
|
19106
19107
|
if (!raw.trim()) return [];
|
|
19107
19108
|
return parseLines(raw, spec.commaSeparated ?? false).filter((e) => !spec.item.ok(e));
|
|
19108
19109
|
});
|
|
19109
|
-
const unmet = !typedName ? "Needs a name." : !key ? "Needs a key — type one below." : !orgChosen ? orgsHeld.length === 0 ?
|
|
19110
|
+
const unmet = !typedName ? "Needs a name." : !key ? "Needs a key — type one below." : !orgChosen ? orgsHeld.length === 0 ? `No organisation is filed on this install yet. File one with: ${ctx.me.organisationCommand}` : "Choose the organisation it belongs to." : refusedEntries.length ? `${refusedEntries.join(", ")} cannot be searched — fix or remove ${refusedEntries.length === 1 ? "it" : "them"}.` : null;
|
|
19110
19111
|
async function create() {
|
|
19111
19112
|
if (unmet || busy) return;
|
|
19112
19113
|
setBusy(true);
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
-->
|
|
50
50
|
<link rel="preconnect" href="https://api.fontshare.com" crossorigin />
|
|
51
51
|
<link href="https://api.fontshare.com/v2/css?f[]=satoshi@400,500,700,900&display=swap" rel="stylesheet" />
|
|
52
|
-
<script type="module" crossorigin src="/portal/assets/index-
|
|
52
|
+
<script type="module" crossorigin src="/portal/assets/index-qes7gLpM.js"></script>
|
|
53
53
|
<link rel="stylesheet" crossorigin href="/portal/assets/index-Cv-E_agg.css">
|
|
54
54
|
</head>
|
|
55
55
|
<body>
|
package/portal-ui/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "portal-ui",
|
|
3
3
|
"private": true,
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.3.0-beta.
|
|
5
|
+
"version": "0.3.0-beta.8",
|
|
6
6
|
"license": "AGPL-3.0-only",
|
|
7
7
|
"description": "The unified trademark portal UI. One address, one login: who you are decides what you see. Built as a static bundle, served by driver/portal-service.mjs — the browser never reaches profile-service or recipe-service.",
|
|
8
8
|
"engines": {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trademark-oauth-mcp-bridge",
|
|
3
|
-
"version": "0.3.0-beta.
|
|
3
|
+
"version": "0.3.0-beta.8",
|
|
4
4
|
"license": "AGPL-3.0-only",
|
|
5
5
|
"private": true,
|
|
6
6
|
"description": "OAuth 2.1 MCP stdio bridge used by the engine's case-law gather stage (courtlistener / legaldatahunter).",
|
|
@@ -135,13 +135,14 @@ export function readAudience({ location = "", status = null, error = null, wwwAu
|
|
|
135
135
|
if (error) return { kind: "unreachable", why: String(error?.message ?? error).slice(0, 200) };
|
|
136
136
|
// ── THREE DOORS, NOT ONE ────────────────────────────────────────────────────────────────────────
|
|
137
137
|
//
|
|
138
|
-
// This returned `not-fronted` for every response with no redirect, and measured against production
|
|
139
|
-
// four configured hostnames that one label covered three materially different states
|
|
138
|
+
// This returned `not-fronted` for every response with no redirect, and measured against a production
|
|
139
|
+
// deployment's four configured hostnames that one label covered three materially different states
|
|
140
|
+
// (the hosts are written here as placeholders; a deployment names its own):
|
|
140
141
|
//
|
|
141
|
-
//
|
|
142
|
-
// mcp.
|
|
143
|
-
// clients-mcp.
|
|
144
|
-
// agent-mcp.
|
|
142
|
+
// portal.example.com 302 redirect present → audience read, kid agrees
|
|
143
|
+
// mcp.example.com/mcp 401 no redirect → not-fronted ← FALSE, it IS fronted
|
|
144
|
+
// clients-mcp.example.com/mcp 401 no redirect → not-fronted ← FALSE, it IS fronted
|
|
145
|
+
// agent-mcp.example.com/mcp 502 no redirect → not-fronted ← an origin fault
|
|
145
146
|
//
|
|
146
147
|
// None of these was a false pass — every one returned ok:false, which is the property that matters
|
|
147
148
|
// most and is untouched. It was a WRONG DIAGNOSIS on a safe failure, and the cost is a reader's hour
|
package/shared/brand.mjs
CHANGED
|
@@ -87,10 +87,14 @@ export const BRAND = {
|
|
|
87
87
|
* installation, which on a fresh install has no answer, and answering it with the product's name puts
|
|
88
88
|
* "Organisation: Clearotron" on the top bar of every install that never set it.
|
|
89
89
|
*
|
|
90
|
-
* Null rather than an empty string, so a caller cannot render it by accident.
|
|
91
|
-
*
|
|
90
|
+
* Null rather than an empty string, so a caller cannot render it by accident.
|
|
91
|
+
*
|
|
92
|
+
* ITS OWN SETTING, `CLEAROTRON_ORGANISATION_NAME`, the one setup writes. This read `CLEAROTRON_BRAND_NAME`,
|
|
93
|
+
* which renames the PRODUCT: a wizard-configured organisation was never named, and an operator who set
|
|
94
|
+
* the brand to reach it got "This is Acme's Acme portal" with the title and the logo renamed too
|
|
95
|
+
* (measured on a published beta, 2026-09-11). The two differ on presence as well as on absence now.
|
|
92
96
|
*/
|
|
93
|
-
export const ORGANISATION_NAME = process.env.
|
|
97
|
+
export const ORGANISATION_NAME = process.env.CLEAROTRON_ORGANISATION_NAME?.trim() || null;
|
|
94
98
|
|
|
95
99
|
/**
|
|
96
100
|
* THE CONFIDENTIALITY POSTURE ON A DELIVERED DOCUMENT — one rule, both report templates.
|
package/shared/invocation.mjs
CHANGED
|
@@ -36,8 +36,8 @@
|
|
|
36
36
|
// below is not a resolver check: the shim is read and must NAME THIS INSTALL, and a `clearotron` found
|
|
37
37
|
// earlier on PATH demotes the bare form rather than confirming it. Identity, not availability. A later
|
|
38
38
|
// sweep tempted to simplify this into `command -v` would be reintroducing the defect, not tidying it.
|
|
39
|
-
import { existsSync } from "node:fs";
|
|
40
|
-
import { basename, sep } from "node:path";
|
|
39
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
40
|
+
import { basename, join, sep } from "node:path";
|
|
41
41
|
import { INSTALL_DIR, inspectShim, pathPosition, shimDir, shimPath } from "./verb-shim.mjs";
|
|
42
42
|
import { chdirPrefix } from "./os-advice.mjs";
|
|
43
43
|
|
|
@@ -272,6 +272,25 @@ export function invocationPrefix(argv1 = process.argv[1] ?? "", env = process.en
|
|
|
272
272
|
export const invoke = (verb, argv1 = process.argv[1] ?? "", env = process.env, io = FS, installDir = INSTALL_DIR) =>
|
|
273
273
|
`${invocationPrefix(argv1, env, io, installDir)}clearotron ${verb}`;
|
|
274
274
|
|
|
275
|
+
/** This install's version when it runs from npx's cache, read off its own manifest; `null` anywhere else. */
|
|
276
|
+
export function npxVersionOf(installDir = INSTALL_DIR, read = readFileSync) {
|
|
277
|
+
if (!/[\\/]_npx[\\/]/.test(String(installDir ?? ""))) return null;
|
|
278
|
+
try { return JSON.parse(read(join(installDir, "package.json"), "utf8")).version ?? null; } catch { return null; }
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* A command the reader can type from any directory that still runs after npm cleans its cache.
|
|
283
|
+
*
|
|
284
|
+
* `invoke` answers how this install is reached NOW. From npx's cache that is `cd <the cache> && npx
|
|
285
|
+
* clearotron …`, which stops working once npm cleans the cache, and a remedy is typed later, often after
|
|
286
|
+
* that. So from npx's cache this names the published version, `npx clearotron@<version> …`, which npm
|
|
287
|
+
* fetches again wherever it is typed. Everywhere else it is `invoke`'s answer.
|
|
288
|
+
*/
|
|
289
|
+
export function reachableCommand(verb, { argv1 = process.argv[1] ?? "", env = process.env, io = FS, installDir = INSTALL_DIR, read = readFileSync } = {}) {
|
|
290
|
+
const version = npxVersionOf(installDir, read);
|
|
291
|
+
return version ? `npx clearotron@${version} ${verb}` : invoke(verb, argv1, env, io, installDir);
|
|
292
|
+
}
|
|
293
|
+
|
|
275
294
|
/**
|
|
276
295
|
* The verb by NAME ONLY — no prefix, no path, no `npx`.
|
|
277
296
|
*
|
|
@@ -292,6 +311,16 @@ export const invoke = (verb, argv1 = process.argv[1] ?? "", env = process.env, i
|
|
|
292
311
|
*/
|
|
293
312
|
export const bareInvocation = (verb) => `clearotron ${verb}`;
|
|
294
313
|
|
|
314
|
+
/**
|
|
315
|
+
* A command for a page rendered in a browser: `bareInvocation`'s rule, never a path on this machine, with
|
|
316
|
+
* one exception. Run from npx's cache the bare name is one the reader does not have, so the published
|
|
317
|
+
* version is named instead, `npx clearotron@<version> …`, which runs anywhere and names no path.
|
|
318
|
+
*/
|
|
319
|
+
export const browserCommand = (verb, installDir = INSTALL_DIR, read = readFileSync) => {
|
|
320
|
+
const version = npxVersionOf(installDir, read);
|
|
321
|
+
return version ? `npx clearotron@${version} ${verb}` : bareInvocation(verb);
|
|
322
|
+
};
|
|
323
|
+
|
|
295
324
|
/**
|
|
296
325
|
* Refuse a prompt when there is nobody to answer it.
|
|
297
326
|
*
|
|
@@ -161,7 +161,77 @@ export function packagedUpdate({ installDir = INSTALL_DIR, version = ownVersion(
|
|
|
161
161
|
*/
|
|
162
162
|
export function stableInstallRoot({ installRoot = INSTALL_DIR, env = process.env, exists = existsSync } = {}) {
|
|
163
163
|
if (!inNpxCache(installRoot)) return installRoot;
|
|
164
|
+
const has = (root) => !!root && exists(join(root, "mcp-server", "server.mjs"));
|
|
165
|
+
// A DEMO'S OWN COPY FIRST: it is this version, and it lives inside the demo's base. The demo's children
|
|
166
|
+
// are handed `CLEAROTRON_DEMO=1` and their workspace, `<base>/workspace`, which is how they find it.
|
|
167
|
+
const work = String(env?.CLEAROTRON_WORK_DIR ?? "").trim();
|
|
168
|
+
const demoRoot = env?.CLEAROTRON_DEMO === "1" && work ? packageRootUnder(demoProgramPrefix(dirname(work))) : null;
|
|
169
|
+
if (has(demoRoot)) return demoRoot;
|
|
164
170
|
const prefix = permanentPrefix(env);
|
|
165
171
|
const root = prefix ? packageRootUnder(prefix) : null;
|
|
166
|
-
return
|
|
172
|
+
return has(root) ? root : installRoot;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* WHERE A DEMO RUN FROM NPX KEEPS ITS OWN COPY OF THE PROGRAM: inside the demo's base, never `~/.local`.
|
|
177
|
+
*
|
|
178
|
+
* `npx clearotron demo` ran from npm's cache like `install` did, so the connect line it printed launched
|
|
179
|
+
* the connector from `_npx/<hash>/`, which npm deletes when it cleans up (measured on a published beta,
|
|
180
|
+
* 2026-09-11). The install's answer, a copy under `~/.local`, would leave a trace of the demo outside the
|
|
181
|
+
* demo's directory; this copy lives in `<base>/program`, so removing the demo is still one directory.
|
|
182
|
+
*/
|
|
183
|
+
export function demoProgramPrefix(base) {
|
|
184
|
+
return join(base, "program");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* What `demo` does about its program. PURE given its inputs. `null` outside npx's cache; `{ skip }` on
|
|
189
|
+
* Windows, where npm's global layout differs, or with an unreadable version; otherwise a plan whose
|
|
190
|
+
* `current` says the copy is already this version, so a second start runs no npm at all.
|
|
191
|
+
*/
|
|
192
|
+
export function demoProgramPlan({ base, installDir = INSTALL_DIR, platform = process.platform, version = ownVersion(installDir), exists = existsSync, read = readFileSync } = {}) {
|
|
193
|
+
if (!inNpxCache(installDir)) return null;
|
|
194
|
+
if (platform === "win32") return { skip: "windows" };
|
|
195
|
+
if (!version) return { skip: "no-version" };
|
|
196
|
+
const prefix = demoProgramPrefix(base);
|
|
197
|
+
const root = packageRootUnder(prefix);
|
|
198
|
+
const current = exists(join(root, "mcp-server", "server.mjs")) && ownVersion(root, read) === version;
|
|
199
|
+
return {
|
|
200
|
+
prefix, root, version, current,
|
|
201
|
+
npmArgs: ["install", "--global", "--prefix", prefix, "--prefer-offline", "--no-fund", "--no-audit", `clearotron@${version}`],
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Lays the demo's copy down when it is missing or another version, and returns its root; `null` when there
|
|
207
|
+
* is no copy to use (not run from npx, or a copy that could not be made). It never stops the demo: if npm
|
|
208
|
+
* fails, the demo runs from npx's cache as before and says what that costs. `demoProgramPlan` decides;
|
|
209
|
+
* this is the one place that acts on it, for `demo` and for a `start --demo` run from npx.
|
|
210
|
+
*/
|
|
211
|
+
export function ensureDemoProgram({ base, say, env = process.env, run = spawnSync, exists = existsSync, plan = demoProgramPlan({ base }) }) {
|
|
212
|
+
if (plan?.current) return plan.root;
|
|
213
|
+
if (!plan || plan.skip) return null;
|
|
214
|
+
say(` program copying clearotron ${plan.version} into ${plan.prefix}, so the commands below and your assistant's connection survive npm cleaning its cache`);
|
|
215
|
+
// The npm that launched this, when npm says which: no second npm is guessed at.
|
|
216
|
+
const npmCli = env.npm_execpath;
|
|
217
|
+
const opts = { stdio: ["ignore", "ignore", "pipe"], encoding: "utf8", timeout: 180_000 };
|
|
218
|
+
const r = npmCli && exists(npmCli) ? run(process.execPath, [npmCli, ...plan.npmArgs], opts) : run("npm", plan.npmArgs, opts);
|
|
219
|
+
if (r.status === 0 && exists(join(plan.root, "mcp-server", "server.mjs"))) return plan.root;
|
|
220
|
+
const why = r.error ? r.error.message : String(r.stderr ?? "").trim().split("\n").pop() || `npm exited ${r.status}`;
|
|
221
|
+
say(` program could not be copied (${why}). The commands and the connect line below run from npm's temporary`);
|
|
222
|
+
say(" cache, so they stop working when npm cleans it; start the demo again to retry.");
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The environment a demo's services get when they run from the demo's copy. What makes their printed
|
|
228
|
+
* commands name the copy is running from it: `invocationPrefix` answers from the running install. npm's
|
|
229
|
+
* marks of an npx arrival (`npm_command=exec` among them) and the npx path the dispatcher hands down
|
|
230
|
+
* describe a process npm started, which the copy is not, so they are taken off, as `install` takes them
|
|
231
|
+
* off when it moves out of the cache.
|
|
232
|
+
*/
|
|
233
|
+
export function demoProgramEnv(env = process.env) {
|
|
234
|
+
const out = { ...env };
|
|
235
|
+
for (const k of ["npm_command", "npm_lifecycle_event", "npm_execpath", "CLEAROTRON_INVOKED_AS"]) delete out[k];
|
|
236
|
+
return out;
|
|
167
237
|
}
|