@vellumai/vellum-gateway 0.12.2 → 0.12.3-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 (120) hide show
  1. package/AGENTS.md +7 -5
  2. package/ARCHITECTURE.md +34 -38
  3. package/README.md +30 -32
  4. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +2 -1
  5. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/secret-detection.test.ts +1 -0
  6. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  7. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/integration-categories.ts +36 -0
  8. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/secret-detection.ts +4 -1
  9. package/node_modules/@vellumai/environments/src/shell.test.ts +21 -0
  10. package/node_modules/@vellumai/environments/src/shell.ts +24 -0
  11. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +2 -1
  12. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/secret-detection.test.ts +1 -0
  13. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  14. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/integration-categories.ts +36 -0
  15. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/secret-detection.ts +4 -1
  16. package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +20 -0
  17. package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +8 -2
  18. package/node_modules/@vellumai/gateway-client/src/index.ts +4 -1
  19. package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +38 -7
  20. package/node_modules/@vellumai/gateway-client/src/trust-verdict-contract.ts +5 -6
  21. package/node_modules/@vellumai/service-contracts/package.json +2 -1
  22. package/node_modules/@vellumai/service-contracts/src/__tests__/secret-detection.test.ts +1 -0
  23. package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  24. package/node_modules/@vellumai/service-contracts/src/integration-categories.ts +36 -0
  25. package/node_modules/@vellumai/service-contracts/src/secret-detection.ts +4 -1
  26. package/node_modules/@vellumai/slack-text/src/index.ts +4 -8
  27. package/package.json +1 -1
  28. package/src/__tests__/config.test.ts +0 -1
  29. package/src/__tests__/handle-inbound-invite-intercept.test.ts +36 -41
  30. package/src/__tests__/pair-origin-allowlist.test.ts +20 -1
  31. package/src/__tests__/reply-path.test.ts +36 -0
  32. package/src/__tests__/runtime-client.test.ts +0 -84
  33. package/src/__tests__/telegram-normalize.test.ts +123 -111
  34. package/src/__tests__/telegram-webhook-drop-visibility.test.ts +229 -0
  35. package/src/__tests__/telegram-webhook-handler.test.ts +160 -0
  36. package/src/__tests__/text-verification-reply.test.ts +131 -0
  37. package/src/__tests__/verification-bootstrap-purpose.test.ts +126 -0
  38. package/src/attachments/download.ts +5 -0
  39. package/src/attachments/ingest.test.ts +167 -3
  40. package/src/attachments/ingest.ts +139 -6
  41. package/src/channels/admission-drop-log.test.ts +47 -0
  42. package/src/channels/admission-drop-log.ts +94 -0
  43. package/src/channels/inbound-event.ts +9 -0
  44. package/src/channels/room-admission.test.ts +95 -0
  45. package/src/channels/room-admission.ts +149 -0
  46. package/src/channels/transport-hints.ts +9 -5
  47. package/src/config.ts +1 -2
  48. package/src/db/__tests__/session-store.test.ts +18 -4
  49. package/src/db/connection.ts +10 -0
  50. package/src/db/contact-store.ts +1 -3
  51. package/src/db/session-store.ts +21 -15
  52. package/src/discord/admission-log.test.ts +15 -12
  53. package/src/discord/admission-log.ts +11 -95
  54. package/src/discord/admit.test.ts +8 -9
  55. package/src/discord/admit.ts +48 -93
  56. package/src/discord/forward.ts +1 -1
  57. package/src/discord/gateway-socket.ts +4 -2
  58. package/src/discord/normalize.test.ts +34 -1
  59. package/src/discord/normalize.ts +23 -3
  60. package/src/email/attachments.test.ts +40 -5
  61. package/src/email/attachments.ts +71 -67
  62. package/src/email/inbound-pipeline.test.ts +1 -0
  63. package/src/email/inbound-pipeline.ts +1 -1
  64. package/src/feature-flag-registry.json +16 -0
  65. package/src/handlers/handle-inbound.ts +3 -0
  66. package/src/http/browser-auth-cookies.ts +1 -5
  67. package/src/http/middleware/cors.ts +4 -2
  68. package/src/http/routes/email-webhook.ts +1 -1
  69. package/src/http/routes/log-export.ts +1 -1
  70. package/src/http/routes/telegram-webhook.test.ts +0 -1
  71. package/src/http/routes/telegram-webhook.ts +52 -23
  72. package/src/http/routes/twilio-media-websocket.ts +4 -19
  73. package/src/http/routes/whatsapp-webhook.test.ts +0 -3
  74. package/src/http/routes/whatsapp-webhook.ts +2 -7
  75. package/src/index.ts +4 -4
  76. package/src/ipc/debug-export-handlers.test.ts +103 -0
  77. package/src/ipc/debug-export-handlers.ts +81 -0
  78. package/src/risk/bash-risk-classifier.test.ts +81 -1
  79. package/src/risk/bash-risk-classifier.ts +1 -3
  80. package/src/risk/command-registry/commands/assistant.ts +1 -0
  81. package/src/risk/command-registry/commands/dig.ts +2 -1
  82. package/src/risk/command-registry/commands/host.ts +2 -1
  83. package/src/risk/command-registry/commands/mtr.ts +2 -1
  84. package/src/risk/command-registry/commands/nslookup.ts +2 -1
  85. package/src/risk/command-registry/commands/ping.ts +2 -1
  86. package/src/risk/command-registry/commands/tracepath.ts +2 -1
  87. package/src/risk/command-registry/commands/traceroute.ts +2 -1
  88. package/src/risk/risk-types.ts +1 -0
  89. package/src/risk/shell-parser.test.ts +105 -1
  90. package/src/risk/shell-parser.ts +111 -4
  91. package/src/runtime/client.ts +0 -122
  92. package/src/slack/message-normalizer.ts +14 -1
  93. package/src/slack/source-metadata.test.ts +5 -2
  94. package/src/slack/source-metadata.ts +2 -6
  95. package/src/telegram/admit.test.ts +155 -0
  96. package/src/telegram/admit.ts +149 -0
  97. package/src/telegram/api.ts +0 -33
  98. package/src/telegram/bot-identity.test.ts +124 -0
  99. package/src/telegram/bot-identity.ts +127 -0
  100. package/src/telegram/drop-log.ts +41 -0
  101. package/src/telegram/normalize.test.ts +343 -134
  102. package/src/telegram/normalize.ts +197 -197
  103. package/src/telegram/schemas.ts +217 -0
  104. package/src/telegram/send.test.ts +16 -117
  105. package/src/telegram/send.ts +4 -158
  106. package/src/velay/binary-websocket.test.ts +53 -0
  107. package/src/velay/binary-websocket.ts +46 -0
  108. package/src/velay/client.test.ts +73 -1
  109. package/src/velay/client.ts +11 -1
  110. package/src/velay/protocol.ts +18 -0
  111. package/src/velay/websocket-bridge.test.ts +181 -56
  112. package/src/velay/websocket-bridge.ts +21 -3
  113. package/src/verification/invite-redemption.ts +11 -17
  114. package/src/verification/reply-delivery.ts +49 -64
  115. package/src/verification/session-service.ts +15 -5
  116. package/src/verification/text-verification.ts +2 -2
  117. package/src/whatsapp/api.ts +0 -145
  118. package/src/whatsapp/download.ts +1 -0
  119. package/src/whatsapp/send.ts +1 -198
  120. package/src/__tests__/telegram-send-attachments.test.ts +0 -502
