sockridge 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,55 @@
1
+ # ── Go ────────────────────────────────────────────────────────────────────────
2
+ bin/
3
+ *.exe
4
+ *.test
5
+ *.out
6
+ vendor/
7
+ go.sum
8
+
9
+ # generated proto code — regenerate with buf generate
10
+ gen/
11
+
12
+ # ── Python SDK ────────────────────────────────────────────────────────────────
13
+ sdk/python/__pycache__/
14
+ sdk/python/*.egg-info/
15
+ sdk/python/dist/
16
+ sdk/python/build/
17
+ sdk/python/.venv/
18
+ sdk/python/sockridge/__pycache__/
19
+ **/__pycache__/
20
+ **/*.pyc
21
+ **/*.pyo
22
+
23
+ # ── TypeScript SDK ────────────────────────────────────────────────────────────
24
+ sdk/typescript/node_modules/
25
+ sdk/typescript/dist/
26
+ sdk/typescript/*.js
27
+ sdk/typescript/*.d.ts
28
+ sdk/typescript/*.js.map
29
+
30
+ # ── Docker ────────────────────────────────────────────────────────────────────
31
+ .env
32
+ .env.local
33
+ .env.production
34
+
35
+ # ── Credentials — NEVER commit these ─────────────────────────────────────────
36
+ .sockridge/
37
+ *.key
38
+ credentials.json
39
+ **/ed25519.key
40
+ **/credentials.json
41
+
42
+ # ── Test files ────────────────────────────────────────────────────────────────
43
+ test_agents/
44
+ /tmp/
45
+ *.log
46
+
47
+ # ── OS ────────────────────────────────────────────────────────────────────────
48
+ .DS_Store
49
+ Thumbs.db
50
+
51
+ # ── IDE ───────────────────────────────────────────────────────────────────────
52
+ .vscode/
53
+ .idea/
54
+ *.swp
55
+ *.swo
@@ -0,0 +1,246 @@
1
+ Metadata-Version: 2.4
2
+ Name: sockridge
3
+ Version: 0.1.0
4
+ Summary: Python SDK for the SockRidge agent registry
5
+ Project-URL: Homepage, https://github.com/Sockridge/sockridge
6
+ License: MIT
7
+ Requires-Python: >=3.11
8
+ Requires-Dist: cryptography>=42.0.0
9
+ Requires-Dist: httpx[http2]>=0.27.0
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest; extra == 'dev'
12
+ Requires-Dist: pytest-asyncio; extra == 'dev'
13
+ Description-Content-Type: text/markdown
14
+
15
+ # sockridge — Python SDK
16
+
17
+ Python SDK for the [Sockridge](https://sockridge.com) agent registry.
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ pip install git+https://github.com/Sockridge/sockridge.git#subdirectory=sdk/python
23
+ ```
24
+
25
+ Or for local development:
26
+
27
+ ```bash
28
+ git clone https://github.com/Sockridge/sockridge.git
29
+ pip install -e sockridge/sdk/python
30
+ ```
31
+
32
+ ## Prerequisites
33
+
34
+ You need a publisher account before using the SDK. Set one up with the CLI:
35
+
36
+ ```bash
37
+ # install CLI
38
+ go install github.com/Sockridge/sockridge/cli@latest
39
+
40
+ # register
41
+ sockridge auth keygen
42
+ sockridge auth register --handle yourhandle --server https://sockridge.com:9000
43
+ sockridge auth login --server https://sockridge.com:9000
44
+ ```
45
+
46
+ This creates `~/.sockridge/credentials.json` and `~/.sockridge/ed25519.key` which the SDK reads automatically.
47
+
48
+ ---
49
+
50
+ ## Usage
51
+
52
+ ### Connect
53
+
54
+ ```python
55
+ from sockridge import Registry
56
+
57
+ registry = Registry("https://sockridge.com:9000")
58
+ registry.login() # reads ~/.sockridge/credentials.json
59
+ ```
60
+
61
+ Custom credentials path:
62
+
63
+ ```python
64
+ registry.login(
65
+ credentials_path="/custom/path/credentials.json",
66
+ key_path="/custom/path/ed25519.key",
67
+ )
68
+ ```
69
+
70
+ ---
71
+
72
+ ### Publish an agent
73
+
74
+ ```python
75
+ from sockridge import Registry, AgentCard, Skill, Capabilities
76
+
77
+ registry = Registry("https://sockridge.com:9000")
78
+ registry.login()
79
+
80
+ card = AgentCard(
81
+ name="My FHIR Agent",
82
+ description="Analyzes lab trends from FHIR data using ML models",
83
+ url="https://my-agent.example.com",
84
+ version="1.0.0",
85
+ skills=[
86
+ Skill(
87
+ id="fhir.lab.analyze",
88
+ name="Lab Analyzer",
89
+ description="Detects anomalies in lab result trends",
90
+ tags=["fhir", "labs", "analysis"],
91
+ )
92
+ ],
93
+ capabilities=Capabilities(streaming=True, tool_use=True),
94
+ )
95
+
96
+ published = registry.publish(card)
97
+ print(f"id: {published.id}")
98
+ print(f"status: {published.status}") # PENDING → ACTIVE after gatekeeper
99
+ ```
100
+
101
+ The agent goes through automatic validation after publish:
102
+
103
+ - Fields are checked (name, description, skills required)
104
+ - URL is pinged to verify the agent is running
105
+ - AI scores the card quality (0.0 - 1.0)
106
+ - Score >= 0.4 → `AGENT_STATUS_ACTIVE`
107
+
108
+ ---
109
+
110
+ ### Self-register on startup
111
+
112
+ For agents that register themselves when they start:
113
+
114
+ ```python
115
+ registry = Registry("https://sockridge.com:9000")
116
+ published = registry.register_and_publish(card)
117
+ print(f"registered: {published.id}")
118
+ ```
119
+
120
+ ---
121
+
122
+ ### Search for agents
123
+
124
+ ```python
125
+ # by tag
126
+ agents = registry.search(tags=["fhir", "labs"])
127
+ for agent in agents:
128
+ print(f"{agent.name} — {agent.id}")
129
+
130
+ # by natural language
131
+ results = registry.semantic_search("find me a lab result analyzer")
132
+ for r in results:
133
+ print(f"{r['score']:.2f} {r['agent'].name}")
134
+
135
+ # by ID
136
+ agent = registry.get_agent("agent-uuid-here")
137
+ print(agent.name, agent.skills)
138
+ ```
139
+
140
+ ---
141
+
142
+ ### Access agreements
143
+
144
+ Agents can only get each other's endpoint URLs after a mutual access agreement is approved by both publishers.
145
+
146
+ ```python
147
+ # request access from another publisher
148
+ agreement = registry.request_access(
149
+ receiver_id="other-publisher-uuid",
150
+ message="want to connect our agents for a healthcare pipeline",
151
+ )
152
+ print(f"agreement id: {agreement['id']}")
153
+ print(f"status: {agreement['status']}") # AGREEMENT_STATUS_PENDING
154
+
155
+ # once they approve, get the shared key
156
+ # (they share it with you out of band, or you retrieve via get_agreement)
157
+
158
+ # resolve an agent's endpoint URL
159
+ result = registry.resolve_endpoint(
160
+ agent_id="agent-uuid",
161
+ shared_key="sk_abc123...",
162
+ )
163
+ print(f"url: {result['url']}")
164
+ print(f"transport: {result['transport']}")
165
+ print(f"skills: {[s.name for s in result['agent'].skills]}")
166
+ ```
167
+
168
+ ---
169
+
170
+ ### Full agreement flow
171
+
172
+ ```python
173
+ # publisher A requests access to publisher B
174
+ agreement = registry_a.request_access(
175
+ receiver_id=publisher_b_id,
176
+ message="building a medical pipeline",
177
+ )
178
+
179
+ # publisher B approves (on their side)
180
+ shared_key = registry_b.approve_access(agreement["id"])
181
+ # shared_key = "sk_abc123..."
182
+
183
+ # both sides can now resolve each other's agents
184
+ endpoint_a = registry_b.resolve_endpoint(agent_a_id, shared_key)
185
+ endpoint_b = registry_a.resolve_endpoint(agent_b_id, shared_key)
186
+
187
+ # either side can revoke
188
+ registry_a.revoke_access(agreement["id"])
189
+ # key is instantly invalid
190
+ ```
191
+
192
+ ---
193
+
194
+ ## API reference
195
+
196
+ ### `Registry(server_url)`
197
+
198
+ | Method | Description |
199
+ | -------------------------------------------- | ---------------------------------------------------------- |
200
+ | `login(credentials_path?, key_path?)` | Authenticate with Ed25519 challenge-response |
201
+ | `publish(card)` | Publish an AgentCard, returns card with server-assigned id |
202
+ | `register_and_publish(card, ...)` | Login + publish in one call |
203
+ | `search(tags?, limit?)` | List agents by tag. URL not included |
204
+ | `semantic_search(query, top_k?, min_score?)` | Natural language search |
205
+ | `get_agent(agent_id)` | Get a single agent by ID |
206
+ | `request_access(receiver_id, message?)` | Send mutual access request |
207
+ | `approve_access(agreement_id)` | Approve a pending request, returns shared key |
208
+ | `resolve_endpoint(agent_id, shared_key)` | Resolve agent URL using shared key |
209
+
210
+ ### `AgentCard`
211
+
212
+ | Field | Type | Required |
213
+ | ------------------ | ------------ | ------------------- |
214
+ | `name` | str | ✓ |
215
+ | `description` | str | ✓ |
216
+ | `url` | str | ✓ |
217
+ | `version` | str | defaults to `0.1.0` |
218
+ | `protocol_version` | str | defaults to `0.3.0` |
219
+ | `skills` | list[Skill] | ✓ at least one |
220
+ | `capabilities` | Capabilities | optional |
221
+
222
+ ### `Skill`
223
+
224
+ | Field | Type | Required |
225
+ | ------------- | --------- | --------------------------- |
226
+ | `id` | str | ✓ (e.g. `fhir.lab.analyze`) |
227
+ | `name` | str | ✓ |
228
+ | `description` | str | ✓ |
229
+ | `tags` | list[str] | optional |
230
+
231
+ ### `Capabilities`
232
+
233
+ | Field | Type | Default |
234
+ | -------------------- | ---- | ------- |
235
+ | `streaming` | bool | False |
236
+ | `push_notifications` | bool | False |
237
+ | `multi_turn` | bool | False |
238
+ | `tool_use` | bool | False |
239
+
240
+ ---
241
+
242
+ ## Requirements
243
+
244
+ - Python 3.11+
245
+ - `httpx[http2]` — HTTP/2 client (required, server speaks h2c)
246
+ - `cryptography` — Ed25519 signing
@@ -0,0 +1,232 @@
1
+ # sockridge — Python SDK
2
+
3
+ Python SDK for the [Sockridge](https://sockridge.com) agent registry.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install git+https://github.com/Sockridge/sockridge.git#subdirectory=sdk/python
9
+ ```
10
+
11
+ Or for local development:
12
+
13
+ ```bash
14
+ git clone https://github.com/Sockridge/sockridge.git
15
+ pip install -e sockridge/sdk/python
16
+ ```
17
+
18
+ ## Prerequisites
19
+
20
+ You need a publisher account before using the SDK. Set one up with the CLI:
21
+
22
+ ```bash
23
+ # install CLI
24
+ go install github.com/Sockridge/sockridge/cli@latest
25
+
26
+ # register
27
+ sockridge auth keygen
28
+ sockridge auth register --handle yourhandle --server https://sockridge.com:9000
29
+ sockridge auth login --server https://sockridge.com:9000
30
+ ```
31
+
32
+ This creates `~/.sockridge/credentials.json` and `~/.sockridge/ed25519.key` which the SDK reads automatically.
33
+
34
+ ---
35
+
36
+ ## Usage
37
+
38
+ ### Connect
39
+
40
+ ```python
41
+ from sockridge import Registry
42
+
43
+ registry = Registry("https://sockridge.com:9000")
44
+ registry.login() # reads ~/.sockridge/credentials.json
45
+ ```
46
+
47
+ Custom credentials path:
48
+
49
+ ```python
50
+ registry.login(
51
+ credentials_path="/custom/path/credentials.json",
52
+ key_path="/custom/path/ed25519.key",
53
+ )
54
+ ```
55
+
56
+ ---
57
+
58
+ ### Publish an agent
59
+
60
+ ```python
61
+ from sockridge import Registry, AgentCard, Skill, Capabilities
62
+
63
+ registry = Registry("https://sockridge.com:9000")
64
+ registry.login()
65
+
66
+ card = AgentCard(
67
+ name="My FHIR Agent",
68
+ description="Analyzes lab trends from FHIR data using ML models",
69
+ url="https://my-agent.example.com",
70
+ version="1.0.0",
71
+ skills=[
72
+ Skill(
73
+ id="fhir.lab.analyze",
74
+ name="Lab Analyzer",
75
+ description="Detects anomalies in lab result trends",
76
+ tags=["fhir", "labs", "analysis"],
77
+ )
78
+ ],
79
+ capabilities=Capabilities(streaming=True, tool_use=True),
80
+ )
81
+
82
+ published = registry.publish(card)
83
+ print(f"id: {published.id}")
84
+ print(f"status: {published.status}") # PENDING → ACTIVE after gatekeeper
85
+ ```
86
+
87
+ The agent goes through automatic validation after publish:
88
+
89
+ - Fields are checked (name, description, skills required)
90
+ - URL is pinged to verify the agent is running
91
+ - AI scores the card quality (0.0 - 1.0)
92
+ - Score >= 0.4 → `AGENT_STATUS_ACTIVE`
93
+
94
+ ---
95
+
96
+ ### Self-register on startup
97
+
98
+ For agents that register themselves when they start:
99
+
100
+ ```python
101
+ registry = Registry("https://sockridge.com:9000")
102
+ published = registry.register_and_publish(card)
103
+ print(f"registered: {published.id}")
104
+ ```
105
+
106
+ ---
107
+
108
+ ### Search for agents
109
+
110
+ ```python
111
+ # by tag
112
+ agents = registry.search(tags=["fhir", "labs"])
113
+ for agent in agents:
114
+ print(f"{agent.name} — {agent.id}")
115
+
116
+ # by natural language
117
+ results = registry.semantic_search("find me a lab result analyzer")
118
+ for r in results:
119
+ print(f"{r['score']:.2f} {r['agent'].name}")
120
+
121
+ # by ID
122
+ agent = registry.get_agent("agent-uuid-here")
123
+ print(agent.name, agent.skills)
124
+ ```
125
+
126
+ ---
127
+
128
+ ### Access agreements
129
+
130
+ Agents can only get each other's endpoint URLs after a mutual access agreement is approved by both publishers.
131
+
132
+ ```python
133
+ # request access from another publisher
134
+ agreement = registry.request_access(
135
+ receiver_id="other-publisher-uuid",
136
+ message="want to connect our agents for a healthcare pipeline",
137
+ )
138
+ print(f"agreement id: {agreement['id']}")
139
+ print(f"status: {agreement['status']}") # AGREEMENT_STATUS_PENDING
140
+
141
+ # once they approve, get the shared key
142
+ # (they share it with you out of band, or you retrieve via get_agreement)
143
+
144
+ # resolve an agent's endpoint URL
145
+ result = registry.resolve_endpoint(
146
+ agent_id="agent-uuid",
147
+ shared_key="sk_abc123...",
148
+ )
149
+ print(f"url: {result['url']}")
150
+ print(f"transport: {result['transport']}")
151
+ print(f"skills: {[s.name for s in result['agent'].skills]}")
152
+ ```
153
+
154
+ ---
155
+
156
+ ### Full agreement flow
157
+
158
+ ```python
159
+ # publisher A requests access to publisher B
160
+ agreement = registry_a.request_access(
161
+ receiver_id=publisher_b_id,
162
+ message="building a medical pipeline",
163
+ )
164
+
165
+ # publisher B approves (on their side)
166
+ shared_key = registry_b.approve_access(agreement["id"])
167
+ # shared_key = "sk_abc123..."
168
+
169
+ # both sides can now resolve each other's agents
170
+ endpoint_a = registry_b.resolve_endpoint(agent_a_id, shared_key)
171
+ endpoint_b = registry_a.resolve_endpoint(agent_b_id, shared_key)
172
+
173
+ # either side can revoke
174
+ registry_a.revoke_access(agreement["id"])
175
+ # key is instantly invalid
176
+ ```
177
+
178
+ ---
179
+
180
+ ## API reference
181
+
182
+ ### `Registry(server_url)`
183
+
184
+ | Method | Description |
185
+ | -------------------------------------------- | ---------------------------------------------------------- |
186
+ | `login(credentials_path?, key_path?)` | Authenticate with Ed25519 challenge-response |
187
+ | `publish(card)` | Publish an AgentCard, returns card with server-assigned id |
188
+ | `register_and_publish(card, ...)` | Login + publish in one call |
189
+ | `search(tags?, limit?)` | List agents by tag. URL not included |
190
+ | `semantic_search(query, top_k?, min_score?)` | Natural language search |
191
+ | `get_agent(agent_id)` | Get a single agent by ID |
192
+ | `request_access(receiver_id, message?)` | Send mutual access request |
193
+ | `approve_access(agreement_id)` | Approve a pending request, returns shared key |
194
+ | `resolve_endpoint(agent_id, shared_key)` | Resolve agent URL using shared key |
195
+
196
+ ### `AgentCard`
197
+
198
+ | Field | Type | Required |
199
+ | ------------------ | ------------ | ------------------- |
200
+ | `name` | str | ✓ |
201
+ | `description` | str | ✓ |
202
+ | `url` | str | ✓ |
203
+ | `version` | str | defaults to `0.1.0` |
204
+ | `protocol_version` | str | defaults to `0.3.0` |
205
+ | `skills` | list[Skill] | ✓ at least one |
206
+ | `capabilities` | Capabilities | optional |
207
+
208
+ ### `Skill`
209
+
210
+ | Field | Type | Required |
211
+ | ------------- | --------- | --------------------------- |
212
+ | `id` | str | ✓ (e.g. `fhir.lab.analyze`) |
213
+ | `name` | str | ✓ |
214
+ | `description` | str | ✓ |
215
+ | `tags` | list[str] | optional |
216
+
217
+ ### `Capabilities`
218
+
219
+ | Field | Type | Default |
220
+ | -------------------- | ---- | ------- |
221
+ | `streaming` | bool | False |
222
+ | `push_notifications` | bool | False |
223
+ | `multi_turn` | bool | False |
224
+ | `tool_use` | bool | False |
225
+
226
+ ---
227
+
228
+ ## Requirements
229
+
230
+ - Python 3.11+
231
+ - `httpx[http2]` — HTTP/2 client (required, server speaks h2c)
232
+ - `cryptography` — Ed25519 signing
@@ -0,0 +1,90 @@
1
+ import base64
2
+ import json
3
+ import os
4
+ from pathlib import Path
5
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import (
6
+ Ed25519PrivateKey,
7
+ Ed25519PublicKey,
8
+ )
9
+ from cryptography.hazmat.primitives.serialization import (
10
+ Encoding,
11
+ PublicFormat,
12
+ PrivateFormat,
13
+ NoEncryption,
14
+ )
15
+
16
+
17
+ class KeyPair:
18
+ def __init__(self, private_key: Ed25519PrivateKey):
19
+ self._private_key = private_key
20
+ self._public_key = private_key.public_key()
21
+
22
+ @classmethod
23
+ def generate(cls) -> "KeyPair":
24
+ """Generate a new Ed25519 keypair."""
25
+ return cls(Ed25519PrivateKey.generate())
26
+
27
+ @classmethod
28
+ def load(cls, path: str) -> "KeyPair":
29
+ """Load a private key from a base64-encoded file."""
30
+ raw = Path(path).read_text().strip()
31
+ priv_bytes = base64.b64decode(raw)
32
+ private_key = Ed25519PrivateKey.from_private_bytes(priv_bytes[:32])
33
+ return cls(private_key)
34
+
35
+ def save(self, path: str) -> None:
36
+ """Save the private key to a file (chmod 600)."""
37
+ priv_bytes = self._private_key.private_bytes(
38
+ Encoding.Raw, PrivateFormat.Raw, NoEncryption()
39
+ )
40
+ Path(path).write_text(base64.b64encode(priv_bytes).decode())
41
+ os.chmod(path, 0o600)
42
+
43
+ @property
44
+ def public_key_b64(self) -> str:
45
+ """Base64-encoded public key for registration."""
46
+ pub_bytes = self._public_key.public_bytes(Encoding.Raw, PublicFormat.Raw)
47
+ return base64.b64encode(pub_bytes).decode()
48
+
49
+ def sign(self, message: bytes) -> bytes:
50
+ """Sign a message with the private key."""
51
+ return self._private_key.sign(message)
52
+
53
+
54
+ class Credentials:
55
+ DEFAULT_PATH = Path.home() / ".sockridge" / "credentials.json"
56
+ DEFAULT_KEY = Path.home() / ".sockridge" / "ed25519.key"
57
+
58
+ def __init__(
59
+ self,
60
+ publisher_id: str,
61
+ handle: str,
62
+ server_url: str,
63
+ session_token: str = "",
64
+ ):
65
+ self.publisher_id = publisher_id
66
+ self.handle = handle
67
+ self.server_url = server_url
68
+ self.session_token = session_token
69
+
70
+ @classmethod
71
+ def load(cls, path: str | None = None) -> "Credentials":
72
+ p = Path(path) if path else cls.DEFAULT_PATH
73
+ data = json.loads(p.read_text())
74
+ return cls(
75
+ publisher_id = data["publisher_id"],
76
+ handle = data["handle"],
77
+ server_url = data.get("server_url", "https://sockridge.com:9000"),
78
+ session_token = data.get("session_token", ""),
79
+ )
80
+
81
+ def save(self, path: str | None = None) -> None:
82
+ p = Path(path) if path else cls.DEFAULT_PATH
83
+ p.parent.mkdir(parents=True, exist_ok=True)
84
+ p.write_text(json.dumps({
85
+ "publisher_id": self.publisher_id,
86
+ "handle": self.handle,
87
+ "server_url": self.server_url,
88
+ "session_token": self.session_token,
89
+ }, indent=2))
90
+ os.chmod(p, 0o600)
@@ -0,0 +1,21 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "sockridge"
7
+ version = "0.1.0"
8
+ description = "Python SDK for the SockRidge agent registry"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { text = "MIT" }
12
+ dependencies = [
13
+ "httpx[http2]>=0.27.0",
14
+ "cryptography>=42.0.0",
15
+ ]
16
+
17
+ [project.optional-dependencies]
18
+ dev = ["pytest", "pytest-asyncio"]
19
+
20
+ [project.urls]
21
+ Homepage = "https://github.com/Sockridge/sockridge"
@@ -0,0 +1,6 @@
1
+ from .registry import Registry
2
+ from .models import AgentCard, Skill, Capabilities
3
+ from .auth import KeyPair
4
+
5
+ __all__ = ["Registry", "AgentCard", "Skill", "Capabilities", "KeyPair"]
6
+ __version__ = "0.1.0"
@@ -0,0 +1,90 @@
1
+ import base64
2
+ import json
3
+ import os
4
+ from pathlib import Path
5
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import (
6
+ Ed25519PrivateKey,
7
+ Ed25519PublicKey,
8
+ )
9
+ from cryptography.hazmat.primitives.serialization import (
10
+ Encoding,
11
+ PublicFormat,
12
+ PrivateFormat,
13
+ NoEncryption,
14
+ )
15
+
16
+
17
+ class KeyPair:
18
+ def __init__(self, private_key: Ed25519PrivateKey):
19
+ self._private_key = private_key
20
+ self._public_key = private_key.public_key()
21
+
22
+ @classmethod
23
+ def generate(cls) -> "KeyPair":
24
+ """Generate a new Ed25519 keypair."""
25
+ return cls(Ed25519PrivateKey.generate())
26
+
27
+ @classmethod
28
+ def load(cls, path: str) -> "KeyPair":
29
+ """Load a private key from a base64-encoded file."""
30
+ raw = Path(path).read_text().strip()
31
+ priv_bytes = base64.b64decode(raw)
32
+ private_key = Ed25519PrivateKey.from_private_bytes(priv_bytes[:32])
33
+ return cls(private_key)
34
+
35
+ def save(self, path: str) -> None:
36
+ """Save the private key to a file (chmod 600)."""
37
+ priv_bytes = self._private_key.private_bytes(
38
+ Encoding.Raw, PrivateFormat.Raw, NoEncryption()
39
+ )
40
+ Path(path).write_text(base64.b64encode(priv_bytes).decode())
41
+ os.chmod(path, 0o600)
42
+
43
+ @property
44
+ def public_key_b64(self) -> str:
45
+ """Base64-encoded public key for registration."""
46
+ pub_bytes = self._public_key.public_bytes(Encoding.Raw, PublicFormat.Raw)
47
+ return base64.b64encode(pub_bytes).decode()
48
+
49
+ def sign(self, message: bytes) -> bytes:
50
+ """Sign a message with the private key."""
51
+ return self._private_key.sign(message)
52
+
53
+
54
+ class Credentials:
55
+ DEFAULT_PATH = Path.home() / ".sockridge" / "credentials.json"
56
+ DEFAULT_KEY = Path.home() / ".sockridge" / "ed25519.key"
57
+
58
+ def __init__(
59
+ self,
60
+ publisher_id: str,
61
+ handle: str,
62
+ server_url: str,
63
+ session_token: str = "",
64
+ ):
65
+ self.publisher_id = publisher_id
66
+ self.handle = handle
67
+ self.server_url = server_url
68
+ self.session_token = session_token
69
+
70
+ @classmethod
71
+ def load(cls, path: str | None = None) -> "Credentials":
72
+ p = Path(path) if path else cls.DEFAULT_PATH
73
+ data = json.loads(p.read_text())
74
+ return cls(
75
+ publisher_id = data["publisher_id"],
76
+ handle = data["handle"],
77
+ server_url = data.get("server_url", "https://sockridge.com:9000"),
78
+ session_token = data.get("session_token", ""),
79
+ )
80
+
81
+ def save(self, path: str | None = None) -> None:
82
+ p = Path(path) if path else cls.DEFAULT_PATH
83
+ p.parent.mkdir(parents=True, exist_ok=True)
84
+ p.write_text(json.dumps({
85
+ "publisher_id": self.publisher_id,
86
+ "handle": self.handle,
87
+ "server_url": self.server_url,
88
+ "session_token": self.session_token,
89
+ }, indent=2))
90
+ os.chmod(p, 0o600)
@@ -0,0 +1,82 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import Optional
3
+ from enum import Enum
4
+
5
+
6
+ class AgentStatus(str, Enum):
7
+ PENDING = "AGENT_STATUS_PENDING"
8
+ ACTIVE = "AGENT_STATUS_ACTIVE"
9
+ REJECTED = "AGENT_STATUS_REJECTED"
10
+ INACTIVE = "AGENT_STATUS_INACTIVE"
11
+ DEPRECATED = "AGENT_STATUS_DEPRECATED"
12
+
13
+
14
+ @dataclass
15
+ class Skill:
16
+ id: str
17
+ name: str
18
+ description: str
19
+ tags: list[str] = field(default_factory=list)
20
+
21
+ def to_dict(self) -> dict:
22
+ return {
23
+ "id": self.id,
24
+ "name": self.name,
25
+ "description": self.description,
26
+ "tags": self.tags,
27
+ }
28
+
29
+
30
+ @dataclass
31
+ class Capabilities:
32
+ streaming: bool = False
33
+ push_notifications: bool = False
34
+ multi_turn: bool = False
35
+ tool_use: bool = False
36
+
37
+ def to_dict(self) -> dict:
38
+ return {
39
+ "streaming": self.streaming,
40
+ "pushNotifications": self.push_notifications,
41
+ "multiTurn": self.multi_turn,
42
+ "toolUse": self.tool_use,
43
+ }
44
+
45
+
46
+ @dataclass
47
+ class GatekeeperResult:
48
+ approved: bool
49
+ confidence_score: float
50
+ reason: str
51
+ reachable: bool
52
+ ping_latency_ms: int
53
+
54
+
55
+ @dataclass
56
+ class AgentCard:
57
+ name: str
58
+ description: str
59
+ url: str
60
+ version: str = "0.1.0"
61
+ protocol_version: str = "0.3.0"
62
+ skills: list[Skill] = field(default_factory=list)
63
+ capabilities: Optional[Capabilities] = None
64
+
65
+ # set by registry after publish — do not set manually
66
+ id: Optional[str] = None
67
+ publisher_id: Optional[str] = None
68
+ status: Optional[AgentStatus] = None
69
+ gatekeeper_result: Optional[GatekeeperResult] = None
70
+
71
+ def to_dict(self) -> dict:
72
+ d = {
73
+ "name": self.name,
74
+ "description": self.description,
75
+ "url": self.url,
76
+ "version": self.version,
77
+ "protocolVersion": self.protocol_version,
78
+ "skills": [s.to_dict() for s in self.skills],
79
+ }
80
+ if self.capabilities:
81
+ d["capabilities"] = self.capabilities.to_dict()
82
+ return d
@@ -0,0 +1,260 @@
1
+ import base64
2
+ import json
3
+ import time
4
+ import httpx
5
+ import struct
6
+ from typing import Optional
7
+
8
+ from .models import AgentCard, AgentStatus, GatekeeperResult, Skill, Capabilities
9
+ from .auth import KeyPair, Credentials
10
+
11
+
12
+ class RegistryError(Exception):
13
+ pass
14
+
15
+
16
+ class Registry:
17
+ """
18
+ SockRidge registry client.
19
+
20
+ Usage:
21
+ from sockridge import Registry, AgentCard, Skill, Capabilities
22
+
23
+ registry = Registry("https://sockridge.com:9000")
24
+ registry.login(credentials_path="~/.sockridge/credentials.json")
25
+
26
+ card = AgentCard(
27
+ name="My Agent",
28
+ description="Does something useful",
29
+ url="https://my-agent.example.com",
30
+ skills=[Skill(id="do.thing", name="Do Thing", description="Does the thing", tags=["thing"])],
31
+ capabilities=Capabilities(streaming=True),
32
+ )
33
+
34
+ published = registry.publish(card)
35
+ print(published.id)
36
+ """
37
+
38
+ def __init__(self, server_url: str = "https://sockridge.com:9000"):
39
+ self.server_url = server_url.rstrip("/")
40
+ self._token = ""
41
+ self._keypair: Optional[KeyPair] = None
42
+ self._publisher_id = ""
43
+ self._client = httpx.Client(http2=True, timeout=15.0)
44
+
45
+ # ── Auth ──────────────────────────────────────────────────────────────────
46
+
47
+ def login(
48
+ self,
49
+ credentials_path: str | None = None,
50
+ key_path: str | None = None,
51
+ ) -> None:
52
+ """Load credentials and perform challenge-response auth."""
53
+ creds = Credentials.load(credentials_path)
54
+ kp = KeyPair.load(key_path or str(Credentials.DEFAULT_KEY))
55
+
56
+ self._keypair = kp
57
+ self._publisher_id = creds.publisher_id
58
+
59
+ # challenge → sign → token
60
+ challenge = self._post("/agentregistry.v1.RegistryService/AuthChallenge", {
61
+ "publisherId": creds.publisher_id,
62
+ })
63
+ nonce = challenge["nonce"]
64
+ sig = kp.sign(nonce.encode())
65
+
66
+ verify = self._post("/agentregistry.v1.RegistryService/AuthVerify", {
67
+ "publisherId": creds.publisher_id,
68
+ "nonce": nonce,
69
+ "signature": base64.b64encode(sig).decode(),
70
+ })
71
+
72
+ self._token = verify["sessionToken"]
73
+
74
+ # ── Publish ───────────────────────────────────────────────────────────────
75
+
76
+ def publish(self, card: AgentCard) -> AgentCard:
77
+ """
78
+ Publish an agent to the registry.
79
+ Signs the payload with Ed25519 before sending.
80
+ Returns the AgentCard with server-assigned id and status.
81
+ """
82
+ if not self._keypair:
83
+ raise RegistryError("not authenticated — call registry.login() first")
84
+
85
+ # use compact JSON with sorted keys for deterministic signing
86
+ payload_json = json.dumps(card.to_dict(), separators=(",", ":"), sort_keys=True).encode()
87
+ signature = self._keypair.sign(payload_json)
88
+
89
+ resp = self._post_auth("/agentregistry.v1.RegistryService/PublishAgent", {
90
+ "payload": {
91
+ "payload": base64.b64encode(payload_json).decode(),
92
+ "signature": base64.b64encode(signature).decode(),
93
+ "keyId": self._publisher_id,
94
+ }
95
+ })
96
+
97
+ return self._parse_agent_card(resp.get("agent", {}))
98
+
99
+ # ── Discovery ─────────────────────────────────────────────────────────────
100
+
101
+ def search(self, tags: list[str] = [], limit: int = 20) -> list[AgentCard]:
102
+ """List agents by tags. URL is not included (use resolve for that)."""
103
+ resp = self._post("/agentregistry.v1.DiscoveryService/ListAgents", {
104
+ "tags": tags,
105
+ "limit": limit,
106
+ })
107
+ return [self._parse_agent_card(a) for a in resp if a]
108
+
109
+ def semantic_search(self, query: str, top_k: int = 10, min_score: float = 0.1) -> list[dict]:
110
+ """Find agents by natural language description."""
111
+ resp = self._post("/agentregistry.v1.DiscoveryService/SemanticSearch", {
112
+ "query": query,
113
+ "topK": top_k,
114
+ "minScore": min_score,
115
+ })
116
+ return [{"agent": self._parse_agent_card(r.get("agent", {})), "score": r.get("score", 0)} for r in resp]
117
+
118
+ def get_agent(self, agent_id: str) -> AgentCard:
119
+ """Get a single agent by ID."""
120
+ resp = self._post("/agentregistry.v1.DiscoveryService/GetAgent", {
121
+ "agentId": agent_id,
122
+ })
123
+ return self._parse_agent_card(resp.get("agent", {}))
124
+
125
+ # ── Access Agreements ─────────────────────────────────────────────────────
126
+
127
+ def request_access(self, receiver_id: str, message: str = "") -> dict:
128
+ """Request mutual access with another publisher."""
129
+ resp = self._post_auth("/agentregistry.v1.AccessAgreementService/RequestAccess", {
130
+ "requesterId": self._publisher_id,
131
+ "receiverId": receiver_id,
132
+ "message": message,
133
+ })
134
+ return resp.get("agreement", {})
135
+
136
+ def approve_access(self, agreement_id: str) -> str:
137
+ """Approve a pending access request. Returns the shared key."""
138
+ resp = self._post_auth("/agentregistry.v1.AccessAgreementService/ApproveAccess", {
139
+ "publisherId": self._publisher_id,
140
+ "agreementId": agreement_id,
141
+ })
142
+ return resp.get("sharedKey", "")
143
+
144
+ def resolve_endpoint(self, agent_id: str, shared_key: str) -> dict:
145
+ """
146
+ Resolve an agent's endpoint URL using a shared key.
147
+ Returns { url, transport, agent: AgentCard }
148
+ """
149
+ resp = self._post("/agentregistry.v1.AccessAgreementService/ResolveEndpoint", {
150
+ "agentId": agent_id,
151
+ "sharedKey": shared_key,
152
+ })
153
+ return {
154
+ "url": resp.get("url", ""),
155
+ "transport": resp.get("transport", "http"),
156
+ "agent": self._parse_agent_card(resp.get("agent", {})),
157
+ }
158
+
159
+ # ── Self-register helper ──────────────────────────────────────────────────
160
+
161
+ def register_and_publish(
162
+ self,
163
+ card: AgentCard,
164
+ credentials_path: str | None = None,
165
+ key_path: str | None = None,
166
+ ) -> AgentCard:
167
+ """
168
+ Convenience method: login then publish in one call.
169
+ Ideal for agent startup scripts.
170
+
171
+ Example:
172
+ registry = Registry("https://sockridge.com:9000")
173
+ published = registry.register_and_publish(my_card)
174
+ """
175
+ self.login(credentials_path, key_path)
176
+ return self.publish(card)
177
+
178
+ # ── HTTP helpers ──────────────────────────────────────────────────────────
179
+
180
+ def _post(self, path: str, body: dict) -> dict | list:
181
+ url = self.server_url + path
182
+ resp = self._client.post(
183
+ url,
184
+ json=body,
185
+ headers={"Content-Type": "application/json"},
186
+ )
187
+ self._raise_for_status(resp)
188
+ data = resp.json()
189
+ # connect-rpc returns arrays for server-streaming endpoints
190
+ if isinstance(data, list):
191
+ return data
192
+ return data
193
+
194
+ def _post_auth(self, path: str, body: dict) -> dict:
195
+ if not self._token:
196
+ raise RegistryError("not authenticated — call registry.login() first")
197
+ url = self.server_url + path
198
+ resp = self._client.post(
199
+ url,
200
+ json=body,
201
+ headers={
202
+ "Content-Type": "application/json",
203
+ "Authorization": f"Bearer {self._token}",
204
+ },
205
+ )
206
+ self._raise_for_status(resp)
207
+ return resp.json()
208
+
209
+ def _raise_for_status(self, resp: httpx.Response) -> None:
210
+ if resp.status_code >= 400:
211
+ try:
212
+ detail = resp.json().get("message", resp.text)
213
+ except Exception:
214
+ detail = resp.text
215
+ raise RegistryError(f"registry error {resp.status_code}: {detail}")
216
+
217
+ def _parse_agent_card(self, data: dict) -> AgentCard:
218
+ if not data:
219
+ return AgentCard(name="", description="", url="")
220
+
221
+ skills = [
222
+ Skill(
223
+ id=s.get("id", ""),
224
+ name=s.get("name", ""),
225
+ description=s.get("description", ""),
226
+ tags=s.get("tags", []),
227
+ )
228
+ for s in data.get("skills", [])
229
+ ]
230
+
231
+ caps_data = data.get("capabilities", {})
232
+ caps = Capabilities(
233
+ streaming=caps_data.get("streaming", False),
234
+ push_notifications=caps_data.get("pushNotifications", False),
235
+ multi_turn=caps_data.get("multiTurn", False),
236
+ tool_use=caps_data.get("toolUse", False),
237
+ ) if caps_data else None
238
+
239
+ gk_data = data.get("gatekeeperResult")
240
+ gk = GatekeeperResult(
241
+ approved=gk_data.get("approved", False),
242
+ confidence_score=gk_data.get("confidenceScore", 0),
243
+ reason=gk_data.get("reason", ""),
244
+ reachable=gk_data.get("reachable", False),
245
+ ping_latency_ms=gk_data.get("pingLatencyMs", 0),
246
+ ) if gk_data else None
247
+
248
+ return AgentCard(
249
+ id=data.get("id"),
250
+ name=data.get("name", ""),
251
+ description=data.get("description", ""),
252
+ url=data.get("url", ""),
253
+ version=data.get("version", "0.1.0"),
254
+ protocol_version=data.get("protocolVersion", "0.3.0"),
255
+ skills=skills,
256
+ capabilities=caps,
257
+ publisher_id=data.get("publisherId"),
258
+ status=AgentStatus(data["status"]) if data.get("status") else None,
259
+ gatekeeper_result=gk,
260
+ )