prismnetwork 0.3.0__tar.gz → 0.3.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.5
1
+ Metadata-Version: 2.4
2
2
  Name: prismnetwork
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: Headless GPU leasing on Prism Network for autonomous agents. Wallet-signature auth, on-chain USDG payment, SSH.
5
5
  Project-URL: Homepage, https://prismnetwork.tech
6
6
  Project-URL: Source, https://github.com/prismnetwork-tech/prism
@@ -26,7 +26,7 @@ pip install prismnetwork
26
26
  ```python
27
27
  from prismnetwork import PrismAgent, DEFAULT_IMAGE
28
28
 
29
- agent = PrismAgent(private_key=AGENT_KEY, escrow="0x62C042265991bEa17B07229322A01850974626dA")
29
+ agent = PrismAgent(private_key=AGENT_KEY, escrow="0xfD4228eEEfC49e4b76A0CD40af9fdd546220B2FD")
30
30
  agent.authenticate()
31
31
 
32
32
  lease = agent.lease(image=DEFAULT_IMAGE, duration_seconds=600, min_vram_mib=16000)
@@ -45,6 +45,39 @@ per-second; each lease settles on-chain with a verifiable receipt.
45
45
 
46
46
  Read-only helpers: `offers()`, `balances()`, `leases()`, `quote(...)`, `access(id)`.
47
47
 
48
+ ## Which machine answered
49
+
50
+ Every session `run()` opens checks the SSH host key on the far end, and
51
+ `host_key_policy(lease.access)` says what that check was worth:
52
+
53
+ ```python
54
+ from prismnetwork import host_key_policy
55
+
56
+ host_key_policy(lease.access)
57
+ # {"mode": "attested" | "reported" | "unverified", "fingerprint": ..., "source": ...}
58
+ ```
59
+
60
+ `attested` means the fingerprint comes out of a hardware report whose signed data
61
+ commits to the key the guest generated at boot. Prism walks that report and puts
62
+ the fingerprint on the access grant, and the SDK refuses a session whose host key
63
+ does not match it, so the operator cannot put a different machine on the other
64
+ end. The report is checked in our control plane rather than here, so `attested`
65
+ says we checked it and this client holds the session to what we published.
66
+
67
+ `reported` means the node named the key on the signed report that opened
68
+ access, which rules out the relay and the network path but not the operator,
69
+ whose bond is what a dispute reaches instead.
70
+
71
+ `unverified` means nobody published a key: capacity brokered from a public
72
+ cloud has its host key generated by the cloud and never shown to the network, so
73
+ the key is recorded on first sight and held for the rest of the lease.
74
+
75
+ Your own `~/.ssh/known_hosts` is never touched. The record sits beside the
76
+ lease's private key and is removed with it by `end_lease()`.
77
+
78
+ `PrismAgent(..., require_host_key=True)` refuses to open a session on a lease
79
+ that publishes no key, rather than trusting whichever machine answers first.
80
+
48
81
  ## Batch
49
82
 
50
83
  Pass `command=` to `lease()` and the node runs that one command and reports its
@@ -60,10 +93,27 @@ broker path has no signed result channel. Commands are capped at 8 KiB and outpu
60
93
  at 64 KiB per stream. `result(lease_id)` and `wait_for_result(lease_id)` read the
61
94
  output of an already-funded batch lease.
62
95
 
96
+ ## Paying an x402 endpoint
97
+
98
+ Prism's pay-per-call endpoints take a transfer plus a signature over what the
99
+ transfer buys, so a payment can only be redeemed against the request it was made
100
+ for. `payment_header` builds the `X-PAYMENT` value:
101
+
102
+ ```python
103
+ from prismnetwork import payment_header
104
+
105
+ body = json.dumps({"model": "llama3.2:3b", "prompt": "what is a prism"}).encode()
106
+ header = payment_header(agent.account, tx_hash, body)
107
+ ```
108
+
109
+ Pass the bytes the request actually carries. `bound_message` and `hash_request`
110
+ are exported for a client that builds the envelope itself; they produce the same
111
+ message the Node SDK signs.
112
+
63
113
  The wallet needs USDG (`0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168`, 6 decimals) and
64
114
  native Robinhood-Chain gas.
65
115
 
66
- Requires `ssh` and `ssh-keygen` on `PATH`. Chain id 4663, RPC
116
+ Requires `ssh`, `ssh-keygen` and `ssh-keyscan` on `PATH`. Chain id 4663, RPC
67
117
  `https://rpc.mainnet.chain.robinhood.com`.
68
118
 
69
119
  Prism is pre-production and unaudited. A permissionless supplier is not a trusted
