Compare commits

...
7 Commits
80 changed files with 4305 additions and 169 deletions

No files matched your search

+12 -2
View File
@@ -21,7 +21,7 @@
| 探索、示范、用例提炼与复跑 | [探索说明](docs/exploration-mode.md) | | 探索、示范、用例提炼与复跑 | [探索说明](docs/exploration-mode.md) |
| 插件协议、适配或市场协同 | [插件开发规范](docs/plugin-development.md)及插件仓库自身的 AGENTS.md、DESIGN.md | | 插件协议、适配或市场协同 | [插件开发规范](docs/plugin-development.md)及插件仓库自身的 AGENTS.md、DESIGN.md |
| 远程 Python SDK | [SDK 设计](packages/artemis-client/DESIGN.md),保持标准库薄客户端边界 | | 远程 Python SDK | [SDK 设计](packages/artemis-client/DESIGN.md),保持标准库薄客户端边界 |
| 继续历史开发、确认版本或发布 | [迁入时的开发快照](docs/development-handoff.md)、[2.1.0 发行说明](docs/releases/2.1.0.md),再核对实时 Git 状态;`docs/plans/` 保留各自阶段的状态 | | 继续历史开发、确认版本或发布 | [README 当前发行入口](README.md)、对应发行说明及实时 Git 状态;[开发快照](docs/development-handoff.md)与 `docs/plans/` 仅作为历史线索,不替代当前源码与发布包核验 |
## Quick Commands ## Quick Commands
@@ -60,7 +60,7 @@ Windows 首次安装使用 `start.bat`,国内镜像使用 `start-cn.bat`。启
## Conventions ## Conventions
- Python 使用绝对导入、4 空格缩进和双引号,由 Ruff 检查;不要放宽质量基线来掩盖新增问题。 - Python 使用绝对导入、4 空格缩进和双引号,由 Ruff 检查;不要放宽质量基线来掩盖新增问题。
- 新增可调参数集中进入配置层,并更新环境示例、网页/MCP 入口和必要文档;默认值同步 `config/artemis.jsonc` 与 `artemis/resources/config/artemis.jsonc`。 - 新增可调参数集中进入配置层,按作用域更新环境示例、网页/MCP 入口和文档;明确仅供环境配置的参数不增加前端入口。JSON 默认值同步 `config/artemis.jsonc` 与 `artemis/resources/config/artemis.jsonc`。
- MCP 工具增删时同步工具注册、CLI 安装白名单、测试、Skill 和能力文档。 - MCP 工具增删时同步工具注册、CLI 安装白名单、测试、Skill 和能力文档。
- 同步 Codex MCP/Skill 时保留已有工具白名单、禁用状态和审批策略;异常时保留原配置并告警,不生成非法 TOML 或阻断网页启动。 - 同步 Codex MCP/Skill 时保留已有工具白名单、禁用状态和审批策略;异常时保留原配置并告警,不生成非法 TOML 或阻断网页启动。
- Windows PowerShell 启动脚本保持兼容 Windows PowerShell 5.1,避免编码导致解析失败。升级只做 Git 快进,保护已有配置和本地修改。 - Windows PowerShell 启动脚本保持兼容 Windows PowerShell 5.1,避免编码导致解析失败。升级只做 Git 快进,保护已有配置和本地修改。
@@ -75,6 +75,16 @@ Windows 首次安装使用 `start.bat`,国内镜像使用 `start-cn.bat`。启
- 设备验证沿用用户指定设备;多设备且未指定时先确认。编写应用交互测试前,先通过 ARTEMIS 探索实际页面和操作路径。 - 设备验证沿用用户指定设备;多设备且未指定时先确认。编写应用交互测试前,先通过 ARTEMIS 探索实际页面和操作路径。
- 提交前检查 `git diff --check` 和暂存范围,只暂存本次具体路径;报告实际执行的验证及未完成项,不将未运行的检查写成通过。 - 提交前检查 `git diff --check` 和暂存范围,只暂存本次具体路径;报告实际执行的验证及未完成项,不将未运行的检查写成通过。
## Release Gates
- 主程序、SDK、插件和第三方依赖独立版本化。主程序升版只修改 `pyproject.toml` 的项目版本、`uv.lock` 的 artemis 条目及前端应用的根版本字段,发行文档另行更新。禁止对仓库或整个锁文件全局替换版本字符串。
- 单独审查锁文件差异。第三方条目的版本、下载地址、完整性校验和依赖声明必须相互匹配;主程序升版不应改变这些条目。真正升级依赖时由包管理器生成并核对上游声明;发生 EINTEGRITY 先核验来源,禁止关闭校验或只照错误提示改哈希。
- 发布前必须在隔离目录解压最终 ZIP,使用独立空 npm 缓存完成 `npm ci` 和生产构建,覆盖本次发布的官方/国内安装路径。包内安装或构建输入变化后必须重跑;不得清空用户全局缓存或改动用户运行目录来制造干净环境。
- 记录实际 Node/npm 版本、镜像、源码提交、ZIP SHA256 和验证范围。已有 node_modules、构建指纹、缓存命中或大量单元测试通过,均不能证明全新安装成功;某个 Node 版本通过也不能冒充其他版本实测。测试和构建命令见前端局部指南。
- 发布顺序:固定提交并打包 → 验证最终包 → 推送标签 → 创建草稿并上传包及校验文件 → 回读校验 → 正式发布 → 无认证下载核验。标签必须指向打包提交;推送标签后检查 Release 状态,本仓库曾出现 Gitea 将已有草稿自动发布的情况。失败时修复并重新验证,不提前宣布完成。
- 候选包与正式包目录明确区分。候选包失效后移出可分发路径并标明替代版本,不能留下同名旧 ZIP 供用户误取;对外只给已核验的正式发布链接或准确文件路径。正式版本不覆盖原标签或附件,后续修复发补丁版。
- 安装或市场版本不符时,先核对用户实际 ZIP 的 SHA256、运行源码提交及生效市场地址;不凭界面版本号判断,也不把未经验证的缓存假设当成原因。主程序与插件版本、索引兼容性分别验证。
## Boundaries ## Boundaries
- 始终保护用户任务、历史库、环境配置和凭据。不要把个人设备序列号、绝对目录或 API Key 写入源码。 - 始终保护用户任务、历史库、环境配置和凭据。不要把个人设备序列号、绝对目录或 API Key 写入源码。
+3
View File
@@ -64,6 +64,8 @@ ARTEMIS 面向使用自然语言执行、探索和复跑测试的人。当前产
| 插件与证据 | `artemis/plugins` 管理安装、会话、版本固定、来源、截图、录像和附件;插件执行工具动作,宿主管理任务生命周期和最终验收 | | 插件与证据 | `artemis/plugins` 管理安装、会话、版本固定、来源、截图、录像和附件;插件执行工具动作,宿主管理任务生命周期和最终验收 |
| 配置与交付 | `artemis/config`、两份 `artemis.jsonc` 和环境配置提供默认值;`scripts` 负责启动、镜像、独立升级和源码打包 | | 配置与交付 | `artemis/config`、两份 `artemis.jsonc` 和环境配置提供默认值;`scripts` 负责启动、镜像、独立升级和源码打包 |
插件运行辅助接口见 [SDK 与市场材料](docs/plugin-sdk.md):已批准文件用不可变快照绑定实际消费字节,受管外部工具在返回前回收后代。宿主负责 MCP 总预算,插件负责内部超时和取消。开发检查保持轻量;市场材料使用独立内容指纹与验收记录校验,不代替源码、权利来源或真实业务审查。
请求重试、业务执行与最终验收分别处理。提交使用稳定标识;创建批次或探索草稿不自动执行,查询不产生设备动作。执行准入和恢复遵守现有资源锁,任务固定所用插件版本,不能通过另建队列绕开互斥。 请求重试、业务执行与最终验收分别处理。提交使用稳定标识;创建批次或探索草稿不自动执行,查询不产生设备动作。执行准入和恢复遵守现有资源锁,任务固定所用插件版本,不能通过另建队列绕开互斥。
设备保活是辅助服务。保活所属锁或旧保活清理目录不可访问时记录警告,不使设备连接、恢复或监控退出;未取得保活所属锁时不发送保活动作,也不创建替代锁绕过所属权。执行资源锁、主服务注册锁和队列写锁仍须成功取得。 设备保活是辅助服务。保活所属锁或旧保活清理目录不可访问时记录警告,不使设备连接、恢复或监控退出;未取得保活所属锁时不发送保活动作,也不创建替代锁绕过所属权。执行资源锁、主服务注册锁和队列写锁仍须成功取得。
@@ -155,6 +157,7 @@ Flash 的最终 AI 判定与分析独立配置,默认关闭;首页功能配
- 通常以 4、6、8、12、16、24px 组织内部间距;沿用组件现有网格和断点。子容器允许收缩,长内容使用 `min-width: 0` 及合适换行。 - 通常以 4、6、8、12、16、24px 组织内部间距;沿用组件现有网格和断点。子容器允许收缩,长内容使用 `min-width: 0` 及合适换行。
- 探索面板在 600px 以下收紧外边距并允许操作换行;模型选择控件宽度跟随容器。窄屏仍可辨认状态、选择来源和发送指导。 - 探索面板在 600px 以下收紧外边距并允许操作换行;模型选择控件宽度跟随容器。窄屏仍可辨认状态、选择来源和发送指导。
- 列表后台刷新保留滚动、选择和折叠状态;首次加载与静默更新分开,避免周期性加载提示挤动内容。实时连接复用现有页面可见性管理。 - 列表后台刷新保留滚动、选择和折叠状态;首次加载与静默更新分开,避免周期性加载提示挤动内容。实时连接复用现有页面可见性管理。
- 结束任务的错误与阻塞说明默认只占一行,点击展开安全 Markdown 正文与已有依据,长正文限高滚动。摘要优先采用已保存的有效结论原因,否则取执行错误首行;不新增 AI 复盘请求,不改写原始记录。展开说明不触发任务切换,刷新保留展开状态。
## Elevation ## Elevation
+1 -1
View File
@@ -2,7 +2,7 @@
基于 [ARTEMIS](https://github.com/google/artemis) 扩展的 Android AI 自动化测试平台。用自然语言描述任务,即可通过简体中文网页或 MCP 驱动真机、模拟器执行操作并收集结果。模型统一使用 **OpenAI Responses API**。 基于 [ARTEMIS](https://github.com/google/artemis) 扩展的 Android AI 自动化测试平台。用自然语言描述任务,即可通过简体中文网页或 MCP 驱动真机、模拟器执行操作并收集结果。模型统一使用 **OpenAI Responses API**。
**2.4.0**:人工评审支持原始用例、自动保存与上一条/下一条;执行详情默认折叠长字段。升级 OpenAI/LangChain 及安全依赖,修复 Windows 资源锁、报告证据与历史用量。配套「任务人工评审 1.1.0」独立安装。见 [2.4.0 发行说明](docs/releases/2.4.0.md);上一版本见 [2.3.0](docs/releases/2.3.0.md)。 **2.4.5**:补齐插件开发辅助接口与市场材料检查,提供已批准文件快照、受管外部工具、故障自测和可运行模板。新 SDK 需 ARTEMIS 2.4.5;现有插件继续按自身清单兼容旧版本。见 [2.4.5 发行说明](docs/releases/2.4.5.md)与 [SDK 使用说明](docs/plugin-sdk.md)。人工评审插件保持 1.1.0;任务说明折叠见 [2.4.4](docs/releases/2.4.4.md),批次异常隔离见 [2.4.3](docs/releases/2.4.3.md)。
## 主要功能 ## 主要功能
+3 -1
View File
@@ -12,7 +12,7 @@ from apps.admin_console.services.exploration_listing import EXPLORATION_RUNS_CTE
from apps.admin_console.services.automation_store import identifier, store from apps.admin_console.services.automation_store import identifier, store
from apps.admin_console.services import automation_service as service from apps.admin_console.services import automation_service as service
from apps.admin_console.services import batch_archive as archive_service from apps.admin_console.services import batch_archive as archive_service
from apps.admin_console.services import report_export, task_import from apps.admin_console.services import report_export, task_extensions, task_import
from artemis.config.settings import settings from artemis.config.settings import settings
from apps.admin_console.services.case_review import read_reviewed_result from apps.admin_console.services.case_review import read_reviewed_result
@@ -131,7 +131,9 @@ def batch_by_request(request_id: str):
@router.get("/batches/{batch_id}") @router.get("/batches/{batch_id}")
def batch_detail(batch_id: str): def batch_detail(batch_id: str):
jobs = store.jobs(batch_id) jobs = store.jobs(batch_id)
reviewed = task_extensions.reviewed_batch_sessions(batch_id, store)
for job in jobs: for job in jobs:
job["human_reviewed"] = job["session_id"] in reviewed
job["test_result"] = read_reviewed_result( job["test_result"] = read_reviewed_result(
job["session_id"], job["status"], job.get("error") or "", storage=store job["session_id"], job["status"], job.get("error") or "", storage=store
) )
@@ -36,6 +36,32 @@ def installation_signature() -> str:
) )
def reviewed_batch_sessions(batch_id: str, store: AutomationStore) -> set[str]:
"""Read current human review values once per batch, without loading history."""
manifest = next((item for item in active_manifests() if item.id == "task-review"), None)
if manifest is None or manifest.workspace is None:
return set()
with store.connection() as conn:
rows = conn.execute(
"""SELECT j.session_id, json_extract(r.payload, '$."task-review".values') AS review_values
FROM automation_jobs j JOIN automation_records r
ON r.kind='task_extensions' AND r.id=j.session_id
WHERE j.batch_id=?""",
(batch_id,),
).fetchall()
reviewed = set()
for row in rows:
values = json.loads(row["review_values"] or "{}")
if any(
isinstance(value := values.get(field.id), str)
and value.strip()
and (field.kind == "text" or value in field.options)
for field in manifest.workspace.fields
):
reviewed.add(row["session_id"])
return reviewed
def task_context(session_id: str, store: AutomationStore) -> dict: def task_context(session_id: str, store: AutomationStore) -> dict:
identifier(session_id) identifier(session_id)
session = SessionRepository(store.path).get_session_by_id(session_id) or {} session = SessionRepository(store.path).get_session_by_id(session_id) or {}
+3
View File
@@ -30,6 +30,9 @@ npm run build
## Local Conventions ## Local Conventions
- 发版遵守根指南的 Release Gates。前端应用版本只改 package.json 顶层 `version` 和 package-lock.json 顶层及 `packages[""].version`;禁止把相同数字的第三方依赖一并替换。
- Node 支持范围以锁定的 Angular CLI/构建依赖实际 `engines` 为准,核对启动脚本的主、次、补丁版本边界和提示;不要只判断“Node 22/24/26”。
- 安装或依赖变更需在隔离目录、独立空 npm 缓存下运行 `npm ci`,再运行上述测试和构建;国内入口同时核验镜像安装。不得以关闭完整性校验、强制安装或删除锁文件掩盖不一致,也不得用已装依赖的构建结果宣称全新安装通过。
- 页面组件在 src/app/components,服务在 src/app/services;先复用已有组件、样式及 API 服务。 - 页面组件在 src/app/components,服务在 src/app/services;先复用已有组件、样式及 API 服务。
- 保持现有简体中文文案、颜色、间距和控件样式,菜单、弹窗和表单必须清晰可读。 - 保持现有简体中文文案、颜色、间距和控件样式,菜单、弹窗和表单必须清晰可读。
- 修改任务或配置交互时覆盖加载、保存、失败和空状态;不要将执行中误标成录屏中或把完成误标成验证通过。 - 修改任务或配置交互时覆盖加载、保存、失败和空状态;不要将执行中误标成录屏中或把完成误标成验证通过。
+8 -8
View File
@@ -1,12 +1,12 @@
{ {
"name": "artemis-showcase-ui", "name": "artemis-showcase-ui",
"version": "2.4.0", "version": "2.4.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "artemis-showcase-ui", "name": "artemis-showcase-ui",
"version": "2.4.0", "version": "2.4.5",
"dependencies": { "dependencies": {
"@angular/cdk": "^22.2.4", "@angular/cdk": "^22.2.4",
"@angular/common": "^22.2.3", "@angular/common": "^22.2.3",
@@ -253,7 +253,7 @@
"@angular-devkit/schematics": "22.2.4", "@angular-devkit/schematics": "22.2.4",
"@inquirer/prompts": "8.7.2", "@inquirer/prompts": "8.7.2",
"@listr2/prompt-adapter-inquirer": "4.2.7", "@listr2/prompt-adapter-inquirer": "4.2.7",
"@modelcontextprotocol/server": "2.4.0", "@modelcontextprotocol/server": "2.3.1",
"@schematics/angular": "22.2.4", "@schematics/angular": "22.2.4",
"jsonc-parser": "3.3.1", "jsonc-parser": "3.3.1",
"listr2": "11.1.0", "listr2": "11.1.0",
@@ -2043,8 +2043,8 @@
] ]
}, },
"node_modules/@modelcontextprotocol/core": { "node_modules/@modelcontextprotocol/core": {
"version": "2.4.0", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.4.0.tgz", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.3.1.tgz",
"integrity": "sha512-laVmIhPGpWi7rG9q+0UigblRN0Hws/Yjlb6qLFtVqryXVLkc4h+yN8z731POD2LnAGQlFe7/tXhUm7Bi0YB91g==", "integrity": "sha512-laVmIhPGpWi7rG9q+0UigblRN0Hws/Yjlb6qLFtVqryXVLkc4h+yN8z731POD2LnAGQlFe7/tXhUm7Bi0YB91g==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
@@ -2056,13 +2056,13 @@
} }
}, },
"node_modules/@modelcontextprotocol/server": { "node_modules/@modelcontextprotocol/server": {
"version": "2.4.0", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.4.0.tgz", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.3.1.tgz",
"integrity": "sha512-e59MfWuSssj6DoQ96JCTqdW1AXT5/XtnTl0WWd6MU1Ka434fFUGGosCLJbGNySDhHGvxQWoRWK137FXYFkYNWw==", "integrity": "sha512-e59MfWuSssj6DoQ96JCTqdW1AXT5/XtnTl0WWd6MU1Ka434fFUGGosCLJbGNySDhHGvxQWoRWK137FXYFkYNWw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@modelcontextprotocol/core": "2.4.0", "@modelcontextprotocol/core": "2.3.1",
"zod": "^4.2.0" "zod": "^4.2.0"
}, },
"engines": { "engines": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "artemis-showcase-ui", "name": "artemis-showcase-ui",
"version": "2.4.0", "version": "2.4.5",
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",
"start": "ng serve --proxy-config proxy.conf.json", "start": "ng serve --proxy-config proxy.conf.json",
@@ -129,9 +129,7 @@ limitations under the License.
<div class="item-goal" [title]="session.initial_goal || ''"> <div class="item-goal" [title]="session.initial_goal || ''">
{{ session.display_name || session.initial_goal || '未命名任务' }} {{ session.display_name || session.initial_goal || '未命名任务' }}
</div> </div>
@if (session.error && getTaskStatus(session) === 'failed') { <app-task-outcome [error]="session.error" [result]="session.test_result" [status]="getTaskStatus(session)" />
<div class="item-error" [title]="session.error">{{session.error}}</div>
}
<div class="item-footer"> <div class="item-footer">
<app-task-id-copy [taskId]="session.session_id" /> <app-task-id-copy [taskId]="session.session_id" />
<span class="item-date">{{ session.start_time * 1000 | date:'MM/dd/yy' }} · 已用 {{ formatTaskDuration(session.start_time, session.end_time) }}</span> <span class="item-date">{{ session.start_time * 1000 | date:'MM/dd/yy' }} · 已用 {{ formatTaskDuration(session.start_time, session.end_time) }}</span>
@@ -3694,7 +3694,6 @@ $stream-row-height: 25px;
&.failed { border-left: 3.5px solid #dc2626; background: var(--app-error-soft, #fff7f7); } &.failed { border-left: 3.5px solid #dc2626; background: var(--app-error-soft, #fff7f7); }
&.completed { border-left: 3.5px solid #16a34a; } &.completed { border-left: 3.5px solid #16a34a; }
&.cancelled, &.interrupted { border-left: 3.5px solid #ea580c; } &.cancelled, &.interrupted { border-left: 3.5px solid #ea580c; }
.item-error { color: var(--app-error-text, #b91c1c); font-size: 11px; line-height: 1.4; overflow-wrap: anywhere; }
.item-header { .item-header {
display: flex; display: flex;
@@ -1,3 +1,4 @@
import { TaskOutcomeComponent } from '../task-outcome/task-outcome.component';
import { ExplorationMessage, Demonstration } from '../../services/exploration.service'; import { ExplorationMessage, Demonstration } from '../../services/exploration.service';
import { mergeConversation } from '../../utils/exploration-timeline.util'; import { mergeConversation } from '../../utils/exploration-timeline.util';
import { TaskIdCopyComponent } from '../task-id-copy/task-id-copy.component'; import { TaskIdCopyComponent } from '../task-id-copy/task-id-copy.component';
@@ -251,7 +252,7 @@ export type { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote, StreamRe
@Component({ @Component({
selector: 'app-agent-stream', selector: 'app-agent-stream',
standalone: true, standalone: true,
imports: [ExpandableValueComponent, PluginToolResultComponent, ReportMarkdownComponent, LlmRequestStatusComponent, TaskLifecycleComponent, TaskIdCopyComponent, UiLabelPipe, CommonModule, FormsModule, OverlayModule, TaskHistoryComponent, AutomationPanelComponent, TestResultBadgeComponent, TestResultCardComponent, TaskBatchFilterComponent], imports: [TaskOutcomeComponent,ExpandableValueComponent, PluginToolResultComponent, ReportMarkdownComponent, LlmRequestStatusComponent, TaskLifecycleComponent, TaskIdCopyComponent, UiLabelPipe, CommonModule, FormsModule, OverlayModule, TaskHistoryComponent, AutomationPanelComponent, TestResultBadgeComponent, TestResultCardComponent, TaskBatchFilterComponent],
templateUrl: './agent-stream.component.html', templateUrl: './agent-stream.component.html',
styleUrl: './agent-stream.component.scss', styleUrl: './agent-stream.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
@@ -17,7 +17,7 @@
<div class="report-heading"><app-favorite-button [sourceId]="r.id" /><strong class="report-title">{{r.name}}</strong></div> <div class="report-heading"><app-favorite-button [sourceId]="r.id" /><strong class="report-title">{{r.name}}</strong></div>
<div class="report-badges"><span [class.failed]="r.status === 'failed'">{{label(r.status)}}</span><app-test-result-badge [result]="r.test_result" [executionStatus]="r.status" /></div> <div class="report-badges"><span [class.failed]="r.status === 'failed'">{{label(r.status)}}</span><app-test-result-badge [result]="r.test_result" [executionStatus]="r.status" /></div>
@if(r.test_result; as result){<app-test-result-card [result]="result" [sessionId]="r.id" [executionStatus]="r.status" (reviewSaved)="reviewSaved($event)" />} @if(r.test_result; as result){<app-test-result-card [result]="result" [sessionId]="r.id" [executionStatus]="r.status" (reviewSaved)="reviewSaved($event)" />}
@if (r.error) { <div class="report-error" role="status"><strong>异常原因</strong><p>{{r.error}}</p></div> } @if (r.error) { <app-task-outcome [error]="r.error" [status]="r.status" /> }
</div> </div>
<div class="report-meta"><div><strong>任务目标与预期</strong><p>{{r.goal || '未记录'}}</p></div>@if(r.expected){<div><strong>预期结果</strong><p>{{r.expected}}</p></div>}</div> <div class="report-meta"><div><strong>任务目标与预期</strong><p>{{r.goal || '未记录'}}</p></div>@if(r.expected){<div><strong>预期结果</strong><p>{{r.expected}}</p></div>}</div>
<app-evidence-sources [value]="r.evidence_sources || null" /> <app-evidence-sources [value]="r.evidence_sources || null" />
@@ -108,6 +108,19 @@
</div> </div>
} }
@if(b.report_error){<p class="error">报告生成失败:{{b.report_error}}</p>} @if(b.report_error){<p class="error">报告生成失败:{{b.report_error}}</p>}
@for(j of b.jobs; track j.session_id){<div class="job" [class.selected]="agent.currentSessionId() === j.session_id"><app-favorite-button [sourceId]="j.session_id" /><button (click)="openBatchTask(j.session_id)" [title]="j.case.goal" [attr.aria-pressed]="agent.currentSessionId() === j.session_id">{{j.case.case_id ? j.case.case_id + ' ' : ''}}{{j.case.name}}@if(j.repeat_count > 1){ · 第 {{j.iteration}} / {{j.repeat_count}} 轮}</button><span>{{label(j.status)}}</span><app-test-result-badge [result]="j.test_result" [executionStatus]="j.status" [compact]="true" />@if(j.error){<small>{{j.error}}</small>}</div>} @for(j of b.jobs; track j.session_id){
<div class="job" [class.selected]="agent.currentSessionId() === j.session_id">
<div class="job-heading">
<app-favorite-button [sourceId]="j.session_id" />
<button (click)="openBatchTask(j.session_id)" [title]="j.case.goal" [attr.aria-pressed]="agent.currentSessionId() === j.session_id">{{j.case.case_id ? j.case.case_id + ' ' : ''}}{{j.case.name}}@if(j.repeat_count > 1){ · 第 {{j.iteration}} / {{j.repeat_count}} 轮}</button>
</div>
<div class="job-statuses">
<span>{{label(j.status)}}</span>
<app-test-result-badge [result]="j.test_result" [executionStatus]="j.status" [compact]="true" />
@if(j.human_reviewed){<span class="human-review-badge" title="人工评审已有保存内容,不代表测试通过">人工已审</span>}
</div>
<app-task-outcome [error]="j.error" [result]="j.test_result" [status]="j.status" />
</div>
}
</section>} </section>}
} }
@@ -37,14 +37,21 @@ input[type=checkbox]{width:15px;height:15px;accent-color:var(--app-primary-text,
@keyframes archive-spin{to{transform:rotate(360deg);}} @keyframes archive-spin{to{transform:rotate(360deg);}}
.code{font-family:Consolas,monospace;font-size:12px;line-height:1.6;resize:vertical;background:var(--app-surface-soft, #f8fafc);}.table-wrap{max-height:260px;overflow:auto;border:1px solid var(--app-border, #e2e8f0);border-radius:8px;margin-bottom:14px;}table{width:100%;border-collapse:collapse;}th,td{padding:9px 10px;border-bottom:1px solid var(--app-border, #e2e8f0);text-align:left;overflow-wrap:anywhere;}th{background:var(--app-surface-soft, #f8fafc);color:var(--app-text-secondary, #475569);font-weight:500;} .code{font-family:Consolas,monospace;font-size:12px;line-height:1.6;resize:vertical;background:var(--app-surface-soft, #f8fafc);}.table-wrap{max-height:260px;overflow:auto;border:1px solid var(--app-border, #e2e8f0);border-radius:8px;margin-bottom:14px;}table{width:100%;border-collapse:collapse;}th,td{padding:9px 10px;border-bottom:1px solid var(--app-border, #e2e8f0);text-align:left;overflow-wrap:anywhere;}th{background:var(--app-surface-soft, #f8fafc);color:var(--app-text-secondary, #475569);font-weight:500;}
.preview{white-space:pre-wrap;max-height:400px;overflow:auto;font:inherit;color:var(--app-text-control, #334155);background:var(--app-surface-soft, #f8fafc);border-radius:8px;padding:12px;border:1px solid var(--app-border, #e2e8f0);} .preview{white-space:pre-wrap;max-height:400px;overflow:auto;font:inherit;color:var(--app-text-control, #334155);background:var(--app-surface-soft, #f8fafc);border-radius:8px;padding:12px;border:1px solid var(--app-border, #e2e8f0);}
.job{display:flex;flex-wrap:wrap;gap:8px;align-items:center;padding:10px 0;border-bottom:1px solid var(--app-border, #f1f5f9);}.job:last-child{border-bottom:0;}.job button{flex:1;justify-content:flex-start;text-align:left;min-width:150px;}.job span{font-size:12px;background:var(--app-surface-soft, #f1f5f9);color:var(--app-text-secondary, #475569);border-radius:5px;padding:3px 7px;}.job small{width:100%;}details{margin:14px 0;}summary{color:var(--app-text-secondary, #475569);cursor:pointer;padding:8px 0;} .job{display:flex;flex-direction:column;gap:8px;padding:12px 0;border-bottom:1px solid var(--app-border, #f1f5f9);}
.job:last-child{border-bottom:0;}
.job-heading{position:relative;min-width:0;}
.job-heading>app-favorite-button{position:absolute;left:6px;top:4px;z-index:1;}
.job-heading>button{box-sizing:border-box;width:100%;min-width:0;padding-left:40px;justify-content:flex-start;text-align:left;overflow-wrap:anywhere;}
.job-statuses{display:flex;flex-wrap:wrap;align-items:center;gap:8px;}
.job-statuses>span{display:inline-flex;align-items:center;box-sizing:border-box;height:20px;padding:0 6px;border:1px solid transparent;border-radius:5px;font-size:11px;line-height:14px;background:var(--app-surface-soft, #f1f5f9);color:var(--app-text-secondary, #475569);white-space:nowrap;}
.job small{overflow-wrap:anywhere;}
details{margin:14px 0;}summary{color:var(--app-text-secondary, #475569);cursor:pointer;padding:8px 0;}
.task-guidance{margin-top:16px;padding:14px;border:1px solid var(--app-primary-border, #bfdbfe);border-radius:10px;background:var(--app-primary-soft, #f8fbff);} .task-guidance{margin-top:16px;padding:14px;border:1px solid var(--app-primary-border, #bfdbfe);border-radius:10px;background:var(--app-primary-soft, #f8fbff);}
.task-guidance label{margin:8px 0 4px;} .task-guidance label{margin:8px 0 4px;}
.report-overview{padding:14px;border:1px solid var(--app-primary-border, #dbeafe);border-radius:10px;background:var(--app-primary-soft, #f8fbff);} .report-overview{padding:14px;border:1px solid var(--app-primary-border, #dbeafe);border-radius:10px;background:var(--app-primary-soft, #f8fbff);}
.report-heading{display:flex;align-items:flex-start;gap:8px;}.report-heading .report-title{flex:1;min-width:0;} .report-heading{display:flex;align-items:flex-start;gap:8px;}.report-heading .report-title{flex:1;min-width:0;}
.report-title{display:block;font-size:14px;line-height:1.5;color:var(--app-text-strong, #0f172a);overflow-wrap:anywhere;} .report-title{display:block;font-size:14px;line-height:1.5;color:var(--app-text-strong, #0f172a);overflow-wrap:anywhere;}
.report-badges{display:flex;gap:7px;flex-wrap:wrap;margin-top:9px;span{padding:3px 8px;border:1px solid var(--app-primary-border, #bfdbfe);border-radius:20px;background:var(--app-primary-soft, #eff6ff);color:var(--app-primary-text, #1e40af);font-size:12px;&.failed{background:var(--app-error-soft, #fff1f2);border-color:var(--app-error-border, #fecdd3);color:var(--app-error-text, #be123c);}}} .report-badges{display:flex;gap:7px;flex-wrap:wrap;margin-top:9px;span{padding:3px 8px;border:1px solid var(--app-primary-border, #bfdbfe);border-radius:20px;background:var(--app-primary-soft, #eff6ff);color:var(--app-primary-text, #1e40af);font-size:12px;&.failed{background:var(--app-error-soft, #fff1f2);border-color:var(--app-error-border, #fecdd3);color:var(--app-error-text, #be123c);}}}
.report-error{margin-top:11px;padding:10px 12px;background:var(--app-error-soft, #fff1f2);border-left:3px solid #e11d48;border-radius:6px;color:var(--app-error-text, #9f1239);strong{font-size:12px;}p{margin:4px 0 0;color:var(--app-error-text, #9f1239);white-space:pre-wrap;}}
.report-meta{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:10px;margin:12px 0;div{min-width:0;padding:10px 12px;border:1px solid var(--app-border, #e2e8f0);border-radius:8px;background:var(--app-surface, #fff);}strong{font-size:12px;color:var(--app-text-secondary, #475569);}p{margin:5px 0 0;white-space:pre-wrap;color:var(--app-text-control, #1e293b);}} .report-meta{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:10px;margin:12px 0;div{min-width:0;padding:10px 12px;border:1px solid var(--app-border, #e2e8f0);border-radius:8px;background:var(--app-surface, #fff);}strong{font-size:12px;color:var(--app-text-secondary, #475569);}p{margin:5px 0 0;white-space:pre-wrap;color:var(--app-text-control, #1e293b);}}
.report-content{margin-top:14px;padding:14px;border:1px solid var(--app-border, #e2e8f0);border-radius:10px;background:var(--app-surface, #fff);h3{margin-bottom:12px;}} .report-content{margin-top:14px;padding:14px;border:1px solid var(--app-border, #e2e8f0);border-radius:10px;background:var(--app-surface, #fff);h3{margin-bottom:12px;}}
.report-body{overflow-x:auto;overflow-wrap:anywhere;color:var(--app-text-control, #1e293b);line-height:1.75;} .report-body{overflow-x:auto;overflow-wrap:anywhere;color:var(--app-text-control, #1e293b);line-height:1.75;}
@@ -58,3 +65,5 @@ input[type=checkbox]{width:15px;height:15px;accent-color:var(--app-primary-text,
table{width:100%;border-collapse:collapse;font-size:12px;}th,td{padding:7px 9px;border:1px solid var(--app-border, #e2e8f0);vertical-align:top;}th{background:var(--app-primary-soft, #eff6ff);color:var(--app-primary-text, #1e3a8a);} table{width:100%;border-collapse:collapse;font-size:12px;}th,td{padding:7px 9px;border:1px solid var(--app-border, #e2e8f0);vertical-align:top;}th{background:var(--app-primary-soft, #eff6ff);color:var(--app-primary-text, #1e3a8a);}
.md-para-gap{height:8px;} .md-para-gap{height:8px;}
} }
.job .human-review-badge{white-space:nowrap;border:1px solid var(--app-primary-border, #bfdbfe);background:var(--app-primary-soft, #eff6ff);color:var(--app-primary-text, #1d4ed8);font-weight:500;}
@@ -90,6 +90,30 @@ describe('AutomationPanelComponent batch deletion', () => {
expect(http.post).not.toHaveBeenCalled(); expect(http.post).not.toHaveBeenCalled();
}); });
it('shows only saved human review badges and removes them on a batch refresh', async () => {
const fixture = TestBed.createComponent(AutomationPanelComponent);
const component = fixture.componentInstance;
await new Promise<void>(resolve => setTimeout(resolve, 0));
component.tab.set('batches');
const batch = { id: 'review-batch', name: '评审批次', status: 'completed', jobs: [
{ session_id: 'reviewed', status: 'completed', human_reviewed: true, case: { name: '已评用例' } },
{ session_id: 'empty', status: 'completed', human_reviewed: false, case: { name: '空白用例' } },
{ session_id: 'legacy', status: 'completed', case: { name: '旧版任务' } }
] };
component.batch.set(batch);
fixture.detectChanges();
const rows = fixture.nativeElement.querySelectorAll('.job');
expect(rows[0].querySelector('.human-review-badge').textContent).toBe('人工已审');
expect(rows[1].querySelector('.human-review-badge')).toBeNull();
expect(rows[2].querySelector('.human-review-badge')).toBeNull();
expect(fixture.nativeElement.textContent).not.toContain('人工未审');
(http.get.and as any).callFake((url: string) => of(url.endsWith('/review-batch')
? { ...batch, jobs: batch.jobs.map(job => ({ ...job, human_reviewed: false })) } : []));
await component.refresh();
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.human-review-badge')).toBeNull();
});
it('confirms deletion and removes the batch from the visible list', async () => { it('confirms deletion and removes the batch from the visible list', async () => {
const fixture = TestBed.createComponent(AutomationPanelComponent); const fixture = TestBed.createComponent(AutomationPanelComponent);
fixture.componentInstance.tab.set('batches'); fixture.componentInstance.tab.set('batches');
@@ -129,7 +153,7 @@ describe('AutomationPanelComponent batch deletion', () => {
{session_id:'task-b',status:'completed',case:{name:'用例 B',goal:'观察主页'}} {session_id:'task-b',status:'completed',case:{name:'用例 B',goal:'观察主页'}}
]}); ]});
fixture.detectChanges(); fixture.detectChanges();
const jobs: HTMLButtonElement[] = [...fixture.nativeElement.querySelectorAll('.job > button')]; const jobs: HTMLButtonElement[] = [...fixture.nativeElement.querySelectorAll('.job-heading > button')];
jobs[0].click(); jobs[1].click(); jobs[0].click(); jobs[1].click();
expect(agent.selectSession.calls.allArgs()).toEqual([['task-a',true],['task-b',true]]); expect(agent.selectSession.calls.allArgs()).toEqual([['task-a',true],['task-b',true]]);
expect(component.tab()).toBe('batches'); expect(component.tab()).toBe('batches');
@@ -152,7 +176,12 @@ describe('AutomationPanelComponent batch deletion', () => {
}); });
fixture.detectChanges(); fixture.detectChanges();
const root = fixture.nativeElement as HTMLElement; const root = fixture.nativeElement as HTMLElement;
expect(root.querySelector('.report-error')?.textContent).toContain('连续 3 次'); const explanation = root.querySelector<HTMLDetailsElement>('app-task-outcome details')!;
expect(explanation.open).toBeFalse();
expect(explanation.querySelector('summary')?.textContent).toContain('连续 3 次');
explanation.querySelector('summary')!.click();
expect(explanation.open).toBeTrue();
expect(explanation.querySelector('.body')?.textContent).toContain('连续 3 次');
expect(root.querySelector('.report-body h2')?.textContent).toBe('结果'); expect(root.querySelector('.report-body h2')?.textContent).toBe('结果');
expect(root.querySelector('.report-body')?.textContent).toContain('第一行'); expect(root.querySelector('.report-body')?.textContent).toContain('第一行');
expect(root.querySelector('.report-body')?.textContent).toContain('第二行'); expect(root.querySelector('.report-body')?.textContent).toContain('第二行');
@@ -1,3 +1,4 @@
import { TaskOutcomeComponent } from '../task-outcome/task-outcome.component';
import { Component, DestroyRef, computed, effect, inject, output, signal } from '@angular/core'; import { Component, DestroyRef, computed, effect, inject, output, signal } from '@angular/core';
import { FavoriteButtonComponent } from '../favorite-button/favorite-button.component'; import { FavoriteButtonComponent } from '../favorite-button/favorite-button.component';
import { ReportMarkdownComponent } from '../report-markdown/report-markdown.component'; import { ReportMarkdownComponent } from '../report-markdown/report-markdown.component';
@@ -24,7 +25,7 @@ interface ArchiveProgress {
} }
@Component({ @Component({
selector: 'app-automation-panel', standalone: true, imports: [FavoriteButtonComponent, ReportMarkdownComponent, CommonModule, FormsModule, TestResultBadgeComponent, TestResultCardComponent, TaskPluginsComponent, EvidenceSourcesComponent], selector: 'app-automation-panel', standalone: true, imports: [TaskOutcomeComponent,FavoriteButtonComponent, ReportMarkdownComponent, CommonModule, FormsModule, TestResultBadgeComponent, TestResultCardComponent, TaskPluginsComponent, EvidenceSourcesComponent],
templateUrl: './automation-panel.component.html', styleUrl: './automation-panel.component.scss' templateUrl: './automation-panel.component.html', styleUrl: './automation-panel.component.scss'
}) })
export class AutomationPanelComponent { export class AutomationPanelComponent {
@@ -18,6 +18,28 @@ describe('历史任务检索', () => {
}); });
afterEach(() => http.verify()); afterEach(() => http.verify());
it('历史错误独立折叠,展开不选择任务且后台刷新保留展开状态', () => {
const fixture = TestBed.createComponent(TaskHistoryComponent);
const row = {session_id:'blocked', name:'前置条件缺失', status:'failed', start_time:100,
error:'[web-blocked] FlashRunner failed: **目标未绑定**\n\n- 未执行删除'};
http.expectOne(r => r.url === '/api/automation/history').flush({total:1,items:[row]});
fixture.detectChanges();
const host = fixture.nativeElement as HTMLElement;
const details = host.querySelector<HTMLDetailsElement>('app-task-outcome details')!;
expect(details.closest('button')).toBeNull();
expect(details.open).toBeFalse();
details.querySelector('summary')!.click();
expect(details.open).toBeTrue();
expect(TestBed.inject(AgentService).selectSession).not.toHaveBeenCalled();
fixture.componentInstance.load(true);
http.expectOne(r => r.url === '/api/automation/history').flush({total:1,items:[{...row}]});
fixture.detectChanges();
expect(host.querySelector('app-task-outcome details')).toBe(details);
expect(details.open).toBeTrue();
expect(details.querySelector('strong')!.textContent).toBe('目标未绑定');
fixture.destroy();
});
it('已终止探索可删除,确认范围后刷新,取消不发请求', () => { it('已终止探索可删除,确认范围后刷新,取消不发请求', () => {
const fixture = TestBed.createComponent(TaskHistoryComponent); const fixture = TestBed.createComponent(TaskHistoryComponent);
const row = {session_id:'stopped', status:'cancelled', exploration_kind:'exploration', exploration_status:'paused', start_time:100}; const row = {session_id:'stopped', status:'cancelled', exploration_kind:'exploration', exploration_status:'paused', start_time:100};
@@ -268,7 +290,7 @@ describe('历史任务检索', () => {
expect(row.getAttribute('title')).toBe('请打开设置首页并核对两个真实分类'); expect(row.getAttribute('title')).toBe('请打开设置首页并核对两个真实分类');
expect(row.textContent).toContain('1.1 打开并核对设置'); expect(row.textContent).toContain('1.1 打开并核对设置');
expect(row.textContent).toContain('总耗时 1分5秒'); expect(row.textContent).toContain('总耗时 1分5秒');
expect(row.textContent).toContain('任务执行超时'); expect(fixture.nativeElement.querySelector('app-task-outcome summary').textContent).toContain('任务执行超时');
expect(fixture.nativeElement.querySelector('.status-failed .history-status').textContent).toContain('失败'); expect(fixture.nativeElement.querySelector('.status-failed .history-status').textContent).toContain('失败');
fixture.destroy(); fixture.destroy();
}); });
@@ -1,3 +1,4 @@
import { TaskOutcomeComponent } from '../task-outcome/task-outcome.component';
import { TaskIdCopyComponent } from '../task-id-copy/task-id-copy.component'; import { TaskIdCopyComponent } from '../task-id-copy/task-id-copy.component';
import { FavoriteButtonComponent } from '../favorite-button/favorite-button.component'; import { FavoriteButtonComponent } from '../favorite-button/favorite-button.component';
import { TaskModeBadgeComponent } from '../task-mode-badge/task-mode-badge.component'; import { TaskModeBadgeComponent } from '../task-mode-badge/task-mode-badge.component';
@@ -9,13 +10,13 @@ import { HttpClient, HttpParams } from '@angular/common/http';
import { AgentService } from '../../services/agent.service'; import { AgentService } from '../../services/agent.service';
@Component({ @Component({
selector:'app-task-history', standalone:true, imports:[CommonModule,FormsModule,TaskIdCopyComponent,TestResultBadgeComponent,TaskModeBadgeComponent,FavoriteButtonComponent], selector:'app-task-history', standalone:true, imports:[TaskOutcomeComponent,CommonModule,FormsModule,TaskIdCopyComponent,TestResultBadgeComponent,TaskModeBadgeComponent,FavoriteButtonComponent],
template:`<section aria-label="历史任务"><h3>历史任务 <small>{{total()}} 条</small><small class="query-status" role="status">{{loading() ? '正在查询…' : ''}}</small></h3> template:`<section aria-label="历史任务"><h3>历史任务 <small>{{total()}} 条</small><small class="query-status" role="status">{{loading() ? '正在查询…' : ''}}</small></h3>
<input aria-label="搜索历史任务" placeholder="搜索名称、目标、任务编号或标签" [(ngModel)]="query" (ngModelChange)="search()"> <input aria-label="搜索历史任务" placeholder="搜索名称、目标、任务编号或标签" [(ngModel)]="query" (ngModelChange)="search()">
<div class="filters"><label>起始日期<input type="date" [(ngModel)]="start" (ngModelChange)="search()"></label><label>结束日期<input type="date" [(ngModel)]="end" (ngModelChange)="search()"></label><label>状态<select [(ngModel)]="status" (ngModelChange)="search()"><option value="">全部状态</option><option value="completed">执行完成</option><option value="failed">失败</option><option value="cancelled">已取消</option><option value="interrupted">中断</option><option value="skipped">跳过</option></select></label></div> <div class="filters"><label>起始日期<input type="date" [(ngModel)]="start" (ngModelChange)="search()"></label><label>结束日期<input type="date" [(ngModel)]="end" (ngModelChange)="search()"></label><label>状态<select [(ngModel)]="status" (ngModelChange)="search()"><option value="">全部状态</option><option value="completed">执行完成</option><option value="failed">失败</option><option value="cancelled">已取消</option><option value="interrupted">中断</option><option value="skipped">跳过</option></select></label></div>
@if(error()){<p role="alert">{{error()}}</p>} @if(error()){<p role="alert">{{error()}}</p>}
@for(group of groups(); track group.key){<details [open]="!collapsed.has(group.key)" (toggle)="toggle(group.key,$event)"><summary>{{group.title}} <small>本页 {{group.rows.length}} 条</small></summary> @for(group of groups(); track group.key){<details [open]="!collapsed.has(group.key)" (toggle)="toggle(group.key,$event)"><summary>{{group.title}} <small>本页 {{group.rows.length}} 条</small></summary>
@for(row of group.rows; track row.session_id){<div class="history-item" [class]="'history-item status-'+row.status"><div class="history-heading"><app-favorite-button [sourceId]="row.session_id" /><button class="row" [class.selected]="agent.currentSessionId() === row.session_id" (click)="agent.selectSession(row.session_id,true)" [title]="row.initial_goal || ''"><strong>{{row.name || row.initial_goal}}</strong><span class="history-meta"><app-task-mode-badge [session]="row" />@if(row.exploration_kind === "validation"){<span>用例复跑 · v{{row.case_version}}</span>}<b class="history-status">{{label(row.status)}}</b><app-test-result-badge [result]="row.test_result" [executionStatus]="row.status" [compact]="true" /> <span>{{row.end_time ? '完成于 '+(row.end_time*1000 | date:'MM-dd HH:mm:ss') : '开始于 '+(row.start_time*1000 | date:'MM-dd HH:mm:ss')}}</span><span>总耗时 {{duration(row.start_time,row.end_time)}}</span></span>@if(row.error){<span class="history-error" [title]="row.error">{{row.error}}</span>}</button></div><app-task-id-copy [taskId]="row.session_id" />@if(agent.canDeleteSession(row)){<button class="delete-history" (click)="remove(row.session_id, row.exploration_kind)" aria-label="删除此历史任务"><span class="material-symbols-outlined">delete</span></button>}</div>} @for(row of group.rows; track row.session_id){<div class="history-item" [class]="'history-item status-'+row.status"><div class="history-heading"><app-favorite-button [sourceId]="row.session_id" /><button class="row" [class.selected]="agent.currentSessionId() === row.session_id" (click)="agent.selectSession(row.session_id,true)" [title]="row.initial_goal || ''"><strong>{{row.name || row.initial_goal}}</strong><span class="history-meta"><app-task-mode-badge [session]="row" />@if(row.exploration_kind === "validation"){<span>用例复跑 · v{{row.case_version}}</span>}<b class="history-status">{{label(row.status)}}</b><app-test-result-badge [result]="row.test_result" [executionStatus]="row.status" [compact]="true" /> <span>{{row.end_time ? '完成于 '+(row.end_time*1000 | date:'MM-dd HH:mm:ss') : '开始于 '+(row.start_time*1000 | date:'MM-dd HH:mm:ss')}}</span><span>总耗时 {{duration(row.start_time,row.end_time)}}</span></span></button></div><app-task-outcome [error]="row.error" [result]="row.test_result" [status]="row.status" /><app-task-id-copy [taskId]="row.session_id" />@if(agent.canDeleteSession(row)){<button class="delete-history" (click)="remove(row.session_id, row.exploration_kind)" aria-label="删除此历史任务"><span class="material-symbols-outlined">delete</span></button>}</div>}
</details>} </details>}
@if(!loading() && !total()){<p>没有符合条件的历史任务。</p>} @if(!loading() && !total()){<p>没有符合条件的历史任务。</p>}
<div class="pager"><button [disabled]="page === 1 || loading()" (click)="page=page-1;load()">上一页</button><span>{{page}} / {{pages()}}</span><button [disabled]="page >= pages() || loading()" (click)="page=page+1;load()">下一页</button></div> <div class="pager"><button [disabled]="page === 1 || loading()" (click)="page=page-1;load()">上一页</button><span>{{page}} / {{pages()}}</span><button [disabled]="page >= pages() || loading()" (click)="page=page+1;load()">下一页</button></div>
@@ -51,7 +52,6 @@ import { AgentService } from '../../services/agent.service';
.status-completed .history-status,.status-success .history-status{background:var(--app-success-soft, #dcfce7);color:var(--app-success-text, #166534)} .status-completed .history-status,.status-success .history-status{background:var(--app-success-soft, #dcfce7);color:var(--app-success-text, #166534)}
.status-failed .history-status{background:var(--app-error-soft, #fee2e2);color:var(--app-error-text, #991b1b)} .status-failed .history-status{background:var(--app-error-soft, #fee2e2);color:var(--app-error-text, #991b1b)}
.status-cancelled .history-status,.status-interrupted .history-status{background:var(--app-warning-soft, #fef3c7);color:var(--app-warning-text, #92400e)} .status-cancelled .history-status,.status-interrupted .history-status{background:var(--app-warning-soft, #fef3c7);color:var(--app-warning-text, #92400e)}
.history-error{font-size:11px;color:var(--app-error-text, #b91c1c);line-height:1.4}
.delete-history{position:absolute;right:5px;top:8px;border:0;padding:3px;color:var(--app-text-muted, #64748b);cursor:pointer;font-size:12px} .delete-history{position:absolute;right:5px;top:8px;border:0;padding:3px;color:var(--app-text-muted, #64748b);cursor:pointer;font-size:12px}
.delete-history .material-symbols-outlined{font-size:16px} .delete-history .material-symbols-outlined{font-size:16px}
.delete-history:hover{color:var(--app-error-text, #b91c1c)} .delete-history:hover{color:var(--app-error-text, #b91c1c)}
@@ -0,0 +1,71 @@
import { TestBed } from '@angular/core/testing';
import { TaskOutcomeComponent } from './task-outcome.component';
describe('任务结束说明', () => {
function create(status = 'failed', error = '[web-example] FlashRunner failed: **前置受阻**\n\n- 未找到目标设备\n- 未执行删除') {
const fixture = TestBed.createComponent(TaskOutcomeComponent);
fixture.componentRef.setInput('status', status);
fixture.componentRef.setInput('error', error);
fixture.detectChanges();
return fixture;
}
it('默认只显示一行摘要,展开后渲染完整 Markdown,不选择外层任务', () => {
const fixture = create();
const host = fixture.nativeElement as HTMLElement;
host.style.width = '280px';
const details = host.querySelector('details')!;
expect(details.open).toBeFalse();
expect(host.querySelector('.summary-text')!.textContent).toBe('前置受阻');
expect(host.textContent).not.toContain('FlashRunner failed');
expect(host.querySelector('button details')).toBeNull();
const select = jasmine.createSpy('外层任务选择');
host.addEventListener('click', select);
host.querySelector('summary')!.click();
expect(details.open).toBeTrue();
expect(select).not.toHaveBeenCalled();
expect(host.querySelector('.body strong')!.textContent).toBe('前置受阻');
expect(host.querySelectorAll('.body li').length).toBe(2);
fixture.componentRef.setInput('error', '[web-example] FlashRunner failed: **更新说明**\n\n- 已有记录');
fixture.detectChanges();
expect(host.querySelector('details')).toBe(details);
expect(details.open).toBeTrue();
host.querySelector('summary')!.click();
expect(details.open).toBeFalse();
});
it('优先采用已有结论,同时保留不同的执行错误和依据', () => {
const fixture = create();
fixture.componentRef.setInput('result', {status:'blocked', reason:'目标设备未绑定', evidence:['截图中仅有 **添加设备**']});
fixture.detectChanges();
const host = fixture.nativeElement as HTMLElement;
expect(host.querySelector('.summary-text')!.textContent).toBe('目标设备未绑定');
expect(host.textContent).toContain('未执行删除');
expect(host.textContent).toContain('已记录依据');
expect(host.textContent).toContain('添加设备');
});
it('完成但结论阻塞也展示说明,运行中和无内容时不展示', () => {
const fixture = create('running');
expect(fixture.nativeElement.querySelector('details')).toBeNull();
fixture.componentRef.setInput('status', 'completed');
fixture.componentRef.setInput('error', '');
fixture.componentRef.setInput('result', {status:'blocked',reason:'缺少预置数据',evidence:[]});
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.summary-text').textContent).toBe('缺少预置数据');
fixture.componentRef.setInput('result', null);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('details')).toBeNull();
});
it('不重复相同说明,不执行错误正文中的 HTML 或危险链接', () => {
const error = '**失败**\n<script>alert(1)</script>\n[打开](javascript:alert(1))';
const fixture = create('failed', error);
fixture.componentRef.setInput('result', {status:'blocked',reason:error,evidence:[]});
fixture.detectChanges();
const host = fixture.nativeElement as HTMLElement;
expect(host.querySelectorAll('app-report-markdown').length).toBe(1);
expect(host.querySelector('script')).toBeNull();
expect(host.querySelector('a[href^="javascript:"]')).toBeNull();
});
});
@@ -0,0 +1,62 @@
import { Component, computed, input } from '@angular/core';
import { CaseTestResult } from '../../core/models/session.model';
import { ReportMarkdownComponent } from '../report-markdown/report-markdown.component';
/** Read-only presentation of persisted results; opening it never requests a model. */
@Component({
selector: 'app-task-outcome',
standalone: true,
imports: [ReportMarkdownComponent],
template: `@if (visible()) {
<details (click)="$event.stopPropagation()" (keydown)="$event.stopPropagation()">
<summary aria-label="任务说明,展开或收起">
<span class="chevron" aria-hidden="true">›</span>
<span class="summary-text">{{summary()}}</span>
<span class="hint">详情</span>
</summary>
<div class="body">
@if (reason()) { <app-report-markdown [content]="reason()" /> }
@if (message() && message() !== reason()) {
@if (reason()) { <p class="label">执行记录</p> }
<app-report-markdown [content]="message()" />
}
@if (result()?.evidence?.length) {
<p class="label">已记录依据</p>
<ul>@for (item of result()!.evidence; track $index) {<li><app-report-markdown [content]="item" /></li>}</ul>
}
</div>
</details>
}`,
styles: [`
:host { display: block; min-width: 0; max-width: 100%; }
details { margin: 4px 0; font-size: 12px; color: var(--app-text-secondary, #475569); }
summary { display: flex; align-items: center; gap: 6px; min-width: 0; min-height: 26px; cursor: pointer; list-style: none; border-radius: 4px; }
summary::-webkit-details-marker { display: none; }
summary:focus-visible { outline: 2px solid var(--app-primary-text, #1a73e8); outline-offset: 2px; }
.summary-text { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.chevron { flex: none; font-size: 18px; line-height: 20px; }
details[open] .chevron { transform: rotate(90deg); }
.hint { flex: none; font-size: 11px; color: var(--app-text-muted, #64748b); }
.body { max-height: 280px; overflow: auto; padding: 8px 10px; margin-top: 4px; border: 1px solid var(--app-border, #e2e8f0); border-radius: 6px; background: var(--app-surface, #fff); cursor: text; }
.label { font-size: 11px; color: var(--app-text-muted, #64748b); margin: 10px 0 4px; }
ul { margin: 4px 0; padding-left: 20px; }
`]
})
export class TaskOutcomeComponent {
error = input<string | null>();
result = input<CaseTestResult | null>();
status = input('');
// Strip only the known SDK envelope, preserving the actual explanation and diagnostics.
private explanation(value: string | null | undefined): string {
return (value || '').replace(/^\[[^\]\r\n]+\]\s*FlashRunner failed:\s*/, '').trim();
}
message = computed(() => this.explanation(this.error()));
reason = computed(() => this.explanation(this.result()?.reason));
visible = computed(() => ['completed', 'success', 'failed', 'cancelled', 'interrupted', 'skipped'].includes(this.status())
&& !!(this.message() || (this.result()?.status !== 'passed' && this.reason())));
summary = computed(() => {
const line = (this.reason() || this.message()).split(/\r?\n/).find(part => part.trim()) || '';
return line.replace(/^\s{0,3}(?:#{1,6}\s+|[-*+]\s+|>\s*)/, '')
.replace(/!?\[([^\]]+)\]\([^)]*\)/g, '$1').replace(/(\*\*|__|~~|`)/g, '');
});
}
+45 -10
View File
@@ -15,6 +15,7 @@
"""UI Launch and Web Console CLI Command (artemis ui).""" """UI Launch and Web Console CLI Command (artemis ui)."""
import logging import logging
import os
from pathlib import Path from pathlib import Path
import re import re
import shutil import shutil
@@ -34,6 +35,11 @@ import typer
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _launch_text(english: str, chinese: str) -> str:
"""Honor the language selected by the Windows launch wrapper."""
return chinese if os.environ.get("ARTEMIS_LAUNCH_LANG") == "zh-CN" else english
def running_via_console_script_shim() -> bool: def running_via_console_script_shim() -> bool:
"""Return True when this CLI was launched through a console-script ``.exe`` shim. """Return True when this CLI was launched through a console-script ``.exe`` shim.
@@ -308,7 +314,12 @@ def ui_command(
) )
if restart: if restart:
console.print(f" [yellow]🔄 Restart requested. Recycling port {port}...[/yellow]") console.print(
_launch_text(
f" [yellow]🔄 Restart requested. Recycling port {port}...[/yellow]",
f" [yellow]🔄 已请求重启,正在释放端口 {port}...[/yellow]",
)
)
load_session_reconciler() load_session_reconciler()
stop_server(port=port, timeout=12.0) stop_server(port=port, timeout=12.0)
elif is_port_in_use(port): elif is_port_in_use(port):
@@ -316,19 +327,39 @@ def ui_command(
pid_str = f" (PID: {', '.join(map(str, pids))})" if pids else "" pid_str = f" (PID: {', '.join(map(str, pids))})" if pids else ""
if sys.stdin.isatty(): if sys.stdin.isatty():
console.print( console.print(
f"\n [yellow]⚠ Port {port} is already in use by an active Artemis server{pid_str}.[/yellow]" _launch_text(
f"\n [yellow]⚠ Port {port} is already in use by an active Artemis server{pid_str}.[/yellow]",
f"\n [yellow]⚠ 检测到 ARTEMIS 服务正在运行,占用端口 {port}{pid_str}。[/yellow]",
)
) )
console.print(" [dim]Choose an action:[/dim]") console.print(
console.print(" [bold]1[/bold] Open browser (default)") _launch_text(" [dim]Choose an action:[/dim]", " [dim]请选择操作:[/dim]")
console.print(" [bold]2[/bold] Restart server (stop existing and start here)") )
console.print(" [bold]3[/bold] Cancel") console.print(
_launch_text(
" [bold]1[/bold] Open browser (default)",
" [bold]1[/bold] 打开浏览器(默认)",
)
)
console.print(
_launch_text(
" [bold]2[/bold] Restart server (stop existing and start here)",
" [bold]2[/bold] 重启服务(停止现有服务并在此启动,会中断正在运行的任务)",
)
)
console.print(_launch_text(" [bold]3[/bold] Cancel", " [bold]3[/bold] 取消"))
try: try:
choice = input(" Select [1-3, default 1]: ").strip() choice = input(
_launch_text(" Select [1-3, default 1]: ", " 请输入选项 [1-3,默认 1]:")
).strip()
except (KeyboardInterrupt, EOFError): except (KeyboardInterrupt, EOFError):
choice = "1" choice = "1"
if choice == "2": if choice == "2":
console.print( console.print(
f"\n [yellow]🔄 Terminating previous Artemis instance on port {port}...[/yellow]" _launch_text(
f"\n [yellow]🔄 Terminating previous Artemis instance on port {port}...[/yellow]",
f"\n [yellow]🔄 正在停止端口 {port} 上的现有 ARTEMIS 服务...[/yellow]",
)
) )
load_session_reconciler() load_session_reconciler()
stop_server(port=port, timeout=12.0) stop_server(port=port, timeout=12.0)
@@ -340,8 +371,12 @@ def ui_command(
return return
else: else:
console.print( console.print(
f"\n [yellow]⚠ Port {port} is already in use by Artemis{pid_str}. " _launch_text(
f"Use '[bold]artemis restart[/bold]' to restart.[/yellow]\n" f"\n [yellow]⚠ Port {port} is already in use by Artemis{pid_str}. "
"Use '[bold]artemis restart[/bold]' to restart.[/yellow]\n",
f"\n [yellow]⚠ 检测到 ARTEMIS 服务正在运行,占用端口 {port}{pid_str}。"
"如需重启,请运行 '[bold]artemis restart[/bold]'。[/yellow]\n",
)
) )
if open_browser: if open_browser:
webbrowser.open(local_url) webbrowser.open(local_url)
+53
View File
@@ -0,0 +1,53 @@
"""Bounded fingerprints of received argument deltas, without retaining text."""
import hashlib
PREFIX_BYTES = 4096
MAX_PREFIXES = 16
MAX_TRACKED_TOOLS = 64
class ArgumentFingerprint:
def __init__(self):
self.characters = 0
self.bytes = 0
self.deltas = 0
self.encoding_valid = True
self.digest = hashlib.sha256()
self.prefixes: list[dict] = []
def observe(self, arguments: str) -> None:
self.characters += len(arguments)
if not arguments:
return
self.deltas += 1
if not self.encoding_valid:
return
try:
encoded = arguments.encode("utf-8")
except UnicodeEncodeError:
# Diagnostics must not repair, reject or otherwise alter the stream.
self.encoding_valid = False
return
offset = 0
while offset < len(encoded) and len(self.prefixes) < MAX_PREFIXES:
size = min(PREFIX_BYTES - self.bytes % PREFIX_BYTES, len(encoded) - offset)
self.digest.update(encoded[offset : offset + size])
self.bytes += size
offset += size
if self.bytes % PREFIX_BYTES == 0:
self.prefixes.append({"bytes": self.bytes, "sha256": self.digest.hexdigest()})
if offset < len(encoded):
self.digest.update(encoded[offset:])
self.bytes += len(encoded) - offset
def snapshot(self) -> dict:
return {
"argument_chars": self.characters,
"argument_bytes": self.bytes if self.encoding_valid else None,
"argument_sha256": self.digest.hexdigest() if self.encoding_valid else None,
"argument_deltas": self.deltas,
"argument_prefixes": [dict(prefix) for prefix in self.prefixes],
"argument_encoding_valid": self.encoding_valid,
}
+35 -10
View File
@@ -13,6 +13,7 @@ from uuid import uuid4
from PIL import Image from PIL import Image
from artemis.data_engine.context_vars import CURRENT_NODE_NAME, CURRENT_TRACE_ID from artemis.data_engine.context_vars import CURRENT_NODE_NAME, CURRENT_TRACE_ID
from artemis.llm.argument_fingerprint import ArgumentFingerprint, MAX_TRACKED_TOOLS
from artemis.llm.final_review import ACTIVE_FINAL_REVIEW, FinalReviewBudgetExceeded from artemis.llm.final_review import ACTIVE_FINAL_REVIEW, FinalReviewBudgetExceeded
from artemis.llm.reliability import LLMExhaustedError from artemis.llm.reliability import LLMExhaustedError
from artemis.llm.usage import usage_from_message from artemis.llm.usage import usage_from_message
@@ -107,6 +108,7 @@ class RequestAttempt:
self.clock = time.monotonic() self.clock = time.monotonic()
self.last_publish = self.clock self.last_publish = self.clock
self.tools: dict[int | str, dict] = {} self.tools: dict[int | str, dict] = {}
self.tool_fingerprints: dict[int | str, ArgumentFingerprint] = {}
self.review = ACTIVE_FINAL_REVIEW.get() self.review = ACTIVE_FINAL_REVIEW.get()
if self.review: if self.review:
self.review.request_started(request_id, attempt) self.review.request_started(request_id, attempt)
@@ -126,6 +128,9 @@ class RequestAttempt:
"usage": None, "usage": None,
"output_chunks": 0, "output_chunks": 0,
"tool_argument_chars": 0, "tool_argument_chars": 0,
"tool_observation_layer": "langchain_tool_call_chunks",
"argument_fingerprint_version": 1,
"tool_stats_truncated": False,
"tools": [], "tools": [],
"step_id": str(self.step_id) if self.step_id else None, "step_id": str(self.step_id) if self.step_id else None,
"parent_trace_id": str(self.parent_id) if self.parent_id else None, "parent_trace_id": str(self.parent_id) if self.parent_id else None,
@@ -167,9 +172,10 @@ class RequestAttempt:
self.data.update( self.data.update(
headers_at=time.time(), headers_at=time.time(),
http_status=response.status_code, http_status=response.status_code,
server_request_id=_identifier( server_request_id=_identifier(response.headers.get("x-oneapi-request-id"))
response.headers.get("x-request-id") or response.headers.get("request-id") or _identifier(response.headers.get("x-request-id"))
), or _identifier(response.headers.get("request-id")),
response_x_request_id=_identifier(response.headers.get("x-request-id")),
server_trace_id=_identifier(response.headers.get("x-trace-id")), server_trace_id=_identifier(response.headers.get("x-trace-id")),
phase="waiting_output", phase="waiting_output",
) )
@@ -196,18 +202,31 @@ class RequestAttempt:
self.data["usage"] = usage self.data["usage"] = usage
tool_chunks = getattr(chunk, "tool_call_chunks", None) or [] tool_chunks = getattr(chunk, "tool_call_chunks", None) or []
# Responses exposes the same argument delta in content and tool_call_chunks. # Responses exposes the same argument delta in content and tool_call_chunks.
# Count only the latter; retain lengths/names, never argument bodies. # Count only the latter; retain bounded fingerprints, never argument bodies.
for tool in tool_chunks: for tool in tool_chunks:
arguments = tool.get("args")
if isinstance(arguments, str):
self.data["tool_argument_chars"] += len(arguments)
index = tool.get("index") index = tool.get("index")
key = index if index is not None else tool.get("id") or "unknown" call_id = _identifier(tool.get("id"))
stats = self.tools.setdefault(key, {"name": None, "argument_chars": 0}) key = index if index is not None else call_id or "unknown"
if key not in self.tools and len(self.tools) >= MAX_TRACKED_TOOLS:
self.data["tool_stats_truncated"] = True
continue
stats = self.tools.setdefault(key, {"name": None})
if call_id:
stats["call_id"] = call_id
if index is not None:
stats["chunk_index"] = index
name = tool.get("name") name = tool.get("name")
if isinstance(name, str) and re.fullmatch(r"[\w.:-]{1,128}", name): if isinstance(name, str) and re.fullmatch(r"[\w.:-]{1,128}", name):
stats["name"] = name stats["name"] = name
arguments = tool.get("args") fingerprint = self.tool_fingerprints.get(key)
if fingerprint is None:
fingerprint = self.tool_fingerprints[key] = ArgumentFingerprint()
if isinstance(arguments, str): if isinstance(arguments, str):
stats["argument_chars"] += len(arguments) fingerprint.observe(arguments)
self.data["tool_argument_chars"] += len(arguments) stats.update(fingerprint.snapshot())
self.data["tools"] = list(self.tools.values()) self.data["tools"] = list(self.tools.values())
content = getattr(chunk, "content", None) content = getattr(chunk, "content", None)
substantive = isinstance(content, str) and bool(content) substantive = isinstance(content, str) and bool(content)
@@ -252,7 +271,9 @@ class RequestAttempt:
if isinstance(status, int): if isinstance(status, int):
self.data["http_status"] = status self.data["http_status"] = status
if request_id := _identifier(getattr(error, "request_id", None)): if request_id := _identifier(getattr(error, "request_id", None)):
self.data["server_request_id"] = request_id self.data["error_request_id"] = request_id
if not self.data.get("server_request_id"):
self.data["server_request_id"] = request_id
if category == "timeout": if category == "timeout":
self.data.setdefault("timeout_scope", "transport") self.data.setdefault("timeout_scope", "transport")
if response is not None: if response is not None:
@@ -301,6 +322,7 @@ async def observe_request_headers(request):
if attempt is None: if attempt is None:
return return
request.headers.setdefault("X-Client-Request-Id", attempt.data["request_id"]) request.headers.setdefault("X-Client-Request-Id", attempt.data["request_id"])
request.headers.setdefault("X-Client-Attempt-Id", attempt.data["attempt_id"])
routing = {} routing = {}
for name in ("thread-id", "thread_id", "session-id", "session_id"): for name in ("thread-id", "thread_id", "session-id", "session_id"):
value = request.headers.get(name) value = request.headers.get(name)
@@ -309,9 +331,12 @@ async def observe_request_headers(request):
attempt.data.update( attempt.data.update(
request_sent_at=time.time(), request_sent_at=time.time(),
client_request_id=_identifier(request.headers.get("X-Client-Request-Id")), client_request_id=_identifier(request.headers.get("X-Client-Request-Id")),
client_attempt_id=_identifier(request.headers.get("X-Client-Attempt-Id")),
routing_header_fingerprints=routing, routing_header_fingerprints=routing,
client_request_id_matches=request.headers.get("X-Client-Request-Id") client_request_id_matches=request.headers.get("X-Client-Request-Id")
== attempt.data["request_id"], == attempt.data["request_id"],
client_attempt_id_matches=request.headers.get("X-Client-Attempt-Id")
== attempt.data["attempt_id"],
) )
attempt.publish() attempt.publish()
+1 -1
View File
@@ -153,7 +153,7 @@ class LLMPermanentError(LLMCallError):
class LLMExhaustedError(LLMCallError): class LLMExhaustedError(LLMCallError):
"""Retryable failures exhausted every attempt and the pause deadline.""" """Retryable failures exhausted attempts and any applicable pause deadline."""
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+1 -1
View File
@@ -23,7 +23,7 @@ class ToolArgumentOverflow(ValueError):
"limit_chars": SMALL_ACTION_ARGUMENT_LIMIT, "limit_chars": SMALL_ACTION_ARGUMENT_LIMIT,
} }
super().__init__( super().__init__(
f"模型生成的 {name} 参数异常膨胀({characters} 字符," f"收到的 {name} 参数流超出安全限制({characters} 字符,"
f"保护上限 {SMALL_ACTION_ARGUMENT_LIMIT});已关闭输出流,未执行本次动作。" f"保护上限 {SMALL_ACTION_ARGUMENT_LIMIT});已关闭输出流,未执行本次动作。"
) )
+57
View File
@@ -0,0 +1,57 @@
"""Private launch barrier for sdk.run_process; stdlib-only under Python -I."""
import json
import os
from pathlib import Path
import signal
import subprocess
import sys
import threading
import time
def watch_parent(expected: int):
while True:
if os.getppid() != expected:
os.killpg(os.getpid(), signal.SIGKILL)
time.sleep(0.1)
def main():
status = Path(sys.argv[1])
if os.name != "nt":
threading.Thread(target=watch_parent, args=(int(sys.argv[2]),), daemon=True).start()
# Until the parent owns our job/session and sends this line, no business
# process can start. Buffering is local; tool stdin uses a separate pipe.
control = sys.stdin.buffer.readline(1024 * 1024 + 1)
if not control.endswith(b"\n") or len(control) > 1024 * 1024:
return
request = json.loads(control)
size = request["input_size"]
if type(size) is not int or not 0 <= size <= 64 * 1024 * 1024:
return
content = sys.stdin.buffer.read(size) if request["has_input"] else None
if content is not None and len(content) != size:
return
try:
result = subprocess.run(
request["argv"],
shell=False,
stdin=None if request["has_input"] else subprocess.DEVNULL,
input=content,
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0,
check=False,
)
response = {"returncode": result.returncode}
except OSError:
response = {"launch_failed": True}
pending = status.with_suffix(".tmp")
pending.write_text(json.dumps(response), encoding="utf-8")
pending.replace(status)
# Remain the session/group leader until the owner reclaims the whole tree.
while True:
time.sleep(60)
if __name__ == "__main__":
main()
+40
View File
@@ -0,0 +1,40 @@
"""Synthetic tool used only by the opt-in SDK fault suite. No device/network I/O."""
import json
import os
from pathlib import Path
import subprocess
import sys
import time
def main():
mode = sys.argv[1]
if mode == "sleep":
time.sleep(60)
elif mode == "tree":
child = subprocess.Popen(
[sys.executable, "-I", "-B", __file__, "sleep"],
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0,
)
Path(sys.argv[2]).write_text(json.dumps({"pid": child.pid}), encoding="utf-8")
if sys.argv[3] == "linger":
time.sleep(60)
elif mode == "echo":
data = sys.stdin.buffer.read()
sys.stdout.buffer.write(data)
sys.stderr.buffer.write(b"synthetic-stderr")
elif mode == "overflow":
while True:
os.write(1, b"x" * 65536)
elif mode == "failure":
raise SystemExit(7)
else:
raise SystemExit(2)
if __name__ == "__main__":
main()
+266
View File
@@ -0,0 +1,266 @@
"""Evidence-bound market materials. Declarations still need source/rights review."""
from __future__ import annotations
from collections.abc import Iterator
from contextlib import contextmanager
import hashlib
import json
from pathlib import Path, PurePosixPath
import stat
import tempfile
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
from artemis.config.settings import settings
from artemis.plugins.installer import _extract
from artemis.plugins.models import PluginManifest
REPORT_NAME = "MARKET.json"
CHECKS = (
"installation",
"tool_contract",
"failure_partial",
"timeout_cancel",
"process_tree",
"file_binding",
"resource_conflicts",
"evidence_export",
"history_uninstall",
"secret_redaction",
"real_task",
"source_review",
)
FORBIDDEN_DIRECTORIES = {".git", ".ruff_cache", ".pytest_cache", "__pycache__", ".venv"}
class SubmissionModel(BaseModel):
model_config = ConfigDict(extra="forbid", strict=True)
class Component(SubmissionModel):
name: str = Field(min_length=1)
paths: list[str] = Field(min_length=1)
license: str = Field(min_length=1)
permission: Literal["permitted", "local_only", "unknown"]
license_file: str
source_file: str
class CheckRecord(SubmissionModel):
status: Literal["passed", "failed", "unverified", "not_applicable"]
evidence_file: str = ""
evidence_sha256: str = ""
notes: str = ""
class Traits(SubmissionModel):
uses_subprocesses: bool | None
file_inputs_for_writes: bool | None
class MarketSubmission(SubmissionModel):
schema_version: Literal[1]
plugin_id: str
plugin_version: str
content_sha256: str = Field(pattern=r"^[a-f0-9]{64}$")
traits: Traits
components: list[Component] = Field(min_length=1)
checks: dict[str, CheckRecord]
@contextmanager
def package_directory(path: Path) -> Iterator[tuple[Path, str | None]]:
"""Inspect a directory or safely unpack a ZIP without installing or hooks."""
path = path.resolve(strict=True)
if path.is_dir():
yield path, None
return
if not path.is_file():
raise ValueError("检查输入必须是普通插件目录或 ZIP 文件")
with path.open("rb") as stream:
data = stream.read(settings.ARTEMIS_PLUGIN_DOWNLOAD_MAX_BYTES + 1)
if len(data) > settings.ARTEMIS_PLUGIN_DOWNLOAD_MAX_BYTES:
raise ValueError("插件 ZIP 超过下载大小限制")
with tempfile.TemporaryDirectory(prefix="artemis-plugin-check-") as temporary:
directory = Path(temporary)
_extract(data, directory)
archive_sha256 = hashlib.sha256(data).hexdigest()
yield directory, archive_sha256
# Re-read only to reject changes; never relabel the inspected snapshot.
current_digest = hashlib.sha256()
current_size = 0
with path.open("rb") as stream:
while chunk := stream.read(1024 * 1024):
current_size += len(chunk)
if current_size > settings.ARTEMIS_PLUGIN_DOWNLOAD_MAX_BYTES:
raise ValueError("检查期间 ZIP 发生变化或超过大小限制")
current_digest.update(chunk)
if current_digest.hexdigest() != archive_sha256:
raise ValueError("检查期间 ZIP 发生变化,检查记录失效")
def _relative(value: str, *, root_allowed: bool = False) -> str:
if root_allowed and value == ".":
return value
path = PurePosixPath(value)
if (
not value
or "\\" in value
or ":" in value
or path.is_absolute()
or any(p in {".", ".."} for p in value.split("/"))
or any(ord(c) < 32 for c in value)
or str(path) != value
):
raise ValueError("市场材料必须使用包内普通相对路径")
return value
def package_files(directory: Path) -> dict[str, str]:
"""Hash every distributable file, excluding only the self-referential report."""
directory = directory.resolve(strict=True)
files = {}
seen = set()
size = 0
for path in sorted(directory.rglob("*")):
relative = path.relative_to(directory)
name = relative.as_posix()
if path.is_symlink() or path.is_junction() or not path.resolve().is_relative_to(directory):
raise ValueError("市场包不能包含链接")
if name.casefold() in seen:
raise ValueError("市场包存在大小写冲突的路径")
seen.add(name.casefold())
if len(seen) > 20000:
raise ValueError("市场包超过文件数量限制")
if any(p.casefold() in FORBIDDEN_DIRECTORIES for p in relative.parts):
raise ValueError(f"市场包包含缓存、依赖环境或版本库目录:{name}")
if path.is_dir():
continue
info = path.stat()
if not stat.S_ISREG(info.st_mode):
raise ValueError("市场包只能包含普通文件")
if name == REPORT_NAME:
continue
lower = path.name.casefold()
if (
lower.endswith((".pyc", ".pyo"))
or lower == ".env"
or (
lower.startswith(".env.")
and not lower.endswith((".example", ".sample", ".template"))
)
):
raise ValueError(f"市场包不能包含缓存或个人环境配置:{name}")
_relative(name)
digest = hashlib.sha256()
read = 0
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
read += len(chunk)
size += len(chunk)
if size > settings.ARTEMIS_PLUGIN_EXTRACT_MAX_BYTES:
raise ValueError("市场包超过展开大小或文件数量限制")
digest.update(chunk)
if read != info.st_size:
raise ValueError("文件校验期间大小发生变化")
files[name] = digest.hexdigest()
return files
def content_fingerprint(files: dict[str, str]) -> str:
encoded = json.dumps(files, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
def draft_submission(manifest: PluginManifest, files: dict[str, str]) -> dict:
"""Drafts never fabricate passed results or distribution permission."""
return {
"schema_version": 1,
"plugin_id": manifest.id,
"plugin_version": manifest.version,
"content_sha256": content_fingerprint(files),
"traits": {"uses_subprocesses": None, "file_inputs_for_writes": None},
"components": [
{
"name": "请拆分原始代码、适配层与第三方资料",
"paths": ["."],
"license": manifest.license or "待补充",
"permission": "unknown",
"license_file": "LICENSE",
"source_file": "SOURCE.md",
}
],
"checks": {
name: {"status": "unverified", "evidence_file": "", "evidence_sha256": "", "notes": ""}
for name in CHECKS
},
}
def _document(directory: Path, files: dict[str, str], value: str) -> str:
name = _relative(value)
if name not in files or not 0 < (directory / name).stat().st_size <= 1024 * 1024:
raise ValueError("许可、来源与验证记录必须是非空包内文档,且不超过 1 MiB")
return name
def validate_submission(
directory: Path, manifest: PluginManifest, files: dict[str, str]
) -> MarketSubmission:
path = directory / REPORT_NAME
if not path.is_file() or path.stat().st_size > 1024 * 1024:
raise ValueError("市场检查需要包内 MARKET.json;先生成草稿并补充真实记录")
report = MarketSubmission.model_validate_json(path.read_text("utf-8"))
if (report.plugin_id, report.plugin_version) != (manifest.id, manifest.version):
raise ValueError("市场材料的插件 ID/版本与清单不一致")
if report.content_sha256 != content_fingerprint(files):
raise ValueError("包内容已变化,旧的市场验证记录失效")
if not manifest.license.strip():
raise ValueError("市场清单必须声明许可证或具体的 LicenseRef")
if None in (report.traits.uses_subprocesses, report.traits.file_inputs_for_writes):
raise ValueError("必须明确是否使用子进程、是否将文件用于写操作")
covered = set()
for component in report.components:
if component.permission != "permitted" or component.license.strip().casefold() in {
"",
"unknown",
"todo",
"待补充",
}:
raise ValueError(f"组件未明确允许市场分发或未提供许可证:{component.name}")
_document(directory, files, component.license_file)
_document(directory, files, component.source_file)
for scope in component.paths:
scope = _relative(scope, root_allowed=True)
selected = {
name
for name in files
if scope == "." or name == scope or name.startswith(scope + "/")
}
if not selected:
raise ValueError("来源组件声明了不存在的文件范围")
covered.update(selected)
if set(files) - covered:
raise ValueError("市场包有文件未记录来源与分发许可")
if set(report.checks) != set(CHECKS):
raise ValueError("市场验证项目缺失或包含未知项目")
optional = {
"tool_contract": manifest.server is None,
"timeout_cancel": manifest.server is None,
"process_tree": not report.traits.uses_subprocesses,
"file_binding": not report.traits.file_inputs_for_writes,
"resource_conflicts": not manifest.resources,
}
for name, record in report.checks.items():
if not record.notes.strip():
raise ValueError(f"验证项目必须说明实际范围或不适用原因:{name}")
if record.status == "not_applicable" and optional.get(name, False):
continue
if record.status != "passed":
raise ValueError(f"市场验证项目未通过:{name}")
evidence = _document(directory, files, record.evidence_file)
if files[evidence] != record.evidence_sha256:
raise ValueError(f"验证记录 SHA256 不一致:{name}")
return report
+39 -2
View File
@@ -4,8 +4,8 @@ import ctypes
from ctypes import wintypes from ctypes import wintypes
class CaptureProcessJob: class ProcessJob:
"""A non-inheritable job handle: Windows kills captures when its owner exits.""" """A non-inheritable job handle that owns a Windows process tree."""
def __init__(self): def __init__(self):
class BasicLimits(ctypes.Structure): class BasicLimits(ctypes.Structure):
@@ -54,6 +54,12 @@ class CaptureProcessJob:
), ),
("OpenProcess", [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD], wintypes.HANDLE), ("OpenProcess", [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD], wintypes.HANDLE),
("AssignProcessToJobObject", [wintypes.HANDLE, wintypes.HANDLE], wintypes.BOOL), ("AssignProcessToJobObject", [wintypes.HANDLE, wintypes.HANDLE], wintypes.BOOL),
("TerminateJobObject", [wintypes.HANDLE, wintypes.UINT], wintypes.BOOL),
(
"QueryInformationJobObject",
[wintypes.HANDLE, ctypes.c_int, wintypes.LPVOID, wintypes.DWORD, wintypes.LPVOID],
wintypes.BOOL,
),
("CloseHandle", [wintypes.HANDLE], wintypes.BOOL), ("CloseHandle", [wintypes.HANDLE], wintypes.BOOL),
): ):
function = getattr(self._api, name) function = getattr(self._api, name)
@@ -84,3 +90,34 @@ class CaptureProcessJob:
if self._handle: if self._handle:
self._api.CloseHandle(self._handle) self._api.CloseHandle(self._handle)
self._handle = None self._handle = None
def terminate(self):
if self._handle and not self._api.TerminateJobObject(self._handle, 1):
raise ctypes.WinError(ctypes.get_last_error())
def active_processes(self) -> int:
if not self._handle:
return 0
class Accounting(ctypes.Structure):
_fields_ = [
("user_time", ctypes.c_int64),
("kernel_time", ctypes.c_int64),
("period_user_time", ctypes.c_int64),
("period_kernel_time", ctypes.c_int64),
("page_faults", wintypes.DWORD),
("total", wintypes.DWORD),
("active", wintypes.DWORD),
("terminated", wintypes.DWORD),
]
accounting = Accounting()
if not self._api.QueryInformationJobObject(
self._handle, 1, ctypes.byref(accounting), ctypes.sizeof(accounting), None
):
raise ctypes.WinError(ctypes.get_last_error())
return accounting.active
# Keep the recording implementation and its callers on the same ownership primitive.
CaptureProcessJob = ProcessJob
+347
View File
@@ -0,0 +1,347 @@
"""Plugin author helpers; no scheduler, device actions or implicit retries.
Use snapshot_file before approving a write and consume its immutable bytes, not the
original pathname. run_process owns the tool and its descendants on every exit.
"""
from __future__ import annotations
import asyncio
from collections.abc import Iterator, Mapping, Sequence
from dataclasses import dataclass, field
import hashlib
import json
import math
import os
from pathlib import Path
import re
import signal
import stat
import subprocess
import sys
import tempfile
from artemis.plugins.recording_process import ProcessJob
MAX_INPUT_BYTES = 64 * 1024 * 1024
DEFAULT_OUTPUT_BYTES = 4 * 1024 * 1024
class FileVerificationError(ValueError):
"""The input cannot be bound to its explicitly approved digest."""
@dataclass(frozen=True)
class VerifiedFile:
"""Bounded, immutable input. No original path is exposed for reopening."""
data: bytes = field(repr=False)
sha256: str = field(init=False)
def __post_init__(self):
if not isinstance(self.data, bytes):
raise TypeError("快照内容必须是不可变 bytes")
object.__setattr__(self, "sha256", hashlib.sha256(self.data).hexdigest())
@property
def size(self) -> int:
return len(self.data)
def chunks(self, size: int = 256 * 1024) -> Iterator[memoryview]:
if type(size) is not int or size <= 0:
raise ValueError("分块大小必须是正整数")
content = memoryview(self.data)
for offset in range(0, len(content), size):
yield content[offset : offset + size]
def snapshot_file(
path: Path | str, expected_sha256: str, *, max_bytes: int = MAX_INPUT_BYTES
) -> VerifiedFile:
"""Copy once, then verify the exact bytes that will be consumed by the caller.
The source must already be authorized by the plugin. This is not a path or
permission sandbox. Memory use is bounded by max_bytes plus one read byte.
"""
if type(max_bytes) is not int or max_bytes <= 0:
raise ValueError("文件大小上限必须是正整数")
if not isinstance(expected_sha256, str) or not re.fullmatch(
r"[a-fA-F0-9]{64}", expected_sha256
):
raise FileVerificationError("必须明确提供已批准的 SHA256")
source = Path(path)
if source.is_symlink() or not stat.S_ISREG(source.lstat().st_mode):
raise FileVerificationError("输入必须是普通文件,不能是链接或设备")
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
descriptor = os.open(source, flags)
with os.fdopen(descriptor, "rb") as handle:
info = os.fstat(handle.fileno())
if not stat.S_ISREG(info.st_mode) or info.st_size > max_bytes:
raise FileVerificationError("输入不是普通文件或超过大小上限")
data = handle.read(max_bytes + 1)
if len(data) > max_bytes:
raise FileVerificationError("输入超过大小上限")
snapshot = VerifiedFile(data)
if snapshot.sha256 != expected_sha256.lower():
raise FileVerificationError("输入 SHA256 与批准值不一致;不得开始写操作")
return snapshot
class ManagedProcessError(RuntimeError):
"""Stable failure metadata. Do not infer a business verdict or retry a write."""
def __init__(self, code: str, *, may_have_started: bool = True):
super().__init__(code)
self.error_code = code
self.may_have_started = may_have_started
class ProcessTimedOut(ManagedProcessError, TimeoutError):
pass
@dataclass(frozen=True)
class ProcessResult:
returncode: int
stdout: bytes = field(repr=False)
stderr: bytes = field(repr=False)
async def _cleanup(process: asyncio.subprocess.Process, job: ProcessJob | None) -> None:
error = None
try:
if job is not None:
job.terminate()
async with asyncio.timeout(5):
while job.active_processes():
await asyncio.sleep(0.02)
elif os.name != "nt":
# The bootstrap stays alive until cleanup, pinning the owned PGID.
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
except (OSError, TimeoutError) as exc:
error = exc
finally:
if job is not None:
job.close()
if process.returncode is None:
try:
process.kill()
except ProcessLookupError:
pass
# Process.wait also waits for pipe EOF. A paused, full output reader must be
# drained separately; it cannot serve as evidence that the OS process lives.
try:
async with asyncio.timeout(5):
while process.returncode is None:
await asyncio.sleep(0.02)
except TimeoutError as exc:
error = exc
if error is not None:
raise ManagedProcessError("process_cleanup_unconfirmed") from error
async def _finish_cleanup(task: asyncio.Task[None]) -> None:
cancelled = False
while not task.done():
try:
await asyncio.shield(task)
except asyncio.CancelledError:
cancelled = True
task.result()
if cancelled:
raise asyncio.CancelledError
async def _read_output(stream: asyncio.StreamReader, consumed: list[int], limit: int) -> bytes:
chunks = []
while chunk := await stream.read(65536):
consumed[0] += len(chunk)
if consumed[0] > limit:
raise ManagedProcessError("process_output_limit")
chunks.append(chunk)
return b"".join(chunks)
async def _drain(stream: asyncio.StreamReader | None) -> None:
if stream is not None:
while await stream.read(65536):
pass
async def _wait_result(process: asyncio.subprocess.Process, path: Path) -> int:
while not path.exists():
if process.returncode is not None:
raise ManagedProcessError("process_worker_interrupted")
await asyncio.sleep(0.02)
if path.stat().st_size > 4096:
raise ManagedProcessError("process_invalid_status")
try:
result = json.loads(path.read_text("utf-8"))
if not isinstance(result, dict):
raise ValueError
except (OSError, ValueError) as exc:
raise ManagedProcessError("process_invalid_status") from exc
if result.get("launch_failed") is True:
raise ManagedProcessError("process_launch_failed", may_have_started=False)
if type(result.get("returncode")) is not int:
raise ManagedProcessError("process_invalid_status")
return result["returncode"]
async def run_process(
argv: Sequence[str],
*,
cwd: Path | str,
timeout: float,
env: Mapping[str, str] | None = None,
input_data: bytes | None = None,
max_output_bytes: int = DEFAULT_OUTPUT_BYTES,
) -> ProcessResult:
"""Run one fixed argv without a shell; reclaim descendants even on normal exit.
Windows assigns a waiting bootstrap to a Job Object *before* it can start the
tool. POSIX uses a dedicated session. Cancellation completes cleanup before
propagating. Inputs are at most 64 MiB; stdout+stderr share a bounded budget.
Return codes/output are tool results, never a test verdict. No retries occur.
"""
if (
not isinstance(argv, Sequence)
or isinstance(argv, (str, bytes))
or not argv
or any(not isinstance(arg, str) or "\0" in arg for arg in argv)
):
raise ValueError("必须提供固定的 argv 字符串列表,不能使用 shell 命令文本")
if not argv[0] or isinstance(timeout, bool) or not math.isfinite(timeout) or timeout <= 0:
raise ValueError("启动程序和有限正数超时是必填项")
if type(max_output_bytes) is not int or max_output_bytes <= 0:
raise ValueError("输出上限必须是正整数")
if input_data is not None and (
not isinstance(input_data, bytes) or len(input_data) > MAX_INPUT_BYTES
):
raise ValueError("标准输入必须是至多 64 MiB 的 bytes")
directory = Path(cwd).resolve(strict=True)
if not directory.is_dir():
raise ValueError("工作目录必须是已存在的目录")
control = (
json.dumps(
{
"argv": list(argv),
"has_input": input_data is not None,
"input_size": len(input_data or b""),
}
).encode()
+ b"\n"
)
if len(control) > 1024 * 1024:
raise ValueError("启动参数超过大小上限")
job = None
process: asyncio.subprocess.Process | None = None
cleanup: asyncio.Task[None] | None = None
tasks: list[asyncio.Task] = []
async def reclaim():
nonlocal cleanup
if process is None:
if job is not None:
job.close()
return
if cleanup is None:
cleanup = asyncio.create_task(_cleanup(process, job))
await _finish_cleanup(cleanup)
with tempfile.TemporaryDirectory(prefix="artemis-plugin-process-") as temporary:
status = Path(temporary) / "status.json"
try:
job = ProcessJob() if os.name == "nt" else None
except OSError as exc:
raise ManagedProcessError("process_ownership_failed", may_have_started=False) from exc
launch = asyncio.create_task(
asyncio.create_subprocess_exec(
sys.executable,
"-I",
"-B",
str(Path(__file__).with_name("_process_worker.py")),
str(status),
str(os.getpid()),
cwd=str(directory),
env=dict(env) if env is not None else None,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0,
start_new_session=os.name != "nt",
)
)
try:
cancelled = False
while process is None:
try:
process = await asyncio.shield(launch)
except asyncio.CancelledError:
cancelled = True
except OSError as exc:
raise ManagedProcessError(
"process_launch_failed", may_have_started=False
) from exc
if cancelled:
raise asyncio.CancelledError
if job is not None:
try:
job.assign(process.pid)
except OSError as exc:
raise ManagedProcessError(
"process_ownership_failed", may_have_started=False
) from exc
stdin, stdout, stderr = process.stdin, process.stdout, process.stderr
assert stdin is not None and stdout is not None and stderr is not None
consumed = [0]
async def feed():
try:
stdin.write(control)
await stdin.drain()
if input_data:
stdin.write(input_data)
await stdin.drain()
except (BrokenPipeError, ConnectionResetError):
pass
finally:
stdin.close()
async def wait_tool():
code = await _wait_result(process, status)
await reclaim()
return code
tasks = [
asyncio.create_task(feed()),
asyncio.create_task(_read_output(stdout, consumed, max_output_bytes)),
asyncio.create_task(_read_output(stderr, consumed, max_output_bytes)),
asyncio.create_task(wait_tool()),
]
try:
_, stdout, stderr, code = await asyncio.wait_for(asyncio.gather(*tasks), timeout)
except TimeoutError as exc:
raise ProcessTimedOut("process_timeout") from exc
return ProcessResult(code, stdout, stderr)
finally:
for task in tasks:
task.cancel()
async def finalize():
try:
await reclaim()
finally:
await asyncio.gather(*tasks, return_exceptions=True)
if process is not None:
try:
async with asyncio.timeout(5):
await asyncio.gather(_drain(process.stdout), _drain(process.stderr))
await process.wait()
except TimeoutError as exc:
raise ManagedProcessError("process_cleanup_unconfirmed") from exc
await _finish_cleanup(asyncio.create_task(finalize()))
+212
View File
@@ -0,0 +1,212 @@
"""Run opt-in, offline SDK fault regressions in temporary directories only."""
import argparse
import asyncio
from contextlib import suppress
import hashlib
import json
from pathlib import Path
import sys
import tempfile
import time
import psutil
from artemis.plugins.evidence import decode_receipt, persist_attachments
from artemis.plugins.models import PluginCallResult
from artemis.plugins.sdk import (
FileVerificationError,
ManagedProcessError,
ProcessTimedOut,
run_process,
snapshot_file,
)
PROBE = Path(__file__).with_name("_sdk_probe.py").resolve()
def _require(condition, message):
if not condition:
raise AssertionError(message)
def _owned_process(marker: Path, started: float):
if not marker.exists():
return None
pid = json.loads(marker.read_text("utf-8"))["pid"]
try:
process = psutil.Process(pid)
if process.status() == psutil.STATUS_ZOMBIE:
return None
_require(process.create_time() >= started - 1, "测试 PID 的启动时间不匹配")
_require(str(PROBE) in process.cmdline(), "测试 PID 的脚本身份不匹配")
return process
except psutil.NoSuchProcess:
return None
async def _tree_case(root: Path, mode: str):
marker = root / f"{mode}.json"
started = time.time()
task = asyncio.create_task(
run_process(
[
sys.executable,
"-I",
"-B",
str(PROBE),
"tree",
str(marker),
"exit" if mode == "normal" else "linger",
],
cwd=root,
timeout=2 if mode == "timeout" else 10,
)
)
try:
if mode == "cancel":
async with asyncio.timeout(5):
while not marker.exists() and not task.done():
await asyncio.sleep(0.02)
_require(marker.exists(), "取消探针没有启动")
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
else:
raise AssertionError("取消没有传播")
elif mode == "timeout":
try:
await task
except ProcessTimedOut:
pass
else:
raise AssertionError("超时没有生效")
else:
result = await task
_require(result.returncode == 0, "模拟工具异常退出")
_require(marker.exists(), "后代进程探针没有启动")
async with asyncio.timeout(3):
while _owned_process(marker, started) is not None:
await asyncio.sleep(0.02)
finally:
if not task.done():
task.cancel()
with suppress(asyncio.CancelledError, ManagedProcessError):
await task
# Failing implementations must not leave the fault suite's own sleeper alive.
process = _owned_process(marker, started)
if process is not None:
process.kill()
process.wait(timeout=5)
async def run_checks() -> dict:
checks = []
with tempfile.TemporaryDirectory(prefix="artemis-sdk-faults-") as temporary:
root = Path(temporary)
source = root / "synthetic.rom"
approved = b"approved firmware bytes\x00\xff"
source.write_bytes(approved)
digest = hashlib.sha256(approved).hexdigest()
snapshot = snapshot_file(source, digest)
for replacement in (b"X" * len(approved), b"", b"Y" * (len(approved) + 10)):
source.write_bytes(replacement)
_require(b"".join(snapshot.chunks(3)) == approved, "快照受原文件修改影响")
try:
snapshot_file(source, digest)
except FileVerificationError:
pass
else:
raise AssertionError("摘要不符仍被接收")
_require(all(part.readonly for part in snapshot.chunks()), "分块内容可被修改")
checks.append("file_replacement_truncation_growth_and_digest")
result = await run_process(
[sys.executable, "-I", "-B", str(PROBE), "echo"],
cwd=root,
timeout=5,
input_data=snapshot.data,
)
_require(
result.stdout == approved and result.stderr == b"synthetic-stderr",
"二进制输入输出不一致",
)
checks.append("binary_input_and_separate_output")
failed = await run_process(
[sys.executable, "-I", "-B", str(PROBE), "failure"], cwd=root, timeout=5
)
_require(failed.returncode == 7, "非零退出码被隐藏")
checks.append("nonzero_returncode")
for mode in ("normal", "timeout", "cancel"):
await _tree_case(root, mode)
checks.append(f"descendants_reclaimed_on_{mode}")
try:
await run_process(
[sys.executable, "-I", "-B", str(PROBE), "overflow"],
cwd=root,
timeout=5,
max_output_bytes=1024,
)
except ManagedProcessError as exc:
_require(exc.error_code == "process_output_limit", "输出超限错误类型不正确")
else:
raise AssertionError("输出超限没有停止")
checks.append("output_limit_stops_process")
trace = root / "synthetic-trace"
artifacts = trace / "artifacts/plugins/sdk-faults"
artifacts.mkdir(parents=True)
sample = artifacts / "sample.txt"
sample.write_bytes(b"synthetic evidence")
receipt = {"name": sample.name, "sha256": hashlib.sha256(sample.read_bytes()).hexdigest()}
_require(len(persist_attachments(trace, "sdk-faults", [receipt])) == 1, "附件未被回读")
for invalid in ({**receipt, "sha256": "0" * 64}, {**receipt, "name": "../outside.txt"}):
try:
persist_attachments(trace, "sdk-faults", [invalid])
except ValueError:
pass
else:
raise AssertionError("证据摘要不符或路径穿越未拒绝")
checks.append("evidence_hash_and_path_rejection")
for execution in ("partial", "unknown"):
outcome = PluginCallResult(
status="success",
structured_content={
"artemis": {
"version": 1,
"execution": execution,
"side_effect": "possible",
"retryable": True,
}
},
)
decode_receipt(outcome)
_require(
not outcome.retryable and outcome.requires_observation, "不确定写操作被允许重试"
)
checks.append("partial_unknown_never_authorize_retries")
return {
"status": "passed",
"scope": "sdk_faults_only",
"python": sys.version.split()[0],
"platform": sys.platform,
"checks": checks,
"real_device_actions": False,
}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--json-output", type=Path)
args = parser.parse_args()
result = asyncio.run(run_checks())
if args.json_output:
args.json_output.write_text(
json.dumps(result, ensure_ascii=False, indent=2) + "\n", "utf-8"
)
print(json.dumps(result, ensure_ascii=False))
print("仅验证 SDK 自身;插件仍需对真实入口做故障注入、证据与任务验收。")
if __name__ == "__main__":
main()
+157
View File
@@ -0,0 +1,157 @@
"""Plugin development checks and a separate market materials gate."""
import argparse
import asyncio
import json
from pathlib import Path
from pydantic import ValidationError
from artemis.plugins.evidence import validate_tools
from artemis.plugins.market import (
REPORT_NAME,
content_fingerprint,
draft_submission,
package_directory,
package_files,
validate_submission,
)
from artemis.plugins.models import PluginManifest, PluginProfile
from artemis.plugins.session import PluginSession, resolve_server
from artemis.plugins.store import contained_path
async def check(
directory: Path,
live: bool,
values: dict[str, str],
*,
market: bool = False,
fingerprint: bool = False,
prepare_market: bool = False,
) -> dict:
if prepare_market and not directory.is_dir():
raise ValueError("请在插件目录生成市场草稿,再打包 ZIP")
with package_directory(directory) as (root, archive_sha256):
manifest = PluginManifest.model_validate_json((root / "plugin.json").read_text("utf-8"))
manifest.check_compatible()
if manifest.skill:
skill = contained_path(root, manifest.skill)
if (
not skill.is_file()
or skill.stat().st_size > 256 * 1024
or not skill.read_text("utf-8").strip()
):
raise ValueError("缺少有效的 Skill 或超过大小限制")
if manifest.schema_version not in {2, 3}:
raise ValueError("新市场插件必须适配 schema_version=2 或 3")
if any(name not in manifest.config or manifest.config[name].secret for name in values):
raise ValueError("--values 只能包含清单中声明的普通配置,不得包含密钥")
files = package_files(root) if market or fingerprint or prepare_market else None
if prepare_market:
assert files is not None
draft = draft_submission(manifest, files)
with (root / REPORT_NAME).open("x", encoding="utf-8") as handle:
handle.write(json.dumps(draft, ensure_ascii=False, indent=2) + "\n")
return {
"status": "draft",
"plugin_id": manifest.id,
"version": manifest.version,
"content_sha256": draft["content_sha256"],
}
submission = None
if market:
assert files is not None
submission = validate_submission(root, manifest, files)
handshake = bool((live or market) and manifest.server)
if handshake:
server = resolve_server(manifest, PluginProfile(values=values), root)
server.env["PYTHONDONTWRITEBYTECODE"] = "1"
session = await PluginSession(server, root).start()
try:
validate_tools(manifest, session.tools)
finally:
await session.aclose()
if files is not None and package_files(root) != files:
raise ValueError("MCP 握手期间包内容发生变化,检查记录失效")
if market:
assert files is not None
if validate_submission(root, manifest, files) != submission:
raise ValueError("MCP 握手期间市场记录发生变化,检查记录失效")
result = {
"status": "passed",
"plugin_id": manifest.id,
"version": manifest.version,
"scope": "market_materials" if market else "development",
"mcp_handshake": handshake,
}
if files is not None:
result["content_sha256"] = content_fingerprint(files)
if archive_sha256 is not None:
result["archive_sha256"] = archive_sha256
return result
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("directory", type=Path, help="插件目录或 ZIP;不会安装到现有插件库")
parser.add_argument(
"--live", action="store_true", help="仅启动/关闭 MCP 并核对声明,不调用工具"
)
parser.add_argument("--values", default="{}", help="普通配置 JSON;不得包含密钥")
modes = parser.add_mutually_exclusive_group()
modes.add_argument(
"--market", action="store_true", help="核对市场分发、内容绑定和验收材料,含 MCP 握手"
)
modes.add_argument(
"--prepare-market", action="store_true", help="创建未验证的 MARKET.json 草稿,不覆盖旧文件"
)
modes.add_argument(
"--fingerprint", action="store_true", help="输出包内容摘要;不宣称市场验收通过"
)
parser.add_argument("--json-output", type=Path, help="将检查结果写到包外 JSON 文件")
args = parser.parse_args()
result = {}
can_write = True
try:
if args.json_output and (
args.json_output.resolve() == args.directory.resolve()
or (
args.directory.is_dir()
and args.json_output.resolve().is_relative_to(args.directory.resolve())
)
):
can_write = False
raise ValueError("检查输出必须在插件包之外,不能覆盖输入包")
values = json.loads(args.values)
if not isinstance(values, dict) or any(not isinstance(v, str) for v in values.values()):
raise ValueError("--values 必须是字符串值的 JSON 对象")
result = asyncio.run(
check(
args.directory.resolve(),
args.live,
values,
market=args.market,
fingerprint=args.fingerprint,
prepare_market=args.prepare_market,
)
)
except (ValueError, OSError, RuntimeError) as exc:
message = (
"输入格式或清单/市场材料字段不合法"
if isinstance(exc, (json.JSONDecodeError, ValidationError))
else str(exc)
)
result = {"status": "failed", "error": message}
if args.json_output and can_write:
args.json_output.write_text(
json.dumps(result, ensure_ascii=False, indent=2) + "\n", "utf-8"
)
print(json.dumps(result, ensure_ascii=False))
print("检查范围不包括源码正确性与真实业务通过;市场材料仍需来源审查和真实任务验收。")
if result["status"] == "failed":
raise SystemExit(1)
if __name__ == "__main__":
main()
+13 -5
View File
@@ -381,7 +381,7 @@ async def _run_with_recovery[T](
immediately: retrying cannot help and pausing would hang the task. immediately: retrying cannot help and pausing would hang the task.
- When retryable attempts are exhausted: if a fallback model is waiting - When retryable attempts are exhausted: if a fallback model is waiting
(with_fallback), raise LLMExhaustedError so it takes over immediately; (with_fallback), raise LLMExhaustedError so it takes over immediately;
final acceptance also raises instead of pausing an already-finished task. final acceptance and invalid output also raise instead of globally pausing.
Other calls pause (bounded by settings.LLM_PAUSE_TIMEOUT_SECONDS) and Other calls pause (bounded by settings.LLM_PAUSE_TIMEOUT_SECONDS) and
retry the same request on resume. retry the same request on resume.
""" """
@@ -470,7 +470,7 @@ async def _run_with_recovery[T](
) )
if failure.category is FailureCategory.INVALID_OUTPUT: if failure.category is FailureCategory.INVALID_OUTPUT:
# One fresh response can recover a generation loop. Repeated # One fresh response can recover a generation loop. Repeated
# runaway output must reach the existing fallback/pause path, # runaway output must reach fallback or fail this request,
# even when ordinary network retries are configured higher. # even when ordinary network retries are configured higher.
max_attempts = min( max_attempts = min(
max_attempts, total_attempts + max(0, policy.max_attempts - attempt) max_attempts, total_attempts + max(0, policy.max_attempts - attempt)
@@ -505,7 +505,11 @@ async def _run_with_recovery[T](
ACTIVE_ATTEMPT.reset(observation_token) ACTIVE_ATTEMPT.reset(observation_token)
# Retryable attempts exhausted. # Retryable attempts exhausted.
if _FALLBACK_AVAILABLE.get() or ACTIVE_FINAL_REVIEW.get() is not None: if (
_FALLBACK_AVAILABLE.get()
or ACTIVE_FINAL_REVIEW.get() is not None
or last_failure.category is FailureCategory.INVALID_OUTPUT
):
if failed_endpoints is not None and endpoint_key: if failed_endpoints is not None and endpoint_key:
failed_endpoints[endpoint_key] = last_error failed_endpoints[endpoint_key] = last_error
_record_llm_event( _record_llm_event(
@@ -514,7 +518,11 @@ async def _run_with_recovery[T](
"error": str(last_error)[:1000], "error": str(last_error)[:1000],
"category": last_failure.category.value, "category": last_failure.category.value,
"retryable": True, "retryable": True,
"handover": "fallback" if _FALLBACK_AVAILABLE.get() else "final_review_end", "handover": "fallback"
if _FALLBACK_AVAILABLE.get()
else "final_review_end"
if ACTIVE_FINAL_REVIEW.get() is not None
else "task_failed",
}, },
status="failed", status="failed",
) )
@@ -1253,7 +1261,7 @@ async def _with_fallback[T](
) )
# The contextvar must be reset BEFORE fallback_call runs: the fallback has # The contextvar must be reset BEFORE fallback_call runs: the fallback has
# no further fallback behind it, so its own exhaustion should pause. # no further fallback behind it, so apply its category's terminal policy.
fallback_token = _FALLBACK_AVAILABLE.set(True) fallback_token = _FALLBACK_AVAILABLE.set(True)
try: try:
result = await main_call() result = await main_call()
+39
View File
@@ -0,0 +1,39 @@
# 模型流式参数诊断
小型内置动作的参数流仍受 16384 字符保护。超限时关闭输出流、丢弃不完整参数,不执行该次动作;一次新响应重试和可用备用端点耗尽后,只结束当前任务,不进入共享的模型暂停。批次仍遵循“失败即停止”、人工暂停和取消设置。网络故障、认证错误和最终验收保留各自的恢复规则。
## 请求关联
每个模型请求尝试沿用现有 `llm_diagnostic` 记录,不另建日志数据库。
| 字段 / 请求头 | 含义 |
| --- | --- |
| `request_id` / `X-Client-Request-Id` | 同一次恢复循环内保持稳定的逻辑请求编号;切换备用调用或恢复暂停后可能建立新的逻辑请求 |
| `attempt_id` / `X-Client-Attempt-Id` | 本次客户端尝试编号;重试时变化,`previous_attempt_id` 关联前次尝试 |
| `client_*_id_matches` | 实际发送的请求头是否与本地编号一致;调用方已指定的头保留原值 |
| `server_request_id` | 首个有效的 `X-Oneapi-Request-Id`、`X-Request-Id` 或 `Request-Id`,按此前后顺序选择 |
| `response_x_request_id` | 响应中的原始 `X-Request-Id`,不推定它来自模型上游 |
| `error_request_id` | SDK 异常带回的编号,不覆盖已经从响应头读取的编号 |
| `response_id` | SDK 实际返回的 Responses 响应编号 |
编号只接收有长度限制的可打印白名单字符。请求头不控制会话亲和性,不替换 `thread-id` / `session-id`。网关自身的重试应使用独立编号或序号,不能假装是一次新的客户端尝试。
## 参数指纹 v1
`tool_observation_layer=langchain_tool_call_chunks` 明确表示客户端观测的是 SDK 转换后的参数片段,不是原始 SSE 帧。每工具记录实际可得的 `call_id`、`chunk_index`、工具名,以及:
- `argument_chars`:参数片段的 Unicode 字符数之和。
- `argument_bytes` / `argument_sha256`:按原顺序连接片段后,其 UTF-8 字节长度和 SHA256;不解析、重排参数 JSON,也不规范化空白或 Unicode。
- `argument_deltas`:非空参数片段数,不能直接与网关原始事件数比较。
- `argument_prefixes`:每 4096 字节的累计 SHA256,最多保留前 16 个。可以在分片大小不同、上游预读或中途取消时比较相同字节位置。
- `argument_encoding_valid`:遇到不能编码为 UTF-8 的片段时为 false,当前总字节数和总哈希留空,避免伪造可比结果;诊断本身不改变执行流程。
最多记录 64 个工具;超过时设置 `tool_stats_truncated=true`,总参数字符计数仍继续。诊断不保留参数正文。哈希仅证明已收到的字节前缀,不能证明输出完整或动作成功;完整性仍由响应终止状态和解析校验决定。
跨三端优先使用 `response_id + call_id` 关联。LangChain 的 `chunk_index` 可能因文本或推理子块不同于原始 `output_index`,缺失编号时不能按数组位置猜测。正常的 `arguments.done`、`output_item.done`、`response.completed` 快照不重复计入参数;自定义工具转换、非空初始参数或协议异常需要先核对 SDK 映射,不能仅凭跨层哈希不同认定某一端重复发送。
历史日志没有保存的原始帧无法由新增诊断恢复。HTTP 200 只说明响应头已到达;客户端保护后出现的 499 或 Broken pipe 也不能证明最初原因。网关写入本机下游 writer 成功不等于客户端已消费或执行。
## 离线验证
`tests/unit/test_llm_stream_guard.py` 验证保护、有限重试、相同/不同备用端点,以及不会创建或清除其他请求的全局暂停。`tests/unit/test_request_diagnostics.py` 验证请求编号、超时重试、响应头及单次参数计数。`tests/unit/test_argument_fingerprint.py` 验证不同分片、中文和 emoji、固定字节前缀、有界统计与异常编码。这些测试不调用真实模型或设备。
+6 -2
View File
@@ -1,6 +1,6 @@
# 插件开发 SDK 与证据协议(ARTEMIS 2.3.0) # 插件开发 SDK 与证据协议(ARTEMIS 2.4.5)
新插件必须适配本文规则,再通过实测进入市场。旧 `schema_version: 1` 仍可读取;执行插件使用版本 2,并声明 `capabilities`。需要回放面板、人工表单和报告字段时使用版本 3,见[工作区插件接口](workspace-plugins.md)。模型执行、任务队列、资源锁、超时和报告由 ARTEMIS 管理,插件不另建任务调度器,也不替用户决定用例通过。 新插件必须适配本文规则,再通过实测进入市场。旧 `schema_version: 1` 仍可读取;执行插件使用版本 2,并声明 `capabilities`。需要回放面板、人工表单和报告字段时使用版本 3,见[工作区插件接口](workspace-plugins.md)。模型执行、任务队列、资源锁、整体调用预算和报告由 ARTEMIS 管理;插件负责内部工具的超时、取消与子进程收尾,不另建任务调度器,也不替用户决定用例通过。
## 从可运行模板开始 ## 从可运行模板开始
@@ -23,6 +23,8 @@ uv run python -c "import shutil; shutil.make_archive('evidence-demo', 'zip', 'ex
静态检查通过不等于市场准入,也不表示业务测试通过。需按下面清单提交实际验证记录。 静态检查通过不等于市场准入,也不表示业务测试通过。需按下面清单提交实际验证记录。
涉及外部程序或已批准文件输入时,从 [managed-tool-demo](../examples/plugins/managed-tool-demo/README.md) 开始;它提供真实 MCP 离线回归。通用接口、故障自测和独立 `--market` 检查见[运行辅助接口与市场材料](plugin-sdk.md)。SDK 自测在临时目录运行,不访问真实设备。
## 清单与工具能力 ## 清单与工具能力
`plugin.json` 的安装、版本约束、资源、权限设置沿用 [插件说明](plugins.md)。版本 2 增加: `plugin.json` 的安装、版本约束、资源、权限设置沿用 [插件说明](plugins.md)。版本 2 增加:
@@ -87,3 +89,5 @@ stdio 宿主注入 `ARTEMIS_ARTIFACTS_DIR`,插件默认把生成文件写入
- 视觉:截图来源、遮挡、DPI、窗口切换、坐标一致、无图时拒绝盲点;录屏启动/结束/暂停恢复、编码回退、磁盘不足、时间缺口、关键节点、播放状态、中文可读性及长程体积。 - 视觉:截图来源、遮挡、DPI、窗口切换、坐标一致、无图时拒绝盲点;录屏启动/结束/暂停恢复、编码回退、磁盘不足、时间缺口、关键节点、播放状态、中文可读性及长程体积。
- 文件:中文文件名、覆盖保护、校验失败;终端/API:退出码、业务结果和不确定副作用。 - 文件:中文文件名、覆盖保护、校验失败;终端/API:退出码、业务结果和不确定副作用。
- Skill 必须只描述真实存在且已适配的能力,不绕过权限、调度和证据协议。保留来源和必要许可证,版本与实测范围明确;未测平台/场景不能写成已通过。 - Skill 必须只描述真实存在且已适配的能力,不绕过权限、调度和证据协议。保留来源和必要许可证,版本与实测范围明确;未测平台/场景不能写成已通过。
- 外部程序验证正常退出、内部超时、取消和输出超限后的整个进程树回收;不能只确认 MCP/直接子进程退出。写操作消费文件时,批准摘要必须绑定实际消费的不可变内容,覆盖校验后替换、截断与增长。
- 市场提交须附 [MARKET.json](plugin-sdk.md#marketjson),绑定候选内容、组件分发授权和验收证据;对最终 ZIP 运行 `--market`。本地开发检查不强制公开许可,市场材料检查不替代源码和业务审查。
+121
View File
@@ -0,0 +1,121 @@
# 插件运行辅助接口与市场检查
这些接口从 ARTEMIS **2.4.5** 开始提供,位于 `artemis.plugins.sdk`;使用 ARTEMIS 2.4.5 或更新版本的 Python,并在插件清单中声明 `artemis_version: ">=2.4.5,<3.0.0"`。它们不属于远程薄客户端 `artemis-client`,不启动后台、模型或第二套任务队列。现有不依赖这些辅助接口的插件仍按自身清单兼容旧宿主;采用这些接口才需要提高最低版本。
## 已批准文件必须绑定实际使用的字节
```python
from artemis.plugins.sdk import snapshot_file
snapshot = snapshot_file(authorized_path, approved_sha256)
# 此后只消费快照。连接建立、预检或等待期间原文件发生变化,也不会改变快照。
for chunk in snapshot.chunks():
transport.sendall(chunk)
```
`approved_sha256` 必须来自已批准的内容身份,不能在执行时把新文件的摘要自动当作新的批准。制作快照、验证设备身份和写入授权都应在真正写操作前完成。使用 `.data` 或只读的 `.chunks()`,不要校验之后重新打开原路径;传输后才发现摘要不符不能撤销已经发生的写入。
`snapshot_file` 一次读取授权的普通文件并核对 SHA256,拒绝链接、特殊文件、大小超限和摘要不符。默认上限 64 MiB,副本是不可变内存 bytes;参数 `max_bytes` 仅作用于本次调用,选择更大上限须评估内存。它不负责用户授权或路径沙箱,插件仍要限制允许读取的范围。大文件如采用磁盘快照或流式方案,应提供等价的防修改保证并另行验证,不能恢复到“检查后重开路径”。
`VerifiedFile.size`、`.sha256` 对应快照本身。原路径同长度覆盖、替换、增长或截断后,快照仍保持原内容。对写入端到端的回归必须检查实际发送字节,不能只断言预检函数被调用。
## 外部工具的生命周期
```python
from artemis.plugins.sdk import ManagedProcessError, run_process
try:
result = await run_process(
fixed_argv,
cwd=authorized_directory,
timeout=20,
input_data=snapshot.data,
)
except ManagedProcessError as exc:
# 用稳定的 error_code 返回部分执行/未知;不回显命令或原始敏感输出,不自动重试。
...
```
`fixed_argv` 必须是业务层构造的固定参数列表,不能向模型暴露任意 shell;`cwd` 为明确的现有目录。可通过 `env` 提供所需的环境白名单;未提供时继承当前插件环境,插件应自行排除不需要的密钥和工具日志变量。宿主的凭据管理不会自动证明外部程序的输出安全。
`timeout` 必须为有限正数,作用于本次工具运行;标准输入最多 64 MiB。stdout、stderr 分别返回 bytes,共用默认 4 MiB 上限,可通过本次调用的 `max_output_bytes` 调整。输出超限立即停止;部分输出不作有效结果返回。`ProcessResult.returncode` 保留非零退出码,不据此生成业务用例通过结论。
运行器在 Windows 先将等待启动的引导进程加入不可继承的 Job Object,再允许启动实际工具,防止快速派生后代漏出归属;父进程退出时 Job Object 同样回收其树。POSIX 使用独立会话,正常退出、超时、取消和输出超限均回收所属进程组。它是生命周期辅助,不是恶意程序沙箱,不能承诺回收主动逃离会话的程序。
调用结束后不保留后台子进程。需要常驻驱动的插件应实现会话级归属、关闭与验证,不借本接口后台化工具。取消在清理后继续传播,不能吞掉 `CancelledError`。清理另有固定的有界等待;未确认完成时抛出 `process_cleanup_unconfirmed`,插件必须返回未知状态并核对资源,不声明已全部停止。
常见错误码:`process_timeout`、`process_output_limit`、`process_launch_failed`、`process_ownership_failed`、`process_worker_interrupted`、`process_invalid_status`、`process_cleanup_unconfirmed`。`may_have_started=false` 只表示已知工具尚未启动;其余情况不能授权重发写操作。运行器从不重试。原始输出可能包含密钥,返回给模型前按业务字段白名单处理。
宿主负责整体任务与 MCP 调用预算,插件负责内部操作及其后代。内部超时先发生时,必须在向宿主返回前收尾;宿主未超时、或只关闭 MCP 连接,都不能替代这一步。
## 可运行回归
```sh
<ARTEMIS_PYTHON> -B -m artemis.plugins.sdk_checks --json-output sdk-faults.json
<ARTEMIS_PYTHON> -B <受管工具模板目录>/test_contract.py
```
模板目录在源码仓库的 `examples/plugins/managed-tool-demo`,或本 Skill 的 `assets/managed-tool-demo`;先复制到用户指定开发目录。以下检查均使用 ARTEMIS 2.4.5 或更新版本的 Python。
第一条只在临时目录创建合成文件和自己的测试进程,覆盖文件同长度覆盖、截断/增长、错误摘要、二进制 stdin、非零退出码、正常结束/超时/取消后的后代回收、输出超限、附件摘要与路径拒绝,以及部分/未知执行不能授权重试。它不连接设备、不调用模型、不修改现有任务或配置。结果明确标为 `sdk_faults_only`。
第二条额外通过真实 stdio MCP 调用模板工具,核对有效输入、错误摘要、目录穿越拒绝及宿主附件回读,结果标为 `template_offline_contract`。模板见插件开发文档的受管工具示例。
改写插件后,必须扩展模板测试到自己的实际入口:在最后一次预检与真正消费文件之间替换输入、让实际工具创建后代再超时/取消,并检查实际传输和进程存活。SDK 自测通过不能证明插件调用正确。还需覆盖错误/部分执行、证据丢失或摘要不符、权限、资源互斥以及真实任务时间线。
## 开发检查与市场材料检查
统一入口为仓库/Skill 的 `check_plugin.py`,也可执行 `python -m artemis.plugins.validation`。两处脚本调用同一个实现,不单独维护规则。
```sh
# 日常本地开发,无需公开分发许可;--live 只握手,不派发工具。
<ARTEMIS_PYTHON> <检查脚本> <插件目录或ZIP> --live --values '{"python":"ARTEMIS解释器绝对路径"}'
# 在清洁候选目录生成草稿;所有结论未验证、许可未知,不覆盖已有 MARKET.json。
<ARTEMIS_PYTHON> <检查脚本> <插件目录> --prepare-market
# 对最终候选 ZIP 检查市场材料;包含只读 MCP 声明握手,不调用业务工具。
<ARTEMIS_PYTHON> <检查脚本> <最终ZIP> --market --values '{"python":"ARTEMIS解释器绝对路径"}' --json-output market-check.json
```
路径与 JSON 按当前 Shell 引用;密钥不放进 `--values`。`--json-output` 必须在候选目录外,不能覆盖输入 ZIP。ZIP 沿用宿主的安全解压和大小限制,检查不向已有插件库安装。`--market` 先核对材料,再握手,普通开发检查仍保持轻量。
市场检查拒绝缓存、`.pyc`、虚拟环境、版本库目录、个人 `.env` 配置、链接和冲突路径。环境样例可保留 `.env.example`。不要为通过检查而清理用户运行目录;从可分发输入构建独立候选。
## MARKET.json
此文件随包提交,版本固定为 1,不增加 `plugin.json` 字段,旧宿主仍可安装符合其能力的插件。草稿由检查脚本生成,包含:
| 字段 | 要求 |
| --- | --- |
| `plugin_id`、`plugin_version` | 与候选清单完全一致 |
| `content_sha256` | 全部包内文件路径及 SHA256 的确定性摘要,只排除根层 `MARKET.json` 自身 |
| `traits.uses_subprocesses` | 明确 true/false,不能保留 null |
| `traits.file_inputs_for_writes` | 是否消费文件执行写操作,明确 true/false |
| `components` | 分开列原始实现、适配层、第三方依赖/参考资料的许可和来源 |
| `checks` | 下表全部验收项目,保留实际范围和证据 |
每个组件包含 `name`、`paths`、`license`、`permission`、`license_file`、`source_file`。`paths` 为精确包内相对路径或目录前缀,`.` 表示整个包;全部可分发文件须被覆盖。只有明确的 `permission: "permitted"` 才接受,`local_only` 和 `unknown` 均阻止市场提交。许可和来源文件必须实际在包内;适配层开源许可不会重新许可原始代码、用例、协议或内部资料。可使用合适的 LicenseRef,不强制统一开源,但具体分发授权必须可审查。
每条验收记录包含 `status`、`evidence_file`、`evidence_sha256`、`notes`。`passed` 必须指向真实的非空包内文档及其正确摘要,notes 说明验证环境、范围与结果;缺失、失败和未验证均拒绝。不能把历史记录、SDK 自测或模拟设备改写为本次真实业务通过。
| 验收项目 | 要记录的内容 |
| --- | --- |
| `installation` | 安装、升级、平台和依赖兼容 |
| `tool_contract` | 实际 MCP Schema、能力和参数限制 |
| `failure_partial` | 错误、部分执行、未知副作用与不盲目重试 |
| `timeout_cancel` | 实际工具入口超时与取消 |
| `process_tree` | 工具创建后代后,正常/超时/取消的回收验证 |
| `file_binding` | 已批准文件被替换、截断、增长时的实际消费字节 |
| `resource_conflicts` | 同设备/桌面资源互斥 |
| `evidence_export` | 导出索引、中文路径、摘要不符与缺失附件 |
| `history_uninstall` | 卸载/升级后历史仍可读 |
| `secret_redaction` | 配置、命令、输出和回执脱敏 |
| `real_task` | 经宿主正常任务的操作、时间线、暂停/恢复、证据和报告 |
| `source_review` | 对本次包的源码审查、来源与分发授权核对 |
仅在声明无相应行为时,`process_tree`、`file_binding` 可 `not_applicable`;无 MCP 时 `tool_contract`、`timeout_cancel` 可不适用;无声明资源时 `resource_conflicts` 可不适用。仍须在 notes 中给出理由,不能把未测写成不适用。其他项目须有真实通过记录。
更新任何文件后,旧材料会失效。先重跑受影响验证,再用 `--fingerprint` 查看新的内容摘要,并更新记录;不要只刷新摘要来沿用过期结论。握手结束后重新核对内容与市场记录,检查期间改动会使检查失败。最终 ZIP 检查结果另包含实际解压检查的同一份字节的 SHA256,不重新打开原路径来记录替换文件;公开发布时仍需核对固定附件、市场索引与无认证下载字节。
市场材料检查验证字段、覆盖范围、摘要和证据存在性,不证明声明真实、不替作者确认权利归属,也不自动发布。源码审查和实际业务验收仍由发布方核对;文档、静态检查、握手、SDK 故障自测与真实业务结论分别记录。
+2
View File
@@ -1,5 +1,7 @@
# ARTEMIS 2.4.0 # ARTEMIS 2.4.0
> 全新安装的 npm 校验失败已在 [2.4.1](2.4.1.md) 修复,请使用补丁版源码包或通过更新脚本升级。
本版改进批量用例人工评审与执行详情阅读,并包含 OpenAI SDK 适配、安全依赖更新、Windows 资源锁和报告证据修复。配套「任务人工评审」独立发布 **1.1.0**。 本版改进批量用例人工评审与执行详情阅读,并包含 OpenAI SDK 适配、安全依赖更新、Windows 资源锁和报告证据修复。配套「任务人工评审」独立发布 **1.1.0**。
## 人工评审与原始用例 ## 人工评审与原始用例
+26
View File
@@ -0,0 +1,26 @@
# ARTEMIS 2.4.1
本补丁修复 2.4.0 全新安装时的 `npm ci / EINTEGRITY`,适用于 `start.bat` 和 `start-cn.bat`。人工评审插件版本保持 **1.1.0**。
## 修复
- 2.4.0 发版时误将锁文件中的 MCP 间接依赖版本从 2.3.1 改为 2.4.0,仍保留 2.3.1 的 SHA-512。现在恢复 Angular CLI 22.2.4 官方声明的 `@modelcontextprotocol/server@2.3.1` 及其 `core@2.3.1`,使依赖关系、下载地址和校验值一致。未关闭 npm 完整性验证,也不要求用户清空全局缓存。
- Windows 启动脚本准确检查 Angular 支持的 Node.js 范围:22.x 至少 22.22.3,24.x 至少 24.15.0,或 26.0.0 及以上;提示文案同步修正。
- 保留最终 2.4.0 中的独立 v4 市场索引,提供人工评审 1.1.0;旧版宿主继续使用旧索引。曾用于发版准备的早期 2.4.0 候选包仍使用 v3 索引,会显示人工评审 1.0.0,不应继续分发。
## 验证
- Windows、Node.js 24.22.1、npm 11.19.1,独立源码目录及空 npm 缓存,使用国内镜像执行 `npm ci`:448 个包安装成功。实际依赖树与 Angular CLI 官方声明一致。
- 同一全新环境下,前端 ChromeHeadless 347 项通过,生产构建通过;官方 npm 审计未发现已知漏洞。
- 默认后端回归 3268 通过、2 跳过、8 按标记排除;包含 Node 22/24/26 兼容边界与启动脚本回归。Ruff、质量基线和核心类型检查通过。
- 未启动用户服务,未执行设备或真实业务验收;以上为安装、构建和确定性测试结果。
## 升级
已有安装请先等待任务结束,再运行 `update-cn.bat`(国内镜像)或 `update.bat`,随后重新启动。建议使用更新脚本保留已有配置与数据,不要直接用源码 ZIP 覆盖安装目录。
如果此前已手动修改 `package-lock.json`,更新脚本可能因本地修改而停止;先备份这份文件,确认只包含该安装问题的临时修正后再处理冲突,不要对整个仓库执行重置或清理。
新安装请下载本版 `artemis-2.4.1-source.zip`,按 `SHA256SUMS.txt` 校验后使用。插件仍需在市场中单独安装或更新并启用,升级主程序不会自动授权插件。
本次不构建 Docker,不启动或重启用户服务,不操作真实设备或业务任务。
+24
View File
@@ -0,0 +1,24 @@
# ARTEMIS 2.4.2
本版为批次任务增加人工评审标记,并调整任务列表布局。人工评审插件保持 **1.1.0**,第三方依赖版本不变。
## 更新
- 安装并启用人工评审插件后,任务存在已保存的有效评审内容时,批次列表显示“人工已审”。评审结论或非空备注均计入;清空全部内容后隐藏,禁用或卸载插件后也不显示。
- 标记仅说明已有人工评审内容,不代表测试通过,不覆盖 AI 判定或改变任务执行流程。没有安装插件的用户不显示该标记。
- 批次任务标题与状态分行显示:收藏星标位于标题框内,执行状态、AI 判定和人工已审标签在下方等高、左对齐排列。此布局适用于所有用户。
- 按批次一次读取当前评审内容,沿用列表自动刷新,不逐条增加网络请求;重跑任务不继承旧任务的已审标记。
- 补充发版约束:应用与第三方依赖独立版本化,检查锁文件差异,并验证最终源码包的全新安装。
## 验证
- 默认 Python 回归:3269 通过,2 跳过,8 按标记排除;Ruff、质量基线和核心类型检查通过。
- 前端 ChromeHeadless:348 项通过;生产构建通过。
- 内置浏览器使用隔离数据检查收藏星标、两行布局及有评审/无评审标签显示。未写入真实任务评审数据。
- 最终源码包另行执行独立目录、空 npm 缓存的官方源和国内镜像安装及生产构建,结果随发布附件提供。
## 升级
已有安装请等待任务结束,运行 `update-cn.bat` 或 `update.bat` 后重启。新安装下载 `artemis-2.4.2-source.zip`,按 `SHA256SUMS.txt` 校验后启动。请勿直接覆盖旧安装目录。
人工评审插件仍需单独安装并启用;本次无需升级已安装的 1.1.0。发布过程不构建 Docker,不重启用户服务,不执行设备或真实业务任务。
+25
View File
@@ -0,0 +1,25 @@
# ARTEMIS 2.4.3
本版修复异常模型输出影响整个批次的问题,并增加流式参数诊断。人工评审插件保持 **1.1.0**,SDK、其他插件及第三方依赖版本不变。
## 更新
- 已识别的异常输出(包括工具参数溢出、响应完整性校验失败)在有限重试和可用备用端点耗尽后,只结束当前任务,不再创建全局模型暂停。批次未开启“失败即停止”时可继续下一条;人工暂停、失败即停止、网络恢复及最终验收的原有规则保留。
- 保留 16384 字符工具参数保护,关闭异常输出流,丢弃未完成内容,不执行本次异常动作。报错改为描述“收到的参数流”,避免在证据不足时直接归责模型。
- 增加单次尝试请求头,区分逻辑请求、客户端尝试、网关响应与 SDK 异常中的请求编号,便于跨服务核对。
- 对收到的工具参数片段记录字符数、UTF-8 字节数及有界 SHA256 前缀摘要,默认不新增参数正文记录。最多跟踪 64 个工具,每工具最多保留 16 个固定位置前缀。SDK 的 chunk_index 与原始协议 output_index 分开命名。
诊断字段及边界见 [流式参数诊断](../llm-stream-diagnostics.md)。本次主程序安装不会部署或升级 NEWAPI、CodexManager;服务端修复独立交付。
## 验证
- 默认 Python 回归:3284 通过,6 跳过,8 按标记排除;Ruff 格式和静态检查、质量基线、核心类型检查通过。
- 五类合成输入经过实际 CodexManager、NEWAPI 转发函数及原 OpenAI/LangChain SDK 的离线文件串接:正常输入有效;超长、累计全文、重复及半关闭输入被拒绝;各层参数摘要一致,设备动作执行数为 0。
- 最终源码 ZIP 的隔离解压、独立空 npm 缓存、官方源与国内镜像安装和生产构建结果随发布附件 `INSTALL-VERIFICATION.md` 提供。
- 未重跑用户原业务批次、未做高并发压力测试或真实模型端到端验证。历史原始参数帧未保存,不能据此认定原事故参数膨胀首先发生在哪一层。
## 升级
已有安装请等待任务结束,运行 `update-cn.bat` 或 `update.bat` 后重启。新安装下载 `artemis-2.4.3-source.zip`,按 `SHA256SUMS.txt` 校验后启动。请勿直接覆盖旧安装目录。
人工评审插件 1.1.0 无需更新。本次发布不构建 Docker,不重启用户服务,不执行设备或真实业务任务。
+29
View File
@@ -0,0 +1,29 @@
# ARTEMIS 2.4.4
本版改善任务结束后错误和阻塞说明的阅读体验,基于 2.4.3。人工评审插件保持 **1.1.0**,SDK、其他插件及第三方依赖版本不变。
## 更新
- 历史任务、任务切换列表和批次任务默认只显示一行说明,超长内容省略,点击可展开;报告异常说明采用相同折叠展示。
- 展开后安全渲染 Markdown 正文、列表和表格,并展示已有结论及已记录依据;长正文限高滚动。
- 列表摘要优先使用已保存的结论原因,否则使用执行错误首行;展示时移除 FlashRunner 固定技术前缀,原始记录保持不变。
- 仅展示已结束任务的已有信息,不新增 AI 复盘或模型请求。展开说明不触发任务切换,列表后台刷新保留展开状态。
- `start-cn.bat` 检测到正在运行的服务时,服务占用提示、打开浏览器/重启/取消菜单、输入提示及重启进度均使用中文;非交互启动提示也使用中文。默认仍只打开浏览器,选择重启才停止现有服务。
本版不改变任务执行、模型重试或批次停止策略,继续保留 2.4.3 的异常输出隔离与参数保护。
## 验证
- 前端回归:353 项通过,生产构建通过。
- 内置浏览器使用隔离样例验证默认折叠、Markdown 展开、长内容、无错误完成任务、批次布局、浅色/深色与窄视口。
- 最终源码 ZIP 的隔离解压、独立空 npm 缓存、官方源与国内镜像安装和生产构建结果随附件 `INSTALL-VERIFICATION.md` 提供。
- 启动语言与 Windows 启动脚本定向回归 31 项通过,Ruff 格式与静态检查、质量基线及核心类型检查通过。Python 默认确定性回归结果随安装验证附件提供。
- 未复跑用户业务任务、调用真实模型或进行高并发测试。
## 升级
已有安装请等待任务结束,运行 `update-cn.bat` 或 `update.bat` 后重启。新安装下载 `artemis-2.4.4-source.zip`,按 `SHA256SUMS.txt` 校验后启动。请勿直接覆盖旧安装目录。
2026-10-29 撤回首次发布的 2.4.4,补充中文启动提示后重新发布。此前下载过 2.4.4 的用户请重新下载并核对最新校验文件,或使用更新脚本获取最新提交。首次发布包的 SHA256 为 `9a2a678c0cf97b5de140479a3f3946171d20d19efaf3ad249c708ed35a06e67a`,该包已撤回。
人工评审插件 1.1.0 无需更新。本次发布不构建 Docker,不重启用户服务。
+29
View File
@@ -0,0 +1,29 @@
# ARTEMIS 2.4.5
本版基于 2.4.4,补齐插件作者管理外部工具、已批准文件与市场审查材料的公共能力。远程客户端 `artemis-client`、市场插件及第三方依赖独立版本化,本次不升版。
## 更新
- 新增 `artemis.plugins.sdk.snapshot_file`:按明确批准的 SHA256 读取不可变快照,实际工具消费快照字节,避免校验后原文件被替换、覆盖或截断;默认输入上限 64 MiB。
- 新增 `run_process`:固定 argv、工作目录和超时,限制 stdout/stderr 总量,在正常结束、超时与取消后回收自己启动的后代。Windows 先取得 Job Object 归属再启动实际工具;提供稳定错误码,执行结果不明时不声称尚未执行,也不自动重试。
- 新增可独立运行的 SDK 故障检查,以及受管工具模板。模板覆盖真实 stdio MCP 调用和宿主附件回读,随源码与官方 Skill 同步分发。
- 开发与市场检查共用一份实现。`check_plugin.py --prepare-market` 生成未验证的 `MARKET.json` 草稿;`--market` 检查来源许可、验收材料、候选内容指纹和 MCP 工具声明,并拒绝缓存、私密环境文件及握手期间包内容变化。
- 补充模板的 LICENSE、SOURCE.md 和开发说明。市场材料检查仍需结合源码、分发权利与真实业务审查;检查命令通过不会自动发布插件。
这些辅助接口管理工具生命周期和文件内容绑定;插件仍负责权限范围、业务验收、写操作状态及敏感信息处理。POSIX 使用独立进程组,不将运行器作为恶意程序沙箱。
## 兼容性
采用新增 SDK 的插件须声明 `artemis_version: ">=2.4.5,<3.0.0"`,并使用 ARTEMIS 2.4.5 或更新版本的 Python。正式 2.4.4 包不包含该 SDK。
插件清单和证据协议保持兼容,`MARKET.json` 是独立的审查材料。未使用新增 SDK 的现有插件继续按自身最低版本运行。已独立发布的设备测试插件 `0.5.0+artemis.6` 将修复包含在插件自身,仍可用于正式 ARTEMIS 2.4.4;主程序升级不会自动更新或授权插件。
## 验证
Python 默认确定性回归、Ruff 格式与静态检查、质量基线、核心类型检查,以及最终源码 ZIP 的官方源和国内镜像全新安装、生产构建结果,随发布附件 `INSTALL-VERIFICATION.md` 提供。验证记录注明实际 Python、Node/npm、源码提交及 ZIP SHA256。
SDK 故障检查与模板契约使用合成文件及测试进程,不替代设备、固件升级或用户业务验收。Windows 为本次实际验证环境,POSIX 分支未实测。
## 升级
已有安装请等待任务结束,运行 `update-cn.bat` 或 `update.bat` 后重启。新安装下载 `artemis-2.4.5-source.zip`,按 `SHA256SUMS.txt` 校验后启动。请勿直接覆盖旧安装目录。
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
https://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 Google LLC.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+5
View File
@@ -0,0 +1,5 @@
# 模板来源
本目录为 ARTEMIS 主程序仓库提供的文件证据开发模板,适用本目录 LICENSE 的 Apache-2.0,来源以随模板交付的主程序源码提交为准。没有包含厂商协议、内部资料或用户测试数据。
复制模板后,新增代码、资料和第三方依赖需要分别记录实际来源与分发许可,并为本次候选包保存真实验证结果。模板许可不重新许可新增内容。
+2 -1
View File
@@ -7,7 +7,8 @@
"artemis_version": ">=2.0.0,<3.0.0", "artemis_version": ">=2.0.0,<3.0.0",
"platforms": ["win32", "linux", "darwin"], "platforms": ["win32", "linux", "darwin"],
"architectures": ["amd64", "arm64"], "architectures": ["amd64", "arm64"],
"server": {"command": "${config.python}", "args": ["${plugin_dir}/server.py"]}, "license": "Apache-2.0",
"server": {"command": "${config.python}", "args": ["-B", "${plugin_dir}/server.py"]},
"config": {"python": {"label": "含 MCP 依赖的 Python", "default": "python"}}, "config": {"python": {"label": "含 MCP 依赖的 Python", "default": "python"}},
"skill": "SKILL.md", "skill": "SKILL.md",
"targets": ["mixed", "computer"], "targets": ["mixed", "computer"],
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
https://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 Google LLC.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@@ -0,0 +1,19 @@
# 受管工具与文件快照模板
本模板使用 ARTEMIS 2.4.5 新增的 `artemis.plugins.sdk`,最低宿主版本为 2.4.5。复制模板后使用该版本或更新版本的 ARTEMIS Python;正式的 2.4.4 包不提供这些接口。模板随源码和 Skill 分发,尚未发布到市场。纯文件写入可用相邻 evidence-demo,本模板用于需要外部工具或已批准文件输入的插件。
复制整个目录,使用 ARTEMIS 自己的 Python:
```sh
<ARTEMIS_PYTHON> -B test_contract.py
```
测试只在临时目录创建样例和休眠进程,验证 SDK 的文件替换、超时、取消、后代回收、输出上限,以及真实 stdio MCP 调用、错误输入拒绝和宿主附件回读。结果分为 SDK 自测与本模板离线契约,不证明改写后的插件、设备或业务通过。
扩展时保持固定 argv 和工作目录。先用 `snapshot_file(path, approved_sha256)` 得到不可变 bytes,再将 `snapshot.data` 或 `snapshot.chunks()` 交给实际消费者;不要重新打开原路径。默认内存输入上限 64 MiB,按明确需求选择上限;超大固件需要另外实现有等价保护的流式存储,并单独验证。
`await run_process(argv, cwd=..., timeout=..., input_data=...)` 不执行 shell、不重试,返回原始退出码与 stdout/stderr。标准输入最多 64 MiB,stdout/stderr 共用默认 4 MiB 上限。它在正常退出、超时、取消、输出超限时回收后代;Windows 先将等待启动的引导进程加入 Job Object,再运行工具。POSIX 使用独立会话;这些是生命周期管理,不是恶意程序沙箱,不能托管主动逃离进程组的程序。
宿主负责整个 MCP 调用的期限;插件负责内部操作和子进程。内部超时先发生时,插件必须在返回前完成回收。取消会在回收后传播,不能吞掉 `CancelledError` 后声称完成。`ManagedProcessError.error_code` 稳定且不回显命令,`may_have_started` 决定能否声明尚未执行;`process_cleanup_unconfirmed` 必须按未知处理。命令输出可能包含敏感数据,按业务白名单转换后再返回。
本工具只处理已有任务文件,不提供观察或任意命令能力。保留模板 LICENSE、SOURCE.md,并补充新增代码、协议、资料与依赖的来源和业务回归。在副本中执行 `check_plugin.py --prepare-market` 生成全部未验证的市场记录;本模板自身的离线通过不能填作真实业务验收。
@@ -0,0 +1,8 @@
---
name: managed-tool-demo
description: 对任务目录中已明确批准的文件制作不可变快照,并通过固定工具校验、保存回执。
---
调用 plugin_load 返回映射后的 verify_note。name 只允许任务目录内的普通文件名,sha256 必须是用户明确批准的内容摘要;不要临时计算另一份内容的摘要来绕过批准。
固定工具只读取快照并计算摘要,不提供任意命令、不访问设备或网络。校验与回执保存成功不等于业务测试通过。超时、取消或结果不明时先观察实际状态,不自动重试写操作。没有视觉能力,不能独立承担纯电脑任务。
@@ -0,0 +1,5 @@
# 模板来源
本目录为 ARTEMIS 主程序仓库提供的插件开发模板,适用本目录 LICENSE 的 Apache-2.0。来源为主程序仓库中随该模板交付的对应源码提交;模板使用主程序的 `artemis.plugins.sdk`,不另行分发设备协议、厂商资料或用户用例。
复制后应记录自己的代码、所适配上游、第三方依赖、协议资料和用例的实际来源与许可证;新增内容的分发范围需要分别确认。模板的许可不会重新许可这些新增材料。运行记录须使用自己的实际候选版本和验证结果,不能沿用模板演示结论。
@@ -0,0 +1,23 @@
{
"schema_version": 2,
"id": "managed-tool-demo",
"name": "受管工具与文件快照示例",
"version": "1.0.0",
"description": "开发模板:读取已批准的任务文件快照,调用固定校验程序并保存真实回执。",
"artemis_version": ">=2.4.5,<3.0.0",
"platforms": ["win32", "linux", "darwin"],
"architectures": ["amd64", "arm64"],
"license": "Apache-2.0",
"server": {"command": "${config.python}", "args": ["-B", "${plugin_dir}/server.py"]},
"config": {"python": {"label": "当前 ARTEMIS 环境的 Python", "default": "python"}},
"skill": "SKILL.md",
"targets": ["android", "mixed", "computer"],
"resources": [],
"capabilities": {
"kind": "file", "recording": "none", "required_tools": ["verify_note"],
"tools": {"verify_note": {
"effect": "read", "observation": false, "idempotent": false,
"cancellation": "confirmed", "evidence": ["text", "file"]
}}
}
}
@@ -0,0 +1,128 @@
"""A fixed checksum tool using the installed ARTEMIS authoring SDK."""
import hashlib
import json
import os
from pathlib import Path
import sys
from typing import Annotated
import uuid
from mcp.server.fastmcp import FastMCP
from mcp.types import CallToolResult, TextContent, ToolAnnotations
from pydantic import Field
from artemis.plugins.sdk import ManagedProcessError, run_process, snapshot_file
mcp = FastMCP("managed-tool-demo")
CHECKSUM = "import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())"
def failure(code: str, *, execution: str = "not_started") -> CallToolResult:
return CallToolResult(
isError=True,
content=[TextContent(type="text", text=code)],
structuredContent={
"artemis": {
"version": 1,
"execution": execution,
"side_effect": "none",
"error_code": code,
"retryable": False,
}
},
)
@mcp.tool(
annotations=ToolAnnotations(readOnlyHint=True, destructiveHint=False, idempotentHint=False)
)
async def verify_note(
name: Annotated[str, Field(min_length=1, max_length=180)],
sha256: Annotated[str, Field(pattern=r"^[a-fA-F0-9]{64}$")],
) -> CallToolResult:
"""Verify a task-owned file of at most 64 MiB against its explicitly approved SHA256."""
location = os.environ.get("ARTEMIS_ARTIFACTS_DIR")
if (
not location
or name in {".", ".."}
or Path(name).name != name
or any(c in name for c in '/\\:*?"<>|')
or name.rstrip(" .") != name
or any(ord(c) < 32 for c in name)
):
return failure("invalid_task_filename")
root = Path(location)
try:
if root.is_symlink() or root.is_junction() or root.resolve() != root.absolute():
return failure("invalid_task_directory")
snapshot = snapshot_file(root / name, sha256)
except (OSError, ValueError):
return failure("input_not_bound_to_approved_digest")
try:
# Never reopen the original pathname after verification. The fixed tool
# receives the same immutable bytes even if the source file changes.
result = await run_process(
[sys.executable, "-I", "-B", "-c", CHECKSUM],
cwd=root,
timeout=5,
input_data=snapshot.data,
)
except ManagedProcessError as exc:
return failure(
exc.error_code,
execution="unknown"
if exc.error_code == "process_cleanup_unconfirmed"
else "partial"
if exc.may_have_started
else "not_started",
)
if (
result.returncode != 0
or result.stdout.strip().decode("ascii", errors="replace") != snapshot.sha256
):
return failure("checksum_tool_result_invalid", execution="partial")
content = json.dumps(
{
"source_name": name,
"snapshot_sha256": snapshot.sha256,
"snapshot_bytes": snapshot.size,
"tool_returncode": result.returncode,
"test_verdict": "not_evaluated",
},
ensure_ascii=False,
).encode("utf-8")
receipt_name = f"verified-{uuid.uuid4().hex}.json"
try:
with (root / receipt_name).open("xb") as handle:
handle.write(content)
except OSError:
return failure("receipt_write_failed", execution="partial")
return CallToolResult(
content=[TextContent(type="text", text="已核对批准的文件快照,回执已保存。")],
structuredContent={
"snapshot_sha256": snapshot.sha256,
"artemis": {
"version": 1,
"execution": "completed",
"side_effect": "none",
"retryable": False,
"source": {
"kind": "file",
"target_id": "task-artifacts",
"label": "已批准文件快照",
},
"attachments": [
{
"name": receipt_name,
"media_type": "application/json",
"sha256": hashlib.sha256(content).hexdigest(),
}
],
},
},
)
if __name__ == "__main__":
mcp.run(transport="stdio")
@@ -0,0 +1,69 @@
"""Run with the current ARTEMIS Python; no pytest/device/service/config needed."""
import asyncio
import hashlib
import json
from pathlib import Path
import sys
import tempfile
from artemis.plugins.evidence import persist_attachments, validate_tools
from artemis.plugins.models import PluginManifest, PluginProfile
from artemis.plugins.sdk_checks import run_checks
from artemis.plugins.session import PluginSession, resolve_server
async def run_contract():
directory = Path(__file__).resolve().parent
manifest = PluginManifest.model_validate_json((directory / "plugin.json").read_text("utf-8"))
with tempfile.TemporaryDirectory(prefix="artemis-template-contract-") as temporary:
trace = Path(temporary).resolve()
artifacts = trace / "artifacts" / "plugins" / manifest.id
artifacts.mkdir(parents=True)
content = "synthetic local sample\n中文\x00".encode()
(artifacts / "样例.txt").write_bytes(content)
server = resolve_server(
manifest, PluginProfile(values={"python": sys.executable}), directory
)
server.env["ARTEMIS_ARTIFACTS_DIR"] = str(artifacts)
server.env["PYTHONDONTWRITEBYTECODE"] = "1"
session = await PluginSession(server, directory).start()
try:
validate_tools(manifest, session.tools)
success = await session.call(
"verify_note", {"name": "样例.txt", "sha256": hashlib.sha256(content).hexdigest()}
)
if success.isError:
raise AssertionError("MCP 校验入口失败")
receipt = success.structuredContent["artemis"]
attachments = persist_attachments(trace, manifest.id, receipt["attachments"])
if len(attachments) != 1:
raise AssertionError("宿主未回读到校验回执")
for name, digest in (("样例.txt", "0" * 64), ("../outside.txt", "0" * 64)):
denied = await session.call("verify_note", {"name": name, "sha256": digest})
if (
not denied.isError
or denied.structuredContent["artemis"]["execution"] != "not_started"
):
raise AssertionError("无效输入没有在工具启动前拒绝")
finally:
await session.aclose()
return {
"status": "passed",
"scope": "template_offline_contract",
"mcp_calls": 3,
"host_attachments_verified": 1,
"real_device_actions": False,
}
async def main():
print(
json.dumps(
{"sdk": await run_checks(), "template": await run_contract()}, ensure_ascii=False
)
)
if __name__ == "__main__":
asyncio.run(main())
+1 -1
View File
@@ -14,7 +14,7 @@
[project] [project]
name = "artemis" name = "artemis"
version = "2.4.0" version = "2.4.5"
description = "Autonomous AI Assistant for Mobile Automation" description = "Autonomous AI Assistant for Mobile Automation"
authors = [ authors = [
+4
View File
@@ -6,6 +6,10 @@
"artemis/graph/visibility.py", "artemis/graph/visibility.py",
"artemis/runtime/adb_endpoint.py", "artemis/runtime/adb_endpoint.py",
"artemis/runtime/process_probe.py", "artemis/runtime/process_probe.py",
"artemis/plugins/sdk.py",
"artemis/plugins/market.py",
"artemis/plugins/validation.py",
"artemis/plugins/sdk_checks.py",
"artemis/mcp/action_types.py", "artemis/mcp/action_types.py",
"artemis/data_engine/models.py", "artemis/data_engine/models.py",
"artemis/agents/explorer/perception_tools.py", "artemis/agents/explorer/perception_tools.py",
+2 -42
View File
@@ -1,46 +1,6 @@
"""Validate a plugin package; --live checks MCP declarations without executing actions.""" """Shared plugin development and market-materials checker; no tool actions."""
import argparse from artemis.plugins.validation import check as check, main
import asyncio
import json
from pathlib import Path
from artemis.plugins.evidence import validate_tools
from artemis.plugins.models import PluginManifest, PluginProfile
from artemis.plugins.session import PluginSession, resolve_server
from artemis.plugins.store import contained_path
async def check(directory: Path, live: bool, values: dict[str, str]) -> None:
manifest = PluginManifest.model_validate_json((directory / "plugin.json").read_text("utf-8"))
manifest.check_compatible()
if manifest.skill:
skill = contained_path(directory, manifest.skill)
if not skill.is_file() or not skill.read_text("utf-8").strip():
raise ValueError("缺少有效的 Skill")
if manifest.schema_version not in {2, 3}:
raise ValueError("新市场插件必须适配 schema_version=2 或 3")
if live and manifest.server:
server = resolve_server(manifest, PluginProfile(values=values), directory)
session = await PluginSession(server, directory).start()
try:
validate_tools(manifest, session.tools)
finally:
await session.aclose()
print(
f"PASS {manifest.id} {manifest.version}: 清单/兼容性/Skill"
+ ("/MCP 工具声明" if live and manifest.server else "")
)
print("此检查不代替实际执行、超时取消、证据导出和视觉能力验收。")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("directory", type=Path)
parser.add_argument("--live", action="store_true")
parser.add_argument("--values", default="{}", help="普通配置 JSON;不得包含密钥")
args = parser.parse_args()
asyncio.run(check(args.directory.resolve(), args.live, json.loads(args.values)))
if __name__ == "__main__": if __name__ == "__main__":
+6 -10
View File
@@ -218,14 +218,10 @@ function Test-NodeCompatible {
$verRaw = (& node -v 2>$null) $verRaw = (& node -v 2>$null)
if (-not $verRaw) { return $false } if (-not $verRaw) { return $false }
$verStr = $verRaw.ToString().Trim().TrimStart('v') $verStr = $verRaw.ToString().Trim().TrimStart('v')
$parts = $verStr.Split('.') $version = [version]$verStr
if ($parts.Count -ge 2) { if ($version.Major -ge 26) { return $true }
$major = [int]$parts[0] if ($version.Major -eq 24 -and $version -ge [version]'24.15.0') { return $true }
$minor = [int]$parts[1] if ($version.Major -eq 22 -and $version -ge [version]'22.22.3') { return $true }
if ($major -ge 26) { return $true }
if ($major -eq 24 -and $minor -ge 15) { return $true }
if ($major -eq 22 -and $minor -ge 22) { return $true }
}
} catch { } catch {
return $false return $false
} }
@@ -383,7 +379,7 @@ if ($frontendChanged -or ((-not (Test-Path $ShowcaseIndex)) -and (-not (Test-Pat
if (-not (Test-NodeCompatible)) { if (-not (Test-NodeCompatible)) {
if (Test-CommandExists "node") { if (Test-CommandExists "node") {
$curVer = (& node -v 2>$null) $curVer = (& node -v 2>$null)
Write-Host (Get-LaunchText " [INFO] Detected Node.js $curVer, but Angular CLI requires Node.js >= v22.22.0." " [检查] 检测到 Node.js $curVer;前端需要 v22.22.0 或更新版本。") -ForegroundColor Yellow Write-Host (Get-LaunchText " [INFO] Detected Node.js $curVer; Angular CLI requires 22.x >= 22.22.3, 24.x >= 24.15.0, or >= 26.0.0." " [检查] 检测到 Node.js $curVer;前端需要 22.x >= 22.22.3、24.x >= 24.15.0 或 >= 26.0.0。") -ForegroundColor Yellow
} else { } else {
Write-Host (Get-LaunchText " [INFO] Node.js/npm not found (required for Showcase UI)." " [检查] 未找到 Node.js/npm,网页构建需要安装。") -ForegroundColor Cyan Write-Host (Get-LaunchText " [INFO] Node.js/npm not found (required for Showcase UI)." " [检查] 未找到 Node.js/npm,网页构建需要安装。") -ForegroundColor Cyan
} }
@@ -401,7 +397,7 @@ if ($frontendChanged -or ((-not (Test-Path $ShowcaseIndex)) -and (-not (Test-Pat
$useWinGetNode = $false $useWinGetNode = $false
if ($ArtemisDownloads.mode -ne 'China' -and (Test-CommandExists "winget")) { if ($ArtemisDownloads.mode -ne 'China' -and (Test-CommandExists "winget")) {
if ([Console]::IsInputRedirected -eq $false) { if ([Console]::IsInputRedirected -eq $false) {
Write-Host (Get-LaunchText " [INFO] Node.js >= v22.22.0 required for Showcase UI." " [检查] 网页需要 Node.js v22.22.0 或更新版本。") -ForegroundColor Cyan Write-Host (Get-LaunchText " [INFO] Showcase UI requires Node.js 22.x >= 22.22.3, 24.x >= 24.15.0, or >= 26.0.0." " [检查] 网页需要 Node.js 22.x >= 22.22.3、24.x >= 24.15.0 或 >= 26.0.0。") -ForegroundColor Cyan
$ans = Read-Host (Get-LaunchText " Install/Upgrade Node.js via WinGet (may require Administrator)? [Y/n]" " 通过 WinGet 安装/升级 Node.js(可能需要管理员权限)?[Y/n]") $ans = Read-Host (Get-LaunchText " Install/Upgrade Node.js via WinGet (may require Administrator)? [Y/n]" " 通过 WinGet 安装/升级 Node.js(可能需要管理员权限)?[Y/n]")
if ($ans -eq "" -or $ans -match "^[Yy]") { if ($ans -eq "" -or $ans -match "^[Yy]") {
$useWinGetNode = $true $useWinGetNode = $true
+1
View File
@@ -17,6 +17,7 @@ ARTEMIS 自己编排手机和已授权插件,共用网页的任务队列、历
| 给出任务编号,问失败原因、token、缓存、录像或最终结论 | [诊断与复盘](references/diagnostics.md) | | 给出任务编号,问失败原因、token、缓存、录像或最终结论 | [诊断与复盘](references/diagnostics.md) |
| 使用电脑、路由器、终端等已安装能力 | [插件任务参数](references/plugins.md) | | 使用电脑、路由器、终端等已安装能力 | [插件任务参数](references/plugins.md) |
| 编写、适配、验证或发布 ARTEMIS 插件 | [插件开发规范](references/plugin-development.md),按需读取 [清单与市场协议](references/plugin-manifest.md) | | 编写、适配、验证或发布 ARTEMIS 插件 | [插件开发规范](references/plugin-development.md),按需读取 [清单与市场协议](references/plugin-manifest.md) |
| 插件调用外部程序、消费已批准文件或准备市场材料 | [运行辅助接口、故障自测与市场检查](references/plugin-sdk.md) |
| 开发回放侧栏、人工表单或扩展报告字段 | [工作区插件接口(ARTEMIS 2.3.0+)](references/workspace-plugins.md) | | 开发回放侧栏、人工表单或扩展报告字段 | [工作区插件接口(ARTEMIS 2.3.0+)](references/workspace-plugins.md) |
只读当前任务相关参考。普通执行不加载插件开发文档。 只读当前任务相关参考。普通执行不加载插件开发文档。
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
https://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 Google LLC.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@@ -0,0 +1,5 @@
# 模板来源
本目录为 ARTEMIS 主程序仓库提供的文件证据开发模板,适用本目录 LICENSE 的 Apache-2.0,来源以随模板交付的主程序源码提交为准。没有包含厂商协议、内部资料或用户测试数据。
复制模板后,新增代码、资料和第三方依赖需要分别记录实际来源与分发许可,并为本次候选包保存真实验证结果。模板许可不重新许可新增内容。
@@ -7,7 +7,8 @@
"artemis_version": ">=2.0.0,<3.0.0", "artemis_version": ">=2.0.0,<3.0.0",
"platforms": ["win32", "linux", "darwin"], "platforms": ["win32", "linux", "darwin"],
"architectures": ["amd64", "arm64"], "architectures": ["amd64", "arm64"],
"server": {"command": "${config.python}", "args": ["${plugin_dir}/server.py"]}, "license": "Apache-2.0",
"server": {"command": "${config.python}", "args": ["-B", "${plugin_dir}/server.py"]},
"config": {"python": {"label": "含 MCP 依赖的 Python", "default": "python"}}, "config": {"python": {"label": "含 MCP 依赖的 Python", "default": "python"}},
"skill": "SKILL.md", "skill": "SKILL.md",
"targets": ["mixed", "computer"], "targets": ["mixed", "computer"],
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
https://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 Google LLC.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@@ -0,0 +1,19 @@
# 受管工具与文件快照模板
本模板使用 ARTEMIS 2.4.5 新增的 `artemis.plugins.sdk`,最低宿主版本为 2.4.5。复制模板后使用该版本或更新版本的 ARTEMIS Python;正式的 2.4.4 包不提供这些接口。模板随源码和 Skill 分发,尚未发布到市场。纯文件写入可用相邻 evidence-demo,本模板用于需要外部工具或已批准文件输入的插件。
复制整个目录,使用 ARTEMIS 自己的 Python:
```sh
<ARTEMIS_PYTHON> -B test_contract.py
```
测试只在临时目录创建样例和休眠进程,验证 SDK 的文件替换、超时、取消、后代回收、输出上限,以及真实 stdio MCP 调用、错误输入拒绝和宿主附件回读。结果分为 SDK 自测与本模板离线契约,不证明改写后的插件、设备或业务通过。
扩展时保持固定 argv 和工作目录。先用 `snapshot_file(path, approved_sha256)` 得到不可变 bytes,再将 `snapshot.data` 或 `snapshot.chunks()` 交给实际消费者;不要重新打开原路径。默认内存输入上限 64 MiB,按明确需求选择上限;超大固件需要另外实现有等价保护的流式存储,并单独验证。
`await run_process(argv, cwd=..., timeout=..., input_data=...)` 不执行 shell、不重试,返回原始退出码与 stdout/stderr。标准输入最多 64 MiB,stdout/stderr 共用默认 4 MiB 上限。它在正常退出、超时、取消、输出超限时回收后代;Windows 先将等待启动的引导进程加入 Job Object,再运行工具。POSIX 使用独立会话;这些是生命周期管理,不是恶意程序沙箱,不能托管主动逃离进程组的程序。
宿主负责整个 MCP 调用的期限;插件负责内部操作和子进程。内部超时先发生时,插件必须在返回前完成回收。取消会在回收后传播,不能吞掉 `CancelledError` 后声称完成。`ManagedProcessError.error_code` 稳定且不回显命令,`may_have_started` 决定能否声明尚未执行;`process_cleanup_unconfirmed` 必须按未知处理。命令输出可能包含敏感数据,按业务白名单转换后再返回。
本工具只处理已有任务文件,不提供观察或任意命令能力。保留模板 LICENSE、SOURCE.md,并补充新增代码、协议、资料与依赖的来源和业务回归。在副本中执行 `check_plugin.py --prepare-market` 生成全部未验证的市场记录;本模板自身的离线通过不能填作真实业务验收。
@@ -0,0 +1,8 @@
---
name: managed-tool-demo
description: 对任务目录中已明确批准的文件制作不可变快照,并通过固定工具校验、保存回执。
---
调用 plugin_load 返回映射后的 verify_note。name 只允许任务目录内的普通文件名,sha256 必须是用户明确批准的内容摘要;不要临时计算另一份内容的摘要来绕过批准。
固定工具只读取快照并计算摘要,不提供任意命令、不访问设备或网络。校验与回执保存成功不等于业务测试通过。超时、取消或结果不明时先观察实际状态,不自动重试写操作。没有视觉能力,不能独立承担纯电脑任务。
@@ -0,0 +1,5 @@
# 模板来源
本目录为 ARTEMIS 主程序仓库提供的插件开发模板,适用本目录 LICENSE 的 Apache-2.0。来源为主程序仓库中随该模板交付的对应源码提交;模板使用主程序的 `artemis.plugins.sdk`,不另行分发设备协议、厂商资料或用户用例。
复制后应记录自己的代码、所适配上游、第三方依赖、协议资料和用例的实际来源与许可证;新增内容的分发范围需要分别确认。模板的许可不会重新许可这些新增材料。运行记录须使用自己的实际候选版本和验证结果,不能沿用模板演示结论。
@@ -0,0 +1,23 @@
{
"schema_version": 2,
"id": "managed-tool-demo",
"name": "受管工具与文件快照示例",
"version": "1.0.0",
"description": "开发模板:读取已批准的任务文件快照,调用固定校验程序并保存真实回执。",
"artemis_version": ">=2.4.5,<3.0.0",
"platforms": ["win32", "linux", "darwin"],
"architectures": ["amd64", "arm64"],
"license": "Apache-2.0",
"server": {"command": "${config.python}", "args": ["-B", "${plugin_dir}/server.py"]},
"config": {"python": {"label": "当前 ARTEMIS 环境的 Python", "default": "python"}},
"skill": "SKILL.md",
"targets": ["android", "mixed", "computer"],
"resources": [],
"capabilities": {
"kind": "file", "recording": "none", "required_tools": ["verify_note"],
"tools": {"verify_note": {
"effect": "read", "observation": false, "idempotent": false,
"cancellation": "confirmed", "evidence": ["text", "file"]
}}
}
}
@@ -0,0 +1,128 @@
"""A fixed checksum tool using the installed ARTEMIS authoring SDK."""
import hashlib
import json
import os
from pathlib import Path
import sys
from typing import Annotated
import uuid
from mcp.server.fastmcp import FastMCP
from mcp.types import CallToolResult, TextContent, ToolAnnotations
from pydantic import Field
from artemis.plugins.sdk import ManagedProcessError, run_process, snapshot_file
mcp = FastMCP("managed-tool-demo")
CHECKSUM = "import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())"
def failure(code: str, *, execution: str = "not_started") -> CallToolResult:
return CallToolResult(
isError=True,
content=[TextContent(type="text", text=code)],
structuredContent={
"artemis": {
"version": 1,
"execution": execution,
"side_effect": "none",
"error_code": code,
"retryable": False,
}
},
)
@mcp.tool(
annotations=ToolAnnotations(readOnlyHint=True, destructiveHint=False, idempotentHint=False)
)
async def verify_note(
name: Annotated[str, Field(min_length=1, max_length=180)],
sha256: Annotated[str, Field(pattern=r"^[a-fA-F0-9]{64}$")],
) -> CallToolResult:
"""Verify a task-owned file of at most 64 MiB against its explicitly approved SHA256."""
location = os.environ.get("ARTEMIS_ARTIFACTS_DIR")
if (
not location
or name in {".", ".."}
or Path(name).name != name
or any(c in name for c in '/\\:*?"<>|')
or name.rstrip(" .") != name
or any(ord(c) < 32 for c in name)
):
return failure("invalid_task_filename")
root = Path(location)
try:
if root.is_symlink() or root.is_junction() or root.resolve() != root.absolute():
return failure("invalid_task_directory")
snapshot = snapshot_file(root / name, sha256)
except (OSError, ValueError):
return failure("input_not_bound_to_approved_digest")
try:
# Never reopen the original pathname after verification. The fixed tool
# receives the same immutable bytes even if the source file changes.
result = await run_process(
[sys.executable, "-I", "-B", "-c", CHECKSUM],
cwd=root,
timeout=5,
input_data=snapshot.data,
)
except ManagedProcessError as exc:
return failure(
exc.error_code,
execution="unknown"
if exc.error_code == "process_cleanup_unconfirmed"
else "partial"
if exc.may_have_started
else "not_started",
)
if (
result.returncode != 0
or result.stdout.strip().decode("ascii", errors="replace") != snapshot.sha256
):
return failure("checksum_tool_result_invalid", execution="partial")
content = json.dumps(
{
"source_name": name,
"snapshot_sha256": snapshot.sha256,
"snapshot_bytes": snapshot.size,
"tool_returncode": result.returncode,
"test_verdict": "not_evaluated",
},
ensure_ascii=False,
).encode("utf-8")
receipt_name = f"verified-{uuid.uuid4().hex}.json"
try:
with (root / receipt_name).open("xb") as handle:
handle.write(content)
except OSError:
return failure("receipt_write_failed", execution="partial")
return CallToolResult(
content=[TextContent(type="text", text="已核对批准的文件快照,回执已保存。")],
structuredContent={
"snapshot_sha256": snapshot.sha256,
"artemis": {
"version": 1,
"execution": "completed",
"side_effect": "none",
"retryable": False,
"source": {
"kind": "file",
"target_id": "task-artifacts",
"label": "已批准文件快照",
},
"attachments": [
{
"name": receipt_name,
"media_type": "application/json",
"sha256": hashlib.sha256(content).hexdigest(),
}
],
},
},
)
if __name__ == "__main__":
mcp.run(transport="stdio")
@@ -0,0 +1,69 @@
"""Run with the current ARTEMIS Python; no pytest/device/service/config needed."""
import asyncio
import hashlib
import json
from pathlib import Path
import sys
import tempfile
from artemis.plugins.evidence import persist_attachments, validate_tools
from artemis.plugins.models import PluginManifest, PluginProfile
from artemis.plugins.sdk_checks import run_checks
from artemis.plugins.session import PluginSession, resolve_server
async def run_contract():
directory = Path(__file__).resolve().parent
manifest = PluginManifest.model_validate_json((directory / "plugin.json").read_text("utf-8"))
with tempfile.TemporaryDirectory(prefix="artemis-template-contract-") as temporary:
trace = Path(temporary).resolve()
artifacts = trace / "artifacts" / "plugins" / manifest.id
artifacts.mkdir(parents=True)
content = "synthetic local sample\n中文\x00".encode()
(artifacts / "样例.txt").write_bytes(content)
server = resolve_server(
manifest, PluginProfile(values={"python": sys.executable}), directory
)
server.env["ARTEMIS_ARTIFACTS_DIR"] = str(artifacts)
server.env["PYTHONDONTWRITEBYTECODE"] = "1"
session = await PluginSession(server, directory).start()
try:
validate_tools(manifest, session.tools)
success = await session.call(
"verify_note", {"name": "样例.txt", "sha256": hashlib.sha256(content).hexdigest()}
)
if success.isError:
raise AssertionError("MCP 校验入口失败")
receipt = success.structuredContent["artemis"]
attachments = persist_attachments(trace, manifest.id, receipt["attachments"])
if len(attachments) != 1:
raise AssertionError("宿主未回读到校验回执")
for name, digest in (("样例.txt", "0" * 64), ("../outside.txt", "0" * 64)):
denied = await session.call("verify_note", {"name": name, "sha256": digest})
if (
not denied.isError
or denied.structuredContent["artemis"]["execution"] != "not_started"
):
raise AssertionError("无效输入没有在工具启动前拒绝")
finally:
await session.aclose()
return {
"status": "passed",
"scope": "template_offline_contract",
"mcp_calls": 3,
"host_attachments_verified": 1,
"real_device_actions": False,
}
async def main():
print(
json.dumps(
{"sdk": await run_checks(), "template": await run_contract()}, ensure_ascii=False
)
)
if __name__ == "__main__":
asyncio.run(main())
@@ -1,6 +1,6 @@
# 插件开发 SDK 与证据协议(ARTEMIS 2.3.0) # 插件开发 SDK 与证据协议(ARTEMIS 2.4.5)
新插件必须适配本文规则,再通过实测进入市场。旧 `schema_version: 1` 仍可读取;执行插件使用版本 2,并声明 `capabilities`。工作区面板和人工表单使用版本 3,详见[工作区插件接口](workspace-plugins.md)。纯工作区插件不加入任务执行选择,不向模型开放人工评审写入工具。模型执行、任务队列、资源锁、超时和报告由 ARTEMIS 管理,插件不另建任务调度器,也不替用户决定用例通过。 新插件必须适配本文规则,再通过实测进入市场。旧 `schema_version: 1` 仍可读取;执行插件使用版本 2,并声明 `capabilities`。工作区面板和人工表单使用版本 3,详见[工作区插件接口](workspace-plugins.md)。纯工作区插件不加入任务执行选择,不向模型开放人工评审写入工具。模型执行、任务队列、资源锁、整体调用预算和报告由 ARTEMIS 管理;插件负责内部工具的超时、取消与子进程收尾,不另建任务调度器,也不替用户决定用例通过。
## 从可运行模板开始 ## 从可运行模板开始
@@ -25,6 +25,12 @@
静态检查通过不等于市场准入,也不表示业务测试通过。需按下面清单提交实际验证记录。 静态检查通过不等于市场准入,也不表示业务测试通过。需按下面清单提交实际验证记录。
## 运行辅助接口与市场材料
涉及外部程序或已批准文件输入时,复制 [managed-tool-demo](../assets/managed-tool-demo/README.md) 整个目录开始;最低宿主为 ARTEMIS 2.4.5,使用该版本或更新版本的 Python。固定 argv 和工作目录,用 `snapshot_file` 取得已批准的不可变字节并交给工具,不重新打开原路径。内部超时、取消和正常结束都要回收自己启动的后代;无法确认收尾时如实返回未知状态,不自动重发写操作。SDK 故障检查与模板离线通过不能替代真实任务。
接口与命令见 [SDK 与市场材料](plugin-sdk.md)。在用户插件目录的副本中,使用 `<ARTEMIS_PYTHON> <本Skill目录>/scripts/check_plugin.py <插件目录> --prepare-market` 生成未验证的 `MARKET.json`,再补全来源许可、组件分发授权与实际验收证据。对最终 ZIP 使用同一脚本的 `--market` 核对内容摘要与材料;它只握手核对 MCP 声明,不执行业务工具,也不自动发布。
## 清单与工具能力 ## 清单与工具能力
`plugin.json` 的安装、版本约束、资源、权限设置沿用 [插件说明](plugin-manifest.md)。版本 2 增加: `plugin.json` 的安装、版本约束、资源、权限设置沿用 [插件说明](plugin-manifest.md)。版本 2 增加:
+121
View File
@@ -0,0 +1,121 @@
# 插件运行辅助接口与市场检查
这些接口从 ARTEMIS **2.4.5** 开始提供,位于 `artemis.plugins.sdk`;使用 ARTEMIS 2.4.5 或更新版本的 Python,并在插件清单中声明 `artemis_version: ">=2.4.5,<3.0.0"`。它们不属于远程薄客户端 `artemis-client`,不启动后台、模型或第二套任务队列。现有不依赖这些辅助接口的插件仍按自身清单兼容旧宿主;采用这些接口才需要提高最低版本。
## 已批准文件必须绑定实际使用的字节
```python
from artemis.plugins.sdk import snapshot_file
snapshot = snapshot_file(authorized_path, approved_sha256)
# 此后只消费快照。连接建立、预检或等待期间原文件发生变化,也不会改变快照。
for chunk in snapshot.chunks():
transport.sendall(chunk)
```
`approved_sha256` 必须来自已批准的内容身份,不能在执行时把新文件的摘要自动当作新的批准。制作快照、验证设备身份和写入授权都应在真正写操作前完成。使用 `.data` 或只读的 `.chunks()`,不要校验之后重新打开原路径;传输后才发现摘要不符不能撤销已经发生的写入。
`snapshot_file` 一次读取授权的普通文件并核对 SHA256,拒绝链接、特殊文件、大小超限和摘要不符。默认上限 64 MiB,副本是不可变内存 bytes;参数 `max_bytes` 仅作用于本次调用,选择更大上限须评估内存。它不负责用户授权或路径沙箱,插件仍要限制允许读取的范围。大文件如采用磁盘快照或流式方案,应提供等价的防修改保证并另行验证,不能恢复到“检查后重开路径”。
`VerifiedFile.size`、`.sha256` 对应快照本身。原路径同长度覆盖、替换、增长或截断后,快照仍保持原内容。对写入端到端的回归必须检查实际发送字节,不能只断言预检函数被调用。
## 外部工具的生命周期
```python
from artemis.plugins.sdk import ManagedProcessError, run_process
try:
result = await run_process(
fixed_argv,
cwd=authorized_directory,
timeout=20,
input_data=snapshot.data,
)
except ManagedProcessError as exc:
# 用稳定的 error_code 返回部分执行/未知;不回显命令或原始敏感输出,不自动重试。
...
```
`fixed_argv` 必须是业务层构造的固定参数列表,不能向模型暴露任意 shell;`cwd` 为明确的现有目录。可通过 `env` 提供所需的环境白名单;未提供时继承当前插件环境,插件应自行排除不需要的密钥和工具日志变量。宿主的凭据管理不会自动证明外部程序的输出安全。
`timeout` 必须为有限正数,作用于本次工具运行;标准输入最多 64 MiB。stdout、stderr 分别返回 bytes,共用默认 4 MiB 上限,可通过本次调用的 `max_output_bytes` 调整。输出超限立即停止;部分输出不作有效结果返回。`ProcessResult.returncode` 保留非零退出码,不据此生成业务用例通过结论。
运行器在 Windows 先将等待启动的引导进程加入不可继承的 Job Object,再允许启动实际工具,防止快速派生后代漏出归属;父进程退出时 Job Object 同样回收其树。POSIX 使用独立会话,正常退出、超时、取消和输出超限均回收所属进程组。它是生命周期辅助,不是恶意程序沙箱,不能承诺回收主动逃离会话的程序。
调用结束后不保留后台子进程。需要常驻驱动的插件应实现会话级归属、关闭与验证,不借本接口后台化工具。取消在清理后继续传播,不能吞掉 `CancelledError`。清理另有固定的有界等待;未确认完成时抛出 `process_cleanup_unconfirmed`,插件必须返回未知状态并核对资源,不声明已全部停止。
常见错误码:`process_timeout`、`process_output_limit`、`process_launch_failed`、`process_ownership_failed`、`process_worker_interrupted`、`process_invalid_status`、`process_cleanup_unconfirmed`。`may_have_started=false` 只表示已知工具尚未启动;其余情况不能授权重发写操作。运行器从不重试。原始输出可能包含密钥,返回给模型前按业务字段白名单处理。
宿主负责整体任务与 MCP 调用预算,插件负责内部操作及其后代。内部超时先发生时,必须在向宿主返回前收尾;宿主未超时、或只关闭 MCP 连接,都不能替代这一步。
## 可运行回归
```sh
<ARTEMIS_PYTHON> -B -m artemis.plugins.sdk_checks --json-output sdk-faults.json
<ARTEMIS_PYTHON> -B <受管工具模板目录>/test_contract.py
```
模板目录在源码仓库的 `examples/plugins/managed-tool-demo`,或本 Skill 的 `assets/managed-tool-demo`;先复制到用户指定开发目录。以下检查均使用 ARTEMIS 2.4.5 或更新版本的 Python。
第一条只在临时目录创建合成文件和自己的测试进程,覆盖文件同长度覆盖、截断/增长、错误摘要、二进制 stdin、非零退出码、正常结束/超时/取消后的后代回收、输出超限、附件摘要与路径拒绝,以及部分/未知执行不能授权重试。它不连接设备、不调用模型、不修改现有任务或配置。结果明确标为 `sdk_faults_only`。
第二条额外通过真实 stdio MCP 调用模板工具,核对有效输入、错误摘要、目录穿越拒绝及宿主附件回读,结果标为 `template_offline_contract`。模板见插件开发文档的受管工具示例。
改写插件后,必须扩展模板测试到自己的实际入口:在最后一次预检与真正消费文件之间替换输入、让实际工具创建后代再超时/取消,并检查实际传输和进程存活。SDK 自测通过不能证明插件调用正确。还需覆盖错误/部分执行、证据丢失或摘要不符、权限、资源互斥以及真实任务时间线。
## 开发检查与市场材料检查
统一入口为仓库/Skill 的 `check_plugin.py`,也可执行 `python -m artemis.plugins.validation`。两处脚本调用同一个实现,不单独维护规则。
```sh
# 日常本地开发,无需公开分发许可;--live 只握手,不派发工具。
<ARTEMIS_PYTHON> <检查脚本> <插件目录或ZIP> --live --values '{"python":"ARTEMIS解释器绝对路径"}'
# 在清洁候选目录生成草稿;所有结论未验证、许可未知,不覆盖已有 MARKET.json。
<ARTEMIS_PYTHON> <检查脚本> <插件目录> --prepare-market
# 对最终候选 ZIP 检查市场材料;包含只读 MCP 声明握手,不调用业务工具。
<ARTEMIS_PYTHON> <检查脚本> <最终ZIP> --market --values '{"python":"ARTEMIS解释器绝对路径"}' --json-output market-check.json
```
路径与 JSON 按当前 Shell 引用;密钥不放进 `--values`。`--json-output` 必须在候选目录外,不能覆盖输入 ZIP。ZIP 沿用宿主的安全解压和大小限制,检查不向已有插件库安装。`--market` 先核对材料,再握手,普通开发检查仍保持轻量。
市场检查拒绝缓存、`.pyc`、虚拟环境、版本库目录、个人 `.env` 配置、链接和冲突路径。环境样例可保留 `.env.example`。不要为通过检查而清理用户运行目录;从可分发输入构建独立候选。
## MARKET.json
此文件随包提交,版本固定为 1,不增加 `plugin.json` 字段,旧宿主仍可安装符合其能力的插件。草稿由检查脚本生成,包含:
| 字段 | 要求 |
| --- | --- |
| `plugin_id`、`plugin_version` | 与候选清单完全一致 |
| `content_sha256` | 全部包内文件路径及 SHA256 的确定性摘要,只排除根层 `MARKET.json` 自身 |
| `traits.uses_subprocesses` | 明确 true/false,不能保留 null |
| `traits.file_inputs_for_writes` | 是否消费文件执行写操作,明确 true/false |
| `components` | 分开列原始实现、适配层、第三方依赖/参考资料的许可和来源 |
| `checks` | 下表全部验收项目,保留实际范围和证据 |
每个组件包含 `name`、`paths`、`license`、`permission`、`license_file`、`source_file`。`paths` 为精确包内相对路径或目录前缀,`.` 表示整个包;全部可分发文件须被覆盖。只有明确的 `permission: "permitted"` 才接受,`local_only` 和 `unknown` 均阻止市场提交。许可和来源文件必须实际在包内;适配层开源许可不会重新许可原始代码、用例、协议或内部资料。可使用合适的 LicenseRef,不强制统一开源,但具体分发授权必须可审查。
每条验收记录包含 `status`、`evidence_file`、`evidence_sha256`、`notes`。`passed` 必须指向真实的非空包内文档及其正确摘要,notes 说明验证环境、范围与结果;缺失、失败和未验证均拒绝。不能把历史记录、SDK 自测或模拟设备改写为本次真实业务通过。
| 验收项目 | 要记录的内容 |
| --- | --- |
| `installation` | 安装、升级、平台和依赖兼容 |
| `tool_contract` | 实际 MCP Schema、能力和参数限制 |
| `failure_partial` | 错误、部分执行、未知副作用与不盲目重试 |
| `timeout_cancel` | 实际工具入口超时与取消 |
| `process_tree` | 工具创建后代后,正常/超时/取消的回收验证 |
| `file_binding` | 已批准文件被替换、截断、增长时的实际消费字节 |
| `resource_conflicts` | 同设备/桌面资源互斥 |
| `evidence_export` | 导出索引、中文路径、摘要不符与缺失附件 |
| `history_uninstall` | 卸载/升级后历史仍可读 |
| `secret_redaction` | 配置、命令、输出和回执脱敏 |
| `real_task` | 经宿主正常任务的操作、时间线、暂停/恢复、证据和报告 |
| `source_review` | 对本次包的源码审查、来源与分发授权核对 |
仅在声明无相应行为时,`process_tree`、`file_binding` 可 `not_applicable`;无 MCP 时 `tool_contract`、`timeout_cancel` 可不适用;无声明资源时 `resource_conflicts` 可不适用。仍须在 notes 中给出理由,不能把未测写成不适用。其他项目须有真实通过记录。
更新任何文件后,旧材料会失效。先重跑受影响验证,再用 `--fingerprint` 查看新的内容摘要,并更新记录;不要只刷新摘要来沿用过期结论。握手结束后重新核对内容与市场记录,检查期间改动会使检查失败。最终 ZIP 检查结果另包含实际解压检查的同一份字节的 SHA256,不重新打开原路径来记录替换文件;公开发布时仍需核对固定附件、市场索引与无认证下载字节。
市场材料检查验证字段、覆盖范围、摘要和证据存在性,不证明声明真实、不替作者确认权利归属,也不自动发布。源码审查和实际业务验收仍由发布方核对;文档、静态检查、握手、SDK 故障自测与真实业务结论分别记录。
+2 -42
View File
@@ -1,46 +1,6 @@
"""Validate a plugin package; --live checks MCP declarations without executing actions.""" """Shared plugin development and market-materials checker; no tool actions."""
import argparse from artemis.plugins.validation import check as check, main
import asyncio
import json
from pathlib import Path
from artemis.plugins.evidence import validate_tools
from artemis.plugins.models import PluginManifest, PluginProfile
from artemis.plugins.session import PluginSession, resolve_server
from artemis.plugins.store import contained_path
async def check(directory: Path, live: bool, values: dict[str, str]) -> None:
manifest = PluginManifest.model_validate_json((directory / "plugin.json").read_text("utf-8"))
manifest.check_compatible()
if manifest.skill:
skill = contained_path(directory, manifest.skill)
if not skill.is_file() or not skill.read_text("utf-8").strip():
raise ValueError("缺少有效的 Skill")
if manifest.schema_version not in {2, 3}:
raise ValueError("新市场插件必须适配 schema_version=2 或 3")
if live and manifest.server:
server = resolve_server(manifest, PluginProfile(values=values), directory)
session = await PluginSession(server, directory).start()
try:
validate_tools(manifest, session.tools)
finally:
await session.aclose()
print(
f"PASS {manifest.id} {manifest.version}: 清单/兼容性/Skill"
+ ("/MCP 工具声明" if live and manifest.server else "")
)
print("此检查不代替实际执行、超时取消、证据导出和视觉能力验收。")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("directory", type=Path)
parser.add_argument("--live", action="store_true")
parser.add_argument("--values", default="{}", help="普通配置 JSON;不得包含密钥")
args = parser.parse_args()
asyncio.run(check(args.directory.resolve(), args.live, json.loads(args.values)))
if __name__ == "__main__": if __name__ == "__main__":
@@ -202,10 +202,12 @@ async def test_shutdown_marks_only_ui_owned_running_sessions():
"apps.admin_console.server.ipc_service.stop_server", "apps.admin_console.server.ipc_service.stop_server",
new=AsyncMock(), new=AsyncMock(),
), ),
patch("apps.admin_console.server.DeviceExecutionLock.cleanup_stale_locks") as cleanup_locks,
): ):
await on_shutdown() await on_shutdown()
update_status.assert_called_once_with("ui-running", "interrupted") update_status.assert_called_once_with("ui-running", "interrupted")
cleanup_locks.assert_called_once_with()
assert state.queue_items == [] assert state.queue_items == []
state.active_session_id = None state.active_session_id = None
state.is_shutting_down = False state.is_shutting_down = False
@@ -57,15 +57,16 @@ def manifest_payload():
} }
def install(plugins, original_case=False): def install(plugins, original_case=False, plugin_id="review"):
package = BytesIO() package = BytesIO()
manifest = manifest_payload() manifest = manifest_payload()
manifest["id"] = plugin_id
if original_case: if original_case:
manifest["workspace"]["panels"][0]["kind"] = "original_case" manifest["workspace"]["panels"][0]["kind"] = "original_case"
with zipfile.ZipFile(package, "w") as archive: with zipfile.ZipFile(package, "w") as archive:
archive.writestr("plugin.json", json.dumps(manifest)) archive.writestr("plugin.json", json.dumps(manifest))
install_archive(plugins, package.getvalue()) install_archive(plugins, package.getvalue())
plugins.set_enabled("review", True) plugins.set_enabled(plugin_id, True)
@pytest.fixture @pytest.fixture
@@ -108,6 +109,71 @@ def save(client, values, revision=0, request_id=None):
) )
def test_batch_review_badge_tracks_current_values_and_plugin_lifecycle(context, monkeypatch):
from apps.admin_console.routers import automation
store, plugins, client = context
monkeypatch.setattr(automation, "store", store)
result = {"status": "inconclusive", "source": "execution"}
monkeypatch.setattr(automation, "read_reviewed_result", lambda *args, **kwargs: result)
client.app.include_router(automation.router)
store.create_batch({"id": "review-batch", "status": "completed"}, [], {})
store.save_job(
{"session_id": "batch-task", "batch_id": "review-batch", "status": "completed", "case": {}}
)
store.save_job(
{"session_id": "new-run", "batch_id": "review-batch", "status": "completed", "case": {}}
)
def badge():
response = client.get("/api/automation/batches/review-batch")
assert response.status_code == 200
jobs = {job["session_id"]: job for job in response.json()["jobs"]}
assert "batch-task" in jobs, response.json()
assert jobs["batch-task"]["test_result"] == result
assert jobs["new-run"]["human_reviewed"] is False
return jobs["batch-task"]["human_reviewed"]
assert badge() is False
install(plugins)
assert (
client.put(
"/api/task-extensions/batch-task/review",
json={
"values": {"accuracy": "Pass"},
"expected_revision": 0,
"request_id": str(uuid4()),
},
).status_code
== 200
)
assert badge() is False # Other workspace plugins are not the human review plugin.
install(plugins, plugin_id="task-review")
assert badge() is False
for revision, (values, expected) in enumerate(
[
({}, False),
({"accuracy_note": " \n"}, False),
({"accuracy": "Pass"}, True),
({"accuracy": "Failed"}, True),
({"accuracy": "", "accuracy_note": ""}, False),
({"accuracy_note": "人工核对了截图"}, True),
]
):
response = client.put(
"/api/task-extensions/batch-task/task-review",
json={"values": values, "expected_revision": revision, "request_id": str(uuid4())},
)
assert response.status_code == 200
assert badge() is expected
plugins.set_enabled("task-review", False)
assert badge() is False
plugins.set_enabled("task-review", True)
assert badge() is True
plugins.uninstall("task-review")
assert badge() is False
def test_original_case_import_requires_enabled_plugin_and_keeps_execution_prompt(context): def test_original_case_import_requires_enabled_plugin_and_keeps_execution_prompt(context):
from apps.admin_console.services import task_import from apps.admin_console.services import task_import
+59
View File
@@ -0,0 +1,59 @@
import hashlib
import json
import pytest
from langchain_core.messages import AIMessageChunk
from artemis.llm.argument_fingerprint import ArgumentFingerprint, MAX_PREFIXES, PREFIX_BYTES
from artemis.llm.diagnostics import RequestAttempt
@pytest.mark.parametrize("fragment_size", [1, 37, 4096, 100000])
def test_fingerprints_match_at_byte_offsets_despite_unicode_and_different_fragmentation(
fragment_size,
):
arguments = '{"target":"' + "私人内容🙂" * 8000 + '"}'
expected = arguments.encode("utf-8")
fingerprint = ArgumentFingerprint()
for offset in range(0, len(arguments), fragment_size):
fingerprint.observe(arguments[offset : offset + fragment_size])
result = fingerprint.snapshot()
assert result["argument_chars"] == len(arguments)
assert result["argument_bytes"] == len(expected)
assert result["argument_sha256"] == hashlib.sha256(expected).hexdigest()
assert result["argument_encoding_valid"] is True
assert result["argument_prefixes"] == [
{"bytes": size, "sha256": hashlib.sha256(expected[:size]).hexdigest()}
for size in range(PREFIX_BYTES, PREFIX_BYTES * MAX_PREFIXES + 1, PREFIX_BYTES)
]
assert "私人" not in json.dumps(result, ensure_ascii=False)
def test_invalid_utf8_is_marked_without_failing_or_forging_a_digest():
fingerprint = ArgumentFingerprint()
for value in ["safe", "\ud800", "later"]:
fingerprint.observe(value)
assert fingerprint.snapshot() == {
"argument_chars": 10,
"argument_bytes": None,
"argument_sha256": None,
"argument_deltas": 3,
"argument_prefixes": [],
"argument_encoding_valid": False,
}
def test_tool_tracking_is_bounded_but_total_count_keeps_all_received_arguments():
attempt = RequestAttempt(
None, request_id="bounded", attempt=1, timeout=300, max_retries=0, metadata={}
)
for index in range(65):
attempt.chunk(
AIMessageChunk(
content=[],
tool_call_chunks=[{"name": "click", "args": "{}", "index": index}],
)
)
assert len(attempt.data["tools"]) == len(attempt.tool_fingerprints) == 64
assert attempt.data["tool_stats_truncated"] is True
assert attempt.data["tool_argument_chars"] == 130
+28
View File
@@ -75,6 +75,34 @@ if ($env:npm_config_registry) { throw 'Official mode selected a China registry'
assert result.returncode == 0, result.stdout + result.stderr assert result.returncode == 0, result.stdout + result.stderr
def test_node_compatibility_matches_angular_supported_release_lines(tmp_path):
result = powershell(
tmp_path,
"""
$tokens = $null; $errors = $null
$ast = [System.Management.Automation.Language.Parser]::ParseFile($env:LAUNCH_SCRIPT, [ref]$tokens, [ref]$errors)
if ($errors) { throw 'Launcher parse error' }
$checker = $ast.Find({ param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq 'Test-NodeCompatible' }, $true)
. ([scriptblock]::Create($checker.Extent.Text))
function Test-CommandExists { return $true }
function node { return $script:nodeVersion }
$cases = @{
'v20.19.0' = $false; 'v22.22.0' = $false; 'v22.22.2' = $false
'v22.22.3' = $true; 'v22.23.2' = $true; 'v23.0.0' = $false
'v24.14.0' = $false; 'v24.15.0' = $true; 'v24.22.1' = $true
'v24.15.0-rc.1' = $false; 'v25.0.0' = $false; 'v26.0.0' = $true
'not-a-version' = $false
}
foreach ($case in $cases.GetEnumerator()) {
$script:nodeVersion = $case.Key
if ((Test-NodeCompatible) -ne $case.Value) { throw "Wrong compatibility for $($case.Key)" }
}
""",
LAUNCH_SCRIPT=str(ROOT / "scripts/start.ps1"),
)
assert result.returncode == 0, result.stdout + result.stderr
@pytest.mark.parametrize("first_failure", ["http", "timeout", "hash", "all"]) @pytest.mark.parametrize("first_failure", ["http", "timeout", "hash", "all"])
def test_scrcpy_source_failover_checks_hash_before_installing(tmp_path, first_failure): def test_scrcpy_source_failover_checks_hash_before_installing(tmp_path, first_failure):
archive = tmp_path / "fixture.zip" archive = tmp_path / "fixture.zip"
+29
View File
@@ -2,6 +2,7 @@
import asyncio import asyncio
import json import json
import hashlib
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
@@ -15,6 +16,7 @@ import pytest_asyncio
from artemis.llm.reliability import FailureCategory, LLMExhaustedError from artemis.llm.reliability import FailureCategory, LLMExhaustedError
from artemis.llm.response_guard import ResponseIntegrityError, ensure_response_complete from artemis.llm.response_guard import ResponseIntegrityError, ensure_response_complete
from artemis.llm.router import ModelEndpoint, ModelFactory from artemis.llm.router import ModelEndpoint, ModelFactory
from artemis.llm.diagnostics import RequestAttempt
from artemis.services import llm as llm_service from artemis.services import llm as llm_service
from artemis.services.llm import RobustChatModelWrapper, acomplete from artemis.services.llm import RobustChatModelWrapper, acomplete
@@ -195,6 +197,33 @@ async def test_completed_stream_returns_original_valid_arguments(transport_case)
assert result.tool_calls[0]["args"] == {"passed": True} assert result.tool_calls[0]["args"] == {"passed": True}
@pytest.mark.asyncio
@pytest.mark.parametrize("transport_case", ["fragmented"], indirect=True)
async def test_real_sdk_fingerprint_counts_received_arguments_once(transport_case):
case = transport_case
arguments = json.dumps({"passed": True, "evidence": "中文🙂" * 1000}, ensure_ascii=False)
case.outputs.append(("completed", arguments))
observation = RequestAttempt(
None, request_id="sdk-test", attempt=1, timeout=300, max_retries=0, metadata={}
)
merged = None
async for chunk in case.model.astream([]):
observation.chunk(chunk)
merged = chunk if merged is None else merged + chunk
observation.finish("completed", response=merged)
assert len(observation.data["tools"]) == 1
tool = observation.data["tools"][0]
assert tool["call_id"] == "call_test"
assert tool["argument_chars"] == len(arguments)
assert tool["argument_bytes"] == len(arguments.encode())
assert tool["argument_sha256"] == hashlib.sha256(arguments.encode()).hexdigest()
assert tool["argument_prefixes"][0] == {
"bytes": 4096,
"sha256": hashlib.sha256(arguments.encode()[:4096]).hexdigest(),
}
assert tool["argument_deltas"] == 1
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.parametrize("transport_case", ["fragmented"], indirect=True) @pytest.mark.parametrize("transport_case", ["fragmented"], indirect=True)
@pytest.mark.parametrize("status", ["completed", None]) @pytest.mark.parametrize("status", ["completed", None])
+47 -1
View File
@@ -124,7 +124,9 @@ async def test_gateway_closes_bad_stream_discards_partial_and_bounds_retry(
with pytest.raises(LLMExhaustedError) as caught: with pytest.raises(LLMExhaustedError) as caught:
await wrapper.complete([]) await wrapper.complete([])
assert caught.value.failure.category is FailureCategory.INVALID_OUTPUT assert caught.value.failure.category is FailureCategory.INVALID_OUTPUT
llm._wait_for_resume.assert_awaited_once() llm._wait_for_resume.assert_not_awaited()
assert not llm.PAUSE_FILE.exists()
assert any(name == "llm_gave_up" and p["handover"] == "task_failed" for name, p in resets)
assert model.calls == 2 assert model.calls == 2
assert closed == [1, 2] assert closed == [1, 2]
failure = next( failure = next(
@@ -139,3 +141,47 @@ async def test_gateway_closes_bad_stream_discards_partial_and_bounds_retry(
assert failure["usage"] is None assert failure["usage"] is None
assert "private" not in json.dumps(records + resets, default=str) assert "private" not in json.dumps(records + resets, default=str)
assert any(name == "llm_stream_reset" and p["action"] == "discard" for name, p in resets) assert any(name == "llm_stream_reset" and p["action"] == "discard" for name, p in resets)
@pytest.mark.asyncio
@pytest.mark.parametrize("fallback_kind", ["same", "failing", "working"])
@pytest.mark.parametrize("already_paused", [False, True])
async def test_invalid_output_fallback_does_not_pause_or_clear_another_request(
fallback_kind, already_paused
):
calls = []
if already_paused:
llm.PAUSE_FILE.write_text("another request or user pause", encoding="utf-8")
async def main():
calls.append("main")
raise ToolArgumentOverflow("click", SMALL_ACTION_ARGUMENT_LIMIT + 1)
async def fallback():
calls.append("fallback")
if fallback_kind == "working":
return "valid response"
raise ToolArgumentOverflow("click", SMALL_ACTION_ARGUMENT_LIMIT + 2)
async def run():
return await llm.with_fallback(
lambda: llm._run_with_recovery(main, endpoint_key="main", max_retries=5),
lambda: llm._run_with_recovery(
fallback,
endpoint_key="main" if fallback_kind == "same" else "fallback",
max_retries=5,
),
)
if fallback_kind == "working":
assert await run() == "valid response"
else:
with pytest.raises(LLMExhaustedError) as caught:
await run()
assert caught.value.failure.category is FailureCategory.INVALID_OUTPUT
assert calls.count("main") == 2
assert calls.count("fallback") == {"same": 0, "failing": 2, "working": 1}[fallback_kind]
llm._wait_for_resume.assert_not_awaited()
assert llm.PAUSE_FILE.exists() is already_paused
if already_paused:
assert llm.PAUSE_FILE.read_text(encoding="utf-8") == "another request or user pause"
+326
View File
@@ -0,0 +1,326 @@
"""Market validation uses synthetic packages/evidence, never a real release."""
import hashlib
import io
import json
from pathlib import Path
import sys
import zipfile
import pytest
from artemis.plugins.market import (
CHECKS,
REPORT_NAME,
content_fingerprint,
draft_submission,
package_files,
package_directory,
validate_submission,
)
from artemis.plugins.models import PluginManifest
from artemis.plugins.validation import check, main
from artemis.plugins import validation
@pytest.fixture
def candidate(tmp_path):
manifest = PluginManifest.model_validate(
{
"schema_version": 2,
"id": "synthetic-market-test",
"name": "Synthetic",
"version": "1.0.0",
"platforms": [sys.platform],
"skill": "SKILL.md",
"license": "Apache-2.0",
"capabilities": {"kind": "file", "tools": {}},
}
)
(tmp_path / "plugin.json").write_text(manifest.model_dump_json(), encoding="utf-8")
for name in ("SKILL.md", "LICENSE", "SOURCE.md", "VERIFICATION.md"):
(tmp_path / name).write_text(
"Synthetic test fixture, not a published claim.\n", encoding="utf-8"
)
return tmp_path, manifest
def submitted(candidate):
root, manifest = candidate
files = package_files(root)
report = draft_submission(manifest, files)
report["traits"] = {"uses_subprocesses": False, "file_inputs_for_writes": False}
report["components"] = [
{
"name": "synthetic fixture",
"paths": ["."],
"license": "Apache-2.0",
"permission": "permitted",
"license_file": "LICENSE",
"source_file": "SOURCE.md",
}
]
for record in report["checks"].values():
record.update(
status="passed",
evidence_file="VERIFICATION.md",
evidence_sha256=files["VERIFICATION.md"],
notes="Synthetic validation record for unit testing only.",
)
write_report(root, report)
return root, manifest, report
def write_report(root, report):
(root / REPORT_NAME).write_text(json.dumps(report), encoding="utf-8")
def validate(root, manifest):
return validate_submission(root, manifest, package_files(root))
@pytest.mark.asyncio
async def test_local_development_does_not_require_public_distribution(candidate):
root, _ = candidate
result = await check(root, False, {})
assert result["scope"] == "development"
assert not (root / REPORT_NAME).exists()
with pytest.raises(ValueError, match="MARKET.json"):
await check(root, False, {}, market=True)
@pytest.mark.asyncio
async def test_generated_market_draft_is_unverified_and_never_overwrites(candidate):
root, _ = candidate
result = await check(root, False, {}, prepare_market=True)
assert result["status"] == "draft"
report = json.loads((root / REPORT_NAME).read_text("utf-8"))
assert all(record["status"] == "unverified" for record in report["checks"].values())
assert report["components"][0]["permission"] == "unknown"
assert report["traits"]["uses_subprocesses"] is None
before = (root / REPORT_NAME).read_bytes()
with pytest.raises(FileExistsError):
await check(root, False, {}, prepare_market=True)
assert (root / REPORT_NAME).read_bytes() == before
with pytest.raises(ValueError):
await check(root, False, {}, market=True)
@pytest.mark.asyncio
async def test_valid_market_materials_report_and_zip_have_same_content_hash(
candidate, tmp_path_factory
):
root, manifest, report = submitted(candidate)
checked = await check(root, False, {}, market=True)
assert checked["scope"] == "market_materials"
archive = tmp_path_factory.mktemp("market-zip") / "candidate.zip"
with zipfile.ZipFile(archive, "w") as output:
for path in root.iterdir():
output.write(path, path.name)
zipped = await check(archive, False, {}, market=True)
assert zipped["content_sha256"] == checked["content_sha256"] == report["content_sha256"]
assert zipped["archive_sha256"] == hashlib.sha256(archive.read_bytes()).hexdigest()
@pytest.mark.parametrize("permission", ["unknown", "local_only"])
def test_market_rejects_components_without_public_distribution(candidate, permission):
root, manifest, report = submitted(candidate)
report["components"][0]["permission"] = permission
write_report(root, report)
with pytest.raises(ValueError, match="市场分发"):
validate(root, manifest)
def test_market_rejects_changed_payload_and_forged_evidence(candidate):
root, manifest, report = submitted(candidate)
report["checks"]["source_review"]["evidence_sha256"] = "0" * 64
write_report(root, report)
with pytest.raises(ValueError, match="记录 SHA256"):
validate(root, manifest)
(root / "SKILL.md").write_text("different bytes", encoding="utf-8")
with pytest.raises(ValueError, match="旧的市场验证记录失效"):
validate(root, manifest)
@pytest.mark.parametrize(
"change",
[
"version",
"missing_check",
"unverified",
"no_notes",
"uncovered",
"source_escape",
"license_missing",
"na_required",
"process_na",
"binding_na",
],
)
def test_market_blocks_incomplete_or_inapplicable_claims(candidate, change):
root, manifest, report = submitted(candidate)
if change == "version":
report["plugin_version"] = "2.0.0"
elif change == "missing_check":
del report["checks"]["real_task"]
elif change == "unverified":
report["checks"]["real_task"]["status"] = "unverified"
elif change == "no_notes":
report["checks"]["source_review"]["notes"] = " "
elif change == "uncovered":
report["components"][0]["paths"] = ["LICENSE"]
elif change == "source_escape":
report["components"][0]["source_file"] = "../outside"
elif change == "license_missing":
report["components"][0]["license_file"] = "missing.txt"
elif change == "na_required":
report["checks"]["real_task"]["status"] = "not_applicable"
elif change == "process_na":
report["traits"]["uses_subprocesses"] = True
report["checks"]["process_tree"]["status"] = "not_applicable"
elif change == "binding_na":
report["traits"]["file_inputs_for_writes"] = True
report["checks"]["file_binding"]["status"] = "not_applicable"
write_report(root, report)
with pytest.raises(ValueError):
validate(root, manifest)
@pytest.mark.parametrize(
"name", [".ruff_cache/entry", "__pycache__/x", "module.pyc", ".env", ".env.local"]
)
def test_market_blocks_cache_and_personal_configuration(candidate, name):
root, _ = candidate
path = root / name
path.parent.mkdir(exist_ok=True)
path.write_text("synthetic only", encoding="utf-8")
with pytest.raises(ValueError, match="市场包"):
package_files(root)
def test_fingerprint_is_order_independent_but_binds_paths_and_bytes():
assert content_fingerprint({"b": "2", "a": "1"}) == content_fingerprint({"a": "1", "b": "2"})
assert content_fingerprint({"a": "1"}) != content_fingerprint({"a": "2"})
assert content_fingerprint({"a": "1"}) != content_fingerprint({"b": "1"})
@pytest.mark.asyncio
async def test_zip_path_traversal_cannot_escape_inspection(candidate, tmp_path_factory):
root, _ = candidate
archive = tmp_path_factory.mktemp("bad-market-zip") / "bad.zip"
with zipfile.ZipFile(archive, "w") as output:
output.writestr("../outside.txt", "synthetic")
with pytest.raises(ValueError):
await check(archive, False, {})
assert not (archive.parent / "outside.txt").exists()
def test_json_output_must_not_modify_candidate_or_overwrite_zip(candidate, monkeypatch, capsys):
root, _ = candidate
target = root / "report.json"
monkeypatch.setattr(sys, "argv", ["check_plugin", str(root), "--json-output", str(target)])
with pytest.raises(SystemExit):
main()
assert not target.exists()
archive = root / "original.zip"
archive.write_bytes(b"original immutable zip placeholder")
monkeypatch.setattr(sys, "argv", ["check_plugin", str(archive), "--json-output", str(archive)])
with pytest.raises(SystemExit):
main()
assert archive.read_bytes() == b"original immutable zip placeholder"
def test_invalid_values_are_not_echoed(candidate, monkeypatch, capsys):
root, _ = candidate
private_marker = "SYNTHETIC-DO-NOT-ECHO"
monkeypatch.setattr(
sys, "argv", ["check_plugin", str(root), "--values", '{"key":"' + private_marker]
)
with pytest.raises(SystemExit):
main()
assert private_marker not in capsys.readouterr().out
def add_synthetic_server(candidate):
root, manifest = candidate
data = manifest.model_dump(mode="json")
data["server"] = {"command": sys.executable, "args": ["synthetic-unused-server.py"]}
manifest = PluginManifest.model_validate(data)
(root / "plugin.json").write_text(manifest.model_dump_json(), encoding="utf-8")
return root, manifest
def handshake_fixture(monkeypatch, action):
class FakeSession:
tools = []
def __init__(self, server, root):
self.root = root
async def start(self):
action(self.root)
return self
async def aclose(self):
pass
monkeypatch.setattr(validation, "PluginSession", FakeSession)
@pytest.mark.asyncio
@pytest.mark.parametrize("changed", ["payload", "submission"])
async def test_market_rejects_changes_during_handshake(candidate, monkeypatch, changed):
root, manifest, report = submitted(add_synthetic_server(candidate))
def change(directory):
if changed == "payload":
(directory / "SKILL.md").write_text("changed during handshake", encoding="utf-8")
else:
report["checks"]["real_task"]["notes"] = "A different synthetic claim."
write_report(directory, report)
handshake_fixture(monkeypatch, change)
with pytest.raises(ValueError, match="发生变化"):
await check(root, False, {}, market=True)
@pytest.mark.asyncio
async def test_zip_digest_is_bound_to_bytes_actually_inspected(
candidate, tmp_path_factory, monkeypatch
):
root, _, report = submitted(add_synthetic_server(candidate))
archive = tmp_path_factory.mktemp("changing-zip") / "candidate.zip"
with zipfile.ZipFile(archive, "w") as output:
for path in root.iterdir():
output.write(path, path.name)
approved = hashlib.sha256(archive.read_bytes()).hexdigest()
handshake_fixture(monkeypatch, lambda root: archive.write_bytes(b"uninspected replacement"))
with pytest.raises(ValueError, match="ZIP 发生变化"):
await check(archive, False, {}, market=True)
assert approved != hashlib.sha256(archive.read_bytes()).hexdigest()
def test_archive_fingerprint_uses_extracted_snapshot(candidate, tmp_path_factory):
root, _ = candidate
archive = tmp_path_factory.mktemp("zip-snapshot") / "candidate.zip"
with zipfile.ZipFile(archive, "w") as output:
for path in root.iterdir():
output.write(path, path.name)
approved = hashlib.sha256(archive.read_bytes()).hexdigest()
with pytest.raises(ValueError, match="ZIP 发生变化"):
with package_directory(archive) as (extracted, digest):
archive.write_bytes(b"uninspected replacement")
assert (extracted / "plugin.json").read_bytes() == (root / "plugin.json").read_bytes()
assert digest == approved
@pytest.mark.asyncio
async def test_incomplete_materials_never_launch_server(candidate, monkeypatch):
root, _ = add_synthetic_server(candidate)
def never_start(root):
raise AssertionError("Incomplete market materials launched a plugin")
handshake_fixture(monkeypatch, never_start)
with pytest.raises(ValueError, match="MARKET.json"):
await check(root, False, {}, market=True)
+274
View File
@@ -0,0 +1,274 @@
"""Real local process fault regressions. No devices, network or user processes."""
import asyncio
import hashlib
import importlib.util
import os
from pathlib import Path
import sys
import subprocess
from unittest.mock import AsyncMock
import pytest
import psutil
from artemis.plugins import sdk
from artemis.plugins.sdk import (
FileVerificationError,
ManagedProcessError,
VerifiedFile,
snapshot_file,
)
from artemis.plugins.sdk_checks import run_checks
@pytest.mark.asyncio
async def test_standard_sdk_fault_suite():
result = await run_checks()
assert result["status"] == "passed"
assert "descendants_reclaimed_on_cancel" in result["checks"]
assert "descendants_reclaimed_on_timeout" in result["checks"]
assert result["real_device_actions"] is False
@pytest.mark.parametrize("max_bytes", [0, -1, True])
def test_snapshot_rejects_invalid_limits_before_open(tmp_path, max_bytes):
with pytest.raises(ValueError, match="正整数"):
snapshot_file(tmp_path / "missing", "0" * 64, max_bytes=max_bytes)
def test_snapshot_rejects_wrong_digest_oversized_and_special_input(tmp_path):
path = tmp_path / "firmware.bin"
path.write_bytes(b"approved")
digest = hashlib.sha256(path.read_bytes()).hexdigest()
assert snapshot_file(path, digest.upper()).sha256 == digest
with pytest.raises(FileVerificationError, match="批准"):
snapshot_file(path, "f" * 64)
with pytest.raises(FileVerificationError, match="SHA256"):
snapshot_file(path, "not-a-sha256")
with pytest.raises(FileVerificationError, match="大小"):
snapshot_file(path, digest, max_bytes=3)
with pytest.raises(FileVerificationError, match="普通文件"):
snapshot_file(tmp_path, digest)
with pytest.raises(TypeError):
VerifiedFile(bytearray(b"mutable"))
def test_snapshot_survives_path_replacement(tmp_path):
path = tmp_path / "source"
path.write_bytes(b"old")
snapshot = snapshot_file(path, hashlib.sha256(b"old").hexdigest())
replacement = tmp_path / "new"
replacement.write_bytes(b"new")
replacement.replace(path)
assert snapshot.data == b"old"
assert b"".join(snapshot.chunks(1)) == b"old"
with pytest.raises(TypeError):
next(snapshot.chunks())[0] = 9
with pytest.raises(ValueError):
list(snapshot.chunks(0))
@pytest.mark.asyncio
@pytest.mark.parametrize(
"updates",
[
{"argv": "echo hello"},
{"argv": []},
{"argv": ["bad\x00program"]},
{"timeout": 0},
{"timeout": float("inf")},
{"timeout": True},
{"input_data": "not-bytes"},
{"max_output_bytes": 0},
],
)
async def test_invalid_process_inputs_do_not_launch(monkeypatch, tmp_path, updates):
launch = AsyncMock()
monkeypatch.setattr(asyncio, "create_subprocess_exec", launch)
arguments = {"argv": [sys.executable], "cwd": tmp_path, "timeout": 5, **updates}
with pytest.raises(ValueError):
await sdk.run_process(**arguments)
launch.assert_not_called()
@pytest.mark.asyncio
async def test_tool_launch_error_does_not_authorize_a_retry(tmp_path):
with pytest.raises(ManagedProcessError) as raised:
await sdk.run_process([str(tmp_path / "nonexistent-program")], cwd=tmp_path, timeout=5)
assert raised.value.error_code == "process_launch_failed"
assert raised.value.may_have_started is False
assert "nonexistent-program" not in str(raised.value)
@pytest.mark.asyncio
async def test_combined_stderr_limit_is_enforced(tmp_path):
with pytest.raises(ManagedProcessError, match="process_output_limit"):
await sdk.run_process(
[
sys.executable,
"-I",
"-B",
"-c",
"import os; os.write(1,b'a'*800); os.write(2,b'b'*800)",
],
cwd=tmp_path,
timeout=5,
max_output_bytes=1000,
)
@pytest.mark.asyncio
async def test_process_fixed_argv_does_not_invoke_shell_and_has_explicit_environment(tmp_path):
result = await sdk.run_process(
[
sys.executable,
"-I",
"-B",
"-c",
"import os,sys; print(sys.argv[1]); print(os.environ.get('ARTEMIS_REVIEW_SYNTHETIC'))",
"literal & | ; $(no-command)",
],
cwd=tmp_path,
timeout=5,
env={**os.environ, "ARTEMIS_REVIEW_SYNTHETIC": "explicit"},
)
assert result.returncode == 0
assert b"literal & | ; $(no-command)" in result.stdout
assert b"explicit" in result.stdout
@pytest.mark.asyncio
async def test_cleanup_continues_through_repeated_cancellation():
entered = asyncio.Event()
release = asyncio.Event()
async def cleanup():
entered.set()
await release.wait()
worker = asyncio.create_task(cleanup())
owner = asyncio.create_task(sdk._finish_cleanup(worker))
await entered.wait()
owner.cancel()
await asyncio.sleep(0)
owner.cancel()
await asyncio.sleep(0)
assert not worker.done()
release.set()
with pytest.raises(asyncio.CancelledError):
await owner
assert worker.done() and not worker.cancelled()
@pytest.mark.asyncio
@pytest.mark.skipif(os.name != "nt", reason="Windows Job Object ownership barrier")
async def test_ownership_failure_never_starts_tool(monkeypatch, tmp_path):
marker = tmp_path / "must-not-exist"
def deny_assignment(self, pid):
raise OSError("synthetic assignment failure")
monkeypatch.setattr(sdk.ProcessJob, "assign", deny_assignment)
with pytest.raises(ManagedProcessError, match="process_ownership_failed") as raised:
await sdk.run_process(
[
sys.executable,
"-I",
"-B",
"-c",
"from pathlib import Path; import sys; Path(sys.argv[1]).touch()",
str(marker),
],
cwd=tmp_path,
timeout=5,
)
assert raised.value.may_have_started is False
assert not marker.exists()
@pytest.mark.asyncio
@pytest.mark.skipif(os.name != "nt", reason="Windows owner-death Job Object guarantee")
async def test_forcibly_killing_sdk_owner_reclaims_its_descendants(tmp_path):
from artemis.plugins.sdk_checks import PROBE
marker = tmp_path / "owned-child.json"
owner_script = tmp_path / "owned-sdk-host.py"
owner_script.write_text(
"import asyncio, sys\nfrom pathlib import Path\n"
"from artemis.plugins.sdk import run_process\n"
"asyncio.run(run_process([sys.executable, '-I', '-B', sys.argv[1], 'tree', "
"sys.argv[2], 'linger'], cwd=Path(sys.argv[2]).parent, timeout=60))\n",
encoding="utf-8",
)
owner = await asyncio.create_subprocess_exec(
sys.executable,
"-B",
str(owner_script),
str(PROBE),
str(marker),
stdin=asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
creationflags=subprocess.CREATE_NO_WINDOW,
)
descendants = []
try:
async with asyncio.timeout(10):
while not marker.exists():
assert owner.returncode is None
await asyncio.sleep(0.02)
descendants = psutil.Process(owner.pid).children(recursive=True)
assert len(descendants) >= 3 # bootstrap, synthetic tool, synthetic sleeper
owner.kill()
await owner.wait()
async with asyncio.timeout(3):
while any(p.is_running() for p in descendants):
await asyncio.sleep(0.02)
finally:
if owner.returncode is None:
owner.kill()
await owner.wait()
for process in descendants:
if process.is_running():
process.kill()
process.wait(timeout=5)
@pytest.mark.asyncio
async def test_managed_template_real_mcp_and_host_receipts():
path = (
Path(__file__).resolve().parents[2] / "examples/plugins/managed-tool-demo/test_contract.py"
)
spec = importlib.util.spec_from_file_location("managed_template_contract", path.resolve())
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
result = await module.run_contract()
assert result["mcp_calls"] == 3
assert result["host_attachments_verified"] == 1
def test_authoring_template_and_checker_copies_stay_identical():
root = Path(__file__).resolve().parents[2]
for name in (
"plugin.json",
"server.py",
"SKILL.md",
"README.md",
"test_contract.py",
"LICENSE",
"SOURCE.md",
):
assert (root / "examples/plugins/managed-tool-demo" / name).read_bytes() == (
root / "skills/artemis-use/assets/managed-tool-demo" / name
).read_bytes()
assert (root / "scripts/check_plugin.py").read_bytes() == (
root / "skills/artemis-use/scripts/check_plugin.py"
).read_bytes()
for name in ("plugin.json", "server.py", "SKILL.md", "LICENSE", "SOURCE.md"):
assert (root / "examples/plugins/evidence-demo" / name).read_bytes() == (
root / "skills/artemis-use/assets/evidence-demo" / name
).read_bytes()
assert (root / "docs/plugin-sdk.md").read_bytes() == (
root / "skills/artemis-use/references/plugin-sdk.md"
).read_bytes()
+79 -6
View File
@@ -1,6 +1,7 @@
import asyncio import asyncio
from copy import deepcopy from copy import deepcopy
import json import json
import hashlib
from types import SimpleNamespace from types import SimpleNamespace
from uuid import uuid4 from uuid import uuid4
@@ -36,6 +37,46 @@ class Engine:
pass pass
@pytest.mark.parametrize(
("headers", "expected"),
[
({"X-Oneapi-Request-Id": "gateway-request"}, "gateway-request"),
(
{"X-Oneapi-Request-Id": "gateway-request", "X-Request-Id": "provider-request"},
"gateway-request",
),
({"X-Request-Id": "provider-request"}, "provider-request"),
({"Request-Id": "legacy-request"}, "legacy-request"),
(
{"X-Oneapi-Request-Id": "invalid header value", "X-Request-Id": "standard-request"},
"standard-request",
),
],
)
def test_gateway_request_id_preserves_the_standard_header_without_assuming_its_source(
headers, expected
):
attempt = RequestAttempt(
Engine(), request_id="local-request", attempt=1, timeout=300, max_retries=1, metadata={}
)
attempt.headers(httpx.Response(200, headers=headers))
assert attempt.data["server_request_id"] == expected
assert attempt.data["response_x_request_id"] == headers.get("X-Request-Id")
def test_sdk_error_cannot_overwrite_the_observed_gateway_request_id():
class RequestError(Exception):
request_id = "sdk-request"
attempt = RequestAttempt(
Engine(), request_id="local-request", attempt=1, timeout=300, max_retries=1, metadata={}
)
attempt.headers(httpx.Response(400, headers={"X-Oneapi-Request-Id": "gateway-request"}))
attempt.finish("failed", error=RequestError("failed"))
assert attempt.data["server_request_id"] == "gateway-request"
assert attempt.data["error_request_id"] == "sdk-request"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_request_hook_correlates_api_calls_without_logging_header_values(): async def test_request_hook_correlates_api_calls_without_logging_header_values():
engine = Engine() engine = Engine()
@@ -55,13 +96,18 @@ async def test_request_hook_correlates_api_calls_without_logging_header_values()
) )
await observe_request_headers(request) await observe_request_headers(request)
assert request.headers["x-client-request-id"] == "local-request" assert request.headers["x-client-request-id"] == "local-request"
assert request.headers["x-client-attempt-id"] == str(attempt.trace_id)
assert attempt.data["client_attempt_id_matches"] is True
assert attempt.data["client_request_id_matches"] is True assert attempt.data["client_request_id_matches"] is True
assert len(attempt.data["routing_header_fingerprints"]) == 2 assert len(attempt.data["routing_header_fingerprints"]) == 2
assert "private" not in json.dumps(engine.records, default=str) assert "private" not in json.dumps(engine.records, default=str)
request.headers["X-Client-Request-Id"] = "caller-id" request.headers["X-Client-Request-Id"] = "caller-id"
request.headers["X-Client-Attempt-Id"] = "caller-attempt"
await observe_request_headers(request) await observe_request_headers(request)
assert request.headers["x-client-request-id"] == "caller-id" assert request.headers["x-client-request-id"] == "caller-id"
assert attempt.data["client_request_id_matches"] is False assert attempt.data["client_request_id_matches"] is False
assert request.headers["X-Client-Attempt-Id"] == "caller-attempt"
assert attempt.data["client_attempt_id_matches"] is False
finally: finally:
ACTIVE_ATTEMPT.reset(token) ACTIVE_ATTEMPT.reset(token)
@@ -75,6 +121,7 @@ async def test_headers_without_body_timeout_then_retry_keeps_evidence(monkeypatc
) )
llm._ENDPOINT_BREAKER._states.clear() llm._ENDPOINT_BREAKER._states.clear()
calls = 0 calls = 0
wire_ids = []
class StalledBody(httpx.AsyncByteStream): class StalledBody(httpx.AsyncByteStream):
async def __aiter__(self): async def __aiter__(self):
@@ -84,6 +131,9 @@ async def test_headers_without_body_timeout_then_retry_keeps_evidence(monkeypatc
async def transport(request): async def transport(request):
nonlocal calls nonlocal calls
calls += 1 calls += 1
wire_ids.append(
(request.headers["X-Client-Request-Id"], request.headers["X-Client-Attempt-Id"])
)
headers = {"x-request-id": f"server-{calls}", "set-cookie": "secret-cookie"} headers = {"x-request-id": f"server-{calls}", "set-cookie": "secret-cookie"}
return httpx.Response( return httpx.Response(
200, headers=headers, stream=StalledBody() if calls == 1 else httpx.ByteStream(b"ok") 200, headers=headers, stream=StalledBody() if calls == 1 else httpx.ByteStream(b"ok")
@@ -91,7 +141,7 @@ async def test_headers_without_body_timeout_then_retry_keeps_evidence(monkeypatc
async with httpx.AsyncClient( async with httpx.AsyncClient(
transport=httpx.MockTransport(transport), transport=httpx.MockTransport(transport),
event_hooks={"response": [observe_response_headers]}, event_hooks={"request": [observe_request_headers], "response": [observe_response_headers]},
) as client: ) as client:
class Model: class Model:
@@ -123,6 +173,10 @@ async def test_headers_without_body_timeout_then_retry_keeps_evidence(monkeypatc
assert final["request_id"] == failed["request_id"] assert final["request_id"] == failed["request_id"]
assert final["attempt_id"] != failed["attempt_id"] and final["attempt"] == 2 assert final["attempt_id"] != failed["attempt_id"] and final["attempt"] == 2
assert final["previous_attempt_id"] == failed["attempt_id"] assert final["previous_attempt_id"] == failed["attempt_id"]
assert wire_ids == [
(failed["request_id"], failed["attempt_id"]),
(final["request_id"], final["attempt_id"]),
]
retry = next(r["payload"] for r in engine.records if r["name"] == "llm_retry") retry = next(r["payload"] for r in engine.records if r["name"] == "llm_retry")
assert retry["request_id"] == failed["request_id"] assert retry["request_id"] == failed["request_id"]
assert retry["attempt_id"] == failed["attempt_id"] assert retry["attempt_id"] == failed["attempt_id"]
@@ -163,10 +217,26 @@ def test_tool_deltas_are_counted_once_without_retaining_arguments():
attempt.finish("completed", response=combined) attempt.finish("completed", response=combined)
assert attempt.data["output_chunks"] == 4 assert attempt.data["output_chunks"] == 4
assert attempt.data["tool_argument_chars"] == len('{"secret":"private"}{"seconds":1}') assert attempt.data["tool_argument_chars"] == len('{"secret":"private"}{"seconds":1}')
assert attempt.data["tools"] == [ for tool, name, arguments, call_id, index, count in zip(
{"name": "click", "argument_chars": len('{"secret":"private"}')}, attempt.data["tools"],
{"name": "wait", "argument_chars": len('{"seconds":1}')}, ["click", "wait"],
] ['{"secret":"private"}', '{"seconds":1}'],
["c1", "c2"],
[0, 1],
[2, 1],
strict=True,
):
assert tool == {
"name": name,
"call_id": call_id,
"chunk_index": index,
"argument_chars": len(arguments),
"argument_bytes": len(arguments.encode()),
"argument_sha256": hashlib.sha256(arguments.encode()).hexdigest(),
"argument_deltas": count,
"argument_prefixes": [],
"argument_encoding_valid": True,
}
assert attempt.data["response_status"] == "completed" assert attempt.data["response_status"] == "completed"
assert "private" not in json.dumps(engine.records, default=str) assert "private" not in json.dumps(engine.records, default=str)
assert "secret" not in json.dumps(engine.records, default=str) assert "secret" not in json.dumps(engine.records, default=str)
@@ -205,7 +275,10 @@ async def test_timeout_during_tool_generation_keeps_last_output_and_tool_name(mo
) )
assert failed["failure_phase"] == "generating_tool_arguments" assert failed["failure_phase"] == "generating_tool_arguments"
assert failed["last_output_at"] >= failed["first_output_at"] assert failed["last_output_at"] >= failed["first_output_at"]
assert failed["tools"] == [{"name": "click", "argument_chars": 5}] assert len(failed["tools"]) == 1
tool = failed["tools"][0]
assert tool["name"] == "click" and tool["argument_chars"] == tool["argument_bytes"] == 5
assert tool["argument_sha256"] == hashlib.sha256(b'{"x":').hexdigest()
assert failed["usage"] is None assert failed["usage"] is None
assert failed["max_output_tokens"] == 12000 assert failed["max_output_tokens"] == 12000
+76
View File
@@ -109,6 +109,82 @@ def _recording_console():
return Console(file=io.StringIO(), force_terminal=False, width=120) return Console(file=io.StringIO(), force_terminal=False, width=120)
@pytest.mark.parametrize("language", ["zh-CN", "en"])
@pytest.mark.parametrize("choice", ["", "2", "3"])
def test_existing_server_prompt_respects_launch_language(monkeypatch, language, choice):
from artemis.runtime import server_lifecycle
console = _recording_console()
monkeypatch.setenv("ARTEMIS_LAUNCH_LANG", language)
monkeypatch.setattr(ui, "Console", lambda: console)
monkeypatch.setattr(ui.sys.stdin, "isatty", lambda: True)
monkeypatch.setattr(server_lifecycle, "is_port_in_use", lambda port: True)
monkeypatch.setattr(server_lifecycle, "find_server_pids", lambda port: [1234])
prompts, opened, stopped = [], [], []
monkeypatch.setattr("builtins.input", lambda prompt: prompts.append(prompt) or choice)
monkeypatch.setattr(ui.webbrowser, "open", opened.append)
monkeypatch.setattr(ui, "load_session_reconciler", lambda: None)
class RestartSelected(Exception):
pass
def stop_server(**kwargs):
stopped.append(kwargs)
raise RestartSelected # Never launch or stop a real service.
monkeypatch.setattr(server_lifecycle, "stop_server", stop_server)
if choice == "2":
with pytest.raises(RestartSelected):
ui.ui_command(port=8123)
else:
ui.ui_command(port=8123)
output = console.file.getvalue()
assert "8123" in output and "PID: 1234" in output
if language == "zh-CN":
assert all(
text in output
for text in ("服务正在运行", "请选择操作", "打开浏览器(默认)", "重启服务", "取消")
)
assert "请输入选项" in prompts[0]
assert "Choose an action" not in output and "Restart server" not in output
if choice == "2":
assert "正在停止端口 8123" in output
else:
assert "Choose an action" in output and "Open browser (default)" in output
assert "Select [1-3, default 1]" in prompts[0]
assert opened == (["http://localhost:8123"] if not choice else [])
assert stopped == ([{"port": 8123, "timeout": 12.0}] if choice == "2" else [])
@pytest.mark.parametrize("language", ["zh-CN", None])
@pytest.mark.parametrize("open_browser", [True, False])
def test_existing_server_noninteractive_does_not_restart(monkeypatch, language, open_browser):
from artemis.runtime import server_lifecycle
console = _recording_console()
if language:
monkeypatch.setenv("ARTEMIS_LAUNCH_LANG", language)
else:
monkeypatch.delenv("ARTEMIS_LAUNCH_LANG", raising=False)
monkeypatch.setattr(ui, "Console", lambda: console)
monkeypatch.setattr(ui.sys.stdin, "isatty", lambda: False)
monkeypatch.setattr(server_lifecycle, "is_port_in_use", lambda port: True)
monkeypatch.setattr(server_lifecycle, "find_server_pids", lambda port: [])
monkeypatch.setattr(
server_lifecycle, "stop_server", lambda **kwargs: pytest.fail("Unexpected restart")
)
opened = []
monkeypatch.setattr(ui.webbrowser, "open", opened.append)
ui.ui_command(port=8123, open_browser=open_browser)
output = console.file.getvalue()
assert "artemis restart" in output
assert ("服务正在运行" if language else "already in use") in output
assert opened == (["http://localhost:8123"] if open_browser else [])
def test_ensure_showcase_built_skips_install_when_deps_current(monkeypatch): def test_ensure_showcase_built_skips_install_when_deps_current(monkeypatch):
steps = [] steps = []
monkeypatch.setattr(ui, "_showcase_build_required", lambda showcase_dir: True) monkeypatch.setattr(ui, "_showcase_build_required", lambda showcase_dir: True)
Generated
+1 -1
View File
@@ -248,7 +248,7 @@ wheels = [
[[package]] [[package]]
name = "artemis" name = "artemis"
version = "2.4.0" version = "2.4.5"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "adbutils" }, { name = "adbutils" },