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
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
"""Same-login setup and native MXC qualification. Never grants roadmap authorization."""
|
||||
import csv
|
||||
from datetime import datetime, timezone, timedelta
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
PROFILE = 'jobjakt-local'
|
||||
CATEGORIES = {'configuration', 'approvals', 'key-file', 'personal', 'normal-codex', 'other-repository', 'production-canary'}
|
||||
|
||||
|
||||
def single(config):
|
||||
return config.get('mode') == 'single-user-local'
|
||||
|
||||
|
||||
def detect_identity():
|
||||
result = subprocess.run(['whoami','/user','/fo','csv','/nh'],capture_output=True,text=True,check=True,timeout=15)
|
||||
username,sid = next(csv.reader(result.stdout.splitlines()))
|
||||
return username,sid
|
||||
|
||||
|
||||
def validate_identity(config, sid):
|
||||
if not sid.startswith('S-1-') or config['ownerSid'] != sid or config['runnerSid'] != sid:
|
||||
raise ValueError('single-user-local requires the configured current Windows identity for both owner and runner')
|
||||
|
||||
|
||||
def validate_config(root, config):
|
||||
root = Path(root).resolve()
|
||||
repo = Path(config['repository']).resolve()
|
||||
auth = Path(config['workerCodeXHome']).resolve()
|
||||
normal = Path(config['normalCodeXHome']).resolve()
|
||||
temporary = Path(config['workerTemp']).resolve()
|
||||
if root.is_relative_to(repo) or repo.is_relative_to(root):
|
||||
raise ValueError('ControlRoot and worker must be disjoint')
|
||||
if auth == normal or auth.is_relative_to(repo) or not auth.is_relative_to(root):
|
||||
raise ValueError('Dedicated auth-only CODEX_HOME must be inside ControlRoot, outside worker, distinct from normal auth')
|
||||
if (temporary.is_relative_to(root) or temporary.is_relative_to(normal) or temporary == repo
|
||||
or root.is_relative_to(temporary) or normal.is_relative_to(temporary)):
|
||||
raise ValueError('Use a separate disposable worker temp directory')
|
||||
for name in config.get('toolReadRoots',[]):
|
||||
tool=Path(name).resolve()
|
||||
if any(tool.is_relative_to(private) or private.is_relative_to(tool) for private in (root,normal)):
|
||||
raise ValueError('Tool roots cannot reopen protected control or normal Codex paths')
|
||||
if config.get('trustedCodexOverrides'):
|
||||
raise ValueError('single-user-local plugin/MCP access is disabled until independently qualified')
|
||||
if config.get('executionPolicy') not in ('operator-controlled','qualified-unattended'):
|
||||
raise ValueError('Unknown single-user execution policy')
|
||||
for name in ('config.toml','plugins','skills','hooks.json','AGENTS.md'):
|
||||
if (auth / name).exists():
|
||||
raise ValueError('Auth-only CODEX_HOME contains unrelated config, hooks, skills or plugins')
|
||||
for name in ('.codex/config.toml','.mcp.json'):
|
||||
if (repo / name).exists():
|
||||
raise ValueError('Worker has an unqualified repository Codex/MCP configuration')
|
||||
|
||||
|
||||
def filesystem_rules(root, config):
|
||||
repo = Path(config['repository']).resolve()
|
||||
rules = {':root':'deny', ':minimal':'read', repo.as_posix():'write',
|
||||
Path(config['workerTemp']).resolve().as_posix():'write'}
|
||||
for name in config.get('toolReadRoots',[]):
|
||||
path = Path(name).resolve()
|
||||
if path == Path(path.anchor) or path == Path.home():
|
||||
raise ValueError('Tool read roots cannot grant an entire drive or home directory')
|
||||
rules[path.as_posix()] = 'read'
|
||||
for path in (Path(root),Path(config['workerCodeXHome']),Path(config['normalCodeXHome']),
|
||||
Path.home()/'.ssh',Path.home()/'AppData/Roaming/Microsoft/Crypto'):
|
||||
rules[path.resolve().as_posix()] = 'deny'
|
||||
for name in ('.env','.env.local','.env.production','appsettings.Production.json'):
|
||||
rules[(repo/name).as_posix()] = 'deny'
|
||||
rules[(repo/'.git').as_posix()] = 'read'
|
||||
return rules
|
||||
|
||||
|
||||
def permission_args(root, config, command):
|
||||
validate_config(root,config)
|
||||
table = '{' + ','.join(json.dumps(k)+'='+json.dumps(v) for k,v in filesystem_rules(root,config).items()) + '}'
|
||||
args = ['-c','windows.sandbox="mxc"','-c','approval_policy="never"',
|
||||
'-c','cli_auth_credentials_store="file"','-c','forced_login_method="chatgpt"',
|
||||
'-c','features.apps=false','-c','features.plugins=false','-c','features.remote_plugin=false',
|
||||
'-c','mcp_servers={}','-c','plugins={}','-c','web_search="disabled"',
|
||||
'-c',f'default_permissions="{PROFILE}"',
|
||||
'-c',f'permissions.{PROFILE}.filesystem={table}',
|
||||
'-c',f'permissions.{PROFILE}.network.enabled=false']
|
||||
if command == 'sandbox':
|
||||
args += ['--include-managed-config','--permission-profile',PROFILE,'--']
|
||||
return args
|
||||
|
||||
|
||||
def binding(root,config):
|
||||
value = {k:v for k,v in config.items() if k != 'executionPolicy'}
|
||||
for name in ('local_mode.py','controller.py','Probe-JobjaktSandbox.ps1'):
|
||||
source = Path(root)/name
|
||||
if source.exists():
|
||||
value[name+'Sha256'] = hashlib.sha256(source.read_bytes()).hexdigest()
|
||||
return hashlib.sha256(json.dumps(value,sort_keys=True).encode()).hexdigest()
|
||||
|
||||
|
||||
def probes_passed(result):
|
||||
files = result.get('files',[])
|
||||
return (result.get('positiveControl') is True and result.get('networkDenied') is True
|
||||
and result.get('signingKeyDenied') is True
|
||||
and {f.get('category') for f in files} == CATEGORIES
|
||||
and all(f.get('readDenied') is True and f.get('writeDenied') is True for f in files))
|
||||
|
||||
|
||||
def require_unattended(root,config):
|
||||
validate_config(root,config)
|
||||
if config['executionPolicy'] != 'qualified-unattended':
|
||||
raise ValueError('operator-controlled pause: no autonomous worker is launched; inspect requests and qualify MXC first')
|
||||
path = Path(root)/'qualification.json'
|
||||
if not path.is_file():
|
||||
raise ValueError('Native sandbox qualification is missing')
|
||||
report = json.loads(path.read_text(encoding='utf-8'))
|
||||
issued = datetime.fromisoformat(report['issuedUtc'])
|
||||
now = datetime.now(timezone.utc)
|
||||
if (report.get('status') != 'PASS' or report.get('failure')
|
||||
or report.get('binding') != binding(root,config) or not probes_passed(report.get('probes',{}))
|
||||
or report.get('ownerKeyDenied') is not True
|
||||
or issued > now+timedelta(minutes=5) or now-issued > timedelta(hours=24)):
|
||||
raise ValueError('Native sandbox/key qualification failed, expired or changed; operator-controlled pause required')
|
||||
|
||||
|
||||
def configure_current_user(root,config):
|
||||
username,sid = detect_identity()
|
||||
root = Path(root).resolve()
|
||||
normal = Path(os.environ.get('CODEX_HOME',str(Path.home()/'.codex'))).resolve()
|
||||
config.update(mode='single-user-local',executionPolicy='operator-controlled',
|
||||
windowsUsername=username,ownerSid=sid,runnerSid=sid,normalCodeXHome=str(normal),
|
||||
workerCodeXHome=str(root/'auth'), workerTemp=str(root.parent/(root.name+'-worker-temp')),
|
||||
codexExecutable=shutil.which('codex') or 'codex',gitExecutable=shutil.which('git') or 'git',
|
||||
powershellExecutable=shutil.which('pwsh') or 'pwsh',pythonExecutable=sys.executable,
|
||||
trustedCodexOverrides=[],toolReadRoots=sorted({str(Path(sys.executable).parent),
|
||||
str(Path(shutil.which('pwsh') or sys.executable).parent)}), ownerCertificateThumbprint=None)
|
||||
validate_config(root,config)
|
||||
Path(config['workerCodeXHome']).mkdir(parents=True,exist_ok=True)
|
||||
Path(config['workerTemp']).mkdir(parents=True,exist_ok=True)
|
||||
return config
|
||||
|
||||
|
||||
def auth_command(config, status=False):
|
||||
args = [config['codexExecutable'],'-c','cli_auth_credentials_store="file"',
|
||||
'-c','forced_login_method="chatgpt"','login']
|
||||
return args + (['status'] if status else [])
|
||||
|
||||
|
||||
def qualify(engine):
|
||||
"""Only synthetic canaries and a local TCP listener. No model or production contact."""
|
||||
root,config = engine.root,engine.config
|
||||
validate_config(root,config)
|
||||
_,sid = detect_identity()
|
||||
validate_identity(config,sid)
|
||||
token = uuid.uuid4().hex
|
||||
workspace = engine.repo/'.artifacts/orchestrator-probes'/token
|
||||
outside = root.parent/('orchestrator-probes-'+token)
|
||||
normal = Path(config['normalCodeXHome'])/('.jobjakt-probe-'+token)
|
||||
workspace.mkdir(parents=True)
|
||||
outside.mkdir()
|
||||
paths = []
|
||||
owned = []
|
||||
key = None
|
||||
report = dict(issuedUtc=datetime.now(timezone.utc).isoformat(),binding=binding(root,config),
|
||||
status='BLOCKED',ownerKeyDenied=False,probes={},failure=None)
|
||||
listener = socket.socket()
|
||||
try:
|
||||
for category in sorted(CATEGORIES):
|
||||
path = normal if category == 'normal-codex' else (
|
||||
root/('canary-'+token+'-'+category) if category in ('configuration','approvals','key-file')
|
||||
else outside/category)
|
||||
path.write_text('SYNTHETIC-'+token,encoding='utf-8')
|
||||
owned.append(path)
|
||||
with path.open('r+b'):
|
||||
pass # prove a denial is not a missing file or preexisting host ACL failure
|
||||
paths.append(dict(category=category,path=str(path)))
|
||||
helper = root/'Probe-JobjaktSandbox.ps1'
|
||||
setup = subprocess.run([config['powershellExecutable'],'-NoProfile','-File',str(helper),
|
||||
'-Action','CreateKey','-Tag',token],text=True,capture_output=True,timeout=30)
|
||||
if setup.returncode:
|
||||
raise ValueError('Temporary signing-key positive control failed: '+setup.stderr[:250])
|
||||
key = json.loads(setup.stdout)
|
||||
owner_key = None
|
||||
if config.get('ownerCertificateThumbprint'):
|
||||
positive = subprocess.run([config['powershellExecutable'],'-NoProfile','-File',str(helper),
|
||||
'-Action','TestOwnerKey','-Thumbprint',config['ownerCertificateThumbprint']],
|
||||
text=True,capture_output=True,timeout=30)
|
||||
if positive.returncode:
|
||||
raise ValueError('Configured owner key is not usable in the trusted operator context')
|
||||
owner_key = json.loads(positive.stdout)
|
||||
if owner_key['certificateSha256'] != config['ownerCertificateSha256']:
|
||||
raise ValueError('Configured owner private key does not match the pinned public certificate')
|
||||
listener.bind(('127.0.0.1',0))
|
||||
listener.listen(4)
|
||||
with socket.create_connection(listener.getsockname(),timeout=2):
|
||||
pass
|
||||
accepted,_ = listener.accept()
|
||||
accepted.close()
|
||||
plan = dict(files=paths,port=listener.getsockname()[1],key=key,
|
||||
ownerThumbprint=config.get('ownerCertificateThumbprint'),ownerKeyName=owner_key['keyName'] if owner_key else None)
|
||||
(workspace/'plan.json').write_text(json.dumps(plan),encoding='utf-8')
|
||||
shutil.copyfile(helper,workspace/'probe.ps1')
|
||||
argv = [config['codexExecutable'],'sandbox','-C',str(engine.repo),
|
||||
*permission_args(root,config,'sandbox'),config['powershellExecutable'],'-NoProfile',
|
||||
'-File',str(workspace/'probe.ps1'),'-Action','Probe','-PlanPath',str(workspace/'plan.json')]
|
||||
logs = root/'probe'/token
|
||||
engine.runner.supervise(engine,argv,logs,45)
|
||||
probe = json.loads((logs/'stdout.log').read_text(encoding='utf-8'))
|
||||
report.update(probes=probe,ownerKeyDenied=probe.get('ownerKeyDenied') is True,
|
||||
status='PASS' if probes_passed(probe) else 'FAIL')
|
||||
report['scope'] = 'Synthetic filesystem/network/signing canaries; real owner key '+('probed' if owner_key else 'NOT_CONFIGURED')
|
||||
except Exception as error:
|
||||
report['failure'] = str(error)[:500]
|
||||
finally:
|
||||
listener.close()
|
||||
if key:
|
||||
cleanup = subprocess.run([config['powershellExecutable'],'-NoProfile','-File',
|
||||
str(root/'Probe-JobjaktSandbox.ps1'),'-Action','RemoveKey','-Tag',token,
|
||||
'-Thumbprint',key['thumbprint']],capture_output=True,text=True,timeout=30)
|
||||
if cleanup.returncode:
|
||||
report['status'] = 'BLOCKED'
|
||||
report['failure'] = 'Canary certificate cleanup failed; inspect the recorded synthetic thumbprint'
|
||||
for path in owned:
|
||||
path.unlink(missing_ok=True)
|
||||
# Only the exact disposable directories created above are removed.
|
||||
if workspace.resolve().is_relative_to(engine.repo.resolve()/'.artifacts/orchestrator-probes'):
|
||||
shutil.rmtree(workspace)
|
||||
if outside.parent.resolve() == root.parent.resolve() and outside.name == 'orchestrator-probes-'+token:
|
||||
shutil.rmtree(outside)
|
||||
engine_file = root/'qualification.json'
|
||||
from controller import atomic
|
||||
atomic(engine_file,report)
|
||||
return report
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"features": {
|
||||
"apps": "false",
|
||||
"plugins": "false",
|
||||
"remote_plugin": "false"
|
||||
},
|
||||
"profile": "jobjakt-local",
|
||||
"commandNetworkEnabled": false,
|
||||
"webSearch": "disabled",
|
||||
"mcpServersConfigured": {},
|
||||
"verifiedMcpServerCount": 0
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"file": "automation/tests/test_local_mode.py",
|
||||
"change": "Remove one trailing space after the full regression run",
|
||||
"testedSha256": "197a743c3d796679a84825b43354a1abd503b1158883285be3148839c1098083",
|
||||
"packagedSha256": "524d40ede6379138f729c06876e43b6ef1cb15ee427947f2358a0439cb02ff69",
|
||||
"pythonAstIdentical": true,
|
||||
"additionalTestsCounted": 0
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"binding":"1d4e22171398d8613b854bac311580381a37b3f2684a27120ab2c9ab0338c634","failure":null,"issuedUtc":"2026-10-29T17:11:31.292867+00:00","ownerKeyDenied":false,"probes":{"files":[{"category":"approvals","readDenied":true,"writeDenied":true},{"category":"configuration","readDenied":true,"writeDenied":true},{"category":"key-file","readDenied":true,"writeDenied":true},{"category":"normal-codex","readDenied":true,"writeDenied":true},{"category":"other-repository","readDenied":true,"writeDenied":true},{"category":"personal","readDenied":true,"writeDenied":true},{"category":"production-canary","readDenied":true,"writeDenied":true}],"networkDenied":true,"ownerKeyDenied":false,"positiveControl":true,"signingKeyDenied":true},"scope":"Synthetic filesystem/network/signing canaries; real owner key NOT_CONFIGURED","status":"PASS"}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"testsRun": 69,
|
||||
"failures": [],
|
||||
"errors": [],
|
||||
"skipped": [],
|
||||
"successful": true,
|
||||
"sourceStable": true,
|
||||
"scope": "All 48 original controller tests plus single-user policy/disposable subprocess flows; no real roadmap/model",
|
||||
"sourceHashes": {
|
||||
"automation/controller.py": "25625cb7e1780d3dba438b6d1b60e0be2d1f3af281af8aecd9eccd51a6ac7018",
|
||||
"automation/local_mode.py": "e80b33cd0a915cf432d498e4a6f655ede7fb1b6ddf79ab7b1dee1e5a79d33629",
|
||||
"automation/tests/test_controller.py": "458da3c60571e61aad42987ce156842efd73ef0e2aa0c7bce719d36d626d7e7d",
|
||||
"automation/tests/test_local_mode.py": "197a743c3d796679a84825b43354a1abd503b1158883285be3148839c1098083",
|
||||
"automation/tests/fake_codex.py": "d50bc330392fe7aa15f2141f1ca2cde1bad797674ad9bc74304907f5dbe66cb3",
|
||||
"scripts/Approve-JobjaktBacklog.ps1": "cc941717cd2ae69a0aa14fb4de1d36c1e15f8a26526fb57d2bed307152108aca",
|
||||
"scripts/Probe-JobjaktSandbox.ps1": "39c0892698bb6ded29aed8e052500ab85b9b04f858a6d4fd245c57a77ec333db",
|
||||
"scripts/Run-JobjaktBacklog.ps1": "70679acf9ccf76d0ab8edb6fa8faa01a36fc3d384092804515e3e511b0e22814"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"mode": "single-user-local",
|
||||
"sameWindowsIdentity": true,
|
||||
"authentication": "NOT_SIGNED_IN_WITH_CHATGPT",
|
||||
"tokensCopied": false,
|
||||
"actualOwnerKeyConfigured": false,
|
||||
"executionPolicy": "operator-controlled",
|
||||
"executionEnabled": false,
|
||||
"realSessions": 0,
|
||||
"configuredBaseline": "5032ccb41514807c8ca3008c24ca6326be1170de",
|
||||
"taskRegistration": "REFUSED_UNQUALIFIED_NO_TASK_CREATED"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"exitCode": 0,
|
||||
"cases": [
|
||||
"PASS valid signature and concrete decision",
|
||||
"PASS replayed approval on changed commit",
|
||||
"PASS forged signature",
|
||||
"PASS expired approval",
|
||||
"PASS wrong signing owner",
|
||||
"PASS model-written approval JSON"
|
||||
],
|
||||
"scope": "Temporary in-memory test keys/certificates only"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
PASS valid signature and concrete decision
|
||||
PASS replayed approval on changed commit
|
||||
PASS forged signature
|
||||
PASS expired approval
|
||||
PASS wrong signing owner
|
||||
PASS model-written approval JSON
|
||||
@@ -0,0 +1,74 @@
|
||||
test_accidental_main_checkout_rejected (test_controller.EngineTests.test_accidental_main_checkout_rejected) ... ok
|
||||
test_after_approval_binds_tested_commit_without_rerunning_worker (test_controller.EngineTests.test_after_approval_binds_tested_commit_without_rerunning_worker) ... ok
|
||||
test_changed_prompt_blocks (test_controller.EngineTests.test_changed_prompt_blocks) ... ok
|
||||
test_conditional_can_advance_only_with_explicit_signed_deferral (test_controller.EngineTests.test_conditional_can_advance_only_with_explicit_signed_deferral) ... ok
|
||||
test_conditional_requires_explicit_deferrable_gates (test_controller.EngineTests.test_conditional_requires_explicit_deferrable_gates) ... ok
|
||||
test_corrupt_state_recovers_last_good_without_success (test_controller.EngineTests.test_corrupt_state_recovers_last_good_without_success) ... ok
|
||||
test_cryptographically_approved_resume_e2e (test_controller.EngineTests.test_cryptographically_approved_resume_e2e) ... ok
|
||||
test_dependency_cannot_be_bypassed (test_controller.EngineTests.test_dependency_cannot_be_bypassed) ... ok
|
||||
test_design_revision_reruns_current_checkpoint_and_keeps_owner_selection (test_controller.EngineTests.test_design_revision_reruns_current_checkpoint_and_keeps_owner_selection) ... ok
|
||||
test_disabled_manifest_never_executes (test_controller.EngineTests.test_disabled_manifest_never_executes) ... ok
|
||||
test_double_corruption_fails_closed (test_controller.EngineTests.test_double_corruption_fails_closed) ... ok
|
||||
test_duplicate_lock_rejected (test_controller.EngineTests.test_duplicate_lock_rejected) ... ok
|
||||
test_execution_window_stops_before_worker (test_controller.EngineTests.test_execution_window_stops_before_worker) ... ok
|
||||
test_failed_stage_stops_and_exhausts_retry_budget (test_controller.EngineTests.test_failed_stage_stops_and_exhausts_retry_budget) ... ok
|
||||
test_failing_checks_never_commit_or_advance (test_controller.EngineTests.test_failing_checks_never_commit_or_advance) ... ok
|
||||
test_interruption_is_not_success_and_resume_has_budget (test_controller.EngineTests.test_interruption_is_not_success_and_resume_has_budget) ... ok
|
||||
test_manifest_initially_disabled (test_controller.EngineTests.test_manifest_initially_disabled) ... ok
|
||||
test_missing_approval_does_not_run_model (test_controller.EngineTests.test_missing_approval_does_not_run_model) ... ok
|
||||
test_missing_prompt_blocks (test_controller.EngineTests.test_missing_prompt_blocks) ... ok
|
||||
test_model_cannot_approve_itself (test_controller.EngineTests.test_model_cannot_approve_itself) ... ok
|
||||
test_pause_persists_and_resume_clears_it (test_controller.EngineTests.test_pause_persists_and_resume_clears_it) ... ok
|
||||
test_push_failure_preserves_tested_commit (test_controller.EngineTests.test_push_failure_preserves_tested_commit) ... ok
|
||||
test_push_verification_mismatch_stops (test_controller.EngineTests.test_push_verification_mismatch_stops) ... ok
|
||||
test_recovered_running_without_marker_requires_reconciliation (test_controller.EngineTests.test_recovered_running_without_marker_requires_reconciliation) ... ok
|
||||
test_restart_running_marker_cannot_mean_completed (test_controller.EngineTests.test_restart_running_marker_cannot_mean_completed) ... ok
|
||||
test_same_repo_other_control_root_cannot_take_lock (test_controller.EngineTests.test_same_repo_other_control_root_cannot_take_lock) ... ok
|
||||
test_success_orders_stages_and_verifies_remote (test_controller.EngineTests.test_success_orders_stages_and_verifies_remote) ... ok
|
||||
test_tampered_evidence_cannot_be_approved (test_controller.EngineTests.test_tampered_evidence_cannot_be_approved) ... ok
|
||||
test_unavailable_remote_preserves_commit_and_retries_publication_only (test_controller.EngineTests.test_unavailable_remote_preserves_commit_and_retries_publication_only) ... ok
|
||||
test_unconfigured_test_adapter_blocks_before_model (test_controller.EngineTests.test_unconfigured_test_adapter_blocks_before_model) ... ok
|
||||
test_unsigned_recovery_cannot_clear_running_guard (test_controller.EngineTests.test_unsigned_recovery_cannot_clear_running_guard) ... ok
|
||||
test_forward_dependency_rejected (test_controller.QueueTests.test_forward_dependency_rejected) ... ok
|
||||
test_production_branches_rejected (test_controller.QueueTests.test_production_branches_rejected) ... ok
|
||||
test_real_queue_is_ordered_and_missing_sources_are_explicit (test_controller.QueueTests.test_real_queue_is_ordered_and_missing_sources_are_explicit) ... ok
|
||||
test_empty_report_is_not_a_pass (test_controller.ReportTests.test_empty_report_is_not_a_pass) ... ok
|
||||
test_json_log_redaction_preserves_event_structure (test_controller.ReportTests.test_json_log_redaction_preserves_event_structure) ... ok
|
||||
test_path_escape_rejected (test_controller.ReportTests.test_path_escape_rejected) ... ok
|
||||
test_playwright_retry_and_expected_failure_are_not_pass (test_controller.ReportTests.test_playwright_retry_and_expected_failure_are_not_pass) ... ok
|
||||
test_redacts_common_credentials (test_controller.ReportTests.test_redacts_common_credentials) ... ok
|
||||
test_report_membership_and_skip_enforcement (test_controller.ReportTests.test_report_membership_and_skip_enforcement) ... ok
|
||||
test_cli_nonzero_exit_never_accepts (test_controller.SubprocessTests.test_cli_nonzero_exit_never_accepts) ... ok
|
||||
test_cli_protocol_real_process_and_complete_prompt (test_controller.SubprocessTests.test_cli_protocol_real_process_and_complete_prompt) ... ok
|
||||
test_killed_controller_terminates_worker_and_grandchild (test_controller.SubprocessTests.test_killed_controller_terminates_worker_and_grandchild) ... ok
|
||||
test_marker_write_failure_does_not_leave_worker (test_controller.SubprocessTests.test_marker_write_failure_does_not_leave_worker) ... ok
|
||||
test_no_newline_output_obeys_log_budget (test_controller.SubprocessTests.test_no_newline_output_obeys_log_budget) ... ok
|
||||
test_prompt_stall_still_obeys_timeout (test_controller.SubprocessTests.test_prompt_stall_still_obeys_timeout) ... ok
|
||||
test_real_check_report_must_match_expected_membership (test_controller.SubprocessTests.test_real_check_report_must_match_expected_membership) ... ok
|
||||
test_worker_cannot_execute_before_job_assignment (test_controller.SubprocessTests.test_worker_cannot_execute_before_job_assignment) ... ok
|
||||
test_pending_recovery_can_be_signed_from_idle_operator_terminal (test_local_mode.LocalFlowTests.test_pending_recovery_can_be_signed_from_idle_operator_terminal) ... ok
|
||||
test_single_user_concurrent_controller_and_interrupted_guard (test_local_mode.LocalFlowTests.test_single_user_concurrent_controller_and_interrupted_guard) ... ok
|
||||
test_single_user_main_checkout_is_still_prohibited (test_local_mode.LocalFlowTests.test_single_user_main_checkout_is_still_prohibited) ... ok
|
||||
test_single_user_missing_approval_stops_before_worker (test_local_mode.LocalFlowTests.test_single_user_missing_approval_stops_before_worker) ... ok
|
||||
test_single_user_pause_and_publication_recovery (test_local_mode.LocalFlowTests.test_single_user_pause_and_publication_recovery) ... ok
|
||||
test_single_user_subprocess_protocol_and_ordered_publication (test_local_mode.LocalFlowTests.test_single_user_subprocess_protocol_and_ordered_publication) ... ok
|
||||
test_auth_home_inside_worker_is_rejected (test_local_mode.LocalPolicyTests.test_auth_home_inside_worker_is_rejected) ... ok
|
||||
test_blocked_status_rejects_otherwise_successful_probes (test_local_mode.LocalPolicyTests.test_blocked_status_rejects_otherwise_successful_probes) ... ok
|
||||
test_different_or_missing_sid_rejected (test_local_mode.LocalPolicyTests.test_different_or_missing_sid_rejected) ... ok
|
||||
test_existing_normal_auth_home_is_rejected (test_local_mode.LocalPolicyTests.test_existing_normal_auth_home_is_rejected) ... ok
|
||||
test_failed_probe_and_unconfigured_owner_key_cannot_qualify (test_local_mode.LocalPolicyTests.test_failed_probe_and_unconfigured_owner_key_cannot_qualify) ... ok
|
||||
test_missing_or_stale_qualification_never_enables_unattended (test_local_mode.LocalPolicyTests.test_missing_or_stale_qualification_never_enables_unattended) ... ok
|
||||
test_mxc_is_explicit_without_legacy_or_unrestricted_fallback (test_local_mode.LocalPolicyTests.test_mxc_is_explicit_without_legacy_or_unrestricted_fallback) ... ok
|
||||
test_noninteractive_approval_is_refused (test_local_mode.LocalPolicyTests.test_noninteractive_approval_is_refused) ... ok
|
||||
test_operator_controlled_policy_cannot_launch_a_worker (test_local_mode.LocalPolicyTests.test_operator_controlled_policy_cannot_launch_a_worker) ... ok
|
||||
test_plugins_are_not_an_implicit_same_user_capability (test_local_mode.LocalPolicyTests.test_plugins_are_not_an_implicit_same_user_capability) ... ok
|
||||
test_probe_summary_rejects_partial_and_failed_boundaries (test_local_mode.LocalPolicyTests.test_probe_summary_rejects_partial_and_failed_boundaries) ... ok
|
||||
test_profile_denies_root_control_auth_and_normal_configuration (test_local_mode.LocalPolicyTests.test_profile_denies_root_control_auth_and_normal_configuration) ... ok
|
||||
test_same_chatgpt_auth_is_explicit_and_tokens_are_not_copied (test_local_mode.LocalPolicyTests.test_same_chatgpt_auth_is_explicit_and_tokens_are_not_copied) ... ok
|
||||
test_same_windows_identity_supported (test_local_mode.LocalPolicyTests.test_same_windows_identity_supported) ... ok
|
||||
test_tool_or_temp_roots_cannot_reopen_protected_parent (test_local_mode.LocalPolicyTests.test_tool_or_temp_roots_cannot_reopen_protected_parent) ... ok
|
||||
|
||||
----------------------------------------------------------------------
|
||||
Ran 69 tests in 174.662s
|
||||
|
||||
OK
|
||||
@@ -12,7 +12,10 @@ if args[0] == 'exec':
|
||||
sys.exit(17)
|
||||
if Path('simulate-timeout').exists():
|
||||
time.sleep(60)
|
||||
if '--sandbox' not in args or args[args.index('--sandbox') + 1] != 'workspace-write':
|
||||
legacy = '--sandbox' in args and args[args.index('--sandbox') + 1] == 'workspace-write'
|
||||
local = ('default_permissions="jobjakt-local"' in args and 'windows.sandbox="mxc"' in args
|
||||
and 'permissions.jobjakt-local.network.enabled=false' in args)
|
||||
if not (legacy or local):
|
||||
sys.exit(18)
|
||||
checkpoint = 'work'
|
||||
Path('result.txt').write_text('subprocess worker output', encoding='utf-8')
|
||||
@@ -24,8 +27,16 @@ if args[0] == 'exec':
|
||||
elif args[0] == 'sandbox':
|
||||
# Remove known sandbox switches; execute the real deterministic check in the disposable repo.
|
||||
index = 1
|
||||
while args[index] in ('-C', '-c'):
|
||||
index += 2
|
||||
while index < len(args):
|
||||
if args[index] in ('-C','-c','--permission-profile'):
|
||||
index += 2
|
||||
elif args[index] == '--include-managed-config':
|
||||
index += 1
|
||||
elif args[index] == '--':
|
||||
index += 1
|
||||
break
|
||||
else:
|
||||
break
|
||||
sys.exit(subprocess.run(args[index:], cwd=Path.cwd()).returncode)
|
||||
else:
|
||||
sys.exit(19)
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
"""Single-user policy tests; no real credentials, models or production services."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
from datetime import datetime,timezone,timedelta
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
import local_mode as local
|
||||
import controller as c
|
||||
import test_controller as fixture
|
||||
|
||||
|
||||
class LocalPolicyTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory(prefix='jobjakt-single-user-test-')
|
||||
self.base = Path(self.temp.name)
|
||||
self.root = self.base / 'control'
|
||||
self.repo = self.base / 'worker'
|
||||
self.repo.mkdir()
|
||||
self.config = dict(mode='single-user-local', executionPolicy='operator-controlled',
|
||||
ownerSid='S-1-5-21-100', runnerSid='S-1-5-21-100', repository=str(self.repo),
|
||||
workerCodeXHome=str(self.root / 'auth'), normalCodeXHome=str(self.base / 'normal-codex'),
|
||||
workerTemp=str(self.base / 'worker-temp'), toolReadRoots=[], trustedCodexOverrides=[])
|
||||
|
||||
def tearDown(self):
|
||||
self.temp.cleanup()
|
||||
|
||||
def test_same_windows_identity_supported(self):
|
||||
local.validate_identity(self.config, self.config['ownerSid'])
|
||||
|
||||
def test_different_or_missing_sid_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
local.validate_identity(self.config, 'S-1-5-21-999')
|
||||
|
||||
def test_existing_normal_auth_home_is_rejected(self):
|
||||
self.config['workerCodeXHome'] = self.config['normalCodeXHome']
|
||||
with self.assertRaisesRegex(ValueError, 'auth'):
|
||||
local.validate_config(self.root, self.config)
|
||||
|
||||
def test_auth_home_inside_worker_is_rejected(self):
|
||||
self.config['workerCodeXHome'] = str(self.repo / 'auth')
|
||||
with self.assertRaises(ValueError):
|
||||
local.validate_config(self.root,self.config)
|
||||
|
||||
def test_profile_denies_root_control_auth_and_normal_configuration(self):
|
||||
rules = local.filesystem_rules(self.root,self.config)
|
||||
self.assertEqual('deny',rules[':root'])
|
||||
self.assertEqual('read',rules[':minimal'])
|
||||
for path in [self.root,Path(self.config['workerCodeXHome']),Path(self.config['normalCodeXHome'])]:
|
||||
self.assertEqual('deny',rules[path.resolve().as_posix()])
|
||||
self.assertEqual('write',rules[self.repo.resolve().as_posix()])
|
||||
self.assertEqual('read',rules[(self.repo / '.git').resolve().as_posix()])
|
||||
|
||||
def test_mxc_is_explicit_without_legacy_or_unrestricted_fallback(self):
|
||||
args = local.permission_args(self.root,self.config,'exec')
|
||||
self.assertIn('windows.sandbox="mxc"',args)
|
||||
self.assertIn('permissions.jobjakt-local.network.enabled=false',args)
|
||||
self.assertNotIn('--sandbox',args)
|
||||
self.assertNotIn('--dangerously-bypass-approvals-and-sandbox',args)
|
||||
for value in ('features.apps=false','features.plugins=false','features.remote_plugin=false','mcp_servers={}'):
|
||||
self.assertIn(value,args)
|
||||
|
||||
def test_plugins_are_not_an_implicit_same_user_capability(self):
|
||||
self.config['trustedCodexOverrides'] = [{'key':'mcp_servers.any.command','toml':'"unsafe"'}]
|
||||
with self.assertRaisesRegex(ValueError,'plugin'):
|
||||
local.validate_config(self.root,self.config)
|
||||
|
||||
def test_tool_or_temp_roots_cannot_reopen_protected_parent(self):
|
||||
self.config['toolReadRoots']=[str(self.base)]
|
||||
with self.assertRaises(ValueError):
|
||||
local.validate_config(self.root,self.config)
|
||||
self.config['toolReadRoots']=[]
|
||||
self.config['workerTemp']=str(self.base)
|
||||
with self.assertRaises(ValueError):
|
||||
local.validate_config(self.root,self.config)
|
||||
|
||||
def test_operator_controlled_policy_cannot_launch_a_worker(self):
|
||||
with self.assertRaisesRegex(ValueError,'operator-controlled'):
|
||||
local.require_unattended(self.root,self.config)
|
||||
|
||||
def test_missing_or_stale_qualification_never_enables_unattended(self):
|
||||
self.config['executionPolicy'] = 'qualified-unattended'
|
||||
with self.assertRaises(ValueError):
|
||||
local.require_unattended(self.root,self.config)
|
||||
|
||||
def test_probe_summary_rejects_partial_and_failed_boundaries(self):
|
||||
result = dict(positiveControl=True,networkDenied=True,signingKeyDenied=True,
|
||||
files=[dict(readDenied=True,writeDenied=False)])
|
||||
self.assertFalse(local.probes_passed(result))
|
||||
result['files'][0]['writeDenied'] = True
|
||||
self.assertFalse(local.probes_passed(result)) # required categories cannot be omitted
|
||||
|
||||
def test_same_chatgpt_auth_is_explicit_and_tokens_are_not_copied(self):
|
||||
normal = Path(self.config['normalCodeXHome'])
|
||||
normal.mkdir()
|
||||
(normal/'auth.json').write_text('synthetic-existing-token-do-not-copy')
|
||||
with mock.patch.object(local,'detect_identity',return_value=('synthetic-user',self.config['ownerSid'])), \
|
||||
mock.patch.dict(local.os.environ,{'CODEX_HOME':str(normal)}):
|
||||
config = local.configure_current_user(self.root,dict(self.config))
|
||||
self.assertEqual(config['ownerSid'],config['runnerSid'])
|
||||
self.assertEqual([],list(Path(config['workerCodeXHome']).iterdir()))
|
||||
args = local.auth_command({**config,'codexExecutable':'codex'})
|
||||
self.assertIn('forced_login_method="chatgpt"',args)
|
||||
self.assertIn('cli_auth_credentials_store="file"',args)
|
||||
self.assertNotIn('--with-api-key',args)
|
||||
self.assertEqual('synthetic-existing-token-do-not-copy',(normal/'auth.json').read_text())
|
||||
|
||||
def test_failed_probe_and_unconfigured_owner_key_cannot_qualify(self):
|
||||
self.root.mkdir()
|
||||
self.config['executionPolicy']='qualified-unattended'
|
||||
valid = dict(positiveControl=True,networkDenied=True,signingKeyDenied=True,
|
||||
files=[dict(category=k,readDenied=True,writeDenied=True) for k in sorted(local.CATEGORIES)])
|
||||
report = dict(status='PASS',failure=None,binding=local.binding(self.root,self.config),issuedUtc=datetime.now(timezone.utc).isoformat(),probes=valid,ownerKeyDenied=False)
|
||||
c.atomic(self.root/'qualification.json',report)
|
||||
with self.assertRaises(ValueError):
|
||||
local.require_unattended(self.root,self.config)
|
||||
|
||||
report['ownerKeyDenied']=True
|
||||
report['probes']['networkDenied']=False
|
||||
c.atomic(self.root/'qualification.json',report)
|
||||
with self.assertRaises(ValueError):
|
||||
local.require_unattended(self.root,self.config)
|
||||
report['probes']['networkDenied']=True
|
||||
c.atomic(self.root/'qualification.json',report)
|
||||
local.require_unattended(self.root,self.config)
|
||||
report['issuedUtc']=(datetime.now(timezone.utc)-timedelta(days=2)).isoformat()
|
||||
c.atomic(self.root/'qualification.json',report)
|
||||
with self.assertRaises(ValueError):
|
||||
local.require_unattended(self.root,self.config)
|
||||
|
||||
def test_blocked_status_rejects_otherwise_successful_probes(self):
|
||||
self.root.mkdir()
|
||||
self.config['executionPolicy']='qualified-unattended'
|
||||
c.atomic(self.root/'qualification.json',dict(status='BLOCKED',failure='cleanup failed',
|
||||
binding=local.binding(self.root,self.config),issuedUtc=datetime.now(timezone.utc).isoformat(),ownerKeyDenied=True,
|
||||
probes=dict(positiveControl=True,networkDenied=True,signingKeyDenied=True,
|
||||
files=[dict(category=k,readDenied=True,writeDenied=True) for k in sorted(local.CATEGORIES)])))
|
||||
with self.assertRaises(ValueError):
|
||||
local.require_unattended(self.root,self.config)
|
||||
|
||||
def test_noninteractive_approval_is_refused(self):
|
||||
c.atomic(self.root/'config.json',self.config)
|
||||
c.atomic(self.root/'backlog.json',{})
|
||||
with mock.patch.object(c.sys.stdin,'isatty',return_value=False):
|
||||
with self.assertRaisesRegex(c.GateError,'interactive terminal'):
|
||||
c.main(['approve','--control-root',str(self.root)])
|
||||
|
||||
|
||||
class LocalFlowTests(unittest.TestCase):
|
||||
tearDown=fixture.EngineTests.tearDown
|
||||
git_at=staticmethod(fixture.EngineTests.git_at)
|
||||
run_engine=fixture.EngineTests.run_engine
|
||||
|
||||
def setUp(self):
|
||||
fixture.EngineTests.setUp(self)
|
||||
self.config.update(mode='single-user-local',executionPolicy='qualified-unattended',
|
||||
ownerSid='S-1-5-21-100',runnerSid='S-1-5-21-100',
|
||||
normalCodeXHome=str(self.root/'normal-codex'),workerCodeXHome=str(self.control/'auth'),
|
||||
workerTemp=str(self.root/'worker-temp'),toolReadRoots=[],trustedCodexOverrides=[])
|
||||
Path(self.config['workerCodeXHome']).mkdir()
|
||||
Path(self.config['workerTemp']).mkdir()
|
||||
c.atomic(self.control/'qualification.json',dict(status='PASS',failure=None,binding=local.binding(self.control,self.config),
|
||||
issuedUtc=datetime.now(timezone.utc).isoformat(),ownerKeyDenied=True,
|
||||
probes=dict(positiveControl=True,networkDenied=True,signingKeyDenied=True,
|
||||
files=[dict(category=k,readDenied=True,writeDenied=True) for k in sorted(local.CATEGORIES)])))
|
||||
|
||||
def test_single_user_subprocess_protocol_and_ordered_publication(self):
|
||||
self.engine.runner=fixture.ProtocolRunner()
|
||||
self.assertEqual('COMPLETED',self.run_engine()['stages']['TWO']['status'])
|
||||
|
||||
def test_single_user_missing_approval_stops_before_worker(self):
|
||||
self.engine.approval_verifier=lambda request:False
|
||||
self.assertEqual('AWAITING_APPROVAL',self.run_engine()['stages']['ONE']['status'])
|
||||
self.assertEqual(0,self.runner.calls)
|
||||
|
||||
def test_single_user_pause_and_publication_recovery(self):
|
||||
self.engine.pause()
|
||||
self.run_engine()
|
||||
self.assertEqual(0,self.runner.calls)
|
||||
self.engine.resume()
|
||||
publish=self.engine.publish
|
||||
self.engine.publish=lambda *args: (_ for _ in ()).throw(c.PublicationError('synthetic outage'))
|
||||
self.assertEqual('BLOCKED',self.run_engine()['stages']['ONE']['status'])
|
||||
self.assertEqual(1,self.runner.calls)
|
||||
self.engine.publish=publish
|
||||
self.assertEqual('COMPLETED',self.run_engine()['stages']['TWO']['status'])
|
||||
self.assertEqual(2,self.runner.calls)
|
||||
|
||||
def test_single_user_concurrent_controller_and_interrupted_guard(self):
|
||||
with c.RepoLock(self.repo):
|
||||
with self.assertRaises(c.GateError):
|
||||
self.run_engine()
|
||||
state=self.engine.store.read()
|
||||
state['stages']['ONE']['status']='RUNNING'
|
||||
self.engine.store.write(state)
|
||||
with self.assertRaises(c.GateError):
|
||||
self.run_engine()
|
||||
|
||||
def test_single_user_main_checkout_is_still_prohibited(self):
|
||||
self.git_at(self.repo,'switch','-c','main')
|
||||
self.assertEqual('BLOCKED',self.run_engine()['stages']['ONE']['status'])
|
||||
self.assertEqual(0,self.runner.calls)
|
||||
|
||||
def test_pending_recovery_can_be_signed_from_idle_operator_terminal(self):
|
||||
self.config['ownerCertificateThumbprint']='synthetic-owner-key'
|
||||
c.atomic(self.control/'config.json',self.config)
|
||||
c.atomic(self.control/'backlog.json',self.manifest)
|
||||
state=self.engine.store.read()
|
||||
state['stages']['ONE']['status']='RUNNING'
|
||||
self.engine.store.write(state)
|
||||
self.engine.approval_verifier=lambda r:False
|
||||
def answer(prompt):
|
||||
if prompt.startswith('Type '):
|
||||
return prompt.split()[1]
|
||||
if prompt.startswith('Disposition'):
|
||||
return 'Approve'
|
||||
return 'Operator inspected the stopped synthetic process tree'
|
||||
with mock.patch.object(c,'Engine',return_value=self.engine):
|
||||
self.assertEqual(3,c.main(['recover','--control-root',str(self.control)]))
|
||||
run=c.subprocess.run
|
||||
def operator_command(argv,**kwargs):
|
||||
return mock.Mock(returncode=0) if 'Sign' in argv else run(argv,**kwargs)
|
||||
with mock.patch.object(c.sys.stdin,'isatty',return_value=True), \
|
||||
mock.patch.object(local,'detect_identity',return_value=('synthetic-user',self.config['ownerSid'])), \
|
||||
mock.patch('builtins.input',side_effect=answer), \
|
||||
mock.patch.object(c.subprocess,'run',side_effect=operator_command) as signed:
|
||||
self.assertEqual(0,c.main(['approve','--control-root',str(self.control)]))
|
||||
self.assertIn('Sign',signed.call_args.args[0])
|
||||
self.assertTrue(self.engine.store.read()['recoveryRequired']) # signing alone never marks recovery complete
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main(verbosity=2)
|
||||
Reference in New Issue
Block a user