Files
selfheal-lab-apps/ai/internal/external/responder.go
T
seon b0ff118841 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" 를 보인다.
2026-10-16 09:09:22 +09:00

334 lines
11 KiB
Go

// Package external lets a client outside the AI VM act as the LLM for model ids starting with "external-": the
// diagnosis loop (internal/agent) is unchanged, but every turn is queued as a Turn and served to the client over
// GET /external/next (long poll); the client answers with POST /external/reply. Used for reference measurements with
// a model that has no API path (2026-10-16: Claude Mythos 5.1 driven by a Claude Code subagent). Token usage is not
// known for such turns and is reported as zero; reports mark those runs as reference records.
//
// A reply becomes a diagnosis and, with auto-approval, an executed action, so whoever can answer a turn controls the
// lab: the endpoints are served on their own listener (EXTERNAL_ADDR, off by default; the VM maps it to 127.0.0.1 so
// only the IAP SSH tunnel reaches it), turn ids are random, and every reply must carry the per-turn reply_token that
// only the client who took the turn received.
package external
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/selfheal-lab/selfheal-lab-apps/ai/internal/openai"
)
const maxConvCached = 64
// Turn is one request to the external client: the whole conversation so far (the loop sends the system prompt only
// on the first turn, so it is carried along), the tool definitions and whether the final submit_diagnosis is forced.
type Turn struct {
TurnID string `json:"turn_id"`
ReplyToken string `json:"reply_token"` // random, handed out with the turn, required by /external/reply
Session string `json:"session"` // first response id of the conversation ("" on the first turn)
Model string `json:"model"`
Turn int `json:"turn"` // 1-based index within the conversation
System string `json:"system"`
Input []openai.Item `json:"input"` // user messages, assistant function_call items, function_call_output items
Tools []openai.Tool `json:"tools"`
ForceSubmit bool `json:"force_submit"` // the loop asked for submit_diagnosis now (last turn)
MaxOutputTokens int `json:"max_output_tokens"`
IssuedAt time.Time `json:"issued_at"`
DeadlineAt time.Time `json:"deadline_at"`
}
// Reply is the client's answer: function calls (name + arguments) and/or assistant text.
type Reply struct {
TurnID string `json:"turn_id"`
ReplyToken string `json:"reply_token"`
Output []replyItem `json:"output"`
Error string `json:"error,omitempty"` // client gave up on this turn
}
type replyItem struct {
Type string `json:"type"` // function_call | message
Name string `json:"name,omitempty"`
Arguments json.RawMessage `json:"arguments,omitempty"` // JSON object or a JSON string holding one
Text string `json:"text,omitempty"`
}
type pending struct {
turn Turn
reply chan Reply
}
type conversation struct {
system string
items []openai.Item
turn int
first string
}
// Responder implements agent.Responder; one instance serves every external-* diagnosis.
type Responder struct {
TurnTimeout time.Duration // how long one turn waits for the client (bounded by the caller's context as well)
mu sync.Mutex
queue []*pending // issued, not yet taken by a client
taken map[string]*pending // taken, waiting for the reply
conv map[string]conversation
order []string
seq int
notify chan struct{}
lastPoll time.Time
lastTurn time.Time
}
func New(turnTimeout time.Duration) *Responder {
if turnTimeout <= 0 {
turnTimeout = 20 * time.Minute
}
return &Responder{TurnTimeout: turnTimeout, taken: map[string]*pending{}, conv: map[string]conversation{}, notify: make(chan struct{}, 1)}
}
// CreateResponse queues the turn and blocks until the client replies, the context ends or TurnTimeout passes.
func (r *Responder) CreateResponse(ctx context.Context, req openai.Request) (*openai.Response, error) {
var hist conversation
if req.PreviousResponseID != "" {
r.mu.Lock()
prev, ok := r.conv[req.PreviousResponseID]
r.mu.Unlock()
if !ok {
return nil, fmt.Errorf("external: unknown previous_response_id %q", req.PreviousResponseID)
}
hist = conversation{system: prev.system, items: append([]openai.Item{}, prev.items...), turn: prev.turn, first: prev.first}
}
for _, it := range req.Input {
if it.Type == "message" && it.Role == "system" {
if s, _ := it.Content.(string); s != "" {
if hist.system != "" {
hist.system += "\n\n"
}
hist.system += s
}
continue
}
hist.items = append(hist.items, it)
}
if len(hist.items) == 0 {
return nil, fmt.Errorf("external: no input items")
}
forced := false
if m, ok := req.ToolChoice.(map[string]any); ok && m["type"] == "function" {
forced = true
}
id, tok := "ext_"+randHex(8), randHex(16)
r.mu.Lock()
r.seq++
now := time.Now()
p := &pending{turn: Turn{TurnID: id, ReplyToken: tok, Session: hist.first, Model: req.Model, Turn: hist.turn + 1, System: hist.system, Input: hist.items, Tools: req.Tools,
ForceSubmit: forced, MaxOutputTokens: req.MaxOutputTokens, IssuedAt: now, DeadlineAt: now.Add(r.TurnTimeout)}, reply: make(chan Reply, 1)}
r.queue = append(r.queue, p)
r.lastTurn = now
r.mu.Unlock()
select {
case r.notify <- struct{}{}:
default:
}
var rep Reply
select {
case rep = <-p.reply:
case <-ctx.Done():
r.drop(id)
return nil, fmt.Errorf("external: turn %s not answered before the diagnosis deadline (%v)", id, ctx.Err())
case <-time.After(r.TurnTimeout):
r.drop(id)
return nil, fmt.Errorf("external: turn %s not answered within %s", id, r.TurnTimeout)
}
if rep.Error != "" {
return nil, fmt.Errorf("external: client error on turn %s: %s", id, rep.Error)
}
out := &openai.Response{ID: id, Status: "completed", Model: req.Model}
var text strings.Builder
for i, it := range rep.Output {
switch it.Type {
case "function_call":
out.Output = append(out.Output, openai.Item{Type: "function_call", CallID: fmt.Sprintf("%s_call_%d", id, i+1), ID: fmt.Sprintf("%s_call_%d", id, i+1), Name: it.Name, Arguments: argString(it.Arguments), Status: "completed"})
case "message":
text.WriteString(it.Text)
}
}
if text.Len() > 0 {
out.Output = append([]openai.Item{{Type: "message", Role: "assistant", Content: []any{map[string]any{"type": "output_text", "text": text.String()}}}}, out.Output...)
}
// remember the conversation (assistant output items are replayed to the client on the next turn)
first := hist.first
if first == "" {
first = id
}
items := append(append([]openai.Item{}, hist.items...), out.Output...)
r.mu.Lock()
r.conv[id] = conversation{system: hist.system, items: items, turn: hist.turn + 1, first: first}
r.order = append(r.order, id)
for len(r.order) > maxConvCached {
delete(r.conv, r.order[0])
r.order = r.order[1:]
}
r.mu.Unlock()
return out, nil
}
// argString returns the function arguments as the JSON object text (a JSON string value is unwrapped).
func argString(raw json.RawMessage) string {
s := strings.TrimSpace(string(raw))
if s == "" {
return "{}"
}
if strings.HasPrefix(s, `"`) {
var inner string
if json.Unmarshal(raw, &inner) == nil {
if strings.TrimSpace(inner) == "" {
return "{}"
}
return inner
}
}
return s
}
func (r *Responder) drop(id string) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.taken, id)
for i, p := range r.queue {
if p.turn.TurnID == id {
r.queue = append(r.queue[:i], r.queue[i+1:]...)
return
}
}
}
// Next is GET /external/next?wait=<s>: the oldest queued turn (200), or 204 when none arrived within wait seconds.
func (r *Responder) Next(w http.ResponseWriter, req *http.Request) {
wait := 20 * time.Second
if v, err := strconv.Atoi(req.URL.Query().Get("wait")); err == nil && v >= 0 && v <= 60 {
wait = time.Duration(v) * time.Second
}
deadline := time.NewTimer(wait)
defer deadline.Stop()
for {
r.mu.Lock()
r.lastPoll = time.Now()
if len(r.queue) > 0 {
p := r.queue[0]
r.queue = r.queue[1:]
r.taken[p.turn.TurnID] = p
r.mu.Unlock()
writeJSON(w, 200, p.turn)
return
}
r.mu.Unlock()
select {
case <-r.notify:
case <-deadline.C:
w.WriteHeader(204)
return
case <-req.Context().Done():
return
}
}
}
// ReplyHandler is POST /external/reply with a Reply body; 404 when the turn is unknown (already answered, dropped
// after its deadline, or never taken through /external/next).
func (r *Responder) ReplyHandler(w http.ResponseWriter, req *http.Request) {
var rep Reply
if err := json.NewDecoder(http.MaxBytesReader(w, req.Body, 1<<20)).Decode(&rep); err != nil {
writeJSON(w, 400, map[string]string{"error": err.Error()})
return
}
if rep.Error == "" {
n := 0
for _, it := range rep.Output {
switch it.Type {
case "function_call":
if it.Name == "" {
writeJSON(w, 400, map[string]string{"error": "function_call without name"})
return
}
if a := argString(it.Arguments); !json.Valid([]byte(a)) || !strings.HasPrefix(strings.TrimSpace(a), "{") {
writeJSON(w, 400, map[string]string{"error": "function_call arguments must be a JSON object"})
return
}
n++
case "message":
n++
default:
writeJSON(w, 400, map[string]string{"error": "output item type must be function_call or message"})
return
}
}
if n == 0 {
writeJSON(w, 400, map[string]string{"error": "output must hold at least one function_call or message"})
return
}
}
r.mu.Lock()
p, ok := r.taken[rep.TurnID]
if ok && subtle.ConstantTimeCompare([]byte(p.turn.ReplyToken), []byte(rep.ReplyToken)) != 1 {
r.mu.Unlock()
writeJSON(w, 403, map[string]string{"error": "reply_token does not match turn " + rep.TurnID})
return
}
if ok {
delete(r.taken, rep.TurnID)
}
r.mu.Unlock()
if !ok {
writeJSON(w, 404, map[string]string{"error": "unknown or expired turn_id " + rep.TurnID})
return
}
p.reply <- rep
writeJSON(w, 200, map[string]any{"ok": true, "turn_id": rep.TurnID})
}
// Status is shown by /selfcheck.
func (r *Responder) Status() map[string]any {
r.mu.Lock()
defer r.mu.Unlock()
out := map[string]any{"queued": len(r.queue), "awaiting_reply": len(r.taken), "turn_timeout_s": int(r.TurnTimeout.Seconds())}
if !r.lastPoll.IsZero() {
out["last_poll_age_s"] = int(time.Since(r.lastPoll).Seconds())
}
if !r.lastTurn.IsZero() {
out["last_turn_age_s"] = int(time.Since(r.lastTurn).Seconds())
}
return out
}
func randHex(n int) string {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
panic("crypto/rand: " + err.Error())
}
return hex.EncodeToString(b)
}
// Handler serves the two endpoints; mount it on the dedicated external listener only.
func (r *Responder) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /external/next", r.Next)
mux.HandleFunc("POST /external/reply", r.ReplyHandler)
return mux
}
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}