@anchrd/intel-api 0.13.0 → 0.15.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.
Files changed (57) hide show
  1. package/dist/adapters/cloudflare/cloudflare.js +1 -68
  2. package/dist/adapters/cloudflare/cloudflare.types.d.ts +0 -39
  3. package/dist/adapters/db/db-flows.js +1 -1
  4. package/dist/adapters/db/db-grants.js +1 -1
  5. package/dist/adapters/db/db-indexing.js +79 -0
  6. package/dist/adapters/db/db.js +81 -139
  7. package/dist/adapters/semantic-index/semantic-index.js +97 -17
  8. package/dist/adapters/semantic-index/semantic-index.types.d.ts +20 -1
  9. package/dist/bundle/bundle.js +42 -134
  10. package/dist/cli/cli.js +3 -9
  11. package/dist/http/http.js +5 -206
  12. package/dist/http/http.types.d.ts +0 -8
  13. package/dist/indexing/indexing.js +133 -55
  14. package/dist/indexing/indexing.types.d.ts +1 -0
  15. package/dist/intel/intel.js +4 -9
  16. package/dist/intel/intel.types.d.ts +0 -6
  17. package/dist/mcp/mcp.js +33 -308
  18. package/dist/mcp/mcp.types.d.ts +2 -7
  19. package/dist/nodes/document-links/document-links.d.ts +6 -8
  20. package/dist/nodes/document-links/document-links.js +8 -31
  21. package/dist/nodes/nodes.js +92 -826
  22. package/dist/nodes/nodes.types.d.ts +57 -158
  23. package/dist/tools/tools.js +37 -148
  24. package/dist/tools/tools.types.d.ts +0 -21
  25. package/migrations/0009_no_context_policy.sql +15 -0
  26. package/migrations/0017_a_vector_per_card.sql +38 -0
  27. package/migrations/0018_no_context_policy_at_last.sql +97 -0
  28. package/migrations/0019_one_name_for_the_grants.sql +52 -0
  29. package/package.json +2 -2
  30. package/dist/adapters/cloudflare-api/cloudflare-api.d.ts +0 -22
  31. package/dist/adapters/cloudflare-api/cloudflare-api.js +0 -214
  32. package/dist/adapters/cloudflare-api/cloudflare-api.types.d.ts +0 -64
  33. package/dist/adapters/cloudflare-api/cloudflare-api.types.js +0 -1
  34. package/dist/adapters/gate-applications/gate-applications.d.ts +0 -23
  35. package/dist/adapters/gate-applications/gate-applications.js +0 -88
  36. package/dist/adapters/tool-delegation/tool-delegation.d.ts +0 -22
  37. package/dist/adapters/tool-delegation/tool-delegation.js +0 -90
  38. package/dist/agent-costs/agent-costs.d.ts +0 -16
  39. package/dist/agent-costs/agent-costs.js +0 -105
  40. package/dist/agent-costs/agent-costs.types.d.ts +0 -30
  41. package/dist/agent-costs/agent-costs.types.js +0 -1
  42. package/dist/agent-runtime/agent-runtime.d.ts +0 -16
  43. package/dist/agent-runtime/agent-runtime.js +0 -150
  44. package/dist/agent-runtime/agent-runtime.types.d.ts +0 -122
  45. package/dist/agent-runtime/agent-runtime.types.js +0 -1
  46. package/dist/model-catalog/model-catalog.d.ts +0 -2
  47. package/dist/model-catalog/model-catalog.js +0 -99
  48. package/dist/model-catalog/model-catalog.types.d.ts +0 -15
  49. package/dist/model-catalog/model-catalog.types.js +0 -1
  50. package/dist/nodes/board/board.d.ts +0 -59
  51. package/dist/nodes/board/board.js +0 -528
  52. package/dist/nodes/board/board.types.d.ts +0 -31
  53. package/dist/nodes/board/board.types.js +0 -1
  54. package/migrations/0013_agents_in_the_tree.sql +0 -76
  55. package/migrations/0014_agent_applications.sql +0 -25
  56. package/migrations/0015_tools_delegated_from_a_connection.sql +0 -15
  57. package/migrations/0016_boards_in_the_tree.sql +0 -80
