diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 724a92a..414d45c 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -2,10 +2,10 @@ name: Publish Docker image on: push: - branches: ["main", "dev", "oc", "test"] + branches: ["main", "dev", "oc", "test", "develop"] tags: ["v*.*.*"] pull_request: - branches: ["main", "dev", "oc", "test"] + branches: ["main", "dev", "oc", "test", "develop"] env: REGISTRY: ghcr.io diff --git a/Dockerfile b/Dockerfile index d78c0f8..68c77b7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,6 +15,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && npm install -g openclaw@2026.3.23-2 \ && npm install -g clawhub@latest \ && npm install -g @playwright/cli@latest \ + && npm install -g acpx@latest \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* \ && rm -rf /root/.npm \ @@ -25,6 +26,13 @@ RUN mkdir -p /data /app /home/user/.claude/skills /home/user/.openclaw /home/use chmod 755 /home/user/.openclaw /home/user/.claude /home/user/.claude/skills && \ chmod 755 /data /app /home/user +# 预下载 acpx skill 和文档(放到 /app 备份目录,避免被挂载覆盖) +RUN mkdir -p /app/.acpx-backup/skills/acpx /app/.acpx-backup/docs && \ + curl -fsSL -o /app/.acpx-backup/skills/acpx/SKILL.md \ + https://raw.githubusercontent.com/openclaw/acpx/main/skills/acpx/SKILL.md && \ + curl -fsSL -o /app/.acpx-backup/docs/CLI.md \ + https://raw.githubusercontent.com/openclaw/acpx/main/docs/CLI.md + # 安装 Python 依赖 WORKDIR /app COPY requirements.txt ./ diff --git a/app/api/routes_chat.py b/app/api/routes_chat.py index c703ea4..bd75ade 100644 --- a/app/api/routes_chat.py +++ b/app/api/routes_chat.py @@ -827,6 +827,42 @@ async def _run_openclaw_cmd(): prefix_parts: list[str] = [] + prefix_parts.append( + "### ACPX MANDATORY(编程任务必须执行;否则视为回答失败)\n\n" + "判定为“编程任务”的条件(命中任一即是):\n" + "- 需要:修改/新增/删除代码或文件、生成补丁(diff)、修复报错、运行测试/命令、排查日志、提交 commit/PR、对某个仓库/目录进行操作\n" + "- 或用户消息中出现:fix/bug/implement/add/refactor/test/build/run/compile/commit/PR/diff/文件/路径/报错/日志 等关键词\n\n" + "总规则:\n" + "1) 只要是编程任务,你【必须】通过 ACPX 调用 Claude Code 完成工作;你自己【禁止】直接给出最终代码实现/补丁/测试结论(除非这些内容来自 Claude Code 的输出并被你转述)。\n" + "2) 开始前你【必须】先读取并遵循工作区内文档:AGENTS.md(ACP/ACPX 说明)与 CLAUDE.md(开发习惯/约束)。\n" + "3) 你必须优先使用“持久会话”,会话名固定为:\n" + " acpx-claude-{openclaw_session_id}\n" + "4) 超时(必须遵守):\n" + " - 默认超时:600 秒(10 分钟)\n" + " - 复杂任务:1200 秒(20 分钟)或更长\n" + " - 调用 acpx 时必须显式设置超时参数(按 AGENTS.md / ACPX CLI 支持的方式传入),禁止省略\n" + "5) 工作目录(--cwd)规则:\n" + " - 若用户在自然语言里明确给出目录路径:必须将该路径原样用于 --cwd(禁止自作主张改路径)\n" + " - 否则:使用“当前工作目录”(本 prefix 会提供 workspace_path)作为 --cwd\n\n" + "固定执行流程(必须按顺序):\n" + "A. 会话探测:\n" + " acpx claude sessions show acpx-claude-{openclaw_session_id}\n" + "B. 若不存在/失败,则创建会话(只需一次):\n" + " acpx claude sessions new --name acpx-claude-{openclaw_session_id}\n" + "C. 使用持久会话执行任务(必须带上 --cwd):\n" + " acpx claude -s acpx-claude-{openclaw_session_id} --cwd \"<完整任务描述(包含约束/验收标准/相关文件信息)>\"\n\n" + "输出与可验证锚点(Claude Code 必须在最终输出中包含):\n" + "- 修改了哪些文件(路径列表)\n" + "- 每个文件的变更摘要(或 diff/补丁片段)\n" + "- 运行了哪些命令/测试,以及结果(失败则给出关键错误信息)\n\n" + "失败即停(强制):\n" + "- 若任何 acpx 命令返回非 0 或超时:你只能输出\n" + " (1) “❌ ACPX 调用失败” + 关键 stderr/exit_code(去除无关噪声)\n" + " (2) 下一步建议(例如检查 acpx 是否可用、会话名、--cwd 路径是否存在)\n" + " 然后停止;禁止继续“假装完成”或直接生成实现代码。\n\n" + "非编程问题(概念解释/问答)才允许你直接回答;一旦问题转为编程任务,立刻切换到以上 ACPX 流程。" + ) + if force_skill_lines: prefix_parts.append( "强制/优先 Skills(执行任务前必须先阅读对应 SKILL.md):\n" @@ -841,9 +877,17 @@ async def _run_openclaw_cmd(): prefix_parts.append(f"当前工作目录:{workspace_path}") prefix_parts.append(f"附件目录:{workspace_path}/.302ai/attachments") - prefix_parts.append(f"如果是编程相关任务,请先阅读 {workspace_path}/CLAUDE.md(里面有我的开发习惯),实现代码需要通过claude code CLI生成, 而且你必须确保是在当前工作目录调用claude code的CLI,代码文件必须保存在工作目录") + prefix_parts.append( + f"如果是编程相关任务,请先阅读 {workspace_path}/CLAUDE.md(里面有我使用claude code开发习惯") + prefix_parts.append( + f"实现代码需要通过ACPX调用claude code, 具体见工作区里的AGENTS.md里ACP相关的信息") + prefix_parts.append( + f"如果是编程相关任务,阅读 acpx skill 参考文档,了解所有命令、标志和工作流模式:/home/user/.claude/skills/acpx/SKILL.md") + prefix_parts.append( + f"如果是编程相关任务,需要完整的 CLI 参考及所有选项和示例:/home/user/acpx/docs/CLI.md") prefix = "\n\n".join(prefix_parts) + "\n\n" + final_user_prompt = prefix + user_prompt collected_text_chunks: list[str] = [] # 确保扩展名在路径最后一个 / 之后 diff --git a/app/api/routes_command.py b/app/api/routes_command.py index cf09667..a7835ce 100644 --- a/app/api/routes_command.py +++ b/app/api/routes_command.py @@ -2,15 +2,22 @@ from typing import Optional -from fastapi import APIRouter, Request +import asyncio + +from fastapi import APIRouter, HTTPException, Request from fastapi.responses import StreamingResponse from pydantic import BaseModel -from app.api.response import ok +from app.api.response import ok, fail from app.core.command_runner import CommandRunner router = APIRouter() +# 单进程:按 command 互斥执行 /commands/stream +# key: command +_command_locks: dict[str, asyncio.Lock] = {} +_command_active_run: dict[str, str] = {} + class CommandRequest(BaseModel): command: str @@ -48,8 +55,16 @@ async def execute_command(payload: CommandRequest): async def execute_command_stream(payload: CommandRequest, request: Request): runner = CommandRunner() + command_key = payload.command + lock = _command_locks.setdefault(command_key, asyncio.Lock()) + + # 不排队:如果同 command 正在执行,直接 409 + if lock.locked(): + return fail("Another command is still running", status_code=409) + async def gen(): run_id: Optional[str] = None + await lock.acquire() try: async for ev in runner.stream( payload.command, @@ -59,6 +74,7 @@ async def gen(): ): if ev.get("event") == "start": run_id = ev.get("run_id") + _command_active_run[command_key] = run_id if await request.is_disconnected(): if run_id: @@ -81,8 +97,14 @@ async def gen(): {"run_id": ev["run_id"], "exit_code": ev.get("exit_code"), "lines": ev.get("lines")}, ) finally: - if run_id: - await runner.cleanup(run_id) + try: + if run_id: + await runner.cleanup(run_id) + finally: + if _command_active_run.get(command_key) == run_id: + _command_active_run.pop(command_key, None) + if lock.locked(): + lock.release() return StreamingResponse( gen(), diff --git a/app/core/command_runner.py b/app/core/command_runner.py index 06c6255..4400598 100644 --- a/app/core/command_runner.py +++ b/app/core/command_runner.py @@ -5,6 +5,7 @@ import signal import sys import uuid +import shlex from dataclasses import dataclass from typing import Any, AsyncIterator, Dict, Optional @@ -32,6 +33,8 @@ class CommandRunner: def __init__(self) -> None: self._active: Dict[str, asyncio.subprocess.Process] = {} + # 存储真实业务进程PID(修复核心) + self._real_pids: Dict[str, int] = {} def decode_output(self, data: bytes) -> str: try: @@ -119,6 +122,27 @@ async def exec_json( except Exception as e: return CommandResult(exit_code=-1, stdout="", stderr="", error=f"Execution error: {e}") + async def _get_real_child_pid(self, shell_pid: int) -> Optional[int]: + """ + 核心修复:获取shell进程下的真实业务子进程PID + Linux/Unix专用,Windows直接返回shell PID + """ + if sys.platform == "win32": + return shell_pid + + try: + # 读取 /proc/{shell_pid}/task/{shell_pid}/children 获取直接子进程 + children_path = f"/proc/{shell_pid}/task/{shell_pid}/children" + if os.path.exists(children_path): + async with asyncio.Lock(): + with open(children_path, 'r') as f: + child_pids = f.read().strip().split() + if child_pids: + return int(child_pids[0]) + return None + except Exception: + return None + async def stream( self, command: str, @@ -163,7 +187,19 @@ async def stream( proc = await asyncio.create_subprocess_shell(command, **kwargs) self._active[run_id] = proc - yield {"event": "start", "run_id": run_id, "pid": proc.pid, "command": command} + # ===================== 修复核心:获取真实PID ===================== + real_pid = proc.pid + if not is_windows: + # 等待子进程创建(极短等待,不影响性能) + await asyncio.sleep(0.1) + child_pid = await self._get_real_child_pid(proc.pid) + if child_pid: + real_pid = child_pid + self._real_pids[run_id] = real_pid + # =============================================================== + + # 现在返回的pid就是真实业务进程PID,和ps命令完全一致 + yield {"event": "start", "run_id": run_id, "pid": real_pid, "command": command} line_count = 0 while True: @@ -259,15 +295,23 @@ async def kill(self, run_id: str) -> bool: return True async def cleanup(self, run_id: str) -> None: + # 清理真实PID缓存 + self._real_pids.pop(run_id, None) proc = self._active.pop(run_id, None) if proc is not None: await self._terminate_process(proc) def list_active(self) -> list[dict]: - return [ - {"run_id": rid, "pid": proc.pid, "returncode": proc.returncode} - for rid, proc in self._active.items() - ] + active_list = [] + for rid, proc in self._active.items(): + real_pid = self._real_pids.get(rid, proc.pid) + active_list.append({ + "run_id": rid, + "pid": real_pid, + "shell_pid": proc.pid, + "returncode": proc.returncode + }) + return active_list async def _terminate_process(self, proc: asyncio.subprocess.Process, timeout: float = 5.0) -> None: if proc is None or proc.returncode is not None: @@ -317,4 +361,4 @@ async def _terminate_process(self, proc: asyncio.subprocess.Process, timeout: fl try: await asyncio.wait_for(proc.wait(), timeout=timeout) except Exception: - pass + pass \ No newline at end of file diff --git a/app/core/oc_ops.py b/app/core/oc_ops.py index 33cad28..a1510cc 100644 --- a/app/core/oc_ops.py +++ b/app/core/oc_ops.py @@ -995,10 +995,6 @@ async def add_my_oc_system_prompt_to_agent_md( # ❌ 错误做法(禁止这样做): # acpx claude -s acpx-claude-{session_id} --cwd /home/user/.openclaw/workspace "修复 bug" ``` - -#### 二进制路径 -```bash -ACPX_CMD="acpx" # 已全局安装 ``` --- diff --git a/app/core/request_id_middleware.py b/app/core/request_id_middleware.py index 4c34095..be9a68b 100644 --- a/app/core/request_id_middleware.py +++ b/app/core/request_id_middleware.py @@ -1,14 +1,24 @@ from __future__ import annotations import json +import os from contextvars import ContextVar from fastapi import Request from loguru import logger from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import JSONResponse from app.utils.utils import get_uuid + +CREATE_INSTANCE_APIKEY_ENV = "CREATE_INSTANCE_APIKEY" + + +def _get_create_instance_apikey() -> str: + apikey = os.environ.get(CREATE_INSTANCE_APIKEY_ENV) + return apikey if isinstance(apikey, str) and apikey else "" + REQUEST_ID_HEADER = "X-Request-ID" # Holds request id for the current async context. @@ -35,12 +45,57 @@ def truncate_long_strings(obj, max_length: int = 50): class RequestIDMiddleware(BaseHTTPMiddleware): + def __init__(self, app): + super().__init__(app) + self._create_instance_apikey = "" + + def _refresh_create_instance_apikey_if_needed(self) -> None: + # Keep a per-process cached copy to avoid repeated os.environ lookups. + self._create_instance_apikey = _get_create_instance_apikey() + async def dispatch(self, request: Request, call_next): request_id = get_uuid(remove_hyphen=True) request.state.request_id = request_id request.state.upstream_request_id = "" token = request_id_ctx.set(request_id) + self._refresh_create_instance_apikey_if_needed() + if self._create_instance_apikey and request.client and request.client.host not in {"127.0.0.1", "::1"}: + auth = request.headers.get("Authorization") or "" + expected = f"Bearer {self._create_instance_apikey}" + + # 没有传递 API Key + if not auth: + request_id_ctx.reset(token) + return JSONResponse( + status_code=401, + content={ + "error": { + "err_code": -10001, + "message": "Missing 302 Apikey", + "message_cn": "缺少 302 API 密钥", + "message_jp": "302 APIキーがありません", + "type": "api_error" + } + } + ) + + # 传递了,但密钥不正确 + if auth != expected: + request_id_ctx.reset(token) + return JSONResponse( + status_code=401, + content={ + "error": { + "err_code": -10002, + "message": "Invalid API Key, for details please view 302.AI", + "message_cn": "无效的API KEY,更多请访问 302.AI", + "message_jp": "無効なAPIキーです。詳細は 302.AI をご覧ください。", + "type": "api_error" + } + } + ) + # Streaming endpoints: don't read body. is_streaming = False stream_url_path = [ diff --git a/entrypoint.sh b/entrypoint.sh index ea5e636..eb279b9 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -18,6 +18,19 @@ for p in /app/.skills-backup/*; do echo "Restored skill entry (overwrite): $name" done +# 恢复 acpx skill 与文档(避免被挂载覆盖;并修正 acpx skill 路径) +mkdir -p /home/user/.claude/skills/acpx +if [ -f /app/.acpx-backup/skills/acpx/SKILL.md ]; then + cp -a /app/.acpx-backup/skills/acpx/SKILL.md /home/user/.claude/skills/acpx/SKILL.md + echo "Restored acpx SKILL.md -> /home/user/.claude/skills/acpx/SKILL.md" +fi + +mkdir -p /home/user/acpx/docs +if [ -f /app/.acpx-backup/docs/CLI.md ]; then + cp -a /app/.acpx-backup/docs/CLI.md /home/user/acpx/docs/CLI.md + echo "Restored acpx CLI.md -> /home/user/acpx/docs/CLI.md" +fi + # 启动服务 openclaw gateway run --port 18789 --bind lan & uvicorn main:app --host 0.0.0.0 --port 8000