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
17 changes: 17 additions & 0 deletions KNOWN_ISSUES.md
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,23 @@ registry records them as docker. A fix would either treat a shim `docker` as
podman for capabilities, or reject/redirect `--backend=docker` when the shim is
detected.

### PROXY-001: recreating a missing proxy on `start`/`connect` drops unset credentials

**Status**: Open
**Severity**: Low
**Discovered**: 2026-09-29 while enforcing required provider secrets on `create`/`upgrade`

`paude create` and `paude upgrade` now refuse to run when a provider's
`required_secret_env_vars` are unset on the host. `start` and `connect` are not
checked, which is correct when the proxy container still exists (it keeps its
original credential bindings). But when the proxy is missing,
`PodmanProxyManager.start_if_needed` recreates it from
`gather_proxy_credentials()`, which silently skips unset host variables. A
session recreated that way comes up with no `CLAUDE_CODE_OAUTH_TOKEN` (or API
key) binding, even though the Podman secret from `create` may still exist. A
fix would either reuse the session's existing credential secrets when
recreating, or run `check_required_secrets` in that branch only.

## Agent Limitations

Issues caused by upstream agent behavior, not paude bugs.
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,10 @@ the real token never reaches the agent container (the agent only sees a
`paude-proxy-managed` sentinel). Because the token does not rotate, one token can
be shared across all your sessions — the same value must be present on later
`start`/`connect`/`upgrade`. When it expires, re-run `claude setup-token`, export
the new value, and upgrade (or recreate) the session.
the new value, and upgrade (or recreate) the session. `paude create` and
`paude upgrade` fail up front if a provider's required variable (here
`CLAUDE_CODE_OAUTH_TOKEN`; `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` for the
API-key providers) is not set.

The token is used only by the official `claude` binary running in the session
(including when Gas City's `gc` spawns it). Sharing one subscription seat across
Expand Down
8 changes: 8 additions & 0 deletions src/paude/cli/create.py
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,14 @@ def session_create(
)
raise typer.Exit()

from paude.providers import check_required_secrets

try:
check_required_secrets(resolved.providers)
except ValueError as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1) from None

