memwal 0.1.0.dev2__tar.gz → 0.1.0.dev3__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.
- {memwal-0.1.0.dev2 → memwal-0.1.0.dev3}/PKG-INFO +1 -1
- {memwal-0.1.0.dev2 → memwal-0.1.0.dev3}/memwal/__init__.py +1 -1
- {memwal-0.1.0.dev2 → memwal-0.1.0.dev3}/memwal/client.py +151 -17
- {memwal-0.1.0.dev2 → memwal-0.1.0.dev3}/memwal/utils.py +118 -0
- {memwal-0.1.0.dev2 → memwal-0.1.0.dev3}/pyproject.toml +1 -1
- {memwal-0.1.0.dev2 → memwal-0.1.0.dev3}/tests/test_client.py +110 -1
- {memwal-0.1.0.dev2 → memwal-0.1.0.dev3}/tests/test_integration.py +21 -10
- {memwal-0.1.0.dev2 → memwal-0.1.0.dev3}/tests/test_middleware.py +43 -0
- {memwal-0.1.0.dev2 → memwal-0.1.0.dev3}/tests/test_signing.py +30 -0
- {memwal-0.1.0.dev2 → memwal-0.1.0.dev3}/.gitignore +0 -0
- {memwal-0.1.0.dev2 → memwal-0.1.0.dev3}/README.md +0 -0
- {memwal-0.1.0.dev2 → memwal-0.1.0.dev3}/examples/.env.example +0 -0
- {memwal-0.1.0.dev2 → memwal-0.1.0.dev3}/examples/.gitignore +0 -0
- {memwal-0.1.0.dev2 → memwal-0.1.0.dev3}/examples/async_remember_demo.py +0 -0
- {memwal-0.1.0.dev2 → memwal-0.1.0.dev3}/examples/interactive_demo.py +0 -0
- {memwal-0.1.0.dev2 → memwal-0.1.0.dev3}/memwal/middleware.py +0 -0
- {memwal-0.1.0.dev2 → memwal-0.1.0.dev3}/memwal/types.py +0 -0
- {memwal-0.1.0.dev2 → memwal-0.1.0.dev3}/run_tests.py +0 -0
- {memwal-0.1.0.dev2 → memwal-0.1.0.dev3}/tests/__init__.py +0 -0
- {memwal-0.1.0.dev2 → memwal-0.1.0.dev3}/tests/test_env_presets.py +0 -0
|
@@ -26,12 +26,14 @@ Example::
|
|
|
26
26
|
from __future__ import annotations
|
|
27
27
|
|
|
28
28
|
import asyncio
|
|
29
|
+
import base64
|
|
29
30
|
import json
|
|
30
31
|
import random
|
|
31
32
|
import time
|
|
32
33
|
from typing import Any, Dict, List, Optional, Sequence, Tuple, TypeVar
|
|
33
34
|
|
|
34
35
|
import httpx
|
|
36
|
+
import nacl.signing
|
|
35
37
|
|
|
36
38
|
from .types import (
|
|
37
39
|
AnalyzedFact,
|
|
@@ -62,14 +64,20 @@ from .types import (
|
|
|
62
64
|
RestoreResult,
|
|
63
65
|
)
|
|
64
66
|
from .utils import (
|
|
67
|
+
build_seal_session_personal_message,
|
|
65
68
|
build_signature_message,
|
|
66
69
|
build_signing_key,
|
|
67
70
|
bytes_to_hex,
|
|
71
|
+
delegate_key_to_sui_address,
|
|
72
|
+
encode_sui_private_key,
|
|
68
73
|
sha256_hex,
|
|
69
74
|
sign_message,
|
|
75
|
+
sign_sui_personal_message,
|
|
70
76
|
)
|
|
71
77
|
|
|
72
78
|
T = TypeVar("T")
|
|
79
|
+
SEAL_SESSION_TTL_MIN = 5
|
|
80
|
+
SEAL_SESSION_SAFETY_MARGIN_MS = 30_000
|
|
73
81
|
|
|
74
82
|
|
|
75
83
|
# ============================================================
|
|
@@ -123,6 +131,9 @@ class MemWal:
|
|
|
123
131
|
self._server_url = config.server_url.rstrip("/")
|
|
124
132
|
self._namespace = config.namespace
|
|
125
133
|
self._client: Optional[httpx.AsyncClient] = None
|
|
134
|
+
self._server_config: Optional[Dict[str, str]] = None
|
|
135
|
+
self._session_cache: Optional[Tuple[str, int]] = None
|
|
136
|
+
self._session_build_task: Optional[asyncio.Task[str]] = None
|
|
126
137
|
|
|
127
138
|
@classmethod
|
|
128
139
|
def create(
|
|
@@ -659,11 +670,16 @@ class MemWal:
|
|
|
659
670
|
Returns:
|
|
660
671
|
:class:`RememberManualResult` with id, blob_id, owner, namespace.
|
|
661
672
|
"""
|
|
662
|
-
data = await self._signed_request(
|
|
663
|
-
"
|
|
664
|
-
"
|
|
665
|
-
|
|
666
|
-
|
|
673
|
+
data = await self._signed_request(
|
|
674
|
+
"POST",
|
|
675
|
+
"/api/remember/manual",
|
|
676
|
+
{
|
|
677
|
+
"blob_id": opts.blob_id,
|
|
678
|
+
"vector": opts.vector,
|
|
679
|
+
"namespace": opts.namespace or self._namespace,
|
|
680
|
+
},
|
|
681
|
+
include_seal_session=False,
|
|
682
|
+
)
|
|
667
683
|
return RememberManualResult(
|
|
668
684
|
id=data["id"],
|
|
669
685
|
blob_id=data["blob_id"],
|
|
@@ -683,11 +699,16 @@ class MemWal:
|
|
|
683
699
|
Returns:
|
|
684
700
|
:class:`RecallManualResult` with blob_id + distance pairs.
|
|
685
701
|
"""
|
|
686
|
-
data = await self._signed_request(
|
|
687
|
-
"
|
|
688
|
-
"
|
|
689
|
-
|
|
690
|
-
|
|
702
|
+
data = await self._signed_request(
|
|
703
|
+
"POST",
|
|
704
|
+
"/api/recall/manual",
|
|
705
|
+
{
|
|
706
|
+
"vector": opts.vector,
|
|
707
|
+
"limit": opts.limit,
|
|
708
|
+
"namespace": opts.namespace or self._namespace,
|
|
709
|
+
},
|
|
710
|
+
include_seal_session=False,
|
|
711
|
+
)
|
|
691
712
|
hits = [
|
|
692
713
|
RecallManualHit(blob_id=h["blob_id"], distance=h["distance"])
|
|
693
714
|
for h in data.get("results", [])
|
|
@@ -706,29 +727,141 @@ class MemWal:
|
|
|
706
727
|
# Internal: Signed HTTP Requests
|
|
707
728
|
# ============================================================
|
|
708
729
|
|
|
730
|
+
async def _fetch_server_config(self) -> Dict[str, str]:
|
|
731
|
+
if self._server_config is not None:
|
|
732
|
+
return self._server_config
|
|
733
|
+
|
|
734
|
+
response = await self._http.get(f"{self._server_url}/config")
|
|
735
|
+
if response.status_code != 200:
|
|
736
|
+
raise MemWalError(f"GET /config returned {response.status_code}")
|
|
737
|
+
|
|
738
|
+
data = response.json()
|
|
739
|
+
package_id = data.get("packageId")
|
|
740
|
+
network = data.get("network")
|
|
741
|
+
sui_rpc_url = data.get("suiRpcUrl")
|
|
742
|
+
if not package_id or not network or not sui_rpc_url:
|
|
743
|
+
raise MemWalError("GET /config response missing packageId / network / suiRpcUrl")
|
|
744
|
+
|
|
745
|
+
self._server_config = {
|
|
746
|
+
"packageId": package_id,
|
|
747
|
+
"network": network,
|
|
748
|
+
"suiRpcUrl": sui_rpc_url,
|
|
749
|
+
}
|
|
750
|
+
return self._server_config
|
|
751
|
+
|
|
752
|
+
async def _assert_first_package_version(self, sui_rpc_url: str, package_id: str) -> None:
|
|
753
|
+
response = await self._http.post(
|
|
754
|
+
sui_rpc_url,
|
|
755
|
+
json={
|
|
756
|
+
"jsonrpc": "2.0",
|
|
757
|
+
"id": 1,
|
|
758
|
+
"method": "sui_getObject",
|
|
759
|
+
"params": [package_id, {"showBcs": False, "showContent": False, "showType": False}],
|
|
760
|
+
},
|
|
761
|
+
)
|
|
762
|
+
if response.status_code != 200:
|
|
763
|
+
raise MemWalError(f"sui_getObject returned {response.status_code}")
|
|
764
|
+
|
|
765
|
+
body = response.json()
|
|
766
|
+
result = body.get("result", {})
|
|
767
|
+
version = None
|
|
768
|
+
if isinstance(result, dict):
|
|
769
|
+
data = result.get("data")
|
|
770
|
+
if isinstance(data, dict):
|
|
771
|
+
version = data.get("version")
|
|
772
|
+
if version is None:
|
|
773
|
+
obj = result.get("object")
|
|
774
|
+
if isinstance(obj, dict):
|
|
775
|
+
version = obj.get("version")
|
|
776
|
+
if str(version) != "1":
|
|
777
|
+
raise MemWalError(
|
|
778
|
+
f"SEAL package {package_id} must be at version 1 to build x-seal-session, got {version!r}"
|
|
779
|
+
)
|
|
780
|
+
|
|
781
|
+
async def _build_seal_session_inner(self) -> str:
|
|
782
|
+
cfg = await self._fetch_server_config()
|
|
783
|
+
await self._assert_first_package_version(cfg["suiRpcUrl"], cfg["packageId"])
|
|
784
|
+
|
|
785
|
+
session_signing_key = nacl.signing.SigningKey.generate()
|
|
786
|
+
session_public_key = bytes(session_signing_key.verify_key)
|
|
787
|
+
creation_time_ms = int(time.time() * 1000)
|
|
788
|
+
personal_message = build_seal_session_personal_message(
|
|
789
|
+
package_id=cfg["packageId"],
|
|
790
|
+
ttl_min=SEAL_SESSION_TTL_MIN,
|
|
791
|
+
creation_time_ms=creation_time_ms,
|
|
792
|
+
session_public_key_bytes=session_public_key,
|
|
793
|
+
)
|
|
794
|
+
personal_message_signature = sign_sui_personal_message(
|
|
795
|
+
personal_message,
|
|
796
|
+
self._signing_key,
|
|
797
|
+
)
|
|
798
|
+
|
|
799
|
+
json_str = json.dumps(
|
|
800
|
+
{
|
|
801
|
+
"address": delegate_key_to_sui_address(self._private_key_hex),
|
|
802
|
+
"packageId": cfg["packageId"],
|
|
803
|
+
"mvrName": None,
|
|
804
|
+
"creationTimeMs": creation_time_ms,
|
|
805
|
+
"ttlMin": SEAL_SESSION_TTL_MIN,
|
|
806
|
+
"personalMessageSignature": personal_message_signature,
|
|
807
|
+
"sessionKey": encode_sui_private_key(bytes(session_signing_key)),
|
|
808
|
+
},
|
|
809
|
+
separators=(",", ":"),
|
|
810
|
+
)
|
|
811
|
+
session_bytes = base64.b64encode(json_str.encode("utf-8")).decode("utf-8")
|
|
812
|
+
self._session_cache = (
|
|
813
|
+
session_bytes,
|
|
814
|
+
int(time.time() * 1000) + SEAL_SESSION_TTL_MIN * 60_000 - SEAL_SESSION_SAFETY_MARGIN_MS,
|
|
815
|
+
)
|
|
816
|
+
return session_bytes
|
|
817
|
+
|
|
818
|
+
async def _build_seal_session(self) -> str:
|
|
819
|
+
now_ms = int(time.time() * 1000)
|
|
820
|
+
if self._session_cache is not None:
|
|
821
|
+
cached_bytes, expires_at_ms = self._session_cache
|
|
822
|
+
if now_ms < expires_at_ms:
|
|
823
|
+
return cached_bytes
|
|
824
|
+
|
|
825
|
+
if self._session_build_task is not None:
|
|
826
|
+
return await self._session_build_task
|
|
827
|
+
|
|
828
|
+
self._session_build_task = asyncio.create_task(self._build_seal_session_inner())
|
|
829
|
+
try:
|
|
830
|
+
return await self._session_build_task
|
|
831
|
+
finally:
|
|
832
|
+
self._session_build_task = None
|
|
833
|
+
|
|
709
834
|
async def _signed_request(
|
|
710
835
|
self,
|
|
711
836
|
method: str,
|
|
712
837
|
path: str,
|
|
713
838
|
body: Dict[str, Any],
|
|
714
839
|
accepted_statuses: tuple = (200,),
|
|
840
|
+
include_seal_session: bool = True,
|
|
715
841
|
) -> Dict[str, Any]:
|
|
716
842
|
"""Make a signed request to the server.
|
|
717
843
|
|
|
718
|
-
Signature format:
|
|
844
|
+
Signature format:
|
|
845
|
+
``{timestamp}.{method}.{path}.{body_sha256}.{nonce}.{account_id}``
|
|
846
|
+
|
|
847
|
+
For ``GET`` requests the canonical body string is the empty string,
|
|
848
|
+
and no HTTP request body is sent. This keeps the signed payload hash
|
|
849
|
+
byte-compatible with the TypeScript SDK and with intermediaries that
|
|
850
|
+
strip ``GET`` bodies on the wire.
|
|
719
851
|
|
|
720
852
|
Headers sent:
|
|
721
853
|
- ``x-public-key``: Ed25519 public key hex
|
|
722
854
|
- ``x-signature``: Ed25519 signature hex
|
|
723
855
|
- ``x-timestamp``: Unix seconds string
|
|
724
|
-
- ``x-
|
|
856
|
+
- ``x-seal-session``: Base64-encoded exported session envelope
|
|
725
857
|
- ``x-account-id``: MemWalAccount object ID
|
|
726
858
|
- ``Content-Type``: application/json
|
|
727
859
|
"""
|
|
728
860
|
import uuid
|
|
729
861
|
|
|
862
|
+
method_upper = method.upper()
|
|
730
863
|
timestamp = str(int(time.time()))
|
|
731
|
-
body_str = json.dumps(body, separators=(",", ":"))
|
|
864
|
+
body_str = "" if method_upper == "GET" else json.dumps(body, separators=(",", ":"))
|
|
732
865
|
body_hash = sha256_hex(body_str)
|
|
733
866
|
# MED-1 / LOW-23: nonce + account_id are part of the canonical signed
|
|
734
867
|
# message. Server rejects the request as "unsupported legacy SDK"
|
|
@@ -737,7 +870,7 @@ class MemWal:
|
|
|
737
870
|
|
|
738
871
|
message = build_signature_message(
|
|
739
872
|
timestamp,
|
|
740
|
-
|
|
873
|
+
method_upper,
|
|
741
874
|
path,
|
|
742
875
|
body_hash,
|
|
743
876
|
nonce=nonce,
|
|
@@ -752,15 +885,16 @@ class MemWal:
|
|
|
752
885
|
"x-signature": signature_hex,
|
|
753
886
|
"x-timestamp": timestamp,
|
|
754
887
|
"x-nonce": nonce,
|
|
755
|
-
"x-delegate-key": self._private_key_hex,
|
|
756
888
|
"x-account-id": self._account_id,
|
|
757
889
|
}
|
|
890
|
+
if include_seal_session:
|
|
891
|
+
headers["x-seal-session"] = await self._build_seal_session()
|
|
758
892
|
|
|
759
893
|
response = await self._http.request(
|
|
760
|
-
method=
|
|
894
|
+
method=method_upper,
|
|
761
895
|
url=url,
|
|
762
896
|
headers=headers,
|
|
763
|
-
content=body_str,
|
|
897
|
+
content=None if method_upper == "GET" else body_str,
|
|
764
898
|
)
|
|
765
899
|
|
|
766
900
|
if response.status_code not in accepted_statuses:
|
|
@@ -7,11 +7,16 @@ Uses PyNaCl (nacl.signing) as the primary Ed25519 implementation.
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
import base64
|
|
10
11
|
import hashlib
|
|
12
|
+
from datetime import datetime, timezone
|
|
11
13
|
from typing import Tuple
|
|
12
14
|
|
|
13
15
|
import nacl.signing
|
|
14
16
|
|
|
17
|
+
_BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
|
18
|
+
_SUI_ED25519_SCHEME_FLAG = 0
|
|
19
|
+
|
|
15
20
|
|
|
16
21
|
def hex_to_bytes(hex_str: str) -> bytes:
|
|
17
22
|
"""Convert a hex string to bytes.
|
|
@@ -170,3 +175,116 @@ def delegate_key_to_public_key(private_key_hex: str) -> bytes:
|
|
|
170
175
|
"""
|
|
171
176
|
signing_key = build_signing_key(private_key_hex)
|
|
172
177
|
return bytes(signing_key.verify_key)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _bech32_polymod(values: bytes) -> int:
|
|
181
|
+
generators = [
|
|
182
|
+
0x3B6A57B2,
|
|
183
|
+
0x26508E6D,
|
|
184
|
+
0x1EA119FA,
|
|
185
|
+
0x3D4233DD,
|
|
186
|
+
0x2A1462B3,
|
|
187
|
+
]
|
|
188
|
+
chk = 1
|
|
189
|
+
for value in values:
|
|
190
|
+
top = chk >> 25
|
|
191
|
+
chk = ((chk & 0x1FFFFFF) << 5) ^ value
|
|
192
|
+
for i in range(5):
|
|
193
|
+
if (top >> i) & 1:
|
|
194
|
+
chk ^= generators[i]
|
|
195
|
+
return chk
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _bech32_hrp_expand(hrp: str) -> bytes:
|
|
199
|
+
return bytes([ord(x) >> 5 for x in hrp] + [0] + [ord(x) & 31 for x in hrp])
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _bech32_create_checksum(hrp: str, data: bytes) -> bytes:
|
|
203
|
+
values = _bech32_hrp_expand(hrp) + data
|
|
204
|
+
polymod = _bech32_polymod(values + bytes(6)) ^ 1
|
|
205
|
+
return bytes((polymod >> 5 * (5 - i)) & 31 for i in range(6))
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _convertbits(data: bytes, frombits: int, tobits: int, pad: bool = True) -> bytes:
|
|
209
|
+
acc = 0
|
|
210
|
+
bits = 0
|
|
211
|
+
ret = []
|
|
212
|
+
maxv = (1 << tobits) - 1
|
|
213
|
+
max_acc = (1 << (frombits + tobits - 1)) - 1
|
|
214
|
+
for value in data:
|
|
215
|
+
if value < 0 or value >> frombits:
|
|
216
|
+
raise ValueError("invalid value for convertbits")
|
|
217
|
+
acc = ((acc << frombits) | value) & max_acc
|
|
218
|
+
bits += frombits
|
|
219
|
+
while bits >= tobits:
|
|
220
|
+
bits -= tobits
|
|
221
|
+
ret.append((acc >> bits) & maxv)
|
|
222
|
+
if pad:
|
|
223
|
+
if bits:
|
|
224
|
+
ret.append((acc << (tobits - bits)) & maxv)
|
|
225
|
+
elif bits >= frombits or ((acc << (tobits - bits)) & maxv):
|
|
226
|
+
raise ValueError("invalid incomplete group for convertbits")
|
|
227
|
+
return bytes(ret)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def bech32_encode(hrp: str, data: bytes) -> str:
|
|
231
|
+
combined = data + _bech32_create_checksum(hrp, data)
|
|
232
|
+
return hrp + "1" + "".join(_BECH32_CHARSET[d] for d in combined)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def encode_sui_private_key(seed_bytes: bytes) -> str:
|
|
236
|
+
"""Encode a 32-byte Ed25519 seed to Sui bech32 `suiprivkey...` format."""
|
|
237
|
+
if len(seed_bytes) != 32:
|
|
238
|
+
raise ValueError(f"Ed25519 seed must be exactly 32 bytes, got {len(seed_bytes)}")
|
|
239
|
+
payload = bytes([_SUI_ED25519_SCHEME_FLAG]) + seed_bytes
|
|
240
|
+
return bech32_encode("suiprivkey", _convertbits(payload, 8, 5))
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def uleb128_encode(value: int) -> bytes:
|
|
244
|
+
"""Encode an integer using ULEB128."""
|
|
245
|
+
if value < 0:
|
|
246
|
+
raise ValueError("ULEB128 only supports non-negative integers")
|
|
247
|
+
encoded = bytearray()
|
|
248
|
+
while True:
|
|
249
|
+
byte = value & 0x7F
|
|
250
|
+
value >>= 7
|
|
251
|
+
if value:
|
|
252
|
+
encoded.append(byte | 0x80)
|
|
253
|
+
else:
|
|
254
|
+
encoded.append(byte)
|
|
255
|
+
return bytes(encoded)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def serialize_bcs_byte_vector(value: bytes) -> bytes:
|
|
259
|
+
"""BCS `vector<u8>` encoding: ULEB128 length prefix followed by bytes."""
|
|
260
|
+
return uleb128_encode(len(value)) + value
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def build_seal_session_personal_message(
|
|
264
|
+
package_id: str,
|
|
265
|
+
ttl_min: int,
|
|
266
|
+
creation_time_ms: int,
|
|
267
|
+
session_public_key_bytes: bytes,
|
|
268
|
+
) -> bytes:
|
|
269
|
+
"""Build the SEAL SessionKey personal message string expected by Mysten SEAL."""
|
|
270
|
+
creation_time_utc = datetime.fromtimestamp(
|
|
271
|
+
creation_time_ms / 1000, tz=timezone.utc
|
|
272
|
+
).strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
273
|
+
session_public_key_b64 = base64.b64encode(session_public_key_bytes).decode("ascii")
|
|
274
|
+
message = (
|
|
275
|
+
f"Accessing keys of package {package_id} for {ttl_min} mins from "
|
|
276
|
+
f"{creation_time_utc}, session key {session_public_key_b64}"
|
|
277
|
+
)
|
|
278
|
+
return message.encode("utf-8")
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def sign_sui_personal_message(
|
|
282
|
+
message: bytes, signing_key: nacl.signing.SigningKey
|
|
283
|
+
) -> str:
|
|
284
|
+
"""Sign a Sui PersonalMessage and return serialized base64 signature."""
|
|
285
|
+
intent_message = b"\x03\x00\x00" + serialize_bcs_byte_vector(message)
|
|
286
|
+
digest = hashlib.blake2b(intent_message, digest_size=32).digest()
|
|
287
|
+
signature_bytes = signing_key.sign(digest).signature
|
|
288
|
+
public_key_bytes = bytes(signing_key.verify_key)
|
|
289
|
+
serialized = bytes([_SUI_ED25519_SCHEME_FLAG]) + signature_bytes + public_key_bytes
|
|
290
|
+
return base64.b64encode(serialized).decode("ascii")
|
|
@@ -7,7 +7,9 @@ that the client sends correct headers, body, and handles errors.
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
import base64
|
|
10
11
|
import json
|
|
12
|
+
from typing import Any
|
|
11
13
|
|
|
12
14
|
import httpx
|
|
13
15
|
import nacl.signing
|
|
@@ -29,6 +31,38 @@ _TEST_KEY_HEX = bytes_to_hex(bytes(_TEST_KEY))
|
|
|
29
31
|
_TEST_PUB_HEX = bytes_to_hex(bytes(_TEST_KEY.verify_key))
|
|
30
32
|
_TEST_ACCOUNT_ID = "0xabc123"
|
|
31
33
|
_TEST_SERVER = "http://localhost:8000"
|
|
34
|
+
_TEST_PACKAGE_ID = "0x" + "11" * 32
|
|
35
|
+
_TEST_SUI_RPC = "http://localhost:9001"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def mock_seal_session_prereqs() -> None:
|
|
39
|
+
respx.get(f"{_TEST_SERVER}/config").mock(
|
|
40
|
+
return_value=httpx.Response(
|
|
41
|
+
200,
|
|
42
|
+
json={
|
|
43
|
+
"packageId": _TEST_PACKAGE_ID,
|
|
44
|
+
"network": "testnet",
|
|
45
|
+
"suiRpcUrl": _TEST_SUI_RPC,
|
|
46
|
+
},
|
|
47
|
+
)
|
|
48
|
+
)
|
|
49
|
+
respx.post(_TEST_SUI_RPC).mock(
|
|
50
|
+
return_value=httpx.Response(
|
|
51
|
+
200,
|
|
52
|
+
json={
|
|
53
|
+
"result": {
|
|
54
|
+
"data": {
|
|
55
|
+
"version": "1",
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def decode_seal_session_header(request: httpx.Request) -> dict[str, Any]:
|
|
64
|
+
header = request.headers["x-seal-session"]
|
|
65
|
+
return json.loads(base64.b64decode(header).decode("utf-8"))
|
|
32
66
|
|
|
33
67
|
|
|
34
68
|
@pytest.fixture
|
|
@@ -50,6 +84,7 @@ class TestRemember:
|
|
|
50
84
|
@respx.mock
|
|
51
85
|
async def test_sends_correct_body(self, memwal_client: MemWal) -> None:
|
|
52
86
|
"""remember() should POST to /api/remember with text and namespace."""
|
|
87
|
+
mock_seal_session_prereqs()
|
|
53
88
|
route = respx.post(f"{_TEST_SERVER}/api/remember").mock(
|
|
54
89
|
return_value=httpx.Response(
|
|
55
90
|
202,
|
|
@@ -73,6 +108,7 @@ class TestRemember:
|
|
|
73
108
|
@respx.mock
|
|
74
109
|
async def test_sends_correct_headers(self, memwal_client: MemWal) -> None:
|
|
75
110
|
"""remember() should include all required auth headers."""
|
|
111
|
+
mock_seal_session_prereqs()
|
|
76
112
|
route = respx.post(f"{_TEST_SERVER}/api/remember").mock(
|
|
77
113
|
return_value=httpx.Response(
|
|
78
114
|
202,
|
|
@@ -95,13 +131,22 @@ class TestRemember:
|
|
|
95
131
|
assert "x-timestamp" in headers
|
|
96
132
|
assert headers["x-timestamp"].isdigit()
|
|
97
133
|
assert "x-nonce" in headers
|
|
98
|
-
assert headers["x-delegate-key"] == _TEST_KEY_HEX
|
|
99
134
|
assert headers["x-account-id"] == _TEST_ACCOUNT_ID
|
|
135
|
+
assert "x-seal-session" in headers
|
|
136
|
+
assert "x-delegate-key" not in headers
|
|
100
137
|
assert headers["content-type"] == "application/json"
|
|
101
138
|
|
|
139
|
+
session = decode_seal_session_header(request)
|
|
140
|
+
assert session["address"].startswith("0x")
|
|
141
|
+
assert session["packageId"] == _TEST_PACKAGE_ID
|
|
142
|
+
assert session["ttlMin"] == 5
|
|
143
|
+
assert session["sessionKey"].startswith("suiprivkey")
|
|
144
|
+
assert session["personalMessageSignature"]
|
|
145
|
+
|
|
102
146
|
@respx.mock
|
|
103
147
|
async def test_signature_is_verifiable(self, memwal_client: MemWal) -> None:
|
|
104
148
|
"""The signature in headers should be verifiable with the public key."""
|
|
149
|
+
mock_seal_session_prereqs()
|
|
105
150
|
route = respx.post(f"{_TEST_SERVER}/api/remember").mock(
|
|
106
151
|
return_value=httpx.Response(
|
|
107
152
|
202,
|
|
@@ -141,6 +186,7 @@ class TestRemember:
|
|
|
141
186
|
@respx.mock
|
|
142
187
|
async def test_custom_namespace(self, memwal_client: MemWal) -> None:
|
|
143
188
|
"""remember() should use custom namespace when provided."""
|
|
189
|
+
mock_seal_session_prereqs()
|
|
144
190
|
route = respx.post(f"{_TEST_SERVER}/api/remember").mock(
|
|
145
191
|
return_value=httpx.Response(
|
|
146
192
|
202,
|
|
@@ -168,6 +214,7 @@ class TestRecall:
|
|
|
168
214
|
@respx.mock
|
|
169
215
|
async def test_sends_correct_body(self, memwal_client: MemWal) -> None:
|
|
170
216
|
"""recall() should POST to /api/recall with query, limit, namespace."""
|
|
217
|
+
mock_seal_session_prereqs()
|
|
171
218
|
route = respx.post(f"{_TEST_SERVER}/api/recall").mock(
|
|
172
219
|
return_value=httpx.Response(
|
|
173
220
|
200,
|
|
@@ -198,6 +245,7 @@ class TestRecall:
|
|
|
198
245
|
@respx.mock
|
|
199
246
|
async def test_sends_correct_headers(self, memwal_client: MemWal) -> None:
|
|
200
247
|
"""recall() should include all required auth headers."""
|
|
248
|
+
mock_seal_session_prereqs()
|
|
201
249
|
route = respx.post(f"{_TEST_SERVER}/api/recall").mock(
|
|
202
250
|
return_value=httpx.Response(
|
|
203
251
|
200,
|
|
@@ -211,6 +259,38 @@ class TestRecall:
|
|
|
211
259
|
assert headers["x-public-key"] == _TEST_PUB_HEX
|
|
212
260
|
assert len(headers["x-signature"]) == 128
|
|
213
261
|
assert headers["x-account-id"] == _TEST_ACCOUNT_ID
|
|
262
|
+
assert "x-seal-session" in headers
|
|
263
|
+
assert "x-delegate-key" not in headers
|
|
264
|
+
|
|
265
|
+
@respx.mock
|
|
266
|
+
async def test_get_signed_request_uses_empty_body_hash_and_no_wire_body(
|
|
267
|
+
self, memwal_client: MemWal
|
|
268
|
+
) -> None:
|
|
269
|
+
mock_seal_session_prereqs()
|
|
270
|
+
route = respx.get(f"{_TEST_SERVER}/api/remember/job-1").mock(
|
|
271
|
+
return_value=httpx.Response(
|
|
272
|
+
200,
|
|
273
|
+
json={"job_id": "job-1", "status": "done", "blob_id": "b1", "owner": "0xowner"},
|
|
274
|
+
)
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
result = await memwal_client.wait_for_remember_job("job-1", poll_interval_ms=0, timeout_ms=100)
|
|
278
|
+
|
|
279
|
+
request = route.calls[0].request
|
|
280
|
+
assert request.content == b""
|
|
281
|
+
|
|
282
|
+
headers = request.headers
|
|
283
|
+
message = build_signature_message(
|
|
284
|
+
timestamp=headers["x-timestamp"],
|
|
285
|
+
method="GET",
|
|
286
|
+
path="/api/remember/job-1",
|
|
287
|
+
body_sha256=sha256_hex(""),
|
|
288
|
+
nonce=headers["x-nonce"],
|
|
289
|
+
account_id=headers["x-account-id"],
|
|
290
|
+
)
|
|
291
|
+
verify_key = nacl.signing.VerifyKey(bytes.fromhex(headers["x-public-key"]))
|
|
292
|
+
verify_key.verify(message.encode("utf-8"), bytes.fromhex(headers["x-signature"]))
|
|
293
|
+
assert result.blob_id == "b1"
|
|
214
294
|
|
|
215
295
|
|
|
216
296
|
# ============================================================
|
|
@@ -222,6 +302,7 @@ class TestErrorHandling:
|
|
|
222
302
|
@respx.mock
|
|
223
303
|
async def test_non_200_raises_memwal_error(self, memwal_client: MemWal) -> None:
|
|
224
304
|
"""Non-200 responses should raise MemWalError with status and body."""
|
|
305
|
+
mock_seal_session_prereqs()
|
|
225
306
|
respx.post(f"{_TEST_SERVER}/api/remember").mock(
|
|
226
307
|
return_value=httpx.Response(
|
|
227
308
|
401,
|
|
@@ -235,6 +316,7 @@ class TestErrorHandling:
|
|
|
235
316
|
@respx.mock
|
|
236
317
|
async def test_500_raises_memwal_error(self, memwal_client: MemWal) -> None:
|
|
237
318
|
"""Server errors should raise MemWalError."""
|
|
319
|
+
mock_seal_session_prereqs()
|
|
238
320
|
respx.post(f"{_TEST_SERVER}/api/recall").mock(
|
|
239
321
|
return_value=httpx.Response(
|
|
240
322
|
500,
|
|
@@ -264,6 +346,7 @@ class TestErrorHandling:
|
|
|
264
346
|
class TestAnalyze:
|
|
265
347
|
@respx.mock
|
|
266
348
|
async def test_analyze(self, memwal_client: MemWal) -> None:
|
|
349
|
+
mock_seal_session_prereqs()
|
|
267
350
|
route = respx.post(f"{_TEST_SERVER}/api/analyze").mock(
|
|
268
351
|
return_value=httpx.Response(
|
|
269
352
|
200,
|
|
@@ -289,6 +372,7 @@ class TestAnalyze:
|
|
|
289
372
|
class TestRestore:
|
|
290
373
|
@respx.mock
|
|
291
374
|
async def test_restore(self, memwal_client: MemWal) -> None:
|
|
375
|
+
mock_seal_session_prereqs()
|
|
292
376
|
route = respx.post(f"{_TEST_SERVER}/api/restore").mock(
|
|
293
377
|
return_value=httpx.Response(
|
|
294
378
|
200,
|
|
@@ -348,8 +432,11 @@ class TestManualAPI:
|
|
|
348
432
|
result = await memwal_client.remember_manual(opts)
|
|
349
433
|
|
|
350
434
|
body = json.loads(route.calls[0].request.content)
|
|
435
|
+
headers = route.calls[0].request.headers
|
|
351
436
|
assert body["blob_id"] == "blob-xyz"
|
|
352
437
|
assert body["vector"] == [0.1, 0.2, 0.3]
|
|
438
|
+
assert "x-seal-session" not in headers
|
|
439
|
+
assert "x-delegate-key" not in headers
|
|
353
440
|
assert result.blob_id == "blob-xyz"
|
|
354
441
|
|
|
355
442
|
@respx.mock
|
|
@@ -370,8 +457,11 @@ class TestManualAPI:
|
|
|
370
457
|
result = await memwal_client.recall_manual(opts)
|
|
371
458
|
|
|
372
459
|
body = json.loads(route.calls[0].request.content)
|
|
460
|
+
headers = route.calls[0].request.headers
|
|
373
461
|
assert body["vector"] == [0.1, 0.2, 0.3]
|
|
374
462
|
assert body["limit"] == 5
|
|
463
|
+
assert "x-seal-session" not in headers
|
|
464
|
+
assert "x-delegate-key" not in headers
|
|
375
465
|
assert len(result.results) == 1
|
|
376
466
|
assert result.results[0].blob_id == "b1"
|
|
377
467
|
|
|
@@ -386,6 +476,7 @@ class TestPublicKey:
|
|
|
386
476
|
class TestAsk:
|
|
387
477
|
@respx.mock
|
|
388
478
|
async def test_ask(self, memwal_client: MemWal) -> None:
|
|
479
|
+
mock_seal_session_prereqs()
|
|
389
480
|
route = respx.post(f"{_TEST_SERVER}/api/ask").mock(
|
|
390
481
|
return_value=httpx.Response(
|
|
391
482
|
200,
|
|
@@ -413,6 +504,7 @@ class TestAsk:
|
|
|
413
504
|
|
|
414
505
|
@respx.mock
|
|
415
506
|
async def test_ask_empty_memories(self, memwal_client: MemWal) -> None:
|
|
507
|
+
mock_seal_session_prereqs()
|
|
416
508
|
respx.post(f"{_TEST_SERVER}/api/ask").mock(
|
|
417
509
|
return_value=httpx.Response(
|
|
418
510
|
200,
|
|
@@ -430,6 +522,7 @@ class TestAsk:
|
|
|
430
522
|
|
|
431
523
|
@respx.mock
|
|
432
524
|
async def test_ask_custom_namespace(self, memwal_client: MemWal) -> None:
|
|
525
|
+
mock_seal_session_prereqs()
|
|
433
526
|
route = respx.post(f"{_TEST_SERVER}/api/ask").mock(
|
|
434
527
|
return_value=httpx.Response(
|
|
435
528
|
200,
|
|
@@ -461,3 +554,19 @@ class TestContextManager:
|
|
|
461
554
|
) as client:
|
|
462
555
|
result = await client.health()
|
|
463
556
|
assert result.status == "ok"
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
class TestSealSession:
|
|
560
|
+
@respx.mock
|
|
561
|
+
async def test_builds_cached_session_envelope(self, memwal_client: MemWal) -> None:
|
|
562
|
+
mock_seal_session_prereqs()
|
|
563
|
+
|
|
564
|
+
first = await memwal_client._build_seal_session()
|
|
565
|
+
second = await memwal_client._build_seal_session()
|
|
566
|
+
|
|
567
|
+
exported = json.loads(base64.b64decode(first).decode("utf-8"))
|
|
568
|
+
assert first == second
|
|
569
|
+
assert exported["packageId"] == _TEST_PACKAGE_ID
|
|
570
|
+
assert exported["ttlMin"] == 5
|
|
571
|
+
assert exported["sessionKey"].startswith("suiprivkey")
|
|
572
|
+
assert exported["personalMessageSignature"]
|
|
@@ -13,7 +13,8 @@ No-auth tests (always run, no env vars needed):
|
|
|
13
13
|
- Unregistered key → SDK raises MemWalError
|
|
14
14
|
|
|
15
15
|
Authenticated tests (require MEMWAL_KEY + MEMWAL_ACCOUNT_ID):
|
|
16
|
-
- remember()
|
|
16
|
+
- remember() acceptance
|
|
17
|
+
- remember_and_wait()
|
|
17
18
|
- recall()
|
|
18
19
|
- analyze()
|
|
19
20
|
- ask()
|
|
@@ -181,30 +182,39 @@ class TestAuthRejection:
|
|
|
181
182
|
|
|
182
183
|
@requires_key
|
|
183
184
|
class TestRemember:
|
|
184
|
-
"""remember() against live server."""
|
|
185
|
+
"""remember() / remember_and_wait() against live server."""
|
|
185
186
|
|
|
186
|
-
def
|
|
187
|
+
def test_remember_returns_job_id_and_status(self) -> None:
|
|
187
188
|
mw = MemWalSync.create(
|
|
188
189
|
key=PRIVATE_KEY_HEX, account_id=ACCOUNT_ID, server_url=SERVER_URL
|
|
189
190
|
)
|
|
190
191
|
result = mw.remember("Integration test: the sky is blue", namespace="sdk-test")
|
|
192
|
+
assert result.job_id is not None and isinstance(result.job_id, str)
|
|
193
|
+
assert result.status in ("pending", "running")
|
|
194
|
+
print(f"\n accepted job={result.job_id[:8]}... status={result.status}")
|
|
195
|
+
|
|
196
|
+
def test_remember_and_wait_returns_blob_and_owner(self) -> None:
|
|
197
|
+
mw = MemWalSync.create(
|
|
198
|
+
key=PRIVATE_KEY_HEX, account_id=ACCOUNT_ID, server_url=SERVER_URL
|
|
199
|
+
)
|
|
200
|
+
result = mw.remember_and_wait("Integration test: the sky is blue", namespace="sdk-test")
|
|
191
201
|
assert result.id is not None and isinstance(result.id, str)
|
|
192
202
|
assert result.blob_id is not None and isinstance(result.blob_id, str)
|
|
193
203
|
assert result.owner.startswith("0x")
|
|
194
|
-
print(f"\n
|
|
204
|
+
print(f"\n done job={result.id[:8]}... blob={result.blob_id[:8]}...")
|
|
195
205
|
|
|
196
206
|
def test_remember_default_namespace(self) -> None:
|
|
197
207
|
mw = MemWalSync.create(
|
|
198
208
|
key=PRIVATE_KEY_HEX, account_id=ACCOUNT_ID, server_url=SERVER_URL
|
|
199
209
|
)
|
|
200
|
-
result = mw.
|
|
210
|
+
result = mw.remember_and_wait("Integration test: namespace default")
|
|
201
211
|
assert result.namespace == "default"
|
|
202
212
|
|
|
203
213
|
def test_remember_custom_namespace(self) -> None:
|
|
204
214
|
mw = MemWalSync.create(
|
|
205
215
|
key=PRIVATE_KEY_HEX, account_id=ACCOUNT_ID, server_url=SERVER_URL
|
|
206
216
|
)
|
|
207
|
-
result = mw.
|
|
217
|
+
result = mw.remember_and_wait("Integration test: custom namespace", namespace="sdk-test")
|
|
208
218
|
assert result.namespace == "sdk-test"
|
|
209
219
|
|
|
210
220
|
|
|
@@ -292,7 +302,7 @@ class TestFullFlow:
|
|
|
292
302
|
)
|
|
293
303
|
|
|
294
304
|
# Store a distinctive memory in an isolated namespace
|
|
295
|
-
mem = mw.
|
|
305
|
+
mem = mw.remember_and_wait(text, namespace=ns)
|
|
296
306
|
assert mem.id is not None
|
|
297
307
|
|
|
298
308
|
# Recall — should find the stored memory
|
|
@@ -309,7 +319,7 @@ class TestFullFlow:
|
|
|
309
319
|
mw = MemWalSync.create(
|
|
310
320
|
key=PRIVATE_KEY_HEX, account_id=ACCOUNT_ID, server_url=SERVER_URL
|
|
311
321
|
)
|
|
312
|
-
mw.
|
|
322
|
+
mw.remember_and_wait("I am allergic to shellfish", namespace="sdk-test")
|
|
313
323
|
result = mw.ask("What are my food allergies?", limit=3, namespace="sdk-test")
|
|
314
324
|
assert isinstance(result.answer, str)
|
|
315
325
|
assert len(result.answer) > 0
|
|
@@ -335,13 +345,14 @@ class TestAsync:
|
|
|
335
345
|
key=PRIVATE_KEY_HEX, account_id=ACCOUNT_ID, server_url=SERVER_URL
|
|
336
346
|
) as mw:
|
|
337
347
|
result = await mw.remember("Async SDK test: Paris is the capital of France")
|
|
338
|
-
assert result.
|
|
348
|
+
assert result.job_id is not None
|
|
349
|
+
assert result.status in ("pending", "running")
|
|
339
350
|
|
|
340
351
|
async def test_async_recall(self) -> None:
|
|
341
352
|
async with MemWal.create(
|
|
342
353
|
key=PRIVATE_KEY_HEX, account_id=ACCOUNT_ID, server_url=SERVER_URL
|
|
343
354
|
) as mw:
|
|
344
|
-
await mw.
|
|
355
|
+
await mw.remember_and_wait("Async SDK test: I enjoy reading")
|
|
345
356
|
result = await mw.recall("reading books", limit=3)
|
|
346
357
|
assert isinstance(result.results, list)
|
|
347
358
|
|
|
@@ -51,6 +51,37 @@ _SERVER = "http://localhost:8000"
|
|
|
51
51
|
|
|
52
52
|
_RECALL_URL = f"{_SERVER}/api/recall"
|
|
53
53
|
_ANALYZE_URL = f"{_SERVER}/api/analyze"
|
|
54
|
+
_SUI_RPC_URL = "http://localhost:9001"
|
|
55
|
+
_PACKAGE_ID = "0x" + "11" * 32
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _mock_seal_session_prereqs() -> None:
|
|
59
|
+
"""Register mocks for the SEAL session prerequisites.
|
|
60
|
+
|
|
61
|
+
Every relayer-mode signed request (recall, analyze, remember) now starts
|
|
62
|
+
by fetching ``GET /config`` and verifying the SEAL package version via
|
|
63
|
+
``sui_getObject``. Tests that drive MemWal through ``with_memwal_*`` must
|
|
64
|
+
register these or the first signed request raises
|
|
65
|
+
``AllMockedAssertionError`` from respx.
|
|
66
|
+
|
|
67
|
+
Mirrors the helper of the same name in ``test_client.py``.
|
|
68
|
+
"""
|
|
69
|
+
respx.get(f"{_SERVER}/config").mock(
|
|
70
|
+
return_value=httpx.Response(
|
|
71
|
+
200,
|
|
72
|
+
json={
|
|
73
|
+
"packageId": _PACKAGE_ID,
|
|
74
|
+
"network": "testnet",
|
|
75
|
+
"suiRpcUrl": _SUI_RPC_URL,
|
|
76
|
+
},
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
respx.post(_SUI_RPC_URL).mock(
|
|
80
|
+
return_value=httpx.Response(
|
|
81
|
+
200,
|
|
82
|
+
json={"result": {"data": {"version": "1"}}},
|
|
83
|
+
)
|
|
84
|
+
)
|
|
54
85
|
|
|
55
86
|
|
|
56
87
|
def _mock_recall(memories: list, *, total: int | None = None) -> httpx.Response:
|
|
@@ -213,6 +244,7 @@ class TestWithMemWalLangChain:
|
|
|
213
244
|
@respx.mock
|
|
214
245
|
async def test_memories_injected_as_system_message(self) -> None:
|
|
215
246
|
"""When memories are found, a SystemMessage is injected before the HumanMessage."""
|
|
247
|
+
_mock_seal_session_prereqs()
|
|
216
248
|
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
|
|
217
249
|
from langchain_core.outputs import ChatGeneration, ChatResult
|
|
218
250
|
|
|
@@ -249,6 +281,7 @@ class TestWithMemWalLangChain:
|
|
|
249
281
|
@respx.mock
|
|
250
282
|
async def test_no_memories_found_no_injection(self) -> None:
|
|
251
283
|
"""When no memories match, the message list is passed unchanged."""
|
|
284
|
+
_mock_seal_session_prereqs()
|
|
252
285
|
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
|
|
253
286
|
from langchain_core.outputs import ChatGeneration, ChatResult
|
|
254
287
|
|
|
@@ -278,6 +311,7 @@ class TestWithMemWalLangChain:
|
|
|
278
311
|
@respx.mock
|
|
279
312
|
async def test_min_relevance_filters_low_score_memories(self) -> None:
|
|
280
313
|
"""Memories below min_relevance are filtered out."""
|
|
314
|
+
_mock_seal_session_prereqs()
|
|
281
315
|
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
|
|
282
316
|
from langchain_core.outputs import ChatGeneration, ChatResult
|
|
283
317
|
|
|
@@ -313,6 +347,7 @@ class TestWithMemWalLangChain:
|
|
|
313
347
|
@respx.mock
|
|
314
348
|
async def test_recall_failure_does_not_block_llm(self) -> None:
|
|
315
349
|
"""If MemWal recall fails, the LLM is still called with original messages."""
|
|
350
|
+
_mock_seal_session_prereqs()
|
|
316
351
|
from langchain_core.messages import HumanMessage
|
|
317
352
|
|
|
318
353
|
llm = self._make_llm()
|
|
@@ -329,6 +364,7 @@ class TestWithMemWalLangChain:
|
|
|
329
364
|
@respx.mock
|
|
330
365
|
async def test_auto_save_triggers_analyze(self) -> None:
|
|
331
366
|
"""With auto_save=True, analyze() is called fire-and-forget after LLM call."""
|
|
367
|
+
_mock_seal_session_prereqs()
|
|
332
368
|
from langchain_core.messages import HumanMessage
|
|
333
369
|
|
|
334
370
|
llm = self._make_llm()
|
|
@@ -348,6 +384,7 @@ class TestWithMemWalLangChain:
|
|
|
348
384
|
@respx.mock
|
|
349
385
|
async def test_no_user_message_no_recall(self) -> None:
|
|
350
386
|
"""If there's no user message, recall is not called."""
|
|
387
|
+
_mock_seal_session_prereqs()
|
|
351
388
|
from langchain_core.messages import SystemMessage
|
|
352
389
|
|
|
353
390
|
llm = self._make_llm()
|
|
@@ -402,6 +439,7 @@ class TestWithMemWalOpenAI:
|
|
|
402
439
|
@respx.mock
|
|
403
440
|
async def test_async_client_injects_memory(self) -> None:
|
|
404
441
|
"""Async OpenAI client: memory injected as system message before user message."""
|
|
442
|
+
_mock_seal_session_prereqs()
|
|
405
443
|
client = self._make_async_client()
|
|
406
444
|
captured_messages: list = []
|
|
407
445
|
|
|
@@ -434,6 +472,7 @@ class TestWithMemWalOpenAI:
|
|
|
434
472
|
@respx.mock
|
|
435
473
|
async def test_async_client_no_memories_no_injection(self) -> None:
|
|
436
474
|
"""No memories → original messages passed unchanged."""
|
|
475
|
+
_mock_seal_session_prereqs()
|
|
437
476
|
client = self._make_async_client()
|
|
438
477
|
captured: list = []
|
|
439
478
|
|
|
@@ -460,6 +499,7 @@ class TestWithMemWalOpenAI:
|
|
|
460
499
|
@respx.mock
|
|
461
500
|
async def test_async_client_recall_failure_resilient(self) -> None:
|
|
462
501
|
"""If recall fails, the LLM call still proceeds."""
|
|
502
|
+
_mock_seal_session_prereqs()
|
|
463
503
|
client = self._make_async_client()
|
|
464
504
|
|
|
465
505
|
respx.post(_RECALL_URL).mock(return_value=httpx.Response(500, text="error"))
|
|
@@ -477,6 +517,7 @@ class TestWithMemWalOpenAI:
|
|
|
477
517
|
@respx.mock
|
|
478
518
|
async def test_async_client_auto_save(self) -> None:
|
|
479
519
|
"""With auto_save=True, analyze is triggered after completion."""
|
|
520
|
+
_mock_seal_session_prereqs()
|
|
480
521
|
client = self._make_async_client()
|
|
481
522
|
|
|
482
523
|
respx.post(_RECALL_URL).mock(return_value=_mock_recall([]))
|
|
@@ -496,6 +537,7 @@ class TestWithMemWalOpenAI:
|
|
|
496
537
|
@respx.mock
|
|
497
538
|
async def test_min_relevance_filter(self) -> None:
|
|
498
539
|
"""Memories with relevance below min_relevance are not injected."""
|
|
540
|
+
_mock_seal_session_prereqs()
|
|
499
541
|
client = self._make_async_client()
|
|
500
542
|
captured: list = []
|
|
501
543
|
|
|
@@ -528,6 +570,7 @@ class TestWithMemWalOpenAI:
|
|
|
528
570
|
@respx.mock
|
|
529
571
|
def test_sync_client_wraps_create(self) -> None:
|
|
530
572
|
"""Sync OpenAI client wrapper is applied correctly."""
|
|
573
|
+
_mock_seal_session_prereqs()
|
|
531
574
|
client = self._make_sync_client()
|
|
532
575
|
original_create = client.chat.completions.create
|
|
533
576
|
|
|
@@ -7,18 +7,22 @@ Validates that:
|
|
|
7
7
|
3. The signature message format matches the server expectation exactly
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
|
+
import base64
|
|
10
11
|
import hashlib
|
|
11
12
|
import json
|
|
12
13
|
|
|
13
14
|
import nacl.signing
|
|
14
15
|
|
|
15
16
|
from memwal.utils import (
|
|
17
|
+
build_seal_session_personal_message,
|
|
16
18
|
build_signature_message,
|
|
17
19
|
build_signing_key,
|
|
18
20
|
bytes_to_hex,
|
|
21
|
+
encode_sui_private_key,
|
|
19
22
|
hex_to_bytes,
|
|
20
23
|
sha256_hex,
|
|
21
24
|
sign_message,
|
|
25
|
+
sign_sui_personal_message,
|
|
22
26
|
)
|
|
23
27
|
|
|
24
28
|
|
|
@@ -242,3 +246,29 @@ class TestDelegateKeyUtils:
|
|
|
242
246
|
scheme_input = bytes([0x00]) + pub
|
|
243
247
|
expected = "0x" + hashlib.blake2b(scheme_input, digest_size=32).hexdigest()
|
|
244
248
|
assert delegate_key_to_sui_address(self._KEY) == expected
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class TestSealSessionUtils:
|
|
252
|
+
def test_encode_sui_private_key(self) -> None:
|
|
253
|
+
seed = b"\x02" * 32
|
|
254
|
+
encoded = encode_sui_private_key(seed)
|
|
255
|
+
assert encoded.startswith("suiprivkey1")
|
|
256
|
+
|
|
257
|
+
def test_build_personal_message(self) -> None:
|
|
258
|
+
message = build_seal_session_personal_message(
|
|
259
|
+
package_id="0x" + "11" * 32,
|
|
260
|
+
ttl_min=5,
|
|
261
|
+
creation_time_ms=1_700_000_000_000,
|
|
262
|
+
session_public_key_bytes=b"\x03" * 32,
|
|
263
|
+
)
|
|
264
|
+
decoded = message.decode("utf-8")
|
|
265
|
+
assert "Accessing keys of package" in decoded
|
|
266
|
+
assert "for 5 mins" in decoded
|
|
267
|
+
assert "session key" in decoded
|
|
268
|
+
|
|
269
|
+
def test_sign_sui_personal_message(self) -> None:
|
|
270
|
+
key = nacl.signing.SigningKey.generate()
|
|
271
|
+
signature = sign_sui_personal_message(b"hello", key)
|
|
272
|
+
raw = json.loads(json.dumps(signature)) # assert it's JSON-safe text
|
|
273
|
+
assert isinstance(raw, str)
|
|
274
|
+
assert len(base64.b64decode(signature)) == 97
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|