oneshot-python 0.36.0__tar.gz → 0.37.0__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.
Files changed (40) hide show
  1. {oneshot_python-0.36.0 → oneshot_python-0.37.0}/PKG-INFO +1 -1
  2. {oneshot_python-0.36.0 → oneshot_python-0.37.0}/README.md +2 -1
  3. {oneshot_python-0.36.0 → oneshot_python-0.37.0}/oneshot/client.py +53 -3
  4. {oneshot_python-0.36.0 → oneshot_python-0.37.0}/pyproject.toml +1 -1
  5. oneshot_python-0.37.0/tests/test_compute_outputs.py +47 -0
  6. {oneshot_python-0.36.0 → oneshot_python-0.37.0}/tests/test_domains.py +3 -3
  7. oneshot_python-0.37.0/tests/test_domains_signed.py +78 -0
  8. oneshot_python-0.37.0/tests/test_paid_call_proof.py +101 -0
  9. {oneshot_python-0.36.0 → oneshot_python-0.37.0}/.gitignore +0 -0
  10. {oneshot_python-0.36.0 → oneshot_python-0.37.0}/oneshot/__init__.py +0 -0
  11. {oneshot_python-0.36.0 → oneshot_python-0.37.0}/oneshot/_errors.py +0 -0
  12. {oneshot_python-0.36.0 → oneshot_python-0.37.0}/oneshot/_types.py +0 -0
  13. {oneshot_python-0.36.0 → oneshot_python-0.37.0}/oneshot/physical_mail.py +0 -0
  14. {oneshot_python-0.36.0 → oneshot_python-0.37.0}/oneshot/x402.py +0 -0
  15. {oneshot_python-0.36.0 → oneshot_python-0.37.0}/tests/__init__.py +0 -0
  16. {oneshot_python-0.36.0 → oneshot_python-0.37.0}/tests/conftest.py +0 -0
  17. {oneshot_python-0.36.0 → oneshot_python-0.37.0}/tests/test_access_token.py +0 -0
  18. {oneshot_python-0.36.0 → oneshot_python-0.37.0}/tests/test_approvals.py +0 -0
  19. {oneshot_python-0.36.0 → oneshot_python-0.37.0}/tests/test_balance.py +0 -0
  20. {oneshot_python-0.36.0 → oneshot_python-0.37.0}/tests/test_browser.py +0 -0
  21. {oneshot_python-0.36.0 → oneshot_python-0.37.0}/tests/test_budgets.py +0 -0
  22. {oneshot_python-0.36.0 → oneshot_python-0.37.0}/tests/test_charge_amount.py +0 -0
  23. {oneshot_python-0.36.0 → oneshot_python-0.37.0}/tests/test_compute.py +0 -0
  24. {oneshot_python-0.36.0 → oneshot_python-0.37.0}/tests/test_email_payload.py +0 -0
  25. {oneshot_python-0.36.0 → oneshot_python-0.37.0}/tests/test_emergency_error.py +0 -0
  26. {oneshot_python-0.36.0 → oneshot_python-0.37.0}/tests/test_hunts.py +0 -0
  27. {oneshot_python-0.36.0 → oneshot_python-0.37.0}/tests/test_inbound_voice.py +0 -0
  28. {oneshot_python-0.36.0 → oneshot_python-0.37.0}/tests/test_linkedin.py +0 -0
  29. {oneshot_python-0.36.0 → oneshot_python-0.37.0}/tests/test_max_cost_header.py +0 -0
  30. {oneshot_python-0.36.0 → oneshot_python-0.37.0}/tests/test_payment_rejection.py +0 -0
  31. {oneshot_python-0.36.0 → oneshot_python-0.37.0}/tests/test_phones_pending.py +0 -0
  32. {oneshot_python-0.36.0 → oneshot_python-0.37.0}/tests/test_physical_mail.py +0 -0
  33. {oneshot_python-0.36.0 → oneshot_python-0.37.0}/tests/test_poll_backoff.py +0 -0
  34. {oneshot_python-0.36.0 → oneshot_python-0.37.0}/tests/test_request_id.py +0 -0
  35. {oneshot_python-0.36.0 → oneshot_python-0.37.0}/tests/test_signed_job_and_analytics.py +0 -0
  36. {oneshot_python-0.36.0 → oneshot_python-0.37.0}/tests/test_tag_receipt_value.py +0 -0
  37. {oneshot_python-0.36.0 → oneshot_python-0.37.0}/tests/test_top_up_credits.py +0 -0
  38. {oneshot_python-0.36.0 → oneshot_python-0.37.0}/tests/test_wait_false.py +0 -0
  39. {oneshot_python-0.36.0 → oneshot_python-0.37.0}/tests/test_x402.py +0 -0
  40. {oneshot_python-0.36.0 → oneshot_python-0.37.0}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: oneshot-python
