talon-server 0.1.3__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.
- talon_server-0.1.3/.gitignore +25 -0
- talon_server-0.1.3/PKG-INFO +28 -0
- talon_server-0.1.3/README.md +18 -0
- talon_server-0.1.3/pyproject.toml +17 -0
- talon_server-0.1.3/src/talon_server/__init__.py +3 -0
- talon_server-0.1.3/src/talon_server/server.py +290 -0
- talon_server-0.1.3/tests/test_server.py +43 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
.env
|
|
2
|
+
.tools/
|
|
3
|
+
.pytest_cache/
|
|
4
|
+
__pycache__/
|
|
5
|
+
*.py[cod]
|
|
6
|
+
bench/results*/
|
|
7
|
+
node_modules/
|
|
8
|
+
packages/*/dist/
|
|
9
|
+
sdk/js/*/dist/
|
|
10
|
+
sdk/examples/js/dist/
|
|
11
|
+
sdk/java/.gradle/
|
|
12
|
+
sdk/java/build/
|
|
13
|
+
sdk/java/*/build/
|
|
14
|
+
sdk/examples/java/.gradle/
|
|
15
|
+
sdk/examples/java/build/
|
|
16
|
+
sdk/examples/python/.venv/
|
|
17
|
+
talon_gateway_proto-descriptor-set.proto.bin
|
|
18
|
+
target/
|
|
19
|
+
tests/__pycache__/
|
|
20
|
+
ui/.next/
|
|
21
|
+
ui/coverage/
|
|
22
|
+
ui/node_modules/
|
|
23
|
+
ui/playwright-report/
|
|
24
|
+
ui/test-results/
|
|
25
|
+
ui/tsconfig.tsbuildinfo
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: talon-server
|
|
3
|
+
Version: 0.1.3
|
|
4
|
+
Summary: Local Talon node helper for tests and development
|
|
5
|
+
License-Expression: AGPL-3.0-only
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Provides-Extra: test
|
|
8
|
+
Requires-Dist: pytest<9,>=8; extra == 'test'
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# talon-server
|
|
12
|
+
|
|
13
|
+
Starts `talon-node` as a local subprocess with a temporary SQLite database and
|
|
14
|
+
`local_socket` broker for tests and development.
|
|
15
|
+
|
|
16
|
+
Set `TALON_NODE_PATH` to a local `talon-node` binary, or let the helper download
|
|
17
|
+
one from the Talon GitHub releases.
|
|
18
|
+
|
|
19
|
+
Pass `jwt_secret` to start the gateway in JWT-auth mode, then mint scoped
|
|
20
|
+
browser or test tokens with `mint_jwt`:
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
from talon_server import JwtOptions, Options, mint_jwt, start
|
|
24
|
+
|
|
25
|
+
secret = "dev-secret"
|
|
26
|
+
server = start(Options(jwt_secret=secret))
|
|
27
|
+
token = mint_jwt(secret, JwtOptions(subject="browser-demo", namespace="demo", agent="copilot"))
|
|
28
|
+
```
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# talon-server
|
|
2
|
+
|
|
3
|
+
Starts `talon-node` as a local subprocess with a temporary SQLite database and
|
|
4
|
+
`local_socket` broker for tests and development.
|
|
5
|
+
|
|
6
|
+
Set `TALON_NODE_PATH` to a local `talon-node` binary, or let the helper download
|
|
7
|
+
one from the Talon GitHub releases.
|
|
8
|
+
|
|
9
|
+
Pass `jwt_secret` to start the gateway in JWT-auth mode, then mint scoped
|
|
10
|
+
browser or test tokens with `mint_jwt`:
|
|
11
|
+
|
|
12
|
+
```python
|
|
13
|
+
from talon_server import JwtOptions, Options, mint_jwt, start
|
|
14
|
+
|
|
15
|
+
secret = "dev-secret"
|
|
16
|
+
server = start(Options(jwt_secret=secret))
|
|
17
|
+
token = mint_jwt(secret, JwtOptions(subject="browser-demo", namespace="demo", agent="copilot"))
|
|
18
|
+
```
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "talon-server"
|
|
7
|
+
version = "0.1.3"
|
|
8
|
+
description = "Local Talon node helper for tests and development"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "AGPL-3.0-only"
|
|
12
|
+
|
|
13
|
+
[project.optional-dependencies]
|
|
14
|
+
test = ["pytest>=8,<9"]
|
|
15
|
+
|
|
16
|
+
[tool.hatch.build.targets.wheel]
|
|
17
|
+
packages = ["src/talon_server"]
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import hmac
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import signal
|
|
10
|
+
import socket
|
|
11
|
+
import subprocess
|
|
12
|
+
import tarfile
|
|
13
|
+
import tempfile
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
import urllib.request
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Mapping
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Provider:
|
|
24
|
+
name: str = "mock"
|
|
25
|
+
base_url: str = ""
|
|
26
|
+
model: str = ""
|
|
27
|
+
api_key: str = ""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class Options:
|
|
32
|
+
talon_node_path: str | Path | None = None
|
|
33
|
+
version: str = "latest"
|
|
34
|
+
grpc_port: int | None = None
|
|
35
|
+
ui_port: int | None = None
|
|
36
|
+
keep_temp_dir: bool = False
|
|
37
|
+
env: Mapping[str, str] = field(default_factory=dict)
|
|
38
|
+
startup_timeout_seconds: float = 30.0
|
|
39
|
+
provider: Provider | None = None
|
|
40
|
+
jwt_secret: str | None = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class JwtOptions:
|
|
45
|
+
subject: str = "talon-sdk"
|
|
46
|
+
ttl_seconds: int = 3600
|
|
47
|
+
namespace: str | None = None
|
|
48
|
+
agent: str | None = None
|
|
49
|
+
session: str | None = None
|
|
50
|
+
channel: str | None = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Server:
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
process: subprocess.Popen[bytes],
|
|
57
|
+
temp_dir: Path,
|
|
58
|
+
config_path: Path,
|
|
59
|
+
grpc_port: int,
|
|
60
|
+
ui_port: int,
|
|
61
|
+
keep_temp_dir: bool,
|
|
62
|
+
) -> None:
|
|
63
|
+
self._process = process
|
|
64
|
+
self.temp_dir = temp_dir
|
|
65
|
+
self.config_path = config_path
|
|
66
|
+
self._grpc_port = grpc_port
|
|
67
|
+
self._ui_port = ui_port
|
|
68
|
+
self._keep_temp_dir = keep_temp_dir
|
|
69
|
+
self._logs: list[bytes] = []
|
|
70
|
+
self._logs_lock = threading.Lock()
|
|
71
|
+
if self._process.stdout is not None:
|
|
72
|
+
thread = threading.Thread(target=self._drain_logs, args=(self._process.stdout,), daemon=True)
|
|
73
|
+
thread.start()
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def start(cls, options: Options | None = None) -> "Server":
|
|
77
|
+
options = options or Options()
|
|
78
|
+
node_path = _resolve_talon_node(options)
|
|
79
|
+
grpc_port = options.grpc_port or _free_port()
|
|
80
|
+
ui_port = options.ui_port or _free_port()
|
|
81
|
+
temp_dir = Path(tempfile.mkdtemp(prefix="talon-server-"))
|
|
82
|
+
data_dir = temp_dir / "data"
|
|
83
|
+
data_dir.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
config_path = temp_dir / "talon.yaml"
|
|
85
|
+
config_path.write_text(_config_yaml(options.provider), encoding="utf-8")
|
|
86
|
+
|
|
87
|
+
env = os.environ.copy()
|
|
88
|
+
env.update(
|
|
89
|
+
{
|
|
90
|
+
"GRPC_ADDR": f"127.0.0.1:{grpc_port}",
|
|
91
|
+
"GATEWAY_UI_ADDR": f"127.0.0.1:{ui_port}",
|
|
92
|
+
"TALON_CONFIG_PATH": str(config_path),
|
|
93
|
+
"RUST_LOG": "info",
|
|
94
|
+
}
|
|
95
|
+
)
|
|
96
|
+
if options.jwt_secret:
|
|
97
|
+
env["GATEWAY_JWT_SECRET"] = options.jwt_secret
|
|
98
|
+
env.update(options.env)
|
|
99
|
+
process = subprocess.Popen(
|
|
100
|
+
[str(node_path)],
|
|
101
|
+
stdout=subprocess.PIPE,
|
|
102
|
+
stderr=subprocess.STDOUT,
|
|
103
|
+
env=env,
|
|
104
|
+
)
|
|
105
|
+
server = cls(process, temp_dir, config_path, grpc_port, ui_port, options.keep_temp_dir)
|
|
106
|
+
try:
|
|
107
|
+
_wait_for_port(grpc_port, options.startup_timeout_seconds)
|
|
108
|
+
except Exception:
|
|
109
|
+
logs = server.logs()
|
|
110
|
+
server.stop()
|
|
111
|
+
raise RuntimeError(f"talon-node did not become ready\n{logs}")
|
|
112
|
+
return server
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def grpc_endpoint(self) -> str:
|
|
116
|
+
return f"127.0.0.1:{self._grpc_port}"
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def ui_endpoint(self) -> str:
|
|
120
|
+
return f"http://127.0.0.1:{self._ui_port}"
|
|
121
|
+
|
|
122
|
+
def logs(self) -> str:
|
|
123
|
+
with self._logs_lock:
|
|
124
|
+
return b"".join(self._logs).decode("utf-8", errors="replace")
|
|
125
|
+
|
|
126
|
+
def stop(self) -> None:
|
|
127
|
+
if self._process.poll() is None:
|
|
128
|
+
self._process.send_signal(signal.SIGINT)
|
|
129
|
+
try:
|
|
130
|
+
self._process.wait(timeout=2)
|
|
131
|
+
except subprocess.TimeoutExpired:
|
|
132
|
+
self._process.kill()
|
|
133
|
+
self._process.wait(timeout=2)
|
|
134
|
+
if not self._keep_temp_dir:
|
|
135
|
+
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
|
136
|
+
|
|
137
|
+
def __enter__(self) -> "Server":
|
|
138
|
+
return self
|
|
139
|
+
|
|
140
|
+
def __exit__(self, exc_type: object, exc: object, traceback: object) -> None:
|
|
141
|
+
self.stop()
|
|
142
|
+
|
|
143
|
+
def _drain_logs(self, stream: object) -> None:
|
|
144
|
+
while True:
|
|
145
|
+
chunk = stream.readline() # type: ignore[attr-defined]
|
|
146
|
+
if not chunk:
|
|
147
|
+
return
|
|
148
|
+
with self._logs_lock:
|
|
149
|
+
self._logs.append(chunk)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def start(options: Options | None = None) -> Server:
|
|
153
|
+
return Server.start(options)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def mint_jwt(secret: str, options: JwtOptions | None = None) -> str:
|
|
157
|
+
if not secret:
|
|
158
|
+
raise ValueError("secret is required")
|
|
159
|
+
options = options or JwtOptions()
|
|
160
|
+
if not options.subject.strip():
|
|
161
|
+
raise ValueError("subject is required")
|
|
162
|
+
if options.ttl_seconds <= 0:
|
|
163
|
+
raise ValueError("ttl_seconds must be positive")
|
|
164
|
+
if options.channel is not None and options.namespace is None:
|
|
165
|
+
raise ValueError("channel-scoped JWTs require namespace")
|
|
166
|
+
|
|
167
|
+
claims: dict[str, str | int] = {
|
|
168
|
+
"sub": options.subject,
|
|
169
|
+
"aud": "talon",
|
|
170
|
+
"exp": int(time.time()) + int(options.ttl_seconds),
|
|
171
|
+
}
|
|
172
|
+
_add_jwt_claim(claims, "talon:ns", options.namespace)
|
|
173
|
+
_add_jwt_claim(claims, "talon:agent", options.agent)
|
|
174
|
+
_add_jwt_claim(claims, "talon:session", options.session)
|
|
175
|
+
_add_jwt_claim(claims, "talon:channel", options.channel)
|
|
176
|
+
|
|
177
|
+
header = _jwt_segment({"alg": "HS256", "typ": "JWT"})
|
|
178
|
+
payload = _jwt_segment(claims)
|
|
179
|
+
message = f"{header}.{payload}"
|
|
180
|
+
signature = _base64url(hmac.new(secret.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).digest())
|
|
181
|
+
return f"{message}.{signature}"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def authorization_header(token: str) -> str:
|
|
185
|
+
if not token.strip():
|
|
186
|
+
raise ValueError("token is required")
|
|
187
|
+
return f"Bearer {token}"
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _add_jwt_claim(claims: dict[str, str | int], key: str, value: str | None) -> None:
|
|
191
|
+
if value is None:
|
|
192
|
+
return
|
|
193
|
+
if not value.strip():
|
|
194
|
+
raise ValueError(f"{key} must not be empty")
|
|
195
|
+
claims[key] = value
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _jwt_segment(value: object) -> str:
|
|
199
|
+
return _base64url(json.dumps(value, separators=(",", ":")).encode("utf-8"))
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _base64url(value: bytes) -> str:
|
|
203
|
+
return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _resolve_talon_node(options: Options) -> Path:
|
|
207
|
+
if options.talon_node_path:
|
|
208
|
+
return Path(options.talon_node_path)
|
|
209
|
+
if os.environ.get("TALON_NODE_PATH"):
|
|
210
|
+
return Path(os.environ["TALON_NODE_PATH"])
|
|
211
|
+
return _download_talon_node(options.version)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _download_talon_node(version: str) -> Path:
|
|
215
|
+
platform = _platform_name()
|
|
216
|
+
cache_root = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache"))
|
|
217
|
+
target_dir = cache_root / "talon" / "node" / version / platform
|
|
218
|
+
target = target_dir / "talon-node"
|
|
219
|
+
if target.exists():
|
|
220
|
+
return target
|
|
221
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
222
|
+
base = f"https://github.com/impalasys/talon/releases/{version}/download"
|
|
223
|
+
if version != "latest":
|
|
224
|
+
base = f"https://github.com/impalasys/talon/releases/download/{version}"
|
|
225
|
+
archive_url = f"{base}/talon-node-{platform}.tar.gz"
|
|
226
|
+
checksum_url = f"{archive_url}.sha256"
|
|
227
|
+
archive = urllib.request.urlopen(archive_url, timeout=60).read()
|
|
228
|
+
checksum = urllib.request.urlopen(checksum_url, timeout=60).read().decode("utf-8")
|
|
229
|
+
actual = hashlib.sha256(archive).hexdigest()
|
|
230
|
+
if actual != checksum.split()[0]:
|
|
231
|
+
raise RuntimeError("talon-node checksum mismatch")
|
|
232
|
+
archive_path = target_dir / "talon-node.tar.gz"
|
|
233
|
+
archive_path.write_bytes(archive)
|
|
234
|
+
with tarfile.open(archive_path, "r:gz") as tar:
|
|
235
|
+
for member in tar.getmembers():
|
|
236
|
+
if Path(member.name).name == "talon-node":
|
|
237
|
+
member.name = "talon-node"
|
|
238
|
+
tar.extract(member, target_dir)
|
|
239
|
+
target.chmod(0o755)
|
|
240
|
+
return target
|
|
241
|
+
raise RuntimeError("talon-node not found in release archive")
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _platform_name() -> str:
|
|
245
|
+
if os.uname().sysname == "Linux" and os.uname().machine in {"x86_64", "amd64"}:
|
|
246
|
+
return "linux-x64"
|
|
247
|
+
if os.uname().sysname == "Darwin" and os.uname().machine == "arm64":
|
|
248
|
+
return "darwin-arm64"
|
|
249
|
+
raise RuntimeError(f"unsupported talon-node platform: {os.uname().sysname}-{os.uname().machine}")
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _free_port() -> int:
|
|
253
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
254
|
+
sock.bind(("127.0.0.1", 0))
|
|
255
|
+
return int(sock.getsockname()[1])
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _wait_for_port(port: int, timeout_seconds: float) -> None:
|
|
259
|
+
deadline = time.monotonic() + timeout_seconds
|
|
260
|
+
while time.monotonic() < deadline:
|
|
261
|
+
try:
|
|
262
|
+
with socket.create_connection(("127.0.0.1", port), timeout=0.25):
|
|
263
|
+
return
|
|
264
|
+
except OSError:
|
|
265
|
+
time.sleep(0.1)
|
|
266
|
+
raise TimeoutError(f"timeout waiting for 127.0.0.1:{port}")
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _config_yaml(provider: Provider | None) -> str:
|
|
270
|
+
prefix = ""
|
|
271
|
+
if provider is not None:
|
|
272
|
+
name = provider.name or "mock"
|
|
273
|
+
prefix = (
|
|
274
|
+
"providers:\n"
|
|
275
|
+
f" {name}:\n"
|
|
276
|
+
" type: openai_compatible\n"
|
|
277
|
+
f" base_url: {provider.base_url!r}\n"
|
|
278
|
+
f" model: {provider.model!r}\n"
|
|
279
|
+
f" api_key: {provider.api_key!r}\n"
|
|
280
|
+
f"default_provider: {name!r}\n"
|
|
281
|
+
)
|
|
282
|
+
return (
|
|
283
|
+
prefix
|
|
284
|
+
+ "control_plane:\n"
|
|
285
|
+
+ " database:\n"
|
|
286
|
+
+ " driver: sqlite\n"
|
|
287
|
+
+ " data_dir: ./data\n"
|
|
288
|
+
+ " message_broker:\n"
|
|
289
|
+
+ " driver: local_socket\n"
|
|
290
|
+
)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
from talon_server import JwtOptions, authorization_header, mint_jwt
|
|
5
|
+
from talon_server.server import _config_yaml
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_config_uses_sqlite_and_local_socket() -> None:
|
|
9
|
+
config = _config_yaml(None)
|
|
10
|
+
assert "driver: sqlite" in config
|
|
11
|
+
assert "driver: local_socket" in config
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_mint_jwt_creates_scoped_talon_token() -> None:
|
|
15
|
+
token = mint_jwt(
|
|
16
|
+
"secret",
|
|
17
|
+
JwtOptions(subject="browser-demo", ttl_seconds=60, namespace="demo", agent="copilot", channel="chat"),
|
|
18
|
+
)
|
|
19
|
+
header_segment, payload_segment, signature = token.split(".")
|
|
20
|
+
assert signature
|
|
21
|
+
header = _decode(header_segment)
|
|
22
|
+
payload = _decode(payload_segment)
|
|
23
|
+
assert header == {"alg": "HS256", "typ": "JWT"}
|
|
24
|
+
assert payload["sub"] == "browser-demo"
|
|
25
|
+
assert payload["aud"] == "talon"
|
|
26
|
+
assert payload["talon:ns"] == "demo"
|
|
27
|
+
assert payload["talon:agent"] == "copilot"
|
|
28
|
+
assert payload["talon:channel"] == "chat"
|
|
29
|
+
assert authorization_header(token) == f"Bearer {token}"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_mint_jwt_requires_namespace_for_channel_scope() -> None:
|
|
33
|
+
try:
|
|
34
|
+
mint_jwt("secret", JwtOptions(channel="chat"))
|
|
35
|
+
except ValueError as error:
|
|
36
|
+
assert "namespace" in str(error)
|
|
37
|
+
else:
|
|
38
|
+
raise AssertionError("expected ValueError")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _decode(segment: str) -> dict[str, object]:
|
|
42
|
+
padded = segment + "=" * (-len(segment) % 4)
|
|
43
|
+
return json.loads(base64.urlsafe_b64decode(padded).decode("utf-8"))
|