Files
MemeMeow/backend/opencode_workspace.py
2026-09-24 11:20:49 +08:00

767 lines
38 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""OpenCode workspace 解析、目录安全校验和 capability 契约。
该模块位于任务控制面与 OpenCodeRunner 之间,只消费已经通过任务 claim
恢复的可信上下文。它不读取普通请求字段,也不把 scope 或物理路径暴露给
executor 请求;宿主适配层通过 provider 提供 opaque selector 对应的目录视图。
"""
from __future__ import annotations
import base64
import binascii
import hashlib
import hmac
import json
import os
import re
import stat
import time
from dataclasses import dataclass, field, replace
from pathlib import Path, PurePosixPath
from typing import Callable, Mapping, Protocol
try:
from backend.visual_snapshot import VisualMatchSnapshotError, validate_visual_match_snapshot
except ModuleNotFoundError as exc: # pragma: no cover - Agent 镜像只复制无状态协议快照
if exc.name != "backend":
raise
from executor.visual_snapshot import VisualMatchSnapshotError, validate_visual_match_snapshot
SELECTOR_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}\Z")
TASK_ID_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}\Z")
ATTEMPT_ID_RE = TASK_ID_RE
SESSION_ID_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,254}\Z")
CAPABILITY_AUDIENCE = "mememeow-agent-executor"
CAPABILITY_VERSION = 1
class WorkspaceResolutionError(RuntimeError):
"""workspace provider 无法安全解析可信上下文时使用的稳定错误。"""
def __init__(self, code: str, message: str | None = None) -> None:
super().__init__(message or code)
self.code = code
class WorkspaceCapabilityError(WorkspaceResolutionError):
"""workspace capability 缺失、签名无效或绑定不一致时使用的错误。"""
@dataclass(frozen=True)
class TrustedWorkspaceContext:
"""一次已完成 claim 校验的 workspace 解析输入。
``scope_id``、``task_id`` 和 ``attempt_id`` 只能由后台 Worker 或宿主
provider 装配;普通 API payload 不得直接构造并传入该对象。``selector``
仅用于重试时核对持久 attempt 事实,不能覆盖 provider 的可信映射。
"""
task_id: str
attempt_id: str
scope_id: str
selector: str | None = None
session_id: str | None = None
resume_of_attempt_id: str | None = None
image_relative_path: str | None = None
visual_match_snapshot: Mapping[str, object] | None = None
def __post_init__(self) -> None:
"""校验内部任务标识和恢复绑定,避免 provider 接收任意路径值。"""
if not TASK_ID_RE.fullmatch(self.task_id) or not ATTEMPT_ID_RE.fullmatch(self.attempt_id):
raise WorkspaceResolutionError("opencode_workspace_mismatch", "任务或 attempt 标识无效")
if not isinstance(self.scope_id, str) or not self.scope_id.strip():
raise WorkspaceResolutionError("opencode_workspace_mismatch", "任务 scope 无效")
if self.selector is not None and not SELECTOR_RE.fullmatch(self.selector):
raise WorkspaceResolutionError("opencode_workspace_mismatch", "workspace selector 无效")
if self.session_id is not None and not SESSION_ID_RE.fullmatch(self.session_id):
raise WorkspaceResolutionError("opencode_workspace_mismatch", "session 标识无效")
if self.resume_of_attempt_id is not None and not ATTEMPT_ID_RE.fullmatch(self.resume_of_attempt_id):
raise WorkspaceResolutionError("opencode_workspace_mismatch", "恢复来源 attempt 无效")
if self.image_relative_path is not None:
_validate_relative_path(self.image_relative_path, code="opencode_workspace_mismatch")
if self.visual_match_snapshot is not None:
try:
snapshot = validate_visual_match_snapshot(self.visual_match_snapshot)
except VisualMatchSnapshotError as exc:
raise WorkspaceResolutionError("visual_match_snapshot_invalid", "视觉候选 snapshot 无效") from exc
object.__setattr__(self, "visual_match_snapshot", snapshot)
@dataclass(frozen=True)
class ResolvedWorkspace:
"""一次 OpenCode 执行使用的不可变目录描述。
``directory`` 同时是 CLI ``--dir`` 和子进程 ``cwd``;``config_file`` 和
``config_dir`` 位于当前 Task 临时目录,以便并发任务拥有各自的精确权限规则;
``candidate_root`` 是当前 Task 的只读视觉候选视图;``db_path`` 在同一 runtime
内对所有 selector 固定不变。其余根目录由 provider 装配,runner 只允许用它们
解析当前图片、候选、临时数据和结果文件。
"""
selector: str
directory: Path
config_file: Path
config_dir: Path
images_root: Path
metadata_root: Path
skill_root: Path
candidate_root: Path
task_scratch_root: Path
task_results_root: Path
db_path: Path
local: bool = False
_permission_rules: tuple[tuple[str, str], ...] = field(default_factory=tuple, repr=False)
@property
def result_path(self) -> Path:
"""返回当前 Task 的受控最终结果路径。"""
return self.task_results_root / "result.json.tmp"
@property
def draft_path(self) -> Path:
"""返回当前 Task 的受控草稿路径。"""
return self.task_results_root / "result.json.draft"
@property
def permission_rules(self) -> tuple[tuple[str, str], ...]:
"""返回按 OpenCode 最后匹配优先顺序生成的外部目录规则。"""
return self._permission_rules
def image_path(self, relative: str) -> Path:
"""将受信任务的相对图片路径解析到当前 workspace 只读视图。"""
return _safe_child(self.images_root, _validate_relative_path(relative, code="agent_image_path_forbidden"), file_required=True, code="agent_image_path_forbidden")
def metadata_path(self, relative: str) -> Path:
"""将 metadata 相对路径解析到当前 workspace 只读视图。"""
return _safe_child(self.metadata_root, _validate_relative_path(relative, code="opencode_workspace_invalid"), file_required=False, code="opencode_workspace_invalid")
def candidate_path(self, relative: str) -> Path:
"""将 snapshot 中的候选相对路径解析到当前 Task 只读视图。"""
return _safe_child(self.candidate_root, _validate_relative_path(relative, code="opencode_workspace_invalid"), file_required=True, code="opencode_workspace_invalid")
@property
def candidate_manifest_path(self) -> Path:
"""返回当前 Task 固定的只读候选 manifest 路径。"""
return self.candidate_root / "manifest.json"
class WorkspaceProvider(Protocol):
"""可信任务上下文到受控 OpenCode workspace 的最小 provider 协议。"""
def resolve(self, context: TrustedWorkspaceContext) -> ResolvedWorkspace:
"""解析一次任务 workspace;失败时不得启动任何 OpenCode 子进程。"""
class CandidateMaterializer(Protocol):
"""可选的 task-scoped 候选物化扩展点。"""
def prepare_candidates(self, context: TrustedWorkspaceContext, resolved: ResolvedWorkspace) -> None:
"""把可信 snapshot 物化为 resolved.candidate_root 下的只读视图。"""
class MissingWorkspaceProvider:
"""non-local 未装配 provider 时的 fail-closed 占位实现。"""
def resolve(self, context: TrustedWorkspaceContext) -> ResolvedWorkspace:
"""拒绝所有任务,避免调用方退回 local workspace。"""
del context
raise WorkspaceResolutionError("opencode_workspace_provider_missing", "未配置 workspace provider")
class CapabilityProvider(Protocol):
"""可选的 workspace capability 签发协议。"""
def capability(self, context: TrustedWorkspaceContext, resolved: ResolvedWorkspace) -> str | None:
"""为当前 attempt 返回绑定 selector 的短期 capability。"""
def _validate_relative_path(value: str, *, code: str) -> PurePosixPath:
"""限制图片和 metadata 的 POSIX 相对路径,拒绝绝对路径和父级跳转。"""
if not isinstance(value, str) or not value or len(value) > 512 or "\\" in value or re.match(r"^[A-Za-z]:", value):
raise WorkspaceResolutionError(code, "相对路径无效")
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 WorkspaceResolutionError(code, "相对路径无效")
path = PurePosixPath(value)
if path.is_absolute() or not path.parts:
raise WorkspaceResolutionError(code, "相对路径无效")
return path
def _path_parts(path: Path) -> list[Path]:
"""返回从根到目标的完整路径节点,供逐级 lstat 校验。"""
absolute = Path(os.path.abspath(path))
current = Path(absolute.anchor)
result: list[Path] = [current]
for part in absolute.parts[1:]:
current = current / part
result.append(current)
return result
def validate_directory_path(path: Path, *, create: bool = False, allow_missing_leaf: bool = False, code: str = "opencode_workspace_invalid") -> Path:
"""逐级 lstat 校验目录 containment、节点类型和符号链接。
``create`` 只允许创建缺失目录;任何已经存在的符号链接、普通文件或
其它特殊节点都会稳定失败。该检查在子进程和结果目录副作用之前执行。
"""
path = Path(os.path.abspath(path))
nodes = _path_parts(path)
for index, node in enumerate(nodes):
try:
info = node.lstat()
except FileNotFoundError:
if not create:
if allow_missing_leaf and index == len(nodes) - 1:
return path
raise WorkspaceResolutionError(code, "workspace 目录不存在") from None
try:
node.mkdir()
except FileExistsError:
try:
info = node.lstat()
except OSError as exc:
raise WorkspaceResolutionError(code, "workspace 目录无法检查") from exc
except OSError as exc:
raise WorkspaceResolutionError(code, "workspace 目录无法创建") from exc
else:
continue
except OSError as exc:
raise WorkspaceResolutionError(code, "workspace 目录无法检查") from exc
if stat.S_ISLNK(info.st_mode) or not stat.S_ISDIR(info.st_mode):
raise WorkspaceResolutionError(code, "workspace 路径包含符号链接或非目录节点")
return path
def validate_file_path(path: Path, *, allow_missing: bool = False, code: str = "opencode_workspace_invalid") -> Path:
"""逐级 lstat 校验普通文件路径,拒绝符号链接和特殊节点。"""
path = Path(os.path.abspath(path))
validate_directory_path(path.parent, code=code)
try:
info = path.lstat()
except FileNotFoundError:
if allow_missing:
return path
raise WorkspaceResolutionError(code, "workspace 文件不存在") from None
except OSError as exc:
raise WorkspaceResolutionError(code, "workspace 文件无法检查") from exc
if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode):
raise WorkspaceResolutionError(code, "workspace 文件包含符号链接或非普通文件")
return path
def _safe_child(root: Path, relative: PurePosixPath, *, file_required: bool, code: str) -> Path:
"""在已校验 root 下解析相对路径并重复执行节点类型检查。"""
validate_directory_path(root, code=code)
candidate = root.joinpath(*relative.parts)
root_abs = Path(os.path.abspath(root))
candidate_abs = Path(os.path.abspath(candidate))
try:
candidate_abs.relative_to(root_abs)
except ValueError as exc:
raise WorkspaceResolutionError(code, "路径超出 workspace 根") from exc
parent = candidate_abs.parent
validate_directory_path(parent, code=code)
try:
info = candidate_abs.lstat()
except FileNotFoundError as exc:
raise WorkspaceResolutionError(code, "文件不存在") from exc
except OSError as exc:
raise WorkspaceResolutionError(code, "文件无法检查") from exc
if stat.S_ISLNK(info.st_mode) or (file_required and not stat.S_ISREG(info.st_mode)):
raise WorkspaceResolutionError(code, "文件包含符号链接或节点类型无效")
if not file_required and not (stat.S_ISREG(info.st_mode) or stat.S_ISDIR(info.st_mode)):
raise WorkspaceResolutionError(code, "文件节点类型无效")
return candidate_abs
def _containment(path: Path, root: Path, *, code: str) -> None:
"""确认 path 位于 root 内且两者所有祖先都不是符号链接。"""
path_abs = Path(os.path.abspath(path))
root_abs = Path(os.path.abspath(root))
try:
path_abs.relative_to(root_abs)
except ValueError as exc:
raise WorkspaceResolutionError(code, "workspace 路径逃出配置根") from exc
validate_directory_path(root_abs, code=code)
validate_directory_path(path_abs, create=True, code=code)
def build_external_directory_rules(workspace: ResolvedWorkspace) -> tuple[tuple[str, str], ...]:
"""生成 catch-all deny 后的精确只读输入和受控写入规则。"""
def directory(path: Path) -> str:
"""返回 OpenCode 外部目录自身的绝对路径规则。"""
return Path(os.path.abspath(path)).as_posix()
def subtree(path: Path) -> str:
"""返回 OpenCode 外部目录 glob,保持绝对路径来自 provider。"""
return f"{directory(path)}/**"
result_directory = directory(workspace.task_results_root)
return (
("*", "deny"),
(directory(workspace.skill_root), "allow"),
(subtree(workspace.skill_root), "allow"),
(directory(workspace.images_root), "allow"),
(subtree(workspace.images_root), "allow"),
(directory(workspace.metadata_root), "allow"),
(subtree(workspace.metadata_root), "allow"),
(directory(workspace.candidate_root), "allow"),
(subtree(workspace.candidate_root), "allow"),
(directory(workspace.task_scratch_root), "allow"),
(subtree(workspace.task_scratch_root), "allow"),
(directory(workspace.task_results_root), "allow"),
# OpenCode 对外部文件先请求其父目录 ``<dir>/*``;仅写两个精确文件
# 规则无法允许首次创建,因此这里只放行当前 Task 结果目录的父级检查,
# 实际可写文件由 ``edit`` 规则继续收窄。
(f"{result_directory}/*", "allow"),
(Path(os.path.abspath(workspace.draft_path)).as_posix(), "allow"),
(Path(os.path.abspath(workspace.result_path)).as_posix(), "allow"),
)
def build_edit_permission_rules(
*,
task_scratch_root: Path,
config_file: Path,
config_dir: Path,
draft_path: Path,
result_path: Path,
candidate_root: Path | None = None,
) -> tuple[tuple[str, str], ...]:
"""限制 OpenCode ``edit``、``write`` 和 ``apply_patch`` 的可写路径。
``external_directory`` 只有 allow/deny 目录边界,不能表达只读输入;
OpenCode 将三个编辑工具统一映射为 ``edit`` 权限,因此这里默认拒绝,
仅开放当前 Task 临时目录和两个结果文件,并再次保护服务端生成的配置。
规则同时生成绝对、根相对和 workspace 相对形式,兼容 OpenCode 在无 Git
workspace 下把编辑路径相对化到 ``/`` 的行为。
"""
def pattern(path: Path) -> str:
"""把 provider 路径转换为不依赖 worktree 根的权限模式。"""
absolute = Path(os.path.abspath(path)).as_posix().lstrip("/")
return f"**/{absolute}"
workspace_directory = Path(os.path.abspath(task_scratch_root)).parent.parent
def patterns(path: Path) -> tuple[str, ...]:
"""返回 OpenCode 可能使用的绝对、根相对和 workspace 相对模式。"""
absolute = Path(os.path.abspath(path)).as_posix()
values = [absolute, absolute.lstrip("/"), pattern(path)]
try:
values.append(Path(path).resolve().relative_to(workspace_directory).as_posix())
except (OSError, ValueError):
pass
return tuple(dict.fromkeys(values))
def subtree(path: Path) -> tuple[str, ...]:
"""返回当前目录及其后代的全部编辑权限模式。"""
return tuple(f"{value}/**" for value in patterns(path))
def exact(path: Path) -> tuple[str, ...]:
"""返回一个文件的全部编辑权限模式。"""
return patterns(path)
rules: list[tuple[str, str]] = [("*", "deny")]
rules.extend((value, "allow") for value in subtree(task_scratch_root))
rules.extend((value, "allow") for value in exact(draft_path))
rules.extend((value, "allow") for value in exact(result_path))
if candidate_root is not None:
# 候选由后端物化,Agent 只能读取,任何编辑工具都必须拒绝。
rules.extend((value, "deny") for value in exact(candidate_root))
rules.extend((value, "deny") for value in subtree(candidate_root))
# 配置虽然位于 Task 临时目录,但必须保持由服务端原子生成,不能被
# Agent 通过普通文件工具改写后影响后续 OpenCode 调用。
rules.extend((value, "deny") for value in exact(config_file))
rules.extend((value, "deny") for value in subtree(config_dir))
return tuple(rules)
def capability_claims(*, task_id: str, attempt_id: str, selector: str, exp: int, session_id: str | None = None, resume_of_attempt_id: str | None = None, audience: str = CAPABILITY_AUDIENCE) -> dict[str, object]:
"""构造 workspace capability 的固定 claims 集合。"""
if not TASK_ID_RE.fullmatch(task_id) or not ATTEMPT_ID_RE.fullmatch(attempt_id) or not SELECTOR_RE.fullmatch(selector):
raise WorkspaceCapabilityError("opencode_workspace_capability_invalid", "capability 绑定标识无效")
if not isinstance(exp, int) or isinstance(exp, bool) or exp <= 0:
raise WorkspaceCapabilityError("opencode_workspace_capability_invalid", "capability 期限无效")
if audience != CAPABILITY_AUDIENCE:
raise WorkspaceCapabilityError("opencode_workspace_capability_invalid", "capability 受众无效")
if (session_id is None) != (resume_of_attempt_id is None):
raise WorkspaceCapabilityError("opencode_workspace_capability_invalid", "capability 恢复绑定不完整")
claims: dict[str, object] = {
"v": CAPABILITY_VERSION,
"task_id": task_id,
"attempt_id": attempt_id,
"workspace_selector": selector,
"audience": audience,
"exp": exp,
}
if session_id is not None:
if not SESSION_ID_RE.fullmatch(session_id):
raise WorkspaceCapabilityError("opencode_workspace_capability_invalid", "capability session 无效")
claims["session_id"] = session_id
if resume_of_attempt_id is not None:
if not ATTEMPT_ID_RE.fullmatch(resume_of_attempt_id):
raise WorkspaceCapabilityError("opencode_workspace_capability_invalid", "capability 恢复来源无效")
claims["resume_of_attempt_id"] = resume_of_attempt_id
return claims
def _b64(value: bytes) -> str:
"""将 capability 片段编码为无填充 URL-safe Base64。"""
return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=")
def _unb64(value: str) -> bytes:
"""解码无填充 URL-safe Base64,格式错误统一失败。"""
if not isinstance(value, str) or not value or not re.fullmatch(r"[A-Za-z0-9_-]+", value):
raise ValueError("base64_invalid")
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
class WorkspaceCapabilitySigner:
"""用独立服务密钥签发和验证短期 workspace capability。"""
def __init__(self, secret: str | bytes | None, *, ttl_seconds: int = 300, clock: Callable[[], float] | None = None) -> None:
"""保存 capability 专用密钥;不会复用 provider/API 长期模型 key。"""
if isinstance(secret, str):
secret_bytes = secret.encode("utf-8")
else:
secret_bytes = secret
self.secret = secret_bytes or b""
self.ttl_seconds = max(1, min(int(ttl_seconds), 3600))
self.clock = clock or time.time
@property
def configured(self) -> bool:
"""返回是否配置了非空签名材料。"""
return bool(self.secret)
def sign(self, claims: Mapping[str, object]) -> str:
"""签发 canonical JSON capability,调用者必须提供已绑定 claims。"""
if not self.configured:
raise WorkspaceCapabilityError("opencode_workspace_capability_unavailable", "未配置 workspace capability 验证材料")
value = dict(claims)
try:
validated = capability_claims(
task_id=value["task_id"],
attempt_id=value["attempt_id"],
selector=value["workspace_selector"],
exp=value["exp"],
session_id=value.get("session_id"),
resume_of_attempt_id=value.get("resume_of_attempt_id"),
audience=value["audience"],
)
except (KeyError, TypeError, WorkspaceCapabilityError):
raise WorkspaceCapabilityError("opencode_workspace_capability_invalid", "capability claims 不完整或无效") from None
# capability 是固定协议;拒绝额外 claims,避免签发方和验证方对未定义字段产生
# 不一致解释,也避免把任意业务数据带入可转发的 token。
if set(value) != set(validated):
raise WorkspaceCapabilityError("opencode_workspace_capability_invalid", "capability claims 包含未知字段")
# ``sign`` 是公开的低层签发入口,不能只依赖 ``issue`` 的 TTL 计算;否则
# 任意内部调用方都可以把同一签名材料用于多年有效的 capability。
now = int(self.clock())
expiry = validated["exp"]
if not isinstance(expiry, int) or isinstance(expiry, bool) or expiry <= now or expiry > now + self.ttl_seconds:
raise WorkspaceCapabilityError("opencode_workspace_capability_invalid", "capability 期限超出签发窗口")
value = validated
raw = _b64(json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8"))
signature = _b64(hmac.new(self.secret, raw.encode("ascii"), hashlib.sha256).digest())
return f"v1.{raw}.{signature}"
def issue(self, *, task_id: str, attempt_id: str, selector: str, session_id: str | None = None, resume_of_attempt_id: str | None = None) -> str:
"""按当前时间签发带业务、attempt、selector 和恢复绑定的 capability。"""
claims = capability_claims(
task_id=task_id,
attempt_id=attempt_id,
selector=selector,
exp=int(self.clock()) + self.ttl_seconds,
session_id=session_id,
resume_of_attempt_id=resume_of_attempt_id,
)
return self.sign(claims)
def verify(self, token: str, *, task_id: str, attempt_id: str, selector: str, session_id: str | None = None, resume_of_attempt_id: str | None = None, now: int | None = None) -> dict[str, object]:
"""验证签名、受众、期限以及当前 Task/attempt/session/selector 绑定。"""
if not self.configured:
raise WorkspaceCapabilityError("opencode_workspace_capability_unavailable", "未配置 workspace capability 验证材料")
if (session_id is None) != (resume_of_attempt_id is None):
raise WorkspaceCapabilityError("opencode_workspace_capability_invalid", "当前恢复绑定不完整")
if not isinstance(token, str) or not token or len(token) > 4096:
raise WorkspaceCapabilityError("opencode_workspace_capability_invalid", "workspace capability 无效")
try:
version, encoded, supplied = token.split(".")
if version != "v1":
raise ValueError("version_invalid")
expected = _b64(hmac.new(self.secret, encoded.encode("ascii"), hashlib.sha256).digest())
if not hmac.compare_digest(supplied, expected):
raise ValueError("signature_invalid")
claims = json.loads(_unb64(encoded).decode("utf-8"))
except (AttributeError, TypeError, ValueError, UnicodeDecodeError, json.JSONDecodeError, binascii.Error):
raise WorkspaceCapabilityError("opencode_workspace_capability_invalid", "workspace capability 无效") from None
if not isinstance(claims, dict):
raise WorkspaceCapabilityError("opencode_workspace_capability_invalid", "workspace capability claims 无效")
required = {"v", "task_id", "attempt_id", "workspace_selector", "audience", "exp"}
if not required.issubset(claims) or claims.get("v") != CAPABILITY_VERSION or claims.get("audience") != CAPABILITY_AUDIENCE:
raise WorkspaceCapabilityError("opencode_workspace_capability_invalid", "workspace capability claims 不完整")
allowed_claims = required | {"session_id", "resume_of_attempt_id"}
if set(claims) - allowed_claims:
raise WorkspaceCapabilityError("opencode_workspace_capability_invalid", "workspace capability 包含未知字段")
if (
not isinstance(claims.get("task_id"), str)
or not TASK_ID_RE.fullmatch(str(claims["task_id"]))
or not isinstance(claims.get("attempt_id"), str)
or not ATTEMPT_ID_RE.fullmatch(str(claims["attempt_id"]))
or not isinstance(claims.get("workspace_selector"), str)
or not SELECTOR_RE.fullmatch(str(claims["workspace_selector"]))
):
raise WorkspaceCapabilityError("opencode_workspace_capability_invalid", "workspace capability 标识无效")
expiry = claims.get("exp")
current = int(self.clock()) if now is None else int(now)
if not isinstance(expiry, int) or isinstance(expiry, bool) or expiry <= current:
raise WorkspaceCapabilityError("opencode_workspace_capability_expired", "workspace capability 已过期")
expected_values = {
"task_id": task_id,
"attempt_id": attempt_id,
"workspace_selector": selector,
}
if session_id is not None:
expected_values["session_id"] = session_id
if resume_of_attempt_id is not None:
expected_values["resume_of_attempt_id"] = resume_of_attempt_id
for key, expected_value in expected_values.items():
if claims.get(key) != expected_value:
raise WorkspaceCapabilityError("opencode_workspace_mismatch", "workspace capability 绑定不一致")
if session_id is None and "session_id" in claims:
raise WorkspaceCapabilityError("opencode_workspace_mismatch", "workspace capability 包含非当前 session")
if resume_of_attempt_id is None and "resume_of_attempt_id" in claims:
raise WorkspaceCapabilityError("opencode_workspace_mismatch", "workspace capability 包含非当前恢复来源")
return {str(key): value for key, value in claims.items()}
class _BaseProvider:
"""共享 provider 的目录、selector 和 capability 辅助逻辑。"""
def __init__(self, *, runtime_root: Path, signer: WorkspaceCapabilitySigner | None = None, capability_ttl_seconds: int = 300) -> None:
self.runtime_root = Path(runtime_root).expanduser()
self.signer = signer
self.capability_ttl_seconds = capability_ttl_seconds
def capability(self, context: TrustedWorkspaceContext, resolved: ResolvedWorkspace) -> str | None:
"""按已解析 selector 签发当前 attempt 的 workspace capability。"""
if self.signer is None or not self.signer.configured:
return None
return self.signer.issue(
task_id=context.task_id,
attempt_id=context.attempt_id,
selector=resolved.selector,
session_id=context.session_id,
resume_of_attempt_id=context.resume_of_attempt_id,
)
def _resolved(
self,
*,
selector: str,
directory: Path,
images_root: Path,
metadata_root: Path,
skill_root: Path,
task_id: str,
local: bool,
create_workspace: bool = True,
allow_skill_symlink: bool = False,
) -> ResolvedWorkspace:
"""构造并验证一次 workspace 描述,所有根路径必须来自 provider。"""
if not SELECTOR_RE.fullmatch(selector):
raise WorkspaceResolutionError("opencode_workspace_invalid", "workspace selector 无效")
runtime_root = validate_directory_path(self.runtime_root, create=True)
directory = Path(directory)
if create_workspace:
validate_directory_path(directory, create=True)
else:
validate_directory_path(directory)
# 配置按 Task 独立生成;共享 workspace 内同时运行多个任务时,精确结果
# allow 规则不会被另一个任务最后一次写入的配置覆盖。
task_scratch = directory / "tasks" / task_id
config_file = task_scratch / "opencode.json"
config_dir = task_scratch / ".opencode"
candidate_parent = directory.parent / "candidates"
validate_directory_path(candidate_parent, create=True)
candidate_root = candidate_parent / task_id
validate_directory_path(candidate_root, allow_missing_leaf=True)
validate_directory_path(images_root, create=local)
validate_directory_path(metadata_root, create=local)
if allow_skill_symlink and skill_root.is_symlink():
# local 历史 runtime 用相对链接挂载既有 skill;该兼容例外只由
# 显式 LocalWorkspaceProvider 使用,外部 selector 仍拒绝所有链接。
target = skill_root.resolve(strict=True)
validate_directory_path(target)
else:
validate_directory_path(skill_root, create=local)
task_results = runtime_root / "task-results" / task_id
db_path = runtime_root / "opencode.db"
validate_file_path(db_path, allow_missing=True)
result = ResolvedWorkspace(
selector=selector,
directory=directory,
config_file=config_file,
config_dir=config_dir,
images_root=images_root,
metadata_root=metadata_root,
skill_root=skill_root,
candidate_root=candidate_root,
task_scratch_root=task_scratch,
task_results_root=task_results,
db_path=db_path,
local=local,
)
return replace(result, _permission_rules=build_external_directory_rules(result))
class LocalWorkspaceProvider(_BaseProvider):
"""显式 local 单用户 provider,原地复用既有 runtime/workspace 历史。"""
def __init__(self, runtime_root: Path, *, image_root: Path, skill_root: Path | None = None, signer: WorkspaceCapabilitySigner | None = None, candidate_materializer: Callable[[TrustedWorkspaceContext, ResolvedWorkspace], None] | None = None) -> None:
"""保存 local runtime、图片根、Skill 根和候选物化回调。"""
super().__init__(runtime_root=runtime_root, signer=signer)
self.image_root = Path(image_root).expanduser()
self.skill_root = Path(skill_root).expanduser() if skill_root is not None else None
self.candidate_materializer = candidate_materializer
def resolve(self, context: TrustedWorkspaceContext) -> ResolvedWorkspace:
"""将 local scope 映射到旧 ``<runtime>/workspace``,拒绝其它 scope。"""
if context.scope_id != "local":
raise WorkspaceResolutionError("opencode_workspace_provider_missing", "local provider 不能解析非 local scope")
workspace = self.runtime_root / "workspace"
skill = self.skill_root if self.skill_root is not None and self.skill_root.exists() else workspace / ".opencode" / "skills" / "research-meme-context"
return self._resolved(
selector="local",
directory=workspace,
images_root=self.image_root,
metadata_root=self.image_root,
skill_root=skill,
task_id=context.task_id,
local=True,
allow_skill_symlink=True,
)
def prepare_candidates(self, context: TrustedWorkspaceContext, resolved: ResolvedWorkspace) -> None:
"""调用 local 适配层物化当前 snapshot,缺失实现时失败关闭。"""
if context.visual_match_snapshot is None:
return
if not callable(self.candidate_materializer):
raise WorkspaceResolutionError("visual_candidate_materialization_failed", "local workspace 缺少候选物化能力")
try:
self.candidate_materializer(context, resolved)
except WorkspaceResolutionError:
raise
except Exception as exc: # noqa: BLE001 - 物化边界只允许稳定失败
raise WorkspaceResolutionError("visual_candidate_materialization_failed", "视觉候选无法物化") from exc
class DirectoryWorkspaceProvider(_BaseProvider):
"""按受信 scope 映射固定目录布局的通用外部 provider。
目录布局为 ``<workspace_root>/<selector>/{workspace,images,metadata,skills}``;
适配宿主负责准备 scope 视图和生命周期,provider 只允许创建当前 workspace
的配置、Task 临时目录和结果目录。
"""
def __init__(self, runtime_root: Path, workspace_root: Path, *, selector_for_scope: Callable[[str], str] | None = None, signer: WorkspaceCapabilitySigner | None = None, skill_root: Path | None = None) -> None:
"""保存受控配置根和可信 scope-to-selector 映射回调。"""
super().__init__(runtime_root=runtime_root, signer=signer)
self.workspace_root = Path(workspace_root).expanduser()
self.selector_for_scope = selector_for_scope or self._default_selector
self.skill_root = Path(skill_root).expanduser() if skill_root is not None else None
@staticmethod
def _default_selector(scope_id: str) -> str:
"""将可信 scope 转为不可逆、稳定且不含路径语义的 selector。"""
return "scope-" + hashlib.sha256(scope_id.encode("utf-8")).hexdigest()[:32]
def resolve(self, context: TrustedWorkspaceContext) -> ResolvedWorkspace:
"""解析外部 selector,拒绝未知目录、路径穿越和符号链接节点。"""
try:
mapped = self.selector_for_scope(context.scope_id)
except Exception as exc: # noqa: BLE001 - provider 边界统一失败关闭
raise WorkspaceResolutionError("opencode_workspace_invalid", "workspace selector 无法解析") from exc
if not isinstance(mapped, str) or not SELECTOR_RE.fullmatch(mapped):
raise WorkspaceResolutionError("opencode_workspace_invalid", "workspace selector 无效")
if mapped == "local":
# external provider 不能借用旧 local 保留字;executor 会把该值视为
# 无 capability 的兼容路径,混用会绕过非 local 的签名边界。
raise WorkspaceResolutionError("opencode_workspace_invalid", "workspace selector 保留字无效")
if context.selector is not None and context.selector != mapped:
raise WorkspaceResolutionError("opencode_workspace_mismatch", "workspace selector 与持久任务事实不一致")
# 外部 workspace 根和 selector 目录必须由宿主预装配;解析请求不能借机
# 创建新的 scope 目录或把未知 selector 变成可执行路径。
root = validate_directory_path(self.workspace_root, create=False)
base = root / mapped
# selector 目录必须由宿主预装配;未知 selector 不得因为 runner 的 mkdir
# 变成一个新的可执行 workspace。
validate_directory_path(base, code="opencode_workspace_invalid")
skill = self.skill_root or base / "skills"
return self._resolved(
selector=mapped,
directory=base / "workspace",
images_root=base / "images",
metadata_root=base / "metadata",
skill_root=skill,
task_id=context.task_id,
local=False,
create_workspace=False,
)
def capability_for_provider(provider: object, context: TrustedWorkspaceContext, resolved: ResolvedWorkspace) -> str | None:
"""调用 provider 的 capability 签发扩展点,缺失时保持 fail-closed 语义。"""
issue = getattr(provider, "capability", None)
if callable(issue):
try:
value = issue(context, resolved)
except WorkspaceResolutionError:
raise
except Exception as exc: # noqa: BLE001 - capability 边界统一收敛
raise WorkspaceCapabilityError("opencode_workspace_capability_unavailable", "workspace capability 无法签发") from exc
if value is not None and (not isinstance(value, str) or not value):
raise WorkspaceCapabilityError("opencode_workspace_capability_invalid", "workspace capability 格式无效")
return value
return None
def prepare_candidates_for_provider(provider: object, context: TrustedWorkspaceContext, resolved: ResolvedWorkspace) -> None:
"""调用 provider 的候选物化扩展点,失败时统一为稳定错误。"""
prepare = getattr(provider, "prepare_candidates", None)
if not callable(prepare):
return
try:
prepare(context, resolved)
except WorkspaceResolutionError:
raise
except Exception as exc: # noqa: BLE001 - 适配层失败必须阻止外部执行
raise WorkspaceResolutionError("visual_candidate_materialization_failed", "视觉候选无法物化") from exc
__all__ = [
"ATTEMPT_ID_RE",
"CAPABILITY_AUDIENCE",
"DirectoryWorkspaceProvider",
"LocalWorkspaceProvider",
"MissingWorkspaceProvider",
"ResolvedWorkspace",
"SELECTOR_RE",
"TrustedWorkspaceContext",
"WorkspaceCapabilityError",
"WorkspaceCapabilitySigner",
"CandidateMaterializer",
"WorkspaceProvider",
"WorkspaceResolutionError",
"build_edit_permission_rules",
"build_external_directory_rules",
"prepare_candidates_for_provider",
"capability_claims",
"capability_for_provider",
"validate_directory_path",
"validate_file_path",
]