@vellumai/assistant 0.4.1 → 0.4.3

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 (97) hide show
  1. package/ARCHITECTURE.md +84 -7
  2. package/bun.lock +0 -83
  3. package/docs/trusted-contact-access.md +20 -0
  4. package/package.json +2 -3
  5. package/src/__tests__/access-request-decision.test.ts +0 -1
  6. package/src/__tests__/assistant-id-boundary-guard.test.ts +290 -0
  7. package/src/__tests__/call-routes-http.test.ts +0 -25
  8. package/src/__tests__/channel-approval-routes.test.ts +55 -5
  9. package/src/__tests__/channel-guardian.test.ts +6 -5
  10. package/src/__tests__/config-schema.test.ts +2 -0
  11. package/src/__tests__/daemon-server-session-init.test.ts +54 -1
  12. package/src/__tests__/deterministic-verification-control-plane.test.ts +0 -1
  13. package/src/__tests__/guardian-actions-endpoint.test.ts +21 -0
  14. package/src/__tests__/guardian-decision-primitive-canonical.test.ts +4 -2
  15. package/src/__tests__/guardian-outbound-http.test.ts +0 -1
  16. package/src/__tests__/guardian-routing-invariants.test.ts +50 -9
  17. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +161 -2
  18. package/src/__tests__/inbound-invite-redemption.test.ts +0 -1
  19. package/src/__tests__/ingress-routes-http.test.ts +55 -0
  20. package/src/__tests__/non-member-access-request.test.ts +28 -1
  21. package/src/__tests__/notification-decision-strategy.test.ts +44 -0
  22. package/src/__tests__/relay-server.test.ts +644 -4
  23. package/src/__tests__/send-endpoint-busy.test.ts +129 -3
  24. package/src/__tests__/session-init.benchmark.test.ts +0 -1
  25. package/src/__tests__/session-runtime-assembly.test.ts +4 -1
  26. package/src/__tests__/session-surfaces-task-progress.test.ts +43 -0
  27. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +11 -1
  28. package/src/__tests__/trusted-contact-multichannel.test.ts +0 -1
  29. package/src/__tests__/trusted-contact-verification.test.ts +0 -1
  30. package/src/__tests__/twilio-routes.test.ts +4 -3
  31. package/src/__tests__/update-bulletin.test.ts +0 -1
  32. package/src/approvals/guardian-decision-primitive.ts +24 -2
  33. package/src/approvals/guardian-request-resolvers.ts +42 -3
  34. package/src/calls/call-constants.ts +8 -0
  35. package/src/calls/call-controller.ts +2 -1
  36. package/src/calls/call-domain.ts +5 -4
  37. package/src/calls/relay-server.ts +513 -116
  38. package/src/calls/twilio-routes.ts +3 -5
  39. package/src/calls/types.ts +1 -1
  40. package/src/calls/voice-session-bridge.ts +4 -3
  41. package/src/cli/core-commands.ts +7 -4
  42. package/src/config/bundled-skills/app-builder/SKILL.md +164 -1
  43. package/src/config/bundled-skills/vercel-token-setup/SKILL.md +214 -0
  44. package/src/config/calls-schema.ts +12 -0
  45. package/src/config/feature-flag-registry.json +0 -8
  46. package/src/config/vellum-skills/trusted-contacts/SKILL.md +8 -2
  47. package/src/daemon/handlers/config-channels.ts +5 -7
  48. package/src/daemon/handlers/config-inbox.ts +2 -0
  49. package/src/daemon/handlers/index.ts +2 -1
  50. package/src/daemon/handlers/publish.ts +11 -46
  51. package/src/daemon/handlers/sessions.ts +136 -13
  52. package/src/daemon/ipc-contract/apps.ts +1 -0
  53. package/src/daemon/ipc-contract/inbox.ts +4 -0
  54. package/src/daemon/ipc-contract/integrations.ts +3 -1
  55. package/src/daemon/server.ts +19 -3
  56. package/src/daemon/session-agent-loop.ts +35 -23
  57. package/src/daemon/session-runtime-assembly.ts +3 -1
  58. package/src/daemon/session-surfaces.ts +29 -1
  59. package/src/memory/app-store.ts +6 -0
  60. package/src/memory/conversation-crud.ts +2 -1
  61. package/src/memory/conversation-title-service.ts +16 -2
  62. package/src/memory/db-init.ts +4 -0
  63. package/src/memory/delivery-crud.ts +2 -1
  64. package/src/memory/embedding-local.ts +25 -13
  65. package/src/memory/embedding-runtime-manager.ts +24 -6
  66. package/src/memory/guardian-action-store.ts +2 -1
  67. package/src/memory/guardian-approvals.ts +3 -2
  68. package/src/memory/ingress-invite-store.ts +12 -2
  69. package/src/memory/ingress-member-store.ts +4 -3
  70. package/src/memory/migrations/124-voice-invite-display-metadata.ts +14 -0
  71. package/src/memory/migrations/index.ts +1 -0
  72. package/src/memory/schema.ts +10 -5
  73. package/src/notifications/copy-composer.ts +11 -1
  74. package/src/notifications/emit-signal.ts +2 -1
  75. package/src/runtime/access-request-helper.ts +11 -3
  76. package/src/runtime/actor-trust-resolver.ts +2 -2
  77. package/src/runtime/assistant-scope.ts +10 -0
  78. package/src/runtime/guardian-context-resolver.ts +5 -1
  79. package/src/runtime/guardian-outbound-actions.ts +5 -4
  80. package/src/runtime/guardian-reply-router.ts +12 -0
  81. package/src/runtime/http-server.ts +12 -20
  82. package/src/runtime/ingress-service.ts +14 -0
  83. package/src/runtime/invite-redemption-service.ts +2 -1
  84. package/src/runtime/middleware/twilio-validation.ts +2 -4
  85. package/src/runtime/routes/call-routes.ts +2 -1
  86. package/src/runtime/routes/channel-route-shared.ts +3 -3
  87. package/src/runtime/routes/conversation-attention-routes.ts +2 -1
  88. package/src/runtime/routes/conversation-routes.ts +33 -11
  89. package/src/runtime/routes/events-routes.ts +2 -3
  90. package/src/runtime/routes/inbound-conversation.ts +4 -3
  91. package/src/runtime/routes/inbound-message-handler.ts +16 -4
  92. package/src/runtime/routes/ingress-routes.ts +2 -0
  93. package/src/tools/apps/executors.ts +15 -0
  94. package/src/tools/calls/call-start.ts +2 -1
  95. package/src/tools/terminal/parser.ts +12 -0
  96. package/src/tools/tool-approval-handler.ts +2 -1
  97. package/src/workspace/git-service.ts +19 -0
