mirror of
https://github.com/langgenius/dify.git
synced 2026-11-14 07:11:06 +08:00
fix(builder): preserve startup streams and assess shell risks from accurate context
This commit is contained in:
1 parent
63cbbe6d84
commit
64b2c5e5c8
11 files changed
+418
-22
No files matched your search
+11
-1
@@ -112,7 +112,8 @@ def test_shell_reasoning_receives_owner_scope_and_preserves_unknown_risk_and_mod
|
||||
scope: dict[str, JsonValue] = {
|
||||
"initial_working_directory": "request_owned_agent_workspace",
|
||||
"may_contain_cloned_files": True,
|
||||
"filesystem_confinement_guaranteed": False,
|
||||
"initial_working_directory_is_confinement": False,
|
||||
"runtime_filesystem_isolation": "not_attested_to_assessor",
|
||||
}
|
||||
result = assess_call(
|
||||
goal="Materialize configured synthetic assets in this run workspace; preserve user artifacts.",
|
||||
@@ -134,3 +135,12 @@ def test_shell_reasoning_receives_owner_scope_and_preserves_unknown_risk_and_mod
|
||||
assert result.destructive is destructive
|
||||
assert "uncertain" in result.risk_notice
|
||||
assert result.parameters_summary == {"script": "dify-agent config skills pull --json proof"}
|
||||
|
||||
|
||||
def test_risk_rubric_separates_observed_destruction_from_uncertain_side_effects() -> None:
|
||||
from services.dify_builder.native_testing.agent_risk_assessor import _CALL_RISK_SYSTEM
|
||||
|
||||
assert "Unknown isolation is not evidence that isolation is disabled" in _CALL_RISK_SYSTEM
|
||||
assert "Ordinary external writes such as sending, creating or publishing" in _CALL_RISK_SYSTEM
|
||||
assert "Incomplete task progress or missing proof of success is not destruction" in _CALL_RISK_SYSTEM
|
||||
assert "concrete destruction, damage or deletion" in _CALL_RISK_SYSTEM
|
||||
@@ -18,6 +18,7 @@ from typing_extensions import TypedDict
|
||||
|
||||
from core.dify_builder.models import Actor
|
||||
from models.dify_builder_native_tests import (
|
||||
NativeEffectDispatchClaim,
|
||||
NativeEffectOperation,
|
||||
NativeEffectOutcome,
|
||||
NativeTestInvocation,
|
||||
@@ -183,9 +184,13 @@ def test_reasoning_assessment_cannot_grant_or_hide_opaque_tool_scope() -> None:
|
||||
AgentCallAssessment.model_validate({**assessment.model_dump(), "approved": True})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("boundary", ["core_tool", "model", "shell_run", "bootstrap", "config_pull"])
|
||||
@pytest.mark.parametrize(
|
||||
("boundary", "destructive"),
|
||||
[(boundary, False) for boundary in ("core_tool", "model", "shell_run", "bootstrap", "config_pull")]
|
||||
+ [(boundary, True) for boundary in ("shell_run", "bootstrap", "config_pull")],
|
||||
)
|
||||
def test_prepare_actual_call_freezes_builder_reasoning_without_business_or_oauth(
|
||||
monkeypatch: pytest.MonkeyPatch, boundary: str, operations_service: AgentOperationService
|
||||
monkeypatch: pytest.MonkeyPatch, boundary: str, destructive: bool, operations_service: AgentOperationService
|
||||
) -> None:
|
||||
from contextlib import contextmanager
|
||||
from datetime import UTC, datetime
|
||||
@@ -263,7 +268,7 @@ def test_prepare_actual_call_freezes_builder_reasoning_without_business_or_oauth
|
||||
"target_summary": {"tool": "write"},
|
||||
"parameters_summary": {"enabled": True},
|
||||
"risk_notice": "Creates one test value.",
|
||||
"destructive": False,
|
||||
"destructive": destructive,
|
||||
}
|
||||
from runtime_contracts.builder.native.agent_contracts import (
|
||||
AgentParentNodeRef,
|
||||
@@ -389,7 +394,9 @@ def test_prepare_actual_call_freezes_builder_reasoning_without_business_or_oauth
|
||||
assert capability.execution_context is not None
|
||||
assert capability.execution_context["initial_working_directory"] == "request_owned_agent_workspace"
|
||||
assert capability.execution_context["may_contain_cloned_files"] is True
|
||||
assert capability.execution_context["filesystem_confinement_guaranteed"] is False
|
||||
assert "filesystem_confinement_guaranteed" not in capability.execution_context
|
||||
assert capability.execution_context["initial_working_directory_is_confinement"] is False
|
||||
assert capability.execution_context["runtime_filesystem_isolation"] == "not_attested_to_assessor"
|
||||
asset_cli = capability.execution_context["configured_asset_cli"]
|
||||
assert isinstance(asset_cli, dict)
|
||||
assert asset_cli["may_overwrite_local_copies"] is True
|
||||
@@ -407,6 +414,25 @@ def test_prepare_actual_call_freezes_builder_reasoning_without_business_or_oauth
|
||||
assert "parameters" not in binding
|
||||
assert result.key.occurrence_path == key.occurrence_path
|
||||
|
||||
if destructive:
|
||||
from dify_agent.protocol.native_testing import AgentPolicyStop
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from models.dify_builder_native_tests import NativeEffectAttempt
|
||||
|
||||
# Encrypted payload I/O is the explicit boundary; SQL identity,
|
||||
# saved verdict and the before-call reservation gate remain real.
|
||||
monkeypatch.setattr(service.state_store, "_read_object", lambda _receipt: payload)
|
||||
owned.bindings.update(persisted.bindings)
|
||||
decision = service.bridge.before_call(
|
||||
ref=AgentNativeTestRef.model_validate(ref.model_dump(mode="json")), intent=result
|
||||
)
|
||||
assert isinstance(decision, AgentPolicyStop)
|
||||
assert decision.reason_code == "destructive_effect_forbidden"
|
||||
with service.loader.session_factory() as session:
|
||||
for model in (NativeEffectOperation, NativeEffectAttempt, NativeEffectDispatchClaim):
|
||||
assert session.scalar(select(func.count()).select_from(model)) == 0
|
||||
|
||||
|
||||
def test_only_the_selected_confirmed_model_can_use_the_outer_task_grant() -> None:
|
||||
from services.dify_builder.native_testing.authorization import task_grant_allowed
|
||||
|
||||
@@ -79,6 +79,7 @@ def _receipt(status: str = "running", version: int = 1) -> NativeTestPublicRecei
|
||||
class _FrameData(TypedDict):
|
||||
receipt: dict[str, str]
|
||||
conversation_last_seq: int
|
||||
test_execution: dict[str, object]
|
||||
|
||||
|
||||
class _DecodedFrame(TypedDict):
|
||||
@@ -779,3 +780,173 @@ def test_native_model_diagnostic_owner_drift(completed: CompletedOwner, drift: s
|
||||
assert observed[0]["native_category"] == "timeout"
|
||||
else:
|
||||
assert observed == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"boundary",
|
||||
["startup", "ack_context", "launched", "cancelled", "expired", "owner", "generation", "inactive", "other_policy"],
|
||||
)
|
||||
def test_startup_stream_defers_only_until_sql_root_initialization(
|
||||
completed: CompletedOwner, monkeypatch: pytest.MonkeyPatch, boundary: str
|
||||
) -> None:
|
||||
|
||||
from sqlalchemy import Table
|
||||
from sqlalchemy.orm import make_transient
|
||||
|
||||
from models.dify_builder import DifyBuilderExecutionRequest
|
||||
from services.dify_builder.service import DifyBuilderService
|
||||
from services.dify_builder.test_actions import BuilderTestActions
|
||||
from tests.unit_tests.services.dify_builder.test_service import FakeSessionLock
|
||||
|
||||
owner = completed
|
||||
_restore_public_reads(owner, monkeypatch)
|
||||
with owner.factory.begin() as sql:
|
||||
for model in (NativePayloadObject, NativeTestInvocation):
|
||||
table = model.__table__
|
||||
assert isinstance(table, Table)
|
||||
table.create(sql.connection(), checkfirst=True)
|
||||
root = sql.scalar(select(NativeInvocationState))
|
||||
request = sql.get(DifyBuilderExecutionRequest, owner.request_id)
|
||||
scope = sql.get(NativeTestScope, owner.request_id)
|
||||
assert root is not None
|
||||
assert request is not None
|
||||
assert scope is not None
|
||||
sql.expunge(root)
|
||||
make_transient(root)
|
||||
state_table = NativeInvocationState.__table__
|
||||
assert isinstance(state_table, Table)
|
||||
sql.execute(state_table.delete())
|
||||
request.state, request.native_run_id = "running", None
|
||||
request.observations = [
|
||||
{
|
||||
"native_command_key": "start-key",
|
||||
"body_digest": "a" * 64,
|
||||
"receipt": _receipt("prepared")
|
||||
.model_copy(update={"request_id": owner.request_id})
|
||||
.model_dump(mode="json"),
|
||||
}
|
||||
]
|
||||
scope.generation = 0
|
||||
sql.add(
|
||||
NativeTestInvocation(
|
||||
tenant_id=owner.case.actor.tenant_id,
|
||||
request_id=owner.request_id,
|
||||
invocation_id="root",
|
||||
active=True,
|
||||
bindings={},
|
||||
)
|
||||
)
|
||||
with owner.factory.begin() as sql:
|
||||
request = sql.get(DifyBuilderExecutionRequest, owner.request_id)
|
||||
scope = sql.get(NativeTestScope, owner.request_id)
|
||||
invocation = sql.scalar(select(NativeTestInvocation))
|
||||
assert request is not None
|
||||
assert scope is not None
|
||||
assert invocation is not None
|
||||
if boundary == "ack_context":
|
||||
sql.add(
|
||||
NativePayloadObject(
|
||||
id="context",
|
||||
tenant_id=owner.case.actor.tenant_id,
|
||||
request_id=owner.request_id,
|
||||
invocation_id="root",
|
||||
kind="context",
|
||||
logical_key="context",
|
||||
storage_key="context-storage",
|
||||
payload_digest="a" * 64,
|
||||
value_digest="b" * 64,
|
||||
)
|
||||
)
|
||||
elif boundary == "launched":
|
||||
request.observations = [{**request.observations[0], "admission_launched": True}]
|
||||
elif boundary == "cancelled":
|
||||
scope.cancelled = True
|
||||
elif boundary == "expired":
|
||||
scope.expires_at = datetime(2000, 1, 1, tzinfo=UTC)
|
||||
elif boundary == "owner":
|
||||
request.actor_id = "foreign"
|
||||
elif boundary == "generation":
|
||||
scope.generation = 1
|
||||
elif boundary == "inactive":
|
||||
invocation.active = False
|
||||
elif boundary == "other_policy":
|
||||
request.context_digest = "f" * 64
|
||||
from services.dify_builder.native_testing.confirmed_agent_commands import ProfiledNativeTestCommands
|
||||
|
||||
commands = ProfiledNativeTestCommands(
|
||||
sessions=owner.factory,
|
||||
actor=owner.case.actor,
|
||||
confirmed_factory=lambda: owner.commands,
|
||||
legacy_factory=lambda: pytest.fail("unexpected legacy profile"),
|
||||
)
|
||||
reader = BuilderTestActions(owner.factory, lambda **_: commands)
|
||||
service = DifyBuilderService(
|
||||
owner.repo,
|
||||
FakeSessionLock(),
|
||||
lambda *_: pytest.fail("observer dispatched"),
|
||||
get_app_revision_fn=lambda *_: "fixture-revision",
|
||||
test_actions=reader,
|
||||
)
|
||||
if boundary != "startup":
|
||||
assert not reader.observation_initializing(
|
||||
actor=owner.case.actor, session_id=owner.session.id, test_input_id=owner.saved_id
|
||||
)
|
||||
from runtime_contracts.builder.execution import BuilderExecutionPolicyError
|
||||
|
||||
with pytest.raises(BuilderExecutionPolicyError):
|
||||
owner.commands.api.get(actor=owner.case.actor, session_id=owner.session.id, request_id=owner.request_id)
|
||||
if boundary != "owner":
|
||||
subscription = _subscription(
|
||||
monkeypatch, [json.dumps({"kind": "native_state_changed", "session_id": owner.session.id}).encode()]
|
||||
)
|
||||
frames = list(
|
||||
wiring.stream_advance_frames(
|
||||
{**_view(), "session_id": owner.session.id},
|
||||
subscription,
|
||||
True,
|
||||
read_session_view=lambda: service._read_stream_session_view(owner.session.id, owner.case.actor),
|
||||
)
|
||||
)
|
||||
assert [_decode(frame)["event"] for frame in frames] == ["command_started", "error"]
|
||||
_assert_closed(subscription)
|
||||
return
|
||||
# Ordinary authenticated reads stay strict even during this startup window.
|
||||
from runtime_contracts.builder.execution import BuilderExecutionPolicyError
|
||||
|
||||
with pytest.raises(BuilderExecutionPolicyError, match="native_state_unavailable"):
|
||||
service.get_session_view(owner.session.id, owner.case.actor)
|
||||
reads = 0
|
||||
|
||||
def read_view() -> dict[str, object] | None:
|
||||
nonlocal reads
|
||||
reads += 1
|
||||
result = service._read_stream_session_view(owner.session.id, owner.case.actor)
|
||||
if reads == 1:
|
||||
assert result is None
|
||||
with owner.factory.begin() as sql:
|
||||
sql.add(root)
|
||||
request = sql.get(DifyBuilderExecutionRequest, owner.request_id)
|
||||
scope = sql.get(NativeTestScope, owner.request_id)
|
||||
assert request is not None
|
||||
assert scope is not None
|
||||
request.state, request.native_run_id = "sealed", owner.native_id
|
||||
scope.generation = 1
|
||||
return result
|
||||
|
||||
notification = json.dumps({"kind": "native_state_changed", "session_id": owner.session.id}).encode()
|
||||
subscription = _subscription(monkeypatch, [notification, notification])
|
||||
frames = list(
|
||||
wiring.stream_advance_frames(
|
||||
{**_view(), "session_id": owner.session.id},
|
||||
subscription,
|
||||
True,
|
||||
read_session_view=read_view,
|
||||
command_id="original-command",
|
||||
)
|
||||
)
|
||||
assert frames[1] == ": keep-alive\n\n"
|
||||
events = [_decode(frame) for frame in frames if not frame.startswith(":")]
|
||||
assert [event["event"] for event in events] == ["command_started", "command_finished"]
|
||||
assert events[-1]["data"]["test_execution"]["request_id"] == owner.request_id
|
||||
assert reads == 2
|
||||
_assert_closed(subscription)
|
||||
@@ -430,3 +430,40 @@ def test_observation_uses_original_command_only_for_task_stop(task_paused: bool,
|
||||
assert event["data"]["command_id"] == ("command-1" if task_paused else "command-2")
|
||||
assert "_task_paused" not in event["data"]
|
||||
assert subscription.closed
|
||||
|
||||
|
||||
@pytest.mark.parametrize("native_notification", [False, True])
|
||||
def test_stream_defers_initializing_observation_until_real_view(native_notification: bool) -> None:
|
||||
notification = json.dumps({"kind": "native_state_changed", "session_id": "s1"}).encode()
|
||||
subscription = _FakeSubscription([notification if native_notification else None, notification])
|
||||
views = iter([None, _view(run_status="waiting_input", version=2)])
|
||||
frames = list(
|
||||
wiring.stream_advance_frames(
|
||||
_view(), subscription, True, read_session_view=lambda: next(views), command_id="original-command"
|
||||
)
|
||||
)
|
||||
assert frames[1] == ": keep-alive\n\n"
|
||||
events = [_event(frame) for frame in frames if not frame.startswith(":")]
|
||||
assert [event["event"] for event in events] == ["command_started", "command_finished"]
|
||||
assert events[-1]["data"]["version"] == 2
|
||||
assert subscription.closed
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reason", ["native_state_unavailable", "native_owner_mismatch"])
|
||||
def test_stream_surfaces_projection_policy_refusal_as_terminal_error(reason: str) -> None:
|
||||
from runtime_contracts.builder.execution import BuilderExecutionPolicyError
|
||||
|
||||
subscription = _FakeSubscription([json.dumps({"kind": "native_state_changed", "session_id": "s1"}).encode()])
|
||||
|
||||
def read_view() -> dict[str, object]:
|
||||
raise BuilderExecutionPolicyError(reason)
|
||||
|
||||
frames = list(
|
||||
wiring.stream_advance_frames(
|
||||
_view(), subscription, True, read_session_view=read_view, command_id="original-command"
|
||||
)
|
||||
)
|
||||
events = [_event(frame) for frame in frames]
|
||||
assert [event["event"] for event in events] == ["command_started", "error"]
|
||||
assert events[-1]["data"]["code"] == reason
|
||||
assert subscription.closed
|
||||
@@ -127,6 +127,12 @@ class ActionCommands:
|
||||
assert session_id
|
||||
return self.receipt if test_input_id == "input-1" else None
|
||||
|
||||
def observation_initializing(self, *, actor: Actor, session_id: str, test_input_id: str) -> bool:
|
||||
assert actor.account_id
|
||||
assert session_id
|
||||
assert test_input_id
|
||||
return False
|
||||
|
||||
def current_diagnostics(
|
||||
self, *, actor: Actor, session_id: str, test_input_id: str, receipt: NativeTestPublicReceipt
|
||||
) -> list[dict[str, object]]:
|
||||
|
||||
Reference in new issue
Block a user