2037 lines
95 KiB
Python
2037 lines
95 KiB
Python
#!/usr/bin/env python3
|
||
"""铸澜手机远程开发审批与能力内核。仅使用 Python 标准库。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import base64
|
||
import hashlib
|
||
import hmac
|
||
import html
|
||
import json
|
||
import os
|
||
import re
|
||
import secrets
|
||
import socket
|
||
import sqlite3
|
||
import ssl
|
||
import subprocess
|
||
import sys
|
||
import tempfile
|
||
import threading
|
||
import time
|
||
import urllib.error
|
||
import urllib.request
|
||
from dataclasses import dataclass
|
||
from http import HTTPStatus
|
||
from http.cookies import SimpleCookie
|
||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||
from pathlib import Path
|
||
from typing import Any
|
||
from urllib.parse import parse_qs, urlencode, urlparse
|
||
|
||
|
||
ROOT = Path(__file__).resolve().parents[1]
|
||
DEV_ID_RE = re.compile(r"^DEV-[0-9]{8}-[0-9]{3}$")
|
||
SHA_RE = re.compile(r"^[0-9a-f]{40}$")
|
||
SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}$")
|
||
REQUEST_ID_RE = re.compile(r"^ZLR-[0-9]{8}-[A-Z0-9]{10}$")
|
||
SAFE_REPO_RE = re.compile(r"^[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+$")
|
||
SECRET_PATTERNS = [
|
||
re.compile(rb"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----"),
|
||
re.compile(rb"(?i)(?:api[_-]?key|secret|password|token)\s*[:=]\s*['\"][^'\"\s]{12,}"),
|
||
re.compile(rb"AKID[A-Z0-9]{13,}"),
|
||
]
|
||
|
||
|
||
class OAuthRequiredError(PermissionError):
|
||
"""The MCP transport needs to start or renew OAuth."""
|
||
|
||
|
||
def utc_now() -> int:
|
||
return int(time.time())
|
||
|
||
|
||
def iso(ts: int | None = None) -> str:
|
||
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(ts or utc_now()))
|
||
|
||
|
||
def compact_json(value: Any) -> str:
|
||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||
|
||
|
||
def sha256_text(value: str) -> str:
|
||
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
||
|
||
|
||
def b64url(raw: bytes) -> str:
|
||
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
||
|
||
|
||
def b64url_decode(value: str) -> bytes:
|
||
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
|
||
|
||
|
||
def safe_path(value: Any) -> str:
|
||
if not isinstance(value, str):
|
||
raise ValueError("path_type_invalid")
|
||
text = value.strip().replace("\\", "/")
|
||
if text in ("", "."):
|
||
return "."
|
||
if text.startswith("/") or "\x00" in text:
|
||
raise ValueError("path_absolute_or_null")
|
||
parts = [part for part in text.split("/") if part not in ("", ".")]
|
||
if not parts or any(part == ".." for part in parts):
|
||
raise ValueError("path_traversal")
|
||
return "/".join(parts)
|
||
|
||
|
||
def path_within(path: str, prefix: str) -> bool:
|
||
return prefix == "." or path == prefix or path.startswith(prefix + "/")
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Settings:
|
||
bind: str
|
||
port: int
|
||
db_path: Path
|
||
secret_file: Path
|
||
policy_file: Path
|
||
ui_dir: Path
|
||
workspace_root: Path
|
||
candidate_root: Path
|
||
forgejo_verify_url: str
|
||
owner_login: str
|
||
cookie_secure: bool
|
||
cookie_path: str
|
||
test_mode: bool
|
||
test_password: str
|
||
validation_socket: Path
|
||
|
||
@classmethod
|
||
def load(cls) -> "Settings":
|
||
return cls(
|
||
bind=os.getenv("ZHULAN_BIND", "127.0.0.1"),
|
||
port=int(os.getenv("ZHULAN_PORT", "17631")),
|
||
db_path=Path(os.getenv("ZHULAN_DB", "/var/lib/guanghu/zhulan-remote-cell/state.sqlite3")),
|
||
secret_file=Path(os.getenv("ZHULAN_SECRET_FILE", "/etc/guanghu/secrets/zhulan-remote-cell.secret")),
|
||
policy_file=Path(os.getenv("ZHULAN_POLICY", str(ROOT / "policy.example.json"))),
|
||
ui_dir=Path(os.getenv("ZHULAN_UI_DIR", str(ROOT / "ui"))),
|
||
workspace_root=Path(
|
||
os.getenv("ZHULAN_WORKSPACE_ROOT", "/srv/guanghu/zhulan-cell/workspaces")
|
||
),
|
||
candidate_root=Path(
|
||
os.getenv("ZHULAN_CANDIDATE_ROOT", "/srv/guanghu/zhulan-cell/candidates")
|
||
),
|
||
forgejo_verify_url=os.getenv(
|
||
"ZHULAN_FORGEJO_VERIFY_URL", "https://guanghulab.com/code/api/v1/user"
|
||
),
|
||
owner_login=os.getenv("ZHULAN_OWNER_LOGIN", "bingshuo"),
|
||
cookie_secure=os.getenv("ZHULAN_COOKIE_SECURE", "1") != "0",
|
||
cookie_path=os.getenv("ZHULAN_COOKIE_PATH", "/zhulan/"),
|
||
test_mode=os.getenv("ZHULAN_TEST_MODE", "0") == "1",
|
||
test_password=os.getenv("ZHULAN_TEST_OWNER_PASSWORD", ""),
|
||
validation_socket=Path(
|
||
os.getenv(
|
||
"ZHULAN_VALIDATION_SOCKET",
|
||
"/run/guanghu/zhulan-validation/executor.sock",
|
||
)
|
||
),
|
||
)
|
||
|
||
|
||
class Store:
|
||
def __init__(self, path: Path):
|
||
self.path = path
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
self._init()
|
||
|
||
def connect(self) -> sqlite3.Connection:
|
||
db = sqlite3.connect(self.path, timeout=10)
|
||
db.row_factory = sqlite3.Row
|
||
db.execute("PRAGMA foreign_keys=ON")
|
||
db.execute("PRAGMA journal_mode=WAL")
|
||
return db
|
||
|
||
def _init(self) -> None:
|
||
with self.connect() as db:
|
||
db.executescript(
|
||
"""
|
||
CREATE TABLE IF NOT EXISTS requests (
|
||
id TEXT PRIMARY KEY,
|
||
claim_hash TEXT NOT NULL,
|
||
persona_id TEXT NOT NULL,
|
||
development_id TEXT NOT NULL,
|
||
repository TEXT NOT NULL,
|
||
base_sha TEXT NOT NULL,
|
||
branch TEXT NOT NULL,
|
||
paths_json TEXT NOT NULL,
|
||
actions_json TEXT NOT NULL,
|
||
description TEXT NOT NULL,
|
||
state TEXT NOT NULL,
|
||
created_at INTEGER NOT NULL,
|
||
expires_at INTEGER NOT NULL,
|
||
decided_at INTEGER,
|
||
owner_login TEXT,
|
||
capability_nonce TEXT,
|
||
capability_hash TEXT,
|
||
capability_expires_at INTEGER,
|
||
picked_up_at INTEGER,
|
||
rejection_reason TEXT
|
||
);
|
||
CREATE INDEX IF NOT EXISTS requests_state_created
|
||
ON requests(state, created_at DESC);
|
||
CREATE TABLE IF NOT EXISTS owner_sessions (
|
||
session_hash TEXT PRIMARY KEY,
|
||
owner_login TEXT NOT NULL,
|
||
csrf_hash TEXT NOT NULL,
|
||
created_at INTEGER NOT NULL,
|
||
expires_at INTEGER NOT NULL
|
||
);
|
||
CREATE TABLE IF NOT EXISTS receipts (
|
||
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
receipt_id TEXT UNIQUE NOT NULL,
|
||
request_id TEXT,
|
||
event TEXT NOT NULL,
|
||
result TEXT NOT NULL,
|
||
evidence_json TEXT NOT NULL,
|
||
created_at INTEGER NOT NULL,
|
||
previous_hash TEXT NOT NULL,
|
||
receipt_hash TEXT UNIQUE NOT NULL
|
||
);
|
||
CREATE TABLE IF NOT EXISTS oauth_clients (
|
||
client_id TEXT PRIMARY KEY,
|
||
client_name TEXT NOT NULL,
|
||
redirect_uris_json TEXT NOT NULL,
|
||
created_at INTEGER NOT NULL
|
||
);
|
||
CREATE TABLE IF NOT EXISTS oauth_codes (
|
||
code_hash TEXT PRIMARY KEY,
|
||
client_id TEXT NOT NULL,
|
||
redirect_uri TEXT NOT NULL,
|
||
code_challenge TEXT NOT NULL,
|
||
resource TEXT NOT NULL,
|
||
request_id TEXT NOT NULL,
|
||
scopes_json TEXT NOT NULL,
|
||
created_at INTEGER NOT NULL,
|
||
expires_at INTEGER NOT NULL,
|
||
used_at INTEGER,
|
||
FOREIGN KEY(request_id) REFERENCES requests(id)
|
||
);
|
||
CREATE TABLE IF NOT EXISTS oauth_tokens (
|
||
token_hash TEXT PRIMARY KEY,
|
||
request_id TEXT NOT NULL,
|
||
client_id TEXT NOT NULL,
|
||
scopes_json TEXT NOT NULL,
|
||
created_at INTEGER NOT NULL,
|
||
expires_at INTEGER NOT NULL,
|
||
revoked_at INTEGER,
|
||
FOREIGN KEY(request_id) REFERENCES requests(id)
|
||
);
|
||
"""
|
||
)
|
||
|
||
def append_receipt(
|
||
self, db: sqlite3.Connection, request_id: str | None, event: str, result: str, evidence: dict[str, Any]
|
||
) -> dict[str, Any]:
|
||
# A receipt query followed by an insert must be one serialized operation;
|
||
# otherwise two concurrent read-only callers could fork the evidence chain.
|
||
if not db.in_transaction:
|
||
db.execute("BEGIN IMMEDIATE")
|
||
previous = db.execute("SELECT receipt_hash FROM receipts ORDER BY sequence DESC LIMIT 1").fetchone()
|
||
previous_hash = previous[0] if previous else "0" * 64
|
||
created = utc_now()
|
||
rid = f"ZLC-{time.strftime('%Y%m%d', time.gmtime(created))}-{secrets.token_hex(5).upper()}"
|
||
body = {
|
||
"receipt_id": rid,
|
||
"request_id": request_id,
|
||
"event": event,
|
||
"result": result,
|
||
"evidence": evidence,
|
||
"created_at": iso(created),
|
||
"previous_hash": previous_hash,
|
||
}
|
||
receipt_hash = sha256_text(compact_json(body))
|
||
db.execute(
|
||
"""INSERT INTO receipts
|
||
(receipt_id, request_id, event, result, evidence_json, created_at, previous_hash, receipt_hash)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||
(rid, request_id, event, result, compact_json(evidence), created, previous_hash, receipt_hash),
|
||
)
|
||
body["receipt_hash"] = receipt_hash
|
||
return body
|
||
|
||
|
||
class ZhulanApp:
|
||
def __init__(self, settings: Settings):
|
||
self.settings = settings
|
||
self.policy = json.loads(settings.policy_file.read_text(encoding="utf-8"))
|
||
self._validate_policy()
|
||
self.secret = self._load_secret()
|
||
self.store = Store(settings.db_path)
|
||
settings.workspace_root.mkdir(parents=True, exist_ok=True)
|
||
settings.candidate_root.mkdir(parents=True, exist_ok=True)
|
||
self.rate: dict[str, list[int]] = {}
|
||
self.rate_lock = threading.Lock()
|
||
self.operation_lock = threading.RLock()
|
||
|
||
def _validate_policy(self) -> None:
|
||
required = {"persona_id", "node_id", "allowed_actions", "repositories"}
|
||
if not required.issubset(self.policy):
|
||
raise RuntimeError("policy_missing_required_fields")
|
||
if self.policy.get("node_id") != "BS-SG-003":
|
||
raise RuntimeError("runtime_node_must_be_bs_sg_003")
|
||
|
||
def _load_secret(self) -> bytes:
|
||
if self.settings.secret_file.exists():
|
||
raw = self.settings.secret_file.read_bytes().strip()
|
||
if len(raw) < 32:
|
||
raise RuntimeError("application_secret_too_short")
|
||
return raw
|
||
if self.settings.test_mode:
|
||
return hashlib.sha256(b"zhulan-test-secret-only").digest()
|
||
raise RuntimeError(f"application_secret_missing:{self.settings.secret_file}")
|
||
|
||
def public_config(self) -> dict[str, Any]:
|
||
return {
|
||
"schema": "guanghu.zhulan-public-config/v1",
|
||
"persona_id": self.policy["persona_id"],
|
||
"front_door": self.policy.get("front_door"),
|
||
"runtime_node": self.policy["node_id"],
|
||
"topology": {
|
||
"front": "BS-GZ-006 · 仅域名 / TLS / 反向代理",
|
||
"runtime": "BS-SG-003 · 真实 UI / 审批 / 能力 / 沙箱 / 门禁 / 回执",
|
||
},
|
||
"default_ttl_seconds": int(self.policy.get("default_ttl_seconds", 10800)),
|
||
"repositories": sorted(self.policy["repositories"]),
|
||
"allowed_actions": list(self.policy["allowed_actions"]),
|
||
"ui_brains": [
|
||
{"id": "GHS-012", "state": "CANDIDATE_READ_ONLY"},
|
||
{"id": "GHS-014", "state": "CANDIDATE_READ_ONLY"},
|
||
],
|
||
}
|
||
|
||
def rate_ok(self, bucket: str, source: str, limit: int, window_seconds: int) -> bool:
|
||
now = utc_now()
|
||
key = f"{bucket}:{sha256_text(source)}"
|
||
with self.rate_lock:
|
||
points = [x for x in self.rate.get(key, []) if x > now - window_seconds]
|
||
if len(points) >= limit:
|
||
self.rate[key] = points
|
||
return False
|
||
points.append(now)
|
||
self.rate[key] = points
|
||
return True
|
||
|
||
def validate_request(self, data: dict[str, Any]) -> dict[str, Any]:
|
||
persona = str(data.get("persona_id", ""))
|
||
if persona != self.policy["persona_id"]:
|
||
raise ValueError("persona_not_allowed")
|
||
dev_id = str(data.get("development_id", ""))
|
||
if not DEV_ID_RE.fullmatch(dev_id):
|
||
raise ValueError("development_id_invalid")
|
||
repository = str(data.get("repository", ""))
|
||
if not SAFE_REPO_RE.fullmatch(repository) or repository not in self.policy["repositories"]:
|
||
raise ValueError("repository_not_registered")
|
||
base_sha = str(data.get("base_sha", "")).lower()
|
||
if not SHA_RE.fullmatch(base_sha):
|
||
raise ValueError("base_sha_invalid")
|
||
branch = str(data.get("branch", ""))
|
||
expected = f"zhulan/{dev_id}/"
|
||
if (
|
||
not branch.startswith(expected)
|
||
or not SLUG_RE.fullmatch(branch[len(expected) :])
|
||
or ".." in branch
|
||
or "@{" in branch
|
||
or branch.endswith((".", ".lock"))
|
||
):
|
||
raise ValueError("candidate_branch_invalid")
|
||
raw_paths = data.get("paths")
|
||
if not isinstance(raw_paths, list) or not raw_paths:
|
||
raise ValueError("paths_required")
|
||
max_paths = int(self.policy.get("max_request_paths", 24))
|
||
if len(raw_paths) > max_paths:
|
||
raise ValueError("too_many_paths")
|
||
paths = sorted(set(safe_path(p) for p in raw_paths))
|
||
repo_policy = self.policy["repositories"][repository]
|
||
allowed_prefixes = [safe_path(p) for p in repo_policy.get("allowed_path_prefixes", ["."])]
|
||
if any(not any(path_within(path, prefix) for prefix in allowed_prefixes) for path in paths):
|
||
raise ValueError("path_outside_repository_policy")
|
||
raw_actions = data.get("actions")
|
||
if not isinstance(raw_actions, list) or not raw_actions:
|
||
raise ValueError("actions_required")
|
||
actions = sorted(set(str(x) for x in raw_actions))
|
||
allowed_actions = set(self.policy["allowed_actions"])
|
||
if any(action not in allowed_actions for action in actions):
|
||
raise ValueError("action_not_allowed")
|
||
description = str(data.get("description", "")).strip()
|
||
if not (4 <= len(description) <= 500):
|
||
raise ValueError("description_invalid")
|
||
return {
|
||
"persona_id": persona,
|
||
"development_id": dev_id,
|
||
"repository": repository,
|
||
"base_sha": base_sha,
|
||
"branch": branch,
|
||
"paths": paths,
|
||
"actions": actions,
|
||
"description": description,
|
||
}
|
||
|
||
def create_request(self, data: dict[str, Any], source: str) -> dict[str, Any]:
|
||
if not self.rate_ok(
|
||
"request-create", source, int(os.getenv("ZHULAN_PUBLIC_CREATE_LIMIT", "12")), 3600
|
||
):
|
||
raise PermissionError("public_create_rate_limited")
|
||
clean = self.validate_request(data)
|
||
now = utc_now()
|
||
request_id = f"ZLR-{time.strftime('%Y%m%d', time.gmtime(now))}-{secrets.token_hex(5).upper()}"
|
||
claim = secrets.token_urlsafe(32)
|
||
expires = now + min(int(self.policy.get("max_ttl_seconds", 86400)), 86400)
|
||
with self.store.connect() as db:
|
||
db.execute(
|
||
"""INSERT INTO requests
|
||
(id, claim_hash, persona_id, development_id, repository, base_sha, branch,
|
||
paths_json, actions_json, description, state, created_at, expires_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'PENDING', ?, ?)""",
|
||
(
|
||
request_id,
|
||
sha256_text(claim),
|
||
clean["persona_id"],
|
||
clean["development_id"],
|
||
clean["repository"],
|
||
clean["base_sha"],
|
||
clean["branch"],
|
||
compact_json(clean["paths"]),
|
||
compact_json(clean["actions"]),
|
||
clean["description"],
|
||
now,
|
||
expires,
|
||
),
|
||
)
|
||
receipt = self.store.append_receipt(
|
||
db,
|
||
request_id,
|
||
"request_created",
|
||
"PENDING",
|
||
{
|
||
"persona_id": clean["persona_id"],
|
||
"development_id": clean["development_id"],
|
||
"repository": clean["repository"],
|
||
"branch": clean["branch"],
|
||
"source_hash": sha256_text(source),
|
||
},
|
||
)
|
||
return {
|
||
"schema": "guanghu.zhulan-request-created/v1",
|
||
"request_id": request_id,
|
||
"claim_token": claim,
|
||
"state": "PENDING",
|
||
"request_url": f"{self.policy.get('front_door', '/zhulan/')}?request={request_id}",
|
||
"expires_at": iso(expires),
|
||
"receipt": receipt,
|
||
"warning": "claim_token 仅返回一次,不得写入仓库、日志或长期记忆。",
|
||
}
|
||
|
||
@staticmethod
|
||
def row_to_public(row: sqlite3.Row) -> dict[str, Any]:
|
||
return {
|
||
"id": row["id"],
|
||
"persona_id": row["persona_id"],
|
||
"development_id": row["development_id"],
|
||
"repository": row["repository"],
|
||
"base_sha": row["base_sha"],
|
||
"branch": row["branch"],
|
||
"paths": json.loads(row["paths_json"]),
|
||
"actions": json.loads(row["actions_json"]),
|
||
"description": row["description"],
|
||
"state": row["state"],
|
||
"created_at": iso(row["created_at"]),
|
||
"expires_at": iso(row["expires_at"]),
|
||
"decided_at": iso(row["decided_at"]) if row["decided_at"] else None,
|
||
"capability_expires_at": iso(row["capability_expires_at"])
|
||
if row["capability_expires_at"]
|
||
else None,
|
||
"picked_up": bool(row["picked_up_at"]),
|
||
"rejection_reason": row["rejection_reason"],
|
||
}
|
||
|
||
def claimed_request(self, request_id: str, claim: str) -> sqlite3.Row:
|
||
if not REQUEST_ID_RE.fullmatch(request_id) or not claim:
|
||
raise PermissionError("claim_invalid")
|
||
with self.store.connect() as db:
|
||
row = db.execute("SELECT * FROM requests WHERE id=?", (request_id,)).fetchone()
|
||
if not row or not hmac.compare_digest(row["claim_hash"], sha256_text(claim)):
|
||
raise PermissionError("claim_invalid")
|
||
return row
|
||
|
||
def request_status(self, request_id: str, claim: str) -> dict[str, Any]:
|
||
return self.row_to_public(self.claimed_request(request_id, claim))
|
||
|
||
def verify_owner_credentials(self, username: str, password: str) -> dict[str, Any]:
|
||
if not username or not password or username != self.settings.owner_login:
|
||
raise PermissionError("owner_login_invalid")
|
||
if self.settings.test_mode:
|
||
if not self.settings.test_password or not hmac.compare_digest(password, self.settings.test_password):
|
||
raise PermissionError("owner_login_invalid")
|
||
return {"login": username, "id": 1}
|
||
auth = base64.b64encode(f"{username}:{password}".encode("utf-8")).decode("ascii")
|
||
request = urllib.request.Request(
|
||
self.settings.forgejo_verify_url,
|
||
headers={"Authorization": f"Basic {auth}", "User-Agent": "guanghu-zhulan-cell/1"},
|
||
)
|
||
try:
|
||
context = ssl.create_default_context()
|
||
with urllib.request.urlopen(request, timeout=8, context=context) as response:
|
||
user = json.loads(response.read(65536).decode("utf-8"))
|
||
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, ValueError) as exc:
|
||
raise PermissionError("owner_login_invalid") from exc
|
||
if user.get("login") != self.settings.owner_login:
|
||
raise PermissionError("owner_login_invalid")
|
||
return user
|
||
|
||
def owner_login(
|
||
self, username: str, password: str, source: str
|
||
) -> tuple[str, str, dict[str, Any]]:
|
||
if not self.rate_ok(
|
||
"owner-login", source, int(os.getenv("ZHULAN_OWNER_LOGIN_LIMIT", "8")), 900
|
||
):
|
||
raise PermissionError("owner_login_rate_limited")
|
||
user = self.verify_owner_credentials(username, password)
|
||
token = secrets.token_urlsafe(32)
|
||
csrf = secrets.token_urlsafe(24)
|
||
now = utc_now()
|
||
expires = now + 10800
|
||
with self.store.connect() as db:
|
||
db.execute("DELETE FROM owner_sessions WHERE expires_at < ?", (now,))
|
||
db.execute(
|
||
"INSERT INTO owner_sessions VALUES (?, ?, ?, ?, ?)",
|
||
(sha256_text(token), user["login"], sha256_text(csrf), now, expires),
|
||
)
|
||
self.store.append_receipt(
|
||
db,
|
||
None,
|
||
"owner_login",
|
||
"PASS",
|
||
{"owner_login": user["login"], "forgejo_user_id": user.get("id")},
|
||
)
|
||
return token, csrf, {"owner_login": user["login"], "expires_at": iso(expires)}
|
||
|
||
def owner_session(self, token: str | None, rotate_csrf: bool = False) -> tuple[sqlite3.Row, str | None]:
|
||
if not token:
|
||
raise PermissionError("owner_session_required")
|
||
now = utc_now()
|
||
with self.store.connect() as db:
|
||
row = db.execute(
|
||
"SELECT * FROM owner_sessions WHERE session_hash=? AND expires_at>?",
|
||
(sha256_text(token), now),
|
||
).fetchone()
|
||
if not row:
|
||
raise PermissionError("owner_session_required")
|
||
csrf = None
|
||
if rotate_csrf:
|
||
csrf = secrets.token_urlsafe(24)
|
||
db.execute(
|
||
"UPDATE owner_sessions SET csrf_hash=? WHERE session_hash=?",
|
||
(sha256_text(csrf), sha256_text(token)),
|
||
)
|
||
return row, csrf
|
||
|
||
def require_csrf(self, session: sqlite3.Row, csrf: str | None) -> None:
|
||
if not csrf or not hmac.compare_digest(session["csrf_hash"], sha256_text(csrf)):
|
||
raise PermissionError("csrf_invalid")
|
||
|
||
def owner_requests(self) -> list[dict[str, Any]]:
|
||
with self.store.connect() as db:
|
||
rows = db.execute("SELECT * FROM requests ORDER BY created_at DESC LIMIT 100").fetchall()
|
||
return [self.row_to_public(row) for row in rows]
|
||
|
||
def make_capability(self, row: sqlite3.Row) -> str:
|
||
payload = {
|
||
"schema": "guanghu.zhulan-capability/v1",
|
||
"request_id": row["id"],
|
||
"persona_id": row["persona_id"],
|
||
"development_id": row["development_id"],
|
||
"node_id": self.policy["node_id"],
|
||
"repository": row["repository"],
|
||
"base_sha": row["base_sha"],
|
||
"branch": row["branch"],
|
||
"paths": json.loads(row["paths_json"]),
|
||
"actions": json.loads(row["actions_json"]),
|
||
"iat": row["decided_at"],
|
||
"exp": row["capability_expires_at"],
|
||
"nonce": row["capability_nonce"],
|
||
}
|
||
encoded = b64url(compact_json(payload).encode("utf-8"))
|
||
signature = b64url(hmac.new(self.secret, f"v1.{encoded}".encode("ascii"), hashlib.sha256).digest())
|
||
return f"v1.{encoded}.{signature}"
|
||
|
||
def decide(
|
||
self,
|
||
request_id: str,
|
||
owner: str,
|
||
approve: bool,
|
||
ttl_seconds: int | None = None,
|
||
reason: str = "",
|
||
) -> dict[str, Any]:
|
||
if not REQUEST_ID_RE.fullmatch(request_id):
|
||
raise ValueError("request_id_invalid")
|
||
now = utc_now()
|
||
with self.store.connect() as db:
|
||
row = db.execute("SELECT * FROM requests WHERE id=?", (request_id,)).fetchone()
|
||
if not row:
|
||
raise LookupError("request_not_found")
|
||
if row["state"] != "PENDING":
|
||
raise RuntimeError("request_already_decided")
|
||
if row["expires_at"] <= now:
|
||
db.execute("UPDATE requests SET state='EXPIRED', decided_at=? WHERE id=?", (now, request_id))
|
||
self.store.append_receipt(db, request_id, "request_expired", "EXPIRED", {})
|
||
raise RuntimeError("request_expired")
|
||
if approve:
|
||
default_ttl = int(self.policy.get("default_ttl_seconds", 10800))
|
||
max_ttl = int(self.policy.get("max_ttl_seconds", 86400))
|
||
ttl = int(ttl_seconds or default_ttl)
|
||
if ttl < 900 or ttl > max_ttl:
|
||
raise ValueError("ttl_out_of_policy")
|
||
cap_expires = now + ttl
|
||
nonce = secrets.token_urlsafe(18)
|
||
db.execute(
|
||
"""UPDATE requests SET state='APPROVED', decided_at=?, owner_login=?,
|
||
capability_nonce=?, capability_expires_at=? WHERE id=?""",
|
||
(now, owner, nonce, cap_expires, request_id),
|
||
)
|
||
updated = db.execute("SELECT * FROM requests WHERE id=?", (request_id,)).fetchone()
|
||
token = self.make_capability(updated)
|
||
db.execute("UPDATE requests SET capability_hash=? WHERE id=?", (sha256_text(token), request_id))
|
||
receipt = self.store.append_receipt(
|
||
db,
|
||
request_id,
|
||
"human_approved",
|
||
"APPROVED",
|
||
{
|
||
"owner_login": owner,
|
||
"capability_expires_at": iso(cap_expires),
|
||
"binding_hash": sha256_text(
|
||
compact_json(
|
||
{
|
||
"persona": row["persona_id"],
|
||
"development_id": row["development_id"],
|
||
"repository": row["repository"],
|
||
"base_sha": row["base_sha"],
|
||
"branch": row["branch"],
|
||
"paths": json.loads(row["paths_json"]),
|
||
"actions": json.loads(row["actions_json"]),
|
||
}
|
||
)
|
||
),
|
||
},
|
||
)
|
||
return {"state": "APPROVED", "capability_expires_at": iso(cap_expires), "receipt": receipt}
|
||
reason = reason.strip()[:240] or "主人拒绝本次申请"
|
||
db.execute(
|
||
"UPDATE requests SET state='REJECTED', decided_at=?, owner_login=?, rejection_reason=? WHERE id=?",
|
||
(now, owner, reason, request_id),
|
||
)
|
||
receipt = self.store.append_receipt(
|
||
db, request_id, "human_rejected", "REJECTED", {"owner_login": owner, "reason": reason}
|
||
)
|
||
return {"state": "REJECTED", "receipt": receipt}
|
||
|
||
def pickup(self, request_id: str, claim: str) -> dict[str, Any]:
|
||
self.claimed_request(request_id, claim)
|
||
now = utc_now()
|
||
with self.store.connect() as db:
|
||
row = db.execute("SELECT * FROM requests WHERE id=?", (request_id,)).fetchone()
|
||
if row["state"] != "APPROVED":
|
||
raise RuntimeError(f"request_not_approved:{row['state']}")
|
||
if row["capability_expires_at"] <= now:
|
||
db.execute("UPDATE requests SET state='EXPIRED' WHERE id=?", (request_id,))
|
||
self.store.append_receipt(db, request_id, "capability_expired", "EXPIRED", {})
|
||
raise RuntimeError("capability_expired")
|
||
if row["picked_up_at"]:
|
||
raise RuntimeError("capability_already_picked_up")
|
||
token = self.make_capability(row)
|
||
if not hmac.compare_digest(row["capability_hash"], sha256_text(token)):
|
||
raise RuntimeError("capability_reconstruction_failed")
|
||
db.execute("UPDATE requests SET picked_up_at=? WHERE id=?", (now, request_id))
|
||
receipt = self.store.append_receipt(
|
||
db,
|
||
request_id,
|
||
"capability_picked_up",
|
||
"PASS",
|
||
{"capability_hash": row["capability_hash"], "expires_at": iso(row["capability_expires_at"])},
|
||
)
|
||
return {
|
||
"schema": "guanghu.zhulan-capability-pickup/v1",
|
||
"capability": token,
|
||
"expires_at": iso(row["capability_expires_at"]),
|
||
"receipt": receipt,
|
||
"warning": "临时能力仅返回一次;只交给受限执行器,不得写入仓库或长期日志。",
|
||
}
|
||
|
||
def revoke(self, request_id: str, owner: str, reason: str = "") -> dict[str, Any]:
|
||
if not REQUEST_ID_RE.fullmatch(request_id):
|
||
raise ValueError("request_id_invalid")
|
||
now = utc_now()
|
||
clean_reason = reason.strip()[:240] or "主人主动撤销本次开发能力"
|
||
with self.store.connect() as db:
|
||
row = db.execute("SELECT * FROM requests WHERE id=?", (request_id,)).fetchone()
|
||
if not row:
|
||
raise LookupError("request_not_found")
|
||
if row["state"] != "APPROVED":
|
||
raise RuntimeError("only_approved_request_can_be_revoked")
|
||
db.execute(
|
||
"""UPDATE requests SET state='REVOKED', capability_expires_at=?,
|
||
rejection_reason=? WHERE id=?""",
|
||
(now, clean_reason, request_id),
|
||
)
|
||
db.execute(
|
||
"UPDATE oauth_tokens SET revoked_at=? WHERE request_id=? AND revoked_at IS NULL",
|
||
(now, request_id),
|
||
)
|
||
receipt = self.store.append_receipt(
|
||
db,
|
||
request_id,
|
||
"human_revoked",
|
||
"REVOKED",
|
||
{"owner_login": owner, "reason": clean_reason},
|
||
)
|
||
return {"state": "REVOKED", "receipt": receipt}
|
||
|
||
def verify_capability(self, token: str, expected_action: str | None = None) -> dict[str, Any]:
|
||
try:
|
||
version, encoded, signature = token.split(".", 2)
|
||
expected_sig = b64url(
|
||
hmac.new(self.secret, f"{version}.{encoded}".encode("ascii"), hashlib.sha256).digest()
|
||
)
|
||
if version != "v1" or not hmac.compare_digest(signature, expected_sig):
|
||
raise ValueError
|
||
payload = json.loads(b64url_decode(encoded))
|
||
except (ValueError, json.JSONDecodeError, UnicodeDecodeError) as exc:
|
||
raise PermissionError("capability_invalid") from exc
|
||
now = utc_now()
|
||
if payload.get("node_id") != self.policy["node_id"] or int(payload.get("exp", 0)) <= now:
|
||
raise PermissionError("capability_expired_or_wrong_node")
|
||
if expected_action and expected_action not in payload.get("actions", []):
|
||
raise PermissionError("capability_action_denied")
|
||
with self.store.connect() as db:
|
||
row = db.execute("SELECT * FROM requests WHERE id=?", (payload.get("request_id"),)).fetchone()
|
||
if (
|
||
not row
|
||
or row["state"] != "APPROVED"
|
||
or not row["picked_up_at"]
|
||
or not hmac.compare_digest(row["capability_hash"], sha256_text(token))
|
||
):
|
||
raise PermissionError("capability_not_active")
|
||
receipt = self.store.append_receipt(
|
||
db,
|
||
row["id"],
|
||
"capability_verified",
|
||
"PASS",
|
||
{"action": expected_action or "inspect", "capability_hash": row["capability_hash"]},
|
||
)
|
||
return {"claims": payload, "receipt": receipt}
|
||
|
||
def logout(self, token: str | None) -> None:
|
||
if not token:
|
||
return
|
||
with self.store.connect() as db:
|
||
db.execute("DELETE FROM owner_sessions WHERE session_hash=?", (sha256_text(token),))
|
||
|
||
def receipts(self, request_id: str | None = None) -> list[dict[str, Any]]:
|
||
with self.store.connect() as db:
|
||
if request_id is None:
|
||
rows = db.execute("SELECT * FROM receipts ORDER BY sequence").fetchall()
|
||
else:
|
||
rows = db.execute(
|
||
"SELECT * FROM receipts WHERE request_id=? ORDER BY sequence", (request_id,)
|
||
).fetchall()
|
||
return [
|
||
{
|
||
"sequence": row["sequence"],
|
||
"receipt_id": row["receipt_id"],
|
||
"request_id": row["request_id"],
|
||
"event": row["event"],
|
||
"result": row["result"],
|
||
"evidence": json.loads(row["evidence_json"]),
|
||
"created_at": iso(row["created_at"]),
|
||
"previous_hash": row["previous_hash"],
|
||
"receipt_hash": row["receipt_hash"],
|
||
}
|
||
for row in rows
|
||
]
|
||
|
||
@staticmethod
|
||
def public_origin() -> str:
|
||
return "https://guanghulab.com/zhulan"
|
||
|
||
def protected_resource_metadata(self) -> dict[str, Any]:
|
||
origin = self.public_origin()
|
||
return {
|
||
"resource": f"{origin}/mcp",
|
||
"authorization_servers": [origin],
|
||
"scopes_supported": ["zhulan.request", "zhulan.develop"],
|
||
"resource_documentation": f"{origin}/",
|
||
"bearer_methods_supported": ["header"],
|
||
}
|
||
|
||
def oauth_metadata(self) -> dict[str, Any]:
|
||
origin = self.public_origin()
|
||
return {
|
||
"issuer": origin,
|
||
"authorization_endpoint": f"{origin}/oauth/authorize",
|
||
"token_endpoint": f"{origin}/oauth/token",
|
||
"registration_endpoint": f"{origin}/oauth/register",
|
||
"response_types_supported": ["code"],
|
||
"grant_types_supported": ["authorization_code"],
|
||
"code_challenge_methods_supported": ["S256"],
|
||
"token_endpoint_auth_methods_supported": ["none"],
|
||
"scopes_supported": ["zhulan.request", "zhulan.develop"],
|
||
}
|
||
|
||
@staticmethod
|
||
def valid_redirect_uri(uri: str) -> bool:
|
||
try:
|
||
parsed = urlparse(uri)
|
||
except ValueError:
|
||
return False
|
||
if parsed.scheme != "https" or not parsed.netloc or parsed.username or parsed.password or parsed.fragment:
|
||
return False
|
||
return (
|
||
parsed.hostname in {"chatgpt.com", "chat.openai.com"}
|
||
and parsed.path.startswith(("/connector/oauth/", "/connector_platform_oauth_redirect"))
|
||
)
|
||
|
||
def register_oauth_client(self, data: dict[str, Any], source: str) -> dict[str, Any]:
|
||
if not self.rate_ok(
|
||
"oauth-register", source, int(os.getenv("ZHULAN_OAUTH_REGISTER_LIMIT", "20")), 3600
|
||
):
|
||
raise PermissionError("oauth_registration_rate_limited")
|
||
redirects = data.get("redirect_uris")
|
||
if not isinstance(redirects, list) or not redirects or len(redirects) > 8:
|
||
raise ValueError("redirect_uris_invalid")
|
||
redirects = sorted(set(str(uri) for uri in redirects))
|
||
if any(not self.valid_redirect_uri(uri) for uri in redirects):
|
||
raise ValueError("redirect_uri_not_allowed")
|
||
methods = data.get("token_endpoint_auth_method", "none")
|
||
if methods != "none":
|
||
raise ValueError("token_endpoint_auth_method_not_supported")
|
||
client_id = f"zlc_{secrets.token_urlsafe(18)}"
|
||
name = str(data.get("client_name", "ChatGPT Zhulan Connector"))[:120]
|
||
now = utc_now()
|
||
with self.store.connect() as db:
|
||
db.execute("DELETE FROM oauth_clients WHERE created_at < ?", (now - 7 * 86400,))
|
||
db.execute(
|
||
"INSERT INTO oauth_clients VALUES (?, ?, ?, ?)",
|
||
(client_id, name, compact_json(redirects), now),
|
||
)
|
||
return {
|
||
"client_id": client_id,
|
||
"client_id_issued_at": now,
|
||
"client_name": name,
|
||
"redirect_uris": redirects,
|
||
"grant_types": ["authorization_code"],
|
||
"response_types": ["code"],
|
||
"token_endpoint_auth_method": "none",
|
||
}
|
||
|
||
def validate_oauth_authorize(self, query: dict[str, list[str]]) -> dict[str, str]:
|
||
def one(name: str) -> str:
|
||
values = query.get(name, [])
|
||
if len(values) != 1:
|
||
raise ValueError(f"oauth_{name}_invalid")
|
||
return values[0]
|
||
|
||
client_id = one("client_id")
|
||
redirect_uri = one("redirect_uri")
|
||
response_type = one("response_type")
|
||
state = one("state")
|
||
code_challenge = one("code_challenge")
|
||
method = one("code_challenge_method")
|
||
resource = one("resource")
|
||
scope = one("scope")
|
||
if response_type != "code" or method != "S256":
|
||
raise ValueError("oauth_flow_must_use_code_pkce_s256")
|
||
if resource != f"{self.public_origin()}/mcp":
|
||
raise ValueError("oauth_resource_invalid")
|
||
scopes = sorted(set(scope.split()))
|
||
if not scopes or any(item not in {"zhulan.request", "zhulan.develop"} for item in scopes):
|
||
raise ValueError("oauth_scope_invalid")
|
||
if not re.fullmatch(r"[A-Za-z0-9_-]{43,128}", code_challenge):
|
||
raise ValueError("oauth_code_challenge_invalid")
|
||
with self.store.connect() as db:
|
||
client = db.execute("SELECT * FROM oauth_clients WHERE client_id=?", (client_id,)).fetchone()
|
||
if not client or redirect_uri not in json.loads(client["redirect_uris_json"]):
|
||
raise PermissionError("oauth_client_or_redirect_invalid")
|
||
return {
|
||
"client_id": client_id,
|
||
"redirect_uri": redirect_uri,
|
||
"state": state,
|
||
"code_challenge": code_challenge,
|
||
"resource": resource,
|
||
"scope": " ".join(scopes),
|
||
}
|
||
|
||
def authorize_oauth_request(
|
||
self,
|
||
owner_token: str | None,
|
||
csrf: str | None,
|
||
oauth: dict[str, str],
|
||
request_id: str,
|
||
) -> str:
|
||
session, _ = self.owner_session(owner_token)
|
||
self.require_csrf(session, csrf)
|
||
if not REQUEST_ID_RE.fullmatch(request_id):
|
||
raise ValueError("request_id_invalid")
|
||
now = utc_now()
|
||
with self.store.connect() as db:
|
||
row = db.execute("SELECT * FROM requests WHERE id=?", (request_id,)).fetchone()
|
||
if not row or row["state"] != "APPROVED" or int(row["capability_expires_at"] or 0) <= now:
|
||
raise PermissionError("approved_request_required")
|
||
if not row["picked_up_at"]:
|
||
db.execute("UPDATE requests SET picked_up_at=? WHERE id=?", (now, request_id))
|
||
code = secrets.token_urlsafe(32)
|
||
db.execute(
|
||
"""INSERT INTO oauth_codes
|
||
(code_hash, client_id, redirect_uri, code_challenge, resource, request_id,
|
||
scopes_json, created_at, expires_at, used_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)""",
|
||
(
|
||
sha256_text(code),
|
||
oauth["client_id"],
|
||
oauth["redirect_uri"],
|
||
oauth["code_challenge"],
|
||
oauth["resource"],
|
||
request_id,
|
||
compact_json(oauth["scope"].split()),
|
||
now,
|
||
now + 300,
|
||
),
|
||
)
|
||
self.store.append_receipt(
|
||
db,
|
||
request_id,
|
||
"plugin_connection_authorized",
|
||
"PASS",
|
||
{
|
||
"owner_login": session["owner_login"],
|
||
"client_id_hash": sha256_text(oauth["client_id"]),
|
||
"scopes": oauth["scope"].split(),
|
||
},
|
||
)
|
||
separator = "&" if "?" in oauth["redirect_uri"] else "?"
|
||
return f"{oauth['redirect_uri']}{separator}{urlencode({'code': code, 'state': oauth['state']})}"
|
||
|
||
def exchange_oauth_code(self, data: dict[str, str]) -> dict[str, Any]:
|
||
if data.get("grant_type") != "authorization_code":
|
||
raise ValueError("unsupported_grant_type")
|
||
required = ["code", "client_id", "redirect_uri", "code_verifier", "resource"]
|
||
if any(not data.get(key) for key in required):
|
||
raise ValueError("oauth_token_request_incomplete")
|
||
if data["resource"] != f"{self.public_origin()}/mcp":
|
||
raise ValueError("oauth_resource_invalid")
|
||
verifier = data["code_verifier"]
|
||
if not re.fullmatch(r"[A-Za-z0-9._~-]{43,128}", verifier):
|
||
raise ValueError("oauth_code_verifier_invalid")
|
||
challenge = b64url(hashlib.sha256(verifier.encode("ascii")).digest())
|
||
now = utc_now()
|
||
with self.store.connect() as db:
|
||
db.execute("BEGIN IMMEDIATE")
|
||
row = db.execute("SELECT * FROM oauth_codes WHERE code_hash=?", (sha256_text(data["code"]),)).fetchone()
|
||
if (
|
||
not row
|
||
or row["used_at"]
|
||
or row["expires_at"] <= now
|
||
or row["client_id"] != data["client_id"]
|
||
or row["redirect_uri"] != data["redirect_uri"]
|
||
or row["resource"] != data["resource"]
|
||
or not hmac.compare_digest(row["code_challenge"], challenge)
|
||
):
|
||
raise PermissionError("invalid_grant")
|
||
request_row = db.execute("SELECT * FROM requests WHERE id=?", (row["request_id"],)).fetchone()
|
||
if not request_row or request_row["state"] != "APPROVED" or request_row["capability_expires_at"] <= now:
|
||
raise PermissionError("approved_request_expired")
|
||
token = secrets.token_urlsafe(42)
|
||
expires = min(request_row["capability_expires_at"], now + 10800)
|
||
db.execute("UPDATE oauth_codes SET used_at=? WHERE code_hash=?", (now, row["code_hash"]))
|
||
db.execute(
|
||
"INSERT INTO oauth_tokens VALUES (?, ?, ?, ?, ?, ?, NULL)",
|
||
(sha256_text(token), row["request_id"], row["client_id"], row["scopes_json"], now, expires),
|
||
)
|
||
self.store.append_receipt(
|
||
db,
|
||
row["request_id"],
|
||
"plugin_access_token_issued",
|
||
"PASS",
|
||
{"client_id_hash": sha256_text(row["client_id"]), "expires_at": iso(expires)},
|
||
)
|
||
return {
|
||
"access_token": token,
|
||
"token_type": "Bearer",
|
||
"expires_in": expires - now,
|
||
"scope": " ".join(json.loads(row["scopes_json"])),
|
||
}
|
||
|
||
def oauth_binding(self, bearer: str | None, required_scope: str) -> sqlite3.Row:
|
||
if not bearer:
|
||
raise PermissionError("oauth_required")
|
||
now = utc_now()
|
||
with self.store.connect() as db:
|
||
row = db.execute(
|
||
"""SELECT t.*, r.state AS request_state, r.capability_expires_at,
|
||
r.capability_nonce, r.capability_hash, r.picked_up_at,
|
||
r.id, r.persona_id, r.development_id, r.repository, r.base_sha,
|
||
r.branch, r.paths_json, r.actions_json, r.decided_at
|
||
FROM oauth_tokens t JOIN requests r ON r.id=t.request_id
|
||
WHERE t.token_hash=?""",
|
||
(sha256_text(bearer),),
|
||
).fetchone()
|
||
if (
|
||
not row
|
||
or row["revoked_at"]
|
||
or row["expires_at"] <= now
|
||
or row["request_state"] != "APPROVED"
|
||
or row["capability_expires_at"] <= now
|
||
or required_scope not in json.loads(row["scopes_json"])
|
||
):
|
||
raise PermissionError("oauth_token_invalid_or_insufficient_scope")
|
||
return row
|
||
|
||
def capability_for_oauth(self, row: sqlite3.Row) -> str:
|
||
# OAuth is the transport/session identity. The execution kernel still
|
||
# verifies the exact approved capability on every operation.
|
||
request = self._request_row_for_claims(row["request_id"])
|
||
token = self.make_capability(request)
|
||
if not hmac.compare_digest(request["capability_hash"], sha256_text(token)):
|
||
raise PermissionError("oauth_capability_binding_invalid")
|
||
return token
|
||
|
||
def _git(self, workspace: Path, *args: str, timeout: int = 60) -> str:
|
||
env = {
|
||
"PATH": "/usr/local/bin:/usr/bin:/bin",
|
||
"HOME": str(self.settings.workspace_root),
|
||
"LANG": "C.UTF-8",
|
||
"GIT_TERMINAL_PROMPT": "0",
|
||
}
|
||
result = subprocess.run(
|
||
["git", "-C", str(workspace), *args],
|
||
stdin=subprocess.DEVNULL,
|
||
stdout=subprocess.PIPE,
|
||
stderr=subprocess.PIPE,
|
||
text=True,
|
||
timeout=timeout,
|
||
env=env,
|
||
)
|
||
if result.returncode != 0:
|
||
detail = (result.stderr or result.stdout).strip().splitlines()[-1:] or ["git_failed"]
|
||
raise RuntimeError(f"git_failed:{detail[0][:240]}")
|
||
return result.stdout.strip()
|
||
|
||
def _claims(self, capability: str, action: str) -> dict[str, Any]:
|
||
return self.verify_capability(capability, action)["claims"]
|
||
|
||
def _workspace(self, claims: dict[str, Any]) -> Path:
|
||
owner, repo = claims["repository"].split("/", 1)
|
||
name = f"{owner}__{repo}"
|
||
workspace = (
|
||
self.settings.workspace_root / claims["development_id"] / name
|
||
).resolve()
|
||
workspace.relative_to(self.settings.workspace_root.resolve())
|
||
return workspace
|
||
|
||
def _candidate_repo(self, claims: dict[str, Any]) -> Path:
|
||
owner, repo = claims["repository"].split("/", 1)
|
||
candidate = (self.settings.candidate_root / f"{owner}__{repo}.git").resolve()
|
||
candidate.relative_to(self.settings.candidate_root.resolve())
|
||
return candidate
|
||
|
||
def _ensure_candidate_repo(self, claims: dict[str, Any]) -> Path:
|
||
candidate = self._candidate_repo(claims)
|
||
if candidate.exists() and not candidate.joinpath("HEAD").is_file():
|
||
raise RuntimeError("candidate_store_not_bare_repository")
|
||
if not candidate.exists():
|
||
candidate.parent.mkdir(parents=True, exist_ok=True)
|
||
result = subprocess.run(
|
||
["git", "init", "--bare", "--initial-branch=main", str(candidate)],
|
||
stdin=subprocess.DEVNULL,
|
||
stdout=subprocess.PIPE,
|
||
stderr=subprocess.PIPE,
|
||
text=True,
|
||
timeout=30,
|
||
env={"PATH": "/usr/local/bin:/usr/bin:/bin", "LANG": "C.UTF-8"},
|
||
)
|
||
if result.returncode != 0:
|
||
raise RuntimeError("candidate_store_init_failed")
|
||
subprocess.run(
|
||
["git", "--git-dir", str(candidate), "config", "receive.denyNonFastForwards", "true"],
|
||
check=True,
|
||
stdin=subprocess.DEVNULL,
|
||
stdout=subprocess.DEVNULL,
|
||
stderr=subprocess.DEVNULL,
|
||
timeout=10,
|
||
)
|
||
return candidate
|
||
|
||
@staticmethod
|
||
def _ensure_claim_path(claims: dict[str, Any], path_value: Any) -> str:
|
||
path = safe_path(path_value)
|
||
if path == ".git" or path.startswith(".git/"):
|
||
raise PermissionError("git_metadata_is_server_owned")
|
||
if path == "." or not any(path_within(path, prefix) for prefix in claims["paths"]):
|
||
raise PermissionError("path_outside_capability")
|
||
return path
|
||
|
||
def _request_row_for_claims(self, request_id: str) -> sqlite3.Row:
|
||
with self.store.connect() as db:
|
||
row = db.execute("SELECT * FROM requests WHERE id=?", (request_id,)).fetchone()
|
||
if not row:
|
||
raise PermissionError("request_not_found")
|
||
return row
|
||
|
||
def _operation_receipt(
|
||
self, request_id: str, event: str, result: str, evidence: dict[str, Any]
|
||
) -> dict[str, Any]:
|
||
with self.store.connect() as db:
|
||
return self.store.append_receipt(db, request_id, event, result, evidence)
|
||
|
||
def restore_lane(self, capability: str) -> dict[str, Any]:
|
||
claims = self._claims(capability, "read")
|
||
workspace = self._workspace(claims)
|
||
git_state: dict[str, Any] = {"prepared": workspace.joinpath(".git").is_dir()}
|
||
if git_state["prepared"]:
|
||
try:
|
||
git_state.update(
|
||
{
|
||
"branch": self._git(workspace, "branch", "--show-current"),
|
||
"head": self._git(workspace, "rev-parse", "HEAD"),
|
||
"changes": self._git(workspace, "status", "--short").splitlines(),
|
||
}
|
||
)
|
||
except RuntimeError:
|
||
git_state["status"] = "BROKEN_FAIL_CLOSED"
|
||
recent = self.receipts(claims["request_id"])[-12:]
|
||
return {
|
||
"schema": "guanghu.zhulan-lane-restore/v1",
|
||
"persona_identity": claims["persona_id"],
|
||
"development_id": claims["development_id"],
|
||
"topology": {
|
||
"front": "BS-GZ-006_PROXY_ONLY",
|
||
"runtime": "BS-SG-003_REAL_RUNTIME",
|
||
},
|
||
"repository": claims["repository"],
|
||
"base_sha": claims["base_sha"],
|
||
"candidate_branch": claims["branch"],
|
||
"allowed_paths": claims["paths"],
|
||
"allowed_actions": claims["actions"],
|
||
"expires_at": iso(claims["exp"]),
|
||
"workspace": git_state,
|
||
"candidate_store": "BS-SG-003_INTERNAL_REVIEW_ONLY",
|
||
"recent_receipts": recent,
|
||
"hard_rule": "只在已批准边界内继续;不确定、越界或证据未知时失败关闭并重新申请。",
|
||
}
|
||
|
||
def prepare_workspace(self, capability: str) -> dict[str, Any]:
|
||
claims = self._claims(capability, "read")
|
||
workspace = self._workspace(claims)
|
||
binding_core = {
|
||
key: claims[key]
|
||
for key in (
|
||
"persona_id",
|
||
"development_id",
|
||
"repository",
|
||
"base_sha",
|
||
"branch",
|
||
"paths",
|
||
"actions",
|
||
)
|
||
}
|
||
binding = {"request_id": claims["request_id"], **binding_core}
|
||
if workspace.exists() and (
|
||
workspace.joinpath(".git").is_symlink()
|
||
or not workspace.joinpath(".git").is_dir()
|
||
):
|
||
raise RuntimeError("workspace_exists_without_git")
|
||
binding_path = workspace / ".git" / "zhulan-binding.json"
|
||
if binding_path.exists():
|
||
old = json.loads(binding_path.read_text(encoding="utf-8"))
|
||
old_core = {key: old.get(key) for key in binding_core}
|
||
if old_core != binding_core:
|
||
raise RuntimeError("workspace_binding_mismatch")
|
||
if old.get("request_id") != claims["request_id"]:
|
||
binding["renewed_from_request_id"] = old.get("request_id")
|
||
binding_path.write_text(compact_json(binding) + "\n", encoding="utf-8")
|
||
binding_path.chmod(0o600)
|
||
repo_url = self.policy.get("repository_base_url", "https://guanghulab.com/code/").rstrip("/")
|
||
repo_url = f"{repo_url}/{claims['repository']}.git"
|
||
if not workspace.exists():
|
||
workspace.parent.mkdir(parents=True, exist_ok=True)
|
||
env = {
|
||
"PATH": "/usr/local/bin:/usr/bin:/bin",
|
||
"HOME": str(self.settings.workspace_root),
|
||
"LANG": "C.UTF-8",
|
||
"GIT_TERMINAL_PROMPT": "0",
|
||
}
|
||
result = subprocess.run(
|
||
["git", "clone", "--no-tags", "--filter=blob:none", repo_url, str(workspace)],
|
||
stdin=subprocess.DEVNULL,
|
||
stdout=subprocess.PIPE,
|
||
stderr=subprocess.PIPE,
|
||
text=True,
|
||
timeout=180,
|
||
env=env,
|
||
)
|
||
if result.returncode != 0:
|
||
raise RuntimeError("repository_clone_failed")
|
||
initial_remotes = self._git(workspace, "remote").splitlines()
|
||
source_remote = "upstream" if "upstream" in initial_remotes else "origin"
|
||
current_branch = self._git(workspace, "branch", "--show-current")
|
||
status = self._git(workspace, "status", "--porcelain")
|
||
if status and current_branch != claims["branch"]:
|
||
raise RuntimeError("workspace_dirty_on_other_branch")
|
||
try:
|
||
self._git(workspace, "cat-file", "-e", f"{claims['base_sha']}^{{commit}}")
|
||
except RuntimeError:
|
||
self._git(workspace, "fetch", "--no-tags", source_remote, "main", timeout=180)
|
||
self._git(workspace, "cat-file", "-e", f"{claims['base_sha']}^{{commit}}")
|
||
if current_branch != claims["branch"]:
|
||
self._git(workspace, "checkout", "-B", claims["branch"], claims["base_sha"])
|
||
candidate = self._ensure_candidate_repo(claims)
|
||
remotes = self._git(workspace, "remote").splitlines()
|
||
if "upstream" not in remotes:
|
||
if "origin" in remotes and self._git(workspace, "remote", "get-url", "origin") != str(candidate):
|
||
self._git(workspace, "remote", "rename", "origin", "upstream")
|
||
remotes = self._git(workspace, "remote").splitlines()
|
||
elif "origin" not in remotes:
|
||
self._git(workspace, "remote", "add", "upstream", repo_url)
|
||
remotes = self._git(workspace, "remote").splitlines()
|
||
if "origin" in remotes:
|
||
self._git(workspace, "remote", "set-url", "origin", str(candidate))
|
||
else:
|
||
self._git(workspace, "remote", "add", "origin", str(candidate))
|
||
if not binding_path.exists():
|
||
binding_path.write_text(compact_json(binding) + "\n", encoding="utf-8")
|
||
binding_path.chmod(0o600)
|
||
receipt = self._operation_receipt(
|
||
claims["request_id"],
|
||
"workspace_prepared",
|
||
"PASS",
|
||
{"repository": claims["repository"], "branch": claims["branch"], "base_sha": claims["base_sha"]},
|
||
)
|
||
return {
|
||
"prepared": True,
|
||
"repository": claims["repository"],
|
||
"branch": self._git(workspace, "branch", "--show-current"),
|
||
"head": self._git(workspace, "rev-parse", "HEAD"),
|
||
"clean": not bool(self._git(workspace, "status", "--porcelain")),
|
||
"candidate_store": "BS-SG-003_INTERNAL_REVIEW_ONLY",
|
||
"receipt": receipt,
|
||
}
|
||
|
||
def list_files(self, capability: str, prefix: str = ".") -> dict[str, Any]:
|
||
claims = self._claims(capability, "read")
|
||
workspace = self._workspace(claims)
|
||
if not workspace.joinpath(".git").is_dir():
|
||
raise RuntimeError("workspace_not_prepared")
|
||
requested = safe_path(prefix)
|
||
if requested != "." and not any(path_within(requested, item) or path_within(item, requested) for item in claims["paths"]):
|
||
raise PermissionError("path_outside_capability")
|
||
tracked = self._git(workspace, "ls-files").splitlines()
|
||
untracked = self._git(workspace, "ls-files", "--others", "--exclude-standard").splitlines()
|
||
files = sorted(
|
||
{
|
||
path
|
||
for path in tracked + untracked
|
||
if path
|
||
and (requested == "." or path_within(path, requested))
|
||
and any(path_within(path, allowed) for allowed in claims["paths"])
|
||
}
|
||
)[:5000]
|
||
return {"files": files, "count": len(files), "truncated": len(files) >= 5000}
|
||
|
||
def read_file(self, capability: str, path_value: Any) -> dict[str, Any]:
|
||
claims = self._claims(capability, "read")
|
||
relative = self._ensure_claim_path(claims, path_value)
|
||
workspace = self._workspace(claims)
|
||
target = workspace / relative
|
||
if target.is_symlink() or not target.is_file():
|
||
raise LookupError("file_not_found_or_symlink")
|
||
target.resolve().relative_to(workspace.resolve())
|
||
max_bytes = int(self.policy.get("max_read_file_bytes", 262144))
|
||
raw = target.read_bytes()
|
||
if len(raw) > max_bytes:
|
||
raise ValueError("file_too_large_to_read")
|
||
try:
|
||
content = raw.decode("utf-8")
|
||
except UnicodeDecodeError as exc:
|
||
raise ValueError("binary_file_not_readable") from exc
|
||
return {
|
||
"path": relative,
|
||
"content": content,
|
||
"sha256": hashlib.sha256(raw).hexdigest(),
|
||
"bytes": len(raw),
|
||
}
|
||
|
||
def write_file(
|
||
self, capability: str, path_value: Any, content: Any, expected_sha256: str | None
|
||
) -> dict[str, Any]:
|
||
claims = self._claims(capability, "edit")
|
||
relative = self._ensure_claim_path(claims, path_value)
|
||
if not isinstance(content, str):
|
||
raise ValueError("content_must_be_text")
|
||
raw = content.encode("utf-8")
|
||
max_bytes = int(self.policy.get("max_changed_file_bytes", 20 * 1024 * 1024))
|
||
if len(raw) > max_bytes:
|
||
raise ValueError("file_too_large")
|
||
workspace = self._workspace(claims)
|
||
target = workspace / relative
|
||
parent = target.parent
|
||
parent.mkdir(parents=True, exist_ok=True)
|
||
parent.resolve().relative_to(workspace.resolve())
|
||
if target.exists() and (target.is_symlink() or not target.is_file()):
|
||
raise PermissionError("target_not_regular_file")
|
||
before = target.read_bytes() if target.exists() else b""
|
||
before_hash = hashlib.sha256(before).hexdigest()
|
||
expected = str(expected_sha256 or "")
|
||
if target.exists() and (not expected or not hmac.compare_digest(before_hash, expected)):
|
||
raise RuntimeError("expected_sha256_mismatch")
|
||
if not target.exists() and expected not in ("", hashlib.sha256(b"").hexdigest()):
|
||
raise RuntimeError("new_file_expected_sha256_mismatch")
|
||
temp_path: Path | None = None
|
||
try:
|
||
with tempfile.NamedTemporaryFile(
|
||
dir=target.parent, prefix=".zhulan-write-", delete=False
|
||
) as handle:
|
||
temp_path = Path(handle.name)
|
||
handle.write(raw)
|
||
handle.flush()
|
||
os.fsync(handle.fileno())
|
||
temp_path.chmod(0o600)
|
||
temp_path.replace(target)
|
||
temp_path = None
|
||
finally:
|
||
if temp_path is not None:
|
||
temp_path.unlink(missing_ok=True)
|
||
after_hash = hashlib.sha256(raw).hexdigest()
|
||
receipt = self._operation_receipt(
|
||
claims["request_id"],
|
||
"workspace_file_written",
|
||
"PASS",
|
||
{"path": relative, "before_sha256": before_hash, "after_sha256": after_hash, "bytes": len(raw)},
|
||
)
|
||
return {"path": relative, "sha256": after_hash, "bytes": len(raw), "receipt": receipt}
|
||
|
||
def git_status(self, capability: str) -> dict[str, Any]:
|
||
claims = self._claims(capability, "read")
|
||
workspace = self._workspace(claims)
|
||
return {
|
||
"branch": self._git(workspace, "branch", "--show-current"),
|
||
"head": self._git(workspace, "rev-parse", "HEAD"),
|
||
"base_sha": claims["base_sha"],
|
||
"changes": self._git(workspace, "status", "--short").splitlines(),
|
||
"diff_stat": self._git(workspace, "diff", "--stat", claims["base_sha"]),
|
||
}
|
||
|
||
def enforce_workspace_gate(self, claims: dict[str, Any]) -> list[str]:
|
||
workspace = self._workspace(claims)
|
||
self._git(workspace, "merge-base", "--is-ancestor", claims["base_sha"], "HEAD")
|
||
blocks = [
|
||
self._git(workspace, "diff", "--name-only", f"{claims['base_sha']}...HEAD"),
|
||
self._git(workspace, "diff", "--name-only", "HEAD"),
|
||
self._git(workspace, "ls-files", "--others", "--exclude-standard"),
|
||
]
|
||
files = sorted({line for block in blocks for line in block.splitlines() if line})
|
||
max_bytes = int(self.policy.get("max_changed_file_bytes", 20 * 1024 * 1024))
|
||
for relative in files:
|
||
if not any(path_within(relative, allowed) for allowed in claims["paths"]):
|
||
raise PermissionError(f"changed_path_outside_capability:{relative}")
|
||
target = workspace / relative
|
||
if not target.exists():
|
||
continue
|
||
if target.is_symlink():
|
||
try:
|
||
target.resolve().relative_to(workspace.resolve())
|
||
except ValueError as exc:
|
||
raise PermissionError(f"symlink_outside_workspace:{relative}") from exc
|
||
continue
|
||
if not target.is_file():
|
||
raise PermissionError(f"changed_path_not_regular_file:{relative}")
|
||
size = target.stat().st_size
|
||
if size > max_bytes:
|
||
raise PermissionError(f"changed_file_too_large:{relative}:{size}")
|
||
if size <= 2_000_000:
|
||
raw = target.read_bytes()
|
||
if any(pattern.search(raw) for pattern in SECRET_PATTERNS):
|
||
raise PermissionError(f"possible_secret:{relative}")
|
||
return files
|
||
|
||
def run_validation(self, capability: str) -> dict[str, Any]:
|
||
claims = self._claims(capability, "test")
|
||
workspace = self._workspace(claims)
|
||
files = self.enforce_workspace_gate(claims)
|
||
commands = self.policy["repositories"][claims["repository"]].get("validation_commands", [])
|
||
results: list[dict[str, Any]] = []
|
||
env = {
|
||
"PATH": "/usr/local/bin:/usr/bin:/bin",
|
||
"HOME": str(self.settings.workspace_root),
|
||
"LANG": "C.UTF-8",
|
||
"PYTHONDONTWRITEBYTECODE": "1",
|
||
"GIT_TERMINAL_PROMPT": "0",
|
||
}
|
||
passed = True
|
||
for command in commands:
|
||
if not isinstance(command, list) or not command or not all(isinstance(x, str) for x in command):
|
||
raise RuntimeError("validation_policy_invalid")
|
||
if command == ["git", "diff", "--check"]:
|
||
argv = ["git", "-C", str(workspace), "diff", "--check"]
|
||
cwd = None
|
||
elif self.settings.test_mode:
|
||
argv = command
|
||
cwd = workspace
|
||
else:
|
||
request = compact_json(
|
||
{
|
||
"operation": "run",
|
||
"request_id": claims["request_id"],
|
||
"repository": claims["repository"],
|
||
"development_id": claims["development_id"],
|
||
"workspace": str(workspace),
|
||
"paths": claims["paths"],
|
||
"command": command,
|
||
}
|
||
).encode("utf-8") + b"\n"
|
||
try:
|
||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as broker:
|
||
broker.settimeout(190)
|
||
broker.connect(str(self.settings.validation_socket))
|
||
broker.sendall(request)
|
||
broker.shutdown(socket.SHUT_WR)
|
||
raw = b""
|
||
while len(raw) <= 131072:
|
||
chunk = broker.recv(16384)
|
||
if not chunk:
|
||
break
|
||
raw += chunk
|
||
if b"\n" in raw:
|
||
break
|
||
except (OSError, TimeoutError) as exc:
|
||
raise RuntimeError("validation_executor_unavailable") from exc
|
||
if len(raw) > 131072:
|
||
raise RuntimeError("validation_executor_response_too_large")
|
||
try:
|
||
response = json.loads(raw)
|
||
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
||
raise RuntimeError("validation_executor_response_invalid") from exc
|
||
if not response.get("ok"):
|
||
raise RuntimeError(f"validation_executor_rejected:{response.get('error', 'unknown')}")
|
||
output = str(response.get("output", ""))[-12000:]
|
||
exit_code = int(response.get("exit_code", 1))
|
||
results.append({"command": command, "exit_code": exit_code, "output": output})
|
||
if exit_code != 0:
|
||
passed = False
|
||
break
|
||
continue
|
||
result = subprocess.run(
|
||
argv,
|
||
cwd=cwd,
|
||
stdin=subprocess.DEVNULL,
|
||
stdout=subprocess.PIPE,
|
||
stderr=subprocess.STDOUT,
|
||
text=True,
|
||
timeout=180,
|
||
env=env,
|
||
)
|
||
output = result.stdout[-12000:]
|
||
results.append({"command": command, "exit_code": result.returncode, "output": output})
|
||
if result.returncode != 0:
|
||
passed = False
|
||
break
|
||
head = self._git(workspace, "rev-parse", "HEAD")
|
||
clean = not bool(self._git(workspace, "status", "--porcelain"))
|
||
receipt = self._operation_receipt(
|
||
claims["request_id"],
|
||
"workspace_validation",
|
||
"PASS" if passed else "FAIL",
|
||
{
|
||
"head": head,
|
||
"clean": clean,
|
||
"changed_files": files,
|
||
"commands": [{"argv": item["command"], "exit_code": item["exit_code"]} for item in results],
|
||
},
|
||
)
|
||
return {
|
||
"decision": "PASS" if passed else "FAIL",
|
||
"head": head,
|
||
"clean": clean,
|
||
"changed_files": files,
|
||
"results": results,
|
||
"receipt": receipt,
|
||
}
|
||
|
||
def commit_candidate(self, capability: str, message: Any) -> dict[str, Any]:
|
||
claims = self._claims(capability, "commit")
|
||
if not isinstance(message, str) or not (4 <= len(message.strip()) <= 160) or "\n" in message.strip():
|
||
raise ValueError("commit_message_invalid")
|
||
workspace = self._workspace(claims)
|
||
if self._git(workspace, "branch", "--show-current") != claims["branch"]:
|
||
raise PermissionError("candidate_branch_mismatch")
|
||
files = self.enforce_workspace_gate(claims)
|
||
if not files:
|
||
raise RuntimeError("nothing_to_commit")
|
||
self._git(workspace, "diff", "--check")
|
||
pathspecs = claims["paths"]
|
||
self._git(workspace, "add", "--all", "--", *pathspecs)
|
||
staged = self._git(workspace, "diff", "--cached", "--name-only").splitlines()
|
||
if not staged:
|
||
raise RuntimeError("nothing_to_commit")
|
||
if any(not any(path_within(path, allowed) for allowed in claims["paths"]) for path in staged):
|
||
self._git(workspace, "reset")
|
||
raise PermissionError("staged_path_outside_capability")
|
||
env = {
|
||
"PATH": "/usr/local/bin:/usr/bin:/bin",
|
||
"HOME": str(self.settings.workspace_root),
|
||
"LANG": "C.UTF-8",
|
||
"GIT_AUTHOR_NAME": "Zhulan Remote Cell",
|
||
"GIT_AUTHOR_EMAIL": "zhulan@guanghulab.invalid",
|
||
"GIT_COMMITTER_NAME": "Zhulan Remote Cell",
|
||
"GIT_COMMITTER_EMAIL": "zhulan@guanghulab.invalid",
|
||
"GIT_TERMINAL_PROMPT": "0",
|
||
}
|
||
result = subprocess.run(
|
||
["git", "-C", str(workspace), "commit", "-m", message.strip()],
|
||
stdin=subprocess.DEVNULL,
|
||
stdout=subprocess.PIPE,
|
||
stderr=subprocess.STDOUT,
|
||
text=True,
|
||
timeout=60,
|
||
env=env,
|
||
)
|
||
if result.returncode != 0:
|
||
raise RuntimeError("git_commit_failed")
|
||
commit_sha = self._git(workspace, "rev-parse", "HEAD")
|
||
receipt = self._operation_receipt(
|
||
claims["request_id"],
|
||
"candidate_committed",
|
||
"PASS",
|
||
{"commit_sha": commit_sha, "branch": claims["branch"], "files": staged},
|
||
)
|
||
return {"commit_sha": commit_sha, "branch": claims["branch"], "files": staged, "receipt": receipt}
|
||
|
||
def push_candidate(self, capability: str) -> dict[str, Any]:
|
||
claims = self._claims(capability, "push_candidate")
|
||
workspace = self._workspace(claims)
|
||
branch = self._git(workspace, "branch", "--show-current")
|
||
if branch != claims["branch"] or branch in ("main", "master"):
|
||
raise PermissionError("push_branch_denied")
|
||
if self._git(workspace, "status", "--porcelain"):
|
||
raise RuntimeError("workspace_dirty_before_push")
|
||
before = self._git(workspace, "rev-parse", "HEAD")
|
||
self.enforce_workspace_gate(claims)
|
||
with self.store.connect() as db:
|
||
validation = db.execute(
|
||
"""SELECT result, evidence_json FROM receipts
|
||
WHERE request_id=? AND event='workspace_validation'
|
||
ORDER BY sequence DESC LIMIT 1""",
|
||
(claims["request_id"],),
|
||
).fetchone()
|
||
if not validation or validation["result"] != "PASS":
|
||
raise PermissionError("passing_validation_required_before_push")
|
||
evidence = json.loads(validation["evidence_json"])
|
||
if evidence.get("head") != before or evidence.get("clean") is not True:
|
||
raise PermissionError("validation_not_bound_to_clean_current_head")
|
||
self._git(workspace, "push", "origin", f"HEAD:refs/heads/{branch}", timeout=180)
|
||
remote = self._git(workspace, "ls-remote", "--heads", "origin", f"refs/heads/{branch}")
|
||
if not remote.startswith(before):
|
||
raise RuntimeError("remote_readback_mismatch")
|
||
receipt = self._operation_receipt(
|
||
claims["request_id"],
|
||
"candidate_pushed",
|
||
"PASS",
|
||
{
|
||
"repository": claims["repository"],
|
||
"branch": branch,
|
||
"commit_sha": before,
|
||
"candidate_store": "BS-SG-003_INTERNAL_REVIEW_ONLY",
|
||
"central_publication": "NOT_PERFORMED_REQUIRES_ZHUYUAN_REVIEW",
|
||
},
|
||
)
|
||
return {
|
||
"repository": claims["repository"],
|
||
"branch": branch,
|
||
"commit_sha": before,
|
||
"candidate_store": "BS-SG-003_INTERNAL_REVIEW_ONLY",
|
||
"central_publication": "NOT_PERFORMED_REQUIRES_ZHUYUAN_REVIEW",
|
||
"receipt": receipt,
|
||
}
|
||
|
||
def mcp_tools(self) -> list[dict[str, Any]]:
|
||
noauth = [{"type": "noauth"}]
|
||
oauth = [{"type": "oauth2", "scopes": ["zhulan.develop"]}]
|
||
tools = [
|
||
{
|
||
"name": "zhulan_request_development",
|
||
"description": "创建一张没有执行权的铸澜开发申请。返回的 claim_token 只用于查询和一次领取。",
|
||
"securitySchemes": noauth,
|
||
"inputSchema": {
|
||
"type": "object",
|
||
"additionalProperties": False,
|
||
"required": ["persona_id", "development_id", "repository", "base_sha", "branch", "paths", "actions", "description"],
|
||
"properties": {
|
||
"persona_id": {"type": "string", "const": "ICE-GL-ZL-001"},
|
||
"development_id": {"type": "string"},
|
||
"repository": {"type": "string"},
|
||
"base_sha": {"type": "string"},
|
||
"branch": {"type": "string"},
|
||
"paths": {"type": "array", "items": {"type": "string"}, "minItems": 1},
|
||
"actions": {"type": "array", "items": {"type": "string"}, "minItems": 1},
|
||
"description": {"type": "string"},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
"name": "zhulan_request_status",
|
||
"description": "用一次性领取凭证查看申请是否已被主人批准。",
|
||
"securitySchemes": noauth,
|
||
"inputSchema": {"type": "object", "additionalProperties": False, "required": ["request_id", "claim_token"], "properties": {"request_id": {"type": "string"}, "claim_token": {"type": "string"}}},
|
||
},
|
||
*[
|
||
{
|
||
"name": name,
|
||
"description": description,
|
||
"securitySchemes": oauth,
|
||
"inputSchema": schema,
|
||
}
|
||
for name, description, schema in [
|
||
("zhulan_restore_lane", "每次恢复或上下文变长后先调用;读取服务器绑定的当前车道、Git 状态和最近回执。", {"type": "object", "additionalProperties": False, "properties": {}}),
|
||
("zhulan_prepare_workspace", "按批准的仓库、基线和候选分支准备或恢复唯一工作区。", {"type": "object", "additionalProperties": False, "properties": {}}),
|
||
("zhulan_list_files", "列出能力路径内的文件。", {"type": "object", "additionalProperties": False, "properties": {"prefix": {"type": "string", "default": "."}}}),
|
||
("zhulan_read_file", "读取能力路径内的 UTF-8 文本文件。", {"type": "object", "additionalProperties": False, "required": ["path"], "properties": {"path": {"type": "string"}}}),
|
||
("zhulan_write_file", "以预期 SHA-256 乐观锁写入能力路径内的文本文件。", {"type": "object", "additionalProperties": False, "required": ["path", "content", "expected_sha256"], "properties": {"path": {"type": "string"}, "content": {"type": "string"}, "expected_sha256": {"type": "string"}}}),
|
||
("zhulan_git_status", "读取候选分支、基线和实际改动。", {"type": "object", "additionalProperties": False, "properties": {}}),
|
||
("zhulan_run_validation", "只运行仓库策略登记的验收命令,不接受任意命令。", {"type": "object", "additionalProperties": False, "properties": {}}),
|
||
("zhulan_commit_candidate", "只在批准路径内创建候选提交。", {"type": "object", "additionalProperties": False, "required": ["message"], "properties": {"message": {"type": "string"}}}),
|
||
("zhulan_push_candidate", "只把当前候选分支推到新加坡内部复核库并回读精确提交;永不直接发布代码频道或推 main。", {"type": "object", "additionalProperties": False, "properties": {}}),
|
||
]
|
||
],
|
||
]
|
||
for tool in tools:
|
||
tool["annotations"] = {
|
||
"readOnlyHint": tool["name"] in {"zhulan_request_status", "zhulan_restore_lane", "zhulan_list_files", "zhulan_read_file", "zhulan_git_status"},
|
||
"destructiveHint": False,
|
||
"openWorldHint": tool["name"] in {"zhulan_request_development", "zhulan_request_status"},
|
||
}
|
||
return tools
|
||
|
||
def call_mcp_tool(
|
||
self, name: str, args: dict[str, Any], source: str, bearer: str | None
|
||
) -> dict[str, Any]:
|
||
public_calls = {
|
||
"zhulan_request_development": lambda: self.create_request(args, source),
|
||
"zhulan_request_status": lambda: self.request_status(str(args.get("request_id", "")), str(args.get("claim_token", ""))),
|
||
}
|
||
if name in public_calls:
|
||
result = public_calls[name]()
|
||
else:
|
||
try:
|
||
binding = self.oauth_binding(bearer, "zhulan.develop")
|
||
capability = self.capability_for_oauth(binding)
|
||
except PermissionError as exc:
|
||
raise OAuthRequiredError(str(exc)) from exc
|
||
protected_calls = {
|
||
"zhulan_restore_lane": lambda: self.restore_lane(capability),
|
||
"zhulan_prepare_workspace": lambda: self.prepare_workspace(capability),
|
||
"zhulan_list_files": lambda: self.list_files(capability, str(args.get("prefix", "."))),
|
||
"zhulan_read_file": lambda: self.read_file(capability, args.get("path")),
|
||
"zhulan_write_file": lambda: self.write_file(capability, args.get("path"), args.get("content"), args.get("expected_sha256")),
|
||
"zhulan_git_status": lambda: self.git_status(capability),
|
||
"zhulan_run_validation": lambda: self.run_validation(capability),
|
||
"zhulan_commit_candidate": lambda: self.commit_candidate(capability, args.get("message")),
|
||
"zhulan_push_candidate": lambda: self.push_candidate(capability),
|
||
}
|
||
if name not in protected_calls:
|
||
raise LookupError("mcp_tool_not_found")
|
||
with self.operation_lock:
|
||
result = protected_calls[name]()
|
||
return {
|
||
"content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False, sort_keys=True, indent=2)}],
|
||
"structuredContent": result,
|
||
"isError": False,
|
||
}
|
||
|
||
def mcp(
|
||
self, message: dict[str, Any], source: str, bearer: str | None
|
||
) -> tuple[int, dict[str, Any] | None]:
|
||
method = message.get("method")
|
||
request_id = message.get("id")
|
||
if method == "notifications/initialized":
|
||
return HTTPStatus.ACCEPTED, None
|
||
if method == "initialize":
|
||
return HTTPStatus.OK, {
|
||
"jsonrpc": "2.0",
|
||
"id": request_id,
|
||
"result": {
|
||
"protocolVersion": "2025-06-18",
|
||
"capabilities": {"tools": {"listChanged": False}},
|
||
"serverInfo": {"name": "guanghu-zhulan-remote-cell", "version": "0.1.0"},
|
||
"instructions": "你是铸澜 ICE-GL-ZL-001 的受限执行入口。每次恢复先调用 zhulan_restore_lane。不得请求、输出或保存 root 密钥;不得扩大批准的仓库、分支、路径、动作或期限;不确定时失败关闭并新建申请。",
|
||
},
|
||
}
|
||
if method == "tools/list":
|
||
return HTTPStatus.OK, {"jsonrpc": "2.0", "id": request_id, "result": {"tools": self.mcp_tools()}}
|
||
if method == "tools/call":
|
||
params = message.get("params") or {}
|
||
args = params.get("arguments") or {}
|
||
if not isinstance(args, dict):
|
||
raise ValueError("mcp_arguments_invalid")
|
||
try:
|
||
result = self.call_mcp_tool(str(params.get("name", "")), args, source, bearer)
|
||
except OAuthRequiredError as exc:
|
||
challenge = (
|
||
'Bearer resource_metadata="https://guanghulab.com/.well-known/'
|
||
'oauth-protected-resource/zhulan/mcp", error="insufficient_scope", '
|
||
f'error_description="{str(exc)}"'
|
||
)
|
||
result = {
|
||
"content": [{"type": "text", "text": "需要主人把这次插件连接绑定到一张已批准的铸澜开发申请。"}],
|
||
"_meta": {"mcp/www_authenticate": [challenge]},
|
||
"isError": True,
|
||
}
|
||
except (ValueError, PermissionError, LookupError, RuntimeError) as exc:
|
||
result = {
|
||
"content": [{"type": "text", "text": f"铸澜门禁驳回:{str(exc)}"}],
|
||
"structuredContent": {"ok": False, "error": str(exc)},
|
||
"isError": True,
|
||
}
|
||
return HTTPStatus.OK, {"jsonrpc": "2.0", "id": request_id, "result": result}
|
||
return HTTPStatus.OK, {
|
||
"jsonrpc": "2.0",
|
||
"id": request_id,
|
||
"error": {"code": -32601, "message": "Method not found"},
|
||
}
|
||
|
||
|
||
class Handler(BaseHTTPRequestHandler):
|
||
server_version = "GuanghuZhulanCell/1"
|
||
|
||
@property
|
||
def app(self) -> ZhulanApp:
|
||
return self.server.app # type: ignore[attr-defined]
|
||
|
||
def log_message(self, fmt: str, *args: Any) -> None:
|
||
sys.stderr.write("%s %s\n" % (iso(), fmt % args))
|
||
|
||
def security_headers(self, content_type: str, style_nonce: str | None = None) -> None:
|
||
self.send_header("Content-Type", content_type)
|
||
self.send_header("Cache-Control", "no-store")
|
||
self.send_header("X-Content-Type-Options", "nosniff")
|
||
self.send_header("X-Frame-Options", "DENY")
|
||
self.send_header("Referrer-Policy", "no-referrer")
|
||
self.send_header("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
|
||
style_src = "style-src 'self'" + (f" 'nonce-{style_nonce}'" if style_nonce else "")
|
||
self.send_header(
|
||
"Content-Security-Policy",
|
||
f"default-src 'self'; {style_src}; script-src 'self'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'",
|
||
)
|
||
|
||
def json_response(self, status: int, payload: Any, cookie: str | None = None) -> None:
|
||
raw = compact_json(payload).encode("utf-8")
|
||
self.send_response(status)
|
||
self.security_headers("application/json; charset=utf-8")
|
||
if cookie:
|
||
self.send_header("Set-Cookie", cookie)
|
||
self.send_header("Content-Length", str(len(raw)))
|
||
self.end_headers()
|
||
self.wfile.write(raw)
|
||
|
||
def html_response(self, status: int, markup: str, style_nonce: str | None = None) -> None:
|
||
raw = markup.encode("utf-8")
|
||
self.send_response(status)
|
||
self.security_headers("text/html; charset=utf-8", style_nonce)
|
||
self.send_header("Content-Length", str(len(raw)))
|
||
self.end_headers()
|
||
self.wfile.write(raw)
|
||
|
||
def redirect_response(self, location: str) -> None:
|
||
self.send_response(HTTPStatus.FOUND)
|
||
self.security_headers("text/plain; charset=utf-8")
|
||
self.send_header("Location", location)
|
||
self.send_header("Content-Length", "0")
|
||
self.end_headers()
|
||
|
||
def error(self, status: int, code: str) -> None:
|
||
self.json_response(status, {"ok": False, "error": code})
|
||
|
||
def body(self, limit: int = 65536) -> dict[str, Any]:
|
||
length = int(self.headers.get("Content-Length", "0"))
|
||
if length <= 0 or length > limit:
|
||
raise ValueError("body_size_invalid")
|
||
value = json.loads(self.rfile.read(length).decode("utf-8"))
|
||
if not isinstance(value, dict):
|
||
raise ValueError("body_must_be_object")
|
||
return value
|
||
|
||
def form_body(self, limit: int = 65536) -> dict[str, str]:
|
||
length = int(self.headers.get("Content-Length", "0"))
|
||
if length <= 0 or length > limit:
|
||
raise ValueError("body_size_invalid")
|
||
parsed = parse_qs(self.rfile.read(length).decode("utf-8"), keep_blank_values=True)
|
||
if any(len(values) != 1 for values in parsed.values()):
|
||
raise ValueError("form_field_repeated")
|
||
return {key: values[0] for key, values in parsed.items()}
|
||
|
||
def session_token(self) -> str | None:
|
||
cookie = SimpleCookie(self.headers.get("Cookie", ""))
|
||
morsel = cookie.get("zhulan_owner")
|
||
return morsel.value if morsel else None
|
||
|
||
def client_source(self) -> str:
|
||
forwarded = self.headers.get("X-Forwarded-For", "").split(",", 1)[0].strip()
|
||
return forwarded or self.client_address[0]
|
||
|
||
def route(self) -> str:
|
||
return urlparse(self.path).path.rstrip("/") or "/"
|
||
|
||
def bearer(self) -> str | None:
|
||
match = re.fullmatch(r"Bearer\s+([^\s]+)", self.headers.get("Authorization", ""))
|
||
return match.group(1) if match else None
|
||
|
||
def oauth_authorize_page(self, oauth: dict[str, str], csrf: str, style_nonce: str) -> str:
|
||
requests = [
|
||
item
|
||
for item in self.app.owner_requests()
|
||
if item["state"] == "APPROVED" and item["capability_expires_at"]
|
||
]
|
||
options = "".join(
|
||
f'<option value="{html.escape(item["id"])}">'
|
||
f'{html.escape(item["development_id"])} · {html.escape(item["repository"])} · '
|
||
f'{html.escape(item["branch"])} · 至 {html.escape(item["capability_expires_at"])}</option>'
|
||
for item in requests
|
||
)
|
||
hidden = "".join(
|
||
f'<input type="hidden" name="{html.escape(key)}" value="{html.escape(value)}">'
|
||
for key, value in oauth.items()
|
||
)
|
||
disabled = "" if requests else " disabled"
|
||
return f"""<!doctype html><html lang=\"zh-CN\"><head><meta charset=\"utf-8\">
|
||
<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>铸澜 · 绑定插件连接</title>
|
||
<style nonce="{html.escape(style_nonce)}">color-scheme:dark;*{{box-sizing:border-box}}body{{margin:0;min-height:100vh;display:grid;place-items:center;padding:20px;color:#f2f1ed;background:#090b1a;font-family:-apple-system,BlinkMacSystemFont,'PingFang SC',sans-serif}}main{{width:min(560px,100%);padding:30px;border:1px solid #303552;border-radius:20px;background:#11152e}}p{{color:#a3a9be;line-height:1.8}}label{{display:block;margin:24px 0 8px;font-size:12px;color:#b1b3ff}}select,button{{width:100%;min-height:48px;border-radius:12px;border:1px solid #3b4267;padding:0 13px;font:inherit}}select{{color:#f2f1ed;background:#0b0e20}}button{{margin-top:18px;border:0;color:#11152e;background:#eee6d5;font-weight:750}}small{{display:block;margin-top:18px;color:#7f879f;line-height:1.7}}</style></head><body><main>
|
||
<p>ZHULAN · OAUTH 2.1 · PKCE</p><h1>把插件连接绑定到已批准边界</h1>
|
||
<p>这不会把 SSH 密钥或临时能力交给手机。连接只能使用你选中的人格、仓库、候选分支、路径、动作和到期时间。</p>
|
||
<form method=\"post\" action=\"/zhulan/oauth/authorize\">{hidden}
|
||
<input type=\"hidden\" name=\"csrf\" value=\"{html.escape(csrf)}\">
|
||
<label for=\"request_id\">已批准的铸澜开发申请</label><select id=\"request_id\" name=\"request_id\">{options}</select>
|
||
<button type=\"submit\"{disabled}>确认绑定这一次连接</button></form>
|
||
<small>{'没有仍在有效期内的已批准申请。请返回审批端先批准一张申请。' if not requests else '到期、拒绝或扩大边界后,这个连接都会失效。'}</small>
|
||
</main></body></html>"""
|
||
|
||
def serve_asset(self, path: Path, content_type: str) -> None:
|
||
if not path.is_file():
|
||
self.error(HTTPStatus.NOT_FOUND, "not_found")
|
||
return
|
||
raw = path.read_bytes()
|
||
self.send_response(HTTPStatus.OK)
|
||
self.security_headers(content_type)
|
||
self.send_header("Cache-Control", "public, max-age=300")
|
||
self.send_header("Content-Length", str(len(raw)))
|
||
self.end_headers()
|
||
self.wfile.write(raw)
|
||
|
||
def do_GET(self) -> None:
|
||
route = self.route()
|
||
try:
|
||
if route == "/":
|
||
return self.serve_asset(self.app.settings.ui_dir / "index.html", "text/html; charset=utf-8")
|
||
if route == "/assets/styles.css":
|
||
return self.serve_asset(self.app.settings.ui_dir / "styles.css", "text/css; charset=utf-8")
|
||
if route == "/assets/app.js":
|
||
return self.serve_asset(self.app.settings.ui_dir / "app.js", "application/javascript; charset=utf-8")
|
||
if route == "/health":
|
||
return self.json_response(
|
||
HTTPStatus.OK,
|
||
{
|
||
"ok": True,
|
||
"service": "zhulan-remote-cell",
|
||
"front_role": "PROXY_ONLY",
|
||
"runtime_node": self.app.policy["node_id"],
|
||
"time": iso(),
|
||
},
|
||
)
|
||
if route == "/.well-known/oauth-protected-resource":
|
||
return self.json_response(HTTPStatus.OK, self.app.protected_resource_metadata())
|
||
if route in ("/.well-known/oauth-authorization-server", "/.well-known/openid-configuration"):
|
||
return self.json_response(HTTPStatus.OK, self.app.oauth_metadata())
|
||
if route == "/oauth/authorize":
|
||
oauth = self.app.validate_oauth_authorize(parse_qs(urlparse(self.path).query, keep_blank_values=True))
|
||
try:
|
||
_, csrf = self.app.owner_session(self.session_token(), rotate_csrf=True)
|
||
except PermissionError:
|
||
return_path = urlparse(self.path).path + "?" + urlparse(self.path).query
|
||
encoded = b64url(return_path.encode("utf-8"))
|
||
return self.redirect_response(f"/zhulan/?oauth_return={encoded}")
|
||
style_nonce = secrets.token_urlsafe(18)
|
||
return self.html_response(
|
||
HTTPStatus.OK,
|
||
self.oauth_authorize_page(oauth, str(csrf), style_nonce),
|
||
style_nonce,
|
||
)
|
||
if route == "/api/v1/public/config":
|
||
return self.json_response(HTTPStatus.OK, self.app.public_config())
|
||
if route == "/api/v1/owner/session":
|
||
session, csrf = self.app.owner_session(self.session_token(), rotate_csrf=True)
|
||
return self.json_response(
|
||
HTTPStatus.OK,
|
||
{"authenticated": True, "owner_login": session["owner_login"], "csrf": csrf},
|
||
)
|
||
if route == "/api/v1/owner/requests":
|
||
self.app.owner_session(self.session_token())
|
||
return self.json_response(HTTPStatus.OK, {"requests": self.app.owner_requests()})
|
||
if route == "/api/v1/owner/receipts":
|
||
self.app.owner_session(self.session_token())
|
||
return self.json_response(HTTPStatus.OK, {"receipts": self.app.receipts()})
|
||
match = re.fullmatch(r"/api/v1/owner/requests/([^/]+)/receipts", route)
|
||
if match:
|
||
self.app.owner_session(self.session_token())
|
||
return self.json_response(HTTPStatus.OK, {"receipts": self.app.receipts(match.group(1))})
|
||
self.error(HTTPStatus.NOT_FOUND, "not_found")
|
||
except PermissionError as exc:
|
||
self.error(HTTPStatus.UNAUTHORIZED, str(exc))
|
||
except Exception as exc: # fail closed without exposing internals
|
||
self.log_message("GET failed: %s", exc)
|
||
self.error(HTTPStatus.INTERNAL_SERVER_ERROR, "internal_error")
|
||
|
||
def do_POST(self) -> None:
|
||
route = self.route()
|
||
try:
|
||
content_type = self.headers.get("Content-Type", "").split(";", 1)[0].strip().lower()
|
||
if route in ("/oauth/token", "/oauth/authorize"):
|
||
data = self.form_body()
|
||
else:
|
||
data = self.body(limit=1024 * 1024 if route == "/mcp" else 65536)
|
||
if route == "/oauth/register":
|
||
return self.json_response(
|
||
HTTPStatus.CREATED,
|
||
self.app.register_oauth_client(data, self.client_source()),
|
||
)
|
||
if route == "/oauth/token":
|
||
return self.json_response(HTTPStatus.OK, self.app.exchange_oauth_code(data))
|
||
if route == "/oauth/authorize":
|
||
oauth_keys = ("client_id", "redirect_uri", "state", "code_challenge", "resource", "scope")
|
||
oauth = {key: str(data.get(key, "")) for key in oauth_keys}
|
||
location = self.app.authorize_oauth_request(
|
||
self.session_token(), str(data.get("csrf", "")), oauth, str(data.get("request_id", ""))
|
||
)
|
||
return self.redirect_response(location)
|
||
if route == "/mcp":
|
||
status, result = self.app.mcp(data, self.client_source(), self.bearer())
|
||
if result is None:
|
||
self.send_response(status)
|
||
self.send_header("Content-Length", "0")
|
||
self.end_headers()
|
||
return
|
||
return self.json_response(status, result)
|
||
if route == "/api/v1/public/requests":
|
||
result = self.app.create_request(data, self.client_source())
|
||
return self.json_response(HTTPStatus.CREATED, result)
|
||
match = re.fullmatch(r"/api/v1/public/requests/([^/]+)/status", route)
|
||
if match:
|
||
return self.json_response(
|
||
HTTPStatus.OK, self.app.request_status(match.group(1), str(data.get("claim_token", "")))
|
||
)
|
||
match = re.fullmatch(r"/api/v1/public/requests/([^/]+)/pickup", route)
|
||
if match:
|
||
return self.json_response(
|
||
HTTPStatus.OK, self.app.pickup(match.group(1), str(data.get("claim_token", "")))
|
||
)
|
||
if route == "/api/v1/owner/login":
|
||
token, csrf, result = self.app.owner_login(
|
||
str(data.get("username", "")),
|
||
str(data.get("password", "")),
|
||
self.client_source(),
|
||
)
|
||
flags = [
|
||
f"zhulan_owner={token}",
|
||
f"Path={self.app.settings.cookie_path}",
|
||
"HttpOnly",
|
||
"SameSite=Strict",
|
||
"Max-Age=10800",
|
||
]
|
||
if self.app.settings.cookie_secure:
|
||
flags.append("Secure")
|
||
result["csrf"] = csrf
|
||
return self.json_response(HTTPStatus.OK, result, "; ".join(flags))
|
||
if route == "/api/v1/owner/logout":
|
||
token = self.session_token()
|
||
session, _ = self.app.owner_session(token)
|
||
self.app.require_csrf(session, self.headers.get("X-CSRF-Token"))
|
||
self.app.logout(token)
|
||
return self.json_response(
|
||
HTTPStatus.OK,
|
||
{"ok": True},
|
||
f"zhulan_owner=; Path={self.app.settings.cookie_path}; HttpOnly; SameSite=Strict; Max-Age=0",
|
||
)
|
||
match = re.fullmatch(r"/api/v1/owner/requests/([^/]+)/(approve|reject)", route)
|
||
if match:
|
||
session, _ = self.app.owner_session(self.session_token())
|
||
self.app.require_csrf(session, self.headers.get("X-CSRF-Token"))
|
||
approve = match.group(2) == "approve"
|
||
result = self.app.decide(
|
||
match.group(1),
|
||
session["owner_login"],
|
||
approve,
|
||
data.get("ttl_seconds"),
|
||
str(data.get("reason", "")),
|
||
)
|
||
return self.json_response(HTTPStatus.OK, result)
|
||
match = re.fullmatch(r"/api/v1/owner/requests/([^/]+)/revoke", route)
|
||
if match:
|
||
session, _ = self.app.owner_session(self.session_token())
|
||
self.app.require_csrf(session, self.headers.get("X-CSRF-Token"))
|
||
result = self.app.revoke(
|
||
match.group(1), session["owner_login"], str(data.get("reason", ""))
|
||
)
|
||
return self.json_response(HTTPStatus.OK, result)
|
||
if route == "/api/v1/runtime/verify":
|
||
result = self.app.verify_capability(
|
||
str(data.get("capability", "")), str(data.get("action")) if data.get("action") else None
|
||
)
|
||
return self.json_response(HTTPStatus.OK, result)
|
||
self.error(HTTPStatus.NOT_FOUND, "not_found")
|
||
except json.JSONDecodeError:
|
||
self.error(HTTPStatus.BAD_REQUEST, "json_invalid")
|
||
except ValueError as exc:
|
||
self.error(HTTPStatus.BAD_REQUEST, str(exc))
|
||
except PermissionError as exc:
|
||
self.error(HTTPStatus.FORBIDDEN, str(exc))
|
||
except LookupError as exc:
|
||
self.error(HTTPStatus.NOT_FOUND, str(exc))
|
||
except RuntimeError as exc:
|
||
self.error(HTTPStatus.CONFLICT, str(exc))
|
||
except Exception as exc:
|
||
self.log_message("POST failed: %s", exc)
|
||
self.error(HTTPStatus.INTERNAL_SERVER_ERROR, "internal_error")
|
||
|
||
|
||
class Server(ThreadingHTTPServer):
|
||
def __init__(self, address: tuple[str, int], app: ZhulanApp):
|
||
super().__init__(address, Handler)
|
||
self.app = app
|
||
|
||
|
||
def main() -> None:
|
||
settings = Settings.load()
|
||
app = ZhulanApp(settings)
|
||
server = Server((settings.bind, settings.port), app)
|
||
print(
|
||
compact_json(
|
||
{
|
||
"service": "zhulan-remote-cell",
|
||
"bind": settings.bind,
|
||
"port": settings.port,
|
||
"runtime_node": app.policy["node_id"],
|
||
}
|
||
),
|
||
flush=True,
|
||
)
|
||
server.serve_forever()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|