@vellumai/assistant 0.4.2 → 0.4.4

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 (221) hide show
  1. package/.env.example +3 -0
  2. package/ARCHITECTURE.md +124 -10
  3. package/README.md +43 -35
  4. package/docs/trusted-contact-access.md +20 -0
  5. package/package.json +1 -1
  6. package/scripts/ipc/generate-swift.ts +1 -0
  7. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +58 -120
  8. package/src/__tests__/access-request-decision.test.ts +0 -1
  9. package/src/__tests__/actor-token-service.test.ts +1099 -0
  10. package/src/__tests__/agent-loop.test.ts +51 -0
  11. package/src/__tests__/approval-routes-http.test.ts +2 -0
  12. package/src/__tests__/assistant-events-sse-hardening.test.ts +7 -5
  13. package/src/__tests__/assistant-id-boundary-guard.test.ts +415 -0
  14. package/src/__tests__/call-controller.test.ts +49 -0
  15. package/src/__tests__/call-pointer-message-composer.test.ts +171 -0
  16. package/src/__tests__/call-pointer-messages.test.ts +93 -3
  17. package/src/__tests__/call-pointer-no-hardcoded-copy.guard.test.ts +42 -0
  18. package/src/__tests__/call-routes-http.test.ts +0 -25
  19. package/src/__tests__/callback-handoff-copy.test.ts +186 -0
  20. package/src/__tests__/channel-approval-routes.test.ts +133 -12
  21. package/src/__tests__/channel-guardian.test.ts +0 -86
  22. package/src/__tests__/channel-readiness-service.test.ts +10 -16
  23. package/src/__tests__/checker.test.ts +33 -12
  24. package/src/__tests__/config-schema.test.ts +6 -0
  25. package/src/__tests__/confirmation-request-guardian-bridge.test.ts +410 -0
  26. package/src/__tests__/conversation-routes-guardian-reply.test.ts +256 -0
  27. package/src/__tests__/conversation-routes.test.ts +12 -3
  28. package/src/__tests__/credential-security-invariants.test.ts +1 -1
  29. package/src/__tests__/daemon-server-session-init.test.ts +4 -0
  30. package/src/__tests__/deterministic-verification-control-plane.test.ts +0 -1
  31. package/src/__tests__/guardian-actions-endpoint.test.ts +39 -13
  32. package/src/__tests__/guardian-dispatch.test.ts +8 -0
  33. package/src/__tests__/guardian-outbound-http.test.ts +4 -5
  34. package/src/__tests__/guardian-question-mode.test.ts +200 -0
  35. package/src/__tests__/guardian-routing-invariants.test.ts +178 -0
  36. package/src/__tests__/guardian-routing-state.test.ts +525 -0
  37. package/src/__tests__/handle-user-message-secret-resume.test.ts +2 -0
  38. package/src/__tests__/handlers-telegram-config.test.ts +0 -83
  39. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +55 -0
  40. package/src/__tests__/headless-browser-navigate.test.ts +2 -0
  41. package/src/__tests__/inbound-invite-redemption.test.ts +0 -1
  42. package/src/__tests__/ingress-routes-http.test.ts +55 -0
  43. package/src/__tests__/ipc-snapshot.test.ts +18 -51
  44. package/src/__tests__/non-member-access-request.test.ts +159 -9
  45. package/src/__tests__/notification-decision-fallback.test.ts +129 -4
  46. package/src/__tests__/notification-decision-strategy.test.ts +106 -2
  47. package/src/__tests__/notification-guardian-path.test.ts +3 -0
  48. package/src/__tests__/recording-intent-handler.test.ts +1 -0
  49. package/src/__tests__/relay-server.test.ts +1475 -33
  50. package/src/__tests__/send-endpoint-busy.test.ts +5 -0
  51. package/src/__tests__/session-agent-loop.test.ts +1 -0
  52. package/src/__tests__/session-confirmation-signals.test.ts +523 -0
  53. package/src/__tests__/session-init.benchmark.test.ts +0 -2
  54. package/src/__tests__/session-runtime-assembly.test.ts +4 -1
  55. package/src/__tests__/session-surfaces-task-progress.test.ts +44 -1
  56. package/src/__tests__/session-tool-setup-app-refresh.test.ts +81 -2
  57. package/src/__tests__/session-tool-setup-memory-scope.test.ts +1 -1
  58. package/src/__tests__/session-tool-setup-side-effect-flag.test.ts +1 -1
  59. package/src/__tests__/tool-executor.test.ts +21 -2
  60. package/src/__tests__/tool-grant-request-escalation.test.ts +333 -27
  61. package/src/__tests__/trusted-contact-approval-notifier.test.ts +678 -0
  62. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +1064 -0
  63. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +11 -1
  64. package/src/__tests__/trusted-contact-multichannel.test.ts +0 -1
  65. package/src/__tests__/trusted-contact-verification.test.ts +0 -1
  66. package/src/__tests__/twilio-config.test.ts +2 -13
  67. package/src/__tests__/twilio-routes.test.ts +4 -3
  68. package/src/__tests__/update-bulletin.test.ts +0 -1
  69. package/src/agent/loop.ts +1 -1
  70. package/src/approvals/guardian-decision-primitive.ts +12 -3
  71. package/src/approvals/guardian-request-resolvers.ts +169 -11
  72. package/src/calls/call-constants.ts +29 -0
  73. package/src/calls/call-controller.ts +11 -3
  74. package/src/calls/call-domain.ts +33 -11
  75. package/src/calls/call-pointer-message-composer.ts +154 -0
  76. package/src/calls/call-pointer-messages.ts +106 -27
  77. package/src/calls/guardian-dispatch.ts +4 -2
  78. package/src/calls/relay-server.ts +921 -112
  79. package/src/calls/twilio-config.ts +4 -11
  80. package/src/calls/twilio-routes.ts +4 -6
  81. package/src/calls/types.ts +3 -1
  82. package/src/calls/voice-session-bridge.ts +4 -3
  83. package/src/cli/core-commands.ts +7 -4
  84. package/src/cli.ts +5 -4
  85. package/src/config/bundled-skills/agentmail/SKILL.md +4 -0
  86. package/src/config/bundled-skills/app-builder/SKILL.md +309 -10
  87. package/src/config/bundled-skills/app-builder/TOOLS.json +1 -1
  88. package/src/config/bundled-skills/email-setup/SKILL.md +1 -1
  89. package/src/config/bundled-skills/google-oauth-setup/SKILL.md +105 -81
  90. package/src/config/bundled-skills/messaging/SKILL.md +61 -12
  91. package/src/config/bundled-skills/messaging/TOOLS.json +58 -0
  92. package/src/config/bundled-skills/messaging/tools/gmail-sender-digest.ts +6 -1
  93. package/src/config/bundled-skills/messaging/tools/messaging-archive-by-sender.ts +35 -0
  94. package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +52 -0
  95. package/src/config/bundled-skills/phone-calls/SKILL.md +30 -39
  96. package/src/config/bundled-skills/twitter/SKILL.md +3 -3
  97. package/src/config/bundled-skills/vercel-token-setup/SKILL.md +215 -0
  98. package/src/config/calls-schema.ts +36 -0
  99. package/src/config/env.ts +22 -0
  100. package/src/config/feature-flag-registry.json +8 -8
  101. package/src/config/schema.ts +2 -2
  102. package/src/config/skills.ts +11 -0
  103. package/src/config/system-prompt.ts +11 -1
  104. package/src/config/templates/SOUL.md +2 -0
  105. package/src/config/vellum-skills/sms-setup/SKILL.md +71 -82
  106. package/src/config/vellum-skills/trusted-contacts/SKILL.md +8 -1
  107. package/src/config/vellum-skills/twilio-setup/SKILL.md +88 -73
  108. package/src/daemon/call-pointer-generators.ts +59 -0
  109. package/src/daemon/computer-use-session.ts +2 -5
  110. package/src/daemon/handlers/apps.ts +76 -20
  111. package/src/daemon/handlers/config-channels.ts +9 -61
  112. package/src/daemon/handlers/config-inbox.ts +11 -3
  113. package/src/daemon/handlers/config-ingress.ts +28 -3
  114. package/src/daemon/handlers/config-telegram.ts +12 -0
  115. package/src/daemon/handlers/config.ts +2 -6
  116. package/src/daemon/handlers/index.ts +2 -1
  117. package/src/daemon/handlers/pairing.ts +2 -0
  118. package/src/daemon/handlers/publish.ts +11 -46
  119. package/src/daemon/handlers/sessions.ts +59 -5
  120. package/src/daemon/handlers/shared.ts +17 -2
  121. package/src/daemon/ipc-contract/apps.ts +1 -0
  122. package/src/daemon/ipc-contract/inbox.ts +4 -0
  123. package/src/daemon/ipc-contract/integrations.ts +1 -97
  124. package/src/daemon/ipc-contract/messages.ts +47 -1
  125. package/src/daemon/ipc-contract/notifications.ts +11 -0
  126. package/src/daemon/ipc-contract-inventory.json +2 -4
  127. package/src/daemon/lifecycle.ts +17 -0
  128. package/src/daemon/server.ts +16 -2
  129. package/src/daemon/session-agent-loop-handlers.ts +20 -0
  130. package/src/daemon/session-agent-loop.ts +24 -12
  131. package/src/daemon/session-lifecycle.ts +1 -1
  132. package/src/daemon/session-process.ts +11 -1
  133. package/src/daemon/session-runtime-assembly.ts +6 -1
  134. package/src/daemon/session-surfaces.ts +32 -3
  135. package/src/daemon/session.ts +88 -1
  136. package/src/daemon/tool-side-effects.ts +22 -0
  137. package/src/home-base/prebuilt/brain-graph.html +1483 -0
  138. package/src/home-base/prebuilt/index.html +40 -0
  139. package/src/inbound/platform-callback-registration.ts +157 -0
  140. package/src/memory/canonical-guardian-store.ts +1 -1
  141. package/src/memory/conversation-crud.ts +2 -1
  142. package/src/memory/conversation-title-service.ts +16 -2
  143. package/src/memory/db-init.ts +8 -0
  144. package/src/memory/delivery-crud.ts +2 -1
  145. package/src/memory/guardian-action-store.ts +2 -1
  146. package/src/memory/guardian-approvals.ts +3 -2
  147. package/src/memory/ingress-invite-store.ts +12 -2
  148. package/src/memory/ingress-member-store.ts +4 -3
  149. package/src/memory/migrations/038-actor-token-records.ts +39 -0
  150. package/src/memory/migrations/124-voice-invite-display-metadata.ts +14 -0
  151. package/src/memory/migrations/index.ts +2 -0
  152. package/src/memory/schema.ts +26 -5
  153. package/src/messaging/provider-types.ts +24 -0
  154. package/src/messaging/provider.ts +7 -0
  155. package/src/messaging/providers/gmail/adapter.ts +127 -0
  156. package/src/messaging/providers/sms/adapter.ts +40 -37
  157. package/src/notifications/adapters/macos.ts +45 -2
  158. package/src/notifications/broadcaster.ts +16 -0
  159. package/src/notifications/copy-composer.ts +50 -2
  160. package/src/notifications/decision-engine.ts +22 -9
  161. package/src/notifications/destination-resolver.ts +16 -2
  162. package/src/notifications/emit-signal.ts +18 -9
  163. package/src/notifications/guardian-question-mode.ts +419 -0
  164. package/src/notifications/signal.ts +14 -3
  165. package/src/permissions/checker.ts +13 -1
  166. package/src/permissions/prompter.ts +14 -0
  167. package/src/providers/anthropic/client.ts +20 -0
  168. package/src/providers/provider-send-message.ts +15 -3
  169. package/src/runtime/access-request-helper.ts +82 -4
  170. package/src/runtime/actor-token-service.ts +234 -0
  171. package/src/runtime/actor-token-store.ts +236 -0
  172. package/src/runtime/actor-trust-resolver.ts +2 -2
  173. package/src/runtime/assistant-scope.ts +10 -0
  174. package/src/runtime/channel-approvals.ts +5 -3
  175. package/src/runtime/channel-readiness-service.ts +23 -64
  176. package/src/runtime/channel-readiness-types.ts +3 -4
  177. package/src/runtime/channel-retry-sweep.ts +4 -1
  178. package/src/runtime/confirmation-request-guardian-bridge.ts +197 -0
  179. package/src/runtime/guardian-action-followup-executor.ts +1 -1
  180. package/src/runtime/guardian-context-resolver.ts +82 -0
  181. package/src/runtime/guardian-outbound-actions.ts +5 -7
  182. package/src/runtime/guardian-reply-router.ts +67 -30
  183. package/src/runtime/guardian-vellum-migration.ts +57 -0
  184. package/src/runtime/http-server.ts +75 -31
  185. package/src/runtime/http-types.ts +13 -0
  186. package/src/runtime/ingress-service.ts +14 -0
  187. package/src/runtime/invite-redemption-service.ts +10 -1
  188. package/src/runtime/local-actor-identity.ts +76 -0
  189. package/src/runtime/middleware/actor-token.ts +271 -0
  190. package/src/runtime/middleware/twilio-validation.ts +2 -4
  191. package/src/runtime/routes/approval-routes.ts +82 -7
  192. package/src/runtime/routes/brain-graph-routes.ts +222 -0
  193. package/src/runtime/routes/call-routes.ts +2 -1
  194. package/src/runtime/routes/channel-readiness-routes.ts +71 -0
  195. package/src/runtime/routes/channel-route-shared.ts +3 -3
  196. package/src/runtime/routes/conversation-attention-routes.ts +2 -1
  197. package/src/runtime/routes/conversation-routes.ts +142 -53
  198. package/src/runtime/routes/events-routes.ts +22 -8
  199. package/src/runtime/routes/guardian-action-routes.ts +45 -3
  200. package/src/runtime/routes/guardian-approval-interception.ts +29 -0
  201. package/src/runtime/routes/guardian-bootstrap-routes.ts +145 -0
  202. package/src/runtime/routes/inbound-conversation.ts +4 -3
  203. package/src/runtime/routes/inbound-message-handler.ts +147 -5
  204. package/src/runtime/routes/ingress-routes.ts +2 -0
  205. package/src/runtime/routes/integration-routes.ts +7 -15
  206. package/src/runtime/routes/pairing-routes.ts +163 -0
  207. package/src/runtime/routes/twilio-routes.ts +934 -0
  208. package/src/runtime/tool-grant-request-helper.ts +3 -1
  209. package/src/security/oauth2.ts +27 -2
  210. package/src/security/token-manager.ts +46 -10
  211. package/src/tools/browser/browser-execution.ts +4 -3
  212. package/src/tools/browser/browser-handoff.ts +10 -18
  213. package/src/tools/browser/browser-manager.ts +80 -25
  214. package/src/tools/browser/browser-screencast.ts +35 -119
  215. package/src/tools/calls/call-start.ts +2 -1
  216. package/src/tools/permission-checker.ts +15 -4
  217. package/src/tools/terminal/parser.ts +12 -0
  218. package/src/tools/tool-approval-handler.ts +244 -19
  219. package/src/workspace/git-service.ts +19 -0
  220. package/src/__tests__/handlers-twilio-config.test.ts +0 -1928
  221. package/src/daemon/handlers/config-twilio.ts +0 -1082