package/AGENTS.md CHANGED
@@ -51,7 +51,7 @@ Route-registration consequences (`gateway/src/index.ts`):
51
51
 
52
52
  - **Contacts** (`/v1/contacts...`, `/v1/contact-channels...`) are served on **flat routes only** — both boundaries deliver flat, so no assistant-scoped contact mirrors exist. The self-hosted flattening covers the **entire** `contacts`/`contact-channels` segment family, not just the CRUD writes: invites requests land on the gateway's flat edge-scoped invite routes (the same gateway invite engine the daemon relays to), and daemon-only subpaths (`contacts/search`, `contacts/prompt`) match no flat registration and fall through the runtime-proxy catch-all to the daemon verbatim.
53
53
  - **Other control-plane families** (channel-admission-policy, channel-permission-overrides, trust-rules, backups) register **flat + assistant-scoped variants**: the self-hosted rewrite forwards their paths verbatim, so a scoped mirror must catch that traffic. These stores are gateway-global — the scoped variant matches and discards the id.
54
- - **Invariant: never remove a flat gateway control-plane route.** Cloud's prefix strip means the flat family is the load-bearing production path even when repo-local clients appear to emit only scoped URLs. The flat channel-admission-policy routes were removed once on that mistaken theory (`2fb435314f`) and had to be restored before #35150 merged (`53fcfa7cc2` re-added the schema entries the removal took out).
54
+ - **Invariant: never remove a flat gateway control-plane route.** Cloud's prefix strip means the flat family is the load-bearing production path even when repo-local clients appear to emit only scoped URLs. The flat channel-admission-policy routes were removed once on that mistaken theory ([`2fb435314f`](https://github.com/vellum-ai/vellum-assistant/commit/2fb435314f)) and had to be restored before #35150 merged ([`53fcfa7cc2`](https://github.com/vellum-ai/vellum-assistant/commit/53fcfa7cc2) re-added the schema entries the removal took out); both commits are squashed out of `main`'s history, so they resolve on GitHub and not in a clone.
55
55
 
56
56
  ### Trust Management in Docker Mode
57
57
 
