@anchrd/intel-api 0.7.0 → 0.9.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/dist/http/http.js CHANGED
@@ -1,4 +1,4 @@
1
- import { AppendTableRowsInput, ArchiveFlowInput, ArchiveNodeInput, CancelFlowRunInput, CompleteFlowRunStepInput, CreateAgentInput, CreateFlowInput, CreateNodeInput, DefineTableInput, DeleteTableRowsInput, ExecuteToolInput, GetFlowInput, GetFlowRunInput, GetFlowVersionInput, GetNodeInput, GetNodeVersionInput, ListAgentsInput, ListFlowRunsInput, ListFlowsInput, ListNodesInput, NodeGraphInput, PreviewFlowPublishInput, PublishFlowInput, RedefineTableInput, RelationGraphInput, ResolveNodeLinksInput, RevokeGrantInput, SaveAgentDefinitionInput, SaveAttachmentInput, SaveFlowVersionInput, SaveNodeVersionInput, SearchInput, ShareInput, StartFlowRunInput, TestToolInput, UnpublishFlowInput, UpdateFlowInput, UpdateNodeInput, UpdateTableRowsInput, } from "@anchrd/intel-contract";
1
+ import { AppendTableRowsInput, ArchiveFlowInput, ArchiveNodeInput, CancelFlowRunInput, CompleteFlowRunStepInput, CreateAgentInput, CreateFlowInput, CreateNodeInput, DefineTableInput, DeleteTableRowsInput, ExecuteToolInput, GetFlowInput, GetFlowRunInput, GetFlowVersionInput, GetNodeInput, GetNodeVersionInput, ListAgentsInput, ListFlowRunsInput, ListFlowsInput, ListNodesInput, NodeGraphInput, PreviewFlowPublishInput, PublishFlowInput, RedefineTableInput, RelationGraphInput, ResolveNodeLinksInput, RevokeGrantInput, RotateAgentKeyInput, SaveAgentDefinitionInput, SaveAttachmentInput, SaveFlowVersionInput, SaveNodeVersionInput, SearchInput, ShareInput, StartFlowRunInput, TestToolInput, UnpublishFlowInput, UpdateFlowInput, UpdateNodeInput, UpdateTableRowsInput, } from "@anchrd/intel-contract";
2
2
  import { Hono } from "hono";
3
3
  import { z } from "zod";
4
4
  import { authorizeBearer, bearer, permits, } from "../shared/gate-authorization/gate-authorization.js";
@@ -231,19 +231,34 @@ export function createHttp(deps) {
231
231
  return context.json(await deps.nodes.listAgents(asActor(auth), input));
232
232
  });
233
233
  /**
234
- * ⚠️ The one response in Intel's HTTP surface that carries a credential (#182, D27).
234
+ * ⚠️ NO response in Intel's HTTP surface carries a credential any more (D29, #207).
235
235
  *
236
- * `applicationKey` is the Gate Application key of the new agent, in plain text, exactly once —
237
- * Gate issues it once and keeps only a hash, and Intel writes it nowhere. It belongs in the agent
238
- * runtime's `AGENT_APPLICATION_KEYS` secret; the answer says so rather than assuming the caller
239
- * knows. The caller's own bearer is what creates the principal, so this needs `applications:write`
240
- * in Gate on top of `knowledge:create` in Intel.
236
+ * Creating an agent still mints its Gate Application with the caller's own bearer so this needs
237
+ * `applications:write` in Gate on top of `knowledge:create` in Intel but the key Gate issues
238
+ * once goes straight into the agent runtime over the service binding, inside this request. The
239
+ * answer is the same shape every agent read has. Until #207 it carried the key in plain text and
240
+ * a person had to put it into a Worker secret by hand, which is why an agent created here could
241
+ * never run (#200).
241
242
  */
242
243
  app.post("/nodes/agents", async (context) => {
243
244
  const auth = requireCapability(context, "knowledge", "create");
244
245
  const input = CreateAgentInput.parse(await context.req.json().catch(() => null));
245
246
  return context.json(await deps.nodes.createAgent(asActor(auth), input, { token: context.get("token") }), 201);
246
247
  });
248
+ /**
249
+ * The repair path, and the only one an agent that lost its key has (D29, #207).
250
+ *
251
+ * ⚠️ `knowledge/write`, not `agents/run`. Which principal an agent acts as is a property of the
252
+ * agent, changed by whoever may change the agent; being allowed to press "Run now" is a different
253
+ * question and answers a different one. The resource ACL on the node is checked by the service.
254
+ *
255
+ * ⚠️ Before `/nodes/:nodeId`, like every other literal segment under `/nodes`.
256
+ */
257
+ app.post("/nodes/agents/:nodeId/rotate-key", async (context) => {
258
+ const auth = requireCapability(context, "knowledge", "write");
259
+ const input = RotateAgentKeyInput.parse({ nodeId: context.req.param("nodeId") });
260
+ return context.json(await deps.nodes.rotateAgentKey(asActor(auth), input, { token: context.get("token") }));
261
+ });
247
262
  // ⚠️ Before `/nodes/:nodeId`, or the router would read "export" as a node ID. The root has no
