@vellumai/assistant 0.10.12-staging.1 → 0.10.12-staging.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/docs/trusted-contact-access.md +29 -15
  2. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +69 -1
  3. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +69 -1
  4. package/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +69 -1
  5. package/openapi.yaml +14 -3
  6. package/package.json +1 -1
  7. package/src/__tests__/call-setup-flow-name-capture.test.ts +65 -3
  8. package/src/__tests__/channel-retry-sweep.test.ts +344 -2
  9. package/src/__tests__/conversation-delete-schedule-cleanup.test.ts +1 -1
  10. package/src/__tests__/conversation-starter-routes.test.ts +2 -2
  11. package/src/__tests__/conversation-surfaces-state-update.test.ts +1 -1
  12. package/src/__tests__/conversation-surfaces-task-progress.test.ts +3 -3
  13. package/src/__tests__/conversation-usage.test.ts +27 -0
  14. package/src/__tests__/credential-security-invariants.test.ts +2 -0
  15. package/src/__tests__/guardian-card-withdrawal.test.ts +57 -0
  16. package/src/__tests__/introduction-card-resolver.test.ts +55 -4
  17. package/src/__tests__/memory-upsert-concurrency.test.ts +1 -1
  18. package/src/__tests__/non-member-access-request.test.ts +87 -137
  19. package/src/__tests__/openai-responses-prompt-cache.test.ts +27 -7
  20. package/src/__tests__/playbook-execution.test.ts +2 -2
  21. package/src/__tests__/playbook-tools.test.ts +2 -2
  22. package/src/__tests__/rebuild-index-graph-nodes.test.ts +7 -1
  23. package/src/__tests__/slack-inbound-verification.test.ts +45 -20
  24. package/src/__tests__/slack-share-routes.test.ts +3 -3
  25. package/src/__tests__/task-memory-cleanup.test.ts +13 -11
  26. package/src/__tests__/trusted-contact-multichannel.test.ts +0 -4
  27. package/src/api/events/app-files-changed.ts +20 -0
  28. package/src/api/events/oauth-connect-result.ts +26 -0
  29. package/src/api/events/platform-disconnected.ts +21 -0
  30. package/src/api/events/show-platform-login.ts +20 -0
  31. package/src/api/events/ui-surface-undo-result.ts +27 -0
  32. package/src/api/index.ts +30 -0
  33. package/src/approvals/guardian-card-withdrawal.ts +53 -8
  34. package/src/approvals/guardian-decision-primitive.ts +24 -1
  35. package/src/approvals/guardian-request-resolvers.ts +38 -6
  36. package/src/calls/call-setup-flow.ts +22 -5
  37. package/src/config/__tests__/default-profile-catalog.test.ts +39 -2
  38. package/src/config/bundled-skills/messaging/tools/messaging-analyze-style.ts +3 -2
  39. package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +5 -2
  40. package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +5 -2
  41. package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +5 -2
  42. package/src/config/call-site-defaults.ts +14 -17
  43. package/src/config/default-profile-catalog.ts +125 -51
  44. package/src/config/default-profile-names.ts +27 -0
  45. package/src/config/llm-resolver.ts +2 -2
  46. package/src/config/schemas/llm.ts +9 -1
  47. package/src/contacts/member-status.ts +21 -1
  48. package/src/daemon/channel-ui-capability.ts +15 -8
  49. package/src/daemon/conversation-messaging.ts +11 -0
  50. package/src/daemon/conversation-tool-setup.ts +5 -0
  51. package/src/daemon/conversation-usage.ts +13 -3
  52. package/src/daemon/lifecycle.ts +8 -0
  53. package/src/daemon/message-protocol.ts +2 -10
  54. package/src/daemon/message-types/apps.ts +9 -354
  55. package/src/daemon/message-types/integrations.ts +14 -173
  56. package/src/daemon/message-types/surfaces.ts +39 -78
  57. package/src/daemon/tool-setup-types.ts +12 -0
  58. package/src/home/conversation-starter-checkpoints.ts +2 -2
  59. package/src/home/job-handlers/conversation-starters.ts +15 -11
  60. package/src/mcp/__tests__/mcp-oauth-provider.test.ts +75 -0
  61. package/src/mcp/mcp-oauth-provider.ts +59 -1
  62. package/src/messaging/providers/slack/__tests__/auth.test.ts +1 -86
  63. package/src/messaging/providers/slack/auth.ts +18 -54
  64. package/src/messaging/providers/slack/withdraw.test.ts +71 -0
  65. package/src/messaging/providers/slack/withdraw.ts +53 -5
  66. package/src/monitoring/recovery/__tests__/orphaned-channel-events.test.ts +144 -0
  67. package/src/monitoring/recovery/orphaned-channel-events.ts +72 -0
  68. package/src/monitoring/recovery/run-recovery.ts +2 -0
  69. package/src/notifications/adapters/slack.ts +6 -0
  70. package/src/persistence/conversation-crud.ts +4 -1
  71. package/src/persistence/conversation-title-service.ts +25 -0
  72. package/src/persistence/delivery-crud.ts +27 -3
  73. package/src/persistence/delivery-status.ts +60 -2
  74. package/src/persistence/embeddings/graph-node-orphan-sweep.ts +9 -3
  75. package/src/persistence/migrations/340-sweep-orphaned-graph-node-vectors.test.ts +16 -0
  76. package/src/persistence/migrations/341-sweep-cacheless-graph-node-vectors.test.ts +5 -8
  77. package/src/persistence/migrations/349-move-memory-graph-tables-to-memory-db.ts +227 -0
  78. package/src/persistence/migrations/350-conversations-total-input-tokens-nullable.ts +77 -0
  79. package/src/persistence/migrations/__tests__/350-conversations-total-input-tokens-nullable.test.ts +75 -0
  80. package/src/persistence/raw-query.ts +14 -0
  81. package/src/persistence/schema/conversations.ts +2 -1
  82. package/src/persistence/steps.ts +30 -0
  83. package/src/playbooks/playbook-compiler.ts +5 -2
  84. package/src/plugins/defaults/memory/__tests__/db-memory-attach.test.ts +8 -2
  85. package/src/plugins/defaults/memory/__tests__/table-relocation.test.ts +154 -0
  86. package/src/plugins/defaults/memory/graph/bootstrap.test.ts +22 -0
  87. package/src/plugins/defaults/memory/graph/bootstrap.ts +6 -3
  88. package/src/plugins/defaults/memory/graph/capability-seed.ts +9 -5
  89. package/src/plugins/defaults/memory/graph/consolidation.ts +11 -3
  90. package/src/plugins/defaults/memory/graph/decay.ts +3 -2
  91. package/src/plugins/defaults/memory/graph/graph-search.ts +3 -2
  92. package/src/plugins/defaults/memory/graph/retriever.test.ts +7 -11
  93. package/src/plugins/defaults/memory/graph/store.test.ts +4 -6
  94. package/src/plugins/defaults/memory/graph/store.ts +43 -22
  95. package/src/plugins/defaults/memory/graph-topology/pending-buffer.test.ts +35 -0
  96. package/src/plugins/defaults/memory/graph-topology/pending-buffer.ts +29 -0
  97. package/src/plugins/defaults/memory/job-handlers/index-maintenance.ts +24 -17
  98. package/src/plugins/defaults/memory/src/memory-item-routes.test.ts +38 -4
  99. package/src/plugins/defaults/memory/src/memory-item-routes.ts +44 -10
  100. package/src/plugins/defaults/memory/task-memory-cleanup.ts +94 -25
  101. package/src/plugins/defaults/memory/v2/backfill-jobs.ts +12 -2
  102. package/src/plugins/defaults/memory/v2/migration.ts +2 -5
  103. package/src/prompts/templates/system-sections.ts +2 -0
  104. package/src/providers/inference/__tests__/credential-slot-repair.test.ts +138 -0
  105. package/src/providers/inference/credential-slot-repair.ts +110 -0
  106. package/src/providers/openai/responses-provider.ts +13 -4
  107. package/src/runtime/AGENTS.md +8 -0
  108. package/src/runtime/access-request-helper.ts +71 -58
  109. package/src/runtime/agent-wake.ts +9 -31
  110. package/src/runtime/channel-approval-types.ts +22 -0
  111. package/src/runtime/channel-retry-sweep.ts +198 -20
  112. package/src/runtime/guardian-action-service.ts +14 -1
  113. package/src/runtime/introduction-policy.test.ts +8 -0
  114. package/src/runtime/introduction-policy.ts +14 -2
  115. package/src/runtime/routes/__tests__/browser-tabs-routes.test.ts +35 -0
  116. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +28 -0
  117. package/src/runtime/routes/__tests__/defer-routes.test.ts +39 -0
  118. package/src/runtime/routes/__tests__/events-routes.test.ts +40 -0
  119. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +160 -0
  120. package/src/runtime/routes/__tests__/mcp-auth-routes.test.ts +69 -0
  121. package/src/runtime/routes/__tests__/sequence-routes.test.ts +56 -0
  122. package/src/runtime/routes/__tests__/suggest-trust-rule-routes.test.ts +29 -0
  123. package/src/runtime/routes/__tests__/ui-request-routes.test.ts +37 -0
  124. package/src/runtime/routes/__tests__/user-routes-cli.test.ts +7 -0
  125. package/src/runtime/routes/__tests__/watcher-routes.test.ts +42 -0
  126. package/src/runtime/routes/brain-graph-routes.ts +11 -11
  127. package/src/runtime/routes/browser-tabs-routes.ts +2 -1
  128. package/src/runtime/routes/conversation-query-routes.ts +22 -18
  129. package/src/runtime/routes/debug-routes.ts +2 -2
  130. package/src/runtime/routes/defer-routes.ts +7 -4
  131. package/src/runtime/routes/events-routes.ts +2 -1
  132. package/src/runtime/routes/global-search-routes.ts +8 -2
  133. package/src/runtime/routes/inbound-message-handler.ts +187 -64
  134. package/src/runtime/routes/inbound-stages/acl-enforcement.test.ts +60 -52
  135. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +25 -40
  136. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +158 -0
  137. package/src/runtime/routes/inbound-stages/background-dispatch.ts +91 -26
  138. package/src/runtime/routes/inbound-stages/channel-turn-admission.test.ts +141 -0
  139. package/src/runtime/routes/inbound-stages/channel-turn-admission.ts +111 -0
  140. package/src/runtime/routes/inbound-stages/inbound-content-prep.test.ts +53 -0
  141. package/src/runtime/routes/inbound-stages/inbound-content-prep.ts +64 -0
  142. package/src/runtime/routes/inference-provider-connection-routes.ts +101 -6
  143. package/src/runtime/routes/integrations/slack/__tests__/token-routing.test.ts +38 -85
  144. package/src/runtime/routes/integrations/slack/channels.ts +9 -13
  145. package/src/runtime/routes/integrations/slack/share.ts +12 -21
  146. package/src/runtime/routes/mcp-auth-routes.ts +42 -44
  147. package/src/runtime/routes/parse-body.ts +31 -0
  148. package/src/runtime/routes/sequence-routes.ts +7 -6
  149. package/src/runtime/routes/suggest-trust-rule-routes.ts +6 -19
  150. package/src/runtime/routes/surface-action-routes.ts +10 -0
  151. package/src/runtime/routes/ui-request-routes.ts +2 -1
  152. package/src/runtime/routes/user-routes-cli.ts +2 -1
  153. package/src/runtime/routes/watcher-routes.ts +9 -6
  154. package/src/tools/ask-question/ask-question-tool.test.ts +87 -1
  155. package/src/tools/ask-question/ask-question-tool.ts +58 -0
  156. package/src/tools/tool-types.ts +8 -0
  157. package/src/tools/types.ts +9 -0
  158. package/src/util/single-flight.test.ts +88 -0
  159. package/src/util/single-flight.ts +57 -0
