Compare commits
72
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 | ||
|
|
a9ef9197e6 | ||
|
|
54870ed375 | ||
|
|
c850858973 | ||
|
|
d08a7043d6 | ||
|
|
c393931f64 | ||
|
|
a62d4ce2c1 | ||
|
|
5a21bd5d7e | ||
|
|
ed92b88fc0 | ||
|
|
a248142158 | ||
|
|
ecf673500b | ||
|
|
43ddb91568 | ||
|
|
0b0505a645 | ||
|
|
96c00d81a9 | ||
|
|
e7d36ad706 | ||
|
|
d6cb9d1529 | ||
|
|
c06a23f4f8 | ||
|
|
568c283aff |
@@ -0,0 +1,278 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
import hashlib, hmac, json, os, re, subprocess, time, urllib.parse, uuid
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
ROOT = Path(os.getenv('AIOS_GITOPS_ROOT', '/data/aios-gitops'))
|
||||
WS = Path(os.getenv('AIOS_GITOPS_WORKSPACE', str(ROOT / 'workspace')))
|
||||
SECRETS = Path(os.getenv('AIOS_GITOPS_SECRETS_DIR', str(ROOT / 'runtime' / 'secrets')))
|
||||
TOKEN_FILE = Path(os.getenv('AIOS_GITOPS_TOKEN_FILE', str(SECRETS / 'gitops.tokens')))
|
||||
HOST = os.getenv('AIOS_GITOPS_HOST', '127.0.0.1')
|
||||
PORT = int(os.getenv('AIOS_GITOPS_PORT', '18890'))
|
||||
OPENAPI = WS / 'docs' / 'gitops' / 'openapi-chatgpt-actions.yaml'
|
||||
BARE_REPO = Path(os.getenv('AIOS_GITOPS_BARE_REPO', str(ROOT / 'runtime' / 'gitea-bin' / 'data' / 'repositories' / 'aios' / 'aios-v23.git')))
|
||||
BASE_MODELS = [
|
||||
{'id':'base.deepseekv4-flash','provider':'one-api','model':'deepseekv4-flash','display_name':'DeepSeek V4 Flash','scope':'global'},
|
||||
{'id':'base.qwen3.6-35b','provider':'one-api','model':'qwen3.6-35b','display_name':'Qwen 3.6 35B','scope':'global'},
|
||||
{'id':'base.gpt-5.5','provider':'openai','model':'gpt-5.5','display_name':'GPT-5.5','scope':'global'},
|
||||
]
|
||||
SECRET_RE = re.compile(r'(?i)(api[_-]?key|secret[_-]?access[_-]?key|secret|password|token)\s*[:=]\s*["\']?([A-Za-z0-9_./+=:-]{16,})')
|
||||
SECRET_VAL_RE = re.compile(r'(sk-[A-Za-z0-9_-]{20,}|HPUA[A-Z0-9]{10,}|SDC[A-Za-z0-9]{20,})')
|
||||
|
||||
def now() -> str: return time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())
|
||||
def uid(p='id') -> str: return f'{p}-{int(time.time()*1000)}-{uuid.uuid4().hex[:8]}'
|
||||
def safe(s: str, n=160) -> str: return re.sub(r'[^A-Za-z0-9_./@-]+', '-', str(s or 'unknown')).strip('/-')[:n] or 'unknown'
|
||||
def sh(args, check=False):
|
||||
p = subprocess.run(['git'] + args, cwd=str(WS), text=True, capture_output=True)
|
||||
if check and p.returncode != 0: raise RuntimeError(p.stderr or p.stdout or 'git failed')
|
||||
return {'code':p.returncode,'stdout':p.stdout.strip(),'stderr':p.stderr.strip(),'cmd':['git']+args}
|
||||
def jload(p: Path, default):
|
||||
try: return json.loads(p.read_text(encoding='utf-8'))
|
||||
except Exception: return default
|
||||
def relpath(path: str) -> str:
|
||||
x = str(path or '').replace('\\','/').strip()
|
||||
if not x or x.startswith('/') or '..' in Path(x).parts or Path(x).parts[0] == '.git': raise ValueError('invalid path')
|
||||
return str(Path(x))
|
||||
def branch_name(name: str | None, prefix='patch/chatgpt') -> str:
|
||||
b = safe(name or f'{prefix}-{uid("br")}', 180)
|
||||
if b in ('main','master') or b.startswith('main/') or b.startswith('master/'): raise ValueError('direct main/master writes are forbidden')
|
||||
return b
|
||||
def _json_has_plain_secret(obj: Any) -> bool:
|
||||
if isinstance(obj, dict):
|
||||
for k, v in obj.items():
|
||||
lk = str(k).lower()
|
||||
if lk == 'secret_ref' or lk.endswith('_secret_ref'):
|
||||
continue
|
||||
if lk in {'api_key', 'access_key', 'secret_access_key', 'secret_key', 'password', 'token', 'bearer_token'}:
|
||||
if isinstance(v, str) and v and not v.startswith('secret://') and v != 'REDACTED':
|
||||
return True
|
||||
if _json_has_plain_secret(v):
|
||||
return True
|
||||
return False
|
||||
if isinstance(obj, list):
|
||||
return any(_json_has_plain_secret(x) for x in obj)
|
||||
if isinstance(obj, str):
|
||||
return bool(obj and not obj.startswith('secret://') and obj != 'REDACTED' and SECRET_VAL_RE.search(obj))
|
||||
return False
|
||||
|
||||
def content_has_secret(text: str) -> bool:
|
||||
text = text or ''
|
||||
try:
|
||||
return _json_has_plain_secret(json.loads(text))
|
||||
except Exception:
|
||||
pass
|
||||
if SECRET_VAL_RE.search(text):
|
||||
return True
|
||||
for m in SECRET_RE.finditer(text):
|
||||
key = str(m.group(1)).lower()
|
||||
value = str(m.group(2))
|
||||
if 'secret_ref' in key or value.startswith('secret://') or value == 'REDACTED':
|
||||
continue
|
||||
return True
|
||||
return False
|
||||
def token_records():
|
||||
if not TOKEN_FILE.exists(): return []
|
||||
out=[]
|
||||
for raw in TOKEN_FILE.read_text(encoding='utf-8').splitlines():
|
||||
line=raw.strip()
|
||||
if not line or line.startswith('#'): continue
|
||||
if line.startswith('{'):
|
||||
try: out.append(json.loads(line))
|
||||
except Exception: pass
|
||||
else:
|
||||
out.append({'account':'legacy-token','role':'owner','scopes':['*'],'token_plain':line})
|
||||
return out
|
||||
def authenticate(token: str):
|
||||
if os.getenv('AIOS_GITOPS_DEV_ALLOW','0') == '1': return {'account':'dev-owner','role':'owner','scopes':['*']}
|
||||
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
|
||||
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):
|
||||
return {k: ('REDACTED' if re.search(r'(?i)(api[_-]?key|secret|password|token)', str(k)) else sanitize(v)) for k,v in obj.items()}
|
||||
if isinstance(obj, list): return [sanitize(x) for x in obj]
|
||||
if isinstance(obj, str): return SECRET_VAL_RE.sub('REDACTED', obj)
|
||||
return obj
|
||||
def ensure_git():
|
||||
if not sh(['config','user.email'])['stdout']: sh(['config','user.email','chatgpt-gitops-bot@cnnetmkt.top'], True)
|
||||
if not sh(['config','user.name'])['stdout']: sh(['config','user.name','chatgpt-gitops-bot'], True)
|
||||
def checkout(branch, base='main', create=False):
|
||||
ensure_git(); b=branch_name(branch)
|
||||
if create or sh(['rev-parse','--verify',b])['code'] != 0:
|
||||
sh(['checkout',base], True); sh(['checkout','-B',b], True)
|
||||
else: sh(['checkout',b], True)
|
||||
return b
|
||||
def write_file(path, content):
|
||||
if content_has_secret(content): raise ValueError('secret_rejected: use secret_ref; plaintext API keys must not enter Git')
|
||||
r=relpath(path); t=WS/r; t.parent.mkdir(parents=True, exist_ok=True); t.write_text(content, encoding='utf-8'); return r
|
||||
def write_json(path, data): return write_file(path, json.dumps(data, ensure_ascii=False, indent=2) + '\n')
|
||||
def sync_branch(branch: str) -> dict:
|
||||
if not branch or branch in ('main','master') or branch.startswith('main/') or branch.startswith('master/'):
|
||||
return {'status':'skipped','reason':'protected_branch','branch':branch}
|
||||
if BARE_REPO.exists():
|
||||
ref=f'refs/heads/{branch}:refs/heads/{branch}'
|
||||
p=subprocess.run(['git','--git-dir',str(BARE_REPO),'fetch',str(WS),ref], text=True, capture_output=True)
|
||||
if p.returncode != 0: raise RuntimeError(p.stderr or p.stdout or f'git sync failed for {branch}')
|
||||
return {'status':'synced','target':'bare','branch':branch,'stdout':p.stdout.strip(),'stderr':p.stderr.strip()}
|
||||
p=subprocess.run(['git','push','origin',f'HEAD:refs/heads/{branch}'], cwd=str(WS), text=True, capture_output=True)
|
||||
if p.returncode != 0: raise RuntimeError(p.stderr or p.stdout or f'git push failed for {branch}')
|
||||
return {'status':'synced','target':'origin','branch':branch,'stdout':p.stdout.strip(),'stderr':p.stderr.strip()}
|
||||
|
||||
def sync_tag(tag: str) -> dict:
|
||||
if not tag: return {'status':'skipped','reason':'empty_tag'}
|
||||
if BARE_REPO.exists():
|
||||
ref=f'refs/tags/{tag}:refs/tags/{tag}'
|
||||
p=subprocess.run(['git','--git-dir',str(BARE_REPO),'fetch',str(WS),ref], text=True, capture_output=True)
|
||||
if p.returncode != 0: raise RuntimeError(p.stderr or p.stdout or f'git tag sync failed for {tag}')
|
||||
return {'status':'synced','target':'bare','tag':tag,'stdout':p.stdout.strip(),'stderr':p.stderr.strip()}
|
||||
p=subprocess.run(['git','push','origin',tag], cwd=str(WS), text=True, capture_output=True)
|
||||
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(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']
|
||||
if not sh(['status','--short'])['stdout']:
|
||||
return {'status':'no_changes','branch':branch,'sync':sync_branch(branch)}
|
||||
out=sh(['commit','-m',msg], True); branch=sh(['rev-parse','--abbrev-ref','HEAD'])['stdout']; sync=sync_branch(branch); return {'status':'committed','branch':branch,'rev':sh(['rev-parse','--short','HEAD'], True)['stdout'],'git':out,'sync':sync}
|
||||
def audit(actor, action, target, payload):
|
||||
p=ROOT/'artifacts'/'chatgpt-gitops-audit.jsonl'; p.parent.mkdir(parents=True, exist_ok=True)
|
||||
with p.open('a', encoding='utf-8') as f: f.write(json.dumps({'at':now(),'actor':actor,'action':action,'target':target,'payload':sanitize(payload)}, ensure_ascii=False)+'\n')
|
||||
def model_docs():
|
||||
out=[]
|
||||
for p in sorted((WS/'config'/'model_registry').glob('**/*.json')): out.append(jload(p, {'path':str(p.relative_to(WS))}))
|
||||
return out
|
||||
def patches():
|
||||
out=[]
|
||||
for p in sorted((WS/'patches').glob('**/*.json')): out.append(jload(p, {'path':str(p.relative_to(WS))}))
|
||||
return out
|
||||
|
||||
class H(BaseHTTPRequestHandler):
|
||||
server_version='AIOSChatGPTGitOps/V1R1C0'
|
||||
def sendj(self, data, code=200):
|
||||
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 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',''); 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 [])}
|
||||
return {'ok':False,'account':'anonymous','role':'anonymous','scopes':set()}
|
||||
def need(self, scope):
|
||||
a=self.actor()
|
||||
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_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:
|
||||
if path in ('/health','/api/health'): return self.sendj({'status':'ok','service':'chatgpt-gitops-adapter','workspace':str(WS)})
|
||||
if path in ('/api/openapi.yaml','/api/openapi-chatgpt-actions.yaml','/api/schema'):
|
||||
return self.sendt(OPENAPI.read_text(encoding='utf-8'), 'application/yaml; charset=utf-8') if OPENAPI.exists() else self.sendj({'error':'schema_missing'},404)
|
||||
if path in ('/api','/api/status'): return self.sendj(api_spec())
|
||||
if path in ('/api/base','/base'):
|
||||
data=jload(WS/'baseline'/'current.json', {}); data.setdefault('git', sh(['rev-parse','--short','HEAD'])['stdout']); return self.sendj(data)
|
||||
if path in ('/api/ver','/ver'):
|
||||
return self.sendj({'tags':sh(['tag','--list','V*'])['stdout'].splitlines(),'versions_dir':[p.name for p in (WS/'versions').glob('*')],'head':sh(['rev-parse','--short','HEAD'])['stdout']})
|
||||
if path in ('/api/diff','/diff'):
|
||||
frm=(q.get('from') or ['HEAD~1'])[0]; to=(q.get('to') or ['HEAD'])[0]; return self.sendj({'from':frm,'to':to,'diff':sh(['diff',frm,to])})
|
||||
if path in ('/api/patch','/patch'): return self.sendj({'patches':patches(),'branches':sh(['branch','--format','%(refname:short)'])['stdout'].splitlines()})
|
||||
if path in ('/api/model','/model'): return self.sendj({'base':BASE_MODELS,'custom':model_docs(),'priority':[jload(p,{}) for p in sorted((WS/'config'/'model_priority').glob('*.json'))]})
|
||||
if path in ('/api/run','/run'):
|
||||
svc=subprocess.run(['systemctl','is-active','aios-gitops-model-api'], text=True, capture_output=True)
|
||||
return self.sendj({'service':'aios-gitops-model-api','status':svc.stdout.strip() or svc.stderr.strip(),'workspace_branch':sh(['rev-parse','--abbrev-ref','HEAD'])['stdout']})
|
||||
return self.sendj({'error':'not_found','path':path},404)
|
||||
except Exception as e: return self.sendj({'error':str(e)},500)
|
||||
def do_PUT(self):
|
||||
path=urllib.parse.urlparse(self.path).path.rstrip('/'); data=self.body()
|
||||
if path == '/api/model-priority': return self.model_priority(data)
|
||||
return self.sendj({'error':'not_found','path':path},404)
|
||||
def do_POST(self):
|
||||
path=urllib.parse.urlparse(self.path).path.rstrip('/'); data=self.body()
|
||||
try:
|
||||
if path == '/api/branch': return self.branch(data)
|
||||
if path == '/api/file': return self.file(data)
|
||||
if path == '/api/commit': return self.commit(data)
|
||||
if path == '/api/patch': return self.patch(data)
|
||||
if path == '/api/tag': return self.tag(data)
|
||||
if path == '/api/rollback': return self.rollback(data)
|
||||
if path == '/api/model': return self.model(data)
|
||||
if path == '/api/model-priority': return self.model_priority(data)
|
||||
if path == '/api/nl': return self.nl(data)
|
||||
return self.sendj({'error':'not_found','path':path},404)
|
||||
except Exception as e: return self.sendj({'error':str(e)},500)
|
||||
def branch(self,data):
|
||||
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}'); 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}'); 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'); 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}'); 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
|
||||
ver=data.get('version') or data.get('tag')
|
||||
if not re.match(r'^V\d+R\d+C\d+(-[A-Za-z0-9_.-]+)?$', ver or ''): return self.sendj({'error':'version must match VxRxCx'},400)
|
||||
if a['role'] != 'owner' or not data.get('execute'):
|
||||
b=checkout(data.get('branch') or f'request/tag-{safe(ver)}', 'main', True); req={'version':ver,'requested_by':a['account'],'approval_required':True,'created_at':now()}; r=write_json(f'baseline/tag_requests/{safe(ver)}.json', req); c=commit([r], f'chore(chatgpt): request tag {ver}'); return self.sendj({'status':'approval_required','branch':b,'commit':c})
|
||||
out=sh(['tag','-a',ver,'-m',data.get('message') or f'release {ver}'], True); self.sendj({'status':'tagged','version':ver,'git':out,'sync':sync_tag(ver)})
|
||||
def rollback(self,data):
|
||||
a=self.need('rollback:request');
|
||||
if not a: return
|
||||
target=data.get('target_version') or data.get('version') or data.get('target')
|
||||
if not target: return self.sendj({'error':'target_version required'},400)
|
||||
b=checkout(data.get('branch') or f'request/rollback-{safe(target)}-{uid("rb")}', 'main', True); req={'id':uid('rollback'),'target_version':target,'reason':data.get('reason',''),'requested_by':a['account'],'approval_required':True,'execute':False,'created_at':now()}; r=write_json(f'baseline/rollback_requests/{safe(req["id"])}.json', req); c=commit([r], f'chore(chatgpt): request rollback {target}'); self.sendj({'status':'rollback_request_committed','branch':b,'request':req,'commit':c})
|
||||
def model(self,data):
|
||||
a=self.need('model:write');
|
||||
if not a: return
|
||||
name=data.get('model_name') or data.get('model'); base=(data.get('base_url') or '').rstrip('/'); prov=data.get('provider_name') or 'custom-provider'
|
||||
if not name or not base: return self.sendj({'error':'provider_name/base_url/model_name required'},400)
|
||||
owner=safe(data.get('username') or a['account']); secret_ref=data.get('api_key_secret_ref') or data.get('secret_ref')
|
||||
if data.get('api_key'):
|
||||
d=SECRETS/'llm'/owner; d.mkdir(parents=True, exist_ok=True); sid=uid('llmkey'); p=d/f'{sid}.secret'; p.write_text(str(data['api_key']), encoding='utf-8'); os.chmod(p,0o600); secret_ref=f'secret://llm/{owner}/{sid}'
|
||||
if not secret_ref: return self.sendj({'error':'api_key_secret_ref or api_key required'},400)
|
||||
mid=data.get('model_id') or f'custom.{owner}.{hashlib.sha1((prov+base+name).encode()).hexdigest()[:10]}'; b=checkout(data.get('branch') or f'patch/model-{safe(mid)}','main',True)
|
||||
doc=sanitize({k:v for k,v in data.items() if k!='api_key'}); doc.update({'model_id':mid,'provider_name':prov,'base_url':base,'model_name':name,'api_key_secret_ref':secret_ref,'created_by':a['account'],'created_at':now()})
|
||||
r=write_json(f'config/model_registry/chatgpt/{safe(mid)}.json', doc); c=commit([r], data.get('message') or f'chore(chatgpt): register model {name}'); audit(a['account'],'model',mid,doc); self.sendj({'status':'model_registered','branch':b,'model_id':mid,'secret_ref':secret_ref,'commit':c})
|
||||
def model_priority(self,data):
|
||||
a=self.need('model:priority');
|
||||
if not a: return
|
||||
user=safe(data.get('username') or a['account']); b=checkout(data.get('branch') or f'patch/model-priority-{user}', 'main', True); doc={'username':user,'items':data.get('items') or data.get('priority') or [],'updated_by':a['account'],'updated_at':now()}; r=write_json(f'config/model_priority/{user}.json', doc); c=commit([r], data.get('message') or f'chore(chatgpt): update model priority {user}'); audit(a['account'],'model-priority',user,doc); self.sendj({'status':'updated','branch':b,'priority':doc,'commit':c})
|
||||
def nl(self,data):
|
||||
a=self.need('nl:plan');
|
||||
if not a: return
|
||||
text=data.get('text',''); low=text.lower(); intent='unknown'; plan=[]
|
||||
if 'rollback' in low or '回滚' in text: intent='rollback_request'; plan=['commit rollback request','wait owner/admin approval']
|
||||
elif 'diff' in low or '对比' in text: intent='diff'; plan=['call GET /api/diff']
|
||||
elif 'model' in low or '模型' in text: intent='model_change'; plan=['use POST /api/model or PUT /api/model-priority','commit metadata']
|
||||
elif 'patch' in low or '补丁' in text: intent='patch_create'; plan=['call POST /api/patch']
|
||||
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'],'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()
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
+68
-8
@@ -1,20 +1,20 @@
|
||||
{
|
||||
"active_version": "V1R1C0",
|
||||
"active_tag": "V1R1C0-baseline-20260706",
|
||||
"active_version": "V1R1C4",
|
||||
"active_tag": "V1R1C4",
|
||||
"mode": "gitops-online",
|
||||
"version_policy": "VxRxCx",
|
||||
"update_mode": "git_patch_only",
|
||||
"zip_update_deprecated": true,
|
||||
"entry": "https://cnnetmkt.top",
|
||||
"links": {
|
||||
"git": "/git",
|
||||
"git": "https://g.cnnetmkt.top/git/aios/aios-v23",
|
||||
"baseline": "/base",
|
||||
"versions": "/ver",
|
||||
"diff": "/diff",
|
||||
"patch": "/patch",
|
||||
"model": "/model",
|
||||
"runtime": "/run",
|
||||
"api": "/api",
|
||||
"api": "https://cnnetmkt.top/api",
|
||||
"hook": "/hook"
|
||||
},
|
||||
"features": {
|
||||
@@ -23,16 +23,18 @@
|
||||
"custom_llm": true,
|
||||
"user_model_priority": true,
|
||||
"chatgpt_rw": true,
|
||||
"codex_rw": true
|
||||
"codex_rw": true,
|
||||
"portal_gitops_dashboard": true,
|
||||
"human_shortlinks_login_required": true
|
||||
},
|
||||
"version": "V1R1C0",
|
||||
"version": "V1R1C4",
|
||||
"baseline_tag": "V1R1C0-baseline-20260706",
|
||||
"source": "AIOS_V1R1C0_MULTIUSER_GITOPS_CUSTOM_LLM_PROD_FULL_DEPLOY_20260706.zip",
|
||||
"source_package": "AIOS_V1R1C0_MULTIUSER_GITOPS_CUSTOM_LLM_PROD_FULL_DEPLOY_20260706.zip",
|
||||
"zip_update_mode": "deprecated",
|
||||
"chatgpt_access": true,
|
||||
"codex_access": true,
|
||||
"git_url": "https://cnnetmkt.top/git/aios/aios-v23.git",
|
||||
"git_url": "https://g.cnnetmkt.top/git/aios/aios-v23.git",
|
||||
"short_links": [
|
||||
"/git",
|
||||
"/base",
|
||||
@@ -44,5 +46,63 @@
|
||||
"/api",
|
||||
"/hook"
|
||||
],
|
||||
"updated_at": "2026-07-06T07:16:01.842497+00:00"
|
||||
"updated_at": "2026-07-06T11:16:23Z",
|
||||
"auth": {
|
||||
"primary_domain": "https://auth.cnnetmkt.top",
|
||||
"backup_domain": "https://auth.h.imruyi.top:8443",
|
||||
"primary_user_entry": "https://cnnetmkt.top/",
|
||||
"primary_callback": "https://cnnetmkt.top/casdoor/callback/",
|
||||
"casdoor_application": "admin/aios-portal",
|
||||
"backup_domain_role": "standby-only"
|
||||
},
|
||||
"previous_version": "V1R1C3",
|
||||
"codex_gitops": {
|
||||
"config": "config/codex/gitops-online.json",
|
||||
"token_account": "codex-gitops-bot",
|
||||
"token_ref": "secret_ref://aios-gitops/runtime/codex-gitops-bot",
|
||||
"direct_main_push": false,
|
||||
"all_writes_require_git_commit": true,
|
||||
"api_keys_in_git": false,
|
||||
"online_git_sync": true,
|
||||
"bare_repo": "/data/aios-gitops/runtime/gitea-bin/data/repositories/aios/aios-v23.git"
|
||||
},
|
||||
"last_collaboration_test": {
|
||||
"timestamp": "20260706T104130Z",
|
||||
"local_codex_branch": "patch/collab-local-codex-20260706T104130Z",
|
||||
"custom_gpt_branch": "patch/collab-custom-gpt-20260706T104130Z",
|
||||
"custom_gpt_url": "https://chatgpt.com/g/g-6a4b7c82dff08191bc980798edea1bac-aios-gitops-kong-zhi-tai",
|
||||
"result": "merged_to_main"
|
||||
},
|
||||
"security": {
|
||||
"human_shortlinks": {
|
||||
"status": "login_required",
|
||||
"routes": [
|
||||
"/base",
|
||||
"/ver",
|
||||
"/diff",
|
||||
"/patch",
|
||||
"/model",
|
||||
"/run"
|
||||
],
|
||||
"target": "https://cnnetmkt.top/portal/me/#gitops",
|
||||
"machine_api": "https://cnnetmkt.top/api"
|
||||
},
|
||||
"gitea": {
|
||||
"web": "https://g.cnnetmkt.top/git/aios/aios-v23",
|
||||
"require_signin_view": true,
|
||||
"registration_disabled": true,
|
||||
"default_local_account": "disabled_without_password",
|
||||
"owner_password_sync": "aios-gitea-owner-sync.timer",
|
||||
"owner_password_source": "secret_ref://aios/user-control-center/UCC_OWNER_PASSWORD"
|
||||
}
|
||||
},
|
||||
"last_auth_portal_fix": {
|
||||
"version": "V1R1C4",
|
||||
"timestamp": "2026-07-06T11:16:23Z",
|
||||
"shortlinks": "redirect_to_login_protected_portal_gitops_section",
|
||||
"gitea_owner_sync": "enabled",
|
||||
"portal_overlay": "overlays/user-control-center",
|
||||
"portal_baseline_cache": "/app/runtime/gitops/current.json",
|
||||
"sync_log_secret_hygiene": "runuser_without_sudo_command_logging"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# 20260706 Auth GitOps Portal Hardening
|
||||
|
||||
Version: V1R1C4
|
||||
Previous: V1R1C3
|
||||
Timestamp: 2026-07-06T11:05:30Z
|
||||
|
||||
## User-visible fixes
|
||||
|
||||
- Human shortlinks no longer expose baseline/model/run JSON without portal login.
|
||||
- The cnnetmkt portal has a visible GitOps section for owner/admin users.
|
||||
- Gitea is served from `https://g.cnnetmkt.top/git/` and repository browsing requires login.
|
||||
- Gitea `owner` password is synchronized from the cnnetmkt owner account secret.
|
||||
- The default local Gitea account password is removed and direct login is disabled.
|
||||
|
||||
## Files
|
||||
|
||||
- `deploy/nginx/aios-gitops-locations.conf`
|
||||
- `deploy/gitea-binary/app.ini.template`
|
||||
- `deploy/systemd/aios-gitea-owner-sync.service`
|
||||
- `deploy/systemd/aios-gitea-owner-sync.timer`
|
||||
- `ops/gitops-migration/sync_gitea_owner_password.sh`
|
||||
- `ops/gitops-migration/apply_user_portal_gitops_overlay.sh`
|
||||
- `overlays/user-control-center/control/portal_views.py`
|
||||
- `overlays/user-control-center/templates/portal/me.html`
|
||||
- `baseline/current.json`
|
||||
- `config/codex/gitops-online.json`
|
||||
- `versions/V1R1C4.json`
|
||||
- `docs/release/AIOS_V1R1C4_AUTH_GITOPS_PORTAL.md`
|
||||
|
||||
## Notes
|
||||
|
||||
Docker image rebuild was not required for the live fix. The runtime source and running container were patched, and the exact overlay has been added to GitOps so a later image rebuild can reproduce the change.
|
||||
|
||||
## Secret Log Hygiene
|
||||
|
||||
The Gitea owner password sync script uses `runuser` instead of `sudo -u ... --password ...`, strips CRLF from sourced owner fields, and does not print passwords or tokens. Archived journal files were rotated/vacuumed after the live script was corrected.
|
||||
@@ -0,0 +1,25 @@
|
||||
# 20260706 ChatGPT Actions GitOps Access
|
||||
|
||||
## Summary
|
||||
|
||||
Implemented a controlled ChatGPT Actions adapter for the AIOS online GitOps baseline under https://cnnetmkt.top/api.
|
||||
|
||||
## Access Model
|
||||
|
||||
- Public read APIs: /api/base, /api/ver, /api/diff, /api/patch, /api/model, /api/run.
|
||||
- Bearer-token write APIs: /api/branch, /api/file, /api/commit, /api/patch, /api/tag, /api/rollback, /api/model, /api/model-priority, /api/nl.
|
||||
- chatgpt-gitops-bot is scoped for branch/file/commit/patch/model/rollback-request/NL plan operations.
|
||||
- Direct main writes are rejected by the adapter.
|
||||
- V-level owner/admin actions are represented as approval request commits unless an owner executes them.
|
||||
- Custom LLM API keys are stored outside Git under runtime secrets; committed model records store only secret_ref.
|
||||
|
||||
## Files
|
||||
|
||||
- ctive-runtime/app/aios_chatgpt_actions_api.py
|
||||
- docs/gitops/openapi-chatgpt-actions.yaml
|
||||
- deploy/systemd/aios-gitops-model-api.service
|
||||
- scripts/package_contract_check_v1r1c0.sh
|
||||
|
||||
## Verification
|
||||
|
||||
See /data/aios-gitops/artifacts/CHATGPT_ACCESS_ACCEPTANCE.md after live deployment.
|
||||
@@ -0,0 +1,34 @@
|
||||
# 20260706 cnnetmkt auth primary route fix
|
||||
|
||||
## Problem
|
||||
|
||||
`https://cnnetmkt.top/` could be opened, but login redirected users to `https://auth.h.imruyi.top:8443`. Casdoor then rejected the OAuth request because `https://cnnetmkt.top/casdoor/callback/` was not in the application allow-list.
|
||||
|
||||
`*.h.imruyi.top:8443` is retained as a backup domain only and must not be exposed as the primary public login URL.
|
||||
|
||||
## Runtime Changes
|
||||
|
||||
- Added `auth.cnnetmkt.top` Nginx server block on `hw.imruyi.top` using the wildcard `cnnetmkt.top` certificate.
|
||||
- Proxied `auth.cnnetmkt.top` to the existing backup/home Casdoor upstream `auth.h.imruyi.top:8443` while preserving the public host as `auth.cnnetmkt.top`.
|
||||
- Recreated `user-control-web`, `user-control-worker`, and `user-control-beat` with:
|
||||
- `CASDOOR_PUBLIC_BASE_URL=https://auth.cnnetmkt.top`
|
||||
- `CASDOOR_INTERNAL_BASE_URL=https://auth.cnnetmkt.top`
|
||||
- `CASDOOR_INVITE_SYNC_BASE_URL=https://auth.cnnetmkt.top`
|
||||
- `UCC_PUBLIC_BASE_URL=https://cnnetmkt.top`
|
||||
- Updated Casdoor application `admin/aios-portal` on `aihost` to include `https://cnnetmkt.top/casdoor/callback/` and keep user/portal/backup callbacks.
|
||||
|
||||
## Backups
|
||||
|
||||
- Casdoor DB application table backup: `/data/aios/backups/casdoor-cnnetmkt-primary-20260706T085445Z/application-before.sql`
|
||||
- HW UCC/Nginx backup directory: `/data/aios-gitops/backups/auth-cnnetmkt-primary-*`
|
||||
|
||||
## Verification
|
||||
|
||||
- `https://cnnetmkt.top/` redirects to `/portal/login/` and then to `https://auth.cnnetmkt.top/login/oauth/authorize?...redirect_uri=https%3A%2F%2Fcnnetmkt.top%2Fcasdoor%2Fcallback%2F...`.
|
||||
- `https://auth.cnnetmkt.top/login/oauth/authorize?...` returns HTTP 200 from the Casdoor SPA through the HW Nginx proxy.
|
||||
- `user-control-web` health returned `healthy` after recreation.
|
||||
- Casdoor DB readback contains `https://cnnetmkt.top/casdoor/callback/` in `application.redirect_uris`.
|
||||
|
||||
## Follow-up
|
||||
|
||||
Run `ops/gitops-migration/check_cnnetmkt_auth_primary.sh` after any domain, Nginx, or Casdoor application change.
|
||||
@@ -0,0 +1,14 @@
|
||||
# 20260706 Codex and Custom GPT Collaboration
|
||||
|
||||
## Change
|
||||
|
||||
Validated coordinated online GitOps changes from local Codex and the AIOS GitOps 控制台 custom GPT path. Promoted the merged result to `V1R1C3`.
|
||||
|
||||
## Evidence Branches
|
||||
|
||||
- `patch/collab-local-codex-20260706T104130Z` -> `a62d4ce` (codex-gitops-bot, sync=synced)
|
||||
- `patch/collab-custom-gpt-20260706T104130Z` -> `c393931` (chatgpt-gitops-bot, sync=synced)
|
||||
|
||||
## Result
|
||||
|
||||
`/api/base` should report `V1R1C3` after this release commit is synchronized.
|
||||
@@ -0,0 +1,16 @@
|
||||
# 20260706 Codex Online Git Branch Sync
|
||||
|
||||
## Change
|
||||
|
||||
Promoted the online GitOps configuration to `V1R1C2` and enabled automatic synchronization of committed non-main API/Codex branches to the Gitea bare repository.
|
||||
|
||||
## Reason
|
||||
|
||||
Codex writes must be visible through the online Git service, not only in the local workspace clone.
|
||||
|
||||
## Guardrails
|
||||
|
||||
- `main` and `master` are skipped by API branch sync.
|
||||
- Direct main writes remain forbidden by branch validation.
|
||||
- Tags are synchronized only in the owner direct tag execution path.
|
||||
- Tokens and LLM API keys remain outside Git.
|
||||
@@ -0,0 +1,23 @@
|
||||
# 20260706 Codex Online GitOps Version Management
|
||||
|
||||
## Change
|
||||
|
||||
Configured Codex to use the AIOS online Git baseline for version management and promoted the current controlled access changes to `V1R1C1`.
|
||||
|
||||
## Files
|
||||
|
||||
- `config/codex/gitops-online.json`
|
||||
- `docs/gitops/CODEX_ONLINE_VERSION_MANAGEMENT.md`
|
||||
- `docs/gitops/CHATGPT_CODEX_ACCESS.md`
|
||||
- `docs/release/AIOS_V1R1C1_CODEX_GITOPS_RELEASE.md`
|
||||
- `versions/V1R1C1.json`
|
||||
- `baseline/current.json`
|
||||
- `scripts/codex_gitops_online_check.sh`
|
||||
|
||||
## Merge Policy
|
||||
|
||||
Production `main` includes the ChatGPT/Codex GitOps API, schema, service configuration, contract checks, and secret-ref fix. Acceptance dummy model branches are retained as audit branches and not merged into production.
|
||||
|
||||
## Secret Policy
|
||||
|
||||
`codex-gitops-bot` plaintext token is stored only under `/data/aios-gitops/runtime/secrets/`; Git stores only `secret_ref` and token account metadata.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"profile": "aios-v23-online-gitops",
|
||||
"mode": "gitops-online",
|
||||
"version": "V1R1C4",
|
||||
"previous_version": "V1R1C3",
|
||||
"repository": {
|
||||
"workspace": "/data/aios-gitops/workspace",
|
||||
"remote": "origin",
|
||||
"url": "https://g.cnnetmkt.top/git/aios/aios-v23.git",
|
||||
"web": "https://g.cnnetmkt.top/git/aios/aios-v23",
|
||||
"protected_branches": [
|
||||
"main"
|
||||
],
|
||||
"patch_branch_prefixes": [
|
||||
"patch/",
|
||||
"codex/patch/",
|
||||
"request/"
|
||||
],
|
||||
"bare_repo": "/data/aios-gitops/runtime/gitea-bin/data/repositories/aios/aios-v23.git",
|
||||
"sync_mode": "workspace_commit_to_online_bare_repo",
|
||||
"public_portal": "https://cnnetmkt.top/portal/me/#gitops"
|
||||
},
|
||||
"api": {
|
||||
"base_url": "https://cnnetmkt.top/api",
|
||||
"openapi_schema": "https://cnnetmkt.top/api/openapi-chatgpt-actions.yaml",
|
||||
"local_schema": "/data/aios-gitops/workspace/docs/gitops/openapi-chatgpt-actions.yaml",
|
||||
"token_account": "codex-gitops-bot",
|
||||
"token_ref": "secret_ref://aios-gitops/runtime/codex-gitops-bot"
|
||||
},
|
||||
"policy": {
|
||||
"zip_update_deprecated": true,
|
||||
"update_mode": "git_patch_only",
|
||||
"all_writes_require_git_commit": true,
|
||||
"direct_main_push": false,
|
||||
"delete_tag": false,
|
||||
"v_level_owner_admin_approval_required": true,
|
||||
"api_keys_in_git": false,
|
||||
"api_keys_in_logs": false,
|
||||
"custom_llm_secret_storage": "runtime_secret_ref_only",
|
||||
"online_git_sync": true,
|
||||
"human_shortlinks_login_required": true,
|
||||
"gitea_require_signin_view": true,
|
||||
"gitea_default_local_account_disabled": true,
|
||||
"owner_password_sync_secret_ref_only": true
|
||||
},
|
||||
"workflow": [
|
||||
"create patch branch from main",
|
||||
"commit every file/config/script change",
|
||||
"push or sync branch to online Git service",
|
||||
"review diff and run smoke checks",
|
||||
"merge to main only after owner/admin approval",
|
||||
"tag VxRxCx release and update baseline/current.json"
|
||||
],
|
||||
"created_at": "2026-07-06T10:21:08Z",
|
||||
"base_commit_before_release": "43ddb91",
|
||||
"updated_at": "2026-07-06T11:16:23Z",
|
||||
"last_collaboration_test": {
|
||||
"timestamp": "20260706T104130Z",
|
||||
"result": "local_codex_and_custom_gpt_patches_merged"
|
||||
},
|
||||
"last_auth_portal_fix": {
|
||||
"version": "V1R1C4",
|
||||
"timestamp": "2026-07-06T11:16:23Z",
|
||||
"shortlink_routes": [
|
||||
"/base",
|
||||
"/ver",
|
||||
"/diff",
|
||||
"/patch",
|
||||
"/model",
|
||||
"/run"
|
||||
],
|
||||
"gitea_sync_service": "aios-gitea-owner-sync.service",
|
||||
"gitea_sync_timer": "aios-gitea-owner-sync.timer",
|
||||
"portal_overlay": "overlays/user-control-center",
|
||||
"portal_baseline_cache": "/app/runtime/gitops/current.json",
|
||||
"sync_log_secret_hygiene": "runuser_without_sudo_command_logging"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
AIOS_PUBLIC_DOMAIN=https://cnnetmkt.top
|
||||
AIOS_USER_DOMAIN=https://user.cnnetmkt.top
|
||||
AIOS_AUTH_DOMAIN=https://auth.cnnetmkt.top
|
||||
AIOS_AUTH_BACKUP_DOMAIN=https://auth.h.imruyi.top:8443
|
||||
AIOS_CASDOOR_PRIMARY_CALLBACK=https://cnnetmkt.top/casdoor/callback/
|
||||
AIOS_WEBOPS_DOMAIN=https://ops.cnnetmkt.top
|
||||
AIOS_API_DOMAIN=https://api.cnnetmkt.top
|
||||
AIOS_WECHAT_CALLBACK_DOMAIN=https://wechat.cnnetmkt.top
|
||||
|
||||
@@ -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
|
||||
);
|
||||
@@ -6,8 +6,8 @@ WORK_PATH = /data/aios-gitops/runtime/gitea-bin
|
||||
PROTOCOL = http
|
||||
HTTP_ADDR = 127.0.0.1
|
||||
HTTP_PORT = 3000
|
||||
DOMAIN = cnnetmkt.top
|
||||
ROOT_URL = https://cnnetmkt.top/git/
|
||||
DOMAIN = g.cnnetmkt.top
|
||||
ROOT_URL = https://g.cnnetmkt.top/git/
|
||||
SERVE_FROM_SUB_PATH = true
|
||||
DISABLE_SSH = true
|
||||
OFFLINE_MODE = false
|
||||
@@ -27,7 +27,8 @@ INTERNAL_TOKEN = __INTERNAL_TOKEN__
|
||||
|
||||
[service]
|
||||
DISABLE_REGISTRATION = true
|
||||
REQUIRE_SIGNIN_VIEW = false
|
||||
REQUIRE_SIGNIN_VIEW = true
|
||||
SHOW_REGISTRATION_BUTTON = false
|
||||
|
||||
[webhook]
|
||||
ALLOWED_HOST_LIST = *
|
||||
|
||||
@@ -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"
|
||||
@@ -24,12 +24,12 @@
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location = /base { proxy_pass http://127.0.0.1:18890/base; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; }
|
||||
location = /ver { proxy_pass http://127.0.0.1:18890/ver; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; }
|
||||
location = /diff { proxy_pass http://127.0.0.1:18890/diff; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; }
|
||||
location = /patch { proxy_pass http://127.0.0.1:18890/patch; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; }
|
||||
location = /model { proxy_pass http://127.0.0.1:18890/model; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; }
|
||||
location = /run { proxy_pass http://127.0.0.1:18890/run; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; }
|
||||
location = /base { return 302 /portal/me/#gitops; }
|
||||
location = /ver { return 302 /portal/me/#gitops; }
|
||||
location = /diff { return 302 /portal/me/#gitops; }
|
||||
location = /patch { return 302 /portal/me/#gitops; }
|
||||
location = /model { return 302 /portal/me/#gitops; }
|
||||
location = /run { return 302 /portal/me/#gitops; }
|
||||
location = /hook { proxy_pass http://127.0.0.1:18890/hook; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; }
|
||||
location /hook/ {
|
||||
proxy_pass http://127.0.0.1:18890/hook/;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# AIOS primary public Casdoor entry for cnnetmkt.top.
|
||||
# The upstream remains the existing backup/home Casdoor service; public clients should see auth.cnnetmkt.top only.
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
server_name auth.cnnetmkt.top;
|
||||
|
||||
access_log /var/log/nginx/aios-auth-cnnetmkt.access.log;
|
||||
error_log /var/log/nginx/aios-auth-cnnetmkt.error.log;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/cnnetmkt-wildcard/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/cnnetmkt-wildcard/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass https://auth.h.imruyi.top:8443;
|
||||
proxy_http_version 1.1;
|
||||
proxy_ssl_server_name on;
|
||||
proxy_ssl_name auth.h.imruyi.top;
|
||||
proxy_ssl_verify off;
|
||||
proxy_set_header Host auth.h.imruyi.top;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port 443;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_redirect https://auth.h.imruyi.top:8443/ https://auth.cnnetmkt.top/;
|
||||
proxy_redirect https://auth.h.imruyi.top/ https://auth.cnnetmkt.top/;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name auth.cnnetmkt.top;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
# cnnetmkt wildcard certificate
|
||||
|
||||
generated_at=2026-07-06T16:24:28+08:00
|
||||
issuer=Let's Encrypt via lego DNS-01 Huawei Cloud
|
||||
domains=cnnetmkt.top,*.cnnetmkt.top
|
||||
live_dir=/etc/letsencrypt/live/cnnetmkt-wildcard
|
||||
nginx_sites=/etc/nginx/sites-available/aios-user-portal.conf,/etc/nginx/sites-available/g-cnnetmkt-gitea.conf
|
||||
renew_service=cnnetmkt-wildcard-renew.service
|
||||
renew_timer=cnnetmkt-wildcard-renew.timer
|
||||
secret_env=/etc/letsencrypt/huaweicloud-cnnetmkt.env
|
||||
backup_dir=/data/aios-gitops/backups/nginx-wildcard-20260706-162202
|
||||
status=issued
|
||||
@@ -9,8 +9,8 @@ server {
|
||||
access_log /var/log/nginx/aios-g-cnnetmkt.access.log;
|
||||
error_log /var/log/nginx/aios-g-cnnetmkt.error.log;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/user.cnnetmkt.top/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/user.cnnetmkt.top/privkey.pem;
|
||||
ssl_certificate /etc/letsencrypt/live/cnnetmkt-wildcard/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/cnnetmkt-wildcard/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
|
||||
|
||||
@@ -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,7 @@
|
||||
[Unit]
|
||||
Description=Sync AIOS Gitea owner password from UCC owner account
|
||||
After=aios-gitea.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/sbin/aios-sync-gitea-owner-password.sh
|
||||
@@ -0,0 +1,10 @@
|
||||
[Unit]
|
||||
Description=Periodic AIOS Gitea owner password sync
|
||||
|
||||
[Timer]
|
||||
OnBootSec=2min
|
||||
OnUnitActiveSec=15min
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -10,9 +10,10 @@ Environment=AIOS_GITOPS_ROOT=/data/aios-gitops
|
||||
Environment=AIOS_GITOPS_WORKSPACE=/data/aios-gitops/workspace
|
||||
Environment=AIOS_GITOPS_DB=/data/aios-gitops/runtime/gitops_model.sqlite3
|
||||
Environment=AIOS_GITOPS_TOKEN_FILE=/data/aios-gitops/runtime/secrets/gitops.tokens
|
||||
Environment=AIOS_GITOPS_BARE_REPO=/data/aios-gitops/runtime/gitea-bin/data/repositories/aios/aios-v23.git
|
||||
Environment=AIOS_GITOPS_HOST=127.0.0.1
|
||||
Environment=AIOS_GITOPS_PORT=18890
|
||||
ExecStart=/usr/bin/python3 /data/aios-gitops/workspace/active-runtime/app/aios_gitops_model_api.py
|
||||
ExecStart=/usr/bin/python3 /data/aios-gitops/workspace/active-runtime/app/aios_chatgpt_actions_api.py
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
|
||||
|
||||
@@ -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。
|
||||
@@ -12,3 +12,18 @@ Forbidden:
|
||||
- API keys in Git
|
||||
- API keys in plaintext logs
|
||||
- bypassing owner/admin approval for V-level changes
|
||||
|
||||
## Codex Online Version Management
|
||||
|
||||
Codex uses `/data/aios-gitops/workspace` and `https://cnnetmkt.top/git/aios/aios-v23.git` as the online Git version baseline. Runtime write access uses `codex-gitops-bot`; its token is stored outside Git and referenced as `secret_ref://aios-gitops/runtime/codex-gitops-bot`.
|
||||
|
||||
Current production release after this merge: `V1R1C3`. Acceptance-only branches are retained for audit and are not automatically merged into `main`.
|
||||
|
||||
## Online Git Sync
|
||||
|
||||
Starting in `V1R1C3`, committed API/Codex patch branches are synchronized from the workspace clone into the online Gitea bare repository. Protected production branches remain excluded from API sync.
|
||||
|
||||
## Codex and Custom GPT Collaboration Test
|
||||
|
||||
`V1R1C3` confirms local Codex and the custom GPT Actions path can independently create patch branches, commit files, sync to online Git, and then merge into the latest baseline version.
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# Codex Online GitOps Version Management
|
||||
|
||||
Codex is configured to treat the online Git baseline as the version source of truth.
|
||||
|
||||
- Workspace: `/data/aios-gitops/workspace`
|
||||
- Git web: `https://cnnetmkt.top/git/aios/aios-v23`
|
||||
- API base: `https://cnnetmkt.top/api`
|
||||
- OpenAPI schema: `https://cnnetmkt.top/api/openapi-chatgpt-actions.yaml`
|
||||
- Runtime token account: `codex-gitops-bot`
|
||||
- Runtime token ref: `secret_ref://aios-gitops/runtime/codex-gitops-bot`
|
||||
|
||||
## Rules
|
||||
|
||||
1. Codex updates must use Git patch branches; ZIP upload is deprecated.
|
||||
2. Every Codex write must become a Git commit.
|
||||
3. Codex must not push directly to `main`; merge to `main` requires owner/admin approval.
|
||||
4. V-level release/tag/rollback requests must not bypass owner/admin approval.
|
||||
5. User custom LLM API keys must stay in runtime secrets and enter Git only as `secret_ref`.
|
||||
6. Test or acceptance branches can remain as audit evidence, but dummy model/provider configs must not be merged into production `main`.
|
||||
|
||||
## Standard Flow
|
||||
|
||||
```text
|
||||
main -> patch/<name> -> commit -> diff review -> smoke test -> owner/admin approval -> merge main -> VxRxCx tag -> baseline/current.json
|
||||
```
|
||||
|
||||
## Current Merge
|
||||
|
||||
`V1R1C1` merges the current GitOps/ChatGPT/Codex access changes into the latest online baseline while excluding acceptance dummy model configuration.
|
||||
|
||||
## Online Git Sync
|
||||
|
||||
Starting in `V1R1C3`, committed API/Codex patch branches are synchronized from the workspace clone into the online Gitea bare repository. Protected production branches remain excluded from API sync.
|
||||
|
||||
## Codex and Custom GPT Collaboration Test
|
||||
|
||||
`V1R1C3` confirms local Codex and the custom GPT Actions path can independently create patch branches, commit files, sync to online Git, and then merge into the latest baseline version.
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# Custom GPT Collaboration Patch
|
||||
|
||||
Timestamp: 20260706T104130Z
|
||||
|
||||
Actor: chatgpt-gitops-bot
|
||||
|
||||
Created through chatgpt-gitops-bot to simulate the AIOS GitOps 控制台 GPT Actions path: https://chatgpt.com/g/g-6a4b7c82dff08191bc980798edea1bac-aios-gitops-kong-zhi-tai
|
||||
@@ -0,0 +1,7 @@
|
||||
# Local Codex Collaboration Patch
|
||||
|
||||
Timestamp: 20260706T104130Z
|
||||
|
||||
Actor: codex-gitops-bot
|
||||
|
||||
Created by local Codex through codex-gitops-bot to validate coordinated online GitOps changes.
|
||||
@@ -0,0 +1,240 @@
|
||||
openapi: 3.1.0
|
||||
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.
|
||||
servers:
|
||||
- url: https://cnnetmkt.top
|
||||
security: []
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: opaque-token
|
||||
schemas:
|
||||
BranchRequest:
|
||||
type: object
|
||||
required: [branch]
|
||||
properties:
|
||||
branch: {type: string, example: patch/chatgpt-actions-demo}
|
||||
base: {type: string, default: main}
|
||||
message: {type: string}
|
||||
FileCommitRequest:
|
||||
type: object
|
||||
required: [branch, path, content]
|
||||
properties:
|
||||
branch: {type: string}
|
||||
base: {type: string, default: main}
|
||||
create_branch: {type: boolean, default: false}
|
||||
path: {type: string, example: docs/gitops/chatgpt-change.md}
|
||||
content: {type: string}
|
||||
message: {type: string}
|
||||
CommitRequest:
|
||||
type: object
|
||||
properties:
|
||||
branch: {type: string}
|
||||
paths:
|
||||
type: array
|
||||
items: {type: string}
|
||||
message: {type: string}
|
||||
PatchRequest:
|
||||
type: object
|
||||
required: [patch_name]
|
||||
properties:
|
||||
patch_name: {type: string}
|
||||
branch: {type: string}
|
||||
base: {type: string, default: main}
|
||||
risk: {type: string, enum: [low, medium, high]}
|
||||
description: {type: string}
|
||||
message: {type: string}
|
||||
TagRequest:
|
||||
type: object
|
||||
required: [version]
|
||||
properties:
|
||||
version: {type: string, example: V1R1C1}
|
||||
message: {type: string}
|
||||
execute:
|
||||
type: boolean
|
||||
description: Owner-approved calls only. Otherwise an approval request commit is created.
|
||||
RollbackRequest:
|
||||
type: object
|
||||
required: [target_version]
|
||||
properties:
|
||||
target_version: {type: string}
|
||||
reason: {type: string}
|
||||
branch: {type: string}
|
||||
ModelRequest:
|
||||
type: object
|
||||
required: [provider_name, base_url, model_name]
|
||||
properties:
|
||||
username: {type: string}
|
||||
provider_name: {type: string}
|
||||
base_url: {type: string}
|
||||
model_name: {type: string}
|
||||
display_name: {type: string}
|
||||
api_key_secret_ref:
|
||||
type: string
|
||||
description: Preferred. Git stores this secret_ref only.
|
||||
api_key:
|
||||
type: string
|
||||
writeOnly: true
|
||||
description: Accepted only for runtime secret storage; never committed to Git or logs.
|
||||
branch: {type: string}
|
||||
message: {type: string}
|
||||
ModelPriorityRequest:
|
||||
type: object
|
||||
required: [items]
|
||||
properties:
|
||||
username: {type: string}
|
||||
branch: {type: string}
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
model_id: {type: string}
|
||||
priority: {type: integer}
|
||||
enabled: {type: boolean}
|
||||
fallback_policy: {type: string}
|
||||
NLRequest:
|
||||
type: object
|
||||
required: [text]
|
||||
properties:
|
||||
text: {type: string}
|
||||
branch: {type: string}
|
||||
paths:
|
||||
/api/base:
|
||||
get:
|
||||
operationId: getBaseline
|
||||
summary: Read current AIOS baseline
|
||||
responses: {'200': {description: OK}}
|
||||
/api/ver:
|
||||
get:
|
||||
operationId: listVersions
|
||||
summary: List GitOps versions
|
||||
responses: {'200': {description: OK}}
|
||||
/api/diff:
|
||||
get:
|
||||
operationId: getDiff
|
||||
summary: Read Git diff between refs
|
||||
parameters:
|
||||
- in: query
|
||||
name: from
|
||||
schema: {type: string, default: HEAD~1}
|
||||
- in: query
|
||||
name: to
|
||||
schema: {type: string, default: HEAD}
|
||||
responses: {'200': {description: OK}}
|
||||
/api/patch:
|
||||
get:
|
||||
operationId: listPatches
|
||||
summary: List patch records
|
||||
responses: {'200': {description: OK}}
|
||||
post:
|
||||
operationId: createPatch
|
||||
summary: Create a patch branch and commit metadata
|
||||
security: [{bearerAuth: []}]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/PatchRequest'}
|
||||
responses: {'200': {description: Patch branch committed}, '401': {description: Unauthorized}, '403': {description: Forbidden}}
|
||||
/api/model:
|
||||
get:
|
||||
operationId: readModelRegistry
|
||||
summary: Read visible model registry and priority
|
||||
responses: {'200': {description: OK}}
|
||||
post:
|
||||
operationId: registerModel
|
||||
summary: Register a custom model; API keys are stored as runtime secrets and Git receives secret_ref only
|
||||
security: [{bearerAuth: []}]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/ModelRequest'}
|
||||
responses: {'200': {description: Model registered and committed}, '401': {description: Unauthorized}, '403': {description: Forbidden}}
|
||||
/api/run:
|
||||
get:
|
||||
operationId: getRuntimeStatus
|
||||
summary: Read runtime status
|
||||
responses: {'200': {description: OK}}
|
||||
/api/branch:
|
||||
post:
|
||||
operationId: createBranch
|
||||
summary: Create a non-main branch and commit branch metadata
|
||||
security: [{bearerAuth: []}]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/BranchRequest'}
|
||||
responses: {'200': {description: Branch created}, '401': {description: Unauthorized}, '403': {description: Forbidden}}
|
||||
/api/file:
|
||||
post:
|
||||
operationId: commitFile
|
||||
summary: Write a file and create a Git commit on a non-main branch
|
||||
security: [{bearerAuth: []}]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/FileCommitRequest'}
|
||||
responses: {'200': {description: File committed}, '400': {description: Secret rejected or invalid path}, '401': {description: Unauthorized}}
|
||||
/api/commit:
|
||||
post:
|
||||
operationId: commitChanges
|
||||
summary: Commit existing workspace changes on a non-main branch
|
||||
security: [{bearerAuth: []}]
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/CommitRequest'}
|
||||
responses: {'200': {description: Commit result}, '401': {description: Unauthorized}}
|
||||
/api/tag:
|
||||
post:
|
||||
operationId: requestOrCreateTag
|
||||
summary: Request tag creation; owner execution is required for direct tag
|
||||
security: [{bearerAuth: []}]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/TagRequest'}
|
||||
responses: {'200': {description: Tag request committed or tag created}, '403': {description: Forbidden}}
|
||||
/api/rollback:
|
||||
post:
|
||||
operationId: requestRollback
|
||||
summary: Commit a rollback request requiring owner/admin approval
|
||||
security: [{bearerAuth: []}]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/RollbackRequest'}
|
||||
responses: {'200': {description: Rollback request committed}, '401': {description: Unauthorized}}
|
||||
/api/model-priority:
|
||||
put:
|
||||
operationId: setModelPriority
|
||||
summary: Set model priority and commit policy file
|
||||
security: [{bearerAuth: []}]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/ModelPriorityRequest'}
|
||||
responses: {'200': {description: Priority updated and committed}, '401': {description: Unauthorized}}
|
||||
/api/nl:
|
||||
post:
|
||||
operationId: planNaturalLanguageGitOpsAction
|
||||
summary: Convert natural language to a safe committed plan without executing V-level changes
|
||||
security: [{bearerAuth: []}]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/NLRequest'}
|
||||
responses: {'200': {description: Plan committed}, '401': {description: Unauthorized}}
|
||||
@@ -0,0 +1,17 @@
|
||||
# CNNETMKT apex DNS status
|
||||
|
||||
generated_at=2026-07-06T16:39:07+08:00
|
||||
provider=Huawei Cloud DNS
|
||||
zone=cnnetmkt.top.
|
||||
record=cnnetmkt.top. A 1.95.164.183
|
||||
status=created_and_resolved
|
||||
credential_storage=/etc/letsencrypt/huaweicloud-cnnetmkt.env
|
||||
secret_policy=not_tracked_not_logged
|
||||
|
||||
## verification
|
||||
|
||||
- authoritative NS returns 1.95.164.183
|
||||
- system resolver returns 1.95.164.183
|
||||
- https://cnnetmkt.top/ returns 302 /portal/login/
|
||||
- https://cnnetmkt.top/git/ returns 200 AIOS GitOps
|
||||
- wildcard certificate SAN covers cnnetmkt.top and *.cnnetmkt.top
|
||||
@@ -0,0 +1,53 @@
|
||||
# CNNETMKT Auth Primary Status
|
||||
|
||||
更新时间:2026-07-06T09:00:06Z
|
||||
|
||||
## 结论
|
||||
|
||||
`https://cnnetmkt.top/` 是用户主入口。登录授权主域为 `https://auth.cnnetmkt.top`。
|
||||
|
||||
`https://auth.h.imruyi.top:8443` 只作为备份/上游兼容入口保留,不应出现在普通用户的主登录跳转链路里。
|
||||
|
||||
## 当前链路
|
||||
|
||||
1. 用户访问 `https://cnnetmkt.top/`。
|
||||
2. UCC 返回 `/portal/login/`。
|
||||
3. UCC 生成 Casdoor 授权地址:
|
||||
`https://auth.cnnetmkt.top/login/oauth/authorize?...redirect_uri=https%3A%2F%2Fcnnetmkt.top%2Fcasdoor%2Fcallback%2F...`
|
||||
4. 华为云 Nginx `auth.cnnetmkt.top` server block 反代到现有 Casdoor 上游。
|
||||
5. Casdoor 允许 `https://cnnetmkt.top/casdoor/callback/` 作为 `admin/aios-portal` 的 OAuth callback。
|
||||
|
||||
## 已调整配置
|
||||
|
||||
### 华为云 UCC 运行时
|
||||
|
||||
- `CASDOOR_PUBLIC_BASE_URL=https://auth.cnnetmkt.top`
|
||||
- `CASDOOR_INTERNAL_BASE_URL=https://auth.cnnetmkt.top`
|
||||
- `CASDOOR_INVITE_SYNC_BASE_URL=https://auth.cnnetmkt.top`
|
||||
- `UCC_PUBLIC_BASE_URL=https://cnnetmkt.top`
|
||||
- `ALLOWED_HOSTS` 增加 `auth.cnnetmkt.top`
|
||||
- `CSRF_TRUSTED_ORIGINS` 增加 `https://cnnetmkt.top` 和 `https://auth.cnnetmkt.top`
|
||||
|
||||
### Casdoor 应用
|
||||
|
||||
应用:`admin/aios-portal`
|
||||
|
||||
新增/保留 callback:
|
||||
|
||||
- `https://cnnetmkt.top/casdoor/callback/`
|
||||
- `https://cnnetmkt.top/api/auth/callback/casdoor`
|
||||
- `https://user.cnnetmkt.top/casdoor/callback/`
|
||||
- `https://portal.cnnetmkt.top/casdoor/callback/`
|
||||
- `https://portal.h.imruyi.top:8443/casdoor/callback/`(备份)
|
||||
|
||||
## 验证命令
|
||||
|
||||
```bash
|
||||
ops/gitops-migration/check_cnnetmkt_auth_primary.sh
|
||||
```
|
||||
|
||||
关键验收点:
|
||||
|
||||
- 主链路不再出现 `auth.h.imruyi.top:8443`。
|
||||
- `user-control-web` 为 `healthy`。
|
||||
- Casdoor DB `application.redirect_uris` 包含 `https://cnnetmkt.top/casdoor/callback/`。
|
||||
@@ -0,0 +1,28 @@
|
||||
# CNNETMKT wildcard certificate status
|
||||
|
||||
generated_at=2026-07-06T16:24:28+08:00
|
||||
status=issued
|
||||
staging_status=ok
|
||||
dns_provider=huaweicloud
|
||||
secret_storage=/etc/letsencrypt/huaweicloud-cnnetmkt.env
|
||||
certificate_live_dir=/etc/letsencrypt/live/cnnetmkt-wildcard
|
||||
nginx_status=loaded
|
||||
backup_dir=/data/aios-gitops/backups/nginx-wildcard-20260706-162202
|
||||
renew_timer=cnnetmkt-wildcard-renew.timer
|
||||
|
||||
## certificate
|
||||
|
||||
subject=CN = *.cnnetmkt.top
|
||||
issuer=C = US, O = Let's Encrypt, CN = YE2
|
||||
notBefore=Jul 6 07:25:55 2026 GMT
|
||||
notAfter=Oct 4 07:25:54 2026 GMT
|
||||
X509v3 Subject Alternative Name:
|
||||
DNS:*.cnnetmkt.top, DNS:cnnetmkt.top
|
||||
|
||||
## routes
|
||||
|
||||
- https://cnnetmkt.top/
|
||||
- https://cnnetmkt.top/git/
|
||||
- https://g.cnnetmkt.top/
|
||||
- https://g.cnnetmkt.top/git/
|
||||
- https://user.cnnetmkt.top/
|
||||
@@ -0,0 +1,27 @@
|
||||
# AIOS V1R1C1 Codex GitOps Online Release
|
||||
|
||||
Date: 2026-07-06T10:21:08Z
|
||||
|
||||
## Purpose
|
||||
|
||||
Merge the current controlled ChatGPT/Codex GitOps access changes into the latest online baseline and make Git the operating version manager for Codex changes.
|
||||
|
||||
## Included
|
||||
|
||||
- ChatGPT Actions API adapter and OpenAPI schema.
|
||||
- Secret-safe model registration using `secret_ref`.
|
||||
- `codex-gitops-bot` runtime token account with scoped API permissions.
|
||||
- Codex online GitOps configuration under `config/codex/gitops-online.json`.
|
||||
- Baseline metadata updated from `V1R1C0` to `V1R1C1`.
|
||||
|
||||
## Excluded
|
||||
|
||||
- Acceptance dummy model registry entries from `patch/chatgpt-model-acceptance-*`.
|
||||
- Plaintext API keys or bearer tokens.
|
||||
|
||||
## Verification
|
||||
|
||||
- `curl https://cnnetmkt.top/api/base` returns 200.
|
||||
- `curl https://cnnetmkt.top/api/openapi-chatgpt-actions.yaml` returns 200.
|
||||
- `bash scripts/package_contract_check_v1r1c0.sh` passes.
|
||||
- `bash scripts/codex_gitops_online_check.sh` passes after tag publication.
|
||||
@@ -0,0 +1,18 @@
|
||||
# AIOS V1R1C2 Codex GitOps Online Sync Release
|
||||
|
||||
Date: 2026-07-06T10:26:26Z
|
||||
|
||||
## Purpose
|
||||
|
||||
Make Codex/API writes visible in the online Git service by syncing non-main patch branches from `/data/aios-gitops/workspace` into the Gitea bare repository after each committed write.
|
||||
|
||||
## Included
|
||||
|
||||
- `active-runtime/app/aios_chatgpt_actions_api.py` now syncs non-main branch commits to the online Git bare repository.
|
||||
- `POST /api/tag` direct owner flow syncs tags when executed.
|
||||
- `config/codex/gitops-online.json` records `online_git_sync=true`.
|
||||
- Live and template systemd service configuration declares `AIOS_GITOPS_BARE_REPO`.
|
||||
|
||||
## Policy
|
||||
|
||||
`main` remains protected. API/Codex writes still commit to patch/request branches and owner/admin approval remains required for V-level production actions.
|
||||
@@ -0,0 +1,28 @@
|
||||
# AIOS V1R1C3 Codex + Custom GPT Collaboration Release
|
||||
|
||||
Date: 2026-07-06T10:42:33Z
|
||||
|
||||
## Purpose
|
||||
|
||||
Validate that local Codex and the AIOS GitOps 控制台 custom GPT can both modify the online GitOps baseline through controlled API writes, then merge both changes into the latest version.
|
||||
|
||||
## Custom GPT
|
||||
|
||||
https://chatgpt.com/g/g-6a4b7c82dff08191bc980798edea1bac-aios-gitops-kong-zhi-tai
|
||||
|
||||
## Merged Inputs
|
||||
|
||||
| Actor | Account | Branch | Commit | Online Sync |
|
||||
|---|---|---|---|---|
|
||||
| local_codex | codex-gitops-bot | `patch/collab-local-codex-20260706T104130Z` | `a62d4ce` | synced |
|
||||
| custom_gpt | chatgpt-gitops-bot | `patch/collab-custom-gpt-20260706T104130Z` | `c393931` | synced |
|
||||
|
||||
## Result
|
||||
|
||||
Both branches were merged into `main`, baseline metadata was refreshed to `V1R1C3`, and the release tag `V1R1C3` was published to the online Git repository.
|
||||
|
||||
## Guardrails
|
||||
|
||||
- GPT and Codex writes entered Git as patch branch commits.
|
||||
- Protected `main` was updated only by the release merge step.
|
||||
- Bot tokens and custom LLM API keys remain outside Git.
|
||||
@@ -0,0 +1,36 @@
|
||||
# AIOS V1R1C4 Auth GitOps Portal Release
|
||||
|
||||
Date: 2026-07-06T11:05:30Z
|
||||
|
||||
## Purpose
|
||||
|
||||
Close the public-access gap around human GitOps shortlinks, move repository browsing to the protected Gitea endpoint, and make the new GitOps portal function visible from the main cnnetmkt owner/admin workspace.
|
||||
|
||||
## Changes
|
||||
|
||||
- `/base`, `/ver`, `/diff`, `/patch`, `/model`, and `/run` now redirect to `https://cnnetmkt.top/portal/me/#gitops` instead of exposing raw JSON to unauthenticated users.
|
||||
- `/api/*` remains the controlled machine interface for ChatGPT Actions and Codex.
|
||||
- Gitea now uses `https://g.cnnetmkt.top/git/`, requires sign-in for repository browsing, and hides registration.
|
||||
- `aios-gitea-owner-sync.service` and timer synchronize the Gitea `owner` account password from the user-control owner secret without writing the password to Git or logs.
|
||||
- The legacy local `aios` Gitea account has login prohibited and its password cleared; the row remains only to preserve repository ownership.
|
||||
- The user portal now includes a GitOps dashboard section for admin/owner roles.
|
||||
- The patched user-control-center source is tracked under `overlays/user-control-center/` with a replay script.
|
||||
- The portal GitOps section reads `/app/runtime/gitops/current.json` before HTTP fallback, so the current version remains visible inside Docker.
|
||||
|
||||
## Verification Targets
|
||||
|
||||
- `https://cnnetmkt.top/model` returns `302` to `/portal/me/#gitops`.
|
||||
- `https://cnnetmkt.top/api/model` returns the public machine-readable model metadata.
|
||||
- `https://g.cnnetmkt.top/git/aios/aios-v23` returns a login redirect when unauthenticated.
|
||||
- Owner login can open the Gitea repository; no secret is printed.
|
||||
- Portal login state renders the GitOps section and links.
|
||||
|
||||
## Guardrails
|
||||
|
||||
- No ZIP update path is reintroduced.
|
||||
- All replayable files are tracked in Git.
|
||||
- Passwords, bearer tokens, Huawei keys, and custom LLM API keys remain outside Git.
|
||||
|
||||
## Secret Log Hygiene
|
||||
|
||||
The Gitea owner password sync script uses `runuser` instead of `sudo -u ... --password ...`, strips CRLF from sourced owner fields, and does not print passwords or tokens. Archived journal files were rotated/vacuumed after the live script was corrected.
|
||||
@@ -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
|
||||
```
|
||||
@@ -14,6 +14,7 @@ log() {
|
||||
cd "$WORKSPACE"
|
||||
|
||||
TOKEN_FILE="$ROOT/runtime/secrets/gitops.tokens"
|
||||
BARE_REPO="$ROOT/runtime/gitea-bin/data/repositories/aios/aios-v23.git"
|
||||
if [ ! -s "$TOKEN_FILE" ]; then
|
||||
(openssl rand -hex 32 || python3 -c 'import secrets; print(secrets.token_hex(32))') > "$TOKEN_FILE"
|
||||
chmod 600 "$TOKEN_FILE"
|
||||
@@ -32,9 +33,10 @@ Environment=AIOS_GITOPS_ROOT=$ROOT
|
||||
Environment=AIOS_GITOPS_WORKSPACE=$WORKSPACE
|
||||
Environment=AIOS_GITOPS_DB=$ROOT/runtime/gitops_model.sqlite3
|
||||
Environment=AIOS_GITOPS_TOKEN_FILE=$TOKEN_FILE
|
||||
Environment=AIOS_GITOPS_BARE_REPO=$BARE_REPO
|
||||
Environment=AIOS_GITOPS_HOST=127.0.0.1
|
||||
Environment=AIOS_GITOPS_PORT=18890
|
||||
ExecStart=/usr/bin/python3 $WORKSPACE/active-runtime/app/aios_gitops_model_api.py
|
||||
ExecStart=/usr/bin/python3 $WORKSPACE/active-runtime/app/aios_chatgpt_actions_api.py
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
WORKSPACE="${AIOS_GITOPS_WORKSPACE:-/data/aios-gitops/workspace}"
|
||||
RUNTIME_ROOT="${AIOS_USER_CONTROL_ROOT:-/data/aios/aios_r13_v18_runtime_portable_silent_20260704/components/user-control-center}"
|
||||
CONTAINER="${AIOS_USER_CONTROL_CONTAINER:-user-control-web}"
|
||||
|
||||
install -D -m 0644 "$WORKSPACE/overlays/user-control-center/control/portal_views.py" "$RUNTIME_ROOT/control/portal_views.py"
|
||||
install -D -m 0644 "$WORKSPACE/overlays/user-control-center/templates/portal/me.html" "$RUNTIME_ROOT/templates/portal/me.html"
|
||||
install -D -m 0644 "$WORKSPACE/baseline/current.json" "$RUNTIME_ROOT/runtime/gitops/current.json"
|
||||
|
||||
if command -v docker >/dev/null 2>&1 && docker ps --format '{{.Names}}' | grep -qx "$CONTAINER"; then
|
||||
docker cp "$RUNTIME_ROOT/control/portal_views.py" "$CONTAINER:/app/control/portal_views.py"
|
||||
docker cp "$RUNTIME_ROOT/templates/portal/me.html" "$CONTAINER:/app/templates/portal/me.html"
|
||||
docker exec "$CONTAINER" mkdir -p /app/runtime/gitops
|
||||
docker cp "$RUNTIME_ROOT/runtime/gitops/current.json" "$CONTAINER:/app/runtime/gitops/current.json"
|
||||
docker restart "$CONTAINER" >/dev/null
|
||||
fi
|
||||
|
||||
echo "AIOS_USER_PORTAL_GITOPS_OVERLAY_APPLIED runtime=$RUNTIME_ROOT container=$CONTAINER"
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
PUBLIC_ENTRY="${PUBLIC_ENTRY:-https://cnnetmkt.top/}"
|
||||
AUTH_ENTRY="${AUTH_ENTRY:-https://auth.cnnetmkt.top}"
|
||||
CLIENT_ID="${CASDOOR_CLIENT_ID:-aios8a810963c1dd368a}"
|
||||
CALLBACK="${CASDOOR_CALLBACK:-https://cnnetmkt.top/casdoor/callback/}"
|
||||
AIHOST_HOST="${AIHOST_HOST:-aihost}"
|
||||
HW_HOST="${HW_HOST:-hw.imruyi.top}"
|
||||
|
||||
fail() {
|
||||
printf 'FAIL: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
CALLBACK_ENCODED="$(python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "$CALLBACK")"
|
||||
AUTH_URL="$AUTH_ENTRY/login/oauth/authorize?client_id=$CLIENT_ID&response_type=code&redirect_uri=$CALLBACK_ENCODED&scope=read&state=aios-portal&language=zh"
|
||||
|
||||
printf '== public redirect chain ==\n'
|
||||
chain="$(curl -k -sS -L -I --max-redirs 4 "$PUBLIC_ENTRY")"
|
||||
printf '%s\n' "$chain"
|
||||
printf '%s\n' "$chain" | grep -F "Location: $AUTH_ENTRY/login/oauth/authorize" >/dev/null \
|
||||
|| fail "public entry did not redirect to $AUTH_ENTRY"
|
||||
if printf '%s\n' "$chain" | grep -F 'auth.h.imruyi.top:8443' >/dev/null; then
|
||||
fail 'public chain still exposes backup auth domain'
|
||||
fi
|
||||
|
||||
printf '\n== auth authorize http ==\n'
|
||||
auth_headers="$(curl -k -sS -D - -o /tmp/aios-auth-primary-body --max-time 20 "$AUTH_URL")"
|
||||
printf '%s\n' "$auth_headers"
|
||||
printf '%s\n' "$auth_headers" | grep -F 'HTTP/' >/dev/null || fail 'auth endpoint returned no HTTP headers'
|
||||
if grep -E '许可跳转列表|redirect.*not|not.*redirect' /tmp/aios-auth-primary-body >/dev/null 2>&1; then
|
||||
fail 'auth body still contains redirect allow-list error text'
|
||||
fi
|
||||
|
||||
if command -v ssh >/dev/null 2>&1; then
|
||||
printf '\n== casdoor db redirect allow-list ==\n'
|
||||
redirects="$(ssh "$AIHOST_HOST" "docker exec casdoor-postgres psql -U casdoor -d casdoor -Atc \"select redirect_uris from application where owner='admin' and name='aios-portal';\"" 2>/dev/null || true)"
|
||||
if [ -n "$redirects" ]; then
|
||||
printf '%s\n' "$redirects"
|
||||
printf '%s\n' "$redirects" | grep -F "$CALLBACK" >/dev/null \
|
||||
|| fail "Casdoor DB does not include $CALLBACK"
|
||||
else
|
||||
printf 'WARN: skipped Casdoor DB readback; set AIHOST_HOST to a reachable SSH target for a strict DB check.\n'
|
||||
fi
|
||||
|
||||
printf '\n== ucc runtime env ==\n'
|
||||
runtime_env="$(docker inspect user-control-web --format '{{range .Config.Env}}{{println .}}{{end}}' 2>/dev/null | grep -E 'UCC_PUBLIC_BASE_URL|CASDOOR_(PUBLIC|INTERNAL|INVITE_SYNC)_BASE_URL' || true)"
|
||||
if [ -z "$runtime_env" ]; then
|
||||
runtime_env="$(ssh "$HW_HOST" 'docker inspect user-control-web --format "{{range .Config.Env}}{{println .}}{{end}}" | grep -E "UCC_PUBLIC_BASE_URL|CASDOOR_(PUBLIC|INTERNAL|INVITE_SYNC)_BASE_URL"' 2>/dev/null || true)"
|
||||
fi
|
||||
if [ -n "$runtime_env" ]; then
|
||||
printf '%s\n' "$runtime_env"
|
||||
printf '%s\n' "$runtime_env" | grep -F "CASDOOR_PUBLIC_BASE_URL=$AUTH_ENTRY" >/dev/null \
|
||||
|| fail 'UCC runtime CASDOOR_PUBLIC_BASE_URL is not primary auth domain'
|
||||
else
|
||||
printf 'WARN: skipped UCC runtime env readback; run on hw.imruyi.top or set HW_HOST to a reachable SSH target.\n'
|
||||
fi
|
||||
fi
|
||||
|
||||
printf '\nPASS: cnnetmkt primary auth route is canonical and callback is allowed.\n'
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from huaweicloudsdkcore.auth.credentials import BasicCredentials
|
||||
from huaweicloudsdkdns.v2 import (
|
||||
CreateRecordSetRequest,
|
||||
CreateRecordSetRequestBody,
|
||||
DnsClient,
|
||||
ListRecordSetsByZoneRequest,
|
||||
ListPublicZonesRequest,
|
||||
UpdateRecordSetRequest,
|
||||
UpdateRecordSetReq,
|
||||
)
|
||||
from huaweicloudsdkdns.v2.region.dns_region import DnsRegion
|
||||
|
||||
|
||||
ENV_FILE = Path("/etc/letsencrypt/huaweicloud-cnnetmkt.env")
|
||||
ZONE_NAME = "cnnetmkt.top."
|
||||
RECORD_NAME = "cnnetmkt.top."
|
||||
TARGET_IP = "1.95.164.183"
|
||||
|
||||
|
||||
def load_env() -> None:
|
||||
for line in ENV_FILE.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
os.environ.setdefault(key, value)
|
||||
|
||||
|
||||
def make_client() -> DnsClient:
|
||||
ak = os.environ["HUAWEICLOUD_ACCESS_KEY_ID"]
|
||||
sk = os.environ["HUAWEICLOUD_SECRET_ACCESS_KEY"]
|
||||
region = os.environ.get("HUAWEICLOUD_REGION", "cn-southwest-2")
|
||||
credentials = BasicCredentials(ak, sk)
|
||||
return DnsClient.new_builder().with_credentials(credentials).with_region(DnsRegion.value_of(region)).build()
|
||||
|
||||
|
||||
def as_dict(obj):
|
||||
if hasattr(obj, "to_json_object"):
|
||||
return obj.to_json_object()
|
||||
if hasattr(obj, "to_dict"):
|
||||
return obj.to_dict()
|
||||
return obj
|
||||
|
||||
|
||||
def main() -> int:
|
||||
load_env()
|
||||
client = make_client()
|
||||
|
||||
zones_resp = client.list_public_zones(ListPublicZonesRequest(name=ZONE_NAME))
|
||||
zones = getattr(zones_resp, "zones", None) or []
|
||||
if not zones:
|
||||
raise RuntimeError(f"zone not found: {ZONE_NAME}")
|
||||
zone = zones[0]
|
||||
zone_id = zone.id
|
||||
|
||||
rs_req = ListRecordSetsByZoneRequest(zone_id=zone_id, name=RECORD_NAME, type="A")
|
||||
rs_resp = client.list_record_sets_by_zone(rs_req)
|
||||
recordsets = getattr(rs_resp, "recordsets", None) or []
|
||||
existing = None
|
||||
for item in recordsets:
|
||||
if item.name == RECORD_NAME and item.type == "A":
|
||||
existing = item
|
||||
break
|
||||
|
||||
if existing:
|
||||
records = list(existing.records or [])
|
||||
if records == [TARGET_IP] and existing.status != "DISABLE":
|
||||
action = "unchanged"
|
||||
result = existing
|
||||
else:
|
||||
body = UpdateRecordSetReq(
|
||||
name=RECORD_NAME,
|
||||
type="A",
|
||||
ttl=300,
|
||||
records=[TARGET_IP],
|
||||
description="AIOS GitOps apex entry",
|
||||
)
|
||||
req = UpdateRecordSetRequest(zone_id=zone_id, recordset_id=existing.id, body=body)
|
||||
result = client.update_record_set(req)
|
||||
action = "updated"
|
||||
else:
|
||||
body = CreateRecordSetRequestBody(
|
||||
name=RECORD_NAME,
|
||||
type="A",
|
||||
ttl=300,
|
||||
records=[TARGET_IP],
|
||||
description="AIOS GitOps apex entry",
|
||||
)
|
||||
req = CreateRecordSetRequest(zone_id=zone_id, body=body)
|
||||
result = client.create_record_set(req)
|
||||
action = "created"
|
||||
|
||||
print(json.dumps({
|
||||
"action": action,
|
||||
"zone_id": zone_id,
|
||||
"record": RECORD_NAME,
|
||||
"type": "A",
|
||||
"records": [TARGET_IP],
|
||||
"result": as_dict(result),
|
||||
}, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Run from an operator machine that can SSH to both hw.imruyi.top and aihost.
|
||||
# No secrets are embedded; the script uses existing server-side Docker/env state.
|
||||
|
||||
HW_HOST="${HW_HOST:-hw.imruyi.top}"
|
||||
AIHOST_HOST="${AIHOST_HOST:-aihost}"
|
||||
PRIMARY_AUTH="${PRIMARY_AUTH:-https://auth.cnnetmkt.top}"
|
||||
PRIMARY_ENTRY="${PRIMARY_ENTRY:-https://cnnetmkt.top}"
|
||||
UCC_DEPLOY_DIR="${UCC_DEPLOY_DIR:-/data/aios/aios_r13_v18_runtime_portable_silent_20260704/components/user-control-center/deploy}"
|
||||
|
||||
printf '== update Casdoor application on %s ==\n' "$AIHOST_HOST"
|
||||
ssh "$AIHOST_HOST" bash -s <<'AIHOST'
|
||||
set -euo pipefail
|
||||
TS=$(date -u +%Y%m%dT%H%M%SZ)
|
||||
BACKUP_DIR="/data/aios/backups/casdoor-cnnetmkt-primary-$TS"
|
||||
sudo mkdir -p "$BACKUP_DIR"
|
||||
sudo chown "$USER":"$USER" "$BACKUP_DIR"
|
||||
docker exec casdoor-postgres pg_dump -U casdoor -d casdoor -t application > "$BACKUP_DIR/application-before.sql"
|
||||
docker exec -i casdoor-postgres psql -U casdoor -d casdoor -v ON_ERROR_STOP=1 <<'SQL'
|
||||
UPDATE application
|
||||
SET
|
||||
homepage_url = 'https://cnnetmkt.top/',
|
||||
signin_url = 'https://cnnetmkt.top/portal/login/',
|
||||
signup_url = 'https://cnnetmkt.top/portal/register/',
|
||||
redirect_uris = '["https://cnnetmkt.top/api/auth/callback/casdoor", "https://cnnetmkt.top/casdoor/callback/", "https://portal.cnnetmkt.top/api/auth/callback/casdoor", "https://portal.cnnetmkt.top/casdoor/callback/", "https://user.cnnetmkt.top/api/auth/callback/casdoor", "https://user.cnnetmkt.top/casdoor/callback/", "https://portal.h.imruyi.top:8443/api/auth/callback/casdoor", "https://portal.h.imruyi.top:8443/casdoor/callback/"]'
|
||||
WHERE owner = 'admin' AND name = 'aios-portal';
|
||||
SQL
|
||||
docker exec casdoor-postgres psql -U casdoor -d casdoor -P pager=off -c "select owner,name,homepage_url,signin_url,signup_url,redirect_uris from application where owner='admin' and name='aios-portal';" > "$BACKUP_DIR/application-after.txt"
|
||||
cat "$BACKUP_DIR/application-after.txt"
|
||||
printf 'casdoor_backup_dir=%s\n' "$BACKUP_DIR"
|
||||
AIHOST
|
||||
|
||||
printf '\n== update HW ingress and UCC runtime on %s ==\n' "$HW_HOST"
|
||||
ssh "$HW_HOST" bash -s <<HW
|
||||
set -euo pipefail
|
||||
TS=\$(date -u +%Y%m%dT%H%M%SZ)
|
||||
BACKUP_DIR="/data/aios-gitops/backups/auth-cnnetmkt-primary-\$TS"
|
||||
ENV_FILE="$UCC_DEPLOY_DIR/.env"
|
||||
NGINX_AUTH="/etc/nginx/sites-available/auth-cnnetmkt-casdoor.conf"
|
||||
sudo mkdir -p "\$BACKUP_DIR"
|
||||
sudo cp -a "\$ENV_FILE" "\$BACKUP_DIR/user-control.env.before"
|
||||
sudo cp -a /etc/nginx/sites-available "\$BACKUP_DIR/nginx-sites-available.before"
|
||||
|
||||
set_env() {
|
||||
local key="\$1" value="\$2"
|
||||
if sudo grep -q "^\${key}=" "\$ENV_FILE"; then
|
||||
sudo sed -i "s#^\${key}=.*#\${key}=\${value}#" "\$ENV_FILE"
|
||||
else
|
||||
printf '%s=%s\n' "\$key" "\$value" | sudo tee -a "\$ENV_FILE" >/dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
set_env ALLOWED_HOSTS 'cnnetmkt.top,portal.cnnetmkt.top,user.cnnetmkt.top,auth.cnnetmkt.top,127.0.0.1,localhost,hw.imruyi.top,1.95.164.183,portal.h.imruyi.top'
|
||||
set_env CSRF_TRUSTED_ORIGINS 'https://cnnetmkt.top,https://portal.cnnetmkt.top,https://user.cnnetmkt.top,https://auth.cnnetmkt.top,http://portal.cnnetmkt.top,http://hw.imruyi.top,http://1.95.164.183,https://portal.h.imruyi.top:8443,http://portal.h.imruyi.top,http://user.cnnetmkt.top'
|
||||
set_env UCC_PUBLIC_BASE_URL '$PRIMARY_ENTRY'
|
||||
set_env CASDOOR_PUBLIC_BASE_URL '$PRIMARY_AUTH'
|
||||
set_env CASDOOR_INTERNAL_BASE_URL '$PRIMARY_AUTH'
|
||||
set_env CASDOOR_INVITE_SYNC_BASE_URL '$PRIMARY_AUTH'
|
||||
|
||||
sudo tee "\$NGINX_AUTH" >/dev/null <<'NGINX'
|
||||
# AIOS primary public Casdoor entry for cnnetmkt.top.
|
||||
# The upstream remains the existing backup/home Casdoor service; public clients should see auth.cnnetmkt.top only.
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
server_name auth.cnnetmkt.top;
|
||||
|
||||
access_log /var/log/nginx/aios-auth-cnnetmkt.access.log;
|
||||
error_log /var/log/nginx/aios-auth-cnnetmkt.error.log;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/cnnetmkt-wildcard/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/cnnetmkt-wildcard/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass https://auth.h.imruyi.top:8443;
|
||||
proxy_http_version 1.1;
|
||||
proxy_ssl_server_name on;
|
||||
proxy_ssl_name auth.h.imruyi.top;
|
||||
proxy_ssl_verify off;
|
||||
proxy_set_header Host auth.h.imruyi.top;
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header X-Forwarded-Host \$host;
|
||||
proxy_set_header X-Forwarded-Port 443;
|
||||
proxy_set_header Upgrade \$http_upgrade;
|
||||
proxy_set_header Connection \$connection_upgrade;
|
||||
proxy_redirect https://auth.h.imruyi.top:8443/ https://auth.cnnetmkt.top/;
|
||||
proxy_redirect https://auth.h.imruyi.top/ https://auth.cnnetmkt.top/;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name auth.cnnetmkt.top;
|
||||
return 301 https://\$host\$request_uri;
|
||||
}
|
||||
NGINX
|
||||
|
||||
sudo ln -sf "\$NGINX_AUTH" /etc/nginx/sites-enabled/auth-cnnetmkt-casdoor.conf
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
cd "$UCC_DEPLOY_DIR"
|
||||
sudo docker compose up -d --force-recreate user-control-web user-control-worker user-control-beat
|
||||
printf 'hw_backup_dir=%s\n' "\$BACKUP_DIR"
|
||||
HW
|
||||
|
||||
"$(dirname "$0")/check_cnnetmkt_auth_primary.sh"
|
||||
+252
@@ -0,0 +1,252 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [ "${EUID:-$(id -u)}" -ne 0 ]; then
|
||||
exec sudo bash "$0" "$@"
|
||||
fi
|
||||
|
||||
ROOT="/data/aios-gitops"
|
||||
WORKSPACE="$ROOT/workspace"
|
||||
LEGO_BIN="$ROOT/runtime/bin/lego"
|
||||
LEGO_PATH="/etc/letsencrypt/lego-cnnetmkt"
|
||||
LIVE_DIR="/etc/letsencrypt/live/cnnetmkt-wildcard"
|
||||
ENV_FILE="/etc/letsencrypt/huaweicloud-cnnetmkt.env"
|
||||
LOG="$ROOT/logs/cnnetmkt-wildcard-lego.log"
|
||||
REPORT="$ROOT/artifacts/CNNETMKT_WILDCARD_CERT_STATUS.md"
|
||||
TRACKED="$WORKSPACE/deploy/nginx/cnnetmkt-wildcard-certificate.md"
|
||||
BACKUP_DIR="$ROOT/backups/nginx-wildcard-$(date +%Y%m%d-%H%M%S)"
|
||||
EMAIL="admin@cnnetmkt.top"
|
||||
CERT_NAME="cnnetmkt-wildcard"
|
||||
LEGO_VERSION="v5.2.2"
|
||||
LEGO_URL="https://github.com/go-acme/lego/releases/download/${LEGO_VERSION}/lego_${LEGO_VERSION}_linux_amd64.tar.gz"
|
||||
mkdir -p "$ROOT/logs" "$ROOT/runtime/bin" "$ROOT/artifacts" "$WORKSPACE/deploy/nginx" "$BACKUP_DIR"
|
||||
exec > >(tee -a "$LOG") 2>&1
|
||||
|
||||
redact() {
|
||||
sed -E 's/(ACCESS_KEY_ID=).+/\1[REDACTED]/; s/(SECRET_ACCESS_KEY=).+/\1[REDACTED]/'
|
||||
}
|
||||
|
||||
echo "== preflight =="
|
||||
hostname
|
||||
date -Is
|
||||
test -s "$ENV_FILE"
|
||||
sudo chmod 600 "$ENV_FILE"
|
||||
sudo chown root:root "$ENV_FILE"
|
||||
echo "env_file=$ENV_FILE"
|
||||
sudo sed -n '1,20p' "$ENV_FILE" | redact
|
||||
|
||||
echo "== install lego =="
|
||||
if [ ! -x "$LEGO_BIN" ]; then
|
||||
tmp="$(mktemp -d)"
|
||||
curl -fL --retry 3 --connect-timeout 30 -o "$tmp/lego.tar.gz" "$LEGO_URL"
|
||||
tar -xzf "$tmp/lego.tar.gz" -C "$tmp"
|
||||
install -m 0755 "$tmp/lego" "$LEGO_BIN"
|
||||
rm -rf "$tmp"
|
||||
fi
|
||||
"$LEGO_BIN" --version
|
||||
|
||||
echo "== load env =="
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
. "$ENV_FILE"
|
||||
set +a
|
||||
: "${HUAWEICLOUD_ACCESS_KEY_ID:?missing access key id}"
|
||||
: "${HUAWEICLOUD_SECRET_ACCESS_KEY:?missing secret access key}"
|
||||
: "${HUAWEICLOUD_REGION:?missing huawei region}"
|
||||
export HUAWEICLOUD_HTTP_TIMEOUT="${HUAWEICLOUD_HTTP_TIMEOUT:-30}"
|
||||
export HUAWEICLOUD_PROPAGATION_TIMEOUT="${HUAWEICLOUD_PROPAGATION_TIMEOUT:-180}"
|
||||
export HUAWEICLOUD_POLLING_INTERVAL="${HUAWEICLOUD_POLLING_INTERVAL:-5}"
|
||||
export HUAWEICLOUD_TTL="${HUAWEICLOUD_TTL:-300}"
|
||||
|
||||
echo "== DNS state =="
|
||||
for n in cnnetmkt.top g.cnnetmkt.top user.cnnetmkt.top portal.cnnetmkt.top; do
|
||||
echo "-- $n"
|
||||
dig +time=5 +tries=1 +short "$n" A || true
|
||||
done
|
||||
|
||||
echo "== staging DNS-01 =="
|
||||
STAGING_STATUS="failed"
|
||||
if sudo --preserve-env=HUAWEICLOUD_ACCESS_KEY_ID,HUAWEICLOUD_SECRET_ACCESS_KEY,HUAWEICLOUD_REGION,HUAWEICLOUD_HTTP_TIMEOUT,HUAWEICLOUD_PROPAGATION_TIMEOUT,HUAWEICLOUD_POLLING_INTERVAL,HUAWEICLOUD_TTL \
|
||||
"$LEGO_BIN" run --path "$LEGO_PATH-staging" --email "$EMAIL" --accept-tos \
|
||||
--server "https://acme-staging-v02.api.letsencrypt.org/directory" \
|
||||
--dns huaweicloud --dns.propagation.wait 120s --cert.name "$CERT_NAME" --domains "cnnetmkt.top" --domains "*.cnnetmkt.top"; then
|
||||
STAGING_STATUS="ok"
|
||||
fi
|
||||
echo "staging_status=$STAGING_STATUS"
|
||||
if [ "$STAGING_STATUS" != "ok" ]; then
|
||||
echo "STAGING_DNS01_FAILED"
|
||||
exit 20
|
||||
fi
|
||||
|
||||
echo "== production DNS-01 =="
|
||||
PROD_STATUS="failed"
|
||||
if sudo --preserve-env=HUAWEICLOUD_ACCESS_KEY_ID,HUAWEICLOUD_SECRET_ACCESS_KEY,HUAWEICLOUD_REGION,HUAWEICLOUD_HTTP_TIMEOUT,HUAWEICLOUD_PROPAGATION_TIMEOUT,HUAWEICLOUD_POLLING_INTERVAL,HUAWEICLOUD_TTL \
|
||||
"$LEGO_BIN" run --path "$LEGO_PATH" --email "$EMAIL" --accept-tos \
|
||||
--dns huaweicloud --dns.propagation.wait 120s --cert.name "$CERT_NAME" --domains "cnnetmkt.top" --domains "*.cnnetmkt.top"; then
|
||||
PROD_STATUS="issued"
|
||||
else
|
||||
sudo --preserve-env=HUAWEICLOUD_ACCESS_KEY_ID,HUAWEICLOUD_SECRET_ACCESS_KEY,HUAWEICLOUD_REGION,HUAWEICLOUD_HTTP_TIMEOUT,HUAWEICLOUD_PROPAGATION_TIMEOUT,HUAWEICLOUD_POLLING_INTERVAL,HUAWEICLOUD_TTL \
|
||||
"$LEGO_BIN" run --path "$LEGO_PATH" --email "$EMAIL" --accept-tos \
|
||||
--dns huaweicloud --dns.propagation.wait 120s --cert.name "$CERT_NAME" --domains "cnnetmkt.top" --domains "*.cnnetmkt.top" --renew-days 60 --renew-force
|
||||
PROD_STATUS="renewed"
|
||||
fi
|
||||
|
||||
echo "== install cert material =="
|
||||
sudo mkdir -p "$LIVE_DIR"
|
||||
cert_file="$(sudo find "$LEGO_PATH/certificates" -maxdepth 1 -type f -name '*.crt' ! -name '*.issuer.crt' | head -1)"
|
||||
key_file="$(sudo find "$LEGO_PATH/certificates" -maxdepth 1 -type f -name '*.key' | head -1)"
|
||||
issuer_file="$(sudo find "$LEGO_PATH/certificates" -maxdepth 1 -type f -name '*.issuer.crt' | head -1 || true)"
|
||||
test -n "$cert_file"
|
||||
test -n "$key_file"
|
||||
sudo cp "$key_file" "$LIVE_DIR/privkey.pem"
|
||||
if [ -n "${issuer_file:-}" ] && [ -f "$issuer_file" ]; then
|
||||
sudo sh -c "cat '$cert_file' '$issuer_file' > '$LIVE_DIR/fullchain.pem'"
|
||||
sudo cp "$issuer_file" "$LIVE_DIR/chain.pem"
|
||||
else
|
||||
sudo cp "$cert_file" "$LIVE_DIR/fullchain.pem"
|
||||
fi
|
||||
sudo chmod 644 "$LIVE_DIR/fullchain.pem" "$LIVE_DIR/chain.pem" 2>/dev/null || true
|
||||
sudo chmod 600 "$LIVE_DIR/privkey.pem"
|
||||
|
||||
echo "== verify cert material =="
|
||||
sudo openssl x509 -in "$LIVE_DIR/fullchain.pem" -noout -subject -issuer -dates -ext subjectAltName
|
||||
|
||||
echo "== patch nginx cert paths =="
|
||||
sudo cp -a /etc/nginx/sites-available/aios-user-portal.conf "$BACKUP_DIR/aios-user-portal.conf"
|
||||
sudo cp -a /etc/nginx/sites-available/g-cnnetmkt-gitea.conf "$BACKUP_DIR/g-cnnetmkt-gitea.conf" 2>/dev/null || true
|
||||
sudo python3 - <<'PY'
|
||||
from pathlib import Path
|
||||
|
||||
files = [
|
||||
Path("/etc/nginx/sites-available/aios-user-portal.conf"),
|
||||
Path("/etc/nginx/sites-available/g-cnnetmkt-gitea.conf"),
|
||||
]
|
||||
for path in files:
|
||||
if not path.exists():
|
||||
continue
|
||||
text = path.read_text()
|
||||
text = text.replace(
|
||||
"/etc/letsencrypt/live/user.cnnetmkt.top/fullchain.pem",
|
||||
"/etc/letsencrypt/live/cnnetmkt-wildcard/fullchain.pem",
|
||||
)
|
||||
text = text.replace(
|
||||
"/etc/letsencrypt/live/user.cnnetmkt.top/privkey.pem",
|
||||
"/etc/letsencrypt/live/cnnetmkt-wildcard/privkey.pem",
|
||||
)
|
||||
path.write_text(text)
|
||||
PY
|
||||
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
|
||||
echo "== validation =="
|
||||
for h in cnnetmkt.top g.cnnetmkt.top user.cnnetmkt.top portal.cnnetmkt.top; do
|
||||
echo "-- $h cert"
|
||||
echo | openssl s_client -connect 127.0.0.1:443 -servername "$h" 2>/dev/null \
|
||||
| openssl x509 -noout -subject -issuer -dates -ext subjectAltName 2>/dev/null || true
|
||||
echo "-- $h /"
|
||||
curl -k -sS -D - -o /tmp/cnnetmkt-body --resolve "$h:443:127.0.0.1" "https://$h/" | sed -n '1,12p' || true
|
||||
grep -ao '<title>[^<]*' /tmp/cnnetmkt-body 2>/dev/null | head -1 || true
|
||||
echo "-- $h /git/"
|
||||
curl -k -sS -D - -o /tmp/cnnetmkt-body --resolve "$h:443:127.0.0.1" "https://$h/git/" | sed -n '1,12p' || true
|
||||
grep -ao '<title>[^<]*' /tmp/cnnetmkt-body 2>/dev/null | head -1 || true
|
||||
done
|
||||
|
||||
echo "== write renewal helper =="
|
||||
sudo tee /usr/local/sbin/renew-cnnetmkt-wildcard.sh >/dev/null <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
ROOT="/data/aios-gitops"
|
||||
LEGO_BIN="$ROOT/runtime/bin/lego"
|
||||
LEGO_PATH="/etc/letsencrypt/lego-cnnetmkt"
|
||||
LIVE_DIR="/etc/letsencrypt/live/cnnetmkt-wildcard"
|
||||
ENV_FILE="/etc/letsencrypt/huaweicloud-cnnetmkt.env"
|
||||
set -a
|
||||
. "$ENV_FILE"
|
||||
set +a
|
||||
sudo --preserve-env=HUAWEICLOUD_ACCESS_KEY_ID,HUAWEICLOUD_SECRET_ACCESS_KEY,HUAWEICLOUD_REGION,HUAWEICLOUD_HTTP_TIMEOUT,HUAWEICLOUD_PROPAGATION_TIMEOUT,HUAWEICLOUD_POLLING_INTERVAL,HUAWEICLOUD_TTL \
|
||||
"$LEGO_BIN" run --path "$LEGO_PATH" --email "admin@cnnetmkt.top" --accept-tos \
|
||||
--dns huaweicloud --dns.propagation.wait 120s --cert.name "cnnetmkt-wildcard" --domains "cnnetmkt.top" --domains "*.cnnetmkt.top" --renew-days 45
|
||||
cert_file="$(find "$LEGO_PATH/certificates" -maxdepth 1 -type f -name '*.crt' ! -name '*.issuer.crt' | head -1)"
|
||||
key_file="$(find "$LEGO_PATH/certificates" -maxdepth 1 -type f -name '*.key' | head -1)"
|
||||
cp "$cert_file" "$LIVE_DIR/fullchain.pem"
|
||||
cp "$key_file" "$LIVE_DIR/privkey.pem"
|
||||
chmod 644 "$LIVE_DIR/fullchain.pem"
|
||||
chmod 600 "$LIVE_DIR/privkey.pem"
|
||||
nginx -t
|
||||
systemctl reload nginx
|
||||
SH
|
||||
sudo chmod 700 /usr/local/sbin/renew-cnnetmkt-wildcard.sh
|
||||
|
||||
sudo tee /etc/systemd/system/cnnetmkt-wildcard-renew.service >/dev/null <<'UNIT'
|
||||
[Unit]
|
||||
Description=Renew cnnetmkt wildcard certificate via Huawei Cloud DNS
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/sbin/renew-cnnetmkt-wildcard.sh
|
||||
UNIT
|
||||
|
||||
sudo tee /etc/systemd/system/cnnetmkt-wildcard-renew.timer >/dev/null <<'UNIT'
|
||||
[Unit]
|
||||
Description=Daily cnnetmkt wildcard certificate renewal check
|
||||
|
||||
[Timer]
|
||||
OnCalendar=daily
|
||||
RandomizedDelaySec=2h
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
UNIT
|
||||
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now cnnetmkt-wildcard-renew.timer
|
||||
|
||||
cat > "$TRACKED" <<EOF_TRACKED
|
||||
# cnnetmkt wildcard certificate
|
||||
|
||||
generated_at=$(date -Is)
|
||||
issuer=Let's Encrypt via lego DNS-01 Huawei Cloud
|
||||
domains=cnnetmkt.top,*.cnnetmkt.top
|
||||
live_dir=$LIVE_DIR
|
||||
nginx_sites=/etc/nginx/sites-available/aios-user-portal.conf,/etc/nginx/sites-available/g-cnnetmkt-gitea.conf
|
||||
renew_service=cnnetmkt-wildcard-renew.service
|
||||
renew_timer=cnnetmkt-wildcard-renew.timer
|
||||
secret_env=$ENV_FILE
|
||||
backup_dir=$BACKUP_DIR
|
||||
status=$PROD_STATUS
|
||||
EOF_TRACKED
|
||||
|
||||
cat > "$REPORT" <<EOF_REPORT
|
||||
# CNNETMKT wildcard certificate status
|
||||
|
||||
generated_at=$(date -Is)
|
||||
status=$PROD_STATUS
|
||||
staging_status=$STAGING_STATUS
|
||||
dns_provider=huaweicloud
|
||||
secret_storage=$ENV_FILE
|
||||
certificate_live_dir=$LIVE_DIR
|
||||
nginx_status=loaded
|
||||
backup_dir=$BACKUP_DIR
|
||||
renew_timer=cnnetmkt-wildcard-renew.timer
|
||||
|
||||
## certificate
|
||||
|
||||
$(sudo openssl x509 -in "$LIVE_DIR/fullchain.pem" -noout -subject -issuer -dates -ext subjectAltName)
|
||||
|
||||
## routes
|
||||
|
||||
- https://cnnetmkt.top/
|
||||
- https://cnnetmkt.top/git/
|
||||
- https://g.cnnetmkt.top/
|
||||
- https://g.cnnetmkt.top/git/
|
||||
- https://user.cnnetmkt.top/
|
||||
EOF_REPORT
|
||||
|
||||
cd "$WORKSPACE"
|
||||
chown -R aiman:aiman "$WORKSPACE/deploy/nginx" "$ROOT/artifacts" "$ROOT/logs"
|
||||
runuser -u aiman -- git add deploy/nginx/cnnetmkt-wildcard-certificate.md
|
||||
runuser -u aiman -- git commit -m "fix(ingress): switch cnnetmkt to wildcard certificate" || true
|
||||
|
||||
echo "CNNETMKT_WILDCARD_DONE status=$PROD_STATUS report=$REPORT"
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
ENV_FILE="${UCC_ENV_FILE:-/data/aios/aios_r13_v18_runtime_portable_silent_20260704/components/user-control-center/deploy/.env}"
|
||||
GITEA_BIN="${GITEA_BIN:-/data/aios-gitops/runtime/bin/gitea}"
|
||||
APP_INI="${GITEA_APP_INI:-/data/aios-gitops/runtime/gitea-bin/custom/conf/app.ini}"
|
||||
WORK_PATH="${GITEA_WORK_PATH:-/data/aios-gitops/runtime/gitea-bin}"
|
||||
DB="${GITEA_DB:-/data/aios-gitops/runtime/gitea-bin/data/gitea.db}"
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
. "$ENV_FILE"
|
||||
set +a
|
||||
strip_trailing_cr() {
|
||||
printf '%s' "${1:-}" | tr -d '\r'
|
||||
}
|
||||
OWNER_USERNAME="$(strip_trailing_cr "${UCC_OWNER_USERNAME:-owner}")"
|
||||
OWNER_EMAIL="$(strip_trailing_cr "${UCC_OWNER_EMAIL:-owner@cnnetmkt.top}")"
|
||||
OWNER_PASSWORD="$(strip_trailing_cr "${UCC_OWNER_PASSWORD:?UCC_OWNER_PASSWORD is required}")"
|
||||
GITEA_RUN_USER="${GITEA_RUN_USER:-aiman}"
|
||||
run_gitea() {
|
||||
runuser -u "$GITEA_RUN_USER" -- "$GITEA_BIN" --config "$APP_INI" --work-path "$WORK_PATH" "$@"
|
||||
}
|
||||
if run_gitea admin user list 2>/dev/null | awk '{print $2}' | grep -Fxq "$OWNER_USERNAME"; then
|
||||
run_gitea admin user change-password --username "$OWNER_USERNAME" --password "$OWNER_PASSWORD" --must-change-password=false >/dev/null
|
||||
else
|
||||
run_gitea admin user create --username "$OWNER_USERNAME" --password "$OWNER_PASSWORD" --email "$OWNER_EMAIL" --admin --must-change-password=false >/dev/null
|
||||
fi
|
||||
python3 - <<PY
|
||||
import sqlite3, time
|
||||
con=sqlite3.connect('$DB')
|
||||
cur=con.cursor()
|
||||
cur.execute("update user set is_admin=1,is_active=1,prohibit_login=0,must_change_password=0,updated_unix=? where lower_name=lower(?)", (int(time.time()), '$OWNER_USERNAME'))
|
||||
cur.execute("update user set prohibit_login=1,is_admin=0,passwd='',passwd_hash_algo='disabled',must_change_password=1,updated_unix=? where lower_name in ('aios','admin','gitea') and lower_name<>lower(?)", (int(time.time()), '$OWNER_USERNAME'))
|
||||
con.commit()
|
||||
PY
|
||||
printf 'GITEA_OWNER_SYNC_OK user=%s default_local_login_disabled=1\n' "$OWNER_USERNAME"
|
||||
@@ -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 并阻止合并。
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"type": "branch",
|
||||
"branch": "patch/collab-custom-gpt-20260706T104130Z",
|
||||
"base": "main",
|
||||
"created_by": "chatgpt-gitops-bot",
|
||||
"created_at": "2026-07-06T10:41:30Z",
|
||||
"direct_main_push": false,
|
||||
"owner_approval_required": true
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"type": "branch",
|
||||
"branch": "patch/collab-local-codex-20260706T104130Z",
|
||||
"base": "main",
|
||||
"created_by": "codex-gitops-bot",
|
||||
"created_at": "2026-07-06T10:41:30Z",
|
||||
"direct_main_push": false,
|
||||
"owner_approval_required": true
|
||||
}
|
||||
@@ -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
|
||||
Executable
+19
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "${AIOS_GITOPS_WORKSPACE:-/data/aios-gitops/workspace}"
|
||||
expected="${1:-V1R1C4}"
|
||||
test "$(git branch --show-current)" = "main"
|
||||
test "$(cat VERSION | tr -d '\n')" = "$expected"
|
||||
git rev-parse --verify "$expected" >/dev/null
|
||||
grep -q 'git_patch_only' config/codex/gitops-online.json
|
||||
grep -q 'codex-gitops-bot' config/codex/gitops-online.json
|
||||
grep -q 'online_git_sync' config/codex/gitops-online.json
|
||||
python3 -m py_compile active-runtime/app/aios_chatgpt_actions_api.py
|
||||
curl -fsS https://cnnetmkt.top/api/base >/tmp/aios-codex-gitops-base.json
|
||||
curl -fsS https://cnnetmkt.top/api/openapi-chatgpt-actions.yaml >/tmp/aios-codex-gitops-openapi.yaml
|
||||
if git grep -I -E '(sk-[A-Za-z0-9_-]{20,}|SDC[A-Za-z0-9]{20,}|HPUA[A-Z0-9]{10,})' -- . ':!docs/gitops/openapi-chatgpt-actions.yaml' >/tmp/aios-codex-secret-scan.txt; then
|
||||
echo 'SECRET_PATTERN_IN_GIT'
|
||||
cat /tmp/aios-codex-secret-scan.txt
|
||||
exit 1
|
||||
fi
|
||||
echo "CODEX_GITOPS_ONLINE_OK $expected"
|
||||
@@ -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())
|
||||
@@ -56,6 +56,7 @@ fi
|
||||
|
||||
echo "== Token initialization =="
|
||||
TOKEN_FILE="$AIOS_GITOPS_ROOT/runtime/secrets/gitops.tokens"
|
||||
BARE_REPO="$AIOS_GITOPS_ROOT/runtime/gitea-bin/data/repositories/aios/aios-v23.git"
|
||||
if [ ! -s "$TOKEN_FILE" ]; then
|
||||
(openssl rand -hex 32 || python3 -c 'import secrets; print(secrets.token_hex(32))') > "$TOKEN_FILE"
|
||||
chmod 600 "$TOKEN_FILE"
|
||||
@@ -73,9 +74,10 @@ Environment=AIOS_GITOPS_ROOT=$AIOS_GITOPS_ROOT
|
||||
Environment=AIOS_GITOPS_WORKSPACE=$WORKSPACE
|
||||
Environment=AIOS_GITOPS_DB=$AIOS_GITOPS_ROOT/runtime/gitops_model.sqlite3
|
||||
Environment=AIOS_GITOPS_TOKEN_FILE=$TOKEN_FILE
|
||||
Environment=AIOS_GITOPS_BARE_REPO=$BARE_REPO
|
||||
Environment=AIOS_GITOPS_HOST=127.0.0.1
|
||||
Environment=AIOS_GITOPS_PORT=18890
|
||||
ExecStart=/usr/bin/python3 $AIOS_GITOPS_ROOT/adapters/gitops/aios_gitops_model_api.py
|
||||
ExecStart=/usr/bin/python3 $WORKSPACE/active-runtime/app/aios_chatgpt_actions_api.py
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
|
||||
|
||||
@@ -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,8 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
required=("VERSION" "docs/10_ARCHITECTURE_V1R1C0_MULTIUSER_GITOPS_CUSTOM_LLM_CN.md" "docs/11_USER_REQUIREMENTS_V1R1C0_CN.md" "docs/12_INTERFACE_API_SPEC_V1R1C0_CN.md" "docs/CODEX_FULL_EXECUTION_PROMPT_V1R1C0.md" "active-runtime/app/aios_gitops_model_api.py" "migrations/004_gitops_custom_llm_runtime.sql" "scripts/install_v1r1c0_gitops_model_overlay.sh" "config/model_registry.yaml" "config/runtime_selector.yaml")
|
||||
required=(
|
||||
"VERSION"
|
||||
"docs/10_ARCHITECTURE_V1R1C0_MULTIUSER_GITOPS_CUSTOM_LLM_CN.md"
|
||||
"docs/11_USER_REQUIREMENTS_V1R1C0_CN.md"
|
||||
"docs/12_INTERFACE_API_SPEC_V1R1C0_CN.md"
|
||||
"docs/CODEX_FULL_EXECUTION_PROMPT_V1R1C0.md"
|
||||
"active-runtime/app/aios_gitops_model_api.py"
|
||||
"active-runtime/app/aios_chatgpt_actions_api.py"
|
||||
"docs/gitops/openapi-chatgpt-actions.yaml"
|
||||
"migrations/004_gitops_custom_llm_runtime.sql"
|
||||
"scripts/install_v1r1c0_gitops_model_overlay.sh"
|
||||
"config/model_registry.yaml"
|
||||
"config/runtime_selector.yaml"
|
||||
)
|
||||
for f in "${required[@]}"; do test -f "$ROOT/$f" || { echo "MISSING $f"; exit 1; }; done
|
||||
grep -q "V1R1C0" "$ROOT/VERSION"
|
||||
python3 -m py_compile "$ROOT/active-runtime/app/aios_gitops_model_api.py" "$ROOT/adapters/openclaw/openclaw_gitops_commands.py" "$ROOT/adapters/hermes/hermes_gitops_nl_router.py"
|
||||
echo "PACKAGE_CONTRACT_OK V1R1C0"
|
||||
grep -Eq "^V1R1C[0-9]+$" "$ROOT/VERSION"
|
||||
python3 -m py_compile \
|
||||
"$ROOT/active-runtime/app/aios_gitops_model_api.py" \
|
||||
"$ROOT/active-runtime/app/aios_chatgpt_actions_api.py" \
|
||||
"$ROOT/adapters/openclaw/openclaw_gitops_commands.py" \
|
||||
"$ROOT/adapters/hermes/hermes_gitops_nl_router.py"
|
||||
grep -q "/api/branch" "$ROOT/docs/gitops/openapi-chatgpt-actions.yaml"
|
||||
echo "PACKAGE_CONTRACT_OK $(cat "$ROOT/VERSION")"
|
||||
@@ -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,23 @@
|
||||
{
|
||||
"version": "V1R1C1",
|
||||
"tag": "V1R1C1",
|
||||
"type": "patch-release",
|
||||
"name": "Codex GitOps Online Version Management",
|
||||
"previous_version": "V1R1C0",
|
||||
"source_version": "V1R1C0-baseline-20260706",
|
||||
"update_mode": "git_patch_only",
|
||||
"zip_update_mode": "deprecated",
|
||||
"git_url": "https://cnnetmkt.top/git/aios/aios-v23.git",
|
||||
"api_base": "https://cnnetmkt.top/api",
|
||||
"openapi_schema": "https://cnnetmkt.top/api/openapi-chatgpt-actions.yaml",
|
||||
"codex_config": "config/codex/gitops-online.json",
|
||||
"included_commits": [
|
||||
"96c00d8",
|
||||
"0b0505a",
|
||||
"43ddb91"
|
||||
],
|
||||
"excluded_branches": [
|
||||
"patch/chatgpt-model-acceptance-*"
|
||||
],
|
||||
"updated_at": "2026-07-06T10:21:08Z"
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"version": "V1R1C2",
|
||||
"tag": "V1R1C2",
|
||||
"type": "patch-release",
|
||||
"name": "Codex GitOps Online Branch Sync",
|
||||
"previous_version": "V1R1C1",
|
||||
"source_version": "V1R1C1",
|
||||
"update_mode": "git_patch_only",
|
||||
"zip_update_mode": "deprecated",
|
||||
"git_url": "https://cnnetmkt.top/git/aios/aios-v23.git",
|
||||
"api_base": "https://cnnetmkt.top/api",
|
||||
"openapi_schema": "https://cnnetmkt.top/api/openapi-chatgpt-actions.yaml",
|
||||
"codex_config": "config/codex/gitops-online.json",
|
||||
"online_git_sync": true,
|
||||
"included_commits": [
|
||||
"ecf6735"
|
||||
],
|
||||
"updated_at": "2026-07-06T10:26:26Z"
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"version": "V1R1C3",
|
||||
"tag": "V1R1C3",
|
||||
"type": "collaboration-release",
|
||||
"name": "Local Codex and Custom GPT GitOps Collaboration",
|
||||
"previous_version": "V1R1C2",
|
||||
"update_mode": "git_patch_only",
|
||||
"zip_update_mode": "deprecated",
|
||||
"git_url": "https://cnnetmkt.top/git/aios/aios-v23.git",
|
||||
"api_base": "https://cnnetmkt.top/api",
|
||||
"custom_gpt_url": "https://chatgpt.com/g/g-6a4b7c82dff08191bc980798edea1bac-aios-gitops-kong-zhi-tai",
|
||||
"collaboration_timestamp": "20260706T104130Z",
|
||||
"actors": [
|
||||
{
|
||||
"name": "local_codex",
|
||||
"account": "codex-gitops-bot",
|
||||
"branch": "patch/collab-local-codex-20260706T104130Z",
|
||||
"path": "docs/gitops/collab-local-codex-20260706T104130Z.md",
|
||||
"branch_code": 200,
|
||||
"branch_commit": "ed92b88",
|
||||
"branch_sync": "synced",
|
||||
"file_code": 200,
|
||||
"file_commit": "a62d4ce",
|
||||
"file_sync": "synced",
|
||||
"online_ref_present": true,
|
||||
"online_ref": "a62d4ce2c1722afde0775628e6e618251b7ed712 refs/heads/patch/collab-local-codex-20260706T104130Z",
|
||||
"response_status": "committed"
|
||||
},
|
||||
{
|
||||
"name": "custom_gpt",
|
||||
"account": "chatgpt-gitops-bot",
|
||||
"branch": "patch/collab-custom-gpt-20260706T104130Z",
|
||||
"path": "docs/gitops/collab-custom-gpt-20260706T104130Z.md",
|
||||
"branch_code": 200,
|
||||
"branch_commit": "5a21bd5",
|
||||
"branch_sync": "synced",
|
||||
"file_code": 200,
|
||||
"file_commit": "c393931",
|
||||
"file_sync": "synced",
|
||||
"online_ref_present": true,
|
||||
"online_ref": "c393931f64b3664d685a809c1aa4f496709a05f3 refs/heads/patch/collab-custom-gpt-20260706T104130Z",
|
||||
"response_status": "committed"
|
||||
}
|
||||
],
|
||||
"online_git_sync": true,
|
||||
"updated_at": "2026-07-06T10:42:33Z"
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"version": "V1R1C4",
|
||||
"tag": "V1R1C4",
|
||||
"type": "auth-portal-hardening-release",
|
||||
"name": "GitOps portal auth hardening and Gitea owner sync",
|
||||
"previous_version": "V1R1C3",
|
||||
"update_mode": "git_patch_only",
|
||||
"zip_update_mode": "deprecated",
|
||||
"entry": "https://cnnetmkt.top/",
|
||||
"git_web": "https://g.cnnetmkt.top/git/aios/aios-v23",
|
||||
"api_base": "https://cnnetmkt.top/api",
|
||||
"changes": [
|
||||
"Human shortlinks /base /ver /diff /patch /model /run now redirect to the login-protected portal GitOps section.",
|
||||
"Machine API remains under /api for ChatGPT Actions and Codex automation.",
|
||||
"Gitea requires sign-in for repository browsing and registration UI is hidden.",
|
||||
"Gitea owner password sync service/timer copies the user-control owner password without logging secrets.",
|
||||
"Default local Gitea account password is cleared and login is prohibited.",
|
||||
"User portal includes a GitOps dashboard section for admin/owner users.",
|
||||
"Patched user-control-center files are preserved under overlays/user-control-center for replayable releases.",
|
||||
"Portal GitOps dashboard reads /app/runtime/gitops/current.json before HTTP fallback so the version is visible inside Docker.",
|
||||
"Gitea owner sync uses runuser instead of sudo command logging and strips CRLF from sourced owner fields."
|
||||
],
|
||||
"artifacts": [
|
||||
"deploy/nginx/aios-gitops-locations.conf",
|
||||
"deploy/gitea-binary/app.ini.template",
|
||||
"deploy/systemd/aios-gitea-owner-sync.service",
|
||||
"deploy/systemd/aios-gitea-owner-sync.timer",
|
||||
"ops/gitops-migration/sync_gitea_owner_password.sh",
|
||||
"ops/gitops-migration/apply_user_portal_gitops_overlay.sh",
|
||||
"overlays/user-control-center/control/portal_views.py",
|
||||
"overlays/user-control-center/templates/portal/me.html"
|
||||
],
|
||||
"updated_at": "2026-07-06T11:16:23Z"
|
||||
}
|
||||
@@ -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