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.
- {oneshot_python-0.36.0 → oneshot_python-0.37.0}/PKG-INFO +1 -1
- {oneshot_python-0.36.0 → oneshot_python-0.37.0}/README.md +2 -1
- {oneshot_python-0.36.0 → oneshot_python-0.37.0}/oneshot/client.py +53 -3
- {oneshot_python-0.36.0 → oneshot_python-0.37.0}/pyproject.toml +1 -1
- oneshot_python-0.37.0/tests/test_compute_outputs.py +47 -0
- {oneshot_python-0.36.0 → oneshot_python-0.37.0}/tests/test_domains.py +3 -3
- oneshot_python-0.37.0/tests/test_domains_signed.py +78 -0
- oneshot_python-0.37.0/tests/test_paid_call_proof.py +101 -0
- {oneshot_python-0.36.0 → oneshot_python-0.37.0}/.gitignore +0 -0
- {oneshot_python-0.36.0 → oneshot_python-0.37.0}/oneshot/__init__.py +0 -0
- {oneshot_python-0.36.0 → oneshot_python-0.37.0}/oneshot/_errors.py +0 -0
- {oneshot_python-0.36.0 → oneshot_python-0.37.0}/oneshot/_types.py +0 -0
- {oneshot_python-0.36.0 → oneshot_python-0.37.0}/oneshot/physical_mail.py +0 -0
- {oneshot_python-0.36.0 → oneshot_python-0.37.0}/oneshot/x402.py +0 -0
- {oneshot_python-0.36.0 → oneshot_python-0.37.0}/tests/__init__.py +0 -0
- {oneshot_python-0.36.0 → oneshot_python-0.37.0}/tests/conftest.py +0 -0
- {oneshot_python-0.36.0 → oneshot_python-0.37.0}/tests/test_access_token.py +0 -0
- {oneshot_python-0.36.0 → oneshot_python-0.37.0}/tests/test_approvals.py +0 -0
- {oneshot_python-0.36.0 → oneshot_python-0.37.0}/tests/test_balance.py +0 -0
- {oneshot_python-0.36.0 → oneshot_python-0.37.0}/tests/test_browser.py +0 -0
- {oneshot_python-0.36.0 → oneshot_python-0.37.0}/tests/test_budgets.py +0 -0
- {oneshot_python-0.36.0 → oneshot_python-0.37.0}/tests/test_charge_amount.py +0 -0
- {oneshot_python-0.36.0 → oneshot_python-0.37.0}/tests/test_compute.py +0 -0
- {oneshot_python-0.36.0 → oneshot_python-0.37.0}/tests/test_email_payload.py +0 -0
- {oneshot_python-0.36.0 → oneshot_python-0.37.0}/tests/test_emergency_error.py +0 -0
- {oneshot_python-0.36.0 → oneshot_python-0.37.0}/tests/test_hunts.py +0 -0
- {oneshot_python-0.36.0 → oneshot_python-0.37.0}/tests/test_inbound_voice.py +0 -0
- {oneshot_python-0.36.0 → oneshot_python-0.37.0}/tests/test_linkedin.py +0 -0
- {oneshot_python-0.36.0 → oneshot_python-0.37.0}/tests/test_max_cost_header.py +0 -0
- {oneshot_python-0.36.0 → oneshot_python-0.37.0}/tests/test_payment_rejection.py +0 -0
- {oneshot_python-0.36.0 → oneshot_python-0.37.0}/tests/test_phones_pending.py +0 -0
- {oneshot_python-0.36.0 → oneshot_python-0.37.0}/tests/test_physical_mail.py +0 -0
- {oneshot_python-0.36.0 → oneshot_python-0.37.0}/tests/test_poll_backoff.py +0 -0
- {oneshot_python-0.36.0 → oneshot_python-0.37.0}/tests/test_request_id.py +0 -0
- {oneshot_python-0.36.0 → oneshot_python-0.37.0}/tests/test_signed_job_and_analytics.py +0 -0
- {oneshot_python-0.36.0 → oneshot_python-0.37.0}/tests/test_tag_receipt_value.py +0 -0
- {oneshot_python-0.36.0 → oneshot_python-0.37.0}/tests/test_top_up_credits.py +0 -0
- {oneshot_python-0.36.0 → oneshot_python-0.37.0}/tests/test_wait_false.py +0 -0
- {oneshot_python-0.36.0 → oneshot_python-0.37.0}/tests/test_x402.py +0 -0
- {oneshot_python-0.36.0 → oneshot_python-0.37.0}/uv.lock +0 -0
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|