Workers print their own code and are claimed from the site; fetch and verify llama.cpp
Browse files- distinct_agent/cli.py +250 -41
- distinct_agent/runtime.py +388 -0
- distinct_agent/transport.py +89 -0
- distinct_server/api.py +68 -0
- distinct_server/control_plane.py +203 -2
- distinct_server/models.py +24 -0
- distinct_server/render.py +22 -8
- distinct_server/ui.py +101 -83
- llama_runtime.json +29 -0
- scripts/record_llama_pin.py +92 -0
- tests/test_claim_flow.py +148 -0
- tests/test_llama_runtime.py +188 -0
distinct_agent/cli.py
CHANGED
|
@@ -152,16 +152,42 @@ def _runner_problem(args) -> str:
|
|
| 152 |
)
|
| 153 |
if _find_llama_server():
|
| 154 |
return ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
return (
|
| 156 |
-
"This worker has no llama.cpp to run a model with
|
|
|
|
| 157 |
"\n"
|
| 158 |
-
"
|
| 159 |
-
" from https://github.com/ggml-org/llama.cpp/releases, unzip it, and either\n"
|
| 160 |
-
" put llama-server on your PATH or point at it:\n"
|
| 161 |
"\n"
|
| 162 |
-
" --llama-server
|
| 163 |
"\n"
|
| 164 |
-
"
|
| 165 |
" tool and skill in the library; only the model's own replies are absent:\n"
|
| 166 |
"\n"
|
| 167 |
" --demo-runner\n"
|
|
@@ -169,11 +195,18 @@ def _runner_problem(args) -> str:
|
|
| 169 |
|
| 170 |
|
| 171 |
def _find_llama_server() -> str:
|
| 172 |
-
"""Look in runtime/ first, then PATH.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
|
| 174 |
-
|
| 175 |
-
if
|
| 176 |
-
return
|
| 177 |
from shutil import which
|
| 178 |
|
| 179 |
for name in ("llama-server", "llama-server.exe"):
|
|
@@ -183,6 +216,49 @@ def _find_llama_server() -> str:
|
|
| 183 |
return ""
|
| 184 |
|
| 185 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
def build_parser() -> argparse.ArgumentParser:
|
| 187 |
parser = argparse.ArgumentParser(description="Run a Distinct local inference worker")
|
| 188 |
parser.add_argument(
|
|
@@ -357,6 +433,15 @@ def build_parser() -> argparse.ArgumentParser:
|
|
| 357 |
metavar="ID=PATH",
|
| 358 |
help=argparse.SUPPRESS,
|
| 359 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 360 |
parser.add_argument(
|
| 361 |
"--llama-server",
|
| 362 |
nargs="?",
|
|
@@ -539,18 +624,34 @@ def _forget_servers(args) -> int:
|
|
| 539 |
def _server_pairs(urls, codes):
|
| 540 |
"""Match each --server to its --pair, or explain the mismatch.
|
| 541 |
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 547 |
"""
|
| 548 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 549 |
if len(codes) != len(urls):
|
| 550 |
print(
|
| 551 |
f"{len(urls)} --server value(s) and {len(codes)} --pair value(s). "
|
| 552 |
-
"
|
| 553 |
-
"
|
|
|
|
| 554 |
file=sys.stderr,
|
| 555 |
)
|
| 556 |
return None
|
|
@@ -578,6 +679,92 @@ def _servers_file(explicit: str | None) -> Path:
|
|
| 578 |
return shared_cache_directory().parent / "servers.json"
|
| 579 |
|
| 580 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 581 |
def _join_server(
|
| 582 |
url,
|
| 583 |
code,
|
|
@@ -618,24 +805,38 @@ def _join_server(
|
|
| 618 |
tools=capabilities.tools,
|
| 619 |
)
|
| 620 |
identity_key = generate_ed25519_key()
|
| 621 |
-
|
| 622 |
-
|
|
|
|
| 623 |
url,
|
| 624 |
-
code,
|
| 625 |
capabilities,
|
| 626 |
access_code=access_code,
|
| 627 |
client_factory=client_factory,
|
| 628 |
-
allow_remote=allow_remote
|
| 629 |
-
allow_plaintext=allow_plaintext
|
| 630 |
signing_public_key=ed25519_public_key(identity_key),
|
| 631 |
)
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 639 |
|
| 640 |
# -------- Consent handshake: catalogue, approval, gate ----------
|
| 641 |
#
|
|
@@ -786,9 +987,6 @@ def main(
|
|
| 786 |
# crash. Argparse-level mistakes deserve argparse-level answers.
|
| 787 |
print("--demo-prompt needs something to ask; it was empty.", file=sys.stderr)
|
| 788 |
return 2
|
| 789 |
-
if args.server and transport is None and not args.pair:
|
| 790 |
-
print("--server requires a fresh --pair code.", file=sys.stderr)
|
| 791 |
-
return 2
|
| 792 |
if args.list_servers:
|
| 793 |
return _list_servers(args)
|
| 794 |
if args.forget_server:
|
|
@@ -1290,15 +1488,17 @@ def main(
|
|
| 1290 |
# time anybody sees it: the servers keep a digest and this process
|
| 1291 |
# does not write it anywhere.
|
| 1292 |
access_code = new_access_code()
|
| 1293 |
-
#
|
| 1294 |
-
#
|
| 1295 |
-
#
|
| 1296 |
-
#
|
| 1297 |
-
|
| 1298 |
-
|
| 1299 |
-
|
| 1300 |
-
|
| 1301 |
-
|
|
|
|
|
|
|
| 1302 |
# Kept so the dashboard can show it. It is printed once and stored
|
| 1303 |
# nowhere on disk, so a screen that could not show it would leave
|
| 1304 |
# somebody who scrolled past it with no way to get it back short of
|
|
@@ -1338,6 +1538,15 @@ def main(
|
|
| 1338 |
flush=True,
|
| 1339 |
)
|
| 1340 |
return 2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1341 |
if len(links) == 1:
|
| 1342 |
# One server: the transport it paired with, unwrapped. The
|
| 1343 |
# fan-out is a real object with real behaviour and there is no
|
|
|
|
| 152 |
)
|
| 153 |
if _find_llama_server():
|
| 154 |
return ""
|
| 155 |
+
if _obtain_llama_server(args):
|
| 156 |
+
return ""
|
| 157 |
+
from . import runtime as _runtime
|
| 158 |
+
|
| 159 |
+
where = _runtime.platform_key()
|
| 160 |
+
if _runtime.pin_for(where) is None:
|
| 161 |
+
# Nothing recorded for this platform, so nothing was attempted. Say
|
| 162 |
+
# which platform, because the answer is "add a pin for it" and that is
|
| 163 |
+
# a thing somebody can do.
|
| 164 |
+
return (
|
| 165 |
+
"This worker has no llama.cpp to run a model with, and none is recorded\n"
|
| 166 |
+
f"for {where}, so nothing was downloaded.\n"
|
| 167 |
+
"\n"
|
| 168 |
+
" An unverified build of the program that runs the models is the one\n"
|
| 169 |
+
" thing this project will not install for you. Either point at a build\n"
|
| 170 |
+
" you already have:\n"
|
| 171 |
+
"\n"
|
| 172 |
+
" --llama-server /path/to/llama-server\n"
|
| 173 |
+
"\n"
|
| 174 |
+
" or start without a model. The worker still joins, and still runs every\n"
|
| 175 |
+
" tool and skill in the library; only the model's own replies are absent:\n"
|
| 176 |
+
"\n"
|
| 177 |
+
" --demo-runner\n"
|
| 178 |
+
"\n"
|
| 179 |
+
f" To record a build for {where}, run:\n"
|
| 180 |
+
f" python scripts/record_llama_pin.py {where}\n"
|
| 181 |
+
)
|
| 182 |
return (
|
| 183 |
+
"This worker has no llama.cpp to run a model with, and the pinned build\n"
|
| 184 |
+
"could not be installed. The reason is above.\n"
|
| 185 |
"\n"
|
| 186 |
+
" Point at a build you already have:\n"
|
|
|
|
|
|
|
| 187 |
"\n"
|
| 188 |
+
" --llama-server /path/to/llama-server\n"
|
| 189 |
"\n"
|
| 190 |
+
" or start without a model. The worker still joins, and still runs every\n"
|
| 191 |
" tool and skill in the library; only the model's own replies are absent:\n"
|
| 192 |
"\n"
|
| 193 |
" --demo-runner\n"
|
|
|
|
| 195 |
|
| 196 |
|
| 197 |
def _find_llama_server() -> str:
|
| 198 |
+
"""Look in runtime/ first, then PATH.
|
| 199 |
+
|
| 200 |
+
``runtime/`` is where :func:`distinct_agent.runtime.ensure` installs the
|
| 201 |
+
pinned build, so a worker that fetched one on a previous run finds it here
|
| 202 |
+
and does not fetch again.
|
| 203 |
+
"""
|
| 204 |
+
|
| 205 |
+
from . import runtime as _runtime
|
| 206 |
|
| 207 |
+
installed = _runtime.installed_server()
|
| 208 |
+
if installed:
|
| 209 |
+
return installed
|
| 210 |
from shutil import which
|
| 211 |
|
| 212 |
for name in ("llama-server", "llama-server.exe"):
|
|
|
|
| 216 |
return ""
|
| 217 |
|
| 218 |
|
| 219 |
+
def _obtain_llama_server(args) -> str:
|
| 220 |
+
"""Fetch and verify the pinned llama.cpp for this machine, or say why not.
|
| 221 |
+
|
| 222 |
+
Returns the path, or an empty string when nothing could be installed. The
|
| 223 |
+
reasons are printed as they happen rather than collected, because the
|
| 224 |
+
download is the slow part and a volunteer watching a progress line wants to
|
| 225 |
+
know what it is for.
|
| 226 |
+
"""
|
| 227 |
+
|
| 228 |
+
from . import runtime as _runtime
|
| 229 |
+
|
| 230 |
+
if getattr(args, "no_fetch_runtime", False):
|
| 231 |
+
return ""
|
| 232 |
+
pin = _runtime.pin_for()
|
| 233 |
+
if pin is None:
|
| 234 |
+
return ""
|
| 235 |
+
print(
|
| 236 |
+
f"No llama.cpp on this machine. Fetching the pinned build "
|
| 237 |
+
f"({pin.tag}, {pin.bytes / (1024 * 1024):.0f} MB) and verifying it against "
|
| 238 |
+
"its recorded SHA-256. This happens once.",
|
| 239 |
+
file=sys.stderr,
|
| 240 |
+
flush=True,
|
| 241 |
+
)
|
| 242 |
+
last = [-1]
|
| 243 |
+
|
| 244 |
+
def report(fraction: float, message: str) -> None:
|
| 245 |
+
percent = int(fraction * 100)
|
| 246 |
+
if percent // 10 != last[0] // 10 or fraction >= 1.0:
|
| 247 |
+
last[0] = percent
|
| 248 |
+
print(f" llama.cpp: {message} ({percent}%)", file=sys.stderr, flush=True)
|
| 249 |
+
|
| 250 |
+
try:
|
| 251 |
+
return _runtime.ensure(progress=report)
|
| 252 |
+
except _runtime.RuntimeVerificationError as exc:
|
| 253 |
+
# A digest mismatch is not a transient failure and must not be retried
|
| 254 |
+
# around: it is what a substituted build looks like.
|
| 255 |
+
print(f"\n{exc}\n", file=sys.stderr, flush=True)
|
| 256 |
+
return ""
|
| 257 |
+
except _runtime.RuntimeUnavailable as exc:
|
| 258 |
+
print(f" llama.cpp could not be installed: {exc}", file=sys.stderr, flush=True)
|
| 259 |
+
return ""
|
| 260 |
+
|
| 261 |
+
|
| 262 |
def build_parser() -> argparse.ArgumentParser:
|
| 263 |
parser = argparse.ArgumentParser(description="Run a Distinct local inference worker")
|
| 264 |
parser.add_argument(
|
|
|
|
| 433 |
metavar="ID=PATH",
|
| 434 |
help=argparse.SUPPRESS,
|
| 435 |
)
|
| 436 |
+
parser.add_argument(
|
| 437 |
+
"--no-fetch-runtime",
|
| 438 |
+
action="store_true",
|
| 439 |
+
help=(
|
| 440 |
+
"never download llama.cpp. The worker uses one already on this "
|
| 441 |
+
"machine or refuses to run models, which is what an operator on a "
|
| 442 |
+
"metered or air-gapped connection wants."
|
| 443 |
+
),
|
| 444 |
+
)
|
| 445 |
parser.add_argument(
|
| 446 |
"--llama-server",
|
| 447 |
nargs="?",
|
|
|
|
| 624 |
def _server_pairs(urls, codes):
|
| 625 |
"""Match each --server to its --pair, or explain the mismatch.
|
| 626 |
|
| 627 |
+
With no ``--pair`` values at all, every server is paired the other way
|
| 628 |
+
round: the worker invents a claim code, prints it, and waits for somebody
|
| 629 |
+
signed in to enter it. That is the path the website documents, because the
|
| 630 |
+
old one -- fetch a code from the page, carry it to a terminal -- reliably
|
| 631 |
+
produced a person typing a value of their own where the code went and being
|
| 632 |
+
told one program later that their code was invalid.
|
| 633 |
+
|
| 634 |
+
``--pair`` is kept for scripted setups, where a code is minted and consumed
|
| 635 |
+
by the same program and nobody reads anything. Positional matching, because
|
| 636 |
+
it is the only scheme that reads correctly aloud: "this server, this code;
|
| 637 |
+
that server, that code". A mismatch is an error rather than a best guess,
|
| 638 |
+
since guessing would mean sending one server's code to another, which is a
|
| 639 |
+
credential leak dressed up as convenience.
|
| 640 |
"""
|
| 641 |
|
| 642 |
+
if not codes:
|
| 643 |
+
# NO --pair AT ALL IS THE ORDINARY CASE NOW.
|
| 644 |
+
#
|
| 645 |
+
# Every server gets its own freshly invented claim code, and the
|
| 646 |
+
# operator types it into that server's page. `None` for a code is the
|
| 647 |
+
# signal further down to register-and-wait rather than pair.
|
| 648 |
+
return [(url, None) for url in urls]
|
| 649 |
if len(codes) != len(urls):
|
| 650 |
print(
|
| 651 |
f"{len(urls)} --server value(s) and {len(codes)} --pair value(s). "
|
| 652 |
+
"Either give no --pair at all, and each server will print a code for "
|
| 653 |
+
"you to enter on its page, or give one code per server in the same "
|
| 654 |
+
"order. Codes are never interchangeable between servers.",
|
| 655 |
file=sys.stderr,
|
| 656 |
)
|
| 657 |
return None
|
|
|
|
| 679 |
return shared_cache_directory().parent / "servers.json"
|
| 680 |
|
| 681 |
|
| 682 |
+
def _register_and_wait(
|
| 683 |
+
url,
|
| 684 |
+
capabilities,
|
| 685 |
+
*,
|
| 686 |
+
access_code,
|
| 687 |
+
client_factory,
|
| 688 |
+
allow_remote,
|
| 689 |
+
allow_plaintext,
|
| 690 |
+
signing_public_key,
|
| 691 |
+
poll_seconds: float = 3.0,
|
| 692 |
+
announce=None,
|
| 693 |
+
sleep=None,
|
| 694 |
+
):
|
| 695 |
+
"""Print a claim code for this server and wait until somebody enters it.
|
| 696 |
+
|
| 697 |
+
Returns the transport once the worker has been claimed, or ``None`` if it
|
| 698 |
+
was not claimed in time or the server refused the registration.
|
| 699 |
+
|
| 700 |
+
The wait is deliberately visible. A worker that registered silently and sat
|
| 701 |
+
there would look identical to one that had hung, and the whole point of
|
| 702 |
+
this direction is that the person is looking at the terminal when the code
|
| 703 |
+
appears.
|
| 704 |
+
"""
|
| 705 |
+
|
| 706 |
+
import time as _time
|
| 707 |
+
|
| 708 |
+
from .access import new_access_code as _new_code
|
| 709 |
+
|
| 710 |
+
say = announce or (lambda text: print(text, file=sys.stderr, flush=True))
|
| 711 |
+
nap = sleep or _time.sleep
|
| 712 |
+
claim_code = _new_code()
|
| 713 |
+
try:
|
| 714 |
+
transport = GradioAgentTransport.register(
|
| 715 |
+
url,
|
| 716 |
+
claim_code,
|
| 717 |
+
capabilities,
|
| 718 |
+
access_code=access_code,
|
| 719 |
+
client_factory=client_factory,
|
| 720 |
+
allow_remote=allow_remote or None,
|
| 721 |
+
allow_plaintext=allow_plaintext or None,
|
| 722 |
+
signing_public_key=signing_public_key,
|
| 723 |
+
)
|
| 724 |
+
except (AgentTransportError, TypeError, ValueError) as exc:
|
| 725 |
+
say(f"Registering with {url} failed: {exc}")
|
| 726 |
+
return None
|
| 727 |
+
|
| 728 |
+
say("")
|
| 729 |
+
say(" ┌─────────────────────────────────────────────────────────┐")
|
| 730 |
+
say(" │ This worker is waiting to be claimed. │")
|
| 731 |
+
say(" └─────────────────────────────────────────────────────────┘")
|
| 732 |
+
say("")
|
| 733 |
+
say(f" Sign in at {url}")
|
| 734 |
+
say(' open "Run a community agent", and enter this code:')
|
| 735 |
+
say("")
|
| 736 |
+
say(f" {claim_code}")
|
| 737 |
+
say("")
|
| 738 |
+
say(" Nobody can use this machine until you do, and the code works")
|
| 739 |
+
say(" only once. Waiting…")
|
| 740 |
+
say("")
|
| 741 |
+
|
| 742 |
+
deadline = _time.monotonic() + 15 * 60
|
| 743 |
+
while _time.monotonic() < deadline:
|
| 744 |
+
try:
|
| 745 |
+
state = transport.claim_state()
|
| 746 |
+
except (AgentTransportError, TypeError, ValueError):
|
| 747 |
+
# A poll that fails is not a claim that failed. Keep waiting; a
|
| 748 |
+
# server that is really gone will still be gone at the deadline.
|
| 749 |
+
state = "waiting"
|
| 750 |
+
if state == "claimed":
|
| 751 |
+
say(f"Claimed. {capabilities.name!r} is now serving {url} as {transport.agent_id}.")
|
| 752 |
+
return transport
|
| 753 |
+
if state == "gone":
|
| 754 |
+
say(
|
| 755 |
+
"This worker's registration expired before anybody claimed it. "
|
| 756 |
+
"Nothing was left running on the server; start the worker again "
|
| 757 |
+
"for a fresh code."
|
| 758 |
+
)
|
| 759 |
+
return None
|
| 760 |
+
nap(poll_seconds)
|
| 761 |
+
say(
|
| 762 |
+
"Nobody claimed this worker within fifteen minutes, so it has stopped "
|
| 763 |
+
"waiting. Start it again for a fresh code."
|
| 764 |
+
)
|
| 765 |
+
return None
|
| 766 |
+
|
| 767 |
+
|
| 768 |
def _join_server(
|
| 769 |
url,
|
| 770 |
code,
|
|
|
|
| 805 |
tools=capabilities.tools,
|
| 806 |
)
|
| 807 |
identity_key = generate_ed25519_key()
|
| 808 |
+
if code is None:
|
| 809 |
+
# THE CODE GOES FROM HERE TO THE BROWSER, NOT THE OTHER WAY.
|
| 810 |
+
transport = _register_and_wait(
|
| 811 |
url,
|
|
|
|
| 812 |
capabilities,
|
| 813 |
access_code=access_code,
|
| 814 |
client_factory=client_factory,
|
| 815 |
+
allow_remote=allow_remote,
|
| 816 |
+
allow_plaintext=allow_plaintext,
|
| 817 |
signing_public_key=ed25519_public_key(identity_key),
|
| 818 |
)
|
| 819 |
+
if transport is None:
|
| 820 |
+
return None, None
|
| 821 |
+
else:
|
| 822 |
+
try:
|
| 823 |
+
transport = GradioAgentTransport.pair(
|
| 824 |
+
url,
|
| 825 |
+
code,
|
| 826 |
+
capabilities,
|
| 827 |
+
access_code=access_code,
|
| 828 |
+
client_factory=client_factory,
|
| 829 |
+
allow_remote=allow_remote or None,
|
| 830 |
+
allow_plaintext=allow_plaintext or None,
|
| 831 |
+
signing_public_key=ed25519_public_key(identity_key),
|
| 832 |
+
)
|
| 833 |
+
except (AgentTransportError, TypeError, ValueError) as exc:
|
| 834 |
+
print(f"Pairing with {url} failed: {exc}", file=sys.stderr)
|
| 835 |
+
return None, None
|
| 836 |
+
print(
|
| 837 |
+
f"Paired {capabilities.name!r} with {url} as {transport.agent_id}.",
|
| 838 |
+
file=sys.stderr,
|
| 839 |
+
)
|
| 840 |
|
| 841 |
# -------- Consent handshake: catalogue, approval, gate ----------
|
| 842 |
#
|
|
|
|
| 987 |
# crash. Argparse-level mistakes deserve argparse-level answers.
|
| 988 |
print("--demo-prompt needs something to ask; it was empty.", file=sys.stderr)
|
| 989 |
return 2
|
|
|
|
|
|
|
|
|
|
| 990 |
if args.list_servers:
|
| 991 |
return _list_servers(args)
|
| 992 |
if args.forget_server:
|
|
|
|
| 1488 |
# time anybody sees it: the servers keep a digest and this process
|
| 1489 |
# does not write it anywhere.
|
| 1490 |
access_code = new_access_code()
|
| 1491 |
+
# PRINTED AFTER THE JOIN, NOT BEFORE IT.
|
| 1492 |
+
#
|
| 1493 |
+
# This used to be announced here, which put two high-entropy codes
|
| 1494 |
+
# on screen at once: the access code to share, and then the claim
|
| 1495 |
+
# code to enter. Two codes visible together is the same confusion
|
| 1496 |
+
# this whole change exists to remove -- the person scrolls back,
|
| 1497 |
+
# finds *a* code, and uses it for the wrong thing.
|
| 1498 |
+
#
|
| 1499 |
+
# One at a time, in the order they are needed: the claim code while
|
| 1500 |
+
# the worker waits, and the access code once it is actually running
|
| 1501 |
+
# and there is something worth sharing.
|
| 1502 |
# Kept so the dashboard can show it. It is printed once and stored
|
| 1503 |
# nowhere on disk, so a screen that could not show it would leave
|
| 1504 |
# somebody who scrolled past it with no way to get it back short of
|
|
|
|
| 1538 |
flush=True,
|
| 1539 |
)
|
| 1540 |
return 2
|
| 1541 |
+
# `--server` is repeatable, so this is a list. One worker can serve
|
| 1542 |
+
# several servers with the same code, and the invitation names them
|
| 1543 |
+
# all rather than picking one and quietly misdirecting the people
|
| 1544 |
+
# sent to the others.
|
| 1545 |
+
print(
|
| 1546 |
+
announce_access_code(access_code, servers=tuple(args.server or ())),
|
| 1547 |
+
file=sys.stderr,
|
| 1548 |
+
flush=True,
|
| 1549 |
+
)
|
| 1550 |
if len(links) == 1:
|
| 1551 |
# One server: the transport it paired with, unwrapped. The
|
| 1552 |
# fan-out is a real object with real behaviour and there is no
|
distinct_agent/runtime.py
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Getting llama.cpp onto a volunteer's machine, with the same rules as weights.
|
| 2 |
+
|
| 3 |
+
WHY THIS EXISTS.
|
| 4 |
+
|
| 5 |
+
The setup used to end with a wall. A volunteer cloned the repository, ran the
|
| 6 |
+
install, ran the worker, and was told to go to another project's releases page,
|
| 7 |
+
pick the right build for their machine out of a dozen, unzip it somewhere, and
|
| 8 |
+
either edit their PATH or pass a path back on the command line. Their reaction
|
| 9 |
+
was the correct one: *why do I have to provide this, it should be baked into
|
| 10 |
+
the repo*.
|
| 11 |
+
|
| 12 |
+
It cannot literally be in the repository. A llama.cpp release is around a
|
| 13 |
+
hundred megabytes per platform, there is one per operating system and per
|
| 14 |
+
accelerator, and it is a separate project with its own release cadence. Putting
|
| 15 |
+
those in a Space repository would make every clone enormous and every llama.cpp
|
| 16 |
+
release a commit here.
|
| 17 |
+
|
| 18 |
+
``llama-cpp-python`` was the other obvious answer and is worse: with no
|
| 19 |
+
matching wheel it compiles from source, so on a Windows machine without a
|
| 20 |
+
build toolchain the failure moves from "run the worker" to ``pip install``
|
| 21 |
+
itself, which is a wall one step earlier and harder to read.
|
| 22 |
+
|
| 23 |
+
So this does what the project already does for model weights: fetches a
|
| 24 |
+
**pinned** release over TLS and refuses to install it unless the bytes match a
|
| 25 |
+
**recorded SHA-256**. One command, nothing to go and get, and the thing that
|
| 26 |
+
runs the models is verified rather than trusted.
|
| 27 |
+
|
| 28 |
+
WHAT IS DELIBERATELY NOT DONE.
|
| 29 |
+
|
| 30 |
+
There is no trust-on-first-use path. Reading a digest out of the same response
|
| 31 |
+
that carried the download proves only that the bytes arrived intact, which TLS
|
| 32 |
+
already said. This project withholds a *model* for exactly that reason --
|
| 33 |
+
``llama-2-7b-chat`` is refused with "no upstream repository, pinned revision or
|
| 34 |
+
SHA-256 digest recorded, so this worker cannot obtain the exact published file
|
| 35 |
+
or confirm what it ran" -- and the program that executes those models cannot be
|
| 36 |
+
held to a lower standard than the models.
|
| 37 |
+
|
| 38 |
+
A platform with no recorded digest therefore gets no automatic download. It is
|
| 39 |
+
told so plainly, and keeps both manual routes: ``--llama-server`` for a build
|
| 40 |
+
the operator already has, and ``--demo-runner`` to run the library's tools and
|
| 41 |
+
skills with no model at all.
|
| 42 |
+
"""
|
| 43 |
+
|
| 44 |
+
from __future__ import annotations
|
| 45 |
+
|
| 46 |
+
import hashlib
|
| 47 |
+
import json
|
| 48 |
+
import platform
|
| 49 |
+
import shutil
|
| 50 |
+
import stat
|
| 51 |
+
import sys
|
| 52 |
+
import tempfile
|
| 53 |
+
import urllib.error
|
| 54 |
+
import urllib.parse
|
| 55 |
+
import urllib.request
|
| 56 |
+
import zipfile
|
| 57 |
+
from dataclasses import dataclass
|
| 58 |
+
from pathlib import Path
|
| 59 |
+
from typing import Any, Callable, Mapping, Optional
|
| 60 |
+
|
| 61 |
+
#: Where the pins live. A data file rather than a literal so that recording a
|
| 62 |
+
#: new platform, or moving to a newer llama.cpp, is a data change somebody can
|
| 63 |
+
#: review as a diff of digests.
|
| 64 |
+
PIN_FILE = Path(__file__).resolve().parent.parent / "llama_runtime.json"
|
| 65 |
+
|
| 66 |
+
#: Releases are served from github.com and redirected to their object store.
|
| 67 |
+
#: Anywhere else and the download is refused, for the same reason the weights
|
| 68 |
+
#: fetcher refuses a redirect off Hugging Face: a download that has been bounced
|
| 69 |
+
#: somewhere unexpected is no longer the download that was asked for.
|
| 70 |
+
RUNTIME_REDIRECT_HOSTS = ("github.com", "githubusercontent.com", "github-releases.githubusercontent.com")
|
| 71 |
+
|
| 72 |
+
_CHUNK = 1024 * 1024
|
| 73 |
+
|
| 74 |
+
#: A llama.cpp Windows archive is about 30 MB; a CUDA one rather more. This is
|
| 75 |
+
#: a bomb guard, not a size expectation: an archive claiming to be larger than
|
| 76 |
+
#: this is refused before a byte of it is written.
|
| 77 |
+
MAX_ARCHIVE_BYTES = 600 * 1024 * 1024
|
| 78 |
+
|
| 79 |
+
#: The unpacked tree is checked against the same ceiling, so an archive that is
|
| 80 |
+
#: small on the wire and enormous on disk is refused rather than unpacked.
|
| 81 |
+
MAX_UNPACKED_BYTES = 900 * 1024 * 1024
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
class RuntimeUnavailable(RuntimeError):
|
| 85 |
+
"""The runtime could not be obtained, with a sentence saying why."""
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
class RuntimeVerificationError(RuntimeUnavailable):
|
| 89 |
+
"""The bytes arrived but did not match the recorded digest."""
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
class _RuntimeRedirects(urllib.request.HTTPRedirectHandler):
|
| 93 |
+
max_redirections = 10
|
| 94 |
+
|
| 95 |
+
@staticmethod
|
| 96 |
+
def _permitted(url: str) -> bool:
|
| 97 |
+
parsed = urllib.parse.urlsplit(url)
|
| 98 |
+
if parsed.scheme != "https":
|
| 99 |
+
return False
|
| 100 |
+
host = (parsed.hostname or "").casefold().rstrip(".")
|
| 101 |
+
if not host:
|
| 102 |
+
return False
|
| 103 |
+
return any(
|
| 104 |
+
host == allowed or host.endswith("." + allowed)
|
| 105 |
+
for allowed in RUNTIME_REDIRECT_HOSTS
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: D102
|
| 109 |
+
if not self._permitted(newurl):
|
| 110 |
+
raise urllib.error.HTTPError(
|
| 111 |
+
newurl,
|
| 112 |
+
code,
|
| 113 |
+
"the llama.cpp download was redirected off GitHub and was refused",
|
| 114 |
+
headers,
|
| 115 |
+
fp,
|
| 116 |
+
)
|
| 117 |
+
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def _opener() -> Callable[..., Any]:
|
| 121 |
+
return urllib.request.build_opener(_RuntimeRedirects).open
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
@dataclass(frozen=True)
|
| 125 |
+
class RuntimePin:
|
| 126 |
+
"""One platform's recorded build."""
|
| 127 |
+
|
| 128 |
+
key: str
|
| 129 |
+
tag: str
|
| 130 |
+
asset: str
|
| 131 |
+
url: str
|
| 132 |
+
sha256: str
|
| 133 |
+
bytes: int = 0
|
| 134 |
+
|
| 135 |
+
@property
|
| 136 |
+
def recorded(self) -> bool:
|
| 137 |
+
return bool(self.url and len(self.sha256) == 64)
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def platform_key(system: str = "", machine: str = "") -> str:
|
| 141 |
+
"""The key this machine looks itself up under.
|
| 142 |
+
|
| 143 |
+
Coarse on purpose: operating system and architecture, nothing about the
|
| 144 |
+
accelerator. An operator who wants a CUDA or Metal build passes
|
| 145 |
+
``--llama-server``; automatic installation picks the build that runs
|
| 146 |
+
everywhere rather than the fastest one, because a fast build that does not
|
| 147 |
+
start is worse than a slow one that does.
|
| 148 |
+
"""
|
| 149 |
+
|
| 150 |
+
system = (system or platform.system()).casefold()
|
| 151 |
+
machine = (machine or platform.machine()).casefold()
|
| 152 |
+
if machine in {"amd64", "x86_64", "x64"}:
|
| 153 |
+
arch = "x64"
|
| 154 |
+
elif machine in {"arm64", "aarch64"}:
|
| 155 |
+
arch = "arm64"
|
| 156 |
+
else:
|
| 157 |
+
arch = machine or "unknown"
|
| 158 |
+
if system.startswith("win"):
|
| 159 |
+
return f"windows-{arch}"
|
| 160 |
+
if system == "darwin":
|
| 161 |
+
return f"macos-{arch}"
|
| 162 |
+
if system == "linux":
|
| 163 |
+
return f"linux-{arch}"
|
| 164 |
+
return f"{system or 'unknown'}-{arch}"
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def load_pins(path: Optional[Path] = None) -> dict[str, RuntimePin]:
|
| 168 |
+
"""Read the recorded builds, or an empty mapping if none are recorded."""
|
| 169 |
+
|
| 170 |
+
source = path or PIN_FILE
|
| 171 |
+
try:
|
| 172 |
+
raw = json.loads(source.read_text(encoding="utf-8"))
|
| 173 |
+
except (OSError, ValueError):
|
| 174 |
+
return {}
|
| 175 |
+
builds = raw.get("builds") if isinstance(raw, Mapping) else None
|
| 176 |
+
if not isinstance(builds, Mapping):
|
| 177 |
+
return {}
|
| 178 |
+
pins: dict[str, RuntimePin] = {}
|
| 179 |
+
for key, entry in builds.items():
|
| 180 |
+
if not isinstance(entry, Mapping):
|
| 181 |
+
continue
|
| 182 |
+
pins[str(key)] = RuntimePin(
|
| 183 |
+
key=str(key),
|
| 184 |
+
tag=str(entry.get("tag", "")),
|
| 185 |
+
asset=str(entry.get("asset", "")),
|
| 186 |
+
url=str(entry.get("url", "")),
|
| 187 |
+
sha256=str(entry.get("sha256", "")).casefold(),
|
| 188 |
+
bytes=int(entry.get("bytes", 0) or 0),
|
| 189 |
+
)
|
| 190 |
+
return pins
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def pin_for(key: str = "", path: Optional[Path] = None) -> Optional[RuntimePin]:
|
| 194 |
+
pin = load_pins(path).get(key or platform_key())
|
| 195 |
+
return pin if pin is not None and pin.recorded else None
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def runtime_directory(root: Optional[Path] = None) -> Path:
|
| 199 |
+
"""Where an installed llama.cpp lives: ``runtime/`` beside the project."""
|
| 200 |
+
|
| 201 |
+
return (root or PIN_FILE.parent) / "runtime"
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def installed_server(root: Optional[Path] = None) -> str:
|
| 205 |
+
"""The path to an already-installed llama-server, or empty."""
|
| 206 |
+
|
| 207 |
+
directory = runtime_directory(root)
|
| 208 |
+
for name in ("llama-server.exe", "llama-server"):
|
| 209 |
+
candidate = directory / name
|
| 210 |
+
if candidate.is_file():
|
| 211 |
+
return str(candidate)
|
| 212 |
+
# Some archives nest everything one level down under build/bin.
|
| 213 |
+
for candidate in sorted(directory.glob("**/llama-server*")):
|
| 214 |
+
if candidate.is_file() and candidate.suffix in {"", ".exe"}:
|
| 215 |
+
return str(candidate)
|
| 216 |
+
return ""
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def _safe_members(archive: zipfile.ZipFile) -> list[zipfile.ZipInfo]:
|
| 220 |
+
"""Every member that may be written, refusing traversal and bombs.
|
| 221 |
+
|
| 222 |
+
``ZipFile.extractall`` sanitises paths in modern Python, but it has no
|
| 223 |
+
opinion about total size, and this archive comes from outside the project.
|
| 224 |
+
Both are checked here so the refusal names the member rather than filling a
|
| 225 |
+
volunteer's disk and failing later.
|
| 226 |
+
"""
|
| 227 |
+
|
| 228 |
+
total = 0
|
| 229 |
+
members: list[zipfile.ZipInfo] = []
|
| 230 |
+
for info in archive.infolist():
|
| 231 |
+
name = info.filename
|
| 232 |
+
if name.endswith("/"):
|
| 233 |
+
continue
|
| 234 |
+
pure = Path(name)
|
| 235 |
+
if pure.is_absolute() or ".." in pure.parts or (len(name) > 1 and name[1] == ":"):
|
| 236 |
+
raise RuntimeUnavailable(
|
| 237 |
+
f"the llama.cpp archive contains an unsafe path ({name!r}) and was not unpacked"
|
| 238 |
+
)
|
| 239 |
+
total += info.file_size
|
| 240 |
+
if info.file_size > MAX_UNPACKED_BYTES or total > MAX_UNPACKED_BYTES:
|
| 241 |
+
raise RuntimeUnavailable(
|
| 242 |
+
"the llama.cpp archive unpacks to more than "
|
| 243 |
+
f"{MAX_UNPACKED_BYTES // (1024 * 1024)} MB and was refused"
|
| 244 |
+
)
|
| 245 |
+
members.append(info)
|
| 246 |
+
return members
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
def ensure(
|
| 250 |
+
*,
|
| 251 |
+
root: Optional[Path] = None,
|
| 252 |
+
key: str = "",
|
| 253 |
+
progress: Optional[Callable[[float, str], None]] = None,
|
| 254 |
+
opener: Optional[Callable[..., Any]] = None,
|
| 255 |
+
pins_path: Optional[Path] = None,
|
| 256 |
+
) -> str:
|
| 257 |
+
"""Install the pinned llama.cpp for this machine and return its path.
|
| 258 |
+
|
| 259 |
+
Returns the path to ``llama-server``. Raises :class:`RuntimeUnavailable`
|
| 260 |
+
with a sentence a volunteer can act on when there is nothing recorded for
|
| 261 |
+
this platform, and :class:`RuntimeVerificationError` when the bytes do not
|
| 262 |
+
match -- in which case nothing is installed.
|
| 263 |
+
"""
|
| 264 |
+
|
| 265 |
+
existing = installed_server(root)
|
| 266 |
+
if existing:
|
| 267 |
+
return existing
|
| 268 |
+
|
| 269 |
+
resolved = key or platform_key()
|
| 270 |
+
pin = pin_for(resolved, pins_path)
|
| 271 |
+
if pin is None:
|
| 272 |
+
raise RuntimeUnavailable(
|
| 273 |
+
f"no llama.cpp build is recorded for {resolved}, so this worker will not "
|
| 274 |
+
"download one: an unverified build of the program that runs the models is "
|
| 275 |
+
"exactly what this project refuses to install. Point at a build you already "
|
| 276 |
+
"have with --llama-server, or start without a model using --demo-runner."
|
| 277 |
+
)
|
| 278 |
+
|
| 279 |
+
target = runtime_directory(root)
|
| 280 |
+
target.mkdir(parents=True, exist_ok=True)
|
| 281 |
+
fetch = opener or _opener()
|
| 282 |
+
digest = hashlib.sha256()
|
| 283 |
+
written = 0
|
| 284 |
+
|
| 285 |
+
if progress is not None:
|
| 286 |
+
progress(0.0, f"Fetching llama.cpp {pin.tag} for {resolved}")
|
| 287 |
+
|
| 288 |
+
with tempfile.TemporaryDirectory(prefix="distinct-runtime-") as scratch:
|
| 289 |
+
archive_path = Path(scratch) / "llama.zip"
|
| 290 |
+
try:
|
| 291 |
+
request = urllib.request.Request(
|
| 292 |
+
pin.url,
|
| 293 |
+
headers={"User-Agent": "distinct-agent", "Accept": "application/octet-stream"},
|
| 294 |
+
)
|
| 295 |
+
with fetch(request, timeout=60) as response:
|
| 296 |
+
declared = 0
|
| 297 |
+
try:
|
| 298 |
+
declared = int(response.headers.get("Content-Length") or 0)
|
| 299 |
+
except (TypeError, ValueError):
|
| 300 |
+
declared = 0
|
| 301 |
+
if declared and declared > MAX_ARCHIVE_BYTES:
|
| 302 |
+
raise RuntimeUnavailable(
|
| 303 |
+
"the llama.cpp download declares more than "
|
| 304 |
+
f"{MAX_ARCHIVE_BYTES // (1024 * 1024)} MB and was refused"
|
| 305 |
+
)
|
| 306 |
+
with archive_path.open("wb") as handle:
|
| 307 |
+
while True:
|
| 308 |
+
chunk = response.read(_CHUNK)
|
| 309 |
+
if not chunk:
|
| 310 |
+
break
|
| 311 |
+
handle.write(chunk)
|
| 312 |
+
digest.update(chunk)
|
| 313 |
+
written += len(chunk)
|
| 314 |
+
if written > MAX_ARCHIVE_BYTES:
|
| 315 |
+
raise RuntimeUnavailable(
|
| 316 |
+
"the llama.cpp download exceeded "
|
| 317 |
+
f"{MAX_ARCHIVE_BYTES // (1024 * 1024)} MB and was stopped"
|
| 318 |
+
)
|
| 319 |
+
if progress is not None and declared:
|
| 320 |
+
progress(
|
| 321 |
+
min(0.99, written / declared),
|
| 322 |
+
f"Fetching llama.cpp {pin.tag}",
|
| 323 |
+
)
|
| 324 |
+
except RuntimeUnavailable:
|
| 325 |
+
raise
|
| 326 |
+
except (OSError, urllib.error.URLError, ValueError) as exc:
|
| 327 |
+
raise RuntimeUnavailable(
|
| 328 |
+
f"could not fetch llama.cpp {pin.tag} for {resolved}: "
|
| 329 |
+
f"{type(exc).__name__}: {exc}. Point at a build you already have with "
|
| 330 |
+
"--llama-server, or start without a model using --demo-runner."
|
| 331 |
+
) from exc
|
| 332 |
+
|
| 333 |
+
actual = digest.hexdigest()
|
| 334 |
+
if actual != pin.sha256:
|
| 335 |
+
raise RuntimeVerificationError(
|
| 336 |
+
f"the llama.cpp download for {resolved} does not match the recorded "
|
| 337 |
+
f"SHA-256: expected {pin.sha256}, computed {actual}. Nothing was "
|
| 338 |
+
"installed. This is what a substituted or corrupted build looks like; "
|
| 339 |
+
"report it rather than working around it."
|
| 340 |
+
)
|
| 341 |
+
|
| 342 |
+
if progress is not None:
|
| 343 |
+
progress(0.99, f"llama.cpp {pin.tag} verified, unpacking")
|
| 344 |
+
|
| 345 |
+
unpacked = Path(scratch) / "unpacked"
|
| 346 |
+
unpacked.mkdir()
|
| 347 |
+
try:
|
| 348 |
+
with zipfile.ZipFile(archive_path) as archive:
|
| 349 |
+
members = _safe_members(archive)
|
| 350 |
+
archive.extractall(unpacked, members=members)
|
| 351 |
+
except zipfile.BadZipFile as exc:
|
| 352 |
+
raise RuntimeUnavailable(
|
| 353 |
+
f"the llama.cpp download for {resolved} is not a readable archive: {exc}"
|
| 354 |
+
) from exc
|
| 355 |
+
|
| 356 |
+
# The layout differs between releases, so the binaries are found rather
|
| 357 |
+
# than assumed, and copied flat into runtime/ where installed_server()
|
| 358 |
+
# and the existing _find_llama_server() both already look.
|
| 359 |
+
moved = 0
|
| 360 |
+
for source in sorted(unpacked.glob("**/*")):
|
| 361 |
+
if not source.is_file():
|
| 362 |
+
continue
|
| 363 |
+
destination = target / source.name
|
| 364 |
+
shutil.copy2(source, destination)
|
| 365 |
+
if not sys.platform.startswith("win"):
|
| 366 |
+
destination.chmod(destination.stat().st_mode | stat.S_IXUSR)
|
| 367 |
+
moved += 1
|
| 368 |
+
|
| 369 |
+
found = installed_server(root)
|
| 370 |
+
if not found:
|
| 371 |
+
raise RuntimeUnavailable(
|
| 372 |
+
f"the llama.cpp archive for {resolved} unpacked {moved} file(s) but none of "
|
| 373 |
+
"them was llama-server. The recorded asset is probably the wrong one; "
|
| 374 |
+
"report it rather than working around it."
|
| 375 |
+
)
|
| 376 |
+
if progress is not None:
|
| 377 |
+
progress(1.0, f"llama.cpp {pin.tag} installed in {target}")
|
| 378 |
+
return found
|
| 379 |
+
|
| 380 |
+
|
| 381 |
+
def describe_availability(key: str = "", pins_path: Optional[Path] = None) -> str:
|
| 382 |
+
"""One line about what this machine can install, for the start-up report."""
|
| 383 |
+
|
| 384 |
+
resolved = key or platform_key()
|
| 385 |
+
pin = pin_for(resolved, pins_path)
|
| 386 |
+
if pin is None:
|
| 387 |
+
return f"llama.cpp: no build recorded for {resolved}; supply --llama-server"
|
| 388 |
+
return f"llama.cpp: {pin.tag} recorded for {resolved}, verified on install"
|
distinct_agent/transport.py
CHANGED
|
@@ -42,6 +42,8 @@ from distinct_protocol.netpolicy import validate_server_url as policy_validate_s
|
|
| 42 |
from .queue import OfferDecision
|
| 43 |
|
| 44 |
PAIR_API = "/agent_pair"
|
|
|
|
|
|
|
| 45 |
SYNC_API = "/agent_sync"
|
| 46 |
ACCEPT_API = "/agent_accept"
|
| 47 |
COMPLETE_API = "/agent_complete"
|
|
@@ -299,6 +301,93 @@ class GradioAgentTransport:
|
|
| 299 |
self._last_snapshot: AgentSnapshot | None = None
|
| 300 |
self._last_status: dict[str, JobStatus] = {}
|
| 301 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 302 |
@classmethod
|
| 303 |
def pair(
|
| 304 |
cls,
|
|
|
|
| 42 |
from .queue import OfferDecision
|
| 43 |
|
| 44 |
PAIR_API = "/agent_pair"
|
| 45 |
+
REGISTER_API = "/agent_register"
|
| 46 |
+
CLAIM_STATE_API = "/agent_claim_state"
|
| 47 |
SYNC_API = "/agent_sync"
|
| 48 |
ACCEPT_API = "/agent_accept"
|
| 49 |
COMPLETE_API = "/agent_complete"
|
|
|
|
| 301 |
self._last_snapshot: AgentSnapshot | None = None
|
| 302 |
self._last_status: dict[str, JobStatus] = {}
|
| 303 |
|
| 304 |
+
@classmethod
|
| 305 |
+
def register(
|
| 306 |
+
cls,
|
| 307 |
+
server_url: str,
|
| 308 |
+
claim_code: str,
|
| 309 |
+
capabilities: AgentCapabilities,
|
| 310 |
+
*,
|
| 311 |
+
client_factory: Callable[[str], PredictClient] | None = None,
|
| 312 |
+
clock: Callable[[], float] = time.time,
|
| 313 |
+
nonce_factory: Callable[[], str] | None = None,
|
| 314 |
+
sleep: Callable[[float], None] = time.sleep,
|
| 315 |
+
max_attempts: int = 3,
|
| 316 |
+
retry_delay_seconds: float = 0.25,
|
| 317 |
+
allow_remote: bool | None = None,
|
| 318 |
+
allow_plaintext: bool | None = None,
|
| 319 |
+
verify_server: bool = True,
|
| 320 |
+
signing_public_key: bytes = b"",
|
| 321 |
+
access_code: str = "",
|
| 322 |
+
) -> GradioAgentTransport:
|
| 323 |
+
"""Register with a code this worker invented, to be claimed from the site.
|
| 324 |
+
|
| 325 |
+
The same handshake as :meth:`pair` with the code travelling the other
|
| 326 |
+
way. The transport that comes back is authenticated and usable, but the
|
| 327 |
+
server will offer it no work until somebody signed in claims it, so the
|
| 328 |
+
caller polls :meth:`claim_state` before settling into the work loop.
|
| 329 |
+
|
| 330 |
+
Attempted once, like pairing, and for a stronger reason: a retry that
|
| 331 |
+
succeeded after a lost response would leave two records waiting on one
|
| 332 |
+
code, and the server refuses the second rather than allowing the claim
|
| 333 |
+
to become ambiguous.
|
| 334 |
+
"""
|
| 335 |
+
|
| 336 |
+
if not isinstance(capabilities, AgentCapabilities):
|
| 337 |
+
raise TypeError("capabilities must be AgentCapabilities")
|
| 338 |
+
server_url = validate_server_url(
|
| 339 |
+
server_url, allow_remote=allow_remote, allow_plaintext=allow_plaintext
|
| 340 |
+
)
|
| 341 |
+
factory = client_factory or _default_client_factory
|
| 342 |
+
client: PredictClient | None = None
|
| 343 |
+
try:
|
| 344 |
+
client = factory(server_url)
|
| 345 |
+
raw = client.predict(
|
| 346 |
+
claim_code,
|
| 347 |
+
canonical_body(capabilities.to_dict()),
|
| 348 |
+
bytes(signing_public_key).hex() if signing_public_key else "",
|
| 349 |
+
access_code or "",
|
| 350 |
+
api_name=REGISTER_API,
|
| 351 |
+
)
|
| 352 |
+
response = _decode_response(raw, rejection_type=AgentPairingRejected)
|
| 353 |
+
credential = _credential_from_response(response)
|
| 354 |
+
except AgentTransportError:
|
| 355 |
+
_close_quietly(client)
|
| 356 |
+
raise
|
| 357 |
+
except Exception as exc:
|
| 358 |
+
_close_quietly(client)
|
| 359 |
+
raise AgentTransportUnavailable(
|
| 360 |
+
f"register request failed: {type(exc).__name__}"
|
| 361 |
+
) from exc
|
| 362 |
+
return cls(
|
| 363 |
+
client=client,
|
| 364 |
+
credential=credential,
|
| 365 |
+
clock=clock,
|
| 366 |
+
nonce_factory=nonce_factory,
|
| 367 |
+
sleep=sleep,
|
| 368 |
+
max_attempts=max_attempts,
|
| 369 |
+
retry_delay_seconds=retry_delay_seconds,
|
| 370 |
+
server_auth=(
|
| 371 |
+
ServerAuthenticator(decode_agent_secret(credential.secret))
|
| 372 |
+
if verify_server
|
| 373 |
+
else None
|
| 374 |
+
),
|
| 375 |
+
)
|
| 376 |
+
|
| 377 |
+
def claim_state(self) -> str:
|
| 378 |
+
"""``"waiting"``, ``"claimed"`` or ``"gone"``, for this worker's own record."""
|
| 379 |
+
|
| 380 |
+
try:
|
| 381 |
+
raw = self.client.predict(self.agent_id, api_name=CLAIM_STATE_API)
|
| 382 |
+
response = _decode_response(raw)
|
| 383 |
+
except AgentTransportError:
|
| 384 |
+
raise
|
| 385 |
+
except Exception as exc:
|
| 386 |
+
raise AgentTransportUnavailable(
|
| 387 |
+
f"claim state request failed: {type(exc).__name__}"
|
| 388 |
+
) from exc
|
| 389 |
+
return str(response.get("claim_state", "waiting"))
|
| 390 |
+
|
| 391 |
@classmethod
|
| 392 |
def pair(
|
| 393 |
cls,
|
distinct_server/api.py
CHANGED
|
@@ -159,6 +159,74 @@ class AgentApi:
|
|
| 159 |
except (ControlPlaneError, TypeError, ValueError) as exc:
|
| 160 |
return _response({"ok": False, "error": str(exc)})
|
| 161 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
def sync(
|
| 163 |
self,
|
| 164 |
agent_id: str,
|
|
|
|
| 159 |
except (ControlPlaneError, TypeError, ValueError) as exc:
|
| 160 |
return _response({"ok": False, "error": str(exc)})
|
| 161 |
|
| 162 |
+
def register(
|
| 163 |
+
self,
|
| 164 |
+
claim_code: str,
|
| 165 |
+
capabilities_json: str,
|
| 166 |
+
signing_public_key_hex: str = "",
|
| 167 |
+
access_code: str = "",
|
| 168 |
+
) -> str:
|
| 169 |
+
"""Register a worker that will be claimed from the website afterwards.
|
| 170 |
+
|
| 171 |
+
The mirror image of :meth:`pair`. There, the code came from the server
|
| 172 |
+
and the volunteer carried it to the terminal; here the worker invents
|
| 173 |
+
one and the volunteer carries it to the browser. Nothing is copied out
|
| 174 |
+
of the page, which is what makes the failure this replaced -- somebody
|
| 175 |
+
inventing a value where the pairing code went -- impossible rather than
|
| 176 |
+
merely better explained.
|
| 177 |
+
|
| 178 |
+
This is the only entry point on the server that needs no account. What
|
| 179 |
+
it creates is inert: see ``ControlPlane.register_unclaimed``.
|
| 180 |
+
"""
|
| 181 |
+
|
| 182 |
+
try:
|
| 183 |
+
capabilities = AgentCapabilities.from_dict(
|
| 184 |
+
_json_object(capabilities_json, maximum=65_536)
|
| 185 |
+
)
|
| 186 |
+
public_key = b""
|
| 187 |
+
if signing_public_key_hex:
|
| 188 |
+
if not isinstance(signing_public_key_hex, str):
|
| 189 |
+
raise ValueError("signing public key must be hexadecimal text")
|
| 190 |
+
try:
|
| 191 |
+
public_key = bytes.fromhex(signing_public_key_hex)
|
| 192 |
+
except ValueError as exc:
|
| 193 |
+
raise ValueError("signing public key is not valid hexadecimal") from exc
|
| 194 |
+
credential = self.control_plane.register_unclaimed(
|
| 195 |
+
capabilities,
|
| 196 |
+
claim_code,
|
| 197 |
+
signing_public_key=public_key,
|
| 198 |
+
access_code=access_code or "",
|
| 199 |
+
)
|
| 200 |
+
return _response(
|
| 201 |
+
{
|
| 202 |
+
"ok": True,
|
| 203 |
+
"agent_id": credential.agent_id,
|
| 204 |
+
"secret": credential.secret,
|
| 205 |
+
"issued_at": credential.issued_at,
|
| 206 |
+
"claim_state": "waiting",
|
| 207 |
+
"claim_ttl_s": self.control_plane.limits.claim_ttl_seconds,
|
| 208 |
+
"poll_interval_s": self.control_plane.limits.poll_interval_seconds,
|
| 209 |
+
"heartbeat_interval_s": self.control_plane.limits.poll_interval_seconds,
|
| 210 |
+
"offline_after_s": self.control_plane.limits.offline_after_seconds,
|
| 211 |
+
}
|
| 212 |
+
)
|
| 213 |
+
except (ControlPlaneError, TypeError, ValueError) as exc:
|
| 214 |
+
return _response({"ok": False, "error": str(exc)})
|
| 215 |
+
|
| 216 |
+
def claim_state(self, agent_id: str) -> str:
|
| 217 |
+
"""Whether this worker has been claimed yet.
|
| 218 |
+
|
| 219 |
+
Says nothing a caller could not already know: a worker asks about the
|
| 220 |
+
record it created, and the answer is one of three words.
|
| 221 |
+
"""
|
| 222 |
+
|
| 223 |
+
try:
|
| 224 |
+
return _response(
|
| 225 |
+
{"ok": True, "claim_state": self.control_plane.claim_state(str(agent_id or ""))}
|
| 226 |
+
)
|
| 227 |
+
except (ControlPlaneError, TypeError, ValueError) as exc:
|
| 228 |
+
return _response({"ok": False, "error": str(exc)})
|
| 229 |
+
|
| 230 |
def sync(
|
| 231 |
self,
|
| 232 |
agent_id: str,
|
distinct_server/control_plane.py
CHANGED
|
@@ -324,6 +324,167 @@ class ControlPlane:
|
|
| 324 |
del self._pairing_codes[digest]
|
| 325 |
return AgentCredential(agent_id, encode_secret(secret), current)
|
| 326 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 327 |
def _phantom_record(self) -> AgentRecord:
|
| 328 |
"""A record for an agent that does not exist, so the checks can run.
|
| 329 |
|
|
@@ -833,6 +994,22 @@ class ControlPlane:
|
|
| 833 |
# and its grantees, which is a worker that cannot be used sitting
|
| 834 |
# in somebody's list looking like one that can.
|
| 835 |
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 836 |
if not record.private:
|
| 837 |
return True
|
| 838 |
if not viewer_id:
|
|
@@ -875,7 +1052,12 @@ class ControlPlane:
|
|
| 875 |
if not record.access_digest:
|
| 876 |
continue
|
| 877 |
if hmac.compare_digest(record.access_digest, digest):
|
| 878 |
-
if record.revoked:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 879 |
raise AuthenticationError("that code is not valid")
|
| 880 |
record.granted.add(viewer_id)
|
| 881 |
return agent_id
|
|
@@ -890,7 +1072,7 @@ class ControlPlane:
|
|
| 890 |
return tuple(
|
| 891 |
agent_id
|
| 892 |
for agent_id, record in self._agents.items()
|
| 893 |
-
if viewer_id in record.granted
|
| 894 |
)
|
| 895 |
|
| 896 |
def list_agents(
|
|
@@ -1496,6 +1678,25 @@ class ControlPlane:
|
|
| 1496 |
del self._pairing_codes[digest]
|
| 1497 |
return len(expired)
|
| 1498 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1499 |
@staticmethod
|
| 1500 |
def _cleanup_agent_nonces_locked(record: AgentRecord, now: float) -> int:
|
| 1501 |
expired = [nonce for nonce, expires_at in record.nonces.items() if expires_at <= now]
|
|
|
|
| 324 |
del self._pairing_codes[digest]
|
| 325 |
return AgentCredential(agent_id, encode_secret(secret), current)
|
| 326 |
|
| 327 |
+
def register_unclaimed(
|
| 328 |
+
self,
|
| 329 |
+
capabilities: AgentCapabilities,
|
| 330 |
+
claim_code: str,
|
| 331 |
+
*,
|
| 332 |
+
signing_public_key: bytes = b"",
|
| 333 |
+
access_code: str = "",
|
| 334 |
+
private: bool = False,
|
| 335 |
+
now: Optional[float] = None,
|
| 336 |
+
) -> AgentCredential:
|
| 337 |
+
"""Register a worker that has not been claimed by anybody yet.
|
| 338 |
+
|
| 339 |
+
PAIRING RUNS THE OTHER WAY ROUND NOW, AND THIS IS WHY.
|
| 340 |
+
|
| 341 |
+
The old flow asked a volunteer to sign in, find a pairing code on the
|
| 342 |
+
page, and paste it into a terminal. The reliable failure was somebody
|
| 343 |
+
typing a value of their own where the code went and being told, one
|
| 344 |
+
program later, that their code was invalid -- which reads as the server
|
| 345 |
+
rejecting *them* rather than as a copy that never happened.
|
| 346 |
+
|
| 347 |
+
So the code travels the other way. The worker invents one, prints it,
|
| 348 |
+
and registers here with nothing but its digest. It is issued a
|
| 349 |
+
credential immediately, because it needs one to ask whether it has been
|
| 350 |
+
claimed yet, but the record it gets is inert: ``owner_id`` is empty and
|
| 351 |
+
``claim_digest`` is set, which ``_visible_locked`` treats as "this
|
| 352 |
+
worker does not exist". Nothing can list it, select it or send it work.
|
| 353 |
+
|
| 354 |
+
That inertness is what makes an unauthenticated registration safe. What
|
| 355 |
+
remains is resource use, which is bounded two ways: a hard ceiling on
|
| 356 |
+
how many unclaimed records may exist at once, and a short expiry after
|
| 357 |
+
which they are swept.
|
| 358 |
+
"""
|
| 359 |
+
|
| 360 |
+
if not isinstance(capabilities, AgentCapabilities):
|
| 361 |
+
raise ValidationError("capabilities must be AgentCapabilities")
|
| 362 |
+
with self._lock:
|
| 363 |
+
current = self._now(now)
|
| 364 |
+
self._cleanup_unclaimed_locked(current)
|
| 365 |
+
try:
|
| 366 |
+
digest = pairing_code_digest(claim_code, self._pairing_pepper)
|
| 367 |
+
except ValueError as exc:
|
| 368 |
+
raise ValidationError("claim code is not a usable code") from exc
|
| 369 |
+
|
| 370 |
+
unclaimed = sum(1 for record in self._agents.values() if record.claim_digest)
|
| 371 |
+
if unclaimed >= self.limits.max_unclaimed_agents:
|
| 372 |
+
raise CapacityError(
|
| 373 |
+
"too many workers are waiting to be claimed; try again shortly"
|
| 374 |
+
)
|
| 375 |
+
if len(self._agents) >= self.limits.max_agents:
|
| 376 |
+
raise CapacityError("agent registry is full")
|
| 377 |
+
if any(
|
| 378 |
+
hmac.compare_digest(record.claim_digest, digest)
|
| 379 |
+
for record in self._agents.values()
|
| 380 |
+
if record.claim_digest
|
| 381 |
+
):
|
| 382 |
+
# Two workers offering one code would make the claim ambiguous,
|
| 383 |
+
# and the second one to arrive is the one that must lose.
|
| 384 |
+
raise ConflictError("that claim code is already waiting")
|
| 385 |
+
|
| 386 |
+
agent_id = capabilities.agent_id or _new_id("agent")
|
| 387 |
+
if agent_id in self._agents:
|
| 388 |
+
raise ConflictError("agent id is already registered")
|
| 389 |
+
if not capabilities.models:
|
| 390 |
+
raise ValidationError("agent must advertise at least one model")
|
| 391 |
+
if len(capabilities.models) > self.limits.max_models_per_agent:
|
| 392 |
+
raise CapacityError("agent advertises too many models")
|
| 393 |
+
if len(capabilities.tools) > self.limits.max_tools_per_agent:
|
| 394 |
+
raise CapacityError("agent advertises too many tools")
|
| 395 |
+
if not capabilities.energy_provider.strip():
|
| 396 |
+
raise ValidationError("agent must describe its energy provider")
|
| 397 |
+
if len(capabilities.energy_provider) > 256:
|
| 398 |
+
raise ValidationError("energy provider description is too long")
|
| 399 |
+
if not capabilities.agent_id:
|
| 400 |
+
capabilities = replace(capabilities, agent_id=agent_id)
|
| 401 |
+
if signing_public_key and (
|
| 402 |
+
not isinstance(signing_public_key, (bytes, bytearray))
|
| 403 |
+
or len(signing_public_key) != 32
|
| 404 |
+
):
|
| 405 |
+
raise ValidationError("an Ed25519 public key is exactly 32 bytes")
|
| 406 |
+
|
| 407 |
+
secret = generate_secret()
|
| 408 |
+
self._agents[agent_id] = AgentRecord(
|
| 409 |
+
signing_public_key=bytes(signing_public_key),
|
| 410 |
+
owner_id="",
|
| 411 |
+
access_digest=(
|
| 412 |
+
access_code_digest(access_code, self._pairing_pepper)
|
| 413 |
+
if access_code
|
| 414 |
+
else ""
|
| 415 |
+
),
|
| 416 |
+
capabilities=copy.deepcopy(capabilities),
|
| 417 |
+
secret=secret,
|
| 418 |
+
issued_at=current,
|
| 419 |
+
last_snapshot_at=current,
|
| 420 |
+
private=bool(private),
|
| 421 |
+
registered_capabilities=copy.deepcopy(capabilities),
|
| 422 |
+
claim_digest=digest,
|
| 423 |
+
claim_expires_at=current + self.limits.claim_ttl_seconds,
|
| 424 |
+
)
|
| 425 |
+
return AgentCredential(agent_id, encode_secret(secret), current)
|
| 426 |
+
|
| 427 |
+
def claim_agent(
|
| 428 |
+
self,
|
| 429 |
+
claim_code: str,
|
| 430 |
+
owner_id: str,
|
| 431 |
+
*,
|
| 432 |
+
now: Optional[float] = None,
|
| 433 |
+
) -> str:
|
| 434 |
+
"""Bind a waiting worker to the signed-in person who typed its code.
|
| 435 |
+
|
| 436 |
+
Returns the agent id. The failure message says nothing about why, for
|
| 437 |
+
the same reason ``redeem_access_code`` says nothing: an error that told
|
| 438 |
+
a caller apart "no such code" from "expired" from "already claimed"
|
| 439 |
+
would be an oracle, and this one can be asked without an account on the
|
| 440 |
+
machine that printed the code.
|
| 441 |
+
|
| 442 |
+
Signing in is the whole check. Ownership is what a claim confers -- the
|
| 443 |
+
right to be sent other people's prompts is downstream of it -- so it
|
| 444 |
+
cannot be granted to an identity the server has not authenticated.
|
| 445 |
+
"""
|
| 446 |
+
|
| 447 |
+
if not isinstance(owner_id, str) or not owner_id.strip():
|
| 448 |
+
raise AuthenticationError("that code is not valid")
|
| 449 |
+
owner_id = _single_line_identifier(owner_id, "owner_id")
|
| 450 |
+
with self._lock:
|
| 451 |
+
current = self._now(now)
|
| 452 |
+
self._cleanup_unclaimed_locked(current)
|
| 453 |
+
try:
|
| 454 |
+
digest = pairing_code_digest(claim_code, self._pairing_pepper)
|
| 455 |
+
except ValueError as exc:
|
| 456 |
+
raise AuthenticationError("that code is not valid") from exc
|
| 457 |
+
for agent_id, record in self._agents.items():
|
| 458 |
+
if not record.claim_digest:
|
| 459 |
+
continue
|
| 460 |
+
if not hmac.compare_digest(record.claim_digest, digest):
|
| 461 |
+
continue
|
| 462 |
+
if record.revoked or record.claim_expires_at <= current:
|
| 463 |
+
raise AuthenticationError("that code is not valid")
|
| 464 |
+
record.owner_id = owner_id
|
| 465 |
+
# One use. Clearing the digest is also what makes the record
|
| 466 |
+
# visible, so the two can never disagree.
|
| 467 |
+
record.claim_digest = ""
|
| 468 |
+
record.claim_expires_at = 0.0
|
| 469 |
+
return agent_id
|
| 470 |
+
raise AuthenticationError("that code is not valid")
|
| 471 |
+
|
| 472 |
+
def claim_state(self, agent_id: str, *, now: Optional[float] = None) -> str:
|
| 473 |
+
"""``"waiting"``, ``"claimed"``, or ``"gone"`` for a worker's own poll.
|
| 474 |
+
|
| 475 |
+
The worker asks about itself, having authenticated as itself, so this
|
| 476 |
+
tells it only what it already put there. It exists so a worker can stop
|
| 477 |
+
printing "waiting to be claimed" at the moment somebody claims it.
|
| 478 |
+
"""
|
| 479 |
+
|
| 480 |
+
with self._lock:
|
| 481 |
+
current = self._now(now)
|
| 482 |
+
self._cleanup_unclaimed_locked(current)
|
| 483 |
+
record = self._agents.get(agent_id)
|
| 484 |
+
if record is None or record.revoked:
|
| 485 |
+
return "gone"
|
| 486 |
+
return "waiting" if record.claim_digest else "claimed"
|
| 487 |
+
|
| 488 |
def _phantom_record(self) -> AgentRecord:
|
| 489 |
"""A record for an agent that does not exist, so the checks can run.
|
| 490 |
|
|
|
|
| 994 |
# and its grantees, which is a worker that cannot be used sitting
|
| 995 |
# in somebody's list looking like one that can.
|
| 996 |
return False
|
| 997 |
+
if record.claim_digest:
|
| 998 |
+
# AN UNCLAIMED WORKER DOES NOT EXIST YET.
|
| 999 |
+
#
|
| 1000 |
+
# Registering without a pairing code is the one operation on this
|
| 1001 |
+
# server that needs no account, which is what lets a volunteer
|
| 1002 |
+
# start a worker and claim it afterwards. The price is that anybody
|
| 1003 |
+
# can create one of these records, so until a signed-in person
|
| 1004 |
+
# enters its claim code it must be visible to nobody and reachable
|
| 1005 |
+
# by nothing.
|
| 1006 |
+
#
|
| 1007 |
+
# This is the single place that has to be right. Every listing,
|
| 1008 |
+
# every selection, both dispatch paths and the per-agent view all
|
| 1009 |
+
# ask this function first, so one `False` here keeps an unclaimed
|
| 1010 |
+
# worker out of all of them at once rather than relying on six
|
| 1011 |
+
# call sites to remember.
|
| 1012 |
+
return False
|
| 1013 |
if not record.private:
|
| 1014 |
return True
|
| 1015 |
if not viewer_id:
|
|
|
|
| 1052 |
if not record.access_digest:
|
| 1053 |
continue
|
| 1054 |
if hmac.compare_digest(record.access_digest, digest):
|
| 1055 |
+
if record.revoked or record.claim_digest:
|
| 1056 |
+
# An unclaimed worker prints its access code before
|
| 1057 |
+
# anybody owns it. Honouring that code here would let a
|
| 1058 |
+
# stranger take a share of a machine whose operator has
|
| 1059 |
+
# not finished setting it up, so it grants nothing
|
| 1060 |
+
# until the worker is claimed.
|
| 1061 |
raise AuthenticationError("that code is not valid")
|
| 1062 |
record.granted.add(viewer_id)
|
| 1063 |
return agent_id
|
|
|
|
| 1072 |
return tuple(
|
| 1073 |
agent_id
|
| 1074 |
for agent_id, record in self._agents.items()
|
| 1075 |
+
if viewer_id in record.granted and not record.claim_digest
|
| 1076 |
)
|
| 1077 |
|
| 1078 |
def list_agents(
|
|
|
|
| 1678 |
del self._pairing_codes[digest]
|
| 1679 |
return len(expired)
|
| 1680 |
|
| 1681 |
+
def _cleanup_unclaimed_locked(self, now: float) -> int:
|
| 1682 |
+
"""Forget workers nobody claimed in time.
|
| 1683 |
+
|
| 1684 |
+
An unclaimed record is the only thing on this server an unauthenticated
|
| 1685 |
+
caller can create, so it must not be something an unauthenticated caller
|
| 1686 |
+
can accumulate. It costs a slot for at most ``claim_ttl_seconds`` and is
|
| 1687 |
+
then gone; the worker holding it discovers this on its next poll and
|
| 1688 |
+
says so rather than waiting for ever.
|
| 1689 |
+
"""
|
| 1690 |
+
|
| 1691 |
+
expired = [
|
| 1692 |
+
agent_id
|
| 1693 |
+
for agent_id, record in self._agents.items()
|
| 1694 |
+
if record.claim_digest and record.claim_expires_at <= now
|
| 1695 |
+
]
|
| 1696 |
+
for agent_id in expired:
|
| 1697 |
+
del self._agents[agent_id]
|
| 1698 |
+
return len(expired)
|
| 1699 |
+
|
| 1700 |
@staticmethod
|
| 1701 |
def _cleanup_agent_nonces_locked(record: AgentRecord, now: float) -> int:
|
| 1702 |
expired = [nonce for nonce, expires_at in record.nonces.items() if expires_at <= now]
|
distinct_server/models.py
CHANGED
|
@@ -67,11 +67,21 @@ class ControlPlaneLimits:
|
|
| 67 |
#: nobody else on the server can pair a worker. A per-owner share turns
|
| 68 |
#: that from an outage into one account wasting its own allowance.
|
| 69 |
max_pairing_codes_per_owner: int = 8
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
max_nonces_per_agent: int = 2_048
|
| 71 |
max_models_per_agent: int = 64
|
| 72 |
max_tools_per_agent: int = 64
|
| 73 |
max_result_characters: int = 1_000_000
|
| 74 |
pairing_ttl_seconds: float = 600.0
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
poll_interval_seconds: float = 5.0
|
| 76 |
missed_polls_before_offline: int = 3
|
| 77 |
# A floor on the offline threshold so that lowering the poll interval
|
|
@@ -90,6 +100,7 @@ class ControlPlaneLimits:
|
|
| 90 |
"max_jobs_per_conversation",
|
| 91 |
"max_pairing_codes",
|
| 92 |
"max_pairing_codes_per_owner",
|
|
|
|
| 93 |
"max_nonces_per_agent",
|
| 94 |
"max_models_per_agent",
|
| 95 |
"max_tools_per_agent",
|
|
@@ -98,6 +109,7 @@ class ControlPlaneLimits:
|
|
| 98 |
)
|
| 99 |
duration_fields = (
|
| 100 |
"pairing_ttl_seconds",
|
|
|
|
| 101 |
"poll_interval_seconds",
|
| 102 |
"min_offline_seconds",
|
| 103 |
"request_clock_skew_seconds",
|
|
@@ -259,6 +271,18 @@ class AgentRecord:
|
|
| 259 |
#: one; that is an ordering question rather than a freshness question, and a
|
| 260 |
#: counter answers it without the two sides agreeing a clock.
|
| 261 |
snapshot_counter: int = 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 262 |
#: The capability set registered at pairing, frozen. This is the ceiling an
|
| 263 |
#: advertisement is intersected with. It must be the *pairing* set rather
|
| 264 |
#: than the current one: an agent that advertises nothing while its
|
|
|
|
| 67 |
#: nobody else on the server can pair a worker. A per-owner share turns
|
| 68 |
#: that from an outage into one account wasting its own allowance.
|
| 69 |
max_pairing_codes_per_owner: int = 8
|
| 70 |
+
#: How many workers may sit registered-but-unclaimed at once. An unclaimed
|
| 71 |
+
#: worker is the one thing here that anybody can create without signing in,
|
| 72 |
+
#: so it is the one thing that needs a ceiling and a short life. Both are
|
| 73 |
+
#: deliberately small: a claim is something a person does within a minute
|
| 74 |
+
#: of starting a worker, not something left pending.
|
| 75 |
+
max_unclaimed_agents: int = 64
|
| 76 |
max_nonces_per_agent: int = 2_048
|
| 77 |
max_models_per_agent: int = 64
|
| 78 |
max_tools_per_agent: int = 64
|
| 79 |
max_result_characters: int = 1_000_000
|
| 80 |
pairing_ttl_seconds: float = 600.0
|
| 81 |
+
#: How long an unclaimed worker waits to be claimed before the server
|
| 82 |
+
#: forgets it. Long enough to walk to the browser, short enough that an
|
| 83 |
+
#: abandoned worker does not hold a slot.
|
| 84 |
+
claim_ttl_seconds: float = 900.0
|
| 85 |
poll_interval_seconds: float = 5.0
|
| 86 |
missed_polls_before_offline: int = 3
|
| 87 |
# A floor on the offline threshold so that lowering the poll interval
|
|
|
|
| 100 |
"max_jobs_per_conversation",
|
| 101 |
"max_pairing_codes",
|
| 102 |
"max_pairing_codes_per_owner",
|
| 103 |
+
"max_unclaimed_agents",
|
| 104 |
"max_nonces_per_agent",
|
| 105 |
"max_models_per_agent",
|
| 106 |
"max_tools_per_agent",
|
|
|
|
| 109 |
)
|
| 110 |
duration_fields = (
|
| 111 |
"pairing_ttl_seconds",
|
| 112 |
+
"claim_ttl_seconds",
|
| 113 |
"poll_interval_seconds",
|
| 114 |
"min_offline_seconds",
|
| 115 |
"request_clock_skew_seconds",
|
|
|
|
| 271 |
#: one; that is an ordering question rather than a freshness question, and a
|
| 272 |
#: counter answers it without the two sides agreeing a clock.
|
| 273 |
snapshot_counter: int = 0
|
| 274 |
+
#: Set while this worker is registered but not yet claimed by anybody.
|
| 275 |
+
#:
|
| 276 |
+
#: A worker started without a pairing code generates its own claim code,
|
| 277 |
+
#: prints it, and registers here holding only the digest. Until somebody
|
| 278 |
+
#: signed in enters that code, ``owner_id`` is empty and the record is
|
| 279 |
+
#: invisible to every viewer and every dispatch path -- see
|
| 280 |
+
#: ``ControlPlane._visible_locked``, which treats a non-empty claim digest
|
| 281 |
+
#: as "this worker does not exist yet". Claiming clears it.
|
| 282 |
+
claim_digest: str = ""
|
| 283 |
+
#: When an unclaimed record stops being claimable and is swept.
|
| 284 |
+
claim_expires_at: float = 0.0
|
| 285 |
+
|
| 286 |
#: The capability set registered at pairing, frozen. This is the ceiling an
|
| 287 |
#: advertisement is intersected with. It must be the *pairing* set rather
|
| 288 |
#: than the current one: an agent that advertises nothing while its
|
distinct_server/render.py
CHANGED
|
@@ -366,13 +366,23 @@ def join_this_server(
|
|
| 366 |
|
| 367 |
# STEP FOUR: the run, with this server's address and this browser's own
|
| 368 |
# library defaults already in it.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 369 |
code_note = (
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
if code
|
| 373 |
-
else '<p class="c-join__note">Sign in at the top of this page and a pairing '
|
| 374 |
-
"code appears here. Until then the command below is complete apart from "
|
| 375 |
-
"that one value.</p>"
|
| 376 |
)
|
| 377 |
# One line on purpose. The previous form used backslash continuations,
|
| 378 |
# which are bash syntax: pasted into PowerShell they became stray
|
|
@@ -380,7 +390,7 @@ def join_this_server(
|
|
| 380 |
# into every shell anybody actually uses.
|
| 381 |
arguments = (
|
| 382 |
f"-m distinct_agent --server {first} "
|
| 383 |
-
f"--
|
| 384 |
)
|
| 385 |
run_it = _command_block(f"py -3 {arguments}", platform="win") + _command_block(
|
| 386 |
f"python3 {arguments}", platform="nix"
|
|
@@ -401,9 +411,13 @@ def join_this_server(
|
|
| 401 |
'<p class="c-step-num__head">Get something to run the models with</p>'
|
| 402 |
f"{runner}</div></li>"
|
| 403 |
'<li class="c-step-num"><span class="c-step-num__n">4</span>'
|
| 404 |
-
'<div class="c-step-num__body"><p class="c-step-num__head">
|
| 405 |
f'<code class="c-join__address">{esc(first)}</code>{alternates}{code_note}'
|
| 406 |
f"{run_it}"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 407 |
f"{picture}</div></li>"
|
| 408 |
# THE LAST STEP EXISTS BECAUSE PEOPLE ASK FOR IT AFTER READING THE ONE
|
| 409 |
# BEFORE IT.
|
|
|
|
| 366 |
|
| 367 |
# STEP FOUR: the run, with this server's address and this browser's own
|
| 368 |
# library defaults already in it.
|
| 369 |
+
# NOTHING IS COPIED OUT OF THIS PAGE ANY MORE.
|
| 370 |
+
#
|
| 371 |
+
# This used to show a server-minted pairing code and a command with a
|
| 372 |
+
# `--pair YOUR-CODE` placeholder in it, and ask the volunteer to carry the
|
| 373 |
+
# one into the other. What people did instead -- reasonably, reading a
|
| 374 |
+
# placeholder as a thing to fill in however they liked -- was invent a
|
| 375 |
+
# value, and find out one program later that "your code wasn't valid",
|
| 376 |
+
# which reads as the server rejecting them rather than as a copy that never
|
| 377 |
+
# happened. Signed out, there was not even a code on the page to copy.
|
| 378 |
+
#
|
| 379 |
+
# The code now travels the other way: the worker invents one, prints it,
|
| 380 |
+
# and waits, and it is typed into the box on this page. There is nothing
|
| 381 |
+
# here to transcribe, so there is nothing to transcribe wrongly, and the
|
| 382 |
+
# command is complete for everybody whether they are signed in or not.
|
| 383 |
code_note = (
|
| 384 |
+
'<p class="c-join__note">The command below is complete as it stands. '
|
| 385 |
+
"When you run it, the worker prints a code and waits.</p>"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 386 |
)
|
| 387 |
# One line on purpose. The previous form used backslash continuations,
|
| 388 |
# which are bash syntax: pasted into PowerShell they became stray
|
|
|
|
| 390 |
# into every shell anybody actually uses.
|
| 391 |
arguments = (
|
| 392 |
f"-m distinct_agent --server {first} "
|
| 393 |
+
f"--models olmoe-1b-7b-0924-instruct --tools {tools or 'local'}"
|
| 394 |
)
|
| 395 |
run_it = _command_block(f"py -3 {arguments}", platform="win") + _command_block(
|
| 396 |
f"python3 {arguments}", platform="nix"
|
|
|
|
| 411 |
'<p class="c-step-num__head">Get something to run the models with</p>'
|
| 412 |
f"{runner}</div></li>"
|
| 413 |
'<li class="c-step-num"><span class="c-step-num__n">4</span>'
|
| 414 |
+
'<div class="c-step-num__body"><p class="c-step-num__head">Run it, and claim it</p>'
|
| 415 |
f'<code class="c-join__address">{esc(first)}</code>{alternates}{code_note}'
|
| 416 |
f"{run_it}"
|
| 417 |
+
'<p class="c-join__note">The worker prints a code and waits. Put that code '
|
| 418 |
+
"into <em>Add a worker</em> on this page while signed in, and the machine "
|
| 419 |
+
"becomes yours. Until you do, nobody can see it and nothing can be sent "
|
| 420 |
+
"to it.</p>"
|
| 421 |
f"{picture}</div></li>"
|
| 422 |
# THE LAST STEP EXISTS BECAUSE PEOPLE ASK FOR IT AFTER READING THE ONE
|
| 423 |
# BEFORE IT.
|
distinct_server/ui.py
CHANGED
|
@@ -2228,7 +2228,27 @@ class DistinctUI:
|
|
| 2228 |
profile: "gr.OAuthProfile | None",
|
| 2229 |
request: "gr.Request | None" = None,
|
| 2230 |
) -> tuple:
|
| 2231 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2232 |
|
| 2233 |
Signing in is checked first and separately from the code, because they
|
| 2234 |
are two different refusals: one is "we do not know who you are" and the
|
|
@@ -2251,14 +2271,23 @@ class DistinctUI:
|
|
| 2251 |
render.worker_cards(rows, narrowed_by=narrowed),
|
| 2252 |
)
|
| 2253 |
|
|
|
|
| 2254 |
try:
|
| 2255 |
-
self.control.
|
| 2256 |
message = _status_html(
|
| 2257 |
-
"That worker is
|
| 2258 |
-
"is online and offers the model and mode you have
|
|
|
|
| 2259 |
)
|
| 2260 |
except (ControlPlaneError, ValueError):
|
| 2261 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2262 |
|
| 2263 |
rows, choices, value, narrowed = self._agent_components(
|
| 2264 |
model_id, tool_values, selected_agent, mode, viewer_id=viewer_id
|
|
@@ -3282,83 +3311,48 @@ class DistinctUI:
|
|
| 3282 |
*,
|
| 3283 |
force: bool = False,
|
| 3284 |
) -> tuple:
|
| 3285 |
-
"""
|
| 3286 |
-
|
| 3287 |
-
|
| 3288 |
-
|
| 3289 |
-
|
| 3290 |
-
|
| 3291 |
-
|
| 3292 |
-
|
| 3293 |
-
|
| 3294 |
-
|
| 3295 |
-
|
| 3296 |
-
|
| 3297 |
-
|
| 3298 |
-
|
| 3299 |
-
|
| 3300 |
-
|
| 3301 |
-
|
| 3302 |
-
|
| 3303 |
-
|
| 3304 |
-
|
| 3305 |
-
|
| 3306 |
-
|
| 3307 |
-
|
| 3308 |
-
|
| 3309 |
-
|
| 3310 |
-
|
| 3311 |
-
|
| 3312 |
-
|
| 3313 |
-
|
| 3314 |
-
|
| 3315 |
-
Nothing here is worth a toast. The panel already reads correctly with
|
| 3316 |
-
no code in it — it is four steps, and the code belongs to one of them —
|
| 3317 |
-
so the reason a code was not minted is written into the panel, where
|
| 3318 |
-
the person is already looking, and the section opens either way.
|
| 3319 |
"""
|
| 3320 |
|
| 3321 |
updated = dict(state) if isinstance(state, Mapping) else {}
|
| 3322 |
-
|
| 3323 |
-
|
| 3324 |
-
|
| 3325 |
-
|
| 3326 |
-
|
| 3327 |
-
updated,
|
| 3328 |
-
_status_html(
|
| 3329 |
-
"Sign in with Hugging Face to get a pairing code. The rest of "
|
| 3330 |
-
"the setup is the same either way, so it is all here to read "
|
| 3331 |
-
"first."
|
| 3332 |
-
)
|
| 3333 |
-
+ self._join_panel("", updated),
|
| 3334 |
-
)
|
| 3335 |
-
existing = str(updated.get("pairing_code") or "")
|
| 3336 |
-
expires = updated.get("pairing_expires")
|
| 3337 |
-
alive = (
|
| 3338 |
-
existing
|
| 3339 |
-
and isinstance(expires, (int, float))
|
| 3340 |
-
# A minute's margin: a code that expires while somebody is
|
| 3341 |
-
# pasting it is worse than one re-minted slightly early.
|
| 3342 |
-
and expires - 60 > time.time()
|
| 3343 |
-
)
|
| 3344 |
-
if alive and not force:
|
| 3345 |
-
return updated, updated, self._join_panel(existing, updated)
|
| 3346 |
-
try:
|
| 3347 |
-
value = self.control.create_pairing_code(user_id)
|
| 3348 |
-
except (ControlPlaneError, ValueError) as exc:
|
| 3349 |
-
return (
|
| 3350 |
-
updated,
|
| 3351 |
-
updated,
|
| 3352 |
-
_status_html(f"No new pairing code just now: {exc}")
|
| 3353 |
-
+ self._join_panel(existing, updated),
|
| 3354 |
-
)
|
| 3355 |
-
updated["pairing_code"] = value.code
|
| 3356 |
-
updated["pairing_expires"] = value.expires_at
|
| 3357 |
-
return updated, updated, self._join_panel(value.code, updated)
|
| 3358 |
|
| 3359 |
-
# `new_pairing_code`
|
| 3360 |
-
#
|
| 3361 |
-
#
|
|
|
|
| 3362 |
|
| 3363 |
def build(self) -> gr.Blocks:
|
| 3364 |
# ``head`` belongs to launch() in Gradio 6, not to the constructor; the
|
|
@@ -3569,7 +3563,7 @@ class DistinctUI:
|
|
| 3569 |
# and entering it here is that consent arriving.
|
| 3570 |
with gr.Row():
|
| 3571 |
access_code = gr.Textbox(
|
| 3572 |
-
label="Add a worker ·
|
| 3573 |
placeholder="XXXXX-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX-XXXX",
|
| 3574 |
lines=1,
|
| 3575 |
container=True,
|
|
@@ -3577,10 +3571,11 @@ class DistinctUI:
|
|
| 3577 |
access_add = gr.Button("Add worker", variant="secondary")
|
| 3578 |
access_status = gr.HTML(
|
| 3579 |
_status_html(
|
| 3580 |
-
"A worker prints
|
| 3581 |
-
"
|
| 3582 |
-
"
|
| 3583 |
-
"not use it up."
|
|
|
|
| 3584 |
)
|
| 3585 |
)
|
| 3586 |
# Boundary 1. Boundary 2 lives in its own panel far
|
|
@@ -4218,6 +4213,29 @@ class DistinctUI:
|
|
| 4218 |
api_name="agent_pair",
|
| 4219 |
queue=False,
|
| 4220 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4221 |
signed_inputs = [api_agent_id, api_timestamp, api_nonce, api_payload, api_signature]
|
| 4222 |
api_catalogue_button.click(
|
| 4223 |
self.agent_api.catalogue,
|
|
|
|
| 2228 |
profile: "gr.OAuthProfile | None",
|
| 2229 |
request: "gr.Request | None" = None,
|
| 2230 |
) -> tuple:
|
| 2231 |
+
"""Take a code and do whatever that code means.
|
| 2232 |
+
|
| 2233 |
+
ONE BOX, BECAUSE A PERSON HAS ONE CODE.
|
| 2234 |
+
|
| 2235 |
+
A worker prints two codes over its life: a **claim code** while it waits
|
| 2236 |
+
to be adopted, and an **access code** afterwards for the people its
|
| 2237 |
+
operator wants to share it with. Those are different things and they are
|
| 2238 |
+
entered by different people, but nobody standing at this field knows or
|
| 2239 |
+
cares which kind they are holding -- they were handed a code and told to
|
| 2240 |
+
put it in. Two fields would make choosing wrongly possible, and choosing
|
| 2241 |
+
wrongly would produce "that code is not valid" for a code that was
|
| 2242 |
+
perfectly valid in the box next door. That is the exact shape of the
|
| 2243 |
+
failure this whole change exists to remove.
|
| 2244 |
+
|
| 2245 |
+
So the claim is tried first and access second, and a code that is
|
| 2246 |
+
neither is refused in the words both refusals already used. Both are
|
| 2247 |
+
192-bit random strings drawn from the same alphabet, so in principle one
|
| 2248 |
+
value could be both; at 2**-192 that is not a case worth designing
|
| 2249 |
+
around, and the order makes it deterministic if it ever happened.
|
| 2250 |
+
Holding a code is the authorisation in either reading, so trying both
|
| 2251 |
+
grants nothing that trying one would not.
|
| 2252 |
|
| 2253 |
Signing in is checked first and separately from the code, because they
|
| 2254 |
are two different refusals: one is "we do not know who you are" and the
|
|
|
|
| 2271 |
render.worker_cards(rows, narrowed_by=narrowed),
|
| 2272 |
)
|
| 2273 |
|
| 2274 |
+
typed = str(access_code or "")
|
| 2275 |
try:
|
| 2276 |
+
self.control.claim_agent(typed, viewer_id)
|
| 2277 |
message = _status_html(
|
| 2278 |
+
"That worker is yours. It is now running for you, and it appears "
|
| 2279 |
+
"below when it is online and offers the model and mode you have "
|
| 2280 |
+
"chosen. Share its access code with anyone else who should use it."
|
| 2281 |
)
|
| 2282 |
except (ControlPlaneError, ValueError):
|
| 2283 |
+
try:
|
| 2284 |
+
self.control.redeem_access_code(typed, viewer_id)
|
| 2285 |
+
message = _status_html(
|
| 2286 |
+
"That worker is now available to you. It appears below when it "
|
| 2287 |
+
"is online and offers the model and mode you have chosen."
|
| 2288 |
+
)
|
| 2289 |
+
except (ControlPlaneError, ValueError):
|
| 2290 |
+
message = _status_html("That code is not valid.")
|
| 2291 |
|
| 2292 |
rows, choices, value, narrowed = self._agent_components(
|
| 2293 |
model_id, tool_values, selected_agent, mode, viewer_id=viewer_id
|
|
|
|
| 3311 |
*,
|
| 3312 |
force: bool = False,
|
| 3313 |
) -> tuple:
|
| 3314 |
+
"""Render the setup panel. Nothing is minted, and nothing can fail.
|
| 3315 |
+
|
| 3316 |
+
THIS HANDLER USED TO ISSUE A CREDENTIAL, AND THAT WAS THE PROBLEM.
|
| 3317 |
+
|
| 3318 |
+
Opening a section is navigation. This one minted a server-side pairing
|
| 3319 |
+
code every time it ran, which produced three separate faults:
|
| 3320 |
+
|
| 3321 |
+
* The code changed under people. Open the panel, copy the code, open it
|
| 3322 |
+
again to re-read the command, and the clipboard no longer matched the
|
| 3323 |
+
page.
|
| 3324 |
+
* It was an outage waiting to happen. Opening a section is cheap and
|
| 3325 |
+
scriptable, live codes came from one pool shared by everybody on this
|
| 3326 |
+
server, and they lasted ten minutes -- so a loop here filled the pool
|
| 3327 |
+
and stopped anyone else pairing a worker.
|
| 3328 |
+
* It could refuse. Signed out it raised, so clicking "Run a community
|
| 3329 |
+
agent" to find out what running one involves produced a red toast and
|
| 3330 |
+
a 500 in the log for the crime of reading a page.
|
| 3331 |
+
|
| 3332 |
+
Each was patched in turn -- reuse the browser's live code, cap codes per
|
| 3333 |
+
owner, catch the refusals -- and the patches were all correct and all
|
| 3334 |
+
beside the point. The panel needed a code only because the flow made a
|
| 3335 |
+
volunteer carry one from this page to their terminal, and that flow is
|
| 3336 |
+
gone: the worker prints its own code and the person types it into the
|
| 3337 |
+
box on this page instead.
|
| 3338 |
+
|
| 3339 |
+
So there is nothing to mint, nothing to run out of, nothing to expire
|
| 3340 |
+
under anybody, and nothing that needs an account to read. The signature
|
| 3341 |
+
keeps ``state`` and ``force`` because the accordion and the tests are
|
| 3342 |
+
wired to them, and both are now ignored.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3343 |
"""
|
| 3344 |
|
| 3345 |
updated = dict(state) if isinstance(state, Mapping) else {}
|
| 3346 |
+
# Kept only so a browser that saved one before this change does not
|
| 3347 |
+
# carry it around for ever.
|
| 3348 |
+
updated.pop("pairing_code", None)
|
| 3349 |
+
updated.pop("pairing_expires", None)
|
| 3350 |
+
return updated, updated, self._join_panel("", updated)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3351 |
|
| 3352 |
+
# `new_pairing_code` and the "New code" button are both gone, and so is the
|
| 3353 |
+
# minting they existed to trigger. `ControlPlane.create_pairing_code` stays
|
| 3354 |
+
# for `--pair`, which scripted setups still use: there a code is minted and
|
| 3355 |
+
# consumed by the same program and nobody has to read anything.
|
| 3356 |
|
| 3357 |
def build(self) -> gr.Blocks:
|
| 3358 |
# ``head`` belongs to launch() in Gradio 6, not to the constructor; the
|
|
|
|
| 3563 |
# and entering it here is that consent arriving.
|
| 3564 |
with gr.Row():
|
| 3565 |
access_code = gr.Textbox(
|
| 3566 |
+
label="Add a worker · enter the code your worker printed, or one somebody shared with you",
|
| 3567 |
placeholder="XXXXX-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX-XXXX",
|
| 3568 |
lines=1,
|
| 3569 |
container=True,
|
|
|
|
| 3571 |
access_add = gr.Button("Add worker", variant="secondary")
|
| 3572 |
access_status = gr.HTML(
|
| 3573 |
_status_html(
|
| 3574 |
+
"A worker you started prints a code and waits here for "
|
| 3575 |
+
"you to enter it; that claims it, once. A worker somebody "
|
| 3576 |
+
"else runs prints a code they can give to any number of "
|
| 3577 |
+
"people, and entering that one does not use it up. Both "
|
| 3578 |
+
"go in this box."
|
| 3579 |
)
|
| 3580 |
)
|
| 3581 |
# Boundary 1. Boundary 2 lives in its own panel far
|
|
|
|
| 4213 |
api_name="agent_pair",
|
| 4214 |
queue=False,
|
| 4215 |
)
|
| 4216 |
+
# THE OTHER DIRECTION. `agent_pair` takes a code the server minted;
|
| 4217 |
+
# `agent_register` takes one the worker minted, and creates a record
|
| 4218 |
+
# that is inert until somebody signed in claims it. Same inputs, so
|
| 4219 |
+
# the same hidden controls carry both.
|
| 4220 |
+
api_pair_button.click(
|
| 4221 |
+
self.agent_api.register,
|
| 4222 |
+
inputs=[
|
| 4223 |
+
api_pair_code,
|
| 4224 |
+
api_pair_capabilities,
|
| 4225 |
+
api_pair_pubkey,
|
| 4226 |
+
api_pair_access,
|
| 4227 |
+
],
|
| 4228 |
+
outputs=api_pair_output,
|
| 4229 |
+
api_name="agent_register",
|
| 4230 |
+
queue=False,
|
| 4231 |
+
)
|
| 4232 |
+
api_pair_button.click(
|
| 4233 |
+
self.agent_api.claim_state,
|
| 4234 |
+
inputs=[api_pair_code],
|
| 4235 |
+
outputs=api_pair_output,
|
| 4236 |
+
api_name="agent_claim_state",
|
| 4237 |
+
queue=False,
|
| 4238 |
+
)
|
| 4239 |
signed_inputs = [api_agent_id, api_timestamp, api_nonce, api_payload, api_signature]
|
| 4240 |
api_catalogue_button.click(
|
| 4241 |
self.agent_api.catalogue,
|
llama_runtime.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"_comment": [
|
| 3 |
+
"Pinned llama.cpp builds, one per platform, fetched and verified by",
|
| 4 |
+
"distinct_agent/runtime.py. A worker downloads the build recorded here and",
|
| 5 |
+
"refuses to install it unless the bytes hash to `sha256`.",
|
| 6 |
+
"",
|
| 7 |
+
"A platform with no entry gets no automatic download. That is deliberate:",
|
| 8 |
+
"this project already withholds a MODEL whose digest it cannot check, and",
|
| 9 |
+
"the program that executes those models is held to the same bar. Operators",
|
| 10 |
+
"on those platforms pass --llama-server, or run --demo-runner and offer the",
|
| 11 |
+
"library's tools and skills with no model at all.",
|
| 12 |
+
"",
|
| 13 |
+
"TO ADD OR MOVE A PIN: download the asset, hash it, and record the result",
|
| 14 |
+
"here. `scripts/record_llama_pin.py` does exactly that and prints the JSON.",
|
| 15 |
+
"Never copy a digest from a release page or an API response into this file:",
|
| 16 |
+
"a digest that arrived with the download proves only that the bytes were",
|
| 17 |
+
"not corrupted in transit, which TLS already said, and pinning it would",
|
| 18 |
+
"dress trust-on-first-use up as verification."
|
| 19 |
+
],
|
| 20 |
+
"builds": {
|
| 21 |
+
"windows-x64": {
|
| 22 |
+
"tag": "b10742",
|
| 23 |
+
"asset": "llama-b10742-bin-win-cpu-x64.zip",
|
| 24 |
+
"url": "https://github.com/ggml-org/llama.cpp/releases/download/b10742/llama-b10742-bin-win-cpu-x64.zip",
|
| 25 |
+
"sha256": "a923d80953d618335ae0073233fdcfb93760dfde646e957786894259aba87d72",
|
| 26 |
+
"bytes": 18373032
|
| 27 |
+
}
|
| 28 |
+
}
|
| 29 |
+
}
|
scripts/record_llama_pin.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Record a llama.cpp build for `llama_runtime.json`, by downloading and hashing it.
|
| 2 |
+
|
| 3 |
+
The digest in that file has to be produced by someone who fetched the bytes and
|
| 4 |
+
hashed them. Copying a hash out of a release page or an API response would pin
|
| 5 |
+
the same origin that served the download, which proves nothing TLS had not
|
| 6 |
+
already said -- and this project withholds a *model* on exactly that reasoning.
|
| 7 |
+
|
| 8 |
+
Usage::
|
| 9 |
+
|
| 10 |
+
python scripts/record_llama_pin.py windows-x64 # newest build
|
| 11 |
+
python scripts/record_llama_pin.py linux-x64 --tag b10742
|
| 12 |
+
|
| 13 |
+
Prints a JSON fragment to paste into `llama_runtime.json`, and exits non-zero if
|
| 14 |
+
no matching asset was found. It never writes the file itself: adding a pin
|
| 15 |
+
should be a diff somebody reads.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import argparse
|
| 21 |
+
import hashlib
|
| 22 |
+
import json
|
| 23 |
+
import sys
|
| 24 |
+
import urllib.request
|
| 25 |
+
|
| 26 |
+
RELEASES = "https://api.github.com/repos/ggml-org/llama.cpp/releases?per_page=%d"
|
| 27 |
+
|
| 28 |
+
#: What a platform's asset name looks like, and what must not be in it. The
|
| 29 |
+
#: exclusions pick the build that runs anywhere over the one that runs fastest:
|
| 30 |
+
#: an operator who wants CUDA or Metal passes --llama-server.
|
| 31 |
+
PATTERNS = {
|
| 32 |
+
"windows-x64": (("win", "x64"), ("cuda", "hip", "sycl", "arm64", "vulkan")),
|
| 33 |
+
"windows-arm64": (("win", "arm64"), ("cuda", "hip", "sycl")),
|
| 34 |
+
"linux-x64": (("ubuntu", "x64"), ("cuda", "hip", "sycl", "arm64", "vulkan")),
|
| 35 |
+
"macos-arm64": (("macos", "arm64"), ()),
|
| 36 |
+
"macos-x64": (("macos", "x64"), ()),
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _get(url: str) -> bytes:
|
| 41 |
+
request = urllib.request.Request(url, headers={"User-Agent": "distinct-pin"})
|
| 42 |
+
with urllib.request.urlopen(request, timeout=60) as response: # noqa: S310
|
| 43 |
+
return response.read()
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def main(argv: list[str]) -> int:
|
| 47 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 48 |
+
parser.add_argument("platform", choices=sorted(PATTERNS))
|
| 49 |
+
parser.add_argument("--tag", default="", help="a specific build tag, e.g. b10742")
|
| 50 |
+
parser.add_argument("--scan", type=int, default=15, help="how many releases to look through")
|
| 51 |
+
args = parser.parse_args(argv)
|
| 52 |
+
|
| 53 |
+
wanted, refused = PATTERNS[args.platform]
|
| 54 |
+
releases = json.loads(_get(RELEASES % args.scan))
|
| 55 |
+
for release in releases:
|
| 56 |
+
if args.tag and release.get("tag_name") != args.tag:
|
| 57 |
+
continue
|
| 58 |
+
for asset in release.get("assets", []):
|
| 59 |
+
name = str(asset.get("name", "")).casefold()
|
| 60 |
+
if not name.endswith(".zip"):
|
| 61 |
+
continue
|
| 62 |
+
if not all(part in name for part in wanted):
|
| 63 |
+
continue
|
| 64 |
+
if any(part in name for part in refused):
|
| 65 |
+
continue
|
| 66 |
+
url = asset["browser_download_url"]
|
| 67 |
+
print(f"downloading {asset['name']} ({asset.get('size', 0)} bytes)", file=sys.stderr)
|
| 68 |
+
payload = _get(url)
|
| 69 |
+
print(
|
| 70 |
+
json.dumps(
|
| 71 |
+
{
|
| 72 |
+
args.platform: {
|
| 73 |
+
"tag": release["tag_name"],
|
| 74 |
+
"asset": asset["name"],
|
| 75 |
+
"url": url,
|
| 76 |
+
"sha256": hashlib.sha256(payload).hexdigest(),
|
| 77 |
+
"bytes": len(payload),
|
| 78 |
+
}
|
| 79 |
+
},
|
| 80 |
+
indent=2,
|
| 81 |
+
)
|
| 82 |
+
)
|
| 83 |
+
return 0
|
| 84 |
+
print(
|
| 85 |
+
f"no asset matching {wanted} found in the last {args.scan} releases",
|
| 86 |
+
file=sys.stderr,
|
| 87 |
+
)
|
| 88 |
+
return 1
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
if __name__ == "__main__":
|
| 92 |
+
raise SystemExit(main(sys.argv[1:]))
|
tests/test_claim_flow.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Registering without an account must create something that can do nothing.
|
| 2 |
+
|
| 3 |
+
Pairing runs the other way round now: a worker invents a code, prints it, and
|
| 4 |
+
registers here, and somebody signed in enters that code to claim it. That makes
|
| 5 |
+
``register_unclaimed`` the only entry point on this server that needs no
|
| 6 |
+
account, so every test below is about the same question -- what can a caller do
|
| 7 |
+
with a record nobody has claimed? The answer has to be "nothing".
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import pytest
|
| 13 |
+
|
| 14 |
+
from distinct_server.control_plane import UNASSIGNED_AGENT, ControlPlane
|
| 15 |
+
from distinct_server.models import (
|
| 16 |
+
AgentCapabilities,
|
| 17 |
+
AuthenticationError,
|
| 18 |
+
CapacityError,
|
| 19 |
+
ConflictError,
|
| 20 |
+
)
|
| 21 |
+
from distinct_server.security import new_access_code, new_pairing_code
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _caps(name: str = "worker") -> AgentCapabilities:
|
| 25 |
+
return AgentCapabilities(
|
| 26 |
+
agent_id="",
|
| 27 |
+
name=name,
|
| 28 |
+
os="linux",
|
| 29 |
+
arch="x64",
|
| 30 |
+
cpu="test",
|
| 31 |
+
ram_gb=8.0,
|
| 32 |
+
models=("m1",),
|
| 33 |
+
tools=(),
|
| 34 |
+
energy_provider="test meter",
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@pytest.fixture()
|
| 39 |
+
def plane() -> ControlPlane:
|
| 40 |
+
return ControlPlane()
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def test_an_unclaimed_worker_is_invisible_to_everyone(plane: ControlPlane) -> None:
|
| 44 |
+
plane.register_unclaimed(_caps(), new_pairing_code())
|
| 45 |
+
assert plane.list_agents() == ()
|
| 46 |
+
assert plane.list_agents(viewer_id="user:alice") == ()
|
| 47 |
+
assert plane.list_agents(viewer_id="user:mallory") == ()
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def test_an_unclaimed_workers_access_code_grants_nothing(plane: ControlPlane) -> None:
|
| 51 |
+
"""Otherwise a stranger takes a share of a machine still being set up."""
|
| 52 |
+
|
| 53 |
+
access = new_access_code()
|
| 54 |
+
plane.register_unclaimed(_caps(), new_pairing_code(), access_code=access)
|
| 55 |
+
with pytest.raises(AuthenticationError):
|
| 56 |
+
plane.redeem_access_code(access, "user:mallory")
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def test_claiming_requires_an_account(plane: ControlPlane) -> None:
|
| 60 |
+
code = new_pairing_code()
|
| 61 |
+
plane.register_unclaimed(_caps(), code)
|
| 62 |
+
with pytest.raises(AuthenticationError):
|
| 63 |
+
plane.claim_agent(code, "")
|
| 64 |
+
with pytest.raises(AuthenticationError):
|
| 65 |
+
plane.claim_agent(code, " ")
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def test_a_wrong_code_says_nothing_about_why(plane: ControlPlane) -> None:
|
| 69 |
+
plane.register_unclaimed(_caps(), new_pairing_code())
|
| 70 |
+
with pytest.raises(AuthenticationError) as caught:
|
| 71 |
+
plane.claim_agent(new_pairing_code(), "user:alice")
|
| 72 |
+
assert str(caught.value) == "that code is not valid"
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def test_claiming_makes_the_worker_real(plane: ControlPlane) -> None:
|
| 76 |
+
code = new_pairing_code()
|
| 77 |
+
access = new_access_code()
|
| 78 |
+
credential = plane.register_unclaimed(_caps(), code, access_code=access)
|
| 79 |
+
assert plane.claim_state(credential.agent_id) == "waiting"
|
| 80 |
+
|
| 81 |
+
assert plane.claim_agent(code, "user:alice") == credential.agent_id
|
| 82 |
+
assert plane.claim_state(credential.agent_id) == "claimed"
|
| 83 |
+
assert len(plane.list_agents()) == 1
|
| 84 |
+
# and only now does its access code mean anything
|
| 85 |
+
assert plane.redeem_access_code(access, "user:bob") == credential.agent_id
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def test_a_claim_code_is_used_once(plane: ControlPlane) -> None:
|
| 89 |
+
code = new_pairing_code()
|
| 90 |
+
plane.register_unclaimed(_caps(), code)
|
| 91 |
+
plane.claim_agent(code, "user:alice")
|
| 92 |
+
with pytest.raises(AuthenticationError):
|
| 93 |
+
plane.claim_agent(code, "user:bob")
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def test_two_workers_cannot_wait_on_one_code(plane: ControlPlane) -> None:
|
| 97 |
+
"""An ambiguous claim is worse than a refused registration."""
|
| 98 |
+
|
| 99 |
+
code = new_pairing_code()
|
| 100 |
+
plane.register_unclaimed(_caps("first"), code)
|
| 101 |
+
with pytest.raises(ConflictError):
|
| 102 |
+
plane.register_unclaimed(_caps("second"), code)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def test_an_unclaimed_worker_expires(plane: ControlPlane) -> None:
|
| 106 |
+
code = new_pairing_code()
|
| 107 |
+
credential = plane.register_unclaimed(_caps(), code, now=1000.0)
|
| 108 |
+
later = 1000.0 + plane.limits.claim_ttl_seconds + 1
|
| 109 |
+
assert plane.claim_state(credential.agent_id, now=later) == "gone"
|
| 110 |
+
with pytest.raises(AuthenticationError):
|
| 111 |
+
plane.claim_agent(code, "user:alice", now=later)
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def test_unclaimed_registrations_have_a_ceiling(plane: ControlPlane) -> None:
|
| 115 |
+
"""The one unauthenticated write on this server cannot be accumulated."""
|
| 116 |
+
|
| 117 |
+
made = 0
|
| 118 |
+
with pytest.raises(CapacityError):
|
| 119 |
+
for _ in range(plane.limits.max_unclaimed_agents + 10):
|
| 120 |
+
plane.register_unclaimed(_caps(), new_pairing_code())
|
| 121 |
+
made += 1
|
| 122 |
+
assert made == plane.limits.max_unclaimed_agents
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def test_expiry_frees_the_ceiling(plane: ControlPlane) -> None:
|
| 126 |
+
for _ in range(plane.limits.max_unclaimed_agents):
|
| 127 |
+
plane.register_unclaimed(_caps(), new_pairing_code(), now=1000.0)
|
| 128 |
+
later = 1000.0 + plane.limits.claim_ttl_seconds + 1
|
| 129 |
+
plane.register_unclaimed(_caps(), new_pairing_code(), now=later)
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def test_no_job_can_be_sent_to_an_unclaimed_worker(plane: ControlPlane) -> None:
|
| 133 |
+
"""The visibility gate is what dispatch consults, so this is the proof."""
|
| 134 |
+
|
| 135 |
+
credential = plane.register_unclaimed(_caps(), new_pairing_code())
|
| 136 |
+
session = plane.create_session()
|
| 137 |
+
conversation = plane.create_conversation(session.session_id)
|
| 138 |
+
job = plane.submit_job(
|
| 139 |
+
session.session_id,
|
| 140 |
+
conversation.conversation_id,
|
| 141 |
+
"hello",
|
| 142 |
+
"m1",
|
| 143 |
+
viewer_id="user:alice",
|
| 144 |
+
)
|
| 145 |
+
# Nothing to run it, so it is stranded rather than handed to the worker
|
| 146 |
+
# that is sitting right there unclaimed.
|
| 147 |
+
assert job.spec.target_agent_id != credential.agent_id
|
| 148 |
+
assert job.spec.target_agent_id == UNASSIGNED_AGENT
|
tests/test_llama_runtime.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The runtime fetcher must be as careful as the weights fetcher.
|
| 2 |
+
|
| 3 |
+
It downloads an executable from another project and puts it on a volunteer's
|
| 4 |
+
machine, so every one of these is a property somebody would be harmed by losing:
|
| 5 |
+
the digest is checked before anything is installed, an archive cannot write
|
| 6 |
+
outside the runtime directory, and an archive cannot fill the disk.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import hashlib
|
| 12 |
+
import io
|
| 13 |
+
import json
|
| 14 |
+
import zipfile
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
|
| 17 |
+
import pytest
|
| 18 |
+
|
| 19 |
+
from distinct_agent import runtime
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _archive(names=("llama-server.exe", "ggml.dll"), size=32, deflate=False) -> bytes:
|
| 23 |
+
buf = io.BytesIO()
|
| 24 |
+
mode = zipfile.ZIP_DEFLATED if deflate else zipfile.ZIP_STORED
|
| 25 |
+
with zipfile.ZipFile(buf, "w", mode) as handle:
|
| 26 |
+
for name in names:
|
| 27 |
+
handle.writestr(f"build/bin/{name}", b"x" * size)
|
| 28 |
+
return buf.getvalue()
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _opener(payload: bytes):
|
| 32 |
+
class _Response:
|
| 33 |
+
headers = {"Content-Length": str(len(payload))}
|
| 34 |
+
|
| 35 |
+
def __init__(self) -> None:
|
| 36 |
+
self._buffer = io.BytesIO(payload)
|
| 37 |
+
|
| 38 |
+
def read(self, size=-1):
|
| 39 |
+
return self._buffer.read(size)
|
| 40 |
+
|
| 41 |
+
def __enter__(self):
|
| 42 |
+
return self
|
| 43 |
+
|
| 44 |
+
def __exit__(self, *exc):
|
| 45 |
+
return False
|
| 46 |
+
|
| 47 |
+
return lambda request, timeout=None: _Response()
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _pins(tmp_path: Path, sha: str, key: str = "test-x64") -> Path:
|
| 51 |
+
path = tmp_path / "pins.json"
|
| 52 |
+
path.write_text(
|
| 53 |
+
json.dumps(
|
| 54 |
+
{
|
| 55 |
+
"builds": {
|
| 56 |
+
key: {
|
| 57 |
+
"tag": "bTEST",
|
| 58 |
+
"asset": "a.zip",
|
| 59 |
+
"url": "https://github.com/ggml-org/llama.cpp/releases/download/bTEST/a.zip",
|
| 60 |
+
"sha256": sha,
|
| 61 |
+
"bytes": 1,
|
| 62 |
+
}
|
| 63 |
+
}
|
| 64 |
+
}
|
| 65 |
+
)
|
| 66 |
+
)
|
| 67 |
+
return path
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def test_a_verified_archive_installs_and_is_not_fetched_twice(tmp_path: Path) -> None:
|
| 71 |
+
payload = _archive()
|
| 72 |
+
root = tmp_path / "proj"
|
| 73 |
+
root.mkdir()
|
| 74 |
+
pins = _pins(tmp_path, hashlib.sha256(payload).hexdigest())
|
| 75 |
+
|
| 76 |
+
installed = runtime.ensure(root=root, key="test-x64", opener=_opener(payload), pins_path=pins)
|
| 77 |
+
assert Path(installed).is_file()
|
| 78 |
+
assert Path(installed).name.startswith("llama-server")
|
| 79 |
+
|
| 80 |
+
def refuse(*args, **kwargs):
|
| 81 |
+
raise AssertionError("an installed runtime must not be downloaded again")
|
| 82 |
+
|
| 83 |
+
assert runtime.ensure(root=root, key="test-x64", opener=refuse, pins_path=pins) == installed
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def test_a_wrong_digest_installs_nothing(tmp_path: Path) -> None:
|
| 87 |
+
payload = _archive()
|
| 88 |
+
root = tmp_path / "proj"
|
| 89 |
+
root.mkdir()
|
| 90 |
+
with pytest.raises(runtime.RuntimeVerificationError):
|
| 91 |
+
runtime.ensure(
|
| 92 |
+
root=root, key="test-x64", opener=_opener(payload), pins_path=_pins(tmp_path, "0" * 64)
|
| 93 |
+
)
|
| 94 |
+
assert not list((root / "runtime").glob("*")) if (root / "runtime").exists() else True
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def test_an_unrecorded_platform_is_never_downloaded(tmp_path: Path) -> None:
|
| 98 |
+
"""The bar the catalogue sets for a model applies to the thing that runs it."""
|
| 99 |
+
|
| 100 |
+
payload = _archive()
|
| 101 |
+
root = tmp_path / "proj"
|
| 102 |
+
root.mkdir()
|
| 103 |
+
with pytest.raises(runtime.RuntimeUnavailable) as caught:
|
| 104 |
+
runtime.ensure(
|
| 105 |
+
root=root,
|
| 106 |
+
key="mystery-arch",
|
| 107 |
+
opener=_opener(payload),
|
| 108 |
+
pins_path=_pins(tmp_path, hashlib.sha256(payload).hexdigest()),
|
| 109 |
+
)
|
| 110 |
+
assert "will not download one" in str(caught.value)
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def test_path_traversal_is_refused(tmp_path: Path) -> None:
|
| 114 |
+
buf = io.BytesIO()
|
| 115 |
+
with zipfile.ZipFile(buf, "w") as handle:
|
| 116 |
+
handle.writestr("../../escaped.txt", b"x")
|
| 117 |
+
payload = buf.getvalue()
|
| 118 |
+
root = tmp_path / "proj"
|
| 119 |
+
root.mkdir()
|
| 120 |
+
with pytest.raises(runtime.RuntimeUnavailable) as caught:
|
| 121 |
+
runtime.ensure(
|
| 122 |
+
root=root,
|
| 123 |
+
key="test-x64",
|
| 124 |
+
opener=_opener(payload),
|
| 125 |
+
pins_path=_pins(tmp_path, hashlib.sha256(payload).hexdigest()),
|
| 126 |
+
)
|
| 127 |
+
assert "unsafe path" in str(caught.value)
|
| 128 |
+
assert not (tmp_path / "escaped.txt").exists()
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def test_a_decompression_bomb_is_refused(tmp_path: Path) -> None:
|
| 132 |
+
"""Small on the wire, enormous on disk. The digest would have matched."""
|
| 133 |
+
|
| 134 |
+
payload = _archive(names=("llama-server",), size=runtime.MAX_UNPACKED_BYTES + 1, deflate=True)
|
| 135 |
+
assert len(payload) < 5 * 1024 * 1024
|
| 136 |
+
root = tmp_path / "proj"
|
| 137 |
+
root.mkdir()
|
| 138 |
+
with pytest.raises(runtime.RuntimeUnavailable) as caught:
|
| 139 |
+
runtime.ensure(
|
| 140 |
+
root=root,
|
| 141 |
+
key="test-x64",
|
| 142 |
+
opener=_opener(payload),
|
| 143 |
+
pins_path=_pins(tmp_path, hashlib.sha256(payload).hexdigest()),
|
| 144 |
+
)
|
| 145 |
+
assert "unpacks to more than" in str(caught.value)
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def test_an_archive_without_a_server_is_refused(tmp_path: Path) -> None:
|
| 149 |
+
payload = _archive(names=("readme.txt",))
|
| 150 |
+
root = tmp_path / "proj"
|
| 151 |
+
root.mkdir()
|
| 152 |
+
with pytest.raises(runtime.RuntimeUnavailable):
|
| 153 |
+
runtime.ensure(
|
| 154 |
+
root=root,
|
| 155 |
+
key="test-x64",
|
| 156 |
+
opener=_opener(payload),
|
| 157 |
+
pins_path=_pins(tmp_path, hashlib.sha256(payload).hexdigest()),
|
| 158 |
+
)
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
@pytest.mark.parametrize(
|
| 162 |
+
"url,allowed",
|
| 163 |
+
[
|
| 164 |
+
("https://github.com/ggml-org/llama.cpp/releases/download/b1/a.zip", True),
|
| 165 |
+
("https://objects.githubusercontent.com/whatever", True),
|
| 166 |
+
("http://github.com/ggml-org/llama.cpp/a.zip", False),
|
| 167 |
+
("https://github.com.evil.example/a.zip", False),
|
| 168 |
+
("https://example.com/a.zip", False),
|
| 169 |
+
],
|
| 170 |
+
)
|
| 171 |
+
def test_redirects_leave_github_nowhere(url: str, allowed: bool) -> None:
|
| 172 |
+
assert runtime._RuntimeRedirects._permitted(url) is allowed
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def test_the_shipped_pin_file_is_readable_and_pinned() -> None:
|
| 176 |
+
"""Whatever is recorded must be a real 64-character digest over https."""
|
| 177 |
+
|
| 178 |
+
for key, pin in runtime.load_pins().items():
|
| 179 |
+
assert len(pin.sha256) == 64, key
|
| 180 |
+
assert int(pin.sha256, 16) >= 0, key
|
| 181 |
+
assert pin.url.startswith("https://"), key
|
| 182 |
+
assert pin.tag and pin.asset, key
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def test_platform_key_is_stable() -> None:
|
| 186 |
+
assert runtime.platform_key("Windows", "AMD64") == "windows-x64"
|
| 187 |
+
assert runtime.platform_key("Linux", "x86_64") == "linux-x64"
|
| 188 |
+
assert runtime.platform_key("Darwin", "arm64") == "macos-arm64"
|