@@ -39,7 +39,7 @@ Design doc defining how unknown users gain access to a Vellum assistant via chan
39
39
 
40
40
  This ensures unknown inbound access attempts always trigger guardian notification, even when the requester's source channel has no guardian binding.
41
41
 
42
- 4. **Guardian decides.** All decisions route through the guardian decision primitive (`applyGuardianDecision`, `approvals/guardian-decision-primitive.ts`) and commit via the gateway's `guardian_requests_decide` IPC op: the status CAS and the decision's ACL outcome execute in ONE gateway transaction, so an `approved` request can never exist without its ACL write. The introduction card supports four outcomes: **approve** (start the verification handshake), **trust** (activate directly, no code — used for workspace-vouched identities), **deny** (persist a terminal denial), and **block**.
42
+ 4. **Guardian decides.** All decisions route through the guardian decision primitive (`applyGuardianDecision`, `approvals/guardian-decision-primitive.ts`) and commit via the gateway's `guardian_requests_decide` IPC op: the status CAS and the decision's ACL outcome execute in ONE gateway transaction, so an `approved` request can never exist without its ACL write. The introduction card supports four outcomes: **approve** (start the verification handshake), **trust** (activate directly, no code — used for workspace-vouched identities), **leave unverified** (the `leave_unverified` action id — park the sender as an `unverified` contact, **silently**: the requester is not notified and only learns if they message again; a later trust-requiring inbound re-fires the flow), and **block** (revoke the sender's channel and notify them their request was declined). Note the `unverified` contact left by "leave unverified" is not a guaranteed keep-out — it is still admitted under the permissive admission floors (`any_contact`, `strangers`); **block** (→ revoked) is the hard keep-out.
43
43
  5. **On approval the gateway mints a verification session.** The decide op carries a `mint_outbound_session` outcome; inside the decide transaction the gateway (`gateway/src/verification/session-service.ts`) generates a 6-digit code, persists only its SHA-256 hash in `channel_verification_sessions` (identity-bound to the requester, `verificationPurpose: 'trusted_contact'`), and returns the raw secret to the daemon in the decide response for delivery.
44
44
  6. **The code is delivered.** The daemon delivers the code to the guardian's verified channel (ephemeral + DM on Slack shared channels so other members never see it). On Slack the code is also DM'd straight to the requester; on other channels the guardian relays it out-of-band (in person, text message, phone call). That out-of-band transfer is the trust anchor: it proves the requester has a real-world relationship with the guardian.
45
45
  7. **Requester enters the code** back to the assistant on the same channel. The **gateway** intercepts bare verification codes at ingress (`gateway/src/verification/text-verification.ts`) whenever an interceptable session exists for that channel — the daemon never sees verification code messages.
@@ -52,14 +52,14 @@ Design doc defining how unknown users gain access to a Vellum assistant via chan
52
52
  requested -> pending_guardian -> verification_pending -> active | denied | expired
53
53
  ```
54
54
 
55
- | State | Description | Store representation |
56
- | ---------------------- | ------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
57
- | `requested` | Unknown user messaged the assistant and was denied. The system records the access attempt. | No member record exists. The inbound is logged in `channel_inbound_events` (assistant DB). A notification signal is emitted via `emitNotificationSignal()`. |
58
- | `pending_guardian` | The guardian has been notified and a decision is pending. | A `guardian_requests` record (gateway DB) with `status: 'pending'`, `kind: 'access_request'`. |
59
- | `verification_pending` | The guardian approved. A verification session is active with a 6-digit code waiting for the requester to enter. | Gateway `channel_verification_sessions` record with `status: 'awaiting_response'`, identity-bound to the requester. The guardian request is `status: 'approved'`. |
60
- | `active` | The requester entered the correct code (or the guardian chose direct trust). They are now a trusted contact. | Gateway `contact_channels` record with `status: 'active'`, `policy: 'allow'`; identity mirrored to the assistant DB. The verification session is `status: 'consumed'`. |
61
- | `denied` | The guardian explicitly denied the request. | The guardian request has `status: 'denied'`. The sender is persisted as an unverified contact and later inbound is suppressed (terminal-deny). |
62
- | `expired` | The guardian never responded (approval TTL elapsed) or the requester never entered the code (session TTL elapsed). | Guardian request: `status: 'expired'` (CAS-expired by the gateway via the daemon's expiry sweep). Verification session: expires naturally when `expiresAt < Date.now()`. |
55
+ | State | Description | Store representation |
56
+ | ---------------------- | ------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
57
+ | `requested` | Unknown user messaged the assistant and was denied. The system records the access attempt. | No member record exists. The inbound is logged in `channel_inbound_events` (assistant DB). A notification signal is emitted via `emitNotificationSignal()`. |
58
+ | `pending_guardian` | The guardian has been notified and a decision is pending. | A `guardian_requests` record (gateway DB) with `status: 'pending'`, `kind: 'access_request'`. |
59
+ | `verification_pending` | The guardian approved. A verification session is active with a 6-digit code waiting for the requester to enter. | Gateway `channel_verification_sessions` record with `status: 'awaiting_response'`, identity-bound to the requester. The guardian request is `status: 'approved'`. |
60
+ | `active` | The requester entered the correct code (or the guardian chose direct trust). They are now a trusted contact. | Gateway `contact_channels` record with `status: 'active'`, `policy: 'allow'`; identity mirrored to the assistant DB. The verification session is `status: 'consumed'`. |
61
+ | `denied` | The guardian resolved the request without granting access — `leave_unverified` (park) or `block` (keep-out). | The guardian request has `status: 'denied'` for both. `leave_unverified` persists an `unverified` contact a neutral park, so a later trust-requiring inbound **re-fires** the flow; `block` revokes the channel — a durable keep-out, so re-contact is suppressed. Suppression keys off the durable **contact status** (`revoked`/`blocked`), never this request status. |
62
+ | `expired` | The guardian never responded (approval TTL elapsed) or the requester never entered the code (session TTL elapsed). | Guardian request: `status: 'expired'` (CAS-expired by the gateway via the daemon's expiry sweep). Verification session: expires naturally when `expiresAt < Date.now()`. |
63
63
 
64
64
  ## Identity Binding Rules
65
65
 
@@ -112,7 +112,16 @@ Identity binding ensures the verification code can only be consumed by the inten
112
112
  | -------------------------------------------------- | ------------------- | ------------------------------------------------------------- |
113
113
  | gateway `guardian-request-service.ts` (gateway DB) | `guardian_requests` | Updated to `status: 'denied'`, `decidedByExternalUserId` set. |
114
114
 
115
- No trusted-contact activation happens. The sender is persisted as an unverified contact, and the terminal-deny check suppresses re-prompting the guardian for the same sender.
115
+ No trusted-contact activation happens. The `leave_unverified` outcome persists the sender as an `unverified` contact **and sends no requester notification** (silent park); `block` instead revokes the channel and **does** notify the requester ("Your access request was declined.").
116
+
117
+ The two outcomes differ on re-contact. Suppression of re-prompting keys off the **durable contact status**, not this `denied` request status:
118
+
119
+ - `leave_unverified` → `unverified` contact: a neutral park, **not** kept out. If the contact later does something that needs trust — e.g. DMs on a `trusted_contacts` channel — the deny-path flow **re-fires** (self-verify challenge + a fresh guardian card), so the guardian decides afresh. The guardian's way to stop the prompts is to `block`.
120
+ - `block` → `revoked` contact: a durable keep-out. Re-contact is suppressed (no challenge, no guardian notification) on every floor.
121
+
122
+ The resolved approval card mirrors this distinction on every surface (in-app, Slack): a `leave_unverified` park reads as a neutral **"Left unverified"**, while `block` reads as **"Denied"**. Both share the `denied` request status, so the card presentation is keyed off the decided action, not the status (see `PARK_ACTION_SET` / `isParkAction` in `runtime/channel-approval-types.ts`).
123
+
124
+ Separately, the admitted-mode introduction **nudge** (for a contact who cleared the floor unclassified) is quieted once the guardian has decided the contact, so a classified contact is not re-nudged across conversations. That is distinct from the deny-path re-fire above.
116
125
 
117
126
  ### Stage: `expired`
118
127
 
@@ -191,10 +200,15 @@ sequenceDiagram
191
200
  GW->>A: Forward with allow verdict
192
201
  A->>A: Process message normally
193
202
 
194
- else Guardian denies
195
- G->>A: Deny (inline button / app / plain text)
196
- A->>A: Resolver persists denial (terminal-deny)
197
- A-->>U: (No notification — user only knows<br/>they were denied if they message again)
203
+ else Guardian leaves unverified
204
+ G->>A: Leave unverified (inline button / app / plain text)
205
+ A->>A: Resolver seeds unverified contact (neutral park —<br/>a trust-requiring re-contact re-fires the flow)
206
+ A-->>U: (No notification — user only learns<br/>if they message again)
207
+
208
+ else Guardian blocks
209
+ G->>A: Block
210
+ A->>A: Resolver revokes the channel (durable keep-out —<br/>re-contact suppressed)
211
+ A-->>U: "Your access request was declined."
198
212
 
199
213
  else Guardian never responds
200
214
  Note over A: runGuardianExpirySweep()<br/>runs every 60 seconds
@@ -241,7 +255,7 @@ sequenceDiagram
241
255
 
242
256
  - If the unknown user messages the assistant multiple times before the guardian responds, each message hits the ACL denial path independently.
243
257
  - `notifyGuardianOfAccessRequest()` dedupes on the existing pending guardian request for the same (assistant, channel, requester), and the notification pipeline's `dedupeKey` prevents duplicate notification events.
244
- - A prior terminal deny suppresses re-prompting entirely, and an approval whose verification window is still open suppresses a fresh request (the sender is told to enter their code instead).
258
+ - A **durable keep-out** a `revoked` or `blocked` contact — suppresses re-prompting entirely (`isKeptOutStatus(previousMemberStatus)`). A `leave_unverified` park (`unverified` contact) is **not** a keep-out and re-fires; only `block` stops the prompts. An approval whose verification window is still open also suppresses a fresh request (the sender is told to enter their code instead).
245
259
 
246
260
  ### Requester already has a member record in non-active state
247
261
 
@@ -109,12 +109,77 @@ export type RemoteWebPairingTokenResponse =
109
109
  // only) so both Node and browser callers share one implementation.
110
110
 
111
111
  /** Why a public base URL can't be advertised in a pairing challenge. */
112
- export type PublicBaseUrlRejection = "unparseable" | "loopback" | "non-https";
112
+ export type PublicBaseUrlRejection =
113
+ | "unparseable"
114
+ | "loopback"
115
+ | "non-https"
116
+ | "service-website";
113
117
 
114
118
  export type PublicBaseUrlResult =
115
119
  | { ok: true; url: string }
116
120
  | { ok: false; reason: PublicBaseUrlRejection };
117
121
 
122
+ /**
123
+ * Hosts that are a tunnel/ingress vendor's own website, not a user's assistant
124
+ * endpoint. These are the tunnel vendors our docs mention, and their sites are
125
+ * exactly where a lost user grabs a URL from — e.g. a Tailscale admin invite
126
+ * link (`login.tailscale.com/admin/invite/…`), which is https and non-loopback
127
+ * and so clears every other check. Pairing refuses them with a targeted message
128
+ * rather than minting a challenge the scanning device can never reach.
129
+ */
130
+ export const TUNNEL_PROVIDER_WEBSITE_HOSTS = [
131
+ "login.tailscale.com",
132
+ "tailscale.com",
133
+ "www.tailscale.com",
134
+ "ngrok.com",
135
+ "dashboard.ngrok.com",
136
+ "dash.cloudflare.com",
137
+ "cloudflare.com",
138
+ "www.cloudflare.com",
139
+ ] as const;
140
+
141
+ /**
142
+ * A URL whose exact host is a tunnel/ingress vendor's own website (see
143
+ * {@link TUNNEL_PROVIDER_WEBSITE_HOSTS}). Exact-host only: a user's real
144
+ * Tailscale endpoint (`*.ts.net`) or Cloudflare-fronted domain is never a
145
+ * listed host, so a legitimate address is never mistaken for a vendor site.
146
+ */
147
+ export function isTunnelProviderWebsiteUrl(url: string): boolean {
148
+ let hostname: string;
149
+ try {
150
+ // WHATWG URL lowercases the hostname during parsing.
151
+ hostname = new URL(url).hostname;
152
+ } catch {
153
+ return false;
154
+ }
155
+ return (TUNNEL_PROVIDER_WEBSITE_HOSTS as readonly string[]).includes(
156
+ hostname,
157
+ );
158
+ }
159
+
160
+ /**
161
+ * The display name of the tunnel/ingress vendor a service-website URL points
162
+ * at (`"Tailscale"` / `"ngrok"` / `"Cloudflare"`), or null when the host is not
163
+ * a known vendor site. Drives the "This is <Name>'s website" pairing guidance.
164
+ */
165
+ export function tunnelProviderWebsiteName(url: string): string | null {
166
+ let hostname: string;
167
+ try {
168
+ hostname = new URL(url).hostname;
169
+ } catch {
170
+ return null;
171
+ }
172
+ if (
173
+ !(TUNNEL_PROVIDER_WEBSITE_HOSTS as readonly string[]).includes(hostname)
174
+ ) {
175
+ return null;
176
+ }
177
+ if (hostname.includes("tailscale")) return "Tailscale";
178
+ if (hostname.includes("ngrok")) return "ngrok";
179
+ if (hostname.includes("cloudflare")) return "Cloudflare";
180
+ return null;
181
+ }
182
+
118
183
  /**
119
184
  * A loopback URL — `localhost`, `[::1]`, or `127.x.x.x`. A pairing link that
120
185
  * encodes a loopback address is unreachable from the scanning device.
@@ -167,6 +232,9 @@ export function resolvePublicBaseUrl(raw: string): PublicBaseUrlResult {
167
232
  if (isLoopbackPublicUrl(normalized)) {
168
233
  return { ok: false, reason: "loopback" };
169
234
  }
235
+ if (isTunnelProviderWebsiteUrl(normalized)) {
236
+ return { ok: false, reason: "service-website" };
237
+ }
170
238
  if (new URL(normalized).protocol !== "https:") {
171
239
  return { ok: false, reason: "non-https" };
172
240
  }
@@ -109,12 +109,77 @@ export type RemoteWebPairingTokenResponse =
109
109
  // only) so both Node and browser callers share one implementation.
110
110
 
111
111
  /** Why a public base URL can't be advertised in a pairing challenge. */
112
- export type PublicBaseUrlRejection = "unparseable" | "loopback" | "non-https";
112
+ export type PublicBaseUrlRejection =
113
+ | "unparseable"
114
+ | "loopback"
115
+ | "non-https"
116
+ | "service-website";
113
117
 
114
118
  export type PublicBaseUrlResult =
115
119
  | { ok: true; url: string }
116
120
  | { ok: false; reason: PublicBaseUrlRejection };
117
121
 
122
+ /**
123
+ * Hosts that are a tunnel/ingress vendor's own website, not a user's assistant
124
+ * endpoint. These are the tunnel vendors our docs mention, and their sites are
125
+ * exactly where a lost user grabs a URL from — e.g. a Tailscale admin invite
126
+ * link (`login.tailscale.com/admin/invite/…`), which is https and non-loopback
127
+ * and so clears every other check. Pairing refuses them with a targeted message
128
+ * rather than minting a challenge the scanning device can never reach.
129
+ */
130
+ export const TUNNEL_PROVIDER_WEBSITE_HOSTS = [
131
+ "login.tailscale.com",
132
+ "tailscale.com",
133
+ "www.tailscale.com",
134
+ "ngrok.com",
135
+ "dashboard.ngrok.com",
136
+ "dash.cloudflare.com",
137
+ "cloudflare.com",
138
+ "www.cloudflare.com",
139
+ ] as const;
140
+
141
+ /**
142
+ * A URL whose exact host is a tunnel/ingress vendor's own website (see
143
+ * {@link TUNNEL_PROVIDER_WEBSITE_HOSTS}). Exact-host only: a user's real
144
+ * Tailscale endpoint (`*.ts.net`) or Cloudflare-fronted domain is never a
145
+ * listed host, so a legitimate address is never mistaken for a vendor site.
146
+ */
147
+ export function isTunnelProviderWebsiteUrl(url: string): boolean {
148
+ let hostname: string;
149
+ try {
150
+ // WHATWG URL lowercases the hostname during parsing.
151
+ hostname = new URL(url).hostname;
152
+ } catch {
153
+ return false;
154
+ }
155
+ return (TUNNEL_PROVIDER_WEBSITE_HOSTS as readonly string[]).includes(
156
+ hostname,
157
+ );
158
+ }
159
+
160
+ /**
161
+ * The display name of the tunnel/ingress vendor a service-website URL points
162
+ * at (`"Tailscale"` / `"ngrok"` / `"Cloudflare"`), or null when the host is not
163
+ * a known vendor site. Drives the "This is <Name>'s website" pairing guidance.
164
+ */
165
+ export function tunnelProviderWebsiteName(url: string): string | null {
166
+ let hostname: string;
167
+ try {
168
+ hostname = new URL(url).hostname;
169
+ } catch {
170
+ return null;
171
+ }
172
+ if (
173
+ !(TUNNEL_PROVIDER_WEBSITE_HOSTS as readonly string[]).includes(hostname)
174
+ ) {
175
+ return null;
176
+ }
177
+ if (hostname.includes("tailscale")) return "Tailscale";
178
+ if (hostname.includes("ngrok")) return "ngrok";
179
+ if (hostname.includes("cloudflare")) return "Cloudflare";
180
+ return null;
181
+ }
182
+
118
183
  /**
119
184
  * A loopback URL — `localhost`, `[::1]`, or `127.x.x.x`. A pairing link that
120
185
  * encodes a loopback address is unreachable from the scanning device.
@@ -167,6 +232,9 @@ export function resolvePublicBaseUrl(raw: string): PublicBaseUrlResult {
167
232
  if (isLoopbackPublicUrl(normalized)) {
168
233
  return { ok: false, reason: "loopback" };
169
234
  }
235
+ if (isTunnelProviderWebsiteUrl(normalized)) {
236
+ return { ok: false, reason: "service-website" };
237
+ }
170
238
  if (new URL(normalized).protocol !== "https:") {
171
239
  return { ok: false, reason: "non-https" };
172
240
  }
@@ -109,12 +109,77 @@ export type RemoteWebPairingTokenResponse =
109
109
  // only) so both Node and browser callers share one implementation.
110
110
 
111
111
  /** Why a public base URL can't be advertised in a pairing challenge. */
112
- export type PublicBaseUrlRejection = "unparseable" | "loopback" | "non-https";
112
+ export type PublicBaseUrlRejection =
113
+ | "unparseable"
114
+ | "loopback"
115
+ | "non-https"
116
+ | "service-website";
113
117
 
114
118
  export type PublicBaseUrlResult =
115
119
  | { ok: true; url: string }
116
120
  | { ok: false; reason: PublicBaseUrlRejection };
117
121
 
122
+ /**
123
+ * Hosts that are a tunnel/ingress vendor's own website, not a user's assistant
124
+ * endpoint. These are the tunnel vendors our docs mention, and their sites are
125
+ * exactly where a lost user grabs a URL from — e.g. a Tailscale admin invite
126
+ * link (`login.tailscale.com/admin/invite/…`), which is https and non-loopback
127
+ * and so clears every other check. Pairing refuses them with a targeted message
128
+ * rather than minting a challenge the scanning device can never reach.
129
+ */
130
+ export const TUNNEL_PROVIDER_WEBSITE_HOSTS = [
131
+ "login.tailscale.com",
132
+ "tailscale.com",
133
+ "www.tailscale.com",
134
+ "ngrok.com",
135
+ "dashboard.ngrok.com",
136
+ "dash.cloudflare.com",
137
+ "cloudflare.com",
138
+ "www.cloudflare.com",
139
+ ] as const;
140
+
141
+ /**
142
+ * A URL whose exact host is a tunnel/ingress vendor's own website (see
143
+ * {@link TUNNEL_PROVIDER_WEBSITE_HOSTS}). Exact-host only: a user's real
144
+ * Tailscale endpoint (`*.ts.net`) or Cloudflare-fronted domain is never a
145
+ * listed host, so a legitimate address is never mistaken for a vendor site.
146
+ */
147
+ export function isTunnelProviderWebsiteUrl(url: string): boolean {
148
+ let hostname: string;
149
+ try {
150
+ // WHATWG URL lowercases the hostname during parsing.
151
+ hostname = new URL(url).hostname;
152
+ } catch {
153
+ return false;
154
+ }
155
+ return (TUNNEL_PROVIDER_WEBSITE_HOSTS as readonly string[]).includes(
156
+ hostname,
157
+ );
158
+ }
159
+
160
+ /**
161
+ * The display name of the tunnel/ingress vendor a service-website URL points
162
+ * at (`"Tailscale"` / `"ngrok"` / `"Cloudflare"`), or null when the host is not
163
+ * a known vendor site. Drives the "This is <Name>'s website" pairing guidance.
164
+ */
165
+ export function tunnelProviderWebsiteName(url: string): string | null {
166
+ let hostname: string;
167
+ try {
168
+ hostname = new URL(url).hostname;
169
+ } catch {
170
+ return null;
171
+ }
172
+ if (
173
+ !(TUNNEL_PROVIDER_WEBSITE_HOSTS as readonly string[]).includes(hostname)
174
+ ) {
175
+ return null;
176
+ }
177
+ if (hostname.includes("tailscale")) return "Tailscale";
178
+ if (hostname.includes("ngrok")) return "ngrok";
179
+ if (hostname.includes("cloudflare")) return "Cloudflare";
180
+ return null;
181
+ }
182
+
118
183
  /**
119
184
  * A loopback URL — `localhost`, `[::1]`, or `127.x.x.x`. A pairing link that
120
185
  * encodes a loopback address is unreachable from the scanning device.
@@ -167,6 +232,9 @@ export function resolvePublicBaseUrl(raw: string): PublicBaseUrlResult {
167
232
  if (isLoopbackPublicUrl(normalized)) {
168
233
  return { ok: false, reason: "loopback" };
169
234
  }
235
+ if (isTunnelProviderWebsiteUrl(normalized)) {
236
+ return { ok: false, reason: "service-website" };
237
+ }
170
238
  if (new URL(normalized).protocol !== "https:") {
171
239
  return { ok: false, reason: "non-https" };
172
240
  }
package/openapi.yaml CHANGED
@@ -3,7 +3,7 @@
3
3
  openapi: 3.1.0
4
4
  info:
5
5
  title: Vellum Assistant API
6
- version: 0.10.11
6
+ version: 0.10.12
7
7
  description: Auto-generated OpenAPI specification for the Vellum Assistant runtime HTTP server.
8
8
  servers:
9
9
  - url: http://127.0.0.1:7821
@@ -17480,8 +17480,8 @@ paths:
17480
17480
  summary: Create a memory by remembering a fact
17481
17481
  description:
17482
17482
  Append a user-authored fact to the memory buffer via handleRemember. The fact surfaces in the memory graph
17483
- immediately as a pending node, and a consolidation run is nudged (deduped, backoff-respecting) so it files into
17484
- concept pages promptly.
17483
+ immediately as a pending node (its id is returned so clients can navigate to it), and a consolidation run is
17484
+ nudged (deduped, backoff-respecting) so it files into concept pages promptly.
17485
17485
  tags:
17486
17486
  - memory
17487
17487
  requestBody:
@@ -17507,6 +17507,11 @@ paths:
17507
17507
  type: string
17508
17508
  success:
17509
17509
  type: boolean
17510
+ pendingNodeId:
17511
+ description:
17512
+ Graph node id (`buffer:<hash>`) of the pending entry this create appended, for fly-to-node navigation.
17513
+ Absent when the buffer can't be re-read.
17514
+ type: string
17510
17515
  required:
17511
17516
  - message
17512
17517
  - success
@@ -29396,6 +29401,12 @@ paths:
29396
29401
  description:
29397
29402
  Guardian-facing reply from the resolver (e.g. verification code for access-request approvals). Present only
29398
29403
  when applied is true and the resolver produced a reply.
29404
+ decidedAction:
29405
+ type: string
29406
+ description:
29407
+ The action to present on the resolved card — the resolved outcome, not necessarily the raw button (an
29408
+ access-request 'reject' resolves to the 'leave_unverified' park). Present only when applied is
29409
+ true; lets a client completing the card optimistically render the correct tone.
29399
29410
  required:
29400
29411
  - ok
29401
29412
  additionalProperties: false
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.10.12-staging.1",
3
+ "version": "0.10.12-staging.2",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -19,6 +19,7 @@ import type {
19
19
  GuardianWaitStartParams,
20
20
  } from "../calls/guardian-wait-controller.js";
21
21
  import type { CallSession } from "../calls/types.js";
22
+ import type { ChannelStatus } from "../contacts/types.js";
22
23
  import type { TrustContext } from "../daemon/trust-context-types.js";
23
24
  import type { TrustClass } from "../runtime/trust-class.js";
24
25
 
@@ -77,6 +78,51 @@ function makeResolved(trustClass: TrustClass = "unknown"): SetupResolved {
77
78
  };
78
79
  }
79
80
 
81
+ /**
82
+ * A resolved context whose caller is a known member with the given channel
83
+ * status — used to exercise the `previousMemberStatus` threading into the
84
+ * guardian-notify call (a `revoked` caller reaches name capture when the phone
85
+ * admission floor is inactive; see `routeSetup`).
86
+ */
87
+ function makeResolvedWithMemberStatus(status: ChannelStatus): SetupResolved {
88
+ const base = makeResolved(
89
+ status === "revoked" || status === "blocked"
90
+ ? "unknown"
91
+ : "unverified_contact",
92
+ );
93
+ return {
94
+ ...base,
95
+ actorTrust: {
96
+ ...base.actorTrust,
97
+ memberRecord: {
98
+ contact: {
99
+ id: "contact-caller",
100
+ displayName: "Sam Example",
101
+ notes: null,
102
+ createdAt: 0,
103
+ updatedAt: 0,
104
+ contactType: "human",
105
+ userFile: null,
106
+ channels: [],
107
+ },
108
+ channel: {
109
+ id: "channel-caller",
110
+ contactId: "contact-caller",
111
+ type: "phone",
112
+ address: PHONE_NUMBER,
113
+ isPrimary: true,
114
+ externalChatId: null,
115
+ updatedAt: null,
116
+ createdAt: 0,
117
+ },
118
+ status,
119
+ policy: "allow",
120
+ role: "contact",
121
+ },
122
+ },
123
+ };
124
+ }
125
+
80
126
  function makeSession(overrides?: Partial<CallSession>): CallSession {
81
127
  return {
82
128
  id: CALL_SESSION_ID,
@@ -558,15 +604,31 @@ describe("CallSetupFlow name capture", () => {
558
604
  ]);
559
605
  });
560
606
 
561
- test("a previously denied caller gets the denial copy, not the timeout copy", async () => {
607
+ test("a kept-out (revoked) caller reaching name capture is threaded + gets the denial copy", async () => {
608
+ // A `revoked` caller (trustClass `unknown`, status `revoked`, not `blocked`)
609
+ // reaches name capture when the phone admission floor is inactive — see
610
+ // routeSetup. startNameCapture threads the caller's channel status as
611
+ // `previousMemberStatus`, so the guardian-notify helper suppresses
612
+ // (contact_kept_out) and the caller hears the denial copy, not the timeout
613
+ // copy. Asserting the threaded param (not just the mocked reply) proves the
614
+ // wiring is live, not defensive dead code.
562
615
  const f = createFlow({
563
- notifyResult: { notified: false, reason: "already_denied" },
616
+ notifyResult: { notified: false, reason: "contact_kept_out" },
564
617
  });
565
- await f.flow.start(nameCaptureOutcome, makeResolved());
618
+ await f.flow.start(
619
+ nameCaptureOutcome,
620
+ makeResolvedWithMemberStatus("revoked"),
621
+ );
566
622
 
567
623
  f.flow.pushTranscriptFinal(CALLER_NAME);
568
624
  await sleep();
569
625
 
626
+ // The caller's durable status is threaded into the guardian-notify call.
627
+ expect(f.notifyCalls).toHaveLength(1);
628
+ expect(f.notifyCalls[0].previousMemberStatus).toBe("revoked");
629
+
630
+ // And the caller gets the denial copy rather than the "I'll let them know"
631
+ // timeout copy.
570
632
  expect(f.waits).toHaveLength(0);
571
633
  expect(
572
634
  f.events.find((e) => e.eventType === "inbound_acl_access_denied")