Skip to content

Commit 4e27eb9

Browse files
committed
merge latest upstream main into i18n branch
# Conflicts: # ui/app/workspace/mcp-registry/views/mcpClientSheet.tsx # ui/app/workspace/providers/dialogs/addNewCustomProviderSheet.tsx
2 parents 2e8408b + c80c532 commit 4e27eb9

101 files changed

Lines changed: 1910 additions & 706 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/scripts/run-migration-tests.sh

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3509,8 +3509,10 @@ compare_postgres_snapshots() {
35093509
# calendar_aligned was dropped from governance_budgets in prerelease2 (add_multi_budget_tables) but
35103510
# re-added in prerelease4 (migrateCalendarAlignedToBudgetsAndRateLimitsTable) - no longer dropped
35113511
# enable_litellm_fallbacks (dropped from config_client in latest cut - behavior moved elsewhere)
3512+
# allow_direct_keys (dropped from config_client in v1.5.0 - direct-keys-only mode removed; HTTP header
3513+
# pass-through is no longer accepted)
35123514
if [ "$table" = "config_client" ]; then
3513-
dropped_columns="$dropped_columns enable_litellm_fallbacks"
3515+
dropped_columns="$dropped_columns enable_litellm_fallbacks allow_direct_keys"
35143516
fi
35153517

35163518
local before_col_array

.github/workflows/scripts/schemasync/main.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,10 @@ var ignoreGoFields = map[string]string{
9090
// provider_key_id is the internal DB column resolved from provider_key_name at config load time;
9191
// schema documents only the human-readable provider_key_name alias.
9292
"/properties/governance/properties/pricing_overrides/items|provider_key_id": "internal DB column; config uses provider_key_name alias instead",
93+
// oauth_client_id / oauth_client_secret are response-only fields on MCPClientConfig:
94+
// populated on GET from the oauth_configs table, never accepted as config.json input.
95+
"/properties/mcp/properties/client_configs/items|oauth_client_id": "response-only; populated on GET from oauth_configs, not user-configurable via config.json",
96+
"/properties/mcp/properties/client_configs/items|oauth_client_secret": "response-only; populated on GET from oauth_configs, not user-configurable via config.json",
9397
}
9498

9599
// ignoreGoFieldNames are field names (regardless of parent path) that are

.github/workflows/scripts/test-bifrost-http.sh

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,6 @@ CONFIGS_TO_TEST=(
9191
"withdynamicplugin"
9292
"withobservability"
9393
"withsemanticcache"
94-
"withpostgresmcpclientsinconfig"
9594
)
9695

9796
TEST_BINARY="../tmp/bifrost-http"

.github/workflows/scripts/validate-helm-config-fields.sh

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,10 @@ report_result() {
3939
# Usage: render_config <values-file>
4040
render_config() {
4141
local values_file=$1
42-
helm template bifrost "$CHART_DIR" \
43-
--set image.tag=v1.0.0 \
44-
-f "$values_file" \
45-
> "$TMPDIR/rendered.yaml" 2>"$TMPDIR/render-err.txt"
46-
local rc=$?
47-
if [ "$rc" -ne 0 ]; then
42+
if ! helm template bifrost "$CHART_DIR" \
43+
--set image.tag=v1.0.0 \
44+
-f "$values_file" \
45+
> "$TMPDIR/rendered.yaml" 2>"$TMPDIR/render-err.txt"; then
4846
echo -e "${RED} Render failed:${NC}"
4947
head -5 "$TMPDIR/render-err.txt" | sed 's/^/ /'
5048
return 1
@@ -162,7 +160,6 @@ bifrost:
162160
disableDbPingsInHealth: true
163161
logRetentionDays: 30
164162
enforceGovernanceHeader: true
165-
allowDirectKeys: true
166163
maxRequestBodySizeMb: 50
167164
compat:
168165
convertTextToChat: true
@@ -202,7 +199,6 @@ assert_field_value 'client.disable_content_logging' '.client.disable_content_log
202199
assert_field_value 'client.disable_db_pings_in_health' '.client.disable_db_pings_in_health' 'true'
203200
assert_field_value 'client.log_retention_days' '.client.log_retention_days' '30'
204201
assert_field_value 'client.enforce_governance_header' '.client.enforce_governance_header' 'true'
205-
assert_field_value 'client.allow_direct_keys' '.client.allow_direct_keys' 'true'
206202
assert_field_value 'client.max_request_body_size_mb' '.client.max_request_body_size_mb' '50'
207203
assert_field_value 'client.compat.convert_text_to_chat' '.client.compat.convert_text_to_chat' 'true'
208204
assert_field_value 'client.compat.convert_chat_to_responses' '.client.compat.convert_chat_to_responses' 'true'

core/bifrost.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5675,13 +5675,15 @@ func (bifrost *Bifrost) requestWorker(provider schemas.Provider, config *schemas
56755675
// Step 1: compute effective value for each flag (provider config ← per-request override).
56765676
effectiveSendBackReq := config.SendBackRawRequest
56775677
allowRawOverride, _ := req.Context.Value(schemas.BifrostContextKeyAllowPerRequestRawOverride).(bool)
5678-
if allowRawOverride {
5678+
passthroughOverridePresent, _ := req.Context.Value(schemas.BifrostContextKeyPassthroughOverridesPresent).(bool)
5679+
5680+
if allowRawOverride || passthroughOverridePresent {
56795681
if override, ok := req.Context.Value(schemas.BifrostContextKeySendBackRawRequest).(bool); ok {
56805682
effectiveSendBackReq = override
56815683
}
56825684
}
56835685
effectiveSendBackResp := config.SendBackRawResponse
5684-
if allowRawOverride {
5686+
if allowRawOverride || passthroughOverridePresent {
56855687
if override, ok := req.Context.Value(schemas.BifrostContextKeySendBackRawResponse).(bool); ok {
56865688
effectiveSendBackResp = override
56875689
}

core/providers/anthropic/utils_test.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2541,3 +2541,95 @@ func TestRemapRawToolVersionsForProvider_NormalizesComputerUse(t *testing.T) {
25412541
})
25422542
}
25432543
}
2544+
2545+
// TestIsClaudeCodeRequest covers detection of Claude CLI / Claude Code clients
2546+
// via the User-Agent stored on BifrostContext. ClaudeCLI.Matches uses a
2547+
// case-insensitive substring check, so identifiers such as "claude-cli" should
2548+
// match version-suffixed strings like "claude-cli/2.1.128 (external, cli)".
2549+
func TestIsClaudeCodeRequest(t *testing.T) {
2550+
tests := []struct {
2551+
name string
2552+
setUA bool // false: do not set the user-agent key on the context
2553+
userAgent interface{} // interface{} so we can also test non-string values
2554+
expected bool
2555+
}{
2556+
{
2557+
name: "claude-cli with version and metadata suffix",
2558+
setUA: true,
2559+
userAgent: "claude-cli/2.1.128 (external, cli)",
2560+
expected: true,
2561+
},
2562+
{
2563+
name: "claude-cli older version",
2564+
setUA: true,
2565+
userAgent: "claude-cli/1.0.0",
2566+
expected: true,
2567+
},
2568+
{
2569+
name: "claude-code identifier",
2570+
setUA: true,
2571+
userAgent: "claude-code/0.5.2",
2572+
expected: true,
2573+
},
2574+
{
2575+
name: "claude-vscode identifier",
2576+
setUA: true,
2577+
userAgent: "claude-vscode/0.1.0 (vscode)",
2578+
expected: true,
2579+
},
2580+
{
2581+
name: "uppercase CLAUDE-CLI matches case-insensitively",
2582+
setUA: true,
2583+
userAgent: "CLAUDE-CLI/2.1.128 (external, cli)",
2584+
expected: true,
2585+
},
2586+
{
2587+
name: "claude-cli embedded in a larger user-agent string",
2588+
setUA: true,
2589+
userAgent: "Mozilla/5.0 (compatible; claude-cli/2.1.128) extra-suffix",
2590+
expected: true,
2591+
},
2592+
{
2593+
name: "non-claude client (geminicli) does not match",
2594+
setUA: true,
2595+
userAgent: "geminicli/0.4.1",
2596+
expected: false,
2597+
},
2598+
{
2599+
name: "non-claude client (python-requests) does not match",
2600+
setUA: true,
2601+
userAgent: "python-requests/2.28.0",
2602+
expected: false,
2603+
},
2604+
{
2605+
name: "empty user-agent string",
2606+
setUA: true,
2607+
userAgent: "",
2608+
expected: false,
2609+
},
2610+
{
2611+
name: "no user-agent set on context",
2612+
setUA: false,
2613+
expected: false,
2614+
},
2615+
{
2616+
name: "non-string value stored under the user-agent key",
2617+
setUA: true,
2618+
userAgent: 12345,
2619+
expected: false,
2620+
},
2621+
}
2622+
2623+
for _, tc := range tests {
2624+
t.Run(tc.name, func(t *testing.T) {
2625+
ctx := schemas.NewBifrostContext(context.Background(), time.Time{})
2626+
if tc.setUA {
2627+
ctx.SetValue(schemas.BifrostContextKeyUserAgent, tc.userAgent)
2628+
}
2629+
got := IsClaudeCodeRequest(ctx)
2630+
if got != tc.expected {
2631+
t.Errorf("IsClaudeCodeRequest() = %v, want %v (userAgent=%v)", got, tc.expected, tc.userAgent)
2632+
}
2633+
})
2634+
}
2635+
}

core/schemas/bifrost.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,7 @@ const (
262262
BifrostContextKeyTargetUserID BifrostContextKey = "target_user_id"
263263
BifrostContextKeyIsAzureUserAgent BifrostContextKey = "bifrost-is-azure-user-agent" // bool (set by bifrost - DO NOT SET THIS MANUALLY)) - whether the request is an Azure user agent (only used in gateway)
264264
BifrostContextKeyVideoOutputRequested BifrostContextKey = "bifrost-video-output-requested"
265-
BifrostContextKeyValidateKeys BifrostContextKey = "bifrost-validate-keys" // bool (triggers additional key validation during provider add/update)
265+
BifrostContextKeyValidateKeys BifrostContextKey = "bifrost-validate-keys" // bool (triggers additional key validation during provider add/update)
266266
BifrostContextKeyProviderResponseHeaders BifrostContextKey = "bifrost-provider-response-headers" // map[string]string (set by provider handlers for response header forwarding)
267267
BifrostContextKeyMCPAddedTools BifrostContextKey = "bifrost-mcp-added-tools" // []string (set by bifrost - DO NOT SET THIS MANUALLY)) - list of tools added to the request by MCP, all the tool are in the format "clientName-toolName"
268268
BifrostContextKeyLargePayloadMode BifrostContextKey = "bifrost-large-payload-mode" // bool (set by bifrost - DO NOT SET THIS MANUALLY)) indicates large payload streaming mode is active
@@ -286,7 +286,7 @@ BifrostContextKeyValidateKeys BifrostContextKey = "bifros
286286
BifrostContextKeySessionID BifrostContextKey = "bifrost-session-id" // string session ID for the request (session stickiness)
287287
BifrostContextKeySessionTTL BifrostContextKey = "bifrost-session-ttl" // time.Duration session TTL for the request (session stickiness)
288288
BifrostContextKeyMCPExtraHeaders BifrostContextKey = "bifrost-mcp-extra-headers" // map[string][]string (these headers are forwarded only to the MCP while tool execution if they are in the allowlist of the MCP client)
289-
BifrostContextKeyMCPLogID BifrostContextKey = "bifrost-mcp-log-id" // string (unique UUID for each MCP tool log entry - set per goroutine by agent executor - DO NOT SET THIS MANUALLY)
289+
BifrostContextKeyMCPLogID BifrostContextKey = "bifrost-mcp-log-id" // string (unique UUID for each MCP tool log entry - set per goroutine by agent executor - DO NOT SET THIS MANUALLY)
290290
BifrostContextKeyCompatConvertTextToChat BifrostContextKey = "bifrost-compat-convert-text-to-chat" // bool (per-request override from x-bf-compat header)
291291
BifrostContextKeyCompatConvertChatToResponses BifrostContextKey = "bifrost-compat-convert-chat-to-responses" // bool (per-request override from x-bf-compat header)
292292
BifrostContextKeyCompatShouldDropParams BifrostContextKey = "bifrost-compat-should-drop-params" // bool (per-request override from x-bf-compat header)
@@ -295,7 +295,9 @@ BifrostContextKeyValidateKeys BifrostContextKey = "bifros
295295
BifrostContextKeyDimensions BifrostContextKey = "bifrost-dimensions" // map[string]string (set by HTTP transport from x-bf-dim-* headers) BifrostContextKeyDimensions holds per-request key/value dimensions supplied via x-bf-dim-<key> request headers. These dimensions are forwarded to internal logs (as metadata)
296296
BifrostContextKeySkipModelCatalogProviderSelection BifrostContextKey = "bifrost-skip-model-catalog-provider-selection" // bool (set by bifrost - DO NOT SET THIS MANUALLY)) - skip model catalog provider selection
297297
IsAPIKeyAuthContextKey BifrostContextKey = "is_api_key_auth"
298-
IsLocalAdminContextKey BifrostContextKey = "is_local_admin" // bool (set by auth middleware when password-based auth succeeds - local admin user bypasses RBAC)
298+
IsLocalAdminContextKey BifrostContextKey = "is_local_admin" // bool (set by auth middleware when password-based auth succeeds - local admin user bypasses RBAC)
299+
BifrostContextKeyPassthroughOverridesPresent BifrostContextKey = "passthrough_overrides_present" // bool (set by HTTP transport) - passthrough raw request requested
300+
299301
)
300302

301303
const (

core/schemas/context.go

Lines changed: 40 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,6 @@ var pluginLogStorePool = sync.Pool{
4141
},
4242
}
4343

44-
// pluginScopePool pools BifrostContext instances used as scoped plugin contexts.
45-
var pluginScopePool = sync.Pool{
46-
New: func() any {
47-
return &BifrostContext{}
48-
},
49-
}
50-
5144
// BifrostContext is a custom context.Context implementation that tracks user-set values.
5245
// It supports deadlines, can be derived from other contexts, and provides layered
5346
// value inheritance when derived from another BifrostContext.
@@ -76,6 +69,18 @@ func NewBifrostContext(parent context.Context, deadline time.Time) *BifrostConte
7669
if parent == nil {
7770
parent = context.Background()
7871
}
72+
// Unwrap pooled scoped BifrostContexts to their delegate root. A scoped
73+
// context (from WithPluginScope) is reset and returned to a sync.Pool when
74+
// ReleasePluginScope is called, which can happen before a derived context's
75+
// watchCancellation goroutine has finished observing parent.Deadline()/Done().
76+
// Pointing the derived parent at the long-lived root avoids that race.
77+
for {
78+
bc, ok := parent.(*BifrostContext)
79+
if !ok || bc.valueDelegate == nil {
80+
break
81+
}
82+
parent = bc.valueDelegate
83+
}
7984
ctx := &BifrostContext{
8085
parent: parent,
8186
deadline: deadline,
@@ -399,10 +404,17 @@ func AppendToContextList[T any](ctx *BifrostContext, key BifrostContextKey, valu
399404
ctx.SetValue(key, append(existingValues, value))
400405
}
401406

402-
// WithPluginScope returns a lightweight scoped BifrostContext from the pool.
403-
// The scoped context shares the root's pluginLogs store and delegates all
404-
// Value/SetValue operations to the root context.
405-
// Call ReleasePluginScope() when done to return the scoped context to the pool.
407+
// WithPluginScope returns a scoped BifrostContext that shares the root's
408+
// pluginLogs store and delegates Value/SetValue/Deadline/Err/Done operations
409+
// to the root.
410+
//
411+
// Scoped contexts are NOT pool-reused. Plugins routinely pass the scoped ctx
412+
// to stdlib helpers (context.WithDeadline, HTTP clients, vector store SDKs)
413+
// which spawn watcher goroutines via context.propagateCancel — those watchers
414+
// can read the scoped struct's fields long after the plugin returns. Reusing
415+
// the struct across requests would race those reads. Allocating fresh is
416+
// idiomatic Go context handling (the stdlib context types are not pooled
417+
// either) and keeps the lifecycle race-free without atomics.
406418
func (bc *BifrostContext) WithPluginScope(name *string) *BifrostContext {
407419
// Lazily initialize the plugin log store on the root context (CAS to avoid race)
408420
if bc.pluginLogs.Load() == nil {
@@ -413,28 +425,32 @@ func (bc *BifrostContext) WithPluginScope(name *string) *BifrostContext {
413425
}
414426
}
415427

416-
scoped := pluginScopePool.Get().(*BifrostContext)
417-
scoped.parent = bc.parent
418-
scoped.done = bc.done
419-
scoped.pluginScope = name
428+
scoped := &BifrostContext{
429+
parent: bc.parent,
430+
done: bc.done,
431+
pluginScope: name,
432+
valueDelegate: bc,
433+
}
420434
scoped.pluginLogs.Store(bc.pluginLogs.Load())
421-
scoped.valueDelegate = bc
422435
return scoped
423436
}
424437

425-
// ReleasePluginScope returns a scoped context to the pool.
426-
// Safe no-op if called on a non-scoped context.
427-
// Do not use the scoped context after calling this method.
438+
// ReleasePluginScope marks a scoped context as released. Safe no-op if called
439+
// on a non-scoped context.
440+
//
441+
// We deliberately do NOT mutate the scoped struct's fields here. External
442+
// watcher goroutines spawned via stdlib context helpers (e.g. propagateCancel
443+
// from context.WithDeadline) may still hold references and read parent/done
444+
// asynchronously. Mutating those fields would race the watchers. The struct
445+
// becomes garbage and is reclaimed by the GC once all watchers have exited.
446+
//
447+
// We still release the plugin log store reference so it can be drained or
448+
// reclaimed independently of the scope's lifetime.
428449
func (bc *BifrostContext) ReleasePluginScope() {
429450
if bc.valueDelegate == nil {
430451
return // not a scoped context
431452
}
432-
bc.parent = nil
433-
bc.done = nil
434-
bc.pluginScope = nil
435453
bc.pluginLogs.Store(nil)
436-
bc.valueDelegate = nil
437-
pluginScopePool.Put(bc)
438454
}
439455

440456
// AddSpanAttribute adds an attribute to the span.

core/schemas/context_test.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,3 +329,71 @@ func TestPluginLog_PoolReuse(t *testing.T) {
329329
t.Errorf("expected 100 logs from pool reuse, got %d", len(logs))
330330
}
331331
}
332+
333+
// TestNewBifrostContext_DerivedFromReleasedScope_NoPanic locks in the
334+
// deterministic half of the scoped-parent-release bug: a derived BifrostContext
335+
// must not deref a pool-released scoped ancestor when its accessors are called.
336+
//
337+
// Pre-fix shape (see plugins/semanticcache/utils.go:71):
338+
//
339+
// root := NewBifrostContext(...)
340+
// scope := root.WithPluginScope(...)
341+
// derived := NewBifrostContext(scope, NoDeadline) // derived.parent = scope
342+
// scope.ReleasePluginScope() // scope.parent = nil, valueDelegate = nil
343+
// derived.Deadline() // → scope.Deadline() → nil deref panic
344+
//
345+
// The fix unwraps scoped parents to their valueDelegate (root) at construction
346+
// time, so derived.parent is root and the release of scope cannot affect it.
347+
func TestNewBifrostContext_DerivedFromReleasedScope_NoPanic(t *testing.T) {
348+
root := NewBifrostContext(context.Background(), NoDeadline)
349+
pluginName := "release-race"
350+
scope := root.WithPluginScope(&pluginName)
351+
352+
derived := NewBifrostContext(scope, NoDeadline)
353+
354+
// Release the scope while a derived child is still alive — same shape as
355+
// core/bifrost.go's plugin pipeline releasing pluginCtx after PreLLMHook
356+
// returns, while plugins/semanticcache holds the embeddingCtx child.
357+
scope.ReleasePluginScope()
358+
359+
// All three accessors used to crash via the parent chain. After the unwrap
360+
// fix, derived.parent is the (still-live) root.
361+
_, _ = derived.Deadline()
362+
_ = derived.Err()
363+
_ = derived.Done()
364+
}
365+
366+
// TestNewBifrostContext_WatchdogRaceWithReleasedScope is the stress companion
367+
// to the deterministic test above. Run with `go test -race` to surface the
368+
// data race between watchCancellation reading bc.parent (context.go:202) and
369+
// ReleasePluginScope writing bc.parent = nil (context.go:432).
370+
//
371+
// With the unwrap fix, watchCancellation observes root (long-lived) instead of
372+
// the pool-released scope, so the race window does not exist.
373+
func TestNewBifrostContext_WatchdogRaceWithReleasedScope(t *testing.T) {
374+
root := NewBifrostContext(context.Background(), NoDeadline)
375+
pluginName := "watchdog-race"
376+
377+
const iterations = 200
378+
derivedCtxs := make([]*BifrostContext, 0, iterations)
379+
for i := 0; i < iterations; i++ {
380+
scope := root.WithPluginScope(&pluginName)
381+
// Spawning watchCancellation: parent (scope) has a non-nil Done()
382+
// channel, so NewBifrostContext schedules the goroutine.
383+
derivedCtxs = append(derivedCtxs, NewBifrostContext(scope, NoDeadline))
384+
scope.ReleasePluginScope()
385+
}
386+
387+
// Yield so any late-scheduled watchdog goroutines run their Deadline()
388+
// observation after the surrounding scope was released. Under -race, any
389+
// remaining racy read of a pool-reset field would be flagged here.
390+
runtime.Gosched()
391+
time.Sleep(50 * time.Millisecond)
392+
393+
// Touch each derived context to keep it live across the sleep — without
394+
// this, the compiler/GC could optimize them away before the race window
395+
// has a chance to manifest.
396+
for _, c := range derivedCtxs {
397+
_, _ = c.Deadline()
398+
}
399+
}

core/version

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1.5.7
1+
1.5.8

0 commit comments

Comments
 (0)