@@ -0,0 +1,106 @@
1
+ # prismnetwork
2
+
3
+ Headless GPU leasing on [Prism Network](https://prismnetwork.tech) for autonomous
4
+ agents, the Python counterpart to `@prismnetwork/agent-sdk`. Give it a wallet; it
5
+ authenticates with a signature, pays on-chain in USDG, provisions a GPU, and runs
6
+ over SSH. No browser, no dashboard.
7
+
8
+ ```sh
9
+ pip install prismnetwork
10
+ ```
11
+
12
+ ```python
13
+ from prismnetwork import PrismAgent, DEFAULT_IMAGE
14
+
15
+ agent = PrismAgent(private_key=AGENT_KEY, escrow="0xfD4228eEEfC49e4b76A0CD40af9fdd546220B2FD")
16
+ agent.authenticate()
17
+
18
+ lease = agent.lease(image=DEFAULT_IMAGE, duration_seconds=600, min_vram_mib=16000)
19
+ out = agent.run(lease, "nvidia-smi")
20
+ print(out["stdout"])
21
+ agent.end_lease(lease)
22
+ ```
23
+
24
+ ## What it does
25
+
26
+ `authenticate()` signs a challenge with the wallet and exchanges it for a bearer
27
+ session. `lease()` gets a quote, funds an on-chain USDG escrow bound to the quote
28
+ (`createLease`), waits for the GPU to provision, and returns SSH access. `run()`
29
+ executes a command over SSH, retrying through the host's sshd warmup. Metering is
30
+ per-second; each lease settles on-chain with a verifiable receipt.
31
+
32
+ Read-only helpers: `offers()`, `balances()`, `leases()`, `quote(...)`, `access(id)`.
33
+
34
+ ## Which machine answered
35
+
36
+ Every session `run()` opens checks the SSH host key on the far end, and
37
+ `host_key_policy(lease.access)` says what that check was worth:
38
+
39
+ ```python
40
+ from prismnetwork import host_key_policy
41
+
42
+ host_key_policy(lease.access)
43
+ # {"mode": "attested" | "reported" | "unverified", "fingerprint": ..., "source": ...}
44
+ ```
45
+
46
+ `attested` means the fingerprint comes out of a hardware report whose signed data
47
+ commits to the key the guest generated at boot. Prism walks that report and puts
48
+ the fingerprint on the access grant, and the SDK refuses a session whose host key
49
+ does not match it, so the operator cannot put a different machine on the other
50
+ end. The report is checked in our control plane rather than here, so `attested`
51
+ says we checked it and this client holds the session to what we published.
52
+
53
+ `reported` means the node named the key on the signed report that opened
54
+ access, which rules out the relay and the network path but not the operator,
55
+ whose bond is what a dispute reaches instead.
56
+
57
+ `unverified` means nobody published a key: capacity brokered from a public
58
+ cloud has its host key generated by the cloud and never shown to the network, so
59
+ the key is recorded on first sight and held for the rest of the lease.
60
+
61
+ Your own `~/.ssh/known_hosts` is never touched. The record sits beside the
62
+ lease's private key and is removed with it by `end_lease()`.
63
+
64
+ `PrismAgent(..., require_host_key=True)` refuses to open a session on a lease
65
+ that publishes no key, rather than trusting whichever machine answers first.
66
+
67
+ ## Batch
68
+
69
+ Pass `command=` to `lease()` and the node runs that one command and reports its
70
+ output instead of granting SSH access:
71
+
72
+ ```python
73
+ batch = agent.lease(image=DEFAULT_IMAGE, duration_seconds=600, command="nvidia-smi")
74
+ print(batch.result["stdout"])
75
+ ```
76
+
77
+ Batch leases match only suppliers at trust class `isolated` or above, because the
78
+ broker path has no signed result channel. Commands are capped at 8 KiB and output
79
+ at 64 KiB per stream. `result(lease_id)` and `wait_for_result(lease_id)` read the
80
+ output of an already-funded batch lease.
81
+
82
+ ## Paying an x402 endpoint
83
+
84
+ Prism's pay-per-call endpoints take a transfer plus a signature over what the
85
+ transfer buys, so a payment can only be redeemed against the request it was made
86
+ for. `payment_header` builds the `X-PAYMENT` value:
87
+
88
+ ```python
89
+ from prismnetwork import payment_header
90
+
91
+ body = json.dumps({"model": "llama3.2:3b", "prompt": "what is a prism"}).encode()
92
+ header = payment_header(agent.account, tx_hash, body)
93
+ ```
94
+
95
+ Pass the bytes the request actually carries. `bound_message` and `hash_request`
96
+ are exported for a client that builds the envelope itself; they produce the same
97
+ message the Node SDK signs.
98
+
99
+ The wallet needs USDG (`0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168`, 6 decimals) and
100
+ native Robinhood-Chain gas.
101
+
102
+ Requires `ssh`, `ssh-keygen` and `ssh-keyscan` on `PATH`. Chain id 4663, RPC
103
+ `https://rpc.mainnet.chain.robinhood.com`.
104
+
105
+ Prism is pre-production and unaudited. A permissionless supplier is not a trusted
106
+ computing environment; do not lease with a wallet or workload you cannot lose.
@@ -1,7 +1,7 @@
1
1
  """Authenticate a wallet, list GPUs, and optionally lease one and run a command.
2
2
 
3
3
  PRISM_AGENT_KEY=0x<agent wallet private key> \\
4
- PRISM_ESCROW=0x62C042265991bEa17B07229322A01850974626dA \\
4
+ PRISM_ESCROW=0xfD4228eEEfC49e4b76A0CD40af9fdd546220B2FD \\
5
5
  python quickstart.py
6
6
 
7
7
  By default this authenticates and lists GPUs without spending. Set PRISM_RUN_LEASE=1
@@ -6,8 +6,10 @@ from ._agent import (
6
6
  Lease,
7
7
  PrismAgent,
8
8
  PrismError,
9
+ host_key_policy,
9
10
  )
10
11
 
12
+ from ._x402 import bound_message, hash_request, payment_header
11
13
  from .toolkit import DEFAULT_ESCROW, PrismToolset, agent_from_env
12
14
 
13
15
  __all__ = [
@@ -21,5 +23,9 @@ __all__ = [
21
23
  "DEFAULT_IMAGE",
22
24
  "TRUST_CLASSES",
23
25
  "USDG",
26
+ "host_key_policy",
27
+ "bound_message",
28
+ "hash_request",
29
+ "payment_header",
24
30
  ]
25
- __version__ = "0.3.0"
31
+ __version__ = "0.3.2"
@@ -1,5 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import base64
4
+ import hashlib
5
+ import os
3
6
  import re
4
7
  import shutil
5
8
  import subprocess
@@ -101,7 +104,8 @@ class PrismAgent:
101
104
  signature, pay on-chain in USDG, provision, and run over SSH."""
102
105
 
103
106
  def __init__(self, private_key: str, escrow: str,
104
- api_base: str = "https://prismnetwork.tech", rpc_url: str = ROBINHOOD_RPC):
107
+ api_base: str = "https://prismnetwork.tech", rpc_url: str = ROBINHOOD_RPC,
108
+ require_host_key: bool = False):
105
109
  if not escrow:
