From 1974d42df07bc1f95e81261f8c58675c05ed4ee8 Mon Sep 17 00:00:00 2001 From: Beon de Nood Date: Mon, 2 Mar 2026 16:46:36 -0500 Subject: [PATCH 1/4] feat: env var key injection for ephemeral environments (CAPISCIO_AGENT_PRIVATE_KEY_JWK) Add support for injecting the agent private key via environment variable for containerised/serverless deployments where ~/.capiscio is ephemeral. Key priority: env var > local file > generate new via Init RPC. On first-run identity generation, a capture hint is logged to stderr with the compact JSON JWK for the operator to persist in their secrets manager. - Add _public_jwk_from_private() and _log_agent_key_capture_hint() helpers - Add ENV_AGENT_PRIVATE_KEY constant - Rewrite _init_identity() with three-source priority - Update from_env() docs - Add 4 unit tests for env var injection and capture hint --- capiscio_sdk/connect.py | 94 +++++++++++++++++++++++--- tests/unit/test_connect.py | 132 +++++++++++++++++++++++++++++++++++++ 2 files changed, 216 insertions(+), 10 deletions(-) diff --git a/capiscio_sdk/connect.py b/capiscio_sdk/connect.py index f98e73f..bfae092 100644 --- a/capiscio_sdk/connect.py +++ b/capiscio_sdk/connect.py @@ -38,6 +38,41 @@ PROD_REGISTRY = "https://registry.capisc.io" PROD_DASHBOARD = "https://app.capisc.io" +# Env var for injecting the private key in ephemeral environments +ENV_AGENT_PRIVATE_KEY = "CAPISCIO_AGENT_PRIVATE_KEY_JWK" + + +# ============================================================================= +# Key injection helpers +# ============================================================================= + + +def _public_jwk_from_private(private_jwk: dict) -> dict: + """Derive the public JWK from a private JWK (remove 'd' parameter).""" + public = {k: v for k, v in private_jwk.items() if k != "d"} + return public + + +def _log_agent_key_capture_hint(agent_id: str, private_jwk: dict) -> None: + """Log a one-time hint telling the user how to persist key material.""" + compact_json = json.dumps(private_jwk, separators=(",", ":")) + logger.warning( + "\n" + " \u2554" + "\u2550" * 62 + "\u2557\n" + " \u2551 New agent identity generated \u2014 save key for persistence \u2551\n" + " \u255a" + "\u2550" * 62 + "\u255d\n" + "\n" + " If this agent runs in an ephemeral environment (containers,\n" + " serverless, CI) the identity will be lost on restart unless\n" + " you persist the private key.\n" + "\n" + " Add to your secrets manager / .env:\n" + "\n" + " CAPISCIO_AGENT_PRIVATE_KEY_JWK='" + compact_json + "'\n" + "\n" + " The DID will be recovered automatically from the JWK on startup.\n" + ) + # ============================================================================= # Standalone Helper Functions (for testing and direct use) @@ -278,6 +313,8 @@ def from_env(cls, **kwargs) -> AgentIdentity: - CAPISCIO_AGENT_NAME (optional) - CAPISCIO_SERVER_URL (optional, default: production) - CAPISCIO_DEV_MODE (optional, default: false) + - CAPISCIO_AGENT_PRIVATE_KEY_JWK (optional — JSON-encoded Ed25519 + private JWK for ephemeral environments; printed on first generation) """ api_key = os.environ.get("CAPISCIO_API_KEY") if not api_key: @@ -568,16 +605,44 @@ def _init_identity(self) -> str: All cryptographic operations are performed by capiscio-core Go library. - Identity Recovery: - - If keys exist locally (private.jwk + public.jwk), we derive the DID - from public.jwk's `kid` field (per RFC-002 §6.1: did:key is self-describing) - - No did.txt file is required - it's redundant - - If server has a did:web assigned, we use that instead + Identity Recovery (priority order): + - CAPISCIO_AGENT_PRIVATE_KEY_JWK env var (ephemeral / containerised) + - Local keys on disk (private.jwk + public.jwk) + - Generate new identity via Init RPC (first run) """ private_key_path = self.keys_dir / "private.jwk" public_key_path = self.keys_dir / "public.jwk" - - # Check if we already have keys (for idempotency) + + # ------------------------------------------------------------------ + # Source 1: Environment variable (highest priority) + # ------------------------------------------------------------------ + env_jwk_raw = os.environ.get(ENV_AGENT_PRIVATE_KEY) + if env_jwk_raw: + try: + private_jwk = json.loads(env_jwk_raw) + did = private_jwk.get("kid") + if not did or not did.startswith("did:"): + raise ValueError("JWK is missing a valid 'kid' field with a DID") + + logger.info(f"Loaded agent identity from {ENV_AGENT_PRIVATE_KEY}: {did}") + + # Derive public JWK and persist to disk for subsequent restarts + public_jwk = _public_jwk_from_private(private_jwk) + self.keys_dir.mkdir(parents=True, exist_ok=True) + private_key_path.write_text(json.dumps(private_jwk, indent=2)) + os.chmod(private_key_path, 0o600) + public_key_path.write_text(json.dumps(public_jwk, indent=2)) + + # Register with server (idempotent) + server_did = self._ensure_did_registered(did, public_jwk) + return server_did if server_did else did + + except (json.JSONDecodeError, ValueError) as e: + logger.error(f"Invalid {ENV_AGENT_PRIVATE_KEY}: {e} — falling through to local keys") + + # ------------------------------------------------------------------ + # Source 2: Local keys on disk + # ------------------------------------------------------------------ if private_key_path.exists() and public_key_path.exists(): logger.debug("Found existing keys - recovering identity") @@ -598,8 +663,9 @@ def _init_identity(self) -> str: except (json.JSONDecodeError, IOError) as e: logger.warning(f"Failed to read public.jwk: {e} - regenerating") - # No valid keys exist - generate new identity via Init RPC - # Connect to capiscio-core gRPC + # ------------------------------------------------------------------ + # Source 3: Generate new identity via Init RPC (first run) + # ------------------------------------------------------------------ if not self._rpc_client: self._rpc_client = CapiscioRPCClient() self._rpc_client.connect() @@ -625,7 +691,15 @@ def _init_identity(self) -> str: logger.info(f"Identity initialized: {did}") if result.get("registered"): logger.info("DID registered with server") - + + # Log capture hint for ephemeral environments + if private_key_path.exists(): + try: + private_jwk = json.loads(private_key_path.read_text()) + _log_agent_key_capture_hint(self.agent_id, private_jwk) + except Exception: + pass # Best-effort hint + return did def _ensure_did_registered(self, did: str, public_jwk: dict) -> Optional[str]: diff --git a/tests/unit/test_connect.py b/tests/unit/test_connect.py index 89c87f2..2d610a7 100644 --- a/tests/unit/test_connect.py +++ b/tests/unit/test_connect.py @@ -1,5 +1,6 @@ """Unit tests for capiscio_sdk.connect module.""" +import json import os import pytest import httpx @@ -14,7 +15,10 @@ ConfigurationError, DEFAULT_CONFIG_DIR, DEFAULT_KEYS_DIR, + ENV_AGENT_PRIVATE_KEY, PROD_REGISTRY, + _log_agent_key_capture_hint, + _public_jwk_from_private, ) @@ -834,6 +838,134 @@ def test_init_identity_rpc_error(self, tmp_path): with pytest.raises(ConfigurationError, match="Failed to initialize identity"): connector._init_identity() + def test_init_identity_uses_env_var_jwk(self, tmp_path): + """_init_identity should load identity from CAPISCIO_AGENT_PRIVATE_KEY_JWK.""" + private_jwk = { + "kty": "OKP", + "crv": "Ed25519", + "d": "nWGxne_9WmC6hEr0kuwsxERJxWl7MmkZcDusAxyuf2A", + "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo", + "kid": "did:key:z6MkEnvVar", + } + + connector = _Connector( + api_key="sk_test", + name="Test", + agent_id="agent-123", + server_url="https://test.server.com", + keys_dir=tmp_path, + auto_badge=False, + dev_mode=False, + ) + connector._ensure_did_registered = MagicMock(return_value=None) + + with patch.dict(os.environ, {ENV_AGENT_PRIVATE_KEY: json.dumps(private_jwk)}): + result = connector._init_identity() + + assert result == "did:key:z6MkEnvVar" + # Should have persisted keys to disk + assert (tmp_path / "private.jwk").exists() + priv_on_disk = json.loads((tmp_path / "private.jwk").read_text()) + assert priv_on_disk["kid"] == "did:key:z6MkEnvVar" + assert (tmp_path / "public.jwk").exists() + pub_on_disk = json.loads((tmp_path / "public.jwk").read_text()) + assert "d" not in pub_on_disk # public JWK must not contain private key + + def test_init_identity_env_var_precedence_over_local(self, tmp_path): + """Env var key should override a different key on disk.""" + # Write local keys with a different DID + tmp_path.mkdir(parents=True, exist_ok=True) + (tmp_path / "private.jwk").write_text(json.dumps({ + "kty": "OKP", "crv": "Ed25519", "kid": "did:key:z6MkLocal", + "d": "old", "x": "old", + })) + (tmp_path / "public.jwk").write_text(json.dumps({ + "kty": "OKP", "crv": "Ed25519", "kid": "did:key:z6MkLocal", + "x": "old", + })) + + env_jwk = { + "kty": "OKP", "crv": "Ed25519", + "d": "new_private", "x": "new_public", + "kid": "did:key:z6MkFromEnv", + } + + connector = _Connector( + api_key="sk_test", + name="Test", + agent_id="agent-123", + server_url="https://test.server.com", + keys_dir=tmp_path, + auto_badge=False, + dev_mode=False, + ) + connector._ensure_did_registered = MagicMock(return_value=None) + + with patch.dict(os.environ, {ENV_AGENT_PRIVATE_KEY: json.dumps(env_jwk)}): + result = connector._init_identity() + + assert result == "did:key:z6MkFromEnv" # env var wins, not local + + def test_init_identity_logs_capture_hint_on_new_gen(self, tmp_path): + """_init_identity should log a capture hint when generating a new identity.""" + connector = _Connector( + api_key="sk_test", + name="Test", + agent_id="agent-123", + server_url="https://test.server.com", + keys_dir=tmp_path, + auto_badge=False, + dev_mode=False, + ) + + mock_rpc = MagicMock() + mock_rpc.simpleguard.init.return_value = ( + {"did": "did:key:z6MkNew", "registered": True}, + None, + ) + connector._rpc_client = mock_rpc + + # Write the private.jwk that the RPC would create so capture-hint reads it + tmp_path.mkdir(parents=True, exist_ok=True) + (tmp_path / "private.jwk").write_text(json.dumps({ + "kty": "OKP", "crv": "Ed25519", "kid": "did:key:z6MkNew", + "d": "gen", "x": "gen", + })) + + with patch("capiscio_sdk.connect._log_agent_key_capture_hint") as mock_hint: + connector._init_identity() + + mock_hint.assert_called_once() + assert mock_hint.call_args[0][0] == "agent-123" + + def test_init_identity_no_capture_hint_on_recovery(self, tmp_path): + """_init_identity should NOT log a capture hint when recovering from local keys.""" + tmp_path.mkdir(parents=True, exist_ok=True) + (tmp_path / "private.jwk").write_text(json.dumps({ + "kty": "OKP", "crv": "Ed25519", "kid": "did:key:z6MkExisting", + "d": "priv", "x": "pub", + })) + (tmp_path / "public.jwk").write_text(json.dumps({ + "kty": "OKP", "crv": "Ed25519", "kid": "did:key:z6MkExisting", + "x": "pub", + })) + + connector = _Connector( + api_key="sk_test", + name="Test", + agent_id="agent-123", + server_url="https://test.server.com", + keys_dir=tmp_path, + auto_badge=False, + dev_mode=False, + ) + connector._ensure_did_registered = MagicMock(return_value=None) + + with patch("capiscio_sdk.connect._log_agent_key_capture_hint") as mock_hint: + connector._init_identity() + + mock_hint.assert_not_called() + def test_setup_badge_success(self, tmp_path): """Test _setup_badge sets up keeper and guard.""" connector = _Connector( From d0b0f0221c40a3f0cb12203a55657c83bd6512c1 Mon Sep 17 00:00:00 2001 From: Beon de Nood Date: Tue, 3 Mar 2026 10:27:40 -0500 Subject: [PATCH 2/4] docs: document CAPISCIO_AGENT_PRIVATE_KEY_JWK and ephemeral deployment - Add env var table to README with CAPISCIO_AGENT_PRIVATE_KEY_JWK - Add deployment section with capture hint and docker-compose example - Add Agent Identity Variables section to configuration guide - Add ephemeral deployment guidance and key rotation instructions --- README.md | 45 +++++++++++++++++++++++++---- docs/guides/configuration.md | 55 ++++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 0ea701d..b72b777 100644 --- a/README.md +++ b/README.md @@ -171,11 +171,46 @@ agent = CapiscIO.from_env() ``` **Environment Variables:** -- `CAPISCIO_API_KEY` (required) - Your API key -- `CAPISCIO_AGENT_NAME` - Agent name for lookup/creation -- `CAPISCIO_AGENT_ID` - Specific agent UUID -- `CAPISCIO_SERVER_URL` - Registry URL -- `CAPISCIO_DEV_MODE` - Enable dev mode (`true`/`false`) + +| Variable | Required | Description | +|----------|----------|-------------| +| `CAPISCIO_API_KEY` | Yes | Your API key | +| `CAPISCIO_AGENT_NAME` | No | Agent name for lookup/creation | +| `CAPISCIO_AGENT_ID` | No | Specific agent UUID | +| `CAPISCIO_SERVER_URL` | No | Registry URL (default: production) | +| `CAPISCIO_DEV_MODE` | No | Enable dev mode (`true`/`false`) | +| `CAPISCIO_AGENT_PRIVATE_KEY_JWK` | No | JSON-encoded Ed25519 private JWK for ephemeral environments | + +### Deploying to Containers / Serverless + +In ephemeral environments (Docker, Lambda, Cloud Run) the local `~/.capiscio/` directory +doesn't survive restarts. On first run the SDK generates a keypair and logs a capture hint: + +``` +╔══════════════════════════════════════════════════════════════════╗ +║ New agent identity generated — save key for persistence ║ +╚══════════════════════════════════════════════════════════════════╝ + + Add to your secrets manager / .env: + + CAPISCIO_AGENT_PRIVATE_KEY_JWK='{"kty":"OKP","crv":"Ed25519","d":"...","x":"...","kid":"did:key:z6Mk..."}' +``` + +Copy that value into your secrets manager and set it as an environment variable. +On subsequent starts the SDK recovers the same DID without generating a new identity. + +**Key resolution priority:** env var → local file → generate new. + +```yaml +# docker-compose.yml +services: + my-agent: + environment: + CAPISCIO_API_KEY: "sk_live_..." + CAPISCIO_AGENT_PRIVATE_KEY_JWK: "${AGENT_KEY_JWK}" # from secrets +``` + +See the [Configuration Guide](https://docs.capisc.io/reference/sdk-python/config/) for full deployment examples. ## 🎯 Agent Card Validation with CoreValidator diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index 079c9fc..02f7ff4 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -631,6 +631,8 @@ os.environ['CAPISCIO_BINARY'] = '/opt/capiscio/v2.4.0/capiscio-core' services: agent: environment: + - CAPISCIO_API_KEY=sk_live_... + - CAPISCIO_AGENT_PRIVATE_KEY_JWK=${AGENT_KEY_JWK} - CAPISCIO_FAIL_MODE=block - CAPISCIO_RATE_LIMITING=true - CAPISCIO_RATE_LIMIT_RPM=120 @@ -650,8 +652,61 @@ data: CAPISCIO_RATE_LIMITING: "true" CAPISCIO_RATE_LIMIT_RPM: "200" CAPISCIO_TIMEOUT_MS: "5000" +--- +apiVersion: v1 +kind: Secret +metadata: + name: capiscio-identity +type: Opaque +stringData: + api-key: "sk_live_..." + agent-private-key-jwk: '{"kty":"OKP","crv":"Ed25519","d":"...","x":"...","kid":"did:key:z6Mk..."}' +``` + +### Agent Identity Variables (CapiscIO.connect) + +These variables are used by `CapiscIO.connect()` and `CapiscIO.from_env()` for agent identity management: + +| Variable | Required | Description | +|----------|----------|-------------| +| `CAPISCIO_API_KEY` | Yes | Registry API key | +| `CAPISCIO_AGENT_NAME` | No | Agent name for lookup/creation | +| `CAPISCIO_AGENT_ID` | No | Specific agent UUID | +| `CAPISCIO_SERVER_URL` | No | Registry URL (default: `https://registry.capisc.io`) | +| `CAPISCIO_DEV_MODE` | No | Enable dev mode (`true`/`false`) | +| `CAPISCIO_AGENT_PRIVATE_KEY_JWK` | No | JSON-encoded Ed25519 private JWK for ephemeral environments | + +#### Ephemeral Environment Key Injection + +In ephemeral environments (Docker, Lambda, Cloud Run) the local `~/.capiscio/` directory doesn't survive restarts. Set `CAPISCIO_AGENT_PRIVATE_KEY_JWK` to inject the agent's Ed25519 private key from your secrets manager. + +**Key resolution priority:** + +| Priority | Source | When Used | +|----------|--------|-----------| +| 1 | `CAPISCIO_AGENT_PRIVATE_KEY_JWK` env var | Containers, serverless, CI | +| 2 | Local key file (`~/.capiscio/keys/{agent_id}/private.jwk`) | Persistent environments | +| 3 | Generate new via capiscio-core Init RPC | First run only | + +**First-run capture:** On the very first run, the SDK logs a capture hint to stderr with the full JWK. Copy it into your secrets manager: + +``` +CAPISCIO_AGENT_PRIVATE_KEY_JWK='{"kty":"OKP","crv":"Ed25519","d":"...","x":"...","kid":"did:key:z6Mk..."}' ``` +!!! warning "DID Changes on New Key Generation" + If neither the env var nor local files are available, the SDK generates a **new** keypair with a **different** DID. Any badges issued to the old DID will no longer be valid. Always persist the key in ephemeral environments. + +#### Key Rotation + +To rotate the agent identity: + +1. Unset `CAPISCIO_AGENT_PRIVATE_KEY_JWK` +2. Remove local key files (`~/.capiscio/keys/{agent_id}/`) +3. Restart the agent — a new keypair and DID will be generated +4. Capture the new JWK from the log hint +5. Store the new key in your secrets manager + --- ## Middleware Observability (Auto-Events) From ef32833528e0386c7e8aad307eea983b6d041978 Mon Sep 17 00:00:00 2001 From: Beon de Nood Date: Tue, 3 Mar 2026 12:29:07 -0500 Subject: [PATCH 3/4] =?UTF-8?q?fix(docs):=20correct=20did:key=20=E2=86=92?= =?UTF-8?q?=20did:web=20for=20production=20registry=20usage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The registry assigns did:web when an API key is used. did:key is only for local dev mode without a registry. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b72b777..be2d57b 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ from capiscio_sdk import CapiscIO agent = CapiscIO.connect(api_key="sk_live_...") # Agent is now ready -print(agent.did) # did:key:z6Mk... +print(agent.did) # did:web:registry.capisc.io:agents:... print(agent.badge) # Current badge (auto-renewed) print(agent.name) # Agent name ``` From 54805bfc9649983e39520f85f29c480b9b6bae09 Mon Sep 17 00:00:00 2001 From: Beon de Nood Date: Wed, 4 Mar 2026 22:04:10 -0500 Subject: [PATCH 4/4] fix: use importlib to resolve connect module for patch compatibility The 'import capiscio_sdk.connect as connect_module' statement resolves to the CapiscIO.connect classmethod rather than the connect submodule because capiscio_sdk/__init__.py re-exports 'connect'. This causes patch.object() to fail on Python 3.10+ when trying to patch module-level functions like _log_agent_key_capture_hint. Use importlib.import_module() to ensure we get the actual module object. --- tests/unit/test_connect.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/unit/test_connect.py b/tests/unit/test_connect.py index 2d610a7..deb34b3 100644 --- a/tests/unit/test_connect.py +++ b/tests/unit/test_connect.py @@ -1,5 +1,6 @@ """Unit tests for capiscio_sdk.connect module.""" +import importlib import json import os import pytest @@ -7,7 +8,7 @@ from pathlib import Path from unittest.mock import MagicMock, patch -import capiscio_sdk.connect as connect_module +connect_module = importlib.import_module("capiscio_sdk.connect") from capiscio_sdk.connect import ( AgentIdentity, CapiscIO, @@ -932,7 +933,7 @@ def test_init_identity_logs_capture_hint_on_new_gen(self, tmp_path): "d": "gen", "x": "gen", })) - with patch("capiscio_sdk.connect._log_agent_key_capture_hint") as mock_hint: + with patch.object(connect_module, "_log_agent_key_capture_hint") as mock_hint: connector._init_identity() mock_hint.assert_called_once() @@ -961,7 +962,7 @@ def test_init_identity_no_capture_hint_on_recovery(self, tmp_path): ) connector._ensure_did_registered = MagicMock(return_value=None) - with patch("capiscio_sdk.connect._log_agent_key_capture_hint") as mock_hint: + with patch.object(connect_module, "_log_agent_key_capture_hint") as mock_hint: connector._init_identity() mock_hint.assert_not_called()