fix: preserve legacy plugin catalogs and protect helper token initialization
This commit is contained in:
1 parent
4dc2057d43
commit
e90da727d4
8 files changed
+81
-18
No files matched your search
@@ -87,7 +87,7 @@ class Settings(BaseSettings):
|
|||||||
"""Centralized ARTEMIS runtime settings loaded from environment and .env files."""
|
"""Centralized ARTEMIS runtime settings loaded from environment and .env files."""
|
||||||
|
|
||||||
ARTEMIS_PLUGIN_MARKETPLACE_URL: str = (
|
ARTEMIS_PLUGIN_MARKETPLACE_URL: str = (
|
||||||
"https://git.playones.com/chenmingxuan/Artemis-Plugins/raw/branch/main/catalog-v3.json"
|
"https://git.playones.com/chenmingxuan/Artemis-Plugins/raw/branch/main/catalog-v4.json"
|
||||||
)
|
)
|
||||||
ARTEMIS_PLUGIN_START_TIMEOUT_SECONDS: float = Field(default=30, gt=0, le=300)
|
ARTEMIS_PLUGIN_START_TIMEOUT_SECONDS: float = Field(default=30, gt=0, le=300)
|
||||||
ARTEMIS_PLUGIN_TOOL_TIMEOUT_SECONDS: float = Field(default=60, gt=0, le=3600)
|
ARTEMIS_PLUGIN_TOOL_TIMEOUT_SECONDS: float = Field(default=60, gt=0, le=3600)
|
||||||
|
|||||||
@@ -44,10 +44,11 @@ def download(url: str, progress: Callable[[str, int], None]) -> bytes:
|
|||||||
|
|
||||||
def load_catalog() -> PluginCatalog:
|
def load_catalog() -> PluginCatalog:
|
||||||
url = settings.ARTEMIS_PLUGIN_MARKETPLACE_URL
|
url = settings.ARTEMIS_PLUGIN_MARKETPLACE_URL
|
||||||
# Older hosts reject schema-v3 entries before checking version constraints.
|
# Strict older hosts cannot parse newer panel kinds, even if incompatible.
|
||||||
# Keep their index intact, and route only the exact former official URL.
|
# Preserve their indexes; upgrade only the exact official saved addresses.
|
||||||
if url == "https://git.playones.com/chenmingxuan/Artemis-Plugins/raw/branch/main/catalog.json":
|
official = "https://git.playones.com/chenmingxuan/Artemis-Plugins/raw/branch/main/"
|
||||||
url = url.removesuffix("catalog.json") + "catalog-v3.json"
|
if url in {official + "catalog.json", official + "catalog-v3.json"}:
|
||||||
|
url = official + "catalog-v4.json"
|
||||||
data = download(url, lambda *_: None)
|
data = download(url, lambda *_: None)
|
||||||
if len(data) > 2 * 1024 * 1024:
|
if len(data) > 2 * 1024 * 1024:
|
||||||
raise ValueError("市场索引过大")
|
raise ValueError("市场索引过大")
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ from typing import Any
|
|||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
|
from filelock import FileLock
|
||||||
|
|
||||||
from artemis.config.constants import ENV_ARTEMIS_HELPER_AUTO_INSTALL
|
from artemis.config.constants import ENV_ARTEMIS_HELPER_AUTO_INSTALL
|
||||||
from artemis.config.paths import get_temp_dir
|
from artemis.config.paths import get_temp_dir
|
||||||
from artemis.runtime.adb_endpoint import adb_command
|
from artemis.runtime.adb_endpoint import adb_command
|
||||||
@@ -323,7 +325,7 @@ class AccessibilityHelperManager:
|
|||||||
if re.fullmatch(r"[0-9a-f]{32,128}", text):
|
if re.fullmatch(r"[0-9a-f]{32,128}", text):
|
||||||
self._token = text
|
self._token = text
|
||||||
return text
|
return text
|
||||||
except OSError:
|
except FileNotFoundError:
|
||||||
pass
|
pass
|
||||||
token = secrets.token_hex(24)
|
token = secrets.token_hex(24)
|
||||||
try:
|
try:
|
||||||
@@ -550,6 +552,15 @@ class AccessibilityHelperManager:
|
|||||||
root.mkdir(parents=True, exist_ok=True)
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
digest = hashlib.sha256(serial.encode("utf-8")).hexdigest()[:16]
|
digest = hashlib.sha256(serial.encode("utf-8")).hexdigest()[:16]
|
||||||
path = root / f"{digest}.mutex"
|
path = root / f"{digest}.mutex"
|
||||||
|
# An age-based lease alone can be stolen from a live, delayed owner.
|
||||||
|
# Retain the legacy lease for coexistence with older Artemis processes,
|
||||||
|
# but serialize current processes with a kernel lock released on exit.
|
||||||
|
with FileLock(path.with_suffix(".guard"), timeout=_PROVISION_MUTEX_TIMEOUT_SECONDS):
|
||||||
|
with self._legacy_provision_mutex(path, serial):
|
||||||
|
yield
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _legacy_provision_mutex(self, path: Path, serial: str):
|
||||||
started = time.monotonic()
|
started = time.monotonic()
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -24,11 +24,14 @@
|
|||||||
- OpenAI Python 更新为 3.40.0,配套 LangChain 1.4.7、langchain-core 1.7.0、langchain-openai 1.7.3;httpx 保持兼容的 0.28.1。Python 与前端安全依赖更新写入锁文件。
|
- OpenAI Python 更新为 3.40.0,配套 LangChain 1.4.7、langchain-core 1.7.0、langchain-openai 1.7.3;httpx 保持兼容的 0.28.1。Python 与前端安全依赖更新写入锁文件。
|
||||||
- 新增仅环境配置 `OPENAI_USE_SYSTEM_PROXY=false`,模型请求默认不采用系统代理。需要代理时在 `.env` 设为 `true` 并重启;同步、异步请求和 OpenAI 连接检查一致,TLS 与自定义 CA 校验继续保留。
|
- 新增仅环境配置 `OPENAI_USE_SYSTEM_PROXY=false`,模型请求默认不采用系统代理。需要代理时在 `.env` 设为 `true` 并重启;同步、异步请求和 OpenAI 连接检查一致,TLS 与自定义 CA 校验继续保留。
|
||||||
- 修复 Windows 临时目录 awake 锁访问失败及所有权恢复,检查同类资源锁和运行时收尾。持久权限错误仍保留实际原因,不跳过互斥保护。
|
- 修复 Windows 临时目录 awake 锁访问失败及所有权恢复,检查同类资源锁和运行时收尾。持久权限错误仍保留实际原因,不跳过互斥保护。
|
||||||
|
- Helper 初始化增加操作系统互斥锁,防止仍在执行的操作仅因锁文件超龄而被抢占;已有宿主令牌无法读取时保留原文件和错误,不生成另一份令牌覆盖它。
|
||||||
- 规划器明确区分规划阶段与执行器的插件能力,避免因本阶段没有动作工具而误判插件不可用;批次模型失败保留明确诊断。
|
- 规划器明确区分规划阶段与执行器的插件能力,避免因本阶段没有动作工具而误判插件不可用;批次模型失败保留明确诊断。
|
||||||
|
|
||||||
|
2.4.0 使用独立的 `catalog-v4.json` 市场索引;保留 2.3.x 与 2.2.x 的旧索引,防止旧宿主因新面板类型无法解析而导致市场加载失败。已有官方地址自动路由,自定义地址保持原样。
|
||||||
|
|
||||||
## 验证范围
|
## 验证范围
|
||||||
|
|
||||||
本轮开发默认后端测试 **3263 通过、2 跳过、8 按标记排除**,前端 **347 项通过**;生产构建、Ruff 格式与静态检查、质量基线和核心类型检查通过。Windows 浏览器隔离夹具覆盖深浅色、宽窄布局、视频视图面板、自动保存与切换,以及长 Payload 折叠。未写入真实任务人工评审。
|
发布前最终默认后端测试 **3267 通过、2 跳过、8 按标记排除**,前端 **347 项通过**;生产构建、Ruff 格式与静态检查、质量基线和核心类型检查通过。Windows 浏览器隔离夹具覆盖深浅色、宽窄布局、视频视图面板、自动保存与切换,以及长 Payload 折叠。未写入真实任务人工评审。
|
||||||
|
|
||||||
此前用户重启后的实际复测中,5 项正式用例通过 AI 验收,另有 1 项电脑路径探索通过,覆盖安卓、电脑与跨端;该样本没有发现 awake 锁访问失败。此结果不等于重跑原 192 项业务批次,未完成大批次压力及 macOS/Linux 实测。本次发版不构建 Docker,不重启用户服务。
|
此前用户重启后的实际复测中,5 项正式用例通过 AI 验收,另有 1 项电脑路径探索通过,覆盖安卓、电脑与跨端;该样本没有发现 awake 锁访问失败。此结果不等于重跑原 192 项业务批次,未完成大批次压力及 macOS/Linux 实测。本次发版不构建 Docker,不重启用户服务。
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,6 @@ GET 成功返回完整贡献快照;PUT 成功返回同样结构和新 revision
|
|||||||
|
|
||||||
人工结果只能由用户填写;AI 不自行调用 PUT 生成或推断评审。此接口未加入模型 MCP 工具或远程 Python SDK 的人工写入方法。插件没有设备目标时,无需为安装验证提交安卓/PC 测试任务;用隔离终态示例验证保存、清空、重装、导出与旧缓存失效即可。
|
人工结果只能由用户填写;AI 不自行调用 PUT 生成或推断评审。此接口未加入模型 MCP 工具或远程 Python SDK 的人工写入方法。插件没有设备目标时,无需为安装验证提交安卓/PC 测试任务;用隔离终态示例验证保存、清空、重装、导出与旧缓存失效即可。
|
||||||
|
|
||||||
市场发布时保持清单、ZIP、版本标签与 catalog-v3.json 内嵌清单一致;先上传不可变 ZIP 与 SHA256SUMS.txt 并回读验证,再更新索引。仅静态清单校验通过不能宣称全部平台已实测。
|
市场发布时保持清单、ZIP、版本标签与 catalog-v4.json 内嵌清单一致;先上传不可变 ZIP 与 SHA256SUMS.txt 并回读验证,再更新索引。仅静态清单校验通过不能宣称全部平台已实测。
|
||||||
|
|
||||||
官方市场为 2.3.0+ 提供 catalog-v3.json,catalog.json 保留给 2.2.x。宿主读取原官方地址时自动选择新版索引,不改写用户配置;自定义市场地址不会被替换。
|
官方市场为 2.4.0+ 提供 catalog-v4.json,catalog-v3.json 保留给 2.3.x,catalog.json 保留给 2.2.x,避免旧宿主严格解析新面板类型时导致整个市场加载失败。2.4.0 读取原官方 catalog.json 或 catalog-v3.json 地址时自动选择新版索引,不改写用户配置;自定义市场地址不会被替换。
|
||||||
@@ -76,6 +76,6 @@ GET 成功返回完整贡献快照;PUT 成功返回同样结构和新 revision
|
|||||||
|
|
||||||
人工结果只能由用户填写;AI 不自行调用 PUT 生成或推断评审。此接口未加入模型 MCP 工具或远程 Python SDK 的人工写入方法。插件没有设备目标时,无需为安装验证提交安卓/PC 测试任务;用隔离终态示例验证保存、清空、重装、导出与旧缓存失效即可。
|
人工结果只能由用户填写;AI 不自行调用 PUT 生成或推断评审。此接口未加入模型 MCP 工具或远程 Python SDK 的人工写入方法。插件没有设备目标时,无需为安装验证提交安卓/PC 测试任务;用隔离终态示例验证保存、清空、重装、导出与旧缓存失效即可。
|
||||||
|
|
||||||
市场发布时保持清单、ZIP、版本标签与 catalog-v3.json 内嵌清单一致;先上传不可变 ZIP 与 SHA256SUMS.txt 并回读验证,再更新索引。仅静态清单校验通过不能宣称全部平台已实测。
|
市场发布时保持清单、ZIP、版本标签与 catalog-v4.json 内嵌清单一致;先上传不可变 ZIP 与 SHA256SUMS.txt 并回读验证,再更新索引。仅静态清单校验通过不能宣称全部平台已实测。
|
||||||
|
|
||||||
官方市场为 2.3.0+ 提供 catalog-v3.json,catalog.json 保留给 2.2.x。宿主读取原官方地址时自动选择新版索引,不改写用户配置;自定义市场地址不会被替换。
|
官方市场为 2.4.0+ 提供 catalog-v4.json,catalog-v3.json 保留给 2.3.x,catalog.json 保留给 2.2.x,避免旧宿主严格解析新面板类型时导致整个市场加载失败。2.4.0 读取原官方 catalog.json 或 catalog-v3.json 地址时自动选择新版索引,不改写用户配置;自定义市场地址不会被替换。
|
||||||
@@ -214,6 +214,54 @@ def test_concurrent_managers_share_the_first_host_token(tmp_path, monkeypatch):
|
|||||||
assert token_path.read_text() == tokens[0]
|
assert token_path.read_text() == tokens[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_unreadable_host_token_is_not_replaced(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(hm, "get_temp_dir", lambda _name: tmp_path / "mutex")
|
||||||
|
token_path = tmp_path / "session.token"
|
||||||
|
token_path.write_text("a" * 48)
|
||||||
|
original_read = Path.read_text
|
||||||
|
|
||||||
|
def denied(path, *args, **kwargs):
|
||||||
|
if path == token_path:
|
||||||
|
raise PermissionError("token temporarily inaccessible")
|
||||||
|
return original_read(path, *args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(Path, "read_text", denied)
|
||||||
|
manager = AccessibilityHelperManager(token_path=token_path)
|
||||||
|
with pytest.raises(PermissionError, match="temporarily inaccessible"):
|
||||||
|
manager.host_token()
|
||||||
|
assert original_read(token_path) == "a" * 48
|
||||||
|
monkeypatch.setattr(Path, "read_text", original_read)
|
||||||
|
assert manager.host_token() == "a" * 48
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_provision_mutex_cannot_be_stolen_after_lease_ages(tmp_path, monkeypatch):
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
monkeypatch.setattr(hm, "get_temp_dir", lambda _name: tmp_path)
|
||||||
|
first = AccessibilityHelperManager()
|
||||||
|
second = AccessibilityHelperManager()
|
||||||
|
entered = threading.Event()
|
||||||
|
|
||||||
|
def contender():
|
||||||
|
with second._provision_mutex(SERIAL):
|
||||||
|
entered.set()
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=1) as executor:
|
||||||
|
with first._provision_mutex(SERIAL):
|
||||||
|
digest = hashlib.sha256(SERIAL.encode()).hexdigest()[:16]
|
||||||
|
path = tmp_path / f"{digest}.mutex"
|
||||||
|
old = time.time() - hm._PROVISION_MUTEX_STALE_SECONDS - 10
|
||||||
|
os.utime(path, (old, old))
|
||||||
|
future = executor.submit(contender)
|
||||||
|
stolen = entered.wait(0.2)
|
||||||
|
future.result(timeout=5)
|
||||||
|
assert not stolen
|
||||||
|
assert entered.is_set()
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# Provision
|
# Provision
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
@@ -23,14 +23,14 @@ from artemis.runtime.device_lock import DeviceBusyError, DeviceExecutionLock
|
|||||||
("configured", "expected"),
|
("configured", "expected"),
|
||||||
[
|
[
|
||||||
(
|
(
|
||||||
"https://git.playones.com/chenmingxuan/Artemis-Plugins/raw/branch/main/catalog.json",
|
"https://git.playones.com/chenmingxuan/Artemis-Plugins/raw/branch/main/" + name,
|
||||||
"https://git.playones.com/chenmingxuan/Artemis-Plugins/raw/branch/main/catalog-v3.json",
|
"https://git.playones.com/chenmingxuan/Artemis-Plugins/raw/branch/main/catalog-v4.json",
|
||||||
),
|
)
|
||||||
(
|
for name in ("catalog.json", "catalog-v3.json", "catalog-v4.json")
|
||||||
"https://git.playones.com/chenmingxuan/Artemis-Plugins/raw/branch/main/catalog-v3.json",
|
]
|
||||||
"https://git.playones.com/chenmingxuan/Artemis-Plugins/raw/branch/main/catalog-v3.json",
|
+ [
|
||||||
),
|
|
||||||
("https://example.test/catalog.json", "https://example.test/catalog.json"),
|
("https://example.test/catalog.json", "https://example.test/catalog.json"),
|
||||||
|
("https://example.test/catalog-v3.json", "https://example.test/catalog-v3.json"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_workspace_catalog_routes_only_the_official_legacy_address(
|
def test_workspace_catalog_routes_only_the_official_legacy_address(
|
||||||
|
|||||||
Reference in new issue
Block a user