106
110
  raise ValueError("escrow address is required")
107
111
  if not isinstance(private_key, str) or not private_key.strip():
@@ -118,6 +122,11 @@ class PrismAgent:
118
122
  self._usdg = self.w3.eth.contract(address=USDG, abi=_ERC20)
119
123
  self._escrow = self.w3.eth.contract(address=self.escrow, abi=_ESCROW)
120
124
  self.session: str | None = None
125
+ # Off by default because most capacity publishes no host key, and
126
+ # refusing those leases would take the network's own supply away from
127
+ # callers who never asked for the guarantee. On, nothing runs anywhere
128
+ # the grant cannot name.
129
+ self.require_host_key = require_host_key
121
130
 
122
131
  @property
123
132
  def address(self) -> str:
@@ -288,18 +297,28 @@ class PrismAgent:
288
297
  if not a.get("ssh_host") or not a.get("ssh_port"):
289
298
  raise PrismError(400, "ssh_access_unavailable",
290
299
  {"mode": a.get("mode"), "lease_id": lease.lease_id})
291
- args = ["ssh", "-i", lease.key_path, "-p", str(a["ssh_port"]),
292
- "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null",
293
- "-o", "BatchMode=yes", "-o", "ConnectTimeout=15",
294
- f"{a.get('ssh_user', 'root')}@{a['ssh_host']}", command]
295
300
  last = None
296
301
  for attempt in range(connect_retries + 1):
297
302
  try:
298
- p = subprocess.run(args, capture_output=True, text=True, timeout=timeout + 20,
299
- input=stdin if stdin is not None else "")
300
- res = {"code": p.returncode, "stdout": p.stdout.strip(), "stderr": p.stderr.strip()}
301
- except subprocess.TimeoutExpired:
302
- res = {"code": -1, "stdout": "", "stderr": "timed out"}
303
+ host_key = _host_key_args(a["ssh_host"], a["ssh_port"], lease.key_path, a,
304
+ self.require_host_key)
305
+ except PrismError as e:
306
+ # A box that is still coming up has nothing listening to read a
307
+ # key from, which is the same wait this loop already exists for.
308
+ # Being answered by the wrong machine is not a wait.
309
+ if e.code != "host_key_unavailable":
310
+ raise
311
+ res = {"code": 255, "stdout": "", "stderr": f"ssh: {e.code}"}
312
+ else:
313
+ args = ["ssh", "-i", lease.key_path, "-p", str(a["ssh_port"]), *host_key,
314
+ "-o", "BatchMode=yes", "-o", "ConnectTimeout=15",
315
+ f"{a.get('ssh_user', 'root')}@{a['ssh_host']}", command]
316
+ try:
317
+ p = subprocess.run(args, capture_output=True, text=True, timeout=timeout + 20,
318
+ input=stdin if stdin is not None else "")
319
+ res = {"code": p.returncode, "stdout": p.stdout.strip(), "stderr": p.stderr.strip()}
320
+ except subprocess.TimeoutExpired:
321
+ res = {"code": -1, "stdout": "", "stderr": "timed out"}
303
322
  if not _is_ssh_warmup(res):
