@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.
- package/ARCHITECTURE.md +84 -7
- package/docs/trusted-contact-access.md +20 -0
- package/package.json +1 -1
- package/src/__tests__/access-request-decision.test.ts +0 -1
- package/src/__tests__/assistant-id-boundary-guard.test.ts +290 -0
- package/src/__tests__/call-routes-http.test.ts +0 -25
- package/src/__tests__/channel-guardian.test.ts +6 -5
- package/src/__tests__/config-schema.test.ts +2 -0
- package/src/__tests__/deterministic-verification-control-plane.test.ts +0 -1
- package/src/__tests__/guardian-actions-endpoint.test.ts +21 -0
- package/src/__tests__/guardian-outbound-http.test.ts +0 -1
- package/src/__tests__/inbound-invite-redemption.test.ts +0 -1
- package/src/__tests__/ingress-routes-http.test.ts +55 -0
- package/src/__tests__/non-member-access-request.test.ts +28 -1
- package/src/__tests__/notification-decision-strategy.test.ts +44 -0
- package/src/__tests__/relay-server.test.ts +644 -4
- package/src/__tests__/session-init.benchmark.test.ts +0 -1
- package/src/__tests__/session-runtime-assembly.test.ts +4 -1
- package/src/__tests__/session-surfaces-task-progress.test.ts +43 -0
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +11 -1
- package/src/__tests__/trusted-contact-multichannel.test.ts +0 -1
- package/src/__tests__/trusted-contact-verification.test.ts +0 -1
- package/src/__tests__/twilio-routes.test.ts +4 -3
- package/src/__tests__/update-bulletin.test.ts +0 -1
- package/src/approvals/guardian-decision-primitive.ts +2 -1
- package/src/approvals/guardian-request-resolvers.ts +42 -3
- package/src/calls/call-constants.ts +8 -0
- package/src/calls/call-controller.ts +2 -1
- package/src/calls/call-domain.ts +5 -4
- package/src/calls/relay-server.ts +513 -116
- package/src/calls/twilio-routes.ts +3 -5
- package/src/calls/types.ts +1 -1
- package/src/calls/voice-session-bridge.ts +4 -3
- package/src/cli/core-commands.ts +7 -4
- package/src/config/bundled-skills/app-builder/SKILL.md +164 -1
- package/src/config/bundled-skills/vercel-token-setup/SKILL.md +214 -0
- package/src/config/calls-schema.ts +12 -0
- package/src/config/feature-flag-registry.json +0 -8
- package/src/config/vellum-skills/trusted-contacts/SKILL.md +8 -2
- package/src/daemon/handlers/config-channels.ts +5 -7
- package/src/daemon/handlers/config-inbox.ts +2 -0
- package/src/daemon/handlers/index.ts +2 -1
- package/src/daemon/handlers/publish.ts +11 -46
- package/src/daemon/handlers/sessions.ts +11 -2
- package/src/daemon/ipc-contract/apps.ts +1 -0
- package/src/daemon/ipc-contract/inbox.ts +4 -0
- package/src/daemon/ipc-contract/integrations.ts +3 -1
- package/src/daemon/server.ts +2 -1
- package/src/daemon/session-agent-loop.ts +2 -1
- package/src/daemon/session-runtime-assembly.ts +3 -1
- package/src/daemon/session-surfaces.ts +29 -1
- package/src/memory/conversation-crud.ts +2 -1
- package/src/memory/conversation-title-service.ts +16 -2
- package/src/memory/db-init.ts +4 -0
- package/src/memory/delivery-crud.ts +2 -1
- package/src/memory/guardian-action-store.ts +2 -1
- package/src/memory/guardian-approvals.ts +3 -2
- package/src/memory/ingress-invite-store.ts +12 -2
- package/src/memory/ingress-member-store.ts +4 -3
- package/src/memory/migrations/124-voice-invite-display-metadata.ts +14 -0
- package/src/memory/migrations/index.ts +1 -0
- package/src/memory/schema.ts +10 -5
- package/src/notifications/copy-composer.ts +11 -1
- package/src/notifications/emit-signal.ts +2 -1
- package/src/runtime/access-request-helper.ts +11 -3
- package/src/runtime/actor-trust-resolver.ts +2 -2
- package/src/runtime/assistant-scope.ts +10 -0
- package/src/runtime/guardian-outbound-actions.ts +5 -4
- package/src/runtime/http-server.ts +11 -20
- package/src/runtime/ingress-service.ts +14 -0
- package/src/runtime/invite-redemption-service.ts +2 -1
- package/src/runtime/middleware/twilio-validation.ts +2 -4
- package/src/runtime/routes/call-routes.ts +2 -1
- package/src/runtime/routes/channel-route-shared.ts +3 -3
- package/src/runtime/routes/conversation-attention-routes.ts +2 -1
- package/src/runtime/routes/conversation-routes.ts +2 -1
- package/src/runtime/routes/events-routes.ts +2 -3
- package/src/runtime/routes/inbound-conversation.ts +4 -3
- package/src/runtime/routes/inbound-message-handler.ts +4 -3
- package/src/runtime/routes/ingress-routes.ts +2 -0
- package/src/tools/calls/call-start.ts +2 -1
- package/src/tools/terminal/parser.ts +12 -0
- package/src/tools/tool-approval-handler.ts +2 -1
- 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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
@@ -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
|
|
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: '
|
|
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('
|
|
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
|
|