@anchrd/intel-api 0.7.0 → 0.10.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.
@@ -21,6 +21,19 @@ export interface AgentActor extends Actor {
21
21
  canRun: boolean;
22
22
  }
23
23
  export interface AgentRuntimeDeps {
24
+ /**
25
+ * The shared secret that proves a key handover came down this binding (D29, #207).
26
+ *
27
+ * ⚠️ The SAME value the agent Worker holds as `AGENT_HANDOVER_SECRET`, and it travels on exactly
28
+ * one route. It is not a substitute for the caller's bearer, which travels beside it: the bearer
29
+ * says who is asking, this says the request came through Intel at all. The runtime cannot check
30
+ * Intel's resource ACLs — it knows Gate and nothing about the tree — so without this a published
31
+ * runtime hostname would let anybody with `knowledge/write` write the key of any agent.
32
+ *
33
+ * Absent, `storeKey` refuses by name rather than dialling the binding and being refused there:
34
+ * the deployment is misconfigured, and saying so beats a 403 that reads like a permission problem.
35
+ */
36
+ handoverSecret?: string;
24
37
  /**
25
38
  * Absent where a deployment runs no agent Worker. Every call then refuses by name instead of
26
39
  * failing somewhere unreadable — a customer may deploy Intel without agents.
@@ -44,9 +57,61 @@ export interface ForwardInput {
44
57
  body?: string;
45
58
  signal?: AbortSignal;
46
59
  }
60
+ /**
61
+ * One agent's Application key, on its way from Gate into the agent's Durable Object (D29, #207).
62
+ *
63
+ * ⚠️ It exists as a parameter and never as a field of anything stored. The value is in one local
64
+ * variable from the moment Gate answers until this call returns, exactly as it was before D29 — what
65
+ * changed is where it goes: into the runtime instead of into a response the browser reads.
66
+ */
67
+ export interface StoreKeyInput {
68
+ /** The caller's own Gate bearer, passed on unchanged. Never a service key (D19). */
69
+ token: string;
70
+ agentId: string;
71
+ key: string;
72
+ }
73
+ /**
74
+ * Which agent's alarm has to be brought in line with the definition Intel just wrote (#214).
75
+ *
76
+ * ⚠️ It carries no schedules. The runtime re-reads the definition from Intel itself, with the
77
+ * agent's own token, so this is a "look again" and never a second copy of the document — a schedule
78
+ * pushed down this call would be one the agent could act on without it ever having been written.
79
+ */
80
+ export interface SyncSchedulesInput {
81
+ /** The caller's own Gate bearer, passed on unchanged. Never a service key (D19). */
82
+ token: string;
83
+ agentId: string;
84
+ }
47
85
  export interface AgentRuntimeService {
48
86
  /** The browser's way through: the runtime's answer, body and headers untouched. */
49
87
  forward(input: ForwardInput): Promise<Response>;
88
+ /**
89
+ * Hands an agent the key of the Gate Application it runs as.
90
+ *
91
+ * ⚠️ The one door here that does NOT ask `canRun`, and that is deliberate. Its two callers —
92
+ * creating an agent and rotating its key — have already decided who may act: `knowledge/create`
93
+ * with a writable parent, or `knowledge/write` with the resource ACL of the agent node. Requiring
94
+ * `agents/run` on top would mean a person who may create agents but never run one creates keyless
95
+ * agents, which is #200 arrived at from the other side. The runtime makes the same distinction at
96
+ * its own door.
97
+ *
98
+ * ⚠️ It fails loudly. A handover that quietly did not happen is an agent that exists, looks
99
+ * right, and dies at its first run — the whole failure this ticket removes.
100
+ */
101
+ storeKey(input: StoreKeyInput): Promise<void>;
102
+ /**
103
+ * Arms the agent's alarm for whatever the definition Intel just wrote schedules (#214).
104
+ *
105
+ * ⚠️ Like `storeKey` and unlike everything else here, it does NOT ask `canRun`. Its two callers —
106
+ * creating an agent and saving its definition — have already decided who may act: `knowledge/
107
+ * create` with a writable parent, or `knowledge/write` with the resource ACL of the agent node.
108
+ * Requiring `agents/run` on top would mean an editor who may not drive agents saves a schedule
109
+ * that is never armed, which is the failure this exists to remove.
110
+ *
111
+ * ⚠️ It fails loudly, for the same reason `storeKey` does. A sync that quietly did not happen is
112
+ * an agent whose profile shows a schedule that will never fire.
113
+ */
114
+ syncSchedules(input: SyncSchedulesInput): Promise<void>;
50
115
  /** The same door for a caller that wants the answer, not the response — Intel MCP uses this. */
51
116
  request(input: ForwardInput): Promise<unknown>;
52
117
  /**
@@ -915,6 +915,19 @@ export function createBundle(deps) {
915
915
  }
916
916
  const remapped = {
917
917
  ...definition,
918
+ /**
919
+ * ⚠️ A delegation NEVER travels in a file (#208, D30). `tools.delegatedBy` names
920
+ * whose portal connection an agent acts on, and the only thing that may write it is
921
+ * a person saving the agent in a session Intel authorized — the save path proves
922
+ * they reach every named server first. A bundle is a document somebody hands over;
923
+ * carrying the field through would let a crafted file name a local user and hand an
924
+ * imported agent that person's connection, with no act of theirs anywhere.
925
+ *
926
+ * The consequence is deliberate and mild: an imported agent arrives with no tools,
927
+ * and whoever wants it to have some gives them in the profile — which is the same
928
+ * act D30 made the only way in.
929
+ */
930
+ tools: null,
918
931
  references: definition.references.map((reference) => idMap.has(reference.nodeId)
919
932
  ? { ...reference, nodeId: idMap.get(reference.nodeId) }
920
933
  : reference),
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);