diff --git a/capiscio_sdk/__init__.py b/capiscio_sdk/__init__.py index 583cf93..98f628c 100644 --- a/capiscio_sdk/__init__.py +++ b/capiscio_sdk/__init__.py @@ -60,6 +60,12 @@ DVGrant, ) +# Easy connect API ("Let's Encrypt" style) +from .connect import CapiscIO, connect, from_env, AgentIdentity + +# Event emission +from .events import EventEmitter + __all__ = [ "__version__", # Security middleware @@ -106,5 +112,12 @@ "finalize_dv_order", "DVOrder", "DVGrant", + # Easy Connect API ("Let's Encrypt" style) + "CapiscIO", + "connect", + "from_env", + "AgentIdentity", + # Event emission + "EventEmitter", ] diff --git a/capiscio_sdk/_rpc/client.py b/capiscio_sdk/_rpc/client.py index d182616..b3b3f11 100644 --- a/capiscio_sdk/_rpc/client.py +++ b/capiscio_sdk/_rpc/client.py @@ -1275,6 +1275,55 @@ def get_key_info(self, key_id: str) -> tuple[Optional[dict], Optional[str]]: "has_private_key": response.has_private_key, "public_key_pem": response.public_key_pem, }, None + + def init( + self, + api_key: str = "", + agent_id: str = "", + server_url: str = "", + output_dir: str = "", + force: bool = False, + metadata: Optional[dict] = None, + ) -> tuple[Optional[dict], Optional[str]]: + """Initialize agent identity - Let's Encrypt style one-call setup. + + Generates key pair, derives DID, registers with server, and creates agent card. + All cryptographic operations are performed by capiscio-core Go library. + + Args: + api_key: API key for server authentication + agent_id: Agent UUID to register DID for + server_url: CapiscIO server URL (default: https://api.capisc.io) + output_dir: Directory for generated files (default: .capiscio) + force: Overwrite existing files + metadata: Additional metadata for agent card + + Returns: + Tuple of (init_result, error_message) + init_result contains: did, agent_id, private_key_path, public_key_path, + agent_card_path, agent_card_json, registered + """ + request = simpleguard_pb2.InitRequest( + api_key=api_key, + agent_id=agent_id, + server_url=server_url, + output_dir=output_dir, + force=force, + metadata=metadata or {}, + ) + response = self._stub.Init(request) + error = response.error_message if response.error_message else None + if error: + return None, error + return { + "did": response.did, + "agent_id": response.agent_id, + "private_key_path": response.private_key_path, + "public_key_path": response.public_key_path, + "agent_card_path": response.agent_card_path, + "agent_card_json": response.agent_card_json, + "registered": response.registered, + }, None class MCPClient: diff --git a/capiscio_sdk/_rpc/gen/capiscio/v1/badge_pb2.py b/capiscio_sdk/_rpc/gen/capiscio/v1/badge_pb2.py index 13cad94..cbbf19a 100644 --- a/capiscio_sdk/_rpc/gen/capiscio/v1/badge_pb2.py +++ b/capiscio_sdk/_rpc/gen/capiscio/v1/badge_pb2.py @@ -2,7 +2,7 @@ # Generated by the protocol buffer compiler. DO NOT EDIT! # NO CHECKED-IN PROTOBUF GENCODE # source: capiscio/v1/badge.proto -# Protobuf Python Version: 6.33.4 +# Protobuf Python Version: 6.33.5 """Generated protocol buffer code.""" from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool @@ -13,7 +13,7 @@ _runtime_version.Domain.PUBLIC, 6, 33, - 4, + 5, '', 'capiscio/v1/badge.proto' ) diff --git a/capiscio_sdk/_rpc/gen/capiscio/v1/common_pb2.py b/capiscio_sdk/_rpc/gen/capiscio/v1/common_pb2.py index 6d3d214..61b479d 100644 --- a/capiscio_sdk/_rpc/gen/capiscio/v1/common_pb2.py +++ b/capiscio_sdk/_rpc/gen/capiscio/v1/common_pb2.py @@ -2,7 +2,7 @@ # Generated by the protocol buffer compiler. DO NOT EDIT! # NO CHECKED-IN PROTOBUF GENCODE # source: capiscio/v1/common.proto -# Protobuf Python Version: 6.33.4 +# Protobuf Python Version: 6.33.5 """Generated protocol buffer code.""" from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool @@ -13,7 +13,7 @@ _runtime_version.Domain.PUBLIC, 6, 33, - 4, + 5, '', 'capiscio/v1/common.proto' ) diff --git a/capiscio_sdk/_rpc/gen/capiscio/v1/did_pb2.py b/capiscio_sdk/_rpc/gen/capiscio/v1/did_pb2.py index 20c5b59..6fc0c5d 100644 --- a/capiscio_sdk/_rpc/gen/capiscio/v1/did_pb2.py +++ b/capiscio_sdk/_rpc/gen/capiscio/v1/did_pb2.py @@ -2,7 +2,7 @@ # Generated by the protocol buffer compiler. DO NOT EDIT! # NO CHECKED-IN PROTOBUF GENCODE # source: capiscio/v1/did.proto -# Protobuf Python Version: 6.33.4 +# Protobuf Python Version: 6.33.5 """Generated protocol buffer code.""" from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool @@ -13,7 +13,7 @@ _runtime_version.Domain.PUBLIC, 6, 33, - 4, + 5, '', 'capiscio/v1/did.proto' ) diff --git a/capiscio_sdk/_rpc/gen/capiscio/v1/mcp_pb2.py b/capiscio_sdk/_rpc/gen/capiscio/v1/mcp_pb2.py index 1cb5121..43a2684 100644 --- a/capiscio_sdk/_rpc/gen/capiscio/v1/mcp_pb2.py +++ b/capiscio_sdk/_rpc/gen/capiscio/v1/mcp_pb2.py @@ -2,7 +2,7 @@ # Generated by the protocol buffer compiler. DO NOT EDIT! # NO CHECKED-IN PROTOBUF GENCODE # source: capiscio/v1/mcp.proto -# Protobuf Python Version: 6.33.4 +# Protobuf Python Version: 6.33.5 """Generated protocol buffer code.""" from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool @@ -13,7 +13,7 @@ _runtime_version.Domain.PUBLIC, 6, 33, - 4, + 5, '', 'capiscio/v1/mcp.proto' ) diff --git a/capiscio_sdk/_rpc/gen/capiscio/v1/registry_pb2.py b/capiscio_sdk/_rpc/gen/capiscio/v1/registry_pb2.py index 609109e..c116a31 100644 --- a/capiscio_sdk/_rpc/gen/capiscio/v1/registry_pb2.py +++ b/capiscio_sdk/_rpc/gen/capiscio/v1/registry_pb2.py @@ -2,7 +2,7 @@ # Generated by the protocol buffer compiler. DO NOT EDIT! # NO CHECKED-IN PROTOBUF GENCODE # source: capiscio/v1/registry.proto -# Protobuf Python Version: 6.33.4 +# Protobuf Python Version: 6.33.5 """Generated protocol buffer code.""" from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool @@ -13,7 +13,7 @@ _runtime_version.Domain.PUBLIC, 6, 33, - 4, + 5, '', 'capiscio/v1/registry.proto' ) diff --git a/capiscio_sdk/_rpc/gen/capiscio/v1/revocation_pb2.py b/capiscio_sdk/_rpc/gen/capiscio/v1/revocation_pb2.py index 0a94e60..f658ad4 100644 --- a/capiscio_sdk/_rpc/gen/capiscio/v1/revocation_pb2.py +++ b/capiscio_sdk/_rpc/gen/capiscio/v1/revocation_pb2.py @@ -2,7 +2,7 @@ # Generated by the protocol buffer compiler. DO NOT EDIT! # NO CHECKED-IN PROTOBUF GENCODE # source: capiscio/v1/revocation.proto -# Protobuf Python Version: 6.33.4 +# Protobuf Python Version: 6.33.5 """Generated protocol buffer code.""" from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool @@ -13,7 +13,7 @@ _runtime_version.Domain.PUBLIC, 6, 33, - 4, + 5, '', 'capiscio/v1/revocation.proto' ) diff --git a/capiscio_sdk/_rpc/gen/capiscio/v1/scoring_pb2.py b/capiscio_sdk/_rpc/gen/capiscio/v1/scoring_pb2.py index fea3be4..8cbd4e4 100644 --- a/capiscio_sdk/_rpc/gen/capiscio/v1/scoring_pb2.py +++ b/capiscio_sdk/_rpc/gen/capiscio/v1/scoring_pb2.py @@ -2,7 +2,7 @@ # Generated by the protocol buffer compiler. DO NOT EDIT! # NO CHECKED-IN PROTOBUF GENCODE # source: capiscio/v1/scoring.proto -# Protobuf Python Version: 6.33.4 +# Protobuf Python Version: 6.33.5 """Generated protocol buffer code.""" from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool @@ -13,7 +13,7 @@ _runtime_version.Domain.PUBLIC, 6, 33, - 4, + 5, '', 'capiscio/v1/scoring.proto' ) diff --git a/capiscio_sdk/_rpc/gen/capiscio/v1/simpleguard_pb2.py b/capiscio_sdk/_rpc/gen/capiscio/v1/simpleguard_pb2.py index cba00f2..3636eec 100644 --- a/capiscio_sdk/_rpc/gen/capiscio/v1/simpleguard_pb2.py +++ b/capiscio_sdk/_rpc/gen/capiscio/v1/simpleguard_pb2.py @@ -2,7 +2,7 @@ # Generated by the protocol buffer compiler. DO NOT EDIT! # NO CHECKED-IN PROTOBUF GENCODE # source: capiscio/v1/simpleguard.proto -# Protobuf Python Version: 6.33.4 +# Protobuf Python Version: 6.33.5 """Generated protocol buffer code.""" from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool @@ -13,7 +13,7 @@ _runtime_version.Domain.PUBLIC, 6, 33, - 4, + 5, '', 'capiscio/v1/simpleguard.proto' ) @@ -26,7 +26,7 @@ from capiscio_sdk._rpc.gen.capiscio.v1 import trust_pb2 as capiscio_dot_v1_dot_trust__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1d\x63\x61piscio/v1/simpleguard.proto\x12\x0b\x63\x61piscio.v1\x1a\x18\x63\x61piscio/v1/common.proto\x1a\x17\x63\x61piscio/v1/trust.proto\"\xf1\x01\n\x0bSignRequest\x12\x18\n\x07payload\x18\x01 \x01(\x0cR\x07payload\x12\x15\n\x06key_id\x18\x02 \x01(\tR\x05keyId\x12\x34\n\x06\x66ormat\x18\x03 \x01(\x0e\x32\x1c.capiscio.v1.SignatureFormatR\x06\x66ormat\x12?\n\x07headers\x18\x04 \x03(\x0b\x32%.capiscio.v1.SignRequest.HeadersEntryR\x07headers\x1a:\n\x0cHeadersEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\"|\n\x0cSignResponse\x12\x1c\n\tsignature\x18\x01 \x01(\x0cR\tsignature\x12)\n\x10signature_string\x18\x02 \x01(\tR\x0fsignatureString\x12#\n\rerror_message\x18\x03 \x01(\tR\x0c\x65rrorMessage\"\x9b\x01\n\rVerifyRequest\x12\x18\n\x07payload\x18\x01 \x01(\x0cR\x07payload\x12\x1c\n\tsignature\x18\x02 \x01(\x0cR\tsignature\x12)\n\x10signature_string\x18\x03 \x01(\tR\x0fsignatureString\x12\'\n\x0f\x65xpected_signer\x18\x04 \x01(\tR\x0e\x65xpectedSigner\"\xc0\x01\n\x0eVerifyResponse\x12\x14\n\x05valid\x18\x01 \x01(\x08R\x05valid\x12\x1d\n\nsigner_did\x18\x02 \x01(\tR\tsignerDid\x12\x15\n\x06key_id\x18\x03 \x01(\tR\x05keyId\x12=\n\nvalidation\x18\x04 \x01(\x0b\x32\x1d.capiscio.v1.ValidationResultR\nvalidation\x12#\n\rerror_message\x18\x05 \x01(\tR\x0c\x65rrorMessage\"\xa8\x02\n\x13SignAttachedRequest\x12\x18\n\x07payload\x18\x01 \x01(\x0cR\x07payload\x12\x15\n\x06key_id\x18\x02 \x01(\tR\x05keyId\x12\x34\n\x06\x66ormat\x18\x03 \x01(\x0e\x32\x1c.capiscio.v1.SignatureFormatR\x06\x66ormat\x12G\n\x07headers\x18\x04 \x03(\x0b\x32-.capiscio.v1.SignAttachedRequest.HeadersEntryR\x07headers\x12%\n\x0e\x64\x65tach_payload\x18\x05 \x01(\x08R\rdetachPayload\x1a:\n\x0cHeadersEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\"M\n\x14SignAttachedResponse\x12\x10\n\x03jws\x18\x01 \x01(\tR\x03jws\x12#\n\rerror_message\x18\x02 \x01(\tR\x0c\x65rrorMessage\"}\n\x15VerifyAttachedRequest\x12\x10\n\x03jws\x18\x01 \x01(\tR\x03jws\x12)\n\x10\x64\x65tached_payload\x18\x02 \x01(\x0cR\x0f\x64\x65tachedPayload\x12\'\n\x0f\x65xpected_signer\x18\x03 \x01(\tR\x0e\x65xpectedSigner\"\xe2\x01\n\x16VerifyAttachedResponse\x12\x14\n\x05valid\x18\x01 \x01(\x08R\x05valid\x12\x18\n\x07payload\x18\x02 \x01(\x0cR\x07payload\x12\x1d\n\nsigner_did\x18\x03 \x01(\tR\tsignerDid\x12\x15\n\x06key_id\x18\x04 \x01(\tR\x05keyId\x12=\n\nvalidation\x18\x05 \x01(\x0b\x32\x1d.capiscio.v1.ValidationResultR\nvalidation\x12#\n\rerror_message\x18\x06 \x01(\tR\x0c\x65rrorMessage\"\xf4\x01\n\x16GenerateKeyPairRequest\x12\x37\n\talgorithm\x18\x01 \x01(\x0e\x32\x19.capiscio.v1.KeyAlgorithmR\talgorithm\x12\x15\n\x06key_id\x18\x02 \x01(\tR\x05keyId\x12M\n\x08metadata\x18\x03 \x03(\x0b\x32\x31.capiscio.v1.GenerateKeyPairRequest.MetadataEntryR\x08metadata\x1a;\n\rMetadataEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\"\xb5\x02\n\x17GenerateKeyPairResponse\x12\x15\n\x06key_id\x18\x01 \x01(\tR\x05keyId\x12\x1d\n\npublic_key\x18\x02 \x01(\x0cR\tpublicKey\x12\x1f\n\x0bprivate_key\x18\x03 \x01(\x0cR\nprivateKey\x12$\n\x0epublic_key_pem\x18\x04 \x01(\tR\x0cpublicKeyPem\x12&\n\x0fprivate_key_pem\x18\x05 \x01(\tR\rprivateKeyPem\x12\x37\n\talgorithm\x18\x06 \x01(\x0e\x32\x19.capiscio.v1.KeyAlgorithmR\talgorithm\x12#\n\rerror_message\x18\x07 \x01(\tR\x0c\x65rrorMessage\x12\x17\n\x07\x64id_key\x18\x08 \x01(\tR\x06\x64idKey\"}\n\x0eLoadKeyRequest\x12\x1b\n\tfile_path\x18\x01 \x01(\tR\x08\x66ilePath\x12.\n\x06\x66ormat\x18\x02 \x01(\x0e\x32\x16.capiscio.v1.KeyFormatR\x06\x66ormat\x12\x1e\n\npassphrase\x18\x03 \x01(\tR\npassphrase\"\xae\x01\n\x0fLoadKeyResponse\x12\x15\n\x06key_id\x18\x01 \x01(\tR\x05keyId\x12\x37\n\talgorithm\x18\x02 \x01(\x0e\x32\x19.capiscio.v1.KeyAlgorithmR\talgorithm\x12&\n\x0fhas_private_key\x18\x03 \x01(\x08R\rhasPrivateKey\x12#\n\rerror_message\x18\x04 \x01(\tR\x0c\x65rrorMessage\"\xbf\x01\n\x10\x45xportKeyRequest\x12\x15\n\x06key_id\x18\x01 \x01(\tR\x05keyId\x12\x1b\n\tfile_path\x18\x02 \x01(\tR\x08\x66ilePath\x12.\n\x06\x66ormat\x18\x03 \x01(\x0e\x32\x16.capiscio.v1.KeyFormatR\x06\x66ormat\x12\'\n\x0finclude_private\x18\x04 \x01(\x08R\x0eincludePrivate\x12\x1e\n\npassphrase\x18\x05 \x01(\tR\npassphrase\"U\n\x11\x45xportKeyResponse\x12\x1b\n\tfile_path\x18\x01 \x01(\tR\x08\x66ilePath\x12#\n\rerror_message\x18\x02 \x01(\tR\x0c\x65rrorMessage\"*\n\x11GetKeyInfoRequest\x12\x15\n\x06key_id\x18\x01 \x01(\tR\x05keyId\"\xb5\x03\n\x12GetKeyInfoResponse\x12\x15\n\x06key_id\x18\x01 \x01(\tR\x05keyId\x12\x37\n\talgorithm\x18\x02 \x01(\x0e\x32\x19.capiscio.v1.KeyAlgorithmR\talgorithm\x12&\n\x0fhas_private_key\x18\x03 \x01(\x08R\rhasPrivateKey\x12\x1d\n\npublic_key\x18\x04 \x01(\x0cR\tpublicKey\x12$\n\x0epublic_key_pem\x18\x05 \x01(\tR\x0cpublicKeyPem\x12\x35\n\ncreated_at\x18\x06 \x01(\x0b\x32\x16.capiscio.v1.TimestampR\tcreatedAt\x12I\n\x08metadata\x18\x07 \x03(\x0b\x32-.capiscio.v1.GetKeyInfoResponse.MetadataEntryR\x08metadata\x12#\n\rerror_message\x18\x08 \x01(\tR\x0c\x65rrorMessage\x1a;\n\rMetadataEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01*\x8e\x01\n\x0fSignatureFormat\x12 \n\x1cSIGNATURE_FORMAT_UNSPECIFIED\x10\x00\x12 \n\x1cSIGNATURE_FORMAT_JWS_COMPACT\x10\x01\x12\x1d\n\x19SIGNATURE_FORMAT_JWS_JSON\x10\x02\x12\x18\n\x14SIGNATURE_FORMAT_RAW\x10\x03\x32\x83\x05\n\x12SimpleGuardService\x12;\n\x04Sign\x12\x18.capiscio.v1.SignRequest\x1a\x19.capiscio.v1.SignResponse\x12\x41\n\x06Verify\x12\x1a.capiscio.v1.VerifyRequest\x1a\x1b.capiscio.v1.VerifyResponse\x12S\n\x0cSignAttached\x12 .capiscio.v1.SignAttachedRequest\x1a!.capiscio.v1.SignAttachedResponse\x12Y\n\x0eVerifyAttached\x12\".capiscio.v1.VerifyAttachedRequest\x1a#.capiscio.v1.VerifyAttachedResponse\x12\\\n\x0fGenerateKeyPair\x12#.capiscio.v1.GenerateKeyPairRequest\x1a$.capiscio.v1.GenerateKeyPairResponse\x12\x44\n\x07LoadKey\x12\x1b.capiscio.v1.LoadKeyRequest\x1a\x1c.capiscio.v1.LoadKeyResponse\x12J\n\tExportKey\x12\x1d.capiscio.v1.ExportKeyRequest\x1a\x1e.capiscio.v1.ExportKeyResponse\x12M\n\nGetKeyInfo\x12\x1e.capiscio.v1.GetKeyInfoRequest\x1a\x1f.capiscio.v1.GetKeyInfoResponseB\xb6\x01\n\x0f\x63om.capiscio.v1B\x10SimpleguardProtoP\x01ZDgithub.com/capiscio/capiscio-core/pkg/rpc/gen/capiscio/v1;capisciov1\xa2\x02\x03\x43XX\xaa\x02\x0b\x43\x61piscio.V1\xca\x02\x0b\x43\x61piscio\\V1\xe2\x02\x17\x43\x61piscio\\V1\\GPBMetadata\xea\x02\x0c\x43\x61piscio::V1b\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1d\x63\x61piscio/v1/simpleguard.proto\x12\x0b\x63\x61piscio.v1\x1a\x18\x63\x61piscio/v1/common.proto\x1a\x17\x63\x61piscio/v1/trust.proto\"\xf1\x01\n\x0bSignRequest\x12\x18\n\x07payload\x18\x01 \x01(\x0cR\x07payload\x12\x15\n\x06key_id\x18\x02 \x01(\tR\x05keyId\x12\x34\n\x06\x66ormat\x18\x03 \x01(\x0e\x32\x1c.capiscio.v1.SignatureFormatR\x06\x66ormat\x12?\n\x07headers\x18\x04 \x03(\x0b\x32%.capiscio.v1.SignRequest.HeadersEntryR\x07headers\x1a:\n\x0cHeadersEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\"|\n\x0cSignResponse\x12\x1c\n\tsignature\x18\x01 \x01(\x0cR\tsignature\x12)\n\x10signature_string\x18\x02 \x01(\tR\x0fsignatureString\x12#\n\rerror_message\x18\x03 \x01(\tR\x0c\x65rrorMessage\"\x9b\x01\n\rVerifyRequest\x12\x18\n\x07payload\x18\x01 \x01(\x0cR\x07payload\x12\x1c\n\tsignature\x18\x02 \x01(\x0cR\tsignature\x12)\n\x10signature_string\x18\x03 \x01(\tR\x0fsignatureString\x12\'\n\x0f\x65xpected_signer\x18\x04 \x01(\tR\x0e\x65xpectedSigner\"\xc0\x01\n\x0eVerifyResponse\x12\x14\n\x05valid\x18\x01 \x01(\x08R\x05valid\x12\x1d\n\nsigner_did\x18\x02 \x01(\tR\tsignerDid\x12\x15\n\x06key_id\x18\x03 \x01(\tR\x05keyId\x12=\n\nvalidation\x18\x04 \x01(\x0b\x32\x1d.capiscio.v1.ValidationResultR\nvalidation\x12#\n\rerror_message\x18\x05 \x01(\tR\x0c\x65rrorMessage\"\xa8\x02\n\x13SignAttachedRequest\x12\x18\n\x07payload\x18\x01 \x01(\x0cR\x07payload\x12\x15\n\x06key_id\x18\x02 \x01(\tR\x05keyId\x12\x34\n\x06\x66ormat\x18\x03 \x01(\x0e\x32\x1c.capiscio.v1.SignatureFormatR\x06\x66ormat\x12G\n\x07headers\x18\x04 \x03(\x0b\x32-.capiscio.v1.SignAttachedRequest.HeadersEntryR\x07headers\x12%\n\x0e\x64\x65tach_payload\x18\x05 \x01(\x08R\rdetachPayload\x1a:\n\x0cHeadersEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\"M\n\x14SignAttachedResponse\x12\x10\n\x03jws\x18\x01 \x01(\tR\x03jws\x12#\n\rerror_message\x18\x02 \x01(\tR\x0c\x65rrorMessage\"}\n\x15VerifyAttachedRequest\x12\x10\n\x03jws\x18\x01 \x01(\tR\x03jws\x12)\n\x10\x64\x65tached_payload\x18\x02 \x01(\x0cR\x0f\x64\x65tachedPayload\x12\'\n\x0f\x65xpected_signer\x18\x03 \x01(\tR\x0e\x65xpectedSigner\"\xe2\x01\n\x16VerifyAttachedResponse\x12\x14\n\x05valid\x18\x01 \x01(\x08R\x05valid\x12\x18\n\x07payload\x18\x02 \x01(\x0cR\x07payload\x12\x1d\n\nsigner_did\x18\x03 \x01(\tR\tsignerDid\x12\x15\n\x06key_id\x18\x04 \x01(\tR\x05keyId\x12=\n\nvalidation\x18\x05 \x01(\x0b\x32\x1d.capiscio.v1.ValidationResultR\nvalidation\x12#\n\rerror_message\x18\x06 \x01(\tR\x0c\x65rrorMessage\"\xf4\x01\n\x16GenerateKeyPairRequest\x12\x37\n\talgorithm\x18\x01 \x01(\x0e\x32\x19.capiscio.v1.KeyAlgorithmR\talgorithm\x12\x15\n\x06key_id\x18\x02 \x01(\tR\x05keyId\x12M\n\x08metadata\x18\x03 \x03(\x0b\x32\x31.capiscio.v1.GenerateKeyPairRequest.MetadataEntryR\x08metadata\x1a;\n\rMetadataEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\"\xb5\x02\n\x17GenerateKeyPairResponse\x12\x15\n\x06key_id\x18\x01 \x01(\tR\x05keyId\x12\x1d\n\npublic_key\x18\x02 \x01(\x0cR\tpublicKey\x12\x1f\n\x0bprivate_key\x18\x03 \x01(\x0cR\nprivateKey\x12$\n\x0epublic_key_pem\x18\x04 \x01(\tR\x0cpublicKeyPem\x12&\n\x0fprivate_key_pem\x18\x05 \x01(\tR\rprivateKeyPem\x12\x37\n\talgorithm\x18\x06 \x01(\x0e\x32\x19.capiscio.v1.KeyAlgorithmR\talgorithm\x12#\n\rerror_message\x18\x07 \x01(\tR\x0c\x65rrorMessage\x12\x17\n\x07\x64id_key\x18\x08 \x01(\tR\x06\x64idKey\"}\n\x0eLoadKeyRequest\x12\x1b\n\tfile_path\x18\x01 \x01(\tR\x08\x66ilePath\x12.\n\x06\x66ormat\x18\x02 \x01(\x0e\x32\x16.capiscio.v1.KeyFormatR\x06\x66ormat\x12\x1e\n\npassphrase\x18\x03 \x01(\tR\npassphrase\"\xae\x01\n\x0fLoadKeyResponse\x12\x15\n\x06key_id\x18\x01 \x01(\tR\x05keyId\x12\x37\n\talgorithm\x18\x02 \x01(\x0e\x32\x19.capiscio.v1.KeyAlgorithmR\talgorithm\x12&\n\x0fhas_private_key\x18\x03 \x01(\x08R\rhasPrivateKey\x12#\n\rerror_message\x18\x04 \x01(\tR\x0c\x65rrorMessage\"\xbf\x01\n\x10\x45xportKeyRequest\x12\x15\n\x06key_id\x18\x01 \x01(\tR\x05keyId\x12\x1b\n\tfile_path\x18\x02 \x01(\tR\x08\x66ilePath\x12.\n\x06\x66ormat\x18\x03 \x01(\x0e\x32\x16.capiscio.v1.KeyFormatR\x06\x66ormat\x12\'\n\x0finclude_private\x18\x04 \x01(\x08R\x0eincludePrivate\x12\x1e\n\npassphrase\x18\x05 \x01(\tR\npassphrase\"U\n\x11\x45xportKeyResponse\x12\x1b\n\tfile_path\x18\x01 \x01(\tR\x08\x66ilePath\x12#\n\rerror_message\x18\x02 \x01(\tR\x0c\x65rrorMessage\"*\n\x11GetKeyInfoRequest\x12\x15\n\x06key_id\x18\x01 \x01(\tR\x05keyId\"\xb5\x03\n\x12GetKeyInfoResponse\x12\x15\n\x06key_id\x18\x01 \x01(\tR\x05keyId\x12\x37\n\talgorithm\x18\x02 \x01(\x0e\x32\x19.capiscio.v1.KeyAlgorithmR\talgorithm\x12&\n\x0fhas_private_key\x18\x03 \x01(\x08R\rhasPrivateKey\x12\x1d\n\npublic_key\x18\x04 \x01(\x0cR\tpublicKey\x12$\n\x0epublic_key_pem\x18\x05 \x01(\tR\x0cpublicKeyPem\x12\x35\n\ncreated_at\x18\x06 \x01(\x0b\x32\x16.capiscio.v1.TimestampR\tcreatedAt\x12I\n\x08metadata\x18\x07 \x03(\x0b\x32-.capiscio.v1.GetKeyInfoResponse.MetadataEntryR\x08metadata\x12#\n\rerror_message\x18\x08 \x01(\tR\x0c\x65rrorMessage\x1a;\n\rMetadataEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\"\xcf\x02\n\x0bInitRequest\x12\x17\n\x07\x61pi_key\x18\x01 \x01(\tR\x06\x61piKey\x12\x19\n\x08\x61gent_id\x18\x02 \x01(\tR\x07\x61gentId\x12\x1d\n\nserver_url\x18\x03 \x01(\tR\tserverUrl\x12\x1d\n\noutput_dir\x18\x04 \x01(\tR\toutputDir\x12\x14\n\x05\x66orce\x18\x05 \x01(\x08R\x05\x66orce\x12\x37\n\talgorithm\x18\x06 \x01(\x0e\x32\x19.capiscio.v1.KeyAlgorithmR\talgorithm\x12\x42\n\x08metadata\x18\x07 \x03(\x0b\x32&.capiscio.v1.InitRequest.MetadataEntryR\x08metadata\x1a;\n\rMetadataEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\"\xa2\x02\n\x0cInitResponse\x12\x10\n\x03\x64id\x18\x01 \x01(\tR\x03\x64id\x12\x19\n\x08\x61gent_id\x18\x02 \x01(\tR\x07\x61gentId\x12(\n\x10private_key_path\x18\x03 \x01(\tR\x0eprivateKeyPath\x12&\n\x0fpublic_key_path\x18\x04 \x01(\tR\rpublicKeyPath\x12&\n\x0f\x61gent_card_path\x18\x05 \x01(\tR\ragentCardPath\x12&\n\x0f\x61gent_card_json\x18\x06 \x01(\tR\ragentCardJson\x12\x1e\n\nregistered\x18\x07 \x01(\x08R\nregistered\x12#\n\rerror_message\x18\x08 \x01(\tR\x0c\x65rrorMessage*\x8e\x01\n\x0fSignatureFormat\x12 \n\x1cSIGNATURE_FORMAT_UNSPECIFIED\x10\x00\x12 \n\x1cSIGNATURE_FORMAT_JWS_COMPACT\x10\x01\x12\x1d\n\x19SIGNATURE_FORMAT_JWS_JSON\x10\x02\x12\x18\n\x14SIGNATURE_FORMAT_RAW\x10\x03\x32\xc0\x05\n\x12SimpleGuardService\x12;\n\x04Sign\x12\x18.capiscio.v1.SignRequest\x1a\x19.capiscio.v1.SignResponse\x12\x41\n\x06Verify\x12\x1a.capiscio.v1.VerifyRequest\x1a\x1b.capiscio.v1.VerifyResponse\x12S\n\x0cSignAttached\x12 .capiscio.v1.SignAttachedRequest\x1a!.capiscio.v1.SignAttachedResponse\x12Y\n\x0eVerifyAttached\x12\".capiscio.v1.VerifyAttachedRequest\x1a#.capiscio.v1.VerifyAttachedResponse\x12\\\n\x0fGenerateKeyPair\x12#.capiscio.v1.GenerateKeyPairRequest\x1a$.capiscio.v1.GenerateKeyPairResponse\x12\x44\n\x07LoadKey\x12\x1b.capiscio.v1.LoadKeyRequest\x1a\x1c.capiscio.v1.LoadKeyResponse\x12J\n\tExportKey\x12\x1d.capiscio.v1.ExportKeyRequest\x1a\x1e.capiscio.v1.ExportKeyResponse\x12M\n\nGetKeyInfo\x12\x1e.capiscio.v1.GetKeyInfoRequest\x1a\x1f.capiscio.v1.GetKeyInfoResponse\x12;\n\x04Init\x12\x18.capiscio.v1.InitRequest\x1a\x19.capiscio.v1.InitResponseB\xb6\x01\n\x0f\x63om.capiscio.v1B\x10SimpleguardProtoP\x01ZDgithub.com/capiscio/capiscio-core/pkg/rpc/gen/capiscio/v1;capisciov1\xa2\x02\x03\x43XX\xaa\x02\x0b\x43\x61piscio.V1\xca\x02\x0b\x43\x61piscio\\V1\xe2\x02\x17\x43\x61piscio\\V1\\GPBMetadata\xea\x02\x0c\x43\x61piscio::V1b\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -42,8 +42,10 @@ _globals['_GENERATEKEYPAIRREQUEST_METADATAENTRY']._serialized_options = b'8\001' _globals['_GETKEYINFORESPONSE_METADATAENTRY']._loaded_options = None _globals['_GETKEYINFORESPONSE_METADATAENTRY']._serialized_options = b'8\001' - _globals['_SIGNATUREFORMAT']._serialized_start=3183 - _globals['_SIGNATUREFORMAT']._serialized_end=3325 + _globals['_INITREQUEST_METADATAENTRY']._loaded_options = None + _globals['_INITREQUEST_METADATAENTRY']._serialized_options = b'8\001' + _globals['_SIGNATUREFORMAT']._serialized_start=3814 + _globals['_SIGNATUREFORMAT']._serialized_end=3956 _globals['_SIGNREQUEST']._serialized_start=98 _globals['_SIGNREQUEST']._serialized_end=339 _globals['_SIGNREQUEST_HEADERSENTRY']._serialized_start=281 @@ -84,6 +86,12 @@ _globals['_GETKEYINFORESPONSE']._serialized_end=3180 _globals['_GETKEYINFORESPONSE_METADATAENTRY']._serialized_start=1740 _globals['_GETKEYINFORESPONSE_METADATAENTRY']._serialized_end=1799 - _globals['_SIMPLEGUARDSERVICE']._serialized_start=3328 - _globals['_SIMPLEGUARDSERVICE']._serialized_end=3971 + _globals['_INITREQUEST']._serialized_start=3183 + _globals['_INITREQUEST']._serialized_end=3518 + _globals['_INITREQUEST_METADATAENTRY']._serialized_start=1740 + _globals['_INITREQUEST_METADATAENTRY']._serialized_end=1799 + _globals['_INITRESPONSE']._serialized_start=3521 + _globals['_INITRESPONSE']._serialized_end=3811 + _globals['_SIMPLEGUARDSERVICE']._serialized_start=3959 + _globals['_SIMPLEGUARDSERVICE']._serialized_end=4663 # @@protoc_insertion_point(module_scope) diff --git a/capiscio_sdk/_rpc/gen/capiscio/v1/simpleguard_pb2_grpc.py b/capiscio_sdk/_rpc/gen/capiscio/v1/simpleguard_pb2_grpc.py index cc22c5b..80c82ed 100644 --- a/capiscio_sdk/_rpc/gen/capiscio/v1/simpleguard_pb2_grpc.py +++ b/capiscio_sdk/_rpc/gen/capiscio/v1/simpleguard_pb2_grpc.py @@ -55,6 +55,11 @@ def __init__(self, channel): request_serializer=capiscio_dot_v1_dot_simpleguard__pb2.GetKeyInfoRequest.SerializeToString, response_deserializer=capiscio_dot_v1_dot_simpleguard__pb2.GetKeyInfoResponse.FromString, _registered_method=True) + self.Init = channel.unary_unary( + '/capiscio.v1.SimpleGuardService/Init', + request_serializer=capiscio_dot_v1_dot_simpleguard__pb2.InitRequest.SerializeToString, + response_deserializer=capiscio_dot_v1_dot_simpleguard__pb2.InitResponse.FromString, + _registered_method=True) class SimpleGuardServiceServicer(object): @@ -117,6 +122,14 @@ def GetKeyInfo(self, request, context): context.set_details('Method not implemented!') raise NotImplementedError('Method not implemented!') + def Init(self, request, context): + """Initialize agent identity (Let's Encrypt style one-call setup) + Generates key pair, derives DID, registers with server, creates agent card + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + def add_SimpleGuardServiceServicer_to_server(servicer, server): rpc_method_handlers = { @@ -160,6 +173,11 @@ def add_SimpleGuardServiceServicer_to_server(servicer, server): request_deserializer=capiscio_dot_v1_dot_simpleguard__pb2.GetKeyInfoRequest.FromString, response_serializer=capiscio_dot_v1_dot_simpleguard__pb2.GetKeyInfoResponse.SerializeToString, ), + 'Init': grpc.unary_unary_rpc_method_handler( + servicer.Init, + request_deserializer=capiscio_dot_v1_dot_simpleguard__pb2.InitRequest.FromString, + response_serializer=capiscio_dot_v1_dot_simpleguard__pb2.InitResponse.SerializeToString, + ), } generic_handler = grpc.method_handlers_generic_handler( 'capiscio.v1.SimpleGuardService', rpc_method_handlers) @@ -387,3 +405,30 @@ def GetKeyInfo(request, timeout, metadata, _registered_method=True) + + @staticmethod + def Init(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, + target, + '/capiscio.v1.SimpleGuardService/Init', + capiscio_dot_v1_dot_simpleguard__pb2.InitRequest.SerializeToString, + capiscio_dot_v1_dot_simpleguard__pb2.InitResponse.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) diff --git a/capiscio_sdk/_rpc/gen/capiscio/v1/trust_pb2.py b/capiscio_sdk/_rpc/gen/capiscio/v1/trust_pb2.py index 6a56043..a286e22 100644 --- a/capiscio_sdk/_rpc/gen/capiscio/v1/trust_pb2.py +++ b/capiscio_sdk/_rpc/gen/capiscio/v1/trust_pb2.py @@ -2,7 +2,7 @@ # Generated by the protocol buffer compiler. DO NOT EDIT! # NO CHECKED-IN PROTOBUF GENCODE # source: capiscio/v1/trust.proto -# Protobuf Python Version: 6.33.4 +# Protobuf Python Version: 6.33.5 """Generated protocol buffer code.""" from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool @@ -13,7 +13,7 @@ _runtime_version.Domain.PUBLIC, 6, 33, - 4, + 5, '', 'capiscio/v1/trust.proto' ) diff --git a/capiscio_sdk/connect.py b/capiscio_sdk/connect.py new file mode 100644 index 0000000..c731027 --- /dev/null +++ b/capiscio_sdk/connect.py @@ -0,0 +1,428 @@ +""" +CapiscIO Connect - "Let's Encrypt" style agent identity. + +All cryptographic operations (key generation, DID derivation) are performed +by the capiscio-core Go library via gRPC, ensuring consistency across SDKs. + +Usage: + from capiscio_sdk import CapiscIO + + # One-liner to get a production-ready agent + agent = CapiscIO.connect(api_key="sk_live_...") + + # Use the agent + print(agent.did) # did:key:z6Mk... + print(agent.badge) # Current badge (auto-renewed) + agent.emit("task_started", {"task_id": "123"}) +""" + +import os +import logging +import httpx +from pathlib import Path +from typing import Optional, Dict, Any +from dataclasses import dataclass, field + +from ._rpc.client import CapiscioRPCClient +from .errors import ConfigurationError + +logger = logging.getLogger(__name__) + +# Default paths +DEFAULT_CONFIG_DIR = Path.home() / ".capiscio" +DEFAULT_KEYS_DIR = DEFAULT_CONFIG_DIR / "keys" +DEFAULT_CONFIG_FILE = DEFAULT_CONFIG_DIR / "config.toml" + +# Default server URLs +PROD_REGISTRY = "https://registry.capisc.io" +PROD_DASHBOARD = "https://app.capisc.io" + + +@dataclass +class AgentIdentity: + """Represents a fully-configured agent identity.""" + + agent_id: str + did: str + name: str + api_key: str + server_url: str + keys_dir: Path + badge: Optional[str] = None + badge_expires_at: Optional[str] = None + _guard: Any = field(default=None, repr=False) + _keeper: Any = field(default=None, repr=False) + _emitter: Any = field(default=None, repr=False) + + def emit(self, event_type: str, data: Dict[str, Any]) -> bool: + """Emit an event to the registry.""" + if not self._emitter: + from .events import EventEmitter + self._emitter = EventEmitter( + server_url=self.server_url, + api_key=self.api_key, + agent_id=self.agent_id, + ) + return self._emitter.emit(event_type, data) + + def get_badge(self) -> Optional[str]: + """Get current badge (auto-renewed if needed).""" + if self._keeper: + return self._keeper.get_current_badge() + return self.badge + + def status(self) -> Dict[str, Any]: + """Get agent status including badge validity.""" + return { + "agent_id": self.agent_id, + "did": self.did, + "name": self.name, + "server": self.server_url, + "badge_valid": self.badge is not None, + "badge_expires_at": self.badge_expires_at, + } + + def close(self) -> None: + """Clean up resources.""" + if self._emitter: + self._emitter.close() + self._emitter = None + if self._keeper: + try: + self._keeper.stop() + except Exception: + pass + self._keeper = None + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.close() + return False + + +class CapiscIO: + """ + CapiscIO SDK - "Let's Encrypt" style agent identity. + + Provides seamless agent identity management: + - Auto-creates agents if they don't exist + - Auto-generates and stores cryptographic keys + - Auto-derives and registers DIDs + - Auto-requests and renews badges + + Usage: + agent = CapiscIO.connect(api_key="sk_live_...") + print(agent.did) + """ + + @classmethod + def connect( + cls, + api_key: str, + *, + name: Optional[str] = None, + agent_id: Optional[str] = None, + server_url: str = PROD_REGISTRY, + keys_dir: Optional[Path] = None, + auto_badge: bool = True, + dev_mode: bool = False, + ) -> AgentIdentity: + """ + Connect to CapiscIO and get a fully-configured agent identity. + + This is the main entry point - it handles everything automatically: + 1. Finds or creates the agent + 2. Generates keys if needed + 3. Derives and registers DID + 4. Requests badge (if auto_badge=True) + 5. Sets up auto-renewal + + Args: + api_key: Your CapiscIO API key (sk_live_... or sk_test_...) + name: Agent name (auto-generated if omitted) + agent_id: Specific agent ID to use (auto-discovered if omitted) + server_url: Registry server URL (default: production) + keys_dir: Directory for keys (default: ~/.capiscio/keys/{agent_id}/) + auto_badge: Whether to automatically request a badge + dev_mode: Use self-signed badges (Trust Level 0) + + Returns: + AgentIdentity with full credentials and methods + + Example: + agent = CapiscIO.connect(api_key="sk_live_abc123") + print(f"Agent DID: {agent.did}") + agent.emit("agent_started", {"version": "1.0"}) + """ + connector = _Connector( + api_key=api_key, + name=name, + agent_id=agent_id, + server_url=server_url, + keys_dir=keys_dir, + auto_badge=auto_badge, + dev_mode=dev_mode, + ) + return connector.connect() + + @classmethod + def from_env(cls, **kwargs) -> AgentIdentity: + """ + Connect using environment variables. + + Reads from: + - CAPISCIO_API_KEY (required) + - CAPISCIO_AGENT_ID (optional) + - CAPISCIO_AGENT_NAME (optional) + - CAPISCIO_SERVER_URL (optional, default: production) + - CAPISCIO_DEV_MODE (optional, default: false) + """ + api_key = os.environ.get("CAPISCIO_API_KEY") + if not api_key: + raise ValueError( + "CAPISCIO_API_KEY environment variable is required. " + "Get your API key at https://app.capisc.io" + ) + + return cls.connect( + api_key=api_key, + agent_id=os.environ.get("CAPISCIO_AGENT_ID"), + name=os.environ.get("CAPISCIO_AGENT_NAME"), + server_url=os.environ.get("CAPISCIO_SERVER_URL", PROD_REGISTRY), + dev_mode=os.environ.get("CAPISCIO_DEV_MODE", "").lower() in ("true", "1", "yes"), + **kwargs, + ) + + +class _Connector: + """Internal class that handles the connection logic.""" + + def __init__( + self, + api_key: str, + name: Optional[str], + agent_id: Optional[str], + server_url: str, + keys_dir: Optional[Path], + auto_badge: bool, + dev_mode: bool, + ): + self.api_key = api_key + self.name = name + self.agent_id = agent_id + self.server_url = server_url.rstrip("/") + self.keys_dir = keys_dir + self.auto_badge = auto_badge + self.dev_mode = dev_mode + + # HTTP client for registry API + self._client = httpx.Client( + base_url=self.server_url, + headers={ + "X-Capiscio-Registry-Key": self.api_key, + "Content-Type": "application/json", + }, + timeout=30.0, + ) + + # gRPC client for capiscio-core (crypto operations) + self._rpc_client: Optional[CapiscioRPCClient] = None + + def connect(self) -> AgentIdentity: + """Execute the full connection flow.""" + logger.info("Connecting to CapiscIO...") + + try: + # Step 1: Find or create agent + agent_data = self._ensure_agent() + self.agent_id = agent_data["id"] + self.name = agent_data.get("name") or self.name or f"Agent-{self.agent_id[:8]}" + + logger.info(f"Agent: {self.name} ({self.agent_id})") + + # Step 2: Set up keys directory + if not self.keys_dir: + self.keys_dir = DEFAULT_KEYS_DIR / self.agent_id + self.keys_dir.mkdir(parents=True, exist_ok=True) + + # Step 3: Initialize identity via capiscio-core Init RPC (one call does everything) + did = self._init_identity() + logger.info(f"DID: {did}") + + # Step 4: Set up badge (if auto_badge) + badge = None + badge_expires_at = None + keeper = None + guard = None + + if self.auto_badge and not self.dev_mode: + badge, badge_expires_at, keeper, guard = self._setup_badge() + if badge: + logger.info(f"Badge acquired (expires: {badge_expires_at})") + + return AgentIdentity( + agent_id=self.agent_id, + did=did, + name=self.name, + api_key=self.api_key, + server_url=self.server_url, + keys_dir=self.keys_dir, + badge=badge, + badge_expires_at=badge_expires_at, + _guard=guard, + _keeper=keeper, + ) + finally: + # Clean up clients to avoid resource leaks + if self._rpc_client: + try: + self._rpc_client.close() + except Exception: + pass + self._client.close() + + def _ensure_agent(self) -> Dict[str, Any]: + """Find existing agent or create new one.""" + if self.agent_id: + # Fetch specific agent + resp = self._client.get(f"/v1/agents/{self.agent_id}") + if resp.status_code == 200: + data = resp.json() + return data.get("data", data) + elif resp.status_code == 404: + raise ValueError(f"Agent {self.agent_id} not found") + else: + raise RuntimeError(f"Failed to fetch agent: {resp.text}") + + # List agents and find by name or use first one + resp = self._client.get("/v1/agents") + if resp.status_code != 200: + raise RuntimeError(f"Failed to list agents: {resp.text}") + + data = resp.json() + agents = data.get("data", data) if isinstance(data.get("data", data), list) else [] + + # Find by name if specified + if self.name: + for agent in agents: + if agent.get("name") == self.name: + return agent + + # Use first agent if available + if agents: + return agents[0] + + # Create new agent + return self._create_agent() + + def _create_agent(self) -> Dict[str, Any]: + """Create a new agent.""" + name = self.name or f"Agent-{os.urandom(4).hex()}" + + resp = self._client.post("/v1/agents", json={ + "name": name, + "protocol": "a2a", + }) + + if resp.status_code not in (200, 201): + raise RuntimeError(f"Failed to create agent: {resp.text}") + + data = resp.json() + logger.info(f"Created new agent: {name}") + return data.get("data", data) + + def _init_identity(self) -> str: + """Initialize identity via capiscio-core Init RPC. + + This is the "Let's Encrypt" style one-call setup that: + 1. Generates Ed25519 key pair + 2. Derives did:key URI + 3. Registers DID with the server + 4. Creates agent-card.json + + All cryptographic operations are performed by capiscio-core Go library. + """ + did_file_path = self.keys_dir / "did.txt" + private_key_path = self.keys_dir / "private.jwk" + + # Check if we already have a DID and keys (for idempotency) + if did_file_path.exists() and private_key_path.exists(): + logger.debug("Using existing identity from prior init") + return did_file_path.read_text().strip() + + # Connect to capiscio-core gRPC + if not self._rpc_client: + self._rpc_client = CapiscioRPCClient() + self._rpc_client.connect() + + logger.info("Initializing identity via capiscio-core Init RPC...") + + # Call Init RPC - one call does everything + result, error = self._rpc_client.simpleguard.init( + api_key=self.api_key, + agent_id=self.agent_id, + server_url=self.server_url, + output_dir=str(self.keys_dir), + force=False, + ) + + if error: + # Log detailed error for debugging, but avoid exposing it in the exception + logger.error(f"Init RPC failed during identity initialization: {error}") + raise ConfigurationError("Failed to initialize identity. Check logs for details.") + + did = result["did"] + + # Save DID for future reference (idempotency check) + did_file_path.write_text(did) + + logger.info(f"Identity initialized: {did}") + if result.get("registered"): + logger.info("DID registered with server") + + return did + + def _setup_badge(self): + """Set up BadgeKeeper for automatic badge management.""" + try: + from .badge_keeper import BadgeKeeper + from .simple_guard import SimpleGuard + + # Set up SimpleGuard with correct parameters + guard = SimpleGuard( + base_dir=str(self.keys_dir.parent), + agent_id=self.agent_id, + dev_mode=self.dev_mode, + ) + + # Set up BadgeKeeper with correct parameters + keeper = BadgeKeeper( + api_url=self.server_url, + api_key=self.api_key, + agent_id=self.agent_id, + mode="dev" if self.dev_mode else "ca", + output_file=str(self.keys_dir / "badge.jwt"), + ) + + # Start the keeper and get initial badge + keeper.start() + badge = keeper.get_current_badge() + # Get expiration from keeper if available, otherwise None + expires_at = None + if hasattr(keeper, 'badge_expires_at'): + expires_at = keeper.badge_expires_at + elif hasattr(keeper, 'get_badge_expiration'): + expires_at = keeper.get_badge_expiration() + + return badge, expires_at, keeper, guard + + except Exception as e: + logger.warning(f"Badge setup failed (continuing without badge): {e}") + return None, None, None, None + + +# Convenience alias +connect = CapiscIO.connect +from_env = CapiscIO.from_env diff --git a/capiscio_sdk/events.py b/capiscio_sdk/events.py new file mode 100644 index 0000000..ec296f2 --- /dev/null +++ b/capiscio_sdk/events.py @@ -0,0 +1,326 @@ +""" +Event emission for CapiscIO agents. + +Provides a simple interface for emitting events to the CapiscIO registry. +Events are used for observability, auditing, and real-time monitoring. + +Example: + from capiscio_sdk.events import EventEmitter + + emitter = EventEmitter( + server_url="https://registry.capisc.io", + api_key="sk_live_...", + agent_id="my-agent-id", + ) + + emitter.emit("task_started", {"task_id": "123", "input": "..."}) + emitter.emit("tool_call", {"tool": "search", "query": "AI news"}) + emitter.emit("task_completed", {"task_id": "123", "output": "..."}) +""" + +import logging +import threading +import time +import uuid +from datetime import datetime, timezone +from typing import Any, Dict, Optional + +import httpx + +logger = logging.getLogger(__name__) + + +class EventEmitter: + """ + Emits events to the CapiscIO registry. + + Events provide visibility into agent behavior for: + - Real-time monitoring in the dashboard + - Audit trails for compliance + - Analytics and debugging + + Attributes: + server_url: Registry server URL + api_key: API key for authentication + agent_id: Agent ID for event attribution + """ + + # Standard event types + EVENT_TASK_STARTED = "task_started" + EVENT_TASK_COMPLETED = "task_completed" + EVENT_TASK_FAILED = "task_failed" + EVENT_TOOL_CALL = "tool_call" + EVENT_TOOL_RESULT = "tool_result" + EVENT_LLM_CALL = "llm_call" + EVENT_LLM_RESPONSE = "llm_response" + EVENT_AGENT_STARTED = "agent_started" + EVENT_AGENT_STOPPED = "agent_stopped" + EVENT_ERROR = "error" + EVENT_WARNING = "warning" + EVENT_INFO = "info" + + def __init__( + self, + server_url: str = "https://registry.capisc.io", + api_key: Optional[str] = None, + agent_id: Optional[str] = None, + agent_name: Optional[str] = None, + batch_size: int = 10, + flush_interval: float = 5.0, + enabled: bool = True, + ): + """ + Initialize the event emitter. + + Args: + server_url: Registry server URL (default: production) + api_key: API key for authentication + agent_id: Agent ID for event attribution + agent_name: Human-readable agent name (for logging) + batch_size: Number of events to batch before sending + flush_interval: Max seconds between flushes + enabled: Whether to actually send events (for testing) + """ + self.server_url = server_url.rstrip("/") + self.api_key = api_key + self.agent_id = agent_id + self.agent_name = agent_name or agent_id + self.batch_size = batch_size + self.flush_interval = flush_interval + self.enabled = enabled + + self._client = httpx.Client(timeout=10.0) + self._batch: list = [] + self._batch_lock = threading.Lock() + self._last_flush = time.time() + + # Validate config + if enabled and not api_key: + logger.warning("EventEmitter: No API key provided, events will not be sent") + self.enabled = False + + def emit( + self, + event_type: str, + data: Optional[Dict[str, Any]] = None, + *, + task_id: Optional[str] = None, + correlation_id: Optional[str] = None, + flush: bool = False, + ) -> bool: + """ + Emit an event to the registry. + + Args: + event_type: Type of event (e.g., "task_started", "tool_call") + data: Event-specific data + task_id: Optional task ID for correlation + correlation_id: Optional correlation ID for tracing + flush: Whether to flush immediately (default: batch) + + Returns: + True if event was queued/sent successfully + + Example: + emitter.emit("task_started", { + "task_id": "abc123", + "input": "Research AI trends", + }) + """ + if not self.enabled: + return False + + event = { + "id": str(uuid.uuid4()), + "type": event_type, + "agentId": self.agent_id, + "timestamp": datetime.now(timezone.utc).isoformat(), + "data": data or {}, + } + + if task_id: + event["taskId"] = task_id + if correlation_id: + event["correlationId"] = correlation_id + + with self._batch_lock: + self._batch.append(event) + should_flush = flush or len(self._batch) >= self.batch_size + + # Flush if batch is full or flush requested + if should_flush: + return self.flush() + + # Flush if interval exceeded + if time.time() - self._last_flush > self.flush_interval: + return self.flush() + + return True + + def flush(self) -> bool: + """ + Send all batched events to the registry. + + Returns: + True if flush was successful + """ + if not self._batch: + return True + + if not self.enabled: + with self._batch_lock: + self._batch.clear() + return False + + with self._batch_lock: + events_to_send = self._batch.copy() + self._batch.clear() + self._last_flush = time.time() + + try: + headers = { + "Content-Type": "application/json", + "X-Capiscio-Registry-Key": self.api_key, + } + + # Send batch + response = self._client.post( + f"{self.server_url}/v1/events", + headers=headers, + json={"events": events_to_send}, + ) + + if response.status_code in (200, 201, 202): + logger.debug(f"Sent {len(events_to_send)} events") + return True + else: + logger.warning(f"Failed to send events: {response.status_code} {response.text}") + # Re-queue events on failure + with self._batch_lock: + self._batch.extend(events_to_send) + return False + + except Exception as e: + logger.error(f"Error sending events: {e}") + # Re-queue events on failure + with self._batch_lock: + self._batch.extend(events_to_send) + return False + + def task_started(self, task_id: str, input_text: str, **kwargs) -> bool: + """Convenience method for task_started events.""" + return self.emit( + self.EVENT_TASK_STARTED, + {"input": input_text, **kwargs}, + task_id=task_id, + flush=True, + ) + + def task_completed(self, task_id: str, output: str, **kwargs) -> bool: + """Convenience method for task_completed events.""" + return self.emit( + self.EVENT_TASK_COMPLETED, + {"output": output, **kwargs}, + task_id=task_id, + flush=True, + ) + + def task_failed(self, task_id: str, error: str, **kwargs) -> bool: + """Convenience method for task_failed events.""" + return self.emit( + self.EVENT_TASK_FAILED, + {"error": error, **kwargs}, + task_id=task_id, + flush=True, + ) + + def tool_call(self, tool_name: str, arguments: Dict[str, Any], task_id: Optional[str] = None, **kwargs) -> bool: + """Convenience method for tool_call events.""" + return self.emit( + self.EVENT_TOOL_CALL, + {"tool": tool_name, "arguments": arguments, **kwargs}, + task_id=task_id, + ) + + def tool_result(self, tool_name: str, result: Any, task_id: Optional[str] = None, **kwargs) -> bool: + """Convenience method for tool_result events.""" + return self.emit( + self.EVENT_TOOL_RESULT, + {"tool": tool_name, "result": result, **kwargs}, + task_id=task_id, + ) + + def close(self) -> None: + """Flush remaining events and close the client.""" + self.flush() + self._client.close() + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.close() + return False + + +# Global emitter for simple usage +_global_emitter: Optional[EventEmitter] = None + + +def init( + api_key: str, + agent_id: str, + server_url: str = "https://registry.capisc.io", + **kwargs, +) -> EventEmitter: + """ + Initialize the global event emitter. + + Args: + api_key: API key for authentication + agent_id: Agent ID for event attribution + server_url: Registry server URL + + Returns: + The global EventEmitter instance + + Example: + from capiscio_sdk import events + + events.init(api_key="sk_live_...", agent_id="my-agent") + events.emit("task_started", {"task_id": "123"}) + """ + global _global_emitter + _global_emitter = EventEmitter( + server_url=server_url, + api_key=api_key, + agent_id=agent_id, + **kwargs, + ) + return _global_emitter + + +def emit(event_type: str, data: Optional[Dict[str, Any]] = None, **kwargs) -> bool: + """ + Emit an event using the global emitter. + + Must call `init()` first. + + Args: + event_type: Type of event + data: Event data + **kwargs: Additional arguments passed to EventEmitter.emit() + + Returns: + True if event was queued/sent + """ + if _global_emitter is None: + raise RuntimeError("Event emitter not initialized. Call events.init() first.") + return _global_emitter.emit(event_type, data, **kwargs) + + +def flush() -> bool: + """Flush all pending events.""" + if _global_emitter is None: + return False + return _global_emitter.flush() diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index a8bae37..ab205fd 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -1,8 +1,50 @@ # Quick Start -Get your A2A agent protected in **5 minutes** with the CapiscIO Python SDK. +Get your A2A agent up and running with CapiscIO in **under 60 seconds**. -## See the Difference +## Step 1: Get Your Agent Identity (One Line) + +Just like Let's Encrypt made HTTPS easy, CapiscIO makes agent identity easy: + +```python +from capiscio_sdk import CapiscIO + +# One line - handles everything automatically +agent = CapiscIO.connect(api_key="sk_live_...") + +print(agent.did) # did:key:z6Mk... - your agent's identity +print(agent.badge) # Your trust badge +``` + +**What happens automatically:** + +- ✅ Ed25519 key pair generated +- ✅ `did:key` identity derived (RFC-002 compliant) +- ✅ DID registered with CapiscIO registry +- ✅ Agent card created +- ✅ Trust badge requested + +### Using Environment Variables (Recommended for Production) + +```python +from capiscio_sdk import CapiscIO + +# Reads CAPISCIO_API_KEY from environment +agent = CapiscIO.from_env() +``` + +### Two Setup Paths + +| Path | When to Use | Code | +|------|-------------|------| +| **Quick Start** | Getting started, single agent | `CapiscIO.connect(api_key="...")` | +| **UI-First** | Teams, multiple agents | `CapiscIO.connect(api_key="...", agent_id="agt_123")` | + +--- + +## Step 2: Secure Your Agent (Optional but Recommended) + +Now protect your agent from attacks: ### ❌ Without Security @@ -40,7 +82,7 @@ secured_agent = secure(MyAgentExecutor()) ## Prerequisites - Python 3.10 or higher -- An existing A2A agent executor +- A CapiscIO API key ([get one free](https://app.capisc.io)) - Basic familiarity with the [A2A protocol](https://github.com/google/A2A) ## Installation @@ -55,12 +97,18 @@ pip install capiscio-sdk The fastest way to add security to your agent: ```python -from capiscio_sdk import secure +from capiscio_sdk import CapiscIO, secure from my_agent import MyAgentExecutor -# Wrap your agent with security (production defaults) +# 1. Get your agent identity (one line) +agent = CapiscIO.connect(api_key="sk_live_...") + +# 2. Wrap your agent with security (one line) secured_agent = secure(MyAgentExecutor()) +# Your agent now has identity AND security +print(f"Agent DID: {agent.did}") + # Validate an agent card and access scores result = await secured_agent.validate_agent_card(card_url) print(result.compliance.total, result.trust.total, result.availability.total) @@ -68,6 +116,8 @@ print(result.compliance.total, result.trust.total, result.availability.total) That's it! Your agent now has: +- ✅ Cryptographic identity (`did:key`) +- ✅ Trust badge (auto-renewed) - ✅ Message validation - ✅ Protocol compliance checking - ✅ Rate limiting (60 requests/minute) diff --git a/pyproject.toml b/pyproject.toml index 50187dd..59698f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,7 @@ dependencies = [ "cachetools>=5.3.0", "pyjwt[crypto]>=2.8.0", "grpcio>=1.60.0", - "protobuf>=4.25.0", + "protobuf>=6.33.5", ] [project.optional-dependencies] @@ -52,6 +52,7 @@ dev = [ "types-cachetools>=5.3.0", "fastapi>=0.100.0", "starlette>=0.27.0", + "base58>=2.1.0", # Used in tests for DID verification ] [project.urls] diff --git a/tests/unit/test_connect.py b/tests/unit/test_connect.py new file mode 100644 index 0000000..5ffdc8e --- /dev/null +++ b/tests/unit/test_connect.py @@ -0,0 +1,817 @@ +"""Unit tests for capiscio_sdk.connect module.""" + +import os +import pytest +from pathlib import Path +from unittest.mock import MagicMock, patch + +from capiscio_sdk.connect import ( + AgentIdentity, + CapiscIO, + _Connector, + ConfigurationError, + DEFAULT_CONFIG_DIR, + DEFAULT_KEYS_DIR, + PROD_REGISTRY, +) + + +class TestAgentIdentity: + """Tests for AgentIdentity dataclass.""" + + def test_init_basic(self): + """Test basic initialization.""" + identity = AgentIdentity( + agent_id="test-agent-123", + did="did:key:z6MkTest", + name="Test Agent", + api_key="sk_test_abc", + server_url="https://registry.capisc.io", + keys_dir=Path("/tmp/keys"), + ) + + assert identity.agent_id == "test-agent-123" + assert identity.did == "did:key:z6MkTest" + assert identity.name == "Test Agent" + assert identity.api_key == "sk_test_abc" + assert identity.server_url == "https://registry.capisc.io" + assert identity.keys_dir == Path("/tmp/keys") + assert identity.badge is None + assert identity.badge_expires_at is None + + def test_init_with_badge(self): + """Test initialization with badge.""" + identity = AgentIdentity( + agent_id="test-agent-123", + did="did:key:z6MkTest", + name="Test Agent", + api_key="sk_test_abc", + server_url="https://registry.capisc.io", + keys_dir=Path("/tmp/keys"), + badge="eyJhbGciOiJFZERTQSJ9...", + badge_expires_at="2026-02-06T12:00:00Z", + ) + + assert identity.badge == "eyJhbGciOiJFZERTQSJ9..." + assert identity.badge_expires_at == "2026-02-06T12:00:00Z" + + def test_emit_creates_emitter(self): + """Test that emit creates EventEmitter on first call.""" + identity = AgentIdentity( + agent_id="test-agent-123", + did="did:key:z6MkTest", + name="Test Agent", + api_key="sk_test_abc", + server_url="https://registry.capisc.io", + keys_dir=Path("/tmp/keys"), + ) + + assert identity._emitter is None + + with patch("capiscio_sdk.events.EventEmitter") as MockEmitter: + mock_instance = MagicMock() + mock_instance.emit.return_value = True + MockEmitter.return_value = mock_instance + + result = identity.emit("test_event", {"key": "value"}) + + MockEmitter.assert_called_once_with( + server_url="https://registry.capisc.io", + api_key="sk_test_abc", + agent_id="test-agent-123", + ) + mock_instance.emit.assert_called_once_with("test_event", {"key": "value"}) + assert result is True + + def test_emit_reuses_emitter(self): + """Test that emit reuses existing EventEmitter.""" + identity = AgentIdentity( + agent_id="test-agent-123", + did="did:key:z6MkTest", + name="Test Agent", + api_key="sk_test_abc", + server_url="https://registry.capisc.io", + keys_dir=Path("/tmp/keys"), + ) + + mock_emitter = MagicMock() + mock_emitter.emit.return_value = True + identity._emitter = mock_emitter + + identity.emit("event1", {}) + identity.emit("event2", {}) + + assert mock_emitter.emit.call_count == 2 + + def test_get_badge_with_keeper(self): + """Test get_badge delegates to keeper.""" + identity = AgentIdentity( + agent_id="test-agent-123", + did="did:key:z6MkTest", + name="Test Agent", + api_key="sk_test_abc", + server_url="https://registry.capisc.io", + keys_dir=Path("/tmp/keys"), + badge="old-badge", + ) + + mock_keeper = MagicMock() + mock_keeper.get_current_badge.return_value = "new-badge-from-keeper" + identity._keeper = mock_keeper + + result = identity.get_badge() + + mock_keeper.get_current_badge.assert_called_once() + assert result == "new-badge-from-keeper" + + def test_get_badge_without_keeper(self): + """Test get_badge returns stored badge when no keeper.""" + identity = AgentIdentity( + agent_id="test-agent-123", + did="did:key:z6MkTest", + name="Test Agent", + api_key="sk_test_abc", + server_url="https://registry.capisc.io", + keys_dir=Path("/tmp/keys"), + badge="stored-badge", + ) + + result = identity.get_badge() + + assert result == "stored-badge" + + def test_status(self): + """Test status returns correct dict.""" + identity = AgentIdentity( + agent_id="test-agent-123", + did="did:key:z6MkTest", + name="Test Agent", + api_key="sk_test_abc", + server_url="https://registry.capisc.io", + keys_dir=Path("/tmp/keys"), + badge="test-badge", + badge_expires_at="2026-02-06T12:00:00Z", + ) + + status = identity.status() + + assert status == { + "agent_id": "test-agent-123", + "did": "did:key:z6MkTest", + "name": "Test Agent", + "server": "https://registry.capisc.io", + "badge_valid": True, + "badge_expires_at": "2026-02-06T12:00:00Z", + } + + def test_status_no_badge(self): + """Test status with no badge.""" + identity = AgentIdentity( + agent_id="test-agent-123", + did="did:key:z6MkTest", + name="Test Agent", + api_key="sk_test_abc", + server_url="https://registry.capisc.io", + keys_dir=Path("/tmp/keys"), + ) + + status = identity.status() + + assert status["badge_valid"] is False + assert status["badge_expires_at"] is None + + +class TestCapiscIOConnect: + """Tests for CapiscIO.connect() class method.""" + + def test_connect_calls_connector(self): + """Test that connect creates and runs _Connector.""" + # Patch the _Connector class where it's defined in the module + with patch.object(_Connector, "__init__", return_value=None) as mock_init: + with patch.object(_Connector, "connect") as mock_connect: + mock_identity = AgentIdentity( + agent_id="test-123", + did="did:key:z6MkTest", + name="Test", + api_key="sk_test_abc", + server_url=PROD_REGISTRY, + keys_dir=Path("/tmp/keys"), + ) + mock_connect.return_value = mock_identity + + result = CapiscIO.connect( + api_key="sk_test_abc", + name="Test Agent", + server_url="https://custom.server.com", + ) + + mock_init.assert_called_once_with( + api_key="sk_test_abc", + name="Test Agent", + agent_id=None, + server_url="https://custom.server.com", + keys_dir=None, + auto_badge=True, + dev_mode=False, + ) + mock_connect.assert_called_once() + assert result == mock_identity + + +class TestCapiscIOFromEnv: + """Tests for CapiscIO.from_env() class method.""" + + def test_from_env_requires_api_key(self): + """Test that from_env raises without CAPISCIO_API_KEY.""" + with patch.dict(os.environ, {}, clear=True): + # Remove the key if it exists + os.environ.pop("CAPISCIO_API_KEY", None) + + with pytest.raises(ValueError, match="CAPISCIO_API_KEY environment variable is required"): + CapiscIO.from_env() + + def test_from_env_reads_env_vars(self): + """Test that from_env reads all environment variables.""" + env_vars = { + "CAPISCIO_API_KEY": "sk_test_env", + "CAPISCIO_AGENT_ID": "env-agent-id", + "CAPISCIO_AGENT_NAME": "Env Agent", + "CAPISCIO_SERVER_URL": "https://env.server.com", + "CAPISCIO_DEV_MODE": "true", + } + + with patch.dict(os.environ, env_vars, clear=False): + with patch.object(CapiscIO, "connect") as mock_connect: + mock_connect.return_value = MagicMock() + + CapiscIO.from_env() + + mock_connect.assert_called_once_with( + api_key="sk_test_env", + agent_id="env-agent-id", + name="Env Agent", + server_url="https://env.server.com", + dev_mode=True, + ) + + def test_from_env_defaults(self): + """Test from_env uses defaults for missing optional vars.""" + with patch.dict(os.environ, {"CAPISCIO_API_KEY": "sk_test_only"}, clear=False): + # Clear optional vars + for var in ["CAPISCIO_AGENT_ID", "CAPISCIO_AGENT_NAME", "CAPISCIO_SERVER_URL", "CAPISCIO_DEV_MODE"]: + os.environ.pop(var, None) + + with patch.object(CapiscIO, "connect") as mock_connect: + mock_connect.return_value = MagicMock() + + CapiscIO.from_env() + + mock_connect.assert_called_once_with( + api_key="sk_test_only", + agent_id=None, + name=None, + server_url=PROD_REGISTRY, + dev_mode=False, + ) + + @pytest.mark.parametrize("dev_mode_value,expected", [ + ("true", True), + ("True", True), + ("TRUE", True), + ("1", True), + ("yes", True), + ("Yes", True), + ("false", False), + ("0", False), + ("no", False), + ("", False), + ]) + def test_from_env_dev_mode_parsing(self, dev_mode_value, expected): + """Test dev_mode parsing from various string values.""" + with patch.dict(os.environ, { + "CAPISCIO_API_KEY": "sk_test", + "CAPISCIO_DEV_MODE": dev_mode_value, + }, clear=False): + with patch.object(CapiscIO, "connect") as mock_connect: + mock_connect.return_value = MagicMock() + + CapiscIO.from_env() + + call_kwargs = mock_connect.call_args[1] + assert call_kwargs["dev_mode"] == expected + + +class TestConnector: + """Tests for _Connector internal class.""" + + def test_init(self): + """Test _Connector initialization.""" + connector = _Connector( + api_key="sk_test_abc", + name="Test Agent", + agent_id="test-123", + server_url="https://test.server.com", + keys_dir=Path("/custom/keys"), + auto_badge=True, + dev_mode=False, + ) + + assert connector.api_key == "sk_test_abc" + assert connector.name == "Test Agent" + assert connector.agent_id == "test-123" + assert connector.server_url == "https://test.server.com" + assert connector.keys_dir == Path("/custom/keys") + assert connector.auto_badge is True + assert connector.dev_mode is False + + def test_init_strips_trailing_slash(self): + """Test that server_url trailing slash is stripped.""" + connector = _Connector( + api_key="sk_test", + name=None, + agent_id=None, + server_url="https://test.server.com/", + keys_dir=None, + auto_badge=True, + dev_mode=False, + ) + + assert connector.server_url == "https://test.server.com" + + def test_ensure_agent_with_agent_id(self): + """Test _ensure_agent fetches specific agent.""" + connector = _Connector( + api_key="sk_test", + name=None, + agent_id="specific-agent-id", + server_url="https://test.server.com", + keys_dir=None, + auto_badge=True, + dev_mode=False, + ) + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"data": {"id": "specific-agent-id", "name": "My Agent"}} + connector._client.get = MagicMock(return_value=mock_response) + + result = connector._ensure_agent() + + connector._client.get.assert_called_once_with("/v1/agents/specific-agent-id") + assert result == {"id": "specific-agent-id", "name": "My Agent"} + + def test_ensure_agent_not_found(self): + """Test _ensure_agent raises on 404.""" + connector = _Connector( + api_key="sk_test", + name=None, + agent_id="missing-agent", + server_url="https://test.server.com", + keys_dir=None, + auto_badge=True, + dev_mode=False, + ) + + mock_response = MagicMock() + mock_response.status_code = 404 + connector._client.get = MagicMock(return_value=mock_response) + + with pytest.raises(ValueError, match="Agent missing-agent not found"): + connector._ensure_agent() + + def test_ensure_agent_lists_and_finds_by_name(self): + """Test _ensure_agent finds agent by name.""" + connector = _Connector( + api_key="sk_test", + name="Target Agent", + agent_id=None, + server_url="https://test.server.com", + keys_dir=None, + auto_badge=True, + dev_mode=False, + ) + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "data": [ + {"id": "agent-1", "name": "Other Agent"}, + {"id": "agent-2", "name": "Target Agent"}, + ] + } + connector._client.get = MagicMock(return_value=mock_response) + + result = connector._ensure_agent() + + assert result == {"id": "agent-2", "name": "Target Agent"} + + def test_ensure_agent_uses_first_when_no_name(self): + """Test _ensure_agent uses first agent when no name specified.""" + connector = _Connector( + api_key="sk_test", + name=None, + agent_id=None, + server_url="https://test.server.com", + keys_dir=None, + auto_badge=True, + dev_mode=False, + ) + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "data": [ + {"id": "first-agent", "name": "First"}, + {"id": "second-agent", "name": "Second"}, + ] + } + connector._client.get = MagicMock(return_value=mock_response) + + result = connector._ensure_agent() + + assert result == {"id": "first-agent", "name": "First"} + + def test_create_agent(self): + """Test _create_agent creates new agent.""" + connector = _Connector( + api_key="sk_test", + name="New Agent", + agent_id=None, + server_url="https://test.server.com", + keys_dir=None, + auto_badge=True, + dev_mode=False, + ) + + mock_response = MagicMock() + mock_response.status_code = 201 + mock_response.json.return_value = {"data": {"id": "new-agent-id", "name": "New Agent"}} + connector._client.post = MagicMock(return_value=mock_response) + + result = connector._create_agent() + + connector._client.post.assert_called_once_with("/v1/agents", json={ + "name": "New Agent", + "protocol": "a2a", + }) + assert result == {"id": "new-agent-id", "name": "New Agent"} + + def test_ensure_agent_fetch_error(self): + """Test _ensure_agent raises on server error.""" + connector = _Connector( + api_key="sk_test", + name=None, + agent_id="some-agent", + server_url="https://test.server.com", + keys_dir=None, + auto_badge=True, + dev_mode=False, + ) + + mock_response = MagicMock() + mock_response.status_code = 500 + mock_response.text = "Internal Server Error" + connector._client.get = MagicMock(return_value=mock_response) + + with pytest.raises(RuntimeError, match="Failed to fetch agent"): + connector._ensure_agent() + + def test_ensure_agent_list_error(self): + """Test _ensure_agent raises when listing fails.""" + connector = _Connector( + api_key="sk_test", + name=None, + agent_id=None, + server_url="https://test.server.com", + keys_dir=None, + auto_badge=True, + dev_mode=False, + ) + + mock_response = MagicMock() + mock_response.status_code = 500 + mock_response.text = "Failed to list" + connector._client.get = MagicMock(return_value=mock_response) + + with pytest.raises(RuntimeError, match="Failed to list agents"): + connector._ensure_agent() + + def test_ensure_agent_creates_when_empty(self): + """Test _ensure_agent creates agent when list is empty.""" + connector = _Connector( + api_key="sk_test", + name=None, + agent_id=None, + server_url="https://test.server.com", + keys_dir=None, + auto_badge=True, + dev_mode=False, + ) + + # First call returns empty list, second call creates + list_response = MagicMock() + list_response.status_code = 200 + list_response.json.return_value = {"data": []} + + create_response = MagicMock() + create_response.status_code = 201 + create_response.json.return_value = {"data": {"id": "new-id", "name": "New"}} + + connector._client.get = MagicMock(return_value=list_response) + connector._client.post = MagicMock(return_value=create_response) + + result = connector._ensure_agent() + + assert result == {"id": "new-id", "name": "New"} + connector._client.post.assert_called_once() + + def test_create_agent_generates_name(self): + """Test _create_agent generates name when not provided.""" + connector = _Connector( + api_key="sk_test", + name=None, + agent_id=None, + server_url="https://test.server.com", + keys_dir=None, + auto_badge=True, + dev_mode=False, + ) + + mock_response = MagicMock() + mock_response.status_code = 201 + mock_response.json.return_value = {"data": {"id": "new-id", "name": "Agent-abc123"}} + connector._client.post = MagicMock(return_value=mock_response) + + result = connector._create_agent() + + # Name should start with "Agent-" + call_args = connector._client.post.call_args + assert call_args[1]["json"]["name"].startswith("Agent-") + + def test_create_agent_failure(self): + """Test _create_agent raises on failure.""" + connector = _Connector( + api_key="sk_test", + name="Test", + agent_id=None, + server_url="https://test.server.com", + keys_dir=None, + auto_badge=True, + dev_mode=False, + ) + + mock_response = MagicMock() + mock_response.status_code = 400 + mock_response.text = "Bad request" + connector._client.post = MagicMock(return_value=mock_response) + + with pytest.raises(RuntimeError, match="Failed to create agent"): + connector._create_agent() + + def test_connect_full_flow(self, tmp_path): + """Test connect() executes full flow.""" + connector = _Connector( + api_key="sk_test", + name="Test Agent", + agent_id=None, + server_url="https://test.server.com", + keys_dir=tmp_path / "keys", + auto_badge=False, # Skip badge setup for simplicity + dev_mode=False, + ) + + # Mock _ensure_agent + connector._ensure_agent = MagicMock(return_value={ + "id": "agent-123", + "name": "Test Agent", + }) + + # Mock _init_identity + connector._init_identity = MagicMock(return_value="did:key:z6MkTest") + + result = connector.connect() + + assert result.agent_id == "agent-123" + assert result.did == "did:key:z6MkTest" + assert result.name == "Test Agent" + assert result.keys_dir == tmp_path / "keys" + connector._ensure_agent.assert_called_once() + connector._init_identity.assert_called_once() + + def test_connect_with_auto_badge(self, tmp_path): + """Test connect() sets up badge when auto_badge=True.""" + connector = _Connector( + api_key="sk_test", + name="Test Agent", + agent_id=None, + server_url="https://test.server.com", + keys_dir=tmp_path / "keys", + auto_badge=True, + dev_mode=False, + ) + + connector._ensure_agent = MagicMock(return_value={ + "id": "agent-123", + "name": "Test Agent", + }) + connector._init_identity = MagicMock(return_value="did:key:z6MkTest") + connector._setup_badge = MagicMock(return_value=( + "badge-jwt", + "2026-12-31T00:00:00Z", + MagicMock(), # keeper + MagicMock(), # guard + )) + + result = connector.connect() + + assert result.badge == "badge-jwt" + assert result.badge_expires_at == "2026-12-31T00:00:00Z" + connector._setup_badge.assert_called_once() + + def test_connect_skips_badge_in_dev_mode(self, tmp_path): + """Test connect() skips badge in dev_mode.""" + connector = _Connector( + api_key="sk_test", + name="Test Agent", + agent_id=None, + server_url="https://test.server.com", + keys_dir=tmp_path / "keys", + auto_badge=True, + dev_mode=True, # Dev mode should skip badge + ) + + connector._ensure_agent = MagicMock(return_value={ + "id": "agent-123", + "name": "Test Agent", + }) + connector._init_identity = MagicMock(return_value="did:key:z6MkTest") + connector._setup_badge = MagicMock() + + result = connector.connect() + + assert result.badge is None + connector._setup_badge.assert_not_called() + + def test_connect_generates_name_from_id(self, tmp_path): + """Test connect() generates name from agent ID when missing.""" + connector = _Connector( + api_key="sk_test", + name=None, + agent_id=None, + server_url="https://test.server.com", + keys_dir=tmp_path / "keys", + auto_badge=False, + dev_mode=False, + ) + + connector._ensure_agent = MagicMock(return_value={ + "id": "agent-123456789", + "name": None, # No name from server + }) + connector._init_identity = MagicMock(return_value="did:key:z6MkTest") + + result = connector.connect() + + # Should generate name from first 8 chars of ID + assert result.name == "Agent-agent-12" + + def test_init_identity_uses_existing(self, tmp_path): + """Test _init_identity returns existing DID if files exist.""" + 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, + ) + + # Create existing identity files + (tmp_path / "did.txt").write_text("did:key:z6MkExisting") + (tmp_path / "private.jwk").write_text('{"kty":"OKP"}') + + result = connector._init_identity() + + assert result == "did:key:z6MkExisting" + + def test_init_identity_calls_rpc(self, tmp_path): + """Test _init_identity calls capiscio-core RPC.""" + from capiscio_sdk.connect import ConfigurationError + + 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, + ) + + # Directly set _rpc_client to skip instantiation (connect.py checks if not self._rpc_client) + connector._rpc_client = mock_rpc + result = connector._init_identity() + + assert result == "did:key:z6MkNew" + # Note: connect() not called since _rpc_client was preset + mock_rpc.simpleguard.init.assert_called_once_with( + api_key="sk_test", + agent_id="agent-123", + server_url="https://test.server.com", + output_dir=str(tmp_path), + force=False, + ) + + def test_init_identity_rpc_error(self, tmp_path): + """Test _init_identity raises on RPC error.""" + from capiscio_sdk.connect import ConfigurationError + + 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 = (None, "RPC failed") + + # Directly set _rpc_client to skip instantiation (connect.py checks if not self._rpc_client) + connector._rpc_client = mock_rpc + with pytest.raises(ConfigurationError, match="Failed to initialize identity"): + connector._init_identity() + + def test_setup_badge_success(self, tmp_path): + """Test _setup_badge sets up keeper and guard.""" + connector = _Connector( + api_key="sk_test", + name="Test", + agent_id="agent-123", + server_url="https://test.server.com", + keys_dir=tmp_path, + auto_badge=True, + dev_mode=False, + ) + + mock_keeper = MagicMock() + mock_keeper.get_current_badge.return_value = "badge-jwt" + mock_keeper.badge_expires_at = "2026-12-31T00:00:00Z" + + mock_guard = MagicMock() + + with patch("capiscio_sdk.badge_keeper.BadgeKeeper", return_value=mock_keeper): + with patch("capiscio_sdk.simple_guard.SimpleGuard", return_value=mock_guard): + badge, expires, keeper, guard = connector._setup_badge() + + assert badge == "badge-jwt" + assert expires == "2026-12-31T00:00:00Z" + assert keeper == mock_keeper + assert guard == mock_guard + mock_keeper.start.assert_called_once() + mock_keeper.get_current_badge.assert_called_once() + + def test_setup_badge_failure_continues(self, tmp_path): + """Test _setup_badge returns None on failure without raising.""" + connector = _Connector( + api_key="sk_test", + name="Test", + agent_id="agent-123", + server_url="https://test.server.com", + keys_dir=tmp_path, + auto_badge=True, + dev_mode=False, + ) + + with patch("capiscio_sdk.badge_keeper.BadgeKeeper", side_effect=Exception("Setup failed")): + badge, expires, keeper, guard = connector._setup_badge() + + assert badge is None + assert expires is None + assert keeper is None + assert guard is None + + +class TestDefaultPaths: + """Tests for default path constants.""" + + def test_default_config_dir(self): + """Test DEFAULT_CONFIG_DIR is in home directory.""" + assert DEFAULT_CONFIG_DIR == Path.home() / ".capiscio" + + def test_default_keys_dir(self): + """Test DEFAULT_KEYS_DIR is under config dir.""" + assert DEFAULT_KEYS_DIR == Path.home() / ".capiscio" / "keys" + + def test_prod_registry(self): + """Test PROD_REGISTRY constant.""" + assert PROD_REGISTRY == "https://registry.capisc.io" diff --git a/tests/unit/test_events.py b/tests/unit/test_events.py new file mode 100644 index 0000000..329fecf --- /dev/null +++ b/tests/unit/test_events.py @@ -0,0 +1,461 @@ +"""Unit tests for capiscio_sdk.events module.""" + +import pytest +from unittest.mock import MagicMock, patch + +from capiscio_sdk.events import ( + EventEmitter, + init, + emit, + flush, +) + + +class TestEventEmitter: + """Tests for EventEmitter class.""" + + def test_init_basic(self): + """Test basic initialization.""" + emitter = EventEmitter( + server_url="https://registry.capisc.io", + api_key="sk_test_abc", + agent_id="test-agent-123", + ) + + assert emitter.server_url == "https://registry.capisc.io" + assert emitter.api_key == "sk_test_abc" + assert emitter.agent_id == "test-agent-123" + assert emitter.enabled is True + assert emitter.batch_size == 10 + assert emitter.flush_interval == 5.0 + + def test_init_strips_trailing_slash(self): + """Test that trailing slash is stripped from server_url.""" + emitter = EventEmitter( + server_url="https://registry.capisc.io/", + api_key="sk_test", + agent_id="test", + ) + + assert emitter.server_url == "https://registry.capisc.io" + + def test_init_disabled_without_api_key(self): + """Test that emitter is disabled without API key.""" + emitter = EventEmitter( + server_url="https://registry.capisc.io", + api_key=None, + agent_id="test", + ) + + assert emitter.enabled is False + + def test_init_custom_settings(self): + """Test initialization with custom settings.""" + emitter = EventEmitter( + server_url="https://custom.server.com", + api_key="sk_test", + agent_id="test", + agent_name="Custom Agent", + batch_size=5, + flush_interval=10.0, + enabled=False, + ) + + assert emitter.batch_size == 5 + assert emitter.flush_interval == 10.0 + assert emitter.enabled is False + assert emitter.agent_name == "Custom Agent" + + def test_emit_disabled_returns_false(self): + """Test that emit returns False when disabled.""" + emitter = EventEmitter( + server_url="https://registry.capisc.io", + api_key="sk_test", + agent_id="test", + enabled=False, + ) + + result = emitter.emit("test_event", {"key": "value"}) + + assert result is False + assert len(emitter._batch) == 0 + + def test_emit_adds_to_batch(self): + """Test that emit adds event to batch.""" + emitter = EventEmitter( + server_url="https://registry.capisc.io", + api_key="sk_test", + agent_id="test-agent", + batch_size=10, + ) + + result = emitter.emit("test_event", {"key": "value"}) + + assert result is True + assert len(emitter._batch) == 1 + + event = emitter._batch[0] + assert event["type"] == "test_event" + assert event["agentId"] == "test-agent" + assert event["data"] == {"key": "value"} + assert "id" in event + assert "timestamp" in event + + def test_emit_with_task_id(self): + """Test emit with task_id.""" + emitter = EventEmitter( + server_url="https://registry.capisc.io", + api_key="sk_test", + agent_id="test-agent", + ) + + emitter.emit("test_event", {}, task_id="task-123") + + assert emitter._batch[0]["taskId"] == "task-123" + + def test_emit_with_correlation_id(self): + """Test emit with correlation_id.""" + emitter = EventEmitter( + server_url="https://registry.capisc.io", + api_key="sk_test", + agent_id="test-agent", + ) + + emitter.emit("test_event", {}, correlation_id="corr-456") + + assert emitter._batch[0]["correlationId"] == "corr-456" + + def test_emit_flushes_when_batch_full(self): + """Test that emit flushes when batch is full.""" + emitter = EventEmitter( + server_url="https://registry.capisc.io", + api_key="sk_test", + agent_id="test-agent", + batch_size=2, + ) + + with patch.object(emitter, "flush", return_value=True) as mock_flush: + emitter.emit("event1", {}) + mock_flush.assert_not_called() + + emitter.emit("event2", {}) + mock_flush.assert_called_once() + + def test_emit_with_flush_flag(self): + """Test emit with flush=True.""" + emitter = EventEmitter( + server_url="https://registry.capisc.io", + api_key="sk_test", + agent_id="test-agent", + ) + + with patch.object(emitter, "flush", return_value=True) as mock_flush: + emitter.emit("test_event", {}, flush=True) + mock_flush.assert_called_once() + + def test_flush_empty_batch(self): + """Test flush with empty batch.""" + emitter = EventEmitter( + server_url="https://registry.capisc.io", + api_key="sk_test", + agent_id="test-agent", + ) + + result = emitter.flush() + + assert result is True + + def test_flush_sends_events(self): + """Test flush sends events to server.""" + emitter = EventEmitter( + server_url="https://registry.capisc.io", + api_key="sk_test", + agent_id="test-agent", + ) + + # Add events directly to batch + emitter._batch = [ + {"id": "1", "type": "event1", "data": {}}, + {"id": "2", "type": "event2", "data": {}}, + ] + + mock_response = MagicMock() + mock_response.status_code = 200 + + with patch.object(emitter._client, "post", return_value=mock_response) as mock_post: + result = emitter.flush() + + assert result is True + assert len(emitter._batch) == 0 + mock_post.assert_called_once() + + call_kwargs = mock_post.call_args[1] + assert call_kwargs["json"]["events"] == [ + {"id": "1", "type": "event1", "data": {}}, + {"id": "2", "type": "event2", "data": {}}, + ] + assert call_kwargs["headers"]["X-Capiscio-Registry-Key"] == "sk_test" + + def test_flush_requeues_on_failure(self): + """Test that flush requeues events on server error.""" + emitter = EventEmitter( + server_url="https://registry.capisc.io", + api_key="sk_test", + agent_id="test-agent", + ) + + events = [{"id": "1", "type": "event1", "data": {}}] + emitter._batch = events.copy() + + mock_response = MagicMock() + mock_response.status_code = 500 + mock_response.text = "Internal Server Error" + + with patch.object(emitter._client, "post", return_value=mock_response): + result = emitter.flush() + + assert result is False + assert len(emitter._batch) == 1 # Events requeued + + def test_flush_requeues_on_exception(self): + """Test that flush requeues events on exception.""" + emitter = EventEmitter( + server_url="https://registry.capisc.io", + api_key="sk_test", + agent_id="test-agent", + ) + + emitter._batch = [{"id": "1", "type": "event1", "data": {}}] + + with patch.object(emitter._client, "post", side_effect=Exception("Network error")): + result = emitter.flush() + + assert result is False + assert len(emitter._batch) == 1 + + def test_flush_disabled(self): + """Test flush when disabled clears batch.""" + emitter = EventEmitter( + server_url="https://registry.capisc.io", + api_key="sk_test", + agent_id="test-agent", + enabled=False, + ) + + # Manually add event (bypassing emit's disabled check) + emitter._batch = [{"id": "1", "type": "event1", "data": {}}] + + result = emitter.flush() + + assert result is False + assert len(emitter._batch) == 0 + + def test_task_started(self): + """Test task_started convenience method.""" + emitter = EventEmitter( + server_url="https://registry.capisc.io", + api_key="sk_test", + agent_id="test-agent", + ) + + with patch.object(emitter, "emit", return_value=True) as mock_emit: + emitter.task_started("task-123", "Process data", extra="info") + + mock_emit.assert_called_once_with( + EventEmitter.EVENT_TASK_STARTED, + {"input": "Process data", "extra": "info"}, + task_id="task-123", + flush=True, + ) + + def test_task_completed(self): + """Test task_completed convenience method.""" + emitter = EventEmitter( + server_url="https://registry.capisc.io", + api_key="sk_test", + agent_id="test-agent", + ) + + with patch.object(emitter, "emit", return_value=True) as mock_emit: + emitter.task_completed("task-123", "Result data") + + mock_emit.assert_called_once_with( + EventEmitter.EVENT_TASK_COMPLETED, + {"output": "Result data"}, + task_id="task-123", + flush=True, + ) + + def test_task_failed(self): + """Test task_failed convenience method.""" + emitter = EventEmitter( + server_url="https://registry.capisc.io", + api_key="sk_test", + agent_id="test-agent", + ) + + with patch.object(emitter, "emit", return_value=True) as mock_emit: + emitter.task_failed("task-123", "Error message") + + mock_emit.assert_called_once_with( + EventEmitter.EVENT_TASK_FAILED, + {"error": "Error message"}, + task_id="task-123", + flush=True, + ) + + def test_tool_call(self): + """Test tool_call convenience method.""" + emitter = EventEmitter( + server_url="https://registry.capisc.io", + api_key="sk_test", + agent_id="test-agent", + ) + + with patch.object(emitter, "emit", return_value=True) as mock_emit: + emitter.tool_call("search", {"query": "test"}, task_id="task-123") + + mock_emit.assert_called_once_with( + EventEmitter.EVENT_TOOL_CALL, + {"tool": "search", "arguments": {"query": "test"}}, + task_id="task-123", + ) + + def test_tool_result(self): + """Test tool_result convenience method.""" + emitter = EventEmitter( + server_url="https://registry.capisc.io", + api_key="sk_test", + agent_id="test-agent", + ) + + with patch.object(emitter, "emit", return_value=True) as mock_emit: + emitter.tool_result("search", ["result1", "result2"]) + + mock_emit.assert_called_once_with( + EventEmitter.EVENT_TOOL_RESULT, + {"tool": "search", "result": ["result1", "result2"]}, + task_id=None, + ) + + def test_close_flushes_and_closes(self): + """Test close flushes and closes client.""" + emitter = EventEmitter( + server_url="https://registry.capisc.io", + api_key="sk_test", + agent_id="test-agent", + ) + + with patch.object(emitter, "flush") as mock_flush: + with patch.object(emitter._client, "close") as mock_close: + emitter.close() + + mock_flush.assert_called_once() + mock_close.assert_called_once() + + def test_context_manager(self): + """Test context manager usage.""" + with patch("capiscio_sdk.events.httpx.Client"): + emitter = EventEmitter( + server_url="https://registry.capisc.io", + api_key="sk_test", + agent_id="test-agent", + ) + + with patch.object(emitter, "close") as mock_close: + with emitter as e: + assert e is emitter + + mock_close.assert_called_once() + + def test_event_type_constants(self): + """Test event type constants.""" + assert EventEmitter.EVENT_TASK_STARTED == "task_started" + assert EventEmitter.EVENT_TASK_COMPLETED == "task_completed" + assert EventEmitter.EVENT_TASK_FAILED == "task_failed" + assert EventEmitter.EVENT_TOOL_CALL == "tool_call" + assert EventEmitter.EVENT_TOOL_RESULT == "tool_result" + assert EventEmitter.EVENT_LLM_CALL == "llm_call" + assert EventEmitter.EVENT_LLM_RESPONSE == "llm_response" + assert EventEmitter.EVENT_AGENT_STARTED == "agent_started" + assert EventEmitter.EVENT_AGENT_STOPPED == "agent_stopped" + assert EventEmitter.EVENT_ERROR == "error" + assert EventEmitter.EVENT_WARNING == "warning" + assert EventEmitter.EVENT_INFO == "info" + + +class TestGlobalFunctions: + """Tests for global event functions.""" + + def test_init_creates_global_emitter(self): + """Test init creates global emitter.""" + import capiscio_sdk.events as events_module + + # Reset global emitter + events_module._global_emitter = None + + result = init( + api_key="sk_test", + agent_id="test-agent", + server_url="https://test.server.com", + ) + + assert result is events_module._global_emitter + assert isinstance(result, EventEmitter) + assert result.api_key == "sk_test" + assert result.agent_id == "test-agent" + + # Cleanup + events_module._global_emitter = None + + def test_emit_without_init_raises(self): + """Test emit raises without init.""" + import capiscio_sdk.events as events_module + + # Reset global emitter + events_module._global_emitter = None + + with pytest.raises(RuntimeError, match="Event emitter not initialized"): + emit("test_event", {}) + + def test_emit_with_init(self): + """Test emit works after init.""" + import capiscio_sdk.events as events_module + + # Initialize + events_module._global_emitter = None + init(api_key="sk_test", agent_id="test-agent", enabled=False) + + # Should not raise (even though disabled) + result = emit("test_event", {"key": "value"}) + + assert result is False # Disabled, so returns False + + # Cleanup + events_module._global_emitter = None + + def test_flush_without_init(self): + """Test flush returns False without init.""" + import capiscio_sdk.events as events_module + + events_module._global_emitter = None + + result = flush() + + assert result is False + + def test_flush_with_init(self): + """Test flush works after init.""" + import capiscio_sdk.events as events_module + + events_module._global_emitter = None + emitter = init(api_key="sk_test", agent_id="test-agent") + + with patch.object(emitter, "flush", return_value=True) as mock_flush: + result = flush() + + mock_flush.assert_called_once() + assert result is True + + # Cleanup + events_module._global_emitter = None