From bb42181a6d7bc15291bfc62ce5aad9024167aed6 Mon Sep 17 00:00:00 2001 From: lunnynight <2276214182@qq.com> Date: Tue, 14 Jul 2026 13:28:53 +0800 Subject: [PATCH] feat(acquisition): dispatch managed ChatGPT capture --- backend/acquisition/capabilities.py | 16 +++- backend/acquisition/registry.py | 29 ++++++- backend/acquisition/runner.py | 30 +++---- backend/agent_server.py | 21 +++-- backend/browser_pool.py | 61 +++++++++++--- backend/channels/opencli_channel.py | 10 ++- scripts/install-managed-opencli.ps1 | 2 +- scripts/verify_managed_opencli_runtime.py | 2 +- tests/unit/test_acquisition_capabilities.py | 79 +++++++++-------- tests/unit/test_acquisition_runner.py | 93 +++++++++++++++++++-- tests/unit/test_browser_pool.py | 10 +++ tests/unit/test_managed_opencli_verifier.py | 2 +- 12 files changed, 271 insertions(+), 84 deletions(-) diff --git a/backend/acquisition/capabilities.py b/backend/acquisition/capabilities.py index 45d41140..fe31a7bc 100644 --- a/backend/acquisition/capabilities.py +++ b/backend/acquisition/capabilities.py @@ -101,7 +101,7 @@ async def _registration_is_available( return patch_rc != 0 and registration.route_probe_error in patch_output -def _anonymous_profile_available() -> bool: +def _profile_available(profile_kind: str) -> bool: from backend.browser_pool import get_pool try: @@ -109,7 +109,7 @@ def _anonymous_profile_available() -> bool: except RuntimeError: return False return any( - pool.get_profile_kind(endpoint) == "anonymous" for endpoint in pool.endpoints + pool.get_profile_kind(endpoint) == profile_kind for endpoint in pool.endpoints ) @@ -118,11 +118,11 @@ async def probe_capabilities() -> list[CapabilityDescriptor]: if not await _runtime_is_installed(): return [] - ready = _anonymous_profile_available() descriptors = [] for registration in list_capability_registrations(): if not await _registration_is_available(registration): continue + ready = _profile_available(registration.required_profile_kind) descriptors.append( CapabilityDescriptor( capability_id=registration.capability_id, @@ -130,7 +130,15 @@ async def probe_capabilities() -> list[CapabilityDescriptor]: output_schema_version=registration.output_schema_version, ready=ready, runtime=registration.runtime_identity(), - unavailable_reason=None if ready else "no_clean_profile", + unavailable_reason=( + None + if ready + else ( + "no_clean_profile" + if registration.required_profile_kind == "anonymous" + else f"no_{registration.required_profile_kind}_profile" + ) + ), ) ) return descriptors diff --git a/backend/acquisition/registry.py b/backend/acquisition/registry.py index ecaa5bad..48f15cbb 100644 --- a/backend/acquisition/registry.py +++ b/backend/acquisition/registry.py @@ -1,9 +1,11 @@ """Versioned managed-acquisition capabilities backed by real OpenCLI commands.""" from dataclasses import dataclass +from typing import Any -OHMYOPENCLI_COMMIT = "73cc60c83586ef2c95469b3b70d6cfc80fa5bc53" +OHMYOPENCLI_COMMIT = "54f729d6447238918c4098e322d80b1c64ab8a7a" OFFICIAL_SITE_CAPABILITY_COMMIT = "73cc60c83586ef2c95469b3b70d6cfc80fa5bc53" +CHAT_AI_CAPABILITY_COMMIT = "54f729d6447238918c4098e322d80b1c64ab8a7a" OPENCLI_VERSION = "1.8.5" @@ -13,7 +15,7 @@ class CapabilityRegistration: capability_version: str output_schema_version: str source_commit: str - invocation: dict[str, str] + invocation: dict[str, Any] probe_args: tuple[str, ...] help_marker: str route_probe_args: tuple[str, ...] @@ -59,6 +61,29 @@ def runtime_identity(self) -> dict[str, str]: ), route_probe_error="CDP not reachable at http://127.0.0.1:9", ), + CapabilityRegistration( + capability_id="chat-ai.capture", + capability_version="1.0.0", + output_schema_version="1", + source_commit=CHAT_AI_CAPABILITY_COMMIT, + invocation={ + "site": "chatgpt", + "command": "capture", + "format": "json", + "args": {"timeout": 180}, + }, + probe_args=("chatgpt", "capture", "--help"), + help_marker="chat-ai.capture@1", + route_probe_args=( + "chatgpt", + "capture", + "如何选择适合团队的知识库工具?", + "-f", + "json", + ), + route_probe_error="CDP not reachable at http://127.0.0.1:9", + required_profile_kind="authenticated", + ), ) diff --git a/backend/acquisition/runner.py b/backend/acquisition/runner.py index 96a65dbc..d0e444f2 100644 --- a/backend/acquisition/runner.py +++ b/backend/acquisition/runner.py @@ -14,6 +14,7 @@ from backend.browser_pool import ( LocalBrowserPool, NoCleanProfileError, + NoMatchingProfileError, RedisBrowserPool, ) from backend.models.acquisition import AcquisitionExecution, AcquisitionExecutionStatus @@ -301,16 +302,17 @@ async def run_acquisition_execution( from backend.security.url_guard import SSRFValidationError, avalidate_public_url - try: - input_payload["url"] = await avalidate_public_url(input_payload.get("url")) - except SSRFValidationError as exc: - await _fail_execution( - execution_id, - {"code": "ssrf_rejected", "message": str(exc)}, - session_factory, - lease_owner, - ) - return + if "url" in input_payload: + try: + input_payload["url"] = await avalidate_public_url(input_payload["url"]) + except SSRFValidationError as exc: + await _fail_execution( + execution_id, + {"code": "ssrf_rejected", "message": str(exc)}, + session_factory, + lease_owner, + ) + return heartbeat_stop = asyncio.Event() lease_lost = asyncio.Event() @@ -327,7 +329,7 @@ async def run_acquisition_execution( lease_lost_task = None try: pool = await _managed_browser_pool(session_factory) - endpoint = pool.select_anonymous_endpoint() + endpoint = pool.select_endpoint(registration.required_profile_kind) if channel is None: from backend.channels.opencli_channel import OpenCLIChannel @@ -336,7 +338,7 @@ async def run_acquisition_execution( parameters = { **input_payload, "chrome_endpoint": endpoint, - "required_profile_kind": "anonymous", + "required_profile_kind": registration.required_profile_kind, } from backend.config import get_settings @@ -358,7 +360,7 @@ async def run_acquisition_execution( await collection_task return result = await collection_task - except NoCleanProfileError as exc: + except (NoCleanProfileError, NoMatchingProfileError) as exc: await _fail_execution( execution_id, {"code": exc.code, "message": str(exc)}, @@ -499,7 +501,7 @@ async def run_acquisition_execution( ), "browser": { "endpoint": endpoint, - "profile_kind": "anonymous", + "profile_kind": registration.required_profile_kind, }, "channel_metadata": result.metadata, }, diff --git a/backend/agent_server.py b/backend/agent_server.py index d8d886bf..4e387529 100644 --- a/backend/agent_server.py +++ b/backend/agent_server.py @@ -40,7 +40,7 @@ OPENCLI_CDP_BIN Path to opencli 0.9 binary (default: /opt/opencli-cdp/bin/opencli) OPENCLI_CDP_ENDPOINT Default Chrome CDP endpoint (default: http://localhost:19222) OPENCLI_DAEMON_PORT Bridge daemon port (default: 19825) - OPENCLI_TIMEOUT opencli subprocess timeout in seconds (default: 120) + OPENCLI_TIMEOUT opencli subprocess timeout in seconds (default: 240) """ import asyncio @@ -110,7 +110,7 @@ def _resolve_bin(mode: str) -> str: # noqa: ARG001 # off — disable auto-registration entirely _AGENT_REGISTER = os.environ.get("AGENT_REGISTER", "http").lower() # opencli subprocess execution timeout in seconds -_OPENCLI_TIMEOUT = int(os.environ.get("OPENCLI_TIMEOUT", "120")) +_OPENCLI_TIMEOUT = int(os.environ.get("OPENCLI_TIMEOUT", "240")) # Outbound proxy for agent → center communication (optional) _HTTP_PROXY = os.environ.get("HTTP_PROXY") or os.environ.get("http_proxy") or "" _HTTPS_PROXY = os.environ.get("HTTPS_PROXY") or os.environ.get("https_proxy") or "" @@ -165,7 +165,7 @@ async def _kill_process_tree(proc: asyncio.subprocess.Process) -> None: await proc.wait() -async def _runtime_lineage(bin_path: str) -> dict[str, str]: +async def _runtime_lineage(bin_path: str, site: str, command: str) -> dict[str, str]: """Measure the binaries/source used by this node; never echo declarations.""" async def output(*argv: str, cwd: str | None = None) -> str: try: @@ -178,8 +178,9 @@ async def output(*argv: str, cwd: str | None = None) -> str: return "" repo_commit = await output("git", "rev-parse", "HEAD", cwd=_OHMYOPENCLI_ROOT) + adapter_path = f"adapters/{site}/{command}.js" source_commit = await output( - "git", "log", "-1", "--format=%H", "--", "adapters/official-site/observe.js", + "git", "log", "-1", "--format=%H", "--", adapter_path, cwd=_OHMYOPENCLI_ROOT, ) version_text = await output(bin_path, "--version") @@ -662,7 +663,7 @@ async def collect(req: CollectRequest) -> dict: hostname = "host.docker.internal" env.pop("OPENCLI_CDP_ENDPOINT", None) env["OPENCLI_DAEMON_HOST"] = hostname - env["OPENCLI_DAEMON_PORT"] = str(_DAEMON_PORT) + env.pop("OPENCLI_DAEMON_PORT", None) logger.info("bridge | cmd=%s daemon=%s:%s", " ".join(cmd), hostname, _DAEMON_PORT) else: # Same logic for CDP: remap localhost to host.docker.internal only when @@ -695,7 +696,11 @@ async def collect(req: CollectRequest) -> dict: await _kill_process_tree(proc) if mode == "cdp": await _cleanup_cdp_tabs(cdp_ep, pre_tab_ids) - return {"success": False, "items": [], "error": "opencli timed out after 120s"} + return { + "success": False, + "items": [], + "error": f"opencli timed out after {_OPENCLI_TIMEOUT}s", + } except Exception as exc: logger.exception("subprocess error | %s", exc) if mode == "cdp": @@ -725,7 +730,9 @@ async def collect(req: CollectRequest) -> dict: logger.info("done | site=%s cmd=%s items=%d", req.site, req.command, len(items)) trace_match = re.search(r"OpenCLI trace artifact:\s*([^\r\n]+)", stderr_str) - metadata: dict[str, Any] = {"runtime": await _runtime_lineage(bin_path)} + metadata: dict[str, Any] = { + "runtime": await _runtime_lineage(bin_path, req.site, req.command) + } if trace_match: metadata["trace_artifact"] = trace_match.group(1) return { diff --git a/backend/browser_pool.py b/backend/browser_pool.py index 4a029f2b..8fd2f08b 100644 --- a/backend/browser_pool.py +++ b/backend/browser_pool.py @@ -35,6 +35,14 @@ def __init__(self) -> None: super().__init__(self.code) +class NoMatchingProfileError(RuntimeError): + """No browser profile of the capability's required kind is available.""" + + def __init__(self, profile_kind: str) -> None: + self.code = f"no_{profile_kind}_profile" + super().__init__(self.code) + + class LocalBrowserPool: """In-process pool backed by per-endpoint asyncio.Queue slots. @@ -62,10 +70,11 @@ def __init__(self, endpoints: list[str]) -> None: self._agent_protocols: dict[str, str | None] = {ep: None for ep in endpoints} # node_type per endpoint: "docker" (started in container) | "shell" (native process) self._node_types: dict[str, str] = {ep: "docker" for ep in endpoints} - # Fail closed: an endpoint is potentially personalized until an agent - # or operator explicitly registers it as a dedicated anonymous profile. + # Managed acquisition routes only to explicitly classified profiles. + # Merely appearing in a legacy endpoint setting does not certify an + # account-backed session or a dedicated anonymous browser. self._profile_kinds: dict[str, str] = { - ep: "authenticated" for ep in endpoints + ep: "unclassified" for ep in endpoints } logger.info( "BrowserPool (local): %d Chrome instance(s): %s", @@ -143,13 +152,28 @@ def anonymous_endpoints(self) -> list[str]: raise NoCleanProfileError() return candidates - def select_anonymous_endpoint(self) -> str: - candidates = self.anonymous_endpoints() + def profile_endpoints(self, profile_kind: str) -> list[str]: + candidates = [ + endpoint + for endpoint in self.endpoints + if self.get_profile_kind(endpoint) == profile_kind + ] + if not candidates: + if profile_kind == "anonymous": + raise NoCleanProfileError() + raise NoMatchingProfileError(profile_kind) + return candidates + + def select_endpoint(self, profile_kind: str) -> str: + candidates = self.profile_endpoints(profile_kind) return next( (endpoint for endpoint in candidates if self.available_for(endpoint)), candidates[0], ) + def select_anonymous_endpoint(self) -> str: + return self.select_endpoint("anonymous") + async def _acquire_any(self) -> str: """Wait for whichever endpoint slot becomes free first.""" tasks: dict[asyncio.Task[str], str] = { @@ -226,7 +250,7 @@ def set_node_type(self, endpoint: str, node_type: str) -> None: logger.info("BrowserPool: endpoint %s node_type set to %s", endpoint, node_type) def get_profile_kind(self, endpoint: str) -> str: - return self._profile_kinds.get(endpoint, "authenticated") + return self._profile_kinds.get(endpoint, "unclassified") def set_profile_kind(self, endpoint: str, profile_kind: str) -> None: if profile_kind not in {"anonymous", "authenticated"}: @@ -249,7 +273,7 @@ def add_endpoint(self, endpoint: str) -> None: self._agent_urls.setdefault(endpoint, None) self._agent_protocols.setdefault(endpoint, None) self._node_types.setdefault(endpoint, "docker") - self._profile_kinds.setdefault(endpoint, "authenticated") + self._profile_kinds.setdefault(endpoint, "unclassified") self._total += 1 logger.info("BrowserPool: added endpoint %s (total: %d)", endpoint, self._total) @@ -311,7 +335,7 @@ def __init__(self, endpoints: list[str], redis_url: str) -> None: self._total = len(endpoints) self._modes: dict[str, str] = {ep: "bridge" for ep in endpoints} self._profile_kinds: dict[str, str] = { - ep: "authenticated" for ep in endpoints + ep: "unclassified" for ep in endpoints } def _client(self): @@ -349,7 +373,7 @@ async def register_endpoint(self, endpoint: str) -> None: self._endpoints.append(endpoint) self._total += 1 self._modes.setdefault(endpoint, "bridge") - self._profile_kinds.setdefault(endpoint, "authenticated") + self._profile_kinds.setdefault(endpoint, "unclassified") async with self._client() as r: added = await r.sadd(self._REGISTRY_KEY, endpoint) @@ -460,7 +484,7 @@ def set_mode(self, endpoint: str, mode: str) -> None: self._modes[endpoint] = mode def get_profile_kind(self, endpoint: str) -> str: - return self._profile_kinds.get(endpoint, "authenticated") + return self._profile_kinds.get(endpoint, "unclassified") def set_profile_kind(self, endpoint: str, profile_kind: str) -> None: if profile_kind not in {"anonymous", "authenticated"}: @@ -477,8 +501,23 @@ def anonymous_endpoints(self) -> list[str]: raise NoCleanProfileError() return candidates + def profile_endpoints(self, profile_kind: str) -> list[str]: + candidates = [ + endpoint + for endpoint in self.endpoints + if self.get_profile_kind(endpoint) == profile_kind + ] + if not candidates: + if profile_kind == "anonymous": + raise NoCleanProfileError() + raise NoMatchingProfileError(profile_kind) + return candidates + + def select_endpoint(self, profile_kind: str) -> str: + return self.profile_endpoints(profile_kind)[0] + def select_anonymous_endpoint(self) -> str: - return self.anonymous_endpoints()[0] + return self.select_endpoint("anonymous") # ── Module-level singleton ──────────────────────────────────────────────────── diff --git a/backend/channels/opencli_channel.py b/backend/channels/opencli_channel.py index c86c9f8b..5b8a1f4a 100644 --- a/backend/channels/opencli_channel.py +++ b/backend/channels/opencli_channel.py @@ -558,9 +558,13 @@ async def _collect_with_opencli_subprocess( try: returncode, stdout_text, stderr_text = await _run_opencli(cmd, env) except TimeoutError as exc: + from backend.config import get_settings + + timeout = get_settings().opencli_timeout logger.error("opencli timeout | cmd=%s", " ".join(cmd)) return ChannelResult.fail( - "opencli command timed out after 120s", error_type=type(exc).__name__ + f"opencli command timed out after {timeout}s", + error_type=type(exc).__name__, ) except FileNotFoundError as exc: logger.error("opencli binary not found: %s", cmd[0]) @@ -768,7 +772,9 @@ async def collect( daemon_host = urlparse(cdp_endpoint).hostname or "agent-1" env.pop("OPENCLI_CDP_ENDPOINT", None) env["OPENCLI_DAEMON_HOST"] = daemon_host - env["OPENCLI_DAEMON_PORT"] = str(_DAEMON_PORT) + # OpenCLI 1.8.5 fixes Browser Bridge to localhost:19825 and + # rejects the legacy port override even when it is 19825. + env.pop("OPENCLI_DAEMON_PORT", None) logger.info( "opencli bridge | cmd=%s daemon=%s:%s", " ".join(cmd), diff --git a/scripts/install-managed-opencli.ps1 b/scripts/install-managed-opencli.ps1 index 6cbfa09c..13e22d4a 100644 --- a/scripts/install-managed-opencli.ps1 +++ b/scripts/install-managed-opencli.ps1 @@ -9,7 +9,7 @@ param( $ErrorActionPreference = "Stop" $OpenCliVersion = "1.8.5" -$OhMyOpenCliCommit = "73cc60c83586ef2c95469b3b70d6cfc80fa5bc53" +$OhMyOpenCliCommit = "54f729d6447238918c4098e322d80b1c64ab8a7a" $CapabilitySourceCommit = "73cc60c83586ef2c95469b3b70d6cfc80fa5bc53" $requestHeaders = @{} if ($ApiAuthToken) { diff --git a/scripts/verify_managed_opencli_runtime.py b/scripts/verify_managed_opencli_runtime.py index 53dedd56..129a15f3 100644 --- a/scripts/verify_managed_opencli_runtime.py +++ b/scripts/verify_managed_opencli_runtime.py @@ -12,7 +12,7 @@ from pathlib import Path from typing import Any -OHMYOPENCLI_COMMIT = "73cc60c83586ef2c95469b3b70d6cfc80fa5bc53" +OHMYOPENCLI_COMMIT = "54f729d6447238918c4098e322d80b1c64ab8a7a" CAPABILITY_SOURCE_COMMIT = "73cc60c83586ef2c95469b3b70d6cfc80fa5bc53" OPENCLI_VERSION = "1.8.5" diff --git a/tests/unit/test_acquisition_capabilities.py b/tests/unit/test_acquisition_capabilities.py index f484991e..0948c7ca 100644 --- a/tests/unit/test_acquisition_capabilities.py +++ b/tests/unit/test_acquisition_capabilities.py @@ -3,7 +3,10 @@ import pytest -from backend.acquisition.registry import OFFICIAL_SITE_CAPABILITY_COMMIT +from backend.acquisition.registry import ( + CHAT_AI_CAPABILITY_COMMIT, + OFFICIAL_SITE_CAPABILITY_COMMIT, +) from backend.browser_pool import init_pool @@ -54,40 +57,34 @@ async def test_catalog_does_not_publish_unpinned_runtime(monkeypatch): async def test_catalog_reports_runtime_identity_and_clean_profile_readiness(monkeypatch): from backend.acquisition import capabilities - command = AsyncMock( - side_effect=[ - (0, f"{capabilities.OHMYOPENCLI_COMMIT}\n"), - (0, ""), - (0, ""), - (0, "1.8.5\n"), - (0, "official-site observe help"), - (1, "CDP not reachable at http://127.0.0.1:9"), - ] + monkeypatch.setattr( + capabilities, "_runtime_is_installed", AsyncMock(return_value=True) + ) + monkeypatch.setattr( + capabilities, "_registration_is_available", AsyncMock(return_value=True) ) - monkeypatch.setattr(capabilities, "_command", command) pool = init_pool(["http://default-profile:9222"], use_redis=False) + pool.set_profile_kind("http://default-profile:9222", "authenticated") - [descriptor] = await capabilities.probe_capabilities() - assert descriptor.ready is False - assert descriptor.unavailable_reason == "no_clean_profile" - assert descriptor.runtime == { + descriptors = await capabilities.probe_capabilities() + official, chat = descriptors + assert official.ready is False + assert official.unavailable_reason == "no_clean_profile" + assert official.runtime == { "ohmyopencli_repo_commit": capabilities.OHMYOPENCLI_COMMIT, "capability_source_commit": OFFICIAL_SITE_CAPABILITY_COMMIT, "opencli_version": "1.8.5", } + assert chat.ready is True + assert chat.unavailable_reason is None + assert chat.runtime["capability_source_commit"] == CHAT_AI_CAPABILITY_COMMIT pool.set_profile_kind("http://default-profile:9222", "anonymous") - command.side_effect = [ - (0, f"{capabilities.OHMYOPENCLI_COMMIT}\n"), - (0, ""), - (0, ""), - (0, "1.8.5\n"), - (0, "official-site observe help"), - (1, "CDP not reachable at http://127.0.0.1:9"), - ] - [ready] = await capabilities.probe_capabilities() - assert ready.ready is True - assert ready.unavailable_reason is None + official, chat = await capabilities.probe_capabilities() + assert official.ready is True + assert official.unavailable_reason is None + assert chat.ready is False + assert chat.unavailable_reason == "no_authenticated_profile" @pytest.mark.asyncio @@ -101,6 +98,7 @@ async def test_runtime_probe_uses_the_configured_opencli_binary(monkeypatch): (0, f"{capabilities.OHMYOPENCLI_COMMIT}\n"), (0, ""), (0, ""), + (0, ""), (0, "1.8.5\n"), (0, "official-site observe help"), (1, "CDP not reachable at http://127.0.0.1:9"), @@ -109,10 +107,10 @@ async def test_runtime_probe_uses_the_configured_opencli_binary(monkeypatch): monkeypatch.setattr(capabilities, "_command", command) assert await capabilities._runtime_is_installed() is True - assert command.await_args_list[3].args == (configured_bin, "--version") + assert command.await_args_list[4].args == (configured_bin, "--version") registration = capabilities.list_capability_registrations()[0] assert await capabilities._registration_is_available(registration) is True - assert command.await_args_list[4].args == ( + assert command.await_args_list[5].args == ( configured_bin, "official-site", "observe", @@ -128,14 +126,15 @@ async def test_runtime_probe_rejects_tracked_checkout_changes(monkeypatch): side_effect=[ (0, f"{capabilities.OHMYOPENCLI_COMMIT}\n"), (0, ""), + (0, ""), (0, " M adapters/official-site/observe.js\n"), ] ) monkeypatch.setattr(capabilities, "_command", command) assert await capabilities._runtime_is_installed() is False - assert command.await_count == 3 - assert command.await_args_list[2].args[-2:] == ( + assert command.await_count == 4 + assert command.await_args_list[3].args[-2:] == ( "--porcelain", "--untracked-files=no", ) @@ -156,10 +155,13 @@ async def test_catalog_stays_ready_while_anonymous_inventory_is_busy(monkeypatch pool.set_profile_kind(endpoint, "anonymous") async with pool.acquire(): - [descriptor] = await capabilities.probe_capabilities() + descriptors = await capabilities.probe_capabilities() - assert descriptor.ready is True - assert descriptor.unavailable_reason is None + official, chat = descriptors + assert official.ready is True + assert official.unavailable_reason is None + assert chat.ready is False + assert chat.unavailable_reason == "no_authenticated_profile" @pytest.mark.asyncio @@ -191,11 +193,11 @@ async def test_catalog_rejects_opencli_root_help_for_an_unknown_site(monkeypatch monkeypatch.setattr(capabilities, "_command", command) assert await capabilities.probe_capabilities() == [] - command.assert_awaited_once() + assert command.await_count == 2 @pytest.mark.asyncio -async def test_catalog_does_not_invent_chat_ai_capture(monkeypatch): +async def test_catalog_publishes_registered_chat_ai_capture(monkeypatch): from backend.acquisition import capabilities monkeypatch.setattr( @@ -208,10 +210,15 @@ async def test_catalog_does_not_invent_chat_ai_capture(monkeypatch): side_effect=[ (0, "official-site observe help"), (1, "CDP not reachable at http://127.0.0.1:9"), + (0, "Capture one real ChatGPT web answer for chat-ai.capture@1"), + (1, "CDP not reachable at http://127.0.0.1:9"), ] ), ) descriptors = await capabilities.probe_capabilities() - assert [item.capability_id for item in descriptors] == ["official-site.observe"] + assert [item.capability_id for item in descriptors] == [ + "official-site.observe", + "chat-ai.capture", + ] diff --git a/tests/unit/test_acquisition_runner.py b/tests/unit/test_acquisition_runner.py index 6c425b59..59083946 100644 --- a/tests/unit/test_acquisition_runner.py +++ b/tests/unit/test_acquisition_runner.py @@ -41,6 +41,21 @@ def _submission() -> AcquisitionSubmission: ) +def _chat_submission() -> AcquisitionSubmission: + return AcquisitionSubmission.model_validate( + { + "request_id": "request-chat-1", + "idempotency_key": "attempt-chat-1", + "capability": {"id": "chat-ai.capture", "version": "1.0.0"}, + "output_schema_version": "1", + "input": {"prompt": "如何选择适合团队的知识库工具?"}, + "environment": {"locale": "zh-CN", "region": "CN"}, + "required_artifacts": [], + "geo_refs": {"attempt_id": "attempt-chat-1"}, + } + ) + + @pytest.mark.asyncio async def test_official_site_execution_rejects_private_target_before_opencli( db_engine, monkeypatch @@ -405,7 +420,7 @@ async def test_unknown_capability_invocation_fails_closed(db_engine): ) submission_data = _submission().model_dump() submission_data["capability"] = { - "id": "chat-ai.capture", + "id": "unknown.capture", "version": "1.0.0", } submission = AcquisitionSubmission.model_validate(submission_data) @@ -428,24 +443,92 @@ async def test_unknown_capability_invocation_fails_closed(db_engine): assert execution.status == AcquisitionExecutionStatus.FAILED assert execution.failure == { "code": "unsupported_capability_invocation", - "message": "No invocation is registered for chat-ai.capture@1.0.0 schema 1", + "message": "No invocation is registered for unknown.capture@1.0.0 schema 1", } channel.collect.assert_not_awaited() -def test_dispatch_registry_contains_only_real_versioned_capabilities(): +def test_dispatch_registry_contains_real_versioned_capabilities(): from backend.acquisition.registry import list_capability_registrations registrations = list_capability_registrations() assert [registration.identity for registration in registrations] == [ - ("official-site.observe", "1.0.0", "1") + ("official-site.observe", "1.0.0", "1"), + ("chat-ai.capture", "1.0.0", "1"), ] assert registrations[0].invocation == { "site": "official-site", "command": "observe", "format": "json", } + assert registrations[1].invocation == { + "site": "chatgpt", + "command": "capture", + "format": "json", + "args": {"timeout": 180}, + } + assert registrations[1].required_profile_kind == "authenticated" + + +@pytest.mark.asyncio +async def test_chat_capture_routes_only_to_authenticated_profile(db_engine): + from backend.acquisition.runner import run_acquisition_execution + + sessions = async_sessionmaker( + db_engine, class_=AsyncSession, expire_on_commit=False + ) + async with sessions() as db: + outcome = await acquisition_service.submit_execution(db, _chat_submission()) + await acquisition_service.queue_execution(db, outcome.execution) + execution_id = outcome.execution.id + + pool = init_pool( + ["http://anonymous:9222", "http://signed-in:9222"], use_redis=False + ) + pool.set_profile_kind("http://anonymous:9222", "anonymous") + pool.set_profile_kind("http://signed-in:9222", "authenticated") + payload = { + "capabilityId": "chat-ai.capture", + "capabilityVersion": "1.0.0", + "outputSchemaVersion": "1", + "target": "chatgpt", + "prompt": "如何选择适合团队的知识库工具?", + "completionState": "complete", + "answer": {"text": "可以从权限、检索质量和维护成本三个方面比较。"}, + "citations": [], + "displayedUrl": "https://chatgpt.com/c/example", + "finalUrl": "https://chatgpt.com/c/example", + } + channel = AsyncMock() + channel.collect.return_value = ChannelResult.ok([payload]) + + await run_acquisition_execution( + execution_id, session_factory=sessions, channel=channel + ) + + channel.collect.assert_awaited_once_with( + { + "site": "chatgpt", + "command": "capture", + "format": "json", + "args": {"timeout": 180}, + }, + { + "prompt": "如何选择适合团队的知识库工具?", + "chrome_endpoint": "http://signed-in:9222", + "required_profile_kind": "authenticated", + }, + ) + async with sessions() as db: + execution = await acquisition_service.get_execution(db, execution_id) + assert execution is not None + assert execution.status == AcquisitionExecutionStatus.SUCCEEDED + assert execution.result_payload["payload"] == payload + assert execution.result_payload["operational"]["browser"] == { + "endpoint": "http://signed-in:9222", + "profile_kind": "authenticated", + } @pytest.mark.asyncio @@ -617,7 +700,7 @@ async def test_official_site_execution_preserves_payload_in_versioned_envelope( "operational": { "runtime": { "ohmyopencli_repo_commit": ( - "73cc60c83586ef2c95469b3b70d6cfc80fa5bc53" + "54f729d6447238918c4098e322d80b1c64ab8a7a" ), "capability_source_commit": ( "73cc60c83586ef2c95469b3b70d6cfc80fa5bc53" diff --git a/tests/unit/test_browser_pool.py b/tests/unit/test_browser_pool.py index d8493ac6..14ba0b39 100644 --- a/tests/unit/test_browser_pool.py +++ b/tests/unit/test_browser_pool.py @@ -462,6 +462,16 @@ async def test_acquire_anonymous_profile_fails_closed_when_none_is_registered(): pass +def test_unregistered_endpoint_is_not_an_authenticated_capture_profile(): + from backend.browser_pool import LocalBrowserPool, NoMatchingProfileError + + pool = LocalBrowserPool(["http://legacy-default:9222"]) + + assert pool.get_profile_kind("http://legacy-default:9222") == "unclassified" + with pytest.raises(NoMatchingProfileError, match="no_authenticated_profile"): + pool.select_endpoint("authenticated") + + @pytest.mark.asyncio async def test_acquire_anonymous_profile_routes_only_to_explicit_anonymous_profile(): from backend.browser_pool import LocalBrowserPool diff --git a/tests/unit/test_managed_opencli_verifier.py b/tests/unit/test_managed_opencli_verifier.py index 9dd5c2f4..5e441127 100644 --- a/tests/unit/test_managed_opencli_verifier.py +++ b/tests/unit/test_managed_opencli_verifier.py @@ -4,7 +4,7 @@ from scripts.verify_managed_opencli_runtime import VerificationError, verify_runtime -PINNED_COMMIT = "73cc60c83586ef2c95469b3b70d6cfc80fa5bc53" +PINNED_COMMIT = "54f729d6447238918c4098e322d80b1c64ab8a7a" def _completed(args, returncode=0, stdout="", stderr=""):