248
263
  // node to address, so the whole installation — as this caller may read it — exports here (#136).
249
264
  app.get("/nodes/export", async (context) => {
@@ -295,7 +310,9 @@ export function createHttp(deps) {
295
310
  if (input.nodeId !== context.req.param("nodeId")) {
296
311
  throw new IntelError(400, "node_id_mismatch", "Path and body node IDs differ");
297
312
  }
298
- return context.json(await deps.nodes.saveAgentDefinition(asActor(auth), input), 201);
313
+ // ⚠️ The caller's own bearer travels on, like it does when an agent is created: writing a
314
+ // definition also arms what it schedules, and that call is made as the person saving (#214).
315
+ return context.json(await deps.nodes.saveAgentDefinition(asActor(auth), input, { token: context.get("token") }), 201);
299
316
  });
300
317
  app.get("/nodes/:nodeId/versions", async (context) => {
301
318
  const auth = requireCapability(context, "knowledge", "read");
@@ -673,6 +690,13 @@ export function createHttp(deps) {
673
690
  const auth = requireCapability(context, "tools", "read");
674
691
  return context.json(await deps.tools.catalog(asToolActor(auth)));
675
692
  });
693
+ // ⚠️ Registered before `/tools/test` and `/tools/execute` and behind `tools/read`, not a new
694
+ // capability: it answers strictly less than `/tools` does — the servers behind the same live
695
+ // list, with no schemas (D30).
696
+ app.get("/tools/servers", async (context) => {
697
+ const auth = requireCapability(context, "tools", "read");
698
+ return context.json(await deps.tools.servers(asToolActor(auth)));
699
+ });
676
700
  app.post("/tools/test", async (context) => {
677
701
  const auth = requireCapability(context, "tools", "test");
678
702
  const input = TestToolInput.parse(await context.req.json().catch(() => null));
package/dist/mcp/mcp.js CHANGED
@@ -1,4 +1,4 @@
1
- import { AppendTableRowsInput, ArchiveFlowInput, ArchiveNodeInput, CancelFlowRunInput, CompleteFlowRunStepInput, CreateAgentInput, CreateFlowInput, CreateNodeInput, DefineTableInput, DeleteTableRowsInput, ExecuteToolInput, GetAgentInput, GetFlowInput, GetFlowRunInput, GetFlowVersionInput, GetNodeInput, GetNodeVersionInput, GetTableInput, IntelId, ListAgentsInput, ListFlowRunsInput, ListFlowsInput, ListGrantsInput, ListNodesInput, NodeGraphInput, PauseAgentInput, PreviewFlowPublishInput, PublishFlowInput, RedefineTableInput, RelationGraphInput, ResolveNodeLinksInput, RevokeGrantInput, RunAgentNowInput, SaveAgentDefinitionInput, SaveAttachmentInput, SaveFlowVersionInput, SaveNodeVersionInput, SearchInput, ShareInput, StartFlowRunInput, TestToolInput, UnpublishFlowInput, UpdateFlowInput, UpdateNodeInput, UpdateTableRowsInput, } from "@anchrd/intel-contract";
1
+ import { AppendTableRowsInput, ArchiveFlowInput, ArchiveNodeInput, CancelFlowRunInput, CompleteFlowRunStepInput, CreateAgentInput, CreateFlowInput, CreateNodeInput, DefineTableInput, DeleteTableRowsInput, ExecuteToolInput, GetAgentInput, GetFlowInput, GetFlowRunInput, GetFlowVersionInput, GetNodeInput, GetNodeVersionInput, GetTableInput, IntelId, ListAgentsInput, ListFlowRunsInput, ListFlowsInput, ListGrantsInput, ListNodesInput, NodeGraphInput, PauseAgentInput, PreviewFlowPublishInput, PublishFlowInput, RedefineTableInput, RelationGraphInput, ResolveNodeLinksInput, RevokeGrantInput, RotateAgentKeyInput, RunAgentNowInput, SaveAgentDefinitionInput, SaveAttachmentInput, SaveFlowVersionInput, SaveNodeVersionInput, SearchInput, ShareInput, StartFlowRunInput, TestToolInput, UnpublishFlowInput, UpdateFlowInput, UpdateNodeInput, UpdateTableRowsInput, } from "@anchrd/intel-contract";
2
2
  import { McpServer, ResourceTemplate, } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
4
4
  import { z } from "zod";
@@ -342,19 +342,18 @@ export async function handleMcp(request, deps) {
342
342
  },
343
343
  }, async (input) => text(await deps.nodes.create(actor, input)));
344
344
  /**
345
- * ⚠️ The ONE tool result in Intel that contains a secret, and it is a decision rather than an
346
- * oversight (#182, D27). Everywhere else in this file a credential in a result would be a leak
347
- * into model context; here the result IS the credential Gate issues an Application key once
348
- * and keeps only its hash, so a redacted answer would mean "an agent you can create but never
349
- * run", and the same administrator would get less through MCP than through the browser. Gate
350
- * made the identical exception for `application_create` (`anchrd/gate` GATE-75).
351
- *
352
- * The exception is this tool and nothing beside it: `agent_get`, `agent_list` and every node
353
- * read answer with `applicationId` alone, which names the principal without authenticating it.
345
+ * ⚠️ There is no longer ANY tool result in Intel that contains a secret, and that is the whole
346
+ * of D29 seen from here. Until #207 this one tool answered with the Application key in plain
347
+ * text a deliberate exception, because the result WAS the credential and a redacted answer
348
+ * would have meant "an agent you can create but never run". The exception is gone rather than
349
+ * weakened: the key now travels from Gate into the agent runtime over Intel's service binding
350
+ * inside this call, so there is nothing left to hand to a model. `agent_create`, `agent_get`,
351
+ * `agent_list` and every node read answer with `applicationId` alone, which names the principal
352
+ * without authenticating it.
354
353
  */
355
354
  server.registerTool("agent_create", {
356
355
  title: "Create agent",
357
- description: "Create an agent node with its first definition, and the Gate application it runs as. The definition names nodes and their role, schedules and a model — never accounts, secrets or channels. The response carries the application key ONCE, in plain text: put it into the agent runtime's AGENT_APPLICATION_KEYS secret and store it nowhere else. A repeated idempotency key returns the existing agent with no key.",
356
+ description: "Create an agent node with its first definition, and the Gate application it runs as. The definition names nodes and their role, schedules and a model — never accounts, secrets or channels. The agent's application key is handed to the agent runtime internally and never appears in this result; the agent can run straight away. A repeated idempotency key returns the existing agent and mints nothing.",
358
357
  inputSchema: CreateAgentInput,
359
358
  annotations: {
360
359
  title: "Create agent",
@@ -378,7 +377,29 @@ export async function handleMcp(request, deps) {
378
377
  idempotentHint: true,
379
378
  openWorldHint: false,
380
379
  },
381
- }, async (input) => text(await deps.nodes.saveAgentDefinition(actor, input)));
380
+ }, async (input) => text(await deps.nodes.saveAgentDefinition(actor, input, { token: deps.bearer })));
381
+ /**
382
+ * The MCP half of the "Replace key" action in the agent profile (D29, #207).
383
+ *
384
+ * ⚠️ `knowledge/write` like `agent_update`, not `agents/run`: which principal an agent acts as
385
+ * is a property of the agent, changed by whoever may change the agent. And the result names the
386
+ * principal and the moment — never the key, which went to the runtime and nowhere else.
387
+ */
388
+ server.registerTool("agent_rotate_key", {
389
+ title: "Replace an agent's application key",
390
+ description: "Issue a new Gate application key for an agent and give it to the agent runtime. The previous key stops working immediately. Use it when a run fails with agent_key_missing, or when a key may have been exposed. The key itself is never returned.",
391
+ inputSchema: RotateAgentKeyInput,
392
+ annotations: {
393
+ title: "Replace an agent's application key",
394
+ readOnlyHint: false,
395
+ // The old key stops working the moment this runs, and anything still using it breaks.
396
+ destructiveHint: true,
397
+ // Asking twice issues twice, and the second answer invalidates the first key.
398
+ idempotentHint: false,
399
+ // The principal lives in Gate, which is a system outside Intel.
400
+ openWorldHint: true,
401
+ },
402
+ }, async (input) => text(await deps.nodes.rotateAgentKey(actor, input, { token: deps.bearer })));
382
403
  registerWithAlias(server, { name: "node_save", deprecated: "knowledge_save" }, {
383
404
  title: "Save node version",
384
405
  description: "Append an immutable content version using an optimistic base version.",
@@ -927,6 +948,21 @@ export async function handleMcp(request, deps) {
927
948
  openWorldHint: false,
928
949
  },
929
950
  }, async () => text(await deps.tools.catalog(toolActor)));
951
+ // The MCP twin of the picker in the agent profile (D30): whole servers, so an agent can be
952
+ // given tools from a client instead of from the screen. Assigning and removing is
953
+ // `agent_update` — a delegation is a field of the definition, not a surface of its own.
954
+ server.registerTool("tools_servers_list", {
955
+ title: "List tool servers",
956
+ description: "List the MCP servers the calling user reaches through the portal, as the portal itself names them. These are the handles an agent definition may delegate.",
957
+ inputSchema: EmptyInput,
958
+ annotations: {
959
+ title: "List tool servers",
960
+ readOnlyHint: true,
961
+ destructiveHint: false,
962
+ idempotentHint: true,
963
+ openWorldHint: false,
964
+ },
965
+ }, async () => text(await deps.tools.servers(toolActor)));
930
966
  }