if ssh_key and not host:
typer.echo(
"Error: --ssh-key requires --host.",
Expand Down
43 changes: 37 additions & 6 deletions src/paude/cli/upgrade.py
Original file line number Diff line number Diff line change
Expand Up @@ -349,14 +349,15 @@ def session_upgrade(
err=True,
)

# Auto-stop if running
if session is not None and session.status == "running":
typer.echo(f"Stopping session '{name}'...", err=True)
backend_obj.stop_session(name)

try:
if isinstance(backend_obj, PodmanBackend):
_upgrade_podman(name, backend_obj, True, overrides)
_upgrade_podman(
name,
backend_obj,
True,
overrides,
stop_running=session is not None and session.status == "running",
)
else:
typer.echo("Unsupported backend for upgrade.", err=True)
raise typer.Exit(1)
Expand Down Expand Up @@ -612,6 +613,8 @@ def _upgrade_podman(
backend: PodmanBackend,
rebuild: bool,
overrides: UpgradeOverrides,
*,
stop_running: bool = False,
) -> None:
"""Upgrade a Podman/Docker session in place.

Expand All @@ -630,6 +633,13 @@ def _upgrade_podman(

state, created_at = _resolve_upgrade_state(name, backend)
_apply_overrides(state, overrides)
_require_provider_secrets(name, state.spec.credential_providers)

# Stopped only once the preflight checks pass, so a refused upgrade leaves
# a running session running.
if stop_running:
typer.echo(f"Stopping session '{name}'...", err=True)
backend.stop_session(name)

# Persist the fully-resolved config BEFORE any destructive step, so an
# interrupt from here on can be finished by re-running the upgrade.
Expand Down Expand Up @@ -669,6 +679,27 @@ def _upgrade_podman(
_recreate_session(name, backend, state, images, config)


def _require_provider_secrets(name: str, credential_providers: list[str]) -> None:
"""Refuse to rebuild when the host lacks a provider's required secrets.

The proxy is recreated from the host environment, so rebuilding without
them would leave the session unable to authenticate. Checked before the
manifest is saved or anything is torn down.
"""
from paude.providers import check_required_secrets

try:
check_required_secrets(credential_providers)
except ValueError as e:
typer.echo(f"Error: {e}", err=True)
typer.echo(
f"Session '{name}' was not modified. Export the missing "
f"variables and re-run 'paude upgrade {name}'.",
err=True,
)
raise typer.Exit(1) from None


def _recreate_session(
name: str,
backend: PodmanBackend,
Expand Down
8 changes: 7 additions & 1 deletion src/paude/providers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,17 @@
resolve_agent_provider,
supported_providers,
)
from paude.providers.base import ProviderConfig, get_provider, list_providers
from paude.providers.base import (
ProviderConfig,
check_required_secrets,
get_provider,
list_providers,
)

__all__ = [
"AgentProviderConfig",
"ProviderConfig",
"check_required_secrets",
"get_provider",
"list_providers",
"resolve_agent_provider",
Expand Down
30 changes: 30 additions & 0 deletions src/paude/providers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

from __future__ import annotations

import os
from collections.abc import Iterable, Mapping
from dataclasses import dataclass, field


Expand All @@ -17,6 +19,7 @@ class ProviderConfig:
required_secret_env_vars: Secure env vars required for this provider's
proxy-backed authentication mode. A secret may be optional when the
provider supports an alternative login flow.
auth_hint: How to obtain the required secrets, shown when missing.
passthrough_env_prefixes: Host env var prefixes to forward.
domain_aliases: Domain aliases to auto-include in allowed-domains.
"""
Expand All @@ -26,6 +29,7 @@ class ProviderConfig:
passthrough_env_vars: list[str] = field(default_factory=list)
secret_env_vars: list[str] = field(default_factory=list)
required_secret_env_vars: list[str] = field(default_factory=list)
auth_hint: str = ""
passthrough_env_prefixes: list[str] = field(default_factory=list)
domain_aliases: list[str] = field(default_factory=list)

Expand Down Expand Up @@ -73,6 +77,7 @@ class ProviderConfig:
# `paude-proxy-managed` sentinel (set per-agent via extra_env_vars).
secret_env_vars=["CLAUDE_CODE_OAUTH_TOKEN"],
required_secret_env_vars=["CLAUDE_CODE_OAUTH_TOKEN"],
auth_hint="run `claude setup-token` on the host and export the token",
domain_aliases=["claude"],
),
"cursor": ProviderConfig(
Expand Down Expand Up @@ -112,3 +117,28 @@ def get_provider(name: str) -> ProviderConfig:
def list_providers() -> list[str]:
"""List all registered provider names."""
return sorted(_PROVIDERS.keys())


def check_required_secrets(
provider_names: Iterable[str],
environ: Mapping[str, str] | None = None,
) -> None:
"""Fail if any provider's required secret env vars are unset on the host.

Raises:
ValueError: Naming each missing variable and the provider needing it.
"""
env = os.environ if environ is None else environ
problems: list[str] = []
for name in dict.fromkeys(provider_names):
provider = get_provider(name)
missing = [key for key in provider.required_secret_env_vars if not env.get(key)]
if not missing:
continue
hint = f"; {provider.auth_hint}" if provider.auth_hint else ""
problems.append(f"{', '.join(missing)} (required by provider '{name}'{hint})")
if problems:
raise ValueError(
"Missing required credentials in the host environment: "
+ "; ".join(problems)
)
3 changes: 3 additions & 0 deletions tests/integration/test_upgrade_podman.py
Original file line number Diff line number Diff line change
Expand Up @@ -407,8 +407,11 @@ def test_upgrade_swap_codex_chatgpt_to_openai(
unique_session_name: str,
podman_test_image: str,
podman_proxy_image: str,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Swapping codex chatgpt->openai strips config and clears auth.json."""
# Upgrade refuses to add a provider whose required secret is unset.
monkeypatch.setenv("OPENAI_API_KEY", "sk-test-dummy")
backend = PodmanBackend()

try:
Expand Down
49 changes: 48 additions & 1 deletion tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,50 @@ def test_gascity_claude_codex_swap_to_anthropic_oauth(self):
assert "claude -> anthropic-oauth" in out
assert "codex -> chatgpt" in out

@patch("paude.cli.create_podman.create_podman_session")
@patch("paude.cli.create._prepare_session_create")
def test_create_without_oauth_token_fails(self, mock_prepare, mock_create):
"""create refuses to build a session whose proxy would lack the token."""
result = runner.invoke(
app,
["create", "--agent", "claude", "--provider", "anthropic-oauth"],
env={"CLAUDE_CODE_OAUTH_TOKEN": None},
)
assert result.exit_code == 1
output = result.stdout + (result.stderr or "")
assert "CLAUDE_CODE_OAUTH_TOKEN" in output
assert "claude setup-token" in output
mock_prepare.assert_not_called()
mock_create.assert_not_called()

@patch("paude.cli.create_podman.create_podman_session")
@patch("paude.cli.create._prepare_session_create")
def test_create_with_oauth_token_proceeds(self, mock_prepare, mock_create):
mock_prepare.return_value = ([], [], {}, False)
result = runner.invoke(
app,
["create", "--agent", "claude", "--provider", "anthropic-oauth"],
env={"CLAUDE_CODE_OAUTH_TOKEN": "sk-ant-oat01-test"},
)
assert result.exit_code == 0
mock_create.assert_called_once()

def test_dry_run_without_oauth_token_still_works(self):
"""Dry-run only previews the config, so it does not require secrets."""
result = runner.invoke(
app,
[
"create",
"--agent",
"claude",
"--provider",
"anthropic-oauth",
"--dry-run",
],
env={"CLAUDE_CODE_OAUTH_TOKEN": None},
)
assert result.exit_code == 0

def test_codex_anthropic_oauth_rejected(self):
"""anthropic-oauth is not a valid provider for codex."""
result = runner.invoke(
Expand Down Expand Up @@ -708,6 +752,7 @@ def test_explicit_mappings_and_extra_credentials_pass_to_create(
"--agent-provider",
"claude=anthropic,codex=openai",
],
env={"ANTHROPIC_API_KEY": "sk-ant", "OPENAI_API_KEY": "sk-oai"},
)
assert result.exit_code == 0
mock_create.assert_called_once()
Expand All @@ -727,7 +772,9 @@ def test_single_agent_extra_provider_is_allowed(self, mock_prepare, mock_create)
"""An extra credential provider need not map to an agent."""
mock_prepare.return_value = ([], [], {}, False)
result = runner.invoke(
app, ["create", "--agents", "claude", "--providers", "vertex,openai"]
app,
["create", "--agents", "claude", "--providers", "vertex,openai"],
env={"OPENAI_API_KEY": "sk-oai"},
)
assert result.exit_code == 0
mock_create.assert_called_once()
Expand Down
41 changes: 40 additions & 1 deletion tests/test_providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@
resolve_agent_provider,
supported_providers,
)
from paude.providers.base import ProviderConfig, get_provider, list_providers
from paude.providers.base import (
ProviderConfig,
check_required_secrets,
get_provider,
list_providers,
)


class TestProviderRegistry:
Expand Down Expand Up @@ -273,3 +278,37 @@ def test_results_are_sorted(self) -> None:
for agent_name in AGENT_PROVIDERS:
providers = supported_providers(agent_name)
assert providers == sorted(providers)


class TestCheckRequiredSecrets:
"""Tests for host-side validation of required provider secrets."""

def test_present_secret_passes(self) -> None:
check_required_secrets(
["anthropic-oauth"], environ={"CLAUDE_CODE_OAUTH_TOKEN": "tok"}
)

def test_providers_without_required_secrets_pass(self) -> None:
check_required_secrets(["vertex", "chatgpt", "cursor", "google"], environ={})

def test_missing_oauth_token_names_var_and_hint(self) -> None:
with pytest.raises(ValueError, match="Missing required") as exc:
check_required_secrets(["anthropic-oauth"], environ={})
message = str(exc.value)
assert "CLAUDE_CODE_OAUTH_TOKEN" in message
assert "anthropic-oauth" in message
assert "claude setup-token" in message

def test_empty_value_counts_as_missing(self) -> None:
with pytest.raises(ValueError, match="OPENAI_API_KEY"):
check_required_secrets(["openai"], environ={"OPENAI_API_KEY": ""})

def test_reports_every_missing_provider(self) -> None:
with pytest.raises(ValueError, match="Missing required") as exc:
check_required_secrets(
["anthropic", "vertex", "openai"],
environ={"ANTHROPIC_API_KEY": "k"},
)
message = str(exc.value)
assert "OPENAI_API_KEY" in message
assert "ANTHROPIC_API_KEY" not in message
Loading