@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/README.md CHANGED
@@ -112,17 +112,36 @@ caller without it is refused with `agent_application_forbidden`, and a Gate that
112
112
  with `agent_application_unavailable` — in which case **no agent node is created at all**. There is
113
113
  deliberately no half-created agent to repair later; the request is simply repeated.
114
114
 
115
- The response to `POST /api/v1/nodes/agents` (and to the `agent_create` MCP tool) is the only place
116
- in Intel where a credential appears:
117
-
118
- 1. It carries `applicationKey.key` **once**, in plain text. Gate issues an Application key a single
119
- time and stores only its hash, and Intel writes it to no table, no R2 object, no audit event and
120
- no log. Nothing can hand it out a second time — `application_rotate_key` in Gate issues a new one.
121
- 2. Put it into the agent runtime's `AGENT_APPLICATION_KEYS` secret, keyed by the **agent node ID**
122
- that comes back as `applicationKey.agentId`, and store it nowhere else. That secret carries the
123
- whole list, so `wrangler secret put` is given every entry, not only the new one.
124
- 3. Grant the application what this agent may reach — its Gate roles, and the resource grants on the
125
- Intel nodes it works with. That step is what makes the agent's reach its own and stays manual.
115
+ **No response in Intel carries a credential.** The Application key Gate issues once, in plain text,
116
+ is handed to the agent runtime over the `AGENT` service binding inside the same request, and the
117
+ runtime encrypts it into that agent's Durable Object. Intel writes it to no table, no R2 object, no
118
+ audit event, no log and no response body; the browser never sees it, and neither does an MCP client.
119
+ The agent can run the moment it exists there is no secret to set and no terminal to open.
120
+
121
+ The handover carries the caller's own bearer plus `AGENT_HANDOVER_SECRET`, the one value Intel and
122
+ the agent runtime share. The runtime cannot check Intel's resource ACLs, so that secret is what says
123
+ the key came through Intel rather than off the open internet; without it on both Workers, creating an
124
+ agent refuses by name.
125
+
126
+ The handover happens **before** the node row, for the same reason the Gate call does: a runtime that
127
+ will not take the key leaves no agent behind, and the freshly minted Application is switched off
128
+ again. There is deliberately no half-created agent to repair later; the request is simply repeated.
129
+
130
+ What still stays manual is the reach: grant the application its Gate roles and the resource grants
131
+ on the Intel nodes it works with. That is what makes the agent's reach its own.
132
+
133
+ `POST /api/v1/nodes/agents/<id>/rotate-key` (and the `agent_rotate_key` MCP tool) replaces the key:
134
+ Intel asks Gate for a new one and hands it to the runtime the same way, the previous key stops
135
+ working at once, and the answer names the principal and the moment rather than the key. It needs
136
+ `knowledge:write` in Intel plus write access to the agent node, and the applications permission in
137
+ Gate. It is the repair for a run that fails with `agent_key_missing`.
138
+
139
+ **A written schedule is an armed one.** The agent runtime sets its alarm only when something tells
140
+ it to look, so Intel calls `POST /agents/<id>/schedules/sync` over the same service binding after
141
+ every definition write — creating an agent with schedules, and every save. That call goes down the
142
+ handover route with the caller's own bearer beside `AGENT_HANDOVER_SECRET`, because arming an agent
143
+ belongs to editing one and not to driving one. A save whose arming fails keeps the definition it
144
+ wrote and answers `agent_schedules_not_armed`: the version is current, and saving again arms it.
126
145
 
127
146
  Intel keeps `applicationId` beside the node and returns it on every agent read. It names the
128
147
  principal without authenticating it, which is why it may be stored and shown while the key may not.
@@ -25,6 +25,7 @@ import { createPortalTokenStore } from "../portal-tokens/portal-tokens.js";
25
25
  import { createRemoteTools } from "../remote-tools/remote-tools.js";
26
26
  import { createSemanticIndex } from "../semantic-index/semantic-index.js";
27
27
  import { createSessionCodec } from "../session-cookie/session-cookie.js";
28
+ import { createToolDelegation } from "../tool-delegation/tool-delegation.js";
28
29
  import { createToolSourcePolicy } from "../tool-source-policy/tool-source-policy.js";
29
30
  // biome-ignore lint/performance/noBarrelFile: The package entry must expose Wrangler's named Workflow class.
30
31
  export { IntelFlowWorkflow } from "./cloudflare-flow-workflow.js";
