Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .agents/evidence/pr/issue-392-curl-upstream/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Issue 392 cURL upstream evidence

This evidence comes from a real OpenCode 1.16.0 forward-proxy run against the
configured OpenCode provider. The isolated trace session is
`820a25b9-e9b6-49d9-b70e-c3f1cf6ee776` and contains three captured records.

The exported viewer records `https://opencode.ai` as `upstream_base_url` for
the model request at `/zen/v1/chat/completions`. Invoking the viewer's cURL
copy action produced:

```text
curl -X POST 'https://opencode.ai/zen/v1/chat/completions' \
```

The screenshot shows the real response marker `ISSUE392_OK` together with the
captured upstream origin and request path. Authentication headers remain
redacted in traces and copied cURL commands by design.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
15 changes: 15 additions & 0 deletions claude_tap/forward_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
from aiohttp import WSMessage, WSMsgType
from aiohttp._websocket.reader import WebSocketDataQueue, WebSocketReader
from aiohttp.http_websocket import WS_KEY, WebSocketWriter
from yarl import URL

from claude_tap.bedrock import attach_bedrock_errors, is_bedrock_eventstream_path
from claude_tap.certs import CertificateAuthority
Expand Down Expand Up @@ -90,6 +91,11 @@
PACKAGE_MANAGER_USER_AGENT_MARKERS = ("npm/", "yarn/", "pnpm/", "bun/")


def _upstream_origin(upstream_url: str) -> str:
"""Return a credential-free origin suitable for trace URL reconstruction."""
return str(URL(upstream_url).origin())


