Skip to content

Commit 97670cb

Browse files
authored
feat: env var key injection for ephemeral environments (#35)
* 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 * 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 * fix(docs): correct did:key → did:web for production registry usage The registry assigns did:web when an API key is used. did:key is only for local dev mode without a registry. * 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.
1 parent 034cf96 commit 97670cb

4 files changed

Lines changed: 314 additions & 17 deletions

File tree

README.md

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ from capiscio_sdk import CapiscIO
117117
agent = CapiscIO.connect(api_key="sk_live_...")
118118

119119
# Agent is now ready
120-
print(agent.did) # did:key:z6Mk...
120+
print(agent.did) # did:web:registry.capisc.io:agents:...
121121
print(agent.badge) # Current badge (auto-renewed)
122122
print(agent.name) # Agent name
123123
```
@@ -171,11 +171,46 @@ agent = CapiscIO.from_env()
171171
```
172172

173173
**Environment Variables:**
174-
- `CAPISCIO_API_KEY` (required) - Your API key
175-
- `CAPISCIO_AGENT_NAME` - Agent name for lookup/creation
176-
- `CAPISCIO_AGENT_ID` - Specific agent UUID
177-
- `CAPISCIO_SERVER_URL` - Registry URL
178-
- `CAPISCIO_DEV_MODE` - Enable dev mode (`true`/`false`)
174+
175+
| Variable | Required | Description |
176+
|----------|----------|-------------|
177+
| `CAPISCIO_API_KEY` | Yes | Your API key |
178+
| `CAPISCIO_AGENT_NAME` | No | Agent name for lookup/creation |
179+
| `CAPISCIO_AGENT_ID` | No | Specific agent UUID |
180+
| `CAPISCIO_SERVER_URL` | No | Registry URL (default: production) |
181+
| `CAPISCIO_DEV_MODE` | No | Enable dev mode (`true`/`false`) |
182+
| `CAPISCIO_AGENT_PRIVATE_KEY_JWK` | No | JSON-encoded Ed25519 private JWK for ephemeral environments |
183+
184+
### Deploying to Containers / Serverless
185+
186+
In ephemeral environments (Docker, Lambda, Cloud Run) the local `~/.capiscio/` directory
187+
doesn't survive restarts. On first run the SDK generates a keypair and logs a capture hint:
188+
189+
```
190+
╔══════════════════════════════════════════════════════════════════╗
191+
║ New agent identity generated — save key for persistence ║
192+
╚══════════════════════════════════════════════════════════════════╝
193+
194+
Add to your secrets manager / .env:
195+
196+
CAPISCIO_AGENT_PRIVATE_KEY_JWK='{"kty":"OKP","crv":"Ed25519","d":"...","x":"...","kid":"did:key:z6Mk..."}'
197+
```
198+
199+
Copy that value into your secrets manager and set it as an environment variable.
200+
On subsequent starts the SDK recovers the same DID without generating a new identity.
201+
202+
**Key resolution priority:** env var → local file → generate new.
203+
204+
```yaml
205+
# docker-compose.yml
206+
services:
207+
my-agent:
208+
environment:
209+
CAPISCIO_API_KEY: "sk_live_..."
210+
CAPISCIO_AGENT_PRIVATE_KEY_JWK: "${AGENT_KEY_JWK}" # from secrets
211+
```
212+
213+
See the [Configuration Guide](https://docs.capisc.io/reference/sdk-python/config/) for full deployment examples.
179214
180215
## 🎯 Agent Card Validation with CoreValidator
181216

capiscio_sdk/connect.py

Lines changed: 84 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,41 @@
3838
PROD_REGISTRY = "https://registry.capisc.io"
3939
PROD_DASHBOARD = "https://app.capisc.io"
4040

41+
# Env var for injecting the private key in ephemeral environments
42+
ENV_AGENT_PRIVATE_KEY = "CAPISCIO_AGENT_PRIVATE_KEY_JWK"
43+
44+
45+
# =============================================================================
46+
# Key injection helpers
47+
# =============================================================================
48+
49+
50+
def _public_jwk_from_private(private_jwk: dict) -> dict:
51+
"""Derive the public JWK from a private JWK (remove 'd' parameter)."""
52+
public = {k: v for k, v in private_jwk.items() if k != "d"}
53+
return public
54+
55+
56+
def _log_agent_key_capture_hint(agent_id: str, private_jwk: dict) -> None:
57+
"""Log a one-time hint telling the user how to persist key material."""
58+
compact_json = json.dumps(private_jwk, separators=(",", ":"))
59+
logger.warning(
60+
"\n"
61+
" \u2554" + "\u2550" * 62 + "\u2557\n"
62+
" \u2551 New agent identity generated \u2014 save key for persistence \u2551\n"
63+
" \u255a" + "\u2550" * 62 + "\u255d\n"
64+
"\n"
65+
" If this agent runs in an ephemeral environment (containers,\n"
66+
" serverless, CI) the identity will be lost on restart unless\n"
67+
" you persist the private key.\n"
68+
"\n"
69+
" Add to your secrets manager / .env:\n"
70+
"\n"
71+
" CAPISCIO_AGENT_PRIVATE_KEY_JWK='" + compact_json + "'\n"
72+
"\n"
73+
" The DID will be recovered automatically from the JWK on startup.\n"
74+
)
75+
4176

4277
# =============================================================================
4378
# Standalone Helper Functions (for testing and direct use)
@@ -278,6 +313,8 @@ def from_env(cls, **kwargs) -> AgentIdentity:
278313
- CAPISCIO_AGENT_NAME (optional)
279314
- CAPISCIO_SERVER_URL (optional, default: production)
280315
- CAPISCIO_DEV_MODE (optional, default: false)
316+
- CAPISCIO_AGENT_PRIVATE_KEY_JWK (optional — JSON-encoded Ed25519
317+
private JWK for ephemeral environments; printed on first generation)
281318
"""
282319
api_key = os.environ.get("CAPISCIO_API_KEY")
283320
if not api_key:
@@ -568,16 +605,44 @@ def _init_identity(self) -> str:
568605
569606
All cryptographic operations are performed by capiscio-core Go library.
570607
571-
Identity Recovery:
572-
- If keys exist locally (private.jwk + public.jwk), we derive the DID
573-
from public.jwk's `kid` field (per RFC-002 §6.1: did:key is self-describing)
574-
- No did.txt file is required - it's redundant
575-
- If server has a did:web assigned, we use that instead
608+
Identity Recovery (priority order):
609+
- CAPISCIO_AGENT_PRIVATE_KEY_JWK env var (ephemeral / containerised)
610+
- Local keys on disk (private.jwk + public.jwk)
611+
- Generate new identity via Init RPC (first run)
576612
"""
577613
private_key_path = self.keys_dir / "private.jwk"
578614
public_key_path = self.keys_dir / "public.jwk"
579-
580-
# Check if we already have keys (for idempotency)
615+
616+
# ------------------------------------------------------------------
617+
# Source 1: Environment variable (highest priority)
618+
# ------------------------------------------------------------------
619+
env_jwk_raw = os.environ.get(ENV_AGENT_PRIVATE_KEY)
620+
if env_jwk_raw:
621+
try:
622+
private_jwk = json.loads(env_jwk_raw)
623+
did = private_jwk.get("kid")
624+
if not did or not did.startswith("did:"):
625+
raise ValueError("JWK is missing a valid 'kid' field with a DID")
626+
627+
logger.info(f"Loaded agent identity from {ENV_AGENT_PRIVATE_KEY}: {did}")
628+
629+
# Derive public JWK and persist to disk for subsequent restarts
630+
public_jwk = _public_jwk_from_private(private_jwk)
631+
self.keys_dir.mkdir(parents=True, exist_ok=True)
632+
private_key_path.write_text(json.dumps(private_jwk, indent=2))
633+
os.chmod(private_key_path, 0o600)
634+
public_key_path.write_text(json.dumps(public_jwk, indent=2))
635+
636+
# Register with server (idempotent)
637+
server_did = self._ensure_did_registered(did, public_jwk)
638+
return server_did if server_did else did
639+
640+
except (json.JSONDecodeError, ValueError) as e:
641+
logger.error(f"Invalid {ENV_AGENT_PRIVATE_KEY}: {e} — falling through to local keys")
642+
643+
# ------------------------------------------------------------------
644+
# Source 2: Local keys on disk
645+
# ------------------------------------------------------------------
581646
if private_key_path.exists() and public_key_path.exists():
582647
logger.debug("Found existing keys - recovering identity")
583648

@@ -598,8 +663,9 @@ def _init_identity(self) -> str:
598663
except (json.JSONDecodeError, IOError) as e:
599664
logger.warning(f"Failed to read public.jwk: {e} - regenerating")
600665

601-
# No valid keys exist - generate new identity via Init RPC
602-
# Connect to capiscio-core gRPC
666+
# ------------------------------------------------------------------
667+
# Source 3: Generate new identity via Init RPC (first run)
668+
# ------------------------------------------------------------------
603669
if not self._rpc_client:
604670
self._rpc_client = CapiscioRPCClient()
605671
self._rpc_client.connect()
@@ -625,7 +691,15 @@ def _init_identity(self) -> str:
625691
logger.info(f"Identity initialized: {did}")
626692
if result.get("registered"):
627693
logger.info("DID registered with server")
628-
694+
695+
# Log capture hint for ephemeral environments
696+
if private_key_path.exists():
697+
try:
698+
private_jwk = json.loads(private_key_path.read_text())
699+
_log_agent_key_capture_hint(self.agent_id, private_jwk)
700+
except Exception:
701+
pass # Best-effort hint
702+
629703
return did
630704

631705
def _ensure_did_registered(self, did: str, public_jwk: dict) -> Optional[str]:

docs/guides/configuration.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -631,6 +631,8 @@ os.environ['CAPISCIO_BINARY'] = '/opt/capiscio/v2.4.0/capiscio-core'
631631
services:
632632
agent:
633633
environment:
634+
- CAPISCIO_API_KEY=sk_live_...
635+
- CAPISCIO_AGENT_PRIVATE_KEY_JWK=${AGENT_KEY_JWK}
634636
- CAPISCIO_FAIL_MODE=block
635637
- CAPISCIO_RATE_LIMITING=true
636638
- CAPISCIO_RATE_LIMIT_RPM=120
@@ -650,8 +652,61 @@ data:
650652
CAPISCIO_RATE_LIMITING: "true"
651653
CAPISCIO_RATE_LIMIT_RPM: "200"
652654
CAPISCIO_TIMEOUT_MS: "5000"
655+
---
656+
apiVersion: v1
657+
kind: Secret
658+
metadata:
659+
name: capiscio-identity
660+
type: Opaque
661+
stringData:
662+
api-key: "sk_live_..."
663+
agent-private-key-jwk: '{"kty":"OKP","crv":"Ed25519","d":"...","x":"...","kid":"did:key:z6Mk..."}'
664+
```
665+
666+
### Agent Identity Variables (CapiscIO.connect)
667+
668+
These variables are used by `CapiscIO.connect()` and `CapiscIO.from_env()` for agent identity management:
669+
670+
| Variable | Required | Description |
671+
|----------|----------|-------------|
672+
| `CAPISCIO_API_KEY` | Yes | Registry API key |
673+
| `CAPISCIO_AGENT_NAME` | No | Agent name for lookup/creation |
674+
| `CAPISCIO_AGENT_ID` | No | Specific agent UUID |
675+
| `CAPISCIO_SERVER_URL` | No | Registry URL (default: `https://registry.capisc.io`) |
676+
| `CAPISCIO_DEV_MODE` | No | Enable dev mode (`true`/`false`) |
677+
| `CAPISCIO_AGENT_PRIVATE_KEY_JWK` | No | JSON-encoded Ed25519 private JWK for ephemeral environments |
678+
679+
#### Ephemeral Environment Key Injection
680+
681+
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.
682+
683+
**Key resolution priority:**
684+
685+
| Priority | Source | When Used |
686+
|----------|--------|-----------|
687+
| 1 | `CAPISCIO_AGENT_PRIVATE_KEY_JWK` env var | Containers, serverless, CI |
688+
| 2 | Local key file (`~/.capiscio/keys/{agent_id}/private.jwk`) | Persistent environments |
689+
| 3 | Generate new via capiscio-core Init RPC | First run only |
690+
691+
**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:
692+
693+
```
694+
CAPISCIO_AGENT_PRIVATE_KEY_JWK='{"kty":"OKP","crv":"Ed25519","d":"...","x":"...","kid":"did:key:z6Mk..."}'
653695
```
654696
697+
!!! warning "DID Changes on New Key Generation"
698+
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.
699+
700+
#### Key Rotation
701+
702+
To rotate the agent identity:
703+
704+
1. Unset `CAPISCIO_AGENT_PRIVATE_KEY_JWK`
705+
2. Remove local key files (`~/.capiscio/keys/{agent_id}/`)
706+
3. Restart the agent — a new keypair and DID will be generated
707+
4. Capture the new JWK from the log hint
708+
5. Store the new key in your secrets manager
709+
655710
---
656711
657712
## Middleware Observability (Auto-Events)

0 commit comments

Comments
 (0)