Compare commits

...
44 Commits
Author SHA1 Message Date
ChatGPT Release Governance 3dd622b5a5 v2r1c2_guard_plan_oid_supersession 2026-07-22 04:24:27 +08:00
ChatGPT Release Governance f73052cd5c v2r1c2_audit_manifest_supersession 2026-07-22 04:19:55 +08:00
ChatGPT Release Governance 1b69e89718 v2r1c2_add_audited_not_started_retry 2026-07-22 04:17:53 +08:00
ChatGPT Release Governance f3c00d7294 v2r1c2_harden_provenance_caches 2026-07-22 03:53:22 +08:00
ChatGPT Release Governance 80e3f65cd6 v2r1c2_takeover_gate 2026-07-22 03:46:34 +08:00
ChatGPT Release Governance 0cf769ef67 fix(v2r1c2): auto-issue Owner provenance for protected dispatch 2026-07-22 03:33:41 +08:00
ChatGPT Release Governance 5e53203a61 feat(v2r1c2): add legal ChatGPT takeover provider 2026-07-22 03:25:49 +08:00
ChatGPT Release Governance cf5afdf4c0 docs(task): add concise OpenClaw online execution handoff 2026-07-22 02:46:26 +08:00
ChatGPT Release Governance b6e714e3fc docs(v2r1c2): add unified portal and portable executor contract 2026-07-22 02:36:31 +08:00
ChatGPT Release Governance 0022adf958 test(release): add V2R1C2 offline and online acceptance gate 2026-07-22 02:33:37 +08:00
ChatGPT Release Governance 22888c3d53 test(orchestration): cover portable executor release gates 2026-07-22 02:29:33 +08:00
ChatGPT Release Governance c2c9eb8d81 fix(orchestration): retry failed owner notifications idempotently 2026-07-22 02:26:12 +08:00
ChatGPT Release Governance 6df9c92020 fix(web): repair execution center script and truth projection 2026-07-22 02:23:56 +08:00
ChatGPT Release Governance 6bb8c530a4 feat(web): render portable executor execution center 2026-07-22 02:18:31 +08:00
ChatGPT Release Governance 024143efa5 feat(deploy): add V2R1C2 candidate environment template 2026-07-22 02:10:29 +08:00
ChatGPT Release Governance 06c6374126 feat(deploy): add isolated V2R1C2 portal candidate service 2026-07-22 02:07:44 +08:00
ChatGPT Release Governance 82177403f6 feat(orchestration): add portable execution plan lifecycle CLI 2026-07-22 02:04:52 +08:00
ChatGPT Release Governance ef074a0c97 feat(release): add V2R1C2 portable multi-provider execution plan 2026-07-22 02:00:00 +08:00
ChatGPT Release Governance 00fd375e4c feat(web): wire portable executor plans into V2R1C2 candidate 2026-07-22 01:54:45 +08:00
ChatGPT Release Governance 5b0c3eb404 feat(web): expose provider-neutral execution plan APIs 2026-07-22 01:51:12 +08:00
ChatGPT Release Governance 3161470bd2 fix(orchestration): activate dependent stages only after upstream gates pass 2026-07-22 01:46:28 +08:00
ChatGPT Release Governance 1852701236 feat(web): add execution-center capability and navigation 2026-07-22 01:39:46 +08:00
ChatGPT Release Governance 21fc8c60ed refactor(orchestration): authorize task API from dynamic Provider trust 2026-07-22 01:33:56 +08:00
ChatGPT Release Governance 77395fa9a0 feat(orchestration): add official portable executor providers 2026-07-22 01:29:46 +08:00
ChatGPT Release Governance 490ed4f65a feat(orchestration): add provider-neutral executor worker runtime 2026-07-22 01:26:54 +08:00
ChatGPT Release Governance d6d2407a0d feat(orchestration): add portable multi-provider execution plans 2026-07-22 01:22:48 +08:00
ChatGPT Release Governance db3fcb89a8 feat(orchestration): persist and migrate executor provider catalogs 2026-07-22 01:18:40 +08:00
ChatGPT Release Governance 20b7a41104 refactor(orchestration): make executor protocol provider-based and portable 2026-07-22 01:10:32 +08:00
ChatGPT Release Governance e739e67052 feat(orchestration): unify OpenClaw Hermes and Codex executor protocol 2026-07-22 01:05:56 +08:00
ChatGPT Release Governance 1e6c7b9ad8 feat(orchestration): add protected external task lifecycle API 2026-07-22 00:59:25 +08:00
ChatGPT Release Governance 1afbb11d3b feat(orchestration): add OpenClaw-compatible external task CLI 2026-07-22 00:54:49 +08:00
ChatGPT Release Governance 850beec8d7 feat(orchestration): add external task discovery read model 2026-07-22 00:49:45 +08:00
ChatGPT Release Governance fb11c71ec0 feat(orchestration): require owner notification receipts in external task gate 2026-07-22 00:42:50 +08:00
ChatGPT Release Governance 8acdd104bb feat(orchestration): add durable external task truth registry 2026-07-22 00:38:48 +08:00
ChatGPT Release Governance 43d77e520f feat(web): add tenant and Agent resource center to candidate portal 2026-07-22 00:34:16 +08:00
ChatGPT Release Governance a3f731d01c fix(web): bridge portal-only roles to domain read policy 2026-07-22 00:13:45 +08:00
ChatGPT Release Governance 8dba5b8c2e fix(web): provide unified portal HTML package boundary 2026-07-22 00:06:22 +08:00
ChatGPT Release Governance 74158908e8 feat(web): add V2R1C2 portal release entrypoint and resource aggregation 2026-07-22 00:04:12 +08:00
ChatGPT Release Governance ab36e36d5d feat(web): expose unified portal package 2026-07-22 00:00:17 +08:00
ChatGPT Release Governance c1b14753a3 feat(web): add tenant and Agent resource capabilities 2026-07-21 23:54:08 +08:00
ChatGPT Release Governance c62c8ee9f6 feat(web): replace read-only console with unified role-aware portal 2026-07-21 23:49:52 +08:00
ChatGPT Release Governance 9626e66e5b feat(web): add unified portal aggregation and protected action API 2026-07-21 23:44:06 +08:00
ChatGPT Release Governance b68e3c85a3 feat(web): add server-side role capability matrix 2026-07-21 23:39:20 +08:00
ChatGPT Release Governance 74014514da feat(web): create unified role-aware portal branch from V2R1C1 2026-07-21 23:17:54 +08:00
45 changed files with 8422 additions and 54 deletions
+68 -54
View File
@@ -1,69 +1,83 @@
"""Dependency-free AIOS V2 web console assets.
"""Dependency-free, role-aware AIOS unified portal.
Authentication and tenant identity are injected by the trusted reverse proxy.
The browser never selects a tenant and never calls OpenClaw or Hermes directly.
The browser receives only server-authorized capabilities and tenant-scoped data.
It never selects a tenant, signs Provenance, mutates Git tags, or calls Runtime
services directly.
"""
CONSOLE_HTML = r'''<!doctype html>
<html lang="en">
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="referrer" content="no-referrer">
<title>AIOS V2 Console</title>
<title>AIOS Unified Portal</title>
<style>
:root{font-family:Inter,ui-sans-serif,system-ui,sans-serif;color:#152033;background:#f4f7fb}
*{box-sizing:border-box} body{margin:0} header{background:#111827;color:white;padding:18px 24px;display:flex;justify-content:space-between;align-items:center}
header h1{font-size:20px;margin:0} header span{font-size:12px;color:#cbd5e1}
main{padding:24px;max-width:1440px;margin:auto}.notice{background:#fff7ed;border:1px solid #fed7aa;padding:12px 14px;border-radius:10px;margin-bottom:18px;font-size:13px}
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:14px;margin-bottom:20px}.card,.panel{background:white;border:1px solid #e5e7eb;border-radius:12px;box-shadow:0 1px 2px rgba(0,0,0,.04)}
.card{padding:16px}.card b{display:block;font-size:28px;margin-top:8px}.card small{color:#64748b}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(420px,1fr));gap:16px}.panel{padding:16px;overflow:auto}.panel h2{font-size:16px;margin:0 0 12px}
table{width:100%;border-collapse:collapse;font-size:12px}th,td{text-align:left;border-bottom:1px solid #edf0f4;padding:8px;vertical-align:top}th{color:#64748b;font-weight:600}.state{font-weight:700}.ok{color:#047857}.bad{color:#b91c1c}.muted{color:#64748b}
button{border:0;border-radius:8px;padding:9px 13px;background:#2563eb;color:white;cursor:pointer}button:disabled{opacity:.5}.toolbar{display:flex;gap:8px;align-items:center;margin-bottom:16px}.error{color:#b91c1c;white-space:pre-wrap}
:root{font-family:Inter,"PingFang SC","Microsoft YaHei",ui-sans-serif,system-ui,sans-serif;color:#172033;background:#eef2f7;--nav:#0f172a;--card:#fff;--line:#dfe6ef;--muted:#667085;--blue:#2563eb;--green:#047857;--red:#b42318;--amber:#b54708}
*{box-sizing:border-box}body{margin:0;min-height:100vh}button,input,textarea,select{font:inherit}
.shell{display:grid;grid-template-columns:250px minmax(0,1fr);min-height:100vh}.sidebar{background:var(--nav);color:#e2e8f0;padding:22px 16px;position:sticky;top:0;height:100vh;overflow:auto}.brand{font-weight:800;font-size:20px;letter-spacing:.2px}.version{font-size:12px;color:#94a3b8;margin:5px 0 22px}.identity{padding:12px;background:#182338;border:1px solid #2b3950;border-radius:12px;margin-bottom:18px}.identity strong{display:block;color:#fff}.identity span{font-size:12px;color:#aebbd0;display:block;margin-top:3px;overflow-wrap:anywhere}.nav button{width:100%;display:flex;gap:10px;align-items:center;border:0;background:transparent;color:#cbd5e1;padding:11px 12px;border-radius:9px;text-align:left;cursor:pointer;margin:2px 0}.nav button:hover,.nav button.active{background:#24324b;color:#fff}.nav .dot{width:8px;height:8px;border-radius:50%;background:#60a5fa}.main{min-width:0}.topbar{height:70px;background:#fff;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;padding:0 26px;position:sticky;top:0;z-index:4}.topbar h1{font-size:19px;margin:0}.top-actions{display:flex;align-items:center;gap:10px}.timestamp{font-size:12px;color:var(--muted)}.content{padding:24px;max-width:1680px;margin:auto}.notice{background:#eff6ff;border:1px solid #bfdbfe;border-radius:12px;padding:12px 14px;color:#1e3a8a;font-size:13px;margin-bottom:18px}.error{background:#fef3f2;border:1px solid #fecdca;color:var(--red);border-radius:10px;padding:10px 12px;white-space:pre-wrap;margin-bottom:14px;display:none}.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:14px;margin-bottom:18px}.card,.panel{background:var(--card);border:1px solid var(--line);border-radius:14px;box-shadow:0 1px 3px rgba(16,24,40,.05)}.card{padding:16px}.card small{color:var(--muted);font-weight:600}.card b{display:block;font-size:28px;margin-top:7px}.panel{padding:17px;margin-bottom:16px;overflow:auto}.panel h2{font-size:16px;margin:0}.panel-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:13px}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(410px,1fr));gap:16px}.wide{grid-column:1/-1}.muted{color:var(--muted)}.good{color:var(--green);font-weight:700}.bad{color:var(--red);font-weight:700}.warn{color:var(--amber);font-weight:700}.pill{display:inline-block;border:1px solid #cbd5e1;border-radius:999px;padding:3px 8px;font-size:11px;margin:2px;background:#f8fafc}.pill.good{background:#ecfdf3;border-color:#a6f4c5}.pill.bad{background:#fef3f2;border-color:#fecdca}.toolbar{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.btn{border:0;border-radius:9px;padding:9px 12px;background:var(--blue);color:#fff;cursor:pointer;font-weight:650}.btn.secondary{background:#fff;color:#344054;border:1px solid #d0d5dd}.btn.danger{background:#d92d20}.btn:disabled{opacity:.45;cursor:not-allowed}.icon-btn{padding:7px 10px}.view{display:none}.view.active{display:block}table{width:100%;border-collapse:collapse;font-size:12px}th,td{text-align:left;padding:9px 8px;border-bottom:1px solid #edf0f4;vertical-align:top}th{color:#667085;font-weight:700;white-space:nowrap}td{overflow-wrap:anywhere}.empty{padding:24px;text-align:center;color:var(--muted)}.timeline{border-left:2px solid #dbe4f0;margin-left:8px;padding-left:20px}.event{position:relative;padding:0 0 17px}.event:before{content:"";position:absolute;left:-27px;top:4px;width:11px;height:11px;border-radius:50%;background:#3b82f6;border:3px solid #fff;box-shadow:0 0 0 1px #93c5fd}.event strong{font-size:13px}.event p{margin:4px 0;color:var(--muted);font-size:12px}.chain{display:flex;align-items:stretch;gap:7px;overflow:auto;padding:5px 0}.chain-item{min-width:135px;border:1px solid var(--line);border-radius:10px;padding:10px;background:#f8fafc}.chain-arrow{display:flex;align-items:center;color:#94a3b8}.form-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:12px}.field label{font-size:12px;color:#475467;font-weight:650;display:block;margin-bottom:5px}.field input,.field textarea,.field select{width:100%;border:1px solid #d0d5dd;border-radius:9px;padding:9px;background:#fff}.field textarea{min-height:90px;resize:vertical}.dialog{border:0;border-radius:14px;padding:0;max-width:650px;width:calc(100% - 32px);box-shadow:0 20px 60px rgba(15,23,42,.28)}.dialog::backdrop{background:rgba(15,23,42,.6)}.dialog-head{padding:16px 18px;border-bottom:1px solid var(--line);font-weight:750}.dialog-body{padding:18px}.dialog-actions{padding:14px 18px;border-top:1px solid var(--line);display:flex;justify-content:flex-end;gap:8px}.kv{display:grid;grid-template-columns:150px minmax(0,1fr);gap:6px 12px;font-size:12px}.kv div:nth-child(odd){color:var(--muted)}pre{white-space:pre-wrap;word-break:break-word;font-size:11px;background:#0f172a;color:#dbeafe;border-radius:10px;padding:12px;max-height:300px;overflow:auto}
@media(max-width:900px){.shell{grid-template-columns:1fr}.sidebar{height:auto;position:static}.nav{display:flex;overflow:auto}.nav button{min-width:max-content}.topbar{position:static}.grid{grid-template-columns:1fr}.content{padding:16px}}
</style>
</head>
<body>
<header><h1>AIOS V2 Control Plane</h1><span id="updated">Not loaded</span></header>
<main>
<div class="notice">Read-only candidate console. Identity and tenant context must be injected by the trusted reverse proxy. The console never calls OpenClaw or Hermes directly.</div>
<div class="toolbar"><button id="refresh">Refresh</button><span id="error" class="error"></span></div>
<section class="cards">
<div class="card"><small>Agents</small><b id="agentCount">—</b></div>
<div class="card"><small>Tasks</small><b id="taskCount">—</b></div>
<div class="card"><small>Audit events</small><b id="auditCount">—</b></div>
<div class="card"><small>Executions</small><b id="executionCount">—</b></div>
<div class="shell">
<aside class="sidebar">
<div class="brand">AIOS Unified Portal</div><div class="version">V2R1C1 · Role-aware Control Plane</div>
<div class="identity"><strong id="principal">加载身份…</strong><span id="tenant"></span><span id="roles"></span></div>
<nav id="navigation" class="nav"></nav>
</aside>
<section class="main">
<header class="topbar"><h1 id="pageTitle">系统总览</h1><div class="top-actions"><span id="updated" class="timestamp">尚未加载</span><button class="btn secondary icon-btn" id="refresh">刷新</button></div></header>
<main class="content">
<div class="notice">身份、租户和角色由可信反向代理注入。门户只调用 AIOS Control Plane;Provenance 签发、Tag、生产部署与 Runtime 权威状态仍由受控流程执行。</div>
<div id="error" class="error"></div>
<section id="overview" class="view active"></section>
<section id="tasks" class="view"></section>
<section id="authorization" class="view"></section>
<section id="runtime" class="view"></section>
<section id="evidence" class="view"></section>
<section id="audit" class="view"></section>
<section id="release" class="view"></section>
</main>
</section>
<section class="grid">
<div class="panel"><h2>Runtime health</h2><div id="runtime"></div></div>
<div class="panel"><h2>Tasks</h2><div id="tasks"></div></div>
<div class="panel"><h2>Agents</h2><div id="agents"></div></div>
<div class="panel"><h2>Execution evidence</h2><div id="executions"></div></div>
<div class="panel"><h2>Audit</h2><div id="audit"></div></div>
</section>
</main>
</div>
<dialog id="taskDialog" class="dialog">
<div class="dialog-head">创建任务</div><form id="taskForm"><div class="dialog-body form-grid">
<div class="field"><label>Agent ID</label><input name="agent_id" required></div>
<div class="field"><label>Action</label><input name="action" required></div>
<div class="field"><label>Idempotency Key</label><input name="idempotency_key" required></div>
<div class="field wide"><label>Payload JSON</label><textarea name="payload">{}</textarea></div>
</div><div class="dialog-actions"><button type="button" class="btn secondary" data-close="taskDialog">取消</button><button class="btn">创建</button></div></form>
</dialog>
<dialog id="confirmDialog" class="dialog"><div class="dialog-head" id="confirmTitle">确认操作</div><form id="confirmForm"><div class="dialog-body"><p id="confirmText"></p><div class="field"><label>原因</label><textarea id="confirmReason" required></textarea></div></div><div class="dialog-actions"><button type="button" class="btn secondary" data-close="confirmDialog">取消</button><button class="btn danger">确认</button></div></form></dialog>
<script>
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const table=(cols,rows)=>`<table><thead><tr>${cols.map(c=>`<th>${esc(c[0])}</th>`).join('')}</tr></thead><tbody>${rows.map(r=>`<tr>${cols.map(c=>`<td>${esc(c[1](r))}</td>`).join('')}</tr>`).join('')}</tbody></table>`;
async function api(path){const r=await fetch(path,{credentials:'same-origin',headers:{'Accept':'application/json'}});const data=await r.json().catch(()=>({}));if(!r.ok)throw new Error(`${r.status} ${data.message||data.error||path}`);return data}
async function refresh(){
const btn=document.getElementById('refresh');btn.disabled=true;document.getElementById('error').textContent='';
try{
const [agents,tasks,audit,runtime,executions]=await Promise.all([
api('/api/v2/agents'),api('/api/v2/tasks'),api('/api/v2/audit'),api('/api/v2/runtime'),api('/api/v2/executions')
]);
document.getElementById('agentCount').textContent=agents.agents.length;
document.getElementById('taskCount').textContent=tasks.tasks.length;
document.getElementById('auditCount').textContent=audit.events.length;
document.getElementById('executionCount').textContent=executions.executions.length;
document.getElementById('agents').innerHTML=table([['ID',x=>x.agent_id],['Runtime',x=>x.runtime],['Enabled',x=>x.enabled]],agents.agents);
document.getElementById('tasks').innerHTML=table([['Task',x=>x.task_id],['Action',x=>x.action],['State',x=>x.state],['Rev',x=>x.revision]],tasks.tasks);
document.getElementById('audit').innerHTML=table([['Time',x=>x.created_at],['Action',x=>x.action],['Outcome',x=>x.outcome],['Resource',x=>x.resource_id]],audit.events.slice(-100).reverse());
document.getElementById('executions').innerHTML=table([['Executor',x=>x.executor],['Task',x=>x.task_id],['Status',x=>x.status],['Progress',x=>`${x.progress_completed}/${x.progress_total}`],['Commit',x=>x.last_commit||'']],executions.executions);
document.getElementById('runtime').innerHTML=table([['Runtime',x=>x.name],['Status',x=>x.status],['Details',x=>JSON.stringify(x.details)]],runtime.runtimes);
document.getElementById('updated').textContent=`Updated ${new Date().toLocaleString()}`;
}catch(e){document.getElementById('error').textContent=e.message||String(e)}finally{btn.disabled=false}
}
document.getElementById('refresh').addEventListener('click',refresh);refresh();setInterval(refresh,30000);
const $=s=>document.querySelector(s), esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const state={identity:null,current:'overview',cache:{},confirm:null};
const labels={overview:'系统总览',tasks:'任务中心',authorization:'授权中心',runtime:'运行与运维',evidence:'执行证据',audit:'审计中心',release:'GitOps 与发布'};
function has(c){return !!state.identity?.capabilities?.includes(c)}
async function api(path,options={}){const r=await fetch(path,{credentials:'same-origin',headers:{'Accept':'application/json','Content-Type':'application/json',...(options.headers||{})},...options});const d=await r.json().catch(()=>({}));if(!r.ok)throw new Error(`${r.status} ${d.message||d.error||path}`);return d}
function err(e){const el=$('#error');el.textContent=e?.message||String(e);el.style.display='block'}function clearErr(){$('#error').style.display='none';$('#error').textContent=''}
function pill(v){const s=String(v??'unknown'),c=/complete|pass|healthy|active|synced|approved|used/i.test(s)?'good':/fail|deny|reject|revoke|expire|cancel|unavailable/i.test(s)?'bad':'';return `<span class="pill ${c}">${esc(s)}</span>`}
function table(cols,rows){if(!rows?.length)return '<div class="empty">暂无数据</div>';return `<table><thead><tr>${cols.map(x=>`<th>${esc(x[0])}</th>`).join('')}</tr></thead><tbody>${rows.map(r=>`<tr>${cols.map(x=>`<td>${x[1](r)}</td>`).join('')}</tr>`).join('')}</tbody></table>`}
function card(label,value,detail=''){return `<div class="card"><small>${esc(label)}</small><b>${esc(value)}</b>${detail?`<span class="muted">${esc(detail)}</span>`:''}</div>`}
function json(v){return `<pre>${esc(JSON.stringify(v,null,2))}</pre>`}
function show(view){state.current=view;document.querySelectorAll('.view').forEach(x=>x.classList.toggle('active',x.id===view));document.querySelectorAll('.nav button').forEach(x=>x.classList.toggle('active',x.dataset.view===view));$('#pageTitle').textContent=labels[view]||view;load(view)}
function navigation(){const nav=$('#navigation');nav.innerHTML=(state.identity.navigation||[]).map(x=>`<button data-view="${esc(x.id)}"><span class="dot"></span>${esc(x.label)}</button>`).join('');nav.querySelectorAll('button').forEach(b=>b.onclick=()=>show(b.dataset.view));}
async function identity(){state.identity=await api('/api/v2/me/capabilities');$('#principal').textContent=state.identity.principal_id;$('#tenant').textContent=`租户:${state.identity.tenant_id}`;$('#roles').textContent=`角色:${state.identity.roles.join(', ')||'无'}`;navigation();if(!state.identity.navigation.some(x=>x.id===state.current))state.current=state.identity.navigation[0]?.id||'overview';show(state.current)}
async function load(view){clearErr();try{if(view==='overview')await overview();if(view==='tasks')await tasks();if(view==='authorization')await authorizations();if(view==='runtime')await runtime();if(view==='evidence')await evidence();if(view==='audit')await audit();if(view==='release')await release();$('#updated').textContent=`更新于 ${new Date().toLocaleString()}`}catch(e){err(e)}}
async function overview(){const d=await api('/api/v2/portal/overview');state.cache.overview=d;const s=d.summary||{},rt=d.sections?.runtime?.runtimes||[],rel=d.sections?.release||{};$('#overview').innerHTML=`<div class="cards">${card('任务',s.tasks||0)}${card('活动 Dispatch',s.active_dispatches||0,`总数 ${s.dispatches||0}`)}${card('执行证据',s.evidence||0)}${card('审计事件',s.audit_events||0)}${card('当前版本',rel.version||'—',rel.status||'')}${card('授权 Grant',d.sections?.authorization?.active_grants||0,`总数 ${d.sections?.authorization?.grants||0}`)}</div><div class="grid"><div class="panel"><div class="panel-head"><h2>Runtime 健康</h2></div>${table([['Runtime',x=>esc(x.name)],['状态',x=>pill(x.status)],['版本/详情',x=>esc(x.details?.version||x.details?.runtime_id||'—')]],rt)}</div><div class="panel"><div class="panel-head"><h2>任务状态</h2></div>${table([['状态',x=>pill(x[0])],['数量',x=>esc(x[1])]],Object.entries(s.task_states||{}))}</div><div class="panel wide"><div class="panel-head"><h2>最近任务</h2></div>${table([['任务',x=>esc(x.task_id)],['Agent',x=>esc(x.agent_id)],['动作',x=>esc(x.action)],['状态',x=>pill(x.state)],['更新时间',x=>esc(x.updated_at)]],d.recent?.tasks||[])}</div></div>`}
async function tasks(){const d=await api('/api/v2/portal/tasks');state.cache.tasks=d.items||[];const create=has('task.create')?'<button class="btn" id="newTask">创建任务</button>':'';$('#tasks').innerHTML=`<div class="panel"><div class="panel-head"><h2>任务全链路</h2><div class="toolbar">${create}</div></div>${(d.items||[]).map(taskBlock).join('')||'<div class="empty">暂无任务</div>'}</div>`;$('#newTask')?.addEventListener('click',()=>$('#taskDialog').showModal());document.querySelectorAll('[data-task-action]').forEach(b=>b.onclick=()=>taskAction(b.dataset.taskId,b.dataset.taskAction,b.dataset.revision))}
function taskBlock(x){const t=x.task||{},d=x.dispatch,e=x.evidence||[],a=x.audit||[],actions=(x.available_actions||[]).map(v=>`<button class="btn ${v==='cancel'?'danger':'secondary'}" data-task-action="${v}" data-task-id="${esc(t.task_id)}" data-revision="${esc(t.revision)}">${{approve:'审批',cancel:'取消',dispatch:'调度'}[v]||v}</button>`).join('');return `<div class="panel" style="background:#fbfcfe"><div class="panel-head"><h2>${esc(t.task_id)} · ${esc(t.action)}</h2><div class="toolbar">${pill(t.state)}${actions}</div></div><div class="chain"><div class="chain-item"><b>Task</b><div>${esc(t.agent_id)}</div><small>${esc(t.created_by)}</small></div><span class="chain-arrow">→</span><div class="chain-item"><b>Approval</b><div>${esc(t.approved_by||'待审批')}</div><small>revision ${esc(t.revision)}</small></div><span class="chain-arrow">→</span><div class="chain-item"><b>Provenance</b><div>${esc(d?.provenance_id||'未调度')}</div><small>${esc(d?.authorization_id||'')}</small></div><span class="chain-arrow">→</span><div class="chain-item"><b>Dispatch</b><div>${esc(d?.dispatch_id||'—')}</div><small>${pill(d?.state||'not_created')}</small></div><span class="chain-arrow">→</span><div class="chain-item"><b>Runtime</b><div>${esc(d?.runtime||'—')}</div><small>${esc(d?.runtime_execution_id||'')}</small></div><span class="chain-arrow">→</span><div class="chain-item"><b>Evidence</b><div>${e.length} 条</div><small>Audit ${a.length} 条</small></div></div></div>`}
async function taskAction(id,action,revision){clearErr();try{let body={expected_revision:Number(revision)};if(action==='dispatch'){body={};const p=prompt('Provenance ID(生产调度必填)','');const r=prompt('Run ID(生产调度必填)','');if(p)body.provenance_id=p;if(r)body.run_id=r}await api(`/api/v2/portal/tasks/${encodeURIComponent(id)}/${action}`,{method:'POST',body:JSON.stringify(body)});await tasks()}catch(e){err(e)}}
async function authorizations(){const d=await api('/api/v2/portal/authorizations');state.cache.authorization=d;const workflows=(d.workflows||[]),grants=(d.grants||[]);$('#authorization').innerHTML=`<div class="cards">${card('Workflow',d.summary?.workflows||0)}${card('Grant',d.summary?.grants||0)}${card('有效 Grant',d.summary?.active_grants||0)}${card('已撤销',d.summary?.revoked_grants||0)}</div><div class="panel"><div class="panel-head"><h2>Workflow Provenance</h2></div>${table([['ID',x=>esc(x.provenance_id)],['Run / Task',x=>`${esc(x.run_id)}<br>${esc(x.task_id)}`],['Principal / Agent',x=>`${esc(x.principal_id)}<br>${esc(x.agent_id)}`],['状态',x=>pill(x.status)],['有效期',x=>esc(x.expires_at)],['使用',x=>esc(x.usage_count)],['操作',x=>has('provenance.revoke')&&x.status!=='revoked'?`<button class="btn danger" data-revoke-workflow="${esc(x.provenance_id)}">撤销</button>`:'—']],workflows)}</div><div class="panel"><div class="panel-head"><h2>Child Grants</h2></div>${table([['Grant',x=>esc(x.grant_id)],['类型 / Scope',x=>`${esc(x.grant_type)}<br>${esc(x.execution_path)}`],['Git 操作',x=>esc((x.allowed_git_operations||[]).join(', ')||'—')],['状态',x=>pill(x.status)],['有效期',x=>esc(x.expires_at)],['使用',x=>esc(x.usage_count)],['操作',x=>has('provenance.revoke')&&x.status!=='revoked'?`<button class="btn danger" data-revoke-grant="${esc(x.grant_id)}">撤销</button>`:'—']],grants)}</div>`;document.querySelectorAll('[data-revoke-workflow]').forEach(b=>b.onclick=()=>confirmOp('撤销 Workflow',`撤销 ${b.dataset.revokeWorkflow} 将使所有子 Grant 失效。`,reason=>api(`/api/v2/portal/provenance/${encodeURIComponent(b.dataset.revokeWorkflow)}/revoke`,{method:'POST',body:JSON.stringify({reason})}).then(authorizations)));document.querySelectorAll('[data-revoke-grant]').forEach(b=>b.onclick=()=>confirmOp('撤销 Grant',`撤销 ${b.dataset.revokeGrant}。`,reason=>api(`/api/v2/portal/grants/${encodeURIComponent(b.dataset.revokeGrant)}/revoke`,{method:'POST',body:JSON.stringify({reason})}).then(authorizations)))}
async function runtime(){const d=await api('/api/v2/portal/runtime');const runtimes=d.runtimes||[],dispatches=d.dispatches||[],jobs=d.hermes_jobs||[];$('#runtime').innerHTML=`<div class="grid"><div class="panel"><div class="panel-head"><h2>Runtime</h2></div>${table([['名称',x=>esc(x.name)],['状态',x=>pill(x.status)],['详情',x=>esc(JSON.stringify(x.details||{}))]],runtimes)}</div><div class="panel"><div class="panel-head"><h2>Hermes Queue / Lease</h2></div>${table([['Job',x=>esc(x.job_id)],['Cell / Profile',x=>`${esc(x.cell_id)} / ${esc(x.profile_id)}`],['Worker',x=>esc(x.assigned_worker_id||'—')],['状态',x=>pill(x.state)],['Lease',x=>esc(x.lease_expires_at||'—')]],jobs)}</div><div class="panel wide"><div class="panel-head"><h2>Dispatch</h2></div>${table([['Dispatch',x=>esc(x.dispatch_id)],['Task',x=>esc(x.task_id)],['Runtime',x=>esc(x.runtime)],['执行 ID',x=>esc(x.runtime_execution_id)],['Principal',x=>esc(x.principal_id)],['状态',x=>pill(x.state)]],dispatches)}</div></div>`}
async function evidence(){const d=await api('/api/v2/portal/evidence');$('#evidence').innerHTML=`<div class="panel"><div class="panel-head"><h2>Execution Evidence</h2></div>${table([['Execution',x=>esc(x.execution_id)],['Task / Agent',x=>`${esc(x.task_id)}<br>${esc(x.agent_id)}`],['Executor',x=>esc(x.executor)],['状态',x=>pill(x.status)],['进度',x=>`${esc(x.progress_completed)}/${esc(x.progress_total)}`],['Test',x=>pill(x.test_status)],['Commit',x=>esc(x.last_commit||'—')],['授权',x=>esc(x.last_authorization_id||'—')]],d.executions||[])}</div>`}
async function audit(){const d=await api('/api/v2/portal/audit');const events=(d.events||[]).slice().reverse();$('#audit').innerHTML=`<div class="panel"><div class="panel-head"><h2>统一审计时间线</h2></div><div class="timeline">${events.map(x=>`<div class="event"><strong>${esc(x.action)} · ${pill(x.outcome)}</strong><p>${esc(x.created_at)} · ${esc(x.principal_id)} · ${esc(x.resource_type)}/${esc(x.resource_id)}</p><div class="muted">Trace ${esc(x.trace_id)} · Policy ${esc(x.policy_version)}</div></div>`).join('')||'<div class="empty">暂无审计事件</div>'}</div></div>`}
async function release(){const d=await api('/api/v2/portal/release');const g=d.gitops||{};$('#release').innerHTML=`<div class="cards">${card('版本',d.version||'—')}${card('Baseline',d.baseline||'—')}${card('发布状态',d.status||'—')}${card('Git 同步',g.sync_status||'unknown')}</div><div class="grid"><div class="panel"><div class="panel-head"><h2>当前 Release</h2></div><div class="kv"><div>Release OID</div><div>${esc(d.release_oid)}</div><div>Branch</div><div>${esc(g.workspace_branch||g.branch||'—')}</div><div>Workspace</div><div>${pill(g.workspace_clean===true?'clean':g.workspace_clean===false?'dirty':'unknown')}</div><div>Local Sync</div><div>${pill(g.local_sync||'unknown')}</div><div>Gitea Sync</div><div>${pill(g.gitea_sync||'unknown')}</div></div></div><div class="panel"><div class="panel-head"><h2>治理操作</h2></div><p class="muted">门户不直接移动 Tag、合并主线或部署生产。以下仅为可发起的 GitOps 请求目录。</p>${(d.operations||[]).map(x=>pill(x)).join('')||'<span class="muted">当前角色只读</span>'}</div><div class="panel wide"><div class="panel-head"><h2>原始 GitOps 状态</h2></div>${json(g)}</div></div>`}
function confirmOp(title,text,fn){state.confirm=fn;$('#confirmTitle').textContent=title;$('#confirmText').textContent=text;$('#confirmReason').value='';$('#confirmDialog').showModal()}
$('#taskForm').onsubmit=async e=>{e.preventDefault();clearErr();try{const fd=new FormData(e.target),payload=JSON.parse(fd.get('payload')||'{}');await api('/api/v2/portal/tasks',{method:'POST',body:JSON.stringify({agent_id:fd.get('agent_id'),action:fd.get('action'),idempotency_key:fd.get('idempotency_key'),payload})});$('#taskDialog').close();e.target.reset();await tasks()}catch(e){err(e)}};
$('#confirmForm').onsubmit=async e=>{e.preventDefault();const fn=state.confirm;$('#confirmDialog').close();state.confirm=null;try{await fn?.($('#confirmReason').value)}catch(e){err(e)}};
document.querySelectorAll('[data-close]').forEach(b=>b.onclick=()=>document.getElementById(b.dataset.close).close());$('#refresh').onclick=()=>load(state.current);
identity().catch(err);setInterval(()=>load(state.current),30000);
</script>
</body>
</html>'''
</body></html>'''
@@ -0,0 +1,543 @@
"""Portable multi-provider execution plans and aggregate release gates.
A plan is a DAG of executor-neutral stages. Only root stages are declared at
plan creation. A dependent stage is declared atomically after every dependency
passes its Truth + notification gate, so its start deadline cannot expire while
it is legitimately waiting for upstream work.
"""
from __future__ import annotations
import json
import sqlite3
import threading
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Any, Mapping, Sequence
from .executor_protocol import ExecutorProviderRegistry, normalize_executor
from .external_task_control import ExternalTaskCoordinator
from .external_task_query import list_records
from .task_execution_registry import (
ExecutionTaskManifest,
ExecutionTruthRecord,
ExecutionTruthState,
required,
utc_now,
)
PLAN_SCHEMA_VERSION = "aios.execution-plan.v2"
WAITING_DEPENDENCY = "WAITING_DEPENDENCY"
@dataclass(frozen=True)
class ExecutionStageManifest:
stage_id: str
task_id: str
executor_type: str
role: str
steps: Sequence[str]
depends_on: Sequence[str] = ()
required_evidence: Sequence[str] = ()
notification_events: Sequence[str] = ("STARTED", "SUCCEEDED", "FAILED")
owner_notification_ref: str = "AIOS_OWNER_WECHAT_TARGET_REF"
start_deadline_seconds: int = 300
heartbeat_timeout_seconds: int = 180
required: bool = True
metadata: Mapping[str, Any] = field(default_factory=dict)
def __post_init__(self) -> None:
for name in ("stage_id", "task_id", "role", "owner_notification_ref"):
object.__setattr__(self, name, required(getattr(self, name), name))
object.__setattr__(self, "executor_type", normalize_executor(self.executor_type))
steps = tuple(required(item, "step") for item in self.steps)
if not steps:
raise ValueError("execution stage requires at least one step")
object.__setattr__(self, "steps", steps)
object.__setattr__(
self,
"depends_on",
tuple(sorted({required(item, "depends_on") for item in self.depends_on})),
)
object.__setattr__(
self,
"required_evidence",
tuple(sorted({required(item, "required_evidence") for item in self.required_evidence})),
)
object.__setattr__(
self,
"notification_events",
tuple(sorted({required(item, "notification_event") for item in self.notification_events})),
)
if self.start_deadline_seconds < 1 or self.heartbeat_timeout_seconds < 1:
raise ValueError("stage deadlines must be positive")
object.__setattr__(
self,
"metadata",
json.loads(json.dumps(dict(self.metadata), ensure_ascii=False, default=str)),
)
def as_dict(self) -> dict[str, Any]:
value = asdict(self)
value["steps"] = list(self.steps)
value["depends_on"] = list(self.depends_on)
value["required_evidence"] = list(self.required_evidence)
value["notification_events"] = list(self.notification_events)
return value
@classmethod
def from_dict(cls, value: Mapping[str, Any]) -> "ExecutionStageManifest":
return cls(**dict(value))
@dataclass(frozen=True)
class ExecutionPlanManifest:
plan_id: str
source_branch: str
source_oid: str
target_version: str
stages: Sequence[ExecutionStageManifest]
release_gate_policy: str = "all_required_stages"
schema_version: str = PLAN_SCHEMA_VERSION
metadata: Mapping[str, Any] = field(default_factory=dict)
def __post_init__(self) -> None:
for name in ("plan_id", "source_branch", "source_oid", "target_version"):
object.__setattr__(self, name, required(getattr(self, name), name))
if self.schema_version != PLAN_SCHEMA_VERSION:
raise ValueError("unsupported execution plan schema")
if self.release_gate_policy != "all_required_stages":
raise ValueError("unsupported release gate policy")
stages = tuple(self.stages)
if not stages:
raise ValueError("execution plan requires stages")
stage_ids = [item.stage_id for item in stages]
task_ids = [item.task_id for item in stages]
if len(stage_ids) != len(set(stage_ids)):
raise ValueError("execution plan stage_id values must be unique")
if len(task_ids) != len(set(task_ids)):
raise ValueError("execution plan task_id values must be unique")
known = set(stage_ids)
for stage in stages:
unknown = sorted(set(stage.depends_on) - known)
if unknown:
raise ValueError(f"stage {stage.stage_id} has unknown dependencies: {unknown}")
if stage.stage_id in stage.depends_on:
raise ValueError("stage cannot depend on itself")
self._assert_acyclic(stages)
object.__setattr__(self, "stages", stages)
object.__setattr__(
self,
"metadata",
json.loads(json.dumps(dict(self.metadata), ensure_ascii=False, default=str)),
)
@staticmethod
def _assert_acyclic(stages: Sequence[ExecutionStageManifest]) -> None:
dependencies = {item.stage_id: set(item.depends_on) for item in stages}
resolved: set[str] = set()
while len(resolved) < len(stages):
ready = {
stage_id
for stage_id, deps in dependencies.items()
if stage_id not in resolved and deps.issubset(resolved)
}
if not ready:
raise ValueError("execution plan contains a dependency cycle")
resolved.update(ready)
def as_dict(self) -> dict[str, Any]:
return {
"schema_version": self.schema_version,
"plan_id": self.plan_id,
"source_branch": self.source_branch,
"source_oid": self.source_oid,
"target_version": self.target_version,
"release_gate_policy": self.release_gate_policy,
"stages": [item.as_dict() for item in self.stages],
"metadata": dict(self.metadata),
}
@classmethod
def from_dict(cls, value: Mapping[str, Any]) -> "ExecutionPlanManifest":
raw = dict(value)
raw["stages"] = tuple(
ExecutionStageManifest.from_dict(item) for item in raw.get("stages") or ()
)
return cls(**raw)
def stage(self, stage_id: str) -> ExecutionStageManifest:
normalized = required(stage_id, "stage_id")
for stage in self.stages:
if stage.stage_id == normalized:
return stage
raise KeyError(f"execution stage not found: {normalized}")
class SQLiteExecutionPlanStore:
"""Durable plan declarations; stage state remains in Truth Registry."""
def __init__(self, path: str | Path) -> None:
self.path = Path(path).resolve()
self.path.parent.mkdir(parents=True, exist_ok=True)
self._lock = threading.RLock()
with self._connect() as connection:
connection.executescript(
"""
CREATE TABLE IF NOT EXISTS execution_plans (
plan_id TEXT PRIMARY KEY,
manifest_json TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
"""
)
def _connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(str(self.path), timeout=30.0)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA journal_mode=WAL")
connection.execute("PRAGMA synchronous=FULL")
return connection
@staticmethod
def _encode(plan: ExecutionPlanManifest) -> str:
return json.dumps(
plan.as_dict(),
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
)
def save(
self,
plan: ExecutionPlanManifest,
*,
allow_source_oid_supersession: bool = False,
) -> None:
payload = self._encode(plan)
now = utc_now()
with self._lock, self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
existing = connection.execute(
"SELECT manifest_json FROM execution_plans WHERE plan_id = ?",
(plan.plan_id,),
).fetchone()
if existing is not None:
if existing["manifest_json"] != payload:
previous = json.loads(existing["manifest_json"])
replacement = plan.as_dict()
previous_oid = previous.pop("source_oid", None)
replacement_oid = replacement.pop("source_oid", None)
if (
not allow_source_oid_supersession
or previous != replacement
or previous_oid == replacement_oid
):
connection.rollback()
raise RuntimeError(
"plan_id already exists with another manifest"
)
connection.execute(
"UPDATE execution_plans SET manifest_json = ?, updated_at = ? WHERE plan_id = ?",
(payload, now, plan.plan_id),
)
connection.commit()
return
connection.rollback()
return
connection.execute(
"INSERT INTO execution_plans VALUES (?, ?, ?, ?)",
(plan.plan_id, payload, now, now),
)
connection.commit()
def get(self, plan_id: str) -> ExecutionPlanManifest:
with self._lock, self._connect() as connection:
row = connection.execute(
"SELECT manifest_json FROM execution_plans WHERE plan_id = ?",
(required(plan_id, "plan_id"),),
).fetchone()
if row is None:
raise KeyError("execution plan not found")
value = json.loads(row["manifest_json"])
if not isinstance(value, Mapping):
raise ValueError("stored execution plan is invalid")
return ExecutionPlanManifest.from_dict(value)
def list(self) -> list[ExecutionPlanManifest]:
with self._lock, self._connect() as connection:
rows = connection.execute(
"SELECT manifest_json FROM execution_plans ORDER BY created_at, plan_id"
).fetchall()
return [
ExecutionPlanManifest.from_dict(json.loads(row["manifest_json"]))
for row in rows
]
class ExecutionPlanCoordinator:
"""Declares dependency-ready stages and evaluates aggregate release gates."""
def __init__(
self,
*,
task_coordinator: ExternalTaskCoordinator,
plan_store: SQLiteExecutionPlanStore,
providers: ExecutorProviderRegistry,
) -> None:
self.task_coordinator = task_coordinator
self.plan_store = plan_store
self.providers = providers
@classmethod
def from_file(
cls,
path: str | Path,
*,
providers: ExecutorProviderRegistry,
) -> "ExecutionPlanCoordinator":
return cls(
task_coordinator=ExternalTaskCoordinator.from_file(path),
plan_store=SQLiteExecutionPlanStore(path),
providers=providers,
)
@staticmethod
def _task_manifest(
plan: ExecutionPlanManifest,
stage: ExecutionStageManifest,
) -> ExecutionTaskManifest:
return ExecutionTaskManifest(
task_id=stage.task_id,
executor=stage.executor_type,
source_branch=plan.source_branch,
source_oid=plan.source_oid,
target_version=plan.target_version,
steps=stage.steps,
start_deadline_seconds=stage.start_deadline_seconds,
heartbeat_timeout_seconds=stage.heartbeat_timeout_seconds,
required_evidence=stage.required_evidence,
notification_events=stage.notification_events,
owner_notification_ref=stage.owner_notification_ref,
manifest_version="aios.external-task.v2",
)
def _record(self, task_id: str) -> ExecutionTruthRecord | None:
try:
return self.task_coordinator.registry.get(task_id)
except KeyError:
return None
def _declare_stage(
self,
plan: ExecutionPlanManifest,
stage: ExecutionStageManifest,
) -> ExecutionTruthRecord:
self.providers.validate_manifest(self._task_manifest(plan, stage))
return self.task_coordinator.declare(self._task_manifest(plan, stage))
def declare(self, plan: ExecutionPlanManifest) -> Mapping[str, Any]:
for stage in plan.stages:
self.providers.get(stage.executor_type)
allow_source_oid_supersession = False
try:
previous = self.plan_store.get(plan.plan_id)
except KeyError:
previous = None
if previous is not None and previous.source_oid != plan.source_oid:
previous_value = previous.as_dict()
replacement_value = plan.as_dict()
previous_value.pop("source_oid")
replacement_value.pop("source_oid")
root_records = [
self._record(stage.task_id)
for stage in previous.stages
if not stage.depends_on
]
allow_source_oid_supersession = bool(
previous_value == replacement_value
and root_records
and all(
record is not None
and record.state == ExecutionTruthState.NOT_STARTED
for record in root_records
)
)
self.plan_store.save(
plan,
allow_source_oid_supersession=allow_source_oid_supersession,
)
for stage in plan.stages:
if not stage.depends_on:
self._declare_stage(plan, stage)
return self.snapshot(plan.plan_id)
def declare_file(self, path: str | Path) -> Mapping[str, Any]:
value = json.loads(Path(path).read_text(encoding="utf-8"))
if not isinstance(value, Mapping):
raise ValueError("execution plan file must contain a JSON object")
return self.declare(ExecutionPlanManifest.from_dict(value))
def dependency_gate(self, plan_id: str, stage_id: str) -> Mapping[str, Any]:
plan = self.plan_store.get(plan_id)
stage = plan.stage(stage_id)
dependencies: list[Mapping[str, Any]] = []
passed = True
for dependency_id in stage.depends_on:
dependency = plan.stage(dependency_id)
record = self._record(dependency.task_id)
if record is None:
gate: Mapping[str, Any] = {
"task_id": dependency.task_id,
"state": WAITING_DEPENDENCY,
"passed": False,
"release_blocking": True,
}
else:
gate = self.task_coordinator.gate(dependency.task_id)
dependencies.append(
{
"stage_id": dependency.stage_id,
"task_id": dependency.task_id,
"gate": gate,
}
)
if not gate["passed"]:
passed = False
return {
"plan_id": plan.plan_id,
"stage_id": stage.stage_id,
"passed": passed,
"release_blocking": not passed,
"dependencies": dependencies,
}
def activate_ready(self, plan_id: str) -> list[Mapping[str, Any]]:
plan = self.plan_store.get(plan_id)
activated: list[Mapping[str, Any]] = []
for stage in plan.stages:
if self._record(stage.task_id) is not None:
continue
dependency = self.dependency_gate(plan.plan_id, stage.stage_id)
if not dependency["passed"]:
continue
record = self._declare_stage(plan, stage)
activated.append(
{
"stage_id": stage.stage_id,
"task_id": record.task_id,
"executor_type": stage.executor_type,
"state": record.state.value,
}
)
return activated
def discover_ready(
self,
*,
plan_id: str,
executor_type: str,
) -> list[Mapping[str, Any]]:
plan = self.plan_store.get(plan_id)
self.activate_ready(plan.plan_id)
normalized = normalize_executor(executor_type)
records = {
item.task_id: item
for item in list_records(
self.task_coordinator.registry.path,
executor=normalized,
states=(
ExecutionTruthState.DECLARED.value,
ExecutionTruthState.REGISTERED.value,
),
)
}
ready: list[Mapping[str, Any]] = []
for stage in plan.stages:
if stage.executor_type != normalized or stage.task_id not in records:
continue
dependency = self.dependency_gate(plan.plan_id, stage.stage_id)
if dependency["passed"]:
ready.append(
{
"plan_id": plan.plan_id,
"stage": stage.as_dict(),
"record": records[stage.task_id].as_dict(),
}
)
return ready
def snapshot(self, plan_id: str) -> Mapping[str, Any]:
plan = self.plan_store.get(plan_id)
stages: list[Mapping[str, Any]] = []
for stage in plan.stages:
record = self._record(stage.task_id)
dependency = self.dependency_gate(plan.plan_id, stage.stage_id)
if record is None:
truth: Mapping[str, Any] = {
"task_id": stage.task_id,
"state": WAITING_DEPENDENCY,
"passed": False,
"release_blocking": True,
"executor_run_id": None,
"executor_id": None,
"current_step": 0,
"total_steps": len(stage.steps),
"evidence": {},
"error": {},
"notifications": [],
}
else:
truth = self.task_coordinator.status(stage.task_id)
stages.append(
{
"stage": stage.as_dict(),
"dependency_gate": dependency,
"truth": truth,
}
)
return {
"manifest": plan.as_dict(),
"stages": stages,
}
def gate(self, plan_id: str) -> Mapping[str, Any]:
plan = self.plan_store.get(plan_id)
self.activate_ready(plan.plan_id)
stages: list[Mapping[str, Any]] = []
required_passed = True
for stage in plan.stages:
dependency = self.dependency_gate(plan.plan_id, stage.stage_id)
record = self._record(stage.task_id)
if record is None:
truth_gate: Mapping[str, Any] = {
"task_id": stage.task_id,
"state": WAITING_DEPENDENCY,
"passed": False,
"release_blocking": True,
}
else:
truth_gate = self.task_coordinator.gate(stage.task_id)
stage_passed = bool(truth_gate["passed"] and dependency["passed"])
if stage.required and not stage_passed:
required_passed = False
stages.append(
{
"stage_id": stage.stage_id,
"task_id": stage.task_id,
"executor_type": stage.executor_type,
"role": stage.role,
"required": stage.required,
"passed": stage_passed,
"truth_gate": truth_gate,
"dependency_gate": dependency,
}
)
return {
"plan_id": plan.plan_id,
"target_version": plan.target_version,
"policy": plan.release_gate_policy,
"passed": required_passed,
"release_blocking": not required_passed,
"stages": stages,
}
@@ -0,0 +1,204 @@
"""Persistent and portable AIOS executor provider catalog.
The catalog contains descriptors only: provider names, protocol/adapter versions,
trust roles, capabilities and configuration schema identifiers. Credentials,
endpoints and runtime secrets are intentionally excluded and must be resolved in
the destination environment.
"""
from __future__ import annotations
import hashlib
import json
import os
import threading
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Mapping, Sequence
from .executor_protocol import (
EXECUTOR_PROTOCOL_VERSION,
DescriptorOnlyProvider,
ExecutorProvider,
ExecutorProviderDescriptor,
ExecutorProviderRegistry,
default_provider_registry,
)
CATALOG_SCHEMA_VERSION = "aios.executor.catalog.v1"
MIGRATION_SCHEMA_VERSION = "aios.executor.migration.v1"
def canonical_json_bytes(value: Mapping[str, Any]) -> bytes:
return json.dumps(
dict(value),
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
default=str,
).encode("utf-8")
def sha256_payload(value: Mapping[str, Any]) -> str:
return hashlib.sha256(canonical_json_bytes(value)).hexdigest()
@dataclass(frozen=True)
class ExecutorCatalogSnapshot:
catalog: Mapping[str, Any]
catalog_sha256: str
path: str
def as_dict(self) -> dict[str, Any]:
return {
"catalog": dict(self.catalog),
"catalog_sha256": self.catalog_sha256,
"path": self.path,
}
class FileExecutorCatalogStore:
"""Atomic file store for provider descriptors and migration packages."""
def __init__(self, path: str | Path) -> None:
self.path = Path(path).resolve()
self.path.parent.mkdir(parents=True, exist_ok=True)
self._lock = threading.RLock()
@staticmethod
def _validate_catalog(value: Mapping[str, Any]) -> ExecutorProviderRegistry:
if value.get("schema_version") != CATALOG_SCHEMA_VERSION:
raise ValueError("unsupported executor catalog schema")
if value.get("protocol_version") != EXECUTOR_PROTOCOL_VERSION:
raise ValueError("executor catalog protocol mismatch")
providers = value.get("providers")
if not isinstance(providers, Sequence) or isinstance(providers, (str, bytes)):
raise ValueError("executor catalog providers must be an array")
return ExecutorProviderRegistry.import_catalog(value)
def initialize_defaults(self) -> ExecutorCatalogSnapshot:
with self._lock:
if self.path.exists():
return self.load()
return self.save(default_provider_registry())
def load(self) -> ExecutorCatalogSnapshot:
with self._lock:
value = json.loads(self.path.read_text(encoding="utf-8"))
if not isinstance(value, Mapping):
raise ValueError("executor catalog must contain a JSON object")
registry = self._validate_catalog(value)
normalized = registry.export_catalog()
return ExecutorCatalogSnapshot(
catalog=normalized,
catalog_sha256=sha256_payload(normalized),
path=str(self.path),
)
def registry(
self,
*,
provider_factories: Mapping[str, ExecutorProvider] | None = None,
) -> ExecutorProviderRegistry:
snapshot = self.load()
return ExecutorProviderRegistry.import_catalog(
snapshot.catalog,
provider_factories=provider_factories,
)
def save(self, registry: ExecutorProviderRegistry) -> ExecutorCatalogSnapshot:
catalog = registry.export_catalog()
self._validate_catalog(catalog)
encoded = json.dumps(
catalog,
ensure_ascii=False,
sort_keys=True,
indent=2,
) + "\n"
with self._lock:
temporary = self.path.with_suffix(f".tmp-{os.getpid()}")
temporary.write_text(encoded, encoding="utf-8")
os.chmod(temporary, 0o640)
os.replace(temporary, self.path)
return ExecutorCatalogSnapshot(
catalog=catalog,
catalog_sha256=sha256_payload(catalog),
path=str(self.path),
)
def upsert_descriptor(
self,
descriptor: ExecutorProviderDescriptor,
) -> ExecutorCatalogSnapshot:
with self._lock:
if self.path.exists():
registry = self.registry()
else:
registry = default_provider_registry()
try:
registry.register(DescriptorOnlyProvider(descriptor), replace=True)
except KeyError:
registry.register(DescriptorOnlyProvider(descriptor))
return self.save(registry)
def export_migration_package(
self,
*,
source_environment: str,
required_executor_types: Sequence[str] = (),
) -> Mapping[str, Any]:
snapshot = self.load()
registry = ExecutorProviderRegistry.import_catalog(snapshot.catalog)
required = tuple(sorted({str(item).strip().lower() for item in required_executor_types if str(item).strip()}))
if required:
available = {item.executor_type for item in registry.list_descriptors()}
missing = sorted(set(required) - available)
if missing:
raise ValueError(f"migration package is missing providers: {missing}")
package = {
"schema_version": MIGRATION_SCHEMA_VERSION,
"source_environment": str(source_environment or "").strip(),
"catalog": snapshot.catalog,
"catalog_sha256": snapshot.catalog_sha256,
"required_executor_types": list(required),
"secret_values_included": False,
}
if not package["source_environment"]:
raise ValueError("source_environment is required")
return {
**package,
"package_sha256": sha256_payload(package),
}
@classmethod
def import_migration_package(
cls,
value: Mapping[str, Any],
*,
destination_path: str | Path,
destination_environment: str,
provider_factories: Mapping[str, ExecutorProvider] | None = None,
) -> ExecutorCatalogSnapshot:
if value.get("schema_version") != MIGRATION_SCHEMA_VERSION:
raise ValueError("unsupported executor migration schema")
if value.get("secret_values_included") is not False:
raise ValueError("executor migration package must not contain secrets")
package_without_hash = {key: item for key, item in value.items() if key != "package_sha256"}
if sha256_payload(package_without_hash) != value.get("package_sha256"):
raise ValueError("executor migration package hash mismatch")
catalog = value.get("catalog")
if not isinstance(catalog, Mapping):
raise ValueError("executor migration package has no catalog")
if sha256_payload(catalog) != value.get("catalog_sha256"):
raise ValueError("executor catalog hash mismatch")
registry = ExecutorProviderRegistry.import_catalog(
catalog,
provider_factories=provider_factories,
)
required = tuple(value.get("required_executor_types") or ())
available = {item.executor_type for item in registry.list_descriptors()}
missing = sorted(set(required) - available)
if missing:
raise ValueError(f"destination provider factories are missing: {missing}")
if not str(destination_environment or "").strip():
raise ValueError("destination_environment is required")
return cls(destination_path).save(registry)
@@ -0,0 +1,441 @@
"""Versioned, extensible executor protocol for AIOS external tasks.
The core lifecycle is executor-neutral. OpenClaw, Hermes, Codex and the scoped
ChatGPT supervisor are built-in
providers, not hard-coded protocol branches. Future providers register a
versioned descriptor and use the same Truth Registry, evidence, notification
and release-gate mechanisms without modifying the lifecycle state machine.
"""
from __future__ import annotations
import json
import re
import threading
from dataclasses import asdict, dataclass, field
from typing import Any, Mapping, Protocol, Sequence
from .external_task_control import ExternalTaskCoordinator
from .task_execution_registry import ExecutionTaskManifest, ExecutionTruthRecord
EXECUTOR_PROTOCOL_VERSION = "aios.executor.v1"
_PROVIDER_NAME = re.compile(r"^[a-z][a-z0-9._-]{1,63}$")
_VERSION = re.compile(r"^[0-9]+(?:\.[0-9]+){0,2}(?:[-+][A-Za-z0-9._-]+)?$")
DISCOVERABLE_STATES: Sequence[str] = ("DECLARED", "REGISTERED")
def normalize_executor(value: str) -> str:
normalized = str(value or "").strip().lower()
if not _PROVIDER_NAME.fullmatch(normalized):
raise ValueError("executor type must be a lowercase provider identifier")
return normalized
def _major(version: str) -> int:
normalized = str(version or "").strip()
if not _VERSION.fullmatch(normalized):
raise ValueError(f"invalid version: {version}")
return int(normalized.split(".", 1)[0])
@dataclass(frozen=True)
class ExecutorProviderDescriptor:
"""Portable provider metadata stored without credentials or runtime secrets."""
executor_type: str
adapter_version: str
protocol_version: str = EXECUTOR_PROTOCOL_VERSION
trust_roles: Sequence[str] = ()
capabilities: Sequence[str] = ()
configuration_schema: str = "aios.executor.config.v1"
portable: bool = True
metadata: Mapping[str, Any] = field(default_factory=dict)
def __post_init__(self) -> None:
object.__setattr__(self, "executor_type", normalize_executor(self.executor_type))
adapter_version = str(self.adapter_version or "").strip()
if not _VERSION.fullmatch(adapter_version):
raise ValueError("adapter_version must be version-like")
object.__setattr__(self, "adapter_version", adapter_version)
protocol_version = str(self.protocol_version or "").strip()
if not protocol_version.startswith("aios.executor.v"):
raise ValueError("unsupported executor protocol namespace")
int(protocol_version.rsplit("v", 1)[1])
object.__setattr__(self, "protocol_version", protocol_version)
roles = tuple(sorted({str(item).strip() for item in self.trust_roles if str(item).strip()}))
if not roles:
raise ValueError("provider must declare at least one trust role")
capabilities = tuple(
sorted({str(item).strip() for item in self.capabilities if str(item).strip()})
)
object.__setattr__(self, "trust_roles", roles)
object.__setattr__(self, "capabilities", capabilities)
object.__setattr__(
self,
"configuration_schema",
str(self.configuration_schema or "").strip(),
)
object.__setattr__(
self,
"metadata",
json.loads(json.dumps(dict(self.metadata), ensure_ascii=False, default=str)),
)
def as_dict(self) -> dict[str, Any]:
value = asdict(self)
value["trust_roles"] = list(self.trust_roles)
value["capabilities"] = list(self.capabilities)
return value
@classmethod
def from_dict(cls, value: Mapping[str, Any]) -> "ExecutorProviderDescriptor":
return cls(**dict(value))
def protocol_compatible_with(self, other: "ExecutorProviderDescriptor") -> bool:
return (
self.executor_type == other.executor_type
and self.protocol_version == other.protocol_version
and _major(self.adapter_version) == _major(other.adapter_version)
)
class ExternalExecutorAdapter(Protocol):
"""Runtime-specific implementation behind the common lifecycle."""
descriptor: ExecutorProviderDescriptor
def discover(self) -> Sequence[ExecutionTruthRecord]: ...
def execute(self, manifest: ExecutionTaskManifest) -> Mapping[str, Any]: ...
def notify_owner(self, payload: Mapping[str, Any]) -> str: ...
class ExecutorProvider(Protocol):
descriptor: ExecutorProviderDescriptor
def validate_manifest(self, manifest: ExecutionTaskManifest) -> None: ...
def build_adapter(self, configuration: Mapping[str, Any]) -> ExternalExecutorAdapter: ...
class DescriptorOnlyProvider:
"""Default provider used when lifecycle integration is supplied externally."""
def __init__(self, descriptor: ExecutorProviderDescriptor) -> None:
self.descriptor = descriptor
def validate_manifest(self, manifest: ExecutionTaskManifest) -> None:
if normalize_executor(manifest.executor) != self.descriptor.executor_type:
raise ValueError("manifest executor does not match provider")
def build_adapter(self, configuration: Mapping[str, Any]) -> ExternalExecutorAdapter:
raise RuntimeError(
f"provider {self.descriptor.executor_type} requires an external adapter factory"
)
class ExecutorProviderRegistry:
"""Thread-safe registry supporting runtime registration and catalog migration."""
def __init__(self, providers: Sequence[ExecutorProvider] = ()) -> None:
self._lock = threading.RLock()
self._providers: dict[str, ExecutorProvider] = {}
for provider in providers:
self.register(provider)
def register(self, provider: ExecutorProvider, *, replace: bool = False) -> None:
descriptor = provider.descriptor
key = normalize_executor(descriptor.executor_type)
with self._lock:
current = self._providers.get(key)
if current is not None and not replace:
raise ValueError(f"executor provider already registered: {key}")
if current is not None and not descriptor.protocol_compatible_with(
current.descriptor
):
raise ValueError(
f"replacement provider is incompatible with registered provider: {key}"
)
self._providers[key] = provider
def unregister(self, executor_type: str) -> ExecutorProvider:
key = normalize_executor(executor_type)
with self._lock:
try:
return self._providers.pop(key)
except KeyError as exc:
raise KeyError(f"executor provider is not registered: {key}") from exc
def get(self, executor_type: str) -> ExecutorProvider:
key = normalize_executor(executor_type)
with self._lock:
provider = self._providers.get(key)
if provider is None:
raise KeyError(f"executor provider is not registered: {key}")
return provider
def descriptor(self, executor_type: str) -> ExecutorProviderDescriptor:
return self.get(executor_type).descriptor
def validate_manifest(self, manifest: ExecutionTaskManifest) -> None:
self.get(manifest.executor).validate_manifest(manifest)
def trusted(
self,
*,
executor_type: str,
roles: Sequence[str],
platform_admin: bool = False,
) -> bool:
if platform_admin or "platform_admin" in set(roles):
return True
descriptor = self.descriptor(executor_type)
return bool(set(descriptor.trust_roles).intersection(set(roles)))
def list_descriptors(self) -> tuple[ExecutorProviderDescriptor, ...]:
with self._lock:
return tuple(
self._providers[key].descriptor for key in sorted(self._providers)
)
def export_catalog(self) -> Mapping[str, Any]:
return {
"schema_version": "aios.executor.catalog.v1",
"protocol_version": EXECUTOR_PROTOCOL_VERSION,
"providers": [item.as_dict() for item in self.list_descriptors()],
}
@classmethod
def import_catalog(
cls,
value: Mapping[str, Any],
*,
provider_factories: Mapping[str, ExecutorProvider] | None = None,
) -> "ExecutorProviderRegistry":
if value.get("schema_version") != "aios.executor.catalog.v1":
raise ValueError("unsupported executor catalog schema")
if value.get("protocol_version") != EXECUTOR_PROTOCOL_VERSION:
raise ValueError("executor catalog protocol mismatch")
factories = dict(provider_factories or {})
providers: list[ExecutorProvider] = []
for raw in value.get("providers") or ():
descriptor = ExecutorProviderDescriptor.from_dict(raw)
provider = factories.get(descriptor.executor_type)
if provider is not None:
if not provider.descriptor.protocol_compatible_with(descriptor):
raise ValueError(
f"provider factory does not match imported descriptor: {descriptor.executor_type}"
)
providers.append(provider)
else:
providers.append(DescriptorOnlyProvider(descriptor))
return cls(providers)
BUILTIN_PROVIDER_DESCRIPTORS: Sequence[ExecutorProviderDescriptor] = (
ExecutorProviderDescriptor(
executor_type="openclaw",
adapter_version="2.1.0",
trust_roles=("runtime", "openclaw_executor"),
capabilities=(
"deploy",
"http_runtime",
"owner_notification",
"healthcheck",
"rollback",
),
configuration_schema="aios.executor.openclaw.config.v1",
metadata={"official": True},
),
ExecutorProviderDescriptor(
executor_type="hermes",
adapter_version="2.1.0",
trust_roles=("runtime", "hermes_executor"),
capabilities=(
"queue",
"lease",
"worker",
"runtime_validation",
"restart_recovery",
),
configuration_schema="aios.executor.hermes.config.v1",
metadata={"official": True},
),
ExecutorProviderDescriptor(
executor_type="codex",
adapter_version="2.1.0",
trust_roles=("codex", "codex_executor"),
capabilities=("code", "test", "docs", "gitops_patch", "security_review"),
configuration_schema="aios.executor.codex.config.v1",
metadata={"official": True},
),
ExecutorProviderDescriptor(
executor_type="chatgpt-supervisor",
adapter_version="2.1.0",
trust_roles=("chatgpt_supervisor",),
capabilities=(
"acceptance",
"deploy",
"evidence",
"healthcheck",
"rollback",
"wechat_simulation",
),
configuration_schema="aios.executor.chatgpt-supervisor.config.v1",
metadata={"official": True, "production_mutation_allowed": False},
),
)
def default_provider_registry() -> ExecutorProviderRegistry:
return ExecutorProviderRegistry(
[DescriptorOnlyProvider(item) for item in BUILTIN_PROVIDER_DESCRIPTORS]
)
def trusted_for_executor(
*,
executor: str,
roles: Sequence[str],
platform_admin: bool = False,
registry: ExecutorProviderRegistry | None = None,
) -> bool:
return (registry or default_provider_registry()).trusted(
executor_type=executor,
roles=roles,
platform_admin=platform_admin,
)
@dataclass(frozen=True)
class ExecutorIdentity:
executor_type: str
executor_id: str
executor_run_id: str
protocol_version: str = EXECUTOR_PROTOCOL_VERSION
def __post_init__(self) -> None:
object.__setattr__(self, "executor_type", normalize_executor(self.executor_type))
for name in ("executor_id", "executor_run_id"):
value = str(getattr(self, name) or "").strip()
if not value:
raise ValueError(f"{name} is required")
object.__setattr__(self, name, value)
if self.protocol_version != EXECUTOR_PROTOCOL_VERSION:
raise ValueError("executor identity protocol mismatch")
class ExecutorLifecycleSession:
"""Executor-neutral lifecycle facade used by every registered provider."""
def __init__(
self,
coordinator: ExternalTaskCoordinator,
identity: ExecutorIdentity,
*,
registry: ExecutorProviderRegistry | None = None,
) -> None:
self.coordinator = coordinator
self.identity = identity
self.registry = registry or default_provider_registry()
self.registry.get(identity.executor_type)
def _assert_manifest(self, task_id: str) -> ExecutionTaskManifest:
record = self.coordinator.registry.get(task_id)
manifest = record.manifest
self.registry.validate_manifest(manifest)
if normalize_executor(manifest.executor) != self.identity.executor_type:
raise PermissionError(
f"task executor is {manifest.executor}, not {self.identity.executor_type}"
)
return manifest
def register(self, task_id: str) -> ExecutionTruthRecord:
self._assert_manifest(task_id)
return self.coordinator.register(
task_id,
executor_run_id=self.identity.executor_run_id,
)
def retry_not_started(self, task_id: str) -> ExecutionTruthRecord:
self._assert_manifest(task_id)
return self.coordinator.retry_not_started(
task_id,
executor_run_id=self.identity.executor_run_id,
)
def claim(self, task_id: str) -> ExecutionTruthRecord:
self._assert_manifest(task_id)
return self.coordinator.claim(
task_id,
executor_run_id=self.identity.executor_run_id,
executor_id=self.identity.executor_id,
)
def start(self, task_id: str) -> ExecutionTruthRecord:
self._assert_manifest(task_id)
return self.coordinator.start(
task_id,
executor_run_id=self.identity.executor_run_id,
executor_id=self.identity.executor_id,
)
def heartbeat(self, task_id: str, *, current_step: int) -> ExecutionTruthRecord:
self._assert_manifest(task_id)
return self.coordinator.heartbeat(
task_id,
executor_run_id=self.identity.executor_run_id,
executor_id=self.identity.executor_id,
current_step=current_step,
)
def succeed(
self,
task_id: str,
*,
evidence: Mapping[str, str],
) -> ExecutionTruthRecord:
self._assert_manifest(task_id)
return self.coordinator.succeed(
task_id,
executor_run_id=self.identity.executor_run_id,
executor_id=self.identity.executor_id,
evidence=evidence,
)
def fail(
self,
task_id: str,
*,
error: Mapping[str, Any],
evidence: Mapping[str, str] | None = None,
) -> ExecutionTruthRecord:
self._assert_manifest(task_id)
return self.coordinator.fail(
task_id,
executor_run_id=self.identity.executor_run_id,
executor_id=self.identity.executor_id,
error=error,
evidence=evidence,
)
class OpenClawExecutorSession(ExecutorLifecycleSession):
def __init__(self, coordinator: ExternalTaskCoordinator, *, executor_id: str, executor_run_id: str) -> None:
super().__init__(
coordinator,
ExecutorIdentity("openclaw", executor_id, executor_run_id),
)
class HermesExecutorSession(ExecutorLifecycleSession):
def __init__(self, coordinator: ExternalTaskCoordinator, *, executor_id: str, executor_run_id: str) -> None:
super().__init__(
coordinator,
ExecutorIdentity("hermes", executor_id, executor_run_id),
)
class CodexExecutorSession(ExecutorLifecycleSession):
def __init__(self, coordinator: ExternalTaskCoordinator, *, executor_id: str, executor_run_id: str) -> None:
super().__init__(
coordinator,
ExecutorIdentity("codex", executor_id, executor_run_id),
)
@@ -0,0 +1,313 @@
"""Provider-neutral worker runtime for AIOS external executors.
Providers implement only step execution and notification delivery. Discovery,
dependency gates, lifecycle truth, heartbeat, evidence validation and durable
notification acknowledgement are identical for every current and future
executor Provider.
"""
from __future__ import annotations
import hashlib
import json
import uuid
from dataclasses import dataclass, field
from typing import Any, Mapping, Protocol, Sequence
from .execution_plan import ExecutionPlanCoordinator, ExecutionStageManifest
from .executor_protocol import (
ExecutorIdentity,
ExecutorLifecycleSession,
ExecutorProviderRegistry,
normalize_executor,
)
from .external_task_control import ExternalTaskCoordinator, NotificationRecord
from .task_execution_registry import ExecutionTruthState
class StepRunner(Protocol):
def run_step(
self,
*,
plan_id: str,
stage: ExecutionStageManifest,
step: str,
step_number: int,
executor_id: str,
executor_run_id: str,
) -> Mapping[str, str]: ...
class NotificationSender(Protocol):
def send(self, notification: NotificationRecord) -> str: ...
@dataclass(frozen=True)
class StageRunResult:
plan_id: str
stage_id: str
task_id: str
executor_type: str
executor_id: str
executor_run_id: str
state: str
evidence: Mapping[str, str] = field(default_factory=dict)
error: Mapping[str, Any] = field(default_factory=dict)
delivered_notifications: Sequence[str] = ()
def as_dict(self) -> dict[str, Any]:
return {
"plan_id": self.plan_id,
"stage_id": self.stage_id,
"task_id": self.task_id,
"executor_type": self.executor_type,
"executor_id": self.executor_id,
"executor_run_id": self.executor_run_id,
"state": self.state,
"evidence": dict(self.evidence),
"error": dict(self.error),
"delivered_notifications": list(self.delivered_notifications),
}
class UnifiedExecutorWorker:
"""Dependency-aware worker shared by every registered Provider."""
def __init__(
self,
*,
plan_coordinator: ExecutionPlanCoordinator,
task_coordinator: ExternalTaskCoordinator,
providers: ExecutorProviderRegistry,
executor_type: str,
executor_id: str,
step_runner: StepRunner,
notification_sender: NotificationSender,
) -> None:
self.plan_coordinator = plan_coordinator
self.task_coordinator = task_coordinator
self.providers = providers
self.executor_type = normalize_executor(executor_type)
self.executor_id = str(executor_id or "").strip()
if not self.executor_id:
raise ValueError("executor_id is required")
self.providers.get(self.executor_type)
self.step_runner = step_runner
self.notification_sender = notification_sender
def _session(self, executor_run_id: str) -> ExecutorLifecycleSession:
return ExecutorLifecycleSession(
self.task_coordinator,
ExecutorIdentity(
executor_type=self.executor_type,
executor_id=self.executor_id,
executor_run_id=executor_run_id,
),
registry=self.providers,
)
def _notification_candidates(self, task_id: str) -> tuple[NotificationRecord, ...]:
"""Return idempotent delivery candidates, including prior failures.
The outbox has a unique ``(task_id, event_type)`` key. Retrying therefore
reuses the same notification ID and cannot create duplicate STARTED or
SUCCEEDED events. A later successful acknowledgement replaces FAILED
with DELIVERED and records the receipt hash.
"""
candidates: dict[str, NotificationRecord] = {}
for status in ("PENDING", "FAILED"):
for item in self.task_coordinator.notifications.list(
task_id=task_id,
status=status,
):
candidates[item.notification_id] = item
return tuple(
candidates[key]
for key in sorted(
candidates,
key=lambda item: (
candidates[item].created_at,
candidates[item].notification_id,
),
)
)
def _deliver_notifications(self, task_id: str) -> tuple[str, ...]:
delivered: list[str] = []
for item in self._notification_candidates(task_id):
try:
receipt = self.notification_sender.send(item)
acknowledged = self.task_coordinator.notifications.acknowledge(
item.notification_id,
receipt_sha256=receipt,
)
delivered.append(acknowledged.event_type)
except Exception as exc:
self.task_coordinator.notifications.fail(
item.notification_id,
error=f"{type(exc).__name__}: {exc}",
)
return tuple(sorted(set(delivered)))
def retry_failed_notifications(self, *, task_id: str) -> tuple[str, ...]:
"""Retry failed owner notifications without changing task truth."""
return self._deliver_notifications(task_id)
def run_ready(
self,
*,
plan_id: str,
max_stages: int = 1,
) -> list[StageRunResult]:
if max_stages < 1:
raise ValueError("max_stages must be positive")
ready = self.plan_coordinator.discover_ready(
plan_id=plan_id,
executor_type=self.executor_type,
)[:max_stages]
return [
self.run_stage(
plan_id=plan_id,
stage_id=item["stage"]["stage_id"],
)
for item in ready
]
def run_stage(
self,
*,
plan_id: str,
stage_id: str,
executor_run_id: str | None = None,
) -> StageRunResult:
plan = self.plan_coordinator.plan_store.get(plan_id)
stage = plan.stage(stage_id)
if stage.executor_type != self.executor_type:
raise PermissionError(
f"stage executor is {stage.executor_type}, not {self.executor_type}"
)
dependency = self.plan_coordinator.dependency_gate(plan_id, stage_id)
if not dependency["passed"]:
raise RuntimeError("stage dependencies are not satisfied")
run_id = executor_run_id or f"{self.executor_type}_run_{uuid.uuid4().hex}"
session = self._session(run_id)
record = self.task_coordinator.registry.get(stage.task_id)
evidence: dict[str, str] = dict(record.evidence)
delivered: list[str] = []
try:
if record.state == ExecutionTruthState.DECLARED:
record = session.register(stage.task_id)
if record.state == ExecutionTruthState.REGISTERED:
record = session.claim(stage.task_id)
if record.state == ExecutionTruthState.CLAIMED:
record = session.start(stage.task_id)
delivered.extend(self._deliver_notifications(stage.task_id))
if record.state != ExecutionTruthState.RUNNING:
raise RuntimeError(
f"stage cannot execute from state {record.state.value}"
)
# Re-running the current step is intentional: Provider steps must be
# idempotent and a crash between side effect and heartbeat cannot be
# safely interpreted as completed work.
start_step = max(1, record.current_step)
for number, step in enumerate(stage.steps, start=1):
if number < start_step:
continue
produced = self.step_runner.run_step(
plan_id=plan_id,
stage=stage,
step=step,
step_number=number,
executor_id=self.executor_id,
executor_run_id=run_id,
)
if not isinstance(produced, Mapping):
raise TypeError("step runner must return an evidence mapping")
evidence.update(
{
str(key).strip(): str(value).strip().lower()
for key, value in produced.items()
}
)
record = session.heartbeat(stage.task_id, current_step=number)
record = session.succeed(stage.task_id, evidence=evidence)
delivered.extend(self._deliver_notifications(stage.task_id))
return StageRunResult(
plan_id=plan_id,
stage_id=stage.stage_id,
task_id=stage.task_id,
executor_type=self.executor_type,
executor_id=self.executor_id,
executor_run_id=run_id,
state=record.state.value,
evidence=dict(record.evidence),
delivered_notifications=tuple(sorted(set(delivered))),
)
except Exception as exc:
current = self.task_coordinator.registry.get(stage.task_id)
if current.state in {
ExecutionTruthState.REGISTERED,
ExecutionTruthState.CLAIMED,
ExecutionTruthState.RUNNING,
}:
current = session.fail(
stage.task_id,
error={
"code": "executor_stage_failed",
"error_type": type(exc).__name__,
"message": str(exc),
},
evidence=evidence or None,
)
delivered.extend(self._deliver_notifications(stage.task_id))
return StageRunResult(
plan_id=plan_id,
stage_id=stage.stage_id,
task_id=stage.task_id,
executor_type=self.executor_type,
executor_id=self.executor_id,
executor_run_id=run_id,
state=current.state.value,
evidence=dict(current.evidence),
error=dict(current.error),
delivered_notifications=tuple(sorted(set(delivered))),
)
class JsonEvidenceStepRunner:
"""Adapter for handlers returning evidence-name to SHA256 mappings."""
def __init__(self, handler) -> None:
self.handler = handler
def run_step(self, **values: Any) -> Mapping[str, str]:
result = self.handler(**values)
if not isinstance(result, Mapping):
raise TypeError("step handler must return a mapping")
return {str(key): str(value) for key, value in result.items()}
class JsonReceiptNotificationSender:
"""Adapter for senders returning a SHA256 or receipt JSON object."""
def __init__(self, sender) -> None:
self.sender = sender
def send(self, notification: NotificationRecord) -> str:
result = self.sender(notification.as_dict())
if isinstance(result, str):
return result
if not isinstance(result, Mapping):
raise TypeError(
"notification sender must return SHA256 or a receipt object"
)
encoded = json.dumps(
dict(result),
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
default=str,
).encode("utf-8")
return hashlib.sha256(encoded).hexdigest()
@@ -0,0 +1,266 @@
#!/usr/bin/env python3
"""CLI for durable external-task truth and notification receipts.
The CLI is suitable for OpenClaw workers, systemd jobs and release gates. It
never infers execution from the presence of a manifest: each lifecycle action
must be recorded explicitly in the shared SQLite registry.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from pathlib import Path
from typing import Any, Mapping, Sequence
from .external_task_control import ExternalTaskCoordinator
from .external_task_query import list_records
def _json_mapping(value: str | None, path: str | None) -> Mapping[str, Any]:
if value and path:
raise ValueError("inline JSON and JSON file are mutually exclusive")
if path:
parsed = json.loads(Path(path).read_text(encoding="utf-8"))
else:
parsed = json.loads(value or "{}")
if not isinstance(parsed, Mapping):
raise ValueError("JSON value must be an object")
return dict(parsed)
def _coordinator(args: argparse.Namespace) -> ExternalTaskCoordinator:
return ExternalTaskCoordinator.from_file(args.store_file)
def _print(value: Any) -> None:
print(json.dumps(value, ensure_ascii=False, sort_keys=True, indent=2, default=str))
def cmd_declare(args: argparse.Namespace) -> int:
record = _coordinator(args).declare_file(args.manifest)
_print(record.as_dict())
return 0
def cmd_discover(args: argparse.Namespace) -> int:
records = list_records(
args.store_file,
executor=args.executor,
states=tuple(args.state or ()),
)
_print({"count": len(records), "items": [item.as_dict() for item in records]})
return 0
def cmd_register(args: argparse.Namespace) -> int:
_print(_coordinator(args).register(args.task_id, executor_run_id=args.executor_run_id).as_dict())
return 0
def cmd_claim(args: argparse.Namespace) -> int:
_print(
_coordinator(args).claim(
args.task_id,
executor_run_id=args.executor_run_id,
executor_id=args.executor_id,
).as_dict()
)
return 0
def cmd_start(args: argparse.Namespace) -> int:
_print(
_coordinator(args).start(
args.task_id,
executor_run_id=args.executor_run_id,
executor_id=args.executor_id,
).as_dict()
)
return 0
def cmd_heartbeat(args: argparse.Namespace) -> int:
_print(
_coordinator(args).heartbeat(
args.task_id,
executor_run_id=args.executor_run_id,
executor_id=args.executor_id,
current_step=args.current_step,
).as_dict()
)
return 0
def cmd_succeed(args: argparse.Namespace) -> int:
evidence = _json_mapping(args.evidence_json, args.evidence_file)
_print(
_coordinator(args).succeed(
args.task_id,
executor_run_id=args.executor_run_id,
executor_id=args.executor_id,
evidence={str(key): str(value) for key, value in evidence.items()},
).as_dict()
)
return 0
def cmd_fail(args: argparse.Namespace) -> int:
error = _json_mapping(args.error_json, args.error_file)
evidence = None
if args.evidence_json or args.evidence_file:
evidence_value = _json_mapping(args.evidence_json, args.evidence_file)
evidence = {str(key): str(value) for key, value in evidence_value.items()}
_print(
_coordinator(args).fail(
args.task_id,
executor_run_id=args.executor_run_id,
executor_id=args.executor_id,
error=error,
evidence=evidence,
).as_dict()
)
return 0
def cmd_status(args: argparse.Namespace) -> int:
_print(_coordinator(args).status(args.task_id))
return 0
def cmd_gate(args: argparse.Namespace) -> int:
value = _coordinator(args).gate(args.task_id)
_print(value)
return 0 if value["passed"] else 3
def cmd_notifications(args: argparse.Namespace) -> int:
items = _coordinator(args).notifications.list(task_id=args.task_id, status=args.status)
_print({"count": len(items), "items": [item.as_dict() for item in items]})
return 0
def cmd_ack(args: argparse.Namespace) -> int:
_print(
_coordinator(args).notifications.acknowledge(
args.notification_id,
receipt_sha256=args.receipt_sha256,
).as_dict()
)
return 0
def common(parser: argparse.ArgumentParser) -> None:
parser.add_argument(
"--store-file",
default=os.getenv(
"AIOS_EXTERNAL_TASK_STORE_FILE",
"/var/lib/aios-v2/external-task-truth.sqlite3",
),
)
def lifecycle(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--task-id", required=True)
parser.add_argument("--executor-run-id", required=True)
parser.add_argument("--executor-id", required=True)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="AIOS external task truth CLI")
commands = parser.add_subparsers(dest="command", required=True)
declare = commands.add_parser("declare")
common(declare)
declare.add_argument("--manifest", required=True)
declare.set_defaults(handler=cmd_declare)
discover = commands.add_parser("discover")
common(discover)
discover.add_argument("--executor", default="openclaw")
discover.add_argument("--state", action="append")
discover.set_defaults(handler=cmd_discover)
register = commands.add_parser("register")
common(register)
register.add_argument("--task-id", required=True)
register.add_argument("--executor-run-id", required=True)
register.set_defaults(handler=cmd_register)
claim = commands.add_parser("claim")
common(claim)
lifecycle(claim)
claim.set_defaults(handler=cmd_claim)
start = commands.add_parser("start")
common(start)
lifecycle(start)
start.set_defaults(handler=cmd_start)
heartbeat = commands.add_parser("heartbeat")
common(heartbeat)
lifecycle(heartbeat)
heartbeat.add_argument("--current-step", type=int, required=True)
heartbeat.set_defaults(handler=cmd_heartbeat)
succeed = commands.add_parser("succeed")
common(succeed)
lifecycle(succeed)
evidence = succeed.add_mutually_exclusive_group(required=True)
evidence.add_argument("--evidence-json")
evidence.add_argument("--evidence-file")
succeed.set_defaults(handler=cmd_succeed)
fail = commands.add_parser("fail")
common(fail)
lifecycle(fail)
error = fail.add_mutually_exclusive_group(required=True)
error.add_argument("--error-json")
error.add_argument("--error-file")
fail_evidence = fail.add_mutually_exclusive_group()
fail_evidence.add_argument("--evidence-json")
fail_evidence.add_argument("--evidence-file")
fail.set_defaults(handler=cmd_fail)
status = commands.add_parser("status")
common(status)
status.add_argument("--task-id", required=True)
status.set_defaults(handler=cmd_status)
gate = commands.add_parser("gate")
common(gate)
gate.add_argument("--task-id", required=True)
gate.set_defaults(handler=cmd_gate)
notifications = commands.add_parser("notifications")
common(notifications)
notifications.add_argument("--task-id")
notifications.add_argument("--status")
notifications.set_defaults(handler=cmd_notifications)
ack = commands.add_parser("ack-notification")
common(ack)
ack.add_argument("--notification-id", required=True)
ack.add_argument("--receipt-sha256", required=True)
ack.set_defaults(handler=cmd_ack)
return parser
def main(argv: Sequence[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
return int(args.handler(args))
except Exception as exc:
print(
json.dumps(
{"status": "error", "error": type(exc).__name__, "message": str(exc)},
ensure_ascii=False,
),
file=sys.stderr,
)
return 1
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,420 @@
"""External executor coordination, notification outbox and release truth gate.
OpenClaw/Codex task declarations are not execution evidence. This module wraps
``SQLiteExecutionTruthRegistry`` with a durable notification outbox. A successful
release gate requires both terminal task evidence and acknowledged STARTED /
SUCCEEDED owner notifications when those events are required by the manifest.
"""
from __future__ import annotations
import hashlib
import json
import re
import sqlite3
import threading
import uuid
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any, Mapping, Sequence
from .task_execution_registry import (
ExecutionTaskManifest,
ExecutionTruthRecord,
ExecutionTruthState,
SQLiteExecutionTruthRegistry,
utc_now,
)
_SHA256 = re.compile(r"^[0-9a-f]{64}$")
def sha256_value(value: str, label: str) -> str:
normalized = str(value or "").strip().lower()
if not _SHA256.fullmatch(normalized):
raise ValueError(f"{label} must be a lowercase SHA256 hex digest")
return normalized
@dataclass(frozen=True)
class NotificationRecord:
notification_id: str
task_id: str
event_type: str
target_ref: str
payload: Mapping[str, Any]
status: str
created_at: str
delivered_at: str | None = None
receipt_sha256: str | None = None
error: str | None = None
def as_dict(self) -> dict[str, Any]:
return asdict(self)
class SQLiteNotificationOutbox:
"""Durable owner-notification delivery and acknowledgement records."""
def __init__(self, path: str | Path) -> None:
self.path = Path(path).resolve()
self.path.parent.mkdir(parents=True, exist_ok=True)
self._lock = threading.RLock()
with self._connect() as connection:
connection.executescript(
"""
CREATE TABLE IF NOT EXISTS external_task_notifications (
notification_id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
event_type TEXT NOT NULL,
target_ref TEXT NOT NULL,
payload_json TEXT NOT NULL,
status TEXT NOT NULL,
created_at TEXT NOT NULL,
delivered_at TEXT,
receipt_sha256 TEXT,
error TEXT,
UNIQUE(task_id, event_type)
);
CREATE INDEX IF NOT EXISTS idx_external_notification_status
ON external_task_notifications(status, created_at);
"""
)
def _connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(str(self.path), timeout=30.0)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA journal_mode=WAL")
connection.execute("PRAGMA synchronous=FULL")
return connection
@staticmethod
def _decode(row: sqlite3.Row) -> NotificationRecord:
return NotificationRecord(
notification_id=row["notification_id"],
task_id=row["task_id"],
event_type=row["event_type"],
target_ref=row["target_ref"],
payload=json.loads(row["payload_json"]),
status=row["status"],
created_at=row["created_at"],
delivered_at=row["delivered_at"],
receipt_sha256=row["receipt_sha256"],
error=row["error"],
)
def enqueue(
self,
*,
task_id: str,
event_type: str,
target_ref: str,
payload: Mapping[str, Any],
) -> NotificationRecord:
now = utc_now()
record = NotificationRecord(
notification_id=f"notify_{uuid.uuid4().hex}",
task_id=str(task_id).strip(),
event_type=str(event_type).strip().upper(),
target_ref=str(target_ref).strip(),
payload=json.loads(json.dumps(dict(payload), ensure_ascii=False, default=str)),
status="PENDING",
created_at=now,
)
if not record.task_id or not record.event_type or not record.target_ref:
raise ValueError("task_id, event_type and target_ref are required")
with self._lock, self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
existing = connection.execute(
"SELECT * FROM external_task_notifications WHERE task_id = ? AND event_type = ?",
(record.task_id, record.event_type),
).fetchone()
if existing is not None:
connection.rollback()
return self._decode(existing)
connection.execute(
"""
INSERT INTO external_task_notifications
(notification_id, task_id, event_type, target_ref, payload_json,
status, created_at, delivered_at, receipt_sha256, error)
VALUES (?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL)
""",
(
record.notification_id,
record.task_id,
record.event_type,
record.target_ref,
json.dumps(record.payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")),
record.status,
record.created_at,
),
)
connection.commit()
return record
def get(self, notification_id: str) -> NotificationRecord:
with self._lock, self._connect() as connection:
row = connection.execute(
"SELECT * FROM external_task_notifications WHERE notification_id = ?",
(str(notification_id).strip(),),
).fetchone()
if row is None:
raise KeyError("notification not found")
return self._decode(row)
def acknowledge(self, notification_id: str, *, receipt_sha256: str) -> NotificationRecord:
digest = sha256_value(receipt_sha256, "receipt_sha256")
with self._lock, self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
row = connection.execute(
"SELECT * FROM external_task_notifications WHERE notification_id = ?",
(str(notification_id).strip(),),
).fetchone()
if row is None:
connection.rollback()
raise KeyError("notification not found")
current = self._decode(row)
if current.status == "DELIVERED":
if current.receipt_sha256 != digest:
connection.rollback()
raise RuntimeError("notification already acknowledged with another receipt")
connection.rollback()
return current
delivered_at = utc_now()
connection.execute(
"""
UPDATE external_task_notifications
SET status = 'DELIVERED', delivered_at = ?, receipt_sha256 = ?, error = NULL
WHERE notification_id = ?
""",
(delivered_at, digest, current.notification_id),
)
connection.commit()
return self.get(current.notification_id)
def fail(self, notification_id: str, *, error: str) -> NotificationRecord:
message = str(error or "").strip()
if not message:
raise ValueError("notification error is required")
with self._lock, self._connect() as connection:
connection.execute(
"UPDATE external_task_notifications SET status = 'FAILED', error = ? WHERE notification_id = ?",
(message, str(notification_id).strip()),
)
connection.commit()
return self.get(notification_id)
def list(
self,
*,
task_id: str | None = None,
status: str | None = None,
) -> list[NotificationRecord]:
conditions: list[str] = []
values: list[str] = []
if task_id:
conditions.append("task_id = ?")
values.append(str(task_id).strip())
if status:
conditions.append("status = ?")
values.append(str(status).strip().upper())
where = f" WHERE {' AND '.join(conditions)}" if conditions else ""
with self._lock, self._connect() as connection:
rows = connection.execute(
f"SELECT * FROM external_task_notifications{where} ORDER BY created_at, notification_id",
tuple(values),
).fetchall()
return [self._decode(row) for row in rows]
class ExternalTaskCoordinator:
"""Single safe lifecycle used by external executors and release gates."""
def __init__(
self,
registry: SQLiteExecutionTruthRegistry,
notifications: SQLiteNotificationOutbox,
) -> None:
self.registry = registry
self.notifications = notifications
@classmethod
def from_file(cls, path: str | Path) -> "ExternalTaskCoordinator":
return cls(
SQLiteExecutionTruthRegistry(path),
SQLiteNotificationOutbox(path),
)
def declare(self, manifest: ExecutionTaskManifest) -> ExecutionTruthRecord:
return self.registry.declare(manifest)
def declare_file(self, path: str | Path) -> ExecutionTruthRecord:
value = json.loads(Path(path).read_text(encoding="utf-8"))
if not isinstance(value, Mapping):
raise ValueError("external task manifest must be a JSON object")
return self.declare(ExecutionTaskManifest.from_dict(value))
def register(self, task_id: str, *, executor_run_id: str) -> ExecutionTruthRecord:
return self.registry.register(task_id, executor_run_id=executor_run_id)
def retry_not_started(
self,
task_id: str,
*,
executor_run_id: str,
) -> ExecutionTruthRecord:
return self.registry.retry_not_started(
task_id,
executor_run_id=executor_run_id,
)
def claim(
self,
task_id: str,
*,
executor_run_id: str,
executor_id: str,
) -> ExecutionTruthRecord:
return self.registry.claim(
task_id,
executor_run_id=executor_run_id,
executor_id=executor_id,
)
def _notify(self, record: ExecutionTruthRecord, event_type: str) -> NotificationRecord | None:
event = event_type.upper()
if event not in set(record.manifest.notification_events):
return None
return self.notifications.enqueue(
task_id=record.task_id,
event_type=event,
target_ref=record.manifest.owner_notification_ref,
payload={
"task_id": record.task_id,
"target_version": record.manifest.target_version,
"executor": record.manifest.executor,
"executor_id": record.executor_id,
"executor_run_id": record.executor_run_id,
"state": record.state.value,
"current_step": record.current_step,
"total_steps": len(record.manifest.steps),
"timestamp": utc_now(),
},
)
def start(
self,
task_id: str,
*,
executor_run_id: str,
executor_id: str,
) -> ExecutionTruthRecord:
record = self.registry.start(
task_id,
executor_run_id=executor_run_id,
executor_id=executor_id,
)
self._notify(record, "STARTED")
return record
def heartbeat(
self,
task_id: str,
*,
executor_run_id: str,
executor_id: str,
current_step: int,
) -> ExecutionTruthRecord:
return self.registry.heartbeat(
task_id,
executor_run_id=executor_run_id,
executor_id=executor_id,
current_step=current_step,
)
def succeed(
self,
task_id: str,
*,
executor_run_id: str,
executor_id: str,
evidence: Mapping[str, str],
) -> ExecutionTruthRecord:
normalized = {
str(key).strip(): sha256_value(value, f"evidence[{key}]")
for key, value in evidence.items()
}
record = self.registry.succeed(
task_id,
executor_run_id=executor_run_id,
executor_id=executor_id,
evidence=normalized,
)
self._notify(record, "SUCCEEDED")
return record
def fail(
self,
task_id: str,
*,
executor_run_id: str,
executor_id: str,
error: Mapping[str, Any],
evidence: Mapping[str, str] | None = None,
) -> ExecutionTruthRecord:
normalized = None
if evidence is not None:
normalized = {
str(key).strip(): sha256_value(value, f"evidence[{key}]")
for key, value in evidence.items()
}
record = self.registry.fail(
task_id,
executor_run_id=executor_run_id,
executor_id=executor_id,
error=error,
evidence=normalized,
)
self._notify(record, "FAILED")
return record
def gate(self, task_id: str) -> Mapping[str, Any]:
before = self.registry.get(task_id)
truth = self.registry.gate(task_id)
after = self.registry.get(task_id)
if after.state != before.state and after.state in {
ExecutionTruthState.NOT_STARTED,
ExecutionTruthState.STALLED,
}:
self._notify(after, after.state.value)
notifications = self.notifications.list(task_id=task_id)
delivered = {item.event_type for item in notifications if item.status == "DELIVERED"}
required_success_notifications = {
item
for item in ("STARTED", "SUCCEEDED")
if item in set(after.manifest.notification_events)
}
notification_complete = required_success_notifications.issubset(delivered)
passed = bool(truth["passed"] and notification_complete)
return {
**dict(truth),
"passed": passed,
"release_blocking": not passed,
"notification_complete": notification_complete,
"required_success_notifications": sorted(required_success_notifications),
"delivered_notifications": sorted(delivered),
"notifications": [item.as_dict() for item in notifications],
}
def status(self, task_id: str) -> Mapping[str, Any]:
gate = self.gate(task_id)
record = self.registry.get(task_id)
return {
"record": record.as_dict(),
"gate": gate,
"events": self.registry.events(task_id),
}
@staticmethod
def content_receipt(value: Mapping[str, Any]) -> str:
encoded = json.dumps(dict(value), ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str).encode("utf-8")
return hashlib.sha256(encoded).hexdigest()
@@ -0,0 +1,51 @@
"""Read models for the durable external-task truth registry."""
from __future__ import annotations
import json
import sqlite3
from pathlib import Path
from typing import Sequence
from .task_execution_registry import ExecutionTruthRecord, ExecutionTruthState
def _decode(value: str) -> ExecutionTruthRecord:
payload = json.loads(value)
payload["state"] = ExecutionTruthState(payload["state"])
payload["evidence"] = dict(payload.get("evidence") or {})
payload["error"] = dict(payload.get("error") or {})
return ExecutionTruthRecord(**payload)
def list_records(
path: str | Path,
*,
executor: str | None = None,
states: Sequence[str] = (),
) -> list[ExecutionTruthRecord]:
database = Path(path).resolve()
if not database.exists():
return []
conditions: list[str] = []
values: list[str] = []
normalized_states = tuple(sorted({ExecutionTruthState(item).value for item in states}))
if normalized_states:
conditions.append(f"state IN ({','.join('?' for _ in normalized_states)})")
values.extend(normalized_states)
where = f" WHERE {' AND '.join(conditions)}" if conditions else ""
connection = sqlite3.connect(f"file:{database}?mode=ro", uri=True, timeout=10.0)
connection.row_factory = sqlite3.Row
try:
rows = connection.execute(
f"SELECT record_json FROM execution_truth{where} ORDER BY updated_at, task_id",
tuple(values),
).fetchall()
except sqlite3.OperationalError:
return []
finally:
connection.close()
records = [_decode(row["record_json"]) for row in rows]
if executor:
normalized = str(executor).strip().lower()
records = [item for item in records if item.manifest.executor.lower() == normalized]
return records
@@ -0,0 +1,238 @@
"""Official AIOS executor providers built on the portable provider protocol.
The official providers contain no lifecycle implementation. They validate their
manifests, resolve a generic ``UnifiedExecutorWorker`` from runtime-supplied
boundaries and expose the same adapter contract. Future providers can use
``ConfiguredExecutorProvider`` directly without changing AIOS core modules.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Mapping, Sequence
from .execution_plan import ExecutionPlanCoordinator, ExecutionStageManifest
from .executor_protocol import (
EXECUTOR_PROTOCOL_VERSION,
ExternalExecutorAdapter,
ExecutorProviderDescriptor,
normalize_executor,
)
from .executor_worker import NotificationSender, StepRunner, UnifiedExecutorWorker
from .external_task_control import ExternalTaskCoordinator
from .task_execution_registry import ExecutionTaskManifest, ExecutionTruthRecord
@dataclass(frozen=True)
class ProviderRuntimeConfiguration:
plan_id: str
executor_id: str
plan_coordinator: ExecutionPlanCoordinator
task_coordinator: ExternalTaskCoordinator
providers: Any
step_runner: StepRunner
notification_sender: NotificationSender
@classmethod
def from_mapping(cls, value: Mapping[str, Any]) -> "ProviderRuntimeConfiguration":
required = (
"plan_id",
"executor_id",
"plan_coordinator",
"task_coordinator",
"providers",
"step_runner",
"notification_sender",
)
missing = [name for name in required if value.get(name) is None]
if missing:
raise ValueError(f"provider runtime configuration is missing: {missing}")
return cls(**{name: value[name] for name in required})
class ConfiguredExecutorAdapter:
"""Generic adapter produced by every official or third-party provider."""
def __init__(
self,
descriptor: ExecutorProviderDescriptor,
configuration: ProviderRuntimeConfiguration,
) -> None:
self.descriptor = descriptor
self.configuration = configuration
self.worker = UnifiedExecutorWorker(
plan_coordinator=configuration.plan_coordinator,
task_coordinator=configuration.task_coordinator,
providers=configuration.providers,
executor_type=descriptor.executor_type,
executor_id=configuration.executor_id,
step_runner=configuration.step_runner,
notification_sender=configuration.notification_sender,
)
def discover(self) -> Sequence[ExecutionTruthRecord]:
ready = self.configuration.plan_coordinator.discover_ready(
plan_id=self.configuration.plan_id,
executor_type=self.descriptor.executor_type,
)
return tuple(item["record"] for item in ready)
def execute(self, manifest: ExecutionTaskManifest) -> Mapping[str, Any]:
if normalize_executor(manifest.executor) != self.descriptor.executor_type:
raise PermissionError("manifest executor does not match adapter provider")
plan = self.configuration.plan_coordinator.plan_store.get(
self.configuration.plan_id
)
stage: ExecutionStageManifest | None = None
for item in plan.stages:
if item.task_id == manifest.task_id:
stage = item
break
if stage is None:
raise KeyError("manifest task is not part of configured execution plan")
return self.worker.run_stage(
plan_id=plan.plan_id,
stage_id=stage.stage_id,
).as_dict()
def notify_owner(self, payload: Mapping[str, Any]) -> str:
# External callers normally use the durable NotificationOutbox. This
# method remains part of the portable adapter contract for providers
# that need to send a standalone operational notice.
from .external_task_control import NotificationRecord
from .task_execution_registry import utc_now
notification = NotificationRecord(
notification_id="provider_direct_notice",
task_id=str(payload.get("task_id") or "provider-notice"),
event_type=str(payload.get("event_type") or "INFO"),
target_ref=str(
payload.get("target_ref") or "AIOS_OWNER_WECHAT_TARGET_REF"
),
payload=dict(payload),
status="PENDING",
created_at=utc_now(),
)
return self.configuration.notification_sender.send(notification)
class ConfiguredExecutorProvider:
"""Reusable provider implementation for current and future executors."""
descriptor: ExecutorProviderDescriptor
def __init__(self, descriptor: ExecutorProviderDescriptor) -> None:
self.descriptor = descriptor
def validate_manifest(self, manifest: ExecutionTaskManifest) -> None:
if normalize_executor(manifest.executor) != self.descriptor.executor_type:
raise ValueError("manifest executor does not match provider")
if manifest.manifest_version not in {
"aios.external-task.v1",
"aios.external-task.v2",
}:
raise ValueError("unsupported external task manifest version")
if not manifest.steps:
raise ValueError("executor manifest has no steps")
def build_adapter(self, configuration: Mapping[str, Any]) -> ExternalExecutorAdapter:
return ConfiguredExecutorAdapter(
self.descriptor,
ProviderRuntimeConfiguration.from_mapping(configuration),
)
class OpenClawProvider(ConfiguredExecutorProvider):
def __init__(self) -> None:
super().__init__(
ExecutorProviderDescriptor(
executor_type="openclaw",
adapter_version="2.1.0",
protocol_version=EXECUTOR_PROTOCOL_VERSION,
trust_roles=("runtime", "openclaw_executor"),
capabilities=(
"deploy",
"http_runtime",
"owner_notification",
"healthcheck",
"rollback",
),
configuration_schema="aios.executor.openclaw.config.v1",
metadata={"official": True},
)
)
class HermesProvider(ConfiguredExecutorProvider):
def __init__(self) -> None:
super().__init__(
ExecutorProviderDescriptor(
executor_type="hermes",
adapter_version="2.1.0",
protocol_version=EXECUTOR_PROTOCOL_VERSION,
trust_roles=("runtime", "hermes_executor"),
capabilities=(
"queue",
"lease",
"worker",
"runtime_validation",
"restart_recovery",
),
configuration_schema="aios.executor.hermes.config.v1",
metadata={"official": True},
)
)
class CodexProvider(ConfiguredExecutorProvider):
def __init__(self) -> None:
super().__init__(
ExecutorProviderDescriptor(
executor_type="codex",
adapter_version="2.1.0",
protocol_version=EXECUTOR_PROTOCOL_VERSION,
trust_roles=("codex", "codex_executor"),
capabilities=(
"code",
"test",
"docs",
"gitops_patch",
"security_review",
),
configuration_schema="aios.executor.codex.config.v1",
metadata={"official": True},
)
)
class ChatGPTSupervisorProvider(ConfiguredExecutorProvider):
"""Explicit takeover provider; it does not inherit Codex permissions."""
def __init__(self) -> None:
super().__init__(
ExecutorProviderDescriptor(
executor_type="chatgpt-supervisor",
adapter_version="2.1.0",
protocol_version=EXECUTOR_PROTOCOL_VERSION,
trust_roles=("chatgpt_supervisor",),
capabilities=(
"acceptance",
"deploy",
"evidence",
"healthcheck",
"rollback",
"wechat_simulation",
),
configuration_schema="aios.executor.chatgpt-supervisor.config.v1",
metadata={"official": True, "production_mutation_allowed": False},
)
)
def official_provider_factories() -> Mapping[str, ConfiguredExecutorProvider]:
providers = (
OpenClawProvider(),
HermesProvider(),
CodexProvider(),
ChatGPTSupervisorProvider(),
)
return {item.descriptor.executor_type: item for item in providers}
@@ -0,0 +1,607 @@
"""Durable execution truth registry for externally executed AIOS tasks.
A task manifest being present in Git is not evidence that an executor discovered
or ran it. This registry separates declaration, registration, claim, start,
heartbeat and terminal evidence. Release gates must require a terminal success
record with immutable evidence hashes.
"""
from __future__ import annotations
import json
import sqlite3
import threading
import uuid
from dataclasses import asdict, dataclass, field, replace
from datetime import datetime, timedelta, timezone
from enum import Enum
from pathlib import Path
from typing import Any, Mapping, Sequence
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
def parse_time(value: str) -> datetime:
normalized = str(value or "").strip()
if normalized.endswith("Z"):
normalized = normalized[:-1] + "+00:00"
parsed = datetime.fromisoformat(normalized)
if parsed.tzinfo is None:
raise ValueError("timestamp requires timezone")
return parsed.astimezone(timezone.utc)
def required(value: str, label: str) -> str:
normalized = str(value or "").strip()
if not normalized:
raise ValueError(f"{label} is required")
return normalized
class ExecutionTruthState(str, Enum):
DECLARED = "DECLARED"
REGISTERED = "REGISTERED"
CLAIMED = "CLAIMED"
RUNNING = "RUNNING"
SUCCEEDED = "SUCCEEDED"
FAILED = "FAILED"
CANCELLED = "CANCELLED"
NOT_STARTED = "NOT_STARTED"
STALLED = "STALLED"
TERMINAL_STATES = frozenset(
{
ExecutionTruthState.SUCCEEDED,
ExecutionTruthState.FAILED,
ExecutionTruthState.CANCELLED,
ExecutionTruthState.NOT_STARTED,
ExecutionTruthState.STALLED,
}
)
@dataclass(frozen=True)
class ExecutionTaskManifest:
task_id: str
executor: str
source_branch: str
source_oid: str
target_version: str
steps: Sequence[str]
start_deadline_seconds: int = 300
heartbeat_timeout_seconds: int = 180
required_evidence: Sequence[str] = ()
notification_events: Sequence[str] = ("STARTED", "SUCCEEDED", "FAILED")
owner_notification_ref: str = "AIOS_OWNER_WECHAT_TARGET_REF"
manifest_version: str = "aios.external-task.v1"
def __post_init__(self) -> None:
for name in (
"task_id",
"executor",
"source_branch",
"source_oid",
"target_version",
"manifest_version",
"owner_notification_ref",
):
object.__setattr__(self, name, required(getattr(self, name), name))
steps = tuple(required(item, "step") for item in self.steps)
if not steps:
raise ValueError("at least one execution step is required")
if self.start_deadline_seconds < 1 or self.heartbeat_timeout_seconds < 1:
raise ValueError("execution deadlines must be positive")
object.__setattr__(self, "steps", steps)
object.__setattr__(
self,
"required_evidence",
tuple(sorted({required(item, "required_evidence") for item in self.required_evidence})),
)
object.__setattr__(
self,
"notification_events",
tuple(sorted({required(item, "notification_event") for item in self.notification_events})),
)
def as_dict(self) -> dict[str, Any]:
value = asdict(self)
value["steps"] = list(self.steps)
value["required_evidence"] = list(self.required_evidence)
value["notification_events"] = list(self.notification_events)
return value
@classmethod
def from_dict(cls, value: Mapping[str, Any]) -> "ExecutionTaskManifest":
return cls(**dict(value))
@dataclass(frozen=True)
class ExecutionTruthRecord:
task_id: str
manifest_json: str
state: ExecutionTruthState
executor_run_id: str | None = None
executor_id: str | None = None
declared_at: str = field(default_factory=utc_now)
registered_at: str | None = None
claimed_at: str | None = None
started_at: str | None = None
heartbeat_at: str | None = None
completed_at: str | None = None
current_step: int = 0
evidence: Mapping[str, str] = field(default_factory=dict)
error: Mapping[str, Any] = field(default_factory=dict)
revision: int = 1
@property
def manifest(self) -> ExecutionTaskManifest:
return ExecutionTaskManifest.from_dict(json.loads(self.manifest_json))
def as_dict(self) -> dict[str, Any]:
return {
**asdict(self),
"state": self.state.value,
"manifest": self.manifest.as_dict(),
"evidence": dict(self.evidence),
"error": dict(self.error),
}
class ExecutionTruthConflict(RuntimeError):
pass
class SQLiteExecutionTruthRegistry:
"""Persistent registration and run-evidence store."""
def __init__(self, path: str | Path) -> None:
self.path = Path(path).resolve()
self.path.parent.mkdir(parents=True, exist_ok=True)
self._lock = threading.RLock()
with self._connect() as connection:
connection.executescript(
"""
CREATE TABLE IF NOT EXISTS execution_truth (
task_id TEXT PRIMARY KEY,
manifest_json TEXT NOT NULL,
state TEXT NOT NULL,
record_json TEXT NOT NULL,
revision INTEGER NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS execution_truth_events (
event_id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
event_type TEXT NOT NULL,
details_json TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY(task_id) REFERENCES execution_truth(task_id)
);
CREATE INDEX IF NOT EXISTS idx_execution_truth_state
ON execution_truth(state, updated_at);
"""
)
def _connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(str(self.path), timeout=30.0)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA journal_mode=WAL")
connection.execute("PRAGMA synchronous=FULL")
connection.execute("PRAGMA foreign_keys=ON")
return connection
@staticmethod
def _manifest_json(manifest: ExecutionTaskManifest) -> str:
return json.dumps(manifest.as_dict(), ensure_ascii=False, sort_keys=True, separators=(",", ":"))
@staticmethod
def _record_json(record: ExecutionTruthRecord) -> str:
value = record.as_dict()
value.pop("manifest", None)
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str)
@staticmethod
def _decode(row: sqlite3.Row) -> ExecutionTruthRecord:
value = json.loads(row["record_json"])
value["state"] = ExecutionTruthState(value["state"])
value["evidence"] = dict(value.get("evidence") or {})
value["error"] = dict(value.get("error") or {})
return ExecutionTruthRecord(**value)
def _event(
self,
connection: sqlite3.Connection,
task_id: str,
event_type: str,
details: Mapping[str, Any] | None = None,
) -> None:
connection.execute(
"INSERT INTO execution_truth_events VALUES (?, ?, ?, ?, ?)",
(
f"truth_evt_{uuid.uuid4().hex}",
task_id,
event_type,
json.dumps(dict(details or {}), ensure_ascii=False, sort_keys=True, default=str),
utc_now(),
),
)
def declare(self, manifest: ExecutionTaskManifest) -> ExecutionTruthRecord:
manifest_json = self._manifest_json(manifest)
record = ExecutionTruthRecord(
task_id=manifest.task_id,
manifest_json=manifest_json,
state=ExecutionTruthState.DECLARED,
)
with self._lock, self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
existing = connection.execute(
"SELECT record_json FROM execution_truth WHERE task_id = ?",
(manifest.task_id,),
).fetchone()
if existing is not None:
current = self._decode(existing)
if current.manifest_json != manifest_json:
previous_manifest = current.manifest.as_dict()
replacement_manifest = manifest.as_dict()
previous_oid = previous_manifest.pop("source_oid")
replacement_oid = replacement_manifest.pop("source_oid")
if (
current.state != ExecutionTruthState.NOT_STARTED
or previous_manifest != replacement_manifest
):
connection.rollback()
raise ExecutionTruthConflict(
"task_id already declared with another manifest"
)
now = utc_now()
updated = replace(
current,
manifest_json=manifest_json,
revision=current.revision + 1,
)
connection.execute(
"UPDATE execution_truth SET manifest_json = ?, record_json = ?, revision = ?, updated_at = ? WHERE task_id = ?",
(
manifest_json,
self._record_json(updated),
updated.revision,
now,
updated.task_id,
),
)
self._event(
connection,
updated.task_id,
"MANIFEST_SUPERSEDED",
{
"previous_source_oid": previous_oid,
"source_oid": replacement_oid,
"state": updated.state.value,
},
)
connection.commit()
return updated
connection.rollback()
return current
connection.execute(
"INSERT INTO execution_truth VALUES (?, ?, ?, ?, ?, ?)",
(
record.task_id,
manifest_json,
record.state.value,
self._record_json(record),
record.revision,
record.declared_at,
),
)
self._event(connection, record.task_id, "DECLARED", {"source_oid": manifest.source_oid})
connection.commit()
return record
def get(self, task_id: str) -> ExecutionTruthRecord:
with self._lock, self._connect() as connection:
row = connection.execute(
"SELECT record_json FROM execution_truth WHERE task_id = ?",
(required(task_id, "task_id"),),
).fetchone()
if row is None:
raise KeyError("execution task not declared")
return self._decode(row)
def _transition(
self,
task_id: str,
*,
allowed: Sequence[ExecutionTruthState],
target: ExecutionTruthState,
executor_run_id: str | None = None,
executor_id: str | None = None,
current_step: int | None = None,
evidence: Mapping[str, str] | None = None,
error: Mapping[str, Any] | None = None,
) -> ExecutionTruthRecord:
with self._lock, self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
row = connection.execute(
"SELECT record_json FROM execution_truth WHERE task_id = ?",
(required(task_id, "task_id"),),
).fetchone()
if row is None:
connection.rollback()
raise KeyError("execution task not declared")
current = self._decode(row)
if current.state not in set(allowed):
connection.rollback()
raise ExecutionTruthConflict(
f"cannot transition {current.state.value} to {target.value}"
)
now = utc_now()
updates: dict[str, Any] = {
"state": target,
"revision": current.revision + 1,
}
if executor_run_id is not None:
if current.executor_run_id and current.executor_run_id != executor_run_id:
connection.rollback()
raise ExecutionTruthConflict("executor_run_id mismatch")
updates["executor_run_id"] = required(executor_run_id, "executor_run_id")
if executor_id is not None:
if current.executor_id and current.executor_id != executor_id:
connection.rollback()
raise ExecutionTruthConflict("executor_id mismatch")
updates["executor_id"] = required(executor_id, "executor_id")
if target == ExecutionTruthState.REGISTERED:
updates["registered_at"] = now
if target == ExecutionTruthState.CLAIMED:
updates["claimed_at"] = now
updates["heartbeat_at"] = now
if target == ExecutionTruthState.RUNNING:
updates["started_at"] = current.started_at or now
updates["heartbeat_at"] = now
if current_step is not None:
if current_step < current.current_step or current_step > len(current.manifest.steps):
connection.rollback()
raise ExecutionTruthConflict("invalid task step progression")
updates["current_step"] = current_step
updates["heartbeat_at"] = now
if evidence is not None:
updates["evidence"] = {
**dict(current.evidence),
**{required(key, "evidence key"): required(value, "evidence hash") for key, value in evidence.items()},
}
if error is not None:
updates["error"] = json.loads(json.dumps(dict(error), default=str))
if target in TERMINAL_STATES:
updates["completed_at"] = now
updated = replace(current, **updates)
connection.execute(
"UPDATE execution_truth SET state = ?, record_json = ?, revision = ?, updated_at = ? WHERE task_id = ?",
(
updated.state.value,
self._record_json(updated),
updated.revision,
now,
updated.task_id,
),
)
self._event(
connection,
updated.task_id,
target.value,
{
"executor_run_id": updated.executor_run_id,
"executor_id": updated.executor_id,
"current_step": updated.current_step,
},
)
connection.commit()
return updated
def register(self, task_id: str, *, executor_run_id: str) -> ExecutionTruthRecord:
return self._transition(
task_id,
allowed=(ExecutionTruthState.DECLARED,),
target=ExecutionTruthState.REGISTERED,
executor_run_id=executor_run_id,
)
def retry_not_started(
self,
task_id: str,
*,
executor_run_id: str,
) -> ExecutionTruthRecord:
"""Start a new audited attempt after the declaration deadline expired."""
with self._lock, self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
row = connection.execute(
"SELECT record_json FROM execution_truth WHERE task_id = ?",
(required(task_id, "task_id"),),
).fetchone()
if row is None:
connection.rollback()
raise KeyError("execution task not declared")
current = self._decode(row)
if current.state != ExecutionTruthState.NOT_STARTED:
connection.rollback()
raise ExecutionTruthConflict(
f"cannot retry task from {current.state.value}"
)
now = utc_now()
updated = replace(
current,
state=ExecutionTruthState.REGISTERED,
executor_run_id=required(executor_run_id, "executor_run_id"),
executor_id=None,
registered_at=now,
claimed_at=None,
started_at=None,
heartbeat_at=None,
completed_at=None,
current_step=0,
evidence={},
error={},
revision=current.revision + 1,
)
connection.execute(
"UPDATE execution_truth SET state = ?, record_json = ?, revision = ?, updated_at = ? WHERE task_id = ?",
(
updated.state.value,
self._record_json(updated),
updated.revision,
now,
updated.task_id,
),
)
self._event(
connection,
updated.task_id,
"RETRIED",
{
"previous_state": current.state.value,
"previous_executor_run_id": current.executor_run_id,
"executor_run_id": updated.executor_run_id,
"previous_error": dict(current.error),
},
)
connection.commit()
return updated
def claim(self, task_id: str, *, executor_run_id: str, executor_id: str) -> ExecutionTruthRecord:
return self._transition(
task_id,
allowed=(ExecutionTruthState.REGISTERED,),
target=ExecutionTruthState.CLAIMED,
executor_run_id=executor_run_id,
executor_id=executor_id,
)
def start(self, task_id: str, *, executor_run_id: str, executor_id: str) -> ExecutionTruthRecord:
return self._transition(
task_id,
allowed=(ExecutionTruthState.CLAIMED,),
target=ExecutionTruthState.RUNNING,
executor_run_id=executor_run_id,
executor_id=executor_id,
current_step=1,
)
def heartbeat(
self,
task_id: str,
*,
executor_run_id: str,
executor_id: str,
current_step: int,
) -> ExecutionTruthRecord:
return self._transition(
task_id,
allowed=(ExecutionTruthState.RUNNING,),
target=ExecutionTruthState.RUNNING,
executor_run_id=executor_run_id,
executor_id=executor_id,
current_step=current_step,
)
def succeed(
self,
task_id: str,
*,
executor_run_id: str,
executor_id: str,
evidence: Mapping[str, str],
) -> ExecutionTruthRecord:
current = self.get(task_id)
missing = sorted(set(current.manifest.required_evidence) - set(evidence))
if missing:
raise ExecutionTruthConflict(f"missing required evidence: {missing}")
if current.current_step != len(current.manifest.steps):
raise ExecutionTruthConflict("all declared steps must complete before success")
return self._transition(
task_id,
allowed=(ExecutionTruthState.RUNNING,),
target=ExecutionTruthState.SUCCEEDED,
executor_run_id=executor_run_id,
executor_id=executor_id,
evidence=evidence,
)
def fail(
self,
task_id: str,
*,
executor_run_id: str,
executor_id: str,
error: Mapping[str, Any],
evidence: Mapping[str, str] | None = None,
) -> ExecutionTruthRecord:
return self._transition(
task_id,
allowed=(ExecutionTruthState.REGISTERED, ExecutionTruthState.CLAIMED, ExecutionTruthState.RUNNING),
target=ExecutionTruthState.FAILED,
executor_run_id=executor_run_id,
executor_id=executor_id,
evidence=evidence,
error=error,
)
def evaluate_deadlines(self, task_id: str, *, now: datetime | None = None) -> ExecutionTruthRecord:
current = self.get(task_id)
current_time = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
manifest = current.manifest
if current.state in TERMINAL_STATES:
return current
declared = parse_time(current.declared_at)
if current.state in {ExecutionTruthState.DECLARED, ExecutionTruthState.REGISTERED}:
if current_time >= declared + timedelta(seconds=manifest.start_deadline_seconds):
return self._transition(
task_id,
allowed=(current.state,),
target=ExecutionTruthState.NOT_STARTED,
error={"code": "start_deadline_exceeded"},
)
if current.state in {ExecutionTruthState.CLAIMED, ExecutionTruthState.RUNNING}:
heartbeat = parse_time(current.heartbeat_at or current.claimed_at or current.declared_at)
if current_time >= heartbeat + timedelta(seconds=manifest.heartbeat_timeout_seconds):
return self._transition(
task_id,
allowed=(current.state,),
target=ExecutionTruthState.STALLED,
error={"code": "heartbeat_timeout"},
)
return current
def gate(self, task_id: str) -> Mapping[str, Any]:
record = self.evaluate_deadlines(task_id)
passed = record.state == ExecutionTruthState.SUCCEEDED
return {
"task_id": record.task_id,
"state": record.state.value,
"passed": passed,
"release_blocking": not passed,
"executor_run_id": record.executor_run_id,
"executor_id": record.executor_id,
"current_step": record.current_step,
"total_steps": len(record.manifest.steps),
"evidence": dict(record.evidence),
"error": dict(record.error),
}
def events(self, task_id: str) -> list[Mapping[str, Any]]:
with self._lock, self._connect() as connection:
rows = connection.execute(
"SELECT event_id, event_type, details_json, created_at FROM execution_truth_events WHERE task_id = ? ORDER BY created_at, event_id",
(required(task_id, "task_id"),),
).fetchall()
return [
{
"event_id": row["event_id"],
"event_type": row["event_type"],
"details": json.loads(row["details_json"]),
"created_at": row["created_at"],
}
for row in rows
]
+13
View File
@@ -0,0 +1,13 @@
"""AIOS unified, role-aware web portal."""
from .application import UnifiedPortalApplication, build_application
from .capabilities import ROLE_CAPABILITIES, allows, require, resolve
__all__ = [
"ROLE_CAPABILITIES",
"UnifiedPortalApplication",
"allows",
"build_application",
"require",
"resolve",
]
+512
View File
@@ -0,0 +1,512 @@
"""Unified, role-aware AIOS web application.
The portal wraps the production CandidateApplication instead of replacing its
runtime/control-plane routes. Portal endpoints aggregate existing authoritative
stores and require server-side capabilities before delegating any state change.
"""
from __future__ import annotations
import dataclasses
import json
import os
import re
import sqlite3
import urllib.parse
from enum import Enum
from http import HTTPStatus
from pathlib import Path
from typing import Any, Mapping
from aios_v2.app import (
CandidateApplication,
CandidateHTTPServer,
build_application as build_candidate_application,
)
from aios_v2.control_plane import RequestContext, TaskState
from aios_v2.gitops_status import collect_gitops_status
from aios_v2.provenance import SQLiteProvenanceStore
from aios_v2.provenance_grants import SQLiteGrantStore
from .capabilities import allows, require, resolve
def _json_value(value: Any) -> Any:
if dataclasses.is_dataclass(value):
return {
field.name: _json_value(getattr(value, field.name))
for field in dataclasses.fields(value)
}
if isinstance(value, Enum):
return value.value
if isinstance(value, Mapping):
return {str(key): _json_value(item) for key, item in value.items()}
if isinstance(value, (list, tuple, set, frozenset)):
return [_json_value(item) for item in value]
return value
def _mapping(value: Any) -> Mapping[str, Any]:
if not isinstance(value, Mapping):
raise ValueError("JSON body must be an object")
return value
def _required(value: Any, label: str) -> str:
normalized = str(value or "").strip()
if not normalized:
raise ValueError(f"{label} is required")
return normalized
def _safe_count(items: list[Any]) -> int:
return len(items)
class AuthorizationCatalog:
"""Tenant-filtered read/revoke access to existing Provenance stores."""
def __init__(self, store_file: str | Path) -> None:
self.store_file = Path(store_file).resolve()
def _read_connection(self) -> sqlite3.Connection | None:
if not self.store_file.exists():
return None
connection = sqlite3.connect(
f"file:{self.store_file}?mode=ro",
uri=True,
timeout=10.0,
)
connection.row_factory = sqlite3.Row
return connection
def snapshot(self, *, tenant_id: str) -> Mapping[str, Any]:
connection = self._read_connection()
if connection is None:
return {
"configured": False,
"workflows": [],
"grants": [],
"summary": {"workflows": 0, "grants": 0, "active_grants": 0},
}
try:
workflows: list[dict[str, Any]] = []
grants: list[dict[str, Any]] = []
tables = {
row["name"]
for row in connection.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
).fetchall()
}
if "provenance_records" in tables:
rows = connection.execute(
"""
SELECT r.payload_json, r.status, r.signature_verified,
r.created_at, r.updated_at,
COUNT(u.authorization_id) AS usage_count
FROM provenance_records r
LEFT JOIN provenance_usage u
ON u.provenance_id = r.provenance_id
GROUP BY r.provenance_id
ORDER BY r.updated_at DESC, r.provenance_id
"""
).fetchall()
for row in rows:
payload = json.loads(row["payload_json"])
if payload.get("tenant_id") != tenant_id:
continue
workflows.append(
{
**payload,
"status": row["status"],
"signature_verified": bool(row["signature_verified"]),
"usage_count": int(row["usage_count"]),
"created_at": row["created_at"],
"updated_at": row["updated_at"],
}
)
if "provenance_grants" in tables:
rows = connection.execute(
"""
SELECT g.payload_json, g.status, g.signature_verified,
g.created_at, g.updated_at,
COUNT(u.authorization_id) AS usage_count
FROM provenance_grants g
LEFT JOIN provenance_grant_usage u
ON u.grant_id = g.grant_id
GROUP BY g.grant_id
ORDER BY g.updated_at DESC, g.grant_id
"""
).fetchall()
for row in rows:
payload = json.loads(row["payload_json"])
if payload.get("tenant_id") != tenant_id:
continue
grants.append(
{
**payload,
"status": row["status"],
"signature_verified": bool(row["signature_verified"]),
"usage_count": int(row["usage_count"]),
"created_at": row["created_at"],
"updated_at": row["updated_at"],
}
)
active = sum(
1
for item in grants
if item.get("status") not in {"revoked", "expired"}
)
return {
"configured": True,
"workflows": workflows,
"grants": grants,
"summary": {
"workflows": len(workflows),
"grants": len(grants),
"active_grants": active,
"revoked_grants": sum(
1 for item in grants if item.get("status") == "revoked"
),
},
}
finally:
connection.close()
def revoke_workflow(
self,
*,
provenance_id: str,
tenant_id: str,
principal_id: str,
reason: str,
) -> Mapping[str, Any]:
store = SQLiteProvenanceStore(self.store_file)
try:
record = store.get(provenance_id)
if record.tenant_id != tenant_id:
raise KeyError("workflow provenance not found")
store.revoke(
record.provenance_id,
reason=reason,
principal_id=principal_id,
)
return {
"provenance_id": record.provenance_id,
"status": store.status(record.provenance_id).value,
}
finally:
store.close()
def revoke_grant(
self,
*,
grant_id: str,
tenant_id: str,
principal_id: str,
reason: str,
) -> Mapping[str, Any]:
store = SQLiteGrantStore(self.store_file)
try:
grant = store.get(grant_id)
if grant.tenant_id != tenant_id:
raise KeyError("provenance grant not found")
store.revoke(
grant.grant_id,
reason=reason,
principal_id=principal_id,
)
return {
"grant_id": grant.grant_id,
"status": store.status(grant.grant_id).value,
}
finally:
store.close()
class UnifiedPortalApplication:
"""Role-aware portal facade over the existing production application."""
def __init__(
self,
base: CandidateApplication,
*,
provenance_store_file: str | Path,
) -> None:
self.base = base
self.gateway = base.gateway
self.orchestrator = base.orchestrator
self.tracker = base.tracker
self.hermes = base.hermes
self.openclaw = base.openclaw
self.authorization_catalog = AuthorizationCatalog(provenance_store_file)
def context(self, headers: Mapping[str, str]) -> RequestContext:
return self.base.context(headers)
def _runtime(self, headers: Mapping[str, str]) -> Mapping[str, Any]:
status, payload = self.base.handle_json("GET", "/api/v2/runtime", headers, {})
if int(status) != HTTPStatus.OK:
raise RuntimeError("runtime status is unavailable")
return payload
def _gitops(self, context: RequestContext) -> Mapping[str, Any]:
return {
"tenant_id": context.tenant_id,
"gitops": collect_gitops_status().as_dict(),
}
def _tasks_view(self, context: RequestContext) -> list[Mapping[str, Any]]:
service = self.orchestrator.control_plane
tasks = service.list_tasks(context)
dispatches = {
item.task_id: item
for item in self.orchestrator.dispatch_repository.list(
tenant_id=context.tenant_id
)
}
evidence_by_task: dict[str, list[Any]] = {}
for evidence in self.tracker.list(tenant_id=context.tenant_id):
evidence_by_task.setdefault(evidence.task_id, []).append(evidence)
audit_by_task: dict[str, list[Any]] = {}
for event in service.list_audit_events(context):
if event.resource_type == "task":
audit_by_task.setdefault(event.resource_id, []).append(event)
capabilities = set(resolve(context).capabilities)
result: list[Mapping[str, Any]] = []
for task in tasks:
actions: list[str] = []
if task.state == TaskState.CREATED and "task.approve" in capabilities:
actions.append("approve")
if task.state in {TaskState.CREATED, TaskState.APPROVED, TaskState.RUNNING} and "task.cancel" in capabilities:
actions.append("cancel")
if task.state == TaskState.APPROVED and "task.dispatch" in capabilities:
actions.append("dispatch")
result.append(
{
"task": _json_value(task),
"dispatch": _json_value(dispatches.get(task.task_id)),
"evidence": _json_value(evidence_by_task.get(task.task_id, [])),
"audit": _json_value(audit_by_task.get(task.task_id, [])[-12:]),
"available_actions": actions,
}
)
return result
def _overview(
self,
context: RequestContext,
headers: Mapping[str, str],
) -> Mapping[str, Any]:
service = self.orchestrator.control_plane
capabilities = resolve(context)
sections: dict[str, Any] = {}
tasks = service.list_tasks(context) if allows(context, "task.read") else []
dispatches = (
self.orchestrator.dispatch_repository.list(tenant_id=context.tenant_id)
if allows(context, "dispatch.read")
else []
)
evidence = (
self.tracker.list(tenant_id=context.tenant_id)
if allows(context, "evidence.read")
else []
)
audit = (
service.list_audit_events(context)
if allows(context, "audit.read")
else []
)
if allows(context, "runtime.read"):
sections["runtime"] = self._runtime(headers)
if allows(context, "release.read"):
sections["release"] = {
**self._gitops(context),
"version": os.getenv("AIOS_RELEASE_VERSION", "V2R1C1"),
"release_oid": os.getenv("AIOS_RELEASE_OID", "unknown"),
"baseline": os.getenv("AIOS_BASELINE_VERSION", "V2R1C1"),
"status": os.getenv("AIOS_RELEASE_STATUS", "PRODUCTION_DEPLOYED"),
}
if allows(context, "provenance.read"):
sections["authorization"] = self.authorization_catalog.snapshot(
tenant_id=context.tenant_id
)["summary"]
task_states: dict[str, int] = {}
for task in tasks:
task_states[task.state.value] = task_states.get(task.state.value, 0) + 1
return {
"identity": capabilities.as_dict(),
"summary": {
"tasks": _safe_count(tasks),
"task_states": task_states,
"dispatches": _safe_count(dispatches),
"active_dispatches": sum(
1
for item in dispatches
if item.state.value not in {"COMPLETED", "FAILED", "CANCELLED"}
),
"evidence": _safe_count(evidence),
"audit_events": _safe_count(audit),
},
"sections": sections,
"recent": {
"tasks": _json_value(tasks[:8]),
"dispatches": _json_value(dispatches[-8:]),
"audit": _json_value(audit[-12:]),
},
}
def handle_json(
self,
method: str,
raw_path: str,
headers: Mapping[str, str],
body: Any,
) -> tuple[int, Mapping[str, Any]]:
parsed = urllib.parse.urlparse(raw_path)
path = parsed.path.rstrip("/") or "/"
method = method.upper()
if not path.startswith("/api/v2/portal") and path != "/api/v2/me/capabilities":
return self.base.handle_json(method, raw_path, headers, body)
context = self.context(headers)
payload = _mapping(body or {})
if method == "GET" and path == "/api/v2/me/capabilities":
require(context, "portal.access")
return HTTPStatus.OK, resolve(context).as_dict()
if method == "GET" and path == "/api/v2/portal/overview":
require(context, "dashboard.read")
return HTTPStatus.OK, self._overview(context, headers)
if method == "GET" and path == "/api/v2/portal/tasks":
require(context, "task.read")
return HTTPStatus.OK, {"items": self._tasks_view(context)}
if method == "GET" and path == "/api/v2/portal/authorizations":
require(context, "provenance.read")
return HTTPStatus.OK, self.authorization_catalog.snapshot(
tenant_id=context.tenant_id
)
if method == "GET" and path == "/api/v2/portal/runtime":
require(context, "runtime.read")
return HTTPStatus.OK, {
**self._runtime(headers),
"dispatches": _json_value(
self.orchestrator.dispatch_repository.list(
tenant_id=context.tenant_id
)
),
"hermes_jobs": _json_value(
self.hermes.list(tenant_id=context.tenant_id)
),
}
if method == "GET" and path == "/api/v2/portal/evidence":
require(context, "evidence.read")
return HTTPStatus.OK, {
"executions": _json_value(
self.tracker.list(tenant_id=context.tenant_id)
)
}
if method == "GET" and path == "/api/v2/portal/audit":
require(context, "audit.read")
return HTTPStatus.OK, {
"events": _json_value(
self.orchestrator.control_plane.list_audit_events(context)
)
}
if method == "GET" and path == "/api/v2/portal/release":
require(context, "release.read")
return HTTPStatus.OK, {
**self._gitops(context),
"version": os.getenv("AIOS_RELEASE_VERSION", "V2R1C1"),
"release_oid": os.getenv("AIOS_RELEASE_OID", "unknown"),
"baseline": os.getenv("AIOS_BASELINE_VERSION", "V2R1C1"),
"status": os.getenv("AIOS_RELEASE_STATUS", "PRODUCTION_DEPLOYED"),
"operations": (
["review_release", "request_tag", "request_rollback"]
if allows(context, "release.operate")
else []
),
"note": "Release operations remain GitOps approval requests; the portal does not mutate tags or production directly.",
}
if method == "POST" and path == "/api/v2/portal/tasks":
require(context, "task.create")
return self.base.handle_json("POST", "/api/v2/tasks", headers, payload)
task_action = re.fullmatch(
r"/api/v2/portal/tasks/([^/]+)/(approve|cancel|dispatch)", path
)
if method == "POST" and task_action:
action = task_action.group(2)
require(context, f"task.{action}")
task_id = urllib.parse.unquote(task_action.group(1))
target = (
f"/api/v2/tasks/{task_id}/dispatch"
if action == "dispatch"
else f"/api/v2/tasks/{task_id}/{action}"
)
return self.base.handle_json("POST", target, headers, payload)
workflow_revoke = re.fullmatch(
r"/api/v2/portal/provenance/([^/]+)/revoke", path
)
if method == "POST" and workflow_revoke:
require(context, "provenance.revoke")
reason = _required(payload.get("reason"), "reason")
result = self.authorization_catalog.revoke_workflow(
provenance_id=urllib.parse.unquote(workflow_revoke.group(1)),
tenant_id=context.tenant_id,
principal_id=context.principal_id,
reason=reason,
)
return HTTPStatus.OK, {"workflow": result}
grant_revoke = re.fullmatch(r"/api/v2/portal/grants/([^/]+)/revoke", path)
if method == "POST" and grant_revoke:
require(context, "provenance.revoke")
reason = _required(payload.get("reason"), "reason")
result = self.authorization_catalog.revoke_grant(
grant_id=urllib.parse.unquote(grant_revoke.group(1)),
tenant_id=context.tenant_id,
principal_id=context.principal_id,
reason=reason,
)
return HTTPStatus.OK, {"grant": result}
return HTTPStatus.NOT_FOUND, {
"error": "not_found",
"message": "portal route not found",
}
def build_application() -> UnifiedPortalApplication:
state_dir = Path(os.getenv("AIOS_V2_STATE_DIR", "/var/lib/aios-v2")).resolve()
provenance_file = Path(
os.getenv(
"AIOS_PROVENANCE_STORE_FILE",
str(state_dir / "run-provenance.sqlite3"),
)
)
return UnifiedPortalApplication(
build_candidate_application(),
provenance_store_file=provenance_file,
)
def main() -> None:
host = os.getenv("AIOS_V2_HOST", "127.0.0.1")
port = int(os.getenv("AIOS_V2_PORT", "18920"))
server = CandidateHTTPServer((host, port), build_application())
print(f"AIOS V2 unified portal listening on {host}:{port}")
server.serve_forever()
if __name__ == "__main__":
main()
+111
View File
@@ -0,0 +1,111 @@
"""Server-side role and capability resolution for the unified AIOS portal."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Mapping, Sequence
from aios_v2.control_plane import RequestContext, Unauthorized
ROLE_CAPABILITIES: Mapping[str, frozenset[str]] = {
"platform_admin": frozenset({
"portal.access", "dashboard.read", "tenant.read", "tenant.manage",
"agent.read", "agent.manage", "task.read", "task.create", "task.approve",
"task.cancel", "task.dispatch", "policy.read", "audit.read", "evidence.read",
"runtime.read", "runtime.operate", "dispatch.read", "provenance.read",
"provenance.revoke", "external_task.read", "gitops.read", "release.read",
"release.operate", "worktree.read", "system.admin",
}),
"tenant_admin": frozenset({
"portal.access", "dashboard.read", "tenant.read", "agent.read", "agent.manage",
"task.read", "task.create", "task.approve", "task.cancel", "task.dispatch",
"policy.read", "audit.read", "evidence.read", "runtime.read", "dispatch.read",
"provenance.read", "provenance.revoke", "external_task.read", "gitops.read",
"release.read",
}),
"operator": frozenset({
"portal.access", "dashboard.read", "tenant.read", "agent.read", "task.read",
"task.create", "task.cancel", "task.dispatch", "runtime.read", "dispatch.read",
"evidence.read", "provenance.read", "external_task.read", "gitops.read",
"release.read",
}),
"approver": frozenset({
"portal.access", "dashboard.read", "tenant.read", "task.read", "task.approve",
"task.cancel", "policy.read", "audit.read", "provenance.read", "evidence.read",
"external_task.read", "release.read",
}),
"auditor": frozenset({
"portal.access", "dashboard.read", "tenant.read", "agent.read", "task.read",
"audit.read", "evidence.read", "runtime.read", "dispatch.read",
"provenance.read", "external_task.read", "gitops.read", "release.read",
"worktree.read",
}),
"runtime": frozenset({
"portal.access", "dashboard.read", "task.read", "runtime.read", "runtime.operate",
"dispatch.read", "evidence.read", "provenance.read", "external_task.read",
}),
"release_manager": frozenset({
"portal.access", "dashboard.read", "audit.read", "runtime.read", "dispatch.read",
"provenance.read", "external_task.read", "gitops.read", "release.read",
"release.operate", "worktree.read",
}),
"viewer": frozenset({
"portal.access", "dashboard.read", "task.read", "runtime.read",
"external_task.read",
}),
}
NAVIGATION: Sequence[Mapping[str, str]] = (
{"id": "overview", "label": "总览", "capability": "dashboard.read"},
{"id": "resources", "label": "租户与 Agent", "capability": "tenant.read"},
{"id": "tasks", "label": "任务中心", "capability": "task.read"},
{"id": "execution", "label": "执行中心", "capability": "external_task.read"},
{"id": "authorization", "label": "授权中心", "capability": "provenance.read"},
{"id": "runtime", "label": "运行与运维", "capability": "runtime.read"},
{"id": "evidence", "label": "执行证据", "capability": "evidence.read"},
{"id": "audit", "label": "审计中心", "capability": "audit.read"},
{"id": "release", "label": "GitOps 与发布", "capability": "release.read"},
)
@dataclass(frozen=True)
class ResolvedCapabilities:
tenant_id: str
principal_id: str
roles: tuple[str, ...]
capabilities: tuple[str, ...]
navigation: tuple[Mapping[str, str], ...]
def as_dict(self) -> dict[str, object]:
return {
"tenant_id": self.tenant_id,
"principal_id": self.principal_id,
"roles": list(self.roles),
"capabilities": list(self.capabilities),
"navigation": [dict(item) for item in self.navigation],
}
def resolve(context: RequestContext) -> ResolvedCapabilities:
capabilities: set[str] = set()
for role in context.roles:
capabilities.update(ROLE_CAPABILITIES.get(role, ()))
ordered = tuple(sorted(capabilities))
navigation = tuple(item for item in NAVIGATION if item["capability"] in capabilities)
return ResolvedCapabilities(
tenant_id=context.tenant_id,
principal_id=context.principal_id,
roles=tuple(sorted(context.roles)),
capabilities=ordered,
navigation=navigation,
)
def require(context: RequestContext, capability: str) -> None:
if capability not in resolve(context).capabilities:
raise Unauthorized(f"portal capability is required: {capability}")
def allows(context: RequestContext, capability: str) -> bool:
return capability in resolve(context).capabilities
+48
View File
@@ -0,0 +1,48 @@
"""Unified portal HTML export with V2R1C2 resource and execution centers.
The canonical dependency-free portal remains in ``aios_v2.console``. This
module applies deterministic, fail-fast extensions at import time so the
V2R1C1 production entrypoint remains unchanged while the V2R1C2 candidate gets
resource and portable-executor views.
"""
from __future__ import annotations
from aios_v2.console import CONSOLE_HTML as _BASE_PORTAL_HTML
def _extend_portal(source: str) -> str:
replacements = (
(
'<section id="overview" class="view active"></section>\n <section id="tasks" class="view"></section>\n <section id="authorization" class="view"></section>',
'<section id="overview" class="view active"></section>\n <section id="resources" class="view"></section>\n <section id="tasks" class="view"></section>\n <section id="execution" class="view"></section>\n <section id="authorization" class="view"></section>',
),
(
"const labels={overview:'系统总览',tasks:'任务中心',authorization:'授权中心'",
"const labels={overview:'系统总览',resources:'租户与 Agent',tasks:'任务中心',execution:'执行中心',authorization:'授权中心'",
),
(
"if(view==='overview')await overview();if(view==='tasks')await tasks();if(view==='authorization')await authorizations();",
"if(view==='overview')await overview();if(view==='resources')await resources();if(view==='tasks')await tasks();if(view==='execution')await execution();if(view==='authorization')await authorizations();",
),
(
"async function tasks(){const d=await api('/api/v2/portal/tasks');",
"""async function resources(){const d=await api('/api/v2/portal/resources');const t=d.tenant||{},agents=d.agents||[],ops=d.operations||{};const agentButton=ops.agent_create?'<button class=\"btn\" id=\"newAgent\">注册 Agent</button>':'';$('#resources').innerHTML=`<div class=\"cards\">${card('租户',t.tenant_id||'—',t.status||'')}${card('Agent',d.summary?.agents||0,`启用 ${d.summary?.enabled_agents||0}`)}${card('任务',d.summary?.tasks||0)}${card('Runtime 类型',(d.summary?.runtimes||[]).length,(d.summary?.runtimes||[]).join(', '))}</div><div class=\"grid\"><div class=\"panel\"><div class=\"panel-head\"><h2>当前租户</h2></div><div class=\"kv\"><div>Tenant ID</div><div>${esc(t.tenant_id||'—')}</div><div>名称</div><div>${esc(t.display_name||'—')}</div><div>状态</div><div>${pill(t.status||'unknown')}</div><div>创建时间</div><div>${esc(t.created_at||'—')}</div><div>更新时间</div><div>${esc(t.updated_at||'—')}</div></div></div><div class=\"panel\"><div class=\"panel-head\"><h2>Agent Registry</h2><div>${agentButton}</div></div>${table([['Agent',x=>esc(x.agent_id)],['名称',x=>esc(x.display_name)],['Runtime',x=>pill(x.runtime)],['能力',x=>(x.capabilities||[]).map(pill).join('')],['启用',x=>pill(x.enabled?'enabled':'disabled')],['任务数',x=>esc(x.task_count)]],agents)}</div></div>`;$('#newAgent')?.addEventListener('click',()=>{const agent_id=prompt('Agent ID');const display_name=prompt('显示名称',agent_id||'');const runtime=prompt('Runtime Provider','openclaw');const capabilities=prompt('能力,逗号分隔','');if(!agent_id||!display_name||!runtime)return;api('/api/v2/portal/agents',{method:'POST',body:JSON.stringify({agent_id,display_name,runtime,capabilities:String(capabilities||'').split(',').map(x=>x.trim()).filter(Boolean),metadata:{source:'unified-portal'}})}).then(resources).catch(err)})}
function executionStage(x){const s=x.stage||{},status=x.truth||{},record=status.record||{},gate=status.gate||{},d=x.dependency_gate||{},notifications=gate.notifications||[],evidence=record.evidence||{};const stateName=record.state||'WAITING_DEPENDENCY';const deps=(s.depends_on||[]).join(', ')||'根阶段';return `<div class=\"panel\" style=\"background:#fbfcfe\"><div class=\"panel-head\"><h2>${esc(s.stage_id)} · ${esc(s.role)}</h2><div>${pill(stateName)} ${pill(s.executor_type)}</div></div><div class=\"chain\"><div class=\"chain-item\"><b>Provider</b><div>${esc(s.executor_type)}</div><small>${esc(deps)}</small></div><span class=\"chain-arrow\">→</span><div class=\"chain-item\"><b>Run</b><div>${esc(record.executor_run_id||'未注册')}</div><small>${esc(record.executor_id||'')}</small></div><span class=\"chain-arrow\">→</span><div class=\"chain-item\"><b>Progress</b><div>${esc(record.current_step||0)}/${esc((record.manifest?.steps||s.steps||[]).length)}</div><small>${esc(record.heartbeat_at||'无心跳')}</small></div><span class=\"chain-arrow\">→</span><div class=\"chain-item\"><b>Evidence</b><div>${Object.keys(evidence).length} 项</div><small>${Object.keys(evidence).slice(0,2).map(esc).join(', ')}</small></div><span class=\"chain-arrow\">→</span><div class=\"chain-item\"><b>Notification</b><div>${notifications.length} 条</div><small>${notifications.map(n=>n.status).join(', ')||'无'}</small></div><span class=\"chain-arrow\">→</span><div class=\"chain-item\"><b>Gate</b><div>${pill(gate.passed?'PASS':'BLOCKED')}</div><small>${d.passed?'依赖满足':'等待依赖'}</small></div></div>${Object.keys(record.error||{}).length?json(record.error):''}</div>`}
async function execution(){const d=await api('/api/v2/portal/execution');const providers=d.providers||[],plans=d.plans||[];$('#execution').innerHTML=`<div class=\"cards\">${card('Provider',providers.length)}${card('Execution Plan',plans.length)}${card('协议',d.protocol_version||'—')}${card('Plan Schema',d.plan_schema_version||'—')}</div><div class=\"panel\"><div class=\"panel-head\"><h2>Executor Provider Catalog</h2></div>${table([['Provider',x=>esc(x.executor_type)],['Adapter',x=>esc(x.adapter_version)],['协议',x=>esc(x.protocol_version)],['Trust Roles',x=>(x.trust_roles||[]).map(pill).join('')],['Capabilities',x=>(x.capabilities||[]).map(pill).join('')]],providers)}</div>${plans.map(p=>`<div class=\"panel\"><div class=\"panel-head\"><h2>${esc(p.plan_id)} · ${esc(p.target_version)}</h2><div>${pill(p.gate?.passed?'PASS':'BLOCKED')}</div></div><div class=\"kv\"><div>Source Branch</div><div>${esc(p.source_branch)}</div><div>Source OID</div><div>${esc(p.source_oid)}</div><div>Release Gate</div><div>${pill(p.gate?.passed?'PASS':'BLOCKED')}</div></div></div>${(p.snapshot?.stages||[]).map(executionStage).join('')}`).join('')||'<div class=\"empty\">尚未声明执行计划</div>'}`}
async function tasks(){const d=await api('/api/v2/portal/tasks');""",
),
)
result = source
for old, new in replacements:
if old not in result:
raise RuntimeError(f"unified portal extension anchor not found: {old[:100]}")
result = result.replace(old, new, 1)
return result
PORTAL_HTML = _extend_portal(_BASE_PORTAL_HTML)
if "\n+function executionStage" in PORTAL_HTML or "\n+async function execution" in PORTAL_HTML:
raise RuntimeError("execution center contains an invalid diff marker")
__all__ = ["PORTAL_HTML"]
@@ -0,0 +1,130 @@
"""HTTP facade for portable multi-provider execution plans."""
from __future__ import annotations
import urllib.parse
from http import HTTPStatus
from typing import Any, Mapping
from aios_v2.control_plane import RequestContext, Unauthorized
from aios_v2.execution_plan import ExecutionPlanCoordinator
from aios_v2.executor_protocol import ExecutorProviderRegistry, normalize_executor
from .capabilities import require
class ExecutionPlanAPI:
"""Portal read model and provider-authenticated plan discovery API."""
INTERNAL_PREFIX = "/api/v2/internal/execution-plans"
PORTAL_PATH = "/api/v2/portal/execution"
def __init__(
self,
coordinator: ExecutionPlanCoordinator,
*,
providers: ExecutorProviderRegistry,
) -> None:
self.coordinator = coordinator
self.providers = providers
@classmethod
def is_route(cls, raw_path: str) -> bool:
path = urllib.parse.urlparse(raw_path).path.rstrip("/") or "/"
return path == cls.PORTAL_PATH or path.startswith(cls.INTERNAL_PREFIX)
def _require_provider(
self,
context: RequestContext,
executor_type: str,
) -> str:
normalized = normalize_executor(executor_type)
if not self.providers.trusted(
executor_type=normalized,
roles=tuple(context.roles),
platform_admin="platform_admin" in context.roles,
):
raise Unauthorized(
f"trusted role for executor Provider is required: {normalized}"
)
return normalized
def _portal_snapshot(self) -> Mapping[str, Any]:
plans = []
for plan in self.coordinator.plan_store.list():
plans.append(
{
"plan_id": plan.plan_id,
"target_version": plan.target_version,
"source_branch": plan.source_branch,
"source_oid": plan.source_oid,
"snapshot": self.coordinator.snapshot(plan.plan_id),
"gate": self.coordinator.gate(plan.plan_id),
}
)
return {
"protocol_version": "aios.executor.v1",
"plan_schema_version": "aios.execution-plan.v2",
"providers": [
item.as_dict() for item in self.providers.list_descriptors()
],
"plans": plans,
}
def handle(
self,
method: str,
raw_path: str,
context: RequestContext,
body: Any,
) -> tuple[int, Mapping[str, Any]]:
del body
parsed = urllib.parse.urlparse(raw_path)
path = parsed.path.rstrip("/") or "/"
method = method.upper()
if method == "GET" and path == self.PORTAL_PATH:
require(context, "external_task.read")
return HTTPStatus.OK, self._portal_snapshot()
if not path.startswith(f"{self.INTERNAL_PREFIX}/"):
return HTTPStatus.NOT_FOUND, {
"error": "not_found",
"message": "execution plan route not found",
}
remainder = urllib.parse.unquote(path[len(self.INTERNAL_PREFIX) + 1 :])
parts = tuple(item for item in remainder.split("/") if item)
if len(parts) != 2:
return HTTPStatus.NOT_FOUND, {
"error": "not_found",
"message": "execution plan route not found",
}
plan_id, action = parts
query = urllib.parse.parse_qs(parsed.query)
executor_type = self._require_provider(
context,
str(query.get("executor", [""])[0]),
)
if method == "GET" and action == "discover":
items = self.coordinator.discover_ready(
plan_id=plan_id,
executor_type=executor_type,
)
return HTTPStatus.OK, {
"plan_id": plan_id,
"executor_type": executor_type,
"count": len(items),
"items": items,
}
if method == "GET" and action == "snapshot":
return HTTPStatus.OK, self.coordinator.snapshot(plan_id)
if method == "GET" and action == "gate":
return HTTPStatus.OK, self.coordinator.gate(plan_id)
return HTTPStatus.NOT_FOUND, {
"error": "not_found",
"message": "execution plan route not found",
}
@@ -0,0 +1,276 @@
"""Protected HTTP facade for portable executor truth and notification receipts."""
from __future__ import annotations
import urllib.parse
from http import HTTPStatus
from pathlib import Path
from typing import Any, Mapping, Sequence
from aios_v2.control_plane import RequestContext, Unauthorized
from aios_v2.executor_protocol import ExecutorProviderRegistry, normalize_executor
from aios_v2.external_task_control import ExternalTaskCoordinator
from aios_v2.external_task_query import list_records
from aios_v2.task_execution_registry import ExecutionTruthState
from .capabilities import require
def _body(value: Any) -> Mapping[str, Any]:
if not isinstance(value, Mapping):
raise ValueError("JSON body must be an object")
return value
def _required(value: Any, label: str) -> str:
normalized = str(value or "").strip()
if not normalized:
raise ValueError(f"{label} is required")
return normalized
def _mapping_strings(value: Any, label: str) -> dict[str, str]:
if not isinstance(value, Mapping):
raise ValueError(f"{label} must be an object")
return {
_required(key, f"{label} key"): _required(item, f"{label}[{key}]")
for key, item in value.items()
}
class ExternalTaskAPI:
"""Fail-closed external task API shared by every registered Provider."""
INTERNAL_PREFIX = "/api/v2/internal/external-tasks"
def __init__(
self,
coordinator: ExternalTaskCoordinator,
*,
store_file: str | Path,
providers: ExecutorProviderRegistry,
) -> None:
self.coordinator = coordinator
self.store_file = Path(store_file).resolve()
self.providers = providers
@staticmethod
def is_route(path: str) -> bool:
normalized = path.rstrip("/") or "/"
return normalized.startswith(ExternalTaskAPI.INTERNAL_PREFIX) or normalized == "/api/v2/portal/external-tasks"
def _require_executor(self, context: RequestContext, executor_type: str) -> str:
normalized = normalize_executor(executor_type)
if not self.providers.trusted(
executor_type=normalized,
roles=tuple(context.roles),
platform_admin="platform_admin" in context.roles,
):
raise Unauthorized(
f"trusted role for executor Provider is required: {normalized}"
)
return normalized
def _task_executor(self, task_id: str) -> str:
return normalize_executor(
self.coordinator.registry.get(task_id).manifest.executor
)
@staticmethod
def _executor_identity(
context: RequestContext,
payload: Mapping[str, Any],
) -> str:
executor_id = _required(payload.get("executor_id"), "executor_id")
if executor_id != context.principal_id and "platform_admin" not in context.roles:
raise Unauthorized("executor_id must match trusted principal")
return executor_id
def declare_directory(self, directory: str | Path) -> list[Mapping[str, Any]]:
root = Path(directory).resolve()
if not root.exists():
return []
declared: list[Mapping[str, Any]] = []
for manifest in sorted(root.glob("*.json")):
record = self.coordinator.declare_file(manifest)
self.providers.validate_manifest(record.manifest)
declared.append(
{
"manifest": str(manifest),
"task_id": record.task_id,
"executor_type": record.manifest.executor,
"state": record.state.value,
}
)
return declared
def handle(
self,
method: str,
raw_path: str,
context: RequestContext,
body: Any,
) -> tuple[int, Mapping[str, Any]]:
parsed = urllib.parse.urlparse(raw_path)
path = parsed.path.rstrip("/") or "/"
method = method.upper()
payload = _body(body or {})
if method == "GET" and path == "/api/v2/portal/external-tasks":
require(context, "external_task.read")
records = list_records(self.store_file)
return HTTPStatus.OK, {
"count": len(records),
"items": [self.coordinator.status(item.task_id) for item in records],
}
if method == "GET" and path == f"{self.INTERNAL_PREFIX}/discover":
query = urllib.parse.parse_qs(parsed.query)
states = tuple(query.get("state", ()))
executor = self._require_executor(
context,
str(query.get("executor", [""])[0]),
)
records = list_records(
self.store_file,
executor=executor,
states=states,
)
return HTTPStatus.OK, {
"count": len(records),
"executor_type": executor,
"items": [item.as_dict() for item in records],
}
if method == "GET" and path == f"{self.INTERNAL_PREFIX}/notifications":
query = urllib.parse.parse_qs(parsed.query)
executor = self._require_executor(
context,
str(query.get("executor", [""])[0]),
)
task_id = query.get("task_id", [None])[0]
status = query.get("status", [None])[0]
items = self.coordinator.notifications.list(
task_id=task_id,
status=status,
)
filtered = [
item
for item in items
if self._task_executor(item.task_id) == executor
]
return HTTPStatus.OK, {
"count": len(filtered),
"executor_type": executor,
"items": [item.as_dict() for item in filtered],
}
notification_ack = self._match(
path,
f"{self.INTERNAL_PREFIX}/notifications/",
"/ack",
)
if method == "POST" and notification_ack:
notification = self.coordinator.notifications.get(notification_ack)
executor = self._task_executor(notification.task_id)
self._require_executor(context, executor)
self._executor_identity(context, payload)
record = self.coordinator.notifications.acknowledge(
notification_ack,
receipt_sha256=_required(
payload.get("receipt_sha256"),
"receipt_sha256",
),
)
return HTTPStatus.OK, {"notification": record.as_dict()}
truth_task = self._match(path, f"{self.INTERNAL_PREFIX}/", "/truth")
if method == "GET" and truth_task:
self._require_executor(context, self._task_executor(truth_task))
return HTTPStatus.OK, self.coordinator.status(truth_task)
for action in ("register", "retry", "claim", "start", "heartbeat", "succeed", "fail"):
task_id = self._match(path, f"{self.INTERNAL_PREFIX}/", f"/{action}")
if method != "POST" or not task_id:
continue
executor_type = self._task_executor(task_id)
self._require_executor(context, executor_type)
run_id = _required(payload.get("executor_run_id"), "executor_run_id")
if action == "register":
return HTTPStatus.OK, {
"record": self.coordinator.register(
task_id,
executor_run_id=run_id,
).as_dict()
}
if action == "retry":
return HTTPStatus.OK, {
"record": self.coordinator.retry_not_started(
task_id,
executor_run_id=run_id,
).as_dict()
}
executor_id = self._executor_identity(context, payload)
if action == "claim":
record = self.coordinator.claim(
task_id,
executor_run_id=run_id,
executor_id=executor_id,
)
elif action == "start":
record = self.coordinator.start(
task_id,
executor_run_id=run_id,
executor_id=executor_id,
)
elif action == "heartbeat":
record = self.coordinator.heartbeat(
task_id,
executor_run_id=run_id,
executor_id=executor_id,
current_step=int(payload.get("current_step")),
)
elif action == "succeed":
record = self.coordinator.succeed(
task_id,
executor_run_id=run_id,
executor_id=executor_id,
evidence=_mapping_strings(payload.get("evidence"), "evidence"),
)
else:
error = payload.get("error")
if not isinstance(error, Mapping):
raise ValueError("error must be an object")
evidence_value = payload.get("evidence")
evidence = (
_mapping_strings(evidence_value, "evidence")
if evidence_value is not None
else None
)
record = self.coordinator.fail(
task_id,
executor_run_id=run_id,
executor_id=executor_id,
error=dict(error),
evidence=evidence,
)
return HTTPStatus.OK, {"record": record.as_dict()}
return HTTPStatus.NOT_FOUND, {
"error": "not_found",
"message": "external task route not found",
}
@staticmethod
def _match(path: str, prefix: str, suffix: str) -> str | None:
if not path.startswith(prefix) or not path.endswith(suffix):
return None
middle = path[len(prefix) : len(path) - len(suffix)]
if not middle or "/" in middle:
return None
return urllib.parse.unquote(middle)
DISCOVERABLE_STATES: Sequence[str] = (
ExecutionTruthState.DECLARED.value,
ExecutionTruthState.REGISTERED.value,
)
@@ -0,0 +1,376 @@
"""V2R1C2 unified portal and portable executor release entrypoint.
The V2R1C1 production entrypoint remains unchanged. This candidate entrypoint
uses an isolated state directory, loads a versioned executor catalog, declares
portable multi-provider execution plans and exposes both portal read models and
provider-authenticated lifecycle APIs.
"""
from __future__ import annotations
import json
import os
import re
import urllib.parse
from dataclasses import replace
from http import HTTPStatus
from pathlib import Path
from typing import Any, Mapping, Sequence
import aios_v2.app as candidate_app_module
from aios_v2.app import (
CandidateApplication,
CandidateHTTPServer,
build_application as build_candidate_application,
)
from aios_v2.control_plane import RequestContext
from aios_v2.execution_plan import ExecutionPlanCoordinator, ExecutionPlanManifest
from aios_v2.executor_catalog import FileExecutorCatalogStore
from aios_v2.official_executor_providers import official_provider_factories
from .application import UnifiedPortalApplication, _json_value, _mapping
from .capabilities import allows, require
from .console import PORTAL_HTML
from .execution_plan_api import ExecutionPlanAPI
from .external_task_api import ExternalTaskAPI
_READ_BRIDGE_ROLES = frozenset({"approver", "runtime", "viewer", "release_manager"})
_GIT_OID = re.compile(r"^[0-9a-f]{40}$")
def _resolve_environment_value(value: Any, *, label: str) -> Any:
if not isinstance(value, str) or not value.startswith("ENV:"):
return value
variable = value[4:].strip()
if not variable:
raise ValueError(f"{label} environment variable name is empty")
resolved = str(os.getenv(variable, "")).strip()
if not resolved:
raise RuntimeError(f"required environment variable is not set: {variable}")
return resolved
def _load_execution_plan(path: str | Path) -> ExecutionPlanManifest:
source = Path(path).resolve()
value = json.loads(source.read_text(encoding="utf-8"))
if not isinstance(value, Mapping):
raise ValueError(f"execution plan must contain a JSON object: {source}")
payload = dict(value)
for label in ("source_branch", "source_oid", "target_version"):
payload[label] = _resolve_environment_value(payload.get(label), label=label)
source_oid = str(payload.get("source_oid") or "").strip().lower()
if not _GIT_OID.fullmatch(source_oid):
raise ValueError(
"execution plan source_oid must resolve to an exact 40-character Git OID"
)
payload["source_oid"] = source_oid
return ExecutionPlanManifest.from_dict(payload)
def _path_list(value: str) -> tuple[Path, ...]:
return tuple(
Path(item).expanduser().resolve()
for item in value.split(os.pathsep)
if item.strip()
)
class V2R1C2PortalApplication(UnifiedPortalApplication):
"""Unified portal candidate with provider-neutral execution governance."""
def __init__(
self,
base: CandidateApplication,
*,
provenance_store_file: str | Path,
external_task_api: ExternalTaskAPI,
execution_plan_api: ExecutionPlanAPI,
plan_coordinator: ExecutionPlanCoordinator,
catalog_snapshot: Mapping[str, Any],
declared_plans: Sequence[str],
) -> None:
super().__init__(base, provenance_store_file=provenance_store_file)
self.external_task_api = external_task_api
self.execution_plan_api = execution_plan_api
self.plan_coordinator = plan_coordinator
self.catalog_snapshot = dict(catalog_snapshot)
self.declared_plans = tuple(declared_plans)
def context(self, headers: Mapping[str, str]) -> RequestContext:
original = self.base.context(headers)
if original.roles.intersection(_READ_BRIDGE_ROLES):
return replace(original, roles=frozenset((*original.roles, "auditor")))
return original
def _resource_snapshot(self, context: RequestContext) -> Mapping[str, Any]:
service = self.orchestrator.control_plane
tenant = service.get_tenant(context)
agents = service.list_agents(context) if allows(context, "agent.read") else []
tasks = service.list_tasks(context) if allows(context, "task.read") else []
tasks_by_agent: dict[str, int] = {}
for task in tasks:
tasks_by_agent[task.agent_id] = tasks_by_agent.get(task.agent_id, 0) + 1
return {
"tenant": _json_value(tenant),
"agents": [
{
**_json_value(agent),
"task_count": tasks_by_agent.get(agent.agent_id, 0),
}
for agent in agents
],
"summary": {
"agents": len(agents),
"enabled_agents": sum(1 for agent in agents if agent.enabled),
"tasks": len(tasks),
"runtimes": sorted({agent.runtime for agent in agents}),
},
"operations": {
"tenant_create": allows(context, "tenant.manage"),
"agent_create": allows(context, "agent.manage"),
},
}
def _runtime_snapshot(
self,
context: RequestContext,
headers: Mapping[str, str],
) -> Mapping[str, Any]:
return {
**self._runtime(headers),
"dispatches": _json_value(
self.orchestrator.dispatch_repository.list(
tenant_id=context.tenant_id
)
),
"hermes_jobs": _json_value(
self.hermes.list_jobs(tenant_id=context.tenant_id)
),
"contract": {
"openclaw": os.getenv(
"AIOS_OPENCLAW_CONTRACT_VERSION",
"aios.openclaw.command.v2",
),
"signature_algorithm": os.getenv(
"AIOS_OPENCLAW_SIGNATURE_ALGORITHM",
"Ed25519",
),
"plugin_version": os.getenv(
"AIOS_OPENCLAW_PLUGIN_VERSION",
"2.1.0-rc.4",
),
"key_id": os.getenv(
"AIOS_OPENCLAW_SIGNING_KEY_ID",
"openclaw-v2r1-candidate-ed25519",
),
"executor_protocol": "aios.executor.v1",
"execution_plan": "aios.execution-plan.v2",
},
}
def _release_snapshot(self, context: RequestContext) -> Mapping[str, Any]:
plans = []
for plan_id in self.declared_plans:
plans.append(
{
"plan_id": plan_id,
"gate": self.plan_coordinator.gate(plan_id),
}
)
aggregate_passed = bool(plans) and all(
item["gate"]["passed"] for item in plans
)
return {
**self._gitops(context),
"version": os.getenv("AIOS_RELEASE_VERSION", "V2R1C2-CANDIDATE"),
"target_version": os.getenv("AIOS_TARGET_VERSION", "V2R1C2"),
"release_oid": os.getenv("AIOS_RELEASE_OID", "candidate-not-tagged"),
"source_oid": os.getenv("AIOS_RELEASE_SOURCE_OID", "missing"),
"tag": os.getenv("AIOS_RELEASE_TAG", "NOT_CREATED"),
"baseline": os.getenv("AIOS_BASELINE_VERSION", "V2R1C1"),
"previous_version": os.getenv("AIOS_PREVIOUS_VERSION", "V2R1C1"),
"status": os.getenv("AIOS_RELEASE_STATUS", "CANDIDATE_VALIDATION"),
"evidence_manifest_sha256": os.getenv(
"AIOS_RELEASE_EVIDENCE_SHA256",
"pending",
),
"executor_catalog": {
"path": self.catalog_snapshot.get("path"),
"sha256": self.catalog_snapshot.get("catalog_sha256"),
"protocol_version": "aios.executor.v1",
},
"execution_plans": plans,
"execution_gate": {
"passed": aggregate_passed,
"release_blocking": not aggregate_passed,
},
"operations": (
["review_release", "request_tag", "request_rollback"]
if allows(context, "release.operate")
else []
),
"note": (
"Release operations remain GitOps approval requests. The portal "
"never mutates tags, main, or production directly. Tag approval "
"must remain blocked until every required execution-plan gate passes."
),
}
def handle_json(
self,
method: str,
raw_path: str,
headers: Mapping[str, str],
body: Any,
) -> tuple[int, Mapping[str, Any]]:
parsed = urllib.parse.urlparse(raw_path)
path = parsed.path.rstrip("/") or "/"
method = method.upper()
if self.execution_plan_api.is_route(raw_path):
context = (
self.context(headers)
if path == ExecutionPlanAPI.PORTAL_PATH
else self.base.context(headers)
)
return self.execution_plan_api.handle(
method,
raw_path,
context,
body,
)
if self.external_task_api.is_route(path):
context = (
self.context(headers)
if path == "/api/v2/portal/external-tasks"
else self.base.context(headers)
)
return self.external_task_api.handle(
method,
raw_path,
context,
body,
)
context: RequestContext | None = None
if path in {
"/api/v2/portal/resources",
"/api/v2/portal/runtime",
"/api/v2/portal/release",
"/api/v2/portal/tenants",
"/api/v2/portal/agents",
}:
context = self.context(headers)
if method == "GET" and path == "/api/v2/portal/resources":
assert context is not None
require(context, "tenant.read")
return HTTPStatus.OK, self._resource_snapshot(context)
if method == "POST" and path == "/api/v2/portal/tenants":
assert context is not None
require(context, "tenant.manage")
return self.base.handle_json(
"POST",
"/api/v2/tenants",
headers,
_mapping(body),
)
if method == "POST" and path == "/api/v2/portal/agents":
assert context is not None
require(context, "agent.manage")
return self.base.handle_json(
"POST",
"/api/v2/agents",
headers,
_mapping(body),
)
if method == "GET" and path == "/api/v2/portal/runtime":
assert context is not None
require(context, "runtime.read")
return HTTPStatus.OK, self._runtime_snapshot(context, headers)
if method == "GET" and path == "/api/v2/portal/release":
assert context is not None
require(context, "release.read")
return HTTPStatus.OK, self._release_snapshot(context)
return super().handle_json(method, raw_path, headers, body)
def build_application() -> V2R1C2PortalApplication:
state_dir = Path(
os.getenv("AIOS_V2_STATE_DIR", "/var/lib/aios-v2r1c2-portal")
).resolve()
state_dir.mkdir(parents=True, exist_ok=True)
provenance_file = Path(
os.getenv(
"AIOS_PROVENANCE_STORE_FILE",
str(state_dir / "run-provenance.sqlite3"),
)
).resolve()
truth_file = Path(
os.getenv(
"AIOS_EXTERNAL_TASK_STORE_FILE",
str(state_dir / "external-task-truth.sqlite3"),
)
).resolve()
catalog_file = Path(
os.getenv(
"AIOS_EXECUTOR_CATALOG_FILE",
str(state_dir / "executor-catalog.json"),
)
).resolve()
catalog_store = FileExecutorCatalogStore(catalog_file)
catalog_snapshot = catalog_store.initialize_defaults()
providers = catalog_store.registry(
provider_factories=official_provider_factories()
)
plan_coordinator = ExecutionPlanCoordinator.from_file(
truth_file,
providers=providers,
)
declared_plans: list[str] = []
plan_files = _path_list(os.getenv("AIOS_EXECUTION_PLAN_FILES", ""))
for plan_file in plan_files:
plan = _load_execution_plan(plan_file)
plan_coordinator.declare(plan)
declared_plans.append(plan.plan_id)
base: CandidateApplication = build_candidate_application()
external_task_api = ExternalTaskAPI(
plan_coordinator.task_coordinator,
store_file=truth_file,
providers=providers,
)
execution_plan_api = ExecutionPlanAPI(
plan_coordinator,
providers=providers,
)
return V2R1C2PortalApplication(
base,
provenance_store_file=provenance_file,
external_task_api=external_task_api,
execution_plan_api=execution_plan_api,
plan_coordinator=plan_coordinator,
catalog_snapshot=catalog_snapshot.as_dict(),
declared_plans=declared_plans,
)
def main() -> None:
candidate_app_module.CONSOLE_HTML = PORTAL_HTML
host = os.getenv("AIOS_V2_HOST", "127.0.0.1")
port = int(os.getenv("AIOS_V2_PORT", "18921"))
server = CandidateHTTPServer((host, port), build_application())
print(f"AIOS V2R1C2 unified portal listening on {host}:{port}")
server.serve_forever()
if __name__ == "__main__":
main()
@@ -0,0 +1,30 @@
# AIOS V2R1C2 Unified Portal Candidate
# Install as /etc/aios-v2/aios-v2r1c2-portal.env with mode 0640.
# Do not place secret values in this file. Use secret references only.
AIOS_V2_HOST=127.0.0.1
AIOS_V2_PORT=18921
AIOS_V2_STATE_DIR=/var/lib/aios-v2r1c2-portal
AIOS_PROVENANCE_STORE_FILE=/var/lib/aios-v2r1c2-portal/run-provenance.sqlite3
AIOS_EXTERNAL_TASK_STORE_FILE=/var/lib/aios-v2r1c2-portal/external-task-truth.sqlite3
AIOS_EXECUTOR_CATALOG_FILE=/var/lib/aios-v2r1c2-portal/executor-catalog.json
AIOS_EXECUTION_PLAN_FILES=/opt/aios-v2r1c2/plans/AIOS-V2R1C2-UNIFIED-PORTAL-RELEASE.json:/opt/aios-v2r1c2/plans/AIOS-V2R1C2-UNIFIED-PORTAL-TAKEOVER.json
AIOS_EXECUTION_PLAN_FILE=/opt/aios-v2r1c2/plans/AIOS-V2R1C2-UNIFIED-PORTAL-TAKEOVER.json
AIOS_RELEASE_VERSION=V2R1C2-CANDIDATE
AIOS_TARGET_VERSION=V2R1C2
AIOS_BASELINE_VERSION=V2R1C1
AIOS_PREVIOUS_VERSION=V2R1C1
AIOS_RELEASE_STATUS=CANDIDATE_VALIDATION
AIOS_RELEASE_SOURCE_OID=REPLACE_WITH_EXACT_40_CHARACTER_GIT_OID
AIOS_RELEASE_OID=candidate-not-tagged
AIOS_RELEASE_TAG=NOT_CREATED
AIOS_RELEASE_EVIDENCE_SHA256=pending
AIOS_OPENCLAW_CONTRACT_VERSION=aios.openclaw.command.v2
AIOS_OPENCLAW_SIGNATURE_ALGORITHM=Ed25519
AIOS_OPENCLAW_PLUGIN_VERSION=2.1.0-rc.4
AIOS_OPENCLAW_SIGNING_KEY_ID=openclaw-v2r1-candidate-ed25519
# Runtime resolves this reference through its secret store.
AIOS_OWNER_WECHAT_TARGET_REF=AIOS_OWNER_WECHAT_TARGET_REF
@@ -0,0 +1,44 @@
[Unit]
Description=AIOS V2R1C2 Unified Portal Candidate
After=network-online.target
Wants=network-online.target
ConditionPathExists=/opt/aios-v2r1c2/active-runtime/app/aios_web/release_application.py
ConditionPathExists=/opt/aios-v2r1c2/plans/AIOS-V2R1C2-UNIFIED-PORTAL-RELEASE.json
ConditionPathExists=/opt/aios-v2r1c2/plans/AIOS-V2R1C2-UNIFIED-PORTAL-TAKEOVER.json
[Service]
Type=simple
User=aios
Group=aios
WorkingDirectory=/opt/aios-v2r1c2/active-runtime/app
EnvironmentFile=/etc/aios-v2/aios-v2r1c2-portal.env
Environment=PYTHONUNBUFFERED=1
Environment=PYTHONDONTWRITEBYTECODE=1
ExecStartPre=/usr/bin/test "$AIOS_V2_PORT" = "18921"
ExecStartPre=/usr/bin/test -n "$AIOS_RELEASE_SOURCE_OID"
ExecStartPre=/usr/bin/test "$AIOS_RELEASE_SOURCE_OID" != "candidate-not-tagged"
ExecStart=/usr/bin/python3 -m aios_web.release_application
Restart=on-failure
RestartSec=3
TimeoutStartSec=60
TimeoutStopSec=30
KillSignal=SIGTERM
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
RestrictRealtime=true
LockPersonality=true
MemoryDenyWriteExecute=true
ReadWritePaths=/var/lib/aios-v2r1c2-portal
StateDirectory=aios-v2r1c2-portal
StateDirectoryMode=0750
UMask=0027
LimitNOFILE=65536
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,211 @@
# AIOS V2R1C2 统一门户与可迁移执行器补充契约
- 文档版本:`V2R1C2-candidate`
- 状态:实现与测试资产已提交;在线 Acceptance、Owner 审批与生产发布未完成
- 适用范围:统一角色门户、External Task Truth、Executor Provider、Execution Plan、通知回执、候选部署与 Release Gate
- 关联决策:`ADR-011`
## 1. 新增用户需求
### UR-023 统一角色感知门户
AIOS 必须使用同一 Web Portal 服务平台管理员、租户管理员、操作员、审批人、审计员、Runtime、发布经理和只读用户。服务端能力矩阵是唯一授权依据,前端隐藏按钮不得替代服务端授权。
验收:
- 门户至少包含总览、租户与 Agent、任务、执行中心、授权、Runtime、Evidence、Audit、GitOps/Release。
- `GET /api/v2/me/capabilities` 返回可信身份解析后的角色、能力和导航。
- 所有写操作必须再次进行服务端 Capability 检查。
- 浏览器不得直接调用 OpenClaw、Hermes、Codex 或未来执行器。
### UR-024 外部执行任务真相
任何由 OpenClaw、Hermes、Codex 或未来 Provider 执行的任务,必须进入同一持久 Execution Truth Registry。任务文件、进程存在、日志字符串或退出码不能单独证明任务已经执行。
统一生命周期:
```text
DECLARED -> REGISTERED -> CLAIMED -> RUNNING -> SUCCEEDED
| -> FAILED
+ timeout -> STALLED
REGISTER deadline exceeded -> NOT_STARTED
```
验收:
- 每次执行绑定 `task_id`、`executor_type`、`executor_id` 和 `executor_run_id`。
- Claim、Start、Heartbeat、Success、Fail 均持久化事件。
- Success 只有在所有步骤和 Required Evidence SHA256 完整时才允许。
- 启动超时和心跳超时必须产生可查询的阻塞状态。
### UR-025 可迁移 Executor Provider
执行器类型不得由核心枚举固定。OpenClaw、Hermes 和 Codex 只是内置 Provider;未来执行器必须通过版本化 Provider Descriptor、Catalog 和 Adapter 接入,复用相同生命周期、Evidence、Notification 与 Gate。
验收:
- Provider Descriptor 包含 `executor_type`、`adapter_version`、`protocol_version`、`trust_roles`、`capabilities` 和配置 Schema。
- Catalog 支持原子持久化、SHA256、防篡改导入导出和无 Secret 迁移包。
- 新增 Provider 不修改 Truth Registry、Execution Plan 状态机、Portal 或 Release Gate。
- API 的可信角色由 Provider Catalog 动态解析,不硬编码执行器名称。
### UR-026 多 Provider Execution Plan
发布任务必须可表达为任意 Provider 组成的 DAG。下游 Stage 在全部依赖 Gate 通过前保持 `WAITING_DEPENDENCY`,不得提前声明并消耗启动时限。
验收:
- Plan 使用 `aios.execution-plan.v2`,执行协议使用 `aios.executor.v1`。
- Stage 包含 Provider、角色、步骤、依赖、Required Evidence、通知事件和超时。
- Plan 创建时只声明根 Stage;依赖通过后原子激活下游 Stage。
- 聚合 Release Gate 只有在全部 Required Stage 的 Truth、Evidence、Dependency 和 Notification Gate 通过时才允许放行。
### UR-027 Owner 主动通知与发布门禁
候选部署开始、成功和失败必须生成持久 Notification Outbox 事件。投递目标使用 Secret Reference,真实微信投递必须回写 Receipt SHA256。通知失败可幂等重试,但不得生成重复事件。
验收:
- `(task_id, event_type)` 唯一,同一 STARTED/SUCCEEDED/FAILED 事件只有一个 Notification ID。
- `PENDING` 和 `FAILED` 均可重试;成功后原地更新为 `DELIVERED`。
- Release Gate 要求配置的 STARTED 和 SUCCEEDED 通知均有 Receipt。
- Git、日志、Portal 和任务资产不得存储微信账号、Token 或 Secret。
## 2. 补充模块契约
### 2.1 Executor Provider Catalog
权威实现:
- `active-runtime/app/aios_v2/executor_protocol.py`
- `active-runtime/app/aios_v2/executor_catalog.py`
- `active-runtime/app/aios_v2/official_executor_providers.py`
Catalog 只包含可迁移描述符,不包含 Endpoint Credential、Token、私钥或 Runtime Secret。导入迁移包必须同时校验 Package SHA256 与 Catalog SHA256。
### 2.2 External Execution Truth
权威实现:
- `active-runtime/app/aios_v2/task_execution_registry.py`
- `active-runtime/app/aios_v2/external_task_control.py`
- `active-runtime/app/aios_v2/external_task_query.py`
Truth Record 是外部执行状态的唯一事实源。`SUCCEEDED` 不等于 Release 可放行;仍需 Required Notification Receipt 和 Plan Dependency Gate。
### 2.3 Execution Plan
权威实现:
- `active-runtime/app/aios_v2/execution_plan.py`
- `active-runtime/app/aios_v2/executor_worker.py`
- `tools/aios_execution_plan_cli.py`
Plan 是 Stage 定义的唯一事实源。不得再复制维护三个独立 Task Manifest,否则会产生 Plan/Task 漂移并可能提前启动下游计时。
### 2.4 Portal/API
权威实现:
- `active-runtime/app/aios_web/capabilities.py`
- `active-runtime/app/aios_web/application.py`
- `active-runtime/app/aios_web/release_application.py`
- `active-runtime/app/aios_web/external_task_api.py`
- `active-runtime/app/aios_web/execution_plan_api.py`
- `active-runtime/app/aios_web/console.py`
核心新增 API:
```text
GET /api/v2/portal/execution
GET /api/v2/portal/external-tasks
GET /api/v2/internal/execution-plans/{plan_id}/discover?executor={provider}
GET /api/v2/internal/execution-plans/{plan_id}/snapshot?executor={provider}
GET /api/v2/internal/execution-plans/{plan_id}/gate?executor={provider}
GET /api/v2/internal/external-tasks/discover?executor={provider}
POST /api/v2/internal/external-tasks/{task_id}/{register|claim|start|heartbeat|succeed|fail}
GET /api/v2/internal/external-tasks/notifications?executor={provider}
POST /api/v2/internal/external-tasks/notifications/{notification_id}/ack
```
内部 API 只接受可信代理注入身份。Provider Trust Role、Task Manifest Executor 与 Principal/Executor ID 必须一致。
### 2.5 Candidate Deployment
候选服务:
- `deploy/aios-v2/systemd/aios-v2r1c2-portal.service`
- `deploy/aios-v2/systemd/aios-v2r1c2-portal.env.example`
约束:
- V2R1C1 生产继续监听 `127.0.0.1:18920`。
- V2R1C2 Candidate 只监听 `127.0.0.1:18921`。
- Candidate 使用 `/var/lib/aios-v2r1c2-portal`,不得复用生产状态目录。
- `AIOS_RELEASE_SOURCE_OID` 必须解析为精确 40 位 Git OID,否则拒绝启动。
- Portal 不直接创建 Tag、修改 `main` 或部署生产。
## 3. V2R1C2 发布计划
权威计划:
- `plans/AIOS-V2R1C2-UNIFIED-PORTAL-RELEASE.json`
DAG:
```text
openclaw-candidate-deploy
|
v
hermes-runtime-validation
|
v
codex-release-review
|
v
AIOS Owner approval -> tag/release request
```
OpenClaw 负责隔离候选部署、离线/在线 Acceptance、Role/Tenant 负向测试、重启与回滚演练;Hermes 负责 Queue/Lease/Worker/恢复验证;Codex 负责源代码、证据、安全边界、文档追踪和发布建议审查。所有 Provider 使用同一 Truth Registry。
## 4. 需求追踪矩阵
| 需求 | 决策/契约 | 实现 | 测试/验收 | 状态 |
|---|---|---|---|---|
| UR-023 统一门户 | ADR-011;本文 2.4 | `aios_web/*` | `tests/web/test_portable_executor_release.py`;在线 Portal Acceptance | 已实现;测试资产已提交;在线未执行 |
| UR-024 执行真相 | ADR-011;本文 2.2 | `task_execution_registry.py`、`external_task_control.py` | Truth/Evidence/Notification tests;CLI Gate | 已实现;实际执行证据待 OpenClaw/Hermes/Codex |
| UR-025 Provider 可迁移 | ADR-011;本文 2.1 | `executor_protocol.py`、`executor_catalog.py`、`official_executor_providers.py` | Catalog migration/tamper/dynamic Provider tests | 已实现;测试资产已提交 |
| UR-026 Execution Plan DAG | ADR-011;本文 2.3 | `execution_plan.py`、`executor_worker.py` | 下游延迟激活、聚合 Gate tests | 已实现;在线 DAG 未执行 |
| UR-027 主动通知 | ADR-011;本文 2.2 | Notification Outbox、Worker retry | 首投失败、幂等重试、Receipt Gate test | 已实现;真实微信投递未执行 |
## 5. Acceptance 与证据
离线/在线验收入口:
```text
python3 tools/aios_v2r1c2_acceptance.py \
--source-oid <exact-40-character-oid> \
[--online] \
--evidence-output artifacts/v2r1c2/acceptance-evidence.json
```
专项测试:
- `tests/web/test_portable_executor_release.py`
- `tests/aios_v2_candidate/`
机器证据至少包含:命令、时间、退出码、输出 SHA256、HTTP 响应 SHA256、Source OID、Plan SHA256、Catalog SHA256、Service/Env SHA256、回滚结果和微信 Receipt SHA256。
## 6. 当前门禁状态
| Gate | 状态 | 说明 |
|---|---|---|
| 代码与部署资产 | 已提交 | 当前候选分支包含实现、Plan、systemd、CLI 和测试资产 |
| 测试执行 | 未验证 | 当前 GitOps 工具未执行 Python 测试,禁止声称通过 |
| OpenClaw Candidate Stage | `NOT_STARTED` | 必须由目标环境实际 Discover/Register/Claim/Start |
| Hermes Runtime Stage | `WAITING_DEPENDENCY` | OpenClaw Gate 通过后才激活 |
| Codex Review Stage | `WAITING_DEPENDENCY` | OpenClaw 与 Hermes Gate 通过后才激活 |
| 微信通知 | 未投递 | 需要目标环境 Sender 和 Receipt 回写 |
| Tag/生产发布 | 未批准 | 需要 Plan Gate 通过和 AIOS Owner 明确批准 |
测试文件、Acceptance 脚本、Task YAML、systemd 文件或日志文本的存在都不等于实际 Gate 已通过。
@@ -0,0 +1,51 @@
# V2R1C2 Owner Provenance 与微信接管迁移
## 边界
本迁移不关闭或降低 `aios-tenant-boundary`。普通、已识别的消息可以进入只读对话;
只有服务端确认的 Owner 才能为 `aios_protected_dispatch` 自动签发
`aios.owner-provenance/v2`。未知、阻止名单和受保护操作的非 Owner 请求继续 fail closed。
客户端不得提交可信身份 Header、私钥、签名或 Provenance Token。插件从 OpenClaw
可信 hook 上下文取得 `principal_id`、`agent_id`、`run_id` 和 execution path,并对
精确工具参数计算 SHA256。服务端签发记录绑定:
- `tenant_id`
- `principal_id`
- `task_id`
- `agent_id`
- `run_id`
- `execution_path`
- `request_digest`
- `issued_at` / `expires_at`
- `key_id` 与覆盖全部字段的 Ed25519 signature
参数在 hook 与 tool execute 之间改变、错误身份绑定、过期、撤销或单次 permit 重放
都会被拒绝。
## 配置迁移
私钥仍位于仓库外。部署系统只写入路径引用,并验证目标文件存在、为普通文件且权限
不向 group/other 开放。需要在 Candidate 专用配置中增加:
```text
provenancePrivateKeyPath=<outside-git path>
provenancePublicKeyPath=<outside-git path>
provenanceStoreDir=/var/lib/aios-v2r1c2-portal/provenance
provenanceKeyId=<non-secret key id>
tenantId=<candidate tenant>
protectedDispatchProvider=chatgpt-supervisor
protectedDispatchTransport=mock|sandbox
ownerProvenanceTtlMs<=300000
```
`mockFailFirstDelivery` 只允许在明确的模拟验收配置中启用。模拟输出必须保留:
`simulation=true`、`transport=mock|sandbox`、`real_user_impersonated=false`。
## 部署与回滚
1. 从精确 Candidate OID 构建插件与 Portal;不得从 live 目录回写源码。
2. 备份 Candidate 插件、配置和 unit;不修改 18920 服务或状态目录。
3. 在 18921 完成正向链、负向矩阵、mock/sandbox 微信、restart recovery。
4. 回滚只停止 `aios-v2r1c2-portal.service` 并恢复 Candidate 备份;随后重新验证 18920。
5. 未经 Owner 明确批准,不创建 Tag、不修改 `main`、不替换生产。
@@ -0,0 +1,9 @@
{
"type": "branch",
"branch": "patch/chatgpt/AIOS-V2R1C1-UNIFIED-WEB-ROLE-PORTAL-20260721",
"base": "4e0e6dcb90e6732aa9838fe9d0af0b34746829b0",
"created_by": "chatgpt-gitops-bot",
"created_at": "2026-07-21T15:17:54Z",
"direct_main_push": false,
"owner_approval_required": true
}
@@ -0,0 +1,147 @@
{
"schema_version": "aios.execution-plan.v2",
"plan_id": "AIOS-V2R1C2-UNIFIED-PORTAL-RELEASE-20260721",
"source_branch": "patch/chatgpt/AIOS-V2R1C1-UNIFIED-WEB-ROLE-PORTAL-20260721",
"source_oid": "ENV:AIOS_RELEASE_SOURCE_OID",
"target_version": "V2R1C2",
"release_gate_policy": "all_required_stages",
"metadata": {
"owner": "AIOS-Control-Plane",
"purpose": "Unified role-aware portal and portable executor rollout",
"executor_protocol": "aios.executor.v1",
"owner_notification_channel": "wechat",
"owner_notification_target_ref": "AIOS_OWNER_WECHAT_TARGET_REF",
"production_baseline": "V2R1C1",
"candidate_port": 18921,
"production_port": 18920,
"production_mutation_allowed": false
},
"stages": [
{
"stage_id": "openclaw-candidate-deploy",
"task_id": "AIOS-V2R1C2-OPENCLAW-CANDIDATE-DEPLOY-20260721",
"executor_type": "openclaw",
"role": "candidate_deployment_operator",
"depends_on": [],
"steps": [
"verify_exact_source_oid_and_clean_worktree",
"bootstrap_isolated_candidate_state_and_executor_catalog",
"run_static_compile_and_candidate_unit_tests",
"install_and_start_v2r1c2_candidate_service_on_18921",
"run_health_api_ui_and_role_matrix_smoke",
"run_tenant_isolation_and_protected_action_negative_tests",
"run_restart_recovery_and_rollback_drill",
"publish_candidate_evidence_manifest"
],
"required_evidence": [
"source_oid_attestation",
"bootstrap_log",
"compile_report",
"unit_test_report",
"service_status",
"health_check",
"api_smoke",
"ui_smoke",
"role_matrix",
"tenant_isolation",
"protected_action_negative",
"restart_recovery",
"rollback_drill",
"candidate_evidence_manifest"
],
"notification_events": [
"STARTED",
"SUCCEEDED",
"FAILED"
],
"owner_notification_ref": "AIOS_OWNER_WECHAT_TARGET_REF",
"start_deadline_seconds": 900,
"heartbeat_timeout_seconds": 360,
"required": true,
"metadata": {
"runtime": "openclaw",
"candidate_service": "aios-v2r1c2-portal.service",
"candidate_url": "http://127.0.0.1:18921",
"production_service_must_remain_untouched": true
}
},
{
"stage_id": "hermes-runtime-validation",
"task_id": "AIOS-V2R1C2-HERMES-RUNTIME-VALIDATION-20260721",
"executor_type": "hermes",
"role": "runtime_validation_operator",
"depends_on": [
"openclaw-candidate-deploy"
],
"steps": [
"validate_hermes_health_and_worker_identity",
"validate_queue_claim_and_completion",
"validate_lease_heartbeat_and_timeout_recovery",
"validate_restart_recovery_and_duplicate_delivery_safety",
"reconcile_runtime_evidence_with_candidate_audit"
],
"required_evidence": [
"hermes_health",
"queue_claim_completion",
"lease_heartbeat",
"timeout_recovery",
"restart_recovery",
"duplicate_delivery_safety",
"runtime_evidence_reconciliation"
],
"notification_events": [
"STARTED",
"SUCCEEDED",
"FAILED"
],
"owner_notification_ref": "AIOS_OWNER_WECHAT_TARGET_REF",
"start_deadline_seconds": 900,
"heartbeat_timeout_seconds": 360,
"required": true,
"metadata": {
"runtime": "hermes",
"candidate_url": "http://127.0.0.1:18921"
}
},
{
"stage_id": "codex-release-review",
"task_id": "AIOS-V2R1C2-CODEX-RELEASE-REVIEW-20260721",
"executor_type": "codex",
"role": "release_evidence_reviewer",
"depends_on": [
"openclaw-candidate-deploy",
"hermes-runtime-validation"
],
"steps": [
"review_source_diff_and_architecture_invariants",
"review_test_and_runtime_evidence_hashes",
"review_security_tenant_and_role_boundaries",
"review_documentation_traceability_and_rollback_readiness",
"produce_release_recommendation_without_creating_tag"
],
"required_evidence": [
"source_diff_review",
"test_evidence_review",
"runtime_evidence_review",
"security_boundary_review",
"documentation_traceability_review",
"rollback_readiness_review",
"release_recommendation"
],
"notification_events": [
"STARTED",
"SUCCEEDED",
"FAILED"
],
"owner_notification_ref": "AIOS_OWNER_WECHAT_TARGET_REF",
"start_deadline_seconds": 900,
"heartbeat_timeout_seconds": 360,
"required": true,
"metadata": {
"runtime": "codex",
"tag_creation_allowed": false,
"owner_approval_required": true
}
}
]
}
@@ -0,0 +1,148 @@
{
"schema_version": "aios.execution-plan.v2",
"plan_id": "AIOS-V2R1C2-UNIFIED-PORTAL-TAKEOVER-20260722",
"source_branch": "patch/codex/AIOS-V2R1C2-UNIFIED-PORTAL-TAKEOVER-20260722",
"source_oid": "ENV:AIOS_RELEASE_SOURCE_OID",
"target_version": "V2R1C2",
"release_gate_policy": "all_required_stages",
"metadata": {
"owner": "AIOS-Control-Plane",
"purpose": "Legal ChatGPT supervisor takeover for the isolated Unified Portal candidate",
"executor_protocol": "aios.executor.v1",
"owner_notification_channel": "wechat-simulation",
"owner_notification_target_ref": "AIOS_OWNER_WECHAT_TARGET_REF",
"production_baseline": "V2R1C1",
"candidate_port": 18921,
"production_port": 18920,
"production_mutation_allowed": false,
"release_tag_creation_allowed": false,
"simulation_required": true
},
"stages": [
{
"stage_id": "chatgpt-supervisor-candidate-deploy",
"task_id": "OPENCLAW-TASK-V2R1C2-UNIFIED-PORTAL-DEPLOY",
"executor_type": "chatgpt-supervisor",
"role": "candidate_deployment_supervisor",
"depends_on": [],
"steps": [
"verify_exact_source_oid_and_clean_worktree",
"verify_owner_provenance_and_tenant_boundary",
"compile_and_test_candidate",
"deploy_isolated_candidate_on_18921",
"run_offline_and_online_acceptance",
"run_simulated_wechat_acceptance",
"run_restart_recovery_and_rollback_drill",
"publish_hashed_evidence_and_gate_report"
],
"required_evidence": [
"source_oid_attestation",
"provenance_positive_path",
"provenance_negative_matrix",
"wechat_simulated_transcript",
"wechat_simulated_receipts",
"provenance_replay_test",
"tenant_isolation",
"provider_identity_test",
"compile_report",
"unit_test_report",
"candidate_regression_report",
"acceptance_offline",
"acceptance_online",
"service_status",
"health_check",
"api_smoke",
"ui_smoke",
"role_matrix",
"restart_recovery",
"rollback_drill",
"production_health_before",
"production_health_after",
"final_execution_snapshot",
"final_gate_report"
],
"notification_events": ["STARTED", "SUCCEEDED", "FAILED"],
"owner_notification_ref": "AIOS_OWNER_WECHAT_TARGET_REF",
"start_deadline_seconds": 900,
"heartbeat_timeout_seconds": 360,
"required": true,
"metadata": {
"runtime": "chatgpt-supervisor",
"candidate_service": "aios-v2r1c2-portal.service",
"candidate_url": "http://127.0.0.1:18921",
"production_url": "http://127.0.0.1:18920",
"production_service_must_remain_untouched": true,
"wechat_transport": "mock-or-sandbox",
"real_user_impersonated": false
}
},
{
"stage_id": "hermes-runtime-validation",
"task_id": "AIOS-V2R1C2-HERMES-RUNTIME-VALIDATION-20260722",
"executor_type": "hermes",
"role": "runtime_validation_operator",
"depends_on": ["chatgpt-supervisor-candidate-deploy"],
"steps": [
"validate_hermes_health_and_worker_identity",
"validate_queue_claim_and_completion",
"validate_lease_heartbeat_and_timeout_recovery",
"validate_restart_recovery_and_duplicate_delivery_safety",
"reconcile_runtime_evidence_with_candidate_audit"
],
"required_evidence": [
"hermes_health",
"queue_claim_completion",
"lease_heartbeat",
"timeout_recovery",
"restart_recovery",
"duplicate_delivery_safety",
"runtime_evidence_reconciliation"
],
"notification_events": ["STARTED", "SUCCEEDED", "FAILED"],
"owner_notification_ref": "AIOS_OWNER_WECHAT_TARGET_REF",
"start_deadline_seconds": 900,
"heartbeat_timeout_seconds": 360,
"required": true,
"metadata": {
"runtime": "hermes",
"candidate_url": "http://127.0.0.1:18921"
}
},
{
"stage_id": "codex-release-review",
"task_id": "AIOS-V2R1C2-CODEX-RELEASE-REVIEW-20260722",
"executor_type": "codex",
"role": "release_evidence_reviewer",
"depends_on": [
"chatgpt-supervisor-candidate-deploy",
"hermes-runtime-validation"
],
"steps": [
"review_source_diff_and_architecture_invariants",
"review_test_and_runtime_evidence_hashes",
"review_security_tenant_and_role_boundaries",
"review_documentation_traceability_and_rollback_readiness",
"produce_release_recommendation_without_creating_tag"
],
"required_evidence": [
"source_diff_review",
"test_evidence_review",
"runtime_evidence_review",
"security_boundary_review",
"documentation_traceability_review",
"rollback_readiness_review",
"release_recommendation"
],
"notification_events": ["STARTED", "SUCCEEDED", "FAILED"],
"owner_notification_ref": "AIOS_OWNER_WECHAT_TARGET_REF",
"start_deadline_seconds": 900,
"heartbeat_timeout_seconds": 360,
"required": true,
"metadata": {
"runtime": "codex",
"tag_creation_allowed": false,
"owner_approval_required": true
}
}
]
}
@@ -0,0 +1,51 @@
# AIOS OpenClaw Run Provenance Writer
本插件为 AIOS V2R1 提供 Owner 签名的运行授权、持久 Provenance Store 与专用 Evidence Writer。它不授予通用 Shell 或任意文件写权限。
## AIOS Runtime HTTP 合同
插件在 OpenClaw loopback Gateway 内注册 `/v1/health`、`/v1/execute` 和
`/v1/commands/<id>`。执行请求必须同时通过本机 CredentialRef 比对与 Ed25519
签名验证;热路径只读取公钥,不读取 Candidate 私密签名材料。
Runtime 仅允许无副作用的 `status` 与在线验收专用 `acceptance.delay`。它不把
返回成功冒充为模型推理或任意 Agent 执行。命令身份、幂等键和终态以追加式
JSONL 持久化;Gateway 重启时遗留的 `RUNNING` 命令会收敛为
`FAILED/restart_interrupted`,避免把重启误报成真实超时。
取消只对当前进程内仍在执行的命令生效,超时由签名命令的 `expires_at` 与
30 秒硬上限共同约束。未知 action、错误签名、过期命令、跨 tenant 状态查询、
冲突的 `command_id` 或幂等键均失败关闭。
## 安全边界
- 签名合同:`aios.run-provenance/v1`,Ed25519,授权最长 15 分钟。
- 授权绑定:`run_id`、`sender_id`、任务、执行器实例、当前 Claim OID、Candidate OID 与任务分支。
- 路径:仅允许七个 `.agent/*.json` Evidence 文件。
- 操作:仅允许 `create/update`,随后执行固定 argv 的 `git add`、`git commit` 和当前任务分支普通 push。
- 通用 `exec/shell/bash/write/edit/apply_patch/file_write` 始终拒绝。
- `toolCallId` permit 只能消费一次;参数摘要变化、过期、签名篡改、Run/Sender 不匹配均拒绝。
- Writer 拒绝 symlink、路径逃逸、脏 Worktree、错误分支、错误 HEAD、Candidate 非祖先及暂存范围扩大。
- Gateway 以服务用户调用 Git 时,仅对配置中的精确 `writerRepoRoot` 注入命令级 `safe.directory`,不写全局 Git 配置。
- 禁止 reset、rebase、amend、Tag 和 force push。提交后 push 失败时保留可审计的 local-ahead 状态,不伪造回滚。
## 组件
- `issuer.mjs`:在受保护边界读取现有 Ed25519 材料,生成公开验签材料并原子写入一次性签发记录。
- `provenance.js`:严格 Schema、canonical JSON、Ed25519 sign/verify 和安全 Store 读取。
- `index.js`:OpenClaw hook、Owner/Run/Sender 验证与一次性 permit。
- `writer.js`:限定路径原子写入、固定 Git 操作和追加式审计。
## 部署与回滚
部署必须先备份运行插件和 OpenClaw 配置,再安装 Git 中已测试的完整目录,配置绝对路径、Key ID、任务分支和审计路径,执行 `openclaw plugins doctor` 后才重启 Gateway。回滚只恢复备份插件与配置并重启,不修改任务分支历史或业务状态。
## 验证
执行:
```bash
node --test test/*.test.js
```
验收还必须包含受保护签发、Heartbeat 实际 commit/push、审计链验证,以及 `README.md` 等未授权路径的拒绝测试。
+1
View File
@@ -0,0 +1 @@
export { default } from "../index.js";
+1
View File
@@ -0,0 +1 @@
export * from "../policy.js";
+1
View File
@@ -0,0 +1 @@
export * from "../provenance.js";
+1
View File
@@ -0,0 +1 @@
export * from "../writer.js";
+393
View File
@@ -0,0 +1,393 @@
import crypto from "node:crypto";
import path from "node:path";
import {
canonicalize,
isOwnerProvenanceRevoked,
loadStoredRecord,
readPublicKey,
validatePayload,
verifyOwnerProvenanceRecord,
verifyRecord,
} from "./provenance.js";
import { issueOwnerProvenance } from "./issuer.mjs";
import { classifyRun, decideToolCall, runKey, validateWriterParams } from "./policy.js";
import { executeEvidenceWrite } from "./writer.js";
import { createRuntimeApi } from "./runtime.js";
const activeRuns = new Map();
const permits = new Map();
const ownerPermits = new Map();
const ownerUses = new Map();
const dispatchReceipts = new Map();
const dispatchAttempts = new Map();
const MAX_RUN_AGE_MS = 60 * 60 * 1000;
const MAX_DISPATCH_CACHE_AGE_MS = 60 * 60 * 1000;
const MAX_SECURITY_CACHE_ENTRIES = 4096;
const DEFAULT_PERMIT_TTL_MS = 2 * 60 * 1000;
function pluginConfig(event, api) {
return event?.context?.pluginConfig ?? api.pluginConfig ?? {};
}
function trimToLimit(map, limit = MAX_SECURITY_CACHE_ENTRIES) {
while (map.size > limit) map.delete(map.keys().next().value);
}
function setBounded(map, key, value) {
if (map.has(key)) map.delete(key);
map.set(key, value);
trimToLimit(map);
}
export function prune(now = Date.now()) {
for (const [key, value] of activeRuns) if (now - value.recordedAt > MAX_RUN_AGE_MS) activeRuns.delete(key);
for (const [key, value] of permits) if (now - value.recordedAt > value.ttlMs) permits.delete(key);
for (const [key, value] of ownerPermits) if (now - value.recordedAt > value.ttlMs) ownerPermits.delete(key);
for (const [key, expiresAt] of ownerUses) if (now >= expiresAt) ownerUses.delete(key);
for (const [key, value] of dispatchReceipts) {
if (now - value.recordedAt > MAX_DISPATCH_CACHE_AGE_MS) dispatchReceipts.delete(key);
}
for (const [key, value] of dispatchAttempts) {
if (now - value.recordedAt > MAX_DISPATCH_CACHE_AGE_MS) dispatchAttempts.delete(key);
}
for (const map of [activeRuns, permits, ownerPermits, ownerUses, dispatchReceipts, dispatchAttempts]) trimToLimit(map);
}
function toolCallId(event) {
return typeof event?.toolCallId === "string" && event.toolCallId.trim() ? event.toolCallId.trim() : undefined;
}
function eventParams(event) {
return event?.params ?? event?.parameters;
}
function requireAbsoluteConfig(config, key) {
const value = config[key];
if (typeof value !== "string" || !path.isAbsolute(value)) throw new Error(`config_${key}_missing`);
return value;
}
export function authorizeEvidenceCall({ event, trust, config, now = Date.now() }) {
const callId = toolCallId(event);
if (!callId) return { ok: false, code: "tool_call_id_missing" };
if (!trust?.trusted) return { ok: false, code: trust?.reason || "run_provenance_missing" };
const params = eventParams(event);
if (!params || typeof params !== "object" || Array.isArray(params)) return { ok: false, code: "tool_params_missing" };
let record;
try { record = loadStoredRecord(requireAbsoluteConfig(config, "provenanceStoreDir"), params.provenance_id); }
catch (error) {
const code = error instanceof Error && ["unsafe_provenance_file", "invalid_provenance_location", "provenance_record_too_large"].includes(error.message)
? error.message : "provenance_store_rejected";
return { ok: false, code };
}
const validation = validatePayload(record.payload, now);
if (!validation.ok) return validation;
let publicKey;
try { publicKey = readPublicKey(requireAbsoluteConfig(config, "provenancePublicKeyPath")); }
catch { return { ok: false, code: "public_key_rejected" }; }
const signature = verifyRecord(record, publicKey, config.provenanceKeyId);
if (!signature.ok) return signature;
const payload = record.payload;
const runId = runKey(event, {});
if (!runId || payload.run_id !== runId) return { ok: false, code: "run_id_mismatch" };
if (payload.sender_id !== trust.senderId) return { ok: false, code: "sender_id_mismatch" };
if (config.writerTaskBranch && payload.task_branch !== config.writerTaskBranch) return { ok: false, code: "task_branch_mismatch" };
if (config.writerTaskId && payload.task_id !== config.writerTaskId) return { ok: false, code: "task_id_mismatch" };
if (config.provenanceIssuer && payload.issued_by !== config.provenanceIssuer) return { ok: false, code: "issuer_mismatch" };
if (config.executor && payload.executor !== config.executor) return { ok: false, code: "executor_mismatch" };
if (config.executorInstance && payload.executor_instance !== config.executorInstance) return { ok: false, code: "executor_instance_mismatch" };
const paramsValidation = validateWriterParams(params, payload);
if (!paramsValidation.ok) return paramsValidation;
const digest = crypto.createHash("sha256").update(canonicalize(params)).digest("hex");
if (permits.has(callId)) return { ok: false, code: "permit_replay" };
const ttlMs = Number.isSafeInteger(config.permitTtlMs) && config.permitTtlMs > 0
? Math.min(config.permitTtlMs, DEFAULT_PERMIT_TTL_MS)
: DEFAULT_PERMIT_TTL_MS;
permits.set(callId, { payload, paramsDigest: digest, recordedAt: now, ttlMs });
return { ok: true };
}
export function consumePermit(toolCallIdValue, params, now = Date.now()) {
const permit = permits.get(toolCallIdValue);
permits.delete(toolCallIdValue);
if (!permit) throw new Error("permit_missing");
if (now - permit.recordedAt > permit.ttlMs) throw new Error("permit_expired");
const digest = crypto.createHash("sha256").update(canonicalize(params)).digest("hex");
if (!crypto.timingSafeEqual(Buffer.from(digest), Buffer.from(permit.paramsDigest))) throw new Error("permit_params_mismatch");
return permit;
}
function protectedDispatchParams(params) {
if (!params || typeof params !== "object" || Array.isArray(params)) return { ok: false, code: "protected_params_missing" };
const expected = ["action", "idempotency_key", "payload", "provider", "task_id"];
const keys = Object.keys(params).sort();
if (keys.length !== expected.length || expected.some((key) => !keys.includes(key))) {
return { ok: false, code: "protected_params_schema_mismatch" };
}
for (const key of ["action", "idempotency_key", "provider", "task_id"]) {
if (typeof params[key] !== "string" || !params[key].trim()) return { ok: false, code: `invalid_${key}` };
}
if (!params.payload || typeof params.payload !== "object" || Array.isArray(params.payload)) return { ok: false, code: "invalid_payload" };
return { ok: true };
}
export function verifyOwnerBinding({ record, publicKeyPem, expectedKeyId, expected, storeDir, now = Date.now() }) {
const verified = verifyOwnerProvenanceRecord(record, publicKeyPem, expectedKeyId, now);
if (!verified.ok) return verified;
if (isOwnerProvenanceRevoked(storeDir, record.payload.provenance_id)) return { ok: false, code: "owner_revoked" };
for (const key of ["tenant_id", "principal_id", "task_id", "agent_id", "run_id", "execution_path", "request_digest"]) {
if (record.payload[key] !== expected[key]) return { ok: false, code: `${key}_mismatch` };
}
return { ok: true, payload: record.payload };
}
export function authorizeProtectedDispatchCall({ event, ctx = {}, trust, config, now = Date.now() }) {
const callId = toolCallId(event);
if (!callId) return { ok: false, code: "tool_call_id_missing" };
if (!trust?.trusted || trust.origin !== "authenticated_owner") return { ok: false, code: trust?.reason || "owner_not_authenticated" };
const params = eventParams(event);
const paramsCheck = protectedDispatchParams(params);
if (!paramsCheck.ok) return paramsCheck;
const expectedProvider = String(config.protectedDispatchProvider || "chatgpt-supervisor");
if (params.provider !== expectedProvider) return { ok: false, code: "provider_mismatch" };
const runId = runKey(event, ctx) || trust.runId;
const agentId = String(ctx.agentId || trust.agentId || "");
const tenantId = String(config.tenantId || "");
const executionPath = String(trust.executionPath || "");
if (!runId || !agentId || !tenantId || !executionPath) return { ok: false, code: "server_identity_incomplete" };
const requestDigest = crypto.createHash("sha256").update(canonicalize(params)).digest("hex");
let issued;
try {
issued = issueOwnerProvenance({
identity: {
tenant_id: tenantId,
principal_id: trust.senderId,
task_id: params.task_id,
agent_id: agentId,
run_id: runId,
execution_path: executionPath,
},
request: params,
privateKeyPath: requireAbsoluteConfig(config, "provenancePrivateKeyPath"),
storeDir: requireAbsoluteConfig(config, "provenanceStoreDir"),
keyId: String(config.provenanceKeyId || ""),
ttlMs: Math.min(Number(config.ownerProvenanceTtlMs || 120_000), 300_000),
now,
});
} catch (error) {
return { ok: false, code: error instanceof Error ? error.message : "owner_issue_failed" };
}
const expected = {
tenant_id: tenantId,
principal_id: trust.senderId,
task_id: params.task_id,
agent_id: agentId,
run_id: runId,
execution_path: executionPath,
request_digest: requestDigest,
};
const binding = verifyOwnerBinding({
record: issued.record,
publicKeyPem: readPublicKey(requireAbsoluteConfig(config, "provenancePublicKeyPath")),
expectedKeyId: String(config.provenanceKeyId || ""),
expected,
storeDir: requireAbsoluteConfig(config, "provenanceStoreDir"),
now,
});
if (!binding.ok) return binding;
if (ownerPermits.has(callId)) return { ok: false, code: "owner_permit_replay" };
const ttlMs = Math.min(Number(config.permitTtlMs || DEFAULT_PERMIT_TTL_MS), DEFAULT_PERMIT_TTL_MS);
ownerPermits.set(callId, {
record: issued.record,
expected,
paramsDigest: requestDigest,
recordedAt: now,
ttlMs,
publicKeyPath: requireAbsoluteConfig(config, "provenancePublicKeyPath"),
storeDir: requireAbsoluteConfig(config, "provenanceStoreDir"),
keyId: String(config.provenanceKeyId || ""),
});
return { ok: true, provenance_id: issued.provenanceId };
}
export function consumeProtectedPermit(toolCallIdValue, params, now = Date.now()) {
const permit = ownerPermits.get(toolCallIdValue);
ownerPermits.delete(toolCallIdValue);
if (!permit) throw new Error("owner_permit_missing");
if (now - permit.recordedAt > permit.ttlMs) throw new Error("owner_permit_expired");
const digest = crypto.createHash("sha256").update(canonicalize(params)).digest("hex");
if (!crypto.timingSafeEqual(Buffer.from(digest), Buffer.from(permit.paramsDigest))) throw new Error("owner_permit_params_mismatch");
const binding = verifyOwnerBinding({
record: permit.record,
publicKeyPem: readPublicKey(permit.publicKeyPath),
expectedKeyId: permit.keyId,
expected: permit.expected,
storeDir: permit.storeDir,
now,
});
if (!binding.ok) throw new Error(binding.code);
const provenanceId = permit.record.payload.provenance_id;
if (ownerUses.has(provenanceId)) throw new Error("owner_provenance_replay");
setBounded(ownerUses, provenanceId, permit.record.payload.expires_at);
return permit;
}
function toolResult(details) {
return { content: [{ type: "text", text: JSON.stringify(details) }], details };
}
function registerWriterTool(api) {
api.registerTool((toolContext) => ({
name: "aios_evidence_write",
label: "AIOS Evidence Writer",
description: "Write one Owner-authorized JSON evidence file and normally push the current task branch.",
parameters: {
type: "object",
additionalProperties: false,
required: ["provenance_id", "path", "operation", "document"],
properties: {
provenance_id: { type: "string" },
path: { type: "string" },
operation: { type: "string", enum: ["create", "update"] },
document: { type: "object" },
},
},
executionMode: "sequential",
async execute(callId, params) {
const permit = consumePermit(callId, params);
const config = toolContext?.getRuntimeConfig?.()?.plugins?.entries?.["aios-tenant-boundary"]?.config
?? api.pluginConfig ?? {};
const result = await executeEvidenceWrite({
repoRoot: requireAbsoluteConfig(config, "writerRepoRoot"),
evidencePath: params.path,
operation: params.operation,
document: params.document,
payload: permit.payload,
pushRemote: config.writerPushRemote ?? "gitea-http",
askPassPath: config.writerAskPassPath ?? "",
auditLogPath: requireAbsoluteConfig(config, "writerAuditLogPath"),
gitBinary: config.gitBinary ?? "/usr/bin/git",
});
return toolResult(result);
},
}), { name: "aios_evidence_write" });
}
function registerProtectedDispatchTool(api) {
api.registerTool(() => ({
name: "aios_protected_dispatch",
label: "AIOS Protected Dispatch",
description: "Dispatch one Owner-authenticated request with server-issued provenance.",
parameters: {
type: "object",
additionalProperties: false,
required: ["task_id", "provider", "action", "payload", "idempotency_key"],
properties: {
task_id: { type: "string" },
provider: { type: "string" },
action: { type: "string" },
payload: { type: "object" },
idempotency_key: { type: "string" },
},
},
executionMode: "sequential",
async execute(callId, params) {
const permit = consumeProtectedPermit(callId, params);
const config = api.pluginConfig ?? {};
const mode = String(config.protectedDispatchTransport || "disabled");
if (!new Set(["mock", "sandbox"]).has(mode)) throw new Error("protected_dispatch_transport_not_authorized");
const dedupeKey = `${permit.record.payload.tenant_id}\0${permit.record.payload.principal_id}\0${params.idempotency_key}`;
const prior = dispatchReceipts.get(dedupeKey);
if (prior) {
if (prior.paramsDigest !== permit.paramsDigest) throw new Error("protected_dispatch_idempotency_conflict");
return toolResult({ ...prior.result, deduplicated: true });
}
const attempts = (dispatchAttempts.get(dedupeKey)?.attempts || 0) + 1;
setBounded(dispatchAttempts, dedupeKey, { attempts, recordedAt: Date.now() });
if (config.mockFailFirstDelivery === true && attempts === 1) throw new Error("simulated_delivery_failure");
const result = {
accepted: true,
task_id: params.task_id,
provider: params.provider,
provenance_id: permit.record.payload.provenance_id,
simulation: true,
transport: mode,
real_user_impersonated: false,
attempts,
};
result.receipt_sha256 = crypto.createHash("sha256").update(canonicalize(result)).digest("hex");
setBounded(dispatchReceipts, dedupeKey, { paramsDigest: permit.paramsDigest, result, recordedAt: Date.now() });
return toolResult(result);
},
}), { name: "aios_protected_dispatch" });
}
export default {
id: "aios-tenant-boundary",
name: "AIOS Tenant Boundary",
description: "Fail-closed Owner provenance and dedicated evidence-writer boundary.",
register(api) {
const runtimeApi = createRuntimeApi(api);
api.registerHttpRoute({ path: "/v1/health", auth: "plugin", match: "exact", handler: runtimeApi.handleHealth });
api.registerHttpRoute({ path: "/v1/execute", auth: "plugin", match: "exact", handler: runtimeApi.handleExecute });
api.registerHttpRoute({ path: "/v1/commands/", auth: "plugin", match: "prefix", handler: runtimeApi.commandRequest });
registerWriterTool(api);
registerProtectedDispatchTool(api);
api.on("before_agent_run", async (event, ctx) => {
prune();
const config = pluginConfig(event, api);
const trust = classifyRun(event, ctx, config);
const key = runKey(event, ctx);
if (key) {
const channel = String(event.channelId || ctx.channelId || "unknown");
const session = String(ctx.sessionId || ctx.sessionKey || "unknown");
activeRuns.set(key, {
...trust,
agentId: String(ctx.agentId || ""),
runId: key,
executionPath: `channel:${channel}/session:${session}`,
recordedAt: Date.now(),
});
}
const conversationAllowed = trust.trusted || trust.reason === "sender_not_owner" || trust.reason === "job_id_present";
if (config.blockUnknownAgentRuns === true && !conversationAllowed) {
return { outcome: "block", reason: trust.reason, message: "Authenticated Owner provenance is required." };
}
return { outcome: "pass" };
}, { priority: 100 });
api.on("before_tool_call", async (event, ctx) => {
prune();
const config = pluginConfig(event, api);
const trust = activeRuns.get(runKey(event, ctx));
const decision = decideToolCall(event.toolName, trust, config);
if (decision.action === "block") return { block: true, blockReason: `AIOS V2R1 policy denied: ${decision.code}` };
if (decision.action === "verify_provenance") {
const authorized = authorizeEvidenceCall({ event, trust, config });
if (!authorized.ok) return { block: true, blockReason: `AIOS V2R1 provenance denied: ${authorized.code}` };
return undefined;
}
if (decision.action === "verify_owner_provenance") {
const authorized = authorizeProtectedDispatchCall({ event, ctx, trust, config });
if (!authorized.ok) return { block: true, blockReason: `AIOS Owner provenance denied: ${authorized.code}` };
return undefined;
}
if (decision.action === "approval") {
return { requireApproval: {
title: `Approve ${String(event.toolName)}`,
description: "Authenticated Owner requested a non-evidence side effect.",
severity: "warning", timeoutMs: Number(config.approvalTimeoutMs ?? 60000),
allowedDecisions: ["allow-once", "deny"], pluginId: "aios-tenant-boundary",
} };
}
return undefined;
}, { priority: 100 });
api.on("agent_end", async (event, ctx) => {
const key = runKey(event, ctx);
if (key) activeRuns.delete(key);
});
},
};
export const __state = { activeRuns, permits, ownerPermits, ownerUses, dispatchReceipts, dispatchAttempts };
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
import { fileURLToPath } from "node:url";
import {
canonicalize,
privateKeyFromMaterial,
readPrivateKeyMaterial,
signRecord,
validateOwnerProvenancePayload,
validatePayload,
} from "./provenance.js";
function parseArgs(argv) {
const result = {};
for (let index = 0; index < argv.length; index += 2) {
const key = argv[index];
const value = argv[index + 1];
if (!key?.startsWith("--") || value === undefined) throw new Error("invalid_arguments");
result[key.slice(2)] = value;
}
return result;
}
function atomicWrite(filePath, contents, mode) {
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o750 });
const temporary = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
fs.writeFileSync(temporary, contents, { mode, flag: "wx" });
fs.renameSync(temporary, filePath);
fs.chmodSync(filePath, mode);
}
function atomicCreate(filePath, contents, mode) {
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o750 });
const parent = fs.realpathSync(path.dirname(filePath));
const target = path.join(parent, path.basename(filePath));
const temporary = `${target}.${process.pid}.${crypto.randomUUID()}.tmp`;
try {
fs.writeFileSync(temporary, contents, { mode, flag: "wx" });
fs.linkSync(temporary, target);
fs.chmodSync(target, mode);
} catch (error) {
if (error?.code === "EEXIST") throw new Error("provenance_id_already_issued");
throw error;
} finally {
try { fs.unlinkSync(temporary); } catch {}
}
}
export function issueFromFiles({ payloadPath, privateKeyPath, publicKeyPath, storeDir, keyId, now = Date.now() }) {
for (const value of [payloadPath, privateKeyPath, publicKeyPath, storeDir, keyId]) {
if (typeof value !== "string" || !value) throw new Error("missing_argument");
}
const payload = JSON.parse(fs.readFileSync(payloadPath, "utf8"));
const validated = validatePayload(payload, now);
if (!validated.ok) throw new Error(validated.code);
const privateMaterial = readPrivateKeyMaterial(privateKeyPath);
fs.mkdirSync(storeDir, { recursive: true, mode: 0o750 });
const recordPath = path.join(fs.realpathSync(storeDir), `${payload.provenance_id}.json`);
const privateKey = privateKeyFromMaterial(privateMaterial);
const publicPem = crypto.createPublicKey(privateKey).export({ format: "pem", type: "spki" });
const record = signRecord(payload, privateMaterial, keyId);
atomicWrite(publicKeyPath, publicPem, 0o644);
atomicCreate(recordPath, `${canonicalize(record)}\n`, 0o640);
return payload.provenance_id;
}
export function issueOwnerProvenance({
identity,
request,
privateKeyPath,
storeDir,
keyId,
ttlMs = 120_000,
now = Date.now(),
provenanceId = `owner-${crypto.randomUUID()}`,
}) {
if (!identity || typeof identity !== "object" || Array.isArray(identity)) throw new Error("owner_identity_missing");
for (const value of [privateKeyPath, storeDir, keyId]) {
if (typeof value !== "string" || !value) throw new Error("missing_owner_issuer_argument");
}
if (!Number.isSafeInteger(ttlMs) || ttlMs < 1000 || ttlMs > 300_000) throw new Error("invalid_owner_ttl");
const privateMaterial = readPrivateKeyMaterial(privateKeyPath);
const payload = {
schema_version: "aios.owner-provenance/v2",
provenance_id: provenanceId,
tenant_id: identity.tenant_id,
principal_id: identity.principal_id,
task_id: identity.task_id,
agent_id: identity.agent_id,
run_id: identity.run_id,
execution_path: identity.execution_path,
request_digest: crypto.createHash("sha256").update(canonicalize(request)).digest("hex"),
issued_at: now,
expires_at: now + ttlMs,
key_id: keyId,
};
const validated = validateOwnerProvenancePayload(payload, now);
if (!validated.ok) throw new Error(validated.code);
fs.mkdirSync(storeDir, { recursive: true, mode: 0o750 });
const recordPath = path.join(fs.realpathSync(storeDir), `${provenanceId}.json`);
const record = signRecord(payload, privateMaterial, keyId);
atomicCreate(recordPath, `${canonicalize(record)}\n`, 0o640);
return { provenanceId, recordPath, record };
}
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isMain) {
try {
const args = parseArgs(process.argv.slice(2));
const provenanceId = issueFromFiles({
payloadPath: args.payload,
privateKeyPath: args["private-key"],
publicKeyPath: args["public-key"],
storeDir: args["store-dir"],
keyId: args["key-id"],
});
process.stdout.write(`PROVENANCE_ISSUED=${provenanceId}\n`);
} catch (error) {
process.stderr.write(`PROVENANCE_ISSUE_FAILED=${error instanceof Error ? error.message : "unknown"}\n`);
process.exitCode = 1;
}
}
@@ -0,0 +1,50 @@
{
"id": "aios-tenant-boundary",
"name": "AIOS Tenant Boundary",
"description": "Enforces Owner-signed run provenance, a dedicated evidence writer, and the signed AIOS runtime contract.",
"version": "2.1.0-rc4",
"entry": "dist/index.js",
"contracts": {
"tools": ["aios_evidence_write", "aios_protected_dispatch"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"requireExplicitTrustedSenders": { "type": "boolean", "default": true },
"requireSenderIsOwner": { "type": "boolean", "default": true },
"trustedSenderIds": { "type": "array", "items": { "type": "string" }, "default": [] },
"blockedSenderIds": { "type": "array", "items": { "type": "string" }, "default": [] },
"readOnlyTools": { "type": "array", "items": { "type": "string" } },
"requireApprovalForHumanSideEffects": { "type": "boolean", "default": true },
"blockUnknownAgentRuns": { "type": "boolean", "default": true },
"approvalTimeoutMs": { "type": "integer", "minimum": 1000, "maximum": 300000 },
"permitTtlMs": { "type": "integer", "minimum": 1000, "maximum": 120000 },
"provenanceStoreDir": { "type": "string" },
"provenancePrivateKeyPath": { "type": "string" },
"provenancePublicKeyPath": { "type": "string" },
"provenanceKeyId": { "type": "string" },
"provenanceIssuer": { "type": "string" },
"tenantId": { "type": "string" },
"ownerProvenanceTtlMs": { "type": "integer", "minimum": 1000, "maximum": 300000 },
"protectedDispatchProvider": { "type": "string", "default": "chatgpt-supervisor" },
"protectedDispatchTransport": { "type": "string", "enum": ["disabled", "mock", "sandbox"], "default": "disabled" },
"mockFailFirstDelivery": { "type": "boolean", "default": false },
"executor": { "type": "string" },
"executorInstance": { "type": "string" },
"writerRepoRoot": { "type": "string" },
"writerTaskId": { "type": "string" },
"writerTaskBranch": { "type": "string" },
"writerPushRemote": { "type": "string" },
"writerAskPassPath": { "type": "string" },
"writerAuditLogPath": { "type": "string" },
"gitBinary": { "type": "string" },
"runtimeStatePath": { "type": "string" },
"runtimeSigningPublicKeyPath": { "type": "string" },
"runtimeSigningKeyId": { "type": "string" },
"runtimeCredentialRef": { "type": "string" },
"runtimeMaxActive": { "type": "integer", "minimum": 1, "maximum": 16 },
"runtimeMaxDelayMs": { "type": "integer", "minimum": 100, "maximum": 30000 }
}
}
}
+24
View File
@@ -0,0 +1,24 @@
{
"name": "@aios/openclaw-tenant-boundary",
"version": "2.1.0-rc.4",
"type": "module",
"description": "AIOS V2R1 Tenant Cell provenance and side-effect policy boundary for OpenClaw.",
"files": [
"index.js",
"policy.js",
"provenance.js",
"writer.js",
"runtime.js",
"issuer.mjs",
"dist",
"openclaw.plugin.json"
],
"scripts": {
"test": "node --test test/*.test.js"
},
"openclaw": {
"extensions": [
"./dist/index.js"
]
}
}
+74
View File
@@ -0,0 +1,74 @@
import { ALLOWED_OPERATIONS, ALLOWED_PATHS } from "./provenance.js";
const DEFAULT_READ_ONLY_TOOLS = Object.freeze([
"read", "grep", "glob", "find", "ls", "web_search", "web_fetch",
"memory_search", "sessions_list", "sessions_history", "session_status",
]);
const GENERIC_MUTATION_TOOLS = new Set([
"exec", "shell", "bash", "write", "edit", "apply_patch", "file_write",
"computer", "process", "terminal",
]);
function strings(value) {
return Array.isArray(value)
? value.filter((item) => typeof item === "string" && item.trim()).map((item) => item.trim())
: [];
}
export function resolveSenderId(ctx = {}) {
const direct = typeof ctx.senderId === "string" ? ctx.senderId.trim() : "";
const nested = typeof ctx.channelContext?.sender?.id === "string" ? ctx.channelContext.sender.id.trim() : "";
return direct || nested || undefined;
}
export function classifyRun(event = {}, ctx = {}, config = {}) {
const senderId = resolveSenderId({ ...ctx, senderId: event.senderId ?? ctx.senderId });
const trusted = new Set(strings(config.trustedSenderIds));
const blocked = new Set(strings(config.blockedSenderIds));
if (typeof ctx.jobId === "string" && ctx.jobId) return { trusted: false, origin: "automation", reason: "job_id_present" };
if (!senderId) return { trusted: false, origin: "system_or_unknown", reason: "sender_id_missing" };
if (blocked.has(senderId)) return { trusted: false, origin: "blocked_sender", reason: "sender_blocked", senderId };
if (config.requireExplicitTrustedSenders !== false && !trusted.has(senderId)) {
return { trusted: false, origin: "untrusted_sender", reason: "sender_not_allowlisted", senderId };
}
if (config.requireSenderIsOwner !== false && event.senderIsOwner !== true) {
return { trusted: false, origin: "non_owner", reason: "sender_not_owner", senderId };
}
return { trusted: true, origin: "authenticated_owner", reason: "owner_verified", senderId };
}
export function runKey(event = {}, ctx = {}) {
const key = event.runId || ctx.runId;
return typeof key === "string" && key.trim() ? key.trim() : undefined;
}
export function decideToolCall(toolName, runTrust, config = {}) {
const name = typeof toolName === "string" ? toolName.trim().toLowerCase() : "";
if (!name) return { action: "block", code: "tool_name_missing" };
if (GENERIC_MUTATION_TOOLS.has(name)) return { action: "block", code: "dedicated_writer_required" };
const configured = strings(config.readOnlyTools).map((item) => item.toLowerCase());
const readOnly = new Set(configured.length ? configured : DEFAULT_READ_ONLY_TOOLS);
if (readOnly.has(name)) return { action: "allow", code: "read_only" };
if (!runTrust?.trusted) return { action: "block", code: runTrust?.reason || "run_provenance_missing" };
if (name === "aios_evidence_write") return { action: "verify_provenance", code: "dedicated_writer" };
if (name === "aios_protected_dispatch") return { action: "verify_owner_provenance", code: "protected_dispatch" };
if (config.requireApprovalForHumanSideEffects === false) return { action: "allow", code: "trusted_side_effect" };
return { action: "approval", code: "human_side_effect" };
}
export function validateWriterParams(params = {}, payload) {
const keys = Object.keys(params).sort();
const expected = ["document", "operation", "path", "provenance_id"];
if (keys.length !== expected.length || expected.some((key) => !keys.includes(key))) return { ok: false, code: "tool_params_schema_mismatch" };
if (params.provenance_id !== payload.provenance_id) return { ok: false, code: "provenance_id_mismatch" };
if (!ALLOWED_PATHS.includes(params.path)) return { ok: false, code: "path_not_allowed" };
if (params.operation !== "create" && params.operation !== "update") return { ok: false, code: "operation_not_allowed" };
if (!payload.allowed_paths.includes(params.path)) return { ok: false, code: "path_not_authorized" };
for (const operation of [params.operation, "git add", "git commit", "normal push current task branch"]) {
if (!payload.allowed_operations.includes(operation)) return { ok: false, code: "operation_not_authorized" };
}
let serialized;
try { serialized = JSON.stringify(params.document); } catch { return { ok: false, code: "document_not_json" }; }
if (serialized === undefined || Buffer.byteLength(serialized) > 256 * 1024) return { ok: false, code: "document_invalid_or_too_large" };
return { ok: true };
}
+234
View File
@@ -0,0 +1,234 @@
import fs from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
export const ALLOWED_PATHS = Object.freeze([
".agent/claim.json",
".agent/heartbeat.json",
".agent/progress.json",
".agent/status.json",
".agent/blockers.json",
".agent/result.json",
".agent/credential-status.json",
]);
export const ALLOWED_OPERATIONS = Object.freeze([
"create",
"update",
"git add",
"git commit",
"normal push current task branch",
]);
const PAYLOAD_FIELDS = Object.freeze([
"schema_version", "provenance_id", "run_id", "sender_id", "task_id",
"executor", "executor_instance", "claim_oid", "candidate_oid", "task_branch",
"allowed_paths", "allowed_operations", "issued_by", "issued_at", "expires_at",
]);
const OID_RE = /^[0-9a-f]{40}$/;
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
const MAX_LIFETIME_MS = 15 * 60 * 1000;
const OWNER_MAX_LIFETIME_MS = 5 * 60 * 1000;
const OWNER_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,191}$/;
const DIGEST_RE = /^[0-9a-f]{64}$/;
const OWNER_PAYLOAD_FIELDS = Object.freeze([
"schema_version", "provenance_id", "tenant_id", "principal_id", "task_id",
"agent_id", "run_id", "execution_path", "request_digest", "issued_at",
"expires_at", "key_id",
]);
function normalized(value) {
if (value === null || typeof value !== "object") return value;
if (Array.isArray(value)) return value.map(normalized);
const result = {};
for (const key of Object.keys(value).sort()) {
if (value[key] !== undefined) result[key] = normalized(value[key]);
}
return result;
}
export function canonicalize(value) {
return JSON.stringify(normalized(value));
}
function exactStringSet(actual, expected) {
if (!Array.isArray(actual) || actual.some((v) => typeof v !== "string")) return false;
if (actual.length !== expected.length || new Set(actual).size !== expected.length) return false;
const set = new Set(actual);
return expected.every((value) => set.has(value));
}
function failure(code) {
return { ok: false, code };
}
export function validatePayload(payload, now = Date.now()) {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return failure("payload_not_object");
const keys = Object.keys(payload).sort();
if (keys.length !== PAYLOAD_FIELDS.length || PAYLOAD_FIELDS.some((key) => !keys.includes(key))) {
return failure("payload_schema_mismatch");
}
if (payload.schema_version !== "aios.run-provenance/v1") return failure("schema_version_mismatch");
for (const key of ["run_id", "sender_id", "task_id", "executor", "executor_instance", "task_branch", "issued_by"]) {
if (typeof payload[key] !== "string" || !payload[key].trim()) return failure(`invalid_${key}`);
}
if (!ID_RE.test(payload.provenance_id)) return failure("invalid_provenance_id");
if (!OID_RE.test(payload.claim_oid) || !OID_RE.test(payload.candidate_oid)) return failure("invalid_oid");
if (!exactStringSet(payload.allowed_paths, ALLOWED_PATHS)) return failure("allowed_paths_mismatch");
if (!exactStringSet(payload.allowed_operations, ALLOWED_OPERATIONS)) return failure("allowed_operations_mismatch");
if (!Number.isSafeInteger(payload.issued_at) || !Number.isSafeInteger(payload.expires_at)) return failure("invalid_time");
if (payload.expires_at <= payload.issued_at || payload.expires_at - payload.issued_at > MAX_LIFETIME_MS) {
return failure("invalid_lifetime");
}
if (now < payload.issued_at) return failure("not_yet_valid");
if (now >= payload.expires_at) return failure("expired");
return { ok: true, payload };
}
export function privateKeyFromMaterial(material) {
if (!Buffer.isBuffer(material)) material = Buffer.from(material);
if (material.length === 32) {
const pkcs8Prefix = Buffer.from("302e020100300506032b657004220420", "hex");
return crypto.createPrivateKey({ key: Buffer.concat([pkcs8Prefix, material]), format: "der", type: "pkcs8" });
}
if (material.subarray(0, 5).toString() === "-----") return crypto.createPrivateKey(material);
return crypto.createPrivateKey({ key: material, format: "der", type: "pkcs8" });
}
export function signRecord(payload, privateMaterial, keyId) {
const signature = crypto.sign(null, Buffer.from(canonicalize(payload)), privateKeyFromMaterial(privateMaterial));
return {
payload,
signature: { algorithm: "Ed25519", key_id: keyId, value: signature.toString("base64url") },
};
}
export function verifyRecord(record, publicKeyPem, expectedKeyId) {
if (!record || typeof record !== "object" || Array.isArray(record)) return failure("record_not_object");
const signature = record.signature;
if (!signature || signature.algorithm !== "Ed25519" || signature.key_id !== expectedKeyId || typeof signature.value !== "string") {
return failure("signature_contract_mismatch");
}
try {
const ok = crypto.verify(
null,
Buffer.from(canonicalize(record.payload)),
crypto.createPublicKey(publicKeyPem),
Buffer.from(signature.value, "base64url"),
);
return ok ? { ok: true } : failure("signature_invalid");
} catch {
return failure("signature_invalid");
}
}
export function validateOwnerProvenancePayload(payload, now = Date.now()) {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return failure("owner_payload_not_object");
const keys = Object.keys(payload).sort();
if (keys.length !== OWNER_PAYLOAD_FIELDS.length || OWNER_PAYLOAD_FIELDS.some((key) => !keys.includes(key))) {
return failure("owner_payload_schema_mismatch");
}
if (payload.schema_version !== "aios.owner-provenance/v2") return failure("owner_schema_version_mismatch");
for (const key of ["provenance_id", "tenant_id", "principal_id", "task_id", "agent_id", "run_id", "key_id"]) {
if (typeof payload[key] !== "string" || !OWNER_ID_RE.test(payload[key])) return failure(`invalid_${key}`);
}
if (typeof payload.execution_path !== "string" || !payload.execution_path || payload.execution_path.length > 512) {
return failure("invalid_execution_path");
}
if (typeof payload.request_digest !== "string" || !DIGEST_RE.test(payload.request_digest)) {
return failure("invalid_request_digest");
}
if (!Number.isSafeInteger(payload.issued_at) || !Number.isSafeInteger(payload.expires_at)) return failure("invalid_owner_time");
if (payload.expires_at <= payload.issued_at || payload.expires_at - payload.issued_at > OWNER_MAX_LIFETIME_MS) {
return failure("invalid_owner_lifetime");
}
if (now < payload.issued_at) return failure("owner_not_yet_valid");
if (now >= payload.expires_at) return failure("owner_expired");
return { ok: true, payload };
}
export function verifyOwnerProvenanceRecord(record, publicKeyPem, expectedKeyId, now = Date.now()) {
const validated = validateOwnerProvenancePayload(record?.payload, now);
if (!validated.ok) return validated;
if (record.payload.key_id !== expectedKeyId) return failure("owner_key_id_mismatch");
const signature = verifyRecord(record, publicKeyPem, expectedKeyId);
return signature.ok ? { ok: true, payload: record.payload } : failure("owner_signature_invalid");
}
function ownerRevocationPath(storeDir, provenanceId) {
if (!path.isAbsolute(storeDir) || !OWNER_ID_RE.test(provenanceId)) throw new Error("invalid_owner_revocation_location");
return path.join(storeDir, ".revoked", provenanceId);
}
export function isOwnerProvenanceRevoked(storeDir, provenanceId) {
return fs.existsSync(ownerRevocationPath(storeDir, provenanceId));
}
export function revokeOwnerProvenance(storeDir, provenanceId, now = Date.now()) {
const target = ownerRevocationPath(storeDir, provenanceId);
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o750 });
fs.writeFileSync(target, `${now}\n`, { mode: 0o640, flag: "wx" });
return target;
}
function readRegularFile(filePath, {
unsafeCode,
unsafeModeCode = unsafeCode,
modeMask = 0o022,
encoding,
maxBytes,
expectedParent,
expectedBasename,
} = {}) {
let descriptor;
try {
descriptor = fs.openSync(filePath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
const stat = fs.fstatSync(descriptor);
if (!stat.isFile()) throw new Error(unsafeCode);
if (process.platform !== "win32" && (stat.mode & modeMask) !== 0) throw new Error(unsafeModeCode);
if (Number.isSafeInteger(maxBytes) && stat.size > maxBytes) throw new Error("provenance_record_too_large");
if (expectedParent || expectedBasename) {
const openedPath = process.platform === "linux"
? fs.realpathSync(`/proc/self/fd/${descriptor}`)
: fs.realpathSync(filePath);
if ((expectedParent && path.dirname(openedPath) !== expectedParent)
|| (expectedBasename && path.basename(openedPath) !== expectedBasename)) {
throw new Error(unsafeCode);
}
}
return fs.readFileSync(descriptor, encoding ? { encoding } : undefined);
} catch (error) {
if (error instanceof Error && [unsafeCode, unsafeModeCode, "provenance_record_too_large"].includes(error.message)) throw error;
throw new Error(unsafeCode);
} finally {
if (descriptor !== undefined) fs.closeSync(descriptor);
}
}
export function loadStoredRecord(storeDir, provenanceId) {
if (!path.isAbsolute(storeDir) || !ID_RE.test(provenanceId)) throw new Error("invalid_provenance_location");
const storeReal = fs.realpathSync(storeDir);
const target = path.join(storeReal, `${provenanceId}.json`);
const raw = readRegularFile(target, {
unsafeCode: "unsafe_provenance_file",
encoding: "utf8",
maxBytes: 128 * 1024,
expectedParent: storeReal,
expectedBasename: `${provenanceId}.json`,
});
return JSON.parse(raw);
}
export function readPublicKey(publicKeyPath) {
if (!path.isAbsolute(publicKeyPath)) throw new Error("invalid_public_key_path");
return readRegularFile(publicKeyPath, { unsafeCode: "unsafe_public_key", encoding: "utf8" });
}
export function readPrivateKeyMaterial(privateKeyPath) {
if (!path.isAbsolute(privateKeyPath)) throw new Error("invalid_private_key_path");
return readRegularFile(privateKeyPath, {
unsafeCode: "unsafe_private_key",
unsafeModeCode: "unsafe_private_key_mode",
modeMask: 0o077,
});
}
+280
View File
@@ -0,0 +1,280 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
const CONTRACT_VERSION = "aios.openclaw.command.v1";
const MAX_BODY_BYTES = 64 * 1024;
const CLOCK_SKEW_MS = 300_000;
const COMMAND_FIELDS = [
"command_id", "tenant_id", "task_id", "trace_id", "action", "payload",
"idempotency_key", "created_at", "expires_at", "contract_version", "key_id",
];
function isObject(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function sorted(value) {
if (Array.isArray(value)) return value.map(sorted);
if (!isObject(value)) return value;
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sorted(value[key])]));
}
function canonical(value) {
return JSON.stringify(sorted(value));
}
function nowIso() {
return new Date().toISOString();
}
function sendJson(res, status, body) {
const data = Buffer.from(JSON.stringify(body));
res.writeHead(status, {
"Content-Type": "application/json; charset=utf-8",
"Content-Length": String(data.length),
"Cache-Control": "no-store",
"X-Content-Type-Options": "nosniff",
});
res.end(data);
return true;
}
async function readJson(req) {
const chunks = [];
let size = 0;
for await (const chunk of req) {
size += chunk.length;
if (size > MAX_BODY_BYTES) throw new Error("body_too_large");
chunks.push(chunk);
}
let value;
try { value = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}"); }
catch { throw new Error("invalid_json"); }
if (!isObject(value)) throw new Error("invalid_json_object");
return value;
}
function requireAbsolute(config, name) {
const value = config[name];
if (typeof value !== "string" || !path.isAbsolute(value)) throw new Error(`config_${name}_missing`);
return value;
}
function appendRecord(statePath, record) {
const parent = path.dirname(statePath);
fs.mkdirSync(parent, { recursive: true, mode: 0o750 });
const fd = fs.openSync(statePath, "a", 0o640);
try {
fs.writeSync(fd, `${JSON.stringify(record)}\n`, null, "utf8");
fs.fsyncSync(fd);
} finally {
fs.closeSync(fd);
}
fs.chmodSync(statePath, 0o640);
}
function validateCommand(value, expectedKeyId) {
const allowed = new Set([...COMMAND_FIELDS, "signature"]);
if (Object.keys(value).some((key) => !allowed.has(key))) throw new Error("unknown_command_field");
for (const name of COMMAND_FIELDS.filter((name) => name !== "expires_at" && name !== "payload")) {
if (typeof value[name] !== "string" || !value[name].trim()) throw new Error(`invalid_${name}`);
}
if (!isObject(value.payload)) throw new Error("invalid_payload");
if (typeof value.expires_at !== "string" || !value.expires_at) throw new Error("expires_at_required");
if (typeof value.signature !== "string" || !value.signature) throw new Error("signature_missing");
if (value.contract_version !== CONTRACT_VERSION) throw new Error("contract_version_rejected");
if (value.key_id !== expectedKeyId) throw new Error("key_id_rejected");
const created = Date.parse(value.created_at);
const expires = Date.parse(value.expires_at);
if (!Number.isFinite(created) || !Number.isFinite(expires)) throw new Error("timestamp_invalid");
if (created - Date.now() > CLOCK_SKEW_MS) throw new Error("created_at_in_future");
if (expires <= created || expires - created > 300_000) throw new Error("expiry_window_rejected");
if (Date.now() - expires > CLOCK_SKEW_MS) throw new Error("command_expired");
const unsigned = Object.fromEntries(COMMAND_FIELDS.map((name) => [name, value[name]]));
return { unsigned, expires };
}
function verifySignature(command, publicKey, expectedKeyId) {
const { unsigned, expires } = validateCommand(command, expectedKeyId);
let signature;
try { signature = Buffer.from(command.signature, "base64url"); }
catch { throw new Error("signature_invalid"); }
if (!crypto.verify(null, Buffer.from(canonical(unsigned)), publicKey, signature)) throw new Error("signature_invalid");
return { digest: crypto.createHash("sha256").update(canonical(unsigned)).digest("hex"), expires };
}
function resultFor(command, state, { output = null, error = null, startedAt, finishedAt = nowIso() } = {}) {
return {
command_id: command.command_id, tenant_id: command.tenant_id,
task_id: command.task_id, trace_id: command.trace_id, state, output, error,
started_at: startedAt ?? nowIso(), finished_at: finishedAt,
runtime_id: "openclaw-aios-runtime-v1", attempts: 1,
};
}
function loadState(statePath) {
const commands = new Map();
const idempotency = new Map();
if (!fs.existsSync(statePath)) return { commands, idempotency };
for (const line of fs.readFileSync(statePath, "utf8").split("\n").filter(Boolean)) {
let record;
try { record = JSON.parse(line); } catch { throw new Error("runtime_state_corrupt"); }
if (!isObject(record) || typeof record.command_id !== "string") throw new Error("runtime_state_corrupt");
commands.set(record.command_id, record);
if (record.tenant_id && record.idempotency_key) idempotency.set(`${record.tenant_id}\0${record.idempotency_key}`, record.command_id);
}
return { commands, idempotency };
}
export function createRuntimeApi(api) {
const config = api.pluginConfig ?? {};
const statePath = requireAbsolute(config, "runtimeStatePath");
const publicKeyPath = requireAbsolute(config, "runtimeSigningPublicKeyPath");
const expectedKeyId = String(config.runtimeSigningKeyId || "");
const credentialRef = String(config.runtimeCredentialRef || "");
if (!expectedKeyId) throw new Error("config_runtimeSigningKeyId_missing");
if (!credentialRef) throw new Error("config_runtimeCredentialRef_missing");
const publicKey = crypto.createPublicKey(fs.readFileSync(publicKeyPath));
const maxActive = Math.max(1, Math.min(Number(config.runtimeMaxActive || 4), 16));
const maxDelayMs = Math.max(100, Math.min(Number(config.runtimeMaxDelayMs || 30_000), 30_000));
const state = loadState(statePath);
const active = new Map();
for (const record of state.commands.values()) {
if (record.result?.state === "RUNNING") {
const reconciled = { ...record, result: { ...record.result, state: "FAILED", finished_at: nowIso(), error: { code: "restart_interrupted", message: "execution outcome is unknown after runtime restart" } } };
appendRecord(statePath, reconciled);
state.commands.set(record.command_id, reconciled);
}
}
function requireCredential(req) {
const supplied = String(req.headers["x-aios-credential-ref"] || "");
const left = Buffer.from(supplied);
const right = Buffer.from(credentialRef);
if (left.length !== right.length || !crypto.timingSafeEqual(left, right)) throw new Error("credential_ref_rejected");
}
function persist(command, digest, result) {
const record = {
command_id: command.command_id, tenant_id: command.tenant_id,
task_id: command.task_id, trace_id: command.trace_id,
idempotency_key: command.idempotency_key, digest, result, recorded_at: nowIso(),
};
appendRecord(statePath, record);
state.commands.set(command.command_id, record);
state.idempotency.set(`${command.tenant_id}\0${command.idempotency_key}`, command.command_id);
return record;
}
async function execute(command, digest, expires) {
const startedAt = nowIso();
persist(command, digest, resultFor(command, "RUNNING", { startedAt, finishedAt: null }));
if (command.action === "status") {
const completed = resultFor(command, "COMPLETED", { startedAt, output: { status: "ok", runtime: "openclaw-plugin", side_effects: false } });
persist(command, digest, completed);
return completed;
}
if (command.action !== "acceptance.delay") throw new Error("unsupported_action");
const delayMs = Number(command.payload.delay_ms);
if (!Number.isInteger(delayMs) || delayMs < 0 || delayMs > maxDelayMs) {
const failed = resultFor(command, "FAILED", { startedAt, error: { code: "invalid_delay", message: "delay_ms is outside the allowed range" } });
persist(command, digest, failed);
return failed;
}
const controller = new AbortController();
active.set(command.command_id, controller);
let timedOut = false;
const deadlineMs = Math.max(0, Math.min(maxDelayMs, expires - Date.now()));
const deadline = setTimeout(() => { timedOut = true; controller.abort(); }, deadlineMs);
try {
await new Promise((resolve, reject) => {
const timer = setTimeout(resolve, delayMs);
controller.signal.addEventListener("abort", () => { clearTimeout(timer); reject(new Error("aborted")); }, { once: true });
});
const completed = resultFor(command, "COMPLETED", { startedAt, output: { delayed_ms: delayMs, side_effects: false } });
persist(command, digest, completed);
return completed;
} catch {
const terminal = resultFor(command, timedOut ? "TIMED_OUT" : "CANCELLED", {
startedAt,
error: timedOut ? { code: "runtime_timeout", message: "runtime deadline exceeded" } : { code: "cancelled", message: "cancelled by AIOS" },
});
persist(command, digest, terminal);
return terminal;
} finally {
clearTimeout(deadline);
active.delete(command.command_id);
}
}
async function handleExecute(req, res) {
if (req.method !== "POST") return sendJson(res, 405, { error: "method_not_allowed" });
try {
requireCredential(req);
const command = await readJson(req);
const { digest, expires } = verifySignature(command, publicKey, expectedKeyId);
const prior = state.commands.get(command.command_id);
if (prior) {
if (prior.digest !== digest) return sendJson(res, 409, { error: "command_id_conflict" });
return sendJson(res, 200, prior.result);
}
const idemId = state.idempotency.get(`${command.tenant_id}\0${command.idempotency_key}`);
if (idemId) {
const idem = state.commands.get(idemId);
if (!idem || idem.digest !== digest) return sendJson(res, 409, { error: "idempotency_conflict" });
return sendJson(res, 200, idem.result);
}
if (!new Set(["status", "acceptance.delay"]).has(command.action)) return sendJson(res, 422, { error: "unsupported_action" });
if (active.size >= maxActive) return sendJson(res, 429, { error: "runtime_capacity_exceeded" });
return sendJson(res, 200, await execute(command, digest, expires));
} catch (error) {
const code = error instanceof Error ? error.message : "request_rejected";
const status = code === "body_too_large" ? 413 : code.startsWith("config_") || code === "runtime_state_corrupt" ? 503 : 401;
return sendJson(res, status, { error: code });
}
}
function commandRequest(req, res) {
try {
requireCredential(req);
const url = new URL(req.url, "http://localhost");
const match = url.pathname.match(/^\/v1\/commands\/([^/]+)(?:\/(status|cancel))?$/);
if (!match) return false;
const commandId = decodeURIComponent(match[1]);
const operation = match[2] || "status";
const record = state.commands.get(commandId);
if (!record) return sendJson(res, 404, { error: "command_not_found" });
const tenantId = String(url.searchParams.get("tenant_id") || "");
if (!tenantId || tenantId !== record.tenant_id) return sendJson(res, 404, { error: "command_not_found" });
if (operation === "status" && req.method === "GET") {
return sendJson(res, 200, record.result);
}
if (operation === "cancel" && req.method === "POST") {
const controller = active.get(commandId);
if (controller) controller.abort();
return sendJson(res, 200, { accepted: Boolean(controller), command_id: commandId });
}
return sendJson(res, 405, { error: "method_not_allowed" });
} catch (error) {
return sendJson(res, 401, { error: error instanceof Error ? error.message : "request_rejected" });
}
}
return {
handleHealth(req, res) {
if (req.method !== "GET") return sendJson(res, 405, { error: "method_not_allowed" });
return sendJson(res, 200, {
runtime: "openclaw", runtime_id: "openclaw-aios-runtime-v1", status: "healthy",
version: "2.1.0-rc.4", heartbeat_at: nowIso(), active_tasks: active.size,
workspace_isolated: true, credential_ref_configured: true,
details: { transport: "openclaw_plugin_http", allowed_actions: ["status", "acceptance.delay"] },
});
},
handleExecute,
commandRequest,
};
}
export const __test = { canonical, validateCommand, verifySignature, loadState };
@@ -0,0 +1,455 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import crypto from "node:crypto";
import { spawnSync } from "node:child_process";
import plugin, {
authorizeEvidenceCall,
authorizeProtectedDispatchCall,
consumePermit,
consumeProtectedPermit,
prune,
verifyOwnerBinding,
__state,
} from "../index.js";
import { decideToolCall, validateWriterParams } from "../policy.js";
import {
ALLOWED_OPERATIONS, ALLOWED_PATHS, canonicalize, revokeOwnerProvenance,
loadStoredRecord, readPublicKey, signRecord, validateOwnerProvenancePayload, validatePayload,
verifyOwnerProvenanceRecord, verifyRecord,
} from "../provenance.js";
import { buildPushArgs, executeEvidenceWrite } from "../writer.js";
import { issueFromFiles, issueOwnerProvenance } from "../issuer.mjs";
function fixture(now = Date.now()) {
return {
schema_version: "aios.run-provenance/v1",
provenance_id: "prov-test-1",
run_id: "run-test-1",
sender_id: "owner-1",
task_id: "task/openclaw/test",
executor: "openclaw",
executor_instance: "gateway-test",
claim_oid: "a".repeat(40),
candidate_oid: "b".repeat(40),
task_branch: "task/openclaw/test",
allowed_paths: [...ALLOWED_PATHS],
allowed_operations: [...ALLOWED_OPERATIONS],
issued_by: "AIOS_OWNER",
issued_at: now - 1000,
expires_at: now + 60_000,
};
}
function keyFixture() {
const { privateKey, publicKey } = crypto.generateKeyPairSync("ed25519");
return {
privateDer: privateKey.export({ format: "der", type: "pkcs8" }),
publicPem: publicKey.export({ format: "pem", type: "spki" }),
};
}
function ownerIdentity(overrides = {}) {
return {
tenant_id: "v2r1c2-wechat-acceptance",
principal_id: "synthetic-owner",
task_id: "OPENCLAW-TASK-V2R1C2-UNIFIED-PORTAL-DEPLOY",
agent_id: "portal-agent",
run_id: "run-owner-1",
execution_path: "channel:weixin/session:synthetic-session",
...overrides,
};
}
function protectedParams(overrides = {}) {
return {
task_id: "OPENCLAW-TASK-V2R1C2-UNIFIED-PORTAL-DEPLOY",
provider: "chatgpt-supervisor",
action: "candidate.status",
payload: { message: "synthetic acceptance" },
idempotency_key: "synthetic-message-1",
...overrides,
};
}
test("Ed25519 record verifies and tampering is rejected", () => {
const payload = fixture();
const keys = keyFixture();
const record = signRecord(payload, keys.privateDer, "key-1");
assert.equal(verifyRecord(record, keys.publicPem, "key-1").ok, true);
record.payload.task_id = "tampered";
assert.equal(verifyRecord(record, keys.publicPem, "key-1").ok, false);
});
test("payload rejects expiry and set drift", () => {
const now = Date.now();
const expired = fixture(now);
expired.issued_at = now - 2000;
expired.expires_at = now - 1000;
assert.equal(validatePayload(expired, now).code, "expired");
const drift = fixture(now);
drift.allowed_paths = drift.allowed_paths.slice(1);
assert.equal(validatePayload(drift, now).code, "allowed_paths_mismatch");
});
test("issuer accepts protected raw 32-byte material and refuses duplicate ids", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "aios-issuer-"));
const keys = keyFixture();
const payload = fixture();
const payloadPath = path.join(root, "payload.json");
const privateKeyPath = path.join(root, "private.bin");
const publicKeyPath = path.join(root, "public.pem");
const storeDir = path.join(root, "store");
fs.writeFileSync(payloadPath, canonicalize(payload));
fs.writeFileSync(privateKeyPath, keys.privateDer.subarray(-32), { mode: 0o600 });
if (process.platform !== "win32") fs.chmodSync(privateKeyPath, 0o600);
assert.equal(issueFromFiles({ payloadPath, privateKeyPath, publicKeyPath, storeDir, keyId: "key-1" }), payload.provenance_id);
const record = JSON.parse(fs.readFileSync(path.join(storeDir, `${payload.provenance_id}.json`), "utf8"));
assert.equal(verifyRecord(record, fs.readFileSync(publicKeyPath, "utf8"), "key-1").ok, true);
assert.throws(() => issueFromFiles({ payloadPath, privateKeyPath, publicKeyPath, storeDir, keyId: "key-1" }), /already_issued/);
});
test("provenance and key readers reject symbolic links", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "aios-provenance-link-"));
const store = path.join(root, "store");
fs.mkdirSync(store);
const target = path.join(root, "target.json");
fs.writeFileSync(target, "{}", { mode: 0o600 });
const recordLink = path.join(store, "prov-link.json");
const keyLink = path.join(root, "public-link.pem");
fs.symlinkSync(target, recordLink);
fs.symlinkSync(target, keyLink);
assert.throws(() => loadStoredRecord(store, "prov-link"), /unsafe_provenance_file/);
assert.throws(() => readPublicKey(keyLink), /unsafe_public_key/);
});
test("provenance reads stay pinned to the opened descriptor during path replacement", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "aios-provenance-race-"));
const store = path.join(root, "store");
fs.mkdirSync(store);
const recordPath = path.join(store, "prov-race.json");
fs.writeFileSync(recordPath, '{"marker":"trusted"}', { mode: 0o600 });
const originalRead = fs.readFileSync;
fs.readFileSync = function interceptedRead(target, ...args) {
if (target === recordPath) {
fs.renameSync(recordPath, `${recordPath}.trusted`);
fs.writeFileSync(recordPath, '{"marker":"substituted"}', { mode: 0o600 });
}
return originalRead.call(this, target, ...args);
};
try {
assert.equal(loadStoredRecord(store, "prov-race").marker, "trusted");
} finally {
fs.readFileSync = originalRead;
}
});
test("security caches prune expired entries and enforce a hard bound", () => {
for (const value of Object.values(__state)) value.clear();
const now = Date.now();
__state.ownerUses.set("expired", now - 1);
__state.dispatchReceipts.set("expired", { recordedAt: now - 2 * 60 * 60 * 1000 });
__state.dispatchAttempts.set("expired", { attempts: 1, recordedAt: now - 2 * 60 * 60 * 1000 });
for (let index = 0; index < 5000; index += 1) {
__state.ownerUses.set(`live-${index}`, now + 60_000);
}
prune(now);
assert.equal(__state.ownerUses.has("expired"), false);
assert.equal(__state.dispatchReceipts.has("expired"), false);
assert.equal(__state.dispatchAttempts.has("expired"), false);
assert.equal(__state.ownerUses.size, 4096);
});
test("generic mutation tools stay blocked", () => {
for (const name of ["exec", "shell", "bash", "write", "edit", "apply_patch", "file_write"]) {
assert.deepEqual(decideToolCall(name, { trusted: true }, {}), { action: "block", code: "dedicated_writer_required" });
}
});
test("plugin registers the native writer factory and required hook names", () => {
const hooks = [];
const routes = [];
const factories = {};
const root = fs.mkdtempSync(path.join(os.tmpdir(), "aios-runtime-register-"));
const keys = keyFixture();
const publicKeyPath = path.join(root, "public.pem");
fs.writeFileSync(publicKeyPath, keys.publicPem);
const api = {
pluginConfig: {
runtimeStatePath: path.join(root, "state.jsonl"),
runtimeSigningPublicKeyPath: publicKeyPath,
runtimeSigningKeyId: "key-1",
runtimeCredentialRef: "secretref:test",
},
registerTool(value, options) { factories[options.name] = value; },
registerHttpRoute(value) { routes.push(value); },
on(name) { hooks.push(name); },
};
plugin.register(api);
const tool = factories.aios_evidence_write({});
assert.equal(tool.name, "aios_evidence_write");
assert.equal(tool.label, "AIOS Evidence Writer");
assert.equal(tool.executionMode, "sequential");
assert.equal(factories.aios_protected_dispatch({}).name, "aios_protected_dispatch");
assert.deepEqual(routes.map((route) => route.path), ["/v1/health", "/v1/execute", "/v1/commands/"]);
assert.deepEqual(hooks.sort(), ["agent_end", "before_agent_run", "before_tool_call"]);
});
test("manifest explicitly declares the dedicated tool contract", () => {
const manifest = JSON.parse(fs.readFileSync(new URL("../openclaw.plugin.json", import.meta.url), "utf8"));
assert.deepEqual(manifest.contracts.tools, ["aios_evidence_write", "aios_protected_dispatch"]);
});
test("writer params reject unauthorized paths and operations", () => {
const payload = fixture();
assert.equal(validateWriterParams({ provenance_id: payload.provenance_id, path: "README.md", operation: "update", document: {} }, payload).code, "path_not_allowed");
assert.equal(validateWriterParams({ provenance_id: payload.provenance_id, path: ".agent/status.json", operation: "exec", document: {} }, payload).code, "operation_not_allowed");
});
test("authorization binds signature, run, sender, params, and one-use permit", () => {
__state.permits.clear();
const root = fs.mkdtempSync(path.join(os.tmpdir(), "aios-prov-"));
const store = path.join(root, "store");
fs.mkdirSync(store);
const keys = keyFixture();
const payload = fixture();
const record = signRecord(payload, keys.privateDer, "key-1");
fs.writeFileSync(path.join(store, `${payload.provenance_id}.json`), canonicalize(record), { mode: 0o600 });
const publicKeyPath = path.join(root, "public.pem");
fs.writeFileSync(publicKeyPath, keys.publicPem, { mode: 0o600 });
const params = { provenance_id: payload.provenance_id, path: ".agent/status.json", operation: "update", document: { state: "ok" } };
const event = { toolName: "aios_evidence_write", toolCallId: "call-1", runId: payload.run_id, params };
const config = {
provenanceStoreDir: store, provenancePublicKeyPath: publicKeyPath, provenanceKeyId: "key-1",
writerTaskBranch: payload.task_branch, writerTaskId: payload.task_id, provenanceIssuer: payload.issued_by,
executor: payload.executor, executorInstance: payload.executor_instance,
};
const authorized = authorizeEvidenceCall({ event, trust: { trusted: true, senderId: payload.sender_id }, config });
assert.equal(authorized.ok, true, authorized.code);
assert.equal(consumePermit("call-1", params).payload.run_id, payload.run_id);
assert.throws(() => consumePermit("call-1", params), /permit_missing/);
const wrongRun = { ...event, toolCallId: "call-2", runId: "wrong" };
assert.equal(authorizeEvidenceCall({ event: wrongRun, trust: { trusted: true, senderId: payload.sender_id }, config }).code, "run_id_mismatch");
const wrongSender = { trusted: true, senderId: "wrong" };
assert.equal(authorizeEvidenceCall({ event: { ...event, toolCallId: "call-3" }, trust: wrongSender, config }).code, "sender_id_mismatch");
});
test("Owner provenance v2 binds identity, digest, expiry, revocation, and signature", () => {
const now = Date.now();
const root = fs.mkdtempSync(path.join(os.tmpdir(), "aios-owner-prov-"));
const storeDir = path.join(root, "store");
const privateKeyPath = path.join(root, "private.bin");
const publicKeyPath = path.join(root, "public.pem");
const keys = keyFixture();
fs.writeFileSync(privateKeyPath, keys.privateDer.subarray(-32), { mode: 0o600 });
fs.writeFileSync(publicKeyPath, keys.publicPem, { mode: 0o644 });
if (process.platform !== "win32") fs.chmodSync(privateKeyPath, 0o600);
const params = protectedParams();
const issued = issueOwnerProvenance({
identity: ownerIdentity(), request: params, privateKeyPath, storeDir,
keyId: "owner-key-1", ttlMs: 60_000, now,
});
const verified = verifyOwnerProvenanceRecord(issued.record, keys.publicPem, "owner-key-1", now);
assert.equal(verified.ok, true, verified.code);
assert.equal(validateOwnerProvenancePayload(issued.record.payload, now).ok, true);
const expected = { ...ownerIdentity(), request_digest: issued.record.payload.request_digest };
assert.equal(verifyOwnerBinding({
record: issued.record, publicKeyPem: keys.publicPem, expectedKeyId: "owner-key-1",
expected, storeDir, now,
}).ok, true);
const mismatches = {
tenant_id: "other-tenant",
principal_id: "synthetic-user-a",
task_id: "other-task",
agent_id: "other-agent",
run_id: "other-run",
execution_path: "channel:weixin/session:other",
request_digest: "0".repeat(64),
};
for (const [field, value] of Object.entries(mismatches)) {
const record = signRecord({ ...issued.record.payload, [field]: value }, keys.privateDer, "owner-key-1");
assert.equal(verifyOwnerBinding({
record, publicKeyPem: keys.publicPem, expectedKeyId: "owner-key-1",
expected, storeDir, now,
}).code, `${field}_mismatch`);
}
const tampered = structuredClone(issued.record);
tampered.payload.task_id = "other-task";
assert.equal(verifyOwnerProvenanceRecord(tampered, keys.publicPem, "owner-key-1", now).code, "owner_signature_invalid");
assert.equal(verifyOwnerProvenanceRecord(issued.record, keys.publicPem, "wrong-key", now).code, "owner_key_id_mismatch");
revokeOwnerProvenance(storeDir, issued.provenanceId, now);
assert.equal(verifyOwnerBinding({
record: issued.record, publicKeyPem: keys.publicPem, expectedKeyId: "owner-key-1",
expected, storeDir, now,
}).code, "owner_revoked");
});
function ownerPluginFixture({ mockFailFirstDelivery = false, ownerTtlMs = 60_000 } = {}) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "aios-owner-plugin-"));
const keys = keyFixture();
const privateKeyPath = path.join(root, "private.bin");
const publicKeyPath = path.join(root, "public.pem");
fs.writeFileSync(privateKeyPath, keys.privateDer.subarray(-32), { mode: 0o600 });
fs.writeFileSync(publicKeyPath, keys.publicPem, { mode: 0o644 });
if (process.platform !== "win32") fs.chmodSync(privateKeyPath, 0o600);
const hooks = {};
const factories = {};
const api = {
pluginConfig: {
runtimeStatePath: path.join(root, "runtime.jsonl"),
runtimeSigningPublicKeyPath: publicKeyPath,
runtimeSigningKeyId: "owner-key-1",
runtimeCredentialRef: "secretref:test",
provenanceStoreDir: path.join(root, "store"),
provenancePrivateKeyPath: privateKeyPath,
provenancePublicKeyPath: publicKeyPath,
provenanceKeyId: "owner-key-1",
tenantId: "v2r1c2-wechat-acceptance",
protectedDispatchProvider: "chatgpt-supervisor",
protectedDispatchTransport: "mock",
mockFailFirstDelivery,
ownerProvenanceTtlMs: ownerTtlMs,
blockUnknownAgentRuns: true,
requireExplicitTrustedSenders: true,
requireSenderIsOwner: true,
trustedSenderIds: ["synthetic-owner", "synthetic-user-a", "synthetic-user-b"],
blockedSenderIds: ["blocked-user"],
},
registerTool(factory, options) { factories[options.name] = factory; },
registerHttpRoute() {},
on(name, callback) { hooks[name] = callback; },
};
plugin.register(api);
return { root, keys, api, hooks, factories };
}
async function startSyntheticRun(fixtureValue, { senderId, senderIsOwner, runId }) {
const event = {
prompt: "synthetic acceptance message", messages: [], accountId: "sandbox",
channelId: "weixin", senderId, senderIsOwner,
};
const ctx = { runId, agentId: "portal-agent", sessionId: `session-${senderId}`, channelId: "weixin" };
return { decision: await fixtureValue.hooks.before_agent_run(event, ctx), ctx };
}
test("ordinary synthetic users pass conversation gate but cannot issue Owner provenance", async () => {
__state.activeRuns.clear();
const owner = ownerPluginFixture();
const user = await startSyntheticRun(owner, { senderId: "synthetic-user-a", senderIsOwner: false, runId: "run-user-a" });
assert.equal(user.decision.outcome, "pass");
const params = protectedParams({ idempotency_key: "user-a-1" });
const blocked = await owner.hooks.before_tool_call(
{ toolName: "aios_protected_dispatch", toolCallId: "user-call", runId: "run-user-a", params },
user.ctx,
);
assert.equal(blocked.block, true);
assert.match(blocked.blockReason, /sender_not_owner/);
const unknown = await startSyntheticRun(owner, { senderId: "unknown-user", senderIsOwner: false, runId: "run-unknown" });
assert.equal(unknown.decision.outcome, "block");
});
test("Owner protected dispatch auto-issues provenance, retries, deduplicates, and rejects tamper/provider/replay", async () => {
for (const value of Object.values(__state)) value.clear();
const fixtureValue = ownerPluginFixture({ mockFailFirstDelivery: true });
const owner = await startSyntheticRun(fixtureValue, { senderId: "synthetic-owner", senderIsOwner: true, runId: "run-owner-1" });
assert.equal(owner.decision.outcome, "pass");
const tool = fixtureValue.factories.aios_protected_dispatch({});
const params = protectedParams();
const wrongProvider = await fixtureValue.hooks.before_tool_call(
{ toolName: "aios_protected_dispatch", toolCallId: "provider-call", runId: "run-owner-1", params: { ...params, provider: "openclaw" } },
owner.ctx,
);
assert.equal(wrongProvider.block, true);
assert.match(wrongProvider.blockReason, /provider_mismatch/);
assert.equal(await fixtureValue.hooks.before_tool_call(
{ toolName: "aios_protected_dispatch", toolCallId: "call-1", runId: "run-owner-1", params }, owner.ctx,
), undefined);
await assert.rejects(tool.execute("call-1", params), /simulated_delivery_failure/);
assert.equal(await fixtureValue.hooks.before_tool_call(
{ toolName: "aios_protected_dispatch", toolCallId: "call-2", runId: "run-owner-1", params }, owner.ctx,
), undefined);
const delivered = await tool.execute("call-2", params);
assert.equal(delivered.details.simulation, true);
assert.equal(delivered.details.transport, "mock");
assert.equal(delivered.details.real_user_impersonated, false);
assert.match(delivered.details.receipt_sha256, /^[0-9a-f]{64}$/);
assert.equal(await fixtureValue.hooks.before_tool_call(
{ toolName: "aios_protected_dispatch", toolCallId: "call-3", runId: "run-owner-1", params }, owner.ctx,
), undefined);
const duplicate = await tool.execute("call-3", params);
assert.equal(duplicate.details.deduplicated, true);
assert.equal(duplicate.details.receipt_sha256, delivered.details.receipt_sha256);
await assert.rejects(tool.execute("call-3", params), /owner_permit_missing/);
const tamperParams = protectedParams({ idempotency_key: "tamper-1" });
assert.equal(await fixtureValue.hooks.before_tool_call(
{ toolName: "aios_protected_dispatch", toolCallId: "tamper-call", runId: "run-owner-1", params: tamperParams }, owner.ctx,
), undefined);
await assert.rejects(
tool.execute("tamper-call", { ...tamperParams, payload: { message: "changed" } }),
/owner_permit_params_mismatch/,
);
});
test("Owner permit fails closed after expiry or revocation", async () => {
for (const value of Object.values(__state)) value.clear();
const fixtureValue = ownerPluginFixture({ ownerTtlMs: 1000 });
const owner = await startSyntheticRun(fixtureValue, { senderId: "synthetic-owner", senderIsOwner: true, runId: "run-owner-expiry" });
const params = protectedParams({ idempotency_key: "expiry-1" });
const now = Date.now();
const trust = __state.activeRuns.get("run-owner-expiry");
const event = { toolName: "aios_protected_dispatch", toolCallId: "expiry-call", runId: "run-owner-expiry", params };
assert.equal(authorizeProtectedDispatchCall({ event, ctx: owner.ctx, trust, config: fixtureValue.api.pluginConfig, now }).ok, true);
assert.throws(() => consumeProtectedPermit("expiry-call", params, now + 1500), /owner_expired/);
const revokedParams = protectedParams({ idempotency_key: "revoke-1" });
const revokedEvent = { toolName: "aios_protected_dispatch", toolCallId: "revoke-call", runId: "run-owner-expiry", params: revokedParams };
const issued = authorizeProtectedDispatchCall({ event: revokedEvent, ctx: owner.ctx, trust, config: fixtureValue.api.pluginConfig, now });
assert.equal(issued.ok, true, issued.code);
revokeOwnerProvenance(fixtureValue.api.pluginConfig.provenanceStoreDir, issued.provenance_id, now);
assert.throws(() => consumeProtectedPermit("revoke-call", revokedParams, now), /owner_revoked/);
});
function git(cwd, args) {
const result = spawnSync("git", args, { cwd, encoding: "utf8", shell: false });
if (result.status !== 0) throw new Error(result.stderr || "git failed");
return (result.stdout || "").trim();
}
test("writer commits only one evidence path and explicitly pushes current branch", async (t) => {
if (spawnSync("git", ["--version"], { shell: false }).status !== 0) return t.skip("git unavailable");
const root = fs.mkdtempSync(path.join(os.tmpdir(), "aios-writer-"));
const repo = path.join(root, "repo");
const bare = path.join(root, "remote.git");
fs.mkdirSync(repo);
git(root, ["init", "--bare", bare]);
git(repo, ["init", "-b", "task/openclaw/test"]);
git(repo, ["config", "user.name", "AIOS Test"]);
git(repo, ["config", "user.email", "aios-test@example.invalid"]);
fs.mkdirSync(path.join(repo, ".agent"));
fs.writeFileSync(path.join(repo, ".agent", "status.json"), "{\"state\":\"before\"}\n");
git(repo, ["add", ".agent/status.json"]);
git(repo, ["commit", "-m", "base"]);
const claim = git(repo, ["rev-parse", "HEAD"]);
git(repo, ["remote", "add", "origin", bare]);
git(repo, ["push", "origin", "refs/heads/task/openclaw/test:refs/heads/task/openclaw/test"]);
const payload = fixture();
payload.claim_oid = claim;
payload.candidate_oid = claim;
const result = await executeEvidenceWrite({
repoRoot: repo, evidencePath: ".agent/status.json", operation: "update",
document: { state: "after" }, payload, pushRemote: "origin",
auditLogPath: path.join(root, "audit", "writer.jsonl"), gitBinary: "git",
});
assert.equal(result.outcome, "pushed");
assert.equal(git(root, ["--git-dir", bare, "rev-parse", "refs/heads/task/openclaw/test"]), result.commit_oid);
assert.deepEqual(buildPushArgs("origin", payload.task_branch), ["push", "origin", "refs/heads/task/openclaw/test:refs/heads/task/openclaw/test"]);
});
@@ -0,0 +1,132 @@
import test from "node:test";
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { Readable } from "node:stream";
import { createRuntimeApi, __test } from "../runtime.js";
function fixture() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "aios-runtime-"));
const keys = crypto.generateKeyPairSync("ed25519");
const publicKeyPath = path.join(root, "public.pem");
fs.writeFileSync(publicKeyPath, keys.publicKey.export({ format: "pem", type: "spki" }));
const api = createRuntimeApi({ pluginConfig: {
runtimeStatePath: path.join(root, "state.jsonl"),
runtimeSigningPublicKeyPath: publicKeyPath,
runtimeSigningKeyId: "runtime-key-1",
runtimeCredentialRef: "secretref:runtime-test",
runtimeMaxDelayMs: 3000,
} });
return { root, keys, api };
}
function signed(keys, overrides = {}) {
const command = {
command_id: `cmd-${crypto.randomUUID()}`,
tenant_id: "tenant-a",
task_id: "task-a",
trace_id: "trace-a",
action: "status",
payload: { side_effects: false },
idempotency_key: `idem-${crypto.randomUUID()}`,
created_at: new Date().toISOString(),
expires_at: new Date(Date.now() + 60_000).toISOString(),
contract_version: "aios.openclaw.command.v1",
key_id: "runtime-key-1",
...overrides,
};
command.signature = crypto.sign(null, Buffer.from(__test.canonical(command)), keys.privateKey).toString("base64url");
return command;
}
function request(method, url, body = null) {
const req = Readable.from(body === null ? [] : [Buffer.from(JSON.stringify(body))]);
req.method = method;
req.url = url;
req.headers = { "x-aios-credential-ref": "secretref:runtime-test" };
return req;
}
function response() {
let resolve;
const done = new Promise((value) => { resolve = value; });
return {
status: 0,
headers: {},
body: null,
done,
writeHead(status, headers) { this.status = status; this.headers = headers; },
end(data) { this.body = JSON.parse(Buffer.from(data).toString("utf8")); resolve(this); },
};
}
async function call(handler, req) {
const res = response();
await handler(req, res);
return res.done;
}
test("signed status executes, persists, and replays idempotently", async () => {
const { keys, api, root } = fixture();
const command = signed(keys);
const first = await call(api.handleExecute, request("POST", "/v1/execute", command));
assert.equal(first.status, 200);
assert.equal(first.body.state, "COMPLETED");
assert.equal(first.body.output.runtime, "openclaw-plugin");
const second = await call(api.handleExecute, request("POST", "/v1/execute", command));
assert.deepEqual(second.body, first.body);
assert.ok(fs.statSync(path.join(root, "state.jsonl")).size > 0);
});
test("tamper, expiry, and conflicting identity are rejected", async () => {
const { keys, api } = fixture();
const tampered = signed(keys);
tampered.tenant_id = "tenant-b";
assert.equal((await call(api.handleExecute, request("POST", "/v1/execute", tampered))).status, 401);
const expired = signed(keys, {
created_at: new Date(Date.now() - 700_000).toISOString(),
expires_at: new Date(Date.now() - 600_000).toISOString(),
});
assert.equal((await call(api.handleExecute, request("POST", "/v1/execute", expired))).status, 401);
});
test("status is tenant-scoped and unknown actions fail closed", async () => {
const { keys, api } = fixture();
const rejected = signed(keys, { action: "arbitrary.exec" });
const denied = await call(api.handleExecute, request("POST", "/v1/execute", rejected));
assert.equal(denied.status, 422);
assert.equal(denied.body.error, "unsupported_action");
const command = signed(keys);
await call(api.handleExecute, request("POST", "/v1/execute", command));
const wrong = await call(api.commandRequest, request("GET", `/v1/commands/${command.command_id}?tenant_id=tenant-b`));
assert.equal(wrong.status, 404);
const correct = await call(api.commandRequest, request("GET", `/v1/commands/${command.command_id}?tenant_id=tenant-a`));
assert.equal(correct.body.command_id, command.command_id);
});
test("active delay supports deterministic cancellation", async () => {
const { keys, api } = fixture();
const command = signed(keys, { action: "acceptance.delay", payload: { delay_ms: 2000 } });
const executeResponse = response();
const execution = api.handleExecute(request("POST", "/v1/execute", command), executeResponse);
await new Promise((resolve) => setTimeout(resolve, 30));
const cancelled = await call(api.commandRequest, request("POST", `/v1/commands/${command.command_id}/cancel?tenant_id=tenant-a`));
assert.equal(cancelled.body.accepted, true);
await execution;
const terminal = await executeResponse.done;
assert.equal(terminal.body.state, "CANCELLED");
});
test("signed deadline produces TIMED_OUT", async () => {
const { keys, api } = fixture();
const command = signed(keys, {
action: "acceptance.delay",
payload: { delay_ms: 1000 },
expires_at: new Date(Date.now() + 40).toISOString(),
});
const result = await call(api.handleExecute, request("POST", "/v1/execute", command));
assert.equal(result.body.state, "TIMED_OUT");
});
+143
View File
@@ -0,0 +1,143 @@
import fs from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
import { spawnSync } from "node:child_process";
import { ALLOWED_PATHS, canonicalize } from "./provenance.js";
function safeFsync(fd) {
try { fs.fsyncSync(fd); }
catch (error) {
if (process.platform !== "win32" || error?.code !== "EPERM") throw error;
}
}
function runGit(args, { cwd, gitBinary = "/usr/bin/git", env = {} }) {
const result = spawnSync(gitBinary, ["-c", `safe.directory=${cwd}`, ...args], {
cwd,
shell: false,
encoding: "utf8",
env: { ...process.env, GIT_TERMINAL_PROMPT: "0", ...env },
});
if (result.error || result.status !== 0) throw new Error(`git_${args[0]}_failed`);
return result.stdout || "";
}
function cleanBranch(branch) {
const value = typeof branch === "string" ? branch.replace(/^refs\/heads\//, "") : "";
if (!value || value.startsWith("-") || value.includes("..") || value.endsWith(".") || value.includes("@{")) {
throw new Error("invalid_branch");
}
for (const forbidden of ["~", "^", ":", "?", "*", "[", "\\", " ", "\t", "\n"]) {
if (value.includes(forbidden)) throw new Error("invalid_branch");
}
return value;
}
export function buildPushArgs(remote, branch) {
const clean = cleanBranch(branch);
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(remote)) throw new Error("invalid_remote");
return ["push", remote, `refs/heads/${clean}:refs/heads/${clean}`];
}
function ensureSafeTarget(repoRoot, evidencePath) {
if (!ALLOWED_PATHS.includes(evidencePath)) throw new Error("path_not_allowed");
const root = fs.realpathSync(repoRoot);
const target = path.resolve(root, evidencePath);
if (!target.startsWith(`${root}${path.sep}`)) throw new Error("path_escape");
let cursor = root;
for (const segment of path.relative(root, path.dirname(target)).split(path.sep).filter(Boolean)) {
cursor = path.join(cursor, segment);
if (fs.existsSync(cursor) && fs.lstatSync(cursor).isSymbolicLink()) throw new Error("symlink_path");
}
if (fs.existsSync(target) && fs.lstatSync(target).isSymbolicLink()) throw new Error("symlink_path");
return { root, target };
}
function atomicWrite(target, document, previousMode) {
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o770 });
const temporary = path.join(path.dirname(target), `.${path.basename(target)}.${crypto.randomUUID()}.tmp`);
const mode = previousMode ?? 0o660;
fs.writeFileSync(temporary, `${canonicalize(document)}\n`, { flag: "wx", mode });
const handle = fs.openSync(temporary, "r");
try { safeFsync(handle); } finally { fs.closeSync(handle); }
fs.renameSync(temporary, target);
fs.chmodSync(target, mode);
}
function appendAudit(auditLogPath, entry) {
if (!path.isAbsolute(auditLogPath)) throw new Error("invalid_audit_log_path");
fs.mkdirSync(path.dirname(auditLogPath), { recursive: true, mode: 0o750 });
const fd = fs.openSync(auditLogPath, "a", 0o640);
try {
fs.writeSync(fd, `${canonicalize(entry)}\n`);
safeFsync(fd);
} finally { fs.closeSync(fd); }
}
function gitEnvironment(askPassPath) {
if (!askPassPath) return {};
const stat = fs.lstatSync(askPassPath);
if (!path.isAbsolute(askPassPath) || !stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o022) !== 0) {
throw new Error("unsafe_askpass");
}
return { GIT_ASKPASS: askPassPath, GIT_ASKPASS_REQUIRE: "force", DISPLAY: "aios" };
}
function restoreBeforeCommit(target, existed, backup, mode, root, relative, gitOptions) {
try { runGit(["restore", "--staged", "--", relative], gitOptions); } catch { /* best effort */ }
if (existed) {
fs.writeFileSync(target, backup, { mode });
} else if (fs.existsSync(target)) {
fs.unlinkSync(target);
}
}
export async function executeEvidenceWrite({
repoRoot, evidencePath, operation, document, payload, pushRemote = "gitea-http",
askPassPath = "", auditLogPath, gitBinary = "/usr/bin/git",
}) {
const { root, target } = ensureSafeTarget(repoRoot, evidencePath);
const relative = path.relative(root, target).replace(/\\/g, "/");
const gitOptions = { cwd: root, gitBinary, env: gitEnvironment(askPassPath) };
const branch = cleanBranch(payload.task_branch);
if (runGit(["symbolic-ref", "--short", "HEAD"], gitOptions).trim() !== branch) throw new Error("branch_mismatch");
if (runGit(["rev-parse", "HEAD"], gitOptions).trim() !== payload.claim_oid) throw new Error("head_mismatch");
runGit(["merge-base", "--is-ancestor", payload.candidate_oid, payload.claim_oid], gitOptions);
if (runGit(["status", "--porcelain", "--untracked-files=all"], gitOptions).trim()) throw new Error("worktree_not_clean");
const existed = fs.existsSync(target);
if (operation === "create" && existed) throw new Error("path_exists_for_create");
if (operation === "update" && !existed) throw new Error("path_missing_for_update");
const backup = existed ? fs.readFileSync(target) : null;
const priorMode = existed ? fs.statSync(target).mode & 0o777 : 0o660;
let committed = false;
let commitOid = null;
try {
atomicWrite(target, document, priorMode);
runGit(["add", "--", relative], gitOptions);
const staged = runGit(["diff", "--cached", "--name-only", "-z"], gitOptions).split("\0").filter(Boolean);
if (staged.length !== 1 || staged[0] !== relative) throw new Error("staged_scope_mismatch");
runGit(["commit", "-m", `chore(aios): provenance ${payload.provenance_id} ${path.basename(relative)}`], gitOptions);
committed = true;
commitOid = runGit(["rev-parse", "HEAD"], gitOptions).trim();
runGit(buildPushArgs(pushRemote, branch), gitOptions);
appendAudit(auditLogPath, {
schema_version: "aios.writer-audit/v1", provenance_id: payload.provenance_id,
task_id: payload.task_id, run_id: payload.run_id, path: relative, operation,
branch, commit_oid: commitOid, outcome: "pushed", recorded_at: Date.now(),
});
return { path: relative, operation, branch, commit_oid: commitOid, outcome: "pushed" };
} catch (error) {
const code = error instanceof Error ? error.message : "writer_failed";
if (!committed) restoreBeforeCommit(target, existed, backup, priorMode, root, relative, gitOptions);
try {
appendAudit(auditLogPath, {
schema_version: "aios.writer-audit/v1", provenance_id: payload.provenance_id,
task_id: payload.task_id, run_id: payload.run_id, path: relative, operation,
branch, commit_oid: commitOid, outcome: committed ? "committed_push_blocked" : "rejected",
error_code: code, recorded_at: Date.now(),
});
} catch { /* the original failure remains authoritative */ }
throw new Error(code);
}
}
+26
View File
@@ -0,0 +1,26 @@
# AIOS V2R1C2 ChatGPT Supervisor Takeover
- Task ID: `OPENCLAW-TASK-V2R1C2-UNIFIED-PORTAL-DEPLOY`
- Plan ID: `AIOS-V2R1C2-UNIFIED-PORTAL-TAKEOVER-20260722`
- Source branch: `patch/codex/AIOS-V2R1C2-UNIFIED-PORTAL-TAKEOVER-20260722`
- Source OID: `ENV:AIOS_RELEASE_SOURCE_OID`
- Candidate service: `aios-v2r1c2-portal.service`
- Candidate URL: `http://127.0.0.1:18921`
- Production URL: `http://127.0.0.1:18920` (immutable during candidate work)
## Identity and authority
The root stage uses the explicit `chatgpt-supervisor` provider. It must never
claim or serialize itself as `openclaw`. The existing OpenClaw plan remains an
unchanged historical handoff and is not used for this takeover.
The provider is limited to candidate deployment, health checks, rollback,
acceptance, WeChat simulation, and evidence production. It cannot create a
release tag, modify `main`, or mutate the V2R1C1 production service or state.
## Gate
The stage remains blocked until every required evidence item has a verified
SHA256, the synthetic WeChat transcript is explicitly marked as simulation,
and both candidate and production health are rechecked after rollback drill.
Even a passing candidate cannot be promoted without explicit Owner approval.
@@ -0,0 +1,32 @@
# AIOS V2R1C2 OpenClaw Online Execution
- Task ID: `AIOS-V2R1C2-OPENCLAW-CANDIDATE-DEPLOY-20260721`
- Plan ID: `AIOS-V2R1C2-UNIFIED-PORTAL-RELEASE-20260721`
- Source branch: `patch/chatgpt/AIOS-V2R1C1-UNIFIED-WEB-ROLE-PORTAL-20260721`
- Source OID: `b6e714e3fcb4eb19030cbf310dbe44c5f4e478fa`
- Execution plan: `plans/AIOS-V2R1C2-UNIFIED-PORTAL-RELEASE.json`
- Candidate service: `aios-v2r1c2-portal.service`
- Candidate URL: `http://127.0.0.1:18921`
- Production URL: `http://127.0.0.1:18920` (must remain unchanged)
## OpenClaw action
Use `tools/aios_execution_plan_cli.py` to discover, register, claim, start, heartbeat and complete the OpenClaw root stage. Run `tools/aios_v2r1c2_acceptance.py --online` against the isolated candidate, submit real Evidence SHA256 values, and acknowledge real STARTED/SUCCEEDED WeChat receipts. Do not create a tag or modify production.
## Progress supervision
Report every state transition and blocker to ChatGPT with:
- `task_id`
- `executor_run_id`
- current state and step
- latest heartbeat
- Evidence collected or missing
- notification delivery status
- exact error and failing command, if any
ChatGPT should supervise the plan gate and prepare to intervene when the task is not registered, misses its start deadline, loses heartbeat, fails Acceptance, cannot deliver WeChat notifications, or risks touching V2R1C1 production.
## Completion condition
OpenClaw stage is complete only when Execution Truth is `SUCCEEDED`, all required Evidence hashes exist, STARTED and SUCCEEDED notifications are `DELIVERED`, and the OpenClaw stage gate passes. The overall V2R1C2 release remains blocked until Hermes, Codex and Owner approval complete.
+346
View File
@@ -0,0 +1,346 @@
"""Acceptance-level tests for the portable executor release mechanism."""
from __future__ import annotations
import json
import sys
import tempfile
import time
import unittest
from datetime import timedelta
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
APP = ROOT / "active-runtime" / "app"
if str(APP) not in sys.path:
sys.path.insert(0, str(APP))
from aios_v2.execution_plan import (
ExecutionPlanCoordinator,
ExecutionPlanManifest,
ExecutionStageManifest,
WAITING_DEPENDENCY,
)
from aios_v2.executor_catalog import FileExecutorCatalogStore
from aios_v2.executor_protocol import (
DescriptorOnlyProvider,
ExecutorProviderDescriptor,
default_provider_registry,
)
from aios_v2.executor_worker import UnifiedExecutorWorker
from aios_v2.task_execution_registry import ExecutionTaskManifest, parse_time
from aios_v2.official_executor_providers import official_provider_factories
from aios_web.console import PORTAL_HTML
DIGEST_A = "a" * 64
DIGEST_B = "b" * 64
class EvidenceRunner:
def run_step(self, *, stage, **_: object):
return {stage.required_evidence[0]: DIGEST_A}
class FailOnceSender:
def __init__(self) -> None:
self.calls = 0
def send(self, _notification) -> str:
self.calls += 1
if self.calls == 1:
raise RuntimeError("simulated WeChat transport failure")
return DIGEST_B
class PortableExecutorReleaseTests(unittest.TestCase):
def setUp(self) -> None:
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name)
self.database = self.root / "truth.sqlite3"
self.catalog_file = self.root / "executor-catalog.json"
def providers(self):
store = FileExecutorCatalogStore(self.catalog_file)
store.initialize_defaults()
return store.registry(provider_factories=official_provider_factories())
def plan(self) -> ExecutionPlanManifest:
return ExecutionPlanManifest(
plan_id="test-portable-release",
source_branch="patch/test",
source_oid="1" * 40,
target_version="VTEST",
stages=(
ExecutionStageManifest(
stage_id="root",
task_id="task-root",
executor_type="openclaw",
role="candidate_deploy",
steps=("deploy",),
required_evidence=("deploy_log",),
notification_events=("STARTED", "SUCCEEDED", "FAILED"),
),
ExecutionStageManifest(
stage_id="child",
task_id="task-child",
executor_type="hermes",
role="runtime_validate",
depends_on=("root",),
steps=("validate",),
required_evidence=("runtime_report",),
notification_events=("STARTED", "SUCCEEDED", "FAILED"),
),
),
)
def coordinator(self) -> ExecutionPlanCoordinator:
return ExecutionPlanCoordinator.from_file(
self.database,
providers=self.providers(),
)
def test_catalog_round_trip_and_tamper_detection(self) -> None:
store = FileExecutorCatalogStore(self.catalog_file)
snapshot = store.initialize_defaults()
self.assertEqual("aios.executor.catalog.v1", snapshot.catalog["schema_version"])
package = store.export_migration_package(
source_environment="candidate",
required_executor_types=("openclaw", "hermes", "codex"),
)
destination = self.root / "destination.json"
imported = FileExecutorCatalogStore.import_migration_package(
package,
destination_path=destination,
destination_environment="recovery",
provider_factories=official_provider_factories(),
)
self.assertEqual(snapshot.catalog_sha256, imported.catalog_sha256)
tampered = dict(package)
tampered["source_environment"] = "tampered"
with self.assertRaises(ValueError):
FileExecutorCatalogStore.import_migration_package(
tampered,
destination_path=self.root / "rejected.json",
destination_environment="recovery",
)
def test_future_provider_registers_without_core_change(self) -> None:
registry = default_provider_registry()
descriptor = ExecutorProviderDescriptor(
executor_type="kubernetes-job",
adapter_version="1.0.0",
trust_roles=("kubernetes_executor",),
capabilities=("job", "rollout"),
)
registry.register(DescriptorOnlyProvider(descriptor))
self.assertEqual(
"kubernetes-job",
registry.get("kubernetes-job").descriptor.executor_type,
)
self.assertTrue(
registry.trusted(
executor_type="kubernetes-job",
roles=("kubernetes_executor",),
)
)
def test_dependency_stage_is_not_declared_early(self) -> None:
coordinator = self.coordinator()
coordinator.declare(self.plan())
self.assertEqual("DECLARED", coordinator.task_coordinator.registry.get("task-root").state.value)
with self.assertRaises(KeyError):
coordinator.task_coordinator.registry.get("task-child")
snapshot = coordinator.snapshot("test-portable-release")
child = next(item for item in snapshot["stages"] if item["stage"]["stage_id"] == "child")
self.assertEqual(WAITING_DEPENDENCY, child["truth"]["state"])
def test_chatgpt_supervisor_is_explicit_and_codex_is_unchanged(self) -> None:
registry = self.providers()
supervisor = registry.get("chatgpt-supervisor").descriptor
self.assertEqual(("chatgpt_supervisor",), supervisor.trust_roles)
self.assertEqual(
{
"acceptance",
"deploy",
"evidence",
"healthcheck",
"rollback",
"wechat_simulation",
},
set(supervisor.capabilities),
)
self.assertFalse(supervisor.metadata["production_mutation_allowed"])
codex = registry.get("codex").descriptor
self.assertEqual(("codex", "codex_executor"), codex.trust_roles)
self.assertNotIn("deploy", codex.capabilities)
self.assertNotIn("rollback", codex.capabilities)
def test_takeover_plan_declares_supervisor_root_and_dependency_gate(self) -> None:
plan_path = ROOT / "plans" / "AIOS-V2R1C2-UNIFIED-PORTAL-TAKEOVER.json"
plan = ExecutionPlanManifest.from_dict(
json.loads(plan_path.read_text(encoding="utf-8"))
)
root = plan.stage("chatgpt-supervisor-candidate-deploy")
self.assertEqual("chatgpt-supervisor", root.executor_type)
self.assertEqual(
"OPENCLAW-TASK-V2R1C2-UNIFIED-PORTAL-DEPLOY", root.task_id
)
self.assertFalse(plan.metadata["production_mutation_allowed"])
coordinator = self.coordinator()
coordinator.declare(plan)
with self.assertRaises(KeyError):
coordinator.task_coordinator.registry.get(
"AIOS-V2R1C2-HERMES-RUNTIME-VALIDATION-20260722"
)
gate = coordinator.dependency_gate(
plan.plan_id, "hermes-runtime-validation"
)
self.assertFalse(gate["passed"])
def test_notification_retry_and_aggregate_gate(self) -> None:
coordinator = self.coordinator()
plan = self.plan()
coordinator.declare(plan)
sender = FailOnceSender()
worker = UnifiedExecutorWorker(
plan_coordinator=coordinator,
task_coordinator=coordinator.task_coordinator,
providers=coordinator.providers,
executor_type="openclaw",
executor_id="openclaw-test-worker",
step_runner=EvidenceRunner(),
notification_sender=sender,
)
result = worker.run_stage(
plan_id=plan.plan_id,
stage_id="root",
executor_run_id="openclaw-run-1",
)
self.assertEqual("SUCCEEDED", result.state)
self.assertGreaterEqual(sender.calls, 3)
gate = coordinator.task_coordinator.gate("task-root")
self.assertTrue(gate["passed"])
self.assertEqual(["STARTED", "SUCCEEDED"], gate["delivered_notifications"])
activated = coordinator.activate_ready(plan.plan_id)
self.assertEqual("task-child", activated[0]["task_id"])
self.assertEqual("DECLARED", coordinator.task_coordinator.registry.get("task-child").state.value)
self.assertFalse(coordinator.gate(plan.plan_id)["passed"])
def test_missing_evidence_blocks_success(self) -> None:
coordinator = self.coordinator()
coordinator.declare(self.plan())
task = coordinator.task_coordinator
task.register("task-root", executor_run_id="run-1")
task.claim("task-root", executor_run_id="run-1", executor_id="worker-1")
task.start("task-root", executor_run_id="run-1", executor_id="worker-1")
task.heartbeat(
"task-root",
executor_run_id="run-1",
executor_id="worker-1",
current_step=1,
)
with self.assertRaises(RuntimeError):
task.succeed(
"task-root",
executor_run_id="run-1",
executor_id="worker-1",
evidence={},
)
def test_not_started_retry_is_audited_and_status_is_consistent(self) -> None:
coordinator = self.coordinator()
task = coordinator.task_coordinator
manifest = ExecutionTaskManifest(
task_id="task-retry-not-started",
executor="openclaw",
source_branch="patch/test",
source_oid="2" * 40,
target_version="VTEST",
steps=("deploy",),
start_deadline_seconds=1,
required_evidence=("deploy_log",),
)
task.declare(manifest)
time.sleep(1.1)
expired = task.status("task-retry-not-started")
self.assertEqual("NOT_STARTED", expired["record"]["state"])
self.assertEqual("NOT_STARTED", expired["gate"]["state"])
replacement = ExecutionTaskManifest.from_dict(
{**manifest.as_dict(), "source_oid": "3" * 40}
)
superseded = task.declare(replacement)
self.assertEqual("NOT_STARTED", superseded.state.value)
self.assertEqual("3" * 40, superseded.manifest.source_oid)
retried = task.retry_not_started(
"task-retry-not-started",
executor_run_id="retry-run-1",
)
self.assertEqual("REGISTERED", retried.state.value)
self.assertEqual("retry-run-1", retried.executor_run_id)
self.assertEqual({}, retried.error)
events = task.registry.events("task-retry-not-started")
supersede_event = next(
item for item in events
if item["event_type"] == "MANIFEST_SUPERSEDED"
)
self.assertEqual("2" * 40, supersede_event["details"]["previous_source_oid"])
self.assertEqual("3" * 40, supersede_event["details"]["source_oid"])
retry_event = next(item for item in events if item["event_type"] == "RETRIED")
self.assertEqual("NOT_STARTED", retry_event["details"]["previous_state"])
with self.assertRaises(RuntimeError):
task.retry_not_started(
"task-retry-not-started",
executor_run_id="retry-run-2",
)
def test_plan_source_oid_supersession_requires_not_started_root(self) -> None:
coordinator = self.coordinator()
plan = ExecutionPlanManifest(
plan_id="test-plan-supersession",
source_branch="patch/test",
source_oid="4" * 40,
target_version="VTEST",
stages=(
ExecutionStageManifest(
stage_id="root",
task_id="task-plan-supersession",
executor_type="openclaw",
role="candidate_deploy",
steps=("deploy",),
required_evidence=("deploy_log",),
start_deadline_seconds=1,
),
),
)
coordinator.declare(plan)
replacement = ExecutionPlanManifest.from_dict(
{**plan.as_dict(), "source_oid": "5" * 40}
)
with self.assertRaises(RuntimeError):
coordinator.declare(replacement)
record = coordinator.task_coordinator.registry.get(
"task-plan-supersession"
)
coordinator.task_coordinator.registry.evaluate_deadlines(
record.task_id,
now=parse_time(record.declared_at) + timedelta(seconds=2),
)
coordinator.declare(replacement)
self.assertEqual(
"5" * 40,
coordinator.plan_store.get(plan.plan_id).source_oid,
)
updated = coordinator.task_coordinator.registry.get(record.task_id)
self.assertEqual("5" * 40, updated.manifest.source_oid)
events = coordinator.task_coordinator.registry.events(record.task_id)
self.assertIn("MANIFEST_SUPERSEDED", {item["event_type"] for item in events})
def test_execution_center_has_no_diff_markers(self) -> None:
self.assertIn('id="execution"', PORTAL_HTML)
self.assertIn("async function execution()", PORTAL_HTML)
self.assertNotIn("\n+function executionStage", PORTAL_HTML)
self.assertNotIn("\n+async function execution", PORTAL_HTML)
if __name__ == "__main__":
unittest.main()
+253
View File
@@ -0,0 +1,253 @@
#!/usr/bin/env python3
"""Portable AIOS execution-plan and executor lifecycle CLI.
OpenClaw, Hermes, Codex and future Providers use this same entrypoint. The CLI
records execution truth in the shared SQLite store; it never treats a task file,
process exit or log line as proof of successful execution.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
from pathlib import Path
from typing import Any, Mapping, Sequence
ROOT = Path(__file__).resolve().parents[1]
APP = ROOT / "active-runtime" / "app"
if str(APP) not in sys.path:
sys.path.insert(0, str(APP))
from aios_v2.execution_plan import ExecutionPlanCoordinator, ExecutionPlanManifest
from aios_v2.executor_catalog import FileExecutorCatalogStore
from aios_v2.official_executor_providers import official_provider_factories
_GIT_OID = re.compile(r"^[0-9a-f]{40}$")
def emit(value: Any) -> None:
print(json.dumps(value, ensure_ascii=False, sort_keys=True, indent=2, default=str))
def load_object(path: str | Path) -> Mapping[str, Any]:
value = json.loads(Path(path).read_text(encoding="utf-8"))
if not isinstance(value, Mapping):
raise ValueError(f"JSON object required: {path}")
return dict(value)
def resolve_env(value: Any, label: str) -> Any:
if not isinstance(value, str) or not value.startswith("ENV:"):
return value
name = value[4:].strip()
resolved = str(os.getenv(name, "")).strip()
if not name or not resolved:
raise RuntimeError(f"required environment variable is not set: {name or label}")
return resolved
def load_plan(path: str | Path) -> ExecutionPlanManifest:
value = dict(load_object(path))
for key in ("source_branch", "source_oid", "target_version"):
value[key] = resolve_env(value.get(key), key)
source_oid = str(value.get("source_oid") or "").strip().lower()
if not _GIT_OID.fullmatch(source_oid):
raise ValueError("source_oid must resolve to a 40-character Git OID")
value["source_oid"] = source_oid
return ExecutionPlanManifest.from_dict(value)
def coordinator(args: argparse.Namespace) -> ExecutionPlanCoordinator:
catalog = FileExecutorCatalogStore(args.catalog_file)
catalog.initialize_defaults()
providers = catalog.registry(provider_factories=official_provider_factories())
return ExecutionPlanCoordinator.from_file(args.state_file, providers=providers)
def declared(args: argparse.Namespace) -> tuple[ExecutionPlanCoordinator, ExecutionPlanManifest]:
value = coordinator(args)
plan = load_plan(args.plan_file)
value.declare(plan)
return value, plan
def evidence(args: argparse.Namespace) -> Mapping[str, str]:
raw = load_object(args.evidence_file)
return {str(key): str(value) for key, value in raw.items()}
def command_declare(args: argparse.Namespace) -> int:
value, plan = declared(args)
emit(value.snapshot(plan.plan_id))
return 0
def command_snapshot(args: argparse.Namespace) -> int:
value, plan = declared(args)
emit(value.snapshot(plan.plan_id))
return 0
def command_discover(args: argparse.Namespace) -> int:
value, plan = declared(args)
items = value.discover_ready(plan_id=plan.plan_id, executor_type=args.executor)
emit({"plan_id": plan.plan_id, "executor_type": args.executor, "count": len(items), "items": items})
return 0
def command_gate(args: argparse.Namespace) -> int:
value, plan = declared(args)
result = value.gate(plan.plan_id)
emit(result)
return 0 if result["passed"] else 3
def command_status(args: argparse.Namespace) -> int:
value, _ = declared(args)
emit(value.task_coordinator.status(args.task_id))
return 0
def command_register(args: argparse.Namespace) -> int:
value, _ = declared(args)
emit(value.task_coordinator.register(args.task_id, executor_run_id=args.executor_run_id).as_dict())
return 0
def command_claim(args: argparse.Namespace) -> int:
value, _ = declared(args)
emit(value.task_coordinator.claim(args.task_id, executor_run_id=args.executor_run_id, executor_id=args.executor_id).as_dict())
return 0
def command_start(args: argparse.Namespace) -> int:
value, _ = declared(args)
emit(value.task_coordinator.start(args.task_id, executor_run_id=args.executor_run_id, executor_id=args.executor_id).as_dict())
return 0
def command_heartbeat(args: argparse.Namespace) -> int:
value, _ = declared(args)
emit(value.task_coordinator.heartbeat(args.task_id, executor_run_id=args.executor_run_id, executor_id=args.executor_id, current_step=args.current_step).as_dict())
return 0
def command_succeed(args: argparse.Namespace) -> int:
value, _ = declared(args)
emit(value.task_coordinator.succeed(args.task_id, executor_run_id=args.executor_run_id, executor_id=args.executor_id, evidence=evidence(args)).as_dict())
return 0
def command_fail(args: argparse.Namespace) -> int:
value, _ = declared(args)
error = load_object(args.error_file)
supplied = evidence(args) if args.evidence_file else None
emit(value.task_coordinator.fail(args.task_id, executor_run_id=args.executor_run_id, executor_id=args.executor_id, error=error, evidence=supplied).as_dict())
return 0
def command_notifications(args: argparse.Namespace) -> int:
value, _ = declared(args)
items = value.task_coordinator.notifications.list(task_id=args.task_id, status=args.status)
emit({"count": len(items), "items": [item.as_dict() for item in items]})
return 0
def command_ack(args: argparse.Namespace) -> int:
value, _ = declared(args)
emit(value.task_coordinator.notifications.acknowledge(args.notification_id, receipt_sha256=args.receipt_sha256).as_dict())
return 0
def add_common(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--state-file", default=os.getenv("AIOS_EXTERNAL_TASK_STORE_FILE", "/var/lib/aios-v2r1c2-portal/external-task-truth.sqlite3"))
parser.add_argument("--catalog-file", default=os.getenv("AIOS_EXECUTOR_CATALOG_FILE", "/var/lib/aios-v2r1c2-portal/executor-catalog.json"))
parser.add_argument("--plan-file", default=os.getenv("AIOS_EXECUTION_PLAN_FILE", str(ROOT / "plans" / "AIOS-V2R1C2-UNIFIED-PORTAL-RELEASE.json")))
def add_lifecycle(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--task-id", required=True)
parser.add_argument("--executor-run-id", required=True)
parser.add_argument("--executor-id", required=True)
def parser() -> argparse.ArgumentParser:
root = argparse.ArgumentParser(description="AIOS portable execution plan CLI")
commands = root.add_subparsers(dest="command", required=True)
for name, handler in (
("declare", command_declare),
("snapshot", command_snapshot),
("gate", command_gate),
):
item = commands.add_parser(name)
add_common(item)
item.set_defaults(handler=handler)
discover = commands.add_parser("discover")
add_common(discover)
discover.add_argument("--executor", required=True)
discover.set_defaults(handler=command_discover)
status = commands.add_parser("status")
add_common(status)
status.add_argument("--task-id", required=True)
status.set_defaults(handler=command_status)
register = commands.add_parser("register")
add_common(register)
register.add_argument("--task-id", required=True)
register.add_argument("--executor-run-id", required=True)
register.set_defaults(handler=command_register)
for name, handler in (("claim", command_claim), ("start", command_start)):
item = commands.add_parser(name)
add_common(item)
add_lifecycle(item)
item.set_defaults(handler=handler)
heartbeat = commands.add_parser("heartbeat")
add_common(heartbeat)
add_lifecycle(heartbeat)
heartbeat.add_argument("--current-step", type=int, required=True)
heartbeat.set_defaults(handler=command_heartbeat)
succeed = commands.add_parser("succeed")
add_common(succeed)
add_lifecycle(succeed)
succeed.add_argument("--evidence-file", required=True)
succeed.set_defaults(handler=command_succeed)
fail = commands.add_parser("fail")
add_common(fail)
add_lifecycle(fail)
fail.add_argument("--error-file", required=True)
fail.add_argument("--evidence-file")
fail.set_defaults(handler=command_fail)
notifications = commands.add_parser("notifications")
add_common(notifications)
notifications.add_argument("--task-id")
notifications.add_argument("--status")
notifications.set_defaults(handler=command_notifications)
ack = commands.add_parser("ack-notification")
add_common(ack)
ack.add_argument("--notification-id", required=True)
ack.add_argument("--receipt-sha256", required=True)
ack.set_defaults(handler=command_ack)
return root
def main(argv: Sequence[str] | None = None) -> int:
args = parser().parse_args(argv)
try:
return int(args.handler(args))
except Exception as exc:
print(json.dumps({"status": "error", "error": type(exc).__name__, "message": str(exc)}, ensure_ascii=False), file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
+393
View File
@@ -0,0 +1,393 @@
#!/usr/bin/env python3
"""Offline and online acceptance gate for the AIOS V2R1C2 portal candidate.
Offline mode validates source syntax, the immutable execution plan, the
portable-executor test suite and deployment assets. Online mode additionally
checks the isolated candidate on port 18921. The script writes a machine-readable
Evidence report whose SHA256 can be submitted to the Execution Truth Registry.
It never changes production, creates a tag or marks a plan stage successful.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import subprocess
import sys
import tempfile
import urllib.request
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable, Mapping, Sequence
ROOT = Path(__file__).resolve().parents[1]
APP = ROOT / "active-runtime" / "app"
PLAN = ROOT / "plans" / "AIOS-V2R1C2-UNIFIED-PORTAL-RELEASE.json"
TAKEOVER_PLAN = ROOT / "plans" / "AIOS-V2R1C2-UNIFIED-PORTAL-TAKEOVER.json"
SYSTEMD = ROOT / "deploy" / "aios-v2" / "systemd" / "aios-v2r1c2-portal.service"
ENV_TEMPLATE = ROOT / "deploy" / "aios-v2" / "systemd" / "aios-v2r1c2-portal.env.example"
PORTABLE_TEST = ROOT / "tests" / "web" / "test_portable_executor_release.py"
_GIT_OID = re.compile(r"^[0-9a-f]{40}$")
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
def canonical_bytes(value: Any) -> bytes:
return json.dumps(
value,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
default=str,
).encode("utf-8")
def sha256_bytes(value: bytes) -> str:
return hashlib.sha256(value).hexdigest()
def sha256_file(path: Path) -> str:
return sha256_bytes(path.read_bytes())
@dataclass
class Check:
name: str
passed: bool
details: Mapping[str, Any] = field(default_factory=dict)
def as_dict(self) -> dict[str, Any]:
return {
"name": self.name,
"passed": self.passed,
"details": dict(self.details),
}
class Acceptance:
def __init__(self, *, source_oid: str, base_url: str, online: bool) -> None:
self.source_oid = source_oid
self.base_url = base_url.rstrip("/")
self.online = online
self.checks: list[Check] = []
def record(self, name: str, action: Callable[[], Mapping[str, Any]]) -> None:
try:
details = action()
self.checks.append(Check(name=name, passed=True, details=details))
print(f"PASS: {name}")
except Exception as exc:
self.checks.append(
Check(
name=name,
passed=False,
details={
"error": type(exc).__name__,
"message": str(exc),
},
)
)
print(f"FAIL: {name}: {type(exc).__name__}: {exc}", file=sys.stderr)
def validate_source_oid(self) -> Mapping[str, Any]:
if not _GIT_OID.fullmatch(self.source_oid):
raise ValueError("source OID must be an exact 40-character lowercase Git SHA1")
return {"source_oid": self.source_oid}
def compile_sources(self) -> Mapping[str, Any]:
roots = (
APP / "aios_v2",
APP / "aios_web",
APP / "aios_openclaw",
APP / "aios_hermes",
)
files = sorted(path for root in roots for path in root.rglob("*.py"))
if not files:
raise RuntimeError("no Python sources discovered")
digest = hashlib.sha256()
for path in files:
source = path.read_text(encoding="utf-8")
compile(source, str(path), "exec")
digest.update(str(path.relative_to(ROOT)).encode("utf-8"))
digest.update(source.encode("utf-8"))
return {"files": len(files), "source_bundle_sha256": digest.hexdigest()}
def validate_plan(self) -> Mapping[str, Any]:
os.environ["AIOS_RELEASE_SOURCE_OID"] = self.source_oid
if str(APP) not in sys.path:
sys.path.insert(0, str(APP))
from aios_web.release_application import _load_execution_plan
from aios_v2.executor_catalog import FileExecutorCatalogStore
from aios_v2.official_executor_providers import official_provider_factories
plans = [_load_execution_plan(path) for path in (PLAN, TAKEOVER_PLAN)]
if any(plan.target_version != "V2R1C2" for plan in plans):
raise RuntimeError("unexpected target version")
if any(plan.source_oid != self.source_oid for plan in plans):
raise RuntimeError("plan source OID mismatch")
with tempfile.TemporaryDirectory() as temporary:
store = FileExecutorCatalogStore(Path(temporary) / "catalog.json")
snapshot = store.initialize_defaults()
providers = store.registry(provider_factories=official_provider_factories())
for plan in plans:
for stage in plan.stages:
providers.get(stage.executor_type)
takeover = plans[1]
takeover_executors = {stage.executor_type for stage in takeover.stages}
if "chatgpt-supervisor" not in takeover_executors:
raise RuntimeError("takeover plan is missing chatgpt-supervisor")
if not any(
stage.task_id == "OPENCLAW-TASK-V2R1C2-UNIFIED-PORTAL-DEPLOY"
for stage in takeover.stages
):
raise RuntimeError("takeover plan is missing the authorized deployment task")
return {
"plan_ids": [plan.plan_id for plan in plans],
"target_version": plans[0].target_version,
"stage_count": sum(len(plan.stages) for plan in plans),
"executors": sorted(
{stage.executor_type for plan in plans for stage in plan.stages}
),
"plan_sha256": {
PLAN.name: sha256_file(PLAN),
TAKEOVER_PLAN.name: sha256_file(TAKEOVER_PLAN),
},
"catalog_sha256": snapshot.catalog_sha256,
}
def validate_deployment_assets(self) -> Mapping[str, Any]:
service = SYSTEMD.read_text(encoding="utf-8")
environment = ENV_TEMPLATE.read_text(encoding="utf-8")
required_service = (
"User=aios",
"AIOS_V2_PORT",
"18921",
"NoNewPrivileges=true",
"ProtectSystem=strict",
"ReadWritePaths=/var/lib/aios-v2r1c2-portal",
"AIOS-V2R1C2-UNIFIED-PORTAL-TAKEOVER.json",
)
required_environment = (
"AIOS_V2_PORT=18921",
"AIOS_RELEASE_SOURCE_OID=REPLACE_WITH_EXACT_40_CHARACTER_GIT_OID",
"AIOS_EXECUTION_PLAN_FILES=",
"AIOS-V2R1C2-UNIFIED-PORTAL-TAKEOVER.json",
"AIOS_OWNER_WECHAT_TARGET_REF=",
)
missing = [item for item in required_service if item not in service]
missing += [item for item in required_environment if item not in environment]
if missing:
raise RuntimeError(f"deployment assets are missing requirements: {missing}")
return {
"systemd_sha256": sha256_file(SYSTEMD),
"environment_template_sha256": sha256_file(ENV_TEMPLATE),
}
def run_command(self, name: str, command: Sequence[str]) -> Mapping[str, Any]:
process = subprocess.run(
list(command),
cwd=ROOT,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"},
check=False,
)
encoded = process.stdout.encode("utf-8")
if process.returncode != 0:
tail = "\n".join(process.stdout.splitlines()[-40:])
raise RuntimeError(f"exit={process.returncode}\n{tail}")
return {
"command": list(command),
"exit_code": process.returncode,
"output_sha256": sha256_bytes(encoded),
"output_lines": len(process.stdout.splitlines()),
"label": name,
}
def request_json(
self,
path: str,
*,
authenticated: bool,
) -> Mapping[str, Any]:
headers: dict[str, str] = {}
if authenticated:
headers.update(
{
"X-AIOS-Authenticated": "true",
"X-AIOS-Tenant-ID": "v2r1c2-acceptance",
"X-AIOS-Principal-ID": "v2r1c2-acceptance",
"X-AIOS-Roles": "platform_admin",
"X-AIOS-Trace-ID": "v2r1c2-acceptance",
}
)
request = urllib.request.Request(f"{self.base_url}{path}", headers=headers)
with urllib.request.urlopen(request, timeout=10) as response:
raw = response.read()
status = response.status
payload = json.loads(raw.decode("utf-8"))
if status != 200 or not isinstance(payload, Mapping):
raise RuntimeError(f"unexpected response status={status}")
return {
"path": path,
"status": status,
"response_sha256": sha256_bytes(raw),
"payload": dict(payload),
}
def online_health(self) -> Mapping[str, Any]:
value = self.request_json("/api/v2/health", authenticated=False)
if value["payload"].get("status") != "ok":
raise RuntimeError("candidate health is not ok")
return value
def online_capabilities(self) -> Mapping[str, Any]:
value = self.request_json("/api/v2/me/capabilities", authenticated=True)
capabilities = set(value["payload"].get("capabilities") or ())
required = {"portal.access", "external_task.read", "release.read"}
missing = sorted(required - capabilities)
if missing:
raise RuntimeError(f"portal capabilities are missing: {missing}")
value["payload"] = {
"principal_id": value["payload"].get("principal_id"),
"roles": value["payload"].get("roles"),
"capability_count": len(capabilities),
}
return value
def online_execution_center(self) -> Mapping[str, Any]:
value = self.request_json("/api/v2/portal/execution", authenticated=True)
payload = value["payload"]
if payload.get("protocol_version") != "aios.executor.v1":
raise RuntimeError("executor protocol mismatch")
plans = payload.get("plans") or ()
if not plans:
raise RuntimeError("candidate has no declared execution plan")
plan_ids = {item.get("plan_id") for item in plans}
required_plan = "AIOS-V2R1C2-UNIFIED-PORTAL-TAKEOVER-20260722"
if required_plan not in plan_ids:
raise RuntimeError("candidate is missing the takeover execution plan")
provider_names = {
item.get("provider") or item.get("executor_type") or item.get("name")
for item in payload.get("providers") or ()
}
if "chatgpt-supervisor" not in provider_names:
raise RuntimeError("candidate is missing the chatgpt-supervisor provider")
value["payload"] = {
"protocol_version": payload.get("protocol_version"),
"plan_schema_version": payload.get("plan_schema_version"),
"provider_count": len(payload.get("providers") or ()),
"plan_ids": [item.get("plan_id") for item in plans],
"gates": [item.get("gate") for item in plans],
}
return value
def run(self) -> Mapping[str, Any]:
self.record("source_oid", self.validate_source_oid)
self.record("python_compile", self.compile_sources)
self.record("execution_plan", self.validate_plan)
self.record("deployment_assets", self.validate_deployment_assets)
self.record(
"portable_executor_tests",
lambda: self.run_command(
"portable_executor_tests",
(
sys.executable,
"-m",
"unittest",
"discover",
"-s",
str(PORTABLE_TEST.parent.relative_to(ROOT)),
"-p",
PORTABLE_TEST.name,
"-v",
),
),
)
self.record(
"candidate_regression_tests",
lambda: self.run_command(
"candidate_regression_tests",
(
sys.executable,
"-m",
"unittest",
"discover",
"-s",
"tests/aios_v2_candidate",
"-p",
"test_*.py",
"-v",
),
),
)
if self.online:
self.record("online_health", self.online_health)
self.record("online_capabilities", self.online_capabilities)
self.record("online_execution_center", self.online_execution_center)
passed = all(item.passed for item in self.checks)
report = {
"schema_version": "aios.acceptance-evidence.v1",
"candidate": "V2R1C2",
"baseline": "V2R1C1",
"source_oid": self.source_oid,
"online": self.online,
"base_url": self.base_url if self.online else None,
"generated_at": utc_now(),
"passed": passed,
"checks": [item.as_dict() for item in self.checks],
}
report["report_sha256"] = sha256_bytes(canonical_bytes(report))
return report
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="AIOS V2R1C2 acceptance gate")
parser.add_argument(
"--source-oid",
default=os.getenv("AIOS_RELEASE_SOURCE_OID", ""),
help="exact 40-character candidate Git OID",
)
parser.add_argument(
"--online",
action="store_true",
help="also validate the isolated candidate service",
)
parser.add_argument(
"--base-url",
default=os.getenv("AIOS_V2_BASE_URL", "http://127.0.0.1:18921"),
)
parser.add_argument(
"--evidence-output",
default="artifacts/v2r1c2/acceptance-evidence.json",
)
return parser.parse_args(argv)
def main(argv: Sequence[str] | None = None) -> int:
args = parse_args(argv)
report = Acceptance(
source_oid=str(args.source_oid).strip().lower(),
base_url=args.base_url,
online=bool(args.online),
).run()
output = Path(args.evidence_output).resolve()
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(
json.dumps(report, ensure_ascii=False, sort_keys=True, indent=2) + "\n",
encoding="utf-8",
)
print(json.dumps(report, ensure_ascii=False, sort_keys=True, indent=2))
print(f"EVIDENCE_FILE={output}")
print(f"EVIDENCE_SHA256={sha256_file(output)}")
return 0 if report["passed"] else 1
if __name__ == "__main__":
raise SystemExit(main())
+151
View File
@@ -0,0 +1,151 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import hashlib
import json
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
BASE = "http://127.0.0.1:18921"
PREFIX = "/api/v2/internal/external-tasks"
TASK_ID = "OPENCLAW-TASK-V2R1C2-UNIFIED-PORTAL-DEPLOY"
EXECUTOR = "chatgpt-supervisor"
EXECUTOR_ID = "chatgpt-supervisor-codex"
RUN_ID = "chatgpt-supervisor-f3c00d72943f-run1"
HEADERS = {
"Content-Type": "application/json",
"X-AIOS-Authenticated": "true",
"X-AIOS-Tenant-ID": "v2r1c2-supervision",
"X-AIOS-Principal-ID": EXECUTOR_ID,
"X-AIOS-Roles": "chatgpt_supervisor",
"X-AIOS-Trace-ID": RUN_ID,
}
def call(path: str, *, method: str = "GET", body: object | None = None):
data = None if body is None else json.dumps(body, sort_keys=True, separators=(",", ":")).encode()
request = urllib.request.Request(BASE + path, data=data, headers=HEADERS, method=method)
try:
with urllib.request.urlopen(request, timeout=15) as response:
raw = response.read()
return response.status, json.loads(raw) if raw else {}, raw
except urllib.error.HTTPError as error:
raw = error.read()
raise RuntimeError(f"{method} {path} status={error.code} body={raw.decode('utf-8', 'replace')}") from error
def post_action(action: str, extra: dict[str, object] | None = None):
payload: dict[str, object] = {"executor_run_id": RUN_ID}
if action != "register":
payload["executor_id"] = EXECUTOR_ID
payload.update(extra or {})
return call(f"{PREFIX}/{urllib.parse.quote(TASK_ID)}/{action}", method="POST", body=payload)[1]
def file_sha256(path: str | Path) -> str:
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
def truth():
return call(f"{PREFIX}/{urllib.parse.quote(TASK_ID)}/truth")[1]
def notifications():
query = urllib.parse.urlencode({"executor": EXECUTOR, "task_id": TASK_ID})
return call(f"{PREFIX}/notifications?{query}")[1]["items"]
def acknowledge(event_type: str, receipt_sha256: str):
matches = [item for item in notifications() if item["event_type"] == event_type]
if len(matches) != 1:
raise RuntimeError(f"expected one {event_type} notification, got {len(matches)}")
notification = matches[0]
return call(
f"{PREFIX}/notifications/{urllib.parse.quote(notification['notification_id'])}/ack",
method="POST",
body={"executor_id": EXECUTOR_ID, "receipt_sha256": receipt_sha256},
)[1]
def write_report(output: str, payload: dict[str, object]) -> None:
payload["schema_version"] = "aios.v2r1c2-supervision.v1"
payload["task_id"] = TASK_ID
payload["executor_type"] = EXECUTOR
payload["executor_id"] = EXECUTOR_ID
payload["executor_run_id"] = RUN_ID
payload["generated_at"] = datetime.now(timezone.utc).isoformat()
payload["report_sha256"] = hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
target = Path(output).resolve()
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2) + "\n", encoding="utf-8")
print(f"EVIDENCE_FILE={target}")
print(f"EVIDENCE_SHA256={file_sha256(target)}")
def start(args) -> None:
initial = truth()
if initial["record"]["state"] == "DECLARED":
post_action("register")
post_action("claim")
post_action("start")
elif initial["record"]["state"] == "NOT_STARTED":
post_action("retry")
post_action("claim")
post_action("start")
elif initial["record"]["state"] != "RUNNING":
raise RuntimeError(f"unexpected initial state {initial['record']['state']}")
post_action("heartbeat", {"current_step": 7})
receipt = file_sha256(args.started_receipt)
acknowledge("STARTED", receipt)
current = truth()
if current["record"]["state"] != "RUNNING" or current["record"]["current_step"] != 7:
raise RuntimeError("supervision start truth mismatch")
write_report(args.output, {"phase": "started", "started_receipt_sha256": receipt, "truth": current})
print("SUPERVISION_START=PASS STATE=RUNNING STEP=7 STARTED_NOTIFICATION=DELIVERED")
def heartbeat(args) -> None:
post_action("heartbeat", {"current_step": args.step})
current = truth()
write_report(args.output, {"phase": "heartbeat", "truth": current})
print(f"SUPERVISION_HEARTBEAT=PASS STEP={args.step}")
def succeed(args) -> None:
evidence = json.loads(Path(args.evidence_map).read_text(encoding="utf-8"))
if not isinstance(evidence, dict):
raise RuntimeError("evidence map must be an object")
post_action("heartbeat", {"current_step": 8})
post_action("succeed", {"evidence": evidence})
receipt = file_sha256(args.completed_receipt)
acknowledge("SUCCEEDED", receipt)
current = truth()
if current["record"]["state"] != "SUCCEEDED" or not current["gate"]["passed"]:
raise RuntimeError("terminal truth gate did not pass")
write_report(args.output, {"phase": "succeeded", "completed_receipt_sha256": receipt, "truth": current})
print("SUPERVISION_SUCCEED=PASS STATE=SUCCEEDED STAGE_GATE=PASS NOTIFICATION_GATE=PASS")
def main() -> None:
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest="command", required=True)
start_parser = subparsers.add_parser("start")
start_parser.add_argument("--started-receipt", required=True)
start_parser.add_argument("--output", required=True)
heartbeat_parser = subparsers.add_parser("heartbeat")
heartbeat_parser.add_argument("--step", required=True, type=int)
heartbeat_parser.add_argument("--output", required=True)
succeed_parser = subparsers.add_parser("succeed")
succeed_parser.add_argument("--evidence-map", required=True)
succeed_parser.add_argument("--completed-receipt", required=True)
succeed_parser.add_argument("--output", required=True)
args = parser.parse_args()
{"start": start, "heartbeat": heartbeat, "succeed": succeed}[args.command](args)
if __name__ == "__main__":
main()