@vellumai/assistant 0.4.2 → 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 (84) hide show
  1. package/ARCHITECTURE.md +84 -7
  2. package/docs/trusted-contact-access.md +20 -0
  3. package/package.json +1 -1
  4. package/src/__tests__/access-request-decision.test.ts +0 -1
  5. package/src/__tests__/assistant-id-boundary-guard.test.ts +290 -0
  6. package/src/__tests__/call-routes-http.test.ts +0 -25
  7. package/src/__tests__/channel-guardian.test.ts +6 -5
  8. package/src/__tests__/config-schema.test.ts +2 -0
  9. package/src/__tests__/deterministic-verification-control-plane.test.ts +0 -1
  10. package/src/__tests__/guardian-actions-endpoint.test.ts +21 -0
  11. package/src/__tests__/guardian-outbound-http.test.ts +0 -1
  12. package/src/__tests__/inbound-invite-redemption.test.ts +0 -1
  13. package/src/__tests__/ingress-routes-http.test.ts +55 -0
  14. package/src/__tests__/non-member-access-request.test.ts +28 -1
  15. package/src/__tests__/notification-decision-strategy.test.ts +44 -0
  16. package/src/__tests__/relay-server.test.ts +644 -4
  17. package/src/__tests__/session-init.benchmark.test.ts +0 -1
  18. package/src/__tests__/session-runtime-assembly.test.ts +4 -1
  19. package/src/__tests__/session-surfaces-task-progress.test.ts +43 -0
  20. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +11 -1
  21. package/src/__tests__/trusted-contact-multichannel.test.ts +0 -1
  22. package/src/__tests__/trusted-contact-verification.test.ts +0 -1
  23. package/src/__tests__/twilio-routes.test.ts +4 -3
  24. package/src/__tests__/update-bulletin.test.ts +0 -1
  25. package/src/approvals/guardian-decision-primitive.ts +2 -1
  26. package/src/approvals/guardian-request-resolvers.ts +42 -3
  27. package/src/calls/call-constants.ts +8 -0
  28. package/src/calls/call-controller.ts +2 -1
  29. package/src/calls/call-domain.ts +5 -4
  30. package/src/calls/relay-server.ts +513 -116
  31. package/src/calls/twilio-routes.ts +3 -5
  32. package/src/calls/types.ts +1 -1
  33. package/src/calls/voice-session-bridge.ts +4 -3
  34. package/src/cli/core-commands.ts +7 -4
  35. package/src/config/bundled-skills/app-builder/SKILL.md +164 -1
  36. package/src/config/bundled-skills/vercel-token-setup/SKILL.md +214 -0
  37. package/src/config/calls-schema.ts +12 -0
  38. package/src/config/feature-flag-registry.json +0 -8
  39. package/src/config/vellum-skills/trusted-contacts/SKILL.md +8 -2
  40. package/src/daemon/handlers/config-channels.ts +5 -7
  41. package/src/daemon/handlers/config-inbox.ts +2 -0
  42. package/src/daemon/handlers/index.ts +2 -1
  43. package/src/daemon/handlers/publish.ts +11 -46
  44. package/src/daemon/handlers/sessions.ts +11 -2
  45. package/src/daemon/ipc-contract/apps.ts +1 -0
  46. package/src/daemon/ipc-contract/inbox.ts +4 -0
  47. package/src/daemon/ipc-contract/integrations.ts +3 -1
  48. package/src/daemon/server.ts +2 -1
  49. package/src/daemon/session-agent-loop.ts +2 -1
  50. package/src/daemon/session-runtime-assembly.ts +3 -1
  51. package/src/daemon/session-surfaces.ts +29 -1
  52. package/src/memory/conversation-crud.ts +2 -1
  53. package/src/memory/conversation-title-service.ts +16 -2
  54. package/src/memory/db-init.ts +4 -0
  55. package/src/memory/delivery-crud.ts +2 -1
  56. package/src/memory/guardian-action-store.ts +2 -1
  57. package/src/memory/guardian-approvals.ts +3 -2
  58. package/src/memory/ingress-invite-store.ts +12 -2
  59. package/src/memory/ingress-member-store.ts +4 -3
  60. package/src/memory/migrations/124-voice-invite-display-metadata.ts +14 -0
  61. package/src/memory/migrations/index.ts +1 -0
  62. package/src/memory/schema.ts +10 -5
  63. package/src/notifications/copy-composer.ts +11 -1
  64. package/src/notifications/emit-signal.ts +2 -1
  65. package/src/runtime/access-request-helper.ts +11 -3
  66. package/src/runtime/actor-trust-resolver.ts +2 -2
  67. package/src/runtime/assistant-scope.ts +10 -0
  68. package/src/runtime/guardian-outbound-actions.ts +5 -4
  69. package/src/runtime/http-server.ts +11 -20
  70. package/src/runtime/ingress-service.ts +14 -0
  71. package/src/runtime/invite-redemption-service.ts +2 -1
  72. package/src/runtime/middleware/twilio-validation.ts +2 -4
  73. package/src/runtime/routes/call-routes.ts +2 -1
  74. package/src/runtime/routes/channel-route-shared.ts +3 -3
  75. package/src/runtime/routes/conversation-attention-routes.ts +2 -1
  76. package/src/runtime/routes/conversation-routes.ts +2 -1
  77. package/src/runtime/routes/events-routes.ts +2 -3
  78. package/src/runtime/routes/inbound-conversation.ts +4 -3
  79. package/src/runtime/routes/inbound-message-handler.ts +4 -3
  80. package/src/runtime/routes/ingress-routes.ts +2 -0
  81. package/src/tools/calls/call-start.ts +2 -1
  82. package/src/tools/terminal/parser.ts +12 -0
  83. package/src/tools/tool-approval-handler.ts +2 -1
  84. package/src/workspace/git-service.ts +19 -0
