@anchrd/intel-api 0.16.1 → 0.17.0
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/README.md +328 -115
- package/dist/adapters/db/db-flows.js +10 -1
- package/dist/adapters/db/db.js +27 -3
- package/dist/flows/flows.js +50 -18
- package/dist/nodes/nodes.types.d.ts +1 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -22,26 +22,203 @@ customer CLI.
|
|
|
22
22
|
optional and only add semantic search and attachment conversion)
|
|
23
23
|
- Node 22 or newer
|
|
24
24
|
|
|
25
|
-
##
|
|
25
|
+
## Setting Intel up
|
|
26
|
+
|
|
27
|
+
Nine steps. Step 3 talks only to Gate and can happen at any point; the rest run in this order,
|
|
28
|
+
because each one needs what the one before it produced. **Step 8 is the one people skip**, and
|
|
29
|
+
skipping it produces an installation that looks broken and is not.
|
|
26
30
|
|
|
27
31
|
```bash
|
|
28
32
|
npm install @anchrd/intel-api @anchrd/intel-ui
|
|
29
|
-
npx intel prepare
|
|
30
|
-
npx intel bootstrap
|
|
31
|
-
npx intel build
|
|
32
|
-
npx intel doctor
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
### 1. Create the Cloudflare resources
|
|
36
|
+
|
|
37
|
+
Workflows are created by the deploy in step 7; everything else has to exist first.
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
wrangler d1 create intel # note the printed database_id
|
|
41
|
+
wrangler r2 bucket create intel-content
|
|
42
|
+
wrangler queues create intel-indexing
|
|
43
|
+
wrangler queues create intel-indexing-dlq # without it an exhausted message is dropped silently
|
|
44
|
+
wrangler vectorize create intel-nodes --dimensions=1024 --metric=cosine # optional, see Bindings
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The Vectorize shape is not a free choice: 1024 dimensions and cosine are what Intel's default
|
|
48
|
+
multilingual Workers AI embedding model (`@cf/baai/bge-m3`) produces. Nothing in the deploy compares
|
|
49
|
+
the two, so an index created with other values fails later, at the first write.
|
|
50
|
+
|
|
51
|
+
### 2. Write the Worker
|
|
52
|
+
|
|
53
|
+
The customer edge re-exports the ready-made Cloudflare shell and nothing else. The named Workflow
|
|
54
|
+
class has to travel with it or Wrangler cannot find the entrypoint:
|
|
36
55
|
|
|
37
56
|
```ts
|
|
38
57
|
// biome-ignore lint/performance/noBarrelFile: Wrangler needs the named Workflow entrypoint.
|
|
39
58
|
export { default, IntelFlowWorkflow } from "@anchrd/intel-api/cloudflare";
|
|
40
59
|
```
|
|
41
60
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
61
|
+
Its Wrangler configuration declares the bindings from the table below, and points
|
|
62
|
+
`migrations_dir` at `.intel/migrations` — the directory step 4 fills. `assets.directory` points at
|
|
63
|
+
`.intel/ui`, which step 5 fills.
|
|
64
|
+
|
|
65
|
+
### 3. Declare Intel's interfaces in Gate
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
GATE_URL=… GATE_SERVICE_KEY=… npx intel bootstrap
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
⚠️ **`bootstrap` reads its configuration from the environment of the shell it runs in**, not from
|
|
72
|
+
the Worker's secrets — the two are different places that happen to use the same names. Without both
|
|
73
|
+
values it stops with `Error: GATE_URL and GATE_SERVICE_KEY must be set.` before it does anything.
|
|
74
|
+
|
|
75
|
+
It declares five interfaces — `intel`, `nodes`, `flows`, `tools`, `mcp` — with the functions listed
|
|
76
|
+
under *Permission, layer one* below. It is idempotent and safe to repeat after an upgrade.
|
|
77
|
+
|
|
78
|
+
⚠️ **`bootstrap` declares interfaces and changes no grant.** It creates the permissions an
|
|
79
|
+
administrator can hand out; it hands out none of them. This is step 8, and it is why a fresh
|
|
80
|
+
installation shows an empty screen to everybody including the person who installed it.
|
|
81
|
+
|
|
82
|
+
⚠️ It also never revokes. An interface an older version declared stays declared in Gate; that is
|
|
83
|
+
harmless, and removing one is an act in Gate, by hand.
|
|
84
|
+
|
|
85
|
+
### 4. Copy the migrations out of `node_modules`
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
npx intel prepare
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
This writes the versioned SQL to `.intel/migrations`. It only copies — applying them is the next
|
|
92
|
+
step, and nothing does it for you:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
wrangler d1 migrations apply intel --local # a local Worker
|
|
96
|
+
wrangler d1 migrations apply intel --remote # the deployed one
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Migration filenames and their order are immutable after release. Re-run `intel prepare` after every
|
|
100
|
+
upgrade of this package, before applying.
|
|
101
|
+
|
|
102
|
+
### 5. Build the UI
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
npx intel build
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`intel build` reads an optional strict `intel.json`, applies the customer branding, and writes the
|
|
109
|
+
static output to `.intel/ui`. See *Branding* below. No customer build output ever lives in
|
|
110
|
+
`node_modules`.
|
|
111
|
+
|
|
112
|
+
### 6. Configure the Worker
|
|
113
|
+
|
|
114
|
+
Set the variables and secrets from the two tables below. `GATE_SERVICE_KEY` and
|
|
115
|
+
`INTEL_SESSION_SECRET` are Wrangler secrets (`wrangler secret put …`); the rest are plain vars.
|
|
116
|
+
|
|
117
|
+
For a local Worker, copy `node_modules/@anchrd/intel-api/examples/dev.vars.example` to `.dev.vars`.
|
|
118
|
+
|
|
119
|
+
### 7. Check it, then deploy
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
npx intel doctor
|
|
123
|
+
wrangler deploy
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
`doctor` checks the same environment `bootstrap` uses, plus the installed packages and migrations
|
|
127
|
+
and the Gate interfaces. It reports every problem at once rather than the first:
|
|
128
|
+
|
|
129
|
+
```text
|
|
130
|
+
Note: MCP_PORTAL_URL is unset, so the Tools area stays empty.
|
|
131
|
+
FAIL GATE_URL is missing
|
|
132
|
+
FAIL INTEL_URL is missing
|
|
133
|
+
FAIL GATE_SERVICE_KEY is missing
|
|
134
|
+
FAIL TOOL_SOURCE_ORIGINS is missing
|
|
135
|
+
FAIL INTEL_SESSION_SECRET must contain at least 32 bytes
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
⚠️ **`doctor` cannot see the Worker's secrets.** It answers about the shell it runs in, so it says
|
|
139
|
+
nothing about whether the deployed Worker is configured. `GET /health` on the deployed Worker
|
|
140
|
+
answers `{"status":"ok"}`; a Worker missing one of the five required variables answers
|
|
141
|
+
`500 configuration_missing` and names them.
|
|
142
|
+
|
|
143
|
+
⚠️ **`doctor` does not check that step 4 happened.** It looks for the migrations *installed* in
|
|
144
|
+
`node_modules`, not for the copies in `.intel/migrations`, so it answers `OK` on a project that never
|
|
145
|
+
ran `intel prepare`. `wrangler deploy` does not mind an empty `migrations_dir` either, and the first
|
|
146
|
+
sign is a database with no tables in it. The `.intel/migrations` directory has to be checked by eye.
|
|
147
|
+
|
|
148
|
+
### 8. Hand out permissions — both layers
|
|
149
|
+
|
|
150
|
+
This is the step that decides whether anybody sees anything, and it is entirely manual.
|
|
151
|
+
|
|
152
|
+
1. In **Gate**, grant people the Intel capabilities from *Permission, layer one*. Without
|
|
153
|
+
`nodes.read` the sidebar answers `403` and says a permission is missing.
|
|
154
|
+
2. In **Intel**, share a node with them. Without a grant the tree is empty — correctly, because
|
|
155
|
+
nothing has been shared yet.
|
|
156
|
+
|
|
157
|
+
Both are needed. Neither on its own produces a usable screen, and the symptom of missing either one
|
|
158
|
+
is the same shape: a view with nothing in it. See *Permission has two layers* below.
|
|
159
|
+
|
|
160
|
+
### 9. Connect the portal, if there is one
|
|
161
|
+
|
|
162
|
+
Tools are optional. If the installation has a Cloudflare MCP Portal, set `MCP_PORTAL_URL` and add
|
|
163
|
+
its origin to `TOOL_SOURCE_ORIGINS`, then read *Tools come from the portal* below — two settings
|
|
164
|
+
outside this repository decide whether the silent sign-in can work at all.
|
|
165
|
+
|
|
166
|
+
## Bindings
|
|
167
|
+
|
|
168
|
+
The reference Wrangler deployment binds all of these. "Required" means a request fails without it,
|
|
169
|
+
not that Wrangler refuses to deploy.
|
|
170
|
+
|
|
171
|
+
| Binding | Resource | Required | What its absence costs |
|
|
172
|
+
|---|---|---|---|
|
|
173
|
+
| `DB` | D1 | **yes** | Nothing works: D1 owns metadata, relationships, grants, flow and run state, audit |
|
|
174
|
+
| `CONTENT` | R2 | **yes** | Node content and attachments cannot be read or written |
|
|
175
|
+
| `INDEXING` | Queue | **yes** | Every save fails; this is the queue that builds the indexes |
|
|
176
|
+
| `FLOWS` | Workflow `IntelFlowWorkflow` | **yes** | Flows cannot be run |
|
|
177
|
+
| `ASSETS` | static assets from `.intel/ui` | for the UI | The API and MCP surface still answer; the browser application is not served at all |
|
|
178
|
+
| `AI` | Workers AI | no | **Two losses, and only one is obvious.** No semantic search, and no attachment conversion — an uploaded PDF or Word file is stored intact but never becomes searchable text |
|
|
179
|
+
| `SEARCH` | Vectorize index, 1024 dims, cosine | no | Search silently falls back to lexical only. Nothing says so, and half a search looks exactly like a whole one |
|
|
180
|
+
|
|
181
|
+
⚠️ **Semantic search needs `AI` *and* `SEARCH`.** Either one alone leaves search lexical. If you
|
|
182
|
+
add them to an existing installation, run `intel reindex` afterwards — the embeddings for content
|
|
183
|
+
already stored are built by that pass and by nothing else.
|
|
184
|
+
|
|
185
|
+
There is no `AGENT` service binding and no `AI_GATEWAY_*` configuration. Both belonged to the agent
|
|
186
|
+
runtime, which is no longer part of Intel; an installation that still carries them can drop them,
|
|
187
|
+
including `wrangler secret delete AI_GATEWAY_READ_TOKEN`.
|
|
188
|
+
|
|
189
|
+
## Variables and secrets
|
|
190
|
+
|
|
191
|
+
Every value below is read in **two different places** depending on the command, and mixing them up
|
|
192
|
+
is the most common setup failure:
|
|
193
|
+
|
|
194
|
+
- the **Worker's** environment — what the deployed Intel reads on every request;
|
|
195
|
+
- the **shell's** environment — what `intel bootstrap`, `intel doctor` and `intel reindex` read.
|
|
196
|
+
|
|
197
|
+
`GATE_URL`, `GATE_SERVICE_KEY`, `INTEL_URL`, `INTEL_SESSION_SECRET` and `TOOL_SOURCE_ORIGINS` are
|
|
198
|
+
needed in both. Setting them only as Wrangler secrets leaves the CLI blind, and setting them only in
|
|
199
|
+
a shell leaves the Worker answering `500 configuration_missing`.
|
|
200
|
+
|
|
201
|
+
⚠️ `MCP_PORTAL_URL` is the eighth value in the same trap, and the one that fails quietly.
|
|
202
|
+
`intel doctor` reads it from the shell as well, so a portal configured only as a Wrangler var makes
|
|
203
|
+
`doctor` report `MCP_PORTAL_URL is unset` about an installation that is configured correctly — and
|
|
204
|
+
skip the origin check it would otherwise have run.
|
|
205
|
+
|
|
206
|
+
| Name | Where | Required | What it does, and what a wrong value does |
|
|
207
|
+
|---|---|---|---|
|
|
208
|
+
| `GATE_URL` | Worker + shell | **yes** | The Gate this installation authenticates against. Wrong: every request is refused and no login completes |
|
|
209
|
+
| `GATE_SERVICE_KEY` | Worker secret + shell | **yes** | Intel's own service credential at Gate. Wrong: every authorization call fails, so everything answers as unauthenticated. It is a secret and never appears in a response, log or tool result |
|
|
210
|
+
| `INTEL_URL` | Worker + shell | **yes** | Intel's own public base URL. It is also **the OAuth resource identifier**: Intel asks Gate for a token for `<INTEL_URL>/mcp`. ⚠️ A trailing-slash or scheme difference is a *different* resource. ⚠️ **Changing it later is a one-way street**: the resource is bound once on the Gate side and cannot be re-pointed — the way back is deleting the Gate service and setting it up again. Decide the hostname before the first login, not after |
|
|
211
|
+
| `INTEL_SESSION_SECRET` | Worker secret + shell | **yes** | Derives the key for the encrypted `HttpOnly` session cookie and for sealing each person's portal token in D1. Must be at least 32 bytes. ⚠️ **Changing it invalidates every session and makes every stored portal token unreadable** — everybody signs in again, and everybody reconnects to the portal |
|
|
212
|
+
| `TOOL_SOURCE_ORIGINS` | Worker + shell | **yes** | Comma-separated **exact origins** Intel is allowed to fetch tools from. ⚠️ An entry carrying a path or credentials is refused — and refused *per request*, so a bad value deploys cleanly and then answers `500` on everything |
|
|
213
|
+
| `MCP_PORTAL_URL` | Worker + shell (for `doctor`) | no | The portal's Streamable HTTP MCP endpoint. Unset means the Tools area stays empty and Intel says so rather than reporting an error. ⚠️ Its origin must also appear in `TOOL_SOURCE_ORIGINS`; `intel doctor` fails when it does not — but only if it can see the value, so set it in the shell too before believing the check ran |
|
|
214
|
+
| `ALLOW_INSECURE_OAUTH` | Worker | no | `true` permits an `http://` OAuth issuer. **Local development only.** Production issuers must be HTTPS |
|
|
215
|
+
| `FLOW_RUN_STALL_TIMEOUT` | Worker | no | How long a run may stand on one step before it is ended as stalled, e.g. `30 minutes`. Too short is worse than the problem: it ends runs that were merely slow |
|
|
216
|
+
| `INTEL_OPERATOR_TOKEN` | shell | for `reindex` | A short-lived bearer carrying the Gate capability `intel.admin`, used only by `intel reindex`. Not a Worker value and not a stored one |
|
|
217
|
+
|
|
218
|
+
Gate OAuth uses PKCE and dynamic public-client registration; browser access tokens stay inside an
|
|
219
|
+
encrypted `HttpOnly` cookie and are never exposed to the UI bundle or D1.
|
|
220
|
+
|
|
221
|
+
## Branding: `intel.json`
|
|
45
222
|
|
|
46
223
|
Put `intel.json` next to the customer project's `package.json`:
|
|
47
224
|
|
|
@@ -60,16 +237,105 @@ Put `intel.json` next to the customer project's `package.json`:
|
|
|
60
237
|
The theme is plain CSS containing shadcn semantic tokens such as `--primary`, `--background`,
|
|
61
238
|
`--sidebar`, and `--radius`. It is loaded after Intel's defaults, so customer tokens win without a
|
|
62
239
|
component fork. Unknown config fields, missing assets, non-SVG branding, and incomplete language
|
|
63
|
-
catalogs fail the build. A ready-to-copy starting
|
|
64
|
-
`node_modules/@anchrd/intel-api/examples`.
|
|
240
|
+
catalogs fail the build rather than producing a half-branded application. A ready-to-copy starting
|
|
241
|
+
point is included under `node_modules/@anchrd/intel-api/examples`.
|
|
242
|
+
|
|
243
|
+
The file is optional: `intel build` without it produces the default Intel branding.
|
|
244
|
+
|
|
245
|
+
## Permission has two layers
|
|
246
|
+
|
|
247
|
+
Access to anything in Intel is the **and** of two independent answers, and neither layer knows about
|
|
248
|
+
the other. This is the single most common source of "it is broken" reports, because a person missing
|
|
249
|
+
either layer sees roughly the same thing: a screen with nothing on it.
|
|
250
|
+
|
|
251
|
+
### Layer one: the Gate capability
|
|
252
|
+
|
|
253
|
+
What somebody may do *at all*, granted in Gate. `intel bootstrap` declares these; an administrator
|
|
254
|
+
grants them.
|
|
255
|
+
|
|
256
|
+
| Interface | Functions |
|
|
257
|
+
|---|---|
|
|
258
|
+
| `intel` | `use`, `admin` |
|
|
259
|
+
| `nodes` | `read`, `create`, `write`, `share` |
|
|
260
|
+
| `flows` | `read`, `create`, `write`, `publish`, `run`, `share` |
|
|
261
|
+
| `tools` | `read`, `test`, `execute`, `admin` |
|
|
262
|
+
| `mcp` | `connect` |
|
|
263
|
+
|
|
264
|
+
A missing capability answers **`403`**, and the screen says a permission is missing.
|
|
65
265
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
266
|
+
### Layer two: the grant on the node
|
|
267
|
+
|
|
268
|
+
What somebody may reach *in this tree*, granted in Intel on a node. Four verbs — `read`, `write`,
|
|
269
|
+
`execute`, `share` — each granted on its own; none implies another. Seeing a process is deliberately
|
|
270
|
+
separable from being allowed to start it.
|
|
271
|
+
|
|
272
|
+
A grant on a **folder is inherited by everything beneath it**, including nodes created after the
|
|
273
|
+
grant was made. That is the usual way to share a whole area.
|
|
274
|
+
|
|
275
|
+
A missing grant answers **`404`**, never `403`: Intel gives the same answer for "there is no such
|
|
276
|
+
node" and "not for you", so that a refusal never confirms that something exists.
|
|
277
|
+
|
|
278
|
+
### Reading the empty screen
|
|
279
|
+
|
|
280
|
+
| What you see | Which layer |
|
|
281
|
+
|---|---|
|
|
282
|
+
| A sentence naming a missing permission | Gate — layer one |
|
|
283
|
+
| An empty tree, no error | Intel — nothing has been shared with this person yet |
|
|
284
|
+
| A node that vanished between two visits | Its grant was revoked; revocation takes effect immediately, on every path, without a new login |
|
|
285
|
+
|
|
286
|
+
A shared node appears in the recipient's navigation on its own, at the top level, without exposing
|
|
287
|
+
the titles of the folders above it. Nobody has to be sent a link.
|
|
288
|
+
|
|
289
|
+
### Three things about grants that surprise people
|
|
290
|
+
|
|
291
|
+
- **A flow is not a node.** It carries no grants of its own; it inherits those of the folder it
|
|
292
|
+
lives in. Move a flow and you have changed who can reach it.
|
|
293
|
+
- **`organization` + `execute` on a folder makes that folder a library**: flows from anywhere in the
|
|
294
|
+
tree may then call into it. The share dialog warns before the click, because the way back is
|
|
295
|
+
narrow — revoking is refused with `409 folder_execute_in_use` for as long as one of those callers
|
|
296
|
+
still calls.
|
|
297
|
+
- **A grant can be complete and a flow still stop.** A run is re-authorized against the person
|
|
298
|
+
running it, so a flow in a shared folder that reads a document outside it stops for somebody who
|
|
299
|
+
cannot read that document. The answer names what was out of reach.
|
|
300
|
+
|
|
301
|
+
## Export and import
|
|
302
|
+
|
|
303
|
+
`GET /api/v1/nodes/export` writes the whole installation as this caller may read it; the same route
|
|
304
|
+
under a node id exports that subtree. Import is the mirror:
|
|
305
|
+
|
|
306
|
+
```bash
|
|
307
|
+
curl -X POST https://intel.example.com/api/v1/nodes/import \
|
|
308
|
+
-H "authorization: Bearer $TOKEN" \
|
|
309
|
+
-H "content-type: application/zip" \
|
|
310
|
+
-H "idempotency-key: $(uuidgen)" \
|
|
311
|
+
--data-binary @bundle.zip
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
⚠️ **The idempotency key travels as a header.** The body is the zip itself, so there is nowhere else
|
|
315
|
+
to put it. A request without the header is refused.
|
|
316
|
+
|
|
317
|
+
Only nodes the caller may read enter the bundle; exporting a folder that is out of reach answers
|
|
318
|
+
`404`. A fresh import answers `201`; a repeat of the same idempotency key answers `200` with the
|
|
319
|
+
summary of the earlier import, because this request created nothing.
|
|
320
|
+
|
|
321
|
+
⚠️ **A bundle carries only the current state of each node. Version history does not survive
|
|
322
|
+
export → import.** A document with two versions comes back with one, and the earlier one is then
|
|
323
|
+
retrievable nowhere. Everything else survives in full — nodes per kind, table content including a
|
|
324
|
+
previous `redefine`, and attachments byte for byte. The loss is accepted and deliberate, but anyone
|
|
325
|
+
moving an installation this way has to know about it **before** they start: after the import there
|
|
326
|
+
is nothing to recover from.
|
|
327
|
+
|
|
328
|
+
## Tools come from the portal
|
|
329
|
+
|
|
330
|
+
Intel stores no tool permissions. The catalog is a **live `tools/list`** made with the requesting
|
|
331
|
+
person's own token, never a mirrored table. Two consequences follow, and both are correct behaviour
|
|
332
|
+
rather than bugs:
|
|
333
|
+
|
|
334
|
+
- **Two people legitimately see different tools**, and the same person can see a different set
|
|
335
|
+
tomorrow. What is offered is whatever the portal answers for them at that moment.
|
|
336
|
+
- **A tool can disappear between defining a flow and running it.** Published flows pin immutable
|
|
337
|
+
MCP schema fingerprints, so a changed or vanished tool is reported by name instead of being
|
|
338
|
+
silently substituted.
|
|
73
339
|
|
|
74
340
|
The portal endpoint exposes RFC 9728 metadata. The Tools UI follows that metadata, dynamically
|
|
75
341
|
registers with Gate or Cloudflare Access, and completes a separate PKCE flow — silently, with
|
|
@@ -82,106 +348,53 @@ both, every silent sign-in is refused and the Tools area shows "No access to the
|
|
|
82
348
|
correct behaviour for somebody outside every policy, and a misleading one for a deployment that
|
|
83
349
|
simply never enabled the setting.
|
|
84
350
|
|
|
351
|
+
⚠️ **Today, somebody without the Gate `mcp.connect` capability is sent into that sign-in anyway**
|
|
352
|
+
and can land on the portal's own error page, outside Intel, with no way back but the browser's back
|
|
353
|
+
button. Grant `mcp.connect` alongside the Intel capabilities to everyone who should reach Tools.
|
|
354
|
+
Tracked as anchrd/intel#434.
|
|
355
|
+
|
|
356
|
+
The Tools screen distinguishes four situations rather than calling all of them "no access": a
|
|
357
|
+
refusal that really came back, an expired connection, a sign-in that broke, and a sign-in that was
|
|
358
|
+
started and never answered. Only the first has no button, because only there is there nothing a
|
|
359
|
+
second attempt would change.
|
|
360
|
+
|
|
85
361
|
The only tool secret Intel stores is the resulting per-user access token for its own portal
|
|
86
|
-
endpoint — one per person, never one shared operator token — sealed with a key
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
them needs an account credential and a credential does not belong in a single-page app.
|
|
107
|
-
|
|
108
|
-
Set `AI_GATEWAY_ACCOUNT_ID` and `AI_GATEWAY_ID` — the same account and gateway the agent runtime
|
|
109
|
-
uses — and add a Wrangler secret:
|
|
110
|
-
|
|
111
|
-
```sh
|
|
112
|
-
wrangler secret put AI_GATEWAY_READ_TOKEN # Cloudflare API token: "AI Gateway: Read" + "Workers AI: Read"
|
|
362
|
+
endpoint — one per person, never one shared operator token — sealed with a key derived from
|
|
363
|
+
`INTEL_SESSION_SECRET` and kept in the `portal_tokens` table of your D1; provider credentials stay
|
|
364
|
+
with the portal and never reach Intel.
|
|
365
|
+
|
|
366
|
+
Intel unseals that token just in time and renews it shortly before it expires. **"Expired" above is
|
|
367
|
+
what a failed renewal looks like**: a token that cannot be renewed is dropped rather than kept and
|
|
368
|
+
retried with, the browser signs in again silently, and an MCP client repeats its own authorization.
|
|
369
|
+
Two different causes end up there — a portal that issued no refresh token at all, which is a
|
|
370
|
+
configuration problem, and a refresh that was rejected, which is not — and Intel logs which of the
|
|
371
|
+
two it was, because the person only ever sees "sign in again". The Intel audience token is never forwarded to another OAuth
|
|
372
|
+
resource. Durable Flow state contains no browser, Gate, or provider credentials.
|
|
373
|
+
|
|
374
|
+
## The `intel` CLI
|
|
375
|
+
|
|
376
|
+
```text
|
|
377
|
+
intel prepare Copy versioned D1 migrations to .intel/migrations
|
|
378
|
+
intel bootstrap Idempotently declare Intel interfaces in Gate
|
|
379
|
+
intel build Apply intel.json and atomically build the customer UI
|
|
380
|
+
intel doctor Check packages, environment, migrations, UI, and Gate interfaces
|
|
381
|
+
intel reindex Request a full authorized search-index rebuild
|
|
113
382
|
```
|
|
114
383
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
### Creating an agent creates its Gate application
|
|
130
|
-
|
|
131
|
-
An agent acts as its own machine principal, so creating an agent node also creates the Gate
|
|
132
|
-
Application it runs as, and archiving the node switches that Application off (`disabledAt`) rather
|
|
133
|
-
than deleting it. Restoring the node switches it back on — the same principal, with the same ID and
|
|
134
|
-
the same grants, which is the whole reason it is disabled rather than deleted.
|
|
135
|
-
|
|
136
|
-
Both calls are made with **the bearer of the person asking**, not with `GATE_SERVICE_KEY`. Creating
|
|
137
|
-
and switching a machine principal is administrative work in Gate: the caller needs the applications
|
|
138
|
-
permission there on top of `knowledge:create` in Intel, and Gate audits the act under their name. A
|
|
139
|
-
caller without it is refused with `agent_application_forbidden`, and a Gate that does not answer
|
|
140
|
-
with `agent_application_unavailable` — in which case **no agent node is created at all**. There is
|
|
141
|
-
deliberately no half-created agent to repair later; the request is simply repeated.
|
|
142
|
-
|
|
143
|
-
**No response in Intel carries a credential.** The Application key Gate issues once, in plain text,
|
|
144
|
-
is handed to the agent runtime over the `AGENT` service binding inside the same request, and the
|
|
145
|
-
runtime encrypts it into that agent's Durable Object. Intel writes it to no table, no R2 object, no
|
|
146
|
-
audit event, no log and no response body; the browser never sees it, and neither does an MCP client.
|
|
147
|
-
The agent can run the moment it exists — there is no secret to set and no terminal to open.
|
|
148
|
-
|
|
149
|
-
The handover carries the caller's own bearer plus `AGENT_HANDOVER_SECRET`, the one value Intel and
|
|
150
|
-
the agent runtime share. The runtime cannot check Intel's resource ACLs, so that secret is what says
|
|
151
|
-
the key came through Intel rather than off the open internet; without it on both Workers, creating an
|
|
152
|
-
agent refuses by name.
|
|
153
|
-
|
|
154
|
-
The handover happens **before** the node row, for the same reason the Gate call does: a runtime that
|
|
155
|
-
will not take the key leaves no agent behind, and the freshly minted Application is switched off
|
|
156
|
-
again. There is deliberately no half-created agent to repair later; the request is simply repeated.
|
|
157
|
-
|
|
158
|
-
What still stays manual is the reach: grant the application its Gate roles and the resource grants
|
|
159
|
-
on the Intel nodes it works with. That is what makes the agent's reach its own.
|
|
160
|
-
|
|
161
|
-
`POST /api/v1/nodes/agents/<id>/rotate-key` (and the `agent_rotate_key` MCP tool) replaces the key:
|
|
162
|
-
Intel asks Gate for a new one and hands it to the runtime the same way, the previous key stops
|
|
163
|
-
working at once, and the answer names the principal and the moment rather than the key. It needs
|
|
164
|
-
`knowledge:write` in Intel plus write access to the agent node, and the applications permission in
|
|
165
|
-
Gate. It is the repair for a run that fails with `agent_key_missing`.
|
|
166
|
-
|
|
167
|
-
**A written schedule is an armed one.** The agent runtime sets its alarm only when something tells
|
|
168
|
-
it to look, so Intel calls `POST /agents/<id>/schedules/sync` over the same service binding after
|
|
169
|
-
every definition write — creating an agent with schedules, and every save. That call goes down the
|
|
170
|
-
handover route with the caller's own bearer beside `AGENT_HANDOVER_SECRET`, because arming an agent
|
|
171
|
-
belongs to editing one and not to driving one. A save whose arming fails keeps the definition it
|
|
172
|
-
wrote and answers `agent_schedules_not_armed`: the version is current, and saving again arms it.
|
|
173
|
-
|
|
174
|
-
Intel keeps `applicationId` beside the node and returns it on every agent read. It names the
|
|
175
|
-
principal without authenticating it, which is why it may be stored and shown while the key may not.
|
|
176
|
-
An agent whose `applicationId` is `null` has no principal — imported, restored from a bundle, or
|
|
177
|
-
created before this was automatic — and archiving it touches Gate not at all.
|
|
178
|
-
|
|
179
|
-
The reference Wrangler deployment binds `DB`, `CONTENT`, `INDEXING`, `AI`, `SEARCH`, `FLOWS`, and
|
|
180
|
-
`ASSETS`. Create `SEARCH` as a 1024-dimension cosine Vectorize index for the default multilingual
|
|
181
|
-
Workers AI `@cf/baai/bge-m3` embedding adapter. `FLOWS` targets the exported
|
|
182
|
-
`IntelFlowWorkflow` class. The portal speaks standard Streamable HTTP MCP; Intel caches schemas and
|
|
183
|
-
unseals the caller's portal token from `portal_tokens` just in time, refreshing it shortly before
|
|
184
|
-
use. Durable Flow state contains no browser, Gate, or provider credentials.
|
|
384
|
+
All five run in the customer project directory — the one holding `package.json`, `intel.json` and
|
|
385
|
+
`node_modules` — and read their configuration from that shell's environment.
|
|
386
|
+
|
|
387
|
+
`intel reindex` needs `INTEL_URL` and a short-lived `INTEL_OPERATOR_TOKEN`. It asks the running
|
|
388
|
+
installation to rebuild the derived indexes from D1 and R2 and returns as soon as the request is
|
|
389
|
+
accepted; the work happens on the indexing queue. Reach for it after adding `AI` and `SEARCH` to an
|
|
390
|
+
installation that ran without them, and after restoring an emptied Vectorize index.
|
|
391
|
+
|
|
392
|
+
## Writing against the API directly
|
|
393
|
+
|
|
394
|
+
Everything the browser does is available over HTTP and over the Intel MCP surface, against the same
|
|
395
|
+
application services. If you write your own client, read
|
|
396
|
+
[`@anchrd/intel-contract`](https://www.npmjs.com/package/@anchrd/intel-contract) first — it carries
|
|
397
|
+
the schemas, and the handful of things that reliably cost a first-time caller an afternoon.
|
|
185
398
|
|
|
186
399
|
## Related packages
|
|
187
400
|
|
|
@@ -114,10 +114,19 @@ export function createFlowRepository(deps) {
|
|
|
114
114
|
// An absent `parentId` asks for every visible flow; `null` asks for the root of the shared tree.
|
|
115
115
|
// `IS ?` would collapse the two, so the two cases are separate SQL rather than one binding that
|
|
116
116
|
// silently means both.
|
|
117
|
+
//
|
|
118
|
+
// ⚠️ The root is "the top of every region this actor may read", exactly as it is for nodes in
|
|
119
|
+
// `db.ts` (#429) — a flow whose folder the reader may not open would otherwise sit in a level
|
|
120
|
+
// nothing can ever open. `flowInSubtree` is what puts such a row in reach at all (its owner, or
|
|
121
|
+
// `intel/admin`), and this only decides which level it appears in; it can add no flow that
|
|
122
|
+
// predicate has not already allowed.
|
|
117
123
|
const scopeOf = (parentId) => parentId === undefined
|
|
118
124
|
? { clause: "", bindings: [] }
|
|
119
125
|
: parentId === null
|
|
120
|
-
? {
|
|
126
|
+
? {
|
|
127
|
+
clause: "AND (flow.parent_id IS NULL OR flow.parent_id NOT IN (SELECT id FROM allowed))",
|
|
128
|
+
bindings: [],
|
|
129
|
+
}
|
|
121
130
|
: { clause: "AND flow.parent_id = ?", bindings: [parentId] };
|
|
122
131
|
/**
|
|
123
132
|
* One statement for the list the sidebar reads and for the bounded read the relation graph makes,
|
package/dist/adapters/db/db.js
CHANGED
|
@@ -109,6 +109,28 @@ export function createNodeRepository(deps) {
|
|
|
109
109
|
const scopeCte = (scopeId) => scopeId === undefined ? "" : `,\n${descendantsCte}`;
|
|
110
110
|
const scopeJoin = (scopeId) => scopeId === undefined ? "" : "\n JOIN descendants ON descendants.id = n.id";
|
|
111
111
|
const scopeBindings = (scopeId) => (scopeId === undefined ? [] : [scopeId]);
|
|
112
|
+
/**
|
|
113
|
+
* Which rows belong to the level being asked for.
|
|
114
|
+
*
|
|
115
|
+
* ⚠️ At the ROOT this is not `parent_id IS NULL` but "the top of every region this actor may
|
|
116
|
+
* read" (#429). A grant on a nested folder has no entry point otherwise: the way to it leads
|
|
117
|
+
* through a folder the recipient may not see, so the level it would appear in can never be
|
|
118
|
+
* opened, and the share stays usable only for whoever passes the URL around by hand. It is the
|
|
119
|
+
* same seed `listVisibleSubtree` already starts a whole-installation export from — written once
|
|
120
|
+
* more rather than differently.
|
|
121
|
+
*
|
|
122
|
+
* ⚠️ It says nothing about the way there. A row carries its own id and its own title, both of
|
|
123
|
+
* which `getVisible` already answers to this actor; the ancestors it hangs under are never read,
|
|
124
|
+
* named or counted, so the title of an unreadable parent folder stays where it was.
|
|
125
|
+
*
|
|
126
|
+
* ⚠️ Both `?` take the SAME value, and the second is not redundant: SQLite binds by position, so
|
|
127
|
+
* asking "was the root asked for" needs its own placeholder rather than a second reading of the
|
|
128
|
+
* first.
|
|
129
|
+
*/
|
|
130
|
+
const levelPredicate = `(
|
|
131
|
+
n.parent_id IS ?
|
|
132
|
+
OR (? IS NULL AND n.parent_id NOT IN (SELECT id FROM allowed))
|
|
133
|
+
)`;
|
|
112
134
|
/**
|
|
113
135
|
* One statement for the level the tree reads and for the bounded read the relation graph makes,
|
|
114
136
|
* so the visibility predicate cannot drift between them.
|
|
@@ -128,7 +150,7 @@ export function createNodeRepository(deps) {
|
|
|
128
150
|
) AS has_children
|
|
129
151
|
FROM nodes n
|
|
130
152
|
JOIN allowed ON allowed.id = n.id
|
|
131
|
-
WHERE
|
|
153
|
+
WHERE ${levelPredicate} AND (? = 1 OR n.archived_at IS NULL)
|
|
132
154
|
ORDER BY CASE n.kind WHEN 'folder' THEN 0 ELSE 1 END, lower(n.title), n.id${bounded ? " LIMIT ?" : ""}`;
|
|
133
155
|
/**
|
|
134
156
|
* Everything archived that this actor may see, wherever it sits (#113).
|
|
@@ -150,7 +172,9 @@ export function createNodeRepository(deps) {
|
|
|
150
172
|
async listVisible(actor, input) {
|
|
151
173
|
const result = await deps.db
|
|
152
174
|
.prepare(input.archivedOnly ? archivedEverywhere : visibleChildren(false))
|
|
153
|
-
.bind(...readBindings(actor), ...(input.archivedOnly
|
|
175
|
+
.bind(...readBindings(actor), ...(input.archivedOnly
|
|
176
|
+
? []
|
|
177
|
+
: [input.parentId, input.parentId, input.includeArchived ? 1 : 0]))
|
|
154
178
|
.all();
|
|
155
179
|
const rows = result.results ?? [];
|
|
156
180
|
return {
|
|
@@ -161,7 +185,7 @@ export function createNodeRepository(deps) {
|
|
|
161
185
|
async listVisibleBounded(actor, input) {
|
|
162
186
|
const result = await deps.db
|
|
163
187
|
.prepare(visibleChildren(true))
|
|
164
|
-
.bind(...readBindings(actor), input.parentId, 0, input.limit)
|
|
188
|
+
.bind(...readBindings(actor), input.parentId, input.parentId, 0, input.limit)
|
|
165
189
|
.all();
|
|
166
190
|
const rows = result.results ?? [];
|
|
167
191
|
return { items: rows.map(mapNode), total: rows[0]?.total ?? 0 };
|
package/dist/flows/flows.js
CHANGED
|
@@ -417,16 +417,31 @@ export function createFlows(deps) {
|
|
|
417
417
|
// The flow may have been built by someone with wider portal access. Naming the missing tools
|
|
418
418
|
// before the first step beats failing halfway through with a portal error the user cannot
|
|
419
419
|
// place — and the portal is where they can do something about it.
|
|
420
|
-
|
|
421
|
-
|
|
420
|
+
//
|
|
421
|
+
// ⚠️ ONE entry per missing TOOL, and the unit is the decision (#435). A problem list is a
|
|
422
|
+
// count, so the number of entries has to be the number of things left to do — and what a
|
|
423
|
+
// person does about a missing tool is grant it once in the portal, whether one step reaches
|
|
424
|
+
// for it or six. `graphReferences` already folds the graph down to distinct tool names, which
|
|
425
|
+
// is why a tool hanging off several steps arrives here once. Deliberately NOT one entry per
|
|
426
|
+
// step: the same portal click would then be listed several times, and the reader would go
|
|
427
|
+
// looking for several causes.
|
|
428
|
+
//
|
|
429
|
+
// The one consequence to know about: `start` throws the FIRST entry, so it now names the first
|
|
430
|
+
// missing tool rather than all of them — the same as it has always done for several
|
|
431
|
+
// unpublished sub-flows below. `validate` is what lists every reason at once, and it does.
|
|
432
|
+
for (const tool of await deps.unavailableTools(actor, graphReferences(version.graph).tools)) {
|
|
422
433
|
problems.push({
|
|
423
434
|
status: 403,
|
|
424
435
|
code: "flow_tools_unavailable",
|
|
425
|
-
detail: toolStepDetail(
|
|
436
|
+
detail: toolStepDetail([tool]),
|
|
426
437
|
});
|
|
427
438
|
}
|
|
428
439
|
try {
|
|
429
|
-
|
|
440
|
+
// ⚠️ Tree links only. The loop above has already asked about every tool in the graph — the
|
|
441
|
+
// first step's included — so the tool half of `requireNodeAuthorized` would answer a second
|
|
442
|
+
// time about the same names, which is the duplicate #435 reported. Every place that actually
|
|
443
|
+
// hands a step over still calls the whole of `requireNodeAuthorized`.
|
|
444
|
+
await requireTreeLinksAuthorized(actor, nodeFor(version, first), version.graph);
|
|
430
445
|
}
|
|
431
446
|
catch (error) {
|
|
432
447
|
if (!(error instanceof IntelError))
|
|
@@ -543,26 +558,43 @@ export function createFlows(deps) {
|
|
|
543
558
|
// `context` edge now, and a run never stands on one — so asking "is this node a tree link"
|
|
544
559
|
// would ask about a node the run can no longer reach, and every check here would silently pass.
|
|
545
560
|
async function requireNodeAuthorized(actor, node, graph) {
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
561
|
+
await requireTreeLinksAuthorized(actor, node, graph);
|
|
562
|
+
await requireToolsAuthorized(actor, node, graph);
|
|
563
|
+
}
|
|
564
|
+
// The material of one step: what hangs off it on a `context` edge, as nodes.
|
|
565
|
+
function attachedNodes(node, graph) {
|
|
566
|
+
return graph.edges
|
|
549
567
|
.filter((edge) => edge.kind === "context" && edge.source === node.id)
|
|
550
568
|
.flatMap((edge) => graph.nodes.filter((candidate) => candidate.id === edge.target));
|
|
569
|
+
}
|
|
570
|
+
// ⚠️ The two halves are separate functions only because `collectRunProblems` needs the first one
|
|
571
|
+
// without the second (#435): every tool of the whole graph is already asked about there, so
|
|
572
|
+
// asking again for the first step reported the same tool twice. `requireNodeAuthorized` above is
|
|
573
|
+
// what every execution boundary calls, and it is still both halves — splitting the CHECK would
|
|
574
|
+
// be the drift this file spends a page warning about; splitting the FUNCTION is not.
|
|
575
|
+
async function requireTreeLinksAuthorized(actor, node, graph) {
|
|
576
|
+
if (!node)
|
|
577
|
+
return;
|
|
578
|
+
const attached = attachedNodes(node, graph);
|
|
551
579
|
const wanted = [...new Set(treeLinkNodes({ ...graph, nodes: attached }).map(resourceIdOf))];
|
|
552
|
-
if (wanted.length)
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
580
|
+
if (!wanted.length)
|
|
581
|
+
return;
|
|
582
|
+
const reachable = await reachableNodes(actor, wanted);
|
|
583
|
+
if (reachable.length !== wanted.length) {
|
|
584
|
+
throw new IntelError(403, "flow_node_forbidden", treeLinkStepDetail(node.label, wanted.length - reachable.length));
|
|
557
585
|
}
|
|
558
|
-
|
|
586
|
+
}
|
|
587
|
+
async function requireToolsAuthorized(actor, node, graph) {
|
|
588
|
+
if (!node)
|
|
589
|
+
return;
|
|
590
|
+
const toolNames = attachedNodes(node, graph)
|
|
559
591
|
.filter((candidate) => candidate.kind === "tool")
|
|
560
592
|
.map((candidate) => candidate.configuration.toolName);
|
|
561
|
-
if (toolNames.length)
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
593
|
+
if (!toolNames.length)
|
|
594
|
+
return;
|
|
595
|
+
const missing = await deps.unavailableTools(actor, [...new Set(toolNames)]);
|
|
596
|
+
if (missing.length) {
|
|
597
|
+
throw new IntelError(403, "flow_tools_unavailable", toolStepDetail(missing));
|
|
566
598
|
}
|
|
567
599
|
}
|
|
568
600
|
/**
|
|
@@ -219,6 +219,7 @@ export interface BoundedChildren {
|
|
|
219
219
|
export interface NodeService {
|
|
220
220
|
list(actor: Actor, input: ListNodesInput): Promise<{
|
|
221
221
|
items: Node[];
|
|
222
|
+
withChildren: string[];
|
|
222
223
|
}>;
|
|
223
224
|
/**
|
|
224
225
|
* One level of the tree as this actor may see it, bounded: at most `limit` current children, and
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anchrd/intel-api",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"repository": {
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"@anchrd/gate-sdk": "^0.7.0",
|
|
46
|
-
"@anchrd/intel-contract": "^0.
|
|
46
|
+
"@anchrd/intel-contract": "^0.15.0",
|
|
47
47
|
"@cfworker/json-schema": "^4.1.1",
|
|
48
48
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
49
49
|
"fflate": "^0.8.3",
|