@vellumai/assistant 0.3.28 → 0.4.1

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 (201) hide show
  1. package/ARCHITECTURE.md +33 -3
  2. package/bun.lock +4 -1
  3. package/docs/trusted-contact-access.md +9 -2
  4. package/package.json +6 -3
  5. package/scripts/ipc/generate-swift.ts +3 -3
  6. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +80 -0
  7. package/src/__tests__/agent-loop-thinking.test.ts +1 -1
  8. package/src/__tests__/approval-routes-http.test.ts +13 -5
  9. package/src/__tests__/asset-materialize-tool.test.ts +2 -0
  10. package/src/__tests__/asset-search-tool.test.ts +2 -0
  11. package/src/__tests__/assistant-events-sse-hardening.test.ts +4 -2
  12. package/src/__tests__/attachments-store.test.ts +2 -0
  13. package/src/__tests__/browser-skill-endstate.test.ts +3 -3
  14. package/src/__tests__/call-controller.test.ts +30 -29
  15. package/src/__tests__/call-routes-http.test.ts +34 -32
  16. package/src/__tests__/call-start-guardian-guard.test.ts +2 -0
  17. package/src/__tests__/channel-invite-transport.test.ts +6 -6
  18. package/src/__tests__/channel-reply-delivery.test.ts +19 -0
  19. package/src/__tests__/channel-retry-sweep.test.ts +130 -0
  20. package/src/__tests__/clarification-resolver.test.ts +2 -0
  21. package/src/__tests__/claude-code-skill-regression.test.ts +2 -0
  22. package/src/__tests__/claude-code-tool-profiles.test.ts +2 -0
  23. package/src/__tests__/commit-message-enrichment-service.test.ts +9 -1
  24. package/src/__tests__/computer-use-session-lifecycle.test.ts +2 -0
  25. package/src/__tests__/computer-use-session-working-dir.test.ts +1 -0
  26. package/src/__tests__/computer-use-skill-lifecycle-cleanup.test.ts +2 -0
  27. package/src/__tests__/config-schema.test.ts +5 -5
  28. package/src/__tests__/config-watcher.test.ts +3 -1
  29. package/src/__tests__/connection-policy.test.ts +14 -5
  30. package/src/__tests__/contacts-tools.test.ts +3 -1
  31. package/src/__tests__/contradiction-checker.test.ts +2 -0
  32. package/src/__tests__/conversation-pairing.test.ts +10 -0
  33. package/src/__tests__/conversation-routes.test.ts +1 -1
  34. package/src/__tests__/credential-security-invariants.test.ts +16 -6
  35. package/src/__tests__/credential-vault-unit.test.ts +2 -2
  36. package/src/__tests__/credential-vault.test.ts +5 -4
  37. package/src/__tests__/daemon-lifecycle.test.ts +9 -0
  38. package/src/__tests__/daemon-server-session-init.test.ts +27 -0
  39. package/src/__tests__/elevenlabs-config.test.ts +2 -0
  40. package/src/__tests__/encrypted-store.test.ts +10 -5
  41. package/src/__tests__/followup-tools.test.ts +3 -1
  42. package/src/__tests__/gateway-only-enforcement.test.ts +21 -21
  43. package/src/__tests__/gmail-integration.test.ts +0 -1
  44. package/src/__tests__/guardian-control-plane-policy.test.ts +25 -21
  45. package/src/__tests__/guardian-dispatch.test.ts +2 -0
  46. package/src/__tests__/guardian-grant-minting.test.ts +68 -1
  47. package/src/__tests__/guardian-outbound-http.test.ts +12 -9
  48. package/src/__tests__/guardian-routing-invariants.test.ts +138 -0
  49. package/src/__tests__/handle-user-message-secret-resume.test.ts +1 -0
  50. package/src/__tests__/handlers-slack-config.test.ts +3 -1
  51. package/src/__tests__/handlers-telegram-config.test.ts +3 -1
  52. package/src/__tests__/handlers-twilio-config.test.ts +3 -1
  53. package/src/__tests__/handlers-twitter-config.test.ts +3 -1
  54. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +318 -0
  55. package/src/__tests__/heartbeat-service.test.ts +20 -0
  56. package/src/__tests__/inbound-invite-redemption.test.ts +33 -0
  57. package/src/__tests__/ingress-reconcile.test.ts +3 -1
  58. package/src/__tests__/ingress-routes-http.test.ts +231 -4
  59. package/src/__tests__/intent-routing.test.ts +2 -0
  60. package/src/__tests__/ipc-snapshot.test.ts +13 -0
  61. package/src/__tests__/media-generate-image.test.ts +21 -0
  62. package/src/__tests__/media-reuse-story.e2e.test.ts +2 -0
  63. package/src/__tests__/memory-regressions.test.ts +20 -20
  64. package/src/__tests__/non-member-access-request.test.ts +183 -9
  65. package/src/__tests__/notification-decision-fallback.test.ts +2 -0
  66. package/src/__tests__/notification-decision-strategy.test.ts +61 -0
  67. package/src/__tests__/notification-guardian-path.test.ts +2 -0
  68. package/src/__tests__/oauth-connect-handler.test.ts +3 -1
  69. package/src/__tests__/oauth2-gateway-transport.test.ts +2 -0
  70. package/src/__tests__/onboarding-starter-tasks.test.ts +4 -4
  71. package/src/__tests__/pairing-routes.test.ts +171 -0
  72. package/src/__tests__/playbook-execution.test.ts +3 -1
  73. package/src/__tests__/playbook-tools.test.ts +3 -1
  74. package/src/__tests__/provider-error-scenarios.test.ts +59 -8
  75. package/src/__tests__/proxy-approval-callback.test.ts +2 -0
  76. package/src/__tests__/recording-handler.test.ts +11 -0
  77. package/src/__tests__/recording-intent-handler.test.ts +15 -0
  78. package/src/__tests__/recording-state-machine.test.ts +13 -2
  79. package/src/__tests__/registry.test.ts +7 -3
  80. package/src/__tests__/relay-server.test.ts +148 -28
  81. package/src/__tests__/runtime-attachment-metadata.test.ts +4 -2
  82. package/src/__tests__/runtime-events-sse-parity.test.ts +21 -0
  83. package/src/__tests__/runtime-events-sse.test.ts +4 -2
  84. package/src/__tests__/sandbox-diagnostics.test.ts +2 -0
  85. package/src/__tests__/schedule-tools.test.ts +3 -1
  86. package/src/__tests__/send-endpoint-busy.test.ts +288 -0
  87. package/src/__tests__/session-abort-tool-results.test.ts +23 -0
  88. package/src/__tests__/session-agent-loop.test.ts +16 -0
  89. package/src/__tests__/session-conflict-gate.test.ts +21 -0
  90. package/src/__tests__/session-load-history-repair.test.ts +27 -17
  91. package/src/__tests__/session-pre-run-repair.test.ts +23 -0
  92. package/src/__tests__/session-profile-injection.test.ts +21 -0
  93. package/src/__tests__/session-provider-retry-repair.test.ts +20 -0
  94. package/src/__tests__/session-queue.test.ts +23 -0
  95. package/src/__tests__/session-runtime-assembly.test.ts +50 -12
  96. package/src/__tests__/session-skill-tools.test.ts +27 -5
  97. package/src/__tests__/session-slash-known.test.ts +23 -0
  98. package/src/__tests__/session-slash-queue.test.ts +23 -0
  99. package/src/__tests__/session-slash-unknown.test.ts +23 -0
  100. package/src/__tests__/session-workspace-cache-state.test.ts +7 -0
  101. package/src/__tests__/session-workspace-injection.test.ts +21 -0
  102. package/src/__tests__/session-workspace-tool-tracking.test.ts +21 -0
  103. package/src/__tests__/shell-credential-ref.test.ts +2 -0
  104. package/src/__tests__/skill-feature-flags-integration.test.ts +6 -6
  105. package/src/__tests__/skill-load-feature-flag.test.ts +5 -4
  106. package/src/__tests__/skill-projection-feature-flag.test.ts +22 -0
  107. package/src/__tests__/skills.test.ts +8 -4
  108. package/src/__tests__/slack-channel-config.test.ts +3 -1
  109. package/src/__tests__/subagent-tools.test.ts +19 -0
  110. package/src/__tests__/swarm-recursion.test.ts +2 -0
  111. package/src/__tests__/swarm-session-integration.test.ts +2 -0
  112. package/src/__tests__/swarm-tool.test.ts +2 -0
  113. package/src/__tests__/system-prompt.test.ts +3 -1
  114. package/src/__tests__/task-compiler.test.ts +3 -1
  115. package/src/__tests__/task-management-tools.test.ts +3 -1
  116. package/src/__tests__/task-tools.test.ts +3 -1
  117. package/src/__tests__/terminal-sandbox.test.ts +13 -12
  118. package/src/__tests__/terminal-tools.test.ts +2 -0
  119. package/src/__tests__/tool-approval-handler.test.ts +15 -15
  120. package/src/__tests__/tool-execution-abort-cleanup.test.ts +2 -0
  121. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +2 -0
  122. package/src/__tests__/tool-grant-request-escalation.test.ts +7 -7
  123. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +48 -0
  124. package/src/__tests__/trusted-contact-multichannel.test.ts +22 -19
  125. package/src/__tests__/trusted-contact-verification.test.ts +91 -0
  126. package/src/__tests__/twilio-routes-elevenlabs.test.ts +2 -0
  127. package/src/__tests__/twitter-auth-handler.test.ts +3 -1
  128. package/src/__tests__/twitter-cli-routing.test.ts +3 -1
  129. package/src/__tests__/view-image-tool.test.ts +3 -1
  130. package/src/__tests__/voice-invite-redemption.test.ts +329 -0
  131. package/src/__tests__/voice-scoped-grant-consumer.test.ts +7 -5
  132. package/src/__tests__/voice-session-bridge.test.ts +10 -10
  133. package/src/__tests__/work-item-output.test.ts +3 -1
  134. package/src/__tests__/workspace-lifecycle.test.ts +13 -2
  135. package/src/calls/call-controller.ts +26 -23
  136. package/src/calls/guardian-action-sweep.ts +10 -2
  137. package/src/calls/relay-server.ts +216 -27
  138. package/src/calls/types.ts +1 -1
  139. package/src/calls/voice-session-bridge.ts +3 -3
  140. package/src/cli.ts +12 -0
  141. package/src/config/agent-schema.ts +14 -3
  142. package/src/config/calls-schema.ts +6 -6
  143. package/src/config/core-schema.ts +3 -3
  144. package/src/config/feature-flag-registry.json +8 -0
  145. package/src/config/mcp-schema.ts +1 -1
  146. package/src/config/memory-schema.ts +27 -19
  147. package/src/config/schema.ts +21 -21
  148. package/src/config/skills-schema.ts +7 -7
  149. package/src/config/vellum-skills/trusted-contacts/SKILL.md +139 -16
  150. package/src/daemon/handlers/config-inbox.ts +4 -4
  151. package/src/daemon/handlers/sessions.ts +148 -4
  152. package/src/daemon/ipc-contract/messages.ts +16 -0
  153. package/src/daemon/ipc-contract-inventory.json +1 -0
  154. package/src/daemon/lifecycle.ts +19 -0
  155. package/src/daemon/pairing-store.ts +86 -3
  156. package/src/daemon/response-tier.ts +6 -5
  157. package/src/daemon/session-agent-loop.ts +5 -5
  158. package/src/daemon/session-lifecycle.ts +25 -17
  159. package/src/daemon/session-memory.ts +2 -2
  160. package/src/daemon/session-process.ts +1 -20
  161. package/src/daemon/session-runtime-assembly.ts +28 -22
  162. package/src/daemon/session-tool-setup.ts +2 -2
  163. package/src/daemon/session.ts +3 -3
  164. package/src/memory/canonical-guardian-store.ts +63 -1
  165. package/src/memory/channel-guardian-store.ts +1 -0
  166. package/src/memory/conversation-crud.ts +7 -7
  167. package/src/memory/db-init.ts +4 -0
  168. package/src/memory/embedding-local.ts +257 -39
  169. package/src/memory/embedding-runtime-manager.ts +471 -0
  170. package/src/memory/guardian-bindings.ts +25 -1
  171. package/src/memory/indexer.ts +3 -3
  172. package/src/memory/ingress-invite-store.ts +45 -0
  173. package/src/memory/job-handlers/backfill.ts +16 -9
  174. package/src/memory/migrations/037-voice-invite-columns.ts +16 -0
  175. package/src/memory/migrations/index.ts +1 -0
  176. package/src/memory/qdrant-client.ts +31 -22
  177. package/src/memory/schema.ts +4 -0
  178. package/src/notifications/copy-composer.ts +15 -0
  179. package/src/runtime/access-request-helper.ts +43 -7
  180. package/src/runtime/actor-trust-resolver.ts +46 -50
  181. package/src/runtime/channel-invite-transports/voice.ts +58 -0
  182. package/src/runtime/channel-retry-sweep.ts +18 -6
  183. package/src/runtime/guardian-context-resolver.ts +38 -96
  184. package/src/runtime/guardian-reply-router.ts +31 -1
  185. package/src/runtime/ingress-service.ts +80 -3
  186. package/src/runtime/invite-redemption-service.ts +141 -2
  187. package/src/runtime/routes/channel-route-shared.ts +1 -1
  188. package/src/runtime/routes/channel-routes.ts +1 -1
  189. package/src/runtime/routes/conversation-routes.ts +166 -2
  190. package/src/runtime/routes/guardian-approval-interception.ts +17 -6
  191. package/src/runtime/routes/inbound-message-handler.ts +41 -10
  192. package/src/runtime/routes/ingress-routes.ts +52 -4
  193. package/src/runtime/routes/pairing-routes.ts +3 -0
  194. package/src/tools/guardian-control-plane-policy.ts +2 -2
  195. package/src/tools/reminder/reminder-store.ts +10 -14
  196. package/src/tools/tool-approval-handler.ts +11 -11
  197. package/src/tools/types.ts +2 -2
  198. package/src/util/logger.ts +20 -8
  199. package/src/util/platform.ts +10 -0
  200. package/src/util/voice-code.ts +29 -0
  201. package/src/daemon/guardian-invite-intent.ts +0 -124