@@ -65,31 +66,6 @@ export default {
65
66
  const flowRepository = createFlowRepository({ db: env.DB, now });
66
67
  const nodeRepository = createNodeRepository({ db: env.DB, now });
67
68
  const contentStore = createContentStore(env.CONTENT);
68
- const nodes = createNodes({
69
- repository: nodeRepository,
70
- content: contentStore,
71
- // ⚠️ Built from `GATE_URL` alone — no service key is handed to it, and none would help. The
72
- // Applications surface is admin-gated on a real principal, so every call carries the bearer
73
- // of the person making it, which the tree passes in per operation (#182).
74
- applications: createGateApplications({
75
- fetch: globalThis.fetch.bind(globalThis),
76
- gateUrl: env.GATE_URL,
77
- }),
78
- // The binding's presence IS the signal (#190) — no separate configuration option exists.
79
- agentRuntimeAvailable: () => env.AGENT !== undefined,
80
- id: ulid,
81
- now,
82
- indexing: createIndexQueue(env.INDEXING),
83
- semantic,
84
- externalFlowCallers: async (who, folderId) => await flowRepository.externalCallers(who, folderId),
85
- flowNodeReferences: async (who, folderId) => await flowRepository.nodeReferences(who, folderId),
86
- hash: async (content) => {
87
- const source = typeof content === "string"
88
- ? new TextEncoder().encode(content)
89
- : Uint8Array.from(content).buffer;
90
- return await sha256Hex(crypto, source);
91
- },
92
- });
93
69
  const gate = createGateClient({ url: env.GATE_URL, serviceKey: env.GATE_SERVICE_KEY });
94
70
  const oauth = createOpenId({
95
71
  fetch: globalThis.fetch.bind(globalThis),
@@ -103,21 +79,22 @@ export default {
103
79
  crypto,
104
80
  now,
105
81
  });
106
- const auth = createBrowserAuth({
107
- baseUrl: env.INTEL_URL,
108
- gateUrl: env.GATE_URL,
109
- resource: intelResource,
110
- oauth,
111
- clients: createOAuthClientStore(env.DB),
112
- sessions: createSessionCodec({ secret: env.INTEL_SESSION_SECRET, crypto }),
113
- portalUrl,
114
- portalTokens,
82
+ // Built before the tree since D30: saving an agent has to ask the portal, live, whether the
83
+ // person saving reaches every server they are delegating. The dependency runs one way — the
84
+ // tool service knows nothing about the tree, and the delegation lookup below reads D1 directly
85
+ // rather than through the node service.
86
+ const toolDelegation = createToolDelegation({
87
+ db: env.DB,
88
+ content: contentStore,
89
+ id: ulid,
115
90
  now,
116
91
  });
117
92
  const tools = createTools({
118
93
  portalUrl,
119
94
  remote: createRemoteTools({ fetch: globalThis.fetch.bind(globalThis) }),
120
95
  tokens: portalTokens,
96
+ delegation: toolDelegation.resolve,
97
+ audit: toolDelegation.audit,
121
98
  refresh: async (token) => {
122
99
  try {
123
100
  const refreshed = await oauth.refresh({
@@ -146,6 +123,65 @@ export default {
146
123
  return await sha256Hex(crypto, JSON.stringify(value));
147
124
  },
148
125
  });
126
+ // ⚠️ Built before `nodes` and not after, because creating an agent has to be able to hand the
127
+ // runtime its key inside the same call (D29). Its `visibleNode` closes over `nodes` and is only
128
+ // ever called later, which is what keeps the two from being a cycle.
129
+ const agents = createAgentRuntimeService({
130
+ ...(env.AGENT ? { runtime: env.AGENT } : {}),
131
+ ...(env.AGENT_HANDOVER_SECRET ? { handoverSecret: env.AGENT_HANDOVER_SECRET } : {}),
132
+ // The tree's own visibility lookup, unchanged: whether somebody may reach an agent is the
133
+ // same question as whether they may see the node, and no second answer is invented here.
134
+ visibleNode: async (actor, nodeId) => await nodes.visibleNode(actor, nodeId),
135
+ });
136
+ const nodes = createNodes({
137
+ repository: nodeRepository,
138
+ content: contentStore,
139
+ // ⚠️ Built from `GATE_URL` alone — no service key is handed to it, and none would help. The
140
+ // Applications surface is admin-gated on a real principal, so every call carries the bearer
141
+ // of the person making it, which the tree passes in per operation (#182).
142
+ applications: createGateApplications({
143
+ fetch: globalThis.fetch.bind(globalThis),
144
+ gateUrl: env.GATE_URL,
145
+ }),
146
+ // The binding's presence IS the signal (#190) — no separate configuration option exists.
147
+ agentRuntimeAvailable: () => env.AGENT !== undefined,
148
+ // Where the Application key goes, and the only place it goes (D29). The same service the
149
+ // browser paths are proxied through, so there is one door to the runtime and not two.
150
+ agentKeys: { store: async (input) => await agents.storeKey(input) },
151
+ // The same door again (#214): a definition that was written is a schedule that has to be
152
+ // armed, and the runtime only looks when it is told to.
153
+ agentSchedules: { sync: async (input) => await agents.syncSchedules(input) },
154
+ id: ulid,
155
+ now,
156
+ indexing: createIndexQueue(env.INDEXING),
157
+ semantic,
158
+ externalFlowCallers: async (who, folderId) => await flowRepository.externalCallers(who, folderId),
159
+ flowNodeReferences: async (who, folderId) => await flowRepository.nodeReferences(who, folderId),
160
+ // ⚠️ `canExecute: true` is not a permission being granted here — the servers list touches
161
+ // nothing and executes nothing. It exists because `ToolActor` carries the flag for the one
162
+ // method that needs it, and a `false` would read as if listing were gated on execution.
163
+ toolServers: async (who) => {
164
+ const catalog = await tools.servers({ id: who.id, email: who.email, canExecute: true });
165
+ return catalog.items.map((server) => server.handle);
166
+ },
167
+ hash: async (content) => {
168
+ const source = typeof content === "string"
169
+ ? new TextEncoder().encode(content)
170
+ : Uint8Array.from(content).buffer;
171
+ return await sha256Hex(crypto, source);
172
+ },
173
+ });
174
+ const auth = createBrowserAuth({
175
+ baseUrl: env.INTEL_URL,
176
+ gateUrl: env.GATE_URL,
177
+ resource: intelResource,
178
+ oauth,
179
+ clients: createOAuthClientStore(env.DB),
180
+ sessions: createSessionCodec({ secret: env.INTEL_SESSION_SECRET, crypto }),
181
+ portalUrl,
182
+ portalTokens,
183
+ now,
184
+ });
149
185
  const flows = createFlows({
150
186
  repository: flowRepository,
151
187
  runtime: createFlowRuntime(env.FLOWS),
@@ -186,12 +222,7 @@ export default {
186
222
  },
187
223
  indexing: createIndexQueue(env.INDEXING),
188
224
  }),
189
- agents: createAgentRuntimeService({
190
- ...(env.AGENT ? { runtime: env.AGENT } : {}),
191
- // The tree's own visibility lookup, unchanged: whether somebody may reach an agent is the
192
- // same question as whether they may see the node, and no second answer is invented here.
193
- visibleNode: async (actor, nodeId) => await nodes.visibleNode(actor, nodeId),
194
- }),
225
+ agents,
195
226
  auth,
196
227
  }).fetch(request);
197
228
  },
@@ -30,6 +30,15 @@ export interface CloudflareEnv {
30
30
  GATE_SERVICE_KEY: string;
31
31
  INTEL_URL: string;
32
32
  INTEL_SESSION_SECRET: string;
33
+ /**
34
+ * The shared secret that proves a key handover reached the agent runtime through this Worker
35
+ * (D29, #207). The SAME value the agent Worker holds as `AGENT_HANDOVER_SECRET`.
36
+ *
37
+ * ⚠️ Optional exactly like `AGENT`: a deployment may run Intel without agents, and a hard
38
+ * requirement would make the whole installation fail to start over a feature nobody uses. Where
39
+ * it is absent, creating an agent refuses by name instead of leaving one without a key.
40
+ */
41
+ AGENT_HANDOVER_SECRET?: string;
33
42
  TOOL_SOURCE_ORIGINS: string;
34
43
  MCP_PORTAL_URL?: string;
35
44
  ALLOW_INSECURE_OAUTH?: string;
@@ -928,6 +928,17 @@ export function createNodeRepository(deps) {
928
928
  .bind(input.auditId, input.actorId, input.sourceNodeId, JSON.stringify({ targetNodeIds: input.links.map((link) => link.targetNodeId) }), input.occurredAt),
929
929
  ]);
930
930
  },
931
+ async recordAgentKeyRotation(input) {
932
+ // ⚠️ No node row moves, so there is nothing to batch this with — the event IS the write. The
933
+ // key is not among the columns and must not be added to them: audit is metadata, and a row
934
+ // that carried the credential would outlive every other copy of it.
935
+ await deps.db
936
+ .prepare(`INSERT INTO audit_events (
937
+ id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
938
+ ) VALUES (?, ?, 'agent.rotate_key', 'node', ?, ?, ?)`)
939
+ .bind(input.auditId, input.actorId, input.nodeId, JSON.stringify({ applicationId: input.applicationId }), input.occurredAt)
940
+ .run();
941
+ },
931
942
  async graphVisible(actor, input) {
932
943
  const nodeResult = await deps.db
933
944
  .prepare(`${visibleCte}
@@ -4,6 +4,8 @@ import { IntelError } from "../../shared/intel-error/intel-error.js";
4
4
  // field Gate adds tomorrow must not stop an installation from creating an agent. Only what Intel
5
5
  // actually reads is named.
6
6
  const CreatedApplication = z.object({ id: z.string().min(1), key: z.string().min(1) });
7
+ // The same tolerance, for the same reason. Only the key is read: the ID is the one Intel asked with.
8
+ const RotatedApplication = z.object({ key: z.string().min(1) });
7
9
  const DefaultTimeoutMs = 10_000;
8
10
  export function createGateApplications(deps) {
9
11
  const base = deps.gateUrl.replace(/\/+$/, "");
@@ -50,6 +52,26 @@ export function createGateApplications(deps) {
50
52
  }
51
53
  return { id: parsed.data.id, key: parsed.data.key };
52
54
  },
55
+ async rotateKey(input) {
56
+ // Gate issues the replacement FIRST and only then revokes what was there, so a rotation that
57
+ // fails leaves the old key working rather than locking the agent out (`applications.ts` in
58
+ // `anchrd/gate`). Intel relies on that: the handover to the runtime happens after this call,
59
+ // and until it succeeds the agent keeps running on the key it had.
60
+ const response = await call(`/${encodeURIComponent(input.applicationId)}/rotate-key`, input.token, {});
61
+ if (response.status === 404) {
62
+ throw new IntelError(502, "agent_application_missing", "Gate does not know this agent's application any more");
63
+ }
64
+ if (!response.ok)
65
+ throw refusal(response.status);
66
+ const parsed = RotatedApplication.safeParse(await response.json().catch(() => null));
67
+ if (!parsed.success) {
68
+ // ⚠️ A 2xx Intel cannot read means the OLD key is already revoked in Gate and the new one is
69
+ // lost. The agent is broken either way, so the caller is told the rotation failed and rotates
70
+ // again — which is safe, because rotating twice is just another new key.
71
+ throw new IntelError(502, "agent_application_unavailable", "Gate answered the key rotation in a shape Intel cannot read");
72
+ }
73
+ return { key: parsed.data.key };
74
+ },
53
75
  async setEnabled(input) {
54
76
  // Both routes are idempotent in Gate, which is what lets a retried archive heal a run that
55
77
  // failed between the two writes instead of needing a repair path of its own.
@@ -0,0 +1,22 @@
1
+ import type { ContentStore } from "../../nodes/nodes.types.js";
2
+ import type { ToolAuditEvent, ToolDelegation } from "../../tools/tools.types.js";
3
+ import type { D1Database } from "../db/db.types.js";
4
+ /**
5
+ * The two D1 statements the delegated tool path needs (D30): who is acting, and a record that they
6
+ * did.
7
+ *
8
+ * ⚠️ Its own adapter rather than a method on the node repository, and deliberately so. The question
9
+ * is asked with a **Gate Application id** and no Intel actor at all — there is no ACL to apply,
10
+ * because the answer is not "may you read this agent" but "which agent are you". Hanging it off the
11
+ * tree's repository would put an unauthorized read next to authorized ones, which is the shape a
12
+ * later reader copies by accident.
13
+ */
14
+ export declare function createToolDelegation(deps: {
15
+ db: D1Database;
16
+ content: ContentStore;
17
+ id(): string;
18
+ now(): Date;
19
+ }): {
20
+ resolve(applicationId: string): Promise<ToolDelegation | null>;
21
+ audit(event: ToolAuditEvent): Promise<void>;
22
+ };
@@ -0,0 +1,90 @@
1
+ import { AgentDefinition } from "@anchrd/intel-contract";
2
+ import { reportUnexpectedError } from "../../shared/report-unexpected-error/report-unexpected-error.js";
3
+ /**
4
+ * The two D1 statements the delegated tool path needs (D30): who is acting, and a record that they
5
+ * did.
6
+ *
7
+ * ⚠️ Its own adapter rather than a method on the node repository, and deliberately so. The question
8
+ * is asked with a **Gate Application id** and no Intel actor at all — there is no ACL to apply,
9
+ * because the answer is not "may you read this agent" but "which agent are you". Hanging it off the
10
+ * tree's repository would put an unauthorized read next to authorized ones, which is the shape a
11
+ * later reader copies by accident.
12
+ */
13
+ export function createToolDelegation(deps) {
14
+ return {
15
+ /**
16
+ * ⚠️ `null` has exactly ONE meaning here and it is a strong claim: "this caller is not an
17
+ * agent", after which the tool service treats them as an ordinary person with a portal
18
+ * connection of their own. Every other outcome — an archived agent, an agent with no definition
19
+ * version yet, a body R2 lost, a body that will not parse, a definition with no `tools` — is an
20
+ * agent that delegates NOTHING, and is answered as an empty delegation. That is the difference
21
+ * between "act as yourself" and "act on nobody's connection", and only one of them is safe to
22
+ * fall into by accident.
23
+ *
24
+ * The joins are therefore LEFT: a row in `agent_applications` is already the whole answer to
25
+ * "is this an agent", and losing it to a missing version would turn a fresh agent into a user.
26
+ */
27
+ async resolve(applicationId) {
28
+ const row = await deps.db
29
+ .prepare(`SELECT a.node_id AS node_id, n.archived_at AS archived_at,
30
+ v.content_key AS content_key
31
+ FROM agent_applications a
32
+ LEFT JOIN nodes n ON n.id = a.node_id
33
+ LEFT JOIN node_versions v ON v.id = n.current_version_id
34
+ WHERE a.application_id = ?`)
35
+ .bind(applicationId)
36
+ .first();
37
+ if (!row)
38
+ return null;
39
+ const nothing = { agentId: row.node_id, delegatedBy: "", servers: [] };
40
+ // Archiving an agent switches its Gate Application off, but Gate's own state is not what
41
+ // Intel is allowed to depend on: an archived agent reaches nothing through Intel from the
42
+ // moment it is archived, whatever another system still thinks.
43
+ if (row.archived_at !== null || row.content_key === null)
44
+ return nothing;
45
+ const body = await deps.content.get(row.content_key);
46
+ if (body === null) {
47
+ reportUnexpectedError(new Error(`agent ${row.node_id} has no readable definition body`));
48
+ return nothing;
49
+ }
50
+ let parsed;
51
+ try {
52
+ parsed = JSON.parse(body);
53
+ }
54
+ catch {
55
+ parsed = null;
56
+ }
57
+ const definition = AgentDefinition.safeParse(parsed);
58
+ if (!definition.success) {
59
+ reportUnexpectedError(new Error(`agent ${row.node_id} has a definition Intel cannot parse`));
60
+ return nothing;
61
+ }
62
+ const tools = definition.data.tools;
63
+ if (!tools)
64
+ return nothing;
65
+ return {
66
+ agentId: row.node_id,
67
+ delegatedBy: tools.delegatedBy,
68
+ servers: tools.servers,
69
+ };
70
+ },
71
+ async audit(event) {
72
+ // ⚠️ The agent's node is the resource and the Application is the actor, which is the only
73
+ // pairing that reads correctly later: `actor_id` is what authenticated, `resource_id` is what
74
+ // a person searches for. The tool and the server are metadata, and the ARGUMENTS are absent —
75
+ // an append-only table is the last place a payload should be copied to.
76
+ await deps.db
77
+ .prepare(`INSERT INTO audit_events (
78
+ id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
79
+ ) VALUES (?, ?, 'tool.execute', 'agent', ?, ?, ?)`)
80
+ .bind(deps.id(), event.applicationId, event.agentId, JSON.stringify({
81
+ tool: event.tool,
82
+ server: event.server,
83
+ // Both principals, which is the whole point of the row: who acted, and whose portal
84
+ // connection made it possible (D30).
85
+ delegatedBy: event.delegatedBy,
86
+ }), deps.now().toISOString())
87
+ .run();
88
+ },
89
+ };
90
+ }
@@ -50,8 +50,82 @@ export function createAgentRuntimeService(deps) {
50
50
  ...(input.signal ? { signal: input.signal } : {}),
51
51
  }));
52
52
  }
53
+ /**
54
+ * The two routes Intel dials itself, rather than on a browser's behalf: `/key` and
55
+ * `/schedules/sync`.
56
+ *
57
+ * ⚠️ Neither goes through `forward`, and that is deliberate. `forward` asks `canRun` — the
58
+ * question for somebody driving an agent — while both of these belong to *maintaining* one, which
59
+ * Intel has already authorized with `knowledge/create` or `knowledge/write` plus the resource ACL.
60
+ * What travels instead is the caller's own bearer beside the handover secret: the bearer says who
61
+ * is behind the call, the secret says it came down Intel's binding at all. The runtime cannot
62
+ * check Intel's resource ACL — it knows Gate and nothing about the tree — so without the secret a
63
+ * published runtime hostname would open both routes to anybody holding `knowledge/write` (D29).
64
+ */
65
+ async function handover(call) {
66
+ if (!deps.runtime) {
67
+ throw new IntelError(503, "agent_runtime_not_configured", "This installation has no agent runtime");
68
+ }
69
+ const handoverSecret = deps.handoverSecret?.trim();
70
+ if (!handoverSecret) {
71
+ // ⚠️ Refused here rather than dialled and refused there. The runtime would answer 403, and
72
+ // a 403 reads like "this person may not" — which would send an operator looking at Gate
73
+ // permissions for a secret that was never set.
74
+ throw new IntelError(503, call.unconfigured.code, call.unconfigured.detail);
75
+ }
76
+ return await deps.runtime.fetch(new Request(`${RuntimeOrigin}/agents/${encodeURIComponent(call.agentId)}${call.path}`, {
77
+ method: "POST",
78
+ headers: {
79
+ authorization: `Bearer ${call.token}`,
80
+ "content-type": "application/json",
81
+ // ⚠️ Its own header, never folded into `authorization`: that one already carries the
82
+ // caller's Gate bearer on this very request, and two credentials in one header is how
83
+ // one of them ends up read as the other.
84
+ "x-intel-agent-handover": handoverSecret,
85
+ },
86
+ body: call.body,
87
+ }));
88
+ }
53
89
  return {
54
90
  forward,
91
+ async syncSchedules(input) {
92
+ const response = await handover({
93
+ agentId: input.agentId,
94
+ path: "/schedules/sync",
95
+ token: input.token,
96
+ // No schedules travel: the runtime re-reads the definition from Intel itself (#214).
97
+ body: "{}",
98
+ unconfigured: {
99
+ code: "agent_schedule_sync_not_configured",
100
+ detail: "This installation has no shared secret for reaching the agent runtime, so schedules cannot be armed",
101
+ },
102
+ });
103
+ if (response.ok)
104
+ return;
105
+ // ⚠️ Loud, and named after the state it leaves behind rather than after the call that failed.
106
+ // What the caller has to act on is not "the runtime answered 502" but "this agent's schedules
107
+ // are not armed", and the sentence says the repair: write the definition again.
108
+ throw new IntelError(502, "agent_schedules_not_armed", `The definition was saved, but the agent runtime did not arm its schedules (${response.status}). Saving the definition again arms them.`);
109
+ },
110
+ async storeKey(input) {
111
+ const response = await handover({
112
+ agentId: input.agentId,
113
+ path: "/key",
114
+ token: input.token,
115
+ body: JSON.stringify({ key: input.key }),
116
+ unconfigured: {
117
+ code: "agent_key_handover_not_configured",
118
+ detail: "This installation has no shared secret for handing an agent its application key",
119
+ },
120
+ });
121
+ if (response.ok)
122
+ return;
123
+ // ⚠️ The runtime's answer is read for its status and thrown away. Its body cannot be quoted
124
+ // here the way `request` quotes it: the call that produced it carried a credential, and an
125
+ // echoed body is the shortest path from a refusal to a key in a log. What the caller is told
126
+ // is that the agent has no usable key — which is the only thing they can act on anyway.
127
+ throw new IntelError(502, "agent_key_not_stored", `The agent runtime did not take this agent's application key (${response.status}), so the agent cannot run yet`);
128
+ },
55
129
  // A plain fact, not a probe: the binding is either configured or it is not, and nothing is
56
130
  // dialled to answer. Unlike `reach` this stands before any per-agent check — it names no agent.
57
131
  available: () => deps.runtime !== undefined,
@@ -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),