gateway: a Codex subagent's sealed task goes to the routing group's ChatGPT account that answered its lead, not turned away (#619)

Koohoko: on a routing group in Ultra or normal mode, every subagent Codex spawned sometimes failed with "An OpenAI lead sent a sealed subagent task that a Magpie-served model cannot read". A group with a Codex (ChatGPT) account in it has that member's turns passed through as they came, encrypted marks on spawn_agent's message and all, so when it answered the lead the ChatGPT backend sealed the task (gAAAAA…), and the subagent's request on the same group was refused before routing although the group had an account able to read it: sometimes, as it depended on which member answered the lead. The check moves from codexBackend into serve: a request carrying a sealed agent task goes only to ChatGPT accounts (sealedReader, p.Account.Agent == codex) of the group or model, and the account that answered the lead (its affinity under the parent thread, x-codex-parent-thread-id or the turn metadata's parent_thread_id) goes first while the subagent's own conversation isn't kept anywhere yet, as another account may not open what it sealed, as it doesn't another's reasoning. With none in the group or model it is turned away as before, before any rule or classifier, now saying a group with a Codex account in it would do. TestCodexSealedAgentHandoffGoesToGroupsChatGPTAccount (group xai/m then codex/gpt-5.5, lead pinned to the spare account) fails without the change (400) and without the lead-first part (acct-1 asked). Whether another ChatGPT account than the sealing one can read the task isn't known; not tried with a real account. go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui -count=1 ./... under a temp HOME pass.
This commit is contained in:
yetone committed 2026-10-03 09:51:56 +08:00
1 parent cb82822f3d
commit d50651a1b6
3 files changed
+139 -4

No files matched your search

