feat(automation): support qualified single-user Windows operation
This commit is contained in:
+188
-22
@@ -18,6 +18,7 @@ import tempfile
|
||||
import time
|
||||
import uuid
|
||||
import xml.etree.ElementTree as ET
|
||||
import local_mode
|
||||
|
||||
|
||||
class GateError(RuntimeError):
|
||||
@@ -82,6 +83,16 @@ def read_json(path):
|
||||
return json.loads(Path(path).read_text(encoding='utf-8-sig'))
|
||||
|
||||
|
||||
def recorded_process_exists(marker):
|
||||
pid=marker.get('pid')
|
||||
if not isinstance(pid,int) or pid <= 0:
|
||||
raise GateError('Launch intent has no valid PID; operator must inspect all runner children before clearing it')
|
||||
p=subprocess.run(['tasklist','/FI',f'PID eq {pid}','/FO','CSV','/NH'],capture_output=True,text=True,timeout=15)
|
||||
if p.returncode:
|
||||
raise GateError('Could not verify recorded process termination')
|
||||
return str(pid) in p.stdout
|
||||
|
||||
|
||||
def redact(text):
|
||||
if text.lstrip().startswith(('{', '[')):
|
||||
try:
|
||||
@@ -398,6 +409,8 @@ class ProcessRunner:
|
||||
return [engine.config['codexExecutable'], *args]
|
||||
|
||||
def capability_args(self, engine):
|
||||
if local_mode.single(engine.config) and engine.config.get('trustedCodexOverrides'):
|
||||
raise GateError('single-user-local plugin/MCP capabilities require separate qualification')
|
||||
result = []
|
||||
for item in engine.config.get('trustedCodexOverrides', []):
|
||||
if not re.fullmatch(r'(?:mcp_servers|plugins)\.[A-Za-z0-9_.-]+', item['key']):
|
||||
@@ -405,13 +418,24 @@ class ProcessRunner:
|
||||
result += ['-c', item['key'] + '=' + item['toml']]
|
||||
return result
|
||||
|
||||
def permission_args(self, engine, command):
|
||||
if local_mode.single(engine.config):
|
||||
return local_mode.permission_args(engine.root,engine.config,command)
|
||||
if command == 'exec':
|
||||
return ['--sandbox','workspace-write','-c','approval_policy="never"',
|
||||
'-c','sandbox_workspace_write.network_access=false']
|
||||
return ['-c','sandbox_mode="workspace-write"','-c','approval_policy="never"',
|
||||
'-c','sandbox_workspace_write.network_access=false']
|
||||
|
||||
def supervise(self, engine, argv, directory, seconds, stdin=None, cwd=None):
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
env = {k: v for k, v in os.environ.items() if k.upper() in {
|
||||
'PATH', 'SYSTEMROOT', 'WINDIR', 'TEMP', 'TMP', 'USERPROFILE', 'LOCALAPPDATA',
|
||||
'APPDATA', 'PROGRAMFILES', 'PROGRAMFILES(X86)', 'COMSPEC', 'PATHEXT', 'HOME'}}
|
||||
'APPDATA', 'PROGRAMFILES', 'PROGRAMFILES(X86)', 'COMSPEC', 'PATHEXT', 'HOME','SYSTEMDRIVE'}}
|
||||
env['CODEX_HOME'] = engine.config['workerCodeXHome']
|
||||
env['JOBBJAKT_ORCHESTRATED'] = '1'
|
||||
if local_mode.single(engine.config):
|
||||
env['TEMP'] = env['TMP'] = engine.config['workerTemp']
|
||||
# Explicitly do not inherit production credentials, Docker/SSH sockets or API key variables.
|
||||
flags = (subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW | 0x4) if os.name == 'nt' else 0
|
||||
started = time.monotonic()
|
||||
@@ -496,10 +520,11 @@ class ProcessRunner:
|
||||
raise GateError(log_error[0])
|
||||
|
||||
def run_session(self, engine, stage, checkpoint, directory, prompt):
|
||||
if local_mode.single(engine.config):
|
||||
local_mode.require_unattended(engine.root,engine.config)
|
||||
result = directory / 'result.json'
|
||||
argv = self.codex_command(engine, 'exec', '--ignore-user-config',
|
||||
'--sandbox', 'workspace-write', '-c', 'approval_policy="never"',
|
||||
'-c', 'sandbox_workspace_write.network_access=false',
|
||||
*self.permission_args(engine,'exec'),
|
||||
'-c', 'features.multi_agent=false', *self.capability_args(engine), '--json', '--color', 'never',
|
||||
'--output-schema', str(engine.root / 'schemas/result.schema.json'),
|
||||
'--output-last-message', str(result), '--cd', str(engine.repo), '-')
|
||||
@@ -528,6 +553,8 @@ class ProcessRunner:
|
||||
return data
|
||||
|
||||
def run_check(self, engine, check, directory):
|
||||
if local_mode.single(engine.config):
|
||||
local_mode.require_unattended(engine.root,engine.config)
|
||||
working = engine.repo if check['cwd'] == '.' else under(engine.repo, check['cwd'])
|
||||
report_spec = check.get('report')
|
||||
report = None
|
||||
@@ -535,8 +562,7 @@ class ProcessRunner:
|
||||
report = under(engine.repo, report_spec['path'])
|
||||
report.unlink(missing_ok=True) # old evidence cannot satisfy a new run
|
||||
argv = self.codex_command(engine, 'sandbox', '-C', str(working),
|
||||
'-c', 'sandbox_mode="workspace-write"', '-c', 'approval_policy="never"',
|
||||
'-c', 'sandbox_workspace_write.network_access=false', *check['argv'])
|
||||
*self.permission_args(engine,'sandbox'), *check['argv'])
|
||||
self.supervise(engine, argv, directory, engine.config['maxCheckSeconds'], cwd=working)
|
||||
result = dict(id=check['id'], exitCode=0, passed=None, failed=0, report=None)
|
||||
if report:
|
||||
@@ -563,7 +589,7 @@ class Engine:
|
||||
self.deadline = float('inf')
|
||||
|
||||
def git(self, *args):
|
||||
p = subprocess.run(['git', '-c', 'core.hooksPath=', '-c', 'credential.interactive=false',
|
||||
p = subprocess.run([self.config.get('gitExecutable','git'), '-c', 'core.hooksPath=', '-c', 'credential.interactive=false',
|
||||
'-C', str(self.repo), *args], text=True, capture_output=True,
|
||||
encoding='utf-8', timeout=60,
|
||||
env={**os.environ, 'GIT_TERMINAL_PROMPT': '0'})
|
||||
@@ -630,7 +656,8 @@ class Engine:
|
||||
return True
|
||||
|
||||
def policy_hash(self):
|
||||
files = ['controller.py', 'Approve-JobjaktBacklog.ps1', 'config.json', 'schemas/result.schema.json']
|
||||
files = ['controller.py', 'local_mode.py', 'Probe-JobjaktSandbox.ps1',
|
||||
'Approve-JobjaktBacklog.ps1', 'config.json', 'schemas/result.schema.json']
|
||||
return digest({name: sha_file(self.root / name) for name in files if (self.root / name).exists()})
|
||||
|
||||
def verify_approval(self, request):
|
||||
@@ -652,6 +679,10 @@ class Engine:
|
||||
raise GateError('Control plane and worker checkout must be disjoint')
|
||||
if os.name != 'nt':
|
||||
raise GateError('Real execution currently qualifies Windows hosts only')
|
||||
if local_mode.single(self.config):
|
||||
_,sid = local_mode.detect_identity()
|
||||
local_mode.validate_identity(self.config,sid)
|
||||
local_mode.require_unattended(self.root,self.config)
|
||||
certificate = under(self.root, self.config['publicKeyCertificate'])
|
||||
if not certificate.is_file() or sha_file(certificate) != self.config['ownerCertificateSha256']:
|
||||
raise GateError('Owner public certificate is missing or differs from protected configuration pin')
|
||||
@@ -660,7 +691,7 @@ class Engine:
|
||||
raise GateError('Positive integer resource budgets required')
|
||||
identity = subprocess.run(['whoami', '/user', '/fo', 'csv', '/nh'], capture_output=True,
|
||||
text=True, timeout=15).stdout
|
||||
if (self.config['runnerSid'] not in identity or self.config['ownerSid'] in identity
|
||||
if not local_mode.single(self.config) and (self.config['runnerSid'] not in identity or self.config['ownerSid'] in identity
|
||||
or not re.fullmatch(r'S-1-[0-9-]+', self.config['runnerSid'])):
|
||||
raise GateError('Run under the configured dedicated runner identity, separate from signing owner')
|
||||
version = subprocess.run([self.config['codexExecutable'], '--version'], capture_output=True,
|
||||
@@ -679,7 +710,12 @@ class Engine:
|
||||
for name in ('.mcp.json', '.codex/config.toml'):
|
||||
if (self.repo / name).exists():
|
||||
raise GateError('Repository MCP/Codex config needs removal from isolated runner clone')
|
||||
self.probe_sandbox()
|
||||
if local_mode.single(self.config):
|
||||
report = local_mode.qualify(self)
|
||||
if report['status'] != 'PASS' or report['ownerKeyDenied'] is not True:
|
||||
raise GateError('Native MXC/key qualification failed; operator-controlled pause required')
|
||||
else:
|
||||
self.probe_sandbox()
|
||||
|
||||
def probe_sandbox(self):
|
||||
# A real negative write probe, with a harmless disposable sentinel in the protected root.
|
||||
@@ -1126,14 +1162,16 @@ def status(engine):
|
||||
testsPassed=sum(x.get('passed') or 0 for x in checks), testsFailed=sum(x.get('failed') or 0 for x in checks),
|
||||
testState='VERIFIED' if checks else 'NOT_RUN',
|
||||
reports=ss['reports']))
|
||||
return dict(paused=state['paused'] or (engine.root / 'pause.request').exists(),
|
||||
return dict(mode=engine.config.get('mode','dedicated-runner'),
|
||||
executionPolicy=engine.config.get('executionPolicy','dedicated-runner'),
|
||||
paused=state['paused'] or (engine.root / 'pause.request').exists(),
|
||||
overall='COMPLETED_DEVELOPMENT' if all(s['status'] == 'COMPLETED' for s in stages) else 'GATED',
|
||||
integrationCommit=state['integrationCommit'], currentGitBranch=engine.git('branch', '--show-current'),
|
||||
productionRelease='NOT_AUTHORISED', stages=stages, latestFailure=state['latestFailure'],
|
||||
recentSessions=state['recentSessions'][-10:], recoveryRequired=state['recoveryRequired'])
|
||||
|
||||
|
||||
def install(source, root, repository):
|
||||
def install(source, root, repository, mode='dedicated-runner'):
|
||||
root = root.resolve()
|
||||
repo = Path(repository).resolve()
|
||||
if root.exists():
|
||||
@@ -1141,14 +1179,18 @@ def install(source, root, repository):
|
||||
if root.is_relative_to(repo) or repo.is_relative_to(root):
|
||||
raise GateError('Install control plane outside worker checkout')
|
||||
root.mkdir(parents=True)
|
||||
for name in ('controller.py', 'backlog.json', 'config.example.json'):
|
||||
for name in ('controller.py', 'local_mode.py', 'backlog.json', 'config.example.json'):
|
||||
shutil.copyfile(source / name, root / name)
|
||||
for name in ('schemas', 'prompts'):
|
||||
shutil.copytree(source / name, root / name)
|
||||
shutil.copyfile(source.parent / 'scripts/Approve-JobjaktBacklog.ps1', root / 'Approve-JobjaktBacklog.ps1')
|
||||
shutil.copyfile(source.parent / 'scripts/Run-JobjaktBacklog.ps1', root / 'Run-JobjaktBacklog.ps1')
|
||||
for name in ('Probe-JobjaktSandbox.ps1','Use-JobjaktBacklog.ps1','Register-JobjaktBacklogTask.ps1'):
|
||||
shutil.copyfile(source.parent/'scripts'/name,root/name)
|
||||
config = read_json(root / 'config.example.json')
|
||||
config['repository'] = str(repo)
|
||||
if mode == 'single-user-local':
|
||||
config = local_mode.configure_current_user(root,config)
|
||||
atomic(root / 'config.json', config)
|
||||
manifest = read_json(root / 'backlog.json')
|
||||
Engine(root, manifest, config).initialize()
|
||||
@@ -1157,23 +1199,151 @@ def install(source, root, repository):
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('command', choices=['init', 'validate', 'dry-run', 'start', 'resume', 'status',
|
||||
'pause', 'retry', 'current', 'logs', 'recover', 'configure'])
|
||||
'pause', 'retry', 'current', 'logs', 'recover', 'configure',
|
||||
'setup','auth','auth-status','qualify','approve','prepare','key','enable-unattended'])
|
||||
parser.add_argument('--control-root', type=Path, required=True)
|
||||
parser.add_argument('--repository')
|
||||
parser.add_argument('--stage')
|
||||
parser.add_argument('--manifest', type=Path)
|
||||
args = parser.parse_args(argv)
|
||||
root = args.control_root.resolve()
|
||||
if args.command == 'init':
|
||||
if args.command in ('init','setup'):
|
||||
if not args.repository:
|
||||
parser.error('init requires --repository (isolated development clone/worktree)')
|
||||
install(Path(__file__).resolve().parent, root, args.repository)
|
||||
if args.command == 'setup' and (root/'config.json').exists():
|
||||
existing = read_json(root/'config.json')
|
||||
state = StateStore(root).read()
|
||||
source = Path(__file__).resolve().parent
|
||||
if (local_mode.single(existing) and source != root and not (root/'active-process.json').exists()
|
||||
and not read_json(root/'backlog.json')['executionEnabled']
|
||||
and not any(s['sessions'] for s in state['stages'].values())):
|
||||
with RepoLock(Path(existing['repository'])):
|
||||
for name in ('controller.py','local_mode.py'):
|
||||
shutil.copyfile(source/name,root/name)
|
||||
for name in ('Run-JobjaktBacklog.ps1','Approve-JobjaktBacklog.ps1','Probe-JobjaktSandbox.ps1',
|
||||
'Use-JobjaktBacklog.ps1','Register-JobjaktBacklogTask.ps1'):
|
||||
shutil.copyfile(source.parent/'scripts'/name,root/name)
|
||||
print(json.dumps(dict(existingInstallation=str(root),mode=existing.get('mode','dedicated-runner'),
|
||||
note='Disabled unused single-user installation may refresh code; state/auth/policy preserved; requalify after code changes'),indent=2))
|
||||
return 0
|
||||
install(Path(__file__).resolve().parent, root, args.repository,
|
||||
mode='single-user-local' if args.command == 'setup' else 'dedicated-runner')
|
||||
print('Initialized disabled queue. Review protected config, identities, source gaps and gates before authorization.')
|
||||
return 0
|
||||
engine = Engine(root, read_json(root / 'backlog.json'), read_json(root / 'config.json'))
|
||||
if args.command in ('validate', 'dry-run'):
|
||||
if args.command in ('key','enable-unattended'):
|
||||
if not local_mode.single(engine.config) or not sys.stdin.isatty() or os.environ.get('JOBBJAKT_ORCHESTRATED'):
|
||||
raise GateError('Run this single-user operator action yourself from an interactive PowerShell terminal')
|
||||
with RepoLock(engine.repo):
|
||||
state=engine.store.read()
|
||||
if (root/'active-process.json').exists() or state['recoveryRequired'] or any(s['status']=='RUNNING' for s in state['stages'].values()):
|
||||
raise GateError('Stop/reconcile workers before changing signing or execution settings')
|
||||
_,sid=local_mode.detect_identity()
|
||||
local_mode.validate_identity(engine.config,sid)
|
||||
if args.command == 'key':
|
||||
if engine.config.get('ownerCertificateThumbprint'):
|
||||
print('Owner certificate already configured; it will not be replaced automatically')
|
||||
return 0
|
||||
created=subprocess.run([engine.config['powershellExecutable'],'-NoProfile','-File',
|
||||
str(root/'Approve-JobjaktBacklog.ps1'),'-Action','NewKey'],capture_output=True,text=True,check=True)
|
||||
metadata=json.loads(created.stdout)
|
||||
if metadata['OwnerSid'] != sid:
|
||||
raise GateError('Signing certificate was created under an unexpected identity')
|
||||
certificate=root/'owner.cer'
|
||||
subprocess.run([engine.config['powershellExecutable'],'-NoProfile','-File',str(root/'Approve-JobjaktBacklog.ps1'),
|
||||
'-Action','ExportPublicKey','-Thumbprint',metadata['Thumbprint'],'-CertificatePath',str(certificate)],check=True)
|
||||
engine.config.update(ownerCertificateThumbprint=metadata['Thumbprint'],publicKeyCertificate='owner.cer',
|
||||
ownerCertificateSha256=sha_file(certificate))
|
||||
atomic(root/'config.json',engine.config)
|
||||
print('Nonexportable owner key configured under your existing login. Run qualify to test that exact key. No phase authorized.')
|
||||
else:
|
||||
proposed={**engine.config,'executionPolicy':'qualified-unattended'}
|
||||
local_mode.require_unattended(root,proposed)
|
||||
if input('Type ENABLE to allow qualified unattended workers (roadmap approval still separate): ').strip() != 'ENABLE':
|
||||
raise GateError('Unattended selection cancelled')
|
||||
engine.config.update(executionPolicy='qualified-unattended')
|
||||
atomic(root/'config.json',engine.config)
|
||||
print('Qualified unattended policy selected. Queue activation and phase entry grants are still required.')
|
||||
elif args.command in ('auth','auth-status'):
|
||||
if not local_mode.single(engine.config):
|
||||
raise GateError('Use the single-user-local setup for the managed auth-only profile')
|
||||
local_mode.validate_config(root,engine.config)
|
||||
if args.command == 'auth' and (not sys.stdin.isatty() or os.environ.get('JOBBJAKT_ORCHESTRATED')):
|
||||
raise GateError('Run auth yourself from a PowerShell terminal; no token copying or background login')
|
||||
env = {**os.environ,'CODEX_HOME':engine.config['workerCodeXHome']}
|
||||
result = subprocess.run(local_mode.auth_command(engine.config,args.command == 'auth-status'),env=env,
|
||||
capture_output=args.command == 'auth-status',text=True)
|
||||
if args.command == 'auth-status':
|
||||
kind = 'CHATGPT_SIGNED_IN' if result.returncode == 0 and 'chatgpt' in (result.stdout+result.stderr).lower() else 'NOT_SIGNED_IN_WITH_CHATGPT'
|
||||
print(json.dumps(dict(authentication=kind,profile=engine.config['workerCodeXHome'],
|
||||
sameAccount='Sign in with your existing ChatGPT account; identity not inferred or copied')))
|
||||
return result.returncode
|
||||
elif args.command == 'qualify':
|
||||
if not local_mode.single(engine.config):
|
||||
raise GateError('qualify is for single-user-local; legacy preflight is unchanged')
|
||||
with RepoLock(engine.repo):
|
||||
state = engine.store.read()
|
||||
if (root/'active-process.json').exists() or any(s['status']=='RUNNING' for s in state['stages'].values()):
|
||||
raise GateError('Stop/reconcile all workers before qualification')
|
||||
report = local_mode.qualify(engine)
|
||||
print(json.dumps(report,indent=2))
|
||||
return 0 if report['status']=='PASS' else 3
|
||||
elif args.command == 'approve':
|
||||
if not sys.stdin.isatty() or os.environ.get('JOBBJAKT_ORCHESTRATED'):
|
||||
raise GateError('Human approval requires your interactive terminal, never an autonomous subprocess')
|
||||
with RepoLock(engine.repo):
|
||||
state = engine.store.read()
|
||||
pending = next((ss['awaiting'][0] for ss in state['stages'].values() if ss['awaiting']),None)
|
||||
if not pending:
|
||||
raise GateError('No pending owner request')
|
||||
request = Path(pending['request'])
|
||||
recovery = (pending['kind']=='recovery-reconciled' and read_json(request).get('kind')=='recovery-reconciled')
|
||||
if (state['recoveryRequired'] or any(s['status']=='RUNNING' for s in state['stages'].values())) and not recovery:
|
||||
raise GateError('Only the exact recovery request may be approved during reconciliation')
|
||||
if (root/'active-process.json').exists():
|
||||
if not recovery or recorded_process_exists(read_json(root/'active-process.json')):
|
||||
raise GateError('Stop and verify the recorded process tree before approval')
|
||||
_,sid = local_mode.detect_identity()
|
||||
if sid != engine.config['ownerSid']:
|
||||
raise GateError('Wrong owner identity')
|
||||
print(request.read_text(encoding='utf-8'))
|
||||
if recovery and input('Type RECONCILED after inspecting that the recorded worker and all descendants are gone: ').strip() != 'RECONCILED':
|
||||
raise GateError('Operator reconciliation not confirmed')
|
||||
challenge = uuid.uuid4().hex[:8]
|
||||
if input('Type '+challenge+' to review/sign this exact request: ').strip() != challenge:
|
||||
raise GateError('Approval cancelled')
|
||||
disposition = input('Disposition (Approve/Revise/Reject): ').strip()
|
||||
if disposition not in ('Approve','Revise','Reject'):
|
||||
raise GateError('Invalid disposition')
|
||||
decision = input('Your scope, design selection or revision: ').strip()
|
||||
if not decision:
|
||||
raise GateError('Concrete owner decision required')
|
||||
thumb = engine.config.get('ownerCertificateThumbprint')
|
||||
if not thumb:
|
||||
raise GateError('Configure your owner signing certificate first; no writable-JSON approval fallback exists')
|
||||
result = subprocess.run([engine.config['powershellExecutable'],'-NoProfile','-File',
|
||||
str(root/'Approve-JobjaktBacklog.ps1'),'-Action','Sign','-RequestPath',str(request),
|
||||
'-ApprovalPath',str(root/'approvals'/request.name),'-Thumbprint',thumb,'-ExpectedOwnerSid',sid,
|
||||
'-Disposition',disposition,'-Decision',decision])
|
||||
return result.returncode
|
||||
elif args.command == 'prepare':
|
||||
with RepoLock(engine.repo):
|
||||
state = engine.store.read()
|
||||
stage = next(s for s in engine.manifest['stages'] if state['stages'][s['id']]['status']!='COMPLETED')
|
||||
ss = state['stages'][stage['id']]
|
||||
cp = stage['checkpoints'][min(ss['checkpoint'],len(stage['checkpoints'])-1)]
|
||||
path = root/'operator-review.txt'
|
||||
path.write_text('INSPECTION ONLY. This file grants no Phase 10 or execution authorization.\n'+
|
||||
engine.prompt(stage,cp,ss,state),encoding='utf-8')
|
||||
print('Operator-controlled review prepared at '+str(path)+'; no worker/test/Git operation launched')
|
||||
elif args.command in ('validate', 'dry-run'):
|
||||
validate_manifest(engine.manifest, root)
|
||||
report = dict(configuration='PASS', executionEnabled=engine.manifest['executionEnabled'],
|
||||
if local_mode.single(engine.config):
|
||||
local_mode.validate_config(root,engine.config)
|
||||
report = dict(configuration='PASS',mode=engine.config.get('mode','dedicated-runner'),
|
||||
executionPolicy=engine.config.get('executionPolicy','dedicated-runner'),
|
||||
runtimeReady=False if local_mode.single(engine.config) else None,
|
||||
executionEnabled=engine.manifest['executionEnabled'],
|
||||
sourceGaps=[dict(id=s['id'], status=s['sourceStatus']) for s in engine.manifest['stages'] if s['sourceStatus'] != 'VERIFIED'],
|
||||
unqualifiedAdapters=sorted({check['id'] for s in engine.manifest['stages']
|
||||
for cp in s['checkpoints'] for check in cp['checks'] if not check.get('configured', True)}),
|
||||
@@ -1238,11 +1408,7 @@ def main(argv=None):
|
||||
return 3
|
||||
# Operator must first prove process tree is gone. Never kill an unverified reused PID.
|
||||
if marker:
|
||||
if not marker.get('pid'):
|
||||
raise GateError('Launch intent has no PID: operator must inspect all runner children and clear marker before signed recovery')
|
||||
p = subprocess.run(['tasklist', '/FI', f'PID eq {marker["pid"]}', '/FO', 'CSV', '/NH'],
|
||||
capture_output=True, text=True, timeout=15)
|
||||
if str(marker['pid']) in p.stdout:
|
||||
if recorded_process_exists(marker):
|
||||
raise GateError('Recorded process may still exist; inspect identity/tree before owner stops it')
|
||||
(root / 'active-process.json').unlink()
|
||||
state['recoveryRequired'] = False
|
||||
|
||||
Reference in New Issue
Block a user