@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.
- package/README.md +30 -11
- package/dist/adapters/cloudflare/cloudflare.js +71 -40
- package/dist/adapters/cloudflare/cloudflare.types.d.ts +9 -0
- package/dist/adapters/db/db.js +11 -0
- package/dist/adapters/gate-applications/gate-applications.js +22 -0
- package/dist/adapters/json-schema/json-schema.js +69 -23
- package/dist/adapters/tool-delegation/tool-delegation.d.ts +22 -0
- package/dist/adapters/tool-delegation/tool-delegation.js +90 -0
- package/dist/agent-runtime/agent-runtime.js +74 -0
- package/dist/agent-runtime/agent-runtime.types.d.ts +65 -0
- package/dist/bundle/bundle.js +13 -0
- package/dist/http/http.js +32 -8
- package/dist/mcp/mcp.js +48 -12
- package/dist/nodes/nodes.js +152 -23
- package/dist/nodes/nodes.types.d.ts +94 -6
- package/dist/tools/tool-servers/tool-servers.d.ts +46 -0
- package/dist/tools/tool-servers/tool-servers.js +114 -0
- package/dist/tools/tools.js +190 -31
- package/dist/tools/tools.types.d.ts +23 -1
- package/migrations/0015_tools_delegated_from_a_connection.sql +15 -0
- package/package.json +3 -3
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
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;
|
package/dist/adapters/db/db.js
CHANGED
|
@@ -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.
|
|
@@ -1,31 +1,77 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
//
|
|
4
|
-
|
|
1
|
+
import { Validator } from "@cfworker/json-schema";
|
|
2
|
+
import { reportUnexpectedError } from "../../shared/report-unexpected-error/report-unexpected-error.js";
|
|
3
|
+
// ⚠️ This validator must not generate code, and Ajv must never come back. Ajv turns a schema into
|
|
4
|
+
// JavaScript with `new Function`, and workerd answers that with `EvalError: Code generation from
|
|
5
|
+
// strings disallowed for this context` — so `compile` threw for EVERY schema in production, the
|
|
6
|
+
// catch called it a broken schema, and each tool call died with 400 "The cached tool schema is
|
|
7
|
+
// invalid" while nothing was wrong with any schema (#229). Ajv is usable only with schemas
|
|
8
|
+
// precompiled at build time; these arrive from a remote catalog and are unknown until the call.
|
|
9
|
+
// `@cfworker/json-schema` interprets the schema instead, which is what it was written for.
|
|
10
|
+
//
|
|
11
|
+
// ⚠️ The `workerd` test pool does not prove this on its own: it replaces `globalThis.Function` with
|
|
12
|
+
// a proxy onto an unsafe-eval binding, so Ajv compiled happily in every test while failing in every
|
|
13
|
+
// deployment. `json-schema.int.ts` therefore takes that patch away for the length of the test — it
|
|
14
|
+
// is the only thing that turns red when a code-generating validator returns.
|
|
15
|
+
// Tool schemas come from remote catalogs and are therefore untrusted: `ShortCircuit` stops at the
|
|
16
|
+
// first error so a crafted schema cannot amplify the work of one call, and the prepared validators
|
|
17
|
+
// are bounded because each one holds its dereferenced schema for the life of the isolate.
|
|
18
|
+
const MaxPreparedSchemas = 200;
|
|
19
|
+
const ShortCircuit = true;
|
|
20
|
+
const MaxDetailLength = 2_000;
|
|
21
|
+
// A tool schema that names its draft gets that draft: draft-07 ignores keywords sitting beside a
|
|
22
|
+
// `$ref` and the later drafts apply them, so guessing here would validate a foreign catalog against
|
|
23
|
+
// rules its author never wrote. Unnamed means 2020-12, the draft the MCP generators emit when they
|
|
24
|
+
// stay silent.
|
|
25
|
+
const DefaultDraft = "2020-12";
|
|
26
|
+
const DraftsByMetaSchema = [
|
|
27
|
+
["draft-04", "4"],
|
|
28
|
+
["draft-07", "7"],
|
|
29
|
+
["2019-09", "2019-09"],
|
|
30
|
+
["2020-12", "2020-12"],
|
|
31
|
+
];
|
|
32
|
+
function draftOf(schema) {
|
|
33
|
+
const declared = schema.$schema;
|
|
34
|
+
if (typeof declared !== "string")
|
|
35
|
+
return DefaultDraft;
|
|
36
|
+
return DraftsByMetaSchema.find(([marker]) => declared.includes(marker))?.[1] ?? DefaultDraft;
|
|
37
|
+
}
|
|
5
38
|
export function createJsonSchemaValidator() {
|
|
6
|
-
const
|
|
7
|
-
const compiled = new Map();
|
|
39
|
+
const prepared = new Map();
|
|
8
40
|
return (schema, value) => {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
41
|
+
const key = JSON.stringify(schema);
|
|
42
|
+
let validator = prepared.get(key);
|
|
43
|
+
if (!validator) {
|
|
44
|
+
try {
|
|
45
|
+
validator = new Validator(schema, draftOf(schema), ShortCircuit);
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
// A schema this validator cannot even read is still refused by name — but the caller hears
|
|
49
|
+
// that it was the schema, not their arguments, and the reason itself goes to the log. The
|
|
50
|
+
// old wording claimed a broken schema for every failure, which is what hid #229 for weeks.
|
|
51
|
+
reportUnexpectedError(error);
|
|
52
|
+
return { valid: false, detail: "The tool schema could not be read; the reason was logged" };
|
|
19
53
|
}
|
|
20
|
-
if (
|
|
54
|
+
if (prepared.size >= MaxPreparedSchemas)
|
|
55
|
+
prepared.clear();
|
|
56
|
+
prepared.set(key, validator);
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
const result = validator.validate(value);
|
|
60
|
+
if (result.valid)
|
|
21
61
|
return { valid: true };
|
|
22
|
-
return {
|
|
23
|
-
valid: false,
|
|
24
|
-
detail: ajv.errorsText(validate.errors, { separator: "; " }).slice(0, 2_000),
|
|
25
|
-
};
|
|
62
|
+
return { valid: false, detail: describe(result) };
|
|
26
63
|
}
|
|
27
|
-
catch {
|
|
28
|
-
|
|
64
|
+
catch (error) {
|
|
65
|
+
// An unresolvable `$ref` only shows up while walking the instance, so this is the same
|
|
66
|
+
// unreadable schema arriving one step later — and never a statement about the arguments.
|
|
67
|
+
reportUnexpectedError(error);
|
|
68
|
+
return { valid: false, detail: "The tool schema could not be read; the reason was logged" };
|
|
29
69
|
}
|
|
30
70
|
};
|
|
31
71
|
}
|
|
72
|
+
function describe(result) {
|
|
73
|
+
return result.errors
|
|
74
|
+
.map((unit) => `${unit.instanceLocation}: ${unit.error}`)
|
|
75
|
+
.join("; ")
|
|
76
|
+
.slice(0, MaxDetailLength);
|
|
77
|
+
}
|
|
@@ -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,
|