304
323
  return res
305
324
  last = res
@@ -401,6 +420,129 @@ def _safe_json(res):
401
420
  return None
402
421
 
403
422
 
423
+ # Checking which machine answered.
424
+ #
425
+ # A lease hands the renter an address and a private key. Until the host key on
426
+ # the other end is checked, anything that can reach that address can take the
427
+ # session, read the work and answer as if it were the GPU. What the network can
428
+ # say about that key differs by where the capacity came from, so the decision is
429
+ # made here rather than defaulted: a grant that names a fingerprint is checked
430
+ # before the session opens, and a grant that names none has its key recorded on
431
+ # first sight and held for the rest of the lease. The record lives beside the
432
+ # lease's private key and goes when the lease does; the caller's own
433
+ # ~/.ssh/known_hosts is never touched.
434
+
435
+ _SCAN_TIMEOUT = 10
436
+
437
+
438
+ def host_key_policy(access: dict | None) -> dict:
439
+ """What the network is willing to say about the machine behind a grant.
440
+
441
+ ``attested`` is the only one that survives a hostile operator: the
442
+ fingerprint comes out of a report the processor signed. ``reported`` is the
443
+ operator's word under their bonded device key, which rules out everyone
444
+ between them and the renter. ``unverified`` means nobody published a key and
445
+ the first connection decides.
446
+ """
447
+ fingerprint = (access or {}).get("channel_key_fingerprint")
448
+ if not fingerprint:
449
+ return {"mode": "unverified", "fingerprint": None, "source": None}
450
+ source = access.get("channel_key_source")
451
+ return {
452
+ "mode": "attested" if source == "snp_report" else "reported",
453
+ "fingerprint": fingerprint,
454
+ "source": source,
455
+ }
456
+
457
+
458
+ def _known_hosts_path(key_path: str) -> str:
459
+ return os.path.join(os.path.dirname(key_path), "known_hosts")
460
+
461
+
462
+ def _known_hosts_fingerprint(line: str) -> str | None:
463
+ """The ``ssh-keygen -lf`` form of the key in a known_hosts line. The control
464
+ plane publishes fingerprints in exactly this form, so the two are compared
465
+ as strings."""
466
+ fields = line.strip().split()
467
+ if len(fields) < 3:
468
+ return None
469
+ try:
470
+ raw = base64.b64decode(fields[2], validate=True)
471
+ except Exception:
472
+ return None
473
+ if not raw:
474
+ return None
475
+ return "SHA256:" + base64.b64encode(hashlib.sha256(raw).digest()).decode().rstrip("=")
476
+
477
+
478
+ def _host_field(host: str, port) -> str:
479
+ return str(host) if int(port) == 22 else f"[{host}]:{port}"
480
+
481
+
482
+ def _already_pinned(path: str, host: str, port, fingerprint: str) -> bool:
483
+ # The relay hands out a fresh local port for every session, so a record that
484
+ # names the right key under the wrong address is a record ssh will refuse to
485
+ # use. Both halves have to match for the scan to be worth skipping.
486
+ try:
487
+ with open(path) as handle:
488
+ lines = handle.read().splitlines()
489
+ except OSError:
490
+ return False
491
+ return any(line.split()[:1] == [_host_field(host, port)]
492
+ and _known_hosts_fingerprint(line) == fingerprint for line in lines if line.strip())
493
+
494
+
495
+ def _pin_host_key(host: str, port, fingerprint: str, path: str) -> None:
496
+ """Read the host key off the wire and record it only if it is the one the
497
+ grant named.
498
+
499
+ Done as a separate exchange before ssh runs, because a fingerprint cannot be
500
+ turned into a known_hosts entry without the key itself, and letting ssh learn
501
+ the key first would mean trusting it to find out whether it should have.
502
+ ``ssh-keyscan`` reads the key the server offers and hangs up, so nothing the
503
+ machine could use is sent.
504
+ """
505
+ if _already_pinned(path, host, port, fingerprint):
506
+ return
507
+ try:
508
+ scan = subprocess.run(["ssh-keyscan", "-T", str(_SCAN_TIMEOUT), "-p", str(port), str(host)],
509
+ capture_output=True, text=True, timeout=_SCAN_TIMEOUT + 5)
510
+ offered = [l for l in scan.stdout.splitlines() if l and not l.startswith("#")]
511
+ except (OSError, subprocess.TimeoutExpired) as e:
512
+ raise PrismError(503, "host_key_unavailable", {"host": host, "port": port, "cause": str(e)}) from e
513
+ if not offered:
514
+ raise PrismError(503, "host_key_unavailable",
515
+ {"host": host, "port": port, "cause": (scan.stderr or "").strip()[:200]})
516
+ for line in offered:
517
+ if _known_hosts_fingerprint(line) == fingerprint:
518
+ # Only the key that matched. Writing everything the machine offered
519
+ # would pin keys nobody vouched for alongside the one that was
520
+ # checked.
521
+ with open(os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600), "w") as handle:
522
+ handle.write(line + "\n")
523
+ return
524
+ raise PrismError(502, "host_key_mismatch", {
525
+ "expected": fingerprint,
526
+ "offered": [f for f in (_known_hosts_fingerprint(l) for l in offered) if f],
527
+ "hint": "the machine answering is not the one the lease names; nothing was sent to it",
528
+ })
529
+
530
+
531
+ def _host_key_args(host, port, key_path: str, access: dict | None, require_host_key: bool) -> list:
532
+ """The ssh options that make the connection check the machine it reaches."""
533
+ path = _known_hosts_path(key_path)
534
+ policy = host_key_policy(access)
535
+ if policy["fingerprint"] is None:
536
+ if require_host_key:
537
+ raise PrismError(400, "host_key_unpublished", {
538
+ "mode": (access or {}).get("mode"),
539
+ "hint": "this lease publishes no host key, so which machine answers cannot be checked",
540
+ })
541
+ return ["-o", f"UserKnownHostsFile={path}", "-o", "StrictHostKeyChecking=accept-new"]
542
+ _pin_host_key(host, port, policy["fingerprint"], path)
543
+ return ["-o", f"UserKnownHostsFile={path}", "-o", "StrictHostKeyChecking=yes"]
544
+
545
+
404
546
  def _is_ssh_warmup(res: dict) -> bool:
