feat(automation): add durable runner and signed approval recovery
This commit is contained in:
@@ -115,3 +115,7 @@ automation/**/__pycache__/
|
|||||||
automation/**/*.pyc
|
automation/**/*.pyc
|
||||||
automation/**/*.pfx
|
automation/**/*.pfx
|
||||||
automation/**/*.key
|
automation/**/*.key
|
||||||
|
|
||||||
|
# Regenerated AST cache is local; retain the graph/report/manifest artifacts.
|
||||||
|
graphify-out/cache/
|
||||||
|
graphify-out/.rebuild.lock
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"repository": "REPLACE_WITH_ISOLATED_DEVELOPMENT_CLONE",
|
||||||
|
"codexExecutable": "codex",
|
||||||
|
"powershellExecutable": "pwsh",
|
||||||
|
"publicKeyCertificate": "owner.cer",
|
||||||
|
"ownerCertificateSha256": "REPLACE_WITH_PUBLIC_CERTIFICATE_SHA256",
|
||||||
|
"ownerSid": "REPLACE_WITH_OWNER_SIGNING_SID",
|
||||||
|
"runnerSid": "REPLACE_WITH_SEPARATE_RUNNER_SID",
|
||||||
|
"workerCodeXHome": "C:\\JobjaktRunnerAuth",
|
||||||
|
"trustedCodexOverrides": [],
|
||||||
|
"executionWindows": [],
|
||||||
|
"maxSessionSeconds": 7200,
|
||||||
|
"maxCheckSeconds": 3600,
|
||||||
|
"maxRunSeconds": 28800,
|
||||||
|
"maxLogBytes": 52428800,
|
||||||
|
"maxProcesses": 64,
|
||||||
|
"maxJobMemoryMiB": 8192
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,170 @@
|
|||||||
|
"""Import verified original prompt bytes into a NEW queue; never import into a live control plane."""
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
|
||||||
|
def sha(path):
|
||||||
|
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def prepare(source, output, project):
|
||||||
|
if (output / 'backlog.json').exists():
|
||||||
|
raise SystemExit('Queue already exists; preserve it and prepare changes in a new directory for owner review')
|
||||||
|
output.mkdir(parents=True, exist_ok=True)
|
||||||
|
inventory = []
|
||||||
|
for path in sorted(source.rglob('*')):
|
||||||
|
if path.is_file() and path.suffix.lower() in ('.md', '.txt'):
|
||||||
|
inventory.append(dict(filename=str(path.relative_to(source)), bytes=path.stat().st_size, sha256=sha(path)))
|
||||||
|
def find(pattern):
|
||||||
|
matches = [p for p in source.rglob('*') if p.is_file() and re.search(pattern, p.name, re.I)]
|
||||||
|
if len(matches) > 1:
|
||||||
|
raise SystemExit('Ambiguous prompt match: ' + pattern)
|
||||||
|
return matches[0] if matches else None
|
||||||
|
def snapshot(path):
|
||||||
|
target = output / 'prompts' / path.relative_to(source)
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copyfile(path, target)
|
||||||
|
return target.relative_to(output).as_posix(), sha(target)
|
||||||
|
def check(name, argv, report=None, cwd='.', configured=True):
|
||||||
|
return dict(id=name, argv=argv, cwd=cwd, report=report, configured=configured)
|
||||||
|
core_checks = [
|
||||||
|
check('backend', ['dotnet','test','JobTrackerApi.Tests/JobTrackerApi.Tests.csproj','--logger',
|
||||||
|
'trx;LogFileName=backend.trx','--results-directory','.artifacts/orchestrator/backend'],
|
||||||
|
dict(format='trx',path='.artifacts/orchestrator/backend/backend.trx',minimumPassed=1)),
|
||||||
|
check('frontend', ['cmd','/d','/c','npm','test','--','--runInBand','--json',
|
||||||
|
'--outputFile=../.artifacts/orchestrator/frontend.json'],
|
||||||
|
dict(format='jest',path='.artifacts/orchestrator/frontend.json',minimumPassed=1), 'job-tracker-ui'),
|
||||||
|
check('types', ['cmd','/d','/c','npm','run','typecheck'], cwd='job-tracker-ui'),
|
||||||
|
check('lint', ['cmd','/d','/c','npm','run','lint'], cwd='job-tracker-ui'),
|
||||||
|
check('build', ['cmd','/d','/c','npm','run','build'], cwd='job-tracker-ui'),
|
||||||
|
check('qualified-provider-browser-python-pdf-security-preservation',
|
||||||
|
['pwsh','-NoProfile','-File','automation/qualification/Run-StageQualification.ps1'],
|
||||||
|
dict(format='junit',path='.artifacts/orchestrator/qualification.xml',minimumPassed=1),
|
||||||
|
configured=False)
|
||||||
|
]
|
||||||
|
# The composite adapter is intentionally unconfigured: a local sandbox cannot invent hosted,
|
||||||
|
# provider, PDF, preservation, AI or human qualification. Owner installs a concrete trusted plan.
|
||||||
|
context = ['docs/pre-phase10-ux/ACCEPTANCE.md','docs/pre-phase10-ux/CV_QA_HANDOFF.md',
|
||||||
|
'docs/cv-integration-qa/PHASE_10_HANDOFF.md','docs/redesign/IMPLEMENTATION_PLAN.md']
|
||||||
|
definitions = [
|
||||||
|
('PHASE-10',5,r'^Prompt 5\b','feature/phase10-cleanup',
|
||||||
|
['phase10-development-entry'], ['P10-00','P10-01','P10-02','P10-03','P10-04','P10-05']),
|
||||||
|
('PRODUCT-REVIEW-01',6,r'^Prompt 6\b','planning/product-review-01',
|
||||||
|
[], ['independent-assessment','ranked-roadmap']),
|
||||||
|
('INT-GLOBAL-JOBS-01',7,r'^Prompt 7\b','feature/int-global-jobs-01',
|
||||||
|
['product-ranked-scope','global-jobs-implementation'], ['GJ-01','GJ-02','GJ-03','GJ-04','GJ-05','GJ-06','GJ-07','GJ-08']),
|
||||||
|
('I18N-01',8,r'^I18N-01\b','feature/i18n-01',
|
||||||
|
['i18n-scope'], ['locale-inventory','translation-implementation','locale-qualification']),
|
||||||
|
('LAUNCH-SITE-01',9,r'^Prompt 10\b','feature/launch-site-redesign',
|
||||||
|
[], ['homepage-research','four-designs','approved-design-implementation','homepage-qualification']),
|
||||||
|
('SECURITY-AUDIT-01',10,r'^Prompt 8\b','test/security-audit-01',
|
||||||
|
['security-test-environment-and-scope'], ['threat-model','adversarial-testing','remediation','security-retest']),
|
||||||
|
('RELEASE-READINESS-01',11,r'^prompt 11\b','test/release-readiness-01',
|
||||||
|
[], ['release-inventory','qualification','release-recommendation'])
|
||||||
|
]
|
||||||
|
stages = []
|
||||||
|
for workstream, sequence, pattern, branch, approvals, checkpoints in definitions:
|
||||||
|
if workstream == 'I18N-01':
|
||||||
|
original = source / 'I18N/00_MASTER_ORCHESTRATOR.md'
|
||||||
|
original = original if original.is_file() else None
|
||||||
|
checkpoints = [f'I18N-{n:02d}' for n in range(9)]
|
||||||
|
elif workstream == 'SECURITY-AUDIT-01':
|
||||||
|
original = source / 'jobjakt-security/MASTER_PROMPT.md'
|
||||||
|
original = original if original.is_file() else None
|
||||||
|
checkpoints = [f'SEC-{n:02d}' for n in range(17)]
|
||||||
|
else:
|
||||||
|
original = find(pattern)
|
||||||
|
filename, prompt_hash = snapshot(original) if original else (f'prompts/MISSING-{workstream}.txt', '')
|
||||||
|
source_status = 'MISSING' if not original else ('EMPTY' if original.stat().st_size == 0 else 'VERIFIED')
|
||||||
|
stage = dict(id=workstream,sequence=sequence,sourcePrompt=filename,promptSha256=prompt_hash,
|
||||||
|
sourceFilename=str(original) if original else None,sourceStatus=source_status,
|
||||||
|
dependencies=[stages[-1]['id']] if stages else [],branch=branch,
|
||||||
|
requiredOwnerApprovals=approvals,retryPolicy=dict(maxAttempts=3,maxSessions=30,backoffSeconds=30),
|
||||||
|
status='PENDING',evidence=[],reports=[],contextFiles=context,checkpoints=[],supplementalPrompts=[])
|
||||||
|
for checkpoint in checkpoints:
|
||||||
|
after = []
|
||||||
|
if workstream == 'PRODUCT-REVIEW-01' and checkpoint == 'ranked-roadmap':
|
||||||
|
after = ['product-ranked-scope']
|
||||||
|
if workstream == 'I18N-01' and checkpoint == 'I18N-08':
|
||||||
|
after = ['human-linguistic-and-market-qualification']
|
||||||
|
if checkpoint == 'four-designs':
|
||||||
|
after = ['homepage-design-selection']
|
||||||
|
if checkpoint == 'release-recommendation':
|
||||||
|
after = ['release-evidence-review'] # review only; never deployment approval
|
||||||
|
before = []
|
||||||
|
if checkpoint == 'GJ-04':
|
||||||
|
before = ['provider-licences-and-display-rights']
|
||||||
|
if checkpoint == 'SEC-13':
|
||||||
|
before = ['isolated-adversarial-targets']
|
||||||
|
if checkpoint == 'SEC-15':
|
||||||
|
after = ['material-security-findings-disposition']
|
||||||
|
artifacts = [f'docs/automation/stages/{workstream}/{checkpoint}.md']
|
||||||
|
if checkpoint == 'four-designs':
|
||||||
|
artifacts += [f'docs/automation/stages/{workstream}/concept-{letter}.png' for letter in 'ABCD']
|
||||||
|
stage['checkpoints'].append(dict(id=checkpoint,
|
||||||
|
instruction=f'Complete only {checkpoint} within {workstream}, preserving every full source requirement. '
|
||||||
|
'Read prior evidence. Stop for any source-required owner decision. No production actions.',
|
||||||
|
approvalsBefore=before,approvalsAfter=after,deferrableGates=[],requiredArtifacts=artifacts,
|
||||||
|
checks=core_checks,evidenceRequirements=[
|
||||||
|
'Process exits and exact structured suite membership; no silent skips/retries',
|
||||||
|
'Preservation IDs, canonical owners, old-data and document compatibility',
|
||||||
|
'Separate local/provider/hosted/human/AI/production decisions',
|
||||||
|
'All source-prompt deliverables and stage-specific acceptance criteria; independent review required']))
|
||||||
|
if workstream == 'INT-GLOBAL-JOBS-01':
|
||||||
|
pack = project / 'docs/planning/global-jobs-prompts'
|
||||||
|
if pack.is_dir():
|
||||||
|
for path in sorted(pack.glob('*.md')):
|
||||||
|
target = output / 'prompts/global-jobs-pack' / path.name
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copyfile(path, target)
|
||||||
|
stage['supplementalPrompts'].append(dict(path=target.relative_to(output).as_posix(),sha256=sha(target)))
|
||||||
|
match = re.search(r'GJ_(\d\d)_', path.name)
|
||||||
|
if match:
|
||||||
|
stage['supplementalPrompts'][-1]['checkpoints'] = ['GJ-' + match[1]]
|
||||||
|
planning = project / 'docs/global-jobs/00'
|
||||||
|
if planning.is_dir():
|
||||||
|
for path in sorted(planning.glob('*.md')):
|
||||||
|
target = output / 'prompts/global-jobs-planning' / path.name
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copyfile(path,target)
|
||||||
|
stage['supplementalPrompts'].append(dict(path=target.relative_to(output).as_posix(),sha256=sha(target)))
|
||||||
|
if workstream in ('I18N-01','SECURITY-AUDIT-01'):
|
||||||
|
pack = source / ('I18N' if workstream == 'I18N-01' else 'jobjakt-security')
|
||||||
|
for path in sorted(pack.rglob('*')):
|
||||||
|
if path.is_file():
|
||||||
|
saved_path, saved_hash = snapshot(path)
|
||||||
|
if saved_path != filename:
|
||||||
|
stage['supplementalPrompts'].append(dict(path=saved_path,sha256=saved_hash))
|
||||||
|
match = re.search(r'I18N_(\d\d)_', path.name) if workstream == 'I18N-01' else re.match(r'(\d\d)_',path.name)
|
||||||
|
if match:
|
||||||
|
stage['supplementalPrompts'][-1]['checkpoints'] = [('I18N-' if workstream == 'I18N-01' else 'SEC-') + match[1]]
|
||||||
|
elif path.name == 'ALL_PHASE_PROMPTS.md':
|
||||||
|
stage['supplementalPrompts'][-1]['checkpoints'] = [] # preserved duplicate compilation, individual full prompts drive sessions
|
||||||
|
# Qualitative source requirements cannot be accepted by the implementation model itself.
|
||||||
|
stage['checkpoints'][-1]['approvalsAfter'].append('stage-requirements-review')
|
||||||
|
stages.append(stage)
|
||||||
|
# Preserve UI subprompts without falsely classifying them as the missing I18N contract.
|
||||||
|
for path in source.glob('prompt 9 UI/*.txt'):
|
||||||
|
snapshot(path)
|
||||||
|
manifest = dict(schemaVersion=1,executionEnabled=False,minimumCodexVersion='0.163.0',
|
||||||
|
baselineCommit='',baselineBranch='test/pre-phase10-ux-acceptance',
|
||||||
|
remote='origin',integrationBranch='codex/roadmap-integration',stages=stages)
|
||||||
|
# Resolve the requested local commit instead of guessing its expansion.
|
||||||
|
import subprocess
|
||||||
|
manifest['baselineCommit'] = subprocess.check_output(['git','-C',str(project),'rev-parse','5032ccb^{commit}'],text=True).strip()
|
||||||
|
(output / 'backlog.json').write_text(json.dumps(manifest,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
|
||||||
|
(output / 'SOURCE_INVENTORY.json').write_text(json.dumps(inventory,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
|
||||||
|
print(f'Prepared {len(stages)} ordered stages; execution disabled. Original source bytes preserved.')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument('--source',type=Path,required=True)
|
||||||
|
parser.add_argument('--output',type=Path,required=True)
|
||||||
|
parser.add_argument('--project',type=Path,required=True)
|
||||||
|
args = parser.parse_args()
|
||||||
|
prepare(args.source,args.output,args.project)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"configuration": "PASS",
|
||||||
|
"executionEnabled": false,
|
||||||
|
"sourceGaps": [],
|
||||||
|
"unqualifiedAdapters": [
|
||||||
|
"qualified-provider-browser-python-pdf-security-preservation"
|
||||||
|
],
|
||||||
|
"checkpoints": 49,
|
||||||
|
"phase10": "NO-GO",
|
||||||
|
"note": "Read-only planning; no model, Git mutation, network, service, signing or scheduling action",
|
||||||
|
"realSessionsLaunched": 0,
|
||||||
|
"runtimeQualification": "NOT_CONFIGURED; separate identity, certificate and sandbox qualification required"
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"exitCode": 0,
|
||||||
|
"cases": [
|
||||||
|
"PASS valid signature and concrete decision",
|
||||||
|
"PASS replayed approval on changed commit",
|
||||||
|
"PASS forged signature",
|
||||||
|
"PASS expired approval",
|
||||||
|
"PASS wrong signing owner",
|
||||||
|
"PASS model-written approval JSON"
|
||||||
|
],
|
||||||
|
"scope": "Temporary .NET RSA certificates only; no owner key installed"
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
PASS valid signature and concrete decision
|
||||||
|
PASS replayed approval on changed commit
|
||||||
|
PASS forged signature
|
||||||
|
PASS expired approval
|
||||||
|
PASS wrong signing owner
|
||||||
|
PASS model-written approval JSON
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"testsRun": 48,
|
||||||
|
"failures": [],
|
||||||
|
"errors": [],
|
||||||
|
"skipped": [],
|
||||||
|
"successful": true,
|
||||||
|
"sourceStable": true,
|
||||||
|
"scope": "Disposable repositories, synthetic worker and actual subprocess/Windows containment/signature paths; no real backlog",
|
||||||
|
"sourceHashes": {
|
||||||
|
"automation/controller.py": "bfdd6a2b11020f6a1117719e721a92a3d78cc51e3c8f97637aac40e35a285338",
|
||||||
|
"automation/backlog.json": "b0a32cfdedfbedcf089f0dc929e774fc968221bd66f4e510fd981a99eaf4fe88",
|
||||||
|
"automation/tests/test_controller.py": "458da3c60571e61aad42987ce156842efd73ef0e2aa0c7bce719d36d626d7e7d",
|
||||||
|
"scripts/Approve-JobjaktBacklog.ps1": "a64f22aa85c1c2e9056c73e095975eaf17d1ffb201c7c27c52a34a24a5790ea0",
|
||||||
|
"scripts/Run-JobjaktBacklog.ps1": "fed8289dd78d0583c45a71a5d570492cc54400b46372d3823d530f4d43d48e41"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
test_accidental_main_checkout_rejected (test_controller.EngineTests.test_accidental_main_checkout_rejected) ... ok
|
||||||
|
test_after_approval_binds_tested_commit_without_rerunning_worker (test_controller.EngineTests.test_after_approval_binds_tested_commit_without_rerunning_worker) ... ok
|
||||||
|
test_changed_prompt_blocks (test_controller.EngineTests.test_changed_prompt_blocks) ... ok
|
||||||
|
test_conditional_can_advance_only_with_explicit_signed_deferral (test_controller.EngineTests.test_conditional_can_advance_only_with_explicit_signed_deferral) ... ok
|
||||||
|
test_conditional_requires_explicit_deferrable_gates (test_controller.EngineTests.test_conditional_requires_explicit_deferrable_gates) ... ok
|
||||||
|
test_corrupt_state_recovers_last_good_without_success (test_controller.EngineTests.test_corrupt_state_recovers_last_good_without_success) ... ok
|
||||||
|
test_cryptographically_approved_resume_e2e (test_controller.EngineTests.test_cryptographically_approved_resume_e2e) ... ok
|
||||||
|
test_dependency_cannot_be_bypassed (test_controller.EngineTests.test_dependency_cannot_be_bypassed) ... ok
|
||||||
|
test_design_revision_reruns_current_checkpoint_and_keeps_owner_selection (test_controller.EngineTests.test_design_revision_reruns_current_checkpoint_and_keeps_owner_selection) ... ok
|
||||||
|
test_disabled_manifest_never_executes (test_controller.EngineTests.test_disabled_manifest_never_executes) ... ok
|
||||||
|
test_double_corruption_fails_closed (test_controller.EngineTests.test_double_corruption_fails_closed) ... ok
|
||||||
|
test_duplicate_lock_rejected (test_controller.EngineTests.test_duplicate_lock_rejected) ... ok
|
||||||
|
test_execution_window_stops_before_worker (test_controller.EngineTests.test_execution_window_stops_before_worker) ... ok
|
||||||
|
test_failed_stage_stops_and_exhausts_retry_budget (test_controller.EngineTests.test_failed_stage_stops_and_exhausts_retry_budget) ... ok
|
||||||
|
test_failing_checks_never_commit_or_advance (test_controller.EngineTests.test_failing_checks_never_commit_or_advance) ... ok
|
||||||
|
test_interruption_is_not_success_and_resume_has_budget (test_controller.EngineTests.test_interruption_is_not_success_and_resume_has_budget) ... ok
|
||||||
|
test_manifest_initially_disabled (test_controller.EngineTests.test_manifest_initially_disabled) ... ok
|
||||||
|
test_missing_approval_does_not_run_model (test_controller.EngineTests.test_missing_approval_does_not_run_model) ... ok
|
||||||
|
test_missing_prompt_blocks (test_controller.EngineTests.test_missing_prompt_blocks) ... ok
|
||||||
|
test_model_cannot_approve_itself (test_controller.EngineTests.test_model_cannot_approve_itself) ... ok
|
||||||
|
test_pause_persists_and_resume_clears_it (test_controller.EngineTests.test_pause_persists_and_resume_clears_it) ... ok
|
||||||
|
test_push_failure_preserves_tested_commit (test_controller.EngineTests.test_push_failure_preserves_tested_commit) ... ok
|
||||||
|
test_push_verification_mismatch_stops (test_controller.EngineTests.test_push_verification_mismatch_stops) ... ok
|
||||||
|
test_recovered_running_without_marker_requires_reconciliation (test_controller.EngineTests.test_recovered_running_without_marker_requires_reconciliation) ... ok
|
||||||
|
test_restart_running_marker_cannot_mean_completed (test_controller.EngineTests.test_restart_running_marker_cannot_mean_completed) ... ok
|
||||||
|
test_same_repo_other_control_root_cannot_take_lock (test_controller.EngineTests.test_same_repo_other_control_root_cannot_take_lock) ... ok
|
||||||
|
test_success_orders_stages_and_verifies_remote (test_controller.EngineTests.test_success_orders_stages_and_verifies_remote) ... ok
|
||||||
|
test_tampered_evidence_cannot_be_approved (test_controller.EngineTests.test_tampered_evidence_cannot_be_approved) ... ok
|
||||||
|
test_unavailable_remote_preserves_commit_and_retries_publication_only (test_controller.EngineTests.test_unavailable_remote_preserves_commit_and_retries_publication_only) ... ok
|
||||||
|
test_unconfigured_test_adapter_blocks_before_model (test_controller.EngineTests.test_unconfigured_test_adapter_blocks_before_model) ... ok
|
||||||
|
test_unsigned_recovery_cannot_clear_running_guard (test_controller.EngineTests.test_unsigned_recovery_cannot_clear_running_guard) ... ok
|
||||||
|
test_forward_dependency_rejected (test_controller.QueueTests.test_forward_dependency_rejected) ... ok
|
||||||
|
test_production_branches_rejected (test_controller.QueueTests.test_production_branches_rejected) ... ok
|
||||||
|
test_real_queue_is_ordered_and_missing_sources_are_explicit (test_controller.QueueTests.test_real_queue_is_ordered_and_missing_sources_are_explicit) ... ok
|
||||||
|
test_empty_report_is_not_a_pass (test_controller.ReportTests.test_empty_report_is_not_a_pass) ... ok
|
||||||
|
test_json_log_redaction_preserves_event_structure (test_controller.ReportTests.test_json_log_redaction_preserves_event_structure) ... ok
|
||||||
|
test_path_escape_rejected (test_controller.ReportTests.test_path_escape_rejected) ... ok
|
||||||
|
test_playwright_retry_and_expected_failure_are_not_pass (test_controller.ReportTests.test_playwright_retry_and_expected_failure_are_not_pass) ... ok
|
||||||
|
test_redacts_common_credentials (test_controller.ReportTests.test_redacts_common_credentials) ... ok
|
||||||
|
test_report_membership_and_skip_enforcement (test_controller.ReportTests.test_report_membership_and_skip_enforcement) ... ok
|
||||||
|
test_cli_nonzero_exit_never_accepts (test_controller.SubprocessTests.test_cli_nonzero_exit_never_accepts) ... ok
|
||||||
|
test_cli_protocol_real_process_and_complete_prompt (test_controller.SubprocessTests.test_cli_protocol_real_process_and_complete_prompt) ... ok
|
||||||
|
test_killed_controller_terminates_worker_and_grandchild (test_controller.SubprocessTests.test_killed_controller_terminates_worker_and_grandchild) ... ok
|
||||||
|
test_marker_write_failure_does_not_leave_worker (test_controller.SubprocessTests.test_marker_write_failure_does_not_leave_worker) ... ok
|
||||||
|
test_no_newline_output_obeys_log_budget (test_controller.SubprocessTests.test_no_newline_output_obeys_log_budget) ... ok
|
||||||
|
test_prompt_stall_still_obeys_timeout (test_controller.SubprocessTests.test_prompt_stall_still_obeys_timeout) ... ok
|
||||||
|
test_real_check_report_must_match_expected_membership (test_controller.SubprocessTests.test_real_check_report_must_match_expected_membership) ... ok
|
||||||
|
test_worker_cannot_execute_before_job_assignment (test_controller.SubprocessTests.test_worker_cannot_execute_before_job_assignment) ... ok
|
||||||
|
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
Ran 48 tests in 112.446s
|
||||||
|
|
||||||
|
OK
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
#Requires -Version 7.2
|
||||||
|
# Disposable test key only. Production signing uses the owner's nonexportable certificate.
|
||||||
|
param([ValidateSet('Setup','Sign')][string]$Action, [string]$Root, [string]$RequestPath, [string]$ApprovalPath)
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$rsa = [Security.Cryptography.RSA]::Create(2048)
|
||||||
|
try {
|
||||||
|
if ($Action -eq 'Setup') {
|
||||||
|
$request = [Security.Cryptography.X509Certificates.CertificateRequest]::new('CN=DISPOSABLE SYNTHETIC ONLY', $rsa,
|
||||||
|
[Security.Cryptography.HashAlgorithmName]::SHA256,[Security.Cryptography.RSASignaturePadding]::Pkcs1)
|
||||||
|
$cert = $request.CreateSelfSigned([DateTimeOffset]::UtcNow.AddMinutes(-1),[DateTimeOffset]::UtcNow.AddHours(2))
|
||||||
|
[IO.File]::WriteAllBytes((Join-Path $Root 'owner.cer'),$cert.Export([Security.Cryptography.X509Certificates.X509ContentType]::Cert))
|
||||||
|
[IO.File]::WriteAllText((Join-Path $Root 'synthetic-private.pem'),$rsa.ExportPkcs8PrivateKeyPem())
|
||||||
|
$cert.Dispose()
|
||||||
|
} else {
|
||||||
|
$rsa.ImportFromPem([IO.File]::ReadAllText((Join-Path $Root 'synthetic-private.pem')))
|
||||||
|
$hash = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData([IO.File]::ReadAllBytes($RequestPath))).ToLowerInvariant()
|
||||||
|
$payload = @{requestSha256=$hash;approved=$true;ownerSid='OWNER';decision='Synthetic owner approves exact request';disposition='Approve';
|
||||||
|
issuedUtc=[DateTimeOffset]::UtcNow.ToString('o');expiresUtc=[DateTimeOffset]::UtcNow.AddHours(1).ToString('o')} | ConvertTo-Json -Compress
|
||||||
|
$bytes = [Text.Encoding]::UTF8.GetBytes($payload)
|
||||||
|
$signature = $rsa.SignData($bytes,[Security.Cryptography.HashAlgorithmName]::SHA256,[Security.Cryptography.RSASignaturePadding]::Pkcs1)
|
||||||
|
[IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($ApprovalPath)) | Out-Null
|
||||||
|
@{payloadBase64=[Convert]::ToBase64String($bytes);signatureBase64=[Convert]::ToBase64String($signature)} |
|
||||||
|
ConvertTo-Json -Compress | Set-Content -LiteralPath $ApprovalPath -Encoding utf8NoBOM
|
||||||
|
}
|
||||||
|
} finally { $rsa.Dispose() }
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
#Requires -Version 7.2
|
||||||
|
param([Parameter(Mandatory)][string]$Verifier)
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$folder = Join-Path ([IO.Path]::GetTempPath()) ('jobjakt-signature-test-' + [Guid]::NewGuid().ToString('N'))
|
||||||
|
[IO.Directory]::CreateDirectory($folder) | Out-Null
|
||||||
|
try {
|
||||||
|
$rsa = [Security.Cryptography.RSA]::Create(2048)
|
||||||
|
$request = [Security.Cryptography.X509Certificates.CertificateRequest]::new('CN=Synthetic only', $rsa,
|
||||||
|
[Security.Cryptography.HashAlgorithmName]::SHA256, [Security.Cryptography.RSASignaturePadding]::Pkcs1)
|
||||||
|
$certificate = $request.CreateSelfSigned([DateTimeOffset]::UtcNow.AddDays(-1), [DateTimeOffset]::UtcNow.AddDays(1))
|
||||||
|
$certPath = Join-Path $folder 'owner.cer'
|
||||||
|
[IO.File]::WriteAllBytes($certPath, $certificate.Export([Security.Cryptography.X509Certificates.X509ContentType]::Cert))
|
||||||
|
$requestPath = Join-Path $folder 'request.json'
|
||||||
|
$approvalPath = Join-Path $folder 'approval.json'
|
||||||
|
$original = '{"schemaVersion":1,"kind":"design-selection","manifestSha256":"manifest","policySha256":"policy","candidateCommit":"synthetic"}'
|
||||||
|
[IO.File]::WriteAllText($requestPath, $original, [Text.UTF8Encoding]::new($false))
|
||||||
|
$requestHash = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData([IO.File]::ReadAllBytes($requestPath))).ToLowerInvariant()
|
||||||
|
function Write-Envelope([string]$Owner='SYNTHETIC-OWNER', [int]$Hours=1, [bool]$Forge=$false) {
|
||||||
|
$payload = @{ requestSha256=$requestHash; approved=$true; ownerSid=$Owner; decision='Select concept B';
|
||||||
|
issuedUtc=[DateTimeOffset]::UtcNow.ToString('o'); expiresUtc=[DateTimeOffset]::UtcNow.AddHours($Hours).ToString('o') } | ConvertTo-Json -Compress
|
||||||
|
$bytes = [Text.Encoding]::UTF8.GetBytes($payload)
|
||||||
|
$signature = $rsa.SignData($bytes,[Security.Cryptography.HashAlgorithmName]::SHA256,[Security.Cryptography.RSASignaturePadding]::Pkcs1)
|
||||||
|
if ($Forge) { $signature[0] = $signature[0] -bxor 255 }
|
||||||
|
@{ payloadBase64=[Convert]::ToBase64String($bytes); signatureBase64=[Convert]::ToBase64String($signature) } |
|
||||||
|
ConvertTo-Json -Compress | Set-Content -LiteralPath $approvalPath -Encoding utf8NoBOM
|
||||||
|
}
|
||||||
|
function Assert-Verify([bool]$Expected, [string]$Case) {
|
||||||
|
$messages = & (Get-Process -Id $PID).Path -NoProfile -File $Verifier -Action Verify -RequestPath $requestPath `
|
||||||
|
-ApprovalPath $approvalPath -CertificatePath $certPath -ExpectedOwnerSid 'SYNTHETIC-OWNER' 2>&1
|
||||||
|
if (($LASTEXITCODE -eq 0) -ne $Expected) { throw "Signature assertion failed: $Case $messages" }
|
||||||
|
Write-Output "PASS $Case"
|
||||||
|
}
|
||||||
|
Write-Envelope
|
||||||
|
Assert-Verify $true 'valid signature and concrete decision'
|
||||||
|
[IO.File]::WriteAllText($requestPath, $original.Replace('synthetic','changed'), [Text.UTF8Encoding]::new($false))
|
||||||
|
Assert-Verify $false 'replayed approval on changed commit'
|
||||||
|
[IO.File]::WriteAllText($requestPath, $original, [Text.UTF8Encoding]::new($false))
|
||||||
|
Write-Envelope -Forge $true
|
||||||
|
Assert-Verify $false 'forged signature'
|
||||||
|
Write-Envelope -Hours -1
|
||||||
|
Assert-Verify $false 'expired approval'
|
||||||
|
Write-Envelope -Owner 'OTHER-OWNER'
|
||||||
|
Assert-Verify $false 'wrong signing owner'
|
||||||
|
'{"approved":true}' | Set-Content -LiteralPath $approvalPath
|
||||||
|
Assert-Verify $false 'model-written approval JSON'
|
||||||
|
$rsa.Dispose()
|
||||||
|
$certificate.Dispose()
|
||||||
|
} finally {
|
||||||
|
$resolved = [IO.Path]::GetFullPath($folder)
|
||||||
|
$tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath())
|
||||||
|
if (-not $resolved.StartsWith($tempRoot, [StringComparison]::OrdinalIgnoreCase)) { throw 'Cleanup escaped temp directory' }
|
||||||
|
Remove-Item -LiteralPath $resolved -Recurse -Force
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""Subprocess protocol double, used only in a disposable test checkout. No model/network."""
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
args = sys.argv[1:]
|
||||||
|
if args[0] == 'exec':
|
||||||
|
prompt = sys.stdin.read()
|
||||||
|
if Path('simulate-interruption').exists():
|
||||||
|
sys.exit(17)
|
||||||
|
if Path('simulate-timeout').exists():
|
||||||
|
time.sleep(60)
|
||||||
|
if '--sandbox' not in args or args[args.index('--sandbox') + 1] != 'workspace-write':
|
||||||
|
sys.exit(18)
|
||||||
|
checkpoint = 'work'
|
||||||
|
Path('result.txt').write_text('subprocess worker output', encoding='utf-8')
|
||||||
|
Path('observed-prompt.txt').write_text(prompt, encoding='utf-8')
|
||||||
|
result = dict(outcome='complete', summary='synthetic subprocess', blockers=[], requestedApprovals=[], checkpoint=checkpoint)
|
||||||
|
Path(args[args.index('--output-last-message') + 1]).write_text(json.dumps(result), encoding='utf-8')
|
||||||
|
print(json.dumps(dict(type='thread.started', thread_id='synthetic-thread-id')))
|
||||||
|
print(json.dumps(dict(type='turn.completed')))
|
||||||
|
elif args[0] == 'sandbox':
|
||||||
|
# Remove known sandbox switches; execute the real deterministic check in the disposable repo.
|
||||||
|
index = 1
|
||||||
|
while args[index] in ('-C', '-c'):
|
||||||
|
index += 2
|
||||||
|
sys.exit(subprocess.run(args[index:], cwd=Path.cwd()).returncode)
|
||||||
|
else:
|
||||||
|
sys.exit(19)
|
||||||
@@ -0,0 +1,569 @@
|
|||||||
|
"""Disposable Git repositories only. Never imports or calls application services."""
|
||||||
|
import copy
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
import controller as c
|
||||||
|
|
||||||
|
|
||||||
|
class QueueTests(unittest.TestCase):
|
||||||
|
def test_real_queue_is_ordered_and_missing_sources_are_explicit(self):
|
||||||
|
root = Path(__file__).resolve().parents[1]
|
||||||
|
manifest = json.loads((root / 'backlog.json').read_text(encoding='utf-8'))
|
||||||
|
c.validate_manifest(manifest, root)
|
||||||
|
self.assertEqual(list(range(5, 12)), [s['sequence'] for s in manifest['stages']])
|
||||||
|
self.assertEqual('PHASE-10', manifest['stages'][0]['id'])
|
||||||
|
self.assertFalse(manifest['executionEnabled'])
|
||||||
|
|
||||||
|
def test_production_branches_rejected(self):
|
||||||
|
m = fixture_manifest()
|
||||||
|
m['stages'][0]['branch'] = 'main'
|
||||||
|
with self.assertRaises(c.GateError):
|
||||||
|
c.validate_manifest(m)
|
||||||
|
|
||||||
|
def test_forward_dependency_rejected(self):
|
||||||
|
m = fixture_manifest()
|
||||||
|
m['stages'][0]['dependencies'] = ['TWO']
|
||||||
|
with self.assertRaises(c.GateError):
|
||||||
|
c.validate_manifest(m)
|
||||||
|
|
||||||
|
|
||||||
|
def fixture_manifest():
|
||||||
|
def stage(name, sequence, deps):
|
||||||
|
return dict(id=name, sequence=sequence, sourcePrompt='prompts/task.txt',
|
||||||
|
promptSha256='', sourceStatus='VERIFIED', dependencies=deps,
|
||||||
|
branch='codex/' + name.lower(), requiredOwnerApprovals=['entry'],
|
||||||
|
retryPolicy=dict(maxAttempts=2, maxSessions=4, backoffSeconds=0),
|
||||||
|
status='PENDING', evidence=[], reports=[], contextFiles=[],
|
||||||
|
checkpoints=[dict(id='work', instruction='Synthetic task only',
|
||||||
|
approvalsBefore=[], approvalsAfter=[], deferrableGates=[],
|
||||||
|
requiredArtifacts=['result.txt'], checks=[dict(id='self-check',
|
||||||
|
argv=[sys.executable, '-c', 'print("verified")'], cwd='.',
|
||||||
|
report=None)], evidenceRequirements=['synthetic-only'])])
|
||||||
|
return dict(schemaVersion=1, executionEnabled=True, integrationBranch='codex/integration',
|
||||||
|
minimumCodexVersion='0.163.0', remote='origin', baselineCommit='0'*40,
|
||||||
|
baselineBranch='codex/base', stages=[stage('ONE', 5, []), stage('TWO', 6, ['ONE'])])
|
||||||
|
|
||||||
|
|
||||||
|
class SyntheticRunner:
|
||||||
|
def __init__(self, result='complete', fail_checks=False):
|
||||||
|
self.result = result
|
||||||
|
self.fail_checks = fail_checks
|
||||||
|
self.calls = 0
|
||||||
|
|
||||||
|
def run_session(self, engine, stage, checkpoint, directory, prompt):
|
||||||
|
self.calls += 1
|
||||||
|
(engine.repo / 'result.txt').write_text(f'{stage["id"]} {self.calls}', encoding='utf-8')
|
||||||
|
if self.result == 'interrupt':
|
||||||
|
raise c.Interrupted('simulated CLI termination')
|
||||||
|
if self.result == 'fail':
|
||||||
|
raise c.GateError('synthetic failure')
|
||||||
|
return dict(outcome=self.result, summary='synthetic', blockers=[],
|
||||||
|
requestedApprovals=[], checkpoint=checkpoint['id'])
|
||||||
|
|
||||||
|
def run_check(self, engine, check, directory):
|
||||||
|
if self.fail_checks:
|
||||||
|
raise c.GateError('synthetic test exit 1')
|
||||||
|
return {'id': check['id'], 'exitCode': 0, 'report': None, 'passed': None, 'failed': 0}
|
||||||
|
|
||||||
|
|
||||||
|
class EngineTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp = tempfile.TemporaryDirectory(prefix='jobjakt-orchestrator-test-')
|
||||||
|
self.root = Path(self.temp.name)
|
||||||
|
self.repo = self.root / 'worker'
|
||||||
|
self.repo.mkdir()
|
||||||
|
self.remote = self.root / 'remote.git'
|
||||||
|
self.git_at(self.root, 'init', '--bare', str(self.remote))
|
||||||
|
self.git_at(self.repo, 'init', '-b', 'codex/base')
|
||||||
|
self.git_at(self.repo, 'config', 'user.name', 'Synthetic Test')
|
||||||
|
self.git_at(self.repo, 'config', 'user.email', 'synthetic@invalid.example')
|
||||||
|
(self.repo / 'README').write_text('synthetic only')
|
||||||
|
self.git_at(self.repo, 'add', '.')
|
||||||
|
self.git_at(self.repo, 'commit', '-m', 'baseline')
|
||||||
|
self.git_at(self.repo, 'remote', 'add', 'origin', str(self.remote))
|
||||||
|
self.git_at(self.repo, 'push', 'origin', 'HEAD:refs/heads/codex/base')
|
||||||
|
self.control = self.root / 'control'
|
||||||
|
(self.control / 'prompts').mkdir(parents=True)
|
||||||
|
(self.control / 'prompts/task.txt').write_text('Complete synthetic task. No real backlog.')
|
||||||
|
self.manifest = fixture_manifest()
|
||||||
|
self.manifest['baselineCommit'] = self.git_at(self.repo, 'rev-parse', 'HEAD')
|
||||||
|
for s in self.manifest['stages']:
|
||||||
|
s['promptSha256'] = c.sha_file(self.control / s['sourcePrompt'])
|
||||||
|
self.config = dict(repository=str(self.repo), codexExecutable='codex',
|
||||||
|
powershellExecutable='pwsh', publicKeyCertificate='owner.cer',
|
||||||
|
ownerSid='OWNER', runnerSid='RUNNER', executionWindows=[],
|
||||||
|
maxSessionSeconds=15, maxCheckSeconds=15, maxRunSeconds=60,
|
||||||
|
maxLogBytes=1048576, workerCodeXHome=str(self.root / 'codex-home'))
|
||||||
|
self.runner = SyntheticRunner()
|
||||||
|
self.engine = c.Engine(self.control, self.manifest, self.config, runner=self.runner,
|
||||||
|
approval_verifier=lambda request: True, host_verifier=lambda: None)
|
||||||
|
self.engine.initialize()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.temp.cleanup()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def git_at(directory, *args):
|
||||||
|
p = subprocess.run(['git', '-c', 'core.hooksPath=', '-C', str(directory), *args],
|
||||||
|
text=True, capture_output=True, encoding='utf-8')
|
||||||
|
if p.returncode:
|
||||||
|
raise AssertionError(p.stderr)
|
||||||
|
return p.stdout.strip()
|
||||||
|
|
||||||
|
def run_engine(self):
|
||||||
|
self.engine.run()
|
||||||
|
return self.engine.store.read()
|
||||||
|
|
||||||
|
def test_success_orders_stages_and_verifies_remote(self):
|
||||||
|
state = self.run_engine()
|
||||||
|
self.assertEqual(['COMPLETED', 'COMPLETED'], [s['status'] for s in state['stages'].values()])
|
||||||
|
self.assertEqual(2, self.runner.calls)
|
||||||
|
remote = self.git_at(self.repo, 'ls-remote', 'origin', 'refs/heads/codex/two').split()[0]
|
||||||
|
self.assertEqual(remote, state['integrationCommit'])
|
||||||
|
|
||||||
|
def test_missing_approval_does_not_run_model(self):
|
||||||
|
self.engine.approval_verifier = lambda request: False
|
||||||
|
state = self.run_engine()
|
||||||
|
self.assertEqual('AWAITING_APPROVAL', state['stages']['ONE']['status'])
|
||||||
|
self.assertEqual(0, self.runner.calls)
|
||||||
|
self.assertTrue(list((self.control / 'requests').glob('*.json')))
|
||||||
|
|
||||||
|
def test_dependency_cannot_be_bypassed(self):
|
||||||
|
state = self.engine.store.read()
|
||||||
|
state['stages']['ONE']['status'] = 'CONDITIONALLY_ACCEPTED'
|
||||||
|
self.engine.store.write(state)
|
||||||
|
self.engine.approval_verifier = lambda request: False
|
||||||
|
self.assertEqual(0, self.runner.calls)
|
||||||
|
self.assertNotEqual('COMPLETED', self.run_engine()['stages']['TWO']['status'])
|
||||||
|
|
||||||
|
def test_failed_stage_stops_and_exhausts_retry_budget(self):
|
||||||
|
self.runner.result = 'fail'
|
||||||
|
state = self.run_engine()
|
||||||
|
self.assertEqual('FAILED', state['stages']['ONE']['status'])
|
||||||
|
self.assertEqual(2, self.runner.calls)
|
||||||
|
self.engine.approval_verifier = lambda request: request['kind'] != 'retry-budget-reset'
|
||||||
|
self.engine.retry('ONE')
|
||||||
|
self.run_engine()
|
||||||
|
self.assertEqual(2, self.runner.calls)
|
||||||
|
|
||||||
|
def test_interruption_is_not_success_and_resume_has_budget(self):
|
||||||
|
self.runner.result = 'interrupt'
|
||||||
|
self.assertNotEqual('COMPLETED', self.run_engine()['stages']['ONE']['status'])
|
||||||
|
self.runner.result = 'complete'
|
||||||
|
state = self.run_engine()
|
||||||
|
self.assertEqual('COMPLETED', state['stages']['ONE']['status'])
|
||||||
|
|
||||||
|
def test_missing_prompt_blocks(self):
|
||||||
|
(self.control / 'prompts/task.txt').unlink()
|
||||||
|
self.assertEqual('BLOCKED', self.run_engine()['stages']['ONE']['status'])
|
||||||
|
self.assertEqual(0, self.runner.calls)
|
||||||
|
|
||||||
|
def test_changed_prompt_blocks(self):
|
||||||
|
(self.control / 'prompts/task.txt').write_text('changed')
|
||||||
|
self.assertEqual('BLOCKED', self.run_engine()['stages']['ONE']['status'])
|
||||||
|
|
||||||
|
def test_unavailable_remote_preserves_commit_and_retries_publication_only(self):
|
||||||
|
self.git_at(self.repo, 'remote', 'set-url', 'origin', str(self.root / 'absent.git'))
|
||||||
|
state = self.run_engine()
|
||||||
|
self.assertEqual(0, self.runner.calls) # baseline publication must be verified first
|
||||||
|
self.assertEqual('BLOCKED', state['stages']['ONE']['status'])
|
||||||
|
|
||||||
|
def test_push_failure_preserves_tested_commit(self):
|
||||||
|
original = self.engine.publish
|
||||||
|
self.engine.publish = lambda *args: (_ for _ in ()).throw(c.PublicationError('push unavailable'))
|
||||||
|
state = self.run_engine()
|
||||||
|
commit = state['stages']['ONE']['pendingPublication']
|
||||||
|
self.assertTrue(commit)
|
||||||
|
self.assertEqual(1, self.runner.calls)
|
||||||
|
self.engine.publish = original
|
||||||
|
state = self.run_engine()
|
||||||
|
self.assertEqual('COMPLETED', state['stages']['ONE']['status'])
|
||||||
|
self.assertEqual(2, self.runner.calls) # only stage TWO ran after retry
|
||||||
|
|
||||||
|
def test_push_verification_mismatch_stops(self):
|
||||||
|
self.engine.verify_remote = lambda branch, commit: (_ for _ in ()).throw(c.PublicationError('mismatch'))
|
||||||
|
self.assertEqual('BLOCKED', self.run_engine()['stages']['ONE']['status'])
|
||||||
|
self.assertEqual(0, self.runner.calls)
|
||||||
|
|
||||||
|
def test_conditional_requires_explicit_deferrable_gates(self):
|
||||||
|
self.runner.result = 'conditional'
|
||||||
|
state = self.run_engine()
|
||||||
|
self.assertEqual('CONDITIONALLY_ACCEPTED', state['stages']['ONE']['status'])
|
||||||
|
self.assertEqual(1, self.runner.calls)
|
||||||
|
|
||||||
|
def test_pause_persists_and_resume_clears_it(self):
|
||||||
|
self.engine.pause()
|
||||||
|
self.assertEqual(0, self.runner.calls)
|
||||||
|
self.run_engine()
|
||||||
|
self.assertEqual(0, self.runner.calls)
|
||||||
|
self.engine.resume()
|
||||||
|
self.run_engine()
|
||||||
|
self.assertEqual(2, self.runner.calls)
|
||||||
|
|
||||||
|
def test_duplicate_lock_rejected(self):
|
||||||
|
with c.RepoLock(self.repo):
|
||||||
|
with self.assertRaises(c.GateError):
|
||||||
|
with c.RepoLock(self.repo):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_corrupt_state_recovers_last_good_without_success(self):
|
||||||
|
self.engine.pause()
|
||||||
|
self.engine.store.path.write_text('{corrupt')
|
||||||
|
state = self.engine.store.read()
|
||||||
|
self.assertTrue(state['recoveryRequired'])
|
||||||
|
self.assertEqual('PENDING', state['stages']['ONE']['status'])
|
||||||
|
|
||||||
|
def test_double_corruption_fails_closed(self):
|
||||||
|
self.engine.store.path.write_text('bad')
|
||||||
|
self.engine.store.backup.write_text('bad')
|
||||||
|
with self.assertRaises(c.GateError):
|
||||||
|
self.engine.store.read()
|
||||||
|
|
||||||
|
def test_accidental_main_checkout_rejected(self):
|
||||||
|
self.git_at(self.repo, 'switch', '-c', 'main')
|
||||||
|
state = self.run_engine()
|
||||||
|
self.assertEqual('BLOCKED', state['stages']['ONE']['status'])
|
||||||
|
self.assertEqual(0, self.runner.calls)
|
||||||
|
|
||||||
|
def test_failing_checks_never_commit_or_advance(self):
|
||||||
|
self.runner.fail_checks = True
|
||||||
|
self.assertEqual('FAILED', self.run_engine()['stages']['ONE']['status'])
|
||||||
|
self.assertEqual(self.manifest['baselineCommit'], self.git_at(self.repo, 'rev-parse', 'HEAD'))
|
||||||
|
|
||||||
|
def test_model_cannot_approve_itself(self):
|
||||||
|
self.engine.approval_verifier = lambda request: False
|
||||||
|
(self.repo / 'approval.json').write_text('{"approved":true}')
|
||||||
|
self.assertEqual('AWAITING_APPROVAL', self.run_engine()['stages']['ONE']['status'])
|
||||||
|
self.assertEqual(0, self.runner.calls)
|
||||||
|
|
||||||
|
def test_disabled_manifest_never_executes(self):
|
||||||
|
self.engine.manifest['executionEnabled'] = False
|
||||||
|
with self.assertRaises(c.GateError):
|
||||||
|
self.run_engine() # mutating policy without signed adoption is rejected
|
||||||
|
self.assertEqual(0, self.runner.calls)
|
||||||
|
|
||||||
|
def test_manifest_initially_disabled(self):
|
||||||
|
state = self.engine.store.read()
|
||||||
|
self.engine.manifest['executionEnabled'] = False
|
||||||
|
state['manifestSha256'] = c.digest(self.engine.manifest)
|
||||||
|
self.engine.store.write(state)
|
||||||
|
self.run_engine()
|
||||||
|
self.assertEqual(0, self.runner.calls)
|
||||||
|
|
||||||
|
def test_after_approval_binds_tested_commit_without_rerunning_worker(self):
|
||||||
|
self.manifest['stages'][0]['checkpoints'][0]['approvalsAfter'] = ['design-selection']
|
||||||
|
state = self.engine.store.read()
|
||||||
|
state['manifestSha256'] = c.digest(self.manifest)
|
||||||
|
self.engine.store.write(state)
|
||||||
|
self.engine.approval_verifier = lambda r: r['kind'] != 'design-selection'
|
||||||
|
state = self.run_engine()
|
||||||
|
self.assertEqual('AWAITING_APPROVAL', state['stages']['ONE']['status'])
|
||||||
|
self.assertTrue(state['stages']['ONE']['checkpointCommit'])
|
||||||
|
self.assertEqual(1, self.runner.calls)
|
||||||
|
self.engine.approval_verifier = lambda r: {'decision': 'Use concept B, cyan buttons from D'}
|
||||||
|
self.assertEqual('COMPLETED', self.run_engine()['stages']['TWO']['status'])
|
||||||
|
self.assertEqual(2, self.runner.calls)
|
||||||
|
|
||||||
|
def test_tampered_evidence_cannot_be_approved(self):
|
||||||
|
self.manifest['stages'][0]['checkpoints'][0]['approvalsAfter'] = ['review']
|
||||||
|
state = self.engine.store.read()
|
||||||
|
state['manifestSha256'] = c.digest(self.manifest)
|
||||||
|
self.engine.store.write(state)
|
||||||
|
self.engine.approval_verifier = lambda r: r['kind'] != 'review'
|
||||||
|
self.run_engine()
|
||||||
|
(self.repo / 'result.txt').write_text('tampered')
|
||||||
|
self.engine.approval_verifier = lambda r: True
|
||||||
|
self.assertEqual('BLOCKED', self.run_engine()['stages']['ONE']['status'])
|
||||||
|
|
||||||
|
def test_restart_running_marker_cannot_mean_completed(self):
|
||||||
|
state = self.engine.store.read()
|
||||||
|
state['stages']['ONE']['status'] = 'RUNNING'
|
||||||
|
self.engine.store.write(state)
|
||||||
|
c.atomic(self.control / 'active-process.json', {'pid': 999999})
|
||||||
|
with self.assertRaises(c.GateError):
|
||||||
|
self.run_engine()
|
||||||
|
self.assertEqual(0, self.runner.calls)
|
||||||
|
|
||||||
|
def test_same_repo_other_control_root_cannot_take_lock(self):
|
||||||
|
with c.RepoLock(self.repo):
|
||||||
|
other = c.Engine(self.root / 'other-control', self.manifest, self.config)
|
||||||
|
with self.assertRaises(c.GateError):
|
||||||
|
with c.RepoLock(other.repo):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_execution_window_stops_before_worker(self):
|
||||||
|
self.engine.in_window = lambda: False
|
||||||
|
self.run_engine()
|
||||||
|
self.assertEqual(0, self.runner.calls)
|
||||||
|
|
||||||
|
def test_conditional_can_advance_only_with_explicit_signed_deferral(self):
|
||||||
|
for stage in self.manifest['stages']:
|
||||||
|
stage['checkpoints'][0]['deferrableGates'] = ['production-human-review']
|
||||||
|
state = self.engine.store.read()
|
||||||
|
state['manifestSha256'] = c.digest(self.manifest)
|
||||||
|
self.engine.store.write(state)
|
||||||
|
self.runner.result = 'conditional'
|
||||||
|
self.engine.approval_verifier = lambda r: r['kind'] != 'conditional-deferral'
|
||||||
|
self.assertEqual('AWAITING_APPROVAL', self.run_engine()['stages']['ONE']['status'])
|
||||||
|
self.engine.approval_verifier = lambda r: True
|
||||||
|
self.assertEqual('COMPLETED', self.run_engine()['stages']['TWO']['status'])
|
||||||
|
|
||||||
|
def test_unconfigured_test_adapter_blocks_before_model(self):
|
||||||
|
self.manifest['stages'][0]['checkpoints'][0]['checks'][0]['configured'] = False
|
||||||
|
state = self.engine.store.read()
|
||||||
|
state['manifestSha256'] = c.digest(self.manifest)
|
||||||
|
self.engine.store.write(state)
|
||||||
|
self.assertEqual('BLOCKED', self.run_engine()['stages']['ONE']['status'])
|
||||||
|
self.assertEqual(0, self.runner.calls)
|
||||||
|
|
||||||
|
def test_design_revision_reruns_current_checkpoint_and_keeps_owner_selection(self):
|
||||||
|
self.manifest['stages'][0]['checkpoints'][0]['approvalsAfter'] = ['homepage-design-selection']
|
||||||
|
state = self.engine.store.read()
|
||||||
|
state['manifestSha256'] = c.digest(self.manifest)
|
||||||
|
self.engine.store.write(state)
|
||||||
|
self.engine.approval_verifier = lambda r: ({'decision': 'Revise A using buttons from D', 'disposition': 'Revise'}
|
||||||
|
if r['kind'] == 'homepage-design-selection' else True)
|
||||||
|
self.assertEqual('PENDING', self.run_engine()['stages']['ONE']['status'])
|
||||||
|
self.assertEqual(1, self.runner.calls)
|
||||||
|
self.engine.approval_verifier = lambda r: {'decision': 'Approve revised concept A', 'disposition': 'Approve'}
|
||||||
|
state = self.run_engine()
|
||||||
|
self.assertEqual('COMPLETED', state['stages']['TWO']['status'])
|
||||||
|
self.assertEqual(3, self.runner.calls)
|
||||||
|
self.assertTrue(any('buttons from D' in d['decision'] for d in state['ownerDecisions']))
|
||||||
|
|
||||||
|
def test_recovered_running_without_marker_requires_reconciliation(self):
|
||||||
|
state = self.engine.store.read()
|
||||||
|
state['stages']['ONE']['status'] = 'RUNNING'
|
||||||
|
self.engine.store.write(state)
|
||||||
|
with self.assertRaisesRegex(c.GateError, 'RUNNING'):
|
||||||
|
self.run_engine()
|
||||||
|
self.assertEqual(0, self.runner.calls)
|
||||||
|
|
||||||
|
def test_unsigned_recovery_cannot_clear_running_guard(self):
|
||||||
|
state = self.engine.store.read()
|
||||||
|
state['stages']['ONE']['status'] = 'RUNNING'
|
||||||
|
self.engine.store.write(state)
|
||||||
|
c.atomic(self.control / 'config.json', self.config)
|
||||||
|
c.atomic(self.control / 'backlog.json', self.manifest)
|
||||||
|
self.engine.approval_verifier = lambda r: False
|
||||||
|
with mock.patch.object(c, 'Engine', return_value=self.engine):
|
||||||
|
self.assertEqual(3, c.main(['recover','--control-root',str(self.control)]))
|
||||||
|
with self.assertRaises(c.GateError):
|
||||||
|
self.engine.resume()
|
||||||
|
self.run_engine()
|
||||||
|
self.assertEqual(0, self.runner.calls)
|
||||||
|
|
||||||
|
def test_cryptographically_approved_resume_e2e(self):
|
||||||
|
pwsh = shutil.which('pwsh')
|
||||||
|
self.assertIsNotNone(pwsh, 'PowerShell is a required orchestrator test prerequisite')
|
||||||
|
signer = Path(__file__).with_name('Synthetic-Signer.ps1')
|
||||||
|
verifier = Path(c.__file__).parent.parent / 'scripts/Approve-JobjaktBacklog.ps1'
|
||||||
|
shutil.copyfile(verifier,self.control / 'Approve-JobjaktBacklog.ps1')
|
||||||
|
subprocess.run([pwsh,'-NoProfile','-File',str(signer),'-Action','Setup','-Root',str(self.control)],
|
||||||
|
check=True,capture_output=True)
|
||||||
|
self.engine.config['powershellExecutable'] = pwsh
|
||||||
|
self.engine.approval_verifier = self.engine.verify_approval
|
||||||
|
for _ in range(8):
|
||||||
|
state = self.run_engine()
|
||||||
|
if all(ss['status'] == 'COMPLETED' for ss in state['stages'].values()):
|
||||||
|
break
|
||||||
|
pending = next(ss for ss in state['stages'].values() if ss['awaiting'])
|
||||||
|
request = Path(pending['awaiting'][0]['request'])
|
||||||
|
if state['stages']['ONE']['entryBaseline'] is None:
|
||||||
|
self.assertEqual(0,self.runner.calls)
|
||||||
|
subprocess.run([pwsh,'-NoProfile','-File',str(signer),'-Action','Sign','-Root',str(self.control),
|
||||||
|
'-RequestPath',str(request),'-ApprovalPath',str(self.control / 'approvals' / request.name)],
|
||||||
|
check=True,capture_output=True)
|
||||||
|
self.assertTrue(all(ss['status'] == 'COMPLETED' for ss in state['stages'].values()))
|
||||||
|
self.assertEqual(2,self.runner.calls)
|
||||||
|
|
||||||
|
|
||||||
|
class ReportTests(unittest.TestCase):
|
||||||
|
def test_report_membership_and_skip_enforcement(self):
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
path = Path(td) / 'report.xml'
|
||||||
|
path.write_text('<testsuite><testcase classname="A" name="B"/></testsuite>')
|
||||||
|
spec = dict(format='junit', expectedNames=['A.B'])
|
||||||
|
self.assertEqual(1, c.parse_report(path, spec)['passed'])
|
||||||
|
spec['expectedNames'] = ['A.Missing']
|
||||||
|
with self.assertRaises(c.GateError):
|
||||||
|
c.parse_report(path, spec)
|
||||||
|
path.write_text('<testsuite><testcase name="B"><skipped/></testcase></testsuite>')
|
||||||
|
with self.assertRaises(c.GateError):
|
||||||
|
c.parse_report(path, dict(format='junit'))
|
||||||
|
|
||||||
|
def test_empty_report_is_not_a_pass(self):
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
path = Path(td) / 'report.xml'
|
||||||
|
path.write_text('<testsuite/>')
|
||||||
|
with self.assertRaises(c.GateError):
|
||||||
|
c.parse_report(path, dict(format='junit'))
|
||||||
|
|
||||||
|
def test_playwright_retry_and_expected_failure_are_not_pass(self):
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
path = Path(td) / 'report.json'
|
||||||
|
for case in [dict(projectName='x',results=[dict(status='failed'),dict(status='passed')]),
|
||||||
|
dict(projectName='x',expectedStatus='failed',results=[dict(status='failed')])]:
|
||||||
|
c.atomic(path, dict(suites=[dict(specs=[dict(title='a',tests=[case])])]))
|
||||||
|
with self.assertRaises(c.GateError):
|
||||||
|
c.parse_report(path, dict(format='playwright'))
|
||||||
|
|
||||||
|
def test_path_escape_rejected(self):
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
with self.assertRaises(c.GateError):
|
||||||
|
c.under(td, '../outside')
|
||||||
|
|
||||||
|
def test_redacts_common_credentials(self):
|
||||||
|
result = c.redact('password=secret Bearer abc https://user:pass@example.invalid token=hello')
|
||||||
|
for secret in ['secret', 'abc', 'user:pass', 'hello']:
|
||||||
|
self.assertNotIn(secret, result)
|
||||||
|
|
||||||
|
def test_json_log_redaction_preserves_event_structure(self):
|
||||||
|
line = json.dumps({'type':'item.completed','item':{'text':'password=secret','authorization':'Bearer hidden'}})
|
||||||
|
value = json.loads(c.redact(line))
|
||||||
|
self.assertEqual('item.completed',value['type'])
|
||||||
|
self.assertNotIn('secret',json.dumps(value))
|
||||||
|
self.assertNotIn('hidden',json.dumps(value))
|
||||||
|
|
||||||
|
|
||||||
|
class ProtocolRunner(c.ProcessRunner):
|
||||||
|
def codex_command(self, engine, *args):
|
||||||
|
return [sys.executable, str(Path(__file__).with_name('fake_codex.py')), *args]
|
||||||
|
|
||||||
|
|
||||||
|
class SubprocessTests(unittest.TestCase):
|
||||||
|
tearDown = EngineTests.tearDown
|
||||||
|
git_at = staticmethod(EngineTests.git_at)
|
||||||
|
run_engine = EngineTests.run_engine
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
EngineTests.setUp(self)
|
||||||
|
self.engine.runner = ProtocolRunner()
|
||||||
|
self.engine.config['workerCodeXHome'] = str(self.root / 'auth-only')
|
||||||
|
|
||||||
|
def test_cli_protocol_real_process_and_complete_prompt(self):
|
||||||
|
self.assertEqual('COMPLETED', self.run_engine()['stages']['TWO']['status'])
|
||||||
|
observed = (self.repo / 'observed-prompt.txt').read_text(encoding='utf-8')
|
||||||
|
self.assertIn('Complete synthetic task. No real backlog.', observed)
|
||||||
|
self.assertIn('ACTIVE CHECKPOINT ONLY', observed)
|
||||||
|
self.assertTrue(list((self.control / 'sessions').rglob('session.json')))
|
||||||
|
|
||||||
|
def test_cli_nonzero_exit_never_accepts(self):
|
||||||
|
(self.repo / 'simulate-interruption').write_text('synthetic')
|
||||||
|
self.git_at(self.repo,'add','.')
|
||||||
|
self.git_at(self.repo,'commit','-m','synthetic interruption fixture')
|
||||||
|
commit = self.git_at(self.repo,'rev-parse','HEAD')
|
||||||
|
self.git_at(self.repo,'push','origin','HEAD:refs/heads/codex/base')
|
||||||
|
self.manifest['baselineCommit'] = commit
|
||||||
|
state = self.engine.store.read()
|
||||||
|
state['manifestSha256'] = c.digest(self.manifest)
|
||||||
|
state['integrationCommit'] = commit
|
||||||
|
self.engine.store.write(state)
|
||||||
|
self.assertEqual('FAILED', self.run_engine()['stages']['ONE']['status'])
|
||||||
|
|
||||||
|
def test_real_check_report_must_match_expected_membership(self):
|
||||||
|
check = self.manifest['stages'][0]['checkpoints'][0]['checks'][0]
|
||||||
|
xml = '<testsuite><testcase name="proof"/></testsuite>'
|
||||||
|
check['argv'] = [sys.executable,'-c',
|
||||||
|
f'from pathlib import Path; Path("suite.xml").write_text({xml!r})']
|
||||||
|
check['report'] = dict(format='junit',path='suite.xml',expectedNames=['.proof'])
|
||||||
|
state = self.engine.store.read()
|
||||||
|
state['manifestSha256'] = c.digest(self.manifest)
|
||||||
|
self.engine.store.write(state)
|
||||||
|
self.assertEqual('COMPLETED', self.run_engine()['stages']['ONE']['status'])
|
||||||
|
|
||||||
|
def test_prompt_stall_still_obeys_timeout(self):
|
||||||
|
started = time.monotonic()
|
||||||
|
with self.assertRaises(c.Interrupted):
|
||||||
|
self.engine.runner.supervise(self.engine,
|
||||||
|
[sys.executable,'-c','import time; time.sleep(8)'], self.control / 'stall',
|
||||||
|
1, stdin='x' * 4_000_000)
|
||||||
|
self.assertLess(time.monotonic() - started, 5)
|
||||||
|
|
||||||
|
def test_no_newline_output_obeys_log_budget(self):
|
||||||
|
self.engine.config['maxLogBytes'] = 1024
|
||||||
|
with self.assertRaisesRegex(c.GateError, 'log byte budget'):
|
||||||
|
self.engine.runner.supervise(self.engine,
|
||||||
|
[sys.executable,'-c','import sys,time; sys.stdout.write("x"*200000); sys.stdout.flush(); time.sleep(8)'],
|
||||||
|
self.control / 'huge-line', 3)
|
||||||
|
|
||||||
|
def test_marker_write_failure_does_not_leave_worker(self):
|
||||||
|
original = c.atomic
|
||||||
|
def fail_marker(path, value):
|
||||||
|
if Path(path).name == 'active-process.json' and value.get('pid'):
|
||||||
|
raise OSError('synthetic disk full')
|
||||||
|
return original(path,value)
|
||||||
|
with mock.patch.object(c,'atomic',side_effect=fail_marker):
|
||||||
|
with self.assertRaises(OSError):
|
||||||
|
self.engine.runner.supervise(self.engine,
|
||||||
|
[sys.executable,'-c','import time; time.sleep(8)'],self.control / 'disk-full',3)
|
||||||
|
child = self.engine.runner.child
|
||||||
|
if child and child.poll() is None:
|
||||||
|
child.kill()
|
||||||
|
child.wait()
|
||||||
|
self.fail('Worker survived marker-write failure')
|
||||||
|
|
||||||
|
@unittest.skipUnless(os.name == 'nt', 'Windows suspended launch only')
|
||||||
|
def test_worker_cannot_execute_before_job_assignment(self):
|
||||||
|
early = self.repo / 'early.txt'
|
||||||
|
original = c.WindowsJob.assign
|
||||||
|
def delayed_assign(job, process):
|
||||||
|
time.sleep(.3)
|
||||||
|
self.assertFalse(early.exists(), 'Worker ran before containment assignment')
|
||||||
|
return original(job, process)
|
||||||
|
with mock.patch.object(c.WindowsJob,'assign',delayed_assign):
|
||||||
|
self.engine.runner.supervise(self.engine,[sys.executable,'-c',
|
||||||
|
f'from pathlib import Path; Path({str(early)!r}).write_text("started")'],self.control / 'suspended',5)
|
||||||
|
self.assertTrue(early.exists())
|
||||||
|
|
||||||
|
@unittest.skipUnless(os.name == 'nt', 'Windows Job Objects only')
|
||||||
|
def test_killed_controller_terminates_worker_and_grandchild(self):
|
||||||
|
script = self.root / 'parent.py'
|
||||||
|
grandchild = self.root / 'grandchild.pid'
|
||||||
|
worker_code = (f'import subprocess,sys,time; from pathlib import Path; '
|
||||||
|
f'p=subprocess.Popen([sys.executable,"-c","import time; time.sleep(60)"]); '
|
||||||
|
f'Path({str(grandchild)!r}).write_text(str(p.pid)); time.sleep(60)')
|
||||||
|
script.write_text('import sys\nfrom pathlib import Path\n'
|
||||||
|
f'sys.path.insert(0,{str(Path(c.__file__).parent)!r})\nimport controller as c\n'
|
||||||
|
f'e=c.Engine(Path({str(self.control)!r}), {self.manifest!r}, {self.config!r})\n'
|
||||||
|
f'e.runner.supervise(e,[sys.executable,"-c",{worker_code!r}],Path({str(self.control / "orphan")!r}),60)\n',encoding='utf-8')
|
||||||
|
parent = subprocess.Popen([sys.executable,str(script)],stdout=subprocess.DEVNULL,stderr=subprocess.PIPE)
|
||||||
|
try:
|
||||||
|
until = time.monotonic() + 10
|
||||||
|
while not grandchild.exists() and parent.poll() is None and time.monotonic() < until:
|
||||||
|
time.sleep(.05)
|
||||||
|
self.assertTrue(grandchild.exists(), parent.stderr.read().decode() if parent.poll() is not None else 'child did not start')
|
||||||
|
worker_pid = c.read_json(self.control / 'active-process.json')['pid']
|
||||||
|
grandchild_pid = int(grandchild.read_text())
|
||||||
|
parent.kill()
|
||||||
|
parent.wait(timeout=5)
|
||||||
|
import ctypes
|
||||||
|
api = ctypes.WinDLL('kernel32',use_last_error=True)
|
||||||
|
api.OpenProcess.restype = ctypes.c_void_p
|
||||||
|
api.OpenProcess.argtypes = [ctypes.c_ulong,ctypes.c_int,ctypes.c_ulong]
|
||||||
|
api.WaitForSingleObject.argtypes = [ctypes.c_void_p,ctypes.c_ulong]
|
||||||
|
api.CloseHandle.argtypes = [ctypes.c_void_p]
|
||||||
|
for pid in (worker_pid,grandchild_pid):
|
||||||
|
handle = api.OpenProcess(0x100000,0,pid)
|
||||||
|
if handle:
|
||||||
|
self.assertEqual(0,api.WaitForSingleObject(handle,5000),f'contained process {pid} survived')
|
||||||
|
api.CloseHandle(handle)
|
||||||
|
finally:
|
||||||
|
if parent.poll() is None:
|
||||||
|
parent.kill()
|
||||||
|
parent.wait()
|
||||||
|
parent.stderr.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main(verbosity=2)
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# AUTONOMOUS-ROADMAP-01 acceptance — 29 October 2026
|
||||||
|
|
||||||
|
**Controller implementation and synthetic qualification: delivered. Real backlog execution: disabled. Phase 10 implementation: NO-GO.** No production or main-merge authority is granted.
|
||||||
|
|
||||||
|
Source branch: `codex/autonomous-roadmap-01`, isolated managed worktree based on the actual local `5032ccb`. The queue/source checkpoint is `4da3939547a327fe27572b081a893fdf6cb1638c`, pushed and independently read back from origin. The final controller checkpoint is identified by Git history and the final handoff; tested source hashes are in the machine-readable results below. Publishing this branch does not publish or accept the pending pre-Phase-10 ref.
|
||||||
|
|
||||||
|
## Final decision matrix
|
||||||
|
|
||||||
|
| Required area | Decision | Evidence and boundary |
|
||||||
|
|---|---|---|
|
||||||
|
| Queue configuration | **PASS** | Seven actual source workstreams in requested order; full byte-preserved master/detail packs; 49 bounded checkpoints; manifest/source hash validation; source gaps resolved by owner-supplied folders |
|
||||||
|
| Codex non-interactive runner | **PASS for implemented synthetic protocol** | Installed 0.163.0 help and official docs verified; actual subprocess fake-CLI tests exercise full prompt delivery, JSONL completion, structured result, independent test process/report and captured session ID. **Real model/native-sandbox worker NOT RUN / BLOCKED** |
|
||||||
|
| Resume/recovery | **PASS in synthetic/local process scope** | Durable state/backup/OS lock, interruption, pending-publication resume, corrupt-state recovery, unsigned-recovery rejection, actual killed controller/child/grandchild and suspended-launch tests |
|
||||||
|
| Acceptance gate enforcement | **PASS in synthetic scope** | Dependency/order gates; fresh report membership/failure/skip/retry rules; artifact tamper detection; unknown/missing sources and unqualified adapters block; conditional advancement requires declared deferral plus signature |
|
||||||
|
| Owner approval enforcement | **PASS in synthetic/cryptographic scope** | Actual .NET verifier exercised end-to-end with signed queue/stage approvals; six separate valid/replay/forgery/expiry/wrong-owner/plain-JSON cases; design revision/selection text persists |
|
||||||
|
| Git safety | **PASS in disposable repositories and scoped feature publication** | Explicit feature refs; no force/merge/rebase/deploy path; baseline/publication verification, unavailable remote, push failure/mismatch and integration fast-forward tests; real own-branch publication read back |
|
||||||
|
| Isolation/security | **CONDITIONAL** | Windows suspended launch and Job process-tree behavior tested locally. Native Codex sandbox failed CryptUnprotectData; dedicated account/VM, ACL/read/egress/tool/secret/CPU qualification and actual test adapters are not provisioned or certified |
|
||||||
|
| Synthetic orchestration tests | **PASS** | **48 controller tests**, zero failures/errors/skips; source hashes stable throughout full run. **6 separate signature cases**, exit 0. These are disjoint lanes, not additions to Jobjakt's application counts |
|
||||||
|
| Ready to run Phase 10 | **NO-GO** | Existing acceptance withholds implementation; unpublished pre-Phase-10 baseline, owner scope/entry, qualified runtime and adapter gates remain |
|
||||||
|
|
||||||
|
This is a qualified development handoff, not a claim that the real CLI can safely run the roadmap on the current desktop. A direct model-backed execution was deliberately not attempted as a workaround for failed isolation. The shipped real-stage composite test adapter is unconfigured and fails closed until actual provider/browser/Python/PDF/security/preservation lanes and test membership are installed and approved. Static `dry-run` configuration PASS means the queue/source structure is valid, not that the runner identity, certificate, credentials or application runtime are ready.
|
||||||
|
|
||||||
|
## Reproducible evidence
|
||||||
|
|
||||||
|
- `automation/reports/synthetic-results.json`: final test totals, zero failure/error/skip lists, before/after source-stability assertion and exact controller/manifest/test/PowerShell hashes.
|
||||||
|
- `automation/reports/synthetic-tests.txt`: full final 48-case test output.
|
||||||
|
- `automation/reports/signature-results.json` and `signature-tests.txt`: six separate .NET cryptographic cases using temporary keys/certificates only.
|
||||||
|
- `automation/reports/disabled-dry-run.json`: static queue validation, zero source gaps, 49 checkpoints, unqualified adapter explicitly listed, execution disabled and **zero real sessions** independently asserted from durable state.
|
||||||
|
- `automation/SOURCE_INVENTORY.json` and `automation/backlog.json`: actual prompt names/sizes/SHA256 and immutable workstream/checkpoint policy. `.gitattributes` prevents prompt newline normalization.
|
||||||
|
|
||||||
|
The final full run supersedes earlier focused runs and deliberately failing regression runs; those are not added to the final 48. The six signature cases are reported separately. No historical application suite counts were rerun, combined or promoted by this task.
|
||||||
|
|
||||||
|
## Requested failure scenarios
|
||||||
|
|
||||||
|
| Scenario | Synthetic evidence |
|
||||||
|
|---|---|
|
||||||
|
| Queue ordering and dependencies | `test_success_orders_stages_and_verifies_remote`, `test_dependency_cannot_be_bypassed`, forward/cyclic dependency rejection |
|
||||||
|
| Successful and failed stages | actual two-stage fake-worker Git flow; bounded failed model/check flow |
|
||||||
|
| Conditional acceptance | no-declared-deferral stop; declared deferral still waits for signed approval |
|
||||||
|
| Missing approval / bypass | missing signature launches no model; worker JSON cannot approve; actual signed resume E2E; six crypto cases |
|
||||||
|
| CLI interruption / network-style failure | fake CLI exit 17 and interrupted runner never accept; bounded retry with stored failure context |
|
||||||
|
| Restart/resume | persistent RUNNING with/without marker blocks; unsigned recovery cannot clear guard; actual killed parent kills contained child/grandchild |
|
||||||
|
| Missing/changed prompt | source absence/hash change blocks before model execution |
|
||||||
|
| Unavailable remote / push failure / wrong remote HEAD | baseline check prevents work, tested local commit retained, publication-only resume, mismatch blocks |
|
||||||
|
| Repeated failure limit | retry budget exhaustion stops; resetting requires a signed explicit request; total session budget remains finite |
|
||||||
|
| Duplicate start | OS-held common-Git-directory lock rejects second controller, including a different ControlRoot |
|
||||||
|
| Corrupt state | checksum/last-good recovery marked for reconciliation; double corruption fails closed |
|
||||||
|
| Accidental main operation | main/master/production/detached initial checkout and unsafe branch policies rejected; no merge/deploy implementation |
|
||||||
|
| Source/evidence falsification | modified artifact blocks approval; structured reports reject empty/failing/skipped/retried or wrong-membership output |
|
||||||
|
| Resource and shutdown faults | stalled stdin timeout, huge no-newline log limit, PID-marker disk failure cleanup, suspended pre-assignment launch, Job kill-on-parent-death |
|
||||||
|
| Human design loop | signed Revise returns to same checkpoint, persists element choices; later signed Approve advances without another copied prompt |
|
||||||
|
|
||||||
|
This simulates reboot/restart conditions and tests actual process termination; it does not claim a physical PC reboot occurred. Windows Job memory/process limit configuration is implemented; adversarial memory-exhaustion/load qualification of the actual Codex/native sandbox remains operator work.
|
||||||
|
|
||||||
|
## Review and graph evidence
|
||||||
|
|
||||||
|
A read-only independent code review identified blocking stdin delivery, marker-write cleanup, unbounded log allocation, unsigned-recovery state mutation and pre-assignment process execution. Each was corrected and covered by regression tests. Credential redaction now preserves valid JSONL event structure; its regression was observed failing before the fix. Review is supporting evidence, not proof of real-host isolation.
|
||||||
|
|
||||||
|
`graphify update .` rebuilt the separate worktree's AST graph without LLM/API cost. It reports unsupported/no-symbol files and missing SQL/PDF extraction dependencies; graph orientation does not establish runtime routing, security, test completeness or acceptance. The primary checkout's graph was not modified.
|
||||||
|
|
||||||
|
The inspected Gitea deployment workflow triggers on pushes to main (plus PR testing), and the MariaDB workflow lists other explicit qualification branches. This task published only its development branch and created no PR, workflow dispatch, production operation or scheduled runner.
|
||||||
|
|
||||||
|
## Owner handoff
|
||||||
|
|
||||||
|
Use [README](README.md) for exact installation/start/status/pause/resume/approval commands. **First complete Pre-Phase-10 acceptance, publish and verify its final branch, and explicitly approve Phase 10 development entry on a named baseline and exact candidate scope.** Preserve the separate human, S01/PDF, model, CI/security and production conditions in [GATES](GATES.md). Then qualify the isolated runner/test adapters and sign the enabled manifest/environment/roadmap/entry requests. This task intentionally grants none of those decisions.
|
||||||
|
|
||||||
|
No Jobjakt application code, existing tests, schema, local application stack, real CV, production database, Docker service, Ollama/model cache, main branch or production filesystem was changed by this implementation branch.
|
||||||
@@ -14,13 +14,13 @@ Atomic checksummed state with last-good backup, an OS-held repository lock, a du
|
|||||||
|
|
||||||
## Scope and checkpoints
|
## Scope and checkpoints
|
||||||
|
|
||||||
- [ ] AUTO-00: Audit actual source prompts, CLI 0.163.0, local/remote Git state and Phase 10 authority. Record missing/contradictory sources and sandbox failure.
|
- [x] AUTO-00: Audit actual source prompts, CLI 0.163.0, local/remote Git state and Phase 10 authority. Record source mismatches and sandbox failure; import the owner's subsequently supplied I18N/security packs.
|
||||||
- [ ] AUTO-01: Preserve original prompt bytes; implement strict queue validation and stage/checkpoint gates in `automation/backlog.json` and schemas.
|
- [x] AUTO-01: Preserve original prompt bytes; implement queue validation and stage/checkpoint gates in `automation/backlog.json` and schemas.
|
||||||
- [ ] AUTO-02–03: Implement PowerShell entry point, bounded runner, atomic state, exclusive locking, pause/resume, windows and budgets in `automation/controller.py`.
|
- [x] AUTO-02–03: Implement PowerShell entry point, bounded runner, atomic state, exclusive locking, pause/resume, windows and budgets in `automation/controller.py`.
|
||||||
- [ ] AUTO-04–05: Implement signed owner decisions, real test/report verification, safe feature commits and verified publication.
|
- [x] AUTO-04–05: Implement signed owner decisions, test/report verification, safe feature commits and verified publication. Live-host/application qualification remains independently gated.
|
||||||
- [ ] AUTO-06: Implement status, current-stage and redacted logs plus local event notifications.
|
- [x] AUTO-06: Implement status, current-stage and redacted logs plus local event notifications.
|
||||||
- [ ] AUTO-07: Write failing tests first, implement, then run disposable-repository end-to-end tests for every requested failure mode. No application services or real roadmap sessions.
|
- [x] AUTO-07: Red/green tests, 48 passing controller tests and six signature cases; disposable-repository end-to-end coverage, actual subprocess/Windows containment and real verifier paths. No application services or real roadmap sessions.
|
||||||
- [ ] AUTO-08: Document install, authentication, protected approval setup, scheduling, recovery and exact first-entry owner action; record honest final acceptance.
|
- [x] AUTO-08: Document install, authentication, protected approval setup, scheduling, recovery and exact first-entry owner action; record qualified final acceptance.
|
||||||
|
|
||||||
Files touched: new `automation/`, `scripts/Run-JobjaktBacklog.ps1`, `scripts/Approve-JobjaktBacklog.ps1`, documentation, and runtime ignore rules. No application callers, database schemas, production configuration or existing tests change.
|
Files touched: new `automation/`, `scripts/Run-JobjaktBacklog.ps1`, `scripts/Approve-JobjaktBacklog.ps1`, documentation, and runtime ignore rules. No application callers, database schemas, production configuration or existing tests change.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
# Autonomous Jobjakt backlog controller
|
||||||
|
|
||||||
|
This implementation builds the orchestrator only. **The real backlog is disabled. Phase 10 is NO-GO pending its own entry decision.** See [audit](AUDIT.md), [gate register](GATES.md), [security boundary](SECURITY.md), and [acceptance evidence](ACCEPTANCE.md).
|
||||||
|
|
||||||
|
## What is installed
|
||||||
|
|
||||||
|
`scripts/Run-JobjaktBacklog.ps1` provides the Windows command interface. `automation/controller.py` uses Python's standard library; no package installation or application service is required for controller tests. `automation/backlog.json` preserves sequence 05–11 with 49 bounded checkpoints and full source snapshots. Runtime state, approvals, reports and logs belong in a separate protected ControlRoot, never in the model-writable repository. JSON is sufficient because one controller owns an OS-held repository lock and all state writes are atomic/checksummed; SQLite would add no useful concurrency here.
|
||||||
|
|
||||||
|
Requirements: PowerShell **7.2+**, Python **3.12+**, Git and **Codex CLI 0.163.0** (the version audited here; qualify later versions before unattended use). `codex exec` structured output and sandbox flags are described in the [official non-interactive documentation](https://learn.chatgpt.com/docs/non-interactive-mode). Windows 0.163.0 runs test commands as `codex sandbox -C <workspace> -c sandbox_mode="workspace-write" <command> ...`; see local help and the [Windows sandbox documentation](https://learn.chatgpt.com/docs/windows/windows-sandbox).
|
||||||
|
|
||||||
|
## Commands already safe to run now
|
||||||
|
|
||||||
|
The validation installation at `C:\Users\Connor\.codex\orchestrator-validation\autonomous-roadmap-01-final` is a disabled snapshot for dry runs. It references the separate implementation worktree. It is **not** a qualified unattended deployment; start/resume cannot launch the real queue while disabled.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$control = 'C:\Users\Connor\.codex\orchestrator-validation\autonomous-roadmap-01-final'
|
||||||
|
$run = Join-Path $control 'Run-JobjaktBacklog.ps1'
|
||||||
|
pwsh -NoProfile -File $run -Command validate -ControlRoot $control
|
||||||
|
pwsh -NoProfile -File $run -Command dry-run -ControlRoot $control
|
||||||
|
pwsh -NoProfile -File $run -Command status -ControlRoot $control
|
||||||
|
pwsh -NoProfile -File $run -Command current -ControlRoot $control
|
||||||
|
pwsh -NoProfile -File $run -Command logs -ControlRoot $control
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the reproducible isolated tests from this implementation worktree:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Set-Location 'C:\Users\Connor\.codex\worktrees\autonomous-roadmap-01\Job tracker'
|
||||||
|
python -m unittest discover -s automation/tests -v
|
||||||
|
pwsh -NoProfile -File automation/tests/Test-Approvals.ps1 -Verifier "$PWD\scripts\Approve-JobjaktBacklog.ps1"
|
||||||
|
```
|
||||||
|
|
||||||
|
Tests create disposable local worker repositories and bare remotes. The subprocess double exercises the real supervision/result/report paths without a model or network. RSA tests use temporary in-memory keys/certificates and do not install an owner key. They do not qualify the Windows sandbox or real application acceptance.
|
||||||
|
|
||||||
|
## Provision an actual runner, after approval
|
||||||
|
|
||||||
|
Use a dedicated development Windows account/VM with no production credentials, mounts, Docker socket, remote admin access or production network routes. Keep the owner's signing identity separate. A source worktree provides edit isolation; it is not a production security boundary. Prefer a **separate clone** for unattended execution, avoiding shared Git metadata/hooks with ongoing human work. The owner must protect main/production refs server-side and grant the publisher only the intended development refs.
|
||||||
|
|
||||||
|
Create the runner clone from the accepted development branch after its final publication; never default to main. Do not copy private `.env` files from the live checkout. Configure Git author identity and an approved development-only credential helper. Install dependencies and qualify local synthetic service fixtures separately. Do not let the controller install infrastructure or enable workers on your behalf.
|
||||||
|
|
||||||
|
Initialize a new control directory using the implementation source:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$source = 'C:\Users\Connor\.codex\worktrees\autonomous-roadmap-01\Job tracker'
|
||||||
|
pwsh -NoProfile -File "$source\scripts\Run-JobjaktBacklog.ps1" `
|
||||||
|
-Command init -ControlRoot 'C:\JobjaktControl' -Repository 'C:\JobjaktWorker'
|
||||||
|
```
|
||||||
|
|
||||||
|
`init` snapshots code, schemas, source prompts and initial manifest outside the worker. It refuses to overwrite existing state. Keep the trusted installation read-only to model shell commands, with the runner able to persist its state and reports outside the sandbox. The owner provisions ACLs/VM policy; this task does not change machine accounts, firewall or system permissions.
|
||||||
|
|
||||||
|
Edit protected `config.json` as the operator: exact worker path; Codex/pwsh executable paths; owner SID; separate runner SID; public certificate filename/SHA256; dedicated auth-only `workerCodeXHome`; time/log budgets; optional execution windows. Authenticate **as the development runner** with `codex login` using that dedicated CODEX_HOME. Never print/copy auth tokens into logs. Default child environment excludes API-key/production secret variables. Do not use the owner's normal CODEX_HOME with its plugins or credentials.
|
||||||
|
|
||||||
|
Real execution verifies the runner identity, certificate pin, version, disjoint paths, auth-only configuration and a negative sandbox write probe. It refuses missing/failed sandbox controls and never falls back to unrestricted execution. The current host's CryptUnprotectData error must be resolved and the real boundary qualified before any worker can run. A signed environment qualification additionally records OS ACLs, read/network access, CPU/RAM quotas, synthetic service isolation and credential exposure: a successful single write probe cannot certify all of these.
|
||||||
|
|
||||||
|
The security source pack requires Codex Security plugins and actual scanner artifacts. The default auth-only worker intentionally has no plugin capability. A platform owner can provision a vetted, constrained development-only plugin/MCP runner and add explicit `trustedCodexOverrides` entries (`key` under `mcp_servers.*` or `plugins.*`, plus its exact TOML `toml` value) to protected config. Other override namespaces are rejected; explicit sandbox/approval flags remain fixed. This changes the policy hash and requires fresh signed environment/roadmap approval. Qualify every server's identity, permissions, egress, costs and tool exposure before signing: MCP/plugin tools are not automatically confined by the shell sandbox. Missing required scan capability remains BLOCKED. Do not remove source requirements or use unrestricted config loading to make a scan appear to pass.
|
||||||
|
|
||||||
|
## Configure gates and enable the exact queue
|
||||||
|
|
||||||
|
The core backend/frontend/type/lint/build commands are specified. The composite provider/browser/Python/PDF/security/preservation adapter is marked `configured:false`. Before real execution, the platform owner must install its **actual** checkpoint-specific commands and structured report definitions in a proposed manifest, with exact test-suite membership (`expectedNames`) or a trusted independently verified membership adapter. Use original preservation/PDF oracles; do not rewrite them to match current failures. Human/provider/hosted/model requirements remain separate signed evidence decisions. No successful dry run changes these gates.
|
||||||
|
|
||||||
|
Copy `backlog.json` to `proposed-backlog.json` in ControlRoot. Set the exact accepted/published baseline commit and branch, resolved branch/substage policy, qualified checks/artifacts, and finally `executionEnabled:true`. Do this only after the owner explicitly authorizes first execution. Never modify active state JSON to enable it.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pwsh -NoProfile -File C:\JobjaktControl\Run-JobjaktBacklog.ps1 `
|
||||||
|
-Command configure -ControlRoot C:\JobjaktControl -Manifest C:\JobjaktControl\proposed-backlog.json
|
||||||
|
```
|
||||||
|
|
||||||
|
The first invocation writes a `manifest-policy-update` request and exits without applying the policy. Sign that request through the protected owner workflow below, then run the same command again. It adopts the manifest without resetting completed work. Started-stage policy is immutable. A crash between policy/state writes fails closed; restore the verified previous pair from operator backups rather than guessing which policy ran.
|
||||||
|
|
||||||
|
## Owner signing and decisions
|
||||||
|
|
||||||
|
On the **owner's separate Windows identity or machine**, create a nonexportable signing key once:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pwsh -NoProfile -File .\Approve-JobjaktBacklog.ps1 -Action NewKey
|
||||||
|
pwsh -NoProfile -File .\Approve-JobjaktBacklog.ps1 -Action ExportPublicKey `
|
||||||
|
-Thumbprint '<OWNER_CERTIFICATE_THUMBPRINT>' -CertificatePath .\owner.cer
|
||||||
|
Get-FileHash .\owner.cer -Algorithm SHA256
|
||||||
|
```
|
||||||
|
|
||||||
|
Record the printed owner SID and certificate hash in the protected runner configuration. Transfer only `owner.cer` to the runner, never a PFX/private key. A certificate file placed inside a worker checkout is not a protected approval mechanism.
|
||||||
|
|
||||||
|
`status` shows the exact pending request path. Review that JSON and its linked source/evidence. Transfer the exact request bytes to the owner; signing does not alter them. Example:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pwsh -NoProfile -File .\Approve-JobjaktBacklog.ps1 -Action Sign `
|
||||||
|
-RequestPath '.\requests\<REQUEST_SHA256>.json' `
|
||||||
|
-ApprovalPath '.\approvals\<REQUEST_SHA256>.json' `
|
||||||
|
-Thumbprint '<OWNER_CERTIFICATE_THUMBPRINT>' -ExpectedOwnerSid '<OWNER_SID>' `
|
||||||
|
-Disposition Approve -Decision 'Approve the exact stated scope and evidence' -ValidHours 72
|
||||||
|
```
|
||||||
|
|
||||||
|
Transfer the signed envelope to `C:\JobjaktControl\approvals\<REQUEST_SHA256>.json`, then resume. The controller checks its signature, owner, certificate pin, request digest and expiry. Editing a worker `approval.json` or copying a previous commit's approval will not work.
|
||||||
|
|
||||||
|
For homepage feedback, sign the pending design request with `-Disposition Revise -Decision 'Keep concept A layout; use D buttons; revise mobile spacing'`, transfer it, then resume. The next session revises the same checkpoint using that exact decision. After the revised artifacts are published, approve the **new** request with your final selection. `Reject` blocks advancement. The model never chooses on your behalf. Signed decision text is passed to subsequent sessions, so no roadmap prompt copying is needed.
|
||||||
|
|
||||||
|
Environment and roadmap-execution approval are shared for the exact queue policy; later stages reuse those grants while they remain valid. Scope-specific grants, Product Review selection, language review, design choice, security decisions and final requirement reviews are still required at their actual checkpoints. If a grant expires before use, renew that exact request; an accepted earlier checkpoint is not silently undone by a later expiry.
|
||||||
|
|
||||||
|
## Start, monitor, pause, resume and retry
|
||||||
|
|
||||||
|
These commands are for the **qualified, explicitly enabled** installation only:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$run = 'C:\JobjaktControl\Run-JobjaktBacklog.ps1'
|
||||||
|
pwsh -NoProfile -File $run -Command start -ControlRoot C:\JobjaktControl
|
||||||
|
pwsh -NoProfile -File $run -Command status -ControlRoot C:\JobjaktControl
|
||||||
|
pwsh -NoProfile -File $run -Command current -ControlRoot C:\JobjaktControl
|
||||||
|
pwsh -NoProfile -File $run -Command logs -ControlRoot C:\JobjaktControl
|
||||||
|
pwsh -NoProfile -File $run -Command pause -ControlRoot C:\JobjaktControl
|
||||||
|
pwsh -NoProfile -File $run -Command resume -ControlRoot C:\JobjaktControl
|
||||||
|
pwsh -NoProfile -File $run -Command retry -ControlRoot C:\JobjaktControl -Stage PHASE-10
|
||||||
|
```
|
||||||
|
|
||||||
|
`start` stays in the foreground until the queue completes, hits a genuine gate or reaches a configured window/budget. Use a separate terminal for monitoring/pause. `pause` writes a durable request without racing the controller's state lock; supervision stops the active child tree. `resume` clears the pause and runs the next allowed work. Ordinary failures receive bounded remediation sessions with failure context; `retry` never silently resets an exhausted budget. Its explicit reset request requires a signed owner decision. Session totals remain capped even after resetting attempts.
|
||||||
|
|
||||||
|
The state store records initial/accepted commits, checkpoint progress, attempts/sessions, pending publication, approvals, tests, artifacts, reports and recent sessions. `NOT_RUN` is distinct from verified zero failures. Status never combines overlapping suite counts into one roadmap total.
|
||||||
|
|
||||||
|
## Recovery
|
||||||
|
|
||||||
|
- **Push/network failure:** local verified commits remain. Restore development remote access and run `resume`. Only that publication is retried; the model and tests are not repeated for an unchanged candidate. Remote HEAD mismatch or non-fast-forward stops advancement. No force push/rebase/main merge occurs.
|
||||||
|
- **CLI failure/context limit:** failed/incomplete output cannot advance. Retry/continuation starts a fresh bounded session with full applicable prompt, stored result, source identities, prior decisions and failures. CLI thread IDs are recorded for inspection; raw `exec resume --last` is deliberately avoided because it can select unrelated work or inherit unsuitable permissions.
|
||||||
|
- **Parent killed/reboot:** the OS releases the Git lock. A persistent active-process marker or RUNNING state blocks a new worker. Inspect recorded PID, creation time/session/command and all descendants before stopping an orphan; never kill a reused PID blindly. After the tree is gone, run `recover`, sign its exact reconciliation request, then rerun `recover` and `resume`. A launch marker with no PID requires operator inspection/clearance, not an automatic guess. Native time limits apply while the controller lives; VM/Task Scheduler job limits are required for hard orphan resource bounds.
|
||||||
|
- **Corrupt state:** checksum failure loads the last verified backup and sets recovery-required. It does not restart from zero. `recover` requires a signed owner reconciliation. If both copies are corrupt, restore a verified **state + manifest + reports** backup; do not run `init` over the installation. Restore/reconcile exact Git HEAD and remote publication before resuming.
|
||||||
|
- **Unexpected source/Git/policy changes:** stop and investigate. Resume refuses changed prompt hashes, unexpected branch/HEAD, altered evidence, diverged integration or modified manifest. Preserve logs and local commits. Do not reset the worker or silently remove tests.
|
||||||
|
|
||||||
|
## Scheduling and resource limits
|
||||||
|
|
||||||
|
No task or recurring automation is installed by this build. The CLI works without an interactive GUI. An optional Windows Task Scheduler task may invoke the installed `start` command at logon/startup and/or a periodic interval under the dedicated runner. `start` continues existing state while respecting a durable manual pause; reserve `resume` for the owner's deliberate unpause. Use **Run whether user is logged on or not**, **Run with highest privileges disabled**, a maximum duration and **Do not start a new instance**. The repository lock remains the final concurrency guard. Do not use the owner signing account as the scheduled worker.
|
||||||
|
|
||||||
|
Example task action (configure it through Task Scheduler after approval):
|
||||||
|
|
||||||
|
```text
|
||||||
|
Program: <absolute path to pwsh.exe>
|
||||||
|
Arguments: -NoProfile -File C:\JobjaktControl\Run-JobjaktBacklog.ps1 -Command start -ControlRoot C:\JobjaktControl -Python <absolute path to python.exe>
|
||||||
|
Start in: C:\JobjaktControl
|
||||||
|
```
|
||||||
|
|
||||||
|
The PC must be awake, with its approved development fixtures, authentication, dependencies and remote available. The controller does not bypass lock/sleep or wake the computer. `executionWindows` uses the Windows runner's local time; configure its timezone as Europe/Paris if that is the intended window. Overnight windows are supported and a running session stops when its window closes. Defaults are no time window, eight-hour controller budget, two-hour sessions, one-hour checks, 50 MiB per output stream, 64 processes and 8 GiB total child-tree memory. Windows Job Objects enforce process/memory/kill-on-close limits after a suspended launch; CPU and whole-host limits still belong to the qualified VM. Limits stop continuation; they do not assert completion.
|
||||||
|
|
||||||
|
Notifications are local sanitized `events.jsonl` records and terminal status. No paid provider is added. Existing Jobjakt user notifications belong to the application and are not reused for developer orchestration. Event records contain IDs/status, never documents or credentials; an operator can attach an approved local event watcher later. Full session files remain private access-controlled evidence. No unchanged-state notification loop is created.
|
||||||
|
|
||||||
|
## Add future prompts
|
||||||
|
|
||||||
|
Preserve original bytes and record SHA256. `automation/prepare_queue.py --source <folder> --output <new-folder> --project <repository>` imports the actual mapped pack into a **new** queue, refusing to overwrite one. Add stable workstream IDs/dependencies, exact branch/checkpoint policies, source/supplement hashes, all required gates/checks/artifacts/owner decisions and finite budgets. Validate source identity and qualify tests in a disposable repository. Adding IDs needs a new installation/owner-reviewed queue migration; `configure` deliberately cannot splice new stages into a running queue. Never derive authorization from a filename such as `done`.
|
||||||
|
|
||||||
|
## Exact first owner action
|
||||||
|
|
||||||
|
**Finish pre-Phase-10 acceptance and explicitly approve Phase 10 development entry on a named, published baseline and candidate scope.** This is not yet granted. Publish/verify the pending pre-Phase-10 commits, disposition the scoped CV/PDF/human/AI/CI/security conditions according to their actual gate authority, qualify the isolated runner and test adapters, then sign the proposed enabled policy, environment/roadmap grants and `phase10-development-entry` request. Until those steps, use the disabled dry-run installation. No real backlog command was executed by this task.
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Controller trust boundaries
|
||||||
|
|
||||||
|
## Threat model
|
||||||
|
|
||||||
|
Treat the worker model, repository content, test/build scripts, uploaded inputs and tool output as untrusted execution. Protect owner approvals, controller policy/code, durable state, test receipts, source identity, development publication and all production assets. Threats include forged PASS/approval JSON, replayed decisions, modified tests/artifacts, policy replacement, overlapping workers, branch/push manipulation, secret exposure and orphan processes.
|
||||||
|
|
||||||
|
The approval signature is implemented with .NET RSA PKCS#1/SHA256, using a pinned public certificate and an owner identity separate from the runner. The owner private key must never be exported or accessible from the worker. Signatures bind exact request bytes, manifest/controller/config hashes, source hashes via the manifest, baseline/candidate commit and evidence digests, expiry and decision. Revising/selecting a design is an explicit signed disposition. A JSON file saying approved, a model summary or a state checksum is not authority.
|
||||||
|
|
||||||
|
## Required deployment boundary
|
||||||
|
|
||||||
|
1. Use a dedicated non-administrator development account/VM without production credentials, `.env`, secret mounts, SSH agent, Docker socket, host management APIs or routes to production/LAN services. The signing owner uses another account/machine. `runnerSid` must match the live account and differ from `ownerSid`. Public-key trust is provisioned by the owner, not discovered from model output.
|
||||||
|
2. Install the trusted controller, immutable prompt/manifest snapshot, certificate/config and state **outside** the worker checkout. Native Codex workspace-write shell commands must not modify or read private control-plane records or owner material. Auth uses a dedicated minimal CODEX_HOME. Do not run the repository's mutable controller copy as the unattended trust authority.
|
||||||
|
3. Qualify real filesystem/read/network/process restrictions and resource limits in the actual runner. The built-in negative write probe is one preflight check, not a sandbox certification. Windows native workspace-write denies external writes; it does not by itself prove all external reads or privileged tool calls are safe. Record negative access attempts to owner key/state/config/Git metadata/production paths and denied egress, plus permitted synthetic build/test needs.
|
||||||
|
4. External plugin/MCP tools require their own qualified host boundary and least permissions. Protected `trustedCodexOverrides` allows only explicit MCP/plugin namespaces, passed as separate supported `-c` arguments; it cannot select unrestricted sandbox/approval behavior. The defaults load no user config or plugins. A configured tool still cannot be assumed safe merely because its namespace is allowed. Hash/config changes invalidate policy-bound approvals.
|
||||||
|
5. Tests execute through `codex sandbox` too. Never execute a repository-controlled build/test command directly as the trusted host publisher. If sandbox execution cannot access a required safe development fixture, stop and qualify an appropriate constrained setup; do not disable sandbox/network controls to get green output.
|
||||||
|
6. The trusted publisher controls Git serially, disables hooks, uses explicit non-production refs, never force-pushes and verifies remote HEAD. Provision server-side protected main/production refs and branch-limited development credentials. Use a separate clone so a worker does not share the human workspace's Git directory or hooks. All controllers for one Git common directory use the same OS-held lock.
|
||||||
|
|
||||||
|
The current task runs in an unrestricted assistant environment, but **does not use that permission level for unattended workers**. Real execution is initially disabled and additionally rejects the current unprovisioned identity/certificate/sandbox conditions. No approval key or unattended system task was installed. These operational boundaries require owner qualification and remain CONDITIONAL until then.
|
||||||
|
|
||||||
|
## Durable state, tests and process safety
|
||||||
|
|
||||||
|
The store uses same-directory atomic replace, fsync, checksum and a last-verified backup. It preserves RUNNING before launch. A launch-intent marker precedes process creation; after spawn, cleanup protection covers PID-marker failures. Input is a local file, so a child refusing stdin cannot block timeout/pause supervision. stdout/stderr are drained concurrently in bounded chunks; byte and line budgets prevent unbounded logging allocations. Each session has a wall-clock budget; the host VM/job must enforce hard CPU/RAM/process constraints and parent-death behavior.
|
||||||
|
|
||||||
|
Windows workers launch suspended, are attached to a kill-on-close Job Object, then resume. The Job imposes a configurable process-count and total-memory cap and kills contained descendants when the controller closes/dies. The delayed-assignment regression proves no worker code executes before attachment; the abrupt-parent-death test checks both child and grandchild termination. A crash before attachment can leave a suspended, non-executing process, so persistent RUNNING/launch markers still require signed owner reconciliation before relaunch. An old PID may have been reused. Do not blindly kill it. The operator must inspect the recorded tree/session and confirm it is gone. A lock prevents controller overlap; it cannot independently prove an old process stopped. No automatic deletion of a recovery marker occurs merely because time elapsed. CPU/whole-host limits and native sandbox enforcement still require operator qualification.
|
||||||
|
|
||||||
|
Test process exits, fresh structured results, expected identities, required artifacts and hashes are checked independently. A model's "complete" or "conditional" output is only a proposal. Missing adapters/scans/approvals block. Artifact tampering between verification/publication and approval blocks. A commit written just before a crash can be reconciled only when its exact controller transaction trailer, parent and durably prepared verification/content hashes match. Otherwise stop for operator recovery. A publication failure retries the same local commit without model rerun.
|
||||||
|
|
||||||
|
Final qualitative acceptance uses independent signed review of all source requirements. All seven stage completions remain development decisions. There is no code path for production deployment, main merge, destructive migration, paid service signup, risk suppression or production credential retrieval. This does not claim a sandbox can prevent every malicious Git/API command by itself; server and OS policy remain essential.
|
||||||
|
|
||||||
|
## Secrets and evidence
|
||||||
|
|
||||||
|
Child environments inherit a small OS/path allowlist, CODEX_HOME and an orchestration marker. They do not inherit arbitrary API-key/production variables or sockets. Authentication material and protected logs must have owner-reviewed ACLs. Source snapshots are prompts and technical instructions, not private CVs or production documents.
|
||||||
|
|
||||||
|
Logs redact common password/token/API-key/Bearer/basic-URL patterns. Generic free text, encoded secrets and personal documents cannot be reliably scrubbed by regular expressions. Full stdin, result, structured test reports and session files are **private local evidence**, not public logs. Do not commit or send them externally. Notifications contain only stage IDs and bounded redacted status, never raw documents. Error output remains a reason to inspect private logs, not permission to paste them into a provider. The checked-in reports contain only synthetic test results and safe technical identities.
|
||||||
|
|
||||||
|
Back up control state, manifest, config, prompt versions, approvals, reports and Git checkpoint identities using the operator's approved encrypted backup policy. Do not put the signing private key into runner backups. Corruption recovery is an availability feature, not proof of tamper resistance; ACL/VM isolation protects against malicious writes.
|
||||||
|
|
||||||
|
## Current limitations
|
||||||
|
|
||||||
|
- Native sandbox failed with `CryptUnprotectData failed: 2148073483`; real shell isolation, plugin isolation and model execution were not qualified here.
|
||||||
|
- Automated tests cover state transitions and .NET signature enforcement with disposable inputs. They do not qualify real hosted CI, provider licences, live models, physical devices, language accuracy, production infrastructure or hard host resource quotas.
|
||||||
|
- Runtime services, mandatory scan capabilities and stage-specific membership/qualification adapters must be installed and independently approved before their checkpoint can execute.
|
||||||
|
- Accepted stage policy cannot be rewritten in place. New scope/new stages require owner-reviewed migration or a separate queue. No automatic production stage exists.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Autonomous roadmap follow-up
|
||||||
|
|
||||||
|
Implementation checkpoints AUTO-00–08 are complete in the qualified scope described in ACCEPTANCE.md. The following are deliberately **not authorized/completed** by installing the controller:
|
||||||
|
|
||||||
|
- [ ] Finish current Pre-Phase-10 acceptance; owner records final accepted baseline and candidate-specific cleanup entry.
|
||||||
|
- [ ] Publish and independently verify the final pre-Phase-10 development ref (last verified remote still `994bf8f`, local reported `5032ccb`).
|
||||||
|
- [ ] Provision dedicated non-production runner/signing identities and protected control-plane installation; resolve/qualify native sandbox failure and actual read/egress/tool/resource boundaries.
|
||||||
|
- [ ] Qualify checkpoint-specific provider/browser/Python/PDF/security/preservation adapters, exact suite membership and synthetic runtime fixtures. Do not flip configured merely to bypass this gate.
|
||||||
|
- [ ] Review the packs' substage/finding branch preferences and resolve the exact authorized branch policy explicitly.
|
||||||
|
- [ ] Sign the proposed enabled policy, environment and roadmap grants plus `phase10-development-entry` only after explicit owner authorization. No real stage has run.
|
||||||
|
- [ ] Later, provide mandatory Product Review, provider rights, locale/native, four-concept homepage, security findings and release-evidence decisions at their actual gates.
|
||||||
|
- [ ] Optionally provision Task Scheduler under the qualified runner; no schedule is installed now.
|
||||||
|
|
||||||
|
Production/main merge remains outside this queue, requiring a separate explicit owner process.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"ambiguous_scale": "", "epoch": "v0.9.96-s27-g7154a062ba57", "excludes": [], "follow_symlinks": false, "full_clean": true, "gitignore": true, "graph_mtime_ns": 1793280464911549300, "graph_size": 26584787, "html_present": true, "no_cluster": false, "rcfile": "", "scan_digest": "90bc56c0ff378578f13b3800314eb853", "scan_root": "C:\\Users\\Connor\\.codex\\worktrees\\autonomous-roadmap-01\\Job tracker", "viz_node_limit": ""}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
|||||||
|
.
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,84 @@
|
|||||||
|
#Requires -Version 7.2
|
||||||
|
<# The owner runs NewKey/Sign on a separate Windows identity or signing machine.
|
||||||
|
Only the public certificate reaches the runner. Never export the private key. #>
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)][ValidateSet('NewKey','ExportPublicKey','Sign','Verify')][string]$Action,
|
||||||
|
[string]$RequestPath,
|
||||||
|
[string]$ApprovalPath,
|
||||||
|
[string]$CertificatePath,
|
||||||
|
[string]$Thumbprint,
|
||||||
|
[string]$ExpectedOwnerSid,
|
||||||
|
[string]$Decision = 'Approved as requested',
|
||||||
|
[ValidateSet('Approve','Revise','Reject')][string]$Disposition = 'Approve',
|
||||||
|
[ValidateRange(1,720)][int]$ValidHours = 72
|
||||||
|
)
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
try {
|
||||||
|
if ($Action -eq 'NewKey') {
|
||||||
|
$certificate = New-SelfSignedCertificate -Subject 'CN=Jobjakt Roadmap Owner' -CertStoreLocation Cert:\CurrentUser\My `
|
||||||
|
-KeyAlgorithm RSA -KeyLength 3072 -HashAlgorithm SHA256 -KeyExportPolicy NonExportable `
|
||||||
|
-KeyUsage DigitalSignature -NotAfter (Get-Date).AddYears(2)
|
||||||
|
[pscustomobject]@{ Thumbprint=$certificate.Thumbprint; OwnerSid=[Security.Principal.WindowsIdentity]::GetCurrent().User.Value } | ConvertTo-Json
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
if ($Action -eq 'ExportPublicKey') {
|
||||||
|
if (-not $Thumbprint -or -not $CertificatePath) { throw 'Thumbprint and CertificatePath required' }
|
||||||
|
Export-Certificate -Cert (Get-Item -LiteralPath "Cert:\CurrentUser\My\$Thumbprint") -FilePath $CertificatePath | Out-Null
|
||||||
|
Write-Output 'Public certificate exported. Keep its pinned hash in the protected runner configuration.'
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
if (-not $RequestPath -or -not $ApprovalPath) { throw 'RequestPath and ApprovalPath required' }
|
||||||
|
$requestBytes = [IO.File]::ReadAllBytes([IO.Path]::GetFullPath($RequestPath))
|
||||||
|
$requestHash = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($requestBytes)).ToLowerInvariant()
|
||||||
|
$request = [Text.Encoding]::UTF8.GetString($requestBytes) | ConvertFrom-Json
|
||||||
|
if ($request.schemaVersion -ne 1 -or -not $request.kind -or -not $request.manifestSha256 -or -not $request.policySha256) {
|
||||||
|
throw 'Invalid controller approval request'
|
||||||
|
}
|
||||||
|
if ($Action -eq 'Sign') {
|
||||||
|
if (-not $Thumbprint) { throw 'Owner certificate Thumbprint required' }
|
||||||
|
$owner = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value
|
||||||
|
if ($ExpectedOwnerSid -and $ExpectedOwnerSid -ne $owner) { throw 'Wrong signing identity' }
|
||||||
|
# Display concrete scope and evidence before owner deliberately invokes Sign.
|
||||||
|
Write-Host ([Text.Encoding]::UTF8.GetString($requestBytes))
|
||||||
|
$payload = [ordered]@{ requestSha256=$requestHash; approved=$true; ownerSid=$owner; decision=$Decision; disposition=$Disposition;
|
||||||
|
issuedUtc=[DateTimeOffset]::UtcNow.ToString('o'); expiresUtc=[DateTimeOffset]::UtcNow.AddHours($ValidHours).ToString('o') }
|
||||||
|
$bytes = [Text.Encoding]::UTF8.GetBytes(($payload | ConvertTo-Json -Compress))
|
||||||
|
$certificate = Get-Item -LiteralPath "Cert:\CurrentUser\My\$Thumbprint"
|
||||||
|
$rsa = [Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($certificate)
|
||||||
|
if (-not $rsa) { throw 'Owner private signing key unavailable' }
|
||||||
|
try { $signature = $rsa.SignData($bytes, [Security.Cryptography.HashAlgorithmName]::SHA256, [Security.Cryptography.RSASignaturePadding]::Pkcs1) }
|
||||||
|
finally { $rsa.Dispose() }
|
||||||
|
$envelope = @{ payloadBase64=[Convert]::ToBase64String($bytes); signatureBase64=[Convert]::ToBase64String($signature) }
|
||||||
|
$destination = [IO.Path]::GetFullPath($ApprovalPath)
|
||||||
|
[IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($destination)) | Out-Null
|
||||||
|
$temporary = "$destination.$([Guid]::NewGuid().ToString('N')).tmp"
|
||||||
|
[IO.File]::WriteAllText($temporary, ($envelope | ConvertTo-Json -Compress), [Text.UTF8Encoding]::new($false))
|
||||||
|
[IO.File]::Move($temporary, $destination, $true)
|
||||||
|
Write-Output "Signed request $requestHash. Transfer this envelope into the runner approvals folder, then resume."
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
if (-not $CertificatePath -or -not $ExpectedOwnerSid) { throw 'Public CertificatePath and ExpectedOwnerSid required' }
|
||||||
|
$envelope = Get-Content -LiteralPath $ApprovalPath -Raw | ConvertFrom-Json
|
||||||
|
$bytes = [Convert]::FromBase64String($envelope.payloadBase64)
|
||||||
|
$signature = [Convert]::FromBase64String($envelope.signatureBase64)
|
||||||
|
$certificate = [Security.Cryptography.X509Certificates.X509Certificate2]::new([IO.File]::ReadAllBytes($CertificatePath))
|
||||||
|
if ($certificate.NotAfter.ToUniversalTime() -le [DateTime]::UtcNow -or $certificate.NotBefore.ToUniversalTime() -gt [DateTime]::UtcNow) {
|
||||||
|
throw 'Owner certificate is outside its validity period'
|
||||||
|
}
|
||||||
|
$rsa = [Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPublicKey($certificate)
|
||||||
|
try { $valid = $rsa.VerifyData($bytes, $signature, [Security.Cryptography.HashAlgorithmName]::SHA256, [Security.Cryptography.RSASignaturePadding]::Pkcs1) }
|
||||||
|
finally { $rsa.Dispose() }
|
||||||
|
if (-not $valid) { throw 'Invalid approval signature' }
|
||||||
|
$payload = [Text.Encoding]::UTF8.GetString($bytes) | ConvertFrom-Json
|
||||||
|
$now = [DateTimeOffset]::UtcNow
|
||||||
|
if ($payload.approved -ne $true -or $payload.requestSha256 -cne $requestHash -or $payload.ownerSid -cne $ExpectedOwnerSid `
|
||||||
|
-or [DateTimeOffset]$payload.expiresUtc -le $now -or [DateTimeOffset]$payload.issuedUtc -gt $now.AddMinutes(5)) {
|
||||||
|
throw 'Approval is expired, replayed, unapproved, future-dated or from another owner'
|
||||||
|
}
|
||||||
|
Write-Output 'Verified'
|
||||||
|
exit 0
|
||||||
|
} catch {
|
||||||
|
Write-Error $_ -ErrorAction Continue
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
#Requires -Version 7.2
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)][ValidateSet('init','validate','dry-run','start','resume','status','pause','retry','current','logs','recover','configure')][string]$Command,
|
||||||
|
[Parameter(Mandatory)][string]$ControlRoot,
|
||||||
|
[string]$Repository,
|
||||||
|
[string]$Stage,
|
||||||
|
[string]$Manifest,
|
||||||
|
[string]$Python = 'python'
|
||||||
|
)
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$root = [IO.Path]::GetFullPath($ControlRoot)
|
||||||
|
if ($Command -eq 'init') {
|
||||||
|
$engine = Join-Path $PSScriptRoot '../automation/controller.py'
|
||||||
|
} else {
|
||||||
|
$engine = Join-Path $root 'controller.py'
|
||||||
|
}
|
||||||
|
if (-not (Test-Path -LiteralPath $engine -PathType Leaf)) { throw "Controller missing: $engine" }
|
||||||
|
$arguments = @($engine, $Command, '--control-root', $root)
|
||||||
|
if ($Repository) { $arguments += @('--repository', $Repository) }
|
||||||
|
if ($Stage) { $arguments += @('--stage', $Stage) }
|
||||||
|
if ($Manifest) { $arguments += @('--manifest', $Manifest) }
|
||||||
|
& $Python @arguments
|
||||||
|
exit $LASTEXITCODE
|
||||||
Reference in New Issue
Block a user