comisai 1.0.10 → 1.0.12

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 (251) hide show
  1. package/README.md +3 -3
  2. package/node_modules/@comis/agent/dist/executor/pi-executor.d.ts +1 -4
  3. package/node_modules/@comis/agent/package.json +1 -1
  4. package/node_modules/@comis/channels/package.json +1 -1
  5. package/node_modules/@comis/cli/dist/wizard/steps/06-channels.js +88 -11
  6. package/node_modules/@comis/cli/dist/wizard/steps/10-write-config.js +4 -0
  7. package/node_modules/@comis/cli/dist/wizard/types.d.ts +1 -0
  8. package/node_modules/@comis/cli/package.json +1 -1
  9. package/node_modules/@comis/core/package.json +1 -1
  10. package/node_modules/@comis/daemon/package.json +1 -1
  11. package/node_modules/@comis/gateway/package.json +1 -1
  12. package/node_modules/@comis/infra/package.json +1 -1
  13. package/node_modules/@comis/memory/package.json +1 -1
  14. package/node_modules/@comis/scheduler/package.json +1 -1
  15. package/node_modules/@comis/shared/package.json +1 -1
  16. package/node_modules/@comis/skills/package.json +1 -1
  17. package/package.json +18 -18
  18. package/node_modules/@comis/core/dist/approval/approval-gate.test.d.ts +0 -1
  19. package/node_modules/@comis/core/dist/approval/approval-gate.test.js +0 -1003
  20. package/node_modules/@comis/core/dist/bootstrap.test.d.ts +0 -1
  21. package/node_modules/@comis/core/dist/bootstrap.test.js +0 -150
  22. package/node_modules/@comis/core/dist/config/backup.test.d.ts +0 -1
  23. package/node_modules/@comis/core/dist/config/backup.test.js +0 -160
  24. package/node_modules/@comis/core/dist/config/env-substitution.test.d.ts +0 -1
  25. package/node_modules/@comis/core/dist/config/env-substitution.test.js +0 -259
  26. package/node_modules/@comis/core/dist/config/field-metadata.test.d.ts +0 -1
  27. package/node_modules/@comis/core/dist/config/field-metadata.test.js +0 -72
  28. package/node_modules/@comis/core/dist/config/git-manager.test.d.ts +0 -1
  29. package/node_modules/@comis/core/dist/config/git-manager.test.js +0 -1042
  30. package/node_modules/@comis/core/dist/config/immutable-keys.test.d.ts +0 -1
  31. package/node_modules/@comis/core/dist/config/immutable-keys.test.js +0 -283
  32. package/node_modules/@comis/core/dist/config/include-resolver.test.d.ts +0 -1
  33. package/node_modules/@comis/core/dist/config/include-resolver.test.js +0 -292
  34. package/node_modules/@comis/core/dist/config/layered.test.d.ts +0 -1
  35. package/node_modules/@comis/core/dist/config/layered.test.js +0 -211
  36. package/node_modules/@comis/core/dist/config/loader.test.d.ts +0 -1
  37. package/node_modules/@comis/core/dist/config/loader.test.js +0 -300
  38. package/node_modules/@comis/core/dist/config/migrate.test.d.ts +0 -1
  39. package/node_modules/@comis/core/dist/config/migrate.test.js +0 -244
  40. package/node_modules/@comis/core/dist/config/partial-validator.test.d.ts +0 -1
  41. package/node_modules/@comis/core/dist/config/partial-validator.test.js +0 -98
  42. package/node_modules/@comis/core/dist/config/schema-agent-model.test.d.ts +0 -1
  43. package/node_modules/@comis/core/dist/config/schema-agent-model.test.js +0 -279
  44. package/node_modules/@comis/core/dist/config/schema-agent-session.test.d.ts +0 -1
  45. package/node_modules/@comis/core/dist/config/schema-agent-session.test.js +0 -242
  46. package/node_modules/@comis/core/dist/config/schema-agent.test.d.ts +0 -1
  47. package/node_modules/@comis/core/dist/config/schema-agent.test.js +0 -645
  48. package/node_modules/@comis/core/dist/config/schema-channel.test.d.ts +0 -1
  49. package/node_modules/@comis/core/dist/config/schema-channel.test.js +0 -341
  50. package/node_modules/@comis/core/dist/config/schema-coalescer.test.d.ts +0 -1
  51. package/node_modules/@comis/core/dist/config/schema-coalescer.test.js +0 -51
  52. package/node_modules/@comis/core/dist/config/schema-context-engine.test.d.ts +0 -1
  53. package/node_modules/@comis/core/dist/config/schema-context-engine.test.js +0 -797
  54. package/node_modules/@comis/core/dist/config/schema-context-guard.test.d.ts +0 -1
  55. package/node_modules/@comis/core/dist/config/schema-context-guard.test.js +0 -111
  56. package/node_modules/@comis/core/dist/config/schema-daemon.test.d.ts +0 -1
  57. package/node_modules/@comis/core/dist/config/schema-daemon.test.js +0 -107
  58. package/node_modules/@comis/core/dist/config/schema-delivery-timing.test.d.ts +0 -1
  59. package/node_modules/@comis/core/dist/config/schema-delivery-timing.test.js +0 -51
  60. package/node_modules/@comis/core/dist/config/schema-documentation.test.d.ts +0 -1
  61. package/node_modules/@comis/core/dist/config/schema-documentation.test.js +0 -63
  62. package/node_modules/@comis/core/dist/config/schema-gateway.test.d.ts +0 -1
  63. package/node_modules/@comis/core/dist/config/schema-gateway.test.js +0 -219
  64. package/node_modules/@comis/core/dist/config/schema-gemini-cache.test.d.ts +0 -1
  65. package/node_modules/@comis/core/dist/config/schema-gemini-cache.test.js +0 -41
  66. package/node_modules/@comis/core/dist/config/schema-integrations.test.d.ts +0 -1
  67. package/node_modules/@comis/core/dist/config/schema-integrations.test.js +0 -92
  68. package/node_modules/@comis/core/dist/config/schema-lifecycle-reactions.test.d.ts +0 -1
  69. package/node_modules/@comis/core/dist/config/schema-lifecycle-reactions.test.js +0 -61
  70. package/node_modules/@comis/core/dist/config/schema-misc.test.d.ts +0 -1
  71. package/node_modules/@comis/core/dist/config/schema-misc.test.js +0 -394
  72. package/node_modules/@comis/core/dist/config/schema-observability.test.d.ts +0 -1
  73. package/node_modules/@comis/core/dist/config/schema-observability.test.js +0 -76
  74. package/node_modules/@comis/core/dist/config/schema-providers.test.d.ts +0 -1
  75. package/node_modules/@comis/core/dist/config/schema-providers.test.js +0 -294
  76. package/node_modules/@comis/core/dist/config/schema-queue.test.d.ts +0 -1
  77. package/node_modules/@comis/core/dist/config/schema-queue.test.js +0 -234
  78. package/node_modules/@comis/core/dist/config/schema-response-prefix.test.d.ts +0 -1
  79. package/node_modules/@comis/core/dist/config/schema-response-prefix.test.js +0 -36
  80. package/node_modules/@comis/core/dist/config/schema-security.test.d.ts +0 -1
  81. package/node_modules/@comis/core/dist/config/schema-security.test.js +0 -54
  82. package/node_modules/@comis/core/dist/config/schema-sender-trust-display.test.d.ts +0 -1
  83. package/node_modules/@comis/core/dist/config/schema-sender-trust-display.test.js +0 -60
  84. package/node_modules/@comis/core/dist/config/schema-serializer.test.d.ts +0 -1
  85. package/node_modules/@comis/core/dist/config/schema-serializer.test.js +0 -73
  86. package/node_modules/@comis/core/dist/config/schema-telegram-file-guard.test.d.ts +0 -1
  87. package/node_modules/@comis/core/dist/config/schema-telegram-file-guard.test.js +0 -39
  88. package/node_modules/@comis/core/dist/context/context.test.d.ts +0 -1
  89. package/node_modules/@comis/core/dist/context/context.test.js +0 -276
  90. package/node_modules/@comis/core/dist/domain/agent-response.test.d.ts +0 -1
  91. package/node_modules/@comis/core/dist/domain/agent-response.test.js +0 -159
  92. package/node_modules/@comis/core/dist/domain/credential-mapping.test.d.ts +0 -1
  93. package/node_modules/@comis/core/dist/domain/credential-mapping.test.js +0 -135
  94. package/node_modules/@comis/core/dist/domain/delivery-origin.test.d.ts +0 -1
  95. package/node_modules/@comis/core/dist/domain/delivery-origin.test.js +0 -68
  96. package/node_modules/@comis/core/dist/domain/execution-graph.test.d.ts +0 -1
  97. package/node_modules/@comis/core/dist/domain/execution-graph.test.js +0 -441
  98. package/node_modules/@comis/core/dist/domain/memory-entry.test.d.ts +0 -1
  99. package/node_modules/@comis/core/dist/domain/memory-entry.test.js +0 -193
  100. package/node_modules/@comis/core/dist/domain/normalized-message.test.d.ts +0 -1
  101. package/node_modules/@comis/core/dist/domain/normalized-message.test.js +0 -255
  102. package/node_modules/@comis/core/dist/domain/session-key.test.d.ts +0 -1
  103. package/node_modules/@comis/core/dist/domain/session-key.test.js +0 -291
  104. package/node_modules/@comis/core/dist/domain/subagent-context-config.test.d.ts +0 -1
  105. package/node_modules/@comis/core/dist/domain/subagent-context-config.test.js +0 -131
  106. package/node_modules/@comis/core/dist/domain/subagent-context-types.test.d.ts +0 -1
  107. package/node_modules/@comis/core/dist/domain/subagent-context-types.test.js +0 -182
  108. package/node_modules/@comis/core/dist/event-bus/bus.test.d.ts +0 -1
  109. package/node_modules/@comis/core/dist/event-bus/bus.test.js +0 -152
  110. package/node_modules/@comis/core/dist/event-bus/events-agent.test.d.ts +0 -1
  111. package/node_modules/@comis/core/dist/event-bus/events-agent.test.js +0 -409
  112. package/node_modules/@comis/core/dist/event-bus/events-channel.test.d.ts +0 -1
  113. package/node_modules/@comis/core/dist/event-bus/events-channel.test.js +0 -240
  114. package/node_modules/@comis/core/dist/event-bus/events-infra.test.d.ts +0 -1
  115. package/node_modules/@comis/core/dist/event-bus/events-infra.test.js +0 -416
  116. package/node_modules/@comis/core/dist/event-bus/events-messaging.test.d.ts +0 -1
  117. package/node_modules/@comis/core/dist/event-bus/events-messaging.test.js +0 -433
  118. package/node_modules/@comis/core/dist/event-bus/events.test.d.ts +0 -1
  119. package/node_modules/@comis/core/dist/event-bus/events.test.js +0 -93
  120. package/node_modules/@comis/core/dist/hooks/hook-runner-global.test.d.ts +0 -1
  121. package/node_modules/@comis/core/dist/hooks/hook-runner-global.test.js +0 -29
  122. package/node_modules/@comis/core/dist/hooks/hook-runner.test.d.ts +0 -1
  123. package/node_modules/@comis/core/dist/hooks/hook-runner.test.js +0 -490
  124. package/node_modules/@comis/core/dist/hooks/hook-strategies.test.d.ts +0 -1
  125. package/node_modules/@comis/core/dist/hooks/hook-strategies.test.js +0 -209
  126. package/node_modules/@comis/core/dist/hooks/integration.test.d.ts +0 -1
  127. package/node_modules/@comis/core/dist/hooks/integration.test.js +0 -235
  128. package/node_modules/@comis/core/dist/hooks/plugin-registry.test.d.ts +0 -1
  129. package/node_modules/@comis/core/dist/hooks/plugin-registry.test.js +0 -470
  130. package/node_modules/@comis/core/dist/load-env.test.d.ts +0 -1
  131. package/node_modules/@comis/core/dist/load-env.test.js +0 -21
  132. package/node_modules/@comis/core/dist/security/action-classifier.test.d.ts +0 -1
  133. package/node_modules/@comis/core/dist/security/action-classifier.test.js +0 -298
  134. package/node_modules/@comis/core/dist/security/audit-aggregator.test.d.ts +0 -1
  135. package/node_modules/@comis/core/dist/security/audit-aggregator.test.js +0 -128
  136. package/node_modules/@comis/core/dist/security/audit.test.d.ts +0 -1
  137. package/node_modules/@comis/core/dist/security/audit.test.js +0 -154
  138. package/node_modules/@comis/core/dist/security/canary-token.test.d.ts +0 -1
  139. package/node_modules/@comis/core/dist/security/canary-token.test.js +0 -45
  140. package/node_modules/@comis/core/dist/security/config-redaction.test.d.ts +0 -1
  141. package/node_modules/@comis/core/dist/security/config-redaction.test.js +0 -107
  142. package/node_modules/@comis/core/dist/security/external-content.test.d.ts +0 -1
  143. package/node_modules/@comis/core/dist/security/external-content.test.js +0 -210
  144. package/node_modules/@comis/core/dist/security/injection-patterns.test.d.ts +0 -1
  145. package/node_modules/@comis/core/dist/security/injection-patterns.test.js +0 -213
  146. package/node_modules/@comis/core/dist/security/injection-rate-limiter.test.d.ts +0 -1
  147. package/node_modules/@comis/core/dist/security/injection-rate-limiter.test.js +0 -194
  148. package/node_modules/@comis/core/dist/security/input-guard.test.d.ts +0 -1
  149. package/node_modules/@comis/core/dist/security/input-guard.test.js +0 -215
  150. package/node_modules/@comis/core/dist/security/input-security-guard.test.d.ts +0 -1
  151. package/node_modules/@comis/core/dist/security/input-security-guard.test.js +0 -215
  152. package/node_modules/@comis/core/dist/security/input-validator.test.d.ts +0 -1
  153. package/node_modules/@comis/core/dist/security/input-validator.test.js +0 -133
  154. package/node_modules/@comis/core/dist/security/log-sanitizer.test.d.ts +0 -1
  155. package/node_modules/@comis/core/dist/security/log-sanitizer.test.js +0 -260
  156. package/node_modules/@comis/core/dist/security/memory-write-validator.test.d.ts +0 -1
  157. package/node_modules/@comis/core/dist/security/memory-write-validator.test.js +0 -62
  158. package/node_modules/@comis/core/dist/security/output-guard.test.d.ts +0 -1
  159. package/node_modules/@comis/core/dist/security/output-guard.test.js +0 -397
  160. package/node_modules/@comis/core/dist/security/safe-path.test.d.ts +0 -1
  161. package/node_modules/@comis/core/dist/security/safe-path.test.js +0 -95
  162. package/node_modules/@comis/core/dist/security/scoped-secret-manager.test.d.ts +0 -1
  163. package/node_modules/@comis/core/dist/security/scoped-secret-manager.test.js +0 -231
  164. package/node_modules/@comis/core/dist/security/secret-access.test.d.ts +0 -1
  165. package/node_modules/@comis/core/dist/security/secret-access.test.js +0 -41
  166. package/node_modules/@comis/core/dist/security/secret-crypto.test.d.ts +0 -1
  167. package/node_modules/@comis/core/dist/security/secret-crypto.test.js +0 -113
  168. package/node_modules/@comis/core/dist/security/secret-manager.test.d.ts +0 -1
  169. package/node_modules/@comis/core/dist/security/secret-manager.test.js +0 -394
  170. package/node_modules/@comis/core/dist/security/secret-ref-resolver.test.d.ts +0 -1
  171. package/node_modules/@comis/core/dist/security/secret-ref-resolver.test.js +0 -268
  172. package/node_modules/@comis/core/dist/security/secrets-audit.test.d.ts +0 -10
  173. package/node_modules/@comis/core/dist/security/secrets-audit.test.js +0 -295
  174. package/node_modules/@comis/core/dist/security/ssrf-guard.test.d.ts +0 -1
  175. package/node_modules/@comis/core/dist/security/ssrf-guard.test.js +0 -127
  176. package/node_modules/@comis/core/dist/security/token-generator.test.d.ts +0 -1
  177. package/node_modules/@comis/core/dist/security/token-generator.test.js +0 -35
  178. package/node_modules/@comis/core/dist/tool-metadata.test.d.ts +0 -1
  179. package/node_modules/@comis/core/dist/tool-metadata.test.js +0 -112
  180. package/node_modules/@comis/memory/dist/compaction.test.d.ts +0 -1
  181. package/node_modules/@comis/memory/dist/compaction.test.js +0 -298
  182. package/node_modules/@comis/memory/dist/context-schema.test.d.ts +0 -1
  183. package/node_modules/@comis/memory/dist/context-schema.test.js +0 -246
  184. package/node_modules/@comis/memory/dist/context-store.test.d.ts +0 -1
  185. package/node_modules/@comis/memory/dist/context-store.test.js +0 -708
  186. package/node_modules/@comis/memory/dist/credential-mapping-schema.test.d.ts +0 -7
  187. package/node_modules/@comis/memory/dist/credential-mapping-schema.test.js +0 -73
  188. package/node_modules/@comis/memory/dist/credential-mapping-store.test.d.ts +0 -8
  189. package/node_modules/@comis/memory/dist/credential-mapping-store.test.js +0 -340
  190. package/node_modules/@comis/memory/dist/delivery-mirror-adapter.test.d.ts +0 -1
  191. package/node_modules/@comis/memory/dist/delivery-mirror-adapter.test.js +0 -165
  192. package/node_modules/@comis/memory/dist/delivery-queue-adapter.test.d.ts +0 -1
  193. package/node_modules/@comis/memory/dist/delivery-queue-adapter.test.js +0 -263
  194. package/node_modules/@comis/memory/dist/embedding-batch-indexer.test.d.ts +0 -1
  195. package/node_modules/@comis/memory/dist/embedding-batch-indexer.test.js +0 -202
  196. package/node_modules/@comis/memory/dist/embedding-cache-lru.test.d.ts +0 -1
  197. package/node_modules/@comis/memory/dist/embedding-cache-lru.test.js +0 -241
  198. package/node_modules/@comis/memory/dist/embedding-cache-sqlite.test.d.ts +0 -8
  199. package/node_modules/@comis/memory/dist/embedding-cache-sqlite.test.js +0 -678
  200. package/node_modules/@comis/memory/dist/embedding-cache.test.d.ts +0 -1
  201. package/node_modules/@comis/memory/dist/embedding-cache.test.js +0 -213
  202. package/node_modules/@comis/memory/dist/embedding-fingerprint.test.d.ts +0 -1
  203. package/node_modules/@comis/memory/dist/embedding-fingerprint.test.js +0 -87
  204. package/node_modules/@comis/memory/dist/embedding-hash.test.d.ts +0 -1
  205. package/node_modules/@comis/memory/dist/embedding-hash.test.js +0 -31
  206. package/node_modules/@comis/memory/dist/embedding-integration.test.d.ts +0 -8
  207. package/node_modules/@comis/memory/dist/embedding-integration.test.js +0 -232
  208. package/node_modules/@comis/memory/dist/embedding-provider-factory.test.d.ts +0 -1
  209. package/node_modules/@comis/memory/dist/embedding-provider-factory.test.js +0 -176
  210. package/node_modules/@comis/memory/dist/embedding-provider-local.test.d.ts +0 -8
  211. package/node_modules/@comis/memory/dist/embedding-provider-local.test.js +0 -76
  212. package/node_modules/@comis/memory/dist/embedding-provider-openai.test.d.ts +0 -8
  213. package/node_modules/@comis/memory/dist/embedding-provider-openai.test.js +0 -100
  214. package/node_modules/@comis/memory/dist/embedding-queue.test.d.ts +0 -1
  215. package/node_modules/@comis/memory/dist/embedding-queue.test.js +0 -236
  216. package/node_modules/@comis/memory/dist/hybrid-search.test.d.ts +0 -1
  217. package/node_modules/@comis/memory/dist/hybrid-search.test.js +0 -476
  218. package/node_modules/@comis/memory/dist/identity-link-store.test.d.ts +0 -1
  219. package/node_modules/@comis/memory/dist/identity-link-store.test.js +0 -88
  220. package/node_modules/@comis/memory/dist/memory-api.test.d.ts +0 -1
  221. package/node_modules/@comis/memory/dist/memory-api.test.js +0 -495
  222. package/node_modules/@comis/memory/dist/memory-concurrency.test.d.ts +0 -20
  223. package/node_modules/@comis/memory/dist/memory-concurrency.test.js +0 -222
  224. package/node_modules/@comis/memory/dist/named-graph-store.test.d.ts +0 -1
  225. package/node_modules/@comis/memory/dist/named-graph-store.test.js +0 -227
  226. package/node_modules/@comis/memory/dist/observability-store.test.d.ts +0 -1
  227. package/node_modules/@comis/memory/dist/observability-store.test.js +0 -704
  228. package/node_modules/@comis/memory/dist/row-mapper.test.d.ts +0 -1
  229. package/node_modules/@comis/memory/dist/row-mapper.test.js +0 -409
  230. package/node_modules/@comis/memory/dist/schema.test.d.ts +0 -1
  231. package/node_modules/@comis/memory/dist/schema.test.js +0 -240
  232. package/node_modules/@comis/memory/dist/secret-store-schema.test.d.ts +0 -7
  233. package/node_modules/@comis/memory/dist/secret-store-schema.test.js +0 -90
  234. package/node_modules/@comis/memory/dist/session-store.test.d.ts +0 -1
  235. package/node_modules/@comis/memory/dist/session-store.test.js +0 -262
  236. package/node_modules/@comis/memory/dist/setup-secrets.test.d.ts +0 -1
  237. package/node_modules/@comis/memory/dist/setup-secrets.test.js +0 -140
  238. package/node_modules/@comis/memory/dist/sqlite-memory-adapter.test.d.ts +0 -1
  239. package/node_modules/@comis/memory/dist/sqlite-memory-adapter.test.js +0 -888
  240. package/node_modules/@comis/memory/dist/sqlite-secret-store.test.d.ts +0 -8
  241. package/node_modules/@comis/memory/dist/sqlite-secret-store.test.js +0 -245
  242. package/node_modules/@comis/shared/dist/abort.test.d.ts +0 -1
  243. package/node_modules/@comis/shared/dist/abort.test.js +0 -71
  244. package/node_modules/@comis/shared/dist/result.test.d.ts +0 -1
  245. package/node_modules/@comis/shared/dist/result.test.js +0 -178
  246. package/node_modules/@comis/shared/dist/suppress-error.test.d.ts +0 -1
  247. package/node_modules/@comis/shared/dist/suppress-error.test.js +0 -50
  248. package/node_modules/@comis/shared/dist/timeout.test.d.ts +0 -1
  249. package/node_modules/@comis/shared/dist/timeout.test.js +0 -75
  250. package/node_modules/@comis/shared/dist/ttl-cache.test.d.ts +0 -1
  251. package/node_modules/@comis/shared/dist/ttl-cache.test.js +0 -81
