@vellumai/vellum-gateway 0.11.6 → 0.11.7-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 (129) hide show
  1. package/AGENTS.md +15 -1
  2. package/ARCHITECTURE.md +16 -16
  3. package/Dockerfile +3 -0
  4. package/knip.json +5 -1
  5. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +37 -0
  6. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/remote-web-pairing.test.ts +389 -0
  7. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/ingress.ts +22 -0
  8. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +508 -18
  9. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/rpc.ts +174 -0
  10. package/node_modules/@vellumai/ces-client/src/http-credentials.ts +161 -0
  11. package/node_modules/@vellumai/ces-client/src/index.ts +1 -0
  12. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +37 -0
  13. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/remote-web-pairing.test.ts +389 -0
  14. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/ingress.ts +22 -0
  15. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +508 -18
  16. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/rpc.ts +174 -0
  17. package/node_modules/@vellumai/gateway-client/src/__tests__/contact-read-contracts.test.ts +32 -0
  18. package/node_modules/@vellumai/gateway-client/src/__tests__/gateway-client.test.ts +1 -0
  19. package/node_modules/@vellumai/gateway-client/src/__tests__/inbound-event-kind.test.ts +82 -0
  20. package/node_modules/@vellumai/gateway-client/src/__tests__/plugin-admission-denied-contract.test.ts +56 -0
  21. package/node_modules/@vellumai/gateway-client/src/__tests__/trust-verdict-contract.test.ts +18 -0
  22. package/node_modules/@vellumai/gateway-client/src/admission-policy-contract.ts +5 -11
  23. package/node_modules/@vellumai/gateway-client/src/channel-permission-contract.ts +3 -1
  24. package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +9 -0
  25. package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +4 -0
  26. package/node_modules/@vellumai/gateway-client/src/inbound-event-kind.ts +75 -0
  27. package/node_modules/@vellumai/gateway-client/src/index.ts +17 -0
  28. package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +8 -0
  29. package/node_modules/@vellumai/gateway-client/src/plugin-admission-denied-contract.ts +47 -0
  30. package/node_modules/@vellumai/gateway-client/src/trust-verdict-contract.ts +11 -0
  31. package/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +37 -0
  32. package/node_modules/@vellumai/service-contracts/src/__tests__/remote-web-pairing.test.ts +389 -0
  33. package/node_modules/@vellumai/service-contracts/src/ingress.ts +22 -0
  34. package/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +508 -18
  35. package/node_modules/@vellumai/service-contracts/src/rpc.ts +174 -0
  36. package/openapi.json +25 -3
  37. package/package.json +5 -1
  38. package/src/__tests__/config-file-cache.test.ts +29 -51
  39. package/src/__tests__/contact-prompt-submit.test.ts +49 -0
  40. package/src/__tests__/contact-rich-read.test.ts +60 -0
  41. package/src/__tests__/contact-store-mark-channel-verified.test.ts +39 -1
  42. package/src/__tests__/contacts-control-plane-proxy.test.ts +90 -1
  43. package/src/__tests__/contacts-info-joiner.test.ts +15 -0
  44. package/src/__tests__/device-identity-text.test.ts +124 -0
  45. package/src/__tests__/devices.test.ts +317 -2
  46. package/src/__tests__/edge-auth.test.ts +156 -0
  47. package/src/__tests__/guardian-init-lockfile.test.ts +46 -0
  48. package/src/__tests__/ipc-contact-threshold.test.ts +193 -0
  49. package/src/__tests__/refresh-device-binding.test.ts +110 -0
  50. package/src/__tests__/remote-web-pairing-token.test.ts +425 -3
  51. package/src/__tests__/route-helpers.test.ts +239 -0
  52. package/src/__tests__/route-schema-guard.test.ts +1 -0
  53. package/src/__tests__/seed-admission-policy.test.ts +6 -6
  54. package/src/__tests__/speech-relay-websocket.test.ts +45 -0
  55. package/src/__tests__/text-verification-lane-guard.test.ts +140 -0
  56. package/src/__tests__/trust-rules-routes.test.ts +35 -5
  57. package/src/auth/device-identity-text.ts +39 -0
  58. package/src/auth/guardian-bootstrap.ts +88 -1
  59. package/src/auth/guardian-refresh.ts +14 -0
  60. package/src/backup/backup-worker.ts +6 -1
  61. package/src/channels/inbound-event.ts +33 -0
  62. package/src/channels/plugin-inbound.test.ts +29 -1
  63. package/src/channels/plugin-inbound.ts +45 -0
  64. package/src/cli/__tests__/contacts.test.ts +184 -0
  65. package/src/cli/contacts.ts +319 -0
  66. package/src/cli/index.ts +36 -0
  67. package/src/db/__tests__/contact-auto-approve-threshold.test.ts +31 -0
  68. package/src/db/admission-policy-store.ts +3 -3
  69. package/src/db/contact-auto-approve-threshold.ts +20 -0
  70. package/src/db/contact-store.ts +46 -14
  71. package/src/db/schema.ts +13 -0
  72. package/src/discord/admission-log-visibility.test.ts +25 -22
  73. package/src/discord/admission-log.test.ts +16 -62
  74. package/src/discord/admission-log.ts +13 -10
  75. package/src/discord/admit.test.ts +62 -43
  76. package/src/discord/admit.ts +47 -36
  77. package/src/discord/gateway-socket.test.ts +5 -19
  78. package/src/discord/gateway-socket.ts +17 -17
  79. package/src/discord/message-schemas.ts +2 -2
  80. package/src/discord/normalize.test.ts +11 -21
  81. package/src/discord/normalize.ts +7 -4
  82. package/src/discord/thread-parents.test.ts +1 -1
  83. package/src/discord/thread-parents.ts +5 -6
  84. package/src/email/normalize.ts +1 -0
  85. package/src/feature-flag-registry.json +34 -2
  86. package/src/handlers/handle-inbound.ts +10 -4
  87. package/src/http/route-helpers.ts +79 -14
  88. package/src/http/routes/auth-token.ts +6 -0
  89. package/src/http/routes/channel-admission-policy.ts +1 -2
  90. package/src/http/routes/channel-verification-session-proxy.ts +22 -4
  91. package/src/http/routes/contact-prompt.ts +24 -0
  92. package/src/http/routes/contacts-control-plane-proxy.ts +33 -4
  93. package/src/http/routes/contacts-control-plane-routes.ts +9 -0
  94. package/src/http/routes/devices.ts +4 -0
  95. package/src/http/routes/guardian-refresh.ts +4 -3
  96. package/src/http/routes/pair.ts +12 -0
  97. package/src/http/routes/plugin-webhook.test.ts +51 -6
  98. package/src/http/routes/plugin-webhook.ts +109 -8
  99. package/src/http/routes/remote-web-pairing-token.ts +81 -26
  100. package/src/http/routes/speech-relay-websocket.ts +40 -2
  101. package/src/http/routes/telegram-webhook.ts +4 -5
  102. package/src/http/routes/trust-rules-routes.ts +2 -2
  103. package/src/http/routes/trust-rules.ts +15 -1
  104. package/src/index.ts +36 -12
  105. package/src/ipc/admission-policy-handlers.ts +0 -23
  106. package/src/ipc/risk-classification-handlers.test.ts +10 -0
  107. package/src/ipc/risk-classification-handlers.ts +20 -0
  108. package/src/ipc/threshold-handlers.ts +67 -0
  109. package/src/risk/__tests__/trust-verdict-resolver.test.ts +95 -2
  110. package/src/risk/bash-risk-classifier.test.ts +21 -0
  111. package/src/risk/command-registry/commands/gateway.ts +27 -0
  112. package/src/risk/command-registry/index.ts +2 -0
  113. package/src/risk/powershell-risk-classifier.test.ts +184 -0
  114. package/src/risk/powershell-risk-classifier.ts +773 -0
  115. package/src/risk/trust-verdict-resolver.ts +18 -4
  116. package/src/schema.ts +3 -3
  117. package/src/slack/block-actions-normalizer.ts +1 -0
  118. package/src/slack/message-change-normalizer.ts +3 -1
  119. package/src/slack/message-normalizer.ts +16 -1
  120. package/src/slack/normalize.test.ts +5 -0
  121. package/src/slack/reaction-normalizer.ts +1 -0
  122. package/src/slack/source-metadata.ts +2 -2
  123. package/src/telegram/normalize.test.ts +46 -0
  124. package/src/telegram/normalize.ts +6 -0
  125. package/src/verification/text-verification.ts +33 -1
  126. package/src/whatsapp/normalize.ts +1 -0
  127. package/src/discord/allowed-channels.test.ts +0 -156
  128. package/src/discord/allowed-channels.ts +0 -28
  129. package/src/slack/event-kind.ts +0 -17
