Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 9 additions & 20 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,42 +1,31 @@
name: ci
name: CI

on:
pull_request:
push:
branches: [main]

permissions:
contents: read

jobs:
tests:
name: Linux Python ${{ matrix.python-version }}
unit-tests:
name: Unit tests
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
python-version: "3.12"
- run: python -m pip install ".[dev]"
- run: python scripts/green_gate.py
- run: python -m pytest -q

packaging:
name: Credential-free packaging and installer
installer:
name: Installer
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: python -m pip install ".[dev]"
- run: python scripts/prepare_release.py --output-dir "$RUNNER_TEMP/release"
- run: >-
python -m pytest -q
-m "integration or not integration"
tests/test_package.py
tests/test_installer.py
tests/test_platform.py
tests/test_workflow.py
- run: python -m pytest -q -m integration tests/test_installer.py
49 changes: 44 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ An unofficial external role runner for the OpenAI Codex CLI.
> OpenAI. Codex is a trademark of OpenAI. The project does not imitate OpenAI
> branding.

Some current Codex runtimes can make configured custom-agent profiles
unreachable through the native spawn surface, leaving the requested role's
model, reasoning effort, sandbox, and instructions unapplied
([upstream issue #31097](https://github.com/openai/codex/issues/31097)). This
package is a pragmatic bridge while that behavior is repaired, not a
replacement for native subagents.

`codex-exec-subagents` launches independent `codex exec` sessions from global
role files and gives each session a durable handle, captured transport
artifacts, and exact-thread follow-up. It is useful when a developer wants
Expand Down Expand Up @@ -169,8 +176,9 @@ six nonempty string fields: `name`, `description`, `model`,
`read-only`, `workspace-write`, and `danger-full-access`.

A small inspection role is maintained as
[`examples/agents/reviewer.toml`](examples/agents/reviewer.toml). Print the
same valid TOML from an installed command without changing configuration:
[`examples/agents/reviewer.toml`](https://github.com/antoinezambelli/codex-exec-subagents/blob/main/examples/agents/reviewer.toml).
Print the same valid TOML from an installed command without changing
configuration:

```bash
codex-exec-subagents example-role reviewer
Expand Down Expand Up @@ -239,6 +247,35 @@ remains usable.
not prove that transport or verification succeeded, so inspect `status` and
the receipt as well.

The status JSON includes `receiptPath`; open that file to inspect evidence for
the exact invocation. A completed receipt includes fields like:

```json
{
"state": "completed",
"role": "reviewer",
"requested": {
"model": "gpt-5.6-terra",
"effort": "medium"
},
"declaredSandboxMode": "read-only",
"effectivePolicy": {
"executionMode": "sandboxed",
"effectiveSandbox": "read-only",
"approvalBehavior": "never"
},
"threadId": "...",
"processExitStatus": 0,
"terminalEventType": "turn.completed",
"verificationErrors": []
}
```

Together, `receipt.json`, `events.jsonl`, and `last-message.txt` record what the
runner requested, what Codex emitted, and whether transport-level verification
succeeded. They do not prove that the model semantically followed its role
instructions; inspect the result and any repository changes accordingly.

## Lifecycle commands

Tasks can come from exactly one explicit source, or from standard input:
Expand Down Expand Up @@ -352,7 +389,7 @@ Each UUID handle contains `session.json` and numbered invocation directories.
Each invocation retains `prompt.txt`, `events.jsonl`, `stderr.txt`,
`last-message.txt`, `status.json`, and `receipt.json`. Handle and invocation
directories are `0700`; owned artifact files are `0600`. See
[the artifact lifecycle and local security contract](docs/artifact-lifecycle.md)
[the artifact lifecycle and local security contract](https://github.com/antoinezambelli/codex-exec-subagents/blob/main/docs/artifact-lifecycle.md)
for the exact schema, atomic publication, interruption, and pruning behavior.

If a nonterminal worker disappears, `status` reports an inspectable
Expand Down Expand Up @@ -423,5 +460,7 @@ Git-boundary, release, installer, and other host-level tests carry the
`integration` marker. The green gate restores them and enforces one
180-second wall-clock limit across the complete suite.

See the [changelog](CHANGELOG.md). The project is available under the
[MIT License](LICENSE).
See the
[changelog](https://github.com/antoinezambelli/codex-exec-subagents/blob/main/CHANGELOG.md).
The project is available under the
[MIT License](https://github.com/antoinezambelli/codex-exec-subagents/blob/main/LICENSE).
55 changes: 48 additions & 7 deletions src/codex_exec_subagents/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,20 +220,61 @@ def build_parser() -> argparse.ArgumentParser:
)

status_parser = subparsers.add_parser(
"status", help="show the latest invocation state"
"status",
help="show the latest invocation state",
description=(
"Print JSON for the latest invocation, including its state, "
"policy, artifact paths, and verification evidence."
),
)
status_parser.add_argument(
"handle",
metavar="HANDLE",
help="UUID handle returned by run or follow-up",
)
status_parser.add_argument("handle")

wait_parser = subparsers.add_parser("wait", help="wait for the latest invocation")
wait_parser.add_argument("handle")
wait_parser.add_argument("--timeout", type=float, default=900)
wait_parser = subparsers.add_parser(
"wait",
help="wait for the latest invocation",
description=(
"Wait for the latest invocation to reach a terminal state, then "
"print its status JSON."
),
)
wait_parser.add_argument(
"handle",
metavar="HANDLE",
help="UUID handle returned by run or follow-up",
)
wait_parser.add_argument(
"--timeout",
type=float,
default=900,
help=(
"seconds to wait; 0 waits indefinitely; timeout exits 124 without "
"stopping the worker (default: 900)"
),
)

result_parser = subparsers.add_parser(
"result",
help="print a raw final-message artifact, including active or failed turns",
description=(
"Print a raw final-message artifact, including one retained from "
"an active or failed invocation."
),
)
result_parser.add_argument(
"handle",
metavar="HANDLE",
help="UUID handle returned by run or follow-up",
)
result_parser.add_argument(
"--sequence",
type=int,
metavar="NUMBER",
help="positive one-based invocation number; omit to read the latest",
)
result_parser.add_argument("handle")
result_parser.add_argument("--sequence", type=int)

prune_parser = subparsers.add_parser(
"prune", help="remove terminal role artifacts while preserving the root"
Expand Down
45 changes: 44 additions & 1 deletion tests/test_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,48 @@ def test_documented_lifecycle_syntax_is_accepted_without_dispatch(
assert not run_root.exists()


@pytest.mark.parametrize(
("command", "phrases"),
[
(
"status",
(
"UUID handle returned by run or follow-up",
"artifact paths, and verification evidence",
),
),
(
"wait",
(
"UUID handle returned by run or follow-up",
"0 waits indefinitely",
"timeout exits 124 without stopping the worker",
),
),
(
"result",
(
"UUID handle returned by run or follow-up",
"positive one-based invocation number",
"omit to read the latest",
"active or failed invocation",
),
),
],
)
def test_lifecycle_subcommand_help_describes_arguments(
command: str,
phrases: tuple[str, ...],
capsys: pytest.CaptureFixture[str],
) -> None:
with pytest.raises(SystemExit) as raised:
cli.build_parser().parse_args([command, "--help"])
assert raised.value.code == 0
output = " ".join(capsys.readouterr().out.split())
for phrase in phrases:
assert phrase in output


def test_readme_matches_public_platform_policy_and_security_contracts() -> None:
text = README.read_text(encoding="utf-8")
required = (
Expand Down Expand Up @@ -153,7 +195,8 @@ def test_reviewer_example_is_canonical_and_cli_discoverable() -> None:
assert phrase in help_text
readme = README.read_text(encoding="utf-8")
assert (
"[`examples/agents/reviewer.toml`](examples/agents/reviewer.toml)"
"[`examples/agents/reviewer.toml`](https://github.com/antoinezambelli/"
"codex-exec-subagents/blob/main/examples/agents/reviewer.toml)"
in readme
)
assert "codex-exec-subagents example-role reviewer" in readme
Expand Down
33 changes: 19 additions & 14 deletions tests/test_workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,27 +37,32 @@ def _collected_tests(*arguments: str) -> set[str]:
}


def test_ci_workflow_is_linux_model_free_and_covers_supported_matrix() -> None:
def test_ci_workflow_has_only_unit_and_installer_pr_gates() -> None:
text = WORKFLOW.read_text(encoding="utf-8")
assert text.count("runs-on: ubuntu-latest") == 2
matrix = re.search(
r'python-version:\s*\[([^\]]+)\]', text
)
assert matrix is not None
assert re.findall(r'"(3\.[0-9]+)"', matrix.group(1)) == [
"3.11",
"3.12",
"3.13",
assert re.findall(r"^ ([a-z][a-z-]+):\n name:", text, re.MULTILINE) == [
"unit-tests",
"installer",
]
assert "python scripts/green_gate.py" in text
assert "python scripts/prepare_release.py" in text
assert '-m "integration or not integration"' in text
assert text.count('python-version: "3.12"') == 2
assert "name: Unit tests" in text
assert "name: Installer" in text
assert "python -m pytest -q\n" in text
assert "python -m pytest -q -m integration tests/test_installer.py" in text
assert "tests/test_installer.py" in text
assert "tests/test_platform.py" in text
assert "pull_request:" in text
assert "push:" in text
assert "branches: [main]" in text

forbidden = (
"push:",
"strategy:",
"python scripts/green_gate.py",
"python scripts/prepare_release.py",
'python-version: "3.11"',
'python-version: "3.13"',
"tests/test_package.py",
"tests/test_platform.py",
"tests/test_workflow.py",
"confirm-live",
"confirm-yolo",
"codex exec",
Expand Down
Loading