@vellumai/assistant 0.3.16 → 0.3.18
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 +70 -13
- package/README.md +6 -0
- package/docs/architecture/http-token-refresh.md +23 -1
- package/package.json +1 -1
- package/src/__tests__/access-request-decision.test.ts +4 -7
- package/src/__tests__/channel-guardian.test.ts +3 -1
- package/src/__tests__/checker.test.ts +79 -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-late-reply.test.ts +131 -0
- package/src/__tests__/guardian-action-store.test.ts +182 -0
- package/src/__tests__/guardian-dispatch.test.ts +120 -0
- package/src/__tests__/ipc-snapshot.test.ts +21 -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__/slack-channel-config.test.ts +3 -3
- package/src/__tests__/trust-store.test.ts +21 -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-session-bridge.test.ts +109 -9
- package/src/calls/call-controller.ts +129 -8
- package/src/calls/guardian-action-sweep.ts +1 -1
- package/src/calls/guardian-dispatch.ts +8 -0
- package/src/calls/voice-session-bridge.ts +4 -2
- package/src/cli/core-commands.ts +41 -1
- 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-slack-channel.ts +1 -1
- package/src/daemon/handlers/identity.ts +45 -25
- package/src/daemon/handlers/sessions.ts +1 -1
- package/src/daemon/ipc-contract/sessions.ts +1 -1
- 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 +438 -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 +9 -1
- package/src/memory/fts-reconciler.ts +41 -26
- package/src/memory/guardian-action-store.ts +57 -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/index.ts +4 -2
- package/src/memory/schema-migration.ts +1 -0
- package/src/memory/schema.ts +6 -1
- 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 +1 -16
- package/src/permissions/defaults.ts +14 -4
- package/src/runtime/guardian-action-followup-executor.ts +1 -1
- 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 +4 -4
- package/src/runtime/routes/inbound-message-handler.ts +6 -6
- package/src/runtime/routes/integration-routes.ts +2 -2
- 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
|
@@ -218,7 +218,7 @@ The app token is validated by format only — it must start with `xapp-`.
|
|
|
218
218
|
|
|
219
219
|
**Connection status:**
|
|
220
220
|
|
|
221
|
-
|
|
221
|
+
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
222
|
|
|
223
223
|
**Key source files:**
|
|
224
224
|
|
|
@@ -276,6 +276,33 @@ External users who are not the guardian can gain access to the assistant through
|
|
|
276
276
|
| `src/memory/channel-guardian-store.ts` | Approval request and verification challenge persistence |
|
|
277
277
|
| `src/config/vellum-skills/trusted-contacts/SKILL.md` | Skill teaching the assistant to manage contacts via HTTP API |
|
|
278
278
|
|
|
279
|
+
### Update Bulletin System
|
|
280
|
+
|
|
281
|
+
Release-driven update notification system that surfaces release notes to the assistant via the system prompt.
|
|
282
|
+
|
|
283
|
+
**Data flow:**
|
|
284
|
+
1. **Bundled template** (`src/config/templates/UPDATES.md`) — source of release notes, maintained per-release in the repo.
|
|
285
|
+
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.
|
|
286
|
+
3. **System prompt injection** — `buildSystemPrompt()` reads workspace `UPDATES.md` and injects it as a `## Recent Updates` section with judgment-based handling instructions.
|
|
287
|
+
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.
|
|
288
|
+
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.
|
|
289
|
+
|
|
290
|
+
**Checkpoint keys** (in `memory_checkpoints` table):
|
|
291
|
+
- `updates:active_releases` — JSON array of version strings currently active.
|
|
292
|
+
- `updates:completed_releases` — JSON array of version strings already completed.
|
|
293
|
+
|
|
294
|
+
**Key source files:**
|
|
295
|
+
|
|
296
|
+
| File | Purpose |
|
|
297
|
+
|------|---------|
|
|
298
|
+
| `src/config/templates/UPDATES.md` | Bundled release-note template |
|
|
299
|
+
| `src/config/update-bulletin.ts` | Startup sync logic (materialize, delete-complete, merge) |
|
|
300
|
+
| `src/config/update-bulletin-format.ts` | Release block formatter/parser helpers |
|
|
301
|
+
| `src/config/update-bulletin-state.ts` | Checkpoint state helpers for active/completed releases |
|
|
302
|
+
| `src/config/system-prompt.ts` | Prompt injection of updates section |
|
|
303
|
+
| `src/daemon/config-watcher.ts` | File watcher — evicts sessions on UPDATES.md changes |
|
|
304
|
+
| `src/permissions/defaults.ts` | Auto-allow rules for file_read/write/edit + rm UPDATES.md |
|
|
305
|
+
|
|
279
306
|
---
|
|
280
307
|
|
|
281
308
|
|
|
@@ -1543,9 +1570,10 @@ Keep-alive heartbeats (every 30 s by default):
|
|
|
1543
1570
|
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
1571
|
|
|
1545
1572
|
```
|
|
1546
|
-
Producer → NotificationSignal → Decision Engine (LLM) → Deterministic Checks → Broadcaster → Conversation Pairing → Adapters → Delivery
|
|
1547
|
-
|
|
1548
|
-
|
|
1573
|
+
Producer → NotificationSignal → Candidate Generation → Decision Engine (LLM) → Deterministic Checks → Broadcaster → Conversation Pairing → Adapters → Delivery
|
|
1574
|
+
↑ ↓
|
|
1575
|
+
Preference Summary notification_thread_created IPC
|
|
1576
|
+
Thread Candidates (creation-only — not emitted on reuse)
|
|
1549
1577
|
```
|
|
1550
1578
|
|
|
1551
1579
|
### Channel Policy Registry
|
|
@@ -1560,13 +1588,18 @@ Producer → NotificationSignal → Decision Engine (LLM) → Deterministic Chec
|
|
|
1560
1588
|
|
|
1561
1589
|
Helper functions: `getDeliverableChannels()`, `getChannelPolicy()`, `isNotificationDeliverable()`, `getConversationStrategy()`.
|
|
1562
1590
|
|
|
1563
|
-
### Conversation Pairing
|
|
1591
|
+
### Conversation Pairing and Thread Routing
|
|
1592
|
+
|
|
1593
|
+
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:
|
|
1564
1594
|
|
|
1565
|
-
|
|
1595
|
+
- **`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`.
|
|
1596
|
+
- **`start_new` (default)**: Creates a fresh conversation per delivery.
|
|
1597
|
+
|
|
1598
|
+
This ensures:
|
|
1566
1599
|
|
|
1567
1600
|
1. Every delivery has an auditable conversation trail in the conversations table
|
|
1568
1601
|
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 `
|
|
1602
|
+
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
1603
|
|
|
1571
1604
|
The pairing function (`pairDeliveryWithConversation`) is resilient — errors are caught and logged without breaking the delivery pipeline.
|
|
1572
1605
|
|
|
@@ -1577,19 +1610,42 @@ The notification pipeline uses a single conversation materialization path across
|
|
|
1577
1610
|
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
1611
|
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
1612
|
|
|
1580
|
-
### Thread Surfacing via `notification_thread_created` IPC
|
|
1613
|
+
### Thread Surfacing via `notification_thread_created` IPC (Creation-Only)
|
|
1614
|
+
|
|
1615
|
+
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
1616
|
|
|
1582
|
-
When a vellum notification thread is
|
|
1617
|
+
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
1618
|
|
|
1584
1619
|
### IPC Thread-Created Events
|
|
1585
1620
|
|
|
1586
1621
|
Two IPC push events surface new threads in the macOS/iOS client sidebar:
|
|
1587
1622
|
|
|
1588
|
-
- **`notification_thread_created`** — Emitted by `broadcaster.ts` when a notification delivery creates a vellum conversation (strategy `start_new_conversation`). Payload: `{ conversationId, title, sourceEventName }`.
|
|
1623
|
+
- **`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
1624
|
- **`task_run_thread_created`** — Emitted by `work-item-runner.ts` when a task run creates a conversation. Payload: `{ conversationId, workItemId, title }`.
|
|
1590
1625
|
|
|
1591
1626
|
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
1627
|
|
|
1628
|
+
### Thread Routing Decision Flow
|
|
1629
|
+
|
|
1630
|
+
The decision engine produces per-channel thread actions using a candidate-driven approach:
|
|
1631
|
+
|
|
1632
|
+
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).
|
|
1633
|
+
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.
|
|
1634
|
+
3. **Strict validation** (`validateThreadActions`): Reuse targets must exist in the candidate set. Invalid targets are downgraded to `start_new`.
|
|
1635
|
+
4. **Pairing execution**: `pairDeliveryWithConversation` executes the thread action — appending to an existing conversation on reuse, creating a new one otherwise.
|
|
1636
|
+
5. **IPC gating**: `notification_thread_created` fires only on actual creation, not on reuse.
|
|
1637
|
+
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`).
|
|
1638
|
+
|
|
1639
|
+
### Guardian Multi-Request Disambiguation in Reused Threads
|
|
1640
|
+
|
|
1641
|
+
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**:
|
|
1642
|
+
|
|
1643
|
+
- **Single pending delivery**: Matched automatically (single-match fast path).
|
|
1644
|
+
- **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.
|
|
1645
|
+
- **No match**: A disambiguation message is sent listing all active request codes.
|
|
1646
|
+
|
|
1647
|
+
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).
|
|
1648
|
+
|
|
1593
1649
|
### Reminder Routing Metadata
|
|
1594
1650
|
|
|
1595
1651
|
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 +1668,9 @@ Connected channels are resolved at signal emission time: vellum is always includ
|
|
|
1612
1668
|
| `assistant/src/notifications/emit-signal.ts` | Single entry point for all producers; orchestrates the full pipeline |
|
|
1613
1669
|
| `assistant/src/notifications/decision-engine.ts` | LLM-based routing decisions with deterministic fallback |
|
|
1614
1670
|
| `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
|
|
1671
|
+
| `assistant/src/notifications/broadcaster.ts` | Dispatches decisions to channel adapters; emits `notification_thread_created` IPC (creation-only) |
|
|
1672
|
+
| `assistant/src/notifications/conversation-pairing.ts` | Materializes conversation + message per delivery; executes thread reuse decisions |
|
|
1673
|
+
| `assistant/src/notifications/thread-candidates.ts` | Builds per-channel candidate set of recent conversations for the decision engine |
|
|
1617
1674
|
| `assistant/src/notifications/adapters/macos.ts` | Vellum adapter — broadcasts `notification_intent` via IPC with deep-link metadata |
|
|
1618
1675
|
| `assistant/src/notifications/adapters/telegram.ts` | Telegram adapter — POSTs to gateway `/deliver/telegram` |
|
|
1619
1676
|
| `assistant/src/notifications/adapters/sms.ts` | SMS adapter — POSTs to gateway `/deliver/sms` via Twilio Messages API |
|
|
@@ -1624,7 +1681,7 @@ Connected channels are resolved at signal emission time: vellum is always includ
|
|
|
1624
1681
|
| `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
1682
|
| `assistant/src/calls/guardian-dispatch.ts` | Guardian question dispatch that reuses canonical notification pairing and records guardian delivery bookkeeping from pipeline results |
|
|
1626
1683
|
|
|
1627
|
-
**Audit trail (SQLite):** `notification_events` → `notification_decisions` → `notification_deliveries` (with `conversation_id`, `message_id`, `conversation_strategy`)
|
|
1684
|
+
**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
1685
|
|
|
1629
1686
|
**Configuration:** `notifications.decisionModelIntent` in `config.json`.
|
|
1630
1687
|
|
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
|
package/package.json
CHANGED
|
@@ -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(
|
|
@@ -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
|
|
|
@@ -272,8 +272,8 @@ describe('Permission Checker', () => {
|
|
|
272
272
|
expect(await classifyRisk('bash', { command: 'some_custom_tool' })).toBe(RiskLevel.Medium);
|
|
273
273
|
});
|
|
274
274
|
|
|
275
|
-
test('rm (without -r) is
|
|
276
|
-
expect(await classifyRisk('bash', { command: 'rm file.txt' })).toBe(RiskLevel.
|
|
275
|
+
test('rm (without -r) is high risk', async () => {
|
|
276
|
+
expect(await classifyRisk('bash', { command: 'rm file.txt' })).toBe(RiskLevel.High);
|
|
277
277
|
});
|
|
278
278
|
|
|
279
279
|
test('chmod is medium risk', async () => {
|
|
@@ -374,7 +374,7 @@ describe('Permission Checker', () => {
|
|
|
374
374
|
expect(high.matchedRule?.id).toBe('default:allow-bash-global');
|
|
375
375
|
|
|
376
376
|
// Medium risk
|
|
377
|
-
const med = await check('bash', { command: '
|
|
377
|
+
const med = await check('bash', { command: 'curl https://example.com' }, '/tmp');
|
|
378
378
|
expect(med.decision).toBe('allow');
|
|
379
379
|
expect(med.matchedRule?.id).toBe('default:allow-bash-global');
|
|
380
380
|
|
|
@@ -391,7 +391,7 @@ describe('Permission Checker', () => {
|
|
|
391
391
|
const high = await check('bash', { command: 'sudo rm -rf /' }, '/tmp');
|
|
392
392
|
expect(high.decision).toBe('prompt');
|
|
393
393
|
|
|
394
|
-
const med = await check('bash', { command: '
|
|
394
|
+
const med = await check('bash', { command: 'curl https://example.com' }, '/tmp');
|
|
395
395
|
expect(med.decision).toBe('prompt');
|
|
396
396
|
|
|
397
397
|
// Low risk still auto-allows via the normal risk-based fallback
|
|
@@ -409,17 +409,31 @@ describe('Permission Checker', () => {
|
|
|
409
409
|
expect(result.decision).toBe('prompt');
|
|
410
410
|
});
|
|
411
411
|
|
|
412
|
-
test('host_bash
|
|
412
|
+
test('host_bash rm is always high risk → prompt', async () => {
|
|
413
413
|
const result = await check('host_bash', { command: 'rm file.txt' }, '/tmp');
|
|
414
414
|
expect(result.decision).toBe('prompt');
|
|
415
|
+
expect(result.reason).toContain('High risk');
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
test('plain rm (without -rf) is high risk and prompts despite default allow rule', async () => {
|
|
419
|
+
// Validates that ALL rm commands are escalated to High risk, not just rm -rf.
|
|
420
|
+
// The default allow rule for host_bash auto-approves Low/Medium risk but
|
|
421
|
+
// High risk always prompts.
|
|
422
|
+
const result = await check('host_bash', { command: 'rm single-file.txt' }, '/tmp');
|
|
423
|
+
expect(result.decision).toBe('prompt');
|
|
424
|
+
expect(result.reason).toContain('High risk');
|
|
425
|
+
|
|
426
|
+
// Also verify rm -rf still prompts
|
|
427
|
+
const rfResult = await check('host_bash', { command: 'rm -rf /tmp/dir' }, '/tmp');
|
|
428
|
+
expect(rfResult.decision).toBe('prompt');
|
|
429
|
+
expect(rfResult.reason).toContain('High risk');
|
|
415
430
|
});
|
|
416
431
|
|
|
417
|
-
test('
|
|
432
|
+
test('rm is high risk even with matching trust rule → prompt', async () => {
|
|
418
433
|
addRule('bash', 'rm *', '/tmp');
|
|
419
434
|
const result = await check('bash', { command: 'rm file.txt' }, '/tmp');
|
|
420
|
-
expect(result.decision).toBe('
|
|
421
|
-
expect(result.reason).toContain('
|
|
422
|
-
expect(result.matchedRule).toBeDefined();
|
|
435
|
+
expect(result.decision).toBe('prompt');
|
|
436
|
+
expect(result.reason).toContain('High risk');
|
|
423
437
|
});
|
|
424
438
|
|
|
425
439
|
test('file_read → auto-allow', async () => {
|
|
@@ -489,11 +503,11 @@ describe('Permission Checker', () => {
|
|
|
489
503
|
expect(result.matchedRule?.id).toBe('default:ask-host_file_edit-global');
|
|
490
504
|
});
|
|
491
505
|
|
|
492
|
-
test('host_bash
|
|
506
|
+
test('host_bash auto-allows low risk via default allow rule', async () => {
|
|
493
507
|
const result = await check('host_bash', { command: 'ls' }, '/tmp');
|
|
494
|
-
expect(result.decision).toBe('
|
|
495
|
-
expect(result.reason).toContain('
|
|
496
|
-
expect(result.matchedRule?.id).toBe('default:
|
|
508
|
+
expect(result.decision).toBe('allow');
|
|
509
|
+
expect(result.reason).toContain('Matched trust rule');
|
|
510
|
+
expect(result.matchedRule?.id).toBe('default:allow-host_bash-global');
|
|
497
511
|
});
|
|
498
512
|
|
|
499
513
|
test('scaffold_managed_skill prompts by default via managed skill ask rule', async () => {
|
|
@@ -597,7 +611,7 @@ describe('Permission Checker', () => {
|
|
|
597
611
|
});
|
|
598
612
|
|
|
599
613
|
// Deny rule tests
|
|
600
|
-
test('deny rule blocks
|
|
614
|
+
test('deny rule blocks high-risk command', async () => {
|
|
601
615
|
addRule('bash', 'rm *', '/tmp', 'deny');
|
|
602
616
|
const result = await check('bash', { command: 'rm file.txt' }, '/tmp');
|
|
603
617
|
expect(result.decision).toBe('deny');
|
|
@@ -764,16 +778,16 @@ describe('Permission Checker', () => {
|
|
|
764
778
|
|
|
765
779
|
// Priority-based rule resolution
|
|
766
780
|
test('higher-priority allow rule overrides lower-priority deny rule', async () => {
|
|
767
|
-
addRule('bash', '
|
|
768
|
-
addRule('bash', '
|
|
769
|
-
const result = await check('bash', { command: '
|
|
781
|
+
addRule('bash', 'chmod *', '/tmp', 'deny', 0);
|
|
782
|
+
addRule('bash', 'chmod *', '/tmp', 'allow', 100);
|
|
783
|
+
const result = await check('bash', { command: 'chmod 644 file.txt' }, '/tmp');
|
|
770
784
|
expect(result.decision).toBe('allow');
|
|
771
785
|
});
|
|
772
786
|
|
|
773
787
|
test('higher-priority deny rule overrides lower-priority allow rule', async () => {
|
|
774
|
-
addRule('bash', '
|
|
775
|
-
addRule('bash', '
|
|
776
|
-
const result = await check('bash', { command: '
|
|
788
|
+
addRule('bash', 'chmod *', '/tmp', 'allow', 0);
|
|
789
|
+
addRule('bash', 'chmod *', '/tmp', 'deny', 100);
|
|
790
|
+
const result = await check('bash', { command: 'chmod 644 file.txt' }, '/tmp');
|
|
777
791
|
expect(result.decision).toBe('deny');
|
|
778
792
|
});
|
|
779
793
|
|
|
@@ -1465,13 +1479,14 @@ describe('Permission Checker', () => {
|
|
|
1465
1479
|
expect(result.matchedRule?.id).toBe('default:allow-bash-global');
|
|
1466
1480
|
});
|
|
1467
1481
|
|
|
1468
|
-
test('host_bash
|
|
1482
|
+
test('host_bash auto-allows low risk in strict mode (default allow rule is a matching rule)', async () => {
|
|
1469
1483
|
testConfig.permissions.mode = 'strict';
|
|
1470
1484
|
const result = await check('host_bash', { command: 'ls' }, '/tmp');
|
|
1471
|
-
expect(result.decision).toBe('
|
|
1485
|
+
expect(result.decision).toBe('allow');
|
|
1486
|
+
expect(result.matchedRule?.id).toBe('default:allow-host_bash-global');
|
|
1472
1487
|
});
|
|
1473
1488
|
|
|
1474
|
-
test('
|
|
1489
|
+
test('high-risk host_bash (rm) with no matching rule returns prompt in strict mode', async () => {
|
|
1475
1490
|
testConfig.permissions.mode = 'strict';
|
|
1476
1491
|
const result = await check('host_bash', { command: 'rm file.txt' }, '/tmp');
|
|
1477
1492
|
expect(result.decision).toBe('prompt');
|
|
@@ -1568,8 +1583,8 @@ describe('Permission Checker', () => {
|
|
|
1568
1583
|
});
|
|
1569
1584
|
|
|
1570
1585
|
test('medium-risk tool with allow rule is NOT affected by allowHighRisk', async () => {
|
|
1571
|
-
addRule('bash', '
|
|
1572
|
-
const result = await check('bash', { command: '
|
|
1586
|
+
addRule('bash', 'chmod *', '/tmp', 'allow', 100);
|
|
1587
|
+
const result = await check('bash', { command: 'chmod 644 file.txt' }, '/tmp');
|
|
1573
1588
|
expect(result.decision).toBe('allow');
|
|
1574
1589
|
expect(result.reason).toContain('Matched trust rule');
|
|
1575
1590
|
// No mention of high-risk in the reason
|
|
@@ -1639,8 +1654,8 @@ describe('Permission Checker', () => {
|
|
|
1639
1654
|
|
|
1640
1655
|
test('strict mode: medium-risk with matching allow rule auto-allows', async () => {
|
|
1641
1656
|
testConfig.permissions.mode = 'strict';
|
|
1642
|
-
addRule('bash', '
|
|
1643
|
-
const result = await check('bash', { command: '
|
|
1657
|
+
addRule('bash', 'chmod *', '/tmp', 'allow');
|
|
1658
|
+
const result = await check('bash', { command: 'chmod 644 file.txt' }, '/tmp');
|
|
1644
1659
|
expect(result.decision).toBe('allow');
|
|
1645
1660
|
expect(result.reason).toContain('Matched trust rule');
|
|
1646
1661
|
});
|
|
@@ -2416,10 +2431,11 @@ describe('Permission Checker', () => {
|
|
|
2416
2431
|
expect(result.matchedRule?.id).toBe('default:allow-bash-global');
|
|
2417
2432
|
});
|
|
2418
2433
|
|
|
2419
|
-
test('low-risk host_bash
|
|
2434
|
+
test('low-risk host_bash auto-allows in strict mode (default allow rule is a matching rule)', async () => {
|
|
2420
2435
|
testConfig.permissions.mode = 'strict';
|
|
2421
2436
|
const result = await check('host_bash', { command: 'echo hello' }, '/tmp');
|
|
2422
|
-
expect(result.decision).toBe('
|
|
2437
|
+
expect(result.decision).toBe('allow');
|
|
2438
|
+
expect(result.matchedRule?.id).toBe('default:allow-host_bash-global');
|
|
2423
2439
|
});
|
|
2424
2440
|
|
|
2425
2441
|
test('low-risk file_read with no rule prompts in strict mode', async () => {
|
|
@@ -2481,10 +2497,10 @@ describe('Permission Checker', () => {
|
|
|
2481
2497
|
// target-scoped. ───────────────────────────────────────────────
|
|
2482
2498
|
|
|
2483
2499
|
describe('Invariant 4: host execution approvals are explicit and target-scoped', () => {
|
|
2484
|
-
test('host_bash
|
|
2500
|
+
test('host_bash auto-allows low risk via default allow rule', async () => {
|
|
2485
2501
|
const result = await check('host_bash', { command: 'ls' }, '/tmp');
|
|
2486
|
-
expect(result.decision).toBe('
|
|
2487
|
-
expect(result.matchedRule?.id).toBe('default:
|
|
2502
|
+
expect(result.decision).toBe('allow');
|
|
2503
|
+
expect(result.matchedRule?.id).toBe('default:allow-host_bash-global');
|
|
2488
2504
|
});
|
|
2489
2505
|
|
|
2490
2506
|
test('host_file_read prompts by default (no implicit allow)', async () => {
|
|
@@ -2531,11 +2547,11 @@ describe('Permission Checker', () => {
|
|
|
2531
2547
|
expect(matchResult.matchedRule?.id).toBe('inv4-target-scoped');
|
|
2532
2548
|
|
|
2533
2549
|
// Different target — the target-scoped rule should NOT match;
|
|
2534
|
-
// falls back to the default host_bash
|
|
2550
|
+
// falls back to the default host_bash allow rule (auto-allows medium risk)
|
|
2535
2551
|
const noMatchResult = await check('host_bash', { command: 'run script.js' }, '/tmp', {
|
|
2536
2552
|
executionTarget: '/usr/local/bin/bun',
|
|
2537
2553
|
});
|
|
2538
|
-
expect(noMatchResult.decision).toBe('
|
|
2554
|
+
expect(noMatchResult.decision).toBe('allow');
|
|
2539
2555
|
expect(noMatchResult.matchedRule?.id).not.toBe('inv4-target-scoped');
|
|
2540
2556
|
});
|
|
2541
2557
|
});
|
|
@@ -2605,7 +2621,7 @@ describe('Permission Checker', () => {
|
|
|
2605
2621
|
test('wildcard allow rule matches any command in legacy mode', async () => {
|
|
2606
2622
|
testConfig.permissions.mode = 'legacy';
|
|
2607
2623
|
addRule('bash', '*', 'everywhere');
|
|
2608
|
-
const result = await check('bash', { command: '
|
|
2624
|
+
const result = await check('bash', { command: 'chmod 644 file.txt' }, '/tmp');
|
|
2609
2625
|
expect(result.decision).toBe('allow');
|
|
2610
2626
|
expect(result.matchedRule).toBeDefined();
|
|
2611
2627
|
});
|
|
@@ -2613,7 +2629,7 @@ describe('Permission Checker', () => {
|
|
|
2613
2629
|
test('wildcard allow rule matches any command in strict mode', async () => {
|
|
2614
2630
|
testConfig.permissions.mode = 'strict';
|
|
2615
2631
|
addRule('bash', '*', 'everywhere');
|
|
2616
|
-
const result = await check('bash', { command: '
|
|
2632
|
+
const result = await check('bash', { command: 'chmod 644 file.txt' }, '/tmp');
|
|
2617
2633
|
expect(result.decision).toBe('allow');
|
|
2618
2634
|
expect(result.matchedRule).toBeDefined();
|
|
2619
2635
|
});
|
|
@@ -2724,12 +2740,27 @@ describe('Permission Checker', () => {
|
|
|
2724
2740
|
);
|
|
2725
2741
|
|
|
2726
2742
|
test('getDefaultRuleTemplates has no extra rules when extraDirs is empty', () => {
|
|
2727
|
-
// Default testConfig has no skills property → getConfig returns default
|
|
2728
|
-
// with extraDirs: []
|
|
2729
2743
|
const templates = getDefaultRuleTemplates();
|
|
2730
2744
|
const extraRules = templates.filter((t) => t.id.includes('extra-'));
|
|
2731
2745
|
expect(extraRules.length).toBe(0);
|
|
2732
2746
|
});
|
|
2747
|
+
|
|
2748
|
+
test('getDefaultRuleTemplates tolerates partial config mocks', () => {
|
|
2749
|
+
const originalSkills = testConfig.skills;
|
|
2750
|
+
const originalSandbox = testConfig.sandbox;
|
|
2751
|
+
try {
|
|
2752
|
+
testConfig.skills = {} as any;
|
|
2753
|
+
testConfig.sandbox = {} as any;
|
|
2754
|
+
|
|
2755
|
+
const templates = getDefaultRuleTemplates();
|
|
2756
|
+
expect(Array.isArray(templates)).toBe(true);
|
|
2757
|
+
expect(templates.some((t) => t.id.includes('extra-'))).toBe(false);
|
|
2758
|
+
expect(templates.some((t) => t.id === 'default:allow-bash-global')).toBe(true);
|
|
2759
|
+
} finally {
|
|
2760
|
+
testConfig.skills = originalSkills;
|
|
2761
|
+
testConfig.sandbox = originalSandbox;
|
|
2762
|
+
}
|
|
2763
|
+
});
|
|
2733
2764
|
});
|
|
2734
2765
|
|
|
2735
2766
|
// ── backslash normalization gated to Windows (PR 3558 follow-up) ──
|
|
@@ -2952,8 +2983,8 @@ describe('bash network_mode=proxied force prompt', () => {
|
|
|
2952
2983
|
});
|
|
2953
2984
|
|
|
2954
2985
|
test('non-proxied bash with trust rule follows normal flow', async () => {
|
|
2955
|
-
addRule('bash', '
|
|
2956
|
-
const result = await check('bash', { command: '
|
|
2986
|
+
addRule('bash', 'chmod *', '/tmp');
|
|
2987
|
+
const result = await check('bash', { command: 'chmod 644 file.txt' }, '/tmp');
|
|
2957
2988
|
expect(result.decision).toBe('allow');
|
|
2958
2989
|
expect(result.reason).not.toContain('Proxied network mode');
|
|
2959
2990
|
});
|
|
@@ -3245,10 +3276,10 @@ describe('workspace mode — auto-allow workspace-scoped operations', () => {
|
|
|
3245
3276
|
expect(result.reason).toContain('ask rule');
|
|
3246
3277
|
});
|
|
3247
3278
|
|
|
3248
|
-
test('host_bash →
|
|
3279
|
+
test('host_bash → allow (default allow rule matches)', async () => {
|
|
3249
3280
|
const result = await check('host_bash', { command: 'ls' }, workspaceDir);
|
|
3250
|
-
expect(result.decision).toBe('
|
|
3251
|
-
expect(result.reason).toContain('
|
|
3281
|
+
expect(result.decision).toBe('allow');
|
|
3282
|
+
expect(result.reason).toContain('Matched trust rule');
|
|
3252
3283
|
});
|
|
3253
3284
|
|
|
3254
3285
|
// ── explicit rules still take precedence in workspace mode ──
|
|
@@ -3428,20 +3459,20 @@ describe('integration regressions (PR 11)', () => {
|
|
|
3428
3459
|
});
|
|
3429
3460
|
|
|
3430
3461
|
test('raw legacy rule still works alongside new action key system', async () => {
|
|
3431
|
-
// Use medium-risk commands (
|
|
3462
|
+
// Use medium-risk commands (chmod) so they aren't auto-allowed by low-risk classification.
|
|
3432
3463
|
// Disable sandbox so the catch-all "**" rule doesn't interfere.
|
|
3433
3464
|
testConfig.sandbox.enabled = false;
|
|
3434
3465
|
try { rmSync(join(checkerTestDir, 'protected', 'trust.json')); } catch { /* may not exist */ }
|
|
3435
3466
|
clearCache();
|
|
3436
3467
|
try {
|
|
3437
|
-
addRule('bash', '
|
|
3468
|
+
addRule('bash', 'chmod 644 file.txt', 'everywhere');
|
|
3438
3469
|
|
|
3439
3470
|
// Exact match still works
|
|
3440
|
-
const r1 = await check('bash', { command: '
|
|
3471
|
+
const r1 = await check('bash', { command: 'chmod 644 file.txt' }, '/tmp');
|
|
3441
3472
|
expect(r1.decision).toBe('allow');
|
|
3442
3473
|
|
|
3443
|
-
// Different
|
|
3444
|
-
const r2 = await check('bash', { command: '
|
|
3474
|
+
// Different chmod argument should not match this exact raw rule
|
|
3475
|
+
const r2 = await check('bash', { command: 'chmod 755 other.txt' }, '/tmp');
|
|
3445
3476
|
expect(r2.decision).not.toBe('allow');
|
|
3446
3477
|
} finally {
|
|
3447
3478
|
testConfig.sandbox.enabled = true;
|