931
967
  if (permits(deps.authorization, "tools", "test")) {
932
968
  server.registerTool("tools_test", {
@@ -327,6 +327,34 @@ export function createNodes(deps) {
327
327
  throw new IntelError(500, "content_missing", "Version content is missing");
328
328
  return { node, version, definition: parseStoredDefinition(body), applicationId };
329
329
  }
330
+ /**
331
+ * The one place a caller's asked-for definition becomes the definition Intel stores (D30).
332
+ *
333
+ * ⚠️ `delegatedBy` is ADDED here, from the session, and could not have arrived any other way:
334
+ * `AgentDefinitionInput` has no field for it, so a body naming somebody else is a parse error at
335
+ * the boundary rather than a value this function has to remember to ignore. Adding it here is
336
+ * safe in exactly one direction — the writer must prove below that they reach every named server
337
+ * themselves, so the delegation can only ever move to somebody who already had it.
338
+ *
339
+ * ⚠️ The check is a live `tools/list` through the portal, before anything is written. A server the
340
+ * saver cannot reach is refused BY NAME — a silent drop would leave a person looking at a saved
341
+ * agent that quietly has one tool fewer than they picked.
342
+ */
343
+ async function delegationOf(actor, definition) {
344
+ const tools = definition.tools;
345
+ if (!tools)
346
+ return { ...definition, tools: null };
347
+ const servers = [...new Set(tools.servers)];
348
+ if (servers.length === 0) {
349
+ return { ...definition, tools: { delegatedBy: actor.id, servers: [] } };
350
+ }
351
+ const reachable = new Set(await deps.toolServers(actor));
352
+ const missing = servers.filter((server) => !reachable.has(server));
353
+ if (missing.length > 0) {
354
+ throw new IntelError(403, "tool_server_not_delegatable", `You do not reach these MCP servers, so you cannot delegate them: ${missing.join(", ")}`);
355
+ }
356
+ return { ...definition, tools: { delegatedBy: actor.id, servers } };
357
+ }
330
358
  /**
331
359
  * One immutable definition version, written exactly the way a document's content is (ADR-0005 §1).
332
360
  *
@@ -694,10 +722,12 @@ export function createNodes(deps) {
694
722
  * would make the second write look like a replay of the first. A caller who repeats the
695
723
  * request gets the agent that already exists rather than a second one beside it.
696
724
  *
697
- * ⚠️ A REPLAY MAKES NO SECOND APPLICATION and answers `applicationKey: null`. It still returns
698
- * the agent that exists — that promise is not weakened — but a repeat that minted a fresh
699
- * principal to fill the field would leave the installation with two machine accounts for one
700
- * agent, one of which nobody would ever switch off. Gate is not asked at all on that path.
725
+ * ⚠️ A REPLAY MAKES NO SECOND APPLICATION. It still returns the agent that exists — that promise
726
+ * is not weakened — but a repeat that minted a fresh principal would leave the installation with
727
+ * two machine accounts for one agent, one of which nobody would ever switch off, and would
728
+ * replace a working agent's key with one it never asked for. Gate is not asked at all on that
729
+ * path, and no key travels either. What a replay DOES do is arm the agent's schedules again
730
+ * (#214) — that is idempotent, and it is the repair for a create whose arming failed.
701
731
  *
702
732
  * ⚠️ Gate is asked BEFORE anything is written, and that ordering IS the answer to "what if Gate
703
733
  * is down". Nothing exists yet at that moment, so a Gate that does not answer leaves no node, no
@@ -722,14 +752,21 @@ export function createNodes(deps) {
722
752
  const definitionKey = `${input.idempotencyKey}:definition`;
723
753
  const existingId = await deps.repository.findIdempotentNode(actor.id, "node.create", input.idempotencyKey);
724
754
  if (existingId) {
725
- return {
726
- ...(await agentOf(await requireVisible(actor, existingId))),
727
- applicationKey: null,
728
- };
755
+ const existing = await agentOf(await requireVisible(actor, existingId));
756
+ // ⚠️ A replay arms as well, and that is what makes a create whose sync failed repairable
757
+ // (#214): repeating it with the same key mints nothing, returns this agent, and puts its
758
+ // alarm right. Without it the only repair left would be a fresh key — a second agent.
759
+ if ((existing.definition?.schedules.length ?? 0) > 0) {
760
+ await deps.agentSchedules.sync({ token: caller.token, agentId: existing.node.id });
761
+ }
762
+ return existing;
729
763
  }
730
764
  if (input.parentId !== null && !(await deps.repository.can(actor, input.parentId, "write"))) {
731
765
  throw new IntelError(403, "node_forbidden", "Parent folder cannot be edited");
732
766
  }
767
+ // Before Gate, for the same reason the runtime check is before Gate: a delegation the saver
768
+ // cannot back must not leave a machine principal behind for an agent that was never created.
769
+ const definition = await delegationOf(actor, input.definition);
733
770
  const nodeId = deps.id();
734
771
  // The Application's name is what a person reads in Gate's list, so it has to be enough to
735
772
  // recognise the agent by. Title alone would leave two agents called "Research" indis-
@@ -738,6 +775,22 @@ export function createNodes(deps) {
738
775
  token: caller.token,
739
776
  name: `Intel agent ${input.title} (${nodeId})`,
740
777
  });
778
+ // ⚠️ The handover happens BEFORE the node is written, for the same reason Gate is asked before
779
+ // it: at this moment nothing exists on Intel's side, so a runtime that does not take the key
780
+ // leaves no node, no version, no R2 object and no audit event — the agent simply does not come
781
+ // into being and the caller repeats the request. The other order would produce exactly the
782
+ // agent #200 is about: a node that looks finished and dies at its first run. What it can leave
783
+ // behind is a Durable Object holding a key for a node id that will never exist, which is
784
+ // ciphertext nobody can address — ULIDs are not reused.
785
+ try {
786
+ await deps.agentKeys.store({ token: caller.token, agentId: nodeId, key: application.key });
787
+ }
788
+ catch (error) {
789
+ await deps.applications
790
+ .setEnabled({ token: caller.token, applicationId: application.id, enabled: false })
791
+ .catch(() => undefined);
792
+ throw error;
793
+ }
741
794
  const timestamp = deps.now().toISOString();
742
795
  let node;
743
796
  try {
@@ -766,21 +819,95 @@ export function createNodes(deps) {
766
819
  .catch(() => undefined);
767
820
  throw error;
768
821
  }
769
- const agent = await writeAgentVersion(actor, node, input.definition, null, definitionKey);
770
- return {
771
- ...agent,
772
- // ⚠️ The one place a credential is put into an answer, and it is assembled here rather than
773
- // carried around: `application.key` has been in one local variable since Gate returned it
774
- // and reaches no store, no audit row and no log on the way (D27).
775
- applicationKey: {
776
- agentId: node.id,
777
- applicationId: application.id,
778
- key: application.key,
779
- notice: "This key is shown once. Add it to the agent runtime's AGENT_APPLICATION_KEYS secret as { agentId, key } — the whole list, not just this entry — and store it nowhere else. Intel keeps only the application ID; Gate keeps only a hash.",
780
- },
781
- };
822
+ // ⚠️ No key in the answer, and no field one could travel in (D29). `application.key` has been
823
+ // in one local variable since Gate returned it, went into the runtime, and is out of reach of
824
+ // every surface above this line.
825
+ const created = (await writeAgentVersion(actor, node, definition, null, definitionKey));
826
+ /**
827
+ * ⚠️ Only when this definition actually schedules something unlike the save below, which
828
+ * arms unconditionally. A brand-new agent has no earlier definition, so there is no alarm that
829
+ * could be left standing: no schedules means there is nothing to arm and nothing to clear, and
830
+ * the screen creates every agent that way (the profile is where schedules are added).
831
+ *
832
+ * ⚠️ It fails loudly, and the agent stays. Everything is written by now and nothing here can
833
+ * roll an agent back, so the caller is told the one thing they can act on: the schedules are
834
+ * not armed. The repair is repeating the create with the SAME idempotency key — which returns
835
+ * this agent, mints no second Application, and arms it. A fresh key would create a second
836
+ * agent beside this one, which is why the message says "again" and not "retry".
837
+ */
838
+ if (definition.schedules.length > 0) {
839
+ await deps.agentSchedules.sync({ token: caller.token, agentId: node.id });
840
+ }
841
+ return created;
782
842
  },
783
- async saveAgentDefinition(actor, input) {
843
+ /**
844
+ * A new key for the Application this agent runs as, handed straight to the runtime (D29, #207).
845
+ *
846
+ * ⚠️ A write on the agent node, not a run: `knowledge/write` at the door and the resource ACL
847
+ * here. Whoever may change what an agent does may change which key it does it with; being
848
+ * allowed to press "Run now" says nothing about being allowed to decide its principal.
849
+ *
850
+ * ⚠️ Gate first, runtime second, and nothing in between. Gate issues the replacement before it
851
+ * revokes the old one, so a rotation that fails at Gate changes nothing at all; one that fails
852
+ * at the handover leaves the agent with a key it no longer has — named, repairable, and fixed by
853
+ * rotating again. The alternative order cannot exist: the runtime has nothing to store until
854
+ * Gate has answered.
855
+ *
856
+ * ⚠️ The key is in one local variable and in no answer. This is the operation that exists BECAUSE
857
+ * Gate hands a key out exactly once — reading one back is not something any repair can do.
858
+ */
859
+ async rotateAgentKey(actor, input, caller) {
860
+ const node = await requireVisible(actor, input.nodeId);
861
+ if (node.kind !== "agent") {
862
+ throw new IntelError(409, "not_an_agent", "Only agents have an application key");
863
+ }
864
+ if (!(await deps.repository.can(actor, node.id, "write"))) {
865
+ throw new IntelError(403, "node_forbidden", "This agent cannot be edited");
866
+ }
867
+ if (!deps.agentRuntimeAvailable()) {
868
+ throw new IntelError(503, "agent_runtime_not_configured", "This installation has no agent runtime, so there is nothing to give a key to");
869
+ }
870
+ const applicationId = await deps.repository.agentApplicationId(node.id);
871
+ if (applicationId === null) {
872
+ // An agent written before #182, restored from a bundle, or imported. Giving it a principal
873
+ // is an act in Gate, and inventing one here would make Intel the author of a machine
874
+ // account nobody asked for.
875
+ throw new IntelError(409, "agent_application_missing", "This agent has no Gate application, so there is no key to replace");
876
+ }
877
+ const rotated = await deps.applications.rotateKey({ token: caller.token, applicationId });
878
+ await deps.agentKeys.store({ token: caller.token, agentId: node.id, key: rotated.key });
879
+ const rotatedAt = deps.now().toISOString();
880
+ // ⚠️ Written LAST, and only once both sides took the change. An event ahead of the runtime
881
+ // would record a rotation that may not have arrived, and this row is the only trace the act
882
+ // leaves in Intel — nothing about the node itself changes, so a reader who diffed versions
883
+ // would see an agent that silently began acting with a different credential.
884
+ await deps.repository.recordAgentKeyRotation({
885
+ auditId: deps.id(),
886
+ actorId: actor.id,
887
+ nodeId: node.id,
888
+ applicationId,
889
+ occurredAt: rotatedAt,
890
+ });
891
+ return { nodeId: node.id, applicationId, rotatedAt };
892
+ },
893
+ /**
894
+ * A new definition version, and the alarm that has to match it (#214).
895
+ *
896
+ * ⚠️ The runtime is told to re-arm after EVERY definition write, not only after one that names a
897
+ * schedule. The alternative — comparing against the definition that was there before — needs the
898
+ * previous document read back out of R2, and it is wrong in exactly the direction that matters:
899
+ * a save that removes the LAST schedule has nothing to compare against in the new definition and
900
+ * is precisely the write that has to clear the alarm. One binding call per save buys the rule
901
+ * "what is written is what is armed" without a comparison anybody could get backwards.
902
+ *
903
+ * ⚠️ Written first, armed second, and the definition STAYS when the arming fails. It has to: the
904
+ * runtime reads the definition back out of Intel to arm it, so there is no order in which it
905
+ * could be armed before it exists. What the caller gets instead of silence is
906
+ * `agent_schedules_not_armed` — the version is in the history, the profile shows it, and saving
907
+ * again arms it. Rolling the version back would be worse than the unarmed alarm: it would throw
908
+ * away an edit somebody made because a Worker was briefly unreachable.
909
+ */
910
+ async saveAgentDefinition(actor, input, caller) {
784
911
  const existingId = await deps.repository.findIdempotentNode(actor.id, "node.save", input.idempotencyKey);
785
912
  if (existingId)
786
913
  return await agentOf(await requireVisible(actor, input.nodeId));
@@ -794,7 +921,9 @@ export function createNodes(deps) {
794
921
  if (node.currentVersionId !== input.baseVersionId) {
795
922
  throw new IntelError(409, "version_conflict", "A newer version already exists");
796
923
  }
797
- return await writeAgentVersion(actor, node, input.definition, input.baseVersionId, input.idempotencyKey);
924
+ const saved = await writeAgentVersion(actor, node, await delegationOf(actor, input.definition), input.baseVersionId, input.idempotencyKey);
925
+ await deps.agentSchedules.sync({ token: caller.token, agentId: node.id });
926
+ return saved;
798
927
  },
799
928
  async saveAttachment(actor, input) {
800
929
  const existingId = await deps.repository.findIdempotentNode(actor.id, "node.save", input.idempotencyKey);
@@ -1,4 +1,4 @@
1
- import type { AgentList, AppendTableRowsInput, AppendTableRowsResult, ArchiveNodeInput, CreateAgentInput, CreatedAgent, CreateNodeInput, DefineTableInput, DeleteTableRowsInput, DeleteTableRowsResult, Flow, FlowVersion, GetAgentInput, ListAgentsInput, ListNodesInput, Node, NodeAgent, NodeAttachment, NodeCitation, NodeDocument, NodeGraph, NodeGraphInput, NodeLink, NodeTable, NodeVersion, RedefineTableInput, ResolveNodeLinksInput, ResolveNodeLinksResult, ResourceGrant, ResourceGrantList, ResourceVerb, RevokeGrantInput, SaveAgentDefinitionInput, SaveAttachmentInput, SaveNodeVersionInput, SearchInput, ShareInput, ShareResult, UpdateNodeInput, UpdateTableRowsInput, UpdateTableRowsResult } from "@anchrd/intel-contract";
1
+ import type { AgentKeyRotated, AgentList, AppendTableRowsInput, AppendTableRowsResult, ArchiveNodeInput, CreateAgentInput, CreatedAgent, CreateNodeInput, DefineTableInput, DeleteTableRowsInput, DeleteTableRowsResult, Flow, FlowVersion, GetAgentInput, ListAgentsInput, ListNodesInput, Node, NodeAgent, NodeAttachment, NodeCitation, NodeDocument, NodeGraph, NodeGraphInput, NodeLink, NodeTable, NodeVersion, RedefineTableInput, ResolveNodeLinksInput, ResolveNodeLinksResult, ResourceGrant, ResourceGrantList, ResourceVerb, RevokeGrantInput, RotateAgentKeyInput, SaveAgentDefinitionInput, 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;
@@ -55,6 +55,26 @@ export interface NodeRepository {
55
55
  findIdempotentRevocation(actorId: string, idempotencyKey: string): Promise<boolean | null>;
56
56
  insertNode(input: NewNode): Promise<Node>;
57
57
  agentApplicationId(nodeId: string): Promise<string | null>;
58
+ /**
59
+ * The audit row for replacing the key of the Application an agent runs as (D29, #207).
60
+ *
61
+ * ⚠️ Its own write because it is the only mutation on an agent node that changes nothing IN the
62
+ * node — no version, no row, nothing a reader could diff afterwards. Swapping which credential an
63
+ * agent acts with is the most security-relevant thing anybody does to it, and without this it
64
+ * would be the one act on the tree that leaves no trace in Intel at all. Gate audits its side;
65
+ * Gate does not know which Intel node the principal belongs to.
66
+ *
67
+ * ⚠️ `applicationId` names the principal and is a name, not a credential. The key itself is not a
68
+ * parameter here and must never become one — audit is metadata, and this is exactly the row a
69
+ * "helpful" second field would leak it into.
70
+ */
71
+ recordAgentKeyRotation(input: {
72
+ auditId: string;
73
+ actorId: string;
74
+ nodeId: string;
75
+ applicationId: string;
76
+ occurredAt: string;
77
+ }): Promise<void>;
58
78
  getVersion(versionId: string): Promise<NodeVersion | null>;
59
79
  appendVersion(input: NewNodeVersion): Promise<"saved" | "conflict">;
60
80
  appendTableVersion(input: NewTableVersion): Promise<NodeVersion>;
@@ -184,6 +204,19 @@ export interface AgentApplications {
184
204
  id: string;
185
205
  key: string;
186
206
  }>;
207
+ /**
208
+ * A replacement key for an Application that already exists, invalidating every earlier one.
209
+ *
210
+ * ⚠️ Gate issues before it revokes, so a failed rotation leaves the old key working. That is what
211
+ * makes `rotate-key` a safe repair rather than a way to lock an agent out: the only state this
212
+ * can leave behind is "new key issued, runtime never got it", and rotating again fixes it.
213
+ */
214
+ rotateKey(input: {
215
+ token: string;
216
+ applicationId: string;
217
+ }): Promise<{
218
+ key: string;
219
+ }>;
187
220
  setEnabled(input: {
188
221
  token: string;
189
222
  applicationId: string;
@@ -198,6 +231,38 @@ export interface NodesDeps {
198
231
  content: ContentStore;
199
232
  applications: AgentApplications;
200
233
  agentRuntimeAvailable(): boolean;
234
+ /**
235
+ * The runtime's key store, as the tree needs it (D29, #207).
236
+ *
237
+ * ⚠️ This is where the Application key GOES, and it is the only place it goes. Gate hands it out
238
+ * once, this port carries it into the agent's Durable Object over the service binding, and the
239
+ * local variable that held it dies with the call. Nothing in this file writes it to D1, R2, an
240
+ * audit row, a log line or a response — the proof is the absence of the value in every argument
241
+ * every port was called with, not a redaction somebody could forget.
242
+ */
243
+ agentKeys: {
244
+ store(input: {
245
+ token: string;
246
+ agentId: string;
247
+ key: string;
248
+ }): Promise<void>;
249
+ };
250
+ /**
251
+ * The runtime's alarm, brought in line with the definition that was just written (#214).
252
+ *
253
+ * ⚠️ Required, not optional, and it carries no schedules. The runtime arms its alarm only when
254
+ * something tells it to look — a chat turn, a resume, a manual run, or this call — so a definition
255
+ * written without it leaves an agent whose schedules are visible in its profile and fire never.
256
+ * What travels is the agent's ID and the caller's bearer; the runtime re-reads the definition from
257
+ * Intel with the agent's own token, which is what keeps a schedule something that was written
258
+ * before it can be acted on.
259
+ */
260
+ agentSchedules: {
261
+ sync(input: {
262
+ token: string;
263
+ agentId: string;
264
+ }): Promise<void>;
265
+ };
201
266
  id(): string;
202
267
  now(): Date;
203
268
  externalFlowCallers(actor: Actor, folderId: string): Promise<{
@@ -205,6 +270,14 @@ export interface NodesDeps {
205
270
  hidden: number;
206
271
  }>;
207
272
  flowNodeReferences(actor: Actor, folderId: string): Promise<string[]>;
273
+ /**
274
+ * The handles of the MCP servers this actor reaches through the portal right now (D30).
275
+ *
276
+ * ⚠️ Injected as a live question, never as a stored list. Delegating a server is only allowed to
277
+ * somebody who has it themselves, and "has it" can only be answered by asking the portal at the
278
+ * moment of the save — a mirrored table here would be the permission mirror ADR-0003 removed.
279
+ */
280
+ toolServers(actor: Actor): Promise<string[]>;
208
281
  hash(content: string | Uint8Array): Promise<string>;
209
282
  indexing: {
210
283
  enqueue(versionId: string): Promise<void>;
@@ -254,14 +327,29 @@ export interface NodeService {
254
327
  redefineTable(actor: Actor, input: RedefineTableInput): Promise<NodeTable>;
255
328
  getAgent(actor: Actor, input: GetAgentInput): Promise<NodeAgent>;
256
329
  /**
257
- * An agent node, its first definition, and the Gate Application it runs as (#182).
330
+ * An agent node, its first definition, and the Gate Application it runs as (#182, D29).
258
331
  *
259
- * ⚠️ The answer carries the Application key ONCE. It is the only value any Intel service returns
260
- * that is a credential, it is never written down on the way out, and the caller who receives it
261
- * is the only one who will ever see it.
332
+ * ⚠️ The answer carries NO key. The one Gate issues goes straight into the agent runtime over the
333
+ * service binding, inside this call, and the caller never sees it which is what makes an agent
334
+ * created through the screen able to run without a terminal step (#200, #207).
262
335
  */
263
336
  createAgent(actor: Actor, input: CreateAgentInput, caller: GateCaller): Promise<CreatedAgent>;
264
- saveAgentDefinition(actor: Actor, input: SaveAgentDefinitionInput): Promise<NodeAgent>;
337
+ /**
338
+ * Replace the key of the Application an agent runs as, and give the new one to the runtime.
339
+ *
340
+ * ⚠️ The repair path for an agent that answers `agent_key_missing`, and the only one: Gate keeps
341
+ * a key hashed, so an agent whose Durable Object lost its key cannot be handed the old one back.
342
+ * It is a write on the agent node — `knowledge/write` and the resource ACL — not a run.
343
+ */
344
+ rotateAgentKey(actor: Actor, input: RotateAgentKeyInput, caller: GateCaller): Promise<AgentKeyRotated>;
345
+ /**
346
+ * A new definition version for an agent, and the alarm that goes with it (#214).
347
+ *
348
+ * ⚠️ It takes the caller's bearer for the same reason `createAgent` does: the runtime is told to
349
+ * re-arm over the service binding, and what travels on that call is the person behind this request
350
+ * — never a service key (D19).
351
+ */
352
+ saveAgentDefinition(actor: Actor, input: SaveAgentDefinitionInput, caller: GateCaller): Promise<NodeAgent>;
265
353
  listAgents(actor: Actor, input: ListAgentsInput): Promise<AgentList>;
266
354
  getAttachment(actor: Actor, nodeId: string): Promise<NodeAttachment>;
267
355
  readAttachment(actor: Actor, nodeId: string): Promise<NodeAttachmentBody>;
@@ -0,0 +1,46 @@
1
+ import { type ToolServer } from "@anchrd/intel-contract";
2
+ /**
3
+ * The portal's own directory tool. It is an ordinary entry in `tools/list`, so it is reached the
4
+ * same way every other tool is: `tools/call` with the asking user's portal token.
5
+ *
6
+ * ⚠️ Its presence in the live list is also the permission check. A deployment whose portal does not
7
+ * offer it, or a user whose Access policies hide it, gets no server list at all — and therefore
8
+ * cannot delegate anything. That is deliberate: the alternative would be inventing the server list
9
+ * out of tool names, which is exactly the guess this module exists to avoid.
10
+ */
11
+ export declare const ServerDirectoryTool = "portal_list_servers";
12
+ export interface ToolServerDirectoryAnswer {
13
+ isError: boolean;
14
+ content: unknown[];
15
+ structuredContent?: unknown;
16
+ }
17
+ /**
18
+ * The servers this user reaches: the portal's directory, confirmed against the portal's live tool
19
+ * list.
20
+ *
21
+ * Two sets come out of one answer, and keeping them apart is the whole of this function:
22
+ *
23
+ * - **`declared`** — every identifier the directory mentions at all, disabled rows included. It is
24
+ * the universe `serverOf` attributes against, and it must be as WIDE as possible. ⚠️ Narrowing it
25
+ * widens the delegation: drop `wiki_extra` because the portal marked it disabled, and the live
26
+ * tool `wiki_extra__read` falls back onto `wiki` — a tool of a server nobody delegated, handed to
27
+ * an agent that was given `wiki`. Filtering therefore happens after attribution, never before it.
28
+ * - **the returned list** — what may actually be delegated: enabled, and confirmed by at least one
29
+ * live tool. Intel cannot prove that a directory identifier is the same string the namespace uses,
30
+ * so it only ever offers handles it has just seen work.
31
+ *
32
+ * ⚠️ What remains outside Intel's reach: a directory that omits a server whose tools ARE in
33
+ * `tools/list`. Nothing in the answer distinguishes that from a tool of the enclosing namespace, and
34
+ * the portal is the authority on its own servers — so an incomplete directory widens by exactly one
35
+ * namespace level, and it is a portal defect rather than something this code can detect.
36
+ */
37
+ export interface ToolServerDirectory {
38
+ /** Every identifier the directory mentioned. Attribute against this, never against `servers`. */
39
+ declared: string[];
40
+ /** What may be offered and delegated: enabled, and confirmed by a live tool. */
41
+ servers: ToolServer[];
42
+ }
43
+ export declare function toolServersFrom(input: {
44
+ directory: ToolServerDirectoryAnswer;
45
+ toolNames: string[];
46
+ }): ToolServerDirectory | null;