@@ -31,10 +31,31 @@ const DECLARED_LEGACY_KEY = 'skills.hatch-new-assistant.enabled';
31
31
 
32
32
  mock.module('../config/skills.js', () => ({
33
33
  loadSkillCatalog: () => mockCatalog,
34
+ checkSkillRequirements: () => ({ satisfied: true, missing: [] }),
34
35
  }));
35
36
 
36
37
  mock.module('../config/loader.js', () => ({
37
38
  getConfig: () => currentConfig,
39
+ loadConfig: () => currentConfig,
40
+ invalidateConfigCache: () => {},
41
+ }));
42
+
43
+ mock.module('../config/assistant-feature-flags.js', () => ({
44
+ isAssistantFeatureFlagEnabled: (key: string, config: Record<string, unknown>) => {
45
+ const vals = (config as { assistantFeatureFlagValues?: Record<string, boolean> }).assistantFeatureFlagValues;
46
+ if (vals && typeof vals[key] === 'boolean') return vals[key];
47
+ // Check legacy featureFlags too
48
+ const legacy = (config as { featureFlags?: Record<string, boolean> }).featureFlags;
49
+ if (legacy && typeof legacy[key] === 'boolean') return legacy[key];
50
+ return true; // default enabled
51
+ },
52
+ loadDefaultsRegistry: () => ({}),
53
+ getAssistantFeatureFlagDefaults: () => ({}),
54
+ _resetDefaultsCache: () => {},
55
+ }));
56
+
57
+ mock.module('../config/skill-state.js', () => ({
58
+ skillFlagKey: (skillId: string) => `skills.${skillId}.enabled`,
38
59
  }));
