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.
@@ -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,3 @@
1
+ from .server import JwtOptions, Options, Provider, Server, authorization_header, mint_jwt, start
2
+
3
+ __all__ = ["JwtOptions", "Options", "Provider", "Server", "authorization_header", "mint_jwt", "start"]
@@ -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"))