1514 lines
86 KiB
Python
1514 lines
86 KiB
Python
"""Trusted control-plane source. Deploy outside the Codex-writable checkout before use."""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import base64
|
|
import contextlib
|
|
from datetime import datetime, timezone
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import shutil
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
import uuid
|
|
import xml.etree.ElementTree as ET
|
|
import local_mode
|
|
import assessment_adapter
|
|
|
|
|
|
class GateError(RuntimeError):
|
|
pass
|
|
|
|
|
|
class PublicationError(GateError):
|
|
pass
|
|
|
|
|
|
class Interrupted(GateError):
|
|
pass
|
|
|
|
|
|
STATUSES = {'PENDING', 'READY', 'RUNNING', 'BLOCKED', 'AWAITING_APPROVAL', 'FAILED',
|
|
'CONDITIONALLY_ACCEPTED', 'ACCEPTED', 'COMPLETED'}
|
|
OUTCOMES = {'complete', 'checkpoint', 'blocked', 'approval', 'conditional', 'failed'}
|
|
SAFE_BRANCH = re.compile(r'^(?:codex|feature|planning|test)/[A-Za-z0-9][A-Za-z0-9/_-]*$')
|
|
|
|
|
|
def utc():
|
|
return datetime.now(timezone.utc).isoformat()
|
|
|
|
|
|
def canonical(value):
|
|
return json.dumps(value, sort_keys=True, separators=(',', ':'), ensure_ascii=False).encode('utf-8')
|
|
|
|
|
|
def digest(value):
|
|
return hashlib.sha256(canonical(value)).hexdigest()
|
|
|
|
|
|
def sha_file(path):
|
|
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
|
|
|
|
|
|
def under(root, name):
|
|
root = Path(root).resolve()
|
|
p = (root / name).resolve()
|
|
if not p.is_relative_to(root) or p == root:
|
|
raise GateError(f'Path must be a child of {root}: {name}')
|
|
# resolve() checks junction/symlink escape as well as traversal.
|
|
return p
|
|
|
|
|
|
def atomic(path, value):
|
|
path = Path(path)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
fd, temporary = tempfile.mkstemp(prefix='.' + path.name, dir=path.parent)
|
|
try:
|
|
with os.fdopen(fd, 'wb') as stream:
|
|
stream.write(canonical(value))
|
|
stream.flush()
|
|
os.fsync(stream.fileno())
|
|
os.replace(temporary, path)
|
|
finally:
|
|
if os.path.exists(temporary):
|
|
os.unlink(temporary)
|
|
|
|
|
|
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:
|
|
value = json.loads(text)
|
|
except ValueError:
|
|
value = None
|
|
if isinstance(value, (dict, list)):
|
|
def scrub(item):
|
|
if isinstance(item, dict):
|
|
return {key: '[REDACTED]' if re.fullmatch(
|
|
r'(?i)password|token|secret|api[_-]?key|authorization|access_token|refresh_token|id_token', key)
|
|
else scrub(child) for key, child in item.items()}
|
|
if isinstance(item, list):
|
|
return [scrub(child) for child in item]
|
|
return redact(item) if isinstance(item, str) else item
|
|
return json.dumps(scrub(value), ensure_ascii=False)
|
|
text = re.sub(r'(?i)(bearer\s+)\S+', r'\1[REDACTED]', text)
|
|
text = re.sub(r'(?i)((?:password|token|secret|api[_-]?key|authorization)\s*[=:]\s*)[^\s,;]+',
|
|
r'\1[REDACTED]', text)
|
|
text = re.sub(r'(https?://)[^/@\s]+:[^/@\s]+@', r'\1[REDACTED]@', text)
|
|
return text
|
|
|
|
|
|
def validate_manifest(m, root=None):
|
|
if m.get('schemaVersion') != 1 or not isinstance(m.get('executionEnabled'), bool):
|
|
raise GateError('Unsupported manifest or missing executionEnabled boolean')
|
|
if not SAFE_BRANCH.fullmatch(m.get('integrationBranch', '')):
|
|
raise GateError('Unsafe integration branch')
|
|
if not re.fullmatch(r'[0-9a-f]{40}', m.get('baselineCommit', '')):
|
|
raise GateError('Baseline must be an exact 40-character commit')
|
|
if not SAFE_BRANCH.fullmatch(m.get('baselineBranch', '')):
|
|
raise GateError('Unsafe baseline branch')
|
|
if not re.fullmatch(r'[A-Za-z0-9_-]+', m.get('remote', '')):
|
|
raise GateError('Remote must be a configured name, not a URL or option')
|
|
seen, sequences, branches = set(), [], set()
|
|
if not m.get('stages'):
|
|
raise GateError('Queue is empty')
|
|
for s in m['stages']:
|
|
required = {'id', 'sequence', 'sourcePrompt', 'promptSha256', 'sourceStatus', 'dependencies',
|
|
'branch', 'requiredOwnerApprovals', 'retryPolicy', 'status', 'evidence',
|
|
'reports', 'contextFiles', 'checkpoints'}
|
|
if not required.issubset(s) or s['id'] in seen or not re.fullmatch(r'[A-Z0-9-]+', s['id']):
|
|
raise GateError('Missing stage fields or duplicate/invalid workstream ID')
|
|
if not set(s['dependencies']).issubset(seen):
|
|
raise GateError(f'Forward, unknown or cyclic dependency: {s["id"]}')
|
|
if not SAFE_BRANCH.fullmatch(s['branch']) or '..' in s['branch'] or s['branch'] in branches:
|
|
raise GateError('Unsafe or duplicate stage branch')
|
|
if s['branch'] == m['integrationBranch'] or s['status'] not in STATUSES:
|
|
raise GateError('Invalid branch/status')
|
|
seen.add(s['id'])
|
|
branches.add(s['branch'])
|
|
sequences.append(s['sequence'])
|
|
p = s['retryPolicy']
|
|
if not (1 <= p['maxAttempts'] <= 10 and 1 <= p['maxSessions'] <= 100
|
|
and 0 <= p['backoffSeconds'] <= 3600):
|
|
raise GateError('Retry/session budget out of bounds')
|
|
checkpoints = set()
|
|
if not s['checkpoints']:
|
|
raise GateError('Stage has no checkpoints')
|
|
for cp in s['checkpoints']:
|
|
if cp['id'] in checkpoints or not re.fullmatch(r'[A-Za-z0-9-]+', cp['id']):
|
|
raise GateError('Duplicate/invalid checkpoint')
|
|
checkpoints.add(cp['id'])
|
|
for field in ('instruction', 'approvalsBefore', 'approvalsAfter', 'deferrableGates',
|
|
'requiredArtifacts', 'checks', 'evidenceRequirements'):
|
|
if field not in cp:
|
|
raise GateError(f'Missing checkpoint field: {field}')
|
|
check_ids = set()
|
|
for check in cp['checks']:
|
|
if check['id'] in check_ids or not check.get('argv'):
|
|
raise GateError('Duplicate check or empty test command')
|
|
check_ids.add(check['id'])
|
|
if check.get('adapter') == 'assessment-evidence-inspection':
|
|
if not m.get('executionScope') or not re.fullmatch(r'[0-9a-f]{64}',check.get('receiptSha256','')):
|
|
raise GateError('Assessment adapter requires a restricted policy and pinned receipt')
|
|
if check.get('configured') is not False:
|
|
raise GateError('Evidence inspection cannot qualify the command-running composite adapter')
|
|
if root and sha_file(under(root,check['receipt'])) != check['receiptSha256']:
|
|
raise GateError('Assessment qualification receipt changed')
|
|
if not all(isinstance(a, str) and '\x00' not in a for a in check['argv']):
|
|
raise GateError('Command arguments must be strings')
|
|
if check.get('report') and check['report']['format'] not in ('trx', 'jest', 'playwright', 'junit'):
|
|
raise GateError('Unsupported structured test format')
|
|
if root and s['sourceStatus'] == 'VERIFIED':
|
|
path = under(root, s['sourcePrompt'])
|
|
if not path.is_file() or not path.stat().st_size or sha_file(path) != s['promptSha256']:
|
|
raise GateError(f'Prompt missing/changed: {s["id"]}')
|
|
if root:
|
|
for item in s.get('supplementalPrompts', []):
|
|
path = under(root, item['path'])
|
|
if not path.is_file() or sha_file(path) != item['sha256']:
|
|
raise GateError('Supplemental prompt missing/changed')
|
|
if sequences != sorted(set(sequences)):
|
|
raise GateError('Queue sequence must be unique and ascending')
|
|
scope = m.get('executionScope')
|
|
if scope:
|
|
stage = next((s for s in m['stages'] if s['id'] == scope.get('stage')), None)
|
|
cp = next((x for x in stage['checkpoints'] if x['id'] == scope.get('checkpoint')), None) if stage else None
|
|
if (scope.get('mode') != 'assessment-only' or scope.get('stage') != 'PHASE-10'
|
|
or scope.get('checkpoint') != 'P10-00' or not cp
|
|
or scope.get('reportDirectory') != 'docs/automation/stages/PHASE-10'
|
|
or 'candidate-specific-cleanup-scope' not in cp['approvalsAfter']):
|
|
raise GateError('Assessment policy requires an exact checkpoint and protected stop')
|
|
if not re.fullmatch(r'[0-9a-f]{40}', scope.get('handoffCommit','')) or not SAFE_BRANCH.fullmatch(scope.get('handoffBranch','')):
|
|
raise GateError('Invalid published handoff identity')
|
|
|
|
|
|
class StateStore:
|
|
def __init__(self, root):
|
|
self.path = Path(root) / 'state.json'
|
|
self.backup = Path(root) / 'state.last-good.json'
|
|
|
|
def decode(self, path):
|
|
envelope = read_json(path)
|
|
if envelope['sha256'] != digest(envelope['state']):
|
|
raise ValueError('state checksum mismatch')
|
|
return envelope['state']
|
|
|
|
def read(self):
|
|
try:
|
|
return self.decode(self.path)
|
|
except (OSError, ValueError, KeyError, TypeError):
|
|
try:
|
|
state = self.decode(self.backup)
|
|
except (OSError, ValueError, KeyError, TypeError) as error:
|
|
raise GateError('State and last-good copy unreadable; restore verified backup, never reinitialize') from error
|
|
state['recoveryRequired'] = True
|
|
return state
|
|
|
|
def write(self, state):
|
|
if self.path.exists():
|
|
try:
|
|
previous = self.decode(self.path)
|
|
atomic(self.backup, {'state': previous, 'sha256': digest(previous)})
|
|
except (OSError, ValueError, KeyError, TypeError):
|
|
pass # keep the last verified backup, never copy corrupt bytes
|
|
state['updatedUtc'] = utc()
|
|
atomic(self.path, {'state': state, 'sha256': digest(state)})
|
|
if not self.backup.exists():
|
|
atomic(self.backup, {'state': state, 'sha256': digest(state)})
|
|
|
|
|
|
class RepoLock:
|
|
"""OS releases this lock on process death. Shared Git directory locks all worktrees."""
|
|
def __init__(self, repo):
|
|
p = subprocess.run(['git', '-C', str(repo), 'rev-parse', '--path-format=absolute', '--git-common-dir'],
|
|
capture_output=True, text=True)
|
|
if p.returncode:
|
|
raise GateError('Repository is not a Git checkout')
|
|
self.path = Path(p.stdout.strip()) / 'jobjakt-orchestrator.lock'
|
|
self.stream = None
|
|
|
|
def __enter__(self):
|
|
self.stream = open(self.path, 'a+b')
|
|
self.stream.seek(0)
|
|
self.stream.write(b'0')
|
|
self.stream.flush()
|
|
self.stream.seek(0)
|
|
try:
|
|
if os.name == 'nt':
|
|
import msvcrt
|
|
msvcrt.locking(self.stream.fileno(), msvcrt.LK_NBLCK, 1)
|
|
else:
|
|
import fcntl
|
|
fcntl.flock(self.stream.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
except OSError as error:
|
|
self.stream.close()
|
|
raise GateError('Another orchestrator controls this repository') from error
|
|
return self
|
|
|
|
def __exit__(self, *args):
|
|
if self.stream:
|
|
self.stream.seek(0)
|
|
if os.name == 'nt':
|
|
import msvcrt
|
|
msvcrt.locking(self.stream.fileno(), msvcrt.LK_UNLCK, 1)
|
|
else:
|
|
import fcntl
|
|
fcntl.flock(self.stream.fileno(), fcntl.LOCK_UN)
|
|
self.stream.close()
|
|
|
|
|
|
def parse_report(path, spec):
|
|
"""Count assertions independently, require membership, and reject skips/failures."""
|
|
fmt = spec['format']
|
|
names, passed, failed, skipped = [], 0, 0, 0
|
|
if fmt == 'trx':
|
|
tree = ET.parse(path)
|
|
for node in tree.iter():
|
|
if node.tag.split('}')[-1] == 'UnitTestResult':
|
|
names.append(node.attrib['testName'])
|
|
outcome = node.attrib.get('outcome')
|
|
passed += outcome == 'Passed'
|
|
failed += outcome not in ('Passed', 'NotExecuted')
|
|
skipped += outcome == 'NotExecuted'
|
|
elif fmt == 'junit':
|
|
for node in ET.parse(path).iter('testcase'):
|
|
names.append(node.attrib.get('classname', '') + '.' + node.attrib['name'])
|
|
bad = node.find('failure') is not None or node.find('error') is not None
|
|
skip = node.find('skipped') is not None
|
|
failed += bad
|
|
skipped += skip
|
|
passed += not (bad or skip)
|
|
elif fmt == 'jest':
|
|
data = read_json(path)
|
|
if data.get('success') is not True:
|
|
raise GateError('Jest success flag is false')
|
|
for suite in data['testResults']:
|
|
for case in suite['assertionResults']:
|
|
names.append(case['fullName'])
|
|
passed += case['status'] == 'passed'
|
|
failed += case['status'] == 'failed'
|
|
skipped += case['status'] not in ('passed', 'failed')
|
|
else:
|
|
data = read_json(path)
|
|
if data.get('errors'):
|
|
raise GateError('Playwright global errors')
|
|
def visit(suites):
|
|
nonlocal passed, failed, skipped
|
|
for suite in suites:
|
|
for test_spec in suite.get('specs', []):
|
|
for case in test_spec['tests']:
|
|
names.append(f'{test_spec.get("file", "")}:{test_spec["title"]}:{case["projectName"]}')
|
|
results = case.get('results', [])
|
|
ok = (len(results) == 1 and results[0]['status'] == 'passed'
|
|
and case.get('expectedStatus', 'passed') == 'passed')
|
|
passed += ok
|
|
failed += not ok
|
|
visit(suite.get('suites', []))
|
|
visit(data['suites'])
|
|
if not names or passed < spec.get('minimumPassed', 1) or failed or skipped:
|
|
raise GateError(f'Invalid/empty/skipped/failing report: passed={passed}, failed={failed}, skipped={skipped}')
|
|
expected = spec.get('expectedNames', [])
|
|
if expected and sorted(names) != sorted(expected):
|
|
raise GateError('Expected test membership differs from report')
|
|
if len(names) != len(set(names)):
|
|
raise GateError('Duplicate test identities in report')
|
|
return dict(passed=passed, failed=failed, skipped=skipped, membershipSha256=digest(sorted(names)))
|
|
|
|
|
|
class WindowsJob:
|
|
"""A non-inherited kill-on-close Job handle bounds the complete child process tree."""
|
|
def __init__(self, config):
|
|
import ctypes as ct
|
|
from ctypes import wintypes as wt
|
|
class Basic(ct.Structure):
|
|
_fields_ = [('processTime', ct.c_longlong), ('jobTime', ct.c_longlong),
|
|
('flags', wt.DWORD), ('minimumWorkingSet', ct.c_size_t),
|
|
('maximumWorkingSet', ct.c_size_t), ('activeProcesses', wt.DWORD),
|
|
('affinity', ct.c_size_t), ('priority', wt.DWORD), ('scheduling', wt.DWORD)]
|
|
class Io(ct.Structure):
|
|
_fields_ = [(name, ct.c_ulonglong) for name in
|
|
('readOps', 'writeOps', 'otherOps', 'readBytes', 'writeBytes', 'otherBytes')]
|
|
class Extended(ct.Structure):
|
|
_fields_ = [('basic', Basic), ('io', Io), ('processMemory', ct.c_size_t),
|
|
('jobMemory', ct.c_size_t), ('peakProcessMemory', ct.c_size_t), ('peakJobMemory', ct.c_size_t)]
|
|
self.api = ct.WinDLL('kernel32', use_last_error=True)
|
|
self.api.CreateJobObjectW.argtypes = [ct.c_void_p, wt.LPCWSTR]
|
|
self.api.CreateJobObjectW.restype = wt.HANDLE
|
|
self.api.SetInformationJobObject.argtypes = [wt.HANDLE, ct.c_int, ct.c_void_p, wt.DWORD]
|
|
self.api.SetInformationJobObject.restype = wt.BOOL
|
|
self.api.AssignProcessToJobObject.argtypes = [wt.HANDLE, wt.HANDLE]
|
|
self.api.AssignProcessToJobObject.restype = wt.BOOL
|
|
self.api.CloseHandle.argtypes = [wt.HANDLE]
|
|
self.api.CloseHandle.restype = wt.BOOL
|
|
self.handle = self.api.CreateJobObjectW(None, None)
|
|
if not self.handle:
|
|
raise GateError('Cannot create Windows process containment job')
|
|
limits = Extended()
|
|
limits.basic.flags = 0x2000 | 0x8 | 0x200 # KILL_ON_JOB_CLOSE | ACTIVE_PROCESS | JOB_MEMORY
|
|
limits.basic.activeProcesses = config.get('maxProcesses', 64)
|
|
limits.jobMemory = config.get('maxJobMemoryMiB', 8192) * 1024 * 1024
|
|
if not self.api.SetInformationJobObject(self.handle, 9, ct.byref(limits), ct.sizeof(limits)):
|
|
self.close()
|
|
raise GateError('Cannot enforce Windows process/memory limits')
|
|
|
|
def assign(self, process):
|
|
if not self.api.AssignProcessToJobObject(self.handle, int(process._handle)):
|
|
raise GateError('Cannot attach worker to Windows containment job; refusing uncontained execution')
|
|
|
|
def resume(self, process):
|
|
import ctypes as ct
|
|
from ctypes import wintypes as wt
|
|
class ThreadEntry(ct.Structure):
|
|
_fields_ = [('size', wt.DWORD), ('usage', wt.DWORD), ('threadId', wt.DWORD),
|
|
('processId', wt.DWORD), ('basePriority', wt.LONG),
|
|
('deltaPriority', wt.LONG), ('flags', wt.DWORD)]
|
|
self.api.CreateToolhelp32Snapshot.argtypes = [wt.DWORD, wt.DWORD]
|
|
self.api.CreateToolhelp32Snapshot.restype = wt.HANDLE
|
|
self.api.Thread32First.argtypes = [wt.HANDLE, ct.POINTER(ThreadEntry)]
|
|
self.api.Thread32Next.argtypes = [wt.HANDLE, ct.POINTER(ThreadEntry)]
|
|
self.api.OpenThread.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]
|
|
self.api.OpenThread.restype = wt.HANDLE
|
|
self.api.ResumeThread.argtypes = [wt.HANDLE]
|
|
self.api.ResumeThread.restype = wt.DWORD
|
|
snapshot = self.api.CreateToolhelp32Snapshot(0x4, 0) # TH32CS_SNAPTHREAD
|
|
if snapshot in (None, ct.c_void_p(-1).value):
|
|
raise GateError('Cannot enumerate suspended worker thread')
|
|
entry = ThreadEntry()
|
|
entry.size = ct.sizeof(entry)
|
|
resumed = False
|
|
try:
|
|
valid = self.api.Thread32First(snapshot, ct.byref(entry))
|
|
while valid:
|
|
if entry.processId == process.pid:
|
|
thread = self.api.OpenThread(0x2, False, entry.threadId)
|
|
if not thread:
|
|
raise GateError('Cannot open suspended worker thread')
|
|
try:
|
|
if self.api.ResumeThread(thread) == 0xFFFFFFFF:
|
|
raise GateError('Cannot resume contained worker')
|
|
resumed = True
|
|
finally:
|
|
self.api.CloseHandle(thread)
|
|
valid = self.api.Thread32Next(snapshot, ct.byref(entry))
|
|
finally:
|
|
self.api.CloseHandle(snapshot)
|
|
if not resumed:
|
|
raise GateError('Suspended worker thread was not found')
|
|
|
|
def close(self):
|
|
if self.handle:
|
|
self.api.CloseHandle(self.handle)
|
|
self.handle = None
|
|
|
|
|
|
class ProcessRunner:
|
|
def __init__(self):
|
|
self.child = None
|
|
|
|
def codex_command(self, engine, *args):
|
|
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']):
|
|
raise GateError('Only separately qualified MCP/plugin capability overrides are permitted')
|
|
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','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()
|
|
input_path = directory / 'stdin.txt'
|
|
input_path.write_text(stdin or '', encoding='utf-8', newline='')
|
|
marker = dict(pid=None, argv=argv, startedUtc=utc(), session=str(directory))
|
|
atomic(engine.root / 'active-process.json', marker) # durable launch intent before spawn
|
|
process = None
|
|
job = WindowsJob(engine.config) if os.name == 'nt' else None
|
|
# Reader threads drain both pipes; logs are local, redacted, bounded, and never notifications.
|
|
import threading
|
|
log_error = []
|
|
def drain(stream, target):
|
|
total, pending = 0, b''
|
|
with open(target, 'w', encoding='utf-8', newline='') as output:
|
|
while chunk := stream.read1(8192):
|
|
total += len(chunk)
|
|
if total > engine.config['maxLogBytes']:
|
|
if not log_error:
|
|
log_error.append('log byte budget exceeded')
|
|
continue
|
|
pending += chunk
|
|
if len(pending) > min(engine.config['maxLogBytes'], 1048576):
|
|
if not log_error:
|
|
log_error.append('log byte budget exceeded for a single line')
|
|
pending = b''
|
|
continue
|
|
while b'\n' in pending:
|
|
line, pending = pending.split(b'\n', 1)
|
|
output.write(redact(line.rstrip(b'\r').decode('utf-8', errors='replace')) + '\n')
|
|
output.flush()
|
|
if pending and not log_error:
|
|
output.write(redact(pending.decode('utf-8', errors='replace')))
|
|
threads = []
|
|
try:
|
|
with open(input_path, 'rb') as input_stream:
|
|
process = subprocess.Popen(argv, cwd=cwd or engine.repo, env=env, stdin=input_stream,
|
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
creationflags=flags, start_new_session=os.name != 'nt')
|
|
self.child = process
|
|
marker['pid'] = process.pid
|
|
atomic(engine.root / 'active-process.json', marker)
|
|
if job:
|
|
job.assign(process)
|
|
job.resume(process) # no worker instruction can execute before containment
|
|
threads = [threading.Thread(target=drain, args=(process.stdout, directory / 'stdout.log'), daemon=True),
|
|
threading.Thread(target=drain, args=(process.stderr, directory / 'stderr.log'), daemon=True)]
|
|
for thread in threads:
|
|
thread.start()
|
|
while process.poll() is None:
|
|
if (engine.root / 'pause.request').exists():
|
|
raise Interrupted('Owner requested pause')
|
|
if time.monotonic() - started > seconds or time.monotonic() > engine.deadline:
|
|
raise Interrupted('Session/run time budget exceeded')
|
|
if not engine.in_window():
|
|
raise Interrupted('Execution window closed')
|
|
if log_error:
|
|
raise GateError(log_error[0])
|
|
time.sleep(0.25)
|
|
if process.returncode:
|
|
raise GateError(f'Process exited {process.returncode}; see redacted local logs')
|
|
finally:
|
|
if job:
|
|
job.close() # closes/kills all descendants, including on normal parent-child completion
|
|
if process is not None and process.poll() is None:
|
|
if os.name == 'nt':
|
|
subprocess.run(['taskkill', '/PID', str(process.pid), '/T', '/F'],
|
|
capture_output=True, timeout=20)
|
|
else:
|
|
os.killpg(process.pid, signal.SIGKILL)
|
|
process.wait(timeout=20)
|
|
for thread in threads:
|
|
thread.join(timeout=10)
|
|
if any(t.is_alive() for t in threads):
|
|
raise GateError('Descendant still holds log handles; reconcile active-process marker')
|
|
if process is not None:
|
|
process.stdout.close()
|
|
process.stderr.close()
|
|
self.child = None
|
|
(engine.root / 'active-process.json').unlink(missing_ok=True)
|
|
if log_error:
|
|
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',
|
|
*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), '-')
|
|
# Output is written by the CLI parent, not a model shell; worker cannot write protected state.
|
|
self.supervise(engine, argv, directory, engine.config['maxSessionSeconds'], stdin=prompt)
|
|
events = []
|
|
for line in (directory / 'stdout.log').read_text(encoding='utf-8').splitlines():
|
|
if not line.strip():
|
|
continue
|
|
try:
|
|
events.append(json.loads(line))
|
|
except ValueError:
|
|
raise GateError('CLI event stream is not valid JSONL')
|
|
if (not any(e.get('type') == 'turn.completed' for e in events)
|
|
or any(e.get('type') in ('turn.failed', 'error') for e in events)):
|
|
raise GateError('CLI did not produce an unambiguous completed turn')
|
|
thread_ids = [e['thread_id'] for e in events if e.get('type') == 'thread.started']
|
|
atomic(directory / 'session.json', dict(threadIds=thread_ids, finishedUtc=utc()))
|
|
data = read_json(result)
|
|
if (set(data) != {'outcome', 'summary', 'blockers', 'requestedApprovals', 'checkpoint'}
|
|
or data['outcome'] not in OUTCOMES or data['checkpoint'] != checkpoint['id']
|
|
or not isinstance(data['summary'], str)
|
|
or not isinstance(data['blockers'], list) or not isinstance(data['requestedApprovals'], list)
|
|
or not all(isinstance(x, str) for x in data['blockers'] + data['requestedApprovals'])):
|
|
raise GateError('Invalid structured handoff')
|
|
return data
|
|
|
|
def run_check(self, engine, check, directory):
|
|
if check.get('adapter') == 'assessment-evidence-inspection':
|
|
raise GateError('Evidence inspection cannot replace command-running composite qualification')
|
|
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
|
|
if report_spec:
|
|
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),
|
|
*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:
|
|
if not report.is_file():
|
|
raise GateError(f'Required test report missing: {report_spec["path"]}')
|
|
result.update(parse_report(report, report_spec))
|
|
# Preserve exact structured report separately; private local evidence, never notifications.
|
|
shutil.copyfile(report, directory / report.name)
|
|
result['report'] = {'path': str(directory / report.name), 'sha256': sha_file(report)}
|
|
atomic(directory / 'verification.json', result)
|
|
return result
|
|
|
|
|
|
class Engine:
|
|
def __init__(self, root, manifest, config, *, runner=None, approval_verifier=None, host_verifier=None):
|
|
self.root = Path(root).resolve()
|
|
self.manifest = manifest
|
|
self.config = config
|
|
if manifest.get('executionScope'):
|
|
self.config = dict(config)
|
|
self.config['assessmentReportDirectory'] = manifest['executionScope']['reportDirectory']
|
|
self.repo = Path(config['repository']).resolve()
|
|
self.store = StateStore(self.root)
|
|
self.runner = runner or ProcessRunner()
|
|
self.approval_verifier = approval_verifier or self.verify_approval
|
|
self.host_verifier = host_verifier or self.verify_host
|
|
self.deadline = float('inf')
|
|
|
|
def git(self, *args):
|
|
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'})
|
|
if p.returncode:
|
|
raise GateError(redact(f'git {args[0]} failed: {p.stderr.strip()}'))
|
|
return p.stdout.strip()
|
|
|
|
def initialize(self):
|
|
validate_manifest(self.manifest)
|
|
if self.store.path.exists() or self.store.backup.exists():
|
|
raise GateError('State already exists; use resume, never overwrite progress')
|
|
state = dict(schemaVersion=1, manifestSha256=digest(self.manifest),
|
|
integrationCommit=self.manifest['baselineCommit'], paused=False,
|
|
recoveryRequired=False, stages={}, recentSessions=[], latestFailure=None, ownerDecisions=[],
|
|
productionRelease='NOT_AUTHORISED')
|
|
for stage in self.manifest['stages']:
|
|
state['stages'][stage['id']] = dict(status='PENDING', checkpoint=0, attempts=0,
|
|
sessions=0, pendingPublication=None, latestCommit=None, evidence=[], reports=[],
|
|
lastResult=None, entryBaseline=None, checkpointBase=None, checkpointCommit=None,
|
|
awaiting=[], verifiedChecks=[], failures=[])
|
|
self.store.write(state)
|
|
|
|
def request(self, stage, cp, kind, state, detail=None):
|
|
ss = state['stages'][stage['id']]
|
|
request = dict(schemaVersion=1, manifestSha256=digest(self.manifest),
|
|
policySha256=self.policy_hash(), kind=kind, stage=stage['id'], checkpoint=cp['id'],
|
|
baselineCommit=ss['entryBaseline'] or state['integrationCommit'],
|
|
candidateCommit=ss['checkpointCommit'], evidence=ss['evidence'], detail=detail)
|
|
if kind in ('environment-qualified', 'roadmap-execution'):
|
|
request.update(stage='QUEUE', checkpoint='POLICY', baselineCommit=self.manifest['baselineCommit'],
|
|
candidateCommit=None, evidence=[], detail='Entire exact manifest; development only; no production authority')
|
|
if self.manifest.get('executionScope'):
|
|
request['executionScope'] = self.manifest['executionScope']
|
|
path = self.root / 'requests' / (digest(request) + '.json')
|
|
atomic(path, request)
|
|
approval = self.approval_verifier(request)
|
|
if not approval:
|
|
ss['awaiting'] = [dict(kind=kind, request=str(path))]
|
|
ss['status'] = 'AWAITING_APPROVAL'
|
|
self.store.write(state)
|
|
return False
|
|
ss['awaiting'] = []
|
|
if isinstance(approval, dict):
|
|
record = dict(requestSha256=digest(request), kind=kind, stage=stage['id'], checkpoint=cp['id'],
|
|
decision=approval.get('decision', 'Approved as requested'),
|
|
disposition=approval.get('disposition', 'Approve'), manifestSha256=digest(self.manifest))
|
|
if record not in state['ownerDecisions']:
|
|
state['ownerDecisions'].append(record)
|
|
self.store.write(state)
|
|
if approval.get('disposition', 'Approve') != 'Approve':
|
|
if approval.get('disposition') == 'Revise' and ss['checkpointCommit'] and kind in (
|
|
'homepage-design-selection', 'worker-decision', 'stage-requirements-review'):
|
|
ss['latestCommit'] = ss['checkpointCommit']
|
|
ss['checkpointCommit'] = None
|
|
ss['pendingPublication'] = None
|
|
ss['evidence'] = []
|
|
ss['verifiedChecks'] = []
|
|
ss['verificationPrepared'] = False
|
|
ss['attempts'] = 0
|
|
ss['status'] = 'PENDING'
|
|
else:
|
|
ss['status'] = 'BLOCKED'
|
|
ss['awaiting'] = [dict(kind=kind, request=str(path), decision='Owner rejected or requested changed scope')]
|
|
self.store.write(state)
|
|
return False
|
|
return True
|
|
|
|
def policy_hash(self):
|
|
files = ['controller.py', 'local_mode.py', 'assessment_adapter.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):
|
|
path = self.root / 'approvals' / (digest(request) + '.json')
|
|
if not path.is_file():
|
|
return False
|
|
p = subprocess.run([self.config['powershellExecutable'], '-NoProfile', '-File',
|
|
str(self.root / 'Approve-JobjaktBacklog.ps1'), '-Action', 'Verify',
|
|
'-RequestPath', str(self.root / 'requests' / (digest(request) + '.json')),
|
|
'-ApprovalPath', str(path), '-CertificatePath', str(self.root / self.config['publicKeyCertificate']),
|
|
'-ExpectedOwnerSid', self.config['ownerSid']], capture_output=True, text=True, timeout=30)
|
|
if p.returncode:
|
|
return False
|
|
envelope = read_json(path)
|
|
return json.loads(base64.b64decode(envelope['payloadBase64']))
|
|
|
|
def verify_host(self):
|
|
if self.root.is_relative_to(self.repo) or self.repo.is_relative_to(self.root):
|
|
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)
|
|
if self.manifest.get('executionScope') and not local_mode.single(self.config):
|
|
raise GateError('Restricted assessment filesystem profile currently qualifies single-user-local only')
|
|
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')
|
|
for field in ('maxSessionSeconds', 'maxCheckSeconds', 'maxRunSeconds', 'maxLogBytes'):
|
|
if not isinstance(self.config[field], int) or self.config[field] <= 0:
|
|
raise GateError('Positive integer resource budgets required')
|
|
identity = subprocess.run(['whoami', '/user', '/fo', 'csv', '/nh'], capture_output=True,
|
|
text=True, timeout=15).stdout
|
|
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,
|
|
text=True, timeout=15)
|
|
found = re.search(r'(\d+)\.(\d+)\.(\d+)', version.stdout)
|
|
if not found or tuple(map(int, found.groups())) < (0, 163, 0):
|
|
raise GateError('Codex CLI 0.163.0 or later required; qualify new versions before deployment')
|
|
worker_home = Path(self.config['workerCodeXHome']).resolve()
|
|
if worker_home.is_relative_to(self.repo):
|
|
raise GateError('CODEX_HOME must be outside worker workspace')
|
|
# Capability settings are a protected, signed policy input, not worker-controlled config.
|
|
ProcessRunner().capability_args(self)
|
|
# Refuse user/plugin configuration and repo MCP endpoints. A dedicated auth-only home is required.
|
|
if (worker_home / 'config.toml').exists() or (worker_home / 'plugins').exists():
|
|
raise GateError('Use a dedicated auth-only CODEX_HOME; do not inherit MCP/plugins/config')
|
|
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')
|
|
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.
|
|
sentinel = self.root / ('sandbox-deny-' + uuid.uuid4().hex + '.txt')
|
|
script = ('try { [IO.File]::WriteAllText(' + "'" + str(sentinel).replace("'", "''")
|
|
+ "','unexpected'); exit 9 } catch { exit 0 }")
|
|
argv = [self.config['codexExecutable'], 'sandbox', '-C', str(self.repo),
|
|
'-c', 'sandbox_mode="workspace-write"', '-c', 'approval_policy="never"',
|
|
'-c', 'sandbox_workspace_write.network_access=false',
|
|
self.config['powershellExecutable'], '-NoProfile', '-Command', script]
|
|
probe = self.root / 'probe' / uuid.uuid4().hex
|
|
try:
|
|
self.runner.supervise(self, argv, probe, 30)
|
|
if sentinel.exists():
|
|
raise GateError('Sandbox wrote to protected control plane')
|
|
finally:
|
|
sentinel.unlink(missing_ok=True)
|
|
|
|
def verify_remote(self, branch, commit):
|
|
try:
|
|
lines = self.git('ls-remote', '--exit-code', self.manifest['remote'], 'refs/heads/' + branch)
|
|
if lines.split()[0] != commit:
|
|
raise PublicationError(f'Remote HEAD differs: {branch}')
|
|
except (GateError, IndexError) as error:
|
|
raise PublicationError(f'Publication not verified for {branch}: {error}') from error
|
|
|
|
def publish(self, branch, commit):
|
|
if not SAFE_BRANCH.fullmatch(branch):
|
|
raise PublicationError('Refusing publication to protected branch')
|
|
try:
|
|
self.git('push', self.manifest['remote'], f'{commit}:refs/heads/{branch}')
|
|
self.verify_remote(branch, commit)
|
|
except GateError as error:
|
|
raise PublicationError(str(error)) from error
|
|
|
|
def event(self, kind, stage, message):
|
|
path = self.root / 'events.jsonl'
|
|
# Events contain IDs and bounded redacted status only, never prompts or documents.
|
|
with open(path, 'a', encoding='utf-8') as stream:
|
|
stream.write(json.dumps(dict(at=utc(), event=kind, stage=stage,
|
|
message=redact(message)[:600])) + '\n')
|
|
|
|
def pause(self):
|
|
atomic(self.root / 'pause.request', dict(at=utc()))
|
|
|
|
def resume(self):
|
|
with RepoLock(self.repo):
|
|
state = self.store.read()
|
|
if state.get('recoveryRequired'):
|
|
raise GateError('Recovered backup requires signed state-recovery approval; run recovery command')
|
|
(self.root / 'pause.request').unlink(missing_ok=True)
|
|
state['paused'] = False
|
|
self.store.write(state)
|
|
|
|
def retry(self, stage_id):
|
|
with RepoLock(self.repo):
|
|
state = self.store.read()
|
|
stage = next(s for s in self.manifest['stages'] if s['id'] == stage_id)
|
|
ss = state['stages'][stage_id]
|
|
if ss['status'] != 'FAILED':
|
|
raise GateError('Only FAILED stages can be retried; resume handles blocked publication/approvals')
|
|
if ss['attempts'] >= stage['retryPolicy']['maxAttempts']:
|
|
# Budget resets are never implicit. Owner signs an exact reset request through run().
|
|
ss['status'] = 'AWAITING_APPROVAL'
|
|
ss['budgetResetRequested'] = True
|
|
else:
|
|
ss['status'] = 'PENDING'
|
|
self.store.write(state)
|
|
|
|
def in_window(self):
|
|
windows = self.config.get('executionWindows', [])
|
|
if not windows:
|
|
return True
|
|
now = datetime.now().strftime('%H:%M')
|
|
return any((w['start'] <= now < w['end']) if w['start'] < w['end']
|
|
else (now >= w['start'] or now < w['end']) for w in windows)
|
|
|
|
def prepare_branch(self, stage, ss, state):
|
|
current = self.git('branch', '--show-current')
|
|
if current in ('main', 'master', 'production', ''):
|
|
raise GateError('Checkout of main/master/production or detached HEAD is prohibited')
|
|
if ss['entryBaseline'] is None:
|
|
if self.git('status', '--porcelain', '--untracked-files=all'):
|
|
raise GateError('New stage requires a clean worktree, including untracked files')
|
|
scope = self.manifest.get('executionScope')
|
|
if scope and not any(x['status'] == 'COMPLETED' for x in state['stages'].values()):
|
|
self.verify_remote(scope['handoffBranch'], scope['handoffCommit'])
|
|
self.git('merge-base', '--is-ancestor', state['integrationCommit'], scope['handoffCommit'])
|
|
changed = self.git('diff','--name-only',state['integrationCommit'],scope['handoffCommit']).splitlines()
|
|
if any(not name.startswith('docs/automation/handoffs/pre-phase10/') for name in changed):
|
|
raise GateError('Published handoff changed outside the evidence envelope')
|
|
else:
|
|
self.verify_remote(self.manifest['baselineBranch'] if not any(
|
|
x['status'] == 'COMPLETED' for x in state['stages'].values())
|
|
else self.manifest['integrationBranch'], state['integrationCommit'])
|
|
baseline = state['integrationCommit']
|
|
branches = self.git('for-each-ref', '--format=%(refname:short)', 'refs/heads').splitlines()
|
|
if stage['branch'] in branches:
|
|
if self.git('rev-parse', stage['branch']) != baseline:
|
|
raise GateError('Existing stage branch is not exactly the accepted baseline')
|
|
self.git('switch', stage['branch'])
|
|
else:
|
|
self.git('switch', '-c', stage['branch'], baseline)
|
|
ss['entryBaseline'] = baseline
|
|
self.store.write(state)
|
|
elif current != stage['branch']:
|
|
raise GateError('Unexpected branch after interruption; reconcile manually')
|
|
self.git('merge-base', '--is-ancestor', ss['entryBaseline'], 'HEAD')
|
|
expected = ss['checkpointCommit'] or ss['latestCommit'] or ss['entryBaseline']
|
|
if self.git('rev-parse', 'HEAD') != expected:
|
|
# Crash between commit and state save: exact controller trailer permits reconciliation.
|
|
trailer = f'Orchestrator-Checkpoint: {ss.get("transaction", "missing")}'
|
|
body = self.git('log', '-1', '--format=%B')
|
|
parent = self.git('rev-parse', 'HEAD^')
|
|
if (trailer not in body or parent != expected or not ss.get('verificationPrepared')
|
|
or self.content_hash() != ss.get('testedContentSha256')):
|
|
raise GateError('Unexpected HEAD; refusing stale evidence or unrelated commit')
|
|
self.verify_evidence(ss)
|
|
ss['checkpointCommit'] = self.git('rev-parse', 'HEAD')
|
|
ss['pendingPublication'] = ss['checkpointCommit']
|
|
self.store.write(state)
|
|
|
|
def prompt(self, stage, cp, ss, state):
|
|
source = under(self.root, stage['sourcePrompt'])
|
|
if stage['sourceStatus'] != 'VERIFIED' or not source.is_file() or not source.stat().st_size:
|
|
raise GateError(f'Missing complete authoritative prompt for {stage["id"]}')
|
|
if sha_file(source) != stage['promptSha256']:
|
|
raise GateError('Prompt hash changed; owner must install/re-authorize a new manifest')
|
|
chunks = [source.read_text(encoding='utf-8-sig')]
|
|
for item in stage.get('supplementalPrompts', []):
|
|
path = under(self.root, item['path'])
|
|
if sha_file(path) != item['sha256']:
|
|
raise GateError('Supplemental prompt hash changed')
|
|
if item.get('checkpoints') is not None and cp['id'] not in item['checkpoints']:
|
|
continue
|
|
chunks.append(f'\nCOMPLETE SUPPLEMENTAL INSTRUCTIONS {item["path"]}\n' + path.read_text(encoding='utf-8-sig'))
|
|
for name in stage['contextFiles']:
|
|
path = under(self.repo, name)
|
|
if not path.is_file():
|
|
raise GateError('Required prior context missing: ' + name)
|
|
chunks.append(f'\nPRIOR AUTHORITY {name}\n' + path.read_text(encoding='utf-8-sig'))
|
|
for dependency in stage['dependencies']:
|
|
prior = state['stages'][dependency]
|
|
if prior['reports']:
|
|
receipt = prior['reports'][-1]
|
|
report_path = Path(receipt['path'])
|
|
if sha_file(report_path) != receipt['sha256']:
|
|
raise GateError('Prior acceptance report changed')
|
|
chunks.append(f'\nFULL ACCEPTED DEPENDENCY REPORT {dependency}\n' + report_path.read_text(encoding='utf-8'))
|
|
for evidence in prior['evidence']:
|
|
path = under(self.repo, evidence['path'])
|
|
if sha_file(path) != evidence['sha256']:
|
|
raise GateError('Prior accepted artifact changed')
|
|
if path.suffix.lower() in ('.md', '.txt', '.json'):
|
|
chunks.append(f'\nFULL PRIOR ACCEPTANCE ARTIFACT {evidence["path"]}\n' + path.read_text(encoding='utf-8-sig'))
|
|
chunks.append('\nPRIOR CONTROLLER REPORTS\n' + json.dumps(state['recentSessions'][-12:], ensure_ascii=False))
|
|
chunks.append('\nVERIFIED OWNER DECISIONS\n' + json.dumps(state['ownerDecisions'], ensure_ascii=False))
|
|
chunks.append('\nACTIVE CHECKPOINT ONLY\n' + json.dumps(dict(stage=stage['id'], checkpoint=cp,
|
|
integrationCommit=state['integrationCommit'], lastResult=ss['lastResult'],
|
|
priorEvidence=ss['evidence'], failures=ss['failures'][-5:],
|
|
remainingAttempts=stage['retryPolicy']['maxAttempts'] - ss['attempts']),
|
|
ensure_ascii=False))
|
|
chunks.append('''\nCONTROL CONTRACT: Work only on this checkpoint and current branch. Read repository AGENTS.md.
|
|
Do not start another checkpoint, another Codex process, or another orchestrator. Do not commit,
|
|
push, switch branches, merge, deploy, change approval/control files, access production services,
|
|
enable models, accept security risks, buy services or remove failing tests. The controller owns Git
|
|
publication and independent verification. Report blockers honestly. Preserve ALL source prompt
|
|
requirements; where a prompt stops pending owner approval, return outcome approval. A signed
|
|
checkpoint entry grant permits only its named implementation scope; it never approves production.
|
|
Return the required structured JSON. outcome checkpoint means more work is needed on this same
|
|
checkpoint; complete means ready for independent checks. Do not infer acceptance from a test count.
|
|
''')
|
|
return '\n'.join(chunks)
|
|
|
|
def verify_evidence(self, ss):
|
|
for artifact in ss['evidence']:
|
|
path = under(self.repo, artifact['path'])
|
|
if not path.is_file() or sha_file(path) != artifact['sha256']:
|
|
raise GateError('Checkpoint evidence changed since verification')
|
|
for check in ss['verifiedChecks']:
|
|
if check.get('report'):
|
|
report = Path(check['report']['path'])
|
|
if not report.is_file() or sha_file(report) != check['report']['sha256']:
|
|
raise GateError('Trusted test report missing or changed')
|
|
|
|
def checkpoint(self, stage, cp, ss, state):
|
|
# Recovery after commit/push uses stored evidence, never another model session.
|
|
if ss['pendingPublication']:
|
|
self.verify_evidence(ss)
|
|
self.publish(stage['branch'], ss['pendingPublication'])
|
|
ss['latestCommit'] = ss['pendingPublication']
|
|
ss['pendingPublication'] = None
|
|
self.store.write(state)
|
|
return self.finish_checkpoint(stage, cp, ss, state)
|
|
if ss['checkpointCommit']:
|
|
return self.finish_checkpoint(stage, cp, ss, state)
|
|
if ss['attempts'] >= stage['retryPolicy']['maxAttempts']:
|
|
if not ss.get('budgetResetRequested'):
|
|
ss['status'] = 'FAILED'
|
|
self.store.write(state)
|
|
return False
|
|
if not self.request(stage, cp, 'retry-budget-reset', state, ss['failures']):
|
|
return False
|
|
ss['attempts'] = 0
|
|
ss['budgetResetRequested'] = False
|
|
if ss['sessions'] >= stage['retryPolicy']['maxSessions']:
|
|
raise GateError('Total stage session limit reached; owner must revise policy')
|
|
for kind in cp['approvalsBefore']:
|
|
if not self.request(stage, cp, kind, state):
|
|
return False
|
|
if ss.get('requestedDecision'):
|
|
if not self.request(stage, cp, 'worker-decision', state, ss['requestedDecision']):
|
|
return False
|
|
ss.pop('requestedDecision')
|
|
prompt = self.prompt(stage, cp, ss, state)
|
|
if not cp['checks'] or any(check.get('adapter') == 'assessment-evidence-inspection'
|
|
or not check.get('configured', True) for check in cp['checks']):
|
|
raise GateError('Independent test adapters must be configured and owner-qualified before execution')
|
|
ss['attempts'] += 1
|
|
ss['sessions'] += 1
|
|
ss['status'] = 'RUNNING'
|
|
ss['checkpointBase'] = self.git('rev-parse', 'HEAD')
|
|
session_id = uuid.uuid4().hex
|
|
ss['transaction'] = session_id
|
|
directory = self.root / 'sessions' / stage['id'] / cp['id'] / session_id
|
|
directory.mkdir(parents=True)
|
|
atomic(directory / 'input.json', dict(prompt=prompt, promptSha256=stage['promptSha256'],
|
|
sourceCommit=ss['checkpointBase'], manifestSha256=digest(self.manifest), at=utc()))
|
|
self.store.write(state) # durable RUNNING before launching child
|
|
result = self.runner.run_session(self, stage, cp, directory, prompt)
|
|
self.verify_assessment_writes()
|
|
ss['lastResult'] = result
|
|
state['recentSessions'].append(dict(id=session_id, stage=stage['id'], checkpoint=cp['id'],
|
|
outcome=result['outcome'], summary=redact(result['summary'])[:600], directory=str(directory)))
|
|
self.store.write(state)
|
|
if self.git('rev-parse', 'HEAD') != ss['checkpointBase'] or self.git('branch', '--show-current') != stage['branch']:
|
|
raise GateError('Worker performed forbidden Git operation')
|
|
if result['outcome'] == 'blocked':
|
|
ss['requestedDecision'] = result
|
|
ss['status'] = 'BLOCKED'
|
|
self.store.write(state)
|
|
return False
|
|
if result['outcome'] == 'failed':
|
|
raise GateError('Worker reports failure: ' + redact(result['summary']))
|
|
if result['outcome'] == 'conditional' and not cp['deferrableGates']:
|
|
ss['status'] = 'CONDITIONALLY_ACCEPTED'
|
|
ss['requestedDecision'] = result
|
|
self.store.write(state)
|
|
return False
|
|
if result['outcome'] == 'checkpoint':
|
|
ss['status'] = 'PENDING'
|
|
ss['attempts'] -= 1 # bounded by total sessions, not an ordinary failure
|
|
self.store.write(state)
|
|
return True
|
|
ss['verifiedChecks'] = []
|
|
for check in cp['checks']:
|
|
ss['verifiedChecks'].append(self.runner.run_check(self, check, directory / 'checks' / check['id']))
|
|
self.verify_assessment_writes()
|
|
ss['evidence'] = []
|
|
for name in cp['requiredArtifacts']:
|
|
artifact = under(self.repo, name)
|
|
if not artifact.is_file() or artifact.stat().st_size == 0:
|
|
raise GateError('Required nonempty artifact missing: ' + name)
|
|
ss['evidence'].append(dict(path=name, sha256=sha_file(artifact)))
|
|
if result['outcome'] == 'complete' and (result['blockers'] or result['requestedApprovals']):
|
|
raise GateError('Complete result still contains blockers/approval requests')
|
|
# Pin tested content before Git hooks or unrelated processes can alter it.
|
|
before = self.content_hash()
|
|
ss['testedContentSha256'] = before
|
|
ss['verificationPrepared'] = True
|
|
self.store.write(state) # crash after commit can reconcile only these independently verified bytes
|
|
self.git('add', '--all')
|
|
if self.content_hash() != before:
|
|
raise GateError('Worktree changed during verification/commit')
|
|
if self.git('diff', '--cached', '--name-only'):
|
|
self.git('commit', '-m', f'{stage["id"]}: {cp["id"]} validated checkpoint\n\nOrchestrator-Checkpoint: {session_id}')
|
|
ss['checkpointCommit'] = self.git('rev-parse', 'HEAD')
|
|
ss['pendingPublication'] = ss['checkpointCommit']
|
|
ss['testedContentSha256'] = before
|
|
ss['status'] = 'ACCEPTED'
|
|
report = dict(stage=stage['id'], checkpoint=cp['id'], candidateCommit=ss['checkpointCommit'],
|
|
promptSha256=stage['promptSha256'], checks=ss['verifiedChecks'], evidence=ss['evidence'],
|
|
result=result, productionRelease='NOT_AUTHORISED')
|
|
report_path = self.root / 'reports' / (session_id + '.json')
|
|
atomic(report_path, report)
|
|
ss['reports'].append(dict(path=str(report_path), sha256=sha_file(report_path)))
|
|
self.store.write(state)
|
|
self.publish(stage['branch'], ss['pendingPublication'])
|
|
ss['latestCommit'] = ss['pendingPublication']
|
|
ss['pendingPublication'] = None
|
|
self.store.write(state)
|
|
return self.finish_checkpoint(stage, cp, ss, state)
|
|
|
|
def verify_assessment_writes(self):
|
|
scope = self.manifest.get('executionScope')
|
|
if not scope:
|
|
return
|
|
prefix = scope['reportDirectory'] + '/'
|
|
changed = self.git('diff', '--name-only', 'HEAD', '-z').split('\0')
|
|
changed += self.git('ls-files', '--others', '--exclude-standard', '-z').split('\0')
|
|
deleted = self.git('diff', '--name-only', '--diff-filter=D', 'HEAD')
|
|
if deleted or any(name and (not name.startswith(prefix) or not name.endswith('.md')) for name in changed):
|
|
raise GateError('Read-only assessment modified a forbidden path or deleted a file')
|
|
|
|
def content_hash(self):
|
|
# Git blob hashes include tracked modifications and untracked source without reading secrets into logs.
|
|
files = self.git('ls-files', '--cached', '--others', '--exclude-standard', '-z').split('\0')
|
|
return digest({name: sha_file(under(self.repo, name)) for name in sorted(set(files))
|
|
if name and under(self.repo, name).is_file()})
|
|
|
|
def finish_checkpoint(self, stage, cp, ss, state):
|
|
self.verify_evidence(ss)
|
|
if ss.get('testedContentSha256') and self.content_hash() != ss['testedContentSha256']:
|
|
raise GateError('Tested source changed before acceptance')
|
|
self.verify_remote(stage['branch'], ss['checkpointCommit'])
|
|
kinds = list(cp['approvalsAfter'])
|
|
if ss['lastResult']['outcome'] == 'approval':
|
|
kinds.append('worker-decision')
|
|
if ss['lastResult']['outcome'] == 'conditional':
|
|
kinds.append('conditional-deferral')
|
|
for kind in kinds:
|
|
detail = dict(requirements=cp['evidenceRequirements'], workerResult=ss['lastResult'],
|
|
deferrableGates=cp['deferrableGates'] if kind == 'conditional-deferral' else [])
|
|
if not self.request(stage, cp, kind, state, detail):
|
|
return False
|
|
ss['checkpoint'] += 1
|
|
ss['attempts'] = 0
|
|
ss['checkpointCommit'] = None
|
|
ss['checkpointBase'] = None
|
|
ss['verificationPrepared'] = False
|
|
ss['lastVerifiedChecks'] = ss['verifiedChecks']
|
|
ss['verifiedChecks'] = []
|
|
ss['status'] = 'PENDING'
|
|
self.store.write(state)
|
|
if ss['checkpoint'] == len(stage['checkpoints']):
|
|
self.integrate_stage(stage, ss, state)
|
|
return True
|
|
|
|
def integrate_stage(self, stage, ss, state):
|
|
commit = ss['latestCommit']
|
|
integration = self.manifest['integrationBranch']
|
|
if self.git('rev-parse', 'HEAD') != commit or self.git('status', '--porcelain', '--untracked-files=all'):
|
|
raise GateError('Integration requires the clean, verified stage commit')
|
|
self.verify_remote(stage['branch'], commit)
|
|
self.git('merge-base', '--is-ancestor', state['integrationCommit'], commit)
|
|
existing = self.git('for-each-ref', '--format=%(objectname)', 'refs/heads/' + integration)
|
|
if existing and existing not in (state['integrationCommit'], commit):
|
|
raise GateError('Integration branch diverged; no automatic merge/rebase')
|
|
self.git('update-ref', 'refs/heads/' + integration, commit, existing or '0'*40)
|
|
self.publish(integration, commit)
|
|
state['integrationCommit'] = commit
|
|
ss['status'] = 'COMPLETED'
|
|
self.event('stage-completed', stage['id'], 'Development accepted and published; production remains unauthorised')
|
|
self.store.write(state)
|
|
|
|
def run(self):
|
|
self.deadline = time.monotonic() + self.config['maxRunSeconds']
|
|
with RepoLock(self.repo):
|
|
state = self.store.read()
|
|
if state['manifestSha256'] != digest(self.manifest):
|
|
raise GateError('Manifest changed since initialization; owner-controlled migration required')
|
|
if not self.manifest['executionEnabled']:
|
|
state['latestFailure'] = 'Execution disabled: initial orchestrator-building task only'
|
|
self.store.write(state)
|
|
return
|
|
if (self.root / 'pause.request').exists() or state['paused']:
|
|
state['paused'] = True
|
|
self.store.write(state)
|
|
return
|
|
if state.get('recoveryRequired') or (self.root / 'active-process.json').exists():
|
|
raise GateError('Recovery/orphan reconciliation required; no model launched')
|
|
if any(s['status'] == 'RUNNING' for s in state['stages'].values()):
|
|
state['recoveryRequired'] = True
|
|
self.store.write(state)
|
|
raise GateError('Interrupted RUNNING state requires signed orphan reconciliation, even without a PID marker')
|
|
self.host_verifier()
|
|
for stage in self.manifest['stages']:
|
|
ss = state['stages'][stage['id']]
|
|
if ss['status'] == 'COMPLETED':
|
|
continue
|
|
if not all(state['stages'][dep]['status'] == 'COMPLETED' for dep in stage['dependencies']):
|
|
ss['status'] = 'BLOCKED'
|
|
state['latestFailure'] = 'Dependencies are not accepted and published'
|
|
self.store.write(state)
|
|
break
|
|
while ss['checkpoint'] < len(stage['checkpoints']):
|
|
cp = stage['checkpoints'][ss['checkpoint']]
|
|
if (self.root / 'pause.request').exists() or time.monotonic() > self.deadline or not self.in_window():
|
|
state['paused'] = (self.root / 'pause.request').exists()
|
|
self.store.write(state)
|
|
return
|
|
try:
|
|
scope = self.manifest.get('executionScope')
|
|
if scope and (stage['id'], cp['id']) != (scope['stage'], scope['checkpoint']):
|
|
raise GateError('Restricted assessment scope ends after P10-00; new owner-scoped policy required')
|
|
# Check source before requesting approval or modifying Git.
|
|
self.prompt(stage, cp, ss, state)
|
|
if ss['entryBaseline'] is None:
|
|
for kind in ['environment-qualified', 'roadmap-execution', *stage['requiredOwnerApprovals']]:
|
|
if kind == 'product-ranked-scope' and any(d['kind'] == kind and
|
|
d.get('disposition') == 'Approve' and d.get('manifestSha256') == digest(self.manifest)
|
|
for d in state['ownerDecisions']):
|
|
continue
|
|
if not self.request(stage, cp, kind, state):
|
|
return
|
|
self.prepare_branch(stage, ss, state)
|
|
if not self.checkpoint(stage, cp, ss, state):
|
|
return
|
|
except Interrupted as error:
|
|
ss['status'] = 'BLOCKED'
|
|
state['latestFailure'] = str(error)
|
|
self.store.write(state)
|
|
self.event('interrupted', stage['id'], str(error))
|
|
return
|
|
except PublicationError as error:
|
|
ss['status'] = 'BLOCKED'
|
|
state['latestFailure'] = str(error)
|
|
self.store.write(state)
|
|
self.event('publication-blocked', stage['id'], str(error))
|
|
return
|
|
except (GateError, OSError, ValueError, KeyError, subprocess.SubprocessError) as error:
|
|
message = redact(str(error))
|
|
state['latestFailure'] = message
|
|
ss['failures'].append(dict(at=utc(), checkpoint=cp['id'], message=message))
|
|
if (self.root / 'active-process.json').exists():
|
|
ss['status'] = 'BLOCKED'
|
|
self.store.write(state)
|
|
return # failed cleanup can never launch an overlapping remediation worker
|
|
# Failures after acceptance are policy/evidence failures, never remediation input.
|
|
if ss['attempts'] and not ss['checkpointCommit'] and ss['status'] == 'RUNNING':
|
|
ss['status'] = 'FAILED'
|
|
self.store.write(state)
|
|
if ss['attempts'] < stage['retryPolicy']['maxAttempts']:
|
|
wait = stage['retryPolicy']['backoffSeconds']
|
|
until = time.monotonic() + wait
|
|
while time.monotonic() < until:
|
|
if (self.root / 'pause.request').exists() or time.monotonic() > self.deadline:
|
|
return
|
|
time.sleep(0.25)
|
|
continue
|
|
else:
|
|
ss['status'] = 'BLOCKED'
|
|
self.store.write(state)
|
|
self.event('blocked', stage['id'], message)
|
|
return
|
|
# Resume after a crash while publishing the integration ref.
|
|
if ss['checkpoint'] == len(stage['checkpoints']) and ss['status'] != 'COMPLETED':
|
|
try:
|
|
self.integrate_stage(stage, ss, state)
|
|
except GateError as error:
|
|
ss['status'] = 'BLOCKED'
|
|
state['latestFailure'] = str(error)
|
|
self.store.write(state)
|
|
return
|
|
if all(s['status'] == 'COMPLETED' for s in state['stages'].values()):
|
|
self.event('roadmap-development-completed', None, 'Awaiting independent production release approval; no deploy action exists')
|
|
|
|
|
|
def status(engine):
|
|
state = engine.store.read()
|
|
stages = []
|
|
for stage in engine.manifest['stages']:
|
|
ss = state['stages'][stage['id']]
|
|
checks = ss['verifiedChecks'] or ss.get('lastVerifiedChecks', [])
|
|
stages.append(dict(id=stage['id'], status=ss['status'],
|
|
checkpoint=stage['checkpoints'][ss['checkpoint']]['id'] if ss['checkpoint'] < len(stage['checkpoints']) else 'done',
|
|
branch=stage['branch'], latestVerifiedCommit=ss['latestCommit'],
|
|
attempts=ss['attempts'], sessions=ss['sessions'], requiredOwnerAction=ss['awaiting'],
|
|
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(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, mode='dedicated-runner'):
|
|
root = root.resolve()
|
|
repo = Path(repository).resolve()
|
|
if root.exists():
|
|
raise GateError('ControlRoot must be a new directory; do not overwrite trusted state')
|
|
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', 'local_mode.py', 'assessment_adapter.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()
|
|
|
|
|
|
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',
|
|
'setup','auth','auth-status','qualify','approve','prepare','prepare-entry','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 in ('init','setup'):
|
|
if not args.repository:
|
|
parser.error('init requires --repository (isolated development clone/worktree)')
|
|
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','assessment_adapter.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 ('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-entry':
|
|
with RepoLock(engine.repo):
|
|
validate_manifest(engine.manifest,root)
|
|
state=engine.store.read()
|
|
scope=engine.manifest.get('executionScope')
|
|
if not scope or not engine.manifest['executionEnabled']:
|
|
raise GateError('Adopt the signed restricted assessment policy first')
|
|
if (root/'active-process.json').exists() or state['recoveryRequired'] or any(s['sessions'] for s in state['stages'].values()):
|
|
raise GateError('Entry preparation requires an unused reconciled queue')
|
|
local_mode.require_unattended(root,engine.config)
|
|
if engine.git('status','--porcelain','--untracked-files=all') or engine.git('rev-parse','HEAD') != engine.manifest['baselineCommit']:
|
|
raise GateError('Assessment entry requires a clean exact baseline checkout')
|
|
engine.verify_remote(scope['handoffBranch'],scope['handoffCommit'])
|
|
engine.git('merge-base','--is-ancestor',engine.manifest['baselineCommit'],scope['handoffCommit'])
|
|
stage=next(s for s in engine.manifest['stages'] if s['id']==scope['stage'])
|
|
cp=next(x for x in stage['checkpoints'] if x['id']==scope['checkpoint'])
|
|
engine.prompt(stage,cp,state['stages'][stage['id']],state)
|
|
if not any(check['id']=='qualified-provider-browser-python-pdf-security-preservation'
|
|
and check.get('configured') is True for check in cp['checks']):
|
|
raise GateError('P10-00 composite adapter is not qualified')
|
|
for check in cp['checks']:
|
|
if check.get('adapter') == 'assessment-evidence-inspection' or not check.get('configured'):
|
|
raise GateError('P10-00 composite adapter is not qualified')
|
|
for kind in ['environment-qualified','roadmap-execution',*stage['requiredOwnerApprovals'],*cp['approvalsBefore']]:
|
|
if not engine.request(stage,cp,kind,state):
|
|
print('Protected entry request prepared; approve it and run prepare-entry again. No worker launched.')
|
|
return 3
|
|
print('All exact restricted entry approvals verified. Owner may deliberately run start. No worker launched.')
|
|
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)
|
|
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)}),
|
|
checkpoints=sum(len(s['checkpoints']) for s in engine.manifest['stages']),
|
|
phase10='NO-GO', note='Read-only planning; no model, Git mutation, network, service, signing or scheduling action')
|
|
print(json.dumps(report, indent=2))
|
|
elif args.command == 'pause':
|
|
engine.pause()
|
|
print('Pause persisted; active process tree will be stopped by the controller.')
|
|
elif args.command in ('status', 'current'):
|
|
data = status(engine)
|
|
if args.command == 'current':
|
|
data = next((s for s in data['stages'] if s['status'] != 'COMPLETED'), {'status': 'COMPLETED_DEVELOPMENT'})
|
|
print(json.dumps(data, indent=2))
|
|
elif args.command == 'logs':
|
|
path = root / 'events.jsonl'
|
|
print('\n'.join(path.read_text(encoding='utf-8').splitlines()[-25:]) if path.exists() else 'No events yet')
|
|
elif args.command == 'retry':
|
|
if not args.stage:
|
|
parser.error('retry requires --stage')
|
|
engine.retry(args.stage)
|
|
elif args.command == 'configure':
|
|
if not args.manifest:
|
|
parser.error('configure requires --manifest (owner-prepared proposed manifest)')
|
|
proposed = read_json(args.manifest)
|
|
validate_manifest(proposed, root)
|
|
with RepoLock(engine.repo):
|
|
state = engine.store.read()
|
|
if (root / 'active-process.json').exists() or state['recoveryRequired']:
|
|
raise GateError('Recover/reconcile active work before changing policy')
|
|
if [s['id'] for s in proposed['stages']] != [s['id'] for s in engine.manifest['stages']]:
|
|
raise GateError('This policy adoption preserves the existing stage IDs/order; new queues need a new installation')
|
|
for old, new in zip(engine.manifest['stages'], proposed['stages']):
|
|
if old != new and state['stages'][old['id']]['sessions']:
|
|
raise GateError('Cannot rewrite a started stage; preserve its immutable policy and evidence')
|
|
if any(s['sessions'] for s in state['stages'].values()) and (
|
|
proposed['baselineCommit'] != engine.manifest['baselineCommit'] or
|
|
proposed['integrationBranch'] != engine.manifest['integrationBranch'] or
|
|
proposed['remote'] != engine.manifest['remote']):
|
|
raise GateError('Cannot replace baseline/integration/remote after execution starts')
|
|
stage = next((s for s in engine.manifest['stages'] if state['stages'][s['id']]['status'] != 'COMPLETED'),
|
|
engine.manifest['stages'][-1])
|
|
cp = stage['checkpoints'][min(state['stages'][stage['id']]['checkpoint'], len(stage['checkpoints']) - 1)]
|
|
if not engine.request(stage, cp, 'manifest-policy-update', state,
|
|
dict(proposedManifestSha256=digest(proposed))):
|
|
return 3
|
|
state['manifestSha256'] = digest(proposed)
|
|
if not any(s['sessions'] for s in state['stages'].values()):
|
|
state['integrationCommit'] = proposed['baselineCommit']
|
|
atomic(root / 'backlog.json', proposed)
|
|
engine.store.write(state)
|
|
print('Signed manifest adopted. Resume still enforces environment and stage entry approvals.')
|
|
elif args.command == 'recover':
|
|
with RepoLock(engine.repo):
|
|
state = engine.store.read()
|
|
state['recoveryRequired'] = True
|
|
engine.store.write(state) # a pending/unsigned request cannot clear the interrupted-run guard
|
|
stage = next(s for s in engine.manifest['stages'] if state['stages'][s['id']]['status'] != 'COMPLETED')
|
|
cp = stage['checkpoints'][min(state['stages'][stage['id']]['checkpoint'], len(stage['checkpoints']) - 1)]
|
|
marker = read_json(root / 'active-process.json') if (root / 'active-process.json').exists() else None
|
|
if not engine.request(stage, cp, 'recovery-reconciled', state, marker):
|
|
return 3
|
|
# Operator must first prove process tree is gone. Never kill an unverified reused PID.
|
|
if marker:
|
|
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
|
|
for ss in state['stages'].values():
|
|
if ss['status'] == 'RUNNING':
|
|
ss['status'] = 'BLOCKED'
|
|
engine.store.write(state)
|
|
else:
|
|
if args.command == 'resume':
|
|
engine.resume()
|
|
engine.run()
|
|
print(json.dumps(status(engine), indent=2))
|
|
return 0
|
|
|
|
|
|
if __name__ == '__main__':
|
|
try:
|
|
sys.exit(main())
|
|
except (GateError, OSError, ValueError, KeyError, subprocess.SubprocessError) as error:
|
|
print(redact(str(error)), file=sys.stderr)
|
|
sys.exit(2)
|