@vellumai/assistant 0.3.19 → 0.3.21

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 (199) hide show
  1. package/ARCHITECTURE.md +151 -15
  2. package/Dockerfile +1 -0
  3. package/README.md +40 -4
  4. package/bun.lock +139 -2
  5. package/docs/architecture/integrations.md +7 -11
  6. package/package.json +2 -1
  7. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +54 -0
  8. package/src/__tests__/approval-primitive.test.ts +540 -0
  9. package/src/__tests__/assistant-feature-flag-guard.test.ts +206 -0
  10. package/src/__tests__/assistant-feature-flag-guardrails.test.ts +198 -0
  11. package/src/__tests__/assistant-feature-flags-integration.test.ts +272 -0
  12. package/src/__tests__/call-controller.test.ts +439 -108
  13. package/src/__tests__/channel-invite-transport.test.ts +264 -0
  14. package/src/__tests__/cli.test.ts +42 -1
  15. package/src/__tests__/config-schema.test.ts +11 -127
  16. package/src/__tests__/config-watcher.test.ts +0 -8
  17. package/src/__tests__/daemon-lifecycle.test.ts +1 -0
  18. package/src/__tests__/daemon-server-session-init.test.ts +8 -2
  19. package/src/__tests__/diff.test.ts +22 -0
  20. package/src/__tests__/guardian-action-copy-generator.test.ts +5 -0
  21. package/src/__tests__/guardian-action-grant-mint-consume.test.ts +300 -32
  22. package/src/__tests__/guardian-action-late-reply.test.ts +546 -1
  23. package/src/__tests__/guardian-actions-endpoint.test.ts +774 -0
  24. package/src/__tests__/guardian-control-plane-policy.test.ts +36 -3
  25. package/src/__tests__/guardian-dispatch.test.ts +124 -0
  26. package/src/__tests__/guardian-grant-minting.test.ts +6 -17
  27. package/src/__tests__/inbound-invite-redemption.test.ts +367 -0
  28. package/src/__tests__/invite-redemption-service.test.ts +306 -0
  29. package/src/__tests__/ipc-snapshot.test.ts +57 -0
  30. package/src/__tests__/notification-decision-fallback.test.ts +88 -0
  31. package/src/__tests__/sandbox-diagnostics.test.ts +6 -249
  32. package/src/__tests__/sandbox-host-parity.test.ts +6 -13
  33. package/src/__tests__/scoped-approval-grants.test.ts +6 -6
  34. package/src/__tests__/scoped-grant-security-matrix.test.ts +5 -4
  35. package/src/__tests__/script-proxy-session-manager.test.ts +1 -19
  36. package/src/__tests__/session-load-history-repair.test.ts +169 -2
  37. package/src/__tests__/session-runtime-assembly.test.ts +33 -5
  38. package/src/__tests__/skill-feature-flags-integration.test.ts +171 -0
  39. package/src/__tests__/skill-feature-flags.test.ts +188 -0
  40. package/src/__tests__/skill-load-feature-flag.test.ts +141 -0
  41. package/src/__tests__/skill-mirror-parity.test.ts +1 -0
  42. package/src/__tests__/skill-projection-feature-flag.test.ts +363 -0
  43. package/src/__tests__/system-prompt.test.ts +1 -1
  44. package/src/__tests__/terminal-sandbox.test.ts +142 -9
  45. package/src/__tests__/terminal-tools.test.ts +2 -93
  46. package/src/__tests__/thread-seed-composer.test.ts +18 -0
  47. package/src/__tests__/tool-approval-handler.test.ts +350 -0
  48. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +8 -10
  49. package/src/__tests__/voice-scoped-grant-consumer.test.ts +46 -84
  50. package/src/agent/loop.ts +36 -1
  51. package/src/approvals/approval-primitive.ts +381 -0
  52. package/src/approvals/guardian-decision-primitive.ts +191 -0
  53. package/src/calls/call-controller.ts +252 -209
  54. package/src/calls/call-domain.ts +44 -6
  55. package/src/calls/guardian-dispatch.ts +48 -0
  56. package/src/calls/types.ts +1 -1
  57. package/src/calls/voice-session-bridge.ts +46 -30
  58. package/src/cli/core-commands.ts +0 -4
  59. package/src/cli/mcp.ts +58 -0
  60. package/src/cli.ts +76 -34
  61. package/src/config/__tests__/feature-flag-registry-guard.test.ts +179 -0
  62. package/src/config/assistant-feature-flags.ts +162 -0
  63. package/src/config/bundled-skills/api-mapping/icon.svg +18 -0
  64. package/src/config/bundled-skills/messaging/TOOLS.json +30 -0
  65. package/src/config/bundled-skills/messaging/tools/slack-delete-message.ts +24 -0
  66. package/src/config/bundled-skills/notifications/SKILL.md +1 -1
  67. package/src/config/bundled-skills/reminder/SKILL.md +49 -2
  68. package/src/config/bundled-skills/time-based-actions/SKILL.md +49 -2
  69. package/src/config/bundled-skills/voice-setup/SKILL.md +122 -0
  70. package/src/config/core-schema.ts +1 -1
  71. package/src/config/env-registry.ts +10 -0
  72. package/src/config/feature-flag-registry.json +61 -0
  73. package/src/config/loader.ts +22 -1
  74. package/src/config/mcp-schema.ts +46 -0
  75. package/src/config/sandbox-schema.ts +0 -39
  76. package/src/config/schema.ts +18 -2
  77. package/src/config/skill-state.ts +34 -0
  78. package/src/config/skills-schema.ts +0 -1
  79. package/src/config/skills.ts +9 -0
  80. package/src/config/system-prompt.ts +110 -46
  81. package/src/config/templates/SOUL.md +1 -1
  82. package/src/config/types.ts +19 -1
  83. package/src/config/vellum-skills/catalog.json +1 -1
  84. package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +1 -0
  85. package/src/config/vellum-skills/sms-setup/SKILL.md +1 -1
  86. package/src/config/vellum-skills/telegram-setup/SKILL.md +6 -5
  87. package/src/config/vellum-skills/trusted-contacts/SKILL.md +105 -3
  88. package/src/config/vellum-skills/twilio-setup/SKILL.md +1 -1
  89. package/src/daemon/config-watcher.ts +0 -1
  90. package/src/daemon/daemon-control.ts +1 -1
  91. package/src/daemon/guardian-invite-intent.ts +124 -0
  92. package/src/daemon/handlers/avatar.ts +68 -0
  93. package/src/daemon/handlers/browser.ts +2 -2
  94. package/src/daemon/handlers/guardian-actions.ts +120 -0
  95. package/src/daemon/handlers/index.ts +4 -0
  96. package/src/daemon/handlers/sessions.ts +19 -0
  97. package/src/daemon/handlers/shared.ts +3 -1
  98. package/src/daemon/install-cli-launchers.ts +58 -13
  99. package/src/daemon/ipc-contract/guardian-actions.ts +53 -0
  100. package/src/daemon/ipc-contract/sessions.ts +8 -2
  101. package/src/daemon/ipc-contract/settings.ts +25 -2
  102. package/src/daemon/ipc-contract-inventory.json +10 -0
  103. package/src/daemon/ipc-contract.ts +4 -0
  104. package/src/daemon/lifecycle.ts +14 -2
  105. package/src/daemon/main.ts +1 -0
  106. package/src/daemon/providers-setup.ts +26 -1
  107. package/src/daemon/server.ts +1 -0
  108. package/src/daemon/session-lifecycle.ts +52 -7
  109. package/src/daemon/session-memory.ts +45 -0
  110. package/src/daemon/session-process.ts +258 -432
  111. package/src/daemon/session-runtime-assembly.ts +12 -0
  112. package/src/daemon/session-skill-tools.ts +14 -1
  113. package/src/daemon/session-tool-setup.ts +5 -0
  114. package/src/daemon/session.ts +11 -0
  115. package/src/daemon/shutdown-handlers.ts +11 -0
  116. package/src/daemon/tool-side-effects.ts +35 -9
  117. package/src/index.ts +2 -2
  118. package/src/mcp/client.ts +152 -0
  119. package/src/mcp/manager.ts +139 -0
  120. package/src/memory/conversation-display-order-migration.ts +44 -0
  121. package/src/memory/conversation-queries.ts +2 -0
  122. package/src/memory/conversation-store.ts +91 -0
  123. package/src/memory/db-init.ts +5 -1
  124. package/src/memory/embedding-local.ts +13 -8
  125. package/src/memory/guardian-action-store.ts +125 -2
  126. package/src/memory/ingress-invite-store.ts +95 -1
  127. package/src/memory/migrations/035-guardian-action-supersession.ts +23 -0
  128. package/src/memory/migrations/index.ts +2 -1
  129. package/src/memory/schema.ts +5 -1
  130. package/src/memory/scoped-approval-grants.ts +14 -5
  131. package/src/messaging/providers/slack/client.ts +12 -0
  132. package/src/messaging/providers/slack/types.ts +5 -0
  133. package/src/notifications/decision-engine.ts +49 -12
  134. package/src/notifications/emit-signal.ts +7 -0
  135. package/src/notifications/signal.ts +7 -0
  136. package/src/notifications/thread-seed-composer.ts +2 -1
  137. package/src/runtime/channel-approval-types.ts +16 -6
  138. package/src/runtime/channel-approvals.ts +19 -15
  139. package/src/runtime/channel-invite-transport.ts +85 -0
  140. package/src/runtime/channel-invite-transports/telegram.ts +105 -0
  141. package/src/runtime/guardian-action-grant-minter.ts +92 -35
  142. package/src/runtime/guardian-action-message-composer.ts +30 -0
  143. package/src/runtime/guardian-decision-types.ts +91 -0
  144. package/src/runtime/http-server.ts +23 -1
  145. package/src/runtime/ingress-service.ts +22 -0
  146. package/src/runtime/invite-redemption-service.ts +181 -0
  147. package/src/runtime/invite-redemption-templates.ts +39 -0
  148. package/src/runtime/routes/call-routes.ts +2 -1
  149. package/src/runtime/routes/guardian-action-routes.ts +206 -0
  150. package/src/runtime/routes/guardian-approval-interception.ts +66 -190
  151. package/src/runtime/routes/identity-routes.ts +73 -0
  152. package/src/runtime/routes/inbound-message-handler.ts +486 -394
  153. package/src/runtime/routes/pairing-routes.ts +4 -0
  154. package/src/security/encrypted-store.ts +31 -17
  155. package/src/security/keychain.ts +176 -2
  156. package/src/security/secure-keys.ts +97 -0
  157. package/src/security/tool-approval-digest.ts +1 -1
  158. package/src/tools/browser/browser-execution.ts +2 -2
  159. package/src/tools/browser/browser-manager.ts +46 -32
  160. package/src/tools/browser/browser-screencast.ts +2 -2
  161. package/src/tools/calls/call-start.ts +1 -1
  162. package/src/tools/executor.ts +22 -17
  163. package/src/tools/mcp/mcp-tool-factory.ts +100 -0
  164. package/src/tools/network/script-proxy/session-manager.ts +1 -5
  165. package/src/tools/registry.ts +64 -1
  166. package/src/tools/skills/load.ts +22 -8
  167. package/src/tools/system/avatar-generator.ts +119 -0
  168. package/src/tools/system/navigate-settings.ts +65 -0
  169. package/src/tools/system/open-system-settings.ts +75 -0
  170. package/src/tools/system/voice-config.ts +121 -32
  171. package/src/tools/terminal/backends/native.ts +40 -19
  172. package/src/tools/terminal/backends/types.ts +3 -3
  173. package/src/tools/terminal/parser.ts +1 -1
  174. package/src/tools/terminal/sandbox-diagnostics.ts +6 -87
  175. package/src/tools/terminal/sandbox.ts +1 -12
  176. package/src/tools/terminal/shell.ts +3 -31
  177. package/src/tools/tool-approval-handler.ts +141 -3
  178. package/src/tools/tool-manifest.ts +6 -0
  179. package/src/tools/types.ts +10 -2
  180. package/src/util/diff.ts +36 -13
  181. package/Dockerfile.sandbox +0 -5
  182. package/src/__tests__/doordash-client.test.ts +0 -187
  183. package/src/__tests__/doordash-session.test.ts +0 -154
  184. package/src/__tests__/signup-e2e.test.ts +0 -354
  185. package/src/__tests__/terminal-sandbox-docker.test.ts +0 -1065
  186. package/src/__tests__/terminal-sandbox.integration.test.ts +0 -180
  187. package/src/cli/doordash.ts +0 -1057
  188. package/src/config/bundled-skills/doordash/SKILL.md +0 -163
  189. package/src/config/templates/LOOKS.md +0 -25
  190. package/src/doordash/cart-queries.ts +0 -787
  191. package/src/doordash/client.ts +0 -1016
  192. package/src/doordash/order-queries.ts +0 -85
  193. package/src/doordash/queries.ts +0 -13
  194. package/src/doordash/query-extractor.ts +0 -94
  195. package/src/doordash/search-queries.ts +0 -203
  196. package/src/doordash/session.ts +0 -84
  197. package/src/doordash/store-queries.ts +0 -246
  198. package/src/doordash/types.ts +0 -367
  199. package/src/tools/terminal/backends/docker.ts +0 -379
