karma-openclaw 0.4.0__py3-none-any.whl

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.
@@ -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,6 @@
1
+ """Allow ``python -m karma_openclaw``."""
2
+
3
+ from karma_openclaw.server import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -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
+ })
@@ -0,0 +1,152 @@
1
+ """Handoff and policy gates before OpenClaw mutates Karma state."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from karma_openclaw.handoff import validate_handoff_v1
11
+
12
+
13
+ def _env_flag(name: str) -> bool:
14
+ return os.environ.get(name, "").strip().lower() in ("1", "true", "yes")
15
+
16
+
17
+ def buyer_confirm_allowed() -> bool:
18
+ return _env_flag("KARMA_OPENCLAW_ALLOW_BUYER_CONFIRM")
19
+
20
+
21
+ def buyer_accept_allowed() -> bool:
22
+ return _env_flag("KARMA_OPENCLAW_ALLOW_BUYER_ACCEPT")
23
+
24
+
25
+ def setup_mutations_allowed() -> bool:
26
+ """Contract / settlement create — default off (Console)."""
27
+ return _env_flag("KARMA_OPENCLAW_ALLOW_SETUP_MUTATIONS")
28
+
29
+
30
+ def load_handoff_payload(handoff_json: str | None) -> dict[str, Any]:
31
+ raw = (handoff_json or "").strip()
32
+ if not raw:
33
+ path = os.environ.get("KARMA_OPENCLAW_HANDOFF_PATH", "").strip()
34
+ if path:
35
+ raw = Path(path).read_text(encoding="utf-8").strip()
36
+ if not raw:
37
+ raise ValueError(
38
+ "handoff JSON required: pass handoff_json or set KARMA_OPENCLAW_HANDOFF_PATH to a handoff v1 file"
39
+ )
40
+ payload = json.loads(raw)
41
+ if not isinstance(payload, dict):
42
+ raise ValueError("handoff must be a JSON object")
43
+ return payload
44
+
45
+
46
+ def require_valid_handoff(handoff_json: str | None) -> tuple[dict[str, Any] | None, dict[str, Any]]:
47
+ """
48
+ Returns (error_dict, normalized_handoff).
49
+
50
+ error_dict is None when handoff validates locally.
51
+ """
52
+ try:
53
+ payload = load_handoff_payload(handoff_json)
54
+ except (ValueError, json.JSONDecodeError) as exc:
55
+ return (
56
+ {
57
+ "ok": False,
58
+ "error": "handoff_required",
59
+ "detail": str(exc),
60
+ "hint": "Complete Console authorization first; see karma_manual_auth_checklist",
61
+ },
62
+ {},
63
+ )
64
+
65
+ ok, errors, normalized = validate_handoff_v1(payload)
66
+ if not ok:
67
+ return (
68
+ {
69
+ "ok": False,
70
+ "error": "handoff_invalid",
71
+ "errors": errors,
72
+ "hint": "Fix handoff.json manual_console_steps_completed after Console steps",
73
+ },
74
+ normalized,
75
+ )
76
+ return None, normalized
77
+
78
+
79
+ def block_response(reason: str, *, hint: str = "") -> dict[str, Any]:
80
+ out: dict[str, Any] = {"ok": False, "error": reason}
81
+ if hint:
82
+ out["hint"] = hint
83
+ return out
84
+
85
+
86
+ def server_attestation_required() -> bool:
87
+ return _env_flag("KARMA_OPENCLAW_REQUIRE_SERVER_ATTESTATION")
88
+
89
+
90
+ async def require_valid_handoff_for_automation(
91
+ handoff_json: str | None,
92
+ *,
93
+ karma_identity_id: str | None = None,
94
+ ) -> tuple[dict[str, Any] | None, dict[str, Any]]:
95
+ """
96
+ Local handoff validation plus optional server attestation (POST handoff-confirm).
97
+ """
98
+ err, normalized = require_valid_handoff(handoff_json)
99
+ if err:
100
+ return err, normalized
101
+
102
+ if not server_attestation_required():
103
+ return None, normalized
104
+
105
+ task_id = str(normalized.get("task_id") or "").strip()
106
+ kid = (
107
+ (karma_identity_id or "").strip()
108
+ or os.environ.get("KARMA_ID", "").strip()
109
+ or str(normalized.get("buyer_identity_id") or "").strip()
110
+ )
111
+ if not task_id or not kid:
112
+ return (
113
+ {
114
+ "ok": False,
115
+ "error": "attestation_context_missing",
116
+ "detail": "task_id and karma_identity_id required for server attestation check",
117
+ "hint": "Set KARMA_ID to this OpenClaw party identity",
118
+ },
119
+ normalized,
120
+ )
121
+
122
+ from urllib.parse import quote
123
+
124
+ from karma_openclaw.http_client import api_get
125
+
126
+ try:
127
+ path = (
128
+ f"/v1/openclaw/handoff-attestation?task_id={quote(task_id, safe='')}"
129
+ f"&karma_identity_id={quote(kid, safe='')}"
130
+ )
131
+ att = await api_get(path)
132
+ except Exception as exc: # noqa: BLE001
133
+ return (
134
+ {
135
+ "ok": False,
136
+ "error": "attestation_check_failed",
137
+ "detail": str(exc),
138
+ "hint": "Call POST /v1/openclaw/handoff-confirm in Console first",
139
+ },
140
+ normalized,
141
+ )
142
+
143
+ if not (isinstance(att, dict) and att.get("attested")):
144
+ return (
145
+ {
146
+ "ok": False,
147
+ "error": "handoff_not_attested",
148
+ "hint": "Complete Console: automation-readiness → handoff-confirm → export handoff",
149
+ },
150
+ normalized,
151
+ )
152
+ return None, normalized
@@ -0,0 +1,102 @@
1
+ """OpenClaw ↔ OpenClaw task handoff (v1) — coordination without automating authorization."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ HANDOFF_VERSION = "1"
8
+
9
+ # Steps that MUST be completed by a human in Karma Console (or wallet UI), not by Claw MCP.
10
+ MANUAL_CONSOLE_STEPS_BUYER = frozenset(
11
+ {
12
+ "buyer_create_voucher",
13
+ "buyer_mint_runtime_key",
14
+ "buyer_lock_capacity",
15
+ }
16
+ )
17
+ MANUAL_CONSOLE_STEPS_SELLER = frozenset(
18
+ {
19
+ "seller_accept_voucher",
20
+ "seller_mint_runtime_key",
21
+ "seller_lock_capacity",
22
+ }
23
+ )
24
+ MANUAL_CONSOLE_STEPS_SHARED = frozenset(
25
+ {
26
+ "settlement_created",
27
+ "settlement_worker_locked",
28
+ }
29
+ )
30
+
31
+ ALL_KNOWN_MANUAL_STEPS = MANUAL_CONSOLE_STEPS_BUYER | MANUAL_CONSOLE_STEPS_SELLER | MANUAL_CONSOLE_STEPS_SHARED
32
+
33
+
34
+ def validate_handoff_v1(payload: dict[str, Any]) -> tuple[bool, list[str], dict[str, Any]]:
35
+ """
36
+ Validate handoff JSON shape and return (ok, error_messages, normalized_view).
37
+
38
+ Does not call Karma HTTP — use together with ``karma_validate_handoff`` MCP tool for live checks.
39
+ """
40
+ errors: list[str] = []
41
+ if payload.get("handoff_version") != HANDOFF_VERSION:
42
+ errors.append(f"handoff_version must be {HANDOFF_VERSION!r}")
43
+
44
+ for field in ("trace_id", "task_id", "buyer_identity_id", "seller_identity_id"):
45
+ val = payload.get(field)
46
+ if not isinstance(val, str) or not val.strip():
47
+ errors.append(f"{field} is required (non-empty string)")
48
+
49
+ voucher_id = payload.get("voucher_id")
50
+ if voucher_id is not None and (not isinstance(voucher_id, str) or not voucher_id.strip()):
51
+ errors.append("voucher_id must be a non-empty string when present")
52
+
53
+ auth = payload.get("authorization")
54
+ if not isinstance(auth, dict):
55
+ errors.append("authorization object is required")
56
+ auth = {}
57
+
58
+ completed = auth.get("manual_console_steps_completed")
59
+ if not isinstance(completed, list):
60
+ errors.append("authorization.manual_console_steps_completed must be a list of step ids")
61
+ completed = []
62
+
63
+ unknown = [s for s in completed if s not in ALL_KNOWN_MANUAL_STEPS]
64
+ if unknown:
65
+ errors.append(f"unknown manual_console_steps_completed entries: {unknown}")
66
+
67
+ # Minimum bar before Claw may run verify / delivery automation.
68
+ required_before_automation = {"buyer_create_voucher", "seller_accept_voucher", "settlement_created"}
69
+ missing = sorted(required_before_automation - set(completed))
70
+ if missing:
71
+ errors.append(
72
+ "authorization incomplete for automated verify/delivery — complete in Console first, "
73
+ f"then add to manual_console_steps_completed: {missing}"
74
+ )
75
+
76
+ voucher_status = auth.get("voucher_status")
77
+ if voucher_status is not None and voucher_status not in (
78
+ "created",
79
+ "accepted",
80
+ "used",
81
+ "cancelled",
82
+ "expired",
83
+ ):
84
+ errors.append(f"authorization.voucher_status invalid: {voucher_status!r}")
85
+
86
+ if voucher_status and voucher_status != "accepted" and "seller_accept_voucher" in completed:
87
+ errors.append("voucher_status must be 'accepted' when seller_accept_voucher is marked complete")
88
+
89
+ normalized = {
90
+ "handoff_version": HANDOFF_VERSION,
91
+ "trace_id": str(payload.get("trace_id", "")).strip(),
92
+ "task_id": str(payload.get("task_id", "")).strip(),
93
+ "voucher_id": str(voucher_id).strip() if voucher_id else None,
94
+ "buyer_identity_id": str(payload.get("buyer_identity_id", "")).strip(),
95
+ "seller_identity_id": str(payload.get("seller_identity_id", "")).strip(),
96
+ "bill_credit_amount": payload.get("bill_credit_amount"),
97
+ "authorization": {
98
+ "voucher_status": voucher_status,
99
+ "manual_console_steps_completed": list(completed),
100
+ },
101
+ }
102
+ return (len(errors) == 0, errors, normalized)