@luckydraw/cumulus 1.0.0 → 1.0.1
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/CHANGELOG.md +9 -0
- package/dist/gateway/adapters/webchat.d.ts +13 -0
- package/dist/gateway/adapters/webchat.d.ts.map +1 -1
- package/dist/gateway/adapters/webchat.js +56 -3
- package/dist/gateway/adapters/webchat.js.map +1 -1
- package/dist/gateway/static/blex-render.js +341 -0
- package/dist/gateway/static/chat.html +1 -0
- package/dist/gateway/static/widget.js +112 -127
- package/docs/web-app-agent-guide.md +40 -1
- package/examples/web-app-agent/README.md +161 -14
- package/examples/web-app-agent/gateway.config.example.json +22 -4
- package/examples/web-app-agent/public/agent/blex-mount.js +136 -0
- package/examples/web-app-agent/public/agent/bridge-mount.js +19 -3
- package/examples/web-app-agent/public/agent/commands.js +10 -4
- package/examples/web-app-agent/public/agent/device-thread.js +5 -5
- package/examples/web-app-agent/public/agent/panel.css +6 -0
- package/examples/web-app-agent/public/agent/panel.js +27 -4
- package/examples/web-app-agent/public/index.html +15 -0
- package/examples/web-app-agent/server.js +148 -11
- package/package.json +1 -1
|
@@ -92,6 +92,8 @@ Default is off; with it off every bridge surface is inert.
|
|
|
92
92
|
"command": "node",
|
|
93
93
|
"args": ["/path/to/myapp/mcp-shim.js"],
|
|
94
94
|
"env": {
|
|
95
|
+
// 8080 is the fresh-install default — use your gateway's actual
|
|
96
|
+
// top-level "port" here (jq .port ~/.cumulus/gateway.config.json).
|
|
95
97
|
"GATEWAY_ORIGIN": "http://127.0.0.1:8080",
|
|
96
98
|
"GATEWAY_API_KEY": "sk-myapp-<same-scoped-key>",
|
|
97
99
|
"BRIDGE_THREAD": "{thread}",
|
|
@@ -191,6 +193,7 @@ The front end then calls `AgentStart()` once the config arrives, and `AgentStop(
|
|
|
191
193
|
Notes:
|
|
192
194
|
|
|
193
195
|
- **Yes, the scoped key reaches the browser.** That is the design: it is worthless outside `myapp-*`, can enumerate nothing, and each visitor's thread name is its own secret. Ship only the namespace-scoped key — never an admin key.
|
|
196
|
+
- **Enumeration fails as `200` + empty, not `403`.** `/api/threads` and `/api/agents` answer `200 {"threads":[]}` to a scoped key even when the namespace holds hundreds of threads — a scoped caller already knows the one name it needs, and listing siblings would hand out every other visitor's capability. `403` is reserved for reaching _outside_ the namespace. Worth knowing if your client branches on status.
|
|
194
197
|
- **Gate it on a session anyway.** Pursuit takes the older route and injects the key into `<head>` at serve time, which puts it in public HTML, in view-source, and in any intermediary cache. It works, and the blast radius is bounded by the namespace — but a session-gated endpoint is strictly better and costs one route. Use it for new apps.
|
|
195
198
|
- In production, pass the key via a systemd drop-in (`Environment=MYAPP_API_KEY=...`), not a file in the repo.
|
|
196
199
|
- `THREAD_ID` is the **base name**; the browser appends the per-device suffix (§4.1). Minting the suffix client-side keeps the full thread name — the actual capability — from ever travelling server → client.
|
|
@@ -292,7 +295,7 @@ MyAppAgent.register(def) MyAppAgent.call(name, params)
|
|
|
292
295
|
MyAppAgent.list() MyAppAgent.manifest() // → [{ name, description, risk, input_schema }]
|
|
293
296
|
```
|
|
294
297
|
|
|
295
|
-
|
|
298
|
+
The gate is binary, and only one tier is on the gated side. **`export` never auto-runs** — the gateway forces a confirm round-trip (§5.3) regardless of caller flags. `read`, `display` and `mutate` all dispatch immediately; the tier is advisory, riding in the tool description the model sees (`[mutate] …`) so it can weigh the call, but nothing stops it. So the question when tiering a command is not "is this irreversible?" — it is **"must a human see this before it happens?"** If yes, it is `export`, whatever the verb is.
|
|
296
299
|
|
|
297
300
|
Two commands every app should register (the agent's eyes):
|
|
298
301
|
|
|
@@ -418,6 +421,42 @@ Delivery is decoupled via an event — `window.dispatchEvent(new CustomEvent('my
|
|
|
418
421
|
|
|
419
422
|
**The `data-agent-ref` convention** is what makes captured feedback _addressable_: any element rendering a record carries `data-agent-ref="accounts:ACME-01"` (optionally `/field:owner`). Ref collection walks ancestors and contained elements of the selection/target, so "this row is wrong" arrives at the agent with the exact records attached.
|
|
420
423
|
|
|
424
|
+
### 4.6b Rich blocks (`blex-mount.js`, ~110 lines)
|
|
425
|
+
|
|
426
|
+
The gateway instructs **every** thread to emit `~~~blex:TYPE` fences for tabular data, status boards, metrics, charts and diagrams. That rule is in the global includes and `mergeConfigs` is union-only, so a thread cannot opt out of it. The consequence is sharp and easy to miss: **a panel with no blex renderer shows the visitor raw JSON**, and it does so for exactly the content the model was told to present richly. Two independent halves of the system, each correct, never introduced.
|
|
427
|
+
|
|
428
|
+
Serve both halves — `blex.min.js` and `blex-render.js` — **from your own origin, resolved out of the installed cumulus package**, exactly as the kit already does for the bridge client (`server.js` → `/agent/blex/*`). Do not vendor them: `blex-render.js` is cumulus's own renderer, shared with the standalone chat widget, so a copy in your tree forks the seam contract and drifts the first time either side moves.
|
|
429
|
+
|
|
430
|
+
> **Do not `<script src="${GATEWAY_ORIGIN}/blex.min.js">`.** That recipe is correct only when your app is on a _different_ origin from the gateway. The common production shape is the opposite: `GATEWAY_ORIGIN` is your own hostname and an edge (Caddy, Cloudflare) routes just `/bridge*` and `/api/thread/*` through to the gateway. Your hostname has no `/blex.min.js`, so the load 404s and the panel degrades silently to plain text. Serving from your own origin is correct in **both** deployments, which is why the kit does it unconditionally.
|
|
431
|
+
|
|
432
|
+
**Correct origin cache headers are not a defence.** Measured in both directions on this project's own edge, and independently reproduced on a second one: the origin answers `/widget.js` with `Cache-Control: no-cache, must-revalidate` and the browser is handed `max-age=14400`. A CDN-class edge rewrites by **file extension**, regardless of what the origin said. So the `?v=` stamp is not belt-and-braces over your headers — behind such an edge it is the _only_ mechanism you have, and a reader who concludes "my origin already sends no-cache" will skip the one thing that would have worked. HTML is the exception (`no-cache`, `cf-cache-status: DYNAMIC`), which is exactly why an HTML-level stamp works at all — and why everything fetched _after_ the HTML is the hole.
|
|
433
|
+
|
|
434
|
+
Three cache traps — the first two measured at a real Cloudflare edge, and each one makes a _correct_ deploy look broken:
|
|
435
|
+
|
|
436
|
+
- **404s are cached too.** If you probe the route before it exists, the edge caches the 404 for its default TTL (measured: `max-age=14400` with `cf-cache-status: HIT`, overriding the origin's `no-cache`) — so a correct deploy keeps serving "no library" for four hours. This is nastier than stale content because it reads as "my route isn't registered", sending you to re-debug working code. After adding a route the edge has already seen 404, purge or cache-bust before concluding anything about the route.
|
|
437
|
+
- **An HTML stamp only reaches what HTML requests.** A `?v=` on a `<script src>` versions that file and nothing the file goes on to fetch by itself — so a loader that pulls its own dependencies at runtime has to propagate the version token, or the parent is versioned and its children are not. The kit's `server.js` stamps every `src`/`href` it can see in the served HTML, and — because `panel.css` and the two blex scripts are attached from _inside_ JavaScript — also fills a `window.__AGENT_ASSET_V` map that those loaders consult via `window.agentAsset(url)`. **Do not lift the token off `document.currentScript.src`.** It is the obvious shortcut and it has already shipped broken in a real adopter: that token describes _your_ build, but the library it stamps comes out of the _installed cumulus package_, so a cumulus upgrade changes the bytes while your build — and therefore the URL — stands still, and browsers keep the old library indefinitely. A server-published map gives each file its **own** hash, so the upgrade busts it even though the loader's own bytes didn't move. If you test this, assert that the library URL's token moves when the **package** file changes, not when your build does; a test that rebuilds the app passes against the broken version. (This is a general rule, not a blex problem: `blex.min.js` fetches nothing. Measured on `@luckydraw/blex@0.1.16` — its only dynamic `import()` is the bare `"mermaid"` specifier discussed in §4.6b, `Chart.js v4.5.1` is inlined, and `blex-chart.min.js` is an opt-in companion global that nothing requests.)
|
|
438
|
+
- **Static ESM `import` specifiers can't be stamped this way.** `bridge-mount.js` imports `client.js`, which imports `protocol.js`; both are fetched bare. They ship from the cumulus package and change only on upgrade, and the kit serves them `no-cache, must-revalidate` — which, per the paragraph above, a CDN-class edge will override anyway. Treat these as genuinely unstamped: excluding `/agent/` from your CDN is the fix, not a precaution.
|
|
439
|
+
|
|
440
|
+
(One caveat if you probe with `curl -I`: the gateway answers `HEAD` on static assets with `401` while `GET` returns `200` with `Access-Control-Allow-Origin: *`. Probe with `GET`; the asset is not auth-gated.)
|
|
441
|
+
|
|
442
|
+
Drive it with an adapter. The required trio is `allowType` / `getInput` / `getSendButton`; `addChip` and `persist` are optional, **and leaving them out is what makes a surface render-only** — there is no mode flag to set.
|
|
443
|
+
|
|
444
|
+
```js
|
|
445
|
+
window.CumulusBlexRender.renderOnlyAdapter({
|
|
446
|
+
getInput: () => document.querySelector('[data-testid="agent-input"]'),
|
|
447
|
+
getSendButton: () => document.querySelector('[data-testid="agent-send"]'),
|
|
448
|
+
});
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
`renderOnlyAdapter` denies `confirm`, `poll`, `form` and `diff`. The test the allow set is derived from is: _a type may render iff every affordance it draws either completes locally or is purely visual._ Selection in `table`/`code`/`file-tree`/`timeline`/`image` is a highlight, so a no-op is invisible. `terminal`/`svg` draw labelled buttons but the clipboard write and the object-URL download both run **before** the emit — the emit is a receipt, not the mechanism. `diff` fails the test: Apply/Reject have no local half, so on a render-only surface they are guaranteed dead buttons that read as "click to apply" — worse than not rendering, because a dead button looks alive in a way raw JSON does not.
|
|
452
|
+
|
|
453
|
+
Two implementation traps:
|
|
454
|
+
|
|
455
|
+
- **Extract fences before your code-block pass**, not after. Otherwise a fence someone is _showing_ inside a ``` block is eaten by the extractor and vanishes from the block that exists to display it.
|
|
456
|
+
- **Do not implement deny with `unregisterBlockType`.** The registry is module-global, so unregistering `confirm` removes it for every surface sharing that module instance. Deny is a per-render gate. (Nor can you route denials to a library fallback renderer — the global bundle exports no such thing; it exists only on the ESM surface. Leaving the container unclaimed shows the raw fence, which is the honest outcome and costs nothing.)
|
|
457
|
+
|
|
458
|
+
Nothing is lost by not rendering `confirm`: export-tier confirms arrive over the **bridge** as a native, audited chip (§4.3), never through message content.
|
|
459
|
+
|
|
421
460
|
### 4.7 Testability (house rules)
|
|
422
461
|
|
|
423
462
|
Per the global standards: every interactive element gets a `data-testid` (`agent-panel-*`, `agent-fb-pop|input|send|caret|queue`), the message list carries `data-loading`/`aria-busy` while streaming, and `window.__PUPPET_TEST_MODE__` sets `data-test-mode` to kill animations.
|
|
@@ -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
|
|
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
|
|
|
@@ -74,6 +84,7 @@ public/agent/commands.js ← THE FILE YOU WRITE. Your capability surface.
|
|
|
74
84
|
public/agent/device-thread.js per-visitor thread identity
|
|
75
85
|
public/agent/bridge-mount.js wires the bridge client to your registry
|
|
76
86
|
public/agent/chat-client.js speaks the gateway's chat API (SSE)
|
|
87
|
+
public/agent/blex-mount.js rich blocks (tables, charts, diagrams) — optional
|
|
77
88
|
public/agent/panel.js the chat UI
|
|
78
89
|
public/agent/panel.css themed from six CSS variables
|
|
79
90
|
```
|
|
@@ -82,9 +93,87 @@ Copy all of `public/agent/` and `agent/mcp-shim.js` as-is. Then **rewrite
|
|
|
82
93
|
`commands.js`** against your own app and delete the demo's notes commands.
|
|
83
94
|
That's the whole port.
|
|
84
95
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
the
|
|
96
|
+
Three browser files are deliberately **not** in this tree — the `BridgeClient`,
|
|
97
|
+
the blex renderer and the blex library. `server.js` resolves all of them out of
|
|
98
|
+
the installed `@luckydraw/cumulus` package and serves them from **your own
|
|
99
|
+
origin** (`/agent/bridge-client/…`, `/agent/blex/…`), so they can't drift from
|
|
100
|
+
the gateway and can't depend on how your edge is routed. Don't fork them, and
|
|
101
|
+
don't load them cross-origin from `GATEWAY_ORIGIN` — that only works if your app
|
|
102
|
+
is on a different hostname than the gateway; if it isn't (the common shape: your
|
|
103
|
+
hostname, with the edge routing only `/bridge*` and `/api/thread/*` through), the
|
|
104
|
+
cross-origin URL 404s and blex degrades to plain text with no obvious cause.
|
|
105
|
+
|
|
106
|
+
### Rich blocks
|
|
107
|
+
|
|
108
|
+
The gateway instructs every thread to emit `~~~blex` fences for tables, status
|
|
109
|
+
boards, metrics, charts and diagrams — it's a global rule and a thread can't opt
|
|
110
|
+
out. So a panel with no renderer shows the visitor **raw JSON**. That isn't a
|
|
111
|
+
gateway bug; it's a missing half, and it's what `blex-mount.js` supplies.
|
|
112
|
+
|
|
113
|
+
It loads two scripts from your own origin (`/agent/blex/…`, served out of the
|
|
114
|
+
installed cumulus package — see above) at mount time, and degrades to plain
|
|
115
|
+
markdown if either is unavailable. Nothing else in the kit depends on it.
|
|
116
|
+
|
|
117
|
+
The panel is **render-only**: `confirm`, `poll`, `form` and `diff` are not
|
|
118
|
+
rendered, because their buttons have no local half here and a dead "Apply"
|
|
119
|
+
button reads as live in a way raw JSON does not. Denied fences stay visible as
|
|
120
|
+
their original text. Nothing is lost — confirms arrive over the _bridge_ as a
|
|
121
|
+
native audited chip, never through message content.
|
|
122
|
+
|
|
123
|
+
If your app has a build step that mirrors static assets into an output
|
|
124
|
+
directory, note that adopting this kit adds a whole **directory** (`agent/`) to
|
|
125
|
+
that output. A rollback that merge-copies a snapshot over the output tree
|
|
126
|
+
(`cp -r snapshot/. dist/`) cannot remove a directory the bad build added — the
|
|
127
|
+
agent assets survive the rollback and keep being served while the rest of the
|
|
128
|
+
app has no reference to them. A verification pass that checks referenced assets
|
|
129
|
+
(a version hash, a manifest) reports success — the orphan is unreferenced, so
|
|
130
|
+
nothing looks at it. Roll back by replacing the output directory, and verify
|
|
131
|
+
with `diff -rq` against the snapshot.
|
|
132
|
+
|
|
133
|
+
### Asset delivery (why every URL carries `?v=`)
|
|
134
|
+
|
|
135
|
+
`server.js` stamps each asset URL with a hash of its bytes and serves it
|
|
136
|
+
`no-cache, must-revalidate` — unless the request's `?v=` matches the current
|
|
137
|
+
hash, in which case it gets `immutable`. Copy this into your own server.
|
|
138
|
+
|
|
139
|
+
The reason is measured, not theoretical: a Cloudflare edge **overrides** an
|
|
140
|
+
origin's `no-cache` with `max-age=14400`. Without a stamp, an edit to
|
|
141
|
+
`commands.js` — the file you'll change most — can take four hours to reach a
|
|
142
|
+
browser, and each file expires on its own clock, so a visitor can end up holding
|
|
143
|
+
`panel.js` from one deploy and `commands.js` from another. A content-addressed
|
|
144
|
+
URL is the only part of this a cache policy can't override.
|
|
145
|
+
|
|
146
|
+
Two details worth keeping when you port it:
|
|
147
|
+
|
|
148
|
+
- **The hash is computed at serve time**, cached on mtime+size. So a deploy
|
|
149
|
+
lands with no restart, and the hash always describes the bytes actually on
|
|
150
|
+
disk — a build-time hash goes stale against anything edited afterwards, and
|
|
151
|
+
the blex/bridge assets come out of the installed cumulus package, which
|
|
152
|
+
changes on `npm i`, not on your build.
|
|
153
|
+
- **A stale or forged `?v=` must not get `immutable`.** Otherwise a wrong token
|
|
154
|
+
pins today's bytes under a key that no longer describes them.
|
|
155
|
+
|
|
156
|
+
An HTML-level stamp can only reach URLs that appear in the markup. Three assets
|
|
157
|
+
here are loaded from _inside_ JavaScript — `panel.css` (a `<link>` built by
|
|
158
|
+
`bridge-mount`) and the two blex scripts (appended by `blex-mount`) — so
|
|
159
|
+
`index.html` also publishes a `window.__AGENT_ASSET_V` map that the server fills
|
|
160
|
+
in, and those loaders call `window.agentAsset(url)` to look themselves up.
|
|
161
|
+
General rule: **a loader that fetches its own dependencies has to propagate the
|
|
162
|
+
version token**, because nothing upstream can see that URL.
|
|
163
|
+
|
|
164
|
+
Known limit: `bridge-mount.js` reaches `client.js` through a static ESM
|
|
165
|
+
`import`, and `client.js` imports `protocol.js` in turn, so those two are
|
|
166
|
+
fetched unstamped. Both ship from the cumulus package and change only on
|
|
167
|
+
upgrade, and the server marks them `no-cache`, so the browser is correct — an
|
|
168
|
+
edge that overrides it is the exposure. Simplest answer: exclude `/agent/` from
|
|
169
|
+
your CDN.
|
|
170
|
+
|
|
171
|
+
And one trap that makes a _correct_ deploy look broken: **404s get cached too.**
|
|
172
|
+
If you probe a route before it exists, the edge caches the 404 for its default
|
|
173
|
+
TTL (measured: `max-age=14400`, `cf-cache-status: HIT`), so after you ship the
|
|
174
|
+
route it keeps serving "not found" for four hours — which reads as "my route is
|
|
175
|
+
wrong" and sends you off to re-debug working code. Purge before concluding
|
|
176
|
+
anything.
|
|
88
177
|
|
|
89
178
|
---
|
|
90
179
|
|
|
@@ -99,6 +188,17 @@ knowing a thread's name is what grants access to that conversation. Which means:
|
|
|
99
188
|
full name never travels server → client where it could be logged or cached.
|
|
100
189
|
- Use **at least 16 hex characters** (64 bits). 8 is brute-forceable against a
|
|
101
190
|
live gateway.
|
|
191
|
+
- Mint them with a **CSPRNG** — `crypto.getRandomValues(new Uint8Array(8))`, as
|
|
192
|
+
`device-thread.js` does. The length is not the guarantee; the primitive is.
|
|
193
|
+
`Math.random().toString(16)` yields an id that reads correct at 16 characters
|
|
194
|
+
and is seeded from predictable state, so it fails the only test that matters.
|
|
195
|
+
|
|
196
|
+
`device-thread.js` re-mints whenever the stored id fails `/^[0-9a-f]{16,}$/`, so
|
|
197
|
+
an app that previously shipped short ids upgrades every visitor to 64 bits on
|
|
198
|
+
first load — no migration code. Know the cost before you rely on it: a new id is
|
|
199
|
+
a **new thread**, so the prior conversation becomes unreachable from that browser
|
|
200
|
+
(it stays on disk, addressable only by its old name). Widening entropy is
|
|
201
|
+
therefore a one-way cut-over for existing visitors, not a transparent fix.
|
|
102
202
|
|
|
103
203
|
### 2. Never ship the key in the page
|
|
104
204
|
|
|
@@ -106,6 +206,40 @@ knowing a thread's name is what grants access to that conversation. Which means:
|
|
|
106
206
|
session. If you inject it into static HTML instead, anyone who views source can
|
|
107
207
|
talk to your gateway as your app.
|
|
108
208
|
|
|
209
|
+
This is not hypothetical — it has shipped. An app inlined its key into a
|
|
210
|
+
`<script>` block in `index.html`; the key was live on the public origin and
|
|
211
|
+
authenticated successfully against the gateway. What let it survive review was a
|
|
212
|
+
comment elsewhere in the tree describing a _different_ file as the sensitive one
|
|
213
|
+
("dev only, excluded from builds"). The documented mitigation was aimed at a
|
|
214
|
+
file that wasn't the leak. If your repo has a note about where the key lives,
|
|
215
|
+
verify it against what the origin actually serves:
|
|
216
|
+
|
|
217
|
+
```sh
|
|
218
|
+
curl -s https://your-app.example.com/ | grep -i 'api_key\|sk-'
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
The blast radius is bounded by §1 and by the gateway's namespace enforcement: a
|
|
222
|
+
scoped key cannot enumerate threads, cannot read outside its namespace, and
|
|
223
|
+
cannot reach gateway settings. So an exposed key is worth exactly the thread
|
|
224
|
+
names an attacker can guess — which is why §1's 64 bits is load-bearing rather
|
|
225
|
+
than belt-and-braces. Exposing the key turns your device-id entropy into the
|
|
226
|
+
_only_ remaining barrier.
|
|
227
|
+
|
|
228
|
+
**How enumeration fails matters if your client branches on status.** The list
|
|
229
|
+
endpoints do not refuse a scoped key — they answer `200` with an empty list:
|
|
230
|
+
|
|
231
|
+
```
|
|
232
|
+
GET /api/threads → 200 {"threads":[]}
|
|
233
|
+
GET /api/agents → 200 {"agents":[]}
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Empty is not "your namespace happens to be empty." It is empty even when the
|
|
237
|
+
namespace holds hundreds of live threads, because a scoped caller already knows
|
|
238
|
+
the one name it needs and listing siblings would hand out every other visitor's
|
|
239
|
+
capability. Reads outside the namespace are the ones that `403`. Don't write a
|
|
240
|
+
client that reads `200` as "I'm allowed to enumerate, there just isn't anything
|
|
241
|
+
here" — it will never see a thread, and no error will tell it why.
|
|
242
|
+
|
|
109
243
|
### 3. Commands act through an adapter, not the DOM
|
|
110
244
|
|
|
111
245
|
`window.HostApp` is the app's own actions exposed as functions. Commands call
|
|
@@ -130,16 +264,29 @@ The second one prevents a whole class of annoying behaviour.
|
|
|
130
264
|
|
|
131
265
|
### 5. Pick the risk tier honestly
|
|
132
266
|
|
|
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` |
|
|
139
|
-
|
|
140
|
-
`export` is
|
|
141
|
-
|
|
142
|
-
|
|
267
|
+
| tier | meaning | gated? |
|
|
268
|
+
| --------- | ----------------------------------------------- | ---------------------- |
|
|
269
|
+
| `read` | answers a question, changes nothing | no — runs immediately |
|
|
270
|
+
| `display` | changes the view only, trivially undoable | no — runs immediately |
|
|
271
|
+
| `mutate` | changes stored data, but recoverably | no — runs immediately |
|
|
272
|
+
| `export` | must not happen without a human seeing it first | **yes — confirm chip** |
|
|
273
|
+
|
|
274
|
+
**The gate is binary and `export` is the only tier on the gated side.** The
|
|
275
|
+
gateway confirm-gates a call when its manifest entry says `risk: "export"`, full
|
|
276
|
+
stop — `read`, `display` and `mutate` all dispatch on arrival. Those three tiers
|
|
277
|
+
are advisory: the tier rides in the tool description the model sees
|
|
278
|
+
(`[mutate] …`) so it can weigh the call, but nothing stops it.
|
|
279
|
+
|
|
280
|
+
So the question when tiering is **not** "is this irreversible?" — it is _"must a
|
|
281
|
+
human see this before it happens?"_ If yes, it is `export`, whatever the verb
|
|
282
|
+
is. An app that wants every write confirmed puts every write at `export`; the
|
|
283
|
+
alternative is a command that reads as guarded and isn't, and the failure is
|
|
284
|
+
silent — it just runs.
|
|
285
|
+
|
|
286
|
+
Worth an explicit test: enumerate the registered manifest and fail on anything
|
|
287
|
+
outside an allowlist of genuinely read-only commands that is not `export`. A
|
|
288
|
+
command quietly retiered down becomes an ungated write while a suite that only
|
|
289
|
+
checks the chip's behaviour stays green.
|
|
143
290
|
|
|
144
291
|
---
|
|
145
292
|
|
|
@@ -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
|
-
"
|
|
18
|
-
"
|
|
19
|
-
"
|
|
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
|
+
})();
|
|
@@ -6,7 +6,14 @@
|
|
|
6
6
|
|
|
7
7
|
Because the agent config arrives only after login, this is a function the
|
|
8
8
|
app calls (AgentStart) rather than something that runs at load. AgentStop
|
|
9
|
-
tears it down on logout so a second login re-mounts cleanly.
|
|
9
|
+
tears it down on logout so a second login re-mounts cleanly.
|
|
10
|
+
|
|
11
|
+
KNOWN LIMIT of the version stamp: the import below is a static specifier, so
|
|
12
|
+
client.js — and protocol.js, which client.js imports in turn — are fetched
|
|
13
|
+
unstamped. Both ship from the cumulus package and change only on upgrade, and
|
|
14
|
+
the server sends `no-cache, must-revalidate` on them, so the browser is
|
|
15
|
+
correct; an edge that overrides that is the exposure. If you put a CDN in
|
|
16
|
+
front of this app, exclude /agent/ from it. */
|
|
10
17
|
import { BridgeClient } from './bridge-client/client.js';
|
|
11
18
|
|
|
12
19
|
let bridge = null;
|
|
@@ -16,20 +23,29 @@ window.AgentStart = function () {
|
|
|
16
23
|
const cfg = window.__AGENT_CONFIG__;
|
|
17
24
|
if (!cfg || !cfg.API_KEY || !window.AgentRegistry || !window.agentDeviceThread) return;
|
|
18
25
|
|
|
19
|
-
window.agentDeviceThread(cfg); // THREAD_ID ->
|
|
26
|
+
window.agentDeviceThread(cfg); // THREAD_ID -> <your-namespace>-v-<deviceId>
|
|
20
27
|
console.info('[agent] thread: ' + cfg.THREAD_ID);
|
|
21
28
|
|
|
22
29
|
if (!document.getElementById('agent-css')) {
|
|
23
30
|
const link = document.createElement('link');
|
|
24
31
|
link.id = 'agent-css';
|
|
25
32
|
link.rel = 'stylesheet';
|
|
26
|
-
|
|
33
|
+
// Built here, so the server's HTML stamp cannot see it — it takes its hash
|
|
34
|
+
// from the map index.html publishes. Bare URL if that map is absent.
|
|
35
|
+
link.href =
|
|
36
|
+
typeof window.agentAsset === 'function'
|
|
37
|
+
? window.agentAsset('/agent/panel.css')
|
|
38
|
+
: '/agent/panel.css';
|
|
27
39
|
document.head.appendChild(link);
|
|
28
40
|
}
|
|
29
41
|
|
|
30
42
|
window.AgentChat.init(cfg);
|
|
31
43
|
window.AgentPanel.init();
|
|
32
44
|
|
|
45
|
+
// Rich blocks, loaded same-origin from /agent/blex/. Fire-and-forget: the panel renders
|
|
46
|
+
// plain markdown until these arrive, and keeps doing so if they never do.
|
|
47
|
+
if (window.AgentBlex) window.AgentBlex.load(cfg);
|
|
48
|
+
|
|
33
49
|
bridge = new BridgeClient({
|
|
34
50
|
url: cfg.BRIDGE_URL,
|
|
35
51
|
thread: cfg.THREAD_ID,
|
|
@@ -11,12 +11,18 @@
|
|
|
11
11
|
|
|
12
12
|
Frozen contract:
|
|
13
13
|
command { name, description, params, risk, execute(params) }
|
|
14
|
-
risk "
|
|
14
|
+
risk "export" — the ONLY gated tier. ALWAYS routed through the
|
|
15
|
+
confirm chip by the gateway; the model cannot bypass
|
|
16
|
+
it, and neither can this file.
|
|
17
|
+
"read" — answers a question, changes nothing
|
|
15
18
|
"display" — changes what's on screen only, trivially undoable
|
|
16
19
|
"mutate" — changes stored data
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
+
The last three are ADVISORY: they dispatch on arrival. The tier
|
|
21
|
+
rides in the tool description the model sees ("[mutate] …") so it
|
|
22
|
+
can weigh the call, but nothing stops it. Tier by "must a human
|
|
23
|
+
see this first?", not by "is this irreversible?" — if yes, it is
|
|
24
|
+
"export". A command that reads as guarded and isn't fails
|
|
25
|
+
silently: it just runs.
|
|
20
26
|
execute -> { summary, data?, affected? } (may be async)
|
|
21
27
|
call() never throws — failures become { ok: false, summary }
|
|
22
28
|
|
|
@@ -5,16 +5,16 @@
|
|
|
5
5
|
name must be unguessable and must never be minted server-side and broadcast.
|
|
6
6
|
|
|
7
7
|
This mints 16 hex characters (64 bits) once per browser, keeps it in
|
|
8
|
-
localStorage, and pins THREAD_ID = <base>-<deviceId>. The base the
|
|
9
|
-
|
|
10
|
-
get their own gateway config (cheap model,
|
|
11
|
-
"
|
|
8
|
+
localStorage, and pins THREAD_ID = <base>-<deviceId>. The base comes from the
|
|
9
|
+
server (this file never names your app). Send "<ns>-v" — a sub-namespace
|
|
10
|
+
under "<ns>" — so visitor threads get their own gateway config (cheap model,
|
|
11
|
+
tight prompt) while the base "<ns>" thread stays yours for working on the app.
|
|
12
12
|
|
|
13
13
|
Do not shorten the id. 8 hex characters is 32 bits, which is brute-forceable
|
|
14
14
|
against a live gateway. */
|
|
15
15
|
(function () {
|
|
16
16
|
'use strict';
|
|
17
|
-
var KEY = '
|
|
17
|
+
var KEY = 'agent.deviceId'; // app-neutral: localStorage is origin-partitioned
|
|
18
18
|
|
|
19
19
|
window.agentDeviceThread = function (cfg) {
|
|
20
20
|
if (!cfg || !cfg.THREAD_ID) return null;
|
|
@@ -79,6 +79,12 @@
|
|
|
79
79
|
#agent-root .agmsg table { border-collapse: collapse; font-size: 12.5px; }
|
|
80
80
|
#agent-root .agmsg th, #agent-root .agmsg td { border: 1px solid var(--line); padding: 4px 8px; text-align: left; }
|
|
81
81
|
|
|
82
|
+
/* Blex blocks. The container is what the shared renderer (blex-render.js) fills;
|
|
83
|
+
.blex-fallback is the raw fence, shown when nothing claims the container —
|
|
84
|
+
a denied type, or blex failing to load. Both beat a blank rectangle. */
|
|
85
|
+
#agent-root .blex-block-container { margin: 6px 0; border-radius: 8px; overflow: hidden; max-width: 100%; }
|
|
86
|
+
#agent-root .blex-fallback { margin: 0; padding: 8px 10px; background: var(--panel3); border: 1px solid var(--line); border-radius: 8px; font-size: 11.5px; line-height: 1.45; white-space: pre-wrap; word-break: break-word; opacity: .85; overflow-x: auto; }
|
|
87
|
+
|
|
82
88
|
#agent-root .agactivity { align-self: flex-start; color: var(--muted); font-size: 12px; padding: 0 4px; animation: agpulse 1.4s ease-in-out infinite; }
|
|
83
89
|
@keyframes agpulse { 0%, 100% { opacity: .55; } 50% { opacity: 1; } }
|
|
84
90
|
/* Browser tests set data-test-mode to kill animation timing races. */
|
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
window.AgentPanel = { init(), setState(s), confirm(req), notify(text), reset() } */
|
|
10
10
|
(function () {
|
|
11
11
|
'use strict';
|
|
12
|
-
|
|
12
|
+
/* App-neutral on purpose: localStorage is already origin-partitioned, so an
|
|
13
|
+
app-name prefix buys nothing, and this file is meant to be copied as-is. */
|
|
14
|
+
var LS_KEY = 'agent-panel-v1';
|
|
13
15
|
var root, barEl, winEl, logEl, inputEl, dotEl;
|
|
14
16
|
var prefs = { expanded: false, draft: '' };
|
|
15
17
|
var streaming = false;
|
|
@@ -78,6 +80,20 @@
|
|
|
78
80
|
});
|
|
79
81
|
}
|
|
80
82
|
|
|
83
|
+
/* Render markdown, then blex blocks into it.
|
|
84
|
+
|
|
85
|
+
Blex fences are pulled out FIRST so the code-block branch below cannot eat
|
|
86
|
+
them, and the placeholders go back in last. Returns the extracted blocks so
|
|
87
|
+
the caller can hand them to AgentBlex.render once the HTML is in the DOM.
|
|
88
|
+
Without blex loaded this is exactly the old behaviour. */
|
|
89
|
+
function renderRich(text) {
|
|
90
|
+
var blex = window.AgentBlex;
|
|
91
|
+
var extracted = blex ? blex.extract(String(text)) : { text: String(text), blocks: [] };
|
|
92
|
+
var html = renderMarkdown(extracted.text);
|
|
93
|
+
if (blex) html = blex.insertPlaceholders(html, extracted.blocks);
|
|
94
|
+
return { html: html, blocks: extracted.blocks };
|
|
95
|
+
}
|
|
96
|
+
|
|
81
97
|
function renderMarkdown(text) {
|
|
82
98
|
var lines = String(text).split('\n');
|
|
83
99
|
var html = '',
|
|
@@ -170,8 +186,11 @@
|
|
|
170
186
|
|
|
171
187
|
function addMsg(role, content, asMarkdown) {
|
|
172
188
|
var m = el('div', 'agmsg ' + role, logEl);
|
|
173
|
-
if (asMarkdown)
|
|
174
|
-
|
|
189
|
+
if (asMarkdown) {
|
|
190
|
+
var r = renderRich(content);
|
|
191
|
+
m.innerHTML = r.html;
|
|
192
|
+
if (window.AgentBlex) window.AgentBlex.render(m, r.blocks);
|
|
193
|
+
} else m.textContent = content;
|
|
175
194
|
scrollLog();
|
|
176
195
|
return m;
|
|
177
196
|
}
|
|
@@ -235,7 +254,11 @@
|
|
|
235
254
|
streaming = false;
|
|
236
255
|
setLoading(false);
|
|
237
256
|
activity.remove();
|
|
238
|
-
|
|
257
|
+
// Blocks are rendered only at `done` — mid-stream a fence is usually
|
|
258
|
+
// incomplete, and re-rendering each token would churn the handles.
|
|
259
|
+
var done = renderRich(acc);
|
|
260
|
+
bubble.innerHTML = done.html;
|
|
261
|
+
if (window.AgentBlex) window.AgentBlex.render(bubble, done.blocks);
|
|
239
262
|
scrollLog();
|
|
240
263
|
},
|
|
241
264
|
});
|