3
- Version: 0.36.0
3
+ Version: 0.37.0
4
4
  Summary: Core Python SDK for the OneShot API — HTTP client with x402 payment handling — 48 tools
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.10
@@ -194,6 +194,7 @@ print(goal["goal_id"])
194
194
  # Observe
195
195
  status = client.get_compute_goal(goal["goal_id"])
196
196
  tasks = client.get_compute_tasks(goal["goal_id"])
197
+ outputs = client.get_compute_outputs(goal["goal_id"]) # what the tasks produced; read one with get_compute_output(goal_id, key)
197
198
  budget = client.get_compute_budget(goal["goal_id"])
198
199
 
199
200
  # Top up a recurring goal (paid)
@@ -212,7 +213,7 @@ Every method has an `a*` async mirror (`acompute`, `aget_compute_goal`, …).
212
213
 
213
214
  ### Physical mail
214
215
 
215
- `client.physical_mail` supports U.S. letters and 4×6 postcards through `upload_artwork`, `validate_address`, `preview`, `get_quote`, `approve`, `send`, `get_order`, `recover`, and `cancel`. Async variants prefix the method with `a`. Review each proof and price, then call `approve(..., approved=True)` explicitly. Persist `idempotency_key` before sending. `recover(key)` retrieves the original order without a second charge. Postal delivery does not prove the letter was read; paid receipts and fulfillment events are separate. Lob credentials stay on OneShot's server.
216
+ `client.physical_mail` supports U.S. letters and 4×6 postcards through `upload_artwork`, `validate_address`, `preview`, `get_quote`, `approve`, `send`, `get_order`, `recover`, and `cancel`. Async variants prefix the method with `a`. Review each proof and price, then call `approve(..., approved=True)` explicitly. Persist `idempotency_key` before sending. `recover(key)` retrieves the original order without a second charge. Postal delivery does not prove the letter was read; paid receipts and fulfillment events are separate. Mail provider credentials stay on OneShot's server.
216
217
 
217
218
  ### Account login and 2FA (0.24.0+)
218
219
 
@@ -59,11 +59,13 @@ try:
59
59
 
60
60
  SDK_VERSION = _pkg_version("oneshot-python")
61
61
  except Exception: # pragma: no cover - editable/source runs without dist metadata
62
- SDK_VERSION = "0.36.0"
62
+ SDK_VERSION = "0.37.0"
63
63
 
64
64
  _BASE_URL = "https://win.oneshotagent.com"
65
65
  _HUNT_BASE_URL = "https://api.soulhunt.ai"
66
66
  _ACCESS_TOKEN_RE = re.compile(r"^oneshot_[0-9a-f]{64}$")
67
+ # Endpoints whose admission reads a "submit"-scoped agent proof (same list as the TypeScript SDK).
68
+ _DURABLE_ADMISSION_ENDPOINT = re.compile(r"(enrich/(profile|email)|verify/email|physical-mail/send)$")
67
69
  _CHAIN_ID = 8453
