aimarket-oracle-core 0.2.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.
- aimarket_oracle_core-0.2.0/.gitignore +36 -0
- aimarket_oracle_core-0.2.0/PKG-INFO +79 -0
- aimarket_oracle_core-0.2.0/README.md +60 -0
- aimarket_oracle_core-0.2.0/docs/SIGNING.md +100 -0
- aimarket_oracle_core-0.2.0/oracle_core/__init__.py +37 -0
- aimarket_oracle_core-0.2.0/oracle_core/app.py +108 -0
- aimarket_oracle_core-0.2.0/oracle_core/hub_client.py +89 -0
- aimarket_oracle_core-0.2.0/oracle_core/metrics.py +37 -0
- aimarket_oracle_core-0.2.0/oracle_core/protocol.py +193 -0
- aimarket_oracle_core-0.2.0/oracle_core/ratelimit.py +41 -0
- aimarket_oracle_core-0.2.0/oracle_core/signing.py +208 -0
- aimarket_oracle_core-0.2.0/pyproject.toml +39 -0
- aimarket_oracle_core-0.2.0/tests/test_core.py +216 -0
- aimarket_oracle_core-0.2.0/tests/test_packaging.py +72 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
.venv/
|
|
3
|
+
**/.venv/
|
|
4
|
+
**/__pycache__/
|
|
5
|
+
**/.pytest_cache/
|
|
6
|
+
.coverage
|
|
7
|
+
coverage.json
|
|
8
|
+
**/coverage.json
|
|
9
|
+
*.pyc
|
|
10
|
+
*.egg-info/
|
|
11
|
+
dist/
|
|
12
|
+
build/
|
|
13
|
+
|
|
14
|
+
# Node / frontend
|
|
15
|
+
**/node_modules/
|
|
16
|
+
**/frontend/dist/
|
|
17
|
+
**/test-results/
|
|
18
|
+
**/playwright-report/
|
|
19
|
+
|
|
20
|
+
# Secrets & runtime data (any location)
|
|
21
|
+
data/
|
|
22
|
+
**/data/
|
|
23
|
+
**/*_signing_key
|
|
24
|
+
**/*_signing_key_mldsa
|
|
25
|
+
**/*_mldsa
|
|
26
|
+
**/test_key
|
|
27
|
+
.env
|
|
28
|
+
.env.*
|
|
29
|
+
!.env.example
|
|
30
|
+
|
|
31
|
+
# OS / IDE
|
|
32
|
+
.DS_Store
|
|
33
|
+
*.swp
|
|
34
|
+
*.log
|
|
35
|
+
.idea/
|
|
36
|
+
.vscode/
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aimarket-oracle-core
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Shared AIMarket v2 infrastructure for the oracle family
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Requires-Dist: cryptography>=44.0.0
|
|
8
|
+
Requires-Dist: fastapi>=0.115.0
|
|
9
|
+
Requires-Dist: httpx>=0.27.0
|
|
10
|
+
Requires-Dist: pydantic>=2.9.0
|
|
11
|
+
Requires-Dist: uvicorn[standard]>=0.32.0
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: httpx>=0.27.0; extra == 'dev'
|
|
14
|
+
Requires-Dist: pytest-asyncio>=0.24.0; extra == 'dev'
|
|
15
|
+
Requires-Dist: pytest>=8.3.0; extra == 'dev'
|
|
16
|
+
Provides-Extra: pqc
|
|
17
|
+
Requires-Dist: dilithium-py>=1.4.0; extra == 'pqc'
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# aimarket-oracle-core
|
|
21
|
+
|
|
22
|
+
Shared infrastructure for the [alexar76 oracle family](https://github.com/alexar76/oracles) —
|
|
23
|
+
the layer that turns a pure function into a sellable, verifiable capability on
|
|
24
|
+
**AIMarket Protocol v2**.
|
|
25
|
+
|
|
26
|
+
An oracle written on top of this declares its capabilities and its maths. Everything else —
|
|
27
|
+
the HTTP surface, the signed manifest, priced invoke, Ed25519 receipts, measured latency and
|
|
28
|
+
success metrics, rate limiting, and hub federation — comes from here.
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from oracle_core import Capability, OracleSpec
|
|
32
|
+
|
|
33
|
+
SPEC = OracleSpec(
|
|
34
|
+
name="Example Oracle",
|
|
35
|
+
product_id="prod-example",
|
|
36
|
+
description="What it sells, in a sentence an agent can choose on.",
|
|
37
|
+
capabilities=[
|
|
38
|
+
Capability(
|
|
39
|
+
capability_id="example.double@v1",
|
|
40
|
+
product_id="prod-example",
|
|
41
|
+
description="Return twice the input.",
|
|
42
|
+
handler=lambda d: {"result": 2 * float(d["value"])},
|
|
43
|
+
input_schema={"type": "object", "required": ["value"],
|
|
44
|
+
"properties": {"value": {"type": "number"}}},
|
|
45
|
+
output_schema={"type": "object", "required": ["result"],
|
|
46
|
+
"properties": {"result": {"type": "number"}}},
|
|
47
|
+
price_per_call_usd=0.001,
|
|
48
|
+
),
|
|
49
|
+
],
|
|
50
|
+
)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Install
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pip install aimarket-oracle-core
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The distribution is `aimarket-oracle-core`; the import is `oracle_core`. The unprefixed
|
|
60
|
+
name `oracle-core` on PyPI is an **unrelated project** that installs a module of the same
|
|
61
|
+
name — installing it in place of this one fails at
|
|
62
|
+
`ImportError: cannot import name 'Capability' from 'oracle_core'`.
|
|
63
|
+
|
|
64
|
+
## What you get
|
|
65
|
+
|
|
66
|
+
| | |
|
|
67
|
+
|---|---|
|
|
68
|
+
| **Protocol** | `.well-known/ai-market.json` discovery, v2 manifest, priced `invoke` |
|
|
69
|
+
| **Signing** | Ed25519 manifest signatures and 7-field receipts, canonical form shared with the hub; optional hybrid post-quantum via the `pqc` extra |
|
|
70
|
+
| **Metrics** | measured `p50_latency_ms` and `success_rate_30d`, not declared constants |
|
|
71
|
+
| **Safety** | handler exceptions become named refusals (`{"ok": false, "error": …}`) rather than opaque 500s |
|
|
72
|
+
| **Federation** | manifests a hub can verify byte-for-byte and re-list |
|
|
73
|
+
|
|
74
|
+
## Extras
|
|
75
|
+
|
|
76
|
+
- `pqc` — hybrid post-quantum signatures (`dilithium-py`)
|
|
77
|
+
- `dev` — pytest, pytest-asyncio, httpx
|
|
78
|
+
|
|
79
|
+
Apache-2.0.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# aimarket-oracle-core
|
|
2
|
+
|
|
3
|
+
Shared infrastructure for the [alexar76 oracle family](https://github.com/alexar76/oracles) —
|
|
4
|
+
the layer that turns a pure function into a sellable, verifiable capability on
|
|
5
|
+
**AIMarket Protocol v2**.
|
|
6
|
+
|
|
7
|
+
An oracle written on top of this declares its capabilities and its maths. Everything else —
|
|
8
|
+
the HTTP surface, the signed manifest, priced invoke, Ed25519 receipts, measured latency and
|
|
9
|
+
success metrics, rate limiting, and hub federation — comes from here.
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
from oracle_core import Capability, OracleSpec
|
|
13
|
+
|
|
14
|
+
SPEC = OracleSpec(
|
|
15
|
+
name="Example Oracle",
|
|
16
|
+
product_id="prod-example",
|
|
17
|
+
description="What it sells, in a sentence an agent can choose on.",
|
|
18
|
+
capabilities=[
|
|
19
|
+
Capability(
|
|
20
|
+
capability_id="example.double@v1",
|
|
21
|
+
product_id="prod-example",
|
|
22
|
+
description="Return twice the input.",
|
|
23
|
+
handler=lambda d: {"result": 2 * float(d["value"])},
|
|
24
|
+
input_schema={"type": "object", "required": ["value"],
|
|
25
|
+
"properties": {"value": {"type": "number"}}},
|
|
26
|
+
output_schema={"type": "object", "required": ["result"],
|
|
27
|
+
"properties": {"result": {"type": "number"}}},
|
|
28
|
+
price_per_call_usd=0.001,
|
|
29
|
+
),
|
|
30
|
+
],
|
|
31
|
+
)
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install aimarket-oracle-core
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The distribution is `aimarket-oracle-core`; the import is `oracle_core`. The unprefixed
|
|
41
|
+
name `oracle-core` on PyPI is an **unrelated project** that installs a module of the same
|
|
42
|
+
name — installing it in place of this one fails at
|
|
43
|
+
`ImportError: cannot import name 'Capability' from 'oracle_core'`.
|
|
44
|
+
|
|
45
|
+
## What you get
|
|
46
|
+
|
|
47
|
+
| | |
|
|
48
|
+
|---|---|
|
|
49
|
+
| **Protocol** | `.well-known/ai-market.json` discovery, v2 manifest, priced `invoke` |
|
|
50
|
+
| **Signing** | Ed25519 manifest signatures and 7-field receipts, canonical form shared with the hub; optional hybrid post-quantum via the `pqc` extra |
|
|
51
|
+
| **Metrics** | measured `p50_latency_ms` and `success_rate_30d`, not declared constants |
|
|
52
|
+
| **Safety** | handler exceptions become named refusals (`{"ok": false, "error": …}`) rather than opaque 500s |
|
|
53
|
+
| **Federation** | manifests a hub can verify byte-for-byte and re-list |
|
|
54
|
+
|
|
55
|
+
## Extras
|
|
56
|
+
|
|
57
|
+
- `pqc` — hybrid post-quantum signatures (`dilithium-py`)
|
|
58
|
+
- `dev` — pytest, pytest-asyncio, httpx
|
|
59
|
+
|
|
60
|
+
Apache-2.0.
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# oracle-core signing — implementation note
|
|
2
|
+
|
|
3
|
+
This document describes **what the code does today**. It is **not** a normative protocol spec,
|
|
4
|
+
an external audit, or a proof of correctness. For ecosystem-level honesty see
|
|
5
|
+
[`docs/crypto-maturity.en.md`](../../docs/crypto-maturity.en.md).
|
|
6
|
+
|
|
7
|
+
Implementation: [`oracle_core/signing.py`](../oracle_core/signing.py).
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Ed25519 (default, always on)
|
|
12
|
+
|
|
13
|
+
**Manifest canonical string** (4 fields + tools hash):
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
capabilities_count:{n}|generated_at:{iso}|protocol_version:{v}|tools_hash:{sha256}
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`tools_hash = SHA-256(JSON.dumps(tools, sort_keys=True))`.
|
|
20
|
+
|
|
21
|
+
**Receipt canonical string** (7 fields):
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
nonce:{n}|product_id:{id}|capability_id:{cap}|price_usd:{p}|timestamp:{iso}|success:{0|1}|latency_ms:{ms}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
**Signature object** on manifests (via `sign_payload`):
|
|
28
|
+
|
|
29
|
+
```json
|
|
30
|
+
{
|
|
31
|
+
"algorithm": "ed25519",
|
|
32
|
+
"public_key": "<base64 32-byte pubkey>",
|
|
33
|
+
"value": "<base64 64-byte sig>"
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Receipts embed `signature.algorithm` + `signature.value` only (legacy 7-field receipt shape).
|
|
38
|
+
|
|
39
|
+
Verification uses `cryptography` Ed25519 over the UTF-8 canonical string.
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Hybrid ML-DSA-65 (optional, off by default)
|
|
44
|
+
|
|
45
|
+
Enable: `ORACLE_PQC=1` or `Signer(..., pqc=True)` **and** install `dilithium-py`.
|
|
46
|
+
|
|
47
|
+
When enabled, `sign_payload` **adds** (does not replace Ed25519):
|
|
48
|
+
|
|
49
|
+
```json
|
|
50
|
+
{
|
|
51
|
+
"pq_algorithm": "ml-dsa-65",
|
|
52
|
+
"pq_public_key": "<base64 ML-DSA-65 public key>",
|
|
53
|
+
"pq_value": "<base64 ML-DSA-65 signature>"
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
**Verification policy** (`verify_signature_object`):
|
|
58
|
+
|
|
59
|
+
1. Ed25519 **must** verify.
|
|
60
|
+
2. If `pq_value` is present, ML-DSA-65 **must also** verify (both required).
|
|
61
|
+
3. If `pq_value` is absent, Ed25519 alone suffices.
|
|
62
|
+
|
|
63
|
+
**Rationale (informal):** dual signatures — safe if either primitive holds during migration.
|
|
64
|
+
This is a common hybrid pattern but **has not been independently reviewed** for this codebase.
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## Key material
|
|
69
|
+
|
|
70
|
+
| Key | Storage | Notes |
|
|
71
|
+
|-----|---------|-------|
|
|
72
|
+
| Ed25519 | `{key_path}` — 64 B seed‖pub | Or `ORACLE_SIGNING_SEED_B64` env (32 B seed) |
|
|
73
|
+
| ML-DSA-65 | `{key_path}_mldsa` — hex pk/sk lines | Generated on first use when PQC enabled |
|
|
74
|
+
|
|
75
|
+
File permissions attempted `0600`. **No HSM integration** in-tree.
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## Hub integration gap
|
|
80
|
+
|
|
81
|
+
The AIMarket Hub verifies **Ed25519 manifest/receipt signatures** on its hot path. PQ fields are
|
|
82
|
+
**ignored** unless a consumer calls `Signer.verify_signature_object` locally.
|
|
83
|
+
|
|
84
|
+
Until protocol v2.x freezes PQ fields and the Hub enforces both layers, treat hybrid PQC as
|
|
85
|
+
**operator opt-in**, not ecosystem-wide post-quantum readiness.
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## Test coverage
|
|
90
|
+
|
|
91
|
+
- `core/tests/test_core.py` — Ed25519 round-trip; hybrid when `dilithium-py` installed.
|
|
92
|
+
- `oracles/platon/backend/tests/test_signing.py` — hybrid tamper cases.
|
|
93
|
+
|
|
94
|
+
**Missing for production hardening:**
|
|
95
|
+
|
|
96
|
+
- Negative test vectors published in `aimarket-protocol`
|
|
97
|
+
- Cross-language verifiers (TypeScript, Solidity) for PQ extension
|
|
98
|
+
- External audit of canonical string choices and key-binding policy
|
|
99
|
+
|
|
100
|
+
See [KI-6](https://github.com/alexar76/aicom/blob/main/docs/known-issues.md#ki-6--oracle-family-cryptographic-maturity-not-production-hardened).
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""oracle-core — shared AIMarket v2 infrastructure for the oracle family.
|
|
2
|
+
|
|
3
|
+
Build an oracle by declaring capabilities and handing them to ``create_app``:
|
|
4
|
+
|
|
5
|
+
from oracle_core import Capability, OracleSpec, create_app
|
|
6
|
+
|
|
7
|
+
spec = OracleSpec(
|
|
8
|
+
name="My Oracle", product_id="prod-x", description="...",
|
|
9
|
+
public_url="http://localhost:9300", categories=["..."],
|
|
10
|
+
capabilities=[Capability("x.do@v1", "does x", handler=lambda d: {...})],
|
|
11
|
+
)
|
|
12
|
+
app = create_app(spec)
|
|
13
|
+
|
|
14
|
+
You get signed manifest + invoke (with receipts + measured metrics) + .well-known
|
|
15
|
+
+ rate-limiting + (optional) hybrid PQC for free.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from oracle_core.app import create_app
|
|
19
|
+
from oracle_core.hub_client import HubClient
|
|
20
|
+
from oracle_core.metrics import Metrics
|
|
21
|
+
from oracle_core.protocol import Capability, OracleSpec, Protocol, input_hash, utc_now_z
|
|
22
|
+
from oracle_core.ratelimit import RateLimiter
|
|
23
|
+
from oracle_core.signing import Signer, pqc_available
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"Capability",
|
|
27
|
+
"OracleSpec",
|
|
28
|
+
"Protocol",
|
|
29
|
+
"create_app",
|
|
30
|
+
"HubClient",
|
|
31
|
+
"Metrics",
|
|
32
|
+
"RateLimiter",
|
|
33
|
+
"Signer",
|
|
34
|
+
"pqc_available",
|
|
35
|
+
"input_hash",
|
|
36
|
+
"utc_now_z",
|
|
37
|
+
]
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""FastAPI app factory — every oracle gets a compliant AIMarket v2 surface for free."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any, Callable, Optional
|
|
7
|
+
|
|
8
|
+
from fastapi import FastAPI, HTTPException, Request
|
|
9
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
10
|
+
from pydantic import BaseModel, Field
|
|
11
|
+
|
|
12
|
+
from oracle_core.protocol import OracleSpec, Protocol
|
|
13
|
+
from oracle_core.ratelimit import RateLimiter
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class InvokeRequest(BaseModel):
|
|
20
|
+
capability_id: str
|
|
21
|
+
input: dict[str, Any] = Field(default_factory=dict)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def client_key(request: Request) -> str:
|
|
25
|
+
"""Per-client rate-limit key — the real client IP behind the reverse proxy.
|
|
26
|
+
|
|
27
|
+
Behind nginx the socket peer is always 127.0.0.1, so trust the proxy-set
|
|
28
|
+
``X-Real-IP`` / first ``X-Forwarded-For`` hop. This is only spoofable by a
|
|
29
|
+
client that can reach the app directly; the service binds to loopback and is
|
|
30
|
+
published solely through nginx, so that path is closed.
|
|
31
|
+
"""
|
|
32
|
+
xri = (request.headers.get("x-real-ip") or "").strip()
|
|
33
|
+
if xri:
|
|
34
|
+
return xri
|
|
35
|
+
xff = (request.headers.get("x-forwarded-for") or "").strip()
|
|
36
|
+
if xff:
|
|
37
|
+
return xff.split(",")[0].strip()
|
|
38
|
+
return request.client.host if request.client else "*"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def create_app(
|
|
42
|
+
spec: OracleSpec,
|
|
43
|
+
cors_origins: str = "*",
|
|
44
|
+
invoke_rate_limit: int = 120,
|
|
45
|
+
extra: Optional[Callable[[FastAPI, Protocol], None]] = None,
|
|
46
|
+
) -> FastAPI:
|
|
47
|
+
proto = Protocol(spec)
|
|
48
|
+
limiter = RateLimiter(invoke_rate_limit)
|
|
49
|
+
|
|
50
|
+
app = FastAPI(title=spec.name, description=spec.description, version=spec.version)
|
|
51
|
+
app.state.protocol = proto # exposed for tests / extra routes
|
|
52
|
+
|
|
53
|
+
origins = [o.strip() for o in cors_origins.split(",") if o.strip()] or ["*"]
|
|
54
|
+
allow_all = origins == ["*"]
|
|
55
|
+
app.add_middleware(
|
|
56
|
+
CORSMiddleware,
|
|
57
|
+
allow_origins=origins,
|
|
58
|
+
allow_credentials=not allow_all, # "*" + credentials is invalid per the CORS spec
|
|
59
|
+
allow_methods=["*"],
|
|
60
|
+
allow_headers=["*"],
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
@app.get("/api/health")
|
|
64
|
+
async def health() -> dict[str, Any]:
|
|
65
|
+
return {"status": "ok", "oracle": spec.product_id, "capabilities": len(spec.capabilities)}
|
|
66
|
+
|
|
67
|
+
@app.get("/.well-known/ai-market.json")
|
|
68
|
+
async def well_known() -> dict[str, Any]:
|
|
69
|
+
return proto.well_known()
|
|
70
|
+
|
|
71
|
+
@app.get("/ai-market/v2/manifest")
|
|
72
|
+
async def manifest() -> dict[str, Any]:
|
|
73
|
+
return proto.manifest()
|
|
74
|
+
|
|
75
|
+
@app.post("/ai-market/v2/invoke")
|
|
76
|
+
async def invoke(req: InvokeRequest, request: Request) -> dict[str, Any]:
|
|
77
|
+
if not limiter.allow(client_key(request)):
|
|
78
|
+
raise HTTPException(status_code=429, detail="rate limited")
|
|
79
|
+
try:
|
|
80
|
+
result = await proto.invoke(req.capability_id, req.input)
|
|
81
|
+
return {"ok": True, **result}
|
|
82
|
+
except ValueError as exc:
|
|
83
|
+
# A handler rejecting its input, e.g. "points must be a list of [x, y] pairs".
|
|
84
|
+
return {"ok": False, "error": str(exc)}
|
|
85
|
+
except KeyError as exc:
|
|
86
|
+
# A required field was simply absent. Only ValueError used to be translated, so
|
|
87
|
+
# `lumen.score` without `target_node` answered a bare 500 Internal Server Error
|
|
88
|
+
# — a caller that read the schema and mistyped one field learned nothing and had
|
|
89
|
+
# nothing to correct. KeyError stringifies to a quoted name, hence the strip.
|
|
90
|
+
return {"ok": False, "error": f"missing required input field: {str(exc).strip(chr(39))}"}
|
|
91
|
+
except RuntimeError as exc:
|
|
92
|
+
# A federated upstream refused. The oracle-family proxy raises this carrying the
|
|
93
|
+
# upstream's own message ("Unknown capability: …"), which is exactly what the
|
|
94
|
+
# caller needs and exactly what the 500 was swallowing.
|
|
95
|
+
return {"ok": False, "error": str(exc)}
|
|
96
|
+
except Exception as exc: # noqa: BLE001 — deliberate catch-all, see below
|
|
97
|
+
# Anything else is a real fault, not a refusal, so it stays a 5xx. But it says
|
|
98
|
+
# what broke: an empty "Internal Server Error" is indistinguishable from a dead
|
|
99
|
+
# process, and callers retry it forever. Full traceback goes to the log.
|
|
100
|
+
logger.exception("invoke %s failed unexpectedly", req.capability_id)
|
|
101
|
+
raise HTTPException(
|
|
102
|
+
status_code=500, detail=f"{type(exc).__name__}: {exc}"
|
|
103
|
+
) from exc
|
|
104
|
+
|
|
105
|
+
if extra is not None:
|
|
106
|
+
extra(app, proto)
|
|
107
|
+
|
|
108
|
+
return app
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Generic AIMarket hub federation client (shared by every oracle)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from oracle_core.protocol import Protocol
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def announce_canonical(hub_url: str, well_known_url: str, capabilities_count: int) -> str:
|
|
13
|
+
return f"hub_url:{hub_url}|well_known_url:{well_known_url}|capabilities_count:{capabilities_count}"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class HubClient:
|
|
17
|
+
def __init__(self, proto: Protocol, hub_url: str):
|
|
18
|
+
self.proto = proto
|
|
19
|
+
self.hub_url = hub_url.rstrip("/")
|
|
20
|
+
|
|
21
|
+
def self_verify_manifest(self) -> dict[str, Any]:
|
|
22
|
+
man = self.proto.manifest()
|
|
23
|
+
return {
|
|
24
|
+
"ok": self.proto.signer.verify_manifest_signature(man),
|
|
25
|
+
"capabilities": [t["capability_id"] for t in man["tools"]],
|
|
26
|
+
"signer_public_key": self.proto.signer.public_key_b64,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
def announce_body(self) -> dict[str, Any]:
|
|
30
|
+
base = self.proto.spec.public_url.rstrip("/")
|
|
31
|
+
wk = f"{base}/.well-known/ai-market.json"
|
|
32
|
+
count = len(self.proto.spec.capabilities)
|
|
33
|
+
canonical = announce_canonical(base, wk, count)
|
|
34
|
+
return {
|
|
35
|
+
"hub_url": base,
|
|
36
|
+
"well_known_url": wk,
|
|
37
|
+
"capabilities_count": count,
|
|
38
|
+
"hub_name": self.proto.spec.name,
|
|
39
|
+
"signer_public_key": self.proto.signer.public_key_b64,
|
|
40
|
+
"signature": {"algorithm": "ed25519", "value": self.proto.signer.sign_canonical(canonical)},
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async def hub_info(self) -> dict[str, Any]:
|
|
44
|
+
try:
|
|
45
|
+
async with httpx.AsyncClient(timeout=15.0) as c:
|
|
46
|
+
r = await c.get(f"{self.hub_url}/.well-known/ai-market.json")
|
|
47
|
+
if r.status_code == 200:
|
|
48
|
+
d = r.json()
|
|
49
|
+
return {"reachable": True, "name": d.get("name"), "hub_version": d.get("hub_version")}
|
|
50
|
+
return {"reachable": False, "status": r.status_code}
|
|
51
|
+
except httpx.HTTPError as exc:
|
|
52
|
+
return {"reachable": False, "error": str(exc)}
|
|
53
|
+
|
|
54
|
+
async def announce(self, admin_token: str = "") -> dict[str, Any]:
|
|
55
|
+
params = {"authorization": admin_token} if admin_token else {}
|
|
56
|
+
try:
|
|
57
|
+
async with httpx.AsyncClient(timeout=20.0) as c:
|
|
58
|
+
r = await c.post(f"{self.hub_url}/ai-market/v2/federation/announce", params=params, json=self.announce_body())
|
|
59
|
+
body: Any
|
|
60
|
+
try:
|
|
61
|
+
body = r.json()
|
|
62
|
+
except ValueError:
|
|
63
|
+
body = r.text
|
|
64
|
+
return {"registered": r.status_code == 200, "status": r.status_code, "response": body}
|
|
65
|
+
except httpx.HTTPError as exc:
|
|
66
|
+
return {"registered": False, "error": str(exc)}
|
|
67
|
+
|
|
68
|
+
async def open_channel(self, deposit_usd: float = 1.0, token: str = "USDT", chain: str = "base") -> dict[str, Any]:
|
|
69
|
+
try:
|
|
70
|
+
async with httpx.AsyncClient(timeout=20.0) as c:
|
|
71
|
+
r = await c.post(f"{self.hub_url}/ai-market/v2/channel/open", json={"deposit_usd": deposit_usd, "token": token, "chain": chain})
|
|
72
|
+
return {"status": r.status_code, "response": _json_or_text(r)}
|
|
73
|
+
except httpx.HTTPError as exc:
|
|
74
|
+
return {"error": str(exc)}
|
|
75
|
+
|
|
76
|
+
async def search(self, intent: str, budget: float = 0.05, limit: int = 10) -> dict[str, Any]:
|
|
77
|
+
try:
|
|
78
|
+
async with httpx.AsyncClient(timeout=20.0) as c:
|
|
79
|
+
r = await c.get(f"{self.hub_url}/ai-market/v2/search", params={"intent": intent, "budget": budget, "limit": limit})
|
|
80
|
+
return {"status": r.status_code, "response": _json_or_text(r)}
|
|
81
|
+
except httpx.HTTPError as exc:
|
|
82
|
+
return {"error": str(exc)}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _json_or_text(r: "httpx.Response") -> Any:
|
|
86
|
+
try:
|
|
87
|
+
return r.json()
|
|
88
|
+
except ValueError:
|
|
89
|
+
return r.text
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Rolling per-capability metrics — real measured latency and success rate."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import defaultdict, deque
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Metrics:
|
|
10
|
+
def __init__(self, window: int = 200) -> None:
|
|
11
|
+
self._window = window
|
|
12
|
+
self._latency: dict[str, deque] = defaultdict(lambda: deque(maxlen=window))
|
|
13
|
+
self._success: dict[str, deque] = defaultdict(lambda: deque(maxlen=window))
|
|
14
|
+
|
|
15
|
+
def record(self, capability_id: str, latency_ms: float, success: bool) -> None:
|
|
16
|
+
self._latency[capability_id].append(float(latency_ms))
|
|
17
|
+
self._success[capability_id].append(1 if success else 0)
|
|
18
|
+
|
|
19
|
+
def count(self, capability_id: str) -> int:
|
|
20
|
+
return len(self._success.get(capability_id, ()))
|
|
21
|
+
|
|
22
|
+
def p50_latency_ms(self, capability_id: str) -> Optional[float]:
|
|
23
|
+
samples = self._latency.get(capability_id)
|
|
24
|
+
if not samples:
|
|
25
|
+
return None
|
|
26
|
+
ordered = sorted(samples)
|
|
27
|
+
return ordered[len(ordered) // 2]
|
|
28
|
+
|
|
29
|
+
def success_rate(self, capability_id: str) -> Optional[float]:
|
|
30
|
+
samples = self._success.get(capability_id)
|
|
31
|
+
if not samples:
|
|
32
|
+
return None
|
|
33
|
+
return sum(samples) / len(samples)
|
|
34
|
+
|
|
35
|
+
def reset(self) -> None:
|
|
36
|
+
self._latency.clear()
|
|
37
|
+
self._success.clear()
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""AIMarket Protocol v2 — shared by every oracle in the family.
|
|
2
|
+
|
|
3
|
+
An oracle declares an :class:`OracleSpec` (name, product, priced capabilities with
|
|
4
|
+
handlers); this module turns it into a compliant ``.well-known`` doc, a signed
|
|
5
|
+
manifest (measured metrics overlaid), and an ``invoke`` that wraps every result in
|
|
6
|
+
a signed 7-field receipt + provenance. No per-oracle protocol code needed.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import hashlib
|
|
13
|
+
import inspect
|
|
14
|
+
import json
|
|
15
|
+
import secrets
|
|
16
|
+
import time
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from typing import Any, Callable
|
|
20
|
+
|
|
21
|
+
from oracle_core.metrics import Metrics
|
|
22
|
+
from oracle_core.signing import Signer
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def utc_now_z() -> str:
|
|
26
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def input_hash(input_data: dict[str, Any]) -> str:
|
|
30
|
+
return hashlib.sha256(
|
|
31
|
+
json.dumps(input_data, sort_keys=True, ensure_ascii=False).encode()
|
|
32
|
+
).hexdigest()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class Capability:
|
|
37
|
+
capability_id: str # e.g. "platon.random@v1"
|
|
38
|
+
description: str
|
|
39
|
+
handler: Callable[[dict[str, Any]], Any] # sync or async; receives the input dict
|
|
40
|
+
product_id: str = "prod-oracle"
|
|
41
|
+
input_schema: dict[str, Any] = field(default_factory=lambda: {"type": "object", "properties": {}})
|
|
42
|
+
output_schema: dict[str, Any] = field(default_factory=lambda: {"type": "object"})
|
|
43
|
+
price_per_call_usd: float = 0.001
|
|
44
|
+
p50_latency_ms: float = 10
|
|
45
|
+
success_rate_30d: float = 0.999
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def name(self) -> str:
|
|
49
|
+
return self.capability_id.split("@", 1)[0]
|
|
50
|
+
|
|
51
|
+
def tool(self) -> dict[str, Any]:
|
|
52
|
+
return {
|
|
53
|
+
"name": self.name,
|
|
54
|
+
"capability_id": self.capability_id,
|
|
55
|
+
"product_id": self.product_id,
|
|
56
|
+
"description": self.description,
|
|
57
|
+
"input_schema": self.input_schema,
|
|
58
|
+
"output_schema": self.output_schema,
|
|
59
|
+
"price_per_call_usd": self.price_per_call_usd,
|
|
60
|
+
# Coerced for the same reason as the measured value in _tool_with_metrics: the
|
|
61
|
+
# field is declared `integer`, and a spec author writing 10.5 would silently
|
|
62
|
+
# make this oracle unfederatable.
|
|
63
|
+
"p50_latency_ms": int(round(self.p50_latency_ms)),
|
|
64
|
+
"success_rate_30d": self.success_rate_30d,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class OracleSpec:
|
|
70
|
+
name: str
|
|
71
|
+
product_id: str
|
|
72
|
+
description: str
|
|
73
|
+
public_url: str
|
|
74
|
+
categories: list[str]
|
|
75
|
+
capabilities: list[Capability]
|
|
76
|
+
signing_key_path: str = "data/signing_key"
|
|
77
|
+
version: str = "0.1.0"
|
|
78
|
+
related: list[str] = field(default_factory=list)
|
|
79
|
+
|
|
80
|
+
def capability(self, capability_id: str) -> Capability:
|
|
81
|
+
for c in self.capabilities:
|
|
82
|
+
if c.capability_id == capability_id:
|
|
83
|
+
return c
|
|
84
|
+
raise ValueError(f"Unknown capability: {capability_id}")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class Protocol:
|
|
88
|
+
"""Binds an OracleSpec to a Signer + Metrics and serves the v2 surface."""
|
|
89
|
+
|
|
90
|
+
def __init__(self, spec: OracleSpec, signer: Signer | None = None, metrics: Metrics | None = None):
|
|
91
|
+
self.spec = spec
|
|
92
|
+
self.signer = signer or Signer(spec.signing_key_path)
|
|
93
|
+
self.metrics = metrics or Metrics()
|
|
94
|
+
|
|
95
|
+
def well_known(self) -> dict[str, Any]:
|
|
96
|
+
base = self.spec.public_url.rstrip("/")
|
|
97
|
+
return {
|
|
98
|
+
"name": self.spec.name,
|
|
99
|
+
"protocol_versions": ["v2"],
|
|
100
|
+
"hub_version": self.spec.version,
|
|
101
|
+
"manifest_url": f"{base}/ai-market/v2/manifest",
|
|
102
|
+
"mcp_endpoint": f"{base}/ai-market/v2/invoke",
|
|
103
|
+
"capabilities_count": len(self.spec.capabilities),
|
|
104
|
+
"signer_public_key": self.signer.public_key_b64,
|
|
105
|
+
"description": self.spec.description,
|
|
106
|
+
"categories": self.spec.categories,
|
|
107
|
+
"protocol_version": "v2",
|
|
108
|
+
"hub_name": self.spec.name,
|
|
109
|
+
"hub_url": base,
|
|
110
|
+
"ecosystem": {"product": self.spec.product_id, "related": self.spec.related},
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
def _tool_with_metrics(self, cap: Capability) -> dict[str, Any]:
|
|
114
|
+
tool = cap.tool()
|
|
115
|
+
cid = cap.capability_id
|
|
116
|
+
observed = self.metrics.count(cid)
|
|
117
|
+
p50 = self.metrics.p50_latency_ms(cid)
|
|
118
|
+
sr = self.metrics.success_rate(cid)
|
|
119
|
+
if p50 is not None:
|
|
120
|
+
# int, not round(p50, 2): aimarket-protocol/schemas/manifest.json declares
|
|
121
|
+
# p50_latency_ms as {"type": "integer"}, so a measured 2261.84 made the hub
|
|
122
|
+
# reject the WHOLE manifest — "2261.84 is not of type 'integer'" — and index
|
|
123
|
+
# zero of the family's 22 capabilities. Sub-millisecond precision in a
|
|
124
|
+
# catalogue listing buys nothing; interoperating with the published schema
|
|
125
|
+
# buys federation.
|
|
126
|
+
tool["p50_latency_ms"] = int(round(p50))
|
|
127
|
+
if sr is not None:
|
|
128
|
+
tool["success_rate_30d"] = round(sr, 4)
|
|
129
|
+
tool["calls_observed"] = observed
|
|
130
|
+
tool["metrics_source"] = "measured" if observed else "declared"
|
|
131
|
+
return tool
|
|
132
|
+
|
|
133
|
+
def manifest(self) -> dict[str, Any]:
|
|
134
|
+
tools = [self._tool_with_metrics(c) for c in self.spec.capabilities]
|
|
135
|
+
body = {
|
|
136
|
+
"protocol_version": "v2",
|
|
137
|
+
"release_version": self.spec.version,
|
|
138
|
+
"generated_at": utc_now_z(),
|
|
139
|
+
"base_url": self.spec.public_url,
|
|
140
|
+
"products_count": 1,
|
|
141
|
+
"capabilities_count": len(tools),
|
|
142
|
+
"total_capabilities": len(tools),
|
|
143
|
+
"local_capabilities": len(tools),
|
|
144
|
+
"federated_capabilities": 0,
|
|
145
|
+
"hubs_indexed": 0,
|
|
146
|
+
"tools": tools,
|
|
147
|
+
}
|
|
148
|
+
body["signature"] = self.signer.sign_manifest(body)
|
|
149
|
+
return body
|
|
150
|
+
|
|
151
|
+
async def invoke(self, capability_id: str, input_data: dict[str, Any]) -> dict[str, Any]:
|
|
152
|
+
cap = self.spec.capability(capability_id) # ValueError -> caller maps to {ok:false}
|
|
153
|
+
start = time.perf_counter()
|
|
154
|
+
success = True
|
|
155
|
+
try:
|
|
156
|
+
if inspect.iscoroutinefunction(cap.handler):
|
|
157
|
+
output = await cap.handler(input_data)
|
|
158
|
+
else:
|
|
159
|
+
# Sync handlers can be CPU-bound (e.g. Chronos VDF: T sequential
|
|
160
|
+
# squarings over an RSA-2048 modulus). Running them inline would
|
|
161
|
+
# block the event loop and stall every other request — a trivial
|
|
162
|
+
# DoS. Offload to a worker thread so one expensive call is isolated.
|
|
163
|
+
output = await asyncio.to_thread(cap.handler, input_data)
|
|
164
|
+
if inspect.isawaitable(output):
|
|
165
|
+
output = await output
|
|
166
|
+
except Exception:
|
|
167
|
+
success = False
|
|
168
|
+
raise
|
|
169
|
+
finally:
|
|
170
|
+
latency_ms = (time.perf_counter() - start) * 1000.0
|
|
171
|
+
self.metrics.record(capability_id, latency_ms, success)
|
|
172
|
+
return self._envelope(cap, capability_id, input_data, output, latency_ms)
|
|
173
|
+
|
|
174
|
+
def _envelope(self, cap, capability_id, input_data, output, latency_ms) -> dict[str, Any]:
|
|
175
|
+
timestamp = utc_now_z()
|
|
176
|
+
receipt = self.signer.sign_receipt(
|
|
177
|
+
{
|
|
178
|
+
"nonce": secrets.token_hex(8),
|
|
179
|
+
"product_id": cap.product_id,
|
|
180
|
+
"capability_id": capability_id,
|
|
181
|
+
"price_usd": cap.price_per_call_usd,
|
|
182
|
+
"timestamp": timestamp,
|
|
183
|
+
"success": True,
|
|
184
|
+
"latency_ms": round(latency_ms, 2),
|
|
185
|
+
}
|
|
186
|
+
)
|
|
187
|
+
return {
|
|
188
|
+
"capability_id": capability_id,
|
|
189
|
+
"output": output,
|
|
190
|
+
"price_usd": cap.price_per_call_usd,
|
|
191
|
+
"provenance": {"source": self.spec.product_id, "timestamp": timestamp, "input_hash": input_hash(input_data)},
|
|
192
|
+
"receipt": receipt,
|
|
193
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Tiny fixed-window rate limiter — bounds DoS / cost on open endpoints.
|
|
2
|
+
|
|
3
|
+
Keyed per client (typically the real client IP supplied by the reverse proxy)
|
|
4
|
+
so a single noisy client cannot exhaust the budget for everyone. An unkeyed
|
|
5
|
+
``allow()`` falls back to a shared bucket for backwards compatibility.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from collections import defaultdict, deque
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RateLimiter:
|
|
15
|
+
def __init__(self, limit: int, window_s: float = 60.0, *, max_keys: int = 8192) -> None:
|
|
16
|
+
self.limit = limit
|
|
17
|
+
self.window = window_s
|
|
18
|
+
self.max_keys = max_keys
|
|
19
|
+
self._buckets: dict[str, deque[float]] = defaultdict(deque)
|
|
20
|
+
|
|
21
|
+
def allow(self, key: str = "*") -> bool:
|
|
22
|
+
now = time.monotonic()
|
|
23
|
+
# Opportunistic eviction so idle/one-shot keys (e.g. spoofed source IPs)
|
|
24
|
+
# cannot grow the bucket map without bound.
|
|
25
|
+
if len(self._buckets) > self.max_keys:
|
|
26
|
+
self._evict_stale(now)
|
|
27
|
+
hits = self._buckets[key]
|
|
28
|
+
while hits and now - hits[0] > self.window:
|
|
29
|
+
hits.popleft()
|
|
30
|
+
if len(hits) >= self.limit:
|
|
31
|
+
return False
|
|
32
|
+
hits.append(now)
|
|
33
|
+
return True
|
|
34
|
+
|
|
35
|
+
def _evict_stale(self, now: float) -> None:
|
|
36
|
+
stale = [k for k, h in self._buckets.items() if not h or now - h[-1] > self.window]
|
|
37
|
+
for k in stale:
|
|
38
|
+
del self._buckets[k]
|
|
39
|
+
|
|
40
|
+
def reset(self) -> None:
|
|
41
|
+
self._buckets.clear()
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Ed25519 signing (+ optional hybrid ML-DSA-65) for the oracle family.
|
|
2
|
+
|
|
3
|
+
Canonical forms for manifest (4-field, with tools_hash) and receipts (7-field)
|
|
4
|
+
match the AIMarket hub exactly, so signatures verify against the live hub. PQC is
|
|
5
|
+
additive: when enabled, an ML-DSA-65 (FIPS 204) signature is attached alongside
|
|
6
|
+
Ed25519 — verifiers that understand it require both; the hub keeps checking Ed25519.
|
|
7
|
+
|
|
8
|
+
Decoupled from any single oracle's config: the seed may come from
|
|
9
|
+
``ORACLE_SIGNING_SEED_B64`` (secrets manager / KMS) and PQC from ``ORACLE_PQC=1``
|
|
10
|
+
or the constructor flag.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import base64
|
|
16
|
+
import contextlib
|
|
17
|
+
import hashlib
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
from dilithium_py.ml_dsa import ML_DSA_65 as _MLDSA
|
|
25
|
+
|
|
26
|
+
_PQ_LIB = True
|
|
27
|
+
except Exception: # pragma: no cover
|
|
28
|
+
_MLDSA = None
|
|
29
|
+
_PQ_LIB = False
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def pqc_available() -> bool:
|
|
33
|
+
return _PQ_LIB
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _ensure_keypair(path: Path) -> tuple[bytes, bytes]:
|
|
37
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
38
|
+
|
|
39
|
+
if path.exists():
|
|
40
|
+
raw = path.read_bytes()
|
|
41
|
+
if len(raw) == 64:
|
|
42
|
+
return raw[:32], raw[32:]
|
|
43
|
+
raise RuntimeError(f"Ed25519 key file {path} is corrupted (size={len(raw)})")
|
|
44
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
priv = Ed25519PrivateKey.generate()
|
|
46
|
+
seed = priv.private_bytes_raw()
|
|
47
|
+
pub = priv.public_key().public_bytes_raw()
|
|
48
|
+
path.write_bytes(seed + pub)
|
|
49
|
+
with contextlib.suppress(OSError):
|
|
50
|
+
os.chmod(path, 0o600)
|
|
51
|
+
return seed, pub
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _seed_from_env() -> bytes | None:
|
|
55
|
+
raw = os.environ.get("ORACLE_SIGNING_SEED_B64")
|
|
56
|
+
if not raw:
|
|
57
|
+
return None
|
|
58
|
+
seed = base64.b64decode(raw)
|
|
59
|
+
if len(seed) != 32:
|
|
60
|
+
raise RuntimeError("ORACLE_SIGNING_SEED_B64 must decode to a 32-byte seed")
|
|
61
|
+
return seed
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _load_or_make_pq(path: Path) -> tuple[bytes, bytes]:
|
|
65
|
+
if path.exists():
|
|
66
|
+
pk_hex, sk_hex = path.read_text().split("\n")[:2]
|
|
67
|
+
return bytes.fromhex(pk_hex), bytes.fromhex(sk_hex)
|
|
68
|
+
pk, sk = _MLDSA.keygen()
|
|
69
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
path.write_text(f"{pk.hex()}\n{sk.hex()}\n")
|
|
71
|
+
with contextlib.suppress(OSError):
|
|
72
|
+
os.chmod(path, 0o600)
|
|
73
|
+
return pk, sk
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class Signer:
|
|
77
|
+
def __init__(self, key_path: str | Path = "data/oracle_signing_key", pqc: bool | None = None) -> None:
|
|
78
|
+
self.key_path = Path(key_path)
|
|
79
|
+
env_seed = _seed_from_env()
|
|
80
|
+
if env_seed is not None:
|
|
81
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
82
|
+
|
|
83
|
+
self._seed = env_seed
|
|
84
|
+
self._pub_bytes = (
|
|
85
|
+
Ed25519PrivateKey.from_private_bytes(env_seed).public_key().public_bytes_raw()
|
|
86
|
+
)
|
|
87
|
+
else:
|
|
88
|
+
self._seed, self._pub_bytes = _ensure_keypair(self.key_path)
|
|
89
|
+
self._public_key_b64 = base64.b64encode(self._pub_bytes).decode()
|
|
90
|
+
|
|
91
|
+
if pqc is None:
|
|
92
|
+
pqc = os.environ.get("ORACLE_PQC", "").lower() in ("1", "true", "yes")
|
|
93
|
+
self._pq: tuple[bytes, bytes] | None = None
|
|
94
|
+
if pqc and _PQ_LIB:
|
|
95
|
+
self._pq = _load_or_make_pq(Path(f"{self.key_path}_mldsa"))
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def public_key_b64(self) -> str:
|
|
99
|
+
return self._public_key_b64
|
|
100
|
+
|
|
101
|
+
def sign_canonical(self, canonical: str) -> str:
|
|
102
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
103
|
+
|
|
104
|
+
sig = Ed25519PrivateKey.from_private_bytes(self._seed).sign(canonical.encode())
|
|
105
|
+
return base64.b64encode(sig).decode()
|
|
106
|
+
|
|
107
|
+
def sign_payload(self, canonical: str) -> dict[str, str]:
|
|
108
|
+
obj: dict[str, str] = {
|
|
109
|
+
"algorithm": "ed25519",
|
|
110
|
+
"public_key": self._public_key_b64,
|
|
111
|
+
"value": self.sign_canonical(canonical),
|
|
112
|
+
}
|
|
113
|
+
if self._pq is not None:
|
|
114
|
+
pk, sk = self._pq
|
|
115
|
+
obj["pq_algorithm"] = "ml-dsa-65"
|
|
116
|
+
obj["pq_public_key"] = base64.b64encode(pk).decode()
|
|
117
|
+
obj["pq_value"] = base64.b64encode(_MLDSA.sign(sk, canonical.encode())).decode()
|
|
118
|
+
return obj
|
|
119
|
+
|
|
120
|
+
@staticmethod
|
|
121
|
+
def verify(canonical: str, value_b64: str, public_key_b64: str) -> bool:
|
|
122
|
+
from cryptography.exceptions import InvalidSignature
|
|
123
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
pub = Ed25519PublicKey.from_public_bytes(base64.b64decode(public_key_b64))
|
|
127
|
+
pub.verify(base64.b64decode(value_b64), canonical.encode())
|
|
128
|
+
return True
|
|
129
|
+
except (InvalidSignature, ValueError):
|
|
130
|
+
return False
|
|
131
|
+
|
|
132
|
+
@staticmethod
|
|
133
|
+
def verify_signature_object(canonical: str, sig: dict[str, Any], ed_public_key_b64: str | None = None) -> bool:
|
|
134
|
+
ed_key = ed_public_key_b64 or sig.get("public_key")
|
|
135
|
+
if not ed_key or not Signer.verify(canonical, sig.get("value", ""), ed_key):
|
|
136
|
+
return False
|
|
137
|
+
if sig.get("pq_value"):
|
|
138
|
+
if not _PQ_LIB:
|
|
139
|
+
return False
|
|
140
|
+
try:
|
|
141
|
+
pk = base64.b64decode(sig.get("pq_public_key", ""))
|
|
142
|
+
return bool(_MLDSA.verify(pk, canonical.encode(), base64.b64decode(sig["pq_value"])))
|
|
143
|
+
except Exception:
|
|
144
|
+
return False
|
|
145
|
+
return True
|
|
146
|
+
|
|
147
|
+
# --- manifest / receipt canonicals (match the AIMarket hub) ---
|
|
148
|
+
def manifest_canonical(self, manifest: dict[str, Any]) -> str:
|
|
149
|
+
"""Byte-identical to aimarket_hub.signing.Signer.manifest_canonical.
|
|
150
|
+
|
|
151
|
+
`by_hub_hash` is the fifth field and was missing here. The hub added it so a relay
|
|
152
|
+
cannot tamper with per-peer trust_score and routing metadata under a valid
|
|
153
|
+
signature — and since the hub verifies with ITS canonical, every oracle manifest
|
|
154
|
+
failed with "Invalid manifest signature" and no oracle could federate at all. An
|
|
155
|
+
oracle serves no `by_hub`, so this hashes `{}`, which is exactly what the hub
|
|
156
|
+
computes for the absent key: the two agree without the oracle inventing a field.
|
|
157
|
+
|
|
158
|
+
If the hub's canonical gains a sixth field, this must follow the same day.
|
|
159
|
+
"""
|
|
160
|
+
tools = manifest.get("tools", [])
|
|
161
|
+
tools_hash = hashlib.sha256(
|
|
162
|
+
json.dumps(tools, sort_keys=True, ensure_ascii=False).encode()
|
|
163
|
+
).hexdigest()
|
|
164
|
+
by_hub_hash = hashlib.sha256(
|
|
165
|
+
json.dumps(manifest.get("by_hub", {}), sort_keys=True, ensure_ascii=False).encode()
|
|
166
|
+
).hexdigest()
|
|
167
|
+
return (
|
|
168
|
+
f"capabilities_count:{manifest.get('capabilities_count', 0)}"
|
|
169
|
+
f"|generated_at:{manifest.get('generated_at', '')}"
|
|
170
|
+
f"|protocol_version:{manifest.get('protocol_version', 'v1')}"
|
|
171
|
+
f"|tools_hash:{tools_hash}"
|
|
172
|
+
f"|by_hub_hash:{by_hub_hash}"
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
def sign_manifest(self, manifest: dict[str, Any]) -> dict[str, str]:
|
|
176
|
+
return self.sign_payload(self.manifest_canonical(manifest))
|
|
177
|
+
|
|
178
|
+
def verify_manifest_signature(self, manifest: dict[str, Any], public_key_b64: str | None = None) -> bool:
|
|
179
|
+
sig = manifest.get("signature") or {}
|
|
180
|
+
key = public_key_b64 or sig.get("public_key") or self._public_key_b64
|
|
181
|
+
return self.verify(self.manifest_canonical(manifest), sig.get("value", ""), key)
|
|
182
|
+
|
|
183
|
+
@staticmethod
|
|
184
|
+
def receipt_canonical(receipt: dict[str, Any]) -> str:
|
|
185
|
+
success = 1 if receipt.get("success", True) else 0
|
|
186
|
+
return (
|
|
187
|
+
f"nonce:{receipt.get('nonce', '')}"
|
|
188
|
+
f"|product_id:{receipt.get('product_id', '')}"
|
|
189
|
+
f"|capability_id:{receipt.get('capability_id', '')}"
|
|
190
|
+
f"|price_usd:{receipt.get('price_usd', 0)}"
|
|
191
|
+
f"|timestamp:{receipt.get('timestamp', '')}"
|
|
192
|
+
f"|success:{success}"
|
|
193
|
+
f"|latency_ms:{receipt.get('latency_ms', 0)}"
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
def sign_receipt(self, receipt: dict[str, Any]) -> dict[str, Any]:
|
|
197
|
+
signed = dict(receipt)
|
|
198
|
+
signed["signature"] = {
|
|
199
|
+
"algorithm": "ed25519",
|
|
200
|
+
"value": self.sign_canonical(self.receipt_canonical(receipt)),
|
|
201
|
+
}
|
|
202
|
+
return signed
|
|
203
|
+
|
|
204
|
+
def verify_receipt(self, receipt: dict[str, Any], public_key_b64: str | None = None) -> bool:
|
|
205
|
+
sig = receipt.get("signature") or {}
|
|
206
|
+
key = public_key_b64 or self._public_key_b64
|
|
207
|
+
body = {k: v for k, v in receipt.items() if k != "signature"}
|
|
208
|
+
return self.verify(self.receipt_canonical(body), sig.get("value", ""), key)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
# `aimarket-oracle-core`, NOT `oracle-core`. The short name belongs to somebody else on
|
|
3
|
+
# PyPI — an unrelated "personal prediction engine" that happens to install the same
|
|
4
|
+
# top-level `oracle_core` module. Every oracle here declared `dependencies =
|
|
5
|
+
# ["oracle-core"]` unpinned, so `pip install oracles/chronos` in a clean environment
|
|
6
|
+
# resolved that name from the index and installed a stranger's package (plus flask,
|
|
7
|
+
# pandas and scikit-learn) instead of this one, ending in
|
|
8
|
+
# `ImportError: cannot import name 'Capability' from 'oracle_core'`. Verified 2026-07-28.
|
|
9
|
+
# The family image survived only because it installs core and all oracles in one pip
|
|
10
|
+
# invocation, where the local project satisfies the requirement before the index is
|
|
11
|
+
# consulted. Do not shorten this name back.
|
|
12
|
+
name = "aimarket-oracle-core"
|
|
13
|
+
version = "0.2.0"
|
|
14
|
+
description = "Shared AIMarket v2 infrastructure for the oracle family"
|
|
15
|
+
readme = "README.md"
|
|
16
|
+
license = "Apache-2.0"
|
|
17
|
+
requires-python = ">=3.11"
|
|
18
|
+
dependencies = [
|
|
19
|
+
"fastapi>=0.115.0",
|
|
20
|
+
"uvicorn[standard]>=0.32.0",
|
|
21
|
+
"httpx>=0.27.0",
|
|
22
|
+
"pydantic>=2.9.0",
|
|
23
|
+
"cryptography>=44.0.0",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.optional-dependencies]
|
|
27
|
+
dev = ["pytest>=8.3.0", "pytest-asyncio>=0.24.0", "httpx>=0.27.0"]
|
|
28
|
+
pqc = ["dilithium-py>=1.4.0"]
|
|
29
|
+
|
|
30
|
+
[build-system]
|
|
31
|
+
requires = ["hatchling"]
|
|
32
|
+
build-backend = "hatchling.build"
|
|
33
|
+
|
|
34
|
+
[tool.hatch.build.targets.wheel]
|
|
35
|
+
packages = ["oracle_core"]
|
|
36
|
+
|
|
37
|
+
[tool.pytest.ini_options]
|
|
38
|
+
asyncio_mode = "auto"
|
|
39
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from httpx import ASGITransport, AsyncClient
|
|
3
|
+
|
|
4
|
+
from oracle_core import Capability, OracleSpec, Signer, create_app
|
|
5
|
+
from oracle_core.protocol import Protocol
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _spec(tmp_path):
|
|
9
|
+
return OracleSpec(
|
|
10
|
+
name="Test Oracle",
|
|
11
|
+
product_id="prod-test",
|
|
12
|
+
description="test",
|
|
13
|
+
public_url="http://localhost:9999",
|
|
14
|
+
categories=["test"],
|
|
15
|
+
signing_key_path=str(tmp_path / "key"),
|
|
16
|
+
capabilities=[
|
|
17
|
+
Capability("test.echo@v1", "echo", handler=lambda d: {"echo": d.get("msg", "")}, price_per_call_usd=0.002),
|
|
18
|
+
Capability("test.async@v1", "async", handler=_async_handler),
|
|
19
|
+
],
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def _async_handler(d):
|
|
24
|
+
return {"doubled": d.get("n", 0) * 2}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TestProtocol:
|
|
28
|
+
def test_manifest_self_verifies(self, tmp_path):
|
|
29
|
+
proto = Protocol(_spec(tmp_path))
|
|
30
|
+
m = proto.manifest()
|
|
31
|
+
assert m["capabilities_count"] == 2
|
|
32
|
+
assert proto.signer.verify_manifest_signature(m) is True
|
|
33
|
+
|
|
34
|
+
@pytest.mark.asyncio
|
|
35
|
+
async def test_invoke_envelope_and_receipt(self, tmp_path):
|
|
36
|
+
proto = Protocol(_spec(tmp_path))
|
|
37
|
+
r = await proto.invoke("test.echo@v1", {"msg": "hi"})
|
|
38
|
+
assert r["output"] == {"echo": "hi"}
|
|
39
|
+
assert r["price_usd"] == 0.002
|
|
40
|
+
assert len(r["provenance"]["input_hash"]) == 64
|
|
41
|
+
assert proto.signer.verify_receipt(r["receipt"]) is True
|
|
42
|
+
|
|
43
|
+
@pytest.mark.asyncio
|
|
44
|
+
async def test_async_handler(self, tmp_path):
|
|
45
|
+
proto = Protocol(_spec(tmp_path))
|
|
46
|
+
r = await proto.invoke("test.async@v1", {"n": 21})
|
|
47
|
+
assert r["output"] == {"doubled": 42}
|
|
48
|
+
|
|
49
|
+
def test_unknown_capability_raises(self, tmp_path):
|
|
50
|
+
with pytest.raises(ValueError):
|
|
51
|
+
Protocol(_spec(tmp_path)).spec.capability("nope@v1")
|
|
52
|
+
|
|
53
|
+
@pytest.mark.asyncio
|
|
54
|
+
async def test_measured_metrics_after_invoke(self, tmp_path):
|
|
55
|
+
proto = Protocol(_spec(tmp_path))
|
|
56
|
+
await proto.invoke("test.echo@v1", {"msg": "x"})
|
|
57
|
+
tool = next(t for t in proto.manifest()["tools"] if t["capability_id"] == "test.echo@v1")
|
|
58
|
+
assert tool["metrics_source"] == "measured" and tool["calls_observed"] >= 1
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class TestApp:
|
|
62
|
+
@pytest.mark.asyncio
|
|
63
|
+
async def test_endpoints(self, tmp_path):
|
|
64
|
+
app = create_app(_spec(tmp_path))
|
|
65
|
+
transport = ASGITransport(app=app)
|
|
66
|
+
async with AsyncClient(transport=transport, base_url="http://t") as c:
|
|
67
|
+
assert (await c.get("/api/health")).json()["status"] == "ok"
|
|
68
|
+
wk = (await c.get("/.well-known/ai-market.json")).json()
|
|
69
|
+
assert wk["protocol_version"] == "v2" and wk["signer_public_key"]
|
|
70
|
+
inv = (await c.post("/ai-market/v2/invoke", json={"capability_id": "test.echo@v1", "input": {"msg": "yo"}})).json()
|
|
71
|
+
assert inv["ok"] is True and inv["output"] == {"echo": "yo"}
|
|
72
|
+
bad = (await c.post("/ai-market/v2/invoke", json={"capability_id": "x@v1", "input": {}})).json()
|
|
73
|
+
assert bad["ok"] is False
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class TestSigningPQC:
|
|
77
|
+
def test_hybrid_when_enabled(self, tmp_path):
|
|
78
|
+
from oracle_core.signing import pqc_available
|
|
79
|
+
|
|
80
|
+
if not pqc_available():
|
|
81
|
+
pytest.skip("dilithium-py not installed")
|
|
82
|
+
s = Signer(tmp_path / "k", pqc=True)
|
|
83
|
+
sig = s.sign_payload("a|b")
|
|
84
|
+
assert sig.get("pq_algorithm") == "ml-dsa-65"
|
|
85
|
+
assert Signer.verify_signature_object("a|b", sig) is True
|
|
86
|
+
bad = dict(sig)
|
|
87
|
+
bad["pq_value"] = "AA" + sig["pq_value"][2:]
|
|
88
|
+
assert Signer.verify_signature_object("a|b", bad) is False
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ── Invoke error translation ─────────────────────────────────────
|
|
92
|
+
# Only ValueError used to become {"ok": false, "error": …}; every other way a handler can
|
|
93
|
+
# say "no" escaped as a bare 500 Internal Server Error with an empty body. A caller that
|
|
94
|
+
# read the schema and mistyped one field got nothing to correct, and a federated refusal
|
|
95
|
+
# ("Unknown capability: platon.verify@v1") was replaced by silence.
|
|
96
|
+
|
|
97
|
+
def _err_spec(tmp_path):
|
|
98
|
+
def missing_field(d):
|
|
99
|
+
return {"v": d["required_field"]} # KeyError
|
|
100
|
+
|
|
101
|
+
def bad_input(d):
|
|
102
|
+
raise ValueError("points must be a list of [x, y] pairs")
|
|
103
|
+
|
|
104
|
+
def upstream_refused(d):
|
|
105
|
+
raise RuntimeError("Unknown capability: platon.verify@v1")
|
|
106
|
+
|
|
107
|
+
def genuine_fault(d):
|
|
108
|
+
return 1 / 0 # ZeroDivisionError
|
|
109
|
+
|
|
110
|
+
return OracleSpec(
|
|
111
|
+
name="Err Oracle", product_id="prod-err", description="err",
|
|
112
|
+
public_url="http://localhost:9999", categories=["test"],
|
|
113
|
+
signing_key_path=str(tmp_path / "errkey"),
|
|
114
|
+
capabilities=[
|
|
115
|
+
Capability("err.missing@v1", "missing", handler=missing_field),
|
|
116
|
+
Capability("err.bad@v1", "bad", handler=bad_input),
|
|
117
|
+
Capability("err.upstream@v1", "upstream", handler=upstream_refused),
|
|
118
|
+
Capability("err.fault@v1", "fault", handler=genuine_fault),
|
|
119
|
+
],
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@pytest.mark.asyncio
|
|
124
|
+
@pytest.mark.parametrize(
|
|
125
|
+
"cap,needle",
|
|
126
|
+
[
|
|
127
|
+
("err.missing@v1", "missing required input field: required_field"),
|
|
128
|
+
("err.bad@v1", "points must be a list of [x, y] pairs"),
|
|
129
|
+
("err.upstream@v1", "Unknown capability: platon.verify@v1"),
|
|
130
|
+
],
|
|
131
|
+
)
|
|
132
|
+
async def test_refusals_reach_the_caller_with_their_reason(tmp_path, cap, needle):
|
|
133
|
+
app = create_app(_err_spec(tmp_path))
|
|
134
|
+
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as c:
|
|
135
|
+
r = await c.post("/ai-market/v2/invoke", json={"capability_id": cap, "input": {}})
|
|
136
|
+
assert r.status_code == 200, r.text
|
|
137
|
+
body = r.json()
|
|
138
|
+
assert body["ok"] is False
|
|
139
|
+
assert needle in body["error"], body
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@pytest.mark.asyncio
|
|
143
|
+
async def test_a_real_fault_stays_a_5xx_but_says_what_broke(tmp_path):
|
|
144
|
+
"""A crash must not be laundered into a 200 — but an empty body is indistinguishable
|
|
145
|
+
from a dead process, so the type and message travel with the 500."""
|
|
146
|
+
app = create_app(_err_spec(tmp_path))
|
|
147
|
+
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as c:
|
|
148
|
+
r = await c.post("/ai-market/v2/invoke", json={"capability_id": "err.fault@v1", "input": {}})
|
|
149
|
+
assert r.status_code == 500
|
|
150
|
+
assert "ZeroDivisionError" in r.text
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@pytest.mark.asyncio
|
|
154
|
+
async def test_manifest_validates_against_the_published_protocol_schema(tmp_path):
|
|
155
|
+
"""The whole point of a manifest is that a hub will accept it.
|
|
156
|
+
|
|
157
|
+
`p50_latency_ms` is declared `integer` in aimarket-protocol/schemas/manifest.json.
|
|
158
|
+
oracle_core emitted `round(p50, 2)`, so once a capability had been called and its
|
|
159
|
+
measured latency was fractional (2261.84), every hub rejected the entire manifest and
|
|
160
|
+
federated none of the oracle's capabilities.
|
|
161
|
+
"""
|
|
162
|
+
import json
|
|
163
|
+
from pathlib import Path
|
|
164
|
+
|
|
165
|
+
import jsonschema
|
|
166
|
+
|
|
167
|
+
schema_path = (
|
|
168
|
+
Path(__file__).resolve().parents[3] / "aimarket-protocol" / "schemas" / "manifest.json"
|
|
169
|
+
)
|
|
170
|
+
if not schema_path.is_file():
|
|
171
|
+
pytest.skip(f"protocol schema not present at {schema_path}")
|
|
172
|
+
|
|
173
|
+
app = create_app(_spec(tmp_path))
|
|
174
|
+
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as c:
|
|
175
|
+
# Call one capability first so the metrics path (not just the declared default)
|
|
176
|
+
# is what gets serialised — that is the shape that actually broke.
|
|
177
|
+
await c.post("/ai-market/v2/invoke", json={"capability_id": "test.echo@v1", "input": {"msg": "x"}})
|
|
178
|
+
manifest = (await c.get("/ai-market/v2/manifest")).json()
|
|
179
|
+
|
|
180
|
+
for tool in manifest.get("tools") or []:
|
|
181
|
+
assert isinstance(tool["p50_latency_ms"], int), tool
|
|
182
|
+
jsonschema.validate(manifest, json.loads(schema_path.read_text()))
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@pytest.mark.asyncio
|
|
186
|
+
async def test_the_hub_can_verify_an_oracle_manifest_signature(tmp_path):
|
|
187
|
+
"""Cross-implementation check: the hub's verifier against an oracle's signature.
|
|
188
|
+
|
|
189
|
+
These are two separate codebases signing the same canonical string, and they had
|
|
190
|
+
drifted — the hub added a fifth field (`by_hub_hash`) to stop a relay tampering with
|
|
191
|
+
peer trust, oracle_core kept signing four, and so EVERY oracle manifest was rejected
|
|
192
|
+
with "Invalid manifest signature". Federation was silently dead; the only federated
|
|
193
|
+
rows in the live catalogue predated the hub's change. A unit test inside either
|
|
194
|
+
package would have passed — only checking one against the other catches this.
|
|
195
|
+
"""
|
|
196
|
+
import sys
|
|
197
|
+
from pathlib import Path
|
|
198
|
+
|
|
199
|
+
hub_pkg = Path(__file__).resolve().parents[3] / "aimarket-hub"
|
|
200
|
+
if not (hub_pkg / "aimarket_hub" / "signing.py").is_file():
|
|
201
|
+
pytest.skip("aimarket-hub not available in this checkout")
|
|
202
|
+
sys.path.insert(0, str(hub_pkg))
|
|
203
|
+
try:
|
|
204
|
+
from aimarket_hub.signing import Signer as HubSigner
|
|
205
|
+
except Exception as exc: # noqa: BLE001 — hub deps may be absent in the oracle venv
|
|
206
|
+
pytest.skip(f"aimarket_hub not importable: {exc}")
|
|
207
|
+
|
|
208
|
+
app = create_app(_spec(tmp_path))
|
|
209
|
+
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as c:
|
|
210
|
+
manifest = (await c.get("/ai-market/v2/manifest")).json()
|
|
211
|
+
|
|
212
|
+
oracle_key = manifest["signature"]["public_key"]
|
|
213
|
+
hub_signer = HubSigner(str(tmp_path / "hubkey"))
|
|
214
|
+
assert hub_signer.verify_manifest_signature(manifest, oracle_key), (
|
|
215
|
+
"the hub rejects this oracle's manifest — the canonical forms have diverged again"
|
|
216
|
+
)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Every oracle must depend on the shared library by the name this repo actually builds.
|
|
2
|
+
|
|
3
|
+
On 2026-07-28 all 17 oracles declared `dependencies = ["oracle-core"]`, unpinned. That name
|
|
4
|
+
belongs to an unrelated project on PyPI which installs the same top-level `oracle_core`
|
|
5
|
+
module, so `pip install oracles/chronos` in a clean environment resolved it from the index
|
|
6
|
+
and installed a stranger's package — flask, pandas and scikit-learn came with it — ending in
|
|
7
|
+
`ImportError: cannot import name 'Capability' from 'oracle_core'`. The family image never
|
|
8
|
+
noticed: it installs core and every oracle in one pip invocation, where the local project
|
|
9
|
+
satisfies the requirement before the index is consulted.
|
|
10
|
+
|
|
11
|
+
That is the whole failure mode — a dependency name nothing in the repo provides is not an
|
|
12
|
+
error at declaration time, only at install time, and only outside the one environment we
|
|
13
|
+
routinely build. So the check is static and needs no network: the name the oracles ask for
|
|
14
|
+
must be the name core publishes.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import pathlib
|
|
20
|
+
import re
|
|
21
|
+
|
|
22
|
+
import pytest
|
|
23
|
+
|
|
24
|
+
ORACLES = pathlib.Path(__file__).resolve().parents[2] / "oracles"
|
|
25
|
+
CORE_PYPROJECT = pathlib.Path(__file__).resolve().parents[1] / "pyproject.toml"
|
|
26
|
+
|
|
27
|
+
_NAME = re.compile(r'^\s*name\s*=\s*["\']([^"\']+)["\']', re.M)
|
|
28
|
+
_DEPS = re.compile(r"^dependencies\s*=\s*\[(.*?)\]", re.M | re.S)
|
|
29
|
+
# "aimarket-oracle-core>=0.2" -> "aimarket-oracle-core"
|
|
30
|
+
_REQ = re.compile(r'["\']\s*([A-Za-z0-9._-]+)')
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _dist_name(pyproject: pathlib.Path) -> str:
|
|
34
|
+
m = _NAME.search(pyproject.read_text())
|
|
35
|
+
assert m, f"{pyproject} declares no [project] name"
|
|
36
|
+
return m.group(1).lower().replace("_", "-")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _dependency_names(pyproject: pathlib.Path) -> list[str]:
|
|
40
|
+
m = _DEPS.search(pyproject.read_text())
|
|
41
|
+
if not m:
|
|
42
|
+
return []
|
|
43
|
+
return [r.lower().replace("_", "-") for r in _REQ.findall(m.group(1))]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
CORE_DIST = _dist_name(CORE_PYPROJECT)
|
|
47
|
+
ORACLE_PYPROJECTS = sorted(ORACLES.glob("*/pyproject.toml"))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_the_oracle_tree_was_found():
|
|
51
|
+
"""A bad glob is how gaia slipped through the first sweep of this same fix."""
|
|
52
|
+
assert len(ORACLE_PYPROJECTS) >= 17, f"only found {len(ORACLE_PYPROJECTS)} oracles under {ORACLES}"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_core_is_namespaced():
|
|
56
|
+
assert CORE_DIST.startswith("aimarket-"), (
|
|
57
|
+
f"core publishes as {CORE_DIST!r}. Unprefixed names collide with strangers on PyPI; "
|
|
58
|
+
"keep the shared library inside the namespace this project owns."
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@pytest.mark.parametrize("pyproject", ORACLE_PYPROJECTS, ids=lambda p: p.parent.name)
|
|
63
|
+
def test_oracle_depends_on_core_by_the_name_core_publishes(pyproject):
|
|
64
|
+
deps = _dependency_names(pyproject)
|
|
65
|
+
if CORE_DIST in deps:
|
|
66
|
+
return
|
|
67
|
+
stale = [d for d in deps if "oracle-core" in d or d == "oracle_core"]
|
|
68
|
+
pytest.fail(
|
|
69
|
+
f"{pyproject.parent.name} depends on {stale or deps} but core publishes as "
|
|
70
|
+
f"{CORE_DIST!r}. An unresolvable-locally name is fetched from PyPI, where "
|
|
71
|
+
f"`oracle-core` is somebody else's project."
|
|
72
|
+
)
|