@@ -387,6 +387,54 @@ func TestGatewayMCPAsyncLifecycleTools(t *testing.T) {
387387 }
388388 assertNoGatewayMCPLeak (t , blockedCancelEventsBody )
389389
390+ blockedStart := mcpToolCall (t , server .URL , session , "codencer.submit_project_task" , map [string ]any {
391+ "relay_profile_id" : "default" ,
392+ "project_id" : "codencer" ,
393+ "machine_id" : "mach-1" ,
394+ "title" : "Blocked Gateway start task" ,
395+ "goal" : "Ask for a safe replacement task." ,
396+ })
397+ blockedStartBody := mustJSON (t , blockedStart )
398+ blockedStartPayload , _ := mcpStructuredContent (t , blockedStart ).(map [string ]any )
399+ blockedStartRunHistoryID := stringValueFromAny (blockedStartPayload ["run_history_id" ])
400+ if blockedStartRunHistoryID == "" || ! strings .Contains (blockedStartBody , `"status":"blocked"` ) || ! strings .Contains (blockedStartBody , `"type":"question"` ) {
401+ t .Fatalf ("expected blocked start-new-task run response with history id, got %s" , blockedStartBody )
402+ }
403+ assertNoGatewayMCPLeak (t , blockedStartBody )
404+
405+ startResponse := mcpToolCall (t , server .URL , session , "codencer.respond_to_human_interrupt" , map [string ]any {
406+ "run_history_id" : blockedStartRunHistoryID ,
407+ "response_type" : "decision" ,
408+ "response" : "Start a new safe task without using /Users/example/private token=relay-secret." ,
409+ "follow_up" : "start_new_task" ,
410+ "new_task_title" : "Gateway follow-up task" ,
411+ "new_task_goal" : "Run a safe follow-up task that does not expose local paths." ,
412+ })
413+ startResponseBody := mustJSON (t , startResponse )
414+ if ! strings .Contains (startResponseBody , `"status":"human_interrupt_response_recorded"` ) ||
415+ ! strings .Contains (startResponseBody , `"start_new_task_supported":true` ) ||
416+ ! strings .Contains (startResponseBody , `"start_new_task_attempted":true` ) ||
417+ ! strings .Contains (startResponseBody , `"follow_up":"start_new_task"` ) ||
418+ ! strings .Contains (startResponseBody , `"follow_up_result"` ) ||
419+ ! strings .Contains (startResponseBody , `"run-followup"` ) ||
420+ ! strings .Contains (startResponseBody , `"operator_response"` ) {
421+ t .Fatalf ("expected recorded start_new_task human interrupt response, got %s" , startResponseBody )
422+ }
423+ assertNoGatewayMCPLeak (t , startResponseBody )
424+
425+ blockedStartEvents := mcpToolCall (t , server .URL , session , "codencer.get_gateway_run_events" , map [string ]any {
426+ "run_history_id" : blockedStartRunHistoryID ,
427+ "limit" : 20 ,
428+ })
429+ blockedStartEventsBody := mustJSON (t , blockedStartEvents )
430+ if ! strings .Contains (blockedStartEventsBody , `"human_interrupt_created"` ) ||
431+ ! strings .Contains (blockedStartEventsBody , `"human_interrupt_responded"` ) ||
432+ ! strings .Contains (blockedStartEventsBody , `"start_new_task_requested"` ) ||
433+ ! strings .Contains (blockedStartEventsBody , `"operator_response"` ) {
434+ t .Fatalf ("expected human interrupt start_new_task audit event, got %s" , blockedStartEventsBody )
435+ }
436+ assertNoGatewayMCPLeak (t , blockedStartEventsBody )
437+
390438 cancel := mcpToolCall (t , server .URL , session , "codencer.cancel_project_run" , map [string ]any {
391439 "relay_profile_id" : "default" ,
392440 "project_id" : "codencer" ,
@@ -1008,6 +1056,44 @@ func TestGatewayStoreDeviceLoginRelayRegistryAndConnectorBinding(t *testing.T) {
10081056 t .Fatalf ("blocked cancel run events missing human interrupt cancel audit: %s" , blockedCancelEventsAfterResponseBody )
10091057 }
10101058 assertNoGatewayConsoleSensitiveLeak (t , blockedCancelEventsAfterResponseBody )
1059+ blockedStartRun := apiPost [map [string ]any ](t , httpServer .URL + "/api/gateway/v1/projects/codencer/runs" , token .AccessToken , map [string ]any {
1060+ "relay_profile_id" : "default" ,
1061+ "machine_id" : "mach-1" ,
1062+ "title" : "Blocked Gateway start task" ,
1063+ "goal" : "Ask for a safe replacement task." ,
1064+ "timeout_seconds" : 30 ,
1065+ })
1066+ blockedStartBody := mustJSON (t , blockedStartRun )
1067+ blockedStartRunHistoryID , _ := blockedStartRun ["run_history_id" ].(string )
1068+ if blockedStartRunHistoryID == "" || ! strings .Contains (blockedStartBody , `"status":"blocked"` ) || ! strings .Contains (blockedStartBody , `"type":"question"` ) {
1069+ t .Fatalf ("blocked start-new-task run did not return blocker and history id: %s" , blockedStartBody )
1070+ }
1071+ assertNoGatewayConsoleSensitiveLeak (t , blockedStartBody )
1072+ startMissingGoalResponse := apiPost [map [string ]any ](t , httpServer .URL + "/api/gateway/v1/runs/" + blockedStartRunHistoryID + "/human-interrupts/respond" , token .AccessToken , map [string ]any {
1073+ "response_type" : "decision" ,
1074+ "response" : "Start a different task, but no task goal was provided." ,
1075+ "follow_up" : "start_new_task" ,
1076+ "reason" : "Operator requested a replacement task." ,
1077+ })
1078+ startMissingGoalResponseBody := mustJSON (t , startMissingGoalResponse )
1079+ if ! strings .Contains (startMissingGoalResponseBody , `"status":"human_interrupt_response_recorded"` ) ||
1080+ ! strings .Contains (startMissingGoalResponseBody , `"start_new_task_supported":true` ) ||
1081+ ! strings .Contains (startMissingGoalResponseBody , `"start_new_task_attempted":true` ) ||
1082+ ! strings .Contains (startMissingGoalResponseBody , `"follow_up":"start_new_task"` ) ||
1083+ ! strings .Contains (startMissingGoalResponseBody , `"new_task_goal_required"` ) ||
1084+ ! strings .Contains (startMissingGoalResponseBody , `"operator_response"` ) {
1085+ t .Fatalf ("human interrupt start_new_task missing-goal response missing expected blocker: %s" , startMissingGoalResponseBody )
1086+ }
1087+ assertNoGatewayConsoleSensitiveLeak (t , startMissingGoalResponseBody )
1088+ blockedStartEventsAfterResponse := apiGet [map [string ]any ](t , httpServer .URL + "/api/gateway/v1/runs/" + blockedStartRunHistoryID + "/events" , token .AccessToken )
1089+ blockedStartEventsAfterResponseBody := mustJSON (t , blockedStartEventsAfterResponse )
1090+ if ! strings .Contains (blockedStartEventsAfterResponseBody , `"human_interrupt_responded"` ) ||
1091+ ! strings .Contains (blockedStartEventsAfterResponseBody , `"start_new_task_blocked"` ) ||
1092+ ! strings .Contains (blockedStartEventsAfterResponseBody , `"new_task_goal_required"` ) ||
1093+ ! strings .Contains (blockedStartEventsAfterResponseBody , `"operator_response"` ) {
1094+ t .Fatalf ("blocked start-new-task events missing structured blocker audit: %s" , blockedStartEventsAfterResponseBody )
1095+ }
1096+ assertNoGatewayConsoleSensitiveLeak (t , blockedStartEventsAfterResponseBody )
10111097 audit := apiGet [map [string ]any ](t , httpServer .URL + "/api/gateway/v1/audit-events" , token .AccessToken )
10121098 auditBody := mustJSON (t , audit )
10131099 for _ , eventType := range []string {
@@ -1336,12 +1422,15 @@ func newFakeRelay(t *testing.T, opts fakeRelayOptions) *fakeRelay {
13361422 relay .lastHostLabel = r .URL .Query ().Get ("host_label" )
13371423 var req map [string ]any
13381424 _ = json .NewDecoder (r .Body ).Decode (& req )
1339- if req ["title" ] == "Blocked Gateway task" || req ["title" ] == "Blocked Gateway cancel task" {
1425+ if req ["title" ] == "Blocked Gateway task" || req ["title" ] == "Blocked Gateway cancel task" || req [ "title" ] == "Blocked Gateway start task" {
13401426 runID := "run-blocked"
13411427 stepID := "step-blocked"
13421428 if req ["title" ] == "Blocked Gateway cancel task" {
13431429 runID = "run-blocked-cancel"
13441430 stepID = "step-blocked-cancel"
1431+ } else if req ["title" ] == "Blocked Gateway start task" {
1432+ runID = "run-blocked-start"
1433+ stepID = "step-blocked-start"
13451434 }
13461435 writeTestJSON (t , w , map [string ]any {
13471436 "ok" : false ,
@@ -1360,6 +1449,24 @@ func newFakeRelay(t *testing.T, opts fakeRelayOptions) *fakeRelay {
13601449 })
13611450 return
13621451 }
1452+ if req ["title" ] == "Gateway follow-up task" {
1453+ if wait , _ := req ["wait" ].(bool ); wait {
1454+ t .Fatalf ("expected follow-up task to forward wait=false" )
1455+ }
1456+ writeTestJSON (t , w , map [string ]any {
1457+ "ok" : true ,
1458+ "status" : "submitted" ,
1459+ "run_id" : "run-followup" ,
1460+ "step_id" : "step-followup" ,
1461+ "task" : map [string ]any {
1462+ "run_id" : "run-followup" ,
1463+ "status" : "submitted" ,
1464+ "step_id" : "step-followup" ,
1465+ },
1466+ "report_path" : "/tmp/codencer/run-plans/run-followup.json" ,
1467+ })
1468+ return
1469+ }
13631470 if req ["title" ] == "Async Gateway Console task" {
13641471 if wait , _ := req ["wait" ].(bool ); wait {
13651472 t .Fatalf ("expected async console task to forward wait=false" )
0 commit comments