blun-king-cli 9.1.563 → 9.1.565

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/agent-spine-plugin/.claude-plugin/marketplace.json +20 -20
  2. package/agent-spine-plugin/.claude-plugin/plugin.json +14 -14
  3. package/agent-spine-plugin/.codex-plugin/plugin.json +34 -34
  4. package/agent-spine-plugin/.mcp.json +8 -8
  5. package/agent-spine-plugin/CONTRIBUTING.md +52 -0
  6. package/agent-spine-plugin/LICENSE +186 -186
  7. package/agent-spine-plugin/README.md +394 -0
  8. package/agent-spine-plugin/SECURITY.md +47 -0
  9. package/agent-spine-plugin/assets/agentspine-banner.svg +32 -32
  10. package/agent-spine-plugin/bin/agentspine-mcp.js +4 -4
  11. package/agent-spine-plugin/bin/agentspine.js +7 -7
  12. package/agent-spine-plugin/blun.plugin.json +33 -33
  13. package/agent-spine-plugin/hooks/codex.json +47 -47
  14. package/agent-spine-plugin/hooks/hooks.json +106 -106
  15. package/agent-spine-plugin/hooks/version.json +5 -5
  16. package/agent-spine-plugin/package.json +69 -69
  17. package/agent-spine-plugin/scripts/check-hosts.js +199 -199
  18. package/agent-spine-plugin/skill/SKILL.md +76 -76
  19. package/agent-spine-plugin/skills/agent-spine/SKILL.md +85 -85
  20. package/agent-spine-plugin/src/cli.js +1488 -1442
  21. package/agent-spine-plugin/src/hook.js +886 -812
  22. package/agent-spine-plugin/src/index.js +93 -93
  23. package/agent-spine-plugin/src/lib/acceptance.js +333 -333
  24. package/agent-spine-plugin/src/lib/attention.js +755 -755
  25. package/agent-spine-plugin/src/lib/audit.js +351 -342
  26. package/agent-spine-plugin/src/lib/authentication.js +515 -515
  27. package/agent-spine-plugin/src/lib/briefing.js +317 -317
  28. package/agent-spine-plugin/src/lib/catalog.js +167 -167
  29. package/agent-spine-plugin/src/lib/channel-runtime.js +665 -665
  30. package/agent-spine-plugin/src/lib/context.js +154 -154
  31. package/agent-spine-plugin/src/lib/continuity.js +338 -338
  32. package/agent-spine-plugin/src/lib/coordination.js +577 -577
  33. package/agent-spine-plugin/src/lib/documents.js +217 -217
  34. package/agent-spine-plugin/src/lib/feed-transport.js +499 -499
  35. package/agent-spine-plugin/src/lib/filesystem-retry.js +32 -32
  36. package/agent-spine-plugin/src/lib/gateway-runtime.js +1119 -760
  37. package/agent-spine-plugin/src/lib/graph.js +337 -337
  38. package/agent-spine-plugin/src/lib/hook-audit.js +29 -0
  39. package/agent-spine-plugin/src/lib/https-transport.js +392 -392
  40. package/agent-spine-plugin/src/lib/indexed-memory-offline.js +40 -40
  41. package/agent-spine-plugin/src/lib/indexed-memory.js +281 -281
  42. package/agent-spine-plugin/src/lib/learning.js +6923 -6466
  43. package/agent-spine-plugin/src/lib/object-transport.js +206 -206
  44. package/agent-spine-plugin/src/lib/owned-file-lock.js +143 -143
  45. package/agent-spine-plugin/src/lib/paths.js +109 -109
  46. package/agent-spine-plugin/src/lib/peer-transport.js +283 -283
  47. package/agent-spine-plugin/src/lib/persona-runtime.js +581 -581
  48. package/agent-spine-plugin/src/lib/preflight.js +702 -702
  49. package/agent-spine-plugin/src/lib/runtime.js +13 -13
  50. package/agent-spine-plugin/src/lib/selfstarter.js +892 -819
  51. package/agent-spine-plugin/src/lib/sharing.js +969 -969
  52. package/agent-spine-plugin/src/lib/source-roots.js +529 -482
  53. package/agent-spine-plugin/src/lib/sqlite-transport.js +501 -501
  54. package/agent-spine-plugin/src/lib/telegram-adapter.js +119 -119
  55. package/agent-spine-plugin/src/lib/voice-runtime.js +39 -39
  56. package/agent-spine-plugin/src/mcp.js +597 -572
  57. package/agent-spine-plugin/src/version.js +1 -1
  58. package/agent-spine-plugin/src/worker.js +202 -195
  59. package/bin/active-steer-priority-policy.cjs +24 -0
  60. package/bin/launcher-runtime.js +8 -1
  61. package/bin/mnemo-tool-agent-policy.cjs +22 -0
  62. package/bin/thinking-activity-status-policy.cjs +132 -0
  63. package/bin/thinking-only-guard.cjs +75 -0
  64. package/bin/tool-call-loop-policy.cjs +53 -0
  65. package/bin/turn-thinking-policy.cjs +25 -1
  66. package/blun.mjs +554 -64
  67. package/package.json +4 -1
  68. package/standard-skills/translate-native/LICENSE +21 -21
  69. package/standard-skills/translate-native/references/evaluation-protocol.md +95 -95
  70. package/standard-skills/translate-native/references/native-orthography.md +79 -79
  71. package/standard-skills/translate-native/references/native-translation-standard.md +94 -94
  72. package/standard-skills/translate-native/references/structured-content.md +72 -72
  73. package/standard-skills/translate-native/references/translationese-review.md +77 -77
  74. package/standard-skills/translate-native/scripts/blun_language_guard.py +697 -697
  75. package/standard-skills/translate-native/scripts/check_diacritics.py +353 -353
  76. package/standard-skills/translate-native/scripts/guard_service_client.py +264 -264
  77. package/standard-skills/translate-native/scripts/language_gateway.py +62 -62
  78. package/standard-skills/translate-native/scripts/language_quality.py +377 -377
  79. package/standard-skills/translate-native/scripts/pre_output_guard.py +64 -64
  80. package/standard-skills/translate-native/scripts/translation_guard.py +916 -916
  81. package/standard-tools/language-guard/blun_language_guard.py +697 -697
  82. package/standard-tools/language-guard/check_diacritics.py +353 -353
  83. package/standard-tools/language-guard/guard_service_client.py +264 -264
  84. package/standard-tools/language-guard/language_quality.py +377 -377
  85. package/standard-tools/language-guard/translation_guard.py +916 -916
