Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 9 additions & 2 deletions docs/OPENSHIFT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<VAR_NAME>` 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

Expand Down
1 change: 1 addition & 0 deletions docs/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/paude/backends/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
20 changes: 20 additions & 0 deletions src/paude/backends/openshift/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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]]:
Expand Down
11 changes: 11 additions & 0 deletions src/paude/backends/openshift/session_connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions src/paude/backends/openshift/session_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 (
Expand All @@ -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()
)
53 changes: 46 additions & 7 deletions src/paude/backends/podman/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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)

Expand All @@ -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)
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -595,14 +631,17 @@ 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)
try:
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)
Expand Down
Loading