From 14df4f9e1c16915a0fafa1b64ea2ae8e7ecad1be Mon Sep 17 00:00:00 2001 From: jatin Date: Mon, 8 Jun 2026 11:37:38 -0400 Subject: [PATCH] agent-sandbox: validate required secrets, flexible Postgres DSN sourcing The agent-sandbox secret story was under-validated and rigid: - An empty postgres.url silently base64-encoded to nothing ({{ $as.postgres.url | default "" | b64enc }}), so a misconfigured deploy installed cleanly and the controller/proxy crash-looped at runtime. - jwtPublicKey / jwtPrivateKey (required for the controller/proxy to boot and for the backend to sign sandbox tokens) had no guard when absent. - Postgres could only be supplied as a plaintext DSN; operators could not reuse an existing password-only secret (e.g. the backend's Postgres password). The agent-sandbox app consumes a single connection string (no split-field code path), so the chart now offers four ways to supply it, validated at install: 1. postgres.url -- plaintext DSN. 2. postgres.host (+ user + database) -- the chart assembles postgres://user@host:port/database and supplies the password out-of-band via the PGPASSWORD env var, from postgres.password or postgres.passwordSecretName. node-postgres reads PGPASSWORD when the DSN omits the password (verified: pg-connection-string yields an empty password for user@host, and pg's val() uses `||` so it falls through to PGPASSWORD), so the password is never parsed as a URL and needs no escaping -- any characters are safe. This is what lets a password-only secret be reused. 3. postgres.urlSecretName -- existing secret holding the full DSN. 4. externalSecret.name -- catch-all secret, postgres-url key. Changes: - Add retool.agentSandbox.validateSecrets: fail at install time when an enabled workload is missing a Postgres source, user/database for the assemble path, a JWT public key, or a JWT private key. - Promote the controller/proxy URL block to retool.agentSandbox.postgresUrlEnv, which implements the resolution order above (emitting PGPASSWORD for the assemble path). - Only write postgres-url into the chart-managed secret when a plaintext url is set, so empty keys are never emitted. - Document the canonical shapes and the password-secret reuse path. Audit: mcp already fails on its missing required secret; js_executor has no secrets, so neither needs changes. Co-Authored-By: Claude Opus 4.8 (1M context) --- charts/retool/templates/_helpers.tpl | 73 +++++++++++++++++++ .../templates/deployment_agent_sandbox.yaml | 25 ++----- charts/retool/values.yaml | 60 +++++++++------ values.yaml | 60 +++++++++------ 4 files changed, 151 insertions(+), 67 deletions(-) diff --git a/charts/retool/templates/_helpers.tpl b/charts/retool/templates/_helpers.tpl index 660fe32..3797c71 100644 --- a/charts/retool/templates/_helpers.tpl +++ b/charts/retool/templates/_helpers.tpl @@ -623,6 +623,79 @@ app.kubernetes.io/component: proxy telemetry.retool.com/service-name: agent-sandbox-proxy {{- end -}} +{{/* +Validate that an enabled agent sandbox has its required secrets supplied. The +controller and proxy fail to boot without a Postgres connection and a JWT +public key, and the Retool backend needs the JWT private key to sign sandbox +tokens. Each may come from a plaintext value, the per-key existing-secret refs, +or the catch-all externalSecret.name. No-op when agentSandbox is disabled. +*/}} +{{- define "retool.agentSandbox.validateSecrets" -}} +{{- if .Values.agentSandbox.enabled -}} +{{- $as := .Values.agentSandbox -}} +{{- $ext := $as.externalSecret.name -}} +{{- if not (or $as.postgres.url $as.postgres.urlSecretName $as.postgres.host $ext) -}} +{{- fail "agentSandbox.enabled requires a Postgres connection. Set one of: agentSandbox.postgres.url (DSN), agentSandbox.postgres.host + user + database (the chart assembles the DSN, password from postgres.password or postgres.passwordSecretName), agentSandbox.postgres.urlSecretName (existing secret holding the DSN), or agentSandbox.externalSecret.name." -}} +{{- end -}} +{{- if and $as.postgres.host (not (and $as.postgres.user $as.postgres.database)) -}} +{{- fail "agentSandbox.postgres.host is set, so postgres.user and postgres.database are also required to assemble the DSN." -}} +{{- end -}} +{{- if not (or $as.jwtPublicKey $ext) -}} +{{- fail "agentSandbox.enabled requires a JWT public key. Set agentSandbox.jwtPublicKey or agentSandbox.externalSecret.name." -}} +{{- end -}} +{{- if not (or $as.jwtPrivateKey $ext) -}} +{{- fail "agentSandbox.enabled requires a JWT private key (the backend signs sandbox tokens with it). Set agentSandbox.jwtPrivateKey or agentSandbox.externalSecret.name." -}} +{{- end -}} +{{- end -}} +{{- end -}} + +{{/* +Render the AGENT_SANDBOX_POSTGRES_URL env entry for the controller/proxy (plus a +PGPASSWORD entry when assembling from fields). validateSecrets guarantees one of +these applies, in order: postgres.url -> postgres.host -> postgres.urlSecretName +-> externalSecret.name. + +For the host path the password is passed via PGPASSWORD rather than embedded in +the URL: node-postgres reads PGPASSWORD when the connection string omits the +password, so it needs no URL escaping. PGPASSWORD is process-global but safe +here because the controller/proxy open exactly one Postgres connection. +Usage: {{- include "retool.agentSandbox.postgresUrlEnv" . | nindent 12 }} +*/}} +{{- define "retool.agentSandbox.postgresUrlEnv" -}} +{{- $pg := .Values.agentSandbox.postgres -}} +{{- $ext := .Values.agentSandbox.externalSecret.name -}} +{{- if $pg.url }} +- name: AGENT_SANDBOX_POSTGRES_URL + value: {{ $pg.url | quote }} +{{- else if $pg.host }} +{{- $port := $pg.port | default 5432 -}} +{{- if $pg.passwordSecretName }} +- name: PGPASSWORD + valueFrom: + secretKeyRef: + name: {{ $pg.passwordSecretName }} + key: {{ $pg.passwordSecretKey | default "password" }} +{{- else if $pg.password }} +- name: PGPASSWORD + value: {{ $pg.password | quote }} +{{- end }} +- name: AGENT_SANDBOX_POSTGRES_URL + value: {{ printf "postgres://%s@%s:%v/%s" $pg.user $pg.host $port $pg.database | quote }} +{{- else if $pg.urlSecretName }} +- name: AGENT_SANDBOX_POSTGRES_URL + valueFrom: + secretKeyRef: + name: {{ $pg.urlSecretName }} + key: {{ $pg.urlSecretKey | default "postgres-url" }} +{{- else if $ext }} +- name: AGENT_SANDBOX_POSTGRES_URL + valueFrom: + secretKeyRef: + name: {{ $ext }} + key: postgres-url +{{- end }} +{{- end -}} + {{/* Agent sandbox env vars for the Retool backend, workflow backend, and workers. Outputs env entries that tell the backend how to reach the agent sandbox services. diff --git a/charts/retool/templates/deployment_agent_sandbox.yaml b/charts/retool/templates/deployment_agent_sandbox.yaml index 9f7e54b..2e4d10d 100644 --- a/charts/retool/templates/deployment_agent_sandbox.yaml +++ b/charts/retool/templates/deployment_agent_sandbox.yaml @@ -1,4 +1,5 @@ {{- if .Values.agentSandbox.enabled }} +{{- include "retool.agentSandbox.validateSecrets" . }} {{- $as := .Values.agentSandbox -}} {{- $defaultSecretName := $as.externalSecret.name | default (include "retool.agentSandbox.name" .) -}} {{- $nodeSelector := $as.nodeSelector | default .Values.nodeSelector -}} @@ -22,7 +23,9 @@ data: jwt-private-key: {{ $as.jwtPrivateKey | default "" | b64enc | quote }} encryption-key: {{ $as.encryptionKey | default "" | b64enc | quote }} api-secret: {{ $as.apiSecret | default "" | b64enc | quote }} - postgres-url: {{ $as.postgres.url | default "" | b64enc | quote }} + {{- if $as.postgres.url }} + postgres-url: {{ $as.postgres.url | b64enc | quote }} + {{- end }} --- {{- end }} {{- /* @@ -300,15 +303,7 @@ spec: value: {{ $as.controller.port | quote }} - name: STATE_BACKEND value: "postgres" - - name: AGENT_SANDBOX_POSTGRES_URL - {{- if $as.postgres.url }} - value: {{ $as.postgres.url | quote }} - {{- else }} - valueFrom: - secretKeyRef: - name: {{ $defaultSecretName }} - key: postgres-url - {{- end }} + {{- include "retool.agentSandbox.postgresUrlEnv" . | nindent 12 }} - name: AGENT_SANDBOX_POSTGRES_SCHEMA value: {{ $as.postgres.schema | quote }} - name: AGENT_SANDBOX_POSTGRES_POOL_MAX @@ -504,15 +499,7 @@ spec: value: {{ $as.proxy.port | quote }} - name: STATE_BACKEND value: "postgres" - - name: AGENT_SANDBOX_POSTGRES_URL - {{- if $as.postgres.url }} - value: {{ $as.postgres.url | quote }} - {{- else }} - valueFrom: - secretKeyRef: - name: {{ $defaultSecretName }} - key: postgres-url - {{- end }} + {{- include "retool.agentSandbox.postgresUrlEnv" . | nindent 12 }} - name: AGENT_SANDBOX_POSTGRES_SCHEMA value: {{ $as.postgres.schema | quote }} - name: AGENT_SANDBOX_POSTGRES_POOL_MAX diff --git a/charts/retool/values.yaml b/charts/retool/values.yaml index 33f80bb..b55fa98 100644 --- a/charts/retool/values.yaml +++ b/charts/retool/values.yaml @@ -928,33 +928,45 @@ agentSandbox: # Labels for agent sandbox pods labels: {} - # Pre-existing K8s Secret containing keys: jwt-public-key, jwt-private-key, - # encryption-key, api-secret, postgres-url. When set, the chart references - # this secret by default for all secret-backed env vars. - # - # Individual keys can still be overridden by setting the corresponding - # plaintext values below (e.g. jwtPublicKey, postgres.url). When a plaintext - # value is provided alongside externalSecret.name, the plaintext value takes - # precedence for that key and the external secret is used for the rest. + # === Secrets ============================================================ + # Provide each secret as a plaintext value below, OR set externalSecret.name + # to a pre-existing Secret with keys jwt-public-key, jwt-private-key, + # encryption-key, api-secret, postgres-url. A plaintext value always wins over + # the external secret for that key. externalSecret: - name: '' - - # Secrets — used directly when externalSecret.name is not set, or as - # per-key overrides when externalSecret.name IS set. - # JWT key pair (ES256) for sandbox token authentication. - jwtPublicKey: '' - jwtPrivateKey: '' - # Hex-encoded 256-bit key for encrypting credentials stored in state backend. - # Must match the backend's AGENT_SANDBOX_ENCRYPTION_KEY. - encryptionKey: '' - # API secret for admin/test endpoints. - apiSecret: '' - - # Postgres state backend (shared by controller and proxy for state coordination). - # Connection string for the agent sandbox's state database. When set, takes - # precedence over the postgres-url key in externalSecret. + name: '' # optional: existing Secret holding all keys below + + jwtPublicKey: '' # REQUIRED (ES256) unless provided via externalSecret + jwtPrivateKey: '' # REQUIRED (ES256) unless provided via externalSecret + encryptionKey: '' # optional: hex 256-bit; must match backend AGENT_SANDBOX_ENCRYPTION_KEY + apiSecret: '' # optional: admin/test endpoints + + # === Postgres state backend ============================================= + # REQUIRED. Choose exactly ONE sourcing option; leave the others blank. postgres: + # -- Option 1: plaintext DSN -- url: '' + + # -- Option 2: assemble from fields -- + # The password is passed via PGPASSWORD (never embedded in the URL), so any + # characters are safe and a password-only secret can be reused as-is. + # Set either password or passwordSecretName. + host: '' + port: 5432 + database: '' + user: '' + password: '' + passwordSecretName: '' + passwordSecretKey: 'password' + + # -- Option 3: existing Secret holding the full DSN -- + urlSecretName: '' + urlSecretKey: 'postgres-url' + + # -- Option 4: reuse externalSecret.name (its postgres-url key) -- + # Nothing to set here; just leave options 1-3 blank. + + # -- Optional tuning (defaults shown) -- schema: 'agent_executor' poolMax: 10 sweeperIntervalMs: 60000 diff --git a/values.yaml b/values.yaml index 33f80bb..b55fa98 100644 --- a/values.yaml +++ b/values.yaml @@ -928,33 +928,45 @@ agentSandbox: # Labels for agent sandbox pods labels: {} - # Pre-existing K8s Secret containing keys: jwt-public-key, jwt-private-key, - # encryption-key, api-secret, postgres-url. When set, the chart references - # this secret by default for all secret-backed env vars. - # - # Individual keys can still be overridden by setting the corresponding - # plaintext values below (e.g. jwtPublicKey, postgres.url). When a plaintext - # value is provided alongside externalSecret.name, the plaintext value takes - # precedence for that key and the external secret is used for the rest. + # === Secrets ============================================================ + # Provide each secret as a plaintext value below, OR set externalSecret.name + # to a pre-existing Secret with keys jwt-public-key, jwt-private-key, + # encryption-key, api-secret, postgres-url. A plaintext value always wins over + # the external secret for that key. externalSecret: - name: '' - - # Secrets — used directly when externalSecret.name is not set, or as - # per-key overrides when externalSecret.name IS set. - # JWT key pair (ES256) for sandbox token authentication. - jwtPublicKey: '' - jwtPrivateKey: '' - # Hex-encoded 256-bit key for encrypting credentials stored in state backend. - # Must match the backend's AGENT_SANDBOX_ENCRYPTION_KEY. - encryptionKey: '' - # API secret for admin/test endpoints. - apiSecret: '' - - # Postgres state backend (shared by controller and proxy for state coordination). - # Connection string for the agent sandbox's state database. When set, takes - # precedence over the postgres-url key in externalSecret. + name: '' # optional: existing Secret holding all keys below + + jwtPublicKey: '' # REQUIRED (ES256) unless provided via externalSecret + jwtPrivateKey: '' # REQUIRED (ES256) unless provided via externalSecret + encryptionKey: '' # optional: hex 256-bit; must match backend AGENT_SANDBOX_ENCRYPTION_KEY + apiSecret: '' # optional: admin/test endpoints + + # === Postgres state backend ============================================= + # REQUIRED. Choose exactly ONE sourcing option; leave the others blank. postgres: + # -- Option 1: plaintext DSN -- url: '' + + # -- Option 2: assemble from fields -- + # The password is passed via PGPASSWORD (never embedded in the URL), so any + # characters are safe and a password-only secret can be reused as-is. + # Set either password or passwordSecretName. + host: '' + port: 5432 + database: '' + user: '' + password: '' + passwordSecretName: '' + passwordSecretKey: 'password' + + # -- Option 3: existing Secret holding the full DSN -- + urlSecretName: '' + urlSecretKey: 'postgres-url' + + # -- Option 4: reuse externalSecret.name (its postgres-url key) -- + # Nothing to set here; just leave options 1-3 blank. + + # -- Optional tuning (defaults shown) -- schema: 'agent_executor' poolMax: 10 sweeperIntervalMs: 60000