Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/docker-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand All @@ -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 ./
Expand Down
46 changes: 45 additions & 1 deletion app/api/routes_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <PATH> \"<完整任务描述(包含约束/验收标准/相关文件信息)>\"\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"
Expand All @@ -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] = []
# 确保扩展名在路径最后一个 / 之后
Expand Down
30 changes: 26 additions & 4 deletions app/api/routes_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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:
Expand All @@ -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(),
Expand Down
56 changes: 50 additions & 6 deletions app/core/command_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import signal
import sys
import uuid
import shlex
from dataclasses import dataclass
from typing import Any, AsyncIterator, Dict, Optional

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
4 changes: 0 additions & 4 deletions app/core/oc_ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -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" # 已全局安装
```

---
Expand Down
55 changes: 55 additions & 0 deletions app/core/request_id_middleware.py
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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 = [
Expand Down
13 changes: 13 additions & 0 deletions entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading