clearotron 0.3.0-beta.10 → 0.3.0-beta.2

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.
Files changed (75) hide show
  1. package/.env.example +3 -13
  2. package/INSTALL.md +6 -110
  3. package/README.md +21 -31
  4. package/bin/connect.mjs +26 -71
  5. package/bin/disconnect.mjs +11 -16
  6. package/bin/example.mjs +2 -14
  7. package/bin/key.mjs +5 -23
  8. package/bin/onboard.mjs +44 -191
  9. package/bin/start.mjs +33 -197
  10. package/bin/status.mjs +3 -23
  11. package/bin/stop.mjs +8 -23
  12. package/bin/update.mjs +6 -38
  13. package/build-info.json +2 -2
  14. package/demo/full-country-search/run/status.json +1 -1
  15. package/demo/global-preliminary-search/run/status.json +1 -1
  16. package/demo/knockout-search/run/email-body.md +1 -1
  17. package/demo/knockout-search/run/status.json +2 -2
  18. package/demo/multi-country-focus-search/run/status.json +1 -1
  19. package/docs/architecture/04-configuration-reference.md +1 -2
  20. package/driver/CHANGELOG.md +8 -107
  21. package/driver/compose-read.mjs +2 -21
  22. package/driver/contract-e3-backlog.mjs +1 -1
  23. package/driver/demo-container.mjs +2 -65
  24. package/driver/driver.config.mjs +0 -5
  25. package/driver/engine/anthropic-agent.mjs +17 -34
  26. package/driver/engine/jx-turn.mjs +1 -4
  27. package/driver/engine/openai-agent.mjs +2 -60
  28. package/driver/enqueue-schema.mjs +1 -3
  29. package/driver/gateway.mjs +15 -88
  30. package/driver/package.json +1 -1
  31. package/driver/portal-local-auth.mjs +3 -35
  32. package/driver/portal-service.mjs +22 -91
  33. package/driver/portal-upstream.mjs +1 -20
  34. package/driver/profile-service.mjs +3 -31
  35. package/driver/publish/index.mjs +9 -12
  36. package/driver/publish/knockout.mjs +2 -4
  37. package/driver/publish/office-record-links.mjs +21 -56
  38. package/driver/recipe-service.mjs +5 -14
  39. package/driver/record-origins.mjs +0 -14
  40. package/driver/search-policy.mjs +2 -7
  41. package/driver/suite-census.json +46 -154
  42. package/driver/systemd/render-units.mjs +13 -7
  43. package/mcp-server/CHANGELOG.md +2 -36
  44. package/mcp-server/CONNECT.md +2 -3
  45. package/mcp-server/lib/driver.mjs +0 -2
  46. package/mcp-server/lib/knockout.mjs +2 -2
  47. package/mcp-server/lib/options.mjs +5 -20
  48. package/mcp-server/package.json +1 -1
  49. package/package.json +1 -1
  50. package/portal-ui/dist/assets/{index-CwPAS0we.js → index-CcFjgM78.js} +383 -525
  51. package/portal-ui/dist/assets/{index-Cv-E_agg.css → index-CsCuPshD.css} +86 -199
  52. package/portal-ui/dist/index.html +2 -2
  53. package/portal-ui/package.json +1 -1
  54. package/providers/clarivate/src/core.js +0 -5
  55. package/providers/oauth-mcp-bridge/CHANGELOG.md +0 -32
  56. package/providers/oauth-mcp-bridge/package.json +1 -1
  57. package/scripts/e2e-unread-terminals.mjs +1 -1
  58. package/scripts/e2e.mjs +20 -197
  59. package/scripts/engine-probe.mjs +2 -2
  60. package/scripts/env-classify.mjs +0 -3
  61. package/scripts/revisit-render-check.mjs +4 -22
  62. package/scripts/travelling-predicates.mjs +1 -1
  63. package/shared/access-audience.mjs +6 -7
  64. package/shared/brand.mjs +3 -7
  65. package/shared/client-door.mjs +0 -39
  66. package/shared/connect-clients.mjs +321 -290
  67. package/shared/env-file-merge.mjs +0 -24
  68. package/shared/invocation.mjs +2 -49
  69. package/shared/names-in-force.mjs +0 -2
  70. package/shared/stdio-connect.mjs +14 -95
  71. package/shared/store-in-repo.mjs +2 -50
  72. package/shared/verb-shim.mjs +1 -10
  73. package/shared/permanent-install.mjs +0 -237
  74. package/shared/running-start.mjs +0 -73
  75. package/shared/wsl.mjs +0 -23
package/.env.example CHANGED
@@ -399,15 +399,6 @@ CLEAROTRON_DEMO=
399
399
  # effect: deployment
400
400
  # CLEAROTRON_INVOKED_AS=
401
401
 
