"""受控 OpenCode executor HTTP 服务。 该模块运行在 ``mememeow-agent-runtime`` 容器内,是后端与 OpenCode 之间的 唯一执行边界。它只接受固定字段的研究任务,不接受 shell、任意命令、任意 环境变量或任意工作目录;任务结果仍通过共享 ``/runtime/task-results`` 文件 协议交付给后端。 """ from __future__ import annotations import hashlib import hmac import json import os import re import signal import socket import stat import subprocess import tempfile import threading import time from concurrent.futures import Future from dataclasses import dataclass, field from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path, PurePosixPath from typing import Any from decimal import Decimal from loguru import logger from urllib.parse import unquote, urlsplit from opentelemetry import metrics, trace from runtime_telemetry import TelemetryConfig, extract_carrier, protected_content, start_telemetry, trace_carrier from executor.sqlite_telemetry import SQLiteTelemetry try: from backend.opencode import RUNTIME_OPENCODE_CONFIG except ModuleNotFoundError: # pragma: no cover - Agent 镜像只复制 executor 公共快照 from executor.runtime_opencode_config import RUNTIME_OPENCODE_CONFIG from executor.model_capability import ( MODEL_BROKER_URL_ENV, MODEL_CAPABILITY_FIELD, MODEL_CAPABILITY_ENV, ModelCapabilityError, validate_model_broker_url, validate_model_capability, validate_model_name, ) try: from backend.opencode_workspace import ( SELECTOR_RE, WorkspaceCapabilityError, WorkspaceCapabilitySigner, build_edit_permission_rules, validate_directory_path, validate_file_path, ) except ModuleNotFoundError as exc: # pragma: no cover - Agent 镜像只复制 executor 快照 if exc.name != "backend": raise from executor.opencode_workspace import ( SELECTOR_RE, WorkspaceCapabilityError, WorkspaceCapabilitySigner, build_edit_permission_rules, validate_directory_path, validate_file_path, ) from executor.agent_limits import validate_agent_concurrency from executor.analysis_policy import AnalysisPolicyError, parse_analysis_policy from executor.analysis_monitor import AnalysisMonitor, AnalysisControlError from executor.attempt_diagnostics import AttemptOutput, configure_executor_diagnostics, stream_sample as _stream_sample from executor.process_supervisor import ProcessSupervisor from executor.database_maintenance import DatabaseMaintenance from executor.result_store import ExecutorResultStore, ExecutorResultStoreError from executor.task_queue import ExecutionQueue from executor.token import ExecutorTokenError, ensure_token_file, read_token_file try: from backend.public_dto import PublicDataError, secret_inventory_from_mapping, validate_agent_result except ModuleNotFoundError as exc: # pragma: no cover - Agent 镜像只复制 executor 快照 if exc.name != "backend": raise from executor.public_dto import PublicDataError, secret_inventory_from_mapping, validate_agent_result try: from backend.visual_snapshot import VisualMatchSnapshotError, validate_visual_match_snapshot_manifest except ModuleNotFoundError as exc: # pragma: no cover - Agent 镜像只复制无状态协议快照 if exc.name not in {"backend", "backend.visual_snapshot"}: raise try: from executor.visual_snapshot import VisualMatchSnapshotError, validate_visual_match_snapshot_manifest except ModuleNotFoundError: # 最小验证镜像可能只复制 executor 公共快照;此时加载同一份无状态校验 # 实现,避免 executor 因可选视觉 manifest 文件缺失而无法启动。 class VisualMatchSnapshotError(ValueError): """最小 executor 镜像中的视觉 manifest 校验错误。""" def validate_visual_match_snapshot_manifest(value: object, *, expected_sha256: str | None = None) -> dict[str, object]: """在无完整 backend 源码时拒绝无法验证的视觉 manifest。""" del value, expected_sha256 raise VisualMatchSnapshotError("visual_match_snapshot_invalid") TASK_ID_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}\Z") SESSION_ID_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,254}\Z") CONFIG_HASH_RE = re.compile(r"[0-9a-fA-F]{64}\Z") RESULT_FILE_NAME = "result.json.tmp" RESULT_DRAFT_NAME = "result.json.draft" ATTEMPT_METADATA_NAME = ".executor-attempts.json" RUNTIME_ROOT = Path(os.getenv("MEMEMEOW_EXECUTOR_RUNTIME_ROOT", "/runtime")) IMAGE_ROOT = Path(os.getenv("MEMEMEOW_EXECUTOR_IMAGE_ROOT", "/images")) WORKSPACE = RUNTIME_ROOT / "workspace" RESULT_ROOT = RUNTIME_ROOT / "task-results" LOG_ROOT = RUNTIME_ROOT / "logs" ANALYSIS_SOCKET_ROOT = RUNTIME_ROOT / "analysis-sockets" SKILL_ROOT = Path(os.getenv("MEMEMEOW_EXECUTOR_SKILL_ROOT", "/skills/research-meme-context")) WORKSPACE_ROOT = Path(os.getenv("MEMEMEOW_EXECUTOR_WORKSPACE_ROOT", str(RUNTIME_ROOT / "workspaces"))) DEFAULT_MAX_RESULT_BYTES = 1024 * 1024 MAX_CANDIDATE_FILE_BYTES = 64 * 1024 * 1024 MAX_CANDIDATE_ROOT_BYTES = 256 * 1024 * 1024 # session 只解析完整 JSONL 行;单行超过上限时跳过,避免异常输出占满内存。 SESSION_CAPTURE_LINE_BYTES = 1024 * 1024 ALLOWED_REQUEST_FIELDS = frozenset( { "task_id", "business_task_id", "executor_attempt_id", "resume_of_attempt_id", "session_id", "processing_config_hash", "image_relative_path", "reverse_image_policy", "timeout_seconds", "wait", "callback_token", "trace_context", "trace_attributes", "workspace_selector", "workspace_capability", "visual_snapshot_sha256", "analysis_policy", MODEL_CAPABILITY_FIELD, } ) REQUIRED_RESULT_FIELDS = frozenset( {"title", "summary", "subjects", "visible_text", "references", "meaning", "keywords", "search_queries", "uncertainties"} ) TASK_HISTORY_LIMIT = 5000 _EXECUTOR_ERROR_CODES = frozenset( { "agent_analysis_policy_missing", "agent_analysis_policy_invalid", "agent_analysis_usage_unavailable", "agent_analysis_reminder_plugin_unavailable", "agent_maximum_analysis_depth_exceeded", "agent_timeout", "task_interrupted", "agent_process_failed", "agent_executor_internal_error", "unknown_execution", "agent_output_invalid_json", "agent_result_file_missing", "agent_result_file_unreadable", "agent_result_file_too_large", "agent_result_file_invalid_json", "agent_result_file_schema_invalid", "agent_result_path_invalid", "agent_image_path_forbidden", "agent_timeout_limit_exceeded", "agent_runtime_unavailable", "opencode_not_configured", "invalid_task", "invalid_reverse_image_policy", "agent_backpressure", "task_exists", "agent_provider_rate_limited", "agent_provider_server_error", "agent_connection_interrupted", "session_binding_mismatch", "session_not_resumable", "opencode_workspace_invalid", "opencode_workspace_mismatch", "opencode_workspace_capability_invalid", "opencode_workspace_capability_expired", "opencode_workspace_capability_unavailable", "model_capability_invalid", "model_capability_unavailable", "model_broker_endpoint_invalid", "model_name_invalid", "visual_candidate_materialization_failed", "visual_match_snapshot_invalid", } ) _SECRET_PATTERNS = ( # 分开处理 JSON 字符串值和普通 header/日志格式,保留原有引号结构。 re.compile(r"(?i)(authorization\s*[\"']?\s*:\s*[\"']?bearer\s+)[^\s,;}\]\"']+"), re.compile(r"(?i)([\"']?(?:api[_-]?key|access[_-]?token|refresh[_-]?token|token|secret|password)[\"']?\s*:\s*)([\"'])(.*?)\2"), re.compile(r"(?i)([\"']?(?:api[_-]?key|access[_-]?token|refresh[_-]?token|token|secret|password)[\"']?\s*[:=]\s*)[^\s,;}\]\"']+"), ) _PATH_PATTERN = re.compile(r"(?:/runtime|/images|/skills|/app|[A-Za-z]:\\)[^\s,;]+") _GENERIC_PATH_PATTERN = re.compile(r"(? None: """初始化结果文件失败;外部错误码仍保持原有协议。""" super().__init__(code) self.reason_code = reason_code def _env_int(name: str, default: int, minimum: int, maximum: int) -> int: """读取并限制 executor 的整数配置,非法值使用安全默认值。""" try: value = int(os.getenv(name, str(default))) except (TypeError, ValueError): return default return max(minimum, min(value, maximum)) def _env_agent_concurrency(name: str, default: int) -> int: """读取 executor 的 Agent 运行并发,只校验正整数。""" raw = os.getenv(name) if raw is None: value = default else: try: value = int(raw) except (TypeError, ValueError) as exc: raise ValueError("agent_concurrency_invalid") from exc return validate_agent_concurrency(value) def _json_error(code: str, message: str, *, reason_code: str | None = None) -> dict[str, object]: """构造不包含本地路径、命令或秘密的稳定错误响应。""" error: dict[str, object] = {"error": code, "message": message} if isinstance(reason_code, str) and re.fullmatch(r"[a-z0-9][a-z0-9_.-]{0,127}", reason_code): error["reason_code"] = reason_code return error def _redact_diagnostic(value: str, secrets: tuple[str, ...] = ()) -> str: """从上游诊断中移除已知密钥和常见凭据格式,保留有限可读错误。""" value = value.splitlines()[0] if value.splitlines() else value for secret in secrets: if secret: value = value.replace(secret, "[REDACTED]") for pattern in _SECRET_PATTERNS: if pattern.groups == 3: value = pattern.sub(r"\1\2[REDACTED]\2", value) else: value = pattern.sub(r"\1[REDACTED]", value) value = _PATH_PATTERN.sub("[PATH]", value) value = _GENERIC_PATH_PATTERN.sub("[PATH]", value) return value[:500] def _safe_json(value: Any) -> bytes: """序列化受控响应,避免响应中出现非 JSON 值。""" return json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode("utf-8") def _session_id_from_event(value: Any) -> str | None: """从 OpenCode JSONL 事件递归提取 session 标识。""" if isinstance(value, dict): for key in ("session_id", "sessionID", "sessionId"): candidate = value.get(key) if isinstance(candidate, str) and candidate: return candidate for nested in value.values(): found = _session_id_from_event(nested) if found: return found elif isinstance(value, list): for nested in value: found = _session_id_from_event(nested) if found: return found return None def _diagnostic(stdout: bytes, stderr: bytes, *, secrets: tuple[str, ...] = ()) -> str: """从有限输出中提取安全诊断,不返回完整 transcript 或密钥。""" text = stderr[:2048].decode("utf-8", errors="replace").strip() if text: return _redact_diagnostic(text, secrets) for line in stdout.splitlines()[:128]: try: event = json.loads(line) except (TypeError, json.JSONDecodeError): continue if isinstance(event, dict) and event.get("type") == "error": error = event.get("error") data = error.get("data") if isinstance(error, dict) else None message = data.get("message") if isinstance(data, dict) else None if isinstance(message, str) and message.strip(): return _redact_diagnostic(message.strip(), secrets) return "OpenCode 进程执行失败" def _relative_image_path(value: object) -> Path: """验证图片相对路径,拒绝绝对路径、父级跳转和符号链接入口。""" if not isinstance(value, str) or not value or len(value) > 512 or "\\" in value or re.match(r"^[A-Za-z]:", value): raise ValueError("agent_image_path_forbidden") raw_parts = value.split("/") if any(not part or part in {".", ".."} for part in raw_parts) or any(ord(character) < 0x20 for character in value): raise ValueError("agent_image_path_forbidden") normalized = PurePosixPath(value) if normalized.is_absolute() or not normalized.parts: raise ValueError("agent_image_path_forbidden") return Path(*normalized.parts) @dataclass class TaskState: """一个 executor attempt 的受控状态和取消句柄。""" task_id: str business_task_id: str executor_attempt_id: str image_relative_path: str reverse_image_policy: str timeout_seconds: int processing_config_hash: str | None = None session_id: str | None = None resume_of_attempt_id: str | None = None is_resume: bool = False callback_token: str | None = field(default=None, repr=False) workspace_selector: str = "local" workspace_capability: str | None = field(default=None, repr=False) model_capability: str | None = field(default=None, repr=False) workspace_directory: Path | None = field(default=None, repr=False) images_root: Path | None = field(default=None, repr=False) metadata_root: Path | None = field(default=None, repr=False) skill_root: Path | None = field(default=None, repr=False) candidate_root: Path | None = field(default=None, repr=False) visual_snapshot_sha256: str | None = field(default=None, repr=False) task_scratch_root: Path | None = field(default=None, repr=False) config_file: Path | None = field(default=None, repr=False) config_dir: Path | None = field(default=None, repr=False) status: str = "queued" created_at: float = field(default_factory=time.time) started_at: float | None = None completed_at: float | None = None process_reaped: bool = True error: dict[str, object] | None = None result_path: str | None = None process: subprocess.Popen[bytes] | None = field(default=None, repr=False) cancel_event: threading.Event = field(default_factory=threading.Event, repr=False) cancel_reason: str | None = None done: threading.Event = field(default_factory=threading.Event, repr=False) analysis_policy: dict[str, object] | None = field(default=None, repr=False) observed_cost: str | None = None usage_checked_at: str | None = None reminder_sent: bool = False database_maintenance: dict[str, object] | None = None trace_context: dict[str, str] = field(default_factory=dict, repr=False) trace_attributes: dict[str, str] = field(default_factory=dict, repr=False) def public(self) -> dict[str, object]: """返回 API 可见状态,不暴露进程对象和 executor 本地实现细节。""" return { "task_id": self.business_task_id, "business_task_id": self.business_task_id, "executor_attempt_id": self.executor_attempt_id, "status": self.status, "image_relative_path": self.image_relative_path, "reverse_image_policy": self.reverse_image_policy, "session_id": self.session_id, "resume_of_attempt_id": self.resume_of_attempt_id, "processing_config_hash": self.processing_config_hash, "is_resume": self.is_resume, "result_path": self.result_path, "error": self.error, "created_at": self.created_at, "started_at": self.started_at, "completed_at": self.completed_at, "process_reaped": self.process_reaped, "workspace_selector": self.workspace_selector, "observed_cost": self.observed_cost, "usage_checked_at": self.usage_checked_at, "reminder_sent": self.reminder_sent, "database_maintenance": self.database_maintenance, } @dataclass(frozen=True) class WorkspaceLayout: """executor 内部已验证的 selector 目录布局。""" selector: str directory: Path images_root: Path metadata_root: Path skill_root: Path candidate_root: Path task_scratch_root: Path config_file: Path config_dir: Path local: bool = False @property def candidate_manifest_path(self) -> Path: """返回当前 executor Task 固定的候选 manifest 路径。""" return self.candidate_root / "manifest.json" class Executor: """在容器内管理固定 OpenCode 任务接口的并发执行器。""" def __init__(self) -> None: """初始化共享目录、认证配置和有限并发池。""" # Agent 运行身份创建的 runtime 文件不应继承镜像默认 umask 的 group/other 位。 os.umask(0o077) self.token_file = os.getenv("MEMEMEOW_AGENT_EXECUTOR_TOKEN_FILE", "").strip() # 环境变量兼容显式 host/测试夹具,但空白 token 必须和 token 文件一样失败关闭。 self.token = os.getenv("MEMEMEOW_AGENT_EXECUTOR_TOKEN", "").strip() self.token_error: str | None = None if not self.token and self.token_file: try: self.token = ensure_token_file(self.token_file) except ExecutorTokenError as exc: self.token_error = str(exc) try: self.model = validate_model_name(os.getenv("MEMEMEOW_OPENCODE_MODEL", "")) except ModelCapabilityError: self.model = "" self.release_profile = os.getenv("MEMEMEOW_PUBLIC_RELEASE_PROFILE", "local").strip().casefold() self.model_broker_url = os.getenv(MODEL_BROKER_URL_ENV, "").strip() self.legacy_base_url = os.getenv("MEMEMEOW_OPENCODE_BASE_URL", "").strip() self.legacy_api_key = os.getenv("MEMEMEOW_OPENCODE_API_KEY", "").strip() self.opencode_executable = os.getenv("MEMEMEOW_OPENCODE_EXECUTABLE", "opencode") self.max_workers = _env_agent_concurrency("MEMEMEOW_OPENCODE_CONCURRENCY", 1) self.max_timeout = _env_int("MEMEMEOW_AGENT_EXECUTOR_MAX_TIMEOUT_SECONDS", 1800, 1, 7200) self.max_result_bytes = _env_int("MEMEMEOW_AGENT_RESULT_MAX_BYTES", DEFAULT_MAX_RESULT_BYTES, 1024, 16 * 1024 * 1024) capability_key = os.getenv("MEMEMEOW_WORKSPACE_CAPABILITY_KEY", os.getenv("MEMEMEOW_AGENT_WORKSPACE_CAPABILITY_KEY", "")) self.capability_signer = WorkspaceCapabilitySigner(capability_key) # workspace root 只允许由部署预装配;executor 不因请求 selector 创建新的 # scope 目录。未配置时仍保留旧 local 请求兼容路径。 self.workspace_root = Path(os.getenv("MEMEMEOW_EXECUTOR_WORKSPACE_ROOT", str(WORKSPACE_ROOT))) self.lock = threading.RLock() self.tasks: dict[str, TaskState] = {} # queue 只负责线程池生命周期;任务状态、attempt 和结果事实仍由 Executor # 控制面维护,避免 HTTP 入口直接持有线程池细节。 self.pool = ExecutionQueue(self.max_workers, thread_name_prefix="mememeow-opencode") self.process_supervisor = ProcessSupervisor() self.database_maintenance = DatabaseMaintenance() self.futures: dict[str, Future[None]] = {} self.ready_error: str | None = None self._prepare_runtime() @property def model_broker_configured(self) -> bool: """判断生产模型 broker endpoint 是否已配置且格式有效。""" try: validate_model_broker_url(self.model_broker_url) except ModelCapabilityError: return False return True @property def model_configured(self) -> bool: """判断当前 profile 是否具备模型连接材料。 生产 executor 只能使用 broker endpoint 和任务 capability;local profile 保留旧 endpoint/key 夹具兼容,但该兼容值不会进入生产配置。 """ if not self.model: return False if self.release_profile in {"production", "public", "1", "true", "yes", "on"}: return self.model_broker_configured return self.model_broker_configured or bool(self.legacy_base_url and self.legacy_api_key) def _prepare_runtime(self) -> None: """创建共享 runtime 目录并确保 executor 以非 root 可写方式启动。""" try: for path in (RUNTIME_ROOT, WORKSPACE, RESULT_ROOT, LOG_ROOT, ANALYSIS_SOCKET_ROOT, RUNTIME_ROOT / "candidates", RUNTIME_ROOT / "home"): path.mkdir(parents=True, exist_ok=True) database_path = RUNTIME_ROOT / "opencode.db" database_path.touch(exist_ok=True) os.chmod(database_path, stat.S_IRUSR | stat.S_IWUSR) if any(not os.access(path, os.R_OK | os.W_OK) for path in (RUNTIME_ROOT, WORKSPACE, RESULT_ROOT, LOG_ROOT)): self.ready_error = "runtime_not_writable" return if self.token_error: self.ready_error = self.token_error elif not self.token: self.ready_error = "executor_token_not_configured" elif not self.model_configured: self.ready_error = "model_broker_not_configured" elif not shutil_which(self.opencode_executable): self.ready_error = "opencode_executable_missing" except OSError: self.ready_error = "runtime_not_ready" def health(self) -> dict[str, object]: """返回真实 executor 健康状态,供 Compose 和后端探针使用。""" runtime_ok = RUNTIME_ROOT.is_dir() and os.access(RUNTIME_ROOT, os.R_OK | os.W_OK) workspace_ok = WORKSPACE.is_dir() and os.access(WORKSPACE, os.R_OK | os.W_OK) result_ok = RESULT_ROOT.is_dir() and os.access(RESULT_ROOT, os.R_OK | os.W_OK) logs_ok = LOG_ROOT.is_dir() and os.access(LOG_ROOT, os.R_OK | os.W_OK) image_ok = IMAGE_ROOT.is_dir() and os.access(IMAGE_ROOT, os.R_OK) and not os.access(IMAGE_ROOT, os.W_OK) skill_ok = SKILL_ROOT.is_dir() and os.access(SKILL_ROOT, os.R_OK) and not os.access(SKILL_ROOT, os.W_OK) executable_ok = bool(shutil_which(self.opencode_executable)) socket_absent = not Path("/var/run/docker.sock").exists() token_ok = bool(self.token) if self.token_file: try: token_ok = hmac.compare_digest(read_token_file(self.token_file), self.token) except ExecutorTokenError: token_ok = False ready = bool(not self.ready_error and token_ok and runtime_ok and workspace_ok and result_ok and logs_ok and image_ok and skill_ok and executable_ok and socket_absent) return { "status": "ok" if ready else "degraded", "ready": ready, "executor": "mememeow-agent-executor", "opencode": executable_ok, "runtime_read_write": runtime_ok and workspace_ok and result_ok and logs_ok, "images_read_only": image_ok, "skills_read_only": skill_ok, "docker_socket_absent": socket_absent, "token_configured": token_ok, "opencode_configured": self.model_configured, "model_broker_configured": self.model_broker_configured, "capacity": self.max_workers, "queued": self._queued_count(), "error": self.ready_error, } def _queued_count(self) -> int: """返回尚未开始的任务数量,调用方可在锁外使用。""" with self.lock: return sum(1 for task in self.tasks.values() if task.status == "queued") @staticmethod def _checked_directory(path: Path, *, create: bool = False, missing_code: str = "opencode_workspace_invalid") -> Path: """在 executor 边界逐级拒绝符号链接、文件和越界目录。""" try: return validate_directory_path(path, create=create, code=missing_code) except Exception as exc: # noqa: BLE001 - executor 对外只返回稳定错误码 raise ValueError(missing_code) from exc def _workspace_layout(self, *, selector: str | None, business_task_id: str) -> WorkspaceLayout: """把 selector 解析到固定布局;未知 selector 不创建任何 scope 目录。""" if selector is None: directory = self._checked_directory(WORKSPACE) images_root = self._checked_directory(IMAGE_ROOT) metadata_root = images_root skill_root = self._checked_directory(SKILL_ROOT) base = directory local = True else: if not isinstance(selector, str) or not SELECTOR_RE.fullmatch(selector): raise ValueError("opencode_workspace_invalid") if selector == "local": directory = self._checked_directory(WORKSPACE) images_root = self._checked_directory(IMAGE_ROOT) metadata_root = images_root skill_root = self._checked_directory(SKILL_ROOT) local = True task_scratch = directory / "tasks" / business_task_id candidate_root = RUNTIME_ROOT / "candidates" / business_task_id return WorkspaceLayout( selector="local", directory=directory, images_root=images_root, metadata_root=metadata_root, skill_root=skill_root, candidate_root=candidate_root, task_scratch_root=task_scratch, config_file=task_scratch / "opencode.json", config_dir=task_scratch / ".opencode", local=True, ) root = self._checked_directory(self.workspace_root, missing_code="opencode_workspace_invalid") base = root / selector self._checked_directory(base, missing_code="opencode_workspace_invalid") directory = self._checked_directory(base / "workspace") images_root = self._checked_directory(base / "images") metadata_root = self._checked_directory(base / "metadata") skill_root = self._checked_directory(base / "skills") local = False task_scratch = directory / "tasks" / business_task_id # 只检查祖先;创建由 _run 在锁定任务后完成,校验失败不会留下目录。 tasks_root = directory / "tasks" try: tasks_root.lstat() except FileNotFoundError: pass else: self._checked_directory(tasks_root) config_dir = task_scratch / ".opencode" candidate_parent = (base / "candidates") if not local else (base.parent / "candidates") self._checked_directory(candidate_parent, create=True) candidate_root = candidate_parent / business_task_id if candidate_root.exists() or candidate_root.is_symlink(): self._checked_directory(candidate_root) return WorkspaceLayout( selector=selector or "local", directory=directory, images_root=images_root, metadata_root=metadata_root, skill_root=skill_root, candidate_root=candidate_root, task_scratch_root=task_scratch, config_file=task_scratch / "opencode.json", config_dir=config_dir, local=local, ) def _verify_workspace_capability(self, values: dict[str, object]) -> None: """校验 selector capability 的签名、受众、期限和 attempt 绑定。""" selector = values.get("workspace_selector") capability = values.get("workspace_capability") if selector is None: if capability is not None: raise ValueError("opencode_workspace_mismatch") return if selector == "local" and capability is None: # 显式 local 仍兼容旧单用户调用;非 local selector 永远需要签名材料。 return if not isinstance(capability, str) or not capability: raise ValueError("opencode_workspace_capability_unavailable") if not self.capability_signer.configured: raise ValueError("opencode_workspace_capability_unavailable") try: self.capability_signer.verify( capability, task_id=str(values["business_task_id"]), attempt_id=str(values["executor_attempt_id"]), selector=str(selector), session_id=str(values["session_id"]) if isinstance(values.get("session_id"), str) else None, resume_of_attempt_id=str(values["resume_of_attempt_id"]) if isinstance(values.get("resume_of_attempt_id"), str) else None, ) except WorkspaceCapabilityError as exc: raise ValueError(exc.code) from exc def _verify_task_workspace_capability(self, task: TaskState) -> None: """在排队任务真正启动前再次验证 capability,避免长队列越过有效期。""" if task.workspace_selector == "local": return if not task.workspace_capability or not self.capability_signer.configured: raise RuntimeError("opencode_workspace_capability_unavailable") try: self.capability_signer.verify( task.workspace_capability, task_id=task.business_task_id, attempt_id=task.executor_attempt_id, selector=task.workspace_selector, session_id=task.session_id, resume_of_attempt_id=task.resume_of_attempt_id, ) except WorkspaceCapabilityError as exc: raise RuntimeError(exc.code) from exc def _prune_history_locked(self) -> None: """限制内存中的终态历史,避免长期运行的 executor 被任务标识耗尽内存。""" overflow = len(self.tasks) - TASK_HISTORY_LIMIT if overflow <= 0: return terminal = sorted( (task for task in self.tasks.values() if task.status in {"succeeded", "failed", "cancelled"}), key=lambda task: (task.completed_at or task.created_at, task.created_at, task.task_id), ) for task in terminal[:overflow]: self.tasks.pop(task.executor_attempt_id, None) self.futures.pop(task.executor_attempt_id, None) def _attempt_metadata_path(self, business_task_id: str) -> Path: """返回任务专属 attempt 元数据路径,并验证业务标识可安全拼接。""" if not TASK_ID_RE.fullmatch(business_task_id): raise ValueError("agent_result_path_invalid") root_info = RESULT_ROOT.lstat() if stat.S_ISLNK(root_info.st_mode) or not stat.S_ISDIR(root_info.st_mode): raise ValueError("agent_result_path_invalid") result_dir = RESULT_ROOT / business_task_id if result_dir.is_symlink() or (result_dir.exists() and not result_dir.is_dir()): raise ValueError("agent_result_path_invalid") return result_dir / ATTEMPT_METADATA_NAME def _metadata_signature(self, attempts: list[dict[str, object]]) -> str: """使用 executor token 对可恢复 attempt 事实做完整性签名。""" raw = json.dumps(attempts, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8") return hmac.new(self.token.encode("utf-8"), raw, hashlib.sha256).hexdigest() def _load_attempt_metadata(self, business_task_id: str) -> list[dict[str, object]]: """读取并验证 runtime 中持久化的 attempt 绑定,损坏时返回空集合。""" try: path = self._attempt_metadata_path(business_task_id) metadata = path.lstat() if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISREG(metadata.st_mode) or metadata.st_size > 256 * 1024: return [] descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)) try: raw = os.read(descriptor, 256 * 1024 + 1) finally: os.close(descriptor) document = json.loads(raw.decode("utf-8")) attempts = document.get("attempts") if isinstance(document, dict) else None signature = document.get("signature") if isinstance(document, dict) else None if not isinstance(attempts, list) or not isinstance(signature, str): return [] values = [item for item in attempts if isinstance(item, dict)] if len(values) != len(attempts) or not hmac.compare_digest(signature, self._metadata_signature(values)): return [] return values[-32:] except (OSError, UnicodeDecodeError, json.JSONDecodeError, TypeError, ValueError, RecursionError): return [] def _persist_attempt_metadata(self, task: TaskState) -> None: """原子保存终态 attempt 的最小绑定事实,供 executor 重启后恢复。""" # 同一业务任务的两个 attempt 可能几乎同时收束;锁住读-改-写序列, # 避免后完成的 attempt 覆盖前一个失败事实,导致重启后无法续跑。 with self.lock: self._persist_attempt_metadata_locked(task) def _persist_attempt_metadata_locked(self, task: TaskState) -> None: """在 executor 锁内原子保存 attempt 元数据,调用者负责持有锁。""" path = self._attempt_metadata_path(task.business_task_id) path.parent.mkdir(parents=True, exist_ok=True) existing = self._load_attempt_metadata(task.business_task_id) entry: dict[str, object] = { "business_task_id": task.business_task_id, "executor_attempt_id": task.executor_attempt_id, "image_relative_path": task.image_relative_path, "reverse_image_policy": task.reverse_image_policy, "processing_config_hash": task.processing_config_hash, "visual_snapshot_sha256": task.visual_snapshot_sha256, "workspace_selector": task.workspace_selector, "session_id": task.session_id, "resume_of_attempt_id": task.resume_of_attempt_id, "status": task.status, "error": task.error, "created_at": task.created_at, "completed_at": task.completed_at, "process_reaped": task.process_reaped, "analysis_policy": task.analysis_policy, "observed_cost": task.observed_cost, "usage_checked_at": task.usage_checked_at, "reminder_sent": task.reminder_sent, "database_maintenance": task.database_maintenance, } values = [item for item in existing if item.get("executor_attempt_id") != task.executor_attempt_id] values.append(entry) document = {"attempts": values[-32:], "signature": self._metadata_signature(values[-32:])} raw = _safe_json(document) descriptor, temporary_name = tempfile.mkstemp(prefix=".executor-attempts.", dir=path.parent) try: with os.fdopen(descriptor, "wb") as handle: handle.write(raw) os.chmod(temporary_name, stat.S_IRUSR | stat.S_IWUSR) os.replace(temporary_name, path) except Exception: try: os.unlink(temporary_name) except OSError: pass raise def _persisted_resume_source(self, values: dict[str, object]) -> TaskState | None: """从签名元数据恢复失败 attempt,供 executor 服务重启后续跑。""" business_task_id = str(values["business_task_id"]) session_id = values.get("session_id") resume_of = values.get("resume_of_attempt_id") if not isinstance(session_id, str): return None for entry in reversed(self._load_attempt_metadata(business_task_id)): if ( entry.get("business_task_id") != business_task_id or entry.get("session_id") != session_id or entry.get("image_relative_path") != values.get("image_relative_path") or entry.get("reverse_image_policy") != values.get("reverse_image_policy") or entry.get("processing_config_hash") != values.get("processing_config_hash") or entry.get("visual_snapshot_sha256") != values.get("visual_snapshot_sha256") or entry.get("workspace_selector", "local") != (values.get("workspace_selector") or "local") or (resume_of is not None and entry.get("executor_attempt_id") != resume_of) ): continue if entry.get("status") != "failed" or entry.get("process_reaped") is not True or not isinstance(entry.get("executor_attempt_id"), str): continue error = entry.get("error") if isinstance(entry.get("error"), dict) else {} if error.get("error") not in { "service_shutdown", "agent_provider_rate_limited", "agent_provider_server_error", "agent_connection_interrupted", "agent_process_failed", }: continue return TaskState( task_id=business_task_id, business_task_id=business_task_id, executor_attempt_id=str(entry["executor_attempt_id"]), image_relative_path=str(entry["image_relative_path"]), reverse_image_policy=str(entry["reverse_image_policy"]), timeout_seconds=int(values["timeout_seconds"]), processing_config_hash=values.get("processing_config_hash") if isinstance(values.get("processing_config_hash"), str) else None, visual_snapshot_sha256=values.get("visual_snapshot_sha256") if isinstance(values.get("visual_snapshot_sha256"), str) else None, session_id=session_id, workspace_selector=str(values.get("workspace_selector") or "local"), status="failed", error={str(key): value for key, value in error.items()}, created_at=float(entry.get("created_at")) if isinstance(entry.get("created_at"), (int, float)) else time.time(), completed_at=float(entry.get("completed_at")) if isinstance(entry.get("completed_at"), (int, float)) else time.time(), process_reaped=True, analysis_policy=entry.get("analysis_policy"), observed_cost=entry.get("observed_cost"), usage_checked_at=entry.get("usage_checked_at"), ) return None def _validate_request(self, payload: object) -> dict[str, object]: """校验固定任务字段并返回业务任务与 attempt 的规范化参数。""" if not isinstance(payload, dict): raise ValueError("invalid_task") unknown = set(payload) - ALLOWED_REQUEST_FIELDS if unknown: raise ValueError("invalid_task") task_id = payload.get("task_id") if not isinstance(task_id, str) or not TASK_ID_RE.fullmatch(task_id): raise ValueError("agent_result_path_invalid") business_task_id = payload.get("business_task_id", task_id) if not isinstance(business_task_id, str) or not TASK_ID_RE.fullmatch(business_task_id) or business_task_id != task_id: raise ValueError("invalid_task") raw_attempt_id = payload.get("executor_attempt_id") # 旧客户端没有 attempt 字段时使用业务 ID 作为兼容别名;新客户端总是 # 发送独立 attempt,因而终态历史不会被恢复请求复用。 executor_attempt_id = raw_attempt_id if raw_attempt_id is not None else business_task_id if not isinstance(executor_attempt_id, str) or not TASK_ID_RE.fullmatch(executor_attempt_id): raise ValueError("invalid_task") raw_resume_of = payload.get("resume_of_attempt_id") if raw_resume_of is not None and ( not isinstance(raw_resume_of, str) or not TASK_ID_RE.fullmatch(raw_resume_of) ): raise ValueError("invalid_task") session_id = payload.get("session_id") if session_id is not None and (not isinstance(session_id, str) or not SESSION_ID_RE.fullmatch(session_id)): raise ValueError("session_binding_mismatch") if raw_resume_of is not None and session_id is None: raise ValueError("session_binding_mismatch") processing_config_hash = payload.get("processing_config_hash") if processing_config_hash is not None and ( not isinstance(processing_config_hash, str) or not CONFIG_HASH_RE.fullmatch(processing_config_hash) ): raise ValueError("invalid_task") visual_snapshot_sha256 = payload.get("visual_snapshot_sha256") if visual_snapshot_sha256 is not None and ( not isinstance(visual_snapshot_sha256, str) or not CONFIG_HASH_RE.fullmatch(visual_snapshot_sha256) ): raise ValueError("visual_match_snapshot_invalid") relative = _relative_image_path(payload.get("image_relative_path")) raw_selector = payload.get("workspace_selector") if raw_selector is None or raw_selector == "local": image = IMAGE_ROOT / relative try: current = IMAGE_ROOT for part in relative.parts: current = current / part if current.is_symlink(): raise ValueError("agent_image_path_forbidden") root = IMAGE_ROOT.resolve() candidate = image.resolve(strict=True) candidate.relative_to(root) if not candidate.is_file(): raise ValueError("agent_image_path_forbidden") except (OSError, RuntimeError, ValueError) as exc: if isinstance(exc, ValueError) and str(exc) == "agent_image_path_forbidden": raise raise ValueError("agent_image_path_forbidden") from exc policy = payload.get("reverse_image_policy", "forbid") if policy not in {"forbid", "auto"}: raise ValueError("invalid_reverse_image_policy") raw_timeout = payload.get("timeout_seconds", self.max_timeout) if isinstance(raw_timeout, bool): raise ValueError("invalid_task") try: timeout = int(raw_timeout) except (TypeError, ValueError) as exc: raise ValueError("invalid_task") from exc if timeout < 1 or timeout > self.max_timeout: raise ValueError("agent_timeout_limit_exceeded") wait = payload.get("wait", True) if not isinstance(wait, bool): raise ValueError("invalid_task") callback_token = payload.get("callback_token") if callback_token is not None and ( not isinstance(callback_token, str) or len(callback_token) > 4096 or any(character.isspace() or ord(character) < 0x20 for character in callback_token) ): raise ValueError("invalid_task") workspace_selector = raw_selector if workspace_selector is not None and (not isinstance(workspace_selector, str) or not SELECTOR_RE.fullmatch(workspace_selector)): raise ValueError("opencode_workspace_invalid") workspace_capability = payload.get("workspace_capability") if workspace_capability is not None and (not isinstance(workspace_capability, str) or not workspace_capability or len(workspace_capability) > 4096): raise ValueError("opencode_workspace_capability_invalid") model_capability = payload.get(MODEL_CAPABILITY_FIELD) if model_capability is not None: try: model_capability = validate_model_capability(model_capability) except ModelCapabilityError as exc: raise ValueError(str(exc)) from exc elif self.release_profile in {"production", "public", "1", "true", "yes", "on"} or self.model_broker_configured: raise ValueError("model_capability_unavailable") values = { "task_id": business_task_id, "business_task_id": business_task_id, "executor_attempt_id": executor_attempt_id, "resume_of_attempt_id": raw_resume_of, "session_id": session_id, "processing_config_hash": processing_config_hash.lower() if isinstance(processing_config_hash, str) else None, "visual_snapshot_sha256": visual_snapshot_sha256.lower() if isinstance(visual_snapshot_sha256, str) else None, "image_relative_path": relative.as_posix(), "reverse_image_policy": str(policy), "timeout_seconds": timeout, "wait": wait, "callback_token": callback_token, "workspace_selector": workspace_selector, "workspace_capability": workspace_capability, MODEL_CAPABILITY_FIELD: model_capability, "trace_context": {key: value for key, value in (payload.get("trace_context") or {}).items() if key in {"traceparent", "tracestate"} and isinstance(value, str) and len(value) <= 512} if isinstance(payload.get("trace_context"), dict) else {}, "trace_attributes": {key: value for key, value in (payload.get("trace_attributes") or {}).items() if key in {"account.id", "scope.id"} and isinstance(value, str) and len(value) <= 128} if isinstance(payload.get("trace_attributes"), dict) else {}, } self._verify_workspace_capability(values) policy = payload.get("analysis_policy") if policy is not None: try: parsed = parse_analysis_policy(policy) except AnalysisPolicyError as exc: raise ValueError(exc.code) from exc if parsed.model != self.model or parsed.variant != "max": raise ValueError("agent_analysis_policy_invalid") if self.release_profile in {"production", "public", "1", "true", "yes", "on"} and not self.model_broker_configured: raise ValueError("model_broker_endpoint_invalid") if model_capability is not None and not self.model_broker_configured: raise ValueError("model_broker_endpoint_invalid") values["analysis_policy"] = parsed.model_dump(mode="json") layout = self._workspace_layout(selector=workspace_selector if isinstance(workspace_selector, str) else None, business_task_id=business_task_id) image = layout.images_root / relative try: current = layout.images_root for part in relative.parts: current = current / part info = current.lstat() if stat.S_ISLNK(info.st_mode): raise ValueError("agent_image_path_forbidden") info = image.lstat() if not stat.S_ISREG(info.st_mode): raise ValueError("agent_image_path_forbidden") except (OSError, ValueError) as exc: if isinstance(exc, ValueError) and str(exc) == "agent_image_path_forbidden": raise raise ValueError("agent_image_path_forbidden") from exc values["workspace_layout"] = layout return values def _resume_source(self, values: dict[str, object]) -> TaskState | None: """查找与续跑 session 完全绑定的失败 attempt,拒绝跨任务猜测。""" session_id = values.get("session_id") if not isinstance(session_id, str): return None business_task_id = values["business_task_id"] resume_of = values.get("resume_of_attempt_id") matches = [ task for task in self.tasks.values() if task.session_id == session_id and task.business_task_id == business_task_id and task.image_relative_path == values["image_relative_path"] and task.reverse_image_policy == values["reverse_image_policy"] and task.processing_config_hash == values.get("processing_config_hash") and task.visual_snapshot_sha256 == values.get("visual_snapshot_sha256") and task.workspace_selector == (values.get("workspace_selector") or "local") and (resume_of is None or task.executor_attempt_id == resume_of) ] if not matches: persisted = self._persisted_resume_source(values) if persisted is not None: return persisted # 同一 session 若属于其它业务任务,明确返回绑定错误;完全未知的 # session 则返回不可续跑,避免把最近会话当作恢复目标。 if any(task.session_id == session_id for task in self.tasks.values()): raise RuntimeError("session_binding_mismatch") raise RuntimeError("session_not_resumable") source = max(matches, key=lambda task: (task.completed_at or task.created_at, task.created_at)) if source.status != "failed": raise RuntimeError("session_not_resumable") if source.process_reaped is not True or not source.done.is_set(): raise RuntimeError("session_not_resumable") source_error = (source.error or {}).get("error") if source_error not in { "service_shutdown", "agent_provider_rate_limited", "agent_provider_server_error", "agent_connection_interrupted", "agent_process_failed", }: raise RuntimeError("session_not_resumable") return source def submit(self, payload: object) -> tuple[TaskState, bool]: """创建固定研究任务并交给受限线程池,返回状态和同步等待标记。""" values = self._validate_request(payload) with self.lock: self._prune_history_locked() if not self.health().get("ready"): raise RuntimeError("agent_runtime_unavailable") if not self.model_configured: raise RuntimeError("opencode_not_configured") existing = self.tasks.get(values["executor_attempt_id"]) if existing is not None: raise RuntimeError("task_exists") same_business = [ task for task in self.tasks.values() if task.business_task_id == values["business_task_id"] and task.executor_attempt_id != values["executor_attempt_id"] and (task.status in {"queued", "running"} or task.process_reaped is not True) ] if same_business: # 同一业务 Task 只能有一个活动 attempt;未确认回收的旧进程更严格地 # 收束为 unknown_execution,避免其晚到结果污染新 attempt。 if not any(task.status in {"queued", "running"} for task in same_business) and any(task.process_reaped is not True for task in same_business): raise RuntimeError("unknown_execution") raise RuntimeError("task_exists") source = self._resume_source(values) if values.get("session_id") else None if source is not None and source.analysis_policy != values.get("analysis_policy"): raise RuntimeError("agent_analysis_policy_invalid") task = TaskState( task_id=str(values["business_task_id"]), business_task_id=str(values["business_task_id"]), executor_attempt_id=str(values["executor_attempt_id"]), image_relative_path=str(values["image_relative_path"]), reverse_image_policy=str(values["reverse_image_policy"]), timeout_seconds=int(values["timeout_seconds"]), processing_config_hash=values.get("processing_config_hash") if isinstance(values.get("processing_config_hash"), str) else None, visual_snapshot_sha256=values.get("visual_snapshot_sha256") if isinstance(values.get("visual_snapshot_sha256"), str) else None, session_id=values.get("session_id") if isinstance(values.get("session_id"), str) else None, workspace_selector=str(values.get("workspace_selector") or "local"), workspace_capability=values.get("workspace_capability") if isinstance(values.get("workspace_capability"), str) else None, model_capability=values.get(MODEL_CAPABILITY_FIELD) if isinstance(values.get(MODEL_CAPABILITY_FIELD), str) else None, workspace_directory=(values.get("workspace_layout").directory if isinstance(values.get("workspace_layout"), WorkspaceLayout) else None), images_root=(values.get("workspace_layout").images_root if isinstance(values.get("workspace_layout"), WorkspaceLayout) else None), metadata_root=(values.get("workspace_layout").metadata_root if isinstance(values.get("workspace_layout"), WorkspaceLayout) else None), skill_root=(values.get("workspace_layout").skill_root if isinstance(values.get("workspace_layout"), WorkspaceLayout) else None), candidate_root=(values.get("workspace_layout").candidate_root if isinstance(values.get("workspace_layout"), WorkspaceLayout) else None), task_scratch_root=(values.get("workspace_layout").task_scratch_root if isinstance(values.get("workspace_layout"), WorkspaceLayout) else None), config_file=(values.get("workspace_layout").config_file if isinstance(values.get("workspace_layout"), WorkspaceLayout) else None), config_dir=(values.get("workspace_layout").config_dir if isinstance(values.get("workspace_layout"), WorkspaceLayout) else None), resume_of_attempt_id=source.executor_attempt_id if source else None, is_resume=source is not None, callback_token=values.get("callback_token") if isinstance(values.get("callback_token"), str) else None, result_path=f"task-results/{values['business_task_id']}/{RESULT_FILE_NAME}", analysis_policy=values.get("analysis_policy"), observed_cost=source.observed_cost if source else None, trace_context=values["trace_context"], trace_attributes=values["trace_attributes"], ) self.tasks[task.executor_attempt_id] = task self.futures[task.executor_attempt_id] = self.pool.submit(self._run, task) return task, bool(values["wait"]) @staticmethod def _workspace_permission_rules(task: TaskState) -> dict[str, str]: """生成 OpenCode 外部目录的最后匹配优先规则。""" images = task.images_root or IMAGE_ROOT metadata = task.metadata_root or IMAGE_ROOT skills = task.skill_root or SKILL_ROOT scratch = task.task_scratch_root or (WORKSPACE / "tasks" / task.business_task_id) candidate_root = task.candidate_root or (RUNTIME_ROOT / "candidates" / task.business_task_id) result_dir = RESULT_ROOT / task.business_task_id subtree = lambda path: f"{Path(os.path.abspath(path)).as_posix()}/**" return { "*": "deny", subtree(skills): "allow", subtree(images): "allow", subtree(metadata): "allow", subtree(candidate_root): "allow", subtree(scratch): "allow", # 外部文件工具先检查结果目录的 ``