package/ARCHITECTURE.md CHANGED
@@ -397,7 +397,7 @@ A complementary access-granting flow where the guardian proactively creates a sh
397
397
  | Channel | Status | Prerequisites |
398
398
  |---------|--------|--------------|
399
399
  | Telegram | Shipped | Bot username resolved from credential metadata or `TELEGRAM_BOT_USERNAME` env |
400
- | Voice | Shipped | Identity-bound voice code redemption via DTMF/speech in the relay state machine. Gated behind `feature_flags.voice-invite-redemption.enabled` (default OFF). |
400
+ | Voice | Shipped | Identity-bound voice code redemption via DTMF/speech in the relay state machine. Always-on canonical behavior with personalized friend/guardian name prompts. |
401
401
  | SMS | Deferred | Needs a deep-link strategy compatible with SMS (short URL or web redemption page) |
402
402
  | Slack | Deferred | Needs DM-safe ingress — Socket Mode handles channel messages but DM-initiated invite flows need routing |
403
403
 
@@ -413,10 +413,10 @@ Voice invites use a short numeric code (4-10 digits, default 6) instead of a URL
413
413
 
414
414
  **Call-time redemption subflow (`invite_redemption_pending`):**
415
415
  1. Unknown caller dials in. `relay-server.ts` resolves trust via `resolveActorTrust`. Caller is `unknown`, no pending guardian challenge.
416
- 2. If `feature_flags.voice-invite-redemption.enabled` is ON, the relay checks `findActiveVoiceInvites` for invites bound to the caller's phone number.
417
- 3. If active, non-expired invites exist, the relay enters the `invite_redemption_pending` state (reuses the `verification_pending` connection state) and prompts the caller to enter their invite code via DTMF or speech.
416
+ 2. The relay checks `findActiveVoiceInvites` for invites bound to the caller's phone number.
417
+ 3. If active, non-expired invites exist, the relay enters the `invite_redemption_pending` state (reuses the `verification_pending` connection state) and prompts the caller with personalized copy: `Welcome <friend-name>. Please enter the 6-digit code that <guardian-name> provided you to verify your identity.`
418
418
  4. `redeemVoiceInviteCode` validates: identity match, code hash match, expiry, use count. On success, an active member record is upserted and the call transitions to the normal call flow.