+65 -4
View File
@@ -88,10 +88,8 @@ func (s *Server) codexBackend(w http.ResponseWriter, r *http.Request) {
writeError(w, provider.Responses, 400, "/responses/compact is not supported for Magpie models; use a compaction_trigger on /responses")
return
}
if hasSealedAgentMessage(body) {
writeError(w, provider.Responses, 400, "An OpenAI lead sent a sealed subagent task that a Magpie-served model cannot read. Use a Magpie-served model for the lead, or choose an OpenAI subagent.")
return
}
// a sealed subagent task goes to a ChatGPT account the model
// or group has, or is turned away (serve, sealedReader)
body, compact := codexInput(body, true)
if compact {
s.codexCompact(w, r, body)
@@ -118,6 +116,69 @@ func (s *Server) codexBackend(w http.ResponseWriter, r *http.Request) {
s.codexUpstream(w, r, rest, body)
}
// sealedTaskError is what a subagent is told whose task its lead sealed
// when nothing model names can read it.
func sealedTaskError(model string) string {
return fmt.Sprintf("An OpenAI lead sent a sealed subagent task that only a ChatGPT account can read, and %s has none. Use a Magpie-served model for the lead, or choose an OpenAI subagent (or a group with a Codex account in it).", model)
}
// sealedReader is who can read a subagent's task its lead sealed: a
// ChatGPT account (#619). The ChatGPT backend seals spawn_agent's message
// for a lead it answers as it came (passthrough, a Codex account a group
// has as well), and only it opens it again.
func sealedReader(p provider.Provider) bool {
return p.Account != nil && p.Account.Agent == "codex"
}
// sealedReaders keeps of cands those that can read a sealed subagent task,
// pl's order with them.
func sealedReaders(cands []candidate, pl planned) ([]candidate, planned) {
var kept []candidate
var order []Weighed
for i, c := range cands {
if sealedReader(c.p) {
kept = append(kept, c)
order = append(order, pl.order[i])
}
}
cands, pl.order = kept, order
return cands, pl
}
// leadFirst puts first the account that answered the lead, the thread
// parent names, in scope: the one that sealed its subagent's task, which
// another account may not open, as it doesn't another's reasoning.
func leadFirst(scope, parent string, cands []candidate, pl planned) ([]candidate, planned) {
parent = strings.TrimSpace(parent)
if parent == "" {
return cands, pl
}
sticks.Lock()
st, had := stickOf(scope + "|" + parent)
if !had {
// a group with rules keeps the lead's conversation under its
// first words too
for k, v := range sticks.m {
if strings.HasPrefix(k, scope+"|") && strings.HasSuffix(k, "|"+parent) {
st, had = v, true
break
}
}
}
sticks.Unlock()
if !had || time.Since(st.at) > stickKeep {
return cands, pl
}
for i, c := range cands {
if i > 0 && c.who() == st.who {
cands = append(append([]candidate{c}, cands[:i]...), cands[i+1:]...)
pl.order = append(append([]Weighed{pl.order[i]}, pl.order[:i]...), pl.order[i+1:]...)
break
}
}
return cands, pl
}
// Only native sealed agent tasks need this guidance. Other encrypted_content
// fields (including ordinary model history) keep their existing handling.
func hasSealedAgentMessage(body []byte) bool {
+23
View File
@@ -1050,6 +1050,17 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro
// before any image is taken out of the request: one may be for images
g, ms, isGroup := provider.FindGroup(asked)
g = g.Live() // a manual group's rules wait
// a Codex subagent's task its lead sealed — the lead answered by a
// ChatGPT account, the group's own or Codex's — goes only to a ChatGPT
// account, the lead's first (#619); with none, it is turned away
// before anyone is asked
sealedTask := from == provider.Responses && hasSealedAgentMessage(body)
if sealedTask && !(isGroup && slices.ContainsFunc(ms, func(m provider.Member) bool { return sealedReader(m.Provider) }) || !isGroup && sealedReader(p)) {
call.Status, call.Error = 400, "sealed subagent task"
writeError(w, from, 400, sealedTaskError(call.Model))
turnedAway()
return
}
var hit *RuleHit
var ruled []provider.Member
var ruleAt, words string
@@ -1133,11 +1144,13 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro
// own, and an agent's side requests (a title, a summary) to a smaller
// model leave the conversation where it is
scope, mode, rotate := p.ID+"/"+model, p.Affinity, p.Routing == provider.Rotate
leadScope := scope // where the lead of a subagent is kept
if isGroup {
// a routing group: every member's keys or accounts weighed together
cands, pl = s.planGroup(g, ms, from)
group = groupRef(g, ms)
scope, mode, rotate = provider.GroupPrefix+g.ID, g.Affinity, g.Routing == provider.Rotate
leadScope = scope
if words != "" {
// a subagent a rule sends elsewhere mustn't take its agent's
// conversation with it: each keeps where it is on its own
@@ -1146,6 +1159,9 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro
} else {
cands, pl = s.plan(p, model, from)
}
if sealedTask {
cands, pl = sealedReaders(cands, pl)
}
if len(cands) == 0 && len(pl.left) > 0 && !slices.ContainsFunc(pl.left, func(w Weighed) bool { return !w.Barred }) {
// every account or key there is was set not to serve the model
call.Status, call.Error = 403, "every account barred"
@@ -1166,6 +1182,13 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro
// (#63): its reasoning, sealed by the account that wrote it, is refused
// by another.
cands, pl, aff, stuck := affine(scope, mode, rotate, r.Header, from, body, cands, pl)
if sealedTask && !aff.Kept {
parent := metadata.Parent
if parent == "" {
parent = r.Header.Get("x-codex-parent-thread-id")
}
cands, pl = leadFirst(leadScope, parent, cands, pl)
}
if hit != nil && hit.Use != "" && !(hit.Held && aff.Kept) {
// the rule's member first, over whoever answered last, as a turn
// begins; within it, whoever answered last stays — the rule's
@@ -82,3 +82,54 @@ func TestCodexSealedAgentHandoffNativePassthrough(t *testing.T) {
t.Fatalf("native request: status=%d forwarded=%v response=%s", code, len(got) > 0, response)
}
}
// A Codex subagent on a routing group whose lead a ChatGPT account of the
// group answered (Koohoko, #619): the task the lead's spawn_agent sealed
// goes to the group's ChatGPT account that answered the lead, not to the
// member routing puts first, which can't read it, nor is it turned away.
func TestCodexSealedAgentHandoffGoesToGroupsChatGPTAccount(t *testing.T) {
var tried []string
var got []byte
chatgpt(t, func(w http.ResponseWriter, r *http.Request) {
got, _ = io.ReadAll(r.Body)
tried = append(tried, r.Header.Get("chatgpt-account-id"))
io.WriteString(w, sse(`data: {"type":"response.output_text.delta","delta":"on it"}`,
`data: {"type":"response.completed","response":{"id":"r1","status":"completed","output":[]}}`))
})
codexSignedIn(t, "spare@example.com")
sticks.Lock()
sticks.m = map[string]stick{}
sticks.Unlock()
grok := &scripted{replies: []reply{{200, "text/event-stream", grokAnswer}}}
scriptedOn(t, "xai", provider.Chat, grok)
refusalGroup(t, "xai/m", "codex/gpt-5.5")
s := New()
ask := func(session, parent, pin, input string) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
req := httptest.NewRequest("POST", CodexPath+"/responses", strings.NewReader(`{"model":"group/g","stream":true,"input":[`+input+`]}`))
req.Header.Set("Authorization", "Bearer chatgpt-token")
req.Header.Set("chatgpt-account-id", "acct-1")
req.Header.Set("session_id", session)
if parent != "" {
req.Header.Set("x-codex-parent-thread-id", parent)
}
if pin != "" {
req.Header.Set(AccountHeader, pin)
}
s.Handler().ServeHTTP(rec, req)
return rec
}
// the lead, answered by the spare account
lead := `{"type":"message","role":"user","content":[{"type":"input_text","text":"spawn a worker"}]}`
if rec := ask("lead-1", "", "spare@example.com", lead); rec.Code != 200 || strings.Join(tried, ",") != "acct-2" {
t.Fatalf("lead: %d %s (tried %v)", rec.Code, rec.Body.String(), tried)
}
tried = nil
rec := ask("worker-1", "lead-1", "", sealedHandoff)
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "on it") {
t.Fatalf("subagent: %d %s", rec.Code, rec.Body.String())
}
if grok.n != 0 || strings.Join(tried, ",") != "acct-2" || !strings.Contains(string(got), "gAAAAATest_ciphertext==") {
t.Fatalf("sealed task went to grok %d, accounts %v, forwarded sealed=%v", grok.n, tried, strings.Contains(string(got), "gAAAAATest_ciphertext=="))
}
}