package/AGENTS.md CHANGED
@@ -179,7 +179,7 @@ A default row per enforced channel is **seeded at startup** (`seedAdmissionPolic
179
179
 
180
180
  For exempt ids, `PUT /v1/assistants/:id/channel-admission-policy/:channelType` returns **403**, the GET list omits them, and the runtime short-circuits `admitted: true` in `admission-policy.ts` (defense in depth). Codex finding from #35006 review: exemption checks must live in _both_ the gateway route handler AND the runtime stage — single-side enforcement creates a misuse wedge.
181
181
 
182
- **Hidden channels** (`ADMISSION_POLICY_HIDDEN_CHANNELS` = `vellum`, `whatsapp`, `discord`) — managed automatically, **not** user-configurable, but (unlike exempt channels) **still enforced at runtime**:
182
+ **Hidden channels** (`ADMISSION_POLICY_HIDDEN_CHANNELS` = `vellum`, `whatsapp`) — managed automatically, **not** user-configurable, but (unlike exempt channels) **still enforced at runtime**:
183
183
 
184
184
  - The GET list omits them, and `PUT`/`DELETE` return **403** (`isAdmissionPolicyHiddenChannel`).
185
185
  - They are **not** exempt — the runtime still evaluates rank-vs-floor, so a real inbound channel like `whatsapp` keeps its admission floor.
@@ -242,6 +242,20 @@ The gateway classifies the actor at ingress (keyed on `actorExternalId`) and for
242
242
 
243
243
  **Intentionally NOT capability-gated** (these are identity / admission-flow decisions, not permissions, and stay raw class checks): `calls/*` guardian-identity call routing, `inbound-message-handler` heartbeat/timezone side-effects, `surface-action-routes` drift-heal re-resolution, and `channel-retry-sweep` trust-class parsing.
244
244
 
245
+ ## Operator CLI (`gateway contacts`)
246
+
247
+ The gateway package ships an operator CLI (`gateway/src/cli/`, bin `gateway`) for gateway-owned contact ACL. This is the write surface for a contact's assistant-access ceiling. Do not add that write to `assistant contacts`.
248
+
249
+ ```
250
+ gateway contacts list
251
+ gateway contacts get <contactId>
252
+ gateway contacts set-risk-threshold <contactId> --threshold none|low|medium|high|inherit
253
+ ```
254
+
255
+ The CLI talks to the running gateway over its IPC socket (`set_contact_threshold`, `contacts_list_rich`, `contacts_get_rich`). It does not open `gateway.sqlite` itself.
256
+
257
+ From the host: `vellum exec --service gateway -- gateway contacts ...`. Do not add a `vellum gateway contacts` verb.
258
+
245
259
  ## Guardian Requests (gateway-owned)
246
260
 
247
261
  The gateway owns guardian approval requests and their delivery records: the `guardian_requests` and `guardian_request_deliveries` tables (`src/db/guardian-request-store.ts`), fronted by `src/approvals/guardian-request-service.ts` and exposed to the daemon over the `guardian_requests_*` IPC routes (`src/ipc/guardian-request-handlers.ts`; shared contract in `packages/gateway-client/src/guardian-request-contract.ts`). The daemon holds no request state — it relays everything through `assistant/src/channels/gateway-guardian-requests.ts` and keeps the daemon-domain side effects: notifications, card delivery/withdrawal, pending-interaction resume, grant minting into its `scoped_approval_grants`.
package/ARCHITECTURE.md CHANGED
@@ -54,7 +54,7 @@ The request carries base64-encoded WAV audio and a MIME type. The daemon resolve
54
54
 
55
55
  Clients open WebSocket connections through the gateway to the daemon's real-time STT streaming endpoint for conversation chat message capture. The gateway authenticates the downstream client using an edge JWT (actor principal required), then opens an upstream WebSocket connection to the daemon's `/v1/stt/stream` endpoint with a short-lived gateway service token. This keeps the daemon's WebSocket endpoint unreachable from the public internet while allowing authenticated clients to stream audio for real-time transcription.
56
56
 
57
- **Config-authoritative model:** The runtime always resolves the streaming transcriber from `services.stt.provider` in the assistant config, regardless of any `provider` query parameter. The `provider` parameter is optional compatibility metadata — when supplied and it disagrees with the configured provider, the runtime logs a mismatch warning for operator visibility.
57
+ **Config-authoritative model:** The runtime always resolves the streaming transcriber from the assistant config, regardless of any `provider` query parameter. Dictation reads its own role (`services.stt.roles.dictation`, falling back to `services.stt.provider`). The `provider` parameter is optional compatibility metadata: when supplied and it disagrees with the provider that role resolves to, the runtime logs a mismatch warning for operator visibility.
58
58
 
59
59
  **Client path:** `wss://<gateway>/v1/stt/stream?mimeType=<mime>[&provider=<id>][&sampleRate=<hz>]`
60
60
 
@@ -1116,20 +1116,20 @@ Malformed or unprocessable provider callback payloads are logged as dead-letter
1116
1116
 
1117
1117
  Call behavior is controlled via the `calls` config block in the assistant configuration (`config/schema.ts`). All values have sensible defaults and are validated via Zod:
1118
1118
 
1119
- | Field | Type | Default | Description |
1120
- | --------------------------------- | -------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1121
- | `calls.enabled` | boolean | `true` | Master toggle for the calls feature. When `false`, call routes return 403 and tools return errors. |
1122
- | `calls.provider` | enum | `'twilio'` | Voice provider to use (currently only Twilio is supported). |
1123
- | `calls.maxDurationSeconds` | int | `3600` | Maximum allowed duration per call. |
1124
- | `calls.userConsultTimeoutSeconds` | int | `120` | How long to wait for a user answer before timing out a pending question. |
1125
- | `calls.disclosure.enabled` | boolean | `true` | Whether the AI should disclose it is an AI at the start of the call. |
1126
- | `calls.disclosure.text` | string | _(default disclosure prompt)_ | The disclosure instruction included in the system prompt. |
1127
- | `calls.safety.denyCategories` | string[] | `[]` | Categories of calls to deny (e.g., emergency numbers are always denied regardless of this setting). |
1128
- | `llm.callSites.callAgent.model` | string | _(unset; falls back to the resolved call-site default)_ | Optional override for the LLM model used in voice call conversations. |
1129
- | `services.stt.provider` | enum | `'deepgram'` | STT provider for all boundaries including telephony. The daemon transcribes media-stream call audio with this provider (streaming when supported, batch otherwise). |
1130
- | `services.stt.language` | string | `multi` | Spoken language for transcription: a BCP-47 code pins one language, `multi` (the schema default) enables code-switching on providers that support it. Per-language TTS voices are configured via `services.tts.providers.<id>.languageVoices`. |
1131
- | `services.tts.provider` | enum | `'elevenlabs'` | Active TTS provider for speech synthesis (catalog-driven; see [TTS Provider Abstraction](../assistant/ARCHITECTURE.md#tts-provider-abstraction-servicestts)). |
1132
- | `services.tts.providers.<id>.*` | object | _(per-provider defaults)_ | Provider-specific settings block. One block per catalog entry (e.g. `elevenlabs`, `fish-audio`). |
1119
+ | Field | Type | Default | Description |
1120
+ | --------------------------------- | -------- | ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1121
+ | `calls.enabled` | boolean | `true` | Master toggle for the calls feature. When `false`, call routes return 403 and tools return errors. |
1122
+ | `calls.provider` | enum | `'twilio'` | Voice provider to use (currently only Twilio is supported). |
1123
+ | `calls.maxDurationSeconds` | int | `3600` | Maximum allowed duration per call. |
1124
+ | `calls.userConsultTimeoutSeconds` | int | `120` | How long to wait for a user answer before timing out a pending question. |
1125
+ | `calls.disclosure.enabled` | boolean | `true` | Whether the AI should disclose it is an AI at the start of the call. |
1126
+ | `calls.disclosure.text` | string | _(default disclosure prompt)_ | The disclosure instruction included in the system prompt. |
1127
+ | `calls.safety.denyCategories` | string[] | `[]` | Categories of calls to deny (e.g., emergency numbers are always denied regardless of this setting). |
1128
+ | `llm.callSites.callAgent.model` | string | _(unset; falls back to the resolved call-site default)_ | Optional override for the LLM model used in voice call conversations. |
1129
+ | `services.stt.provider` | enum | `'deepgram'` | Global STT provider. Every boundary falls back to it, and a consumer with a `services.stt.roles.<role>` override (`liveVoice`, `telephony`, `dictation`, `watch`, `batch`) uses that instead. The daemon transcribes media-stream call audio with whichever provider the `telephony` role resolves to (streaming when supported, batch otherwise). |
1130
+ | `services.stt.language` | string | `multi` | Spoken language for transcription: a BCP-47 code pins one language, `multi` (the schema default) enables code-switching on providers that support it. Per-language TTS voices are configured via `services.tts.providers.<id>.languageVoices`. |
1131
+ | `services.tts.provider` | enum | `'elevenlabs'` | Active TTS provider for speech synthesis (catalog-driven; see [TTS Provider Abstraction](../assistant/ARCHITECTURE.md#tts-provider-abstraction-servicestts)). |
1132
+ | `services.tts.providers.<id>.*` | object | _(per-provider defaults)_ | Provider-specific settings block. One block per catalog entry (e.g. `elevenlabs`, `fish-audio`). |
1133
1133
 
1134
1134
  ### Caller Identity Resolution
1135
1135
 
@@ -1158,7 +1158,7 @@ For full details on the catalog-driven TTS architecture, provider catalog, call
1158
1158
 
1159
1159
  ### Telephony STT: Daemon-Side Media-Stream Transcription
1160
1160
 
1161
- Telephony STT is unified under `services.stt.provider`. The voice webhook in `twilio-routes.ts` emits `<Connect><Stream>` TwiML pointing to the gateway's media-stream proxy (`/webhooks/twilio/media-stream/<callSessionId>/<token>`); the gateway forwards raw audio frames to the daemon's media-stream server, which transcribes them itself:
1161
+ Telephony STT is unified under `services.stt`: calls resolve the `telephony` role when it is set and `services.stt.provider` otherwise. The voice webhook in `twilio-routes.ts` emits `<Connect><Stream>` TwiML pointing to the gateway's media-stream proxy (`/webhooks/twilio/media-stream/<callSessionId>/<token>`); the gateway forwards raw audio frames to the daemon's media-stream server, which transcribes them itself:
1162
1162
 
1163
1163
  - **Streaming** (default) — when `calls.voice.telephonyStreaming` is enabled and the configured provider resolves a streaming transcriber, audio is decoded (mu-law → PCM16, 8 kHz → 16 kHz) and fed to the provider's realtime adapter; replies trigger only on utterance-boundary finals and barge-in fires from local energy VAD.
1164
1164
  - **Batch fallback** — otherwise turns are segmented with the energy-based VAD turn detector and transcribed via the provider's batch API.
package/Dockerfile CHANGED
@@ -57,6 +57,9 @@ COPY --chown=gateway:gateway packages/slack-text /app/packages/slack-text
57
57
  COPY --chown=gateway:gateway packages/twilio-client /app/packages/twilio-client
58
58
  COPY --chown=gateway:gateway gateway /app/gateway
59
59
 
60
+ RUN ln -sf /app/gateway/src/cli/index.ts /usr/local/bin/gateway \
61
+ && chmod +x /app/gateway/src/cli/index.ts
62
+
60
63
  RUN mkdir -p /gateway-security && chown gateway:gateway /gateway-security
61
64
 
62
65
  COPY packages/block-volume-bootstrap/scripts/*.sh /usr/local/bin/
package/knip.json CHANGED
@@ -1,5 +1,9 @@
1
1
  {
2
- "entry": ["src/**/*.test.ts", "src/**/__tests__/**/*.ts"],
2
+ "entry": [
3
+ "src/**/*.test.ts",
4
+ "src/**/__tests__/**/*.ts",
5
+ "src/cli/index.ts"
6
+ ],
3
7
  "project": ["src/**/*.ts"],
4
8
  "ignoreDependencies": [
5
9
  "@vellumai/assistant-client",
@@ -6,8 +6,10 @@ import {
6
6
  normalizePublicBaseUrl,
7
7
  parseRecordedAssistantId,
8
8
  parseTunnelRecord,
9
+ selectRestartTunnelRecord,
9
10
  trimmedNonEmptyString,
10
11
  TUNNEL_PROVIDERS,
12
+ type TunnelRecord,
11
13
  velayHostForPlatformHost,
12
14
  } from "../ingress.js";
13
15
  import {
@@ -221,6 +223,41 @@ describe("parseTunnelRecord", () => {
221
223
  });
222
224
  });
223
225
 
226
+ describe("selectRestartTunnelRecord", () => {
227
+ const PAIRING: TunnelRecord = {
228
+ provider: "tailscale",
229
+ publicBaseUrl: "https://assistant-1.example.ts.net",
230
+ };
231
+ const LAST: TunnelRecord = {
232
+ provider: "ngrok",
233
+ publicBaseUrl: "https://assistant-1.ngrok.app",
234
+ };
235
+
236
+ test("prefers the pairing tunnel over the last one that ran", () => {
237
+ // The pairing tunnel deliberately left the older public record beside it,
238
+ // so naming that one would point at an ingress nobody is using.
239
+ expect(
240
+ selectRestartTunnelRecord({ lastTunnel: LAST, pairingTunnel: PAIRING }),
241
+ ).toEqual(PAIRING);
242
+ });
243
+
244
+ test("falls back to the last tunnel and to null", () => {
245
+ expect(selectRestartTunnelRecord({ lastTunnel: LAST })).toEqual(LAST);
246
+ for (const ingress of [undefined, null, {}, "nonsense", 42]) {
247
+ expect(selectRestartTunnelRecord(ingress)).toBeNull();
248
+ }
249
+ });
250
+
251
+ test("skips a malformed pairing record for a usable last one", () => {
252
+ expect(
253
+ selectRestartTunnelRecord({
254
+ lastTunnel: LAST,
255
+ pairingTunnel: { provider: "wireguard", publicBaseUrl: "nope" },
256
+ }),
257
+ ).toEqual(LAST);
258
+ });
259
+ });
260
+
224
261
  describe("trimmedNonEmptyString", () => {
225
262
  test("trims and rejects blank or non-string values", () => {
226
263
  expect(trimmedNonEmptyString(" assistant-1 ")).toBe("assistant-1");
@@ -0,0 +1,389 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import {
4
+ buildRemoteWebPairingUrl,
5
+ isDnsIndependentLoopbackUrl,
6
+ isLoopbackPublicUrl,
7
+ isPrivateNetworkPublicUrl,
8
+ isRetryablePairingReason,
9
+ pairingSessionSurvives,
10
+ parsePairingAddress,
11
+ parseRemoteWebPairingParams,
12
+ resolvePublicBaseUrl,
13
+ RETRYABLE_PAIRING_REASONS,
14
+ type PairingFailureReason,
15
+ type PublicBaseUrlRejection,
16
+ } from "../remote-web-pairing.js";
17
+
18
+ describe("parseRemoteWebPairingParams", () => {
19
+ test("reads snake_case fragment parameters", () => {
20
+ expect(
21
+ parseRemoteWebPairingParams(
22
+ "https://assistant.example.com/assistant/pair#device_code=device-1&user_code=ABCD-EFGH",
23
+ ),
24
+ ).toEqual({ deviceCode: "device-1", userCode: "ABCD-EFGH" });
25
+ });
26
+
27
+ test("reads camelCase query parameters", () => {
28
+ expect(
29
+ parseRemoteWebPairingParams(
30
+ "https://assistant.example.com/assistant/pair?deviceCode=device-2&userCode=WXYZ-1234",
31
+ ),
32
+ ).toEqual({ deviceCode: "device-2", userCode: "WXYZ-1234" });
33
+ });
34
+
35
+ test("accepts a relative link", () => {
36
+ expect(
37
+ parseRemoteWebPairingParams("/assistant/pair#device_code=device-3"),
38
+ ).toEqual({ deviceCode: "device-3", userCode: null });
39
+ });
40
+
41
+ test("reports null when no codes are present", () => {
42
+ expect(
43
+ parseRemoteWebPairingParams("https://assistant.example.com/assistant"),
44
+ ).toEqual({ deviceCode: null, userCode: null });
45
+ });
46
+ });
47
+
48
+ describe("parsePairingAddress", () => {
49
+ test("splits a pairing link into its base and device code", () => {
50
+ const link = buildRemoteWebPairingUrl({
51
+ verificationUri: "https://assistant.example.com/assistant/pair",
52
+ deviceCode: "device-1",
53
+ });
54
+
55
+ expect(parsePairingAddress(link)).toEqual({
56
+ ok: true,
57
+ publicBaseUrl: "https://assistant.example.com",
58
+ deviceCode: "device-1",
59
+ });
60
+ });
61
+
62
+ test("accepts the device code in the query string", () => {
63
+ expect(
64
+ parsePairingAddress(
65
+ "https://assistant.example.com/assistant/pair?deviceCode=device-2",
66
+ ),
67
+ ).toEqual({
68
+ ok: true,
69
+ publicBaseUrl: "https://assistant.example.com",
70
+ deviceCode: "device-2",
71
+ });
72
+ });
73
+
74
+ test("preserves a path prefix while dropping the app-route suffix", () => {
75
+ expect(
76
+ parsePairingAddress(
77
+ "https://host.example.com/assistant-123/assistant/pair#device_code=device-3",
78
+ ),
79
+ ).toEqual({
80
+ ok: true,
81
+ publicBaseUrl: "https://host.example.com/assistant-123",
82
+ deviceCode: "device-3",
83
+ });
84
+ });
85
+
86
+ test("drops a trailing app-route suffix from a bare address", () => {
87
+ expect(
88
+ parsePairingAddress("https://assistant.example.com/assistant/pair"),
89
+ ).toEqual({
90
+ ok: true,
91
+ publicBaseUrl: "https://assistant.example.com",
92
+ deviceCode: null,
93
+ });
94
+ });
95
+
96
+ test("accepts a bare address with no device code", () => {
97
+ expect(parsePairingAddress("https://assistant.example.com")).toEqual({
98
+ ok: true,
99
+ publicBaseUrl: "https://assistant.example.com",
100
+ deviceCode: null,
101
+ });
102
+ });
103
+
104
+ test.each<[string, PublicBaseUrlRejection]>([
105
+ ["not a url", "unparseable"],
106
+ ["https://localhost:3000", "loopback"],
107
+ ["https://127.0.0.1:3000", "loopback"],
108
+ ["http://assistant.example.com", "non-https"],
109
+ ["https://login.tailscale.com/admin/invite/abc123", "service-website"],
110
+ ])("rejects %s", (raw, reason) => {
111
+ expect(parsePairingAddress(raw)).toEqual({ ok: false, reason });
112
+ });
113
+ });
114
+
115
+ describe("resolvePublicBaseUrl private-address containment", () => {
116
+ // A host POSTs to whatever address was pasted, so an IP literal aimed at the
117
+ // local network (or at 169.254.169.254, the cloud instance metadata
118
+ // endpoint) is a blind SSRF target and is refused before any request.
119
+ test.each([
120
+ ["https://0.0.0.0", "0/8"],
121
+ ["https://10.0.0.1", "10/8"],
122
+ ["https://10.255.255.254:8443", "10/8 with a port"],
123
+ ["https://169.254.169.254", "169.254/16 cloud metadata"],
124
+ ["https://172.16.0.1", "172.16/12 low edge"],
125
+ ["https://172.31.255.254", "172.16/12 high edge"],
126
+ ["https://192.0.0.8", "192.0.0/24"],
127
+ ["https://192.168.1.5", "192.168/16"],
128
+ ["https://198.18.0.1", "198.18/15"],
129
+ ["https://198.19.255.254", "198.18/15 high half"],
130
+ ["https://224.0.0.1", "224/4 multicast"],
131
+ ["https://240.0.0.1", "240/4 reserved"],
132
+ ["https://255.255.255.255", "broadcast"],
133
+ // WHATWG URL canonicalizes these to a dotted-quad before the check.
134
+ ["https://2130706433", "decimal-encoded 127.0.0.1"],
135
+ ["https://0xa000001", "hex-encoded 10.0.0.1"],
136
+ ["https://[::]", "IPv6 unspecified"],
137
+ ["https://[fd00::1]", "fc00::/7 unique-local"],
138
+ ["https://[fc00::5]", "fc00::/7 low edge"],
139
+ ["https://[fe80::1]", "fe80::/10 link-local"],
140
+ ["https://[ff02::1]", "ff00::/8 multicast"],
141
+ ["https://[::ffff:10.0.0.1]", "IPv4-mapped private"],
142
+ ["https://[::ffff:169.254.169.254]", "IPv4-mapped metadata"],
143
+ ["https://[::192.168.1.5]", "IPv4-compatible private"],
144
+ ])("refuses %s (%s)", (raw) => {
145
+ const result = resolvePublicBaseUrl(raw);
146
+ expect(result.ok).toBe(false);
147
+ if (result.ok) {
148
+ return;
149
+ }
150
+ // 127/8 and ::1 keep their more specific loopback reason.
151
+ expect(["private-address", "loopback"]).toContain(result.reason);
152
+ });
153
+
154
+ test.each([
155
+ "https://10.0.0.1",
156
+ "https://169.254.169.254",
157
+ "https://172.20.3.4",
158
+ "https://192.168.1.5",
159
+ "https://198.18.0.1",
160
+ "https://[fd00::1]",
161
+ "https://[fe80::1]",
162
+ "https://[::ffff:10.0.0.1]",
163
+ ])("reports %s as private-address, not loopback", (raw) => {
164
+ expect(resolvePublicBaseUrl(raw)).toEqual({
165
+ ok: false,
166
+ reason: "private-address",
167
+ });
168
+ });
169
+
170
+ // A wildcard bind reaches a listener on the machine that dials it, so it
171
+ // gets the more specific loopback reason and the "points back at this
172
+ // machine" guidance that goes with it.
173
+ test.each([
174
+ ["https://0.0.0.0", "IPv4 wildcard"],
175
+ ["https://[::]", "IPv6 wildcard"],
176
+ ["https://[::ffff:0.0.0.0]", "IPv4-mapped wildcard"],
177
+ ["https://[::ffff:127.0.0.1]", "IPv4-mapped loopback"],
178
+ ])("reports %s (%s) as loopback", (raw) => {
179
+ expect(isLoopbackPublicUrl(raw)).toBe(true);
180
+ expect(resolvePublicBaseUrl(raw)).toEqual({
181
+ ok: false,
182
+ reason: "loopback",
183
+ });
184
+ });
185
+
186
+ // Tailscale hands out 100.64.0.0/10 CGNAT addresses and `*.ts.net` names are
187
+ // a supported pairing target, so neither the range nor any hostname is
188
+ // filtered. Names are never resolved (see isPrivateNetworkPublicUrl).
189
+ test.each([
190
+ "https://my-box.tail1234.ts.net",
191
+ "https://assistant.example.com",
192
+ "https://100.101.102.103",
193
+ "https://[2606:4700::1111]",
194
+ "https://172.32.0.1",
195
+ "https://192.0.1.1",
196
+ "https://198.20.0.1",
197
+ "https://223.255.255.255",
198
+ ])("still resolves %s", (raw) => {
199
+ expect(resolvePublicBaseUrl(raw)).toEqual({ ok: true, url: raw });
200
+ });
201
+
202
+ test("isPrivateNetworkPublicUrl reports a plain hostname as public", () => {
203
+ expect(isPrivateNetworkPublicUrl("https://my-box.tail1234.ts.net")).toBe(
204
+ false,
205
+ );
206
+ expect(isPrivateNetworkPublicUrl("not a url")).toBe(false);
207
+ expect(isPrivateNetworkPublicUrl("https://192.168.0.1")).toBe(true);
208
+ });
209
+ });
210
+
211
+ describe("resolvePublicBaseUrl localhost normalization", () => {
212
+ // A terminal DNS root dot survives WHATWG parsing, and resolvers read the
213
+ // absolute name as loopback, so the dot is stripped before every host
214
+ // comparison. The whole `.localhost` namespace is reserved (RFC 6761) and
215
+ // resolves to loopback too.
216
+ test.each([
217
+ ["https://localhost.", "absolute localhost name"],
218
+ ["https://localhost.:3000", "absolute localhost name with a port"],
219
+ ["https://LOCALHOST.", "uppercase absolute localhost name"],
220
+ ["https://foo.localhost", "reserved .localhost namespace"],
221
+ ["https://foo.localhost.", "absolute .localhost namespace"],
222
+ ["https://a.b.localhost.", "nested .localhost namespace"],
223
+ ["https://127.0.0.1.", "absolute IPv4 loopback literal"],
224
+ ])("reports %s (%s) as loopback", (raw) => {
225
+ expect(isLoopbackPublicUrl(raw)).toBe(true);
226
+ expect(resolvePublicBaseUrl(raw)).toEqual({
227
+ ok: false,
228
+ reason: "loopback",
229
+ });
230
+ });
231
+
232
+ test("reports an absolute private literal as private-address", () => {
233
+ expect(isPrivateNetworkPublicUrl("https://10.0.0.1.")).toBe(true);
234
+ expect(resolvePublicBaseUrl("https://10.0.0.1.")).toEqual({
235
+ ok: false,
236
+ reason: "private-address",
237
+ });
238
+ });
239
+
240
+ test("reports an absolute vendor-site name as service-website", () => {
241
+ expect(resolvePublicBaseUrl("https://login.tailscale.com.")).toEqual({
242
+ ok: false,
243
+ reason: "service-website",
244
+ });
245
+ });
246
+
247
+ test.each([
248
+ "https://my-box.tail1234.ts.net.",
249
+ "https://assistant.example.com.",
250
+ "https://localhostage.example.com",
251
+ "https://mylocalhost.example.com.",
252
+ ])("still resolves %s", (raw) => {
253
+ expect(isLoopbackPublicUrl(raw)).toBe(false);
254
+ expect(resolvePublicBaseUrl(raw)).toEqual({ ok: true, url: raw });
255
+ });
256
+ });
257
+
258
+ describe("isDnsIndependentLoopbackUrl", () => {
259
+ test.each([
260
+ ["http://localhost:7830", "the exact name"],
261
+ ["http://LOCALHOST.:7830", "the absolute, uppercase name"],
262
+ ["http://127.0.0.1:7830", "an IPv4 loopback literal"],
263
+ ["http://127.9.9.9", "the rest of 127/8"],
264
+ ["http://[::1]:7830", "the IPv6 loopback literal"],
265
+ ["http://[::ffff:127.0.0.1]", "an IPv4-mapped loopback literal"],
266
+ ["http://0.0.0.0:7830", "the IPv4 wildcard bind"],
267
+ ["http://[::]:7830", "the IPv6 wildcard bind"],
268
+ ])("%s reaches this machine (%s)", (raw) => {
269
+ expect(isDnsIndependentLoopbackUrl(raw)).toBe(true);
270
+ expect(isLoopbackPublicUrl(raw)).toBe(true);
271
+ });
272
+
273
+ // RFC 6761 reserves the namespace and says resolvers should map it to
274
+ // loopback, but glibc does not by default, so the name is ordinary DNS and
275
+ // can answer with any address. The wide predicate still refuses it as
276
+ // loopback; the narrow one refuses to hand it a loopback privilege.
277
+ test.each([
278
+ ["https://foo.localhost", "reserved .localhost namespace"],
279
+ ["https://foo.localhost.", "absolute .localhost namespace"],
280
+ ["https://a.b.localhost", "nested .localhost namespace"],
281
+ ])("%s is loopback but not DNS-independent (%s)", (raw) => {
282
+ expect(isLoopbackPublicUrl(raw)).toBe(true);
283
+ expect(isDnsIndependentLoopbackUrl(raw)).toBe(false);
284
+ });
285
+
286
+ test.each([
287
+ "https://assistant.example.com",
288
+ "https://localhostage.example.com",
289
+ "https://10.0.0.5",
290
+ "not a url",
291
+ ])("%s is not loopback at all", (raw) => {
292
+ expect(isDnsIndependentLoopbackUrl(raw)).toBe(false);
293
+ expect(isLoopbackPublicUrl(raw)).toBe(false);
294
+ });
295
+ });
296
+
297
+ /** Every reason, so a classification table cannot silently miss one. */
298
+ const EVERY_PAIRING_REASON: PairingFailureReason[] = [
299
+ "invalid-address",
300
+ "unknown-session",
301
+ "expired",
302
+ "unreachable",
303
+ "gateway-retryable",
304
+ "gateway",
305
+ "import",
306
+ "import-precheck",
307
+ ];
308
+
309
+ describe("isRetryablePairingReason", () => {
310
+ test.each([
311
+ // Nothing reached the assistant, so the session and code are untouched.
312
+ "unreachable",
313
+ // The assistant refused with a status that released the code.
314
+ "gateway-retryable",
315
+ ] as const)("%s is worth another attempt", (reason) => {
316
+ expect(isRetryablePairingReason(reason)).toBe(true);
317
+ });
318
+
319
+ test.each([
320
+ "invalid-address",
321
+ "unknown-session",
322
+ "expired",
323
+ // The assistant answered with something unusable, past which the code is
324
+ // spent rather than released.
325
+ "gateway",
326
+ "import",
327
+ // Nothing was spent, but an identical attempt is refused identically:
328
+ // only a caller that changes the name it asked for gets past this.
329
+ "import-precheck",
330
+ ] as const)("%s settles the attempt", (reason) => {
331
+ expect(isRetryablePairingReason(reason)).toBe(false);
332
+ });
333
+
334
+ test("an absent reason settles the attempt", () => {
335
+ expect(isRetryablePairingReason(undefined)).toBe(false);
336
+ expect(isRetryablePairingReason(null)).toBe(false);
337
+ });
338
+
339
+ test("the exported set is exactly the reasons worth another attempt", () => {
340
+ expect(
341
+ EVERY_PAIRING_REASON.filter(isRetryablePairingReason).sort(),
342
+ ).toEqual(["gateway-retryable", "unreachable"]);
343
+ expect([...RETRYABLE_PAIRING_REASONS].sort()).toEqual([
344
+ "gateway-retryable",
345
+ "unreachable",
346
+ ]);
347
+ });
348
+ });
349
+
350
+ describe("pairingSessionSurvives", () => {
351
+ test.each([
352
+ // Nothing reached the assistant, so the session and code are untouched.
353
+ "unreachable",
354
+ // The assistant released the code before answering.
355
+ "gateway-retryable",
356
+ // The refusal happened before the exchange, so the code is unspent.
357
+ "import-precheck",
358
+ ] as const)("%s leaves the session pollable", (reason) => {
359
+ expect(pairingSessionSurvives(reason)).toBe(true);
360
+ });
361
+
362
+ test.each([
363
+ "invalid-address",
364
+ "unknown-session",
365
+ "expired",
366
+ "gateway",
367
+ // The exchange spent the code; only the local write failed.
368
+ "import",
369
+ ] as const)("%s leaves nothing to poll", (reason) => {
370
+ expect(pairingSessionSurvives(reason)).toBe(false);
371
+ });
372
+
373
+ test("an absent reason leaves nothing to poll", () => {
374
+ expect(pairingSessionSurvives(undefined)).toBe(false);
375
+ expect(pairingSessionSurvives(null)).toBe(false);
376
+ });
377
+
378
+ test("a pre-check refusal is the one reason the two axes disagree on", () => {
379
+ // A caller that reads retryability as "keep the session" throws away a
380
+ // device code that is still good, which is what this reason exists to
381
+ // prevent: nothing was spent, so the same handle completes the pairing
382
+ // once the caller supplies a name that is free.
383
+ const disagreeing = EVERY_PAIRING_REASON.filter(
384
+ (reason) =>
385
+ pairingSessionSurvives(reason) !== isRetryablePairingReason(reason),
386
+ );
387
+ expect(disagreeing).toEqual(["import-precheck"]);
388
+ });
389
+ });
@@ -147,6 +147,28 @@ export function parseTunnelRecord(value: unknown): TunnelRecord | null {
147
147
  return { provider, publicBaseUrl: normalized };
148
148
  }
149
149
 
150
+ /**
151
+ * The tunnel a reader should offer to restart, given an `ingress` section, or
152
+ * null when it records none.
153
+ *
154
+ * A pairing-only record wins: it is written by a tunnel that deliberately left
155
+ * `publicBaseUrl` (and the `lastTunnel` beside it) pointing at an older public
156
+ * provider, so naming that older one would send the user to rebuild an ingress
157
+ * they are not using instead of the tunnel actually in front of them.
158
+ */
159
+ export function selectRestartTunnelRecord(
160
+ ingress: unknown,
161
+ ): TunnelRecord | null {
162
+ if (typeof ingress !== "object" || ingress === null) {
163
+ return null;
164
+ }
165
+ const section = ingress as Record<string, unknown>;
166
+ return (
167
+ parseTunnelRecord(section[INGRESS_PAIRING_TUNNEL_KEY]) ??
168
+ parseTunnelRecord(section[INGRESS_LAST_TUNNEL_KEY])
169
+ );
170
+ }
171
+
150
172
  /** Parse an `ingress.assistantId` value, or null when it is absent or blank. */
151
173
  export function parseRecordedAssistantId(value: unknown): string | null {
152
174
  return trimmedNonEmptyString(value) ?? null;