clearotron 0.3.0-beta.2 → 0.3.0-beta.4
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/INSTALL.md +32 -6
- package/bin/connect.mjs +68 -25
- package/bin/disconnect.mjs +16 -11
- package/bin/onboard.mjs +65 -6
- package/bin/start.mjs +7 -1
- package/build-info.json +2 -2
- package/driver/CHANGELOG.md +38 -0
- package/driver/driver.config.mjs +5 -0
- package/driver/engine/anthropic-agent.mjs +34 -17
- package/driver/gateway.mjs +76 -15
- package/driver/package.json +1 -1
- package/driver/portal-service.mjs +1 -1
- package/driver/profile-service.mjs +31 -3
- package/driver/publish/index.mjs +12 -9
- package/driver/publish/knockout.mjs +4 -2
- package/driver/publish/office-record-links.mjs +56 -21
- package/driver/recipe-service.mjs +14 -5
- package/driver/record-origins.mjs +14 -0
- package/driver/suite-census.json +34 -28
- package/mcp-server/CHANGELOG.md +8 -0
- package/mcp-server/lib/driver.mjs +2 -0
- package/mcp-server/lib/knockout.mjs +2 -2
- package/mcp-server/lib/options.mjs +9 -2
- package/mcp-server/package.json +1 -1
- package/package.json +1 -1
- package/portal-ui/dist/assets/{index-CsCuPshD.css → index-Cv-E_agg.css} +199 -86
- package/portal-ui/dist/assets/{index-CcFjgM78.js → index-DWYCsOCJ.js} +514 -380
- package/portal-ui/dist/index.html +2 -2
- package/portal-ui/package.json +1 -1
- package/providers/clarivate/src/core.js +5 -0
- package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/scripts/e2e-unread-terminals.mjs +1 -1
- package/scripts/e2e.mjs +114 -9
- package/scripts/revisit-render-check.mjs +22 -4
- package/scripts/travelling-predicates.mjs +1 -1
- package/shared/connect-clients.mjs +282 -321
- package/shared/names-in-force.mjs +1 -0
- package/shared/stdio-connect.mjs +63 -0
- package/shared/store-in-repo.mjs +50 -2
|
@@ -8,386 +8,347 @@
|
|
|
8
8
|
// understand, i can access the UI at 127.0.0.1 but i cant access the MCP server? … i didnt need to mint
|
|
9
9
|
// a key for the UI or open a tunnel i just ran clearotron start?"* And then: *"AND it might not just be
|
|
10
10
|
// cowork, it might be chatgpt or perplexity. or [another agent platform]. COME ON MAN. this shouldn't
|
|
11
|
-
// be so hard."*
|
|
12
|
-
// product that lists an integrator by name starts describing itself in terms of what it happens to run.
|
|
13
|
-
// Any agent of that shape is served by the "Another agent" row, which is what that row is for.)
|
|
11
|
+
// be so hard."*
|
|
14
12
|
//
|
|
15
|
-
// He is right that it should not be hard
|
|
16
|
-
//
|
|
17
|
-
//
|
|
13
|
+
// He is right that it should not be hard. The reader knows two things for certain: which app they use,
|
|
14
|
+
// and whether Clearotron is on the machine that app runs on. Everything else — a command or a settings
|
|
15
|
+
// block, an address, a key — follows from those two answers, so the product works it out and the reader
|
|
16
|
+
// is only ever asked the two questions.
|
|
18
17
|
//
|
|
19
|
-
//
|
|
20
|
-
// no address, no port, no tunnel. This is the whole answer and readers do not
|
|
21
|
-
// believe it, having just been told about tunnels — so the copy says it plainly.
|
|
22
|
-
// accepts "http" the client speaks to an address and proves itself with a key. That address is
|
|
23
|
-
// ALWAYS the publicly reachable one — see below.
|
|
24
|
-
// accepts "either" we do not know what the reader's agent can do, so we say both and let them pick.
|
|
18
|
+
// ── TWO ROUTES, AND EVERY APP TAKES BOTH ─────────────────────────────────────────────────────────────
|
|
25
19
|
//
|
|
26
|
-
//
|
|
20
|
+
// disk Clearotron is installed on the machine the app runs on. The app starts the server
|
|
21
|
+
// itself from the copy already there: no key, no address, no network.
|
|
22
|
+
// public-http Clearotron is running elsewhere. The app reaches its public address with a key minted
|
|
23
|
+
// for the person pressing. That address is ALWAYS the publicly reachable one — see
|
|
24
|
+
// below for why no loopback address is ever a true answer.
|
|
27
25
|
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
26
|
+
// This table used to give each row ONE of those, as an `accepts` axis, and it was wrong in both
|
|
27
|
+
// directions: Claude Code and Codex were offered only on this computer although both connect to a remote
|
|
28
|
+
// address with a key, and ChatGPT only remotely although its desktop app reads the same settings file as
|
|
29
|
+
// Codex. The owner met the result as a hosted install that "could never work from his laptop". So every
|
|
30
|
+
// row now carries both routes' steps, drawn from the approved design, and the page asks where
|
|
31
|
+
// Clearotron is running instead of guessing.
|
|
30
32
|
//
|
|
31
|
-
//
|
|
32
|
-
// your local device. This is true across every Claude client, including claude.ai, Claude Desktop,
|
|
33
|
-
// Cowork, and the mobile apps." … "Your MCP server must be reachable over the public internet from
|
|
34
|
-
// Anthropic's IP ranges."
|
|
33
|
+
// ── DATA, NOT BRANCHES ───────────────────────────────────────────────────────────────────────────
|
|
35
34
|
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
35
|
+
// "The list of clients is data, not code branches — adding one is a row." A single `if (id === 'codex')`
|
|
36
|
+
// anywhere downstream is the seed of drift: the branch and the row disagree, both still render, and the
|
|
37
|
+
// reader follows whichever one is wrong. `driver/test/connect-clients-are-data.test.mjs` refuses a client
|
|
38
|
+
// name in a conditional on every surface that renders these rows.
|
|
39
39
|
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
40
|
+
// A STEP NAMES ITS COPY BY SHAPE, and never spells it. The strings a reader pastes are composed in
|
|
41
|
+
// `shared/stdio-connect.mjs`, once, and a row says which one its first step hands over. That keeps each
|
|
42
|
+
// command to one author (`driver/test/the-connect-route-has-one-author.test.mjs`) and lets two rows that
|
|
43
|
+
// take the same file — ChatGPT's desktop app and Codex both read `~/.codex/config.toml` — hand over the
|
|
44
|
+
// same bytes by construction rather than by care.
|
|
42
45
|
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
46
|
+
// STEP TEXT CARRIES TWO MARKS and nothing else: `**…**` for the name of a control the reader looks for,
|
|
47
|
+
// and a backtick pair for a literal they type or read back. The page draws them; the terminal prints the
|
|
48
|
+
// literal and drops the emphasis. Anything richer would be markup in data, which a surface then has to
|
|
49
|
+
// trust.
|
|
45
50
|
//
|
|
46
|
-
// ──
|
|
51
|
+
// ── THESE SENTENCES ARE READ BY A LAWYER, NOT AN ENGINEER ────────────────────────────────────────
|
|
47
52
|
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
// Codex needs no key at all. On a local install the page's answer resolved to `null`, so the page named
|
|
53
|
-
// a one-line command in its own instructions and then rendered no command — which is that very
|
|
54
|
-
// defect, sitting inside the page written to answer it.
|
|
53
|
+
// The owner quoted two earlier refusals back as fails: "this installation is not running yet, so there
|
|
54
|
+
// is nothing for an assistant to connect to" ("very confusing") and "connects from its vendor's servers,
|
|
55
|
+
// so it cannot reach a machine that is not published to the internet … under a name that resolves".
|
|
56
|
+
// Every refusal here says the fact as WHAT HAPPENS NEXT and WHO DOES IT.
|
|
55
57
|
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
// that broke.
|
|
58
|
+
// NO REFUSAL MAY SAY "address" OR "key". A refusal renders where an arriving reader can see it, and
|
|
59
|
+
// `scripts/ai-page-render-check.mjs` refuses six words on every line of the arriving page. The steps may
|
|
60
|
+
// use them: they appear only after the reader has picked an app, which is the moment those words start
|
|
61
|
+
// meaning something to them.
|
|
61
62
|
//
|
|
62
|
-
// ──
|
|
63
|
+
// ── THE LAUNCH ROUTE ( settled 8; owner: "fastest possible way to reach 'chat about my report'") ──
|
|
63
64
|
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
65
|
+
// A row MAY carry `launch: { url, verifiedOn, by }` — a page a press can open so the reader lands in
|
|
66
|
+
// their assistant with the connector in front of them. NO ROW CARRIES ONE TODAY, and that is a statement
|
|
67
|
+
// rather than an omission: which vendors allow it is a fact somebody has to DRIVE, and a URL written here
|
|
68
|
+
// from memory would be a button that looks like it works and does not. `connect-clients-are-data`
|
|
69
|
+
// REFUSES a launch URL that carries no date and no name.
|
|
68
70
|
|
|
69
|
-
|
|
70
|
-
* Every client we can speak to, and what it can accept. Adding one is a row.
|
|
71
|
-
*
|
|
72
|
-
* `steps` is a function of what the deployment resolved, not a fixed list, because the instruction for
|
|
73
|
-
* a browser-door assistant names an email and the instruction for a stdio assistant names a command.
|
|
74
|
-
* Interpolating them here keeps the recipe and the address that recipe refers to in one place.
|
|
75
|
-
*/
|
|
76
|
-
/* ── item 5 — THESE SENTENCES ARE READ BY A LAWYER, NOT AN ENGINEER ─────────
|
|
77
|
-
The owner quoted two of them back as fails: "this installation is not running yet, so there is
|
|
78
|
-
nothing for an assistant to connect to" ("very confusing") and "connects from its vendor's servers,
|
|
79
|
-
so it cannot reach a machine that is not published to the internet … under a name that resolves"
|
|
80
|
-
("who cares about vendors servers etc if you are a UI user … resolves, vendors severs, wtf?").
|
|
81
|
-
|
|
82
|
-
Every one of them now says the same fact as WHAT HAPPENS NEXT and WHO DOES IT. No vendor's servers,
|
|
83
|
-
no name resolution, no processes, no checkouts — the four things a reader cannot act on.
|
|
84
|
-
|
|
85
|
-
AND NONE OF THEM MAY SAY "address" OR "key". These rows render on the ARRIVING page, before any
|
|
86
|
-
press, and `scripts/ai-page-render-check.mjs` refuses six words on every line an arriving reader
|
|
87
|
-
sees. That constraint is why the old sentence reached for "a name that resolves" instead of the
|
|
88
|
-
obvious word, and it is worth knowing before rewriting one of these: the plain word is "on the web".
|
|
71
|
+
import { KEY_SLOT, STDIO_SERVER_NAME, remoteConnectFor } from "./stdio-connect.mjs";
|
|
89
72
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
local one entirely. Wording them honestly costs nothing if it does. */
|
|
93
|
-
/* ── THE LAUNCH ROUTE ( settled 8; owner: "fastest possible way to reach 'chat about
|
|
94
|
-
my report'") ────────────────────────────────────────────────────────────────────────────────────────
|
|
73
|
+
/** The two places Clearotron can be, relative to the reader's app. The page asks; the terminal flags. */
|
|
74
|
+
export const ROUTES = Object.freeze(["disk", "public-http"]);
|
|
95
75
|
|
|
96
|
-
|
|
97
|
-
|
|
76
|
+
/** The terminal's spelling of the same question: `--where here|elsewhere`, from the reader's side. */
|
|
77
|
+
export const WHERE_FLAG = Object.freeze({ here: "disk", elsewhere: "public-http" });
|
|
98
78
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
this file exists to prevent, and the one the owner has already met twice.
|
|
79
|
+
// Hints more than one row uses. One spelling each, so two apps cannot describe one fact two ways.
|
|
80
|
+
const KEY_HINT = "The key is made for you when you press, and is not shown again.";
|
|
81
|
+
const CHECK_HINT = `To check: \`claude mcp list\` shows \`${STDIO_SERVER_NAME} ✓ Connected\`.`;
|
|
82
|
+
const BRIEF = "ask it to brief you on your clearances.";
|
|
104
83
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
84
|
+
/**
|
|
85
|
+
* Every app we can speak to, and the steps for each route. Adding one is a row.
|
|
86
|
+
*
|
|
87
|
+
* `routes[route]` is `{ steps(ctx) }`: the reader's steps, in order, as `{ text, copy?, hint? }`. Step 1
|
|
88
|
+
* is always the copy — the one thing the reader takes away — and `copy` names a shape in
|
|
89
|
+
* `shared/stdio-connect.mjs` (a stdio shape on disk, a remote shape on the web). `ctx` carries what the
|
|
90
|
+
* deployment resolved that a sentence may name: today, the operator's sign-in identity.
|
|
91
|
+
*
|
|
92
|
+
* `lead` is the route a caller gets when it names none — the terminal's `--client` without `--where`.
|
|
93
|
+
* It is the route each row was served on before both existed, so a scripted invocation keeps doing what
|
|
94
|
+
* it did. `aliases` are ids a row answered to before rows merged, each with the route it meant; old
|
|
95
|
+
* scripts and old muscle memory keep working, and nothing else reads them.
|
|
96
|
+
*/
|
|
108
97
|
export const CONNECT_CLIENTS = Object.freeze([
|
|
109
|
-
// ── Runs on the reader's own machine and can spawn a process: needs NOTHING but a command. ──────
|
|
110
98
|
{
|
|
111
|
-
id: "claude
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
99
|
+
id: "claude", name: "Claude", lead: "public-http",
|
|
100
|
+
// ONE ROW, BECAUSE IT IS ONE APP ("you know its just ONE APP on a laptop which has cowork and code in
|
|
101
|
+
// it and claude is what its called and there is no such thing as desktop"). `cowork` and
|
|
102
|
+
// `claude-desktop` were rows once; they answer here now, each on the route it used to mean.
|
|
103
|
+
aliases: { cowork: "public-http", "claude-desktop": "disk" },
|
|
104
|
+
routes: {
|
|
105
|
+
disk: {
|
|
106
|
+
steps: () => [
|
|
107
|
+
{ text: "Copy this.", copy: "desktop-json" },
|
|
108
|
+
{ text: "In the Claude desktop app, open **Settings → Developer → Edit Config** and paste it in.",
|
|
109
|
+
hint: `Other servers already there? Add just the \`${STDIO_SERVER_NAME}\` entry inside \`mcpServers\`.` },
|
|
110
|
+
{ text: `Restart the Claude app. \`${STDIO_SERVER_NAME}\` appears in its tools.`,
|
|
111
|
+
hint: "Desktop app only — Claude on the web or your phone can’t reach this machine." },
|
|
112
|
+
],
|
|
113
|
+
},
|
|
114
|
+
"public-http": {
|
|
115
|
+
// DRIVEN, NOT RECALLED: the owner connected on 2026-09-04 by pasting the address, setting
|
|
116
|
+
// Authentication to None, and adding an `Authorization: Bearer <key>` request header. The warning
|
|
117
|
+
// travels with the steps and is not optional — Claude probes, infers sign-in, and shows an
|
|
118
|
+
// authentication warning even when None is right; a reader who is not told to ignore it will
|
|
119
|
+
// assume they have done it wrong.
|
|
120
|
+
verifiedOn: "2026-09-04", by: "owner",
|
|
121
|
+
steps: () => [
|
|
122
|
+
{ text: "Copy your address and key.", copy: "address-and-key", hint: KEY_HINT },
|
|
123
|
+
{ text: "In Claude, open **Settings → Connectors → Add custom connector**." },
|
|
124
|
+
{ text: "Paste the address — the first line." },
|
|
125
|
+
{ text: "Set **Authentication** to **None**." },
|
|
126
|
+
{ text: "Add a request header: **Authorization** = `Bearer`, then the key — the second line." },
|
|
127
|
+
{ text: "Press **Add**. If Claude shows an authentication warning, ignore it." },
|
|
128
|
+
],
|
|
129
|
+
},
|
|
130
|
+
},
|
|
117
131
|
},
|
|
118
132
|
{
|
|
119
|
-
id: "
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
133
|
+
id: "claude-code", name: "Claude Code", lead: "disk",
|
|
134
|
+
routes: {
|
|
135
|
+
disk: {
|
|
136
|
+
steps: () => [
|
|
137
|
+
{ text: "Copy this.", copy: "claude-cli" },
|
|
138
|
+
{ text: "Paste it into a terminal on this computer and press Enter." },
|
|
139
|
+
{ text: `Start Claude Code and ${BRIEF}`, hint: CHECK_HINT },
|
|
140
|
+
],
|
|
141
|
+
},
|
|
142
|
+
// From the vendor's documentation, 2026-09-11; not yet driven against a hosted install.
|
|
143
|
+
"public-http": {
|
|
144
|
+
steps: () => [
|
|
145
|
+
{ text: "Copy this command.", copy: "claude-cli-http", hint: KEY_HINT },
|
|
146
|
+
{ text: "Paste it into a terminal and press Enter." },
|
|
147
|
+
{ text: `Start Claude Code and ${BRIEF}`, hint: CHECK_HINT },
|
|
148
|
+
],
|
|
149
|
+
},
|
|
150
|
+
},
|
|
124
151
|
},
|
|
125
152
|
{
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
//
|
|
152
|
-
// The earlier defect in the same class, kept here because it is the reason the rule exists: the row
|
|
153
|
-
// used to end "Choose API key, and paste the second line we copied", and there is no API key control
|
|
154
|
-
// in that dialog. A client following it went looking for a box that is not the way in, on the page
|
|
155
|
-
// whose entire job is to get them connected.
|
|
156
|
-
//
|
|
157
|
-
// The warning travels with the steps and is not optional: Claude tags the server "Always required ·
|
|
158
|
-
// Detected" because it probes and infers OAuth. `None` is still correct despite the orange box, and a
|
|
159
|
-
// reader who is not told that will assume they have done it wrong.
|
|
160
|
-
{
|
|
161
|
-
id: "claude", name: "Claude", sub: "app, web, and Cowork", accepts: "http",
|
|
162
|
-
verifiedOn: "2026-09-04", by: "owner",
|
|
163
|
-
steps: ({ address }) => [
|
|
164
|
-
"Settings → Connectors → Add custom connector",
|
|
165
|
-
`Paste ${address ?? "the first of the two lines we copied"}`,
|
|
166
|
-
"Set Authentication to None",
|
|
167
|
-
"Add a request header: Authorization = Bearer, then the second line we copied",
|
|
168
|
-
"Add. If it warns that authentication is required, that is its own guess — None is correct here",
|
|
169
|
-
],
|
|
153
|
+
id: "chatgpt", name: "ChatGPT", lead: "public-http",
|
|
154
|
+
routes: {
|
|
155
|
+
// The ChatGPT desktop app reads Codex's settings file (the vendor's documentation, 2026-09-11), so
|
|
156
|
+
// it takes Codex's shape and the same bytes. ChatGPT on the web cannot start a local server.
|
|
157
|
+
disk: {
|
|
158
|
+
steps: () => [
|
|
159
|
+
{ text: "Copy this.", copy: "codex-toml" },
|
|
160
|
+
{ text: "Open `~/.codex/config.toml` and paste it at the end.",
|
|
161
|
+
hint: "The ChatGPT desktop app reads the same settings file as Codex." },
|
|
162
|
+
{ text: "Restart the ChatGPT app.",
|
|
163
|
+
hint: "Desktop app only — ChatGPT on the web or your phone can’t reach this machine." },
|
|
164
|
+
],
|
|
165
|
+
},
|
|
166
|
+
// The address and no key: ChatGPT signs its reader in through the browser. The Developer-mode path
|
|
167
|
+
// is the one the vendor documents today; the older "Connectors → Advanced" path was stale.
|
|
168
|
+
"public-http": {
|
|
169
|
+
steps: ({ operator }) => [
|
|
170
|
+
{ text: "Copy the address.", copy: "address" },
|
|
171
|
+
{ text: "In ChatGPT on the web, turn on **Settings → Security and login → Developer mode**.",
|
|
172
|
+
hint: "Needs a Plus, Pro, Business, Enterprise or Edu plan. On a company plan, your admin may have to allow it." },
|
|
173
|
+
{ text: "Add a custom connector and paste the address." },
|
|
174
|
+
{ text: `Sign in when the browser opens — use ${operator ?? "your work email"}.` },
|
|
175
|
+
],
|
|
176
|
+
},
|
|
177
|
+
},
|
|
170
178
|
},
|
|
171
179
|
{
|
|
172
|
-
id: "
|
|
173
|
-
|
|
174
|
-
"
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
180
|
+
id: "codex", name: "Codex", lead: "disk",
|
|
181
|
+
routes: {
|
|
182
|
+
// A SETTINGS BLOCK, NOT A COMMAND. This row once said "Paste it into a terminal on this machine and
|
|
183
|
+
// run it" over a TOML block whose home is a file — an instruction that ends in a shell error.
|
|
184
|
+
disk: {
|
|
185
|
+
steps: () => [
|
|
186
|
+
{ text: "Copy this.", copy: "codex-toml" },
|
|
187
|
+
{ text: "Open `~/.codex/config.toml` and paste it at the end." },
|
|
188
|
+
{ text: `Restart Codex and ${BRIEF}`,
|
|
189
|
+
hint: "Codex in the terminal, your code editor and the ChatGPT desktop app all read this file." },
|
|
190
|
+
],
|
|
191
|
+
},
|
|
192
|
+
// The key goes in the shell profile and the file names the variable, because Codex does not forward
|
|
193
|
+
// the environment and a key written into a settings file outlives the moment it was needed.
|
|
194
|
+
"public-http": {
|
|
195
|
+
steps: () => [
|
|
196
|
+
{ text: "Copy this.", copy: "codex-toml-http" },
|
|
197
|
+
{ text: "Open `~/.codex/config.toml` and paste it at the end." },
|
|
198
|
+
{ text: "Copy your key and add the line to your shell profile.", copy: "codex-key-line",
|
|
199
|
+
hint: "Codex reads the key from there, so it never sits in the settings file." },
|
|
200
|
+
{ text: `Restart Codex and ${BRIEF}` },
|
|
201
|
+
],
|
|
202
|
+
},
|
|
203
|
+
},
|
|
178
204
|
},
|
|
179
205
|
{
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
// being read by somebody who is not us. Found by driving the four decks; neither instrument could
|
|
202
|
-
// see it, because both ask whether the right row rendered and neither asks whether the sentence reads.
|
|
203
|
-
//
|
|
204
|
-
// Owner's ruling 2026-09-06, option B: this row gets its own line and the approved
|
|
205
|
-
// sentence is left untouched for the three named ones. Option A — renaming the row to "your
|
|
206
|
-
// assistant" — was rejected because it edits a line he approved to repair a line he did not.
|
|
207
|
-
//
|
|
208
|
-
// HERE RATHER THAN IN THE SCREEN, and that is this table's own rule enforced by
|
|
209
|
-
// `connect-clients-are-data.test.mjs`: no surface may branch on a client's identity, because a branch
|
|
210
|
-
// in a screen drifts from the row silently and both keep rendering while the reader follows whichever
|
|
211
|
-
// one is wrong. A fifth row that needs its own sentence writes it here and the page needs no edit.
|
|
212
|
-
pasteAs: "Paste it wherever your assistant takes it.",
|
|
213
|
-
steps: () => [
|
|
214
|
-
"If your agent can run a local command, paste what we copied and run it — it needs nothing else",
|
|
215
|
-
"If it can only reach a web link, use Advanced under these steps, which carries both lines it wants",
|
|
216
|
-
],
|
|
206
|
+
// ANYTHING ELSE. We do not know what the app is, so the steps name what any of them takes. The sub
|
|
207
|
+
// line names two it covers, because a reader scanning for their app's name should find somewhere to
|
|
208
|
+
// land; Perplexity had a row of its own with steps nobody had driven, and is folded in here.
|
|
209
|
+
id: "other", name: "Another agent", sub: "Perplexity, OpenClaw and others", lead: "disk",
|
|
210
|
+
aliases: { perplexity: "public-http" },
|
|
211
|
+
routes: {
|
|
212
|
+
disk: {
|
|
213
|
+
steps: () => [
|
|
214
|
+
{ text: "Copy this.", copy: "generic-json" },
|
|
215
|
+
{ text: "Paste it wherever your app adds an MCP server." },
|
|
216
|
+
{ text: "Restart the app if it asks you to." },
|
|
217
|
+
],
|
|
218
|
+
},
|
|
219
|
+
"public-http": {
|
|
220
|
+
steps: () => [
|
|
221
|
+
{ text: "Copy your address and key.", copy: "address-and-key", hint: KEY_HINT },
|
|
222
|
+
{ text: "Paste the address and the key wherever your app adds a custom MCP server.",
|
|
223
|
+
hint: "It may call them “server URL” and “bearer token”." },
|
|
224
|
+
],
|
|
225
|
+
},
|
|
226
|
+
},
|
|
217
227
|
},
|
|
218
228
|
]);
|
|
219
229
|
|
|
230
|
+
/** A step's text for a surface that draws no emphasis: the terminal. The literals keep their marks. */
|
|
231
|
+
export const plainStep = (text) => String(text ?? "").replace(/\*\*/g, "");
|
|
232
|
+
|
|
220
233
|
/**
|
|
221
234
|
* The offers as they go ON THE WIRE, composed once for every caller that puts them there.
|
|
222
235
|
*
|
|
223
|
-
* ── WHY THIS IS A FUNCTION ────────────────────────────────────────────────
|
|
224
|
-
*
|
|
225
236
|
* There were two hand-written copies of this mapping: the portal route's, and the browser check's stub
|
|
226
237
|
* of the portal route. They agreed until the day the shape changed, and then the check went red about
|
|
227
|
-
* the page rather than about itself
|
|
228
|
-
*
|
|
238
|
+
* the page rather than about itself. A stub that restates a wire is a second author for one shape. This
|
|
239
|
+
* is the shape; both callers ask.
|
|
229
240
|
*
|
|
230
|
-
* A
|
|
241
|
+
* A copy goes out as what the page needs to hand it over and nothing more: a block's text, or a secret's
|
|
242
|
+
* button label and the template the minted key is put into. The stdio route object stays server-side —
|
|
243
|
+
* the page has no use for where a block goes, because the step text already says.
|
|
231
244
|
*/
|
|
232
|
-
// `sub`, `verifiedOn` and `by` ride only where the ROW carries them, and absent means absent rather than
|
|
233
|
-
// null. Two of the three are load-bearing on the page:
|
|
234
|
-
//
|
|
235
|
-
// • `sub` is how one app can appear once per route without two rows claiming to be two products —
|
|
236
|
-
// "Claude · app, web, and Cowork" and "Claude · app, on this computer" are one product met two ways.
|
|
237
|
-
// • `verifiedOn`/`by` are what let the page show "✓ Checked <date>" on a row somebody actually drove
|
|
238
|
-
// and NO stamp on one nobody did. A stamp defaulted onto an undriven row would be the file's own
|
|
239
|
-
// defect class — asserting vendor behaviour from no observation — dressed up as evidence.
|
|
240
|
-
//
|
|
241
|
-
// So a row without them sends no key at all, and the page has nothing to render rather than something
|
|
242
|
-
// empty to render badly.
|
|
243
245
|
export const offersForWire = (offers) =>
|
|
244
246
|
offers.map(({ client, steps, ...rest }) => ({
|
|
245
247
|
id: client.id,
|
|
246
248
|
name: client.name,
|
|
247
249
|
...(client.sub ? { sub: client.sub } : {}),
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
250
|
+
steps: (Array.isArray(steps) ? steps : []).map((s) => ({
|
|
251
|
+
text: s.text,
|
|
252
|
+
...(s.hint ? { hint: s.hint } : {}),
|
|
253
|
+
...(s.copy ? { copy: s.copy.kind === "secret"
|
|
254
|
+
? { kind: "secret", label: s.copy.label, template: s.copy.template, slot: s.copy.slot }
|
|
255
|
+
: { kind: "block", text: s.copy.text } } : {}),
|
|
256
|
+
})),
|
|
251
257
|
...rest,
|
|
252
258
|
}));
|
|
253
259
|
|
|
254
|
-
|
|
255
|
-
|
|
260
|
+
const ALIAS_ROUTE = new Map(CONNECT_CLIENTS.flatMap((c) =>
|
|
261
|
+
Object.entries(c.aliases ?? {}).map(([alias, route]) => [alias, { client: c, route }])));
|
|
262
|
+
|
|
263
|
+
/** The client by id — or by an id it answered to before rows merged — or null. */
|
|
264
|
+
export const clientById = (id) => {
|
|
265
|
+
const key = String(id ?? "").trim();
|
|
266
|
+
return CONNECT_CLIENTS.find((c) => c.id === key) ?? ALIAS_ROUTE.get(key)?.client ?? null;
|
|
267
|
+
};
|
|
256
268
|
|
|
257
269
|
/**
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
270
|
+
* The route a caller gets for this id when it names none: an alias's own route, else the row's `lead`.
|
|
271
|
+
* An alias says which route it meant — `cowork` was the web, `claude-desktop` the disk — and answering
|
|
272
|
+
* `--client cowork` with a settings block would be handing that reader the other half of a merged row.
|
|
273
|
+
*/
|
|
274
|
+
export const leadRouteFor = (id) => {
|
|
275
|
+
const key = String(id ?? "").trim();
|
|
276
|
+
return ALIAS_ROUTE.get(key)?.route ?? CONNECT_CLIENTS.find((c) => c.id === key)?.lead ?? null;
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* What THIS client needs from THIS deployment on ONE route. PURE — the caller supplies what the
|
|
281
|
+
* deployment has.
|
|
261
282
|
*
|
|
262
|
-
* served: true
|
|
263
|
-
* served:
|
|
264
|
-
* standing and turning it on is a real change to who can reach this
|
|
265
|
-
* install, so the row carries WHAT WOULD BE TURNED ON in words, and
|
|
266
|
-
* the caller states it before doing it. Never silently.
|
|
267
|
-
* served: false cannot be served here, with the reason and what would change it.
|
|
283
|
+
* served: true ready now — every copy the steps name resolved against this deployment.
|
|
284
|
+
* served: false cannot be served here, with the reason and what would change it.
|
|
268
285
|
*
|
|
269
|
-
* `served: false` always carries `reason` and `fix`. An absence with no reason reads as breakage
|
|
270
|
-
* defect `` closed on the knockout's Export menu, and the
|
|
271
|
-
* defect this page had for every self-hosted reader.
|
|
286
|
+
* `served: false` always carries `reason` and `fix`. An absence with no reason reads as breakage.
|
|
272
287
|
*
|
|
273
288
|
* @param {object} client a row of CONNECT_CLIENTS
|
|
274
|
-
* @param {{
|
|
289
|
+
* @param {{ stdioRoutes?: object, publicAddress?: string|null, operator?: string|null }} have
|
|
290
|
+
* @param {"disk"|"public-http"} [route] defaults to the row's `lead`
|
|
275
291
|
*/
|
|
276
|
-
export function whatItNeeds(client, have = {}) {
|
|
292
|
+
export function whatItNeeds(client, have = {}, route = client?.lead) {
|
|
277
293
|
if (!client) return null;
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
} = have;
|
|
281
|
-
// THE ROUTE FOR THIS HOST'S OWN SHAPE, never a fallback to another host's. Handing a Codex user
|
|
282
|
-
// `claude mcp add` is a command their machine does not have, delivered with confidence — the same
|
|
283
|
-
// false-offer class as pointing Cowork at the door that refuses its key.
|
|
284
|
-
const route = Object.hasOwn(stdioRoutes, client.stdioShape ?? "") ? stdioRoutes[client.stdioShape] : null;
|
|
294
|
+
const author = client.routes?.[route];
|
|
295
|
+
if (!author) return null;
|
|
296
|
+
const { stdioRoutes = {}, publicAddress = null, operator = null } = have;
|
|
285
297
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
298
|
+
// EACH COPY RESOLVES TO ITS OWN SHAPE, never to another's. Handing a Codex user `claude mcp add` is a
|
|
299
|
+
// command their machine does not have, delivered with confidence — so a shape this deployment cannot
|
|
300
|
+
// produce is an unserved route, not a fallback to one it can.
|
|
301
|
+
const resolve = route === "disk"
|
|
302
|
+
? (shape) => {
|
|
303
|
+
const r = Object.hasOwn(stdioRoutes, shape ?? "") ? stdioRoutes[shape] : null;
|
|
304
|
+
return r ? { kind: "block", text: r.text, stdio: r } : null;
|
|
305
|
+
}
|
|
306
|
+
: (shape) => {
|
|
307
|
+
const r = remoteConnectFor(shape, { address: publicAddress });
|
|
308
|
+
if (!r) return null;
|
|
309
|
+
return r.secret ? { kind: "secret", label: r.label, template: r.text, slot: KEY_SLOT } : { kind: "block", text: r.text };
|
|
310
|
+
};
|
|
294
311
|
|
|
295
|
-
|
|
296
|
-
const
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
: withSteps({ client, served: false, enables: null,
|
|
300
|
-
reason: "this copy of the software is incomplete, so there is nothing to hand your assistant",
|
|
301
|
-
fix: "whoever installed it will need to install it again" });
|
|
312
|
+
const asked = author.steps({ operator });
|
|
313
|
+
const steps = asked.map((s) => (s.copy ? { ...s, copy: resolve(s.copy) } : { ...s }));
|
|
314
|
+
const resolved = steps.every((s, i) => !asked[i].copy || s.copy);
|
|
315
|
+
const evidence = { ...(author.verifiedOn ? { verifiedOn: author.verifiedOn } : {}), ...(author.by ? { by: author.by } : {}) };
|
|
302
316
|
|
|
303
|
-
if (
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
//
|
|
309
|
-
// There was a `localOffer()` here for assistants classified `runsOn: "readers-machine"` — Cowork and
|
|
310
|
-
// "Another agent" — which served them a LOOPBACK address with the note "nothing to publish, nothing
|
|
311
|
-
// to open up". It is refuted at source, by the vendor:
|
|
312
|
-
//
|
|
313
|
-
// "Claude connects to your remote MCP server from Anthropic's cloud infrastructure, rather than
|
|
314
|
-
// from your local device. This is true across every Claude client, including claude.ai, Claude
|
|
315
|
-
// Desktop, Cowork, and the mobile apps." … "Your MCP server must be reachable over the public
|
|
316
|
-
// internet from Anthropic's IP ranges."
|
|
317
|
-
//
|
|
318
|
-
// Cowork RUNS on the reader's machine and CONNECTS from the vendor's cloud, and the axis conflated
|
|
319
|
-
// those. `clearotron connect --client cowork` therefore printed an address Cowork rejects — a live
|
|
320
|
-
// wrong answer, of exactly the class this file's own comments exist to prevent, and the reason the
|
|
321
|
-
// owner's testing kept failing. HTTPS only; HTTP is refused as well.
|
|
322
|
-
//
|
|
323
|
-
// So there is ONE address now, the publicly reachable one, and no surface serves loopback to anybody.
|
|
324
|
-
// Two rows in the whole model: an assistant that can launch a local process needs a command and
|
|
325
|
-
// nothing else, and everything else — wherever it appears to run — needs the public address and a key.
|
|
326
|
-
const webOffer = () => publicAddress
|
|
327
|
-
? withSteps({ client, served: true, route: "public-http", address: publicAddress, key: "issued",
|
|
328
|
-
command: null, enables: null,
|
|
329
|
-
note: "This assistant connects through its maker's service, so it reaches this installation at its web address rather than from your machine." })
|
|
330
|
-
// ── THE ONE HONEST UNAVAILABLE ( §5) ────────────────────────────────────────
|
|
331
|
-
// "Not available" is honest in exactly one case: this deployment has no public web address. It has
|
|
332
|
-
// nothing to do with who is reading, and it is never bare — the copy names who enables it. The
|
|
333
|
-
// state it replaces ("this service is not set up to take assistants yet") described the client door
|
|
334
|
-
// not running, which under settled point 2 cannot happen: the door auto-starts with the product and
|
|
335
|
-
// the key is the gate.
|
|
336
|
-
// NOT "address", and not "key" either. These rows render on the ARRIVING page, before any press,
|
|
337
|
-
// where `scripts/ai-page-render-check.mjs` refuses six words on every line a reader sees. The plain
|
|
338
|
-
// word for a reader is "the internet", and it happens to be the truer one: what is missing is not a
|
|
339
|
-
// string somebody forgot to type, it is that nothing outside this machine can reach the service.
|
|
340
|
-
: withSteps({ client, served: false, enables: null,
|
|
341
|
-
reason: `${client.name} reaches this service over the internet, and this installation is not on the internet yet`,
|
|
342
|
-
// ── NAME THE ACTOR *AND* WHAT RESOLVES IT ( — F30) ─────────────────
|
|
343
|
-
//
|
|
344
|
-
// "whoever installed it can put it online" is written for a client looking at somebody else's
|
|
345
|
-
// deployment. The person reading this in a terminal IS whoever installed it, and the sentence
|
|
346
|
-
// named no command, no file and no document — while INSTALL.md §7 covers exactly this.
|
|
347
|
-
// `connect` cannot do it itself either: the address comes from the installer's question or from
|
|
348
|
-
// CLEAROTRON_CLIENT_MCP_URL, and neither was named.
|
|
349
|
-
//
|
|
350
|
-
// KEEPING THE ACTOR IS NOT A REGRESSION, and dropping it was a near-miss caught in review. This
|
|
351
|
-
// row renders on TWO surfaces with two audiences: a terminal, where the reader is the operator
|
|
352
|
-
// and "whoever installed it" names them uselessly, and the portal's own page, where a CLIENT
|
|
353
|
-
// reads it and genuinely cannot do this themselves. For that reader, WHO resolves it is part of
|
|
354
|
-
// what resolves it. So the sentence carries both — the actor, and the thing to set — which is
|
|
355
|
-
// what the theme asked for and what neither wording alone delivered.
|
|
356
|
-
fix: "whoever installed it can put it online — it takes about a minute and needs no account",
|
|
357
|
-
// THE OPERATOR'S HALF, WHICH THE ARRIVING PAGE MUST NEVER RENDER ( — F30).
|
|
358
|
-
//
|
|
359
|
-
// Two audiences, two incompatible constraints, and one string cannot serve both. `fix` is read
|
|
360
|
-
// by a lawyer on the arriving page, where `scripts/ai-page-render-check.mjs` refuses six words —
|
|
361
|
-
// MCP, connector, token, scope, address, key — so it cannot name the variable OR the thing the
|
|
362
|
-
// variable sets. The operator reading this in a terminal needs exactly those.
|
|
363
|
-
//
|
|
364
|
-
// I learned that by breaking it: F30's first fix put the variable name and "public address" into
|
|
365
|
-
// `fix`, and the browser check caught both words on the page a client sees. The comment above
|
|
366
|
-
// this table warns about it in as many words, and I had read it. So the row carries BOTH
|
|
367
|
-
// registers as separate fields and each surface takes the one its reader can act on — which is
|
|
368
|
-
// this file's own rule, data rather than a branch downstream.
|
|
369
|
-
operatorFix: "put it online and set CLEAROTRON_CLIENT_MCP_URL to the public URL of this install — INSTALL.md §7 walks the tunnel" });
|
|
370
|
-
|
|
371
|
-
if (client.accepts === "either") {
|
|
372
|
-
// Both routes, because we do not know which one this agent can walk. The stdio answer LEADS: it is
|
|
373
|
-
// the one that needs nothing, and an agent that can take it must not be sent to mint a key. Settled
|
|
374
|
-
// point 6 makes that the rule for the whole page and not just this row.
|
|
375
|
-
const stdio = stdioOffer();
|
|
376
|
-
if (stdio.served) {
|
|
377
|
-
// `stdio` CARRIES THROUGH. Without it the caller cannot tell a command from a config block, and
|
|
378
|
-
// renders "run this once" over four lines of JSON — an instruction that reads as a shell command
|
|
379
|
-
// and is not one. The shape is part of the answer, not decoration on it.
|
|
380
|
-
return withSteps({ client, served: true, route: "either", command: stdio.command, stdio: stdio.stdio,
|
|
381
|
-
address: null, key: null, enables: null,
|
|
382
|
-
note: "Two ways in. Most agents take the configuration above and need nothing else. "
|
|
383
|
-
+ "If yours can only reach a web link, connect it that way instead." });
|
|
317
|
+
if (route === "disk") {
|
|
318
|
+
if (!resolved) {
|
|
319
|
+
return { client, served: false, route, steps: [], launch: null, enables: null, command: null, address: null, key: null,
|
|
320
|
+
reason: "this copy of the software is incomplete, so there is nothing to hand your assistant",
|
|
321
|
+
fix: "whoever installed it will need to install it again" };
|
|
384
322
|
}
|
|
385
|
-
|
|
323
|
+
const first = steps.find((s) => s.copy)?.copy;
|
|
324
|
+
// `command` and `stdio` ride for the terminal, which prints a disk offer's copy by its shape.
|
|
325
|
+
return { client, served: true, route, steps, launch: client.launch ?? null, enables: null, ...evidence,
|
|
326
|
+
command: first?.text ?? null, stdio: first?.stdio ?? null, address: null, key: null,
|
|
327
|
+
note: "Nothing to sign up for and nothing to open up — this assistant runs the software itself, from the copy already on this machine." };
|
|
386
328
|
}
|
|
387
329
|
|
|
388
|
-
//
|
|
389
|
-
|
|
330
|
+
// ── THE WEB ROUTE ─────────────────────────────────────────────────────────────────────────────────
|
|
331
|
+
//
|
|
332
|
+
// ONE ADDRESS, and it is the publicly reachable one. There used to be a loopback offer for apps that
|
|
333
|
+
// run on the reader's machine, and it is refuted at source, by the vendor: "Claude connects to your
|
|
334
|
+
// remote MCP server from Anthropic's cloud infrastructure, rather than from your local device. This is
|
|
335
|
+
// true across every Claude client, including claude.ai, Claude Desktop, Cowork, and the mobile apps."
|
|
336
|
+
// An app on this machine that wants no network takes the disk route; nothing is served loopback.
|
|
337
|
+
if (!resolved || !publicAddress) {
|
|
338
|
+
// THE ONE HONEST UNAVAILABLE. "Not available" is true in exactly one case — this deployment has no
|
|
339
|
+
// public web address — and it is never bare: it names who resolves it. `fix` renders where a client
|
|
340
|
+
// reads it, so it names the actor and no banned word; `operatorFix` is the terminal's, read by the
|
|
341
|
+
// person who IS that actor, and carries the variable and the document they need.
|
|
342
|
+
return { client, served: false, route, steps: [], launch: null, enables: null, command: null, address: null, key: null,
|
|
343
|
+
reason: `${client.name} reaches this service over the internet, and this installation is not on the internet yet`,
|
|
344
|
+
fix: "whoever installed it can put it online — it takes about a minute and needs no account",
|
|
345
|
+
operatorFix: "put it online and set CLEAROTRON_CLIENT_MCP_URL to the public URL of this install — INSTALL.md §7 walks the tunnel" };
|
|
346
|
+
}
|
|
347
|
+
return { client, served: true, route, steps, launch: client.launch ?? null, enables: null, ...evidence,
|
|
348
|
+
command: null, stdio: null, address: publicAddress, key: "issued",
|
|
349
|
+
note: "This assistant connects through its maker's service, so it reaches this installation at its web address rather than from your machine." };
|
|
390
350
|
}
|
|
391
351
|
|
|
392
|
-
/** Every client, resolved against one deployment. The page and the verb both render this. */
|
|
393
|
-
export const connectOffers = (have = {}) =>
|
|
352
|
+
/** Every client on every route, resolved against one deployment. The page and the verb both render this. */
|
|
353
|
+
export const connectOffers = (have = {}) =>
|
|
354
|
+
CONNECT_CLIENTS.flatMap((c) => ROUTES.map((route) => whatItNeeds(c, have, route)));
|