#!/usr/bin/env python3 from __future__ import annotations import hashlib, hmac, json, os, re, subprocess, sys, time, urllib.parse, uuid from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from typing import Any sys.path.insert(0, str(Path(os.getenv('AIOS_GITOPS_WORKSPACE', '/data/aios-gitops/workspace')) / 'runtime' / 'agent-os')) from orchestrator.action_monitor import ActionMonitor 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'))) monitor = ActionMonitor() 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','/api/baseline','/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','/api/versions','/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','/api/patches','/api/branches','/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','/api/runtime-status','/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']}) if path in ('/api/action-monitor','/action-monitor'): latest=monitor.latest() ldict={'event_id':latest.event_id,'action':latest.action,'error':latest.error,'context':latest.context,'created_at':latest.created_at} if latest else None return self.sendj({'events':[{"event_id":e.event_id,"action":e.action,"error":e.error,"context":e.context,"created_at":e.created_at} for e in monitor.list_events()],'latest':ldict}) return self.sendj({'error':'not_found','path':path},404) except Exception as e: monitor.record_failure('do_GET', str(e), {'path':path}) 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 in ('/api/branch','/api/create-branch'): return self.branch(data) if path in ('/api/file','/api/commit-file'): return self.file(data) if path == '/api/commit': return self.commit(data) if path in ('/api/patch','/api/create-patch'): return self.patch(data) if path in ('/api/tag','/api/create-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: monitor.record_failure('do_POST', str(e), {'path':path}) 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 data.get('confirm') != True: self.sendj({'status':'confirm_required','operation':'tag','hint':'This operation creates a Git tag. Confirm to proceed.','x-openai-isConsequential':True}, 428) return 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()}) if data.get('confirm') != True: self.sendj({'status':'confirm_required','operation':'model','hint':'This operation registers a new model. Confirm to proceed.','x-openai-isConsequential':True}, 428) return 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 if data.get('confirm') != True: self.sendj({'status':'confirm_required','operation':'model-priority','hint':'This operation updates model priority. Confirm to proceed.','x-openai-isConsequential':True}, 428) 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()