@@ -90,6 +90,8 @@ Gateway inbound events use a channel-discriminated union model (`GatewayInboundE
90
90
 
91
91
  Trust/guardian decisions must be keyed on `actorExternalId` only — never fall back to `conversationExternalId` for actor identity.
92
92
 
93
+ **Room admission is one verdict, two adapter halves.** Whether the gateway acts on an inbound message at all is decided by `channels/room-admission.ts` over a neutral candidate: self and bot authors drop first, a direct chat is admitted on its own lane without a mention, a room only when the message addresses the bot, a chat kind the product does not serve is refused, and an unknown bot identity drops rather than admits. Each channel builds the candidate from its own facts (`discord/admit.ts`, `telegram/admit.ts`): how the platform proves a chat is direct (guild absence, `chat.type`) and how it proves a message is addressed (a mentions array, text entities, a reply to the bot's post). Extend the verdict, never a copy of it; a platform-only rule rides a neutral fact (Discord's legacy allow-list is `roomAllowed`). A drop is never silent: every channel logs it through `channels/admission-drop-log.ts`, one promoted line per reason and conversation, with a drop that names no conversation promoted every time. Slack's socket filter predates this and still carries the same rules inline; converging it is queued with the Slack envelope work.
94
+
93
95
  Physical DB column names (`externalUserId`, `externalChatId`) are unchanged; the rename is at the API/type layer only.
94
96
 
95
97
  **Provider words that are not our words.** A Discord **guild** is what Discord's
@@ -193,7 +195,7 @@ Both the flat (`/v1/channel-admission-policy/...`) and assistant-scoped (`/v1/as
193
195
 
194
196
  - **Gateway kill switch** — `handle-inbound.ts` enforces the `no_one` floor before forwarding. Zero contact-table lookups, zero daemon I/O, true kill.
195
197
  - **Runtime floor** — every other policy flows through the gateway unchanged; the runtime evaluates rank-vs-floor inside `admission-policy.ts`. This keeps the canonical gateway classifier (`gateway/src/risk/trust-verdict-resolver.ts`) as the single source of `TrustClass` truth (no fork): the runtime consumes the stamped verdict; the daemon's `actor-trust-resolver.ts` is only a residual sync guardian-or-unknown view for the vellum reset-drift path.
196
- - **Gateway vs runtime reciprocity** — the gateway section in `gateway/CLAUDE.md` records _which channels the gateway enforces_; the assistant section records _how the runtime classifies_. Either side getting out of sync is a bug, not an over-defended boundary.
198
+ - **Gateway vs runtime reciprocity**: this section records _which channels the gateway enforces_; the runtime's evaluation lives in `assistant/src/runtime/routes/inbound-stages/admission-policy.ts`, which reads `TRUST_CLASS_RANK` and `ADMISSION_FLOOR` from the shared contract. Either side getting out of sync is a bug, not an over-defended boundary.
197
199
 
198
200
  **Adding a new policy**: extend the `AdmissionPolicy` union in `packages/gateway-client/src/admission-policy-contract.ts`, add its floor in `ADMISSION_FLOOR`, update the openapi schema, and update `gateway/src/__tests__/channel-admission-policy-routes.test.ts` + `assistant/src/runtime/routes/inbound-stages/admission-policy.test.ts`. Do not add a 6th floor without also bumping the `TRUST_CLASS_RANK` ceiling to match.
199
201
 
@@ -208,9 +210,9 @@ The gateway owns channel-permission matrix storage (`gateway/src/db/channel-perm
208
210
  - **Cascade, least → most specific:** `workspace` → `adapter` → `channel_type` (`dm | private | public`) → `channel` (external channel ID). `ChannelPermissionStore.resolve()` walks most-specific-first and returns the first cell set for the contact-type.
209
211
  - **Vocabulary contract:** `packages/gateway-client/src/channel-permission-contract.ts` (selectors, thresholds, scopes, resolve request). The contact-type axis is the canonical `TrustClass` — granularity intentionally stops there; do not add per-individual-contact cells.
210
212
  - **IPC surface:** `list_channel_permission_overrides`, `set_channel_permission_override`, `delete_channel_permission_override`, `resolve_channel_permission_threshold`. Writes validate the adapter against the gateway channel registry.
211
- - **HTTP surface (configuration clients):** `GET`/`PUT /v1/channel-permission-overrides` + `POST /v1/channel-permission-overrides/delete` + `POST /v1/channel-permission-overrides/resolve` (`gateway/src/http/routes/channel-permission-overrides.ts`), published in the gateway OpenAPI spec for the web SDK. Mirrors the IPC list/set/delete/resolve with the same contract schemas and adapter validation. Resolve over HTTP is read-only (`settings.read`) and exists so configuration clients can display the effective fall-through without re-implementing the cascade walk client-side (which drifts — the runtime resolves Slack rooms with no conversation type, so a client-side walk would apply `channel_type` cells the evaluator never matches); the runtime evaluator keeps using the IPC resolve. Flat + assistant-scoped variants, `settings.read`/`settings.write` scopes — same shape as channel-admission-policy.
213
+ - **HTTP surface (configuration clients):** `GET`/`PUT /v1/channel-permission-overrides` + `POST /v1/channel-permission-overrides/delete` + `POST /v1/channel-permission-overrides/resolve` (`gateway/src/http/routes/channel-permission-overrides.ts`), published in the gateway OpenAPI spec for the web SDK. Mirrors the IPC list/set/delete/resolve with the same contract schemas and adapter validation. Resolve over HTTP is read-only (`settings.read`) and exists so configuration clients can display the effective fall-through without re-implementing the cascade walk client-side (which drifts as the cascade evolves); the runtime evaluator keeps using the IPC resolve. Flat + assistant-scoped variants, `settings.read`/`settings.write` scopes: same shape as channel-admission-policy.
212
214
  - **Migration provenance:** `m0012-migrate-slack-channel-permissions` lifts Slack-skill `channelPermissions` profiles with `trustLevel: "restricted"` into channel-scoped Strict cells (non-guardian contact-types). Per-tool fields (`blockedTools` / `allowedToolCategories`) have no matrix representation — they stay in the Slack skill config, enforced by the legacy deterministic channel gate in `assistant/src/tools/tool-approval-handler.ts`.
213
- - **Runtime consumption (per-tool-call evaluation):** the assistant's permission checker (`assistant/src/permissions/checker.ts`) builds a resolve query from the turn's `PolicyContext` (adapter = source channel, conversation type, external channel ID, contact-type = trust class) and threads it into the threshold cascade in `assistant/src/permissions/gateway-threshold-reader.ts`. The cell sits between the per-conversation override (most specific) and the global defaults; the winning threshold feeds `DefaultApprovalPolicy.evaluate` as `autoApproveUpTo`, composing cell RiskThreshold × tool RiskLevel with the untouched capability floor (`resolveSensitiveToolDecision`). Fail-safe semantics: a cell transport failure falls through to global on the cached read, but the pre-prompt refresh keeps its prompt (returns null) rather than falling through to a possibly-looser global. Slack non-DMs resolve with no conversation type (the gateway forwards public and private alike as `"channel"`), so the `channel_type` tier only matches DMs/groups until the gateway forwards the distinction.
215
+ - **Runtime consumption (per-tool-call evaluation):** the assistant's permission checker (`assistant/src/permissions/checker.ts`) builds a resolve query from the turn's `PolicyContext` (adapter = source channel, conversation type, external channel ID, contact-type = trust class) and threads it into the threshold cascade in `assistant/src/permissions/gateway-threshold-reader.ts`. The cell sits between the per-conversation override (most specific) and the global defaults; the winning threshold feeds `DefaultApprovalPolicy.evaluate` as `autoApproveUpTo`, composing cell RiskThreshold × tool RiskLevel with the untouched capability floor (`resolveSensitiveToolDecision`). Fail-safe semantics: a cell transport failure falls through to global on the cached read, but the pre-prompt refresh keeps its prompt (returns null) rather than falling through to a possibly-looser global. Slack states `conversationType` for every room it can classify (`slackConversationVisibility` in `gateway/src/slack/message-normalizer.ts`: a public channel is `public`, a private channel or group DM is `private`, a DM is `dm`), so the `channel_type` tier matches Slack rooms as well as DMs.
214
216
 
215
217
  ### Trust Classes → Capabilities (what an actor may do)
216
218
 
@@ -219,7 +221,7 @@ Two orthogonal axes, do not conflate them:
219
221
  - **Admission** (above) — _who gets in the door_. `TRUST_CLASS_RANK` vs `ADMISSION_FLOOR`, enforced across gateway + runtime.
220
222
  - **Capabilities** — _what an actor may do once admitted_. Resolved in the runtime, never on the gateway.
221
223
 
222
- **Trust classes** (`TrustClass` in `assistant/src/runtime/trust-class.ts`, re-exported from `actor-trust-resolver.ts`) are the _role_, ranked by `TRUST_CLASS_RANK`:
224
+ **Trust classes** (`TrustClass` in `packages/gateway-client/src/trust-verdict-contract.ts`; the daemon imports it and never redeclares it) are the _role_, ranked by `TRUST_CLASS_RANK`:
223
225
 
224
226
  | Class | Rank | Meaning |
225
227
  | -------------------- | ---- | ------------------------------------------------------------------------ |
package/ARCHITECTURE.md CHANGED
@@ -282,7 +282,7 @@ Channel bindings follow a three-phase lifecycle:
282
282
 
283
283
  1. **Bind** — An inbound message from an external channel (e.g., Telegram chat) arrives at the gateway, which normalizes it and forwards it to the runtime's `/v1/channels/inbound` endpoint. The runtime creates or reuses a conversation, establishing the channel binding (`sourceChannel` metadata on the conversation).
284
284
 
285
- 2. **Route**: Subsequent messages on the same external chat are routed to the same conversation via the channel binding. Slack and Telegram are thread-scoped: a message that arrives in a Slack thread (including every message in a Slack agent DM, which Slack always delivers in a thread) or a Telegram topic resolves to that thread's own conversation, keyed on the chat plus the thread id (`assistant/src/persistence/delivery-crud.ts`, `buildScopedConversationKey`); a thread-less message resolves to the chat's base conversation. Replies from the assistant are delivered back through the gateway's `/deliver/telegram` endpoint. The desktop client filters out channel-bound conversations during conversation restoration (`ConversationRestorer`) so they never appear in the desktop conversation list.
285
+ 2. **Route**: Subsequent messages on the same external chat are routed to the same conversation via the channel binding. Slack and Telegram are thread-scoped: a message that arrives in a Slack thread (including every message in a Slack agent DM, which Slack always delivers in a thread) or a Telegram topic resolves to that thread's own conversation, keyed on the chat plus the thread id (`assistant/src/persistence/delivery-crud.ts`, `buildScopedConversationKey`); a thread-less message resolves to the chat's base conversation. Replies from the assistant go out through the daemon's channel transport for that channel (`assistant/src/messaging/providers`), which calls the provider's API directly; they never pass back through the gateway. The desktop client filters out channel-bound conversations during conversation restoration (`ConversationRestorer`) so they never appear in the desktop conversation list.
286
286
 
287
287
  3. **Rebind** — If a message arrives on an external chat whose conversation was previously deleted, the channel inbound handler treats it as a new conversation and establishes a fresh binding. The external chat ID is reused, but the conversation is new.
288
288
 
@@ -371,18 +371,18 @@ Telegram messages follow three paths through the system:
371
371
  Inbound (user → assistant):
372
372
  Telegram → Gateway POST /webhooks/telegram → verify secret → normalize → route
373
373
  → Runtime POST /v1/assistants/:id/channels/inbound
374
- (replyCallbackUrl = ${gatewayInternalBaseUrl}/deliver/telegram)
374
+ (replyCallbackUrl = ${gatewayInternalBaseUrl}/deliver/telegram[?threadId=<topic>])
375
375
 
376
376
  Outbound reply (assistant → user, triggered by inbound):
377
- Runtime callback → Gateway POST /deliver/telegram (bearer auth) → Telegram sendMessage/sendPhoto/sendDocument/sendChatAction
377
+ Daemon Telegram transport → Telegram Bot API sendMessage/sendPhoto/sendDocument/sendChatAction
378
378
 
379
- Outbound proactive (assistant → user, initiated by messaging provider):
380
- Runtime messaging provider → Gateway POST /deliver/telegram (bearer auth) → Telegram sendMessage/sendChatAction
379
+ Outbound proactive (assistant → user, messaging tool or POST /v1/channels/send):
380
+ Daemon Telegram transport → Telegram Bot API sendMessage
381
381
  ```
382
382
 
383
- The `replyCallbackUrl` included in the inbound forward is built from the `gatewayInternalBaseUrl` config field, which is always derived from `GATEWAY_PORT` as `http://127.0.0.1:${GATEWAY_PORT}` (default port `7830`). Both the hostname (`127.0.0.1`) and port derivation are hardcoded in `gateway/src/config.ts`, so the gateway and runtime must be co-located (same host, `--network host`, or Docker Compose with shared networking) for callbacks to reach the gateway. Separate-host deployments are not currently supported.
383
+ The gateway does not serve `/deliver/telegram`. The `replyCallbackUrl` it attaches to the inbound forward is an addressing token: the daemon resolves its path to the channel's transport (`channelForCallback` in `assistant/src/messaging/providers/callback-routing.ts`) and reads per-channel parameters from its query (the Telegram transport reads `threadId` to reply into the same topic). The daemon never dials the URL's host and port, which come from `gatewayInternalBaseUrl` (`http://127.0.0.1:${GATEWAY_PORT}`, `gateway/src/config.ts`). The gateway's own replies to invite and verification codes it intercepts at ingress go out the same way: `deliverVerificationReply` (`gateway/src/verification/reply-delivery.ts`) hands the reply and the inbound message's callback URL to the daemon's IPC-only `deliver_gateway_reply` method, which resolves the transport from that URL as it does for any reply. The gateway never sends those replies to a provider itself. The daemon's Telegram transport (`assistant/src/messaging/providers/telegram-bot/`) reads the bot token from credential storage and calls the Bot API itself.
384
384
 
385
- The `/deliver/telegram` endpoint requires bearer auth unconditionally (fail-closed). If no bearer token is configured and the dev-only bypass flag (`telegram.deliverAuthBypass` in `workspace/config.json`) is not set, the endpoint returns 503 rather than allowing unauthenticated access. The bypass requires `APP_VERSION=0.0.0-dev`.
385
+ The gateway sends to Telegram on its own only for notices it composes while handling the webhook, before or instead of a runtime turn: the `/start` acknowledgement, the "not fully set up" routing-rejection notice, a setup-hiccup notice when the forward fails, and the denial text the runtime returns when ingress ACL rejects the sender. These go through `sendTelegramReply` in `gateway/src/telegram/send.ts`.
386
386
 
387
387
  **Bot-account limitations:** The Telegram Bot API only supports sending messages to chats that have previously interacted with the bot. Bots cannot enumerate chats, read message history, or search messages. A future MTProto user-account session track may lift some of these restrictions.
388
388
 
@@ -403,8 +403,7 @@ The run transitions to `NeedsConfirmation` when the agent loop emits a `confirma
403
403
  ```
404
404
  Runtime detects needs_confirmation
405
405
  → runtime builds approval prompt + UI metadata
406
- → POST /deliver/telegram with `approval` payload
407
- → gateway renders inline keyboard (buttons: Approve once, Approve always, Reject)
406
+ → daemon Telegram transport sends the prompt with an inline keyboard (buttons: Approve once, Approve always, Reject)
408
407
  → user clicks button → Telegram callback_query
409
408
  → gateway normalizes callback_query into inbound event (callbackData field)
410
409
  → runtime parses callback data (format: apr:<requestId>:<action>)
@@ -431,19 +430,19 @@ Runtime detects needs_confirmation
431
430
 
432
431
  **Key modules:**
433
432
 
434
- | Module | Purpose |
435
- | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
436
- | `assistant/src/runtime/approval-conversation-turn.ts` | Conversational approval turn engine: LLM-based intent classification (structured output) for pending approval follow-ups, with fail-closed safety |
437
- | `assistant/src/runtime/approval-message-composer.ts` | Centralized approval message composition: layered source selection (assistant preface → deterministic fallback) for all approval/guardian/verification user-facing copy |
438
- | `assistant/src/runtime/channel-approvals.ts` | Orchestration: detect pending confirmations, build prompts (including guardian-aware prompts), apply decisions, plain-text fallback selection |
439
- | `assistant/src/runtime/channel-approval-types.ts` | Shared types: actions, prompts, UI metadata, decisions |
440
- | `assistant/src/runtime/routes/channel-routes.ts` | Integration point: approval interception, actor role resolution, guardian approval routing, deliver-once guard, fail-closed prompt delivery |
441
- | `assistant/src/runtime/channel-verification-service.ts` | Guardian binding lookups: `isGuardian()`, `getGuardianBinding()` |
442
- | `assistant/src/memory/delivery-channels.ts` | `claimRunDelivery()` — in-memory deliver-once guard for terminal reply idempotency |
443
- | `assistant/src/channels/gateway-guardian-requests.ts` | Typed daemon client for the gateway-owned `guardian_requests` lifecycle (`guardian_requests_create` / `_decide` / `_list_expired_pending` / `_expire`) |
444
- | `assistant/src/runtime/gateway-client.ts` | `deliverApprovalPrompt()` — sends approval payload to gateway |
445
- | `gateway/src/telegram/send.ts` | `buildInlineKeyboard()` — renders approval actions as Telegram inline buttons |
446
- | `gateway/src/telegram/normalize.ts` | `callback_query` normalization into `GatewayInboundEvent` (DM-only, drops callbacks without data) |
433
+ | Module | Purpose |
434
+ | -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
435
+ | `assistant/src/runtime/approval-conversation-turn.ts` | Conversational approval turn engine: LLM-based intent classification (structured output) for pending approval follow-ups, with fail-closed safety |
436
+ | `assistant/src/runtime/approval-message-composer.ts` | Centralized approval message composition: layered source selection (assistant preface → deterministic fallback) for all approval/guardian/verification user-facing copy |
437
+ | `assistant/src/runtime/channel-approvals.ts` | Orchestration: detect pending confirmations, build prompts (including guardian-aware prompts), apply decisions, plain-text fallback selection |
438
+ | `assistant/src/runtime/channel-approval-types.ts` | Shared types: actions, prompts, UI metadata, decisions |
439
+ | `assistant/src/runtime/routes/channel-routes.ts` | Integration point: approval interception, actor role resolution, guardian approval routing, deliver-once guard, fail-closed prompt delivery |
440
+ | `assistant/src/runtime/channel-verification-service.ts` | Guardian binding lookups: `isGuardian()`, `getGuardianBinding()` |
441
+ | `assistant/src/memory/delivery-channels.ts` | `claimRunDelivery()`: in-memory deliver-once guard for terminal reply idempotency |
442
+ | `assistant/src/channels/gateway-guardian-requests.ts` | Typed daemon client for the gateway-owned `guardian_requests` lifecycle (`guardian_requests_create` / `_decide` / `_list_expired_pending` / `_expire`) |
443
+ | `assistant/src/runtime/gateway-client.ts` | `deliverApprovalPrompt()`: hands the approval prompt to the channel transport the callback URL names |
444
+ | `assistant/src/messaging/providers/telegram-bot/send.ts` | `buildInlineKeyboard()`: renders approval actions as Telegram inline buttons |
445
+ | `gateway/src/telegram/normalize.ts` | `callback_query` normalization into `GatewayInboundEvent` (private chats, groups, and supergroups; drops callbacks without data) |
447
446
 
448
447
  ### Approval Message Composer
449
448
 
@@ -562,19 +561,19 @@ sequenceDiagram
562
561
  GW->>Daemon: POST /v1/channels/inbound (JWT auth)
563
562
  Daemon->>Daemon: Detect non-guardian, set forcePromptSideEffects
564
563
  Daemon->>Daemon: Tool needs confirmation → create GuardianApprovalRequest
565
- Daemon->>GW: POST /deliver/telegram (approval prompt + inline keyboard)
566
- GW->>Guardian: sendMessage (approval prompt)
567
- Daemon->>GW: POST /deliver/telegram (requester notification)
568
- GW-->>NG: "Waiting for guardian approval..."
564
+ Daemon->>TG: sendMessage via Telegram transport (approval prompt + inline keyboard)
565
+ TG-->>Guardian: Approval prompt
566
+ Daemon->>TG: sendMessage via Telegram transport (requester notification)
567
+ TG-->>NG: "Waiting for guardian approval..."
569
568
  Guardian->>TG: Approve / Deny (callback_query or text)
570
569
  TG->>GW: POST /webhooks/telegram (callback_query)
571
570
  GW->>Daemon: POST /v1/channels/inbound (JWT auth)
572
571
  Daemon->>Daemon: Validate guardian identity, update approval decision
573
572
  Daemon->>Daemon: Apply decision to pending run
574
- Daemon->>GW: POST /deliver/telegram (outcome notification)
575
- GW-->>NG: "Guardian approved/denied your request"
576
- Daemon->>GW: POST /deliver/telegram (confirmation)
577
- GW-->>Guardian: Confirmation of decision
573
+ Daemon->>TG: sendMessage via Telegram transport (outcome notification)
574
+ TG-->>NG: "Guardian approved/denied your request"
575
+ Daemon->>TG: sendMessage via Telegram transport (confirmation)
576
+ TG-->>Guardian: Confirmation of decision
578
577
  ```
579
578
 
580
579
  Approval state lives in the gateway's `guardian_requests` table (kind `tool_approval`), with per-surface card deliveries in `guardian_request_deliveries`. Each request records the requester, guardian, tool name, risk level, and decision outcome; decisions commit atomically via the gateway's `guardian_requests_decide` IPC route.
@@ -732,14 +731,11 @@ The Slack channel enables inbound and outbound messaging via Slack's Socket Mode
732
731
  3. Events are deduplicated by a compound key in the SQLite-backed `slack_seen_events` table: every event records its Slack `event_id`, and message-shaped events additionally record `msg:${channel}:${ts}` so the live and reconnect-replay paths dedup symmetrically. Entries TTL out after 24h; a periodic cleanup sweep evicts expired rows.
733
732
  4. The `normalizeSlackAppMention()` function strips leading bot-mention tokens (`<@U...>`) from the message text and produces a `GatewayInboundEvent` with `sourceChannel: "slack"`, using the Slack channel ID as `conversationExternalId` and the sender's user ID as `actorExternalId`.
734
733
  5. Routing uses the standard `resolveAssistant()` chain (conversation_id -> actor_id -> default/reject). Events that cannot be routed are dropped.
735
- 6. The normalized event is forwarded to the runtime via `POST /v1/channels/inbound` with a `replyCallbackUrl` pointing to `/deliver/slack`.
734
+ 6. The normalized event is forwarded to the runtime via `POST /v1/channels/inbound` with a `replyCallbackUrl` of `/deliver/slack?channel=<id>`, plus `threadTs` for a threaded message or `messageTs` for a thread-less message.
736
735
 
737
- **Egress** (`POST /deliver/slack`):
736
+ **Egress:**
738
737
 
739
- 1. The runtime calls the gateway's `/deliver/slack` endpoint with `{ chatId, text }` or `{ to, text }` (alias). The `chatId` field maps to the Slack channel ID where the reply should be posted.
740
- 2. The gateway authenticates the request via bearer token (same fail-closed model as other deliver endpoints).
741
- 3. The gateway posts the message via `POST https://slack.com/api/chat.postMessage` using the bot token.
742
- 4. Threading is supported via a `threadTs` query parameter on the deliver URL. When present, replies are posted as thread replies to the specified message timestamp.
738
+ The gateway does not serve `/deliver/slack`; for the daemon the callback URL only addresses the reply (the gateway's intercepted-code replies reach the same transport through the daemon, see Telegram Messaging Flow). The daemon's Slack transport (`assistant/src/messaging/providers/slack/`) calls the Slack Web API itself with the bot token: `chat.postMessage` and `chat.update` for whole messages, and `chat.startStream` / `chat.appendStream` / `chat.stopStream` for streamed replies. It reads `threadTs` from the callback URL to reply in the thread and `messageTs` to anchor the busy indicator on a thread-less message.
743
739
 
744
740
  **Credential management:**
745
741
 
@@ -773,7 +769,7 @@ Any persistent-stream transport that does not buffer events for disconnected cli
773
769
  | `gateway/src/slack/socket-mode.ts` | `SlackSocketModeClient`: WebSocket lifecycle, ACK, dedup, auto-reconnect, reconnect catch-up |
774
770
  | `gateway/src/slack/slack-web.ts` | `conversations.history` / `conversations.replies` helpers for reconnect catch-up |
775
771
  | `gateway/src/slack/message-normalizer.ts` | Normalizers per event family (`normalizeSlackAppMention()`, DM, group DM, channel message) with bot-mention stripping |
776
- | `gateway/src/index.ts` | `/deliver/slack` route: outbound message delivery via `chat.postMessage`, thread and message ts on the callback URL |
772
+ | `gateway/src/index.ts` | Builds the `/deliver/slack` callback URL (channel, thread ts, message ts) the daemon's Slack transport replies to |
777
773
 
778
774
  **What the ingress does not do:** it never forwards the bot's own posts to the daemon (except a deletion of one, which the daemon records), and it never reads history on the daemon's behalf beyond the bounded reconnect catch-up above; the daemon's inbound-triggered backfill hydrates context.
779
775
 
@@ -846,7 +842,7 @@ sequenceDiagram
846
842
  Ctrl->>CallStore: createPendingQuestion()
847
843
  Ctrl->>GuardianDispatch: dispatchGuardianQuestion()
848
844
  GuardianDispatch->>Mac: notification_conversation_created SSE
849
- GuardianDispatch->>TG: POST /deliver/{channel}
845
+ GuardianDispatch->>TG: sendMessage via notification pipeline
850
846
  Note over Mac,TG: First channel to respond wins
851
847
  Mac/TG->>Routes: guardian answer
852
848
  Routes->>CallDomain: answerCall()
package/README.md CHANGED
@@ -58,19 +58,6 @@ For manual setup (or reference), register the webhook with Telegram using the `s
58
58
 
59
59
  See the [Telegram Bot API docs](https://core.telegram.org/bots/api#setwebhook) for the full API reference.
60
60
 
61
- ## Telegram Deliver Endpoint Security
62
-
63
- The `/deliver/telegram` endpoint requires bearer auth by default (fail-closed). The security behavior is:
64
-
65
- | Condition | Result |
66
- | ----------------------------------------------------------------------------------------- | -------------------------- |
67
- | Bearer token configured + valid `Authorization` header | Request allowed |
68
- | Bearer token configured + missing/invalid `Authorization` header | 401 Unauthorized |
69
- | No bearer token configured + `telegram.deliverAuthBypass=true` in `workspace/config.json` | Request allowed (dev-only) |
70
- | No bearer token configured + bypass not set | 503 Service Not Configured |
71
-
72
- This ensures that misconfiguration cannot expose an unauthenticated public message-send surface. In production, ensure JWT authentication is properly configured. The `telegram.deliverAuthBypass` config flag (in `workspace/config.json`) is intended for local development only and requires `APP_VERSION=0.0.0-dev`.
73
-
74
61
  ## Voice Ingress — Inbound Calls (Twilio)
75
62
 
76
63
  The `/webhooks/twilio/voice` endpoint handles both outbound and inbound voice calls. For **outbound** calls (initiated by the assistant via `call_start`), the voice webhook URL includes a `callSessionId` query parameter that identifies the pre-created session. For **inbound** calls (someone dialing the assistant's Twilio phone number), no `callSessionId` is present — the gateway resolves the target assistant and the runtime creates a session on the fly.
@@ -106,15 +93,15 @@ The gateway normalizes Telegram `callback_query` updates (inline button clicks)
106
93
 
107
94
  These fields are forwarded to the runtime in the `/channels/inbound` payload alongside the standard `conversationExternalId`, `externalMessageId`, and actor metadata. The runtime uses `callbackData` to route the click to the appropriate approval handler.
108
95
 
109
- **Normalization constraints:** Only DM-only (`private` chat type) callback queries are processed. Group and channel callbacks are dropped and acknowledged with `answerCallbackQuery` so the Telegram button spinner clears. Callback queries with no `data` field or no associated `message` are also dropped.
96
+ **Normalization constraints:** Callback queries from private chats, groups, and supergroups are processed; a tap on a keyboard the bot posted is addressed to the bot by construction, and who may press it is the runtime's decision. Callbacks from broadcast channels are dropped and acknowledged with `answerCallbackQuery` so the Telegram button spinner clears. Callback queries with no `data` field or no associated `message` are also dropped. Every drop is logged with its reason before the update is acknowledged (`telegram/drop-log.ts`).
110
97
 
111
98
  **Stale callback blocking:** When the runtime receives `callbackData` that does not match any pending approval (e.g., a button from an old prompt), it returns `stale_ignored` and does not process the payload as a regular message. This is enforced regardless of whether the callback has non-empty content. The gateway sends a best-effort `answerCallbackQuery` acknowledgment for normalized callback updates (including stale, rejected, and forward-failure paths) so the button spinner clears promptly. Transient forwarding failures may still return `500` so Telegram retries update delivery.
112
99
 
113
100
  ## Approval Buttons and Inline Keyboard
114
101
 
115
- The `/deliver/telegram` endpoint accepts an optional `approval` field in the request body. When present, the gateway renders Telegram inline keyboard buttons below the message text.
102
+ The gateway does not send approval prompts. The assistant's Telegram transport (`assistant/src/messaging/providers/telegram-bot/`) sends them to the Bot API directly, and when the reply payload carries an `approval` field it renders Telegram inline keyboard buttons below the message text. The gateway's part is the return trip: it normalizes the button press (`callback_query`) and forwards it as described above.
116
103
 
117
- **Approval payload shape:**
104
+ **Approval reply payload shape:**
118
105
 
119
106
  ```json
120
107
  {
@@ -134,7 +121,7 @@ The `/deliver/telegram` endpoint accepts an optional `approval` field in the req
134
121
 
135
122
  **Inline keyboard format:** Each action is rendered as a single-button row. The callback data uses the compact format `apr:<requestId>:<action>` (e.g., `apr:request-uuid:approve_once`) so the runtime can parse it back when the button is clicked.
136
123
 
137
- **Fallback behavior:** For non-rich channels that do not support inline keyboards, the runtime substitutes the `plainTextFallback` string for the structured `promptText` before calling the delivery endpoint. The fallback includes plain-text instructions so the user can respond via text. The `supportsInlineOptions` channel capability (`channelSupportsInlineOptions()`) in the runtime determines which format to use. Free-text responses are classified by the conversational approval engine.
124
+ **Fallback behavior:** For non-rich channels that do not support inline keyboards, the runtime substitutes the `plainTextFallback` string for the structured `promptText` before handing the prompt to the channel transport. The fallback includes plain-text instructions so the user can respond via text. The `supportsInlineOptions` channel capability (`channelSupportsInlineOptions()`) in the runtime determines which format to use. Free-text responses are classified by the conversational approval engine.
138
125
 
139
126
  ## Public Ingress Routes
140
127
 
@@ -145,7 +132,6 @@ Control-plane routes are listed with their flat paths. Clients emit assistant-sc
145
132
  | Route | Method | Description |
146
133
  | ----------------------------------------------------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
147
134
  | `/webhooks/telegram` | POST | Telegram bot webhook (validated via `TELEGRAM_WEBHOOK_SECRET`) |
148
- | `/deliver/telegram` | POST | Internal endpoint for the assistant runtime to deliver outbound messages/attachments to Telegram chats |
149
135
  | `/webhooks/twilio/voice` | POST | Twilio voice webhook (validated via HMAC-SHA1 signature) |
150
136
  | `/webhooks/twilio/status` | POST | Twilio status callback (validated via HMAC-SHA1 signature) |
151
137
  | `/webhooks/twilio/media-stream/:callSessionId/:token` | WS | Twilio Media Streams WebSocket (bidirectional proxy to runtime; handshake metadata in URL path segments) |
@@ -304,15 +290,13 @@ curl -i -X POST http://localhost:7830/webhooks/telegram
304
290
 
305
291
  ## Outbound Attachments (Telegram)
306
292
 
307
- When the assistant includes attachments in a reply, the gateway downloads each attachment from the runtime API and delivers it to the Telegram chat:
293
+ The gateway does not send attachments. When a reply carries attachments, the assistant's Telegram transport (`assistant/src/messaging/providers/telegram-bot/`) reads each one from the assistant's attachment store and uploads it to the Bot API itself:
308
294
 
309
- - **Images** (`image/*` MIME types) are sent via `sendPhoto` (multipart form upload).
310
- - **Other files** are sent via `sendDocument` (multipart form upload).
311
- - **Oversized** attachments (exceeding the hardcoded max attachment size, default 20 MB) are skipped and included in the partial-failure notice.
312
- - **Partial failures** are handled gracefully: each attachment is attempted independently. If any fail, a single summary notice is sent to the chat listing the undelivered filenames.
313
- - **Concurrency** is controlled by a hardcoded max concurrency limit (default 3).
295
+ - **Images** (`image/jpeg`, `image/png`, `image/gif`, `image/webp`) within the Bot API's 10 MB photo upload limit are sent via `sendPhoto`; **larger images and other files** via `sendDocument`.
296
+ - **Oversized** attachments (over the 50 MB `sendDocument` limit) are skipped.
297
+ - **Partial failures**: each attachment is attempted on its own, and if any fail, one summary notice lists the undelivered filenames.
314
298
 
315
- Text and attachments are sent separately — the text reply goes first via `sendMessage`, then each attachment follows.
299
+ The text reply goes first via `sendMessage`, then each attachment follows in order.
316
300
 
317
301
  ## Health & Readiness Probes
318
302
 
@@ -385,10 +369,24 @@ See [`benchmarking/gateway/README.md`](../benchmarking/gateway/README.md) for lo
385
369
 
386
370
  ### Guardian-Specific Troubleshooting
387
371
 
388
- | Symptom | Cause | Resolution |
389
- | -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
390
- | Guardian verification code reply gets no response | The verification message did not reach the runtime, or the challenge expired | Ensure the gateway is running, the bot token is valid, and the Telegram webhook is registered. Challenges expire after 10 minutes -- generate a new one via the desktop UI. |
391
- | Non-guardian actions auto-denied with "no guardian configured" | No guardian binding exists for the channel. The runtime is fail-closed for unverified channels. | Set up a guardian by running the verification flow from the desktop UI. |
392
- | Approval prompt not delivered to guardian | The `replyCallbackUrl` may be unreachable, or the guardian's chat ID is stale | Verify `GATEWAY_PORT` is correct and the gateway is reachable at `http://127.0.0.1:<GATEWAY_PORT>` from the runtime (requires co-located networking in containerized deployments). Re-verify the guardian if the chat ID has changed. |
393
- | Guardian approval expired | The 30-minute TTL elapsed without a decision. A proactive sweep (every 60s) auto-denied the approval and notified both the requester and guardian. | The non-guardian user must re-trigger the action. |
394
- | "Only the verified guardian can approve or deny" | A non-guardian sender attempted to respond to a guardian approval prompt | Only the guardian whose `actorExternalId` matches the approval request can approve or deny. |
372
+ | Symptom | Cause | Resolution |
373
+ | -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
374
+ | Guardian verification code reply gets no response | The code never reached the gateway, the challenge expired, or the assistant could not send the gateway's reply | Ensure the gateway is running, the bot token is valid, and the Telegram webhook is registered. Search the gateway log for `Verification reply`: a reply the assistant could not send does not undo the verification, so check the binding with the `channel-verification-sessions status` CLI command. Challenges expire after 10 minutes -- generate a new one via the desktop UI. |
375
+ | Non-guardian actions auto-denied with "no guardian configured" | No guardian binding exists for the channel. The runtime is fail-closed for unverified channels. | Set up a guardian by running the verification flow from the desktop UI. |
376
+ | Approval prompt not delivered to guardian | The assistant's channel transport could not send it (for example a missing or invalid bot token), or the guardian's chat ID is stale | Check the assistant log for the transport's delivery error and confirm the channel's bot token is configured. For a channel with a transport the assistant never dials the `replyCallbackUrl`, so gateway reachability is not the cause. Re-verify the guardian if the chat ID has changed. |
377
+ | Guardian approval expired | The 30-minute TTL elapsed without a decision. A proactive sweep (every 60s) auto-denied the approval and notified both the requester and guardian. | The non-guardian user must re-trigger the action. |
378
+ | "Only the verified guardian can approve or deny" | A non-guardian sender attempted to respond to a guardian approval prompt | Only the guardian whose `actorExternalId` matches the approval request can approve or deny. |
379
+
380
+ ### Binary virtual desktop tunnel
381
+
382
+ The gateway advertises `X-Vellum-Velay-Binary-WebSocket: 1` when registering.
383
+ It uses binary tunnel messages only when Velay sends `binary_messages: true`
384
+ on a `/v1/desktop/stream` open frame. The envelope is byte `0x01`, 32 lowercase
385
+ ASCII hex connection-ID bytes, then the unchanged payload (including empty
386
+ payloads). Control and text messages remain JSON. Other routes and older relays
387
+ retain JSON/base64 framing. Deploy Velay support before updating gateways;
388
+ older gateways can continue using the upgraded relay.
389
+
390
+ Malformed tunnel messages retain the existing log-and-ignore behavior. Raw binary
391
+ frames addressed to streams without desktop negotiation are ignored, preserving
392
+ other active streams on the shared tunnel.
@@ -27,7 +27,8 @@
27
27
  "./reactions": "./src/reactions.ts",
28
28
  "./guardian-requests": "./src/guardian-requests.ts",
29
29
  "./platform-credential": "./src/platform-credential.ts",
30
- "./plan-credit": "./src/plan-credit.ts"
30
+ "./plan-credit": "./src/plan-credit.ts",
31
+ "./integration-categories": "./src/integration-categories.ts"
31
32
  },
32
33
  "scripts": {
33
34
  "typecheck": "bunx tsc --noEmit",
@@ -63,6 +63,7 @@ const FIXTURES: Record<string, string> = {
63
63
  "Perplexity API Key": `pplx-${filler(40)}`,
64
64
  "Tavily API Key": `tvly-${filler(20)}`,
65
65
  "Firecrawl API Key": `fc-${filler(20)}`,
66
+ "Resend API Key": `re_${filler(8)}_${filler(24)}`,
66
67
  };
67
68
 
68
69
  describe("subpath export", () => {
@@ -29,6 +29,7 @@ export * from "./trust-rules.js";
29
29
  export * from "./ingress.js";
30
30
  export * from "./no-response.js";
31
31
  export * from "./plan-credit.js";
32
+ export * from "./integration-categories.js";
32
33
  export * from "./platform-credential.js";
33
34
  export * from "./remote-web-pairing.js";
34
35
  export * from "./twilio-ingress.js";
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Category vocabulary for the integrations catalog, shared by the assistant
3
+ * daemon (which stamps it on OAuth providers and marketplace MCP entries) and
4
+ * the clients (which draw the category filter from it).
5
+ *
6
+ * The order here is the order clients show the categories in. The slug is the
7
+ * wire value; the user-facing label is the client's, so it can be translated.
8
+ */
9
+ export const INTEGRATION_CATEGORIES = [
10
+ "productivity",
11
+ "communication",
12
+ "meetings",
13
+ "sales",
14
+ "marketing",
15
+ "finance",
16
+ "commerce",
17
+ "engineering",
18
+ "knowledge",
19
+ "recruiting",
20
+ ] as const;
21
+
22
+ export type IntegrationCategory = (typeof INTEGRATION_CATEGORIES)[number];
23
+
24
+ const INTEGRATION_CATEGORY_SET: ReadonlySet<string> = new Set(
25
+ INTEGRATION_CATEGORIES,
26
+ );
27
+
28
+ /**
29
+ * Whether `value` names a category this build knows. A client reading a newer
30
+ * catalog treats an unknown slug as uncategorized rather than failing.
31
+ */
32
+ export function isIntegrationCategory(
33
+ value: unknown,
34
+ ): value is IntegrationCategory {
35
+ return typeof value === "string" && INTEGRATION_CATEGORY_SET.has(value);
36
+ }
@@ -230,6 +230,9 @@ export const PREFIX_PATTERNS: SecretPrefixPattern[] = [
230
230
 
231
231
  // -- Firecrawl --
232
232
  { label: "Firecrawl API Key", regex: /fc-[A-Za-z0-9]{20,}/ },
233
+ // Resend documents only the `re_` prefix; the segment shape is the observed
234
+ // key format. Strict on purpose: bare `re_` prefixes ordinary identifiers.
235
+ { label: "Resend API Key", regex: /re_[A-Za-z0-9]{8}_[A-Za-z0-9]{24,}/ },
233
236
  ];
234
237
 
235
238
  /**
@@ -362,7 +365,7 @@ export function isPlaceholderValue(value: string): boolean {
362
365
  // Strip known prefixes to isolate the variable part
363
366
  const variablePart = value
364
367
  .replace(
365
- /^(?:AKIA|gh[pousr]_|github_pat_|glpat-|sk_live_|rk_live_|xoxb-|xoxp-|xapp-|sk-ant-|sk-proj-|sk-or-v1-|AIza|GOCSPX-|SK|SG\.|npm_|pypi-|key-|lin_api_|ntn_|fw_|pplx-|-----BEGIN [A-Z ]*PRIVATE KEY-----)/,
368
+ /^(?:AKIA|gh[pousr]_|github_pat_|glpat-|sk_live_|rk_live_|xoxb-|xoxp-|xapp-|sk-ant-|sk-proj-|sk-or-v1-|AIza|GOCSPX-|SK|SG\.|npm_|pypi-|key-|lin_api_|ntn_|fw_|pplx-|re_|-----BEGIN [A-Z ]*PRIVATE KEY-----)/,
366
369
  "",
367
370
  )
368
371
  .replace(/[^A-Za-z0-9]/g, "");
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
2
2
 
3
3
  import {
4
4
  buildShellInvocation,
5
+ buildShellSpawnFlags,
5
6
  pathListDelimiter,
6
7
  prependUniquePathEntries,
7
8
  } from "./shell.js";
@@ -38,6 +39,26 @@ describe("buildShellInvocation", () => {
38
39
  });
39
40
  });
40
41
 
42
+ describe("buildShellSpawnFlags", () => {
43
+ test("creates a POSIX process group and hides Windows consoles", () => {
44
+ expect(buildShellSpawnFlags("linux")).toEqual({
45
+ detached: true,
46
+ windowsHide: true,
47
+ });
48
+ expect(buildShellSpawnFlags("darwin")).toEqual({
49
+ detached: true,
50
+ windowsHide: true,
51
+ });
52
+ });
53
+
54
+ test("does not detach Windows children that use piped stdio", () => {
55
+ expect(buildShellSpawnFlags("win32")).toEqual({
56
+ detached: false,
57
+ windowsHide: true,
58
+ });
59
+ });
60
+ });
61
+
41
62
  describe("path list handling", () => {
42
63
  test("uses the platform delimiter", () => {
43
64
  expect(pathListDelimiter("win32")).toBe(";");
@@ -5,6 +5,11 @@ export interface ShellInvocation {
5
5
  args: string[];
6
6
  }
7
7
 
8
+ export interface ShellSpawnFlags {
9
+ detached: boolean;
10
+ windowsHide: true;
11
+ }
12
+
8
13
  const WINDOWS_UTF8_PREAMBLE =
9
14
  "try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch {}; " +
10
15
  "$OutputEncoding = [System.Text.Encoding]::UTF8; " +
@@ -43,6 +48,25 @@ export function buildShellInvocation(
43
48
  return { command: "bash", args: ["-c", "--", command] };
44
49
  }
45
50
 
51
+ /**
52
+ * Spawn flags for assistant-owned shell children (sandbox bash, local
53
+ * host_bash fallback, sanitized CLI bash, skill runners, scheduled scripts).
54
+ *
55
+ * POSIX uses a new process group so timeout/abort can SIGKILL the tree via
56
+ * `-pid`. Windows process trees are torn down with `taskkill /T`, which does
57
+ * not need a detached process. Combining `DETACHED_PROCESS`,
58
+ * `CREATE_NO_WINDOW`, and piped stdio on Windows can emit `close` with exit
59
+ * 0 and empty pipes without running the encoded command.
60
+ */
61
+ export function buildShellSpawnFlags(
62
+ hostPlatform: NodeJS.Platform = process.platform,
63
+ ): ShellSpawnFlags {
64
+ return {
65
+ detached: hostPlatform !== "win32",
66
+ windowsHide: true,
67
+ };
68
+ }
69
+
46
70
  export function pathListDelimiter(
47
71
  hostPlatform: NodeJS.Platform = process.platform,
48
72
  ): string {
@@ -27,7 +27,8 @@
27
27
  "./reactions": "./src/reactions.ts",
28
28
  "./guardian-requests": "./src/guardian-requests.ts",
29
29
  "./platform-credential": "./src/platform-credential.ts",
30
- "./plan-credit": "./src/plan-credit.ts"
30
+ "./plan-credit": "./src/plan-credit.ts",
31
+ "./integration-categories": "./src/integration-categories.ts"
31
32
  },
32
33
  "scripts": {
33
34
  "typecheck": "bunx tsc --noEmit",
@@ -63,6 +63,7 @@ const FIXTURES: Record<string, string> = {
63
63
  "Perplexity API Key": `pplx-${filler(40)}`,
64
64
  "Tavily API Key": `tvly-${filler(20)}`,
65
65
  "Firecrawl API Key": `fc-${filler(20)}`,
66
+ "Resend API Key": `re_${filler(8)}_${filler(24)}`,
66
67
  };
67
68
 
68
69
  describe("subpath export", () => {
@@ -29,6 +29,7 @@ export * from "./trust-rules.js";
29
29
  export * from "./ingress.js";
30
30
  export * from "./no-response.js";
31
31
  export * from "./plan-credit.js";
32
+ export * from "./integration-categories.js";
32
33
  export * from "./platform-credential.js";
33
34
  export * from "./remote-web-pairing.js";
34
35
  export * from "./twilio-ingress.js";
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Category vocabulary for the integrations catalog, shared by the assistant
3
+ * daemon (which stamps it on OAuth providers and marketplace MCP entries) and
4
+ * the clients (which draw the category filter from it).
5
+ *
6
+ * The order here is the order clients show the categories in. The slug is the
7
+ * wire value; the user-facing label is the client's, so it can be translated.
8
+ */
9
+ export const INTEGRATION_CATEGORIES = [
10
+ "productivity",
11
+ "communication",
12
+ "meetings",
13
+ "sales",
14
+ "marketing",
15
+ "finance",
16
+ "commerce",
17
+ "engineering",
18
+ "knowledge",
19
+ "recruiting",
20
+ ] as const;
21
+
22
+ export type IntegrationCategory = (typeof INTEGRATION_CATEGORIES)[number];
23
+
24
+ const INTEGRATION_CATEGORY_SET: ReadonlySet<string> = new Set(
25
+ INTEGRATION_CATEGORIES,
26
+ );
27
+
28
+ /**
29
+ * Whether `value` names a category this build knows. A client reading a newer
30
+ * catalog treats an unknown slug as uncategorized rather than failing.
31
+ */
32
+ export function isIntegrationCategory(
33
+ value: unknown,
34
+ ): value is IntegrationCategory {
35
+ return typeof value === "string" && INTEGRATION_CATEGORY_SET.has(value);
36
+ }
@@ -230,6 +230,9 @@ export const PREFIX_PATTERNS: SecretPrefixPattern[] = [
230
230
 
231
231
  // -- Firecrawl --
232
232
  { label: "Firecrawl API Key", regex: /fc-[A-Za-z0-9]{20,}/ },
233
+ // Resend documents only the `re_` prefix; the segment shape is the observed
234
+ // key format. Strict on purpose: bare `re_` prefixes ordinary identifiers.
235
+ { label: "Resend API Key", regex: /re_[A-Za-z0-9]{8}_[A-Za-z0-9]{24,}/ },
233
236
  ];
234
237
 
235
238
  /**
@@ -362,7 +365,7 @@ export function isPlaceholderValue(value: string): boolean {
362
365
  // Strip known prefixes to isolate the variable part
363
366
  const variablePart = value
364
367
  .replace(
365
- /^(?:AKIA|gh[pousr]_|github_pat_|glpat-|sk_live_|rk_live_|xoxb-|xoxp-|xapp-|sk-ant-|sk-proj-|sk-or-v1-|AIza|GOCSPX-|SK|SG\.|npm_|pypi-|key-|lin_api_|ntn_|fw_|pplx-|-----BEGIN [A-Z ]*PRIVATE KEY-----)/,
368
+ /^(?:AKIA|gh[pousr]_|github_pat_|glpat-|sk_live_|rk_live_|xoxb-|xoxp-|xapp-|sk-ant-|sk-proj-|sk-or-v1-|AIza|GOCSPX-|SK|SG\.|npm_|pypi-|key-|lin_api_|ntn_|fw_|pplx-|re_|-----BEGIN [A-Z ]*PRIVATE KEY-----)/,
366
369
  "",
367
370
  )
368
371
  .replace(/[^A-Za-z0-9]/g, "");