#!/usr/bin/env python3 """Initialize one private control runtime and optionally its own user service.""" import argparse import ipaddress import json import os from pathlib import Path import secrets import shlex import subprocess import sys def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--repo', required=True) parser.add_argument('--runtime', required=True) parser.add_argument('--bind', required=True) parser.add_argument('--port', type=int, default=8796) parser.add_argument('--hostname', required=True) parser.add_argument('--install-user-service', action='store_true') parser.add_argument('--start', action='store_true') args = parser.parse_args() if os.name != 'posix': raise ValueError('Service setup requires the authorized Linux service account') address = ipaddress.IPv4Address(args.bind) if not any(address in ipaddress.ip_network(net) for net in ['10.0.0.0/8','172.16.0.0/12','192.168.0.0/16','127.0.0.0/8']): raise ValueError('Explicit LAN or loopback IPv4 required') if not 1024 <= args.port <= 65535: raise ValueError('Unprivileged port required') if any(c.isspace() for c in args.hostname) or any(c in args.hostname for c in '/:@'): raise ValueError('Invalid hostname') repo, runtime = Path(args.repo).resolve(), Path(args.runtime).expanduser().resolve() if not (repo/'agent_control/server.py').is_file(): raise ValueError('Control service source is missing from this repository') if not runtime.is_relative_to(Path.home()): raise ValueError('Runtime must be within the service account home') runtime.mkdir(parents=True, exist_ok=True, mode=0o700) info = runtime.stat() if info.st_uid != os.getuid() or info.st_mode & 0o077: raise ValueError('Runtime must be owned by current account with mode 700') token = runtime/'access-token' if token.is_symlink(): raise ValueError('Token cannot be a symlink') if not token.exists(): fd = os.open(str(token), os.O_CREAT|os.O_EXCL|os.O_WRONLY, 0o600) with os.fdopen(fd,'w') as stream: stream.write(secrets.token_urlsafe(48)) config_path = runtime/'config.json' config = {'bind':args.bind,'port':args.port,'runtime_root':str(runtime),'token_file':str(token), 'allowed_hosts':[args.hostname+':'+str(args.port)],'metrics':{}, 'title':'Agent Workbench','primary_session_id':'human-progress'} if config_path.is_symlink(): raise ValueError('Config cannot be a symlink') if config_path.exists(): old = json.loads(config_path.read_text()) if any(old.get(k) != config[k] for k in ['bind','port','runtime_root','token_file']): raise ValueError('Existing runtime configuration differs; inspect it before explicit changes') else: fd = os.open(str(config_path), os.O_CREAT|os.O_EXCL|os.O_WRONLY, 0o600) with os.fdopen(fd,'w') as stream: json.dump(config,stream,ensure_ascii=False,indent=2) if args.start and not args.install_user_service: raise ValueError('--start requires --install-user-service') if args.install_user_service: # This is a user-owned unit, never a system service and never sudo. units = Path.home()/'.config/systemd/user' units.mkdir(parents=True,exist_ok=True) unit = units/'agent-workbench.service' # systemd accepts double-quoted paths, independently of shell quoting. quote = lambda value: '"'+str(value).replace('\\','\\\\').replace('"','\\"').replace('%','%%')+'"' if '\n' in str(repo) or '\r' in str(repo): raise ValueError('Invalid repository path') content = ('[Unit]\nDescription=Agent Workbench\nAfter=network.target\n\n' '[Service]\nType=simple\nWorkingDirectory='+str(repo).replace('%','%%')+'\nExecStart='+quote(sys.executable)+ ' -m agent_control.server --config '+quote(config_path)+ '\nRestart=on-failure\nRestartSec=5\nUMask=0077\nNoNewPrivileges=true\n\n' '[Install]\nWantedBy=default.target\n') previous = content.replace('WorkingDirectory='+str(repo).replace('%','%%'), 'WorkingDirectory='+quote(repo)) if unit.exists() and unit.read_text() not in (content,previous): raise ValueError('Existing user unit differs; inspect before replacement') unit.write_text(content) subprocess.run(['systemctl','--user','daemon-reload'],check=True) if args.start: subprocess.run(['systemctl','--user','enable','--now',unit.name],check=True) print(json.dumps({'config':str(config_path),'token_file':str(token),'url':'http://'+args.hostname+':'+str(args.port), 'user_service_started':bool(args.start)},ensure_ascii=False)) if __name__ == '__main__': main()