diff --git a/agent-task/m-openai-workspace-agent-execution-contract/02+01_workspace_required_enforcement/CODE_REVIEW-local-G06.md b/agent-task/archive/2026/06/m-openai-workspace-agent-execution-contract/02+01_workspace_required_enforcement/code_review_local_G06_0.log similarity index 79% rename from agent-task/m-openai-workspace-agent-execution-contract/02+01_workspace_required_enforcement/CODE_REVIEW-local-G06.md rename to agent-task/archive/2026/06/m-openai-workspace-agent-execution-contract/02+01_workspace_required_enforcement/code_review_local_G06_0.log index 33f5824..b9ef906 100644 --- a/agent-task/m-openai-workspace-agent-execution-contract/02+01_workspace_required_enforcement/CODE_REVIEW-local-G06.md +++ b/agent-task/archive/2026/06/m-openai-workspace-agent-execution-contract/02+01_workspace_required_enforcement/code_review_local_G06_0.log @@ -42,42 +42,44 @@ task=m-openai-workspace-agent-execution-contract/02+01_workspace_required_enforc | 항목 | 완료 여부 | |------|---------| -| [API-1] Workspace-Required Validation Helper | [ ] | -| [API-2] Endpoint Regression Tests For Required And Non-Required Routes | [ ] | +| [API-1] Workspace-Required Validation Helper | [x] | +| [API-2] Endpoint Regression Tests For Required And Non-Required Routes | [x] | ## 구현 체크리스트 -- [ ] predecessor `01_route_catalog_workspace_bound`의 `complete.log`가 active 또는 archive 위치에 있는지 확인한다. -- [ ] workspace-bound route에서 workspace가 빈 값 또는 상대 경로이면 `/v1/responses`와 `/v1/chat/completions` 모두 OpenAI-compatible 400 error를 반환하게 한다. -- [ ] workspace가 필요 없는 inference/fallback route는 기존 동작을 유지하는 회귀 test를 추가한다. -- [ ] `go test ./apps/edge/internal/openai ./apps/edge/internal/service`와 `./scripts/e2e-openai-ollama.sh`를 실행한다. -- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. +- [x] predecessor `01_route_catalog_workspace_bound`의 `complete.log`가 active 또는 archive 위치에 있는지 확인한다. +- [x] workspace-bound route에서 workspace가 빈 값 또는 상대 경로이면 `/v1/responses`와 `/v1/chat/completions` 모두 OpenAI-compatible 400 error를 반환하게 한다. +- [x] workspace가 필요 없는 inference/fallback route는 기존 동작을 유지하는 회귀 test를 추가한다. +- [x] `go test ./apps/edge/internal/openai ./apps/edge/internal/service`와 `./scripts/e2e-openai-ollama.sh`를 실행한다. +- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. ## 코드리뷰 전용 체크리스트 > **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. > 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다. -- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. -- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. -- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G06_N.log`로 아카이브한다. -- [ ] active `PLAN-*-G??.md`를 `plan_local_G06_M.log`로 아카이브한다. -- [ ] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다. -- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. -- [ ] PASS이면 active task 디렉터리 `agent-task/m-openai-workspace-agent-execution-contract/02+01_workspace_required_enforcement/`를 `agent-task/archive/YYYY/MM/m-openai-workspace-agent-execution-contract/02+01_workspace_required_enforcement/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. -- [ ] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. -- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-openai-workspace-agent-execution-contract/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. +- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. +- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. +- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G06_N.log`로 아카이브한다. +- [x] active `PLAN-*-G??.md`를 `plan_local_G06_M.log`로 아카이브한다. +- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다. +- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. +- [x] PASS이면 active task 디렉터리 `agent-task/m-openai-workspace-agent-execution-contract/02+01_workspace_required_enforcement/`를 `agent-task/archive/YYYY/MM/m-openai-workspace-agent-execution-contract/02+01_workspace_required_enforcement/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [x] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-openai-workspace-agent-execution-contract/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. - [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G06.md`와 `CODE_REVIEW-local-G06.md`를 작성하고 `complete.log`를 작성하지 않는다. - [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다. - [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다. ## 계획 대비 변경 사항 -_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._ +없음. 계획의 Before/After 패턴을 그대로 따랐다. ## 주요 설계 결정 -_구현 에이전트가 주요 설계 결정 사항을 기록한다._ +- `validateWorkspaceForRoute`를 `chat_handler.go`에 package-level 함수로 배치했다. `responses_handler.go`도 같은 패키지이므로 별도 파일 없이 재사용 가능하다. +- `path/filepath` import를 `chat_handler.go`에만 추가했다. `responses_handler.go`는 helper를 호출만 하므로 직접 import하지 않아도 된다. +- 테스트용 공통 config helper `workspaceBoundCfg()`를 추가해 8개 테스트 케이스의 설정 중복을 제거했다. ## 사용자 리뷰 요청 @@ -110,21 +112,25 @@ _구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 ### API-1 중간 검증 ```bash -$ go test ./apps/edge/internal/openai -(output) +$ go test -count=1 ./apps/edge/internal/openai +ok iop/apps/edge/internal/openai 1.508s ``` ### API-2 중간 검증 ```bash -$ go test ./apps/edge/internal/openai ./apps/edge/internal/service -(output) +$ go test -count=1 ./apps/edge/internal/openai ./apps/edge/internal/service +ok iop/apps/edge/internal/openai 1.509s +ok iop/apps/edge/internal/service 0.005s ``` ### 최종 검증 ```bash -$ go test ./apps/edge/internal/openai ./apps/edge/internal/service +$ go test -count=1 ./apps/edge/internal/openai ./apps/edge/internal/service +ok iop/apps/edge/internal/openai 1.509s +ok iop/apps/edge/internal/service 0.005s + $ ./scripts/e2e-openai-ollama.sh -(output) +[openai-ollama] OpenAI-compatible Ollama serving test PASSED. ``` --- @@ -142,3 +148,17 @@ $ ./scripts/e2e-openai-ollama.sh | 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless user input is required to proceed. | | 리뷰어를 위한 체크포인트 | Fixed at stub creation | Review focus areas from the plan. | | 검증 결과 | Implementing agent | Paste actual stdout/stderr for the commands above. | + +## 코드리뷰 결과 + +- 종합 판정: PASS +- 차원별 평가: + - correctness: Pass + - completeness: Pass + - test coverage: Pass + - API contract: Pass + - code quality: Pass + - plan deviation: Pass + - verification trust: Pass +- 발견된 문제: 없음 +- 다음 단계: PASS - `complete.log` 작성 후 task directory를 archive로 이동한다. diff --git a/agent-task/archive/2026/06/m-openai-workspace-agent-execution-contract/02+01_workspace_required_enforcement/complete.log b/agent-task/archive/2026/06/m-openai-workspace-agent-execution-contract/02+01_workspace_required_enforcement/complete.log new file mode 100644 index 0000000..788219b --- /dev/null +++ b/agent-task/archive/2026/06/m-openai-workspace-agent-execution-contract/02+01_workspace_required_enforcement/complete.log @@ -0,0 +1,41 @@ +# Complete - m-openai-workspace-agent-execution-contract/02+01_workspace_required_enforcement + +## 완료 일시 + +2026-06-13 + +## 요약 + +Workspace-required OpenAI-compatible route enforcement 작업을 1회 리뷰 루프로 검증했고 최종 판정은 PASS다. + +## 루프 이력 + +| Plan | Review | Verdict | 메모 | +|------|--------|---------|------| +| `plan_local_G06_0.log` | `code_review_local_G06_0.log` | PASS | workspace-bound route의 missing/relative workspace 400 처리, non-required route 회귀, 검증 재실행이 계획 범위와 일치함 | + +## 구현/정리 내용 + +- `routeDispatch.WorkspaceRequired`가 true인 OpenAI route에서 workspace가 비어 있거나 상대 경로이면 `/v1/responses`와 `/v1/chat/completions`가 OpenAI-compatible 400 error를 반환하도록 했다. +- workspace 검증은 route catalog 기반 agent route에만 적용하고, non-required inference/fallback route는 workspace 없이 기존대로 실행되도록 유지했다. +- Responses/Chat Completions handler tests에 missing, relative, absolute workspace와 non-required route 회귀 케이스를 추가했다. + +## 최종 검증 + +- `go test -count=1 ./apps/edge/internal/openai ./apps/edge/internal/service` - PASS; `ok iop/apps/edge/internal/openai 1.509s`, `ok iop/apps/edge/internal/service 0.005s` +- `./scripts/e2e-openai-ollama.sh` - PASS; `[openai-ollama] OpenAI-compatible Ollama serving test PASSED.` + +## Roadmap Completion + +- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/openai-workspace-agent-execution-contract.md` +- Completed task ids: + - `workspace-required`: PASS; evidence=`agent-task/archive/2026/06/m-openai-workspace-agent-execution-contract/02+01_workspace_required_enforcement/plan_local_G06_0.log`, `agent-task/archive/2026/06/m-openai-workspace-agent-execution-contract/02+01_workspace_required_enforcement/code_review_local_G06_0.log`; verification=`go test -count=1 ./apps/edge/internal/openai ./apps/edge/internal/service`, `./scripts/e2e-openai-ollama.sh` +- Not completed task ids: 없음 + +## 잔여 Nit + +- 없음 + +## 후속 작업 + +- 없음 diff --git a/agent-task/m-openai-workspace-agent-execution-contract/02+01_workspace_required_enforcement/PLAN-local-G06.md b/agent-task/archive/2026/06/m-openai-workspace-agent-execution-contract/02+01_workspace_required_enforcement/plan_local_G06_0.log similarity index 100% rename from agent-task/m-openai-workspace-agent-execution-contract/02+01_workspace_required_enforcement/PLAN-local-G06.md rename to agent-task/archive/2026/06/m-openai-workspace-agent-execution-contract/02+01_workspace_required_enforcement/plan_local_G06_0.log diff --git a/apps/edge/internal/openai/chat_handler.go b/apps/edge/internal/openai/chat_handler.go index 67c8a31..7a77ef2 100644 --- a/apps/edge/internal/openai/chat_handler.go +++ b/apps/edge/internal/openai/chat_handler.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "net/http" + "path/filepath" "strings" "time" @@ -38,6 +39,10 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "invalid_request_error", "model is required") return } + if err := validateWorkspaceForRoute(dispatch, workspace); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request_error", err.Error()) + return + } basePrompt := promptFromMessages(req.Messages) if strings.TrimSpace(basePrompt) == "" { writeError(w, http.StatusBadRequest, "invalid_request_error", "messages are required") @@ -293,6 +298,19 @@ func httpStatusForRunError(err error) int { return http.StatusBadGateway } +func validateWorkspaceForRoute(d routeDispatch, workspace string) error { + if !d.WorkspaceRequired { + return nil + } + if strings.TrimSpace(workspace) == "" { + return fmt.Errorf("workspace is required for this model route") + } + if !filepath.IsAbs(workspace) { + return fmt.Errorf("workspace must be an absolute path") + } + return nil +} + func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) diff --git a/apps/edge/internal/openai/responses_handler.go b/apps/edge/internal/openai/responses_handler.go index 1002ef2..27a7c73 100644 --- a/apps/edge/internal/openai/responses_handler.go +++ b/apps/edge/internal/openai/responses_handler.go @@ -53,6 +53,10 @@ func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "invalid_request_error", "model is required") return } + if err := validateWorkspaceForRoute(dispatch, workspace); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request_error", err.Error()) + return + } runMeta["source"] = "openai-responses" runMeta["openai_model"] = req.Model diff --git a/apps/edge/internal/openai/server_test.go b/apps/edge/internal/openai/server_test.go index a278667..9ece61d 100644 --- a/apps/edge/internal/openai/server_test.go +++ b/apps/edge/internal/openai/server_test.go @@ -1167,3 +1167,138 @@ func TestCollectRunResultFailsWhenEventStreamCloses(t *testing.T) { t.Fatalf("expected run stream closed error, got %v", err) } } + +// workspaceBoundCfg returns a server config with a workspace-required codex route and a +// non-required llama3 route for workspace validation tests. +func workspaceBoundCfg() config.EdgeOpenAIConf { + return config.EdgeOpenAIConf{ + Adapter: "ollama", + Target: "llama3", + ModelRoutes: []config.OpenAIRouteEntry{ + {Model: "codex", Adapter: "cli", Target: "codex", WorkspaceRequired: true}, + {Model: "llama3", Adapter: "ollama", Target: "llama3"}, + }, + } +} + +func TestResponsesWorkspaceRequiredRouteMissingWorkspace400(t *testing.T) { + fake := &fakeRunService{events: make(chan *iop.RunEvent)} + srv := NewServer(workspaceBoundCfg(), fake, nil) + body := `{"model":"codex","input":"hello"}` + req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(body)) + w := httptest.NewRecorder() + srv.handleResponses(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("missing workspace: want 400, got %d body=%s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "workspace is required") { + t.Fatalf("expected workspace error, got %s", w.Body.String()) + } +} + +func TestResponsesWorkspaceRequiredRouteRelativeWorkspace400(t *testing.T) { + fake := &fakeRunService{events: make(chan *iop.RunEvent)} + srv := NewServer(workspaceBoundCfg(), fake, nil) + body := `{"model":"codex","input":"hello","metadata":{"workspace":"relative/path"}}` + req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(body)) + w := httptest.NewRecorder() + srv.handleResponses(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("relative workspace: want 400, got %d body=%s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "absolute path") { + t.Fatalf("expected absolute path error, got %s", w.Body.String()) + } +} + +func TestResponsesWorkspaceRequiredRouteAbsoluteWorkspaceOK(t *testing.T) { + fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)} + fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"} + fake.events <- &iop.RunEvent{Type: "complete"} + srv := NewServer(workspaceBoundCfg(), fake, nil) + body := `{"model":"codex","input":"hello","metadata":{"workspace":"/abs/path"}}` + req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(body)) + w := httptest.NewRecorder() + srv.handleResponses(w, req) + if w.Code != http.StatusOK { + t.Fatalf("absolute workspace: want 200, got %d body=%s", w.Code, w.Body.String()) + } + if fake.req.Workspace != "/abs/path" { + t.Fatalf("workspace not preserved: got %q", fake.req.Workspace) + } +} + +func TestResponsesNonRequiredRouteNoWorkspaceOK(t *testing.T) { + fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)} + fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"} + fake.events <- &iop.RunEvent{Type: "complete"} + srv := NewServer(workspaceBoundCfg(), fake, nil) + body := `{"model":"llama3","input":"hello"}` + req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(body)) + w := httptest.NewRecorder() + srv.handleResponses(w, req) + if w.Code != http.StatusOK { + t.Fatalf("non-required route no workspace: want 200, got %d body=%s", w.Code, w.Body.String()) + } +} + +func TestChatCompletionsWorkspaceRequiredRouteMissingWorkspace400(t *testing.T) { + fake := &fakeRunService{events: make(chan *iop.RunEvent)} + srv := NewServer(workspaceBoundCfg(), fake, nil) + body := `{"model":"codex","messages":[{"role":"user","content":"hi"}]}` + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body)) + w := httptest.NewRecorder() + srv.handleChatCompletions(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("missing workspace: want 400, got %d body=%s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "workspace is required") { + t.Fatalf("expected workspace error, got %s", w.Body.String()) + } +} + +func TestChatCompletionsWorkspaceRequiredRouteRelativeWorkspace400(t *testing.T) { + fake := &fakeRunService{events: make(chan *iop.RunEvent)} + srv := NewServer(workspaceBoundCfg(), fake, nil) + body := `{"model":"codex","messages":[{"role":"user","content":"hi"}],"metadata":{"workspace":"some/relative"}}` + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body)) + w := httptest.NewRecorder() + srv.handleChatCompletions(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("relative workspace: want 400, got %d body=%s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "absolute path") { + t.Fatalf("expected absolute path error, got %s", w.Body.String()) + } +} + +func TestChatCompletionsWorkspaceRequiredRouteAbsoluteWorkspaceOK(t *testing.T) { + fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)} + fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"} + fake.events <- &iop.RunEvent{Type: "complete"} + srv := NewServer(workspaceBoundCfg(), fake, nil) + body := `{"model":"codex","messages":[{"role":"user","content":"hi"}],"metadata":{"workspace":"/abs/workspace"}}` + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body)) + w := httptest.NewRecorder() + srv.handleChatCompletions(w, req) + if w.Code != http.StatusOK { + t.Fatalf("absolute workspace: want 200, got %d body=%s", w.Code, w.Body.String()) + } + if fake.req.Workspace != "/abs/workspace" { + t.Fatalf("workspace not preserved: got %q", fake.req.Workspace) + } +} + +func TestChatCompletionsNonRequiredRouteNoWorkspaceOK(t *testing.T) { + fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)} + fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"} + fake.events <- &iop.RunEvent{Type: "complete"} + srv := NewServer(workspaceBoundCfg(), fake, nil) + body := `{"model":"llama3","messages":[{"role":"user","content":"hi"}]}` + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body)) + w := httptest.NewRecorder() + srv.handleChatCompletions(w, req) + if w.Code != http.StatusOK { + t.Fatalf("non-required route no workspace: want 200, got %d body=%s", w.Code, w.Body.String()) + } +}