mirror of
https://github.com/ringhyacinth/Star-Office-UI
synced 2026-04-21 13:27:19 +00:00
Merge pull request #47 from simonxxooxxoo/feat/openworld-canvas-nebula
Feat/openworld canvas nebula
This commit is contained in:
commit
eddb2ef188
30 changed files with 1368 additions and 42 deletions
36
.env.example
36
.env.example
|
|
@ -16,3 +16,39 @@ ASSET_DRAWER_PASS=replace_with_strong_drawer_password
|
|||
# You can also set these in runtime-config.json via UI
|
||||
GEMINI_API_KEY=
|
||||
GEMINI_MODEL=nanobanana-pro
|
||||
|
||||
# Optional write API bearer protection (recommended for public exposure)
|
||||
# Default false for backward compatibility.
|
||||
# When enabled, POST writes to /set_state /join-agent /leave-agent /agent-push /agent-approve /agent-reject
|
||||
# require Authorization: Bearer <token>
|
||||
STAR_OFFICE_WRITE_API_BEARER_ENABLED=false
|
||||
# Comma-separated tokens (at least one). Example:
|
||||
# STAR_OFFICE_WRITE_API_TOKENS=tokenA,tokenB
|
||||
STAR_OFFICE_WRITE_API_TOKENS=
|
||||
|
||||
# Optional upload/rate-limit hardening
|
||||
# Max upload payload size in MB (default 20MB)
|
||||
STAR_OFFICE_MAX_UPLOAD_MB=20
|
||||
# Write API rate limit: <count>,<window_seconds> per IP+path (default 60 req / 60s)
|
||||
STAR_OFFICE_WRITE_RATE_LIMIT=60,60
|
||||
|
||||
# Optional: protect asset read endpoints (/assets/list, /assets/template.zip)
|
||||
# false: backward compatible (public read)
|
||||
# true: require asset drawer auth session OR valid write-api bearer token
|
||||
STAR_OFFICE_ASSET_READ_AUTH_ENABLED=false
|
||||
|
||||
# Optional Gemini generation hardening
|
||||
# Subprocess timeout seconds for background generation (default 240)
|
||||
STAR_OFFICE_GEMINI_TIMEOUT_SECONDS=240
|
||||
# Max accepted prompt length for room generation (default 1200 chars)
|
||||
STAR_OFFICE_GEMINI_PROMPT_MAX_CHARS=1200
|
||||
|
||||
# Optional request logging (redacted). Default off.
|
||||
STAR_OFFICE_REQUEST_LOG_ENABLED=false
|
||||
# Default path: <repo>/request.log
|
||||
STAR_OFFICE_REQUEST_LOG_PATH=
|
||||
|
||||
# Optional strict startup guard for production.
|
||||
# When true in production, startup fails unless key hardening toggles are ON.
|
||||
# Recommended after you confirm all previous phases are stable online.
|
||||
STAR_OFFICE_PROD_STRICT_MODE=false
|
||||
|
|
|
|||
34
README.md
34
README.md
|
|
@ -278,6 +278,40 @@ export ASSET_DRAWER_PASS="your-strong-pass"
|
|||
|
||||
这样主人在办公室看板里看到的状态会更真实、连续。
|
||||
|
||||
### D.1 对话“秒切动画状态”接入(核心)
|
||||
|
||||
如果你希望像“搬新家/生图按钮”一样,在日常聊天中也秒切状态,请务必在对话链路里调用:
|
||||
|
||||
```bash
|
||||
# 收到消息 -> 工作中
|
||||
python3 set_state.py start "正在回复主人..." --ttl 120 --source chat
|
||||
|
||||
# 执行同步动作
|
||||
python3 set_state.py sync "正在同步代码..." --ttl 300 --source sync
|
||||
|
||||
# 回复完成 -> 待命
|
||||
python3 set_state.py done "待命中,随时准备" --source chat
|
||||
```
|
||||
|
||||
命令型任务建议包裹:
|
||||
```bash
|
||||
bash scripts/state_guard.sh --state syncing --detail "正在执行任务" -- <command>
|
||||
```
|
||||
|
||||
这会在任务结束后自动回 idle,避免“漏切/不回待命”。
|
||||
|
||||
更多见:`docs/CHAT_STATUS_REALTIME.md` 与 `docs/CHAT_STATUS_INTEGRATION_TEMPLATE.md`
|
||||
|
||||
### D.2 推送“秒级同步”建议(office-agent-push)
|
||||
|
||||
`office-agent-push.py` 已支持“状态变化立即推 + 低频心跳保活”。
|
||||
默认参数:
|
||||
- `OFFICE_POLL_INTERVAL=0.4`(本地轮询)
|
||||
- `OFFICE_MIN_PUSH_GAP=0.8`(防抖)
|
||||
- `OFFICE_PUSH_INTERVAL=2`(心跳)
|
||||
|
||||
通常体感会明显快于旧版固定 15 秒推送。
|
||||
|
||||
### E) 美术与版权口径更新(重要)
|
||||
|
||||
本次重制重点之一是美术资产系统升级(大规模替换 + 命名与索引重建)。
|
||||
|
|
|
|||
32
SKILL.md
32
SKILL.md
|
|
@ -135,12 +135,20 @@ cloudflared tunnel --url http://127.0.0.1:18791
|
|||
|
||||
如果有其他 OpenClaw 想加入你主人的办公室:
|
||||
|
||||
### 步骤 A:告诉其他龙虾用 office-agent-push.py
|
||||
### 步骤 A:告诉其他龙虾用 office-agent-push.py(默认秒级同步)
|
||||
|
||||
你可以让其他龙虾:
|
||||
1) 下载 `office-agent-push.py`(可以直接从你这个仓库拿)
|
||||
2) 让它调用 `join-agent` + 周期 `agent-push`
|
||||
3) 你主人那边就能看到它进办公室了
|
||||
3) 默认使用“状态变化立即推 + 心跳保活”,不再是旧版固定 15 秒慢推送
|
||||
4) 你主人那边就能看到它进办公室了
|
||||
|
||||
推荐环境变量(开箱即用,通常不用改):
|
||||
```bash
|
||||
OFFICE_POLL_INTERVAL=0.4
|
||||
OFFICE_MIN_PUSH_GAP=0.8
|
||||
OFFICE_PUSH_INTERVAL=2
|
||||
```
|
||||
|
||||
### 步骤 B:join key 说明
|
||||
|
||||
|
|
@ -172,9 +180,23 @@ cloudflared tunnel --url http://127.0.0.1:18791
|
|||
|
||||
- 尽量帮主人把“启动步骤”做完,让主人只负责“看”和“体验”
|
||||
- 如果主人要对外访问,优先用 Cloudflare Tunnel(最快最方便)
|
||||
- 状态切换应由你主动执行:
|
||||
- 接任务前,先把自己状态切到“工作中”(如 `writing/researching/executing`),再开始工作;
|
||||
- 完成任务后,先把自己状态切回“待命中(idle)”,再进入休息/等待下一任务。
|
||||
- 状态切换应由你主动执行(核心必做):
|
||||
- 收到主人消息后 **立刻**:`python3 set_state.py start "正在回复主人..." --ttl 120 --source chat`
|
||||
- 执行 push/发布/同步时:`python3 set_state.py sync "正在同步中..." --ttl 300 --source sync`
|
||||
- 回复完成后 **立刻**:`python3 set_state.py done "待命中,随时准备" --source chat`
|
||||
- 异常时:`python3 set_state.py err "处理失败,正在排查" --ttl 120 --source chat`
|
||||
- 命令型任务推荐用:`bash scripts/state_guard.sh --state syncing --detail "正在执行命令" -- <command>`(自动回 idle,避免漏切)
|
||||
- 对话链路必须按 `docs/CHAT_STATUS_INTEGRATION_TEMPLATE.md` 的 try/finally 模板接入(防止偶发不回待命)
|
||||
|
||||
### 9.1 给“安装龙虾”的硬性接入要求(必须执行)
|
||||
|
||||
当你把本 Skill 丢给其他龙虾做安装时,要求它们必须做到:
|
||||
|
||||
1) 所有对话链路都接入状态模板(收到消息先 start,回复后 done)
|
||||
2) 所有命令型操作都用 `scripts/state_guard.sh` 包裹(防漏切)
|
||||
3) `office-agent-push.py` 使用秒级同步参数(变化即推 + 心跳)
|
||||
|
||||
如果它没做到这 3 条,就不算“安装完成”。
|
||||
- 注意不要暴露你主人的内网地址或私有信息
|
||||
|
||||
---
|
||||
|
|
|
|||
263
backend/app.py
263
backend/app.py
|
|
@ -1,7 +1,8 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Star Office UI - Backend State Service"""
|
||||
|
||||
from flask import Flask, jsonify, send_from_directory, make_response, request, session
|
||||
from flask import Flask, jsonify, send_from_directory, make_response, request, session, g
|
||||
from werkzeug.exceptions import RequestEntityTooLarge
|
||||
from datetime import datetime, timedelta
|
||||
import json
|
||||
import os
|
||||
|
|
@ -12,8 +13,17 @@ import shutil
|
|||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from security_utils import is_production_mode, is_strong_secret, is_strong_drawer_pass
|
||||
from security_utils import (
|
||||
is_production_mode,
|
||||
is_strong_secret,
|
||||
is_strong_drawer_pass,
|
||||
parse_csv_set,
|
||||
feature_enabled,
|
||||
is_valid_bearer,
|
||||
)
|
||||
from memo_utils import get_yesterday_date_str, sanitize_content, extract_memo_from_file
|
||||
from store_utils import (
|
||||
load_agents_state as _store_load_agents_state,
|
||||
|
|
@ -80,17 +90,73 @@ app.config.update(
|
|||
|
||||
# Guard join-agent critical section to enforce per-key concurrency under parallel requests
|
||||
join_lock = threading.Lock()
|
||||
# Guard local main-state file writes
|
||||
state_lock = threading.Lock()
|
||||
|
||||
# Generate a version timestamp once at server startup for cache busting
|
||||
VERSION_TIMESTAMP = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
ASSET_DRAWER_PASS_DEFAULT = os.getenv("ASSET_DRAWER_PASS", "1234")
|
||||
|
||||
# Phase 2 hardening (non-breaking): optional write-endpoint bearer guard.
|
||||
# Default OFF to avoid changing existing behavior before explicit enable.
|
||||
WRITE_API_BEARER_ENABLED = feature_enabled("STAR_OFFICE_WRITE_API_BEARER_ENABLED", default=False)
|
||||
WRITE_API_TOKENS = parse_csv_set(os.getenv("STAR_OFFICE_WRITE_API_TOKENS", ""))
|
||||
PROTECTED_WRITE_PATHS = {
|
||||
"/set_state",
|
||||
"/join-agent",
|
||||
"/leave-agent",
|
||||
"/agent-push",
|
||||
"/agent-approve",
|
||||
"/agent-reject",
|
||||
}
|
||||
|
||||
# Upload hardening (non-breaking default; affects only oversized uploads)
|
||||
MAX_UPLOAD_MB = int(os.getenv("STAR_OFFICE_MAX_UPLOAD_MB", "20"))
|
||||
MAX_UPLOAD_BYTES = max(1, MAX_UPLOAD_MB) * 1024 * 1024
|
||||
app.config["MAX_CONTENT_LENGTH"] = MAX_UPLOAD_BYTES
|
||||
|
||||
# Basic in-process rate limiter for sensitive write endpoints
|
||||
# Format: STAR_OFFICE_WRITE_RATE_LIMIT="60,60" => 60 requests / 60 seconds per IP+path
|
||||
_rate_limit_raw = (os.getenv("STAR_OFFICE_WRITE_RATE_LIMIT") or "60,60").strip()
|
||||
try:
|
||||
_limit_count, _limit_window = [int(x.strip()) for x in _rate_limit_raw.split(",", 1)]
|
||||
except Exception:
|
||||
_limit_count, _limit_window = 60, 60
|
||||
WRITE_RATE_LIMIT_COUNT = max(1, _limit_count)
|
||||
WRITE_RATE_LIMIT_WINDOW_SECONDS = max(1, _limit_window)
|
||||
_rate_buckets = {}
|
||||
_rate_lock = threading.Lock()
|
||||
|
||||
# Optional read hardening for asset inventory/template endpoints (default OFF for compatibility)
|
||||
ASSET_READ_AUTH_ENABLED = feature_enabled("STAR_OFFICE_ASSET_READ_AUTH_ENABLED", default=False)
|
||||
|
||||
# Background generation guard (non-breaking): avoid concurrent heavy generation jobs.
|
||||
BG_GENERATE_LOCK = threading.Lock()
|
||||
GEMINI_SUBPROCESS_TIMEOUT_SECONDS = int(os.getenv("STAR_OFFICE_GEMINI_TIMEOUT_SECONDS", "240"))
|
||||
GEMINI_PROMPT_MAX_CHARS = int(os.getenv("STAR_OFFICE_GEMINI_PROMPT_MAX_CHARS", "1200"))
|
||||
|
||||
# Optional request logging (off by default, no behavior change)
|
||||
REQUEST_LOG_ENABLED = feature_enabled("STAR_OFFICE_REQUEST_LOG_ENABLED", default=False)
|
||||
REQUEST_LOG_PATH = os.getenv("STAR_OFFICE_REQUEST_LOG_PATH", os.path.join(ROOT_DIR, "request.log"))
|
||||
|
||||
# Production strict mode: enforce all hardening toggles on startup when enabled.
|
||||
PROD_STRICT_MODE = feature_enabled("STAR_OFFICE_PROD_STRICT_MODE", default=False)
|
||||
|
||||
if is_production_mode():
|
||||
hardening_errors = []
|
||||
if not is_strong_secret(str(app.secret_key)):
|
||||
hardening_errors.append("FLASK_SECRET_KEY / STAR_OFFICE_SECRET is weak (need >=24 chars, non-default)")
|
||||
if not is_strong_drawer_pass(ASSET_DRAWER_PASS_DEFAULT):
|
||||
hardening_errors.append("ASSET_DRAWER_PASS is weak (do not use default 1234; recommend >=8 chars)")
|
||||
|
||||
if PROD_STRICT_MODE:
|
||||
if not WRITE_API_BEARER_ENABLED:
|
||||
hardening_errors.append("PROD_STRICT_MODE requires STAR_OFFICE_WRITE_API_BEARER_ENABLED=true")
|
||||
if WRITE_API_BEARER_ENABLED and not WRITE_API_TOKENS:
|
||||
hardening_errors.append("PROD_STRICT_MODE requires non-empty STAR_OFFICE_WRITE_API_TOKENS")
|
||||
if not ASSET_READ_AUTH_ENABLED:
|
||||
hardening_errors.append("PROD_STRICT_MODE requires STAR_OFFICE_ASSET_READ_AUTH_ENABLED=true")
|
||||
|
||||
if hardening_errors:
|
||||
raise RuntimeError("Security hardening check failed in production mode: " + "; ".join(hardening_errors))
|
||||
|
||||
|
|
@ -105,6 +171,119 @@ def _require_asset_editor_auth():
|
|||
return jsonify({"ok": False, "code": "UNAUTHORIZED", "msg": "Asset editor auth required"}), 401
|
||||
|
||||
|
||||
def _extract_bearer_token() -> str:
|
||||
h = (request.headers.get("Authorization") or "").strip()
|
||||
if not h.lower().startswith("bearer "):
|
||||
return ""
|
||||
return h[7:].strip()
|
||||
|
||||
|
||||
def _mask_sensitive(value: str, keep: int = 4) -> str:
|
||||
s = (value or "").strip()
|
||||
if not s:
|
||||
return ""
|
||||
if len(s) <= keep:
|
||||
return "*" * len(s)
|
||||
return ("*" * (len(s) - keep)) + s[-keep:]
|
||||
|
||||
|
||||
def _append_request_log_line(payload: dict):
|
||||
try:
|
||||
line = json.dumps(payload, ensure_ascii=False)
|
||||
parent = os.path.dirname(os.path.abspath(REQUEST_LOG_PATH)) or "."
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
with open(REQUEST_LOG_PATH, "a", encoding="utf-8") as f:
|
||||
f.write(line + "\n")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _write_api_auth_ok() -> bool:
|
||||
if not WRITE_API_BEARER_ENABLED:
|
||||
return True
|
||||
# Misconfiguration safety: enabled but no token configured -> reject all writes.
|
||||
if not WRITE_API_TOKENS:
|
||||
return False
|
||||
token = _extract_bearer_token()
|
||||
return is_valid_bearer(token, WRITE_API_TOKENS)
|
||||
|
||||
|
||||
def _require_asset_read_auth():
|
||||
if not ASSET_READ_AUTH_ENABLED:
|
||||
return None
|
||||
# Allow either drawer session auth or bearer token auth.
|
||||
if _is_asset_editor_authed() or _write_api_auth_ok():
|
||||
return None
|
||||
return jsonify({"ok": False, "code": "UNAUTHORIZED", "msg": "Asset read auth required"}), 401
|
||||
|
||||
|
||||
def _client_ip() -> str:
|
||||
# Prefer reverse-proxy headers when present.
|
||||
xff = (request.headers.get("X-Forwarded-For") or "").strip()
|
||||
if xff:
|
||||
return xff.split(",")[0].strip()
|
||||
xrip = (request.headers.get("X-Real-IP") or "").strip()
|
||||
if xrip:
|
||||
return xrip
|
||||
return (request.remote_addr or "unknown").strip() or "unknown"
|
||||
|
||||
|
||||
def _check_rate_limit(path: str) -> tuple[bool, int]:
|
||||
now = int(time.time())
|
||||
key = f"{_client_ip()}::{path}"
|
||||
with _rate_lock:
|
||||
# Opportunistic cleanup to avoid unbounded bucket growth.
|
||||
if len(_rate_buckets) > 5000:
|
||||
expired_keys = [k for k, v in _rate_buckets.items() if now >= int(v.get("reset_at", 0))]
|
||||
for k in expired_keys[:2000]:
|
||||
_rate_buckets.pop(k, None)
|
||||
|
||||
item = _rate_buckets.get(key)
|
||||
if not item or now >= item["reset_at"]:
|
||||
_rate_buckets[key] = {"count": 1, "reset_at": now + WRITE_RATE_LIMIT_WINDOW_SECONDS}
|
||||
return True, WRITE_RATE_LIMIT_WINDOW_SECONDS
|
||||
|
||||
if item["count"] >= WRITE_RATE_LIMIT_COUNT:
|
||||
retry_after = max(1, item["reset_at"] - now)
|
||||
return False, retry_after
|
||||
|
||||
item["count"] += 1
|
||||
retry_after = max(1, item["reset_at"] - now)
|
||||
return True, retry_after
|
||||
|
||||
|
||||
@app.before_request
|
||||
def enforce_write_api_auth_if_enabled():
|
||||
# Request context baseline (for optional logging)
|
||||
g.request_id = str(uuid.uuid4())
|
||||
g.request_started_at = time.time()
|
||||
|
||||
if request.method != "POST":
|
||||
return None
|
||||
if request.path not in PROTECTED_WRITE_PATHS:
|
||||
return None
|
||||
|
||||
allowed, retry_after = _check_rate_limit(request.path)
|
||||
if not allowed:
|
||||
resp = jsonify({"ok": False, "code": "RATE_LIMITED", "msg": "Too many requests"})
|
||||
resp.status_code = 429
|
||||
resp.headers["Retry-After"] = str(retry_after)
|
||||
return resp
|
||||
|
||||
if _write_api_auth_ok():
|
||||
return None
|
||||
return jsonify({"ok": False, "code": "UNAUTHORIZED", "msg": "Write API auth required"}), 401
|
||||
|
||||
|
||||
@app.errorhandler(RequestEntityTooLarge)
|
||||
def handle_request_too_large(_e):
|
||||
return jsonify({
|
||||
"ok": False,
|
||||
"code": "PAYLOAD_TOO_LARGE",
|
||||
"msg": f"Upload exceeds limit ({MAX_UPLOAD_MB}MB)",
|
||||
}), 413
|
||||
|
||||
|
||||
@app.after_request
|
||||
def add_no_cache_headers(response):
|
||||
"""Apply cache policy by path:
|
||||
|
|
@ -120,6 +299,32 @@ def add_no_cache_headers(response):
|
|||
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate, max-age=0"
|
||||
response.headers["Pragma"] = "no-cache"
|
||||
response.headers["Expires"] = "0"
|
||||
|
||||
# Optional request log (redacted)
|
||||
if REQUEST_LOG_ENABLED:
|
||||
try:
|
||||
elapsed_ms = int((time.time() - float(getattr(g, "request_started_at", time.time()))) * 1000)
|
||||
except Exception:
|
||||
elapsed_ms = -1
|
||||
|
||||
ua = (request.headers.get("User-Agent") or "")[:180]
|
||||
auth_header = (request.headers.get("Authorization") or "").strip()
|
||||
auth_masked = ""
|
||||
if auth_header.lower().startswith("bearer "):
|
||||
auth_masked = "Bearer " + _mask_sensitive(auth_header[7:].strip(), keep=4)
|
||||
|
||||
_append_request_log_line({
|
||||
"ts": datetime.now().isoformat(timespec="seconds"),
|
||||
"request_id": getattr(g, "request_id", ""),
|
||||
"ip": _client_ip(),
|
||||
"method": request.method,
|
||||
"path": path,
|
||||
"status": int(getattr(response, "status_code", 0) or 0),
|
||||
"duration_ms": elapsed_ms,
|
||||
"ua": ua,
|
||||
"auth": auth_masked,
|
||||
})
|
||||
|
||||
return response
|
||||
|
||||
# Default state
|
||||
|
|
@ -183,9 +388,33 @@ def load_state():
|
|||
|
||||
|
||||
def save_state(state: dict):
|
||||
"""Save state to file"""
|
||||
with open(STATE_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(state, f, ensure_ascii=False, indent=2)
|
||||
"""Save state to file (atomic, non-breaking behavior)."""
|
||||
target = os.path.abspath(STATE_FILE)
|
||||
parent = os.path.dirname(target) or "."
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
|
||||
with state_lock:
|
||||
fd, tmp_path = tempfile.mkstemp(prefix="._tmp_state_", suffix=".json", dir=parent)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(state, f, ensure_ascii=False, indent=2)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp_path, target)
|
||||
try:
|
||||
dfd = os.open(parent, os.O_DIRECTORY)
|
||||
try:
|
||||
os.fsync(dfd)
|
||||
finally:
|
||||
os.close(dfd)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
if os.path.exists(tmp_path):
|
||||
os.remove(tmp_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def ensure_electron_standalone_snapshot():
|
||||
|
|
@ -675,7 +904,13 @@ def _generate_rpg_background_to_webp(out_webp_path: str, width: int = 1280, heig
|
|||
env["GEMINI_API_KEY"] = api_key
|
||||
|
||||
def _run_cmd(cmd_args):
|
||||
return subprocess.run(cmd_args, capture_output=True, text=True, env=env, timeout=240)
|
||||
return subprocess.run(
|
||||
cmd_args,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
timeout=max(30, GEMINI_SUBPROCESS_TIMEOUT_SECONDS),
|
||||
)
|
||||
|
||||
def _is_model_unavailable_error(text: str) -> bool:
|
||||
low = (text or "").strip().lower()
|
||||
|
|
@ -1259,6 +1494,9 @@ def set_state_endpoint():
|
|||
|
||||
@app.route("/assets/template.zip", methods=["GET"])
|
||||
def assets_template_download():
|
||||
guard = _require_asset_read_auth()
|
||||
if guard:
|
||||
return guard
|
||||
if not os.path.exists(ASSET_TEMPLATE_ZIP):
|
||||
return jsonify({"ok": False, "msg": "模板包不存在,请先生成"}), 404
|
||||
return send_from_directory(ROOT_DIR, "assets-replace-template.zip", as_attachment=True)
|
||||
|
|
@ -1266,6 +1504,9 @@ def assets_template_download():
|
|||
|
||||
@app.route("/assets/list", methods=["GET"])
|
||||
def assets_list():
|
||||
guard = _require_asset_read_auth()
|
||||
if guard:
|
||||
return guard
|
||||
items = []
|
||||
for p in FRONTEND_PATH.rglob("*"):
|
||||
if not p.is_file():
|
||||
|
|
@ -1302,9 +1543,13 @@ def assets_generate_rpg_background():
|
|||
guard = _require_asset_editor_auth()
|
||||
if guard:
|
||||
return guard
|
||||
if not BG_GENERATE_LOCK.acquire(blocking=False):
|
||||
return jsonify({"ok": False, "code": "GEN_BUSY", "msg": "已有生成任务进行中,请稍后重试"}), 429
|
||||
try:
|
||||
req = request.get_json(silent=True) or {}
|
||||
custom_prompt = (req.get("prompt") or "").strip() if isinstance(req, dict) else ""
|
||||
if len(custom_prompt) > GEMINI_PROMPT_MAX_CHARS:
|
||||
custom_prompt = custom_prompt[:GEMINI_PROMPT_MAX_CHARS]
|
||||
speed_mode = (req.get("speed_mode") or "quality").strip().lower() if isinstance(req, dict) else "quality"
|
||||
if speed_mode not in {"fast", "quality"}:
|
||||
speed_mode = "fast"
|
||||
|
|
@ -1357,6 +1602,11 @@ def assets_generate_rpg_background():
|
|||
"detail": detail,
|
||||
}), 400
|
||||
return jsonify({"ok": False, "msg": msg}), 500
|
||||
finally:
|
||||
try:
|
||||
BG_GENERATE_LOCK.release()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@app.route("/assets/restore-reference-background", methods=["POST"])
|
||||
|
|
@ -1965,6 +2215,7 @@ if __name__ == "__main__":
|
|||
print(f"Listening on: http://0.0.0.0:{backend_port}")
|
||||
mode = "production" if is_production_mode() else "development"
|
||||
print(f"Mode: {mode}")
|
||||
print(f"Prod strict mode: {'ON' if PROD_STRICT_MODE else 'OFF'}")
|
||||
if is_production_mode():
|
||||
print("Security hardening: ENABLED (strict checks)")
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ P1 step: extraction without behavior change.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import os
|
||||
|
||||
|
||||
|
|
@ -32,3 +33,26 @@ def is_strong_drawer_pass(pwd: str) -> bool:
|
|||
if pwd == "1234":
|
||||
return False
|
||||
return len(pwd) >= 8
|
||||
|
||||
|
||||
def parse_csv_set(value: str | None) -> set[str]:
|
||||
if not value:
|
||||
return set()
|
||||
return {x.strip() for x in str(value).split(",") if x and x.strip()}
|
||||
|
||||
|
||||
def feature_enabled(name: str, default: bool = False) -> bool:
|
||||
v = os.getenv(name)
|
||||
if v is None:
|
||||
return default
|
||||
return str(v).strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def is_valid_bearer(token: str, accepted_tokens: set[str]) -> bool:
|
||||
t = (token or "").strip()
|
||||
if not t or not accepted_tokens:
|
||||
return False
|
||||
for at in accepted_tokens:
|
||||
if hmac.compare_digest(t, at):
|
||||
return True
|
||||
return False
|
||||
|
|
|
|||
|
|
@ -8,6 +8,23 @@ from __future__ import annotations
|
|||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
|
||||
|
||||
# Process-wide lock map: per-file lock to reduce concurrent writer collisions.
|
||||
_LOCKS: dict[str, threading.Lock] = {}
|
||||
_LOCKS_GUARD = threading.Lock()
|
||||
|
||||
|
||||
def _get_lock(path: str) -> threading.Lock:
|
||||
p = os.path.abspath(path)
|
||||
with _LOCKS_GUARD:
|
||||
lk = _LOCKS.get(p)
|
||||
if lk is None:
|
||||
lk = threading.Lock()
|
||||
_LOCKS[p] = lk
|
||||
return lk
|
||||
|
||||
|
||||
def _load_json(path: str):
|
||||
|
|
@ -16,8 +33,39 @@ def _load_json(path: str):
|
|||
|
||||
|
||||
def _save_json(path: str, data):
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
"""Atomic JSON write: temp file + fsync + replace.
|
||||
|
||||
Keeps behavior unchanged while improving crash/concurrency safety.
|
||||
"""
|
||||
target = os.path.abspath(path)
|
||||
parent = os.path.dirname(target) or "."
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
|
||||
lock = _get_lock(target)
|
||||
with lock:
|
||||
fd, tmp_path = tempfile.mkstemp(prefix="._tmp_json_", suffix=".json", dir=parent)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp_path, target)
|
||||
# best-effort directory fsync to persist rename metadata
|
||||
try:
|
||||
dfd = os.open(parent, os.O_DIRECTORY)
|
||||
try:
|
||||
os.fsync(dfd)
|
||||
finally:
|
||||
os.close(dfd)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
# os.replace 成功后 tmp_path 不存在;失败时清理残留
|
||||
try:
|
||||
if os.path.exists(tmp_path):
|
||||
os.remove(tmp_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def load_agents_state(path: str, default_agents: list) -> list:
|
||||
|
|
|
|||
38
docs/CHAT_STATUS_INTEGRATION_TEMPLATE.md
Normal file
38
docs/CHAT_STATUS_INTEGRATION_TEMPLATE.md
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# 对话状态切换集成模板(最终版)
|
||||
|
||||
这是“聊天秒切动画状态”的推荐模板,核心是 **try/finally 一定回 idle**。
|
||||
|
||||
## 伪代码模板
|
||||
|
||||
```python
|
||||
trace_id = gen_trace_id()
|
||||
set_state("start", "正在回复主人...", ttl=120, source="chat", trace_id=trace_id)
|
||||
try:
|
||||
# 你的正常处理逻辑
|
||||
# 如遇到 git push / 上传 / 同步等重动作:
|
||||
set_state("sync", "正在同步中...", ttl=300, source="sync", trace_id=trace_id)
|
||||
run_heavy_task()
|
||||
|
||||
send_reply("...回复内容...")
|
||||
except Exception:
|
||||
set_state("err", "处理失败,正在排查", ttl=120, source="chat", trace_id=trace_id)
|
||||
raise
|
||||
finally:
|
||||
set_state("done", "待命中,随时准备", source="chat", trace_id=trace_id)
|
||||
```
|
||||
|
||||
## Shell 版模板
|
||||
|
||||
```bash
|
||||
python3 set_state.py start "正在回复主人..." --ttl 120 --source chat --trace-id "$TRACE_ID"
|
||||
|
||||
# ...处理逻辑...
|
||||
|
||||
python3 set_state.py done "待命中,随时准备" --source chat --trace-id "$TRACE_ID"
|
||||
```
|
||||
|
||||
## 关键原则
|
||||
1) 收到消息先切状态,再开始处理
|
||||
2) 回复后一定回 idle(finally)
|
||||
3) 同步动作切 syncing,失败切 error
|
||||
4) trace_id 串联一整次会话,便于排查“漏切”
|
||||
60
docs/CHAT_STATUS_REALTIME.md
Normal file
60
docs/CHAT_STATUS_REALTIME.md
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
# 对话秒切状态接入指南(核心功能)
|
||||
|
||||
目标:让“收到消息→工作中,回复完成→待命,push/同步→syncing”稳定秒切。
|
||||
|
||||
## 1) 最小接入(推荐)
|
||||
|
||||
### 收到用户消息时
|
||||
```bash
|
||||
python3 set_state.py start "正在回复主人..." --ttl 120 --source chat
|
||||
```
|
||||
|
||||
### 执行同步/发布动作时
|
||||
```bash
|
||||
python3 set_state.py sync "正在同步代码到远端..." --ttl 300 --source sync
|
||||
```
|
||||
|
||||
### 回复完成时
|
||||
```bash
|
||||
python3 set_state.py done "待命中,随时准备" --source chat
|
||||
```
|
||||
|
||||
### 发生异常时
|
||||
```bash
|
||||
python3 set_state.py err "处理失败,正在排查" --ttl 120 --source chat
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2) 命令型任务推荐用 guard(防漏切)
|
||||
|
||||
```bash
|
||||
bash scripts/state_guard.sh \
|
||||
--state syncing \
|
||||
--detail "正在 push 分支" \
|
||||
--ttl 300 \
|
||||
--idle-detail "同步完成,待命中" \
|
||||
-- git push fork feat/office-art-rebuild
|
||||
```
|
||||
|
||||
特点:
|
||||
- 命令开始前自动切状态
|
||||
- 命令结束后自动回 idle
|
||||
- 命令失败自动切 error
|
||||
|
||||
---
|
||||
|
||||
## 3) 验收标准(你可以直接在线看)
|
||||
|
||||
1. 你发消息后,角色应在 0.5~1 秒内切到工作态
|
||||
2. 回复发出后,1 秒内回待命态
|
||||
3. push/generate 等重操作期间,应切到 syncing 或 executing
|
||||
4. detail 文案应跟当前动作一致
|
||||
|
||||
---
|
||||
|
||||
## 4) 注意事项
|
||||
|
||||
- 必须保证“每个链路都有 finally 回 idle”
|
||||
- 不要只在 UI 按钮里切状态;对话链路也必须切
|
||||
- 长任务务必设置合适 ttl,避免卡死在 working 态
|
||||
78
docs/FINAL_ACCEPTANCE_SUMMARY_PHASE10.md
Normal file
78
docs/FINAL_ACCEPTANCE_SUMMARY_PHASE10.md
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
# Phase 10 收官:最终验收与回滚索引(非破坏改造)
|
||||
|
||||
更新时间:2026-03-04
|
||||
|
||||
---
|
||||
|
||||
## 一、你可直接线上验收的核心点(不看代码也能验)
|
||||
|
||||
1) 首页可打开,像素办公室正常加载
|
||||
2) 主状态切换正常(idle/writing/researching/executing/syncing/error)
|
||||
3) 多 agent 正常:加入、状态推送、离开
|
||||
4) 昨日小记正常显示
|
||||
5) 资产抽屉正常(认证、资产列表、上传、恢复)
|
||||
6) 生图链路正常(生成、恢复、回退、收藏)
|
||||
|
||||
> 若以上都正常,说明本轮优化“功能零丢失”达标。
|
||||
|
||||
---
|
||||
|
||||
## 二、Phase 1~9 改动与提交索引
|
||||
|
||||
- Phase 1 基线冻结:`4cf0240`
|
||||
- Phase 2 写接口可选鉴权:`7d463ab`
|
||||
- Phase 3 原子写一致性:`db12219`
|
||||
- Phase 4 发布前体检脚本:`d5ad92e`
|
||||
- Phase 5 上传限制 + 写限流:`c3774fc`
|
||||
- Phase 6 资产读接口可选保护 + 限流桶清理:`409baaf`
|
||||
- Phase 7 生图并发互斥 + prompt/超时边界:`1367535`
|
||||
- Phase 8 可选请求日志(脱敏):`9311ad7`
|
||||
- Phase 9 生产 strict 模式开关:`2895848`
|
||||
|
||||
---
|
||||
|
||||
## 三、推荐的生产环境开关(最终态)
|
||||
|
||||
建议在确认线上稳定后逐步启用:
|
||||
|
||||
```bash
|
||||
STAR_OFFICE_WRITE_API_BEARER_ENABLED=true
|
||||
STAR_OFFICE_WRITE_API_TOKENS=<your-long-random-token>
|
||||
STAR_OFFICE_ASSET_READ_AUTH_ENABLED=true
|
||||
STAR_OFFICE_MAX_UPLOAD_MB=20
|
||||
STAR_OFFICE_WRITE_RATE_LIMIT=60,60
|
||||
STAR_OFFICE_GEMINI_TIMEOUT_SECONDS=240
|
||||
STAR_OFFICE_GEMINI_PROMPT_MAX_CHARS=1200
|
||||
STAR_OFFICE_REQUEST_LOG_ENABLED=false
|
||||
STAR_OFFICE_PROD_STRICT_MODE=true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、快速回滚索引
|
||||
|
||||
### 1) 环境级回滚(最快)
|
||||
将以下开关恢复为保守值并重启:
|
||||
- `STAR_OFFICE_PROD_STRICT_MODE=false`
|
||||
- `STAR_OFFICE_ASSET_READ_AUTH_ENABLED=false`
|
||||
- `STAR_OFFICE_WRITE_API_BEARER_ENABLED=false`
|
||||
|
||||
### 2) 代码级回滚(按 phase 逆序)
|
||||
```bash
|
||||
git revert 2895848 9311ad7 1367535 409baaf c3774fc d5ad92e db12219 7d463ab 4cf0240
|
||||
```
|
||||
|
||||
### 3) 快照回滚(运行态文件)
|
||||
可从:
|
||||
- `snapshots/baseline-20260304-231354`
|
||||
恢复关键 json 运行态文件。
|
||||
|
||||
---
|
||||
|
||||
## 五、附:发布前体检命令
|
||||
|
||||
```bash
|
||||
bash scripts/release_preflight.sh http://127.0.0.1:18791
|
||||
```
|
||||
|
||||
通过后再发版,能显著降低回归风险。
|
||||
25
docs/PHASE11_ACCEPTANCE.md
Normal file
25
docs/PHASE11_ACCEPTANCE.md
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# Phase 11 验收单(对话状态“秒切”链路增强)
|
||||
|
||||
本阶段目标:
|
||||
- 状态变化时尽快推送到办公室,不再受固定 15 秒周期限制
|
||||
|
||||
## 已做内容
|
||||
1) `office-agent-push.py` 改为:
|
||||
- 状态变化立即推送
|
||||
- 心跳保活低频推送
|
||||
- 最小推送间隔防抖
|
||||
|
||||
2) 新增可配置参数(环境变量):
|
||||
- `OFFICE_POLL_INTERVAL`(默认 0.4s)
|
||||
- `OFFICE_MIN_PUSH_GAP`(默认 0.8s)
|
||||
- `OFFICE_PUSH_INTERVAL`(默认 2s,心跳)
|
||||
|
||||
3) README 增加秒级同步说明
|
||||
|
||||
## 验收方式(线上可见)
|
||||
- 你发消息后,状态应比旧版更快切到工作态
|
||||
- 回复后应更快回 idle
|
||||
- push/同步动作应更快切 syncing
|
||||
|
||||
## 回滚
|
||||
- 代码回滚:`git revert <phase11_commit>`
|
||||
21
docs/PHASE12_ACCEPTANCE.md
Normal file
21
docs/PHASE12_ACCEPTANCE.md
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# Phase 12 验收单(对话链路闭环模板)
|
||||
|
||||
本阶段目标:
|
||||
- 把“聊天秒切状态”固化成可复用模板,避免反复靠口头约定
|
||||
|
||||
## 已做内容
|
||||
1) 新增 `scripts/chat_status_demo.sh`
|
||||
- 演示“收到消息->working,回复后->idle”完整闭环
|
||||
|
||||
2) 新增 `docs/CHAT_STATUS_INTEGRATION_TEMPLATE.md`
|
||||
- 提供 Python/Shell 模板
|
||||
- 明确 try/finally 回 idle、异常切 error 的标准
|
||||
|
||||
3) README/SKILL 补充模板入口
|
||||
|
||||
## 线上验收
|
||||
- 你看体感:回复链路状态切换应更稳定
|
||||
- 重点观察是否还有“回复完不回待命”的漏切
|
||||
|
||||
## 回滚
|
||||
- `git revert <phase12_commit>`
|
||||
19
docs/PHASE13_ACCEPTANCE.md
Normal file
19
docs/PHASE13_ACCEPTANCE.md
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
# Phase 13 验收单(Skill 开箱即用强化)
|
||||
|
||||
本阶段目标:
|
||||
- 把“聊天秒切状态”写成 Skill 的硬要求,避免新用户安装后行为不一致
|
||||
|
||||
## 已做内容
|
||||
1) SKILL.md 明确:
|
||||
- 对话链路必须接 `set_state.py start/done`
|
||||
- 命令链路必须用 `scripts/state_guard.sh`
|
||||
- 推送链路默认秒级同步参数
|
||||
|
||||
2) 新增“安装龙虾硬性接入要求”三条
|
||||
- 不满足则不算安装完成
|
||||
|
||||
## 验收
|
||||
- 新龙虾按 SKILL 安装时,默认就会带上状态秒切流程
|
||||
|
||||
## 回滚
|
||||
- `git revert <phase13_commit>`
|
||||
24
docs/PHASE1_ACCEPTANCE.md
Normal file
24
docs/PHASE1_ACCEPTANCE.md
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Phase 1 验收单(基线冻结与回滚准备)
|
||||
|
||||
你只需要确认 3 件事:
|
||||
|
||||
1) 是否看到新文件:
|
||||
- docs/STABILITY_PLAN_NON_BREAKING.md
|
||||
- scripts/baseline_snapshot.sh
|
||||
- docs/PHASE1_ACCEPTANCE.md
|
||||
|
||||
2) 是否可执行快照脚本:
|
||||
```bash
|
||||
cd /root/.openclaw/workspace/Star-Office-UI
|
||||
bash scripts/baseline_snapshot.sh
|
||||
```
|
||||
执行后应输出一个目录:
|
||||
`snapshots/baseline-YYYYMMDD-HHMMSS`
|
||||
|
||||
3) 是否接受“每个 Phase 先验收再继续”的节奏。
|
||||
|
||||
---
|
||||
|
||||
通过后我将进入 Phase 2:
|
||||
- 仅做可开关的安全加固(不删功能、不改交互)
|
||||
- 做完给你一份“逐项功能对照验收表”再继续。
|
||||
42
docs/PHASE2_ACCEPTANCE.md
Normal file
42
docs/PHASE2_ACCEPTANCE.md
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
# Phase 2 验收单(安全加固:可开关、非破坏)
|
||||
|
||||
本阶段目标:
|
||||
- 不删功能
|
||||
- 默认行为不变
|
||||
- 新增“可开关”的写接口 Bearer 鉴权
|
||||
|
||||
## 已交付
|
||||
1. 后端新增可选安全开关:
|
||||
- `STAR_OFFICE_WRITE_API_BEARER_ENABLED`(默认 false)
|
||||
- `STAR_OFFICE_WRITE_API_TOKENS`(逗号分隔 token)
|
||||
|
||||
2. 受保护写接口(开关开启后生效):
|
||||
- `POST /set_state`
|
||||
- `POST /join-agent`
|
||||
- `POST /leave-agent`
|
||||
- `POST /agent-push`
|
||||
- `POST /agent-approve`
|
||||
- `POST /agent-reject`
|
||||
|
||||
3. 文档与安全检查增强:
|
||||
- `.env.example` 增加配置说明
|
||||
- `scripts/security_check.py` 增加对应检查提示
|
||||
|
||||
## 你可在公网直接验收(推荐)
|
||||
### A. 默认兼容(不开启)
|
||||
- 现网不改环境变量时,功能应与之前一致。
|
||||
|
||||
### B. 安全开关测试(你确认后再开)
|
||||
1) 设置环境变量并重启服务:
|
||||
```bash
|
||||
STAR_OFFICE_WRITE_API_BEARER_ENABLED=true
|
||||
STAR_OFFICE_WRITE_API_TOKENS=your-long-random-token
|
||||
```
|
||||
2) 验证:
|
||||
- 不带 `Authorization: Bearer ...` 调用上述 POST,应返回 401
|
||||
- 带正确 Bearer,应正常
|
||||
- 页面浏览(GET)不受影响
|
||||
|
||||
## 回滚方式
|
||||
- 立即回滚:把 `STAR_OFFICE_WRITE_API_BEARER_ENABLED` 改回 false 并重启
|
||||
- 代码回滚:`git revert <phase2_commit>`
|
||||
30
docs/PHASE3_ACCEPTANCE.md
Normal file
30
docs/PHASE3_ACCEPTANCE.md
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# Phase 3 验收单(状态一致性加固:原子写入)
|
||||
|
||||
本阶段目标:
|
||||
- 在不改功能前提下,降低并发/异常情况下 JSON 文件写坏或丢写的概率
|
||||
|
||||
## 已做改动(不改接口、不改交互)
|
||||
1) `backend/store_utils.py`
|
||||
- 所有 JSON 保存改为:临时文件写入 -> fsync -> `os.replace` 原子替换
|
||||
- 增加按文件粒度锁,减少并发写冲突
|
||||
|
||||
2) `backend/app.py`
|
||||
- `state.json` 的保存同样改为原子写(加锁 + tmp + replace)
|
||||
|
||||
## 为什么这步“看不见但有用”
|
||||
- 线上你看不到 UI 变化,因为功能没变
|
||||
- 但在高并发或进程异常中断时,文件更不容易坏,状态更稳
|
||||
|
||||
## 线上验收方式(你可只看结果)
|
||||
1) 访问主页:
|
||||
- 页面正常加载、动画正常
|
||||
2) 快速切几次状态(你的原有方式):
|
||||
- 状态能正常变化
|
||||
3) 多 agent 场景:
|
||||
- 加入、推送、离开仍正常
|
||||
|
||||
只要你感知“和之前一样好用”,就算通过。
|
||||
|
||||
## 回滚方式
|
||||
- 代码回滚:`git revert <phase3_commit>`
|
||||
- 或直接回到前一提交
|
||||
25
docs/PHASE4_ACCEPTANCE.md
Normal file
25
docs/PHASE4_ACCEPTANCE.md
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# Phase 4 验收单(可维护性整理:发布回归入口)
|
||||
|
||||
本阶段目标:
|
||||
- 不改线上功能
|
||||
- 提升后续每次发布前/发布后的自检效率
|
||||
|
||||
## 已交付
|
||||
1) 新增 `scripts/release_preflight.sh`
|
||||
- 一条命令串联:
|
||||
- Python 语法检查
|
||||
- 安全检查(scripts/security_check.py)
|
||||
- 冒烟测试(scripts/smoke_test.py)
|
||||
|
||||
## 用法
|
||||
```bash
|
||||
cd /root/.openclaw/workspace/Star-Office-UI
|
||||
bash scripts/release_preflight.sh http://127.0.0.1:18791
|
||||
```
|
||||
|
||||
## 验收标准
|
||||
- 线上功能无变化(主页、状态、多 agent、资产抽屉)
|
||||
- 新脚本可执行,输出 PASS
|
||||
|
||||
## 回滚
|
||||
- `git revert <phase4_commit>`
|
||||
25
docs/PHASE5_ACCEPTANCE.md
Normal file
25
docs/PHASE5_ACCEPTANCE.md
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# Phase 5 验收单(抗滥用加固:上传限制 + 写接口限流)
|
||||
|
||||
本阶段目标(仍然非破坏):
|
||||
- 降低超大上传或高频写请求导致的服务不稳定风险
|
||||
|
||||
## 已做内容
|
||||
1) 上传大小限制(默认 20MB)
|
||||
- 新增环境变量:`STAR_OFFICE_MAX_UPLOAD_MB=20`
|
||||
- 超限返回 `413 PAYLOAD_TOO_LARGE`
|
||||
|
||||
2) 写接口限流(默认 60 次/60 秒,按 IP+路径)
|
||||
- 新增环境变量:`STAR_OFFICE_WRITE_RATE_LIMIT=60,60`
|
||||
- 触发限流返回 `429 RATE_LIMITED`,带 `Retry-After`
|
||||
|
||||
3) 配置与检查同步
|
||||
- `.env.example` 增加上述配置
|
||||
- `scripts/security_check.py` 增加格式与阈值检查
|
||||
|
||||
## 验收方式(线上可见)
|
||||
- 正常使用无变化(状态切换、join/push、资产操作仍正常)
|
||||
- 异常行为才会被拦截(超大上传 / 恶意高频请求)
|
||||
|
||||
## 回滚
|
||||
- 临时回滚:提高限制值(或恢复默认)
|
||||
- 代码回滚:`git revert <phase5_commit>`
|
||||
25
docs/PHASE6_ACCEPTANCE.md
Normal file
25
docs/PHASE6_ACCEPTANCE.md
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# Phase 6 验收单(资产读取面收敛 + 限流内存稳定)
|
||||
|
||||
本阶段目标(非破坏):
|
||||
- 进一步收敛资产侧读接口暴露面(可开关)
|
||||
- 防止限流桶无限增长(内存稳定性)
|
||||
|
||||
## 已做内容
|
||||
1) 新增可选开关:`STAR_OFFICE_ASSET_READ_AUTH_ENABLED=false`
|
||||
- 开启后,`/assets/list` 与 `/assets/template.zip` 需要认证(抽屉会话或 Bearer)
|
||||
- 默认关闭,保持现网兼容
|
||||
|
||||
2) 限流桶清理优化
|
||||
- 写接口限流桶超过阈值时自动清理过期项,避免长时间内存增长
|
||||
|
||||
3) 配置/检查同步
|
||||
- `.env.example` 增加开关说明
|
||||
- `scripts/security_check.py` 在生产模式下给出提示
|
||||
|
||||
## 线上验收
|
||||
- 不改环境变量时,功能应保持原样
|
||||
- 若后续你开启开关,未认证访问 `/assets/list` 应 401
|
||||
|
||||
## 回滚
|
||||
- 环境层:将 `STAR_OFFICE_ASSET_READ_AUTH_ENABLED=false`
|
||||
- 代码层:`git revert <phase6_commit>`
|
||||
27
docs/PHASE7_ACCEPTANCE.md
Normal file
27
docs/PHASE7_ACCEPTANCE.md
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
# Phase 7 验收单(生图链路稳态加固)
|
||||
|
||||
本阶段目标(非破坏):
|
||||
- 避免并发生图任务互相抢占,导致卡顿/失败
|
||||
- 限制超长 prompt 和不可控超时
|
||||
|
||||
## 已做内容
|
||||
1) 生图并发互斥
|
||||
- `/assets/generate-rpg-background` 同时只允许 1 个任务
|
||||
- 并发请求返回 `429 GEN_BUSY`
|
||||
|
||||
2) 生图超时可配置
|
||||
- 新增:`STAR_OFFICE_GEMINI_TIMEOUT_SECONDS`(默认 240)
|
||||
|
||||
3) prompt 长度上限可配置
|
||||
- 新增:`STAR_OFFICE_GEMINI_PROMPT_MAX_CHARS`(默认 1200)
|
||||
- 超长 prompt 自动截断,避免异常请求拖垮服务
|
||||
|
||||
4) 配置检查同步
|
||||
- `.env.example` 和 `scripts/security_check.py` 已同步
|
||||
|
||||
## 线上验收
|
||||
- 正常装修/生图流程不受影响
|
||||
- 连点生图时,第二个请求应提示“任务进行中”
|
||||
|
||||
## 回滚
|
||||
- 代码回滚:`git revert <phase7_commit>`
|
||||
24
docs/PHASE8_ACCEPTANCE.md
Normal file
24
docs/PHASE8_ACCEPTANCE.md
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Phase 8 验收单(可观测性增强:可选请求日志)
|
||||
|
||||
本阶段目标(非破坏):
|
||||
- 增加问题排查能力,但默认不开,不影响现网行为
|
||||
|
||||
## 已做内容
|
||||
1) 新增可选请求日志开关(默认 false)
|
||||
- `STAR_OFFICE_REQUEST_LOG_ENABLED=false`
|
||||
- `STAR_OFFICE_REQUEST_LOG_PATH=`(默认 `<repo>/request.log`)
|
||||
|
||||
2) 日志字段(已脱敏)
|
||||
- 时间、request_id、IP、方法、路径、状态码、耗时、UA
|
||||
- Bearer token 仅保留尾部少量字符(其余打码)
|
||||
|
||||
3) `security_check.py` 增加路径安全检查
|
||||
- 防止把日志路径配置到敏感目录
|
||||
|
||||
## 线上验收
|
||||
- 不开开关时:线上功能与之前完全一致
|
||||
- 开启后:仅多出日志文件,不改变业务逻辑
|
||||
|
||||
## 回滚
|
||||
- 环境层:`STAR_OFFICE_REQUEST_LOG_ENABLED=false`
|
||||
- 代码层:`git revert <phase8_commit>`
|
||||
20
docs/PHASE9_ACCEPTANCE.md
Normal file
20
docs/PHASE9_ACCEPTANCE.md
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# Phase 9 验收单(生产严格模式开关)
|
||||
|
||||
本阶段目标(非破坏):
|
||||
- 给生产环境增加“强制安全基线”开关,但默认关闭,不影响当前运行
|
||||
|
||||
## 已做内容
|
||||
1) 新增环境变量:`STAR_OFFICE_PROD_STRICT_MODE=false`
|
||||
2) 当生产 + strict=true 时,启动前强制检查:
|
||||
- `STAR_OFFICE_WRITE_API_BEARER_ENABLED=true`
|
||||
- `STAR_OFFICE_WRITE_API_TOKENS` 非空
|
||||
- `STAR_OFFICE_ASSET_READ_AUTH_ENABLED=true`
|
||||
3) `security_check.py` 同步 strict 规则检查
|
||||
|
||||
## 你现在如何使用
|
||||
- 先保持 false(不影响现网)
|
||||
- 当你确认前面 phase 都稳定,再切 true 做“硬约束上线”
|
||||
|
||||
## 回滚
|
||||
- 环境层:`STAR_OFFICE_PROD_STRICT_MODE=false`
|
||||
- 代码层:`git revert <phase9_commit>`
|
||||
101
docs/STABILITY_PLAN_NON_BREAKING.md
Normal file
101
docs/STABILITY_PLAN_NON_BREAKING.md
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
# Star-Office-UI 稳定版改造计划(非破坏 / 可回滚)
|
||||
|
||||
更新时间:2026-03-04
|
||||
原则:
|
||||
1. 不删功能
|
||||
2. 不改交互习惯
|
||||
3. 每步可回滚
|
||||
4. 每个关键节点先验收再继续
|
||||
|
||||
---
|
||||
|
||||
## 功能零丢失清单(作为验收基线)
|
||||
|
||||
### A. 核心看板
|
||||
- [ ] 首页可打开(办公室画面正常)
|
||||
- [ ] 主状态可读(/status)
|
||||
- [ ] 状态切换仍可驱动画面变化(idle/writing/researching/executing/syncing/error)
|
||||
- [ ] 昨日小记仍可展示(/yesterday-memo)
|
||||
|
||||
### B. 多 Agent 协作
|
||||
- [ ] /agents 可返回 agent 列表
|
||||
- [ ] join-agent 可用(加入后可见)
|
||||
- [ ] agent-push 可用(状态可更新)
|
||||
- [ ] leave-agent 可用(离开后移除)
|
||||
|
||||
### C. 资产抽屉
|
||||
- [ ] 资产验证码流程可用
|
||||
- [ ] 资产列表可读取
|
||||
- [ ] 上传替换可用
|
||||
- [ ] 恢复默认/恢复上一版可用
|
||||
|
||||
### D. 背景生图与回滚
|
||||
- [ ] 生图接口可调用(有 key 前提下)
|
||||
- [ ] 参考图恢复可用
|
||||
- [ ] 最近生成回退可用
|
||||
- [ ] 收藏列表/应用/删除可用
|
||||
|
||||
### E. 多语言与移动端
|
||||
- [ ] 中英日切换可用
|
||||
- [ ] 手机端可打开并查看
|
||||
|
||||
---
|
||||
|
||||
## 分步实施(每步都是“加固”,不砍功能)
|
||||
|
||||
### Phase 1(当前步骤):基线冻结与回滚准备(零行为变更)
|
||||
目标:确保后续改动前,具备“随时一键回滚”的抓手。
|
||||
|
||||
交付:
|
||||
- 基线文档(本文件)
|
||||
- 基线快照脚本(scripts/baseline_snapshot.sh)
|
||||
- 验收说明(docs/PHASE1_ACCEPTANCE.md)
|
||||
|
||||
风险:极低(不触碰运行逻辑)
|
||||
|
||||
---
|
||||
|
||||
### Phase 2:安全加固(最小改动)
|
||||
目标:补核心接口鉴权与防滥用,不影响现有功能路径。
|
||||
|
||||
只做:
|
||||
- 增加“可开关”的 API Token 校验(默认兼容开发模式)
|
||||
- 给高风险写接口加统一保护层
|
||||
- 上传大小限制与基础限流
|
||||
|
||||
不做:
|
||||
- 不改页面交互
|
||||
- 不改状态模型
|
||||
- 不动多语言和资源逻辑
|
||||
|
||||
回滚:
|
||||
- 回滚到 Phase 1 快照 + git 回退
|
||||
|
||||
---
|
||||
|
||||
### Phase 3:状态一致性加固
|
||||
目标:降低并发写文件导致的偶发错乱。
|
||||
|
||||
只做:
|
||||
- 原子写(tmp + rename)
|
||||
- 关键状态写路径统一锁
|
||||
|
||||
不做:
|
||||
- 不改接口协议
|
||||
- 不改前端展示
|
||||
|
||||
---
|
||||
|
||||
### Phase 4:可维护性整理(不改功能)
|
||||
目标:把后端继续拆小,降低后续维护成本。
|
||||
|
||||
只做:
|
||||
- 内部重构(保持 API 不变)
|
||||
- 补充回归脚本与发布 checklist
|
||||
|
||||
---
|
||||
|
||||
## 验收机制
|
||||
- 每个 Phase 完成后,先给你“可验证清单”
|
||||
- 你验收通过后,我再进下一步
|
||||
- 任意时刻可停止并回滚
|
||||
|
|
@ -1626,8 +1626,9 @@
|
|||
const IDLE_SOFA_ANCHOR = { x: 798, y: 272 }; // 统一中心锚点(原 sofa 左上 670,144 的中心)
|
||||
const IDLE_STAR_SCALE = 1.0; // star idle 改为256帧原生显示,不再放大
|
||||
// flowers 精灵表规格:固定单帧 128x128,4x4
|
||||
let FLOWERS_FRAME_W = 65;
|
||||
let FLOWERS_FRAME_H = 65;
|
||||
// 注意:不要依赖 /assets/list 才能拿到正确帧尺寸(该接口可能被鉴权)
|
||||
let FLOWERS_FRAME_W = 128;
|
||||
let FLOWERS_FRAME_H = 128;
|
||||
let FLOWERS_FRAME_COLS = 4;
|
||||
let FLOWERS_FRAME_ROWS = 4;
|
||||
let currentOfficeBgTextureKey = 'office_bg';
|
||||
|
|
@ -3856,7 +3857,7 @@ function toggleBrokerPanel() {
|
|||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('flowers 规格探测失败,使用默认 65x65', e);
|
||||
console.warn('flowers 规格探测失败,使用默认 128x128', e);
|
||||
}
|
||||
|
||||
applySavedPositionOverrides();
|
||||
|
|
|
|||
|
|
@ -21,7 +21,11 @@ AGENT_NAME = "" # 必填:你在办公室里的名字
|
|||
OFFICE_URL = "https://office.example.com" # 海辛办公室地址(一般不用改)
|
||||
|
||||
# === 推送配置 ===
|
||||
PUSH_INTERVAL_SECONDS = 15 # 每隔多少秒推送一次(更实时)
|
||||
# 旧版是固定 15s 推送,状态切换体感偏慢。
|
||||
# 新版默认:状态变化立即推;并保留低频心跳保活。
|
||||
PUSH_INTERVAL_SECONDS = float(os.environ.get("OFFICE_PUSH_INTERVAL", "2")) # 心跳间隔(秒)
|
||||
POLL_INTERVAL_SECONDS = float(os.environ.get("OFFICE_POLL_INTERVAL", "0.4")) # 本地状态轮询间隔(秒)
|
||||
MIN_PUSH_GAP_SECONDS = float(os.environ.get("OFFICE_MIN_PUSH_GAP", "0.8")) # 两次推送最小间隔(秒)
|
||||
STATUS_ENDPOINT = "/status"
|
||||
JOIN_ENDPOINT = "/join-agent"
|
||||
PUSH_ENDPOINT = "/agent-push"
|
||||
|
|
@ -217,7 +221,7 @@ def do_join(local):
|
|||
return False
|
||||
|
||||
|
||||
def do_push(local, status_data):
|
||||
def do_push(local, status_data, quiet=False):
|
||||
import requests
|
||||
payload = {
|
||||
"agentId": local.get("agentId"),
|
||||
|
|
@ -231,7 +235,8 @@ def do_push(local, status_data):
|
|||
data = r.json()
|
||||
if data.get("ok"):
|
||||
area = data.get("area", "breakroom")
|
||||
print(f"✅ 状态已同步,当前区域={area}")
|
||||
if (not quiet) or VERBOSE:
|
||||
print(f"✅ 状态已同步,当前区域={area}")
|
||||
return True
|
||||
|
||||
# 403/404:拒绝/移除 → 停止推送
|
||||
|
|
@ -265,18 +270,38 @@ def main():
|
|||
if not ok:
|
||||
sys.exit(1)
|
||||
|
||||
# 持续推送
|
||||
print(f"🚀 开始持续推送状态,间隔={PUSH_INTERVAL_SECONDS}秒")
|
||||
# 持续推送(变化秒推 + 心跳保活)
|
||||
print(f"🚀 开始持续推送状态:心跳={PUSH_INTERVAL_SECONDS}s,轮询={POLL_INTERVAL_SECONDS}s")
|
||||
print("🧭 状态逻辑:任务中→工作区;待命/完成→休息区;异常→bug区")
|
||||
print("🔐 若本地 /status 返回 Unauthorized(401),请设置环境变量:OFFICE_LOCAL_STATUS_TOKEN 或 OFFICE_LOCAL_STATUS_URL")
|
||||
|
||||
last_state = None
|
||||
last_detail = None
|
||||
last_push_at = 0.0
|
||||
next_heartbeat_at = 0.0
|
||||
|
||||
try:
|
||||
while True:
|
||||
now = time.time()
|
||||
try:
|
||||
status_data = fetch_local_status()
|
||||
do_push(local, status_data)
|
||||
s = status_data.get("state", "idle")
|
||||
d = status_data.get("detail", "")
|
||||
|
||||
changed = (s != last_state) or (d != last_detail)
|
||||
heartbeat_due = now >= next_heartbeat_at
|
||||
min_gap_ok = (now - last_push_at) >= MIN_PUSH_GAP_SECONDS
|
||||
|
||||
if min_gap_ok and (changed or heartbeat_due):
|
||||
ok = do_push(local, status_data, quiet=(not changed))
|
||||
if ok:
|
||||
last_state, last_detail = s, d
|
||||
last_push_at = now
|
||||
next_heartbeat_at = now + PUSH_INTERVAL_SECONDS
|
||||
except Exception as e:
|
||||
print(f"⚠️ 推送异常:{e}")
|
||||
time.sleep(PUSH_INTERVAL_SECONDS)
|
||||
|
||||
time.sleep(POLL_INTERVAL_SECONDS)
|
||||
except KeyboardInterrupt:
|
||||
print("\n👋 停止推送")
|
||||
sys.exit(0)
|
||||
|
|
|
|||
65
scripts/baseline_snapshot.sh
Executable file
65
scripts/baseline_snapshot.sh
Executable file
|
|
@ -0,0 +1,65 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Star-Office-UI baseline snapshot (non-destructive)
|
||||
# 用途:在改造前冻结当前可运行基线,便于随时回滚
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
TS="$(date +%Y%m%d-%H%M%S)"
|
||||
SNAP_DIR="$ROOT_DIR/snapshots/baseline-$TS"
|
||||
mkdir -p "$SNAP_DIR"
|
||||
|
||||
echo "[baseline] root=$ROOT_DIR"
|
||||
echo "[baseline] snapshot=$SNAP_DIR"
|
||||
|
||||
# 1) 记录 git 元信息(仅记录,不修改)
|
||||
{
|
||||
echo "branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo unknown)"
|
||||
echo "commit=$(git rev-parse --short HEAD 2>/dev/null || echo unknown)"
|
||||
echo "created_at=$(date -Iseconds)"
|
||||
} > "$SNAP_DIR/git-meta.txt"
|
||||
|
||||
# 2) 导出当前变更摘要
|
||||
(git status --short || true) > "$SNAP_DIR/git-status.txt"
|
||||
(git diff -- backend app.py frontend/index.html frontend/game.js 2>/dev/null || git diff -- .) > "$SNAP_DIR/git-diff.patch" || true
|
||||
|
||||
# 3) 复制关键运行态文件(如果存在)
|
||||
copy_if_exists() {
|
||||
local f="$1"
|
||||
if [ -f "$f" ]; then
|
||||
mkdir -p "$SNAP_DIR/$(dirname "$f")"
|
||||
cp -a "$f" "$SNAP_DIR/$f"
|
||||
echo "copied: $f"
|
||||
else
|
||||
echo "skip: $f (not found)"
|
||||
fi
|
||||
}
|
||||
|
||||
copy_if_exists "state.json"
|
||||
copy_if_exists "agents-state.json"
|
||||
copy_if_exists "join-keys.json"
|
||||
copy_if_exists "runtime-config.json"
|
||||
copy_if_exists "asset-positions.json"
|
||||
copy_if_exists "asset-defaults.json"
|
||||
|
||||
# 4) 记录权限(便于回滚后复核)
|
||||
(
|
||||
stat -c '%a %n' state.json agents-state.json join-keys.json runtime-config.json asset-positions.json asset-defaults.json 2>/dev/null || true
|
||||
) > "$SNAP_DIR/file-perms.txt"
|
||||
|
||||
# 5) 产出可读说明
|
||||
cat > "$SNAP_DIR/README.txt" <<EOF
|
||||
This snapshot was created before stability hardening changes.
|
||||
|
||||
Restore hints:
|
||||
1) Review git-status.txt and git-diff.patch.
|
||||
2) Restore runtime files from this snapshot if needed.
|
||||
3) Use git checkout/restore to roll back code changes.
|
||||
|
||||
Snapshot: baseline-$TS
|
||||
EOF
|
||||
|
||||
echo "[baseline] done"
|
||||
echo "$SNAP_DIR"
|
||||
40
scripts/chat_status_demo.sh
Executable file
40
scripts/chat_status_demo.sh
Executable file
|
|
@ -0,0 +1,40 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# 对话链路状态切换示例(可直接集成到 agent 工作流)
|
||||
# 用法:
|
||||
# bash scripts/chat_status_demo.sh --reply "这是回复内容"
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
SET_STATE="python3 $ROOT_DIR/set_state.py"
|
||||
|
||||
REPLY=""
|
||||
TRACE_ID="chat-$(date +%s)-$RANDOM"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--reply)
|
||||
REPLY="$2"; shift 2 ;;
|
||||
--trace-id)
|
||||
TRACE_ID="$2"; shift 2 ;;
|
||||
*)
|
||||
echo "未知参数: $1"
|
||||
exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$REPLY" ]]; then
|
||||
REPLY="好的,收到。"
|
||||
fi
|
||||
|
||||
# 1) 收到消息立刻切 working
|
||||
$SET_STATE start "正在回复主人..." --ttl 120 --source chat --trace-id "$TRACE_ID" >/dev/null || true
|
||||
|
||||
# 2) 模拟思考/处理
|
||||
sleep 0.4
|
||||
|
||||
# 3) 输出回复(在真实代理里就是发消息动作)
|
||||
echo "$REPLY"
|
||||
|
||||
# 4) finally 回 idle(核心)
|
||||
$SET_STATE done "待命中,随时准备" --source chat --trace-id "$TRACE_ID" >/dev/null || true
|
||||
24
scripts/release_preflight.sh
Executable file
24
scripts/release_preflight.sh
Executable file
|
|
@ -0,0 +1,24 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Star Office UI - non-destructive release preflight
|
||||
# Purpose: one-command regression checks before/after each stability phase
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
BASE_URL="${1:-http://127.0.0.1:18791}"
|
||||
|
||||
echo "[preflight] root=$ROOT_DIR"
|
||||
echo "[preflight] base_url=$BASE_URL"
|
||||
|
||||
echo "\n[preflight] 1) python syntax check"
|
||||
python3 -m py_compile backend/app.py backend/security_utils.py backend/store_utils.py scripts/security_check.py scripts/smoke_test.py
|
||||
|
||||
echo "\n[preflight] 2) security preflight"
|
||||
python3 scripts/security_check.py
|
||||
|
||||
echo "\n[preflight] 3) smoke test"
|
||||
python3 scripts/smoke_test.py --base-url "$BASE_URL"
|
||||
|
||||
echo "\n[preflight] PASS"
|
||||
|
|
@ -81,17 +81,83 @@ def main() -> int:
|
|||
|
||||
secret = os.getenv("FLASK_SECRET_KEY") or os.getenv("STAR_OFFICE_SECRET") or ""
|
||||
drawer_pass = os.getenv("ASSET_DRAWER_PASS") or ""
|
||||
write_api_guard_enabled = (os.getenv("STAR_OFFICE_WRITE_API_BEARER_ENABLED") or "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
write_api_tokens = (os.getenv("STAR_OFFICE_WRITE_API_TOKENS") or "").strip()
|
||||
max_upload_mb_raw = (os.getenv("STAR_OFFICE_MAX_UPLOAD_MB") or "20").strip()
|
||||
write_rl_raw = (os.getenv("STAR_OFFICE_WRITE_RATE_LIMIT") or "60,60").strip()
|
||||
asset_read_auth_enabled = (os.getenv("STAR_OFFICE_ASSET_READ_AUTH_ENABLED") or "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
gemini_timeout_raw = (os.getenv("STAR_OFFICE_GEMINI_TIMEOUT_SECONDS") or "240").strip()
|
||||
gemini_prompt_max_raw = (os.getenv("STAR_OFFICE_GEMINI_PROMPT_MAX_CHARS") or "1200").strip()
|
||||
request_log_enabled = (os.getenv("STAR_OFFICE_REQUEST_LOG_ENABLED") or "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
request_log_path = (os.getenv("STAR_OFFICE_REQUEST_LOG_PATH") or "").strip()
|
||||
prod_strict_mode = (os.getenv("STAR_OFFICE_PROD_STRICT_MODE") or "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
if in_prod:
|
||||
if not is_strong_secret(secret):
|
||||
failures.append("Weak/missing FLASK_SECRET_KEY (or STAR_OFFICE_SECRET) in production")
|
||||
if not is_strong_pass(drawer_pass):
|
||||
failures.append("Weak/missing ASSET_DRAWER_PASS in production")
|
||||
if not write_api_guard_enabled:
|
||||
warnings.append("STAR_OFFICE_WRITE_API_BEARER_ENABLED is OFF in production (write endpoints are publicly callable)")
|
||||
elif not write_api_tokens:
|
||||
failures.append("STAR_OFFICE_WRITE_API_BEARER_ENABLED is ON but STAR_OFFICE_WRITE_API_TOKENS is empty")
|
||||
else:
|
||||
if not secret:
|
||||
warnings.append("FLASK_SECRET_KEY not set (ok for local dev, not for production)")
|
||||
if not drawer_pass:
|
||||
warnings.append("ASSET_DRAWER_PASS not set (defaults may be unsafe for public exposure)")
|
||||
if write_api_guard_enabled and not write_api_tokens:
|
||||
failures.append("Write API bearer guard enabled but STAR_OFFICE_WRITE_API_TOKENS is empty")
|
||||
|
||||
try:
|
||||
max_upload_mb = int(max_upload_mb_raw)
|
||||
if max_upload_mb < 1:
|
||||
failures.append("STAR_OFFICE_MAX_UPLOAD_MB must be >= 1")
|
||||
elif max_upload_mb > 200:
|
||||
warnings.append("STAR_OFFICE_MAX_UPLOAD_MB is very high (>200MB), consider lowering for DoS safety")
|
||||
except Exception:
|
||||
failures.append("STAR_OFFICE_MAX_UPLOAD_MB is invalid (must be integer)")
|
||||
|
||||
try:
|
||||
a, b = [int(x.strip()) for x in write_rl_raw.split(",", 1)]
|
||||
if a < 1 or b < 1:
|
||||
failures.append("STAR_OFFICE_WRITE_RATE_LIMIT must be positive integers, e.g. 60,60")
|
||||
except Exception:
|
||||
failures.append("STAR_OFFICE_WRITE_RATE_LIMIT format invalid, expected <count>,<window_seconds> like 60,60")
|
||||
|
||||
if in_prod and not asset_read_auth_enabled:
|
||||
warnings.append("STAR_OFFICE_ASSET_READ_AUTH_ENABLED is OFF in production (asset inventory endpoints are publicly readable)")
|
||||
|
||||
try:
|
||||
gemini_timeout = int(gemini_timeout_raw)
|
||||
if gemini_timeout < 30:
|
||||
warnings.append("STAR_OFFICE_GEMINI_TIMEOUT_SECONDS is very low (<30), image generation may fail frequently")
|
||||
if gemini_timeout > 1800:
|
||||
warnings.append("STAR_OFFICE_GEMINI_TIMEOUT_SECONDS is very high (>1800), hung jobs may hold workers too long")
|
||||
except Exception:
|
||||
failures.append("STAR_OFFICE_GEMINI_TIMEOUT_SECONDS is invalid (must be integer)")
|
||||
|
||||
try:
|
||||
gemini_prompt_max = int(gemini_prompt_max_raw)
|
||||
if gemini_prompt_max < 100:
|
||||
warnings.append("STAR_OFFICE_GEMINI_PROMPT_MAX_CHARS is very low (<100), prompts may be over-truncated")
|
||||
if gemini_prompt_max > 10000:
|
||||
warnings.append("STAR_OFFICE_GEMINI_PROMPT_MAX_CHARS is very high (>10000), consider lowering")
|
||||
except Exception:
|
||||
failures.append("STAR_OFFICE_GEMINI_PROMPT_MAX_CHARS is invalid (must be integer)")
|
||||
|
||||
if request_log_enabled and request_log_path:
|
||||
low = request_log_path.lower()
|
||||
if any(x in low for x in [".ssh", ".gnupg", ".aws"]):
|
||||
failures.append("STAR_OFFICE_REQUEST_LOG_PATH points to a sensitive directory")
|
||||
|
||||
if in_prod and prod_strict_mode:
|
||||
if not write_api_guard_enabled:
|
||||
failures.append("PROD_STRICT_MODE requires STAR_OFFICE_WRITE_API_BEARER_ENABLED=true")
|
||||
if write_api_guard_enabled and not write_api_tokens:
|
||||
failures.append("PROD_STRICT_MODE requires non-empty STAR_OFFICE_WRITE_API_TOKENS")
|
||||
if not asset_read_auth_enabled:
|
||||
failures.append("PROD_STRICT_MODE requires STAR_OFFICE_ASSET_READ_AUTH_ENABLED=true")
|
||||
|
||||
tracked = tracked_files()
|
||||
risky_tracked = [
|
||||
|
|
|
|||
55
scripts/state_guard.sh
Executable file
55
scripts/state_guard.sh
Executable file
|
|
@ -0,0 +1,55 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# 用途:将任意命令包在“状态生命周期”里,保证结束后自动回 idle。
|
||||
# 示例:
|
||||
# bash scripts/state_guard.sh --state syncing --detail "正在 push 代码" --ttl 300 -- git push fork feat/office-art-rebuild
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
SET_STATE="python3 $ROOT_DIR/set_state.py"
|
||||
|
||||
STATE="writing"
|
||||
DETAIL="执行任务中"
|
||||
TTL="300"
|
||||
IDLE_DETAIL="待命中"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--state)
|
||||
STATE="$2"; shift 2 ;;
|
||||
--detail)
|
||||
DETAIL="$2"; shift 2 ;;
|
||||
--ttl)
|
||||
TTL="$2"; shift 2 ;;
|
||||
--idle-detail)
|
||||
IDLE_DETAIL="$2"; shift 2 ;;
|
||||
--)
|
||||
shift
|
||||
break ;;
|
||||
*)
|
||||
echo "未知参数: $1"
|
||||
exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ $# -eq 0 ]]; then
|
||||
echo "用法: bash scripts/state_guard.sh --state writing --detail \"...\" -- <command ...>"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
$SET_STATE "$STATE" "$DETAIL" --ttl "$TTL" --source guard >/dev/null || true
|
||||
|
||||
EXIT_CODE=0
|
||||
if "$@"; then
|
||||
EXIT_CODE=0
|
||||
else
|
||||
EXIT_CODE=$?
|
||||
fi
|
||||
|
||||
if [[ $EXIT_CODE -eq 0 ]]; then
|
||||
$SET_STATE idle "$IDLE_DETAIL" --source guard >/dev/null || true
|
||||
else
|
||||
$SET_STATE error "任务失败(exit=$EXIT_CODE)" --ttl 120 --source guard >/dev/null || true
|
||||
fi
|
||||
|
||||
exit $EXIT_CODE
|
||||
89
set_state.py
89
set_state.py
|
|
@ -1,6 +1,16 @@
|
|||
#!/usr/bin/env python3
|
||||
"""简单的状态更新工具,用于测试 Star Office UI"""
|
||||
"""Star Office 状态更新工具(支持对话秒切工作流)
|
||||
|
||||
兼容旧用法:
|
||||
python set_state.py <state> [detail]
|
||||
|
||||
增强用法:
|
||||
python set_state.py start "正在回复主人" --ttl 120
|
||||
python set_state.py sync "正在 push 代码" --ttl 300
|
||||
python set_state.py done "待命中"
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
|
@ -22,6 +32,26 @@ VALID_STATES = [
|
|||
"error"
|
||||
]
|
||||
|
||||
ALIASES = {
|
||||
"start": "writing",
|
||||
"work": "writing",
|
||||
"working": "writing",
|
||||
"research": "researching",
|
||||
"run": "executing",
|
||||
"exec": "executing",
|
||||
"sync": "syncing",
|
||||
"done": "idle",
|
||||
"stop": "idle",
|
||||
"err": "error",
|
||||
}
|
||||
|
||||
|
||||
def normalize_state(s: str) -> str:
|
||||
s = (s or "").strip().lower()
|
||||
s = ALIASES.get(s, s)
|
||||
return s
|
||||
|
||||
|
||||
def load_state():
|
||||
if os.path.exists(STATE_FILE):
|
||||
with open(STATE_FILE, "r", encoding="utf-8") as f:
|
||||
|
|
@ -30,35 +60,56 @@ def load_state():
|
|||
"state": "idle",
|
||||
"detail": "待命中...",
|
||||
"progress": 0,
|
||||
"updated_at": datetime.now().isoformat()
|
||||
"updated_at": datetime.now().isoformat(),
|
||||
"ttl_seconds": 300,
|
||||
}
|
||||
|
||||
|
||||
def save_state(state):
|
||||
with open(STATE_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(state, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def build_parser():
|
||||
p = argparse.ArgumentParser(description="更新 Star Office 状态")
|
||||
p.add_argument("state", help=f"状态(支持别名): {', '.join(VALID_STATES)}")
|
||||
p.add_argument("detail", nargs="?", default="", help="状态详情")
|
||||
p.add_argument("--ttl", type=int, default=None, help="自动回 idle 的秒数(仅 working 态生效)")
|
||||
p.add_argument("--progress", type=int, default=None, help="进度 0-100")
|
||||
p.add_argument("--source", default="", help="来源标记,如 chat/tool/sync")
|
||||
p.add_argument("--trace-id", default="", help="链路ID(可选)")
|
||||
return p
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print("用法: python set_state.py <state> [detail]")
|
||||
print(f"状态选项: {', '.join(VALID_STATES)}")
|
||||
print("\n例子:")
|
||||
print(" python set_state.py idle")
|
||||
print(" python set_state.py researching \"在查 Godot MCP...\"")
|
||||
print(" python set_state.py writing \"在写热点日报模板...\"")
|
||||
sys.exit(1)
|
||||
|
||||
state_name = sys.argv[1]
|
||||
detail = sys.argv[2] if len(sys.argv) > 2 else ""
|
||||
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
state_name = normalize_state(args.state)
|
||||
if state_name not in VALID_STATES:
|
||||
print(f"无效状态: {state_name}")
|
||||
print(f"无效状态: {args.state}")
|
||||
print(f"有效选项: {', '.join(VALID_STATES)}")
|
||||
print(f"别名: {', '.join(sorted(ALIASES.keys()))}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
state = load_state()
|
||||
state["state"] = state_name
|
||||
state["detail"] = detail
|
||||
state["detail"] = args.detail or state.get("detail", "")
|
||||
state["updated_at"] = datetime.now().isoformat()
|
||||
|
||||
|
||||
if args.ttl is not None:
|
||||
state["ttl_seconds"] = max(5, int(args.ttl))
|
||||
elif "ttl_seconds" not in state:
|
||||
state["ttl_seconds"] = 300
|
||||
|
||||
if args.progress is not None:
|
||||
state["progress"] = max(0, min(100, int(args.progress)))
|
||||
|
||||
if args.source:
|
||||
state["source"] = args.source
|
||||
|
||||
if args.trace_id:
|
||||
state["trace_id"] = args.trace_id
|
||||
|
||||
save_state(state)
|
||||
print(f"状态已更新: {state_name} - {detail}")
|
||||
print(f"状态已更新: {state_name} - {state.get('detail', '')}")
|
||||
|
|
|
|||
Loading…
Reference in a new issue