Compare commits
@@ -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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
@@ -0,0 +1 @@
|
||||
export { default } from "../index.js";
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "../policy.js";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "../provenance.js";
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "../writer.js";
|
||||
@@ -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 };
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user