fix: 透传 Claude 订阅审批上下文与服务端结果 (#794)
Co-authored-by: yizhuolin <yizhuolin@i-calorie.com>
This commit is contained in:
1 parent
7df9345903
commit
e8cd4bac8c
4 files changed
+469
-36
No files matched your search
@@ -79,8 +79,9 @@ type aRequest struct {
|
||||
Schema json.RawMessage `json:"schema"`
|
||||
} `json:"format,omitempty"`
|
||||
} `json:"output_config,omitempty"`
|
||||
Metadata json.RawMessage `json:"metadata,omitempty"`
|
||||
Speed string `json:"speed,omitempty"` // "fast": Claude's fast mode
|
||||
Metadata json.RawMessage `json:"metadata,omitempty"`
|
||||
Speed string `json:"speed,omitempty"` // "fast": Claude's fast mode
|
||||
Safeguards json.RawMessage `json:"safeguards,omitempty"`
|
||||
}
|
||||
|
||||
func parseAnthropic(body []byte) (*Request, error) {
|
||||
@@ -90,6 +91,10 @@ func parseAnthropic(body []byte) (*Request, error) {
|
||||
}
|
||||
r := &Request{Model: a.Model, System: stringOrText(a.System), MaxTokens: a.MaxTokens,
|
||||
Temp: a.Temperature, TopP: a.TopP, Stop: a.StopSequences, Stream: a.Stream, Fast: a.Speed == "fast"}
|
||||
r.Safeguards = a.Safeguards
|
||||
if string(r.Safeguards) == "null" {
|
||||
r.Safeguards = nil
|
||||
}
|
||||
if len(a.Metadata) > 0 && string(a.Metadata) != "null" {
|
||||
r.Metadata = a.Metadata
|
||||
}
|
||||
@@ -775,8 +780,12 @@ func (e *anthropicEncoder) finish() {
|
||||
}
|
||||
e.close()
|
||||
res := e.col.finish()
|
||||
delta := map[string]any{"stop_reason": stopToAnthropic(res.Stop), "stop_sequence": nil}
|
||||
if len(res.SafeguardResults) > 0 {
|
||||
delta["safeguard_results"] = res.SafeguardResults
|
||||
}
|
||||
e.w.event("message_delta", map[string]any{"type": "message_delta",
|
||||
"delta": map[string]any{"stop_reason": stopToAnthropic(res.Stop), "stop_sequence": nil},
|
||||
"delta": delta,
|
||||
"usage": res.Usage.anthropic()})
|
||||
e.w.event("message_stop", map[string]any{"type": "message_stop"})
|
||||
}
|
||||
@@ -807,8 +816,12 @@ func renderAnthropic(res Result, model string) []byte {
|
||||
if res.Model != "" {
|
||||
model = res.Model
|
||||
}
|
||||
b, _ := json.Marshal(map[string]any{"id": id, "type": "message", "role": "assistant", "model": model,
|
||||
"content": content, "stop_reason": stopToAnthropic(res.Stop), "stop_sequence": nil, "usage": res.Usage.anthropic()})
|
||||
reply := map[string]any{"id": id, "type": "message", "role": "assistant", "model": model,
|
||||
"content": content, "stop_reason": stopToAnthropic(res.Stop), "stop_sequence": nil, "usage": res.Usage.anthropic()}
|
||||
if len(res.SafeguardResults) > 0 {
|
||||
reply["safeguard_results"] = res.SafeguardResults
|
||||
}
|
||||
b, _ := json.Marshal(reply)
|
||||
return b
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/yetone/magpie/internal/catalog"
|
||||
"github.com/yetone/magpie/internal/provider"
|
||||
"github.com/yetone/magpie/internal/wslrun"
|
||||
)
|
||||
|
||||
// autoModeAsk is Claude Code's auto mode classifier's first stage as it
|
||||
@@ -253,3 +260,269 @@ done
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The subscription bridge keeps the API's verdict opaque, including a denial,
|
||||
// skipped/unavailable calls and future fields. Missing results stay missing.
|
||||
func TestClaudeBridgeSafeguardResults(t *testing.T) {
|
||||
for _, verdict := range []string{
|
||||
`[{"type":"dangerous_tool_use","status":{"type":"available","tool_uses":{"toolu_client":{"type":"evaluated","outcome":"flagged","explanation":"dangerous"}}},"future":{"x":1}}]`,
|
||||
`[{"type":"dangerous_tool_use","status":{"type":"available","tool_uses":{"toolu_client":{"type":"evaluated","outcome":"not_flagged"}}}}]`,
|
||||
`[{"type":"dangerous_tool_use","status":{"type":"available","tool_uses":{"toolu_client":{"type":"skipped"}}}}]`,
|
||||
`[{"type":"dangerous_tool_use","status":{"type":"unavailable","reason":"error"}}]`,
|
||||
`[{"type":"future_check","status":{"type":"unknown"}}]`,
|
||||
`null`,
|
||||
} {
|
||||
for _, whole := range []bool{false, true} {
|
||||
name := "stream "
|
||||
if whole {
|
||||
name = "whole "
|
||||
}
|
||||
t.Run(name+verdict, func(t *testing.T) {
|
||||
ch := make(chan Event, 16)
|
||||
run := &subscriptionRun{segment: ch}
|
||||
delta := map[string]any{"stop_reason": "tool_use"}
|
||||
if verdict != "null" {
|
||||
delta["safeguard_results"] = json.RawMessage(verdict)
|
||||
}
|
||||
d, _ := json.Marshal(map[string]any{"type": "stream_event", "event": map[string]any{"type": "message_delta", "delta": delta}})
|
||||
lines := []string{
|
||||
`{"type":"stream_event","event":{"type":"message_start","message":{"id":"msg_client","model":"claude-sonnet-5"}}}`,
|
||||
`{"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_client","name":"mcp__magpie__Bash"}}}`,
|
||||
string(d),
|
||||
`{"type":"stream_event","event":{"type":"message_stop"}}`,
|
||||
}
|
||||
if whole {
|
||||
message := map[string]any{"id": "msg_whole", "model": "claude-sonnet-5", "stop_reason": "tool_use",
|
||||
"content": []any{map[string]any{"type": "tool_use", "id": "toolu_client", "name": "mcp__magpie__Bash", "input": map[string]any{}}}}
|
||||
if verdict != "null" {
|
||||
message["safeguard_results"] = json.RawMessage(verdict)
|
||||
}
|
||||
d, _ := json.Marshal(map[string]any{"type": "assistant", "message": message})
|
||||
lines = append(append([]string{}, cutAttempt...), string(d))
|
||||
}
|
||||
run.readOutput(strings.NewReader(strings.Join(lines, "\n")))
|
||||
rec := httptest.NewRecorder()
|
||||
enc := encoder(provider.Anthropic, newSSEWriter(rec), &Request{Model: "claude-sonnet-5"})
|
||||
var col collector
|
||||
for e := range ch {
|
||||
enc.event(e)
|
||||
col.add(e)
|
||||
}
|
||||
enc.finish()
|
||||
result := col.finish()
|
||||
var response map[string]any
|
||||
json.Unmarshal(renderAnthropic(result, "claude-sonnet-5"), &response)
|
||||
for _, e := range events(rec.Body.String()) {
|
||||
if e["type"] == "message_delta" {
|
||||
delta := e["delta"].(map[string]any)
|
||||
actual, _ := json.Marshal(delta["safeguard_results"])
|
||||
var wantValue any
|
||||
json.Unmarshal([]byte(verdict), &wantValue)
|
||||
want, _ := json.Marshal(wantValue)
|
||||
if string(actual) != string(want) {
|
||||
t.Fatalf("stream verdict changed: %s != %s", actual, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
actual, _ := json.Marshal(response["safeguard_results"])
|
||||
var wantValue any
|
||||
json.Unmarshal([]byte(verdict), &wantValue)
|
||||
want, _ := json.Marshal(wantValue)
|
||||
if string(actual) != string(want) {
|
||||
t.Fatalf("non-stream verdict changed: %s != %s", actual, want)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), `"id":"toolu_client"`) || !strings.Contains(string(renderAnthropic(result, "claude-sonnet-5")), `"id":"toolu_client"`) {
|
||||
t.Fatal("tool ID changed")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Exercise the actual subprocess/control wire: changed and removed contexts
|
||||
// reach an existing run; a rejected update restarts it with the new context.
|
||||
func TestClaudeBridgeSafeguardContextUpdates(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("a Python script stands in for Claude Code")
|
||||
}
|
||||
t.Setenv("TMPDIR", t.TempDir())
|
||||
dir := t.TempDir()
|
||||
script := `#!/usr/bin/env python3
|
||||
import json,os,sys
|
||||
body=json.loads(os.environ.get('CLAUDE_CODE_EXTRA_BODY','{}'))
|
||||
effort=sys.argv[sys.argv.index('--effort')+1] if '--effort' in sys.argv else ''
|
||||
effort_id=''
|
||||
turns=0
|
||||
def send(x): print(json.dumps(x),flush=True)
|
||||
for line in sys.stdin:
|
||||
x=json.loads(line)
|
||||
if x['type']=='control_request':
|
||||
if x['request']['subtype']=='initialize':
|
||||
send({'type':'control_response','response':{'request_id':x['request_id'],'subtype':'success'}})
|
||||
continue
|
||||
settings=x['request']['settings']
|
||||
if 'effortLevel' in settings:
|
||||
effort=settings['effortLevel']
|
||||
effort_id=x['request_id']
|
||||
continue
|
||||
if effort_id:
|
||||
send({'type':'control_response','response':{'request_id':effort_id,'subtype':'success'}})
|
||||
fresh=json.loads(x['request']['settings']['env']['CLAUDE_CODE_EXTRA_BODY'])
|
||||
rejected=turns>0 and 'reject' in json.dumps(fresh)
|
||||
if not rejected:body=fresh
|
||||
send({'type':'control_response','response':{'request_id':'unrelated','subtype':'success'}})
|
||||
send({'type':'control_response','response':{'request_id':x['request_id'],'subtype':'error' if rejected else 'success','error':'probe rejected'}})
|
||||
continue
|
||||
turns+=1
|
||||
send({'type':'stream_event','event':{'type':'message_start','message':{'id':'m','model':'claude-sonnet-5'}}})
|
||||
text=str(os.getpid())+' '+effort+' '+json.dumps(body,sort_keys=True)
|
||||
send({'type':'stream_event','event':{'type':'content_block_delta','index':0,'delta':{'type':'text_delta','text':text}}})
|
||||
send({'type':'stream_event','event':{'type':'message_delta','delta':{'stop_reason':'end_turn'}}})
|
||||
send({'type':'stream_event','event':{'type':'message_stop'}})
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(dir, "claude"), []byte(script), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
t.Setenv("CLAUDE_CODE_EXTRA_BODY", `{"model":"must-not-leak"}`)
|
||||
s := New()
|
||||
t.Cleanup(s.subscription.abortAll)
|
||||
p := provider.Provider{ID: "claude", Account: &provider.Account{Agent: "claude", User: "u"}}
|
||||
messages := []map[string]any{{"role": "assistant", "content": "seed"}, {"role": "user", "content": "hi"}}
|
||||
var pid string
|
||||
for i, context := range []string{"first", "second", "", "third", "reject"} {
|
||||
b := map[string]any{"model": "claude-sonnet-5", "max_tokens": 64, "stream": false, "messages": messages}
|
||||
effort := []string{"high", "low"}[i%2]
|
||||
b["thinking"] = map[string]any{"type": "adaptive"}
|
||||
b["output_config"] = map[string]any{"effort": effort}
|
||||
if context != "" {
|
||||
b["safeguards"] = []any{map[string]any{"type": "dangerous_tool_use", "classifier_context": map[string]any{"marker": context, "future": []int{1, 2}}}}
|
||||
}
|
||||
body, _ := json.Marshal(b)
|
||||
req := httptest.NewRequest("POST", "/v1/messages", strings.NewReader(string(body)))
|
||||
req.Header.Set("anthropic-beta", "claude-code-20250219,dangerous-tool-use-2026-09-03")
|
||||
rec := httptest.NewRecorder()
|
||||
var usage Usage
|
||||
if code, msg := s.serveClaudeSubscription(rec, req, provider.Anthropic, p, "claude-sonnet-5", body, &usage); code != 200 {
|
||||
t.Fatalf("%d %s", code, msg)
|
||||
}
|
||||
var a struct {
|
||||
Content []struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &a); err != nil || len(a.Content) != 1 {
|
||||
t.Fatalf("bad reply %s", rec.Body)
|
||||
}
|
||||
text := a.Content[0].Text
|
||||
parts := strings.SplitN(text, " ", 3)
|
||||
if len(parts) != 3 {
|
||||
t.Fatalf("no pid/context: %s", text)
|
||||
}
|
||||
if parts[1] != effort {
|
||||
t.Fatalf("stale effort: %s, want %s", parts[1], effort)
|
||||
}
|
||||
if pid != "" && (parts[0] == pid) == (context == "reject") {
|
||||
t.Fatalf("wrong reuse for %q: %s => %s", context, pid, parts[0])
|
||||
}
|
||||
pid = parts[0]
|
||||
var extra map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(parts[2]), &extra); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expected := safeguardBody(nil)
|
||||
if v := b["safeguards"]; v != nil {
|
||||
raw, _ := json.Marshal(v)
|
||||
expected = safeguardBody(raw)
|
||||
}
|
||||
got, _ := json.Marshal(extra)
|
||||
if string(got) != expected {
|
||||
t.Fatalf("stale/modified context: %s != %s", got, expected)
|
||||
}
|
||||
messages = append(messages, map[string]any{"role": "assistant", "content": text}, map[string]any{"role": "user", "content": "next"})
|
||||
}
|
||||
}
|
||||
|
||||
// The beta must reach a Claude Code running in WSL as well as a native run.
|
||||
func TestClaudeBridgeSafeguardWSLEnv(t *testing.T) {
|
||||
cli := claudeCLI{wsl: &wslrun.Tool{}}
|
||||
env := cli.env([]string{"ANTHROPIC_BETAS=dangerous-tool-use-2026-09-03"}, "")
|
||||
if !slices.Contains(env, "ANTHROPIC_BETAS=dangerous-tool-use-2026-09-03") ||
|
||||
!slices.Contains(env, "WSLENV=ANTHROPIC_BETAS") {
|
||||
t.Fatalf("safety beta not forwarded to WSL: %v", env)
|
||||
}
|
||||
}
|
||||
|
||||
// A context update and its tool-result handoff own the run together, through
|
||||
// the reply's end. Another caller cannot update it or abort its owner.
|
||||
func TestClaudeBridgeSafeguardConcurrentHandoff(t *testing.T) {
|
||||
s := New()
|
||||
b := s.subscription
|
||||
input, stdin := io.Pipe()
|
||||
stdout, output := io.Pipe()
|
||||
run := &subscriptionRun{bridge: b, token: "tok", stdin: stdin, safeguardBeta: "dangerous-tool-use-2026-09-03",
|
||||
pending: map[string]chan mcpToolResult{"a": make(chan mcpToolResult, 1), "b": make(chan mcpToolResult, 1)}}
|
||||
b.runs[run.token], b.calls["a"], b.calls["b"] = run, run, run
|
||||
t.Cleanup(func() { b.abortAll(); stdin.Close(); input.Close(); output.Close(); stdout.Close() })
|
||||
go run.readOutput(stdout)
|
||||
go func() {
|
||||
scanner := bufio.NewScanner(input)
|
||||
for scanner.Scan() {
|
||||
var ctl struct {
|
||||
RequestID string `json:"request_id"`
|
||||
}
|
||||
if json.Unmarshal(scanner.Bytes(), &ctl) == nil {
|
||||
fmt.Fprintf(output, "{\"type\":\"control_response\",\"response\":{\"subtype\":\"success\",\"request_id\":%q}}\n", ctl.RequestID)
|
||||
}
|
||||
}
|
||||
}()
|
||||
req := &Request{SafeguardBeta: run.safeguardBeta, Safeguards: json.RawMessage(`[{"context":"A"}]`),
|
||||
Messages: []Message{{Role: "assistant"}, {Role: "user", Parts: []Part{{Kind: ToolResult, CallID: "a", Text: "A"}}}}}
|
||||
found, results := b.findRun(req)
|
||||
if found != run || !found.claimResume() {
|
||||
t.Fatal("first claim failed")
|
||||
}
|
||||
if err := run.setSafeguards(req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run.endSegment() // a late end with no attached segment cannot release A
|
||||
// B arrives after A's ACK and before its handoff, then again while A's
|
||||
// reply is active, using another call that still maps to the same run.
|
||||
blocked := func(id string) {
|
||||
t.Helper()
|
||||
body := []byte(`{"model":"claude-sonnet-5","max_tokens":64,"safeguards":[{"context":"B"}],"messages":[{"role":"assistant","content":"call"},{"role":"user","content":[{"type":"tool_result","tool_use_id":"` + id + `","content":"B"}]}]}`)
|
||||
done := make(chan int, 1)
|
||||
go func() {
|
||||
rec := httptest.NewRecorder()
|
||||
code, _ := s.serveSubscription(rec, httptest.NewRequest("POST", "/v1/messages", nil), provider.Anthropic, "Claude Code", req.Model, body, &Usage{},
|
||||
func(context.Context, *Request) (*subscriptionRun, <-chan Event, error) {
|
||||
return nil, nil, fmt.Errorf("must not start another run")
|
||||
})
|
||||
done <- code
|
||||
}()
|
||||
select {
|
||||
case code := <-done:
|
||||
if code != 409 || run.closed || string(run.safeguards) != string(req.Safeguards) {
|
||||
t.Fatalf("second caller: status %d, closed %v, context %s", code, run.closed, run.safeguards)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("second caller did not reject the claimed run")
|
||||
}
|
||||
}
|
||||
blocked("a")
|
||||
waiter := run.pending["a"]
|
||||
segment, err := run.continueWith(results, nil)
|
||||
if err != nil || (<-waiter).Content[0]["text"] != "A" {
|
||||
t.Fatalf("first handoff: %v", err)
|
||||
}
|
||||
blocked("b")
|
||||
fmt.Fprintln(output, `{"type":"stream_event","event":{"type":"message_start","message":{"id":"m"}}}`)
|
||||
fmt.Fprintln(output, `{"type":"stream_event","event":{"type":"message_delta","delta":{"stop_reason":"tool_use"}}}`)
|
||||
fmt.Fprintln(output, `{"type":"stream_event","event":{"type":"message_stop"}}`)
|
||||
for range segment {
|
||||
}
|
||||
req.Messages[1].Parts[0].CallID = "b"
|
||||
if next, _ := b.findRun(req); next != run || !next.claimResume() {
|
||||
t.Fatal("claim not released at segment end")
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ package gateway
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
@@ -125,12 +126,19 @@ type subscriptionRun struct {
|
||||
// the tools its agent was told of, as it started and since
|
||||
tools map[string]bool
|
||||
|
||||
mu sync.Mutex
|
||||
segment chan Event
|
||||
pending map[string]chan mcpToolResult
|
||||
closed bool
|
||||
stderr strings.Builder
|
||||
timer *time.Timer
|
||||
mu sync.Mutex
|
||||
segment chan Event
|
||||
pending map[string]chan mcpToolResult
|
||||
closed bool
|
||||
resuming bool // a tool-result request owns this run until its segment ends
|
||||
stderr strings.Builder
|
||||
|
||||
// Only one settings update is in flight; it must be acknowledged before
|
||||
// a tool result or a new user turn can trigger the next model request.
|
||||
settingsMu sync.Mutex
|
||||
safeguards json.RawMessage
|
||||
safeguardBeta string
|
||||
timer *time.Timer
|
||||
|
||||
// Claude Code's input, kept open for the next turn; owner is the account
|
||||
// it runs as, and idleKey and idleAt say it is waiting for one. convKey
|
||||
@@ -365,7 +373,7 @@ func (c claudeCLI) command(ctx context.Context, args ...string) *exec.Cmd {
|
||||
// claudeEnvVars are the variables magpie sets for a Claude Code run, which
|
||||
// one in WSL is handed (wslrun's Env).
|
||||
var claudeEnvVars = []string{"ENABLE_CLAUDEAI_MCP_SERVERS", "DISABLE_AUTO_COMPACT", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC",
|
||||
"DISABLE_TELEMETRY", "DISABLE_ERROR_REPORTING", "DISABLE_AUTOUPDATER"}
|
||||
"DISABLE_TELEMETRY", "DISABLE_ERROR_REPORTING", "DISABLE_AUTOUPDATER", "ANTHROPIC_BETAS"}
|
||||
|
||||
// env is env for a run in config directory configDir ("" for the account
|
||||
// Claude Code is signed in to): for one in WSL, what of it goes in.
|
||||
@@ -470,7 +478,11 @@ func (b *subscriptionBridge) start(ctx context.Context, req *Request, model, con
|
||||
}
|
||||
cmd := binary.command(context.Background(), args...)
|
||||
cmd.Dir = work
|
||||
cmd.Env = binary.env(netproxy.EnvWith(claudeProxy(ctx), cleanClaudeEnv(os.Environ())), configDir)
|
||||
env := netproxy.EnvWith(claudeProxy(ctx), cleanClaudeEnv(os.Environ()))
|
||||
if len(req.Safeguards) > 0 {
|
||||
env = append(env, "ANTHROPIC_BETAS="+req.SafeguardBeta)
|
||||
}
|
||||
cmd.Env = binary.env(env, configDir)
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
cleanup()
|
||||
@@ -488,6 +500,7 @@ func (b *subscriptionBridge) start(ctx context.Context, req *Request, model, con
|
||||
}
|
||||
|
||||
run := &subscriptionRun{bridge: b, token: token, model: model, cmd: cmd, tmp: tmp, schema: len(req.Schema) > 0, pending: map[string]chan mcpToolResult{}, stdin: stdin, owner: owner, effort: req.Effort, sessions: sessions}
|
||||
run.safeguardBeta = req.SafeguardBeta
|
||||
if user, _ := ownerAccount(owner); user != "" {
|
||||
run.loginVersion = provider.ClaudeLoginVersion(user)
|
||||
}
|
||||
@@ -508,6 +521,18 @@ func (b *subscriptionBridge) start(ctx context.Context, req *Request, model, con
|
||||
}()
|
||||
go run.readOutput(stdout)
|
||||
|
||||
if len(req.Safeguards) > 0 {
|
||||
// Initialize the SDK before applying settings. Send the potentially
|
||||
// large context over stdin rather than an environment entry.
|
||||
if err := run.cliControl(map[string]any{"subtype": "initialize"}); err != nil {
|
||||
run.abort()
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
if err := run.setSafeguards(req); err != nil {
|
||||
run.abort()
|
||||
return nil, nil, err
|
||||
}
|
||||
var prompt []map[string]any
|
||||
if from != nil {
|
||||
// the session is the run's now: its file goes when the run does,
|
||||
@@ -529,6 +554,89 @@ func (b *subscriptionBridge) start(ctx context.Context, req *Request, model, con
|
||||
return run, segment, nil
|
||||
}
|
||||
|
||||
// safeguardBody passes only the safety field through EXTRA_BODY: overriding the
|
||||
// CLI's own betas there would remove headers its genuine request needs.
|
||||
func safeguardBody(raw json.RawMessage) string {
|
||||
body := map[string]any{}
|
||||
if len(raw) > 0 {
|
||||
body["safeguards"] = raw
|
||||
}
|
||||
b, _ := json.Marshal(body)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// setSafeguards updates the caller's context, not the VPS's permission state.
|
||||
// A CLI unable to acknowledge the update is discarded by the caller; its old
|
||||
// context must never be used for another turn. The client's approval still
|
||||
// decides whether the tool runs, including a server verdict that is unavailable.
|
||||
func (r *subscriptionRun) setSafeguards(req *Request) error {
|
||||
r.settingsMu.Lock()
|
||||
defer r.settingsMu.Unlock()
|
||||
if bytes.Equal(r.safeguards, req.Safeguards) && (len(req.Safeguards) == 0 || r.safeguardBeta == req.SafeguardBeta) {
|
||||
return nil
|
||||
}
|
||||
if len(req.Safeguards) > 0 && r.safeguardBeta != req.SafeguardBeta {
|
||||
return errors.New("safety beta changed; restart Claude Code")
|
||||
}
|
||||
err := r.cliControl(map[string]any{"subtype": "apply_flag_settings", "settings": map[string]any{
|
||||
"env": map[string]string{"CLAUDE_CODE_EXTRA_BODY": safeguardBody(req.Safeguards)}}})
|
||||
if err == nil {
|
||||
r.safeguards = bytes.Clone(req.Safeguards)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// cliControl shares the SDK reply dispatcher with rewind, so unrelated or
|
||||
// late control responses cannot acknowledge a safety-context update.
|
||||
func (r *subscriptionRun) cliControl(request map[string]any) error {
|
||||
id := randomToken()
|
||||
ch := make(chan controlReply, 1)
|
||||
r.mu.Lock()
|
||||
if r.closed {
|
||||
r.mu.Unlock()
|
||||
return errors.New("Claude Code ended")
|
||||
}
|
||||
if r.controls == nil {
|
||||
r.controls = map[string]chan controlReply{}
|
||||
}
|
||||
r.controls[id] = ch
|
||||
r.mu.Unlock()
|
||||
defer func() {
|
||||
r.mu.Lock()
|
||||
delete(r.controls, id)
|
||||
r.mu.Unlock()
|
||||
}()
|
||||
line, _ := json.Marshal(map[string]any{"type": "control_request", "request_id": id, "request": request})
|
||||
if _, err := r.stdin.Write(append(line, '\n')); err != nil {
|
||||
return err
|
||||
}
|
||||
t := time.NewTimer(10 * time.Second)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case reply, ok := <-ch:
|
||||
if !ok {
|
||||
return errors.New("Claude Code ended")
|
||||
}
|
||||
if reply.Subtype != "success" {
|
||||
return fmt.Errorf("Claude settings: %s", reply.Error)
|
||||
}
|
||||
return nil
|
||||
case <-t.C:
|
||||
return errors.New("Claude Code did not acknowledge safety context")
|
||||
}
|
||||
}
|
||||
|
||||
// claimResume holds a tool-result handoff across its context update and reply.
|
||||
func (r *subscriptionRun) claimResume() bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.closed || r.resuming || r.segment != nil {
|
||||
return false
|
||||
}
|
||||
r.resuming = true
|
||||
return true
|
||||
}
|
||||
|
||||
// resume hands a conversation's next turn to the run that had its last
|
||||
// one, when one is waiting: the user's messages since, and nothing else.
|
||||
func (b *subscriptionBridge) resume(req *Request, owner string) (*subscriptionRun, <-chan Event) {
|
||||
@@ -554,6 +662,10 @@ func (b *subscriptionBridge) resume(req *Request, owner string) (*subscriptionRu
|
||||
if rewinding != nil {
|
||||
<-rewinding
|
||||
}
|
||||
if err := run.setSafeguards(req); err != nil {
|
||||
run.abort()
|
||||
return nil, nil
|
||||
}
|
||||
// the message's id is the turn's, to rewind to if the client gives up
|
||||
// on it
|
||||
turn := newUUID()
|
||||
@@ -1226,6 +1338,7 @@ func cleanClaudeEnv(env []string) []string {
|
||||
"ANTHROPIC_BASE_URL": true, "ANTHROPIC_API_KEY": true, "ANTHROPIC_AUTH_TOKEN": true,
|
||||
"CLAUDECODE": true, "CLAUDE_CODE_ENTRYPOINT": true, "CLAUDE_CODE_SSE_PORT": true,
|
||||
"CLAUDE_CODE_OAUTH_TOKEN": true, "CLAUDE_CODE_PROMPT_CACHE_TTL": true,
|
||||
"CLAUDE_CODE_EXTRA_BODY": true, "ANTHROPIC_BETAS": true,
|
||||
}
|
||||
out := make([]string, 0, len(env)+3)
|
||||
for _, e := range env {
|
||||
@@ -1292,6 +1405,9 @@ func (r *subscriptionRun) endSegment() {
|
||||
r.mu.Lock()
|
||||
ch := r.segment
|
||||
r.segment = nil
|
||||
if ch != nil {
|
||||
r.resuming = false
|
||||
}
|
||||
r.mu.Unlock()
|
||||
if ch != nil {
|
||||
close(ch)
|
||||
@@ -1460,12 +1576,13 @@ func (r *subscriptionRun) readOutput(rd io.Reader) {
|
||||
Text string `json:"text"`
|
||||
} `json:"content_block"`
|
||||
Delta struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
Thinking string `json:"thinking"`
|
||||
Signature string `json:"signature"`
|
||||
PartialJSON string `json:"partial_json"`
|
||||
StopReason string `json:"stop_reason"`
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
Thinking string `json:"thinking"`
|
||||
Signature string `json:"signature"`
|
||||
PartialJSON string `json:"partial_json"`
|
||||
StopReason string `json:"stop_reason"`
|
||||
SafeguardResults json.RawMessage `json:"safeguard_results"`
|
||||
} `json:"delta"`
|
||||
Usage cliUsage `json:"usage"`
|
||||
} `json:"event"`
|
||||
@@ -1474,11 +1591,12 @@ func (r *subscriptionRun) readOutput(rd io.Reader) {
|
||||
return
|
||||
}
|
||||
var whole struct {
|
||||
ID string `json:"id"`
|
||||
Model string `json:"model"`
|
||||
StopReason string `json:"stop_reason"`
|
||||
Usage cliUsage `json:"usage"`
|
||||
Content []struct {
|
||||
ID string `json:"id"`
|
||||
Model string `json:"model"`
|
||||
StopReason string `json:"stop_reason"`
|
||||
Usage cliUsage `json:"usage"`
|
||||
SafeguardResults json.RawMessage `json:"safeguard_results"`
|
||||
Content []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
Thinking string `json:"thinking"`
|
||||
@@ -1598,7 +1716,7 @@ func (r *subscriptionRun) readOutput(rd io.Reader) {
|
||||
}
|
||||
}
|
||||
}
|
||||
r.emit(Event{Kind: KUsage, Usage: usage(whole.Usage), RequestID: reqID})
|
||||
r.emit(Event{Kind: KUsage, Usage: usage(whole.Usage), RequestID: reqID, SafeguardResults: whole.SafeguardResults})
|
||||
if quiet != nil {
|
||||
quiet.Stop()
|
||||
}
|
||||
@@ -1657,7 +1775,7 @@ func (r *subscriptionRun) readOutput(rd io.Reader) {
|
||||
}
|
||||
}
|
||||
case "message_delta":
|
||||
r.emit(Event{Kind: KUsage, Usage: usage(e.Usage), RequestID: reqID})
|
||||
r.emit(Event{Kind: KUsage, Usage: usage(e.Usage), RequestID: reqID, SafeguardResults: e.Delta.SafeguardResults})
|
||||
if e.Delta.StopReason != "" {
|
||||
stopped = true
|
||||
}
|
||||
@@ -2299,6 +2417,7 @@ func (r *subscriptionRun) finish() {
|
||||
return
|
||||
}
|
||||
r.closed = true
|
||||
r.resuming = false
|
||||
if r.timer != nil {
|
||||
r.timer.Stop()
|
||||
}
|
||||
@@ -2445,6 +2564,15 @@ func (s *Server) serveSubscription(w http.ResponseWriter, r *http.Request, from
|
||||
return writeError(w, from, 400, err.Error()), err.Error()
|
||||
}
|
||||
req.Model = model
|
||||
if len(req.Safeguards) > 0 {
|
||||
req.SafeguardBeta = "dangerous-tool-use-2026-09-03"
|
||||
for _, beta := range strings.Split(r.Header.Get("anthropic-beta"), ",") {
|
||||
if beta = strings.TrimSpace(beta); strings.HasPrefix(beta, "dangerous-tool-use-") {
|
||||
req.SafeguardBeta = beta
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// its model searches the web with magpie's tool, which magpie answers
|
||||
var search Tool
|
||||
if req.WebSearch && !searching(r.Context()) {
|
||||
@@ -2455,6 +2583,10 @@ func (s *Server) serveSubscription(w http.ResponseWriter, r *http.Request, from
|
||||
}
|
||||
|
||||
run, results, how := s.subscription.match(req)
|
||||
if run != nil && !run.claimResume() {
|
||||
msg := "the agent's turn is already being resumed"
|
||||
return writeError(w, from, http.StatusConflict, msg), msg
|
||||
}
|
||||
// the client rewrote the conversation since the run's last reply, as Pi
|
||||
// does compacting it mid-turn: the run's agent holds the one from before,
|
||||
// would answer from it, and tell the client a context as large as ever,
|
||||
@@ -2476,7 +2608,14 @@ func (s *Server) serveSubscription(w http.ResponseWriter, r *http.Request, from
|
||||
}
|
||||
var events <-chan Event
|
||||
if run != nil {
|
||||
if events, err = run.continueWith(results, more); err != nil {
|
||||
if name == "Claude Code" {
|
||||
err = run.setSafeguards(req)
|
||||
}
|
||||
if err == nil {
|
||||
events, err = run.continueWith(results, more)
|
||||
}
|
||||
if err != nil {
|
||||
run.abort()
|
||||
// it ended while it waited: a new one is told the whole
|
||||
// conversation
|
||||
run, how = nil, runExpired
|
||||
|
||||
+17
-9
@@ -127,6 +127,9 @@ type Request struct {
|
||||
// Anthropic upstream: a relay that serves only Claude Code turns a
|
||||
// request without it away (#359).
|
||||
Metadata json.RawMessage
|
||||
// Safeguards are the caller's safety context, opaque to the gateway.
|
||||
Safeguards json.RawMessage
|
||||
SafeguardBeta string
|
||||
// Schema is the JSON schema an Anthropic client asked the answer to fit
|
||||
// (output_config.format, of type json_schema).
|
||||
Schema json.RawMessage
|
||||
@@ -180,10 +183,11 @@ type Event struct {
|
||||
// Code: for KError, the source error or safety-filter code (rate_limit,
|
||||
// server_error, bio_policy, content_filter…); RequestID: the vendor's id for the
|
||||
// request the event is of, when it is known by then
|
||||
Code string
|
||||
RequestID string
|
||||
Usage Usage
|
||||
Hits []Hit
|
||||
Code string
|
||||
RequestID string
|
||||
Usage Usage
|
||||
Hits []Hit
|
||||
SafeguardResults json.RawMessage
|
||||
}
|
||||
|
||||
// Usage counts tokens.
|
||||
@@ -244,11 +248,12 @@ func (u *Usage) add(v Usage) {
|
||||
|
||||
// Result is a whole reply, for non-streaming clients.
|
||||
type Result struct {
|
||||
ID string
|
||||
Model string
|
||||
Parts []Part
|
||||
Stop string
|
||||
Usage Usage
|
||||
ID string
|
||||
Model string
|
||||
Parts []Part
|
||||
Stop string
|
||||
Usage Usage
|
||||
SafeguardResults json.RawMessage
|
||||
}
|
||||
|
||||
// collector assembles a Result from events. Encoders use the same logic to
|
||||
@@ -281,6 +286,9 @@ func (c *collector) closeTool() {
|
||||
}
|
||||
|
||||
func (c *collector) add(ev Event) {
|
||||
if len(ev.SafeguardResults) > 0 {
|
||||
c.res.SafeguardResults = ev.SafeguardResults
|
||||
}
|
||||
switch ev.Kind {
|
||||
case KStart:
|
||||
c.res.ID, c.res.Model = ev.MsgID, ev.Model
|
||||
|
||||
Reference in new issue
Block a user