Ported from authority branch agent/g7-security-critical-closure-20260914 (d5f5c9b, base=beabd053).
S1 auth: role/user from server token->identity mapping, ignore client X-AIOS-* headers.
S2 bootstrap credential: PBKDF2 random-salt, fail-closed SECRET, no plaintext fallback, session HMAC+expiry.
S3 path containment + control-plane file-read hardening.
S4 admin UI XSS + real governed approval + dashboard CSRF/branch validation.
S5 runtime delivery: registry.yaml conflict, openapi securityScheme, install.sh fail propagation, do_POST shadowing.
Tests: 5 negative suites (66 tests). Evidence pack in deliverables/g7-evidence/.
297 lines
32 KiB
Python
Executable File
297 lines
32 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
from __future__ import annotations
|
|
import json, os, re, sqlite3, subprocess, time, uuid, hashlib, urllib.parse, html
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from release_version import validate_release_version
|
|
|
|
ROOT = Path(os.getenv('AIOS_GITOPS_ROOT', '/data/aios-gitops'))
|
|
WORKSPACE = Path(os.getenv('AIOS_GITOPS_WORKSPACE', str(ROOT / 'workspace')))
|
|
DB_PATH = Path(os.getenv('AIOS_GITOPS_DB', str(ROOT / 'runtime' / 'gitops_model.sqlite3')))
|
|
SECRETS_DIR = Path(os.getenv('AIOS_GITOPS_SECRETS_DIR', str(ROOT / 'runtime' / 'secrets')))
|
|
TOKEN_FILE = Path(os.getenv('AIOS_GITOPS_TOKEN_FILE', str(SECRETS_DIR / 'gitops.tokens')))
|
|
HOST = os.getenv('AIOS_GITOPS_HOST', '127.0.0.1')
|
|
PORT = int(os.getenv('AIOS_GITOPS_PORT', '18890'))
|
|
ENTRY = os.getenv('AIOS_ENTRY', 'https://cnnetmkt.top')
|
|
DEV_ALLOW = os.getenv('AIOS_GITOPS_DEV_ALLOW', '0') == '1'
|
|
BASE_MODELS = [
|
|
{'id':'base.deepseekv4-flash','provider':'one-api','model':'deepseekv4-flash','display':'DeepSeek V4 Flash','tier':'regular','reasoning':1,'vision':0,'tools':1,'context':128000},
|
|
{'id':'base.qwen3.6-35b','provider':'one-api','model':'qwen3.6-35b','display':'Qwen 3.6 35B','tier':'pro','reasoning':1,'vision':0,'tools':1,'context':262144},
|
|
{'id':'base.gpt-5.5','provider':'openai','model':'gpt-5.5','display':'GPT-5.5','tier':'ultra','reasoning':1,'vision':1,'tools':1,'context':256000},
|
|
]
|
|
|
|
def now() -> str: return time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())
|
|
def uid(prefix='id') -> str: return f'{prefix}-{int(time.time()*1000)}-{uuid.uuid4().hex[:8]}'
|
|
def esc(x: Any) -> str: return html.escape('' if x is None else str(x))
|
|
def safe_name(s: str) -> str: return re.sub(r'[^a-zA-Z0-9_.@-]+', '_', s or 'unknown')[:120]
|
|
def db():
|
|
DB_PATH.parent.mkdir(parents=True, exist_ok=True); con = sqlite3.connect(DB_PATH); con.row_factory = sqlite3.Row; return con
|
|
def rows(sql, args=()):
|
|
with db() as con: return [dict(r) for r in con.execute(sql, args).fetchall()]
|
|
def one(sql, args=()):
|
|
r = rows(sql, args); return r[0] if r else None
|
|
def exec_sql(sql, args=()):
|
|
with db() as con: con.execute(sql, args); con.commit()
|
|
def audit(actor, action, target='', payload=None): exec_sql('INSERT INTO model_audit_logs(id,actor,action,target,payload,created_at) VALUES(?,?,?,?,?,?)',(uid('audit'), actor, action, target, json.dumps(payload or {}, ensure_ascii=False), now()))
|
|
|
|
def init_db():
|
|
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
schema = Path(__file__).resolve().parents[1] / 'migrations' / '004_gitops_custom_llm_runtime.sql'
|
|
with db() as con:
|
|
if schema.exists(): con.executescript(schema.read_text(encoding='utf-8'))
|
|
else:
|
|
con.executescript('''CREATE TABLE IF NOT EXISTS gitops_baseline(id TEXT PRIMARY KEY,active_version TEXT,active_tag TEXT,source_package TEXT,entry TEXT,update_mode TEXT,status TEXT,created_at TEXT,updated_at TEXT);CREATE TABLE IF NOT EXISTS gitops_versions(version TEXT PRIMARY KEY,tag TEXT,level TEXT,summary TEXT,status TEXT,created_by TEXT,created_at TEXT);CREATE TABLE IF NOT EXISTS gitops_patches(id TEXT PRIMARY KEY,patch_name TEXT,branch TEXT,base_ref TEXT,target_version TEXT,risk TEXT,status TEXT,created_by TEXT,created_at TEXT,applied_at TEXT,result TEXT);CREATE TABLE IF NOT EXISTS llm_providers(id TEXT PRIMARY KEY,owner_username TEXT,name TEXT,provider_type TEXT,base_url TEXT,auth_mode TEXT,api_key_secret_ref TEXT,scope TEXT,status TEXT,created_at TEXT,updated_at TEXT);CREATE TABLE IF NOT EXISTS llm_models(id TEXT PRIMARY KEY,provider_id TEXT,owner_username TEXT,model_name TEXT,display_name TEXT,context_window INTEGER,input_modalities TEXT,output_modalities TEXT,supports_tools INTEGER,supports_vision INTEGER,supports_reasoning INTEGER,cost_tier TEXT,is_base INTEGER,scope TEXT,status TEXT,created_at TEXT,updated_at TEXT, UNIQUE(owner_username, provider_id, model_name));CREATE TABLE IF NOT EXISTS user_model_priority(username TEXT,model_id TEXT,priority INTEGER,enabled INTEGER,fallback_policy TEXT,updated_at TEXT,PRIMARY KEY(username,model_id));CREATE TABLE IF NOT EXISTS model_route_decisions(id TEXT PRIMARY KEY,username TEXT,task_type TEXT,selected_model_id TEXT,candidate_order TEXT,reason TEXT,created_at TEXT);CREATE TABLE IF NOT EXISTS user_runtime_profiles(username TEXT PRIMARY KEY,role TEXT,tier TEXT,runtime_mode TEXT,allow_dedicated INTEGER,allow_custom_llm INTEGER,updated_at TEXT);CREATE TABLE IF NOT EXISTS runtime_instances(id TEXT PRIMARY KEY,username TEXT,runtime_type TEXT,backend TEXT,status TEXT,endpoint TEXT,last_seen_at TEXT,created_at TEXT,metadata TEXT);CREATE TABLE IF NOT EXISTS model_audit_logs(id TEXT PRIMARY KEY,actor TEXT,action TEXT,target TEXT,payload TEXT,created_at TEXT);''')
|
|
con.commit()
|
|
seed_base_models()
|
|
|
|
def seed_base_models():
|
|
exec_sql("INSERT OR IGNORE INTO llm_providers(id,owner_username,name,provider_type,base_url,auth_mode,api_key_secret_ref,scope,status,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)", ('provider.one-api','system','AIOS One API','openai_compatible','http://127.0.0.1:13000/v1','bearer','secret://one-api/global','global','active',now(),now()))
|
|
exec_sql("INSERT OR IGNORE INTO llm_providers(id,owner_username,name,provider_type,base_url,auth_mode,api_key_secret_ref,scope,status,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)", ('provider.openai','system','OpenAI Official','openai_compatible','https://api.openai.com/v1','bearer','secret://openai/global','global','active',now(),now()))
|
|
for m in BASE_MODELS:
|
|
pid = 'provider.openai' if m['provider']=='openai' else 'provider.one-api'
|
|
exec_sql('''INSERT OR IGNORE INTO llm_models(id,provider_id,owner_username,model_name,display_name,context_window,input_modalities,output_modalities,supports_tools,supports_vision,supports_reasoning,cost_tier,is_base,scope,status,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)''', (m['id'], pid, 'system', m['model'], m['display'], m['context'], '["text"]', '["text"]', m['tools'], m['vision'], m['reasoning'], m['tier'], 1, 'global', 'active', now(), now()))
|
|
if not one('SELECT * FROM gitops_baseline WHERE id=?', ('current',)):
|
|
exec_sql('INSERT INTO gitops_baseline(id,active_version,active_tag,source_package,entry,update_mode,status,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?)', ('current','V1R1C0','V1R1C0-baseline-20260706','AIOS_V1R1C0_MULTIUSER_GITOPS_CUSTOM_LLM_PROD_FULL_DEPLOY_20260706.zip',ENTRY,'git_patch_only','active',now(),now()))
|
|
if not one('SELECT * FROM gitops_versions WHERE version=?', ('V1R1C0',)):
|
|
exec_sql('INSERT INTO gitops_versions(version,tag,level,summary,status,created_by,created_at) VALUES(?,?,?,?,?,?,?)', ('V1R1C0','V1R1C0','R','MultiUser runtime selector + GitOps + custom LLM priority registry','ready','system',now()))
|
|
|
|
_ROLE_WHITELIST = {'owner', 'admin', 'user'}
|
|
def token_identity(token: str):
|
|
"""Resolve the server-authoritative identity bound to a token.
|
|
|
|
Format of each TOKEN_FILE line (one per line, ':'-separated):
|
|
<token> -> authenticated principal, role 'user'
|
|
<token>:<user>:<role> -> mapped principal; role restricted to owner/admin/user
|
|
Identity NEVER comes from a client-supplied header. In local dev mode the
|
|
DEV_ALLOW flag maps a fixed dev principal (must be explicitly enabled).
|
|
"""
|
|
if DEV_ALLOW:
|
|
return {'username': 'dev-owner', 'role': 'owner'}
|
|
if not token or not TOKEN_FILE.exists():
|
|
return None
|
|
for line in TOKEN_FILE.read_text().splitlines():
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
parts = [x.strip() for x in line.split(':')]
|
|
if not parts or parts[0] != token:
|
|
continue
|
|
if len(parts) >= 3:
|
|
role = parts[2] if parts[2] in _ROLE_WHITELIST else 'user'
|
|
return {'username': safe_name(parts[1]), 'role': role}
|
|
return {'username': 'user-%s' % token[:8], 'role': 'user'}
|
|
return None
|
|
|
|
def valid_token(token: str) -> bool:
|
|
if DEV_ALLOW: return True
|
|
return token_identity(token) is not None
|
|
def git_cmd(args, check=False):
|
|
WORKSPACE.mkdir(parents=True, exist_ok=True); p = subprocess.run(['git'] + args, cwd=str(WORKSPACE), text=True, capture_output=True)
|
|
if check and p.returncode != 0: raise RuntimeError(p.stderr or p.stdout or 'git command failed')
|
|
return {'code': p.returncode, 'stdout': p.stdout.strip(), 'stderr': p.stderr.strip(), 'cmd': ['git']+args}
|
|
def model_visible_to(username: str): return rows('''SELECT m.*, p.name provider_name, p.base_url, p.provider_type, p.scope provider_scope FROM llm_models m JOIN llm_providers p ON p.id=m.provider_id WHERE m.status='active' AND (m.scope='global' OR m.owner_username=? OR p.owner_username=?) ORDER BY m.is_base DESC, m.created_at ASC''', (username, username))
|
|
def priority_for(username: str): return rows('''SELECT pr.username, pr.model_id, pr.priority, pr.enabled, pr.fallback_policy, pr.updated_at, m.model_name, m.display_name, m.is_base, m.owner_username, p.base_url, p.name provider_name FROM user_model_priority pr JOIN llm_models m ON m.id=pr.model_id JOIN llm_providers p ON p.id=m.provider_id WHERE pr.username=? AND m.status='active' ORDER BY pr.priority ASC''', (username,))
|
|
def route_model(username: str, task_type='chat'):
|
|
ordered = [r for r in priority_for(username) if int(r.get('enabled') or 0) == 1]
|
|
if not ordered:
|
|
ordered = [m for m in model_visible_to(username) if int(m.get('is_base') or 0) == 1]
|
|
selected = dict(ordered[0]) if ordered else None
|
|
if selected and 'id' not in selected and selected.get('model_id'):
|
|
selected['id'] = selected.get('model_id')
|
|
reason = 'user_priority' if priority_for(username) else 'role_default_base_model'
|
|
candidate_ids = [(x.get('id') or x.get('model_id')) for x in ordered]
|
|
selected_id = selected.get('id') if selected else None
|
|
exec_sql('INSERT INTO model_route_decisions(id,username,task_type,selected_model_id,candidate_order,reason,created_at) VALUES(?,?,?,?,?,?,?)',(uid('route'), username, task_type, selected_id, json.dumps(candidate_ids, ensure_ascii=False), reason, now()))
|
|
return {'username': username, 'task_type': task_type, 'selected': selected, 'candidate_order': ordered, 'reason': reason}
|
|
def parse_body(handler):
|
|
n = int(handler.headers.get('Content-Length','0') or '0')
|
|
if n <= 0: return {}
|
|
raw = handler.rfile.read(n).decode('utf-8')
|
|
try: return json.loads(raw or '{}')
|
|
except Exception: return {'_raw': raw}
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
server_version = 'AIOSGitOpsModelAPI/V1R1C0'
|
|
def actor(self):
|
|
auth = self.headers.get('Authorization',''); token = auth.replace('Bearer ', '').strip() if auth.startswith('Bearer ') else self.headers.get('X-AIOS-Token','')
|
|
ident = token_identity(token)
|
|
if ident is None:
|
|
return {'username': 'anonymous', 'role': 'user', 'token_ok': False}
|
|
return {'username': ident['username'], 'role': ident['role'], 'token_ok': True}
|
|
def require_write(self, roles=None):
|
|
a = self.actor()
|
|
if not a['token_ok']: self.send_json({'error':'unauthorized','hint':'write APIs require Bearer token or AIOS_GITOPS_DEV_ALLOW=1'}, 401); return None
|
|
if roles and a['role'] not in roles: self.send_json({'error':'forbidden','required_roles':roles,'role':a['role']}, 403); return None
|
|
return a
|
|
def send_json(self, data, code=200):
|
|
b = json.dumps(data, ensure_ascii=False, indent=2).encode('utf-8'); 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 send_html(self, title, body, code=200):
|
|
links = '<a href="/base">base</a> <a href="/ver">ver</a> <a href="/diff">diff</a> <a href="/patch">patch</a> <a href="/run">run</a> <a href="/model">model</a> <a href="/api">api</a>'
|
|
html_doc = f'''<!doctype html><html><head><meta charset="utf-8"><title>{esc(title)}</title><style>body{{font-family:Arial,"Microsoft YaHei",sans-serif;margin:24px;background:#f8fafc}}a{{margin-right:12px}}.card{{background:white;border:1px solid #e5e7eb;border-radius:12px;padding:16px;margin:12px 0}}table{{border-collapse:collapse;width:100%;background:white}}td,th{{border:1px solid #ddd;padding:6px;text-align:left;vertical-align:top}}pre{{background:#0f172a;color:#e2e8f0;padding:12px;border-radius:8px;overflow:auto}}</style></head><body><h1>{esc(title)}</h1><div class="card">{links}</div>{body}</body></html>'''
|
|
b = html_doc.encode('utf-8'); self.send_response(code); self.send_header('Content-Type','text/html; charset=utf-8'); self.send_header('Content-Length',str(len(b))); self.end_headers(); self.wfile.write(b)
|
|
def table(self, data):
|
|
if not data: return '<p>No records.</p>'
|
|
cols = list(data[0].keys()); return '<table><thead><tr>'+''.join(f'<th>{esc(c)}</th>' for c in cols)+'</tr></thead><tbody>'+''.join('<tr>'+''.join(f'<td>{esc(r.get(c))}</td>' for c in cols)+'</tr>' for r in data)+'</tbody></table>'
|
|
def do_GET(self):
|
|
init_db(); u = urllib.parse.urlparse(self.path); path = u.path; q = urllib.parse.parse_qs(u.query); username = self.actor()['username']
|
|
try:
|
|
if path in ('/health','/api/health'): self.send_json({'status':'ok','version':'V1R1C0','entry':ENTRY,'workspace':str(WORKSPACE),'db':str(DB_PATH)}); return
|
|
if path in ('/api/status','/api'):
|
|
if path == '/api': self.send_html('AIOS V1R1C0 API', '<pre>'+esc(json.dumps(api_spec(),ensure_ascii=False,indent=2))+'</pre>'); return
|
|
self.send_json({'version':'V1R1C0','git':git_cmd(['status','--short']),'baseline':one('SELECT * FROM gitops_baseline WHERE id=?',('current',))}); return
|
|
if path in ('/api/base','/base'):
|
|
data = one('SELECT * FROM gitops_baseline WHERE id=?',('current',)) or {}; self.send_html('Baseline', '<pre>'+esc(json.dumps(data,ensure_ascii=False,indent=2))+'</pre>') if path == '/base' else self.send_json(data); return
|
|
if path in ('/api/ver','/ver'):
|
|
data = rows('SELECT * FROM gitops_versions ORDER BY created_at DESC'); self.send_html('Versions', self.table(data)) if path == '/ver' else self.send_json(data); return
|
|
if path in ('/api/patch','/patch'):
|
|
data = rows('SELECT * FROM gitops_patches ORDER BY created_at DESC'); self.send_html('Patches', self.table(data)) if path == '/patch' else self.send_json(data); return
|
|
if path in ('/api/diff','/diff'):
|
|
frm = (q.get('from') or q.get('from_') or ['HEAD~1'])[0]; to = (q.get('to') or ['HEAD'])[0]; diff = git_cmd(['diff', frm, to])
|
|
self.send_html('Diff', f'<p>from={esc(frm)} to={esc(to)}</p><pre>{esc(diff["stdout"] or diff["stderr"])}</pre>') if path == '/diff' else self.send_json({'from':frm,'to':to,'diff':diff}); return
|
|
if path in ('/api/model/base','/api/model/custom','/api/model/priority','/model'):
|
|
if path == '/api/model/base': self.send_json([m for m in model_visible_to(username) if int(m.get('is_base') or 0)==1]); return
|
|
if path == '/api/model/custom': self.send_json([m for m in model_visible_to(username) if int(m.get('is_base') or 0)==0]); return
|
|
if path == '/api/model/priority': self.send_json(priority_for(username)); return
|
|
body = '<h2>Base Models</h2>'+self.table([m for m in model_visible_to(username) if int(m.get('is_base') or 0)==1]); body += '<h2>Custom Models</h2>'+self.table([m for m in model_visible_to(username) if int(m.get('is_base') or 0)==0]); body += '<h2>Priority</h2>'+self.table(priority_for(username)); self.send_html('Model Registry', body); return
|
|
if path in ('/api/runtime/status','/run'):
|
|
data = rows('SELECT * FROM runtime_instances ORDER BY created_at DESC LIMIT 100'); self.send_html('Runtime', self.table(data)) if path == '/run' else self.send_json(data); return
|
|
if path in ('/hook','/api/hook'):
|
|
self.send_html('Git Webhook', '<pre>'+esc(json.dumps({'status':'ready','endpoint':'/hook/git','version':'V1R1C0'}, ensure_ascii=False, indent=2))+'</pre>'); return
|
|
if path == '/api/audit': self.send_json(rows('SELECT * FROM model_audit_logs ORDER BY created_at DESC LIMIT 200')); return
|
|
self.send_json({'error':'not_found','path':path},404)
|
|
except Exception as e: self.send_json({'error':str(e)},500)
|
|
def do_POST(self):
|
|
init_db(); path = urllib.parse.urlparse(self.path).path; data = parse_body(self)
|
|
try:
|
|
if path == '/api/model/custom/add':
|
|
a = self.require_write();
|
|
if not a: return
|
|
username = safe_name(data.get('username') or a['username']); provider_name = data.get('provider_name') or data.get('name') or 'custom-provider'; base_url = (data.get('base_url') or '').rstrip('/'); model_name = data.get('model_name') or data.get('model')
|
|
if not base_url or not model_name: self.send_json({'error':'base_url and model_name required'},400); return
|
|
secret_ref = data.get('api_key_secret_ref')
|
|
if data.get('api_key'):
|
|
sdir = SECRETS_DIR / 'llm' / safe_name(username); sdir.mkdir(parents=True, exist_ok=True); secret_id = uid('llmkey'); sp = sdir / f'{secret_id}.secret'; sp.write_text(str(data['api_key']), encoding='utf-8'); os.chmod(sp, 0o600); secret_ref = f'secret://llm/{safe_name(username)}/{secret_id}'
|
|
provider_id = data.get('provider_id') or f'provider.custom.{safe_name(username)}.{hashlib.sha1((provider_name+base_url).encode()).hexdigest()[:8]}'; model_id = data.get('model_id') or f'custom.{safe_name(username)}.{hashlib.sha1((provider_id+model_name).encode()).hexdigest()[:10]}'
|
|
exec_sql('''INSERT OR REPLACE INTO llm_providers(id,owner_username,name,provider_type,base_url,auth_mode,api_key_secret_ref,scope,status,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)''',(provider_id, username, provider_name, data.get('provider_type','openai_compatible'), base_url, data.get('auth_mode','bearer'), secret_ref, data.get('scope','user'), 'active', now(), now()))
|
|
exec_sql('''INSERT OR REPLACE INTO llm_models(id,provider_id,owner_username,model_name,display_name,context_window,input_modalities,output_modalities,supports_tools,supports_vision,supports_reasoning,cost_tier,is_base,scope,status,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)''',(model_id, provider_id, username, model_name, data.get('display_name') or model_name, int(data.get('context_window') or 0), json.dumps(data.get('input_modalities') or ['text'],ensure_ascii=False), json.dumps(data.get('output_modalities') or ['text'],ensure_ascii=False), int(bool(data.get('supports_tools',1))), int(bool(data.get('supports_vision',0))), int(bool(data.get('supports_reasoning',0))), data.get('cost_tier','custom'), 0, data.get('scope','user'), 'active', now(), now()))
|
|
if 'priority' in data: exec_sql('INSERT OR REPLACE INTO user_model_priority(username,model_id,priority,enabled,fallback_policy,updated_at) VALUES(?,?,?,?,?,?)',(username, model_id, int(data.get('priority')), int(bool(data.get('enabled',1))), data.get('fallback_policy','try_next_on_error'), now()))
|
|
audit(a['username'],'custom_model_add',model_id,{'username':username,'provider_id':provider_id,'model_name':model_name}); self.send_json({'status':'ok','provider_id':provider_id,'model_id':model_id,'secret_ref':secret_ref}); return
|
|
if path == '/api/model/custom/update':
|
|
a = self.require_write();
|
|
if not a: return
|
|
model_id = data.get('model_id'); m = one('SELECT * FROM llm_models WHERE id=?',(model_id,))
|
|
if not m: self.send_json({'error':'model not found'},404); return
|
|
if m['owner_username'] != a['username'] and a['role'] not in ('owner','admin'): self.send_json({'error':'forbidden'},403); return
|
|
fields=[]; args=[]
|
|
for col in ['display_name','status','cost_tier']:
|
|
if col in data: fields.append(f'{col}=?'); args.append(data[col])
|
|
for col in ['context_window','supports_tools','supports_vision','supports_reasoning']:
|
|
if col in data: fields.append(f'{col}=?'); args.append(int(bool(data[col])) if col.startswith('supports') else int(data[col]))
|
|
fields.append('updated_at=?'); args.append(now()); args.append(model_id); exec_sql(f'UPDATE llm_models SET {", ".join(fields)} WHERE id=?', args); audit(a['username'],'custom_model_update',model_id,data); self.send_json({'status':'updated','model_id':model_id}); return
|
|
if path == '/api/model/delete':
|
|
a = self.require_write();
|
|
if not a: return
|
|
model_id = data.get('model_id'); m = one('SELECT * FROM llm_models WHERE id=?',(model_id,))
|
|
if not m: self.send_json({'error':'model not found'},404); return
|
|
if int(m.get('is_base') or 0)==1: self.send_json({'error':'base model cannot be deleted'},400); return
|
|
if m['owner_username'] != a['username'] and a['role'] not in ('owner','admin'): self.send_json({'error':'forbidden'},403); return
|
|
exec_sql('UPDATE llm_models SET status=?,updated_at=? WHERE id=?',('disabled',now(),model_id)); audit(a['username'],'custom_model_delete',model_id,{}); self.send_json({'status':'disabled','model_id':model_id}); return
|
|
if path == '/api/model/priority/set':
|
|
a = self.require_write();
|
|
if not a: return
|
|
username = safe_name(data.get('username') or a['username'])
|
|
if username != a['username'] and a['role'] not in ('owner','admin'): self.send_json({'error':'forbidden'},403); return
|
|
items = data.get('items') or data.get('priority') or []; exec_sql('DELETE FROM user_model_priority WHERE username=?',(username,)); visible={m['id'] for m in model_visible_to(username)}
|
|
for idx,item in enumerate(items):
|
|
if isinstance(item,str): item={'model_id':item,'priority':(idx+1)*10,'enabled':1}
|
|
mid=item.get('model_id') or item.get('id')
|
|
if mid and mid in visible: exec_sql('INSERT OR REPLACE INTO user_model_priority(username,model_id,priority,enabled,fallback_policy,updated_at) VALUES(?,?,?,?,?,?)',(username, mid, int(item.get('priority',(idx+1)*10)), int(bool(item.get('enabled',1))), item.get('fallback_policy','try_next_on_error'), now()))
|
|
audit(a['username'],'model_priority_set',username,{'items':items}); self.send_json({'status':'updated','username':username,'priority':priority_for(username)}); return
|
|
if path == '/api/model/route/resolve': a=self.actor(); self.send_json(route_model(safe_name(data.get('username') or a['username']), data.get('task_type','chat'))); return
|
|
if path == '/api/model/test':
|
|
a = self.require_write();
|
|
if not a: return
|
|
mid=data.get('model_id'); m=one('SELECT m.*,p.base_url,p.provider_type FROM llm_models m JOIN llm_providers p ON p.id=m.provider_id WHERE m.id=?',(mid,))
|
|
if not m: self.send_json({'error':'model not found'},404); return
|
|
self.send_json({'status':'ok' if m['base_url'] and m['model_name'] else 'failed','live_test':os.getenv('AIOS_ENABLE_MODEL_LIVE_TEST','0')=='1','model':m['model_name'],'base_url':m['base_url'],'note':'Network live call is disabled unless AIOS_ENABLE_MODEL_LIVE_TEST=1'}); return
|
|
if path == '/api/runtime/select':
|
|
a=self.actor(); username=safe_name(data.get('username') or a['username']); role=data.get('role') or a['role']; tier=data.get('tier') or ('ultra' if role=='owner' else 'regular'); risk=data.get('risk','normal')
|
|
if risk in ('high_tool','shell','docker','write_prod'): runtime,backend='ephemeral_sandbox','openclaw_executor'
|
|
elif role in ('owner','admin') or tier=='ultra': runtime,backend='dedicated','hermes'
|
|
elif tier=='pro': runtime,backend='dedicated_or_shared','hermes'
|
|
else: runtime,backend='shared','hermes'
|
|
exec_sql('INSERT OR REPLACE INTO user_runtime_profiles(username,role,tier,runtime_mode,allow_dedicated,allow_custom_llm,updated_at) VALUES(?,?,?,?,?,?,?)',(username,role,tier,runtime,1 if runtime!='shared' else 0,1,now())); self.send_json({'username':username,'role':role,'tier':tier,'runtime_type':runtime,'backend':backend,'reason':'V1R1C0 selector policy'}); return
|
|
if path == '/api/runtime/register':
|
|
a = self.require_write(['owner','admin']);
|
|
if not a: return
|
|
rid=data.get('id') or uid('rt'); exec_sql('INSERT OR REPLACE INTO runtime_instances(id,username,runtime_type,backend,status,endpoint,last_seen_at,created_at,metadata) VALUES(?,?,?,?,?,?,?,?,?)',(rid,safe_name(data.get('username') or 'shared'),data.get('runtime_type','shared'),data.get('backend','hermes'),data.get('status','registered'),data.get('endpoint'),now(),now(),json.dumps(data.get('metadata') or {},ensure_ascii=False))); audit(a['username'],'runtime_register',rid,data); self.send_json({'status':'registered','id':rid}); return
|
|
if path == '/api/runtime/heartbeat': exec_sql('UPDATE runtime_instances SET status=?,last_seen_at=?,metadata=? WHERE id=?',(data.get('status','ok'),now(),json.dumps(data.get('metadata') or {},ensure_ascii=False),data.get('id'))); self.send_json({'status':'ok','id':data.get('id')}); return
|
|
if path == '/api/branch':
|
|
a = self.require_write(['owner','admin']);
|
|
if not a: return
|
|
branch=data.get('branch'); base=data.get('base','main')
|
|
if not branch: self.send_json({'error':'branch required'},400); return
|
|
git_cmd(['fetch','--all']); git_cmd(['checkout',base]); git_cmd(['checkout','-B',branch],check=True); audit(a['username'],'git_branch',branch,data); self.send_json({'status':'ok','branch':branch}); return
|
|
if path == '/api/file':
|
|
a = self.require_write(['owner','admin']);
|
|
if not a: return
|
|
branch=data.get('branch') or 'main'; rel=data.get('path'); content=data.get('content',''); msg=data.get('message') or f'chore(gitops): update {rel}'
|
|
if not rel or rel.startswith('/') or '..' in Path(rel).parts: self.send_json({'error':'invalid path'},400); return
|
|
git_cmd(['checkout',branch],check=True); target=WORKSPACE/rel; target.parent.mkdir(parents=True,exist_ok=True); target.write_text(content,encoding='utf-8'); git_cmd(['add',rel],check=True); commit=git_cmd(['commit','-m',msg]); audit(a['username'],'git_file_update',rel,{'branch':branch,'commit':commit}); self.send_json({'status':'committed','path':rel,'commit':commit}); return
|
|
if path == '/api/commit':
|
|
a = self.require_write(['owner','admin']);
|
|
if not a: return
|
|
msg=data.get('message') or 'chore(gitops): commit changes'; git_cmd(['add','.'],check=True); out=git_cmd(['commit','-m',msg]); audit(a['username'],'git_commit','workspace',{'message':msg}); self.send_json({'status':'ok','commit':out}); return
|
|
if path == '/api/tag':
|
|
a = self.require_write(['owner']);
|
|
if not a: return
|
|
ver=data.get('version') or data.get('tag')
|
|
try:
|
|
validate_release_version(ver)
|
|
except ValueError:
|
|
self.send_json({'error':'version must match VxRxCx'},400)
|
|
return
|
|
msg=data.get('message') or f'release {ver}'; out=git_cmd(['tag','-a',ver,'-m',msg]);
|
|
if data.get('push',False): git_cmd(['push','origin',ver])
|
|
level='V' if re.match(r'^V[1-9]\d*R0C0', ver) else ('R' if re.match(r'^V\d+R[1-9]\d*C0', ver) else 'C'); exec_sql('INSERT OR REPLACE INTO gitops_versions(version,tag,level,summary,status,created_by,created_at) VALUES(?,?,?,?,?,?,?)',(ver,ver,level,msg,'ready',a['username'],now())); exec_sql('UPDATE gitops_baseline SET active_version=?,active_tag=?,updated_at=? WHERE id=?',(ver,ver,now(),'current')); audit(a['username'],'git_tag',ver,{'level':level}); self.send_json({'status':'tagged','version':ver,'git':out}); return
|
|
if path == '/api/rollback':
|
|
a = self.require_write(['owner','admin']);
|
|
if not a: return
|
|
target=data.get('target_version') or data.get('version')
|
|
if not target: self.send_json({'error':'target_version required'},400); return
|
|
out=git_cmd(['checkout',target]); audit(a['username'],'git_rollback',target,{}); self.send_json({'status':'rollback_checkout','target':target,'git':out}); return
|
|
if path == '/api/nl':
|
|
a = self.require_write();
|
|
if not a: return
|
|
text=data.get('text',''); intent='unknown'; plan=[]
|
|
if '回滚' in text or 'rollback' in text.lower(): intent='rollback'; plan=['extract target VxRxCx','call /api/rollback','run smoke test']
|
|
elif '对比' in text or 'diff' in text.lower(): intent='diff'; plan=['extract from/to refs','call /api/diff']
|
|
elif '模型' in text and ('优先' in text or 'priority' in text.lower()): intent='set_model_priority'; plan=['resolve model ids','call /api/model/priority/set']
|
|
elif '模型' in text and ('添加' in text or '新增' in text or 'add' in text.lower()): intent='add_custom_model'; plan=['validate provider','store api key as secret_ref','call /api/model/custom/add']
|
|
elif 'patch' in text.lower() or '补丁' in text: intent='create_patch'; plan=['create patch branch','commit patch metadata','run tests']
|
|
self.send_json({'intent':intent,'action_plan':plan,'execute':False,'safety_check':'V-level changes require owner approval'}); return
|
|
if path in ('/hook/git','/api/hook/git'):
|
|
event_id = uid('hook')
|
|
event_file = ROOT / 'artifacts' / 'git-webhook-events.jsonl'
|
|
event_file.parent.mkdir(parents=True, exist_ok=True)
|
|
event = {
|
|
'id': event_id,
|
|
'received_at': now(),
|
|
'event': self.headers.get('X-Gitea-Event') or self.headers.get('X-GitHub-Event') or data.get('event') or 'unknown',
|
|
'ref': data.get('ref'),
|
|
'repository': (data.get('repository') or {}).get('full_name') if isinstance(data.get('repository'), dict) else data.get('repository'),
|
|
}
|
|
with event_file.open('a', encoding='utf-8') as fh:
|
|
fh.write(json.dumps(event, ensure_ascii=False) + '\n')
|
|
smoke = subprocess.run(['bash','scripts/package_contract_check_v1r1c0.sh'], cwd=str(WORKSPACE), text=True, capture_output=True, timeout=120)
|
|
audit('webhook','git_event',event_id,{'event':event,'smoke_code':smoke.returncode})
|
|
self.send_json({'status':'accepted' if smoke.returncode == 0 else 'smoke_failed','id':event_id,'event':event,'smoke':{'code':smoke.returncode,'stdout':smoke.stdout[-2000:],'stderr':smoke.stderr[-2000:]}}); return
|
|
self.send_json({'error':'not_found','path':path},404)
|
|
except Exception as e: self.send_json({'error':str(e)},500)
|
|
|
|
def api_spec(): return {'version':'V1R1C0','short_links':['/base','/ver','/diff','/patch','/run','/model','/api'],'gitops_get':['/api/status','/api/base','/api/ver','/api/diff','/api/patch'],'gitops_post':['/api/branch','/api/file','/api/commit','/api/tag','/api/rollback','/api/nl'],'model_get':['/api/model/base','/api/model/custom','/api/model/priority'],'model_post':['/api/model/custom/add','/api/model/custom/update','/api/model/delete','/api/model/priority/set','/api/model/route/resolve','/api/model/test'],'runtime':['/api/runtime/status','/api/runtime/select','/api/runtime/register','/api/runtime/heartbeat'],'auth':'write APIs require Bearer token unless AIOS_GITOPS_DEV_ALLOW=1 for local acceptance tests'}
|
|
def main(): init_db(); print(f'AIOS V1R1C0 GitOps Model API listening on {HOST}:{PORT}'); ThreadingHTTPServer((HOST, PORT), Handler).serve_forever()
|
|
if __name__ == '__main__': main()
|