def _matches_path_prefix(path: str, prefixes: tuple[str, ...]) -> bool:
clean = path.split("?", 1)[0].rstrip("/")
return any(
Expand Down Expand Up @@ -492,6 +498,7 @@ async def _forward_and_record(
log_prefix = f"[Turn {turn}]"

req_body = _parse_request_body_for_trace(body)
upstream_base_url = _upstream_origin(upstream_url)
Comment thread
YoungCan-Wang marked this conversation as resolved.
Outdated
Comment thread
YoungCan-Wang marked this conversation as resolved.
Outdated

is_streaming = is_capture_only_streaming_request(path, req_body)

Expand All @@ -513,6 +520,7 @@ async def _forward_and_record(
200,
response_headers,
resp_body,
upstream_base_url=upstream_base_url,
)
await self._writer.write(record)
body_bytes = (
Expand Down Expand Up @@ -560,6 +568,7 @@ async def _forward_and_record(
502,
{"Content-Type": "text/plain"},
{"error": error_text},
upstream_base_url=upstream_base_url,
)
await self._writer.write(record)
response_line = b"HTTP/1.1 502 Bad Gateway\r\n"
Expand All @@ -580,6 +589,7 @@ async def _forward_and_record(
headers,
req_body,
log_prefix,
upstream_base_url,
)
else:
await self._handle_non_streaming(
Expand All @@ -594,6 +604,7 @@ async def _forward_and_record(
req_body,
log_prefix,
upstream_url,
upstream_base_url,
)

async def _handle_streaming(
Expand All @@ -608,6 +619,7 @@ async def _handle_streaming(
req_headers: dict[str, str],
req_body: dict | None,
log_prefix: str,
upstream_base_url: str,
) -> None:
"""Handle a streaming response: forward chunks while recording SSE."""
# Send response status line
Expand Down Expand Up @@ -682,6 +694,7 @@ async def _handle_streaming(
dict(upstream_resp.headers),
reconstructed,
sse_events=reassembler.events,
upstream_base_url=upstream_base_url,
)
await self._writer.write(record)

Expand All @@ -698,6 +711,7 @@ async def _handle_non_streaming(
req_body: dict | None,
log_prefix: str,
upstream_url: str,
upstream_base_url: str,
) -> None:
"""Handle a non-streaming response."""
if _should_skip_trace_record(upstream_url, path, upstream_resp.headers, req_headers, method):
Expand Down Expand Up @@ -739,6 +753,7 @@ async def _handle_non_streaming(
upstream_resp.status,
dict(upstream_resp.headers),
resp_body,
upstream_base_url=upstream_base_url,
)
await self._writer.write(record)

Expand Down
3 changes: 2 additions & 1 deletion claude_tap/viewer_assets/sections_json.js
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,8 @@ function copyCurl(btn) {
e = resolveEntryForDetail(e);
const method = e.request?.method || 'POST', path = e.request?.path || '/v1/messages';
const headers = e.request?.headers || {}, body = e.request?.body;
const base = e.upstream_base_url || 'https://api.anthropic.com';
const hostHeader = Object.entries(headers).find(([key]) => key.toLowerCase() === 'host');
const base = e.upstream_base_url || (hostHeader ? `https://${hostHeader[1]}` : 'https://api.anthropic.com');
Comment thread
YoungCan-Wang marked this conversation as resolved.
Outdated
let cmd = `curl -X ${method} '${base}${path}'`;
for (const [k, v] of Object.entries(headers)) {
const kl = k.toLowerCase();
Expand Down
2 changes: 2 additions & 0 deletions scripts/check_coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,8 @@ def collect_viewer_js_coverage() -> tuple[float, set[str], int, int]:
'beforeend',
renderToolInput({ cmd: 'printf "coverage\\n"', yield_time_ms: 1000 })
);
window.copyToClipboard = () => Promise.resolve();
copyCurl(null);
document.querySelector('.tool-input-toggle')?.click();
const tooltipTrigger = document.querySelector('.sidebar-group-header') || document.createElement('div');
if (!tooltipTrigger.isConnected) document.body.appendChild(tooltipTrigger);
Expand Down
10 changes: 10 additions & 0 deletions tests/test_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -3456,6 +3456,13 @@ async def drain(self) -> None:
assert upstream_resp.closed is True


def test_forward_proxy_upstream_origin_omits_default_port_and_credentials():
from claude_tap.forward_proxy import _upstream_origin

assert _upstream_origin("https://user:secret@api.example.test:443/v1/messages") == "https://api.example.test"
assert _upstream_origin("https://api.example.test:8443/v1/messages") == "https://api.example.test:8443"


@pytest.mark.asyncio
async def test_forward_proxy_connect():
"""Test the forward proxy CONNECT/TLS flow with a fake HTTPS upstream."""
Expand Down Expand Up @@ -3539,6 +3546,7 @@ async def test_forward_proxy_connect():
assert records[0]["request"]["method"] == "POST"
assert "/v1/messages" in records[0]["request"]["path"]
assert records[0]["response"]["status"] == 200
assert records[0]["upstream_base_url"] == f"https://127.0.0.1:{upstream_port}"
print(" OK: trace recorded correctly")

# Check header redaction
Expand Down Expand Up @@ -3720,6 +3728,7 @@ async def chunked_body():
assert records[0]["request"]["path"] == "/v1internal:loadCodeAssist"
assert records[0]["request"]["body"]["request"]["contents"][0]["role"] == "user"
assert records[0]["response"]["status"] == 200
assert records[0]["upstream_base_url"] == f"https://127.0.0.1:{upstream_port}"
finally:
await server.stop()
await session.close()
Expand Down Expand Up @@ -3778,6 +3787,7 @@ async def test_forward_proxy_records_upstream_error():
assert records[0]["request"]["body"]["request"]["client"] == "agy"
assert records[0]["response"]["status"] == 502
assert records[0]["response"]["body"]["error"]
assert records[0]["upstream_base_url"] == f"http://127.0.0.1:{unreachable_port}"
finally:
await server.stop()
await session.close()
Expand Down
49 changes: 49 additions & 0 deletions tests/test_viewer_contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -1991,6 +1991,52 @@ def test_viewer_detail_tabs_keep_default_view_and_expose_trace_mode(tmp_path: Pa
assert remaining_tabs == ["default", "trace"]


def test_viewer_curl_uses_recorded_upstream_and_historical_host_fallback(tmp_path: Path, chromium_browser) -> None:
current_record = json.loads(json.dumps(_responses_record()))
current_record["upstream_base_url"] = "https://gateway.example.test:8443"
current_record["request"]["path"] = "/custom/v1/responses"
current_record["request"]["headers"] = {
"Host": "ignored.example.test",
"Authorization": "Bearer sk-test...",
}

historical_record = json.loads(json.dumps(_responses_record()))
historical_record["request_id"] = "req_historical_curl"
historical_record["turn"] = 2
historical_record["request"]["path"] = "/legacy/v1/responses"
historical_record["request"]["headers"] = {
"host": "legacy.example.test:9443",
"Authorization": "Bearer sk-test...",
}

html_path = _generate_case_html(tmp_path, "curl_upstream", (current_record, historical_record))
page = chromium_browser.new_page()
try:
errors = _open_viewer_with_error_capture(page, html_path)
page.evaluate(
"""() => {
window.__copiedCurl = [];
window.copyToClipboard = (text) => { window.__copiedCurl.push(text); return Promise.resolve(); };
}"""
)
curl_button = '#detail .act-btn[onclick="copyCurl(this)"]'
page.locator(".sidebar-item").nth(0).click()
page.locator(curl_button).click()
page.locator(".sidebar-item").nth(1).click()
page.locator(curl_button).click()
copied = page.evaluate("window.__copiedCurl")
finally:
page.close()

assert errors == []
assert copied[0].startswith("curl -X POST 'https://gateway.example.test:8443/custom/v1/responses'")
assert "ignored.example.test" not in copied[0]
assert copied[1].startswith("curl -X POST 'https://legacy.example.test:9443/legacy/v1/responses'")
assert "Authorization: Bearer sk-test..." in copied[0]
assert "Authorization: Bearer sk-test..." in copied[1]
assert "sk-test-secret" not in "\n".join(copied)


def test_viewer_tool_call_params_can_expand_escaped_string_newlines(tmp_path: Path, chromium_browser) -> None:
record = json.loads(json.dumps(_responses_record()))
decoded_command = "python - <<'PY'\nprint(\"hello\")\nPY"
Expand Down Expand Up @@ -2877,6 +2923,7 @@ def test_viewer_v8_coverage_exercises_core_inline_js_functions(tmp_path: Path, c
"getUsage",
"getResponseEvents",
"getResponseOutput",
"copyCurl",
"geminiMessages",
"geminiResponseOutput",
"renderTools",
Expand Down Expand Up @@ -2974,6 +3021,8 @@ def test_viewer_v8_coverage_exercises_core_inline_js_functions(tmp_path: Path, c
'beforeend',
renderToolInput({ cmd: 'printf "coverage\\n"', yield_time_ms: 1000 })
);
window.copyToClipboard = () => Promise.resolve();
copyCurl(null);
document.querySelector('.tool-input-toggle')?.click();
const tooltipTrigger = document.querySelector('.sidebar-group-header') || document.createElement('div');
if (!tooltipTrigger.isConnected) document.body.appendChild(tooltipTrigger);
Expand Down
Loading