419
- 5. On failure, the caller gets up to 3 attempts. After max attempts, the call is terminated.
419
+ 5. On invalid/expired code, the caller hears deterministic failure copy: `Sorry, the code you provided is incorrect or has since expired. Please ask <guardian-name> for a new code. Goodbye.` and the call ends immediately.
420
420
 
421
421
  **Security invariants:**
422
422
  - The plaintext voice code is returned exactly once at creation time and never stored.
@@ -424,8 +424,6 @@ Voice invites use a short numeric code (4-10 digits, default 6) instead of a URL
424
424
  - Failure responses are intentionally generic (`invalid_or_expired`) to prevent oracle attacks.
425
425
  - Blocked members cannot bypass the guardian's explicit block via invite redemption.
426
426
 
427
- **Feature flag:** `feature_flags.voice-invite-redemption.enabled` (default OFF). When disabled, unknown callers with active voice invites are denied normally — the invite check is skipped entirely.
428
-
429
427
  **Key source files:**
430
428
 
431
429
  | File | Purpose |
@@ -439,10 +437,76 @@ Voice invites use a short numeric code (4-10 digits, default 6) instead of a URL
439
437
  | `src/runtime/ingress-service.ts` | Shared business logic for invite/member operations (used by both HTTP routes and IPC) |
440
438
  | `src/runtime/routes/ingress-routes.ts` | HTTP API handlers for member/invite management including voice invite creation and redemption |
441
439
  | `src/runtime/routes/inbound-message-handler.ts` | Invite token intercept in the inbound flow (non-member and inactive-member branches) |
442
- | `src/calls/relay-server.ts` | Voice relay state machine — `invite_redemption_pending` subflow, feature flag gate |
440
+ | `src/calls/relay-server.ts` | Voice relay state machine — `invite_redemption_pending` subflow (always-on canonical behavior) |
443
441
  | `src/util/voice-code.ts` | Cryptographic voice code generation and SHA-256 hashing |
444
442
  | `src/memory/ingress-invite-store.ts` | Invite persistence including `findActiveVoiceInvites` for identity-bound lookup |
445
443
 
