diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 7f40901..f066b8f 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -328,6 +328,47 @@ See [`examples/README.md`](../examples/README.md) for more configurations (Pytho | `features` | Dev container features (ghcr.io OCI artifacts) | | `postCreateCommand` | Run after first start | | `containerEnv` | Environment variables | +| `customizations.paude.secretEnv` | Secret env vars (see below) | + +## Secret Environment Variables + +Inject security-sensitive environment variables (API tokens, credentials) into sessions without exposing them in container specs or process listings. Values are read from your host environment at connect/start time and injected securely. + +The format is `"CONTAINER_NAME": "HOST_NAME"` — the key is the variable name inside the container, the value is the variable name on your host. Use the same name for both when no remapping is needed. + +**paude.json** — use a top-level `secretEnv`: + +```json +{ + "secretEnv": { + "JIRA_API_TOKEN": "JIRA_TOKEN_READONLY", + "SLACK_BOT_TOKEN": "SLACK_BOT_TOKEN" + } +} +``` + +**devcontainer.json** — use `customizations.paude.secretEnv`: + +```json +{ + "customizations": { + "paude": { + "secretEnv": { + "JIRA_API_TOKEN": "JIRA_TOKEN_READONLY" + } + } + } +} +``` + +In the examples above, the host's `JIRA_TOKEN_READONLY` is read and exposed as `JIRA_API_TOKEN` inside the container. `SLACK_BOT_TOKEN` uses the same name on both sides. + +### How It Works + +- **Podman/Docker**: Injected via `podman exec -e` on connect — never stored in the container spec. +- **OpenShift**: Written to tmpfs at `/credentials/env/` on connect — RAM-only, never persisted to disk. +- Fresh values are read on every `paude connect` or `paude start`, so rotated tokens propagate without restarting the container. +- If a declared variable is missing from your host environment, a warning is printed and that variable is skipped (not set in the container). The session still starts normally. ## GPU Passthrough diff --git a/docs/OPENSHIFT.md b/docs/OPENSHIFT.md index 68a2f60..128e15b 100644 --- a/docs/OPENSHIFT.md +++ b/docs/OPENSHIFT.md @@ -138,9 +138,16 @@ Configuration is synced via `oc cp` to tmpfs on session start and reconnect: Plugin paths are automatically rewritten from host paths to container paths. +**Custom Secret Env Vars (`secretEnv`):** +- Declared in `paude.json` or `devcontainer.json` (see [CONFIGURATION.md](CONFIGURATION.md#secret-environment-variables)) +- Written to tmpfs at `/credentials/env/` alongside built-in credentials +- Supports name mapping: host env var names can differ from container names +- Mapping is stored in StatefulSet annotations so it survives across connect/start/upgrade +- Values are read fresh from the host on every connect + **Credential Refresh:** -- **First connect** (after pod start): Full sync of gcloud, claude config, and gitconfig -- **Reconnect** (subsequent connects): Only gcloud credentials refreshed (fast) +- **First connect** (after pod start): Full sync of gcloud, claude config, gitconfig, and secretEnv +- **Reconnect** (subsequent connects): Only gcloud credentials and secretEnv refreshed (fast) - This ensures fresh OAuth tokens propagate if you re-authenticate locally - Long-running pods stay current with local credential changes diff --git a/docs/SECURITY.md b/docs/SECURITY.md index e9b007d..ee09b10 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -12,6 +12,7 @@ The container intentionally restricts certain operations: | SSH keys | not mounted | Prevents git push via SSH | | GitHub CLI config | not mounted | Prevents cached host credentials | | `GH_TOKEN` (host) | never propagated | Use `PAUDE_GITHUB_TOKEN` or `--github-token` on start/connect | +| Custom secrets (`secretEnv`) | injected (exec -e / tmpfs) | User-defined secret env vars; never in container spec | | Git credentials | not mounted | Prevents HTTPS git push | ## Verified Attack Vectors diff --git a/src/paude/backends/base.py b/src/paude/backends/base.py index 18283b7..0b553d1 100644 --- a/src/paude/backends/base.py +++ b/src/paude/backends/base.py @@ -89,6 +89,7 @@ class SessionConfig: ports: list[tuple[int, int]] = field(default_factory=list) otel_ports: list[int] = field(default_factory=list) otel_endpoint: str | None = None + secret_env_mapping: dict[str, str] = field(default_factory=dict) class Backend(Protocol): diff --git a/src/paude/backends/openshift/resources.py b/src/paude/backends/openshift/resources.py index 8c1d6f7..b0d4a66 100644 --- a/src/paude/backends/openshift/resources.py +++ b/src/paude/backends/openshift/resources.py @@ -12,6 +12,7 @@ PAUDE_LABEL_AGENT, PAUDE_LABEL_GPU, PAUDE_LABEL_PROVIDER, + PAUDE_LABEL_SECRET_ENV, PAUDE_LABEL_VERSION, PAUDE_LABEL_YOLO, encode_path, @@ -82,6 +83,7 @@ def __init__( self._otel_endpoint: str | None = None self._env: dict[str, str] = {} self._workspace: Path | None = None + self._secret_env_mapping: dict[str, str] = {} self._pvc_size = "10Gi" self._storage_class: str | None = None @@ -121,6 +123,18 @@ def with_workspace(self, workspace: Path) -> StatefulSetBuilder: self._workspace = workspace return self + def with_secret_env_mapping(self, mapping: dict[str, str]) -> StatefulSetBuilder: + """Set custom secret env var mapping. + + Args: + mapping: Container name -> host name mapping. + + Returns: + Self for method chaining. + """ + self._secret_env_mapping = mapping + return self + def with_pvc( self, size: str = "10Gi", @@ -170,6 +184,12 @@ def _build_metadata(self, created_at: str) -> dict[str, Any]: metadata["annotations"]["paude.io/workspace"] = encoded if self._otel_endpoint: metadata["annotations"]["paude.io/otel-endpoint"] = self._otel_endpoint + if self._secret_env_mapping: + from paude.backends.shared import serialize_secret_env_mapping + + metadata["annotations"][PAUDE_LABEL_SECRET_ENV] = ( + serialize_secret_env_mapping(self._secret_env_mapping) + ) return metadata def _build_volumes(self) -> list[dict[str, Any]]: diff --git a/src/paude/backends/openshift/session_connection.py b/src/paude/backends/openshift/session_connection.py index 945cf5d..6ddc301 100644 --- a/src/paude/backends/openshift/session_connection.py +++ b/src/paude/backends/openshift/session_connection.py @@ -264,6 +264,11 @@ def _sync_for_connect( """Sync credentials/config for a connect operation.""" from paude.agents import get_agent from paude.agents.base import build_secret_environment_from_config + from paude.backends.shared import ( + PAUDE_LABEL_SECRET_ENV, + build_custom_secret_env, + parse_secret_env_label, + ) sts = self._lookup.get_statefulset(name) agent_name = self._agent_name_from_sts(sts) @@ -273,6 +278,12 @@ def _sync_for_connect( agent = get_agent(agent_name, provider=provider) secret_env = build_secret_environment_from_config(agent.config) + # Merge custom secret env vars from STS annotations + annotations = (sts or {}).get("metadata", {}).get("annotations", {}) + custom_mapping = parse_secret_env_label(annotations.get(PAUDE_LABEL_SECRET_ENV)) + if custom_mapping: + secret_env.update(build_custom_secret_env(custom_mapping)) + if self._syncer.is_config_synced(pname): self._syncer.sync_credentials( pname, diff --git a/src/paude/backends/openshift/session_lifecycle.py b/src/paude/backends/openshift/session_lifecycle.py index 6fd13b9..4a058ef 100644 --- a/src/paude/backends/openshift/session_lifecycle.py +++ b/src/paude/backends/openshift/session_lifecycle.py @@ -131,9 +131,12 @@ def _build_session_env( """ from paude.agents import get_agent from paude.agents.base import build_secret_environment_from_config + from paude.backends.shared import build_custom_secret_env agent = get_agent(config.agent, provider=config.provider) secret_env = build_secret_environment_from_config(agent.config) + if config.secret_env_mapping: + secret_env.update(build_custom_secret_env(config.secret_env_mapping)) proxy_name = ( proxy_resource_name(session_name) if config.allowed_domains is not None @@ -171,6 +174,7 @@ def _apply_and_wait( gpu=config.gpu, yolo=config.yolo, otel_endpoint=config.otel_endpoint, + secret_env_mapping=config.secret_env_mapping, ) print( @@ -396,6 +400,7 @@ def _generate_statefulset_spec( gpu: str | None = None, yolo: bool = False, otel_endpoint: str | None = None, + secret_env_mapping: dict[str, str] | None = None, ) -> dict[str, Any]: """Generate a Kubernetes StatefulSet specification.""" return ( @@ -413,5 +418,6 @@ def _generate_statefulset_spec( .with_workspace(workspace) .with_pvc(size=pvc_size, storage_class=storage_class) .with_otel_endpoint(otel_endpoint) + .with_secret_env_mapping(secret_env_mapping or {}) .build() ) diff --git a/src/paude/backends/podman/backend.py b/src/paude/backends/podman/backend.py index f8267d6..dbce738 100644 --- a/src/paude/backends/podman/backend.py +++ b/src/paude/backends/podman/backend.py @@ -36,6 +36,7 @@ PAUDE_LABEL_OTEL_PORTS, PAUDE_LABEL_PROVIDER, PAUDE_LABEL_PROXY_IMAGE, + PAUDE_LABEL_SECRET_ENV, PAUDE_LABEL_SESSION, PAUDE_LABEL_VERSION, PAUDE_LABEL_WORKSPACE, @@ -123,6 +124,13 @@ def _get_session_agent(self, session_name: str) -> Agent: provider = labels.get(PAUDE_LABEL_PROVIDER) or None return get_agent(agent_name, provider=provider) + def _get_secret_env_mapping(self, session_name: str) -> dict[str, str]: + """Get custom secret env mapping from session labels.""" + from paude.backends.shared import parse_secret_env_label + + labels = self._get_session_labels(session_name) + return parse_secret_env_label(labels.get(PAUDE_LABEL_SECRET_ENV)) + def _get_port_urls(self, agent: Agent) -> list[str]: """Get port-forward URL strings for an agent.""" return [f"http://localhost:{hp}" for hp, _cp in agent.config.exposed_ports] @@ -162,10 +170,14 @@ def _start_port_forward(self, session_name: str, agent: Agent) -> None: self._port_forward.start(session_name, cname, ports) def _build_attach_env( - self, agent: Agent, github_token: str | None + self, + agent: Agent, + github_token: str | None, + secret_env_mapping: dict[str, str] | None = None, ) -> dict[str, str] | None: """Build extra environment for container attachment.""" from paude.agents.base import build_secret_environment_from_config + from paude.backends.shared import build_custom_secret_env secret_env = build_secret_environment_from_config(agent.config) @@ -174,6 +186,9 @@ def _build_attach_env( extra_env["GH_TOKEN"] = github_token extra_env.update(secret_env) + if secret_env_mapping: + extra_env.update(build_custom_secret_env(secret_env_mapping)) + port_urls = self._get_port_urls(agent) if port_urls: extra_env["PAUDE_PORT_URLS"] = ";".join(port_urls) @@ -301,6 +316,12 @@ def create_session(self, config: SessionConfig) -> Session: ) if config.otel_endpoint: labels[PAUDE_LABEL_OTEL_ENDPOINT] = config.otel_endpoint + if config.secret_env_mapping: + from paude.backends.shared import serialize_secret_env_mapping + + labels[PAUDE_LABEL_SECRET_ENV] = serialize_secret_env_mapping( + config.secret_env_mapping + ) print(f"Creating session '{session_name}'...", file=sys.stderr) @@ -433,19 +454,31 @@ def start_session_no_attach( agent = self._get_session_agent(name) self._sync_host_config(cname, agent.config.name) self._sync_sandbox_config(cname, name) - self._start_agent_headless_in_container(cname, agent, github_token) + secret_mapping = self._get_secret_env_mapping(name) + self._start_agent_headless_in_container( + cname, agent, github_token, secret_env_mapping=secret_mapping + ) def start_agent_headless(self, name: str, github_token: str | None = None) -> None: """Start the agent in headless mode inside the container.""" cname = self._require_running_session(name) agent = self._get_session_agent(name) - self._start_agent_headless_in_container(cname, agent, github_token) + secret_mapping = self._get_secret_env_mapping(name) + self._start_agent_headless_in_container( + cname, agent, github_token, secret_env_mapping=secret_mapping + ) def _start_agent_headless_in_container( - self, cname: str, agent: Agent, github_token: str | None = None + self, + cname: str, + agent: Agent, + github_token: str | None = None, + secret_env_mapping: dict[str, str] | None = None, ) -> None: """Start the agent in headless mode (internal, skips session lookup).""" - env_vars = self._build_attach_env(agent, github_token=github_token) + env_vars = self._build_attach_env( + agent, github_token=github_token, secret_env_mapping=secret_env_mapping + ) cmd: list[str] = ["env", "PAUDE_HEADLESS=1"] if env_vars: for key, value in env_vars.items(): @@ -528,12 +561,15 @@ def start_session(self, name: str, github_token: str | None = None) -> int: self._sync_host_config(cname, agent.config.name) self._sync_sandbox_config(cname, name) + secret_mapping = self._get_secret_env_mapping(name) self._start_port_forward(name, agent) self._print_port_urls(name, agent) exit_code = self._runner.attach_container( cname, entrypoint=CONTAINER_ENTRYPOINT, - extra_env=self._build_attach_env(agent, github_token), + extra_env=self._build_attach_env( + agent, github_token, secret_env_mapping=secret_mapping + ), ) self._print_port_urls(name, agent) return exit_code @@ -595,6 +631,7 @@ def connect_session(self, name: str, github_token: str | None = None) -> int: self._sync_host_config(cname, agent.config.name) self._sync_sandbox_config(cname, name) + secret_mapping = self._get_secret_env_mapping(name) self._start_port_forward(name, agent) print(f"Connecting to session '{name}'...", file=sys.stderr) self._print_port_urls(name, agent) @@ -602,7 +639,9 @@ def connect_session(self, name: str, github_token: str | None = None) -> int: exit_code = self._runner.attach_container( cname, entrypoint=CONTAINER_ENTRYPOINT, - extra_env=self._build_attach_env(agent, github_token), + extra_env=self._build_attach_env( + agent, github_token, secret_env_mapping=secret_mapping + ), ) finally: self._port_forward.stop(name) diff --git a/src/paude/backends/shared.py b/src/paude/backends/shared.py index 20e79a1..62298c6 100644 --- a/src/paude/backends/shared.py +++ b/src/paude/backends/shared.py @@ -3,6 +3,8 @@ from __future__ import annotations import base64 +import os +import sys from pathlib import Path from typing import TYPE_CHECKING @@ -25,6 +27,7 @@ PAUDE_LABEL_PROVIDER = "paude.io/provider" PAUDE_LABEL_OTEL_PORTS = "paude.io/otel-ports" PAUDE_LABEL_OTEL_ENDPOINT = "paude.io/otel-endpoint" +PAUDE_LABEL_SECRET_ENV = "paude.io/secret-env" # noqa: S105 - label key, not a password SQUID_BLOCKED_LOG_PATH = "/tmp/squid-blocked.log" # noqa: S108 @@ -221,6 +224,80 @@ def generate_sandbox_config_script( return agent.apply_sandbox_config(CONTAINER_HOME, workspace, args) +def build_custom_secret_env( + secret_env_mapping: dict[str, str], +) -> dict[str, str]: + """Build secret env dict from a mapping, reading values from os.environ. + + Args: + secret_env_mapping: Mapping of container_name -> host_name. + + Returns: + Dictionary of container_name -> value for vars present in os.environ. + """ + env: dict[str, str] = {} + for container_name, host_name in secret_env_mapping.items(): + value = os.environ.get(host_name) + if value is not None: + env[container_name] = value + else: + if container_name == host_name: + msg = f"Warning: secretEnv variable '{host_name}'" + else: + msg = ( + f"Warning: secretEnv variable '{host_name}'" + f" (mapped to '{container_name}')" + ) + print( + f"{msg} not found in host environment", + file=sys.stderr, + ) + return env + + +def serialize_secret_env_mapping(mapping: dict[str, str]) -> str: + """Serialize a secret env mapping for storage in labels/annotations. + + Same-name mappings are stored as just the name. + Renamed mappings are stored as container_name=host_name. + Entries are comma-separated. + """ + parts: list[str] = [] + for container_name, host_name in mapping.items(): + if container_name == host_name: + parts.append(container_name) + else: + parts.append(f"{container_name}={host_name}") + return ",".join(parts) + + +def parse_secret_env_label(label_value: str | None) -> dict[str, str]: + """Parse a secret-env label/annotation into a mapping. + + Args: + label_value: Serialized mapping string, or None. + + Returns: + Mapping of container_name -> host_name. + """ + if not label_value: + return {} + mapping: dict[str, str] = {} + for entry in label_value.split(","): + entry = entry.strip() + if not entry: + continue + if "=" in entry: + container_name, host_name = entry.split("=", 1) + container_name = container_name.strip() + host_name = host_name.strip() + if container_name and host_name: + mapping[container_name] = host_name + else: + mapping[entry] = entry + return mapping + + def build_ssh_backend( entry: object, connect_timeout: int | None = None, diff --git a/src/paude/cli/create.py b/src/paude/cli/create.py index 9d31a63..9c15858 100644 --- a/src/paude/cli/create.py +++ b/src/paude/cli/create.py @@ -322,14 +322,16 @@ def session_create( raise typer.Exit(1) from None # Shared pre-create: parse args, build env, expand domains, show warnings - expanded_domains, parsed_args, env, unrestricted = _prepare_session_create( - allowed_domains=r_allowed_domains, - yolo=r_yolo, - claude_args=claude_args, - config_obj=config, - agent_name=r_agent, - provider_name=r_provider, - otel_endpoint=r_otel_endpoint, + expanded_domains, parsed_args, env, unrestricted, secret_env_mapping = ( + _prepare_session_create( + allowed_domains=r_allowed_domains, + yolo=r_yolo, + claude_args=claude_args, + config_obj=config, + agent_name=r_agent, + provider_name=r_provider, + otel_endpoint=r_otel_endpoint, + ) ) # Compute OTEL proxy ports (non-standard ports to open in squid) @@ -364,6 +366,7 @@ def session_create( gpu=r_gpu, otel_ports=otel_ports, otel_endpoint=r_otel_endpoint, + secret_env_mapping=secret_env_mapping, ) else: from paude.cli.create_openshift import create_openshift_session @@ -392,4 +395,5 @@ def session_create( build_resources=r_openshift_build_resources, otel_ports=otel_ports, otel_endpoint=r_otel_endpoint, + secret_env_mapping=secret_env_mapping, ) diff --git a/src/paude/cli/create_openshift.py b/src/paude/cli/create_openshift.py index 41474eb..53ebac3 100644 --- a/src/paude/cli/create_openshift.py +++ b/src/paude/cli/create_openshift.py @@ -50,6 +50,7 @@ def create_openshift_session( build_resources: dict[str, dict[str, str]] | None = None, otel_ports: list[int] | None = None, otel_endpoint: str | None = None, + secret_env_mapping: dict[str, str] | None = None, ) -> None: """OpenShift-specific session creation logic.""" os_script_dir = _detect_dev_script_dir() @@ -123,6 +124,7 @@ def create_openshift_session( ports=agent.config.exposed_ports, otel_ports=otel_ports or [], otel_endpoint=otel_endpoint, + secret_env_mapping=secret_env_mapping or {}, ) session = os_backend.create_session(session_config) diff --git a/src/paude/cli/create_podman.py b/src/paude/cli/create_podman.py index 93e89b3..679ec94 100644 --- a/src/paude/cli/create_podman.py +++ b/src/paude/cli/create_podman.py @@ -45,6 +45,7 @@ def create_podman_session( gpu: str | None = None, otel_ports: list[int] | None = None, otel_endpoint: str | None = None, + secret_env_mapping: dict[str, str] | None = None, ) -> None: """Local container session creation logic (Podman or Docker).""" from paude.container import ImageManager @@ -115,6 +116,7 @@ def create_podman_session( ports=agent_instance.config.exposed_ports, otel_ports=otel_ports or [], otel_endpoint=otel_endpoint, + secret_env_mapping=secret_env_mapping or {}, ) try: diff --git a/src/paude/cli/helpers.py b/src/paude/cli/helpers.py index 4e74b46..2742615 100644 --- a/src/paude/cli/helpers.py +++ b/src/paude/cli/helpers.py @@ -257,6 +257,20 @@ def _expand_allowed_domains( return expand_domains(domains_input, extra_aliases=extra_aliases) +def _validate_secret_env_names(mapping: dict[str, str]) -> None: + """Validate that secret env names don't contain commas or equals signs. + + These characters are used as separators in label/annotation storage. + """ + bad = [n for n in (*mapping.keys(), *mapping.values()) if "," in n or "=" in n] + if bad: + typer.echo( + f"Error: secretEnv names cannot contain commas or equals signs: {bad}", + err=True, + ) + raise typer.Exit(1) + + def _prepare_session_create( allowed_domains: list[str] | None, yolo: bool, @@ -265,11 +279,12 @@ def _prepare_session_create( agent_name: str = "claude", provider_name: str | None = None, otel_endpoint: str | None = None, -) -> tuple[list[str] | None, list[str], dict[str, str], bool]: +) -> tuple[list[str] | None, list[str], dict[str, str], bool, dict[str, str]]: """Shared pre-create logic for both backends. Returns: - Tuple of (expanded_domains, parsed_args, env, unrestricted). + Tuple of (expanded_domains, parsed_args, env, unrestricted, + secret_env_mapping). """ from paude.agents import get_agent from paude.domains import is_unrestricted @@ -298,6 +313,12 @@ def _prepare_session_create( unrestricted = is_unrestricted(expanded_domains) + # Extract secret env mapping from config + secret_env_mapping: dict[str, str] = {} + if config_obj and config_obj.container_secret_env: + _validate_secret_env_names(config_obj.container_secret_env) + secret_env_mapping = dict(config_obj.container_secret_env) + # Show warnings for dangerous configurations if yolo and unrestricted: typer.echo( @@ -310,7 +331,7 @@ def _prepare_session_create( ) typer.echo("", err=True) - return expanded_domains, parsed_args, env, unrestricted + return expanded_domains, parsed_args, env, unrestricted, secret_env_mapping def _finalize_session_create( diff --git a/src/paude/cli/upgrade.py b/src/paude/cli/upgrade.py index cde8eb7..8d62719 100644 --- a/src/paude/cli/upgrade.py +++ b/src/paude/cli/upgrade.py @@ -352,13 +352,15 @@ def _upgrade_podman( # allowed_domains=None means no proxy (unrestricted); # passing None to _prepare_session_create would incorrectly add defaults. if allowed_domains is not None: - expanded_domains, parsed_args, _env, unrestricted = _prepare_session_create( - allowed_domains, - yolo, - None, - config, - agent_name=agent_name, - otel_endpoint=otel_endpoint, + expanded_domains, parsed_args, _env, unrestricted, _secret_mapping = ( + _prepare_session_create( + allowed_domains, + yolo, + None, + config, + agent_name=agent_name, + otel_endpoint=otel_endpoint, + ) ) session_domains = expanded_domains if not unrestricted else allowed_domains else: @@ -376,6 +378,14 @@ def _upgrade_podman( otel_ports = otel_proxy_ports(otel_endpoint) + # Extract secret env mapping from config + secret_env_mapping: dict[str, str] = {} + if config and config.container_secret_env: + from paude.cli.helpers import _validate_secret_env_names + + _validate_secret_env_names(config.container_secret_env) + secret_env_mapping = dict(config.container_secret_env) + # Create new session config with reuse_volume=True from paude.backends import SessionConfig @@ -395,6 +405,7 @@ def _upgrade_podman( ports=agent_instance.config.exposed_ports, otel_ports=otel_ports, otel_endpoint=otel_endpoint, + secret_env_mapping=secret_env_mapping, ) backend.create_session(session_config) @@ -568,6 +579,30 @@ def _upgrade_openshift( "--overwrite", ) + # Update secret-env annotation from project config (clear if empty) + if config is not None: + from paude.backends.shared import ( + PAUDE_LABEL_SECRET_ENV, + serialize_secret_env_mapping, + ) + from paude.cli.helpers import _validate_secret_env_names + + secret_mapping = config.container_secret_env + if secret_mapping: + _validate_secret_env_names(secret_mapping) + ann_value = ( + serialize_secret_env_mapping(secret_mapping) if secret_mapping else "" + ) + oc.run( + "annotate", + "statefulset", + sts_name, + "-n", + ns, + f"{PAUDE_LABEL_SECRET_ENV}={ann_value}", + "--overwrite", + ) + # Scale to 1 and wait typer.echo(f"Starting session '{name}'...", err=True) backend._lifecycle._scale_statefulset(name, 1) @@ -612,11 +647,14 @@ def _upgrade_openshift( typer.echo(f"Waiting for pod {pname} to be ready...", err=True) backend._pod_waiter.wait_for_ready(pname) - # Re-sync config + # Re-sync config (including custom secret env vars from project config) from paude.agents.base import build_secret_environment_from_config + from paude.backends.shared import build_custom_secret_env agent_instance = get_agent(agent_name, provider=provider_name) secret_env = build_secret_environment_from_config(agent_instance.config) + if config and config.container_secret_env: + secret_env.update(build_custom_secret_env(config.container_secret_env)) backend._syncer.sync_full_config( pname, agent_name=agent_name, provider=provider_name, secret_env=secret_env ) diff --git a/src/paude/config/models.py b/src/paude/config/models.py index 0793d85..699bc71 100644 --- a/src/paude/config/models.py +++ b/src/paude/config/models.py @@ -44,6 +44,11 @@ class PaudeConfig: # Container environment variables container_env: dict[str, str] = field(default_factory=dict) + # Secret environment variable mapping (container_name -> host_name). + # Values read from host os.environ, injected securely via tmpfs/exec, + # never in container spec. + container_secret_env: dict[str, str] = field(default_factory=dict) + # Additional packages to install (paude.json format) packages: list[str] = field(default_factory=list) diff --git a/src/paude/config/parser.py b/src/paude/config/parser.py index a1514a2..0299fb2 100644 --- a/src/paude/config/parser.py +++ b/src/paude/config/parser.py @@ -123,11 +123,15 @@ def _parse_devcontainer(config_file: Path, data: dict[str, Any]) -> PaudeConfig: # Parse containerEnv container_env = data.get("containerEnv", {}) + # Parse secretEnv from customizations.paude.secretEnv + paude_customizations = data.get("customizations", {}).get("paude", {}) + container_secret_env = _parse_secret_env(paude_customizations.get("secretEnv")) + # Warn about unsupported properties _warn_unsupported_properties(data) # Parse create hints from customizations.paude.create - create_section = data.get("customizations", {}).get("paude", {}).get("create", {}) + create_section = paude_customizations.get("create", {}) create_allowed_domains, create_agent, create_provider, create_otel_endpoint = ( _parse_create_section(create_section) ) @@ -141,6 +145,7 @@ def _parse_devcontainer(config_file: Path, data: dict[str, Any]) -> PaudeConfig: features=features, post_create_command=post_create_command, container_env=container_env, + container_secret_env=container_secret_env, build_args=build_args, create_allowed_domains=create_allowed_domains, create_agent=create_agent, @@ -178,6 +183,9 @@ def _parse_paude_json(config_file: Path, data: dict[str, Any]) -> PaudeConfig: file=sys.stderr, ) + # Parse secretEnv + container_secret_env = _parse_secret_env(data.get("secretEnv")) + # Parse "create" section for create hints create_allowed_domains, create_agent, create_provider, create_otel_endpoint = ( _parse_create_section(data.get("create", {})) @@ -192,6 +200,7 @@ def _parse_paude_json(config_file: Path, data: dict[str, Any]) -> PaudeConfig: build_args=build_args, packages=packages, post_create_command=setup_command, + container_secret_env=container_secret_env, create_allowed_domains=create_allowed_domains, create_agent=create_agent, create_provider=create_provider, @@ -199,6 +208,21 @@ def _parse_paude_json(config_file: Path, data: dict[str, Any]) -> PaudeConfig: ) +def _parse_secret_env(raw: object) -> dict[str, str]: + """Parse secretEnv config into a container_name -> host_name mapping. + + Format: {"CONTAINER_NAME": "HOST_NAME"} + Use the same name for both when no remapping is needed. + """ + if raw is None: + return {} + if isinstance(raw, dict): + return { + k: v for k, v in raw.items() if isinstance(k, str) and isinstance(v, str) + } + return {} + + _KNOWN_CREATE_KEYS = {"allowed-domains", "agent", "provider", "otel-endpoint"} diff --git a/tests/test_upgrade.py b/tests/test_upgrade.py index 5d08df8..dbeefeb 100644 --- a/tests/test_upgrade.py +++ b/tests/test_upgrade.py @@ -173,7 +173,7 @@ def test_upgrade_podman_preserves_volume( mock_image_manager.ensure_default_image.return_value = "paude:latest" mock_image_manager_class.return_value = mock_image_manager - mock_prepare.return_value = (None, [], {}, False) + mock_prepare.return_value = (None, [], {}, False, []) from paude.backends.podman.backend import PodmanBackend @@ -236,6 +236,7 @@ def test_upgrade_podman_reads_labels( [], {}, False, + [], ) from paude.backends.podman.backend import PodmanBackend @@ -277,7 +278,7 @@ def test_upgrade_podman_rebuilds_image( mock_image_manager.ensure_default_image.return_value = "paude:latest" mock_image_manager_class.return_value = mock_image_manager - mock_prepare.return_value = (None, [], {}, False) + mock_prepare.return_value = (None, [], {}, False, []) from paude.backends.podman.backend import PodmanBackend @@ -318,7 +319,7 @@ def test_upgrade_podman_removes_proxy( mock_image_manager.ensure_proxy_image.return_value = "proxy:rebuilt" mock_image_manager_class.return_value = mock_image_manager - mock_prepare.return_value = ([".googleapis.com"], [], {}, False) + mock_prepare.return_value = ([".googleapis.com"], [], {}, False, []) from paude.backends.podman.backend import PodmanBackend @@ -361,7 +362,7 @@ def test_upgrade_podman_no_proxy( mock_image_manager.ensure_default_image.return_value = "paude:latest" mock_image_manager_class.return_value = mock_image_manager - mock_prepare.return_value = (None, [], {}, False) + mock_prepare.return_value = (None, [], {}, False, []) from paude.backends.podman.backend import PodmanBackend @@ -1041,7 +1042,7 @@ def test_upgrade_adds_otel_endpoint( mock_image_manager.ensure_proxy_image.return_value = "proxy:latest" mock_image_manager_class.return_value = mock_image_manager - mock_prepare.return_value = ([".googleapis.com"], [], {}, False) + mock_prepare.return_value = ([".googleapis.com"], [], {}, False, []) from paude.backends.podman.backend import PodmanBackend @@ -1084,7 +1085,7 @@ def test_upgrade_clears_otel_endpoint( mock_image_manager.ensure_proxy_image.return_value = "proxy:latest" mock_image_manager_class.return_value = mock_image_manager - mock_prepare.return_value = ([".googleapis.com"], [], {}, False) + mock_prepare.return_value = ([".googleapis.com"], [], {}, False, []) from paude.backends.podman.backend import PodmanBackend @@ -1127,7 +1128,7 @@ def test_upgrade_preserves_existing_otel( mock_image_manager.ensure_proxy_image.return_value = "proxy:latest" mock_image_manager_class.return_value = mock_image_manager - mock_prepare.return_value = ([".googleapis.com"], [], {}, False) + mock_prepare.return_value = ([".googleapis.com"], [], {}, False, []) from paude.backends.podman.backend import PodmanBackend