@@ -0,0 +1,290 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+
5
+ import { describe, expect, test } from 'bun:test';
6
+
7
+ import { DAEMON_INTERNAL_ASSISTANT_ID } from '../runtime/assistant-scope.js';
8
+
9
+ /**
10
+ * Guard tests for the assistant identity boundary.
11
+ *
12
+ * The daemon uses a fixed internal scope constant (`DAEMON_INTERNAL_ASSISTANT_ID`)
13
+ * for all assistant-scoped storage. Public assistant IDs are an edge concern
14
+ * handled by the gateway/platform layer — they must not leak into daemon
15
+ * scoping logic.
16
+ *
17
+ * These tests prevent regressions by scanning source files for banned patterns:
18
+ * - No `normalizeAssistantId` usage in daemon/runtime scoping modules
19
+ * - No assistant-scoped route handlers in the daemon HTTP server
20
+ * - No hardcoded `'self'` string for assistant scoping (use the constant)
21
+ * - The constant itself equals `'self'`
22
+ */
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Helpers
26
+ // ---------------------------------------------------------------------------
27
+
28
+ /** Resolve repo root (tests run from assistant/). */
29
+ function getRepoRoot(): string {
30
+ return join(process.cwd(), '..');
31
+ }
32
+
33
+ /**
34
+ * Directories containing daemon/runtime source files that must not reference
35
+ * `normalizeAssistantId` or hardcode assistant scope strings.
36
+ *
37
+ * Each directory gets both a `*.ts` glob (top-level files) and a `**\/*.ts`
38
+ * glob (nested files) so that `git grep` matches at all directory depths.
39
+ */
40
+ const SCANNED_DIRS = [
41
+ 'assistant/src/runtime',
42
+ 'assistant/src/daemon',
43
+ 'assistant/src/memory',
44
+ 'assistant/src/approvals',
45
+ 'assistant/src/calls',
46
+ 'assistant/src/tools',
47
+ ];
48
+
49
+ const SCANNED_DIR_GLOBS = SCANNED_DIRS.flatMap((dir) => [`${dir}/*.ts`, `${dir}/**/*.ts`]);
50
+
51
+ function isTestFile(filePath: string): boolean {
52
+ return (
53
+ filePath.includes('/__tests__/') ||
54
+ filePath.endsWith('.test.ts') ||
55
+ filePath.endsWith('.test.js') ||
56
+ filePath.endsWith('.spec.ts') ||
57
+ filePath.endsWith('.spec.js')
58
+ );
59
+ }
60
+
61
+ function isMigrationFile(filePath: string): boolean {
62
+ return filePath.includes('/migrations/');
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Tests
67
+ // ---------------------------------------------------------------------------
68
+
69
+ describe('assistant ID boundary', () => {
70
+ // -------------------------------------------------------------------------
71
+ // Rule (d): The DAEMON_INTERNAL_ASSISTANT_ID constant equals 'self'
72
+ // -------------------------------------------------------------------------
73
+
74
+ test('DAEMON_INTERNAL_ASSISTANT_ID equals "self"', () => {
75
+ expect(DAEMON_INTERNAL_ASSISTANT_ID).toBe('self');
76
+ });
77
+
78
+ // -------------------------------------------------------------------------
79
+ // Rule (a): No normalizeAssistantId in daemon scoping paths — spot check
80
+ // -------------------------------------------------------------------------
81
+
82
+ test('no normalizeAssistantId imports in daemon scoping paths', () => {
83
+ // Key daemon/runtime files that previously used normalizeAssistantId
84
+ // should now use DAEMON_INTERNAL_ASSISTANT_ID instead.
85
+ const daemonScopingFiles = [
86
+ 'runtime/actor-trust-resolver.ts',
87
+ 'runtime/guardian-outbound-actions.ts',
88
+ 'daemon/handlers/config-channels.ts',
89
+ 'runtime/routes/channel-route-shared.ts',
90
+ 'calls/relay-server.ts',
91
+ ];
92
+
93
+ const srcDir = join(import.meta.dir, '..');
94
+ for (const relPath of daemonScopingFiles) {
95
+ const content = readFileSync(join(srcDir, relPath), 'utf-8');
96
+ expect(content).not.toContain("import { normalizeAssistantId }");
97
+ expect(content).not.toContain("import { normalizeAssistantId,");
98
+ expect(content).not.toContain("normalizeAssistantId(");
99
+ }
100
+ });
101
+
102
+ // -------------------------------------------------------------------------
103
+ // Rule (a): No normalizeAssistantId in daemon/runtime directories — broad scan
104
+ // -------------------------------------------------------------------------
105
+
106
+ test('no normalizeAssistantId usage across daemon/runtime source directories', () => {
107
+ const repoRoot = getRepoRoot();
108
+
109
+ // Scan all daemon/runtime source directories for any reference to
110
+ // normalizeAssistantId. The function is defined in util/platform.ts for
111
+ // gateway use — it must not appear in daemon scoping modules.
112
+ let grepOutput = '';
113
+ try {
114
+ grepOutput = execFileSync(
115
+ 'git',
116
+ ['grep', '-lE', 'normalizeAssistantId', '--', ...SCANNED_DIR_GLOBS],
117
+ { encoding: 'utf-8', cwd: repoRoot },
118
+ ).trim();
119
+ } catch (err) {
120
+ // Exit code 1 means no matches — happy path
121
+ if ((err as { status?: number }).status === 1) {
122
+ return;
123
+ }
124
+ throw err;
125
+ }
126
+
127
+ const files = grepOutput.split('\n').filter((f) => f.length > 0);
128
+ const violations = files.filter((f) => !isTestFile(f));
129
+
130
+ if (violations.length > 0) {
131
+ const message = [
132
+ 'Found daemon/runtime source files that reference `normalizeAssistantId`.',
133
+ 'Daemon code should use the `DAEMON_INTERNAL_ASSISTANT_ID` constant instead.',
134
+ 'The `normalizeAssistantId` function is for gateway/platform use only (defined in util/platform.ts).',
135
+ '',
136
+ 'Violations:',
137
+ ...violations.map((f) => ` - ${f}`),
138
+ ].join('\n');
139
+
140
+ expect(violations, message).toEqual([]);
141
+ }
142
+ });
143
+
144
+ // -------------------------------------------------------------------------
145
+ // Rule (b): No assistant-scoped route registration in daemon HTTP server
146
+ // -------------------------------------------------------------------------
147
+
148
+ test('no /v1/assistants/:assistantId/ route handler registration in daemon HTTP server', () => {
149
+ const httpServerPath = join(import.meta.dir, '..', 'runtime', 'http-server.ts');
150
+ const content = readFileSync(httpServerPath, 'utf-8');
151
+
152
+ // The daemon HTTP server must not contain any assistant-scoped route
153
+ // patterns. All routes use flat /v1/<endpoint> paths; the gateway handles
154
+ // legacy assistant-scoped URL rewriting in its runtime proxy layer.
155
+
156
+ // Check that there's no regex extracting assistantId from a /v1/assistants/ path.
157
+ // Match both literal slashes (/v1/assistants/([) and escaped slashes in regex
158
+ // literals (\/v1\/assistants\/([) so we catch patterns like:
159
+ // endpoint.match(/^\/v1\/assistants\/([^/]+)\/(.+)$/)
160
+ const routeHandlerRegex = /\\?\/v1\\?\/assistants\\?\/\(\[/;
161
+ const match = content.match(routeHandlerRegex);
162
+ expect(
163
+ match,
164
+ 'Found a route pattern matching /v1/assistants/([^/]+)/... that extracts an assistantId. ' +
165
+ 'The daemon HTTP server should not have assistant-scoped route handlers — ' +
166
+ 'use flat /v1/<endpoint> paths instead.',
167
+ ).toBeNull();
168
+
169
+ // Scan the entire file for assistant-scoped path literals. No references
170
+ // to /v1/assistants/ should exist — the daemon uses flat paths only.
171
+ const lines = content.split('\n');
172
+ const violations: string[] = [];
173
+
174
+ for (let i = 0; i < lines.length; i++) {
175
+ const line = lines[i];
176
+ // Match both literal /v1/assistants/ and escaped \/v1\/assistants\/
177
+ if (line.includes('/v1/assistants/') || line.includes('\\/v1\\/assistants\\/')) {
178
+ violations.push(` line ${i + 1}: ${line.trim()}`);
179
+ }
180
+ }
181
+
182
+ expect(
183
+ violations,
184
+ 'Found /v1/assistants/ references in the daemon HTTP server — ' +
185
+ 'the daemon should not have assistant-scoped path literals.\n' +
186
+ violations.join('\n'),
187
+ ).toEqual([]);
188
+
189
+ // Guard against prefix-less assistants/ route patterns that extract an
190
+ // assistantId. dispatchEndpoint receives the endpoint *after* the /v1/
191
+ // prefix has been stripped, so a regex like `assistants\/([^/]+)` would
192
+ // capture an external assistant ID from the path — violating the
193
+ // assistant-scoping boundary.
194
+ const prefixLessViolations: string[] = [];
195
+ for (let i = 0; i < lines.length; i++) {
196
+ const line = lines[i];
197
+ // Match regex patterns like assistants\/([^/]+) that capture the ID
198
+ // segment. We look for the escaped-slash form used inside JS regex
199
+ // literals (e.g. /^assistants\/([^/]+)\//).
200
+ if (/assistants\\\/\(\[/.test(line)) {
201
+ prefixLessViolations.push(` line ${i + 1}: ${line.trim()}`);
202
+ }
203
+ }
204
+
205
+ expect(
206
+ prefixLessViolations,
207
+ 'Found prefix-less assistants/([^/]+) route pattern that extracts an assistantId. ' +
208
+ 'The daemon should not parse assistant IDs from URL paths — use ' +
209
+ 'DAEMON_INTERNAL_ASSISTANT_ID instead.\n' +
210
+ prefixLessViolations.join('\n'),
211
+ ).toEqual([]);
212
+ });
213
+
214
+ // -------------------------------------------------------------------------
215
+ // Rule (c): No hardcoded 'self' for assistant scoping in daemon files
216
+ // -------------------------------------------------------------------------
217
+
218
+ test('no hardcoded \'self\' string for assistant scoping in daemon source files', () => {
219
+ const repoRoot = getRepoRoot();
220
+
221
+ // Search for patterns where 'self' is used as an assistant ID value.
222
+ // We look for assignment / default / comparison patterns that suggest
223
+ // using the raw string instead of the DAEMON_INTERNAL_ASSISTANT_ID constant.
224
+ //
225
+ // Patterns matched:
226
+ // assistantId: 'self'
227
+ // assistantId = 'self'
228
+ // assistantId ?? 'self'
229
+ // ?? 'self' (fallback to self)
230
+ // || 'self' (fallback to self)
231
+ //
232
+ // Excluded:
233
+ // - Test files (they may legitimately assert against the value)
234
+ // - Migration files (SQL literals like DEFAULT 'self' are fine)
235
+ // - IPC contract files (comments documenting default values are fine)
236
+ // - CSP headers ('self' in Content-Security-Policy has nothing to do with assistant IDs)
237
+ const pattern = `(assistantId|assistant_id).*['"]self['"]`;
238
+
239
+ let grepOutput = '';
240
+ try {
241
+ grepOutput = execFileSync(
242
+ 'git',
243
+ ['grep', '-nE', pattern, '--', ...SCANNED_DIR_GLOBS],
244
+ { encoding: 'utf-8', cwd: repoRoot },
245
+ ).trim();
246
+ } catch (err) {
247
+ // Exit code 1 means no matches — happy path
248
+ if ((err as { status?: number }).status === 1) {
249
+ return;
250
+ }
251
+ throw err;
252
+ }
253
+
254
+ const lines = grepOutput.split('\n').filter((l) => l.length > 0);
255
+ const violations = lines.filter((line) => {
256
+ const filePath = line.split(':')[0];
257
+ if (isTestFile(filePath)) return false;
258
+ if (isMigrationFile(filePath)) return false;
259
+
260
+ // Allow comments (lines where the code portion starts with //)
261
+ const parts = line.split(':');
262
+ // parts[0] = file, parts[1] = line number, rest = content
263
+ const content = parts.slice(2).join(':').trim();
264
+ if (content.startsWith('//') || content.startsWith('*') || content.startsWith('/*')) {
265
+ return false;
266
+ }
267
+
268
+ return true;
269
+ });
270
+
271
+ if (violations.length > 0) {
272
+ const message = [
273
+ "Found daemon/runtime source files with hardcoded 'self' for assistant scoping.",
274
+ 'Use the `DAEMON_INTERNAL_ASSISTANT_ID` constant from `runtime/assistant-scope.ts` instead.',
275
+ '',
276
+ 'Violations:',
277
+ ...violations.map((v) => ` - ${v}`),
278
+ ].join('\n');
279
+
280
+ expect(violations, message).toEqual([]);
281
+ }
282
+ });
283
+
284
+ // -------------------------------------------------------------------------
285
+ // Rule (d): Daemon storage keys don't contain external assistant IDs
286
+ // (verified by the constant value test above — if the constant is 'self',
287
+ // all daemon storage keyed by DAEMON_INTERNAL_ASSISTANT_ID uses the fixed
288
+ // internal value rather than externally-provided IDs).
289
+ // -------------------------------------------------------------------------
290
+ });
@@ -177,10 +177,6 @@ describe('runtime call routes — HTTP layer', () => {
177
177
  return `http://127.0.0.1:${port}/v1/calls${path}`;
178
178
  }
179
179
 
180
- function assistantCallsUrl(assistantId: string, path = ''): string {
181
- return `http://127.0.0.1:${port}/v1/assistants/${assistantId}/calls${path}`;
182
- }
183
-
184
180
  // ── POST /v1/calls/start ────────────────────────────────────────────
185
181
 
186
182
  test('POST /v1/calls/start returns 201 with call session', async () => {
@@ -235,27 +231,6 @@ describe('runtime call routes — HTTP layer', () => {
235
231
  await stopServer();
236
232
  });
237
233
 
238
- test('POST /v1/assistants/:assistantId/calls/start uses assistant-scoped caller number', async () => {
239
- await startServer();
240
- ensureConversation('conv-start-scoped-1');
241
-
242
- const res = await fetch(assistantCallsUrl('asst-alpha', '/start'), {
243
- method: 'POST',
244
- headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
245
- body: JSON.stringify({
246
- phoneNumber: '+15559997777',
247
- task: 'Check order status',
248
- conversationId: 'conv-start-scoped-1',
249
- }),
250
- });
251
-
252
- expect(res.status).toBe(201);
253
- const body = await res.json() as { fromNumber: string };
254
- expect(body.fromNumber).toBe('+15550009999');
255
-
256
- await stopServer();
257
- });
258
-
259
234
  test('POST /v1/calls/start returns 400 for invalid phone number', async () => {
260
235
  await startServer();
261
236
  ensureConversation('conv-start-2');
@@ -2615,6 +2615,56 @@ describe('background channel processing approval prompts', () => {
2615
2615
  deliverPromptSpy.mockRestore();
2616
2616
  });
2617
2617
 
2618
+ test('guardian prompt delivery still works when binding ID formatting differs from sender ID', async () => {
2619
+ // Guardian binding includes extra whitespace; trust resolution canonicalizes
2620
+ // identity and prompt delivery should still treat this sender as the guardian.
2621
+ createBinding({
2622
+ assistantId: 'self',
2623
+ channel: 'telegram',
2624
+ guardianExternalUserId: ' telegram-user-default ',
2625
+ guardianDeliveryChatId: 'chat-123',
2626
+ });
2627
+
2628
+ const deliverPromptSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined);
2629
+ const processCalls: Array<{ options?: Record<string, unknown> }> = [];
2630
+
2631
+ const processMessage = mock(async (
2632
+ conversationId: string,
2633
+ _content: string,
2634
+ _attachmentIds?: string[],
2635
+ options?: Record<string, unknown>,
2636
+ ) => {
2637
+ processCalls.push({ options });
2638
+
2639
+ registerPendingInteraction('req-bg-format-1', conversationId, 'host_bash', {
2640
+ input: { command: 'ls -la' },
2641
+ riskLevel: 'medium',
2642
+ });
2643
+
2644
+ await new Promise((resolve) => setTimeout(resolve, 350));
2645
+ return { messageId: 'msg-bg-format-1' };
2646
+ });
2647
+
2648
+ const req = makeInboundRequest({
2649
+ content: 'run ls',
2650
+ sourceChannel: 'telegram',
2651
+ replyCallbackUrl: 'https://gateway.test/deliver/telegram',
2652
+ externalMessageId: 'msg-bg-format-1',
2653
+ });
2654
+
2655
+ const res = await handleChannelInbound(req, processMessage as unknown as typeof noopProcessMessage, 'token');
2656
+ const body = await res.json() as Record<string, unknown>;
2657
+ expect(body.accepted).toBe(true);
2658
+
2659
+ await new Promise((resolve) => setTimeout(resolve, 700));
2660
+
2661
+ expect(processCalls.length).toBeGreaterThan(0);
2662
+ expect(processCalls[0].options?.isInteractive).toBe(true);
2663
+ expect(deliverPromptSpy).toHaveBeenCalled();
2664
+
2665
+ deliverPromptSpy.mockRestore();
2666
+ });
2667
+
2618
2668
  test('non-guardian channel turns are not interactive to prevent self-approval', async () => {
2619
2669
  // Set up a guardian binding for a DIFFERENT user so the sender is non-guardian
2620
2670
  createBinding({
@@ -2709,8 +2759,8 @@ describe('NL approval routing via destination-scoped canonical requests', () =>
2709
2759
  noopProcessMessage.mockClear();
2710
2760
  });
2711
2761
 
2712
- test('guardian plain-text "yes" resolves a pending_question with no guardianExternalUserId via delivery-scoped hint', async () => {
2713
- // Simulate a voice-originated pending_question without guardianExternalUserId
2762
+ test('guardian plain-text "yes" fails closed for tool_approval with no guardianExternalUserId', async () => {
2763
+ // Simulate a voice-originated tool approval without guardianExternalUserId
2714
2764
  const guardianChatId = 'guardian-chat-nl-1';
2715
2765
  const guardianUserId = 'guardian-user-nl-1';
2716
2766
 
@@ -2759,12 +2809,12 @@ describe('NL approval routing via destination-scoped canonical requests', () =>
2759
2809
  const body = await res.json() as Record<string, unknown>;
2760
2810
 
2761
2811
  expect(body.accepted).toBe(true);
2762
- expect(body.canonicalRouter).toBe('canonical_decision_applied');
2812
+ expect(body.canonicalRouter).toBe('canonical_decision_stale');
2763
2813
 
2764
- // Verify the request was resolved
2814
+ // Verify the request remains pending (identity-bound fail-closed).
2765
2815
  const resolved = getCanonicalGuardianRequest(canonicalReq.id);
2766
2816
  expect(resolved).not.toBeNull();
2767
- expect(resolved!.status).toBe('approved');
2817
+ expect(resolved!.status).toBe('pending');
2768
2818
  });
2769
2819
 
2770
2820
  test('inbound from different chat ID does not auto-match delivery-scoped canonical request', async () => {
@@ -22,7 +22,6 @@ mock.module('../util/platform.js', () => ({
22
22
  getDbPath: () => join(testDir, 'test.db'),
23
23
  getLogPath: () => join(testDir, 'test.log'),
24
24
  ensureDataDir: () => {},
25
- normalizeAssistantId: (id: string) => id === 'self' ? 'self' : id,
26
25
  readHttpToken: () => 'test-bearer-token',
27
26
  }));
28
27
 
@@ -1458,9 +1457,11 @@ describe('IPC handler channel-aware guardian status', () => {
1458
1457
  expect(resp!.channel).toBe('sms');
1459
1458
  });
1460
1459
 
1461
- test('status action with custom assistantId returns correct value', () => {
1460
+ test('status action with custom assistantId is ignored (daemon uses internal scope)', () => {
1461
+ // Create binding under the internal scope constant — the handler always
1462
+ // uses DAEMON_INTERNAL_ASSISTANT_ID regardless of what the caller passes.
1462
1463
  createBinding({
1463
- assistantId: 'asst-custom',
1464
+ assistantId: 'self',
1464
1465
  channel: 'telegram',
1465
1466
  guardianExternalUserId: 'user-77',
1466
1467
  guardianDeliveryChatId: 'chat-77',
@@ -1471,7 +1472,7 @@ describe('IPC handler channel-aware guardian status', () => {
1471
1472
  type: 'guardian_verification',
1472
1473
  action: 'status',
1473
1474
  channel: 'telegram',
1474
- assistantId: 'asst-custom',
1475
+ assistantId: 'asst-custom', // ignored by handler
1475
1476
  };
1476
1477
 
1477
1478
  handleGuardianVerification(msg, mockSocket, ctx);
@@ -1480,7 +1481,7 @@ describe('IPC handler channel-aware guardian status', () => {
1480
1481
  expect(resp).not.toBeNull();
1481
1482
  expect(resp!.success).toBe(true);
1482
1483
  expect(resp!.bound).toBe(true);
1483
- expect(resp!.assistantId).toBe('asst-custom');
1484
+ expect(resp!.assistantId).toBe('self');
1484
1485
  expect(resp!.channel).toBe('telegram');
1485
1486
  expect(resp!.guardianExternalUserId).toBe('user-77');
1486
1487
  expect(resp!.guardianDeliveryChatId).toBe('chat-77');
@@ -581,6 +581,8 @@ describe('AssistantConfigSchema', () => {
581
581
  provider: 'twilio',
582
582
  maxDurationSeconds: 3600,
583
583
  userConsultTimeoutSeconds: 120,
584
+ ttsPlaybackDelayMs: 3000,
585
+ accessRequestPollIntervalMs: 500,
584
586
  disclosure: {
585
587
  enabled: true,
586
588
  text: 'At the very beginning of the call, introduce yourself as an assistant calling on behalf of the person you represent. Do not say "AI assistant".',
@@ -36,6 +36,7 @@ let lastCreateConversationArgs: unknown;
36
36
  // field declarations create own-properties that mask prototype assignments.
37
37
  let mockConfirmationToEmitDuringLoop: Record<string, unknown> | undefined;
38
38
  let mockMidLoopCallback: ((session: MockSession) => void) | undefined;
39
+ let lastCanonicalGuardianCreateParams: Record<string, unknown> | undefined;
39
40
 
40
41
  class MockSession {
41
42
  public readonly conversationId: string;
@@ -253,7 +254,10 @@ mock.module('../memory/conversation-attention-store.js', () => ({
253
254
 
254
255
  mock.module('../memory/canonical-guardian-store.js', () => ({
255
256
  generateCanonicalRequestCode: () => 'mock-code-0000',
256
- createCanonicalGuardianRequest: () => ({ requestCode: 'mock-code-0000', status: 'pending' }),
257
+ createCanonicalGuardianRequest: (params: Record<string, unknown>) => {
258
+ lastCanonicalGuardianCreateParams = params;
259
+ return { requestCode: 'mock-code-0000', status: 'pending' };
260
+ },
257
261
  submitCanonicalRequest: () => ({ requestCode: 'mock-code-0000', status: 'pending' }),
258
262
  getCanonicalRequest: () => null,
259
263
  resolveCanonicalRequest: () => false,
@@ -342,6 +346,7 @@ describe('DaemonServer initial session hydration', () => {
342
346
  lastCreatedWorkingDir = undefined;
343
347
  lastCreatedMemoryPolicy = undefined;
344
348
  lastCreateConversationArgs = undefined;
349
+ lastCanonicalGuardianCreateParams = undefined;
345
350
  mockConfirmationToEmitDuringLoop = undefined;
346
351
  mockMidLoopCallback = undefined;
347
352
  pendingInteractions.clear();
@@ -686,6 +691,54 @@ describe('DaemonServer initial session hydration', () => {
686
691
  expect(interaction?.conversationId).toBe(conversation.id);
687
692
  });
688
693
 
694
+ test('confirmation_request canonical records include bound guardian identity context', async () => {
695
+ const server = new DaemonServer();
696
+
697
+ mockConfirmationToEmitDuringLoop = {
698
+ type: 'confirmation_request',
699
+ requestId: 'req-bound-1',
700
+ toolName: 'host_bash',
701
+ input: { command: 'ls' },
702
+ riskLevel: 'high',
703
+ allowlistOptions: [{ label: 'host_bash:*', description: 'host_bash:*', pattern: 'host_bash:*' }],
704
+ scopeOptions: [{ label: 'everywhere', scope: 'everywhere' }],
705
+ persistentDecisionsAllowed: true,
706
+ };
707
+
708
+ await server.processMessage(
709
+ conversation.id,
710
+ 'run ls',
711
+ undefined,
712
+ {
713
+ isInteractive: false,
714
+ guardianContext: {
715
+ sourceChannel: 'telegram',
716
+ trustClass: 'trusted_contact',
717
+ guardianExternalUserId: 'guardian-123',
718
+ requesterExternalUserId: 'trusted-456',
719
+ requesterChatId: 'chat-789',
720
+ },
721
+ },
722
+ 'telegram',
723
+ 'telegram',
724
+ );
725
+
726
+ expect(lastCanonicalGuardianCreateParams).toBeDefined();
727
+ expect(lastCanonicalGuardianCreateParams).toMatchObject({
728
+ id: 'req-bound-1',
729
+ kind: 'tool_approval',
730
+ sourceType: 'channel',
731
+ sourceChannel: 'telegram',
732
+ conversationId: conversation.id,
733
+ guardianExternalUserId: 'guardian-123',
734
+ requesterExternalUserId: 'trusted-456',
735
+ requesterChatId: 'chat-789',
736
+ toolName: 'host_bash',
737
+ status: 'pending',
738
+ requestCode: 'mock-code-0000',
739
+ });
740
+ });
741
+
689
742
  test('finally block does not overwrite IPC client that connected during interactive agent loop (processMessage)', async () => {
690
743
  const server = new DaemonServer();
691
744
  const internal = asDaemonServerTestAccess(server);
@@ -32,7 +32,6 @@ mock.module('../util/platform.js', () => ({
32
32
  getDbPath: () => join(testDir, 'test.db'),
33
33
  getLogPath: () => join(testDir, 'test.log'),
34
34
  ensureDataDir: () => {},
35
- normalizeAssistantId: (id: string) => id === 'self' ? 'self' : id,
36
35
  readHttpToken: () => 'test-bearer-token',
37
36
  }));
38
37
 
@@ -262,6 +262,27 @@ describe('HTTP handleGuardianActionDecision', () => {
262
262
  expect(mockApplyCanonicalGuardianDecision).toHaveBeenCalledTimes(1);
263
263
  });
264
264
 
265
+ test('applies decision for voice access_request kind through canonical primitive', async () => {
266
+ createTestCanonicalRequest({
267
+ conversationId: 'conv-voice-access',
268
+ requestId: 'req-voice-access-1',
269
+ kind: 'access_request',
270
+ toolName: 'ingress_access_request',
271
+ guardianExternalUserId: 'guardian-voice-42',
272
+ });
273
+ mockApplyCanonicalGuardianDecision.mockResolvedValueOnce({ applied: true, requestId: 'req-voice-access-1', grantMinted: false });
274
+
275
+ const req = new Request('http://localhost/v1/guardian-actions/decision', {
276
+ method: 'POST',
277
+ body: JSON.stringify({ requestId: 'req-voice-access-1', action: 'approve_once' }),
278
+ });
279
+ const res = await handleGuardianActionDecision(req);
280
+ expect(res.status).toBe(200);
281
+ const body = await res.json();
282
+ expect(body.applied).toBe(true);
283
+ expect(mockApplyCanonicalGuardianDecision).toHaveBeenCalledTimes(1);
284
+ });
285
+
265
286
  test('returns stale reason from canonical decision primitive', async () => {
266
287
  createTestCanonicalRequest({ conversationId: 'conv-stale', requestId: 'req-stale-1' });
267
288
  mockApplyCanonicalGuardianDecision.mockResolvedValueOnce({ applied: false, reason: 'already_resolved' });
@@ -248,7 +248,7 @@ describe('applyCanonicalGuardianDecision', () => {
248
248
  expect(result.grantMinted).toBe(false);
249
249
  });
250
250
 
251
- test('allows decision when request has no guardian binding', async () => {
251
+ test('rejects non-trusted decision when tool approval has no guardian binding', async () => {
252
252
  const req = createCanonicalGuardianRequest({
253
253
  kind: 'tool_approval',
254
254
  sourceType: 'channel',
@@ -263,7 +263,9 @@ describe('applyCanonicalGuardianDecision', () => {
263
263
  actorContext: guardianActor({ externalUserId: 'anyone' }),
264
264
  });
265
265
 
266
- expect(result.applied).toBe(true);
266
+ expect(result.applied).toBe(false);
267
+ if (result.applied) return;
268
+ expect(result.reason).toBe('identity_mismatch');
267
269
  });
268
270
 
269
271
  // ── Stale / already-resolved (race condition) ──────────────────────
@@ -35,7 +35,6 @@ mock.module('../util/platform.js', () => ({
35
35
  getDbPath: () => join(testDir, 'test.db'),
36
36
  getLogPath: () => join(testDir, 'test.log'),
37
37
  ensureDataDir: () => {},
38
- normalizeAssistantId: (id: string) => id === 'self' ? 'self' : id,
39
38
  readHttpToken: () => 'test-bearer-token',
40
39
  }));
41
40