444
+ ### Voice Inbound Security Model (Canonical)
445
+
446
+ The voice inbound security model determines how unknown callers are handled when they dial in. Three paths exist, evaluated in priority order by `relay-server.ts` during the `handleSetup` phase. All guardian decisions route through `applyCanonicalGuardianDecision` in the canonical guardian request system.
447
+
448
+ **Decision tree for inbound unknown callers:**
449
+
450
+ ```
451
+ Unknown caller dials in
452
+ |
453
+ v
454
+ resolveActorTrust() → trustClass
455
+ |
456
+ ├── guardian / trusted_contact → normal call flow
457
+ ├── blocked → immediate denial + disconnect
458
+ ├── policy: deny → immediate denial + disconnect
459
+ ├── policy: escalate → denial (voice cannot hold for async approval)
460
+ |
461
+ └── unknown (no binding) ──┐
462
+ |
463
+ ┌────────────────────┼──────────────────────┐
464
+ | | |
465
+ pendingChallenge? activeVoiceInvites? no invite, no challenge
466
+ | | |
467
+ v v v
468
+ Guardian verification Invite redemption Name capture +
469
+ (DTMF/speech code) (personalized code) guardian approval wait
470
+ ```
471
+
472
+ **Path 1: Voice invite code redemption (guardian-initiated)**
473
+
474
+ The guardian proactively creates a voice invite bound to the caller's E.164 phone number. When the unknown caller dials in and has an active, non-expired invite, the relay enters the `invite_redemption_pending` subflow with personalized prompts using the friend's and guardian's names. This is always-on canonical behavior (no feature flag). See [Voice Invite Flow](#voice-invite-flow-invite_redemption_pending) above.
475
+
476
+ **Path 2: Live in-call guardian approval (friend-initiated)**
477
+
478
+ When no invite exists and no pending guardian challenge is active, the relay enters the name capture + guardian approval wait flow:
479
+
480
+ 1. The relay transitions to `awaiting_name` state and prompts the caller for their name with a timeout.
481
+ 2. On name capture, `notifyGuardianOfAccessRequest` creates a canonical guardian request (`kind: 'access_request'`) and notifies the guardian via the notification pipeline.
482
+ 3. The relay transitions to `awaiting_guardian_decision` and plays hold music/messaging while polling the canonical request status.
483
+ 4. The guardian approves or denies via any channel (Telegram, SMS, desktop). All decisions route through `applyCanonicalGuardianDecision`, which dispatches to the `access_request` resolver in `guardian-request-resolvers.ts`.
484
+ 5. On approval: the resolver directly activates the caller as a trusted contact (upserts member with `status: 'active'`, `policy: 'allow'`), the poll detects the approved status, the relay transitions to the normal call flow with the caller's guardian context updated.
485
+ 6. On denial or timeout: the caller hears a denial message and the call ends.
486
+
487
+ **Path 3: Inbound guardian verification (pending challenge)**
488
+
489
+ When a pending voice guardian challenge exists (`getPendingChallenge`), the caller enters the DTMF/speech verification flow to complete an outbound-initiated guardian binding. This path is for guardian identity verification, not trusted-contact access.
490
+
491
+ **Canonical decision routing:**
492
+
493
+ All guardian decisions for voice access requests flow through:
494
+ - `applyCanonicalGuardianDecision` (canonical guardian request system)
495
+ - `accessRequestResolver` in `guardian-request-resolvers.ts` (kind-specific resolver)
496
+ - For voice approvals: direct trusted-contact activation (no verification session needed since the caller is already on the line)
497
+ - For text-channel access requests: verification session creation with 6-digit code (existing `access-request-decision.ts` path for legacy `channel_guardian_approval_requests`)
498
+
499
+ **Key source files:**
500
+
501
+ | File | Purpose |
502
+ |------|---------|
503
+ | `src/calls/relay-server.ts` | Inbound call decision tree, name capture, guardian approval wait polling |
504
+ | `src/runtime/access-request-helper.ts` | Creates canonical access request and notifies guardian |
505
+ | `src/approvals/guardian-decision-primitive.ts` | `applyCanonicalGuardianDecision` — unified decision primitive |
506
+ | `src/approvals/guardian-request-resolvers.ts` | `access_request` resolver — voice direct activation, text-channel verification session |
507
+ | `src/runtime/actor-trust-resolver.ts` | `resolveActorTrust` — caller trust classification |
508
+ | `src/memory/canonical-guardian-store.ts` | Canonical request persistence and CAS resolution |
509
+
446
510
  ### Update Bulletin System
447
511
 
448
512
  Release-driven update notification system that surfaces release notes to the assistant via the system prompt.
@@ -1954,3 +2018,16 @@ Key files: `src/tools/sensitive-output-placeholders.ts`, `src/tools/executor.ts`
1954
2018
  ### Notifications
1955
2019
 
1956
2020
  For full notification developer guidance and lifecycle details, see [`assistant/src/notifications/README.md`](src/notifications/README.md).