@@ -1,1003 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
- import { createApprovalGate } from "./approval-gate.js";
3
- import { TypedEventBus } from "../event-bus/bus.js";
4
- import { readFileSync } from "node:fs";
5
- import { fileURLToPath } from "node:url";
6
- import { dirname, join } from "node:path";
7
- // ---------------------------------------------------------------------------
8
- // Setup
9
- // ---------------------------------------------------------------------------
10
- let eventBus;
11
- let gate;
12
- const DEFAULT_TIMEOUT_MS = 5000;
13
- function makeRequest(overrides = {}) {
14
- return {
15
- toolName: overrides.toolName ?? "agents.restart",
16
- action: overrides.action ?? "agents.restart",
17
- params: overrides.params ?? { agentId: "bot-1" },
18
- agentId: overrides.agentId ?? "agent-1",
19
- sessionKey: overrides.sessionKey ?? "default:user1:discord",
20
- trustLevel: overrides.trustLevel ?? "user",
21
- };
22
- }
23
- beforeEach(() => {
24
- vi.useFakeTimers();
25
- eventBus = new TypedEventBus();
26
- gate = createApprovalGate({
27
- eventBus,
28
- getTimeoutMs: () => DEFAULT_TIMEOUT_MS,
29
- });
30
- });
31
- afterEach(() => {
32
- gate.dispose();
33
- vi.useRealTimers();
34
- });
35
- // ---------------------------------------------------------------------------
36
- // 1. Request creation
37
- // ---------------------------------------------------------------------------
38
- describe("request creation", () => {
39
- it("requestApproval adds request to pending list", () => {
40
- gate.requestApproval(makeRequest());
41
- expect(gate.pending()).toHaveLength(1);
42
- });
43
- it("requestApproval returns a promise that does not resolve immediately", () => {
44
- const promise = gate.requestApproval(makeRequest());
45
- // Promise should be pending (not resolved yet)
46
- let resolved = false;
47
- promise.then(() => { resolved = true; });
48
- // Flush microtask queue
49
- expect(resolved).toBe(false);
50
- });
51
- it("pending() returns request with all expected fields", () => {
52
- gate.requestApproval(makeRequest({
53
- toolName: "system.reboot",
54
- action: "system.reboot",
55
- params: { target: "host-1" },
56
- agentId: "agent-2",
57
- sessionKey: "default:admin:telegram",
58
- trustLevel: "admin",
59
- }));
60
- const [request] = gate.pending();
61
- expect(request).toBeDefined();
62
- expect(request.toolName).toBe("system.reboot");
63
- expect(request.action).toBe("system.reboot");
64
- expect(request.params).toEqual({ target: "host-1" });
65
- expect(request.agentId).toBe("agent-2");
66
- expect(request.sessionKey).toBe("default:admin:telegram");
67
- expect(request.trustLevel).toBe("admin");
68
- expect(request.requestId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
69
- expect(typeof request.createdAt).toBe("number");
70
- expect(request.timeoutMs).toBe(DEFAULT_TIMEOUT_MS);
71
- });
72
- it("getRequest(id) returns the correct pending request", () => {
73
- gate.requestApproval(makeRequest());
74
- const [pending] = gate.pending();
75
- const found = gate.getRequest(pending.requestId);
76
- expect(found).toBe(pending);
77
- });
78
- it("getRequest(unknown) returns undefined", () => {
79
- expect(gate.getRequest("nonexistent-id")).toBeUndefined();
80
- });
81
- });
82
- // ---------------------------------------------------------------------------
83
- // 2. Event emission -- approval:requested
84
- // ---------------------------------------------------------------------------
85
- describe("approval:requested event", () => {
86
- it("requestApproval emits approval:requested event on the event bus", () => {
87
- const handler = vi.fn();
88
- eventBus.on("approval:requested", handler);
89
- gate.requestApproval(makeRequest());
90
- expect(handler).toHaveBeenCalledOnce();
91
- });
92
- it("event payload contains all APPR-07 fields", () => {
93
- const handler = vi.fn();
94
- eventBus.on("approval:requested", handler);
95
- gate.requestApproval(makeRequest({
96
- toolName: "config.write",
97
- action: "config.write",
98
- params: { section: "agents" },
99
- agentId: "agent-3",
100
- sessionKey: "default:op1:slack",
101
- trustLevel: "admin",
102
- }));
103
- const payload = handler.mock.calls[0][0];
104
- expect(payload.requestId).toMatch(/^[0-9a-f-]{36}$/);
105
- expect(payload.toolName).toBe("config.write");
106
- expect(payload.action).toBe("config.write");
107
- expect(payload.params).toEqual({ section: "agents" });
108
- expect(payload.agentId).toBe("agent-3");
109
- expect(payload.sessionKey).toBe("default:op1:slack");
110
- expect(payload.trustLevel).toBe("admin");
111
- expect(typeof payload.createdAt).toBe("number");
112
- expect(payload.timeoutMs).toBe(DEFAULT_TIMEOUT_MS);
113
- });
114
- });
115
- // ---------------------------------------------------------------------------
116
- // 3. Resolve -- approve
117
- // ---------------------------------------------------------------------------
118
- describe("resolve -- approve", () => {
119
- it("resolveApproval with approved=true resolves the promise with approved=true", async () => {
120
- const promise = gate.requestApproval(makeRequest());
121
- const [pending] = gate.pending();
122
- gate.resolveApproval(pending.requestId, true, "operator");
123
- const result = await promise;
124
- expect(result.approved).toBe(true);
125
- expect(result.approvedBy).toBe("operator");
126
- expect(result.requestId).toBe(pending.requestId);
127
- expect(typeof result.resolvedAt).toBe("number");
128
- });
129
- it("after approval, request is removed from pending list", async () => {
130
- const promise = gate.requestApproval(makeRequest());
131
- const [pending] = gate.pending();
132
- gate.resolveApproval(pending.requestId, true, "admin");
133
- await promise;
134
- expect(gate.pending()).toHaveLength(0);
135
- });
136
- it("pending() returns empty array after sole request resolved", async () => {
137
- const promise = gate.requestApproval(makeRequest());
138
- const [pending] = gate.pending();
139
- gate.resolveApproval(pending.requestId, true, "admin");
140
- await promise;
141
- expect(gate.pending()).toEqual([]);
142
- });
143
- });
144
- // ---------------------------------------------------------------------------
145
- // 4. Resolve -- deny
146
- // ---------------------------------------------------------------------------
147
- describe("resolve -- deny", () => {
148
- it("resolveApproval with approved=false resolves with denial and reason", async () => {
149
- const promise = gate.requestApproval(makeRequest());
150
- const [pending] = gate.pending();
151
- gate.resolveApproval(pending.requestId, false, "operator", "Too risky");
152
- const result = await promise;
153
- expect(result.approved).toBe(false);
154
- expect(result.approvedBy).toBe("operator");
155
- expect(result.reason).toBe("Too risky");
156
- expect(result.requestId).toBe(pending.requestId);
157
- });
158
- it("after denial, request is removed from pending list", async () => {
159
- const promise = gate.requestApproval(makeRequest());
160
- const [pending] = gate.pending();
161
- gate.resolveApproval(pending.requestId, false, "operator", "Denied");
162
- await promise;
163
- expect(gate.pending()).toHaveLength(0);
164
- });
165
- });
166
- // ---------------------------------------------------------------------------
167
- // 5. Event emission -- approval:resolved
168
- // ---------------------------------------------------------------------------
169
- describe("approval:resolved event", () => {
170
- it("resolveApproval emits approval:resolved event", () => {
171
- const handler = vi.fn();
172
- eventBus.on("approval:resolved", handler);
173
- gate.requestApproval(makeRequest());
174
- const [pending] = gate.pending();
175
- gate.resolveApproval(pending.requestId, true, "admin");
176
- expect(handler).toHaveBeenCalledOnce();
177
- });
178
- it("event payload includes requestId, approved, approvedBy, reason, resolvedAt", () => {
179
- const handler = vi.fn();
180
- eventBus.on("approval:resolved", handler);
181
- gate.requestApproval(makeRequest());
182
- const [pending] = gate.pending();
183
- gate.resolveApproval(pending.requestId, false, "operator", "Suspicious");
184
- const payload = handler.mock.calls[0][0];
185
- expect(payload.requestId).toBe(pending.requestId);
186
- expect(payload.approved).toBe(false);
187
- expect(payload.approvedBy).toBe("operator");
188
- expect(payload.reason).toBe("Suspicious");
189
- expect(typeof payload.resolvedAt).toBe("number");
190
- });
191
- });
192
- // ---------------------------------------------------------------------------
193
- // 6. Timeout auto-deny
194
- // ---------------------------------------------------------------------------
195
- describe("timeout auto-deny", () => {
196
- it("unanswered request auto-denies after timeout", async () => {
197
- const promise = gate.requestApproval(makeRequest());
198
- // Advance time past the timeout
199
- vi.advanceTimersByTime(DEFAULT_TIMEOUT_MS + 1);
200
- const result = await promise;
201
- expect(result.approved).toBe(false);
202
- expect(result.approvedBy).toBe("system:timeout");
203
- expect(result.reason).toBe("Approval request timed out");
204
- });
205
- it("approval:resolved event emitted with approvedBy system:timeout", () => {
206
- const handler = vi.fn();
207
- eventBus.on("approval:resolved", handler);
208
- gate.requestApproval(makeRequest());
209
- vi.advanceTimersByTime(DEFAULT_TIMEOUT_MS + 1);
210
- const payload = handler.mock.calls[0][0];
211
- expect(payload.approved).toBe(false);
212
- expect(payload.approvedBy).toBe("system:timeout");
213
- });
214
- it("request removed from pending list after timeout", () => {
215
- gate.requestApproval(makeRequest());
216
- expect(gate.pending()).toHaveLength(1);
217
- vi.advanceTimersByTime(DEFAULT_TIMEOUT_MS + 1);
218
- expect(gate.pending()).toHaveLength(0);
219
- });
220
- });
221
- // ---------------------------------------------------------------------------
222
- // 7. Idempotent resolve
223
- // ---------------------------------------------------------------------------
224
- describe("idempotent resolve", () => {
225
- it("resolving an already-resolved request does not throw", async () => {
226
- const promise = gate.requestApproval(makeRequest());
227
- const [pending] = gate.pending();
228
- const id = pending.requestId;
229
- gate.resolveApproval(id, true, "admin");
230
- await promise;
231
- // Second resolve should be silently ignored
232
- expect(() => gate.resolveApproval(id, false, "admin2", "Late denial")).not.toThrow();
233
- });
234
- it("resolving a timed-out request does not throw", () => {
235
- gate.requestApproval(makeRequest());
236
- const [pending] = gate.pending();
237
- const id = pending.requestId;
238
- vi.advanceTimersByTime(DEFAULT_TIMEOUT_MS + 1);
239
- // Manual resolve after timeout should be silently ignored
240
- expect(() => gate.resolveApproval(id, true, "admin")).not.toThrow();
241
- });
242
- it("resolving an unknown requestId does not throw", () => {
243
- expect(() => gate.resolveApproval("unknown-id", true, "admin")).not.toThrow();
244
- });
245
- });
246
- // ---------------------------------------------------------------------------
247
- // 8. Multiple concurrent requests
248
- // ---------------------------------------------------------------------------
249
- describe("multiple concurrent requests", () => {
250
- it("two requests can be pending simultaneously", () => {
251
- gate.requestApproval(makeRequest({ toolName: "tool-a", action: "tool-a.run" }));
252
- gate.requestApproval(makeRequest({ toolName: "tool-b", action: "tool-b.run" }));
253
- expect(gate.pending()).toHaveLength(2);
254
- });
255
- it("resolving one does not affect the other", async () => {
256
- const promise1 = gate.requestApproval(makeRequest({ toolName: "tool-a", action: "tool-a.run" }));
257
- gate.requestApproval(makeRequest({ toolName: "tool-b", action: "tool-b.run" }));
258
- const pending = gate.pending();
259
- expect(pending).toHaveLength(2);
260
- const first = pending[0];
261
- gate.resolveApproval(first.requestId, true, "admin");
262
- await promise1;
263
- // Second request should still be pending
264
- expect(gate.pending()).toHaveLength(1);
265
- expect(gate.pending()[0].toolName).toBe("tool-b");
266
- });
267
- it("pending() returns correct count throughout lifecycle", async () => {
268
- expect(gate.pending()).toHaveLength(0);
269
- const p1 = gate.requestApproval(makeRequest({ toolName: "tool-1", action: "tool-1.run" }));
270
- expect(gate.pending()).toHaveLength(1);
271
- const p2 = gate.requestApproval(makeRequest({ toolName: "tool-2", action: "tool-2.run" }));
272
- expect(gate.pending()).toHaveLength(2);
273
- const p3 = gate.requestApproval(makeRequest({ toolName: "tool-3", action: "tool-3.run" }));
274
- expect(gate.pending()).toHaveLength(3);
275
- // Resolve first
276
- gate.resolveApproval(gate.pending()[0].requestId, true, "admin");
277
- await p1;
278
- expect(gate.pending()).toHaveLength(2);
279
- // Timeout second
280
- vi.advanceTimersByTime(DEFAULT_TIMEOUT_MS + 1);
281
- await p2;
282
- await p3;
283
- expect(gate.pending()).toHaveLength(0);
284
- });
285
- });
286
- // ---------------------------------------------------------------------------
287
- // 9. dispose()
288
- // ---------------------------------------------------------------------------
289
- describe("dispose", () => {
290
- it("dispose() clears all timers (no lingering timeouts)", () => {
291
- gate.requestApproval(makeRequest({ toolName: "tool-x" }));
292
- gate.requestApproval(makeRequest({ toolName: "tool-y" }));
293
- gate.dispose();
294
- // Advancing timers should not trigger any timeout auto-deny (timers cleared)
295
- const resolvedHandler = vi.fn();
296
- eventBus.on("approval:resolved", resolvedHandler);
297
- vi.advanceTimersByTime(DEFAULT_TIMEOUT_MS * 2);
298
- expect(resolvedHandler).not.toHaveBeenCalled();
299
- });
300
- it("pending() returns empty after dispose", () => {
301
- gate.requestApproval(makeRequest());
302
- expect(gate.pending()).toHaveLength(1);
303
- gate.dispose();
304
- expect(gate.pending()).toHaveLength(0);
305
- });
306
- });
307
- // ---------------------------------------------------------------------------
308
- // 10. Denial cache
309
- // ---------------------------------------------------------------------------
310
- describe("denial cache", () => {
311
- const DENIAL_CACHE_TTL = 30_000;
312
- let gateWithTtl;
313
- beforeEach(() => {
314
- gateWithTtl = createApprovalGate({
315
- eventBus,
316
- getTimeoutMs: () => DEFAULT_TIMEOUT_MS,
317
- getDenialCacheTtlMs: () => DENIAL_CACHE_TTL,
318
- getBatchApprovalTtlMs: () => 0, // Disable approval cache for denial cache tests
319
- });
320
- });
321
- afterEach(() => {
322
- gateWithTtl.dispose();
323
- });
324
- it("second requestApproval for same sessionKey+action returns cached denial instantly after first denial", async () => {
325
- // First request: create and deny
326
- const promise1 = gateWithTtl.requestApproval(makeRequest());
327
- const [pending1] = gateWithTtl.pending();
328
- gateWithTtl.resolveApproval(pending1.requestId, false, "operator", "Not allowed");
329
- const result1 = await promise1;
330
- expect(result1.approved).toBe(false);
331
- // Second request: same sessionKey + action should resolve instantly from cache
332
- const promise2 = gateWithTtl.requestApproval(makeRequest());
333
- const result2 = await promise2;
334
- expect(result2.approved).toBe(false);
335
- // Should NOT appear in pending list (resolved instantly from cache)
336
- expect(gateWithTtl.pending()).toHaveLength(0);
337
- });
338
- it("cached denial uses approvedBy 'system:cached-denial'", async () => {
339
- const promise1 = gateWithTtl.requestApproval(makeRequest());
340
- const [pending1] = gateWithTtl.pending();
341
- gateWithTtl.resolveApproval(pending1.requestId, false, "operator", "No");
342
- await promise1;
343
- const promise2 = gateWithTtl.requestApproval(makeRequest());
344
- const result2 = await promise2;
345
- expect(result2.approvedBy).toBe("system:cached-denial");
346
- expect(result2.reason).toContain("Auto-denied");
347
- expect(result2.reason).toContain("agents.restart");
348
- });
349
- it("cached denial expires after TTL and next request creates a real pending entry", async () => {
350
- const promise1 = gateWithTtl.requestApproval(makeRequest());
351
- const [pending1] = gateWithTtl.pending();
352
- gateWithTtl.resolveApproval(pending1.requestId, false, "operator", "No");
353
- await promise1;
354
- // Advance past denial cache TTL
355
- vi.advanceTimersByTime(DENIAL_CACHE_TTL + 1);
356
- // Next request should create a real pending entry (cache expired)
357
- gateWithTtl.requestApproval(makeRequest());
358
- expect(gateWithTtl.pending()).toHaveLength(1);
359
- });
360
- it("approval (not denial) does NOT populate the denial cache", async () => {
361
- // Approve the first request
362
- const promise1 = gateWithTtl.requestApproval(makeRequest());
363
- const [pending1] = gateWithTtl.pending();
364
- gateWithTtl.resolveApproval(pending1.requestId, true, "operator");
365
- await promise1;
366
- // Second request should create a real pending entry (no cache)
367
- gateWithTtl.requestApproval(makeRequest());
368
- expect(gateWithTtl.pending()).toHaveLength(1);
369
- });
370
- it("different action on same sessionKey is NOT cached", async () => {
371
- const promise1 = gateWithTtl.requestApproval(makeRequest({ action: "agents.create" }));
372
- const [pending1] = gateWithTtl.pending();
373
- gateWithTtl.resolveApproval(pending1.requestId, false, "operator", "No");
374
- await promise1;
375
- // Same sessionKey but different action — should create a real pending entry
376
- gateWithTtl.requestApproval(makeRequest({ action: "agents.delete" }));
377
- expect(gateWithTtl.pending()).toHaveLength(1);
378
- });
379
- it("clearDenialCache(sessionKey) removes entries for that session", async () => {
380
- const promise1 = gateWithTtl.requestApproval(makeRequest());
381
- const [pending1] = gateWithTtl.pending();
382
- gateWithTtl.resolveApproval(pending1.requestId, false, "operator", "No");
383
- await promise1;
384
- // Clear cache for this session
385
- gateWithTtl.clearDenialCache("default:user1:discord");
386
- // Next request should create a real pending entry (cache cleared)
387
- gateWithTtl.requestApproval(makeRequest());
388
- expect(gateWithTtl.pending()).toHaveLength(1);
389
- });
390
- it("clearDenialCache() with no args clears all entries", async () => {
391
- // Deny for two different sessions
392
- const promise1 = gateWithTtl.requestApproval(makeRequest({ sessionKey: "default:user1:discord" }));
393
- const [p1] = gateWithTtl.pending();
394
- gateWithTtl.resolveApproval(p1.requestId, false, "op", "No");
395
- await promise1;
396
- const promise2 = gateWithTtl.requestApproval(makeRequest({ sessionKey: "default:user2:telegram" }));
397
- const [p2] = gateWithTtl.pending();
398
- gateWithTtl.resolveApproval(p2.requestId, false, "op", "No");
399
- await promise2;
400
- // Clear all denial cache entries
401
- gateWithTtl.clearDenialCache();
402
- // Both sessions should create real pending entries
403
- gateWithTtl.requestApproval(makeRequest({ sessionKey: "default:user1:discord" }));
404
- gateWithTtl.requestApproval(makeRequest({ sessionKey: "default:user2:telegram" }));
405
- expect(gateWithTtl.pending()).toHaveLength(2);
406
- });
407
- it("dispose() clears denial cache", async () => {
408
- const promise1 = gateWithTtl.requestApproval(makeRequest());
409
- const [pending1] = gateWithTtl.pending();
410
- gateWithTtl.resolveApproval(pending1.requestId, false, "operator", "No");
411
- await promise1;
412
- gateWithTtl.dispose();
413
- // After dispose, create a fresh gate with same eventBus to test that
414
- // the old gate's denial cache was cleared. However, since denial cache
415
- // is internal to the gate instance, we test by creating a new request
416
- // on a NEW gate (the old gate's cache is irrelevant after dispose).
417
- // Instead, test that the old gate does not return cached denials after
418
- // dispose by checking that a new request on a fresh gate works normally.
419
- const freshGate = createApprovalGate({
420
- eventBus,
421
- getTimeoutMs: () => DEFAULT_TIMEOUT_MS,
422
- getDenialCacheTtlMs: () => DENIAL_CACHE_TTL,
423
- });
424
- freshGate.requestApproval(makeRequest());
425
- expect(freshGate.pending()).toHaveLength(1);
426
- freshGate.dispose();
427
- });
428
- it("cached denial emits approval:resolved event", async () => {
429
- const promise1 = gateWithTtl.requestApproval(makeRequest());
430
- const [pending1] = gateWithTtl.pending();
431
- gateWithTtl.resolveApproval(pending1.requestId, false, "operator", "No");
432
- await promise1;
433
- const resolvedHandler = vi.fn();
434
- eventBus.on("approval:resolved", resolvedHandler);
435
- const promise2 = gateWithTtl.requestApproval(makeRequest());
436
- await promise2;
437
- expect(resolvedHandler).toHaveBeenCalledOnce();
438
- const payload = resolvedHandler.mock.calls[0][0];
439
- expect(payload.approved).toBe(false);
440
- expect(payload.approvedBy).toBe("system:cached-denial");
441
- });
442
- it("timeout denial does NOT populate the denial cache", async () => {
443
- // First request: let it time out
444
- const promise1 = gateWithTtl.requestApproval(makeRequest());
445
- vi.advanceTimersByTime(DEFAULT_TIMEOUT_MS + 1);
446
- const result1 = await promise1;
447
- expect(result1.approved).toBe(false);
448
- expect(result1.approvedBy).toBe("system:timeout");
449
- // Second identical request: should create a real pending entry (no cached denial)
450
- gateWithTtl.requestApproval(makeRequest());
451
- expect(gateWithTtl.pending()).toHaveLength(1);
452
- });
453
- it("explicit denial still populates cache even after timeout fix", async () => {
454
- // First request: deny explicitly
455
- const promise1 = gateWithTtl.requestApproval(makeRequest());
456
- const [pending1] = gateWithTtl.pending();
457
- gateWithTtl.resolveApproval(pending1.requestId, false, "operator", "Nope");
458
- await promise1;
459
- // Second identical request: should return from cache
460
- const promise2 = gateWithTtl.requestApproval(makeRequest());
461
- const result2 = await promise2;
462
- expect(result2.approved).toBe(false);
463
- expect(result2.approvedBy).toBe("system:cached-denial");
464
- });
465
- });
466
- // ---------------------------------------------------------------------------
467
- // 11. Batch parallel requests
468
- // ---------------------------------------------------------------------------
469
- describe("batch parallel requests", () => {
470
- it("second request for same sessionKey::action joins existing pending instead of creating new entry", async () => {
471
- const promise1 = gate.requestApproval(makeRequest());
472
- const promise2 = gate.requestApproval(makeRequest());
473
- // Only one pending entry (second batched onto first)
474
- expect(gate.pending()).toHaveLength(1);
475
- // Resolve the single pending entry
476
- const [pending] = gate.pending();
477
- gate.resolveApproval(pending.requestId, true, "admin");
478
- const [result1, result2] = await Promise.all([promise1, promise2]);
479
- expect(result1.approved).toBe(true);
480
- expect(result1.approvedBy).toBe("admin");
481
- expect(result2.approved).toBe(true);
482
- expect(result2.approvedBy).toBe("admin");
483
- });
484
- it("batched follower resolves when primary is approved", async () => {
485
- const promise1 = gate.requestApproval(makeRequest());
486
- const promise2 = gate.requestApproval(makeRequest());
487
- const [pending] = gate.pending();
488
- gate.resolveApproval(pending.requestId, true, "operator");
489
- const [result1, result2] = await Promise.all([promise1, promise2]);
490
- expect(result1.approved).toBe(true);
491
- expect(result2.approved).toBe(true);
492
- });
493
- it("batched follower resolves when primary times out", async () => {
494
- const promise1 = gate.requestApproval(makeRequest());
495
- const promise2 = gate.requestApproval(makeRequest());
496
- vi.advanceTimersByTime(DEFAULT_TIMEOUT_MS + 1);
497
- const [result1, result2] = await Promise.all([promise1, promise2]);
498
- expect(result1.approved).toBe(false);
499
- expect(result1.approvedBy).toBe("system:timeout");
500
- expect(result2.approved).toBe(false);
501
- expect(result2.approvedBy).toBe("system:timeout");
502
- });
503
- it("different action on same sessionKey creates separate pending entries (no batching)", () => {
504
- gate.requestApproval(makeRequest({ action: "agents.create" }));
505
- gate.requestApproval(makeRequest({ action: "agents.delete" }));
506
- expect(gate.pending()).toHaveLength(2);
507
- });
508
- it("approval:requested event emitted only once for batched requests", () => {
509
- const handler = vi.fn();
510
- eventBus.on("approval:requested", handler);
511
- gate.requestApproval(makeRequest());
512
- gate.requestApproval(makeRequest());
513
- // Only the primary request emits the event; the follower joins silently
514
- expect(handler).toHaveBeenCalledOnce();
515
- });
516
- });
517
- // ---------------------------------------------------------------------------
518
- // 12. Serialization and restore
519
- // ---------------------------------------------------------------------------
520
- describe("serialization and restore", () => {
521
- it("serializePending() returns correct data for pending requests", () => {
522
- gate.requestApproval(makeRequest({ action: "agents.create", toolName: "agents_manage" }));
523
- gate.requestApproval(makeRequest({ action: "agents.delete", toolName: "agents_manage" }));
524
- const serialized = gate.serializePending();
525
- expect(serialized).toHaveLength(2);
526
- expect(serialized[0].action).toBe("agents.create");
527
- expect(serialized[0].toolName).toBe("agents_manage");
528
- expect(serialized[0].sessionKey).toBe("default:user1:discord");
529
- expect(serialized[0].trustLevel).toBe("user");
530
- expect(typeof serialized[0].requestId).toBe("string");
531
- expect(typeof serialized[0].createdAt).toBe("number");
532
- expect(serialized[0].timeoutMs).toBe(DEFAULT_TIMEOUT_MS);
533
- expect(serialized[1].action).toBe("agents.delete");
534
- });
535
- it("restorePending() restores with correct remaining timeout", async () => {
536
- const now = Date.now();
537
- const record = {
538
- requestId: "00000000-0000-0000-0000-000000000001",
539
- toolName: "agents_manage",
540
- action: "agents.create",
541
- params: { agent_id: "bot-1" },
542
- agentId: "agent-1",
543
- sessionKey: "default:user1:discord",
544
- trustLevel: "user",
545
- createdAt: now - 1000, // 1 second ago
546
- timeoutMs: 5000, // 5 second timeout -> 4 seconds remaining
547
- };
548
- const restored = gate.restorePending([record]);
549
- expect(restored).toBe(1);
550
- expect(gate.pending()).toHaveLength(1);
551
- expect(gate.pending()[0].requestId).toBe("00000000-0000-0000-0000-000000000001");
552
- // Advance 3999ms -- should still be pending (4000ms remaining)
553
- vi.advanceTimersByTime(3999);
554
- expect(gate.pending()).toHaveLength(1);
555
- // Advance 2 more ms -- should have timed out
556
- vi.advanceTimersByTime(2);
557
- expect(gate.pending()).toHaveLength(0);
558
- });
559
- it("restorePending() skips expired records", () => {
560
- const now = Date.now();
561
- const record = {
562
- requestId: "00000000-0000-0000-0000-000000000002",
563
- toolName: "agents_manage",
564
- action: "agents.create",
565
- params: { agent_id: "bot-2" },
566
- agentId: "agent-1",
567
- sessionKey: "default:user1:discord",
568
- trustLevel: "user",
569
- createdAt: now - 10000, // 10 seconds ago
570
- timeoutMs: 5000, // 5 second timeout -- already expired
571
- };
572
- const restored = gate.restorePending([record]);
573
- expect(restored).toBe(0);
574
- expect(gate.pending()).toHaveLength(0);
575
- });
576
- it("restorePending() emits approval:requested events for restored entries", () => {
577
- const handler = vi.fn();
578
- eventBus.on("approval:requested", handler);
579
- const now = Date.now();
580
- const record = {
581
- requestId: "00000000-0000-0000-0000-000000000003",
582
- toolName: "agents_manage",
583
- action: "agents.delete",
584
- params: { agent_id: "bot-3" },
585
- agentId: "agent-1",
586
- sessionKey: "default:user1:discord",
587
- trustLevel: "admin",
588
- createdAt: now - 500,
589
- timeoutMs: 10000,
590
- };
591
- gate.restorePending([record]);
592
- expect(handler).toHaveBeenCalledOnce();
593
- const payload = handler.mock.calls[0][0];
594
- expect(payload.requestId).toBe("00000000-0000-0000-0000-000000000003");
595
- expect(payload.action).toBe("agents.delete");
596
- expect(payload.toolName).toBe("agents_manage");
597
- });
598
- });
599
- // ---------------------------------------------------------------------------
600
- // 13. Dispose with system:shutdown
601
- // ---------------------------------------------------------------------------
602
- describe("dispose with system:shutdown", () => {
603
- it("dispose() resolves pending promises with system:shutdown denial", async () => {
604
- const promise = gate.requestApproval(makeRequest());
605
- gate.dispose();
606
- const result = await promise;
607
- expect(result.approved).toBe(false);
608
- expect(result.approvedBy).toBe("system:shutdown");
609
- expect(result.reason).toBe("Daemon shutting down");
610
- });
611
- it("system:shutdown denial does NOT populate denial cache", async () => {
612
- const DENIAL_CACHE_TTL = 30_000;
613
- const gateWithTtl = createApprovalGate({
614
- eventBus,
615
- getTimeoutMs: () => DEFAULT_TIMEOUT_MS,
616
- getDenialCacheTtlMs: () => DENIAL_CACHE_TTL,
617
- getBatchApprovalTtlMs: () => 0, // Disable approval cache for this test
618
- });
619
- // Create a request and resolve it with system:shutdown directly
620
- const promise = gateWithTtl.requestApproval(makeRequest());
621
- const [pending] = gateWithTtl.pending();
622
- gateWithTtl.resolveApproval(pending.requestId, false, "system:shutdown", "Shutting down");
623
- await promise;
624
- // Next identical request should create a real pending entry (not cached)
625
- gateWithTtl.requestApproval(makeRequest());
626
- expect(gateWithTtl.pending()).toHaveLength(1);
627
- gateWithTtl.dispose();
628
- });
629
- });
630
- // ---------------------------------------------------------------------------
631
- // 14. Approval cache
632
- // ---------------------------------------------------------------------------
633
- describe("approval cache", () => {
634
- const APPROVAL_CACHE_TTL = 15_000;
635
- let gateWithApprovalCache;
636
- beforeEach(() => {
637
- gateWithApprovalCache = createApprovalGate({
638
- eventBus,
639
- getTimeoutMs: () => DEFAULT_TIMEOUT_MS,
640
- getDenialCacheTtlMs: () => 30_000,
641
- getBatchApprovalTtlMs: () => APPROVAL_CACHE_TTL,
642
- });
643
- });
644
- afterEach(() => {
645
- gateWithApprovalCache.dispose();
646
- });
647
- // -- Cache hit tests --
648
- it("second requestApproval for same sessionKey+action returns cached approval instantly after first approval", async () => {
649
- // First request: create and approve
650
- const promise1 = gateWithApprovalCache.requestApproval(makeRequest());
651
- const [pending1] = gateWithApprovalCache.pending();
652
- gateWithApprovalCache.resolveApproval(pending1.requestId, true, "operator");
653
- const result1 = await promise1;
654
- expect(result1.approved).toBe(true);
655
- // Second request: same sessionKey + action should resolve instantly from cache
656
- const promise2 = gateWithApprovalCache.requestApproval(makeRequest());
657
- const result2 = await promise2;
658
- expect(result2.approved).toBe(true);
659
- // Should NOT appear in pending list (resolved instantly from cache)
660
- expect(gateWithApprovalCache.pending()).toHaveLength(0);
661
- });
662
- it("cached approval uses approvedBy 'system:cached-approval'", async () => {
663
- const promise1 = gateWithApprovalCache.requestApproval(makeRequest());
664
- const [pending1] = gateWithApprovalCache.pending();
665
- gateWithApprovalCache.resolveApproval(pending1.requestId, true, "operator");
666
- await promise1;
667
- const promise2 = gateWithApprovalCache.requestApproval(makeRequest());
668
- const result2 = await promise2;
669
- expect(result2.approvedBy).toBe("system:cached-approval");
670
- expect(result2.reason).toContain("Auto-approved");
671
- expect(result2.reason).toContain("agents.restart");
672
- });
673
- it("cached approval emits approval:resolved event", async () => {
674
- const promise1 = gateWithApprovalCache.requestApproval(makeRequest());
675
- const [pending1] = gateWithApprovalCache.pending();
676
- gateWithApprovalCache.resolveApproval(pending1.requestId, true, "operator");
677
- await promise1;
678
- const resolvedHandler = vi.fn();
679
- eventBus.on("approval:resolved", resolvedHandler);
680
- const promise2 = gateWithApprovalCache.requestApproval(makeRequest());
681
- await promise2;
682
- expect(resolvedHandler).toHaveBeenCalledOnce();
683
- const payload = resolvedHandler.mock.calls[0][0];
684
- expect(payload.approved).toBe(true);
685
- expect(payload.approvedBy).toBe("system:cached-approval");
686
- });
687
- // -- Cache miss tests --
688
- it("cached approval expires after TTL and next request creates a real pending entry", async () => {
689
- const promise1 = gateWithApprovalCache.requestApproval(makeRequest());
690
- const [pending1] = gateWithApprovalCache.pending();
691
- gateWithApprovalCache.resolveApproval(pending1.requestId, true, "operator");
692
- await promise1;
693
- // Advance past approval cache TTL
694
- vi.advanceTimersByTime(APPROVAL_CACHE_TTL + 1);
695
- // Next request should create a real pending entry (cache expired)
696
- gateWithApprovalCache.requestApproval(makeRequest());
697
- expect(gateWithApprovalCache.pending()).toHaveLength(1);
698
- });
699
- it("different action on same sessionKey is NOT cached", async () => {
700
- const promise1 = gateWithApprovalCache.requestApproval(makeRequest({ action: "agents.create" }));
701
- const [pending1] = gateWithApprovalCache.pending();
702
- gateWithApprovalCache.resolveApproval(pending1.requestId, true, "operator");
703
- await promise1;
704
- // Same sessionKey but different action -- should create a real pending entry
705
- gateWithApprovalCache.requestApproval(makeRequest({ action: "agents.delete" }));
706
- expect(gateWithApprovalCache.pending()).toHaveLength(1);
707
- });
708
- it("denial does NOT populate the approval cache", async () => {
709
- // Deny the first request
710
- const promise1 = gateWithApprovalCache.requestApproval(makeRequest());
711
- const [pending1] = gateWithApprovalCache.pending();
712
- gateWithApprovalCache.resolveApproval(pending1.requestId, false, "operator", "Not allowed");
713
- await promise1;
714
- // Clear denial cache so we can test approval cache in isolation
715
- gateWithApprovalCache.clearDenialCache();
716
- // Second request should create a real pending entry (no cached approval)
717
- gateWithApprovalCache.requestApproval(makeRequest());
718
- expect(gateWithApprovalCache.pending()).toHaveLength(1);
719
- });
720
- // -- Disable test --
721
- it("batchApprovalTtlMs=0 disables approval cache entirely", async () => {
722
- const disabledGate = createApprovalGate({
723
- eventBus,
724
- getTimeoutMs: () => DEFAULT_TIMEOUT_MS,
725
- getBatchApprovalTtlMs: () => 0,
726
- });
727
- // Approve first request
728
- const promise1 = disabledGate.requestApproval(makeRequest());
729
- const [pending1] = disabledGate.pending();
730
- disabledGate.resolveApproval(pending1.requestId, true, "operator");
731
- await promise1;
732
- // Second request should create a real pending entry (cache disabled)
733
- disabledGate.requestApproval(makeRequest());
734
- expect(disabledGate.pending()).toHaveLength(1);
735
- disabledGate.dispose();
736
- });
737
- // -- Clear tests --
738
- it("clearApprovalCache(sessionKey) removes entries for that session", async () => {
739
- const promise1 = gateWithApprovalCache.requestApproval(makeRequest());
740
- const [pending1] = gateWithApprovalCache.pending();
741
- gateWithApprovalCache.resolveApproval(pending1.requestId, true, "operator");
742
- await promise1;
743
- // Clear cache for this session
744
- gateWithApprovalCache.clearApprovalCache("default:user1:discord");
745
- // Next request should create a real pending entry (cache cleared)
746
- gateWithApprovalCache.requestApproval(makeRequest());
747
- expect(gateWithApprovalCache.pending()).toHaveLength(1);
748
- });
749
- it("clearApprovalCache() with no args clears all entries", async () => {
750
- // Approve for two different sessions
751
- const promise1 = gateWithApprovalCache.requestApproval(makeRequest({ sessionKey: "default:user1:discord" }));
752
- const [p1] = gateWithApprovalCache.pending();
753
- gateWithApprovalCache.resolveApproval(p1.requestId, true, "op");
754
- await promise1;
755
- const promise2 = gateWithApprovalCache.requestApproval(makeRequest({ sessionKey: "default:user2:telegram" }));
756
- const [p2] = gateWithApprovalCache.pending();
757
- gateWithApprovalCache.resolveApproval(p2.requestId, true, "op");
758
- await promise2;
759
- // Clear all approval cache entries
760
- gateWithApprovalCache.clearApprovalCache();
761
- // Both sessions should create real pending entries
762
- gateWithApprovalCache.requestApproval(makeRequest({ sessionKey: "default:user1:discord" }));
763
- gateWithApprovalCache.requestApproval(makeRequest({ sessionKey: "default:user2:telegram" }));
764
- expect(gateWithApprovalCache.pending()).toHaveLength(2);
765
- });
766
- // -- Dispose test --
767
- it("dispose() clears approval cache", async () => {
768
- const promise1 = gateWithApprovalCache.requestApproval(makeRequest());
769
- const [pending1] = gateWithApprovalCache.pending();
770
- gateWithApprovalCache.resolveApproval(pending1.requestId, true, "operator");
771
- await promise1;
772
- gateWithApprovalCache.dispose();
773
- // After dispose, create a fresh gate to verify old cache is gone
774
- const freshGate = createApprovalGate({
775
- eventBus,
776
- getTimeoutMs: () => DEFAULT_TIMEOUT_MS,
777
- getBatchApprovalTtlMs: () => APPROVAL_CACHE_TTL,
778
- });
779
- freshGate.requestApproval(makeRequest());
780
- expect(freshGate.pending()).toHaveLength(1);
781
- freshGate.dispose();
782
- });
783
- // -- Source-level mutual invalidation tests --
784
- // These tests read the source .ts file directly to verify structural correctness
785
- // of mutual invalidation branches that are hard to test purely behaviorally.
786
- const sourceFilePath = join(dirname(fileURLToPath(import.meta.url)), "approval-gate.ts");
787
- it("resolveApproval approved path calls denialCache.delete (source-level)", () => {
788
- const source = readFileSync(sourceFilePath, "utf-8");
789
- // In the approved branch, denialCache.delete should be called for mutual invalidation
790
- expect(source).toContain("denialCache.delete(cacheKey)");
791
- });
792
- it("resolveApproval explicit deny path calls approvalCache.delete (source-level)", () => {
793
- const source = readFileSync(sourceFilePath, "utf-8");
794
- // In the explicit deny branch, approvalCache.delete should be called for mutual invalidation
795
- expect(source).toContain("approvalCache.delete(cacheKey)");
796
- });
797
- it("resolveApproval timeout path calls approvalCache.delete (source-level)", () => {
798
- const source = readFileSync(sourceFilePath, "utf-8");
799
- // The timeout branch should also delete from approvalCache
800
- // Verify by checking that system:timeout branch contains approvalCache.delete
801
- const timeoutSection = source.split('"system:timeout"')[1];
802
- expect(timeoutSection).toBeDefined();
803
- expect(timeoutSection.substring(0, 200)).toContain("approvalCache.delete");
804
- });
805
- it("resolveApproval shutdown path does NOT call approvalCache.delete (source-level)", () => {
806
- const source = readFileSync(sourceFilePath, "utf-8");
807
- // The shutdown branch should NOT touch approvalCache
808
- // Extract the shutdown block between system:shutdown and system:timeout in resolveApproval
809
- const shutdownStart = source.indexOf('"system:shutdown"');
810
- const timeoutStart = source.indexOf('"system:timeout"');
811
- expect(shutdownStart).toBeGreaterThan(-1);
812
- expect(timeoutStart).toBeGreaterThan(-1);
813
- const shutdownBlock = source.substring(shutdownStart, timeoutStart);
814
- expect(shutdownBlock).not.toContain("approvalCache.delete");
815
- });
816
- it("cached approval does NOT re-populate cache (no infinite TTL extension) (source-level)", () => {
817
- const source = readFileSync(sourceFilePath, "utf-8");
818
- // Verify the guard: approvedBy !== "system:cached-approval" before populating approval cache
819
- expect(source).toContain('!== "system:cached-approval"');
820
- });
821
- });
822
- // ---------------------------------------------------------------------------
823
- // 15. Approval cache serialization and logging (Phase 437)
824
- // ---------------------------------------------------------------------------
825
- describe("approval cache serialization and logging", () => {
826
- const APPROVAL_CACHE_TTL = 15_000;
827
- let gateWithApprovalCache;
828
- beforeEach(() => {
829
- gateWithApprovalCache = createApprovalGate({
830
- eventBus,
831
- getTimeoutMs: () => DEFAULT_TIMEOUT_MS,
832
- getDenialCacheTtlMs: () => 30_000,
833
- getBatchApprovalTtlMs: () => APPROVAL_CACHE_TTL,
834
- });
835
- });
836
- afterEach(() => {
837
- gateWithApprovalCache.dispose();
838
- });
839
- // -- Serialization tests (SERIAL-01, TEST-02) --
840
- it("serializeApprovalCache returns empty array when no cached entries", () => {
841
- const entries = gateWithApprovalCache.serializeApprovalCache();
842
- expect(entries).toEqual([]);
843
- });
844
- it("serializeApprovalCache returns populated entries after approval", async () => {
845
- const promise = gateWithApprovalCache.requestApproval(makeRequest());
846
- const [pending] = gateWithApprovalCache.pending();
847
- gateWithApprovalCache.resolveApproval(pending.requestId, true, "operator");
848
- await promise;
849
- const entries = gateWithApprovalCache.serializeApprovalCache();
850
- expect(entries).toHaveLength(1);
851
- expect(entries[0].cacheKey).toBe("default:user1:discord::agents.restart");
852
- expect(entries[0].resolution.approved).toBe(true);
853
- expect(entries[0].resolution.approvedBy).toBe("operator");
854
- expect(entries[0].expiresAt).toBeGreaterThan(Date.now());
855
- });
856
- it("serializeApprovalCache skips expired entries", async () => {
857
- const promise = gateWithApprovalCache.requestApproval(makeRequest());
858
- const [pending] = gateWithApprovalCache.pending();
859
- gateWithApprovalCache.resolveApproval(pending.requestId, true, "operator");
860
- await promise;
861
- // Advance time past TTL
862
- vi.advanceTimersByTime(APPROVAL_CACHE_TTL + 1);
863
- const entries = gateWithApprovalCache.serializeApprovalCache();
864
- expect(entries).toEqual([]);
865
- });
866
- it("restoreApprovalCache restores valid entries and auto-approves subsequent requests", async () => {
867
- const entry = {
868
- cacheKey: "default:user1:discord::agents.restart",
869
- resolution: {
870
- requestId: "00000000-0000-0000-0000-000000000099",
871
- approved: true,
872
- approvedBy: "operator",
873
- resolvedAt: Date.now(),
874
- },
875
- expiresAt: Date.now() + 15_000,
876
- };
877
- const restored = gateWithApprovalCache.restoreApprovalCache([entry]);
878
- expect(restored).toBe(1);
879
- // Submit same request -- should resolve from cache
880
- const promise = gateWithApprovalCache.requestApproval(makeRequest());
881
- const result = await promise;
882
- expect(result.approved).toBe(true);
883
- expect(result.approvedBy).toBe("system:cached-approval");
884
- expect(gateWithApprovalCache.pending()).toHaveLength(0);
885
- });
886
- it("restoreApprovalCache skips entries with expired expiresAt", () => {
887
- const entry = {
888
- cacheKey: "default:user1:discord::agents.restart",
889
- resolution: {
890
- requestId: "00000000-0000-0000-0000-000000000098",
891
- approved: true,
892
- approvedBy: "operator",
893
- resolvedAt: Date.now() - 20_000,
894
- },
895
- expiresAt: Date.now() - 1000,
896
- };
897
- const restored = gateWithApprovalCache.restoreApprovalCache([entry]);
898
- expect(restored).toBe(0);
899
- // Submit request -- should go to pending (not cached)
900
- gateWithApprovalCache.requestApproval(makeRequest());
901
- expect(gateWithApprovalCache.pending()).toHaveLength(1);
902
- });
903
- it("serialize then restore round-trip preserves cache behavior", async () => {
904
- // Create and approve on first gate
905
- const promise1 = gateWithApprovalCache.requestApproval(makeRequest());
906
- const [pending1] = gateWithApprovalCache.pending();
907
- gateWithApprovalCache.resolveApproval(pending1.requestId, true, "operator");
908
- await promise1;
909
- // Serialize from first gate
910
- const entries = gateWithApprovalCache.serializeApprovalCache();
911
- expect(entries).toHaveLength(1);
912
- // Create a NEW gate and restore
913
- const newGate = createApprovalGate({
914
- eventBus,
915
- getTimeoutMs: () => DEFAULT_TIMEOUT_MS,
916
- getDenialCacheTtlMs: () => 30_000,
917
- getBatchApprovalTtlMs: () => APPROVAL_CACHE_TTL,
918
- });
919
- const restored = newGate.restoreApprovalCache(entries);
920
- expect(restored).toBe(1);
921
- // Submit same request on new gate -- should auto-approve from restored cache
922
- const promise2 = newGate.requestApproval(makeRequest());
923
- const result2 = await promise2;
924
- expect(result2.approved).toBe(true);
925
- expect(result2.approvedBy).toBe("system:cached-approval");
926
- newGate.dispose();
927
- });
928
- // -- Shutdown ordering test (SERIAL-02) --
929
- it("dispose clears approval cache -- serialization must happen before dispose", async () => {
930
- const promise = gateWithApprovalCache.requestApproval(makeRequest());
931
- const [pending] = gateWithApprovalCache.pending();
932
- gateWithApprovalCache.resolveApproval(pending.requestId, true, "operator");
933
- await promise;
934
- // Before dispose: serialization returns entries
935
- const before = gateWithApprovalCache.serializeApprovalCache();
936
- expect(before).toHaveLength(1);
937
- // After dispose: serialization returns empty
938
- gateWithApprovalCache.dispose();
939
- const after = gateWithApprovalCache.serializeApprovalCache();
940
- expect(after).toEqual([]);
941
- });
942
- // -- Cache logging tests (OBS-03) --
943
- it("logger.debug called on approval cache hit with cacheKey and ttlRemainingMs", async () => {
944
- const debugFn = vi.fn();
945
- const gateWithLogger = createApprovalGate({
946
- eventBus,
947
- getTimeoutMs: () => DEFAULT_TIMEOUT_MS,
948
- getDenialCacheTtlMs: () => 30_000,
949
- getBatchApprovalTtlMs: () => APPROVAL_CACHE_TTL,
950
- logger: { debug: debugFn },
951
- });
952
- // First: approve
953
- const promise1 = gateWithLogger.requestApproval(makeRequest());
954
- const [pending1] = gateWithLogger.pending();
955
- gateWithLogger.resolveApproval(pending1.requestId, true, "operator");
956
- await promise1;
957
- // Second: cache hit
958
- const promise2 = gateWithLogger.requestApproval(makeRequest());
959
- await promise2;
960
- expect(debugFn).toHaveBeenCalledWith(expect.objectContaining({
961
- cacheKey: "default:user1:discord::agents.restart",
962
- action: "agents.restart",
963
- }), "Approval cache hit");
964
- gateWithLogger.dispose();
965
- });
966
- it("expired approval cache entry does not produce cache hit", async () => {
967
- const debugFn = vi.fn();
968
- const gateWithLogger = createApprovalGate({
969
- eventBus,
970
- getTimeoutMs: () => DEFAULT_TIMEOUT_MS,
971
- getDenialCacheTtlMs: () => 30_000,
972
- getBatchApprovalTtlMs: () => APPROVAL_CACHE_TTL,
973
- logger: { debug: debugFn },
974
- });
975
- // First: approve
976
- const promise1 = gateWithLogger.requestApproval(makeRequest());
977
- const [pending1] = gateWithLogger.pending();
978
- gateWithLogger.resolveApproval(pending1.requestId, true, "operator");
979
- await promise1;
980
- // Advance past TTL
981
- vi.advanceTimersByTime(APPROVAL_CACHE_TTL + 1);
982
- // Submit same request -- TTLCache auto-evicts expired entry, so no cache hit
983
- gateWithLogger.requestApproval(makeRequest());
984
- // No "Approval cache hit" debug log should be emitted for the second request
985
- const cacheHitCalls = debugFn.mock.calls.filter((call) => call[1] === "Approval cache hit");
986
- expect(cacheHitCalls).toHaveLength(0);
987
- // The request should be pending (not auto-resolved from cache)
988
- expect(gateWithLogger.pending()).toHaveLength(1);
989
- gateWithLogger.dispose();
990
- });
991
- it("no logger crash when logger dep is omitted", async () => {
992
- // This gate uses the beforeEach gate which has NO logger dep
993
- const promise1 = gateWithApprovalCache.requestApproval(makeRequest());
994
- const [pending1] = gateWithApprovalCache.pending();
995
- gateWithApprovalCache.resolveApproval(pending1.requestId, true, "operator");
996
- await promise1;
997
- // Second request: cache hit -- should not throw even without logger
998
- const promise2 = gateWithApprovalCache.requestApproval(makeRequest());
999
- const result2 = await promise2;
1000
- expect(result2.approved).toBe(true);
1001
- expect(result2.approvedBy).toBe("system:cached-approval");
1002
- });
1003
- });