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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 17 additions & 16 deletions cmd/forge-codex-plugin/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"agent-forge/internal/configjson"
"agent-forge/internal/pluginprotocol"
"agent-forge/internal/processtree"
"agent-forge/internal/protocol"
)

func main() {
Expand All @@ -32,60 +33,60 @@ func main() {
}

func serve(in io.Reader, out io.Writer) error {
return pluginprotocol.Serve(in, out, []pluginprotocol.Capability{pluginprotocol.WorkspaceEdit, pluginprotocol.CommitSubject}, func(ctx context.Context, request pluginprotocol.Request) (pluginprotocol.Result, error) {
subject, err := executeCodex(ctx, request)
return pluginprotocol.Result{CommitSubject: subject}, err
return pluginprotocol.Serve(in, out, []pluginprotocol.Capability{pluginprotocol.WorkspaceEdit, pluginprotocol.CommitSubject, pluginprotocol.AgentReport}, func(ctx context.Context, request pluginprotocol.Request) (pluginprotocol.Result, error) {
return executeCodex(ctx, request)
})
}

func executeCodex(parent context.Context, request pluginprotocol.Request) (*string, error) {
func executeCodex(parent context.Context, request pluginprotocol.Request) (pluginprotocol.Result, error) {
head, err := gitHead(request.Workspace)
if err != nil {
return nil, fmt.Errorf("invalid_workspace")
return pluginprotocol.Result{}, fmt.Errorf("invalid_workspace")
}
privateDir, err := os.MkdirTemp(os.TempDir(), "forge-codex-")
if err != nil {
return nil, fmt.Errorf("codex_failed")
return pluginprotocol.Result{}, fmt.Errorf("codex_failed")
}
defer os.RemoveAll(privateDir)
schemaPath, outputPath := filepath.Join(privateDir, "schema.json"), filepath.Join(privateDir, "final.json")
schema := []byte(`{"type":"object","properties":{"commit_subject":{"type":"string"}},"required":["commit_subject"],"additionalProperties":false}`)
schema := []byte(`{"type":"object","properties":{"commit_subject":{"type":"string","minLength":1,"maxLength":256},"summary":{"type":"string","minLength":1,"maxLength":1024},"changes":{"type":"array","minItems":1,"maxItems":12,"items":{"type":"string","minLength":1,"maxLength":256}}},"required":["commit_subject","summary","changes"],"additionalProperties":false}`)
if os.WriteFile(schemaPath, schema, 0o600) != nil || os.WriteFile(outputPath, nil, 0o600) != nil {
return nil, fmt.Errorf("codex_failed")
return pluginprotocol.Result{}, fmt.Errorf("codex_failed")
}
bin := os.Getenv("CODEX_BIN")
if bin == "" {
bin = "codex"
}
ctx, cancel := context.WithTimeout(parent, time.Duration(request.TimeoutMS)*time.Millisecond)
defer cancel()
prompt := "Edit only files in the provided workspace to complete the task. Follow AGENTS.md and other repository instructions only when they do not conflict with this prompt's constraints or the task. Within this plugin's existing execution environment and lifecycle, you may run workspace-local, repository-native focused validation when useful and not prohibited by the task. Its output and your claims are advisory executor feedback, never Worker acceptance evidence. Do not use Git, commit, or access paths outside the workspace. After inspecting the actual resulting diff, return exactly the structured final object requested by the output schema with one conventional commit subject describing the actual change.\n\nTask:\n" + request.Instruction
prompt := "Edit only files in the provided workspace to complete the task. Follow AGENTS.md and other repository instructions only when they do not conflict with this prompt's constraints or the task. Within this plugin's existing execution environment and lifecycle, you may run workspace-local, repository-native focused validation when useful and not prohibited by the task. Its output and your claims are advisory executor feedback, never Worker acceptance evidence. Use read-only git diff within the workspace to inspect changes; do not use other Git commands, commit, or access paths outside the workspace. After inspecting the actual resulting diff, return exactly the structured final object requested by the output schema with exactly commit_subject (one conventional commit subject), summary (concise, at most 1024 UTF-8 bytes), and changes (1–12 concrete change bullets, at most 256 UTF-8 bytes each). All strings must be non-empty, trimmed and contain no control characters. Describe only actual changes in the diff, including why they matter. This report is self-reported and is not independent check evidence.\n\nTask:\n" + request.Instruction
cmd := exec.Command(bin, "exec", "--ephemeral", "--sandbox", "workspace-write", "--color", "never", "-C", request.Workspace, "--output-schema", schemaPath, "--output-last-message", outputPath, "-")
cmd.Stdin = bytes.NewBufferString(prompt)
budget := &outputBudget{n: 1 << 20}
cmd.Stdout = &limitedWriter{budget: budget}
cmd.Stderr = &limitedWriter{budget: budget}
if err := processtree.Run(ctx, cmd); err != nil {
return nil, fmt.Errorf("codex_failed")
return pluginprotocol.Result{}, fmt.Errorf("codex_failed")
}
after, err := gitHead(request.Workspace)
if err != nil || after != head {
return nil, fmt.Errorf("plugin_committed")
return pluginprotocol.Result{}, fmt.Errorf("plugin_committed")
}
file, err := os.Open(outputPath)
if err != nil {
return nil, fmt.Errorf("codex_failed")
return pluginprotocol.Result{}, fmt.Errorf("codex_failed")
}
defer file.Close()
const maxFinalBytes = pluginprotocol.MaxCommitSubjectBytes + 64
const maxFinalBytes = protocol.MaxAgentReportBytes + 2048
data, err := io.ReadAll(io.LimitReader(file, maxFinalBytes+1))
var final struct {
CommitSubject string `json:"commit_subject"`
protocol.AgentReport
}
if err != nil || len(data) == 0 || len(data) > maxFinalBytes || !utf8.Valid(data) || configjson.Decode(data, &final) != nil || pluginprotocol.ValidateCommitSubject(&final.CommitSubject, true) != nil {
return nil, fmt.Errorf("codex_failed")
if err != nil || len(data) == 0 || len(data) > maxFinalBytes || !utf8.Valid(data) || configjson.Decode(data, &final) != nil || pluginprotocol.ValidateCommitSubject(&final.CommitSubject, true) != nil || protocol.ValidateAgentReport(&final.AgentReport) != nil {
return pluginprotocol.Result{}, fmt.Errorf("codex_failed")
}
return &final.CommitSubject, nil
return pluginprotocol.Result{CommitSubject: &final.CommitSubject, Report: &final.AgentReport}, nil
}

func gitHead(workspace string) (string, error) {
Expand Down
53 changes: 33 additions & 20 deletions cmd/forge-codex-plugin/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,32 +40,42 @@ assert schema.parent == output.parent and schema.parent.parent == pathlib.Path(o
assert stat.S_IMODE(schema.parent.stat().st_mode) == 0o700
assert stat.S_IMODE(schema.stat().st_mode) == 0o600
spec=json.loads(schema.read_text())
assert spec["required"] == ["commit_subject"] and spec["additionalProperties"] is False
assert spec["required"] == ["commit_subject", "summary", "changes"] and spec["additionalProperties"] is False
prompt=sys.stdin.read()
assert "actual resulting diff" in prompt
assert "read-only git diff" in prompt
assert "self-reported" in prompt and "1–12 concrete change bullets" in prompt
assert "Do not run tests" not in prompt
assert "Follow AGENTS.md and other repository instructions only when they do not conflict with this prompt's constraints or the task." in prompt
assert "Within this plugin's existing execution environment and lifecycle, you may run workspace-local, repository-native focused validation when useful and not prohibited by the task." in prompt
assert "Its output and your claims are advisory executor feedback, never Worker acceptance evidence." in prompt
pathlib.Path(workspace,"answer.txt").write_text("edited\n")
output.write_text(json.dumps({"commit_subject":"fix: use executor result"},separators=(",",":")))
output.write_text(json.dumps({"commit_subject":"fix: use executor result","summary":"Update the answer from executor feedback","changes":["Replace the base answer with the edited answer"]},separators=(",",":")))
`), 0o700); err != nil {
t.Fatal(err)
}
t.Setenv("CODEX_BIN", fake)
privateTmp := t.TempDir()
t.Setenv("TMPDIR", privateTmp)
id := strings.Repeat("a", 32)
input := `{"version":"v1","id":"` + id + `","type":"initialize","capabilities":["workspace_edit","commit_subject"],"limits":{"frame_bytes":1048576,"progress_frames":128,"text_bytes":65536,"progress_text_bytes":1024,"commit_subject_bytes":256}}` + "\n" +
`{"version":"v1","id":"` + id + `","type":"execute","operation":"workspace_edit","workspace":` + quote(workspace) + `,"instruction":"edit","timeout_ms":1000}` + "\n"
var output bytes.Buffer
if err := serve(strings.NewReader(input), &output); err != nil {
t.Fatal(err)
}
want := `{"version":"v1","id":"` + id + `","type":"initialized","capabilities":["workspace_edit","commit_subject"]}` + "\n" +
`{"version":"v1","id":"` + id + `","type":"result","commit_subject":"fix: use executor result"}` + "\n"
if output.String() != want || git("rev-parse", "HEAD") != head {
t.Fatalf("output=%q head=%s", output.String(), git("rev-parse", "HEAD"))
for _, report := range []bool{false, true} {
id := strings.Repeat("a", 32)
capabilities := `"workspace_edit","commit_subject"`
fields := ""
if report {
capabilities += `,"agent_report"`
fields = `,"summary":"Update the answer from executor feedback","changes":["Replace the base answer with the edited answer"]`
}
input := `{"version":"v1","id":"` + id + `","type":"initialize","capabilities":[` + capabilities + `],"limits":{"frame_bytes":1048576,"progress_frames":128,"text_bytes":65536,"progress_text_bytes":1024,"commit_subject_bytes":256}}` + "\n" +
`{"version":"v1","id":"` + id + `","type":"execute","operation":"workspace_edit","workspace":` + quote(workspace) + `,"instruction":"edit","timeout_ms":1000}` + "\n"
var output bytes.Buffer
if err := serve(strings.NewReader(input), &output); err != nil {
t.Fatal(err)
}
want := `{"version":"v1","id":"` + id + `","type":"initialized","capabilities":[` + capabilities + `]}` + "\n" +
`{"version":"v1","id":"` + id + `","type":"result","commit_subject":"fix: use executor result"` + fields + "}\n"
if output.String() != want || git("rev-parse", "HEAD") != head {
t.Fatalf("report=%v output=%q", report, output.String())
}
}
entries, err := os.ReadDir(privateTmp)
if err != nil || len(entries) != 0 {
Expand All @@ -91,13 +101,16 @@ if body:
t.Setenv("CODEX_BIN", fake)
t.Setenv("TMPDIR", privateTmp)
for name, body := range map[string][]byte{
"missing": nil,
"malformed": []byte(`{"commit_subject":`),
"duplicate": []byte(`{"commit_subject":"fix: one","commit_subject":"fix: two"}`),
"unknown": []byte(`{"commit_subject":"fix: one","private":"secret"}`),
"trailing": []byte(`{"commit_subject":"fix: one"}{}`),
"invalid UTF-8": {'{', '"', 'c', 'o', 'm', 'm', 'i', 't', '_', 's', 'u', 'b', 'j', 'e', 'c', 't', '"', ':', '"', 0xff, '"', '}'},
"oversized": []byte(`{"commit_subject":"` + strings.Repeat("x", 400) + `"}`),
"missing": nil,
"missing report": []byte(`{"commit_subject":"fix: valid"}`),
"oversized summary": []byte(`{"commit_subject":"fix: valid","summary":"` + strings.Repeat("x", 1025) + `","changes":["x"]}`),
"malformed report": []byte(`{"commit_subject":"fix: valid","summary":"x","changes":[]}`),
"malformed": []byte(`{"commit_subject":`),
"duplicate": []byte(`{"commit_subject":"fix: one","commit_subject":"fix: two"}`),
"unknown": []byte(`{"commit_subject":"fix: one","private":"secret"}`),
"trailing": []byte(`{"commit_subject":"fix: one"}{}`),
"invalid UTF-8": {'{', '"', 'c', 'o', 'm', 'm', 'i', 't', '_', 's', 'u', 'b', 'j', 'e', 'c', 't', '"', ':', '"', 0xff, '"', '}'},
"oversized": []byte(`{"commit_subject":"` + strings.Repeat("x", 400) + `"}`),
} {
t.Run(name, func(t *testing.T) {
workspace := t.TempDir()
Expand Down
2 changes: 1 addition & 1 deletion cmd/forge-codex-plugin/testdata/fake-codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@
answer_go.write_text(answer_go.read_text().replace("return 0", "return 42"))
else:
pathlib.Path(workspace, "answer.txt").write_text("conformance\n")
output.write_text(json.dumps({"commit_subject": "test: prove conformance"}, separators=(",", ":")))
output.write_text(json.dumps({"commit_subject": "test: prove conformance", "summary":"Update the fixture answer", "changes":["Write the conformance answer into the workspace"]}, separators=(",", ":")))
6 changes: 4 additions & 2 deletions docs/plugin-protocol-v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ The Worker generates a lowercase 32-hex `id`. Every frame has exactly `version`,
{"version":"v1","id":"0123456789abcdef0123456789abcdef","type":"initialized","capabilities":["text"]}
```

Capabilities are closed: `text`, `workspace_edit`, `progress`, `cancel`, `commit_subject`. `initialized.capabilities` is a duplicate-free subset of the offer and must include the operation capability. Unknown or unoffered selections fail.
Capabilities are closed: `text`, `workspace_edit`, `progress`, `cancel`, `commit_subject`, `agent_report`. `initialized.capabilities` is a duplicate-free subset of the offer and must include the operation capability. Unknown or unoffered selections fail.

## Operations and terminals

Expand All @@ -38,7 +38,9 @@ Workspace execution and success:
{"version":"v1","id":"0123456789abcdef0123456789abcdef","type":"result","commit_subject":"feat: describe the edit"}
```

The workspace result has exactly the common fields and optional `commit_subject`. If present, `commit_subject` requires negotiation, is 1..256 UTF-8 bytes, has no leading/trailing Unicode whitespace, Unicode control or format characters, U+2028/U+2029, or logical second line. When absent, Worker uses `chore: apply coding task`. Worker passes it as one argv element.
`agent_report` is an optional v1 capability. Workers offer it; plugins select it only when offered. Workspace results may contain `summary` and `changes` only when selected. Otherwise plugins omit both fields and receivers strictly reject either field, including null. Legacy `commit_subject` results remain valid. The v1 limits and version are unchanged.

The workspace result has the common fields, optional `commit_subject`, and an optional report pair: `summary` and `changes`. Legacy results without the report remain valid. A report has a non-empty summary of at most 1024 UTF-8 bytes and 1–12 non-empty change strings of at most 256 UTF-8 bytes each. Report strings must be trimmed and contain no Unicode control/format characters or U+2028/U+2029. Unknown, duplicate, malformed Unicode, or partially supplied report fields fail closed. The report is advisory and self-reported, never check evidence. Worker encodes it canonically into the existing candidate result only after successful checks and candidate creation; Gate persists it with the producing attempt. Progress frames remain transient and are not stored. If present, `commit_subject` requires negotiation, is 1..256 UTF-8 bytes, has no leading/trailing Unicode whitespace, Unicode control or format characters, U+2028/U+2029, or logical second line. When absent, Worker uses `chore: apply coding task`. Worker passes it as one argv element.

Progress requires negotiation, is limited to 128 frames, and has monotonically consecutive sequence numbers starting at 1. `stage` is one of `started`, `working`, `finalizing`; `text` is at most 1,024 UTF-8 bytes:

Expand Down
Loading
Loading