From cb7b00cf9bfb72709facd8b3af3e44a82dbc36a5 Mon Sep 17 00:00:00 2001 From: David McKay Date: Fri, 2 Oct 2026 12:28:50 -0700 Subject: [PATCH] Configure Slack, Teams, SMS, push, SCIM, inbound email and OTel in the Helm chart These settings had no chart values and needed config.extraEnv. They now have config.opentag, config.sms, config.push, config.deliveryPublicUrl, config.scim, config.inboundEmail and config.otel, with their secrets under secrets (or an existing Secret or store, by key). The install refuses what the server refuses at boot: a switch with no secret behind it, an OpenTag secret under 32 characters, an OpenTag URL or icon with the pairing off or of the wrong scheme, a partial set of Twilio settings, a push project id that is not a UUID, and a deliveryPublicUrl that is not http(s). The CI refusal step exercises them on every target. With none set, every CI target renders byte for byte as before. --- .github/workflows/ci.yml | 17 ++++ CHANGELOG.md | 11 +++ charts/openbot/README.md | 35 +++++++ charts/openbot/templates/_helpers.tpl | 114 +++++++++++++++++++++++ charts/openbot/templates/secret.yaml | 25 +++++ charts/openbot/templates/validation.yaml | 61 ++++++++++++ charts/openbot/values.yaml | 81 ++++++++++++++++ 7 files changed, 344 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4f2d7bb8e..06ea57441 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -218,6 +218,23 @@ jobs: --set networkPolicy.enabled=true \ --set computers.extraEnv[0].name=EGRESS_PROXY_DEFAULT \ --set-string computers.extraEnv[0].value=http://proxy.internal:3128 + # A delivery or directory switch with no secret behind it. Armed on every target: the chart + # requires the value when it holds the secret, and requires the store to name the key when + # a store does. + refuses "OpenTag switched on with no shared secret" \ + --set config.opentag.enabled=true + refuses "SCIM switched on with no bearer token" \ + --set config.scim.enabled=true + # The rest are refusals the server makes at boot, mirrored so the value is named at install. + refuses "an OpenTag URL with the pairing switched off" \ + --set-string config.opentag.url=https://opentag.example.com + refuses "a partial set of Twilio settings" \ + --set config.sms.enabled=true --set-string secrets.twilioAuthToken=token \ + --set-string config.sms.accountSid=AC0 --set-string config.sms.verifyServiceSid=VA0 + refuses "a push project id that is not a UUID" \ + --set-string config.push.projectId=not-a-uuid + refuses "a delivery public URL that is not http(s)" \ + --set-string config.deliveryPublicUrl=hooks.example.com # A warm pool nothing claims from. Only meaningful where the target asks for per-Bot # computers; on the others the mode is not sandbox and the refusal is not armed. if grep -qE '^ *mode: sandbox' charts/openbot/ci/${{ matrix.target }}-values.yaml; then diff --git a/CHANGELOG.md b/CHANGELOG.md index c7d9d9df8..282c64404 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,17 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. ## Unreleased +### The Helm chart configures Slack, Teams, text messages, push, SCIM, inbound email and OpenTelemetry + +These settings had no chart values and could only be passed through `config.extraEnv`. They now have +their own: `config.opentag`, `config.sms`, `config.push`, `config.deliveryPublicUrl`, `config.scim`, +`config.inboundEmail` and `config.otel`, with the OpenTag secret, the Twilio auth token, the Expo +access token, the SCIM bearer tokens and the OpenTelemetry headers under `secrets` (or an existing +Secret or store, by key). The install refuses what the server would refuse at boot, such as an +OpenTag secret under 32 characters or a partial set of Twilio settings. With none of them set, the +chart renders exactly as before, so a deployment already passing these through `config.extraEnv` +keeps working unchanged until it moves them across. + ### A channel cursor with a malformed time reads as the first page, not a 500 `GET /api/channels` only checked that a cursor's time was a string before casting it with diff --git a/charts/openbot/README.md b/charts/openbot/README.md index cf34d3c75..1002ba719 100644 --- a/charts/openbot/README.md +++ b/charts/openbot/README.md @@ -327,6 +327,41 @@ Left empty, this deployment has the Bots its tenant package declares as built-in package entry pointing at an endpoint that resolves to nothing is dropped rather than registered as a coworker nobody can talk to. +## Slack, Teams, text messages, push, SCIM, inbound email and OpenTelemetry + +Each is off until its values are set, and each is the same setting [docs/configuration.md](../../docs/configuration.md) +describes for a `.env`. Secrets go under `secrets`; with an existing Secret or an external store, use +the key in the last column. + +| Feature | Switch | Plain values | Secret key | +| -------------------------------- | ------------------------------------------------- | --------------------------------------------------------- | -------------------------------------------------------------- | +| Slack and Teams, through OpenTag | `config.opentag.enabled` | `url`, `botIconUrlTemplate` | `opentag-shared-secret` (required, 32+ characters) | +| Text messages, through Twilio | `config.sms.enabled` | `accountSid`, `verifyServiceSid`, `fromNumber` (all three) | `twilio-auth-token` (required) | +| Push and native-app sign-in | `config.push.projectId` (the EAS project UUID) | | `expo-access-token` (optional) | +| Where Twilio calls back | `config.deliveryPublicUrl` | defaults to `config.publicUrl` | | +| SCIM provisioning | `config.scim.enabled` | `connectionId` | `scim-bearer-token` (required), `scim-bearer-token-next` (optional) | +| Inbound email triggers | `config.inboundEmail.domain` and `snsTopicArns` | both needed, or the route is not mounted | | +| OpenTelemetry export | `config.otel.endpoint` or `logsEndpoint` | `serviceName`, `paused` | `otel-logs-headers`, `otel-headers` (optional) | + +```yaml +config: + opentag: + enabled: true + url: https://opentag.example.com + scim: + enabled: true +secrets: + opentagSharedSecret: + scimBearerToken: +``` + +The install refuses what the server would refuse at boot: a switch with no secret behind it (or, on +`externalSecrets`, no key named for it), an OpenTag secret under 32 characters, an OpenTag URL or icon +with the pairing off, an OpenTag URL that is not http(s) or an icon that is not https, a partial set +of Twilio settings, a push project id that is not a UUID, and a `deliveryPublicUrl` that is not +http(s). With `networkPolicy.enabled`, a collector inside the cluster has to be named in +`networkPolicy.extraEgress`; the providers themselves are reached on 443. + ## Upgrading the server without the computers `computers.mode: shared` runs one browser for every Bot, and on that shape the transcript's kept diff --git a/charts/openbot/templates/_helpers.tpl b/charts/openbot/templates/_helpers.tpl index f7f47efe9..21cce80c5 100644 --- a/charts/openbot/templates/_helpers.tpl +++ b/charts/openbot/templates/_helpers.tpl @@ -428,6 +428,120 @@ and in whatever holds the release, which is not where `KEY_ENCRYPTION_KEY` belon name: {{ default (include "openbot.secretName" .) .Values.computers.existingTokenSecret }} key: computer-token optional: {{ eq .Values.computers.mode "external" }} +{{- /* + Delivery (Slack and Teams through OpenTag, text messages, push), SCIM, inbound email and + OpenTelemetry export. + + PARENTHESISED, every map, for the reason `config.handoff` is above: these are keys this chart did + not have before, and `helm upgrade --reuse-values` leaves them absent on an existing deployment. + + A secret the server requires is referenced whenever its switch is on, never `optional`, so a + Secret that lacks it fails to start the pod with the key named rather than starting a server that + then refuses. A secret the server treats as optional is referenced `optional: true` whenever its + feature is, so it can live in `secrets.existingSecret` or a store without the chart reading it. +*/}} +{{- $opentag := .Values.config.opentag | default dict }} +{{- if $opentag.enabled }} +- name: OPENTAG_SHARED_SECRET + valueFrom: + secretKeyRef: + name: {{ include "openbot.secretName" . }} + key: opentag-shared-secret +{{- with $opentag.url }} +- name: OPENTAG_URL + value: {{ . | quote }} +{{- end }} +{{- with $opentag.botIconUrlTemplate }} +- name: OPENTAG_BOT_ICON_URL_TEMPLATE + value: {{ . | quote }} +{{- end }} +{{- end }} +{{- $sms := .Values.config.sms | default dict }} +{{- if $sms.enabled }} +- name: TWILIO_ACCOUNT_SID + value: {{ $sms.accountSid | quote }} +- name: TWILIO_AUTH_TOKEN + valueFrom: + secretKeyRef: + name: {{ include "openbot.secretName" . }} + key: twilio-auth-token +- name: TWILIO_VERIFY_SERVICE_SID + value: {{ $sms.verifyServiceSid | quote }} +- name: TWILIO_FROM_NUMBER + value: {{ $sms.fromNumber | quote }} +{{- end }} +{{- with (.Values.config.push | default dict).projectId }} +- name: EXPO_PROJECT_ID + value: {{ . | quote }} +- name: EXPO_ACCESS_TOKEN + valueFrom: + secretKeyRef: + name: {{ include "openbot.secretName" $ }} + key: expo-access-token + optional: true +{{- end }} +{{- with .Values.config.deliveryPublicUrl }} +- name: DELIVERY_PUBLIC_URL + value: {{ . | quote }} +{{- end }} +{{- $scim := .Values.config.scim | default dict }} +{{- if $scim.enabled }} +- name: SCIM_BEARER_TOKEN + valueFrom: + secretKeyRef: + name: {{ include "openbot.secretName" . }} + key: scim-bearer-token +- name: SCIM_BEARER_TOKEN_NEXT + valueFrom: + secretKeyRef: + name: {{ include "openbot.secretName" . }} + key: scim-bearer-token-next + optional: true +{{- with $scim.connectionId }} +- name: SCIM_CONNECTION_ID + value: {{ . | quote }} +{{- end }} +{{- end }} +{{- $inboundEmail := .Values.config.inboundEmail | default dict }} +{{- with $inboundEmail.domain }} +- name: OPENBOT_INBOUND_EMAIL_DOMAIN + value: {{ . | quote }} +{{- end }} +{{- with $inboundEmail.snsTopicArns }} +- name: OPENBOT_INBOUND_EMAIL_SNS_TOPIC_ARNS + value: {{ . | quote }} +{{- end }} +{{- $otel := .Values.config.otel | default dict }} +{{- if or $otel.endpoint $otel.logsEndpoint }} +{{- with $otel.endpoint }} +- name: OTEL_EXPORTER_OTLP_ENDPOINT + value: {{ . | quote }} +{{- end }} +{{- with $otel.logsEndpoint }} +- name: OTEL_EXPORTER_OTLP_LOGS_ENDPOINT + value: {{ . | quote }} +{{- end }} +{{- with $otel.serviceName }} +- name: OTEL_SERVICE_NAME + value: {{ . | quote }} +{{- end }} +{{- if $otel.paused }} +- name: OPENBOT_OTEL_EXPORT + value: "off" +{{- end }} +- name: OTEL_EXPORTER_OTLP_LOGS_HEADERS + valueFrom: + secretKeyRef: + name: {{ include "openbot.secretName" . }} + key: otel-logs-headers + optional: true +- name: OTEL_EXPORTER_OTLP_HEADERS + valueFrom: + secretKeyRef: + name: {{ include "openbot.secretName" . }} + key: otel-headers + optional: true +{{- end }} {{- /* One definition, for the same reason `openbot.databaseUrlEnv` is one (see its comment above): the API server needs this value to RECOGNISE the worker, and the routines CronJob needs the same value diff --git a/charts/openbot/templates/secret.yaml b/charts/openbot/templates/secret.yaml index a6ac6d093..901e35979 100644 --- a/charts/openbot/templates/secret.yaml +++ b/charts/openbot/templates/secret.yaml @@ -57,4 +57,29 @@ stringData: {{- if (.Values.routines).enabled }} worker-shared-secret: {{ required "secrets.workerSharedSecret is required when routines.enabled. Generate one with: openssl rand -base64 32" .Values.secrets.workerSharedSecret | quote }} {{- end }} + {{- /* + The same reasoning for the delivery and directory switches: `commonEnv` references these keys + whenever the switch is on, so the switch without its secret fails here rather than in a pod. + */}} + {{- if (.Values.config.opentag).enabled }} + opentag-shared-secret: {{ required "secrets.opentagSharedSecret is required when config.opentag.enabled. Generate one with: openssl rand -hex 32" .Values.secrets.opentagSharedSecret | quote }} + {{- end }} + {{- if (.Values.config.sms).enabled }} + twilio-auth-token: {{ required "secrets.twilioAuthToken is required when config.sms.enabled." .Values.secrets.twilioAuthToken | quote }} + {{- end }} + {{- if (.Values.config.scim).enabled }} + scim-bearer-token: {{ required "secrets.scimBearerToken is required when config.scim.enabled. Generate one with: openssl rand -hex 32" .Values.secrets.scimBearerToken | quote }} + {{- end }} + {{- with .Values.secrets.scimBearerTokenNext }} + scim-bearer-token-next: {{ . | quote }} + {{- end }} + {{- with .Values.secrets.expoAccessToken }} + expo-access-token: {{ . | quote }} + {{- end }} + {{- with .Values.secrets.otelLogsHeaders }} + otel-logs-headers: {{ . | quote }} + {{- end }} + {{- with .Values.secrets.otelHeaders }} + otel-headers: {{ . | quote }} + {{- end }} {{- end }} diff --git a/charts/openbot/templates/validation.yaml b/charts/openbot/templates/validation.yaml index 16b75c26b..337b401db 100644 --- a/charts/openbot/templates/validation.yaml +++ b/charts/openbot/templates/validation.yaml @@ -318,3 +318,64 @@ This template renders nothing. {{- fail "routines.enabled but externalSecrets.data does not name worker-shared-secret. The API server reads it to recognise the worker and the CronJob reads it to be one, so every pod that mounts it fails to start." }} {{- end }} {{- end }} + +{{- /* + Delivery, the directory and OpenTelemetry export: the refusals the server makes at boot. + + Each mirrors a throw in `configuredDeliveryProviders` (server/src/delivery/routes.ts), so the value + to change is named by `helm install` rather than by a pod's crash loop. Nothing is refused here that + the server would accept: inbound email with one half set is not mounted rather than refused, and + is left alone here too. +*/}} +{{- $opentag := .Values.config.opentag | default dict }} +{{- if and (not $opentag.enabled) (or $opentag.url $opentag.botIconUrlTemplate) }} +{{- fail "config.opentag.url or config.opentag.botIconUrlTemplate is set but config.opentag.enabled is not. The server refuses either without the shared secret; set config.opentag.enabled and secrets.opentagSharedSecret." }} +{{- end }} +{{- if $opentag.enabled }} +{{- if and $opentag.url (not (regexMatch "^https?://" $opentag.url)) }} +{{- fail "config.opentag.url must be an http(s) URL." }} +{{- end }} +{{- if and $opentag.botIconUrlTemplate (not (hasPrefix "https://" $opentag.botIconUrlTemplate)) }} +{{- fail "config.opentag.botIconUrlTemplate must be an https URL." }} +{{- end }} +{{- if and .Values.secrets.opentagSharedSecret (not .Values.secrets.existingSecret) (not .Values.externalSecrets.enabled) }} +{{- if lt (len .Values.secrets.opentagSharedSecret) 32 }} +{{- fail "secrets.opentagSharedSecret must be at least 32 characters. Generate one with: openssl rand -hex 32" }} +{{- end }} +{{- end }} +{{- end }} +{{- $sms := .Values.config.sms | default dict }} +{{- if $sms.enabled }} +{{- if or (not $sms.accountSid) (not $sms.verifyServiceSid) (not $sms.fromNumber) }} +{{- fail "config.sms.enabled needs config.sms.accountSid, config.sms.verifyServiceSid and config.sms.fromNumber. The server takes all four Twilio settings or none." }} +{{- end }} +{{- else if or $sms.accountSid $sms.verifyServiceSid $sms.fromNumber }} +{{- fail "config.sms has Twilio settings but config.sms.enabled is not set. The server takes all four Twilio settings or none; set config.sms.enabled and secrets.twilioAuthToken, or clear them." }} +{{- end }} +{{- with (.Values.config.push | default dict).projectId }} +{{- if not (regexMatch "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" .) }} +{{- fail "config.push.projectId must be the native app's EAS project UUID." }} +{{- end }} +{{- end }} +{{- with .Values.config.deliveryPublicUrl }} +{{- if not (regexMatch "^https?://" .) }} +{{- fail "config.deliveryPublicUrl must be an http(s) URL." }} +{{- end }} +{{- end }} +{{- /* + The same secrets, for a deployment whose secrets come from a store: the value is not readable at + template time, but the list of keys is, checked the way `worker-shared-secret` is above. +*/}} +{{- if .Values.externalSecrets.enabled }} +{{- $named := list }} +{{- range .Values.externalSecrets.data }}{{- $named = append $named .secretKey }}{{- end }} +{{- if and $opentag.enabled (not (has "opentag-shared-secret" $named)) }} +{{- fail "config.opentag.enabled but externalSecrets.data does not name opentag-shared-secret, so the server pod cannot start." }} +{{- end }} +{{- if and $sms.enabled (not (has "twilio-auth-token" $named)) }} +{{- fail "config.sms.enabled but externalSecrets.data does not name twilio-auth-token, so the server pod cannot start." }} +{{- end }} +{{- if and (.Values.config.scim | default dict).enabled (not (has "scim-bearer-token" $named)) }} +{{- fail "config.scim.enabled but externalSecrets.data does not name scim-bearer-token, so the server pod cannot start." }} +{{- end }} +{{- end }} diff --git a/charts/openbot/values.yaml b/charts/openbot/values.yaml index 813c55bad..be9490730 100644 --- a/charts/openbot/values.yaml +++ b/charts/openbot/values.yaml @@ -195,6 +195,73 @@ config: # The token travels in `secrets.managedAgentToken`. Required whenever a url is set: the server # refuses to start with one and not the other, because an unauthenticated Bot endpoint is an # open door to whatever that Bot can reach. + + # Slack and Microsoft Teams, through the OpenTag pairing. + # + # `enabled` is the switch, rather than the secret's presence, because a secret held in + # `secrets.existingSecret` or a store is not readable at template time. The shared secret travels in + # `secrets.opentagSharedSecret`; the server refuses one shorter than 32 characters, and refuses a + # `url` or icon template with no secret at all. + opentag: + enabled: false + # OpenTag's base URL, for messages sent when no Slack turn is open. Without it those sends fail + # visibly in the outbox and Slack triggers are refused. Must be http(s). + url: "" + # Optional https PNG URL with `{seed}` or `{agentId}`, used as the Bot's Slack icon. + botIconUrlTemplate: "" + + # Text messages, through Twilio. The server takes all four Twilio settings or none, so `enabled` + # needs the three below and `secrets.twilioAuthToken`. + sms: + enabled: false + accountSid: "" + verifyServiceSid: "" + fromNumber: "" + + # Push notifications and sign-in from the native app, through Expo. + # + # Setting `projectId`, the app's EAS project UUID, switches both on; the server refuses anything + # that is not a UUID. `secrets.expoAccessToken` is optional and sent to Expo's push service when set. + push: + projectId: "" + + # The public origin Twilio calls back on, when it is not `publicUrl`. Twilio signs each request over + # the URL it was given, so this has to be the address Twilio was configured with. Must be http(s). + deliveryPublicUrl: "" + + # SCIM provisioning at `/api/auth/scim/v2`. + # + # `enabled` needs `secrets.scimBearerToken`, the token the directory sends. A second token in + # `secrets.scimBearerTokenNext` is accepted beside it, so the directory can move to a new one before + # the old one is removed. + scim: + enabled: false + # The name of the directory connection. Empty means the server's default, `directory`. + connectionId: "" + + # Inbound email triggers: an SES receipt rule delivering through SNS to + # `/api/events/email/sns`. Both are needed; with either missing the route is not mounted. + inboundEmail: + # The domain the receipt rule covers, such as `in.example.com`. + domain: "" + # Comma-separated ARNs of the SNS topics allowed to deliver mail. + snsTopicArns: "" + + # Every audit row as an OTLP log record over HTTP, for a SIEM or an OpenTelemetry Collector. + # + # Setting either endpoint switches export on. Headers, which usually carry a collector's API key, + # are secrets: `secrets.otelHeaders` and `secrets.otelLogsHeaders`, as `key=value` pairs separated + # by commas. With `networkPolicy.enabled`, a collector inside the cluster has to be named in + # `networkPolicy.extraEgress`. + otel: + # Collector base URL; the server appends `/v1/logs`. + endpoint: "" + # Where log records go, used exactly as written. Takes precedence over `endpoint`. + logsEndpoint: "" + # The `service.name` on every record. Empty means the server's default, `openbot`. + serviceName: "" + # Stops export while an endpoint is still set. + paused: false logLevel: "" # Free-form additions, for anything this chart has no opinion about. extraEnv: [] @@ -484,6 +551,20 @@ secrets: googleClientSecret: "" microsoftClientSecret: "" oktaClientSecret: "" + # Required when `config.opentag.enabled`. At least 32 characters. Generate one with: + # openssl rand -hex 32 + opentagSharedSecret: "" + # Required when `config.sms.enabled`. + twilioAuthToken: "" + # Optional, with `config.push.projectId`. + expoAccessToken: "" + # Required when `config.scim.enabled`; the next one is optional, for rotation. + scimBearerToken: "" + scimBearerTokenNext: "" + # Optional, with `config.otel`. Headers for the logs endpoint, and the general ones used when the + # logs ones are unset. + otelLogsHeaders: "" + otelHeaders: "" externalSecrets: enabled: false