402
- # Marks an install that has already moved itself out of npm's npx cache. `npx clearotron install` first
403
- # installs the same version under ~/.local and then runs that copy's install, with this set to 1, so the
404
- # copy does not try to move itself a second time. Unset, an install running from npx's cache moves.
405
- # Set by bin/onboard.mjs on the one process it starts, never by an operator, so the row is left
406
- # commented. It is listed for the reason CLEAROTRON_DEMO is: the catalogue is about what an operator can
407
- # FIND. Read by bin/onboard.mjs.
408
- # effect: deployment
409
- # CLEAROTRON_RELOCATED=
410
-
411
402
  # How many client what-ifs the runner drains at once (default 1). A what-if re-runs ONE stage of a run
412
403
  # that already exists, in a sandbox, at a client's request — the owner opened it to clients on
413
404
  # 2026-08-27 and ruled SPEND controls out with it ("ignore the call spend"). This is not one.
@@ -561,9 +552,8 @@ CLEAROTRON_DEMO_PROFILES=
561
552
  # Your organisation's name. `npm run setup` asks for it (and, on a local install, not for a sign-in
562
553
  # address) and writes it here, quoted, so a name holding a `#` survives. The first `clearotron start` files it as the first
563
554
  # organisation in the grants file (CLEAROTRON_ACCESS_FILE) when that file holds none; from then on the
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.
555
+ # grants file holds the name, renaming is an edit there, and this is not read. Unset or empty, no
556
+ # organisation is invented. `clearotron start --organisation <name>` supplies it for one start, and a
557
+ # demo never reads it. Read by bin/start.mjs.
568
558
  # effect: deployment
569
559
  CLEAROTRON_ORGANISATION_NAME=
package/INSTALL.md CHANGED
@@ -64,15 +64,6 @@ run is [mcp-server/CONNECT.md](mcp-server/CONNECT.md), and why something is the
64
64
  npx clearotron install
