blob: 750033fca0e1a6e481c3071e0ed332e48e40a38f [file] [edit]
#!/usr/bin/env python3
"""Run one read-only TinyUSB agent role on Codex and print its JSON result.
The role allowlist here is the enforcement boundary: the Markdown bridge that
invokes this script cannot be trusted to honour a rule stated only in prose.
"""
import argparse
import json
import subprocess
import sys
import tempfile
import tomllib
from pathlib import Path
# A role belongs here only if it can do its whole job from the local checkout.
# `codex exec --sandbox read-only` has no network, so a role built on `gh` does
# not fail there - it answers from an empty view, which is worse.
READ_ONLY_ROLES = frozenset({'code-verifier'})
TIMEOUT = '1800s' # a full-diff review at xhigh effort routinely passes 10 min
def failure_detail(returncode, stderr):
if returncode == 124: # `timeout` killed the child
return (f'codex exec timed out after {TIMEOUT}; its partial output is not a '
'result. Narrow the prompt or raise TIMEOUT.')
return stderr
def repo_root():
"""From this file's location, not the caller's cwd: the bridge agent may
invoke us from anywhere."""
return Path(__file__).resolve().parents[1]
def resolve_adapter(root, role):
if role not in READ_ONLY_ROLES:
raise ValueError(
f'role {role!r} is not in the read-only set '
f'({", ".join(sorted(READ_ONLY_ROLES))}); '
'write-capable roles are not routable to Codex')
with open(Path(root) / '.codex' / 'agents' / f'{role}.toml', 'rb') as f:
return tomllib.load(f)
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--role', required=True)
parser.add_argument('--prompt-file', required=True)
parser.add_argument('--schema-file', required=True)
args = parser.parse_args()
root = repo_root()
try:
adapter = resolve_adapter(root, args.role)
except ValueError as e:
print(e, file=sys.stderr)
return 2
prompt = Path(args.prompt_file).read_text()
json.loads(Path(args.schema_file).read_text()) # reject a bad schema before spending a run
with tempfile.TemporaryDirectory() as tmp:
tmp = Path(tmp)
composed = tmp / 'prompt.txt'
composed.write_text(
adapter['developer_instructions'].rstrip() + '\n\n' + prompt)
result_path = tmp / 'result.json'
with open(composed, 'rb') as stdin:
run = subprocess.run([
'timeout', TIMEOUT, 'codex', 'exec',
'-C', str(root),
'-m', adapter['model'],
'-c', f'model_reasoning_effort={adapter["model_reasoning_effort"]}',
'--sandbox', 'read-only',
'--output-schema', args.schema_file,
'--output-last-message', str(result_path),
'-',
], stdin=stdin, capture_output=True, text=True)
if run.returncode != 0:
print(failure_detail(run.returncode, run.stderr), file=sys.stderr)
return 1
if not result_path.is_file():
print('codex produced no result file', file=sys.stderr)
return 1
body = result_path.read_text()
json.loads(body) # never hand the caller something that is not JSON
sys.stdout.write(body)
return 0
if __name__ == '__main__':
try:
sys.exit(main())
except Exception as e: # any failure prints nothing to stdout
print(f'{type(e).__name__}: {e}', file=sys.stderr)
sys.exit(1)