karma-openclaw 0.4.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.
- karma_openclaw-0.4.0/PKG-INFO +114 -0
- karma_openclaw-0.4.0/README.md +100 -0
- karma_openclaw-0.4.0/karma_openclaw/__init__.py +21 -0
- karma_openclaw-0.4.0/karma_openclaw/__main__.py +6 -0
- karma_openclaw-0.4.0/karma_openclaw/agent_binding.py +180 -0
- karma_openclaw-0.4.0/karma_openclaw/agent_env.py +121 -0
- karma_openclaw-0.4.0/karma_openclaw/bilateral_tools.py +104 -0
- karma_openclaw-0.4.0/karma_openclaw/guard.py +152 -0
- karma_openclaw-0.4.0/karma_openclaw/handoff.py +102 -0
- karma_openclaw-0.4.0/karma_openclaw/helpers.py +145 -0
- karma_openclaw-0.4.0/karma_openclaw/http_client.py +229 -0
- karma_openclaw-0.4.0/karma_openclaw/orchestration.py +70 -0
- karma_openclaw-0.4.0/karma_openclaw/p0_tools.py +292 -0
- karma_openclaw-0.4.0/karma_openclaw/pairing_tools.py +628 -0
- karma_openclaw-0.4.0/karma_openclaw/phase1_tools.py +301 -0
- karma_openclaw-0.4.0/karma_openclaw/phase2_tools.py +33 -0
- karma_openclaw-0.4.0/karma_openclaw/runtime_tools.py +192 -0
- karma_openclaw-0.4.0/karma_openclaw/server.py +275 -0
- karma_openclaw-0.4.0/karma_openclaw.egg-info/PKG-INFO +114 -0
- karma_openclaw-0.4.0/karma_openclaw.egg-info/SOURCES.txt +36 -0
- karma_openclaw-0.4.0/karma_openclaw.egg-info/dependency_links.txt +1 -0
- karma_openclaw-0.4.0/karma_openclaw.egg-info/entry_points.txt +2 -0
- karma_openclaw-0.4.0/karma_openclaw.egg-info/requires.txt +7 -0
- karma_openclaw-0.4.0/karma_openclaw.egg-info/top_level.txt +1 -0
- karma_openclaw-0.4.0/pyproject.toml +26 -0
- karma_openclaw-0.4.0/setup.cfg +4 -0
- karma_openclaw-0.4.0/tests/test_agent_binding.py +143 -0
- karma_openclaw-0.4.0/tests/test_agent_env_live.py +87 -0
- karma_openclaw-0.4.0/tests/test_automation_readiness_mcp.py +38 -0
- karma_openclaw-0.4.0/tests/test_dev_delivery_signatures.py +15 -0
- karma_openclaw-0.4.0/tests/test_guard.py +48 -0
- karma_openclaw-0.4.0/tests/test_handoff.py +44 -0
- karma_openclaw-0.4.0/tests/test_helpers.py +44 -0
- karma_openclaw-0.4.0/tests/test_import.py +12 -0
- karma_openclaw-0.4.0/tests/test_openclaw_server_identity.py +60 -0
- karma_openclaw-0.4.0/tests/test_orchestration.py +16 -0
- karma_openclaw-0.4.0/tests/test_pairing_mcp.py +34 -0
- karma_openclaw-0.4.0/tests/test_phase1_tools.py +20 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: karma-openclaw
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Karma ↔ OpenClaw — stdio MCP server bridging to Karma public HTTP API
|
|
5
|
+
License: Apache-2.0
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: cryptography>=42.0.0
|
|
9
|
+
Requires-Dist: httpx>=0.27.0
|
|
10
|
+
Requires-Dist: mcp<2,>=1.8.0
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest>=8.2.0; extra == "dev"
|
|
13
|
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
|
|
14
|
+
|
|
15
|
+
# karma-openclaw — OpenClaw MCP proof plugin
|
|
16
|
+
|
|
17
|
+
Stdio MCP server that attaches verifiable execution receipts and evidence bundles to OpenClaw agent workflows. Karma acts as a proof layer for paid agent actions — it does not replace payment or tool execution, it records proof after actions happen.
|
|
18
|
+
|
|
19
|
+
**P0 proof tools:** execution receipt construction, evidence bundle submission, verification and handoff validation. High-risk settlement actions (voucher create/accept, Runtime Key mint) remain manual in the Karma Console. See [Advanced OpenClaw Workflows](../../docs/OPENCLAW_P1_DUAL_AGENT.md) and [`examples/openclaw-dual-agent/`](../../examples/openclaw-dual-agent/).
|
|
20
|
+
|
|
21
|
+
**Runtime Key access is agent-bound.** A `KRM_RT_…` key is a bearer token — whoever holds it could spend the owner's money — so Karma mints every key against a named agent, pins it to that agent's Ed25519 public key and requires the agent to sign every `/runtime/*` request with the matching private key. The private key never leaves the agent machine, so a copied key string is useless.
|
|
22
|
+
|
|
23
|
+
**Pairing activates the key by itself.** `karma_connect` generates this machine's Ed25519 key on first use and sends its public key with the pairing request, so the owner's approval *is* the activation: `karma_connect_claim` returns a key that is already live — nothing to type, no matching code. (Without a local key, or if the declared key is malformed, the older two-step path still applies: `karma_runtime_bind_key` hands the owner an 8-character `activation_code`, then `karma_runtime_await_activation`.) Keys have no fixed expiry by default; the owner revokes them in the Console. Set `KARMA_RUNTIME_KEY`, `KARMA_AGENT_ID` and `KARMA_AGENT_PRIVATE_KEY` (Ed25519, stays on this machine). See [Runtime Key 指南](../../docs/runtime-key-guide.md).
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install ./packages/karma-openclaw
|
|
31
|
+
# or dev mode:
|
|
32
|
+
pip install -e ./packages/karma-openclaw
|
|
33
|
+
# no clone? one line, from the public repo:
|
|
34
|
+
pip install "karma-openclaw @ git+https://github.com/AtoB101/Karma@main#subdirectory=packages/karma-openclaw"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Not on PyPI yet (`pip install karma-openclaw` returns 404); see
|
|
38
|
+
[`docs/mcp/karma-mcp-integration.md`](../../docs/mcp/karma-mcp-integration.md).
|
|
39
|
+
|
|
40
|
+
## Run (stdio MCP)
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
export KARMA_RUNTIME_URL=http://localhost:8000
|
|
44
|
+
export KARMA_API_KEY=***karma_worker-001_***
|
|
45
|
+
export KARMA_OPENCLAW_HANDOFF_PATH=./handoff.json
|
|
46
|
+
|
|
47
|
+
# /runtime/* tools (agent-bound key):
|
|
48
|
+
export KARMA_RUNTIME_KEY=KRM_RT_…
|
|
49
|
+
export KARMA_AGENT_ID=<the agent this key names>
|
|
50
|
+
export KARMA_AGENT_PRIVATE_KEY=<base64(32 bytes) or 64-char hex>
|
|
51
|
+
|
|
52
|
+
karma-openclaw-mcp
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## MCP Proof Tools
|
|
56
|
+
|
|
57
|
+
| Tool | Purpose |
|
|
58
|
+
|------|---------|
|
|
59
|
+
| `karma_build_execution_receipt_step` | Generate a signed receipt for one tool call |
|
|
60
|
+
| `karma_submit_evidence_bundle` | Package receipts into a verifiable bundle |
|
|
61
|
+
| `karma_get_evidence_bundle` | Retrieve a previously submitted bundle |
|
|
62
|
+
| `karma_validate_handoff` | Verify operator handoff for high-risk actions |
|
|
63
|
+
| `karma_connect` | **No key yet?** Ask to be connected; returns the `user_code` for your owner |
|
|
64
|
+
| `karma_connect_claim` | Collect the credentials as soon as the owner approves; writes `~/.karma/agent.env` (0600) — including the local Ed25519 seed — and returns fingerprints only. Also answers "where is this pairing now": while it is unapproved it returns status only and consumes nothing |
|
|
65
|
+
| `karma_connect_status` | What this machine holds: pending pairings + credential fingerprints. Local only — it never calls the backend |
|
|
66
|
+
| `karma_pairing_*` | Deprecated aliases of the three above (same code path, each return carries a `deprecated` note). **Removal date: 2026-12-01** (next minor release); see `docs/AGENT_PAIRING_V1.md` |
|
|
67
|
+
| `karma_runtime_bind_key` | Bind this agent's Ed25519 public key; returns the matching code for the owner |
|
|
68
|
+
| `karma_runtime_await_activation` | Wait for the owner to enter the code; then signs every request |
|
|
69
|
+
| `karma_runtime_binding_status` | Where this key stands (pending / active / bearer) |
|
|
70
|
+
| `karma_check_automation_readiness` | Check if automation policy allows this action |
|
|
71
|
+
| `karma_get_settlement` | (Optional) Read settlement status |
|
|
72
|
+
|
|
73
|
+
## Onboarding with no key at all
|
|
74
|
+
|
|
75
|
+
An agent that holds nothing can still get itself connected — and the credentials never
|
|
76
|
+
travel through the chat:
|
|
77
|
+
|
|
78
|
+
```text
|
|
79
|
+
karma_connect(agent_name="claw-001", requested_side="seller", requested_vertical="food")
|
|
80
|
+
-> user_code "QKFS-3J5Z" + verification_uri + agent_fingerprint
|
|
81
|
+
# hand these to your owner: they compare agent_fingerprint with the Console card,
|
|
82
|
+
# so "the request in the Console" is provably "this machine's public key"
|
|
83
|
+
... owner reviews it in Karma Console, sets the allowance, and approves — that is the delivery
|
|
84
|
+
karma_connect_claim()
|
|
85
|
+
-> credentials written to ~/.karma/agent.env (0600); only sha256 fingerprints returned
|
|
86
|
+
karma_connect_status()
|
|
87
|
+
-> what this machine holds; local only, no network
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
The direction is fixed: the agent produces the code, the owner types it in, approval releases
|
|
91
|
+
the credentials. The agent's own `pairing_code` (256-bit, returned once, stored as SHA-256) is
|
|
92
|
+
the only thing that redeems them — so a `user_code` on a screenshot, or a whole chat transcript,
|
|
93
|
+
is worth nothing. If the owner wants one more hand-to-hand confirmation they can *optionally*
|
|
94
|
+
issue a `handoff_code` (3 minutes, single use); then — and only then — the claim has to carry
|
|
95
|
+
it too. See [Agent 配对接入 v1](../../docs/AGENT_PAIRING_V1.md).
|
|
96
|
+
|
|
97
|
+
## Quick Demo
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
pip install -e ".[dev]"
|
|
101
|
+
uvicorn api.app:app --reload &
|
|
102
|
+
export KARMA_RUNTIME_URL=http://127.0.0.1:8000
|
|
103
|
+
export KARMA_API_KEY=***
|
|
104
|
+
|
|
105
|
+
karma-openclaw-mcp
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Then connect OpenClaw to the MCP server and call `karma_build_execution_receipt_step`.
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## License
|
|
113
|
+
|
|
114
|
+
AGPL-3.0-only — see [LICENSE](../../LICENSE).
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# karma-openclaw — OpenClaw MCP proof plugin
|
|
2
|
+
|
|
3
|
+
Stdio MCP server that attaches verifiable execution receipts and evidence bundles to OpenClaw agent workflows. Karma acts as a proof layer for paid agent actions — it does not replace payment or tool execution, it records proof after actions happen.
|
|
4
|
+
|
|
5
|
+
**P0 proof tools:** execution receipt construction, evidence bundle submission, verification and handoff validation. High-risk settlement actions (voucher create/accept, Runtime Key mint) remain manual in the Karma Console. See [Advanced OpenClaw Workflows](../../docs/OPENCLAW_P1_DUAL_AGENT.md) and [`examples/openclaw-dual-agent/`](../../examples/openclaw-dual-agent/).
|
|
6
|
+
|
|
7
|
+
**Runtime Key access is agent-bound.** A `KRM_RT_…` key is a bearer token — whoever holds it could spend the owner's money — so Karma mints every key against a named agent, pins it to that agent's Ed25519 public key and requires the agent to sign every `/runtime/*` request with the matching private key. The private key never leaves the agent machine, so a copied key string is useless.
|
|
8
|
+
|
|
9
|
+
**Pairing activates the key by itself.** `karma_connect` generates this machine's Ed25519 key on first use and sends its public key with the pairing request, so the owner's approval *is* the activation: `karma_connect_claim` returns a key that is already live — nothing to type, no matching code. (Without a local key, or if the declared key is malformed, the older two-step path still applies: `karma_runtime_bind_key` hands the owner an 8-character `activation_code`, then `karma_runtime_await_activation`.) Keys have no fixed expiry by default; the owner revokes them in the Console. Set `KARMA_RUNTIME_KEY`, `KARMA_AGENT_ID` and `KARMA_AGENT_PRIVATE_KEY` (Ed25519, stays on this machine). See [Runtime Key 指南](../../docs/runtime-key-guide.md).
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install ./packages/karma-openclaw
|
|
17
|
+
# or dev mode:
|
|
18
|
+
pip install -e ./packages/karma-openclaw
|
|
19
|
+
# no clone? one line, from the public repo:
|
|
20
|
+
pip install "karma-openclaw @ git+https://github.com/AtoB101/Karma@main#subdirectory=packages/karma-openclaw"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Not on PyPI yet (`pip install karma-openclaw` returns 404); see
|
|
24
|
+
[`docs/mcp/karma-mcp-integration.md`](../../docs/mcp/karma-mcp-integration.md).
|
|
25
|
+
|
|
26
|
+
## Run (stdio MCP)
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
export KARMA_RUNTIME_URL=http://localhost:8000
|
|
30
|
+
export KARMA_API_KEY=***karma_worker-001_***
|
|
31
|
+
export KARMA_OPENCLAW_HANDOFF_PATH=./handoff.json
|
|
32
|
+
|
|
33
|
+
# /runtime/* tools (agent-bound key):
|
|
34
|
+
export KARMA_RUNTIME_KEY=KRM_RT_…
|
|
35
|
+
export KARMA_AGENT_ID=<the agent this key names>
|
|
36
|
+
export KARMA_AGENT_PRIVATE_KEY=<base64(32 bytes) or 64-char hex>
|
|
37
|
+
|
|
38
|
+
karma-openclaw-mcp
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## MCP Proof Tools
|
|
42
|
+
|
|
43
|
+
| Tool | Purpose |
|
|
44
|
+
|------|---------|
|
|
45
|
+
| `karma_build_execution_receipt_step` | Generate a signed receipt for one tool call |
|
|
46
|
+
| `karma_submit_evidence_bundle` | Package receipts into a verifiable bundle |
|
|
47
|
+
| `karma_get_evidence_bundle` | Retrieve a previously submitted bundle |
|
|
48
|
+
| `karma_validate_handoff` | Verify operator handoff for high-risk actions |
|
|
49
|
+
| `karma_connect` | **No key yet?** Ask to be connected; returns the `user_code` for your owner |
|
|
50
|
+
| `karma_connect_claim` | Collect the credentials as soon as the owner approves; writes `~/.karma/agent.env` (0600) — including the local Ed25519 seed — and returns fingerprints only. Also answers "where is this pairing now": while it is unapproved it returns status only and consumes nothing |
|
|
51
|
+
| `karma_connect_status` | What this machine holds: pending pairings + credential fingerprints. Local only — it never calls the backend |
|
|
52
|
+
| `karma_pairing_*` | Deprecated aliases of the three above (same code path, each return carries a `deprecated` note). **Removal date: 2026-12-01** (next minor release); see `docs/AGENT_PAIRING_V1.md` |
|
|
53
|
+
| `karma_runtime_bind_key` | Bind this agent's Ed25519 public key; returns the matching code for the owner |
|
|
54
|
+
| `karma_runtime_await_activation` | Wait for the owner to enter the code; then signs every request |
|
|
55
|
+
| `karma_runtime_binding_status` | Where this key stands (pending / active / bearer) |
|
|
56
|
+
| `karma_check_automation_readiness` | Check if automation policy allows this action |
|
|
57
|
+
| `karma_get_settlement` | (Optional) Read settlement status |
|
|
58
|
+
|
|
59
|
+
## Onboarding with no key at all
|
|
60
|
+
|
|
61
|
+
An agent that holds nothing can still get itself connected — and the credentials never
|
|
62
|
+
travel through the chat:
|
|
63
|
+
|
|
64
|
+
```text
|
|
65
|
+
karma_connect(agent_name="claw-001", requested_side="seller", requested_vertical="food")
|
|
66
|
+
-> user_code "QKFS-3J5Z" + verification_uri + agent_fingerprint
|
|
67
|
+
# hand these to your owner: they compare agent_fingerprint with the Console card,
|
|
68
|
+
# so "the request in the Console" is provably "this machine's public key"
|
|
69
|
+
... owner reviews it in Karma Console, sets the allowance, and approves — that is the delivery
|
|
70
|
+
karma_connect_claim()
|
|
71
|
+
-> credentials written to ~/.karma/agent.env (0600); only sha256 fingerprints returned
|
|
72
|
+
karma_connect_status()
|
|
73
|
+
-> what this machine holds; local only, no network
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
The direction is fixed: the agent produces the code, the owner types it in, approval releases
|
|
77
|
+
the credentials. The agent's own `pairing_code` (256-bit, returned once, stored as SHA-256) is
|
|
78
|
+
the only thing that redeems them — so a `user_code` on a screenshot, or a whole chat transcript,
|
|
79
|
+
is worth nothing. If the owner wants one more hand-to-hand confirmation they can *optionally*
|
|
80
|
+
issue a `handoff_code` (3 minutes, single use); then — and only then — the claim has to carry
|
|
81
|
+
it too. See [Agent 配对接入 v1](../../docs/AGENT_PAIRING_V1.md).
|
|
82
|
+
|
|
83
|
+
## Quick Demo
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
pip install -e ".[dev]"
|
|
87
|
+
uvicorn api.app:app --reload &
|
|
88
|
+
export KARMA_RUNTIME_URL=http://127.0.0.1:8000
|
|
89
|
+
export KARMA_API_KEY=***
|
|
90
|
+
|
|
91
|
+
karma-openclaw-mcp
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Then connect OpenClaw to the MCP server and call `karma_build_execution_receipt_step`.
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## License
|
|
99
|
+
|
|
100
|
+
AGPL-3.0-only — see [LICENSE](../../LICENSE).
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Karma ↔ OpenClaw MCP bridge package.
|
|
2
|
+
|
|
3
|
+
Keep package import light: handoff/helpers must work without pulling FastMCP
|
|
4
|
+
(mcp v1). Server entrypoints are exported lazily.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
__all__ = ["build_app", "main"]
|
|
12
|
+
|
|
13
|
+
__version__ = "0.4.0"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def __getattr__(name: str) -> Any:
|
|
17
|
+
if name in ("build_app", "main"):
|
|
18
|
+
from karma_openclaw.server import build_app, main
|
|
19
|
+
|
|
20
|
+
return build_app if name == "build_app" else main
|
|
21
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Agent-side Runtime Key binding: the Ed25519 key + the per-request signature.
|
|
2
|
+
|
|
3
|
+
Why this exists. A Runtime Key is a bearer token: whoever holds ``KRM_RT_...`` can spend
|
|
4
|
+
the owner's money within its limits. Karma therefore refuses to serve *any* key that is
|
|
5
|
+
not bound to an agent public key and activated by the owner with the 8-character
|
|
6
|
+
matching code. So an agent process must be able to
|
|
7
|
+
|
|
8
|
+
(a) present its Ed25519 public key (``POST /runtime/bind-key``), and
|
|
9
|
+
(b) sign every ``/runtime/*`` request (``X-Karma-Agent-Signature`` + timestamp + nonce).
|
|
10
|
+
|
|
11
|
+
This mirrors ``sdk/runtime_client.py`` and, on the server side,
|
|
12
|
+
``services/runtime_wallet.build_agent_request_message``. Both sides are pinned against
|
|
13
|
+
each other by tests so the format cannot drift.
|
|
14
|
+
|
|
15
|
+
Env:
|
|
16
|
+
KARMA_AGENT_PRIVATE_KEY base64(32 raw bytes) or 64-char hex -- never leaves this process
|
|
17
|
+
KARMA_AGENT_ID the agent id the owner authorized (falls back to what the key was minted for)
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import base64
|
|
22
|
+
import hashlib
|
|
23
|
+
import os
|
|
24
|
+
import uuid
|
|
25
|
+
from datetime import UTC, datetime, timezone
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def runtime_key_id(token: str) -> str:
|
|
30
|
+
"""Pull the key_id out of ``KRM_RT_<key_id>_<secret>`` (the signature message needs it)."""
|
|
31
|
+
raw = (token or "").strip()
|
|
32
|
+
if not raw.startswith("KRM_RT_"):
|
|
33
|
+
return ""
|
|
34
|
+
head, _, _ = raw[len("KRM_RT_") :].partition("_")
|
|
35
|
+
return head
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def agent_key_from_seed(seed: str):
|
|
39
|
+
"""base64(raw 32 bytes) or 64-char hex -> Ed25519PrivateKey."""
|
|
40
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
41
|
+
|
|
42
|
+
text = (seed or "").strip()
|
|
43
|
+
if not text:
|
|
44
|
+
raise ValueError("agent private key is empty")
|
|
45
|
+
if len(text) == 64 and all(c in "0123456789abcdefABCDEF" for c in text):
|
|
46
|
+
raw = bytes.fromhex(text)
|
|
47
|
+
else:
|
|
48
|
+
raw = base64.b64decode(text, validate=True)
|
|
49
|
+
if len(raw) != 32:
|
|
50
|
+
raise ValueError("agent private key must be 32 raw bytes (base64 or hex seed)")
|
|
51
|
+
return Ed25519PrivateKey.from_private_bytes(raw)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def agent_key_from_env() -> Any | None:
|
|
55
|
+
"""The agent's Ed25519 key, or None when no seed is configured.
|
|
56
|
+
|
|
57
|
+
Reads the env var first, then the on-disk credential file -- the same file
|
|
58
|
+
``karma_connect_claim`` writes, so a freshly paired process can sign without
|
|
59
|
+
waiting for its host to restart it.
|
|
60
|
+
"""
|
|
61
|
+
from karma_openclaw.agent_env import credential
|
|
62
|
+
|
|
63
|
+
seed = credential("KARMA_AGENT_PRIVATE_KEY")
|
|
64
|
+
if not seed:
|
|
65
|
+
return None
|
|
66
|
+
return agent_key_from_seed(seed)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def ensure_local_agent_key() -> Any | None:
|
|
70
|
+
"""本机 agent 私钥:有就用,没有就现生成一把并落盘(0600)。
|
|
71
|
+
|
|
72
|
+
为什么由 agent 自己生成:这把私钥是「光有钥匙字符串花不了钱」的全部依据,
|
|
73
|
+
它必须只存在于 agent 本机 —— Karma 服务端从头到尾看不到它,只收到公钥,
|
|
74
|
+
用来把 Runtime Key 钉在这次的 agent 身上。
|
|
75
|
+
"""
|
|
76
|
+
existing = agent_key_from_env()
|
|
77
|
+
if existing is not None:
|
|
78
|
+
return existing
|
|
79
|
+
try:
|
|
80
|
+
import base64 as _b64
|
|
81
|
+
|
|
82
|
+
from cryptography.hazmat.primitives import serialization
|
|
83
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
84
|
+
|
|
85
|
+
from karma_openclaw.agent_env import upsert_credential
|
|
86
|
+
|
|
87
|
+
key = Ed25519PrivateKey.generate()
|
|
88
|
+
raw = key.private_bytes(
|
|
89
|
+
serialization.Encoding.Raw,
|
|
90
|
+
serialization.PrivateFormat.Raw,
|
|
91
|
+
serialization.NoEncryption(),
|
|
92
|
+
)
|
|
93
|
+
upsert_credential("KARMA_AGENT_PRIVATE_KEY", _b64.b64encode(raw).decode())
|
|
94
|
+
return key
|
|
95
|
+
except Exception: # noqa: BLE001 - 落不了盘就当没有,退回「领取时再绑」那条老路
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def agent_id_from_env() -> str:
|
|
100
|
+
from karma_openclaw.agent_env import credential
|
|
101
|
+
|
|
102
|
+
return credential("KARMA_AGENT_ID")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def agent_public_key_b64(key: Any) -> str:
|
|
106
|
+
"""Ed25519 public key as base64 of the raw 32 bytes -- the shape the server stores."""
|
|
107
|
+
from cryptography.hazmat.primitives import serialization
|
|
108
|
+
|
|
109
|
+
raw = key.public_key().public_bytes(
|
|
110
|
+
serialization.Encoding.Raw, serialization.PublicFormat.Raw
|
|
111
|
+
)
|
|
112
|
+
return base64.b64encode(raw).decode()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def build_agent_request_message(
|
|
116
|
+
*, key_id: str, method: str, path: str, timestamp: str, nonce: str, body_sha256: str
|
|
117
|
+
) -> str:
|
|
118
|
+
"""The exact text the server recomputes. Do not reorder these lines."""
|
|
119
|
+
return "\n".join(
|
|
120
|
+
[
|
|
121
|
+
"Karma Runtime Request",
|
|
122
|
+
"key_id:" + key_id,
|
|
123
|
+
"method:" + method.upper(),
|
|
124
|
+
"path:" + path,
|
|
125
|
+
"timestamp:" + timestamp,
|
|
126
|
+
"nonce:" + nonce,
|
|
127
|
+
"body_sha256:" + body_sha256,
|
|
128
|
+
]
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def build_agent_pairing_request_message(
|
|
133
|
+
*, agent_name: str, public_key: str, nonce: str, timestamp: str
|
|
134
|
+
) -> str:
|
|
135
|
+
"""与 services/runtime_wallet.build_agent_pairing_request_message 逐字一致。"""
|
|
136
|
+
return "\n".join(
|
|
137
|
+
[
|
|
138
|
+
"Karma Agent Pairing Request",
|
|
139
|
+
"agent_name:" + agent_name,
|
|
140
|
+
"public_key:" + public_key,
|
|
141
|
+
"nonce:" + nonce,
|
|
142
|
+
"timestamp:" + timestamp,
|
|
143
|
+
]
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def sign_pairing_request(*, key: Any, agent_name: str, public_key: str) -> dict[str, str]:
|
|
148
|
+
"""申请接入要带的三件套:证明申请方真的持有这把公钥对应的私钥。"""
|
|
149
|
+
timestamp = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
150
|
+
nonce = uuid.uuid4().hex
|
|
151
|
+
message = build_agent_pairing_request_message(
|
|
152
|
+
agent_name=agent_name, public_key=public_key, nonce=nonce, timestamp=timestamp
|
|
153
|
+
)
|
|
154
|
+
return {
|
|
155
|
+
"signature": base64.b64encode(key.sign(message.encode("utf-8"))).decode(),
|
|
156
|
+
"nonce": nonce,
|
|
157
|
+
"timestamp": timestamp,
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def sign_runtime_request(
|
|
162
|
+
*, key: Any, key_id: str, method: str, path: str, body: bytes
|
|
163
|
+
) -> dict[str, str]:
|
|
164
|
+
"""The three headers a bound key has to carry on every request."""
|
|
165
|
+
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
166
|
+
nonce = uuid.uuid4().hex
|
|
167
|
+
message = build_agent_request_message(
|
|
168
|
+
key_id=key_id,
|
|
169
|
+
method=method,
|
|
170
|
+
path=path,
|
|
171
|
+
timestamp=timestamp,
|
|
172
|
+
nonce=nonce,
|
|
173
|
+
body_sha256=hashlib.sha256(body or b"").hexdigest(),
|
|
174
|
+
)
|
|
175
|
+
signature = base64.b64encode(key.sign(message.encode("utf-8"))).decode()
|
|
176
|
+
return {
|
|
177
|
+
"X-Karma-Agent-Signature": signature,
|
|
178
|
+
"X-Karma-Runtime-Timestamp": timestamp,
|
|
179
|
+
"X-Karma-Runtime-Nonce": nonce,
|
|
180
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""凭据落在哪、以及怎么把它读回来。
|
|
3
|
+
|
|
4
|
+
`karma_connect_claim` 把凭据写到 ``~/.karma/agent.env``(0600)。但在此之前没有任何
|
|
5
|
+
代码会把它读回 ``os.environ`` —— 也就是说:**已经跑着的 MCP 进程永远看不到刚领到的
|
|
6
|
+
凭据**,非要宿主把 MCP server 重启一次才生效。而「宿主重启」恰恰是我们不能假设用户
|
|
7
|
+
做得到的那一步(OpenClaw 的 config 重载链路本身就可能坏着)。
|
|
8
|
+
|
|
9
|
+
所以路径解析放在这一个模块里,写盘和读盘共用;HTTP 层每次要用凭据时按文件
|
|
10
|
+
mtime+size 判断是否需要重读,文件变了就立刻生效。
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
#: 这两把钥匙在 agent 侧的用途不同:API key 调公共 API,Runtime key 调 /runtime/*。
|
|
18
|
+
CREDENTIAL_KEYS = (
|
|
19
|
+
"KARMA_API_KEY",
|
|
20
|
+
"KARMA_RUNTIME_KEY",
|
|
21
|
+
"KARMA_RUNTIME_URL",
|
|
22
|
+
"KARMA_AGENT_ID",
|
|
23
|
+
"KARMA_AGENT_PRIVATE_KEY",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def agent_env_path() -> Path:
|
|
28
|
+
"""凭据文件位置:``KARMA_AGENT_ENV_PATH`` 覆盖,否则 ``~/.karma/agent.env``。"""
|
|
29
|
+
override = os.environ.get("KARMA_AGENT_ENV_PATH", "").strip()
|
|
30
|
+
if override:
|
|
31
|
+
return Path(override).expanduser()
|
|
32
|
+
return Path.home() / ".karma" / "agent.env"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def read_agent_env() -> dict:
|
|
36
|
+
"""把文件解析成 dict;文件不存在或读不动时返回空 dict(不抛)。"""
|
|
37
|
+
path = agent_env_path()
|
|
38
|
+
try:
|
|
39
|
+
text = path.read_text(encoding="utf-8")
|
|
40
|
+
except OSError:
|
|
41
|
+
return {}
|
|
42
|
+
values: dict = {}
|
|
43
|
+
for line in text.splitlines():
|
|
44
|
+
line = line.strip()
|
|
45
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
46
|
+
continue
|
|
47
|
+
key, _, value = line.partition("=")
|
|
48
|
+
key = key.strip()
|
|
49
|
+
if key:
|
|
50
|
+
values[key] = value.strip()
|
|
51
|
+
return values
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
_cache: dict = {"stamp": None, "values": {}}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _stamp():
|
|
58
|
+
path = agent_env_path()
|
|
59
|
+
try:
|
|
60
|
+
st = path.stat()
|
|
61
|
+
except OSError:
|
|
62
|
+
return (str(path), None, None)
|
|
63
|
+
return (str(path), st.st_mtime_ns, st.st_size)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def load_agent_env(force: bool = False) -> dict:
|
|
67
|
+
"""磁盘上的凭据;文件变了就重读,没变就吃缓存(每次工具调用都会走这里)。"""
|
|
68
|
+
stamp = _stamp()
|
|
69
|
+
if force or _cache["stamp"] != stamp:
|
|
70
|
+
_cache["stamp"] = stamp
|
|
71
|
+
_cache["values"] = read_agent_env()
|
|
72
|
+
return _cache["values"]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def invalidate_agent_env() -> None:
|
|
76
|
+
"""刚写完文件时调用,避免同一进程里还吃旧缓存。"""
|
|
77
|
+
_cache["stamp"] = None
|
|
78
|
+
load_agent_env(force=True)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def upsert_credential(name: str, value: str) -> Path:
|
|
82
|
+
"""把一对键值并进凭据文件(0600),同名行就地替换。
|
|
83
|
+
|
|
84
|
+
agent 自己的 Ed25519 私钥要先落盘再上报:进程崩了、宿主重启了,它还得在。
|
|
85
|
+
"""
|
|
86
|
+
path = agent_env_path()
|
|
87
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
try:
|
|
89
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
90
|
+
except OSError:
|
|
91
|
+
lines = []
|
|
92
|
+
prefix = name + "="
|
|
93
|
+
out: list = []
|
|
94
|
+
replaced = False
|
|
95
|
+
for line in lines:
|
|
96
|
+
if line.strip().startswith(prefix):
|
|
97
|
+
if not replaced:
|
|
98
|
+
out.append(prefix + value)
|
|
99
|
+
replaced = True
|
|
100
|
+
continue
|
|
101
|
+
out.append(line)
|
|
102
|
+
if not replaced:
|
|
103
|
+
out.append(prefix + value)
|
|
104
|
+
text = "\n".join(out).rstrip("\n") + "\n"
|
|
105
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
106
|
+
tmp.write_text(text, encoding="utf-8")
|
|
107
|
+
try:
|
|
108
|
+
os.chmod(tmp, 0o600)
|
|
109
|
+
except OSError:
|
|
110
|
+
pass
|
|
111
|
+
os.replace(tmp, path)
|
|
112
|
+
invalidate_agent_env()
|
|
113
|
+
return path
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def credential(name: str) -> str:
|
|
117
|
+
"""环境变量优先(宿主显式注入的压过文件),否则回落到凭据文件。"""
|
|
118
|
+
value = (os.environ.get(name) or "").strip()
|
|
119
|
+
if value:
|
|
120
|
+
return value
|
|
121
|
+
return str(load_agent_env().get(name) or "").strip()
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""KarmaBilateral MCP tools for OpenClaw — lock/bind/settle via BFF API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
from urllib.parse import quote
|
|
8
|
+
|
|
9
|
+
from karma_openclaw.http_client import api_get, api_post
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def register_bilateral_tools(mcp):
|
|
13
|
+
"""Register KarmaBilateral tools on the MCP server."""
|
|
14
|
+
|
|
15
|
+
@mcp.tool()
|
|
16
|
+
async def karma_bilateral_lock(token: str, amount: str, role: str = "buyer") -> dict[str, Any]:
|
|
17
|
+
"""Lock USDC into KarmaBilateral and mint a Bill Token.
|
|
18
|
+
|
|
19
|
+
Both buyer AND agent must call this before any task begins.
|
|
20
|
+
This replaces the old lockFunds flow.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
token: ERC-20 token address (use USDC address for testnet)
|
|
24
|
+
amount: Amount in base units, e.g. '10000000' for 10 USDC
|
|
25
|
+
role: 'buyer' or 'agent'
|
|
26
|
+
"""
|
|
27
|
+
return await api_post("/v1/bilateral/lock", {
|
|
28
|
+
"token": token,
|
|
29
|
+
"amount": int(amount),
|
|
30
|
+
"role": role,
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
@mcp.tool()
|
|
34
|
+
async def karma_bilateral_bind(buyer_bill_id: int, agent_bill_id: int, scope_description: str) -> dict[str, Any]:
|
|
35
|
+
"""Bilaterally bind buyer + agent Bill Tokens. Both sides freeze.
|
|
36
|
+
|
|
37
|
+
Must be called AFTER both parties have locked (karma_bilateral_lock).
|
|
38
|
+
This enters the BOUND state — bills cannot be withdrawn until settled.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
buyer_bill_id: The bill ID from buyer's lock call
|
|
42
|
+
agent_bill_id: The bill ID from agent's lock call
|
|
43
|
+
scope_description: Human-readable task scope, e.g. 'search:latest-pricing'
|
|
44
|
+
"""
|
|
45
|
+
import hashlib
|
|
46
|
+
scope_hash = "0x" + hashlib.sha256(scope_description.encode()).hexdigest()
|
|
47
|
+
return await api_post("/v1/bilateral/bind", {
|
|
48
|
+
"buyer_bill_id": buyer_bill_id,
|
|
49
|
+
"agent_bill_id": agent_bill_id,
|
|
50
|
+
"scope_hash": scope_hash,
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
@mcp.tool()
|
|
54
|
+
async def karma_bilateral_settle(binding_id: int, proof_description: str) -> dict[str, Any]:
|
|
55
|
+
"""Submit settlement proof for a binding. Enters FINALIZING state.
|
|
56
|
+
|
|
57
|
+
After the settle delay and dispute window pass, call
|
|
58
|
+
karma_bilateral_finalize to complete settlement.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
binding_id: The binding ID from the bind call
|
|
62
|
+
proof_description: What was delivered, e.g. 'ipfs://Qm...evidence'
|
|
63
|
+
"""
|
|
64
|
+
import hashlib
|
|
65
|
+
proof_hash = "0x" + hashlib.sha256(proof_description.encode()).hexdigest()
|
|
66
|
+
return await api_post("/v1/bilateral/settle", {
|
|
67
|
+
"binding_id": binding_id,
|
|
68
|
+
"proof_hash": proof_hash,
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
@mcp.tool()
|
|
72
|
+
async def karma_bilateral_finalize(binding_id: int) -> dict[str, Any]:
|
|
73
|
+
"""Finalize settlement after dispute window closes. Burns bills, releases USDC.
|
|
74
|
+
|
|
75
|
+
Can only be called after the dispute window has passed since settle().
|
|
76
|
+
"""
|
|
77
|
+
return await api_post(f"/v1/bilateral/finalize/{binding_id}", {})
|
|
78
|
+
|
|
79
|
+
@mcp.tool()
|
|
80
|
+
async def karma_bilateral_status(binding_id: int) -> dict[str, Any]:
|
|
81
|
+
"""Query the full status of a binding — state, bill states, USDC locked.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
binding_id, state, bill_states (list), can_settle, can_dispute,
|
|
85
|
+
can_finalize, usdc_locked, settle_after
|
|
86
|
+
"""
|
|
87
|
+
return await api_get(f"/v1/bilateral/status/{binding_id}")
|
|
88
|
+
|
|
89
|
+
@mcp.tool()
|
|
90
|
+
async def karma_bilateral_dispute(binding_id: int, reason: str) -> dict[str, Any]:
|
|
91
|
+
"""Raise a dispute on a binding in FINALIZING state.
|
|
92
|
+
|
|
93
|
+
Only the buyer can dispute. Must be within the dispute window.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
binding_id: The binding to dispute
|
|
97
|
+
reason: Human-readable reason for dispute
|
|
98
|
+
"""
|
|
99
|
+
import hashlib
|
|
100
|
+
evidence_hash = "0x" + hashlib.sha256(reason.encode()).hexdigest()
|
|
101
|
+
# Dispute via direct contract call through BFF
|
|
102
|
+
return await api_post(f"/v1/bilateral/dispute/{binding_id}", {
|
|
103
|
+
"evidence_hash": evidence_hash,
|
|
104
|
+
})
|