diff --git a/server-tools/zhulan-remote-cell/README.md b/server-tools/zhulan-remote-cell/README.md new file mode 100644 index 0000000..c5c53a4 --- /dev/null +++ b/server-tools/zhulan-remote-cell/README.md @@ -0,0 +1,35 @@ +# 铸澜受限远程开发单元 + +`zhulan-remote-cell` 是 `ICE-GL-ZL-001` 的手机远程开发运行入口。它把公网前门与真实运行体明确分开: + +- `BS-GZ-006` 只承载 `guanghulab.com/zhulan/` 的域名、TLS 和反向代理,不保存或运行审批 UI; +- `BS-SG-003` 承载真实 UI、申请、主人登录、批准、临时能力、策略校验、SQLite 状态与哈希回执链; +- UI 只投影运行层返回的事实,不拥有执行权; +- 公开申请本身没有任何服务器或仓库权限; +- 一次批准绑定 `persona + development_id + repository + base_sha + branch + paths + actions + expiry`,不得在会话内扩大。 + +## 当前能力 + +1. 铸澜可创建无执行权申请并取得一次性领取凭证; +2. 冰朔使用光湖代码频道账号登录审批端,密码仅用于当次 Forgejo 验证,不写入本地状态; +3. 批准后由运行层生成三小时临时能力,磁盘只保存摘要和重建所需的非秘密声明; +4. 临时能力只能领取一次,可由受限执行器通过回环 API 校验; +5. 所有申请、批准、拒绝、领取与校验事件写入前后哈希相连的回执链; +6. 代码门脚本检查当前分支、基线、改动路径、符号链接、超大文件与常见秘密模式; +7. 候选提交只进入 `BS-SG-003` 的内部复核库,不在远程会话中持有代码频道写凭证,也不直接发布中央仓库;本机铸渊复核后再走既有发布门。 + +这里不提供任意远程 shell。后续手机 Codex 接入只能通过受限执行器或受限工具协议使用这些能力,不能把临时能力降级成长期 root 密钥。 + +## 本地验收 + +```bash +python3 -m unittest discover -s tests -v +``` + +## 运行配置 + +生产安装读取 `/etc/guanghu/zhulan-remote-cell.env`,参考 `deploy/zhulan-remote-cell.env.example`。真实应用秘密、代码频道密码和临时能力都不得提交到仓库。 + +运行层默认仅监听 `127.0.0.1:17631`。广州 Nginx 只允许代理到一条专用的回环隧道,不得把新加坡运行端口直接暴露到公网。 + +Ubuntu 24.04 的生产验收命令通过专用的 `zhulan-bwrap` 副本和本机 Unix 套接字执行器运行。公网审批/MCP 服务继续保持 `NoNewPrivileges=true`;只有无公网监听、只接受策略登记命令的执行器负责创建 Bubblewrap 用户命名空间。安装器只为 root 持有、`zhulan-runtime` 组可执行的副本路径加载 AppArmor `userns` 例外;不得关闭全机的未授权用户命名空间限制,也不得把系统 `bwrap` 改成 setuid。 diff --git a/server-tools/zhulan-remote-cell/contracts/SOURCE.hdlp b/server-tools/zhulan-remote-cell/contracts/SOURCE.hdlp new file mode 100644 index 0000000..9bd86bf --- /dev/null +++ b/server-tools/zhulan-remote-cell/contracts/SOURCE.hdlp @@ -0,0 +1,17 @@ +runtime_contract_projection: + development_id: "DEV-20260813-005" + persona: "ICE-GL-ZL-001" + source_repository: "https://guanghulab.com/code/bingshuo/guanghu-ice-heart" + source_commit: "5f9e83e1b716fd50ee2880b24ea47f83b75d1f8f" + architecture: "HLP-CURRENT-ARCH-001@2026-08-13.1" + ui_brains: + - id: "GHS-012" + state: "CANDIDATE_READ_ONLY" + purpose: "Fifth Domain private engineering projection constraints" + - id: "GHS-014" + state: "CANDIDATE_READ_ONLY" + purpose: "Guanghu client UI projection constraints" + topology: + BS-GZ-006: "DOMAIN_TLS_REVERSE_PROXY_ONLY" + BS-SG-003: "REAL_APPROVAL_CAPABILITY_SANDBOX_GATE_RECEIPT_RUNTIME" + boundary: "UI and skill brains guide projection; neither owns execution authority." diff --git a/server-tools/zhulan-remote-cell/deploy/guanghulab-zhulan.nginx.conf b/server-tools/zhulan-remote-cell/deploy/guanghulab-zhulan.nginx.conf new file mode 100644 index 0000000..188e6e0 --- /dev/null +++ b/server-tools/zhulan-remote-cell/deploy/guanghulab-zhulan.nginx.conf @@ -0,0 +1,41 @@ +# BS-GZ-006 only: domain/TLS/reverse proxy. UI and all real services remain on BS-SG-003. +# The local upstream port must be supplied by a dedicated restricted tunnel. +location = /zhulan { + return 308 /zhulan/; +} + +# Runtime-only verification is reachable from the BS-SG-003 loopback executor, +# never through the Guangzhou public projection. +location ^~ /zhulan/api/v1/runtime/ { + return 404; +} + +location ^~ /zhulan/ { + proxy_pass http://127.0.0.1:17631/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Prefix /zhulan; + # Do not inherit a client-supplied X-Forwarded-For chain. The runtime uses + # this value for per-source rate limits. + proxy_set_header X-Forwarded-For $remote_addr; + proxy_connect_timeout 5s; + proxy_read_timeout 30s; + proxy_send_timeout 30s; + client_max_body_size 1m; +} + +# RFC 8414 path-aware discovery aliases for issuer +# https://guanghulab.com/zhulan. These are metadata projections only. +location = /.well-known/oauth-authorization-server/zhulan { + proxy_pass http://127.0.0.1:17631/.well-known/oauth-authorization-server; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto https; +} + +location = /.well-known/oauth-protected-resource/zhulan/mcp { + proxy_pass http://127.0.0.1:17631/.well-known/oauth-protected-resource; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto https; +} diff --git a/server-tools/zhulan-remote-cell/deploy/install-front-proxy.sh b/server-tools/zhulan-remote-cell/deploy/install-front-proxy.sh new file mode 100755 index 0000000..fa9c834 --- /dev/null +++ b/server-tools/zhulan-remote-cell/deploy/install-front-proxy.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ "$(id -u)" -ne 0 ]]; then + echo "INSTALL_REQUIRES_ROOT" >&2 + exit 1 +fi + +SOURCE_DIR="${1:-}" +TUNNEL_PUBLIC_KEY_FILE="${2:-}" +if [[ -z "$SOURCE_DIR" || ! -f "$SOURCE_DIR/deploy/guanghulab-zhulan.nginx.conf" ]]; then + echo "SOURCE_DIR_INVALID" >&2 + exit 1 +fi +if [[ -z "$TUNNEL_PUBLIC_KEY_FILE" || ! -f "$TUNNEL_PUBLIC_KEY_FILE" ]]; then + echo "TUNNEL_PUBLIC_KEY_FILE_INVALID" >&2 + exit 1 +fi + +EXPECTED_HOST="${ZHULAN_EXPECTED_HOST:-VM-0-16-ubuntu}" +EXPECTED_DMI="${ZHULAN_EXPECTED_DMI:-b2ecf109-1999-4643-b223-e67e24d7667d}" +ACTUAL_HOST="$(hostname)" +ACTUAL_DMI="$(tr '[:upper:]' '[:lower:]' &2 + exit 1 +fi + +SITE_FILE="/etc/nginx/sites-enabled/guanghulab" +SNIPPET_FILE="/etc/nginx/snippets/guanghu-zhulan.conf" +SSHD_DROPIN="/etc/ssh/sshd_config.d/80-zhulan-proxy.conf" +PROXY_HOME="/var/lib/guanghu/zhulan-proxy" +AUTH_KEYS="$PROXY_HOME/.ssh/authorized_keys" +BACKUP_ROOT="/var/backups/guanghu/zhulan-front" +STAMP="$(date -u +%Y%m%dT%H%M%SZ)" + +if [[ ! -f "$SITE_FILE" ]]; then + echo "GUANGHULAB_SITE_NOT_FOUND" >&2 + exit 1 +fi +if ! grep -q 'include /etc/nginx/snippets/guanghu-ai-discovery.conf;' "$SITE_FILE"; then + echo "GUANGHULAB_INCLUDE_ANCHOR_NOT_FOUND" >&2 + exit 1 +fi + +getent passwd zhulan-proxy >/dev/null || { + echo "PROXY_USER_MUST_BE_PRECREATED" >&2 + exit 1 +} + +PUBLIC_KEY="$(tr -d '\r\n' <"$TUNNEL_PUBLIC_KEY_FILE")" +if [[ "$PUBLIC_KEY" != ssh-ed25519\ * ]]; then + echo "TUNNEL_PUBLIC_KEY_NOT_ED25519" >&2 + exit 1 +fi + +install -d -o root -g root -m 0700 "$BACKUP_ROOT" +install -m 0644 "$SITE_FILE" "$BACKUP_ROOT/guanghulab-$STAMP" +[[ ! -f "$SNIPPET_FILE" ]] || install -m 0644 "$SNIPPET_FILE" "$BACKUP_ROOT/snippet-$STAMP" +[[ ! -f "$SSHD_DROPIN" ]] || install -m 0600 "$SSHD_DROPIN" "$BACKUP_ROOT/sshd-$STAMP" + +install -d -o zhulan-proxy -g zhulan-proxy -m 0700 "$PROXY_HOME" "$PROXY_HOME/.ssh" +{ + printf '%s %s\n' \ + 'restrict,port-forwarding,permitlisten="127.0.0.1:17631"' \ + "$PUBLIC_KEY" +} >"$AUTH_KEYS.tmp" +chown zhulan-proxy:zhulan-proxy "$AUTH_KEYS.tmp" +chmod 0600 "$AUTH_KEYS.tmp" +mv "$AUTH_KEYS.tmp" "$AUTH_KEYS" + +cat >"$SSHD_DROPIN.tmp" <<'EOF' +Match User zhulan-proxy + AuthenticationMethods publickey + PasswordAuthentication no + KbdInteractiveAuthentication no + AllowAgentForwarding no + AllowTcpForwarding remote + GatewayPorts no + PermitListen 127.0.0.1:17631 + PermitOpen none + PermitTTY no + X11Forwarding no + PermitTunnel no + PermitUserRC no + MaxSessions 0 +EOF +chown root:root "$SSHD_DROPIN.tmp" +chmod 0600 "$SSHD_DROPIN.tmp" +mv "$SSHD_DROPIN.tmp" "$SSHD_DROPIN" +sshd -t + +install -o root -g root -m 0644 \ + "$SOURCE_DIR/deploy/guanghulab-zhulan.nginx.conf" "$SNIPPET_FILE" +if ! grep -q 'include /etc/nginx/snippets/guanghu-zhulan.conf;' "$SITE_FILE"; then + sed -i \ + '/include \/etc\/nginx\/snippets\/guanghu-ai-discovery.conf;/a\ include /etc/nginx/snippets/guanghu-zhulan.conf;' \ + "$SITE_FILE" +fi + +if ! nginx -t; then + install -m 0644 "$BACKUP_ROOT/guanghulab-$STAMP" "$SITE_FILE" + nginx -t + echo "NGINX_VALIDATION_FAILED_ROLLED_BACK" >&2 + exit 1 +fi + +systemctl reload ssh +systemctl reload nginx +printf '{"schema":"guanghu.zhulan-front-install-receipt/v1","development_id":"DEV-20260813-005","front_node":"BS-GZ-006","role":"DOMAIN_TLS_REVERSE_PROXY_ONLY","listener":"127.0.0.1:17631","installed_at":"%s"}\n' "$STAMP" diff --git a/server-tools/zhulan-remote-cell/deploy/install-runtime.sh b/server-tools/zhulan-remote-cell/deploy/install-runtime.sh new file mode 100755 index 0000000..d958669 --- /dev/null +++ b/server-tools/zhulan-remote-cell/deploy/install-runtime.sh @@ -0,0 +1,191 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ "$(id -u)" -ne 0 ]]; then + echo "INSTALL_REQUIRES_ROOT" >&2 + exit 1 +fi + +SOURCE_DIR="${1:-}" +if [[ -z "$SOURCE_DIR" || ! -f "$SOURCE_DIR/runtime/zhulan_cell.py" ]]; then + echo "SOURCE_DIR_INVALID" >&2 + exit 1 +fi + +EXPECTED_HOST="${ZHULAN_EXPECTED_HOST:-VM-12-12-ubuntu}" +EXPECTED_DMI="${ZHULAN_EXPECTED_DMI:-7a500f2d-9aed-4b93-b3b8-59c87c65d031}" +ACTUAL_HOST="$(hostname)" +ACTUAL_DMI="$(tr '[:upper:]' '[:lower:]' &2 + exit 1 +fi + +if ! command -v bwrap >/dev/null 2>&1 || ! command -v apparmor_parser >/dev/null 2>&1; then + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends apparmor bubblewrap +fi + +INSTALL_ROOT="/opt/guanghu/zhulan-remote-cell" +STATE_ROOT="/var/lib/guanghu/zhulan-remote-cell" +WORKSPACE_ROOT="/srv/guanghu/zhulan-cell/workspaces" +CANDIDATE_ROOT="/srv/guanghu/zhulan-cell/candidates" +SECRET_ROOT="/etc/guanghu/secrets" +ENV_FILE="/etc/guanghu/zhulan-remote-cell.env" +SERVICE_FILE="/etc/systemd/system/zhulan-remote-cell.service" +EXECUTOR_SERVICE_FILE="/etc/systemd/system/zhulan-validation-executor.service" +APPARMOR_FILE="/etc/apparmor.d/zhulan-remote-cell-bwrap" +BACKUP_ROOT="/var/backups/guanghu/zhulan-remote-cell" +STAMP="$(date -u +%Y%m%dT%H%M%SZ)" + +getent passwd zhulan-runtime >/dev/null || { + echo "RUNTIME_USER_MUST_BE_PRECREATED" >&2 + exit 1 +} + +install -d -o root -g root -m 0755 "$INSTALL_ROOT" "$BACKUP_ROOT" +install -d -o zhulan-runtime -g zhulan-runtime -m 0700 "$STATE_ROOT" +install -d -o zhulan-runtime -g zhulan-runtime -m 0700 "$WORKSPACE_ROOT" +install -d -o zhulan-runtime -g zhulan-runtime -m 0700 "$CANDIDATE_ROOT" +install -d -o root -g zhulan-runtime -m 0750 "$SECRET_ROOT" + +if [[ -d "$INSTALL_ROOT/runtime" ]]; then + tar -C "$INSTALL_ROOT" -czf "$BACKUP_ROOT/runtime-$STAMP.tgz" runtime ui policy.json 2>/dev/null || true +fi +if [[ -f "$ENV_FILE" ]]; then + install -m 0600 "$ENV_FILE" "$BACKUP_ROOT/env-$STAMP" +fi +if [[ -f "$SERVICE_FILE" ]]; then + install -m 0644 "$SERVICE_FILE" "$BACKUP_ROOT/service-$STAMP" +fi +if [[ -f "$EXECUTOR_SERVICE_FILE" ]]; then + install -m 0644 "$EXECUTOR_SERVICE_FILE" "$BACKUP_ROOT/executor-service-$STAMP" +fi +if [[ -f "$APPARMOR_FILE" ]]; then + install -m 0644 "$APPARMOR_FILE" "$BACKUP_ROOT/apparmor-$STAMP" +fi + +install -d -o root -g root -m 0755 "$INSTALL_ROOT/runtime" "$INSTALL_ROOT/ui" "$INSTALL_ROOT/contracts" +install -o root -g root -m 0755 "$SOURCE_DIR/runtime/zhulan_cell.py" "$INSTALL_ROOT/runtime/zhulan_cell.py" +install -o root -g root -m 0755 "$SOURCE_DIR/runtime/zhulan_code_gate.py" "$INSTALL_ROOT/runtime/zhulan_code_gate.py" +install -o root -g root -m 0755 "$SOURCE_DIR/runtime/zhulan_validation_executor.py" "$INSTALL_ROOT/runtime/zhulan_validation_executor.py" +# Keep the distro bwrap binary non-setuid. A dedicated group-executable copy +# lets AppArmor grant userns only to this runtime path instead of weakening the +# host-wide unprivileged-userns policy or opening /usr/bin/bwrap for all users. +install -o root -g zhulan-runtime -m 0750 /usr/bin/bwrap "$INSTALL_ROOT/runtime/zhulan-bwrap" +install -o root -g root -m 0644 "$SOURCE_DIR/ui/index.html" "$INSTALL_ROOT/ui/index.html" +install -o root -g root -m 0644 "$SOURCE_DIR/ui/styles.css" "$INSTALL_ROOT/ui/styles.css" +install -o root -g root -m 0644 "$SOURCE_DIR/ui/app.js" "$INSTALL_ROOT/ui/app.js" +install -o root -g root -m 0644 "$SOURCE_DIR/policy.example.json" "$INSTALL_ROOT/policy.json" +install -o root -g root -m 0644 "$SOURCE_DIR/README.md" "$INSTALL_ROOT/README.md" + +if [[ -d "$SOURCE_DIR/contracts" ]]; then + find "$SOURCE_DIR/contracts" -maxdepth 1 -type f -print0 | while IFS= read -r -d '' file; do + install -o root -g root -m 0444 "$file" "$INSTALL_ROOT/contracts/$(basename "$file")" + done +fi + +# Install current Fifth Domain brains and Zhulan entry as read-only runtime +# contracts from the same exact repository checkout as this installer. +REPO_ROOT="$(cd "$SOURCE_DIR/../.." && pwd)" +SOURCE_COMMIT="${ZHULAN_SOURCE_COMMIT:-}" +if [[ -z "$SOURCE_COMMIT" ]] && git -C "$REPO_ROOT" rev-parse HEAD >/dev/null 2>&1; then + SOURCE_COMMIT="$(git -C "$REPO_ROOT" rev-parse HEAD)" +fi +if [[ ! "$SOURCE_COMMIT" =~ ^[0-9a-f]{40}$ ]]; then + echo "SOURCE_COMMIT_REQUIRED" >&2 + exit 1 +fi +for relative in \ + "skills/shared/guanghu-ui-brain/SKILL.md" \ + "skills/shared/guanghu-ui-brain/BRAIN.hdlp" \ + "skills/shared/guanghu-client-ui-brain/SKILL.md" \ + "skills/shared/guanghu-client-ui-brain/BRAIN.hdlp" \ + "光之湖/ICE-GL-ZL-001-铸澜/WAKE.hdlp" \ + "光之湖/ICE-GL-ZL-001-铸澜/INDEX.hdlp" \ + "光之湖/ICE-GL-ZL-001-铸澜/CURRENT.hdlp" \ + "光之湖/ICE-GL-ZL-001-铸澜/PERMANENT-MEMORY-WRITE-PROTOCOL.hdlp"; do + source_file="$REPO_ROOT/$relative" + if [[ ! -f "$source_file" ]]; then + echo "CONTRACT_SOURCE_MISSING $relative" >&2 + exit 1 + fi + safe_name="$(printf '%s' "$relative" | tr '/ ' '__')" + install -o root -g root -m 0444 "$source_file" "$INSTALL_ROOT/contracts/$safe_name" +done + +printf '%s\n' "$SOURCE_COMMIT" >"$INSTALL_ROOT/contracts/REPOSITORY-SOURCE-SHA" +chmod 0444 "$INSTALL_ROOT/contracts/REPOSITORY-SOURCE-SHA" + +SECRET_FILE="$SECRET_ROOT/zhulan-remote-cell.secret" +if [[ ! -f "$SECRET_FILE" ]]; then + umask 0177 + openssl rand -hex 48 >"$SECRET_FILE" +fi +chown root:zhulan-runtime "$SECRET_FILE" +chmod 0640 "$SECRET_FILE" + +install -o root -g zhulan-runtime -m 0640 "$SOURCE_DIR/deploy/zhulan-remote-cell.env.example" "$ENV_FILE" +install -o root -g root -m 0644 "$SOURCE_DIR/deploy/zhulan-remote-cell.service" "$SERVICE_FILE" +install -o root -g root -m 0644 "$SOURCE_DIR/deploy/zhulan-validation-executor.service" "$EXECUTOR_SERVICE_FILE" +install -o root -g root -m 0644 "$SOURCE_DIR/deploy/zhulan-bwrap.apparmor" "$APPARMOR_FILE" +apparmor_parser -r "$APPARMOR_FILE" + +systemctl daemon-reload +systemctl enable zhulan-validation-executor.service +systemctl restart zhulan-validation-executor.service +for _ in $(seq 1 30); do + if [[ -S /run/guanghu/zhulan-validation/executor.sock ]]; then + break + fi + sleep 1 +done +if [[ ! -S /run/guanghu/zhulan-validation/executor.sock ]]; then + systemctl status zhulan-validation-executor.service --no-pager >&2 || true + echo "VALIDATION_EXECUTOR_HEALTH_FAILED" >&2 + exit 1 +fi +systemctl enable zhulan-remote-cell.service +systemctl restart zhulan-remote-cell.service + +for _ in $(seq 1 30); do + if curl --fail --silent --show-error --max-time 2 http://127.0.0.1:17631/health >"$STATE_ROOT/health.json.tmp"; then + mv "$STATE_ROOT/health.json.tmp" "$STATE_ROOT/health.json" + break + fi + sleep 1 +done + +if [[ ! -s "$STATE_ROOT/health.json" ]]; then + systemctl status zhulan-remote-cell.service --no-pager >&2 || true + echo "RUNTIME_HEALTH_FAILED" >&2 + exit 1 +fi + +SOURCE_SHA256="$(find \ + "$INSTALL_ROOT/runtime" \ + "$INSTALL_ROOT/ui" \ + "$INSTALL_ROOT/contracts" \ + "$INSTALL_ROOT/policy.json" \ + "$INSTALL_ROOT/README.md" \ + -type f -print0 | sort -z | xargs -0 sha256sum | sha256sum | awk '{print $1}')" +cat >"$STATE_ROOT/install-receipt.json.tmp" <&2 + exit 1 +fi + +FRONT_HOST="${1:-}" +KNOWN_HOSTS_SOURCE="${2:-}" +if [[ ! "$FRONT_HOST" =~ ^[0-9A-Fa-f:.]+$ ]]; then + echo "FRONT_HOST_MUST_BE_LITERAL_IP" >&2 + exit 1 +fi +if [[ -z "$KNOWN_HOSTS_SOURCE" || ! -f "$KNOWN_HOSTS_SOURCE" ]]; then + echo "KNOWN_HOSTS_SOURCE_INVALID" >&2 + exit 1 +fi + +EXPECTED_HOST="${ZHULAN_EXPECTED_HOST:-VM-12-12-ubuntu}" +EXPECTED_DMI="${ZHULAN_EXPECTED_DMI:-7a500f2d-9aed-4b93-b3b8-59c87c65d031}" +ACTUAL_HOST="$(hostname)" +ACTUAL_DMI="$(tr '[:upper:]' '[:lower:]' &2 + exit 1 +fi + +CONFIG_ROOT="/etc/guanghu/zhulan-front-tunnel" +KEY_FILE="$CONFIG_ROOT/id_ed25519" +KNOWN_HOSTS="$CONFIG_ROOT/known_hosts" +SSH_CONFIG="$CONFIG_ROOT/ssh_config" +SERVICE_FILE="/etc/systemd/system/zhulan-front-tunnel.service" +if [[ ! -f "$KEY_FILE" || ! -f "$KEY_FILE.pub" ]]; then + echo "TUNNEL_IDENTITY_NOT_PREPARED" >&2 + exit 1 +fi +if ! getent passwd zhulan-tunnel >/dev/null; then + echo "TUNNEL_USER_NOT_PREPARED" >&2 + exit 1 +fi + +install -o root -g zhulan-tunnel -m 0640 "$KNOWN_HOSTS_SOURCE" "$KNOWN_HOSTS" +if ! ssh-keygen -F "$FRONT_HOST" -f "$KNOWN_HOSTS" >/dev/null; then + echo "PINNED_FRONT_HOST_KEY_MISSING" >&2 + exit 1 +fi +EXPECTED_FRONT_HOST_KEY="${ZHULAN_EXPECTED_FRONT_HOST_KEY:-SHA256:P2EtuYFg8hptha4s0auP4yzp+Wg+H27bw4mdqzk1Cvk}" +ACTUAL_FRONT_HOST_KEY="$(ssh-keygen -F "$FRONT_HOST" -f "$KNOWN_HOSTS" | awk 'NF && $1 !~ /^#/ {print $2, $3}' | ssh-keygen -lf - 2>/dev/null | awk '{print $2}' | head -1)" +if [[ "$ACTUAL_FRONT_HOST_KEY" != "$EXPECTED_FRONT_HOST_KEY" ]]; then + echo "PINNED_FRONT_HOST_KEY_MISMATCH" >&2 + exit 1 +fi + +cat >"$SSH_CONFIG.tmp" <"$SERVICE_FILE.tmp" <<'EOF' +[Unit] +Description=Zhulan SG003 to GZ006 loopback-only reverse proxy tunnel +After=network-online.target zhulan-remote-cell.service +Wants=network-online.target +Requires=zhulan-remote-cell.service + +[Service] +Type=simple +User=zhulan-tunnel +Group=zhulan-tunnel +ExecStart=/usr/bin/ssh -F /etc/guanghu/zhulan-front-tunnel/ssh_config -NT guanghu-zhulan-front +Restart=always +RestartSec=5 +UMask=0077 +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +RestrictSUIDSGID=true +LockPersonality=true +MemoryDenyWriteExecute=true +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 +ReadOnlyPaths=/etc/guanghu/zhulan-front-tunnel +StateDirectory=guanghu/zhulan-front-tunnel + +[Install] +WantedBy=multi-user.target +EOF +chown root:root "$SERVICE_FILE.tmp" +chmod 0644 "$SERVICE_FILE.tmp" +mv "$SERVICE_FILE.tmp" "$SERVICE_FILE" + +systemctl daemon-reload +systemctl enable --now zhulan-front-tunnel.service +for _ in $(seq 1 20); do + if systemctl is-active --quiet zhulan-front-tunnel.service; then + systemctl show zhulan-front-tunnel.service -p ActiveState -p SubState -p MainPID + exit 0 + fi + sleep 1 +done +systemctl status zhulan-front-tunnel.service --no-pager >&2 || true +exit 1 diff --git a/server-tools/zhulan-remote-cell/deploy/prepare-tunnel-identity.sh b/server-tools/zhulan-remote-cell/deploy/prepare-tunnel-identity.sh new file mode 100755 index 0000000..9ea0479 --- /dev/null +++ b/server-tools/zhulan-remote-cell/deploy/prepare-tunnel-identity.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ "$(id -u)" -ne 0 ]]; then + echo "INSTALL_REQUIRES_ROOT" >&2 + exit 1 +fi + +EXPECTED_HOST="${ZHULAN_EXPECTED_HOST:-VM-12-12-ubuntu}" +EXPECTED_DMI="${ZHULAN_EXPECTED_DMI:-7a500f2d-9aed-4b93-b3b8-59c87c65d031}" +ACTUAL_HOST="$(hostname)" +ACTUAL_DMI="$(tr '[:upper:]' '[:lower:]' &2 + exit 1 +fi + +CONFIG_ROOT="/etc/guanghu/zhulan-front-tunnel" +KEY_FILE="$CONFIG_ROOT/id_ed25519" + +getent passwd zhulan-tunnel >/dev/null || { + echo "TUNNEL_USER_MUST_BE_PRECREATED" >&2 + exit 1 +} +install -d -o root -g zhulan-tunnel -m 0750 "$CONFIG_ROOT" +install -d -o zhulan-tunnel -g zhulan-tunnel -m 0700 /var/lib/guanghu/zhulan-front-tunnel + +if [[ ! -f "$KEY_FILE" ]]; then + ssh-keygen -q -t ed25519 -N '' -C 'zhulan-sg003-to-gz006-loopback-only' -f "$KEY_FILE" +fi +chown root:zhulan-tunnel "$KEY_FILE" "$KEY_FILE.pub" +chmod 0640 "$KEY_FILE" +chmod 0644 "$KEY_FILE.pub" + +ssh-keygen -lf "$KEY_FILE.pub" +echo "TUNNEL_PUBLIC_KEY_FILE=$KEY_FILE.pub" diff --git a/server-tools/zhulan-remote-cell/deploy/zhulan-bwrap.apparmor b/server-tools/zhulan-remote-cell/deploy/zhulan-bwrap.apparmor new file mode 100644 index 0000000..07fbfa7 --- /dev/null +++ b/server-tools/zhulan-remote-cell/deploy/zhulan-bwrap.apparmor @@ -0,0 +1,10 @@ +# Ubuntu 24.04 restricts unprivileged user namespaces unless the executable +# has an AppArmor profile that explicitly allows userns. This profile applies +# only to the root-owned Zhulan bwrap copy; that binary is executable only by +# root and the zhulan-runtime group. +abi , +include + +profile zhulan-remote-cell-bwrap /opt/guanghu/zhulan-remote-cell/runtime/zhulan-bwrap flags=(unconfined) { + userns, +} diff --git a/server-tools/zhulan-remote-cell/deploy/zhulan-remote-cell.env.example b/server-tools/zhulan-remote-cell/deploy/zhulan-remote-cell.env.example new file mode 100644 index 0000000..6b25a25 --- /dev/null +++ b/server-tools/zhulan-remote-cell/deploy/zhulan-remote-cell.env.example @@ -0,0 +1,17 @@ +ZHULAN_BIND=127.0.0.1 +ZHULAN_PORT=17631 +ZHULAN_DB=/var/lib/guanghu/zhulan-remote-cell/state.sqlite3 +ZHULAN_SECRET_FILE=/etc/guanghu/secrets/zhulan-remote-cell.secret +ZHULAN_POLICY=/opt/guanghu/zhulan-remote-cell/policy.json +ZHULAN_UI_DIR=/opt/guanghu/zhulan-remote-cell/ui +ZHULAN_WORKSPACE_ROOT=/srv/guanghu/zhulan-cell/workspaces +ZHULAN_CANDIDATE_ROOT=/srv/guanghu/zhulan-cell/candidates +ZHULAN_FORGEJO_VERIFY_URL=https://guanghulab.com/code/api/v1/user +ZHULAN_OWNER_LOGIN=bingshuo +ZHULAN_COOKIE_SECURE=1 +ZHULAN_COOKIE_PATH=/zhulan/ +ZHULAN_PUBLIC_CREATE_LIMIT=12 +ZHULAN_OWNER_LOGIN_LIMIT=8 +ZHULAN_OAUTH_REGISTER_LIMIT=20 +ZHULAN_BWRAP=/opt/guanghu/zhulan-remote-cell/runtime/zhulan-bwrap +ZHULAN_VALIDATION_SOCKET=/run/guanghu/zhulan-validation/executor.sock diff --git a/server-tools/zhulan-remote-cell/deploy/zhulan-remote-cell.service b/server-tools/zhulan-remote-cell/deploy/zhulan-remote-cell.service new file mode 100644 index 0000000..a271835 --- /dev/null +++ b/server-tools/zhulan-remote-cell/deploy/zhulan-remote-cell.service @@ -0,0 +1,37 @@ +[Unit] +Description=Guanghu Zhulan Restricted Remote Development Cell +After=network-online.target zhulan-validation-executor.service +Wants=network-online.target +Requires=zhulan-validation-executor.service + +[Service] +Type=simple +User=zhulan-runtime +Group=zhulan-runtime +EnvironmentFile=/etc/guanghu/zhulan-remote-cell.env +ExecStart=/usr/bin/python3 /opt/guanghu/zhulan-remote-cell/runtime/zhulan_cell.py +Restart=on-failure +RestartSec=3 +UMask=0077 +NoNewPrivileges=true +PrivateTmp=true +PrivateDevices=true +ProtectSystem=strict +ProtectHome=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +RestrictSUIDSGID=true +RestrictNamespaces=user mnt pid net ipc uts +LockPersonality=true +MemoryDenyWriteExecute=true +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 +SystemCallArchitectures=native +ReadOnlyPaths=/opt/guanghu/zhulan-remote-cell /etc/guanghu/zhulan-remote-cell.env /etc/guanghu/secrets/zhulan-remote-cell.secret +ReadWritePaths=/var/lib/guanghu/zhulan-remote-cell +ReadWritePaths=/srv/guanghu/zhulan-cell/workspaces +ReadWritePaths=/srv/guanghu/zhulan-cell/candidates +StateDirectory=guanghu/zhulan-remote-cell + +[Install] +WantedBy=multi-user.target diff --git a/server-tools/zhulan-remote-cell/deploy/zhulan-validation-executor.service b/server-tools/zhulan-remote-cell/deploy/zhulan-validation-executor.service new file mode 100644 index 0000000..7d7cae0 --- /dev/null +++ b/server-tools/zhulan-remote-cell/deploy/zhulan-validation-executor.service @@ -0,0 +1,37 @@ +[Unit] +Description=Zhulan local-only allowlisted validation executor +After=local-fs.target +Before=zhulan-remote-cell.service + +[Service] +Type=simple +User=zhulan-runtime +Group=zhulan-runtime +EnvironmentFile=/etc/guanghu/zhulan-remote-cell.env +ExecStart=/usr/bin/python3 /opt/guanghu/zhulan-remote-cell/runtime/zhulan_validation_executor.py +Restart=always +RestartSec=3 +UMask=0077 +PrivateTmp=true +PrivateDevices=true +PrivateNetwork=true +ProtectSystem=strict +ProtectHome=true +ProtectKernelModules=true +ProtectControlGroups=true +RestrictSUIDSGID=true +LockPersonality=true +MemoryDenyWriteExecute=true +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK +RestrictNamespaces=user mnt pid net ipc uts +SystemCallArchitectures=native +RuntimeDirectory=guanghu/zhulan-validation +RuntimeDirectoryMode=0700 +ReadOnlyPaths=/opt/guanghu/zhulan-remote-cell /etc/guanghu/zhulan-remote-cell.env +ReadOnlyPaths=/srv/guanghu/zhulan-cell/workspaces +InaccessiblePaths=/etc/guanghu/secrets +ReadOnlyPaths=/var/lib/guanghu/zhulan-remote-cell +InaccessiblePaths=/srv/guanghu/zhulan-cell/candidates + +[Install] +WantedBy=multi-user.target diff --git a/server-tools/zhulan-remote-cell/policy.example.json b/server-tools/zhulan-remote-cell/policy.example.json new file mode 100644 index 0000000..c41b902 --- /dev/null +++ b/server-tools/zhulan-remote-cell/policy.example.json @@ -0,0 +1,41 @@ +{ + "schema": "guanghu.zhulan-remote-cell-policy/v1", + "persona_id": "ICE-GL-ZL-001", + "node_id": "BS-SG-003", + "front_door": "https://guanghulab.com/zhulan/", + "default_ttl_seconds": 10800, + "max_ttl_seconds": 86400, + "max_request_paths": 24, + "max_changed_file_bytes": 20971520, + "max_read_file_bytes": 262144, + "workspace_root": "/srv/guanghu/zhulan-cell/workspaces", + "candidate_root": "/srv/guanghu/zhulan-cell/candidates", + "repository_base_url": "https://guanghulab.com/code/", + "allowed_actions": [ + "read", + "edit", + "test", + "commit", + "push_candidate" + ], + "repositories": { + "bingshuo/guanghu-ice-heart": { + "candidate_branch_prefix": "zhulan/", + "allowed_path_prefixes": ["."], + "validation_commands": [ + ["git", "diff", "--check"], + ["python3", "-m", "unittest", "discover", "-s", "server-tools/zhulan-remote-cell/tests", "-v"] + ] + }, + "bingshuo/hololake-system-architecture": { + "candidate_branch_prefix": "zhulan/", + "allowed_path_prefixes": ["."], + "validation_commands": [["git", "diff", "--check"]] + }, + "bingshuo/grok-build-upstream-mirror": { + "candidate_branch_prefix": "zhulan/", + "allowed_path_prefixes": ["."], + "validation_commands": [["git", "diff", "--check"]] + } + } +} diff --git a/server-tools/zhulan-remote-cell/runtime/zhulan_cell.py b/server-tools/zhulan-remote-cell/runtime/zhulan_cell.py new file mode 100644 index 0000000..eb3b24b --- /dev/null +++ b/server-tools/zhulan-remote-cell/runtime/zhulan_cell.py @@ -0,0 +1,2037 @@ +#!/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'' + for item in requests + ) + hidden = "".join( + f'' + for key, value in oauth.items() + ) + disabled = "" if requests else " disabled" + return f""" +铸澜 · 绑定插件连接 +
+

