b2alpha 0.4.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.
- b2alpha-0.4.0/.gitignore +63 -0
- b2alpha-0.4.0/PKG-INFO +9 -0
- b2alpha-0.4.0/b2alpha/__init__.py +79 -0
- b2alpha-0.4.0/b2alpha/api.py +185 -0
- b2alpha-0.4.0/b2alpha/auth.py +216 -0
- b2alpha-0.4.0/b2alpha/bridge/__init__.py +15 -0
- b2alpha-0.4.0/b2alpha/bridge/adapter.py +72 -0
- b2alpha-0.4.0/b2alpha/bridge/adapters/__init__.py +5 -0
- b2alpha-0.4.0/b2alpha/bridge/adapters/acme_orders.py +51 -0
- b2alpha-0.4.0/b2alpha/bridge/proxy.py +42 -0
- b2alpha-0.4.0/b2alpha/bridge/registry.py +25 -0
- b2alpha-0.4.0/b2alpha/bridge/types.py +38 -0
- b2alpha-0.4.0/b2alpha/cli.py +763 -0
- b2alpha-0.4.0/b2alpha/config.py +55 -0
- b2alpha-0.4.0/b2alpha/crypto.py +80 -0
- b2alpha-0.4.0/b2alpha/realtime.py +140 -0
- b2alpha-0.4.0/pyproject.toml +21 -0
- b2alpha-0.4.0/tests/__init__.py +0 -0
- b2alpha-0.4.0/tests/test_api.py +186 -0
- b2alpha-0.4.0/tests/test_bridge.py +93 -0
- b2alpha-0.4.0/tests/test_cli.py +539 -0
- b2alpha-0.4.0/tests/test_config.py +96 -0
- b2alpha-0.4.0/tests/test_crypto.py +194 -0
b2alpha-0.4.0/.gitignore
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Go
|
|
2
|
+
node/vendor/
|
|
3
|
+
node/*.exe
|
|
4
|
+
node/*.test
|
|
5
|
+
*.sum
|
|
6
|
+
|
|
7
|
+
# Rust
|
|
8
|
+
phonebook/target/
|
|
9
|
+
phonebook/src/gen/
|
|
10
|
+
|
|
11
|
+
# Python
|
|
12
|
+
sdk/python/.venv/
|
|
13
|
+
sdk/python/.venv-build/
|
|
14
|
+
sdk/python/__pycache__/
|
|
15
|
+
sdk/python/*.egg-info/
|
|
16
|
+
sdk/python/.pytest_cache/
|
|
17
|
+
sdk/python/.mypy_cache/
|
|
18
|
+
sdk/python/.ruff_cache/
|
|
19
|
+
**/__pycache__/
|
|
20
|
+
**/*.pyc
|
|
21
|
+
|
|
22
|
+
# TypeScript / Node
|
|
23
|
+
sdk/typescript/node_modules/
|
|
24
|
+
sdk/typescript/dist/
|
|
25
|
+
clawdbot/node_modules/
|
|
26
|
+
clawdbot/.next/
|
|
27
|
+
web/node_modules/
|
|
28
|
+
web/.next/
|
|
29
|
+
web/out/
|
|
30
|
+
|
|
31
|
+
# Secrets & credentials
|
|
32
|
+
*.pem
|
|
33
|
+
*.key
|
|
34
|
+
*.p12
|
|
35
|
+
.env
|
|
36
|
+
.env.local
|
|
37
|
+
.env.*.local
|
|
38
|
+
*_credentials.json
|
|
39
|
+
secrets/
|
|
40
|
+
|
|
41
|
+
# Keep the example env file
|
|
42
|
+
!infra/.env.example
|
|
43
|
+
|
|
44
|
+
# Editor
|
|
45
|
+
.vscode/
|
|
46
|
+
.idea/
|
|
47
|
+
*.swp
|
|
48
|
+
*.swo
|
|
49
|
+
.DS_Store
|
|
50
|
+
|
|
51
|
+
# Docker
|
|
52
|
+
infra/.env
|
|
53
|
+
|
|
54
|
+
# Build artifacts
|
|
55
|
+
dist/
|
|
56
|
+
build/
|
|
57
|
+
|
|
58
|
+
# Generated protobuf bindings are committed so teammates don't need to run codegen
|
|
59
|
+
# Exclude only Rust gen (produced at cargo build time, not checked in)
|
|
60
|
+
phonebook/src/gen/
|
|
61
|
+
|
|
62
|
+
phone-agent/.env
|
|
63
|
+
phone-agent/.venv
|
b2alpha-0.4.0/PKG-INFO
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: b2alpha
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: B2Alpha – AI agent-to-agent messaging network CLI & SDK
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: click>=8.1
|
|
7
|
+
Requires-Dist: httpx>=0.27
|
|
8
|
+
Requires-Dist: pynacl>=1.5
|
|
9
|
+
Requires-Dist: websocket-client>=1.7
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""B2Alpha – AI agent-to-agent messaging network.
|
|
2
|
+
|
|
3
|
+
Connect AI agents to the B2Alpha network — register, discover,
|
|
4
|
+
message, and transact with other agents in under 30 lines of code.
|
|
5
|
+
|
|
6
|
+
Quickstart::
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
from b2alpha import Agent, AgentConfig
|
|
10
|
+
|
|
11
|
+
async def main():
|
|
12
|
+
agent = Agent(AgentConfig(
|
|
13
|
+
did="did:b2a:zMyAgentDID",
|
|
14
|
+
private_key_path="~/.b2alpha/agent.pem",
|
|
15
|
+
node_url="ws://143.198.30.138:8080/v1/connect",
|
|
16
|
+
))
|
|
17
|
+
|
|
18
|
+
async with agent:
|
|
19
|
+
# Discover a flight booking agent
|
|
20
|
+
results = await agent.phonebook.search("flight booking agent")
|
|
21
|
+
target = results[0]
|
|
22
|
+
|
|
23
|
+
# Send a message
|
|
24
|
+
response = await agent.send(
|
|
25
|
+
to=target.did,
|
|
26
|
+
intent="book.flight",
|
|
27
|
+
params={"from": "NYC", "to": "LAX", "date": "2026-03-01"},
|
|
28
|
+
)
|
|
29
|
+
print(response)
|
|
30
|
+
|
|
31
|
+
asyncio.run(main())
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from typing import TYPE_CHECKING, Any
|
|
35
|
+
|
|
36
|
+
if TYPE_CHECKING:
|
|
37
|
+
from b2alpha.agent import Agent
|
|
38
|
+
from b2alpha.config import AgentConfig
|
|
39
|
+
from b2alpha.identity import AgentIdentity
|
|
40
|
+
from b2alpha.messages import AgentMessage, Envelope
|
|
41
|
+
|
|
42
|
+
__version__ = "0.1.6"
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
"Agent",
|
|
46
|
+
"AgentConfig",
|
|
47
|
+
"AgentIdentity",
|
|
48
|
+
"generate_keypair",
|
|
49
|
+
"Envelope",
|
|
50
|
+
"AgentMessage",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def __getattr__(name: str) -> Any:
|
|
55
|
+
if name == "Agent":
|
|
56
|
+
from b2alpha.agent import Agent
|
|
57
|
+
|
|
58
|
+
return Agent
|
|
59
|
+
if name == "AgentConfig":
|
|
60
|
+
from b2alpha.config import AgentConfig
|
|
61
|
+
|
|
62
|
+
return AgentConfig
|
|
63
|
+
if name == "AgentIdentity":
|
|
64
|
+
from b2alpha.identity import AgentIdentity
|
|
65
|
+
|
|
66
|
+
return AgentIdentity
|
|
67
|
+
if name == "generate_keypair":
|
|
68
|
+
from b2alpha.identity import generate_keypair
|
|
69
|
+
|
|
70
|
+
return generate_keypair
|
|
71
|
+
if name == "Envelope":
|
|
72
|
+
from b2alpha.messages import Envelope
|
|
73
|
+
|
|
74
|
+
return Envelope
|
|
75
|
+
if name == "AgentMessage":
|
|
76
|
+
from b2alpha.messages import AgentMessage
|
|
77
|
+
|
|
78
|
+
return AgentMessage
|
|
79
|
+
raise AttributeError(f"module 'b2alpha' has no attribute '{name}'")
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""HTTP client for B2Alpha Supabase edge functions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json as _json
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from .config import load_env_config
|
|
12
|
+
|
|
13
|
+
DEBUG = False
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _debug_request(method: str, url: str, headers: dict, body: Any = None) -> None:
|
|
17
|
+
if not DEBUG:
|
|
18
|
+
return
|
|
19
|
+
print(f"\n{'='*60}", file=sys.stderr)
|
|
20
|
+
print(f"DEBUG >>> {method} {url}", file=sys.stderr)
|
|
21
|
+
for k, v in headers.items():
|
|
22
|
+
val = v[:20] + "..." if k.lower() in ("authorization", "apikey") else v
|
|
23
|
+
print(f" {k}: {val}", file=sys.stderr)
|
|
24
|
+
if body is not None:
|
|
25
|
+
print(f" Body: {_json.dumps(body, indent=2)}", file=sys.stderr)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _debug_response(resp: httpx.Response) -> None:
|
|
29
|
+
if not DEBUG:
|
|
30
|
+
return
|
|
31
|
+
print(f"DEBUG <<< {resp.status_code} {resp.reason_phrase}", file=sys.stderr)
|
|
32
|
+
print(f" URL: {resp.url}", file=sys.stderr)
|
|
33
|
+
try:
|
|
34
|
+
print(f" Body: {_json.dumps(resp.json(), indent=2)}", file=sys.stderr)
|
|
35
|
+
except Exception:
|
|
36
|
+
print(f" Body: {resp.text[:500]}", file=sys.stderr)
|
|
37
|
+
print(f"{'='*60}\n", file=sys.stderr)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _base_url() -> str:
|
|
41
|
+
url, _ = load_env_config()
|
|
42
|
+
return f"{url}/functions/v1"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _anon_headers() -> dict[str, str]:
|
|
46
|
+
_, anon_key = load_env_config()
|
|
47
|
+
return {"apikey": anon_key, "Content-Type": "application/json"}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _auth_headers(token: str) -> dict[str, str]:
|
|
51
|
+
_, anon_key = load_env_config()
|
|
52
|
+
return {
|
|
53
|
+
"apikey": anon_key,
|
|
54
|
+
"Authorization": f"Bearer {token}",
|
|
55
|
+
"Content-Type": "application/json",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def list_my_agents(token: str) -> list[dict[str, Any]]:
|
|
60
|
+
"""Fetch the authenticated user's agents via PostgREST."""
|
|
61
|
+
from .auth import get_user_id
|
|
62
|
+
|
|
63
|
+
uid = get_user_id(token)
|
|
64
|
+
url, _ = load_env_config()
|
|
65
|
+
rest_url = f"{url}/rest/v1/agents"
|
|
66
|
+
headers = _auth_headers(token)
|
|
67
|
+
params = {
|
|
68
|
+
"select": "did,display_name,agent_type,public_key,status",
|
|
69
|
+
"owner_user_id": f"eq.{uid}",
|
|
70
|
+
"status": "eq.1",
|
|
71
|
+
}
|
|
72
|
+
_debug_request("GET", rest_url, headers, params)
|
|
73
|
+
resp = httpx.get(rest_url, params=params, headers=headers, timeout=15)
|
|
74
|
+
_debug_response(resp)
|
|
75
|
+
if resp.status_code >= 400:
|
|
76
|
+
return []
|
|
77
|
+
return resp.json()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def register_agent(token: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
81
|
+
url = f"{_base_url()}/agent-register"
|
|
82
|
+
headers = _auth_headers(token)
|
|
83
|
+
_debug_request("POST", url, headers, payload)
|
|
84
|
+
resp = httpx.post(url, headers=headers, json=payload, timeout=15)
|
|
85
|
+
_debug_response(resp)
|
|
86
|
+
data = resp.json()
|
|
87
|
+
if resp.status_code >= 400:
|
|
88
|
+
raise RuntimeError(data.get("error", f"HTTP {resp.status_code}"))
|
|
89
|
+
return data
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def update_agent(token: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
93
|
+
url = f"{_base_url()}/agent-update"
|
|
94
|
+
headers = _auth_headers(token)
|
|
95
|
+
_debug_request("PATCH", url, headers, payload)
|
|
96
|
+
resp = httpx.patch(url, headers=headers, json=payload, timeout=15)
|
|
97
|
+
_debug_response(resp)
|
|
98
|
+
data = resp.json()
|
|
99
|
+
if resp.status_code >= 400:
|
|
100
|
+
raise RuntimeError(data.get("error", f"HTTP {resp.status_code}"))
|
|
101
|
+
return data
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def lookup_agent(did: str, token: str | None = None) -> dict[str, Any]:
|
|
105
|
+
url = f"{_base_url()}/agent-lookup"
|
|
106
|
+
headers = _auth_headers(token) if token else _anon_headers()
|
|
107
|
+
_debug_request("GET", url, headers, {"did": did})
|
|
108
|
+
resp = httpx.get(url, params={"did": did}, headers=headers, timeout=15)
|
|
109
|
+
_debug_response(resp)
|
|
110
|
+
data = resp.json()
|
|
111
|
+
if resp.status_code >= 400:
|
|
112
|
+
raise RuntimeError(data.get("error", f"HTTP {resp.status_code}"))
|
|
113
|
+
return data
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def search_phonebook(
|
|
117
|
+
query: str | None = None,
|
|
118
|
+
agent_type: str | None = None,
|
|
119
|
+
limit: int = 20,
|
|
120
|
+
token: str | None = None,
|
|
121
|
+
) -> dict[str, Any]:
|
|
122
|
+
params: dict[str, str] = {"limit": str(limit)}
|
|
123
|
+
if query:
|
|
124
|
+
params["q"] = query
|
|
125
|
+
if agent_type:
|
|
126
|
+
params["type"] = agent_type
|
|
127
|
+
url = f"{_base_url()}/agent-lookup"
|
|
128
|
+
headers = _auth_headers(token) if token else _anon_headers()
|
|
129
|
+
_debug_request("GET", url, headers, params)
|
|
130
|
+
resp = httpx.get(url, params=params, headers=headers, timeout=15)
|
|
131
|
+
_debug_response(resp)
|
|
132
|
+
data = resp.json()
|
|
133
|
+
if resp.status_code >= 400:
|
|
134
|
+
raise RuntimeError(data.get("error", f"HTTP {resp.status_code}"))
|
|
135
|
+
return data
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def list_conversations(
|
|
139
|
+
token: str,
|
|
140
|
+
limit: int = 50,
|
|
141
|
+
with_did: str | None = None,
|
|
142
|
+
filter: str | None = None,
|
|
143
|
+
) -> dict[str, Any]:
|
|
144
|
+
params: dict[str, str] = {"limit": str(limit)}
|
|
145
|
+
if with_did:
|
|
146
|
+
params["with"] = with_did
|
|
147
|
+
if filter:
|
|
148
|
+
params["filter"] = filter
|
|
149
|
+
url = f"{_base_url()}/conversations-list"
|
|
150
|
+
headers = _auth_headers(token)
|
|
151
|
+
_debug_request("GET", url, headers, params)
|
|
152
|
+
resp = httpx.get(url, params=params, headers=headers, timeout=15)
|
|
153
|
+
_debug_response(resp)
|
|
154
|
+
data = resp.json()
|
|
155
|
+
if resp.status_code >= 400:
|
|
156
|
+
raise RuntimeError(data.get("error", f"HTTP {resp.status_code}"))
|
|
157
|
+
return data
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def send_message(
|
|
161
|
+
token: str,
|
|
162
|
+
sender_did: str,
|
|
163
|
+
recipient_did: str,
|
|
164
|
+
message: dict[str, Any],
|
|
165
|
+
interaction_id: str | None = None,
|
|
166
|
+
on_behalf_of: str | None = None,
|
|
167
|
+
) -> dict[str, Any]:
|
|
168
|
+
payload: dict[str, Any] = {
|
|
169
|
+
"sender_did": sender_did,
|
|
170
|
+
"recipient_did": recipient_did,
|
|
171
|
+
"message": message,
|
|
172
|
+
}
|
|
173
|
+
if interaction_id:
|
|
174
|
+
payload["interaction_id"] = interaction_id
|
|
175
|
+
if on_behalf_of:
|
|
176
|
+
payload["on_behalf_of"] = on_behalf_of
|
|
177
|
+
url = f"{_base_url()}/interactions-upsert"
|
|
178
|
+
headers = _auth_headers(token)
|
|
179
|
+
_debug_request("POST", url, headers, payload)
|
|
180
|
+
resp = httpx.post(url, headers=headers, json=payload, timeout=15)
|
|
181
|
+
_debug_response(resp)
|
|
182
|
+
data = resp.json()
|
|
183
|
+
if resp.status_code >= 400:
|
|
184
|
+
raise RuntimeError(data.get("error", f"HTTP {resp.status_code}"))
|
|
185
|
+
return data
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Google OAuth authentication via Supabase."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import http.server
|
|
6
|
+
import json
|
|
7
|
+
import threading
|
|
8
|
+
import urllib.parse
|
|
9
|
+
import webbrowser
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .config import AUTH_FILE, load_env_config, save_json
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _find_free_port() -> int:
|
|
16
|
+
import socket
|
|
17
|
+
|
|
18
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
19
|
+
s.bind(("127.0.0.1", 0))
|
|
20
|
+
return s.getsockname()[1]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# HTML returned to the browser after OAuth completes
|
|
24
|
+
_SUCCESS_HTML = """<!DOCTYPE html>
|
|
25
|
+
<html><head><title>B2Alpha</title>
|
|
26
|
+
<style>body{font-family:system-ui;display:flex;justify-content:center;align-items:center;
|
|
27
|
+
height:100vh;margin:0;background:#0a0a0a;color:#fff}
|
|
28
|
+
.card{text-align:center;padding:2rem;border:1px solid #333;border-radius:12px;background:#111}
|
|
29
|
+
h1{color:#22c55e}p{color:#aaa}</style></head>
|
|
30
|
+
<body><div class="card"><h1>Logged in</h1>
|
|
31
|
+
<p>You can close this tab and return to the terminal.</p></div></body></html>"""
|
|
32
|
+
|
|
33
|
+
_ERROR_HTML = """<!DOCTYPE html>
|
|
34
|
+
<html><head><title>B2Alpha</title>
|
|
35
|
+
<style>body{font-family:system-ui;display:flex;justify-content:center;align-items:center;
|
|
36
|
+
height:100vh;margin:0;background:#0a0a0a;color:#fff}
|
|
37
|
+
.card{text-align:center;padding:2rem;border:1px solid #333;border-radius:12px;background:#111}
|
|
38
|
+
h1{color:#ef4444}p{color:#aaa}</style></head>
|
|
39
|
+
<body><div class="card"><h1>Login failed</h1>
|
|
40
|
+
<p>%s</p><p>Please try again.</p></div></body></html>"""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def login_google() -> dict[str, Any]:
|
|
44
|
+
"""Run the full Google OAuth flow via Supabase and return the session dict."""
|
|
45
|
+
supabase_url, _anon_key = load_env_config()
|
|
46
|
+
port = 8914
|
|
47
|
+
redirect_uri = f"http://127.0.0.1:{port}/callback"
|
|
48
|
+
|
|
49
|
+
result: dict[str, Any] = {}
|
|
50
|
+
error_msg: str = ""
|
|
51
|
+
event = threading.Event()
|
|
52
|
+
|
|
53
|
+
class CallbackHandler(http.server.BaseHTTPRequestHandler):
|
|
54
|
+
def do_GET(self) -> None:
|
|
55
|
+
nonlocal result, error_msg
|
|
56
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
57
|
+
|
|
58
|
+
if parsed.path == "/callback":
|
|
59
|
+
# Supabase redirects with fragment (#access_token=...) which
|
|
60
|
+
# the browser keeps client-side. Serve a page that extracts it.
|
|
61
|
+
self.send_response(200)
|
|
62
|
+
self.send_header("Content-Type", "text/html")
|
|
63
|
+
self.end_headers()
|
|
64
|
+
self.wfile.write(b"""<!DOCTYPE html><html><body>
|
|
65
|
+
<script>
|
|
66
|
+
const hash = window.location.hash.substring(1);
|
|
67
|
+
if (hash) {
|
|
68
|
+
fetch('/token?' + hash).then(() => {
|
|
69
|
+
document.body.innerHTML = '""" + _SUCCESS_HTML.encode().replace(b"'", b"\\'").replace(b"\n", b"") + b"""';
|
|
70
|
+
});
|
|
71
|
+
} else {
|
|
72
|
+
const params = new URLSearchParams(window.location.search);
|
|
73
|
+
const err = params.get('error_description') || params.get('error') || 'unknown';
|
|
74
|
+
document.body.innerHTML = '<p>Error: ' + err + '</p>';
|
|
75
|
+
fetch('/token?error=' + encodeURIComponent(err));
|
|
76
|
+
}
|
|
77
|
+
</script><p>Processing...</p></body></html>""")
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
if parsed.path == "/token":
|
|
81
|
+
params = urllib.parse.parse_qs(parsed.query)
|
|
82
|
+
if "error" in params:
|
|
83
|
+
error_msg = params["error"][0]
|
|
84
|
+
self.send_response(200)
|
|
85
|
+
self.send_header("Content-Type", "text/html")
|
|
86
|
+
self.end_headers()
|
|
87
|
+
self.wfile.write((_ERROR_HTML % error_msg).encode())
|
|
88
|
+
elif "access_token" in params:
|
|
89
|
+
result = {
|
|
90
|
+
"access_token": params["access_token"][0],
|
|
91
|
+
"refresh_token": params.get("refresh_token", [""])[0],
|
|
92
|
+
"expires_in": params.get("expires_in", ["3600"])[0],
|
|
93
|
+
"token_type": params.get("token_type", ["bearer"])[0],
|
|
94
|
+
}
|
|
95
|
+
self.send_response(200)
|
|
96
|
+
self.send_header("Content-Type", "text/plain")
|
|
97
|
+
self.end_headers()
|
|
98
|
+
self.wfile.write(b"ok")
|
|
99
|
+
else:
|
|
100
|
+
error_msg = "no_token_received"
|
|
101
|
+
self.send_response(400)
|
|
102
|
+
self.end_headers()
|
|
103
|
+
self.wfile.write(b"no token")
|
|
104
|
+
event.set()
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
self.send_response(404)
|
|
108
|
+
self.end_headers()
|
|
109
|
+
|
|
110
|
+
def log_message(self, format: str, *args: Any) -> None:
|
|
111
|
+
pass # Silence HTTP logs
|
|
112
|
+
|
|
113
|
+
server = http.server.HTTPServer(("127.0.0.1", port), CallbackHandler)
|
|
114
|
+
thread = threading.Thread(target=server.handle_request) # handle 2 requests (callback + token)
|
|
115
|
+
thread.daemon = True
|
|
116
|
+
thread.start()
|
|
117
|
+
|
|
118
|
+
# Build Supabase OAuth URL
|
|
119
|
+
oauth_url = (
|
|
120
|
+
f"{supabase_url}/auth/v1/authorize"
|
|
121
|
+
f"?provider=google"
|
|
122
|
+
f"&redirect_to={urllib.parse.quote(redirect_uri)}"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
print(f"Opening browser for Google sign-in...")
|
|
126
|
+
print(f"If the browser doesn't open, visit:\n {oauth_url}\n")
|
|
127
|
+
webbrowser.open(oauth_url)
|
|
128
|
+
|
|
129
|
+
# Wait for the callback, then handle the second /token request
|
|
130
|
+
server.handle_request()
|
|
131
|
+
event.wait(timeout=120)
|
|
132
|
+
server.server_close()
|
|
133
|
+
|
|
134
|
+
if error_msg:
|
|
135
|
+
raise RuntimeError(f"OAuth failed: {error_msg}")
|
|
136
|
+
if not result:
|
|
137
|
+
raise RuntimeError("OAuth timed out – no token received.")
|
|
138
|
+
|
|
139
|
+
save_json(AUTH_FILE, result)
|
|
140
|
+
return result
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def refresh_session() -> dict[str, Any] | None:
|
|
144
|
+
"""Attempt to refresh the access token using the stored refresh token."""
|
|
145
|
+
import httpx
|
|
146
|
+
|
|
147
|
+
from .config import load_json
|
|
148
|
+
|
|
149
|
+
auth = load_json(AUTH_FILE)
|
|
150
|
+
refresh_token = auth.get("refresh_token")
|
|
151
|
+
if not refresh_token:
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
supabase_url, anon_key = load_env_config()
|
|
155
|
+
resp = httpx.post(
|
|
156
|
+
f"{supabase_url}/auth/v1/token?grant_type=refresh_token",
|
|
157
|
+
headers={"apikey": anon_key, "Content-Type": "application/json"},
|
|
158
|
+
json={"refresh_token": refresh_token},
|
|
159
|
+
)
|
|
160
|
+
if resp.status_code != 200:
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
data = resp.json()
|
|
164
|
+
session = {
|
|
165
|
+
"access_token": data["access_token"],
|
|
166
|
+
"refresh_token": data.get("refresh_token", refresh_token),
|
|
167
|
+
"expires_in": data.get("expires_in", 3600),
|
|
168
|
+
"token_type": "bearer",
|
|
169
|
+
}
|
|
170
|
+
save_json(AUTH_FILE, session)
|
|
171
|
+
return session
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _decode_jwt_payload(token: str) -> dict:
|
|
175
|
+
"""Decode the payload of a JWT without verification."""
|
|
176
|
+
import base64
|
|
177
|
+
|
|
178
|
+
payload_b64 = token.split(".")[1]
|
|
179
|
+
padding = 4 - len(payload_b64) % 4
|
|
180
|
+
if padding != 4:
|
|
181
|
+
payload_b64 += "=" * padding
|
|
182
|
+
return json.loads(base64.urlsafe_b64decode(payload_b64))
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def get_user_id(token: str) -> str:
|
|
186
|
+
"""Extract the Supabase user ID (sub claim) from an access token."""
|
|
187
|
+
payload = _decode_jwt_payload(token)
|
|
188
|
+
uid = payload.get("sub")
|
|
189
|
+
if not uid:
|
|
190
|
+
raise RuntimeError("Token has no 'sub' claim.")
|
|
191
|
+
return uid
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _is_token_expired(token: str) -> bool:
|
|
195
|
+
"""Check if a JWT access token is expired (with 30s margin)."""
|
|
196
|
+
import time
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
payload = _decode_jwt_payload(token)
|
|
200
|
+
exp = payload.get("exp", 0)
|
|
201
|
+
return time.time() > (exp - 30)
|
|
202
|
+
except Exception:
|
|
203
|
+
return True # If we can't decode, treat as expired
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def get_valid_token() -> str:
|
|
207
|
+
"""Return a valid access token, refreshing if needed."""
|
|
208
|
+
from .config import get_access_token
|
|
209
|
+
|
|
210
|
+
token = get_access_token()
|
|
211
|
+
if token and not _is_token_expired(token):
|
|
212
|
+
return token
|
|
213
|
+
session = refresh_session()
|
|
214
|
+
if session:
|
|
215
|
+
return session["access_token"]
|
|
216
|
+
raise RuntimeError("Not logged in. Run 'b2a login' first.")
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Intent-to-REST bridge scaffolding for company agent proxies."""
|
|
2
|
+
|
|
3
|
+
from .adapter import RESTIntentAdapter
|
|
4
|
+
from .proxy import CompanyAgentProxy
|
|
5
|
+
from .registry import AdapterRegistry
|
|
6
|
+
from .types import AdapterCall, AdapterResult, B2AIntent
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"AdapterCall",
|
|
10
|
+
"AdapterRegistry",
|
|
11
|
+
"AdapterResult",
|
|
12
|
+
"B2AIntent",
|
|
13
|
+
"CompanyAgentProxy",
|
|
14
|
+
"RESTIntentAdapter",
|
|
15
|
+
]
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Adapter interface + REST execution base."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import Any, Iterable
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from .types import AdapterCall, AdapterResult, B2AIntent
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RESTIntentAdapter(ABC):
|
|
14
|
+
"""Translates B2A intents to REST calls and normalizes responses."""
|
|
15
|
+
|
|
16
|
+
name: str = "adapter"
|
|
17
|
+
intent_patterns: tuple[str, ...] = ()
|
|
18
|
+
|
|
19
|
+
def can_handle(self, intent: str) -> bool:
|
|
20
|
+
for pattern in self.intent_patterns:
|
|
21
|
+
if pattern.endswith(".*") and intent.startswith(pattern[:-1]):
|
|
22
|
+
return True
|
|
23
|
+
if intent == pattern:
|
|
24
|
+
return True
|
|
25
|
+
return False
|
|
26
|
+
|
|
27
|
+
@abstractmethod
|
|
28
|
+
def build_call(self, intent: B2AIntent) -> AdapterCall:
|
|
29
|
+
"""Convert a B2A intent into an HTTP request definition."""
|
|
30
|
+
|
|
31
|
+
def normalize_response(self, response: httpx.Response) -> Any:
|
|
32
|
+
"""Return JSON when available, otherwise raw text."""
|
|
33
|
+
try:
|
|
34
|
+
return response.json()
|
|
35
|
+
except Exception:
|
|
36
|
+
return {"text": response.text}
|
|
37
|
+
|
|
38
|
+
def execute(
|
|
39
|
+
self,
|
|
40
|
+
intent: B2AIntent,
|
|
41
|
+
*,
|
|
42
|
+
base_url: str,
|
|
43
|
+
shared_headers: dict[str, str] | None = None,
|
|
44
|
+
) -> AdapterResult:
|
|
45
|
+
call = self.build_call(intent)
|
|
46
|
+
headers: dict[str, str] = dict(shared_headers or {})
|
|
47
|
+
headers.update(call.headers)
|
|
48
|
+
url = f"{base_url.rstrip('/')}/{call.path.lstrip('/')}"
|
|
49
|
+
|
|
50
|
+
response = httpx.request(
|
|
51
|
+
call.method,
|
|
52
|
+
url,
|
|
53
|
+
params=call.query,
|
|
54
|
+
headers=headers,
|
|
55
|
+
json=call.json_body,
|
|
56
|
+
timeout=call.timeout_s,
|
|
57
|
+
)
|
|
58
|
+
data = self.normalize_response(response)
|
|
59
|
+
return AdapterResult(
|
|
60
|
+
status_code=response.status_code,
|
|
61
|
+
ok=response.status_code < 400,
|
|
62
|
+
data=data,
|
|
63
|
+
adapter=self.name,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def list_supported_intents(adapters: Iterable[RESTIntentAdapter]) -> list[str]:
|
|
68
|
+
patterns: list[str] = []
|
|
69
|
+
for adapter in adapters:
|
|
70
|
+
patterns.extend(adapter.intent_patterns)
|
|
71
|
+
return sorted(set(patterns))
|
|
72
|
+
|