39
60
 
40
61
  mock.module('../skills/active-skill-tools.js', () => {
@@ -184,6 +205,7 @@ mock.module('../util/logger.js', () => ({
184
205
  debug: () => {},
185
206
  error: () => {},
186
207
  }),
208
+ isDebug: () => false,
187
209
  }));
188
210
 
189
211
  // ---------------------------------------------------------------------------
@@ -518,14 +518,14 @@ describe('bundled browser skill', () => {
518
518
  expect(browserSkill!.disableModelInvocation).toBe(false);
519
519
  });
520
520
 
521
- test('browser skill has a valid tool manifest with 10 tools', () => {
521
+ test('browser skill has a valid tool manifest with 14 tools', () => {
522
522
  const catalog = loadSkillCatalog();
523
523
  const browserSkill = catalog.find((s) => s.id === 'browser');
524
524
  expect(browserSkill).toBeDefined();
525
525
  expect(browserSkill!.toolManifest).toBeDefined();
526
526
  expect(browserSkill!.toolManifest!.present).toBe(true);
527
527
  expect(browserSkill!.toolManifest!.valid).toBe(true);
528
- expect(browserSkill!.toolManifest!.toolCount).toBe(10);
528
+ expect(browserSkill!.toolManifest!.toolCount).toBe(14);
529
529
  expect(browserSkill!.toolManifest!.toolNames).toEqual([
530
530
  'browser_navigate',
531
531
  'browser_snapshot',
@@ -534,8 +534,12 @@ describe('bundled browser skill', () => {
534
534
  'browser_click',
535
535
  'browser_type',
536
536
  'browser_press_key',
537
+ 'browser_scroll',
538
+ 'browser_select_option',
539
+ 'browser_hover',
537
540
  'browser_wait_for',
538
541
  'browser_extract',
542
+ 'browser_wait_for_download',
539
543
  'browser_fill_credential',
540
544
  ]);
541
545
  });
@@ -618,10 +622,10 @@ describe('ingress-dependent setup skills declare public-ingress', () => {
618
622
  expect(includes).toContain('public-ingress');
619
623
  });
620
624
 
621
- test('slack-oauth-setup includes public-ingress', () => {
625
+ test('slack-oauth-setup includes browser', () => {
622
626
  const includes = readSkillIncludes(VELLUM_SKILLS_DIR, 'slack-oauth-setup');
623
627
  expect(includes).toBeDefined();
624
- expect(includes).toContain('public-ingress');
628
+ expect(includes).toContain('browser');
625
629
  });
626
630
  });
627
631
 
@@ -7,7 +7,9 @@ import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test';
7
7
  const testDir = mkdtempSync(join(tmpdir(), 'slack-channel-cfg-test-'));
8
8
 
9
9
  mock.module('../config/loader.js', () => ({
10
- getConfig: () => ({}),
10
+ getConfig: () => ({
11
+ ui: {},
12
+ }),
11
13
  loadConfig: () => ({}),
12
14
  loadRawConfig: () => ({}),
13
15
  saveRawConfig: () => {},
@@ -6,6 +6,25 @@ import { describe, expect, mock, test } from 'bun:test';
6
6
  // Mock conversation-store before importing tool executors that depend on it.
7
7
  let mockGetMessages: (conversationId: string) => Array<{ role: string; content: string }> | null = () => null;
8
8
  mock.module('../memory/conversation-store.js', () => ({
9
+ getConversationThreadType: () => 'default',
10
+ setConversationOriginChannelIfUnset: () => {},
11
+ updateConversationContextWindow: () => {},
12
+ deleteMessageById: () => {},
13
+ updateConversationTitle: () => {},
14
+ updateConversationUsage: () => {},
15
+ addMessage: () => ({ id: 'mock-msg-id' }),
16
+ getConversation: () => ({
17
+ id: 'conv-1',
18
+ contextSummary: null,
19
+ contextCompactedMessageCount: 0,
20
+ totalInputTokens: 0,
21
+ totalOutputTokens: 0,
22
+ totalEstimatedCost: 0,
23
+ title: null,
24
+ }),
25
+ provenanceFromGuardianContext: () => ({ source: 'user', guardianContext: undefined }),
26
+ getConversationOriginInterface: () => null,
27
+ getConversationOriginChannel: () => null,
9
28
  getMessages: (conversationId: string) => mockGetMessages(conversationId),
10
29
  createConversation: () => ({ id: 'mock-conv' }),
11
30
  }));
@@ -30,6 +30,8 @@ mock.module('../util/logger.js', () => ({
30
30
 
31
31
  mock.module('../config/loader.js', () => ({
32
32
  getConfig: () => ({
33
+ ui: {},
34
+
33
35
  provider: 'anthropic',
34
36
  providerOrder: ['anthropic'],
35
37
  apiKeys: { anthropic: 'test-key' },
@@ -21,6 +21,8 @@ let hasApiKey = true;
21
21
 
22
22
  mock.module('../config/loader.js', () => ({
23
23
  getConfig: () => ({
24
+ ui: {},
25
+
24
26
  provider: 'anthropic',
25
27
  providerOrder: ['anthropic'],
26
28
  apiKeys: { anthropic: hasApiKey ? 'test-key' : '' },
@@ -12,6 +12,8 @@ mock.module('../util/logger.js', () => ({
12
12
 
13
13
  mock.module('../config/loader.js', () => ({
14
14
  getConfig: () => ({
15
+ ui: {},
16
+
15
17
  provider: 'anthropic',
16
18
  providerOrder: ['anthropic'],
17
19
  apiKeys: { anthropic: 'test-key' },
@@ -49,6 +49,8 @@ mock.module('../util/logger.js', () => ({
49
49
 
50
50
  mock.module('../config/loader.js', () => ({
51
51
  getConfig: () => ({
52
+ ui: {},
53
+
52
54
  sandbox: { enabled: true },
53
55
  }),
54
56
  }));
@@ -203,7 +205,7 @@ describe('buildSystemPrompt', () => {
203
205
 
204
206
  test('config section uses workspace directory from platform util', () => {
205
207
  const result = buildSystemPrompt();
206
- expect(result).toContain(`Your workspace is mounted at \`/workspace/\` inside the Docker sandbox (host path: \`${TEST_DIR}/\`)`);
208
+ expect(result).toContain(`Your configuration directory is \`${TEST_DIR}/\`.`);
207
209
  });
208
210
 
209
211
  test('omits user skills from catalog when none are configured', () => {
@@ -28,7 +28,9 @@ mock.module('../util/logger.js', () => ({
28
28
  }));
29
29
 
30
30
  mock.module('../config/loader.js', () => ({
31
- getConfig: () => ({ memory: {} }),
31
+ getConfig: () => ({
32
+ ui: {},
33
+ memory: {} }),
32
34
  }));
33
35
 
34
36
  mock.module('./indexer.js', () => ({
@@ -27,7 +27,9 @@ mock.module('../util/logger.js', () => ({
27
27
  }));
28
28
 
29
29
  mock.module('../config/loader.js', () => ({
30
- getConfig: () => ({ memory: {} }),
30
+ getConfig: () => ({
31
+ ui: {},
32
+ memory: {} }),
31
33
  }));
32
34
 
33
35
  mock.module('../tools/registry.js', () => ({
@@ -27,7 +27,9 @@ mock.module('../util/logger.js', () => ({
27
27
  }));
28
28
 
29
29
  mock.module('../config/loader.js', () => ({
30
- getConfig: () => ({ memory: {} }),
30
+ getConfig: () => ({
31
+ ui: {},
32
+ memory: {} }),
31
33
  }));
32
34
 
33
35
  mock.module('./indexer.js', () => ({
@@ -177,20 +177,21 @@ describe('terminal sandbox — macOS sandbox-exec behavior', () => {
177
177
  expect(result.args.slice(2)).toEqual(['bash', '-c', '--', 'echo hello']);
178
178
  });
179
179
 
180
- test('throws ToolError for working dirs with SBPL metacharacters', () => {
181
- expect(() => wrapCommand('pwd', '/tmp/bad"dir', nativeConfig())).toThrow(ToolError);
182
- expect(() => wrapCommand('pwd', '/tmp/bad(dir', nativeConfig())).toThrow(ToolError);
183
- expect(() => wrapCommand('pwd', '/tmp/bad;dir', nativeConfig())).toThrow(ToolError);
180
+ test('escapes SBPL metacharacters in working dirs instead of throwing', () => {
181
+ // The sandbox now escapes metacharacters rather than rejecting them
182
+ const result1 = wrapCommand('pwd', '/tmp/bad"dir', nativeConfig());
183
+ expect(result1.sandboxed).toBe(true);
184
+ const result2 = wrapCommand('pwd', '/tmp/bad(dir', nativeConfig());
185
+ expect(result2.sandboxed).toBe(true);
186
+ const result3 = wrapCommand('pwd', '/tmp/bad;dir', nativeConfig());
187
+ expect(result3.sandboxed).toBe(true);
184
188
  });
185
189
 
186
- test('SBPL metacharacter error mentions unsafe characters', () => {
187
- try {
188
- wrapCommand('pwd', '/tmp/bad"dir', nativeConfig());
189
- throw new Error('should have thrown');
190
- } catch (err) {
191
- expect(err).toBeInstanceOf(ToolError);
192
- expect((err as Error).message).toContain('SBPL metacharacters');
193
- }
190
+ test('SBPL profile escapes metacharacters in working dir path', () => {
191
+ // Verify the sandbox profile is written with escaped chars
192
+ wrapCommand('pwd', '/tmp/bad"dir', nativeConfig());
193
+ const profileContent = writeFileSyncMock.mock.calls[0]?.[1] as string;
194
+ expect(profileContent).toContain('bad\\"dir');
194
195
  });
195
196
  });
196
197
 
@@ -34,6 +34,8 @@ mock.module('../util/platform.js', () => ({
34
34
 
35
35
  mock.module('../config/loader.js', () => ({
36
36
  getConfig: () => ({
37
+ ui: {},
38
+
37
39
  timeouts: { shellDefaultTimeoutSec: 120, shellMaxTimeoutSec: 600 },
38
40
  sandbox: {
39
41
  enabled: false,
@@ -100,7 +100,7 @@ function makeContext(overrides: Partial<ToolContext> = {}): ToolContext {
100
100
  conversationId: 'conv-1',
101
101
  assistantId: 'self',
102
102
  requestId: 'req-1',
103
- guardianActorRole: 'non-guardian',
103
+ guardianTrustClass: 'trusted_contact',
104
104
  ...overrides,
105
105
  };
106
106
  }
@@ -134,7 +134,7 @@ describe('ToolApprovalHandler / pre-exec gate grant check', () => {
134
134
  );
135
135
  expect(mintResult.ok).toBe(true);
136
136
 
137
- const context = makeContext({ guardianActorRole: 'non-guardian' });
137
+ const context = makeContext({ guardianTrustClass: 'trusted_contact' });
138
138
  const result = await handler.checkPreExecutionGates(
139
139
  toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
140
140
  );
@@ -149,7 +149,7 @@ describe('ToolApprovalHandler / pre-exec gate grant check', () => {
149
149
  const toolName = 'bash';
150
150
  const input = { command: 'rm -rf /' };
151
151
 
152
- const context = makeContext({ guardianActorRole: 'non-guardian' });
152
+ const context = makeContext({ guardianTrustClass: 'trusted_contact' });
153
153
  const result = await handler.checkPreExecutionGates(
154
154
  toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
155
155
  );
@@ -177,7 +177,7 @@ describe('ToolApprovalHandler / pre-exec gate grant check', () => {
177
177
  }),
178
178
  );
179
179
 
180
- const context = makeContext({ guardianActorRole: 'unverified_channel' });
180
+ const context = makeContext({ guardianTrustClass: 'unknown' });
181
181
  const result = await handler.checkPreExecutionGates(
182
182
  toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
183
183
  );
@@ -189,7 +189,7 @@ describe('ToolApprovalHandler / pre-exec gate grant check', () => {
189
189
  const toolName = 'bash';
190
190
  const input = { command: 'deploy' };
191
191
 
192
- const context = makeContext({ guardianActorRole: 'unverified_channel' });
192
+ const context = makeContext({ guardianTrustClass: 'unknown' });
193
193
  const result = await handler.checkPreExecutionGates(
194
194
  toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
195
195
  );
@@ -212,7 +212,7 @@ describe('ToolApprovalHandler / pre-exec gate grant check', () => {
212
212
  }),
213
213
  );
214
214
 
215
- const context = makeContext({ guardianActorRole: 'non-guardian' });
215
+ const context = makeContext({ guardianTrustClass: 'trusted_contact' });
216
216
 
217
217
  // First invocation — should consume the grant and allow
218
218
  const first = await handler.checkPreExecutionGates(
@@ -241,7 +241,7 @@ describe('ToolApprovalHandler / pre-exec gate grant check', () => {
241
241
  }),
242
242
  );
243
243
 
244
- const context = makeContext({ guardianActorRole: 'non-guardian' });
244
+ const context = makeContext({ guardianTrustClass: 'trusted_contact' });
245
245
  const result = await handler.checkPreExecutionGates(
246
246
  toolName, invokeInput, context, 'host', 'high', Date.now(), emitLifecycleEvent,
247
247
  );
@@ -264,7 +264,7 @@ describe('ToolApprovalHandler / pre-exec gate grant check', () => {
264
264
  }),
265
265
  );
266
266
 
267
- const context = makeContext({ guardianActorRole: 'non-guardian' });
267
+ const context = makeContext({ guardianTrustClass: 'trusted_contact' });
268
268
  const result = await handler.checkPreExecutionGates(
269
269
  toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
270
270
  );
@@ -277,7 +277,7 @@ describe('ToolApprovalHandler / pre-exec gate grant check', () => {
277
277
  const input = { command: 'deploy' };
278
278
 
279
279
  // No grants minted at all
280
- const context = makeContext({ guardianActorRole: 'guardian' });
280
+ const context = makeContext({ guardianTrustClass: 'guardian' });
281
281
  const result = await handler.checkPreExecutionGates(
282
282
  toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
283
283
  );
@@ -290,7 +290,7 @@ describe('ToolApprovalHandler / pre-exec gate grant check', () => {
290
290
  const toolName = 'bash';
291
291
  const input = { command: 'deploy' };
292
292
 
293
- const context = makeContext({ guardianActorRole: undefined });
293
+ const context = makeContext({ guardianTrustClass: undefined });
294
294
  const result = await handler.checkPreExecutionGates(
295
295
  toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
296
296
  );
@@ -309,7 +309,7 @@ describe('ToolApprovalHandler / pre-exec gate grant check', () => {
309
309
  }),
310
310
  );
311
311
 
312
- const context = makeContext({ guardianActorRole: 'non-guardian', requestId: 'req-1' });
312
+ const context = makeContext({ guardianTrustClass: 'trusted_contact', requestId: 'req-1' });
313
313
  const result = await handler.checkPreExecutionGates(
314
314
  toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
315
315
  );
@@ -333,7 +333,7 @@ describe('ToolApprovalHandler / pre-exec gate grant check', () => {
333
333
 
334
334
  // Context conversationId does not match the grant's conversationId
335
335
  const context = makeContext({
336
- guardianActorRole: 'non-guardian',
336
+ guardianTrustClass: 'trusted_contact',
337
337
  conversationId: 'conv-1',
338
338
  });
339
339
  const result = await handler.checkPreExecutionGates(
@@ -349,7 +349,7 @@ describe('ToolApprovalHandler / pre-exec gate grant check', () => {
349
349
 
350
350
  // executionChannel defaults to undefined (non-voice)
351
351
  const context = makeContext({
352
- guardianActorRole: 'non-guardian',
352
+ guardianTrustClass: 'trusted_contact',
353
353
  executionChannel: 'telegram',
354
354
  });
355
355
 
@@ -383,7 +383,7 @@ describe('ToolApprovalHandler / pre-exec gate grant check', () => {
383
383
  }, 300);
384
384
 
385
385
  const context = makeContext({
386
- guardianActorRole: 'non-guardian',
386
+ guardianTrustClass: 'trusted_contact',
387
387
  executionChannel: 'voice',
388
388
  });
389
389
 
@@ -408,7 +408,7 @@ describe('ToolApprovalHandler / pre-exec gate grant check', () => {
408
408
  setTimeout(() => controller.abort(), 200);
409
409
 
410
410
  const context = makeContext({
411
- guardianActorRole: 'non-guardian',
411
+ guardianTrustClass: 'trusted_contact',
412
412
  executionChannel: 'voice',
413
413
  signal: controller.signal,
414
414
  });
@@ -22,6 +22,8 @@ import { mock } from 'bun:test';
22
22
 
23
23
  mock.module('../config/loader.js', () => ({
24
24
  getConfig: () => ({
25
+ ui: {},
26
+
25
27
  provider: 'anthropic',
26
28
  model: 'test',
27
29
  apiKeys: {},
@@ -59,6 +59,8 @@ mock.module('../permissions/trust-store.js', () => ({
59
59
 
60
60
  mock.module('../config/loader.js', () => ({
61
61
  getConfig: () => ({
62
+ ui: {},
63
+
62
64
  provider: 'mock-provider',
63
65
  timeouts: { permissionTimeoutSec: 5, toolExecutionTimeoutSec: 120 },
64
66
  permissions: { mode: 'legacy' },
@@ -147,7 +147,7 @@ function makeContext(overrides: Partial<ToolContext> = {}): ToolContext {
147
147
  conversationId: 'conv-1',
148
148
  assistantId: 'self',
149
149
  requestId: 'req-1',
150
- guardianActorRole: 'non-guardian',
150
+ guardianTrustClass: 'trusted_contact',
151
151
  executionChannel: 'telegram',
152
152
  requesterExternalUserId: 'requester-1',
153
153
  ...overrides,
@@ -204,7 +204,7 @@ describe('ToolApprovalHandler / grant-miss escalation', () => {
204
204
  const toolName = 'bash';
205
205
  const input = { command: 'cat /etc/passwd' };
206
206
 
207
- const context = makeContext({ guardianActorRole: 'non-guardian' });
207
+ const context = makeContext({ guardianTrustClass: 'trusted_contact' });
208
208
  const result = await handler.checkPreExecutionGates(
209
209
  toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
210
210
  );
@@ -231,7 +231,7 @@ describe('ToolApprovalHandler / grant-miss escalation', () => {
231
231
  const toolName = 'bash';
232
232
  const input = { command: 'deploy' };
233
233
 
234
- const context = makeContext({ guardianActorRole: 'non-guardian' });
234
+ const context = makeContext({ guardianTrustClass: 'trusted_contact' });
235
235
  const result = await handler.checkPreExecutionGates(
236
236
  toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
237
237
  );
@@ -247,7 +247,7 @@ describe('ToolApprovalHandler / grant-miss escalation', () => {
247
247
  const toolName = 'bash';
248
248
  const input = { command: 'rm -rf /' };
249
249
 
250
- const context = makeContext({ guardianActorRole: 'non-guardian' });
250
+ const context = makeContext({ guardianTrustClass: 'trusted_contact' });
251
251
 
252
252
  // First invocation creates the request
253
253
  await handler.checkPreExecutionGates(
@@ -288,7 +288,7 @@ describe('ToolApprovalHandler / grant-miss escalation', () => {
288
288
  const input = { command: 'ls' };
289
289
 
290
290
  const context = makeContext({
291
- guardianActorRole: 'unverified_channel',
291
+ guardianTrustClass: 'unknown',
292
292
  executionChannel: 'telegram',
293
293
  requesterExternalUserId: 'unknown-user',
294
294
  });
@@ -314,7 +314,7 @@ describe('ToolApprovalHandler / grant-miss escalation', () => {
314
314
  const input = { command: 'deploy' };
315
315
 
316
316
  const context = makeContext({
317
- guardianActorRole: 'non-guardian',
317
+ guardianTrustClass: 'trusted_contact',
318
318
  executionChannel: undefined, // no channel info
319
319
  });
320
320
  const result = await handler.checkPreExecutionGates(
@@ -449,7 +449,7 @@ describe('end-to-end: tool grant escalation -> approval -> consume', () => {
449
449
  const input = { command: 'echo secret' };
450
450
  const _inputDigest = computeToolApprovalDigest(toolName, input);
451
451
 
452
- const context = makeContext({ guardianActorRole: 'non-guardian' });
452
+ const context = makeContext({ guardianTrustClass: 'trusted_contact' });
453
453
 
454
454
  // Step 1: First invocation is denied, but a tool_grant_request is created
455
455
  const firstResult = await handler.checkPreExecutionGates(
@@ -419,6 +419,54 @@ describe('trusted contact activated notification signal', () => {
419
419
  expect(hints.urgency).toBe('low');
420
420
  });
421
421
 
422
+ test('re-verification preserves an existing guardian-managed member display name', async () => {
423
+ createBinding({
424
+ assistantId: 'self',
425
+ channel: 'telegram',
426
+ guardianExternalUserId: 'guardian-user-789',
427
+ guardianDeliveryChatId: 'guardian-chat-789',
428
+ });
429
+
430
+ upsertMember({
431
+ assistantId: 'self',
432
+ sourceChannel: 'telegram',
433
+ externalUserId: 'requester-user-456',
434
+ externalChatId: 'chat-123',
435
+ status: 'revoked',
436
+ policy: 'allow',
437
+ displayName: 'Jeff',
438
+ });
439
+
440
+ const session = createOutboundSession({
441
+ assistantId: 'self',
442
+ channel: 'telegram',
443
+ expectedExternalUserId: 'requester-user-456',
444
+ expectedChatId: 'chat-123',
445
+ identityBindingStatus: 'bound',
446
+ destinationAddress: 'chat-123',
447
+ verificationPurpose: 'trusted_contact',
448
+ });
449
+
450
+ const verifyReq = buildInboundRequest({
451
+ content: session.secret,
452
+ externalChatId: 'chat-123',
453
+ senderExternalUserId: 'requester-user-456',
454
+ senderName: 'Noa Flaherty',
455
+ });
456
+
457
+ await handleChannelInbound(verifyReq, undefined, TEST_BEARER_TOKEN);
458
+
459
+ const member = findMember({
460
+ assistantId: 'self',
461
+ sourceChannel: 'telegram',
462
+ externalUserId: 'requester-user-456',
463
+ externalChatId: 'chat-123',
464
+ });
465
+ expect(member).not.toBeNull();
466
+ expect(member!.status).toBe('active');
467
+ expect(member!.displayName).toBe('Jeff');
468
+ });
469
+
422
470
  test('guardian verification does NOT emit activated signal', async () => {
423
471
  // Create an inbound challenge (guardian flow, not trusted contact)
424
472
  // eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -59,6 +59,19 @@ mock.module('../notifications/emit-signal.js', () => ({
59
59
  deliveryResults: [],
60
60
  };
61
61
  },
62
+ registerBroadcastFn: () => {},
63
+ }));
64
+
65
+ // Mock access-request-helper directly to capture notification calls.
66
+ // Bun's mock.module does not intercept transitive imports reliably, so
67
+ // mocking emit-signal.js alone is not sufficient — access-request-helper
68
+ // imports emit-signal before the mock takes effect.
69
+ const notifyGuardianCalls: Array<Record<string, unknown>> = [];
70
+ mock.module('../runtime/access-request-helper.js', () => ({
71
+ notifyGuardianOfAccessRequest: (params: Record<string, unknown>) => {
72
+ notifyGuardianCalls.push(params);
73
+ return { notified: true, created: true, requestId: `mock-req-${Date.now()}` };
74
+ },
62
75
  }));
63
76
 
64
77
  const deliverReplyCalls: Array<{ url: string; payload: Record<string, unknown> }> = [];
@@ -75,7 +88,6 @@ mock.module('../runtime/approval-message-composer.js', () => ({
75
88
 
76
89
  import {
77
90
  createBinding,
78
- findPendingAccessRequestForRequester,
79
91
  } from '../memory/channel-guardian-store.js';
80
92
  import { getDb, initializeDb, resetDb } from '../memory/db.js';
81
93
  import { findMember, upsertMember } from '../memory/ingress-member-store.js';
@@ -109,6 +121,7 @@ function resetState(): void {
109
121
  db.run('DELETE FROM notification_events');
110
122
  db.run('DELETE FROM assistant_ingress_members');
111
123
  emitSignalCalls.length = 0;
124
+ notifyGuardianCalls.length = 0;
112
125
  deliverReplyCalls.length = 0;
113
126
  }
114
127
 
@@ -186,7 +199,10 @@ for (const config of CHANNEL_CONFIGS) {
186
199
  expect(json.denied).toBe(true);
187
200
  expect(json.reason).toBe('not_a_member');
188
201
  expect(deliverReplyCalls.length).toBe(1);
189
- expect((deliverReplyCalls[0].payload as Record<string, unknown>).text).toContain("you haven't been approved");
202
+ const replyText = (deliverReplyCalls[0].payload as Record<string, unknown>).text as string;
203
+ expect(
204
+ replyText.includes("you haven't been approved") || replyText.includes("you don't have access"),
205
+ ).toBe(true);
190
206
  });
191
207
 
192
208
  test('guardian is notified when a non-member messages', async () => {
@@ -203,23 +219,10 @@ for (const config of CHANNEL_CONFIGS) {
203
219
 
204
220
  expect(json.denied).toBe(true);
205
221
 
206
- // Notification signal was emitted for the correct channel
207
- expect(emitSignalCalls.length).toBe(1);
208
- expect(emitSignalCalls[0].sourceEventName).toBe('ingress.access_request');
209
- expect(emitSignalCalls[0].sourceChannel).toBe(config.channel);
210
-
211
- const payload = emitSignalCalls[0].contextPayload as Record<string, unknown>;
212
- expect(payload.senderExternalUserId).toBe(config.senderExternalUserId);
213
-
214
- // Approval request was created for the correct channel
215
- const pending = findPendingAccessRequestForRequester(
216
- 'self',
217
- config.channel,
218
- config.senderExternalUserId,
219
- 'ingress_access_request',
220
- );
221
- expect(pending).not.toBeNull();
222
- expect(pending!.channel).toBe(config.channel);
222
+ // Guardian notification helper was called for the correct channel
223
+ expect(notifyGuardianCalls.length).toBe(1);
224
+ expect(notifyGuardianCalls[0].sourceChannel).toBe(config.channel);
225
+ expect(notifyGuardianCalls[0].senderExternalUserId).toBe(config.senderExternalUserId);
223
226
  });
224
227
 
225
228
  test('verification creates active member for channel', () => {