Skip to content
Merged
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
9 changes: 5 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ help:

# Detect native architecture for builds
NATIVE_ARCH := $(shell uname -m | sed 's/x86_64/amd64/')
PYTEST := env -u NO_COLOR FORCE_COLOR=1 TERM=xterm-256color uv run pytest

# Build images locally (native arch, for development)
build:
Expand All @@ -66,19 +67,19 @@ run:

# Run unit tests (default, fast - integration tests excluded via pyproject.toml)
test:
uv run pytest --cov=paude --cov-report=term-missing
$(PYTEST) --cov=paude --cov-report=term-missing

# Run all integration tests (requires infrastructure)
test-integration:
uv run pytest tests/integration/ -v -m integration
$(PYTEST) tests/integration/ -v -m integration

# Run all tests (unit + integration, for CI)
test-all:
uv run pytest -o "addopts=-v" --cov=paude --cov-report=term-missing
$(PYTEST) -o "addopts=-v" --cov=paude --cov-report=term-missing

# Run Podman integration tests
test-podman:
uv run pytest tests/integration/ -v -m podman
$(PYTEST) tests/integration/ -v -m podman

# Development targets
install:
Expand Down
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,37 @@ an upgrade is interrupted (e.g. `Ctrl-C`) you can simply re-run
existing session in place, e.g. `paude upgrade SESSION --add-agent codex`. See
[Session Management](docs/SESSIONS.md) for the per-agent persistence paths.

### Updating allowed domains safely

Change a running session's egress policy with `paude allowed-domains`:

```bash
paude allowed-domains SESSION --add .example.com
paude allowed-domains SESSION --remove .example.com
paude allowed-domains SESSION --replace default .example.com
```

Domain-only updates preserve every credential binding already attached to the
proxy; they do not re-read possibly missing or stale values from the invoking
shell. Paude preflights the replacement and retains the existing proxy until
the replacement is running and the new domains are committed. A failed update
restores the old proxy and policy instead of leaving the session without an
authenticated route. Committed domains survive proxy recovery and are used by
subsequent backups and upgrades.

To deliberately replace credentials whose fresh values are available in the
current environment, add `--refresh-credentials` to a domain mutation. Fresh
values replace matching bindings while unrelated credentials remain attached:

```bash
export CLAUDE_CODE_OAUTH_TOKEN=new-setup-token
paude allowed-domains SESSION --add .example.com --refresh-credentials
```

If a required binding is neither attached nor supplied for an explicit
refresh, the command fails before changing the working proxy and names the
missing environment variable.

### Backing up a session

To guard a long-running session against loss, snapshot it to a portable bundle:
Expand Down
10 changes: 9 additions & 1 deletion src/paude/backends/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,12 +235,20 @@ def get_proxy_blocked_log(self, name: str) -> str | None:
"""
...

def update_allowed_domains(self, name: str, domains: list[str]) -> None:
def update_allowed_domains(
self,
name: str,
domains: list[str],
*,
refresh_credentials: bool = False,
) -> None:
"""Update allowed domains for a session.