405
547
  if res["code"] != 255:
406
548
  return False
@@ -0,0 +1,48 @@
1
+ """What a payer signs on Prism's legacy x402 rail.
2
+
3
+ The transfer is already on chain by the time the header is sent, so the header
4
+ only says who made it. Signing the transaction hash alone proves who paid and
5
+ not what for: anyone who read the header in flight could put their own command
6
+ in front of it and spend someone else's transfer. The request travels inside the
7
+ signed message, and the endpoint checks it against the request that arrived.
8
+
9
+ This is the construction the Node SDK signs in ``sdk/x402.mjs``, and
10
+ ``tests/test_x402.py`` holds the two against each other.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import base64
16
+ import hashlib
17
+ import json
18
+
19
+ from eth_account.messages import encode_defunct
20
+
21
+
22
+ def hash_request(payload) -> str:
23
+ """The digest both sides compare: the command for a job, the request bytes
24
+ for a generation. Text is hashed as UTF-8, which is how it goes on the wire."""
25
+ data = payload.encode("utf-8") if isinstance(payload, str) else bytes(payload)
26
+ return hashlib.sha256(data).hexdigest()
27
+
28
+
29
+ def bound_message(tx_hash: str, request_hash: str) -> str:
30
+ return f"prism-x402:v2\n{str(tx_hash).lower()}\n{request_hash}"
31
+
32
+
33
+ def payment_header(account, tx_hash: str, body, network: str | None = None) -> str:
34
+ """The ``X-PAYMENT`` value for one paid request: the transfer that funded it,
35
+ and a signature over that transfer together with the exact bytes being sent.
36
+
37
+ ``body`` has to be the bytes the request actually carries. Re-encoding a dict
38
+ here would sign something the endpoint never receives.
39
+ """
40
+ signed = account.sign_message(encode_defunct(text=bound_message(tx_hash, hash_request(body))))
41
+ signature = signed.signature.hex()
42
+ envelope = {
43
+ "txHash": tx_hash,
44
+ "signature": signature if signature.startswith("0x") else "0x" + signature,
45
+ }
46
+ if network:
47
+ envelope["network"] = network
48
+ return base64.b64encode(json.dumps(envelope, separators=(",", ":")).encode("utf-8")).decode("ascii")
@@ -22,7 +22,7 @@ import requests
22
22
 
23
23
  from ._agent import DEFAULT_IMAGE, MAX_COMMAND_BYTES, TRUST_CLASSES, Lease, PrismAgent, PrismError
24
24
 
25
- DEFAULT_ESCROW = "0x62C042265991bEa17B07229322A01850974626dA"
25
+ DEFAULT_ESCROW = "0xfD4228eEEfC49e4b76A0CD40af9fdd546220B2FD"
26
26
  PUBLIC_API = "https://api.prismnetwork.tech"
27
27
 
28
28
  NO_WALLET = (
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "prismnetwork"
7
- version = "0.3.0"
7
+ version = "0.3.2"
8
8
  description = "Headless GPU leasing on Prism Network for autonomous agents. Wallet-signature auth, on-chain USDG payment, SSH."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
File without changes
@@ -0,0 +1,128 @@
1
+ """What a renter's client does about the machine on the other end of a lease."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import socket
8
+ import subprocess
9
+ import tempfile
10
+ import time
11
+ import unittest
12
+
13
+ from prismnetwork import PrismError, host_key_policy
14
+ from prismnetwork._agent import _host_key_args, _known_hosts_fingerprint, _known_hosts_path
15
+
16
+ SSHD = "/usr/sbin/sshd"
17
+
18
+
19
+ def free_port() -> int:
20
+ with socket.socket() as probe:
21
+ probe.bind(("127.0.0.1", 0))
22
+ return probe.getsockname()[1]
23
+
24
+
25
+ def reachable(port: int) -> bool:
26
+ with socket.socket() as probe:
27
+ probe.settimeout(0.2)
28
+ return probe.connect_ex(("127.0.0.1", port)) == 0
29
+
30
+
31
+ def generate_key(directory: str, name: str) -> tuple:
32
+ path = os.path.join(directory, name)
33
+ subprocess.run(["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", path], check=True)
34
+ listed = subprocess.run(["ssh-keygen", "-lf", path + ".pub"], capture_output=True, text=True, check=True)
35
+ return path, listed.stdout.split()[1]
36
+
37
+
38
+ def serve_sshd(directory: str, host_key: str, port: int):
39
+ """A real sshd, because what is being tested is whether the key the network
40
+ named is the key the machine actually offers. Nothing logs in: ssh-keyscan
41
+ reads the host key out of the exchange and hangs up."""
42
+ config = os.path.join(directory, "sshd_config")
43
+ with open(config, "w") as handle:
44
+ handle.write(f"Port {port}\nListenAddress 127.0.0.1\nHostKey {host_key}\n"
45
+ "PasswordAuthentication no\nStrictModes no\n")
46
+ child = subprocess.Popen([SSHD, "-D", "-e", "-f", config],
47
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
48
+ for _ in range(50):
49
+ if reachable(port):
50
+ return child
51
+ time.sleep(0.1)
52
+ child.kill()
53
+ raise AssertionError("sshd did not come up")
54
+
55
+
56
+ def stop(child):
57
+ child.kill()
58
+ child.wait()
59
+
60
+
61
+ class HostKeyTest(unittest.TestCase):
62
+ def setUp(self):
63
+ self.dir = tempfile.mkdtemp(prefix="prism-hostkey-")
64
+ self.addCleanup(shutil.rmtree, self.dir, True)
65
+ self.key_path = os.path.join(self.dir, "id_ed25519")
66
+ open(self.key_path, "w").close()
67
+ self.known = _known_hosts_path(self.key_path)
68
+
69
+ @unittest.skipUnless(os.path.exists(SSHD), "no sshd to serve a host key")
70
+ def test_a_published_fingerprint_is_checked_against_the_machine_that_answers(self):
71
+ served, fingerprint = generate_key(self.dir, "served")
72
+ port = free_port()
73
+ sshd = serve_sshd(self.dir, served, port)
74
+ self.addCleanup(stop, sshd)
75
+
76
+ args = _host_key_args("127.0.0.1", port, self.key_path,
77
+ {"channel_key_fingerprint": fingerprint, "channel_key_source": "node_report"},
78
+ False)
79
+ self.assertEqual(args, ["-o", f"UserKnownHostsFile={self.known}", "-o", "StrictHostKeyChecking=yes"])
80
+ with open(self.known) as handle:
81
+ recorded = handle.read().strip().splitlines()
82
+ self.assertEqual(len(recorded), 1, "only the key that was checked is recorded")
83
+ self.assertEqual(recorded[0].split()[0], f"[127.0.0.1]:{port}")
84
+ self.assertEqual(_known_hosts_fingerprint(recorded[0]), fingerprint)
85
+
86
+ # A grant naming a key the machine does not hold has to fail before
87
+ # anything is sent to it.
88
+ os.remove(self.known)
89
+ _, other = generate_key(self.dir, "other")
90
+ with self.assertRaises(PrismError) as caught:
91
+ _host_key_args("127.0.0.1", port, self.key_path, {"channel_key_fingerprint": other}, False)
92
+ self.assertEqual(caught.exception.code, "host_key_mismatch")
93
+ self.assertEqual(caught.exception.body["offered"], [fingerprint])
94
+ self.assertFalse(os.path.exists(self.known), "a machine that failed the check is not recorded")
95
+
96
+ def test_an_unreachable_box_reads_as_a_wait_rather_than_a_wrong_machine(self):
97
+ with self.assertRaises(PrismError) as caught:
98
+ _host_key_args("127.0.0.1", free_port(), self.key_path,
99
+ {"channel_key_fingerprint": "SHA256:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU"},
100
+ False)
101
+ self.assertEqual(caught.exception.code, "host_key_unavailable")
102
+
103
+ def test_a_lease_that_publishes_no_key_is_pinned_on_first_sight_or_refused(self):
104
+ args = _host_key_args("127.0.0.1", 2222, self.key_path, {"mode": "direct_ssh"}, False)
105
+ self.assertEqual(args, ["-o", f"UserKnownHostsFile={self.known}",
106
+ "-o", "StrictHostKeyChecking=accept-new"])
107
+ self.assertNotIn("StrictHostKeyChecking=no", args)
108
+ self.assertNotIn("UserKnownHostsFile=/dev/null", args)
109
+
110
+ with self.assertRaises(PrismError) as caught:
111
+ _host_key_args("127.0.0.1", 2222, self.key_path, {"mode": "direct_ssh"}, True)
112
+ self.assertEqual(caught.exception.code, "host_key_unpublished")
113
+
114
+ def test_the_grant_says_which_claim_it_is_making_about_the_host_key(self):
115
+ attested = {"channel_key_fingerprint": "SHA256:a", "channel_key_source": "snp_report"}
116
+ reported = {"channel_key_fingerprint": "SHA256:a", "channel_key_source": "node_report"}
117
+ self.assertEqual(host_key_policy(attested),
118
+ {"mode": "attested", "fingerprint": "SHA256:a", "source": "snp_report"})
119
+ self.assertEqual(host_key_policy(reported),
120
+ {"mode": "reported", "fingerprint": "SHA256:a", "source": "node_report"})
121
+ self.assertEqual(host_key_policy({"mode": "direct_ssh"}),
122
+ {"mode": "unverified", "fingerprint": None, "source": None})
123
+ self.assertEqual(host_key_policy(None),
124
+ {"mode": "unverified", "fingerprint": None, "source": None})
125
+
126
+
127
+ if __name__ == "__main__":
128
+ unittest.main()
@@ -0,0 +1,99 @@
1
+ """The payment binding, and whether this SDK signs the same bytes the Node one does."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import hashlib
7
+ import json
8
+ import shutil
9
+ import subprocess
10
+ import unittest
11
+ from pathlib import Path
12
+
13
+ from eth_account import Account
14
+ from eth_account.messages import encode_defunct
15
+
16
+ from prismnetwork import bound_message, hash_request, payment_header
17
+
18
+ TX = "0x" + "ab" * 32
19
+ KEY = "0x" + "33" * 32
20
+
21
+ # The Node definition these functions are a port of. Absent from an installed
22
+ # wheel, present in a checkout, which is where the comparison is worth making.
23
+ NODE_MODULE = Path(__file__).resolve().parents[2] / "sdk" / "x402.mjs"
24
+
25
+ VECTORS = [
26
+ (TX, "nvidia-smi"),
27
+ (TX.upper().replace("0X", "0x"), "nvidia-smi"),
28
+ ("0x" + "0" * 64, ""),
29
+ (TX, '{"model":"llama3.2:3b","prompt":"quelle heure est-il à Paris"}'),
30
+ (TX, "\U0001f680 run it"),
31
+ ]
32
+
33
+
34
+ def node_says(vectors):
35
+ """What ``sdk/x402.mjs`` produces for the same inputs, out of a real node."""
36
+ script = """
37
+ import { boundMessage, hashRequest } from %s;
38
+ const answers = JSON.parse(process.argv[1]).map(([tx, payload]) => [
39
+ hashRequest(payload),
40
+ boundMessage(tx, hashRequest(payload)),
41
+ ]);
42
+ console.log(JSON.stringify(answers));
43
+ """ % json.dumps(str(NODE_MODULE))
44
+ out = subprocess.run(
45
+ ["node", "--input-type=module", "-e", script, json.dumps(vectors)],
46
+ capture_output=True,
47
+ text=True,
48
+ check=True,
49
+ )
50
+ return json.loads(out.stdout)
51
+
52
+
53
+ class BindingTest(unittest.TestCase):
54
+ def test_digest_is_a_plain_sha256_of_the_bytes_on_the_wire(self):
55
+ self.assertEqual(hash_request("nvidia-smi"), hashlib.sha256(b"nvidia-smi").hexdigest())
56
+ self.assertEqual(hash_request(b"nvidia-smi"), hash_request("nvidia-smi"))
57
+ self.assertEqual(hash_request("café"), hashlib.sha256("café".encode("utf-8")).hexdigest())
58
+
59
+ def test_message_names_its_version_the_transaction_and_the_request(self):
60
+ digest = hash_request("nvidia-smi")
61
+ self.assertEqual(bound_message(TX, digest), f"prism-x402:v2\n{TX}\n{digest}")
62
+ # A payer holding a checksummed hash and a server reading a lowercase one
63
+ # have to arrive at the same string.
64
+ self.assertEqual(bound_message(TX.upper().replace("0X", "0x"), digest), bound_message(TX, digest))
65
+
66
+ @unittest.skipUnless(shutil.which("node") and NODE_MODULE.exists(), "needs node and a checkout")
67
+ def test_the_two_sdks_sign_the_same_bytes(self):
68
+ mine = [[hash_request(payload), bound_message(tx, hash_request(payload))] for tx, payload in VECTORS]
69
+ self.assertEqual(mine, node_says(VECTORS))
70
+
71
+ def test_the_header_a_payer_sends_recovers_that_payer(self):
72
+ account = Account.from_key(KEY)
73
+ body = json.dumps({"model": "llama3.2:3b", "prompt": "hello"}).encode("utf-8")
74
+
75
+ envelope = json.loads(base64.b64decode(payment_header(account, TX, body)))
76
+ self.assertEqual(envelope["txHash"], TX)
77
+ self.assertNotIn("network", envelope)
78
+
79
+ signer = Account.recover_message(
80
+ encode_defunct(text=bound_message(TX, hash_request(body))), signature=envelope["signature"]
81
+ )
82
+ self.assertEqual(signer, account.address)
83
+
84
+ # The replay this closes: the same header sent back with a body of the
85
+ # reader's own choosing.
86
+ swapped = Account.recover_message(
87
+ encode_defunct(text=bound_message(TX, hash_request(b"something else"))),
88
+ signature=envelope["signature"],
89
+ )
90
+ self.assertNotEqual(swapped, account.address)
91
+
92
+ def test_a_named_network_travels_with_the_payment(self):
93
+ account = Account.from_key(KEY)
94
+ envelope = json.loads(base64.b64decode(payment_header(account, TX, b"{}", network="eip155:4663")))
95
+ self.assertEqual(envelope["network"], "eip155:4663")
96
+
97
+
98
+ if __name__ == "__main__":
99
+ unittest.main()
@@ -1,56 +0,0 @@
1
- # prismnetwork
2
-
3
- Headless GPU leasing on [Prism Network](https://prismnetwork.tech) for autonomous
4
- agents, the Python counterpart to `@prismnetwork/agent-sdk`. Give it a wallet; it
5
- authenticates with a signature, pays on-chain in USDG, provisions a GPU, and runs
6
- over SSH. No browser, no dashboard.
7
-
8
- ```sh
9
- pip install prismnetwork
10
- ```
11
-
12
- ```python
13
- from prismnetwork import PrismAgent, DEFAULT_IMAGE
14
-
15
- agent = PrismAgent(private_key=AGENT_KEY, escrow="0x62C042265991bEa17B07229322A01850974626dA")
16
- agent.authenticate()
17
-
18
- lease = agent.lease(image=DEFAULT_IMAGE, duration_seconds=600, min_vram_mib=16000)
19
- out = agent.run(lease, "nvidia-smi")
20
- print(out["stdout"])
21
- agent.end_lease(lease)
22
- ```
23
-
24
- ## What it does
25
-
26
- `authenticate()` signs a challenge with the wallet and exchanges it for a bearer
27
- session. `lease()` gets a quote, funds an on-chain USDG escrow bound to the quote
28
- (`createLease`), waits for the GPU to provision, and returns SSH access. `run()`
29
- executes a command over SSH, retrying through the host's sshd warmup. Metering is
30
- per-second; each lease settles on-chain with a verifiable receipt.
31
-
32
- Read-only helpers: `offers()`, `balances()`, `leases()`, `quote(...)`, `access(id)`.
33
-
34
- ## Batch
35
-
36
- Pass `command=` to `lease()` and the node runs that one command and reports its
37
- output instead of granting SSH access:
38
-
39
- ```python
40
- batch = agent.lease(image=DEFAULT_IMAGE, duration_seconds=600, command="nvidia-smi")
41
- print(batch.result["stdout"])
42
- ```
43
-
44
- Batch leases match only suppliers at trust class `isolated` or above, because the
45
- broker path has no signed result channel. Commands are capped at 8 KiB and output
46
- at 64 KiB per stream. `result(lease_id)` and `wait_for_result(lease_id)` read the
47
- output of an already-funded batch lease.
48
-
49
- The wallet needs USDG (`0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168`, 6 decimals) and
50
- native Robinhood-Chain gas.
51
-
52
- Requires `ssh` and `ssh-keygen` on `PATH`. Chain id 4663, RPC
53
- `https://rpc.mainnet.chain.robinhood.com`.
54
-
55
- Prism is pre-production and unaudited. A permissionless supplier is not a trusted
56
- computing environment; do not lease with a wallet or workload you cannot lose.
File without changes