feat: release v1.0.1 CADWorld 网站与 LeKiwi 智能抓放
web-platform-ci / Standalone decision service (no cloud credentials) (push) Has been cancelled
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled
lekiwi-compatibility / cpu-compatibility (push) Has been cancelled
web-platform-ci / Standalone decision service (no cloud credentials) (pull_request) Has been cancelled
web-platform-ci / TypeScript, lint, unit, build (pull_request) Has been cancelled
web-platform-ci / Playwright E2E (pull_request) Has been cancelled
lekiwi-compatibility / cpu-compatibility (pull_request) Has been cancelled

集成同源 BYOK 会话隔离、精简模型设置、官方订阅入口和 HTTPS 发布运维;保留本地训练/调参与控制能力。同步 npm 版本及 CHANGELOG,记录公网真实 API 验收仍待用户凭据。
This commit is contained in:
2026-09-24 09:57:41 +08:00
parent 3ad29356c9
commit f3a8a38acd
194 changed files with 32918 additions and 236 deletions
+16
View File
@@ -0,0 +1,16 @@
*
!decision_server/
!decision_server/**
decision_server/tests/
**/__pycache__/
**/*.pyc
!contracts/
!contracts/lekiwi-agent-v1.schema.json
!vendor/
!vendor/codex
!vendor/wheels/
!vendor/wheels/*.whl
!deploy/
!deploy/cadworld/
!deploy/cadworld/requirements.lock.txt
!Dockerfile
+13
View File
@@ -0,0 +1,13 @@
# Imported and verified from images.lock.json; never pull during production builds.
FROM cadworld-python-base:locked
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1
WORKDIR /app
COPY vendor/wheels/ /opt/wheels/
COPY deploy/cadworld/requirements.lock.txt /opt/requirements.lock.txt
RUN pip install --no-index --find-links=/opt/wheels --require-hashes -r /opt/requirements.lock.txt && pip check
COPY vendor/codex /usr/local/bin/codex
RUN chmod 755 /usr/local/bin/codex && codex --version && useradd --uid 10001 --no-create-home --shell /usr/sbin/nologin cadworld
COPY decision_server/ /app/decision_server/
COPY contracts/lekiwi-agent-v1.schema.json /app/contracts/lekiwi-agent-v1.schema.json
USER 10001:10001
ENTRYPOINT ["python", "-m", "decision_server"]
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# One-time installation. Existing domain configuration is never silently overwritten.
set -euo pipefail
root=/opt/cadworld-sim
base=/opt/1panel/apps/openresty/openresty
conf=$base/conf/conf.d/cadworld-sim.robotquan.com.conf
source_dir=$(cd -- "$(dirname -- "$0")" && pwd)
[[ ! -e "$conf" ]] || { echo 'Domain vhost already exists; inspect/backup it first.' >&2; exit 1; }
mkdir -p "$root/backups" "$base/www/acme/cadworld-sim.robotquan.com" "$base/www/ssl/cadworld-sim.robotquan.com"
chmod 700 "$root/backups" "$base/www/ssl/cadworld-sim.robotquan.com"
tar -czf "$root/backups/openresty-pre-cadworld.tar.gz" -C "$base" conf
cp "$source_dir/compose.yaml" "$root/compose.yaml"
printf 'CADWORLD_RELEASE=bootstrap\n' > "$root/release.env"
# HTTP only serves challenges. No API or credentials until a trusted certificate exists.
printf '%s\n' \
'server {' \
' listen 80; server_name cadworld-sim.robotquan.com;' \
' location ^~ /.well-known/acme-challenge/ { root /www/acme/cadworld-sim.robotquan.com; default_type text/plain; }' \
' location / { return 503; }' \
'}' > "$conf"
docker exec 1Panel-openresty-m72w nginx -t
docker exec 1Panel-openresty-m72w nginx -s reload
docker compose --env-file "$root/release.env" -f "$root/compose.yaml" run --rm -T --interactive=false certbot certonly \
--non-interactive --agree-tos --register-unsafely-without-email \
--webroot -w /var/www/acme -d cadworld-sim.robotquan.com
cp "$source_dir/openresty.conf" "$conf"
docker exec 1Panel-openresty-m72w nginx -t
docker exec 1Panel-openresty-m72w nginx -s reload
cp "$source_dir/renew-certificate.sh" "$root/renew-certificate.sh"
chmod 700 "$root/renew-certificate.sh"
cp "$source_dir/cadworld-certificate.service" /etc/systemd/system/
cp "$source_dir/cadworld-certificate.timer" /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now cadworld-certificate.timer
@@ -0,0 +1,9 @@
[Unit]
Description=Renew CADWorld Sim TLS certificate
After=docker.service network-online.target
Requires=docker.service
[Service]
Type=oneshot
ExecStart=/opt/cadworld-sim/renew-certificate.sh
TimeoutStartSec=10min
@@ -0,0 +1,10 @@
[Unit]
Description=Twice daily CADWorld TLS renewal check
[Timer]
OnCalendar=*-*-* 03,15:00:00
RandomizedDelaySec=1h
Persistent=true
[Install]
WantedBy=timers.target
+58
View File
@@ -0,0 +1,58 @@
name: cadworld-sim
services:
decision:
image: cadworld-decision:${CADWORLD_RELEASE:?release tag required}
command:
- --website-origin
- https://cadworld-sim.robotquan.com
- --bind
- 0.0.0.0
- --trusted-proxy
- 172.30.88.1
ports:
- '127.0.0.1:8768:8768'
restart: unless-stopped
init: true
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=268435456,mode=1777
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
mem_limit: 2g
cpus: 2
pids_limit: 128
stop_grace_period: 20s
healthcheck:
test:
[
CMD,
python,
-c,
"import urllib.request; r=urllib.request.Request('http://127.0.0.1:8768/healthz',headers={'Host':'cadworld-sim.robotquan.com'}); urllib.request.urlopen(r,timeout=3).read()",
]
interval: 15s
timeout: 5s
retries: 3
start_period: 15s
logging:
driver: json-file
options: { max-size: 5m, max-file: '3' }
networks: [cadworld]
certbot:
image: cadworld-certbot:locked
profiles: [maintenance]
volumes:
- /opt/1panel/apps/openresty/openresty/www/acme/cadworld-sim.robotquan.com:/var/www/acme
- /opt/1panel/apps/openresty/openresty/www/ssl/cadworld-sim.robotquan.com:/etc/letsencrypt
tmpfs:
- /var/lib/letsencrypt
- /var/log/letsencrypt
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
networks: [cadworld]
networks:
cadworld:
ipam:
config:
- subnet: 172.30.88.0/24
gateway: 172.30.88.1
+7
View File
@@ -0,0 +1,7 @@
{
"platform": "linux/amd64",
"crane": "v0.20.6",
"python": "docker.io/library/python@sha256:1aaa65a85fda306ffb8b910824d4e93bdce61e212c7e87168123ea3073b41a1a",
"certbot": "docker.io/certbot/certbot@sha256:398c47284a6d6782825be71685f677ef3a1e65b8b5c278a8b1e99f6da84b4eb9",
"codex": "@openai/codex@0.147.0-linux-x64"
}
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
# Archive hashes must be verified before running this script.
set -euo pipefail
images=${1:?usage: import-images.sh IMAGE_DIRECTORY}
docker load -i "$images/python.tar"
docker tag sha256:febd0be41adb897a0ab8f1f1c693d8912669ea60c4940e076e9946b60e210ef0 cadworld-python-base:locked
docker load -i "$images/certbot.tar"
docker tag sha256:d9a5b0cd892677dd43fa6f60adba9c06f68d7fd1da09b04f15a7b0f468e4c3f8 cadworld-certbot:locked
+62
View File
@@ -0,0 +1,62 @@
# Managed by CADWorld deployment, not a replacement for 1Panel's main config.
limit_req_zone $binary_remote_addr zone=cadworld_api:10m rate=10r/s;
server {
listen 80;
server_name cadworld-sim.robotquan.com;
location ^~ /.well-known/acme-challenge/ {
root /www/acme/cadworld-sim.robotquan.com;
default_type text/plain;
}
location / { return 301 https://cadworld-sim.robotquan.com$request_uri; }
}
server {
listen 443 ssl http2;
server_name cadworld-sim.robotquan.com;
ssl_certificate /www/ssl/cadworld-sim.robotquan.com/live/cadworld-sim.robotquan.com/fullchain.pem;
ssl_certificate_key /www/ssl/cadworld-sim.robotquan.com/live/cadworld-sim.robotquan.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_cache shared:CADWorldTLS:10m;
ssl_session_timeout 1d;
root /www/sites/cadworld-sim.robotquan.com/current;
index index.html;
autoindex off;
client_max_body_size 64k;
client_body_timeout 15s;
keepalive_timeout 30s;
server_tokens off;
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy no-referrer always;
add_header X-Frame-Options DENY always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; worker-src 'self' blob:; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' blob:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'none'" always;
# Hash assets cache; all headers remain inherited (no nested add_header).
set $cadworld_cache "no-cache";
if ($uri ~ ^/assets/) { set $cadworld_cache "public, max-age=31536000, immutable"; }
if ($uri ~ ^/pyodide/) { set $cadworld_cache "public, max-age=86400"; }
if ($uri ~ ^/api/) { set $cadworld_cache "no-store"; }
add_header Cache-Control $cadworld_cache always;
location ^~ /api/decision/v1/ {
access_log off;
limit_req zone=cadworld_api burst=20 nodelay;
limit_req_status 429;
proxy_pass http://127.0.0.1:8768;
proxy_http_version 1.1;
proxy_set_header Host cadworld-sim.robotquan.com;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Forwarded "";
proxy_set_header Connection "";
proxy_connect_timeout 5s;
proxy_read_timeout 70s;
proxy_send_timeout 15s;
proxy_buffering off;
proxy_hide_header Cache-Control;
}
location ^~ /api/ { return 404; }
location ^~ /physics/ { return 404; }
location = /tuning.html { return 404; }
location ~ /\. { return 404; }
location ~ \.wasm$ { types { application/wasm wasm; } try_files $uri =404; }
location / { try_files $uri $uri/ =404; }
}
+80
View File
@@ -0,0 +1,80 @@
"""Package an explicit allowlist, never the working tree/.env/user assets wholesale."""
import argparse
import hashlib
import json
import re
import shutil
import tarfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def main():
parser = argparse.ArgumentParser()
parser.add_argument("release")
args = parser.parse_args()
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,63}", args.release):
parser.error("invalid release")
output = ROOT / "build/website-deployment/bundles" / args.release
output.mkdir(parents=True, exist_ok=False)
def copy(source, target):
if source.is_symlink():
raise RuntimeError("symlinks not allowed in publish inputs")
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(source, target)
for source in (ROOT / "decision_server").rglob("*"):
relative = source.relative_to(ROOT)
if (
source.is_file()
and "tests" not in relative.parts
and "__pycache__" not in relative.parts
and source.suffix in (".py", ".json", ".txt", ".md")
):
copy(source, output / "app" / relative)
copy(
ROOT / "contracts/lekiwi-agent-v1.schema.json",
output / "app/contracts/lekiwi-agent-v1.schema.json",
)
for source in (ROOT / "deploy/cadworld").iterdir():
if source.is_file():
copy(source, output / "deploy" / source.name)
copy(ROOT / "deploy/cadworld/Dockerfile", output / "app/Dockerfile")
copy(ROOT / "deploy/cadworld/.dockerignore", output / "app/.dockerignore")
copy(
ROOT / "deploy/cadworld/requirements.lock.txt",
output / "app/deploy/cadworld/requirements.lock.txt",
)
vendor = ROOT / "build/website-deployment/vendor"
for source in [
vendor / "codex",
vendor / "codex-origin.json",
*sorted((vendor / "wheels").glob("*.whl")),
]:
copy(source, output / "app/vendor" / source.relative_to(vendor))
for source in (ROOT / "website-dist").rglob("*"):
if source.is_file():
copy(source, output / "static" / source.relative_to(ROOT / "website-dist"))
if not (output / "static/index.html").exists() or (output / "static/tuning.html").exists():
raise RuntimeError("website production build required")
manifest = {
str(p.relative_to(output)): hashlib.sha256(p.read_bytes()).hexdigest()
for p in sorted(output.rglob("*"))
if p.is_file()
}
(output / "manifest.json").write_text(
json.dumps({"release": args.release, "files": manifest}, indent=2)
)
archive = output.with_suffix(".tar.gz")
with tarfile.open(archive, "w:gz") as tar:
for path in sorted(output.iterdir()):
tar.add(path, arcname=path.name)
print(str(archive))
print("sha256", hashlib.sha256(archive.read_bytes()).hexdigest())
if __name__ == "__main__":
main()
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
# Run on the target host after baseline backup, image imports and TLS bootstrap.
set -euo pipefail
release=${1:?usage: publish.sh RELEASE}
[[ "$release" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$ ]] || exit 2
root=/opt/cadworld-sim
www=/opt/1panel/apps/openresty/openresty/www
site=$www/sites/cadworld-sim.robotquan.com
nginx=1Panel-openresty-m72w
stage=$root/releases/$release
[[ -f "$stage/manifest.json" ]] || { echo 'missing verified release'; exit 1; }
python3 - "$stage" <<'PY'
import hashlib,json,pathlib,sys
root=pathlib.Path(sys.argv[1])
manifest=json.loads((root/'manifest.json').read_text())
for name,digest in manifest['files'].items():
path=root/name
if '..' in pathlib.Path(name).parts or pathlib.Path(name).is_absolute() or path.is_symlink():
raise RuntimeError('invalid manifest path')
if hashlib.sha256(path.read_bytes()).hexdigest()!=digest:
raise RuntimeError('artifact checksum mismatch: '+name)
print('Release manifest verified')
PY
old=''
[[ ! -f $root/current.txt ]] || read -r old < "$root/current.txt"
docker build --network none --pull=false -t "cadworld-decision:$release" "$stage/app"
# No current traffic is changed before the build and static manifest succeed.
mkdir -p "$site/releases/$release"
cp -a "$stage/static/." "$site/releases/$release/"
chmod -R a+rX "$site/releases/$release"
restore() {
trap - ERR
if [[ -n "$old" ]]; then
cp "$root/releases/$old/deploy/compose.yaml" "$root/compose.yaml"
cp "$root/releases/$old/deploy/openresty.conf" /opt/1panel/apps/openresty/openresty/conf/conf.d/cadworld-sim.robotquan.com.conf
printf 'CADWORLD_RELEASE=%s\n' "$old" > "$root/release.env"
docker compose --env-file "$root/release.env" -f "$root/compose.yaml" up -d --wait decision || true
ln -sfn "releases/$old" "$site/current.next"
mv -Tf "$site/current.next" "$site/current"
docker exec "$nginx" nginx -t && docker exec "$nginx" nginx -s reload || true
else
docker compose --env-file "$root/release.env" -f "$root/compose.yaml" stop decision || true
conf=/opt/1panel/apps/openresty/openresty/conf/conf.d/cadworld-sim.robotquan.com.conf
[[ ! -f "$conf" ]] || mv "$conf" "$root/failed-first-vhost.conf"
docker exec "$nginx" nginx -t && docker exec "$nginx" nginx -s reload || true
fi
echo 'Publish failed; previous deployment retained/restored. Inspect logs before retry.' >&2
exit 1
}
trap restore ERR
cp "$stage/deploy/compose.yaml" "$root/compose.yaml"
printf 'CADWORLD_RELEASE=%s\n' "$release" > "$root/release.env"
docker compose --env-file "$root/release.env" -f "$root/compose.yaml" up -d --wait --wait-timeout 75 decision
curl --fail --silent --show-error --max-time 5 -H 'Host: cadworld-sim.robotquan.com' http://127.0.0.1:8768/healthz
ln -sfn "releases/$release" "$site/current.next"
mv -Tf "$site/current.next" "$site/current"
cp "$stage/deploy/openresty.conf" /opt/1panel/apps/openresty/openresty/conf/conf.d/cadworld-sim.robotquan.com.conf
docker exec "$nginx" nginx -t
docker exec "$nginx" nginx -s reload
curl --fail --silent --show-error --max-time 15 https://cadworld-sim.robotquan.com/ -o /dev/null
printf '%s\n' "$old" > "$root/previous.txt"
printf '%s\n' "$release" > "$root/current.txt"
docker image inspect "cadworld-decision:$release" --format '{{.Id}}' > "$stage/image-id.txt"
trap - ERR
echo "Published $release; previous=$old"
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
root=/opt/cadworld-sim
docker compose --env-file "$root/release.env" -f "$root/compose.yaml" run --rm -T --interactive=false certbot renew --non-interactive --no-random-sleep-on-renew "$@"
docker exec 1Panel-openresty-m72w nginx -t
docker exec 1Panel-openresty-m72w nginx -s reload
+11
View File
@@ -0,0 +1,11 @@
# Generated for CPython 3.12 Linux amd64; verified offline wheels.
aiohappyeyeballs==2.7.1 --hash=sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472
aiohttp==3.14.3 --hash=sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42
aiosignal==1.4.0 --hash=sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e
attrs==26.1.0 --hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309
frozenlist==1.8.0 --hash=sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383
idna==3.20 --hash=sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c
multidict==6.9.1 --hash=sha256:976fd7689d69ec78d67d31d38d396d8adb562f7e8368279f76aed4aa451fa06d
propcache==0.5.4 --hash=sha256:2814ecd8e818f487bee4b0f921bc4d1c176cc5fc71ac0f072d0fa67eda4ac14b
typing_extensions==4.16.0 --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8
yarl==1.25.1 --hash=sha256:c6f117789d22dce188e5754e8bc65b7e6ebf8cb73963b9fa761f672a5883769d
+83
View File
@@ -0,0 +1,83 @@
"""Operator-invoked production recovery check; creates no model credentials or calls."""
import http.client
import json
import subprocess
import time
HOST = "cadworld-sim.robotquan.com"
CONTAINER = "cadworld-sim-decision-1"
def request(path, method="GET", headers=None, body=None):
connection = http.client.HTTPSConnection(HOST, timeout=10)
connection.request(method, path, body=body, headers=headers or {})
response = connection.getresponse()
status, cookie = response.status, response.getheader("Set-Cookie")
response.read()
connection.close()
return status, cookie
def restart_count():
return int(
subprocess.check_output(
["docker", "inspect", CONTAINER, "--format", "{{.RestartCount}}"],
universal_newlines=True,
)
)
def main():
status, cookie = request(
"/api/decision/v1/session",
"POST",
{"Origin": "https://" + HOST, "Content-Type": "application/json"},
"{}",
)
if status != 200 or not cookie:
raise RuntimeError("session bootstrap failed")
cookie = cookie.split(";", 1)[0] # Never print or persist the credential.
before = restart_count()
# Kill only this application's Python worker, not Docker/1Panel/host PID 1.
code = """import os,signal
for ident in os.listdir('/proc'):
if ident.isdigit():
try:
raw=open('/proc/'+ident+'/cmdline','rb').read()
except OSError:
continue
if raw.startswith(b'python\\x00-m\\x00decision_server\\x00'):
os.kill(int(ident),signal.SIGKILL)
break
else:
raise RuntimeError('worker not found')
"""
# Tini may terminate the exec probe together with the killed worker (137).
probe = subprocess.run(["docker", "exec", CONTAINER, "python", "-c", code], check=False)
if probe.returncode not in (0, 137):
raise RuntimeError("worker crash probe failed")
deadline = time.monotonic() + 60
while time.monotonic() < deadline:
time.sleep(1)
try:
status, _ = request("/api/decision/v1/status", headers={"Cookie": cookie})
if status == 401 and restart_count() > before:
print(
json.dumps(
{
"automaticRestart": True,
"oldSessionStatus": status,
"restartCount": restart_count(),
"modelCalls": 0,
}
)
)
return
except OSError:
pass
raise RuntimeError("automatic recovery not verified")
if __name__ == "__main__":
main()