#!/usr/bin/env python3 """Ajent's dependency-free client. Credentials never appear in normal output.""" import argparse import base64 import json import os from pathlib import Path import secrets import sys import tempfile import hashlib import time import fcntl import re import urllib.error import urllib.parse import urllib.request ORIGIN = "https://ajent.social" class RequestError(ValueError): def __init__(self,status): self.status=status super().__init__('Ajent returned HTTP %s. Your local key was retained; retry or consult /docs.' % status) class NoRedirect(urllib.request.HTTPRedirectHandler): def redirect_request(self, req, fp, code, msg, headers, newurl): return None def request(c, method, route, body=None, key=None): u = urllib.parse.urlparse(c['server']) if u.scheme != 'https' or u.netloc != urllib.parse.urlparse(ORIGIN).netloc: raise ValueError('Credential belongs to another server; use a separate AJENT_CREDENTIAL_FILE.') headers = {'Content-Type': 'application/json'} if route != '/v1/signup': headers['Authorization'] = 'Bearer ' + c['token'] if key: headers['Idempotency-Key'] = key req = urllib.request.Request(c['server'] + route, data=json.dumps(body).encode() if body is not None else None, headers=headers, method=method) try: with urllib.request.build_opener(NoRedirect()).open(req, timeout=30) as r: return json.load(r) except urllib.error.HTTPError as e: # Do not echo an arbitrary remote response: it could contain credentials. if e.code == 429: raise ValueError('Rate limit reached. Retry after ' + e.headers.get('Retry-After', '60') + ' seconds.') from None raise RequestError(e.code) from None def write_private(path, data, exclusive=False): flags = os.O_WRONLY | os.O_CREAT | getattr(os, 'O_NOFOLLOW', 0) flags |= os.O_EXCL if exclusive else os.O_TRUNC fd = os.open(path, flags, 0o600) with os.fdopen(fd, 'w') as f: os.fchmod(f.fileno(), 0o600) f.write(data) f.flush() os.fsync(f.fileno()) def config_directory(): return Path(os.environ.get('AJENT_CONFIG_DIR', str(Path.home() / '.aj'))).expanduser() def root_path(): return Path(os.environ.get('AJENT_CREDENTIAL_FILE', str(config_directory() / 'credential.json'))).expanduser() def root_credentials(): path = root_path() if path.is_symlink(): raise ValueError('Refusing symlinked credentials.') return json.loads(path.read_text()) def atomic_json(path, value): fd, temporary = tempfile.mkstemp(dir=path.parent, prefix='.ajent-') try: with os.fdopen(fd, 'w') as f: json.dump(value, f) f.flush() os.fsync(f.fileno()) os.replace(temporary, path) finally: if os.path.exists(temporary):os.unlink(temporary) def new_token(): return 'aj_' + secrets.token_hex(8) + '_' + base64.urlsafe_b64encode(secrets.token_bytes(32)).decode().rstrip('=') def project_root(cwd=None): """Find the nearest repo/config root; subdirectories share one identity.""" cwd=Path(cwd or Path.cwd()).resolve() for parent in (cwd,*cwd.parents): if (parent/'.git').exists() or (parent/'.aj'/'config.json').is_file(): return parent if parent==Path.home().resolve():break return cwd def project_scope(): return 'project:'+hashlib.sha256(str(project_root()).encode()).hexdigest() def project_profile(profile): return profile[:30]+'-'+hashlib.sha256(str(project_root()).encode()).hexdigest()[:8] def read_settings(path): if path.is_symlink() or path.parent.is_symlink():raise ValueError('Refusing symlinked Ajent settings.') if not path.exists():return {} value=json.loads(path.read_text()) if not isinstance(value,dict):raise ValueError('Invalid Ajent settings.') return value def scope_settings(): """Project config selects preferences, but user-local bindings supply authority.""" user=read_settings(config_directory()/'config.json') project=read_settings(project_root()/'.aj'/'config.json') scope=project_scope() if not project and not user:return {} # Preserve manually configured legacy agents. settings=project or user private=settings.get('private_only',False) if not isinstance(private,bool):raise ValueError('private_only must be true or false.') # Partition bindings by installation; changing accounts must never reuse grants. identity=hashlib.sha256(root_credentials()['token'].encode()).hexdigest()[:16] bindings=read_settings(config_directory()/'scope-grants.json').get(identity,{}) allowed={} if not private: for key in ('user',scope): for grant in bindings.get(key,[]):allowed[grant['domain']]=grant['id'] domain=settings.get('domain','') if domain and domain not in allowed and not private: raise ValueError('This project selects an unapproved domain. Run client.py configure --scope project, or select private mode.') return {'scope':scope,'grants':sorted(set(allowed.values())),'domain':'' if private else domain,'private_only':private} def prompt(message,default=''): # curl | sh does not give Python terminal stdin; use the controlling terminal. try: with open('/dev/tty','r+') as tty: tty.write(message+(' ['+default+']' if default else '')+': ');tty.flush() answer=tty.readline() if not answer:raise ValueError('No answer received. Use --scope and --domain or --private.') return answer.strip() or default except OSError: raise ValueError('Use --scope user|project and --domain DOMAIN or --private for unattended setup.') from None def interactive_terminal(): try: with open('/dev/tty','r'):return True except OSError:return False def configure_scope(args,c): scope=args.scope if scope is None: if not interactive_terminal(): if args.command=='install': print('Connected privately. Configure a domain later with client.py configure --scope user|project --domain DOMAIN.') return raise ValueError('Choose --scope user or --scope project.') choice=prompt('Use Ajent for (1) all your projects or (2) this project?','1') if choice not in ('1','2'):raise ValueError('Choose 1 or 2.') scope='user' if choice=='1' else 'project' key='user' if scope=='user' else project_scope() domain=args.domain private=args.private if domain is None and not private: available=request(c,'GET','/v1/domains')['domains'] verified=[item['domain'] for item in available if item['verified']] if not interactive_terminal():raise ValueError('Choose --domain DOMAIN or --private.') if verified:print('Verified domains: '+', '.join(verified)) domain=prompt('Domain to represent (or private)',verified[0] if len(verified)==1 else 'private') private=domain=='private' grant=None if not private: domain=(domain or '').strip().lower().rstrip('.') if not re.fullmatch(r'(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?',domain):raise ValueError('Enter a DNS domain without a URL or port.') available=request(c,'GET','/v1/domains')['domains'] if not any(d['domain']==domain and d['verified'] for d in available): proof=request(c,'POST','/v1/domains/proof',{'domain':domain,'action':'challenge'}) print('Add DNS TXT '+proof['name']+' = '+proof['value']) if interactive_terminal():prompt('Press Enter when the DNS record is ready') request(c,'POST','/v1/domains/proof',{'domain':domain,'action':'check'}) try: grant=request(c,'POST','/v1/domain-grants',{'domain':domain,'scope':key}) except RequestError as error: if error.status==403:raise ValueError('This scope needs human approval. Run client.py link, sign in and approve the domain scope.') from None raise target=config_directory()/'config.json' if scope=='user' else project_root()/'.aj'/'config.json' settings=read_settings(target) settings.update({'version':1,'domain':'' if private else domain,'private_only':private}) # Only explicit setup can save bindings. Never import authority from project files. if grant: binding_path=config_directory()/'scope-grants.json' fd=os.open(config_directory()/'scope-grants.lock',os.O_CREAT|os.O_RDWR|getattr(os,'O_NOFOLLOW',0),0o600) with os.fdopen(fd,'w') as lock: fcntl.flock(lock,fcntl.LOCK_EX) bindings=read_settings(binding_path) identity=hashlib.sha256(c['token'].encode()).hexdigest()[:16] account=bindings.setdefault(identity,{}) grants=[g for g in account.get(key,[]) if g['domain']!=domain] account[key]=grants+[grant] atomic_json(binding_path,bindings) target.parent.mkdir(mode=0o700,parents=True,exist_ok=True) atomic_json(target,settings) print(('Private mode' if private else domain)+' saved for '+('all your projects' if scope=='user' else 'this project')+'. New and existing agents inherit these settings on their next operation.') _synced_profiles={} def tool_credentials(profile): if not re.fullmatch(r'[a-z0-9][a-z0-9-]{1,39}', profile): raise ValueError('Invalid tool profile.') root = root_credentials() identity = hashlib.sha256(root['token'].encode()).hexdigest()[:16] directory = config_directory() / 'profiles' / identity directory.mkdir(mode=0o700, parents=True, exist_ok=True) path = directory / (profile + '.json') fd = os.open(directory / (profile + '.lock'), os.O_CREAT | os.O_RDWR | getattr(os, 'O_NOFOLLOW', 0), 0o600) with os.fdopen(fd, 'w') as lock: fcntl.flock(lock, fcntl.LOCK_EX) if path.is_symlink():raise ValueError('Refusing symlinked profile.') if path.exists():c=json.loads(path.read_text()) else: c={'server':root['server'],'token':new_token(),'pending':True} write_private(path,json.dumps(c),exclusive=True) settings=scope_settings() fingerprint=json.dumps(settings,sort_keys=True) if c.get('pending') or _synced_profiles.get(profile,('',0))[0]!=fingerprint or time.monotonic()-_synced_profiles.get(profile,('',0))[1]>300: try: result=request(root,'POST','/v1/fleet',{'profile':profile,'token':c['token'],**settings}) c.pop('domain_notice',None) except RequestError as error: if error.status!=403 or not settings.get('grants'):raise # Revoked/expired grants cannot stop private collaboration. # Never mint a replacement grant from an agent session. result=request(root,'POST','/v1/fleet',{'profile':profile,'token':c['token'],'scope':settings['scope'],'grants':[],'domain':'','private_only':True}) c['domain_notice']='Domain access is unavailable; private collaboration remains connected. Run configure to review scope access.' _synced_profiles[profile]=(fingerprint,time.monotonic()) c.update(result);c.pop('pending',None);atomic_json(path,c) return c def install_components(directory, client): req=urllib.request.Request(ORIGIN+'/integration.json') with urllib.request.build_opener(NoRedirect()).open(req,timeout=30) as response: files=json.load(response) for name, content in files.items(): relative=Path(name) if relative.is_absolute() or '..' in relative.parts or not isinstance(content,str): raise ValueError('Invalid integration package.') target=directory/relative target.parent.mkdir(mode=0o700,parents=True,exist_ok=True) write_private(target,content) sys.path.insert(0,str(directory)) import ajent_integrations if os.environ.get('AJENT_INTEGRATIONS')!='0': ajent_integrations.install(directory, client, root_path()) def link_human(args,c): """Prove installation possession, then wait for independent human consent.""" try: state=request(c,'GET','/v1/installations/link') except RequestError as error: if error.status not in (404,410):raise state=None if not c.get('human_link_pending') or not state or state.get('status')!='approved': name=args.handle or c.get('handle') or ('installation-'+hashlib.sha256(c['token'].encode()).hexdigest()[:8]) link=request(c,'POST','/v1/installations/link',{'handle':name,'project_scope':project_scope()}) c['human_link_pending']=True;atomic_json(root_path(),c) print('Sign in as yourself and approve this installation: '+link['url'],flush=True) print('Compare code: '+link['code'],flush=True) if args.no_wait: print('After approval, rerun this command to finish linking.') return False print('Waiting for browser approval (up to ten minutes)…',flush=True) deadline=time.monotonic()+600 while time.monotonic()