Skip to content
4 changes: 4 additions & 0 deletions cmd/made/contracts_red_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ func TestCapabilitiesJSONExposesStructuredRunContract(t *testing.T) {
SchemaVersion int `json:"schema_version"`
ProtocolVersion int `json:"protocol_version"`
Commands []string `json:"commands"`
Agents []string `json:"agents"`
}
if err := json.Unmarshal(readOutputFile(t, stdoutFile), &payload); err != nil {
t.Fatalf("capabilities output is not JSON: %v", err)
Expand All @@ -40,6 +41,9 @@ func TestCapabilitiesJSONExposesStructuredRunContract(t *testing.T) {
t.Fatalf("capabilities missing command %q: %+v", want, payload.Commands)
}
}
if len(payload.Agents) != 1 || payload.Agents[0] != "codex" {
t.Fatalf("capabilities agents = %v, want only codex", payload.Agents)
}
_ = stdout
_ = stderr
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/made/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -480,7 +480,7 @@ func gateNotifyPushHandler(rm *daemon.RunManager, reviewDecisions *daemon.Review

work := func(workCtx context.Context, emit func(daemon.Event)) error {
return orchestrator.Run(workCtx, gatePath, defaultBranch, worktreesDir, runID, newSHA,
orchestrator.NewWorkFunc(rm, reviewDecisions, emit, runID, defaultBranch, branch, orchestrator.Options{}))
orchestrator.NewWorkFunc(rm, reviewDecisions, emit, runID, defaultBranch, branch, orchestrator.Options{CandidateOutputSHA: p.OutputSHA}))
}

submissionID := p.SubmissionID
Expand Down
12 changes: 12 additions & 0 deletions cmd/made/runcommands.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ import (
"fmt"
"os"

"github.com/douglasjarquin/made/internal/agent"
"github.com/douglasjarquin/made/internal/api"
)

type capabilitiesReport struct {
SchemaVersion int `json:"schema_version"`
ProtocolVersion int `json:"protocol_version"`
Commands []string `json:"commands"`
Agents []string `json:"agents"`
}

