Integrate delivered HQ memory, finite physical convoy and RHS WCS dependencies
This commit is contained in:
+148
-5
@@ -20,6 +20,7 @@ import zipfile
|
||||
from source_bundle import build, inventory, PROJECTS
|
||||
from headless_game import assert_no_game, assert_prefix_idle, cleanup_owned_run
|
||||
from native_report import analyze
|
||||
from external_addons import policy as external_policy, installed as external_installed, prepare_profile
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
ORDER = tuple(PROJECTS)
|
||||
@@ -56,6 +57,89 @@ def project_order(project):
|
||||
return [name for name in ORDER if name in needed]
|
||||
|
||||
|
||||
|
||||
def external_request(project, source, root):
|
||||
needs_external = project in {'ai', 'chat'}
|
||||
if needs_external and source is None:
|
||||
raise ValueError('AI and Chat builds require --external-addons with the audited installed RHS/WCS/BMP closure')
|
||||
if source is not None and not needs_external:
|
||||
raise ValueError('Core, Intel and Map builds must use vanilla-only profiles; --external-addons is not permitted')
|
||||
if source is None:
|
||||
return None
|
||||
source = Path(source).resolve()
|
||||
if not source.is_dir():
|
||||
raise ValueError('External addon source directory missing')
|
||||
policy_path = root/'docs/external-addons.json'
|
||||
if policy_path.is_symlink() or not policy_path.is_file():
|
||||
raise ValueError('External policy must be a regular repository file')
|
||||
before = digest(policy_path)
|
||||
value = external_policy(root)
|
||||
if digest(policy_path) != before:
|
||||
raise ValueError('External policy changed while reading')
|
||||
return {'source': source, 'policy': value, 'policy_path': policy_path,
|
||||
'policy_sha256': before}
|
||||
|
||||
|
||||
def capture_external(context, session):
|
||||
cache = {}
|
||||
context['records'] = external_installed(context['source'], context['policy'], cache)
|
||||
context['cache_path'] = session/'external-source-cache.json'
|
||||
save(context['cache_path'], cache)
|
||||
context['cache_sha256'] = digest(context['cache_path'])
|
||||
save(session/'external-addons-policy.json', context['policy'])
|
||||
save(session/'external-addons-before.json', context['records'])
|
||||
verify_external(context)
|
||||
|
||||
|
||||
def verify_external(context):
|
||||
context['last_records'] = None
|
||||
if context['cache_path'].is_symlink() or digest(context['cache_path']) != context['cache_sha256']:
|
||||
raise ValueError('External fingerprint cache changed during package attempt')
|
||||
if context['policy_path'].is_symlink() or digest(context['policy_path']) != context['policy_sha256']:
|
||||
raise ValueError('External dependency policy changed during package attempt')
|
||||
cache = json.loads(context['cache_path'].read_text())
|
||||
current = external_installed(context['source'], context['policy'], cache)
|
||||
context['last_records'] = current
|
||||
if current != context['records']:
|
||||
raise ValueError('External addon inputs changed during package attempt')
|
||||
return current
|
||||
|
||||
|
||||
def verify_package_profile(profile, steam, records):
|
||||
addons = profile/'addons'
|
||||
expected = {'ArmaReforger', *('ERRI_'+guid for guid in records)}
|
||||
if addons.is_symlink() or not addons.is_dir() or addons.resolve() != addons:
|
||||
raise ValueError('Private package addon directory changed')
|
||||
names = {path.name for path in addons.iterdir()}
|
||||
if names - {'saves'} != expected:
|
||||
raise ValueError('Private package profile contains missing or unrelated addons')
|
||||
saves = addons/'saves'
|
||||
if saves.exists() or saves.is_symlink():
|
||||
if saves.is_symlink() or not saves.is_dir():
|
||||
raise ValueError('Unexpected private package saves entry')
|
||||
targets = {'ArmaReforger': steam/'steamapps/common/Arma Reforger/addons/data'}
|
||||
targets.update({'ERRI_'+guid: Path(entry['path']) for guid, entry in records.items()})
|
||||
for name, target in targets.items():
|
||||
link = addons/name
|
||||
if not link.is_symlink() or link.resolve() != target.resolve():
|
||||
raise ValueError('Private package dependency link changed: '+name)
|
||||
|
||||
|
||||
def expose_external(project, profile, steam, context):
|
||||
records = {}
|
||||
if project in {'ai', 'chat'}:
|
||||
if context is None:
|
||||
raise ValueError('AI and Chat native packaging require audited external inputs')
|
||||
verify_external(context)
|
||||
records = prepare_profile(profile/'addons', context['source'], context['policy'], context['cache_path'])
|
||||
if records != context['records']:
|
||||
raise ValueError('External addon inputs changed before package launch')
|
||||
elif context is not None:
|
||||
raise ValueError('External links are forbidden in vanilla-only package profiles')
|
||||
verify_package_profile(profile, steam, records)
|
||||
return records
|
||||
|
||||
|
||||
def validate_session(root, steam, compat, session):
|
||||
if session.exists() or session.is_symlink():
|
||||
raise ValueError('--session must not already exist; outputs are never reused')
|
||||
@@ -215,7 +299,9 @@ def quiet_streams(application_id, sink, compat):
|
||||
break
|
||||
|
||||
|
||||
def run_project(steam, compat, session, project, timeout, authored, silent_workbench=False, exit_after_init=False):
|
||||
def run_project(steam, compat, session, project, timeout, authored, silent_workbench=False, exit_after_init=False, *, external=None):
|
||||
if (project in {'ai', 'chat'}) != (external is not None):
|
||||
raise ValueError('Only AI/Chat package profiles require and permit audited external inputs')
|
||||
command = command_for(steam, session, project, silent_workbench, exit_after_init)
|
||||
proton = Path(command[command.index('--')+1])
|
||||
executable = Path(command[command.index('--')+3])
|
||||
@@ -225,6 +311,9 @@ def run_project(steam, compat, session, project, timeout, authored, silent_workb
|
||||
for name in ('packages', 'cache', 'evidence'):
|
||||
(session/name/project).mkdir(parents=True)
|
||||
evidence = session/'evidence'/project
|
||||
external_records = expose_external(project, profile, steam, external)
|
||||
if external_records:
|
||||
save(evidence/'external-addons-before.json', external_records)
|
||||
separator = command.index('--')
|
||||
native_command = command[separator+1:]
|
||||
recorder = evidence/'native-child.py'
|
||||
@@ -238,6 +327,9 @@ def run_project(steam, compat, session, project, timeout, authored, silent_workb
|
||||
save(evidence/'launch.json', {'command': command, 'native_command': native_command, 'authored': authored, 'started_unix': time.time(),
|
||||
'executable_sha256': digest(executable), 'proton_sha256': digest(proton),
|
||||
'child_recorder_sha256': digest(recorder), 'native_environment_sha256': digest(environment_path),
|
||||
'external_addon_count': len(external_records),
|
||||
'external_policy_sha256': external['policy_sha256'] if external else None,
|
||||
'external_addon_inputs': 'external-addons-before.json' if external else None,
|
||||
'silent_workbench_experiment': silent_workbench,
|
||||
'exit_after_init_experiment': exit_after_init})
|
||||
run_id = uuid.uuid4().hex
|
||||
@@ -327,10 +419,27 @@ def run_project(steam, compat, session, project, timeout, authored, silent_workb
|
||||
except (OSError, ValueError) as error:
|
||||
logs, packages, unchanged = {}, {}, False
|
||||
errors = ['Output/evidence validation failed: '+str(error)]
|
||||
profile_unchanged, external_unchanged = True, True
|
||||
try:
|
||||
verify_package_profile(profile, steam, external_records)
|
||||
except (OSError, ValueError) as error:
|
||||
profile_unchanged = False
|
||||
errors.append('Private profile validation failed: '+str(error))
|
||||
if external is not None:
|
||||
try:
|
||||
verify_external(external)
|
||||
except (OSError, ValueError) as error:
|
||||
external_unchanged = False
|
||||
errors.append('External input validation failed: '+str(error))
|
||||
if external_records:
|
||||
save(evidence/'external-addons-after.json', {'records': external['last_records'], 'unchanged': external_unchanged})
|
||||
save(evidence/'external-addons-verification.json', {
|
||||
'unchanged': external_unchanged, 'profile_unchanged': profile_unchanged,
|
||||
'policy_sha256': external['policy_sha256'], 'before': 'external-addons-before.json'})
|
||||
native_logs = [name for name in logs if Path(name).name == 'console.log' and logs[name]['bytes'] > 0]
|
||||
native_exit = child_status(status_path, native_command)
|
||||
wrapper_ok = exit_code == 0 or (exit_code is None and result == 'native-child-exited')
|
||||
candidate = wrapper_ok and candidate_ok(native_exit, result, cleanup_ok, unchanged, native_logs, errors, packages)
|
||||
candidate = wrapper_ok and profile_unchanged and external_unchanged and candidate_ok(native_exit, result, cleanup_ok, unchanged, native_logs, errors, packages)
|
||||
report = {'project': project, 'result': 'native-pack-candidate' if candidate else ('validation-failed' if result in {'process-exited', 'native-child-exited'} else result),
|
||||
'candidate_created': candidate, 'native_exit_code': native_exit, 'launcher_exit_code': exit_code,
|
||||
'completion_observed': result, 'display_cleanup_after_native_exit': result == 'native-child-exited',
|
||||
@@ -339,6 +448,11 @@ def run_project(steam, compat, session, project, timeout, authored, silent_workb
|
||||
'native_status_sha256': digest(status_path) if status_path.is_file() and not status_path.is_symlink() else None,
|
||||
'cleanup_confirmed': cleanup_ok, 'cleanup_report': cleanup_report,
|
||||
'copied_authored_unchanged': unchanged, 'runner_error': runner_error,
|
||||
'profile_addons_unchanged': profile_unchanged,
|
||||
'external_addons_unchanged_after_run': external_unchanged,
|
||||
'external_addon_count': len(external_records),
|
||||
'external_policy_sha256': external['policy_sha256'] if external else None,
|
||||
'external_addon_inputs': 'external-addons-before.json' if external else None,
|
||||
'logs': logs, 'errors': errors, 'packages': packages,
|
||||
'package_contents_verified': False, 'native_consumption_verified': False,
|
||||
'tool_completion_semantics_verified': False, 'release_qualified': False,
|
||||
@@ -351,6 +465,7 @@ def run(a, root=ROOT):
|
||||
root, steam, compat = root.resolve(), a.steam.resolve(), a.compat.resolve()
|
||||
session = a.session.absolute() # refuse existing symlinks before resolve
|
||||
validate_session(root, steam, compat, session)
|
||||
external = external_request(a.project, getattr(a, 'external_addons', None), root)
|
||||
if not 30 <= a.timeout <= 1800:
|
||||
raise ValueError('--timeout must be 30..1800 seconds per addon')
|
||||
silent_workbench = getattr(a, 'silent_workbench', False)
|
||||
@@ -371,16 +486,30 @@ def run(a, root=ROOT):
|
||||
raise ValueError('Another native runner owns the disposable prefix')
|
||||
assert_prefix_idle(compat)
|
||||
authored = copy_payload(root, session)
|
||||
if external is not None:
|
||||
capture_external(external, session)
|
||||
reports = []
|
||||
order = project_order(a.project)
|
||||
continue_on_failure = getattr(a, 'continue_on_failure', False)
|
||||
stopped_reason = None
|
||||
for project in order:
|
||||
report = run_project(steam, compat, session, project, a.timeout, authored, silent_workbench, exit_after_init)
|
||||
selected_external = external if project in {'ai', 'chat'} else None
|
||||
report = run_project(steam, compat, session, project, a.timeout, authored, silent_workbench, exit_after_init, external=selected_external)
|
||||
reports.append(report)
|
||||
# Diagnostic continuation never grants authority to reuse an unsafe
|
||||
# prefix or a changed source copy. Timeout/interruption also stop.
|
||||
if not report.get('cleanup_confirmed'):
|
||||
if external is not None:
|
||||
try:
|
||||
verify_external(external)
|
||||
except (OSError, ValueError) as error:
|
||||
stopped_reason = 'external dependency inputs changed or unverifiable: '+str(error)
|
||||
if stopped_reason:
|
||||
break
|
||||
if report.get('profile_addons_unchanged') is False:
|
||||
stopped_reason = 'private addon profile changed or unverifiable'
|
||||
elif selected_external is not None and report.get('external_addons_unchanged_after_run') is not True:
|
||||
stopped_reason = 'external dependency inputs changed or unverifiable'
|
||||
elif not report.get('cleanup_confirmed'):
|
||||
stopped_reason = 'owned cleanup unconfirmed'
|
||||
elif report.get('runner_error'):
|
||||
stopped_reason = 'native runner error'
|
||||
@@ -404,14 +533,27 @@ def run(a, root=ROOT):
|
||||
original_unchanged = original_after == authored
|
||||
except (OSError, ValueError):
|
||||
original_unchanged = False
|
||||
external_unchanged = True
|
||||
if external is not None:
|
||||
try:
|
||||
verify_external(external)
|
||||
except (OSError, ValueError) as error:
|
||||
external_unchanged = False
|
||||
if stopped_reason is None:
|
||||
stopped_reason = 'external dependency final verification failed: '+str(error)
|
||||
save(session/'external-addons-after.json', {'records': external['last_records'], 'unchanged': external_unchanged})
|
||||
summary = {'kind': 'UNQUALIFIED NATIVE PACKAGE ATTEMPT', 'session': str(session),
|
||||
'project_order': order, 'projects': reports,
|
||||
'continue_on_failure': continue_on_failure, 'stopped_reason': stopped_reason,
|
||||
'skipped_projects': order[len(reports):],
|
||||
'source_repository_unchanged': original_unchanged,
|
||||
'external_addons_unchanged_after_run': external_unchanged,
|
||||
'external_addon_count': len(external['records']) if external else 0,
|
||||
'external_addon_inputs': 'external-addons-before.json' if external else None,
|
||||
'external_policy_sha256': external['policy_sha256'] if external else None,
|
||||
'release_qualified': False, 'published': False,
|
||||
'qualification': 'PENDING native contents inspection and packed-only consumption'}
|
||||
summary['all_candidates_created'] = original_unchanged and stopped_reason is None and len(reports) == len(order) and all(r['candidate_created'] for r in reports)
|
||||
summary['all_candidates_created'] = original_unchanged and external_unchanged and stopped_reason is None and len(reports) == len(order) and all(r['candidate_created'] for r in reports)
|
||||
save(session/'package-report.json', summary)
|
||||
print(json.dumps(summary, indent=2))
|
||||
return 0 if summary['all_candidates_created'] else 1
|
||||
@@ -424,6 +566,7 @@ def main(argv=None):
|
||||
parser.add_argument('--compat', type=Path, required=True, help='Idle disposable cloned Tools prefix under /tmp')
|
||||
parser.add_argument('--steam', type=Path, default=Path.home()/'.local/share/Steam')
|
||||
parser.add_argument('--timeout', type=int, default=300, help='30..1800 seconds per addon')
|
||||
parser.add_argument('--external-addons', type=Path, help='Required for AI/Chat: installed audited RHS/WCS/BMP root; only allowlisted links enter private profiles')
|
||||
lifecycle = parser.add_mutually_exclusive_group()
|
||||
lifecycle.add_argument('--silent-workbench', action='store_true', help='Opt-in documented -wbSilent experiment; packing compatibility still requires native evidence')
|
||||
lifecycle.add_argument('--exit-after-init', action='store_true', help='Opt-in documented -exitAfterInit experiment; natural exit and all native error gates remain mandatory')
|
||||
|
||||
Reference in New Issue
Block a user