From 5cf741895f91588b3f96bd941d03dbf3ce4c1e50 Mon Sep 17 00:00:00 2001 From: Tim Ren <137012659+xr843@users.noreply.github.com> Date: Thu, 30 Apr 2026 16:09:56 +0800 Subject: [PATCH 1/2] fix(plugin): preserve multi-value HTTP response headers (#35726) Co-authored-by: Claude Opus 4.7 (1M context) --- api/core/plugin/utils/http_parser.py | 8 +++- .../core/plugin/utils/test_http_parser.py | 44 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/api/core/plugin/utils/http_parser.py b/api/core/plugin/utils/http_parser.py index ce943929be..af0ff10bfb 100644 --- a/api/core/plugin/utils/http_parser.py +++ b/api/core/plugin/utils/http_parser.py @@ -151,6 +151,12 @@ def deserialize_response(raw_data: bytes) -> Response: response = Response(response=body, status=status_code) + # Replace Flask's default headers (e.g. Content-Type, Content-Length) with the + # parsed ones so we faithfully reproduce the original response. Use Headers.add + # rather than dict-style assignment so that repeated headers such as Set-Cookie + # (and any other multi-valued header per RFC 9110) are preserved instead of + # being overwritten. + response.headers.clear() for line in lines[1:]: if not line: continue @@ -158,6 +164,6 @@ def deserialize_response(raw_data: bytes) -> Response: if ":" not in line_str: continue name, value = line_str.split(":", 1) - response.headers[name] = value.strip() + response.headers.add(name, value.strip()) return response diff --git a/api/tests/unit_tests/core/plugin/utils/test_http_parser.py b/api/tests/unit_tests/core/plugin/utils/test_http_parser.py index 71144695bc..e0419d3266 100644 --- a/api/tests/unit_tests/core/plugin/utils/test_http_parser.py +++ b/api/tests/unit_tests/core/plugin/utils/test_http_parser.py @@ -323,6 +323,50 @@ class TestDeserializeResponse: with pytest.raises(ValueError, match="Invalid status line"): deserialize_response(raw_data) + def test_deserialize_response_preserves_duplicate_set_cookie_headers(self): + # Regression test for https://github.com/langgenius/dify/issues/35722 + # Multiple Set-Cookie headers must be preserved per RFC 9110, not collapsed + # into a single value by dict-style assignment. + raw_data = ( + b"HTTP/1.1 200 OK\r\n" + b"Content-Type: text/plain\r\n" + b"Set-Cookie: session=abc; Path=/; HttpOnly\r\n" + b"Set-Cookie: tracking=xyz; Path=/; Secure\r\n" + b"\r\n" + b"ok" + ) + + response = deserialize_response(raw_data) + + cookies = response.headers.getlist("Set-Cookie") + assert cookies == [ + "session=abc; Path=/; HttpOnly", + "tracking=xyz; Path=/; Secure", + ] + # Single-valued headers should still be readable normally. + assert response.headers.get("Content-Type") == "text/plain" + + def test_deserialize_response_preserves_duplicate_generic_headers(self): + # Any header name (not just Set-Cookie) may legitimately repeat; verify the + # parser preserves all values rather than overwriting earlier ones. + raw_data = b"HTTP/1.1 200 OK\r\nX-Custom: first\r\nX-Custom: second\r\n\r\n" + + response = deserialize_response(raw_data) + + assert response.headers.getlist("X-Custom") == ["first", "second"] + + def test_deserialize_response_does_not_inject_default_content_type(self): + # Flask's Response constructor adds a default Content-Type header. When the + # raw response has no Content-Type, the parsed response should not silently + # gain one from the framework default. + raw_data = b"HTTP/1.1 204 No Content\r\nX-Trace-Id: abc\r\n\r\n" + + response = deserialize_response(raw_data) + + header_names = [name for name, _ in response.headers.items()] + assert "Content-Type" not in header_names + assert response.headers.get("X-Trace-Id") == "abc" + def test_roundtrip_response(self): # Test that serialize -> deserialize produces equivalent response original_response = Response( From 0ca339103f4ad856db57f08031bb59518b5c33ba Mon Sep 17 00:00:00 2001 From: Joel Date: Thu, 30 Apr 2026 16:33:29 +0800 Subject: [PATCH 2/2] fix: var reference picker can not choose sub vars (#35732) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- eslint-suppressions.json | 5 --- .../__tests__/index.spec.tsx | 42 +++++++++++++++++++ .../plugins/component-picker-block/index.tsx | 13 +++--- .../object-child-tree-panel/picker/field.tsx | 2 +- .../variable/var-reference-vars.tsx | 5 ++- 5 files changed, 54 insertions(+), 13 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 7b24f216aa..272fa034ad 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1744,11 +1744,6 @@ "count": 4 } }, - "web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx": { - "ts/no-explicit-any": { - "count": 1 - } - }, "web/app/components/base/prompt-editor/plugins/component-picker-block/menu.tsx": { "erasable-syntax-only/parameter-properties": { "count": 1 diff --git a/web/app/components/base/prompt-editor/plugins/component-picker-block/__tests__/index.spec.tsx b/web/app/components/base/prompt-editor/plugins/component-picker-block/__tests__/index.spec.tsx index a09e25f6e9..d14de7d58b 100644 --- a/web/app/components/base/prompt-editor/plugins/component-picker-block/__tests__/index.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/component-picker-block/__tests__/index.spec.tsx @@ -29,6 +29,7 @@ import { } from 'lexical' import * as React from 'react' import { GeneratorType } from '@/app/components/app/configuration/config/automatic/types' +import { VAR_REFERENCE_CHILD_POPUP_CLASS_NAME } from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars' import { VarType } from '@/app/components/workflow/types' import { useEventEmitterContextContext } from '@/context/event-emitter' import { EventEmitterContextProvider } from '@/context/event-emitter-provider' @@ -928,5 +929,46 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => { vi.useRealTimers() }) + + it('does not hide the menu when focus moves into a variable child popup', async () => { + const captures: Captures = { editor: null, eventEmitter: null } + + render(( + + )) + + const editor = await waitForEditor(captures) + await setEditorText(editor, '/', true) + expect(await screen.findByText('payload')).toBeInTheDocument() + + vi.useFakeTimers() + + const popupTarget = document.createElement('button') + const popup = document.createElement('div') + popup.classList.add(VAR_REFERENCE_CHILD_POPUP_CLASS_NAME) + popup.appendChild(popupTarget) + document.body.appendChild(popup) + + act(() => { + editor.dispatchCommand(BLUR_COMMAND, new FocusEvent('blur-sm', { relatedTarget: popupTarget })) + }) + + act(() => { + vi.advanceTimersByTime(200) + }) + + expect(screen.queryByText('payload')).toBeInTheDocument() + + popup.remove() + vi.useRealTimers() + }) }) }) diff --git a/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx b/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx index 503af4077d..10c371916b 100644 --- a/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx +++ b/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx @@ -14,6 +14,7 @@ import type { WorkflowVariableBlockType, } from '../../types' import type { PickerBlockMenuOption } from './menu' +import type { EventEmitterValue } from '@/context/event-emitter' import { flip, offset, @@ -39,7 +40,7 @@ import { } from 'react' import ReactDOM from 'react-dom' import { GeneratorType } from '@/app/components/app/configuration/config/automatic/types' -import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars' +import VarReferenceVars, { VAR_REFERENCE_CHILD_POPUP_CLASS_NAME } from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars' import { useEventEmitterContextContext } from '@/context/event-emitter' import { useBasicTypeaheadTriggerMatch } from '../../hooks' import { $splitNodeContainingQuery } from '../../utils' @@ -119,7 +120,9 @@ const ComponentPicker = ({ (event) => { clearBlurTimer() const target = event?.relatedTarget as HTMLElement - if (!target?.classList?.contains('var-search-input')) + const isVariableMenuTarget = target?.classList?.contains('var-search-input') + || target?.closest?.(`.${VAR_REFERENCE_CHILD_POPUP_CLASS_NAME}`) + if (!isVariableMenuTarget) blurTimerRef.current = setTimeout(() => setBlurHidden(true), 200) return false }, @@ -143,8 +146,8 @@ const ComponentPicker = ({ } }, [editor, clearBlurTimer]) - eventEmitter?.useSubscription((v: any) => { - if (v.type === INSERT_VARIABLE_VALUE_BLOCK_COMMAND) + eventEmitter?.useSubscription((v: EventEmitterValue) => { + if (typeof v !== 'string' && v.type === INSERT_VARIABLE_VALUE_BLOCK_COMMAND && typeof v.payload === 'string') editor.dispatchCommand(INSERT_VARIABLE_VALUE_BLOCK_COMMAND, `{{${v.payload}}}`) }) @@ -303,7 +306,7 @@ const ComponentPicker = ({ } ) - }, [blurHidden, allFlattenOptions.length, workflowVariableBlock?.show, floatingStyles, isPositioned, refs, workflowVariableOptions, isSupportFileVar, handleClose, currentBlock?.generatorType, handleSelectWorkflowVariable, queryString, workflowVariableBlock?.showManageInputField, workflowVariableBlock?.onManageInputField]) + }, [blurHidden, allFlattenOptions.length, workflowVariableBlock?.show, floatingStyles, isPositioned, refs, workflowVariableOptions, isSupportFileVar, handleClose, currentBlock?.generatorType, handleSelectWorkflowVariable, queryString, triggerString, workflowVariableBlock?.showManageInputField, workflowVariableBlock?.onManageInputField]) return ( = ({ disabled={depth !== MAX_DEPTH + 1} render={(
!readonly && onSelect?.([...valueSelector, name])} >
diff --git a/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx b/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx index 38fef9016d..420888cb13 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx @@ -29,6 +29,7 @@ import { } from './var-reference-vars.helpers' const VAR_SEARCH_INPUT_CLASS_NAME = 'var-search-input' +export const VAR_REFERENCE_CHILD_POPUP_CLASS_NAME = 'var-reference-vars-child-popup' const resolveValueSelector = ({ itemData, @@ -210,7 +211,7 @@ const Item: FC = ({ className={cn( (isObj || isStructureOutput) ? 'pr-1' : 'pr-[18px]', (isHovering || isSelected) && ((isObj || isStructureOutput) ? 'bg-components-panel-on-panel-item-bg-hover' : 'bg-state-base-hover'), - 'relative flex h-6 w-full cursor-pointer items-center rounded-md pl-3', + 'relative flex h-6 w-full cursor-pointer items-center rounded-md pl-3 outline-none focus:outline-none focus-visible:outline-none', className, )} data-selected={isSelected ? 'true' : 'false'} @@ -263,7 +264,7 @@ const Item: FC = ({