68
70
  _USDC_ADDRESS = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
69
71
 
@@ -631,6 +633,24 @@ class OneShotClient:
631
633
  self._log(f"Failed to sign read proof (continuing without): {e}")
632
634
  return headers
633
635
 
636
+ def _prove_wallet(self, headers: dict[str, str], endpoint: str) -> dict[str, str]:
637
+ """Add a signed agent proof to a paid-call leg that carries no signed
638
+ payment. The API spends an agent's credits, and shows them on a quote,
639
+ only for a caller that has proven it holds the wallet: ``X-Agent-ID``
640
+ alone is a public address. A leg with a real payment signature needs
641
+ none. The endpoints with durable admission read a ``submit`` proof.
642
+ An access-token session is already bound to its agent. A signing
643
+ failure is logged and the leg goes without, so the call is priced as
644
+ if the agent held no credits."""
645
+ if self._access_token:
646
+ return headers
647
+ scope = "submit" if _DURABLE_ADMISSION_ENDPOINT.search(endpoint) else "write"
648
+ try:
649
+ headers["x-agent-proof"] = sign_read_proof(self._private_key, self.address, scope=scope)
650
+ except Exception as e: # noqa: BLE001
651
+ self._log(f"Failed to sign agent proof (continuing without; credits will not be used): {e}")
652
+ return headers
653
+
634
654
  def _write_headers(self, domain_name: str = "OneShot Agent Auth") -> dict[str, str]:
