From b3c8b91732ab8d846da4b618dce2d3c546a64909 Mon Sep 17 00:00:00 2001 From: Bo Wu Date: Sun, 16 Aug 2026 03:00:20 -0700 Subject: [PATCH 1/6] Add pluggable coding-agent backends --- README.md | 20 +- docs/architecture.md | 4 + docs/coding-agent-backends-design.md | 289 +++++ docs/coding-agent-backends-prd.md | 135 +++ docs/control-plane-boundary.md | 15 +- docs/getting-started.md | 51 +- evaluation/swe_bench_pro_on_demand.py | 9 +- src/agent.rs | 1295 ++++++++++++++++++++++ src/main.rs | 3 + src/runtime.rs | 636 ++++++++--- tests/live-qwen-smoke.sh | 56 + tests/run.sh | 213 +++- tests/test_native_solver_import_model.py | 1 + tests/test_swe_provenance.py | 4 + 14 files changed, 2517 insertions(+), 214 deletions(-) create mode 100644 docs/coding-agent-backends-design.md create mode 100644 docs/coding-agent-backends-prd.md create mode 100644 src/agent.rs create mode 100755 tests/live-qwen-smoke.sh diff --git a/README.md b/README.md index 01fe87a..f6eb638 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,10 @@ # Multiagent Multiagent is the reference implementation of an orchestration layer for -coding agents. It is not another coding agent: it composes existing Codex and -Claude CLIs into parallel roles, records their work, independently verifies the -result, and gates acceptance on evidence bound to the exact Git diff. +coding agents. It is not another coding agent: it composes existing Codex, +Claude Code, and Qwen Code agents into parallel roles, records their work, +independently verifies the result, and gates acceptance on evidence bound to the +exact Git diff. The project prioritizes orchestration, evaluation, and runtime rigor over a custom UI or model implementation. @@ -13,8 +14,8 @@ custom UI or model implementation. Building from source requires Rust 1.75 or newer, Cargo, Bash, and Git. Rust owns the production control plane. Python 3.8 or newer is required only for evaluation and evidence-analysis commands; those modules have no third-party Python package -dependency. Live agent sessions also require `tmux` plus the configured Codex or -Claude CLI. +dependency. Live agent sessions also require `tmux` plus the configured coding-agent +executables. ## Try It Locally @@ -75,8 +76,8 @@ official scorer; it does not implement a second solver or acceptance gate. See ## Run With Agents -Live orchestration additionally requires `tmux` and at least one configured -Codex or Claude CLI: +Live orchestration additionally requires `tmux` and the coding-agent executables +selected for its roles: ```bash ./launch.sh --session multiagent --root /absolute/path/to/target-repo @@ -126,8 +127,9 @@ technical findings and repair TODOs remain authoritative. Running `multiagent subagent gate-check`. The default roles use Codex for orchestration and verification and Claude for -workers. `WORKER_CLI`: worker CLI for manual worker windows, default `claude`. -`VERIFIER_CLI`: verifier CLI, default `codex`. CLI choices, recovery, ownership +workers. `WORKER_CLI`: worker coding-agent backend for manual worker windows, +default `claude`; supported values are `codex`, `claude`, and `qwen`. +`VERIFIER_CLI`: verifier backend, default `codex`. Backend choices, recovery, ownership policy, role prompts, DAG workflows, and all control-plane commands are in the [getting-started and operations guide](docs/getting-started.md). diff --git a/docs/architecture.md b/docs/architecture.md index 5fca2d3..af2d8eb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -4,6 +4,10 @@ Multiagent is an orchestration and evidence layer around existing coding-agent CLIs. It is not a replacement model or a claim that every task benefits from parallelism. +The proposed provider-neutral coding-agent boundary is described in the +[backend PRD](coding-agent-backends-prd.md) and +[refactoring design](coding-agent-backends-design.md). + ```mermaid flowchart LR U["Real issue + immutable base commit"] --> P["Pilot manifest"] diff --git a/docs/coding-agent-backends-design.md b/docs/coding-agent-backends-design.md new file mode 100644 index 0000000..6291bd9 --- /dev/null +++ b/docs/coding-agent-backends-design.md @@ -0,0 +1,289 @@ +# Refactoring Design: Coding-Agent Backend Boundary + +Status: Implemented; Codex regression gate passed + +Related product requirements: [Pluggable Coding-Agent Backends](coding-agent-backends-prd.md) + +## Design Summary + +Extract provider-specific process construction and output decoding from +`runtime.rs` into three small Rust backends. Keep one shared process supervisor +for role isolation, tmux integration, cancellation, trace persistence, and +durable workflow state. + +```text +workflow / role state machine + | + v + AgentBackend registry + / | \ + Codex Claude Qwen Code + \ | / + v + shared process + role sandbox supervisor + | + v + raw logs + normalized events + final result +``` + +The backend describes how to invoke an existing coding agent. It never decides +whether the process may write, whether verification passed, or whether the +workflow may advance. + +## Current Boundary + +`build_cli_command` currently combines backend selection, shell rendering, +prompt delivery, final-message capture, and Codex-specific sandbox flags. Its +callers also own the durable assignment and role lifecycle. + +The refactor separates these concerns: + +| Concern | Owner | +| --- | --- | +| Workflow phase and role | Rust workflow state machine | +| Writable roots and UID | Rust role sandbox | +| Process group, timeout, cancellation | Shared process supervisor | +| tmux window and terminal capture | Existing tmux integration | +| Executable, arguments, input/output protocol | Agent backend | +| Provider event decoding | Agent backend | +| Raw/normalized trace persistence | Shared trace sink | +| Correctness and acceptance | Verifier and workflow gate | +| Benchmark scoring | Official benchmark runner | + +## Core Types + +The first extraction should remain synchronous and use the standard library so +it does not require an async runtime merely to construct commands. + +```rust +pub enum AgentBackendId { + Codex, + Claude, + Qwen, +} + +pub struct AgentRequest { + pub role: String, + pub cwd: PathBuf, + pub prompt: Vec, + pub access: RoleAccess, + pub final_output: PathBuf, + pub trace_dir: PathBuf, + pub resume_session: Option, +} + +pub struct CommandSpec { + pub program: PathBuf, + pub args: Vec, + pub cwd: PathBuf, + pub env: BTreeMap, + pub stdin: InputSpec, +} + +pub struct AgentCapabilities { + pub structured_events: bool, + pub native_resume: bool, + pub usage_events: bool, + pub interactive: bool, +} + +pub trait AgentBackend { + fn id(&self) -> AgentBackendId; + fn capabilities(&self) -> AgentCapabilities; + fn preflight(&self) -> Result; + fn command(&self, request: &AgentRequest) -> Result; +} +``` + +Provider JSON formats currently share enough structure that decoding and final +result selection are implemented once in the runner. A provider-specific +decoder should be added to the trait only when a real backend cannot be +normalized without it. + +`CommandSpec` is argv-based. Shell text is rendered only at the existing tmux or +privilege-bridge boundary, using one audited escaping function. Prompt contents +are delivered through stdin or a supervisor-created file and are never inserted +into a command substitution. + +## Normalized Result and Trace + +The common event schema stays deliberately small: + +```rust +pub enum AgentEvent { + Started { session_id: Option }, + Text { text: String }, + ToolStarted { id: String, name: String }, + ToolFinished { id: String, success: bool }, + Usage { input_tokens: u64, output_tokens: u64 }, + Completed { final_message: String }, + Diagnostic { level: Level, message: String }, +} +``` + +Backends may omit optional event types. The shared trace sink always stores: + +- metadata with backend name/version and workflow correlation identifiers; +- raw stdout and stderr without lossy rewriting; +- normalized JSONL events when decoding is available; +- process exit, timeout, signal, and cancellation reason; +- the final-message artifact. The workflow-level SWE trace archive separately + binds the submitted diff and official row identity. + +Raw logs remain the diagnostic source of truth. Normalized events are an index, +not a replacement, so adding a decoder cannot discard provider data. + +## Backend Mapping + +### Codex + +- Headless: `codex exec` with prompt on stdin. +- Final result: retain `--output-last-message` during the behavior-preserving + extraction. +- Structured events: adopt `--json` only in a separate trace change, because it + changes stdout semantics. +- Access flags: selected from role access, while Linux continues to rely on the + inherited outer Landlock/UID boundary where nested Codex sandboxing is not + available. + +### Claude Code + +- Headless execution becomes the default backend contract instead of depending + on interactive command rendering. +- Structured stream output is decoded when enabled; otherwise raw output and + exit status still produce a valid result. +- Provider permission bypass is allowed only inside the outer role sandbox. + +### Qwen Code + +- Use the Qwen Code agent's headless mode and `stream-json` output. +- Map native session identifiers to `resume_session` when requested. +- Use provider approval bypass only after the supervisor has installed the role + sandbox. +- Model/provider configuration remains Qwen Code configuration. It is not added + to Multiagent's workflow state machine. +- Interactive/PTY integration is deferred; Qwen v1 is headless only. + +## Capability Policy + +Required workflow behavior cannot depend on an optional capability. For +example, generic recovery may start a new process with persisted task context; +native resume is used only when explicitly requested and supported. A request +for native resume on an unsupported backend fails with a typed error rather +than silently starting a new conversation. + +The registry owns backend lookup: + +```text +codex -> CodexBackend +claude -> ClaudeBackend +qwen -> QwenBackend +``` + +There is no dynamic plugin ABI in v1. A Rust trait and static registry are the +simplest sufficient extension point for three bundled process backends. + +## Security Invariants + +1. `AgentRequest.access` is derived from persisted role state, never from agent + output or mutable provider configuration. +2. The backend cannot add writable roots, change UID, disable lifecycle checks, + or mark verification complete. +3. Approval-bypass flags are rejected unless the shared supervisor confirms an + outer isolation boundary for the role. +4. Executable paths are operator configuration. They are validated during + preflight and are not accepted from task prompts. +5. Arguments and environment metadata are logged with credential values + redacted. Credentials are not passed as argv. +6. Cancellation terminates the complete process group before the role is + finalized, regardless of backend behavior. + +## File Layout + +The first implementation intentionally stays in `src/agent.rs`: three short +command builders, one registry, one runner, and one trace normalizer. Split it +into `process`, `trace`, and provider modules only when independent ownership or +compile-time boundaries justify the extra files. + +Initially, tmux and privileged role execution may remain in `runtime.rs` and +consume `CommandSpec`. Moving them is optional cleanup after contract parity; +it is not required to add Qwen Code safely. + +## Refactoring Sequence + +1. Add core types and extract `CodexBackend` without changing generated + commands. Lock behavior with golden argv tests. +2. Extract `ClaudeBackend`; keep existing configuration aliases. +3. Route both through the shared process/result path and run the complete test + suite. This is the behavior-preserving checkpoint. +4. Add fake-executable integration tests for events, non-zero exit, timeout, + cancellation, access, and trace persistence. +5. Add `QwenBackend`, capability preflight, configuration, and documentation. +6. Run opt-in live Qwen smoke tests in read-only and workspace-write roles. +7. Rerun the first ten SWE-Bench rows with Codex and compare each previously + solved row to the stored baseline before enabling the refactor by default. +8. Remove the old provider branches only after parity evidence is retained. + +Steps 1 through 5 and the old provider-branch removal are implemented. The +Codex first-ten regression gate passed at 6/10 with all five baseline successes +retained. The live Qwen check remains an explicit operator-authenticated rollout +gate. + +## Test Plan + +### Unit + +- Exact `CommandSpec` for every backend and access mode. +- Prompt bytes never appear in rendered command text. +- Version/preflight parsing and missing executable errors. +- Event decoding with partial, malformed, unknown, and out-of-order lines. +- Final result selection when the final event is missing or the process exits + non-zero. +- Capability mismatch errors. +- Credential redaction. + +### Integration + +- Fake agents read stdin, emit fixture events, write a candidate file, and exit + with controlled statuses. +- Read-only roles cannot modify the repository even when the fake agent tries. +- Writer cancellation kills descendants and prevents late writes. +- Raw and normalized traces survive process/container completion in the + configured external trace directory. +- Existing Codex and Claude spawn, wait, restore, verifier, and lifecycle tests + remain green. + +### Regression evaluation + +The Codex first-ten SWE-Bench run is the migration regression gate. Compare by +row, not only aggregate score. Any previously solved row that becomes unresolved +blocks rollout until trace analysis attributes and resolves the regression. +Qwen Code receives a separate exploratory result set because agent quality is +not adapter parity. + +## Rollback + +Backend selection remains behind the existing role CLI configuration. Codex is +the default, so a rollout can disable `qwen` without changing persisted workflow +state. Rollback selects the Codex backend; it does not restore the removed +provider-specific command branches. + +## Validation Result + +The first-ten Codex run produced the following official row outcomes: + +| Row | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| Baseline | fail | pass | pass | pass | pass | fail | pass | fail | fail | fail | +| Refactor | fail | pass | pass | pass | pass | fail | pass | pass | fail | fail | + +This is a 6/10 aggregate result, up from 5/10, with no loss among previously +solved rows. The failed rows were solver-output failures rather than adapter +scoring decisions: generated-file pollution (0), incomplete compatibility +coverage (5), uncaught Go compile errors (8), and an empty diff (9). + +## References + +- [Qwen Code headless mode](https://qwenlm.github.io/qwen-code-docs/en/users/features/headless/) +- [Claude Code CLI reference](https://docs.anthropic.com/en/docs/claude-code/cli-reference) +- [Codex CLI reference](https://developers.openai.com/codex/cli/reference/) diff --git a/docs/coding-agent-backends-prd.md b/docs/coding-agent-backends-prd.md new file mode 100644 index 0000000..c7c9651 --- /dev/null +++ b/docs/coding-agent-backends-prd.md @@ -0,0 +1,135 @@ +# PRD: Pluggable Coding-Agent Backends + +Status: Implemented; Qwen live-auth smoke pending + +## Problem + +The Rust runtime currently constructs Codex and Claude CLI commands directly in +`runtime.rs`. Adding another coding agent would add more provider-specific +branches to orchestration, permission, tracing, and lifecycle code. + +We need one small backend contract that can run: + +- Codex CLI; +- Claude Code; +- Qwen Code as an open-source coding agent, independently of which model or + inference provider Qwen Code uses. + +This is an agent-runtime abstraction, not a common model API and not a new +agent loop implemented by Multiagent. + +## Product Goal + +Let each workflow role select a supported coding-agent backend without changing +Multiagent's workflow semantics, security boundary, trace layout, or benchmark +submission behavior. + +## Users and Use Cases + +- Operators can compare agents on the same task and role policy. +- Developers can add a backend without editing the supervisor state machine. +- Evaluators can preserve raw and normalized traces outside task containers and + submit the resulting workspace diff to the official benchmark scorer. + +## Requirements + +### Required backend contract + +Every v1 backend must support: + +1. A non-interactive, single-task invocation. +2. A configured working directory and prompt input that does not depend on a + shell-specific quoting convention. +3. A final message, raw stdout/stderr, exit status, and cancellation. +4. Read-only or workspace-write execution as determined by the outer Rust + supervisor. +5. A stable backend name, executable path, version preflight, and explicit + failure when a requested capability is unavailable. +6. Trace correlation with workflow, role, assignment, process, and optional + provider session identifiers. + +### Provider-specific capabilities + +Structured events, native session resume, usage data, interactive UI, and +provider-side sandboxing are capabilities, not assumptions. The runtime must +query the selected backend's declared capabilities and must not silently +simulate unsupported behavior. + +Qwen Code v1 support uses its complete open-source coding-agent runtime. It may +connect to Qwen or another supported model provider; Multiagent does not +implement Qwen Code's tool loop. + +### Supervisor invariants + +- Rust remains authoritative for role assignment, workflow transitions, + writable paths, UID isolation, timeouts, cancellation, durable state, and the + final acceptance gate. +- Agent approval or `--yolo` flags cannot grant access beyond the outer role + sandbox. +- An agent's success exit code or final message is not verification evidence. +- Evaluation adapters only collect the workspace result and submit it to the + benchmark. They do not duplicate acceptance or scoring. +- Credentials are passed through the environment or provider-native stores, + never rendered into command logs. Trace storage retains restrictive + permissions and records any redaction performed. + +## Non-goals + +- Reimplementing a shared agent loop, tool registry, context manager, or model + protocol. +- Guaranteeing identical reasoning or solution quality across agents. +- Reproducing every Codex CLI feature; parity is limited to features used by + this repository. +- Migrating tmux or PTY behavior. Existing interactive compatibility remains; + Qwen Code v1 only needs the headless backend contract. +- Letting an agent backend make workflow, authorization, or verification + decisions. + +## Configuration + +Existing role-level CLI selection becomes backend selection. The initial names +are `codex`, `claude`, and `qwen`. Each backend has an overridable executable +path. Invalid names and missing executables fail during launch preflight. + +Existing Codex and Claude environment variables remain compatible for one +deprecation cycle. Qwen Code receives an equivalent executable override without +embedding provider credentials in repository configuration. + +## Acceptance Criteria + +- Existing Codex and Claude launches produce equivalent commands, permissions, + lifecycle state, final artifacts, and cancellation behavior after extraction. +- Unit tests cover command specifications and event/result normalization for all + three backends, including malformed events, non-zero exits, timeout, and + cancellation. +- Integration tests use fake executables to prove role access, trace persistence, + final-message capture, and unsupported-capability failures without network + access. +- An opt-in live smoke test completes one read-only and one workspace-write task + with Qwen Code inside the existing supervisor boundary. +- The first ten-row SWE-Bench regression run with the Codex backend does not lose + any row previously solved by the pre-refactor baseline. Qwen Code results are + reported separately and are not treated as proof of Codex parity. +- `launch.sh` continues to launch the Rust workflow unchanged for existing + callers. + +## Success Measures + +- Adding a fourth process-based agent requires a backend module and contract + tests, but no changes to workflow or authorization logic. +- No provider-specific command construction remains in the workflow state + machine. +- Every run identifies its backend and version, and retains enough raw evidence + to diagnose a provider or adapter failure after its container exits. + +## Validation Snapshot + +The Codex first-ten SWE-Bench Pro regression run scored 6/10 versus the stored +5/10 baseline. All previously solved rows (1, 2, 3, 4, and 6) remained solved; +row 7 became solved. Raw workflow traces for every row were exported outside the +task containers before teardown. + +Offline unit and integration coverage exercises Codex, Claude, and Qwen command +construction and Qwen process behavior. The opt-in live Qwen read/write smoke +test is implemented but remains a rollout check until an operator authenticates +Qwen Code; credentials are intentionally not bundled with this repository. diff --git a/docs/control-plane-boundary.md b/docs/control-plane-boundary.md index 0af365e..2864600 100644 --- a/docs/control-plane-boundary.md +++ b/docs/control-plane-boundary.md @@ -26,9 +26,11 @@ implementations. In the production Linux-container boundary, tmux runs as the read-only orchestrator UID. A raw tmux window therefore cannot acquire repository writes. Worker/reviewer transitions use the Rust binary's narrowly gated -`role-agent-exec` entrypoint: it accepts only a persisted named Codex agent, -validates the trusted bridge, and starts Codex in a dedicated process group -under the role's UID. A minimal wait-only parent retains no workflow discretion; +`role-agent-exec` entrypoint: it accepts only a persisted named headless coding +agent, validates the configured root-owned agent binary, and starts the shared +Rust runner in a dedicated process group under the role's UID. The runner then +executes the recorded Codex, Claude, or Qwen Code backend through argv and stdin. +A minimal wait-only parent retains no workflow discretion; it exists solely to forward pane termination to the complete role process tree. `subagent kill` waits for that boundary to close, preventing detached or late worker output from modifying the workspace after cancellation. The setuid @@ -40,6 +42,13 @@ writer it revalidates the assignment against the live workflow phase and approved implementation context; setting `MULTIAGENT_LIFECYCLE_ENFORCEMENT=0` cannot reopen a completed workflow. +Headless runs retain raw stdout/stderr, normalized JSONL events, provider session +identity when available, the final message, and the exit/cancellation reason +under `MULTIAGENT_LOG_DIR/agents`. Each invocation receives an immutable +`attempt-NNNN` directory and `latest` points to the newest attempt, so restore +does not overwrite the trace it relies on. This directory may be mounted outside +an evaluation container so evidence survives task teardown. + Python under `evaluation/` is limited to benchmark adapters, status readers, and provenance. SWE Bench adapters launch the production workflow and pass the current workspace diff to the official scorer. They neither derive a second diff --git a/docs/getting-started.md b/docs/getting-started.md index 4213518..73a3bf8 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -22,7 +22,7 @@ This project launches a tmux session with one `orchestrator` window. The orchest - `tmux` - Rust 1.75 or newer and Cargo when running from a source checkout - Python 3.8 or newer only for evaluation and evidence-analysis commands; no `pip install` or virtual environment is required -- Codex CLI or Claude CLI, according to the configured orchestrator and agent roles +- Codex CLI, Claude Code, or Qwen Code, according to the configured role backends `launch.sh` locates or builds the Rust binary and execs `multiagent launch`, which checks runtime prerequisites before creating the tmux session. Durable @@ -57,12 +57,17 @@ Environment: - `MULTIAGENT_WRITE_POLICY`: repo write policy, default `$MULTIAGENT_ROOT/docs/write-policy.paths` - `MULTIAGENT_VERIFIER_MAX_ITERATIONS`: worker/verifier follow-up loop cap, default `3` - `MULTIAGENT_PROMPT`: orchestrator prompt, default `/orchestrator_prompt.md` -- `ORCHESTRATOR_CLI`: orchestrator CLI, default `codex` -- `WORKER_CLI`: worker CLI for manual worker windows, default `claude` -- `SUBAGENT_CLI`: named subagent CLI, default `$WORKER_CLI` -- `VERIFIER_CLI`: verifier CLI, default `codex` +- `ORCHESTRATOR_CLI`: orchestrator backend (`codex`, `claude`, or `qwen`), default `codex` +- `WORKER_CLI`: worker backend, default `claude` +- `SUBAGENT_CLI`: named subagent backend, default `$WORKER_CLI` +- `VERIFIER_CLI`: verifier backend, default `codex` - `CODEX_BIN`: Codex CLI command, default `codex` - `CLAUDE_BIN`: Claude CLI command, default `claude` +- `QWEN_BIN`: Qwen Code command, default `qwen` +- `MULTIAGENT_AGENT_HEADLESS`: use the normalized headless runner for Codex and Claude (`0` or `1`); Qwen is always headless in v1 +- `MULTIAGENT_NATIVE_RESUME`: resume a provider session when supported and a persisted session ID exists +- `MULTIAGENT_AGENT_TIMEOUT_SECONDS`: outer wall-clock timeout for every headless backend +- `MULTIAGENT_AGENT_MAX_TURNS`, `MULTIAGENT_AGENT_MAX_WALL_TIME`, `MULTIAGENT_AGENT_MAX_TOOL_CALLS`: optional Qwen Code budgets The default setup keeps the orchestrator on Codex, uses Claude for workers and generic named subagents, and uses Codex for verifier agents. To use Codex for @@ -72,6 +77,22 @@ workers and generic named subagents too: ORCHESTRATOR_CLI=codex WORKER_CLI=codex SUBAGENT_CLI=codex ./launch.sh ``` +To use the open-source Qwen Code agent for all roles: + +```bash +ORCHESTRATOR_CLI=qwen WORKER_CLI=qwen SUBAGENT_CLI=qwen VERIFIER_CLI=qwen ./launch.sh +``` + +Qwen Code remains responsible for its agent loop, tools, context, and model +provider. Multiagent passes it a task and normalizes process evidence; it does +not replace Qwen Code with a Qwen model API. + +After installing and authenticating Qwen Code, run the opt-in live backend +check with `bash tests/live-qwen-smoke.sh`. It performs one read-only task and +one workspace-write task and checks both the response and filesystem result. +The regular test suite uses a fake Qwen executable and never requires network +access or credentials. + The Rust supervisor assigns Codex access from trusted process roles. On hosts where Codex's native sandbox is available, the orchestrator starts in the durable state directory with `workspace-write`, workers start in the target @@ -80,14 +101,16 @@ repository with `workspace-write`, and scouts/authority reviewers use Unix identities instead because nested bubblewrap is unavailable under Docker's default seccomp profile. Its tmux server runs as the non-writing orchestrator identity. A narrowly gated, setuid Rust entrypoint may only start the fixed -Codex subagent command recorded for a named role; all other invocations +coding-agent binary recorded for a named headless role; all other invocations permanently drop back to the caller UID. Each role also receives a private Codex runtime home so one role's private lock/config files cannot stall another. The isolated orchestrator's real UID makes lifecycle enforcement mandatory, so shell-level environment overrides cannot authorize a writer after completion. In both environments the orchestrator can read the target but cannot write it, while workers can. Claude remains a compatibility path and does not provide -Codex's native role boundary outside the production adapter. +Codex's native role boundary outside the production adapter. Qwen uses `plan` +approval for read-only roles and its sandbox on non-Linux hosts, but the +production security claim remains the outer Linux role boundary. `--root` selects the target project repo for `MULTIAGENT_ROOT`, state, and write policy. The orchestrator CLI works from the durable state directory and reads @@ -464,19 +487,19 @@ orchestrator/user decision: multiagent policy approve /tmp --actor orchestrator --assignment-id build-logs --reason "user approved shared temp output" --force ``` -For Codex roles, the OS boundary mechanically prevents the orchestrator, +For isolated coding-agent roles, the OS boundary mechanically prevents the orchestrator, authority reviewers, and scouts from writing the target repository. On native hosts that boundary is Codex's sandbox; in the production Linux container it is Unix ownership plus a permanent role UID drop. The tmux server itself has the orchestrator UID, so bypassing the Rust CLI to open a raw pane still produces a non-writing process. The only privileged transition is the fixed -`role-agent-exec` path, which validates persisted role metadata and a -root-owned, non-group-writable Codex bridge before dropping to the writer or -reader UID. Generic `role-exec` calls from the orchestrator lose setuid +`role-agent-exec` path, which validates persisted role metadata and the +root-owned, non-group-writable configured agent binary before dropping to the +writer or reader UID. Generic `role-exec` calls from the orchestrator lose setuid privilege before dispatch. The write-policy helper remains responsible for -explicit writes outside the normal role root. Claude -compatibility processes do not receive this mechanical boundary on native -hosts. +explicit writes outside the normal role root. Compatibility processes do not +receive this mechanical boundary on native hosts unless their own sandbox is +enabled. ## Assignment Metadata and Acceptance diff --git a/evaluation/swe_bench_pro_on_demand.py b/evaluation/swe_bench_pro_on_demand.py index 58a2df8..2bc21ba 100644 --- a/evaluation/swe_bench_pro_on_demand.py +++ b/evaluation/swe_bench_pro_on_demand.py @@ -58,7 +58,14 @@ def skip_repo_bake_path(path: Path) -> bool: """Return whether a repository path is excluded from task-image source.""" parts = set(path.parts) - if parts & {".git", ".multiagent", "__pycache__", ".pytest_cache", "node_modules"}: + if parts & { + ".git", + ".multiagent", + "__pycache__", + ".pytest_cache", + "node_modules", + "target", + }: return True if path.parts and path.parts[0] in {"tests", "docs"}: return True diff --git a/src/agent.rs b/src/agent.rs new file mode 100644 index 0000000..5e741b3 --- /dev/null +++ b/src/agent.rs @@ -0,0 +1,1295 @@ +use serde::Serialize; +use serde_json::{json, Value}; +use std::collections::BTreeMap; +use std::env; +use std::ffi::OsString; +use std::fs::{self, File}; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Command, ExitCode, Stdio}; +#[cfg(unix)] +use std::sync::atomic::{AtomicI32, Ordering}; +use std::thread; +use std::time::{Duration, Instant}; + +#[cfg(unix)] +static AGENT_CHILD_GROUP: AtomicI32 = AtomicI32::new(0); +#[cfg(unix)] +static AGENT_CANCEL_SIGNAL: AtomicI32 = AtomicI32::new(0); + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum BackendId { + Codex, + Claude, + Qwen, +} + +impl BackendId { + pub fn parse(value: &str) -> Result { + match value { + "codex" => Ok(Self::Codex), + "claude" => Ok(Self::Claude), + "qwen" => Ok(Self::Qwen), + _ => Err(format!( + "unsupported coding-agent backend '{value}' (expected codex, claude, or qwen)" + )), + } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::Codex => "codex", + Self::Claude => "claude", + Self::Qwen => "qwen", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RoleAccess { + ReadOnly, + WorkspaceWrite, +} + +impl RoleAccess { + pub fn parse(value: &str) -> Result { + match value { + "read-only" => Ok(Self::ReadOnly), + "workspace-write" => Ok(Self::WorkspaceWrite), + _ => Err(format!( + "invalid coding-agent access '{value}' (expected read-only or workspace-write)" + )), + } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::ReadOnly => "read-only", + Self::WorkspaceWrite => "workspace-write", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum InvocationMode { + Interactive, + Headless, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +pub struct AgentCapabilities { + pub structured_events: bool, + pub native_resume: bool, + pub usage_events: bool, + pub interactive: bool, +} + +#[derive(Clone, Debug)] +pub struct BackendPaths { + pub codex: String, + pub claude: String, + pub qwen: String, +} + +impl BackendPaths { + pub fn from_env() -> Self { + Self { + codex: env_nonempty("CODEX_BIN").unwrap_or_else(|| "codex".into()), + claude: env_nonempty("CLAUDE_BIN").unwrap_or_else(|| "claude".into()), + qwen: env_nonempty("QWEN_BIN").unwrap_or_else(|| "qwen".into()), + } + } +} + +#[derive(Clone, Debug)] +pub struct AgentRequest { + pub cwd: PathBuf, + pub prompt_file: Option, + pub final_output: Option, + pub access: RoleAccess, + pub mode: InvocationMode, + pub resume_session: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CommandSpec { + pub program: String, + pub args: Vec, + pub cwd: PathBuf, + pub stdin_file: Option, + // Interactive compatibility only. Headless backends must use stdin_file. + pub legacy_prompt_argument: Option, +} + +impl CommandSpec { + pub fn render_shell(&self) -> String { + let mut command = shell_escape(&self.program); + for arg in &self.args { + command.push(' '); + command.push_str(&shell_escape(&arg.to_string_lossy())); + } + if let Some(path) = &self.legacy_prompt_argument { + command.push_str(&format!( + " \"$(cat {})\"", + shell_escape(&path.display().to_string()) + )); + } + if let Some(path) = &self.stdin_file { + command.push_str(&format!(" < {}", shell_escape(&path.display().to_string()))); + } + command + } +} + +pub trait AgentBackend { + fn id(&self) -> BackendId; + fn executable(&self) -> &str; + fn capabilities(&self) -> AgentCapabilities; + fn command(&self, request: &AgentRequest) -> Result; + + fn preflight(&self) -> Result { + let output = Command::new(self.executable()) + .arg("--version") + .output() + .map_err(|error| { + format!( + "run {} coding-agent preflight ({}): {error}", + self.id().as_str(), + self.executable() + ) + })?; + if !output.status.success() { + return Err(format!( + "{} coding-agent preflight failed for {}: {}", + self.id().as_str(), + self.executable(), + String::from_utf8_lossy(&output.stderr).trim() + )); + } + let version = String::from_utf8_lossy(if output.stdout.is_empty() { + &output.stderr + } else { + &output.stdout + }) + .trim() + .to_string(); + Ok(BackendVersion { + backend: self.id(), + executable: self.executable().into(), + version: if version.is_empty() { + "unknown".into() + } else { + version + }, + }) + } +} + +#[derive(Clone, Debug, Serialize)] +pub struct BackendVersion { + pub backend: BackendId, + pub executable: String, + pub version: String, +} + +struct CodexBackend { + executable: String, +} + +struct ClaudeBackend { + executable: String, +} + +struct QwenBackend { + executable: String, +} + +pub fn backend(id: BackendId, paths: &BackendPaths) -> Box { + match id { + BackendId::Codex => Box::new(CodexBackend { + executable: paths.codex.clone(), + }), + BackendId::Claude => Box::new(ClaudeBackend { + executable: paths.claude.clone(), + }), + BackendId::Qwen => Box::new(QwenBackend { + executable: paths.qwen.clone(), + }), + } +} + +impl AgentBackend for CodexBackend { + fn id(&self) -> BackendId { + BackendId::Codex + } + + fn executable(&self) -> &str { + &self.executable + } + + fn capabilities(&self) -> AgentCapabilities { + AgentCapabilities { + structured_events: true, + native_resume: false, + usage_events: true, + interactive: true, + } + } + + fn command(&self, request: &AgentRequest) -> Result { + let mut args = Vec::::new(); + match request.mode { + InvocationMode::Headless => { + if request.resume_session.is_some() { + return Err( + "codex native resume is not enabled by the v1 backend contract".into(), + ); + } + args.extend(["exec".into(), "--cd".into(), request.cwd.as_os_str().into()]); + args.push("--skip-git-repo-check".into()); + for value in codex_safety_args(request.access, true) { + args.push(value.into()); + } + if let Some(path) = &request.final_output { + args.push("--output-last-message".into()); + args.push(path.as_os_str().into()); + } + args.push("-".into()); + Ok(CommandSpec { + program: self.executable.clone(), + args, + cwd: request.cwd.clone(), + stdin_file: request.prompt_file.clone(), + legacy_prompt_argument: None, + }) + } + InvocationMode::Interactive => { + args.extend(["--cd".into(), request.cwd.as_os_str().into()]); + for value in codex_safety_args(request.access, false) { + args.push(value.into()); + } + args.push("--no-alt-screen".into()); + Ok(CommandSpec { + program: self.executable.clone(), + args, + cwd: request.cwd.clone(), + stdin_file: None, + legacy_prompt_argument: request.prompt_file.clone(), + }) + } + } + } +} + +impl AgentBackend for ClaudeBackend { + fn id(&self) -> BackendId { + BackendId::Claude + } + + fn executable(&self) -> &str { + &self.executable + } + + fn capabilities(&self) -> AgentCapabilities { + AgentCapabilities { + structured_events: true, + native_resume: true, + usage_events: true, + interactive: true, + } + } + + fn command(&self, request: &AgentRequest) -> Result { + let mut args = Vec::::new(); + match request.mode { + InvocationMode::Headless => { + args.extend([ + "-p".into(), + "--input-format".into(), + "text".into(), + "--output-format".into(), + "stream-json".into(), + "--verbose".into(), + "--dangerously-skip-permissions".into(), + ]); + if let Some(session) = &request.resume_session { + args.push("--resume".into()); + args.push(session.into()); + } + Ok(CommandSpec { + program: self.executable.clone(), + args, + cwd: request.cwd.clone(), + stdin_file: request.prompt_file.clone(), + legacy_prompt_argument: None, + }) + } + InvocationMode::Interactive => Ok(CommandSpec { + program: self.executable.clone(), + args: vec!["--dangerously-skip-permissions".into()], + cwd: request.cwd.clone(), + stdin_file: None, + legacy_prompt_argument: request.prompt_file.clone(), + }), + } + } +} + +impl AgentBackend for QwenBackend { + fn id(&self) -> BackendId { + BackendId::Qwen + } + + fn executable(&self) -> &str { + &self.executable + } + + fn capabilities(&self) -> AgentCapabilities { + AgentCapabilities { + structured_events: true, + native_resume: true, + usage_events: true, + interactive: false, + } + } + + fn command(&self, request: &AgentRequest) -> Result { + if request.mode != InvocationMode::Headless { + return Err("Qwen Code v1 backend supports headless workflow roles only".into()); + } + let mut args = vec![ + "--output-format".into(), + "stream-json".into(), + "--approval-mode".into(), + match request.access { + RoleAccess::ReadOnly => "plan".into(), + RoleAccess::WorkspaceWrite => "yolo".into(), + }, + ]; + #[cfg(not(target_os = "linux"))] + args.push("--sandbox".into()); + if let Some(session) = &request.resume_session { + args.push("--resume".into()); + args.push(session.into()); + } + for (key, flag) in [ + ("MULTIAGENT_AGENT_MAX_TURNS", "--max-session-turns"), + ("MULTIAGENT_AGENT_MAX_WALL_TIME", "--max-wall-time"), + ("MULTIAGENT_AGENT_MAX_TOOL_CALLS", "--max-tool-calls"), + ] { + if let Some(value) = env_nonempty(key) { + args.push(flag.into()); + args.push(value.into()); + } + } + Ok(CommandSpec { + program: self.executable.clone(), + args, + cwd: request.cwd.clone(), + stdin_file: request.prompt_file.clone(), + legacy_prompt_argument: None, + }) + } +} + +#[cfg(target_os = "linux")] +fn codex_safety_args(_access: RoleAccess, _headless: bool) -> Vec<&'static str> { + vec!["--dangerously-bypass-approvals-and-sandbox"] +} + +#[cfg(not(target_os = "linux"))] +fn codex_safety_args(access: RoleAccess, headless: bool) -> Vec<&'static str> { + if headless { + vec!["--sandbox", access.as_str(), "-c", "approval_policy=never"] + } else { + vec!["--sandbox", access.as_str(), "--ask-for-approval", "never"] + } +} + +pub fn run(args: &[String]) -> Result { + let Some(command) = args.first().map(String::as_str) else { + print_usage(); + return Ok(ExitCode::SUCCESS); + }; + match command { + "run" => run_backend(&args[1..]), + "backend-info" => backend_info(&args[1..]), + "-h" | "--help" | "help" => { + print_usage(); + Ok(ExitCode::SUCCESS) + } + _ => Err(format!("unknown agent command: {command}")), + } +} + +fn print_usage() { + println!( + "Usage:\n multiagent agent backend-info BACKEND\n multiagent agent run --backend BACKEND --cwd DIR --prompt-file FILE --final-output FILE --trace-dir DIR --access read-only|workspace-write [--resume-session ID]" + ); +} + +fn backend_info(args: &[String]) -> Result { + if args.len() != 1 { + return Err("agent backend-info requires BACKEND".into()); + } + let id = BackendId::parse(&args[0])?; + let paths = BackendPaths::from_env(); + let selected = backend(id, &paths); + let version = selected.preflight()?; + println!( + "{}", + serde_json::to_string(&json!({ + "backend": id, + "capabilities": selected.capabilities(), + "executable": version.executable, + "version": version.version, + })) + .map_err(|error| format!("serialize backend info: {error}"))? + ); + Ok(ExitCode::SUCCESS) +} + +fn run_backend(args: &[String]) -> Result { + let mut values = BTreeMap::::new(); + let mut index = 0; + while index < args.len() { + let key = match args[index].as_str() { + "--backend" | "--cwd" | "--prompt-file" | "--final-output" | "--trace-dir" + | "--access" | "--resume-session" => args[index].trim_start_matches("--"), + other => return Err(format!("unknown agent run argument: {other}")), + }; + let value = args + .get(index + 1) + .filter(|value| !value.is_empty()) + .ok_or_else(|| format!("agent run --{key} requires a value"))?; + values.insert(key.into(), value.clone()); + index += 2; + } + let required = |key: &str| { + values + .get(key) + .cloned() + .ok_or_else(|| format!("agent run requires --{key}")) + }; + let id = BackendId::parse(&required("backend")?)?; + let cwd = PathBuf::from(required("cwd")?); + let prompt_file = PathBuf::from(required("prompt-file")?); + let final_output = PathBuf::from(required("final-output")?); + let trace_root = PathBuf::from(required("trace-dir")?); + let access = RoleAccess::parse(&required("access")?)?; + if !cwd.is_dir() { + return Err(format!( + "agent working directory is missing: {}", + cwd.display() + )); + } + if !prompt_file.is_file() { + return Err(format!( + "agent prompt file is missing: {}", + prompt_file.display() + )); + } + + let paths = BackendPaths::from_env(); + let selected = backend(id, &paths); + let version = selected.preflight()?; + let request = AgentRequest { + cwd, + prompt_file: Some(prompt_file.clone()), + final_output: Some(final_output.clone()), + access, + mode: InvocationMode::Headless, + resume_session: values.get("resume-session").cloned(), + }; + let spec = selected.command(&request)?; + let timeout = agent_timeout()?; + let trace_dir = next_trace_attempt(&trace_root)?; + run_spec( + id, + version, + selected.capabilities(), + spec, + RunFiles { + prompt: &prompt_file, + final_output: &final_output, + trace_dir: &trace_dir, + }, + timeout, + ) +} + +struct RunFiles<'a> { + prompt: &'a Path, + final_output: &'a Path, + trace_dir: &'a Path, +} + +fn agent_timeout() -> Result, String> { + let timeout = env_nonempty("MULTIAGENT_AGENT_TIMEOUT_SECONDS") + .map(|value| { + value.parse::().map(Duration::from_secs).map_err(|_| { + "MULTIAGENT_AGENT_TIMEOUT_SECONDS must be a positive integer".to_string() + }) + }) + .transpose()?; + if timeout.is_some_and(|value| value.is_zero()) { + return Err("MULTIAGENT_AGENT_TIMEOUT_SECONDS must be a positive integer".into()); + } + Ok(timeout) +} + +fn next_trace_attempt(root: &Path) -> Result { + create_private_dir(root)?; + for number in 1..=9999 { + let name = format!("attempt-{number:04}"); + let path = root.join(&name); + match fs::create_dir(&path) { + Ok(()) => { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&path, fs::Permissions::from_mode(0o2770)).map_err( + |error| { + format!( + "set agent trace attempt permissions {}: {error}", + path.display() + ) + }, + )?; + } + write_private(&root.join("latest"), format!("{name}\n").as_bytes())?; + return Ok(path); + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(error) => { + return Err(format!( + "create agent trace attempt {}: {error}", + path.display() + )) + } + } + } + Err(format!( + "agent trace attempt limit reached under {}", + root.display() + )) +} + +fn run_spec( + id: BackendId, + version: BackendVersion, + capabilities: AgentCapabilities, + spec: CommandSpec, + files: RunFiles<'_>, + timeout: Option, +) -> Result { + let prompt = fs::read(files.prompt) + .map_err(|error| format!("read agent prompt {}: {error}", files.prompt.display()))?; + create_private_dir(files.trace_dir)?; + // Never let a restored attempt inherit a stale success message from the + // previous process. Provider output or normalized events repopulate it. + write_private(files.final_output, b"")?; + let raw_stdout = files.trace_dir.join("raw-stdout.log"); + let raw_stderr = files.trace_dir.join("raw-stderr.log"); + let normalized = files.trace_dir.join("events.jsonl"); + let metadata = json!({ + "schema_version": 1, + "backend": id, + "executable": version.executable, + "version": version.version, + "capabilities": capabilities, + "cwd": spec.cwd, + "prompt_file": files.prompt, + "final_output": files.final_output, + "workflow_id": env::var("MULTIAGENT_WORKFLOW_ID").unwrap_or_default(), + "role": env::var("MULTIAGENT_SUBAGENT_NAME").unwrap_or_else(|_| "orchestrator".into()), + }); + write_private( + &files.trace_dir.join("metadata.json"), + serde_json::to_string_pretty(&metadata) + .map_err(|error| format!("serialize agent metadata: {error}"))? + .as_bytes(), + )?; + + let mut command = Command::new(&spec.program); + command + .args(&spec.args) + .current_dir(&spec.cwd) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + configure_agent_process(&mut command)?; + let mut child = command.spawn().map_err(|error| { + format!( + "start {} coding agent ({}): {error}", + id.as_str(), + spec.program + ) + })?; + #[cfg(unix)] + AGENT_CHILD_GROUP.store(child.id() as i32, Ordering::SeqCst); + let prompt_write = child + .stdin + .take() + .ok_or_else(|| "coding-agent stdin was not captured".to_string())? + .write_all(&prompt); + if let Err(error) = prompt_write { + terminate_agent_process(&mut child); + let _ = child.wait(); + #[cfg(unix)] + AGENT_CHILD_GROUP.store(0, Ordering::SeqCst); + return Err(format!("write coding-agent prompt: {error}")); + } + + let stdout = child + .stdout + .take() + .ok_or_else(|| "coding-agent stdout was not captured".to_string())?; + let stderr = child + .stderr + .take() + .ok_or_else(|| "coding-agent stderr was not captured".to_string())?; + let stdout_path = raw_stdout.clone(); + let stderr_path = raw_stderr.clone(); + let stdout_thread = thread::spawn(move || tee_stream(stdout, &stdout_path, true)); + let stderr_thread = thread::spawn(move || tee_stream(stderr, &stderr_path, false)); + let started = Instant::now(); + let mut timed_out = false; + let status = loop { + if let Some(status) = child + .try_wait() + .map_err(|error| format!("wait for {} coding agent: {error}", id.as_str()))? + { + break status; + } + if timeout.is_some_and(|limit| started.elapsed() >= limit) { + timed_out = true; + terminate_agent_process(&mut child); + break child.wait().map_err(|error| { + format!("wait for timed-out {} coding agent: {error}", id.as_str()) + })?; + } + thread::sleep(Duration::from_millis(25)); + }; + #[cfg(unix)] + AGENT_CHILD_GROUP.store(0, Ordering::SeqCst); + stdout_thread + .join() + .map_err(|_| "coding-agent stdout capture panicked".to_string())??; + stderr_thread + .join() + .map_err(|_| "coding-agent stderr capture panicked".to_string())??; + + let stdout_bytes = + fs::read(&raw_stdout).map_err(|error| format!("read raw coding-agent stdout: {error}"))?; + let decoded = normalize_output(id, &stdout_bytes); + let mut event_bytes = Vec::new(); + for event in &decoded.events { + serde_json::to_writer(&mut event_bytes, event) + .map_err(|error| format!("serialize normalized agent event: {error}"))?; + event_bytes.push(b'\n'); + } + write_private(&normalized, &event_bytes)?; + if let Some(session_id) = decoded.session_id.as_deref() { + write_private( + &files.trace_dir.join("session-id"), + format!("{session_id}\n").as_bytes(), + )?; + } + if id != BackendId::Codex + || fs::metadata(files.final_output).is_ok_and(|value| value.len() == 0) + { + if let Some(message) = decoded.final_message.as_deref() { + write_private(files.final_output, format!("{message}\n").as_bytes())?; + } + } + #[cfg(unix)] + let signal = { + use std::os::unix::process::ExitStatusExt; + status.signal() + }; + #[cfg(not(unix))] + let signal = None::; + #[cfg(unix)] + let cancel_signal = AGENT_CANCEL_SIGNAL.swap(0, Ordering::SeqCst); + #[cfg(not(unix))] + let cancel_signal = 0; + let canceled = cancel_signal != 0; + let code = if timed_out { + 124 + } else if canceled { + (128 + cancel_signal).min(255) + } else { + status + .code() + .unwrap_or_else(|| signal.map_or(1, |value| (128 + value).min(255))) + }; + let reason = if timed_out { + "timeout" + } else if canceled { + "canceled" + } else if signal.is_some() { + "signal" + } else if status.success() { + "completed" + } else { + "nonzero-exit" + }; + write_private( + &files.trace_dir.join("exit.json"), + serde_json::to_string_pretty(&json!({ + "success": status.success() && !timed_out && !canceled, + "code": code, + "signal": signal, + "timed_out": timed_out, + "canceled": canceled, + "reason": reason, + })) + .map_err(|error| format!("serialize coding-agent exit: {error}"))? + .as_bytes(), + )?; + Ok(ExitCode::from(code.clamp(0, 255) as u8)) +} + +#[cfg(unix)] +fn configure_agent_process(command: &mut Command) -> Result<(), String> { + use std::os::unix::process::CommandExt; + + install_agent_signal_handlers()?; + unsafe { + command.pre_exec(|| { + if libc::setpgid(0, 0) != 0 { + return Err(std::io::Error::last_os_error()); + } + #[cfg(target_os = "linux")] + if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL) != 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + Ok(()) +} + +#[cfg(not(unix))] +fn configure_agent_process(_command: &mut Command) -> Result<(), String> { + Ok(()) +} + +#[cfg(unix)] +fn install_agent_signal_handlers() -> Result<(), String> { + for signal in [libc::SIGHUP, libc::SIGINT, libc::SIGTERM, libc::SIGQUIT] { + let mut action: libc::sigaction = unsafe { std::mem::zeroed() }; + action.sa_sigaction = forward_agent_signal as usize; + if unsafe { libc::sigemptyset(&mut action.sa_mask) } != 0 + || unsafe { libc::sigaction(signal, &action, std::ptr::null_mut()) } != 0 + { + return Err(format!( + "install coding-agent signal handler: {}", + std::io::Error::last_os_error() + )); + } + } + Ok(()) +} + +#[cfg(unix)] +extern "C" fn forward_agent_signal(signal: libc::c_int) { + AGENT_CANCEL_SIGNAL.store(signal, Ordering::SeqCst); + let child = AGENT_CHILD_GROUP.load(Ordering::SeqCst); + if child > 0 { + unsafe { + libc::kill(-child, libc::SIGKILL); + libc::kill(child, libc::SIGKILL); + } + } +} + +#[cfg(unix)] +fn terminate_agent_process(child: &mut std::process::Child) { + let pid = child.id() as i32; + unsafe { + libc::kill(-pid, libc::SIGKILL); + libc::kill(pid, libc::SIGKILL); + } +} + +#[cfg(not(unix))] +fn terminate_agent_process(child: &mut std::process::Child) { + let _ = child.kill(); +} + +fn tee_stream(mut source: impl Read, path: &Path, stdout: bool) -> Result<(), String> { + let mut file = private_file(path)?; + let mut buffer = [0_u8; 8192]; + loop { + let read = source + .read(&mut buffer) + .map_err(|error| format!("read coding-agent output: {error}"))?; + if read == 0 { + break; + } + file.write_all(&buffer[..read]) + .map_err(|error| format!("write raw coding-agent trace: {error}"))?; + if stdout { + std::io::stdout() + .write_all(&buffer[..read]) + .map_err(|error| format!("forward coding-agent stdout: {error}"))?; + std::io::stdout().flush().ok(); + } else { + std::io::stderr() + .write_all(&buffer[..read]) + .map_err(|error| format!("forward coding-agent stderr: {error}"))?; + std::io::stderr().flush().ok(); + } + } + file.sync_all() + .map_err(|error| format!("sync raw coding-agent trace: {error}")) +} + +#[derive(Serialize)] +struct NormalizedEvent { + backend: BackendId, + sequence: usize, + kind: String, + raw_type: String, + session_id: Option, + text: Option, + tool_id: Option, + tool_name: Option, + success: Option, + usage: Option, + raw: Value, +} + +struct DecodedOutput { + events: Vec, + final_message: Option, + session_id: Option, +} + +fn normalize_output(id: BackendId, bytes: &[u8]) -> DecodedOutput { + let text = String::from_utf8_lossy(bytes); + let mut events = Vec::new(); + let mut final_message = None; + let mut session_id = None; + for (sequence, line) in text.lines().enumerate() { + if line.trim().is_empty() { + continue; + } + let raw = serde_json::from_str::(line) + .unwrap_or_else(|_| json!({ "type": "text", "text": line })); + let raw_type = raw + .get("type") + .and_then(Value::as_str) + .or_else(|| raw.pointer("/event/type").and_then(Value::as_str)) + .unwrap_or("unknown") + .to_string(); + let event_session = find_string(&raw, &["session_id", "sessionId"]); + if event_session.is_some() { + session_id = event_session.clone(); + } + let event_text = extract_event_text(&raw); + if is_final_event(&raw_type, &raw) { + if let Some(value) = event_text.as_ref().filter(|value| !value.trim().is_empty()) { + final_message = Some(value.clone()); + } + } else if matches!(raw_type.as_str(), "assistant" | "message" | "text") { + if let Some(value) = event_text.as_ref().filter(|value| !value.trim().is_empty()) { + final_message = Some(value.clone()); + } + } + let kind = normalized_kind(&raw_type, &raw); + let tool_id = find_string(&raw, &["tool_use_id", "toolUseId"]).or_else(|| { + (kind == "tool-started") + .then(|| find_string(&raw, &["id"])) + .flatten() + }); + let tool_name = (kind == "tool-started") + .then(|| find_string(&raw, &["name"])) + .flatten(); + let success = if raw_type == "result" { + raw.get("is_error") + .and_then(Value::as_bool) + .map(|value| !value) + } else if kind == "tool-finished" { + raw.get("is_error") + .and_then(Value::as_bool) + .map(|value| !value) + .or(Some(true)) + } else { + None + }; + let usage = find_value(&raw, &["usage"]).cloned(); + events.push(NormalizedEvent { + backend: id, + sequence, + kind, + raw_type, + session_id: event_session, + text: event_text, + tool_id, + tool_name, + success, + usage, + raw, + }); + } + DecodedOutput { + events, + final_message, + session_id, + } +} + +fn normalized_kind(raw_type: &str, raw: &Value) -> String { + if raw_type == "system" { + "started" + } else if matches!(raw_type, "result" | "completed" | "complete" | "final") { + if raw.get("is_error").and_then(Value::as_bool) == Some(true) { + "failed" + } else { + "completed" + } + } else if raw_type.contains("tool_result") || contains_type(raw, "tool_result") { + "tool-finished" + } else if raw_type.contains("tool_use") || contains_type(raw, "tool_use") { + "tool-started" + } else if matches!(raw_type, "assistant" | "message" | "text") { + "text" + } else { + "diagnostic" + } + .into() +} + +fn contains_type(value: &Value, expected: &str) -> bool { + match value { + Value::Object(values) => { + values.get("type").and_then(Value::as_str) == Some(expected) + || values.values().any(|value| contains_type(value, expected)) + } + Value::Array(values) => values.iter().any(|value| contains_type(value, expected)), + _ => false, + } +} + +fn find_string(value: &Value, keys: &[&str]) -> Option { + match value { + Value::Object(values) => { + for key in keys { + if let Some(value) = values.get(*key).and_then(Value::as_str) { + return Some(value.into()); + } + } + values.values().find_map(|value| find_string(value, keys)) + } + Value::Array(values) => values.iter().find_map(|value| find_string(value, keys)), + _ => None, + } +} + +fn find_value<'a>(value: &'a Value, keys: &[&str]) -> Option<&'a Value> { + match value { + Value::Object(values) => { + for key in keys { + if let Some(value) = values.get(*key) { + return Some(value); + } + } + values.values().find_map(|value| find_value(value, keys)) + } + Value::Array(values) => values.iter().find_map(|value| find_value(value, keys)), + _ => None, + } +} + +fn extract_event_text(value: &Value) -> Option { + match value { + Value::String(value) => Some(value.clone()), + Value::Array(values) => { + let text = values + .iter() + .filter_map(extract_event_text) + .filter(|value| !value.trim().is_empty()) + .collect::>() + .join(""); + (!text.is_empty()).then_some(text) + } + Value::Object(values) => { + for key in ["result", "text", "content"] { + if let Some(text) = values.get(key).and_then(extract_event_text) { + return Some(text); + } + } + for key in ["message", "event", "delta"] { + if let Some(text) = values.get(key).and_then(extract_event_text) { + return Some(text); + } + } + None + } + _ => None, + } +} + +fn is_final_event(raw_type: &str, raw: &Value) -> bool { + matches!(raw_type, "result" | "completed" | "complete" | "final") + || raw.get("stop_reason").is_some_and(|value| !value.is_null()) + || raw.pointer("/event/type").and_then(Value::as_str) == Some("message_stop") +} + +fn create_private_dir(path: &Path) -> Result<(), String> { + fs::create_dir_all(path) + .map_err(|error| format!("create agent trace directory {}: {error}", path.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o2770)).map_err(|error| { + format!( + "set agent trace directory permissions {}: {error}", + path.display() + ) + })?; + } + Ok(()) +} + +fn private_file(path: &Path) -> Result { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("create agent output directory: {error}"))?; + } + let file = File::create(path) + .map_err(|error| format!("create private agent file {}: {error}", path.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o660)).map_err(|error| { + format!( + "set private agent file permissions {}: {error}", + path.display() + ) + })?; + } + Ok(file) +} + +fn write_private(path: &Path, bytes: &[u8]) -> Result<(), String> { + let mut file = private_file(path)?; + file.write_all(bytes) + .map_err(|error| format!("write private agent file {}: {error}", path.display()))?; + file.sync_all() + .map_err(|error| format!("sync private agent file {}: {error}", path.display())) +} + +fn env_nonempty(key: &str) -> Option { + env::var(key).ok().filter(|value| !value.is_empty()) +} + +fn shell_escape(value: &str) -> String { + if !value.is_empty() + && value.chars().all(|character| { + character.is_ascii_alphanumeric() + || matches!( + character, + '_' | '@' | '%' | '+' | '=' | ':' | ',' | '.' | '/' | '-' + ) + }) + { + return value.into(); + } + format!("'{}'", value.replace(char::from(39), "'\\''")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn request(mode: InvocationMode) -> AgentRequest { + AgentRequest { + cwd: PathBuf::from("/tmp/project with spaces"), + prompt_file: Some(PathBuf::from("/tmp/prompt file")), + final_output: Some(PathBuf::from("/tmp/final message")), + access: RoleAccess::ReadOnly, + mode, + resume_session: None, + } + } + + #[test] + fn backend_names_are_strict() { + assert_eq!(BackendId::parse("qwen").unwrap(), BackendId::Qwen); + assert!(BackendId::parse("ollama").is_err()); + } + + #[test] + fn codex_headless_uses_argv_and_stdin() { + let paths = BackendPaths { + codex: "codex".into(), + claude: "claude".into(), + qwen: "qwen".into(), + }; + let command = backend(BackendId::Codex, &paths) + .command(&request(InvocationMode::Headless)) + .unwrap(); + assert_eq!(command.stdin_file, Some(PathBuf::from("/tmp/prompt file"))); + assert!(command.legacy_prompt_argument.is_none()); + assert!(command + .args + .iter() + .any(|arg| arg == "--output-last-message")); + assert!(!command.render_shell().contains("$(cat")); + } + + #[test] + fn codex_rejects_native_resume_and_preserves_interactive_compatibility() { + let paths = BackendPaths { + codex: "codex".into(), + claude: "claude".into(), + qwen: "qwen".into(), + }; + let selected = backend(BackendId::Codex, &paths); + let mut headless = request(InvocationMode::Headless); + headless.resume_session = Some("session-123".into()); + assert!(selected.command(&headless).is_err()); + assert!(!selected.capabilities().native_resume); + + let interactive = selected + .command(&request(InvocationMode::Interactive)) + .unwrap(); + assert!(interactive.stdin_file.is_none()); + assert_eq!( + interactive.legacy_prompt_argument, + Some(PathBuf::from("/tmp/prompt file")) + ); + assert!(interactive.args.iter().any(|arg| arg == "--no-alt-screen")); + } + + #[test] + fn claude_headless_uses_stream_json_stdin_and_native_resume() { + let paths = BackendPaths { + codex: "codex".into(), + claude: "claude".into(), + qwen: "qwen".into(), + }; + let mut value = request(InvocationMode::Headless); + value.resume_session = Some("session-123".into()); + let selected = backend(BackendId::Claude, &paths); + let command = selected.command(&value).unwrap(); + let args = command + .args + .iter() + .map(|value| value.to_string_lossy()) + .collect::>(); + assert_eq!(command.stdin_file, Some(PathBuf::from("/tmp/prompt file"))); + assert!(command.legacy_prompt_argument.is_none()); + assert!(args + .windows(2) + .any(|pair| pair == ["--output-format", "stream-json"])); + assert!(args + .windows(2) + .any(|pair| pair == ["--resume", "session-123"])); + assert!(args + .iter() + .any(|arg| arg == "--dangerously-skip-permissions")); + assert!(selected.capabilities().native_resume); + } + + #[test] + fn qwen_headless_declares_streaming_and_resume() { + let paths = BackendPaths { + codex: "codex".into(), + claude: "claude".into(), + qwen: "qwen".into(), + }; + let mut value = request(InvocationMode::Headless); + value.resume_session = Some("session-123".into()); + let selected = backend(BackendId::Qwen, &paths); + let command = selected.command(&value).unwrap(); + let args = command + .args + .iter() + .map(|value| value.to_string_lossy()) + .collect::>(); + assert!(args + .windows(2) + .any(|pair| pair == ["--output-format", "stream-json"])); + assert!(args + .windows(2) + .any(|pair| pair == ["--resume", "session-123"])); + assert!(args + .windows(2) + .any(|pair| pair == ["--approval-mode", "plan"])); + assert!(selected.capabilities().native_resume); + assert!(selected + .command(&request(InvocationMode::Interactive)) + .is_err()); + + let mut writer = request(InvocationMode::Headless); + writer.access = RoleAccess::WorkspaceWrite; + let writer_args = selected + .command(&writer) + .unwrap() + .args + .into_iter() + .map(|value| value.to_string_lossy().into_owned()) + .collect::>(); + assert!(writer_args + .windows(2) + .any(|pair| pair == ["--approval-mode", "yolo"])); + } + + #[test] + fn normalizes_json_and_plain_text_without_discarding_raw_events() { + let decoded = normalize_output( + BackendId::Qwen, + b"{\"type\":\"system\",\"session_id\":\"s-1\"}\n{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"done\"}]}}\n", + ); + assert_eq!(decoded.session_id.as_deref(), Some("s-1")); + assert_eq!(decoded.final_message.as_deref(), Some("done")); + assert_eq!(decoded.events.len(), 2); + assert_eq!(decoded.events[0].kind, "started"); + assert_eq!(decoded.events[1].kind, "text"); + + let plain = normalize_output(BackendId::Codex, b"plain final text\n"); + assert_eq!(plain.final_message.as_deref(), Some("plain final text")); + assert_eq!(plain.events[0].raw_type, "text"); + } + + #[test] + fn normalizes_tool_usage_and_completion_fields() { + let decoded = normalize_output( + BackendId::Qwen, + b"{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"tool_use\",\"id\":\"tool-1\",\"name\":\"shell\"}],\"usage\":{\"input_tokens\":12}}}\n{\"type\":\"result\",\"is_error\":false,\"result\":\"done\"}\n", + ); + assert_eq!(decoded.events[0].kind, "tool-started"); + assert_eq!(decoded.events[0].tool_id.as_deref(), Some("tool-1")); + assert_eq!(decoded.events[0].tool_name.as_deref(), Some("shell")); + assert_eq!( + decoded.events[0] + .usage + .as_ref() + .and_then(|value| value.get("input_tokens")) + .and_then(Value::as_u64), + Some(12) + ); + assert_eq!(decoded.events[1].kind, "completed"); + assert_eq!(decoded.events[1].success, Some(true)); + } + + #[test] + fn prompt_content_is_never_part_of_headless_command() { + let spec = CommandSpec { + program: "qwen".into(), + args: vec!["--output-format".into(), "stream-json".into()], + cwd: PathBuf::from("/tmp"), + stdin_file: Some(PathBuf::from("/tmp/prompt")), + legacy_prompt_argument: None, + }; + let rendered = spec.render_shell(); + assert_eq!(rendered, "qwen --output-format stream-json < /tmp/prompt"); + assert!(!rendered.contains("secret prompt contents")); + } +} diff --git a/src/main.rs b/src/main.rs index bd67b8a..c5a38af 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +mod agent; mod config; mod dag; mod decision; @@ -14,6 +15,7 @@ use std::process::ExitCode; const USAGE: &str = r#"Usage: multiagent dag COMMAND [ARGS...] + multiagent agent COMMAND [ARGS...] multiagent decision COMMAND [ARGS...] multiagent policy COMMAND [ARGS...] multiagent prompt-bundle [ARGS...] @@ -38,6 +40,7 @@ fn main() -> ExitCode { return ExitCode::from(1); } let result: Result = match command.as_str() { + "agent" => agent::run(&args).map_err(|message| ("agent", message)), "launch" => runtime::launch(&args).map_err(|message| ("launch", message)), "orchestrator" => runtime::orchestrator(&args).map_err(|message| ("orchestrator", message)), "status" => runtime::status(&args).map_err(|message| ("status", message)), diff --git a/src/runtime.rs b/src/runtime.rs index f84699d..810c05d 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -1,4 +1,7 @@ -use crate::{config, policy, role_sandbox}; +use crate::{ + agent::{self, AgentRequest, BackendId, BackendPaths, InvocationMode, RoleAccess}, + config, policy, role_sandbox, +}; use chrono::{Local, SecondsFormat, Utc}; use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, BTreeSet}; @@ -26,29 +29,18 @@ struct RuntimeConfig { verifier_cli: String, codex_bin: String, claude_bin: String, + qwen_bin: String, code_exec: bool, + agent_headless: bool, } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum CodexAccess { - ReadOnly, - WorkspaceWrite, -} +type CodexAccess = RoleAccess; const ORCHESTRATOR_UID: u32 = config::ORCHESTRATOR_UID; const WRITER_UID: u32 = 10002; const READER_UID: u32 = 10003; const ROLE_GID: u32 = 10001; -impl CodexAccess { - fn sandbox(self) -> &'static str { - match self { - Self::ReadOnly => "read-only", - Self::WorkspaceWrite => "workspace-write", - } - } -} - impl RuntimeConfig { fn load() -> Result { let root = config::root()?; @@ -75,7 +67,9 @@ impl RuntimeConfig { verifier_cli, codex_bin: env_nonempty("CODEX_BIN").unwrap_or_else(|| "codex".into()), claude_bin: env_nonempty("CLAUDE_BIN").unwrap_or_else(|| "claude".into()), + qwen_bin: env_nonempty("QWEN_BIN").unwrap_or_else(|| "qwen".into()), code_exec: env::var("MULTIAGENT_CODEX_EXEC").as_deref() == Ok("1"), + agent_headless: env::var("MULTIAGENT_AGENT_HEADLESS").as_deref() == Ok("1"), }) } @@ -83,11 +77,16 @@ impl RuntimeConfig { match cli { "codex" => Ok(&self.codex_bin), "claude" => Ok(&self.claude_bin), + "qwen" => Ok(&self.qwen_bin), _ => Err(format!( - "unsupported CLI '{cli}' (expected codex or claude)" + "unsupported coding-agent backend '{cli}' (expected codex, claude, or qwen)" )), } } + + fn headless(&self, cli: &str) -> bool { + cli == "qwen" || self.agent_headless || cli == "codex" && self.code_exec + } } pub fn role_agent_exec(args: &[String]) -> Result { @@ -106,23 +105,42 @@ pub fn role_agent_exec(args: &[String]) -> Result { } let cfg = RuntimeConfig::load()?; - if !cfg.code_exec { - return Err("role-agent-exec requires MULTIAGENT_CODEX_EXEC=1".into()); - } let dir = cfg.state.join("subagents").join(name); let metadata = read_env(&dir.join("meta.env"))?; + let cli = metadata + .get("cli") + .filter(|value| !value.is_empty()) + .ok_or_else(|| "role-agent-exec metadata is missing the backend".to_string())?; + validate_cli(cli)?; + if !cfg.headless(cli) { + return Err("role-agent-exec requires a headless coding-agent backend".into()); + } + let configured_binary = cfg.cli_bin(cli)?; if metadata.get("name").map(String::as_str) != Some(name) - || metadata.get("cli").map(String::as_str) != Some("codex") - || metadata.get("cli_bin").map(String::as_str) != Some(cfg.codex_bin.as_str()) + || metadata.get("cli_bin").map(String::as_str) != Some(configured_binary) { - return Err("role-agent-exec metadata does not match the requested Codex agent".into()); + return Err("role-agent-exec metadata does not match the requested coding agent".into()); } - let access = match metadata.get("codex_access").map(String::as_str) { + let access = match metadata + .get("access") + .or_else(|| metadata.get("codex_access")) + .map(String::as_str) + { Some("read-only") => CodexAccess::ReadOnly, Some("workspace-write") => CodexAccess::WorkspaceWrite, - _ => return Err("role-agent-exec metadata has invalid codex_access".into()), + _ => return Err("role-agent-exec metadata has invalid role access".into()), }; - validate_privileged_codex_bridge(Path::new(&cfg.codex_bin))?; + let trusted_binary = resolve_command_path(configured_binary)?; + validate_privileged_agent_binary(&trusted_binary)?; + env::set_var( + match cli.as_str() { + "codex" => "CODEX_BIN", + "claude" => "CLAUDE_BIN", + "qwen" => "QWEN_BIN", + _ => unreachable!("validated backend"), + }, + &trusted_binary, + ); let prompt = dir.join(if restored { "restore-instruction.txt" } else { @@ -142,16 +160,20 @@ pub fn role_agent_exec(args: &[String]) -> Result { validate_implementation_context(&cfg, name, Some(&prompt), &instruction)?; } let output = dir.join("last-message.txt"); - let command = build_cli_command( - "codex", + let trace_dir = cfg.logs.join("agents").join(name); + let resume_session = restored + .then(|| native_resume_session(&trace_dir)) + .flatten(); + let executable = env::current_exe().map_err(io_error("resolve multiagent executable"))?; + let runner_args = build_agent_runner_args( + cli, &cfg.root, - Some(&prompt), - Some(&output), - &cfg.codex_bin, - &cfg.claude_bin, - true, + &prompt, + &output, + &trace_dir, access, - )?; + resume_session.as_deref(), + ); let supervisor_pid = dir.join("supervisor.pid"); atomic_write( &supervisor_pid, @@ -165,29 +187,51 @@ pub fn role_agent_exec(args: &[String]) -> Result { READER_UID }, ROLE_GID, - "/bin/sh", - &["-c".into(), command], + &executable.display().to_string(), + &runner_args, ); let _ = fs::remove_file(supervisor_pid); result } #[cfg(unix)] -fn validate_privileged_codex_bridge(path: &Path) -> Result<(), String> { +fn validate_privileged_agent_binary(path: &Path) -> Result<(), String> { use std::os::unix::fs::{MetadataExt, PermissionsExt}; - let metadata = fs::metadata(path).map_err(io_error("inspect privileged Codex bridge"))?; + let canonical = + fs::canonicalize(path).map_err(io_error("resolve privileged coding-agent binary"))?; + let metadata = + fs::metadata(&canonical).map_err(io_error("inspect privileged coding-agent binary"))?; if !metadata.is_file() || metadata.uid() != 0 || metadata.permissions().mode() & 0o022 != 0 { return Err(format!( - "privileged Codex bridge must be a root-owned, non-group-writable executable: {}", - path.display() + "privileged coding-agent binary must be a root-owned, non-group-writable executable: {}", + canonical.display() )); } + let mut parent = canonical.parent(); + while let Some(path) = parent { + let metadata = + fs::metadata(path).map_err(io_error("inspect coding-agent binary parent"))?; + if !metadata.is_dir() + || !privileged_agent_parent_mode_is_safe(metadata.uid(), metadata.permissions().mode()) + { + return Err(format!( + "privileged coding-agent binary parent must be root-owned and either non-writable or sticky: {}", + path.display() + )); + } + parent = path.parent(); + } Ok(()) } +#[cfg(unix)] +fn privileged_agent_parent_mode_is_safe(uid: u32, mode: u32) -> bool { + uid == 0 && (mode & 0o022 == 0 || mode & 0o1000 != 0) +} + #[cfg(not(unix))] -fn validate_privileged_codex_bridge(_path: &Path) -> Result<(), String> { +fn validate_privileged_agent_binary(_path: &Path) -> Result<(), String> { Err("role-agent-exec requires Unix".into()) } @@ -254,6 +298,11 @@ pub fn launch(args: &[String]) -> Result { } let codex_bin = env_nonempty("CODEX_BIN").unwrap_or_else(|| "codex".into()); let claude_bin = env_nonempty("CLAUDE_BIN").unwrap_or_else(|| "claude".into()); + let qwen_bin = env_nonempty("QWEN_BIN").unwrap_or_else(|| "qwen".into()); + let agent_headless = env_nonempty("MULTIAGENT_AGENT_HEADLESS").unwrap_or_else(|| "0".into()); + if !matches!(agent_headless.as_str(), "0" | "1") { + return Err("MULTIAGENT_AGENT_HEADLESS must be 0 or 1".into()); + } let verifier_max = env_nonempty("MULTIAGENT_VERIFIER_MAX_ITERATIONS").unwrap_or_else(|| "3".into()); if verifier_max @@ -281,12 +330,19 @@ pub fn launch(args: &[String]) -> Result { state_dir.join("runtime_state/tmux.sock"), ); } - let orchestrator_bin = if orchestrator_cli == "codex" { - &codex_bin - } else { - &claude_bin + let backend_paths = BackendPaths { + codex: codex_bin.clone(), + claude: claude_bin.clone(), + qwen: qwen_bin.clone(), }; - require_command(orchestrator_bin)?; + let mut backend_versions = Vec::new(); + let mut selected_backends = BTreeSet::new(); + for name in [&orchestrator_cli, &worker_cli, &subagent_cli, &verifier_cli] { + if selected_backends.insert(name.clone()) { + let id = BackendId::parse(name)?; + backend_versions.push(agent::backend(id, &backend_paths).preflight()?); + } + } if !prompt.is_file() { return Err(format!("missing orchestrator prompt: {}", prompt.display())); } @@ -353,6 +409,8 @@ pub fn launch(args: &[String]) -> Result { &verifier_cli, &codex_bin, &claude_bin, + &qwen_bin, + &agent_headless, &executable, ); for (key, value) in &shared_env { @@ -386,6 +444,20 @@ pub fn launch(args: &[String]) -> Result { &format!("{workflow_id}\n"), "active workflow", )?; + let mut backend_manifest = String::from("backend\texecutable\tversion\n"); + for version in &backend_versions { + backend_manifest.push_str(&format!( + "{}\t{}\t{}\n", + version.backend.as_str(), + version.executable.replace(['\t', '\n'], " "), + version.version.replace(['\t', '\n'], " ") + )); + } + atomic_write( + &state_dir.join("runtime_state/agent-backends.tsv"), + &backend_manifest, + "coding-agent backend manifest", + )?; let bootstrap = state_dir.join("orchestrator-bootstrap.sh"); let mut bootstrap_env = shared_env.clone(); @@ -400,12 +472,16 @@ pub fn launch(args: &[String]) -> Result { &orchestrator_cli, &codex_bin, &claude_bin, + &qwen_bin, &prompt_bundle, &state_dir.join("orchestrator-last-message.txt"), resume, )?; if env::var("MULTIAGENT_UID_SANDBOX").as_deref() == Ok("1") { prepare_uid_state_permissions(&state_dir)?; + if !log_dir.starts_with(&state_dir) { + prepare_uid_state_permissions(&log_dir)?; + } } let bootstrap_command = format!("bash {}", shell_escape(&bootstrap.display().to_string())); let new_session = [ @@ -450,6 +526,7 @@ pub fn launch(args: &[String]) -> Result { println!("Worker CLI: {worker_cli}"); println!("Subagent CLI: {subagent_cli}"); println!("Verifier CLI: {verifier_cli}"); + println!("Agent headless mode: {agent_headless}"); println!("Write policy:"); policy::run(&["show".into()])?; if attach { @@ -485,6 +562,8 @@ fn launch_environment( verifier_cli: &str, codex_bin: &str, claude_bin: &str, + qwen_bin: &str, + agent_headless: &str, executable: &Path, ) -> BTreeMap { let mut values = BTreeMap::new(); @@ -524,6 +603,28 @@ fn launch_environment( ("VERIFIER_CLI", verifier_cli.to_string()), ("CODEX_BIN", codex_bin.to_string()), ("CLAUDE_BIN", claude_bin.to_string()), + ("QWEN_BIN", qwen_bin.to_string()), + ("MULTIAGENT_AGENT_HEADLESS", agent_headless.to_string()), + ( + "MULTIAGENT_NATIVE_RESUME", + env_nonempty("MULTIAGENT_NATIVE_RESUME").unwrap_or_else(|| "0".into()), + ), + ( + "MULTIAGENT_AGENT_MAX_TURNS", + env_nonempty("MULTIAGENT_AGENT_MAX_TURNS").unwrap_or_default(), + ), + ( + "MULTIAGENT_AGENT_MAX_WALL_TIME", + env_nonempty("MULTIAGENT_AGENT_MAX_WALL_TIME").unwrap_or_default(), + ), + ( + "MULTIAGENT_AGENT_MAX_TOOL_CALLS", + env_nonempty("MULTIAGENT_AGENT_MAX_TOOL_CALLS").unwrap_or_default(), + ), + ( + "MULTIAGENT_AGENT_TIMEOUT_SECONDS", + env_nonempty("MULTIAGENT_AGENT_TIMEOUT_SECONDS").unwrap_or_default(), + ), ( "MULTIAGENT_CODEX_EXEC", env_nonempty("MULTIAGENT_CODEX_EXEC").unwrap_or_else(|| "0".into()), @@ -568,6 +669,7 @@ fn write_bootstrap( cli: &str, codex_bin: &str, claude_bin: &str, + qwen_bin: &str, prompt: &Path, last_message: &Path, resume: bool, @@ -600,19 +702,52 @@ fn write_bootstrap( u8::from(resume), if resume { "resume" } else { "clean" } )); - let command = build_cli_command( - cli, - environment - .get("MULTIAGENT_STATE_DIR") - .map(Path::new) - .unwrap_or(root), - Some(prompt), - Some(last_message), - codex_bin, - claude_bin, - env::var("MULTIAGENT_CODEX_EXEC").as_deref() == Ok("1"), - CodexAccess::WorkspaceWrite, - )?; + let cwd = environment + .get("MULTIAGENT_STATE_DIR") + .map(Path::new) + .unwrap_or(root); + let codex_exec = environment.get("MULTIAGENT_CODEX_EXEC").map(String::as_str) == Some("1"); + let agent_headless = environment + .get("MULTIAGENT_AGENT_HEADLESS") + .map(String::as_str) + == Some("1"); + let headless = cli == "qwen" || agent_headless || cli == "codex" && codex_exec; + let command = if headless { + let executable = Path::new( + environment + .get("MULTIAGENT_BIN") + .ok_or_else(|| "missing MULTIAGENT_BIN in launch environment".to_string())?, + ); + let trace_dir = Path::new( + environment + .get("MULTIAGENT_LOG_DIR") + .ok_or_else(|| "missing MULTIAGENT_LOG_DIR in launch environment".to_string())?, + ) + .join("agents/orchestrator"); + build_agent_runner_command( + executable, + cli, + cwd, + prompt, + last_message, + &trace_dir, + CodexAccess::WorkspaceWrite, + None, + ) + } else { + build_cli_command( + cli, + cwd, + Some(prompt), + Some(last_message), + codex_bin, + claude_bin, + qwen_bin, + codex_exec, + agent_headless, + CodexAccess::WorkspaceWrite, + )? + }; let command = if environment .get("MULTIAGENT_UID_SANDBOX") .map(String::as_str) @@ -1093,9 +1228,14 @@ fn spawn(cfg: &RuntimeConfig, args: &[String]) -> Result<(), String> { } instruction = fs::read_to_string(path).map_err(io_error("read instruction file"))?; } - if cfg.code_exec && cfg.subagent_cli == "codex" && instruction.is_empty() { + if cfg.headless(&cfg.subagent_cli) && instruction.is_empty() { + let label = if cfg.subagent_cli == "codex" && cfg.code_exec { + "codex exec" + } else { + "headless coding-agent" + }; return Err(format!( - "codex exec subagent spawn requires --instruction or --instruction-file: {name}" + "{label} subagent spawn requires --instruction or --instruction-file: {name}" )); } instruction = compose_role_instruction(cfg, name, &role, &instruction)?; @@ -1155,18 +1295,21 @@ fn spawn(cfg: &RuntimeConfig, args: &[String]) -> Result<(), String> { validate_implementation_context(cfg, name, instruction_file.as_deref(), &instruction)?; let dir = cfg.state.join("subagents").join(name); + let trace_dir = cfg.logs.join("agents").join(name); fs::create_dir_all(&dir).map_err(io_error("create subagent state"))?; fs::create_dir_all(&cfg.logs).map_err(io_error("create subagent log directory"))?; let executable = env::current_exe().map_err(io_error("resolve multiagent executable"))?; let metadata = format!( - "name={name}\nsession={}\nroot={}\nrole={}\ncodex_access={}\nworkflow_id={}\nwrite_policy={}\nlog_file={}\ncli={cli}\ncli_bin={binary}\nhelper={}\ncreated_at={}\n", + "name={name}\nsession={}\nroot={}\nrole={}\naccess={}\ncodex_access={}\nworkflow_id={}\nwrite_policy={}\nlog_file={}\ntrace_dir={}\ncli={cli}\ncli_bin={binary}\nhelper={}\ncreated_at={}\n", cfg.session, cfg.root.display(), if role.is_empty() { assignment_role } else { &role }, - access.sandbox(), + access.as_str(), + access.as_str(), env_nonempty("MULTIAGENT_WORKFLOW_ID").unwrap_or_default(), cfg.policy.display(), cfg.logs.join(format!("{name}.log")).display(), + trace_dir.display(), executable.display(), timestamp() ); @@ -1175,9 +1318,13 @@ fn spawn(cfg: &RuntimeConfig, args: &[String]) -> Result<(), String> { let mut prompt_file = None; let output_file = dir.join("last-message.txt"); - if cfg.code_exec && cli == "codex" && !instruction.is_empty() { + if cfg.headless(cli) && !instruction.is_empty() { let path = dir.join("instruction.txt"); - let prompt = format!("{}{}\n", codex_exec_protocol_prelude(), instruction); + let prompt = if cli == "codex" { + format!("{}{}\n", codex_exec_protocol_prelude(), instruction) + } else { + format!("{instruction}\n") + }; atomic_write(&path, &prompt, "subagent instruction")?; append_file( &dir.join("transcript.log"), @@ -1186,8 +1333,8 @@ fn spawn(cfg: &RuntimeConfig, args: &[String]) -> Result<(), String> { prompt_file = Some(path); } let cli_command = if env::var("MULTIAGENT_UID_SANDBOX").as_deref() == Ok("1") { - if cli != "codex" || !cfg.code_exec { - return Err("UID role isolation requires codex exec subagents".into()); + if !cfg.headless(cli) { + return Err("UID role isolation requires a headless coding-agent backend".into()); } format!( "{} role-agent-exec {}", @@ -1195,16 +1342,33 @@ fn spawn(cfg: &RuntimeConfig, args: &[String]) -> Result<(), String> { shell_escape(name) ) } else { - let command = build_cli_command( - cli, - &cfg.root, - prompt_file.as_deref(), - Some(&output_file), - &cfg.codex_bin, - &cfg.claude_bin, - cfg.code_exec, - access, - )?; + let command = if cfg.headless(cli) { + build_agent_runner_command( + &executable, + cli, + &cfg.root, + prompt_file + .as_deref() + .ok_or_else(|| format!("headless coding-agent prompt is missing: {name}"))?, + &output_file, + &trace_dir, + access, + None, + ) + } else { + build_cli_command( + cli, + &cfg.root, + prompt_file.as_deref(), + Some(&output_file), + &cfg.codex_bin, + &cfg.claude_bin, + &cfg.qwen_bin, + cfg.code_exec, + cfg.agent_headless, + access, + )? + }; wrap_linux_role_sandbox( &command, &executable, @@ -1230,7 +1394,7 @@ fn spawn(cfg: &RuntimeConfig, args: &[String]) -> Result<(), String> { run_self_quiet(&["subagent", "assignment-status", name, "running"])?; } let _ = capture_subagent(cfg, name); - if !(instruction.is_empty() || cfg.code_exec && cli == "codex") { + if !(instruction.is_empty() || cfg.headless(cli)) { deliver_instruction(cfg, name, &instruction)?; } println!("spawned {name}"); @@ -1499,7 +1663,11 @@ fn restore(cfg: &RuntimeConfig, args: &[String]) -> Result<(), String> { .cloned() .unwrap_or_else(|| cfg.subagent_cli.clone()); validate_cli(&cli)?; - let access = match metadata.get("codex_access").map(String::as_str) { + let access = match metadata + .get("access") + .or_else(|| metadata.get("codex_access")) + .map(String::as_str) + { Some("read-only") => CodexAccess::ReadOnly, _ => CodexAccess::WorkspaceWrite, }; @@ -1555,17 +1723,18 @@ fn restore(cfg: &RuntimeConfig, args: &[String]) -> Result<(), String> { set_subagent_status(cfg, name, "restoring")?; fs::create_dir_all(&cfg.logs).map_err(io_error("create log directory"))?; let output_file = dir.join("last-message.txt"); - let prompt_file = if cfg.code_exec && cli == "codex" { + let prompt_file = if cfg.headless(&cli) { let path = dir.join("restore-instruction.txt"); atomic_write(&path, &instruction, "restore instruction")?; Some(path) } else { None }; + let trace_dir = cfg.logs.join("agents").join(name); let executable = env::current_exe().map_err(io_error("resolve multiagent executable"))?; let cli_command = if env::var("MULTIAGENT_UID_SANDBOX").as_deref() == Ok("1") { - if cli != "codex" || !cfg.code_exec { - return Err("UID role isolation requires codex exec subagents".into()); + if !cfg.headless(&cli) { + return Err("UID role isolation requires a headless coding-agent backend".into()); } format!( "{} role-agent-exec {} --restore", @@ -1573,16 +1742,34 @@ fn restore(cfg: &RuntimeConfig, args: &[String]) -> Result<(), String> { shell_escape(name) ) } else { - let command = build_cli_command( - &cli, - &cfg.root, - prompt_file.as_deref(), - Some(&output_file), - &cfg.codex_bin, - &cfg.claude_bin, - cfg.code_exec, - access, - )?; + let resume_session = native_resume_session(&trace_dir); + let command = if cfg.headless(&cli) { + build_agent_runner_command( + &executable, + &cli, + &cfg.root, + prompt_file.as_deref().ok_or_else(|| { + format!("headless coding-agent restore prompt is missing: {name}") + })?, + &output_file, + &trace_dir, + access, + resume_session.as_deref(), + ) + } else { + build_cli_command( + &cli, + &cfg.root, + prompt_file.as_deref(), + Some(&output_file), + &cfg.codex_bin, + &cfg.claude_bin, + &cfg.qwen_bin, + cfg.code_exec, + cfg.agent_headless, + access, + )? + }; wrap_linux_role_sandbox( &command, &executable, @@ -1598,7 +1785,7 @@ fn restore(cfg: &RuntimeConfig, args: &[String]) -> Result<(), String> { tmux_checked(&["new-window", "-d", "-t", &cfg.session, "-n", name, &command])?; pipe_log(&cfg.session, name, &cfg.logs)?; set_subagent_status(cfg, name, "running")?; - if !(cfg.code_exec && cli == "codex") { + if !cfg.headless(&cli) { deliver_instruction(cfg, name, &instruction)?; } println!("restored {name}"); @@ -1673,6 +1860,7 @@ fn kill(cfg: &RuntimeConfig, args: &[String]) -> Result<(), String> { if let Some(pid) = supervisor_pid { wait_for_process_exit(pid, name)?; } + record_supervisor_termination(cfg, name, "canceled")?; set_subagent_status(cfg, name, "killed")?; if cfg .state @@ -1687,6 +1875,38 @@ fn kill(cfg: &RuntimeConfig, args: &[String]) -> Result<(), String> { Ok(()) } +fn record_supervisor_termination( + cfg: &RuntimeConfig, + name: &str, + reason: &str, +) -> Result<(), String> { + let dir = cfg.state.join("subagents").join(name); + let metadata = read_env(&dir.join("meta.env")).unwrap_or_default(); + let trace_dir = metadata + .get("trace_dir") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| cfg.logs.join("agents").join(name)); + let body = serde_json::to_string_pretty(&serde_json::json!({ + "reason": reason, + "recorded_at": timestamp(), + "source": "rust-supervisor", + })) + .map_err(|error| format!("serialize supervisor termination: {error}"))?; + fs::create_dir_all(&trace_dir).map_err(io_error("create supervisor trace directory"))?; + let output = trace_dir.join("supervisor-termination.json"); + atomic_write(&output, &body, "supervisor termination")?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&trace_dir, fs::Permissions::from_mode(0o2770)) + .map_err(io_error("set supervisor trace directory permissions"))?; + fs::set_permissions(&output, fs::Permissions::from_mode(0o660)) + .map_err(io_error("set supervisor trace file permissions"))?; + } + Ok(()) +} + fn read_supervisor_pid(cfg: &RuntimeConfig, name: &str) -> Option { read_trimmed( &cfg.state @@ -1967,81 +2187,97 @@ fn build_cli_command( output: Option<&Path>, codex_bin: &str, claude_bin: &str, + qwen_bin: &str, codex_exec: bool, + agent_headless: bool, access: CodexAccess, ) -> Result { - match cli { - "codex" if codex_exec => { - let mut command = format!( - "{} exec --cd {} --skip-git-repo-check {}", - shell_escape(codex_bin), - shell_escape(&cwd.display().to_string()), - codex_safety_args(access, true), - ); - if let Some(path) = output { - command.push_str(&format!( - " --output-last-message {}", - shell_escape(&path.display().to_string()) - )); - } - if let Some(path) = prompt { - command.push_str(&format!( - " - < {}", - shell_escape(&path.display().to_string()) - )); - } - Ok(command) - } - "codex" => { - let mut command = format!( - "{} --cd {} {} --no-alt-screen", - shell_escape(codex_bin), - shell_escape(&cwd.display().to_string()), - codex_safety_args(access, false), - ); - if let Some(path) = prompt { - command.push_str(&format!( - " \"$(cat {})\"", - shell_escape(&path.display().to_string()) - )); - } - Ok(command) - } - "claude" => { - let mut command = format!( - "{} --dangerously-skip-permissions", - shell_escape(claude_bin) - ); - if let Some(path) = prompt { - command.push_str(&format!( - " \"$(cat {})\"", - shell_escape(&path.display().to_string()) - )); - } - Ok(command) - } - _ => Err(format!( - "unsupported CLI '{cli}' (expected codex or claude)" - )), + let id = BackendId::parse(cli)?; + let paths = BackendPaths { + codex: codex_bin.into(), + claude: claude_bin.into(), + qwen: qwen_bin.into(), + }; + let selected = agent::backend(id, &paths); + let mode = if cli == "qwen" || agent_headless || cli == "codex" && codex_exec { + InvocationMode::Headless + } else { + InvocationMode::Interactive + }; + selected + .command(&AgentRequest { + cwd: cwd.to_path_buf(), + prompt_file: prompt.map(Path::to_path_buf), + final_output: output.map(Path::to_path_buf), + access, + mode, + resume_session: None, + }) + .map(|command| command.render_shell()) +} + +#[allow(clippy::too_many_arguments)] +fn build_agent_runner_args( + cli: &str, + cwd: &Path, + prompt: &Path, + output: &Path, + trace_dir: &Path, + access: CodexAccess, + resume_session: Option<&str>, +) -> Vec { + let mut args = vec![ + "agent".into(), + "run".into(), + "--backend".into(), + cli.into(), + "--cwd".into(), + cwd.display().to_string(), + "--prompt-file".into(), + prompt.display().to_string(), + "--final-output".into(), + output.display().to_string(), + "--trace-dir".into(), + trace_dir.display().to_string(), + "--access".into(), + access.as_str().into(), + ]; + if let Some(session) = resume_session { + args.push("--resume-session".into()); + args.push(session.into()); } + args } -#[cfg(target_os = "linux")] -fn codex_safety_args(_access: CodexAccess, _exec: bool) -> String { - // Docker's default seccomp profile blocks the user namespaces required by - // Codex/bubblewrap. The enclosing role-exec Landlock boundary is inherited - // by Codex and every model-generated child process, so Codex itself must not - // attempt a second sandbox. - "--dangerously-bypass-approvals-and-sandbox".into() +#[allow(clippy::too_many_arguments)] +fn build_agent_runner_command( + executable: &Path, + cli: &str, + cwd: &Path, + prompt: &Path, + output: &Path, + trace_dir: &Path, + access: CodexAccess, + resume_session: Option<&str>, +) -> String { + let mut command = shell_escape(&executable.display().to_string()); + for arg in build_agent_runner_args(cli, cwd, prompt, output, trace_dir, access, resume_session) + { + command.push(' '); + command.push_str(&shell_escape(&arg)); + } + command } -#[cfg(not(target_os = "linux"))] -fn codex_safety_args(access: CodexAccess, exec: bool) -> String { - if exec { - format!("--sandbox {} -c approval_policy=never", access.sandbox()) - } else { - format!("--sandbox {} --ask-for-approval never", access.sandbox()) +fn native_resume_session(trace_dir: &Path) -> Option { + if env::var("MULTIAGENT_NATIVE_RESUME").as_deref() != Ok("1") { + return None; } + let latest = read_trimmed(&trace_dir.join("latest")) + .filter(|value| !value.is_empty()) + .map(|value| trace_dir.join(value)) + .unwrap_or_else(|| trace_dir.to_path_buf()); + read_trimmed(&latest.join("session-id")).filter(|value| !value.is_empty()) } fn role_write_roots(root: &Path, state: &Path, include_source: bool) -> Vec { @@ -2053,6 +2289,7 @@ fn role_write_roots(root: &Path, state: &Path, include_source: bool) -> Vec>() .join(" "); + let final_marker = if cli == "codex" { + "final status: codex exec exited rc=%s" + } else { + "final status: coding agent exited rc=%s" + }; format!( - "cd {} && umask 0007 && export {exports} && {cli_command}; rc=$?; printf '\\nfinal status: codex exec exited rc=%s\\n' $rc; sleep infinity", + "cd {} && umask 0007 && export {exports} && {cli_command}; rc=$?; printf '\\n{final_marker}\\n' $rc; sleep infinity", shell_escape(&cfg.root.display().to_string()) ) } @@ -2476,14 +2743,19 @@ fn looks_done_report(text: &str) -> bool { } fn nonzero_exec_status(text: &str) -> bool { - let marker = "final status: codex exec exited rc="; + let markers = [ + "final status: codex exec exited rc=", + "final status: coding agent exited rc=", + ]; text.lines().any(|line| { - line.find(marker).is_some_and(|index| { - line[index + marker.len()..] - .split_whitespace() - .next() - .and_then(|value| value.parse::().ok()) - .is_some_and(|value| value > 0) + markers.iter().any(|marker| { + line.find(marker).is_some_and(|index| { + line[index + marker.len()..] + .split_whitespace() + .next() + .and_then(|value| value.parse::().ok()) + .is_some_and(|value| value > 0) + }) }) }) } @@ -2659,25 +2931,27 @@ fn run_self_quiet(args: &[&str]) -> Result<(), String> { } fn validate_cli(value: &str) -> Result<(), String> { - if matches!(value, "codex" | "claude") { - Ok(()) - } else { - Err(format!( - "unsupported CLI '{value}' (expected codex or claude)" - )) - } + BackendId::parse(value).map(|_| ()) } fn require_command(command: &str) -> Result<(), String> { + resolve_command_path(command).map(|_| ()) +} + +fn resolve_command_path(command: &str) -> Result { let path = Path::new(command); if command.contains('/') { if is_executable(path) { - return Ok(()); + return fs::canonicalize(path) + .map_err(|error| format!("resolve required command {command}: {error}")); } } else if let Some(paths) = env::var_os("PATH") { for directory in env::split_paths(&paths) { - if is_executable(&directory.join(command)) { - return Ok(()); + let candidate = directory.join(command); + if is_executable(&candidate) { + return fs::canonicalize(&candidate).map_err(|error| { + format!("resolve required command {}: {error}", candidate.display()) + }); } } } @@ -2991,6 +3265,16 @@ mod tests { assert_eq!(shell_escape("it's"), "'it'\\''s'"); } + #[cfg(unix)] + #[test] + fn privileged_agent_parent_accepts_only_root_owned_safe_modes() { + assert!(privileged_agent_parent_mode_is_safe(0, 0o040755)); + assert!(privileged_agent_parent_mode_is_safe(0, 0o041777)); + assert!(!privileged_agent_parent_mode_is_safe(0, 0o040777)); + assert!(!privileged_agent_parent_mode_is_safe(1000, 0o040755)); + assert!(!privileged_agent_parent_mode_is_safe(1000, 0o041777)); + } + #[test] fn status_classification_prioritizes_blockers() { assert_eq!( diff --git a/tests/live-qwen-smoke.sh b/tests/live-qwen-smoke.sh new file mode 100755 index 0000000..1ad86a4 --- /dev/null +++ b/tests/live-qwen-smoke.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +MULTIAGENT="${MULTIAGENT_BIN:-$ROOT/target/debug/multiagent}" +QWEN_BIN="${QWEN_BIN:-qwen}" + +if ! command -v "$QWEN_BIN" >/dev/null 2>&1; then + echo "Qwen Code executable not found: $QWEN_BIN" >&2 + exit 2 +fi + +if [[ ! -x "$MULTIAGENT" ]]; then + cargo build --manifest-path "$ROOT/Cargo.toml" +fi + +SMOKE_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/multiagent-qwen-smoke.XXXXXX")" +trap 'rm -rf "$SMOKE_ROOT"' EXIT +WORKSPACE="$SMOKE_ROOT/workspace" +mkdir -p "$WORKSPACE" +printf 'immutable fixture\n' >"$WORKSPACE/input.txt" +BEFORE="$(shasum -a 256 "$WORKSPACE/input.txt" | awk '{print $1}')" + +printf '%s\n' \ + 'Read input.txt. Do not modify any file. Reply with exactly READ_ONLY_OK.' \ + >"$SMOKE_ROOT/read-only.prompt" +QWEN_BIN="$QWEN_BIN" \ +MULTIAGENT_AGENT_TIMEOUT_SECONDS="${MULTIAGENT_AGENT_TIMEOUT_SECONDS:-300}" \ + "$MULTIAGENT" agent run \ + --backend qwen \ + --cwd "$WORKSPACE" \ + --prompt-file "$SMOKE_ROOT/read-only.prompt" \ + --final-output "$SMOKE_ROOT/read-only.final" \ + --trace-dir "$SMOKE_ROOT/traces/read-only" \ + --access read-only + +AFTER="$(shasum -a 256 "$WORKSPACE/input.txt" | awk '{print $1}')" +[[ "$BEFORE" == "$AFTER" ]] +grep -Fq 'READ_ONLY_OK' "$SMOKE_ROOT/read-only.final" + +printf '%s\n' \ + 'Create output.txt with exactly the single line WRITE_OK, then reply with exactly WRITE_DONE.' \ + >"$SMOKE_ROOT/write.prompt" +QWEN_BIN="$QWEN_BIN" \ +MULTIAGENT_AGENT_TIMEOUT_SECONDS="${MULTIAGENT_AGENT_TIMEOUT_SECONDS:-300}" \ + "$MULTIAGENT" agent run \ + --backend qwen \ + --cwd "$WORKSPACE" \ + --prompt-file "$SMOKE_ROOT/write.prompt" \ + --final-output "$SMOKE_ROOT/write.final" \ + --trace-dir "$SMOKE_ROOT/traces/write" \ + --access workspace-write + +[[ "$(cat "$WORKSPACE/output.txt")" == "WRITE_OK" ]] +grep -Fq 'WRITE_DONE' "$SMOKE_ROOT/write.final" +echo "Qwen Code live read-only and workspace-write smoke passed" diff --git a/tests/run.sh b/tests/run.sh index c85ff4f..9833c03 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -180,6 +180,37 @@ esac TMUX chmod +x "$MOCK_BIN/tmux" +cat >"$MOCK_BIN/qwen" <<'QWEN' +#!/usr/bin/env bash +set -euo pipefail +if [[ " ${*:-} " == *" --version "* ]]; then + printf 'qwen-code test-1.0\n' + exit 0 +fi +prompt="$(cat)" +if [[ -n "${QWEN_PROMPT_CAPTURE:-}" ]]; then + printf '%s' "$prompt" >"$QWEN_PROMPT_CAPTURE" +fi +if [[ -n "${QWEN_TRY_WRITE:-}" ]]; then + printf 'unauthorized\n' >"$QWEN_TRY_WRITE" +fi +if [[ -n "${QWEN_DESCENDANT_PID_FILE:-}" ]]; then + sleep 30 & + descendant_pid=$! + printf '%s\n' "$descendant_pid" >"$QWEN_DESCENDANT_PID_FILE" +fi +if [[ -n "${QWEN_SLEEP_SECONDS:-}" ]]; then + sleep "$QWEN_SLEEP_SECONDS" +fi +printf '%s\n' '{"type":"system","session_id":"qwen-session-1"}' +printf '%s\n' 'malformed provider line retained as raw text' +printf '%s\n' '{"type":"assistant","message":{"content":[{"type":"text","text":"Qwen working"}]}}' +printf '%s\n' '{"type":"result","result":"Qwen final result"}' +printf 'qwen diagnostic\n' >&2 +exit "${QWEN_EXIT_CODE:-0}" +QWEN +chmod +x "$MOCK_BIN/qwen" + export PATH="$MOCK_BIN:$PATH" export MOCK_TMUX_WINDOWS="$TMPDIR/windows" export MOCK_TMUX_CAPTURES="$TMPDIR/captures" @@ -192,6 +223,7 @@ export MULTIAGENT_READY_ATTEMPTS=1 export MULTIAGENT_READY_DELAY=0 export CODEX_BIN="true" export CLAUDE_BIN="true" +export QWEN_BIN="$MOCK_BIN/qwen" export ORCHESTRATOR_CLI="codex" export WORKER_CLI="claude" export SUBAGENT_CLI="claude" @@ -213,6 +245,121 @@ assert_file_contains() { fi } +AGENT_RUN_DIR="$TMPDIR/agent-run" +mkdir -p "$AGENT_RUN_DIR/work" +printf 'prompt payload with spaces and '\''quotes'\''\n' >"$AGENT_RUN_DIR/prompt.txt" +QWEN_PROMPT_CAPTURE="$AGENT_RUN_DIR/prompt-captured.txt" \ + "$MULTIAGENT" agent run \ + --backend qwen \ + --cwd "$AGENT_RUN_DIR/work" \ + --prompt-file "$AGENT_RUN_DIR/prompt.txt" \ + --final-output "$AGENT_RUN_DIR/final.txt" \ + --trace-dir "$AGENT_RUN_DIR/trace" \ + --access read-only >"$AGENT_RUN_DIR/forwarded.out" 2>"$AGENT_RUN_DIR/forwarded.err" +AGENT_TRACE="$AGENT_RUN_DIR/trace/$(tr -d '\r\n' <"$AGENT_RUN_DIR/trace/latest")" +assert_file_contains "$AGENT_RUN_DIR/prompt-captured.txt" "prompt payload with spaces and 'quotes'" +assert_file_contains "$AGENT_RUN_DIR/final.txt" "Qwen final result" +assert_file_contains "$AGENT_TRACE/raw-stdout.log" "malformed provider line retained as raw text" +assert_file_contains "$AGENT_TRACE/raw-stderr.log" "qwen diagnostic" +assert_file_contains "$AGENT_TRACE/events.jsonl" '"backend":"qwen"' +assert_file_contains "$AGENT_TRACE/events.jsonl" '"raw_type":"result"' +assert_file_contains "$AGENT_TRACE/session-id" "qwen-session-1" +assert_file_contains "$AGENT_TRACE/metadata.json" '"version": "qwen-code test-1.0"' +assert_file_contains "$AGENT_TRACE/exit.json" '"success": true' +"$MULTIAGENT" agent run \ + --backend qwen \ + --cwd "$AGENT_RUN_DIR/work" \ + --prompt-file "$AGENT_RUN_DIR/prompt.txt" \ + --final-output "$AGENT_RUN_DIR/final-second.txt" \ + --trace-dir "$AGENT_RUN_DIR/trace" \ + --access read-only >/dev/null 2>/dev/null +[[ "$(tr -d '\r\n' <"$AGENT_RUN_DIR/trace/latest")" == "attempt-0002" ]] +assert_file_contains "$AGENT_RUN_DIR/trace/attempt-0001/raw-stdout.log" "Qwen final result" +assert_file_contains "$AGENT_RUN_DIR/trace/attempt-0002/raw-stdout.log" "Qwen final result" +assert_file_contains "$AGENT_RUN_DIR/final-second.txt" "Qwen final result" +agent_backend_info="$("$MULTIAGENT" agent backend-info qwen)" +[[ "$agent_backend_info" == *'"backend":"qwen"'* ]] +[[ "$agent_backend_info" == *'"native_resume":true'* ]] +[[ "$agent_backend_info" == *'"version":"qwen-code test-1.0"'* ]] +if MULTIAGENT_AGENT_TIMEOUT_SECONDS=0 "$MULTIAGENT" agent run \ + --backend qwen \ + --cwd "$AGENT_RUN_DIR/work" \ + --prompt-file "$AGENT_RUN_DIR/prompt.txt" \ + --final-output "$AGENT_RUN_DIR/invalid-timeout-final.txt" \ + --trace-dir "$AGENT_RUN_DIR/invalid-timeout-trace" \ + --access read-only >"$AGENT_RUN_DIR/invalid-timeout.out" 2>&1; then + echo "expected zero coding-agent timeout to fail" >&2 + exit 1 +fi +assert_file_contains "$AGENT_RUN_DIR/invalid-timeout.out" "MULTIAGENT_AGENT_TIMEOUT_SECONDS must be a positive integer" +[[ ! -e "$AGENT_RUN_DIR/invalid-timeout-trace/latest" ]] + +set +e +QWEN_EXIT_CODE=7 "$MULTIAGENT" agent run \ + --backend qwen \ + --cwd "$AGENT_RUN_DIR/work" \ + --prompt-file "$AGENT_RUN_DIR/prompt.txt" \ + --final-output "$AGENT_RUN_DIR/nonzero-final.txt" \ + --trace-dir "$AGENT_RUN_DIR/nonzero-trace" \ + --access workspace-write >/dev/null 2>/dev/null +agent_nonzero_rc=$? +set -e +[[ "$agent_nonzero_rc" -eq 7 ]] +AGENT_NONZERO_TRACE="$AGENT_RUN_DIR/nonzero-trace/$(tr -d '\r\n' <"$AGENT_RUN_DIR/nonzero-trace/latest")" +assert_file_contains "$AGENT_NONZERO_TRACE/exit.json" '"code": 7' + +set +e +MULTIAGENT_AGENT_TIMEOUT_SECONDS=1 \ + QWEN_SLEEP_SECONDS=30 \ + QWEN_DESCENDANT_PID_FILE="$AGENT_RUN_DIR/descendant.pid" \ + "$MULTIAGENT" agent run \ + --backend qwen \ + --cwd "$AGENT_RUN_DIR/work" \ + --prompt-file "$AGENT_RUN_DIR/prompt.txt" \ + --final-output "$AGENT_RUN_DIR/timeout-final.txt" \ + --trace-dir "$AGENT_RUN_DIR/timeout-trace" \ + --access workspace-write >/dev/null 2>/dev/null +agent_timeout_rc=$? +set -e +[[ "$agent_timeout_rc" -eq 124 ]] +AGENT_TIMEOUT_TRACE="$AGENT_RUN_DIR/timeout-trace/$(tr -d '\r\n' <"$AGENT_RUN_DIR/timeout-trace/latest")" +assert_file_contains "$AGENT_TIMEOUT_TRACE/exit.json" '"timed_out": true' +assert_file_contains "$AGENT_TIMEOUT_TRACE/exit.json" '"reason": "timeout"' +if [[ -f "$AGENT_RUN_DIR/descendant.pid" ]]; then + descendant_pid="$(tr -d '\r\n' <"$AGENT_RUN_DIR/descendant.pid")" + for _ in $(seq 1 40); do + if ! kill -0 "$descendant_pid" 2>/dev/null; then + break + fi + sleep 0.05 + done + if kill -0 "$descendant_pid" 2>/dev/null; then + echo "timed-out coding-agent descendant is still alive: $descendant_pid" >&2 + exit 1 + fi +fi + +if [[ "$HOST_KERNEL" == Linux ]]; then + mkdir -p "$AGENT_RUN_DIR/landlock-output" + set +e + QWEN_TRY_WRITE="$AGENT_RUN_DIR/work/forbidden.txt" \ + "$MULTIAGENT" role-exec \ + --allow-write "$AGENT_RUN_DIR/landlock-output" \ + -- "$MULTIAGENT" agent run \ + --backend qwen \ + --cwd "$AGENT_RUN_DIR/work" \ + --prompt-file "$AGENT_RUN_DIR/prompt.txt" \ + --final-output "$AGENT_RUN_DIR/landlock-output/final.txt" \ + --trace-dir "$AGENT_RUN_DIR/landlock-output/trace" \ + --access read-only >/dev/null 2>/dev/null + agent_readonly_rc=$? + set -e + [[ "$agent_readonly_rc" -ne 0 ]] + [[ ! -e "$AGENT_RUN_DIR/work/forbidden.txt" ]] + AGENT_READONLY_TRACE="$AGENT_RUN_DIR/landlock-output/trace/$(tr -d '\r\n' <"$AGENT_RUN_DIR/landlock-output/trace/latest")" + assert_file_contains "$AGENT_READONLY_TRACE/exit.json" '"success": false' +fi + assert_file_not_contains() { local file="$1" local unexpected="$2" @@ -333,6 +480,34 @@ assert_file_contains "$TMPDIR/launch-explicit-state/orchestrator-bootstrap.sh" " assert_file_contains "$TMPDIR/launch-explicit-state/runtime_state/orchestrator-prompt-bundle.md" "custom prompt" assert_file_contains "$TMPDIR/launch-explicit-state/runtime_state/orchestrator-prompt-bundle.md" "BEGIN MANDATORY IMPLEMENTATION LIFECYCLE" +rm -f "$MOCK_TMUX_LOG" +MOCK_TMUX_HAS_SESSION=0 \ + MULTIAGENT_SESSION="launch-qwen" \ + MULTIAGENT_PROMPT= \ + MULTIAGENT_STATE_DIR="$TMPDIR/launch-qwen-state" \ + MULTIAGENT_WRITE_POLICY="$TMPDIR/launch-qwen-policy/write-policy.paths" \ + ORCHESTRATOR_CLI=qwen WORKER_CLI=qwen SUBAGENT_CLI=qwen VERIFIER_CLI=qwen \ + "$ROOT/launch.sh" --session launch-qwen --root "$LAUNCH_TARGET" --no-attach >"$TMPDIR/launch-qwen.out" +QWEN_BOOTSTRAP="$TMPDIR/launch-qwen-state/orchestrator-bootstrap.sh" +assert_file_contains "$TMPDIR/launch-qwen.out" "Worker CLI: qwen" +assert_file_contains "$QWEN_BOOTSTRAP" "$MULTIAGENT agent run --backend qwen" +assert_file_contains "$QWEN_BOOTSTRAP" "--trace-dir $TMPDIR/launch-qwen-state/logs/agents/orchestrator" +assert_file_contains "$TMPDIR/launch-qwen-state/runtime_state/agent-backends.tsv" $'qwen\t' +assert_file_contains "$TMPDIR/launch-qwen-state/runtime_state/agent-backends.tsv" "qwen-code test-1.0" + +if MOCK_TMUX_HAS_SESSION=0 \ + MULTIAGENT_SESSION="launch-missing-qwen" \ + MULTIAGENT_PROMPT= \ + MULTIAGENT_STATE_DIR="$TMPDIR/launch-missing-qwen-state" \ + MULTIAGENT_WRITE_POLICY="$TMPDIR/launch-missing-qwen-policy/write-policy.paths" \ + ORCHESTRATOR_CLI=qwen WORKER_CLI=qwen SUBAGENT_CLI=qwen VERIFIER_CLI=qwen \ + QWEN_BIN="$TMPDIR/does-not-exist/qwen" \ + "$ROOT/launch.sh" --session launch-missing-qwen --root "$LAUNCH_TARGET" --no-attach >"$TMPDIR/launch-missing-qwen.out" 2>&1; then + echo "expected missing Qwen Code executable to fail launch preflight" >&2 + exit 1 +fi +assert_file_contains "$TMPDIR/launch-missing-qwen.out" "run qwen coding-agent preflight" + REPAIR_STATE="$TMPDIR/repair-state" mkdir -p "$REPAIR_STATE" if MULTIAGENT_STATE_DIR="$REPAIR_STATE" "$MULTIAGENT" subagent finding-create invalid-prose-finding \ @@ -790,8 +965,8 @@ assert_file_contains "$ROOT/README.md" "MULTIAGENT_VERIFIER_MAX_ITERATIONS=3" assert_file_contains "$ROOT/README.md" "compact contract ledger" assert_file_contains "$ROOT/README.md" "hidden-contract edge cases" assert_file_contains "$ROOT/README.md" "hidden-contract-ledger" -assert_file_contains "$ROOT/README.md" 'WORKER_CLI`: worker CLI for manual worker windows, default `claude`' -assert_file_contains "$ROOT/README.md" 'VERIFIER_CLI`: verifier CLI, default `codex`' +assert_file_contains "$ROOT/README.md" 'WORKER_CLI`: worker coding-agent backend for manual worker windows' +assert_file_contains "$ROOT/README.md" 'VERIFIER_CLI`: verifier backend, default `codex`' assert_file_contains "$ROOT/README.md" "Evaluation Framework" assert_file_contains "$ROOT/README.md" "Parallel DAG Discipline" assert_file_contains "$ROOT/README.md" "Structured Repair Loop" @@ -1449,15 +1624,14 @@ assert_file_contains "$MULTIAGENT_STATE_DIR/subagents/codex-exec-protocol/instru assert_file_contains "$MULTIAGENT_STATE_DIR/subagents/codex-exec-protocol/instruction.txt" '{"cmd":"cd /app && sed -n' assert_file_contains "$MULTIAGENT_STATE_DIR/subagents/codex-exec-protocol/instruction.txt" "Inspect /app" codex_exec_spawn_line="$(grep -F "new-window -d test-session codex-exec-protocol " "$MOCK_TMUX_LOG")" -[[ "$codex_exec_spawn_line" == *"exec --cd $ROOT"* ]] +[[ "$codex_exec_spawn_line" == *"$MULTIAGENT agent run --backend codex --cwd $ROOT"* ]] if [[ "$HOST_KERNEL" == Linux ]]; then [[ "$codex_exec_spawn_line" == *"$MULTIAGENT role-exec"* ]] - [[ "$codex_exec_spawn_line" == *"--dangerously-bypass-approvals-and-sandbox"* ]] [[ "$codex_exec_spawn_line" == *"--allow-write $ROOT"* ]] -else - [[ "$codex_exec_spawn_line" == *"--sandbox workspace-write -c approval_policy=never"* ]] fi -[[ "$codex_exec_spawn_line" == *"--output-last-message"* ]] +[[ "$codex_exec_spawn_line" == *"--final-output $MULTIAGENT_STATE_DIR/subagents/codex-exec-protocol/last-message.txt"* ]] +[[ "$codex_exec_spawn_line" == *"--trace-dir $MULTIAGENT_STATE_DIR/logs/agents/codex-exec-protocol"* ]] +[[ "$codex_exec_spawn_line" == *"--access workspace-write"* ]] printf 'final status: codex exec exited rc=0\n' >"$MOCK_TMUX_CAPTURES/codex-exec-protocol.txt" codex_wait_output="$(MULTIAGENT_CODEX_EXEC=1 SUBAGENT_CLI=codex "$MULTIAGENT" subagent wait codex-exec-protocol --timeout 1 --poll-interval 0)" @@ -1467,14 +1641,12 @@ printf 'Codex exec prompt ready\n' >"$MOCK_TMUX_CAPTURES/decision-authority-read MULTIAGENT_CODEX_EXEC=1 SUBAGENT_CLI=codex "$MULTIAGENT" subagent spawn decision-authority-read-only \ --role reviewer --instruction "Review the proposed authority" authority_spawn_line="$(grep -F "new-window -d test-session decision-authority-read-only " "$MOCK_TMUX_LOG")" -[[ "$authority_spawn_line" == *"exec --cd $ROOT"* ]] +[[ "$authority_spawn_line" == *"$MULTIAGENT agent run --backend codex --cwd $ROOT"* ]] if [[ "$HOST_KERNEL" == Linux ]]; then [[ "$authority_spawn_line" == *"$MULTIAGENT role-exec"* ]] - [[ "$authority_spawn_line" == *"--dangerously-bypass-approvals-and-sandbox"* ]] [[ "$authority_spawn_line" != *"--allow-write $ROOT"* ]] -else - [[ "$authority_spawn_line" == *"--sandbox read-only -c approval_policy=never"* ]] fi +[[ "$authority_spawn_line" == *"--access read-only"* ]] assert_file_contains "$MULTIAGENT_STATE_DIR/subagents/decision-authority-read-only/meta.env" "role=reviewer" assert_file_contains "$MULTIAGENT_STATE_DIR/subagents/decision-authority-read-only/meta.env" "codex_access=read-only" @@ -1537,6 +1709,25 @@ fi printf 'Final status: completed\n' >"$MOCK_TMUX_CAPTURES/subagent-claude.txt" "$MULTIAGENT" subagent finalize subagent-claude >/dev/null +SUBAGENT_CLI=qwen "$MULTIAGENT" subagent spawn subagent-qwen --role reviewer --instruction "Review with Qwen Code" +assert_file_contains "$MULTIAGENT_STATE_DIR/subagents/subagent-qwen/meta.env" "cli=qwen" +assert_file_contains "$MULTIAGENT_STATE_DIR/subagents/subagent-qwen/meta.env" "cli_bin=$QWEN_BIN" +assert_file_contains "$MULTIAGENT_STATE_DIR/subagents/subagent-qwen/meta.env" "access=read-only" +assert_file_contains "$MULTIAGENT_STATE_DIR/subagents/subagent-qwen/meta.env" "trace_dir=$MULTIAGENT_STATE_DIR/logs/agents/subagent-qwen" +qwen_spawn_line="$(grep -F "new-window -d test-session subagent-qwen " "$MOCK_TMUX_LOG")" +[[ "$qwen_spawn_line" == *"$MULTIAGENT agent run --backend qwen --cwd $ROOT"* ]] +[[ "$qwen_spawn_line" == *"--prompt-file $MULTIAGENT_STATE_DIR/subagents/subagent-qwen/instruction.txt"* ]] +[[ "$qwen_spawn_line" == *"--access read-only"* ]] +if grep -Fq "send-key test-session:subagent-qwen" "$MOCK_TMUX_LOG"; then + echo "headless Qwen Code must receive its prompt through stdin, not tmux send-keys" >&2 + exit 1 +fi +printf 'final status: coding agent exited rc=0\n' >"$MOCK_TMUX_CAPTURES/subagent-qwen.txt" +qwen_poll="$(SUBAGENT_CLI=qwen "$MULTIAGENT" subagent poll subagent-qwen)" +[[ "$qwen_poll" == $'subagent-qwen\tdone' ]] +SUBAGENT_CLI=qwen "$MULTIAGENT" subagent kill subagent-qwen >/dev/null +assert_file_contains "$MULTIAGENT_STATE_DIR/logs/agents/subagent-qwen/supervisor-termination.json" '"reason": "canceled"' + printf 'Progress update: still running\n' >"$MOCK_TMUX_CAPTURES/subagent-watch.txt" poll_output="$("$MULTIAGENT" subagent poll subagent-watch)" [[ "$poll_output" == $'subagent-watch\trunning' ]] diff --git a/tests/test_native_solver_import_model.py b/tests/test_native_solver_import_model.py index f76ca76..72a4865 100644 --- a/tests/test_native_solver_import_model.py +++ b/tests/test_native_solver_import_model.py @@ -94,6 +94,7 @@ def test_bake_copies_package_initializers(self) -> None: self.assertTrue((baked_root / "evaluation" / "support" / "state.py").is_file()) self.assertEqual(list((baked_root / "evaluation" / "support" / "coding").glob("*.py")), []) self.assertFalse((baked_root / "multiagent_framework").exists()) + self.assertFalse((baked_root / "target").exists()) self.assertEqual(package_hint, f"python3 -m {MODULE_ENTRYPOINT}") self.assertEqual( copy_lines[-1], diff --git a/tests/test_swe_provenance.py b/tests/test_swe_provenance.py index a516fa3..b985734 100644 --- a/tests/test_swe_provenance.py +++ b/tests/test_swe_provenance.py @@ -220,12 +220,16 @@ def test_solver_digest_tracks_included_content_only(self): (root / "launch.sh").write_text("one\n", encoding="utf-8") (root / "docs").mkdir() (root / "docs/ignored.md").write_text("ignored one\n", encoding="utf-8") + (root / "target/debug").mkdir(parents=True) + (root / "target/debug/multiagent").write_bytes(b"build artifact one") first = native_solver_source_digest(root) (root / "launch.sh").chmod(0o755) self.assertNotEqual(native_solver_source_digest(root), first) first = native_solver_source_digest(root) (root / "docs/ignored.md").write_text("ignored two\n", encoding="utf-8") self.assertEqual(native_solver_source_digest(root), first) + (root / "target/debug/multiagent").write_bytes(b"build artifact two") + self.assertEqual(native_solver_source_digest(root), first) (root / "launch.sh").write_text("two\n", encoding="utf-8") self.assertNotEqual(native_solver_source_digest(root), first) From 4febc66f32fd86ffde89bba1984e269fd70ff517 Mon Sep 17 00:00:00 2001 From: Bo Wu Date: Sun, 16 Aug 2026 17:43:49 -0700 Subject: [PATCH 2/6] refactor: enforce multiagent authority boundaries --- .github/workflows/contract-tests.yml | 3 + README.md | 27 +- docs/architecture.md | 19 +- docs/control-plane-boundary.md | 31 +- docs/getting-started.md | 22 +- evaluation/README.md | 6 +- .../native_solver/swe_prod_lifecycle.py | 29 +- .../native_solver/swe_prod_repository.py | 15 +- .../templates/swe_autonomous_appendix.md | 15 + prompts/playbooks/agent-spawning.md | 43 +- prompts/roles/acceptance-scout.md | 6 + prompts/roles/build-verifier.md | 5 +- prompts/roles/contract-scout.md | 7 + prompts/verifier.md | 16 + prompts/worker.md | 20 + src/config.rs | 5 + src/main.rs | 11 + src/role_sandbox.rs | 23 +- src/runtime.rs | 430 ++++++- src/snapshot.rs | 120 +- src/subagent.rs | 140 ++- src/supervisor.rs | 1049 +++++++++++++++++ src/workflow.rs | 86 +- tests/malicious-orchestrator.sh | 290 +++++ tests/run.sh | 41 +- tests/test_migration_contracts.py | 39 +- tests/test_swe_outcomes.py | 38 +- 27 files changed, 2345 insertions(+), 191 deletions(-) create mode 100644 src/supervisor.rs create mode 100755 tests/malicious-orchestrator.sh diff --git a/.github/workflows/contract-tests.yml b/.github/workflows/contract-tests.yml index aeeb900..bf91d24 100644 --- a/.github/workflows/contract-tests.yml +++ b/.github/workflows/contract-tests.yml @@ -48,3 +48,6 @@ jobs: run: cargo test --locked - name: Run shell CLI and lifecycle contracts run: tests/run.sh + - name: Run malicious orchestrator boundary contracts + if: runner.os == 'Linux' + run: sudo -E tests/malicious-orchestrator.sh diff --git a/README.md b/README.md index f6eb638..5a7fe1c 100644 --- a/README.md +++ b/README.md @@ -186,16 +186,17 @@ an advanced path. tests/run.sh ``` -## Enforcement Caveat - -Decision-authority review, approved-context handoff, lifecycle TODO convergence, -and completion are enforced by the orchestrator prompt plus normal-path checks -in `multiagent workflow`, `multiagent subagent`, and `multiagent orchestrator`. This makes -ordinary violations fail visibly, but it is not a security or capability -boundary: an orchestrator with direct shell and state-file access can bypass or -disable these checks. - -Revisit this limitation before treating the workflow as strict enforcement. -The stronger design is a trusted supervisor that exclusively owns writable -worker launch and independently validates TODO state, decision ownership, user -approval, context revision, and assignment scope before starting a worker. +## Enforcement Boundary + +Production Linux launches separate the orchestrator, writer, reader, and +authority supervisor into distinct Unix identities. The supervisor exclusively +owns workflow state, one-time role launch authorizations, and sealed reviewer +evidence. The orchestrator can request transitions and spawn named roles, but it +cannot write the target repository or authority state directly. A writer gets +temporary ownership only of its predeclared paths, and only one writer may be +active at a time. Read-only roles cannot acquire those writes. + +This is a capability boundary for filesystem writes and typed state changes, +not proof that an agent's semantic judgment is correct. Reviewer evidence proves +which isolated process produced a verdict and which workflow/diff it covered; +task correctness still depends on the reviewer, tests, and final human review. diff --git a/docs/architecture.md b/docs/architecture.md index af2d8eb..afa3eb7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -14,9 +14,10 @@ flowchart LR P --> R["Pilot runner"] R --> B["Baseline: one coding-agent CLI"] R --> O["Orchestrated: commander in tmux"] - O --> C["Contract / scope scouts"] - O --> W["Path-owned workers"] - O --> V["Read-only verifier"] + O --> A["UID-isolated authority supervisor"] + A --> C["Contract / scope scouts"] + A --> W["One path-owned writer"] + A --> V["Read-only verifier"] C --> S["Structured runtime state"] W --> S V --> S @@ -39,9 +40,15 @@ persisted under `MULTIAGENT_STATE_DIR`. Python under `evaluation/` provides benchmark execution, status reading, and provenance; it does not implement a second control plane or participate in normal launches. -Workers own disjoint writable paths. Scouts and verifiers are read-only. The -orchestrator alone accepts follow-up work and decides whether the final gate can -close. Hash-bound verifier evidence becomes stale when the final diff changes. +On production Linux the orchestrator, writer, readers, and authority supervisor +run as different Unix users. The orchestrator decomposes work and requests typed +transitions over a Unix socket; it does not own protected state or repository +writes. The supervisor issues one-time role launches, permits only one writer, +temporarily grants that writer its predeclared existing paths, and seals reviewer +output before exposing it to the orchestrator. Scouts and verifiers are +read-only. The orchestrator can request follow-up or closure, while fixed rules +and sealed evidence decide whether the protected transition succeeds. +Hash-bound verifier evidence becomes stale when the final diff changes. ## Evaluation Boundary diff --git a/docs/control-plane-boundary.md b/docs/control-plane-boundary.md index 2864600..3636d33 100644 --- a/docs/control-plane-boundary.md +++ b/docs/control-plane-boundary.md @@ -23,15 +23,25 @@ allocate or emulate a PTY; tmux continues to own terminal lifecycle and interactive process semantics. This keeps PTY behavior without preserving shell implementations. -In the production Linux-container boundary, tmux runs as the read-only -orchestrator UID. A raw tmux window therefore cannot acquire repository writes. +In the production Linux-container boundary, four Unix identities separate the +orchestrator, the single active writer, read-only reviewers/scouts, and a small +authority supervisor. Tmux runs as the non-writing orchestrator UID, so a raw +tmux window cannot acquire repository writes. The supervisor owns the workflow, +assignment, finding/TODO, launch-authorization, and sealed-evidence directories +and exposes only typed operations over a Unix socket. Peer credentials determine +which role may call each operation; choosing another state directory cannot +replace the supervisor's root-registered socket. + Worker/reviewer transitions use the Rust binary's narrowly gated `role-agent-exec` entrypoint: it accepts only a persisted named headless coding agent, validates the configured root-owned agent binary, and starts the shared Rust runner in a dedicated process group under the role's UID. The runner then executes the recorded Codex, Claude, or Qwen Code backend through argv and stdin. -A minimal wait-only parent retains no workflow discretion; -it exists solely to forward pane termination to the complete role process tree. +Launch authorizations are one-time and bind the role, backend, prompt, workflow, +and owned paths. Writer paths receive temporary writer ownership for the role's +lifetime and are revoked afterward; a global authority-owned lease prevents two +writers from overlapping. Landlock narrows this further when the kernel supports +it, while Unix ownership remains the tested base boundary when it does not. `subagent kill` waits for that boundary to close, preventing detached or late worker output from modifying the workspace after cancellation. The setuid privilege gate drops privilege for every other command, including generic @@ -42,6 +52,19 @@ writer it revalidates the assignment against the live workflow phase and approved implementation context; setting `MULTIAGENT_LIFECYCLE_ENFORCEMENT=0` cannot reopen a completed workflow. +Reviewer output is first written to a role-private file, then copied by the +supervisor into an immutable evidence directory with role, workflow, completion, +and SHA-256 metadata. The orchestrator may request `todo-close` or +`finding-dismiss`, but the authority process authorizes it only from an accepted, +seal-valid reviewer result (and the current final-diff hash when hash binding is +enabled). Thus orchestration chooses what work to ask for; reviewer evidence and +predetermined transition rules decide whether protected state may change. + +The boundary does not distinguish a good reviewer prompt from a biased one and +does not prove semantic correctness. It guarantees process identity, access +mode, evidence integrity, workflow binding, and filesystem scope. Reviewer/test +quality and human acceptance remain separate concerns. + Headless runs retain raw stdout/stderr, normalized JSONL events, provider session identity when available, the final message, and the exit/cancellation reason under `MULTIAGENT_LOG_DIR/agents`. Each invocation receives an immutable diff --git a/docs/getting-started.md b/docs/getting-started.md index 73a3bf8..ec2deec 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -93,21 +93,24 @@ one workspace-write task and checks both the response and filesystem result. The regular test suite uses a fake Qwen executable and never requires network access or credentials. -The Rust supervisor assigns Codex access from trusted process roles. On hosts +The Rust runtime assigns coding-agent access from trusted process roles. On hosts where Codex's native sandbox is available, the orchestrator starts in the durable state directory with `workspace-write`, workers start in the target repository with `workspace-write`, and scouts/authority reviewers use `read-only`. The production Linux-container adapter uses separate unprivileged Unix identities instead because nested bubblewrap is unavailable under Docker's default seccomp profile. Its tmux server runs as the non-writing orchestrator -identity. A narrowly gated, setuid Rust entrypoint may only start the fixed +identity, while a separate authority UID owns protected state and a typed Unix +socket. A narrowly gated, setuid Rust entrypoint may only start the fixed coding-agent binary recorded for a named headless role; all other invocations permanently drop back to the caller UID. Each role also receives a private Codex runtime home so one role's private lock/config files cannot stall another. The isolated orchestrator's real UID makes lifecycle enforcement mandatory, so shell-level environment overrides cannot authorize a writer after completion. In both environments the orchestrator can read the target but cannot write it, -while workers can. Claude remains a compatibility path and does not provide +while a single active worker receives temporary ownership only of its assigned +existing paths. Reviewer output is sealed by the authority process before the +orchestrator can read or cite it. Claude remains a compatibility path and does not provide Codex's native role boundary outside the production adapter. Qwen uses `plan` approval for read-only roles and its sandbox on non-Linux hosts, but the production security claim remains the outer Linux role boundary. @@ -378,8 +381,10 @@ are normative input to the verifier. The verifier still reconstructs the task contract independently, then checks the worker diff against both the reconstructed contract and the scout's must-preserve requirements. -The orchestrator reviews the verifier's findings and gives the verdict. Only -accepted follow-ups are passed back to the original worker. The worker then +The orchestrator reads the verifier's findings and chooses which follow-up to +request. Protected closure is accepted only when the authority process can bind +that request to completed, supervisor-sealed reviewer evidence; a forged public +message cannot authorize it. Accepted follow-ups are passed back to the original worker. The worker then reports done again, the orchestrator reruns assignment checks, and verification may repeat until no accepted follow-up remains or the max iteration cap is reached. The cap limits accepted worker follow-up cycles after verifier review. @@ -490,12 +495,15 @@ multiagent policy approve /tmp --actor orchestrator --assignment-id build-logs - For isolated coding-agent roles, the OS boundary mechanically prevents the orchestrator, authority reviewers, and scouts from writing the target repository. On native hosts that boundary is Codex's sandbox; in the production Linux container it is -Unix ownership plus a permanent role UID drop. The tmux server itself has the +Unix ownership plus a permanent role UID drop, with Landlock as an additional +restriction when available. The tmux server itself has the orchestrator UID, so bypassing the Rust CLI to open a raw pane still produces a non-writing process. The only privileged transition is the fixed `role-agent-exec` path, which validates persisted role metadata and the root-owned, non-group-writable configured agent binary before dropping to the -writer or reader UID. Generic `role-exec` calls from the orchestrator lose setuid +writer or reader UID. An authority process under a fourth UID owns protected +workflow state, one-time launch permits, the single-writer lease, and sealed +reviewer output. Generic `role-exec` calls from the orchestrator lose setuid privilege before dispatch. The write-policy helper remains responsible for explicit writes outside the normal role root. Compatibility processes do not receive this mechanical boundary on native hosts unless their own sandbox is diff --git a/evaluation/README.md b/evaluation/README.md index 840ca7f..3d67709 100644 --- a/evaluation/README.md +++ b/evaluation/README.md @@ -148,8 +148,10 @@ image. `evaluation.native_solver.solve_swe_prod` is the packaged container entrypoint, launched with `python3 -m` from `/opt/multiagent`. The adapter only starts the workflow, waits for the Rust orchestrator process, exposes committed and -untracked workspace changes, and returns control to EvalScope. It does not -inspect status narratives, run validation gates, filter files, or score the +newly created untracked workspace changes, and returns control to EvalScope. +The adapter snapshots pre-existing untracked image residue before launch so it +is not misrepresented as solver output. It does not inspect status narratives, +run validation gates, decide which source changes are correct, or score the patch. EvalScope extracts the current `/app` diff and passes it to the official verifier. diff --git a/evaluation/native_solver/swe_prod_lifecycle.py b/evaluation/native_solver/swe_prod_lifecycle.py index 43c3d98..08e3cbc 100644 --- a/evaluation/native_solver/swe_prod_lifecycle.py +++ b/evaluation/native_solver/swe_prod_lifecycle.py @@ -29,6 +29,7 @@ ) from .swe_prod_repository import ( git_head, + list_untracked_files, make_prompt, mark_untracked_intent_to_add, materialize_committed_changes, @@ -38,6 +39,7 @@ ORCHESTRATOR_UID = 10001 WRITER_UID = 10002 READER_UID = 10003 +SUPERVISOR_UID = 10004 ROLE_GID = 10001 @@ -69,7 +71,11 @@ def prepare_tree(root: Path, uid: int, *, group_write: bool) -> None: except FileNotFoundError: continue - prepare_tree(workdir, WRITER_UID, group_write=False) + # The repository starts neutral. The privileged Rust launcher grants the + # single active writer ownership only over its supervisor-owned paths and + # revokes that grant when the role exits. This remains enforceable on + # kernels where Landlock is unavailable. + prepare_tree(workdir, 0, group_write=False) os.chown(role_launcher, 0, 0) os.chmod(role_launcher, 0o4755) @@ -85,6 +91,7 @@ def prepare_tree(root: Path, uid: int, *, group_write: bool) -> None: ("orchestrator", ORCHESTRATOR_UID), ("writer", WRITER_UID), ("reader", READER_UID), + ("supervisor", SUPERVISOR_UID), ): home = ROLE_CODEX_HOME_ROOT / role home.mkdir(parents=True, exist_ok=True) @@ -97,7 +104,12 @@ def prepare_tree(root: Path, uid: int, *, group_write: bool) -> None: prepare_tree(home, uid, group_write=False) os.chmod(home, 0o700) - for cache in (RUNTIME_ROOT / "go-build-cache", RUNTIME_ROOT / "go-mod-cache"): + for cache in ( + RUNTIME_ROOT / "go-build-cache", + RUNTIME_ROOT / "go-mod-cache", + RUNTIME_ROOT / "role-shared", + ): + cache.mkdir(parents=True, exist_ok=True) prepare_tree(cache, WRITER_UID, group_write=True) @@ -188,7 +200,13 @@ def run_prod_solver(prompt_path: str | None, workdir: Path, repo_root: Path, tim ) start_head = git_head(workdir) + baseline_untracked = set(list_untracked_files(workdir)) RUNTIME_ROOT.mkdir(parents=True, exist_ok=True) + baseline_untracked_path = RUNTIME_ROOT / "baseline-untracked.txt" + baseline_untracked_path.write_text( + "".join(f"{path}\n" for path in sorted(baseline_untracked)), + encoding="utf-8", + ) RUNTIME_IDENTITY_PATH.unlink(missing_ok=True) codex_version_result = run([real_codex, "--version"], timeout=30) @@ -237,6 +255,7 @@ def run_prod_solver(prompt_path: str | None, workdir: Path, repo_root: Path, tim "MULTIAGENT_PROMPT_MODULE_ROOT": str(repo_root), "MULTIAGENT_RESUME": "0", "MULTIAGENT_START_HEAD": start_head, + "MULTIAGENT_BASELINE_UNTRACKED_FILE": str(baseline_untracked_path), "ORCHESTRATOR_CLI": "codex", "WORKER_CLI": "codex", "SUBAGENT_CLI": "codex", @@ -246,7 +265,9 @@ def run_prod_solver(prompt_path: str | None, workdir: Path, repo_root: Path, tim "MULTIAGENT_CODEX_HOME_ROOT": str(ROLE_CODEX_HOME_ROOT), "MULTIAGENT_CODEX_EXEC": os.environ.get("MULTIAGENT_CODEX_EXEC", "1"), "MULTIAGENT_EXTRA_PATH": str(RUNTIME_ROOT), - "MULTIAGENT_ROLE_SHARED_WRITE_DIR": str(RUNTIME_ROOT), + "MULTIAGENT_ROLE_SHARED_WRITE_DIR": str(RUNTIME_ROOT / "role-shared"), + "CARGO_TARGET_DIR": str(RUNTIME_ROOT / "role-shared" / "cargo-target"), + "PYTHONPYCACHEPREFIX": str(RUNTIME_ROOT / "role-shared" / "pycache"), "MULTIAGENT_UID_SANDBOX": "1", "PATH": ":".join(part for part in path_parts if part), "GOCACHE": ensure_cache_dir(RUNTIME_ROOT / "go-build-cache"), @@ -275,6 +296,6 @@ def run_prod_solver(prompt_path: str | None, workdir: Path, repo_root: Path, tim restore_workspace_owner(workdir) materialize_committed_changes(workdir, start_head) - mark_untracked_intent_to_add(workdir) + mark_untracked_intent_to_add(workdir, baseline_untracked=baseline_untracked) log("workspace prepared for EvalScope submission") return 0 diff --git a/evaluation/native_solver/swe_prod_repository.py b/evaluation/native_solver/swe_prod_repository.py index 6659d12..f83c24b 100644 --- a/evaluation/native_solver/swe_prod_repository.py +++ b/evaluation/native_solver/swe_prod_repository.py @@ -51,12 +51,19 @@ def materialize_committed_changes(cwd: Path, start_head: str) -> None: raise RuntimeError(f"failed to materialize committed changes with git reset --mixed: {tail}") -def mark_untracked_intent_to_add(cwd: Path) -> list[str]: - """Make every solver-created file visible to EvalScope's Git diff.""" - +def list_untracked_files(cwd: Path) -> list[str]: + """Return non-ignored untracked files in stable Git order.""" others = run(["git", "ls-files", "--others", "--exclude-standard"], cwd=cwd, timeout=30) - untracked = [line.strip() for line in others.stdout.splitlines() if line.strip()] + return [line.strip() for line in others.stdout.splitlines() if line.strip()] + + +def mark_untracked_intent_to_add(cwd: Path, *, baseline_untracked: set[str] | None = None) -> list[str]: + """Expose newly created solver files without submitting image residue.""" + + baseline = baseline_untracked or set() + untracked = list_untracked_files(cwd) intent_to_add = [path for path in untracked if (cwd / path).is_file()] + intent_to_add = [path for path in intent_to_add if path not in baseline] if intent_to_add: result = run(["git", "add", "-N", "--", *intent_to_add], cwd=cwd, timeout=120) if result.returncode != 0: diff --git a/evaluation/native_solver/templates/swe_autonomous_appendix.md b/evaluation/native_solver/templates/swe_autonomous_appendix.md index 5a31563..a0b1a35 100644 --- a/evaluation/native_solver/templates/swe_autonomous_appendix.md +++ b/evaluation/native_solver/templates/swe_autonomous_appendix.md @@ -14,7 +14,22 @@ detail open, use the narrowest backward-compatible interpretation supported by visible source/tests, record the assumption, and continue. Stop only for a true contradiction that makes the public task impossible to implement safely. +If the public task explicitly changes an API, option default, or wrapper +propagation path, that new contract outranks pre-change exact-call mocks that +only encode the old argument shape. Preserve unrelated compatibility, but do +not omit a newly required default at an intermediate layer merely to keep such +a stale mock green; verify the declared default and an override reach the next +layer. + Leave the final working-tree changes in `/app`. The adapter only transports that workspace to EvalScope; the official SWE-bench verifier evaluates it. +This is an autonomous run-to-terminal workflow. Do not end the orchestrator +turn by offering to continue, reporting that implementation is still in +flight, or submitting a known incomplete candidate. If a worker stops because +its assignment omitted a path required by the approved plan or visible +validation, create the bounded follow-up TODO and worker with that path. Exit +only after the lifecycle completes or after recording a true source-visible +blocker that the workflow cannot safely resolve. + ## SWE Issue Text For Worker Assignments diff --git a/prompts/playbooks/agent-spawning.md b/prompts/playbooks/agent-spawning.md index 1a4dcf1..414d274 100644 --- a/prompts/playbooks/agent-spawning.md +++ b/prompts/playbooks/agent-spawning.md @@ -18,37 +18,42 @@ with a narrower locked hypothesis. Worker ownership and done criteria must cover every listed mutated output or explicitly preserve an open blocking todo for outputs assigned elsewhere. +Before creating assignment metadata, compare the worker's owned paths and hard +constraints with the approved implementation context. The assignment may split +the approved plan across coordinated TODOs, but it must not silently narrow or +contradict that plan. In particular, if the approved plan or contract ledger +requires updating visible tests, fixtures, callers, generated files, or other +outputs, either include those paths in this worker's ownership or assign them +to another active TODO. Never forbid a required path and then accept the +resulting partial diff or failed validation as completion. + ## Worker Spawn Skill -Before spawning a worker, create durable assignment metadata: +Create durable assignment metadata and launch the worker with one atomic Rust +CLI operation. Do not issue a separate `assignment-create` concurrently with +`spawn`; doing so creates an avoidable race between authority registration and +worker launch: ```bash -multiagent subagent assignment-create worker-01-task \ +SUBAGENT_CLI="$WORKER_CLI" multiagent subagent spawn worker-01-task \ + --role worker \ + --own PATH[,PATH...] \ --assignment-id ASSIGNMENT_ID \ - --role exploitation \ --workflow-id "$MULTIAGENT_WORKFLOW_ID" \ --decision-id DECISION_ID \ --plan-id PLAN_ID \ --branch BRANCH \ - --owned PATH[,PATH...] -multiagent subagent checkpoint-update worker-01-task --step "assignment created" --status assigned -``` - -For the normal single-writer path, spawn through the Rust supervisor in the -shared target workspace. The trusted worker role receives workspace-write -access while the orchestrator remains unable to edit that workspace: - -```bash -SUBAGENT_CLI="$WORKER_CLI" multiagent subagent spawn worker-01-task \ - --role worker --instruction-file WORKER_INSTRUCTION + --instruction-file WORKER_INSTRUCTION multiagent subagent wait worker-01-task --timeout 1800 ``` -The supervisor handles readiness and capture. Inspect a terminal `blocked` or -`failed` result instead of treating it as completion. Separate git worktrees -remain available for intentionally parallel, disjoint assignments, but require -an explicit integration step before completion; do not use them for the normal -SWE single-writer path. +The supervisor creates the assignment under its lock, completes authority +registration, and only then launches the trusted workspace-write worker. The +orchestrator remains unable to edit the target workspace. Inspect a terminal +`blocked` or `failed` result instead of treating it as completion. Separate git +worktrees remain available for intentionally parallel, disjoint assignments, +but require an explicit integration step before completion; do not use them for +the normal SWE single-writer path. ## Long-Running Subagent Skill diff --git a/prompts/roles/acceptance-scout.md b/prompts/roles/acceptance-scout.md index 874d8fb..94ba46e 100644 --- a/prompts/roles/acceptance-scout.md +++ b/prompts/roles/acceptance-scout.md @@ -99,6 +99,12 @@ Report a compact ledger with: - extension surface: when the task promises registration, configuration, overrides, or adding behavior without core edits, name the concrete API, production integration path, preserved defaults, and override probe +- wrapper propagation: when an explicit task adds an option/default through + multiple functions or adapters, list every named layer and require the next + layer to receive both the declared default and one override. Mark pre-change + exact-call mocks that assert the old argument shape as stale when they + directly conflict with that explicit new contract; do not turn them into a + requirement to omit the new default and rely on a downstream fallback. If a visible test, issue text, docs, source, or user message shows assignment targets, treat those targets as normative. For example, `id, name := helper(x)` diff --git a/prompts/roles/build-verifier.md b/prompts/roles/build-verifier.md index bc137a0..1333a05 100644 --- a/prompts/roles/build-verifier.md +++ b/prompts/roles/build-verifier.md @@ -13,7 +13,10 @@ correctness is proven. 1. Run `git diff --name-only` and identify changed code files. 2. Infer affected language packages/modules from the changed files. -3. Compute or request the final diff hash from the orchestrator. +3. Compute or request the canonical final diff hash from the orchestrator with + `multiagent snapshot --root "$MULTIAGENT_ROOT" --base "${MULTIAGENT_START_HEAD:-HEAD}" --format json`. + Do not substitute `git diff | sha256sum`: raw `git diff` omits untracked new + files and therefore does not bind the complete candidate. 4. Run compile/test commands after the final diff, not before follow-up edits. 5. Require return code 0 for every selected command. 6. Treat any `undefined:`, `undefined method`, `undefined field`, diff --git a/prompts/roles/contract-scout.md b/prompts/roles/contract-scout.md index 69dc9a2..fe57b17 100644 --- a/prompts/roles/contract-scout.md +++ b/prompts/roles/contract-scout.md @@ -108,6 +108,13 @@ production integration/caller path, default compatibility, and a probe that changes behavior through the extension surface rather than by editing core logic. Centralized hardcoding does not satisfy this contract. +When the task explicitly adds an option/default across wrappers, record a +propagation contract naming every layer. Require evidence that the declared +default and one override are passed to the next layer. A pre-change exact-call +mock that asserts the old argument list is stale where it directly conflicts +with the new contract; preserving it by conditionally omitting the new default +is not propagation and must be flagged. + When the task asks for all, every, complete, associated, linked, repeated, alternate, fallback-chain, or multi-value behavior, include a completeness contract: workers and verifiers must check more than one matching value and must diff --git a/prompts/verifier.md b/prompts/verifier.md index b2caa23..4dffeac 100644 --- a/prompts/verifier.md +++ b/prompts/verifier.md @@ -227,6 +227,15 @@ follow-up instructions, or acceptance evidence. Acceptance must be based on user intent, issue text, visible tests, docs, source compatibility behavior, public APIs, data schemas, and runtime behavior. +When the explicit task adds an option, default, or argument across wrapper +layers, distinguish new-contract evidence from pre-change exact-call mocks. The +task's requested API/propagation change outranks a stale mock that merely +asserts the old keyword or argv shape. Require a call-level probe showing the +declared default and one override reach the next layer. Reject conditional +default omission that only keeps the old mock green by relying on the callee to +recreate the value; it does not prove the requested propagation. This rule does +not authorize weakening unrelated compatibility assertions. + If visible task evidence includes a concrete expected value, reproduce that exact assertion with a temporary probe or source-level comparison before accepting. Reject patches that only pass weaker semantic probes when legitimate @@ -459,3 +468,10 @@ If a later failure shows the verifier missed something, categorize it as one of: - task-intent mismatch Feed that category into the next verifier instruction for similar work. +For route, router, middleware, handler-registration, plugin-registration, or +dependency-injection changes, validation must exercise the assembled production +entrypoint. Run the existing focused integration test/module when available, or +start/build the real router and make a request-level probe. Loading the edited +module, checking syntax, or invoking a handler through a hand-written stub does +not prove that production registration order, mount point, middleware, or URL +reachability works. Treat stub-only validation as a blocking validation gap. diff --git a/prompts/worker.md b/prompts/worker.md index e62553e..344f4bf 100644 --- a/prompts/worker.md +++ b/prompts/worker.md @@ -86,6 +86,14 @@ Also include: helper's name, arity, parameter order, return shape, or package placement unless you have updated all reachable callers and have source evidence that compatibility is preserved. +- When the explicit task adds an option, default, or argument that must travel + through wrappers, the new task contract outranks pre-change exact-call mocks. + Trace the value through every named layer and probe both the default and an + override. Do not conditionally omit the default at an intermediate call just + to preserve a stale mock's old keyword/argv shape; that makes propagation + depend on a downstream default and does not prove the requested wiring. Treat + such exact-call expectations as tests to update when they directly conflict + with the explicit new API contract, while preserving unrelated compatibility. - Do not rely on leaked evaluator tests, hidden test names, non-public evaluator rows, or benchmark-only metadata as implementation guidance. Infer unstated contracts from legitimate task/source/product evidence. @@ -324,3 +332,15 @@ expensive package validation command. If you intentionally take a shortcut, mark it with `ponytail:` and name the ceiling plus the trigger to revisit it. +For route, router, middleware, handler-registration, plugin-registration, or +dependency-injection changes, validate through the assembled production +entrypoint. Prefer the existing focused integration test/module; otherwise +start/build the real router and issue a request-level probe. Syntax checks, +module loading, and hand-written handler stubs are useful diagnostics but are +not completion evidence because they do not prove registration order, mount +point, middleware, or URL reachability. +For option/argument propagation across wrappers, validation must observe the +next layer receiving the value for both the declared default and one override. +An implementation that omits the default keyword/field and relies on the next +layer to recreate it has not demonstrated propagation when the task explicitly +requires the option at each layer. diff --git a/src/config.rs b/src/config.rs index 7b296ca..62cfddc 100644 --- a/src/config.rs +++ b/src/config.rs @@ -2,6 +2,11 @@ use std::env; use std::path::PathBuf; pub const ORCHESTRATOR_UID: u32 = 10001; +pub const WRITER_UID: u32 = 10002; +pub const READER_UID: u32 = 10003; +#[cfg(target_os = "linux")] +pub const SUPERVISOR_UID: u32 = 10004; +pub const ROLE_GID: u32 = 10001; /// Return whether lifecycle gates are mandatory for the current process. /// diff --git a/src/main.rs b/src/main.rs index c5a38af..4cd3ae6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,7 @@ mod role_sandbox; mod runtime; mod snapshot; mod subagent; +mod supervisor; mod workflow; use std::env; @@ -39,6 +40,15 @@ fn main() -> ExitCode { eprintln!("multiagent: {message}"); return ExitCode::from(1); } + if let Some(result) = supervisor::proxy_if_required(&command, &args) { + return match result { + Ok(code) => code, + Err(message) => { + eprintln!("supervisor: {message}"); + ExitCode::from(1) + } + }; + } let result: Result = match command.as_str() { "agent" => agent::run(&args).map_err(|message| ("agent", message)), "launch" => runtime::launch(&args).map_err(|message| ("launch", message)), @@ -65,6 +75,7 @@ fn main() -> ExitCode { .map(|_| ExitCode::SUCCESS) .map_err(|message| ("snapshot", message)), "subagent" => subagent::run(&args).map_err(|message| ("subagent", message)), + "supervisor" => supervisor::run(&args).map_err(|message| ("supervisor", message)), "workflow" => workflow::run(&args) .map(|_| ExitCode::SUCCESS) .map_err(|message| ("workflow", message)), diff --git a/src/role_sandbox.rs b/src/role_sandbox.rs index 54110a5..5658997 100644 --- a/src/role_sandbox.rs +++ b/src/role_sandbox.rs @@ -97,6 +97,8 @@ pub fn run(args: &[String]) -> Result { pub fn run_supervised( uid: u32, gid: u32, + write_roots: &[PathBuf], + filesystem_write_boundary: bool, command: &str, args: &[String], ) -> Result { @@ -122,11 +124,19 @@ pub fn run_supervised( unsafe { libc::_exit(126) }; } } - if drop_identity(uid, gid).is_err() { + if let Err(error) = drop_identity(uid, gid) { + eprintln!("role supervisor could not drop identity: {error}"); unsafe { libc::_exit(126) }; } + if let Err(error) = restrict_writes(write_roots) { + if !filesystem_write_boundary || !landlock_unavailable(&error) { + eprintln!("role supervisor could not apply write boundary: {error}"); + unsafe { libc::_exit(126) }; + } + } use std::os::unix::process::CommandExt; - let _ = Command::new(command).args(args).exec(); + let error = Command::new(command).args(args).exec(); + eprintln!("role supervisor could not execute {command}: {error}"); unsafe { libc::_exit(127) }; } @@ -167,12 +177,21 @@ pub fn run_supervised( pub fn run_supervised( _uid: u32, _gid: u32, + _write_roots: &[PathBuf], + _filesystem_write_boundary: bool, _command: &str, _args: &[String], ) -> Result { Err("supervised role execution requires Unix".into()) } +fn landlock_unavailable(error: &str) -> bool { + error.contains("Landlock is unavailable") + && (error.contains("Function not implemented") + || error.contains("Operation not supported") + || error.contains("Protocol not supported")) +} + #[cfg(unix)] extern "C" fn terminate_supervised_child(_signal: libc::c_int) { let child = SUPERVISED_CHILD.load(Ordering::SeqCst); diff --git a/src/runtime.rs b/src/runtime.rs index 810c05d..2947df4 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -1,8 +1,9 @@ use crate::{ agent::{self, AgentRequest, BackendId, BackendPaths, InvocationMode, RoleAccess}, - config, policy, role_sandbox, + config, policy, role_sandbox, supervisor, }; use chrono::{Local, SecondsFormat, Utc}; +use fs2::FileExt; use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, BTreeSet}; use std::env; @@ -37,9 +38,9 @@ struct RuntimeConfig { type CodexAccess = RoleAccess; const ORCHESTRATOR_UID: u32 = config::ORCHESTRATOR_UID; -const WRITER_UID: u32 = 10002; -const READER_UID: u32 = 10003; -const ROLE_GID: u32 = 10001; +const WRITER_UID: u32 = config::WRITER_UID; +const READER_UID: u32 = config::READER_UID; +const ROLE_GID: u32 = config::ROLE_GID; impl RuntimeConfig { fn load() -> Result { @@ -105,29 +106,36 @@ pub fn role_agent_exec(args: &[String]) -> Result { } let cfg = RuntimeConfig::load()?; + supervisor::validate_runtime_state(&cfg.state)?; let dir = cfg.state.join("subagents").join(name); - let metadata = read_env(&dir.join("meta.env"))?; - let cli = metadata - .get("cli") - .filter(|value| !value.is_empty()) - .ok_or_else(|| "role-agent-exec metadata is missing the backend".to_string())?; + let writer_lock = if supervisor::launch_requires_writer(&cfg.state, name)? { + let lock_path = cfg.state.join("launch-authorizations/.writer.lock"); + let lock = OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(&lock_path) + .map_err(io_error("open secure writer lock"))?; + lock.try_lock_exclusive() + .map_err(|_| "another workspace writer is already active".to_string())?; + Some(lock) + } else { + None + }; + let authorization = supervisor::claim_launch(&cfg.state, name)?; + let cli = &authorization.cli; validate_cli(cli)?; if !cfg.headless(cli) { return Err("role-agent-exec requires a headless coding-agent backend".into()); } let configured_binary = cfg.cli_bin(cli)?; - if metadata.get("name").map(String::as_str) != Some(name) - || metadata.get("cli_bin").map(String::as_str) != Some(configured_binary) - { - return Err("role-agent-exec metadata does not match the requested coding agent".into()); + if authorization.cli_bin != configured_binary { + return Err("authorized coding-agent binary does not match the launch manifest".into()); } - let access = match metadata - .get("access") - .or_else(|| metadata.get("codex_access")) - .map(String::as_str) - { - Some("read-only") => CodexAccess::ReadOnly, - Some("workspace-write") => CodexAccess::WorkspaceWrite, + let access = match authorization.access.as_str() { + "read-only" => CodexAccess::ReadOnly, + "workspace-write" if authorization.role == "worker" => CodexAccess::WorkspaceWrite, _ => return Err("role-agent-exec metadata has invalid role access".into()), }; let trusted_binary = resolve_command_path(configured_binary)?; @@ -141,11 +149,7 @@ pub fn role_agent_exec(args: &[String]) -> Result { }, &trusted_binary, ); - let prompt = dir.join(if restored { - "restore-instruction.txt" - } else { - "instruction.txt" - }); + let prompt = authorization.instruction.clone(); if !prompt.is_file() { return Err(format!( "role-agent-exec instruction is missing: {}", @@ -159,12 +163,21 @@ pub fn role_agent_exec(args: &[String]) -> Result { // cannot gain a writer by overriding launch-time environment flags. validate_implementation_context(&cfg, name, Some(&prompt), &instruction)?; } - let output = dir.join("last-message.txt"); + let public_output = dir.join("last-message.txt"); let trace_dir = cfg.logs.join("agents").join(name); let resume_session = restored .then(|| native_resume_session(&trace_dir)) .flatten(); let executable = env::current_exe().map_err(io_error("resolve multiagent executable"))?; + let role_uid = if access == CodexAccess::WorkspaceWrite { + WRITER_UID + } else { + READER_UID + }; + if access == CodexAccess::WorkspaceWrite { + prepare_workspace_write_boundary(&cfg.state, &cfg.root, &authorization.owned_paths)?; + } + let output = supervisor::prepare_private_output(&cfg.state, name, role_uid)?; let runner_args = build_agent_runner_args( cli, &cfg.root, @@ -180,17 +193,34 @@ pub fn role_agent_exec(args: &[String]) -> Result { &format!("{}\n", std::process::id()), "role supervisor pid", )?; + prepare_role_output_paths(&output, &trace_dir, role_uid)?; + let write_roots = secure_agent_write_roots(&authorization.owned_paths, &output, &trace_dir); let result = role_sandbox::run_supervised( - if access == CodexAccess::WorkspaceWrite { - WRITER_UID - } else { - READER_UID - }, + role_uid, ROLE_GID, + &write_roots, + true, &executable.display().to_string(), &runner_args, ); let _ = fs::remove_file(supervisor_pid); + let revoked = if access == CodexAccess::WorkspaceWrite { + revoke_workspace_writes(&cfg.state, &cfg.root, &authorization.owned_paths) + } else { + Ok(()) + }; + let sealed = supervisor::seal_role_output( + &cfg.state, + name, + &authorization.role, + &authorization.workflow_id, + &output, + &public_output, + ); + supervisor::finish_launch(&cfg.state, name)?; + drop(writer_lock); + revoked?; + sealed?; result } @@ -478,10 +508,17 @@ pub fn launch(args: &[String]) -> Result { resume, )?; if env::var("MULTIAGENT_UID_SANDBOX").as_deref() == Ok("1") { - prepare_uid_state_permissions(&state_dir)?; + supervisor::register_runtime_state(&state_dir)?; + supervisor::prepare_state_permissions(&state_dir)?; if !log_dir.starts_with(&state_dir) { prepare_uid_state_permissions(&log_dir)?; } + let supervisor_pid = supervisor::start(&state_dir, &executable)?; + atomic_write( + &state_dir.join("runtime_state/authority-supervisor.pid"), + &format!("{supervisor_pid}\n"), + "authority supervisor pid", + )?; } let bootstrap_command = format!("bash {}", shell_escape(&bootstrap.display().to_string())); let new_session = [ @@ -584,6 +621,10 @@ fn launch_environment( "MULTIAGENT_REQUIRE_HASH_BOUND_VERIFIER", env_nonempty("MULTIAGENT_REQUIRE_HASH_BOUND_VERIFIER").unwrap_or_else(|| "1".into()), ), + ( + "MULTIAGENT_BASELINE_UNTRACKED_FILE", + env_nonempty("MULTIAGENT_BASELINE_UNTRACKED_FILE").unwrap_or_default(), + ), ("MULTIAGENT_STATE_DIR", state.display().to_string()), ("MULTIAGENT_LOG_DIR", logs.display().to_string()), ("MULTIAGENT_WRITE_POLICY", policy.display().to_string()), @@ -1165,7 +1206,7 @@ pub fn subagent(args: &[String]) -> Result { fn print_subagent_usage() { println!( - "Usage:\n multiagent subagent spawn NAME [--own PATH[,PATH...] ...] [--role ROLE] [--instruction TEXT | --instruction-file PATH | -- TEXT]\n multiagent subagent list|recover-plan|restore-all|gate-check\n multiagent subagent poll|inspect|restore|finalize|kill NAME [OPTIONS]\n multiagent subagent wait NAME [--timeout SECONDS] [--poll-interval SECONDS]\n\nAll durable state and tmux subprocess orchestration are implemented by the Rust CLI." + "Usage:\n multiagent subagent spawn NAME [--own PATH[,PATH...] ...] [--assignment-id ID] [--workflow-id ID --decision-id ID --plan-id ID] [--branch BRANCH] [--start-commit COMMIT] [--role ROLE] [--instruction TEXT | --instruction-file PATH | -- TEXT]\n multiagent subagent list|recover-plan|restore-all|gate-check\n multiagent subagent poll|inspect|restore|finalize|kill NAME [OPTIONS]\n multiagent subagent wait NAME [--timeout SECONDS] [--poll-interval SECONDS]\n\nAll durable state and tmux subprocess orchestration are implemented by the Rust CLI." ); } @@ -1179,6 +1220,7 @@ fn spawn(cfg: &RuntimeConfig, args: &[String]) -> Result<(), String> { let mut instruction_file = None::; let mut owned = Vec::new(); let mut role = String::new(); + let mut assignment_values = BTreeMap::::new(); let mut index = 1; while index < args.len() { match args[index].as_str() { @@ -1193,6 +1235,14 @@ fn spawn(cfg: &RuntimeConfig, args: &[String]) -> Result<(), String> { } index += 2; } + "--assignment-id" | "--workflow-id" | "--decision-id" | "--plan-id" | "--branch" + | "--start-commit" => { + assignment_values.insert( + args[index].clone(), + required_value(args, index, "spawn assignment metadata")?.to_string(), + ); + index += 2; + } "--instruction" => { instruction = required_value(args, index, "spawn --instruction")?.to_string(); index += 2; @@ -1241,7 +1291,21 @@ fn spawn(cfg: &RuntimeConfig, args: &[String]) -> Result<(), String> { instruction = compose_role_instruction(cfg, name, &role, &instruction)?; instruction = append_verifier_diff_binding(cfg, name, &role, &instruction)?; let assignment_role = assignment_role_for_spawn(cfg, name, &role); - let access = codex_access_for_spawn(cfg, name, &role); + let authority_role = if role.is_empty() { + match assignment_role { + "verifier" => "verifier", + "scout" => "scout", + _ => "worker", + } + } else { + role.as_str() + }; + let access = + if env::var("MULTIAGENT_UID_SANDBOX").as_deref() == Ok("1") && authority_role != "worker" { + CodexAccess::ReadOnly + } else { + codex_access_for_spawn(cfg, name, &role) + }; require_command("tmux")?; let cli = &cfg.subagent_cli; @@ -1254,9 +1318,30 @@ fn spawn(cfg: &RuntimeConfig, args: &[String]) -> Result<(), String> { return Err(format!("subagent window already exists: {name}")); } reject_parallel_generic_worker_spawn(cfg, name)?; + if owned.is_empty() && !assignment_values.is_empty() { + return Err("spawn assignment metadata requires --own PATH".into()); + } if !owned.is_empty() { let assignment_dir = cfg.state.join("assignments").join(name); if assignment_dir.join("assignment.env").is_file() { + let metadata = read_env(&assignment_dir.join("assignment.env"))?; + for (flag, key) in [ + ("--assignment-id", "assignment_id"), + ("--workflow-id", "workflow_id"), + ("--decision-id", "decision_id"), + ("--plan-id", "plan_id"), + ("--branch", "branch"), + ("--start-commit", "start_commit"), + ] { + if let Some(requested) = assignment_values.get(flag) { + if metadata.get(key) != Some(requested) { + return Err(format!( + "spawn {flag} does not match existing assignment: agent={name} requested={requested} actual={}", + metadata.get(key).map(String::as_str).unwrap_or("") + )); + } + } + } let allowed = fs::read_to_string(assignment_dir.join("owned-paths")) .map_err(io_error("read assignment owned paths"))? .lines() @@ -1275,21 +1360,41 @@ fn spawn(cfg: &RuntimeConfig, args: &[String]) -> Result<(), String> { } } } else { - let branch = git_text(&cfg.root, &["rev-parse", "--abbrev-ref", "HEAD"])?; + let branch = match assignment_values.get("--branch") { + Some(value) => value.clone(), + None => git_text(&cfg.root, &["rev-parse", "--abbrev-ref", "HEAD"])?, + }; let joined = owned.join(","); - run_self_quiet(&[ - "subagent", - "assignment-create", - name, - "--assignment-id", - &format!("spawn-{name}"), - "--branch", - &branch, - "--owned", - &joined, - "--role", - assignment_role, - ])?; + let assignment_id = assignment_values + .get("--assignment-id") + .cloned() + .unwrap_or_else(|| format!("spawn-{name}")); + let mut command = vec![ + "subagent".to_string(), + "assignment-create".to_string(), + name.to_string(), + "--assignment-id".to_string(), + assignment_id, + "--branch".to_string(), + branch, + "--owned".to_string(), + joined, + "--role".to_string(), + assignment_role.to_string(), + ]; + for flag in [ + "--workflow-id", + "--decision-id", + "--plan-id", + "--start-commit", + ] { + if let Some(value) = assignment_values.get(flag) { + command.push(flag.to_string()); + command.push(value.clone()); + } + } + let command = command.iter().map(String::as_str).collect::>(); + run_self_quiet(&command)?; } } validate_implementation_context(cfg, name, instruction_file.as_deref(), &instruction)?; @@ -1332,6 +1437,24 @@ fn spawn(cfg: &RuntimeConfig, args: &[String]) -> Result<(), String> { )?; prompt_file = Some(path); } + if env::var("MULTIAGENT_UID_SANDBOX").as_deref() == Ok("1") { + let registered_prompt = prompt_file + .as_deref() + .ok_or_else(|| format!("secure subagent prompt is missing: {name}"))?; + run_self_quiet(&[ + "supervisor", + "register-launch", + name, + "--role", + authority_role, + "--cli", + cli, + "--cli-bin", + binary, + "--instruction-file", + ®istered_prompt.display().to_string(), + ])?; + } let cli_command = if env::var("MULTIAGENT_UID_SANDBOX").as_deref() == Ok("1") { if !cfg.headless(cli) { return Err("UID role isolation requires a headless coding-agent backend".into()); @@ -1730,6 +1853,31 @@ fn restore(cfg: &RuntimeConfig, args: &[String]) -> Result<(), String> { } else { None }; + if env::var("MULTIAGENT_UID_SANDBOX").as_deref() == Ok("1") { + let role = match metadata.get("role").map(String::as_str) { + Some("reviewer") => "reviewer", + Some("verifier") => "verifier", + Some("scout") => "scout", + _ => "worker", + }; + run_self_quiet(&[ + "supervisor", + "renew-launch", + name, + "--role", + role, + "--cli", + &cli, + "--cli-bin", + binary, + "--instruction-file", + &prompt_file + .as_deref() + .ok_or_else(|| format!("secure restore prompt is missing: {name}"))? + .display() + .to_string(), + ])?; + } let trace_dir = cfg.logs.join("agents").join(name); let executable = env::current_exe().map_err(io_error("resolve multiagent executable"))?; let cli_command = if env::var("MULTIAGENT_UID_SANDBOX").as_deref() == Ok("1") { @@ -1899,8 +2047,9 @@ fn record_supervisor_termination( #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - fs::set_permissions(&trace_dir, fs::Permissions::from_mode(0o2770)) - .map_err(io_error("set supervisor trace directory permissions"))?; + // The role UID owns this directory. The orchestrator has group write + // access for the termination record, but must not attempt to chmod a + // reader-owned directory after cancellation. fs::set_permissions(&output, fs::Permissions::from_mode(0o660)) .map_err(io_error("set supervisor trace file permissions"))?; } @@ -2005,6 +2154,7 @@ fn codex_access_for_spawn(cfg: &RuntimeConfig, name: &str, role: &str) -> CodexA .map(|value| value.to_string_lossy().to_string()) }); if role == "reviewer" + || role == "verifier" || role == "scout" || lower.contains("decision-authority-reviewer") || matches!( @@ -2020,9 +2170,9 @@ fn codex_access_for_spawn(cfg: &RuntimeConfig, name: &str, role: &str) -> CodexA { CodexAccess::ReadOnly } else { - // Workers need source writes. Technical/build verifiers retain workspace - // writes because repository-local compilers and test runners commonly - // create build artifacts; their role prompt still forbids source edits. + // Only implementation workers receive source writes. Verifiers use + // external caches and temporary directories, so their inability to + // mutate the candidate is mechanical rather than prompt-based. CodexAccess::WorkspaceWrite } } @@ -2306,6 +2456,180 @@ fn role_write_roots(root: &Path, state: &Path, include_source: bool) -> Vec Vec { + let mut paths = owned_paths.iter().cloned().collect::>(); + paths.insert(output.to_path_buf()); + paths.insert(trace_dir.to_path_buf()); + for key in [ + "CODEX_HOME", + "GOCACHE", + "GOMODCACHE", + "CARGO_TARGET_DIR", + "TMPDIR", + "MULTIAGENT_ROLE_SHARED_WRITE_DIR", + ] { + if let Some(path) = env_path(key) { + if path.exists() { + paths.insert(path); + } + } + } + for path in [PathBuf::from("/dev/null"), PathBuf::from("/dev/tty")] { + if path.exists() { + paths.insert(path); + } + } + paths.into_iter().collect() +} + +#[cfg(target_os = "linux")] +fn prepare_workspace_write_boundary( + state: &Path, + root: &Path, + owned_paths: &[PathBuf], +) -> Result<(), String> { + use std::os::unix::fs::PermissionsExt; + + let ledger = state.join("launch-authorizations/active-writer-paths"); + if ledger.is_file() { + for line in fs::read_to_string(&ledger) + .map_err(io_error("read prior writer ownership ledger"))? + .lines() + .filter(|line| !line.is_empty()) + { + let path = PathBuf::from(line); + if path.starts_with(root) && path != root && path.exists() { + set_workspace_tree_owner(&path, 0, false)?; + } + } + } + let text = owned_paths + .iter() + .map(|path| format!("{}\n", path.display())) + .collect::(); + atomic_write(&ledger, &text, "active writer ownership ledger")?; + fs::set_permissions(&ledger, fs::Permissions::from_mode(0o600)) + .map_err(io_error("protect writer ownership ledger"))?; + for path in owned_paths { + if !path.starts_with(root) || path == root { + return Err(format!( + "writer ownership path is outside the repository: {}", + path.display() + )); + } + set_workspace_tree_owner(path, WRITER_UID, true)?; + } + Ok(()) +} + +#[cfg(not(target_os = "linux"))] +fn prepare_workspace_write_boundary( + _state: &Path, + _root: &Path, + _owned_paths: &[PathBuf], +) -> Result<(), String> { + Err("filesystem writer ownership requires Linux".into()) +} + +#[cfg(target_os = "linux")] +fn revoke_workspace_writes( + state: &Path, + root: &Path, + owned_paths: &[PathBuf], +) -> Result<(), String> { + use std::os::unix::fs::PermissionsExt; + + for path in owned_paths { + if path.starts_with(root) && path != root && path.exists() { + set_workspace_tree_owner(path, 0, false)?; + } + } + let ledger = state.join("launch-authorizations/active-writer-paths"); + atomic_write(&ledger, "", "clear writer ownership ledger")?; + fs::set_permissions(&ledger, fs::Permissions::from_mode(0o600)) + .map_err(io_error("protect writer ownership ledger")) +} + +#[cfg(not(target_os = "linux"))] +fn revoke_workspace_writes( + _state: &Path, + _root: &Path, + _owned_paths: &[PathBuf], +) -> Result<(), String> { + Err("filesystem writer ownership requires Linux".into()) +} + +#[cfg(target_os = "linux")] +fn set_workspace_tree_owner(path: &Path, uid: u32, writable: bool) -> Result<(), String> { + use std::os::unix::fs::PermissionsExt; + + let metadata = fs::symlink_metadata(path).map_err(io_error("inspect workspace ownership"))?; + chown_path(path, uid, ROLE_GID)?; + if metadata.file_type().is_symlink() { + return Ok(()); + } + let mode = metadata.permissions().mode(); + if metadata.is_dir() { + let updated = if writable { + mode | 0o700 + } else { + (mode & !0o222) | 0o550 + }; + fs::set_permissions(path, fs::Permissions::from_mode(updated & 0o7777)) + .map_err(io_error("set workspace directory ownership mode"))?; + for entry in fs::read_dir(path).map_err(io_error("read workspace ownership tree"))? { + set_workspace_tree_owner( + &entry + .map_err(io_error("read workspace ownership entry"))? + .path(), + uid, + writable, + )?; + } + } else if metadata.is_file() { + let updated = if writable { + mode | 0o600 + } else { + (mode & !0o222) | 0o440 + }; + fs::set_permissions(path, fs::Permissions::from_mode(updated & 0o7777)) + .map_err(io_error("set workspace file ownership mode"))?; + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn prepare_role_output_paths(output: &Path, trace_dir: &Path, uid: u32) -> Result<(), String> { + use std::os::unix::fs::PermissionsExt; + + if let Some(parent) = output.parent() { + fs::create_dir_all(parent).map_err(io_error("create role output directory"))?; + } + OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(output) + .map_err(io_error("create role output"))?; + fs::create_dir_all(trace_dir).map_err(io_error("create role trace directory"))?; + chown_path(output, uid, ROLE_GID)?; + chown_path(trace_dir, uid, ROLE_GID)?; + fs::set_permissions(output, fs::Permissions::from_mode(0o660)) + .map_err(io_error("set role output permissions"))?; + fs::set_permissions(trace_dir, fs::Permissions::from_mode(0o2770)) + .map_err(io_error("set role trace permissions"))?; + Ok(()) +} + +#[cfg(not(target_os = "linux"))] +fn prepare_role_output_paths(_output: &Path, _trace_dir: &Path, _uid: u32) -> Result<(), String> { + Err("secure role output preparation requires Linux".into()) +} + #[cfg(target_os = "linux")] fn wrap_linux_role_sandbox( command: &str, diff --git a/src/snapshot.rs b/src/snapshot.rs index 0692705..57aa860 100644 --- a/src/snapshot.rs +++ b/src/snapshot.rs @@ -1,9 +1,14 @@ use serde::Serialize; use sha2::{Digest, Sha256}; use std::collections::BTreeSet; -use std::path::Path; +use std::ffi::OsString; +use std::fs; +use std::path::{Path, PathBuf}; use std::process::Command; +#[cfg(unix)] +use std::os::unix::ffi::OsStringExt; + #[derive(Debug, Serialize)] struct Snapshot { final_diff_sha256: String, @@ -58,21 +63,8 @@ fn required_value<'a>(args: &'a [String], index: usize, option: &str) -> Result< } fn capture(root: &Path, base: &str) -> Result { - let output = Command::new("git") - .arg("-C") - .arg(root) - .args(["diff", base, "--binary", "--ignore-submodules=all", "--"]) - .output() - .map_err(|error| format!("run git diff: {error}"))?; - if !output.status.success() { - let message = String::from_utf8_lossy(&output.stderr).trim().to_string(); - return Err(if message.is_empty() { - "git diff failed".into() - } else { - message - }); - } - let diff = String::from_utf8_lossy(&output.stdout); + let bytes = canonical_diff(root, base)?; + let diff = String::from_utf8_lossy(&bytes); let changed_paths = changed_paths(&diff); let changed_code_paths = changed_paths .iter() @@ -80,7 +72,7 @@ fn capture(root: &Path, base: &str) -> Result { .cloned() .collect(); Ok(Snapshot { - final_diff_sha256: format!("{:x}", Sha256::digest(&output.stdout)), + final_diff_sha256: format!("{:x}", Sha256::digest(&bytes)), changed_files: diff .lines() .filter(|line| line.starts_with("diff --git a/")) @@ -90,6 +82,100 @@ fn capture(root: &Path, base: &str) -> Result { }) } +pub(crate) fn canonical_diff(root: &Path, base: &str) -> Result, String> { + let output = Command::new("git") + .arg("-C") + .arg(root) + .args(["diff", base, "--binary", "--ignore-submodules=all", "--"]) + .output() + .map_err(|error| format!("run git diff: {error}"))?; + if !output.status.success() { + return Err(git_error("git diff failed", &output.stderr)); + } + + let mut diff = output.stdout; + for path in untracked_paths(root)? { + let output = Command::new("git") + .arg("-C") + .arg(root) + .args(["diff", "--no-index", "--binary", "--"]) + .arg("/dev/null") + .arg(&path) + .output() + .map_err(|error| format!("run git diff for {}: {error}", path.display()))?; + if !matches!(output.status.code(), Some(0 | 1)) { + return Err(git_error( + &format!("git diff failed for untracked path {}", path.display()), + &output.stderr, + )); + } + diff.extend_from_slice(&output.stdout); + } + Ok(diff) +} + +fn untracked_paths(root: &Path) -> Result, String> { + let output = Command::new("git") + .arg("-C") + .arg(root) + .args(["ls-files", "--others", "--exclude-standard", "-z"]) + .output() + .map_err(|error| format!("list untracked files: {error}"))?; + if !output.status.success() { + return Err(git_error("git ls-files failed", &output.stderr)); + } + let baseline = baseline_untracked()?; + let mut paths = output + .stdout + .split(|byte| *byte == 0) + .filter(|bytes| !bytes.is_empty()) + .map(path_from_git_bytes) + .filter(|path| !baseline.contains(&path.to_string_lossy().into_owned())) + .filter(|path| { + fs::symlink_metadata(root.join(path)) + .map(|metadata| metadata.is_file() || metadata.file_type().is_symlink()) + .unwrap_or(false) + }) + .collect::>(); + paths.sort(); + Ok(paths) +} + +fn baseline_untracked() -> Result, String> { + let Ok(path) = std::env::var("MULTIAGENT_BASELINE_UNTRACKED_FILE") else { + return Ok(BTreeSet::new()); + }; + if path.is_empty() { + return Ok(BTreeSet::new()); + } + let contents = fs::read_to_string(&path) + .map_err(|error| format!("read baseline untracked file {path}: {error}"))?; + Ok(contents + .lines() + .filter(|line| !line.is_empty()) + .map(str::to_string) + .collect()) +} + +#[cfg(unix)] +fn path_from_git_bytes(bytes: &[u8]) -> PathBuf { + PathBuf::from(OsString::from_vec(bytes.to_vec())) +} + +#[cfg(not(unix))] +fn path_from_git_bytes(bytes: &[u8]) -> PathBuf { + PathBuf::from(String::from_utf8_lossy(bytes).into_owned()) +} + +fn git_error(fallback: &str, stderr: &[u8]) -> String { + let message = String::from_utf8_lossy(stderr).trim().to_string(); + if message.is_empty() { + fallback.to_string() + } else { + message + } +} + fn changed_paths(diff: &str) -> BTreeSet { let mut paths = BTreeSet::new(); for line in diff.lines() { diff --git a/src/subagent.rs b/src/subagent.rs index 3fd890d..aa5dfa1 100644 --- a/src/subagent.rs +++ b/src/subagent.rs @@ -259,9 +259,17 @@ fn checkpoint_update(args: &[String]) -> Result<(), String> { let _lock = lock_file(&assignments.join(".lock"), "assignments")?; atomic_write(&dir.join("checkpoint.env"), &text)?; atomic_write(&dir.join("status"), &format!("{status}\n"))?; - let subagent = config::state_dir()?.join("subagents").join(name); - fs::create_dir_all(&subagent).map_err(io_error("create subagent state"))?; - atomic_write(&subagent.join("status"), &format!("{status}\n"))?; + // Under UID isolation, the authority server owns assignments but must not + // create files in the orchestrator-owned runtime projection. Doing so + // would make the later role prompt/status directory unwritable by the + // orchestrator. Runtime spawn/poll remains the sole owner of that mirror. + if !(env::var("MULTIAGENT_UID_SANDBOX").as_deref() == Ok("1") + && env::var("MULTIAGENT_AUTHORITY_SERVER_CHILD").as_deref() == Ok("1")) + { + let subagent = config::state_dir()?.join("subagents").join(name); + fs::create_dir_all(&subagent).map_err(io_error("create subagent state"))?; + atomic_write(&subagent.join("status"), &format!("{status}\n"))?; + } println!("checkpoint updated\t{name}\t{status}"); Ok(()) } @@ -736,22 +744,7 @@ fn finding_dismiss(args: &[String]) -> Result<(), String> { )); } } - let evidence_path = state - .join("subagents") - .join(verified) - .join("last-message.txt"); - if !evidence_path.is_file() { - return Err(format!( - "finding-dismiss requires verifier evidence: {verified}" - )); - } - let evidence = - fs::read_to_string(&evidence_path).map_err(io_error("read verifier evidence"))?; - if !accepted_verdict(&evidence) { - return Err(format!( - "finding dismissal verifier {verified} did not ACCEPT" - )); - } + let (evidence_path, evidence) = verifier_evidence(&state, verified, "finding-dismiss")?; let recheck: Value = serde_json::from_str(recheck_raw) .map_err(|error| format!("invalid finding dismissal recheck: {error}"))?; let object = recheck @@ -832,6 +825,61 @@ fn accepted_verdict(text: &str) -> bool { .strip_prefix("verdict=") .is_some_and(|value| value.trim().starts_with("accepted")) } + +fn uid_authority_child() -> bool { + env::var("MULTIAGENT_UID_SANDBOX").as_deref() == Ok("1") + && env::var("MULTIAGENT_AUTHORITY_SERVER_CHILD").as_deref() == Ok("1") +} + +fn verifier_evidence( + state: &Path, + verified: &str, + operation: &str, +) -> Result<(PathBuf, String), String> { + let evidence_path = if uid_authority_child() { + let directory = state.join("reviewer-evidence").join(verified); + let metadata = read_env(&directory.join("evidence.env"))?; + if env_value(&metadata, "role") != "reviewer" + || env_value(&metadata, "access") != "read-only" + || env_value(&metadata, "state") != "completed" + { + return Err(format!( + "{operation} requires completed supervisor-sealed reviewer evidence: {verified}" + )); + } + let workflow = env::var("MULTIAGENT_WORKFLOW_ID").unwrap_or_default(); + if !workflow.is_empty() && env_value(&metadata, "workflow_id") != workflow { + return Err(format!( + "{operation} reviewer evidence {verified} belongs to a different workflow" + )); + } + let path = directory.join("last-message.txt"); + let expected = env_value(&metadata, "output_sha256"); + if expected.is_empty() || !file_sha256(&path)?.eq_ignore_ascii_case(expected) { + return Err(format!( + "{operation} reviewer evidence {verified} failed its supervisor seal" + )); + } + path + } else { + state + .join("subagents") + .join(verified) + .join("last-message.txt") + }; + if !evidence_path.is_file() { + return Err(format!( + "{operation} requires verifier evidence: {verified}" + )); + } + let evidence = + fs::read_to_string(&evidence_path).map_err(io_error("read verifier evidence"))?; + if !accepted_verdict(&evidence) { + return Err(format!("{operation} verifier {verified} did not ACCEPT")); + } + Ok((evidence_path, evidence)) +} + fn current_final_diff_sha256() -> Result { if env::var("MULTIAGENT_REQUIRE_HASH_BOUND_VERIFIER").as_deref() != Ok("1") { return Ok(String::new()); @@ -840,23 +888,17 @@ fn current_final_diff_sha256() -> Result { if !root.is_dir() { return Ok(String::new()); } - let mut command = Command::new("git"); - command - .arg("-C") - .arg(root) - .args(["diff", "--binary", "--ignore-submodules=all"]); - if let Ok(start) = env::var("MULTIAGENT_START_HEAD") { - if !start.is_empty() { - command.arg(start); - } - } - let output = command.output().map_err(io_error("capture final diff"))?; - if !output.status.success() || output.stdout.iter().all(u8::is_ascii_whitespace) { + let base = env::var("MULTIAGENT_START_HEAD") + .ok() + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "HEAD".into()); + let diff = crate::snapshot::canonical_diff(&root, &base)?; + if diff.iter().all(u8::is_ascii_whitespace) { return Ok(String::new()); } use sha2::{Digest, Sha256}; let mut digest = Sha256::new(); - digest.update(&output.stdout); + digest.update(&diff); Ok(format!("{:x}", digest.finalize())) } @@ -1615,7 +1657,8 @@ fn todo_close(args: &[String]) -> Result<(), String> { )?; let notes = option_first(&values, "--notes"); reject_newline("--notes", notes)?; - let base = config::state_dir()?.join("todos"); + let state = config::state_dir()?; + let base = state.join("todos"); let dir = base.join(todo_id); if !dir.join("todo.env").is_file() { return Err(format!("no todo: {todo_id}")); @@ -1631,6 +1674,35 @@ fn todo_close(args: &[String]) -> Result<(), String> { .map_err(|error| format!("invalid recheck JSON: {error}"))?; validate_closure(&recheck)?; validate_required_commands(&dir, "verifier recheck", &recheck)?; + let require_verifier_evidence = uid_authority_child() + || env::var("MULTIAGENT_REQUIRE_HASH_BOUND_VERIFIER").as_deref() == Ok("1"); + let (evidence_path, evidence) = if require_verifier_evidence { + verifier_evidence(&state, verified, "todo-close")? + } else { + ( + state + .join("subagents") + .join(verified) + .join("last-message.txt"), + String::new(), + ) + }; + let final_hash = current_final_diff_sha256()?; + if !final_hash.is_empty() { + let reported = recheck + .get("final_diff_sha256") + .or_else(|| recheck.get("final_diff_hash")) + .and_then(Value::as_str) + .unwrap_or(""); + if !reported.eq_ignore_ascii_case(&final_hash) { + return Err(format!("todo-close must bind to final diff {final_hash}")); + } + if !evidence_matches_hash(&evidence, &final_hash) { + return Err(format!( + "todo-close verifier {verified} is not bound to final diff {final_hash}" + )); + } + } let metadata = read_env(&dir.join("todo.env"))?; let source = env_value(&metadata, "source_finding_id"); let source_hash = env_value(&metadata, "source_finding_hash"); @@ -1646,7 +1718,7 @@ fn todo_close(args: &[String]) -> Result<(), String> { &dir.join("recheck.json"), &format!("{}\n", serde_json::to_string(&recheck).map_err(json_error)?), )?; - let closure = json!({"todo_id":todo_id,"source_finding_id":source,"source_finding_hash":if source_hash.is_empty(){Value::Null}else{Value::String(source_hash.into())},"verified_by":verified,"recheck":recheck,"notes":notes,"created_at":created}); + let closure = json!({"todo_id":todo_id,"source_finding_id":source,"source_finding_hash":if source_hash.is_empty(){Value::Null}else{Value::String(source_hash.into())},"verified_by":verified,"verifier_evidence":evidence_path.display().to_string(),"recheck":recheck,"notes":notes,"created_at":created}); write_json(&dir.join("closure.json"), &closure)?; update_todo_state_locked(&dir, None, "closed")?; println!("todo closed\t{todo_id}\t{verified}"); diff --git a/src/supervisor.rs b/src/supervisor.rs new file mode 100644 index 0000000..1472071 --- /dev/null +++ b/src/supervisor.rs @@ -0,0 +1,1049 @@ +use crate::config; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; +use std::env; +use std::fs; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; +#[cfg(target_os = "linux")] +use std::process::{Command, Stdio}; +#[cfg(target_os = "linux")] +use std::thread; +#[cfg(target_os = "linux")] +use std::time::{Duration, Instant}; + +#[cfg(target_os = "linux")] +use std::os::unix::net::UnixListener; +#[cfg(unix)] +use std::os::unix::net::UnixStream; + +const SERVER_CHILD_ENV: &str = "MULTIAGENT_AUTHORITY_SERVER_CHILD"; +#[cfg(target_os = "linux")] +const AUTHORITY_REGISTRY: &str = "/run/multiagent/authority-state-10001"; +#[cfg(target_os = "linux")] +const CONTROL_DIRECTORIES: &[&str] = &[ + "assignments", + "decisions", + "findings", + "launch-authorizations", + "reviewer-evidence", + "role-io", + "todos", + "validation-leases", + "workflows", +]; + +#[derive(Deserialize, Serialize)] +struct Request { + command: String, + args: Vec, +} + +#[derive(Deserialize, Serialize)] +struct Response { + code: i32, + stdout: String, + stderr: String, +} + +pub fn run(args: &[String]) -> Result { + match args { + [command] if command == "bootstrap-test" => bootstrap_test(), + [command] if command == "serve" => serve(&authority_socket(&config::state_dir()?)), + [command] if command == "stop" => proxy_request(Request { + command: "supervisor".into(), + args: vec!["shutdown".into()], + }), + [command, rest @ ..] if command == "register-launch" && server_child() => { + register_launch(rest, false)?; + Ok(ExitCode::SUCCESS) + } + [command, rest @ ..] if command == "renew-launch" && server_child() => { + register_launch(rest, true)?; + Ok(ExitCode::SUCCESS) + } + [command] if command == "shutdown" && server_child() => Ok(ExitCode::SUCCESS), + _ => Err("usage: multiagent supervisor stop".into()), + } +} + +fn bootstrap_test() -> Result { + if env::var("MULTIAGENT_TEST_MODE").as_deref() != Ok("1") { + return Err("supervisor bootstrap-test requires MULTIAGENT_TEST_MODE=1".into()); + } + #[cfg(unix)] + if unsafe { libc::getuid() } != 0 || unsafe { libc::geteuid() } != 0 { + return Err("supervisor bootstrap-test requires real root".into()); + } + let state = config::state_dir()?; + fs::create_dir_all(&state).map_err(|error| format!("create test authority state: {error}"))?; + register_runtime_state(&state)?; + prepare_state_permissions(&state)?; + let executable = env::current_exe() + .map_err(|error| format!("resolve test supervisor executable: {error}"))?; + let pid = start(&state, &executable)?; + println!("{pid}"); + Ok(ExitCode::SUCCESS) +} + +pub fn proxy_if_required(command: &str, args: &[String]) -> Option> { + if command == "supervisor" && args.first().map(String::as_str) == Some("stop") { + return None; + } + if !uid_sandbox() || !authority_client_uid() || server_child() || !proxy_command(command, args) + { + return None; + } + Some(proxy_request(Request { + command: command.into(), + args: args.to_vec(), + })) +} + +fn proxy_command(command: &str, args: &[String]) -> bool { + match command { + "workflow" | "decision" | "dag" => true, + "supervisor" => args.first().is_some_and(|value| { + matches!(value.as_str(), "stop" | "register-launch" | "renew-launch") + }), + "subagent" => args.first().is_some_and(|value| { + matches!( + value.as_str(), + "assignment-create" + | "assignment-show" + | "assignment-status" + | "assignment-check" + | "checkpoint-update" + | "checkpoint-show" + | "finding-create" + | "finding-show" + | "finding-list" + | "finding-dismiss" + | "todo-create" + | "todo-show" + | "todo-list" + | "todo-assign" + | "todo-status" + | "resolution-create" + | "todo-close" + | "validation-lease-acquire" + | "validation-lease-status" + | "validation-lease-show" + | "validation-lease-list" + | "gate-check" + ) + }), + _ => false, + } +} + +#[derive(Clone, Debug)] +pub struct LaunchAuthorization { + pub role: String, + pub access: String, + pub workflow_id: String, + pub cli: String, + pub cli_bin: String, + pub instruction: PathBuf, + pub owned_paths: Vec, +} + +fn register_launch(args: &[String], renew: bool) -> Result<(), String> { + let name = args + .first() + .filter(|value| valid_name(value)) + .ok_or_else(|| "register-launch requires a valid NAME".to_string())?; + let options = parse_options(&args[1..])?; + let role = required_option(&options, "--role")?; + let cli = required_option(&options, "--cli")?; + let cli_bin = required_option(&options, "--cli-bin")?; + let instruction_source = PathBuf::from(required_option(&options, "--instruction-file")?); + if !matches!(role, "worker" | "verifier" | "reviewer" | "scout") { + return Err("register-launch role must be worker, verifier, reviewer, or scout".into()); + } + if !matches!(cli, "codex" | "claude" | "qwen") { + return Err("register-launch backend must be codex, claude, or qwen".into()); + } + let expected_binary = env::var(match cli { + "codex" => "CODEX_BIN", + "claude" => "CLAUDE_BIN", + "qwen" => "QWEN_BIN", + _ => unreachable!(), + }) + .map_err(|_| format!("authority supervisor has no configured {cli} binary"))?; + if cli_bin != expected_binary { + return Err("register-launch binary does not match the launch manifest".into()); + } + let state = config::state_dir()?; + let expected_instruction = state.join("subagents").join(name).join(if renew { + "restore-instruction.txt" + } else { + "instruction.txt" + }); + if fs::canonicalize(&instruction_source).ok() != fs::canonicalize(&expected_instruction).ok() + || !instruction_source.is_file() + { + return Err(format!( + "register-launch instruction must be the persisted subagent instruction: {}", + expected_instruction.display() + )); + } + let access = if role == "worker" { + "workspace-write" + } else { + "read-only" + }; + let workflow_id = env::var("MULTIAGENT_WORKFLOW_ID").unwrap_or_default(); + let assignment = state.join("assignments").join(name); + let owned_paths = if access == "workspace-write" { + if !assignment.join("assignment.env").is_file() { + return Err(format!( + "workspace writer requires a supervisor-owned assignment: {name}" + )); + } + let status = fs::read_to_string(assignment.join("status")).unwrap_or_default(); + if matches!(status.trim(), "done" | "failed" | "released" | "cancelled") { + return Err(format!("assignment is not active: {name}")); + } + read_owned_paths(&state, name)? + } else { + Vec::new() + }; + let directory = state.join("launch-authorizations").join(name); + if directory.exists() { + if !renew { + return Err(format!("launch authorization already exists: {name}")); + } + let current = read_env_file(&directory.join("launch.env"))?; + if current.get("state").map(String::as_str) != Some("completed") { + return Err(format!("launch authorization is not renewable: {name}")); + } + if current.get("role").map(String::as_str) != Some(role) + || current.get("cli").map(String::as_str) != Some(cli) + || current.get("cli_bin").map(String::as_str) != Some(cli_bin) + { + return Err(format!( + "renewed launch cannot change role or coding-agent identity: {name}" + )); + } + } else if renew { + return Err(format!("launch authorization does not exist: {name}")); + } + fs::create_dir_all(&directory) + .map_err(|error| format!("create launch authorization: {error}"))?; + let instruction = fs::read(&instruction_source) + .map_err(|error| format!("read registered instruction: {error}"))?; + let instruction_path = directory.join("instruction.txt"); + atomic_write_bytes(&instruction_path, &instruction)?; + let metadata = format!( + "name={name}\nrole={role}\naccess={access}\nworkflow_id={workflow_id}\ncli={cli}\ncli_bin={cli_bin}\ninstruction_sha256={:x}\nstate=registered\n", + Sha256::digest(&instruction) + ); + atomic_write_bytes(&directory.join("launch.env"), metadata.as_bytes())?; + if !owned_paths.is_empty() { + let text = owned_paths + .iter() + .map(|path| format!("{}\n", path.display())) + .collect::(); + atomic_write_bytes(&directory.join("owned-paths"), text.as_bytes())?; + } + println!("launch authorized\t{name}\t{role}\t{access}"); + Ok(()) +} + +pub fn claim_launch(state: &Path, name: &str) -> Result { + let directory = state.join("launch-authorizations").join(name); + let metadata = read_env_file(&directory.join("launch.env"))?; + if metadata.get("name").map(String::as_str) != Some(name) + || metadata.get("state").map(String::as_str) != Some("registered") + { + return Err(format!( + "launch authorization is missing or already consumed: {name}" + )); + } + let instruction = directory.join("instruction.txt"); + let bytes = + fs::read(&instruction).map_err(|error| format!("read authorized instruction: {error}"))?; + let actual = format!("{:x}", Sha256::digest(&bytes)); + if metadata.get("instruction_sha256") != Some(&actual) { + return Err(format!("authorized instruction hash changed: {name}")); + } + let owned_paths = read_owned_paths(state, name)?; + let authorization = LaunchAuthorization { + role: required_field(&metadata, "role")?.into(), + access: required_field(&metadata, "access")?.into(), + workflow_id: metadata.get("workflow_id").cloned().unwrap_or_default(), + cli: required_field(&metadata, "cli")?.into(), + cli_bin: required_field(&metadata, "cli_bin")?.into(), + instruction, + owned_paths, + }; + write_launch_state(&directory, &metadata, "running")?; + Ok(authorization) +} + +pub fn launch_requires_writer(state: &Path, name: &str) -> Result { + let metadata = read_env_file( + &state + .join("launch-authorizations") + .join(name) + .join("launch.env"), + )?; + if metadata.get("name").map(String::as_str) != Some(name) + || metadata.get("state").map(String::as_str) != Some("registered") + { + return Err(format!( + "launch authorization is missing or already consumed: {name}" + )); + } + Ok(metadata.get("role").map(String::as_str) == Some("worker") + && metadata.get("access").map(String::as_str) == Some("workspace-write")) +} + +pub fn finish_launch(state: &Path, name: &str) -> Result<(), String> { + let directory = state.join("launch-authorizations").join(name); + let metadata = read_env_file(&directory.join("launch.env"))?; + if metadata.get("state").map(String::as_str) != Some("running") { + return Err(format!("launch authorization is not running: {name}")); + } + write_launch_state(&directory, &metadata, "completed") +} + +#[cfg(target_os = "linux")] +pub fn prepare_private_output(state: &Path, name: &str, uid: u32) -> Result { + use std::os::unix::fs::PermissionsExt; + + let directory = state.join("role-io").join(name); + fs::create_dir_all(&directory) + .map_err(|error| format!("create private role output directory: {error}"))?; + chown(&directory, uid, config::ROLE_GID)?; + fs::set_permissions(&directory, fs::Permissions::from_mode(0o700)) + .map_err(|error| format!("protect private role output directory: {error}"))?; + let output = directory.join(format!("final-message.{}.txt", std::process::id())); + fs::write(&output, []).map_err(|error| format!("create private role output: {error}"))?; + chown(&output, uid, config::ROLE_GID)?; + fs::set_permissions(&output, fs::Permissions::from_mode(0o600)) + .map_err(|error| format!("protect private role output: {error}"))?; + Ok(output) +} + +#[cfg(not(target_os = "linux"))] +pub fn prepare_private_output(_state: &Path, _name: &str, _uid: u32) -> Result { + Err("private role output requires Linux UID isolation".into()) +} + +#[cfg(target_os = "linux")] +pub fn seal_role_output( + state: &Path, + name: &str, + role: &str, + workflow_id: &str, + private_output: &Path, + public_output: &Path, +) -> Result<(), String> { + use std::os::unix::fs::PermissionsExt; + + let bytes = + fs::read(private_output).map_err(|error| format!("read private role output: {error}"))?; + atomic_write_bytes(public_output, &bytes)?; + chown(public_output, config::ORCHESTRATOR_UID, config::ROLE_GID)?; + fs::set_permissions(public_output, fs::Permissions::from_mode(0o660)) + .map_err(|error| format!("set public role output permissions: {error}"))?; + if role == "reviewer" { + let directory = state.join("reviewer-evidence").join(name); + fs::create_dir_all(&directory) + .map_err(|error| format!("create reviewer evidence directory: {error}"))?; + chown(&directory, config::SUPERVISOR_UID, config::ROLE_GID)?; + fs::set_permissions(&directory, fs::Permissions::from_mode(0o2750)) + .map_err(|error| format!("protect reviewer evidence directory: {error}"))?; + atomic_write_bytes(&directory.join("last-message.txt"), &bytes)?; + let metadata = format!( + "name={name}\nrole=reviewer\naccess=read-only\nworkflow_id={workflow_id}\nstate=completed\noutput_sha256={:x}\n", + Sha256::digest(&bytes) + ); + atomic_write_bytes(&directory.join("evidence.env"), metadata.as_bytes())?; + } + Ok(()) +} + +#[cfg(not(target_os = "linux"))] +pub fn seal_role_output( + _state: &Path, + _name: &str, + _role: &str, + _workflow_id: &str, + _private_output: &Path, + _public_output: &Path, +) -> Result<(), String> { + Err("sealed role output requires Linux UID isolation".into()) +} + +fn write_launch_state( + directory: &Path, + metadata: &BTreeMap, + state: &str, +) -> Result<(), String> { + let mut text = String::new(); + for key in [ + "name", + "role", + "access", + "cli", + "cli_bin", + "instruction_sha256", + ] { + text.push_str(&format!("{key}={}\n", required_field(metadata, key)?)); + } + text.push_str(&format!( + "workflow_id={}\n", + metadata + .get("workflow_id") + .map(String::as_str) + .unwrap_or("") + )); + text.push_str(&format!("state={state}\n")); + atomic_write_bytes(&directory.join("launch.env"), text.as_bytes()) +} + +fn read_owned_paths(state: &Path, name: &str) -> Result, String> { + let root = fs::canonicalize(config::root()?) + .map_err(|error| format!("resolve authority workspace: {error}"))?; + let path = state.join("assignments").join(name).join("owned-paths"); + if !path.is_file() { + return Ok(Vec::new()); + } + let mut values = Vec::new(); + for relative in fs::read_to_string(path) + .map_err(|error| format!("read authorized owned paths: {error}"))? + .lines() + .filter(|line| !line.is_empty()) + { + let candidate = root.join(relative); + let canonical = fs::canonicalize(&candidate).map_err(|_| { + format!( + "secure writer owned path must already exist: {}", + candidate.display() + ) + })?; + if canonical == root || !canonical.starts_with(&root) { + return Err(format!("authorized path escaped the workspace: {relative}")); + } + values.push(canonical); + } + Ok(values) +} + +fn parse_options(args: &[String]) -> Result, String> { + if args.len() % 2 != 0 { + return Err("register-launch options require flag/value pairs".into()); + } + let mut values = BTreeMap::new(); + for pair in args.chunks_exact(2) { + if !matches!( + pair[0].as_str(), + "--role" | "--cli" | "--cli-bin" | "--instruction-file" + ) || pair[1].contains(['\n', '\r']) + { + return Err(format!("invalid register-launch option: {}", pair[0])); + } + values.insert(pair[0].clone(), pair[1].clone()); + } + Ok(values) +} + +fn required_option<'a>( + values: &'a BTreeMap, + name: &str, +) -> Result<&'a str, String> { + values + .get(name) + .filter(|value| !value.is_empty()) + .map(String::as_str) + .ok_or_else(|| format!("register-launch requires {name}")) +} + +fn required_field<'a>(values: &'a BTreeMap, name: &str) -> Result<&'a str, String> { + values + .get(name) + .filter(|value| !value.is_empty()) + .map(String::as_str) + .ok_or_else(|| format!("launch authorization is missing {name}")) +} + +fn read_env_file(path: &Path) -> Result, String> { + let mut values = BTreeMap::new(); + for line in fs::read_to_string(path) + .map_err(|error| format!("read launch authorization {}: {error}", path.display()))? + .lines() + { + if let Some((key, value)) = line.split_once('=') { + values.insert(key.into(), value.into()); + } + } + Ok(values) +} + +fn valid_name(name: &str) -> bool { + !name.is_empty() + && !name.starts_with('-') + && name != "orchestrator" + && name + .chars() + .all(|value| value.is_ascii_alphanumeric() || matches!(value, '_' | '.' | '-')) +} + +fn atomic_write_bytes(path: &Path, bytes: &[u8]) -> Result<(), String> { + let parent = path + .parent() + .ok_or_else(|| format!("path has no parent: {}", path.display()))?; + fs::create_dir_all(parent) + .map_err(|error| format!("create authority directory {}: {error}", parent.display()))?; + let temporary = parent.join(format!( + ".{}.tmp.{}", + path.file_name() + .and_then(|value| value.to_str()) + .unwrap_or("authority"), + std::process::id() + )); + fs::write(&temporary, bytes) + .map_err(|error| format!("write authority temporary file: {error}"))?; + fs::rename(&temporary, path).map_err(|error| format!("publish authority file: {error}"))?; + #[cfg(unix)] + set_mode(path, 0o640)?; + #[cfg(target_os = "linux")] + if unsafe { libc::geteuid() } == 0 { + chown(path, config::SUPERVISOR_UID, config::ROLE_GID)?; + } + Ok(()) +} + +fn uid_sandbox() -> bool { + env::var("MULTIAGENT_UID_SANDBOX").as_deref() == Ok("1") +} + +fn server_child() -> bool { + env::var(SERVER_CHILD_ENV).as_deref() == Ok("1") +} + +#[cfg(unix)] +fn authority_client_uid() -> bool { + matches!( + unsafe { libc::getuid() }, + config::ORCHESTRATOR_UID | config::WRITER_UID | config::READER_UID + ) +} + +#[cfg(not(unix))] +fn authority_client_uid() -> bool { + false +} + +pub fn authority_socket(state: &Path) -> PathBuf { + state.join("authority.sock") +} + +#[cfg(target_os = "linux")] +pub fn register_runtime_state(state: &Path) -> Result<(), String> { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + + if unsafe { libc::geteuid() } != 0 { + return Err("registering authority state requires root".into()); + } + let canonical = fs::canonicalize(state) + .map_err(|error| format!("canonicalize authority state {}: {error}", state.display()))?; + let registry = Path::new(AUTHORITY_REGISTRY); + let parent = registry + .parent() + .ok_or_else(|| "authority registry has no parent".to_string())?; + fs::create_dir_all(parent) + .map_err(|error| format!("create authority registry directory: {error}"))?; + let parent_metadata = fs::metadata(parent) + .map_err(|error| format!("inspect authority registry directory: {error}"))?; + if parent_metadata.uid() != 0 || parent_metadata.permissions().mode() & 0o022 != 0 { + return Err("authority registry directory must be root-owned and non-writable".into()); + } + if registry.exists() { + let existing = fs::read_to_string(registry) + .map_err(|error| format!("read authority registry: {error}"))?; + if Path::new(existing.trim()) != canonical { + return Err(format!( + "another UID-isolated authority state is already registered: {}", + existing.trim() + )); + } + return Ok(()); + } + fs::write(registry, format!("{}\n", canonical.display())) + .map_err(|error| format!("write authority registry: {error}"))?; + fs::set_permissions(registry, fs::Permissions::from_mode(0o600)) + .map_err(|error| format!("protect authority registry: {error}"))?; + Ok(()) +} + +#[cfg(not(target_os = "linux"))] +pub fn register_runtime_state(_state: &Path) -> Result<(), String> { + Err("authority state registration requires Linux".into()) +} + +#[cfg(target_os = "linux")] +pub fn validate_runtime_state(state: &Path) -> Result<(), String> { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + + let registry = Path::new(AUTHORITY_REGISTRY); + let metadata = fs::metadata(registry) + .map_err(|_| "trusted authority state is not registered".to_string())?; + if metadata.uid() != 0 || metadata.permissions().mode() & 0o077 != 0 { + return Err("trusted authority state registry has unsafe ownership or mode".into()); + } + let expected = fs::read_to_string(registry) + .map_err(|error| format!("read trusted authority state: {error}"))?; + let actual = fs::canonicalize(state) + .map_err(|error| format!("canonicalize requested authority state: {error}"))?; + if actual != Path::new(expected.trim()) { + return Err(format!( + "requested state is not the registered authority state: {}", + state.display() + )); + } + Ok(()) +} + +#[cfg(not(target_os = "linux"))] +pub fn validate_runtime_state(_state: &Path) -> Result<(), String> { + Err("authority state validation requires Linux".into()) +} + +#[cfg(unix)] +fn proxy_request(request: Request) -> Result { + let state = config::state_dir()?; + let socket = authority_socket(&state); + let mut stream = UnixStream::connect(&socket) + .map_err(|error| format!("connect authority supervisor {}: {error}", socket.display()))?; + let payload = serde_json::to_vec(&request) + .map_err(|error| format!("encode authority request: {error}"))?; + stream + .write_all(&payload) + .map_err(|error| format!("send authority request: {error}"))?; + stream + .shutdown(std::net::Shutdown::Write) + .map_err(|error| format!("finish authority request: {error}"))?; + let mut bytes = Vec::new(); + stream + .read_to_end(&mut bytes) + .map_err(|error| format!("read authority response: {error}"))?; + let response: Response = serde_json::from_slice(&bytes) + .map_err(|error| format!("decode authority response: {error}"))?; + print!("{}", response.stdout); + eprint!("{}", response.stderr); + Ok(ExitCode::from(response.code.clamp(0, 255) as u8)) +} + +#[cfg(not(unix))] +fn proxy_request(_request: Request) -> Result { + Err("authority supervisor requires Unix".into()) +} + +#[cfg(target_os = "linux")] +fn serve(socket: &Path) -> Result { + if unsafe { libc::getuid() } != config::SUPERVISOR_UID { + return Err(format!( + "authority supervisor must run as uid {}", + config::SUPERVISOR_UID + )); + } + if socket.exists() { + fs::remove_file(socket).map_err(|error| { + format!( + "remove stale authority socket {}: {error}", + socket.display() + ) + })?; + } + let listener = UnixListener::bind(socket) + .map_err(|error| format!("bind authority socket {}: {error}", socket.display()))?; + set_mode(socket, 0o660)?; + for incoming in listener.incoming() { + let mut stream = incoming.map_err(|error| format!("accept authority request: {error}"))?; + let peer_uid = peer_uid(&stream)?; + if !matches!( + peer_uid, + 0 | config::ORCHESTRATOR_UID | config::WRITER_UID | config::READER_UID + ) { + let _ = write_response( + &mut stream, + &Response { + code: 1, + stdout: String::new(), + stderr: "authority supervisor: unauthorized peer\n".into(), + }, + ); + continue; + } + let mut bytes = Vec::new(); + stream + .read_to_end(&mut bytes) + .map_err(|error| format!("read authority request: {error}"))?; + let request: Request = match serde_json::from_slice(&bytes) { + Ok(request) => request, + Err(error) => { + write_response( + &mut stream, + &Response { + code: 1, + stdout: String::new(), + stderr: format!("authority supervisor: invalid request: {error}\n"), + }, + )?; + continue; + } + }; + if request.command == "supervisor" && request.args == ["shutdown"] { + write_response( + &mut stream, + &Response { + code: 0, + stdout: String::new(), + stderr: String::new(), + }, + )?; + let _ = fs::remove_file(socket); + return Ok(ExitCode::SUCCESS); + } + if !proxy_command(&request.command, &request.args) + || !caller_authorized(peer_uid, &request.command, &request.args) + { + write_response( + &mut stream, + &Response { + code: 1, + stdout: String::new(), + stderr: format!( + "authority supervisor: caller uid {peer_uid} is not authorized for: {} {}\n", + request.command, + request.args.first().map(String::as_str).unwrap_or("") + ), + }, + )?; + continue; + } + write_response(&mut stream, &execute(request)?)?; + } + Ok(ExitCode::SUCCESS) +} + +#[cfg(target_os = "linux")] +fn execute(request: Request) -> Result { + let executable = + env::current_exe().map_err(|error| format!("resolve authority executable: {error}"))?; + let output = Command::new(executable) + .arg(&request.command) + .args(&request.args) + .env(SERVER_CHILD_ENV, "1") + .stdin(Stdio::null()) + .output() + .map_err(|error| format!("execute authority transaction: {error}"))?; + Ok(Response { + code: output.status.code().unwrap_or(1), + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + }) +} + +#[cfg(target_os = "linux")] +fn write_response(stream: &mut UnixStream, response: &Response) -> Result<(), String> { + let bytes = serde_json::to_vec(response) + .map_err(|error| format!("encode authority response: {error}"))?; + stream + .write_all(&bytes) + .map_err(|error| format!("write authority response: {error}")) +} + +#[cfg(target_os = "linux")] +fn peer_uid(stream: &UnixStream) -> Result { + use std::os::fd::AsRawFd; + + let mut credentials: libc::ucred = unsafe { std::mem::zeroed() }; + let mut length = std::mem::size_of::() as libc::socklen_t; + let result = unsafe { + libc::getsockopt( + stream.as_raw_fd(), + libc::SOL_SOCKET, + libc::SO_PEERCRED, + &mut credentials as *mut _ as *mut libc::c_void, + &mut length, + ) + }; + if result != 0 { + return Err(format!( + "read authority peer credentials: {}", + std::io::Error::last_os_error() + )); + } + Ok(credentials.uid) +} + +#[cfg(any(target_os = "linux", test))] +fn caller_authorized(uid: u32, command: &str, args: &[String]) -> bool { + if uid == 0 { + return true; + } + let subcommand = args.first().map(String::as_str).unwrap_or(""); + match command { + "workflow" | "decision" | "dag" | "supervisor" => uid == config::ORCHESTRATOR_UID, + "subagent" => match subcommand { + "finding-create" => uid == config::READER_UID, + // The orchestrator may request a disposition, but subagent.rs + // authorizes it only from supervisor-sealed reviewer evidence. + "finding-dismiss" | "todo-close" => { + matches!(uid, config::ORCHESTRATOR_UID | config::READER_UID) + } + "resolution-create" => uid == config::WRITER_UID, + "checkpoint-update" | "checkpoint-show" => matches!( + uid, + config::ORCHESTRATOR_UID | config::WRITER_UID | config::READER_UID + ), + "finding-show" + | "finding-list" + | "todo-show" + | "todo-list" + | "validation-lease-show" + | "validation-lease-list" => matches!( + uid, + config::ORCHESTRATOR_UID | config::WRITER_UID | config::READER_UID + ), + "validation-lease-acquire" | "validation-lease-status" => { + matches!(uid, config::WRITER_UID | config::READER_UID) + } + _ => uid == config::ORCHESTRATOR_UID, + }, + _ => false, + } +} + +#[cfg(not(target_os = "linux"))] +fn serve(_socket: &Path) -> Result { + Err("authority supervisor requires Unix".into()) +} + +#[cfg(unix)] +fn set_mode(path: &Path, mode: u32) -> Result<(), String> { + use std::os::unix::fs::PermissionsExt; + + fs::set_permissions(path, fs::Permissions::from_mode(mode)) + .map_err(|error| format!("set permissions {}: {error}", path.display())) +} + +#[cfg(target_os = "linux")] +pub fn prepare_state_permissions(state: &Path) -> Result<(), String> { + use std::os::unix::fs::PermissionsExt; + + fs::create_dir_all(state).map_err(|error| format!("create state directory: {error}"))?; + for name in CONTROL_DIRECTORIES { + let directory = state.join(name); + fs::create_dir_all(&directory) + .map_err(|error| format!("create authority directory {name}: {error}"))?; + let metadata = fs::symlink_metadata(&directory) + .map_err(|error| format!("inspect authority directory {name}: {error}"))?; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return Err(format!( + "authority path must be a real directory: {}", + directory.display() + )); + } + } + for entry in fs::read_dir(state).map_err(|error| format!("read state directory: {error}"))? { + let path = entry + .map_err(|error| format!("read state entry: {error}"))? + .path(); + let control = path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| CONTROL_DIRECTORIES.contains(&name)); + prepare_tree( + &path, + if control { + config::SUPERVISOR_UID + } else { + config::ORCHESTRATOR_UID + }, + control, + )?; + } + chown(state, config::SUPERVISOR_UID, config::ROLE_GID)?; + fs::set_permissions(state, fs::Permissions::from_mode(0o3770)) + .map_err(|error| format!("set state root permissions: {error}"))?; + Ok(()) +} + +#[cfg(target_os = "linux")] +fn prepare_tree(path: &Path, uid: u32, authority: bool) -> Result<(), String> { + use std::os::unix::fs::PermissionsExt; + + let metadata = fs::symlink_metadata(path) + .map_err(|error| format!("inspect state path {}: {error}", path.display()))?; + chown(path, uid, config::ROLE_GID)?; + if metadata.is_dir() { + fs::set_permissions( + path, + fs::Permissions::from_mode(if authority { 0o2750 } else { 0o2770 }), + ) + .map_err(|error| { + format!( + "set state directory permissions {}: {error}", + path.display() + ) + })?; + for entry in fs::read_dir(path) + .map_err(|error| format!("read state directory {}: {error}", path.display()))? + { + prepare_tree( + &entry + .map_err(|error| format!("read state entry: {error}"))? + .path(), + uid, + authority, + )?; + } + } else if metadata.is_file() { + let executable = metadata.permissions().mode() & 0o111 != 0; + fs::set_permissions( + path, + fs::Permissions::from_mode(if authority { + 0o640 + } else if executable { + 0o770 + } else { + 0o660 + }), + ) + .map_err(|error| format!("set state file permissions {}: {error}", path.display()))?; + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn chown(path: &Path, uid: u32, gid: u32) -> Result<(), String> { + use std::ffi::CString; + use std::os::unix::ffi::OsStrExt; + + let raw = CString::new(path.as_os_str().as_bytes()) + .map_err(|_| format!("path contains NUL: {}", path.display()))?; + if unsafe { libc::lchown(raw.as_ptr(), uid, gid) } != 0 { + return Err(format!( + "chown {}: {}", + path.display(), + std::io::Error::last_os_error() + )); + } + Ok(()) +} + +#[cfg(not(target_os = "linux"))] +pub fn prepare_state_permissions(_state: &Path) -> Result<(), String> { + Err("authority supervisor UID isolation requires Linux".into()) +} + +#[cfg(target_os = "linux")] +pub fn start(state: &Path, executable: &Path) -> Result { + let socket = authority_socket(state); + let log_path = state.join("runtime_state/authority-supervisor.log"); + if let Some(parent) = log_path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("create authority log directory: {error}"))?; + } + let log = fs::OpenOptions::new() + .create(true) + .append(true) + .open(&log_path) + .map_err(|error| format!("open authority supervisor log: {error}"))?; + let log_stdout = log + .try_clone() + .map_err(|error| format!("clone authority supervisor log: {error}"))?; + let mut command = Command::new(executable); + if let Some(root) = env::var_os("MULTIAGENT_CODEX_HOME_ROOT").filter(|value| !value.is_empty()) + { + let home = PathBuf::from(root).join("supervisor"); + command.env("HOME", &home).env("CODEX_HOME", &home); + } + let child = command + .arg("role-exec") + .arg("--uid") + .arg(config::SUPERVISOR_UID.to_string()) + .arg("--gid") + .arg(config::ROLE_GID.to_string()) + .arg("--") + .arg(executable) + .arg("supervisor") + .arg("serve") + .stdin(Stdio::null()) + .stdout(Stdio::from(log_stdout)) + .stderr(Stdio::from(log)) + .spawn() + .map_err(|error| format!("start authority supervisor: {error}"))?; + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline { + if socket.exists() { + return Ok(child.id()); + } + thread::sleep(Duration::from_millis(25)); + } + let detail = fs::read_to_string(&log_path).unwrap_or_default(); + Err(format!( + "authority supervisor did not create socket: {}: {}", + socket.display(), + detail.trim() + )) +} + +#[cfg(not(target_os = "linux"))] +pub fn start(_state: &Path, _executable: &Path) -> Result { + Err("authority supervisor UID isolation requires Linux".into()) +} + +#[cfg(test)] +mod tests { + use super::{caller_authorized, proxy_command}; + use crate::config; + + #[test] + fn typed_api_excludes_runtime_and_arbitrary_execution() { + assert!(proxy_command("workflow", &["status".into()])); + assert!(proxy_command("subagent", &["assignment-create".into()])); + assert!(!proxy_command("agent", &["run".into()])); + assert!(!proxy_command("role-exec", &[])); + assert!(!proxy_command("subagent", &["spawn".into()])); + assert!(!proxy_command("subagent", &["worktree-create".into()])); + assert!(!proxy_command("subagent", &["validation-run".into()])); + } + + #[test] + fn authority_mutations_are_role_typed() { + assert!(caller_authorized( + config::ORCHESTRATOR_UID, + "workflow", + &["transition".into()] + )); + assert!(!caller_authorized( + config::ORCHESTRATOR_UID, + "subagent", + &["finding-create".into()] + )); + assert!(caller_authorized( + config::READER_UID, + "subagent", + &["finding-create".into()] + )); + assert!(caller_authorized( + config::ORCHESTRATOR_UID, + "subagent", + &["todo-close".into()] + )); + assert!(!caller_authorized( + config::WRITER_UID, + "workflow", + &["transition".into()] + )); + } +} diff --git a/src/workflow.rs b/src/workflow.rs index c894913..7e7776c 100644 --- a/src/workflow.rs +++ b/src/workflow.rs @@ -640,7 +640,7 @@ fn record_review(args: &[String]) -> Result<(), String> { if reviewer.is_empty() { return Err("reviewer-backed lifecycle requires --reviewer NAME".into()); } - validate_reviewer_evidence(&store, reviewer, kind, verdict, diff)?; + validate_reviewer_evidence(&store, id, reviewer, kind, verdict, diff)?; } let mut rows = read_reviews(&p.reviews)?; if rows.iter().any(|r| r.get(0) == review_id) { @@ -852,7 +852,7 @@ fn completion_state(store: &Store, id: &str) -> Result, review.get(1) )); } - validate_reviewer_evidence(store, reviewer, review.get(1), "pass", diff)?; + validate_reviewer_evidence(store, id, reviewer, review.get(1), "pass", diff)?; } } validate_context(&state)?; @@ -865,7 +865,12 @@ fn unrecorded_reviewer_findings( diff: &str, reviews: &[Review], ) -> Result, String> { - let root = store.state_dir.join("subagents"); + let secure = secure_reviewer_evidence(); + let root = store.state_dir.join(if secure { + "reviewer-evidence" + } else { + "subagents" + }); if !root.is_dir() { return Ok(Vec::new()); } @@ -875,9 +880,10 @@ fn unrecorded_reviewer_findings( if !dir.is_dir() { continue; } - let metadata = read_simple_env(&dir.join("meta.env"))?; + let metadata = + read_simple_env(&dir.join(if secure { "evidence.env" } else { "meta.env" }))?; if state_value(&metadata, "role") != "reviewer" - || state_value(&metadata, "codex_access") != "read-only" + || state_value(&metadata, if secure { "access" } else { "codex_access" }) != "read-only" { continue; } @@ -885,9 +891,15 @@ fn unrecorded_reviewer_findings( if !reviewer_workflow.is_empty() && reviewer_workflow != workflow_id { continue; } - let status = fs::read_to_string(dir.join("status")).unwrap_or_default(); - if status.trim() != "finalized" || !dir.join("finalized_at").is_file() { - continue; + if secure { + if state_value(&metadata, "state") != "completed" { + continue; + } + } else { + let status = fs::read_to_string(dir.join("status")).unwrap_or_default(); + if status.trim() != "finalized" || !dir.join("finalized_at").is_file() { + continue; + } } let message = fs::read_to_string(dir.join("last-message.txt")).unwrap_or_default(); let reviewer = dir @@ -924,25 +936,44 @@ fn reviewer_evidence_required() -> bool { fn validate_reviewer_evidence( store: &Store, + workflow_id: &str, reviewer: &str, kind: &str, verdict: &str, diff: &str, ) -> Result<(), String> { valid_id("reviewer name", reviewer)?; - let dir = store.state_dir.join("subagents").join(reviewer); - let metadata = read_simple_env(&dir.join("meta.env"))?; + let secure = secure_reviewer_evidence(); + let dir = store + .state_dir + .join(if secure { + "reviewer-evidence" + } else { + "subagents" + }) + .join(reviewer); + let metadata = read_simple_env(&dir.join(if secure { "evidence.env" } else { "meta.env" }))?; if state_value(&metadata, "role") != "reviewer" - || state_value(&metadata, "codex_access") != "read-only" + || state_value(&metadata, if secure { "access" } else { "codex_access" }) != "read-only" { return Err(format!( "reviewer evidence must come from a read-only reviewer role: {reviewer}" )); } - let status = fs::read_to_string(dir.join("status")) - .map_err(|_| format!("reviewer status is missing: {reviewer}"))?; - if status.trim() != "finalized" || !dir.join("finalized_at").is_file() { - return Err(format!("reviewer is not finalized: {reviewer}")); + if secure { + if state_value(&metadata, "state") != "completed" + || state_value(&metadata, "workflow_id") != workflow_id + { + return Err(format!( + "reviewer evidence is not sealed for workflow {workflow_id}: {reviewer}" + )); + } + } else { + let status = fs::read_to_string(dir.join("status")) + .map_err(|_| format!("reviewer status is missing: {reviewer}"))?; + if status.trim() != "finalized" || !dir.join("finalized_at").is_file() { + return Err(format!("reviewer is not finalized: {reviewer}")); + } } let message = fs::read_to_string(dir.join("last-message.txt")) .map_err(|_| format!("reviewer final message is missing: {reviewer}"))?; @@ -958,6 +989,11 @@ fn validate_reviewer_evidence( Ok(()) } +fn secure_reviewer_evidence() -> bool { + std::env::var("MULTIAGENT_UID_SANDBOX").as_deref() == Ok("1") + && std::env::var("MULTIAGENT_AUTHORITY_SERVER_CHILD").as_deref() == Ok("1") +} + fn review_marker_matches(line: &str, marker: &str) -> bool { let mut value = line.trim(); if let Some((prefix, rest)) = value.split_once(' ') { @@ -976,7 +1012,12 @@ fn review_marker_matches(line: &str, marker: &str) -> bool { } fn active_reviewers(store: &Store) -> Result, String> { - let root = store.state_dir.join("subagents"); + let secure = secure_reviewer_evidence(); + let root = store.state_dir.join(if secure { + "launch-authorizations" + } else { + "subagents" + }); if !root.is_dir() { return Ok(Vec::new()); } @@ -986,12 +1027,19 @@ fn active_reviewers(store: &Store) -> Result, String> { if !dir.is_dir() { continue; } - let metadata = read_simple_env(&dir.join("meta.env"))?; + let metadata = read_simple_env(&dir.join(if secure { "launch.env" } else { "meta.env" }))?; if state_value(&metadata, "role") != "reviewer" { continue; } - let status = fs::read_to_string(dir.join("status")).unwrap_or_default(); - if matches!(status.trim(), "starting" | "pending" | "running") { + let status = if secure { + state_value(&metadata, "state").to_string() + } else { + fs::read_to_string(dir.join("status")).unwrap_or_default() + }; + if matches!( + status.trim(), + "starting" | "pending" | "registered" | "running" + ) { active.push( dir.file_name() .and_then(|value| value.to_str()) diff --git a/tests/malicious-orchestrator.sh b/tests/malicious-orchestrator.sh new file mode 100755 index 0000000..87a3827 --- /dev/null +++ b/tests/malicious-orchestrator.sh @@ -0,0 +1,290 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ "$(uname -s)" != Linux || "$(id -u)" -ne 0 ]]; then + echo "malicious orchestrator boundary test requires Linux root; skipped" + exit 0 +fi + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +TARGET_DIR="${CARGO_TARGET_DIR:-$ROOT/target}" +SOURCE_BIN="$TARGET_DIR/debug/multiagent" +[[ -x "$SOURCE_BIN" ]] || cargo build --offline --locked --manifest-path "$ROOT/Cargo.toml" >/dev/null + +TEST_ROOT="$(mktemp -d /tmp/multiagent-malicious.XXXXXX)" +chmod 0755 "$TEST_ROOT" +SUPERVISOR_PID="" +cleanup() { + if [[ -n "$SUPERVISOR_PID" ]]; then + kill "$SUPERVISOR_PID" 2>/dev/null || true + fi + rm -f /run/multiagent/authority-state-10001 + rm -rf "$TEST_ROOT" +} +trap cleanup EXIT + +install -d -m 0755 "$TEST_ROOT/bin" "$TEST_ROOT/repo/allowed" "$TEST_ROOT/repo/forbidden" +install -m 4755 "$SOURCE_BIN" "$TEST_ROOT/bin/multiagent" +MULTIAGENT="$TEST_ROOT/bin/multiagent" +REPO="$TEST_ROOT/repo" +STATE="$TEST_ROOT/state" +HOMES="$TEST_ROOT/homes" + +git -C "$REPO" init -q +git -C "$REPO" config user.email test@example.com +git -C "$REPO" config user.name "Boundary Test" +printf 'base\n' >"$REPO/allowed/result.txt" +printf 'protected\n' >"$REPO/forbidden/secret.txt" +git -C "$REPO" add . +git -C "$REPO" commit -q -m initial +BRANCH="$(git -C "$REPO" branch --show-current)" +chown -R 0:10001 "$REPO" +find "$REPO" -type d -exec chmod 0750 {} + +find "$REPO" -type f -exec chmod 0640 {} + + +install -d -o 10004 -g 10001 -m 0700 "$HOMES/supervisor" +printf '[safe]\n\tdirectory = %s\n' "$REPO" >"$HOMES/supervisor/.gitconfig" +chown 10004:10001 "$HOMES/supervisor/.gitconfig" +chmod 0600 "$HOMES/supervisor/.gitconfig" +install -d -o 10001 -g 10001 -m 0700 "$HOMES/orchestrator" +printf '[safe]\n\tdirectory = %s\n' "$REPO" >"$HOMES/orchestrator/.gitconfig" +chown 10001:10001 "$HOMES/orchestrator/.gitconfig" +chmod 0600 "$HOMES/orchestrator/.gitconfig" + +cat >"$TEST_ROOT/bin/codex" <<'FAKE_CODEX' +#!/usr/bin/env bash +set -u +if [[ "${1:-}" == "--version" ]]; then + printf 'codex-boundary-test 1.0\n' + exit 0 +fi +output="" +while [[ $# -gt 0 ]]; do + if [[ "$1" == "--output-last-message" ]]; then + output="$2" + shift 2 + else + shift + fi +done +cat >/dev/null || true +if [[ "$output" == *worker-post-review* ]]; then + printf 'malicious post-review source\n' >"$TEST_REPO/allowed/post-review.rs" 2>/dev/null || true +else + printf 'worker-write\n' >"$TEST_REPO/allowed/result.txt" 2>/dev/null || true +fi +printf 'escaped\n' >"$TEST_REPO/forbidden/secret.txt" 2>/dev/null || true +final_hash="$(cat "${TEST_REPO%/repo}/review-hash" 2>/dev/null || true)" +printf 'ACCEPTED\nbuild-verification-passed: final-diff-sha256=%s compile_clean=true returncode=0\nreview-record: type=decision-authority verdict=pass diff=-\n' "$final_hash" >"$output" +printf '{"type":"result","result":"completed"}\n' +FAKE_CODEX +chmod 0755 "$TEST_ROOT/bin/codex" + +cat >"$TEST_ROOT/bin/tmux" <<'FAKE_TMUX' +#!/usr/bin/env bash +# No session exists in this boundary test. A real executable is sufficient to +# exercise the cancellation path after the window lookup returns false. +exit 1 +FAKE_TMUX +chmod 0755 "$TEST_ROOT/bin/tmux" + +mkdir -p "$STATE/subagents" "$STATE/runtime_state" "$STATE/tmp" "$STATE/logs" + +BASE_ENV=( + MULTIAGENT_TEST_MODE=1 + MULTIAGENT_UID_SANDBOX=1 + MULTIAGENT_LIFECYCLE_ENFORCEMENT=1 + MULTIAGENT_ROOT="$REPO" + MULTIAGENT_STATE_DIR="$STATE" + MULTIAGENT_LOG_DIR="$STATE/logs" + MULTIAGENT_WORKFLOW_ID=WF-ATTACK + MULTIAGENT_CODEX_EXEC=1 + MULTIAGENT_REQUIRE_HASH_BOUND_VERIFIER=1 + MULTIAGENT_CODEX_HOME_ROOT="$HOMES" + ORCHESTRATOR_CLI=codex + WORKER_CLI=codex + SUBAGENT_CLI=codex + VERIFIER_CLI=codex + CODEX_BIN="$TEST_ROOT/bin/codex" + CLAUDE_BIN="$TEST_ROOT/bin/codex" + QWEN_BIN="$TEST_ROOT/bin/codex" + TEST_REPO="$REPO" + HOME="$HOMES/orchestrator" + TMPDIR="$STATE/tmp" + PATH="$TEST_ROOT/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" +) + +SUPERVISOR_PID="$(env "${BASE_ENV[@]}" "$MULTIAGENT" supervisor bootstrap-test)" + +as_orchestrator() { + setpriv --reuid=10001 --regid=10001 --clear-groups env "${BASE_ENV[@]}" "$@" +} + +as_writer() { + setpriv --reuid=10002 --regid=10001 --clear-groups env "${BASE_ENV[@]}" "$@" +} + +as_reader() { + setpriv --reuid=10003 --regid=10001 --clear-groups env "${BASE_ENV[@]}" "$@" +} + +as_orchestrator "$MULTIAGENT" workflow init WF-ATTACK >/dev/null + +if as_orchestrator "$MULTIAGENT" subagent finding-create forged-finding \ + --severity blocking --type security --summary forged \ + --evidence-json '{"path":"forged"}' --required-resolution forged \ + >/dev/null 2>&1; then + echo "orchestrator unexpectedly exercised reviewer authority" >&2 + exit 1 +fi +if as_orchestrator "$MULTIAGENT" subagent validation-run forged-validation \ + --owner orchestrator --target authority -- sh -c \ + "touch '$STATE/workflows/WF-ATTACK/forged'" >/dev/null 2>&1; then + echo "orchestrator unexpectedly executed validation with supervisor authority" >&2 + exit 1 +fi +[[ ! -e "$STATE/workflows/WF-ATTACK/forged" ]] + +if as_orchestrator sh -c 'printf compromised >"$1"' sh "$REPO/forbidden/secret.txt" 2>/dev/null; then + echo "orchestrator unexpectedly wrote the repository" >&2 + exit 1 +fi +if as_orchestrator sh -c 'printf forged >"$1"' sh "$STATE/workflows/WF-ATTACK/lifecycle/lifecycle.env" 2>/dev/null; then + echo "orchestrator unexpectedly mutated authority state" >&2 + exit 1 +fi + +as_orchestrator "$MULTIAGENT" subagent assignment-create worker-evil \ + --assignment-id ATTACK-WORK --role qa --branch "$BRANCH" --owned allowed >/dev/null +as_orchestrator "$MULTIAGENT" subagent checkpoint-update worker-evil \ + --step assigned --status assigned >/dev/null +[[ ! -e "$STATE/subagents/worker-evil" ]] +if as_orchestrator sh -c 'printf forbidden >"$1"' sh "$STATE/assignments/worker-evil/owned-paths" 2>/dev/null; then + echo "orchestrator unexpectedly forged an assignment" >&2 + exit 1 +fi + +as_orchestrator mkdir -p "$STATE/subagents/worker-evil" +as_orchestrator sh -c 'printf "%s\n" "$2" >"$1"' sh \ + "$STATE/subagents/worker-evil/instruction.txt" "perform bounded worker test" +as_orchestrator "$MULTIAGENT" supervisor register-launch worker-evil \ + --role worker --cli codex --cli-bin "$TEST_ROOT/bin/codex" \ + --instruction-file "$STATE/subagents/worker-evil/instruction.txt" >/dev/null + +as_orchestrator "$MULTIAGENT" role-agent-exec worker-evil +grep -Fxq worker-write "$REPO/allowed/result.txt" +grep -Fxq protected "$REPO/forbidden/secret.txt" +as_orchestrator "$MULTIAGENT" subagent assignment-status worker-evil done >/dev/null +if as_orchestrator "$MULTIAGENT" role-agent-exec worker-evil >/dev/null 2>&1; then + echo "consumed writer authorization was replayed" >&2 + exit 1 +fi +BOUNDARY_HASH="$(as_orchestrator "$MULTIAGENT" snapshot --root "$REPO" --format shell | awk '{print $1}')" +printf '%s\n' "$BOUNDARY_HASH" >"$TEST_ROOT/review-hash" +chmod 0644 "$TEST_ROOT/review-hash" + +FAKE_STATE="$TEST_ROOT/fake-state" +mkdir -p "$FAKE_STATE/launch-authorizations/forged" +printf 'name=forged\nrole=worker\naccess=workspace-write\nstate=registered\n' \ + >"$FAKE_STATE/launch-authorizations/forged/launch.env" +if setpriv --reuid=10001 --regid=10001 --clear-groups env "${BASE_ENV[@]}" \ + MULTIAGENT_STATE_DIR="$FAKE_STATE" "$MULTIAGENT" role-agent-exec forged >/dev/null 2>&1; then + echo "role launcher accepted an unregistered state directory" >&2 + exit 1 +fi + +as_orchestrator mkdir -p "$STATE/subagents/forged-reviewer" +as_orchestrator sh -c 'printf "%s\n" "role=reviewer" "codex_access=read-only" >"$1/meta.env"; printf finalized >"$1/status"; printf now >"$1/finalized_at"; printf "%s\n" "review-record: type=decision-authority verdict=pass diff=-" >"$1/last-message.txt"' \ + sh "$STATE/subagents/forged-reviewer" +if as_orchestrator "$MULTIAGENT" workflow record-review WF-ATTACK FORGED \ + --type decision-authority --verdict pass --evidence forged \ + --reviewer forged-reviewer >/dev/null 2>&1; then + echo "workflow accepted forged reviewer evidence" >&2 + exit 1 +fi + +as_orchestrator mkdir -p "$STATE/subagents/authority-verifier" +as_orchestrator sh -c 'printf "%s\n" "perform independent authority review" >"$1"' sh \ + "$STATE/subagents/authority-verifier/instruction.txt" +as_orchestrator "$MULTIAGENT" supervisor register-launch authority-verifier \ + --role reviewer --cli codex --cli-bin "$TEST_ROOT/bin/codex" \ + --instruction-file "$STATE/subagents/authority-verifier/instruction.txt" >/dev/null +as_orchestrator "$MULTIAGENT" role-agent-exec authority-verifier +as_orchestrator sh -c 'printf "%s\n" "review-record: type=decision-authority verdict=findings diff=-" >"$1"' sh \ + "$STATE/subagents/authority-verifier/last-message.txt" +as_orchestrator "$MULTIAGENT" workflow record-review WF-ATTACK SEALED \ + --type decision-authority --verdict pass --evidence sealed \ + --reviewer authority-verifier >/dev/null +as_orchestrator sh -c 'printf "ACCEPTED\nbuild-verification-passed: final-diff-sha256=%s compile_clean=true returncode=0\n" "$2" >"$1"' sh \ + "$STATE/subagents/authority-verifier/last-message.txt" "$BOUNDARY_HASH" + +# An orchestrator may request closure, but a forged public verifier message +# cannot authorize it. Only the supervisor-sealed reviewer output can. +as_reader "$MULTIAGENT" subagent finding-create closure-finding \ + --severity blocking --type security --summary "exercise closure authority" \ + --evidence-json '{"source_evidence":"boundary-test"}' \ + --required-resolution "record and independently verify a resolution" >/dev/null +as_orchestrator "$MULTIAGENT" subagent todo-create closure-todo \ + --source-finding-id closure-finding --task "resolve boundary test" \ + --context "malicious orchestrator test" --done-criteria "record evidence" >/dev/null +as_writer "$MULTIAGENT" subagent resolution-create closure-todo \ + --worker worker-evil --status resolved \ + --validation-json '[{"cmd":"true","rc":0}]' --why "boundary exercised" >/dev/null +as_orchestrator mkdir -p "$STATE/subagents/forged-closer" +as_orchestrator sh -c 'printf "ACCEPTED\n" >"$1"' sh \ + "$STATE/subagents/forged-closer/last-message.txt" +if as_orchestrator "$MULTIAGENT" subagent todo-close closure-todo \ + --verified-by forged-closer \ + --recheck-json '{"accepted":true,"finding_rechecked":"closure-finding","commands":[{"cmd":"true","rc":0}]}' \ + >/dev/null 2>&1; then + echo "orchestrator closed a todo with forged public evidence" >&2 + exit 1 +fi +as_orchestrator "$MULTIAGENT" subagent todo-close closure-todo \ + --verified-by authority-verifier \ + --recheck-json "{\"accepted\":true,\"finding_rechecked\":\"closure-finding\",\"final_diff_sha256\":\"$BOUNDARY_HASH\",\"commands\":[{\"cmd\":\"true\",\"rc\":0}]}" \ + >/dev/null + +# A malicious orchestrator may ask a legitimate writer to add source after the +# sealed review. The old review must not authorize that larger candidate, even +# when the new file is untracked and therefore absent from raw `git diff`. +as_orchestrator "$MULTIAGENT" subagent assignment-create worker-post-review \ + --assignment-id ATTACK-POST-REVIEW --role qa --branch "$BRANCH" \ + --owned allowed >/dev/null +as_orchestrator mkdir -p "$STATE/subagents/worker-post-review" +as_orchestrator sh -c 'printf "%s\n" "add a post-review source file" >"$1"' sh \ + "$STATE/subagents/worker-post-review/instruction.txt" +as_orchestrator "$MULTIAGENT" supervisor register-launch worker-post-review \ + --role worker --cli codex --cli-bin "$TEST_ROOT/bin/codex" \ + --instruction-file "$STATE/subagents/worker-post-review/instruction.txt" >/dev/null +as_orchestrator "$MULTIAGENT" role-agent-exec worker-post-review +[[ -f "$REPO/allowed/post-review.rs" ]] +if as_orchestrator "$MULTIAGENT" subagent gate-check \ + >"$TEST_ROOT/post-review-gate.out" 2>&1; then + echo "orchestrator reused sealed review after adding untracked source" >&2 + exit 1 +fi +grep -Fq $'reject\tlatest-verifier-final-diff-hash-mismatch' \ + "$TEST_ROOT/post-review-gate.out" + +# Cancellation must be able to record termination in a reader-owned trace +# directory without trying to change that directory's ownership or mode. +install -d -o 10001 -g 10001 -m 2770 "$STATE/subagents/reader-cleanup" +install -d -o 10003 -g 10001 -m 2770 "$STATE/logs/agents/reader-cleanup" +as_orchestrator sh -c 'printf "%s\n" "trace_dir=$1" >"$2/meta.env"; printf "running\n" >"$2/status"' \ + sh "$STATE/logs/agents/reader-cleanup" "$STATE/subagents/reader-cleanup" +as_orchestrator env MULTIAGENT_SESSION=missing-boundary-session \ + "$MULTIAGENT" subagent kill reader-cleanup >/dev/null +grep -Fxq killed "$STATE/subagents/reader-cleanup/status" +grep -Fq '"reason": "canceled"' \ + "$STATE/logs/agents/reader-cleanup/supervisor-termination.json" + +if as_orchestrator sh -c 'printf forged >"$1"' sh \ + "$STATE/reviewer-evidence/authority-verifier/last-message.txt" 2>/dev/null; then + echo "orchestrator unexpectedly replaced sealed reviewer evidence" >&2 + exit 1 +fi + +as_orchestrator "$MULTIAGENT" supervisor stop +SUPERVISOR_PID="" +echo "malicious orchestrator boundary tests passed" diff --git a/tests/run.sh b/tests/run.sh index 9833c03..b0a2889 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -665,11 +665,26 @@ if MULTIAGENT_ROOT="$HASH_GATE_ROOT" MULTIAGENT_STATE_DIR="$HASH_GATE_STATE" MUL exit 1 fi assert_file_contains "$TMPDIR/gate-verifier-unbound-hash.out" $'reject\tlatest-verifier-final-diff-hash-mismatch' -HASH_GATE_DIFF_SHA="$(git -C "$HASH_GATE_ROOT" diff --binary --ignore-submodules=all | shasum -a 256 | awk '{print $1}')" +HASH_GATE_DIFF_SHA="$("$MULTIAGENT" snapshot --root "$HASH_GATE_ROOT" --format shell | awk '{print $1}')" printf 'ACCEPTED\nbuild-verification-passed: final-diff-sha256=%s compile_clean=true returncode=0\n' "$HASH_GATE_DIFF_SHA" >"$HASH_GATE_STATE/subagents/verifier-01-hash/last-message.txt" MULTIAGENT_ROOT="$HASH_GATE_ROOT" MULTIAGENT_STATE_DIR="$HASH_GATE_STATE" MULTIAGENT_REQUIRE_HASH_BOUND_VERIFIER=1 \ "$MULTIAGENT" subagent gate-check >"$TMPDIR/gate-verifier-bound-hash.out" assert_file_contains "$TMPDIR/gate-verifier-bound-hash.out" "accepted" +printf 'malicious post-review source\n' >"$HASH_GATE_ROOT/untracked-source.txt" +if MULTIAGENT_ROOT="$HASH_GATE_ROOT" MULTIAGENT_STATE_DIR="$HASH_GATE_STATE" MULTIAGENT_REQUIRE_HASH_BOUND_VERIFIER=1 \ + "$MULTIAGENT" subagent gate-check >"$TMPDIR/gate-verifier-untracked-bypass.out" 2>&1; then + echo "expected post-review untracked source to invalidate verifier evidence" >&2 + cat "$TMPDIR/gate-verifier-untracked-bypass.out" >&2 + exit 1 +fi +assert_file_contains "$TMPDIR/gate-verifier-untracked-bypass.out" $'reject\tlatest-verifier-final-diff-hash-mismatch' +HASH_GATE_UNTRACKED_SHA="$("$MULTIAGENT" snapshot --root "$HASH_GATE_ROOT" --format shell | awk '{print $1}')" +printf 'ACCEPTED\nbuild-verification-passed: final-diff-sha256=%s compile_clean=true returncode=0\n' "$HASH_GATE_UNTRACKED_SHA" >"$HASH_GATE_STATE/subagents/verifier-01-hash/last-message.txt" +MULTIAGENT_ROOT="$HASH_GATE_ROOT" MULTIAGENT_STATE_DIR="$HASH_GATE_STATE" MULTIAGENT_REQUIRE_HASH_BOUND_VERIFIER=1 \ + "$MULTIAGENT" subagent gate-check >"$TMPDIR/gate-verifier-untracked-bound.out" +assert_file_contains "$TMPDIR/gate-verifier-untracked-bound.out" "accepted" +rm "$HASH_GATE_ROOT/untracked-source.txt" +printf 'ACCEPTED\nbuild-verification-passed: final-diff-sha256=%s compile_clean=true returncode=0\n' "$HASH_GATE_DIFF_SHA" >"$HASH_GATE_STATE/subagents/verifier-01-hash/last-message.txt" printf 'ACCEPTED\n{"verdict":"ACCEPTED","final_diff_sha256":"%s","build_verification_passed":{"final_diff_sha256":"%s","compile_clean":true,"commands":[{"cmd":"test -f source.txt","rc":0}]}}\n' \ "$HASH_GATE_DIFF_SHA" "$HASH_GATE_DIFF_SHA" >"$HASH_GATE_STATE/subagents/verifier-01-hash/last-message.txt" printf 'running\n' >"$HASH_GATE_STATE/subagents/verifier-01-hash/status" @@ -852,6 +867,7 @@ assert_file_contains "$ROOT/prompts/worker.md" "legitimate product or visible-te assert_file_contains "$ROOT/prompts/worker.md" "validation-repair-needed:" assert_file_contains "$ROOT/prompts/worker.md" "structured worker" assert_file_contains "$ROOT/prompts/worker.md" "resolution-create" +assert_file_contains "$ROOT/prompts/worker.md" "assembled production" assert_file_contains "$ROOT/prompts/verifier.md" "Verifier Role Prompt" assert_file_contains "$ROOT/prompts/verifier.md" "Hidden Contract Verification" assert_file_contains "$ROOT/prompts/verifier.md" "unresolved risk" @@ -872,6 +888,7 @@ assert_file_contains "$ROOT/prompts/verifier.md" "--severity blocking" assert_file_contains "$ROOT/prompts/verifier.md" "--affected PATH[,PATH...]" assert_file_contains "$ROOT/prompts/verifier.md" "--evidence-json" assert_file_contains "$ROOT/prompts/verifier.md" "do not invent" +assert_file_contains "$ROOT/prompts/verifier.md" "assembled production" assert_file_contains "$ROOT/prompts/worker.md" 'Every entry in a `resolved` report' assert_file_contains "$ROOT/prompts/worker.md" 'must have `rc: 0`' assert_file_contains "$ROOT/prompts/playbooks/finding-todo-loop.md" 'All `validation-json` entries in a resolved report' @@ -1079,6 +1096,7 @@ assert_file_contains "$ROOT/prompts/roles/contract-scout.md" "source-owner-ledge assert_file_contains "$ROOT/prompts/roles/contract-scout.md" "constructor-dependency contract" assert_file_contains "$ROOT/prompts/roles/build-verifier.md" "build-verification-passed:" assert_file_contains "$ROOT/prompts/roles/build-verifier.md" "final-diff-sha256=" +assert_file_contains "$ROOT/prompts/roles/build-verifier.md" "omits untracked new" assert_file_contains "$ROOT/prompts/roles/build-verifier.md" "go-package-validation-passed:" assert_file_contains "$ROOT/prompts/roles/build-verifier.md" "contract scout validation" assert_file_contains "$ROOT/prompts/playbooks/orchestration-routing.md" "source-owner-ledger:" @@ -1099,7 +1117,11 @@ assert_file_contains "$ROOT/evaluation/native_solver/swe_prod_lifecycle.py" "wor assert_file_contains "$ROOT/evaluation/native_solver/swe_prod_lifecycle.py" '"MULTIAGENT_PROMPT_MODULE_ROOT": str(repo_root)' assert_file_contains "$ROOT/evaluation/native_solver/swe_prod_lifecycle.py" '"GOMODCACHE": ensure_cache_dir(RUNTIME_ROOT / "go-mod-cache")' assert_file_contains "$ROOT/evaluation/native_solver/templates/swe_autonomous_appendix.md" "adapter only transports" +assert_file_contains "$ROOT/evaluation/native_solver/templates/swe_autonomous_appendix.md" "autonomous run-to-terminal workflow" +assert_file_contains "$ROOT/evaluation/native_solver/templates/swe_autonomous_appendix.md" "assignment omitted a path required by the approved plan" assert_file_not_contains "$ROOT/evaluation/native_solver/templates/swe_autonomous_appendix.md" "status.json" +assert_file_contains "$ROOT/prompts/playbooks/agent-spawning.md" "must not silently narrow or" +assert_file_contains "$ROOT/prompts/playbooks/agent-spawning.md" "Never forbid a required path" assert_file_contains "$ROOT/evaluation/evalscope_multiagent_native_runner.py" "does not inspect or score patches" assert_file_contains "$ROOT/evaluation/evalscope_multiagent_native_runner.py" "_public_solver_metadata(dict(task.metadata or {}))" assert_file_contains "$ROOT/evaluation/evalscope_multiagent_native_runner.py" '"fail_to_pass"' @@ -1540,6 +1562,23 @@ assert_file_contains "$MULTIAGENT_STATE_DIR/assignments/owned-inline/owned-paths assert_file_contains "$MULTIAGENT_STATE_DIR/assignments/owned-inline/status" "running" assert_file_contains "$MOCK_TMUX_LOG" "send-key test-session:owned-inline Repair the bounded path" +printf 'Claude prompt ready\n' >"$MOCK_TMUX_CAPTURES/owned-atomic.txt" +owned_atomic_output="$("$MULTIAGENT" subagent spawn owned-atomic \ + --own docs/architecture.md \ + --assignment-id atomic-001 \ + --workflow-id WF-ATOMIC \ + --decision-id DEC-ATOMIC \ + --plan-id PLAN-ATOMIC \ + --branch atomic/worker \ + --instruction "Run one atomic assignment and launch")" +[[ "$owned_atomic_output" == $'spawned owned-atomic' ]] +assert_file_contains "$MULTIAGENT_STATE_DIR/assignments/owned-atomic/assignment.env" "assignment_id=atomic-001" +assert_file_contains "$MULTIAGENT_STATE_DIR/assignments/owned-atomic/assignment.env" "workflow_id=WF-ATOMIC" +assert_file_contains "$MULTIAGENT_STATE_DIR/assignments/owned-atomic/assignment.env" "decision_id=DEC-ATOMIC" +assert_file_contains "$MULTIAGENT_STATE_DIR/assignments/owned-atomic/assignment.env" "plan_id=PLAN-ATOMIC" +assert_file_contains "$MULTIAGENT_STATE_DIR/assignments/owned-atomic/assignment.env" "branch=atomic/worker" +assert_file_contains "$MULTIAGENT_STATE_DIR/assignments/owned-atomic/status" "running" + "$MULTIAGENT" subagent assignment-create owned-mismatch --assignment-id existing-owned --branch "$(git -C "$ROOT" rev-parse --abbrev-ref HEAD)" --owned prompts/worker.md >/dev/null printf 'Claude prompt ready\n' >"$MOCK_TMUX_CAPTURES/owned-mismatch.txt" if "$MULTIAGENT" subagent spawn owned-mismatch --own src/subagent.rs --instruction "Do not widen ownership" >"$TMPDIR/owned-mismatch.out" 2>&1; then diff --git a/tests/test_migration_contracts.py b/tests/test_migration_contracts.py index c80eb28..4ab8034 100644 --- a/tests/test_migration_contracts.py +++ b/tests/test_migration_contracts.py @@ -395,6 +395,7 @@ def test_concurrent_overlapping_assignments_admit_exactly_one_owner(self): def test_snapshot_cli_json_contract(self): (self.repo / "README.md").write_text("changed\n", encoding="utf-8") (self.repo / "src" / "lib.rs").write_text("pub fn value() -> u8 { 2 }\n", encoding="utf-8") + (self.repo / "src" / "new.rs").write_text("pub fn added() {}\n", encoding="utf-8") result = subprocess.run( [ str(MULTIAGENT), @@ -417,11 +418,43 @@ def test_snapshot_cli_json_contract(self): set(payload), {"final_diff_sha256", "changed_files", "changed_paths", "changed_code_paths"}, ) - self.assertEqual(payload["changed_files"], 2) - self.assertEqual(payload["changed_paths"], ["README.md", "src/lib.rs"]) - self.assertEqual(payload["changed_code_paths"], ["src/lib.rs"]) + self.assertEqual(payload["changed_files"], 3) + self.assertEqual( + payload["changed_paths"], ["README.md", "src/lib.rs", "src/new.rs"] + ) + self.assertEqual(payload["changed_code_paths"], ["src/lib.rs", "src/new.rs"]) self.assertRegex(payload["final_diff_sha256"], r"^[0-9a-f]{64}$") + def test_snapshot_excludes_only_baseline_untracked_files(self): + residue = self.repo / "runtime-residue.txt" + residue.write_text("created before the solver starts\n", encoding="utf-8") + baseline = self.root / "baseline-untracked.txt" + baseline.write_text("runtime-residue.txt\n", encoding="utf-8") + (self.repo / "src" / "new.rs").write_text("pub fn added() {}\n", encoding="utf-8") + env = dict(self.env) + env["MULTIAGENT_BASELINE_UNTRACKED_FILE"] = str(baseline) + + result = subprocess.run( + [ + str(MULTIAGENT), + "snapshot", + "--root", + str(self.repo), + "--format", + "json", + ], + cwd=self.repo, + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(result.returncode, 0, result.stderr) + payload = json.loads(result.stdout) + self.assertEqual(payload["changed_paths"], ["src/new.rs"]) + self.assertEqual(payload["changed_code_paths"], ["src/new.rs"]) + def test_dag_concurrent_node_updates_do_not_lose_rows(self): self.run_cli("dag", "init", "WF-DAG-CONCURRENT", "--title", "Concurrent DAG") processes = [] diff --git a/tests/test_swe_outcomes.py b/tests/test_swe_outcomes.py index 300e594..f379c3f 100644 --- a/tests/test_swe_outcomes.py +++ b/tests/test_swe_outcomes.py @@ -67,6 +67,11 @@ def test_autonomous_authority_does_not_reopen_explicit_task_behavior(self): self.assertIn("explicit task contract is already approved", lifecycle) self.assertIn("This run has no interactive user", autonomous) self.assertIn("narrowest backward-compatible interpretation", autonomous) + self.assertIn("new contract outranks pre-change exact-call mocks", autonomous) + self.assertIn("verify the declared default and an override", autonomous) + self.assertIn("autonomous run-to-terminal workflow", autonomous) + self.assertIn("turn by offering to continue", autonomous) + self.assertIn("assignment omitted a path required by the approved plan", autonomous) def test_runner_has_no_submission_rejection_path(self): self.assertFalse(hasattr(evalscope_multiagent_native_runner, "is_submission_gate_rejection")) @@ -104,7 +109,7 @@ def test_role_filesystem_seeds_private_codex_home_per_identity(self): ) as chmod: swe_prod_lifecycle.prepare_role_filesystem(workdir, launcher) - for role in ("orchestrator", "writer", "reader"): + for role in ("orchestrator", "writer", "reader", "supervisor"): home = role_homes / role self.assertEqual((home / "auth.json").read_text(encoding="utf-8"), '{"token":"test"}') self.assertTrue((home / "config.toml").is_file()) @@ -160,6 +165,7 @@ def test_orchestrator_exit_prepares_workspace_for_official_scorer(self): "multiagent_command": mock.Mock(return_value=["multiagent"]), "find_codex_cli": mock.Mock(return_value="/usr/bin/codex"), "git_head": mock.Mock(return_value="a" * 40), + "list_untracked_files": mock.Mock(return_value=["appendonlydir/runtime.aof"]), "run": mock.Mock(return_value=completed), "write_codex_bridge": mock.DEFAULT, "write_apply_patch_helper": mock.DEFAULT, @@ -208,7 +214,10 @@ def test_orchestrator_exit_prepares_workspace_for_official_scorer(self): self.assertEqual(result, 0) materialize.assert_called_once_with(root, "a" * 40) - expose_untracked.assert_called_once_with(root) + expose_untracked.assert_called_once_with( + root, + baseline_untracked={"appendonlydir/runtime.aof"}, + ) prepare_roles.assert_called_once_with(root, Path("multiagent")) restore_owner.assert_called_once_with(root) self.assertEqual(launch_env["MULTIAGENT_UID_SANDBOX"], "1") @@ -247,6 +256,31 @@ def test_workspace_handoff_includes_new_source_and_test_files(self): self.assertIn("feature.py", diff) self.assertIn("tests/test_feature.py", diff) + def test_workspace_handoff_excludes_preexisting_image_residue(self): + with tempfile.TemporaryDirectory() as directory: + repo = Path(directory) + subprocess.run(["git", "init", "-q"], cwd=repo, check=True) + (repo / "appendonlydir").mkdir() + residue = repo / "appendonlydir" / "appendonly.aof" + residue.write_text("runtime\n", encoding="utf-8") + baseline = set(swe_prod_repository.list_untracked_files(repo)) + (repo / "new_source.py").write_text("fixed = True\n", encoding="utf-8") + + exposed = swe_prod_repository.mark_untracked_intent_to_add( + repo, + baseline_untracked=baseline, + ) + diff = subprocess.run( + ["git", "diff", "--binary"], + cwd=repo, + check=True, + text=True, + stdout=subprocess.PIPE, + ).stdout + + self.assertEqual(exposed, ["new_source.py"]) + self.assertNotIn("appendonly.aof", diff) + def test_summary_counts_submitted_patch_even_when_official_score_is_zero(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) From 691b6c1e2d73a6896ea2fdda14124065d53026e6 Mon Sep 17 00:00:00 2001 From: Bo Wu Date: Sun, 16 Aug 2026 18:04:32 -0700 Subject: [PATCH 3/6] fix: bind reviewers to canonical workspace snapshot --- src/runtime.rs | 11 +++++------ tests/run.sh | 1 + 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/runtime.rs b/src/runtime.rs index 2947df4..e7b2838 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -2193,13 +2193,12 @@ fn append_verifier_diff_binding( if !matches!(file, "verifier.md" | "build-verifier.md") { return Ok(instruction.into()); } - let diff = git_bytes( - &cfg.root, - &["diff", "--binary", "--ignore-submodules=all", "HEAD"], - )?; - let changed = git_text(&cfg.root, &["diff", "--name-only", "HEAD"])? + // Bind reviewers to the exact supervisor candidate. Raw `git diff` omits + // untracked source files and would give the reviewer a second, weaker hash. + let diff = crate::snapshot::canonical_diff(&cfg.root, "HEAD")?; + let changed = String::from_utf8_lossy(&diff) .lines() - .filter(|line| !line.is_empty()) + .filter(|line| line.starts_with("diff --git a/")) .count(); if changed == 0 { return Ok(instruction.into()); diff --git a/tests/run.sh b/tests/run.sh index b0a2889..018b0fc 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -1104,6 +1104,7 @@ assert_file_contains "$ROOT/prompts/playbooks/orchestration-routing.md" "prompts assert_file_contains "$ROOT/prompts/playbooks/orchestration-routing.md" "build-verification-passed:" assert_file_contains "$ROOT/prompts/playbooks/finding-todo-loop.md" "Do not create or reopen a todo from command evidence bound" assert_file_contains "$MULTIAGENT" subagent '--own|--owned-path)' +assert_file_contains "$ROOT/src/runtime.rs" 'crate::snapshot::canonical_diff(&cfg.root, "HEAD")' assert_file_contains "$MULTIAGENT" subagent '--source-finding-id|--finding)' assert_file_contains "$MULTIAGENT" subagent '--role)' assert_file_contains "$ROOT/prompts/roles/acceptance-scout.md" "declared-type ownership risk" From 7e39ca0964837f77226b0996a104884d67ed82b8 Mon Sep 17 00:00:00 2001 From: Bo Wu Date: Sun, 16 Aug 2026 18:42:45 -0700 Subject: [PATCH 4/6] fix: resume incomplete solver workflows --- .../native_solver/swe_prod_lifecycle.py | 52 +++++++++++- prompts/playbooks/orchestration-routing.md | 5 ++ prompts/verifier.md | 13 ++- tests/run.sh | 3 + tests/test_swe_outcomes.py | 85 +++++++++++++++++++ 5 files changed, 155 insertions(+), 3 deletions(-) diff --git a/evaluation/native_solver/swe_prod_lifecycle.py b/evaluation/native_solver/swe_prod_lifecycle.py index 08e3cbc..a848823 100644 --- a/evaluation/native_solver/swe_prod_lifecycle.py +++ b/evaluation/native_solver/swe_prod_lifecycle.py @@ -169,6 +169,29 @@ def tmux_has_orchestrator(session: str) -> bool: return result.returncode == 0 and "orchestrator" in result.stdout.splitlines() +def active_workflow_phase() -> str | None: + """Return the persisted lifecycle phase for the active production workflow.""" + + state = RUNTIME_ROOT / "state" + active_id_path = state / "runtime_state" / "active-workflow-id" + try: + workflow_id = active_id_path.read_text(encoding="utf-8").strip() + except OSError: + return None + if not workflow_id: + return None + lifecycle_path = state / "workflows" / workflow_id / "lifecycle" / "lifecycle.env" + try: + lines = lifecycle_path.read_text(encoding="utf-8").splitlines() + except OSError: + return None + for line in lines: + key, separator, value = line.partition("=") + if separator and key == "phase": + return value.strip() or None + return None + + def run_prod_solver(prompt_path: str | None, workdir: Path, repo_root: Path, timeout: int) -> int: """Run the production workflow and leave its current diff for SWE-bench. @@ -287,9 +310,34 @@ def run_prod_solver(prompt_path: str | None, workdir: Path, repo_root: Path, tim raise RuntimeError(f"production multiagent launch failed: {launch_tail}") deadline = time.monotonic() + timeout + resume_count = 0 try: - while time.monotonic() < deadline and tmux_has_orchestrator(session): - time.sleep(5) + while time.monotonic() < deadline: + while time.monotonic() < deadline and tmux_has_orchestrator(session): + time.sleep(5) + + phase = active_workflow_phase() + if phase in {None, "complete"} or time.monotonic() >= deadline: + break + + resume_count += 1 + log( + "orchestrator exited before lifecycle completion; " + f"resuming session={session} phase={phase} attempt={resume_count}" + ) + resume_args = [ + str(repo_root / "launch.sh"), + "--session", + session, + "--root", + str(workdir), + "--resume", + "--no-attach", + ] + resumed = run(resume_args, env=env, timeout=120) + resume_tail = ((resumed.stderr or "") + "\n" + (resumed.stdout or "")).strip()[-4000:] + if resumed.returncode != 0: + raise RuntimeError(f"production multiagent resume failed: {resume_tail}") finally: if tmux_has_session(session): run(["tmux", "-S", str(TMUX_SOCKET), "kill-session", "-t", session], timeout=30) diff --git a/prompts/playbooks/orchestration-routing.md b/prompts/playbooks/orchestration-routing.md index 86fc821..f34e4cd 100644 --- a/prompts/playbooks/orchestration-routing.md +++ b/prompts/playbooks/orchestration-routing.md @@ -96,6 +96,11 @@ acceptance review. Load `prompts/playbooks/agent-spawning.md` for the worker/verifier loop mechanics and `prompts/verifier.md` for the review role. The verifier module requires a verifier contract ledger, source-derived hidden-contract probes, assumption challenges, and an over-engineering pass. +Give the verifier a validation lease for the narrowest visible behavior test +that directly covers the changed path. When a scout or worker names such a test, +the verifier must run it after the final diff or return a concrete environment +blocker; compile-only or syntax-only evidence cannot satisfy behavior +verification. Before behavior verification or submission, run the build-verifier workflow for any code diff. Load `prompts/roles/build-verifier.md` and require diff --git a/prompts/verifier.md b/prompts/verifier.md index 4dffeac..6074601 100644 --- a/prompts/verifier.md +++ b/prompts/verifier.md @@ -84,6 +84,17 @@ Start by reconstructing the task contract independently from the user request, issue text, source, nearby tests, docs, and worker diff. Do not rely on the worker's summary as the source of truth. +For every code diff, identify the narrowest visible test file or documented +behavior command that directly exercises the changed behavior. If it is +runnable in the repository, acquire or receive its validation lease and run it +after the final diff. Syntax checks, compile-only commands, source review, and +another agent's narrative are not behavioral validation. If no direct visible +test exists, run a source-derived behavior probe through the affected public or +production entrypoint. If the direct test cannot run because of a concrete +environment dependency, report that exact command and dependency as unresolved +risk; do not silently replace it with `node --check`, `git diff --check`, or an +equally weak proxy. + Report a compact verifier contract ledger: - intended outcome @@ -440,7 +451,7 @@ accept, accept with follow-up, or reject pending follow-up. The first non-empty line of the final verifier message must be exactly `ACCEPTED` or `BLOCKING`. For a code diff, behavior `ACCEPTED` must include -`behavior-verification-passed: final-diff-sha256=... behavior_clean=true public-clauses-covered=true` +`behavior-verification-passed: final-diff-sha256=... behavior_clean=true public-clauses-covered=true command=... returncode=0` for the exact live final diff. Build acceptance remains a separate build verifier artifact. A missing verdict, stale hash, or unbound acceptance is blocking at the framework gate. diff --git a/tests/run.sh b/tests/run.sh index 018b0fc..a209c8e 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -1004,6 +1004,9 @@ assert_file_contains "$ROOT/prompts/verifier.md" "state-space partition audit" assert_file_contains "$ROOT/prompts/verifier.md" "mixed-category, unknown/forward-compatible variant" assert_file_contains "$ROOT/prompts/verifier.md" "state-space-partition-audit:" assert_file_contains "$ROOT/prompts/verifier.md" "behavior-verification-passed:" +assert_file_contains "$ROOT/prompts/verifier.md" "narrowest visible test file" +assert_file_contains "$ROOT/prompts/verifier.md" "Syntax checks, compile-only commands" +assert_file_contains "$ROOT/prompts/verifier.md" "command=... returncode=0" assert_file_contains "$ROOT/prompts/roles/contract-scout.md" "partition contract" assert_file_contains "$ROOT/prompts/roles/contract-scout.md" "historical-contract-ledger:" assert_file_contains "$ROOT/prompts/worker.md" "historical-contract-ledger:" diff --git a/tests/test_swe_outcomes.py b/tests/test_swe_outcomes.py index f379c3f..c44e464 100644 --- a/tests/test_swe_outcomes.py +++ b/tests/test_swe_outcomes.py @@ -72,6 +72,14 @@ def test_autonomous_authority_does_not_reopen_explicit_task_behavior(self): self.assertIn("autonomous run-to-terminal workflow", autonomous) self.assertIn("turn by offering to continue", autonomous) self.assertIn("assignment omitted a path required by the approved plan", autonomous) + verifier = (root / "prompts/verifier.md").read_text(encoding="utf-8") + routing = (root / "prompts/playbooks/orchestration-routing.md").read_text( + encoding="utf-8" + ) + self.assertIn("narrowest visible test file", verifier) + self.assertIn("Syntax checks, compile-only commands", verifier) + self.assertIn("command=... returncode=0", verifier) + self.assertIn("validation lease for the narrowest visible behavior test", routing) def test_runner_has_no_submission_rejection_path(self): self.assertFalse(hasattr(evalscope_multiagent_native_runner, "is_submission_gate_rejection")) @@ -130,6 +138,83 @@ def test_runner_monitors_the_orchestrator_tmux_socket(self): for call in run.call_args_list: self.assertEqual(call.args[0][:3], ["tmux", "-S", str(swe_prod_lifecycle.TMUX_SOCKET)]) + def test_active_workflow_phase_reads_persisted_lifecycle(self): + with tempfile.TemporaryDirectory() as directory: + runtime = Path(directory) + state = runtime / "state" + (state / "runtime_state").mkdir(parents=True) + (state / "runtime_state" / "active-workflow-id").write_text( + "workflow-1\n", encoding="utf-8" + ) + lifecycle = state / "workflows" / "workflow-1" / "lifecycle" + lifecycle.mkdir(parents=True) + (lifecycle / "lifecycle.env").write_text( + "workflow_id=workflow-1\nphase=implementation\n", encoding="utf-8" + ) + + with mock.patch.object(swe_prod_lifecycle, "RUNTIME_ROOT", runtime): + self.assertEqual(swe_prod_lifecycle.active_workflow_phase(), "implementation") + + def test_incomplete_workflow_is_resumed_before_workspace_handoff(self): + completed = SimpleNamespace(returncode=0, stdout="codex-cli 1.0\n", stderr="") + + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + prompt = root / "prompt.md" + prompt.write_text("prompt", encoding="utf-8") + lifecycle_patches = { + "require_path": mock.DEFAULT, + "multiagent_command": mock.Mock(return_value=["multiagent"]), + "find_codex_cli": mock.Mock(return_value="/usr/bin/codex"), + "git_head": mock.Mock(return_value="a" * 40), + "list_untracked_files": mock.Mock(return_value=[]), + "run": mock.Mock(return_value=completed), + "write_codex_bridge": mock.DEFAULT, + "write_apply_patch_helper": mock.DEFAULT, + "write_rg_fallback": mock.DEFAULT, + "read_prompt": mock.Mock(return_value="public task"), + "read_task_metadata": mock.Mock(return_value={}), + "make_prompt": mock.Mock(return_value=prompt), + "toolchain_path_prefixes": mock.Mock(return_value=[]), + "ensure_cache_dir": mock.Mock(return_value=str(root)), + "prepare_role_filesystem": mock.DEFAULT, + "restore_workspace_owner": mock.DEFAULT, + "tmux_has_session": mock.Mock(return_value=True), + "tmux_has_orchestrator": mock.Mock(return_value=False), + "active_workflow_phase": mock.Mock(side_effect=["implementation", "complete"]), + "materialize_committed_changes": mock.DEFAULT, + "mark_untracked_intent_to_add": mock.DEFAULT, + } + with mock.patch.multiple(swe_prod_lifecycle, **lifecycle_patches): + with mock.patch.object( + swe_prod_lifecycle.shutil, + "which", + side_effect=lambda name: "/usr/bin/tmux" if name == "tmux" else None, + ): + with mock.patch.dict( + swe_prod_lifecycle.os.environ, + { + "EVAL_CODEX_AUTH_MODE": "bridge", + "OPENAI_BASE_URL": "http://127.0.0.1:1/v1", + "OPENAI_API_KEY": "test-key", + }, + ): + self.assertEqual(swe_prod_lifecycle.run_prod_solver(None, root, root, 60), 0) + + launch_calls = [ + call + for call in swe_prod_lifecycle.run.call_args_list + if call.kwargs.get("env") is not None + and call.args + and isinstance(call.args[0], list) + and call.args[0] + and str(call.args[0][0]).endswith("launch.sh") + ] + + self.assertEqual(len(launch_calls), 2) + self.assertNotIn("--resume", launch_calls[0].args[0]) + self.assertIn("--resume", launch_calls[1].args[0]) + def test_shard_problem_statement_uses_relative_sample_id(self): with tempfile.TemporaryDirectory() as directory: repo = Path(directory) From 5a4d01eb60b0845a9affe25cfdf0ac3b01e6e03b Mon Sep 17 00:00:00 2001 From: Bo Wu Date: Sun, 16 Aug 2026 19:07:44 -0700 Subject: [PATCH 5/6] fix: resume orchestrator in live tmux sessions --- src/runtime.rs | 38 ++++++++++++++++++++++++++++---------- src/supervisor.rs | 8 ++++++++ tests/run.sh | 26 ++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 10 deletions(-) diff --git a/src/runtime.rs b/src/runtime.rs index e7b2838..45bf07d 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -382,11 +382,17 @@ pub fn launch(args: &[String]) -> Result { lifecycle_prompt.display() )); } - if tmux_success(&["has-session", "-t", &session]) { + let session_exists = tmux_success(&["has-session", "-t", &session]); + if session_exists && !resume { return Err(format!( "tmux session already exists: {session}\nAttach with: tmux attach -t {session}" )); } + if session_exists && window_exists(&session, "orchestrator") { + return Err(format!( + "tmux session already has an orchestrator window: {session}\nAttach with: tmux attach -t {session}" + )); + } let run_id = env_nonempty("MULTIAGENT_RUN_ID").unwrap_or_else(|| { format!( @@ -521,15 +527,27 @@ pub fn launch(args: &[String]) -> Result { )?; } let bootstrap_command = format!("bash {}", shell_escape(&bootstrap.display().to_string())); - let new_session = [ - "new-session", - "-d", - "-s", - &session, - "-n", - "orchestrator", - &bootstrap_command, - ]; + let new_session = if session_exists { + vec![ + "new-window", + "-d", + "-t", + &session, + "-n", + "orchestrator", + &bootstrap_command, + ] + } else { + vec![ + "new-session", + "-d", + "-s", + &session, + "-n", + "orchestrator", + &bootstrap_command, + ] + }; if env::var("MULTIAGENT_UID_SANDBOX").as_deref() == Ok("1") { tmux_checked_as_uid(&new_session, &executable, ORCHESTRATOR_UID)?; } else { diff --git a/src/supervisor.rs b/src/supervisor.rs index 1472071..27664fa 100644 --- a/src/supervisor.rs +++ b/src/supervisor.rs @@ -948,6 +948,14 @@ pub fn prepare_state_permissions(_state: &Path) -> Result<(), String> { #[cfg(target_os = "linux")] pub fn start(state: &Path, executable: &Path) -> Result { let socket = authority_socket(state); + if socket.exists() && UnixStream::connect(&socket).is_ok() { + let pid_path = state.join("runtime_state/authority-supervisor.pid"); + return fs::read_to_string(&pid_path) + .map_err(|error| format!("read existing authority supervisor pid: {error}"))? + .trim() + .parse::() + .map_err(|error| format!("parse existing authority supervisor pid: {error}")); + } let log_path = state.join("runtime_state/authority-supervisor.log"); if let Some(parent) = log_path.parent() { fs::create_dir_all(parent) diff --git a/tests/run.sh b/tests/run.sh index a209c8e..406f994 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -453,6 +453,32 @@ assert_file_contains "$LAUNCH_RESUME_BOOTSTRAP" "export MULTIAGENT_RESUME=1" assert_file_contains "$LAUNCH_RESUME_BOOTSTRAP" "export MULTIAGENT_VERIFIER_MAX_ITERATIONS=5" assert_file_contains "$LAUNCH_RESUME_BOOTSTRAP" "resume" +rm -f "$MOCK_TMUX_LOG" +printf 'reviewer-still-running\n' >"$MOCK_TMUX_WINDOWS" +MOCK_TMUX_HAS_SESSION=1 \ + MULTIAGENT_SESSION="launch-resume" \ + MULTIAGENT_ROOT= \ + MULTIAGENT_PROMPT= \ + MULTIAGENT_VERIFIER_MAX_ITERATIONS=5 \ + MULTIAGENT_STATE_DIR="$TMPDIR/launch-resume-state" \ + MULTIAGENT_WRITE_POLICY="$TMPDIR/launch-resume-policy/write-policy.paths" \ + "$ROOT/launch.sh" --session launch-resume --root "$LAUNCH_TARGET" --resume --no-attach >"$TMPDIR/launch-resume-existing.out" +assert_file_contains "$TMPDIR/launch-resume-existing.out" "Resume mode: 1" +assert_file_contains "$MOCK_TMUX_LOG" "new-window -d launch-resume orchestrator" +assert_file_not_contains "$MOCK_TMUX_LOG" "new-session launch-resume orchestrator" + +if MOCK_TMUX_HAS_SESSION=1 \ + MULTIAGENT_SESSION="launch-existing-clean" \ + MULTIAGENT_ROOT= \ + MULTIAGENT_PROMPT= \ + MULTIAGENT_STATE_DIR="$TMPDIR/launch-existing-clean-state" \ + MULTIAGENT_WRITE_POLICY="$TMPDIR/launch-existing-clean-policy/write-policy.paths" \ + "$ROOT/launch.sh" --session launch-existing-clean --root "$LAUNCH_TARGET" --no-attach >"$TMPDIR/launch-existing-clean.out" 2>&1; then + echo "expected clean launch against existing tmux session to fail" >&2 + exit 1 +fi +assert_file_contains "$TMPDIR/launch-existing-clean.out" "tmux session already exists" + if MOCK_TMUX_HAS_SESSION=0 \ MULTIAGENT_SESSION="launch-invalid-verifier-cap" \ MULTIAGENT_ROOT= \ From dd2e4ab7331fcb0796a5557bccfd19265fa63037 Mon Sep 17 00:00:00 2001 From: Bo Wu Date: Sun, 16 Aug 2026 20:26:03 -0700 Subject: [PATCH 6/6] fix: fail fast when docker is unavailable --- evaluation/swe_bench_pro_on_demand.py | 10 ++++++++ tests/test_swe_provenance.py | 35 +++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/evaluation/swe_bench_pro_on_demand.py b/evaluation/swe_bench_pro_on_demand.py index 2bc21ba..16c519b 100644 --- a/evaluation/swe_bench_pro_on_demand.py +++ b/evaluation/swe_bench_pro_on_demand.py @@ -28,6 +28,12 @@ SOLVER_SOURCE_LABEL = "org.multiagent.solver-source-sha256" +def docker_inspect_reports_missing(error: str) -> bool: + """Return whether Docker conclusively reported an absent local image.""" + + return bool(re.search(r"\b(?:no such image|no such object|not found)\b", error, re.IGNORECASE)) + + def inspect_image_identity(image: str) -> dict[str, Any]: """Return content-addressed local identity for a runnable Docker image.""" @@ -178,6 +184,10 @@ def ensure_image(self, image: str, instance_id: str) -> str: self.records.append({"instance_id": instance_id, "image": image, "status": "already_present"}) self._write("running") return self._ensure_baked_image(image, instance_id) + if inspect_error and not docker_inspect_reports_missing(inspect_error): + raise RuntimeError( + f"cannot determine whether Docker image {image} exists: {inspect_error}" + ) if self.min_free_gb > 0: free_gib = free_disk_gib(self.archive_dir) diff --git a/tests/test_swe_provenance.py b/tests/test_swe_provenance.py index b985734..27de249 100644 --- a/tests/test_swe_provenance.py +++ b/tests/test_swe_provenance.py @@ -14,7 +14,9 @@ from evaluation.swe_bench_pro import native_runner_summary_from_text from evaluation.swe_bench_pro_on_demand import ( + OnDemandImageManager, SOLVER_SOURCE_LABEL, + docker_inspect_reports_missing, inspect_image_identity, native_solver_source_digest, ) @@ -186,6 +188,39 @@ def test_rejects_image_with_unbound_source_label(self): class ImageIdentityTest(unittest.TestCase): + def test_docker_inspect_distinguishes_missing_image_from_infrastructure_failure(self): + self.assertTrue(docker_inspect_reports_missing("Error response from daemon: No such image: local:test")) + self.assertFalse( + docker_inspect_reports_missing( + "permission denied while trying to connect to the docker API" + ) + ) + + def test_on_demand_image_manager_fails_fast_when_docker_is_unavailable(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + manager = OnDemandImageManager( + archive_dir=root, + status_path=root / "status.json", + platform="linux/amd64", + image_timeout=60, + retries=3, + backoff_s=180, + min_free_gb=0, + prune_after_sample=False, + native_solver_source=root, + ) + with mock.patch( + "evaluation.swe_bench_pro_on_demand.docker_image_present", + return_value=(False, "permission denied while trying to connect to the docker API"), + ): + with mock.patch( + "evaluation.swe_bench_pro_on_demand.preload_image_with_retries" + ) as preload: + with self.assertRaisesRegex(RuntimeError, "cannot determine whether Docker image"): + manager.ensure_image("local:test", "row") + preload.assert_not_called() + def test_adapter_is_python38_and_within_line_budget(self): source = (Path(__file__).resolve().parents[1] / "evaluation/swe_bench_pro_provenance.py").read_text( encoding="utf-8"