Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a3bbaa3fb | ||
|
|
506e3bf1c5 | ||
|
|
c6d2050ff0 | ||
|
|
0c3ca69c1d | ||
|
|
486e6d550d |
No files matched your search
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.2**:批次任务增加人工已审标签,优化收藏、标题与状态标签布局。人工评审插件保持 1.1.0。见 [2.4.2 发行说明](docs/releases/2.4.2.md);安装修复见 [2.4.1](docs/releases/2.4.1.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)。
|
||||||
|
|
||||||
## 主要功能
|
## 主要功能
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "artemis-showcase-ui",
|
"name": "artemis-showcase-ui",
|
||||||
"version": "2.4.2",
|
"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.2",
|
"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",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "artemis-showcase-ui",
|
"name": "artemis-showcase-ui",
|
||||||
"version": "2.4.2",
|
"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
|
||||||
|
|||||||
+2
-2
@@ -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" />
|
||||||
@@ -119,7 +119,7 @@
|
|||||||
<app-test-result-badge [result]="j.test_result" [executionStatus]="j.status" [compact]="true" />
|
<app-test-result-badge [result]="j.test_result" [executionStatus]="j.status" [compact]="true" />
|
||||||
@if(j.human_reviewed){<span class="human-review-badge" title="人工评审已有保存内容,不代表测试通过">人工已审</span>}
|
@if(j.human_reviewed){<span class="human-review-badge" title="人工评审已有保存内容,不代表测试通过">人工已审</span>}
|
||||||
</div>
|
</div>
|
||||||
@if(j.error){<small>{{j.error}}</small>}
|
<app-task-outcome [error]="j.error" [result]="j.test_result" [status]="j.status" />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</section>}
|
</section>}
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ details{margin:14px 0;}summary{color:var(--app-text-secondary, #475569);cursor:p
|
|||||||
.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;}
|
||||||
|
|||||||
+6
-1
@@ -176,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, '');
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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});已关闭输出流,未执行本次动作。"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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()))
|
||||||
@@ -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()
|
||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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、固定字节前缀、有界统计与异常编码。这些测试不调用真实模型或设备。
|
||||||
@@ -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`。本地开发检查不强制公开许可,市场材料检查不替代源码和业务审查。
|
||||||
@@ -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 故障自测与真实业务结论分别记录。
|
||||||
@@ -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,不重启用户服务,不执行设备或真实业务任务。
|
||||||
@@ -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,不重启用户服务。
|
||||||
@@ -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` 校验后启动。请勿直接覆盖旧安装目录。
|
||||||
@@ -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
-1
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "artemis"
|
name = "artemis"
|
||||||
version = "2.4.2"
|
version = "2.4.5"
|
||||||
description = "Autonomous AI Assistant for Mobile Automation"
|
description = "Autonomous AI Assistant for Mobile Automation"
|
||||||
|
|
||||||
authors = [
|
authors = [
|
||||||
|
|||||||
@@ -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
@@ -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__":
|
||||||
|
|||||||
@@ -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 增加:
|
||||||
|
|||||||
@@ -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 故障自测与真实业务结论分别记录。
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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])
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in new issue
Block a user