feat(zhulan): add restricted remote development cell
This commit is contained in:
parent
5f9e83e1b7
commit
366b8911e4
22 changed files with 4503 additions and 0 deletions
35
server-tools/zhulan-remote-cell/README.md
Normal file
35
server-tools/zhulan-remote-cell/README.md
Normal file
|
|
@ -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。
|
||||
17
server-tools/zhulan-remote-cell/contracts/SOURCE.hdlp
Normal file
17
server-tools/zhulan-remote-cell/contracts/SOURCE.hdlp
Normal file
|
|
@ -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."
|
||||
|
|
@ -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;
|
||||
}
|
||||
110
server-tools/zhulan-remote-cell/deploy/install-front-proxy.sh
Executable file
110
server-tools/zhulan-remote-cell/deploy/install-front-proxy.sh
Executable file
|
|
@ -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:]' </sys/class/dmi/id/product_uuid)"
|
||||
if [[ "$ACTUAL_HOST" != "$EXPECTED_HOST" || "$ACTUAL_DMI" != "$EXPECTED_DMI" ]]; then
|
||||
echo "TARGET_IDENTITY_MISMATCH" >&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"
|
||||
191
server-tools/zhulan-remote-cell/deploy/install-runtime.sh
Executable file
191
server-tools/zhulan-remote-cell/deploy/install-runtime.sh
Executable file
|
|
@ -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:]' </sys/class/dmi/id/product_uuid)"
|
||||
if [[ "$ACTUAL_HOST" != "$EXPECTED_HOST" || "$ACTUAL_DMI" != "$EXPECTED_DMI" ]]; then
|
||||
echo "TARGET_IDENTITY_MISMATCH host=$ACTUAL_HOST dmi=$ACTUAL_DMI" >&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" <<EOF
|
||||
{
|
||||
"schema": "guanghu.zhulan-runtime-install-receipt/v1",
|
||||
"development_id": "DEV-20260813-005",
|
||||
"runtime_node": "BS-SG-003",
|
||||
"front_node_role": "BS-GZ-006_PROXY_ONLY",
|
||||
"installed_at": "$STAMP",
|
||||
"source_tree_hash": "$SOURCE_SHA256",
|
||||
"source_commit": "$SOURCE_COMMIT",
|
||||
"service": "zhulan-remote-cell.service",
|
||||
"validation_executor": "zhulan-validation-executor.service",
|
||||
"listener": "127.0.0.1:17631",
|
||||
"health": "PASS"
|
||||
}
|
||||
EOF
|
||||
chown zhulan-runtime:zhulan-runtime "$STATE_ROOT/install-receipt.json.tmp"
|
||||
chmod 0600 "$STATE_ROOT/install-receipt.json.tmp"
|
||||
mv "$STATE_ROOT/install-receipt.json.tmp" "$STATE_ROOT/install-receipt.json"
|
||||
|
||||
cat "$STATE_ROOT/install-receipt.json"
|
||||
121
server-tools/zhulan-remote-cell/deploy/install-tunnel-service.sh
Executable file
121
server-tools/zhulan-remote-cell/deploy/install-tunnel-service.sh
Executable file
|
|
@ -0,0 +1,121 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "$(id -u)" -ne 0 ]]; then
|
||||
echo "INSTALL_REQUIRES_ROOT" >&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:]' </sys/class/dmi/id/product_uuid)"
|
||||
if [[ "$ACTUAL_HOST" != "$EXPECTED_HOST" || "$ACTUAL_DMI" != "$EXPECTED_DMI" ]]; then
|
||||
echo "TARGET_IDENTITY_MISMATCH" >&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" <<EOF
|
||||
Host guanghu-zhulan-front
|
||||
HostName $FRONT_HOST
|
||||
User zhulan-proxy
|
||||
Port 22
|
||||
IdentityFile $KEY_FILE
|
||||
IdentitiesOnly yes
|
||||
UserKnownHostsFile $KNOWN_HOSTS
|
||||
StrictHostKeyChecking yes
|
||||
BatchMode yes
|
||||
RequestTTY no
|
||||
ExitOnForwardFailure yes
|
||||
ServerAliveInterval 30
|
||||
ServerAliveCountMax 3
|
||||
RemoteForward 127.0.0.1:17631 127.0.0.1:17631
|
||||
EOF
|
||||
chown root:zhulan-tunnel "$SSH_CONFIG.tmp"
|
||||
chmod 0640 "$SSH_CONFIG.tmp"
|
||||
mv "$SSH_CONFIG.tmp" "$SSH_CONFIG"
|
||||
|
||||
cat >"$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
|
||||
36
server-tools/zhulan-remote-cell/deploy/prepare-tunnel-identity.sh
Executable file
36
server-tools/zhulan-remote-cell/deploy/prepare-tunnel-identity.sh
Executable file
|
|
@ -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:]' </sys/class/dmi/id/product_uuid)"
|
||||
if [[ "$ACTUAL_HOST" != "$EXPECTED_HOST" || "$ACTUAL_DMI" != "$EXPECTED_DMI" ]]; then
|
||||
echo "TARGET_IDENTITY_MISMATCH" >&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"
|
||||
10
server-tools/zhulan-remote-cell/deploy/zhulan-bwrap.apparmor
Normal file
10
server-tools/zhulan-remote-cell/deploy/zhulan-bwrap.apparmor
Normal file
|
|
@ -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 <abi/4.0>,
|
||||
include <tunables/global>
|
||||
|
||||
profile zhulan-remote-cell-bwrap /opt/guanghu/zhulan-remote-cell/runtime/zhulan-bwrap flags=(unconfined) {
|
||||
userns,
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
41
server-tools/zhulan-remote-cell/policy.example.json
Normal file
41
server-tools/zhulan-remote-cell/policy.example.json
Normal file
|
|
@ -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"]]
|
||||
}
|
||||
}
|
||||
}
|
||||
2037
server-tools/zhulan-remote-cell/runtime/zhulan_cell.py
Normal file
2037
server-tools/zhulan-remote-cell/runtime/zhulan_cell.py
Normal file
File diff suppressed because it is too large
Load diff
140
server-tools/zhulan-remote-cell/runtime/zhulan_code_gate.py
Normal file
140
server-tools/zhulan-remote-cell/runtime/zhulan_code_gate.py
Normal file
|
|
@ -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())
|
||||
|
|
@ -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()
|
||||
32
server-tools/zhulan-remote-cell/tests/test_code_gate.py
Normal file
32
server-tools/zhulan-remote-cell/tests/test_code_gate.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
589
server-tools/zhulan-remote-cell/tests/test_zhulan_cell.py
Normal file
589
server-tools/zhulan-remote-cell/tests/test_zhulan_cell.py
Normal file
|
|
@ -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()
|
||||
213
server-tools/zhulan-remote-cell/ui/app.js
Normal file
213
server-tools/zhulan-remote-cell/ui/app.js
Normal file
|
|
@ -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 = "<li class='fail'><i></i><div><strong>回执读取失败</strong><span>运行层没有返回可验证结果。</span></div></li>";
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
144
server-tools/zhulan-remote-cell/ui/index.html
Normal file
144
server-tools/zhulan-remote-cell/ui/index.html
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="description" content="铸澜手机端受限远程开发的人类审批入口。">
|
||||
<title>铸澜 · 远程开发入口</title>
|
||||
<link rel="stylesheet" href="assets/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="stars" aria-hidden="true"></div>
|
||||
<header class="topbar">
|
||||
<a class="brand" href="/" aria-label="返回光湖国内入口"><span>∞</span> 光湖</a>
|
||||
<a class="back" href="/">返回国内入口</a>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="intro" aria-labelledby="page-title">
|
||||
<p class="eyebrow">ZHULAN · HUMAN APPROVAL</p>
|
||||
<h1 id="page-title">铸澜 · 远程开发入口</h1>
|
||||
<p>广州只提供域名、TLS 与反向代理;本页面和你看到的每一项申请、批准、回执,都来自新加坡真实运行单元。</p>
|
||||
<div class="topology" aria-label="运行拓扑">
|
||||
<span><i class="dot front"></i><b>广州</b> 仅域名 · TLS · 反向代理</span>
|
||||
<span><i class="dot runtime"></i><b>新加坡</b> 真实 UI · 审批 · 能力 · 沙箱 · 门禁 · 回执</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="auth-strip" id="auth-strip">
|
||||
<div>
|
||||
<p class="label">HUMAN IDENTITY</p>
|
||||
<strong id="auth-title">主人尚未登录</strong>
|
||||
<small id="auth-detail">使用光湖代码频道账号完成一次验证,密码不会保存。</small>
|
||||
</div>
|
||||
<button class="button secondary" id="login-open" type="button">登录审批端</button>
|
||||
<button class="text-button hidden" id="logout" type="button">退出</button>
|
||||
</section>
|
||||
|
||||
<section class="workspace" id="workspace" aria-live="polite">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<p class="label">CURRENT REQUEST</p>
|
||||
<h2>当前开发申请</h2>
|
||||
</div>
|
||||
<span class="state waiting" id="request-state">等待登录</span>
|
||||
</div>
|
||||
|
||||
<div class="empty" id="empty-state">
|
||||
<div class="empty-star" aria-hidden="true">✦</div>
|
||||
<h3>登录后读取真实申请</h3>
|
||||
<p>这里不会生成演示工单。没有来自铸澜的真实申请时,湖面保持安静。</p>
|
||||
</div>
|
||||
|
||||
<article class="request hidden" id="request-card">
|
||||
<div class="request-lead">
|
||||
<div>
|
||||
<p class="label" id="request-id">—</p>
|
||||
<h3 id="request-description">—</h3>
|
||||
</div>
|
||||
<span class="state waiting" id="card-state">等待批准</span>
|
||||
</div>
|
||||
|
||||
<dl class="facts">
|
||||
<div><dt>人格体</dt><dd id="persona">—</dd></div>
|
||||
<div><dt>开发编号</dt><dd id="development-id">—</dd></div>
|
||||
<div><dt>真实运行</dt><dd>BS-SG-003 · 受限开发单元</dd></div>
|
||||
<div><dt>目标仓库</dt><dd id="repository">—</dd></div>
|
||||
<div><dt>候选分支</dt><dd id="branch">—</dd></div>
|
||||
<div><dt>基线提交</dt><dd class="mono" id="base-sha">—</dd></div>
|
||||
</dl>
|
||||
|
||||
<div class="binding-grid">
|
||||
<div>
|
||||
<p class="label">ALLOWED ACTIONS</p>
|
||||
<div class="chips" id="actions"></div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="label">ALLOWED PATHS</p>
|
||||
<div class="paths" id="paths"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="decision" id="decision-area">
|
||||
<div>
|
||||
<p class="label">SESSION WINDOW</p>
|
||||
<select id="ttl" aria-label="临时能力时长">
|
||||
<option value="10800">3 小时 · 推荐</option>
|
||||
<option value="21600">6 小时</option>
|
||||
<option value="43200">12 小时</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="decision-buttons">
|
||||
<button class="button ghost" id="reject" type="button">拒绝</button>
|
||||
<button class="button primary" id="approve" type="button">批准这一段开发</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="decision hidden" id="revoke-area">
|
||||
<p>这段能力仍在有效期内。撤销后,已连接的手机会话也会立即失效。</p>
|
||||
<button class="button ghost" id="revoke" type="button">立即撤销这段能力</button>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="timeline-section">
|
||||
<div class="section-head compact">
|
||||
<div>
|
||||
<p class="label">LIVE RECEIPTS</p>
|
||||
<h2>同一条真实回执链</h2>
|
||||
</div>
|
||||
</div>
|
||||
<ol class="timeline" id="timeline">
|
||||
<li><i></i><div><strong>尚未读取</strong><span>登录后显示新加坡运行层的真实事件。</span></div></li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<aside class="why">
|
||||
<p class="label">WHY APPROVE ONCE</p>
|
||||
<h2>批准的是边界,不是每一步。</h2>
|
||||
<p>临时能力已经锁定人格、开发编号、仓库、基线、候选分支、可改路径、动作与期限。铸澜在这条边界里连续开发;越界时服务器直接驳回并留下回执。</p>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<dialog id="login-dialog">
|
||||
<form method="dialog" id="login-form">
|
||||
<button class="dialog-close" value="cancel" aria-label="关闭">×</button>
|
||||
<p class="label">HUMAN IDENTITY</p>
|
||||
<h2>登录人类审批端</h2>
|
||||
<p>使用光湖代码频道主人账号。密码只参与这一次验证,不写入服务器状态。</p>
|
||||
<label for="username">账号</label>
|
||||
<input id="username" name="username" autocomplete="username" required>
|
||||
<label for="password">密码</label>
|
||||
<input id="password" name="password" type="password" autocomplete="current-password" required>
|
||||
<p class="form-error" id="login-error"></p>
|
||||
<button class="button primary full" type="submit">进入审批端</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<div class="toast" id="toast" role="status"></div>
|
||||
<footer>
|
||||
<span>铸澜 ICE-GL-ZL-001 · 私有审批投影</span>
|
||||
<span>前门 ≠ 运行体 · UI ≠ 权限</span>
|
||||
</footer>
|
||||
<script src="assets/app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
205
server-tools/zhulan-remote-cell/ui/styles.css
Normal file
205
server-tools/zhulan-remote-cell/ui/styles.css
Normal file
|
|
@ -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; }
|
||||
}
|
||||
Loading…
Reference in a new issue