2021
+
2022
+ ### Assistant Identity Boundary
2023
+
2024
+ The daemon uses a single fixed internal scope constant — `DAEMON_INTERNAL_ASSISTANT_ID` (`'self'`), exported from `src/runtime/assistant-scope.ts` — for all assistant-scoped storage and routing within the daemon process. Public/external assistant IDs (e.g., those assigned during hatch, invite links, or platform registration) are an **edge concern** owned by the gateway and platform layers.
2025
+
2026
+ **Boundary rule:** Daemon code must never derive internal scoping decisions from externally-provided assistant IDs. When a daemon path needs an assistant scope and none is provided, it defaults to `DAEMON_INTERNAL_ASSISTANT_ID`. The gateway is responsible for mapping public assistant IDs to internal routing before forwarding requests to the daemon.
2027
+
2028
+ **Key files:**
2029
+
2030
+ | File | Purpose |
2031
+ |------|---------|
2032
+ | `src/runtime/assistant-scope.ts` | Exports `DAEMON_INTERNAL_ASSISTANT_ID` constant |
2033
+ | `src/__tests__/assistant-id-boundary-guard.test.ts` | Guard tests enforcing the identity boundary |
@@ -112,6 +112,26 @@ The `assistant_ingress_invites` table supports a parallel invite-based onboardin
112
112
  |-------|--------------------------------|
113
113
  | `assistant_ingress_invites` | Not used in the guardian-mediated flow. Available as an alternative for direct invite links (e.g., guardian shares a URL instead of going through the approval + verification flow). |
114
114
 
115
+ ### Voice In-Call Guardian Approval (friend-initiated)
116
+
117
+ Voice calls have a dedicated in-call guardian approval flow that differs from the text-channel flow. Since the caller is actively on the line, the voice flow captures the caller's name, creates a canonical access request, and holds the call while awaiting the guardian's decision.
118
+
119
+ **Flow:**
120
+ 1. Unknown caller dials in. `relay-server.ts` resolves trust — caller is `unknown`, no pending challenge, no active invite.
121
+ 2. Relay enters `awaiting_name` state and prompts the caller for their name (with a timeout).
122
+ 3. On name capture, `notifyGuardianOfAccessRequest` creates a canonical guardian request (`kind: 'access_request'`) and notifies the guardian.
123
+ 4. Relay transitions to `awaiting_guardian_decision` and polls `canonical_guardian_requests` for status changes.
124
+ 5. Guardian approves or denies via any channel. All decisions route through `applyCanonicalGuardianDecision`.
125
+ 6. On approval: the `access_request` resolver directly activates the caller as a trusted contact (`upsertMember` with `status: 'active'`, `policy: 'allow'`) — no verification session needed since the caller is already authenticated by their phone number.
126
+ 7. On denial or timeout: the caller hears a denial message and the call ends.
127
+
128
+ **Key difference from text-channel flow:** Voice approvals skip the verification session step because the caller's phone identity is already known from the active call. Text-channel approvals still mint a 6-digit verification code for out-of-band identity confirmation.
129
+
130
+ | Store | Table | Record |
131
+ |-------|-------|--------|
132
+ | `canonical-guardian-store.ts` | `canonical_guardian_requests` | `kind: 'access_request'`, `status: 'pending'` -> `'approved'` or `'denied'` |
133
+ | `ingress-member-store.ts` | `assistant_ingress_members` | On approval: upserted with `status: 'active'`, `policy: 'allow'` |
134
+
115
135
  ## Sequence Diagram
116
136
 
117
137
  ```mermaid
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "vellum": "./src/index.ts"
@@ -31,7 +31,6 @@ mock.module('../util/platform.js', () => ({
31
31
  getDbPath: () => join(testDir, 'test.db'),
32
32
  getLogPath: () => join(testDir, 'test.log'),
33
33
  ensureDataDir: () => {},
34
- normalizeAssistantId: (id: string) => id === 'self' ? 'self' : id,
35
34
  readHttpToken: () => 'test-bearer-token',
36
35
  }));
37
36
 
@@ -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');
@@ -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".',
@@ -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' });
@@ -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
 
@@ -28,7 +28,6 @@ mock.module('../util/platform.js', () => ({
28
28
  getDbPath: () => join(testDir, 'test.db'),
29
29
  getLogPath: () => join(testDir, 'test.log'),
30
30
  ensureDataDir: () => {},
31
- normalizeAssistantId: (id: string) => id === 'self' ? 'self' : id,
32
31
  readHttpToken: () => 'test-bearer-token',
33
32
  }));
34
33