@@ -0,0 +1,1064 @@
1
+ /**
2
+ * End-to-end integration tests for the trusted-contact inline guardian approval feature.
3
+ *
4
+ * Verifies the full integration of M1-M4 milestones:
5
+ * M1: RoutingState (guardian-context-resolver.ts)
6
+ * M2: Confirmation request guardian bridge (confirmation-request-guardian-bridge.ts)
7
+ * M3: Pending approval notifier (inbound-message-handler.ts)
8
+ * M4: Inline grant wait-and-resume (tool-approval-handler.ts) +
9
+ * staleness guard (guardian-request-resolvers.ts)
10
+ *
11
+ * Covered UX flows:
12
+ * a. Target flow: trusted contact -> guardian-gated action -> pending msg -> guardian approves -> tool executes
13
+ * b. Prompt-path flow: confirmation_request bridges to guardian notification and resumes
14
+ * c. No-binding flow: trusted contact without guardian binding fails fast (no dead-end wait)
15
+ * d. Unknown actor flow: remains fail-closed (no interactive approval)
16
+ * e. Guardian-only prompt delivery invariant: non-guardian never receives approval prompt UI
17
+ * f. Timeout/stale flow: guardian decision after prompt timeout produces deterministic outcome
18
+ */
19
+
20
+ import { mkdtempSync, rmSync } from 'node:fs';
21
+ import { tmpdir } from 'node:os';
22
+ import { join } from 'node:path';
23
+
24
+ import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test';
25
+
26
+ const testDir = mkdtempSync(join(tmpdir(), 'tc-inline-approval-integration-'));
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Mocks — must be set before any production imports
30
+ // ---------------------------------------------------------------------------
31
+
32
+ mock.module('../util/platform.js', () => ({
33
+ getDataDir: () => testDir,
34
+ getRootDir: () => testDir,
35
+ isMacOS: () => process.platform === 'darwin',
36
+ isLinux: () => process.platform === 'linux',
37
+ isWindows: () => process.platform === 'win32',
38
+ getSocketPath: () => join(testDir, 'test.sock'),
39
+ getPidPath: () => join(testDir, 'test.pid'),
40
+ getDbPath: () => join(testDir, 'test.db'),
41
+ getLogPath: () => join(testDir, 'test.log'),
42
+ readHttpToken: () => 'test-token',
43
+ ensureDataDir: () => {},
44
+ migrateToDataLayout: () => {},
45
+ migrateToWorkspaceLayout: () => {},
46
+ }));
47
+
48
+ mock.module('../util/logger.js', () => ({
49
+ getLogger: () =>
50
+ new Proxy({} as Record<string, unknown>, {
51
+ get: () => () => {},
52
+ }),
53
+ isDebug: () => false,
54
+ truncateForLog: (value: string) => value,
55
+ }));
56
+
57
+ // Mock notification emission — capture calls
58
+ const emittedSignals: Array<Record<string, unknown>> = [];
59
+ mock.module('../notifications/emit-signal.js', () => ({
60
+ emitNotificationSignal: async (params: Record<string, unknown>) => {
61
+ emittedSignals.push(params);
62
+ return {
63
+ signalId: 'test-signal',
64
+ deduplicated: false,
65
+ dispatched: true,
66
+ reason: 'ok',
67
+ deliveryResults: [
68
+ { channel: 'telegram', destination: 'guardian-chat-1', success: true },
69
+ ],
70
+ };
71
+ },
72
+ registerBroadcastFn: () => {},
73
+ }));
74
+
75
+ // Mock guardian control-plane policy — not targeting control-plane by default
76
+ mock.module('../tools/guardian-control-plane-policy.js', () => ({
77
+ enforceGuardianOnlyPolicy: () => ({ denied: false }),
78
+ }));
79
+
80
+ // Mock task run rules
81
+ mock.module('../tasks/ephemeral-permissions.js', () => ({
82
+ getTaskRunRules: () => [],
83
+ }));
84
+
85
+ // Mock tool registry — provide a fake 'bash' tool
86
+ const fakeTool = {
87
+ name: 'bash',
88
+ description: 'Run a shell command',
89
+ category: 'shell',
90
+ defaultRiskLevel: 'high',
91
+ getDefinition: () => ({ name: 'bash', description: 'Run a shell command', input_schema: {} }),
92
+ execute: async () => ({ content: 'ok', isError: false }),
93
+ };
94
+ mock.module('../tools/registry.js', () => ({
95
+ getTool: (name: string) => (name === 'bash' ? fakeTool : undefined),
96
+ getAllTools: () => [fakeTool],
97
+ }));
98
+
99
+ // Mock channel guardian service — configurable per test
100
+ let mockGuardianBinding: Record<string, unknown> | null = {
101
+ id: 'binding-1',
102
+ assistantId: 'self',
103
+ channel: 'telegram',
104
+ guardianExternalUserId: 'guardian-1',
105
+ guardianDeliveryChatId: 'guardian-chat-1',
106
+ status: 'active',
107
+ };
108
+
109
+ mock.module('../runtime/channel-guardian-service.js', () => ({
110
+ getGuardianBinding: (assistantId: string, channel: string) => {
111
+ if (assistantId === 'self' && channel === 'telegram' && mockGuardianBinding) {
112
+ return mockGuardianBinding;
113
+ }
114
+ return null;
115
+ },
116
+ createOutboundSession: () => ({
117
+ sessionId: 'test-session',
118
+ secret: '123456',
119
+ }),
120
+ bindSessionIdentity: () => {},
121
+ findActiveSession: () => null,
122
+ getPendingChallenge: () => null,
123
+ isGuardian: () => false,
124
+ resolveBootstrapToken: () => null,
125
+ updateSessionDelivery: () => {},
126
+ updateSessionStatus: () => {},
127
+ validateAndConsumeChallenge: () => ({ success: false, reason: 'no_challenge' }),
128
+ }));
129
+
130
+ // Mock gateway client — capture delivery calls
131
+ const deliveredReplies: Array<{ url: string; payload: Record<string, unknown>; bearerToken?: string }> = [];
132
+ mock.module('../runtime/gateway-client.js', () => ({
133
+ deliverChannelReply: async (
134
+ url: string,
135
+ payload: Record<string, unknown>,
136
+ bearerToken?: string,
137
+ ) => {
138
+ deliveredReplies.push({ url, payload, bearerToken });
139
+ return { ok: true };
140
+ },
141
+ }));
142
+
143
+ // Mock pending interactions (channel-approvals)
144
+ let mockPendingApprovals: Array<{
145
+ requestId: string;
146
+ toolName: string;
147
+ input: Record<string, unknown>;
148
+ riskLevel: string;
149
+ }> = [];
150
+
151
+ mock.module('../runtime/channel-approvals.js', () => ({
152
+ getApprovalInfoByConversation: () => mockPendingApprovals,
153
+ getChannelApprovalPrompt: () => null,
154
+ buildApprovalUIMetadata: () => ({}),
155
+ handleChannelDecision: () => ({ applied: false }),
156
+ }));
157
+
158
+ mock.module('../config/env.js', () => ({
159
+ getGatewayInternalBaseUrl: () => 'http://localhost:3000',
160
+ }));
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // Production imports (AFTER mocks)
164
+ // ---------------------------------------------------------------------------
165
+
166
+ import {
167
+ applyCanonicalGuardianDecision,
168
+ } from '../approvals/guardian-decision-primitive.js';
169
+ import type { ActorContext } from '../approvals/guardian-request-resolvers.js';
170
+ import { getResolver } from '../approvals/guardian-request-resolvers.js';
171
+ import type { GuardianRuntimeContext } from '../daemon/session-runtime-assembly.js';
172
+ import {
173
+ createCanonicalGuardianRequest,
174
+ getCanonicalGuardianRequest,
175
+ listCanonicalGuardianRequests,
176
+ updateCanonicalGuardianRequest,
177
+ } from '../memory/canonical-guardian-store.js';
178
+ import { getDb, initializeDb, resetDb } from '../memory/db.js';
179
+ import { scopedApprovalGrants } from '../memory/schema.js';
180
+ import { bridgeConfirmationRequestToGuardian } from '../runtime/confirmation-request-guardian-bridge.js';
181
+ import {
182
+ type GuardianContext,
183
+ resolveRoutingState,
184
+ } from '../runtime/guardian-context-resolver.js';
185
+ import { TC_GRANT_WAIT_MAX_MS, ToolApprovalHandler } from '../tools/tool-approval-handler.js';
186
+ import type { ToolContext, ToolLifecycleEvent } from '../tools/types.js';
187
+
188
+ initializeDb();
189
+
190
+ function resetTables(): void {
191
+ const db = getDb();
192
+ db.delete(scopedApprovalGrants).run();
193
+ db.run('DELETE FROM canonical_guardian_deliveries');
194
+ db.run('DELETE FROM canonical_guardian_requests');
195
+ }
196
+
197
+ afterAll(() => {
198
+ resetDb();
199
+ try { rmSync(testDir, { recursive: true }); } catch { /* best effort */ }
200
+ });
201
+
202
+ // ---------------------------------------------------------------------------
203
+ // Helpers
204
+ // ---------------------------------------------------------------------------
205
+
206
+ function makeToolContext(overrides: Partial<ToolContext> = {}): ToolContext {
207
+ return {
208
+ workingDir: testDir,
209
+ sessionId: 'session-1',
210
+ conversationId: 'conv-1',
211
+ assistantId: 'self',
212
+ requestId: 'req-1',
213
+ guardianTrustClass: 'trusted_contact',
214
+ executionChannel: 'telegram',
215
+ requesterExternalUserId: 'requester-1',
216
+ ...overrides,
217
+ };
218
+ }
219
+
220
+ function guardianActor(overrides: Partial<ActorContext> = {}): ActorContext {
221
+ return {
222
+ externalUserId: 'guardian-1',
223
+ channel: 'telegram',
224
+ isTrusted: false,
225
+ ...overrides,
226
+ };
227
+ }
228
+
229
+ function makeTrustedContactGuardianContext(): GuardianRuntimeContext {
230
+ return {
231
+ sourceChannel: 'telegram',
232
+ trustClass: 'trusted_contact',
233
+ guardianExternalUserId: 'guardian-1',
234
+ guardianChatId: 'guardian-chat-1',
235
+ requesterExternalUserId: 'requester-1',
236
+ requesterChatId: 'requester-chat-1',
237
+ requesterIdentifier: '@requester',
238
+ };
239
+ }
240
+
241
+ const events: ToolLifecycleEvent[] = [];
242
+ const emitLifecycleEvent = (event: ToolLifecycleEvent) => { events.push(event); };
243
+
244
+ // ===========================================================================
245
+ // a. Target flow: trusted contact -> guardian-gated tool -> approve -> execute
246
+ // ===========================================================================
247
+
248
+ describe('(a) target flow: trusted-contact inline guardian approval end-to-end', () => {
249
+ const handler = new ToolApprovalHandler({ inlineGrantWait: { maxWaitMs: 2_000, intervalMs: 20 } });
250
+
251
+ beforeEach(() => {
252
+ resetTables();
253
+ events.length = 0;
254
+ emittedSignals.length = 0;
255
+ deliveredReplies.length = 0;
256
+ mockGuardianBinding = {
257
+ id: 'binding-1',
258
+ assistantId: 'self',
259
+ channel: 'telegram',
260
+ guardianExternalUserId: 'guardian-1',
261
+ guardianDeliveryChatId: 'guardian-chat-1',
262
+ status: 'active',
263
+ };
264
+ });
265
+
266
+ test('trusted contact requests tool, guardian approves mid-wait, tool executes inline', async () => {
267
+ const toolName = 'bash';
268
+ const input = { command: 'echo hello' };
269
+ const context = makeToolContext({ guardianTrustClass: 'trusted_contact' });
270
+
271
+ // Schedule guardian approval after 100ms during the inline wait
272
+ const approvalPromise = (async () => {
273
+ await new Promise((r) => setTimeout(r, 100));
274
+ const pending = listCanonicalGuardianRequests({
275
+ kind: 'tool_grant_request',
276
+ status: 'pending',
277
+ toolName: 'bash',
278
+ });
279
+ expect(pending.length).toBeGreaterThan(0);
280
+
281
+ // Verify the request has an inline_wait_active stamp
282
+ const freshReq = getCanonicalGuardianRequest(pending[0].id);
283
+ expect(freshReq?.followupState).toMatch(/^inline_wait_active:\d+$/);
284
+
285
+ await applyCanonicalGuardianDecision({
286
+ requestId: pending[0].id,
287
+ action: 'approve_once',
288
+ actorContext: guardianActor(),
289
+ });
290
+ })();
291
+
292
+ const result = await handler.checkPreExecutionGates(
293
+ toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
294
+ );
295
+
296
+ await approvalPromise;
297
+
298
+ // Tool execution should proceed inline
299
+ expect(result.allowed).toBe(true);
300
+ if (!result.allowed) return;
301
+ expect(result.grantConsumed).toBe(true);
302
+
303
+ // followupState should be cleared after a successful inline grant
304
+ const resolved = listCanonicalGuardianRequests({ kind: 'tool_grant_request' });
305
+ expect(resolved.length).toBe(1);
306
+ const freshReq = getCanonicalGuardianRequest(resolved[0].id);
307
+ expect(freshReq?.followupState).toBeNull();
308
+
309
+ // A guardian.question notification should have been emitted
310
+ const questionSignals = emittedSignals.filter((s) => s.sourceEventName === 'guardian.question');
311
+ expect(questionSignals.length).toBeGreaterThan(0);
312
+ });
313
+
314
+ test('complete flow: routing state allows interactive + bridge notifies guardian + tool resumes', async () => {
315
+ // Step 1: Verify routing state allows interactive turns for trusted contacts
316
+ const guardianCtx: GuardianContext = {
317
+ trustClass: 'trusted_contact',
318
+ guardianExternalUserId: 'guardian-1',
319
+ guardianChatId: 'guardian-chat-1',
320
+ };
321
+ const routing = resolveRoutingState(guardianCtx);
322
+ expect(routing.promptWaitingAllowed).toBe(true);
323
+ expect(routing.guardianRouteResolvable).toBe(true);
324
+
325
+ // Step 2: Tool invocation creates escalation + waits inline + guardian approves
326
+ const toolName = 'bash';
327
+ const input = { command: 'deploy' };
328
+ const context = makeToolContext({ guardianTrustClass: 'trusted_contact' });
329
+
330
+ const approvalPromise = (async () => {
331
+ await new Promise((r) => setTimeout(r, 80));
332
+ const pending = listCanonicalGuardianRequests({
333
+ kind: 'tool_grant_request',
334
+ status: 'pending',
335
+ });
336
+ if (pending.length > 0) {
337
+ await applyCanonicalGuardianDecision({
338
+ requestId: pending[0].id,
339
+ action: 'approve_once',
340
+ actorContext: guardianActor(),
341
+ });
342
+ }
343
+ })();
344
+
345
+ const result = await handler.checkPreExecutionGates(
346
+ toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
347
+ );
348
+
349
+ await approvalPromise;
350
+
351
+ expect(result.allowed).toBe(true);
352
+ if (!result.allowed) return;
353
+ expect(result.grantConsumed).toBe(true);
354
+ });
355
+ });
356
+
357
+ // ===========================================================================
358
+ // b. Prompt-path flow: confirmation_request bridges to guardian notification
359
+ // ===========================================================================
360
+
361
+ describe('(b) prompt-path flow: confirmation_request bridges to guardian', () => {
362
+ beforeEach(() => {
363
+ resetTables();
364
+ emittedSignals.length = 0;
365
+ mockGuardianBinding = {
366
+ id: 'binding-1',
367
+ assistantId: 'self',
368
+ channel: 'telegram',
369
+ guardianExternalUserId: 'guardian-1',
370
+ guardianDeliveryChatId: 'guardian-chat-1',
371
+ status: 'active',
372
+ };
373
+ });
374
+
375
+ test('trusted-contact confirmation_request emits guardian.question and creates delivery records', () => {
376
+ const canonicalRequest = createCanonicalGuardianRequest({
377
+ id: `req-bridge-${Date.now()}`,
378
+ kind: 'tool_approval',
379
+ sourceType: 'channel',
380
+ sourceChannel: 'telegram',
381
+ conversationId: 'conv-bridge-1',
382
+ requesterExternalUserId: 'requester-1',
383
+ guardianExternalUserId: 'guardian-1',
384
+ toolName: 'bash',
385
+ status: 'pending',
386
+ expiresAt: new Date(Date.now() + 5 * 60_000).toISOString(),
387
+ });
388
+
389
+ const guardianContext = makeTrustedContactGuardianContext();
390
+
391
+ const result = bridgeConfirmationRequestToGuardian({
392
+ canonicalRequest,
393
+ guardianContext,
394
+ conversationId: 'conv-bridge-1',
395
+ toolName: 'bash',
396
+ });
397
+
398
+ expect('bridged' in result && result.bridged).toBe(true);
399
+
400
+ // guardian.question notification was emitted
401
+ expect(emittedSignals.length).toBeGreaterThan(0);
402
+ expect(emittedSignals[0].sourceEventName).toBe('guardian.question');
403
+
404
+ const payload = emittedSignals[0].contextPayload as Record<string, unknown>;
405
+ expect(payload.requestId).toBe(canonicalRequest.id);
406
+ expect(payload.toolName).toBe('bash');
407
+ expect(payload.requesterIdentifier).toBe('@requester');
408
+ });
409
+
410
+ test('bridge + tool_grant_request both use guardian.question for unified routing', () => {
411
+ // The confirmation_request bridge and tool_grant_request helper both
412
+ // use 'guardian.question' as the notification signal, ensuring consistent
413
+ // guardian routing regardless of the approval path.
414
+ const canonicalRequest = createCanonicalGuardianRequest({
415
+ id: `req-unified-${Date.now()}`,
416
+ kind: 'tool_approval',
417
+ sourceType: 'channel',
418
+ sourceChannel: 'telegram',
419
+ conversationId: 'conv-unified-1',
420
+ requesterExternalUserId: 'requester-1',
421
+ guardianExternalUserId: 'guardian-1',
422
+ toolName: 'bash',
423
+ status: 'pending',
424
+ expiresAt: new Date(Date.now() + 5 * 60_000).toISOString(),
425
+ });
426
+
427
+ const guardianContext = makeTrustedContactGuardianContext();
428
+
429
+ bridgeConfirmationRequestToGuardian({
430
+ canonicalRequest,
431
+ guardianContext,
432
+ conversationId: 'conv-unified-1',
433
+ toolName: 'bash',
434
+ });
435
+
436
+ // All emitted signals should use guardian.question
437
+ const eventNames = emittedSignals.map((s) => s.sourceEventName);
438
+ for (const name of eventNames) {
439
+ expect(name).toBe('guardian.question');
440
+ }
441
+ });
442
+ });
443
+
444
+ // ===========================================================================
445
+ // c. No-binding flow: trusted contact fails fast without guardian binding
446
+ // ===========================================================================
447
+
448
+ describe('(c) no-binding flow: trusted contact fails fast without guardian binding', () => {
449
+ const shortHandler = new ToolApprovalHandler({ inlineGrantWait: { maxWaitMs: 100, intervalMs: 20 } });
450
+
451
+ beforeEach(() => {
452
+ resetTables();
453
+ events.length = 0;
454
+ emittedSignals.length = 0;
455
+ deliveredReplies.length = 0;
456
+ mockGuardianBinding = null; // No guardian binding
457
+ });
458
+
459
+ test('routing state blocks prompt waiting when no guardian binding exists', () => {
460
+ const ctx: GuardianContext = {
461
+ trustClass: 'trusted_contact',
462
+ // No guardianExternalUserId — mirrors no binding
463
+ };
464
+ const state = resolveRoutingState(ctx);
465
+
466
+ expect(state.canBeInteractive).toBe(true);
467
+ expect(state.guardianRouteResolvable).toBe(false);
468
+ expect(state.promptWaitingAllowed).toBe(false);
469
+ });
470
+
471
+ test('tool escalation returns generic denial (no dead-end wait) without binding', async () => {
472
+ const toolName = 'bash';
473
+ const input = { command: 'ls' };
474
+ const context = makeToolContext({
475
+ guardianTrustClass: 'trusted_contact',
476
+ executionChannel: 'telegram',
477
+ });
478
+
479
+ const start = Date.now();
480
+ const result = await shortHandler.checkPreExecutionGates(
481
+ toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
482
+ );
483
+ const elapsed = Date.now() - start;
484
+
485
+ expect(result.allowed).toBe(false);
486
+ if (result.allowed) return;
487
+
488
+ // Should return the generic guardian approval message, not enter inline wait
489
+ expect(result.result.content).toContain('guardian approval');
490
+
491
+ // No canonical tool_grant_request should have been created (no binding)
492
+ const requests = listCanonicalGuardianRequests({
493
+ kind: 'tool_grant_request',
494
+ status: 'pending',
495
+ });
496
+ expect(requests.length).toBe(0);
497
+
498
+ // Should complete nearly instantly, not block for the full wait budget
499
+ expect(elapsed).toBeLessThan(500);
500
+ });
501
+
502
+ test('bridge skips when no guardian binding exists for channel', () => {
503
+ const canonicalRequest = createCanonicalGuardianRequest({
504
+ id: `req-nobinding-${Date.now()}`,
505
+ kind: 'tool_approval',
506
+ sourceType: 'channel',
507
+ sourceChannel: 'telegram',
508
+ conversationId: 'conv-nobinding',
509
+ requesterExternalUserId: 'requester-1',
510
+ guardianExternalUserId: 'guardian-1',
511
+ toolName: 'bash',
512
+ status: 'pending',
513
+ expiresAt: new Date(Date.now() + 5 * 60_000).toISOString(),
514
+ });
515
+
516
+ const guardianContext = makeTrustedContactGuardianContext();
517
+
518
+ const result = bridgeConfirmationRequestToGuardian({
519
+ canonicalRequest,
520
+ guardianContext,
521
+ conversationId: 'conv-nobinding',
522
+ toolName: 'bash',
523
+ });
524
+
525
+ expect('skipped' in result && result.skipped).toBe(true);
526
+ if ('skipped' in result) {
527
+ expect(result.reason).toBe('no_guardian_binding');
528
+ }
529
+ expect(emittedSignals.length).toBe(0);
530
+ });
531
+ });
532
+
533
+ // ===========================================================================
534
+ // d. Unknown actor flow: remains fail-closed
535
+ // ===========================================================================
536
+
537
+ describe('(d) unknown actor flow: fail-closed with no interactive approval', () => {
538
+ const handler = new ToolApprovalHandler({ inlineGrantWait: { maxWaitMs: 2_000, intervalMs: 20 } });
539
+
540
+ beforeEach(() => {
541
+ resetTables();
542
+ events.length = 0;
543
+ emittedSignals.length = 0;
544
+ mockGuardianBinding = {
545
+ id: 'binding-1',
546
+ assistantId: 'self',
547
+ channel: 'telegram',
548
+ guardianExternalUserId: 'guardian-1',
549
+ guardianDeliveryChatId: 'guardian-chat-1',
550
+ status: 'active',
551
+ };
552
+ });
553
+
554
+ test('unknown actors get immediate denial with no escalation or wait', async () => {
555
+ const toolName = 'bash';
556
+ const input = { command: 'ls' };
557
+ const context = makeToolContext({
558
+ guardianTrustClass: 'unknown',
559
+ executionChannel: 'telegram',
560
+ requesterExternalUserId: 'unknown-user',
561
+ });
562
+
563
+ const start = Date.now();
564
+ const result = await handler.checkPreExecutionGates(
565
+ toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
566
+ );
567
+ const elapsed = Date.now() - start;
568
+
569
+ expect(result.allowed).toBe(false);
570
+ if (result.allowed) return;
571
+
572
+ // Unknown actors get the verified-identity message
573
+ expect(result.result.content).toContain('verified channel identity');
574
+
575
+ // No canonical request created — unknown actors don't escalate
576
+ const requests = listCanonicalGuardianRequests({
577
+ kind: 'tool_grant_request',
578
+ status: 'pending',
579
+ });
580
+ expect(requests.length).toBe(0);
581
+
582
+ // Near-instant: no inline wait for unknown actors
583
+ expect(elapsed).toBeLessThan(200);
584
+ });
585
+
586
+ test('unknown actors have promptWaitingAllowed=false regardless of guardian route', () => {
587
+ const withRoute: GuardianContext = {
588
+ trustClass: 'unknown',
589
+ guardianExternalUserId: 'guardian-1',
590
+ };
591
+ const withoutRoute: GuardianContext = {
592
+ trustClass: 'unknown',
593
+ };
594
+
595
+ expect(resolveRoutingState(withRoute).promptWaitingAllowed).toBe(false);
596
+ expect(resolveRoutingState(withRoute).canBeInteractive).toBe(false);
597
+ expect(resolveRoutingState(withoutRoute).promptWaitingAllowed).toBe(false);
598
+ expect(resolveRoutingState(withoutRoute).canBeInteractive).toBe(false);
599
+ });
600
+
601
+ test('bridge skips unknown actor sessions entirely', () => {
602
+ const canonicalRequest = createCanonicalGuardianRequest({
603
+ id: `req-unknown-${Date.now()}`,
604
+ kind: 'tool_approval',
605
+ sourceType: 'channel',
606
+ sourceChannel: 'telegram',
607
+ conversationId: 'conv-unknown',
608
+ requesterExternalUserId: 'unknown-user',
609
+ guardianExternalUserId: 'guardian-1',
610
+ toolName: 'bash',
611
+ status: 'pending',
612
+ expiresAt: new Date(Date.now() + 5 * 60_000).toISOString(),
613
+ });
614
+
615
+ const guardianContext: GuardianRuntimeContext = {
616
+ sourceChannel: 'telegram',
617
+ trustClass: 'unknown',
618
+ };
619
+
620
+ const result = bridgeConfirmationRequestToGuardian({
621
+ canonicalRequest,
622
+ guardianContext,
623
+ conversationId: 'conv-unknown',
624
+ toolName: 'bash',
625
+ });
626
+
627
+ expect('skipped' in result && result.skipped).toBe(true);
628
+ if ('skipped' in result) {
629
+ expect(result.reason).toBe('not_trusted_contact');
630
+ }
631
+ });
632
+ });
633
+
634
+ // ===========================================================================
635
+ // e. Guardian-only prompt delivery invariant
636
+ // ===========================================================================
637
+
638
+ /**
639
+ * Mirrors the `isBoundGuardianActor` guard from inbound-message-handler.ts.
640
+ * Uses the same runtime-value shape so TypeScript treats the comparisons as
641
+ * `string === string` rather than `'literal_a' === 'literal_b'` (which TS
642
+ * flags as always-false under strict literal narrowing — TS2367/TS2872).
643
+ */
644
+ function checkIsBoundGuardianActor(params: {
645
+ guardianTrustClass: string;
646
+ guardianExternalUserId: string | undefined;
647
+ requesterExternalUserId: string;
648
+ }): boolean {
649
+ return (
650
+ params.guardianTrustClass === 'guardian'
651
+ && !!params.guardianExternalUserId
652
+ && params.requesterExternalUserId === params.guardianExternalUserId
653
+ );
654
+ }
655
+
656
+ describe('(e) guardian-only prompt delivery invariant', () => {
657
+ beforeEach(() => {
658
+ deliveredReplies.length = 0;
659
+ mockPendingApprovals = [{
660
+ requestId: 'req-prompt-test',
661
+ toolName: 'bash',
662
+ input: { command: 'ls' },
663
+ riskLevel: 'high',
664
+ }];
665
+ });
666
+
667
+ test('trusted_contact does NOT receive approval prompt UI (notifier only sends waiting message)', async () => {
668
+ // The startPendingApprovalPromptWatcher in inbound-message-handler.ts
669
+ // has a guard: isBoundGuardianActor check. Non-guardian actors (including
670
+ // trusted contacts) get () => {} (noop) for the watcher. Only guardian
671
+ // actors matching the binding receive the prompt.
672
+
673
+ const result = checkIsBoundGuardianActor({
674
+ guardianTrustClass: 'trusted_contact',
675
+ guardianExternalUserId: 'guardian-1',
676
+ requesterExternalUserId: 'requester-1',
677
+ });
678
+
679
+ expect(result).toBe(false);
680
+ // The prompt watcher would return a noop for trusted contacts
681
+ });
682
+
683
+ test('unknown actors do NOT receive approval prompt UI', () => {
684
+ const result = checkIsBoundGuardianActor({
685
+ guardianTrustClass: 'unknown',
686
+ guardianExternalUserId: 'guardian-1',
687
+ requesterExternalUserId: 'unknown-user',
688
+ });
689
+
690
+ expect(result).toBe(false);
691
+ });
692
+
693
+ test('guardian actor that matches binding DOES receive approval prompt UI', () => {
694
+ const result = checkIsBoundGuardianActor({
695
+ guardianTrustClass: 'guardian',
696
+ guardianExternalUserId: 'guardian-1',
697
+ requesterExternalUserId: 'guardian-1',
698
+ });
699
+
700
+ expect(result).toBe(true);
701
+ });
702
+
703
+ test('guardian actor with identity mismatch does NOT receive approval prompt UI', () => {
704
+ // After guardian rotation, old guardian identity should not receive prompts
705
+ const result = checkIsBoundGuardianActor({
706
+ guardianTrustClass: 'guardian',
707
+ guardianExternalUserId: 'new-guardian-2',
708
+ requesterExternalUserId: 'old-guardian-1',
709
+ });
710
+
711
+ expect(result).toBe(false);
712
+ });
713
+ });
714
+
715
+ // ===========================================================================
716
+ // f. Timeout/stale flow: guardian decision after prompt timeout
717
+ // ===========================================================================
718
+
719
+ describe('(f) timeout/stale flow: stale guardian decision after inline wait timeout', () => {
720
+ const handler = new ToolApprovalHandler({ inlineGrantWait: { maxWaitMs: 100, intervalMs: 20 } });
721
+
722
+ beforeEach(() => {
723
+ resetTables();
724
+ events.length = 0;
725
+ emittedSignals.length = 0;
726
+ deliveredReplies.length = 0;
727
+ mockGuardianBinding = {
728
+ id: 'binding-1',
729
+ assistantId: 'self',
730
+ channel: 'telegram',
731
+ guardianExternalUserId: 'guardian-1',
732
+ guardianDeliveryChatId: 'guardian-chat-1',
733
+ status: 'active',
734
+ };
735
+ });
736
+
737
+ test('inline wait timeout clears followupState so later approval sends retry notification', async () => {
738
+ const toolName = 'bash';
739
+ const input = { command: 'echo stale' };
740
+ const context = makeToolContext({ guardianTrustClass: 'trusted_contact' });
741
+
742
+ // Let the tool invocation time out (no guardian approval within 100ms)
743
+ const result = await handler.checkPreExecutionGates(
744
+ toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
745
+ );
746
+
747
+ expect(result.allowed).toBe(false);
748
+ if (result.allowed) return;
749
+ expect(result.result.content).toContain('guardian approval was not received in time');
750
+
751
+ // After timeout, the followupState should be cleared (null)
752
+ const pending = listCanonicalGuardianRequests({
753
+ kind: 'tool_grant_request',
754
+ status: 'pending',
755
+ });
756
+ expect(pending.length).toBe(1);
757
+
758
+ const freshReq = getCanonicalGuardianRequest(pending[0].id);
759
+ // followupState should be null after timeout — the inline wait cleared it
760
+ expect(freshReq?.followupState).toBeNull();
761
+
762
+ // Now simulate guardian approving after the timeout
763
+ const approvalResult = await applyCanonicalGuardianDecision({
764
+ requestId: pending[0].id,
765
+ action: 'approve_once',
766
+ actorContext: guardianActor(),
767
+ channelDeliveryContext: {
768
+ replyCallbackUrl: 'http://localhost:3000/reply',
769
+ guardianChatId: 'guardian-chat-1',
770
+ assistantId: 'self',
771
+ bearerToken: 'test-token',
772
+ },
773
+ });
774
+ expect(approvalResult.applied).toBe(true);
775
+
776
+ // The resolver should have sent the retry notification because
777
+ // followupState was cleared (not inline_wait_active)
778
+ const retryNotifications = deliveredReplies.filter(
779
+ (r) => typeof r.payload.text === 'string' && (r.payload.text as string).includes('approved'),
780
+ );
781
+ expect(retryNotifications.length).toBeGreaterThan(0);
782
+ });
783
+
784
+ test('inline_wait_active staleness guard: expired marker allows retry notification', async () => {
785
+ // Create a canonical request with a stale inline_wait_active marker
786
+ // that simulates a daemon crash during the wait.
787
+ const staleTimestamp = Date.now() - TC_GRANT_WAIT_MAX_MS - 60_000;
788
+ const req = createCanonicalGuardianRequest({
789
+ id: `req-stale-${Date.now()}`,
790
+ kind: 'tool_grant_request',
791
+ sourceType: 'channel',
792
+ sourceChannel: 'telegram',
793
+ conversationId: 'conv-stale-1',
794
+ requesterExternalUserId: 'requester-1',
795
+ requesterChatId: 'requester-chat-1',
796
+ guardianExternalUserId: 'guardian-1',
797
+ toolName: 'bash',
798
+ inputDigest: 'sha256:stale',
799
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
800
+ });
801
+
802
+ // Set a stale inline_wait_active marker
803
+ updateCanonicalGuardianRequest(req.id, {
804
+ followupState: `inline_wait_active:${staleTimestamp}`,
805
+ });
806
+
807
+ // Verify marker is stale
808
+ const freshReq = getCanonicalGuardianRequest(req.id);
809
+ expect(freshReq?.followupState).toContain('inline_wait_active:');
810
+
811
+ // Guardian approves — the resolver should detect the stale marker
812
+ // and send the retry notification instead of suppressing it.
813
+ const approvalResult = await applyCanonicalGuardianDecision({
814
+ requestId: req.id,
815
+ action: 'approve_once',
816
+ actorContext: guardianActor(),
817
+ channelDeliveryContext: {
818
+ replyCallbackUrl: 'http://localhost:3000/reply',
819
+ guardianChatId: 'guardian-chat-1',
820
+ assistantId: 'self',
821
+ bearerToken: 'test-token',
822
+ },
823
+ });
824
+ expect(approvalResult.applied).toBe(true);
825
+
826
+ // The retry notification should have been sent (stale marker treated as cleared)
827
+ const retryNotifications = deliveredReplies.filter(
828
+ (r) => typeof r.payload.text === 'string' && (r.payload.text as string).includes('approved'),
829
+ );
830
+ expect(retryNotifications.length).toBeGreaterThan(0);
831
+ });
832
+
833
+ test('fresh inline_wait_active marker suppresses retry notification', async () => {
834
+ // Create a request with a FRESH inline_wait_active marker
835
+ const freshTimestamp = Date.now();
836
+ const req = createCanonicalGuardianRequest({
837
+ id: `req-fresh-${Date.now()}`,
838
+ kind: 'tool_grant_request',
839
+ sourceType: 'channel',
840
+ sourceChannel: 'telegram',
841
+ conversationId: 'conv-fresh-1',
842
+ requesterExternalUserId: 'requester-1',
843
+ requesterChatId: 'requester-chat-1',
844
+ guardianExternalUserId: 'guardian-1',
845
+ toolName: 'bash',
846
+ inputDigest: 'sha256:fresh',
847
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
848
+ });
849
+
850
+ updateCanonicalGuardianRequest(req.id, {
851
+ followupState: `inline_wait_active:${freshTimestamp}`,
852
+ });
853
+
854
+ // Guardian approves while an active inline waiter is running
855
+ deliveredReplies.length = 0;
856
+ const approvalResult = await applyCanonicalGuardianDecision({
857
+ requestId: req.id,
858
+ action: 'approve_once',
859
+ actorContext: guardianActor(),
860
+ channelDeliveryContext: {
861
+ replyCallbackUrl: 'http://localhost:3000/reply',
862
+ guardianChatId: 'guardian-chat-1',
863
+ assistantId: 'self',
864
+ bearerToken: 'test-token',
865
+ },
866
+ });
867
+ expect(approvalResult.applied).toBe(true);
868
+
869
+ // The retry notification should NOT have been sent — the inline waiter
870
+ // is still active and will consume the grant directly.
871
+ const retryNotifications = deliveredReplies.filter(
872
+ (r) => typeof r.payload.text === 'string' && (r.payload.text as string).includes('Please retry'),
873
+ );
874
+ expect(retryNotifications.length).toBe(0);
875
+ });
876
+
877
+ test('denied inline wait produces explicit denial (no false success)', async () => {
878
+ const toolName = 'bash';
879
+ const input = { command: 'rm -rf /' };
880
+ const context = makeToolContext({ guardianTrustClass: 'trusted_contact' });
881
+
882
+ // Schedule guardian rejection after 80ms
883
+ const rejectionPromise = (async () => {
884
+ await new Promise((r) => setTimeout(r, 80));
885
+ const pending = listCanonicalGuardianRequests({
886
+ kind: 'tool_grant_request',
887
+ status: 'pending',
888
+ toolName: 'bash',
889
+ });
890
+ if (pending.length > 0) {
891
+ await applyCanonicalGuardianDecision({
892
+ requestId: pending[0].id,
893
+ action: 'reject',
894
+ actorContext: guardianActor(),
895
+ });
896
+ }
897
+ })();
898
+
899
+ const wideHandler = new ToolApprovalHandler({ inlineGrantWait: { maxWaitMs: 2_000, intervalMs: 20 } });
900
+ const result = await wideHandler.checkPreExecutionGates(
901
+ toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
902
+ );
903
+
904
+ await rejectionPromise;
905
+
906
+ expect(result.allowed).toBe(false);
907
+ if (result.allowed) return;
908
+ expect(result.result.content).toContain('guardian rejected the request');
909
+ expect(result.result.isError).toBe(true);
910
+ });
911
+
912
+ test('timeout produces explicit timeout message (no false success)', async () => {
913
+ const toolName = 'bash';
914
+ const input = { command: 'curl example.com' };
915
+ const context = makeToolContext({ guardianTrustClass: 'trusted_contact' });
916
+
917
+ const result = await handler.checkPreExecutionGates(
918
+ toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
919
+ );
920
+
921
+ expect(result.allowed).toBe(false);
922
+ if (result.allowed) return;
923
+ expect(result.result.content).toContain('guardian approval was not received in time');
924
+ expect(result.result.content).toContain('request code:');
925
+ expect(result.result.isError).toBe(true);
926
+ });
927
+ });
928
+
929
+ // ===========================================================================
930
+ // Cross-milestone integration checks
931
+ // ===========================================================================
932
+
933
+ describe('cross-milestone integration checks', () => {
934
+ beforeEach(() => {
935
+ resetTables();
936
+ events.length = 0;
937
+ emittedSignals.length = 0;
938
+ deliveredReplies.length = 0;
939
+ mockGuardianBinding = {
940
+ id: 'binding-1',
941
+ assistantId: 'self',
942
+ channel: 'telegram',
943
+ guardianExternalUserId: 'guardian-1',
944
+ guardianDeliveryChatId: 'guardian-chat-1',
945
+ status: 'active',
946
+ };
947
+ });
948
+
949
+ test('M1+M4: routing state interactivity drives inline wait eligibility', async () => {
950
+ // With guardian binding: interactive + inline wait allowed
951
+ const withBinding: GuardianContext = {
952
+ trustClass: 'trusted_contact',
953
+ guardianExternalUserId: 'guardian-1',
954
+ };
955
+ expect(resolveRoutingState(withBinding).promptWaitingAllowed).toBe(true);
956
+
957
+ // Without guardian binding: not interactive + inline wait should not enter dead-end
958
+ const withoutBinding: GuardianContext = {
959
+ trustClass: 'trusted_contact',
960
+ };
961
+ expect(resolveRoutingState(withoutBinding).promptWaitingAllowed).toBe(false);
962
+ });
963
+
964
+ test('M2+M4: bridge and tool_grant_request target the same guardian identity', () => {
965
+ // Both the confirmation_request bridge (M2) and tool grant request escalation (M4)
966
+ // use the guardian binding's guardianExternalUserId to route notifications.
967
+ // Verify this consistency:
968
+
969
+ const canonicalRequest = createCanonicalGuardianRequest({
970
+ id: `req-consistency-${Date.now()}`,
971
+ kind: 'tool_approval',
972
+ sourceType: 'channel',
973
+ sourceChannel: 'telegram',
974
+ conversationId: 'conv-consistency',
975
+ requesterExternalUserId: 'requester-1',
976
+ guardianExternalUserId: 'guardian-1',
977
+ toolName: 'bash',
978
+ status: 'pending',
979
+ expiresAt: new Date(Date.now() + 5 * 60_000).toISOString(),
980
+ });
981
+
982
+ const guardianContext = makeTrustedContactGuardianContext();
983
+
984
+ const bridgeResult = bridgeConfirmationRequestToGuardian({
985
+ canonicalRequest,
986
+ guardianContext,
987
+ conversationId: 'conv-consistency',
988
+ toolName: 'bash',
989
+ });
990
+
991
+ expect('bridged' in bridgeResult && bridgeResult.bridged).toBe(true);
992
+
993
+ // Both the bridge signal and the tool_grant_request signal would target
994
+ // the same guardian binding (guardian-1)
995
+ if (emittedSignals.length > 0) {
996
+ const payload = emittedSignals[0].contextPayload as Record<string, unknown>;
997
+ expect(payload.requesterExternalUserId).toBe('requester-1');
998
+ }
999
+ });
1000
+
1001
+ test('M4: tool_grant_request resolver is correctly registered', () => {
1002
+ const resolver = getResolver('tool_grant_request');
1003
+ expect(resolver).toBeDefined();
1004
+ expect(resolver!.kind).toBe('tool_grant_request');
1005
+ });
1006
+
1007
+ test('M1: guardian actors bypass inline wait entirely (self-approve path)', async () => {
1008
+ const handler = new ToolApprovalHandler({ inlineGrantWait: { maxWaitMs: 100, intervalMs: 20 } });
1009
+ const toolName = 'bash';
1010
+ const input = { command: 'ls' };
1011
+ const context = makeToolContext({
1012
+ guardianTrustClass: 'guardian',
1013
+ executionChannel: 'telegram',
1014
+ requesterExternalUserId: 'guardian-1',
1015
+ });
1016
+
1017
+ // Guardian actors resolve through the standard permission prompt path,
1018
+ // not the grant escalation path. The tool should be allowed without
1019
+ // going through grant consumption.
1020
+ const result = await handler.checkPreExecutionGates(
1021
+ toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
1022
+ );
1023
+
1024
+ // Guardian + no grant check = allowed without grantConsumed
1025
+ // (guardians use the interactive prompt, not the grant system)
1026
+ expect(result.allowed).toBe(true);
1027
+ if (!result.allowed) return;
1028
+ expect(result.grantConsumed).toBeUndefined();
1029
+ });
1030
+
1031
+ test('M4: abort signal during inline wait clears followupState for later retries', async () => {
1032
+ const handler = new ToolApprovalHandler({ inlineGrantWait: { maxWaitMs: 5_000, intervalMs: 20 } });
1033
+ const toolName = 'bash';
1034
+ const input = { command: 'aborted-command' };
1035
+ const controller = new AbortController();
1036
+ const context = makeToolContext({
1037
+ guardianTrustClass: 'trusted_contact',
1038
+ signal: controller.signal,
1039
+ });
1040
+
1041
+ // Abort after 100ms
1042
+ setTimeout(() => controller.abort(), 100);
1043
+
1044
+ const result = await handler.checkPreExecutionGates(
1045
+ toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
1046
+ );
1047
+
1048
+ expect(result.allowed).toBe(false);
1049
+ if (result.allowed) return;
1050
+ expect(result.result.content).toBe('Cancelled');
1051
+
1052
+ // The canonical request should exist but followupState should be cleared
1053
+ const pending = listCanonicalGuardianRequests({
1054
+ kind: 'tool_grant_request',
1055
+ status: 'pending',
1056
+ });
1057
+ expect(pending.length).toBe(1);
1058
+
1059
+ const freshReq = getCanonicalGuardianRequest(pending[0].id);
1060
+ // After abort, followupState should be cleared so a later guardian
1061
+ // approval sends the retry notification
1062
+ expect(freshReq?.followupState).toBeNull();
1063
+ });
1064
+ });