@@ -1,665 +1,665 @@
1
- import {
2
- createHash, createHmac, randomUUID, timingSafeEqual
3
- } from "node:crypto";
4
- import {
5
- open, readFile, rename, stat, unlink, writeFile
6
- } from "node:fs/promises";
7
- import { join } from "node:path";
8
- import { buildCatalog } from "./catalog.js";
9
- import { isFileLockContention } from "./filesystem-retry.js";
10
- import { loadGraph } from "./graph.js";
11
- import { projectStateDir } from "./paths.js";
12
-
13
- const POLICY_SCHEMA = "agentspine.channel-policy/v1";
14
- const RUNTIME_SCHEMA = "agentspine.channel-runtime/v1";
15
- const EVENT_SCHEMA = "agentspine.channel-event/v1";
16
- const CONFIRMATION = "local-owner-confirmed";
17
- const MAX_STATE_BYTES = 5 * 1024 * 1024;
18
- const MAX_TEXT_BYTES = 16 * 1024;
19
- const ID_RE = /^[A-Za-z0-9][A-Za-z0-9:_.@/+~-]{0,255}$/;
20
- const ROUTE_ID_RE = /^[A-Za-z0-9_+~-][A-Za-z0-9:_.@/+~-]{0,255}$/;
21
- const ENV_RE = /^[A-Z][A-Z0-9_]{2,127}$/;
22
- const DIGEST_RE = /^[a-f0-9]{64}$/;
23
- const SIGNATURE_RE = /^sha256=([a-f0-9]{64})$/;
24
- const PRIVACY = new Set(["private", "shared", "group"]);
25
- const CAPABILITIES = new Set(["receive", "reply"]);
26
- const BINDING_STATUSES = new Set(["active", "revoked"]);
27
- const EVENT_STATUSES = new Set(["pending", "leased", "completed", "failed", "cancelled"]);
28
- const TERMINAL_STATUSES = new Set(["completed", "cancelled"]);
29
- const INBOUND_FIELDS = new Set([
30
- "schema", "eventId", "provider", "tenantId", "accountId", "chatId", "threadId",
31
- "senderId", "replyTo", "observedAt", "privacy", "text"
32
- ]);
33
- const SECRET_RE = /-----BEGIN [A-Z ]*PRIVATE KEY-----|\b(?:sk|gh[opusu])_[A-Za-z0-9_-]{20,}\b|\bBearer\s+[A-Za-z0-9._~+/-]{20,}|\b(?:api[-_ ]?key|token|password|secret|passwort|geheimnis)\s*[:=]\s*\S{8,}/i;
34
-
35
- function emptyPolicy(root) {
36
- return { schema: POLICY_SCHEMA, root, revision: 0, bindings: [], history: [] };
37
- }
38
-
39
- function emptyRuntime(root) {
40
- return { schema: RUNTIME_SCHEMA, root, events: [], history: [], receipts: [] };
41
- }
42
-
43
- function stableId(value, field, { nullable = false, route = false } = {}) {
44
- if ((value === null || value === undefined || value === "") && nullable) return null;
45
- const expression = route ? ROUTE_ID_RE : ID_RE;
46
- if (typeof value !== "string" || !expression.test(value) || value.includes("*")) {
47
- throw new Error(`${field} must be an exact stable ID without wildcards`);
48
- }
49
- return value;
50
- }
51
-
52
- function timestamp(value, field = "timestamp") {
53
- const parsed = value instanceof Date ? value : new Date(value);
54
- if (!Number.isFinite(parsed.getTime())) throw new Error(`${field} must be a valid date`);
55
- return parsed.toISOString();
56
- }
57
-
58
- function safeText(value, field, maximumBytes = MAX_TEXT_BYTES) {
59
- if (typeof value !== "string" || !value.trim()) throw new Error(`${field} is required`);
60
- const normalized = value.trim().replace(/\r\n?/g, "\n");
61
- if (Buffer.byteLength(normalized) > maximumBytes) throw new Error(`${field} exceeds ${maximumBytes} bytes`);
62
- if (SECRET_RE.test(normalized)) throw new Error(`${field} appears to contain a secret and cannot enter channel state`);
63
- return normalized;
64
- }
65
-
66
- function exactCapabilities(value) {
67
- if (!Array.isArray(value) || !value.length) throw new Error("channel capabilities are required");
68
- const unique = [...new Set(value)].sort();
69
- if (unique.some((item) => !CAPABILITIES.has(item))) throw new Error("channel capabilities may contain only receive and reply");
70
- if (!unique.includes("receive")) throw new Error("channel bindings must include receive");
71
- return unique;
72
- }
73
-
74
- function exactSenderIds(value) {
75
- if (!Array.isArray(value) || !value.length) throw new Error("senderIds must contain exact authenticated sender IDs");
76
- return [...new Set(value.map((item) => stableId(item, "senderId", { route: true })))].sort();
77
- }
78
-
79
- function sha256(value) {
80
- return createHash("sha256").update(value).digest("hex");
81
- }
82
-
83
- function bindingRoute(binding) {
84
- return [binding.provider, binding.tenantId, binding.accountId, binding.chatId, binding.threadId || ""].join("\0");
85
- }
86
-
87
- function normalizeInboundEvent(value, { inspectContent = false } = {}) {
88
- if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("channel event must be one object");
89
- const unknown = Object.keys(value).filter((key) => !INBOUND_FIELDS.has(key));
90
- if (unknown.length) throw new Error(`channel event contains unknown field: ${unknown.sort()[0]}`);
91
- if (value.schema !== EVENT_SCHEMA) throw new Error(`channel event schema must be ${EVENT_SCHEMA}`);
92
- if (!PRIVACY.has(value.privacy)) throw new Error("channel event privacy must be private, shared, or group");
93
- const text = inspectContent ? safeText(value.text, "channel event text") : String(value.text ?? "").trim().replace(/\r\n?/g, "\n");
94
- if (!text || Buffer.byteLength(text) > MAX_TEXT_BYTES) throw new Error(`channel event text must be 1-${MAX_TEXT_BYTES} bytes`);
95
- return {
96
- schema: EVENT_SCHEMA,
97
- eventId: stableId(value.eventId, "eventId"),
98
- provider: stableId(value.provider, "provider", { route: true }),
99
- tenantId: stableId(value.tenantId, "tenantId", { route: true }),
100
- accountId: stableId(value.accountId, "accountId", { route: true }),
101
- chatId: stableId(value.chatId, "chatId", { route: true }),
102
- threadId: stableId(value.threadId, "threadId", { nullable: true, route: true }),
103
- senderId: stableId(value.senderId, "senderId", { route: true }),
104
- replyTo: stableId(value.replyTo, "replyTo", { nullable: true, route: true }),
105
- observedAt: timestamp(value.observedAt, "observedAt"),
106
- privacy: value.privacy,
107
- text
108
- };
109
- }
110
-
111
- export function channelEventSigningPayload(event) {
112
- return JSON.stringify(normalizeInboundEvent(event));
113
- }
114
-
115
- function validBinding(binding) {
116
- return binding && ID_RE.test(binding.id || "") && BINDING_STATUSES.has(binding.status)
117
- && ROUTE_ID_RE.test(binding.provider || "") && ROUTE_ID_RE.test(binding.tenantId || "")
118
- && ROUTE_ID_RE.test(binding.accountId || "") && ROUTE_ID_RE.test(binding.chatId || "")
119
- && (binding.threadId === null || ROUTE_ID_RE.test(binding.threadId || ""))
120
- && Array.isArray(binding.senderIds) && binding.senderIds.length > 0
121
- && binding.senderIds.every((id) => ROUTE_ID_RE.test(id) && !id.includes("*"))
122
- && ID_RE.test(binding.agentId || "") && ID_RE.test(binding.projectId || "")
123
- && (binding.groupId === null || ID_RE.test(binding.groupId || ""))
124
- && ID_RE.test(binding.sessionKey || "") && ENV_RE.test(binding.secretEnv || "")
125
- && (binding.outboundSecretEnv === null || ENV_RE.test(binding.outboundSecretEnv || ""))
126
- && Array.isArray(binding.capabilities) && binding.capabilities.includes("receive")
127
- && binding.capabilities.every((item) => CAPABILITIES.has(item))
128
- && binding.authority === "explicit-local-channel-policy"
129
- && Number.isFinite(new Date(binding.createdAt).getTime())
130
- && Number.isFinite(new Date(binding.updatedAt).getTime())
131
- && (binding.revokedAt === null || Number.isFinite(new Date(binding.revokedAt).getTime()));
132
- }
133
-
134
- function validEvent(event) {
135
- return event && event.schema === EVENT_SCHEMA && ID_RE.test(event.eventId || "")
136
- && ID_RE.test(event.bindingId || "") && ROUTE_ID_RE.test(event.provider || "")
137
- && ROUTE_ID_RE.test(event.tenantId || "") && ROUTE_ID_RE.test(event.accountId || "")
138
- && ROUTE_ID_RE.test(event.chatId || "") && (event.threadId === null || ROUTE_ID_RE.test(event.threadId || ""))
139
- && ROUTE_ID_RE.test(event.senderId || "") && (event.replyTo === null || ROUTE_ID_RE.test(event.replyTo || ""))
140
- && ID_RE.test(event.agentId || "") && ID_RE.test(event.projectId || "")
141
- && (event.groupId === null || ID_RE.test(event.groupId || "")) && ID_RE.test(event.sessionKey || "")
142
- && PRIVACY.has(event.privacy) && typeof event.text === "string" && event.text.length > 0
143
- && Buffer.byteLength(event.text) <= MAX_TEXT_BYTES && !SECRET_RE.test(event.text)
144
- && EVENT_STATUSES.has(event.status) && Number.isInteger(event.attempts) && event.attempts >= 0
145
- && event.authority === "execution-state-only" && DIGEST_RE.test(event.payloadDigest || "")
146
- && Number.isFinite(new Date(event.observedAt).getTime()) && Number.isFinite(new Date(event.receivedAt).getTime())
147
- && (event.completedAt === null || Number.isFinite(new Date(event.completedAt).getTime()))
148
- && (event.lease === null || (
149
- event.status === "leased" && ID_RE.test(event.lease.workerId || "")
150
- && Number.isFinite(new Date(event.lease.claimedAt).getTime())
151
- && Number.isFinite(new Date(event.lease.expiresAt).getTime())
152
- ));
153
- }
154
-
155
- function validReceipt(receipt) {
156
- if (!(receipt && ID_RE.test(receipt.id || "") && ID_RE.test(receipt.eventId || "")
157
- && typeof receipt.event === "string" && DIGEST_RE.test(receipt.digest || "")
158
- && Number.isFinite(new Date(receipt.at).getTime()) && receipt.authority === "execution-state-only")) return false;
159
- const { id, digest, ...material } = receipt;
160
- return id === `channel-receipt:${digest.slice(0, 24)}` && sha256(JSON.stringify(material)) === digest;
161
- }
162
-
163
- function validPolicyHistory(record) {
164
- return record && record.kind === "channel-binding" && ID_RE.test(record.recordId || "")
165
- && record.recordId === record.value?.id && validBinding(record.value)
166
- && Number.isFinite(new Date(record.supersededAt).getTime())
167
- && record.authority === "explicit-local-channel-policy";
168
- }
169
-
170
- function validRuntimeHistory(record) {
171
- return record && record.kind === "channel-event" && ID_RE.test(record.recordId || "")
172
- && record.recordId === record.value?.eventId && validEvent(record.value)
173
- && typeof record.transition === "string" && record.transition.length > 0
174
- && Number.isFinite(new Date(record.supersededAt).getTime())
175
- && record.authority === "execution-state-only";
176
- }
177
-
178
- function validPayloadDigest(event) {
179
- if (!validEvent(event)) return false;
180
- const inbound = Object.fromEntries([...INBOUND_FIELDS].map((field) => [field, event[field]]));
181
- return sha256(channelEventSigningPayload(inbound)) === event.payloadDigest;
182
- }
183
-
184
- function normalizePolicy(value, root) {
185
- if (!value || typeof value !== "object" || Array.isArray(value) || value.schema !== POLICY_SCHEMA
186
- || value.root !== root || !Number.isInteger(value.revision) || value.revision < 0
187
- || !Array.isArray(value.bindings) || !Array.isArray(value.history)) {
188
- throw new Error("channel policy structure is invalid; channel runtime is disabled until repaired");
189
- }
190
- return value;
191
- }
192
-
193
- function normalizeRuntime(value, root) {
194
- if (!value || typeof value !== "object" || Array.isArray(value) || value.schema !== RUNTIME_SCHEMA
195
- || value.root !== root || !Array.isArray(value.events) || !Array.isArray(value.history)
196
- || !Array.isArray(value.receipts)) {
197
- throw new Error("channel runtime structure is invalid; channel processing is disabled until repaired");
198
- }
199
- return value;
200
- }
201
-
202
- async function readJson(path, root, normalize, empty) {
203
- try {
204
- const metadata = await stat(path);
205
- if (metadata.size > MAX_STATE_BYTES) throw new Error("channel state exceeds the 5 MiB read limit");
206
- return normalize(JSON.parse(await readFile(path, "utf8")), root);
207
- } catch (error) {
208
- if (error.code !== "ENOENT") throw error;
209
- return empty(root);
210
- }
211
- }
212
-
213
- async function writeJson(path, value) {
214
- const content = `${JSON.stringify(value, null, 2)}\n`;
215
- if (Buffer.byteLength(content) > MAX_STATE_BYTES) throw new Error("channel state exceeds 5 MiB; purge terminal events first");
216
- const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
217
- try {
218
- await writeFile(temporary, content, { mode: 0o600 });
219
- for (let attempt = 0; ; attempt += 1) {
220
- try {
221
- await rename(temporary, path);
222
- break;
223
- } catch (error) {
224
- if (attempt >= 5 || !["EACCES", "EPERM"].includes(error.code)) throw error;
225
- await new Promise((resolve) => setTimeout(resolve, 10 * (attempt + 1)));
226
- }
227
- }
228
- } finally {
229
- await unlink(temporary).catch((error) => { if (error.code !== "ENOENT") throw error; });
230
- }
231
- }
232
-
233
- async function pathsFor(root, providedCatalog = null) {
234
- const catalog = providedCatalog || await buildCatalog(root);
235
- const directory = await projectStateDir(catalog.root);
236
- return {
237
- catalog, directory,
238
- channelPolicyPath: join(directory, "channel-policy.json"),
239
- channelRuntimePath: join(directory, "channel-runtime.json")
240
- };
241
- }
242
-
243
- async function withChannelLock(paths, task) {
244
- const lockPath = join(paths.directory, "channel-runtime.lock");
245
- let handle;
246
- for (let attempt = 0; attempt < 120; attempt += 1) {
247
- try {
248
- handle = await open(lockPath, "wx", 0o600);
249
- break;
250
- } catch (error) {
251
- if (!isFileLockContention(error)) throw error;
252
- try {
253
- const metadata = await stat(lockPath);
254
- if (Date.now() - metadata.mtimeMs > 90000) await unlink(lockPath);
255
- } catch (lockError) {
256
- if (lockError.code !== "ENOENT") throw lockError;
257
- }
258
- await new Promise((resolve) => setTimeout(resolve, 25));
259
- }
260
- }
261
- if (!handle) throw new Error("channel state is busy; retry later");
262
- try {
263
- return await task();
264
- } finally {
265
- await handle.close();
266
- await unlink(lockPath).catch((error) => { if (error.code !== "ENOENT") throw error; });
267
- }
268
- }
269
-
270
- function requireConfirmation(value) {
271
- if (value !== CONFIRMATION) throw new Error("channel policy changes require explicit local owner confirmation");
272
- }
273
-
274
- function referenceFindings(policy, runtime, graph) {
275
- const findings = [];
276
- const entities = new Map(graph.entities.map((entity) => [entity.id, entity]));
277
- const bindings = new Map();
278
- const routes = new Map();
279
- for (const binding of policy.bindings) {
280
- if (!validBinding(binding)) findings.push(`invalid-binding:${binding?.id || "unknown"}`);
281
- if (bindings.has(binding.id)) findings.push(`duplicate-binding:${binding.id}`);
282
- bindings.set(binding.id, binding);
283
- if (binding.status === "active") {
284
- const route = bindingRoute(binding);
285
- if (routes.has(route)) findings.push(`duplicate-active-route:${binding.id}`);
286
- routes.set(route, binding.id);
287
- }
288
- if (entities.get(binding.agentId)?.kind !== "agent") findings.push(`binding-agent-mismatch:${binding.id}`);
289
- if (entities.get(binding.projectId)?.kind !== "project") findings.push(`binding-project-mismatch:${binding.id}`);
290
- if (binding.groupId !== null) {
291
- if (entities.get(binding.groupId)?.kind !== "group") findings.push(`binding-group-mismatch:${binding.id}`);
292
- const member = graph.entityEdges.some((edge) => edge.relation === "member-of" && edge.privacy !== "private"
293
- && ((edge.from === binding.agentId && edge.to === binding.groupId)
294
- || (edge.to === binding.agentId && edge.from === binding.groupId)));
295
- if (!member) findings.push(`binding-group-membership-mismatch:${binding.id}`);
296
- }
297
- }
298
- for (const record of policy.history) {
299
- if (!validPolicyHistory(record)) findings.push(`invalid-binding-history:${record?.recordId || "unknown"}`);
300
- }
301
- const eventIds = new Set();
302
- for (const event of runtime.events) {
303
- if (!validEvent(event)) findings.push(`invalid-channel-event:${event?.eventId || "unknown"}`);
304
- else if (!validPayloadDigest(event)) findings.push(`channel-event-digest-mismatch:${event.eventId}`);
305
- if (eventIds.has(event.eventId)) findings.push(`duplicate-channel-event:${event.eventId}`);
306
- eventIds.add(event.eventId);
307
- const binding = bindings.get(event.bindingId);
308
- if (!binding || [event.provider, event.tenantId, event.accountId, event.chatId, event.threadId || ""].join("\0") !== bindingRoute(binding)
309
- || event.agentId !== binding?.agentId || event.projectId !== binding?.projectId
310
- || event.groupId !== binding?.groupId || event.sessionKey !== binding?.sessionKey
311
- || !binding?.senderIds.includes(event.senderId)) findings.push(`event-binding-mismatch:${event.eventId}`);
312
- }
313
- for (const record of runtime.history) {
314
- if (!validRuntimeHistory(record)) findings.push(`invalid-channel-history:${record?.recordId || "unknown"}`);
315
- else if (!validPayloadDigest(record.value)) findings.push(`channel-history-digest-mismatch:${record.recordId}`);
316
- }
317
- const receiptIds = new Set();
318
- for (const receipt of runtime.receipts) {
319
- if (!validReceipt(receipt)) findings.push(`invalid-channel-receipt:${receipt?.id || "unknown"}`);
320
- if (receiptIds.has(receipt.id)) findings.push(`duplicate-channel-receipt:${receipt.id}`);
321
- if (!eventIds.has(receipt.eventId)) findings.push(`orphan-channel-receipt:${receipt.id}`);
322
- receiptIds.add(receipt.id);
323
- }
324
- return findings;
325
- }
326
-
327
- export function channelPolicyFindings(policy, graph) {
328
- return referenceFindings(policy, emptyRuntime(policy.root), graph).filter((item) => item.includes("binding") || item.includes("route"));
329
- }
330
-
331
- export function channelRuntimeFindings(runtime, policy, graph) {
332
- return referenceFindings(policy, runtime, graph);
333
- }
334
-
335
- export async function loadChannelPolicy(root = process.cwd(), providedCatalog = null) {
336
- const paths = await pathsFor(root, providedCatalog);
337
- return { policy: await readJson(paths.channelPolicyPath, paths.catalog.root, normalizePolicy, emptyPolicy), ...paths };
338
- }
339
-
340
- export async function loadChannelRuntime(root = process.cwd(), providedCatalog = null) {
341
- const paths = await pathsFor(root, providedCatalog);
342
- return { runtime: await readJson(paths.channelRuntimePath, paths.catalog.root, normalizeRuntime, emptyRuntime), ...paths };
343
- }
344
-
345
- export async function inspectChannelRuntime(root = process.cwd(), providedCatalog = null) {
346
- const paths = await pathsFor(root, providedCatalog);
347
- let policy = emptyPolicy(paths.catalog.root);
348
- let runtime = emptyRuntime(paths.catalog.root);
349
- const errors = [];
350
- try { policy = await readJson(paths.channelPolicyPath, paths.catalog.root, normalizePolicy, emptyPolicy); } catch (error) { errors.push(`policy:${error.message}`); }
351
- try { runtime = await readJson(paths.channelRuntimePath, paths.catalog.root, normalizeRuntime, emptyRuntime); } catch (error) { errors.push(`runtime:${error.message}`); }
352
- return { policy, runtime, errors, ...paths };
353
- }
354
-
355
- function preserveBinding(policy, binding, at) {
356
- policy.history.push({
357
- kind: "channel-binding", recordId: binding.id, supersededAt: at,
358
- value: structuredClone(binding), authority: "explicit-local-channel-policy"
359
- });
360
- }
361
-
362
- function preserveEvent(runtime, event, transition, at) {
363
- runtime.history.push({
364
- kind: "channel-event", recordId: event.eventId, transition, supersededAt: at,
365
- value: structuredClone(event), authority: "execution-state-only"
366
- });
367
- }
368
-
369
- function appendReceipt(runtime, eventId, event, at, details = {}) {
370
- const material = { eventId, event, at, details, authority: "execution-state-only" };
371
- const digest = sha256(JSON.stringify(material));
372
- const id = `channel-receipt:${digest.slice(0, 24)}`;
373
- const existing = runtime.receipts.find((receipt) => receipt.id === id);
374
- if (existing) return { receipt: existing, duplicate: true };
375
- const receipt = { id, ...material, digest };
376
- runtime.receipts.push(receipt);
377
- return { receipt, duplicate: false };
378
- }
379
-
380
- function ensureHealthy(policy, runtime, graph) {
381
- const findings = referenceFindings(policy, runtime, graph);
382
- if (findings.length) throw new Error(`channel runtime failed closed: ${findings.join(", ")}`);
383
- }
384
-
385
- export async function grantChannelBinding({
386
- root = process.cwd(), id = `channel-binding:${randomUUID()}`, provider, tenantId, accountId,
387
- chatId, threadId = null, senderIds, agentId, projectId, groupId = null, sessionKey,
388
- secretEnv, outboundSecretEnv = null, capabilities = ["receive"], confirmation, now = new Date()
389
- }) {
390
- requireConfirmation(confirmation);
391
- const paths = await pathsFor(root);
392
- return withChannelLock(paths, async () => {
393
- const [policy, runtime, { graph }] = await Promise.all([
394
- readJson(paths.channelPolicyPath, paths.catalog.root, normalizePolicy, emptyPolicy),
395
- readJson(paths.channelRuntimePath, paths.catalog.root, normalizeRuntime, emptyRuntime),
396
- loadGraph(paths.catalog.root, paths.catalog)
397
- ]);
398
- ensureHealthy(policy, runtime, graph);
399
- const at = timestamp(now, "now");
400
- const binding = {
401
- id: stableId(id, "bindingId"),
402
- provider: stableId(provider, "provider", { route: true }),
403
- tenantId: stableId(tenantId, "tenantId", { route: true }),
404
- accountId: stableId(accountId, "accountId", { route: true }),
405
- chatId: stableId(chatId, "chatId", { route: true }),
406
- threadId: stableId(threadId, "threadId", { nullable: true, route: true }),
407
- senderIds: exactSenderIds(senderIds),
408
- agentId: stableId(agentId, "agentId"), projectId: stableId(projectId, "projectId"),
409
- groupId: stableId(groupId, "groupId", { nullable: true }),
410
- sessionKey: stableId(sessionKey, "sessionKey"),
411
- secretEnv: ENV_RE.test(secretEnv || "") ? secretEnv : (() => { throw new Error("secretEnv must be an uppercase environment variable name"); })(),
412
- outboundSecretEnv: outboundSecretEnv === null ? null : (ENV_RE.test(outboundSecretEnv || "")
413
- ? outboundSecretEnv : (() => { throw new Error("outboundSecretEnv must be an uppercase environment variable name"); })()),
414
- capabilities: exactCapabilities(capabilities), status: "active", createdAt: at, updatedAt: at,
415
- revokedAt: null, revokeReason: null, authority: "explicit-local-channel-policy"
416
- };
417
- const agent = graph.entities.find((entity) => entity.id === binding.agentId && entity.kind === "agent");
418
- const project = graph.entities.find((entity) => entity.id === binding.projectId && entity.kind === "project");
419
- if (binding.capabilities.includes("reply") && !binding.outboundSecretEnv) {
420
- throw new Error("reply capability requires an environment-only outbound adapter credential name");
421
- }
422
- if (!agent) throw new Error(`unknown agent entity: ${binding.agentId}`);
423
- if (!project) throw new Error(`unknown project entity: ${binding.projectId}`);
424
- if (binding.groupId !== null) {
425
- if (!graph.entities.some((entity) => entity.id === binding.groupId && entity.kind === "group")) throw new Error(`unknown group entity: ${binding.groupId}`);
426
- const member = graph.entityEdges.some((edge) => edge.relation === "member-of" && edge.privacy !== "private"
427
- && ((edge.from === binding.agentId && edge.to === binding.groupId)
428
- || (edge.to === binding.agentId && edge.from === binding.groupId)));
429
- if (!member) throw new Error("channel binding agent must be a visible member of its exact group");
430
- }
431
- const activeRoute = policy.bindings.find((item) => item.status === "active" && bindingRoute(item) === bindingRoute(binding) && item.id !== binding.id);
432
- if (activeRoute) throw new Error(`channel route is already bound by ${activeRoute.id}`);
433
- const previous = policy.bindings.find((item) => item.id === binding.id);
434
- if (previous) preserveBinding(policy, previous, at);
435
- policy.bindings = policy.bindings.filter((item) => item.id !== binding.id);
436
- policy.bindings.push(binding);
437
- policy.bindings.sort((left, right) => left.id.localeCompare(right.id));
438
- policy.revision += 1;
439
- ensureHealthy(policy, runtime, graph);
440
- await writeJson(paths.channelPolicyPath, policy);
441
- return { binding, revision: policy.revision, channelPolicyPath: paths.channelPolicyPath };
442
- });
443
- }
444
-
445
- export async function revokeChannelBinding({ root = process.cwd(), id, reason, confirmation, now = new Date() }) {
446
- requireConfirmation(confirmation);
447
- const paths = await pathsFor(root);
448
- return withChannelLock(paths, async () => {
449
- const [policy, runtime, { graph }] = await Promise.all([
450
- readJson(paths.channelPolicyPath, paths.catalog.root, normalizePolicy, emptyPolicy),
451
- readJson(paths.channelRuntimePath, paths.catalog.root, normalizeRuntime, emptyRuntime),
452
- loadGraph(paths.catalog.root, paths.catalog)
453
- ]);
454
- ensureHealthy(policy, runtime, graph);
455
- const binding = policy.bindings.find((item) => item.id === stableId(id, "bindingId"));
456
- if (!binding) throw new Error(`unknown channel binding: ${id}`);
457
- const at = timestamp(now, "now");
458
- if (binding.status === "revoked") return { binding, duplicate: true };
459
- preserveBinding(policy, binding, at);
460
- binding.status = "revoked";
461
- binding.revokedAt = at;
462
- binding.updatedAt = at;
463
- binding.revokeReason = safeText(reason, "revoke reason", 1000);
464
- for (const event of runtime.events.filter((item) => item.bindingId === binding.id && !TERMINAL_STATUSES.has(item.status))) {
465
- preserveEvent(runtime, event, "binding-revoked", at);
466
- event.status = "cancelled";
467
- event.lease = null;
468
- event.completedAt = at;
469
- appendReceipt(runtime, event.eventId, "cancelled", at, { reason: "binding-revoked" });
470
- }
471
- policy.revision += 1;
472
- ensureHealthy(policy, runtime, graph);
473
- await Promise.all([writeJson(paths.channelPolicyPath, policy), writeJson(paths.channelRuntimePath, runtime)]);
474
- return { binding, duplicate: false, revision: policy.revision };
475
- });
476
- }
477
-
478
- function verifySignature(event, signature, binding, env) {
479
- const match = String(signature || "").match(SIGNATURE_RE);
480
- if (!match) throw new Error("channel event signature is missing or invalid");
481
- const secret = env?.[binding.secretEnv];
482
- if (typeof secret !== "string" || Buffer.byteLength(secret) < 32) throw new Error(`channel adapter secret ${binding.secretEnv} is unavailable or too short`);
483
- const expected = createHmac("sha256", secret).update(channelEventSigningPayload(event)).digest();
484
- const supplied = Buffer.from(match[1], "hex");
485
- if (supplied.length !== expected.length || !timingSafeEqual(supplied, expected)) throw new Error("channel event signature verification failed");
486
- }
487
-
488
- export async function ingestChannelEvent({ root = process.cwd(), event, signature, env = process.env, now = new Date() }) {
489
- const normalized = normalizeInboundEvent(event, { inspectContent: true });
490
- const paths = await pathsFor(root);
491
- return withChannelLock(paths, async () => {
492
- const [policy, runtime, { graph }] = await Promise.all([
493
- readJson(paths.channelPolicyPath, paths.catalog.root, normalizePolicy, emptyPolicy),
494
- readJson(paths.channelRuntimePath, paths.catalog.root, normalizeRuntime, emptyRuntime),
495
- loadGraph(paths.catalog.root, paths.catalog)
496
- ]);
497
- ensureHealthy(policy, runtime, graph);
498
- const route = [normalized.provider, normalized.tenantId, normalized.accountId, normalized.chatId, normalized.threadId || ""].join("\0");
499
- const routeBindings = policy.bindings.filter((binding) => binding.status === "active" && bindingRoute(binding) === route);
500
- if (!routeBindings.length) throw new Error("no active exact channel binding matches this event");
501
- const binding = routeBindings.find((item) => item.senderIds.includes(normalized.senderId));
502
- if (!binding) throw new Error("channel sender is not permitted by the exact binding");
503
- if (!binding.capabilities.includes("receive")) throw new Error("channel binding lacks receive capability");
504
- verifySignature(event, signature, binding, env);
505
- const payloadDigest = sha256(channelEventSigningPayload(event));
506
- const existing = runtime.events.find((item) => item.eventId === normalized.eventId);
507
- if (existing) {
508
- if (existing.payloadDigest !== payloadDigest || existing.bindingId !== binding.id) throw new Error("channel event ID collision; processing stopped");
509
- return { event: existing, duplicate: true, receipt: runtime.receipts.find((item) => item.eventId === existing.eventId && item.event === "ingested") || null };
510
- }
511
- const at = timestamp(now, "now");
512
- const stored = {
513
- ...normalized, bindingId: binding.id, agentId: binding.agentId, projectId: binding.projectId,
514
- groupId: binding.groupId, sessionKey: binding.sessionKey, status: "pending", attempts: 0,
515
- lease: null, receivedAt: at, completedAt: null, payloadDigest,
516
- authority: "execution-state-only"
517
- };
518
- runtime.events.push(stored);
519
- runtime.events.sort((left, right) => left.receivedAt.localeCompare(right.receivedAt) || left.eventId.localeCompare(right.eventId));
520
- const { receipt } = appendReceipt(runtime, stored.eventId, "ingested", at, { bindingId: binding.id, payloadDigest });
521
- ensureHealthy(policy, runtime, graph);
522
- await writeJson(paths.channelRuntimePath, runtime);
523
- return { event: stored, duplicate: false, receipt };
524
- });
525
- }
526
-
527
- function activeBinding(policy, event) {
528
- const binding = policy.bindings.find((item) => item.id === event.bindingId && item.status === "active");
529
- return binding && binding.capabilities.includes("receive") ? binding : null;
530
- }
531
-
532
- export async function claimChannelEvent({
533
- root = process.cwd(), agentId, projectId, groupId = null, provider, workerId,
534
- eventId = null, leaseSeconds = 120, now = new Date(), catalog: providedCatalog = null
535
- }) {
536
- agentId = stableId(agentId, "agentId");
537
- projectId = stableId(projectId, "projectId");
538
- groupId = stableId(groupId, "groupId", { nullable: true });
539
- provider = stableId(provider, "provider", { route: true });
540
- workerId = stableId(workerId, "workerId");
541
- eventId = stableId(eventId, "eventId", { nullable: true });
542
- const seconds = Number(leaseSeconds);
543
- if (!Number.isInteger(seconds) || seconds < 15 || seconds > 900) throw new Error("leaseSeconds must be an integer between 15 and 900");
544
- const paths = await pathsFor(root, providedCatalog);
545
- return withChannelLock(paths, async () => {
546
- const [policy, runtime, { graph }] = await Promise.all([
547
- readJson(paths.channelPolicyPath, paths.catalog.root, normalizePolicy, emptyPolicy),
548
- readJson(paths.channelRuntimePath, paths.catalog.root, normalizeRuntime, emptyRuntime),
549
- loadGraph(paths.catalog.root, paths.catalog)
550
- ]);
551
- ensureHealthy(policy, runtime, graph);
552
- const at = timestamp(now, "now");
553
- for (const event of runtime.events) {
554
- if (event.status === "leased" && new Date(event.lease.expiresAt) <= new Date(at)) {
555
- preserveEvent(runtime, event, "lease-expired", at);
556
- event.status = "pending";
557
- event.lease = null;
558
- appendReceipt(runtime, event.eventId, "lease-expired", at);
559
- }
560
- }
561
- const candidates = runtime.events.filter((event) => !TERMINAL_STATUSES.has(event.status)
562
- && event.status !== "leased" && event.agentId === agentId && event.projectId === projectId
563
- && event.groupId === groupId && event.provider === provider
564
- && (eventId === null || event.eventId === eventId) && activeBinding(policy, event));
565
- candidates.sort((left, right) => left.receivedAt.localeCompare(right.receivedAt) || left.eventId.localeCompare(right.eventId));
566
- const event = candidates[0] || null;
567
- if (!event) {
568
- await writeJson(paths.channelRuntimePath, runtime);
569
- return { event: null, receipt: null };
570
- }
571
- preserveEvent(runtime, event, "leased", at);
572
- event.status = "leased";
573
- event.attempts += 1;
574
- event.lease = {
575
- workerId, claimedAt: at,
576
- expiresAt: new Date(new Date(at).getTime() + seconds * 1000).toISOString()
577
- };
578
- const { receipt } = appendReceipt(runtime, event.eventId, "leased", at, { workerId, attempt: event.attempts });
579
- ensureHealthy(policy, runtime, graph);
580
- await writeJson(paths.channelRuntimePath, runtime);
581
- return { event, receipt };
582
- });
583
- }
584
-
585
- export async function completeChannelEvent({ root = process.cwd(), eventId, workerId, status, now = new Date() }) {
586
- eventId = stableId(eventId, "eventId");
587
- workerId = stableId(workerId, "workerId");
588
- if (!new Set(["completed", "failed"]).has(status)) throw new Error("channel completion status must be completed or failed");
589
- const paths = await pathsFor(root);
590
- return withChannelLock(paths, async () => {
591
- const [policy, runtime, { graph }] = await Promise.all([
592
- readJson(paths.channelPolicyPath, paths.catalog.root, normalizePolicy, emptyPolicy),
593
- readJson(paths.channelRuntimePath, paths.catalog.root, normalizeRuntime, emptyRuntime),
594
- loadGraph(paths.catalog.root, paths.catalog)
595
- ]);
596
- ensureHealthy(policy, runtime, graph);
597
- const event = runtime.events.find((item) => item.eventId === eventId);
598
- if (!event) throw new Error(`unknown channel event: ${eventId}`);
599
- if (event.status === status && event.completedAt) return { event, duplicate: true };
600
- if (event.status !== "leased" || event.lease?.workerId !== workerId) throw new Error("channel event completion requires its exact active worker lease");
601
- if (!activeBinding(policy, event)) throw new Error("channel binding was revoked before completion");
602
- const at = timestamp(now, "now");
603
- preserveEvent(runtime, event, status, at);
604
- event.status = status;
605
- event.lease = null;
606
- event.completedAt = status === "completed" ? at : null;
607
- const { receipt } = appendReceipt(runtime, event.eventId, status, at, { workerId, attempt: event.attempts });
608
- ensureHealthy(policy, runtime, graph);
609
- await writeJson(paths.channelRuntimePath, runtime);
610
- return { event, receipt, duplicate: false };
611
- });
612
- }
613
-
614
- export async function acknowledgeChannelDelivery({ root = process.cwd(), eventId, bindingId, deliveryReceiptId, now = new Date() }) {
615
- eventId = stableId(eventId, "eventId");
616
- bindingId = stableId(bindingId, "bindingId");
617
- deliveryReceiptId = stableId(deliveryReceiptId, "deliveryReceiptId");
618
- const paths = await pathsFor(root);
619
- return withChannelLock(paths, async () => {
620
- const [policy, runtime, { graph }] = await Promise.all([
621
- readJson(paths.channelPolicyPath, paths.catalog.root, normalizePolicy, emptyPolicy),
622
- readJson(paths.channelRuntimePath, paths.catalog.root, normalizeRuntime, emptyRuntime),
623
- loadGraph(paths.catalog.root, paths.catalog)
624
- ]);
625
- ensureHealthy(policy, runtime, graph);
626
- const event = runtime.events.find((item) => item.eventId === eventId && item.bindingId === bindingId);
627
- if (!event) throw new Error("delivered channel event is unavailable in the exact binding");
628
- if (event.status === "completed") return { event, duplicate: true };
629
- if (!activeBinding(policy, event)?.capabilities.includes("reply")) throw new Error("channel reply capability was revoked before delivery acknowledgement");
630
- const completedAt = timestamp(now, "now");
631
- preserveEvent(runtime, event, "delivery-acknowledged", completedAt);
632
- event.status = "completed"; event.lease = null; event.completedAt = completedAt;
633
- const { receipt } = appendReceipt(runtime, event.eventId, "completed", completedAt, { deliveryReceiptId });
634
- ensureHealthy(policy, runtime, graph);
635
- await writeJson(paths.channelRuntimePath, runtime);
636
- return { event, receipt, duplicate: false };
637
- });
638
- }
639
-
640
- export async function channelRuntimeContext({
641
- root = process.cwd(), agentId = null, projectId = null, groupId = undefined,
642
- provider = null, includeTerminal = false, maxItems = 20
643
- } = {}) {
644
- const [{ policy }, { runtime }, { graph }] = await Promise.all([
645
- loadChannelPolicy(root), loadChannelRuntime(root), loadGraph(root)
646
- ]);
647
- ensureHealthy(policy, runtime, graph);
648
- const limit = Number(maxItems);
649
- if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw new Error("maxItems must be an integer between 1 and 100");
650
- const items = runtime.events.filter((event) => {
651
- if (!includeTerminal && TERMINAL_STATUSES.has(event.status)) return false;
652
- if (agentId !== null && event.agentId !== stableId(agentId, "agentId")) return false;
653
- if (projectId !== null && event.projectId !== stableId(projectId, "projectId")) return false;
654
- if (groupId !== undefined && event.groupId !== stableId(groupId, "groupId", { nullable: true })) return false;
655
- if (provider !== null && event.provider !== stableId(provider, "provider", { route: true })) return false;
656
- return true;
657
- }).slice(0, limit).map((event) => structuredClone(event));
658
- return {
659
- schema: "agentspine.channel-context/v1", items,
660
- pending: items.filter((event) => !TERMINAL_STATUSES.has(event.status)).length,
661
- authority: "execution-state-only"
662
- };
663
- }
664
-
665
- export { EVENT_SCHEMA as CHANNEL_EVENT_SCHEMA, POLICY_SCHEMA as CHANNEL_POLICY_SCHEMA, RUNTIME_SCHEMA as CHANNEL_RUNTIME_SCHEMA };
1
+ import {
2
+ createHash, createHmac, randomUUID, timingSafeEqual
3
+ } from "node:crypto";
4
+ import {
5
+ open, readFile, rename, stat, unlink, writeFile
6
+ } from "node:fs/promises";
7
+ import { join } from "node:path";
8
+ import { buildCatalog } from "./catalog.js";
9
+ import { isFileLockContention } from "./filesystem-retry.js";
10
+ import { loadGraph } from "./graph.js";
11
+ import { projectStateDir } from "./paths.js";
12
+
13
+ const POLICY_SCHEMA = "agentspine.channel-policy/v1";
14
+ const RUNTIME_SCHEMA = "agentspine.channel-runtime/v1";
15
+ const EVENT_SCHEMA = "agentspine.channel-event/v1";
16
+ const CONFIRMATION = "local-owner-confirmed";
17
+ const MAX_STATE_BYTES = 5 * 1024 * 1024;
18
+ const MAX_TEXT_BYTES = 16 * 1024;
19
+ const ID_RE = /^[A-Za-z0-9][A-Za-z0-9:_.@/+~-]{0,255}$/;
20
+ const ROUTE_ID_RE = /^[A-Za-z0-9_+~-][A-Za-z0-9:_.@/+~-]{0,255}$/;
21
+ const ENV_RE = /^[A-Z][A-Z0-9_]{2,127}$/;
22
+ const DIGEST_RE = /^[a-f0-9]{64}$/;
23
+ const SIGNATURE_RE = /^sha256=([a-f0-9]{64})$/;
24
+ const PRIVACY = new Set(["private", "shared", "group"]);
25
+ const CAPABILITIES = new Set(["receive", "reply"]);
26
+ const BINDING_STATUSES = new Set(["active", "revoked"]);
27
+ const EVENT_STATUSES = new Set(["pending", "leased", "completed", "failed", "cancelled"]);
28
+ const TERMINAL_STATUSES = new Set(["completed", "cancelled"]);
29
+ const INBOUND_FIELDS = new Set([
30
+ "schema", "eventId", "provider", "tenantId", "accountId", "chatId", "threadId",
31
+ "senderId", "replyTo", "observedAt", "privacy", "text"
32
+ ]);
33
+ const SECRET_RE = /-----BEGIN [A-Z ]*PRIVATE KEY-----|\b(?:sk|gh[opusu])_[A-Za-z0-9_-]{20,}\b|\bBearer\s+[A-Za-z0-9._~+/-]{20,}|\b(?:api[-_ ]?key|token|password|secret|passwort|geheimnis)\s*[:=]\s*\S{8,}/i;
34
+
35
+ function emptyPolicy(root) {
36
+ return { schema: POLICY_SCHEMA, root, revision: 0, bindings: [], history: [] };
37
+ }
38
+
39
+ function emptyRuntime(root) {
40
+ return { schema: RUNTIME_SCHEMA, root, events: [], history: [], receipts: [] };
41
+ }
42
+
43
+ function stableId(value, field, { nullable = false, route = false } = {}) {
44
+ if ((value === null || value === undefined || value === "") && nullable) return null;
45
+ const expression = route ? ROUTE_ID_RE : ID_RE;
46
+ if (typeof value !== "string" || !expression.test(value) || value.includes("*")) {
47
+ throw new Error(`${field} must be an exact stable ID without wildcards`);
48
+ }
49
+ return value;
50
+ }
51
+
52
+ function timestamp(value, field = "timestamp") {
53
+ const parsed = value instanceof Date ? value : new Date(value);
54
+ if (!Number.isFinite(parsed.getTime())) throw new Error(`${field} must be a valid date`);
55
+ return parsed.toISOString();
56
+ }
57
+
58
+ function safeText(value, field, maximumBytes = MAX_TEXT_BYTES) {
59
+ if (typeof value !== "string" || !value.trim()) throw new Error(`${field} is required`);
60
+ const normalized = value.trim().replace(/\r\n?/g, "\n");
61
+ if (Buffer.byteLength(normalized) > maximumBytes) throw new Error(`${field} exceeds ${maximumBytes} bytes`);
62
+ if (SECRET_RE.test(normalized)) throw new Error(`${field} appears to contain a secret and cannot enter channel state`);
63
+ return normalized;
64
+ }
65
+
66
+ function exactCapabilities(value) {
67
+ if (!Array.isArray(value) || !value.length) throw new Error("channel capabilities are required");
68
+ const unique = [...new Set(value)].sort();
69
+ if (unique.some((item) => !CAPABILITIES.has(item))) throw new Error("channel capabilities may contain only receive and reply");
70
+ if (!unique.includes("receive")) throw new Error("channel bindings must include receive");
71
+ return unique;
72
+ }
73
+
74
+ function exactSenderIds(value) {
75
+ if (!Array.isArray(value) || !value.length) throw new Error("senderIds must contain exact authenticated sender IDs");
76
+ return [...new Set(value.map((item) => stableId(item, "senderId", { route: true })))].sort();
77
+ }
78
+
79
+ function sha256(value) {
80
+ return createHash("sha256").update(value).digest("hex");
81
+ }
82
+
83
+ function bindingRoute(binding) {
84
+ return [binding.provider, binding.tenantId, binding.accountId, binding.chatId, binding.threadId || ""].join("\0");
85
+ }
86
+
87
+ function normalizeInboundEvent(value, { inspectContent = false } = {}) {
88
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("channel event must be one object");
89
+ const unknown = Object.keys(value).filter((key) => !INBOUND_FIELDS.has(key));
90
+ if (unknown.length) throw new Error(`channel event contains unknown field: ${unknown.sort()[0]}`);
91
+ if (value.schema !== EVENT_SCHEMA) throw new Error(`channel event schema must be ${EVENT_SCHEMA}`);
92
+ if (!PRIVACY.has(value.privacy)) throw new Error("channel event privacy must be private, shared, or group");
93
+ const text = inspectContent ? safeText(value.text, "channel event text") : String(value.text ?? "").trim().replace(/\r\n?/g, "\n");
94
+ if (!text || Buffer.byteLength(text) > MAX_TEXT_BYTES) throw new Error(`channel event text must be 1-${MAX_TEXT_BYTES} bytes`);
95
+ return {
96
+ schema: EVENT_SCHEMA,
97
+ eventId: stableId(value.eventId, "eventId"),
98
+ provider: stableId(value.provider, "provider", { route: true }),
99
+ tenantId: stableId(value.tenantId, "tenantId", { route: true }),
100
+ accountId: stableId(value.accountId, "accountId", { route: true }),
101
+ chatId: stableId(value.chatId, "chatId", { route: true }),
102
+ threadId: stableId(value.threadId, "threadId", { nullable: true, route: true }),
103
+ senderId: stableId(value.senderId, "senderId", { route: true }),
104
+ replyTo: stableId(value.replyTo, "replyTo", { nullable: true, route: true }),
105
+ observedAt: timestamp(value.observedAt, "observedAt"),
106
+ privacy: value.privacy,
107
+ text
108
+ };
109
+ }
110
+
111
+ export function channelEventSigningPayload(event) {
112
+ return JSON.stringify(normalizeInboundEvent(event));
113
+ }
114
+
115
+ function validBinding(binding) {
116
+ return binding && ID_RE.test(binding.id || "") && BINDING_STATUSES.has(binding.status)
117
+ && ROUTE_ID_RE.test(binding.provider || "") && ROUTE_ID_RE.test(binding.tenantId || "")
118
+ && ROUTE_ID_RE.test(binding.accountId || "") && ROUTE_ID_RE.test(binding.chatId || "")
119
+ && (binding.threadId === null || ROUTE_ID_RE.test(binding.threadId || ""))
120
+ && Array.isArray(binding.senderIds) && binding.senderIds.length > 0
121
+ && binding.senderIds.every((id) => ROUTE_ID_RE.test(id) && !id.includes("*"))
122
+ && ID_RE.test(binding.agentId || "") && ID_RE.test(binding.projectId || "")
123
+ && (binding.groupId === null || ID_RE.test(binding.groupId || ""))
124
+ && ID_RE.test(binding.sessionKey || "") && ENV_RE.test(binding.secretEnv || "")
125
+ && (binding.outboundSecretEnv === null || ENV_RE.test(binding.outboundSecretEnv || ""))
126
+ && Array.isArray(binding.capabilities) && binding.capabilities.includes("receive")
127
+ && binding.capabilities.every((item) => CAPABILITIES.has(item))
128
+ && binding.authority === "explicit-local-channel-policy"
129
+ && Number.isFinite(new Date(binding.createdAt).getTime())
130
+ && Number.isFinite(new Date(binding.updatedAt).getTime())
131
+ && (binding.revokedAt === null || Number.isFinite(new Date(binding.revokedAt).getTime()));
132
+ }
133
+
134
+ function validEvent(event) {
135
+ return event && event.schema === EVENT_SCHEMA && ID_RE.test(event.eventId || "")
136
+ && ID_RE.test(event.bindingId || "") && ROUTE_ID_RE.test(event.provider || "")
137
+ && ROUTE_ID_RE.test(event.tenantId || "") && ROUTE_ID_RE.test(event.accountId || "")
138
+ && ROUTE_ID_RE.test(event.chatId || "") && (event.threadId === null || ROUTE_ID_RE.test(event.threadId || ""))
139
+ && ROUTE_ID_RE.test(event.senderId || "") && (event.replyTo === null || ROUTE_ID_RE.test(event.replyTo || ""))
140
+ && ID_RE.test(event.agentId || "") && ID_RE.test(event.projectId || "")
141
+ && (event.groupId === null || ID_RE.test(event.groupId || "")) && ID_RE.test(event.sessionKey || "")
142
+ && PRIVACY.has(event.privacy) && typeof event.text === "string" && event.text.length > 0
143
+ && Buffer.byteLength(event.text) <= MAX_TEXT_BYTES && !SECRET_RE.test(event.text)
144
+ && EVENT_STATUSES.has(event.status) && Number.isInteger(event.attempts) && event.attempts >= 0
145
+ && event.authority === "execution-state-only" && DIGEST_RE.test(event.payloadDigest || "")
146
+ && Number.isFinite(new Date(event.observedAt).getTime()) && Number.isFinite(new Date(event.receivedAt).getTime())
147
+ && (event.completedAt === null || Number.isFinite(new Date(event.completedAt).getTime()))
148
+ && (event.lease === null || (
149
+ event.status === "leased" && ID_RE.test(event.lease.workerId || "")
150
+ && Number.isFinite(new Date(event.lease.claimedAt).getTime())
151
+ && Number.isFinite(new Date(event.lease.expiresAt).getTime())
152
+ ));
153
+ }
154
+
155
+ function validReceipt(receipt) {
156
+ if (!(receipt && ID_RE.test(receipt.id || "") && ID_RE.test(receipt.eventId || "")
157
+ && typeof receipt.event === "string" && DIGEST_RE.test(receipt.digest || "")
158
+ && Number.isFinite(new Date(receipt.at).getTime()) && receipt.authority === "execution-state-only")) return false;
159
+ const { id, digest, ...material } = receipt;
160
+ return id === `channel-receipt:${digest.slice(0, 24)}` && sha256(JSON.stringify(material)) === digest;
161
+ }
162
+
163
+ function validPolicyHistory(record) {
164
+ return record && record.kind === "channel-binding" && ID_RE.test(record.recordId || "")
165
+ && record.recordId === record.value?.id && validBinding(record.value)
166
+ && Number.isFinite(new Date(record.supersededAt).getTime())
167
+ && record.authority === "explicit-local-channel-policy";
168
+ }
169
+
170
+ function validRuntimeHistory(record) {
171
+ return record && record.kind === "channel-event" && ID_RE.test(record.recordId || "")
172
+ && record.recordId === record.value?.eventId && validEvent(record.value)
173
+ && typeof record.transition === "string" && record.transition.length > 0
174
+ && Number.isFinite(new Date(record.supersededAt).getTime())
175
+ && record.authority === "execution-state-only";
176
+ }
177
+
178
+ function validPayloadDigest(event) {
179
+ if (!validEvent(event)) return false;
180
+ const inbound = Object.fromEntries([...INBOUND_FIELDS].map((field) => [field, event[field]]));
181
+ return sha256(channelEventSigningPayload(inbound)) === event.payloadDigest;
182
+ }
183
+
184
+ function normalizePolicy(value, root) {
185
+ if (!value || typeof value !== "object" || Array.isArray(value) || value.schema !== POLICY_SCHEMA
186
+ || value.root !== root || !Number.isInteger(value.revision) || value.revision < 0
187
+ || !Array.isArray(value.bindings) || !Array.isArray(value.history)) {
188
+ throw new Error("channel policy structure is invalid; channel runtime is disabled until repaired");
189
+ }
190
+ return value;
191
+ }
192
+
193
+ function normalizeRuntime(value, root) {
194
+ if (!value || typeof value !== "object" || Array.isArray(value) || value.schema !== RUNTIME_SCHEMA
195
+ || value.root !== root || !Array.isArray(value.events) || !Array.isArray(value.history)
196
+ || !Array.isArray(value.receipts)) {
197
+ throw new Error("channel runtime structure is invalid; channel processing is disabled until repaired");
198
+ }
199
+ return value;
200
+ }
201
+
202
+ async function readJson(path, root, normalize, empty) {
203
+ try {
204
+ const metadata = await stat(path);
205
+ if (metadata.size > MAX_STATE_BYTES) throw new Error("channel state exceeds the 5 MiB read limit");
206
+ return normalize(JSON.parse(await readFile(path, "utf8")), root);
207
+ } catch (error) {
208
+ if (error.code !== "ENOENT") throw error;
209
+ return empty(root);
210
+ }
211
+ }
212
+
213
+ async function writeJson(path, value) {
214
+ const content = `${JSON.stringify(value, null, 2)}\n`;
215
+ if (Buffer.byteLength(content) > MAX_STATE_BYTES) throw new Error("channel state exceeds 5 MiB; purge terminal events first");
216
+ const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
217
+ try {
218
+ await writeFile(temporary, content, { mode: 0o600 });
219
+ for (let attempt = 0; ; attempt += 1) {
220
+ try {
221
+ await rename(temporary, path);
222
+ break;
223
+ } catch (error) {
224
+ if (attempt >= 5 || !["EACCES", "EPERM"].includes(error.code)) throw error;
225
+ await new Promise((resolve) => setTimeout(resolve, 10 * (attempt + 1)));
226
+ }
227
+ }
228
+ } finally {
229
+ await unlink(temporary).catch((error) => { if (error.code !== "ENOENT") throw error; });
230
+ }
231
+ }
232
+
233
+ async function pathsFor(root, providedCatalog = null) {
234
+ const catalog = providedCatalog || await buildCatalog(root);
235
+ const directory = await projectStateDir(catalog.root);
236
+ return {
237
+ catalog, directory,
238
+ channelPolicyPath: join(directory, "channel-policy.json"),
239
+ channelRuntimePath: join(directory, "channel-runtime.json")
240
+ };
241
+ }
242
+
243
+ async function withChannelLock(paths, task) {
244
+ const lockPath = join(paths.directory, "channel-runtime.lock");
245
+ let handle;
246
+ for (let attempt = 0; attempt < 120; attempt += 1) {
247
+ try {
248
+ handle = await open(lockPath, "wx", 0o600);
249
+ break;
250
+ } catch (error) {
251
+ if (!isFileLockContention(error)) throw error;
252
+ try {
253
+ const metadata = await stat(lockPath);
254
+ if (Date.now() - metadata.mtimeMs > 90000) await unlink(lockPath);
255
+ } catch (lockError) {
256
+ if (lockError.code !== "ENOENT") throw lockError;
257
+ }
258
+ await new Promise((resolve) => setTimeout(resolve, 25));
259
+ }
260
+ }
261
+ if (!handle) throw new Error("channel state is busy; retry later");
262
+ try {
263
+ return await task();
264
+ } finally {
265
+ await handle.close();
266
+ await unlink(lockPath).catch((error) => { if (error.code !== "ENOENT") throw error; });
267
+ }
268
+ }
269
+
270
+ function requireConfirmation(value) {
271
+ if (value !== CONFIRMATION) throw new Error("channel policy changes require explicit local owner confirmation");
272
+ }
273
+
274
+ function referenceFindings(policy, runtime, graph) {
275
+ const findings = [];
276
+ const entities = new Map(graph.entities.map((entity) => [entity.id, entity]));
277
+ const bindings = new Map();
278
+ const routes = new Map();
279
+ for (const binding of policy.bindings) {
280
+ if (!validBinding(binding)) findings.push(`invalid-binding:${binding?.id || "unknown"}`);
281
+ if (bindings.has(binding.id)) findings.push(`duplicate-binding:${binding.id}`);
282
+ bindings.set(binding.id, binding);
283
+ if (binding.status === "active") {
284
+ const route = bindingRoute(binding);
285
+ if (routes.has(route)) findings.push(`duplicate-active-route:${binding.id}`);
286
+ routes.set(route, binding.id);
287
+ }
288
+ if (entities.get(binding.agentId)?.kind !== "agent") findings.push(`binding-agent-mismatch:${binding.id}`);
289
+ if (entities.get(binding.projectId)?.kind !== "project") findings.push(`binding-project-mismatch:${binding.id}`);
290
+ if (binding.groupId !== null) {
291
+ if (entities.get(binding.groupId)?.kind !== "group") findings.push(`binding-group-mismatch:${binding.id}`);
292
+ const member = graph.entityEdges.some((edge) => edge.relation === "member-of" && edge.privacy !== "private"
293
+ && ((edge.from === binding.agentId && edge.to === binding.groupId)
294
+ || (edge.to === binding.agentId && edge.from === binding.groupId)));
295
+ if (!member) findings.push(`binding-group-membership-mismatch:${binding.id}`);
296
+ }
297
+ }
298
+ for (const record of policy.history) {
299
+ if (!validPolicyHistory(record)) findings.push(`invalid-binding-history:${record?.recordId || "unknown"}`);
300
+ }
301
+ const eventIds = new Set();
302
+ for (const event of runtime.events) {
303
+ if (!validEvent(event)) findings.push(`invalid-channel-event:${event?.eventId || "unknown"}`);
304
+ else if (!validPayloadDigest(event)) findings.push(`channel-event-digest-mismatch:${event.eventId}`);
305
+ if (eventIds.has(event.eventId)) findings.push(`duplicate-channel-event:${event.eventId}`);
306
+ eventIds.add(event.eventId);
307
+ const binding = bindings.get(event.bindingId);
308
+ if (!binding || [event.provider, event.tenantId, event.accountId, event.chatId, event.threadId || ""].join("\0") !== bindingRoute(binding)
309
+ || event.agentId !== binding?.agentId || event.projectId !== binding?.projectId
310
+ || event.groupId !== binding?.groupId || event.sessionKey !== binding?.sessionKey
311
+ || !binding?.senderIds.includes(event.senderId)) findings.push(`event-binding-mismatch:${event.eventId}`);
312
+ }
313
+ for (const record of runtime.history) {
314
+ if (!validRuntimeHistory(record)) findings.push(`invalid-channel-history:${record?.recordId || "unknown"}`);
315
+ else if (!validPayloadDigest(record.value)) findings.push(`channel-history-digest-mismatch:${record.recordId}`);
316
+ }
317
+ const receiptIds = new Set();
318
+ for (const receipt of runtime.receipts) {
319
+ if (!validReceipt(receipt)) findings.push(`invalid-channel-receipt:${receipt?.id || "unknown"}`);
320
+ if (receiptIds.has(receipt.id)) findings.push(`duplicate-channel-receipt:${receipt.id}`);
321
+ if (!eventIds.has(receipt.eventId)) findings.push(`orphan-channel-receipt:${receipt.id}`);
322
+ receiptIds.add(receipt.id);
323
+ }
324
+ return findings;
325
+ }
326
+
327
+ export function channelPolicyFindings(policy, graph) {
328
+ return referenceFindings(policy, emptyRuntime(policy.root), graph).filter((item) => item.includes("binding") || item.includes("route"));
329
+ }
330
+
331
+ export function channelRuntimeFindings(runtime, policy, graph) {
332
+ return referenceFindings(policy, runtime, graph);
333
+ }
334
+
335
+ export async function loadChannelPolicy(root = process.cwd(), providedCatalog = null) {
336
+ const paths = await pathsFor(root, providedCatalog);
337
+ return { policy: await readJson(paths.channelPolicyPath, paths.catalog.root, normalizePolicy, emptyPolicy), ...paths };
338
+ }
339
+
340
+ export async function loadChannelRuntime(root = process.cwd(), providedCatalog = null) {
341
+ const paths = await pathsFor(root, providedCatalog);
342
+ return { runtime: await readJson(paths.channelRuntimePath, paths.catalog.root, normalizeRuntime, emptyRuntime), ...paths };
343
+ }
344
+
345
+ export async function inspectChannelRuntime(root = process.cwd(), providedCatalog = null) {
346
+ const paths = await pathsFor(root, providedCatalog);
347
+ let policy = emptyPolicy(paths.catalog.root);
348
+ let runtime = emptyRuntime(paths.catalog.root);
349
+ const errors = [];
350
+ try { policy = await readJson(paths.channelPolicyPath, paths.catalog.root, normalizePolicy, emptyPolicy); } catch (error) { errors.push(`policy:${error.message}`); }
351
+ try { runtime = await readJson(paths.channelRuntimePath, paths.catalog.root, normalizeRuntime, emptyRuntime); } catch (error) { errors.push(`runtime:${error.message}`); }
352
+ return { policy, runtime, errors, ...paths };
353
+ }
354
+
355
+ function preserveBinding(policy, binding, at) {
356
+ policy.history.push({
357
+ kind: "channel-binding", recordId: binding.id, supersededAt: at,
358
+ value: structuredClone(binding), authority: "explicit-local-channel-policy"
359
+ });
360
+ }
361
+
362
+ function preserveEvent(runtime, event, transition, at) {
363
+ runtime.history.push({
364
+ kind: "channel-event", recordId: event.eventId, transition, supersededAt: at,
365
+ value: structuredClone(event), authority: "execution-state-only"
366
+ });
367
+ }
368
+
369
+ function appendReceipt(runtime, eventId, event, at, details = {}) {
370
+ const material = { eventId, event, at, details, authority: "execution-state-only" };
371
+ const digest = sha256(JSON.stringify(material));
372
+ const id = `channel-receipt:${digest.slice(0, 24)}`;
373
+ const existing = runtime.receipts.find((receipt) => receipt.id === id);
374
+ if (existing) return { receipt: existing, duplicate: true };
375
+ const receipt = { id, ...material, digest };
376
+ runtime.receipts.push(receipt);
377
+ return { receipt, duplicate: false };
378
+ }
379
+
380
+ function ensureHealthy(policy, runtime, graph) {
381
+ const findings = referenceFindings(policy, runtime, graph);
382
+ if (findings.length) throw new Error(`channel runtime failed closed: ${findings.join(", ")}`);
383
+ }
384
+
385
+ export async function grantChannelBinding({
386
+ root = process.cwd(), id = `channel-binding:${randomUUID()}`, provider, tenantId, accountId,
387
+ chatId, threadId = null, senderIds, agentId, projectId, groupId = null, sessionKey,
388
+ secretEnv, outboundSecretEnv = null, capabilities = ["receive"], confirmation, now = new Date()
389
+ }) {
390
+ requireConfirmation(confirmation);
391
+ const paths = await pathsFor(root);
392
+ return withChannelLock(paths, async () => {
393
+ const [policy, runtime, { graph }] = await Promise.all([
394
+ readJson(paths.channelPolicyPath, paths.catalog.root, normalizePolicy, emptyPolicy),
395
+ readJson(paths.channelRuntimePath, paths.catalog.root, normalizeRuntime, emptyRuntime),
396
+ loadGraph(paths.catalog.root, paths.catalog)
397
+ ]);
398
+ ensureHealthy(policy, runtime, graph);
399
+ const at = timestamp(now, "now");
400
+ const binding = {
401
+ id: stableId(id, "bindingId"),
402
+ provider: stableId(provider, "provider", { route: true }),
403
+ tenantId: stableId(tenantId, "tenantId", { route: true }),
404
+ accountId: stableId(accountId, "accountId", { route: true }),
405
+ chatId: stableId(chatId, "chatId", { route: true }),
406
+ threadId: stableId(threadId, "threadId", { nullable: true, route: true }),
407
+ senderIds: exactSenderIds(senderIds),
408
+ agentId: stableId(agentId, "agentId"), projectId: stableId(projectId, "projectId"),
409
+ groupId: stableId(groupId, "groupId", { nullable: true }),
410
+ sessionKey: stableId(sessionKey, "sessionKey"),
411
+ secretEnv: ENV_RE.test(secretEnv || "") ? secretEnv : (() => { throw new Error("secretEnv must be an uppercase environment variable name"); })(),
412
+ outboundSecretEnv: outboundSecretEnv === null ? null : (ENV_RE.test(outboundSecretEnv || "")
413
+ ? outboundSecretEnv : (() => { throw new Error("outboundSecretEnv must be an uppercase environment variable name"); })()),
414
+ capabilities: exactCapabilities(capabilities), status: "active", createdAt: at, updatedAt: at,
415
+ revokedAt: null, revokeReason: null, authority: "explicit-local-channel-policy"
416
+ };
417
+ const agent = graph.entities.find((entity) => entity.id === binding.agentId && entity.kind === "agent");
418
+ const project = graph.entities.find((entity) => entity.id === binding.projectId && entity.kind === "project");
419
+ if (binding.capabilities.includes("reply") && !binding.outboundSecretEnv) {
420
+ throw new Error("reply capability requires an environment-only outbound adapter credential name");
421
+ }
422
+ if (!agent) throw new Error(`unknown agent entity: ${binding.agentId}`);
423
+ if (!project) throw new Error(`unknown project entity: ${binding.projectId}`);
424
+ if (binding.groupId !== null) {
425
+ if (!graph.entities.some((entity) => entity.id === binding.groupId && entity.kind === "group")) throw new Error(`unknown group entity: ${binding.groupId}`);
426
+ const member = graph.entityEdges.some((edge) => edge.relation === "member-of" && edge.privacy !== "private"
427
+ && ((edge.from === binding.agentId && edge.to === binding.groupId)
428
+ || (edge.to === binding.agentId && edge.from === binding.groupId)));
429
+ if (!member) throw new Error("channel binding agent must be a visible member of its exact group");
430
+ }
431
+ const activeRoute = policy.bindings.find((item) => item.status === "active" && bindingRoute(item) === bindingRoute(binding) && item.id !== binding.id);
432
+ if (activeRoute) throw new Error(`channel route is already bound by ${activeRoute.id}`);
433
+ const previous = policy.bindings.find((item) => item.id === binding.id);
434
+ if (previous) preserveBinding(policy, previous, at);
435
+ policy.bindings = policy.bindings.filter((item) => item.id !== binding.id);
436
+ policy.bindings.push(binding);
437
+ policy.bindings.sort((left, right) => left.id.localeCompare(right.id));
438
+ policy.revision += 1;
439
+ ensureHealthy(policy, runtime, graph);
440
+ await writeJson(paths.channelPolicyPath, policy);
441
+ return { binding, revision: policy.revision, channelPolicyPath: paths.channelPolicyPath };
442
+ });
443
+ }
444
+
445
+ export async function revokeChannelBinding({ root = process.cwd(), id, reason, confirmation, now = new Date() }) {
446
+ requireConfirmation(confirmation);
447
+ const paths = await pathsFor(root);
448
+ return withChannelLock(paths, async () => {
449
+ const [policy, runtime, { graph }] = await Promise.all([
450
+ readJson(paths.channelPolicyPath, paths.catalog.root, normalizePolicy, emptyPolicy),
451
+ readJson(paths.channelRuntimePath, paths.catalog.root, normalizeRuntime, emptyRuntime),
452
+ loadGraph(paths.catalog.root, paths.catalog)
453
+ ]);
454
+ ensureHealthy(policy, runtime, graph);
455
+ const binding = policy.bindings.find((item) => item.id === stableId(id, "bindingId"));
456
+ if (!binding) throw new Error(`unknown channel binding: ${id}`);
457
+ const at = timestamp(now, "now");
458
+ if (binding.status === "revoked") return { binding, duplicate: true };
459
+ preserveBinding(policy, binding, at);
460
+ binding.status = "revoked";
461
+ binding.revokedAt = at;
462
+ binding.updatedAt = at;
463
+ binding.revokeReason = safeText(reason, "revoke reason", 1000);
464
+ for (const event of runtime.events.filter((item) => item.bindingId === binding.id && !TERMINAL_STATUSES.has(item.status))) {
465
+ preserveEvent(runtime, event, "binding-revoked", at);
466
+ event.status = "cancelled";
467
+ event.lease = null;
468
+ event.completedAt = at;
469
+ appendReceipt(runtime, event.eventId, "cancelled", at, { reason: "binding-revoked" });
470
+ }
471
+ policy.revision += 1;
472
+ ensureHealthy(policy, runtime, graph);
473
+ await Promise.all([writeJson(paths.channelPolicyPath, policy), writeJson(paths.channelRuntimePath, runtime)]);
474
+ return { binding, duplicate: false, revision: policy.revision };
475
+ });
476
+ }
477
+
478
+ function verifySignature(event, signature, binding, env) {
479
+ const match = String(signature || "").match(SIGNATURE_RE);
480
+ if (!match) throw new Error("channel event signature is missing or invalid");
481
+ const secret = env?.[binding.secretEnv];
482
+ if (typeof secret !== "string" || Buffer.byteLength(secret) < 32) throw new Error(`channel adapter secret ${binding.secretEnv} is unavailable or too short`);
483
+ const expected = createHmac("sha256", secret).update(channelEventSigningPayload(event)).digest();
484
+ const supplied = Buffer.from(match[1], "hex");
485
+ if (supplied.length !== expected.length || !timingSafeEqual(supplied, expected)) throw new Error("channel event signature verification failed");
486
+ }
487
+
488
+ export async function ingestChannelEvent({ root = process.cwd(), event, signature, env = process.env, now = new Date() }) {
489
+ const normalized = normalizeInboundEvent(event, { inspectContent: true });
490
+ const paths = await pathsFor(root);
491
+ return withChannelLock(paths, async () => {
492
+ const [policy, runtime, { graph }] = await Promise.all([
493
+ readJson(paths.channelPolicyPath, paths.catalog.root, normalizePolicy, emptyPolicy),
494
+ readJson(paths.channelRuntimePath, paths.catalog.root, normalizeRuntime, emptyRuntime),
495
+ loadGraph(paths.catalog.root, paths.catalog)
496
+ ]);
497
+ ensureHealthy(policy, runtime, graph);
498
+ const route = [normalized.provider, normalized.tenantId, normalized.accountId, normalized.chatId, normalized.threadId || ""].join("\0");
499
+ const routeBindings = policy.bindings.filter((binding) => binding.status === "active" && bindingRoute(binding) === route);
500
+ if (!routeBindings.length) throw new Error("no active exact channel binding matches this event");
501
+ const binding = routeBindings.find((item) => item.senderIds.includes(normalized.senderId));
502
+ if (!binding) throw new Error("channel sender is not permitted by the exact binding");
503
+ if (!binding.capabilities.includes("receive")) throw new Error("channel binding lacks receive capability");
504
+ verifySignature(event, signature, binding, env);
505
+ const payloadDigest = sha256(channelEventSigningPayload(event));
506
+ const existing = runtime.events.find((item) => item.eventId === normalized.eventId);
507
+ if (existing) {
508
+ if (existing.payloadDigest !== payloadDigest || existing.bindingId !== binding.id) throw new Error("channel event ID collision; processing stopped");
509
+ return { event: existing, duplicate: true, receipt: runtime.receipts.find((item) => item.eventId === existing.eventId && item.event === "ingested") || null };
510
+ }
511
+ const at = timestamp(now, "now");
512
+ const stored = {
513
+ ...normalized, bindingId: binding.id, agentId: binding.agentId, projectId: binding.projectId,
514
+ groupId: binding.groupId, sessionKey: binding.sessionKey, status: "pending", attempts: 0,
515
+ lease: null, receivedAt: at, completedAt: null, payloadDigest,
516
+ authority: "execution-state-only"
517
+ };
518
+ runtime.events.push(stored);
519
+ runtime.events.sort((left, right) => left.receivedAt.localeCompare(right.receivedAt) || left.eventId.localeCompare(right.eventId));
520
+ const { receipt } = appendReceipt(runtime, stored.eventId, "ingested", at, { bindingId: binding.id, payloadDigest });
521
+ ensureHealthy(policy, runtime, graph);
522
+ await writeJson(paths.channelRuntimePath, runtime);
523
+ return { event: stored, duplicate: false, receipt };
524
+ });
525
+ }
526
+
527
+ function activeBinding(policy, event) {
528
+ const binding = policy.bindings.find((item) => item.id === event.bindingId && item.status === "active");
529
+ return binding && binding.capabilities.includes("receive") ? binding : null;
530
+ }
531
+
532
+ export async function claimChannelEvent({
533
+ root = process.cwd(), agentId, projectId, groupId = null, provider, workerId,
534
+ eventId = null, leaseSeconds = 120, now = new Date(), catalog: providedCatalog = null
535
+ }) {
536
+ agentId = stableId(agentId, "agentId");
537
+ projectId = stableId(projectId, "projectId");
538
+ groupId = stableId(groupId, "groupId", { nullable: true });
539
+ provider = stableId(provider, "provider", { route: true });
540
+ workerId = stableId(workerId, "workerId");
541
+ eventId = stableId(eventId, "eventId", { nullable: true });
542
+ const seconds = Number(leaseSeconds);
543
+ if (!Number.isInteger(seconds) || seconds < 15 || seconds > 900) throw new Error("leaseSeconds must be an integer between 15 and 900");
544
+ const paths = await pathsFor(root, providedCatalog);
545
+ return withChannelLock(paths, async () => {
546
+ const [policy, runtime, { graph }] = await Promise.all([
547
+ readJson(paths.channelPolicyPath, paths.catalog.root, normalizePolicy, emptyPolicy),
548
+ readJson(paths.channelRuntimePath, paths.catalog.root, normalizeRuntime, emptyRuntime),
549
+ loadGraph(paths.catalog.root, paths.catalog)
550
+ ]);
551
+ ensureHealthy(policy, runtime, graph);
552
+ const at = timestamp(now, "now");
553
+ for (const event of runtime.events) {
554
+ if (event.status === "leased" && new Date(event.lease.expiresAt) <= new Date(at)) {
555
+ preserveEvent(runtime, event, "lease-expired", at);
556
+ event.status = "pending";
557
+ event.lease = null;
558
+ appendReceipt(runtime, event.eventId, "lease-expired", at);
559
+ }
560
+ }
561
+ const candidates = runtime.events.filter((event) => !TERMINAL_STATUSES.has(event.status)
562
+ && event.status !== "leased" && event.agentId === agentId && event.projectId === projectId
563
+ && event.groupId === groupId && event.provider === provider
564
+ && (eventId === null || event.eventId === eventId) && activeBinding(policy, event));
565
+ candidates.sort((left, right) => left.receivedAt.localeCompare(right.receivedAt) || left.eventId.localeCompare(right.eventId));
566
+ const event = candidates[0] || null;
567
+ if (!event) {
568
+ await writeJson(paths.channelRuntimePath, runtime);
569
+ return { event: null, receipt: null };
570
+ }
571
+ preserveEvent(runtime, event, "leased", at);
572
+ event.status = "leased";
573
+ event.attempts += 1;
574
+ event.lease = {
575
+ workerId, claimedAt: at,
576
+ expiresAt: new Date(new Date(at).getTime() + seconds * 1000).toISOString()
577
+ };
578
+ const { receipt } = appendReceipt(runtime, event.eventId, "leased", at, { workerId, attempt: event.attempts });
579
+ ensureHealthy(policy, runtime, graph);
580
+ await writeJson(paths.channelRuntimePath, runtime);
581
+ return { event, receipt };
582
+ });
583
+ }
584
+
585
+ export async function completeChannelEvent({ root = process.cwd(), eventId, workerId, status, now = new Date() }) {
586
+ eventId = stableId(eventId, "eventId");
587
+ workerId = stableId(workerId, "workerId");
588
+ if (!new Set(["completed", "failed"]).has(status)) throw new Error("channel completion status must be completed or failed");
589
+ const paths = await pathsFor(root);
590
+ return withChannelLock(paths, async () => {
591
+ const [policy, runtime, { graph }] = await Promise.all([
592
+ readJson(paths.channelPolicyPath, paths.catalog.root, normalizePolicy, emptyPolicy),
593
+ readJson(paths.channelRuntimePath, paths.catalog.root, normalizeRuntime, emptyRuntime),
594
+ loadGraph(paths.catalog.root, paths.catalog)
595
+ ]);
596
+ ensureHealthy(policy, runtime, graph);
597
+ const event = runtime.events.find((item) => item.eventId === eventId);
598
+ if (!event) throw new Error(`unknown channel event: ${eventId}`);
599
+ if (event.status === status && event.completedAt) return { event, duplicate: true };
600
+ if (event.status !== "leased" || event.lease?.workerId !== workerId) throw new Error("channel event completion requires its exact active worker lease");
601
+ if (!activeBinding(policy, event)) throw new Error("channel binding was revoked before completion");
602
+ const at = timestamp(now, "now");
603
+ preserveEvent(runtime, event, status, at);
604
+ event.status = status;
605
+ event.lease = null;
606
+ event.completedAt = status === "completed" ? at : null;
607
+ const { receipt } = appendReceipt(runtime, event.eventId, status, at, { workerId, attempt: event.attempts });
608
+ ensureHealthy(policy, runtime, graph);
609
+ await writeJson(paths.channelRuntimePath, runtime);
610
+ return { event, receipt, duplicate: false };
611
+ });
612
+ }
613
+
614
+ export async function acknowledgeChannelDelivery({ root = process.cwd(), eventId, bindingId, deliveryReceiptId, now = new Date() }) {
615
+ eventId = stableId(eventId, "eventId");
616
+ bindingId = stableId(bindingId, "bindingId");
617
+ deliveryReceiptId = stableId(deliveryReceiptId, "deliveryReceiptId");
618
+ const paths = await pathsFor(root);
619
+ return withChannelLock(paths, async () => {
620
+ const [policy, runtime, { graph }] = await Promise.all([
621
+ readJson(paths.channelPolicyPath, paths.catalog.root, normalizePolicy, emptyPolicy),
622
+ readJson(paths.channelRuntimePath, paths.catalog.root, normalizeRuntime, emptyRuntime),
623
+ loadGraph(paths.catalog.root, paths.catalog)
624
+ ]);
625
+ ensureHealthy(policy, runtime, graph);
626
+ const event = runtime.events.find((item) => item.eventId === eventId && item.bindingId === bindingId);
627
+ if (!event) throw new Error("delivered channel event is unavailable in the exact binding");
628
+ if (event.status === "completed") return { event, duplicate: true };
629
+ if (!activeBinding(policy, event)?.capabilities.includes("reply")) throw new Error("channel reply capability was revoked before delivery acknowledgement");
630
+ const completedAt = timestamp(now, "now");
631
+ preserveEvent(runtime, event, "delivery-acknowledged", completedAt);
632
+ event.status = "completed"; event.lease = null; event.completedAt = completedAt;
633
+ const { receipt } = appendReceipt(runtime, event.eventId, "completed", completedAt, { deliveryReceiptId });
634
+ ensureHealthy(policy, runtime, graph);
635
+ await writeJson(paths.channelRuntimePath, runtime);
636
+ return { event, receipt, duplicate: false };
637
+ });
638
+ }
639
+
640
+ export async function channelRuntimeContext({
641
+ root = process.cwd(), agentId = null, projectId = null, groupId = undefined,
642
+ provider = null, includeTerminal = false, maxItems = 20
643
+ } = {}) {
644
+ const [{ policy }, { runtime }, { graph }] = await Promise.all([
645
+ loadChannelPolicy(root), loadChannelRuntime(root), loadGraph(root)
646
+ ]);
647
+ ensureHealthy(policy, runtime, graph);
648
+ const limit = Number(maxItems);
649
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw new Error("maxItems must be an integer between 1 and 100");
650
+ const items = runtime.events.filter((event) => {
651
+ if (!includeTerminal && TERMINAL_STATUSES.has(event.status)) return false;
652
+ if (agentId !== null && event.agentId !== stableId(agentId, "agentId")) return false;
653
+ if (projectId !== null && event.projectId !== stableId(projectId, "projectId")) return false;
654
+ if (groupId !== undefined && event.groupId !== stableId(groupId, "groupId", { nullable: true })) return false;
655
+ if (provider !== null && event.provider !== stableId(provider, "provider", { route: true })) return false;
656
+ return true;
657
+ }).slice(0, limit).map((event) => structuredClone(event));
658
+ return {
659
+ schema: "agentspine.channel-context/v1", items,
660
+ pending: items.filter((event) => !TERMINAL_STATUSES.has(event.status)).length,
661
+ authority: "execution-state-only"
662
+ };
663
+ }
664
+
665
+ export { EVENT_SCHEMA as CHANNEL_EVENT_SCHEMA, POLICY_SCHEMA as CHANNEL_POLICY_SCHEMA, RUNTIME_SCHEMA as CHANNEL_RUNTIME_SCHEMA };