diff --git a/.github/scripts/builder-confirmed-recovery.py b/.github/scripts/builder-confirmed-recovery.py index b1240267ed5..926af16201b 100644 --- a/.github/scripts/builder-confirmed-recovery.py +++ b/.github/scripts/builder-confirmed-recovery.py @@ -9,14 +9,9 @@ import dify_agent import pytest # API-installed SDK must be this checkout, with the missing server extra pinned by its own lock. -assert ( - Path(dify_agent.__file__).resolve().is_relative_to(Path("dify-agent/src").resolve()) -) +assert Path(dify_agent.__file__).resolve().is_relative_to(Path("dify-agent/src").resolve()) packages = tomllib.loads(Path("dify-agent/uv.lock").read_text())["package"] -assert ( - next(package["version"] for package in packages if package["name"] == "jwcrypto") - == "1.5.7" -) +assert next(package["version"] for package in packages if package["name"] == "jwcrypto") == "1.5.7" EXPECTED = { "tests.integration_tests.services.dify_builder.test_agent_recovery_postgres": { @@ -103,9 +98,7 @@ else: for module, functions in EXPECTED.items(): print(f"COLLECTED {module}: {sum(actual[module].values())} cases", flush=True) if dict(actual[module]) != functions: - problems.append( - f"recovery case inventory mismatch {module}: {dict(actual[module])!r}" - ) + problems.append(f"recovery case inventory mismatch {module}: {dict(actual[module])!r}") for problem in problems: print(f"COMPLETENESS {problem}", flush=True) raise SystemExit(int(code) or (1 if problems else 0)) diff --git a/.github/scripts/builder-confirmed-tool-units.py b/.github/scripts/builder-confirmed-tool-units.py index 672fe8e3a7d..70c795a8bc3 100644 --- a/.github/scripts/builder-confirmed-tool-units.py +++ b/.github/scripts/builder-confirmed-tool-units.py @@ -138,19 +138,13 @@ FILES += [ ] FILES += [ path - for path in [ - "api/tests/unit_tests/services/dify_builder/native_testing/test_confirmed_agent_completion.py" - ] + for path in ["api/tests/unit_tests/services/dify_builder/native_testing/test_confirmed_agent_completion.py"] if path not in FILES ] -FILES += [ - "api/tests/unit_tests/services/dify_builder/native_testing/test_confirmed_agent_terminal_scan.py" -] +FILES += ["api/tests/unit_tests/services/dify_builder/native_testing/test_confirmed_agent_terminal_scan.py"] FILES += [ path - for path in [ - "api/tests/unit_tests/services/dify_builder/test_agent_model_result_canonical.py" - ] + for path in ["api/tests/unit_tests/services/dify_builder/test_agent_model_result_canonical.py"] if path not in FILES ] FILES += ["api/tests/unit_tests/services/dify_builder/test_test_actions.py"] @@ -264,10 +258,7 @@ code = pytest.main( ) # Full module identity avoids silently merging same-basename owners. module_paths = [*FILES, *(selection.split("::", 1)[0] for selection in SELECTED_GUARDS)] -modules = { - Path(path).with_suffix("").as_posix().removeprefix("api/").replace("/", "."): 0 - for path in module_paths -} +modules = {Path(path).with_suffix("").as_posix().removeprefix("api/").replace("/", "."): 0 for path in module_paths} identities = set() problems = [] if not Path(JUNIT).is_file(): @@ -276,11 +267,7 @@ else: for case in ElementTree.parse(JUNIT).getroot().iter("testcase"): classname, name = case.get("classname") or "", case.get("name") or "" normalized = classname.removeprefix("api.") - matching = [ - module - for module in modules - if normalized == module or normalized.startswith(module + ".") - ] + matching = [module for module in modules if normalized == module or normalized.startswith(module + ".")] if len(matching) != 1: problems.append(f"unexpected unit case {classname}::{name}") continue @@ -831,12 +818,8 @@ for module, functions in { }.items(): a4_expected.setdefault(module, {}).update(functions) # Candidate 3: frozen exact collection; retain every previously required function. -a4_expected.setdefault( - "tests.unit_tests.services.dify_builder.test_agent_model_result_canonical", {} -).update( - { - "test_model_receipt_matches_sdk_roundtrip_without_changing_stream_or_accepting_replacement": 2 - } +a4_expected.setdefault("tests.unit_tests.services.dify_builder.test_agent_model_result_canonical", {}).update( + {"test_model_receipt_matches_sdk_roundtrip_without_changing_stream_or_accepting_replacement": 2} ) a4_expected.setdefault( "tests.unit_tests.services.dify_builder.native_testing.test_confirmed_agent_completion", @@ -957,9 +940,7 @@ a4_expected["tests.unit_tests.core.dify_builder.test_ports"] = { "test_mutation_intent_defaults_and_args_not_shared": 1, } # Exact collected Code failure-policy regression inventories (Local JUnit/AST checked). -a4_expected[ - "tests.unit_tests.services.dify_builder.test_bound_resource_plan_reconciliation" -] = { +a4_expected["tests.unit_tests.services.dify_builder.test_bound_resource_plan_reconciliation"] = { "test_confirmed_resource_reconciles_fallback_and_overview_before_approval": 1, "test_unusable_reconciliation_keeps_resource_gate_and_does_not_offer_approval": 3, "test_reconciliation_rereads_selected_facts_and_uses_one_model_reply": 1, @@ -2139,9 +2120,7 @@ a4_expected["tests.unit_tests.services.dify_builder.test_test_actions"] = { "test_standard_choice_envelope_can_submit_native_control": 1, "test_earlier_action_identity_cannot_be_reused_after_another_command": 1, } -a4_expected[ - "tests.unit_tests.services.dify_builder.native_testing.test_confirmed_agent_completion" -].update( +a4_expected["tests.unit_tests.services.dify_builder.native_testing.test_confirmed_agent_completion"].update( { "test_native_control_only_commits_do_not_strand_successful_builder_run": 1, "test_terminal_projection_crash_is_recovered_without_get_or_dispatch": 1, @@ -2155,17 +2134,13 @@ a4_expected.setdefault( "tests.unit_tests.services.dify_builder.native_testing.test_confirmed_agent_completion", {}, ).update({"test_expired_terminal_context_requires_actual_owned_terminal_workflow": 7}) -a4_expected.setdefault( - "tests.unit_tests.services.dify_builder.test_agent_operation_owner", {} -).update( +a4_expected.setdefault("tests.unit_tests.services.dify_builder.test_agent_operation_owner", {}).update( {"test_expired_completed_receipt_requires_exact_original_permit_and_result": 1} ) -a4_expected[ - "tests.unit_tests.services.dify_builder.native_testing.test_confirmed_agent_terminal_scan" -] = {"test_terminal_scan_compares_input_ids_as_strings": 2} -a4_expected[ - "tests.unit_tests.services.dify_builder.native_testing.test_confirmed_agent_completion" -].update( +a4_expected["tests.unit_tests.services.dify_builder.native_testing.test_confirmed_agent_terminal_scan"] = { + "test_terminal_scan_compares_input_ids_as_strings": 2 +} +a4_expected["tests.unit_tests.services.dify_builder.native_testing.test_confirmed_agent_completion"].update( { "test_cancel_after_completed_effects_closes_original_parent": 2, "test_resumed_saved_cancellation_projects_only_unchanged_terminal_proof": 17, @@ -2227,15 +2202,12 @@ task_control_transport_expected = { } for function, count in task_control_transport_expected.items(): actual = sum( - classname.removeprefix("api.") - == "tests.unit_tests.controllers.console.test_dify_builder_routes" + classname.removeprefix("api.") == "tests.unit_tests.controllers.console.test_dify_builder_routes" and name.split("[", 1)[0] == function for classname, name in identities ) if actual != count: - problems.append( - f"Task control transport inventory mismatch {function}: {actual} != {count}" - ) + problems.append(f"Task control transport inventory mismatch {function}: {actual} != {count}") # Current priority closeout: exact Local-collected regression inventory. a4_expected["tests.unit_tests.core.dify_builder.test_handlers_build"] = { "test_a_determinate_outcome_resets_the_unknown_outcome_count": 1, @@ -2701,9 +2673,7 @@ a4_expected["tests.unit_tests.services.dify_builder.test_agent_operation_owner"] "test_prepare_actual_call_freezes_builder_reasoning_without_business_or_oauth": 8, } ) -a4_expected[ - "tests.unit_tests.services.dify_builder.native_testing.test_agent_risk_assessor" -].update( +a4_expected["tests.unit_tests.services.dify_builder.native_testing.test_agent_risk_assessor"].update( { "test_risk_rubric_separates_observed_destruction_from_uncertain_side_effects": 1, } @@ -2885,20 +2855,16 @@ a4_expected.update( ) # Exact first-phase P0 additions; retain all unrelated reviewed cases. a4_expected["tests.unit_tests.services.dify_builder.test_agent_operation_owner"].update( - { - "test_native_model_permission_expiring_during_metadata_prevents_preparation_and_claim": 1 - } + {"test_native_model_permission_expiring_during_metadata_prevents_preparation_and_claim": 1} ) # The latest main merge adds transaction-lifetime witnesses to the existing owner. a4_expected["tests.unit_tests.services.test_workflow_run_service"].update( {"test_trace_assembly_uses_a_session_from_the_injected_factory": 2} ) -a4_expected[ - "tests.unit_tests.core.workflow.generator.test_native_code_output_contract" -].update({"test_batch_code_contract_validates_identity_before_grouping": 3}) -knowledge_cases = a4_expected[ - "tests.unit_tests.services.dify_builder.test_knowledge_discovery" -] +a4_expected["tests.unit_tests.core.workflow.generator.test_native_code_output_contract"].update( + {"test_batch_code_contract_validates_identity_before_grouping": 3} +) +knowledge_cases = a4_expected["tests.unit_tests.services.dify_builder.test_knowledge_discovery"] del knowledge_cases["test_refusing_goal_change_keeps_knowledge_wait_and_never_replans"] knowledge_cases.update( { @@ -3140,9 +3106,7 @@ for module, functions in { a4_expected.setdefault(module, {}).update(functions) # ENG-1240: exact additions from affected module collection; retain prior inventories. for module, functions in { - "tests.unit_tests.controllers.inner_api.agent.test_llm": { - "test_post_streams_plugin_compatible_envelope": 2 - }, + "tests.unit_tests.controllers.inner_api.agent.test_llm": {"test_post_streams_plugin_compatible_envelope": 2}, "tests.unit_tests.controllers.inner_api.test_agent_runtime_lineage": { "test_actual_native_identity_with_all_body_markers_removed_refuses_before_services": 20, "test_agent_bearer_refuses_equality_with_existing_enterprise_or_daemon_credential": 2, @@ -3291,9 +3255,7 @@ for module, expected in {**a3_expected, **a4_expected}.items(): actual[function] = actual.get(function, 0) + 1 if actual != expected: problems.append(f"A3 function inventory mismatch {module}: {actual}") -json_admission_module = ( - "tests.unit_tests.services.dify_builder.test_json_tool_execution_admission" -) +json_admission_module = "tests.unit_tests.services.dify_builder.test_json_tool_execution_admission" expected_json_admission = { f"test_owned_json_parse_prepare_persists_restricted_request[{variant}]" for variant in ("raw-true", "raw-false", "constant-true", "constant-false") @@ -3305,14 +3267,10 @@ expected_json_admission.update( } ) actual_json_admission = { - name - for classname, name in identities - if classname.removeprefix("api.") == json_admission_module + name for classname, name in identities if classname.removeprefix("api.") == json_admission_module } if actual_json_admission != expected_json_admission: - problems.append( - "JSON admission case identities differ from the reviewed prepare variants and owner checks" - ) + problems.append("JSON admission case identities differ from the reviewed prepare variants and owner checks") json_policy_module = "tests.unit_tests.services.dify_builder.test_json_tool_execution" json_policy_expected = { "test_reviewed_pure_mixed_graph_admits_exact_native_chain": 1, @@ -3494,9 +3452,7 @@ for module, functions in { }.items(): json_contract_expected.setdefault(module, {}).update(functions) # ENG-1417: value-free failed Code format facts and current failed-Run provenance. -json_contract_expected.setdefault( - "tests.unit_tests.services.dify_builder.test_condition_evidence", {} -).update( +json_contract_expected.setdefault("tests.unit_tests.services.dify_builder.test_condition_evidence", {}).update( { "test_failed_code_format_facts_reach_diagnosis_and_repair_without_values": 1, "test_failed_code_format_classifies_only_observed_table_samples": 5, @@ -3521,9 +3477,7 @@ for module, expected in { for function, expected_count in expected.items(): if actual.get(function, 0) != expected_count: problems.append(f"JSON case inventory mismatch {module}::{function}") - if (module == json_policy_module or module in json_runtime_expected) and set( - actual - ) != set(expected): + if (module == json_policy_module or module in json_runtime_expected) and set(actual) != set(expected): problems.append(f"unexpected JSON test functions {module}") print(f"COLLECTED total: {len(identities)} unit cases", flush=True) for problem in problems: diff --git a/.github/workflows/web-bundle-size.yml b/.github/workflows/web-bundle-size.yml index e47e52eb095..dc81f0becd4 100644 --- a/.github/workflows/web-bundle-size.yml +++ b/.github/workflows/web-bundle-size.yml @@ -24,9 +24,7 @@ jobs: matrix: include: - revision: base - sha: ${{ inputs.base-rev }} - revision: merge - sha: ${{ github.sha }} env: NEXT_TELEMETRY_DISABLED: '1' NODE_OPTIONS: --max-old-space-size=6144 @@ -43,9 +41,22 @@ jobs: - name: Checkout measured revision uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ matrix.sha }} + ref: ${{ matrix.revision == 'merge' && github.sha || github.event.pull_request.base.sha || github.event.merge_group.base_sha || 'refs/heads/main' }} + fetch-depth: ${{ matrix.revision == 'base' && github.event_name == 'workflow_dispatch' && '0' || '1' }} persist-credentials: false + - name: Select verified base commit + if: matrix.revision == 'base' && github.event_name == 'workflow_dispatch' + shell: bash + env: + BASE_REV: ${{ inputs.base-rev }} + run: | + if [[ ! "$BASE_REV" =~ ^[0-9a-f]{40}$ ]] || ! git merge-base --is-ancestor "$BASE_REV" HEAD; then + echo 'The base revision must be a commit in main history.' >&2 + exit 1 + fi + git checkout --detach "$BASE_REV" + - name: Setup web environment uses: ./.github/actions/setup-web @@ -62,9 +73,7 @@ jobs: printf 'Full Vinext build: **%s seconds**.\n' "$((SECONDS - started))" >> "$GITHUB_STEP_SUMMARY" - name: Collect sizes - env: - COMMIT_SHA: ${{ matrix.sha }} - run: node "$RUNNER_TEMP/bundle-analysis.ts" collect web "$COMMIT_SHA" "$RUNNER_TEMP/bundle-size/summary.json" + run: node "$RUNNER_TEMP/bundle-analysis.ts" collect web "$(git rev-parse HEAD)" "$RUNNER_TEMP/bundle-size/summary.json" - name: Upload size snapshot uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 @@ -122,7 +131,7 @@ jobs: bundle-results/web-bundle-summary-merge/summary.json \ bundle-report.md printf '\n[Download base/merge snapshots and official Rolldown analysis reports](%s#artifacts).\n' "$RUN_URL" >> bundle-report.md - printf '\nComparison: event base SHA vs the tested merge commit at GITHUB_SHA (PR or merge queue).\n' >> bundle-report.md + printf '\nCompared the selected base commit with the commit tested at GITHUB_SHA.\n' >> bundle-report.md cat bundle-report.md >> "$GITHUB_STEP_SUMMARY" # Forks still receive the job summary and artifacts, without a privileged PR build. diff --git a/api/core/helper/ssrf_proxy.py b/api/core/helper/ssrf_proxy.py index b7596b1b70d..99926c7df12 100644 --- a/api/core/helper/ssrf_proxy.py +++ b/api/core/helper/ssrf_proxy.py @@ -223,14 +223,14 @@ def make_request( """Send one SSRF-protected request with optional streaming. Cookies set by responses are sent only on the redirect hops of the same attempt, never on later requests; - callers that need a cookie pass it explicitly. + callers that need a cookie pass it explicitly. Retry warnings omit URLs and exception text. Args: method: HTTP method sent through the configured SSRF client. url: Absolute request URL. max_retries: Number of retry attempts after the initial request. stream_response: Return an open streaming response that the caller must close. - sensitive_request: Omit URL and exception values from this owner's warnings. + sensitive_request: Use fixed warnings without exception types for sensitive requests. Lower HTTPX/telemetry redaction remains the observability owner's responsibility. **kwargs: Additional keyword arguments forwarded to ``httpx.Client``. @@ -340,11 +340,7 @@ def make_request( if sensitive_request: logger.warning("Sensitive HTTP request received retryable status code %s", response.status_code) else: - logger.warning( - "Received status code %s for URL %s which is in the force list", - response.status_code, - url, - ) + logger.warning("HTTP request received retryable status code %s", response.status_code) response.close() except httpx.RequestError as e: @@ -352,7 +348,7 @@ def make_request( if sensitive_request: logger.warning("Sensitive HTTP request failed on attempt %s", retries + 1) else: - logger.warning("Request to URL %s failed on attempt %s: %s", url, retries + 1, e) + logger.warning("HTTP request failed on attempt %s: %s", retries + 1, type(e).__name__) if max_retries == 0: raise diff --git a/api/services/dify_builder/agent/build.py b/api/services/dify_builder/agent/build.py index 1be1305f60a..ece4a19b449 100644 --- a/api/services/dify_builder/agent/build.py +++ b/api/services/dify_builder/agent/build.py @@ -271,7 +271,7 @@ def _scrub_invented_defaults(values: dict[str, Any], goal_text: str) -> dict[str scrubbed = dict(values) for key in blanked: scrubbed[key] = "" - logger.info("dify_builder: blanked invented default(s) for %s", ", ".join(sorted(blanked))) + logger.info("dify_builder: blanked %d invented defaults", len(blanked)) return scrubbed diff --git a/api/tests/unit_tests/core/helper/test_ssrf_proxy.py b/api/tests/unit_tests/core/helper/test_ssrf_proxy.py index 1283cd2b9fe..403603be140 100644 --- a/api/tests/unit_tests/core/helper/test_ssrf_proxy.py +++ b/api/tests/unit_tests/core/helper/test_ssrf_proxy.py @@ -724,7 +724,7 @@ def test_squid_block_with_internal_10_x_url_mentions_allowlist(mock_get_client, @pytest.mark.parametrize("sensitive", [False, True]) -def test_sensitive_request_errors_have_fixed_warnings_and_ordinary_diagnostics_are_preserved( +def test_request_error_warnings_omit_urls_and_exception_messages( monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, sensitive: bool ) -> None: """Actual SSRF owner over an explicitly synthetic lower HTTPX transport.""" @@ -736,14 +736,11 @@ def test_sensitive_request_errors_have_fixed_warnings_and_ordinary_diagnostics_a monkeypatch.setattr("core.helper.ssrf_proxy._get_ssrf_client", lambda _verify: client) with pytest.raises(httpx.ReadTimeout): make_request("POST", "https://example.com/private-canary-url", max_retries=0, sensitive_request=sensitive) + assert "private-canary" not in caplog.text if sensitive: - assert "private-canary" not in caplog.text assert "Sensitive HTTP request failed on attempt 1" in caplog.text else: - assert ( - "Request to URL https://example.com/private-canary-url failed on attempt 1: private-canary-exception" - in caplog.text - ) + assert "HTTP request failed on attempt 1: ReadTimeout" in caplog.text @pytest.mark.parametrize("sensitive", [False, True]) @@ -762,8 +759,8 @@ def test_sensitive_force_list_warnings_preserve_ordinary_retry_behavior( with pytest.raises(max_retries_exceeded_error): make_request("POST", "https://example.com/private-canary-url", max_retries=1, sensitive_request=sensitive) assert len(sent) == 2 + assert "private-canary" not in caplog.text if sensitive: - assert "private-canary" not in caplog.text assert "Sensitive HTTP request received retryable status code 503" in caplog.text else: - assert "Received status code 503 for URL https://example.com/private-canary-url" in caplog.text + assert "HTTP request received retryable status code 503" in caplog.text diff --git a/api/tests/unit_tests/services/dify_builder/test_build_cognition.py b/api/tests/unit_tests/services/dify_builder/test_build_cognition.py index af02849a593..4593610e93f 100644 --- a/api/tests/unit_tests/services/dify_builder/test_build_cognition.py +++ b/api/tests/unit_tests/services/dify_builder/test_build_cognition.py @@ -1130,7 +1130,7 @@ def test_topology_directive_no_longer_steers_away_from_installed_tools() -> None assert "installed tool" in lowered # and an invented / placeholder endpoint is forbidden outright assert "never invent" in lowered - assert "example.com" in lowered + assert "never use a placeholder such as example.com --" in lowered def test_planner_catalogue_includes_setup_tools_and_remains_capped(monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/dify-agent-runtime/internal/server/service.go b/dify-agent-runtime/internal/server/service.go index eba195b35f1..b4417e12d22 100644 --- a/dify-agent-runtime/internal/server/service.go +++ b/dify-agent-runtime/internal/server/service.go @@ -3,6 +3,7 @@ package server import ( "context" "fmt" + "io/fs" "log" "os" "os/exec" @@ -142,7 +143,8 @@ func (s *Service) StartBackgroundPipeMonitor() { // RunJob creates and starts a new tmux-backed job, then waits for initial output. func (s *Service) RunJob(req *RunJobRequest) (*JobResult, error) { if s.config.NativeScopeConfigPath != "" { - cwd := "/workspace" + nativeCwd := "/workspace" + cwd := nativeCwd if req.Cwd != nil { cwd = *req.Cwd } @@ -156,7 +158,7 @@ func (s *Service) RunJob(req *RunJobRequest) (*JobResult, error) { if len(jobs) >= 64 { return nil, NewServerError(409, "native_job_limit", "scope job capacity exhausted") } - req.Cwd = &cwd + req.Cwd = &nativeCwd } log.Printf("RunJob: script=%d bytes, cwd=%v, env_keys=%d", len(req.Script), req.Cwd, len(req.Env)) cwd, err := s.resolveCwd(req.Cwd) @@ -871,12 +873,18 @@ func (s *Service) resolveCwd(rawCwd *string) (string, error) { cwd = s.config.DefaultCwd } - info, err := os.Stat(cwd) - if err != nil || !info.IsDir() { - return "", NewServerError(400, "invalid_cwd", fmt.Sprintf("cwd is not a directory: %s", cwd)) + abs, err := filepath.Abs(cwd) + if err == nil && cwd != "" { + root := filepath.VolumeName(abs) + string(os.PathSeparator) + name, err := filepath.Rel(root, abs) + if err == nil && filepath.IsLocal(name) { + info, err := fs.Stat(os.DirFS(root), filepath.ToSlash(name)) + if err == nil && info.IsDir() { + return abs, nil + } + } } - abs, _ := filepath.Abs(cwd) - return abs, nil + return "", NewServerError(400, "invalid_cwd", fmt.Sprintf("cwd is not a directory: %s", cwd)) } func (s *Service) allocateJobDir() (string, string, error) { diff --git a/dify-agent-runtime/internal/testscope/supervisor_linux.go b/dify-agent-runtime/internal/testscope/supervisor_linux.go index 0ee8c28a6b3..cc7272b97a4 100644 --- a/dify-agent-runtime/internal/testscope/supervisor_linux.go +++ b/dify-agent-runtime/internal/testscope/supervisor_linux.go @@ -447,8 +447,21 @@ func startRetainedWorker(cmd *exec.Cmd) error { } func (m *Manager) startBroker(s Scope) error { - socket := filepath.Join(m.scopeDir(s.Spec.ScopeID), "broker.sock") - _ = os.Remove(socket) + id := s.Spec.ScopeID + if !safeID.MatchString(id) { + return errors.New("invalid scope identity") + } + scopesDir := filepath.Join(m.config.StateDir, "scopes") + root, err := os.OpenRoot(scopesDir) + if err != nil { + return err + } + defer root.Close() + socketName := filepath.Join(id, "broker.sock") + if err := root.Remove(socketName); err != nil && !os.IsNotExist(err) { + return err + } + socket := filepath.Join(scopesDir, socketName) broker, err := StartBroker(socket, s.Network, s.Spec.Deadline, func(authority string, n int64) error { return m.store.Spend(s.Spec.ScopeID, "network:"+authority, n) }) if err != nil { return err diff --git a/dify-agent-runtime/internal/testscope/supervisor_linux_test.go b/dify-agent-runtime/internal/testscope/supervisor_linux_test.go index ec7fa2e1898..b4939ec4887 100644 --- a/dify-agent-runtime/internal/testscope/supervisor_linux_test.go +++ b/dify-agent-runtime/internal/testscope/supervisor_linux_test.go @@ -50,6 +50,32 @@ func TestTrustedReferenceRejectsSymlinks(t *testing.T) { t.Fatal("resolved traversal") } } + +func TestStartBrokerRejectsSocketOutsideScopeState(t *testing.T) { + m := NewManager(Config{StateDir: t.TempDir()}, nil) + spec := validSpec() + outside := t.TempDir() + socket := filepath.Join(outside, "broker.sock") + if err := os.WriteFile(socket, []byte("outside state"), 0600); err != nil { + t.Fatal(err) + } + dir := m.scopeDir(spec.ScopeID) + if err := os.MkdirAll(filepath.Dir(dir), 0700); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, dir); err != nil { + t.Fatal(err) + } + if err := m.startBroker(Scope{Spec: spec}); err == nil { + m.brokers[spec.ScopeID].Close() + t.Fatal("started broker outside the scope state directory") + } + data, err := os.ReadFile(socket) + if err != nil || string(data) != "outside state" { + t.Fatalf("modified file outside the scope state directory: %q, %v", data, err) + } +} + func TestMissingScopeAuthenticationNeverReachesJobs(t *testing.T) { // Missing credentials must be rejected even when no runtime/store is installed. m := NewManager(Config{AuthToken: "private-control-token"}, nil) diff --git a/dify-agent/tests/local/dify_agent/runtime_backend/test_binding_inspection_deadline.py b/dify-agent/tests/local/dify_agent/runtime_backend/test_binding_inspection_deadline.py index fc201ef2ec2..f505ed45882 100644 --- a/dify-agent/tests/local/dify_agent/runtime_backend/test_binding_inspection_deadline.py +++ b/dify-agent/tests/local/dify_agent/runtime_backend/test_binding_inspection_deadline.py @@ -2,7 +2,9 @@ import asyncio import json +import shlex import subprocess +import sys import threading from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path @@ -11,7 +13,7 @@ from typing import override import pytest from dify_agent.protocol import InspectExecutionBindingRequest -from dify_agent.runtime_backend.local import LocalExecutionBindingBackend +from dify_agent.runtime_backend.local import LocalExecutionBindingBackend, _LOCAL_INSPECTION_PROBE @pytest.mark.anyio @@ -23,10 +25,22 @@ async def test_local_inspection_has_total_deadline_and_private_cleanup( home_root.mkdir() workspace_root.mkdir() (home_root / "b").mkdir() + probe_args = json.dumps( + dict( + binding_ref="b:w", + workspace_ref="w", + home_root=str(home_root), + workspace_root=str(workspace_root), + home_path=str(home_root / "b"), + workspace_path=str(workspace_root / "w"), + ) + ) + expected_script = shlex.join(["python3", "-c", _LOCAL_INSPECTION_PROBE, probe_args]) # A real missing Workspace would authorize gone only after bounded cleanup. release = threading.Event() polling = mode in {"poll", "poll_delete"} requests: list[str] = [] + probes_match: list[bool] = [] marker = "PRIVATE_CREDENTIAL_/private/home/b_PROVIDER_BODY" class Handler(BaseHTTPRequestHandler): @@ -46,6 +60,12 @@ async def test_local_inspection_has_total_deadline_and_private_cleanup( def do_POST(self) -> None: requests.append(self.path) body = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) + if self.path == "/v1/jobs/run": + matches = body.get("script") == expected_script + probes_match.append(matches) + if not matches: + self.reply({"error": "unexpected inspection command"}, status=400) + return if mode == "run": release.wait(10) if self.path.endswith("/wait"): @@ -53,9 +73,7 @@ async def test_local_inspection_has_total_deadline_and_private_cleanup( output = "" else: output = subprocess.run( - body["script"], - shell=True, - executable="/bin/sh", + [sys.executable, "-c", _LOCAL_INSPECTION_PROBE, probe_args], check=True, capture_output=True, text=True, @@ -109,6 +127,7 @@ async def test_local_inspection_has_total_deadline_and_private_cleanup( assert marker not in result.model_dump_json() + caplog.text assert all(record.exc_info is None for record in caplog.records) assert requests.count("/v1/jobs/run") == 1 + assert probes_match == [True] assert all(path == "/v1/jobs/run" or path.startswith("/v1/jobs/probe") for path in requests) assert sum(path.startswith("/v1/jobs/probe?force=") for path in requests) <= 1 finally: diff --git a/dify-agent/tests/local/dify_agent/server/test_binding_inspection.py b/dify-agent/tests/local/dify_agent/server/test_binding_inspection.py index 0427fc15a5e..0141b48f324 100644 --- a/dify-agent/tests/local/dify_agent/server/test_binding_inspection.py +++ b/dify-agent/tests/local/dify_agent/server/test_binding_inspection.py @@ -3,6 +3,7 @@ from collections.abc import Generator from contextlib import contextmanager import json +import shlex import subprocess import sys import threading @@ -20,7 +21,7 @@ from pydantic import ValidationError from dify_agent import protocol from dify_agent.client import Client from dify_agent.runtime_backend.enterprise import EnterpriseExecutionBindingBackend -from dify_agent.runtime_backend.local import LocalExecutionBindingBackend +from dify_agent.runtime_backend.local import LocalExecutionBindingBackend, _LOCAL_INSPECTION_PROBE from dify_agent.server.auth import create_bearer_token_dependency from dify_agent.server.execution_bindings import ExecutionBindingService from dify_agent.server.routes import execution_bindings @@ -118,8 +119,6 @@ async def test_enterprise_health_is_never_authoritative_absence(state: str) -> N ], ) def test_fixed_local_probe_executes_only_exact_paths(tmp_path: Path, case: str, want: str) -> None: - from dify_agent.runtime_backend.local import _LOCAL_INSPECTION_PROBE - home_root, workspace_root = tmp_path / "homes", tmp_path / "workspaces" home_root.mkdir() workspace_root.mkdir() @@ -202,7 +201,19 @@ async def test_local_real_transport_runs_fixed_read_only_probe(tmp_path: Path, c (workspace_root / "w").rmdir() if case == "permission": (home_root / "b").chmod(0) + probe_args = json.dumps( + dict( + binding_ref="b:w", + workspace_ref="w", + home_root=str(home_root), + workspace_root=str(workspace_root), + home_path=str(home_root / "b"), + workspace_path=str(workspace_root / "w"), + ) + ) + expected_script = shlex.join(["python3", "-c", _LOCAL_INSPECTION_PROBE, probe_args]) requests: list[tuple[str, str]] = [] + probes_match: list[bool] = [] class Handler(BaseHTTPRequestHandler): @override @@ -212,10 +223,13 @@ async def test_local_real_transport_runs_fixed_read_only_probe(tmp_path: Path, c def do_POST(self) -> None: requests.append((self.command, self.path)) body = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) + matches = body.get("script") == expected_script + probes_match.append(matches) + if not matches: + self.send_error(400, "unexpected inspection command") + return completed = subprocess.run( - body["script"], - shell=True, - executable="/bin/sh", + [sys.executable, "-c", _LOCAL_INSPECTION_PROBE, probe_args], check=True, capture_output=True, text=True, @@ -267,6 +281,7 @@ async def test_local_real_transport_runs_fixed_read_only_probe(tmp_path: Path, c ) assert result.state == want assert requests == [("POST", "/v1/jobs/run"), ("DELETE", "/v1/jobs/probe?force=true")] + assert probes_match == [True] assert home_root.is_dir() and workspace_root.is_dir() finally: if case == "permission":