@@ -0,0 +1,774 @@
1
+ /**
2
+ * Tests for the deterministic guardian action endpoints:
3
+ * - HTTP route handlers (guardian-action-routes.ts)
4
+ * - IPC handlers (guardian-actions.ts)
5
+ *
6
+ * Covers: conversationId scoping, stale handling, access-request routing,
7
+ * invalid action rejection, pending interaction fallback, and not-found paths.
8
+ */
9
+ import { mkdtempSync, rmSync } from 'node:fs';
10
+ import { tmpdir } from 'node:os';
11
+ import { join } from 'node:path';
12
+
13
+ import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test';
14
+
15
+ const testDir = mkdtempSync(join(tmpdir(), 'guardian-actions-endpoint-test-'));
16
+
17
+ mock.module('../util/platform.js', () => ({
18
+ getDataDir: () => testDir,
19
+ isMacOS: () => process.platform === 'darwin',
20
+ isLinux: () => process.platform === 'linux',
21
+ isWindows: () => process.platform === 'win32',
22
+ getSocketPath: () => join(testDir, 'test.sock'),
23
+ getPidPath: () => join(testDir, 'test.pid'),
24
+ getDbPath: () => join(testDir, 'test.db'),
25
+ getLogPath: () => join(testDir, 'test.log'),
26
+ ensureDataDir: () => {},
27
+ }));
28
+
29
+ mock.module('../util/logger.js', () => ({
30
+ getLogger: () =>
31
+ new Proxy({} as Record<string, unknown>, {
32
+ get: () => () => {},
33
+ }),
34
+ }));
35
+
36
+ // Mock applyGuardianDecision to avoid needing the full approval + session machinery
37
+ const mockApplyGuardianDecision = mock(
38
+ (..._args: any[]): { applied: boolean; requestId?: string; reason?: string; userText?: string } => ({
39
+ applied: true,
40
+ requestId: 'req-123',
41
+ }),
42
+ );
43
+ mock.module('../approvals/guardian-decision-primitive.js', () => ({
44
+ applyGuardianDecision: mockApplyGuardianDecision,
45
+ }));
46
+
47
+ // Mock handleChannelDecision for the pending-interactions fallback path
48
+ const mockHandleChannelDecision = mock(
49
+ (..._args: any[]): { applied: boolean; requestId?: string } => ({
50
+ applied: true,
51
+ requestId: 'req-456',
52
+ }),
53
+ );
54
+ mock.module('../runtime/channel-approvals.js', () => ({
55
+ handleChannelDecision: mockHandleChannelDecision,
56
+ }));
57
+
58
+ // Mock handleAccessRequestDecision for ingress_access_request routing
59
+ const mockHandleAccessRequestDecision = mock(
60
+ (..._args: any[]): { handled: boolean; type: string; verificationSessionId?: string; verificationCode?: string } => ({
61
+ handled: true,
62
+ type: 'approved',
63
+ verificationSessionId: 'vs-1',
64
+ verificationCode: '123456',
65
+ }),
66
+ );
67
+ mock.module('../runtime/routes/access-request-decision.js', () => ({
68
+ handleAccessRequestDecision: mockHandleAccessRequestDecision,
69
+ }));
70
+
71
+ import { guardianActionsHandlers } from '../daemon/handlers/guardian-actions.js';
72
+ import {
73
+ createApprovalRequest,
74
+ } from '../memory/channel-guardian-store.js';
75
+ import { initializeDb, resetDb } from '../memory/db.js';
76
+ import { getDb } from '../memory/db.js';
77
+ import { conversations } from '../memory/schema.js';
78
+ import * as pendingInteractions from '../runtime/pending-interactions.js';
79
+ import {
80
+ handleGuardianActionDecision,
81
+ handleGuardianActionsPending,
82
+ listGuardianDecisionPrompts,
83
+ } from '../runtime/routes/guardian-action-routes.js';
84
+
85
+ initializeDb();
86
+
87
+ function ensureConversation(id: string): void {
88
+ const db = getDb();
89
+ const now = Date.now();
90
+ db.insert(conversations)
91
+ .values({ id, title: `Conversation ${id}`, createdAt: now, updatedAt: now })
92
+ .run();
93
+ }
94
+
95
+ function resetTables(): void {
96
+ const db = getDb();
97
+ db.run('DELETE FROM channel_guardian_approval_requests');
98
+ db.run('DELETE FROM conversations');
99
+ pendingInteractions.clear();
100
+ mockApplyGuardianDecision.mockClear();
101
+ mockHandleChannelDecision.mockClear();
102
+ mockHandleAccessRequestDecision.mockClear();
103
+ }
104
+
105
+ /** Create a minimal pending approval for testing. */
106
+ function createTestApproval(overrides: {
107
+ conversationId: string;
108
+ requestId: string;
109
+ toolName?: string;
110
+ guardianExternalUserId?: string;
111
+ reason?: string;
112
+ }) {
113
+ ensureConversation(overrides.conversationId);
114
+ return createApprovalRequest({
115
+ runId: `run-${overrides.requestId}`,
116
+ requestId: overrides.requestId,
117
+ conversationId: overrides.conversationId,
118
+ channel: 'vellum',
119
+ requesterExternalUserId: 'user-1',
120
+ requesterChatId: 'chat-1',
121
+ guardianExternalUserId: overrides.guardianExternalUserId ?? 'guardian-1',
122
+ guardianChatId: 'gchat-1',
123
+ toolName: overrides.toolName ?? 'bash',
124
+ reason: overrides.reason,
125
+ expiresAt: Date.now() + 60_000,
126
+ });
127
+ }
128
+
129
+ // ── IPC helper ──────────────────────────────────────────────────────────
130
+
131
+ /** Minimal stub for IPC socket and context to capture sent messages. */
132
+ function createIpcStub() {
133
+ const sent: Array<Record<string, unknown>> = [];
134
+ const socket = {} as unknown; // opaque — the handler just passes it through
135
+ const ctx = {
136
+ send: (_socket: unknown, msg: Record<string, unknown>) => {
137
+ sent.push(msg);
138
+ },
139
+ };
140
+ return { socket, ctx, sent };
141
+ }
142
+
143
+ // ── Cleanup ─────────────────────────────────────────────────────────────
144
+
145
+ afterAll(() => {
146
+ resetDb();
147
+ try {
148
+ rmSync(testDir, { recursive: true });
149
+ } catch {
150
+ // best-effort
151
+ }
152
+ });
153
+
154
+ // =========================================================================
155
+ // HTTP route: handleGuardianActionDecision
156
+ // =========================================================================
157
+
158
+ describe('HTTP handleGuardianActionDecision', () => {
159
+ beforeEach(resetTables);
160
+
161
+ test('rejects missing requestId', async () => {
162
+ const req = new Request('http://localhost/v1/guardian-actions/decision', {
163
+ method: 'POST',
164
+ body: JSON.stringify({ action: 'approve_once' }),
165
+ });
166
+ const res = await handleGuardianActionDecision(req);
167
+ expect(res.status).toBe(400);
168
+ const body = await res.json();
169
+ expect(body.error.message).toContain('requestId');
170
+ });
171
+
172
+ test('rejects missing action', async () => {
173
+ const req = new Request('http://localhost/v1/guardian-actions/decision', {
174
+ method: 'POST',
175
+ body: JSON.stringify({ requestId: 'req-1' }),
176
+ });
177
+ const res = await handleGuardianActionDecision(req);
178
+ expect(res.status).toBe(400);
179
+ const body = await res.json();
180
+ expect(body.error.message).toContain('action');
181
+ });
182
+
183
+ test('rejects invalid action', async () => {
184
+ const req = new Request('http://localhost/v1/guardian-actions/decision', {
185
+ method: 'POST',
186
+ body: JSON.stringify({ requestId: 'req-1', action: 'nuke_from_orbit' }),
187
+ });
188
+ const res = await handleGuardianActionDecision(req);
189
+ expect(res.status).toBe(400);
190
+ const body = await res.json();
191
+ expect(body.error.message).toContain('Invalid action');
192
+ });
193
+
194
+ test('returns 404 when no pending approval or interaction exists', async () => {
195
+ const req = new Request('http://localhost/v1/guardian-actions/decision', {
196
+ method: 'POST',
197
+ body: JSON.stringify({ requestId: 'nonexistent', action: 'approve_once' }),
198
+ });
199
+ const res = await handleGuardianActionDecision(req);
200
+ expect(res.status).toBe(404);
201
+ });
202
+
203
+ test('applies decision via applyGuardianDecision for channel approval', async () => {
204
+ createTestApproval({ conversationId: 'conv-1', requestId: 'req-gd-1' });
205
+ mockApplyGuardianDecision.mockReturnValueOnce({ applied: true, requestId: 'req-gd-1' });
206
+
207
+ const req = new Request('http://localhost/v1/guardian-actions/decision', {
208
+ method: 'POST',
209
+ body: JSON.stringify({ requestId: 'req-gd-1', action: 'approve_once' }),
210
+ });
211
+ const res = await handleGuardianActionDecision(req);
212
+ expect(res.status).toBe(200);
213
+ const body = await res.json();
214
+ expect(body.applied).toBe(true);
215
+ expect(body.requestId).toBe('req-gd-1');
216
+ expect(mockApplyGuardianDecision).toHaveBeenCalledTimes(1);
217
+ });
218
+
219
+ test('rejects decision when conversationId does not match approval', async () => {
220
+ createTestApproval({ conversationId: 'conv-1', requestId: 'req-scope-1' });
221
+
222
+ const req = new Request('http://localhost/v1/guardian-actions/decision', {
223
+ method: 'POST',
224
+ body: JSON.stringify({ requestId: 'req-scope-1', action: 'approve_once', conversationId: 'conv-wrong' }),
225
+ });
226
+ const res = await handleGuardianActionDecision(req);
227
+ expect(res.status).toBe(400);
228
+ const body = await res.json();
229
+ expect(body.error.message).toContain('does not match');
230
+ expect(mockApplyGuardianDecision).not.toHaveBeenCalled();
231
+ });
232
+
233
+ test('allows decision when conversationId matches approval', async () => {
234
+ createTestApproval({ conversationId: 'conv-match', requestId: 'req-scope-2' });
235
+ mockApplyGuardianDecision.mockReturnValueOnce({ applied: true, requestId: 'req-scope-2' });
236
+
237
+ const req = new Request('http://localhost/v1/guardian-actions/decision', {
238
+ method: 'POST',
239
+ body: JSON.stringify({ requestId: 'req-scope-2', action: 'reject', conversationId: 'conv-match' }),
240
+ });
241
+ const res = await handleGuardianActionDecision(req);
242
+ expect(res.status).toBe(200);
243
+ const body = await res.json();
244
+ expect(body.applied).toBe(true);
245
+ });
246
+
247
+ test('allows decision when no conversationId is provided (backward compat)', async () => {
248
+ createTestApproval({ conversationId: 'conv-any', requestId: 'req-scope-3' });
249
+ mockApplyGuardianDecision.mockReturnValueOnce({ applied: true, requestId: 'req-scope-3' });
250
+
251
+ const req = new Request('http://localhost/v1/guardian-actions/decision', {
252
+ method: 'POST',
253
+ body: JSON.stringify({ requestId: 'req-scope-3', action: 'approve_once' }),
254
+ });
255
+ const res = await handleGuardianActionDecision(req);
256
+ expect(res.status).toBe(200);
257
+ });
258
+
259
+ test('routes ingress_access_request through handleAccessRequestDecision', async () => {
260
+ createTestApproval({
261
+ conversationId: 'conv-access',
262
+ requestId: 'req-access-1',
263
+ toolName: 'ingress_access_request',
264
+ guardianExternalUserId: 'guardian-42',
265
+ });
266
+
267
+ const req = new Request('http://localhost/v1/guardian-actions/decision', {
268
+ method: 'POST',
269
+ body: JSON.stringify({ requestId: 'req-access-1', action: 'approve_once' }),
270
+ });
271
+ const res = await handleGuardianActionDecision(req);
272
+ expect(res.status).toBe(200);
273
+ const body = await res.json();
274
+ expect(body.applied).toBe(true);
275
+ expect(body.accessRequestResult).toBeDefined();
276
+ expect(mockHandleAccessRequestDecision).toHaveBeenCalledTimes(1);
277
+ // Should NOT call applyGuardianDecision for access requests
278
+ expect(mockApplyGuardianDecision).not.toHaveBeenCalled();
279
+ });
280
+
281
+ test('maps reject to deny for access request decisions', async () => {
282
+ createTestApproval({
283
+ conversationId: 'conv-access-deny',
284
+ requestId: 'req-access-deny',
285
+ toolName: 'ingress_access_request',
286
+ });
287
+
288
+ const req = new Request('http://localhost/v1/guardian-actions/decision', {
289
+ method: 'POST',
290
+ body: JSON.stringify({ requestId: 'req-access-deny', action: 'reject' }),
291
+ });
292
+ await handleGuardianActionDecision(req);
293
+ const call = mockHandleAccessRequestDecision.mock.calls[0]!;
294
+ expect(call[1]).toBe('deny');
295
+ });
296
+
297
+ test('returns stale when access request decision is stale', async () => {
298
+ createTestApproval({
299
+ conversationId: 'conv-access-stale',
300
+ requestId: 'req-access-stale',
301
+ toolName: 'ingress_access_request',
302
+ });
303
+ mockHandleAccessRequestDecision.mockReturnValueOnce({
304
+ handled: false,
305
+ type: 'stale' as const,
306
+ });
307
+
308
+ const req = new Request('http://localhost/v1/guardian-actions/decision', {
309
+ method: 'POST',
310
+ body: JSON.stringify({ requestId: 'req-access-stale', action: 'approve_once' }),
311
+ });
312
+ const res = await handleGuardianActionDecision(req);
313
+ const body = await res.json();
314
+ expect(body.applied).toBe(false);
315
+ expect(body.reason).toBe('stale');
316
+ expect(body.requestId).toBe('req-access-stale');
317
+ });
318
+
319
+ test('preserves requestId in response when applyGuardianDecision returns stale without requestId', async () => {
320
+ createTestApproval({ conversationId: 'conv-stale', requestId: 'req-stale-1' });
321
+ mockApplyGuardianDecision.mockReturnValueOnce({ applied: false, reason: 'stale' });
322
+
323
+ const req = new Request('http://localhost/v1/guardian-actions/decision', {
324
+ method: 'POST',
325
+ body: JSON.stringify({ requestId: 'req-stale-1', action: 'approve_once' }),
326
+ });
327
+ const res = await handleGuardianActionDecision(req);
328
+ const body = await res.json();
329
+ expect(body.applied).toBe(false);
330
+ expect(body.reason).toBe('stale');
331
+ // requestId should fall back to the original msg requestId
332
+ expect(body.requestId).toBe('req-stale-1');
333
+ });
334
+
335
+ test('falls back to pending interactions when no channel approval exists', async () => {
336
+ const fakeSession = {} as any;
337
+ pendingInteractions.register('req-pi-1', {
338
+ session: fakeSession,
339
+ conversationId: 'conv-pi',
340
+ kind: 'confirmation',
341
+ });
342
+ mockHandleChannelDecision.mockReturnValueOnce({ applied: true, requestId: 'req-pi-1' });
343
+
344
+ const req = new Request('http://localhost/v1/guardian-actions/decision', {
345
+ method: 'POST',
346
+ body: JSON.stringify({ requestId: 'req-pi-1', action: 'approve_always' }),
347
+ });
348
+ const res = await handleGuardianActionDecision(req);
349
+ const body = await res.json();
350
+ expect(body.applied).toBe(true);
351
+ expect(mockHandleChannelDecision).toHaveBeenCalledTimes(1);
352
+ expect(mockApplyGuardianDecision).not.toHaveBeenCalled();
353
+ });
354
+
355
+ test('rejects interaction decision when conversationId mismatches', async () => {
356
+ const fakeSession = {} as any;
357
+ pendingInteractions.register('req-pi-scope', {
358
+ session: fakeSession,
359
+ conversationId: 'conv-pi-correct',
360
+ kind: 'confirmation',
361
+ });
362
+
363
+ const req = new Request('http://localhost/v1/guardian-actions/decision', {
364
+ method: 'POST',
365
+ body: JSON.stringify({ requestId: 'req-pi-scope', action: 'approve_once', conversationId: 'conv-pi-wrong' }),
366
+ });
367
+ const res = await handleGuardianActionDecision(req);
368
+ expect(res.status).toBe(400);
369
+ expect(mockHandleChannelDecision).not.toHaveBeenCalled();
370
+ });
371
+
372
+ test('passes actorExternalUserId as undefined (unauthenticated endpoint)', async () => {
373
+ createTestApproval({ conversationId: 'conv-actor', requestId: 'req-actor-1' });
374
+ mockApplyGuardianDecision.mockReturnValueOnce({ applied: true, requestId: 'req-actor-1' });
375
+
376
+ const req = new Request('http://localhost/v1/guardian-actions/decision', {
377
+ method: 'POST',
378
+ body: JSON.stringify({ requestId: 'req-actor-1', action: 'approve_once' }),
379
+ });
380
+ await handleGuardianActionDecision(req);
381
+ const call = mockApplyGuardianDecision.mock.calls[0]![0] as Record<string, unknown>;
382
+ expect(call.actorExternalUserId).toBeUndefined();
383
+ expect(call.actorChannel).toBe('vellum');
384
+ });
385
+ });
386
+
387
+ // =========================================================================
388
+ // HTTP route: handleGuardianActionsPending
389
+ // =========================================================================
390
+
391
+ describe('HTTP handleGuardianActionsPending', () => {
392
+ beforeEach(resetTables);
393
+
394
+ test('returns 400 when conversationId is missing', () => {
395
+ const req = new Request('http://localhost/v1/guardian-actions/pending');
396
+ const res = handleGuardianActionsPending(req);
397
+ expect(res.status).toBe(400);
398
+ });
399
+
400
+ test('returns prompts for a conversation with pending approvals', () => {
401
+ createTestApproval({ conversationId: 'conv-list', requestId: 'req-list-1', reason: 'Run bash: ls' });
402
+
403
+ const req = new Request('http://localhost/v1/guardian-actions/pending?conversationId=conv-list');
404
+ const res = handleGuardianActionsPending(req);
405
+ expect(res.status).toBe(200);
406
+
407
+ // Verify the prompts directly via the shared helper
408
+ const prompts = listGuardianDecisionPrompts({ conversationId: 'conv-list' });
409
+ expect(prompts).toHaveLength(1);
410
+ expect(prompts[0].requestId).toBe('req-list-1');
411
+ expect(prompts[0].questionText).toBe('Run bash: ls');
412
+ });
413
+
414
+ test('returns empty prompts for a conversation with no pending approvals', () => {
415
+ const prompts = listGuardianDecisionPrompts({ conversationId: 'conv-empty' });
416
+ expect(prompts).toHaveLength(0);
417
+ });
418
+ });
419
+
420
+ // =========================================================================
421
+ // listGuardianDecisionPrompts
422
+ // =========================================================================
423
+
424
+ describe('listGuardianDecisionPrompts', () => {
425
+ beforeEach(resetTables);
426
+
427
+ test('excludes expired approvals', () => {
428
+ ensureConversation('conv-expired');
429
+ // Create approval that's already expired
430
+ createApprovalRequest({
431
+ runId: 'run-expired',
432
+ requestId: 'req-expired',
433
+ conversationId: 'conv-expired',
434
+ channel: 'vellum',
435
+ requesterExternalUserId: 'user-1',
436
+ requesterChatId: 'chat-1',
437
+ guardianExternalUserId: 'guardian-1',
438
+ guardianChatId: 'gchat-1',
439
+ toolName: 'bash',
440
+ expiresAt: Date.now() - 1000, // already expired
441
+ });
442
+
443
+ const prompts = listGuardianDecisionPrompts({ conversationId: 'conv-expired' });
444
+ expect(prompts).toHaveLength(0);
445
+ });
446
+
447
+ test('excludes approvals without requestId', () => {
448
+ ensureConversation('conv-no-reqid');
449
+ createApprovalRequest({
450
+ runId: 'run-no-reqid',
451
+ // no requestId
452
+ conversationId: 'conv-no-reqid',
453
+ channel: 'vellum',
454
+ requesterExternalUserId: 'user-1',
455
+ requesterChatId: 'chat-1',
456
+ guardianExternalUserId: 'guardian-1',
457
+ guardianChatId: 'gchat-1',
458
+ toolName: 'bash',
459
+ expiresAt: Date.now() + 60_000,
460
+ });
461
+
462
+ const prompts = listGuardianDecisionPrompts({ conversationId: 'conv-no-reqid' });
463
+ expect(prompts).toHaveLength(0);
464
+ });
465
+
466
+ test('includes pending interaction confirmations', () => {
467
+ const fakeSession = {} as any;
468
+ pendingInteractions.register('req-int-prompt', {
469
+ session: fakeSession,
470
+ conversationId: 'conv-int-prompt',
471
+ kind: 'confirmation',
472
+ confirmationDetails: {
473
+ toolName: 'read_file',
474
+ input: { path: '/etc/passwd' },
475
+ riskLevel: 'high',
476
+ allowlistOptions: [],
477
+ scopeOptions: [],
478
+ persistentDecisionsAllowed: true,
479
+ },
480
+ });
481
+
482
+ const prompts = listGuardianDecisionPrompts({ conversationId: 'conv-int-prompt' });
483
+ expect(prompts).toHaveLength(1);
484
+ expect(prompts[0].toolName).toBe('read_file');
485
+ expect(prompts[0].requestId).toBe('req-int-prompt');
486
+ });
487
+
488
+ test('deduplicates interactions that share a requestId with a channel approval', () => {
489
+ createTestApproval({ conversationId: 'conv-dedup', requestId: 'req-dedup-shared' });
490
+
491
+ const fakeSession = {} as any;
492
+ pendingInteractions.register('req-dedup-shared', {
493
+ session: fakeSession,
494
+ conversationId: 'conv-dedup',
495
+ kind: 'confirmation',
496
+ confirmationDetails: {
497
+ toolName: 'bash',
498
+ input: {},
499
+ riskLevel: 'medium',
500
+ allowlistOptions: [],
501
+ scopeOptions: [],
502
+ },
503
+ });
504
+
505
+ const prompts = listGuardianDecisionPrompts({ conversationId: 'conv-dedup' });
506
+ // Should only appear once (from the channel approval)
507
+ expect(prompts).toHaveLength(1);
508
+ expect(prompts[0].requestId).toBe('req-dedup-shared');
509
+ });
510
+
511
+ test('skips non-confirmation interactions', () => {
512
+ const fakeSession = {} as any;
513
+ pendingInteractions.register('req-secret', {
514
+ session: fakeSession,
515
+ conversationId: 'conv-secret',
516
+ kind: 'secret',
517
+ });
518
+
519
+ const prompts = listGuardianDecisionPrompts({ conversationId: 'conv-secret' });
520
+ expect(prompts).toHaveLength(0);
521
+ });
522
+ });
523
+
524
+ // =========================================================================
525
+ // IPC handler: guardian_action_decision
526
+ // =========================================================================
527
+
528
+ describe('IPC guardian_action_decision', () => {
529
+ beforeEach(resetTables);
530
+
531
+ const handler = guardianActionsHandlers.guardian_action_decision;
532
+
533
+ test('rejects invalid action', () => {
534
+ const { socket, ctx, sent } = createIpcStub();
535
+ handler(
536
+ { type: 'guardian_action_decision', requestId: 'req-ipc-1', action: 'self_destruct' } as any,
537
+ socket as any,
538
+ ctx as any,
539
+ );
540
+ expect(sent).toHaveLength(1);
541
+ expect(sent[0].applied).toBe(false);
542
+ expect(sent[0].reason).toBe('invalid_action');
543
+ expect(sent[0].requestId).toBe('req-ipc-1');
544
+ });
545
+
546
+ test('returns not_found when no approval or interaction exists', () => {
547
+ const { socket, ctx, sent } = createIpcStub();
548
+ handler(
549
+ { type: 'guardian_action_decision', requestId: 'req-ghost', action: 'approve_once' } as any,
550
+ socket as any,
551
+ ctx as any,
552
+ );
553
+ expect(sent).toHaveLength(1);
554
+ expect(sent[0].applied).toBe(false);
555
+ expect(sent[0].reason).toBe('not_found');
556
+ });
557
+
558
+ test('applies decision via applyGuardianDecision for channel approval', () => {
559
+ createTestApproval({ conversationId: 'conv-ipc-1', requestId: 'req-ipc-gd' });
560
+ mockApplyGuardianDecision.mockReturnValueOnce({ applied: true, requestId: 'req-ipc-gd' });
561
+
562
+ const { socket, ctx, sent } = createIpcStub();
563
+ handler(
564
+ { type: 'guardian_action_decision', requestId: 'req-ipc-gd', action: 'approve_once' } as any,
565
+ socket as any,
566
+ ctx as any,
567
+ );
568
+ expect(sent).toHaveLength(1);
569
+ expect(sent[0].applied).toBe(true);
570
+ expect(sent[0].requestId).toBe('req-ipc-gd');
571
+ expect(mockApplyGuardianDecision).toHaveBeenCalledTimes(1);
572
+ });
573
+
574
+ test('rejects decision when conversationId does not match approval', () => {
575
+ createTestApproval({ conversationId: 'conv-ipc-correct', requestId: 'req-ipc-scope' });
576
+
577
+ const { socket, ctx, sent } = createIpcStub();
578
+ handler(
579
+ {
580
+ type: 'guardian_action_decision',
581
+ requestId: 'req-ipc-scope',
582
+ action: 'approve_once',
583
+ conversationId: 'conv-ipc-wrong',
584
+ } as any,
585
+ socket as any,
586
+ ctx as any,
587
+ );
588
+ expect(sent).toHaveLength(1);
589
+ expect(sent[0].applied).toBe(false);
590
+ expect(sent[0].reason).toBe('conversation_mismatch');
591
+ expect(sent[0].requestId).toBe('req-ipc-scope');
592
+ expect(mockApplyGuardianDecision).not.toHaveBeenCalled();
593
+ });
594
+
595
+ test('allows decision when conversationId matches', () => {
596
+ createTestApproval({ conversationId: 'conv-ipc-match', requestId: 'req-ipc-match' });
597
+ mockApplyGuardianDecision.mockReturnValueOnce({ applied: true, requestId: 'req-ipc-match' });
598
+
599
+ const { socket, ctx, sent } = createIpcStub();
600
+ handler(
601
+ {
602
+ type: 'guardian_action_decision',
603
+ requestId: 'req-ipc-match',
604
+ action: 'reject',
605
+ conversationId: 'conv-ipc-match',
606
+ } as any,
607
+ socket as any,
608
+ ctx as any,
609
+ );
610
+ expect(sent).toHaveLength(1);
611
+ expect(sent[0].applied).toBe(true);
612
+ });
613
+
614
+ test('routes ingress_access_request through handleAccessRequestDecision', () => {
615
+ createTestApproval({
616
+ conversationId: 'conv-ipc-access',
617
+ requestId: 'req-ipc-access',
618
+ toolName: 'ingress_access_request',
619
+ guardianExternalUserId: 'guardian-99',
620
+ });
621
+
622
+ const { socket, ctx, sent } = createIpcStub();
623
+ handler(
624
+ { type: 'guardian_action_decision', requestId: 'req-ipc-access', action: 'approve_once' } as any,
625
+ socket as any,
626
+ ctx as any,
627
+ );
628
+ expect(sent).toHaveLength(1);
629
+ expect(sent[0].applied).toBe(true);
630
+ expect(mockHandleAccessRequestDecision).toHaveBeenCalledTimes(1);
631
+ // Actor is 'desktop' because this endpoint is unauthenticated —
632
+ // we cannot verify the caller is the assigned guardian.
633
+ const call = mockHandleAccessRequestDecision.mock.calls[0]!;
634
+ expect(call[2]).toBe('desktop');
635
+ });
636
+
637
+ test('returns stale for stale access request', () => {
638
+ createTestApproval({
639
+ conversationId: 'conv-ipc-stale-ar',
640
+ requestId: 'req-ipc-stale-ar',
641
+ toolName: 'ingress_access_request',
642
+ });
643
+ mockHandleAccessRequestDecision.mockReturnValueOnce({
644
+ handled: false,
645
+ type: 'stale' as const,
646
+ });
647
+
648
+ const { socket, ctx, sent } = createIpcStub();
649
+ handler(
650
+ { type: 'guardian_action_decision', requestId: 'req-ipc-stale-ar', action: 'approve_once' } as any,
651
+ socket as any,
652
+ ctx as any,
653
+ );
654
+ expect(sent).toHaveLength(1);
655
+ expect(sent[0].applied).toBe(false);
656
+ expect(sent[0].reason).toBe('stale');
657
+ expect(sent[0].requestId).toBe('req-ipc-stale-ar');
658
+ });
659
+
660
+ test('preserves requestId when applyGuardianDecision returns without one', () => {
661
+ createTestApproval({ conversationId: 'conv-ipc-stale', requestId: 'req-ipc-stale' });
662
+ mockApplyGuardianDecision.mockReturnValueOnce({ applied: false, reason: 'stale' });
663
+
664
+ const { socket, ctx, sent } = createIpcStub();
665
+ handler(
666
+ { type: 'guardian_action_decision', requestId: 'req-ipc-stale', action: 'approve_once' } as any,
667
+ socket as any,
668
+ ctx as any,
669
+ );
670
+ expect(sent).toHaveLength(1);
671
+ expect(sent[0].requestId).toBe('req-ipc-stale');
672
+ expect(sent[0].reason).toBe('stale');
673
+ });
674
+
675
+ test('falls back to pending interactions', () => {
676
+ const fakeSession = {} as any;
677
+ pendingInteractions.register('req-ipc-pi', {
678
+ session: fakeSession,
679
+ conversationId: 'conv-ipc-pi',
680
+ kind: 'confirmation',
681
+ });
682
+ mockHandleChannelDecision.mockReturnValueOnce({ applied: true, requestId: 'req-ipc-pi' });
683
+
684
+ const { socket, ctx, sent } = createIpcStub();
685
+ handler(
686
+ { type: 'guardian_action_decision', requestId: 'req-ipc-pi', action: 'approve_always' } as any,
687
+ socket as any,
688
+ ctx as any,
689
+ );
690
+ expect(sent).toHaveLength(1);
691
+ expect(sent[0].applied).toBe(true);
692
+ expect(mockHandleChannelDecision).toHaveBeenCalledTimes(1);
693
+ });
694
+
695
+ test('rejects interaction fallback when conversationId mismatches', () => {
696
+ const fakeSession = {} as any;
697
+ pendingInteractions.register('req-ipc-pi-scope', {
698
+ session: fakeSession,
699
+ conversationId: 'conv-ipc-pi-right',
700
+ kind: 'confirmation',
701
+ });
702
+
703
+ const { socket, ctx, sent } = createIpcStub();
704
+ handler(
705
+ {
706
+ type: 'guardian_action_decision',
707
+ requestId: 'req-ipc-pi-scope',
708
+ action: 'approve_once',
709
+ conversationId: 'conv-ipc-pi-wrong',
710
+ } as any,
711
+ socket as any,
712
+ ctx as any,
713
+ );
714
+ expect(sent).toHaveLength(1);
715
+ expect(sent[0].applied).toBe(false);
716
+ expect(sent[0].reason).toBe('conversation_mismatch');
717
+ expect(mockHandleChannelDecision).not.toHaveBeenCalled();
718
+ });
719
+
720
+ test('passes actorExternalUserId as undefined (unauthenticated endpoint)', () => {
721
+ createTestApproval({ conversationId: 'conv-ipc-actor', requestId: 'req-ipc-actor' });
722
+ mockApplyGuardianDecision.mockReturnValueOnce({ applied: true, requestId: 'req-ipc-actor' });
723
+
724
+ const { socket, ctx } = createIpcStub();
725
+ handler(
726
+ { type: 'guardian_action_decision', requestId: 'req-ipc-actor', action: 'approve_once' } as any,
727
+ socket as any,
728
+ ctx as any,
729
+ );
730
+ const call = mockApplyGuardianDecision.mock.calls[0]![0] as Record<string, unknown>;
731
+ expect(call.actorExternalUserId).toBeUndefined();
732
+ expect(call.actorChannel).toBe('vellum');
733
+ });
734
+ });
735
+
736
+ // =========================================================================
737
+ // IPC handler: guardian_actions_pending_request
738
+ // =========================================================================
739
+
740
+ describe('IPC guardian_actions_pending_request', () => {
741
+ beforeEach(resetTables);
742
+
743
+ const handler = guardianActionsHandlers.guardian_actions_pending_request;
744
+
745
+ test('returns prompts for a conversation', () => {
746
+ createTestApproval({ conversationId: 'conv-ipc-list', requestId: 'req-ipc-list', reason: 'Run bash: pwd' });
747
+
748
+ const { socket, ctx, sent } = createIpcStub();
749
+ handler(
750
+ { type: 'guardian_actions_pending_request', conversationId: 'conv-ipc-list' } as any,
751
+ socket as any,
752
+ ctx as any,
753
+ );
754
+ expect(sent).toHaveLength(1);
755
+ expect(sent[0].type).toBe('guardian_actions_pending_response');
756
+ expect(sent[0].conversationId).toBe('conv-ipc-list');
757
+ const prompts = sent[0].prompts as Array<{ requestId: string; questionText: string }>;
758
+ expect(prompts).toHaveLength(1);
759
+ expect(prompts[0].requestId).toBe('req-ipc-list');
760
+ expect(prompts[0].questionText).toBe('Run bash: pwd');
761
+ });
762
+
763
+ test('returns empty prompts for conversation with no pending approvals', () => {
764
+ const { socket, ctx, sent } = createIpcStub();
765
+ handler(
766
+ { type: 'guardian_actions_pending_request', conversationId: 'conv-empty-ipc' } as any,
767
+ socket as any,
768
+ ctx as any,
769
+ );
770
+ expect(sent).toHaveLength(1);
771
+ const prompts = sent[0].prompts as unknown[];
772
+ expect(prompts).toHaveLength(0);
773
+ });
774
+ });