635
655
  """Like ``_read_headers`` but with a ``write``-scoped proof, for the
636
656
  signed mutation routes (budgets). The server enforces the proof on
@@ -851,6 +871,7 @@ class OneShotClient:
851
871
  # on this very first attempt, so an already-obtained quote_id
852
872
  # must be sent from the start, not just on the paid retry.
853
873
  initial_headers = self._with_approval(self._headers(max_cost=max_cost, idempotency_key=idempotency_key), approval_id)
874
+ self._prove_wallet(initial_headers, endpoint)
854
875
  if quote_id:
855
876
  initial_headers["x-quote-id"] = quote_id
856
877
  resp = await client.post(url, headers=initial_headers, json=payload)
@@ -985,6 +1006,9 @@ class OneShotClient:
985
1006
  **self._with_approval(self._headers(max_cost=max_cost, idempotency_key=idempotency_key), approval_id),
986
1007
  "payment-signature": encode_payment_header(auth),
987
1008
  }
1009
+ # The zero-cost authorization is an unsigned stub, so this leg
1010
+ # still has to prove the wallet for the credits to be spent.
1011
+ self._prove_wallet(headers, endpoint)
988
1012
  if quote_id:
989
1013
  headers["x-quote-id"] = quote_id
990
1014
  resp2 = await client.post(url, headers=headers, json=payload)
@@ -1368,6 +1392,32 @@ class OneShotClient:
1368
1392
  """List tasks under a goal. Async."""
1369
1393
  return self._unwrap_data(await self.acall_free_get(f"/v1/compute/{goal_id}/tasks"))
1370
1394
 
1395
+ def get_compute_outputs(self, goal_id: str) -> Any:
1396
+ """List what a goal produced: the files its sandbox tasks left and the
1397
+ tool results too large for a task row. The goal's short answer is
1398
+ ``result`` on ``get_compute_goal``. Blocking."""
1399
+ return asyncio.get_event_loop().run_until_complete(
1400
+ self.aget_compute_outputs(goal_id)
1401
+ )
1402
+
1403
+ async def aget_compute_outputs(self, goal_id: str) -> Any:
1404
+ """List what a goal produced. Async."""
1405
+ return self._unwrap_data(await self.acall_free_get(f"/v1/compute/{goal_id}/outputs"))
1406
+
1407
+ def get_compute_output(self, goal_id: str, key: str) -> Any:
1408
+ """Read one output of a goal by the ``key`` from ``get_compute_outputs``
1409
+ (or a task's ``outputs``). ``content`` is None when the output was too
1410
+ large to keep (``readable`` False). Blocking."""
1411
+ return asyncio.get_event_loop().run_until_complete(
1412
+ self.aget_compute_output(goal_id, key)
1413
+ )
1414
+
1415
+ async def aget_compute_output(self, goal_id: str, key: str) -> Any:
1416
+ """Read one output of a goal. Async."""
1417
+ # The key is a path: keep its slashes, escape everything else.
1418
+ path = "/".join(quote(part, safe="") for part in key.split("/"))
1419
+ return self._unwrap_data(await self.acall_free_get(f"/v1/compute/{goal_id}/outputs/{path}"))
1420
+
1371
1421
  def get_compute_budget(self, goal_id: str) -> Any:
1372
1422
  """Get budget breakdown. Blocking."""
1373
1423
  return asyncio.get_event_loop().run_until_complete(
@@ -2118,7 +2168,7 @@ class OneShotClient:
2118
2168
 
2119
2169
  async def apause_domain(self, domain: str) -> DomainPoolStatusResult:
2120
2170
  """Take a domain out of rotation without releasing it. Async."""
2121
- return await self.acall_free_post(self._domain_action_path(domain, "pause"))
2171
+ return await self.acall_free_post(self._domain_action_path(domain, "pause"), scope="write")
2122
2172
 
2123
2173
  def resume_domain(self, domain: str) -> DomainPoolStatusResult:
2124
2174
  """Put a paused domain back into rotation. Blocking."""
@@ -2130,7 +2180,7 @@ class OneShotClient:
2130
2180
  Reputation pauses (`pause_reason='low_reputation'`) auto-recover once the
2131
2181
  domain is warmed again; a manual pause needs this call.
2132
2182
  """
2133
- return await self.acall_free_post(self._domain_action_path(domain, "resume"))
2183
+ return await self.acall_free_post(self._domain_action_path(domain, "resume"), scope="write")
2134
2184
 
2135
2185
  @staticmethod
2136
2186
  def _domain_action_path(domain: str, action: str) -> str:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "oneshot-python"
3
- version = "0.36.0"
3
+ version = "0.37.0"
4
4
  description = "Core Python SDK for the OneShot API — HTTP client with x402 payment handling — 48 tools"
5
5
  readme = {text = "Core Python SDK for the OneShot API", content-type = "text/plain"}
6
6
  license = "MIT"
@@ -0,0 +1,47 @@
1
+ """Reading what a compute goal produced. Mirrors tests/unit/sdk-compute-outputs.test.ts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from unittest.mock import AsyncMock, patch
6
+
7
+ import pytest
8
+
9
+ from oneshot.client import OneShotClient
10
+
11
+ TEST_PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
12
+ GOAL = "b33839af-380f-4880-8355-57629ec68cb3"
13
+ KEY = "artifacts/5b36dba3-7978-4bab-b222-c003455c1bcf/result.txt"
14
+ OUTPUT = {"key": KEY, "task_id": "5b36dba3-7978-4bab-b222-c003455c1bcf", "content_type": "text/plain",
15
+ "size_bytes": 84, "summary": "Agent output", "readable": True, "updated_at": "2026-10-12T09:54:52.000Z"}
16
+
17
+
18
+ @pytest.mark.asyncio
19
+ async def test_lists_outputs():
20
+ client = OneShotClient(TEST_PRIVATE_KEY)
21
+ with patch.object(client, "acall_free_get", new=AsyncMock(return_value={"success": True, "data": [OUTPUT]})) as get:
22
+ assert await client.aget_compute_outputs(GOAL) == [OUTPUT]
23
+ get.assert_awaited_once_with(f"/v1/compute/{GOAL}/outputs")
24
+
25
+
26
+ @pytest.mark.asyncio
27
+ async def test_reads_one_output_keeping_the_slashes_of_its_key():
28
+ client = OneShotClient(TEST_PRIVATE_KEY)
29
+ body = {**OUTPUT, "content": "f6a0\n2880067194370816120", "truncated": False}
30
+ with patch.object(client, "acall_free_get", new=AsyncMock(return_value={"success": True, "data": body})) as get:
31
+ out = await client.aget_compute_output(GOAL, KEY)
32
+ assert out["content"] == "f6a0\n2880067194370816120"
33
+ get.assert_awaited_once_with(f"/v1/compute/{GOAL}/outputs/{KEY}")
34
+
35
+
36
+ @pytest.mark.asyncio
37
+ async def test_escapes_each_segment_of_a_key():
38
+ client = OneShotClient(TEST_PRIVATE_KEY)
39
+ with patch.object(client, "acall_free_get", new=AsyncMock(return_value={"success": True, "data": {}})) as get:
40
+ await client.aget_compute_output(GOAL, "artifacts/task/my report #1.txt")
41
+ get.assert_awaited_once_with(f"/v1/compute/{GOAL}/outputs/artifacts/task/my%20report%20%231.txt")
42
+
43
+
44
+ def test_blocking_wrappers_exist():
45
+ client = OneShotClient(TEST_PRIVATE_KEY)
46
+ with patch.object(client, "acall_free_get", new=AsyncMock(return_value={"success": True, "data": [OUTPUT]})):
47
+ assert client.get_compute_outputs(GOAL) == [OUTPUT]
@@ -148,7 +148,7 @@ class TestPauseResumeDomain:
148
148
  ) as mock:
149
149
  result = await client.apause_domain("warmed-example.com")
150
150
 
151
- mock.assert_called_once_with("/v1/tools/email/domains/warmed-example.com/pause")
151
+ mock.assert_called_once_with("/v1/tools/email/domains/warmed-example.com/pause", scope="write")
152
152
  assert result == MOCK_STATUS_RESPONSE
153
153
 
154
154
  @pytest.mark.asyncio
@@ -160,7 +160,7 @@ class TestPauseResumeDomain:
160
160
  ) as mock:
161
161
  await client.aresume_domain("warmed-example.com")
162
162
 
163
- mock.assert_called_once_with("/v1/tools/email/domains/warmed-example.com/resume")
163
+ mock.assert_called_once_with("/v1/tools/email/domains/warmed-example.com/resume", scope="write")
164
164
 
165
165
  @pytest.mark.asyncio
166
166
  async def test_encodes_the_domain_segment(self) -> None:
@@ -173,7 +173,7 @@ class TestPauseResumeDomain:
173
173
  await client.apause_domain("evil.com/../../admin")
174
174
 
175
175
  mock.assert_called_once_with(
176
- "/v1/tools/email/domains/evil.com%2F..%2F..%2Fadmin/pause"
176
+ "/v1/tools/email/domains/evil.com%2F..%2F..%2Fadmin/pause", scope="write"
177
177
  )
178
178
 
179
179
  @pytest.mark.asyncio
@@ -0,0 +1,78 @@
1
+ """The email-domain routes are signed by this SDK (#1399).
2
+
3
+ The API answers an unsigned caller 401 ``proof_required`` on GET
4
+ /v1/tools/email/domains (read proof) and POST .../pause and .../resume (write
5
+ proof). These tests decode the real ``x-agent-proof`` header the client sends,
6
+ not just that one is present.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import base64
12
+ import json
13
+ from unittest.mock import AsyncMock, MagicMock, patch
14
+
15
+ import pytest
16
+
17
+ from oneshot.client import OneShotClient
18
+
19
+ TEST_PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
20
+
21
+
22
+ def _proof(headers: dict[str, str]) -> dict:
23
+ assert "x-agent-proof" in headers, f"no x-agent-proof in {sorted(headers)}"
24
+ return json.loads(base64.b64decode(headers["x-agent-proof"]))
25
+
26
+
27
+ def _http(method: str) -> MagicMock:
28
+ resp = MagicMock()
29
+ resp.is_success = True
30
+ resp.status_code = 200
31
+ resp.json.return_value = {"ok": True}
32
+ http = MagicMock()
33
+ setattr(http, method, AsyncMock(return_value=resp))
34
+ http.__aenter__ = AsyncMock(return_value=http)
35
+ http.__aexit__ = AsyncMock(return_value=None)
36
+ return http
37
+
38
+
39
+ @pytest.mark.asyncio
40
+ async def test_list_domains_sends_a_read_proof() -> None:
41
+ client = OneShotClient(TEST_PRIVATE_KEY)
42
+ http = _http("get")
43
+ with patch("oneshot.client.httpx.AsyncClient", return_value=http):
44
+ await client.alist_domains()
45
+
46
+ call = http.get.call_args
47
+ assert call.args[0].endswith("/v1/tools/email/domains")
48
+ proof = _proof(call.kwargs["headers"])
49
+ assert proof["scope"] == "read"
50
+ assert proof["agent"] == client.address
51
+
52
+
53
+ @pytest.mark.asyncio
54
+ @pytest.mark.parametrize("action", ["pause", "resume"])
55
+ async def test_pause_and_resume_send_a_write_proof(action: str) -> None:
56
+ client = OneShotClient(TEST_PRIVATE_KEY)
57
+ http = _http("post")
58
+ method = client.apause_domain if action == "pause" else client.aresume_domain
59
+ with patch("oneshot.client.httpx.AsyncClient", return_value=http):
60
+ await method("warmed-example.com")
61
+
62
+ call = http.post.call_args
63
+ assert call.args[0].endswith(f"/v1/tools/email/domains/warmed-example.com/{action}")
64
+ proof = _proof(call.kwargs["headers"])
65
+ assert proof["scope"] == "write"
66
+ assert proof["agent"] == client.address
67
+
68
+
69
+ @pytest.mark.asyncio
70
+ async def test_each_domain_call_signs_a_fresh_nonce() -> None:
71
+ client = OneShotClient(TEST_PRIVATE_KEY)
72
+ http = _http("post")
73
+ with patch("oneshot.client.httpx.AsyncClient", return_value=http):
74
+ await client.apause_domain("a.com")
75
+ await client.apause_domain("a.com")
76
+
77
+ nonces = [_proof(c.kwargs["headers"])["nonce"] for c in http.post.call_args_list]
78
+ assert len(set(nonces)) == 2
@@ -0,0 +1,101 @@
1
+ """The SDK proves the wallet on the legs of a paid call that carry no signed payment.
2
+
3
+ The API spends an agent's credits, and shows them on a quote, only for a caller
4
+ that has proven it holds the wallet: ``X-Agent-ID`` alone is a public address.
5
+ Mirrors tests/unit/sdk-paid-call-proof.test.ts on the TypeScript side.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import json
12
+ from unittest.mock import AsyncMock, MagicMock, patch
13
+
14
+ import pytest
15
+ from eth_account import Account
16
+ from eth_account.messages import encode_typed_data
17
+
18
+ from oneshot.client import OneShotClient
19
+
20
+ TEST_PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
21
+ ACCESS_TOKEN = "oneshot_" + "a" * 64
22
+ WALLET = "0x" + "22" * 20
23
+
24
+
25
+ def _resp(status_code: int, payload: dict) -> MagicMock:
26
+ resp = MagicMock()
27
+ resp.status_code = status_code
28
+ resp.is_success = 200 <= status_code < 300
29
+ resp.json.return_value = payload
30
+ resp.text = ""
31
+ resp.headers = {}
32
+ return resp
33
+
34
+
35
+ def _proof(headers: dict) -> dict:
36
+ """Decode an x-agent-proof header and recover who signed it."""
37
+ proof = json.loads(base64.b64decode(headers["x-agent-proof"]))
38
+ signable = encode_typed_data(
39
+ domain_data={"name": "OneShot Agent Auth", "version": "1"},
40
+ message_types={"AgentReadAuth": [
41
+ {"name": "agent", "type": "address"}, {"name": "scope", "type": "string"},
42
+ {"name": "issuedAt", "type": "uint256"}, {"name": "nonce", "type": "bytes32"},
43
+ ]},
44
+ message_data={"agent": proof["agent"], "scope": proof["scope"], "issuedAt": proof["issuedAt"],
45
+ "nonce": bytes.fromhex(proof["nonce"][2:])},
46
+ )
47
+ proof["signer"] = Account.recover_message(signable, signature=proof["signature"])
48
+ return proof
49
+
50
+
51
+ async def _first_leg_headers(client: OneShotClient, endpoint: str) -> dict:
52
+ """Run one paid call that the API answers at once, and return the headers of its first request."""
53
+ client._budget_synced = True
54
+ http = MagicMock()
55
+ http.post = AsyncMock(return_value=_resp(200, {"success": True, "data": {"status": "completed"}}))
56
+ http.__aenter__ = AsyncMock(return_value=http)
57
+ http.__aexit__ = AsyncMock(return_value=False)
58
+ with patch("oneshot.client.httpx.AsyncClient", return_value=http):
59
+ await client.acall_tool(endpoint, {"query": "x", "memo": "test"})
60
+ return http.post.await_args_list[0].kwargs["headers"]
61
+
62
+
63
+ @pytest.mark.asyncio
64
+ async def test_first_leg_carries_a_write_proof_signed_by_the_wallet():
65
+ client = OneShotClient(TEST_PRIVATE_KEY)
66
+ headers = await _first_leg_headers(client, "/v1/tools/search")
67
+ assert "payment-signature" not in headers
68
+ proof = _proof(headers)
69
+ assert proof["scope"] == "write"
70
+ assert proof["agent"] == client.address
71
+ assert proof["signer"] == client.address
72
+
73
+
74
+ @pytest.mark.asyncio
75
+ async def test_each_call_signs_a_fresh_proof():
76
+ client = OneShotClient(TEST_PRIVATE_KEY)
77
+ first = _proof(await _first_leg_headers(client, "/v1/tools/search"))
78
+ second = _proof(await _first_leg_headers(client, "/v1/tools/search"))
79
+ assert first["nonce"] != second["nonce"]
80
+
81
+
82
+ @pytest.mark.asyncio
83
+ @pytest.mark.parametrize("endpoint", ["/v1/tools/enrich/profile", "/v1/tools/enrich/email", "/v1/tools/verify/email"])
84
+ async def test_durable_admission_endpoints_get_the_submit_proof_they_read(endpoint):
85
+ client = OneShotClient(TEST_PRIVATE_KEY)
86
+ assert _proof(await _first_leg_headers(client, endpoint))["scope"] == "submit"
87
+
88
+
89
+ def test_access_token_session_sends_no_proof():
90
+ client = OneShotClient(access_token=ACCESS_TOKEN, address=WALLET)
91
+ headers = client._prove_wallet(client._headers(), "/v1/tools/search")
92
+ assert headers["Authorization"] == f"Bearer {ACCESS_TOKEN}"
93
+ assert "x-agent-proof" not in headers
94
+
95
+
96
+ def test_a_signing_failure_sends_the_leg_without_a_proof():
97
+ client = OneShotClient(TEST_PRIVATE_KEY)
98
+ with patch("oneshot.client.sign_read_proof", side_effect=RuntimeError("no signer")):
99
+ headers = client._prove_wallet(client._headers(), "/v1/tools/search")
100
+ assert "x-agent-proof" not in headers
101
+ assert headers["X-Agent-ID"] == client.address
File without changes