Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3f7db29b1 | ||
|
|
09c33eb448 | ||
|
|
f7abb8066b | ||
|
|
efc9ffcdc6 | ||
|
|
05b0d7fb80 | ||
|
|
13c2d24e76 | ||
|
|
fbb17597d1 | ||
|
|
7f8261a4b6 | ||
|
|
839e567060 | ||
|
|
db8d9c694d | ||
|
|
7fc2da56d2 | ||
|
|
ae30d716f9 | ||
|
|
d96086b345 | ||
|
|
6c7928c096 | ||
|
|
5585856cd3 | ||
|
|
9ffcf93fcd | ||
|
|
94db629904 | ||
|
|
7f9d664d6b | ||
|
|
90e05be0a9 | ||
|
|
70fbed1050 | ||
|
|
ab268789ee | ||
|
|
35e4a5350f | ||
|
|
dc5dae2a51 | ||
|
|
73c5b2330e | ||
|
|
8aa436043d | ||
|
|
3d4e630582 | ||
|
|
3b609a8e57 | ||
|
|
0a4e2b741d | ||
|
|
e68313f95e | ||
|
|
e62ff487ab | ||
|
|
680a2f4f2b | ||
|
|
8d6d92bcec | ||
|
|
3a5fc56c9d | ||
|
|
6327664d22 | ||
|
|
228d8fedf2 | ||
|
|
f94af50497 | ||
|
|
2a81d8ced4 | ||
|
|
759e343075 | ||
|
|
bef2de2c66 | ||
|
|
e8bd867c33 | ||
|
|
1953cacebc | ||
|
|
43be07af73 | ||
|
|
bdea3ef5ba | ||
|
|
76848939e9 | ||
|
|
f2a08293dd | ||
|
|
866da79a81 | ||
|
|
8a810c2ae8 | ||
|
|
c475a8238c | ||
|
|
24c55564a0 | ||
|
|
55a4b32aa6 | ||
|
|
42c4d11457 | ||
|
|
7be2107ad0 | ||
|
|
4587c9c46d | ||
|
|
aebff86864 | ||
|
|
76d2453ef3 |
@@ -84,39 +84,14 @@ def token_records():
|
||||
else:
|
||||
out.append({'account':'legacy-token','role':'owner','scopes':['*'],'token_plain':line})
|
||||
return out
|
||||
def normalize_auth_token(raw: str | None) -> tuple[str, str]:
|
||||
value = str(raw or '').strip()
|
||||
state = 'missing'
|
||||
if not value:
|
||||
return '', state
|
||||
state = 'present'
|
||||
for _ in range(4):
|
||||
lowered = value.lower().strip()
|
||||
if lowered.startswith('authorization:'):
|
||||
value = value.split(':', 1)[1].strip()
|
||||
state = 'authorization_prefix_stripped'
|
||||
continue
|
||||
if lowered.startswith('bearer '):
|
||||
value = value[7:].strip()
|
||||
state = 'bearer_prefix_stripped'
|
||||
continue
|
||||
break
|
||||
if (value.startswith('"') and value.endswith('"')) or (value.startswith("'") and value.endswith("'")):
|
||||
value = value[1:-1].strip()
|
||||
return value, state
|
||||
|
||||
def authenticate(token: str):
|
||||
if os.getenv('AIOS_GITOPS_DEV_ALLOW','0') == '1': return {'account':'dev-owner','role':'owner','scopes':['*']}
|
||||
token, _ = normalize_auth_token(token)
|
||||
if not token or token.startswith('secret_ref://'):
|
||||
return None
|
||||
if not token: return None
|
||||
digest=hashlib.sha256(token.encode()).hexdigest()
|
||||
for r in token_records():
|
||||
if r.get('disabled'): continue
|
||||
if r.get('token_sha256') and hmac.compare_digest(str(r['token_sha256']), digest): return r
|
||||
plain = str(r.get('token_plain') or '')
|
||||
norm_plain, _ = normalize_auth_token(plain)
|
||||
if norm_plain and hmac.compare_digest(norm_plain, token): return r
|
||||
if r.get('token_plain') and hmac.compare_digest(str(r['token_plain']), token): return r
|
||||
return None
|
||||
def sanitize(obj: Any) -> Any:
|
||||
if isinstance(obj, dict):
|
||||
@@ -160,16 +135,6 @@ def sync_tag(tag: str) -> dict:
|
||||
if p.returncode != 0: raise RuntimeError(p.stderr or p.stdout or f'git tag push failed for {tag}')
|
||||
return {'status':'synced','target':'origin','tag':tag,'stdout':p.stdout.strip(),'stderr':p.stderr.strip()}
|
||||
|
||||
def commit_summary(c: Any) -> dict:
|
||||
if not isinstance(c, dict):
|
||||
return {'rev': None, 'sync_status': None, 'sync': None}
|
||||
sync = c.get('sync') if isinstance(c.get('sync'), dict) else None
|
||||
return {
|
||||
'rev': c.get('rev'),
|
||||
'sync_status': sync.get('status') if sync else None,
|
||||
'sync': sync,
|
||||
}
|
||||
|
||||
def commit(paths, msg):
|
||||
ensure_git(); paths = [relpath(p) for p in paths] if paths else ['.']; sh(['add'] + paths, True)
|
||||
branch=sh(['rev-parse','--abbrev-ref','HEAD'])['stdout']
|
||||
@@ -194,58 +159,20 @@ class H(BaseHTTPRequestHandler):
|
||||
b=json.dumps(data, ensure_ascii=False, indent=2).encode(); self.send_response(code); self.send_header('Content-Type','application/json; charset=utf-8'); self.send_header('Content-Length',str(len(b))); self.end_headers(); self.wfile.write(b)
|
||||
def sendt(self, text, ctype='text/plain; charset=utf-8', code=200):
|
||||
b=text.encode(); self.send_response(code); self.send_header('Content-Type',ctype); self.send_header('Content-Length',str(len(b))); self.end_headers(); self.wfile.write(b)
|
||||
def send_head_only(self, code=200, ctype='application/json; charset=utf-8', length=0):
|
||||
self.send_response(code); self.send_header('Content-Type',ctype); self.send_header('Content-Length',str(length)); self.end_headers()
|
||||
def body(self):
|
||||
n=int(self.headers.get('Content-Length','0') or '0'); raw=self.rfile.read(n).decode() if n else '{}'
|
||||
try: return json.loads(raw or '{}')
|
||||
except Exception: return {'_raw':raw}
|
||||
def actor(self):
|
||||
auth=self.headers.get('Authorization','')
|
||||
raw = auth if auth else self.headers.get('X-AIOS-Token','')
|
||||
tok, auth_state = normalize_auth_token(raw)
|
||||
if not raw:
|
||||
auth_state = 'missing_bearer'
|
||||
elif tok.startswith('secret_ref://'):
|
||||
auth_state = 'secret_ref_literal_not_token'
|
||||
auth=self.headers.get('Authorization',''); tok=auth[7:].strip() if auth.startswith('Bearer ') else self.headers.get('X-AIOS-Token','')
|
||||
rec=authenticate(tok)
|
||||
if rec: return {'ok':True,'account':safe(rec.get('account') or rec.get('name') or 'token-user'),'role':rec.get('role','user'),'scopes':set(rec.get('scopes') or []),'auth_state':'authenticated'}
|
||||
if raw and auth_state not in ('missing_bearer', 'secret_ref_literal_not_token'):
|
||||
auth_state = 'invalid_token'
|
||||
return {'ok':False,'account':'anonymous','role':'anonymous','scopes':set(),'auth_state':auth_state or 'invalid_token'}
|
||||
if rec: return {'ok':True,'account':safe(rec.get('account') or rec.get('name') or 'token-user'),'role':rec.get('role','user'),'scopes':set(rec.get('scopes') or [])}
|
||||
return {'ok':False,'account':'anonymous','role':'anonymous','scopes':set()}
|
||||
def need(self, scope):
|
||||
a=self.actor()
|
||||
if not a['ok']:
|
||||
hint = 'Bearer token required for GitOps write scope'
|
||||
if a.get('auth_state') == 'invalid_token':
|
||||
hint = 'Bearer token was received but does not match the GitOps write token registry'
|
||||
elif a.get('auth_state') == 'secret_ref_literal_not_token':
|
||||
hint = 'secret_ref is a pointer, not a credential; paste the raw runtime token into GPT Builder'
|
||||
self.sendj({
|
||||
'error':'unauthorized',
|
||||
'hint':hint,
|
||||
'auth_state':a.get('auth_state') or 'invalid_token',
|
||||
'required_scope':scope,
|
||||
'token_secret_ref':'secret_ref://aios-gitops/runtime/gitops-write-bot',
|
||||
'auth_header':'Authorization: Bearer <raw-token>',
|
||||
'rebind_required':True,
|
||||
'gpt_builder_note':'When GPT Builder auth type is Bearer, paste only the raw token value. Do not paste secret_ref://... and do not include the word Bearer in the API key field.'
|
||||
},401); return None
|
||||
if '*' not in a['scopes'] and scope not in a['scopes']:
|
||||
self.sendj({'error':'forbidden','required_scope':scope,'account':a['account'],'auth_state':a.get('auth_state'),'token_secret_ref':'secret_ref://aios-gitops/runtime/gitops-write-bot'},403); return None
|
||||
if not a['ok']: self.sendj({'error':'unauthorized','hint':'Bearer token required'},401); return None
|
||||
if '*' not in a['scopes'] and scope not in a['scopes']: self.sendj({'error':'forbidden','required_scope':scope,'account':a['account']},403); return None
|
||||
return a
|
||||
def do_HEAD(self):
|
||||
u=urllib.parse.urlparse(self.path); path=u.path.rstrip('/') if u.path != '/' else u.path
|
||||
try:
|
||||
if path in ('/api/openapi.yaml','/api/openapi-chatgpt-actions.yaml','/api/schema'):
|
||||
if not OPENAPI.exists():
|
||||
return self.send_head_only(404)
|
||||
return self.send_head_only(200, 'application/yaml; charset=utf-8', OPENAPI.stat().st_size)
|
||||
if path in ('/health','/api/health','/api','/api/status','/api/base','/base','/api/ver','/ver','/api/diff','/diff','/api/patch','/patch','/api/model','/model','/api/run','/run'):
|
||||
return self.send_head_only(200)
|
||||
return self.send_head_only(404)
|
||||
except Exception:
|
||||
return self.send_head_only(500)
|
||||
def do_GET(self):
|
||||
u=urllib.parse.urlparse(self.path); path=u.path.rstrip('/') if u.path != '/' else u.path; q=urllib.parse.parse_qs(u.query)
|
||||
try:
|
||||
@@ -288,20 +215,20 @@ class H(BaseHTTPRequestHandler):
|
||||
a=self.need('branch:create');
|
||||
if not a: return
|
||||
b=checkout(data.get('branch'), data.get('base','main'), True); meta={'type':'branch','branch':b,'base':data.get('base','main'),'created_by':a['account'],'created_at':now(),'direct_main_push':False,'owner_approval_required':True}
|
||||
r=write_json(f'patches/{safe(b).replace("/","_")}.branch.json', meta); c=commit([r], data.get('message') or f'chore(chatgpt): create {b}'); summary=commit_summary(c); audit(a['account'],'branch',b,meta); self.sendj({'status':'ok','branch':b,'commit':c, **summary})
|
||||
r=write_json(f'patches/{safe(b).replace("/","_")}.branch.json', meta); c=commit([r], data.get('message') or f'chore(chatgpt): create {b}'); audit(a['account'],'branch',b,meta); self.sendj({'status':'ok','branch':b,'commit':c})
|
||||
def file(self,data):
|
||||
a=self.need('file:write');
|
||||
if not a: return
|
||||
b=checkout(data.get('branch'), data.get('base','main'), bool(data.get('create_branch'))); r=write_file(data.get('path'), data.get('content','')); c=commit([r], data.get('message') or f'chore(chatgpt): update {r}'); summary=commit_summary(c); audit(a['account'],'file',r,{'branch':b,'commit':c}); self.sendj({'status':'committed','branch':b,'path':r,'commit':c, **summary})
|
||||
b=checkout(data.get('branch'), data.get('base','main'), bool(data.get('create_branch'))); r=write_file(data.get('path'), data.get('content','')); c=commit([r], data.get('message') or f'chore(chatgpt): update {r}'); audit(a['account'],'file',r,{'branch':b,'commit':c}); self.sendj({'status':'committed','branch':b,'path':r,'commit':c})
|
||||
def commit(self,data):
|
||||
a=self.need('commit:create');
|
||||
if not a: return
|
||||
b=checkout(data.get('branch') or sh(['rev-parse','--abbrev-ref','HEAD'])['stdout'], data.get('base','main'), False); paths=data.get('paths') or ['.']; paths=[paths] if isinstance(paths,str) else paths; c=commit(paths, data.get('message') or 'chore(chatgpt): commit changes'); summary=commit_summary(c); audit(a['account'],'commit',b,{'paths':paths,'commit':c}); self.sendj({'status':c['status'],'branch':b,'commit':c, **summary})
|
||||
b=checkout(data.get('branch') or sh(['rev-parse','--abbrev-ref','HEAD'])['stdout'], data.get('base','main'), False); paths=data.get('paths') or ['.']; paths=[paths] if isinstance(paths,str) else paths; c=commit(paths, data.get('message') or 'chore(chatgpt): commit changes'); audit(a['account'],'commit',b,{'paths':paths,'commit':c}); self.sendj({'status':c['status'],'branch':b,'commit':c})
|
||||
def patch(self,data):
|
||||
a=self.need('patch:create');
|
||||
if not a: return
|
||||
name=data.get('patch_name') or data.get('name') or uid('patch'); b=checkout(data.get('branch') or f'patch/{safe(name)}', data.get('base','main'), True); meta={'id':uid('patch'),'patch_name':name,'branch':b,'risk':data.get('risk','medium'),'description':data.get('description',''),'created_by':a['account'],'created_at':now()}
|
||||
r=write_json(f'patches/{safe(name)}.json', meta); c=commit([r], data.get('message') or f'chore(chatgpt): create patch {name}'); summary=commit_summary(c); audit(a['account'],'patch',name,meta); self.sendj({'status':'patch_branch_created','branch':b,'patch':meta,'commit':c, **summary})
|
||||
r=write_json(f'patches/{safe(name)}.json', meta); c=commit([r], data.get('message') or f'chore(chatgpt): create patch {name}'); audit(a['account'],'patch',name,meta); self.sendj({'status':'patch_branch_created','patch':meta,'commit':c})
|
||||
def tag(self,data):
|
||||
a=self.need('tag:create');
|
||||
if not a: return
|
||||
@@ -343,23 +270,9 @@ class H(BaseHTTPRequestHandler):
|
||||
b=checkout(data.get('branch') or f'patch/nl-{uid("nl")}', 'main', True); doc={'id':uid('nl'),'text':text,'intent':intent,'action_plan':plan,'execute':False,'owner_approval_required':True,'created_by':a['account'],'created_at':now()}; r=write_json(f'patches/nl/{safe(doc["id"])}.json', doc); c=commit([r], f'chore(chatgpt): record nl plan {intent}'); self.sendj({'status':'planned','branch':b,'request':doc,'commit':c})
|
||||
|
||||
def api_spec():
|
||||
return {
|
||||
'version':'V1R1C0-chatgpt-actions',
|
||||
'openapi':'/api/openapi.yaml',
|
||||
'read':['GET /api/base','GET /api/ver','GET /api/diff','GET /api/patch','GET /api/model','GET /api/run'],
|
||||
'write':['POST /api/branch','POST /api/file','POST /api/commit','POST /api/patch','POST /api/tag','POST /api/rollback','POST /api/model','PUT /api/model-priority','POST /api/nl'],
|
||||
'auth':{
|
||||
'write_bearer_required':True,
|
||||
'write_token_account':'gitops-write-bot',
|
||||
'write_token_secret_ref':'secret_ref://aios-gitops/runtime/gitops-write-bot',
|
||||
'required_header':'Authorization: Bearer <raw-token>',
|
||||
'write_scopes':['branch:create','file:write','commit:create','patch:create','rollback:request','model:write','model:priority','nl:plan','runtime:test'],
|
||||
'bridge_gpts':[{'name':'codex-chartgpt-bridge','gpt_id':'g-6a4ba1b4ea588191bab125d53ce83612','url':'https://chatgpt.com/g/g-6a4ba1b4ea588191bab125d53ce83612-codex-chartgpt-bridge','auth_binding_status':'requires_gpt_builder_bearer_secret_binding','api_key_field':'raw token only, without Bearer prefix'}]
|
||||
},
|
||||
'policy':{'bearer_required_for_write':True,'direct_main_push':False,'delete_tag':False,'owner_approval_required_for_v_level':True,'api_keys_in_git':False,'git_secret_field':'secret_ref','online_git_sync':True}
|
||||
}
|
||||
return {'version':'V1R1C0-chatgpt-actions','openapi':'/api/openapi.yaml','read':['GET /api/base','GET /api/ver','GET /api/diff','GET /api/patch','GET /api/model','GET /api/run'],'write':['POST /api/branch','POST /api/file','POST /api/commit','POST /api/patch','POST /api/tag','POST /api/rollback','POST /api/model','PUT /api/model-priority','POST /api/nl'],'policy':{'bearer_required_for_write':True,'direct_main_push':False,'delete_tag':False,'owner_approval_required_for_v_level':True,'api_keys_in_git':False,'git_secret_field':'secret_ref','online_git_sync':True}}
|
||||
|
||||
def main():
|
||||
print(f'AIOS ChatGPT GitOps adapter listening on {HOST}:{PORT}')
|
||||
ThreadingHTTPServer((HOST,PORT), H).serve_forever()
|
||||
if __name__ == '__main__': main()
|
||||
if __name__ == '__main__': main()
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"approval_id": "owner-preapprove-V1R1C5-main-entry-20260707",
|
||||
"created_at": "2026-07-07T00:00:00Z",
|
||||
"approved_by_role": "owner",
|
||||
"target_branch": "patch/web-ui-complete-gitops-dashboard-20260706",
|
||||
"target_base": "main",
|
||||
"target_version": "V1R1C5",
|
||||
"rollback_target": "V1R1C4",
|
||||
"allowed_actions": [
|
||||
"merge_to_main",
|
||||
"create_release_tag",
|
||||
"activate_new_baseline"
|
||||
],
|
||||
"risk_acceptance": "Owner approved promotion of autonomous GitOps platform control plane to main, subject to validation, OpenClaw supervision, no secret leakage, no direct force push, and rollback target V1R1C4.",
|
||||
"expires_at": "2026-07-14T23:59:59Z",
|
||||
"constraints": {
|
||||
"direct_main_push": false,
|
||||
"force_push": false,
|
||||
"secrets_in_git": false,
|
||||
"destructive_operations": false,
|
||||
"validation_required": true,
|
||||
"owner_notification_required": true,
|
||||
"web_ui_audit_required": true
|
||||
}
|
||||
}
|
||||
+1
-51
@@ -46,7 +46,7 @@
|
||||
"/api",
|
||||
"/hook"
|
||||
],
|
||||
"updated_at": "2026-07-06T13:24:28Z",
|
||||
"updated_at": "2026-07-06T11:16:23Z",
|
||||
"auth": {
|
||||
"primary_domain": "https://auth.cnnetmkt.top",
|
||||
"backup_domain": "https://auth.h.imruyi.top:8443",
|
||||
@@ -104,55 +104,5 @@
|
||||
"portal_overlay": "overlays/user-control-center",
|
||||
"portal_baseline_cache": "/app/runtime/gitops/current.json",
|
||||
"sync_log_secret_hygiene": "runuser_without_sudo_command_logging"
|
||||
},
|
||||
"actions_write_auth": {
|
||||
"account": "gitops-write-bot",
|
||||
"token_ref": "secret_ref://aios-gitops/runtime/gitops-write-bot",
|
||||
"header": "Authorization: Bearer <token>",
|
||||
"scopes": [
|
||||
"baseline:read",
|
||||
"version:read",
|
||||
"diff:read",
|
||||
"patch:read",
|
||||
"model:read",
|
||||
"runtime:read",
|
||||
"branch:create",
|
||||
"file:write",
|
||||
"commit:create",
|
||||
"patch:create",
|
||||
"rollback:request",
|
||||
"model:write",
|
||||
"model:priority",
|
||||
"nl:plan",
|
||||
"runtime:test"
|
||||
],
|
||||
"direct_main_push": false,
|
||||
"delete_tag": false,
|
||||
"owner_approval_required": true,
|
||||
"updated_at": "2026-07-06T11:50:41Z",
|
||||
"bridge_gpts": [
|
||||
{
|
||||
"name": "codex-chartgpt-bridge",
|
||||
"gpt_id": "g-6a4ba1b4ea588191bab125d53ce83612",
|
||||
"url": "https://chatgpt.com/g/g-6a4ba1b4ea588191bab125d53ce83612-codex-chartgpt-bridge",
|
||||
"token_ref": "secret_ref://aios-gitops/runtime/gitops-write-bot",
|
||||
"schema_url": "https://cnnetmkt.top/api/openapi-chatgpt-actions.yaml",
|
||||
"auth_binding_status": "requires_gpt_builder_bearer_secret_binding",
|
||||
"server_side_smoke": "pass",
|
||||
"updated_at": "2026-07-06T13:24:28Z"
|
||||
}
|
||||
],
|
||||
"external_actions_binding_note": "ChatGPT GPT Builder must configure Bearer auth with the runtime token behind token_ref; server cannot inject the token into an external GPT by URL alone.",
|
||||
"gpt_builder_api_key_field": "raw token only, without Bearer prefix",
|
||||
"do_not_use_as_api_key": "secret_ref://aios-gitops/runtime/gitops-write-bot",
|
||||
"last_client_response_error_fix": {
|
||||
"updated_at": "2026-07-06T13:41:34Z",
|
||||
"server_changes": [
|
||||
"normalize duplicated Bearer/Authorization prefixes",
|
||||
"return auth_state diagnostics",
|
||||
"flatten branch/rev/sync_status on write success"
|
||||
],
|
||||
"external_builder_requirement": "paste raw token into Bearer API key field"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
{
|
||||
"name": "codex-chartgpt-bridge",
|
||||
"gpt_id": "g-6a4ba1b4ea588191bab125d53ce83612",
|
||||
"gpt_url": "https://chatgpt.com/g/g-6a4ba1b4ea588191bab125d53ce83612-codex-chartgpt-bridge",
|
||||
"api_base": "https://cnnetmkt.top/api",
|
||||
"openapi_schema": "https://cnnetmkt.top/api/openapi-chatgpt-actions.yaml",
|
||||
"auth": {
|
||||
"type": "bearer",
|
||||
"header": "Authorization: Bearer <token>",
|
||||
"token_account": "gitops-write-bot",
|
||||
"token_ref": "secret_ref://aios-gitops/runtime/gitops-write-bot",
|
||||
"runtime_token_file": "/data/aios-gitops/runtime/secrets/gitops-write-bot.token",
|
||||
"plaintext_token_in_git": false
|
||||
},
|
||||
"scopes": {
|
||||
"read": [
|
||||
"baseline:read",
|
||||
"version:read",
|
||||
"diff:read",
|
||||
"patch:read",
|
||||
"model:read",
|
||||
"runtime:read"
|
||||
],
|
||||
"write": [
|
||||
"branch:create",
|
||||
"file:write",
|
||||
"commit:create",
|
||||
"patch:create",
|
||||
"rollback:request",
|
||||
"model:write",
|
||||
"model:priority",
|
||||
"nl:plan",
|
||||
"runtime:test"
|
||||
]
|
||||
},
|
||||
"policy": {
|
||||
"direct_main_push": false,
|
||||
"delete_tag": false,
|
||||
"owner_approval_required": true,
|
||||
"v_level_owner_admin_approval_required": true,
|
||||
"api_keys_in_git": false
|
||||
},
|
||||
"status": {
|
||||
"server_side_smoke": "pass",
|
||||
"external_gpt_builder_auth_binding": "required_in_gpt_builder_ui",
|
||||
"last_updated_at": "2026-07-06T13:24:28Z"
|
||||
},
|
||||
"gpt_builder_api_key_field": "raw token only, without Bearer prefix",
|
||||
"do_not_use_as_api_key": "secret_ref://aios-gitops/runtime/gitops-write-bot",
|
||||
"last_client_response_error_fix": {
|
||||
"updated_at": "2026-07-06T13:41:34Z",
|
||||
"server_changes": [
|
||||
"normalize duplicated Bearer/Authorization prefixes",
|
||||
"return auth_state diagnostics",
|
||||
"flatten branch/rev/sync_status on write success"
|
||||
],
|
||||
"external_builder_requirement": "paste raw token into Bearer API key field"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"name": "aios-autonomous-merge-policy",
|
||||
"version": "1.0.0",
|
||||
"mode": "gated-autonomous",
|
||||
"description": "Defines when Codex CI may mark a patch branch as auto-merge eligible and when main merge must remain blocked.",
|
||||
"branch_scope": {
|
||||
"allowed_source_patterns": [
|
||||
"patch/*"
|
||||
],
|
||||
"protected_targets": [
|
||||
"main"
|
||||
],
|
||||
"direct_main_push": false
|
||||
},
|
||||
"merge_gate": {
|
||||
"required_status": [
|
||||
"validation_passed",
|
||||
"acceptance_required_passed",
|
||||
"secret_scan_passed",
|
||||
"policy_scan_passed",
|
||||
"indirect_regression_passed",
|
||||
"gray_deployment_passed"
|
||||
],
|
||||
"required_artifacts": [
|
||||
"reports/codex/validation-report.json",
|
||||
"reports/codex/merge-candidate.json",
|
||||
"versions/proposals/V1R1C5-gitops-platform-ui.proposed.json"
|
||||
],
|
||||
"required_approval": {
|
||||
"owner_or_admin": true,
|
||||
"audit_record_required": true,
|
||||
"approval_request_type": "release",
|
||||
"approval_target": "V1R1C5"
|
||||
}
|
||||
},
|
||||
"autonomous_behavior": {
|
||||
"auto_validate": true,
|
||||
"auto_self_heal": true,
|
||||
"auto_create_merge_candidate": true,
|
||||
"auto_merge_to_main": "only_after_policy_and_owner_approval",
|
||||
"auto_tag_after_merge": false,
|
||||
"max_self_heal_iterations": 5
|
||||
},
|
||||
"hard_blocks": [
|
||||
"secret_leak_detected",
|
||||
"direct_main_write_detected",
|
||||
"destructive_operation_detected",
|
||||
"baseline_active_version_changed_before_merge",
|
||||
"auth_or_gitea_security_config_modified",
|
||||
"required_test_failed",
|
||||
"unapproved_high_risk_change"
|
||||
],
|
||||
"post_merge": {
|
||||
"proposed_version": "V1R1C5",
|
||||
"rollback_target": "V1R1C4",
|
||||
"tag_creation": "request_only",
|
||||
"baseline_activation": "owner_approved_action_only"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"name": "aios-bounded-autonomous-production-policy",
|
||||
"version": "1.0.0",
|
||||
"mode": "bounded-lights-out-production",
|
||||
"description": "Allows unattended CI/self-healing/canary within pre-approved policy boundaries while preserving production safety gates.",
|
||||
"non_negotiable_controls": {
|
||||
"direct_main_push": false,
|
||||
"secrets_in_git": false,
|
||||
"destructive_operations": "forbidden",
|
||||
"rollback_execution": "request_or_preapproved_runbook_only",
|
||||
"tag_creation": "request_or_preapproved_runbook_only",
|
||||
"baseline_activation": "preapproved_runbook_only",
|
||||
"force_push": false,
|
||||
"delete_tags_or_history": false
|
||||
},
|
||||
"autonomous_scope": {
|
||||
"allowed_without_human_intervention": [
|
||||
"static_validation",
|
||||
"acceptance_validation",
|
||||
"secret_scan",
|
||||
"policy_scan",
|
||||
"self_healing_patch_on_same_branch",
|
||||
"merge_candidate_generation",
|
||||
"gray_deployment_validation",
|
||||
"failure_report_generation",
|
||||
"rollback_request_generation",
|
||||
"baseline_proposal_generation"
|
||||
],
|
||||
"requires_preapproved_runbook": [
|
||||
"merge_to_main",
|
||||
"create_release_tag",
|
||||
"activate_new_baseline",
|
||||
"production_runtime_change",
|
||||
"production_rollback_execution"
|
||||
],
|
||||
"always_blocked": [
|
||||
"force_push",
|
||||
"purge_baseline",
|
||||
"delete_version_history",
|
||||
"delete_audit_records",
|
||||
"commit_real_secret",
|
||||
"disable_auth_or_gitea_protection"
|
||||
]
|
||||
},
|
||||
"preapproval_contract": {
|
||||
"path_pattern": "approvals/preapproved/*.json",
|
||||
"required_fields": [
|
||||
"approval_id",
|
||||
"approved_by_role",
|
||||
"target_branch",
|
||||
"target_version",
|
||||
"expires_at",
|
||||
"allowed_actions",
|
||||
"risk_acceptance",
|
||||
"rollback_target"
|
||||
],
|
||||
"required_values": {
|
||||
"approved_by_role": ["owner", "admin"],
|
||||
"target_version": "V1R1C5",
|
||||
"rollback_target": "V1R1C4"
|
||||
}
|
||||
},
|
||||
"promotion_pipeline": [
|
||||
"validate",
|
||||
"self_heal_until_pass",
|
||||
"generate_merge_candidate",
|
||||
"gray_deploy_validate",
|
||||
"check_preapproval",
|
||||
"merge_or_request_approval",
|
||||
"propose_or_activate_baseline",
|
||||
"monitor",
|
||||
"auto_generate_rollback_request_on_failure"
|
||||
],
|
||||
"slo_gates": {
|
||||
"gray_deployment_required": true,
|
||||
"error_budget_policy": "block_if_unknown",
|
||||
"observability_required": true,
|
||||
"post_merge_monitoring_window": "30m"
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,7 @@
|
||||
],
|
||||
"created_at": "2026-07-06T10:21:08Z",
|
||||
"base_commit_before_release": "43ddb91",
|
||||
"updated_at": "2026-07-06T13:24:28Z",
|
||||
"updated_at": "2026-07-06T11:16:23Z",
|
||||
"last_collaboration_test": {
|
||||
"timestamp": "20260706T104130Z",
|
||||
"result": "local_codex_and_custom_gpt_patches_merged"
|
||||
@@ -74,55 +74,5 @@
|
||||
"portal_overlay": "overlays/user-control-center",
|
||||
"portal_baseline_cache": "/app/runtime/gitops/current.json",
|
||||
"sync_log_secret_hygiene": "runuser_without_sudo_command_logging"
|
||||
},
|
||||
"actions_write_auth": {
|
||||
"account": "gitops-write-bot",
|
||||
"token_ref": "secret_ref://aios-gitops/runtime/gitops-write-bot",
|
||||
"header": "Authorization: Bearer <token>",
|
||||
"scopes": [
|
||||
"baseline:read",
|
||||
"version:read",
|
||||
"diff:read",
|
||||
"patch:read",
|
||||
"model:read",
|
||||
"runtime:read",
|
||||
"branch:create",
|
||||
"file:write",
|
||||
"commit:create",
|
||||
"patch:create",
|
||||
"rollback:request",
|
||||
"model:write",
|
||||
"model:priority",
|
||||
"nl:plan",
|
||||
"runtime:test"
|
||||
],
|
||||
"direct_main_push": false,
|
||||
"delete_tag": false,
|
||||
"owner_approval_required": true,
|
||||
"updated_at": "2026-07-06T11:50:41Z",
|
||||
"bridge_gpts": [
|
||||
{
|
||||
"name": "codex-chartgpt-bridge",
|
||||
"gpt_id": "g-6a4ba1b4ea588191bab125d53ce83612",
|
||||
"url": "https://chatgpt.com/g/g-6a4ba1b4ea588191bab125d53ce83612-codex-chartgpt-bridge",
|
||||
"token_ref": "secret_ref://aios-gitops/runtime/gitops-write-bot",
|
||||
"schema_url": "https://cnnetmkt.top/api/openapi-chatgpt-actions.yaml",
|
||||
"auth_binding_status": "requires_gpt_builder_bearer_secret_binding",
|
||||
"server_side_smoke": "pass",
|
||||
"updated_at": "2026-07-06T13:24:28Z"
|
||||
}
|
||||
],
|
||||
"external_actions_binding_note": "ChatGPT GPT Builder must configure Bearer auth with the runtime token behind token_ref; server cannot inject the token into an external GPT by URL alone.",
|
||||
"gpt_builder_api_key_field": "raw token only, without Bearer prefix",
|
||||
"do_not_use_as_api_key": "secret_ref://aios-gitops/runtime/gitops-write-bot",
|
||||
"last_client_response_error_fix": {
|
||||
"updated_at": "2026-07-06T13:41:34Z",
|
||||
"server_changes": [
|
||||
"normalize duplicated Bearer/Authorization prefixes",
|
||||
"return auth_state diagnostics",
|
||||
"flatten branch/rev/sync_status on write success"
|
||||
],
|
||||
"external_builder_requirement": "paste raw token into Bearer API key field"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"name": "aios-multi-agent-consensus-policy",
|
||||
"version": "1.0.0",
|
||||
"mode": "bounded-autonomous-consensus",
|
||||
"description": "Production-safe consensus policy for OpenClaw, Codex and ChatGPT agents supervising AIOS GitOps changes.",
|
||||
"agents": {
|
||||
"openclaw": {
|
||||
"role": "supervisor",
|
||||
"weight": 2,
|
||||
"required_for_high_risk": true,
|
||||
"responsibility": [
|
||||
"execution supervision",
|
||||
"policy compliance",
|
||||
"failure containment",
|
||||
"owner-facing status certification"
|
||||
]
|
||||
},
|
||||
"codex": {
|
||||
"role": "validator_and_repairer",
|
||||
"weight": 2,
|
||||
"required_for_high_risk": true,
|
||||
"responsibility": [
|
||||
"static validation",
|
||||
"acceptance validation",
|
||||
"self-healing patch loop",
|
||||
"merge candidate generation"
|
||||
]
|
||||
},
|
||||
"chatgpt": {
|
||||
"role": "operator_and_summary_agent",
|
||||
"weight": 1,
|
||||
"required_for_high_risk": false,
|
||||
"responsibility": [
|
||||
"change explanation",
|
||||
"owner notification summary",
|
||||
"repair proposal generation",
|
||||
"runbook authoring"
|
||||
]
|
||||
}
|
||||
},
|
||||
"decision_rules": {
|
||||
"low_risk": {
|
||||
"minimum_score": 2,
|
||||
"required_agents": ["codex"],
|
||||
"allowed_outcome": "auto_merge_candidate"
|
||||
},
|
||||
"medium_risk": {
|
||||
"minimum_score": 3,
|
||||
"required_agents": ["codex", "openclaw"],
|
||||
"allowed_outcome": "auto_merge_candidate_after_gray"
|
||||
},
|
||||
"high_risk": {
|
||||
"minimum_score": 5,
|
||||
"required_agents": ["codex", "openclaw"],
|
||||
"allowed_outcome": "production_request_or_preapproved_execution"
|
||||
}
|
||||
},
|
||||
"hard_blocks": [
|
||||
"credential_material_detected",
|
||||
"direct_main_push_detected",
|
||||
"destructive_history_operation",
|
||||
"auth_protection_disabled",
|
||||
"gitea_protection_disabled",
|
||||
"baseline_activation_without_approval",
|
||||
"owner_notification_failed_for_main_change",
|
||||
"required_acceptance_test_failed"
|
||||
],
|
||||
"required_artifacts": {
|
||||
"validation": "reports/codex/validation-report.json",
|
||||
"openclaw": "reports/openclaw/final-supervision-report.json",
|
||||
"consensus": "reports/consensus/decision.json",
|
||||
"merge_candidate": "reports/codex/merge-candidate.json",
|
||||
"baseline_proposal": "versions/proposals/V1R1C5-gitops-platform-ui.proposed.json"
|
||||
},
|
||||
"production_safety": {
|
||||
"direct_main_push": false,
|
||||
"tag_creation": "request_or_preapproved_only",
|
||||
"baseline_activation": "request_or_preapproved_only",
|
||||
"rollback_execution": "request_or_preapproved_only",
|
||||
"owner_notification": "mandatory_for_main_version_change",
|
||||
"web_ui_audit_record": "mandatory"
|
||||
},
|
||||
"outputs": {
|
||||
"approved": "merge_candidate_ready",
|
||||
"rejected": "failure_report_required",
|
||||
"blocked": "owner_review_required"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
{
|
||||
"name": "aios-self-governing-devops-policy",
|
||||
"version": "1.0.0",
|
||||
"mode": "bounded-self-governing",
|
||||
"description": "Self-governing DevOps policy for AIOS GitOps with dynamic agent weights, drift detection, guarded rollback requests, policy runtime and skill evolution.",
|
||||
"non_negotiable_controls": {
|
||||
"direct_main_push": false,
|
||||
"all_writes_require_git_commit": true,
|
||||
"real_credentials_in_git": false,
|
||||
"disable_auth_protection": false,
|
||||
"delete_audit_or_version_history": false,
|
||||
"production_execution_without_policy": false
|
||||
},
|
||||
"governance_loop": [
|
||||
"observe_events",
|
||||
"evaluate_policy",
|
||||
"score_agents",
|
||||
"detect_consensus_drift",
|
||||
"render_decision_graph",
|
||||
"run_validation_or_repair",
|
||||
"generate_candidate_or_request",
|
||||
"notify_owner",
|
||||
"persist_audit",
|
||||
"learn_from_outcome",
|
||||
"propose_skill_update"
|
||||
],
|
||||
"agent_learning": {
|
||||
"enabled": true,
|
||||
"weight_bounds": {
|
||||
"openclaw": [1, 4],
|
||||
"codex": [1, 4],
|
||||
"chatgpt": [1, 3]
|
||||
},
|
||||
"initial_weights": {
|
||||
"openclaw": 2,
|
||||
"codex": 2,
|
||||
"chatgpt": 1
|
||||
},
|
||||
"signals": {
|
||||
"validation_accuracy": 0.35,
|
||||
"repair_success": 0.25,
|
||||
"false_positive_rate": -0.15,
|
||||
"owner_override": -0.2,
|
||||
"post_merge_stability": 0.25
|
||||
},
|
||||
"update_policy": "bounded_exponential_smoothing",
|
||||
"write_target": "runtime/governance/agent-weights.json"
|
||||
},
|
||||
"drift_detection": {
|
||||
"enabled": true,
|
||||
"thresholds": {
|
||||
"consensus_score_drop": 2,
|
||||
"agent_disagreement_ratio": 0.5,
|
||||
"repeated_failure_count": 3,
|
||||
"owner_override_count": 2
|
||||
},
|
||||
"on_drift": "block_production_and_request_owner_review"
|
||||
},
|
||||
"policy_runtime": {
|
||||
"required_checks": [
|
||||
"no_direct_main_write",
|
||||
"no_real_credentials",
|
||||
"no_destructive_history_change",
|
||||
"acceptance_tests_pass",
|
||||
"owner_notice_for_main_change",
|
||||
"rollback_target_defined",
|
||||
"baseline_proposal_exists"
|
||||
],
|
||||
"decision_outputs": [
|
||||
"allow_candidate",
|
||||
"conditional_candidate",
|
||||
"request_owner_review",
|
||||
"block"
|
||||
]
|
||||
},
|
||||
"guarded_rollback": {
|
||||
"mode": "request_first",
|
||||
"automatic_action": "create_rollback_request_and_notify_owner",
|
||||
"execution": "requires_preapproved_runbook_or_owner_action",
|
||||
"default_target": "V1R1C4"
|
||||
},
|
||||
"skill_evolution": {
|
||||
"enabled": true,
|
||||
"mode": "propose_only",
|
||||
"sources": [
|
||||
"validation_failures",
|
||||
"owner_feedback",
|
||||
"post_merge_incidents",
|
||||
"repeated_manual_repairs"
|
||||
],
|
||||
"output_dir": "skills/proposals",
|
||||
"activation": "merge_after_acceptance"
|
||||
},
|
||||
"owner_control": {
|
||||
"channels": ["web_ui", "wechat_or_wecom"],
|
||||
"actions": ["audit", "approve", "reject", "request_change", "request_rollback"],
|
||||
"main_version_change_notice": "mandatory"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "self-healing-gitops",
|
||||
"version": "1.0.0",
|
||||
"mode": "codex-driven-ci",
|
||||
"description": "Defines how Codex + CI loop detects failures, generates repair plan, and re-runs validation.",
|
||||
"workflow": {
|
||||
"trigger": ["push", "pull_request"],
|
||||
"validation_script": "scripts/validate-gitops-platform-ui.sh",
|
||||
"failure_handling": {
|
||||
"create_failure_report": true,
|
||||
"report_path": "reports/codex/failure.json",
|
||||
"notify": "codex-gpt",
|
||||
"auto_repair": false,
|
||||
"require_human_or_gpt_review": true
|
||||
},
|
||||
"success_handling": {
|
||||
"allow_merge_candidate": true,
|
||||
"propose_baseline_update": "V1R1C5"
|
||||
}
|
||||
},
|
||||
"self_healing_loop": {
|
||||
"steps": [
|
||||
"run_validation",
|
||||
"collect_failures",
|
||||
"send_to_gpt",
|
||||
"apply_patch_to_branch",
|
||||
"rerun_validation",
|
||||
"repeat_until_pass"
|
||||
],
|
||||
"max_iterations": 5,
|
||||
"stop_conditions": [
|
||||
"PASS",
|
||||
"critical_security_failure",
|
||||
"manual_override"
|
||||
]
|
||||
},
|
||||
"constraints": {
|
||||
"no_main_write": true,
|
||||
"no_secret_exposure": true,
|
||||
"approval_required_for_release": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
{
|
||||
"name": "aios-self-optimizing-orchestrator",
|
||||
"version": "1.0.0",
|
||||
"mode": "hybrid-event-plus-reconciliation",
|
||||
"description": "Low-impact orchestrator for ChatGPT/Codex self-optimization, version-change notification, audit, and owner action routing.",
|
||||
"design_principles": {
|
||||
"execution_layer_impact": "minimal",
|
||||
"writes": "git_patch_only",
|
||||
"direct_main_push": false,
|
||||
"secrets_in_git": false,
|
||||
"production_high_risk_actions": "approval_or_preapproved_runbook_only",
|
||||
"user_impact": "prefer_async_sidecar_and_read_only_observation"
|
||||
},
|
||||
"triggers": {
|
||||
"primary": {
|
||||
"type": "event_driven",
|
||||
"sources": [
|
||||
"git_push",
|
||||
"tag_created",
|
||||
"baseline_changed",
|
||||
"patch_created",
|
||||
"validation_failed",
|
||||
"validation_passed",
|
||||
"merge_candidate_created",
|
||||
"runtime_health_degraded"
|
||||
],
|
||||
"target": "orchestrator_queue"
|
||||
},
|
||||
"fallback": {
|
||||
"type": "scheduled_reconciliation",
|
||||
"intervals": {
|
||||
"codex_branch_scan": "*/15 * * * *",
|
||||
"chatgpt_patch_scan": "*/15 * * * *",
|
||||
"main_version_scan": "*/5 * * * *",
|
||||
"notification_retry_scan": "*/10 * * * *",
|
||||
"daily_owner_digest": "0 9 * * *"
|
||||
},
|
||||
"purpose": "Catch missed events, reconcile web UI records, and retry failed notifications."
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"codex": {
|
||||
"responsibility": [
|
||||
"code validation",
|
||||
"static/security/policy tests",
|
||||
"self-healing patch loop",
|
||||
"merge candidate report"
|
||||
],
|
||||
"trigger": "event_and_schedule",
|
||||
"skill": "skills/gitops-platform-validation/SKILL.md"
|
||||
},
|
||||
"chatgpt": {
|
||||
"responsibility": [
|
||||
"operator summary",
|
||||
"failure triage",
|
||||
"GitOps patch authoring",
|
||||
"owner-facing change explanation"
|
||||
],
|
||||
"trigger": "event_and_schedule",
|
||||
"skill": "skills/gitops-owner-notification/SKILL.md"
|
||||
}
|
||||
},
|
||||
"change_summary_required_fields": [
|
||||
"event_id",
|
||||
"branch",
|
||||
"from_rev",
|
||||
"to_rev",
|
||||
"from_version",
|
||||
"to_version",
|
||||
"elapsed_since_previous_main_change",
|
||||
"changed_features",
|
||||
"code_lines_added",
|
||||
"code_lines_deleted",
|
||||
"config_lines_added",
|
||||
"config_lines_deleted",
|
||||
"test_status",
|
||||
"risk",
|
||||
"approval_status",
|
||||
"rollback_target",
|
||||
"web_ui_audit_url"
|
||||
],
|
||||
"owner_channels": {
|
||||
"wechat": {
|
||||
"enabled": true,
|
||||
"provider": "wechat_or_wecom",
|
||||
"webhook_secret_ref": "secret_ref://aios/notifications/owner-wechat-webhook",
|
||||
"signing_secret_ref": "secret_ref://aios/notifications/owner-action-signing-key",
|
||||
"message_types": [
|
||||
"main_version_changed",
|
||||
"merge_candidate_ready",
|
||||
"validation_failed",
|
||||
"rollback_request_created",
|
||||
"approval_required",
|
||||
"daily_digest"
|
||||
],
|
||||
"actions": [
|
||||
"view_audit",
|
||||
"approve_preapproved_runbook",
|
||||
"request_rollback",
|
||||
"request_change",
|
||||
"reject_release"
|
||||
]
|
||||
},
|
||||
"web_ui": {
|
||||
"enabled": true,
|
||||
"audit_route": "/portal/me/#gitops-audit",
|
||||
"owner_action_route": "/portal/me/#gitops-owner-actions",
|
||||
"record_store": "runtime/gitops/events/*.json"
|
||||
}
|
||||
},
|
||||
"version_change_policy": {
|
||||
"main_change_notification": "mandatory",
|
||||
"notify_owner_on_every_main_version_change": true,
|
||||
"include_diff_stats": true,
|
||||
"include_elapsed_time_since_previous_main_change": true,
|
||||
"include_function_summary": true,
|
||||
"write_web_ui_record": true,
|
||||
"retry_until_delivered_or_acknowledged": true
|
||||
},
|
||||
"owner_action_policy": {
|
||||
"wechat_actions": "signed_short_link_or_command_token",
|
||||
"web_actions": "login_required_portal_action",
|
||||
"all_actions_audited": true,
|
||||
"high_risk_actions": "request_only_unless_preapproved_runbook",
|
||||
"rollback": "create_request_by_default",
|
||||
"change_request": "create_patch_or_plan_by_default"
|
||||
},
|
||||
"minimal_execution_layer_changes": [
|
||||
"Add sidecar orchestrator queue consumer.",
|
||||
"Add event writer for GitOps version changes.",
|
||||
"Add notification sender using secret_ref only.",
|
||||
"Add Web UI audit/action records reading event files.",
|
||||
"Keep existing /api/base /api/ver /api/diff /api/patch /api/model /api/run stable."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "v1r1-execution-api-runtime",
|
||||
"version": "1.0.0",
|
||||
"mode": "api_runtime_not_filesystem",
|
||||
"baseline": "V1R1C4",
|
||||
"target_proposal": "V1R1C5",
|
||||
"description": "Execution API runtime contract for AIOS Xiamayiti self-evolution loop. All production execution requests must go through the V1R1 runtime API rather than direct filesystem or protected credential access.",
|
||||
"security": {
|
||||
"direct_runtime_secret_access": false,
|
||||
"direct_filesystem_production_write": false,
|
||||
"all_execution_audited": true,
|
||||
"owner_control_required_for_high_risk": true,
|
||||
"direct_main_push": false
|
||||
},
|
||||
"api": {
|
||||
"submit_execution_request": "POST /api/v1r1/execution/request",
|
||||
"get_execution_status": "GET /api/v1r1/execution/status/{request_id}",
|
||||
"append_audit": "POST /api/v1r1/audit/append",
|
||||
"send_owner_notification": "POST /api/v1r1/notification/owner",
|
||||
"submit_owner_action": "POST /api/v1r1/owner/action"
|
||||
},
|
||||
"allowed_request_types": [
|
||||
"audit_record",
|
||||
"owner_notification",
|
||||
"evolution_finding",
|
||||
"evolution_plan",
|
||||
"validation_run",
|
||||
"merge_candidate",
|
||||
"release_request",
|
||||
"rollback_request",
|
||||
"baseline_activation_request"
|
||||
],
|
||||
"blocked_request_types": [
|
||||
"write_protected_credential",
|
||||
"force_push",
|
||||
"delete_history",
|
||||
"delete_audit",
|
||||
"disable_auth",
|
||||
"disable_gitea_protection",
|
||||
"direct_main_push"
|
||||
],
|
||||
"execution_states": [
|
||||
"received",
|
||||
"policy_checked",
|
||||
"accepted",
|
||||
"queued",
|
||||
"executed_as_request",
|
||||
"blocked",
|
||||
"failed"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "v1r1-existing-wechat-runtime-integration",
|
||||
"version": "1.0.0",
|
||||
"baseline": "V1R1C4",
|
||||
"description": "Integrates AIOS self-evolution notifications with the already deployed Xiamayiti/V1R1 personal WeChat runtime instead of creating a duplicate gateway.",
|
||||
"detected_runtime_processes": [
|
||||
{
|
||||
"process_hint": "/data/agent/services/ucc-wechat-qr-adapter/adapter.py",
|
||||
"role": "user-control-center personal WeChat QR adapter",
|
||||
"integration_priority": 1
|
||||
},
|
||||
{
|
||||
"process_hint": "scripts/wechat_notify_worker.py",
|
||||
"role": "existing personal WeChat notification worker",
|
||||
"integration_priority": 2
|
||||
}
|
||||
],
|
||||
"preferred_integration": {
|
||||
"mode": "existing_worker_queue_bridge",
|
||||
"runtime_env": "AIOS_V1R1_WECHAT_BRIDGE_OUTBOX",
|
||||
"behavior": "Write normalized owner notification JSON into the existing V1R1 WeChat worker queue directory. The existing V1R1 worker is responsible for actual personal WeChat delivery."
|
||||
},
|
||||
"fallback_integration": {
|
||||
"mode": "http_endpoint",
|
||||
"runtime_env": "AIOS_V1R1_WECHAT_OUTBOUND_URL",
|
||||
"behavior": "POST normalized owner notification JSON to a runtime-provided V1R1 notification API endpoint."
|
||||
},
|
||||
"rules": {
|
||||
"do_not_duplicate_wechat_gateway": true,
|
||||
"do_not_store_credentials_in_git": true,
|
||||
"do_not_access_runtime_secrets_directly": true,
|
||||
"web_ui_and_wechat_share_event_id": true,
|
||||
"delivery_status_must_be_audited": true
|
||||
},
|
||||
"operator_discovery_commands": [
|
||||
"ps aux | grep -E 'ucc-wechat|wechat_notify'",
|
||||
"pwdx <pid>",
|
||||
"tr '\\0' '\\n' < /proc/<pid>/environ",
|
||||
"grep -R \"queue\|outbox\|notify\|send\|wechat\" -n /data/agent /data/aios-gitops/workspace/scripts 2>/dev/null | head -100"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
CREATE TABLE IF NOT EXISTS message_outbox (
|
||||
id TEXT PRIMARY KEY,
|
||||
channel TEXT NOT NULL,
|
||||
recipient_user_id TEXT NOT NULL,
|
||||
recipient_role TEXT,
|
||||
message_type TEXT NOT NULL,
|
||||
priority TEXT NOT NULL DEFAULT 'normal',
|
||||
title TEXT,
|
||||
content TEXT NOT NULL,
|
||||
payload TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
retry_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
sent_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_message_outbox_dispatch
|
||||
ON message_outbox(channel, status, priority, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channel_bindings (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
channel_type TEXT NOT NULL,
|
||||
external_chat_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_bindings_lookup
|
||||
ON channel_bindings(user_id, channel_type, status, updated_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id TEXT PRIMARY KEY,
|
||||
actor TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
target TEXT,
|
||||
detail TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
echo "[AIOS] Installing Autonomous GitOps System..."
|
||||
|
||||
WORKDIR=/data/aios-gitops/workspace
|
||||
ETC=/etc/aios
|
||||
RUNTIME=/data/aios-gitops/runtime
|
||||
SERVICE_USER=aios
|
||||
|
||||
if ! id "$SERVICE_USER" >/dev/null 2>&1; then
|
||||
echo "[AIOS] Creating system user: $SERVICE_USER"
|
||||
useradd --system --home /data/aios-gitops --shell /usr/sbin/nologin "$SERVICE_USER"
|
||||
fi
|
||||
|
||||
mkdir -p "$ETC"
|
||||
cp deploy/systemd/aios-autonomous-daemon.env.template "$ETC/aios-autonomous-daemon.env"
|
||||
|
||||
mkdir -p \
|
||||
"$RUNTIME/events" \
|
||||
"$RUNTIME/audit" \
|
||||
"$RUNTIME/requests" \
|
||||
"$RUNTIME/reports/codex" \
|
||||
"$RUNTIME/approvals/preapproved" \
|
||||
"$RUNTIME/integrations/v1r1-personal-wechat/outbox" \
|
||||
"$RUNTIME/integrations/v1r1-personal-wechat/inbox" \
|
||||
"$RUNTIME/integrations/v1r1-personal-wechat/status"
|
||||
|
||||
chown -R "$SERVICE_USER:$SERVICE_USER" /data/aios-gitops/runtime || true
|
||||
chown -R "$SERVICE_USER:$SERVICE_USER" "$WORKDIR" || true
|
||||
|
||||
cp deploy/systemd/aios-autonomous-daemon.service /etc/systemd/system/
|
||||
cp deploy/systemd/aios-v1r1-wechat-worker.service /etc/systemd/system/
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable aios-autonomous-daemon.service || true
|
||||
systemctl enable aios-v1r1-wechat-worker.service || true
|
||||
|
||||
systemctl restart aios-autonomous-daemon.service || true
|
||||
systemctl restart aios-v1r1-wechat-worker.service || true
|
||||
|
||||
sleep 2
|
||||
systemctl status aios-autonomous-daemon.service --no-pager || true
|
||||
systemctl status aios-v1r1-wechat-worker.service --no-pager || true
|
||||
|
||||
echo "[AIOS] Installation complete"
|
||||
@@ -0,0 +1,16 @@
|
||||
# AIOS Autonomous GitOps Daemon runtime environment template
|
||||
# Copy to /etc/aios/aios-autonomous-daemon.env during deployment.
|
||||
# This template stores only paths and secret_ref identifiers. Runtime values are injected by the host secret manager.
|
||||
|
||||
AIOS_REPO_ROOT=/data/aios-gitops/workspace
|
||||
AIOS_EVENT_DIR=/data/aios-gitops/runtime/events
|
||||
AIOS_AUDIT_DIR=/data/aios-gitops/runtime/audit
|
||||
AIOS_REQUEST_DIR=/data/aios-gitops/runtime/requests
|
||||
AIOS_REPORT_DIR=/data/aios-gitops/runtime/reports/codex
|
||||
AIOS_PREAPPROVAL_DIR=/data/aios-gitops/runtime/approvals/preapproved
|
||||
AIOS_DAEMON_INTERVAL=30
|
||||
AIOS_INGRESS_BIND=127.0.0.1
|
||||
AIOS_INGRESS_PORT=8787
|
||||
AIOS_OWNER_CHANNEL_REF=secret_ref://aios/notifications/owner-wechat-endpoint
|
||||
AIOS_OWNER_ACTION_SIGN_REF=secret_ref://aios/notifications/owner-action-signing
|
||||
AIOS_INGRESS_SIGN_REF=secret_ref://aios/notifications/inbound-event-signing
|
||||
@@ -0,0 +1,21 @@
|
||||
[Unit]
|
||||
Description=AIOS Autonomous GitOps Daemon
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=aios
|
||||
WorkingDirectory=/data/aios-gitops/workspace
|
||||
EnvironmentFile=/etc/aios/aios-autonomous-daemon.env
|
||||
ExecStart=/usr/bin/python3 services/aios-autonomous-daemon/daemon.py run
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
# Hardening (minimal impact)
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=full
|
||||
ProtectHome=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,18 @@
|
||||
[Unit]
|
||||
Description=AIOS Multi-Agent Consensus Controller
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=aios
|
||||
WorkingDirectory=/data/aios-gitops/workspace
|
||||
ExecStart=/usr/bin/python3 services/aios-consensus-controller/controller.py
|
||||
|
||||
# Hardening (non-invasive)
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=full
|
||||
ProtectHome=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,22 @@
|
||||
[Unit]
|
||||
Description=AIOS V1R1 Personal WeChat Outbound Worker
|
||||
After=network.target aios-autonomous-daemon.service
|
||||
Wants=aios-autonomous-daemon.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=aios
|
||||
WorkingDirectory=/data/aios-gitops/workspace
|
||||
EnvironmentFile=/etc/aios/aios-autonomous-daemon.env
|
||||
ExecStart=/usr/bin/python3 services/aios-v1r1-wechat-outbound-worker/worker.py
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
# Hardening
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=full
|
||||
ProtectHome=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,210 @@
|
||||
# AIOS 远程 Codex 多 Agent 统筹架构
|
||||
|
||||
## 1. 核心定位
|
||||
|
||||
当前 AIOS / 虾马一体系统的自我进化,不再假设服务器上存在 Codex CLI。
|
||||
|
||||
真实运行模型是:
|
||||
|
||||
- Windows 11 Codex App:总控 / 统筹 / 拆分任务 / 监督多个子 agent;
|
||||
- 服务器 OpenClaw:生产运行侧消息、微信、执行与通道能力;
|
||||
- 服务器 Hermes Agent:运行观测、事件、消息/用户交互、反馈归集;
|
||||
- 服务器 AIOS Autonomous Daemon:GitOps event loop、validation、audit、request;
|
||||
- GitOps 仓库:唯一可审计状态同步面;
|
||||
- WebOps / Portal:owner 审计、审批、回滚、变更入口。
|
||||
|
||||
## 2. 架构图
|
||||
|
||||
```text
|
||||
Windows 11 Codex App
|
||||
├─ Planner Agent
|
||||
├─ Code Patch Agent
|
||||
├─ Validation Agent
|
||||
├─ WeChat/UCC Integration Agent
|
||||
├─ WebOps UI Agent
|
||||
└─ Supervisor Agent
|
||||
↓ git / ssh / reports
|
||||
GitOps Repository: patch/web-ui-complete-gitops-dashboard-20260706
|
||||
↓ pull / event
|
||||
AIOS Server Runtime
|
||||
├─ aios-autonomous-daemon
|
||||
├─ OpenClaw Agent / Gateway
|
||||
├─ Hermes Agent
|
||||
├─ UCC WeChat Adapter
|
||||
├─ WebOps / message_outbox
|
||||
└─ audit / reports / requests
|
||||
↓
|
||||
Owner WebOps + 个人微信
|
||||
```
|
||||
|
||||
## 3. 分工
|
||||
|
||||
### Windows Codex App
|
||||
|
||||
Windows Codex 是统筹器,不是服务器守护进程。
|
||||
|
||||
职责:
|
||||
|
||||
1. 读取 GitOps 指令与当前 branch;
|
||||
2. 启动多个子 agent 并行处理;
|
||||
3. 生成代码/配置/文档 patch;
|
||||
4. push 到同一 GitOps patch 分支;
|
||||
5. 读取服务器回传的验证结果;
|
||||
6. 对失败项继续修复;
|
||||
7. 输出 merge candidate / blocked reason。
|
||||
|
||||
### OpenClaw Agent
|
||||
|
||||
职责:
|
||||
|
||||
1. 维护微信通道、message send、用户通道状态;
|
||||
2. 提供个人微信实际执行能力;
|
||||
3. 接收 message_outbox worker 或 UCC Adapter 的调用;
|
||||
4. 输出 channel status / delivery result;
|
||||
5. 不直接接收业务模块调用。
|
||||
|
||||
### Hermes Agent
|
||||
|
||||
职责:
|
||||
|
||||
1. 汇总系统运行事件;
|
||||
2. 处理用户/消息交互流;
|
||||
3. 将微信或 WebOps 反馈转为系统事件;
|
||||
4. 协助 self-evolution loop 判断用户体验和业务状态;
|
||||
5. 输出观测报告给 GitOps / Codex。
|
||||
|
||||
### AIOS Autonomous Daemon
|
||||
|
||||
职责:
|
||||
|
||||
1. 处理 runtime events;
|
||||
2. 触发 validation / merge gate;
|
||||
3. 写 audit / request / report;
|
||||
4. 不直接改 runtime secrets;
|
||||
5. 不直接调用微信底层适配器。
|
||||
|
||||
## 4. 并行子 Agent 模型
|
||||
|
||||
Windows Codex 可启动以下子 agent:
|
||||
|
||||
| 子 Agent | 任务 |
|
||||
|---|---|
|
||||
| planner | 读取 directive,拆分任务,确认风险 |
|
||||
| server-inspector | 解析服务器命令输出,定位 runtime 状态 |
|
||||
| code-patcher | 修改 GitOps 仓库代码 |
|
||||
| test-author | 补验收用例和验证脚本 |
|
||||
| wechat-integrator | 处理 message_outbox / UCC / OpenClaw 适配 |
|
||||
| webops-ui | 补 WebOps 页面和 API contract |
|
||||
| supervisor | 汇总 PASS/FAIL/BLOCKED |
|
||||
|
||||
## 5. 自动运行机制
|
||||
|
||||
自动运行不代表 Codex 在服务器常驻,而是通过以下 loop 运行:
|
||||
|
||||
```text
|
||||
server event / owner request
|
||||
↓
|
||||
GitOps directive / report
|
||||
↓
|
||||
Windows Codex pulls branch
|
||||
↓
|
||||
Codex child agents patch code/config
|
||||
↓
|
||||
git push
|
||||
↓
|
||||
server git pull + run validation
|
||||
↓
|
||||
reports written
|
||||
↓
|
||||
Codex reads reports
|
||||
↓
|
||||
repeat until PASS/BLOCKED
|
||||
```
|
||||
|
||||
## 6. 通信通道
|
||||
|
||||
### Git
|
||||
|
||||
主要状态同步面:
|
||||
|
||||
- directives;
|
||||
- patches;
|
||||
- code/config;
|
||||
- docs;
|
||||
- acceptance tests;
|
||||
- version proposals。
|
||||
|
||||
### SSH / Operator Logs
|
||||
|
||||
服务器运行结果通过 SSH 或人工粘贴日志回传给 Codex/GPT。
|
||||
|
||||
### Runtime Reports
|
||||
|
||||
服务器写入:
|
||||
|
||||
```text
|
||||
reports/codex/*.json
|
||||
reports/openclaw/*.json
|
||||
reports/hermes/*.json
|
||||
reports/consensus/*.json
|
||||
runtime/gitops/audit/*.json
|
||||
runtime/gitops/requests/*.json
|
||||
```
|
||||
|
||||
## 7. 生产安全边界
|
||||
|
||||
仍然禁止:
|
||||
|
||||
- 直接 main push;
|
||||
- force push;
|
||||
- 写 runtime secrets;
|
||||
- 删除 audit / version history;
|
||||
- 业务模块直接调用 OpenClaw;
|
||||
- 业务模块直接调用 UCC WeChat adapter;
|
||||
- 绕过 message_outbox;
|
||||
- 伪造 sent 状态。
|
||||
|
||||
## 8. 微信链路标准
|
||||
|
||||
生产标准链路:
|
||||
|
||||
```text
|
||||
业务/系统事件
|
||||
→ POST /api/v1/wechat/outbox 或写 message_outbox
|
||||
→ wechat_notify_worker
|
||||
→ wechat_send_backend
|
||||
→ ucc-adapter / openclaw-channel
|
||||
→ 个人微信
|
||||
→ sent/failed/retry/audit
|
||||
```
|
||||
|
||||
当前优先后端:
|
||||
|
||||
```text
|
||||
ucc-adapter
|
||||
```
|
||||
|
||||
OpenClaw channel 仅作为可发现的可选后端,不得硬编码 `openclaw-weixin`。
|
||||
|
||||
## 9. 自我进化目标
|
||||
|
||||
自我进化不是“代码自己改代码”,而是虾马一体系统整体优化:
|
||||
|
||||
1. 业务流程优化;
|
||||
2. 运行健康优化;
|
||||
3. 模型路由优化;
|
||||
4. 通知和审批体验优化;
|
||||
5. WebOps 可观测性优化;
|
||||
6. 必要时生成代码/配置 patch。
|
||||
|
||||
## 10. 进入自动运行的判据
|
||||
|
||||
满足以下条件即认为进入自动运行:
|
||||
|
||||
1. `aios-autonomous-daemon` running;
|
||||
2. OpenClaw/Hermes 进程 running;
|
||||
3. message_outbox 可写;
|
||||
4. Windows Codex 监控目标 branch;
|
||||
5. Codex 可读取 directive 并 push patch;
|
||||
6. 服务器可运行验收命令并写 reports;
|
||||
7. owner 可在 WebOps 或微信收到关键进展。
|
||||
@@ -0,0 +1,154 @@
|
||||
# Codex 验收手册:GitOps Platform UI
|
||||
|
||||
## 目标
|
||||
|
||||
本次变更的目标是把 `cnnetmkt.top` AIOS GitOps Web UI 从基础 dashboard 升级为平台级控制台,形成可审计、可审批、可灰度、可回滚请求的 GitOps 操作入口。
|
||||
|
||||
## 验证分支
|
||||
|
||||
- Branch: `patch/web-ui-complete-gitops-dashboard-20260706`
|
||||
- Base: `main`
|
||||
- 当前目标基线: `V1R1C4`
|
||||
- 合并后建议新基线: `V1R1C5`
|
||||
|
||||
## 主线差异点
|
||||
|
||||
与 `main` 相比,本分支新增或修改以下直接内容:
|
||||
|
||||
1. `overlays/user-control-center/portal/gitops-dashboard/index.html`
|
||||
- 新增平台级 GitOps 控制台页面。
|
||||
- 包含 Overview、RBAC、Approval、Diff Tree、Patch、Model、Release、Audit 模块。
|
||||
|
||||
2. `overlays/user-control-center/portal/gitops-dashboard/app.js`
|
||||
- 新增浏览器运行时 Platform Control Plane。
|
||||
- 包含 API-first client、RBAC guard、approval fallback、audit local store、diff tree parser、model graph、SSE event hook。
|
||||
|
||||
3. `overlays/user-control-center/portal/gitops-dashboard/styles.css`
|
||||
- 控制台样式。
|
||||
|
||||
4. `overlays/user-control-center/portal/gitops-dashboard/manifest.json`
|
||||
- 控制台 manifest。
|
||||
|
||||
5. `overlays/user-control-center/portal/gitops-dashboard/gitops-ui-policy.json`
|
||||
- UI 侧策略声明。
|
||||
|
||||
6. `overlays/user-control-center/portal/gitops-dashboard/gitops-enterprise-api.contract.json`
|
||||
- 后端 API 契约说明。
|
||||
|
||||
7. `patches/patch_web-ui-complete-gitops-dashboard-20260706.branch.json`
|
||||
- GitOps branch 元数据。
|
||||
|
||||
## 间接影响区域
|
||||
|
||||
Codex 验证时必须确认以下间接影响:
|
||||
|
||||
1. Portal 登录入口不应被破坏。
|
||||
- `/portal/me/#gitops`
|
||||
- `/portal/me/#gitops-dashboard`
|
||||
|
||||
2. 现有短链仍应保持登录保护或正常跳转。
|
||||
- `/base`
|
||||
- `/ver`
|
||||
- `/diff`
|
||||
- `/patch`
|
||||
- `/model`
|
||||
- `/run`
|
||||
|
||||
3. 机器 API 不应被前端变更破坏。
|
||||
- `/api/base`
|
||||
- `/api/ver`
|
||||
- `/api/diff`
|
||||
- `/api/patch`
|
||||
- `/api/model`
|
||||
- `/api/run`
|
||||
|
||||
4. 敏感信息不得进入 Git、日志、前端源码或测试产物。
|
||||
- 禁止真实 API key。
|
||||
- 禁止 Bearer token。
|
||||
- 禁止 password / private key。
|
||||
- 允许 `secret_ref://...`。
|
||||
|
||||
5. 高风险操作不得绕过审批。
|
||||
- rollback 只能 request。
|
||||
- tag/release 默认只能 request。
|
||||
- runtime 写入只能 request。
|
||||
- UI 不提供 direct main write。
|
||||
|
||||
## Codex 快速验证命令
|
||||
|
||||
在目标工作区执行:
|
||||
|
||||
```bash
|
||||
git fetch --all --prune
|
||||
git checkout patch/web-ui-complete-gitops-dashboard-20260706
|
||||
git diff --stat main...HEAD
|
||||
bash scripts/validate-gitops-platform-ui.sh patch/web-ui-complete-gitops-dashboard-20260706 main
|
||||
```
|
||||
|
||||
## 灰度验证外的必测项目
|
||||
|
||||
除正常灰度页面点击验证外,Codex 必须按 `tests/acceptance/gitops-platform-ui.acceptance.json` 执行以下测试组:
|
||||
|
||||
1. Static Files
|
||||
- 必须存在 index/html/js/css/manifest/policy/contract 文件。
|
||||
|
||||
2. Security
|
||||
- 源码中不得出现真实 secret/token/password。
|
||||
- 模型注册必须只允许 `secret_ref://`。
|
||||
- rollback/tag/runtime/main write 必须 request-only 或 disabled。
|
||||
|
||||
3. UI Direct Impact
|
||||
- 所有 tab id 必须能在 HTML 中找到。
|
||||
- app.js 必须绑定 `DOMContentLoaded`。
|
||||
- app.js 必须包含 `guardAction`、`audit`、`createApproval`、`renderDiffTree`。
|
||||
|
||||
4. API Contract Compatibility
|
||||
- 读接口保持 `/api/base`、`/api/ver`、`/api/run`、`/api/patch`、`/api/model`。
|
||||
- 写接口必须包含 branch、patch/plan、model、model-priority、rollback-request、tag-request。
|
||||
|
||||
5. Indirect Regression
|
||||
- 不得修改 auth/casdoor 配置。
|
||||
- 不得修改 Gitea owner password sync 配置。
|
||||
- 不得修改 baseline active_version。
|
||||
- 不得删除任何版本/tag/patch 历史。
|
||||
|
||||
## 验收判定
|
||||
|
||||
- PASS: 所有 required 测试项通过,且没有 secret 泄漏、没有 direct main write、没有破坏短链/API。
|
||||
- FAIL: 任一 required 测试项失败。
|
||||
- BLOCKED: 需要真实后端或浏览器环境,但静态检查通过;必须列出 blocked 项,不得合并 main。
|
||||
|
||||
## 失败回流规则
|
||||
|
||||
如果验证失败,Codex 必须输出:
|
||||
|
||||
1. 失败测试 ID。
|
||||
2. 失败文件路径。
|
||||
3. 具体失败原因。
|
||||
4. 最小修改建议。
|
||||
5. 是否阻止合并。
|
||||
|
||||
然后把问题交给自定义 GPT 继续修改同一 patch 分支。修复后重复执行完整验收,直到 PASS。
|
||||
|
||||
## 合并规则
|
||||
|
||||
只有满足以下条件才能合并 main:
|
||||
|
||||
1. `scripts/validate-gitops-platform-ui.sh` PASS。
|
||||
2. acceptance JSON 中所有 `required: true` 项 PASS。
|
||||
3. 灰度验证通过。
|
||||
4. 无 secret 泄漏。
|
||||
5. 无 direct main write。
|
||||
6. 已创建或确认合并后基线提案 `V1R1C5`。
|
||||
|
||||
## 合并后基线要求
|
||||
|
||||
合并进入 main 后,必须定义新基线:
|
||||
|
||||
- Version: `V1R1C5`
|
||||
- Purpose: `Promote AIOS GitOps Platform Control Plane UI with Codex validation workflow, acceptance tests, RBAC/approval/audit/release/model control-plane scaffolding.`
|
||||
- Risk: `high`
|
||||
- Approval: `owner/admin required`
|
||||
- Rollback target: `V1R1C4`
|
||||
|
||||
合并后再创建 tag/version,不允许在未合并或未验收通过前直接创建生产 tag。
|
||||
@@ -1,81 +0,0 @@
|
||||
# Codex ChatGPT Bridge Actions Setup
|
||||
|
||||
Date: 2026-07-06T13:24:28Z
|
||||
GPT: codex-chartgpt-bridge
|
||||
URL: https://chatgpt.com/g/g-6a4ba1b4ea588191bab125d53ce83612-codex-chartgpt-bridge
|
||||
|
||||
## Current Server-Side State
|
||||
|
||||
The AIOS GitOps API write path is ready for this GPT bridge.
|
||||
|
||||
- API base: `https://cnnetmkt.top/api`
|
||||
- OpenAPI schema: `https://cnnetmkt.top/api/openapi-chatgpt-actions.yaml`
|
||||
- Auth type: Bearer token
|
||||
- Required header: `Authorization: Bearer <token>`
|
||||
- Token account: `gitops-write-bot`
|
||||
- Token source: `secret_ref://aios-gitops/runtime/gitops-write-bot`
|
||||
- Runtime token file: `/data/aios-gitops/runtime/secrets/gitops-write-bot.token`
|
||||
|
||||
The token value must not be copied into Git, docs, logs, prompts, or issue comments.
|
||||
|
||||
## Required GPT Builder Action Binding
|
||||
|
||||
The GPT URL alone cannot make the GPT send a Bearer header. In the GPT Builder Actions configuration for `codex-chartgpt-bridge`, bind authentication as:
|
||||
|
||||
```text
|
||||
Authentication type: API Key / Bearer
|
||||
Auth header: Authorization
|
||||
Auth value / API key field: <raw runtime token value only>
|
||||
Schema URL: https://cnnetmkt.top/api/openapi-chatgpt-actions.yaml
|
||||
Secret source: secret_ref://aios-gitops/runtime/gitops-write-bot
|
||||
```
|
||||
|
||||
If the builder asks for the token value, read it only from the runtime secret file on the server and paste it into the GPT Builder secret field. Do not paste it into chat.
|
||||
|
||||
## Server-Side Smoke Already Passed
|
||||
|
||||
- no-token `createPatch`: `401 unauthorized`
|
||||
- `gitops-write-bot` `createPatch`: `200`, branch synced
|
||||
- `gitops-write-bot` `commitFile`: `200`, branch synced
|
||||
- `codex-gitops-bot` `createPatch`: `200`, branch synced
|
||||
- `codex-gitops-bot` `commitFile`: `200`, branch synced
|
||||
|
||||
## Latest ClientResponseError Fix
|
||||
|
||||
Current public API and auth smoke status:
|
||||
|
||||
- `GET /api/base`, `GET /api/run`, `GET /api/status`, and the OpenAPI schema URL return `200`.
|
||||
- no-token writes return `401` with `auth_state=missing_bearer`.
|
||||
- using the public `secret_ref://...` string as the API key returns `401` with `auth_state=secret_ref_literal_not_token`.
|
||||
- duplicated `Bearer ` prefixes are normalized server-side and accepted when the underlying token is valid.
|
||||
- valid raw-token `createPatch` and `commitFile` return `branch`, `rev`, and `sync_status` at top level.
|
||||
- `HEAD /api/base`, `HEAD /api/run`, and `HEAD /api/openapi-chatgpt-actions.yaml` return `200`, preventing endpoint validators from treating public reads as unsupported.
|
||||
|
||||
The GPT Builder API key field must contain only the raw token value.
|
||||
|
||||
If Actions returns `auth_state=invalid_token`, the GPT did send a Bearer value, but it is not the current runtime token. Re-open the GPT Builder Action authentication dialog, replace the hidden API key field with the current raw value from `/data/aios-gitops/runtime/secrets/gitops-write-bot.token`, save the Action, publish/update the GPT, and start a fresh GPT conversation before retesting.
|
||||
|
||||
## External GPT Retest
|
||||
|
||||
After binding the Bearer token in GPT Builder, ask the GPT to run:
|
||||
|
||||
1. `createPatch` with a branch under `patch/`.
|
||||
2. `commitFile` on that same branch.
|
||||
3. `getDiff` for the new branch versus `main`.
|
||||
|
||||
Expected result:
|
||||
|
||||
- `createPatch` returns `200`.
|
||||
- `commitFile` returns `200`.
|
||||
- branch sync status is `synced`.
|
||||
- no direct `main` write occurs.
|
||||
|
||||
## Security Boundary
|
||||
|
||||
- `direct_main_push=false`
|
||||
- `delete_tag=false`
|
||||
- `owner_approval_required=true`
|
||||
- V-level operations still require owner/admin approval.
|
||||
- Custom LLM API keys must use secret refs only.
|
||||
|
||||
Important: because the GPT Builder screenshot has Bearer selected, the API key field must contain only the raw token value. Do not type `Bearer ` in that field. Do not type `secret_ref://aios-gitops/runtime/gitops-write-bot`; that string is a public reference, not a credential.
|
||||
@@ -1,57 +0,0 @@
|
||||
# GitOps Write Auth Fix
|
||||
|
||||
Date: 2026-07-06T11:50:41Z
|
||||
Branch: `patch/gitops-write-auth-fix`
|
||||
|
||||
## Root Cause
|
||||
|
||||
Read operations were working because they are public at the server layer. Write operations returned `401 unauthorized` when ChatGPT Actions did not inject `Authorization: Bearer ...` for write calls.
|
||||
|
||||
Runtime token records already supported write scopes for bot accounts, so the API write path itself was not broken. A bearer-authenticated smoke test proved `createPatch` and `commitFile` succeed when the write token is supplied.
|
||||
|
||||
## Fix
|
||||
|
||||
- Added/normalized runtime bot account: `gitops-write-bot`.
|
||||
- Runtime token file: `/data/aios-gitops/runtime/secrets/gitops-write-bot.token`.
|
||||
- Git-visible credential reference only: `secret_ref://aios-gitops/runtime/gitops-write-bot`.
|
||||
- Added token hash and scopes to `/data/aios-gitops/runtime/secrets/gitops.tokens`.
|
||||
- Updated OpenAPI schema to declare bearer auth at top level and on write operations.
|
||||
- Updated API 401/403 responses to include required scope and `token_secret_ref`, without exposing secrets.
|
||||
- Added smoke test: `scripts/smoke_gitops_write_auth.py`.
|
||||
|
||||
## Required Actions Auth Binding
|
||||
|
||||
Configure ChatGPT Actions / Codex Actions auth with:
|
||||
|
||||
- Auth type: Bearer token
|
||||
- Header: `Authorization: Bearer <token>`
|
||||
- Secret source: `secret_ref://aios-gitops/runtime/gitops-write-bot`
|
||||
|
||||
Do not paste the token into Git, docs, logs, prompts, or issue comments.
|
||||
|
||||
## Smoke
|
||||
|
||||
```bash
|
||||
python3 scripts/smoke_gitops_write_auth.py --api https://cnnetmkt.top/api --token-file /data/aios-gitops/runtime/secrets/gitops-write-bot.token
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- no-token `createPatch` returns `401`
|
||||
- authenticated `createPatch` returns `200`
|
||||
- authenticated `commitFile` returns `200`
|
||||
|
||||
## Rollback
|
||||
|
||||
1. Remove or disable the `gitops-write-bot` JSON record in `/data/aios-gitops/runtime/secrets/gitops.tokens`.
|
||||
2. Remove `/data/aios-gitops/runtime/secrets/gitops-write-bot.token` if rotation is required.
|
||||
3. Revert this patch branch or restore OpenAPI/API files from `main`.
|
||||
4. Restart the API service:
|
||||
|
||||
```bash
|
||||
sudo systemctl restart aios-gitops-model-api.service
|
||||
```
|
||||
|
||||
5. Verify unauthenticated writes still return `401`.
|
||||
|
||||
Important: because the GPT Builder screenshot has Bearer selected, the API key field must contain only the raw token value. Do not type `Bearer ` in that field. Do not type `secret_ref://aios-gitops/runtime/gitops-write-bot`; that string is a public reference, not a credential.
|
||||
@@ -1,135 +0,0 @@
|
||||
# GitOps Write Auth Fix Acceptance
|
||||
|
||||
Time: 2026-07-06T13:26:26Z
|
||||
Branch: `patch/gitops-write-auth-fix`
|
||||
Patch commit: see `git rev-parse --short patch/gitops-write-auth-fix`
|
||||
Bridge GPT: `codex-chartgpt-bridge`
|
||||
Bridge URL: https://chatgpt.com/g/g-6a4ba1b4ea588191bab125d53ce83612-codex-chartgpt-bridge
|
||||
|
||||
## Result
|
||||
|
||||
SERVER-SIDE PASS. The GitOps API write path accepts valid Bearer tokens and rejects no-token writes. The external GPT still must have Bearer authentication configured in GPT Builder Actions; a GPT URL alone cannot inject the header.
|
||||
|
||||
## Root Cause Confirmed
|
||||
|
||||
- Read endpoints were healthy without auth.
|
||||
- Write endpoints returned `401 unauthorized` when Actions did not send `Authorization: Bearer ...`.
|
||||
- Runtime token registry supports write scopes; the broken boundary was Actions auth header injection / write token binding.
|
||||
|
||||
## Fix Applied
|
||||
|
||||
- Added dedicated runtime account `gitops-write-bot`.
|
||||
- Runtime token file: `/data/aios-gitops/runtime/secrets/gitops-write-bot.token`, mode `600`.
|
||||
- Token registry stores hash/scopes only in `/data/aios-gitops/runtime/secrets/gitops.tokens`.
|
||||
- Git-visible token reference only: `secret_ref://aios-gitops/runtime/gitops-write-bot`.
|
||||
- OpenAPI now advertises Bearer auth, write operation scopes, and the bridge GPT URL.
|
||||
- `/api/status` returns `bridge_gpts` with the `codex-chartgpt-bridge` URL and auth-binding status.
|
||||
- Added bridge setup doc: `docs/gitops/CODEX_CHATGPT_BRIDGE_ACTIONS_SETUP.md`.
|
||||
- Added smoke script: `scripts/smoke_gitops_write_auth.py`.
|
||||
|
||||
## Smoke Results
|
||||
|
||||
### gitops-write-bot
|
||||
|
||||
```json
|
||||
{
|
||||
"noauth": {"operation": "createPatch without token", "http": 401, "status": "unauthorized"},
|
||||
"createPatch": {"operation": "createPatch", "http": 200, "status": "patch_branch_created", "branch": "patch/gitops-write-auth-smoke-20260706T115230Z", "rev": "6d3a050", "sync_status": "synced"},
|
||||
"commitFile": {"operation": "commitFile", "http": 200, "status": "committed", "branch": "patch/gitops-write-auth-smoke-20260706T115230Z", "rev": "17344f7", "sync_status": "synced"}
|
||||
}
|
||||
```
|
||||
|
||||
### codex-gitops-bot
|
||||
|
||||
```json
|
||||
{
|
||||
"noauth": {"operation": "createPatch without token", "http": 401, "status": "unauthorized"},
|
||||
"createPatch": {"operation": "createPatch", "http": 200, "status": "patch_branch_created", "branch": "patch/codex-write-auth-smoke-20260706T115256Z", "rev": "d684df2", "sync_status": "synced"},
|
||||
"commitFile": {"operation": "commitFile", "http": 200, "status": "committed", "branch": "patch/codex-write-auth-smoke-20260706T115256Z", "rev": "884a5ec", "sync_status": "synced"}
|
||||
}
|
||||
```
|
||||
|
||||
### codex-chartgpt-bridge binding smoke
|
||||
|
||||
```json
|
||||
{
|
||||
"noauth": {"operation": "createPatch without token", "http": 401, "status": "unauthorized"},
|
||||
"createPatch": {"operation": "createPatch", "http": 200, "status": "patch_branch_created", "branch": "patch/bridge-write-auth-smoke-20260706T132506Z", "rev": "f4ef8e5", "sync_status": "synced"},
|
||||
"commitFile": {"operation": "commitFile", "http": 200, "status": "committed", "branch": "patch/bridge-write-auth-smoke-20260706T132506Z", "rev": "0ed0816", "sync_status": "synced"}
|
||||
}
|
||||
```
|
||||
|
||||
## ClientResponseError Retest (2026-07-06T13:47:06Z)
|
||||
|
||||
The external GPT Actions failure was reproduced as a write-auth boundary issue, not a public endpoint outage. Public reads reached the API and returned typed responses, while writes without a valid Bearer credential returned structured `401` responses.
|
||||
|
||||
```text
|
||||
GET /api/base: 200 application/json
|
||||
GET /api/run: 200 application/json
|
||||
GET /api/status: 200 application/json
|
||||
GET /api/openapi-chatgpt-actions.yaml: 200 application/yaml
|
||||
POST /api/patch without token: 401 auth_state=missing_bearer
|
||||
POST /api/patch with literal secret_ref as token: 401 auth_state=secret_ref_literal_not_token
|
||||
POST /api/patch with duplicated Bearer prefix: 200 branch=patch/actions-double-bearer-20260706T134646Z rev=ddd8711 sync_status=synced
|
||||
POST /api/patch with raw runtime token: 200 branch=patch/actions-raw-token-smoke-20260706T134706Z rev=0e48e1d sync_status=synced
|
||||
POST /api/file with raw runtime token: 200 branch=patch/actions-raw-token-smoke-20260706T134706Z rev=6ab67e9 sync_status=synced
|
||||
```
|
||||
|
||||
Server-side compatibility added in this patch:
|
||||
|
||||
- Normalize accidental `Authorization:` and repeated `Bearer ` prefixes before token verification.
|
||||
- Reject literal `secret_ref://...` as a token and return `auth_state=secret_ref_literal_not_token`.
|
||||
- Return `auth_state`, `required_scope`, and GPT Builder guidance on `401`.
|
||||
- Return top-level `branch`, `rev`, and `sync_status` on write success so Actions can make deterministic pass/fail decisions.
|
||||
- Support `HEAD` probes for public read/schema endpoints so GPT Builder or gateway validators do not see `501` during endpoint discovery.
|
||||
|
||||
GPT Builder still must store the raw runtime token in the hidden API key field. The public `secret_ref://aios-gitops/runtime/gitops-write-bot` value is a pointer only and must not be used as the API key.
|
||||
|
||||
HEAD compatibility retest:
|
||||
|
||||
```text
|
||||
HEAD /api/base: 200 application/json
|
||||
HEAD /api/run: 200 application/json
|
||||
HEAD /api/openapi-chatgpt-actions.yaml: 200 application/yaml
|
||||
```
|
||||
|
||||
## External GPT Retest (2026-07-06T13:59:43Z)
|
||||
|
||||
The custom GPT retest reached the server:
|
||||
|
||||
```text
|
||||
GET /api/run: 200
|
||||
GET /api/base: 200
|
||||
POST /api/branch: 401
|
||||
POST /api/patch: 401
|
||||
```
|
||||
|
||||
This confirms that routing, TLS, schema loading, and read access are healthy. The remaining failure is the external GPT Builder secret binding: the write request does not carry the runtime `gitops-write-bot` raw token value. If a token-like value is present but does not match the token registry, the API now reports `auth_state=invalid_token` and `rebind_required=true`.
|
||||
|
||||
## External GPT Builder Binding Required
|
||||
|
||||
Configure the GPT Actions auth for `codex-chartgpt-bridge` as:
|
||||
|
||||
```text
|
||||
Schema URL: https://cnnetmkt.top/api/openapi-chatgpt-actions.yaml
|
||||
Authentication type: API Key / Bearer
|
||||
Header: Authorization
|
||||
Value / API key field: <raw runtime token value only>
|
||||
Secret source: secret_ref://aios-gitops/runtime/gitops-write-bot
|
||||
```
|
||||
|
||||
The actual token value must be read from `/data/aios-gitops/runtime/secrets/gitops-write-bot.token` and entered only into the GPT Builder secret/auth field. Do not paste it into chat or Git.
|
||||
|
||||
## Exit Criteria
|
||||
|
||||
- ChatGPT Actions write capability: PASS once the GPT Builder auth field is bound to the runtime token.
|
||||
- Server-side authenticated write path: PASS.
|
||||
- Codex API commit capability: PASS.
|
||||
- No-token write requests remain rejected: PASS, `401 unauthorized`.
|
||||
- No plaintext token committed to Git: PASS by workspace secret pattern scan.
|
||||
|
||||
## Security Notes
|
||||
|
||||
The patch does not allow direct `main` writes, does not permit tag deletion, and does not bypass owner/admin approval for V-level actions. Credentials stay in runtime secrets; Git stores only secret refs and token hashes.
|
||||
|
||||
Important: because the GPT Builder screenshot has Bearer selected, the API key field must contain only the raw token value. Do not type `Bearer ` in that field. Do not type `secret_ref://aios-gitops/runtime/gitops-write-bot`; that string is a public reference, not a credential.
|
||||
@@ -3,23 +3,15 @@ info:
|
||||
title: AIOS GitOps Controlled Access API
|
||||
version: V1R1C0-chatgpt-actions
|
||||
description: Controlled ChatGPT Actions API for AIOS online GitOps baseline. Read endpoints are public. Write endpoints require Bearer token and create Git commits on non-main branches.
|
||||
x-aios-bridge-gpts:
|
||||
- name: codex-chartgpt-bridge
|
||||
url: https://chatgpt.com/g/g-6a4ba1b4ea588191bab125d53ce83612-codex-chartgpt-bridge
|
||||
token_secret_ref: secret_ref://aios-gitops/runtime/gitops-write-bot
|
||||
api_key_field: raw token only, without Bearer prefix
|
||||
servers:
|
||||
- url: https://cnnetmkt.top
|
||||
security:
|
||||
- bearerAuth: []
|
||||
security: []
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: opaque-token
|
||||
description: Use Bearer auth for write operations. In GPT Builder Bearer mode, paste only the raw runtime token into the API key field; do not include the word Bearer and do not paste the public secret_ref string. Read endpoints remain public at server level.
|
||||
x-token-secret-ref: secret_ref://aios-gitops/runtime/gitops-write-bot
|
||||
schemas:
|
||||
BranchRequest:
|
||||
type: object
|
||||
@@ -105,26 +97,6 @@ components:
|
||||
priority: {type: integer}
|
||||
enabled: {type: boolean}
|
||||
fallback_policy: {type: string}
|
||||
ActionWriteResult:
|
||||
type: object
|
||||
properties:
|
||||
status: {type: string}
|
||||
branch: {type: string}
|
||||
rev: {type: string}
|
||||
sync_status: {type: string}
|
||||
commit: {type: object}
|
||||
patch: {type: object}
|
||||
path: {type: string}
|
||||
ErrorResponse:
|
||||
type: object
|
||||
properties:
|
||||
error: {type: string}
|
||||
hint: {type: string}
|
||||
auth_state: {type: string}
|
||||
required_scope: {type: string}
|
||||
token_secret_ref: {type: string}
|
||||
auth_header: {type: string}
|
||||
gpt_builder_note: {type: string}
|
||||
NLRequest:
|
||||
type: object
|
||||
required: [text]
|
||||
@@ -161,7 +133,6 @@ paths:
|
||||
responses: {'200': {description: OK}}
|
||||
post:
|
||||
operationId: createPatch
|
||||
x-required-scope: patch:create
|
||||
summary: Create a patch branch and commit metadata
|
||||
security: [{bearerAuth: []}]
|
||||
requestBody:
|
||||
@@ -169,10 +140,7 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/PatchRequest'}
|
||||
responses:
|
||||
'200': {description: Patch branch committed, content: {application/json: {schema: {$ref: '#/components/schemas/ActionWriteResult'}}}}
|
||||
'401': {description: Unauthorized, content: {application/json: {schema: {$ref: '#/components/schemas/ErrorResponse'}}}}
|
||||
'403': {description: Forbidden, content: {application/json: {schema: {$ref: '#/components/schemas/ErrorResponse'}}}}
|
||||
responses: {'200': {description: Patch branch committed}, '401': {description: Unauthorized}, '403': {description: Forbidden}}
|
||||
/api/model:
|
||||
get:
|
||||
operationId: readModelRegistry
|
||||
@@ -180,7 +148,6 @@ paths:
|
||||
responses: {'200': {description: OK}}
|
||||
post:
|
||||
operationId: registerModel
|
||||
x-required-scope: model:write
|
||||
summary: Register a custom model; API keys are stored as runtime secrets and Git receives secret_ref only
|
||||
security: [{bearerAuth: []}]
|
||||
requestBody:
|
||||
@@ -197,7 +164,6 @@ paths:
|
||||
/api/branch:
|
||||
post:
|
||||
operationId: createBranch
|
||||
x-required-scope: branch:create
|
||||
summary: Create a non-main branch and commit branch metadata
|
||||
security: [{bearerAuth: []}]
|
||||
requestBody:
|
||||
@@ -205,14 +171,10 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/BranchRequest'}
|
||||
responses:
|
||||
'200': {description: Branch created, content: {application/json: {schema: {$ref: '#/components/schemas/ActionWriteResult'}}}}
|
||||
'401': {description: Unauthorized, content: {application/json: {schema: {$ref: '#/components/schemas/ErrorResponse'}}}}
|
||||
'403': {description: Forbidden, content: {application/json: {schema: {$ref: '#/components/schemas/ErrorResponse'}}}}
|
||||
responses: {'200': {description: Branch created}, '401': {description: Unauthorized}, '403': {description: Forbidden}}
|
||||
/api/file:
|
||||
post:
|
||||
operationId: commitFile
|
||||
x-required-scope: file:write
|
||||
summary: Write a file and create a Git commit on a non-main branch
|
||||
security: [{bearerAuth: []}]
|
||||
requestBody:
|
||||
@@ -220,14 +182,10 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/FileCommitRequest'}
|
||||
responses:
|
||||
'200': {description: File committed, content: {application/json: {schema: {$ref: '#/components/schemas/ActionWriteResult'}}}}
|
||||
'400': {description: Secret rejected or invalid path, content: {application/json: {schema: {$ref: '#/components/schemas/ErrorResponse'}}}}
|
||||
'401': {description: Unauthorized, content: {application/json: {schema: {$ref: '#/components/schemas/ErrorResponse'}}}}
|
||||
responses: {'200': {description: File committed}, '400': {description: Secret rejected or invalid path}, '401': {description: Unauthorized}}
|
||||
/api/commit:
|
||||
post:
|
||||
operationId: commitChanges
|
||||
x-required-scope: commit:create
|
||||
summary: Commit existing workspace changes on a non-main branch
|
||||
security: [{bearerAuth: []}]
|
||||
requestBody:
|
||||
@@ -250,7 +208,6 @@ paths:
|
||||
/api/rollback:
|
||||
post:
|
||||
operationId: requestRollback
|
||||
x-required-scope: rollback:request
|
||||
summary: Commit a rollback request requiring owner/admin approval
|
||||
security: [{bearerAuth: []}]
|
||||
requestBody:
|
||||
@@ -262,7 +219,6 @@ paths:
|
||||
/api/model-priority:
|
||||
put:
|
||||
operationId: setModelPriority
|
||||
x-required-scope: model:priority
|
||||
summary: Set model priority and commit policy file
|
||||
security: [{bearerAuth: []}]
|
||||
requestBody:
|
||||
@@ -274,7 +230,6 @@ paths:
|
||||
/api/nl:
|
||||
post:
|
||||
operationId: planNaturalLanguageGitOpsAction
|
||||
x-required-scope: nl:plan
|
||||
summary: Convert natural language to a safe committed plan without executing V-level changes
|
||||
security: [{bearerAuth: []}]
|
||||
requestBody:
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
# Codex 指令:修复 V1R1 微信主动发送后端
|
||||
|
||||
Directive ID: `CODEX-FIX-V1R1-WECHAT-BACKEND-20260707`
|
||||
|
||||
Branch: `patch/web-ui-complete-gitops-dashboard-20260706`
|
||||
|
||||
Base: `main`
|
||||
|
||||
Current baseline: `V1R1C4`
|
||||
|
||||
## 1. 当前阻塞
|
||||
|
||||
当前微信主动发送链路已改为:
|
||||
|
||||
```text
|
||||
message_outbox
|
||||
→ wechat_notify_worker.py
|
||||
→ wechat_send_backend.py
|
||||
→ openclaw-channel 或 ucc-adapter
|
||||
```
|
||||
|
||||
实际验证结果:
|
||||
|
||||
```json
|
||||
{
|
||||
"backend": "unavailable",
|
||||
"reason": "wechat outbound backend unavailable: openclaw channel not registered; ucc adapter send endpoint not detected"
|
||||
}
|
||||
```
|
||||
|
||||
已确认:
|
||||
|
||||
1. OpenClaw Gateway 运行中,但 `openclaw channels list --json` 只有 `{ "chat": {} }`,没有 wechat/weixin channel。
|
||||
2. 当前生产微信能力优先出口应是 `ucc-wechat-qr-adapter`,不是 OpenClaw channel。
|
||||
3. 当前系统存在进程:`/data/agent/services/ucc-wechat-qr-adapter/adapter.py`。
|
||||
4. 当前服务配置:`/etc/aios/ucc-wechat-qr-adapter.env`。
|
||||
5. 当前监听端口:`AIOS_WECHAT_QR_ADAPTER_PORT=18800`。
|
||||
6. `wechat_send_backend.py` 没有探测到 UCC adapter 的可用发送接口。
|
||||
|
||||
## 2. 修复目标
|
||||
|
||||
Codex 必须补齐 V1R1 微信主动发送后端,使 `message_outbox → worker → ucc-adapter → 个人微信` 成为主路径。
|
||||
|
||||
不得让业务模块直接调用:
|
||||
|
||||
- `openclaw message send`;
|
||||
- OpenClaw Gateway `127.0.0.1:18789`;
|
||||
- 微信适配器底层进程;
|
||||
- 本地 secrets 文件。
|
||||
|
||||
业务/外部系统只允许写入:
|
||||
|
||||
```text
|
||||
message_outbox
|
||||
```
|
||||
|
||||
或调用未来统一 API:
|
||||
|
||||
```text
|
||||
POST /api/v1/wechat/outbox
|
||||
```
|
||||
|
||||
## 3. Codex 必须先执行的检查
|
||||
|
||||
只执行命令,不输出 token。
|
||||
|
||||
### 3.1 检查 adapter 路由和函数
|
||||
|
||||
```bash
|
||||
sudo grep -RniE '@.*route|@app\.(get|post)|FastAPI|Flask|send|message|notify|outbound|wechat|target' \
|
||||
/data/agent/services/ucc-wechat-qr-adapter/adapter.py \
|
||||
| sed -E 's/(token|password|secret|key|auth)([=: ][^ ]+)/\1=***MASKED***/Ig' \
|
||||
| head -n 200
|
||||
```
|
||||
|
||||
### 3.2 检查 adapter 健康接口
|
||||
|
||||
```bash
|
||||
for p in \
|
||||
/health \
|
||||
/status \
|
||||
/api/health \
|
||||
/api/internal/health \
|
||||
/api/internal/wechat/status \
|
||||
/api/internal/wechat/send
|
||||
do
|
||||
echo "=== $p ==="
|
||||
curl -sS "http://127.0.0.1:18800$p" || true
|
||||
echo
|
||||
done
|
||||
```
|
||||
|
||||
### 3.3 如需 token,安全读取但不得打印
|
||||
|
||||
```bash
|
||||
ADAPTER_TOKEN="$(sudo awk -F= '/^AIOS_WECHAT_QR_ADAPTER_TOKEN=/{print $2}' /etc/aios/ucc-wechat-qr-adapter.env)"
|
||||
|
||||
for p in \
|
||||
/health \
|
||||
/status \
|
||||
/api/health \
|
||||
/api/internal/health \
|
||||
/api/internal/wechat/status
|
||||
do
|
||||
echo "=== $p ==="
|
||||
curl -sS \
|
||||
-H "Authorization: Bearer ${ADAPTER_TOKEN}" \
|
||||
-H "X-AIOS-Token: ${ADAPTER_TOKEN}" \
|
||||
"http://127.0.0.1:18800$p" || true
|
||||
echo
|
||||
done
|
||||
```
|
||||
|
||||
## 4. 必须实现
|
||||
|
||||
### 4.1 如果 adapter 已有发送接口
|
||||
|
||||
若 `adapter.py` 已经存在发送接口,Codex 应:
|
||||
|
||||
1. 确认真实路径、请求格式、认证头;
|
||||
2. 修改 `scripts/wechat_send_backend.py` 的 `ucc-adapter` backend 调用该真实接口;
|
||||
3. 保证 token 仅从运行时环境或 env 文件读取,不进入 Git;
|
||||
4. 更新验收文档。
|
||||
|
||||
### 4.2 如果 adapter 没有发送接口
|
||||
|
||||
Codex 应在 `/data/agent/services/ucc-wechat-qr-adapter/adapter.py` 对应源码路径中补内部接口,或在 GitOps 仓库新增 overlay/patch 指令,目标接口为:
|
||||
|
||||
```http
|
||||
POST /api/internal/wechat/send
|
||||
```
|
||||
|
||||
请求体:
|
||||
|
||||
```json
|
||||
{
|
||||
"target": "...",
|
||||
"content": "...",
|
||||
"account_id": "...",
|
||||
"message_id": "...",
|
||||
"metadata": {}
|
||||
}
|
||||
```
|
||||
|
||||
认证:
|
||||
|
||||
- 使用 `/etc/aios/ucc-wechat-qr-adapter.env` 中的 `AIOS_WECHAT_QR_ADAPTER_TOKEN`;
|
||||
- 支持 `Authorization: Bearer <token>`;
|
||||
- 支持 `X-AIOS-Token: <token>`;
|
||||
- 不允许打印 token。
|
||||
|
||||
返回:
|
||||
|
||||
成功:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"provider_message_id": "...",
|
||||
"status": "sent"
|
||||
}
|
||||
```
|
||||
|
||||
如果底层暂时没有真实发送函数,必须返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"code": "adapter_send_not_supported"
|
||||
}
|
||||
```
|
||||
|
||||
不得假装 `sent`。
|
||||
|
||||
## 5. 修改范围
|
||||
|
||||
允许修改或新增:
|
||||
|
||||
- `scripts/wechat_send_backend.py`
|
||||
- `scripts/wechat_notify_worker.py`
|
||||
- `scripts/enqueue_wechat_notification.py`
|
||||
- `scripts/message_outbox_schema.py`
|
||||
- `docs/ops/*wechat*`
|
||||
- `tests/acceptance/*wechat*`
|
||||
- UCC adapter 的 overlay/patch 文件
|
||||
- systemd/env template 中与 worker 配置相关的字段
|
||||
|
||||
禁止修改:
|
||||
|
||||
- runtime secrets 明文;
|
||||
- 个人微信原始 token;
|
||||
- Gitea 安全配置;
|
||||
- Casdoor/auth 配置;
|
||||
- baseline active version;
|
||||
- 删除 audit/version/history 的逻辑。
|
||||
|
||||
## 6. 必须新增或更新验收用例
|
||||
|
||||
### TC-WX-BE-01
|
||||
|
||||
OpenClaw channel list 只有 `{ "chat": {} }` 时,不得调用 `openclaw-weixin`。
|
||||
|
||||
预期:
|
||||
|
||||
```text
|
||||
wechat_send_backend 选择 ucc-adapter 或明确 unavailable
|
||||
last_error 不包含 Unknown channel: openclaw-weixin
|
||||
```
|
||||
|
||||
### TC-WX-BE-02
|
||||
|
||||
UCC adapter 存活时自动选择 `ucc-adapter`。
|
||||
|
||||
预期:
|
||||
|
||||
```text
|
||||
python3 scripts/wechat_send_backend.py
|
||||
→ backend=ucc-adapter
|
||||
```
|
||||
|
||||
### TC-WX-BE-03
|
||||
|
||||
outbox 测试消息可进入 worker。
|
||||
|
||||
预期:
|
||||
|
||||
```text
|
||||
message_outbox.status 从 queued 变为 dispatching,再变为 sent 或 failed
|
||||
```
|
||||
|
||||
### TC-WX-BE-04
|
||||
|
||||
如果 adapter 支持真实发送,owner 微信收到测试消息。
|
||||
|
||||
预期:
|
||||
|
||||
```text
|
||||
message_outbox.status=sent
|
||||
audit_logs.action=wechat_outbound_sent
|
||||
```
|
||||
|
||||
### TC-WX-BE-05
|
||||
|
||||
如果 adapter 不支持发送,消息不得丢失。
|
||||
|
||||
预期:
|
||||
|
||||
```text
|
||||
message_outbox.status=queued 或 failed
|
||||
last_error 包含 adapter_send_not_supported
|
||||
audit_logs.action=wechat_outbound_failed
|
||||
```
|
||||
|
||||
### TC-WX-SEC-01
|
||||
|
||||
日志不得泄露:
|
||||
|
||||
- token;
|
||||
- password;
|
||||
- secret;
|
||||
- cookie;
|
||||
- 个人微信原始 ID 给普通用户;
|
||||
- route proof;
|
||||
- runtime secrets 路径。
|
||||
|
||||
## 7. 最小验收命令
|
||||
|
||||
### 7.1 检查 backend
|
||||
|
||||
```bash
|
||||
cd /data/aios-gitops/workspace
|
||||
python3 scripts/wechat_send_backend.py
|
||||
```
|
||||
|
||||
### 7.2 入队测试
|
||||
|
||||
```bash
|
||||
python3 scripts/enqueue_wechat_notification.py \
|
||||
--recipient owner \
|
||||
--recipient-role owner \
|
||||
--message-type system_report \
|
||||
--priority high \
|
||||
--title "AIOS 测试通知" \
|
||||
--content "这是通过 message_outbox → ucc-adapter 发送的主动微信通知。"
|
||||
```
|
||||
|
||||
### 7.3 执行 worker
|
||||
|
||||
```bash
|
||||
python3 scripts/wechat_notify_worker.py --once
|
||||
```
|
||||
|
||||
### 7.4 查询状态
|
||||
|
||||
```bash
|
||||
python3 - <<'PY'
|
||||
import sqlite3
|
||||
con=sqlite3.connect('/data/aios-gitops/workspace/data/webops.sqlite3')
|
||||
con.row_factory=sqlite3.Row
|
||||
print('message_outbox')
|
||||
for r in con.execute('select id,status,retry_count,last_error,sent_at from message_outbox order by created_at desc limit 5'):
|
||||
print(dict(r))
|
||||
print('audit_logs')
|
||||
for r in con.execute('select action,target,detail,created_at from audit_logs order by created_at desc limit 5'):
|
||||
print(dict(r))
|
||||
PY
|
||||
```
|
||||
|
||||
## 8. 输出要求
|
||||
|
||||
Codex 完成后必须输出:
|
||||
|
||||
```text
|
||||
Status: PASS / FAIL / BLOCKED
|
||||
Branch: patch/web-ui-complete-gitops-dashboard-20260706
|
||||
Files Changed: ...
|
||||
Backend Selected: openclaw-channel / ucc-adapter / unavailable
|
||||
Adapter Send Endpoint: detected / added / unsupported
|
||||
Message Outbox Status: sent / queued / failed
|
||||
Audit Status: written / missing
|
||||
Owner WeChat Received: yes / no / not testable
|
||||
Blocked Reason: ...
|
||||
Next Action: ...
|
||||
```
|
||||
|
||||
## 9. 当前期望结果
|
||||
|
||||
最优结果:
|
||||
|
||||
```text
|
||||
backend=ucc-adapter
|
||||
message_outbox.status=sent
|
||||
owner 微信收到 AIOS 测试通知
|
||||
```
|
||||
|
||||
可接受结果:
|
||||
|
||||
```text
|
||||
backend=ucc-adapter
|
||||
message_outbox.status=failed
|
||||
last_error=adapter_send_not_supported
|
||||
```
|
||||
|
||||
不可接受结果:
|
||||
|
||||
```text
|
||||
继续报 Unknown channel: openclaw-weixin
|
||||
假装 sent
|
||||
日志打印 token
|
||||
业务模块直接调用 OpenClaw
|
||||
```
|
||||
@@ -0,0 +1,154 @@
|
||||
# OpenClaw 指令:AIOS Autonomous GitOps 静默优化与部署监督
|
||||
|
||||
Directive ID: `OPENCLAW-AIOS-AUTONOMOUS-GITOPS-DEPLOY-20260707`
|
||||
|
||||
Branch: `patch/web-ui-complete-gitops-dashboard-20260706`
|
||||
|
||||
Base: `main`
|
||||
|
||||
Current baseline: `V1R1C4`
|
||||
|
||||
Proposed baseline after approved merge: `V1R1C5`
|
||||
|
||||
## 1. 执行目的
|
||||
|
||||
OpenClaw 负责接管本分支的执行与监督,目标是在不影响现有执行层、不破坏用户使用体验的前提下,完成 AIOS Autonomous GitOps Runtime 的验证、静默优化、部署准备、Owner 通报与合并候选推进。
|
||||
|
||||
本次改动的目标不是直接绕过 main 保护,而是让系统形成:
|
||||
|
||||
- 自动 CI 验证;
|
||||
- Codex 自愈修复;
|
||||
- event bus + daemon 自动执行;
|
||||
- 微信/网页 Owner 审计与操作通道;
|
||||
- V1R1C5 基线提案;
|
||||
- 失败回流自定义 GPT 修复;
|
||||
- 通过后生成 merge candidate。
|
||||
|
||||
## 2. OpenClaw 角色
|
||||
|
||||
OpenClaw 是本任务的执行监督代理,职责如下:
|
||||
|
||||
1. 读取并监督当前分支与 main 的差异。
|
||||
2. 按验收测试执行静态验证、安全验证、策略验证、灰度验证。
|
||||
3. 发现失败后,生成结构化 failure report。
|
||||
4. 将失败项交给自定义 GPT 继续修复同一 patch 分支。
|
||||
5. 修复后重复验证,直到 PASS 或触发 hard block。
|
||||
6. 生成 merge candidate,但不得直接绕过 main 保护。
|
||||
7. 监督 Owner 通知记录:每个 main 版本变更必须有摘要通报与 Web UI 记录。
|
||||
|
||||
## 3. 禁止事项
|
||||
|
||||
OpenClaw 不得执行以下操作:
|
||||
|
||||
- 不得直接 force push;
|
||||
- 不得删除版本历史、tag、audit 记录;
|
||||
- 不得写入真实凭据;
|
||||
- 不得关闭 auth、Gitea 登录保护或 owner sync;
|
||||
- 不得在未通过验收前合并 main;
|
||||
- 不得在未有审批或预授权 runbook 前执行生产 tag、baseline activation 或 rollback execution。
|
||||
|
||||
## 4. 必须执行的命令
|
||||
|
||||
```bash
|
||||
git fetch --all --prune
|
||||
git checkout patch/web-ui-complete-gitops-dashboard-20260706
|
||||
git diff --stat main...HEAD
|
||||
bash scripts/validate-gitops-platform-ui.sh patch/web-ui-complete-gitops-dashboard-20260706 main
|
||||
bash scripts/autonomous-merge-gate.sh patch/web-ui-complete-gitops-dashboard-20260706 main
|
||||
```
|
||||
|
||||
若部署验证需要启动本地 runtime:
|
||||
|
||||
```bash
|
||||
bash deploy/install/install.sh
|
||||
python3 services/aios-autonomous-daemon/daemon.py emit --type patch_created --branch patch/web-ui-complete-gitops-dashboard-20260706 --base main
|
||||
python3 services/aios-autonomous-daemon/daemon.py run --once
|
||||
```
|
||||
|
||||
## 5. 必须验证的直接修改
|
||||
|
||||
OpenClaw 必须检查以下路径:
|
||||
|
||||
- `overlays/user-control-center/portal/gitops-dashboard/`
|
||||
- `services/aios-autonomous-daemon/`
|
||||
- `services/aios-event-ingress/`
|
||||
- `deploy/systemd/`
|
||||
- `deploy/install/`
|
||||
- `config/codex/`
|
||||
- `scripts/`
|
||||
- `tests/acceptance/`
|
||||
- `versions/proposals/V1R1C5-gitops-platform-ui.proposed.json`
|
||||
- `skills/`
|
||||
- `docs/codex/`
|
||||
|
||||
## 6. 必须验证的间接影响
|
||||
|
||||
OpenClaw 必须确认以下内容未被破坏:
|
||||
|
||||
- `/api/base`、`/api/ver`、`/api/diff`、`/api/patch`、`/api/model`、`/api/run`;
|
||||
- `/base`、`/ver`、`/diff`、`/patch`、`/model`、`/run` 登录保护;
|
||||
- Portal 登录入口 `/portal/me/#gitops`;
|
||||
- Gitea require sign-in view;
|
||||
- owner password sync;
|
||||
- active baseline 仍为 `V1R1C4`,未提前激活 `V1R1C5`。
|
||||
|
||||
## 7. Owner 通报要求
|
||||
|
||||
每个 main 版本变更、merge candidate、validation failure、rollback request、approval required 事件都必须形成:
|
||||
|
||||
1. Owner 消息摘要;
|
||||
2. Web UI 审计记录;
|
||||
3. 可追踪 event_id;
|
||||
4. 变更功能摘要;
|
||||
5. 代码量与配置量统计;
|
||||
6. 与上一次主线变化的间隔;
|
||||
7. 测试状态;
|
||||
8. rollback target。
|
||||
|
||||
## 8. OpenClaw 进度监督格式
|
||||
|
||||
OpenClaw 每次监督输出必须包含:
|
||||
|
||||
```text
|
||||
Status: PASS / FAIL / BLOCKED / RUNNING
|
||||
Branch: patch/web-ui-complete-gitops-dashboard-20260706
|
||||
Base: main
|
||||
Current Rev: <rev>
|
||||
Validation: <summary>
|
||||
Gray Test: <summary>
|
||||
Owner Notice: <summary>
|
||||
Merge Candidate: yes/no
|
||||
Blocked Reason: <reason or none>
|
||||
Next Action: <action>
|
||||
```
|
||||
|
||||
## 9. 失败回流规则
|
||||
|
||||
若失败,OpenClaw 必须写入 failure report:
|
||||
|
||||
- 路径:`reports/openclaw/failure-<event_id>.json`
|
||||
- 内容:失败测试 ID、文件路径、失败原因、建议修复、是否阻断合并。
|
||||
|
||||
随后将问题交给自定义 GPT 修改当前 patch 分支,修复后重新执行完整验收。
|
||||
|
||||
## 10. 合并前门禁
|
||||
|
||||
只有全部满足以下条件,OpenClaw 才能标记为 `merge_candidate_ready`:
|
||||
|
||||
- required acceptance tests 全部 PASS;
|
||||
- 无凭据泄露;
|
||||
- 无 direct main write;
|
||||
- 无高风险未审批动作;
|
||||
- 灰度验证 PASS;
|
||||
- Owner 通报与 Web UI 记录正常;
|
||||
- `V1R1C5` baseline proposal 存在且未提前激活。
|
||||
|
||||
## 11. 最终输出
|
||||
|
||||
OpenClaw 最终应生成:
|
||||
|
||||
- `reports/openclaw/final-supervision-report.json`
|
||||
- `reports/codex/merge-candidate.json`
|
||||
- Owner 摘要事件记录;
|
||||
- Web UI audit record;
|
||||
- 若失败则生成 failure report 并阻止合并。
|
||||
@@ -0,0 +1,462 @@
|
||||
/* AIOS GitOps Platform Control Plane
|
||||
* Browser-only runtime with API-first behavior and safe local fallback.
|
||||
* No credentials or secret values are stored here. Model registration accepts secret_ref only.
|
||||
*/
|
||||
const Platform = (() => {
|
||||
const state = {
|
||||
tenant: 'default',
|
||||
role: 'viewer',
|
||||
identity: null,
|
||||
baseline: null,
|
||||
versions: null,
|
||||
runtime: null,
|
||||
patches: null,
|
||||
models: null,
|
||||
approvals: [],
|
||||
audit: [],
|
||||
policy: null,
|
||||
eventSource: null
|
||||
};
|
||||
|
||||
const ENDPOINTS = {
|
||||
identity: 'identity',
|
||||
policy: 'platform/policy',
|
||||
audit: 'audit',
|
||||
approvals: 'approval',
|
||||
approvalRequest: 'approval-request',
|
||||
approvalDecision: 'approval-decision',
|
||||
branch: 'branch',
|
||||
plan: 'patch/plan',
|
||||
base: 'base',
|
||||
versions: 'ver',
|
||||
diff: 'diff',
|
||||
patch: 'patch',
|
||||
model: 'model',
|
||||
priority: 'model-priority',
|
||||
runtime: 'run',
|
||||
rollback: 'rollback-request',
|
||||
tag: 'tag-request',
|
||||
events: 'events/gitops'
|
||||
};
|
||||
|
||||
const DEFAULT_POLICY = {
|
||||
roles: {
|
||||
viewer: ['read:*', 'audit:read'],
|
||||
operator: ['read:*', 'audit:read', 'branch:create', 'plan:create', 'approval:create'],
|
||||
admin: ['read:*', 'audit:read', 'branch:create', 'plan:create', 'approval:create', 'model:write', 'priority:write', 'release:request', 'rollback:request'],
|
||||
owner: ['read:*', 'audit:*', 'branch:*', 'plan:*', 'approval:*', 'model:*', 'priority:*', 'release:*', 'rollback:*', 'runtime:*']
|
||||
},
|
||||
highRiskActions: ['release:execute', 'rollback:execute', 'runtime:write', 'tag:create', 'main:write'],
|
||||
destructiveActions: ['delete:*', 'force_push', 'purge:*', 'clear:*'],
|
||||
requiredApprovals: { low: 1, medium: 1, high: 2 },
|
||||
tenantIsolation: true,
|
||||
secretHygiene: {
|
||||
allowSecretRefOnly: true,
|
||||
forbiddenFields: ['api_key', 'token', 'password', 'secret', 'private_key', 'bearer']
|
||||
}
|
||||
};
|
||||
|
||||
function el(id) { return document.getElementById(id); }
|
||||
function qs(sel) { return document.querySelector(sel); }
|
||||
function qsa(sel) { return Array.from(document.querySelectorAll(sel)); }
|
||||
|
||||
function now() { return new Date().toISOString(); }
|
||||
|
||||
function safeStringify(value) {
|
||||
return JSON.stringify(redact(value), null, 2);
|
||||
}
|
||||
|
||||
function redact(value) {
|
||||
if (Array.isArray(value)) return value.map(redact);
|
||||
if (value && typeof value === 'object') {
|
||||
const out = {};
|
||||
for (const [key, val] of Object.entries(value)) {
|
||||
const k = key.toLowerCase();
|
||||
if (['api_key', 'apikey', 'token', 'password', 'secret', 'private_key', 'authorization', 'bearer'].some(x => k.includes(x))) {
|
||||
out[key] = typeof val === 'string' && val.startsWith('secret_ref://') ? val : '[REDACTED]';
|
||||
} else out[key] = redact(val);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function put(id, value) {
|
||||
const target = el(id);
|
||||
if (target) target.textContent = typeof value === 'string' ? value : safeStringify(value);
|
||||
}
|
||||
|
||||
async function api(path, method = 'GET', body = undefined, options = {}) {
|
||||
const url = `/api/${path}`;
|
||||
const headers = { 'Content-Type': 'application/json', 'X-AIOS-Tenant': state.tenant };
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
credentials: 'same-origin',
|
||||
body: body === undefined ? undefined : JSON.stringify(body)
|
||||
});
|
||||
let data = null;
|
||||
try { data = await res.json(); } catch (_) { data = { status: res.status, text: await res.text() }; }
|
||||
if (!res.ok && !options.allowError) {
|
||||
const err = new Error(`API ${method} ${url} failed with ${res.status}`);
|
||||
err.response = data;
|
||||
throw err;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function has(capability) {
|
||||
const policy = state.policy || DEFAULT_POLICY;
|
||||
const caps = policy.roles[state.role] || [];
|
||||
return caps.includes('*') || caps.includes(capability) || caps.some(c => c.endsWith(':*') && capability.startsWith(c.slice(0, -1))) || caps.includes('read:*') && capability.startsWith('read:');
|
||||
}
|
||||
|
||||
function guardAction(capability, risk = 'low') {
|
||||
const policy = state.policy || DEFAULT_POLICY;
|
||||
if (policy.destructiveActions.some(pattern => matchCapability(pattern, capability))) return { allowed: false, reason: 'destructive_action_blocked' };
|
||||
if (policy.highRiskActions.includes(capability)) return { allowed: false, reason: 'high_risk_requires_approval' };
|
||||
if (!has(capability)) return { allowed: false, reason: 'insufficient_role' };
|
||||
if (risk === 'high' && !['admin', 'owner'].includes(state.role)) return { allowed: false, reason: 'high_risk_admin_required' };
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
function matchCapability(pattern, capability) {
|
||||
if (pattern === capability) return true;
|
||||
if (pattern.endsWith('*')) return capability.startsWith(pattern.slice(0, -1));
|
||||
return false;
|
||||
}
|
||||
|
||||
function audit(action, target, status, detail = {}) {
|
||||
const record = redact({
|
||||
ts: now(), tenant: state.tenant, role: state.role, action, target, status, detail
|
||||
});
|
||||
state.audit.unshift(record);
|
||||
state.audit = state.audit.slice(0, 500);
|
||||
localStorage.setItem('aios.gitops.audit', JSON.stringify(state.audit));
|
||||
put('audit-output', record);
|
||||
renderAudit();
|
||||
postAudit(record);
|
||||
return record;
|
||||
}
|
||||
|
||||
async function postAudit(record) {
|
||||
try { await api(ENDPOINTS.audit, 'POST', record, { allowError: true }); } catch (_) { /* local fallback */ }
|
||||
}
|
||||
|
||||
function loadLocal() {
|
||||
try { state.audit = JSON.parse(localStorage.getItem('aios.gitops.audit') || '[]'); } catch (_) { state.audit = []; }
|
||||
try { state.approvals = JSON.parse(localStorage.getItem('aios.gitops.approvals') || '[]'); } catch (_) { state.approvals = []; }
|
||||
}
|
||||
|
||||
function saveApprovals() {
|
||||
localStorage.setItem('aios.gitops.approvals', JSON.stringify(state.approvals));
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
loadLocal();
|
||||
wireTabs();
|
||||
wireForms();
|
||||
await loadIdentity();
|
||||
await loadPolicy();
|
||||
applyRbac();
|
||||
await refreshAll();
|
||||
await refreshApprovals();
|
||||
await refreshAudit();
|
||||
connectEvents();
|
||||
}
|
||||
|
||||
async function loadIdentity() {
|
||||
try {
|
||||
const identity = await api(ENDPOINTS.identity, 'GET', undefined, { allowError: true });
|
||||
state.identity = identity && identity.user ? identity : { user: 'unknown', role: identity.role || 'viewer', tenant: identity.tenant || 'default' };
|
||||
state.role = state.identity.role || 'viewer';
|
||||
state.tenant = state.identity.tenant || 'default';
|
||||
} catch (_) {
|
||||
state.identity = { user: 'local-preview', role: 'admin', tenant: 'default', source: 'local_fallback' };
|
||||
state.role = 'admin';
|
||||
state.tenant = 'default';
|
||||
}
|
||||
put('identity-output', state.identity);
|
||||
const roleChip = el('role-chip');
|
||||
if (roleChip) roleChip.textContent = `role: ${state.role}`;
|
||||
}
|
||||
|
||||
async function loadPolicy() {
|
||||
try {
|
||||
const policy = await api(ENDPOINTS.policy, 'GET', undefined, { allowError: true });
|
||||
state.policy = policy && policy.roles ? policy : DEFAULT_POLICY;
|
||||
} catch (_) { state.policy = DEFAULT_POLICY; }
|
||||
put('rbac-output', state.policy);
|
||||
renderPermissionMatrix();
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
const tasks = [
|
||||
['baseline', ENDPOINTS.base],
|
||||
['versions', ENDPOINTS.versions],
|
||||
['runtime', ENDPOINTS.runtime],
|
||||
['patches', ENDPOINTS.patch],
|
||||
['models', ENDPOINTS.model]
|
||||
];
|
||||
for (const [key, endpoint] of tasks) {
|
||||
try { state[key] = await api(endpoint, 'GET', undefined, { allowError: true }); }
|
||||
catch (e) { state[key] = { error: e.message, response: e.response }; }
|
||||
}
|
||||
put('baseline-output', state.baseline);
|
||||
put('versions-output', state.versions);
|
||||
put('runtime-output', state.runtime);
|
||||
put('patch-summary-output', state.patches);
|
||||
put('model-output', state.models);
|
||||
updateMetrics();
|
||||
renderModelGraph();
|
||||
audit('dashboard.refresh', 'all', 'ok');
|
||||
}
|
||||
|
||||
function updateMetrics() {
|
||||
const runtimeStatus = state.runtime && state.runtime.status || 'unknown';
|
||||
const version = state.baseline && (state.baseline.active_version || state.baseline.version) || '-';
|
||||
const branches = state.patches && Array.isArray(state.patches.branches) ? state.patches.branches.filter(b => b.startsWith('patch/')).length : 0;
|
||||
const pending = state.approvals.filter(a => a.status === 'pending').length;
|
||||
setText('metric-runtime', runtimeStatus);
|
||||
setText('metric-version', version);
|
||||
setText('metric-branches', String(branches));
|
||||
setText('metric-approvals', String(pending));
|
||||
setText('runtime-chip', `runtime: ${runtimeStatus}`);
|
||||
}
|
||||
|
||||
function setText(id, text) { const node = el(id); if (node) node.textContent = text; }
|
||||
|
||||
function applyRbac() {
|
||||
qsa('.require-operator').forEach(btn => btn.disabled = !has('approval:create') && !has('branch:create'));
|
||||
qsa('.require-admin').forEach(btn => btn.disabled = !['admin', 'owner'].includes(state.role));
|
||||
qsa('.guarded').forEach(btn => btn.disabled = state.role !== 'owner');
|
||||
renderPermissionMatrix();
|
||||
}
|
||||
|
||||
function renderPermissionMatrix() {
|
||||
const policy = state.policy || DEFAULT_POLICY;
|
||||
const caps = ['read:*', 'branch:create', 'plan:create', 'approval:create', 'model:write', 'priority:write', 'release:request', 'rollback:request', 'runtime:write'];
|
||||
const rows = Object.keys(policy.roles).map(role => {
|
||||
const cells = caps.map(cap => `<td>${roleHas(role, cap, policy) ? '✅' : '—'}</td>`).join('');
|
||||
return `<tr><th>${escapeHtml(role)}</th>${cells}</tr>`;
|
||||
}).join('');
|
||||
const html = `<table><thead><tr><th>Role</th>${caps.map(c => `<th>${escapeHtml(c)}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table>`;
|
||||
const target = el('permission-matrix'); if (target) target.innerHTML = html;
|
||||
}
|
||||
|
||||
function roleHas(role, cap, policy) {
|
||||
const caps = policy.roles[role] || [];
|
||||
return caps.includes('*') || caps.includes(cap) || caps.some(c => c.endsWith(':*') && cap.startsWith(c.slice(0, -1))) || (caps.includes('read:*') && cap.startsWith('read:'));
|
||||
}
|
||||
|
||||
function renderModelGraph() {
|
||||
const target = el('model-priority-graph');
|
||||
if (!target) return;
|
||||
const base = state.models && Array.isArray(state.models.base) ? state.models.base : [];
|
||||
const custom = state.models && Array.isArray(state.models.custom) ? state.models.custom : [];
|
||||
const priority = state.models && Array.isArray(state.models.priority) ? state.models.priority : [];
|
||||
const models = [...base, ...custom];
|
||||
if (!models.length) { target.innerHTML = '<p class="empty">无模型数据</p>'; return; }
|
||||
const byId = new Map(priority.map(p => [p.model_id || p.id, p]));
|
||||
target.innerHTML = models.map((m, idx) => {
|
||||
const p = byId.get(m.id) || { priority: 100 - idx, enabled: true, fallback_policy: 'next_enabled' };
|
||||
return `<div class="node"><strong>${escapeHtml(m.display_name || m.model || m.id)}</strong><span>${escapeHtml(m.id || '')}</span><em>priority ${escapeHtml(String(p.priority))} · ${p.enabled === false ? 'disabled' : 'enabled'} · ${escapeHtml(p.fallback_policy || 'next_enabled')}</em></div>`;
|
||||
}).join('<div class="edge">↓ fallback</div>');
|
||||
}
|
||||
|
||||
async function refreshApprovals() {
|
||||
try {
|
||||
const remote = await api(ENDPOINTS.approvals, 'GET', undefined, { allowError: true });
|
||||
if (remote && Array.isArray(remote.items)) state.approvals = remote.items;
|
||||
else if (Array.isArray(remote)) state.approvals = remote;
|
||||
} catch (_) { /* local approvals already loaded */ }
|
||||
renderApprovals();
|
||||
updateMetrics();
|
||||
}
|
||||
|
||||
function renderApprovals() {
|
||||
const target = el('approval-board');
|
||||
if (!target) return;
|
||||
if (!state.approvals.length) { target.innerHTML = '<p class="empty">暂无审批请求</p>'; return; }
|
||||
target.innerHTML = state.approvals.map(item => {
|
||||
const canDecide = has('approval:*') || state.role === 'owner';
|
||||
return `<article class="approval ${escapeHtml(item.risk || 'medium')}">
|
||||
<header><strong>${escapeHtml(item.type)}</strong><span>${escapeHtml(item.status)}</span></header>
|
||||
<p>${escapeHtml(item.target)}</p><small>${escapeHtml(item.reason || '')}</small>
|
||||
<footer>${canDecide && item.status === 'pending' ? `<button data-approve="${escapeHtml(item.id)}">Approve</button><button data-reject="${escapeHtml(item.id)}">Reject</button>` : ''}</footer>
|
||||
</article>`;
|
||||
}).join('');
|
||||
qsa('[data-approve]').forEach(btn => btn.onclick = () => decideApproval(btn.dataset.approve, 'approved'));
|
||||
qsa('[data-reject]').forEach(btn => btn.onclick = () => decideApproval(btn.dataset.reject, 'rejected'));
|
||||
}
|
||||
|
||||
async function createApproval(payload) {
|
||||
const guard = guardAction('approval:create', payload.risk || 'medium');
|
||||
if (!guard.allowed) return { status: 'blocked', ...guard };
|
||||
const item = {
|
||||
id: `apr-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`,
|
||||
tenant: state.tenant,
|
||||
status: 'pending',
|
||||
created_at: now(),
|
||||
created_by: state.identity && state.identity.user || 'unknown',
|
||||
...payload
|
||||
};
|
||||
try {
|
||||
const remote = await api(ENDPOINTS.approvalRequest, 'POST', item, { allowError: true });
|
||||
if (remote && (remote.id || remote.status)) {
|
||||
audit('approval.create', item.target, 'ok', remote);
|
||||
await refreshApprovals();
|
||||
return remote;
|
||||
}
|
||||
} catch (_) { /* local fallback */ }
|
||||
state.approvals.unshift(item); saveApprovals(); renderApprovals(); updateMetrics();
|
||||
audit('approval.create.local', item.target, 'ok', item);
|
||||
return item;
|
||||
}
|
||||
|
||||
async function decideApproval(id, status) {
|
||||
const guard = guardAction('approval:decide', 'high');
|
||||
if (!guard.allowed) { audit('approval.decide', id, 'blocked', guard); return; }
|
||||
try {
|
||||
const remote = await api(ENDPOINTS.approvalDecision, 'POST', { id, status }, { allowError: true });
|
||||
audit('approval.decide', id, 'ok', remote);
|
||||
} catch (_) {
|
||||
const item = state.approvals.find(a => a.id === id);
|
||||
if (item) { item.status = status; item.decided_at = now(); item.decided_by = state.identity && state.identity.user || 'unknown'; }
|
||||
saveApprovals(); audit('approval.decide.local', id, 'ok', { status });
|
||||
}
|
||||
renderApprovals(); updateMetrics();
|
||||
}
|
||||
|
||||
async function refreshAudit() {
|
||||
try {
|
||||
const remote = await api(ENDPOINTS.audit, 'GET', undefined, { allowError: true });
|
||||
if (remote && Array.isArray(remote.items)) state.audit = remote.items.concat(state.audit).slice(0, 500);
|
||||
} catch (_) { /* local fallback */ }
|
||||
renderAudit();
|
||||
}
|
||||
|
||||
function renderAudit() {
|
||||
const target = el('audit-table'); if (!target) return;
|
||||
const filter = (el('audit-filter') && el('audit-filter').value || '').toLowerCase();
|
||||
const rows = state.audit.filter(r => !filter || JSON.stringify(r).toLowerCase().includes(filter)).slice(0, 100).map(r =>
|
||||
`<tr><td>${escapeHtml(r.ts)}</td><td>${escapeHtml(r.tenant || '')}</td><td>${escapeHtml(r.role || '')}</td><td>${escapeHtml(r.action)}</td><td>${escapeHtml(r.target)}</td><td>${escapeHtml(r.status)}</td></tr>`
|
||||
).join('');
|
||||
target.innerHTML = `<table><thead><tr><th>Time</th><th>Tenant</th><th>Role</th><th>Action</th><th>Target</th><th>Status</th></tr></thead><tbody>${rows || '<tr><td colspan="6">无审计记录</td></tr>'}</tbody></table>`;
|
||||
}
|
||||
|
||||
function parseDiffTree(diffData) {
|
||||
const stdout = diffData && diffData.diff && diffData.diff.stdout || diffData && diffData.stdout || '';
|
||||
const files = [];
|
||||
let current = null;
|
||||
stdout.split('\n').forEach(line => {
|
||||
if (line.startsWith('diff --git ')) {
|
||||
const parts = line.split(' ');
|
||||
current = { file: (parts[3] || '').replace(/^b\//, ''), additions: 0, deletions: 0, chunks: 0 };
|
||||
files.push(current);
|
||||
} else if (current) {
|
||||
if (line.startsWith('@@')) current.chunks++;
|
||||
else if (line.startsWith('+') && !line.startsWith('+++')) current.additions++;
|
||||
else if (line.startsWith('-') && !line.startsWith('---')) current.deletions++;
|
||||
}
|
||||
});
|
||||
return files;
|
||||
}
|
||||
|
||||
function renderDiffTree(diffData) {
|
||||
const target = el('diff-tree'); if (!target) return;
|
||||
const files = parseDiffTree(diffData);
|
||||
if (!files.length) { target.innerHTML = '<p class="empty">未解析到文件级 diff,可能是接口返回过大或为空。</p>'; return; }
|
||||
target.innerHTML = files.map(f => `<div class="diff-file"><strong>${escapeHtml(f.file)}</strong><span>+${f.additions} / -${f.deletions} / ${f.chunks} chunks</span></div>`).join('');
|
||||
}
|
||||
|
||||
function connectEvents() {
|
||||
if (!window.EventSource) return;
|
||||
try {
|
||||
state.eventSource = new EventSource(`/api/${ENDPOINTS.events}?tenant=${encodeURIComponent(state.tenant)}`);
|
||||
state.eventSource.onmessage = (event) => {
|
||||
const payload = JSON.parse(event.data);
|
||||
audit('event.gitops', payload.type || 'event', 'ok', payload);
|
||||
if (['approval', 'patch', 'runtime', 'model'].includes(payload.type)) refreshAll();
|
||||
};
|
||||
state.eventSource.onerror = () => { if (state.eventSource) state.eventSource.close(); };
|
||||
} catch (_) { /* server-sent events optional */ }
|
||||
}
|
||||
|
||||
function wireTabs() {
|
||||
qsa('.tabs button').forEach(button => {
|
||||
button.onclick = () => {
|
||||
qsa('.tabs button').forEach(b => b.classList.remove('active'));
|
||||
qsa('.tab').forEach(tab => tab.classList.remove('active'));
|
||||
button.classList.add('active');
|
||||
const target = el(`tab-${button.dataset.tab}`);
|
||||
if (target) target.classList.add('active');
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function wireForms() {
|
||||
const refreshBtn = el('refresh-all'); if (refreshBtn) refreshBtn.onclick = refreshAll;
|
||||
const refreshModel = el('refresh-model'); if (refreshModel) refreshModel.onclick = refreshAll;
|
||||
const refreshApprovalsBtn = el('refresh-approvals'); if (refreshApprovalsBtn) refreshApprovalsBtn.onclick = refreshApprovals;
|
||||
const clearApprovals = el('clear-local-approvals'); if (clearApprovals) clearApprovals.onclick = () => { state.approvals = []; saveApprovals(); renderApprovals(); audit('approval.local.clear', 'localStorage', 'ok'); };
|
||||
const refreshAuditBtn = el('refresh-audit'); if (refreshAuditBtn) refreshAuditBtn.onclick = refreshAudit;
|
||||
const auditFilter = el('audit-filter'); if (auditFilter) auditFilter.oninput = renderAudit;
|
||||
const exportAudit = el('export-audit'); if (exportAudit) exportAudit.onclick = () => downloadJson('aios-gitops-audit.json', state.audit);
|
||||
|
||||
bindForm('approval-form', async f => createApproval({ type: f.get('type'), target: f.get('target'), risk: f.get('risk'), reason: f.get('reason') }), 'approval-output');
|
||||
bindForm('diff-form', async f => {
|
||||
const data = await api(`${ENDPOINTS.diff}?from=${encodeURIComponent(f.get('from'))}&to=${encodeURIComponent(f.get('to'))}`, 'GET', undefined, { allowError: true });
|
||||
renderDiffTree(data); return data;
|
||||
}, 'diff-output');
|
||||
bindForm('branch-form', async f => checkedApi('branch:create', ENDPOINTS.branch, 'POST', { branch: f.get('branch'), base: f.get('base'), message: f.get('message') }), 'branch-output');
|
||||
bindForm('plan-form', async f => checkedApi('plan:create', ENDPOINTS.plan, 'POST', { branch: f.get('branch'), text: f.get('text') }), 'plan-output');
|
||||
bindForm('model-form', async f => {
|
||||
const payload = { provider_name: f.get('provider_name'), base_url: f.get('base_url'), model_name: f.get('model_name'), display_name: f.get('display_name'), api_key_secret_ref: f.get('api_key_secret_ref') };
|
||||
if (!String(payload.api_key_secret_ref || '').startsWith('secret_ref://')) return { status: 'blocked', reason: 'api_key_secret_ref_required' };
|
||||
return checkedApi('model:write', ENDPOINTS.model, 'POST', payload, 'high');
|
||||
}, 'model-register-output');
|
||||
bindForm('priority-form', async f => checkedApi('priority:write', ENDPOINTS.priority, 'PUT', { model_id: f.get('model_id'), priority: Number(f.get('priority')), enabled: f.get('enabled') === 'true', fallback_policy: f.get('fallback_policy') }, 'medium'), 'priority-output');
|
||||
bindForm('rollback-form', async f => checkedApi('rollback:request', ENDPOINTS.rollback, 'POST', { target_version: f.get('target_version'), reason: f.get('reason'), branch: f.get('branch') }, 'high'), 'rollback-output');
|
||||
bindForm('tag-form', async f => checkedApi('release:request', ENDPOINTS.tag, 'POST', { version: f.get('version'), message: f.get('message') }, 'high'), 'tag-output');
|
||||
}
|
||||
|
||||
function bindForm(id, handler, outputId) {
|
||||
const form = el(id); if (!form) return;
|
||||
form.onsubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const f = new FormData(form);
|
||||
try {
|
||||
const result = await handler(f);
|
||||
put(outputId, result);
|
||||
audit(`${id}.submit`, outputId, result && result.status === 'blocked' ? 'blocked' : 'ok', result);
|
||||
} catch (err) {
|
||||
const detail = { error: err.message, response: err.response };
|
||||
put(outputId, detail);
|
||||
audit(`${id}.submit`, outputId, 'failed', detail);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function checkedApi(capability, endpoint, method, payload, risk = 'low') {
|
||||
const guard = guardAction(capability, risk);
|
||||
if (!guard.allowed) return { status: 'blocked', capability, ...guard };
|
||||
return await api(endpoint, method, payload, { allowError: true });
|
||||
}
|
||||
|
||||
function downloadJson(filename, value) {
|
||||
const blob = new Blob([safeStringify(value)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = filename; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value == null ? '' : value).replace(/[&<>'"]/g, ch => ({ '&': '&', '<': '<', '>': '>', '\'': ''', '"': '"' }[ch]));
|
||||
}
|
||||
|
||||
return { bootstrap, refreshAll, state };
|
||||
})();
|
||||
|
||||
window.addEventListener('DOMContentLoaded', Platform.bootstrap);
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"name": "aios-gitops-enterprise-api-contract",
|
||||
"version": "1.0.0",
|
||||
"scope": "portal-gitops-dashboard",
|
||||
"principles": {
|
||||
"all_writes_require_git_commit": true,
|
||||
"direct_main_push_from_ui": false,
|
||||
"secrets_in_git": false,
|
||||
"destructive_operations": "forbidden_or_request_only",
|
||||
"server_side_authz_is_authoritative": true
|
||||
},
|
||||
"read_endpoints": {
|
||||
"baseline": "GET /api/base",
|
||||
"versions": "GET /api/ver",
|
||||
"runtime": "GET /api/run",
|
||||
"patches": "GET /api/patch",
|
||||
"model_registry": "GET /api/model",
|
||||
"diff_raw": "GET /api/diff?from={from}&to={to}",
|
||||
"diff_tree": "GET /api/diff/tree?from={from}&to={to}",
|
||||
"approval_queue": "GET /api/approval/queue",
|
||||
"release_pipeline": "GET /api/release/pipeline",
|
||||
"audit_log": "GET /api/audit/log?limit={limit}",
|
||||
"me": "GET /api/me"
|
||||
},
|
||||
"write_endpoints": {
|
||||
"create_branch": "POST /api/branch",
|
||||
"natural_language_plan": "POST /api/patch/plan",
|
||||
"register_model": "POST /api/model",
|
||||
"set_model_priority": "PUT /api/model-priority",
|
||||
"rollback_request": "POST /api/rollback-request",
|
||||
"tag_request": "POST /api/tag-request",
|
||||
"approval_decision": "POST /api/approval/decision",
|
||||
"release_promotion_request": "POST /api/release/promote-request",
|
||||
"audit_append": "POST /api/audit/log"
|
||||
},
|
||||
"approval_decision_payload": {
|
||||
"request_id": "string",
|
||||
"decision": "approve | reject",
|
||||
"reason": "string",
|
||||
"branch": "patch/approval-..."
|
||||
},
|
||||
"release_promotion_payload": {
|
||||
"from_environment": "dev | staging",
|
||||
"to_environment": "staging | prod",
|
||||
"version": "VxRxCx",
|
||||
"reason": "string",
|
||||
"branch": "patch/release-promote-..."
|
||||
},
|
||||
"response_contract": {
|
||||
"write_success_any_of": ["branch", "commit", "rev", "sync_status"],
|
||||
"auth_failure": {
|
||||
"401": "Bearer token not injected",
|
||||
"403": "Bearer token injected but scope insufficient"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
{
|
||||
"name": "aios-gitops-enterprise-ui-policy",
|
||||
"version": "1.0.0",
|
||||
"scope": "portal-gitops-dashboard",
|
||||
"client_side_notice": "Client-side RBAC is advisory only. Server-side Actions/API authorization remains authoritative.",
|
||||
"roles": {
|
||||
"viewer": {
|
||||
"label": "Viewer",
|
||||
"permissions": [
|
||||
"read:baseline",
|
||||
"read:versions",
|
||||
"read:diff",
|
||||
"read:patch",
|
||||
"read:model",
|
||||
"read:runtime",
|
||||
"read:audit"
|
||||
]
|
||||
},
|
||||
"developer": {
|
||||
"label": "Developer",
|
||||
"inherits": ["viewer"],
|
||||
"permissions": [
|
||||
"write:branch",
|
||||
"write:plan"
|
||||
]
|
||||
},
|
||||
"admin": {
|
||||
"label": "Admin",
|
||||
"inherits": ["developer"],
|
||||
"permissions": [
|
||||
"write:model",
|
||||
"write:model_priority",
|
||||
"request:rollback",
|
||||
"request:tag"
|
||||
]
|
||||
},
|
||||
"owner": {
|
||||
"label": "Owner",
|
||||
"inherits": ["admin"],
|
||||
"permissions": [
|
||||
"approve:rollback",
|
||||
"approve:tag",
|
||||
"approve:priority",
|
||||
"approve:release"
|
||||
]
|
||||
}
|
||||
},
|
||||
"risk_policy": {
|
||||
"direct_main_write": "disabled_in_ui",
|
||||
"api_keys_in_git": "forbidden",
|
||||
"rollback": "request_only",
|
||||
"tag_create": "request_only_by_default",
|
||||
"runtime_change": "request_only",
|
||||
"baseline_high_risk_change": "request_only",
|
||||
"bulk_delete": "forbidden"
|
||||
},
|
||||
"secret_redaction": {
|
||||
"enabled": true,
|
||||
"patterns": [
|
||||
"api_key",
|
||||
"apikey",
|
||||
"authorization",
|
||||
"bearer",
|
||||
"token",
|
||||
"password",
|
||||
"secret",
|
||||
"private_key",
|
||||
"credential"
|
||||
],
|
||||
"replacement": "[REDACTED]"
|
||||
},
|
||||
"approval_queues": [
|
||||
"rollback",
|
||||
"tag",
|
||||
"model_priority",
|
||||
"release",
|
||||
"runtime_change"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>AIOS Enterprise GitOps Control Plane</title>
|
||||
<link rel="stylesheet" href="./styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<p class="eyebrow">cnnetmkt.top / AIOS Enterprise</p>
|
||||
<h1>GitOps Enterprise Control Plane</h1>
|
||||
<p class="subtitle">审批流、RBAC、Release Pipeline、Diff Tree、模型优先级、审计查询</p>
|
||||
</div>
|
||||
<div class="statusbar">
|
||||
<span id="role-chip" class="chip">role: loading</span>
|
||||
<span id="runtime-chip" class="chip muted">runtime: loading</span>
|
||||
<button id="refresh-all" class="primary">刷新全部</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="tabs" aria-label="Enterprise GitOps modules">
|
||||
<button data-tab="overview" class="active">总览</button>
|
||||
<button data-tab="rbac">RBAC</button>
|
||||
<button data-tab="approval">审批流</button>
|
||||
<button data-tab="diff">Diff Tree</button>
|
||||
<button data-tab="patch">Patch / Branch</button>
|
||||
<button data-tab="model">模型控制</button>
|
||||
<button data-tab="release">Release Pipeline</button>
|
||||
<button data-tab="audit">审计中心</button>
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
<section id="tab-overview" class="tab active">
|
||||
<div class="grid four">
|
||||
<article class="metric"><span>Runtime</span><strong id="metric-runtime">-</strong></article>
|
||||
<article class="metric"><span>Active Version</span><strong id="metric-version">-</strong></article>
|
||||
<article class="metric"><span>Patch Branches</span><strong id="metric-branches">-</strong></article>
|
||||
<article class="metric"><span>Pending Approvals</span><strong id="metric-approvals">-</strong></article>
|
||||
</div>
|
||||
<div class="grid two">
|
||||
<article class="card"><h2>Baseline</h2><pre id="baseline-output">loading...</pre></article>
|
||||
<article class="card"><h2>Versions</h2><pre id="versions-output">loading...</pre></article>
|
||||
<article class="card"><h2>Runtime</h2><pre id="runtime-output">loading...</pre></article>
|
||||
<article class="card"><h2>Patch Summary</h2><pre id="patch-summary-output">loading...</pre></article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="tab-rbac" class="tab grid two">
|
||||
<article class="card">
|
||||
<h2>当前用户权限</h2>
|
||||
<p class="hint">按钮级权限由 role + capability 共同控制;viewer 只能读。</p>
|
||||
<pre id="identity-output">loading...</pre>
|
||||
</article>
|
||||
<article class="card">
|
||||
<h2>RBAC 策略</h2>
|
||||
<pre id="rbac-output">loading...</pre>
|
||||
</article>
|
||||
<article class="card span-two">
|
||||
<h2>权限矩阵</h2>
|
||||
<div id="permission-matrix" class="table-wrap">loading...</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section id="tab-approval" class="tab">
|
||||
<div class="grid two">
|
||||
<article class="card">
|
||||
<h2>Approval Queue</h2>
|
||||
<p class="hint">高风险操作进入审批队列;UI 不直接执行 destructive / production 变更。</p>
|
||||
<div class="toolbar">
|
||||
<button id="refresh-approvals" class="secondary">刷新审批队列</button>
|
||||
<button id="clear-local-approvals" class="secondary guarded">清理本地缓存</button>
|
||||
</div>
|
||||
<div id="approval-board" class="approval-board">loading...</div>
|
||||
</article>
|
||||
<article class="card">
|
||||
<h2>创建审批请求</h2>
|
||||
<form id="approval-form" class="stack">
|
||||
<label>类型
|
||||
<select name="type">
|
||||
<option value="merge">merge</option>
|
||||
<option value="release">release</option>
|
||||
<option value="rollback">rollback</option>
|
||||
<option value="model_priority">model_priority</option>
|
||||
<option value="runtime_change">runtime_change</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>目标 <input name="target" placeholder="branch / version / model id" required /></label>
|
||||
<label>风险
|
||||
<select name="risk"><option>low</option><option selected>medium</option><option>high</option></select>
|
||||
</label>
|
||||
<label>说明 <textarea name="reason" rows="5" required></textarea></label>
|
||||
<button class="primary require-operator" type="submit">提交审批请求</button>
|
||||
</form>
|
||||
<pre id="approval-output">等待操作...</pre>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="tab-diff" class="tab">
|
||||
<article class="card">
|
||||
<h2>File-level Diff Tree</h2>
|
||||
<p class="hint">支持 from/to 参数;会自动按文件聚合 diff,便于 review。</p>
|
||||
<form id="diff-form" class="form-row wrap">
|
||||
<label>From <input name="from" value="HEAD~1" /></label>
|
||||
<label>To <input name="to" value="HEAD" /></label>
|
||||
<button class="primary" type="submit">读取 Diff</button>
|
||||
</form>
|
||||
<div id="diff-tree" class="diff-tree">等待查询...</div>
|
||||
<pre id="diff-output">等待查询...</pre>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section id="tab-patch" class="tab grid two">
|
||||
<article class="card">
|
||||
<h2>创建 Patch Branch</h2>
|
||||
<p class="hint">默认从 main 创建非生产 patch 分支;main 写入仅通过明确授权的 GitOps Action。</p>
|
||||
<form id="branch-form" class="stack">
|
||||
<label>Branch <input name="branch" placeholder="patch/my-change" required /></label>
|
||||
<label>Base <input name="base" value="main" /></label>
|
||||
<label>Message <input name="message" value="Create GitOps patch branch" /></label>
|
||||
<button class="primary require-operator" type="submit">创建 Branch</button>
|
||||
</form>
|
||||
<pre id="branch-output">等待操作...</pre>
|
||||
</article>
|
||||
<article class="card">
|
||||
<h2>自然语言 GitOps Plan</h2>
|
||||
<p class="hint">仅生成可审计 plan;高风险操作必须审批。</p>
|
||||
<form id="plan-form" class="stack">
|
||||
<label>目标 Branch <input name="branch" value="patch/web-ui-complete-gitops-dashboard-20260706" /></label>
|
||||
<label>Plan Text <textarea name="text" rows="5" placeholder="描述要执行的 GitOps 变更" required></textarea></label>
|
||||
<button class="primary require-operator" type="submit">生成 Plan</button>
|
||||
</form>
|
||||
<pre id="plan-output">等待操作...</pre>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section id="tab-model" class="tab">
|
||||
<div class="grid two">
|
||||
<article class="card">
|
||||
<h2>模型注册表</h2>
|
||||
<button id="refresh-model" class="secondary">刷新模型</button>
|
||||
<pre id="model-output">loading...</pre>
|
||||
</article>
|
||||
<article class="card">
|
||||
<h2>模型优先级图</h2>
|
||||
<div id="model-priority-graph" class="graph">loading...</div>
|
||||
</article>
|
||||
<article class="card">
|
||||
<h2>注册自定义模型</h2>
|
||||
<p class="hint">仅允许 secret_ref;禁止真实 API key 明文。</p>
|
||||
<form id="model-form" class="stack">
|
||||
<label>Provider <input name="provider_name" placeholder="openai / one-api / compatible" required /></label>
|
||||
<label>Base URL <input name="base_url" placeholder="https://example.com/v1" required /></label>
|
||||
<label>Model Name <input name="model_name" placeholder="model-id" required /></label>
|
||||
<label>Display Name <input name="display_name" placeholder="Human readable name" /></label>
|
||||
<label>API Key Secret Ref <input name="api_key_secret_ref" placeholder="secret_ref://..." required /></label>
|
||||
<button class="primary require-admin" type="submit">注册模型</button>
|
||||
</form>
|
||||
<pre id="model-register-output">等待操作...</pre>
|
||||
</article>
|
||||
<article class="card">
|
||||
<h2>模型优先级 / Fallback</h2>
|
||||
<form id="priority-form" class="stack">
|
||||
<label>Model ID <input name="model_id" placeholder="base.gpt-5.5" required /></label>
|
||||
<label>Priority <input name="priority" type="number" value="100" required /></label>
|
||||
<label>Enabled <select name="enabled"><option value="true">true</option><option value="false">false</option></select></label>
|
||||
<label>Fallback Policy <input name="fallback_policy" value="next_enabled" /></label>
|
||||
<button class="primary require-admin" type="submit">保存优先级</button>
|
||||
</form>
|
||||
<pre id="priority-output">等待操作...</pre>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="tab-release" class="tab">
|
||||
<div class="pipeline" id="release-pipeline">
|
||||
<div class="stage">Patch</div><div class="arrow">→</div><div class="stage">Review</div><div class="arrow">→</div><div class="stage">Approval</div><div class="arrow">→</div><div class="stage guarded-stage">Tag Request</div><div class="arrow">→</div><div class="stage guarded-stage">Deploy</div>
|
||||
</div>
|
||||
<div class="grid two">
|
||||
<article class="card danger-zone">
|
||||
<h2>Rollback Request</h2>
|
||||
<p class="hint">只创建审批请求;不直接执行回滚。</p>
|
||||
<form id="rollback-form" class="stack">
|
||||
<label>Target Version <input name="target_version" placeholder="V1R1C3" required /></label>
|
||||
<label>Reason <textarea name="reason" rows="4" required></textarea></label>
|
||||
<label>Request Branch <input name="branch" value="patch/rollback-request-web-ui" /></label>
|
||||
<button class="danger require-admin" type="submit">创建 Rollback 请求</button>
|
||||
</form>
|
||||
<pre id="rollback-output">等待操作...</pre>
|
||||
</article>
|
||||
<article class="card danger-zone">
|
||||
<h2>Tag / Version Request</h2>
|
||||
<p class="hint">默认创建 owner/admin approval request;UI 禁用直接执行。</p>
|
||||
<form id="tag-form" class="stack">
|
||||
<label>Version <input name="version" placeholder="V1R1C5" required /></label>
|
||||
<label>Message <input name="message" placeholder="Release request" /></label>
|
||||
<label class="disabled"><input name="execute" type="checkbox" disabled /> 直接执行(禁用)</label>
|
||||
<button class="danger require-admin" type="submit">创建 Tag 请求</button>
|
||||
</form>
|
||||
<pre id="tag-output">等待操作...</pre>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="tab-audit" class="tab">
|
||||
<article class="card">
|
||||
<h2>审计中心</h2>
|
||||
<div class="toolbar">
|
||||
<input id="audit-filter" placeholder="过滤 action / target / status" />
|
||||
<button id="export-audit" class="secondary">导出 JSON</button>
|
||||
<button id="refresh-audit" class="secondary">刷新</button>
|
||||
</div>
|
||||
<div id="audit-table" class="table-wrap">loading...</div>
|
||||
<pre id="audit-output">暂无操作。</pre>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
<script src="./app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "gitops-dashboard",
|
||||
"version": "1.0.0",
|
||||
"mount": "/portal/me/#gitops-dashboard",
|
||||
"description": "Complete GitOps Web UI module",
|
||||
"features": [
|
||||
"baseline_view",
|
||||
"version_list",
|
||||
"diff_view",
|
||||
"patch_view",
|
||||
"model_registry",
|
||||
"runtime_status"
|
||||
],
|
||||
"api": {
|
||||
"base": "/api/base",
|
||||
"ver": "/api/ver",
|
||||
"diff": "/api/diff",
|
||||
"patch": "/api/patch",
|
||||
"model": "/api/model",
|
||||
"runtime": "/api/run"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
body{font-family:Arial,Helvetica,sans-serif;background:#0b0f14;color:#e6edf3;margin:0}
|
||||
header{display:flex;align-items:center;justify-content:space-between;padding:12px 16px;background:#111826;border-bottom:1px solid #222}
|
||||
nav button{margin-right:8px;padding:8px 10px;background:#1f2937;color:#e6edf3;border:1px solid #2b3645;cursor:pointer}
|
||||
nav button:hover{background:#2b3645}
|
||||
main{padding:16px}
|
||||
pre{background:#0f172a;padding:12px;border:1px solid #1f2937;overflow:auto;min-height:400px}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"type": "branch",
|
||||
"branch": "patch/web-ui-complete-gitops-dashboard-20260706",
|
||||
"base": "main",
|
||||
"created_by": "gitops-write-bot",
|
||||
"created_at": "2026-07-06T15:33:08Z",
|
||||
"direct_main_push": false,
|
||||
"owner_approval_required": true
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
BRANCH=$1
|
||||
BASE=${2:-main}
|
||||
|
||||
if [ -z "$BRANCH" ]; then
|
||||
echo "Usage: autonomous-merge-gate.sh <branch> <base>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[Autonomous Gate] Evaluating: $BRANCH vs $BASE"
|
||||
|
||||
REPORT_DIR="reports/codex"
|
||||
mkdir -p "$REPORT_DIR"
|
||||
|
||||
# Run validation and capture exit code correctly
|
||||
bash scripts/validate-gitops-platform-ui.sh "$BRANCH" "$BASE"
|
||||
VALIDATION_RC=$?
|
||||
|
||||
if [ $VALIDATION_RC -ne 0 ]; then
|
||||
echo "FAIL: validation failed"
|
||||
exit $VALIDATION_RC
|
||||
fi
|
||||
|
||||
# Secret safety re-check
|
||||
if grep -R "BEGIN PRIVATE KEY" -n overlays/ >/dev/null 2>&1; then
|
||||
echo "BLOCKED: secret leak"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Create merge candidate artifact
|
||||
cat > "$REPORT_DIR/merge-candidate.json" <<EOF
|
||||
{
|
||||
"branch": "$BRANCH",
|
||||
"base": "$BASE",
|
||||
"status": "merge_candidate",
|
||||
"created_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
}
|
||||
EOF
|
||||
|
||||
echo "PASS: merge candidate ready"
|
||||
exit 0
|
||||
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Enqueue a WeChat notification into WebOps message_outbox.
|
||||
|
||||
Business modules should call this helper or the equivalent WebOps API instead of
|
||||
calling OpenClaw / WeChat adapters directly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(os.environ.get("AIOS_WEBOPS_ROOT", "/data/aios-gitops/workspace"))
|
||||
DB = Path(os.environ.get("AIOS_WEBOPS_DB", str(ROOT / "data" / "webops.sqlite3")))
|
||||
MIGRATION = ROOT / "db" / "migrations" / "20260707_message_outbox.sql"
|
||||
|
||||
|
||||
def ensure_schema(con: sqlite3.Connection) -> None:
|
||||
if MIGRATION.exists():
|
||||
con.executescript(MIGRATION.read_text(encoding="utf-8"))
|
||||
else:
|
||||
con.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS message_outbox (
|
||||
id TEXT PRIMARY KEY,
|
||||
channel TEXT NOT NULL,
|
||||
recipient_user_id TEXT NOT NULL,
|
||||
recipient_role TEXT,
|
||||
message_type TEXT NOT NULL,
|
||||
priority TEXT NOT NULL DEFAULT 'normal',
|
||||
title TEXT,
|
||||
content TEXT NOT NULL,
|
||||
payload TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
retry_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
sent_at TEXT
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Enqueue a WeChat notification")
|
||||
parser.add_argument("--recipient", "--recipient-user-id", dest="recipient_user_id", required=True)
|
||||
parser.add_argument("--recipient-role", default="owner")
|
||||
parser.add_argument("--message-type", default="alert")
|
||||
parser.add_argument("--priority", default="normal", choices=["critical", "high", "normal", "low"])
|
||||
parser.add_argument("--title", default="AIOS 通知")
|
||||
parser.add_argument("--content", required=True)
|
||||
parser.add_argument("--payload", default="{}")
|
||||
parser.add_argument("--id")
|
||||
args = parser.parse_args()
|
||||
|
||||
DB.parent.mkdir(parents=True, exist_ok=True)
|
||||
msg_id = args.id or f"msg_{int(time.time() * 1000)}"
|
||||
|
||||
with sqlite3.connect(DB) as con:
|
||||
ensure_schema(con)
|
||||
con.execute(
|
||||
"""
|
||||
INSERT INTO message_outbox (
|
||||
id, channel, recipient_user_id, recipient_role, message_type,
|
||||
priority, title, content, payload, status, created_at
|
||||
) VALUES (?, 'wechat', ?, ?, ?, ?, ?, ?, ?, 'queued', datetime('now'))
|
||||
""",
|
||||
(
|
||||
msg_id,
|
||||
args.recipient_user_id,
|
||||
args.recipient_role,
|
||||
args.message_type,
|
||||
args.priority,
|
||||
args.title,
|
||||
args.content,
|
||||
args.payload,
|
||||
),
|
||||
)
|
||||
con.commit()
|
||||
|
||||
print(json.dumps({"status": "queued", "id": msg_id, "db": str(DB)}, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Ensure WebOps message outbox schema.
|
||||
|
||||
This schema is the only supported active outbound notification entrypoint for
|
||||
business modules and external systems. Delivery is performed by the worker.
|
||||
|
||||
The migration is backward-compatible with already-created SQLite tables:
|
||||
columns are added before indexes that reference them, and UNIQUE constraints are
|
||||
created as indexes because SQLite cannot add a UNIQUE column via ALTER TABLE.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(os.environ.get("AIOS_WEBOPS_ROOT", "/data/aios-gitops/workspace"))
|
||||
DB = Path(os.environ.get("AIOS_WEBOPS_DB", str(ROOT / "data" / "webops.sqlite3")))
|
||||
|
||||
BASE_TABLES_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS message_outbox (
|
||||
id TEXT PRIMARY KEY,
|
||||
idempotency_key TEXT,
|
||||
channel TEXT NOT NULL,
|
||||
recipient_user_id TEXT,
|
||||
recipient_role TEXT,
|
||||
message_type TEXT NOT NULL,
|
||||
priority TEXT NOT NULL DEFAULT 'normal',
|
||||
title TEXT,
|
||||
content TEXT NOT NULL,
|
||||
source_system TEXT,
|
||||
source_event_id TEXT,
|
||||
require_approval INTEGER NOT NULL DEFAULT 0,
|
||||
payload TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
retry_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
sent_at TEXT,
|
||||
cancelled_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channel_bindings (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
user_role TEXT,
|
||||
channel_type TEXT NOT NULL,
|
||||
external_chat_id TEXT NOT NULL,
|
||||
notification_prefs TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id TEXT PRIMARY KEY,
|
||||
actor TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
target TEXT,
|
||||
detail TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
|
||||
INDEX_SQL = """
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_message_outbox_idempotency
|
||||
ON message_outbox(idempotency_key)
|
||||
WHERE idempotency_key IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_message_outbox_dispatch
|
||||
ON message_outbox(channel, status, priority, created_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_bindings_lookup
|
||||
ON channel_bindings(user_id, channel_type, status, updated_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_bindings_role_lookup
|
||||
ON channel_bindings(user_role, channel_type, status, updated_at);
|
||||
"""
|
||||
|
||||
COLUMNS = {
|
||||
"message_outbox": {
|
||||
"idempotency_key": "TEXT",
|
||||
"recipient_user_id": "TEXT",
|
||||
"recipient_role": "TEXT",
|
||||
"source_system": "TEXT",
|
||||
"source_event_id": "TEXT",
|
||||
"require_approval": "INTEGER NOT NULL DEFAULT 0",
|
||||
"cancelled_at": "TEXT",
|
||||
},
|
||||
"channel_bindings": {
|
||||
"user_role": "TEXT",
|
||||
"notification_prefs": "TEXT",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def existing_columns(con: sqlite3.Connection, table: str) -> set[str]:
|
||||
return {row[1] for row in con.execute(f"PRAGMA table_info({table})")}
|
||||
|
||||
|
||||
def ensure_schema(db_path: Path = DB) -> None:
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with sqlite3.connect(db_path) as con:
|
||||
con.executescript(BASE_TABLES_SQL)
|
||||
for table, cols in COLUMNS.items():
|
||||
existing = existing_columns(con, table)
|
||||
for name, ddl in cols.items():
|
||||
if name not in existing:
|
||||
con.execute(f"ALTER TABLE {table} ADD COLUMN {name} {ddl}")
|
||||
con.executescript(INDEX_SQL)
|
||||
con.commit()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
ensure_schema()
|
||||
print(f"schema_ok db={DB}")
|
||||
@@ -1,85 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
import argparse, json, os, ssl, time, urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def post_json(api: str, path: str, token: str | None, payload: dict) -> tuple[int, dict]:
|
||||
data = json.dumps(payload, ensure_ascii=False).encode('utf-8')
|
||||
req = urllib.request.Request(api.rstrip('/') + path, data=data, method='POST')
|
||||
req.add_header('Content-Type', 'application/json')
|
||||
if token:
|
||||
req.add_header('Authorization', f'Bearer {token}')
|
||||
ctx = ssl._create_unverified_context()
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30, context=ctx) as r:
|
||||
return r.status, json.loads(r.read().decode('utf-8'))
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode('utf-8', errors='ignore')
|
||||
try:
|
||||
obj = json.loads(body)
|
||||
except Exception:
|
||||
obj = {'raw': body[:300]}
|
||||
return e.code, obj
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument('--api', default=os.environ.get('AIOS_GITOPS_API', 'https://cnnetmkt.top/api'))
|
||||
ap.add_argument('--token-file', default=os.environ.get('AIOS_GITOPS_WRITE_TOKEN_FILE', '/data/aios-gitops/runtime/secrets/gitops-write-bot.token'))
|
||||
ap.add_argument('--token-secret-ref', default=os.environ.get('AIOS_GITOPS_WRITE_TOKEN_REF', 'secret_ref://aios-gitops/runtime/gitops-write-bot'))
|
||||
ap.add_argument('--branch-prefix', default='patch/gitops-write-auth-smoke')
|
||||
args = ap.parse_args()
|
||||
token = Path(args.token_file).read_text(encoding='utf-8').strip()
|
||||
stamp = time.strftime('%Y%m%dT%H%M%SZ', time.gmtime())
|
||||
branch = f'{args.branch_prefix}-{stamp}'
|
||||
patch_name = f'gitops-write-auth-smoke-{stamp}'
|
||||
|
||||
noauth_code, noauth_body = post_json(args.api, '/patch', None, {'patch_name': patch_name + '-noauth'})
|
||||
if noauth_code != 401:
|
||||
raise SystemExit(f'expected noauth 401, got {noauth_code}: {noauth_body}')
|
||||
|
||||
patch_code, patch_body = post_json(args.api, '/patch', token, {
|
||||
'patch_name': patch_name,
|
||||
'branch': branch,
|
||||
'base': 'main',
|
||||
'risk': 'low',
|
||||
'description': 'GitOps write auth smoke using secret_ref token',
|
||||
'message': f'test(gitops): create write auth smoke patch {stamp}',
|
||||
})
|
||||
if patch_code != 200 or patch_body.get('status') != 'patch_branch_created':
|
||||
raise SystemExit(f'createPatch failed: {patch_code}: {patch_body}')
|
||||
|
||||
file_code, file_body = post_json(args.api, '/file', token, {
|
||||
'branch': branch,
|
||||
'base': 'main',
|
||||
'create_branch': False,
|
||||
'path': f'docs/gitops/write-auth-smoke-{stamp}.md',
|
||||
'content': f'# GitOps Write Auth Smoke\n\nTime: {stamp}\nResult: createPatch and commitFile authenticated via secret_ref token.\n',
|
||||
'message': f'test(gitops): commit write auth smoke {stamp}',
|
||||
})
|
||||
if file_code != 200 or file_body.get('status') != 'committed':
|
||||
raise SystemExit(f'commitFile failed: {file_code}: {file_body}')
|
||||
|
||||
def summarize(name: str, code: int, body: dict) -> dict:
|
||||
commit = body.get('commit') or {}
|
||||
return {
|
||||
'operation': name,
|
||||
'http': code,
|
||||
'status': body.get('status'),
|
||||
'branch': body.get('branch') or (body.get('patch') or {}).get('branch'),
|
||||
'rev': commit.get('rev') if isinstance(commit, dict) else None,
|
||||
'sync_status': ((commit.get('sync') or {}).get('status') if isinstance(commit, dict) else None),
|
||||
}
|
||||
|
||||
print(json.dumps({
|
||||
'noauth': {'operation': 'createPatch without token', 'http': noauth_code, 'status': noauth_body.get('error')},
|
||||
'createPatch': summarize('createPatch', patch_code, patch_body),
|
||||
'commitFile': summarize('commitFile', file_code, file_body),
|
||||
'token_secret_ref': args.token_secret_ref,
|
||||
}, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
BRANCH=$1
|
||||
BASE=${2:-main}
|
||||
|
||||
if [ -z "$BRANCH" ]; then
|
||||
echo "Usage: validate-gitops-platform-ui.sh <branch> <base>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[Codex] Validating branch: $BRANCH against $BASE"
|
||||
|
||||
echo "[1/5] Git diff check"
|
||||
git diff --stat $BASE...$BRANCH
|
||||
|
||||
echo "[2/5] Acceptance test load"
|
||||
if [ ! -f tests/acceptance/gitops-platform-ui.acceptance.json ]; then
|
||||
echo "FAIL: acceptance file missing"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
echo "[3/5] Secret scan"
|
||||
if grep -R "BEGIN PRIVATE KEY" -n overlays/ >/dev/null 2>&1; then
|
||||
echo "FAIL: private key detected"
|
||||
exit 3
|
||||
fi
|
||||
|
||||
if grep -R "Bearer " -n overlays/ >/dev/null 2>&1; then
|
||||
echo "WARN: bearer token pattern found"
|
||||
fi
|
||||
|
||||
echo "[4/5] UI structural validation"
|
||||
FILES=(
|
||||
overlays/user-control-center/portal/gitops-dashboard/index.html
|
||||
overlays/user-control-center/portal/gitops-dashboard/app.js
|
||||
)
|
||||
for f in "${FILES[@]}"; do
|
||||
if [ ! -f "$f" ]; then
|
||||
echo "FAIL: missing $f"
|
||||
exit 4
|
||||
fi
|
||||
done
|
||||
|
||||
echo "[5/5] Policy validation"
|
||||
grep -R "direct_main_push" -n overlays/user-control-center/portal/gitops-dashboard/ || true
|
||||
|
||||
echo "PASS: Codex validation completed"
|
||||
exit 0
|
||||
@@ -0,0 +1,256 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Production WeChat notify worker for AIOS / Xiamayiti.
|
||||
|
||||
Flow:
|
||||
message_outbox -> channel_bindings -> wechat_send_backend -> sent/failed/retry/audit
|
||||
|
||||
Business modules must not call OpenClaw or UCC Adapter directly. They only
|
||||
enqueue messages. The pluggable backend chooses OpenClaw channel or UCC Adapter
|
||||
at runtime.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
ROOT = Path(os.environ.get("AIOS_WEBOPS_ROOT", "/data/aios-gitops/workspace"))
|
||||
DB = Path(os.environ.get("AIOS_WEBOPS_DB", str(ROOT / "data" / "webops.sqlite3")))
|
||||
DEFAULT_LIMIT = int(os.environ.get("AIOS_WECHAT_WORKER_LIMIT", "20"))
|
||||
|
||||
PRIORITY_SQL = """
|
||||
CASE priority
|
||||
WHEN 'critical' THEN 1
|
||||
WHEN 'high' THEN 2
|
||||
WHEN 'normal' THEN 3
|
||||
ELSE 4
|
||||
END
|
||||
"""
|
||||
|
||||
|
||||
def load_module(path: Path, name: str):
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
if not spec or not spec.loader:
|
||||
raise RuntimeError(f"cannot load module: {path}")
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod) # type: ignore[attr-defined]
|
||||
return mod
|
||||
|
||||
|
||||
def ensure_schema(con: sqlite3.Connection) -> None:
|
||||
schema_path = ROOT / "scripts" / "message_outbox_schema.py"
|
||||
if schema_path.exists():
|
||||
mod = load_module(schema_path, "message_outbox_schema")
|
||||
mod.ensure_schema(DB)
|
||||
return
|
||||
con.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS message_outbox (
|
||||
id TEXT PRIMARY KEY,
|
||||
idempotency_key TEXT,
|
||||
channel TEXT NOT NULL,
|
||||
recipient_user_id TEXT,
|
||||
recipient_role TEXT,
|
||||
message_type TEXT NOT NULL,
|
||||
priority TEXT NOT NULL DEFAULT 'normal',
|
||||
title TEXT,
|
||||
content TEXT NOT NULL,
|
||||
source_system TEXT,
|
||||
source_event_id TEXT,
|
||||
require_approval INTEGER NOT NULL DEFAULT 0,
|
||||
payload TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
retry_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
sent_at TEXT,
|
||||
cancelled_at TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS channel_bindings (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
user_role TEXT,
|
||||
channel_type TEXT NOT NULL,
|
||||
external_chat_id TEXT NOT NULL,
|
||||
notification_prefs TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id TEXT PRIMARY KEY,
|
||||
actor TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
target TEXT,
|
||||
detail TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def audit(con: sqlite3.Connection, action: str, target: str, detail: Dict[str, Any]) -> None:
|
||||
con.execute(
|
||||
"""
|
||||
INSERT INTO audit_logs(id, actor, action, target, detail, created_at)
|
||||
VALUES(?, 'system', ?, ?, ?, datetime('now'))
|
||||
""",
|
||||
(f"audit_{int(time.time() * 1000)}", action, target, json.dumps(detail, ensure_ascii=False)),
|
||||
)
|
||||
|
||||
|
||||
def load_wechat_target(con: sqlite3.Connection, row: sqlite3.Row) -> str:
|
||||
recipient_user_id = row["recipient_user_id"]
|
||||
recipient_role = row["recipient_role"]
|
||||
|
||||
if recipient_user_id:
|
||||
found = con.execute(
|
||||
"""
|
||||
SELECT external_chat_id
|
||||
FROM channel_bindings
|
||||
WHERE user_id = ?
|
||||
AND channel_type = 'wechat'
|
||||
AND status = 'active'
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(recipient_user_id,),
|
||||
).fetchone()
|
||||
if found:
|
||||
return found["external_chat_id"]
|
||||
|
||||
if recipient_role:
|
||||
found = con.execute(
|
||||
"""
|
||||
SELECT external_chat_id
|
||||
FROM channel_bindings
|
||||
WHERE (user_role = ? OR user_id = ?)
|
||||
AND channel_type = 'wechat'
|
||||
AND status = 'active'
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(recipient_role, recipient_role),
|
||||
).fetchone()
|
||||
if found:
|
||||
return found["external_chat_id"]
|
||||
|
||||
raise RuntimeError(f"wechat binding not found for recipient_user_id={recipient_user_id} role={recipient_role}")
|
||||
|
||||
|
||||
def sanitize_content(row: sqlite3.Row) -> str:
|
||||
content = row["content"] or ""
|
||||
blocked_markers = ["BEGIN PRIVATE KEY", "Bearer ", "PASSWORD=", "TOKEN=", "/data/aios-gitops/runtime/secrets"]
|
||||
for marker in blocked_markers:
|
||||
if marker in content:
|
||||
content = content.replace(marker, "[REDACTED]")
|
||||
title = row["title"] or "AIOS 通知"
|
||||
return f"{title}\n\n{content}"
|
||||
|
||||
|
||||
def send_with_backend(target: str, content: str, row: sqlite3.Row, dry_run: bool = False) -> Dict[str, Any]:
|
||||
backend_path = ROOT / "scripts" / "wechat_send_backend.py"
|
||||
if not backend_path.exists():
|
||||
raise RuntimeError("wechat_send_backend.py not found")
|
||||
backend = load_module(backend_path, "wechat_send_backend")
|
||||
metadata = {}
|
||||
if row["payload"]:
|
||||
try:
|
||||
metadata = json.loads(row["payload"])
|
||||
except Exception:
|
||||
metadata = {"raw_payload": row["payload"][:1000]}
|
||||
return backend.send_message(
|
||||
target=target,
|
||||
content=content,
|
||||
account_id=os.environ.get("AIOS_WECHAT_ACCOUNT_ID", ""),
|
||||
message_id=row["id"],
|
||||
metadata=metadata,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
|
||||
|
||||
def mark_dispatching(con: sqlite3.Connection, msg_id: str) -> bool:
|
||||
cur = con.execute("UPDATE message_outbox SET status='dispatching' WHERE id=? AND status='queued'", (msg_id,))
|
||||
return cur.rowcount == 1
|
||||
|
||||
|
||||
def process_row(con: sqlite3.Connection, row: sqlite3.Row, dry_run: bool = False) -> None:
|
||||
msg_id = row["id"]
|
||||
if int(row["require_approval"] or 0) == 1 and row["message_type"] == "approval_request":
|
||||
con.execute("UPDATE message_outbox SET status='approval_pending' WHERE id=?", (msg_id,))
|
||||
audit(con, "wechat_outbound_approval_pending", row["recipient_user_id"] or row["recipient_role"] or "unknown", {"message_id": msg_id})
|
||||
return
|
||||
|
||||
if not mark_dispatching(con, msg_id):
|
||||
return
|
||||
|
||||
try:
|
||||
target = load_wechat_target(con, row)
|
||||
content = sanitize_content(row)
|
||||
result = send_with_backend(target, content, row, dry_run=dry_run)
|
||||
ok = bool(result.get("ok"))
|
||||
if not ok:
|
||||
code = result.get("code") or result.get("status") or "send_failed"
|
||||
raise RuntimeError(f"wechat send failed via {result.get('backend')}: {code}; {result.get('error') or result}")
|
||||
status = "sent" if not dry_run else "queued"
|
||||
con.execute(
|
||||
"""
|
||||
UPDATE message_outbox
|
||||
SET status=?,
|
||||
sent_at=CASE WHEN ?='sent' THEN datetime('now') ELSE sent_at END,
|
||||
payload=json_set(COALESCE(payload, '{}'), '$.send_result', ?),
|
||||
last_error=NULL
|
||||
WHERE id=?
|
||||
""",
|
||||
(status, status, json.dumps(result, ensure_ascii=False), msg_id),
|
||||
)
|
||||
audit(con, "wechat_outbound_sent" if not dry_run else "wechat_outbound_dry_run", row["recipient_user_id"] or row["recipient_role"] or "unknown", {"message_id": msg_id, "backend": result.get("backend"), "result": result})
|
||||
except Exception as exc:
|
||||
retry_count = int(row["retry_count"] or 0) + 1
|
||||
status = "failed" if retry_count >= 5 else "queued"
|
||||
con.execute(
|
||||
"UPDATE message_outbox SET retry_count=?, status=?, last_error=? WHERE id=?",
|
||||
(retry_count, status, str(exc)[:1000], msg_id),
|
||||
)
|
||||
audit(con, "wechat_outbound_failed", row["recipient_user_id"] or row["recipient_role"] or "unknown", {"message_id": msg_id, "retry_count": retry_count, "error": str(exc)[:1000]})
|
||||
|
||||
|
||||
def run_once(limit: int, dry_run: bool = False) -> Dict[str, Any]:
|
||||
DB.parent.mkdir(parents=True, exist_ok=True)
|
||||
with sqlite3.connect(DB) as con:
|
||||
con.row_factory = sqlite3.Row
|
||||
ensure_schema(con)
|
||||
rows = con.execute(
|
||||
f"SELECT * FROM message_outbox WHERE channel='wechat' AND status='queued' ORDER BY {PRIORITY_SQL}, created_at LIMIT ?",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
process_row(con, row, dry_run=dry_run)
|
||||
con.commit()
|
||||
return {"status": "ok", "processed": len(rows), "db": str(DB), "dry_run": dry_run}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Dispatch message_outbox WeChat notifications through the selected backend")
|
||||
parser.add_argument("--once", action="store_true", help="Run one dispatch pass and exit")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Run backend dry-run if supported and keep messages queued")
|
||||
parser.add_argument("--limit", type=int, default=DEFAULT_LIMIT)
|
||||
parser.add_argument("--interval", type=int, default=int(os.environ.get("AIOS_WECHAT_WORKER_INTERVAL", "10")))
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.once:
|
||||
print(json.dumps(run_once(args.limit, dry_run=args.dry_run), ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
while True:
|
||||
print(json.dumps(run_once(args.limit, dry_run=args.dry_run), ensure_ascii=False), flush=True)
|
||||
time.sleep(args.interval)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,273 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Pluggable WeChat send backend for AIOS / Xiamayiti.
|
||||
|
||||
Selection order:
|
||||
1. AIOS_WECHAT_SEND_BACKEND, if explicitly set;
|
||||
2. UCC adapter backend, when ucc-wechat-qr-adapter is alive;
|
||||
3. OpenClaw channel backend, only when channel discovery finds wechat/weixin;
|
||||
4. unavailable with explicit diagnostics.
|
||||
|
||||
This module must not print runtime secrets and must not hard-code account,
|
||||
target, adapter credentials, or channel names.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
OPENCLAW_BIN = os.environ.get("AIOS_OPENCLAW_BIN", "openclaw")
|
||||
OPENCLAW_GATEWAY = os.environ.get("AIOS_OPENCLAW_GATEWAY", "http://127.0.0.1:18789")
|
||||
OPENCLAW_AUTH_MODE = os.environ.get("AIOS_OPENCLAW_AUTH_MODE", "")
|
||||
OPENCLAW_AUTH_VALUE = os.environ.get("AIOS_OPENCLAW_AUTH_VALUE", "")
|
||||
WECHAT_ACCOUNT_ID = os.environ.get("AIOS_WECHAT_ACCOUNT_ID", "")
|
||||
WECHAT_CHANNEL = os.environ.get("AIOS_WECHAT_CHANNEL", "")
|
||||
SEND_BACKEND = os.environ.get("AIOS_WECHAT_SEND_BACKEND", "")
|
||||
UCC_ADAPTER_URL = os.environ.get("AIOS_UCC_WECHAT_ADAPTER_URL", "http://127.0.0.1:18800")
|
||||
UCC_ADAPTER_TOKEN = os.environ.get("AIOS_UCC_WECHAT_ADAPTER_TOKEN") or os.environ.get("AIOS_WECHAT_QR_ADAPTER_TOKEN", "")
|
||||
UCC_SEND_PATH = os.environ.get("AIOS_UCC_WECHAT_SEND_PATH", "/api/internal/wechat/send")
|
||||
|
||||
|
||||
class BackendResult:
|
||||
def __init__(self, ok: bool, backend: str, status: str, data: Dict[str, Any]) -> None:
|
||||
self.ok = ok
|
||||
self.backend = backend
|
||||
self.status = status
|
||||
self.data = data
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {"ok": self.ok, "backend": self.backend, "status": self.status, **self.data}
|
||||
|
||||
|
||||
def _openclaw_base_args() -> List[str]:
|
||||
args = [OPENCLAW_BIN]
|
||||
if OPENCLAW_GATEWAY:
|
||||
args.extend(["--gateway", OPENCLAW_GATEWAY])
|
||||
if OPENCLAW_AUTH_MODE and OPENCLAW_AUTH_VALUE:
|
||||
if OPENCLAW_AUTH_MODE == "token":
|
||||
args.extend(["--token", OPENCLAW_AUTH_VALUE])
|
||||
elif OPENCLAW_AUTH_MODE == "password":
|
||||
args.extend(["--password", OPENCLAW_AUTH_VALUE])
|
||||
return args
|
||||
|
||||
|
||||
def _run_openclaw(args: List[str], timeout: int = 30) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(args, text=True, capture_output=True, timeout=timeout, check=False)
|
||||
|
||||
|
||||
def _json_or_raw(text: str) -> Dict[str, Any]:
|
||||
try:
|
||||
return json.loads(text or "{}")
|
||||
except Exception:
|
||||
return {"raw": (text or "")[:1000]}
|
||||
|
||||
|
||||
def discover_openclaw_wechat_channel() -> Optional[str]:
|
||||
if WECHAT_CHANNEL:
|
||||
return WECHAT_CHANNEL
|
||||
cmd = _openclaw_base_args() + ["channels", "list", "--json"]
|
||||
proc = _run_openclaw(cmd, timeout=15)
|
||||
if proc.returncode != 0:
|
||||
return None
|
||||
data = _json_or_raw(proc.stdout)
|
||||
candidates: List[str] = []
|
||||
|
||||
def walk(value: Any) -> None:
|
||||
if isinstance(value, dict):
|
||||
name = str(value.get("name") or value.get("channel") or value.get("id") or "")
|
||||
typ = str(value.get("type") or value.get("kind") or "")
|
||||
joined = f"{name} {typ}".lower()
|
||||
if ("wechat" in joined or "weixin" in joined) and name:
|
||||
candidates.append(name)
|
||||
for child in value.values():
|
||||
walk(child)
|
||||
elif isinstance(value, list):
|
||||
for child in value:
|
||||
walk(child)
|
||||
|
||||
walk(data)
|
||||
return candidates[0] if candidates else None
|
||||
|
||||
|
||||
def _auth_headers() -> Dict[str, str]:
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if UCC_ADAPTER_TOKEN:
|
||||
headers["Authorization"] = "Bearer " + UCC_ADAPTER_TOKEN
|
||||
headers["X-AIOS-Token"] = UCC_ADAPTER_TOKEN
|
||||
return headers
|
||||
|
||||
|
||||
def _unique_paths(paths: List[str]) -> List[str]:
|
||||
seen = set()
|
||||
out = []
|
||||
for path in paths:
|
||||
if not path.startswith("/"):
|
||||
path = "/" + path
|
||||
if path not in seen:
|
||||
seen.add(path)
|
||||
out.append(path)
|
||||
return out
|
||||
|
||||
|
||||
def _ucc_send_paths() -> List[str]:
|
||||
return _unique_paths([UCC_SEND_PATH, "/api/internal/wechat/send", "/internal/channels/wechat/send"])
|
||||
|
||||
|
||||
def ucc_adapter_alive() -> bool:
|
||||
for path in ("/health", "/api/internal/health", "/api/health", "/status", "/api/internal/wechat/status"):
|
||||
try:
|
||||
req = urllib.request.Request(UCC_ADAPTER_URL.rstrip("/") + path, headers=_auth_headers(), method="GET")
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
if 200 <= resp.status < 500:
|
||||
return True
|
||||
except Exception:
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
def select_backend() -> Dict[str, Any]:
|
||||
explicit = SEND_BACKEND.strip().lower()
|
||||
if explicit:
|
||||
return {"backend": explicit, "reason": "explicit"}
|
||||
|
||||
if ucc_adapter_alive():
|
||||
return {"backend": "ucc-adapter", "reason": "ucc_adapter_alive"}
|
||||
|
||||
channel = discover_openclaw_wechat_channel()
|
||||
if channel:
|
||||
return {"backend": "openclaw-channel", "channel": channel, "reason": "openclaw_channel_discovered"}
|
||||
|
||||
return {
|
||||
"backend": "unavailable",
|
||||
"reason": "wechat outbound backend unavailable: ucc adapter not reachable; openclaw channel not registered",
|
||||
}
|
||||
|
||||
|
||||
def send_openclaw_channel(
|
||||
target: str,
|
||||
content: str,
|
||||
account_id: str = "",
|
||||
message_id: str = "",
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
dry_run: bool = False,
|
||||
) -> BackendResult:
|
||||
channel = WECHAT_CHANNEL or discover_openclaw_wechat_channel()
|
||||
if not channel:
|
||||
return BackendResult(False, "openclaw-channel", "failed", {"code": "openclaw_wechat_channel_not_registered"})
|
||||
account = account_id or WECHAT_ACCOUNT_ID
|
||||
if not account:
|
||||
return BackendResult(False, "openclaw-channel", "failed", {"code": "openclaw_account_not_configured"})
|
||||
|
||||
cmd = _openclaw_base_args() + [
|
||||
"message",
|
||||
"send",
|
||||
"--channel",
|
||||
channel,
|
||||
"--account",
|
||||
account,
|
||||
"--target",
|
||||
target,
|
||||
"-m",
|
||||
content,
|
||||
]
|
||||
if dry_run:
|
||||
cmd.append("--dry-run")
|
||||
cmd.append("--json")
|
||||
|
||||
proc = _run_openclaw(cmd)
|
||||
if proc.returncode != 0:
|
||||
return BackendResult(
|
||||
False,
|
||||
"openclaw-channel",
|
||||
"failed",
|
||||
{"code": "openclaw_send_failed", "error": (proc.stderr or proc.stdout)[:1000], "channel": channel},
|
||||
)
|
||||
return BackendResult(
|
||||
True,
|
||||
"openclaw-channel",
|
||||
"sent" if not dry_run else "dry_run",
|
||||
{"channel": channel, "result": _json_or_raw(proc.stdout)},
|
||||
)
|
||||
|
||||
|
||||
def _post_ucc(path: str, payload: Dict[str, Any]) -> tuple[int, Dict[str, Any]]:
|
||||
url = UCC_ADAPTER_URL.rstrip("/") + path
|
||||
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
req = urllib.request.Request(url, data=body, headers=_auth_headers(), method="POST")
|
||||
with urllib.request.urlopen(req, timeout=20) as resp:
|
||||
return resp.status, _json_or_raw(resp.read().decode("utf-8", errors="replace"))
|
||||
|
||||
|
||||
def _ucc_result_is_ok(data: Dict[str, Any], dry_run: bool) -> bool:
|
||||
status = str(data.get("status") or data.get("delivery_status") or "").lower()
|
||||
if dry_run:
|
||||
return bool(data.get("ok") or data.get("accepted")) and status in {"dry_run", "sent", "accepted", "ok"}
|
||||
return bool(data.get("ok")) and status == "sent"
|
||||
|
||||
|
||||
def send_ucc_adapter(
|
||||
target: str,
|
||||
content: str,
|
||||
account_id: str = "",
|
||||
message_id: str = "",
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
dry_run: bool = False,
|
||||
) -> BackendResult:
|
||||
payload = {
|
||||
"target": target,
|
||||
"content": content,
|
||||
"account_id": account_id or WECHAT_ACCOUNT_ID,
|
||||
"message_id": message_id,
|
||||
"metadata": metadata or {},
|
||||
"dry_run": dry_run,
|
||||
}
|
||||
last_error: Dict[str, Any] = {}
|
||||
for path in _ucc_send_paths():
|
||||
try:
|
||||
http_status, data = _post_ucc(path, payload)
|
||||
ok = _ucc_result_is_ok(data, dry_run)
|
||||
status = str(data.get("status") or ("sent" if ok and not dry_run else "dry_run" if ok else "failed"))
|
||||
code = str(data.get("code") or status)
|
||||
return BackendResult(
|
||||
ok,
|
||||
"ucc-adapter",
|
||||
status if ok else "failed",
|
||||
{"code": code, "http_status": http_status, "endpoint": path, "result": data},
|
||||
)
|
||||
except urllib.error.HTTPError as exc:
|
||||
data = _json_or_raw(exc.read().decode("utf-8", errors="replace"))
|
||||
last_error = {"code": data.get("code") or "ucc_adapter_http_error", "http_status": exc.code, "endpoint": path, "error": data}
|
||||
if exc.code in {404, 405}:
|
||||
continue
|
||||
return BackendResult(False, "ucc-adapter", "failed", last_error)
|
||||
except Exception as exc:
|
||||
last_error = {"code": "ucc_adapter_unavailable", "endpoint": path, "error": str(exc)[:1000]}
|
||||
continue
|
||||
return BackendResult(False, "ucc-adapter", "failed", last_error or {"code": "ucc_adapter_unavailable"})
|
||||
|
||||
|
||||
def send_message(
|
||||
target: str,
|
||||
content: str,
|
||||
account_id: str = "",
|
||||
message_id: str = "",
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
dry_run: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
selected = select_backend()
|
||||
backend = selected.get("backend")
|
||||
if backend == "ucc-adapter":
|
||||
return send_ucc_adapter(target, content, account_id, message_id, metadata, dry_run).to_dict()
|
||||
if backend == "openclaw-channel":
|
||||
return send_openclaw_channel(target, content, account_id, message_id, metadata, dry_run).to_dict()
|
||||
if backend == "unavailable":
|
||||
return BackendResult(False, "unavailable", "failed", {"code": "wechat_outbound_backend_unavailable", "error": selected.get("reason")}).to_dict()
|
||||
return BackendResult(False, backend or "unknown", "failed", {"code": "unsupported_backend", "backend": backend}).to_dict()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(json.dumps(select_backend(), ensure_ascii=False, indent=2))
|
||||
@@ -0,0 +1,444 @@
|
||||
#!/usr/bin/env python3
|
||||
"""AIOS Autonomous GitOps Daemon.
|
||||
|
||||
Low-impact sidecar daemon for:
|
||||
- reading GitOps events from a file-backed event bus;
|
||||
- running safe validation/self-healing commands;
|
||||
- sending owner notifications through a runtime-injected WeChat/WeCom webhook;
|
||||
- creating audit records and request artifacts instead of bypassing production gates.
|
||||
|
||||
No credentials are stored in this file. Runtime secrets must be injected by the host
|
||||
through environment variables or secret-mounted files.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime as _dt
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
||||
|
||||
ROOT = pathlib.Path(os.environ.get("AIOS_REPO_ROOT", ".")).resolve()
|
||||
CONFIG_PATH = ROOT / "config" / "codex" / "self-optimizing-orchestrator.json"
|
||||
PROD_POLICY_PATH = ROOT / "config" / "codex" / "autonomous-production-policy.json"
|
||||
MERGE_POLICY_PATH = ROOT / "config" / "codex" / "autonomous-merge-policy.json"
|
||||
EVENT_DIR = pathlib.Path(os.environ.get("AIOS_EVENT_DIR", str(ROOT / "runtime" / "gitops" / "events")))
|
||||
AUDIT_DIR = pathlib.Path(os.environ.get("AIOS_AUDIT_DIR", str(ROOT / "runtime" / "gitops" / "audit")))
|
||||
REQUEST_DIR = pathlib.Path(os.environ.get("AIOS_REQUEST_DIR", str(ROOT / "runtime" / "gitops" / "requests")))
|
||||
REPORT_DIR = pathlib.Path(os.environ.get("AIOS_REPORT_DIR", str(ROOT / "reports" / "codex")))
|
||||
PREAPPROVAL_DIR = pathlib.Path(os.environ.get("AIOS_PREAPPROVAL_DIR", str(ROOT / "approvals" / "preapproved")))
|
||||
|
||||
SAFE_AUTONOMOUS_EVENTS = {
|
||||
"git_push",
|
||||
"patch_created",
|
||||
"validation_failed",
|
||||
"validation_passed",
|
||||
"merge_candidate_created",
|
||||
"runtime_health_degraded",
|
||||
}
|
||||
|
||||
OWNER_NOTIFY_EVENTS = {
|
||||
"main_version_changed",
|
||||
"baseline_changed",
|
||||
"merge_candidate_created",
|
||||
"validation_failed",
|
||||
"rollback_request_created",
|
||||
"approval_required",
|
||||
"daily_digest",
|
||||
}
|
||||
|
||||
PRODUCTION_ACTIONS = {
|
||||
"merge_to_main",
|
||||
"create_release_tag",
|
||||
"activate_new_baseline",
|
||||
"production_runtime_change",
|
||||
"production_rollback_execution",
|
||||
}
|
||||
|
||||
ALWAYS_BLOCKED = {
|
||||
"force_push",
|
||||
"purge_baseline",
|
||||
"delete_version_history",
|
||||
"delete_audit_records",
|
||||
"commit_real_secret",
|
||||
"disable_auth_or_gitea_protection",
|
||||
}
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return _dt.datetime.now(_dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def load_json(path: pathlib.Path, default: Any) -> Any:
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError:
|
||||
return default
|
||||
except json.JSONDecodeError as exc:
|
||||
raise RuntimeError(f"Invalid JSON: {path}: {exc}") from exc
|
||||
|
||||
|
||||
def write_json(path: pathlib.Path, data: Any) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
tmp.replace(path)
|
||||
|
||||
|
||||
def event_id(event: Dict[str, Any]) -> str:
|
||||
raw = json.dumps(event, ensure_ascii=False, sort_keys=True).encode("utf-8")
|
||||
return hashlib.sha256(raw).hexdigest()[:16]
|
||||
|
||||
|
||||
def audit(action: str, status: str, detail: Dict[str, Any]) -> Dict[str, Any]:
|
||||
record = {
|
||||
"ts": utc_now(),
|
||||
"action": action,
|
||||
"status": status,
|
||||
"detail": redact(detail),
|
||||
}
|
||||
write_json(AUDIT_DIR / f"{record['ts'].replace(':', '-')}-{action}.json", record)
|
||||
print(json.dumps(record, ensure_ascii=False), flush=True)
|
||||
return record
|
||||
|
||||
|
||||
def redact(value: Any) -> Any:
|
||||
if isinstance(value, list):
|
||||
return [redact(v) for v in value]
|
||||
if isinstance(value, dict):
|
||||
out: Dict[str, Any] = {}
|
||||
for key, val in value.items():
|
||||
lowered = key.lower()
|
||||
sensitive = any(word in lowered for word in ["api_key", "apikey", "password", "secret", "private_key", "credential", "webhook"])
|
||||
if sensitive and not (isinstance(val, str) and val.startswith("secret_ref://")):
|
||||
out[key] = "[REDACTED]"
|
||||
else:
|
||||
out[key] = redact(val)
|
||||
return out
|
||||
return value
|
||||
|
||||
|
||||
def run_cmd(cmd: List[str], timeout: int = 300) -> Tuple[int, str, str]:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
cwd=str(ROOT),
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
)
|
||||
return result.returncode, result.stdout, result.stderr
|
||||
|
||||
|
||||
def read_event_files(limit: int = 100) -> List[pathlib.Path]:
|
||||
EVENT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
return sorted(EVENT_DIR.glob("*.json"), key=lambda p: p.stat().st_mtime)[:limit]
|
||||
|
||||
|
||||
def archive_event(path: pathlib.Path, status: str) -> None:
|
||||
target = EVENT_DIR / status / path.name
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(str(path), str(target))
|
||||
|
||||
|
||||
def get_webhook_url() -> Optional[str]:
|
||||
# Runtime-only injection. Do not commit real values.
|
||||
return os.environ.get("AIOS_OWNER_WECHAT_WEBHOOK_URL") or os.environ.get("AIOS_OWNER_WECOM_WEBHOOK_URL")
|
||||
|
||||
|
||||
def sign_payload(payload: Dict[str, Any]) -> Optional[str]:
|
||||
secret = os.environ.get("AIOS_OWNER_ACTION_SIGNING_SECRET")
|
||||
if not secret:
|
||||
return None
|
||||
raw = json.dumps(payload, ensure_ascii=False, sort_keys=True).encode("utf-8")
|
||||
return hmac.new(secret.encode("utf-8"), raw, hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def send_owner_wechat(event: Dict[str, Any], summary: Dict[str, Any]) -> Dict[str, Any]:
|
||||
url = get_webhook_url()
|
||||
if not url:
|
||||
return {"status": "skipped", "reason": "wechat_webhook_not_configured"}
|
||||
|
||||
content = format_owner_message(event, summary)
|
||||
body = {
|
||||
"msgtype": "markdown",
|
||||
"markdown": {"content": content},
|
||||
}
|
||||
signature = sign_payload(body)
|
||||
if signature:
|
||||
body["aios_signature"] = signature
|
||||
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=json.dumps(body, ensure_ascii=False).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
data = resp.read().decode("utf-8", errors="replace")
|
||||
return {"status": "sent", "http_status": resp.status, "response": data[:500]}
|
||||
except urllib.error.URLError as exc:
|
||||
return {"status": "failed", "error": str(exc)}
|
||||
|
||||
|
||||
def format_owner_message(event: Dict[str, Any], summary: Dict[str, Any]) -> str:
|
||||
title = event.get("type", "gitops_event")
|
||||
branch = summary.get("branch") or event.get("branch") or "-"
|
||||
from_version = summary.get("from_version") or event.get("from_version") or "-"
|
||||
to_version = summary.get("to_version") or event.get("to_version") or "-"
|
||||
changed = summary.get("changed_features") or event.get("changed_features") or []
|
||||
if isinstance(changed, list):
|
||||
changed_text = ", ".join(str(x) for x in changed[:8]) or "-"
|
||||
else:
|
||||
changed_text = str(changed)
|
||||
return "\n".join([
|
||||
f"### AIOS GitOps 通报:{title}",
|
||||
f"- Branch: `{branch}`",
|
||||
f"- Version: `{from_version}` → `{to_version}`",
|
||||
f"- Code Δ: +{summary.get('code_lines_added', 0)} / -{summary.get('code_lines_deleted', 0)}",
|
||||
f"- Config Δ: +{summary.get('config_lines_added', 0)} / -{summary.get('config_lines_deleted', 0)}",
|
||||
f"- Elapsed: {summary.get('elapsed_since_previous_main_change', '-')}",
|
||||
f"- Risk: {summary.get('risk', event.get('risk', 'unknown'))}",
|
||||
f"- Test: {summary.get('test_status', '-')}",
|
||||
f"- Rollback: `{summary.get('rollback_target', 'V1R1C4')}`",
|
||||
f"- Changed: {changed_text}",
|
||||
f"- Web UI: {summary.get('web_ui_audit_url', '/portal/me/#gitops-audit')}",
|
||||
])
|
||||
|
||||
|
||||
def diff_stats(base: str, branch: str) -> Dict[str, int]:
|
||||
code_add = code_del = config_add = config_del = 0
|
||||
rc, stdout, _ = run_cmd(["git", "diff", "--numstat", f"{base}...{branch}"], timeout=60)
|
||||
if rc != 0:
|
||||
return {"code_lines_added": 0, "code_lines_deleted": 0, "config_lines_added": 0, "config_lines_deleted": 0}
|
||||
config_ext = {".json", ".yaml", ".yml", ".toml", ".ini", ".conf", ".service"}
|
||||
for line in stdout.splitlines():
|
||||
parts = line.split("\t")
|
||||
if len(parts) < 3 or parts[0] == "-" or parts[1] == "-":
|
||||
continue
|
||||
add, delete, path = int(parts[0]), int(parts[1]), parts[2]
|
||||
if pathlib.Path(path).suffix in config_ext or path.startswith("config/"):
|
||||
config_add += add
|
||||
config_del += delete
|
||||
else:
|
||||
code_add += add
|
||||
code_del += delete
|
||||
return {
|
||||
"code_lines_added": code_add,
|
||||
"code_lines_deleted": code_del,
|
||||
"config_lines_added": config_add,
|
||||
"config_lines_deleted": config_del,
|
||||
}
|
||||
|
||||
|
||||
def build_summary(event: Dict[str, Any]) -> Dict[str, Any]:
|
||||
branch = event.get("branch") or "patch/web-ui-complete-gitops-dashboard-20260706"
|
||||
base = event.get("base") or "main"
|
||||
stats = diff_stats(base, branch)
|
||||
return {
|
||||
"event_id": event.get("id") or event_id(event),
|
||||
"branch": branch,
|
||||
"from_rev": event.get("from_rev", base),
|
||||
"to_rev": event.get("to_rev", branch),
|
||||
"from_version": event.get("from_version", "V1R1C4"),
|
||||
"to_version": event.get("to_version", "V1R1C5"),
|
||||
"elapsed_since_previous_main_change": event.get("elapsed_since_previous_main_change", "unknown"),
|
||||
"changed_features": event.get("changed_features", ["GitOps autonomous orchestration", "Owner notifications", "event bus", "safe daemon"]),
|
||||
"test_status": event.get("test_status", "pending"),
|
||||
"risk": event.get("risk", "high"),
|
||||
"approval_status": event.get("approval_status", "required"),
|
||||
"rollback_target": event.get("rollback_target", "V1R1C4"),
|
||||
"web_ui_audit_url": event.get("web_ui_audit_url", "/portal/me/#gitops-audit"),
|
||||
**stats,
|
||||
}
|
||||
|
||||
|
||||
def has_preapproval(action: str, event: Dict[str, Any]) -> bool:
|
||||
target_version = event.get("to_version") or "V1R1C5"
|
||||
rollback_target = event.get("rollback_target") or "V1R1C4"
|
||||
now_dt = _dt.datetime.now(_dt.timezone.utc)
|
||||
for path in PREAPPROVAL_DIR.glob("*.json"):
|
||||
try:
|
||||
doc = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
continue
|
||||
allowed = doc.get("allowed_actions", [])
|
||||
if action not in allowed:
|
||||
continue
|
||||
if doc.get("target_version") != target_version:
|
||||
continue
|
||||
if doc.get("rollback_target") != rollback_target:
|
||||
continue
|
||||
if doc.get("approved_by_role") not in ["owner", "admin"]:
|
||||
continue
|
||||
expires_at = doc.get("expires_at")
|
||||
if not expires_at:
|
||||
continue
|
||||
try:
|
||||
exp = _dt.datetime.fromisoformat(expires_at.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
continue
|
||||
if exp > now_dt:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def create_request(event: Dict[str, Any], reason: str) -> Dict[str, Any]:
|
||||
req = {
|
||||
"id": f"req-{utc_now().replace(':', '-')}-{event_id(event)}",
|
||||
"created_at": utc_now(),
|
||||
"event": redact(event),
|
||||
"reason": reason,
|
||||
"status": "pending_owner_review",
|
||||
"web_ui": "/portal/me/#gitops-owner-actions",
|
||||
}
|
||||
write_json(REQUEST_DIR / f"{req['id']}.json", req)
|
||||
return req
|
||||
|
||||
|
||||
def run_validation(event: Dict[str, Any]) -> Dict[str, Any]:
|
||||
branch = event.get("branch") or "patch/web-ui-complete-gitops-dashboard-20260706"
|
||||
base = event.get("base") or "main"
|
||||
script = ROOT / "scripts" / "validate-gitops-platform-ui.sh"
|
||||
if not script.exists():
|
||||
return {"status": "blocked", "reason": "validation_script_missing"}
|
||||
rc, out, err = run_cmd(["bash", str(script), branch, base], timeout=600)
|
||||
REPORT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
(REPORT_DIR / "validation-daemon.log").write_text(out + "\n" + err, encoding="utf-8")
|
||||
return {"status": "passed" if rc == 0 else "failed", "exit_code": rc, "log": "reports/codex/validation-daemon.log"}
|
||||
|
||||
|
||||
def run_merge_gate(event: Dict[str, Any]) -> Dict[str, Any]:
|
||||
branch = event.get("branch") or "patch/web-ui-complete-gitops-dashboard-20260706"
|
||||
base = event.get("base") or "main"
|
||||
script = ROOT / "scripts" / "autonomous-merge-gate.sh"
|
||||
if not script.exists():
|
||||
return {"status": "blocked", "reason": "merge_gate_script_missing"}
|
||||
rc, out, err = run_cmd(["bash", str(script), branch, base], timeout=600)
|
||||
(REPORT_DIR / "merge-gate-daemon.log").write_text(out + "\n" + err, encoding="utf-8")
|
||||
return {"status": "passed" if rc == 0 else "failed", "exit_code": rc, "log": "reports/codex/merge-gate-daemon.log"}
|
||||
|
||||
|
||||
def handle_event(event: Dict[str, Any]) -> Dict[str, Any]:
|
||||
event_type = event.get("type", "unknown")
|
||||
action = event.get("action", event_type)
|
||||
|
||||
if action in ALWAYS_BLOCKED:
|
||||
req = create_request(event, f"Blocked permanently by autonomous production policy: {action}")
|
||||
audit("event.blocked", "blocked", {"event": event, "request": req})
|
||||
return {"status": "blocked", "request": req}
|
||||
|
||||
summary = build_summary(event)
|
||||
|
||||
if event_type in OWNER_NOTIFY_EVENTS:
|
||||
notify_result = send_owner_wechat(event, summary)
|
||||
audit("owner.notify", notify_result.get("status", "unknown"), {"event": event, "summary": summary, "result": notify_result})
|
||||
|
||||
if event_type in {"git_push", "patch_created", "validation_failed"}:
|
||||
validation = run_validation(event)
|
||||
audit("codex.validation", validation["status"], {"event": event, "validation": validation})
|
||||
if validation["status"] != "passed":
|
||||
req = create_request(event, "Validation failed; GPT repair loop required.")
|
||||
return {"status": "validation_failed", "request": req, "validation": validation}
|
||||
gate = run_merge_gate(event)
|
||||
audit("codex.merge_gate", gate["status"], {"event": event, "gate": gate})
|
||||
return {"status": "merge_candidate" if gate["status"] == "passed" else "gate_failed", "validation": validation, "gate": gate}
|
||||
|
||||
if action in PRODUCTION_ACTIONS:
|
||||
if has_preapproval(action, event):
|
||||
# This daemon deliberately records intent. The actual protected write must be
|
||||
# performed by configured GitOps Actions with server-side authorization.
|
||||
req = create_request(event, f"Preapproval found for {action}; execute through protected GitOps Action.")
|
||||
audit("production.preapproved", "request_created", {"event": event, "request": req})
|
||||
return {"status": "preapproved_request_created", "request": req}
|
||||
req = create_request(event, f"Production action requires preapproved runbook: {action}")
|
||||
audit("production.approval_required", "pending", {"event": event, "request": req})
|
||||
return {"status": "approval_required", "request": req}
|
||||
|
||||
audit("event.record", "ok", {"event": event, "summary": summary})
|
||||
return {"status": "recorded", "summary": summary}
|
||||
|
||||
|
||||
def process_once() -> int:
|
||||
count = 0
|
||||
for path in read_event_files():
|
||||
try:
|
||||
event = json.loads(path.read_text(encoding="utf-8"))
|
||||
result = handle_event(event)
|
||||
archive_event(path, "done")
|
||||
count += 1
|
||||
audit("event.processed", "ok", {"path": str(path), "result": result})
|
||||
except Exception as exc: # noqa: BLE001 - daemon must not crash on single event
|
||||
archive_event(path, "failed")
|
||||
audit("event.processed", "failed", {"path": str(path), "error": str(exc)})
|
||||
return count
|
||||
|
||||
|
||||
def emit_event(args: argparse.Namespace) -> None:
|
||||
event = {
|
||||
"id": args.id or f"evt-{utc_now().replace(':', '-')}",
|
||||
"type": args.type,
|
||||
"action": args.action or args.type,
|
||||
"branch": args.branch,
|
||||
"base": args.base,
|
||||
"from_version": args.from_version,
|
||||
"to_version": args.to_version,
|
||||
"risk": args.risk,
|
||||
"created_at": utc_now(),
|
||||
}
|
||||
write_json(EVENT_DIR / f"{event['id']}.json", event)
|
||||
print(json.dumps(redact(event), ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
def main(argv: Optional[Iterable[str]] = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="AIOS Autonomous GitOps Daemon")
|
||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
run = sub.add_parser("run")
|
||||
run.add_argument("--interval", type=int, default=30)
|
||||
run.add_argument("--once", action="store_true")
|
||||
|
||||
emit = sub.add_parser("emit")
|
||||
emit.add_argument("--id")
|
||||
emit.add_argument("--type", required=True)
|
||||
emit.add_argument("--action")
|
||||
emit.add_argument("--branch", default="patch/web-ui-complete-gitops-dashboard-20260706")
|
||||
emit.add_argument("--base", default="main")
|
||||
emit.add_argument("--from-version", default="V1R1C4")
|
||||
emit.add_argument("--to-version", default="V1R1C5")
|
||||
emit.add_argument("--risk", default="high")
|
||||
|
||||
args = parser.parse_args(list(argv) if argv is not None else None)
|
||||
|
||||
if args.cmd == "emit":
|
||||
emit_event(args)
|
||||
return 0
|
||||
|
||||
if args.cmd == "run":
|
||||
EVENT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
AUDIT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
REQUEST_DIR.mkdir(parents=True, exist_ok=True)
|
||||
if args.once:
|
||||
return 0 if process_once() >= 0 else 1
|
||||
audit("daemon.start", "ok", {"event_dir": str(EVENT_DIR)})
|
||||
while True:
|
||||
process_once()
|
||||
time.sleep(args.interval)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env python3
|
||||
"""AIOS Multi-Agent Consensus Controller
|
||||
|
||||
This service aggregates decisions from OpenClaw, Codex, and ChatGPT agents
|
||||
and computes a bounded consensus decision for GitOps operations.
|
||||
|
||||
It does NOT directly modify production state. It only produces decision artifacts.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import time
|
||||
from typing import Dict, Any
|
||||
|
||||
ROOT = pathlib.Path(os.environ.get("AIOS_REPO_ROOT", ".")).resolve()
|
||||
CONSENSUS_DIR = ROOT / "reports" / "consensus"
|
||||
AGENT_DIRS = {
|
||||
"openclaw": ROOT / "reports" / "openclaw",
|
||||
"codex": ROOT / "reports" / "codex",
|
||||
"chatgpt": ROOT / "reports" / "chatgpt",
|
||||
}
|
||||
|
||||
CONSENSUS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def load_latest(path: pathlib.Path) -> Dict[str, Any]:
|
||||
if not path.exists():
|
||||
return {}
|
||||
files = sorted(path.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
if not files:
|
||||
return {}
|
||||
return json.loads(files[0].read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def compute_consensus(reports: Dict[str, Dict[str, Any]]) -> Dict[str, Any]:
|
||||
weights = {"openclaw": 2, "codex": 2, "chatgpt": 1}
|
||||
|
||||
score = 0
|
||||
for agent, report in reports.items():
|
||||
status = report.get("status", "unknown")
|
||||
if status in ("PASS", "approved", "merge_candidate"):
|
||||
score += weights.get(agent, 1)
|
||||
elif status in ("FAIL", "blocked"):
|
||||
score -= weights.get(agent, 1)
|
||||
|
||||
if score >= 4:
|
||||
decision = "approved"
|
||||
elif score >= 2:
|
||||
decision = "conditional"
|
||||
else:
|
||||
decision = "rejected"
|
||||
|
||||
return {
|
||||
"decision": decision,
|
||||
"score": score,
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
reports = {
|
||||
"openclaw": load_latest(AGENT_DIRS["openclaw"]),
|
||||
"codex": load_latest(AGENT_DIRS["codex"]),
|
||||
"chatgpt": load_latest(AGENT_DIRS["chatgpt"]),
|
||||
}
|
||||
|
||||
result = compute_consensus(reports)
|
||||
|
||||
out_path = CONSENSUS_DIR / "decision.json"
|
||||
out_path.write_text(json.dumps(result, indent=2), encoding="utf-8")
|
||||
|
||||
print("CONSENSUS:", result)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env python3
|
||||
"""AIOS Event Ingress Server
|
||||
|
||||
Receives GitOps events via HTTP and writes them into the local event bus directory.
|
||||
No authentication secrets are embedded; optional signature validation can be enabled via env.
|
||||
"""
|
||||
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import time
|
||||
|
||||
EVENT_DIR = pathlib.Path(os.environ.get("AIOS_EVENT_DIR", "runtime/gitops/events"))
|
||||
EVENT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_POST(self):
|
||||
length = int(self.headers.get('Content-Length', 0))
|
||||
body = self.rfile.read(length).decode('utf-8')
|
||||
try:
|
||||
event = json.loads(body)
|
||||
except Exception:
|
||||
self.send_response(400)
|
||||
self.end_headers()
|
||||
return
|
||||
|
||||
event_id = event.get("id", f"evt-{int(time.time())}")
|
||||
path = EVENT_DIR / f"{event_id}.json"
|
||||
path.write_text(json.dumps(event, indent=2), encoding='utf-8')
|
||||
|
||||
self.send_response(200)
|
||||
self.end_headers()
|
||||
self.wfile.write(b"OK")
|
||||
|
||||
def log_message(self, format, *args):
|
||||
return
|
||||
|
||||
if __name__ == "__main__":
|
||||
port = int(os.environ.get("AIOS_INGRESS_PORT", "8787"))
|
||||
server = HTTPServer(("0.0.0.0", port), Handler)
|
||||
print(f"[AIOS] Event ingress running on {port}")
|
||||
server.serve_forever()
|
||||
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env python3
|
||||
"""V1R1 Personal WeChat Endpoint Discovery
|
||||
|
||||
Helps locate active V1R1 personal WeChat runtime endpoints on the host.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import re
|
||||
|
||||
CANDIDATES = [8788, 8789, 8790, 8088, 8000]
|
||||
|
||||
print("[AIOS] Scanning for V1R1 WeChat endpoints...")
|
||||
|
||||
for port in CANDIDATES:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["bash", "-lc", f"ss -ltnp | grep {port}"],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
if result.stdout:
|
||||
print(f"[FOUND] Port {port}: {result.stdout.strip()}")
|
||||
else:
|
||||
print(f"[MISS] Port {port}")
|
||||
except Exception as e:
|
||||
print(f"[ERROR] Port {port}: {e}")
|
||||
|
||||
print("[AIOS] Discovery complete. Please map discovered endpoint to AIOS_V1R1_WECHAT_OUTBOUND_URL")
|
||||
@@ -0,0 +1,206 @@
|
||||
#!/usr/bin/env python3
|
||||
"""AIOS V1R1 Personal WeChat Outbound Worker.
|
||||
|
||||
This worker is the bridge between the Xiamayiti self-evolution loop and the
|
||||
already-deployed V1R1 personal WeChat capability.
|
||||
|
||||
Design rules:
|
||||
- Do not embed credentials.
|
||||
- Do not access runtime/secrets directly.
|
||||
- Read owner-notification outbox JSON files.
|
||||
- Deliver to a runtime-configured V1R1 notification endpoint, or try local V1R1 candidates.
|
||||
- Write delivery status for audit/Web UI.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import shutil
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any, Dict, Iterable, List, Optional
|
||||
|
||||
ROOT = pathlib.Path(os.environ.get("AIOS_REPO_ROOT", "/data/aios-gitops/workspace"))
|
||||
BASE = pathlib.Path(os.environ.get("AIOS_RUNTIME_ROOT", "/data/aios-gitops/runtime"))
|
||||
OUTBOX = pathlib.Path(os.environ.get("AIOS_V1R1_WECHAT_OUTBOX", str(BASE / "integrations" / "v1r1-personal-wechat" / "outbox")))
|
||||
SENT = pathlib.Path(os.environ.get("AIOS_V1R1_WECHAT_SENT", str(BASE / "integrations" / "v1r1-personal-wechat" / "sent")))
|
||||
FAILED = pathlib.Path(os.environ.get("AIOS_V1R1_WECHAT_FAILED", str(BASE / "integrations" / "v1r1-personal-wechat" / "failed")))
|
||||
STATUS = pathlib.Path(os.environ.get("AIOS_V1R1_WECHAT_STATUS", str(BASE / "integrations" / "v1r1-personal-wechat" / "status")))
|
||||
AUDIT = pathlib.Path(os.environ.get("AIOS_AUDIT_DIR", str(BASE / "audit")))
|
||||
INTERVAL = int(os.environ.get("AIOS_V1R1_WECHAT_INTERVAL", "10"))
|
||||
|
||||
# Runtime-configured endpoint. This can point to the existing V1R1 personal-WeChat module.
|
||||
PRIMARY_ENDPOINT = os.environ.get("AIOS_V1R1_WECHAT_OUTBOUND_URL", "")
|
||||
|
||||
LOCAL_CANDIDATES = [
|
||||
"http://127.0.0.1:8788/api/v1r1/personal-wechat/send",
|
||||
"http://127.0.0.1:8788/api/v1r1/notification/owner",
|
||||
"http://127.0.0.1:8789/api/owner/wechat/send",
|
||||
"http://127.0.0.1:8790/owner/notify",
|
||||
]
|
||||
|
||||
|
||||
def now() -> str:
|
||||
return dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def write_json(path: pathlib.Path, value: Dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
tmp.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
tmp.replace(path)
|
||||
|
||||
|
||||
def audit(action: str, status: str, detail: Dict[str, Any]) -> None:
|
||||
record = {"ts": now(), "action": action, "status": status, "detail": detail}
|
||||
safe_name = record["ts"].replace(":", "-") + f"-{action}.json"
|
||||
write_json(AUDIT / safe_name, record)
|
||||
print(json.dumps(record, ensure_ascii=False), flush=True)
|
||||
|
||||
|
||||
def normalize_message(item: Dict[str, Any]) -> Dict[str, Any]:
|
||||
title = item.get("title") or "AIOS GitOps 通报"
|
||||
content = item.get("content") or item.get("message") or json.dumps(item, ensure_ascii=False)
|
||||
priority = item.get("priority") or "normal"
|
||||
return {
|
||||
"event_id": item.get("event_id") or item.get("id") or f"evt-{int(time.time())}",
|
||||
"channel": "personal_wechat",
|
||||
"priority": priority,
|
||||
"title": title,
|
||||
"content": content,
|
||||
"source": item.get("source") or "aios-self-evolution",
|
||||
"branch": item.get("branch"),
|
||||
"from_version": item.get("from_version"),
|
||||
"to_version": item.get("to_version"),
|
||||
"rollback_target": item.get("rollback_target") or "V1R1C4",
|
||||
"web_ui_audit_url": item.get("web_ui_audit_url") or "/portal/me/#gitops-audit",
|
||||
"created_at": item.get("created_at") or now(),
|
||||
}
|
||||
|
||||
|
||||
def endpoints() -> List[str]:
|
||||
values: List[str] = []
|
||||
if PRIMARY_ENDPOINT:
|
||||
values.append(PRIMARY_ENDPOINT)
|
||||
values.extend(LOCAL_CANDIDATES)
|
||||
# Preserve order but remove duplicates.
|
||||
result: List[str] = []
|
||||
seen = set()
|
||||
for value in values:
|
||||
if value and value not in seen:
|
||||
result.append(value)
|
||||
seen.add(value)
|
||||
return result
|
||||
|
||||
|
||||
def post_json(url: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
req = urllib.request.Request(url, data=body, headers={"Content-Type": "application/json"}, method="POST")
|
||||
with urllib.request.urlopen(req, timeout=8) as resp:
|
||||
text = resp.read().decode("utf-8", errors="replace")[:500]
|
||||
return {"ok": 200 <= resp.status < 300, "status": resp.status, "response": text, "url": url}
|
||||
|
||||
|
||||
def deliver(message: Dict[str, Any]) -> Dict[str, Any]:
|
||||
attempts = []
|
||||
for url in endpoints():
|
||||
try:
|
||||
result = post_json(url, message)
|
||||
attempts.append(result)
|
||||
if result.get("ok"):
|
||||
return {"status": "sent", "endpoint": url, "attempts": attempts}
|
||||
except urllib.error.URLError as exc:
|
||||
attempts.append({"ok": False, "url": url, "error": str(exc)})
|
||||
except Exception as exc: # noqa: BLE001
|
||||
attempts.append({"ok": False, "url": url, "error": str(exc)})
|
||||
return {"status": "failed", "attempts": attempts, "reason": "no_endpoint_accepted_message"}
|
||||
|
||||
|
||||
def process_file(path: pathlib.Path) -> None:
|
||||
try:
|
||||
item = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
failed_target = FAILED / path.name
|
||||
FAILED.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(str(path), str(failed_target))
|
||||
audit("v1r1.wechat.parse", "failed", {"path": str(path), "error": str(exc)})
|
||||
return
|
||||
|
||||
message = normalize_message(item)
|
||||
result = deliver(message)
|
||||
status_record = {"ts": now(), "file": path.name, "message": message, "delivery": result}
|
||||
write_json(STATUS / (path.stem + ".status.json"), status_record)
|
||||
|
||||
if result.get("status") == "sent":
|
||||
SENT.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(str(path), str(SENT / path.name))
|
||||
audit("v1r1.wechat.deliver", "sent", status_record)
|
||||
else:
|
||||
FAILED.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(str(path), str(FAILED / path.name))
|
||||
audit("v1r1.wechat.deliver", "failed", status_record)
|
||||
|
||||
|
||||
def process_once() -> int:
|
||||
OUTBOX.mkdir(parents=True, exist_ok=True)
|
||||
count = 0
|
||||
for path in sorted(OUTBOX.glob("*.json"), key=lambda p: p.stat().st_mtime):
|
||||
process_file(path)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def emit_startup_notice() -> pathlib.Path:
|
||||
OUTBOX.mkdir(parents=True, exist_ok=True)
|
||||
path = OUTBOX / f"startup-{int(time.time())}.json"
|
||||
write_json(path, {
|
||||
"event_id": f"startup-{int(time.time())}",
|
||||
"type": "owner_notification",
|
||||
"channel": "personal_wechat",
|
||||
"priority": "high",
|
||||
"title": "AIOS 自我进化系统已启动",
|
||||
"content": "AIOS 虾马一体 self-evolution loop 已进入运行态。daemon、event bus、GitOps 写链路已恢复;后续将通过 V1R1 个人微信模块与 Web UI 审计同步进展。",
|
||||
"source": "aios-v1r1-wechat-outbound-worker",
|
||||
"branch": "patch/web-ui-complete-gitops-dashboard-20260706",
|
||||
"from_version": "V1R1C4",
|
||||
"to_version": "V1R1C5",
|
||||
"rollback_target": "V1R1C4",
|
||||
"web_ui_audit_url": "/portal/me/#gitops-audit",
|
||||
"created_at": now()
|
||||
})
|
||||
return path
|
||||
|
||||
|
||||
def main() -> int:
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description="AIOS V1R1 personal WeChat outbound worker")
|
||||
parser.add_argument("--once", action="store_true")
|
||||
parser.add_argument("--emit-startup-notice", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
OUTBOX.mkdir(parents=True, exist_ok=True)
|
||||
SENT.mkdir(parents=True, exist_ok=True)
|
||||
FAILED.mkdir(parents=True, exist_ok=True)
|
||||
STATUS.mkdir(parents=True, exist_ok=True)
|
||||
AUDIT.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if args.emit_startup_notice:
|
||||
path = emit_startup_notice()
|
||||
audit("v1r1.wechat.outbox", "created", {"path": str(path)})
|
||||
|
||||
if args.once:
|
||||
process_once()
|
||||
return 0
|
||||
|
||||
audit("v1r1.wechat.worker.start", "ok", {"outbox": str(OUTBOX), "has_primary_endpoint": bool(PRIMARY_ENDPOINT)})
|
||||
while True:
|
||||
process_once()
|
||||
time.sleep(INTERVAL)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,52 @@
|
||||
# Skill: GitOps Platform Validation
|
||||
|
||||
## Name
|
||||
gitops-platform-validation
|
||||
|
||||
## Version
|
||||
1.0.0
|
||||
|
||||
## Purpose
|
||||
Provide reusable Codex execution behavior for validating GitOps platform UI branches and enabling self-healing CI loops.
|
||||
|
||||
## Trigger
|
||||
- branch matches: patch/*
|
||||
- contains: gitops-dashboard
|
||||
|
||||
## Actions
|
||||
1. Run validation script
|
||||
```bash
|
||||
bash scripts/validate-gitops-platform-ui.sh <branch> main
|
||||
```
|
||||
|
||||
2. If FAIL:
|
||||
- generate failure report
|
||||
- invoke GPT repair loop
|
||||
- re-run validation
|
||||
|
||||
3. If PASS:
|
||||
- run autonomous merge gate
|
||||
```bash
|
||||
bash scripts/autonomous-merge-gate.sh <branch> main
|
||||
```
|
||||
|
||||
4. If merge candidate created:
|
||||
- propose V1R1C5 baseline update
|
||||
|
||||
## Constraints
|
||||
- no secrets in logs
|
||||
- no direct main write
|
||||
- destructive operations forbidden
|
||||
- all merges must pass policy gate
|
||||
|
||||
## Self-Healing Behavior
|
||||
- max iterations: 5
|
||||
- stop on:
|
||||
- PASS
|
||||
- security failure
|
||||
- manual override
|
||||
|
||||
## Output
|
||||
- validation report
|
||||
- merge candidate artifact
|
||||
- optional baseline proposal
|
||||
@@ -0,0 +1,239 @@
|
||||
{
|
||||
"suite": "gitops-platform-ui",
|
||||
"version": "1.0.0",
|
||||
"branch_under_test": "patch/web-ui-complete-gitops-dashboard-20260706",
|
||||
"base_branch": "main",
|
||||
"purpose": "Validate AIOS GitOps Platform Control Plane UI before merge to main and before V1R1C5 baseline proposal.",
|
||||
"required_result": "PASS",
|
||||
"tests": [
|
||||
{
|
||||
"id": "STATIC-001",
|
||||
"group": "static_files",
|
||||
"required": true,
|
||||
"description": "Platform UI files exist.",
|
||||
"assert": {
|
||||
"files_exist": [
|
||||
"overlays/user-control-center/portal/gitops-dashboard/index.html",
|
||||
"overlays/user-control-center/portal/gitops-dashboard/app.js",
|
||||
"overlays/user-control-center/portal/gitops-dashboard/styles.css",
|
||||
"overlays/user-control-center/portal/gitops-dashboard/manifest.json",
|
||||
"overlays/user-control-center/portal/gitops-dashboard/gitops-ui-policy.json",
|
||||
"overlays/user-control-center/portal/gitops-dashboard/gitops-enterprise-api.contract.json"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "STATIC-002",
|
||||
"group": "static_files",
|
||||
"required": true,
|
||||
"description": "Enterprise tabs exist in HTML.",
|
||||
"assert": {
|
||||
"file_contains": {
|
||||
"overlays/user-control-center/portal/gitops-dashboard/index.html": [
|
||||
"tab-overview",
|
||||
"tab-rbac",
|
||||
"tab-approval",
|
||||
"tab-diff",
|
||||
"tab-patch",
|
||||
"tab-model",
|
||||
"tab-release",
|
||||
"tab-audit"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "RUNTIME-001",
|
||||
"group": "frontend_runtime",
|
||||
"required": true,
|
||||
"description": "Platform runtime bootstraps on DOMContentLoaded.",
|
||||
"assert": {
|
||||
"file_contains": {
|
||||
"overlays/user-control-center/portal/gitops-dashboard/app.js": [
|
||||
"Platform.bootstrap",
|
||||
"DOMContentLoaded",
|
||||
"guardAction",
|
||||
"createApproval",
|
||||
"renderDiffTree",
|
||||
"audit(" ,
|
||||
"redact("
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "SEC-001",
|
||||
"group": "security",
|
||||
"required": true,
|
||||
"description": "No real credentials are committed.",
|
||||
"assert": {
|
||||
"secret_scan": {
|
||||
"paths": [
|
||||
"overlays/user-control-center/portal/gitops-dashboard",
|
||||
"docs/codex",
|
||||
"tests/acceptance",
|
||||
"scripts",
|
||||
"skills"
|
||||
],
|
||||
"allow_patterns": [
|
||||
"secret_ref://",
|
||||
"[REDACTED]",
|
||||
"api_key_secret_ref",
|
||||
"forbiddenFields",
|
||||
"secret_redaction",
|
||||
"No credentials"
|
||||
],
|
||||
"deny_patterns": [
|
||||
"Bearer [A-Za-z0-9._-]{20,}",
|
||||
"sk-[A-Za-z0-9]{20,}",
|
||||
"ghp_[A-Za-z0-9]{20,}",
|
||||
"AKIA[0-9A-Z]{16}",
|
||||
"BEGIN PRIVATE KEY",
|
||||
"BEGIN RSA PRIVATE KEY"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "SEC-002",
|
||||
"group": "security",
|
||||
"required": true,
|
||||
"description": "Model registration requires secret_ref and never raw api_key.",
|
||||
"assert": {
|
||||
"file_contains": {
|
||||
"overlays/user-control-center/portal/gitops-dashboard/app.js": [
|
||||
"api_key_secret_ref",
|
||||
"startsWith('secret_ref://')",
|
||||
"api_key_secret_ref_required"
|
||||
]
|
||||
},
|
||||
"file_not_contains": {
|
||||
"overlays/user-control-center/portal/gitops-dashboard/index.html": [
|
||||
"name=\"api_key\"",
|
||||
"name=\"token\"",
|
||||
"name=\"password\""
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "POLICY-001",
|
||||
"group": "gitops_policy",
|
||||
"required": true,
|
||||
"description": "Direct main writes and destructive actions are disabled or blocked.",
|
||||
"assert": {
|
||||
"file_contains": {
|
||||
"overlays/user-control-center/portal/gitops-dashboard/app.js": [
|
||||
"main:write",
|
||||
"destructive_action_blocked",
|
||||
"high_risk_requires_approval"
|
||||
],
|
||||
"overlays/user-control-center/portal/gitops-dashboard/gitops-ui-policy.json": [
|
||||
"direct_main_write",
|
||||
"disabled_in_ui",
|
||||
"bulk_delete",
|
||||
"forbidden"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "API-001",
|
||||
"group": "api_contract",
|
||||
"required": true,
|
||||
"description": "Read/write endpoint contract remains compatible with existing GitOps APIs.",
|
||||
"assert": {
|
||||
"file_contains": {
|
||||
"overlays/user-control-center/portal/gitops-dashboard/gitops-enterprise-api.contract.json": [
|
||||
"GET /api/base",
|
||||
"GET /api/ver",
|
||||
"GET /api/run",
|
||||
"GET /api/patch",
|
||||
"GET /api/model",
|
||||
"POST /api/branch",
|
||||
"POST /api/patch/plan",
|
||||
"POST /api/model",
|
||||
"PUT /api/model-priority",
|
||||
"POST /api/rollback-request",
|
||||
"POST /api/tag-request"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "REGRESSION-001",
|
||||
"group": "indirect_regression",
|
||||
"required": true,
|
||||
"description": "This branch must not alter auth, gitea owner sync, baseline version, or destructive history files.",
|
||||
"assert": {
|
||||
"diff_not_touch_paths": [
|
||||
"config/auth/",
|
||||
"config/casdoor/",
|
||||
"config/gitea/",
|
||||
"versions/V1R1C4.json",
|
||||
"runtime/gitops/current.json"
|
||||
],
|
||||
"diff_not_contain": [
|
||||
"active_version\": \"V1R1C5",
|
||||
"force push",
|
||||
"delete tag",
|
||||
"purge baseline",
|
||||
"clear audit"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "BASELINE-001",
|
||||
"group": "baseline_proposal",
|
||||
"required": true,
|
||||
"description": "A V1R1C5 proposal exists but is not activated before merge.",
|
||||
"assert": {
|
||||
"files_exist": [
|
||||
"versions/proposals/V1R1C5-gitops-platform-ui.proposed.json"
|
||||
],
|
||||
"file_contains": {
|
||||
"versions/proposals/V1R1C5-gitops-platform-ui.proposed.json": [
|
||||
"V1R1C5",
|
||||
"V1R1C4",
|
||||
"approval_required"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "SKILL-001",
|
||||
"group": "auto_loaded_skill",
|
||||
"required": true,
|
||||
"description": "Codex/GPT reusable skill exists and references the validation workflow.",
|
||||
"assert": {
|
||||
"files_exist": [
|
||||
"skills/gitops-platform-validation/SKILL.md"
|
||||
],
|
||||
"file_contains": {
|
||||
"skills/gitops-platform-validation/SKILL.md": [
|
||||
"validate-gitops-platform-ui.sh",
|
||||
"gitops-platform-ui.acceptance.json",
|
||||
"self-healing"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"merge_gate": {
|
||||
"allow_merge_to_main_when": [
|
||||
"all required tests PASS",
|
||||
"gray deployment PASS",
|
||||
"no secret leak",
|
||||
"no direct main write",
|
||||
"V1R1C5 proposal reviewed",
|
||||
"owner/admin approval recorded"
|
||||
],
|
||||
"block_merge_when": [
|
||||
"any required test FAIL",
|
||||
"any required test BLOCKED without explicit owner override",
|
||||
"secret leak detected",
|
||||
"destructive operation detected",
|
||||
"baseline active version changed before merge"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"version": "V1R1C5",
|
||||
"status": "proposed",
|
||||
"activation": "approval_required",
|
||||
"base_version": "V1R1C4",
|
||||
"rollback_target": "V1R1C4",
|
||||
"purpose": "Promote AIOS GitOps Platform Control Plane with autonomous CI, self-healing validation, bounded production policy, event bus, daemon, owner notification, Web UI audit and Codex validation skills.",
|
||||
"risk": "high",
|
||||
"approval": {
|
||||
"required": true,
|
||||
"roles": ["owner", "admin"],
|
||||
"reason": "Introduces autonomous orchestration and production-adjacent daemon. Main merge, tag creation and baseline activation remain gated."
|
||||
},
|
||||
"scope": {
|
||||
"direct_changes": [
|
||||
"overlays/user-control-center/portal/gitops-dashboard",
|
||||
"services/aios-autonomous-daemon",
|
||||
"config/codex",
|
||||
"scripts",
|
||||
"tests/acceptance",
|
||||
"skills",
|
||||
"docs/codex",
|
||||
"docs/ops",
|
||||
"deploy/systemd"
|
||||
],
|
||||
"indirect_impact": [
|
||||
"Portal GitOps UI visibility",
|
||||
"Owner notification workflow",
|
||||
"Codex validation workflow",
|
||||
"Self-healing patch loop",
|
||||
"Web UI audit trail"
|
||||
]
|
||||
},
|
||||
"non_goals": [
|
||||
"No direct main push",
|
||||
"No committed secrets",
|
||||
"No destructive history deletion",
|
||||
"No production rollback execution without approval or preapproved runbook"
|
||||
],
|
||||
"validation": {
|
||||
"acceptance_suite": "tests/acceptance/gitops-platform-ui.acceptance.json",
|
||||
"script": "scripts/validate-gitops-platform-ui.sh",
|
||||
"merge_gate": "scripts/autonomous-merge-gate.sh",
|
||||
"gray_validation": "scripts/gray-validate-gitops-platform-ui.sh"
|
||||
},
|
||||
"owner_notification": {
|
||||
"required_on_main_version_change": true,
|
||||
"channels": ["wechat_or_wecom", "web_ui_audit"],
|
||||
"webhook_secret_ref": "secret_ref://aios/notifications/owner-wechat-webhook"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user