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
5 changes: 3 additions & 2 deletions docs/release-qualification.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,10 +117,10 @@ Local CLI providers (`local_cli` driver) only run automatically if a `campaign_a
|---|---|---|
| `codex` | `codex` | `codex exec` with stdin (`-`), `--ephemeral`, `--approve-for-me`, a Code Mower-owned root-deny/workspace-write profile, keyring-only auth, `--skip-git-repo-check`, `--json`, `--output-schema`, `--output-last-message`, `-C` (codex-cli 0.147.0) |
| `claude_audit` | `claude` | `claude --print` with stdin, `--output-format json`, strict OS sandbox and PyPI allowlist, `--json-schema` (Claude Code 2.1.258) |
| `antigravity_cli` | `antigravity` | `agy --print` with a prompt file, `--sandbox`, noninteractive permission approval, `--add-dir`, `--print-timeout` (agy 1.1.26) |
| `antigravity_cli` | `antigravity` | `agy --print` with a prompt file, `--sandbox`, noninteractive permission approval, `--new-project`, `--add-dir`, `--print-timeout` |
| `muse_cli` | `muse` | `muse exec` with `--json`, `--prompt-file`, `--workspace` (Muse Code 1.0.3) |

Newer CLIs keep working while the flags exist; a removed flag fails closed. Prompts travel on stdin or a prompt file, never through a shell. Provider children receive an allowlisted environment with home/config locations for their stored login, but no ambient GitHub, Code Mower cloud, or provider API keys; Muse's explicit API key travels on stdin only. Provider stdout/stderr are parsed transiently and never persisted: only a closed, validated `code_mower.adoptionResult.v1` document is written to `{output}`. Codex gets outbound network access inside its ephemeral workspace-write sandbox for package downloads; its CLI does not provide a domain allowlist here. Claude requires its OS sandbox, disables the unsandboxed escape hatch, denies Bash access to the operator's home directory, and allows package-download network access only to PyPI. Antigravity runs with its CLI sandbox retained and noninteractive permission prompts auto-approved inside that sandbox; this is required because headless `agy --print` cannot answer command prompts. Antigravity/Muse refuse without their ambient-home opt-in (`ANTIGRAVITY_CLI_USE_AMBIENT_HOME` / `MUSE_CLI_USE_AMBIENT_HOME`) or a provider key, mirroring the audit wrappers.
Newer CLIs keep working while the flags exist; a removed flag fails closed. Prompts travel on stdin or a prompt file, never through a shell. Provider children receive an allowlisted environment with home/config locations for their stored login, but no ambient GitHub, Code Mower cloud, or provider API keys; Muse's explicit API key travels on stdin only. Provider stdout/stderr are parsed transiently and never persisted: only a closed, validated `code_mower.adoptionResult.v1` document is written to `{output}`. Codex gets outbound network access inside its ephemeral workspace-write sandbox for package downloads; its CLI does not provide a domain allowlist here. Claude requires its OS sandbox, disables the unsandboxed escape hatch, denies Bash access to the operator's home directory, and allows package-download network access only to PyPI. Antigravity runs with its CLI sandbox retained, `--new-project` passed on every campaign run for project boundary isolation (strictly excluding continue or resume semantics: `--continue`, `-c`, `--conversation`, `--resume`, `-i`, `--prompt-interactive`), and noninteractive permission prompts auto-approved inside that sandbox; this is required because headless `agy --print` cannot answer command prompts. Before writing prompt input or invoking provider work, the adapter checks installed `agy` CLI capability via `--help` for `--new-project` support and fails closed if unsupported. Antigravity/Muse refuse without their ambient-home opt-in (`ANTIGRAVITY_CLI_USE_AMBIENT_HOME` / `MUSE_CLI_USE_AMBIENT_HOME`) or a provider key, mirroring the audit wrappers.

Timeout model: each maintained lane sets `campaign_adapter_timeout_seconds: 900`. The campaign passes the outer timeout minus `ADAPTER_INNER_TIMEOUT_MARGIN_SECONDS` (30s) as the adapter's `{adapter_timeout}`, so the adapter's own provider budget always fires first. `{python}` resolves to the running interpreter (launching the adapter); `{target_python}` to the deterministically resolved Python 3.12+ runtime binary passed to `--python-bin`; `{command}` to the installed provider CLI (the campaign refuses to run when it is missing).

Expand Down Expand Up @@ -158,6 +158,7 @@ An installed CLI and a valid argv contract do not prove the isolated home the ad

- **Codex isolated auth probe**: Where Codex exposes `codex login status` (20s budget), doctor probes the isolated home. Authenticated passes; confirmed logged-out produces an actionable warning and removes Codex from `ready_providers`. Timeouts or probe errors degrade safely to a skip.
- **Antigravity & Muse ambient-home opt-ins**: Antigravity and Muse require trusted ambient-home opt-ins (`ANTIGRAVITY_CLI_USE_AMBIENT_HOME=1`, `MUSE_CLI_USE_AMBIENT_HOME=1` or `META_API_KEY`/`META_API_KEY_FILE`). Doctor models these requirements directly: missing opt-ins produce an actionable warning and prevent the provider from being reported `campaign-ready`.
- **Antigravity campaign isolation & --new-project capability**: Every maintained Antigravity campaign invocation includes `--new-project` and excludes continue/resume flags (`--continue`, `-c`, `--conversation`, `--resume`, `-i`, `--prompt-interactive`) to guarantee session isolation inside a fresh project boundary. Both the adapter and doctor campaign readiness call the same bounded capability check (`agy --help`) before writing prompts, invoking provider work, or declaring Antigravity ready. When `--new-project` is unsupported, doctor excludes Antigravity from `ready_providers` with bounded actionable metadata and remediation instructing to upgrade to a version whose `--help` exposes `--new-project` (without claiming an unverified minimum version). Probe output, prompts, paths, and secrets are strictly excluded from evidence.
- **Structured-result capability**: Doctor distinguishes executable/auth readiness from structured-result capability using a bounded offline fixture (zero token spend, zero network). `doctor.campaign.readiness` detail breaks down `command`, `auth`, and `structured_result` per provider without leaking paths or command output.
- **Deterministic Python 3.12+ runtime resolution**: Before invoking local provider adapters, the campaign runner resolves a supported Python 3.12+ executable (probing `CODE_MOWER_PYTHON`, running interpreter, and versioned `python3.12+` binaries on PATH) and passes exact `--python-bin` and `--target-runtime` arguments. Providers must not pick ambient `python3`. If no supported runtime exists, the dispatch fails closed with `python_runtime_unavailable` and actionable remediation. Result validators enforce `runtime_class >= python_3.12`.

Expand Down
140 changes: 134 additions & 6 deletions src/code_mower/campaign_adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,9 @@
``--json``, schema/last-message output, ``-C``
``claude`` Claude Code 2.1.258 ``--print`` with stdin, ``--output-format json``,
explicit tool/permission controls, ``--json-schema``
``antigravity`` agy 1.1.26 ``--print`` with a prompt file, ``--sandbox``,
noninteractive approval, ``--add-dir``,
``--print-timeout``
``antigravity`` runtime-probed ``--print`` with a prompt file, ``--sandbox``,
noninteractive approval, ``--new-project``,
``--add-dir``, ``--print-timeout``
``muse`` Muse Code 1.0.3 ``exec`` with ``--json``, ``--prompt-file``,
``--workspace``
================= ==================== ====================================================
Expand Down Expand Up @@ -96,7 +96,6 @@
VERIFIED_CLI_VERSIONS = {
"codex": "codex-cli 0.147.0",
"claude": "Claude Code 2.1.258",
"antigravity": "agy 1.1.26",
"muse": "Muse Code 1.0.3",
}

Expand Down Expand Up @@ -478,18 +477,21 @@ def build_antigravity_argv(
timeout_seconds: int,
model: str = "",
) -> list[str]:
"""Argv for ``agy --print`` with prompt-file transport.
"""Argv for ``agy --print`` with prompt-file transport and project isolation.

Mirrors the Antigravity audit wrapper (via the Gemini CLI wrapper): the
prompt lives in a file inside the workspace and the agent is pointed at it
with a short instruction, sandboxed to that workspace. Headless agy cannot
prompt for command permission, so permission checks are auto-approved only
inside that retained sandbox.
inside that retained sandbox. Every qualification run passes
``--new-project`` so the session executes inside a fresh project boundary
and never inherits active conversations or resume semantics.
"""
argv = [
agy_bin,
"--sandbox",
"--dangerously-skip-permissions",
"--new-project",
"--add-dir",
workspace_dir,
"--print-timeout",
Expand Down Expand Up @@ -668,6 +670,124 @@ def check_structured_result_capability(provider: str) -> bool:
return False


def check_antigravity_readiness(
agy_bin: str,
*,
runner: Callable[..., subprocess.CompletedProcess[str]] | None = None,
timeout_seconds: int = 10,
child_env: Mapping[str, str] | None = None,
) -> dict[str, Any]:
"""Detect whether installed agy CLI supports --new-project for campaign isolation.

Fails closed with bounded actionable metadata (never leaking stdout/stderr,
prompts, paths, auth details, or secrets) when the installed agy lacks the flag.
"""
if not agy_bin:
return {
"ready": False,
"provider": "antigravity",
"capability": "new_project",
"required_flag": "--new-project",
"error": "command_not_found",
"actionable": True,
"message": "antigravity CLI is not installed",
"remediation": "Install agy CLI on PATH or specify the executable in code-mower.yml.",
}

probe_env = dict(child_env) if child_env is not None else build_adapter_child_env("antigravity")
probe_error = ""
try:
if runner is not None:
try:
completed = runner(
[agy_bin, "--help"],
timeout=timeout_seconds,
env=probe_env,
)
except TypeError:
completed = runner([agy_bin, "--help"])
else:
completed = subprocess.run(
[agy_bin, "--help"],
capture_output=True,
text=True,
check=False,
timeout=timeout_seconds,
env=probe_env,
)
output = (completed.stdout or "") + (completed.stderr or "")
if completed.returncode != 0:
probe_error = "capability_probe_failed"
has_new_project = not probe_error and ("--new-project" in output.split())
except subprocess.TimeoutExpired:
probe_error = "capability_probe_timeout"
has_new_project = False
except OSError:
probe_error = "capability_probe_failed"
has_new_project = False
Comment on lines +718 to +724

@gitar-bot gitar-bot Bot Sep 5, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Edge Case: Readiness check fails closed if agy --help exits nonzero

check_antigravity_readiness requires completed.returncode == 0 before scanning for --new-project, so any agy build whose --help exits with a nonzero status (some CLIs print help then exit nonzero) is reported as missing_new_project_capability even when the flag is present. This would falsely exclude a capable Antigravity CLI from campaigns and doctor readiness. If the flag's presence in output is the real signal, consider not gating on returncode (or accepting a broader set of return codes) for the help probe.

Detect the flag from help output regardless of --help exit status.:

output = (completed.stdout or "") + (completed.stderr or "")
has_new_project = "--new-project" in output

Was this helpful? React with 👍 / 👎


if probe_error:
timed_out = probe_error == "capability_probe_timeout"
return {
"ready": False,
"provider": "antigravity",
"capability": "new_project",
"required_flag": "--new-project",
"error": probe_error,
"actionable": True,
"message": (
"antigravity CLI capability probe timed out"
if timed_out
else "antigravity CLI capability probe failed"
),
"remediation": (
"Retry the bounded agy --help probe; if it continues to time out, "
"repair the CLI installation before running a campaign."
if timed_out
else "Verify that agy --help runs successfully, then retry campaign readiness."
),
}

if not has_new_project:
return {
"ready": False,
"provider": "antigravity",
"capability": "new_project",
"required_flag": "--new-project",
"error": "missing_new_project_capability",
"actionable": True,
"message": "installed agy CLI lacks required --new-project flag for campaign isolation",
"remediation": "Upgrade agy CLI to a version whose --help exposes --new-project.",
}

return {
"ready": True,
"provider": "antigravity",
"capability": "new_project",
"required_flag": "--new-project",
"error": "",
"actionable": False,
"message": "agy CLI supports --new-project campaign isolation",
"remediation": "",
}


def check_antigravity_new_project_capability(
agy_bin: str,
*,
runner: Callable[..., subprocess.CompletedProcess[str]] | None = None,
timeout_seconds: int = 10,
) -> bool:
"""Return True if installed agy supports --new-project, False otherwise."""
return bool(
check_antigravity_readiness(
agy_bin,
runner=runner,
timeout_seconds=timeout_seconds,
).get("ready")
)


def validate_bound_result(
candidate: Any,
*,
Expand Down Expand Up @@ -865,6 +985,7 @@ def run_campaign_adapter(
python_bin: str = "",
target_runtime: str = "",
provider_runner: ProviderRunner = run_provider_command,
capability_runner: Callable[..., subprocess.CompletedProcess[str]] | None = None,
) -> int:
"""Run one maintained provider adapter. Returns a process exit code.

Expand Down Expand Up @@ -986,6 +1107,13 @@ def run_campaign_adapter(
)
candidate = _extract_claude_result(completed.stdout)
elif provider == "antigravity":
readiness = check_antigravity_readiness(
resolved_bin,
runner=capability_runner,
child_env=child_env,
)
if not readiness["ready"]:
return _fail(provider, readiness["message"])
prompt_path = workspace_dir / "campaign.prompt-input.txt"
prompt_path.write_text(prompt, encoding="utf-8")
agy_model = model or _first_env_value(ANTIGRAVITY_MODEL_ENV_NAMES)
Expand Down
65 changes: 64 additions & 1 deletion src/code_mower/doctor_checks/adoption.py
Original file line number Diff line number Diff line change
Expand Up @@ -577,6 +577,18 @@ def _resolve_adapter_config_for_lane(
return _resolve_campaign_adapter_config(lane, repo_root)


def _is_maintained_antigravity_adapter(
lane: Any,
argv_template: Sequence[str] | None,
) -> bool:
if not argv_template:
return False
maintained = lane.provider_config.get("campaign_adapter_argv")
if maintained and tuple(argv_template) == tuple(maintained):
return True
return any("code_mower.campaign_adapters" in str(token) for token in argv_template)


def check_adoption_campaign_readiness(
*,
config: Mapping[str, Any] | None,
Expand All @@ -587,12 +599,16 @@ def check_adoption_campaign_readiness(
which_fn: Callable[[str], str | None] = shutil.which,
command_runner: Any = None,
auth_probe_runner: Any = None,
capability_runner: Any = None,
token_dir: Path | None = None,
providers: Sequence[str] = DEFAULT_CAMPAIGN_PROVIDERS,
) -> tuple[DoctorCheck, ...]:
"""Validate release campaign readiness across configured providers and storage."""
from code_mower import lane_status
from code_mower.campaign_adapters import check_structured_result_capability
from code_mower.campaign_adapters import (
check_antigravity_readiness,
check_structured_result_capability,
)
from code_mower.cloud import resolve_cloud_token
from code_mower.release_campaigns import (
_check_credentials,
Expand Down Expand Up @@ -804,6 +820,53 @@ def check_adoption_campaign_readiness(
)
)
else:
capability_check = None
if canonical == "antigravity" and (
capability_runner is not None
or _is_maintained_antigravity_adapter(lane, argv_template)
):
cap_runner = capability_runner if capability_runner is not None else command_runner
cap_result = check_antigravity_readiness(
which_fn(cmd) or cmd,
runner=cap_runner,
)
if not cap_result.get("ready"):
capability_check = cap_result

if capability_check is not None:
detail = {
"provider": canonical,
"lane": lane.lane_id,
"driver": lane.driver,
"command": command_name,
"command_found": True,
"adapter_configured": True,
"capability": capability_check.get("capability", "new_project"),
"required_flag": capability_check.get("required_flag", "--new-project"),
"error": capability_check.get("error", "missing_new_project_capability"),
"enabled": is_enabled,
"actionable": is_enabled,
"optional": not is_enabled,
}
if is_enabled:
detail["owner_action"] = True
provider_readiness[canonical] = {
"command": True,
"auth": "unknown",
"structured_result": structured_capability,
}
checks.append(
DoctorCheck(
name="doctor.campaign.adapter",
status=STATUS_WARN,
lane=canonical,
message=str(capability_check.get("message")),
detail=detail,
remediation=str(capability_check.get("remediation")),
)
)
continue

checks.append(
DoctorCheck(
name="doctor.campaign.adapter",
Expand Down
Loading
Loading