diff --git a/.github/scripts/builder-confirmed-recovery.py b/.github/scripts/builder-confirmed-recovery.py index 6949de809d9..b1240267ed5 100644 --- a/.github/scripts/builder-confirmed-recovery.py +++ b/.github/scripts/builder-confirmed-recovery.py @@ -26,6 +26,7 @@ EXPECTED = { }, "tests.integration_tests.services.dify_builder.test_confirmed_snapshot_owner": { "test_actual_owned_initial_capture_and_store": 1, + "test_enterprise_absence_keeps_protected_recovery_eligibility_without_grants": 1, "test_confirmed_node_captures_before_stream_and_persists_both_terminal_branches": 4, "test_recovery_config_payload_wraps_acknowledged_random_receipt": 1, "test_actual_acknowledged_successor_graph_finalizes": 1, diff --git a/.github/workflows/builder-safe-execution-tests.yml b/.github/workflows/builder-safe-execution-tests.yml index 8b70ad4c0d9..ec14177b469 100644 --- a/.github/workflows/builder-safe-execution-tests.yml +++ b/.github/workflows/builder-safe-execution-tests.yml @@ -44,6 +44,7 @@ on: - 'api/tests/fixtures/workflow/builder_restricted/**' - '.github/workflows/builder-safe-execution-tests.yml' - '.github/scripts/builder-confirmed-tool-units.py' + - '.github/scripts/builder-confirmed-recovery.py' # Confirmed Tool owners: controllers, Tool/OAuth/plugin, config, credentials, worker and sweeper. - 'api/controllers/console/dify_builder*.py' - 'api/controllers/console/wraps.py' @@ -1013,6 +1014,15 @@ jobs: } }.items(): EXPECTED.setdefault(module, {}).update(functions) + # Qualified exact Enterprise absence: real HTTP and spoofed-evidence coverage. + EXPECTED["tests.local.dify_agent.server.test_binding_inspection"].update({ + "test_enterprise_absence_evidence_requires_coupled_refs": 1, + "test_enterprise_inspection_has_one_shared_total_deadline": 2, + "test_enterprise_inspection_keeps_one_escaped_control_plane_identifier": 3, + "test_enterprise_inspection_refuses_ambiguous_identifiers_without_http": 9, + "test_enterprise_inspection_requires_exact_control_plane_absence": 20, + "test_inspection_rejects_spoofed_cross_adapter_absence": 5 + }) REPORT = "builder-native-agent.xml" code = pytest.main(['tests/local/dify_agent/protocol/test_confirmed_recovery.py', 'tests/local/dify_agent/runtime/test_confirmed_recovery.py', 'tests/local/dify_agent/runtime_backend/test_binding_inspection_deadline.py', 'tests/local/dify_agent/server/test_binding_inspection.py', 'tests/local/dify_agent/protocol/test_shell_job_receipt.py', 'tests/local/dify_agent/server/test_binding_job_status.py', 'tests/local/dify_agent/layers/shell/test_layer.py', 'tests/local/dify_agent/client/test_run_recovery.py', 'tests/local/dify_agent/protocol/test_run_recovery.py', 'tests/local/dify_agent/runtime/test_confirmed_continuation_owner.py', 'tests/local/dify_agent/runtime/test_native_lost_ack.py', 'tests/local/dify_agent/runtime/test_run_recovery.py', 'tests/local/dify_agent/server/test_identity_profile.py', 'tests/local/dify_agent/server/test_run_recovery.py', 'tests/local/dify_agent/storage/test_run_recovery.py', 'tests/local/dify_agent/agent_stub/server/test_runtime_admission.py', 'tests/integration/dify_agent/storage/test_active_stub_identity.py', 'tests/local/dify_agent/adapters/native_testing/test_producers.py', 'tests/local/dify_agent/adapters/native_testing/test_transport.py', 'tests/local/dify_agent/layers/dify_plugin/test_layers.py', 'tests/local/dify_agent/layers/knowledge/test_layer.py', 'tests/local/dify_agent/runtime/test_run_transport.py', 'tests/local/dify_agent/server/test_app.py', 'tests/local/dify_agent/server/test_runtime_transport.py', 'tests/local/dify_agent/protocol/test_initial_preparation.py', 'tests/local/dify_agent/server/test_initial_identity.py', 'tests/local/dify_agent/protocol/test_native_testing.py', 'tests/local/dify_agent/runtime/test_native_continuation.py', 'tests/local/dify_agent/storage/test_native_continuation_store.py', 'tests/local/dify_agent/runtime/test_native_effects.py', 'tests/local/dify_agent/protocol/test_native_checkpoint_readback.py', 'tests/local/dify_agent/server/test_native_checkpoint_readback.py', '-vv', '-rA', '--tb=long', '--junitxml=builder-native-agent.xml']) problems = [] diff --git a/api/services/dify_builder/native_testing/confirmed_agent_commands.py b/api/services/dify_builder/native_testing/confirmed_agent_commands.py index ef011146049..245d5659d58 100644 --- a/api/services/dify_builder/native_testing/confirmed_agent_commands.py +++ b/api/services/dify_builder/native_testing/confirmed_agent_commands.py @@ -267,7 +267,7 @@ class ConfirmedAgentCommands: ) def reserve_confirmed_recovery_attempt(self, *, ref, expected_parent_generation, idempotency_key): - """Prove exact Local loss and reserve once for an authenticated interaction. + """Prove exact retained-resource absence for an authenticated interaction. Uses existing durable parent pause, current checkpoint, protected owner and SDK pure validator; inspection/crypto/RBAC occur outside SQL writers. @@ -332,11 +332,9 @@ class ConfirmedAgentCommands: ) if inspection.state == "usable": return None - if (inspection.adapter, inspection.state, inspection.evidence_code) != ( - "local", - "definitively_gone", - "path_absent", - ): + # The validated DTO admits only exact Local/path_absent or Enterprise/ + # sandbox_not_found evidence; absence alone does not grant replacement. + if inspection.state != "definitively_gone": raise BuilderExecutionPolicyError("native_agent_recovery_absence_unproven") try: validate_recovery_eligibility( diff --git a/api/services/dify_builder/native_testing/state_store.py b/api/services/dify_builder/native_testing/state_store.py index 51fd3d9b579..6a5f23d8912 100644 --- a/api/services/dify_builder/native_testing/state_store.py +++ b/api/services/dify_builder/native_testing/state_store.py @@ -337,9 +337,9 @@ class NativeExecutionStateStore: association.agent_run_id, inspection.binding_ref, inspection.workspace_ref, - inspection.adapter, + # The DTO validates the exact adapter/evidence pair before this + # complete protected predecessor identity comparison. inspection.state, - inspection.evidence_code, ) != ( expected.predecessor, expected.identity.parent_execution_id, @@ -353,9 +353,7 @@ class NativeExecutionStateStore: evidence["agent_run_id"], predecessor.binding_ref, predecessor.workspace_ref, - "local", "definitively_gone", - "path_absent", ): raise BuilderExecutionPolicyError("native_agent_recovery_conflict") checkpoint = self._agent_wait_relation(prepared, association, wait) diff --git a/api/tests/integration_tests/services/dify_builder/test_confirmed_snapshot_owner.py b/api/tests/integration_tests/services/dify_builder/test_confirmed_snapshot_owner.py index 874aba35d88..c763495e799 100644 --- a/api/tests/integration_tests/services/dify_builder/test_confirmed_snapshot_owner.py +++ b/api/tests/integration_tests/services/dify_builder/test_confirmed_snapshot_owner.py @@ -26,7 +26,7 @@ from contextlib import ExitStack from dataclasses import asdict, dataclass from datetime import UTC, datetime, timedelta from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Literal from uuid import uuid4 import httpx @@ -165,6 +165,14 @@ def pinned_home(request: pytest.FixtureRequest) -> bool: return request.param +@pytest.fixture(params=["local"]) +def inspection_adapter(request: pytest.FixtureRequest) -> Literal["local", "enterprise"]: + if request.param == "enterprise": + return "enterprise" + assert request.param == "local" + return "local" + + @pytest.fixture def protected_owner( ephemeral_postgres: Engine, @@ -172,6 +180,7 @@ def protected_owner( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, pinned_home: bool, + inspection_adapter: Literal["local", "enterprise"], ) -> Generator[ProtectedOwner]: """Compose real SQL owner, FS storage and RSA provider; restore globals on exit.""" from sqlalchemy import Table @@ -285,7 +294,9 @@ def protected_owner( binding_id=binding_id, workspace_id=workspace_id, backend_binding_ref="binding-" + binding_id, - backend_workspace_ref="workspace-" + workspace_id, + backend_workspace_ref=("binding-" + binding_id) + if inspection_adapter == "enterprise" + else "workspace-" + workspace_id, pinned_snapshot_id=pin_id, home_snapshot_id=home_id, workspace_generation=0, @@ -1177,6 +1188,7 @@ def allocated_successor( substitution: str, model_pending: bool = False, core_tool_pending: bool = False, + inspection_adapter: Literal["local", "enterprise"] = "local", ) -> AllocatedRecovery: """Acknowledge actual immutable graph bytes for the unactivated finalizer only. @@ -1301,9 +1313,9 @@ def allocated_successor( "inspection": { "binding_ref": workspace.backend_binding_ref, "workspace_ref": workspace.backend_workspace_ref, - "adapter": "local", + "adapter": inspection_adapter, "state": "definitively_gone", - "evidence_code": "path_absent", + "evidence_code": "sandbox_not_found" if inspection_adapter == "enterprise" else "path_absent", }, "agent_run_id": runtime.run_id, "pending_ids": [pending_id], @@ -4068,3 +4080,26 @@ def test_recovery_denial_preserves_existing_decision_idempotency( assert scope is not None assert not scope.cancelled assert runtime.client.get_run_sync(runtime.run_id).status == "waiting" + + +@pytest.mark.parametrize("inspection_adapter", ["enterprise"], indirect=True) +def test_enterprise_absence_keeps_protected_recovery_eligibility_without_grants( + protected_owner: ProtectedOwner, runtime_owner: RuntimeOwner +) -> None: + """Seeded allocation evidence exercises real protected reload, not Dev recovery.""" + owner, runtime = protected_owner, runtime_owner + allocated = allocated_successor( + owner, runtime, substitution="none", model_pending=True, inspection_adapter="enterprise" + ) + prepared, evidence = owner.api.store.prepare_recovery_attempt(ref=owner.parent.ref, expected=allocated.expected) + assert prepared.workspace.backend_binding_ref == prepared.workspace.backend_workspace_ref + assert evidence["inspection"] == { + "binding_ref": prepared.workspace.backend_binding_ref, + "workspace_ref": prepared.workspace.backend_workspace_ref, + "adapter": "enterprise", + "state": "definitively_gone", + "evidence_code": "sandbox_not_found", + } + with owner.api.sessions() as session: + assert session.scalar(select(NativeEffectGrant.id)) is None + assert session.scalar(select(NativeEffectAttempt.id)) is None diff --git a/dify-agent/docs/dify-agent/index.md b/dify-agent/docs/dify-agent/index.md index 384f2275f9f..0ca2c56701b 100644 --- a/dify-agent/docs/dify-agent/index.md +++ b/dify-agent/docs/dify-agent/index.md @@ -129,12 +129,22 @@ fixed phase/category/status fields, without raw exceptions, paths or tracebacks. A timed-out probe never proves remote process termination. Inspection never acquires, creates or destroys a Binding or Workspace. -Enterprise inspects only the existing sandbox's health endpoint and requires an -explicit healthy response. No currently established exact sandbox endpoint -proves absence, so every health failure, including generic or sandbox-expired -404 responses, stays uncertain. E2B, OpenShell and native test scopes are -unsupported. Keep-alive's legacy 410 is not inspection evidence. Inspection is -absent in anonymous migration mode. The shared confirmed-recovery relation and +Enterprise first reads the existing sandbox's health endpoint; an exact healthy +response returns immediately. Otherwise one GET of the exact escaped retained ID +at `/v1/sandboxes/{id}` uses the same existing Gateway authentication and total +five-second budget (or the shorter configured proxy timeout). Only HTTP 404 with +integer `code: 404` and exact `reason: sandbox_not_found` in an unambiguous bounded +error object yields `definitively_gone` / `sandbox_not_found`. Generic/proxy/job +404, sandbox-expired, auth/transport errors, redirects and malformed/oversized or +foreign responses remain uncertain. Ambiguous path identifiers are refused. +Both reads cap response bodies at 4096 bytes and perform no retries. This is the +Gateway's exact control-plane resource absence contract, backed by its initialized +informer cache; it does not prove physical process death or absence of effects. +Replacement still requires all protected waiting-child, scope, generation, +reconstructibility, no-in-flight/no-unknown and fresh-consent checks. E2B, +OpenShell and native test scopes remain unsupported. Keep-alive's legacy 410 is +not inspection evidence. Inspection is absent in anonymous migration mode. +The shared confirmed-recovery relation and handoff DTOs describe protected predecessor/successor refs and generations only; parsing them performs no allocation, CAS, resume, model or tool invocation. diff --git a/dify-agent/src/dify_agent/protocol/execution_binding.py b/dify-agent/src/dify_agent/protocol/execution_binding.py index 824a4e3a30c..c563aa141df 100644 --- a/dify-agent/src/dify_agent/protocol/execution_binding.py +++ b/dify-agent/src/dify_agent/protocol/execution_binding.py @@ -97,13 +97,14 @@ class InspectExecutionBindingRequest(NativeDTO): class InspectExecutionBindingResponse(InspectExecutionBindingRequest): - """Bounded adapter evidence; only exact Local absence can authorize recovery.""" + """Bounded adapter evidence; absence alone never authorizes recovery.""" adapter: Literal["local", "enterprise", "unsupported"] state: Literal["usable", "definitively_gone", "uncertain", "unsupported"] evidence_code: Literal[ "paths_present", "path_absent", + "sandbox_not_found", "root_unavailable", "path_ambiguous", "identity_mismatch", @@ -121,6 +122,11 @@ class InspectExecutionBindingResponse(InspectExecutionBindingRequest): (self.adapter == "local" and self.state == "usable" and self.evidence_code == "paths_present") or (self.adapter == "local" and self.state == "definitively_gone" and self.evidence_code == "path_absent") or (self.adapter == "enterprise" and self.state == "usable" and self.evidence_code == "health_ok") + or ( + self.adapter == "enterprise" + and self.state == "definitively_gone" + and self.evidence_code == "sandbox_not_found" + ) or ( self.adapter == "unsupported" and self.state == "unsupported" @@ -140,6 +146,8 @@ class InspectExecutionBindingResponse(InspectExecutionBindingRequest): } ) ) + if self.adapter == "enterprise" and self.state in {"usable", "definitively_gone"}: + valid = valid and self.binding_ref == self.workspace_ref if not valid: raise ValueError("incoherent binding inspection evidence") return self diff --git a/dify-agent/src/dify_agent/runtime_backend/enterprise.py b/dify-agent/src/dify_agent/runtime_backend/enterprise.py index d1fe6e8440e..80fb7517c3a 100644 --- a/dify-agent/src/dify_agent/runtime_backend/enterprise.py +++ b/dify-agent/src/dify_agent/runtime_backend/enterprise.py @@ -10,9 +10,11 @@ routed through the Gateway's shellctl proxy. from __future__ import annotations +import asyncio from contextlib import suppress from dataclasses import dataclass, field from http import HTTPStatus +import json import logging import shlex from typing import cast @@ -107,6 +109,38 @@ async def _gateway_request( return payload if isinstance(payload, dict) else None +async def _read_inspection_response( + client: httpx.AsyncClient, path: str, *, headers: dict[str, str] | None = None +) -> tuple[int, bytes] | None: + """Read at most 4096 response bytes inside the caller's absolute deadline. + + Inputs are fixed inspection routes and existing auth headers. Overflow closes + the response and returns no evidence; transport failures propagate privately. + """ + async with client.stream("GET", path, headers=headers) as response: + body = bytearray() + async for chunk in response.aiter_bytes(): + if len(body) + len(chunk) > 4096: + return None + body.extend(chunk) + return response.status_code, bytes(body) + + +def _inspection_payload(body: bytes) -> dict[str, object] | None: + """Decode one bounded unambiguous object; malformed evidence remains absent.""" + + def unique_fields(pairs: list[tuple[str, object]]) -> dict[str, object]: + if len({key for key, _ in pairs}) != len(pairs): + raise ValueError("ambiguous inspection response") + return dict(pairs) + + try: + payload: object = json.loads(body, object_pairs_hook=unique_fields) + except (ValueError, UnicodeError): + return None + return payload if isinstance(payload, dict) else None + + @dataclass(slots=True) class EnterpriseHomeSnapshotBackend: """Manage immutable Home Snapshots through the Gateway's snapshot endpoints.""" @@ -240,33 +274,66 @@ class EnterpriseExecutionBindingBackend: raise async def inspect_binding(self, request: InspectExecutionBindingRequest) -> InspectExecutionBindingResponse: - """GET exact sandbox health without acquire, retry, redirect or refresh. + """Inspect exact retained refs with one shared bounded read-only I/O budget. - Preconditions: ordinary coupled Binding/Workspace refs must agree. - Postconditions: only strict healthy output proves usability; no existing - documented resource-specific absence endpoint is available here, so all - failures (including sandbox_expired from the proxy) remain uncertain. + Preconditions: ordinary coupled refs identify the protected predecessor. + Postconditions: exact healthy output is usable; only the dedicated exact + sandbox GET's typed 404/code/reason proves control-plane resource absence. + The Gateway's initialized informer is not a physical-process/no-effect + guarantee. Recovery still requires the independent protected wait proof. + No lifecycle call, redirect or retry occurs; every ambiguity is uncertain. """ selection = request.model_dump() - if request.binding_ref != request.workspace_ref or request.binding_ref.startswith("native:"): + ref = request.binding_ref + if ( + ref != request.workspace_ref + or ref.startswith("native:") + or ref in {".", ".."} + or any(char in "/\\%" or ord(char) < 32 or ord(char) == 127 for char in ref) + ): return InspectExecutionBindingResponse( **selection, adapter="enterprise", state="uncertain", evidence_code="identity_mismatch" ) + budget = min(self.proxy_timeout, 5.0) try: - headers = {"X-Sandbox-Id": request.binding_ref, "X-Inner-Api-Key": self.auth_token} - async with httpx.AsyncClient( - base_url=self.gateway_endpoint.rstrip("/"), - headers=headers, - timeout=min(self.proxy_timeout, 5.0), - follow_redirects=False, - ) as client: - response = await client.get("/proxy/healthz") - if response.status_code == 200 and response.content and len(response.content) <= 4096: - payload: object = response.json() - if payload == {"status": "ok"}: - return InspectExecutionBindingResponse( - **selection, adapter="enterprise", state="usable", evidence_code="health_ok" - ) + async with asyncio.timeout(budget): + async with httpx.AsyncClient( + base_url=self.gateway_endpoint.rstrip("/"), + headers={"X-Inner-Api-Key": self.auth_token}, + timeout=budget, + follow_redirects=False, + ) as client: + try: + health = await _read_inspection_response( + client, "/proxy/healthz", headers={"X-Sandbox-Id": ref} + ) + if ( + health is not None + and health[0] == 200 + and _inspection_payload(health[1]) == {"status": "ok"} + ): + return InspectExecutionBindingResponse( + **selection, adapter="enterprise", state="usable", evidence_code="health_ok" + ) + except Exception: + pass + control = await _read_inspection_response(client, f"/v1/sandboxes/{quote(ref, safe='')}") + if control is not None and control[0] == 404: + payload = _inspection_payload(control[1]) + if ( + payload is not None + and set(payload) <= {"code", "reason", "message"} + and type(payload.get("code")) is int + and payload["code"] == 404 + and payload.get("reason") == "sandbox_not_found" + and ("message" not in payload or isinstance(payload["message"], str)) + ): + return InspectExecutionBindingResponse( + **selection, + adapter="enterprise", + state="definitively_gone", + evidence_code="sandbox_not_found", + ) except Exception: pass return InspectExecutionBindingResponse( 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 02525634fa8..0427fc15a5e 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 @@ -1,5 +1,7 @@ """Read-only evidence must never turn ambiguous health into recovery authority.""" +from collections.abc import Generator +from contextlib import contextmanager import json import subprocess import sys @@ -93,7 +95,11 @@ async def test_enterprise_health_is_never_authoritative_absence(state: str) -> N assert result.state == ("usable" if state == "ok" else "uncertain") assert result.binding_ref == result.workspace_ref == "sandbox" assert "private" not in result.model_dump_json() - assert requests == [("GET", "/proxy/healthz")] + assert requests == ( + [("GET", "/proxy/healthz")] + if state == "ok" + else [("GET", "/proxy/healthz"), ("GET", "/v1/sandboxes/sandbox")] + ) finally: server.shutdown() server.server_close() @@ -348,3 +354,170 @@ async def test_client_rejects_foreign_inspection_identity(mode: str) -> None: server.shutdown() server.server_close() thread.join() + + +@pytest.mark.anyio +@pytest.mark.parametrize( + "status,body,want", + [ + (404, b'{"code":404,"reason":"sandbox_not_found","message":"PRIVATE_UPSTREAM_BODY"}', "definitively_gone"), + (404, b'{"code":404,"reason":"NOT_FOUND"}', "uncertain"), + (404, b'{"code":404,"reason":"sandbox_expired"}', "uncertain"), + (404, b'{"code":404,"reason":"job_not_found"}', "uncertain"), + (404, b'{"code":404,"reason":"route_not_found"}', "uncertain"), + (404, b'{"code":"404","reason":"sandbox_not_found"}', "uncertain"), + (404, b'{"code":true,"reason":"sandbox_not_found"}', "uncertain"), + (404, b'{"reason":"sandbox_not_found"}', "uncertain"), + (200, b'{"code":404,"reason":"sandbox_not_found"}', "uncertain"), + (401, b'{"code":404,"reason":"sandbox_not_found"}', "uncertain"), + (403, b'{"code":404,"reason":"sandbox_not_found"}', "uncertain"), + (500, b'{"code":404,"reason":"sandbox_not_found"}', "uncertain"), + (302, b'{"code":404,"reason":"sandbox_not_found"}', "uncertain"), + (404, b"", "uncertain"), + (404, b"PRIVATE_UPSTREAM_BODY", "uncertain"), + (404, b'{"code":404,"reason":"sandbox_not_found","sandboxId":"foreign"}', "uncertain"), + (404, b'{"code":404,"reason":"sandbox_not_found","metadata":{"sandboxId":"foreign"}}', "uncertain"), + (404, b'{"code":404,"reason":"other","reason":"sandbox_not_found"}', "uncertain"), + (404, b'[["code",404],["reason","sandbox_not_found"]]', "uncertain"), + (404, b'{"code":404,"reason":"sandbox_not_found","message":' + b'"' + b"x" * 4096 + b'"}', "uncertain"), + ], +) +async def test_enterprise_inspection_requires_exact_control_plane_absence( + status: int, body: bytes, want: str, caplog: pytest.LogCaptureFixture +) -> None: + with _inspection_gateway(status=status, body=body) as (endpoint, requests): + backend = EnterpriseExecutionBindingBackend(gateway_endpoint=endpoint, auth_token="PRIVATE_AUTH") + result = await backend.inspect_binding( + protocol.InspectExecutionBindingRequest(binding_ref="sandbox", workspace_ref="sandbox") + ) + assert result.state == want + assert result.evidence_code == ("sandbox_not_found" if want == "definitively_gone" else "health_unavailable") + assert requests == [ + ("/proxy/healthz", "PRIVATE_AUTH"), + ("/v1/sandboxes/sandbox", "PRIVATE_AUTH"), + ] + assert "PRIVATE_AUTH" not in result.model_dump_json() + caplog.text + assert "PRIVATE_UPSTREAM_BODY" not in result.model_dump_json() + caplog.text + + +@pytest.mark.anyio +@pytest.mark.parametrize( + "binding,expected_path", + [ + ("sandbox?x=y#part", "/v1/sandboxes/sandbox%3Fx%3Dy%23part"), + ("sandbox space", "/v1/sandboxes/sandbox%20space"), + ("sandbox:version", "/v1/sandboxes/sandbox%3Aversion"), + ], +) +async def test_enterprise_inspection_keeps_one_escaped_control_plane_identifier( + binding: str, expected_path: str +) -> None: + with _inspection_gateway() as (endpoint, requests): + result = await EnterpriseExecutionBindingBackend( + gateway_endpoint=endpoint, auth_token="PRIVATE_AUTH" + ).inspect_binding(protocol.InspectExecutionBindingRequest(binding_ref=binding, workspace_ref=binding)) + assert result.state == "definitively_gone" + assert requests == [("/proxy/healthz", "PRIVATE_AUTH"), (expected_path, "PRIVATE_AUTH")] + + +@pytest.mark.anyio +@pytest.mark.parametrize("binding", [".", "..", "../foreign", "a/b", "a\\b", "a%2fb", "a\x00b", "a\nb", "native:scope"]) +async def test_enterprise_inspection_refuses_ambiguous_identifiers_without_http(binding: str) -> None: + with _inspection_gateway() as (endpoint, requests): + result = await EnterpriseExecutionBindingBackend( + gateway_endpoint=endpoint, auth_token="PRIVATE_AUTH" + ).inspect_binding(protocol.InspectExecutionBindingRequest(binding_ref=binding, workspace_ref=binding)) + assert (result.state, result.evidence_code) == ("uncertain", "identity_mismatch") + assert requests == [] + + +@pytest.mark.anyio +@pytest.mark.parametrize("health_delay,control_delay,request_count", [(0.15, 0.15, 2), (0.3, 0, 1)]) +async def test_enterprise_inspection_has_one_shared_total_deadline( + health_delay: float, control_delay: float, request_count: int +) -> None: + import asyncio + + with _inspection_gateway(health_delay=health_delay, control_delay=control_delay) as (endpoint, requests): + backend = EnterpriseExecutionBindingBackend( + gateway_endpoint=endpoint, auth_token="PRIVATE_AUTH", proxy_timeout=0.25 + ) + started = asyncio.get_running_loop().time() + result = await backend.inspect_binding( + protocol.InspectExecutionBindingRequest(binding_ref="sandbox", workspace_ref="sandbox") + ) + assert asyncio.get_running_loop().time() - started < 0.4 + assert (result.state, result.evidence_code) == ("uncertain", "health_unavailable") + assert ( + requests == [("/proxy/healthz", "PRIVATE_AUTH"), ("/v1/sandboxes/sandbox", "PRIVATE_AUTH")][:request_count] + ) + + +@pytest.mark.parametrize( + "adapter,state,code", + [ + ("enterprise", "definitively_gone", "path_absent"), + ("local", "definitively_gone", "sandbox_not_found"), + ("enterprise", "usable", "sandbox_not_found"), + ("enterprise", "definitively_gone", "health_unavailable"), + ("unsupported", "definitively_gone", "sandbox_not_found"), + ], +) +def test_inspection_rejects_spoofed_cross_adapter_absence(adapter: str, state: str, code: str) -> None: + with pytest.raises(ValidationError): + protocol.InspectExecutionBindingResponse.model_validate( + dict(binding_ref="sandbox", workspace_ref="sandbox", adapter=adapter, state=state, evidence_code=code) + ) + + +@contextmanager +def _inspection_gateway( + *, + status: int = 404, + body: bytes = b'{"code":404,"reason":"sandbox_not_found"}', + health_delay: float = 0, + control_delay: float = 0, +) -> Generator[tuple[str, list[tuple[str, str | None]]]]: + """Serve real HTTP contract responses without running a sandbox or replacing a client.""" + requests: list[tuple[str, str | None]] = [] + + class Handler(BaseHTTPRequestHandler): + @override + def log_message(self, format: str, *args: object) -> None: + pass + + def do_GET(self) -> None: + requests.append((self.path, self.headers.get("X-Inner-Api-Key"))) + health = self.path == "/proxy/healthz" + threading.Event().wait(health_delay if health else control_delay) + self.send_response(503 if health else status) + self.send_header("Content-Type", "application/json") + self.send_header("Location", "/v1/sandboxes/foreign") + self.end_headers() + try: + self.wfile.write(b'{"status":"degraded"}' if health else body) + except (BrokenPipeError, ConnectionResetError): + pass + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=lambda: server.serve_forever(poll_interval=0.01), daemon=True) + thread.start() + try: + yield f"http://127.0.0.1:{server.server_port}", requests + finally: + server.shutdown() + server.server_close() + thread.join() + + +def test_enterprise_absence_evidence_requires_coupled_refs() -> None: + with pytest.raises(ValidationError): + protocol.InspectExecutionBindingResponse.model_validate( + dict( + binding_ref="sandbox", + workspace_ref="foreign", + adapter="enterprise", + state="definitively_gone", + evidence_code="sandbox_not_found", + ) + )