@luckydraw/cumulus 1.0.0 → 1.0.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.
@@ -17,7 +17,7 @@ This is the code that guide describes.
17
17
  | **The agent sees the live screen** | A fresh `describeView` rides along with every turn — never a cached snapshot. |
18
18
  | **The agent drives the app** | Through a registry you define, calling your app's own actions — not the DOM. |
19
19
  | **A human gate on irreversible things** | `risk: "export"` commands stop for a confirm chip. The model cannot bypass it. |
20
- | **Capability-scoped access** | The app's key can touch its own namespace and cannot enumerate anything, anywhere. |
20
+ | **Capability-scoped access** | The app's key touches only its own namespace, and lists nothing at all — even its own. |
21
21
 
22
22
  ## Run it
23
23
 
@@ -42,6 +42,16 @@ GATEWAY_API_KEY=sk-demoapp-REPLACE-ME GATEWAY_ORIGIN=http://127.0.0.1:8080 node
42
42
  Copy the whole directory somewhere writable before you start editing it — a kit
43
43
  inside `node_modules` is replaced on the next upgrade.
44
44
 
45
+ `8080` is the default port of a _fresh_ install, not a guarantee about your
46
+ machine. Check the real one before you copy anything verbatim:
47
+
48
+ ```bash
49
+ jq .port ~/.cumulus/gateway.config.json # or: cumulus-gateway config get port
50
+ ```
51
+
52
+ Use that value in `GATEWAY_ORIGIN` here and in the shim's env. If some other
53
+ service owns 8080, a verbatim copy talks to it and fails obscurely.
54
+
45
55
  Without `GATEWAY_API_KEY` the app still runs — it just has no assistant. That is
46
56
  the correct degraded state, and it's worth keeping in your own app.
47
57
 
@@ -66,6 +76,9 @@ server.js your serving layer's one job: hand the scoped key
66
76
  agent/mcp-shim.js stdio MCP server the gateway spawns per turn;
67
77
  turns your registry into the model's tools
68
78
  gateway.config.example.json the namespace, the scoped key, the shim
79
+ thread-config.example.json YOUR management thread's config
80
+ thread-config.visitor.example.json EVERY visitor's config — the cheap model
81
+ agent/apply-thread-configs.mjs one command to push both to the gateway
69
82
 
70
83
  public/index.html the demo app
71
84
  public/app.js the demo app + window.HostApp (the adapter)
@@ -74,6 +87,7 @@ public/agent/commands.js ← THE FILE YOU WRITE. Your capability surface.
74
87
  public/agent/device-thread.js per-visitor thread identity
75
88
  public/agent/bridge-mount.js wires the bridge client to your registry
76
89
  public/agent/chat-client.js speaks the gateway's chat API (SSE)
90
+ public/agent/blex-mount.js rich blocks (tables, charts, diagrams) — optional
77
91
  public/agent/panel.js the chat UI
78
92
  public/agent/panel.css themed from six CSS variables
