fix(ai): external responder on its own listener with random turn ids and reply tokens
보안 리뷰 반영. 외부 응답은 곧 진단이고 자동 승인 아래서는 실행 조치가 되므로, /external/next·/external/reply 를 에이전트 포트에서 떼어 EXTERNAL_ADDR 리스너(기본 꺼짐, VM compose 는 127.0.0.1:8090 에만 매핑해 IAP SSH 터널로만 도달)로 옮기고, 턴 id 를 난수로 만들며, 응답마다 그 턴을 가져간 클라이언트만 아는 reply_token 을 요구해 상수 시간으로 비교한다. /selfcheck 는 리스너가 꺼져 있으면 external_responder: "off" 를 보인다.
This commit is contained in:
1 parent
ed3d41d399
commit
b0ff118841
3 files changed
+67
-28
No files matched your search
+14
-16
@@ -18,10 +18,7 @@ import (
|
||||
// the forced-submit flag; the reply's arguments may be a JSON string or object.
|
||||
func TestTurnsOverHTTP(t *testing.T) {
|
||||
r := New(5 * time.Second)
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /external/next", r.Next)
|
||||
mux.HandleFunc("POST /external/reply", r.ReplyHandler)
|
||||
srv := httptest.NewServer(mux)
|
||||
srv := httptest.NewServer(r.Handler())
|
||||
defer srv.Close()
|
||||
tools := []openai.Tool{{Type: "function", Strict: true, Name: "get_probe_status", Parameters: map[string]any{"type": "object"}}}
|
||||
type res struct {
|
||||
@@ -35,14 +32,18 @@ func TestTurnsOverHTTP(t *testing.T) {
|
||||
ch <- res{resp, err}
|
||||
}()
|
||||
turn := poll(t, srv.URL)
|
||||
if turn.TurnID != "ext_1" || turn.Session != "" || turn.Turn != 1 || turn.System != "SYS" || len(turn.Input) != 1 || turn.Input[0].Content != "alert" || len(turn.Tools) != 1 || turn.ForceSubmit || turn.MaxOutputTokens != 4000 {
|
||||
if !strings.HasPrefix(turn.TurnID, "ext_") || len(turn.TurnID) != 20 || len(turn.ReplyToken) != 32 || turn.Session != "" || turn.Turn != 1 || turn.System != "SYS" || len(turn.Input) != 1 || turn.Input[0].Content != "alert" || len(turn.Tools) != 1 || turn.ForceSubmit || turn.MaxOutputTokens != 4000 {
|
||||
t.Fatalf("turn 1: %+v", turn)
|
||||
}
|
||||
// second poll while nothing is queued -> 204 within the wait
|
||||
if code := get(t, srv.URL+"/external/next?wait=0"); code != 204 {
|
||||
t.Fatalf("empty queue must answer 204, got %d", code)
|
||||
}
|
||||
code, body := post(t, srv.URL+"/external/reply", `{"turn_id":"ext_1","output":[{"type":"message","text":"checking"},{"type":"function_call","name":"get_probe_status","arguments":"{}"}]}`)
|
||||
// a reply without the turn's token is refused (the turn stays open)
|
||||
if code, _ := post(t, srv.URL+"/external/reply", `{"turn_id":"`+turn.TurnID+`","reply_token":"wrong","output":[{"type":"message","text":"x"}]}`); code != 403 {
|
||||
t.Fatalf("wrong token must be 403, got %d", code)
|
||||
}
|
||||
code, body := post(t, srv.URL+"/external/reply", `{"turn_id":"`+turn.TurnID+`","reply_token":"`+turn.ReplyToken+`","output":[{"type":"message","text":"checking"},{"type":"function_call","name":"get_probe_status","arguments":"{}"}]}`)
|
||||
if code != 200 {
|
||||
t.Fatalf("reply 1: %d %s", code, body)
|
||||
}
|
||||
@@ -51,7 +52,7 @@ func TestTurnsOverHTTP(t *testing.T) {
|
||||
t.Fatal(r1.err)
|
||||
}
|
||||
fcs := r1.resp.FunctionCalls()
|
||||
if r1.resp.ID != "ext_1" || len(fcs) != 1 || fcs[0].Name != "get_probe_status" || fcs[0].Arguments != "{}" || fcs[0].CallID == "" || r1.resp.Usage.TotalTokens != 0 {
|
||||
if r1.resp.ID != turn.TurnID || len(fcs) != 1 || fcs[0].Name != "get_probe_status" || fcs[0].Arguments != "{}" || fcs[0].CallID == "" || r1.resp.Usage.TotalTokens != 0 {
|
||||
t.Fatalf("response 1: %+v", r1.resp)
|
||||
}
|
||||
if !strings.Contains(r1.resp.Text(), "checking") {
|
||||
@@ -59,20 +60,20 @@ func TestTurnsOverHTTP(t *testing.T) {
|
||||
}
|
||||
|
||||
go func() {
|
||||
resp, err := r.CreateResponse(context.Background(), openai.Request{Model: "external-claude-mythos-5-1", Tools: tools, PreviousResponseID: "ext_1",
|
||||
resp, err := r.CreateResponse(context.Background(), openai.Request{Model: "external-claude-mythos-5-1", Tools: tools, PreviousResponseID: turn.TurnID,
|
||||
ToolChoice: map[string]any{"type": "function", "name": "submit_diagnosis"},
|
||||
Input: []openai.Item{{Type: "function_call_output", CallID: fcs[0].CallID, Output: `{"alb":1}`}}})
|
||||
ch <- res{resp, err}
|
||||
}()
|
||||
turn2 := poll(t, srv.URL)
|
||||
if turn2.TurnID != "ext_2" || turn2.Session != "ext_1" || turn2.Turn != 2 || turn2.System != "SYS" || !turn2.ForceSubmit {
|
||||
if turn2.TurnID == turn.TurnID || turn2.Session != turn.TurnID || turn2.Turn != 2 || turn2.System != "SYS" || !turn2.ForceSubmit {
|
||||
t.Fatalf("turn 2 header: %+v", turn2)
|
||||
}
|
||||
// history: user alert / assistant text / assistant function_call / function_call_output
|
||||
if len(turn2.Input) != 4 || turn2.Input[2].Type != "function_call" || turn2.Input[2].CallID != fcs[0].CallID || turn2.Input[3].Type != "function_call_output" || turn2.Input[3].Output != `{"alb":1}` {
|
||||
t.Fatalf("turn 2 history: %s", mustJSON(turn2.Input))
|
||||
}
|
||||
code, body = post(t, srv.URL+"/external/reply", `{"turn_id":"ext_2","output":[{"type":"function_call","name":"submit_diagnosis","arguments":{"root_cause_label":"x","confidence":0.9}}]}`)
|
||||
code, body = post(t, srv.URL+"/external/reply", `{"turn_id":"`+turn2.TurnID+`","reply_token":"`+turn2.ReplyToken+`","output":[{"type":"function_call","name":"submit_diagnosis","arguments":{"root_cause_label":"x","confidence":0.9}}]}`)
|
||||
if code != 200 {
|
||||
t.Fatalf("reply 2: %d %s", code, body)
|
||||
}
|
||||
@@ -84,7 +85,7 @@ func TestTurnsOverHTTP(t *testing.T) {
|
||||
t.Fatalf("response 2: %+v", r2.resp.Output)
|
||||
}
|
||||
// an answered turn cannot be answered twice; malformed replies are refused before touching the queue
|
||||
if code, _ := post(t, srv.URL+"/external/reply", `{"turn_id":"ext_2","output":[{"type":"message","text":"again"}]}`); code != 404 {
|
||||
if code, _ := post(t, srv.URL+"/external/reply", `{"turn_id":"`+turn2.TurnID+`","reply_token":"`+turn2.ReplyToken+`","output":[{"type":"message","text":"again"}]}`); code != 404 {
|
||||
t.Fatalf("second reply must be 404, got %d", code)
|
||||
}
|
||||
if code, _ := post(t, srv.URL+"/external/reply", `{"turn_id":"ext_9","output":[{"type":"function_call","name":"x","arguments":"[1]"}]}`); code != 400 {
|
||||
@@ -118,10 +119,7 @@ func TestDeadlinesAndErrors(t *testing.T) {
|
||||
}
|
||||
// error reply
|
||||
r2 := New(5 * time.Second)
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /external/next", r2.Next)
|
||||
mux.HandleFunc("POST /external/reply", r2.ReplyHandler)
|
||||
srv := httptest.NewServer(mux)
|
||||
srv := httptest.NewServer(r2.Handler())
|
||||
defer srv.Close()
|
||||
ch := make(chan error, 1)
|
||||
go func() {
|
||||
@@ -129,7 +127,7 @@ func TestDeadlinesAndErrors(t *testing.T) {
|
||||
ch <- err
|
||||
}()
|
||||
turn := poll(t, srv.URL)
|
||||
post(t, srv.URL+"/external/reply", `{"turn_id":"`+turn.TurnID+`","error":"gave up"}`)
|
||||
post(t, srv.URL+"/external/reply", `{"turn_id":"`+turn.TurnID+`","reply_token":"`+turn.ReplyToken+`","error":"gave up"}`)
|
||||
if err := <-ch; err == nil || !strings.Contains(err.Error(), "gave up") {
|
||||
t.Fatalf("expected client error, got %v", err)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user