feat(automation): support qualified single-user Windows operation

This commit is contained in:
cesnimda
2026-10-29 18:27:17 +01:00
parent c1663df44c
commit 1e6c80f708
30 changed files with 2213 additions and 807 deletions
+188 -22
View File
@@ -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