65
65
  ```
66
66
 
67
- Run from `npx`, the install first installs Clearotron under `~/.local`, as `npm install -g --prefix
68
- ~/.local` would, and finishes from there. That way the `clearotron` command and your assistant's connection
69
- do not point into npm's temporary cache, which npm replaces on an update and deletes when it cleans up.
70
- Later, the `update` command updates that copy in place.
71
-
72
- On WSL, a program on the Windows side can hold a port that WSL reports as free, and the browser reaches
73
- it first. VS Code's Remote-SSH port forwarding is the common case. If the page that opens is not this
74
- install's sign-in, run the same command again with `--port 28802`, or any free number.
75
-
76
67
  A *hosted* deployment needs Linux for one further thing, the systemd outbox trigger —
77
68
  [driver/systemd/README.md](driver/systemd/README.md).
78
69
  - **A reasoning CLI on your `PATH`, signed in.** This is the prerequisite people miss. Every stage runs
@@ -295,71 +286,6 @@ keeps working throughout.
295
286
  provider, so it is safe to run on any machine that has the repository. "Safe anywhere" is about spend,
296
287
  not about where it will run — on a packaged tree it refuses, as above.
297
288
 
298
- ## 2a. Removing it
299
-
300
- An install is not one directory, so removing it is not one command. Everything it writes is listed here.
301
- Read the list before you delete anything: one of these directories holds your reports.
302
-
303
- **Stop it first.** If the product is running in a terminal, Ctrl-C in that terminal. If you installed the
304
- background services, `clearotron stop` stops them and removes their unit files. `clearotron status` says
305
- which of the two you have.
306
-
307
- **The program.** Installed globally, `npm rm -g clearotron` removes it. Installed by `clearotron install`
308
- (which is also what a demo run from `npx` leaves behind), it lives in two places under `~/.local`:
309
-
310
- ```
311
- ~/.local/lib/node_modules/clearotron
312
- ~/.local/bin/clearotron
313
- ```
314
-
315
- **The settings, and the keys.** `~/.config/clearotron/.env` holds this install's signing secrets, so
316
- deleting it invalidates every key it ever issued. Beside it: `~/.config/clearotron/token-denylist`, the
317
- list of keys you revoked, and `~/.config/clearotron/running`, which records a product running in a
318
- terminal and is rewritten on every start.
319
-
320
- **Your work.** The install keeps it all under one directory, `~/trademark` by default — wherever
321
- `CLEAROTRON_REPORTS_DIR`'s parent points if you moved it:
322
-
323
- ```
324
- ~/trademark/pool THE REPORTS. Every clearance this install delivered.
325
- ~/trademark/workspace the working files of each run, including unfinished ones
326
- ~/trademark/queue work ordered and not yet run
327
- ~/trademark/outbox what the product prepared to send
328
- ~/trademark/locks which runs are in flight
329
- ~/trademark/config your companies, their projects and your saved searches
330
- ~/trademark/config/recipes the saved searches themselves
331
- ~/trademark/config/profiles the company profiles
332
- ~/trademark/grants.json who may use this install
333
- ~/trademark/portal-audit.log what the portal was asked to do
334
- ~/trademark/portal-local-credential.json the sign-in this install minted for you
335
- ```
336
-
337
- **Keep the pool if you want the reports.** They are finished documents and nothing else reads them: move
338
- `~/trademark/pool` somewhere of your own and delete the rest. Deleting the pool deletes the clearances.
339
-
340
- **The demo, if you ran one.** `~/trademark-demo` holds the demo's own copy of everything above, including
341
- its own copy of the program under `~/trademark-demo/program`. It is one directory to remove and it shares
342
- nothing with an install.
343
-
344
- **The background services, if you installed them.** `clearotron stop` removes the four unit files; if you
345
- would rather see them, they are in `~/.config/systemd/user`:
346
-
347
- ```
348
- clearotron-portal.service
349
- clearotron-mcp-face.service
350
- clearotron-worker.service
351
- clearotron-client-mcp.service
352
- ```
353
-
354
- **And `~/.env`, which only a background install writes.** A service inherits nothing from the terminal
355
- that installed it, so `clearotron start --background` writes everything those services need into `~/.env`,
356
- mode 600 — your register credential, your research key and the engine's settings among it. It is not the
357
- same file as `~/.config/clearotron/.env`, which configures the product when you run it yourself. Delete
358
- both, or you leave a file of credentials in your home for services that no longer exist.
359
-
360
- Nothing else on the machine is ours: no system-wide files, no services outside your own user, and nothing
361
- in `/etc`, `/usr` or `/var`.
362
-
363
289
  ## 3. Configuration (environment)
364
290
 
365
291
  Runtime configuration is by environment variable, and there are two ways to supply them.
@@ -565,25 +491,6 @@ matches, the neutral Generic default applies.
565
491
  - **Your real customers live outside the repo.** Point `CLEAROTRON_CUSTOMERS_DIR` at your own private
566
492
  config store and the engine loads *those* accounts instead. **Same engine, different config path** —
567
493
  the code carries no customer identities.
568
-
569
- Two things go with it, and both are refusals rather than preferences:
570
-
571
- - **`PROFILE_REPO_ROOT` moves too.** The customer directory has to sit inside the repository that
572
- variable names, because editing a profile is a commit. Point one somewhere new and leave the other
573
- behind and the portal and the profile service both refuse to start, naming both variables.
574
- - **That repository needs a `user.name` and a `user.email` of its own.** Saves are committed under
575
- the name of whoever asked for them, but git also records who *made* the commit, and it will not
576
- commit at all without one. A service account usually has no global git identity, so a store created
577
- by hand needs its own:
578
-
579
- ```bash
580
- git init -b main /srv/clearotron-store
581
- git -C /srv/clearotron-store config user.name "clearotron local install"
582
- git -C /srv/clearotron-store config user.email "clearotron@example.com"
583
- ```
584
-
585
- `clearotron start` does this for the store it creates. A store you make yourself does not get it,
586
- and the symptom is the first save failing at a commit rather than anything about profiles.
587
494
  - **Run data is external too.** Published reports, audits, and per-run state go to the archive pool at
588
495
  `CLEAROTRON_REPORTS_DIR`. Nothing customer-specific is committed to the repository.
589
496
 
@@ -823,13 +730,9 @@ Use the demo to see what this system produces. Use `npx clearotron start` to run
823
730
 
824
731
  - Generates `PORTAL_SECRET` and `TRADEMARK_MCP_TOKEN_SECRET` and **appends** them to `~/.config/clearotron/.env` at
825
732
  mode 600. Append, never rewrite: that file also holds the credentials `npx clearotron install` collected.
826
- It also records where saved searches are kept, `CLEAROTRON_RECIPES_DIR` and `RECIPE_REPO_ROOT`, so
827
- `clearotron doctor` and a connected assistant read the same saved searches as the portal.
828
733
  - Creates `~/trademark/` — `pool/`, `workspace/`, `queue/`, `outbox/`, `locks/`, an empty grants file,
829
734
  and a small git repository for saved searches. Same base directory `npx clearotron install` uses, so whichever
830
735
  of the two you ran first, the other finds the same install. Move it with `npx clearotron start --base <dir>`.
831
- That does not move anything the env file already names: the saved-search lines above, and the data
832
- directories `npx clearotron install` wrote, keep pointing at the old place until you edit them.
833
736
  - Mints your sign-in passphrase and **prints it once**. Write it down. It is stored as a scrypt digest in
834
737
  `~/trademark/portal-local-credential.json`, nothing can read it back, and no later start reprints it. To
835
738
  get a new one, run `clearotron passphrase --reset`. An install that has been signing in with
@@ -1000,19 +903,12 @@ their own integration work.
1000
903
  **Two shapes, and the line between them is what your assistant can do — not where it runs.** Ruling
1001
904
  2026-09-03, on the vendor's own documented behaviour:
1002
905
 
1003
- 1. **Clearotron is installed on the machine the assistant runs on** — Claude's desktop app, Claude Code,
1004
- Codex, the ChatGPT desktop app, an agent that runs commands. The assistant spawns the server over
1005
- stdio. No address, no key, no network, no ingress. `npx clearotron start` prints the one line to
1006
- paste, and `npx clearotron connect --where here` hands it over per assistant.
1007
- 2. **Clearotron is running somewhere else** — a server, a cloud machine, anywhere the assistant is
1008
- not. These need **a publicly reachable HTTPS address**, plus a key made for the person connecting.
1009
- Always. Claude (app, web, Cowork and mobile), ChatGPT on the web, Perplexity and other agents only
1010
- ever connect this way; Claude Code and Codex can connect either way. `npx clearotron connect
1011
- --where elsewhere` makes the key and prints the steps for the assistant you name.
1012
-
1013
- Without `--where`, `connect` asks when both answers are possible, and `--client <name>` on its own keeps
1014
- the answer that assistant had before: a key for Claude, the local line for Claude Code and Codex.
1015
- `npx clearotron disconnect` takes the same `--where`.
906
+ 1. **An assistant that can launch a local process** — Claude Code, Claude Desktop's local config,
907
+ Codex CLI, an agent that runs commands. It spawns the server over stdio. No address, no key, no
908
+ network, no ingress. `npx clearotron start` prints the one line to paste, and `npx clearotron
909
+ connect` hands it over per assistant.
910
+ 2. **Everything else, wherever it appears to run** — Cowork, ChatGPT, claude.ai, Perplexity, the mobile
911
+ apps. These need **a publicly reachable HTTPS address**, plus a key. Always.
1016
912
 
1017
913
  **A loopback address is never an answer for shape 2, and that is not about your network.** A remote MCP
1018
914
  connector is reached **from the vendor's cloud**, never from the reader's device. Anthropic's own help
package/README.md CHANGED
@@ -19,62 +19,51 @@ 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 installed.**
22
+ **See it work first, with nothing checked out.**
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 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.
28
+ That fetches the published package — it will ask once before downloading — then replays a finished
29
+ clearance into a local pool and opens the report in your browser. No account, no credentials, no network
30
+ calls to us.
31
31
 
32
32
  **Then install it.**
33
33
 
34
34
  ```bash