79
93
  ```
@@ -82,13 +96,108 @@ Copy all of `public/agent/` and `agent/mcp-shim.js` as-is. Then **rewrite
82
96
  `commands.js`** against your own app and delete the demo's notes commands.
83
97
  That's the whole port.
84
98
 
85
- The browser `BridgeClient` is deliberately **not** in this tree. It's served
86
- from `dist/gateway/bridge/` in the cumulus package, so it can never drift from
87
- the gateway it talks to. Don't fork it.
99
+ Three browser files are deliberately **not** in this tree the `BridgeClient`,
100
+ the blex renderer and the blex library. `server.js` resolves all of them out of
101
+ the installed `@luckydraw/cumulus` package and serves them from **your own
102
+ origin** (`/agent/bridge-client/…`, `/agent/blex/…`), so they can't drift from
103
+ the gateway and can't depend on how your edge is routed. Don't fork them, and
104
+ don't load them cross-origin from `GATEWAY_ORIGIN` — that only works if your app
105
+ is on a different hostname than the gateway; if it isn't (the common shape: your
106
+ hostname, with the edge routing only `/bridge*` and `/api/thread/*` through), the
107
+ cross-origin URL 404s and blex degrades to plain text with no obvious cause.
108
+
109
+ ### Rich blocks
110
+
111
+ The gateway instructs every thread to emit `~~~blex` fences for tables, status
112
+ boards, metrics, charts and diagrams — it's a global rule and a thread can't opt
113
+ out. So a panel with no renderer shows the visitor **raw JSON**. That isn't a
114
+ gateway bug; it's a missing half, and it's what `blex-mount.js` supplies.
115
+
116
+ It loads two scripts from your own origin (`/agent/blex/…`, served out of the
117
+ installed cumulus package — see above) at mount time, and degrades to plain
118
+ markdown if either is unavailable. Nothing else in the kit depends on it.
119
+
120
+ **Diagrams need one extra tag.** `~~~blex:mermaid` is the only block type whose
121
+ renderer loads a library at render time, using a **bare** module specifier — and
122
+ an import map is the one thing a browser can resolve that with. `index.html`
123
+ carries it; keep it when you copy:
124
+
125
+ ```html
126
+ <script type="importmap">
127
+ { "imports": { "mermaid": "/agent/blex/mermaid-esm.js" } }
128
+ </script>
129
+ ```
130
+
131
+ The vendored mermaid bundle is served by the same `/agent/blex/*` route, and only
132
+ fetched when a diagram actually renders. Drop the tag and nothing breaks — the
133
+ renderer checks the document for the mapping and leaves mermaid fences as
134
+ readable text rather than painting an error box. Diagram colours follow the blex
135
+ card's own background, so they match whatever you theme blex to.
136
+
137
+ The panel is **render-only**: `confirm`, `poll`, `form` and `diff` are not
138
+ rendered, because their buttons have no local half here and a dead "Apply"
139
+ button reads as live in a way raw JSON does not. Denied fences stay visible as
140
+ their original text. Nothing is lost — confirms arrive over the _bridge_ as a
141
+ native audited chip, never through message content.
142
+
143
+ If your app has a build step that mirrors static assets into an output
144
+ directory, note that adopting this kit adds a whole **directory** (`agent/`) to
145
+ that output. A rollback that merge-copies a snapshot over the output tree
146
+ (`cp -r snapshot/. dist/`) cannot remove a directory the bad build added — the
147
+ agent assets survive the rollback and keep being served while the rest of the
148
+ app has no reference to them. A verification pass that checks referenced assets
149
+ (a version hash, a manifest) reports success — the orphan is unreferenced, so
150
+ nothing looks at it. Roll back by replacing the output directory, and verify
151
+ with `diff -rq` against the snapshot.
152
+
153
+ ### Asset delivery (why every URL carries `?v=`)
154
+
155
+ `server.js` stamps each asset URL with a hash of its bytes and serves it
156
+ `no-cache, must-revalidate` — unless the request's `?v=` matches the current
157
+ hash, in which case it gets `immutable`. Copy this into your own server.
158
+
159
+ The reason is measured, not theoretical: a Cloudflare edge **overrides** an
160
+ origin's `no-cache` with `max-age=14400`. Without a stamp, an edit to
161
+ `commands.js` — the file you'll change most — can take four hours to reach a
162
+ browser, and each file expires on its own clock, so a visitor can end up holding
163
+ `panel.js` from one deploy and `commands.js` from another. A content-addressed
164
+ URL is the only part of this a cache policy can't override.
165
+
166
+ Two details worth keeping when you port it:
167
+
168
+ - **The hash is computed at serve time**, cached on mtime+size. So a deploy
169
+ lands with no restart, and the hash always describes the bytes actually on
170
+ disk — a build-time hash goes stale against anything edited afterwards, and
171
+ the blex/bridge assets come out of the installed cumulus package, which
172
+ changes on `npm i`, not on your build.
173
+ - **A stale or forged `?v=` must not get `immutable`.** Otherwise a wrong token
174
+ pins today's bytes under a key that no longer describes them.
175
+
176
+ An HTML-level stamp can only reach URLs that appear in the markup. Three assets
177
+ here are loaded from _inside_ JavaScript — `panel.css` (a `<link>` built by
178
+ `bridge-mount`) and the two blex scripts (appended by `blex-mount`) — so
179
+ `index.html` also publishes a `window.__AGENT_ASSET_V` map that the server fills
180
+ in, and those loaders call `window.agentAsset(url)` to look themselves up.
181
+ General rule: **a loader that fetches its own dependencies has to propagate the
182
+ version token**, because nothing upstream can see that URL.
183
+
184
+ Known limit: `bridge-mount.js` reaches `client.js` through a static ESM
185
+ `import`, and `client.js` imports `protocol.js` in turn, so those two are
186
+ fetched unstamped. Both ship from the cumulus package and change only on
187
+ upgrade, and the server marks them `no-cache`, so the browser is correct — an
188
+ edge that overrides it is the exposure. Simplest answer: exclude `/agent/` from
189
+ your CDN.
190
+
191
+ And one trap that makes a _correct_ deploy look broken: **404s get cached too.**
192
+ If you probe a route before it exists, the edge caches the 404 for its default
193
+ TTL (measured: `max-age=14400`, `cf-cache-status: HIT`), so after you ship the
194
+ route it keeps serving "not found" for four hours — which reads as "my route is
195
+ wrong" and sends you off to re-debug working code. Purge before concluding
196
+ anything.
88
197
 
89
198
  ---
90
199
 
91
- ## The five things that are easy to get wrong
200
+ ## The six things that are easy to get wrong
92
201
 
93
202
  ### 1. The thread name is the capability
94
203
 
@@ -99,6 +208,17 @@ knowing a thread's name is what grants access to that conversation. Which means:
99
208
  full name never travels server → client where it could be logged or cached.
100
209
  - Use **at least 16 hex characters** (64 bits). 8 is brute-forceable against a
101
210
  live gateway.
211
+ - Mint them with a **CSPRNG** — `crypto.getRandomValues(new Uint8Array(8))`, as
212
+ `device-thread.js` does. The length is not the guarantee; the primitive is.
213
+ `Math.random().toString(16)` yields an id that reads correct at 16 characters
214
+ and is seeded from predictable state, so it fails the only test that matters.
215
+
216
+ `device-thread.js` re-mints whenever the stored id fails `/^[0-9a-f]{16,}$/`, so
217
+ an app that previously shipped short ids upgrades every visitor to 64 bits on
218
+ first load — no migration code. Know the cost before you rely on it: a new id is
219
+ a **new thread**, so the prior conversation becomes unreachable from that browser
220
+ (it stays on disk, addressable only by its old name). Widening entropy is
221
+ therefore a one-way cut-over for existing visitors, not a transparent fix.
102
222
 
103
223
  ### 2. Never ship the key in the page
104
224
 
@@ -106,14 +226,87 @@ knowing a thread's name is what grants access to that conversation. Which means:
106
226
  session. If you inject it into static HTML instead, anyone who views source can
107
227
  talk to your gateway as your app.
108
228
 
109
- ### 3. Commands act through an adapter, not the DOM
229
+ This is not hypothetical — it has shipped. An app inlined its key into a
230
+ `<script>` block in `index.html`; the key was live on the public origin and
231
+ authenticated successfully against the gateway. What let it survive review was a
232
+ comment elsewhere in the tree describing a _different_ file as the sensitive one
233
+ ("dev only, excluded from builds"). The documented mitigation was aimed at a
234
+ file that wasn't the leak. If your repo has a note about where the key lives,
235
+ verify it against what the origin actually serves:
236
+
237
+ ```sh
238
+ curl -s https://your-app.example.com/ | grep -i 'api_key\|sk-'
239
+ ```
240
+
241
+ The blast radius is bounded by §1 and by the gateway's namespace enforcement: a
242
+ scoped key cannot enumerate threads, cannot read outside its namespace, and
243
+ cannot reach gateway settings. So an exposed key is worth exactly the thread
244
+ names an attacker can guess — which is why §1's 64 bits is load-bearing rather
245
+ than belt-and-braces. Exposing the key turns your device-id entropy into the
246
+ _only_ remaining barrier.
247
+
248
+ **How enumeration fails matters if your client branches on status.** The list
249
+ endpoints do not refuse a scoped key — they answer `200` with an empty list:
250
+
251
+ ```
252
+ GET /api/threads → 200 {"threads":[]}
253
+ GET /api/agents → 200 {"agents":[]}
254
+ ```
255
+
256
+ Empty is not "your namespace happens to be empty." It is empty even when the
257
+ namespace holds hundreds of live threads, because a scoped caller already knows
258
+ the one name it needs and listing siblings would hand out every other visitor's
259
+ capability. Reads outside the namespace are the ones that `403`. Don't write a
260
+ client that reads `200` as "I'm allowed to enumerate, there just isn't anything
261
+ here" — it will never see a thread, and no error will tell it why.
262
+
263
+ ### 3. Visitor threads need their OWN model config
264
+
265
+ Your app has two kinds of thread and they should not run the same model:
266
+
267
+ | File | Applies to |
268
+ | ----------------------- | -------------------------------------- |
269
+ | `demoapp.config.json` | your management thread — strong model |
270
+ | `demoapp-v.config.json` | **every visitor** — small, fast, cheap |
271
+
272
+ The `-v` is what makes that possible. `server.js` sends `THREAD_ID: 'demoapp-v'`
273
+ and `device-thread.js` appends the device id, so a visitor lands on
274
+ `demoapp-v-<deviceId>`. Config resolves by stripping trailing segments and taking
275
+ the longest match:
276
+
277
+ ```
278
+ demoapp-v-a3f8c2d1.config.json (none — visitors never get their own)
279
+ demoapp-v.config.json <- every visitor turn
280
+ demoapp.config.json (only if the -v file is absent)
281
+ ```
282
+
283
+ **Omit the `-v` file and every anonymous visitor runs your management thread's
284
+ model.** It is invisible in development — with one tester the bill looks fine —
285
+ and it is the most expensive mistake in this kit.
286
+
287
+ Edit the two `thread-config*.example.json` files and push them:
288
+
289
+ ```bash
290
+ GATEWAY_ORIGIN=http://127.0.0.1:8080 GATEWAY_ADMIN_KEY=sk-... \
291
+ node agent/apply-thread-configs.mjs --namespace demoapp
292
+ ```
293
+
294
+ The **admin** key, not the app's: a namespace covers `demoapp-*`, so the scoped
295
+ key can write `demoapp-v` but is refused on the bare `demoapp`. The config API
296
+ takes `projectDir`, `template`, `model`, `effort`, `claudeModel` and
297
+ `contextLimit`; `alwaysInclude` and `disallowedTools` have to be added to the file
298
+ on the gateway host (that is deliberate — `alwaysInclude` plus `projectDir` would
299
+ let a public scoped key read any file into its own prompt). The applier reads each
300
+ config back and tells you exactly what didn't stick. No reload needed.
301
+
302
+ ### 4. Commands act through an adapter, not the DOM
110
303
 
111
304
  `window.HostApp` is the app's own actions exposed as functions. Commands call
112
305
  those. That's why validation, persistence, and re-render work identically
113
306
  whether a human or the model is driving — and why your commands survive a UI
114
307
  rewrite.
115
308
 
116
- ### 4. Descriptions are the interface
309
+ ### 5. Descriptions are the interface
117
310
 
118
311
  The model decides what to call based entirely on the `description` string.
119
312
  Write for a reader who cannot see your UI, and say what a command is _for_, not
@@ -128,18 +321,31 @@ description: 'Set the on-screen filter so the human sees a subset. ' +
128
321
 
129
322
  The second one prevents a whole class of annoying behaviour.
130
323
 
131
- ### 5. Pick the risk tier honestly
132
-
133
- | tier | meaning |
134
- | --------- | -------------------------------------------------------------------- |
135
- | `read` | answers a question, changes nothing |
136
- | `display` | changes the view only, trivially undoable |
137
- | `mutate` | changes stored data, but recoverably |
138
- | `export` | irreversible, or leaves the app (sends, publishes, deletes, charges) |
139
-
140
- `export` is **always** routed through the confirm chip by the gateway the
141
- model can't bypass it and neither can your front end. Anything that spends
142
- money, emails a customer, or destroys data belongs here.
324
+ ### 6. Pick the risk tier honestly
325
+
326
+ | tier | meaning | gated? |
327
+ | --------- | ----------------------------------------------- | ---------------------- |
328
+ | `read` | answers a question, changes nothing | no — runs immediately |
329
+ | `display` | changes the view only, trivially undoable | no — runs immediately |
330
+ | `mutate` | changes stored data, but recoverably | no — runs immediately |
331
+ | `export` | must not happen without a human seeing it first | **yes — confirm chip** |
332
+
333
+ **The gate is binary and `export` is the only tier on the gated side.** The
334
+ gateway confirm-gates a call when its manifest entry says `risk: "export"`, full
335
+ stop `read`, `display` and `mutate` all dispatch on arrival. Those three tiers
336
+ are advisory: the tier rides in the tool description the model sees
337
+ (`[mutate] …`) so it can weigh the call, but nothing stops it.
338
+
339
+ So the question when tiering is **not** "is this irreversible?" — it is _"must a
340
+ human see this before it happens?"_ If yes, it is `export`, whatever the verb
341
+ is. An app that wants every write confirmed puts every write at `export`; the
342
+ alternative is a command that reads as guarded and isn't, and the failure is
343
+ silent — it just runs.
344
+
345
+ Worth an explicit test: enumerate the registered manifest and fail on anything
346
+ outside an allowlist of genuinely read-only commands that is not `export`. A
347
+ command quietly retiered down becomes an ungated write while a suite that only
348
+ checks the chip's behaviour stays green.
143
349
 
144
350
  ---
145
351
 
@@ -0,0 +1,139 @@
1
+ #!/usr/bin/env node
2
+ /* Apply this app's two thread configs to the gateway — the base thread you work
3
+ in, and the '-v' sub-namespace every visitor turn inherits.
4
+ *
5
+ * WHY A SCRIPT
6
+ * The visitor config is the one piece of per-app setup with no UI and no obvious
7
+ * home: it lives in a file on the gateway host, its name encodes a namespace rule
8
+ * (see thread-config.visitor.example.json), and getting it wrong silently costs
9
+ * money — every anonymous visitor runs whatever model your management thread uses.
10
+ * One command, run once per app, beats a paragraph telling you to hand-write JSON
11
+ * in a directory you may not have shell access to.
12
+ *
13
+ * GATEWAY_ORIGIN=http://127.0.0.1:8080 \
14
+ * GATEWAY_ADMIN_KEY=sk-... \
15
+ * node agent/apply-thread-configs.mjs [--namespace demoapp] [--dry-run]
16
+ *
17
+ * GATEWAY_ADMIN_KEY, not the app's scoped key: a namespace covers '<ns>-*' only,
18
+ * so a scoped key can write '<ns>-v' but is refused (403) on the bare '<ns>' base
19
+ * thread. That asymmetry is the P7 access model working, not a bug — this is an
20
+ * operator action.
21
+ */
22
+ import fs from 'node:fs';
23
+ import path from 'node:path';
24
+ import { fileURLToPath } from 'node:url';
25
+
26
+ const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
27
+
28
+ const ORIGIN = process.env.GATEWAY_ORIGIN;
29
+ const KEY = process.env.GATEWAY_ADMIN_KEY;
30
+
31
+ /* Near-miss names fail loudly rather than falling back to a default that might be
32
+ somebody's real gateway — the same rule server.js applies. */
33
+ const ALIASES = {
34
+ GATEWAY_URL: 'GATEWAY_ORIGIN',
35
+ AGENT_GATEWAY_ORIGIN: 'GATEWAY_ORIGIN',
36
+ GATEWAY_KEY: 'GATEWAY_ADMIN_KEY',
37
+ ADMIN_KEY: 'GATEWAY_ADMIN_KEY',
38
+ GATEWAY_API_KEY: 'GATEWAY_ADMIN_KEY (the scoped app key cannot write the base thread)',
39
+ };
40
+
41
+ function die(msg) {
42
+ console.error(`\n ${msg}\n`);
43
+ process.exit(1);
44
+ }
45
+
46
+ for (const [wrong, right] of Object.entries(ALIASES)) {
47
+ if (process.env[wrong] && !(wrong === 'GATEWAY_API_KEY' && KEY)) {
48
+ die(`${wrong} is set but this script reads ${right}.`);
49
+ }
50
+ }
51
+ if (!ORIGIN) die('GATEWAY_ORIGIN is required (no default — it must not guess a live gateway).');
52
+ if (!KEY) die('GATEWAY_ADMIN_KEY is required.');
53
+
54
+ const args = process.argv.slice(2);
55
+ const dryRun = args.includes('--dry-run');
56
+ const nsFlag = args.indexOf('--namespace');
57
+ const namespace = nsFlag !== -1 ? args[nsFlag + 1] : 'demoapp';
58
+ if (!/^[a-z0-9][a-z0-9-]*$/i.test(namespace)) die(`invalid --namespace: ${namespace}`);
59
+
60
+ /** Read an example config and drop the `_`-prefixed annotation keys. */
61
+ function load(file) {
62
+ const full = path.join(ROOT, file);
63
+ if (!fs.existsSync(full)) die(`missing ${file}`);
64
+ const parsed = JSON.parse(fs.readFileSync(full, 'utf-8'));
65
+ return Object.fromEntries(Object.entries(parsed).filter(([k]) => !k.startsWith('_')));
66
+ }
67
+
68
+ const targets = [
69
+ { thread: namespace, file: 'thread-config.example.json' },
70
+ { thread: `${namespace}-v`, file: 'thread-config.visitor.example.json' },
71
+ ];
72
+
73
+ let failed = false;
74
+ for (const { thread, file } of targets) {
75
+ const config = load(file);
76
+ if (String(config.projectDir || '').startsWith('/absolute/path/')) {
77
+ die(`${file} still has the placeholder projectDir — edit it before applying.`);
78
+ }
79
+ const label = `${thread.padEnd(24)} <- ${file}`;
80
+ if (dryRun) {
81
+ console.log(`DRY RUN ${label}\n ${JSON.stringify(config)}`);
82
+ continue;
83
+ }
84
+ let res;
85
+ try {
86
+ res = await fetch(`${ORIGIN}/api/thread/${encodeURIComponent(thread)}/config`, {
87
+ method: 'PUT',
88
+ headers: { 'Content-Type': 'application/json', 'X-API-Key': KEY },
89
+ body: JSON.stringify(config),
90
+ });
91
+ } catch (err) {
92
+ die(`cannot reach ${ORIGIN}: ${err.message}`);
93
+ }
94
+ if (res.ok) {
95
+ console.log(`OK ${label}`);
96
+ /* The config API applies a whitelist (projectDir, template, model, effort,
97
+ claudeModel, contextLimit). alwaysInclude and disallowedTools are NOT in it,
98
+ deliberately: combined with projectDir, alwaysInclude would let a
99
+ namespace-scoped key — which ships in your public page — read an arbitrary
100
+ file into its own prompt. So rather than assume, read the config back and
101
+ name anything that did not stick. A silent drop here would look like a
102
+ working persona that was never installed. */
103
+ try {
104
+ const check = await fetch(`${ORIGIN}/api/thread/${encodeURIComponent(thread)}/config`, {
105
+ headers: { 'X-API-Key': KEY },
106
+ });
107
+ if (check.ok) {
108
+ const stored = (await check.json()) ?? {};
109
+ const missing = Object.keys(config).filter(k => stored[k] === undefined);
110
+ if (missing.length) {
111
+ console.log(` not applied by the API: ${missing.join(', ')}`);
112
+ console.log(
113
+ ` add them by hand to ~/.cumulus/threads/${thread}.config.json on the`
114
+ );
115
+ console.log(' gateway host — this file is exactly those contents.');
116
+ }
117
+ }
118
+ } catch {
119
+ /* the write succeeded; a failed read-back is not worth failing over */
120
+ }
121
+ } else {
122
+ failed = true;
123
+ const body = await res.text();
124
+ console.error(`FAILED ${label}\n ${res.status} ${body.slice(0, 200)}`);
125
+ if (res.status === 403) {
126
+ console.error(' 403 on the base thread means the key is namespace-scoped.');
127
+ console.error(' Use the gateway admin key for this one-time setup.');
128
+ }
129
+ }
130
+ }
131
+
132
+ if (failed) process.exit(1);
133
+ if (!dryRun) {
134
+ console.log(
135
+ `\nDone. Visitor turns on ${namespace}-v-<deviceId> now read ${namespace}-v.config.json;\n` +
136
+ `your own ${namespace} thread is unaffected. No gateway reload needed —\n` +
137
+ 'thread config is read per turn.'
138
+ );
139
+ }
@@ -2,9 +2,25 @@
2
2
  "_comment": [
3
3
  "Merge these keys into your ~/.cumulus/gateway.config.json — this file is a fragment, not a whole config.",
4
4
  "Then: sudo systemctl reload cumulus-gateway (SIGHUP; never restart, it kills in-flight turns).",
5
- "Replace sk-demoapp-REPLACE-ME with a long random string. It is the app's only credential."
5
+ "Replace sk-demoapp-REPLACE-ME with a long random string. It is the app's only credential.",
6
+ "PORT: 8080 below is the DEFAULT for a fresh install, not a promise about your box.",
7
+ "Read your own config's top-level \"port\" and use that in GATEWAY_ORIGIN — pointing the",
8
+ "shim at whatever else happens to own 8080 fails in a confusing, non-obvious way."
6
9
  ],