func runCapabilitiesCommand(args []string, stdout, stderr *os.File) int {
Expand All @@ -28,9 +30,19 @@ func runCapabilitiesCommand(args []string, stdout, stderr *os.File) int {
return writeJSON(stdout, capabilitiesReport{
SchemaVersion: 1, ProtocolVersion: api.Version,
Commands: []string{"run.submit", "run.status", "run.list", "run.cancel", "review.decide", "doctor"},
Agents: supportedAgentNames(),
}, stderr, "made capabilities")
}

func supportedAgentNames() []string {
kinds := agent.SupportedKinds()
names := make([]string, len(kinds))
for index, kind := range kinds {
names[index] = string(kind)
}
return names
}

type runSubmitParams struct {
RunID string `json:"run_id,omitempty"`
GatePath string `json:"gate_path"`
Expand Down
4 changes: 2 additions & 2 deletions docs/remediation/made-remediation-p1p3b.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ The signing override was needed because the inherited global SSH signing configu

The installed Codex CLI was `/opt/homebrew/bin/codex` version `codex-cli 0.147.0`.

The supported invocation is `codex exec --cd <directory> --json --output-schema <schema> -`.
The supported invocation is `codex exec --cd <directory> --json --output-schema <schema> --sandbox read-only --ephemeral -`.

## Phase 1 RED contract

Expand Down Expand Up @@ -54,7 +54,7 @@ Those failures prove missing production boundaries rather than fixture defects b

The compatibility fake GitHub boundary rejected PR URLs where workflow run IDs were required and modeled check status, conclusion, workflow run ID, and details URL.

The fake Codex boundary accepted only the installed `codex exec --cd --json --output-schema` invocation and strict structured output.
The fake Codex boundary accepted only the installed `codex exec --cd --json --output-schema --sandbox read-only --ephemeral` invocation and strict structured output.

The old mocks were updated or removed so they do not authorize commands that the real Made binary does not implement.

Expand Down
9 changes: 5 additions & 4 deletions internal/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@ package agent
type Kind string

const (
KindClaude Kind = "claude"
KindCodex Kind = "codex"
KindCodex Kind = "codex"
)

func SupportedKinds() []Kind {
return []Kind{KindCodex}
}

func (k Kind) binaryName() string {
switch k {
case KindClaude:
return "claude"
case KindCodex:
return "codex"
default:
Expand Down
97 changes: 95 additions & 2 deletions internal/agent/agent_contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ func TestSpawn_CodexUsesStructuredExecContract(t *testing.T) {
if _, err := agent.Spawn(context.Background(), agent.KindCodex, agent.SpawnParams{
WorktreePath: worktree,
BinaryPath: bin,
Task: "inspect the candidate diff and return structured findings",
ExtraEnv: []string{
"FAKE_AGENT_KIND=codex",
"FAKE_AGENT_SCENARIO=" + scenarioPath,
Expand All @@ -36,11 +37,16 @@ func TestSpawn_CodexUsesStructuredExecContract(t *testing.T) {
if err != nil {
t.Fatalf("read invocation log: %v", err)
}
for _, token := range []string{"exec", "--json", "--output-schema", "--output-last-message", "--sandbox", "read-only", "--ephemeral", "-C"} {
for _, token := range []string{"exec", "--cd", "--json", "--output-schema", "--sandbox", "read-only", "--ephemeral", "-"} {
if !strings.Contains(string(data), token) {
t.Fatalf("expected Codex structured invocation token %q, got %s", token, data)
}
}
log := string(data)
_, task, ok := strings.Cut(log, "task=")
if !ok || strings.TrimSpace(task) == "" {
t.Fatalf("expected non-empty task on Codex stdin, got %s", data)
}
}

func TestSpawn_DoesNotPassSensitiveEnvironmentToCodex(t *testing.T) {
Expand All @@ -62,6 +68,7 @@ func TestSpawn_DoesNotPassSensitiveEnvironmentToCodex(t *testing.T) {
"COOKIE=must-not-reach-review-agent",
"JWT_KEY=must-not-reach-review-agent",
"KUBECONFIG=/must-not-reach-review-agent",
"LC_REVIEW_SECRET=must-not-reach-review-agent",
},
}); err != nil {
t.Fatalf("Spawn exposed sensitive environment: %v", err)
Expand All @@ -71,7 +78,7 @@ func TestSpawn_DoesNotPassSensitiveEnvironmentToCodex(t *testing.T) {
func TestSpawn_RejectsStructuredOutputWithoutFindingsField(t *testing.T) {
bin := agenttest.Build(t)
scenarioPath := filepath.Join(t.TempDir(), "invalid.json")
if err := os.WriteFile(scenarioPath, []byte(`{"unexpected":[]}`), 0o644); err != nil {
if err := os.WriteFile(scenarioPath, []byte(`{}`), 0o644); err != nil {
t.Fatalf("write invalid scenario: %v", err)
}

Expand All @@ -88,6 +95,92 @@ func TestSpawn_RejectsStructuredOutputWithoutFindingsField(t *testing.T) {
}
}

func TestSpawn_RejectsTrailingStructuredOutput(t *testing.T) {
bin := agenttest.Build(t)
scenarioPath := filepath.Join(t.TempDir(), "trailing.json")
if err := os.WriteFile(scenarioPath, []byte(`{"findings":[]}{"findings":[]}`), 0o644); err != nil {
t.Fatalf("write trailing scenario: %v", err)
}

_, err := agent.Spawn(context.Background(), agent.KindCodex, agent.SpawnParams{
WorktreePath: agentWorktree(t),
BinaryPath: bin,
ExtraEnv: []string{
"FAKE_AGENT_KIND=codex",
"FAKE_AGENT_SCENARIO=" + scenarioPath,
},
})
if err == nil {
t.Fatal("expected trailing structured output to fail closed")
}
}

func TestSpawn_ParsesCodexJSONLEventResponse(t *testing.T) {
bin := agenttest.Build(t)
scenarioPath := filepath.Join(t.TempDir(), "events.jsonl")
events := "{\"type\":\"turn.started\"}\n" +
"{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"{\\\"findings\\\":[]}\"}}\n" +
"{\"type\":\"turn.completed\"}\n"
if err := os.WriteFile(scenarioPath, []byte(events), 0o644); err != nil {
t.Fatalf("write JSONL scenario: %v", err)
}

findings, err := agent.Spawn(context.Background(), agent.KindCodex, agent.SpawnParams{
WorktreePath: agentWorktree(t),
BinaryPath: bin,
ExtraEnv: []string{
"FAKE_AGENT_KIND=codex",
"FAKE_AGENT_SCENARIO=" + scenarioPath,
},
})
if err != nil {
t.Fatalf("Spawn: %v", err)
}
if len(findings.Findings) != 0 {
t.Fatalf("expected empty findings from Codex JSONL response, got %+v", findings)
}
}

func TestSpawn_RejectsPatchOnNonAutoFixableFinding(t *testing.T) {
bin := agenttest.Build(t)
scenarioPath := filepath.Join(t.TempDir(), "invalid-finding.json")
if err := os.WriteFile(scenarioPath, []byte(`{"findings":[{"kind":"ask-user","description":"needs a decision","patch":"diff --git a/x b/x","paths":["x"]}]}`), 0o644); err != nil {
t.Fatalf("write invalid finding scenario: %v", err)
}

_, err := agent.Spawn(context.Background(), agent.KindCodex, agent.SpawnParams{
WorktreePath: agentWorktree(t),
BinaryPath: bin,
ExtraEnv: []string{
"FAKE_AGENT_KIND=codex",
"FAKE_AGENT_SCENARIO=" + scenarioPath,
},
})
if err == nil {
t.Fatal("expected non-auto-fixable patch to fail closed")
}
}

func TestSpawn_RejectsUnknownFindingKind(t *testing.T) {
bin := agenttest.Build(t)
scenarioPath := filepath.Join(t.TempDir(), "unknown-kind.json")
if err := os.WriteFile(scenarioPath, []byte(`{"findings":[{"kind":"style","description":"style advice"}]}`), 0o644); err != nil {
t.Fatalf("write unknown-kind scenario: %v", err)
}

_, err := agent.Spawn(context.Background(), agent.KindCodex, agent.SpawnParams{
WorktreePath: agentWorktree(t),
BinaryPath: bin,
ExtraEnv: []string{
"FAKE_AGENT_KIND=codex",
"FAKE_AGENT_SCENARIO=" + scenarioPath,
},
})
if err == nil {
t.Fatal("expected unknown finding kind to fail closed")
}
}

func TestFindingsJSONRoundTripUsesArrayShape(t *testing.T) {
data, err := json.Marshal(agent.Findings{Findings: []agent.Finding{}})
if err != nil {
Expand Down
10 changes: 0 additions & 10 deletions internal/agent/agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,13 +121,3 @@ func TestSpawn_LogsInvocation(t *testing.T) {
t.Fatalf("expected invocation log entry, got %q", data)
}
}

func TestSpawn_RejectsUnsupportedClaudeContract(t *testing.T) {
_, err := agent.Spawn(context.Background(), agent.KindClaude, agent.SpawnParams{
WorktreePath: agentWorktree(t),
BinaryPath: agenttest.Build(t),
})
if err == nil || !strings.Contains(err.Error(), "structured task contract is unsupported") {
t.Fatalf("expected explicit unsupported Claude error, got %v", err)
}
}
65 changes: 56 additions & 9 deletions internal/agent/findings.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,35 +21,82 @@ type Finding struct {
Paths []string `json:"paths,omitempty"`
}

func (f Finding) MarshalJSON() ([]byte, error) {
var patch *string
if f.Patch != "" {
patch = &f.Patch
}
var paths []string
if f.Paths != nil {
paths = append([]string(nil), f.Paths...)
}
return json.Marshal(struct {
Kind FindingKind `json:"kind"`
Description string `json:"description"`
Patch *string `json:"patch"`
Paths []string `json:"paths"`
}{
Kind: f.Kind,
Description: f.Description,
Patch: patch,
Paths: paths,
})
}

func (f *Finding) UnmarshalJSON(data []byte) error {
var wire struct {
Kind *FindingKind `json:"kind"`
Description *string `json:"description"`
Patch *string `json:"patch"`
Paths []string `json:"paths"`
Kind *FindingKind `json:"kind"`
Description *string `json:"description"`
Patch json.RawMessage `json:"patch"`
Paths json.RawMessage `json:"paths"`
}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&wire); err != nil {
return err
}
if wire.Kind == nil || wire.Description == nil {
return fmt.Errorf("finding requires kind and description")
if wire.Kind == nil || wire.Description == nil || len(wire.Patch) == 0 || len(wire.Paths) == 0 {
return fmt.Errorf("finding requires kind, description, patch, and paths")
}
f.Kind = *wire.Kind
f.Description = *wire.Description
f.Patch = ""
if wire.Patch != nil {
f.Patch = *wire.Patch
f.Paths = nil
if !bytes.Equal(bytes.TrimSpace(wire.Patch), []byte("null")) {
if err := json.Unmarshal(wire.Patch, &f.Patch); err != nil {
return fmt.Errorf("finding patch must be a string or null: %w", err)
}
}
if !bytes.Equal(bytes.TrimSpace(wire.Paths), []byte("null")) {
var paths []string
if err := json.Unmarshal(wire.Paths, &paths); err != nil {
return fmt.Errorf("finding paths must be an array or null: %w", err)
}
f.Paths = append([]string(nil), paths...)
}
f.Paths = append([]string(nil), wire.Paths...)
return nil
}

type Findings struct {
Findings []Finding `json:"findings"`
}

func (f *Findings) UnmarshalJSON(data []byte) error {
var wire struct {
Findings *[]Finding `json:"findings"`
}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&wire); err != nil {
return err
}
if wire.Findings == nil {
return fmt.Errorf("structured output requires findings")
}
f.Findings = append([]Finding(nil), (*wire.Findings)...)
return nil
}

func (f Findings) MarshalJSON() ([]byte, error) {
findings := f.Findings
if findings == nil {
Expand Down
Loading
Loading