Args:
name: Session name.
domains: New list of allowed domains.
refresh_credentials: Replace bindings for credentials currently
supplied by the host while preserving all other bindings.
"""
...

Expand Down
30 changes: 26 additions & 4 deletions src/paude/backends/podman/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -413,16 +413,38 @@ def get_proxy_blocked_log(self, name: str) -> str | None:
require_session(self._runner, name)
return self._proxy.get_blocked_log(name)

def update_allowed_domains(self, name: str, domains: list[str]) -> None:
def update_allowed_domains(
self,
name: str,
domains: list[str],
*,
refresh_credentials: bool = False,
) -> None:
"""Update allowed domains for a session."""
require_session(self._runner, name)
composition = get_session_composition(self._runner, name)
from paude.backends.podman.helpers import get_session_credential_providers
from paude.backends.proxy_config import (
ProxyCredentials,
proxy_credential_targets,
required_proxy_credential_targets,
)

proxy_creds = self._setup.gather_proxy_credentials(
composition, get_session_credential_providers(self._runner, name)
providers = get_session_credential_providers(self._runner, name)
refresh = (
self._setup.gather_proxy_credentials(composition, providers)
if refresh_credentials
else ProxyCredentials(chatgpt_oauth_mode="chatgpt" in providers)
)
self._proxy.update_domains(
name,
domains,
credentials=refresh,
credential_targets=proxy_credential_targets(composition),
required_credentials=required_proxy_credential_targets(
composition, providers
),
)
self._proxy.update_domains(name, domains, credentials=proxy_creds)

def exec_in_session(self, name: str, command: str) -> tuple[int, str, str]:
"""Execute a command inside a running session's container."""
Expand Down
85 changes: 67 additions & 18 deletions src/paude/backends/podman/proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
proxy_container_name,
)
from paude.backends.podman.proxy_credentials import ProxyCredentialManager
from paude.backends.podman.proxy_state import ProxyStateStore
from paude.backends.proxy_config import CA_CERT_CONTAINER_PATH as CA_CERT_CONTAINER_PATH
from paude.backends.proxy_config import (
PROXY_BLOCKED_LOG_PATH,
Expand Down Expand Up @@ -98,6 +99,7 @@ def __init__(
self._proxy_runner = ProxyRunner(runner)
self._ca_cert = CACertDistributor(runner)
self._credentials = ProxyCredentialManager(runner)
self._state = ProxyStateStore(runner)

def _create_credential_secrets(
self,
Expand Down Expand Up @@ -145,12 +147,23 @@ def get_config_from_labels(
return None

domains = [d for d in domains_str.split(",") if d]
durable_domains = self.read_domain_state(session_name, proxy_image)
if durable_domains is not None:
domains = durable_domains

otel_ports_str = labels.get(PAUDE_LABEL_OTEL_PORTS, "")
otel_ports = [int(p) for p in otel_ports_str.split(",") if p]

return (proxy_image, domains, otel_ports)

def read_domain_state(
self, session_name: str, proxy_image: str | None
) -> list[str] | None:
"""Read the committed domain override for a session, if one exists."""
if not proxy_image:
return None
return self._state.read(auth_volume_name(session_name), proxy_image)

def start_if_needed(
self,
session_name: str,
Expand Down Expand Up @@ -414,8 +427,10 @@ def update_domains(
session_name: str,
domains: list[str],
credentials: ProxyCredentials | Mapping[str, str] | None = None,
credential_targets: set[str] | None = None,
required_credentials: set[str] | None = None,
) -> None:
"""Update allowed domains for a session."""
"""Update domains using preserved credentials and a rollback-safe swap."""
pname = proxy_container_name(session_name)
if not self._runner.container_exists(pname):
raise ValueError(
Expand Down Expand Up @@ -445,28 +460,62 @@ def update_domains(
agent_ip = derive_agent_ip(proxy_ip) if proxy_ip else None
dns = _get_host_dns(self._runner.engine)

secret_refs = self._create_credential_secrets(session_name, credentials)
credential_env = self._credential_env(credentials)
if credentials is None:
credentials = ProxyCredentials()
elif not isinstance(credentials, ProxyCredentials):
credentials = ProxyCredentials(environment=dict(credentials))
previous_domains = self._state.read(auth_vol, proxy_image)
prepared = self._credentials.prepare_update(
session_name,
pname,
credentials,
credential_targets or set(),
required_credentials or set(),
)
credential_env = self._credential_env(prepared.credentials)

print(
f"Updating proxy domains for session '{session_name}'...",
file=sys.stderr,
)
self._proxy_runner.recreate_session_proxy(
name=pname,
image=proxy_image,
network=nname,
dns=dns,
allowed_domains=domains,
ip=proxy_ip,
otel_ports=otel_ports,
ca_volume=ca_vol,
credentials=credentials,
allowed_clients=agent_ip,
secret_refs=secret_refs,
credential_env=credential_env,
auth_volume=auth_vol,
)
swap = None
try:
swap = self._proxy_runner.swap_session_proxy(
name=pname,
image=proxy_image,
network=nname,
dns=dns,
allowed_domains=domains,
ip=proxy_ip,
otel_ports=otel_ports,
ca_volume=ca_vol,
credentials=prepared.credentials,
allowed_clients=agent_ip,
secret_refs=prepared.secret_refs,
credential_env=credential_env,
auth_volume=auth_vol,
)
self._state.write(auth_vol, proxy_image, domains)
swap.commit()
except Exception as primary:
rollback_failures: list[str] = []
if swap is not None:
try:
self._state.restore(auth_vol, proxy_image, previous_domains)
except Exception as exc:
rollback_failures.append(f"state restore failed: {exc}")
try:
swap.rollback()
except Exception as exc:
rollback_failures.append(f"proxy restore failed: {exc}")
self._credentials.rollback_update(prepared)
if rollback_failures:
raise ProxyStartError(
f"Proxy update failed: {primary}; " + "; ".join(rollback_failures)
) from primary
raise

self._credentials.commit_update(prepared)

# Verify CA cert survived the recreate (same named volume = same cert).
# If the cert is missing or changed, redistribute to the agent.
Expand Down
Loading