7
10
 
11
+ "_bridge": [
12
+ "Top-level and GLOBAL — one setting for the whole gateway, not per-app. If it is",
13
+ "already true (another app enabled it), leave it alone; adding a second app is a",
14
+ "namespaces[] entry only. Setting it true again is harmless.",
15
+ "",
16
+ "There is one more optional key here: bridge.executorUrl (default",
17
+ "http://127.0.0.1:8091). It is ALSO global — one URL for the whole gateway, so a",
18
+ "second app cannot point it somewhere else. It is used for exactly one thing:",
19
+ "hydrating a visitor's text selection into full records via POST <executorUrl>/execute",
20
+ "before the turn runs. It soft-fails if unreachable, and it is UNRELATED to the",
21
+ "per-namespace executorProxy below — enabling that does not require this. Omit it",
22
+ "unless you have a headless executor serving /execute."
23
+ ],
8
24
  "bridge": { "enabled": true },
9
25
 
10
26
  "namespaces": [
@@ -14,9 +30,11 @@
14
30
 
15
31
  "_apiKeys": [
16
32
  "A key listed here is SCOPED: it can touch demoapp-* threads and nothing else,",
17
- "it cannot enumerate any thread anywhere, and it never sees the base 'demoapp'",
18
- "thread (that one belongs to you, for working on the app). This is the key your",
19
- "serving layer hands to logged-in sessions."
33
+ "and it never sees the base 'demoapp' thread (that one belongs to you, for",
34
+ "working on the app). It also lists NOTHING: /api/threads and /api/agents answer",
35
+ "200 with an empty array even when the namespace is full — not 403. Reads outside",
36
+ "the namespace are what 403. This is the key your serving layer hands to",
37
+ "logged-in sessions."
20
38
  ],
21
39
  "apiKeys": ["sk-demoapp-REPLACE-ME"],
22
40
 
@@ -0,0 +1,136 @@
1
+ /* Blex mount — gives the panel rich blocks (tables, status, metrics, charts,
2
+ diagrams) instead of raw JSON.
3
+
4
+ WHY THIS FILE EXISTS AT ALL
5
+ The gateway instructs every thread to emit ~~~blex fences (it is a global
6
+ rule; a thread cannot opt out). Without a renderer on this side, the model
7
+ dutifully emits fences into a surface that has never heard of them, and the
8
+ user sees a wall of JSON. That is not a gateway bug — it is a missing half.
9
+
10
+ WHAT IS DELIBERATELY NOT HERE
11
+ No vendored copy of the renderer. Both scripts come out of the installed
12
+ cumulus package, served SAME-ORIGIN by this app's own /agent/blex/ route (see
13
+ server.js, and the load() comment below for why not cross-origin). Vendoring
14
+ would fork the seam contract and guarantee version drift the first time
15
+ either side moves.
16
+
17
+ RENDER-ONLY
18
+ This panel supplies only the required adapter members, so `confirm`, `poll`,
19
+ `form` and `diff` are not rendered — their affordances have no local half
20
+ here, and a dead "Apply" button reads as live in a way raw JSON does not.
21
+ Denied fences stay visible as their original text. See the allow-set rationale
22
+ in cumulus's blex-render.js.
23
+
24
+ Confirms come over the BRIDGE (a native, audited chip in panel.js), never
25
+ through message content — so nothing is lost by not rendering them.
26
+
27
+ window.AgentBlex = { load(cfg), ready(), adapter, extract(text), render(el, blocks) } */
28
+ (function () {
29
+ 'use strict';
30
+
31
+ var loading = null;
32
+
33
+ /* These two URLs exist only here, so the server's HTML stamp cannot reach
34
+ them — they carry their hashes via the map index.html publishes instead.
35
+ Falls through to the bare URL if the map is absent. */
36
+ function assetUrl(url) {
37
+ return typeof window.agentAsset === 'function' ? window.agentAsset(url) : url;
38
+ }
39
+
40
+ function loadScript(src) {
41
+ return new Promise(function (resolve, reject) {
42
+ var existing = document.querySelector('script[data-blex-src="' + src + '"]');
43
+ if (existing) return resolve();
44
+ var s = document.createElement('script');
45
+ s.src = src;
46
+ s.setAttribute('data-blex-src', src);
47
+ s.onload = function () {
48
+ resolve();
49
+ };
50
+ s.onerror = function () {
51
+ reject(new Error('failed to load ' + src));
52
+ };
53
+ document.head.appendChild(s);
54
+ });
55
+ }
56
+
57
+ /* Load both halves SAME-ORIGIN, from this app's own /agent/blex/ route, which
58
+ serves them straight out of the installed cumulus package (see server.js).
59
+ Deliberately NOT `GATEWAY_URL + '/blex.min.js'`: that only works when the app
60
+ is on a different origin from the gateway. In the common shape — your own
61
+ hostname, with an edge routing only `/bridge*` and `/api/thread/*` through —
62
+ the gateway origin has no such path and the load 404s into a silent
63
+ plain-text degrade. Same-origin is correct in both deployments, and it is
64
+ still not a vendored copy, so it cannot drift from the gateway.
65
+
66
+ Resolves either way: a failure here must degrade the panel, never break it. */
67
+ function load(cfg) {
68
+ if (loading) return loading;
69
+ if (!cfg) return Promise.resolve(false);
70
+ loading = loadScript(assetUrl('/agent/blex/blex.min.js'))
71
+ .then(function () {
72
+ return loadScript(assetUrl('/agent/blex/blex-render.js'));
73
+ })
74
+ .then(function () {
75
+ return ready();
76
+ })
77
+ .catch(function (err) {
78
+ // warn, not error: a build gate that fails on any console error should
79
+ // not go red because an optional enhancement was unavailable.
80
+ console.warn('[agent] blex unavailable, falling back to plain text:', err.message);
81
+ return false;
82
+ });
83
+ return loading;
84
+ }
85
+
86
+ function ready() {
87
+ return typeof window.CumulusBlexRender !== 'undefined' && typeof window.Blex !== 'undefined';
88
+ }
89
+
90
+ /* The three required members. addChip/persist are intentionally absent —
91
+ that absence IS render-only. */
92
+ function adapter() {
93
+ return window.CumulusBlexRender.renderOnlyAdapter({
94
+ getInput: function () {
95
+ return document.querySelector('[data-testid="agent-input"]');
96
+ },
97
+ getSendButton: function () {
98
+ return document.querySelector('[data-testid="agent-send"]');
99
+ },
100
+ });
101
+ }
102
+
103
+ function extract(text) {
104
+ if (!ready()) return { text: text, blocks: [] };
105
+ return window.CumulusBlexRender.extractBlocks(text);
106
+ }
107
+
108
+ function insertPlaceholders(html, blocks) {
109
+ if (!ready() || !blocks || !blocks.length) return html;
110
+ return window.CumulusBlexRender.insertPlaceholders(html, blocks, {});
111
+ }
112
+
113
+ function render(el, blocks) {
114
+ if (!ready() || !blocks || !blocks.length) return;
115
+ try {
116
+ window.CumulusBlexRender.render(el, blocks, adapter(), {});
117
+ } catch (err) {
118
+ console.warn('[agent] blex render failed:', err.message);
119
+ }
120
+ }
121
+
122
+ function destroy(el) {
123
+ if (!ready()) return;
124
+ window.CumulusBlexRender.destroy(el);
125
+ }
126
+
127
+ window.AgentBlex = {
128
+ load: load,
129
+ ready: ready,
130
+ adapter: adapter,
131
+ extract: extract,
132
+ insertPlaceholders: insertPlaceholders,
133
+ render: render,
134
+ destroy: destroy,
135
+ };
136
+ })();