@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.
- package/docs/trusted-contact-access.md +29 -15
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +69 -1
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +69 -1
- package/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +69 -1
- package/openapi.yaml +14 -3
- package/package.json +1 -1
- package/src/__tests__/call-setup-flow-name-capture.test.ts +65 -3
- package/src/__tests__/channel-retry-sweep.test.ts +344 -2
- package/src/__tests__/conversation-delete-schedule-cleanup.test.ts +1 -1
- package/src/__tests__/conversation-starter-routes.test.ts +2 -2
- package/src/__tests__/conversation-surfaces-state-update.test.ts +1 -1
- package/src/__tests__/conversation-surfaces-task-progress.test.ts +3 -3
- package/src/__tests__/conversation-usage.test.ts +27 -0
- package/src/__tests__/credential-security-invariants.test.ts +2 -0
- package/src/__tests__/guardian-card-withdrawal.test.ts +57 -0
- package/src/__tests__/introduction-card-resolver.test.ts +55 -4
- package/src/__tests__/memory-upsert-concurrency.test.ts +1 -1
- package/src/__tests__/non-member-access-request.test.ts +87 -137
- package/src/__tests__/openai-responses-prompt-cache.test.ts +27 -7
- package/src/__tests__/playbook-execution.test.ts +2 -2
- package/src/__tests__/playbook-tools.test.ts +2 -2
- package/src/__tests__/rebuild-index-graph-nodes.test.ts +7 -1
- package/src/__tests__/slack-inbound-verification.test.ts +45 -20
- package/src/__tests__/slack-share-routes.test.ts +3 -3
- package/src/__tests__/task-memory-cleanup.test.ts +13 -11
- package/src/__tests__/trusted-contact-multichannel.test.ts +0 -4
- package/src/api/events/app-files-changed.ts +20 -0
- package/src/api/events/oauth-connect-result.ts +26 -0
- package/src/api/events/platform-disconnected.ts +21 -0
- package/src/api/events/show-platform-login.ts +20 -0
- package/src/api/events/ui-surface-undo-result.ts +27 -0
- package/src/api/index.ts +30 -0
- package/src/approvals/guardian-card-withdrawal.ts +53 -8
- package/src/approvals/guardian-decision-primitive.ts +24 -1
- package/src/approvals/guardian-request-resolvers.ts +38 -6
- package/src/calls/call-setup-flow.ts +22 -5
- package/src/config/__tests__/default-profile-catalog.test.ts +39 -2
- package/src/config/bundled-skills/messaging/tools/messaging-analyze-style.ts +3 -2
- package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +5 -2
- package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +5 -2
- package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +5 -2
- package/src/config/call-site-defaults.ts +14 -17
- package/src/config/default-profile-catalog.ts +125 -51
- package/src/config/default-profile-names.ts +27 -0
- package/src/config/llm-resolver.ts +2 -2
- package/src/config/schemas/llm.ts +9 -1
- package/src/contacts/member-status.ts +21 -1
- package/src/daemon/channel-ui-capability.ts +15 -8
- package/src/daemon/conversation-messaging.ts +11 -0
- package/src/daemon/conversation-tool-setup.ts +5 -0
- package/src/daemon/conversation-usage.ts +13 -3
- package/src/daemon/lifecycle.ts +8 -0
- package/src/daemon/message-protocol.ts +2 -10
- package/src/daemon/message-types/apps.ts +9 -354
- package/src/daemon/message-types/integrations.ts +14 -173
- package/src/daemon/message-types/surfaces.ts +39 -78
- package/src/daemon/tool-setup-types.ts +12 -0
- package/src/home/conversation-starter-checkpoints.ts +2 -2
- package/src/home/job-handlers/conversation-starters.ts +15 -11
- package/src/mcp/__tests__/mcp-oauth-provider.test.ts +75 -0
- package/src/mcp/mcp-oauth-provider.ts +59 -1
- package/src/messaging/providers/slack/__tests__/auth.test.ts +1 -86
- package/src/messaging/providers/slack/auth.ts +18 -54
- package/src/messaging/providers/slack/withdraw.test.ts +71 -0
- package/src/messaging/providers/slack/withdraw.ts +53 -5
- package/src/monitoring/recovery/__tests__/orphaned-channel-events.test.ts +144 -0
- package/src/monitoring/recovery/orphaned-channel-events.ts +72 -0
- package/src/monitoring/recovery/run-recovery.ts +2 -0
- package/src/notifications/adapters/slack.ts +6 -0
- package/src/persistence/conversation-crud.ts +4 -1
- package/src/persistence/conversation-title-service.ts +25 -0
- package/src/persistence/delivery-crud.ts +27 -3
- package/src/persistence/delivery-status.ts +60 -2
- package/src/persistence/embeddings/graph-node-orphan-sweep.ts +9 -3
- package/src/persistence/migrations/340-sweep-orphaned-graph-node-vectors.test.ts +16 -0
- package/src/persistence/migrations/341-sweep-cacheless-graph-node-vectors.test.ts +5 -8
- package/src/persistence/migrations/349-move-memory-graph-tables-to-memory-db.ts +227 -0
- package/src/persistence/migrations/350-conversations-total-input-tokens-nullable.ts +77 -0
- package/src/persistence/migrations/__tests__/350-conversations-total-input-tokens-nullable.test.ts +75 -0
- package/src/persistence/raw-query.ts +14 -0
- package/src/persistence/schema/conversations.ts +2 -1
- package/src/persistence/steps.ts +30 -0
- package/src/playbooks/playbook-compiler.ts +5 -2
- package/src/plugins/defaults/memory/__tests__/db-memory-attach.test.ts +8 -2
- package/src/plugins/defaults/memory/__tests__/table-relocation.test.ts +154 -0
- package/src/plugins/defaults/memory/graph/bootstrap.test.ts +22 -0
- package/src/plugins/defaults/memory/graph/bootstrap.ts +6 -3
- package/src/plugins/defaults/memory/graph/capability-seed.ts +9 -5
- package/src/plugins/defaults/memory/graph/consolidation.ts +11 -3
- package/src/plugins/defaults/memory/graph/decay.ts +3 -2
- package/src/plugins/defaults/memory/graph/graph-search.ts +3 -2
- package/src/plugins/defaults/memory/graph/retriever.test.ts +7 -11
- package/src/plugins/defaults/memory/graph/store.test.ts +4 -6
- package/src/plugins/defaults/memory/graph/store.ts +43 -22
- package/src/plugins/defaults/memory/graph-topology/pending-buffer.test.ts +35 -0
- package/src/plugins/defaults/memory/graph-topology/pending-buffer.ts +29 -0
- package/src/plugins/defaults/memory/job-handlers/index-maintenance.ts +24 -17
- package/src/plugins/defaults/memory/src/memory-item-routes.test.ts +38 -4
- package/src/plugins/defaults/memory/src/memory-item-routes.ts +44 -10
- package/src/plugins/defaults/memory/task-memory-cleanup.ts +94 -25
- package/src/plugins/defaults/memory/v2/backfill-jobs.ts +12 -2
- package/src/plugins/defaults/memory/v2/migration.ts +2 -5
- package/src/prompts/templates/system-sections.ts +2 -0
- package/src/providers/inference/__tests__/credential-slot-repair.test.ts +138 -0
- package/src/providers/inference/credential-slot-repair.ts +110 -0
- package/src/providers/openai/responses-provider.ts +13 -4
- package/src/runtime/AGENTS.md +8 -0
- package/src/runtime/access-request-helper.ts +71 -58
- package/src/runtime/agent-wake.ts +9 -31
- package/src/runtime/channel-approval-types.ts +22 -0
- package/src/runtime/channel-retry-sweep.ts +198 -20
- package/src/runtime/guardian-action-service.ts +14 -1
- package/src/runtime/introduction-policy.test.ts +8 -0
- package/src/runtime/introduction-policy.ts +14 -2
- package/src/runtime/routes/__tests__/browser-tabs-routes.test.ts +35 -0
- package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +28 -0
- package/src/runtime/routes/__tests__/defer-routes.test.ts +39 -0
- package/src/runtime/routes/__tests__/events-routes.test.ts +40 -0
- package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +160 -0
- package/src/runtime/routes/__tests__/mcp-auth-routes.test.ts +69 -0
- package/src/runtime/routes/__tests__/sequence-routes.test.ts +56 -0
- package/src/runtime/routes/__tests__/suggest-trust-rule-routes.test.ts +29 -0
- package/src/runtime/routes/__tests__/ui-request-routes.test.ts +37 -0
- package/src/runtime/routes/__tests__/user-routes-cli.test.ts +7 -0
- package/src/runtime/routes/__tests__/watcher-routes.test.ts +42 -0
- package/src/runtime/routes/brain-graph-routes.ts +11 -11
- package/src/runtime/routes/browser-tabs-routes.ts +2 -1
- package/src/runtime/routes/conversation-query-routes.ts +22 -18
- package/src/runtime/routes/debug-routes.ts +2 -2
- package/src/runtime/routes/defer-routes.ts +7 -4
- package/src/runtime/routes/events-routes.ts +2 -1
- package/src/runtime/routes/global-search-routes.ts +8 -2
- package/src/runtime/routes/inbound-message-handler.ts +187 -64
- package/src/runtime/routes/inbound-stages/acl-enforcement.test.ts +60 -52
- package/src/runtime/routes/inbound-stages/acl-enforcement.ts +25 -40
- package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +158 -0
- package/src/runtime/routes/inbound-stages/background-dispatch.ts +91 -26
- package/src/runtime/routes/inbound-stages/channel-turn-admission.test.ts +141 -0
- package/src/runtime/routes/inbound-stages/channel-turn-admission.ts +111 -0
- package/src/runtime/routes/inbound-stages/inbound-content-prep.test.ts +53 -0
- package/src/runtime/routes/inbound-stages/inbound-content-prep.ts +64 -0
- package/src/runtime/routes/inference-provider-connection-routes.ts +101 -6
- package/src/runtime/routes/integrations/slack/__tests__/token-routing.test.ts +38 -85
- package/src/runtime/routes/integrations/slack/channels.ts +9 -13
- package/src/runtime/routes/integrations/slack/share.ts +12 -21
- package/src/runtime/routes/mcp-auth-routes.ts +42 -44
- package/src/runtime/routes/parse-body.ts +31 -0
- package/src/runtime/routes/sequence-routes.ts +7 -6
- package/src/runtime/routes/suggest-trust-rule-routes.ts +6 -19
- package/src/runtime/routes/surface-action-routes.ts +10 -0
- package/src/runtime/routes/ui-request-routes.ts +2 -1
- package/src/runtime/routes/user-routes-cli.ts +2 -1
- package/src/runtime/routes/watcher-routes.ts +9 -6
- package/src/tools/ask-question/ask-question-tool.test.ts +87 -1
- package/src/tools/ask-question/ask-question-tool.ts +58 -0
- package/src/tools/tool-types.ts +8 -0
- package/src/tools/types.ts +9 -0
- package/src/util/single-flight.test.ts +88 -0
- 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), **
|
|
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
|
|
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
|
|
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
|
|
195
|
-
G->>A:
|
|
196
|
-
A->>A: Resolver
|
|
197
|
-
A-->>U: (No notification — user only
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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.
|
|
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
|
|
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
|
@@ -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
|
|
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: "
|
|
616
|
+
notifyResult: { notified: false, reason: "contact_kept_out" },
|
|
564
617
|
});
|
|
565
|
-
await f.flow.start(
|
|
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")
|