fix: RemoteStep writes HTTP response to _http_response_writer on StopPipeline#253
fix: RemoteStep writes HTTP response to _http_response_writer on StopPipeline#253
Conversation
…step stops pipeline with response_status Co-authored-by: intel352 <77607+intel352@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR ensures that when an external (gRPC) plugin step stops a pipeline and returns an HTTP response payload (status/body/headers) in its output, the engine writes that response to the in-flight http.ResponseWriter so HTTP handlers don’t fall back to default 200/202 responses that may leak pipeline context.
Changes:
- Update
RemoteStep.Executeto detectStopPipeline=true+response_statusand write headers/status/body to_http_response_writer, setting_response_handled=true. - Add unit tests covering response-writing behavior, absence of
_http_response_writer, and stop-without-response_status.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| plugin/external/remote_step.go | Writes HTTP response from plugin output when StopPipeline is set and a response writer is available. |
| plugin/external/remote_step_test.go | Adds tests validating the response-writing behavior and guard rails. |
| output := structToMap(resp.Output) | ||
|
|
||
| // When the plugin signals a pipeline stop with an HTTP response encoded in | ||
| // the output (response_status, response_body, response_headers), write that | ||
| // response to _http_response_writer so the caller sees the correct status | ||
| // code rather than a default 200/202. This mirrors the pattern used by | ||
| // step.auth_validate for 401 responses. | ||
| if resp.StopPipeline { | ||
| if w, ok := pc.Metadata["_http_response_writer"].(http.ResponseWriter); ok { | ||
| if statusRaw, hasStatus := output["response_status"]; hasStatus { | ||
| var statusCode int | ||
| switch v := statusRaw.(type) { |
There was a problem hiding this comment.
output := structToMap(resp.Output) can be nil when the plugin returns a response with output unset. In the StopPipeline branch the code indexes output["response_status"], which will panic on a nil map. Initialize output to an empty map (or guard output != nil) before reading response_status/response_headers/response_body.
| // TestRemoteStep_Execute_WritesHTTPResponseOnStopPipeline verifies that when a | ||
| // remote plugin returns StopPipeline=true with response_status, response_body, | ||
| // and response_headers in the output, the RemoteStep writes those to the | ||
| // _http_response_writer and sets _response_handled=true in pipeline metadata. | ||
| func TestRemoteStep_Execute_WritesHTTPResponseOnStopPipeline(t *testing.T) { | ||
| output, _ := structpb.NewStruct(map[string]any{ | ||
| "response_status": float64(http.StatusForbidden), | ||
| "response_body": `{"error":"forbidden: insufficient permissions"}`, | ||
| "response_headers": map[string]any{ | ||
| "Content-Type": "application/json", | ||
| }, | ||
| }) | ||
| stub := &stubPluginServiceClient{ | ||
| response: &pb.ExecuteStepResponse{ | ||
| Output: output, | ||
| StopPipeline: true, | ||
| }, | ||
| } | ||
| step := NewRemoteStep("authz-step", "handle-authz", stub, nil) | ||
|
|
||
| recorder := httptest.NewRecorder() | ||
| pc := module.NewPipelineContext(nil, nil) | ||
| pc.Metadata["_http_response_writer"] = http.ResponseWriter(recorder) | ||
|
|
||
| result, err := step.Execute(context.Background(), pc) | ||
| if err != nil { | ||
| t.Fatalf("unexpected error: %v", err) | ||
| } | ||
|
|
||
| if !result.Stop { | ||
| t.Error("expected Stop=true") | ||
| } | ||
|
|
||
| if recorder.Code != http.StatusForbidden { | ||
| t.Errorf("expected HTTP 403, got %d", recorder.Code) | ||
| } | ||
|
|
||
| body := recorder.Body.String() | ||
| if body != `{"error":"forbidden: insufficient permissions"}` { | ||
| t.Errorf("unexpected body: %q", body) | ||
| } | ||
|
|
||
| if pc.Metadata["_response_handled"] != true { | ||
| t.Error("expected _response_handled=true in metadata") | ||
| } | ||
| } |
There was a problem hiding this comment.
Add a regression test for the nil-output case: when the plugin returns StopPipeline=true but ExecuteStepResponse.Output is nil, RemoteStep.Execute should not panic and should not attempt to index into the output map. This test will protect the response-writing path once the nil-map panic is fixed.
|
@copilot apply changes based on the comments in this thread |
1 similar comment
|
@copilot apply changes based on the comments in this thread |
External plugin steps (e.g.
step.authz_check) signal denial viaStopPipeline: truewithresponse_status,response_body, andresponse_headersin their output — butRemoteStep.Executewas ignoring these fields, leaving the HTTP trigger and openapi handler to send their own default response (202 or 200 with full pipeline context), leaking internal state like tokens and claims.Changes
plugin/external/remote_step.go: After aStopPipeline=trueresponse, checks forresponse_statusin the output; if_http_response_writeris present in pipeline metadata, writes the encoded response (headers, status code, body) and sets_response_handled=true. Mirrors the pattern instep.auth_validatefor 401 responses.plugin/external/remote_step_test.go: Three new test cases covering:_response_handledis set_http_response_writerin metadata — no panic,_response_handledunsetStopPipeline=truewithoutresponse_status— response writer is left untouchedBefore / After
Original prompt
✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.