fix(proxy,tooling): serialize+reconnect Caido client, actionable HTTPQL errors, sandbox tool guidance (#794)

* fix(proxy,tooling): serialize+reconnect Caido client, actionable HTTPQL errors, sandbox tool guidance

Addresses the top recurring agent tool-call failures observed in telemetry:

- proxy: the shared Caido client had no locking or reconnect, so concurrent
  agent calls raced ("Transport is already connected") and a dead transport
  poisoned the rest of the run ("Connector is closed"/"Server disconnected").
  Add an asyncio lock + bounded reconnect in caido_api.call_with_client (sandbox
  path) and a scan-wide caido_lock in the run context that host-side proxy tools
  hold around every call. Deterministic errors are not retried.
- proxy: list_requests now returns Caido's exact parser message, echoes the
  offending query, and includes a corrected-syntax hint so agents self-correct
  instead of retrying a broken HTTPQL filter.
- shell/prompt: document that write_stdin requires a process started with
  tty=true; nudge toward writing Python to a file over deeply-nested one-liners;
  note the venv pre-installs common libs.
- agent-browser: distinguish daemon/connection failures (run doctor, don't loop)
  from malformed commands; invoke directly (no sh -c wrapper).
- containers: use POSIX '.' instead of the bashism 'source' in generated rc
  files (fixes 'sh: source: not found'); add file + xxd and pre-install
  requests/httpx/beautifulsoup4/lxml/pyjwt/cryptography in the sandbox venv.
- tests: cover proxy serialization/reconnect/no-retry and HTTPQL errors.

* fix(proxy): host-side reconnect, close stale clients, don't retry mutations

Addresses Greptile review on the reconnect logic:

- Host path had no reconnect: a dead shared context client (Caido restart /
  network blip) previously disabled proxy tools for the rest of the scan. Add
  SharedCaidoClient, a serialized reconnect-safe holder stored once per scan in
  the run context and shared across agents. On a dead transport it rebuilds via
  reconnect_caido, which re-selects the SAME Caido project (preserving captured
  traffic) instead of creating a new empty one.
- Don't repeat completed mutations: call_with_client / SharedCaidoClient.call
  take idempotent=. Reads retry once on reconnect; replay + scope
  create/update/delete heal the client but re-raise instead of risking a
  double-apply.
- Don't leak replaced clients: the stale client is aclose()d (best-effort) on
  every reconnect.
- Extend tests to cover close-on-reconnect, non-idempotent re-raise, and the
  SharedCaidoClient holder.

* fix(proxy): close replacement Caido client when project.select fails

Addresses Greptile P1: in reconnect_caido (and bootstrap_caido) a successful
connect() followed by a failing project.select()/create() discarded the
connected client without closing it, so a missing/unavailable project could
leak a transport on every retry. Close the client before re-raising.

---------

Co-authored-by: Alex Schapiro <bearsyankees@gmail.com>
This commit is contained in:
devin-ai-integration[bot]
2026-07-17 13:31:57 -04:00
committed by GitHub
co-authored by Alex Schapiro
parent df97c86f8f
commit e4548cb28c
12 changed files with 784 additions and 109 deletions
+59 -9
View File
@@ -10,6 +10,7 @@ exposed-port URL for all subsequent SDK calls.
from __future__ import annotations
import asyncio
import contextlib
import json
import logging
from typing import TYPE_CHECKING
@@ -79,23 +80,72 @@ async def _login_as_guest(
raise RuntimeError(f"loginAsGuest failed after {attempts} attempts: {last_err}")
async def bootstrap_caido(
async def _aclose_quietly(client: Client) -> None:
"""Best-effort close of a client whose setup failed; never raises."""
with contextlib.suppress(Exception):
await client.aclose()
async def _connect_client(
session: BaseSandboxSession,
*,
host_url: str,
container_url: str,
) -> Client:
"""Connect to the in-container Caido sidecar and select a fresh project."""
logger.info("Bootstrapping Caido client (host=%s, container=%s)", host_url, container_url)
access_token = await _login_as_guest(session, container_url=container_url)
client = Client(host_url, auth=TokenAuthOptions(token=access_token))
await client.connect()
return client
project = await client.project.create(
CreateProjectOptions(name="sandbox", temporary=True),
)
await client.project.select(project.id)
async def bootstrap_caido(
session: BaseSandboxSession,
*,
host_url: str,
container_url: str,
) -> tuple[Client, str]:
"""Connect to the in-container Caido sidecar and select a fresh project.
Returns the connected client and the id of the temporary project it
selected. The project id lets :func:`reconnect_caido` rebuild a dead
transport while staying on the *same* project (and its captured traffic)
instead of creating a new empty one.
"""
logger.info("Bootstrapping Caido client (host=%s, container=%s)", host_url, container_url)
client = await _connect_client(session, host_url=host_url, container_url=container_url)
try:
project = await client.project.create(
CreateProjectOptions(name="sandbox", temporary=True),
)
await client.project.select(project.id)
except BaseException:
# Don't leak the connected transport if project setup fails.
await _aclose_quietly(client)
raise
logger.info("Caido project selected: %s", project.id)
return client, str(project.id)
async def reconnect_caido(
session: BaseSandboxSession,
*,
host_url: str,
container_url: str,
project_id: str,
) -> Client:
"""Rebuild a Caido client after its transport died, keeping the project.
Re-authenticates, reconnects, and re-selects the existing project so the
caller keeps access to the traffic captured before the disconnect.
"""
logger.info("Reconnecting Caido client (host=%s, project=%s)", host_url, project_id)
client = await _connect_client(session, host_url=host_url, container_url=container_url)
try:
await client.project.select(project_id)
except BaseException:
# A missing/unavailable project must not leave the freshly-connected
# transport dangling — otherwise every retry leaks another one.
await _aclose_quietly(client)
raise
return client
+17 -4
View File
@@ -5,15 +5,20 @@ from __future__ import annotations
import logging
import shutil
from pathlib import Path
from typing import Any
from typing import TYPE_CHECKING, Any
from agents.sandbox.entries import BaseEntry, LocalDir
from agents.sandbox.manifest import Environment, Manifest
from strix.config import load_settings
from strix.runtime.backends import get_backend
from strix.runtime.caido_bootstrap import bootstrap_caido
from strix.runtime.caido_bootstrap import bootstrap_caido, reconnect_caido
from strix.runtime.local_dir_staging import stage_symlink_safe_dir
from strix.tools.proxy.caido_api import SharedCaidoClient
if TYPE_CHECKING:
from caido_sdk_client import Client as CaidoClient
logger = logging.getLogger(__name__)
@@ -131,16 +136,24 @@ async def create_or_reuse(
host_caido_url = f"{scheme}://{caido_endpoint.host}:{caido_endpoint.port}"
logger.debug("Caido host endpoint resolved: %s", host_caido_url)
caido_client = await bootstrap_caido(
caido_client, caido_project_id = await bootstrap_caido(
session,
host_url=host_caido_url,
container_url=container_caido_url,
)
async def _reconnect_caido() -> CaidoClient:
return await reconnect_caido(
session,
host_url=host_caido_url,
container_url=container_caido_url,
project_id=caido_project_id,
)
bundle = {
"client": client,
"session": session,
"caido_client": caido_client,
"caido_client": SharedCaidoClient(caido_client, _reconnect_caido),
}
_SESSION_CACHE[scan_id] = bundle
logger.info("Sandbox session for scan %s ready and cached", scan_id)