35
- npx clearotron install
35
+ npm install -g clearotron
36
36
  ```
37
37
 
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`.
38
+ Node 22.13 or newer, on macOS or Linux. That puts `clearotron` on your `PATH`; every command below
39
+ works in that short form. **On Windows the demo above runs natively; a real clearance needs WSL2.**
40
+ Native Windows clearances are planned for a later release. Until then the engine does not run on native
41
+ Windows: it resolves the reasoning CLI the POSIX way, and a clearance started there refuses at preflight.
50
42
 
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:
43
+ That command gives you the **stable** release — the one that has run a real clearance end to end before it
44
+ was published. If you want the newest code instead, a beta is published whenever there is something worth
45
+ testing — deliberately, days apart, not on every merge:
54
46
 
55
47
  ```bash
56
- npx clearotron@beta install
48
+ npm install -g clearotron@beta
57
49
  ```
58
50
 
59
51
  What each channel promises, and when a stable is cut: [docs/RELEASES.md](docs/RELEASES.md). If you are not
60
52
  sure, the first command is the one you want.
61
53
 
62
- Removing it later: **[INSTALL.md §2a](INSTALL.md#2a-removing-it)** lists every path an install writes, and
63
- says which one holds your reports so you can keep them deliberately.
64
-
65
54
  ## Quick start
66
55
 
67
- With it installed, check what it found before it does anything. `doctor` only reads — it writes nothing,
68
- calls nobody, and names whatever is still missing:
56
+ Check the install before it does anything. `doctor` only reads — it writes nothing, calls nobody, and
57
+ names whatever is still missing:
69
58
 
70
59
  ```bash
71
- clearotron doctor
60
+ npx clearotron doctor
72
61
  ```
73
62
 
74
- Then start the product and open the portal address it prints:
63
+ Then start the product and open the portal it prints:
75
64
 
76
65
  ```bash
77
- clearotron start
66
+ npx clearotron start
78
67
  ```
79
68
 
80
69
  That is the portal a brand owner uses. Ordering a clearance is the same screen — describe it in a
@@ -93,10 +82,11 @@ rights-holders behind them by jurisdiction:
93
82
 
94
83
  ![The conflict landscape, with rights-holders grouped by jurisdiction](docs/assets/portal-conflict-landscape.jpg)
95
84
 
96
- Then run your own: order it in the portal, or hand the engine a job file:
85
+ Then run your own. `install` asks one question at a time and checks each credential before it saves it:
97
86
 
98
87
  ```bash
99
- clearotron run --job my-job.json
88
+ npx clearotron install
89
+ npx clearotron run --job my-job.json
100
90
  ```
101
91
 
102
92
  ## How it fits together
package/bin/connect.mjs CHANGED
@@ -45,9 +45,8 @@ import { homedir, userInfo } from "node:os";
45
45
  import { fileURLToPath } from "node:url";
46
46
  import { execFileSync } from "node:child_process";
47
47
  import { createServer } from "node:net";
48
- import { CONNECT_CLIENTS, WHERE_FLAG, clientById, leadRouteFor, plainStep, whatItNeeds } from "../shared/connect-clients.mjs";
48
+ import { CONNECT_CLIENTS, clientById, whatItNeeds } from "../shared/connect-clients.mjs";
49
49
  import { stdioConnectFor, STDIO_SHAPES } from "../shared/stdio-connect.mjs";
50
- import { isWsl } from "../shared/wsl.mjs";
51
50
  import { defaultDenylistPath, clientDoorAddress, clientDoorPort, clientDoorState, enablePlan, applyEnablePlan, describeChange, recordConnectKey, CLIENT_DOOR_UNIT } from "../shared/client-door.mjs";
52
51
  import { mintToken, tokenId, resolvePerson, loadGrants } from "../shared/scope.mjs";
53
52
  import { envFrom } from "../shared/env-aliases.mjs";
@@ -206,8 +205,7 @@ function deploymentHas(env = process.env) {
206
205
  return {
207
206
  // EVERY SHAPE, RESOLVED ONCE. A row picks its own; nothing here knows a client's name.
208
207
  stdioRoutes: Object.fromEntries(Object.keys(STDIO_SHAPES).map((shape) =>
209
- [shape, stdioConnectFor(shape, { workDir: env.CLEAROTRON_WORK_DIR || null, reportsDir: env.CLEAROTRON_REPORTS_DIR || null })])),
210
- wsl: isWsl({ env }),
208
+ [shape, stdioConnectFor(shape, { workDir: env.CLEAROTRON_WORK_DIR || null })])),
211
209
  // WHERE THE DOOR BINDS — not an address handed to any assistant. It is the loopback address the
212
210
  // unit listens on, and `enablePlan` needs it to write the unit. It used to be passed to the
213
211
  // resolver as `localAddress` and served to Cowork as somewhere to connect, which is the false
@@ -557,12 +555,17 @@ async function render(offer, have, { dryRun, running, allowMove = false }) {
557
555
  return 1;
558
556
  }
559
557
 
560
- if (offer.route === "disk") {
561
- // THE ROW'S OWN STEPS, and nothing written here. A command and a settings block are not the same
562
- // instruction — saying "run this" over a TOML block is how a reader pastes four lines into a shell
563
- // and the steps already say which each one is and where it goes.
564
- printSteps(offer, null);
558
+ if (offer.route === "disk" || (offer.route === "either" && offer.command)) {
559
+ // A COMMAND AND A CONFIG BLOCK ARE NOT THE SAME INSTRUCTION, and saying "run this" over a TOML
560
+ // block is how a reader pastes four lines into a shell. The shape says which it is.
561
+ const s = offer.stdio;
562
+ say(s?.kind === "config"
563
+ ? ` Add this to ${s.where}:`
564
+ : " Run this once, on this machine:");
565
565
  say("");
566
+ for (const line of String(offer.command).split("\n")) say(` ${line}`);
567
+ say("");
568
+ if (s?.after) { say(` ${s.after}`); say(""); }
566
569
  say(` ${offer.note}`);
567
570
  return 0;
568
571
  }
@@ -618,40 +621,22 @@ async function render(offer, have, { dryRun, running, allowMove = false }) {
618
621
  }
619
622
 
620
623
  say(` Address: ${offer.address}`);
621
- // Printed once, stored nowhere — and only here when no step hands it over. A step whose copy carries
622
- // the key prints it inside the line the reader pastes, which is the one place it is needed.
623
- const keyInSteps = (offer.steps ?? []).some((s) => s.copy?.kind === "secret");
624
- if (key && !keyInSteps) say(` Key: ${key}`);
625
- printSteps(offer, key);
626
- say("");
627
- say(` ${offer.note}`);
628
- return 0;
629
- }
630
-
631
- /**
632
- * The offer's own steps, numbered, with each copy printed under the step that hands it over.
633
- *
634
- * ── AND WHERE TO PUT THEM ( — F35) ───────────────────────────────────────
635
- *
636
- * The owner was left with two strings and no destination: *"I don't know how to connect it in Claude
637
- * Cowork with those details."* The steps were DEFINED IN THE PRODUCT the whole time and this verb simply
638
- * never printed them. Nothing is authored here; a second set of instructions written at the CLI would
639
- * drift from the page's, which is the defect connect-clients-are-data exists against. A secret copy is
640
- * printed with the key this press minted put in its slot — the page does the same substitution into the
641
- * clipboard — and with no key (a dry run) the step prints and its copy does not.
642
- */
643
- function printSteps(offer, key) {
624
+ if (key) say(` Key: ${key}`); // printed once, stored nowhere
625
+ // ── AND WHERE TO PUT THEM ( F35) ───────────────────────────────────────
626
+ //
627
+ // The owner was left with two strings and no destination: *"I don't know how to connect it in Claude
628
+ // Cowork with those details."* The steps were DEFINED IN THE PRODUCT the whole time — `withSteps`
629
+ // computes them for every offer, interpolating this install's own address and operator — and this
630
+ // verb simply never printed them. Nothing new is authored here; a second set of instructions written
631
+ // at the CLI would drift from the page's, which is the defect connect-clients-are-data exists against.
644
632
  if (offer.steps?.length) {
645
633
  say("");
646
634
  say(` In ${offer.client?.name ?? "your assistant"}:`);
647
- offer.steps.forEach((step, n) => {
648
- say(` ${n + 1}. ${plainStep(step.text)}`);
649
- const c = step.copy;
650
- const text = !c ? null : c.kind === "secret" ? (key ? c.template.split(c.slot).join(key) : null) : c.text;
651
- if (text) { say(""); for (const line of text.split("\n")) say(` ${line}`); say(""); }
652
- if (step.hint) say(` ${plainStep(step.hint)}`);
653
- });
635
+ offer.steps.forEach((step, n) => say(` ${n + 1}. ${step}`));
654
636
  }
637
+ say("");
638
+ say(` ${offer.note}`);
639
+ return 0;
655
640
  }
656
641
 
657
642
  async function main() {
@@ -669,9 +654,6 @@ async function main() {
669
654
  say(" not about you.");
670
655
  say("");
671
656
  say(" --client <name> skip the question (see --list for the names)");
672
- say(" --where here your assistant runs on this machine: it starts the software itself");
673
- say(" --where elsewhere your assistant runs somewhere else and reaches this install over the");
674
- say(" internet, with a key made for you now");
675
657
  say(" --list the assistants this build knows");
676
658
  say(" --dry-run say what would change, change nothing");
677
659
  say(" --allow-checkout-move");
@@ -681,7 +663,7 @@ async function main() {
681
663
  say("");
682
664
  return 0;
683
665
  }
684
- const known = new Set(["--client", "--where", "--list", "--dry-run", "--allow-checkout-move", "--help", "-h"]);
666
+ const known = new Set(["--client", "--list", "--dry-run", "--allow-checkout-move", "--help", "-h"]);
685
667
  const unknown = argv.filter((a) => a.startsWith("--") && !known.has(a));
686
668
  if (unknown.length) {
687
669
  console.error(`connect: unrecognised flag(s): ${unknown.join(", ")}`);
@@ -689,12 +671,6 @@ async function main() {
689
671
  process.exit(2);
690
672
  }
691
673
  const dryRun = argv.includes("--dry-run");
692
- const w = argv.indexOf("--where");
693
- if (w >= 0 && !Object.hasOwn(WHERE_FLAG, argv[w + 1] ?? "")) {
694
- console.error(`connect: --where takes one of: ${Object.keys(WHERE_FLAG).join(", ")}`);
695
- process.exit(2);
696
- }
697
- let route = w >= 0 ? WHERE_FLAG[argv[w + 1]] : null;
698
674
  // F40 — reason about the RUNNING product from the units' own environment, not this CLI's env file.
699
675
  const running = runningEnv();
700
676
  const have = deploymentHas(running.env);
@@ -736,30 +712,9 @@ async function main() {
736
712
  chosen = CONNECT_CLIENTS[Number(answer) - 1] ?? clientById(answer);
737
713
  } finally { rl.close(); }
738
714
  if (!chosen) { console.error("connect: not one of the listed assistants."); process.exit(2); }
739
- // THE SECOND QUESTION, ASKED ONLY WHEN IT HAS TWO ANSWERS — the page's own, in the terminal's words.
740
- // Where only one route is served here, asking would offer a choice whose other half cannot work.
741
- const both = ["disk", "public-http"].filter((r) => whatItNeeds(chosen, have, r)?.served);
742
- if (!route && both.length > 1) {
743
- say("");
744
- say(" Where does it run?");
745
- say("");
746
- say(" 1) On this machine — it starts the software itself, nothing to open up");
747
- say(" 2) Somewhere else — it reaches this install over the internet, with a key made for you now");
748
- say("");
749
- const rl2 = createInterface({ input: stdin, output: stdout });
750
- try {
751
- const answer = (await rl2.question(" 1-2: ")).trim();
752
- route = answer === "1" ? "disk" : answer === "2" ? "public-http" : null;
753
- } finally { rl2.close(); }
754
- if (!route) { console.error("connect: answer 1 or 2."); process.exit(2); }
755
- }
756
- route ??= both[0] ?? chosen.lead;
757
715
  }
758
716
 
759
- // NAMED WITHOUT --where: the route this id had before every row took both, so a scripted
760
- // `--client cowork` still mints a key and `--client codex` still prints a settings block.
761
- route ??= leadRouteFor(argv[i + 1]);
762
- return await render(whatItNeeds(chosen, have, route), have, { dryRun, running, allowMove: argv.includes("--allow-checkout-move") });
717
+ return await render(whatItNeeds(chosen, have), have, { dryRun, running, allowMove: argv.includes("--allow-checkout-move") });
763
718
  }
764
719
 
765
720
  // THE DISPATCH RUNS ONLY WHEN THIS FILE IS THE COMMAND. Without the guard, importing
@@ -57,7 +57,7 @@ import { stdin, stdout } from "node:process";
57
57
  import { readFileSync, writeFileSync, existsSync, mkdirSync, appendFileSync } from "node:fs";
58
58
  import { join, dirname } from "node:path";
59
59
  import { homedir, userInfo } from "node:os";
60
- import { CONNECT_CLIENTS, WHERE_FLAG, clientById, leadRouteFor } from "../shared/connect-clients.mjs";
60
+ import { CONNECT_CLIENTS, clientById } from "../shared/connect-clients.mjs";
61
61
  import { defaultDenylistPath, disablePlan, revokeEveryonePlan, applyDisablePlan, describeClosure, recordedKeysFor, removeRecordedKeys } from "../shared/client-door.mjs";
62
62
  import { loadGrants } from "../shared/scope.mjs";
63
63
  import { envFrom } from "../shared/env-aliases.mjs";
@@ -83,8 +83,6 @@ async function main() {
83
83
  say(" `clearotron grant` manages that.");
84
84
  say("");
85
85
  say(" --client <name> which assistant you connected (see --list for the names)");
86
- say(" --where here|elsewhere");
87
- say(" how you connected it: on this machine, or over the internet with a key");
88
86
  say(" --list the assistants this build knows");
89
87
  say(" --everyone the admin act: revoke EVERY issued key on this install. It says");
90
88
  say(" how many keys and how many people that is before doing it.");
@@ -92,7 +90,7 @@ async function main() {
92
90
  say("");
93
91
  return 0;
94
92
  }
95
- const known = new Set(["--client", "--where", "--list", "--dry-run", "--everyone", "--help", "-h"]);
93
+ const known = new Set(["--client", "--list", "--dry-run", "--everyone", "--help", "-h"]);
96
94
  const unknown = argv.filter((a) => a.startsWith("--") && !known.has(a));
97
95
  if (unknown.length) {
98
96
  console.error(`disconnect: unrecognised flag(s): ${unknown.join(", ")}`);
@@ -139,21 +137,18 @@ async function main() {
139
137
  say(` ${chosen.name}`);
140
138
  say("");
141
139
 
142
- // THE ROUTE DECIDES THE SIDE, not the assistant: every row takes both now. Connected on this machine,
143
- // it never touched this install; connected over the internet, it holds a key and the key is what goes.
144
- // Unnamed, the route is the one `connect` gives the same id unnamed, so the two verbs pair up.
145
- const w = argv.indexOf("--where");
146
- if (w >= 0 && !Object.hasOwn(WHERE_FLAG, argv[w + 1] ?? "")) {
147
- console.error(`disconnect: --where takes one of: ${Object.keys(WHERE_FLAG).join(", ")}`);
148
- process.exit(2);
149
- }
150
- const route = w >= 0 ? WHERE_FLAG[argv[w + 1]] : (i >= 0 ? leadRouteFor(argv[i + 1]) : chosen.lead);
151
- if (route === "disk") {
140
+ // The row's own property decides the side. `accepts: "stdio"` never touched this install; "either"
141
+ // only opens the door when its stdio route was missing, and on THIS box (the one disconnect runs on)
142
+ // the stdio route resolves, so its connect handed over a command too.
143
+ if (chosen.accepts === "stdio" || chosen.accepts === "either") {
152
144
  say(" Connecting this assistant changed nothing on this install — it runs the software itself,");
153
145
  say(" from the configuration you added on its side. To disconnect it, remove that entry in the");
154
146
  say(" assistant's own settings.");
155
- say("");
156
- say(" If you connected it over the internet instead, run this again with --where elsewhere.");
147
+ if (chosen.accepts === "either") {
148
+ say("");
149
+ say(" If you connected it by address instead, the door and key are shared — disconnect the");
150
+ say(" assistant you named when the door was opened, and the closure covers this one too.");
151
+ }
157
152
  return 0;
158
153
  }
159
154
 
package/bin/example.mjs CHANGED
@@ -47,7 +47,6 @@ 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";
51
50
 
52
51
  const REPO = join(dirname(fileURLToPath(import.meta.url)), "..");
53
52
 
@@ -370,19 +369,8 @@ console.log(` Removing this demo later is one directory: ${removeDirectory(dem
370
369
  strayFromAnOlderDemo();
371
370
  console.log("");
372
371
 
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,
372
+ const child = spawn(process.execPath, [join(REPO, "bin", "start.mjs"), ...startArgs], {
373
+ cwd: REPO, stdio: ["ignore", "inherit", "inherit"],
386
374
  });
387
375
  child.on("error", (e) => die(`demo: could not start the portal: ${String(e?.message ?? e)}`));
388
376
  // Its exit code is the demo's. A supervisor that swallowed a child's refusal would report a demo that
package/bin/key.mjs CHANGED
@@ -29,9 +29,7 @@
29
29
  import "../shared/env-local.mjs"; // side effect: apply the install's .env — FIRST, before anything reads process.env
30
30
  import { invocationPrefix } from "../shared/invocation.mjs";
31
31
  import { existsSync, readFileSync } from "node:fs";
32
- import { defaultGrantsPath, installPaths } from "./start.mjs";
33
- import { demoTokenSecretPath } from "../shared/client-door.mjs";
34
- import { resolvePerson } from "../shared/scope.mjs"; // the door's own reading of the guest list, so one answer serves both
32
+ import { defaultGrantsPath } from "./start.mjs";
35
33
  import { mintFromOptions } from "../mcp-server/mint-token.mjs";
36
34
 
37
35
  const argv = process.argv.slice(2);
@@ -46,7 +44,6 @@ const USAGE = `usage: ${p}clearotron key issue <email> [options]
46
44
 
47
45
  --accounts a,b cap the key to these account keys as well (omit = whatever their grant allows)
48
46
  --ttl-days <n> how long it is valid (default 90)
49
- --base <dir> the install or demo to issue for, when it is not the one set up in ~/trademark
50
47
 
51
48
  The token is printed ONCE on stdout and stored nowhere — possession is the credential. Everything
52
49
  else goes to stderr, including the jti that revokes it.`;
@@ -68,17 +65,6 @@ const email = positional[0];
68
65
  if (!email) die(`who is the key for? Give the person's email.\n\n${USAGE}`);
69
66
  if (!email.includes("@")) die(`"${email}" is not an email address — the subject of an account key is the identity their assistant presents, and the grants file is keyed on it.`);
70
67
 
71
- // `--base` NAMES THE INSTALL, as it does for `start` and `passphrase`: its guest list, and — for a demo,
72
- // which keeps its signing secret in its own base rather than in any settings file — that secret. Without
73
- // it the verb read the install's settings and refused to issue a key for a running demo.
74
- const baseAt = rest.indexOf("--base");
75
- const base = baseAt >= 0 ? rest[baseAt + 1] : null;
76
- if (baseAt >= 0 && (!base || base.startsWith("--"))) die(`--base needs a directory.\n\n${USAGE}`);
77
- if (base) {
78
- const secretFile = demoTokenSecretPath(base);
79
- if (existsSync(secretFile)) process.env.TRADEMARK_MCP_TOKEN_SECRET = readFileSync(secretFile, "utf8").trim();
80
- }
81
-
82
68
  const ttlDays = Number(flag("--ttl-days") ?? 90);
83
69
  const accounts = flag("--accounts") ? flag("--accounts").split(",").map((s) => s.trim()).filter(Boolean) : null;
84
70
 
@@ -107,19 +93,15 @@ for (const line of minted.notes) console.error(line);
107
93
  // inert. STDERR, so the token stays alone on stdout and `key issue ... > token.txt` keeps working —
108
94
  // that split is deliberate and this must not undo it.
109
95
  try {
110
- const rosterPath = base ? installPaths(base).grants : defaultGrantsPath();
96
+ const rosterPath = defaultGrantsPath();
111
97
  if (!existsSync(rosterPath)) {
112
98
  console.error(`\nNOTE: no guest list at ${rosterPath} yet, so nothing grants ${email} anything and this key will be refused at the door. \`clearotron start\` writes the list; then: clearotron grant add ${email} --tenant <name> --accounts <brand-owner-key>`);
113
99
  } else {
114
- // THE DOOR'S OWN RESOLVER DECIDES, not a second reading of the file. This walked `tenants[].users`
115
- // alone, and the demo's guest list grants its account through the top-level `people` map — so the
116
- // demo's own key came with a note saying the door would refuse it, over a key the door accepts
117
- // (driven 2026-09-11). One reader for one question.
118
100
  const roster = JSON.parse(readFileSync(rosterPath, "utf8"));
119
- const person = resolvePerson(email, roster);
120
- const listed = Boolean(person && (person.everything || person.accounts?.length || person.organisations?.length));
101
+ const tenants = roster?.tenants && typeof roster.tenants === "object" ? roster.tenants : {};
102
+ const listed = Object.values(tenants).some((t) => t?.users && Object.prototype.hasOwnProperty.call(t.users, email));
121
103
  if (!listed)
122
- console.error(`\nNOTE: ${email} is granted nothing in ${rosterPath}, so this key resolves to no accounts and the door will refuse it. Grant them access with: clearotron grant add ${email} --tenant <name> --accounts <brand-owner-key>`);
104
+ console.error(`\nNOTE: ${email} is on no tenant in ${rosterPath}, so this key resolves to no accounts and the door will refuse it. Grant them access with: clearotron grant add ${email} --tenant <name> --accounts <brand-owner-key>`);
123
105
  }
124
106
  } catch (e) {
125
107
  // A ROSTER THIS COMMAND CANNOT READ IS NOT A ROSTER SAYING THE SUBJECT IS ABSENT. Said as what it is,