@vellumai/assistant 0.3.16 → 0.3.19
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 +74 -13
- package/README.md +6 -0
- package/docs/architecture/http-token-refresh.md +23 -1
- package/docs/architecture/security.md +80 -0
- package/package.json +1 -1
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +4 -0
- package/src/__tests__/access-request-decision.test.ts +4 -7
- package/src/__tests__/call-controller.test.ts +170 -0
- package/src/__tests__/channel-guardian.test.ts +3 -1
- package/src/__tests__/checker.test.ts +139 -48
- package/src/__tests__/config-watcher.test.ts +11 -13
- package/src/__tests__/conversation-pairing.test.ts +103 -3
- package/src/__tests__/guardian-action-conversation-turn.test.ts +1 -1
- package/src/__tests__/guardian-action-followup-executor.test.ts +1 -1
- package/src/__tests__/guardian-action-grant-mint-consume.test.ts +511 -0
- package/src/__tests__/guardian-action-late-reply.test.ts +131 -0
- package/src/__tests__/guardian-action-store.test.ts +182 -0
- package/src/__tests__/guardian-dispatch.test.ts +180 -0
- package/src/__tests__/guardian-grant-minting.test.ts +543 -0
- package/src/__tests__/ipc-snapshot.test.ts +22 -0
- package/src/__tests__/non-member-access-request.test.ts +1 -2
- package/src/__tests__/notification-broadcaster.test.ts +115 -4
- package/src/__tests__/notification-decision-strategy.test.ts +2 -1
- package/src/__tests__/notification-deep-link.test.ts +44 -1
- package/src/__tests__/notification-guardian-path.test.ts +157 -0
- package/src/__tests__/notification-thread-candidate-validation.test.ts +215 -0
- package/src/__tests__/remote-skill-policy.test.ts +215 -0
- package/src/__tests__/scoped-approval-grants.test.ts +521 -0
- package/src/__tests__/scoped-grant-security-matrix.test.ts +443 -0
- package/src/__tests__/slack-channel-config.test.ts +3 -3
- package/src/__tests__/trust-store.test.ts +23 -21
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +5 -7
- package/src/__tests__/trusted-contact-multichannel.test.ts +2 -6
- package/src/__tests__/trusted-contact-verification.test.ts +9 -9
- package/src/__tests__/update-bulletin-state.test.ts +1 -1
- package/src/__tests__/update-bulletin.test.ts +66 -3
- package/src/__tests__/update-template-contract.test.ts +6 -11
- package/src/__tests__/voice-scoped-grant-consumer.test.ts +571 -0
- package/src/__tests__/voice-session-bridge.test.ts +109 -9
- package/src/calls/call-controller.ts +150 -8
- package/src/calls/call-domain.ts +12 -0
- package/src/calls/guardian-action-sweep.ts +1 -1
- package/src/calls/guardian-dispatch.ts +16 -0
- package/src/calls/relay-server.ts +13 -0
- package/src/calls/voice-session-bridge.ts +46 -5
- package/src/cli/core-commands.ts +41 -1
- package/src/config/bundled-skills/notifications/SKILL.md +18 -0
- package/src/config/schema.ts +6 -0
- package/src/config/skills-schema.ts +27 -0
- package/src/config/templates/UPDATES.md +5 -6
- package/src/config/update-bulletin-format.ts +2 -0
- package/src/config/update-bulletin-state.ts +1 -1
- package/src/config/update-bulletin-template-path.ts +6 -0
- package/src/config/update-bulletin.ts +21 -6
- package/src/daemon/config-watcher.ts +3 -2
- package/src/daemon/daemon-control.ts +64 -10
- package/src/daemon/handlers/config-channels.ts +18 -0
- package/src/daemon/handlers/config-slack-channel.ts +1 -1
- package/src/daemon/handlers/identity.ts +45 -25
- package/src/daemon/handlers/sessions.ts +1 -1
- package/src/daemon/handlers/skills.ts +45 -2
- package/src/daemon/ipc-contract/sessions.ts +1 -1
- package/src/daemon/ipc-contract/skills.ts +1 -0
- package/src/daemon/ipc-contract/workspace.ts +12 -1
- package/src/daemon/ipc-contract-inventory.json +1 -0
- package/src/daemon/lifecycle.ts +8 -0
- package/src/daemon/server.ts +25 -3
- package/src/daemon/session-process.ts +450 -184
- package/src/daemon/tls-certs.ts +17 -12
- package/src/daemon/tool-side-effects.ts +1 -1
- package/src/memory/channel-delivery-store.ts +18 -20
- package/src/memory/channel-guardian-store.ts +39 -42
- package/src/memory/conversation-crud.ts +2 -2
- package/src/memory/conversation-queries.ts +2 -2
- package/src/memory/conversation-store.ts +24 -25
- package/src/memory/db-init.ts +17 -1
- package/src/memory/embedding-local.ts +16 -7
- package/src/memory/fts-reconciler.ts +41 -26
- package/src/memory/guardian-action-store.ts +65 -7
- package/src/memory/guardian-verification.ts +1 -0
- package/src/memory/jobs-worker.ts +2 -2
- package/src/memory/migrations/032-guardian-delivery-conversation-index.ts +15 -0
- package/src/memory/migrations/032-notification-delivery-thread-decision.ts +20 -0
- package/src/memory/migrations/033-scoped-approval-grants.ts +51 -0
- package/src/memory/migrations/034-guardian-action-tool-metadata.ts +12 -0
- package/src/memory/migrations/index.ts +6 -2
- package/src/memory/schema-migration.ts +1 -0
- package/src/memory/schema.ts +36 -1
- package/src/memory/scoped-approval-grants.ts +509 -0
- package/src/memory/search/semantic.ts +3 -3
- package/src/notifications/README.md +158 -17
- package/src/notifications/broadcaster.ts +68 -50
- package/src/notifications/conversation-pairing.ts +96 -18
- package/src/notifications/decision-engine.ts +6 -3
- package/src/notifications/deliveries-store.ts +12 -0
- package/src/notifications/emit-signal.ts +1 -0
- package/src/notifications/thread-candidates.ts +60 -25
- package/src/notifications/types.ts +2 -1
- package/src/permissions/checker.ts +28 -16
- package/src/permissions/defaults.ts +14 -4
- package/src/runtime/guardian-action-followup-executor.ts +1 -1
- package/src/runtime/guardian-action-grant-minter.ts +97 -0
- package/src/runtime/http-server.ts +11 -11
- package/src/runtime/routes/access-request-decision.ts +1 -1
- package/src/runtime/routes/debug-routes.ts +4 -4
- package/src/runtime/routes/guardian-approval-interception.ts +120 -4
- package/src/runtime/routes/inbound-message-handler.ts +100 -33
- package/src/runtime/routes/integration-routes.ts +2 -2
- package/src/security/tool-approval-digest.ts +67 -0
- package/src/skills/remote-skill-policy.ts +131 -0
- package/src/tools/permission-checker.ts +1 -2
- package/src/tools/secret-detection-handler.ts +1 -1
- package/src/tools/system/voice-config.ts +1 -1
- package/src/version.ts +29 -2
package/ARCHITECTURE.md
CHANGED
|
@@ -22,6 +22,10 @@ This document owns assistant-runtime architecture details. The repo-level archit
|
|
|
22
22
|
- Voice calls mirror the same prompt contract: `CallController` receives guardian context on setup and refreshes it immediately after successful voice challenge verification, so the first post-verification turn is grounded as `actor_role: guardian`.
|
|
23
23
|
- Voice-specific behavior (DTMF/speech verification flow, relay state machine) remains voice-local; only actor-role resolution is shared.
|
|
24
24
|
|
|
25
|
+
### Channel-Agnostic Scoped Approval Grants
|
|
26
|
+
|
|
27
|
+
Scoped approval grants allow a guardian's approval decision on one channel (e.g., Telegram) to authorize a tool execution on a different channel (e.g., voice). Two scope modes exist: `request_id` (bound to a specific pending request) and `tool_signature` (bound to `toolName` + canonical `inputDigest`). Grants are one-time-use, exact-match, fail-closed, and TTL-bound. Full architecture details (lifecycle flow, security invariants, key files) live in [`docs/architecture/security.md`](docs/architecture/security.md#channel-agnostic-scoped-approval-grants).
|
|
28
|
+
|
|
25
29
|
### Outbound Guardian Verification (HTTP Endpoints)
|
|
26
30
|
|
|
27
31
|
Guardian verification can be initiated through the runtime HTTP API as an alternative to the legacy IPC-only flow. This enables chat-first verification where the assistant guides the user through guardian setup via normal conversation.
|
|
@@ -218,7 +222,7 @@ The app token is validated by format only — it must start with `xapp-`.
|
|
|
218
222
|
|
|
219
223
|
**Connection status:**
|
|
220
224
|
|
|
221
|
-
|
|
225
|
+
Both `GET` and `POST` endpoints report `connected: true` only when both `hasBotToken` and `hasAppToken` are true. The `POST` endpoint additionally returns a `warning` field when only one token is stored, describing which token is missing.
|
|
222
226
|
|
|
223
227
|
**Key source files:**
|
|
224
228
|
|
|
@@ -276,6 +280,33 @@ External users who are not the guardian can gain access to the assistant through
|
|
|
276
280
|
| `src/memory/channel-guardian-store.ts` | Approval request and verification challenge persistence |
|
|
277
281
|
| `src/config/vellum-skills/trusted-contacts/SKILL.md` | Skill teaching the assistant to manage contacts via HTTP API |
|
|
278
282
|
|
|
283
|
+
### Update Bulletin System
|
|
284
|
+
|
|
285
|
+
Release-driven update notification system that surfaces release notes to the assistant via the system prompt.
|
|
286
|
+
|
|
287
|
+
**Data flow:**
|
|
288
|
+
1. **Bundled template** (`src/config/templates/UPDATES.md`) — source of release notes, maintained per-release in the repo.
|
|
289
|
+
2. **Startup sync** (`syncUpdateBulletinOnStartup()` in `src/config/update-bulletin.ts`) — materializes the bundled template into the workspace `UPDATES.md` on daemon boot. Uses atomic write (temp + rename) for crash safety.
|
|
290
|
+
3. **System prompt injection** — `buildSystemPrompt()` reads workspace `UPDATES.md` and injects it as a `## Recent Updates` section with judgment-based handling instructions.
|
|
291
|
+
4. **Completion by deletion** — the assistant deletes `UPDATES.md` when it has actioned all updates. Next startup detects the deletion and marks those releases as completed in checkpoint state.
|
|
292
|
+
5. **Cross-release merge** — if pending updates from a prior release exist when a new release lands, both release blocks coexist in the same file.
|
|
293
|
+
|
|
294
|
+
**Checkpoint keys** (in `memory_checkpoints` table):
|
|
295
|
+
- `updates:active_releases` — JSON array of version strings currently active.
|
|
296
|
+
- `updates:completed_releases` — JSON array of version strings already completed.
|
|
297
|
+
|
|
298
|
+
**Key source files:**
|
|
299
|
+
|
|
300
|
+
| File | Purpose |
|
|
301
|
+
|------|---------|
|
|
302
|
+
| `src/config/templates/UPDATES.md` | Bundled release-note template |
|
|
303
|
+
| `src/config/update-bulletin.ts` | Startup sync logic (materialize, delete-complete, merge) |
|
|
304
|
+
| `src/config/update-bulletin-format.ts` | Release block formatter/parser helpers |
|
|
305
|
+
| `src/config/update-bulletin-state.ts` | Checkpoint state helpers for active/completed releases |
|
|
306
|
+
| `src/config/system-prompt.ts` | Prompt injection of updates section |
|
|
307
|
+
| `src/daemon/config-watcher.ts` | File watcher — evicts sessions on UPDATES.md changes |
|
|
308
|
+
| `src/permissions/defaults.ts` | Auto-allow rules for file_read/write/edit + rm UPDATES.md |
|
|
309
|
+
|
|
279
310
|
---
|
|
280
311
|
|
|
281
312
|
|
|
@@ -1543,9 +1574,10 @@ Keep-alive heartbeats (every 30 s by default):
|
|
|
1543
1574
|
The notification module (`assistant/src/notifications/`) uses a signal-based architecture where producers emit free-form events and an LLM-backed decision engine determines whether, where, and how to notify the user. See `assistant/src/notifications/README.md` for the full developer guide.
|
|
1544
1575
|
|
|
1545
1576
|
```
|
|
1546
|
-
Producer → NotificationSignal → Decision Engine (LLM) → Deterministic Checks → Broadcaster → Conversation Pairing → Adapters → Delivery
|
|
1547
|
-
|
|
1548
|
-
|
|
1577
|
+
Producer → NotificationSignal → Candidate Generation → Decision Engine (LLM) → Deterministic Checks → Broadcaster → Conversation Pairing → Adapters → Delivery
|
|
1578
|
+
↑ ↓
|
|
1579
|
+
Preference Summary notification_thread_created IPC
|
|
1580
|
+
Thread Candidates (creation-only — not emitted on reuse)
|
|
1549
1581
|
```
|
|
1550
1582
|
|
|
1551
1583
|
### Channel Policy Registry
|
|
@@ -1560,13 +1592,18 @@ Producer → NotificationSignal → Decision Engine (LLM) → Deterministic Chec
|
|
|
1560
1592
|
|
|
1561
1593
|
Helper functions: `getDeliverableChannels()`, `getChannelPolicy()`, `isNotificationDeliverable()`, `getConversationStrategy()`.
|
|
1562
1594
|
|
|
1563
|
-
### Conversation Pairing
|
|
1595
|
+
### Conversation Pairing and Thread Routing
|
|
1564
1596
|
|
|
1565
|
-
Every notification delivery materializes a conversation + seed message **before** the adapter sends it (`conversation-pairing.ts`).
|
|
1597
|
+
Every notification delivery materializes a conversation + seed message **before** the adapter sends it (`conversation-pairing.ts`). The pairing function now accepts a `threadAction` from the decision engine:
|
|
1598
|
+
|
|
1599
|
+
- **`reuse_existing`**: Looks up the target conversation. If valid (exists with `source: 'notification'`), the seed message is appended to the existing thread. If invalid, falls back to creating a new conversation with `threadDecisionFallbackUsed: true`.
|
|
1600
|
+
- **`start_new` (default)**: Creates a fresh conversation per delivery.
|
|
1601
|
+
|
|
1602
|
+
This ensures:
|
|
1566
1603
|
|
|
1567
1604
|
1. Every delivery has an auditable conversation trail in the conversations table
|
|
1568
1605
|
2. The macOS/iOS client can deep-link directly into the notification thread
|
|
1569
|
-
3. Delivery audit rows in `notification_deliveries` carry `conversation_id`, `message_id`, and `
|
|
1606
|
+
3. Delivery audit rows in `notification_deliveries` carry `conversation_id`, `message_id`, `conversation_strategy`, `thread_action`, `thread_target_conversation_id`, and `thread_decision_fallback_used` columns
|
|
1570
1607
|
|
|
1571
1608
|
The pairing function (`pairDeliveryWithConversation`) is resilient — errors are caught and logged without breaking the delivery pipeline.
|
|
1572
1609
|
|
|
@@ -1577,19 +1614,42 @@ The notification pipeline uses a single conversation materialization path across
|
|
|
1577
1614
|
1. **Canonical pipeline** (`emitNotificationSignal` → decision engine → broadcaster → conversation pairing → adapters): The broadcaster pairs each delivery with a conversation, then dispatches a `notification_intent` IPC event via the Vellum adapter. The IPC payload includes `deepLinkMetadata` (e.g. `{ conversationId }`) so the macOS/iOS client can deep-link to the relevant context when the user taps the notification.
|
|
1578
1615
|
2. **Guardian bookkeeping** (`dispatchGuardianQuestion`): Guardian dispatch creates `guardian_action_request` / `guardian_action_delivery` audit rows derived from pipeline delivery results and the per-dispatch `onThreadCreated` callback — there is no separate thread-creation path.
|
|
1579
1616
|
|
|
1580
|
-
### Thread Surfacing via `notification_thread_created` IPC
|
|
1617
|
+
### Thread Surfacing via `notification_thread_created` IPC (Creation-Only)
|
|
1618
|
+
|
|
1619
|
+
The `notification_thread_created` IPC event is emitted **only when a brand-new conversation is created** by the broadcaster. Reusing an existing thread does not trigger this event — the macOS/iOS client already knows about the conversation from the original creation. This is enforced in `broadcaster.ts` by gating on `pairing.createdNewConversation === true`.
|
|
1581
1620
|
|
|
1582
|
-
When a vellum notification thread is
|
|
1621
|
+
When a new vellum notification thread is created (strategy `start_new_conversation`), the broadcaster emits the IPC event **immediately** (before waiting for slower channel deliveries like Telegram). This pushes the thread to the macOS/iOS client so it can display the notification thread in the sidebar and deep-link to it.
|
|
1583
1622
|
|
|
1584
1623
|
### IPC Thread-Created Events
|
|
1585
1624
|
|
|
1586
1625
|
Two IPC push events surface new threads in the macOS/iOS client sidebar:
|
|
1587
1626
|
|
|
1588
|
-
- **`notification_thread_created`** — Emitted by `broadcaster.ts` when a notification delivery creates a vellum conversation (strategy `start_new_conversation`). Payload: `{ conversationId, title, sourceEventName }`.
|
|
1627
|
+
- **`notification_thread_created`** — Emitted by `broadcaster.ts` when a notification delivery **creates** a new vellum conversation (strategy `start_new_conversation`, `createdNewConversation: true`). **Not** emitted when a thread is reused. Payload: `{ conversationId, title, sourceEventName }`.
|
|
1589
1628
|
- **`task_run_thread_created`** — Emitted by `work-item-runner.ts` when a task run creates a conversation. Payload: `{ conversationId, workItemId, title }`.
|
|
1590
1629
|
|
|
1591
1630
|
All events follow the same pattern: the daemon creates a server-side conversation, persists an initial message, and broadcasts the IPC event so the macOS `ThreadManager` can create a visible thread in the sidebar.
|
|
1592
1631
|
|
|
1632
|
+
### Thread Routing Decision Flow
|
|
1633
|
+
|
|
1634
|
+
The decision engine produces per-channel thread actions using a candidate-driven approach:
|
|
1635
|
+
|
|
1636
|
+
1. **Candidate generation** (`thread-candidates.ts`): Queries recent notification-sourced conversations (24-hour window, up to 5 per channel) and enriches them with guardian context (pending request counts).
|
|
1637
|
+
2. **LLM decision**: The candidate set is serialized into the system prompt. The LLM chooses `start_new` or `reuse_existing` (with a candidate `conversationId`) per channel.
|
|
1638
|
+
3. **Strict validation** (`validateThreadActions`): Reuse targets must exist in the candidate set. Invalid targets are downgraded to `start_new`.
|
|
1639
|
+
4. **Pairing execution**: `pairDeliveryWithConversation` executes the thread action — appending to an existing conversation on reuse, creating a new one otherwise.
|
|
1640
|
+
5. **IPC gating**: `notification_thread_created` fires only on actual creation, not on reuse.
|
|
1641
|
+
6. **Audit trail**: Thread actions are persisted in both `notification_decisions.validation_results` and `notification_deliveries` columns (`thread_action`, `thread_target_conversation_id`, `thread_decision_fallback_used`).
|
|
1642
|
+
|
|
1643
|
+
### Guardian Multi-Request Disambiguation in Reused Threads
|
|
1644
|
+
|
|
1645
|
+
When the decision engine routes multiple guardian questions to the same conversation (via `reuse_existing`), those questions share a single thread. The guardian disambiguates which question they are answering using **request-code prefixes**:
|
|
1646
|
+
|
|
1647
|
+
- **Single pending delivery**: Matched automatically (single-match fast path).
|
|
1648
|
+
- **Multiple pending deliveries**: The guardian must prefix their reply with the 6-char hex request code (e.g. `A1B2C3 yes, allow it`). Case-insensitive matching.
|
|
1649
|
+
- **No match**: A disambiguation message is sent listing all active request codes.
|
|
1650
|
+
|
|
1651
|
+
This invariant is enforced identically on mac/vellum (`session-process.ts`), Telegram, and SMS (`inbound-message-handler.ts`). All disambiguation messages are generated through the guardian action message composer (LLM with deterministic fallback).
|
|
1652
|
+
|
|
1593
1653
|
### Reminder Routing Metadata
|
|
1594
1654
|
|
|
1595
1655
|
Reminders carry optional `routingIntent` (`single_channel` | `multi_channel` | `all_channels`) and free-form `routingHints` metadata. When a reminder fires, this metadata flows through the notification signal into a post-decision enforcement step (`enforceRoutingIntent()` in `decision-engine.ts`) that overrides the LLM's channel selection to match the requested coverage. This enables single-reminder fanout: one reminder can produce multi-channel delivery without duplicate reminders. See `assistant/docs/architecture/scheduling.md` for the full trigger-time data flow.
|
|
@@ -1612,8 +1672,9 @@ Connected channels are resolved at signal emission time: vellum is always includ
|
|
|
1612
1672
|
| `assistant/src/notifications/emit-signal.ts` | Single entry point for all producers; orchestrates the full pipeline |
|
|
1613
1673
|
| `assistant/src/notifications/decision-engine.ts` | LLM-based routing decisions with deterministic fallback |
|
|
1614
1674
|
| `assistant/src/notifications/deterministic-checks.ts` | Hard invariant checks (dedupe, source-active suppression, channel availability) |
|
|
1615
|
-
| `assistant/src/notifications/broadcaster.ts` | Dispatches decisions to channel adapters; emits `notification_thread_created` IPC |
|
|
1616
|
-
| `assistant/src/notifications/conversation-pairing.ts` | Materializes conversation + message per delivery
|
|
1675
|
+
| `assistant/src/notifications/broadcaster.ts` | Dispatches decisions to channel adapters; emits `notification_thread_created` IPC (creation-only) |
|
|
1676
|
+
| `assistant/src/notifications/conversation-pairing.ts` | Materializes conversation + message per delivery; executes thread reuse decisions |
|
|
1677
|
+
| `assistant/src/notifications/thread-candidates.ts` | Builds per-channel candidate set of recent conversations for the decision engine |
|
|
1617
1678
|
| `assistant/src/notifications/adapters/macos.ts` | Vellum adapter — broadcasts `notification_intent` via IPC with deep-link metadata |
|
|
1618
1679
|
| `assistant/src/notifications/adapters/telegram.ts` | Telegram adapter — POSTs to gateway `/deliver/telegram` |
|
|
1619
1680
|
| `assistant/src/notifications/adapters/sms.ts` | SMS adapter — POSTs to gateway `/deliver/sms` via Twilio Messages API |
|
|
@@ -1624,7 +1685,7 @@ Connected channels are resolved at signal emission time: vellum is always includ
|
|
|
1624
1685
|
| `assistant/src/config/bundled-skills/messaging/tools/send-notification.ts` | Explicit producer tool for user-requested notifications; emits signals into the same routing pipeline |
|
|
1625
1686
|
| `assistant/src/calls/guardian-dispatch.ts` | Guardian question dispatch that reuses canonical notification pairing and records guardian delivery bookkeeping from pipeline results |
|
|
1626
1687
|
|
|
1627
|
-
**Audit trail (SQLite):** `notification_events` → `notification_decisions` → `notification_deliveries` (with `conversation_id`, `message_id`, `conversation_strategy`)
|
|
1688
|
+
**Audit trail (SQLite):** `notification_events` → `notification_decisions` (with `threadActions` in validation results) → `notification_deliveries` (with `conversation_id`, `message_id`, `conversation_strategy`, `thread_action`, `thread_target_conversation_id`, `thread_decision_fallback_used`)
|
|
1628
1689
|
|
|
1629
1690
|
**Configuration:** `notifications.decisionModelIntent` in `config.json`.
|
|
1630
1691
|
|
package/README.md
CHANGED
|
@@ -50,6 +50,12 @@ cp .env.example .env
|
|
|
50
50
|
| `RUNTIME_GATEWAY_ORIGIN_SECRET` | No | — | Dedicated secret for the `X-Gateway-Origin` proof header on `/channels/inbound`. When not set, falls back to the bearer token. Both gateway and runtime must share the same value. |
|
|
51
51
|
| `VELLUM_DAEMON_SOCKET` | No | `~/.vellum/vellum.sock` | Override the daemon socket path |
|
|
52
52
|
|
|
53
|
+
## Update Bulletin
|
|
54
|
+
|
|
55
|
+
When a release includes relevant updates, the daemon materializes release notes from the bundled `src/config/templates/UPDATES.md` into `~/.vellum/workspace/UPDATES.md` on startup. The assistant uses judgment to surface updates to the user when relevant, and deletes the file when done.
|
|
56
|
+
|
|
57
|
+
**For release maintainers:** Update `assistant/src/config/templates/UPDATES.md` with release notes before each relevant release. Leave the template empty (or comment-only) for releases with no user/assistant-facing changes.
|
|
58
|
+
|
|
53
59
|
## Usage
|
|
54
60
|
|
|
55
61
|
### Start the daemon
|
|
@@ -4,7 +4,7 @@ Design for how the daemon notifies clients of bearer token rotation and how clie
|
|
|
4
4
|
|
|
5
5
|
## Current State
|
|
6
6
|
|
|
7
|
-
The daemon's HTTP bearer token is
|
|
7
|
+
The daemon's HTTP bearer token is resolved at startup and persisted to `~/.vellum/http-token` (mode 0600). The startup token resolution order is: (1) the `RUNTIME_PROXY_BEARER_TOKEN` env var if set, (2) the existing token read from `~/.vellum/http-token` if the file is readable and non-empty, (3) a newly generated random token as a last resort. Clients read this file at connection time:
|
|
8
8
|
|
|
9
9
|
- **macOS (local)**: Reads `~/.vellum/http-token` from disk via `resolveHttpTokenPath()` / `readHttpToken()`. Has direct filesystem access to the token file.
|
|
10
10
|
- **iOS (remote)**: Receives the bearer token during the QR-code pairing flow. The token is stored in the iOS Keychain and used for all subsequent HTTP/SSE requests.
|
|
@@ -177,6 +177,28 @@ private rotateToken(revoke: boolean): string {
|
|
|
177
177
|
}
|
|
178
178
|
```
|
|
179
179
|
|
|
180
|
+
**Failure semantics — routine vs. revocation**:
|
|
181
|
+
|
|
182
|
+
The two rotation modes have deliberately different failure ordering to match their security requirements:
|
|
183
|
+
|
|
184
|
+
| | Routine (`revoke: false`) | Revocation (`revoke: true`) |
|
|
185
|
+
|---|---|---|
|
|
186
|
+
| **Order** | Persist to disk first, then update in-memory state | Update in-memory state first, then persist to disk |
|
|
187
|
+
| **Disk write failure** | Rotation aborts cleanly — in-memory auth state is untouched, clients keep working with the old token | Old token is already invalidated in memory; the API endpoint returns an error to the caller |
|
|
188
|
+
| **Rationale** | Availability: don't lock out clients if persistence fails | Security: a potentially compromised token must never remain valid, even briefly |
|
|
189
|
+
|
|
190
|
+
**Revocation disk-write failure in detail**: If `writeTokenToDisk` throws after the in-memory switch during revocation, the system enters a degraded state:
|
|
191
|
+
|
|
192
|
+
1. **In-memory state**: `currentToken` holds the new (unpersisted) token. The old token is rejected. All old-token SSE connections have been terminated.
|
|
193
|
+
2. **Disk state**: `~/.vellum/http-token` still contains the old (now-invalid) token.
|
|
194
|
+
3. **API response**: The `POST /v1/auth/rotate-token` endpoint returns an error indicating the persistence failure. The response body includes the new token so the caller can manually persist or distribute it if needed.
|
|
195
|
+
4. **Client impact by platform**:
|
|
196
|
+
- **macOS**: Re-reading the token file yields the stale old token, which is rejected (401). Recovery requires a daemon restart (which generates a fresh token and persists it) or a successful retry of the rotation API call.
|
|
197
|
+
- **iOS**: Already disconnected (old-token SSE terminated). Cannot recover until the daemon restarts or the rotation is retried successfully, at which point re-pairing is required.
|
|
198
|
+
- **Chrome extension**: Same as iOS — the pasted token is stale and rejected.
|
|
199
|
+
5. **Daemon restart recovery**: A daemon restart does **not** automatically heal this state. At startup, the daemon first checks for the `RUNTIME_PROXY_BEARER_TOKEN` env var, then tries to read the existing token from `~/.vellum/http-token`, and only generates a new random token if both are unavailable (see `assistant/src/daemon/lifecycle.ts`, lines 110-124). In the degraded state described here — where the disk still holds the old (now-invalid) token — a restart would reload that stale token, making it the active bearer token again. To actually recover, the operator must either: (a) manually delete or overwrite `~/.vellum/http-token` before restarting the daemon, (b) set `RUNTIME_PROXY_BEARER_TOKEN` to a known-good value, or (c) successfully retry the `POST /v1/auth/rotate-token` endpoint while the daemon is still running with the new in-memory token.
|
|
200
|
+
6. **Why this is acceptable**: Revocation is a security-critical operation triggered when the old token is suspected compromised. The invariant — "a compromised token must not remain valid" — takes precedence over client convenience. The degraded state requires manual intervention but disk write failures are rare in practice (permissions, disk full), and the API response includes the new token so the caller can retry or manually persist it.
|
|
201
|
+
|
|
180
202
|
**SSE event emission** (routine rotation only): The `token_rotated` event is published to `assistantEventHub` as a `ServerMessage`, reaching all connected SSE subscribers across all conversations. This event is never emitted during revocation rotations.
|
|
181
203
|
|
|
182
204
|
### 5. iOS Client Implementation
|
|
@@ -315,3 +315,83 @@ The `allowOneTimeSend` config gate (default: `false`) enables a secondary "Send
|
|
|
315
315
|
|
|
316
316
|
---
|
|
317
317
|
|
|
318
|
+
## Channel-Agnostic Scoped Approval Grants
|
|
319
|
+
|
|
320
|
+
Scoped approval grants are a channel-agnostic primitive that allows a guardian's approval decision on one channel (e.g., Telegram) to authorize a tool execution on a different channel (e.g., voice). Each grant authorizes exactly one tool execution and is consumed atomically.
|
|
321
|
+
|
|
322
|
+
### Scope Modes
|
|
323
|
+
|
|
324
|
+
Two scope modes exist:
|
|
325
|
+
|
|
326
|
+
| Mode | Key fields | Use case |
|
|
327
|
+
|------|-----------|----------|
|
|
328
|
+
| `request_id` | `requestId` | Grant is bound to a specific pending confirmation request. Consumed by matching the request ID. |
|
|
329
|
+
| `tool_signature` | `toolName` + `inputDigest` | Grant is bound to a specific tool invocation identified by tool name and a canonical SHA-256 digest of the input. Consumed by matching both fields plus optional context constraints. |
|
|
330
|
+
|
|
331
|
+
### Lifecycle Flow
|
|
332
|
+
|
|
333
|
+
```mermaid
|
|
334
|
+
sequenceDiagram
|
|
335
|
+
participant Caller as Non-Guardian Caller (Voice)
|
|
336
|
+
participant Session as Session / Agent Loop
|
|
337
|
+
participant Bridge as Voice Session Bridge
|
|
338
|
+
participant Guardian as Guardian (Telegram)
|
|
339
|
+
participant Interception as Approval Interception
|
|
340
|
+
participant GrantStore as Scoped Grant Store (SQLite)
|
|
341
|
+
|
|
342
|
+
Caller->>Session: Tool invocation triggers confirmation_request
|
|
343
|
+
Session->>Bridge: confirmation_request event
|
|
344
|
+
Note over Bridge: Non-guardian voice call cannot prompt interactively
|
|
345
|
+
|
|
346
|
+
Bridge->>Session: ASK_GUARDIAN_APPROVAL marker in agent response
|
|
347
|
+
Session->>Guardian: "Approve [tool] with [args]?" (Telegram)
|
|
348
|
+
|
|
349
|
+
Guardian->>Interception: "yes" / approve_once callback
|
|
350
|
+
Interception->>Session: handleChannelDecision(approve_once)
|
|
351
|
+
Interception->>GrantStore: createScopedApprovalGrant(tool_signature)
|
|
352
|
+
Note over GrantStore: Grant minted with 5-min TTL
|
|
353
|
+
|
|
354
|
+
Note over Bridge: On next confirmation_request for same tool+input...
|
|
355
|
+
Bridge->>GrantStore: consumeScopedApprovalGrantByToolSignature()
|
|
356
|
+
GrantStore-->>Bridge: { ok: true, grant }
|
|
357
|
+
Bridge->>Session: handleConfirmationResponse(allow)
|
|
358
|
+
Note over GrantStore: Grant status: active -> consumed (CAS)
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
### Security Invariants
|
|
362
|
+
|
|
363
|
+
1. **One-time use** -- Each grant can be consumed at most once. The consume operation uses compare-and-swap (CAS) on the `status` column (`active` -> `consumed`) so concurrent consumers race safely. At most one wins.
|
|
364
|
+
|
|
365
|
+
2. **Exact-match** -- All non-null scope fields on the grant must match the consumption context exactly. The `inputDigest` is a SHA-256 of the canonical JSON serialization of `{ toolName, input }`, ensuring key-order-independent matching.
|
|
366
|
+
|
|
367
|
+
3. **Fail-closed** -- When no matching active grant exists, consumption returns `{ ok: false }` and the voice bridge auto-denies. There is no fallback to "allow without a grant."
|
|
368
|
+
|
|
369
|
+
4. **TTL-bound** -- Grants expire after a configurable TTL (default: 5 minutes). An expiry sweep transitions active past-TTL grants to `expired` status. Expired grants cannot be consumed.
|
|
370
|
+
|
|
371
|
+
5. **Context-constrained** -- Optional scope fields (`executionChannel`, `conversationId`, `callSessionId`, `requesterExternalUserId`) narrow the grant's applicability. When set on the grant, they must match the consumer's context. When null on the grant, they act as wildcards.
|
|
372
|
+
|
|
373
|
+
6. **Identity-bound** -- The guardian identity is verified at the approval interception level before a grant is minted. A sender whose `externalUserId` does not match the expected guardian cannot mint a grant.
|
|
374
|
+
|
|
375
|
+
7. **Persistent storage** -- Grants are stored in the SQLite `scoped_approval_grants` table, which survives daemon restarts. This ensures fail-closed behavior across restarts: consumed grants remain consumed, and no implicit "reset to allowed" occurs.
|
|
376
|
+
|
|
377
|
+
### Key Source Files
|
|
378
|
+
|
|
379
|
+
| File | Role |
|
|
380
|
+
|------|------|
|
|
381
|
+
| `assistant/src/memory/scoped-approval-grants.ts` | CRUD, atomic CAS consume, expiry sweep, context-based revocation |
|
|
382
|
+
| `assistant/src/memory/migrations/033-scoped-approval-grants.ts` | SQLite schema migration for the `scoped_approval_grants` table |
|
|
383
|
+
| `assistant/src/security/tool-approval-digest.ts` | Canonical JSON serialization + SHA-256 digest for tool signatures |
|
|
384
|
+
| `assistant/src/runtime/routes/guardian-approval-interception.ts` | Grant minting on guardian approve_once decisions (`tryMintToolApprovalGrant`) |
|
|
385
|
+
| `assistant/src/calls/voice-session-bridge.ts` | Voice consumer: checks and consumes grants before auto-denying |
|
|
386
|
+
|
|
387
|
+
### Test Coverage
|
|
388
|
+
|
|
389
|
+
| Test file | Scenarios covered |
|
|
390
|
+
|-----------|-------------------|
|
|
391
|
+
| `assistant/src/__tests__/scoped-approval-grants.test.ts` | Store CRUD, request_id consume, tool_signature consume, expiry, revocation, digest stability |
|
|
392
|
+
| `assistant/src/__tests__/voice-scoped-grant-consumer.test.ts` | Voice bridge integration: grant-allowed, no-grant-denied, tool-mismatch, guardian-bypass, one-time-use, revocation on call end |
|
|
393
|
+
| `assistant/src/__tests__/guardian-grant-minting.test.ts` | Grant minting: callback/engine/legacy paths, informational-skip, reject-skip, identity-mismatch, stale-skip, TTL verification |
|
|
394
|
+
| `assistant/src/__tests__/scoped-grant-security-matrix.test.ts` | Security matrix: requester identity mismatch, concurrent CAS, persistence across restart, fail-closed default, cross-scope invariants |
|
|
395
|
+
|
|
396
|
+
---
|
|
397
|
+
|
package/package.json
CHANGED
|
@@ -1748,6 +1748,10 @@ exports[`IPC message snapshots ServerMessage types skills_list_response serializ
|
|
|
1748
1748
|
"emoji": "🔧",
|
|
1749
1749
|
"id": "my-skill",
|
|
1750
1750
|
"name": "My Skill",
|
|
1751
|
+
"provenance": {
|
|
1752
|
+
"kind": "first-party",
|
|
1753
|
+
"provider": "Vellum",
|
|
1754
|
+
},
|
|
1751
1755
|
"source": "bundled",
|
|
1752
1756
|
"state": "enabled",
|
|
1753
1757
|
"updateAvailable": false,
|
|
@@ -55,20 +55,18 @@ mock.module('../runtime/gateway-client.js', () => ({
|
|
|
55
55
|
|
|
56
56
|
import {
|
|
57
57
|
createApprovalRequest,
|
|
58
|
-
createBinding,
|
|
59
58
|
getApprovalRequestById,
|
|
60
|
-
findPendingAccessRequestForRequester,
|
|
61
59
|
} from '../memory/channel-guardian-store.js';
|
|
60
|
+
import { getDb, initializeDb, resetDb } from '../memory/db.js';
|
|
62
61
|
import {
|
|
63
62
|
findActiveSession,
|
|
64
63
|
} from '../runtime/channel-guardian-service.js';
|
|
65
|
-
import { initializeDb, resetDb } from '../memory/db.js';
|
|
66
64
|
import {
|
|
67
|
-
handleAccessRequestDecision,
|
|
68
65
|
deliverVerificationCodeToGuardian,
|
|
66
|
+
handleAccessRequestDecision,
|
|
69
67
|
notifyRequesterOfApproval,
|
|
70
|
-
notifyRequesterOfDenial,
|
|
71
68
|
notifyRequesterOfDeliveryFailure,
|
|
69
|
+
notifyRequesterOfDenial,
|
|
72
70
|
} from '../runtime/routes/access-request-decision.js';
|
|
73
71
|
|
|
74
72
|
initializeDb();
|
|
@@ -85,7 +83,6 @@ afterAll(() => {
|
|
|
85
83
|
const GUARDIAN_APPROVAL_TTL_MS = 5 * 60 * 1000;
|
|
86
84
|
|
|
87
85
|
function resetState(): void {
|
|
88
|
-
const { getDb } = require('../memory/db.js');
|
|
89
86
|
const db = getDb();
|
|
90
87
|
db.run('DELETE FROM channel_guardian_approval_requests');
|
|
91
88
|
db.run('DELETE FROM channel_guardian_bindings');
|
|
@@ -215,7 +212,7 @@ describe('access request decision handler', () => {
|
|
|
215
212
|
'guardian-user-789',
|
|
216
213
|
);
|
|
217
214
|
expect(result1.type).toBe('approved');
|
|
218
|
-
const
|
|
215
|
+
const _sessionId1 = result1.verificationSessionId;
|
|
219
216
|
|
|
220
217
|
// Approve again — should be idempotent (already resolved with same decision)
|
|
221
218
|
const result2 = handleAccessRequestDecision(
|
|
@@ -1195,4 +1195,174 @@ describe('call-controller', () => {
|
|
|
1195
1195
|
|
|
1196
1196
|
controller.destroy();
|
|
1197
1197
|
});
|
|
1198
|
+
|
|
1199
|
+
// ── Structured tool-approval ASK_GUARDIAN_APPROVAL ──────────────────
|
|
1200
|
+
|
|
1201
|
+
test('ASK_GUARDIAN_APPROVAL: persists toolName and inputDigest on guardian action request', async () => {
|
|
1202
|
+
const approvalPayload = JSON.stringify({
|
|
1203
|
+
question: 'Allow send_email to bob@example.com?',
|
|
1204
|
+
toolName: 'send_email',
|
|
1205
|
+
input: { to: 'bob@example.com', subject: 'Hello' },
|
|
1206
|
+
});
|
|
1207
|
+
mockStartVoiceTurn.mockImplementation(createMockVoiceTurn(
|
|
1208
|
+
[`Let me check with your guardian. [ASK_GUARDIAN_APPROVAL: ${approvalPayload}]`],
|
|
1209
|
+
));
|
|
1210
|
+
const { session, relay, controller } = setupController('Send an email');
|
|
1211
|
+
|
|
1212
|
+
await controller.handleCallerUtterance('Send an email to Bob');
|
|
1213
|
+
|
|
1214
|
+
// Give the async dispatchGuardianQuestion a tick to create the request
|
|
1215
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
1216
|
+
|
|
1217
|
+
// Verify controller entered waiting_on_user
|
|
1218
|
+
expect(controller.getState()).toBe('waiting_on_user');
|
|
1219
|
+
|
|
1220
|
+
// Verify a pending question was created with the correct text
|
|
1221
|
+
const question = getPendingQuestion(session.id);
|
|
1222
|
+
expect(question).not.toBeNull();
|
|
1223
|
+
expect(question!.questionText).toBe('Allow send_email to bob@example.com?');
|
|
1224
|
+
|
|
1225
|
+
// Verify the guardian action request has tool metadata
|
|
1226
|
+
const pendingRequest = getPendingRequestByCallSessionId(session.id);
|
|
1227
|
+
expect(pendingRequest).not.toBeNull();
|
|
1228
|
+
expect(pendingRequest!.toolName).toBe('send_email');
|
|
1229
|
+
expect(pendingRequest!.inputDigest).not.toBeNull();
|
|
1230
|
+
expect(pendingRequest!.inputDigest!.length).toBe(64); // SHA-256 hex = 64 chars
|
|
1231
|
+
|
|
1232
|
+
// The ASK_GUARDIAN_APPROVAL marker should NOT appear in the relay tokens
|
|
1233
|
+
const allText = relay.sentTokens.map((t) => t.token).join('');
|
|
1234
|
+
expect(allText).not.toContain('[ASK_GUARDIAN_APPROVAL:');
|
|
1235
|
+
expect(allText).not.toContain('send_email');
|
|
1236
|
+
|
|
1237
|
+
controller.destroy();
|
|
1238
|
+
});
|
|
1239
|
+
|
|
1240
|
+
test('ASK_GUARDIAN_APPROVAL: computes deterministic digest for same tool+input', async () => {
|
|
1241
|
+
const approvalPayload = JSON.stringify({
|
|
1242
|
+
question: 'Allow send_email?',
|
|
1243
|
+
toolName: 'send_email',
|
|
1244
|
+
input: { subject: 'Hello', to: 'bob@example.com' },
|
|
1245
|
+
});
|
|
1246
|
+
mockStartVoiceTurn.mockImplementation(createMockVoiceTurn(
|
|
1247
|
+
[`Checking. [ASK_GUARDIAN_APPROVAL: ${approvalPayload}]`],
|
|
1248
|
+
));
|
|
1249
|
+
const { session, controller } = setupController('Send email');
|
|
1250
|
+
|
|
1251
|
+
await controller.handleCallerUtterance('Send it');
|
|
1252
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
1253
|
+
|
|
1254
|
+
const request1 = getPendingRequestByCallSessionId(session.id);
|
|
1255
|
+
expect(request1).not.toBeNull();
|
|
1256
|
+
|
|
1257
|
+
// Compute expected digest independently using the same utility
|
|
1258
|
+
const { computeToolApprovalDigest } = await import('../security/tool-approval-digest.js');
|
|
1259
|
+
const expectedDigest = computeToolApprovalDigest('send_email', { subject: 'Hello', to: 'bob@example.com' });
|
|
1260
|
+
expect(request1!.inputDigest).toBe(expectedDigest);
|
|
1261
|
+
|
|
1262
|
+
controller.destroy();
|
|
1263
|
+
});
|
|
1264
|
+
|
|
1265
|
+
test('informational ASK_GUARDIAN: does NOT persist tool metadata (null toolName/inputDigest)', async () => {
|
|
1266
|
+
mockStartVoiceTurn.mockImplementation(createMockVoiceTurn(
|
|
1267
|
+
['Let me check. [ASK_GUARDIAN: What date works best?]'],
|
|
1268
|
+
));
|
|
1269
|
+
const { session, controller } = setupController('Book appointment');
|
|
1270
|
+
|
|
1271
|
+
await controller.handleCallerUtterance('I need to schedule something');
|
|
1272
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
1273
|
+
|
|
1274
|
+
// Verify the guardian action request has NO tool metadata
|
|
1275
|
+
const pendingRequest = getPendingRequestByCallSessionId(session.id);
|
|
1276
|
+
expect(pendingRequest).not.toBeNull();
|
|
1277
|
+
expect(pendingRequest!.toolName).toBeNull();
|
|
1278
|
+
expect(pendingRequest!.inputDigest).toBeNull();
|
|
1279
|
+
expect(pendingRequest!.questionText).toBe('What date works best?');
|
|
1280
|
+
|
|
1281
|
+
controller.destroy();
|
|
1282
|
+
});
|
|
1283
|
+
|
|
1284
|
+
test('ASK_GUARDIAN_APPROVAL: strips marker from TTS output', async () => {
|
|
1285
|
+
const approvalPayload = JSON.stringify({
|
|
1286
|
+
question: 'Allow calendar_create?',
|
|
1287
|
+
toolName: 'calendar_create',
|
|
1288
|
+
input: { date: '2026-03-01', title: 'Meeting' },
|
|
1289
|
+
});
|
|
1290
|
+
mockStartVoiceTurn.mockImplementation(createMockVoiceTurn([
|
|
1291
|
+
'Let me get approval for that. ',
|
|
1292
|
+
`[ASK_GUARDIAN_APPROVAL: ${approvalPayload}]`,
|
|
1293
|
+
' Thank you.',
|
|
1294
|
+
]));
|
|
1295
|
+
const { relay, controller } = setupController('Create event');
|
|
1296
|
+
|
|
1297
|
+
await controller.handleCallerUtterance('Create a meeting');
|
|
1298
|
+
|
|
1299
|
+
const allText = relay.sentTokens.map((t) => t.token).join('');
|
|
1300
|
+
expect(allText).toContain('Let me get approval');
|
|
1301
|
+
expect(allText).not.toContain('[ASK_GUARDIAN_APPROVAL:');
|
|
1302
|
+
expect(allText).not.toContain('calendar_create');
|
|
1303
|
+
expect(allText).not.toContain('inputDigest');
|
|
1304
|
+
|
|
1305
|
+
controller.destroy();
|
|
1306
|
+
});
|
|
1307
|
+
|
|
1308
|
+
test('ASK_GUARDIAN_APPROVAL: handles JSON payloads containing }] in string values', async () => {
|
|
1309
|
+
// The `}]` sequence inside a JSON string value previously caused the
|
|
1310
|
+
// non-greedy regex to terminate early, truncating the JSON and leaking
|
|
1311
|
+
// partial data into TTS output.
|
|
1312
|
+
const approvalPayload = JSON.stringify({
|
|
1313
|
+
question: 'Allow send_message?',
|
|
1314
|
+
toolName: 'send_message',
|
|
1315
|
+
input: { msg: 'test}]more', nested: { key: 'value with }] braces' } },
|
|
1316
|
+
});
|
|
1317
|
+
mockStartVoiceTurn.mockImplementation(createMockVoiceTurn(
|
|
1318
|
+
[`Let me check. [ASK_GUARDIAN_APPROVAL: ${approvalPayload}]`],
|
|
1319
|
+
));
|
|
1320
|
+
const { session, relay, controller } = setupController('Send a message');
|
|
1321
|
+
|
|
1322
|
+
await controller.handleCallerUtterance('Send it');
|
|
1323
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
1324
|
+
|
|
1325
|
+
// Verify controller entered waiting_on_user with the correct question
|
|
1326
|
+
expect(controller.getState()).toBe('waiting_on_user');
|
|
1327
|
+
const question = getPendingQuestion(session.id);
|
|
1328
|
+
expect(question).not.toBeNull();
|
|
1329
|
+
expect(question!.questionText).toBe('Allow send_message?');
|
|
1330
|
+
|
|
1331
|
+
// Verify tool metadata was parsed correctly
|
|
1332
|
+
const pendingRequest = getPendingRequestByCallSessionId(session.id);
|
|
1333
|
+
expect(pendingRequest).not.toBeNull();
|
|
1334
|
+
expect(pendingRequest!.toolName).toBe('send_message');
|
|
1335
|
+
expect(pendingRequest!.inputDigest).not.toBeNull();
|
|
1336
|
+
|
|
1337
|
+
// No partial JSON or marker text should leak into TTS output
|
|
1338
|
+
const allText = relay.sentTokens.map((t) => t.token).join('');
|
|
1339
|
+
expect(allText).not.toContain('[ASK_GUARDIAN_APPROVAL:');
|
|
1340
|
+
expect(allText).not.toContain('send_message');
|
|
1341
|
+
expect(allText).not.toContain('}]');
|
|
1342
|
+
expect(allText).not.toContain('test}]more');
|
|
1343
|
+
expect(allText).toContain('Let me check.');
|
|
1344
|
+
|
|
1345
|
+
controller.destroy();
|
|
1346
|
+
});
|
|
1347
|
+
|
|
1348
|
+
test('ASK_GUARDIAN_APPROVAL with malformed JSON: falls through to informational ASK_GUARDIAN', async () => {
|
|
1349
|
+
// Malformed JSON in the approval marker — should be ignored, and if there's
|
|
1350
|
+
// also an informational ASK_GUARDIAN marker, it should be used instead
|
|
1351
|
+
mockStartVoiceTurn.mockImplementation(createMockVoiceTurn(
|
|
1352
|
+
['Checking. [ASK_GUARDIAN_APPROVAL: {invalid json}] [ASK_GUARDIAN: Fallback question?]'],
|
|
1353
|
+
));
|
|
1354
|
+
const { session, controller } = setupController('Test fallback');
|
|
1355
|
+
|
|
1356
|
+
await controller.handleCallerUtterance('Do something');
|
|
1357
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
1358
|
+
|
|
1359
|
+
const pendingRequest = getPendingRequestByCallSessionId(session.id);
|
|
1360
|
+
expect(pendingRequest).not.toBeNull();
|
|
1361
|
+
expect(pendingRequest!.questionText).toBe('Fallback question?');
|
|
1362
|
+
// Tool metadata should be null since the approval marker was malformed
|
|
1363
|
+
expect(pendingRequest!.toolName).toBeNull();
|
|
1364
|
+
expect(pendingRequest!.inputDigest).toBeNull();
|
|
1365
|
+
|
|
1366
|
+
controller.destroy();
|
|
1367
|
+
});
|
|
1198
1368
|
});
|
|
@@ -2743,7 +2743,9 @@ describe('outbound SMS verification', () => {
|
|
|
2743
2743
|
// Guardian outbound sessions (no verificationPurpose override) create
|
|
2744
2744
|
// guardian bindings on success
|
|
2745
2745
|
expect(result.verificationType).toBe('guardian');
|
|
2746
|
-
|
|
2746
|
+
if (result.verificationType === 'guardian') {
|
|
2747
|
+
expect(result.bindingId).toBeDefined();
|
|
2748
|
+
}
|
|
2747
2749
|
}
|
|
2748
2750
|
});
|
|
2749
2751
|
|