@vellumai/assistant 0.4.6 → 0.4.8
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 +23 -6
- package/bun.lock +51 -0
- package/docs/trusted-contact-access.md +8 -0
- package/package.json +2 -1
- package/src/__tests__/actor-token-service.test.ts +4 -4
- package/src/__tests__/call-controller.test.ts +37 -0
- package/src/__tests__/channel-delivery-store.test.ts +2 -2
- package/src/__tests__/gateway-client-managed-outbound.test.ts +147 -0
- package/src/__tests__/guardian-dispatch.test.ts +39 -1
- package/src/__tests__/guardian-routing-state.test.ts +8 -30
- package/src/__tests__/non-member-access-request.test.ts +7 -0
- package/src/__tests__/notification-decision-fallback.test.ts +232 -0
- package/src/__tests__/notification-decision-strategy.test.ts +304 -8
- package/src/__tests__/notification-guardian-path.test.ts +38 -1
- package/src/__tests__/relay-server.test.ts +65 -5
- package/src/__tests__/send-endpoint-busy.test.ts +29 -1
- package/src/__tests__/tool-grant-request-escalation.test.ts +1 -0
- package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +6 -0
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +2 -2
- package/src/__tests__/trusted-contact-multichannel.test.ts +1 -1
- package/src/calls/call-controller.ts +15 -0
- package/src/calls/relay-server.ts +45 -11
- package/src/calls/types.ts +1 -0
- package/src/daemon/providers-setup.ts +0 -8
- package/src/daemon/session-slash.ts +35 -2
- package/src/memory/db-init.ts +4 -0
- package/src/memory/migrations/039-actor-refresh-token-records.ts +51 -0
- package/src/memory/migrations/index.ts +1 -0
- package/src/memory/migrations/registry.ts +1 -1
- package/src/memory/schema.ts +19 -0
- package/src/notifications/README.md +8 -1
- package/src/notifications/copy-composer.ts +160 -30
- package/src/notifications/decision-engine.ts +98 -1
- package/src/runtime/actor-refresh-token-service.ts +309 -0
- package/src/runtime/actor-refresh-token-store.ts +157 -0
- package/src/runtime/actor-token-service.ts +3 -3
- package/src/runtime/gateway-client.ts +239 -0
- package/src/runtime/http-server.ts +2 -0
- package/src/runtime/routes/guardian-bootstrap-routes.ts +10 -24
- package/src/runtime/routes/guardian-refresh-routes.ts +53 -0
- package/src/runtime/routes/pairing-routes.ts +60 -50
- package/src/types/qrcode.d.ts +10 -0
package/ARCHITECTURE.md
CHANGED
|
@@ -22,7 +22,7 @@ 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
|
-
### Vellum Guardian Identity Model (Actor Tokens +
|
|
25
|
+
### Vellum Guardian Identity Model (Actor Tokens + Refresh Tokens)
|
|
26
26
|
|
|
27
27
|
The vellum channel (macOS desktop, iOS, CLI) uses an identity-bound actor token system to authenticate guardian identity on HTTP routes. This replaces the previous implicit trust model where all local connections were assumed to be the guardian.
|
|
28
28
|
|
|
@@ -30,16 +30,30 @@ The vellum channel (macOS desktop, iOS, CLI) uses an identity-bound actor token
|
|
|
30
30
|
|
|
31
31
|
1. **Startup migration** — On daemon start, `ensureVellumGuardianBinding()` (in `guardian-vellum-migration.ts`) backfills a `channel='vellum'` guardian binding with a stable `guardianPrincipalId` (format: `vellum-principal-<uuid>`). Existing installations get a binding with `verifiedVia: 'startup-migration'`; new installs get one via bootstrap. This migration is idempotent and preserves bindings for other channels (Telegram, SMS, etc.).
|
|
32
32
|
|
|
33
|
-
2. **
|
|
33
|
+
2. **Bootstrap (loopback-only, macOS) — initial issuance only** — On first launch (no existing actor token), the macOS client calls `POST /v1/integrations/guardian/vellum/bootstrap` with `{ platform: 'macos', deviceId }`. The endpoint is loopback-only: it rejects requests with `X-Forwarded-For` and verifies the peer IP is a loopback address (`127.0.0.1`, `::1`, `::ffff:127.0.0.1`). The endpoint ensures a vellum guardian principal exists, revokes any prior token for the same device binding, mints a new HMAC-SHA256 signed actor token with a 30-day TTL and a rotating refresh token, stores only the SHA-256 hashes, and returns `{ guardianPrincipalId, actorToken, actorTokenExpiresAt, refreshToken, refreshTokenExpiresAt, refreshAfter, isNew }`. Bootstrap is only used for initial credential issuance — ongoing renewal is handled exclusively by the refresh endpoint.
|
|
34
34
|
|
|
35
|
-
3. **iOS pairing** — iOS devices obtain actor tokens exclusively through the QR pairing flow
|
|
35
|
+
3. **iOS pairing — initial issuance only** — iOS devices obtain actor tokens exclusively through the QR pairing flow. When an iOS device completes pairing, the pairing response includes an `actorToken` and `refreshToken` minted against the same vellum guardian principal. The pairing handler in `pairing-routes.ts` calls `mintPairingActorToken()` which looks up the vellum binding and mints a device-specific token pair. iOS does not call the bootstrap endpoint. Re-pairing is only needed if both the actor token and refresh token expire.
|
|
36
36
|
|
|
37
37
|
4. **IPC identity** — Local IPC connections (Unix domain socket from the macOS native app) do not send actor tokens. Instead, the daemon assigns a deterministic local actor identity via `resolveLocalIpcGuardianContext()` in `local-actor-identity.ts`. This looks up the vellum guardian binding and routes through the same `resolveGuardianContext` trust pipeline used by HTTP channel ingress. When no vellum binding exists yet (pre-bootstrap), a fallback guardian context is returned since the local macOS user is inherently the guardian of their own machine.
|
|
38
38
|
|
|
39
|
-
**Actor token format:** `base64url(JSON claims) + '.' + base64url(HMAC-SHA256 signature)`. Claims include `assistantId`, `platform`, `deviceId`, `guardianPrincipalId`, `iat`, `exp` (
|
|
39
|
+
**Actor token format:** `base64url(JSON claims) + '.' + base64url(HMAC-SHA256 signature)`. Claims include `assistantId`, `platform`, `deviceId`, `guardianPrincipalId`, `iat`, `exp` (30 days from issuance), and `jti`.
|
|
40
40
|
|
|
41
41
|
**Hash-only storage:** Only the SHA-256 hex digest of the raw token is persisted in the `actor_token_records` table. Token verification recomputes the hash and looks it up in the store to check revocation status. Tokens are scoped to `(assistantId, guardianPrincipalId, hashedDeviceId)` with a one-active-per-device invariant.
|
|
42
42
|
|
|
43
|
+
**Refresh token lifecycle:**
|
|
44
|
+
|
|
45
|
+
Refresh tokens provide a rotating credential renewal mechanism that avoids re-bootstrap or re-pairing for ongoing sessions.
|
|
46
|
+
|
|
47
|
+
- **Issuance:** A refresh token is minted alongside every actor token (during bootstrap or pairing). The response includes `refreshToken`, `refreshTokenExpiresAt`, and `refreshAfter` (the timestamp at which clients should proactively refresh, set to 80% of the actor token TTL).
|
|
48
|
+
- **Dual expiry:** Each refresh token has a 365-day absolute expiry (from issuance) and a 90-day inactivity expiry (from last use). The effective expiry is the earlier of the two. Using a refresh token resets the inactivity window.
|
|
49
|
+
- **Single-use rotation:** Each call to `POST /v1/integrations/guardian/vellum/refresh` consumes the presented refresh token and returns a new actor token + new refresh token pair. The old refresh token is marked as rotated and cannot be reused.
|
|
50
|
+
- **Token family tracking:** Refresh tokens are grouped into families (one family per initial issuance chain). All tokens in a family share a `familyId`.
|
|
51
|
+
- **Replay detection:** If a client presents a refresh token that has already been rotated (i.e., it was used once and a successor was issued), the server treats this as a potential token theft. The entire token family for that device is revoked, forcing re-bootstrap or re-pairing.
|
|
52
|
+
- **Device binding:** Refresh tokens are bound to `(assistantId, guardianPrincipalId, hashedDeviceId)`. A refresh request from a different device binding is rejected.
|
|
53
|
+
- **Hash-only storage:** Only the SHA-256 hex digest of the refresh token is stored in the `actor_refresh_token_records` table. The raw token is returned once and never persisted on the server.
|
|
54
|
+
|
|
55
|
+
**Refresh endpoint:** `POST /v1/integrations/guardian/vellum/refresh` accepts `{ refreshToken }` in the request body. It validates the token hash, checks expiry and device binding, performs replay detection, rotates the token, mints a new actor token, and returns `{ actorToken, actorTokenExpiresAt, refreshToken, refreshTokenExpiresAt, refreshAfter }`. This endpoint is only reachable through the gateway (bearer-authenticated).
|
|
56
|
+
|
|
43
57
|
**Signing key management:** A 32-byte random signing key is generated on first startup and persisted at `~/.vellum/protected/actor-token-signing-key` with `chmod 0o600`. The key is loaded on subsequent startups via `loadOrCreateSigningKey()`.
|
|
44
58
|
|
|
45
59
|
**Strict HTTP enforcement:** Vellum-channel HTTP routes (POST /v1/messages, POST /v1/confirm, POST /v1/guardian-actions/decision, etc.) require a valid actor token via the `X-Actor-Token` header. The middleware in `middleware/actor-token.ts` verifies the HMAC signature, checks the token is active in the store, and resolves a guardian context through the standard trust pipeline. For backward compatibility with the CLI, requests without an actor token that originate from a loopback address (no `X-Forwarded-For` header) fall back to `resolveLocalIpcGuardianContext()`. Gateway-proxied requests (which carry `X-Forwarded-For`) without an actor token are rejected.
|
|
@@ -52,11 +66,14 @@ The vellum channel (macOS desktop, iOS, CLI) uses an identity-bound actor token
|
|
|
52
66
|
|------|---------|
|
|
53
67
|
| `src/runtime/actor-token-service.ts` | HMAC-SHA256 mint/verify, signing key management, `hashToken` |
|
|
54
68
|
| `src/runtime/actor-token-store.ts` | Hash-only persistence: create, find by hash/device binding, revoke |
|
|
69
|
+
| `src/runtime/actor-refresh-token-service.ts` | Refresh token rotation, replay detection, family revocation |
|
|
70
|
+
| `src/runtime/actor-refresh-token-store.ts` | Refresh token hash-only persistence: create, find, rotate, revoke by family |
|
|
55
71
|
| `src/runtime/middleware/actor-token.ts` | HTTP middleware: `verifyHttpActorToken`, `verifyHttpActorTokenWithLocalFallback`, `isActorBoundGuardian` |
|
|
56
72
|
| `src/runtime/local-actor-identity.ts` | `resolveLocalIpcGuardianContext` — deterministic IPC identity |
|
|
57
73
|
| `src/runtime/guardian-vellum-migration.ts` | `ensureVellumGuardianBinding` — startup binding backfill |
|
|
58
|
-
| `src/runtime/routes/guardian-bootstrap-routes.ts` | `POST /v1/integrations/guardian/vellum/bootstrap` handler |
|
|
59
|
-
| `src/runtime/routes/
|
|
74
|
+
| `src/runtime/routes/guardian-bootstrap-routes.ts` | `POST /v1/integrations/guardian/vellum/bootstrap` handler (initial issuance only) |
|
|
75
|
+
| `src/runtime/routes/guardian-refresh-routes.ts` | `POST /v1/integrations/guardian/vellum/refresh` handler (token rotation) |
|
|
76
|
+
| `src/runtime/routes/pairing-routes.ts` | `mintPairingActorToken` — actor token + refresh token in pairing response |
|
|
60
77
|
| `src/memory/guardian-bindings.ts` | Guardian binding persistence (shared across all channels) |
|
|
61
78
|
|
|
62
79
|
### Channel-Agnostic Scoped Approval Grants
|
package/bun.lock
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
"pino-pretty": "^13.1.3",
|
|
26
26
|
"playwright": "^1.58.2",
|
|
27
27
|
"postgres": "^3.4.8",
|
|
28
|
+
"qrcode": "^1.5.4",
|
|
28
29
|
"react": "^19.2.4",
|
|
29
30
|
"rrule": "^2.8.1",
|
|
30
31
|
"tldts": "^7.0.23",
|
|
@@ -594,6 +595,8 @@
|
|
|
594
595
|
|
|
595
596
|
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
|
|
596
597
|
|
|
598
|
+
"camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
|
|
599
|
+
|
|
597
600
|
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
|
598
601
|
|
|
599
602
|
"cjs-module-lexer": ["cjs-module-lexer@2.2.0", "", {}, "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ=="],
|
|
@@ -658,6 +661,8 @@
|
|
|
658
661
|
|
|
659
662
|
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
|
660
663
|
|
|
664
|
+
"decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="],
|
|
665
|
+
|
|
661
666
|
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
|
|
662
667
|
|
|
663
668
|
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
|
|
@@ -666,6 +671,8 @@
|
|
|
666
671
|
|
|
667
672
|
"diff": ["diff@4.0.4", "", {}, "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ=="],
|
|
668
673
|
|
|
674
|
+
"dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="],
|
|
675
|
+
|
|
669
676
|
"dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="],
|
|
670
677
|
|
|
671
678
|
"drizzle-kit": ["drizzle-kit@0.30.6", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.19.7", "esbuild-register": "^3.5.0", "gel": "^2.0.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-U4wWit0fyZuGuP7iNmRleQyK2V8wCuv57vf5l3MnG4z4fzNTjY/U13M8owyQ5RavqvqxBifWORaR3wIUzlN64g=="],
|
|
@@ -1014,6 +1021,8 @@
|
|
|
1014
1021
|
|
|
1015
1022
|
"p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="],
|
|
1016
1023
|
|
|
1024
|
+
"p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
|
|
1025
|
+
|
|
1017
1026
|
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
|
|
1018
1027
|
|
|
1019
1028
|
"pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
|
|
@@ -1060,6 +1069,8 @@
|
|
|
1060
1069
|
|
|
1061
1070
|
"pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="],
|
|
1062
1071
|
|
|
1072
|
+
"pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="],
|
|
1073
|
+
|
|
1063
1074
|
"postgres": ["postgres@3.4.8", "", {}, "sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg=="],
|
|
1064
1075
|
|
|
1065
1076
|
"postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
|
|
@@ -1090,6 +1101,8 @@
|
|
|
1090
1101
|
|
|
1091
1102
|
"pure-rand": ["pure-rand@7.0.1", "", {}, "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ=="],
|
|
1092
1103
|
|
|
1104
|
+
"qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="],
|
|
1105
|
+
|
|
1093
1106
|
"qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="],
|
|
1094
1107
|
|
|
1095
1108
|
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
|
@@ -1120,6 +1133,8 @@
|
|
|
1120
1133
|
|
|
1121
1134
|
"require-in-the-middle": ["require-in-the-middle@8.0.1", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3" } }, "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ=="],
|
|
1122
1135
|
|
|
1136
|
+
"require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="],
|
|
1137
|
+
|
|
1123
1138
|
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
|
1124
1139
|
|
|
1125
1140
|
"restore-cursor": ["restore-cursor@4.0.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="],
|
|
@@ -1152,6 +1167,8 @@
|
|
|
1152
1167
|
|
|
1153
1168
|
"serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
|
|
1154
1169
|
|
|
1170
|
+
"set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="],
|
|
1171
|
+
|
|
1155
1172
|
"setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="],
|
|
1156
1173
|
|
|
1157
1174
|
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
|
|
@@ -1282,6 +1299,8 @@
|
|
|
1282
1299
|
|
|
1283
1300
|
"which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],
|
|
1284
1301
|
|
|
1302
|
+
"which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="],
|
|
1303
|
+
|
|
1285
1304
|
"widest-line": ["widest-line@6.0.0", "", { "dependencies": { "string-width": "^8.1.0" } }, "sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA=="],
|
|
1286
1305
|
|
|
1287
1306
|
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
|
|
@@ -1596,6 +1615,8 @@
|
|
|
1596
1615
|
|
|
1597
1616
|
"pino-pretty/pino-abstract-transport": ["pino-abstract-transport@3.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg=="],
|
|
1598
1617
|
|
|
1618
|
+
"qrcode/yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="],
|
|
1619
|
+
|
|
1599
1620
|
"react-devtools-core/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="],
|
|
1600
1621
|
|
|
1601
1622
|
"readdir-glob/minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="],
|
|
@@ -1746,6 +1767,16 @@
|
|
|
1746
1767
|
|
|
1747
1768
|
"lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
|
1748
1769
|
|
|
1770
|
+
"qrcode/yargs/cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="],
|
|
1771
|
+
|
|
1772
|
+
"qrcode/yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
|
|
1773
|
+
|
|
1774
|
+
"qrcode/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
|
1775
|
+
|
|
1776
|
+
"qrcode/yargs/y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="],
|
|
1777
|
+
|
|
1778
|
+
"qrcode/yargs/yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="],
|
|
1779
|
+
|
|
1749
1780
|
"readdir-glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
|
1750
1781
|
|
|
1751
1782
|
"rimraf/glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
|
@@ -1774,6 +1805,16 @@
|
|
|
1774
1805
|
|
|
1775
1806
|
"glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
|
1776
1807
|
|
|
1808
|
+
"qrcode/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
|
1809
|
+
|
|
1810
|
+
"qrcode/yargs/cliui/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
|
|
1811
|
+
|
|
1812
|
+
"qrcode/yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
|
1813
|
+
|
|
1814
|
+
"qrcode/yargs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
|
1815
|
+
|
|
1816
|
+
"qrcode/yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
|
1817
|
+
|
|
1777
1818
|
"readdir-glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
|
1778
1819
|
|
|
1779
1820
|
"rimraf/glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
|
@@ -1782,6 +1823,16 @@
|
|
|
1782
1823
|
|
|
1783
1824
|
"archiver-utils/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
|
1784
1825
|
|
|
1826
|
+
"qrcode/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
|
1827
|
+
|
|
1828
|
+
"qrcode/yargs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
|
1829
|
+
|
|
1830
|
+
"qrcode/yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
|
|
1831
|
+
|
|
1832
|
+
"qrcode/yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
|
1833
|
+
|
|
1785
1834
|
"rimraf/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
|
1835
|
+
|
|
1836
|
+
"qrcode/yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
|
|
1786
1837
|
}
|
|
1787
1838
|
}
|
|
@@ -16,6 +16,14 @@ Design doc defining how unknown users gain access to a Vellum assistant via chan
|
|
|
16
16
|
2. **Assistant rejects the message** via the ingress ACL in `inbound-message-handler.ts`. The user has no `assistant_ingress_members` record, so the handler replies: *"Hmm looks like you don't have access to talk to me. I'll let them know you tried talking to me and get back to you."* and returns `{ denied: true, reason: 'not_a_member' }`.
|
|
17
17
|
3. **Notification pipeline alerts the guardian.** The rejection triggers `notifyGuardianOfAccessRequest()` which creates a canonical access request and calls `emitNotificationSignal()` with `sourceEventName: 'ingress.access_request'`. The notification routes through the decision engine to all connected channels (vellum macOS app, Telegram, etc.). The guardian sees who is requesting access, including a request code for approve/reject and an `open invite flow` option to start the Trusted Contacts invite flow.
|
|
18
18
|
|
|
19
|
+
**Access-request copy contract:** Every guardian-facing access-request notification must contain:
|
|
20
|
+
1. **Requester context** — best-available identity (display name, username, external ID, source channel), sanitized to prevent control-character injection.
|
|
21
|
+
2. **Request-code decision directive** — e.g., `Reply "A1B2C3 approve" to grant access or "A1B2C3 reject" to deny.`
|
|
22
|
+
3. **Invite directive** — the exact phrase `Reply "open invite flow" to start Trusted Contacts invite flow.`
|
|
23
|
+
4. **Revoked-member warning** (when applicable) — `Note: this user was previously revoked.`
|
|
24
|
+
|
|
25
|
+
Model-generated phrasing is permitted for the surrounding copy, but a post-generation enforcement step in the decision engine validates that all required directive elements are present. If any are missing, the full deterministic contract text is appended. This ensures the guardian can always parse and act on the notification regardless of LLM output quality.
|
|
26
|
+
|
|
19
27
|
**Guardian binding resolution for access requests** uses a fallback strategy:
|
|
20
28
|
1. Source-channel active binding first (e.g., Telegram binding for a Telegram access request).
|
|
21
29
|
2. Any active binding for the assistant on another channel (deterministic: most recently verified first, then alphabetical by channel).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vellumai/assistant",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.8",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"vellum": "./src/index.ts"
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
"pino-pretty": "^13.1.3",
|
|
47
47
|
"playwright": "^1.58.2",
|
|
48
48
|
"postgres": "^3.4.8",
|
|
49
|
+
"qrcode": "^1.5.4",
|
|
49
50
|
"react": "^19.2.4",
|
|
50
51
|
"rrule": "^2.8.1",
|
|
51
52
|
"tldts": "^7.0.23",
|
|
@@ -100,7 +100,7 @@ afterAll(() => {
|
|
|
100
100
|
// ---------------------------------------------------------------------------
|
|
101
101
|
|
|
102
102
|
describe('actor-token mint/verify', () => {
|
|
103
|
-
test('mint returns token, hash, and claims with default
|
|
103
|
+
test('mint returns token, hash, and claims with default 30-day TTL', () => {
|
|
104
104
|
const result = mintActorToken({
|
|
105
105
|
assistantId: 'self',
|
|
106
106
|
platform: 'macos',
|
|
@@ -115,10 +115,10 @@ describe('actor-token mint/verify', () => {
|
|
|
115
115
|
expect(result.claims.deviceId).toBe('device-123');
|
|
116
116
|
expect(result.claims.guardianPrincipalId).toBe('principal-abc');
|
|
117
117
|
expect(result.claims.iat).toBeGreaterThan(0);
|
|
118
|
-
// Default TTL is
|
|
118
|
+
// Default TTL is 30 days
|
|
119
119
|
expect(result.claims.exp).not.toBeNull();
|
|
120
|
-
const
|
|
121
|
-
expect(result.claims.exp! - result.claims.iat).toBe(
|
|
120
|
+
const thirtyDaysMs = 30 * 24 * 60 * 60 * 1000;
|
|
121
|
+
expect(result.claims.exp! - result.claims.iat).toBe(thirtyDaysMs);
|
|
122
122
|
expect(result.claims.jti).toBeTruthy();
|
|
123
123
|
});
|
|
124
124
|
|
|
@@ -400,6 +400,43 @@ describe('call-controller', () => {
|
|
|
400
400
|
controller.destroy();
|
|
401
401
|
});
|
|
402
402
|
|
|
403
|
+
test('markNextCallerTurnAsOpeningAck: tags the next caller turn with CALL_OPENING_ACK without requiring a prior CALL_OPENING', async () => {
|
|
404
|
+
let turnCount = 0;
|
|
405
|
+
mockStartVoiceTurn.mockImplementation(async (opts: { content: string; onTextDelta: (t: string) => void; onComplete: () => void }) => {
|
|
406
|
+
turnCount++;
|
|
407
|
+
|
|
408
|
+
if (turnCount === 1) {
|
|
409
|
+
// First caller utterance after markNextCallerTurnAsOpeningAck
|
|
410
|
+
expect(opts.content).toContain('[CALL_OPENING_ACK]');
|
|
411
|
+
expect(opts.content).toContain('I want to check my balance');
|
|
412
|
+
for (const token of ['Sure, let me check your balance.']) {
|
|
413
|
+
opts.onTextDelta(token);
|
|
414
|
+
}
|
|
415
|
+
} else {
|
|
416
|
+
// Subsequent utterance should NOT have the marker
|
|
417
|
+
expect(opts.content).not.toContain('[CALL_OPENING_ACK]');
|
|
418
|
+
for (const token of ['Your balance is $42.']) {
|
|
419
|
+
opts.onTextDelta(token);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
opts.onComplete();
|
|
423
|
+
return { turnId: `run-${turnCount}`, abort: () => {} };
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
const { controller } = setupController();
|
|
427
|
+
|
|
428
|
+
// Simulate post-approval: call markNextCallerTurnAsOpeningAck directly
|
|
429
|
+
// without any prior startInitialGreeting / CALL_OPENING
|
|
430
|
+
controller.markNextCallerTurnAsOpeningAck();
|
|
431
|
+
|
|
432
|
+
await controller.handleCallerUtterance('I want to check my balance');
|
|
433
|
+
await controller.handleCallerUtterance('How much exactly?');
|
|
434
|
+
|
|
435
|
+
expect(turnCount).toBe(2);
|
|
436
|
+
|
|
437
|
+
controller.destroy();
|
|
438
|
+
});
|
|
439
|
+
|
|
403
440
|
// ── ASK_GUARDIAN pattern ──────────────────────────────────────────
|
|
404
441
|
|
|
405
442
|
test('ASK_GUARDIAN pattern: detects pattern, creates pending question, sets session to waiting_on_user', async () => {
|
|
@@ -508,7 +508,7 @@ describe('channel-delivery-store', () => {
|
|
|
508
508
|
headers: { 'Content-Type': 'application/json' },
|
|
509
509
|
body: JSON.stringify({
|
|
510
510
|
sourceChannel: 'telegram',
|
|
511
|
-
|
|
511
|
+
conversationExternalId: 'chat-del',
|
|
512
512
|
// Note: no assistantId in the body — it comes from the route param
|
|
513
513
|
}),
|
|
514
514
|
});
|
|
@@ -558,7 +558,7 @@ describe('channel-delivery-store', () => {
|
|
|
558
558
|
headers: { 'Content-Type': 'application/json' },
|
|
559
559
|
body: JSON.stringify({
|
|
560
560
|
sourceChannel: 'telegram',
|
|
561
|
-
|
|
561
|
+
conversationExternalId: 'chat-def',
|
|
562
562
|
}),
|
|
563
563
|
});
|
|
564
564
|
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import { deliverChannelReply } from '../runtime/gateway-client.js';
|
|
4
|
+
|
|
5
|
+
type FetchCall = {
|
|
6
|
+
url: string;
|
|
7
|
+
init: RequestInit;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
describe('gateway-client managed outbound lane', () => {
|
|
11
|
+
const originalFetch = globalThis.fetch;
|
|
12
|
+
const calls: FetchCall[] = [];
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
calls.length = 0;
|
|
16
|
+
globalThis.fetch = mock(async (input: string | URL | Request, init?: RequestInit) => {
|
|
17
|
+
const url = typeof input === 'string'
|
|
18
|
+
? input
|
|
19
|
+
: input instanceof URL
|
|
20
|
+
? input.toString()
|
|
21
|
+
: input.url;
|
|
22
|
+
calls.push({ url, init: init ?? {} });
|
|
23
|
+
return new Response(JSON.stringify({ status: 'accepted' }), { status: 202 });
|
|
24
|
+
}) as unknown as typeof globalThis.fetch;
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
globalThis.fetch = originalFetch;
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('translates managed callback URL into managed outbound-send request', async () => {
|
|
32
|
+
await deliverChannelReply(
|
|
33
|
+
'https://platform.test/v1/internal/managed-gateway/outbound-send/?route_id=route-123&assistant_id=assistant-123&source_channel=sms&source_update_id=SM-inbound-123&callback_token=runtime-token',
|
|
34
|
+
{
|
|
35
|
+
chatId: '+15550001111',
|
|
36
|
+
text: 'hello from runtime',
|
|
37
|
+
},
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
expect(calls).toHaveLength(1);
|
|
41
|
+
const call = calls[0];
|
|
42
|
+
expect(call.url).toBe('https://platform.test/v1/internal/managed-gateway/outbound-send/');
|
|
43
|
+
|
|
44
|
+
const headers = call.init.headers as Record<string, string>;
|
|
45
|
+
expect(headers['Content-Type']).toBe('application/json');
|
|
46
|
+
expect(headers['X-Managed-Gateway-Callback-Token']).toBe('runtime-token');
|
|
47
|
+
expect(headers['X-Idempotency-Key']).toStartWith('mgw-send-');
|
|
48
|
+
expect(headers.Authorization).toBeUndefined();
|
|
49
|
+
|
|
50
|
+
const body = JSON.parse(String(call.init.body)) as {
|
|
51
|
+
route_id: string;
|
|
52
|
+
assistant_id: string;
|
|
53
|
+
normalized_send: {
|
|
54
|
+
sourceChannel: string;
|
|
55
|
+
message: {
|
|
56
|
+
to: string;
|
|
57
|
+
content: string;
|
|
58
|
+
externalMessageId: string;
|
|
59
|
+
};
|
|
60
|
+
source: {
|
|
61
|
+
requestId: string;
|
|
62
|
+
};
|
|
63
|
+
raw: {
|
|
64
|
+
sourceUpdateId: string;
|
|
65
|
+
};
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
expect(body.route_id).toBe('route-123');
|
|
69
|
+
expect(body.assistant_id).toBe('assistant-123');
|
|
70
|
+
expect(body.normalized_send.sourceChannel).toBe('sms');
|
|
71
|
+
expect(body.normalized_send.message.to).toBe('+15550001111');
|
|
72
|
+
expect(body.normalized_send.message.content).toBe('hello from runtime');
|
|
73
|
+
expect(body.normalized_send.message.externalMessageId).toStartWith('mgw-send-');
|
|
74
|
+
expect(body.normalized_send.source.requestId).toBe(
|
|
75
|
+
body.normalized_send.message.externalMessageId,
|
|
76
|
+
);
|
|
77
|
+
expect(body.normalized_send.raw.sourceUpdateId).toBe('SM-inbound-123');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('retries managed outbound send on retriable upstream responses with stable idempotency key', async () => {
|
|
81
|
+
calls.length = 0;
|
|
82
|
+
let attempt = 0;
|
|
83
|
+
globalThis.fetch = mock(async (input: string | URL | Request, init?: RequestInit) => {
|
|
84
|
+
const url = typeof input === 'string'
|
|
85
|
+
? input
|
|
86
|
+
: input instanceof URL
|
|
87
|
+
? input.toString()
|
|
88
|
+
: input.url;
|
|
89
|
+
calls.push({ url, init: init ?? {} });
|
|
90
|
+
attempt += 1;
|
|
91
|
+
if (attempt === 1) {
|
|
92
|
+
return new Response('temporary upstream error', { status: 502 });
|
|
93
|
+
}
|
|
94
|
+
return new Response(JSON.stringify({ status: 'accepted' }), { status: 202 });
|
|
95
|
+
}) as unknown as typeof globalThis.fetch;
|
|
96
|
+
|
|
97
|
+
await deliverChannelReply(
|
|
98
|
+
'https://platform.test/v1/internal/managed-gateway/outbound-send/?route_id=route-retry&assistant_id=assistant-retry&source_channel=sms&source_update_id=SM-retry',
|
|
99
|
+
{
|
|
100
|
+
chatId: '+15550002222',
|
|
101
|
+
text: 'retry this outbound send',
|
|
102
|
+
},
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
expect(calls).toHaveLength(2);
|
|
106
|
+
|
|
107
|
+
const firstHeaders = calls[0].init.headers as Record<string, string>;
|
|
108
|
+
const secondHeaders = calls[1].init.headers as Record<string, string>;
|
|
109
|
+
expect(firstHeaders['X-Idempotency-Key']).toBeDefined();
|
|
110
|
+
expect(secondHeaders['X-Idempotency-Key']).toBe(firstHeaders['X-Idempotency-Key']);
|
|
111
|
+
|
|
112
|
+
const firstBody = JSON.parse(String(calls[0].init.body)) as {
|
|
113
|
+
normalized_send: { source: { requestId: string } };
|
|
114
|
+
};
|
|
115
|
+
const secondBody = JSON.parse(String(calls[1].init.body)) as {
|
|
116
|
+
normalized_send: { source: { requestId: string } };
|
|
117
|
+
};
|
|
118
|
+
expect(secondBody.normalized_send.source.requestId).toBe(
|
|
119
|
+
firstBody.normalized_send.source.requestId,
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('falls back to standard callback delivery for non-managed callback URL', async () => {
|
|
124
|
+
await deliverChannelReply(
|
|
125
|
+
'https://gateway.test/deliver/sms',
|
|
126
|
+
{
|
|
127
|
+
chatId: '+15550001111',
|
|
128
|
+
text: 'standard gateway callback',
|
|
129
|
+
},
|
|
130
|
+
'runtime-bearer',
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
expect(calls).toHaveLength(1);
|
|
134
|
+
const call = calls[0];
|
|
135
|
+
expect(call.url).toBe('https://gateway.test/deliver/sms');
|
|
136
|
+
|
|
137
|
+
const headers = call.init.headers as Record<string, string>;
|
|
138
|
+
expect(headers['Content-Type']).toBe('application/json');
|
|
139
|
+
expect(headers.Authorization).toBe('Bearer runtime-bearer');
|
|
140
|
+
|
|
141
|
+
const body = JSON.parse(String(call.init.body)) as { chatId: string; text: string };
|
|
142
|
+
expect(body).toEqual({
|
|
143
|
+
chatId: '+15550001111',
|
|
144
|
+
text: 'standard gateway callback',
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -31,25 +31,45 @@ mock.module('../util/logger.js', () => ({
|
|
|
31
31
|
|
|
32
32
|
let mockTelegramBinding: unknown = null;
|
|
33
33
|
let mockSmsBinding: unknown = null;
|
|
34
|
+
let mockVellumBinding: unknown = null;
|
|
34
35
|
|
|
35
36
|
mock.module('../memory/channel-guardian-store.js', () => ({
|
|
36
37
|
getActiveBinding: (_assistantId: string, channel: string) => {
|
|
37
38
|
if (channel === 'telegram') return mockTelegramBinding;
|
|
38
39
|
if (channel === 'sms') return mockSmsBinding;
|
|
40
|
+
if (channel === 'vellum') return mockVellumBinding;
|
|
39
41
|
return null;
|
|
40
42
|
},
|
|
43
|
+
createBinding: (params: Record<string, unknown>) => ({
|
|
44
|
+
id: `binding-${Date.now()}`,
|
|
45
|
+
...params,
|
|
46
|
+
status: 'active',
|
|
47
|
+
verifiedAt: Date.now(),
|
|
48
|
+
verifiedVia: 'test',
|
|
49
|
+
metadataJson: null,
|
|
50
|
+
createdAt: Date.now(),
|
|
51
|
+
updatedAt: Date.now(),
|
|
52
|
+
}),
|
|
53
|
+
listActiveBindingsByAssistant: () => mockVellumBinding ? [mockVellumBinding] : [],
|
|
41
54
|
}));
|
|
42
55
|
|
|
43
56
|
mock.module('../config/loader.js', () => ({
|
|
44
57
|
getConfig: () => ({
|
|
45
58
|
ui: {},
|
|
46
|
-
|
|
59
|
+
|
|
47
60
|
calls: {
|
|
48
61
|
userConsultTimeoutSeconds: 120,
|
|
49
62
|
},
|
|
50
63
|
}),
|
|
51
64
|
}));
|
|
52
65
|
|
|
66
|
+
// Mock guardian-vellum-migration to use a stable principal, avoiding UNIQUE
|
|
67
|
+
// constraint errors when ensureVellumGuardianBinding is called across tests.
|
|
68
|
+
// Returns a known principal so guardian-dispatch can attribute requests.
|
|
69
|
+
mock.module('../runtime/guardian-vellum-migration.js', () => ({
|
|
70
|
+
ensureVellumGuardianBinding: () => 'test-principal-id',
|
|
71
|
+
}));
|
|
72
|
+
|
|
53
73
|
const emitCalls: unknown[] = [];
|
|
54
74
|
let threadCreatedFromMock: ThreadCreatedInfo | null = null;
|
|
55
75
|
let mockEmitResult: {
|
|
@@ -109,12 +129,30 @@ function resetTables(): void {
|
|
|
109
129
|
db.run('DELETE FROM canonical_guardian_requests');
|
|
110
130
|
db.run('DELETE FROM guardian_action_deliveries');
|
|
111
131
|
db.run('DELETE FROM guardian_action_requests');
|
|
132
|
+
db.run('DELETE FROM channel_guardian_bindings');
|
|
112
133
|
db.run('DELETE FROM call_pending_questions');
|
|
113
134
|
db.run('DELETE FROM call_events');
|
|
114
135
|
db.run('DELETE FROM call_sessions');
|
|
115
136
|
db.run('DELETE FROM conversations');
|
|
137
|
+
|
|
116
138
|
mockTelegramBinding = null;
|
|
117
139
|
mockSmsBinding = null;
|
|
140
|
+
// Pre-seed vellum binding so the self-healing path in dispatchGuardianQuestion
|
|
141
|
+
// never triggers (avoids UNIQUE constraint violations on repeated dispatches).
|
|
142
|
+
mockVellumBinding = {
|
|
143
|
+
id: 'binding-vellum-test',
|
|
144
|
+
assistantId: 'self',
|
|
145
|
+
channel: 'vellum',
|
|
146
|
+
guardianExternalUserId: 'vellum-guardian',
|
|
147
|
+
guardianDeliveryChatId: 'local',
|
|
148
|
+
guardianPrincipalId: 'test-principal-id',
|
|
149
|
+
status: 'active',
|
|
150
|
+
verifiedAt: Date.now(),
|
|
151
|
+
verifiedVia: 'test',
|
|
152
|
+
metadataJson: null,
|
|
153
|
+
createdAt: Date.now(),
|
|
154
|
+
updatedAt: Date.now(),
|
|
155
|
+
};
|
|
118
156
|
emitCalls.length = 0;
|
|
119
157
|
threadCreatedFromMock = null;
|
|
120
158
|
mockEmitResult = {
|
|
@@ -348,44 +348,22 @@ describe('inbound-message-handler trusted-contact interactivity', () => {
|
|
|
348
348
|
expect(processCalls[0].options?.isInteractive).toBe(true);
|
|
349
349
|
});
|
|
350
350
|
|
|
351
|
-
test('unknown actors remain non-interactive', async () => {
|
|
352
|
-
// No
|
|
351
|
+
test('unknown actors remain non-interactive (denied at gate)', async () => {
|
|
352
|
+
// No member record => non-member denied at the ACL gate,
|
|
353
|
+
// which is the strongest form of "not interactive".
|
|
353
354
|
mockFindMember = () => null;
|
|
354
355
|
|
|
355
|
-
const processCalls: Array<{ options?: Record<string, unknown> }> = [];
|
|
356
|
-
const processMessage = mock(async (
|
|
357
|
-
conversationId: string,
|
|
358
|
-
_content: string,
|
|
359
|
-
_attachmentIds?: string[],
|
|
360
|
-
options?: Record<string, unknown>,
|
|
361
|
-
) => {
|
|
362
|
-
processCalls.push({ options });
|
|
363
|
-
const messageId = `msg-unknown-${Date.now()}`;
|
|
364
|
-
const db = getDb();
|
|
365
|
-
db.insert(messages).values({
|
|
366
|
-
id: messageId,
|
|
367
|
-
conversationId,
|
|
368
|
-
role: 'user',
|
|
369
|
-
content: JSON.stringify([{ type: 'text', text: 'hello' }]),
|
|
370
|
-
createdAt: Date.now(),
|
|
371
|
-
}).run();
|
|
372
|
-
return { messageId };
|
|
373
|
-
});
|
|
374
|
-
|
|
375
356
|
const req = makeInboundRequest({
|
|
376
357
|
externalMessageId: `msg-unknown-${Date.now()}`,
|
|
377
|
-
|
|
378
|
-
actorExternalId: undefined,
|
|
358
|
+
actorExternalId: 'unknown-user-no-member',
|
|
379
359
|
});
|
|
380
360
|
|
|
381
|
-
const res = await handleChannelInbound(req,
|
|
361
|
+
const res = await handleChannelInbound(req, undefined, 'test-token');
|
|
382
362
|
const body = await res.json() as Record<string, unknown>;
|
|
363
|
+
// Unknown actors are ACL-denied: accepted but denied with reason
|
|
383
364
|
expect(body.accepted).toBe(true);
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
expect(processCalls.length).toBeGreaterThan(0);
|
|
388
|
-
expect(processCalls[0].options?.isInteractive).toBe(false);
|
|
365
|
+
expect(body.denied).toBe(true);
|
|
366
|
+
expect(body.reason).toBe('not_a_member');
|
|
389
367
|
});
|
|
390
368
|
});
|
|
391
369
|
|