ZHULAN · OAUTH 2.1 · PKCE

把插件连接绑定到已批准边界

+

这不会把 SSH 密钥或临时能力交给手机。连接只能使用你选中的人格、仓库、候选分支、路径、动作和到期时间。

+
{hidden} + + +
+{'没有仍在有效期内的已批准申请。请返回审批端先批准一张申请。' if not requests else '到期、拒绝或扩大边界后,这个连接都会失效。'} +
""" + + 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() diff --git a/server-tools/zhulan-remote-cell/runtime/zhulan_code_gate.py b/server-tools/zhulan-remote-cell/runtime/zhulan_code_gate.py new file mode 100644 index 0000000..c525eaa --- /dev/null +++ b/server-tools/zhulan-remote-cell/runtime/zhulan_code_gate.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +"""铸澜工作区代码门:在 commit / candidate push 前校验能力与实际 Git 变化。""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import subprocess +import sys +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any + + +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,}"), +] + + +def run_git(workspace: Path, *args: str) -> str: + result = subprocess.run( + ["git", "-C", str(workspace), *args], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=30, + ) + return result.stdout.strip() + + +def within(path: str, prefixes: list[str]) -> bool: + return any(prefix == "." or path == prefix or path.startswith(prefix + "/") for prefix in prefixes) + + +def verify_remote(url: str, token: str, action: str) -> dict[str, Any]: + raw = json.dumps({"capability": token, "action": action}).encode("utf-8") + request = urllib.request.Request( + url.rstrip("/") + "/api/v1/runtime/verify", + data=raw, + headers={"Content-Type": "application/json", "User-Agent": "zhulan-code-gate/1"}, + ) + try: + with urllib.request.urlopen(request, timeout=8) as response: + return json.loads(response.read(262144)) + except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, ValueError) as exc: + raise RuntimeError("capability_verify_failed") from exc + + +def changed_files(workspace: Path, base_sha: str) -> list[str]: + run_git(workspace, "merge-base", "--is-ancestor", base_sha, "HEAD") + committed = run_git(workspace, "diff", "--name-only", f"{base_sha}...HEAD") + pending = run_git(workspace, "diff", "--name-only", "HEAD") + untracked = run_git(workspace, "ls-files", "--others", "--exclude-standard") + return sorted({line for block in (committed, pending, untracked) for line in block.splitlines() if line}) + + +def scan_file(workspace: Path, path: Path, max_bytes: int) -> list[str]: + errors: list[str] = [] + if path.is_symlink(): + target = path.resolve() + try: + target.relative_to(workspace) + except ValueError: + errors.append("symlink_outside_workspace") + return errors + if not path.is_file(): + return errors + size = path.stat().st_size + if size > max_bytes: + return [f"file_too_large:{size}"] + if size > 2_000_000: + return errors + raw = path.read_bytes() + for pattern in SECRET_PATTERNS: + if pattern.search(raw): + errors.append("possible_secret") + break + return errors + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--workspace", required=True) + parser.add_argument("--capability-file", required=True) + parser.add_argument("--action", choices=["edit", "test", "commit", "push_candidate"], required=True) + parser.add_argument("--verify-url", default=os.getenv("ZHULAN_VERIFY_URL", "http://127.0.0.1:17631")) + parser.add_argument("--max-file-bytes", type=int, default=20 * 1024 * 1024) + args = parser.parse_args() + + workspace = Path(args.workspace).resolve() + token = Path(args.capability_file).read_text(encoding="utf-8").strip() + verified = verify_remote(args.verify_url, token, args.action) + claims = verified["claims"] + errors: list[dict[str, str]] = [] + + if run_git(workspace, "rev-parse", "--is-inside-work-tree") != "true": + errors.append({"gate": "git", "error": "not_a_worktree"}) + branch = run_git(workspace, "branch", "--show-current") + if branch != claims["branch"]: + errors.append({"gate": "branch", "error": f"expected:{claims['branch']}:actual:{branch}"}) + + try: + files = changed_files(workspace, claims["base_sha"]) + except subprocess.CalledProcessError: + files = [] + errors.append({"gate": "base_sha", "error": "base_not_ancestor"}) + + for name in files: + if not within(name, claims["paths"]): + errors.append({"gate": "path", "error": name}) + continue + for finding in scan_file(workspace, workspace / name, args.max_file_bytes): + errors.append({"gate": finding, "error": name}) + + result = { + "schema": "guanghu.zhulan-code-gate-receipt/v1", + "decision": "PASS" if not errors else "FAIL", + "request_id": claims["request_id"], + "development_id": claims["development_id"], + "repository": claims["repository"], + "branch": branch, + "base_sha": claims["base_sha"], + "changed_files": files, + "errors": errors, + "workspace_fingerprint": hashlib.sha256(str(workspace).encode()).hexdigest(), + "capability_receipt": verified["receipt"], + } + print(json.dumps(result, ensure_ascii=False, sort_keys=True, indent=2)) + return 0 if not errors else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/server-tools/zhulan-remote-cell/runtime/zhulan_validation_executor.py b/server-tools/zhulan-remote-cell/runtime/zhulan_validation_executor.py new file mode 100644 index 0000000..365b658 --- /dev/null +++ b/server-tools/zhulan-remote-cell/runtime/zhulan_validation_executor.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 +"""Local-only allowlisted Bubblewrap validation broker for the Zhulan runtime.""" + +from __future__ import annotations + +import json +import os +import signal +import socketserver +import sqlite3 +import subprocess +import time +from pathlib import Path +from typing import Any + + +MAX_REQUEST_BYTES = 64 * 1024 + + +def safe_relative(value: Any) -> str: + if not isinstance(value, str): + raise PermissionError("validation_path_invalid") + raw = value.replace("\\", "/").strip("/") + if raw in {"", "."}: + return "." + parts = raw.split("/") + if any(part in {"", ".", ".."} for part in parts): + raise PermissionError("validation_path_invalid") + return "/".join(parts) + + +def path_within(path: str, prefix: str) -> bool: + return prefix == "." or path == prefix or path.startswith(prefix + "/") + + +class ValidationExecutor: + def __init__(self) -> None: + self.workspace_root = Path( + os.getenv("ZHULAN_WORKSPACE_ROOT", "/srv/guanghu/zhulan-cell/workspaces") + ).resolve() + self.policy_path = Path( + os.getenv("ZHULAN_POLICY", "/opt/guanghu/zhulan-remote-cell/policy.json") + ) + self.state_db = Path( + os.getenv("ZHULAN_DB", "/var/lib/guanghu/zhulan-remote-cell/state.sqlite3") + ) + self.bwrap = Path( + os.getenv( + "ZHULAN_BWRAP", "/opt/guanghu/zhulan-remote-cell/runtime/zhulan-bwrap" + ) + ) + + def policy(self) -> dict[str, Any]: + return json.loads(self.policy_path.read_text(encoding="utf-8")) + + def validate(self, request: dict[str, Any]) -> tuple[Path, list[str], list[str]]: + if set(request) != { + "operation", + "request_id", + "repository", + "development_id", + "workspace", + "paths", + "command", + }: + raise PermissionError("validation_request_fields_invalid") + if request.get("operation") != "run": + raise PermissionError("validation_operation_invalid") + repository = str(request.get("repository", "")) + request_id = str(request.get("request_id", "")) + development_id = str(request.get("development_id", "")) + command = request.get("command") + paths = request.get("paths") + if not isinstance(command, list) or not command or not all( + isinstance(item, str) for item in command + ): + raise PermissionError("validation_command_invalid") + if not isinstance(paths, list) or not paths: + raise PermissionError("validation_paths_invalid") + clean_paths = sorted(set(safe_relative(item) for item in paths)) + policy = self.policy() + repository_policy = policy.get("repositories", {}).get(repository) + if not isinstance(repository_policy, dict): + raise PermissionError("validation_repository_not_registered") + allowed = repository_policy.get("validation_commands", []) + if command not in allowed or command == ["git", "diff", "--check"]: + raise PermissionError("validation_command_not_registered_for_broker") + allowed_prefixes = [safe_relative(item) for item in repository_policy.get("allowed_path_prefixes", ["."])] + if any(not any(path_within(path, prefix) for prefix in allowed_prefixes) for path in clean_paths): + raise PermissionError("validation_path_outside_repository_policy") + uri = f"file:{self.state_db}?mode=ro" + with sqlite3.connect(uri, uri=True, timeout=5) as db: + db.row_factory = sqlite3.Row + row = db.execute( + """SELECT id, state, picked_up_at, capability_expires_at, + development_id, repository, paths_json, actions_json + FROM requests WHERE id=?""", + (request_id,), + ).fetchone() + if ( + not row + or row["state"] != "APPROVED" + or not row["picked_up_at"] + or int(row["capability_expires_at"] or 0) <= int(time.time()) + ): + raise PermissionError("validation_approval_not_active") + if ( + row["development_id"] != development_id + or row["repository"] != repository + or json.loads(row["paths_json"]) != clean_paths + or "test" not in json.loads(row["actions_json"]) + ): + raise PermissionError("validation_approval_binding_mismatch") + workspace = Path(str(request.get("workspace", ""))).resolve() + workspace.relative_to(self.workspace_root) + if ( + not workspace.is_dir() + or not (workspace / ".git").is_dir() + or (workspace / ".git").is_symlink() + ): + raise PermissionError("validation_workspace_invalid") + owner, name = repository.split("/", 1) + if ( + workspace.name != f"{owner}__{name}" + or workspace.parent.name != development_id + ): + raise PermissionError("validation_workspace_repository_mismatch") + if not self.bwrap.is_file(): + raise RuntimeError("validation_bwrap_missing") + return workspace, clean_paths, command + + @staticmethod + def projection(workspace: Path, paths: list[str]) -> list[str]: + if paths == ["."]: + return ["--ro-bind", str(workspace), "/workspace"] + result: list[str] = [] + created = {"/workspace"} + for relative in paths: + target = workspace / relative + destination = Path("/workspace") / relative + parents = [item for item in reversed(destination.parents) if str(item).startswith("/workspace")] + for parent in parents: + text = str(parent) + if text not in created: + result.extend(["--dir", text]) + created.add(text) + if target.exists(): + target.resolve().relative_to(workspace) + result.extend(["--ro-bind", str(target), str(destination)]) + elif str(destination) not in created: + result.extend(["--dir", str(destination)]) + created.add(str(destination)) + return result + + def execute(self, request: dict[str, Any]) -> dict[str, Any]: + if request.get("operation") == "ping": + if set(request) != {"operation"}: + raise PermissionError("validation_ping_fields_invalid") + return {"ok": True, "executor": "zhulan-validation"} + workspace, paths, command = self.validate(request) + argv = [ + str(self.bwrap), + "--die-with-parent", + "--new-session", + "--unshare-user", + "--unshare-pid", + "--unshare-net", + "--unshare-ipc", + "--unshare-uts", + "--ro-bind", + "/usr", + "/usr", + "--ro-bind", + "/bin", + "/bin", + "--ro-bind-try", + "/lib", + "/lib", + "--ro-bind-try", + "/lib64", + "/lib64", + "--proc", + "/proc", + "--dev", + "/dev", + "--tmpfs", + "/tmp", + "--dir", + "/home", + "--dir", + "/home/zhulan", + *self.projection(workspace, paths), + "--chdir", + "/workspace", + "--clearenv", + "--setenv", + "HOME", + "/home/zhulan", + "--setenv", + "PATH", + "/usr/local/bin:/usr/bin:/bin", + "--", + *command, + ] + result = subprocess.run( + argv, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + timeout=180, + env={"PATH": "/usr/local/bin:/usr/bin:/bin", "LANG": "C.UTF-8"}, + ) + return {"ok": True, "exit_code": result.returncode, "output": result.stdout[-12000:]} + + +class Handler(socketserver.StreamRequestHandler): + def handle(self) -> None: + raw = self.rfile.readline(MAX_REQUEST_BYTES + 1) + if not raw or len(raw) > MAX_REQUEST_BYTES: + return + try: + request = json.loads(raw) + if not isinstance(request, dict): + raise ValueError("request_not_object") + response = self.server.executor.execute(request) # type: ignore[attr-defined] + except Exception as exc: + response = {"ok": False, "error": str(exc)[:300]} + self.wfile.write((json.dumps(response, sort_keys=True) + "\n").encode("utf-8")) + + +class Server(socketserver.UnixStreamServer): + allow_reuse_address = True + + +def main() -> None: + socket_path = Path( + os.getenv( + "ZHULAN_VALIDATION_SOCKET", "/run/guanghu/zhulan-validation/executor.sock" + ) + ) + socket_path.parent.mkdir(parents=True, exist_ok=True) + if socket_path.exists(): + socket_path.unlink() + server = Server(str(socket_path), Handler) + server.executor = ValidationExecutor() # type: ignore[attr-defined] + os.chmod(socket_path, 0o600) + def stop(_signum: int, _frame: object) -> None: + server.server_close() + raise SystemExit(0) + + signal.signal(signal.SIGTERM, stop) + signal.signal(signal.SIGINT, stop) + try: + server.serve_forever() + finally: + server.server_close() + socket_path.unlink(missing_ok=True) + + +if __name__ == "__main__": + main() diff --git a/server-tools/zhulan-remote-cell/tests/test_code_gate.py b/server-tools/zhulan-remote-cell/tests/test_code_gate.py new file mode 100644 index 0000000..cb6b91f --- /dev/null +++ b/server-tools/zhulan-remote-cell/tests/test_code_gate.py @@ -0,0 +1,32 @@ +import tempfile +import unittest +from pathlib import Path + +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "runtime")) +from zhulan_code_gate import scan_file, within # noqa: E402 + + +class CodeGateTest(unittest.TestCase): + def test_paths_fail_closed(self): + self.assertTrue(within("server-tools/zhulan-remote-cell/ui/app.js", ["server-tools/zhulan-remote-cell"])) + self.assertFalse(within("server-tools/lake-lamp-authz/server.js", ["server-tools/zhulan-remote-cell"])) + + def test_private_key_is_rejected(self): + with tempfile.TemporaryDirectory() as root: + workspace = Path(root) + target = workspace / "leak.txt" + target.write_text("-----BEGIN OPENSSH PRIVATE KEY-----\nnot-real\n", encoding="utf-8") + self.assertIn("possible_secret", scan_file(workspace, target, 1024 * 1024)) + + def test_symlink_outside_workspace_is_rejected(self): + with tempfile.TemporaryDirectory() as root, tempfile.TemporaryDirectory() as outside: + workspace = Path(root) + target = workspace / "outside-link" + target.symlink_to(Path(outside) / "secret") + self.assertIn("symlink_outside_workspace", scan_file(workspace, target, 1024 * 1024)) + + +if __name__ == "__main__": + unittest.main() diff --git a/server-tools/zhulan-remote-cell/tests/test_deployment_contract.py b/server-tools/zhulan-remote-cell/tests/test_deployment_contract.py new file mode 100644 index 0000000..707daab --- /dev/null +++ b/server-tools/zhulan-remote-cell/tests/test_deployment_contract.py @@ -0,0 +1,59 @@ +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +class DeploymentContractTest(unittest.TestCase): + def test_runtime_upgrade_restarts_service(self): + installer = (ROOT / "deploy" / "install-runtime.sh").read_text(encoding="utf-8") + self.assertIn("systemctl enable zhulan-remote-cell.service", installer) + self.assertIn("systemctl restart zhulan-remote-cell.service", installer) + self.assertIn("systemctl restart zhulan-validation-executor.service", installer) + self.assertNotIn("systemctl enable --now zhulan-remote-cell.service", installer) + + def test_bwrap_exception_is_scoped_to_runtime_copy(self): + env = (ROOT / "deploy" / "zhulan-remote-cell.env.example").read_text(encoding="utf-8") + profile = (ROOT / "deploy" / "zhulan-bwrap.apparmor").read_text(encoding="utf-8") + installer = (ROOT / "deploy" / "install-runtime.sh").read_text(encoding="utf-8") + private_copy = "/opt/guanghu/zhulan-remote-cell/runtime/zhulan-bwrap" + self.assertIn(f"ZHULAN_BWRAP={private_copy}", env) + self.assertIn(f"profile zhulan-remote-cell-bwrap {private_copy}", profile) + self.assertIn(" userns,", profile) + self.assertIn('install -o root -g zhulan-runtime -m 0750 /usr/bin/bwrap', installer) + self.assertNotIn("kernel.apparmor_restrict_unprivileged_userns=0", installer) + self.assertNotIn("chmod 4755", installer) + + def test_only_executor_allows_bwrap_required_netlink_addition(self): + service = (ROOT / "deploy" / "zhulan-remote-cell.service").read_text(encoding="utf-8") + executor = (ROOT / "deploy" / "zhulan-validation-executor.service").read_text(encoding="utf-8") + self.assertIn( + "RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6", + service, + ) + self.assertNotIn("AF_NETLINK", service) + self.assertIn("RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK", executor) + self.assertIn("RestrictNamespaces=user mnt pid net ipc uts", service) + + def test_public_runtime_keeps_no_new_privileges_and_uses_local_executor(self): + public_service = (ROOT / "deploy" / "zhulan-remote-cell.service").read_text(encoding="utf-8") + executor_service = (ROOT / "deploy" / "zhulan-validation-executor.service").read_text(encoding="utf-8") + runtime = (ROOT / "runtime" / "zhulan_cell.py").read_text(encoding="utf-8") + self.assertIn("NoNewPrivileges=true", public_service) + self.assertIn("ProtectKernelTunables=true", public_service) + self.assertIn("Requires=zhulan-validation-executor.service", public_service) + self.assertNotIn("NoNewPrivileges=true", executor_service) + self.assertNotIn("ProtectKernelTunables=true", executor_service) + self.assertIn("RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK", executor_service) + self.assertIn("ReadOnlyPaths=/srv/guanghu/zhulan-cell/workspaces", executor_service) + self.assertIn("PrivateNetwork=true", executor_service) + self.assertIn("InaccessiblePaths=/etc/guanghu/secrets", executor_service) + self.assertIn("ReadOnlyPaths=/var/lib/guanghu/zhulan-remote-cell", executor_service) + self.assertIn("InaccessiblePaths=/srv/guanghu/zhulan-cell/candidates", executor_service) + self.assertIn("socket.AF_UNIX", runtime) + self.assertNotIn('argv = [\n str(self.settings.bwrap_path)', runtime) + + +if __name__ == "__main__": + unittest.main() diff --git a/server-tools/zhulan-remote-cell/tests/test_validation_executor.py b/server-tools/zhulan-remote-cell/tests/test_validation_executor.py new file mode 100644 index 0000000..42da6fb --- /dev/null +++ b/server-tools/zhulan-remote-cell/tests/test_validation_executor.py @@ -0,0 +1,129 @@ +import json +import sqlite3 +import tempfile +import time +import unittest +from pathlib import Path +from unittest.mock import patch + +from importlib.util import module_from_spec, spec_from_file_location + + +ROOT = Path(__file__).resolve().parents[1] +SPEC = spec_from_file_location( + "zhulan_validation_executor", + ROOT / "runtime" / "zhulan_validation_executor.py", +) +MODULE = module_from_spec(SPEC) +assert SPEC and SPEC.loader +SPEC.loader.exec_module(MODULE) + + +class ValidationExecutorTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + root = Path(self.temp.name) + workspace_root = root / "workspaces" + self.workspace = workspace_root / "DEV-20260813-099" / "bingshuo__guanghu-ice-heart" + (self.workspace / ".git").mkdir(parents=True) + self.policy = root / "policy.json" + self.policy.write_text( + json.dumps( + { + "repositories": { + "bingshuo/guanghu-ice-heart": { + "allowed_path_prefixes": ["server-tools/zhulan-remote-cell"], + "validation_commands": [["python3", "-m", "unittest"]], + } + } + } + ), + encoding="utf-8", + ) + self.bwrap = root / "zhulan-bwrap" + self.bwrap.write_text("test", encoding="utf-8") + self.state_db = root / "state.sqlite3" + with sqlite3.connect(self.state_db) as db: + db.execute( + """CREATE TABLE requests ( + id TEXT PRIMARY KEY, state TEXT, picked_up_at INTEGER, + capability_expires_at INTEGER, development_id TEXT, + repository TEXT, paths_json TEXT, actions_json TEXT + )""" + ) + db.execute( + "INSERT INTO requests VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ( + "ZLR-20260813-AAAAAAAAAA", + "APPROVED", + int(time.time()), + int(time.time()) + 3600, + "DEV-20260813-099", + "bingshuo/guanghu-ice-heart", + json.dumps(["server-tools/zhulan-remote-cell"]), + json.dumps(["read", "test"]), + ), + ) + with patch.dict( + "os.environ", + { + "ZHULAN_WORKSPACE_ROOT": str(workspace_root), + "ZHULAN_POLICY": str(self.policy), + "ZHULAN_BWRAP": str(self.bwrap), + "ZHULAN_DB": str(self.state_db), + }, + ): + self.executor = MODULE.ValidationExecutor() + + def tearDown(self): + self.temp.cleanup() + + def valid(self): + return { + "operation": "run", + "request_id": "ZLR-20260813-AAAAAAAAAA", + "repository": "bingshuo/guanghu-ice-heart", + "development_id": "DEV-20260813-099", + "workspace": str(self.workspace), + "paths": ["server-tools/zhulan-remote-cell"], + "command": ["python3", "-m", "unittest"], + } + + def test_accepts_only_exact_registered_request(self): + workspace, paths, command = self.executor.validate(self.valid()) + self.assertEqual(workspace, self.workspace.resolve()) + self.assertEqual(paths, ["server-tools/zhulan-remote-cell"]) + self.assertEqual(command, ["python3", "-m", "unittest"]) + + def test_rejects_extra_fields_arbitrary_command_and_path(self): + for key, value in [ + ("extra", True), + ("command", ["sh", "-c", "id"]), + ("paths", ["deployment"]), + ]: + request = self.valid() + request[key] = value + with self.assertRaises(PermissionError): + self.executor.validate(request) + + def test_rejects_workspace_repository_mismatch(self): + request = self.valid() + wrong = self.workspace.parent / "bingshuo__other" + (wrong / ".git").mkdir(parents=True) + request["workspace"] = str(wrong) + with self.assertRaises(PermissionError): + self.executor.validate(request) + + def test_rejects_inactive_or_mismatched_approval_binding(self): + for key, value in [ + ("request_id", "ZLR-20260813-BBBBBBBBBB"), + ("development_id", "DEV-20260813-098"), + ]: + request = self.valid() + request[key] = value + with self.assertRaises(PermissionError): + self.executor.validate(request) + + +if __name__ == "__main__": + unittest.main() diff --git a/server-tools/zhulan-remote-cell/tests/test_zhulan_cell.py b/server-tools/zhulan-remote-cell/tests/test_zhulan_cell.py new file mode 100644 index 0000000..2fe467c --- /dev/null +++ b/server-tools/zhulan-remote-cell/tests/test_zhulan_cell.py @@ -0,0 +1,589 @@ +import http.cookiejar +import base64 +import hashlib +import json +import os +import re +import socket +import subprocess +import tempfile +import time +import unittest +import urllib.error +import urllib.request +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +def free_port(): + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +class ZhulanCellTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.temp = tempfile.TemporaryDirectory() + temp_root = Path(cls.temp.name) + seed = temp_root / "seed" + forge_root = temp_root / "forge" + bare = forge_root / "bingshuo" / "guanghu-ice-heart.git" + allowed = seed / "server-tools" / "zhulan-remote-cell" + allowed.mkdir(parents=True) + (allowed / "hello.txt").write_text("before\n", encoding="utf-8") + subprocess.run(["git", "init", "-b", "main", str(seed)], check=True, capture_output=True) + subprocess.run(["git", "-C", str(seed), "add", "."], check=True, capture_output=True) + subprocess.run( + [ + "git", "-C", str(seed), + "-c", "user.name=Zhulan Test", + "-c", "user.email=zhulan-test@invalid", + "commit", "-m", "test source", + ], + check=True, + capture_output=True, + ) + cls.source_sha = subprocess.run( + ["git", "-C", str(seed), "rev-parse", "HEAD"], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + bare.parent.mkdir(parents=True) + subprocess.run(["git", "clone", "--bare", str(seed), str(bare)], check=True, capture_output=True) + policy = json.loads((ROOT / "policy.example.json").read_text(encoding="utf-8")) + policy["repository_base_url"] = forge_root.as_uri() + policy["repositories"]["bingshuo/guanghu-ice-heart"]["validation_commands"] = [ + ["git", "diff", "--check"] + ] + cls.policy_file = temp_root / "policy.json" + cls.policy_file.write_text(json.dumps(policy), encoding="utf-8") + cls.port = free_port() + env = os.environ.copy() + env.update( + { + "ZHULAN_BIND": "127.0.0.1", + "ZHULAN_PORT": str(cls.port), + "ZHULAN_DB": str(Path(cls.temp.name) / "state.sqlite3"), + "ZHULAN_SECRET_FILE": str(Path(cls.temp.name) / "missing-test-secret"), + "ZHULAN_POLICY": str(cls.policy_file), + "ZHULAN_UI_DIR": str(ROOT / "ui"), + "ZHULAN_WORKSPACE_ROOT": str(Path(cls.temp.name) / "workspaces"), + "ZHULAN_CANDIDATE_ROOT": str(Path(cls.temp.name) / "candidates"), + "ZHULAN_COOKIE_SECURE": "0", + "ZHULAN_COOKIE_PATH": "/", + "ZHULAN_TEST_MODE": "1", + "ZHULAN_TEST_OWNER_PASSWORD": "correct-horse-test-only", + } + ) + cls.process = subprocess.Popen( + ["python3", str(ROOT / "runtime" / "zhulan_cell.py")], + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + cls.base = f"http://127.0.0.1:{cls.port}" + deadline = time.time() + 8 + while time.time() < deadline: + try: + with urllib.request.urlopen(cls.base + "/health", timeout=0.5) as response: + if response.status == 200: + break + except Exception: + time.sleep(0.08) + else: + out, err = cls.process.communicate(timeout=2) + raise RuntimeError(f"test server did not start\n{out}\n{err}") + + @classmethod + def tearDownClass(cls): + cls.process.terminate() + cls.process.wait(timeout=5) + if cls.process.stdout: + cls.process.stdout.close() + if cls.process.stderr: + cls.process.stderr.close() + cls.temp.cleanup() + + def setUp(self): + self.jar = http.cookiejar.CookieJar() + self.opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(self.jar)) + + def request(self, path, body=None, headers=None, expected=200, form=False): + if form and body is not None: + from urllib.parse import urlencode + + raw = urlencode(body).encode() + else: + raw = json.dumps(body).encode() if body is not None else None + request = urllib.request.Request( + self.base + path, + data=raw, + headers={"Content-Type": "application/x-www-form-urlencoded" if form else "application/json", **(headers or {})}, + ) + try: + with self.opener.open(request, timeout=3) as response: + self.assertEqual(response.status, expected) + return json.loads(response.read()) + except urllib.error.HTTPError as exc: + with exc: + self.assertEqual(exc.code, expected) + return json.loads(exc.read()) + + def create( + self, + dev="DEV-20260813-005", + slug="approval-ui", + base_sha="5f9e83e1b716fd50ee2880b24ea47f83b75d1f8f", + ): + return self.request( + "/api/v1/public/requests", + { + "persona_id": "ICE-GL-ZL-001", + "development_id": dev, + "repository": "bingshuo/guanghu-ice-heart", + "base_sha": base_sha, + "branch": f"zhulan/{dev}/{slug}", + "paths": ["server-tools/zhulan-remote-cell"], + "actions": ["read", "edit", "test", "commit", "push_candidate"], + "description": "完善铸澜手机审批入口与代码门禁", + }, + expected=201, + ) + + def login(self): + result = self.request( + "/api/v1/owner/login", + {"username": "bingshuo", "password": "correct-horse-test-only"}, + ) + return result["csrf"] + + def approved_oauth_token(self, created): + csrf = self.login() + self.request( + f"/api/v1/owner/requests/{created['request_id']}/approve", + {"ttl_seconds": 10800}, + headers={"X-CSRF-Token": csrf}, + ) + registered = self.request( + "/oauth/register", + { + "client_name": "ChatGPT test connector", + "redirect_uris": ["https://chatgpt.com/connector/oauth/test-callback"], + "token_endpoint_auth_method": "none", + }, + expected=201, + ) + verifier = "v" * 64 + challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).decode().rstrip("=") + oauth = { + "client_id": registered["client_id"], + "redirect_uri": registered["redirect_uris"][0], + "state": "state-test-123", + "code_challenge": challenge, + "resource": "https://guanghulab.com/zhulan/mcp", + "scope": "zhulan.develop", + "csrf": self.request("/api/v1/owner/session")["csrf"], + "request_id": created["request_id"], + } + + class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + + request = urllib.request.Request( + self.base + "/oauth/authorize", + data=__import__("urllib.parse").parse.urlencode(oauth).encode(), + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + no_redirect = urllib.request.build_opener( + urllib.request.HTTPCookieProcessor(self.jar), NoRedirect() + ) + try: + no_redirect.open(request, timeout=3) + self.fail("authorize should redirect") + except urllib.error.HTTPError as exc: + with exc: + self.assertEqual(exc.code, 302) + location = exc.headers["Location"] + from urllib.parse import parse_qs, urlparse + + code = parse_qs(urlparse(location).query)["code"][0] + token = self.request( + "/oauth/token", + { + "grant_type": "authorization_code", + "code": code, + "client_id": registered["client_id"], + "redirect_uri": registered["redirect_uris"][0], + "code_verifier": verifier, + "resource": "https://guanghulab.com/zhulan/mcp", + }, + form=True, + ) + return token["access_token"] + + def mcp_tool(self, token, name, arguments=None): + response = self.request( + "/mcp", + { + "jsonrpc": "2.0", + "id": 91, + "method": "tools/call", + "params": {"name": name, "arguments": arguments or {}}, + }, + headers={"Authorization": f"Bearer {token}"}, + ) + return response["result"] + + def test_public_config_declares_front_and_runtime(self): + config = self.request("/api/v1/public/config") + self.assertEqual(config["runtime_node"], "BS-SG-003") + self.assertIn("BS-GZ-006", config["topology"]["front"]) + self.assertIn("GHS-012", [x["id"] for x in config["ui_brains"]]) + + def test_wrong_persona_is_denied(self): + result = self.request( + "/api/v1/public/requests", + { + "persona_id": "ICE-GL-ZY001", + "development_id": "DEV-20260813-005", + "repository": "bingshuo/guanghu-ice-heart", + "base_sha": "5f9e83e1b716fd50ee2880b24ea47f83b75d1f8f", + "branch": "zhulan/DEV-20260813-005/nope", + "paths": ["server-tools"], + "actions": ["read"], + "description": "错误人格不得进入铸澜车道", + }, + expected=400, + ) + self.assertEqual(result["error"], "persona_not_allowed") + + def test_approval_pickup_and_capability_verification(self): + created = self.create(slug=f"flow-{int(time.time())}") + self.assertRegex(created["request_id"], r"^ZLR-[0-9]{8}-[A-F0-9]{10}$") + status = self.request( + f"/api/v1/public/requests/{created['request_id']}/status", + {"claim_token": created["claim_token"]}, + ) + self.assertEqual(status["state"], "PENDING") + + csrf = self.login() + listing = self.request("/api/v1/owner/requests") + self.assertTrue(any(item["id"] == created["request_id"] for item in listing["requests"])) + approved = self.request( + f"/api/v1/owner/requests/{created['request_id']}/approve", + {"ttl_seconds": 10800}, + headers={"X-CSRF-Token": csrf}, + ) + self.assertEqual(approved["state"], "APPROVED") + + pickup = self.request( + f"/api/v1/public/requests/{created['request_id']}/pickup", + {"claim_token": created["claim_token"]}, + ) + verified = self.request( + "/api/v1/runtime/verify", + {"capability": pickup["capability"], "action": "push_candidate"}, + ) + self.assertEqual(verified["claims"]["branch"], status["branch"]) + self.assertEqual(verified["claims"]["node_id"], "BS-SG-003") + + second = self.request( + f"/api/v1/public/requests/{created['request_id']}/pickup", + {"claim_token": created["claim_token"]}, + expected=409, + ) + self.assertEqual(second["error"], "capability_already_picked_up") + + filtered = self.request(f"/api/v1/owner/requests/{created['request_id']}/receipts") + self.assertGreaterEqual(len(filtered["receipts"]), 4) + receipts = self.request("/api/v1/owner/receipts") + previous = "0" * 64 + for receipt in receipts["receipts"]: + self.assertEqual(receipt["previous_hash"], previous) + previous = receipt["receipt_hash"] + + def test_csrf_is_required_for_decision(self): + created = self.create(slug=f"csrf-{int(time.time())}") + self.login() + result = self.request( + f"/api/v1/owner/requests/{created['request_id']}/approve", + {"ttl_seconds": 10800}, + expected=403, + ) + self.assertEqual(result["error"], "csrf_invalid") + + def test_owner_can_revoke_active_oauth_and_capability(self): + created = self.create(slug=f"revoke-{int(time.time())}") + token = self.approved_oauth_token(created) + restored = self.mcp_tool(token, "zhulan_restore_lane") + self.assertFalse(restored["isError"]) + session = self.request("/api/v1/owner/session") + revoked = self.request( + f"/api/v1/owner/requests/{created['request_id']}/revoke", + {"reason": "test owner stop"}, + headers={"X-CSRF-Token": session["csrf"]}, + ) + self.assertEqual(revoked["state"], "REVOKED") + denied = self.mcp_tool(token, "zhulan_restore_lane") + self.assertTrue(denied["isError"]) + self.assertIn("mcp/www_authenticate", denied["_meta"]) + + def test_mcp_requires_oauth_for_development_tools(self): + response = self.request( + "/mcp", + { + "jsonrpc": "2.0", + "id": 4, + "method": "tools/call", + "params": {"name": "zhulan_restore_lane", "arguments": {}}, + }, + ) + result = response["result"] + self.assertTrue(result["isError"]) + self.assertIn("mcp/www_authenticate", result["_meta"]) + + tools = self.request( + "/mcp", {"jsonrpc": "2.0", "id": 5, "method": "tools/list", "params": {}} + )["result"]["tools"] + protected = next(tool for tool in tools if tool["name"] == "zhulan_restore_lane") + public = next(tool for tool in tools if tool["name"] == "zhulan_request_development") + self.assertEqual(protected["securitySchemes"][0]["type"], "oauth2") + self.assertEqual(public["securitySchemes"][0]["type"], "noauth") + self.assertNotIn("capability", protected["inputSchema"]["properties"]) + + def test_oauth_pkce_binds_to_approved_request_and_code_is_single_use(self): + created = self.create(slug=f"oauth-{int(time.time())}") + csrf = self.login() + self.request( + f"/api/v1/owner/requests/{created['request_id']}/approve", + {"ttl_seconds": 10800}, + headers={"X-CSRF-Token": csrf}, + ) + registered = self.request( + "/oauth/register", + { + "client_name": "ChatGPT test connector", + "redirect_uris": ["https://chatgpt.com/connector/oauth/test-callback"], + "token_endpoint_auth_method": "none", + }, + expected=201, + ) + verifier = "v" * 64 + challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).decode().rstrip("=") + oauth = { + "client_id": registered["client_id"], + "redirect_uri": registered["redirect_uris"][0], + "state": "state-test-123", + "code_challenge": challenge, + "resource": "https://guanghulab.com/zhulan/mcp", + "scope": "zhulan.develop", + } + session = self.request("/api/v1/owner/session") + oauth["csrf"] = session["csrf"] + oauth["request_id"] = created["request_id"] + request = urllib.request.Request( + self.base + "/oauth/authorize", + data=__import__("urllib.parse").parse.urlencode(oauth).encode(), + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + no_redirect = urllib.request.build_opener( + urllib.request.HTTPCookieProcessor(self.jar), + urllib.request.HTTPHandler(), + ) + + class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + + no_redirect = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(self.jar), NoRedirect()) + try: + no_redirect.open(request, timeout=3) + self.fail("authorize should redirect") + except urllib.error.HTTPError as exc: + with exc: + self.assertEqual(exc.code, 302) + location = exc.headers["Location"] + from urllib.parse import parse_qs, urlparse + + code = parse_qs(urlparse(location).query)["code"][0] + token_request = { + "grant_type": "authorization_code", + "code": code, + "client_id": registered["client_id"], + "redirect_uri": registered["redirect_uris"][0], + "code_verifier": verifier, + "resource": "https://guanghulab.com/zhulan/mcp", + } + token = self.request("/oauth/token", token_request, form=True) + self.assertEqual(token["token_type"], "Bearer") + replay = self.request("/oauth/token", token_request, expected=403, form=True) + self.assertEqual(replay["error"], "invalid_grant") + + restored = self.request( + "/mcp", + { + "jsonrpc": "2.0", + "id": 7, + "method": "tools/call", + "params": {"name": "zhulan_restore_lane", "arguments": {}}, + }, + headers={"Authorization": f"Bearer {token['access_token']}"}, + ) + content = restored["result"]["structuredContent"] + self.assertEqual(content["development_id"], "DEV-20260813-005") + self.assertEqual(content["topology"]["front"], "BS-GZ-006_PROXY_ONLY") + + def test_oauth_registration_rejects_untrusted_callback(self): + result = self.request( + "/oauth/register", + { + "client_name": "evil", + "redirect_uris": ["https://example.com/steal"], + "token_endpoint_auth_method": "none", + }, + expected=400, + ) + self.assertEqual(result["error"], "redirect_uri_not_allowed") + + def test_existing_workspace_binding_mismatch_fails_before_git_changes(self): + created = self.create( + dev="DEV-20260813-098", slug="binding", base_sha=self.source_sha + ) + token = self.approved_oauth_token(created) + prepared = self.mcp_tool(token, "zhulan_prepare_workspace")["structuredContent"] + workspace = ( + Path(self.temp.name) + / "workspaces" + / "DEV-20260813-098" + / "bingshuo__guanghu-ice-heart" + ) + before = subprocess.run( + ["git", "-C", str(workspace), "rev-parse", "HEAD"], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + binding = workspace / ".git" / "zhulan-binding.json" + altered = json.loads(binding.read_text(encoding="utf-8")) + altered["branch"] = "zhulan/DEV-20260813-098/tampered" + binding.write_text(json.dumps(altered), encoding="utf-8") + denied = self.mcp_tool(token, "zhulan_prepare_workspace") + self.assertTrue(denied["isError"]) + self.assertEqual(denied["structuredContent"]["error"], "workspace_binding_mismatch") + after = subprocess.run( + ["git", "-C", str(workspace), "rev-parse", "HEAD"], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + self.assertEqual(before, prepared["head"]) + self.assertEqual(after, before) + + def test_exact_reapproval_resumes_same_workspace(self): + dev = "DEV-20260813-097" + first = self.create(dev=dev, slug="renew", base_sha=self.source_sha) + first_token = self.approved_oauth_token(first) + first_prepared = self.mcp_tool(first_token, "zhulan_prepare_workspace")[ + "structuredContent" + ] + second = self.create(dev=dev, slug="renew", base_sha=self.source_sha) + second_token = self.approved_oauth_token(second) + resumed = self.mcp_tool(second_token, "zhulan_prepare_workspace")[ + "structuredContent" + ] + self.assertEqual(resumed["head"], first_prepared["head"]) + binding = ( + Path(self.temp.name) + / "workspaces" + / dev + / "bingshuo__guanghu-ice-heart" + / ".git" + / "zhulan-binding.json" + ) + rebound = json.loads(binding.read_text(encoding="utf-8")) + self.assertEqual(rebound["request_id"], second["request_id"]) + self.assertEqual(rebound["renewed_from_request_id"], first["request_id"]) + + def test_full_workspace_candidate_flow_is_scoped_and_repeatable(self): + dev = "DEV-20260813-099" + created = self.request( + "/api/v1/public/requests", + { + "persona_id": "ICE-GL-ZL-001", + "development_id": dev, + "repository": "bingshuo/guanghu-ice-heart", + "base_sha": self.source_sha, + "branch": f"zhulan/{dev}/full-flow", + "paths": ["server-tools/zhulan-remote-cell"], + "actions": ["read", "edit", "test", "commit", "push_candidate"], + "description": "端到端验证受限工作区和内部候选复核库", + }, + expected=201, + ) + token = self.approved_oauth_token(created) + + prepared = self.mcp_tool(token, "zhulan_prepare_workspace")["structuredContent"] + self.assertEqual(prepared["head"], self.source_sha) + self.assertEqual(prepared["candidate_store"], "BS-SG-003_INTERNAL_REVIEW_ONLY") + + read = self.mcp_tool( + token, + "zhulan_read_file", + {"path": "server-tools/zhulan-remote-cell/hello.txt"}, + )["structuredContent"] + self.assertEqual(read["content"], "before\n") + written = self.mcp_tool( + token, + "zhulan_write_file", + { + "path": "server-tools/zhulan-remote-cell/hello.txt", + "content": "after\n", + "expected_sha256": read["sha256"], + }, + )["structuredContent"] + self.assertEqual(written["sha256"], hashlib.sha256(b"after\n").hexdigest()) + + escaped = self.mcp_tool( + token, + "zhulan_write_file", + {"path": "../escape", "content": "no", "expected_sha256": ""}, + ) + self.assertTrue(escaped["isError"]) + self.assertEqual(escaped["structuredContent"]["error"], "path_traversal") + + initial_validation = self.mcp_tool(token, "zhulan_run_validation")["structuredContent"] + self.assertEqual(initial_validation["decision"], "PASS") + self.assertFalse(initial_validation["clean"]) + committed = self.mcp_tool( + token, "zhulan_commit_candidate", {"message": "test: update bounded file"} + )["structuredContent"] + + premature = self.mcp_tool(token, "zhulan_push_candidate") + self.assertTrue(premature["isError"]) + final_validation = self.mcp_tool(token, "zhulan_run_validation")["structuredContent"] + self.assertTrue(final_validation["clean"]) + self.assertEqual(final_validation["head"], committed["commit_sha"]) + pushed = self.mcp_tool(token, "zhulan_push_candidate")["structuredContent"] + self.assertEqual(pushed["commit_sha"], committed["commit_sha"]) + self.assertEqual(pushed["central_publication"], "NOT_PERFORMED_REQUIRES_ZHUYUAN_REVIEW") + + prepared_again = self.mcp_tool(token, "zhulan_prepare_workspace")["structuredContent"] + self.assertEqual(prepared_again["head"], committed["commit_sha"]) + candidate = Path(self.temp.name) / "candidates" / "bingshuo__guanghu-ice-heart.git" + remote_sha = subprocess.run( + ["git", "--git-dir", str(candidate), "rev-parse", f"refs/heads/zhulan/{dev}/full-flow"], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + self.assertEqual(remote_sha, committed["commit_sha"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/server-tools/zhulan-remote-cell/ui/app.js b/server-tools/zhulan-remote-cell/ui/app.js new file mode 100644 index 0000000..538f407 --- /dev/null +++ b/server-tools/zhulan-remote-cell/ui/app.js @@ -0,0 +1,213 @@ +const $ = (id) => document.getElementById(id); +const state = { csrf: "", requests: [], selected: null }; +const eventNames = { + request_created: "铸澜提交申请", + human_approved: "主人批准边界", + human_rejected: "主人拒绝申请", + human_revoked: "主人撤销能力", + capability_picked_up: "铸澜领取临时能力", + capability_verified: "运行层校验能力", + capability_expired: "临时能力到期", + request_expired: "申请单到期" +}; + +async function api(path, options = {}) { + const headers = { "Content-Type": "application/json", ...(options.headers || {}) }; + const response = await fetch(path, { credentials: "same-origin", ...options, headers }); + let data = {}; + try { data = await response.json(); } catch (_) {} + if (!response.ok) throw new Error(data.error || `HTTP_${response.status}`); + return data; +} + +function toast(message) { + const node = $("toast"); + node.textContent = message; + node.classList.add("show"); + window.setTimeout(() => node.classList.remove("show"), 2600); +} + +function humanAction(value) { + return ({ read: "读取", edit: "编辑", test: "运行测试", commit: "提交", push_candidate: "推送候选分支" })[value] || value; +} + +function stateLabel(value) { + return ({ PENDING: "等待批准", APPROVED: "已批准", REJECTED: "已拒绝", REVOKED: "已撤销", EXPIRED: "已过期" })[value] || value; +} + +function renderRequest(request) { + state.selected = request; + $("empty-state").classList.add("hidden"); + $("request-card").classList.remove("hidden"); + $("request-id").textContent = request.id; + $("request-description").textContent = request.description; + $("persona").textContent = request.persona_id; + $("development-id").textContent = request.development_id; + $("repository").textContent = request.repository; + $("branch").textContent = request.branch; + $("base-sha").textContent = request.base_sha; + $("actions").replaceChildren(...request.actions.map((action) => { + const node = document.createElement("span"); + node.className = "chip"; + node.textContent = humanAction(action); + return node; + })); + $("paths").replaceChildren(...request.paths.map((path) => { + const node = document.createElement("span"); + node.className = "path"; + node.textContent = path; + return node; + })); + const label = stateLabel(request.state); + for (const id of ["request-state", "card-state"]) { + const node = $(id); + node.textContent = label; + node.className = `state ${request.state === "APPROVED" ? "approved" : request.state === "REJECTED" ? "rejected" : "waiting"}`; + } + $("decision-area").classList.toggle("hidden", request.state !== "PENDING"); + $("revoke-area").classList.toggle("hidden", request.state !== "APPROVED"); + loadReceipts(request.id); +} + +function renderEmpty(message, detail) { + state.selected = null; + $("request-card").classList.add("hidden"); + const empty = $("empty-state"); + empty.classList.remove("hidden"); + empty.querySelector("h3").textContent = message; + empty.querySelector("p").textContent = detail; + $("request-state").textContent = "湖面安静"; +} + +async function loadReceipts(requestId) { + try { + const data = await api(`api/v1/owner/requests/${encodeURIComponent(requestId)}/receipts`); + const nodes = data.receipts.map((receipt) => { + const li = document.createElement("li"); + li.className = receipt.result === "PASS" || receipt.result === "APPROVED" || receipt.result === "PENDING" ? "pass" : "fail"; + const dot = document.createElement("i"); + const body = document.createElement("div"); + const title = document.createElement("strong"); + title.textContent = eventNames[receipt.event] || receipt.event; + const detail = document.createElement("span"); + detail.textContent = `${receipt.created_at} · ${receipt.result} · ${receipt.receipt_hash.slice(0, 12)}`; + body.append(title, detail); + li.append(dot, body); + return li; + }); + $("timeline").replaceChildren(...nodes); + } catch (_) { + $("timeline").innerHTML = "
  • 回执读取失败运行层没有返回可验证结果。
  • "; + } +} + +async function loadRequests(preferredId = "") { + const data = await api("api/v1/owner/requests"); + state.requests = data.requests; + const preferred = preferredId && data.requests.find((item) => item.id === preferredId); + const pending = data.requests.find((item) => item.state === "PENDING"); + const selected = preferred || pending || data.requests[0]; + if (selected) renderRequest(selected); + else renderEmpty("暂无真实申请", "铸澜提交结构化申请后会出现在这里;公开建单本身没有执行权。"); +} + +async function restoreSession() { + try { + const session = await api("api/v1/owner/session"); + state.csrf = session.csrf; + $("auth-title").textContent = `主人 ${session.owner_login} · 已验证`; + $("auth-detail").textContent = "审批会话有效。任何扩大仓库、路径、动作或期限的请求都必须重新批准。"; + $("login-open").classList.add("hidden"); + $("logout").classList.remove("hidden"); + const preferred = new URLSearchParams(location.search).get("request") || ""; + await loadRequests(preferred); + const oauthReturn = new URLSearchParams(location.search).get("oauth_return"); + if (oauthReturn) { + try { + const normalized = oauthReturn.replace(/-/g, "+").replace(/_/g, "/"); + const decoded = atob(normalized + "=".repeat((4 - normalized.length % 4) % 4)); + if (decoded.startsWith("/oauth/authorize?")) location.assign(`/zhulan${decoded}`); + } catch (_) {} + } + } catch (_) { + state.csrf = ""; + $("login-open").classList.remove("hidden"); + $("logout").classList.add("hidden"); + } +} + +$("login-open").addEventListener("click", () => $("login-dialog").showModal()); +$("login-form").addEventListener("submit", async (event) => { + event.preventDefault(); + $("login-error").textContent = ""; + const button = event.currentTarget.querySelector("button[type=submit]"); + button.disabled = true; + try { + const result = await api("api/v1/owner/login", { + method: "POST", + body: JSON.stringify({ username: $("username").value, password: $("password").value }) + }); + state.csrf = result.csrf; + $("password").value = ""; + $("login-dialog").close(); + toast("代码频道身份验证通过"); + await restoreSession(); + } catch (_) { + $("login-error").textContent = "账号验证失败。没有创建审批会话。"; + } finally { button.disabled = false; } +}); + +$("approve").addEventListener("click", async () => { + if (!state.selected || state.selected.state !== "PENDING") return; + const exact = `${state.selected.repository}\n${state.selected.branch}\n${state.selected.paths.join(",")}`; + if (!window.confirm(`批准这一段开发?\n\n${exact}\n\n批准不会开放 root 或任意 shell。`)) return; + $("approve").disabled = true; + try { + await api(`api/v1/owner/requests/${state.selected.id}/approve`, { + method: "POST", + headers: { "X-CSRF-Token": state.csrf }, + body: JSON.stringify({ ttl_seconds: Number($("ttl").value) }) + }); + toast("已批准。铸澜可领取一次临时能力。"); + await restoreSession(); + } catch (error) { toast(`批准失败:${error.message}`); } + finally { $("approve").disabled = false; } +}); + +$("reject").addEventListener("click", async () => { + if (!state.selected || state.selected.state !== "PENDING") return; + const reason = window.prompt("写一句拒绝原因,铸澜会据此修正申请:", "范围需要重新确认"); + if (reason === null) return; + try { + await api(`api/v1/owner/requests/${state.selected.id}/reject`, { + method: "POST", + headers: { "X-CSRF-Token": state.csrf }, + body: JSON.stringify({ reason }) + }); + toast("已拒绝并留下回执。"); + await restoreSession(); + } catch (error) { toast(`拒绝失败:${error.message}`); } +}); + +$("revoke").addEventListener("click", async () => { + if (!state.selected || state.selected.state !== "APPROVED") return; + if (!window.confirm("立即撤销这段开发能力?已连接会话会在下一次调用时被服务器驳回。")) return; + try { + await api(`api/v1/owner/requests/${state.selected.id}/revoke`, { + method: "POST", + headers: { "X-CSRF-Token": state.csrf }, + body: JSON.stringify({ reason: "主人在审批端主动撤销" }) + }); + toast("能力已撤销,原连接不再有效。"); + await restoreSession(); + } catch (error) { toast(`撤销失败:${error.message}`); } +}); + +$("logout").addEventListener("click", async () => { + try { + await api("api/v1/owner/logout", { method: "POST", headers: { "X-CSRF-Token": state.csrf }, body: "{}" }); + } catch (_) {} + location.reload(); +}); + +restoreSession(); diff --git a/server-tools/zhulan-remote-cell/ui/index.html b/server-tools/zhulan-remote-cell/ui/index.html new file mode 100644 index 0000000..34c8818 --- /dev/null +++ b/server-tools/zhulan-remote-cell/ui/index.html @@ -0,0 +1,144 @@ + + + + + + + 铸澜 · 远程开发入口 + + + + +
    + 光湖 + 返回国内入口 +
    + +
    +
    +

    ZHULAN · HUMAN APPROVAL

    +

    铸澜 · 远程开发入口

    +

    广州只提供域名、TLS 与反向代理;本页面和你看到的每一项申请、批准、回执,都来自新加坡真实运行单元。

    +
    + 广州 仅域名 · TLS · 反向代理 + 新加坡 真实 UI · 审批 · 能力 · 沙箱 · 门禁 · 回执 +
    +
    + +
    +
    +

    HUMAN IDENTITY

    + 主人尚未登录 + 使用光湖代码频道账号完成一次验证,密码不会保存。 +
    + + +
    + +
    +
    +
    +

    CURRENT REQUEST

    +

    当前开发申请

    +
    + 等待登录 +
    + +
    + +

    登录后读取真实申请

    +

    这里不会生成演示工单。没有来自铸澜的真实申请时,湖面保持安静。

    +
    + + +
    + +
    +
    +
    +

    LIVE RECEIPTS

    +

    同一条真实回执链

    +
    +
    +
      +
    1. 尚未读取登录后显示新加坡运行层的真实事件。
    2. +
    +
    + + +
    + + +
    + +

    HUMAN IDENTITY

    +

    登录人类审批端

    +

    使用光湖代码频道主人账号。密码只参与这一次验证,不写入服务器状态。

    + + + + +

    + +
    +
    + +
    +
    + 铸澜 ICE-GL-ZL-001 · 私有审批投影 + 前门 ≠ 运行体 · UI ≠ 权限 +
    + + + diff --git a/server-tools/zhulan-remote-cell/ui/styles.css b/server-tools/zhulan-remote-cell/ui/styles.css new file mode 100644 index 0000000..042cf86 --- /dev/null +++ b/server-tools/zhulan-remote-cell/ui/styles.css @@ -0,0 +1,205 @@ +:root { + color-scheme: dark; + --ink: #070916; + --ink-soft: #0c1025; + --panel: rgba(16, 20, 47, .76); + --panel-strong: rgba(20, 25, 58, .94); + --line: rgba(226, 231, 255, .13); + --line-bright: rgba(236, 239, 255, .28); + --text: #f2f1ed; + --muted: #9fa6bf; + --quiet: #69718f; + --star: #efe7d5; + --violet: #a6a5ff; + --lake: #86b9de; + --ok: #9cd9bd; + --warn: #e6c68d; + --bad: #e19791; + --shadow: 0 30px 80px rgba(0, 0, 0, .38); + font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "PingFang SC", "Microsoft YaHei", sans-serif; +} + +* { box-sizing: border-box; } +html { min-height: 100%; background: var(--ink); } +body { + min-height: 100vh; + margin: 0; + color: var(--text); + background: + radial-gradient(circle at 14% 9%, rgba(99, 95, 185, .18), transparent 31%), + radial-gradient(circle at 84% 20%, rgba(73, 124, 176, .13), transparent 28%), + radial-gradient(ellipse at 50% 105%, rgba(68, 102, 166, .22), transparent 46%), + linear-gradient(155deg, #070915 0%, #0a0d21 44%, #070915 100%); + overflow-x: hidden; +} + +body::after { + content: ""; + position: fixed; + inset: auto 0 0; + height: 34vh; + pointer-events: none; + background: linear-gradient(to bottom, transparent, rgba(45, 66, 124, .08)); + mask-image: linear-gradient(to bottom, transparent, black); +} + +.stars, .stars::before, .stars::after { + position: fixed; + inset: 0; + content: ""; + pointer-events: none; + background-image: + radial-gradient(circle at 9% 17%, rgba(255,255,255,.75) 0 1px, transparent 1.4px), + radial-gradient(circle at 34% 8%, rgba(255,255,255,.48) 0 1px, transparent 1.4px), + radial-gradient(circle at 72% 13%, rgba(255,255,255,.62) 0 1px, transparent 1.4px), + radial-gradient(circle at 91% 35%, rgba(255,255,255,.40) 0 1px, transparent 1.4px), + radial-gradient(circle at 18% 68%, rgba(255,255,255,.35) 0 1px, transparent 1.4px), + radial-gradient(circle at 79% 79%, rgba(255,255,255,.46) 0 1px, transparent 1.4px); + opacity: .55; +} +.stars::before { transform: translate(5vw, 8vh) scale(.78); opacity: .34; } +.stars::after { transform: translate(-8vw, 15vh) scale(1.22); opacity: .22; } + +a { color: inherit; } +button, input, select { font: inherit; } +button { color: inherit; } +.hidden { display: none !important; } + +.topbar, main, footer { width: min(760px, calc(100% - 36px)); margin-inline: auto; position: relative; z-index: 1; } +.topbar { + min-height: 68px; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--line); +} +.brand { display: inline-flex; align-items: center; gap: 9px; text-decoration: none; font-weight: 740; letter-spacing: .08em; } +.brand span { color: var(--star); font-size: 27px; text-shadow: 0 0 18px rgba(239, 231, 213, .45); } +.back { color: var(--muted); font-size: 12px; text-decoration: none; letter-spacing: .07em; } + +main { padding: 54px 0 78px; } +.intro { text-align: center; } +.eyebrow, .label { margin: 0; color: var(--violet); font: 700 10px/1.3 ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .19em; } +.intro h1 { margin: 15px 0 0; font-size: clamp(31px, 7vw, 48px); line-height: 1.15; letter-spacing: -.04em; } +.intro > p:last-of-type { max-width: 610px; margin: 20px auto 0; color: var(--muted); font-size: 14px; line-height: 1.9; } +.topology { margin: 28px auto 0; display: inline-flex; flex-wrap: wrap; justify-content: center; gap: 10px 20px; color: var(--muted); font-size: 11px; } +.topology span { display: inline-flex; align-items: center; gap: 7px; } +.topology b { color: var(--text); } +.dot { width: 6px; height: 6px; border-radius: 50%; background: var(--lake); box-shadow: 0 0 12px rgba(134,185,222,.75); } +.dot.runtime { background: var(--ok); box-shadow: 0 0 12px rgba(156,217,189,.75); } + +.auth-strip, .workspace, .timeline-section, .why { + margin-top: 28px; + border: 1px solid var(--line); + border-radius: 20px; + background: linear-gradient(160deg, rgba(22, 26, 58, .74), rgba(10, 13, 31, .83)); + box-shadow: var(--shadow); + backdrop-filter: blur(18px); +} +.auth-strip { min-height: 92px; padding: 18px 20px; display: flex; align-items: center; gap: 16px; } +.auth-strip > div { flex: 1; min-width: 0; } +.auth-strip strong { display: block; margin-top: 7px; font-size: 15px; } +.auth-strip small { display: block; margin-top: 5px; color: var(--muted); line-height: 1.55; } + +.button { + min-height: 42px; + padding: 0 18px; + border: 1px solid var(--line-bright); + border-radius: 999px; + background: rgba(255,255,255,.045); + font-weight: 700; + font-size: 13px; + cursor: pointer; + transition: transform .18s ease, background .18s ease, border-color .18s ease; +} +.button:hover { transform: translateY(-1px); border-color: rgba(255,255,255,.5); } +.button:active { transform: translateY(0) scale(.98); } +.button:disabled { opacity: .45; cursor: wait; } +.button.primary { color: #111426; background: var(--star); border-color: var(--star); box-shadow: 0 8px 26px rgba(239,231,213,.13); } +.button.ghost { color: var(--muted); } +.button.full { width: 100%; margin-top: 22px; } +.text-button { border: 0; background: transparent; color: var(--muted); cursor: pointer; font-size: 12px; } + +.workspace, .timeline-section { padding: 24px; } +.section-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; padding-bottom: 20px; border-bottom: 1px solid var(--line); } +.section-head.compact { padding-bottom: 16px; } +.section-head h2, .why h2 { margin: 8px 0 0; font-size: 21px; letter-spacing: -.02em; } +.state { display: inline-flex; align-items: center; min-height: 28px; padding: 0 11px; border-radius: 999px; border: 1px solid var(--line); color: var(--muted); font: 700 10px/1 ui-monospace, monospace; letter-spacing: .09em; } +.state.waiting { color: var(--warn); border-color: rgba(230,198,141,.28); } +.state.approved { color: var(--ok); border-color: rgba(156,217,189,.3); } +.state.rejected { color: var(--bad); border-color: rgba(225,151,145,.3); } + +.empty { padding: 58px 20px 48px; text-align: center; } +.empty-star { color: var(--star); font-size: 25px; text-shadow: 0 0 23px rgba(239,231,213,.5); } +.empty h3 { margin: 14px 0 0; font-size: 17px; } +.empty p { max-width: 420px; margin: 10px auto 0; color: var(--muted); font-size: 13px; line-height: 1.8; } + +.request { padding-top: 22px; } +.request-lead { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; } +.request-lead h3 { max-width: 520px; margin: 9px 0 0; font-size: 18px; line-height: 1.55; } +.facts { margin: 24px 0 0; padding: 0; display: grid; grid-template-columns: 1fr 1fr; border-top: 1px solid var(--line); } +.facts div { min-width: 0; padding: 16px 12px 16px 0; border-bottom: 1px solid var(--line); } +.facts div:nth-child(even) { padding-left: 18px; border-left: 1px solid var(--line); } +.facts dt { color: var(--quiet); font-size: 11px; } +.facts dd { margin: 7px 0 0; overflow-wrap: anywhere; font-size: 13px; font-weight: 650; line-height: 1.55; } +.mono { font: 600 11px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--muted); } +.binding-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; padding: 22px 0; border-bottom: 1px solid var(--line); } +.chips, .paths { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; } +.chip, .path { padding: 7px 9px; border-radius: 8px; color: var(--muted); background: rgba(255,255,255,.045); font: 600 10px/1.3 ui-monospace, monospace; } +.path { width: 100%; overflow-wrap: anywhere; } + +.decision { display: flex; justify-content: space-between; align-items: flex-end; gap: 20px; padding-top: 22px; } +select { min-width: 150px; margin-top: 9px; padding: 10px 34px 10px 11px; color: var(--text); border: 1px solid var(--line); border-radius: 10px; background: var(--ink-soft); } +.decision-buttons { display: flex; gap: 10px; } + +.timeline { margin: 0; padding: 19px 0 2px; list-style: none; } +.timeline li { position: relative; display: grid; grid-template-columns: 14px 1fr; gap: 12px; padding: 8px 0 14px; } +.timeline li:not(:last-child)::after { content: ""; position: absolute; left: 4px; top: 21px; bottom: -4px; width: 1px; background: var(--line); } +.timeline i { width: 9px; height: 9px; margin-top: 4px; border-radius: 50%; background: var(--quiet); box-shadow: 0 0 0 4px rgba(105,113,143,.08); } +.timeline li.pass i { background: var(--ok); box-shadow: 0 0 11px rgba(156,217,189,.6); } +.timeline li.fail i { background: var(--bad); box-shadow: 0 0 11px rgba(225,151,145,.55); } +.timeline strong { display: block; font-size: 13px; } +.timeline span { display: block; margin-top: 5px; color: var(--muted); font-size: 11px; line-height: 1.6; overflow-wrap: anywhere; } + +.why { padding: 28px; text-align: center; } +.why p:last-child { max-width: 600px; margin: 14px auto 0; color: var(--muted); font-size: 13px; line-height: 1.9; } + +dialog { width: min(430px, calc(100% - 28px)); padding: 0; color: var(--text); border: 1px solid var(--line-bright); border-radius: 20px; background: var(--panel-strong); box-shadow: 0 38px 100px rgba(0,0,0,.65); } +dialog::backdrop { background: rgba(3,5,15,.78); backdrop-filter: blur(8px); } +dialog form { position: relative; padding: 30px; } +dialog h2 { margin: 10px 0 0; font-size: 23px; } +dialog p:not(.label):not(.form-error) { margin: 12px 0 20px; color: var(--muted); font-size: 13px; line-height: 1.7; } +dialog label { display: block; margin: 15px 0 7px; color: var(--muted); font-size: 11px; } +dialog input { width: 100%; min-height: 45px; padding: 0 13px; border: 1px solid var(--line); border-radius: 10px; outline: 0; color: var(--text); background: rgba(255,255,255,.045); } +dialog input:focus { border-color: var(--line-bright); box-shadow: 0 0 0 3px rgba(166,165,255,.08); } +.dialog-close { position: absolute; top: 15px; right: 18px; border: 0; background: transparent; color: var(--muted); font-size: 24px; cursor: pointer; } +.form-error { min-height: 18px; margin: 10px 0 0; color: var(--bad); font-size: 12px; } +.toast { position: fixed; z-index: 10; left: 50%; bottom: 24px; transform: translate(-50%, 16px); max-width: calc(100% - 28px); padding: 11px 18px; opacity: 0; pointer-events: none; border: 1px solid var(--line-bright); border-radius: 999px; background: var(--panel-strong); box-shadow: var(--shadow); transition: .25s ease; font-size: 12px; text-align: center; } +.toast.show { opacity: 1; transform: translate(-50%, 0); } + +footer { min-height: 90px; display: flex; justify-content: space-between; align-items: center; gap: 18px; border-top: 1px solid var(--line); color: var(--quiet); font-size: 10px; } + +@media (max-width: 640px) { + .topbar, main, footer { width: min(100% - 24px, 760px); } + main { padding-top: 38px; } + .intro h1 { font-size: 31px; } + .intro > p:last-of-type { font-size: 13px; } + .topology { align-items: flex-start; flex-direction: column; text-align: left; } + .auth-strip { align-items: flex-start; flex-wrap: wrap; } + .auth-strip .button { width: 100%; } + .workspace, .timeline-section { padding: 19px; } + .section-head { align-items: center; } + .request-lead { flex-direction: column; } + .facts { grid-template-columns: 1fr; } + .facts div:nth-child(even) { padding-left: 0; border-left: 0; } + .binding-grid { grid-template-columns: 1fr; gap: 20px; } + .decision { align-items: stretch; flex-direction: column; } + select { width: 100%; } + .decision-buttons { display: grid; grid-template-columns: .8fr 1.4fr; } + .decision-buttons .button { padding-inline: 12px; } + footer { padding: 24px 0; align-items: flex-start; flex-direction: column; justify-content: center; } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { scroll-behavior: auto !important; transition: none !important; } +}