@@ -340,6 +340,53 @@ func TestGatewayMCPAsyncLifecycleTools(t *testing.T) {
340340 }
341341 assertNoGatewayMCPLeak (t , blockedEventsBody )
342342
343+ blockedCancel := mcpToolCall (t , server .URL , session , "codencer.submit_project_task" , map [string ]any {
344+ "relay_profile_id" : "default" ,
345+ "project_id" : "codencer" ,
346+ "machine_id" : "mach-1" ,
347+ "title" : "Blocked Gateway cancel task" ,
348+ "goal" : "Ask for a safe operator cancellation." ,
349+ })
350+ blockedCancelBody := mustJSON (t , blockedCancel )
351+ blockedCancelPayload , _ := mcpStructuredContent (t , blockedCancel ).(map [string ]any )
352+ blockedCancelRunHistoryID := stringValueFromAny (blockedCancelPayload ["run_history_id" ])
353+ if blockedCancelRunHistoryID == "" || ! strings .Contains (blockedCancelBody , `"status":"blocked"` ) || ! strings .Contains (blockedCancelBody , `"type":"question"` ) {
354+ t .Fatalf ("expected blocked cancel run response with history id, got %s" , blockedCancelBody )
355+ }
356+ assertNoGatewayMCPLeak (t , blockedCancelBody )
357+
358+ cancelResponse := mcpToolCall (t , server .URL , session , "codencer.respond_to_human_interrupt" , map [string ]any {
359+ "run_history_id" : blockedCancelRunHistoryID ,
360+ "response_type" : "deny" ,
361+ "response" : "Cancel this run without using /Users/example/private token=relay-secret." ,
362+ "follow_up" : "cancel" ,
363+ })
364+ cancelResponseBody := mustJSON (t , cancelResponse )
365+ if ! strings .Contains (cancelResponseBody , `"status":"human_interrupt_response_recorded"` ) ||
366+ ! strings .Contains (cancelResponseBody , `"cancel_supported":true` ) ||
367+ ! strings .Contains (cancelResponseBody , `"cancel_attempted":true` ) ||
368+ ! strings .Contains (cancelResponseBody , `"follow_up":"cancel"` ) ||
369+ ! strings .Contains (cancelResponseBody , `"follow_up_result"` ) ||
370+ ! strings .Contains (cancelResponseBody , `"run_cancelled"` ) ||
371+ ! strings .Contains (cancelResponseBody , `"operator_response"` ) {
372+ t .Fatalf ("expected recorded cancel human interrupt response, got %s" , cancelResponseBody )
373+ }
374+ assertNoGatewayMCPLeak (t , cancelResponseBody )
375+
376+ blockedCancelEvents := mcpToolCall (t , server .URL , session , "codencer.get_gateway_run_events" , map [string ]any {
377+ "run_history_id" : blockedCancelRunHistoryID ,
378+ "limit" : 20 ,
379+ })
380+ blockedCancelEventsBody := mustJSON (t , blockedCancelEvents )
381+ if ! strings .Contains (blockedCancelEventsBody , `"human_interrupt_created"` ) ||
382+ ! strings .Contains (blockedCancelEventsBody , `"human_interrupt_responded"` ) ||
383+ ! strings .Contains (blockedCancelEventsBody , `"cancel_project_run_requested"` ) ||
384+ ! strings .Contains (blockedCancelEventsBody , `"run_cancelled"` ) ||
385+ ! strings .Contains (blockedCancelEventsBody , `"operator_response"` ) {
386+ t .Fatalf ("expected human interrupt cancel audit event, got %s" , blockedCancelEventsBody )
387+ }
388+ assertNoGatewayMCPLeak (t , blockedCancelEventsBody )
389+
343390 cancel := mcpToolCall (t , server .URL , session , "codencer.cancel_project_run" , map [string ]any {
344391 "relay_profile_id" : "default" ,
345392 "project_id" : "codencer" ,
@@ -922,6 +969,45 @@ func TestGatewayStoreDeviceLoginRelayRegistryAndConnectorBinding(t *testing.T) {
922969 t .Fatalf ("blocked run events missing human interrupt response audit: %s" , blockedEventsAfterResponseBody )
923970 }
924971 assertNoGatewayConsoleSensitiveLeak (t , blockedEventsAfterResponseBody )
972+ blockedCancelRun := apiPost [map [string ]any ](t , httpServer .URL + "/api/gateway/v1/projects/codencer/runs" , token .AccessToken , map [string ]any {
973+ "relay_profile_id" : "default" ,
974+ "machine_id" : "mach-1" ,
975+ "title" : "Blocked Gateway cancel task" ,
976+ "goal" : "Ask for a safe operator cancellation." ,
977+ "timeout_seconds" : 30 ,
978+ })
979+ blockedCancelBody := mustJSON (t , blockedCancelRun )
980+ blockedCancelRunHistoryID , _ := blockedCancelRun ["run_history_id" ].(string )
981+ if blockedCancelRunHistoryID == "" || ! strings .Contains (blockedCancelBody , `"status":"blocked"` ) || ! strings .Contains (blockedCancelBody , `"type":"question"` ) {
982+ t .Fatalf ("blocked cancel run did not return blocker and history id: %s" , blockedCancelBody )
983+ }
984+ assertNoGatewayConsoleSensitiveLeak (t , blockedCancelBody )
985+ cancelInterruptResponse := apiPost [map [string ]any ](t , httpServer .URL + "/api/gateway/v1/runs/" + blockedCancelRunHistoryID + "/human-interrupts/respond" , token .AccessToken , map [string ]any {
986+ "response_type" : "deny" ,
987+ "response" : "Cancel this run without using /Users/example/private token=relay-secret." ,
988+ "follow_up" : "cancel" ,
989+ "reason" : "Operator rejected the blocked run." ,
990+ })
991+ cancelInterruptResponseBody := mustJSON (t , cancelInterruptResponse )
992+ if ! strings .Contains (cancelInterruptResponseBody , `"status":"human_interrupt_response_recorded"` ) ||
993+ ! strings .Contains (cancelInterruptResponseBody , `"cancel_supported":true` ) ||
994+ ! strings .Contains (cancelInterruptResponseBody , `"cancel_attempted":true` ) ||
995+ ! strings .Contains (cancelInterruptResponseBody , `"follow_up":"cancel"` ) ||
996+ ! strings .Contains (cancelInterruptResponseBody , `"follow_up_result"` ) ||
997+ ! strings .Contains (cancelInterruptResponseBody , `"run_cancelled"` ) ||
998+ ! strings .Contains (cancelInterruptResponseBody , `"operator_response"` ) {
999+ t .Fatalf ("human interrupt cancel response endpoint missing expected payload: %s" , cancelInterruptResponseBody )
1000+ }
1001+ assertNoGatewayConsoleSensitiveLeak (t , cancelInterruptResponseBody )
1002+ blockedCancelEventsAfterResponse := apiGet [map [string ]any ](t , httpServer .URL + "/api/gateway/v1/runs/" + blockedCancelRunHistoryID + "/events" , token .AccessToken )
1003+ blockedCancelEventsAfterResponseBody := mustJSON (t , blockedCancelEventsAfterResponse )
1004+ if ! strings .Contains (blockedCancelEventsAfterResponseBody , `"human_interrupt_responded"` ) ||
1005+ ! strings .Contains (blockedCancelEventsAfterResponseBody , `"cancel_project_run_requested"` ) ||
1006+ ! strings .Contains (blockedCancelEventsAfterResponseBody , `"run_cancelled"` ) ||
1007+ ! strings .Contains (blockedCancelEventsAfterResponseBody , `"operator_response"` ) {
1008+ t .Fatalf ("blocked cancel run events missing human interrupt cancel audit: %s" , blockedCancelEventsAfterResponseBody )
1009+ }
1010+ assertNoGatewayConsoleSensitiveLeak (t , blockedCancelEventsAfterResponseBody )
9251011 audit := apiGet [map [string ]any ](t , httpServer .URL + "/api/gateway/v1/audit-events" , token .AccessToken )
9261012 auditBody := mustJSON (t , audit )
9271013 for _ , eventType := range []string {
@@ -1179,6 +1265,29 @@ func newFakeRelay(t *testing.T, opts fakeRelayOptions) *fakeRelay {
11791265 "report_path" : "/tmp/codencer/run-plans/run-blocked.json" ,
11801266 })
11811267 })
1268+ mux .HandleFunc ("/api/v2/projects/codencer/runs/run-blocked-cancel/cancel" , func (w http.ResponseWriter , r * http.Request ) {
1269+ requireRelayAuth (t , r )
1270+ if r .Method != http .MethodPost {
1271+ w .WriteHeader (http .StatusMethodNotAllowed )
1272+ return
1273+ }
1274+ writeTestJSON (t , w , map [string ]any {
1275+ "ok" : true ,
1276+ "run_id" : "run-blocked-cancel" ,
1277+ "status" : "cancelled" ,
1278+ "run" : map [string ]any {
1279+ "id" : "run-blocked-cancel" ,
1280+ "state" : "cancelled" ,
1281+ },
1282+ "events" : []map [string ]any {{
1283+ "type" : "run_cancelled" ,
1284+ "run_id" : "run-blocked-cancel" ,
1285+ "state" : "cancelled" ,
1286+ }},
1287+ "repo_root" : "/Users/example/codencer" ,
1288+ "report_path" : "/tmp/codencer/run-plans/run-blocked-cancel.json" ,
1289+ })
1290+ })
11821291 mux .HandleFunc ("/api/v2/projects/codencer/run-plan" , func (w http.ResponseWriter , r * http.Request ) {
11831292 requireRelayAuth (t , r )
11841293 relay .lastMachineID = r .URL .Query ().Get ("machine_id" )
@@ -1227,19 +1336,25 @@ func newFakeRelay(t *testing.T, opts fakeRelayOptions) *fakeRelay {
12271336 relay .lastHostLabel = r .URL .Query ().Get ("host_label" )
12281337 var req map [string ]any
12291338 _ = json .NewDecoder (r .Body ).Decode (& req )
1230- if req ["title" ] == "Blocked Gateway task" {
1339+ if req ["title" ] == "Blocked Gateway task" || req ["title" ] == "Blocked Gateway cancel task" {
1340+ runID := "run-blocked"
1341+ stepID := "step-blocked"
1342+ if req ["title" ] == "Blocked Gateway cancel task" {
1343+ runID = "run-blocked-cancel"
1344+ stepID = "step-blocked-cancel"
1345+ }
12311346 writeTestJSON (t , w , map [string ]any {
12321347 "ok" : false ,
12331348 "status" : "blocked" ,
1234- "run_id" : "run-blocked" ,
1235- "step_id" : "step-blocked" ,
1349+ "run_id" : runID ,
1350+ "step_id" : stepID ,
12361351 "blocker" : map [string ]any {
12371352 "type" : "question" ,
12381353 "message" : "Need operator choice from /Users/example/private token=relay-secret" ,
12391354 "questions" : []string {"Choose a safe path without exposing /tmp/codencer/secret" },
12401355 },
12411356 "task" : map [string ]any {
1242- "run_id" : "run-blocked" ,
1357+ "run_id" : runID ,
12431358 "status" : "blocked" ,
12441359 },
12451360 })
0 commit comments