feat(automation): add durable runner and signed approval recovery
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user