From 8cc66aa94eeed3d4d7ef38e15d4df874becac26d Mon Sep 17 00:00:00 2001 From: huangjunjia <2489441209@qq.com> Date: Tue, 31 Mar 2026 17:04:42 +0800 Subject: [PATCH 01/13] fix(prompt): udpate prompt for acpx --- Dockerfile | 7 +++++++ app/api/routes_chat.py | 8 +++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index d78c0f8..35812da 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,6 +25,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 和文档 +RUN curl -fsSL -o /home/user/.claude/skills/SKILL.md \ + https://raw.githubusercontent.com/openclaw/acpx/main/skills/acpx/SKILL.md && \ + mkdir -p /home/user/acpx/docs && \ + curl -fsSL -o /home/user/acpx/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..4d1f1db 100644 --- a/app/api/routes_chat.py +++ b/app/api/routes_chat.py @@ -841,9 +841,15 @@ 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开发习惯),实现代码需要通过ACPX调用claude code, 具体见工作区里的AGENT.md") + prefix_parts.append( + f"如果是编程相关任务,阅读 acpx skill 参考文档,了解所有命令、标志和工作流模式:/home/user/.claude/skills/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] = [] # 确保扩展名在路径最后一个 / 之后 From 11c0ff68ca3d439a072451af29a3b7b6141545c7 Mon Sep 17 00:00:00 2001 From: huangjunjia <2489441209@qq.com> Date: Tue, 31 Mar 2026 19:56:23 +0800 Subject: [PATCH 02/13] feat: preinstall acpx --- Dockerfile | 1 + app/api/routes_chat.py | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 35812da..b580092 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 \ diff --git a/app/api/routes_chat.py b/app/api/routes_chat.py index 4d1f1db..d96c119 100644 --- a/app/api/routes_chat.py +++ b/app/api/routes_chat.py @@ -842,7 +842,9 @@ 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开发习惯),实现代码需要通过ACPX调用claude code, 具体见工作区里的AGENT.md") + f"如果是编程相关任务,请先阅读 {workspace_path}/CLAUDE.md(里面有我使用claude code开发习惯") + prefix_parts.append( + f"实现代码需要通过ACPX调用claude code, 具体见工作区里的AGENT.md里ACP相关的信息") prefix_parts.append( f"如果是编程相关任务,阅读 acpx skill 参考文档,了解所有命令、标志和工作流模式:/home/user/.claude/skills/SKILL.md") prefix_parts.append( From 4f05536e902f344b3ca68425d8e1f869e7d530c1 Mon Sep 17 00:00:00 2001 From: huangjunjia <2489441209@qq.com> Date: Wed, 1 Apr 2026 14:19:17 +0800 Subject: [PATCH 03/13] fix(acpx): pre-download docs/skill & update prompt --- Dockerfile | 12 ++++++------ app/api/routes_chat.py | 38 +++++++++++++++++++++++++++++++++++++- app/core/oc_ops.py | 4 ---- entrypoint.sh | 13 +++++++++++++ 4 files changed, 56 insertions(+), 11 deletions(-) diff --git a/Dockerfile b/Dockerfile index b580092..68c77b7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,12 +26,12 @@ 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 和文档 -RUN curl -fsSL -o /home/user/.claude/skills/SKILL.md \ - https://raw.githubusercontent.com/openclaw/acpx/main/skills/acpx/SKILL.md && \ - mkdir -p /home/user/acpx/docs && \ - curl -fsSL -o /home/user/acpx/docs/CLI.md \ - https://raw.githubusercontent.com/openclaw/acpx/main/docs/CLI.md +# 预下载 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 diff --git a/app/api/routes_chat.py b/app/api/routes_chat.py index d96c119..a02018f 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) 开始前你【必须】先读取并遵循工作区内文档:AGENT.md(ACP/ACPX 说明)与 CLAUDE.md(开发习惯/约束)。\n" + "3) 你必须优先使用“持久会话”,会话名固定为:\n" + " acpx-claude-{openclaw_session_id}\n" + "4) 超时(必须遵守):\n" + " - 默认超时:600 秒(10 分钟)\n" + " - 复杂任务:1200 秒(20 分钟)或更长\n" + " - 调用 acpx 时必须显式设置超时参数(按 AGENT.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" @@ -846,7 +882,7 @@ async def _run_openclaw_cmd(): prefix_parts.append( f"实现代码需要通过ACPX调用claude code, 具体见工作区里的AGENT.md里ACP相关的信息") prefix_parts.append( - f"如果是编程相关任务,阅读 acpx skill 参考文档,了解所有命令、标志和工作流模式:/home/user/.claude/skills/SKILL.md") + f"如果是编程相关任务,阅读 acpx skill 参考文档,了解所有命令、标志和工作流模式:/home/user/.claude/skills/acpx/SKILL.md") prefix_parts.append( f"如果是编程相关任务,需要完整的 CLI 参考及所有选项和示例:/home/user/acpx/docs/CLI.md") 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/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 From d7634d52a8403dd8713ad58a02db6650c49ab297 Mon Sep 17 00:00:00 2001 From: huangjunjia <2489441209@qq.com> Date: Wed, 1 Apr 2026 14:21:25 +0800 Subject: [PATCH 04/13] feat: add Authorization --- app/core/request_id_middleware.py | 44 +++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/app/core/request_id_middleware.py b/app/core/request_id_middleware.py index 4c34095..5c31e6c 100644 --- a/app/core/request_id_middleware.py +++ b/app/core/request_id_middleware.py @@ -1,14 +1,32 @@ from __future__ import annotations import json +import os +import time 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 + +def _load_openclaw_gateway_token() -> str: + config_path = os.environ.get("OPENCLAW_CONFIG_PATH") + if not config_path: + return "" + + try: + with open(config_path, "r", encoding="utf-8") as f: + data = json.load(f) + token = ((data.get("gateway") or {}).get("auth") or {}).get("token") + return token if isinstance(token, str) and token else "" + except Exception as e: + logger.warning(f"OPENCLAW_CONFIG_PATH load failed: {config_path}: {e}") + return "" + REQUEST_ID_HEADER = "X-Request-ID" # Holds request id for the current async context. @@ -35,12 +53,38 @@ def truncate_long_strings(obj, max_length: int = 50): class RequestIDMiddleware(BaseHTTPMiddleware): + def __init__(self, app): + super().__init__(app) + self._gateway_token = "" + self._gateway_token_loaded_at = 0.0 + + def _refresh_gateway_token_if_needed(self) -> None: + if not os.environ.get("OPENCLAW_CONFIG_PATH"): + self._gateway_token = "" + self._gateway_token_loaded_at = 0.0 + return + + now = time.time() + if self._gateway_token_loaded_at and (now - self._gateway_token_loaded_at) < 60: + return + + self._gateway_token = _load_openclaw_gateway_token() + self._gateway_token_loaded_at = now + 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_gateway_token_if_needed() + if self._gateway_token and request.client and request.client.host not in {"127.0.0.1", "::1"}: + auth = request.headers.get("Authorization") + expected = f"Bearer {self._gateway_token}" + if auth != expected: + request_id_ctx.reset(token) + return JSONResponse(status_code=401, content={"detail": "Unauthorized"}) + # Streaming endpoints: don't read body. is_streaming = False stream_url_path = [ From bc7e0b815af7e3ea28aacafe222878339bbec8bc Mon Sep 17 00:00:00 2001 From: huangjunjia <2489441209@qq.com> Date: Wed, 1 Apr 2026 14:25:41 +0800 Subject: [PATCH 05/13] ci: add develop branch to CI/CD pipeline --- .github/workflows/docker-image.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 From bfa9046f15df34eaaa4221e0d38fed017177388d Mon Sep 17 00:00:00 2001 From: huangjunjia <2489441209@qq.com> Date: Fri, 3 Apr 2026 14:42:40 +0800 Subject: [PATCH 06/13] fix(middleware): auth --- app/core/request_id_middleware.py | 44 +++++++++---------------------- 1 file changed, 13 insertions(+), 31 deletions(-) diff --git a/app/core/request_id_middleware.py b/app/core/request_id_middleware.py index 5c31e6c..71ea932 100644 --- a/app/core/request_id_middleware.py +++ b/app/core/request_id_middleware.py @@ -2,7 +2,6 @@ import json import os -import time from contextvars import ContextVar from fastapi import Request @@ -13,19 +12,12 @@ from app.utils.utils import get_uuid -def _load_openclaw_gateway_token() -> str: - config_path = os.environ.get("OPENCLAW_CONFIG_PATH") - if not config_path: - return "" +CREATE_INSTANCE_APIKEY_ENV = "CREATE_INSTANCE_APIKEY" - try: - with open(config_path, "r", encoding="utf-8") as f: - data = json.load(f) - token = ((data.get("gateway") or {}).get("auth") or {}).get("token") - return token if isinstance(token, str) and token else "" - except Exception as e: - logger.warning(f"OPENCLAW_CONFIG_PATH load failed: {config_path}: {e}") - return "" + +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" @@ -55,21 +47,11 @@ def truncate_long_strings(obj, max_length: int = 50): class RequestIDMiddleware(BaseHTTPMiddleware): def __init__(self, app): super().__init__(app) - self._gateway_token = "" - self._gateway_token_loaded_at = 0.0 - - def _refresh_gateway_token_if_needed(self) -> None: - if not os.environ.get("OPENCLAW_CONFIG_PATH"): - self._gateway_token = "" - self._gateway_token_loaded_at = 0.0 - return - - now = time.time() - if self._gateway_token_loaded_at and (now - self._gateway_token_loaded_at) < 60: - return + self._create_instance_apikey = "" - self._gateway_token = _load_openclaw_gateway_token() - self._gateway_token_loaded_at = now + 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) @@ -77,10 +59,10 @@ async def dispatch(self, request: Request, call_next): request.state.upstream_request_id = "" token = request_id_ctx.set(request_id) - self._refresh_gateway_token_if_needed() - if self._gateway_token and request.client and request.client.host not in {"127.0.0.1", "::1"}: - auth = request.headers.get("Authorization") - expected = f"Bearer {self._gateway_token}" + 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}" if auth != expected: request_id_ctx.reset(token) return JSONResponse(status_code=401, content={"detail": "Unauthorized"}) From 9d0de21664986ddecb2114a41d23f717881000d1 Mon Sep 17 00:00:00 2001 From: huangjunjia <2489441209@qq.com> Date: Thu, 9 Apr 2026 16:10:27 +0800 Subject: [PATCH 07/13] refactor: update 401 response body format --- app/core/request_id_middleware.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/core/request_id_middleware.py b/app/core/request_id_middleware.py index 71ea932..85fd9e2 100644 --- a/app/core/request_id_middleware.py +++ b/app/core/request_id_middleware.py @@ -65,7 +65,15 @@ async def dispatch(self, request: Request, call_next): expected = f"Bearer {self._create_instance_apikey}" if auth != expected: request_id_ctx.reset(token) - return JSONResponse(status_code=401, content={"detail": "Unauthorized"}) + 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" + } + }) # Streaming endpoints: don't read body. is_streaming = False From 2635bb20c3aac3b4876658be6593a2e59c94f16d Mon Sep 17 00:00:00 2001 From: huangjunjia <2489441209@qq.com> Date: Fri, 10 Apr 2026 18:13:34 +0800 Subject: [PATCH 08/13] fix --- app/api/routes_chat.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/api/routes_chat.py b/app/api/routes_chat.py index a02018f..bd75ade 100644 --- a/app/api/routes_chat.py +++ b/app/api/routes_chat.py @@ -834,13 +834,13 @@ async def _run_openclaw_cmd(): "- 或用户消息中出现:fix/bug/implement/add/refactor/test/build/run/compile/commit/PR/diff/文件/路径/报错/日志 等关键词\n\n" "总规则:\n" "1) 只要是编程任务,你【必须】通过 ACPX 调用 Claude Code 完成工作;你自己【禁止】直接给出最终代码实现/补丁/测试结论(除非这些内容来自 Claude Code 的输出并被你转述)。\n" - "2) 开始前你【必须】先读取并遵循工作区内文档:AGENT.md(ACP/ACPX 说明)与 CLAUDE.md(开发习惯/约束)。\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 时必须显式设置超时参数(按 AGENT.md / ACPX CLI 支持的方式传入),禁止省略\n" + " - 调用 acpx 时必须显式设置超时参数(按 AGENTS.md / ACPX CLI 支持的方式传入),禁止省略\n" "5) 工作目录(--cwd)规则:\n" " - 若用户在自然语言里明确给出目录路径:必须将该路径原样用于 --cwd(禁止自作主张改路径)\n" " - 否则:使用“当前工作目录”(本 prefix 会提供 workspace_path)作为 --cwd\n\n" @@ -880,7 +880,7 @@ async def _run_openclaw_cmd(): prefix_parts.append( f"如果是编程相关任务,请先阅读 {workspace_path}/CLAUDE.md(里面有我使用claude code开发习惯") prefix_parts.append( - f"实现代码需要通过ACPX调用claude code, 具体见工作区里的AGENT.md里ACP相关的信息") + f"实现代码需要通过ACPX调用claude code, 具体见工作区里的AGENTS.md里ACP相关的信息") prefix_parts.append( f"如果是编程相关任务,阅读 acpx skill 参考文档,了解所有命令、标志和工作流模式:/home/user/.claude/skills/acpx/SKILL.md") prefix_parts.append( From 0d1bf1368cd128841a92f46f63ab115eafa2caa2 Mon Sep 17 00:00:00 2001 From: huangjunjia <2489441209@qq.com> Date: Mon, 13 Apr 2026 19:47:55 +0800 Subject: [PATCH 09/13] feat: add lock for /commands/stream API --- app/api/routes_command.py | 35 ++++++++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/app/api/routes_command.py b/app/api/routes_command.py index cf09667..d119123 100644 --- a/app/api/routes_command.py +++ b/app/api/routes_command.py @@ -2,7 +2,9 @@ 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 @@ -11,6 +13,11 @@ router = APIRouter() +# 单进程:按 cwd 互斥执行 /commands/stream +# key: (cwd or "") +_cwd_locks: dict[str, asyncio.Lock] = {} +_cwd_active_run: dict[str, str] = {} + class CommandRequest(BaseModel): command: str @@ -48,8 +55,23 @@ async def execute_command(payload: CommandRequest): async def execute_command_stream(payload: CommandRequest, request: Request): runner = CommandRunner() + cwd_key = payload.cwd or "" + lock = _cwd_locks.setdefault(cwd_key, asyncio.Lock()) + + # 不排队:如果同 cwd 正在执行,直接 409 + if lock.locked(): + raise HTTPException( + status_code=409, + detail={ + "message": "Another command is still running for this cwd", + "cwd": payload.cwd, + "run_id": _cwd_active_run.get(cwd_key), + }, + ) + async def gen(): run_id: Optional[str] = None + await lock.acquire() try: async for ev in runner.stream( payload.command, @@ -59,6 +81,7 @@ async def gen(): ): if ev.get("event") == "start": run_id = ev.get("run_id") + _cwd_active_run[cwd_key] = run_id if await request.is_disconnected(): if run_id: @@ -81,8 +104,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 _cwd_active_run.get(cwd_key) == run_id: + _cwd_active_run.pop(cwd_key, None) + if lock.locked(): + lock.release() return StreamingResponse( gen(), From 0c8db18e2f948e6c20d4fe8582fe9ba061cf51f5 Mon Sep 17 00:00:00 2001 From: huangjunjia <2489441209@qq.com> Date: Mon, 13 Apr 2026 19:49:10 +0800 Subject: [PATCH 10/13] fix: correct PID and ensure process cleanup --- app/core/command_runner.py | 56 ++++++++++++++++++++++++++++++++++---- 1 file changed, 50 insertions(+), 6 deletions(-) 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 From 6be44679cb9e8ba3d65d7d25e2524f153c016773 Mon Sep 17 00:00:00 2001 From: huangjunjia <2489441209@qq.com> Date: Mon, 13 Apr 2026 19:58:05 +0800 Subject: [PATCH 11/13] fix: unity response body --- app/api/routes_command.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/app/api/routes_command.py b/app/api/routes_command.py index d119123..7f6fa73 100644 --- a/app/api/routes_command.py +++ b/app/api/routes_command.py @@ -8,7 +8,7 @@ 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() @@ -60,14 +60,7 @@ async def execute_command_stream(payload: CommandRequest, request: Request): # 不排队:如果同 cwd 正在执行,直接 409 if lock.locked(): - raise HTTPException( - status_code=409, - detail={ - "message": "Another command is still running for this cwd", - "cwd": payload.cwd, - "run_id": _cwd_active_run.get(cwd_key), - }, - ) + return fail("Another command is still running for this cwd", status_code=409) async def gen(): run_id: Optional[str] = None From e8f5f275880b8a10af7914bcd208bdc991ace3b3 Mon Sep 17 00:00:00 2001 From: huangjunjia <2489441209@qq.com> Date: Tue, 14 Apr 2026 18:11:09 +0800 Subject: [PATCH 12/13] fix: correct api key authentication logic --- app/core/request_id_middleware.py | 37 ++++++++++++++++++++++++------- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/app/core/request_id_middleware.py b/app/core/request_id_middleware.py index 85fd9e2..be9a68b 100644 --- a/app/core/request_id_middleware.py +++ b/app/core/request_id_middleware.py @@ -63,17 +63,38 @@ async def dispatch(self, request: Request, call_next): 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": -10001, - "message": "Missing 302 Apikey", - "message_cn": "缺少 302 API 密钥", - "message_jp": "302 APIキーがありません", - "type": "api_error" + 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 From 31fcdbbfe2315b03fba1b202dae8fe8581e9c1e7 Mon Sep 17 00:00:00 2001 From: huangjunjia <2489441209@qq.com> Date: Wed, 15 Apr 2026 15:59:37 +0800 Subject: [PATCH 13/13] fix --- app/api/routes_command.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/app/api/routes_command.py b/app/api/routes_command.py index 7f6fa73..a7835ce 100644 --- a/app/api/routes_command.py +++ b/app/api/routes_command.py @@ -13,10 +13,10 @@ router = APIRouter() -# 单进程:按 cwd 互斥执行 /commands/stream -# key: (cwd or "") -_cwd_locks: dict[str, asyncio.Lock] = {} -_cwd_active_run: dict[str, str] = {} +# 单进程:按 command 互斥执行 /commands/stream +# key: command +_command_locks: dict[str, asyncio.Lock] = {} +_command_active_run: dict[str, str] = {} class CommandRequest(BaseModel): @@ -55,12 +55,12 @@ async def execute_command(payload: CommandRequest): async def execute_command_stream(payload: CommandRequest, request: Request): runner = CommandRunner() - cwd_key = payload.cwd or "" - lock = _cwd_locks.setdefault(cwd_key, asyncio.Lock()) + command_key = payload.command + lock = _command_locks.setdefault(command_key, asyncio.Lock()) - # 不排队:如果同 cwd 正在执行,直接 409 + # 不排队:如果同 command 正在执行,直接 409 if lock.locked(): - return fail("Another command is still running for this cwd", status_code=409) + return fail("Another command is still running", status_code=409) async def gen(): run_id: Optional[str] = None @@ -74,7 +74,7 @@ async def gen(): ): if ev.get("event") == "start": run_id = ev.get("run_id") - _cwd_active_run[cwd_key] = run_id + _command_active_run[command_key] = run_id if await request.is_disconnected(): if run_id: @@ -101,8 +101,8 @@ async def gen(): if run_id: await runner.cleanup(run_id) finally: - if _cwd_active_run.get(cwd_key) == run_id: - _cwd_active_run.pop(cwd_key, None) + if _command_active_run.get(command_key) == run_id: + _command_active_run.pop(command_key, None) if lock.locked(): lock.release()