@@ -1,4 +1,4 @@
1
- import type { AddBoardTaskInput, AgentKeyRotated, AgentList, AppendTableRowsInput, AppendTableRowsResult, ArchiveNodeInput, BoardTaskResult, ConfigureBoardInput, ConfigureBoardResult, CreateAgentInput, CreatedAgent, CreateNodeInput, DefineTableInput, DeleteBoardTaskInput, DeleteBoardTaskResult, DeleteTableRowsInput, DeleteTableRowsResult, Flow, FlowVersion, GetAgentInput, GetBoardInput, ListAgentsInput, ListNodesInput, MoveBoardTaskInput, Node, NodeAgent, NodeAttachment, NodeBoard, NodeCitation, NodeDocument, NodeGraph, NodeGraphInput, NodeLink, NodeTable, NodeVersion, RedefineTableInput, ResolveNodeLinksInput, ResolveNodeLinksResult, ResourceGrant, ResourceGrantList, ResourceVerb, RevokeGrantInput, RotateAgentKeyInput, SaveAgentDefinitionInput, SaveAttachmentInput, SaveNodeVersionInput, SearchInput, ShareInput, ShareResult, UpdateBoardTaskInput, UpdateNodeInput, UpdateTableRowsInput, UpdateTableRowsResult } from "@anchrd/intel-contract";
1
+ import type { AppendTableRowsInput, AppendTableRowsResult, ArchiveNodeInput, CreateNodeInput, DefineTableInput, DeleteTableRowsInput, DeleteTableRowsResult, Flow, FlowVersion, ListNodesInput, Node, NodeAttachment, NodeCitation, NodeDocument, NodeGraph, NodeGraphInput, NodeLink, NodeTable, NodeVersion, RedefineTableInput, ResolveNodeLinksInput, ResolveNodeLinksResult, ResourceGrant, ResourceGrantList, ResourceVerb, RevokeGrantInput, SaveAttachmentInput, SaveNodeVersionInput, SearchInput, ShareInput, ShareResult, UpdateNodeInput, UpdateTableRowsInput, UpdateTableRowsResult } from "@anchrd/intel-contract";
2
2
  import type { SemanticIndex } from "../adapters/semantic-index/semantic-index.types.js";
3
3
  export interface Actor {
4
4
  id: string;
@@ -10,9 +10,6 @@ export interface NewNode {
10
10
  actorId: string;
11
11
  idempotencyKey: string;
12
12
  auditId: string;
13
- application?: {
14
- id: string;
15
- } | undefined;
16
13
  }
17
14
  export interface NewNodeVersion {
18
15
  version: NodeVersion;
@@ -27,7 +24,7 @@ export interface NewTableVersion {
27
24
  idempotencyKey: string;
28
25
  auditId: string;
29
26
  }
30
- export type SnapshotOperation = "node.table_update" | "node.table_delete" | "node.table_redefine" | "node.board_configure" | "node.board_task_add" | "node.board_task_update" | "node.board_task_move" | "node.board_task_delete";
27
+ export type SnapshotOperation = "node.table_update" | "node.table_delete" | "node.table_redefine";
31
28
  /**
32
29
  * One version that replaces the readable state, written only against the state it replaces.
33
30
  *
@@ -35,11 +32,6 @@ export type SnapshotOperation = "node.table_update" | "node.table_delete" | "nod
35
32
  * read, so it has to know which state it replaces — the same optimistic concurrency the document
36
33
  * save uses, enforced in the same statement that inserts the row (#135).
37
34
  *
38
- * ⚠️ A board uses this too, and its `baseVersionId` is NOT the caller's (#285). The caller of a task
39
- * operation never sends one; the service reads the current version, applies the change by task id
40
- * and passes what it read. A refusal here therefore means "somebody wrote in between", which the
41
- * service answers by reading again and re-applying, so two agents moving two different tasks both
42
- * get through instead of one of them meeting `version_conflict`.
43
35
  */
44
36
  export interface NewSnapshotVersion {
45
37
  version: NodeVersion;
@@ -59,8 +51,6 @@ export interface NodeRepository {
59
51
  parentId: string | null;
60
52
  limit: number;
61
53
  }): Promise<BoundedChildren>;
62
- listVisibleAgents(actor: Actor, input: ListAgentsInput): Promise<Node[]>;
63
- listVisibleAgentDefinitionKeys(actor: Actor, folderId: string): Promise<string[]>;
64
54
  getVisible(actor: Actor, nodeId: string): Promise<Node | null>;
65
55
  listVisibleSubtree(actor: Actor, rootId: string | null): Promise<SubtreeNode[]>;
66
56
  can(actor: Actor, nodeId: string, verb: ResourceVerb): Promise<boolean>;
@@ -68,27 +58,6 @@ export interface NodeRepository {
68
58
  findIdempotentRevocation(actorId: string, idempotencyKey: string): Promise<boolean | null>;
69
59
  findSnapshotMetadata(actorId: string, operation: SnapshotOperation, idempotencyKey: string): Promise<Record<string, unknown> | null>;
70
60
  insertNode(input: NewNode): Promise<Node>;
71
- agentApplicationId(nodeId: string): Promise<string | null>;
72
- /**
73
- * The audit row for replacing the key of the Application an agent runs as (D29, #207).
74
- *
75
- * ⚠️ Its own write because it is the only mutation on an agent node that changes nothing IN the
76
- * node — no version, no row, nothing a reader could diff afterwards. Swapping which credential an
77
- * agent acts with is the most security-relevant thing anybody does to it, and without this it
78
- * would be the one act on the tree that leaves no trace in Intel at all. Gate audits its side;
79
- * Gate does not know which Intel node the principal belongs to.
80
- *
81
- * ⚠️ `applicationId` names the principal and is a name, not a credential. The key itself is not a
82
- * parameter here and must never become one — audit is metadata, and this is exactly the row a
83
- * "helpful" second field would leak it into.
84
- */
85
- recordAgentKeyRotation(input: {
86
- auditId: string;
87
- actorId: string;
88
- nodeId: string;
89
- applicationId: string;
90
- occurredAt: string;
91
- }): Promise<void>;
92
61
  getVersion(versionId: string): Promise<NodeVersion | null>;
93
62
  appendVersion(input: NewNodeVersion): Promise<"saved" | "conflict">;
94
63
  appendTableVersion(input: NewTableVersion): Promise<NodeVersion>;
@@ -145,7 +114,21 @@ export interface NodeRepository {
145
114
  occurredAt: string;
146
115
  }): Promise<boolean>;
147
116
  searchVisible(actor: Actor, input: SearchInput): Promise<NodeCitation[]>;
148
- hydrateVisibleCitations(actor: Actor, nodeIds: string[], scopeId?: string): Promise<NodeCitation[]>;
117
+ /**
118
+ * `scopeId` cuts the candidates to one folder subtree, which is how the semantic half of a scoped
119
+ * search is narrowed (#126): the vector index answers over everything and this join is where the
120
+ * subtree — a relation in D1, not a value on a vector — is applied. The ACL still applies too.
121
+ *
122
+ * ⚠️ One entry per node, and it names WHICH chunk answered (anchrd/intel#301). The passage a
123
+ * searcher is shown is then that card rather than whichever one happens to be first in the board;
124
+ * a node whose winning chunk has no `node_vectors` row — every vector written before #301 — falls
125
+ * back on the first full-text passage, which is exactly what this returned before.
126
+ */
127
+ hydrateVisibleCitations(actor: Actor, hits: Array<{
128
+ nodeId: string;
129
+ chunkKey: string;
130
+ }>, scopeId?: string): Promise<NodeCitation[]>;
131
+ invalidateVectors(): Promise<void>;
149
132
  listCurrentVersionIds(input: {
150
133
  after: string | null;
151
134
  limit: number;
@@ -193,90 +176,12 @@ export interface NodeAttachmentBody {
193
176
  attachment: NodeAttachment;
194
177
  body: ReadableStream<Uint8Array>;
195
178
  }
196
- /**
197
- * Gate's Applications surface, as the tree needs it (#182).
198
- *
199
- * ⚠️ Every call carries the CALLER's own Gate bearer, never a service key. Creating and switching a
200
- * machine principal is an administrative act in Gate, gated on `applications:write` and audited
201
- * there under the person who performed it; Intel's service key does not open that door at all
202
- * (Gate's two-token rule). The token is therefore a parameter of the operation rather than a field
203
- * of the actor: it must be impossible to reach for by accident from a service that has no business
204
- * with it, and it must never end up on an audit row or in a log line.
205
- */
206
- export interface AgentApplications {
207
- /**
208
- * A new machine principal, named so a person can recognise the agent behind it.
209
- *
210
- * ⚠️ The returned key is plain text, the only time it exists in readable form anywhere, and it is
211
- * gone from Gate the moment this call returns. It is passed straight back to the caller who asked
212
- * for it and is written nowhere on the way (D27).
213
- */
214
- create(input: {
215
- token: string;
216
- name: string;
217
- }): Promise<{
218
- id: string;
219
- key: string;
220
- }>;
221
- /**
222
- * A replacement key for an Application that already exists, invalidating every earlier one.
223
- *
224
- * ⚠️ Gate issues before it revokes, so a failed rotation leaves the old key working. That is what
225
- * makes `rotate-key` a safe repair rather than a way to lock an agent out: the only state this
226
- * can leave behind is "new key issued, runtime never got it", and rotating again fixes it.
227
- */
228
- rotateKey(input: {
229
- token: string;
230
- applicationId: string;
231
- }): Promise<{
232
- key: string;
233
- }>;
234
- setEnabled(input: {
235
- token: string;
236
- applicationId: string;
237
- enabled: boolean;
238
- }): Promise<void>;
239
- }
240
179
  export interface GateCaller {
241
180
  token: string;
242
181
  }
243
182
  export interface NodesDeps {
244
183
  repository: NodeRepository;
245
184
  content: ContentStore;
246
- applications: AgentApplications;
247
- agentRuntimeAvailable(): boolean;
248
- /**
249
- * The runtime's key store, as the tree needs it (D29, #207).
250
- *
251
- * ⚠️ This is where the Application key GOES, and it is the only place it goes. Gate hands it out
252
- * once, this port carries it into the agent's Durable Object over the service binding, and the
253
- * local variable that held it dies with the call. Nothing in this file writes it to D1, R2, an
254
- * audit row, a log line or a response — the proof is the absence of the value in every argument
255
- * every port was called with, not a redaction somebody could forget.
256
- */
257
- agentKeys: {
258
- store(input: {
259
- token: string;
260
- agentId: string;
261
- key: string;
262
- }): Promise<void>;
263
- };
264
- /**
265
- * The runtime's alarm, brought in line with the definition that was just written (#214).
266
- *
267
- * ⚠️ Required, not optional, and it carries no schedules. The runtime arms its alarm only when
268
- * something tells it to look — a chat turn, a resume, a manual run, or this call — so a definition
269
- * written without it leaves an agent whose schedules are visible in its profile and fire never.
270
- * What travels is the agent's ID and the caller's bearer; the runtime re-reads the definition from
271
- * Intel with the agent's own token, which is what keeps a schedule something that was written
272
- * before it can be acted on.
273
- */
274
- agentSchedules: {
275
- sync(input: {
276
- token: string;
277
- agentId: string;
278
- }): Promise<void>;
279
- };
280
185
  id(): string;
281
186
  now(): Date;
282
187
  externalFlowCallers(actor: Actor, folderId: string): Promise<{
@@ -339,47 +244,6 @@ export interface NodeService {
339
244
  updateTableRows(actor: Actor, input: UpdateTableRowsInput): Promise<UpdateTableRowsResult>;
340
245
  deleteTableRows(actor: Actor, input: DeleteTableRowsInput): Promise<DeleteTableRowsResult>;
341
246
  redefineTable(actor: Actor, input: RedefineTableInput): Promise<NodeTable>;
342
- /**
343
- * A board and the five operations that change it (#285).
344
- *
345
- * ⚠️ Not one "save the board" call, deliberately. Every write here names a TASK ID and the server
346
- * applies it to the document it just read, so two callers touching two different tasks both get
347
- * through — the same problem `appendTableRows` solves for a table, arrived at from the other side.
348
- * A whole-document write would put every concurrent editor into `version_conflict` and force each
349
- * of them to read a board of thousands of tasks before changing one word on one card.
350
- */
351
- getBoard(actor: Actor, input: GetBoardInput): Promise<NodeBoard>;
352
- configureBoard(actor: Actor, input: ConfigureBoardInput): Promise<ConfigureBoardResult>;
353
- addBoardTask(actor: Actor, input: AddBoardTaskInput): Promise<BoardTaskResult>;
354
- updateBoardTask(actor: Actor, input: UpdateBoardTaskInput): Promise<BoardTaskResult>;
355
- moveBoardTask(actor: Actor, input: MoveBoardTaskInput): Promise<BoardTaskResult>;
356
- deleteBoardTask(actor: Actor, input: DeleteBoardTaskInput): Promise<DeleteBoardTaskResult>;
357
- getAgent(actor: Actor, input: GetAgentInput): Promise<NodeAgent>;
358
- /**
359
- * An agent node, its first definition, and the Gate Application it runs as (#182, D29).
360
- *
361
- * ⚠️ The answer carries NO key. The one Gate issues goes straight into the agent runtime over the
362
- * service binding, inside this call, and the caller never sees it — which is what makes an agent
363
- * created through the screen able to run without a terminal step (#200, #207).
364
- */
365
- createAgent(actor: Actor, input: CreateAgentInput, caller: GateCaller): Promise<CreatedAgent>;
366
- /**
367
- * Replace the key of the Application an agent runs as, and give the new one to the runtime.
368
- *
369
- * ⚠️ The repair path for an agent that answers `agent_key_missing`, and the only one: Gate keeps
370
- * a key hashed, so an agent whose Durable Object lost its key cannot be handed the old one back.
371
- * It is a write on the agent node — `knowledge/write` and the resource ACL — not a run.
372
- */
373
- rotateAgentKey(actor: Actor, input: RotateAgentKeyInput, caller: GateCaller): Promise<AgentKeyRotated>;
374
- /**
375
- * A new definition version for an agent, and the alarm that goes with it (#214).
376
- *
377
- * ⚠️ It takes the caller's bearer for the same reason `createAgent` does: the runtime is told to
378
- * re-arm over the service binding, and what travels on that call is the person behind this request
379
- * — never a service key (D19).
380
- */
381
- saveAgentDefinition(actor: Actor, input: SaveAgentDefinitionInput, caller: GateCaller): Promise<NodeAgent>;
382
- listAgents(actor: Actor, input: ListAgentsInput): Promise<AgentList>;
383
247
  getAttachment(actor: Actor, nodeId: string): Promise<NodeAttachment>;
384
248
  readAttachment(actor: Actor, nodeId: string): Promise<NodeAttachmentBody>;
385
249
  listVersions(actor: Actor, nodeId: string): Promise<{
@@ -420,27 +284,62 @@ export interface NodeIndexTarget {
420
284
  description: string | null;
421
285
  contentKeys: string[];
422
286
  mediaType: string;
423
- kind: "document" | "attachment" | "table" | "agent" | "board";
287
+ kind: "document" | "attachment" | "table";
424
288
  updatedAt: string;
425
289
  }
426
290
  /**
427
291
  * One passage of a node, as the full-text index holds it.
428
292
  *
429
- * ⚠️ A list, and most kinds put exactly one in it. A board puts one per TASK (#285): "where do I
430
- * stand with X" has to find a card, and a board indexed as one blob would answer with the whole
431
- * board every time — the passage a searcher is shown would be whichever thousand characters the
432
- * snippet happened to cut, from a task that may have nothing to do with the question.
293
+ * ⚠️ A list, and every kind left after #390 puts exactly one in it. It stays a list because the
294
+ * chunked shape is what `node_fts` and `node_vectors` are built around (#285, anchrd/intel#301)
295
+ * the one thing that would have to be rebuilt to unpick it.
433
296
  *
434
297
  * `title` is what the passage is called rather than what the node is called: for a task it is the
435
298
  * task's own title, which is the text the FTS table weights highest.
436
299
  */
437
300
  export interface IndexChunk {
301
+ /**
302
+ * What this passage is called inside its node (anchrd/intel#301).
303
+ *
304
+ * `""` for a node that is one passage, which since #390 is every kind. The full-text index does
305
+ * not store it; the vector index is NAMED by it, and that is why it stays: the name written
306
+ * before anchrd/intel#301 is the bare node id, so nothing an installation already holds has to be
307
+ * renamed or embedded again.
308
+ */
309
+ key: string;
438
310
  title: string;
439
311
  text: string;
440
312
  }
313
+ /**
314
+ * What the vector index holds for one chunk of one node (anchrd/intel#301).
315
+ *
316
+ * `fingerprint` is over the exact text that was embedded, so the next pass can tell an untouched
317
+ * card from a changed one without asking Vectorize — whose writes are asynchronous and would answer
318
+ * about the save before last. `passage` is what a searcher is shown when this vector is the hit.
319
+ */
320
+ export interface NodeVectorRecord {
321
+ chunkKey: string;
322
+ fingerprint: string;
323
+ passage: string;
324
+ }
441
325
  export interface NodeIndexRepository {
442
326
  getTarget(versionId: string): Promise<NodeIndexTarget | null>;
327
+ /**
328
+ * The node behind this version when the reason `getTarget` refused it is that the node has been
329
+ * ARCHIVED — and null for every other reason (anchrd/intel#348).
330
+ *
331
+ * ⚠️ The distinction is the whole method. `getTarget` answers null for two very different
332
+ * situations: the node is archived, or this version has been superseded by a later save. The
333
+ * first one means "take this node out of the vector index"; the second is a queue message that
334
+ * arrived late — the ordinary case, because Cloudflare Queues deliver at least once — and acting
335
+ * on it would delete the vectors of a node that is perfectly alive. So this asks for the archived
336
+ * case by name and never infers it from an absence.
337
+ */
338
+ archivedNodeId(versionId: string): Promise<string | null>;
443
339
  replace(target: NodeIndexTarget, chunks: IndexChunk[]): Promise<void>;
340
+ listVectors(nodeId: string): Promise<NodeVectorRecord[]>;
341
+ replaceVectors(target: NodeIndexTarget, records: NodeVectorRecord[]): Promise<void>;
342
+ deleteVectors(nodeId: string): Promise<void>;
444
343
  markIndexed(versionId: string, occurredAt: string): Promise<void>;
445
344
  markError(versionId: string, message: string, occurredAt: string): Promise<void>;
446
345
  }
@@ -1,4 +1,3 @@
1
- import { serverOf } from "@anchrd/intel-contract";
2
1
  import { IntelError } from "../shared/intel-error/intel-error.js";
3
2
  import { reportUnexpectedError } from "../shared/report-unexpected-error/report-unexpected-error.js";
4
3
  import { ServerDirectoryTool, toolServersFrom, } from "./tool-servers/tool-servers.js";
@@ -7,18 +6,14 @@ const MaxTools = 1_000;
7
6
  const MaxResultBytes = 1_000_000;
8
7
  // Refresh slightly early so a call cannot start with a token that expires mid-flight.
9
8
  const RefreshWindowMs = 30_000;
10
- /**
11
- * An agent that delegates nothing no servers, or no delegator to act for.
12
- *
13
- * ⚠️ It is answered without touching the token store at all. `delegatedBy` is empty for an archived
14
- * agent, one whose definition Intel could not read, and one that was never given tools; asking the
15
- * store for the connection of user "" would be a lookup that can only ever fail, on a path that has
16
- * already decided the answer.
9
+ /*
10
+ * ⚠️ Until #390 this file answered two kinds of caller: a person, and an agent acting on somebody
11
+ * else's portal connection (D30). The second one is gone with the agents, and with it the
12
+ * delegation lookup, the cut against the portal's server directory, and the audit row that named
13
+ * both principals. What is left is the rule the delegation was an exception to: the token is read
14
+ * per actor and never shared one operator token for everybody would make every catalog the same
15
+ * one and the portal's Access policies decorative (ADR-0003).
17
16
  */
18
- function delegatesNothing(who) {
19
- return (who.delegation !== null &&
20
- (who.delegation.servers.length === 0 || who.delegation.delegatedBy.length === 0));
21
- }
22
17
  export function createTools(deps) {
23
18
  function portal() {
24
19
  if (!deps.portalUrl || !deps.sourceAllowed(deps.portalUrl)) {
@@ -26,23 +21,10 @@ export function createTools(deps) {
26
21
  }
27
22
  return deps.portalUrl;
28
23
  }
29
- // One lookup per request, before anything else happens: everything below has to know whether it
30
- // is answering a person or an agent, and a second lookup could answer differently mid-call.
31
- async function acting(actor) {
32
- const delegation = await deps.delegation(actor.id);
33
- return {
34
- actor,
35
- connectionOf: delegation?.delegatedBy ?? actor.id,
36
- delegation,
37
- };
38
- }
39
24
  // Authorization for tools lives entirely in the portal, so "may this user act" reduces to "does
40
- // this user have a usable portal token". ⚠️ The token is read per actor and never shared: one
41
- // operator token for everybody would make every catalog the same one and the portal's Access
42
- // policies decorative (ADR-0003). For an agent the actor IS somebody else — the delegator — which
43
- // is the whole of D30 and the reason this takes an `Acting` rather than a `ToolActor`.
44
- async function accessToken(who) {
45
- const stored = await deps.tokens.read(who.connectionOf);
25
+ // this user have a usable portal token".
26
+ async function accessToken(actor) {
27
+ const stored = await deps.tokens.read(actor.id);
46
28
  if (!stored) {
47
29
  throw new IntelError(401, "portal_not_connected", "The portal has not signed this user in yet");
48
30
  }
@@ -58,10 +40,10 @@ export function createTools(deps) {
58
40
  // A token that cannot be renewed is dropped: leaving it would keep failing every call with a
59
41
  // stale credential. The browser answers this by signing in silently again (#60); an MCP
60
42
  // client sees the code and repeats its own authorization.
61
- await deps.tokens.clear(who.connectionOf);
43
+ await deps.tokens.clear(actor.id);
62
44
  throw new IntelError(401, "portal_reconnect_required", "The portal sign-in for this user has expired");
63
45
  }
64
- await deps.tokens.write(who.connectionOf, refreshed);
46
+ await deps.tokens.write(actor.id, refreshed);
65
47
  return refreshed.accessToken;
66
48
  }
67
49
  /**
@@ -79,8 +61,8 @@ export function createTools(deps) {
79
61
  * and the reason is in the log" — the same rule `adapters/json-schema` learned the hard way in
80
62
  * #229, where a catch-all that renamed the failure hid it for weeks.
81
63
  */
82
- async function remoteTools(who) {
83
- const token = await accessToken(who);
64
+ async function remoteTools(actor) {
65
+ const token = await accessToken(actor);
84
66
  const remote = await deps.remote
85
67
  .list(portal(), token, AbortSignal.timeout(CallTimeoutMs))
86
68
  .catch((error) => {
@@ -108,13 +90,13 @@ export function createTools(deps) {
108
90
  * be indistinguishable from "you have no servers", and the difference decides whether a screen
109
91
  * says "connect something" or "this portal cannot be delegated from".
110
92
  */
111
- async function serverList(who, toolNames) {
93
+ async function serverList(actor, toolNames) {
112
94
  if (!toolNames.includes(ServerDirectoryTool)) {
113
95
  throw new IntelError(502, "tool_servers_unavailable", `The portal does not offer ${ServerDirectoryTool}, so its servers cannot be listed`);
114
96
  }
115
97
  const answer = await deps.remote.call({
116
98
  url: portal(),
117
- accessToken: await accessToken(who),
99
+ accessToken: await accessToken(actor),
118
100
  name: ServerDirectoryTool,
119
101
  arguments: {},
120
102
  signal: AbortSignal.timeout(CallTimeoutMs),
@@ -128,40 +110,9 @@ export function createTools(deps) {
128
110
  /**
129
111
  * One live tools/list is both the catalog and the authorization answer: the portal only returns
130
112
  * what this user may reach. Nothing here is cached as a permission.
131
- *
132
- * ⚠️ For an agent the list is cut to the delegated servers, and the cut is made against the
133
- * portal's own directory rather than against the tool names — `tool-servers.ts` says why the
134
- * namespace alone is not enough to attribute a tool. The second return value is the attribution
135
- * itself, so the audit below names the server the cut actually used rather than guessing again.
136
113
  */
137
- async function capabilities(who) {
138
- const delegated = who.delegation;
139
- const serverOfTool = new Map();
140
- if (delegatesNothing(who))
141
- return { items: [], serverOfTool };
142
- let remote = await remoteTools(who);
143
- if (delegated) {
144
- const directory = await serverList(who, remote.map((tool) => tool.name));
145
- // ⚠️ Attributed against every identifier the portal DECLARED — including rows it marked
146
- // disabled — and only then checked against the delegation. Attributing against the offerable
147
- // subset (or against the delegation itself) would widen it: with `wiki` delegated and
148
- // `wiki_extra` merely declared, `wiki_extra__read` starts with `wiki_` and would be handed
149
- // over as a `wiki` tool. The longest match over the widest declared set is the only reading
150
- // that no missing row can loosen.
151
- const delegatedSet = new Set(delegated.servers);
152
- // ⚠️ And the attribution is then required to land on a server that is still OFFERABLE — the
153
- // narrow set, enabled and confirmed. Wide for attribution, narrow for permission: a portal
154
- // that switches a server off takes it away from the agent here, which is revocation reaching
155
- // an agent without anybody touching its definition (D30).
156
- const offerable = new Set(directory.servers.map((server) => server.handle));
157
- remote = remote.filter((tool) => {
158
- const handle = serverOf(tool.name, directory.declared);
159
- if (handle === null || !delegatedSet.has(handle) || !offerable.has(handle))
160
- return false;
161
- serverOfTool.set(tool.name, handle);
162
- return true;
163
- });
164
- }
114
+ async function capabilities(actor) {
115
+ const remote = await remoteTools(actor);
165
116
  const items = [];
166
117
  for (const tool of remote) {
167
118
  items.push({
@@ -174,63 +125,27 @@ export function createTools(deps) {
174
125
  }),
175
126
  });
176
127
  }
177
- return { items, serverOfTool };
178
- }
179
- /**
180
- * ⚠️ The refusal that costs nothing. A delegated caller naming a tool outside every delegated
181
- * namespace is turned away here — before the token store, before `tools/list`, before the portal
182
- * hears anything at all. The authoritative cut still happens in `capabilities`, against the
183
- * portal's directory; this one exists so the portal is never touched on behalf of a call that was
184
- * always going to be refused, and so the refusal can name the tool and the servers (D30).
185
- */
186
- function requireDelegated(who, name) {
187
- const delegated = who.delegation;
188
- if (!delegated)
189
- return;
190
- if (delegatesNothing(who)) {
191
- throw new IntelError(403, "tool_not_delegated", `${name} is not available: this agent has no delegated MCP servers`);
192
- }
193
- if (serverOf(name, delegated.servers) === null) {
194
- throw new IntelError(403, "tool_not_delegated", `${name} is not part of this agent's delegated servers (${delegated.servers.join(", ")})`);
195
- }
128
+ return items;
196
129
  }
197
- async function requireCapability(who, name) {
198
- const reachable = await capabilities(who);
199
- const found = reachable.items.find((capability) => capability.name === name);
130
+ async function requireCapability(actor, name) {
131
+ const found = (await capabilities(actor)).find((capability) => capability.name === name);
200
132
  if (!found) {
201
133
  throw new IntelError(404, "tool_not_available", `Tool ${name} is not available to you`);
202
134
  }
203
- return { capability: found, server: reachable.serverOfTool.get(name) };
135
+ return found;
204
136
  }
205
- async function call(who, input) {
206
- if (!who.actor.canExecute) {
137
+ async function call(actor, input) {
138
+ if (!actor.canExecute) {
207
139
  throw new IntelError(403, "tool_execute_forbidden", "Tool execution permission is required");
208
140
  }
209
- requireDelegated(who, input.name);
210
- const { capability, server } = await requireCapability(who, input.name);
141
+ const capability = await requireCapability(actor, input.name);
211
142
  const validation = deps.validate(capability.inputSchema, input.arguments);
212
143
  if (!validation.valid) {
213
144
  throw new IntelError(400, "tool_arguments_invalid", validation.detail ?? "Invalid arguments");
214
145
  }
215
- // ⚠️ Written before the call, not after it: an audit that only records what succeeded is a
216
- // record of the harmless half. Both principals are named — the agent that acted and the person
217
- // whose connection carried it (D30) — and no argument is.
218
- //
219
- // ⚠️ The server is the one the CUT used, carried out of `capabilities`, not a second guess made
220
- // against the delegated handles. Re-deriving it here would name `wiki` for a tool the cut
221
- // attributed to `wiki_extra`, and an audit row that names the wrong system is worse than none.
222
- if (who.delegation) {
223
- await deps.audit({
224
- agentId: who.delegation.agentId,
225
- applicationId: who.actor.id,
226
- delegatedBy: who.delegation.delegatedBy,
227
- server: server ?? "",
228
- tool: capability.name,
229
- });
230
- }
231
146
  const result = await deps.remote.call({
232
147
  url: portal(),
233
- accessToken: await accessToken(who),
148
+ accessToken: await accessToken(actor),
234
149
  name: capability.name,
235
150
  arguments: input.arguments,
236
151
  signal: AbortSignal.timeout(CallTimeoutMs),
@@ -250,23 +165,13 @@ export function createTools(deps) {
250
165
  }
251
166
  return {
252
167
  async catalog(actor) {
253
- const who = await acting(actor);
254
- if (delegatesNothing(who))
255
- return { portalConnected: true, items: [] };
256
- const stored = await deps.tokens.read(who.connectionOf);
168
+ const stored = await deps.tokens.read(actor.id);
257
169
  // No portal sign-in yet is a normal state, not an error: the browser answers it by running
258
- // the silent sign-in and asking again (#60). For an agent it is the ordinary shape of
259
- // revocation — the delegator disconnected, so the agent reaches nothing (D30).
170
+ // the silent sign-in and asking again (#60).
260
171
  if (!stored)
261
172
  return { portalConnected: false, items: [] };
262
173
  try {
263
- const { items, serverOfTool } = await capabilities(who);
264
- // ⚠️ Named only for a delegated caller, and it costs nothing there: the attribution had to
265
- // be made anyway to cut the list. For an ordinary user it would mean a second portal
266
- // request per call — `serverList` is not on their path — and their screen never asks the
267
- // question. The runtime's is the one that does (#289).
268
- const reached = who.delegation ? [...new Set(serverOfTool.values())].sort() : undefined;
269
- return { portalConnected: true, items, ...(reached ? { reached } : {}) };
174
+ return { portalConnected: true, items: await capabilities(actor) };
270
175
  }
271
176
  catch (error) {
272
177
  if (disconnected(error))
@@ -275,25 +180,13 @@ export function createTools(deps) {
275
180
  }
276
181
  },
277
182
  async servers(actor) {
278
- const who = await acting(actor);
279
- if (delegatesNothing(who))
280
- return { portalConnected: true, items: [] };
281
- const stored = await deps.tokens.read(who.connectionOf);
183
+ const stored = await deps.tokens.read(actor.id);
282
184
  if (!stored)
283
185
  return { portalConnected: false, items: [] };
284
186
  try {
285
- const remote = await remoteTools(who);
286
- const { servers } = await serverList(who, remote.map((tool) => tool.name));
287
- // ⚠️ An agent sees its own delegation, never the delegator's whole shelf. Its catalog is cut
288
- // anyway, so the uncut list would grant nothing — it would only tell an agent, and through
289
- // it a model, which other systems the person it acts for is connected to.
290
- const delegated = who.delegation;
291
- return {
292
- portalConnected: true,
293
- items: delegated
294
- ? servers.filter((server) => delegated.servers.includes(server.handle))
295
- : servers,
296
- };
187
+ const remote = await remoteTools(actor);
188
+ const { servers } = await serverList(actor, remote.map((tool) => tool.name));
189
+ return { portalConnected: true, items: servers };
297
190
  }
298
191
  catch (error) {
299
192
  if (disconnected(error))
@@ -301,23 +194,19 @@ export function createTools(deps) {
301
194
  throw error;
302
195
  }
303
196
  },
304
- execute: async (actor, input) => await call(await acting(actor), input),
197
+ execute: async (actor, input) => await call(actor, input),
305
198
  async test(actor, input) {
306
- const who = await acting(actor);
307
- requireDelegated(who, input.name);
308
- const { capability } = await requireCapability(who, input.name);
199
+ const capability = await requireCapability(actor, input.name);
309
200
  if (capability.annotations.readOnlyHint !== true ||
310
201
  capability.annotations.destructiveHint === true) {
311
202
  throw new IntelError(409, "tool_test_unsafe", "Only explicitly read-only, non-destructive tools can run in the test surface");
312
203
  }
313
- return await call(who, input);
204
+ return await call(actor, input);
314
205
  },
315
206
  async unavailable(actor, names) {
316
207
  if (names.length === 0)
317
208
  return [];
318
- const who = await acting(actor);
319
- const reachable = await capabilities(who);
320
- const available = new Set(reachable.items.map((capability) => capability.name));
209
+ const available = new Set((await capabilities(actor)).map((capability) => capability.name));
321
210
  return names.filter((name) => !available.has(name));
322
211
  },
323
212
  };
@@ -35,25 +35,6 @@ export interface RemoteTools {
35
35
  signal: AbortSignal;
36
36
  }): Promise<ToolTestResult>;
37
37
  }
38
- /**
39
- * What an agent's definition delegates, resolved from the Gate Application the caller authenticated
40
- * as (D30). `null` from the port means "this caller is not an agent" — the ordinary user path.
41
- *
42
- * ⚠️ Read fresh on every call and never cached as a permission. It is the same rule the catalog
43
- * follows: the answer has to be able to change between two runs without anybody editing anything.
44
- */
45
- export interface ToolDelegation {
46
- agentId: string;
47
- delegatedBy: string;
48
- servers: string[];
49
- }
50
- export interface ToolAuditEvent {
51
- agentId: string;
52
- applicationId: string;
53
- delegatedBy: string;
54
- server: string;
55
- tool: string;
56
- }
57
38
  export interface ToolDeps {
58
39
  portalUrl: string | null;
59
40
  remote: RemoteTools;
@@ -66,8 +47,6 @@ export interface ToolDeps {
66
47
  valid: boolean;
67
48
  detail?: string;
68
49
  };
69
- delegation(applicationId: string): Promise<ToolDelegation | null>;
70
- audit(event: ToolAuditEvent): Promise<void>;
71
50
  }
72
51
  export interface ToolService {
73
52
  catalog(actor: ToolActor): Promise<ToolCatalog>;