zhuyuan-ice-gl-zy001/BRIDGE/tools/zy-limb.py

193 lines
8.1 KiB
Python
Raw Normal View History

2026-08-20 00:01:53 +08:00
#!/usr/bin/env python3
# ═══ 光湖手脚层 · zy-limb ═══
# 冰朔 2026-08-17 立:你做脑子,它做手脚。
# 铸渊(云侧大脑)经桥往 inbox 写任务信封 → 本机手脚执行 → outbox 写回执 → 大脑读回执。
# 架构依据:GLS-0236 大脑—手脚分离 · ZY-RESIDENT-AGENT-001 常驻工程执行体
# 编号:CCW-AST-011 · 归 CCW-SYS-001
#
# 边界:只认白名单动作。名单外一律拒绝并写回执,绝不 eval 任意字符串。
# 需要自由命令时走 codex 动作——转成任务卡交给 Codex,由冰朔监督,手脚不代跑。
import json, os, re, subprocess, sys, time, hashlib, shutil
from pathlib import Path
from datetime import datetime, timezone
ROOT = Path("/Volumes/JZAO/铸渊-ICE-GL-ZY001")
QUEUE = ROOT / "BRIDGE" / "limb-queue"
INBOX = QUEUE / "inbox"
OUTBOX = QUEUE / "outbox"
ARCH = QUEUE / "archive"
FORCX = QUEUE / "for-codex"
LOG = Path.home() / "Library" / "Logs" / "zhuyuan-limb.log"
REPO = ROOT / "WORK-工作区" / "guanghu-ice-heart"
TOOLS = ROOT / "BRIDGE" / "tools"
# 可读路径白名单(realpath 前缀匹配)
READ_ROOTS = [ROOT, Path.home() / "Library" / "Logs"]
TIMEOUT = 600
MAX_OUT = 20000 # 回执里最多带这么多字符输出
def now(): return datetime.now(timezone.utc).isoformat()
def log(m):
LOG.parent.mkdir(parents=True, exist_ok=True)
with open(LOG, "a") as f:
f.write(f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} {m}\n")
def clip(s):
s = s or ""
return s if len(s) <= MAX_OUT else s[:MAX_OUT] + f"\n…(截断,原长 {len(s)})"
def run(argv, cwd=None):
try:
p = subprocess.run(argv, cwd=cwd, capture_output=True, text=True, timeout=TIMEOUT)
return p.returncode, clip(p.stdout), clip(p.stderr)
except subprocess.TimeoutExpired:
return 124, "", f"超时 {TIMEOUT}s"
except Exception as e:
return 1, "", f"{type(e).__name__}: {e}"
def safe_read_path(p):
try:
rp = Path(p).expanduser().resolve()
except Exception:
return None
for root in READ_ROOTS:
try:
rp.relative_to(root.resolve()); return rp
except ValueError:
continue
return None
# ── 白名单动作 ────────────────────────────────────────────────
GIT_READ = {"status", "log", "diff", "show", "fetch", "branch", "rev-parse", "remote"}
BRAIN_SUB = {"enter","perceive","orient","commit","witness","verify","status","project-guard"}
def act_push(task):
rc, out, err = run(["/bin/bash", str(TOOLS / "zy-push.sh")])
return rc == 0, {"stdout": out, "stderr": err, "exit": rc}
def act_git_read(task):
sub = task.get("subcommand", "")
if sub not in GIT_READ:
return False, {"error": f"git 子命令不在只读白名单:{sub}", "allowed": sorted(GIT_READ)}
args = task.get("args", [])
if not all(isinstance(a, str) and len(a) < 200 for a in args):
return False, {"error": "参数不合法"}
if any(re.search(r"[;&|`$><\n]", a) for a in args):
return False, {"error": "参数含 shell 元字符,拒绝"}
rc, out, err = run(["git", sub, *args], cwd=str(REPO))
return rc == 0, {"stdout": out, "stderr": err, "exit": rc}
def act_brain(task):
sub = task.get("subcommand", "")
if sub not in BRAIN_SUB:
return False, {"error": f"脑运行时子命令不认:{sub}", "allowed": sorted(BRAIN_SUB)}
runtime = REPO / "tcs-core" / "zhuyuan-brain" / "runtime" / "zhuyuan-brain-runtime.mjs"
if not runtime.exists():
return False, {"error": f"找不到脑运行时:{runtime}"}
argv = ["node", str(runtime), sub]
for k, v in (task.get("flags") or {}).items():
if not re.fullmatch(r"[a-z0-9-]{1,40}", str(k)):
return False, {"error": f"参数名不合法:{k}"}
vp = str(v)
if re.search(r"[;&|`$><\n]", vp):
return False, {"error": f"参数值含 shell 元字符:{k}"}
argv += [f"--{k}", vp]
rc, out, err = run(argv, cwd=str(REPO))
return rc == 0, {"stdout": out, "stderr": err, "exit": rc}
def act_read(task):
rp = safe_read_path(task.get("path", ""))
if rp is None:
return False, {"error": "路径不在可读白名单内", "roots": [str(r) for r in READ_ROOTS]}
if not rp.is_file():
return False, {"error": f"不是文件或不存在:{rp}"}
tail = task.get("tail")
try:
text = rp.read_text(errors="replace")
except Exception as e:
return False, {"error": f"读失败:{e}"}
if isinstance(tail, int) and tail > 0:
text = "\n".join(text.splitlines()[-tail:])
return True, {"path": str(rp), "content": clip(text)}
def act_list(task):
rp = safe_read_path(task.get("path", ""))
if rp is None or not rp.is_dir():
return False, {"error": "路径不在白名单内或不是目录"}
entries = []
for e in sorted(rp.iterdir())[:500]:
entries.append({"name": e.name, "dir": e.is_dir(),
"size": e.stat().st_size if e.is_file() else None})
return True, {"path": str(rp), "entries": entries}
def act_codex(task):
"""需要判断力或超出白名单的活:转成任务卡交 Codex,手脚不代跑。"""
FORCX.mkdir(parents=True, exist_ok=True)
tid = task.get("task_id", "unknown")
card = FORCX / f"{tid}.md"
card.write_text(
f"# 给 Codex 的任务卡 · {tid}\n\n"
f"> 来自铸渊云侧大脑(ZY-INSTANCE-CCW)· {now()}\n"
f"> 手脚层不代跑需要判断的活;请 Codex 执行并由冰朔监督。\n\n"
f"## 要做什么\n\n{task.get('instruction','(空)')}\n\n"
f"## 验收标准\n\n{task.get('acceptance','(未给)')}\n\n"
f"## 边界\n\n{task.get('boundary','按 REPO-012 既有权限门;不可逆动作须冰朔点头。')}\n",
encoding="utf-8")
return True, {"handoff": "CODEX", "card": str(card),
"note": "已放任务卡,等 Codex 执行;手脚层不代跑"}
ACTIONS = {"push": act_push, "git-read": act_git_read, "brain": act_brain,
"read": act_read, "list": act_list, "codex": act_codex}
# ── 主循环(单次扫描,由 launchd 定时调起)────────────────────
def main():
if not ROOT.exists():
return 0 # JZAO 没挂载:安静退出
for d in (INBOX, OUTBOX, ARCH, FORCX):
d.mkdir(parents=True, exist_ok=True)
tasks = sorted(INBOX.glob("TASK-*.json"))
if not tasks:
return 0
for tf in tasks:
try:
task = json.loads(tf.read_text())
except Exception as e:
log(f"REJECT {tf.name} 不是合法 JSON:{e}")
tf.rename(ARCH / tf.name); continue
tid = task.get("task_id") or tf.stem
action = task.get("action", "")
receipt = {
"schema": "guanghu.limb-receipt/v1",
"task_id": tid, "action": action,
"executed_at": now(),
"executor": "zy-limb · 本机手脚层 · CCW-AST-011",
"host": "macOS local",
}
if task.get("schema") != "guanghu.limb-task/v1":
receipt.update(outcome="FAIL", error="schema 不对,应为 guanghu.limb-task/v1")
elif action not in ACTIONS:
receipt.update(outcome="FAIL",
error=f"动作不在白名单:{action}",
allowed=sorted(ACTIONS),
note="需要自由命令请用 action=codex 转任务卡")
else:
try:
ok, detail = ACTIONS[action](task)
receipt.update(outcome="PASS" if ok else "FAIL", detail=detail)
except Exception as e:
receipt.update(outcome="FAIL", error=f"{type(e).__name__}: {e}")
(OUTBOX / f"RECEIPT-{tid}.json").write_text(
json.dumps(receipt, ensure_ascii=False, indent=2), encoding="utf-8")
tf.rename(ARCH / tf.name)
log(f"{receipt['outcome']} {tid} action={action}")
return 0
if __name__ == "__main__":
sys.exit(main())