Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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.
26 changes: 26 additions & 0 deletions claude_tap/forward_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,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 @@ -93,6 +94,22 @@
PACKAGE_MANAGER_USER_AGENT_MARKERS = ("npm/", "yarn/", "pnpm/", "bun/")


def _upstream_base_url(upstream_url: str, request_path: str) -> str | None:
"""Return a credential-free base URL suitable for trace reconstruction."""
parsed = URL(upstream_url)
if not parsed.is_absolute():
return None

base = str(parsed.origin())
upstream_path = parsed.raw_path.rstrip("/")
forwarded_path = URL(request_path).raw_path.rstrip("/")
if forwarded_path and upstream_path.endswith(forwarded_path):
prefix = upstream_path[: -len(forwarded_path)].rstrip("/")
if prefix:
return f"{base}{prefix}"
return base


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

req_body = _parse_request_body_for_trace(body)
upstream_base_url = _upstream_base_url(upstream_url, path)

is_streaming = is_capture_only_streaming_request(path, req_body)

Expand All @@ -534,6 +552,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 @@ -583,6 +602,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 Down Expand Up @@ -610,6 +630,7 @@ async def _forward_and_record(
headers,
req_body,
log_prefix,
upstream_base_url,
)
else:
await self._handle_non_streaming(
Expand All @@ -624,6 +645,7 @@ async def _forward_and_record(
req_body,
log_prefix,
upstream_url,
upstream_base_url,
)

async def _handle_streaming(
Expand All @@ -638,6 +660,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 @@ -712,6 +735,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 @@ -728,6 +752,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 @@ -769,6 +794,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
6 changes: 5 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,11 @@ 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 historicalHost = String(hostHeader?.[1] || '').trim();
const isLoopbackHost = /^(?:localhost|127(?:\.\d{1,3}){3}|\[?::1\]?)(?::\d+)?$/i.test(historicalHost);
const base = e.upstream_base_url
|| (historicalHost && !isLoopbackHost ? `https://${historicalHost}` : 'https://api.anthropic.com');
Comment on lines +139 to +141

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject unspecified proxy hosts in cURL fallback

For historical records without upstream_base_url, this guard still treats Host: 0.0.0.0:<port> as an upstream provider and copies https://0.0.0.0:<port>/...; fresh evidence is that claude_tap/cli.py:889-892 defaults --tap-no-launch proxy-only mode to bind on 0.0.0.0, so older reverse-proxy traces from that supported mode can store the local listener host rather than the real target. Those cURL exports should keep falling back to the provider/default instead of a dead local proxy address.

Useful? React with 👍 / 👎.

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
35 changes: 33 additions & 2 deletions tests/test_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -3606,6 +3606,27 @@ async def drain(self) -> None:
assert upstream_resp.closed is True


def test_forward_proxy_upstream_base_url_is_safe_and_supports_path_prefixes():
from claude_tap.forward_proxy import _upstream_base_url

assert _upstream_base_url("/health", "/health") is None
assert (
_upstream_base_url("https://user:secret@api.example.test:443/v1/messages", "/v1/messages")
== "https://api.example.test"
)
assert (
_upstream_base_url("https://api.example.test:8443/v1/messages", "/v1/messages")
== "https://api.example.test:8443"
)
assert (
_upstream_base_url(
"https://user:secret@gateway.example.test:443/proxy/v1/messages",
"/v1/messages",
)
== "https://gateway.example.test/proxy"
)


@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 @@ -3689,6 +3710,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 @@ -3952,7 +3974,7 @@ async def test_forward_proxy_local_reverse_bridge():
ca=ca,
writer=writer,
session=session,
local_reverse_target=f"https://127.0.0.1:{upstream_port}",
local_reverse_target=f"https://127.0.0.1:{upstream_port}/proxy",
local_reverse_allowed_path_prefixes=("/v1internal",),
capture_only=True,
)
Expand Down Expand Up @@ -3982,6 +4004,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}/proxy"
finally:
await server.stop()
await session.close()
Expand Down Expand Up @@ -4030,16 +4053,24 @@ async def test_forward_proxy_records_upstream_error():
) as resp:
assert resp.status == 502
assert await resp.text()
async with client.get(f"http://127.0.0.1:{proxy_port}/health") as resp:
assert resp.status == 502
assert await resp.text()

await asyncio.sleep(0.1)
writer.close()
records = [json.loads(line) for line in store.export_jsonl(session_id).splitlines()]
assert len(records) == 1
assert len(records) == 2
assert records[0]["turn"] == 1
assert records[0]["request"]["path"] == "/v1internal:listExperiments"
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}"
assert records[1]["turn"] == 2
assert records[1]["request"]["path"] == "/health"
assert records[1]["response"]["status"] == 502
assert "upstream_base_url" not in records[1]
finally:
await server.stop()
await session.close()
Expand Down
58 changes: 58 additions & 0 deletions tests/test_viewer_contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -1993,6 +1993,61 @@ 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...",
}

reverse_record = json.loads(json.dumps(_responses_record()))
reverse_record["request_id"] = "req_historical_reverse_curl"
reverse_record["turn"] = 3
reverse_record["request"]["path"] = "/v1/responses"
reverse_record["request"]["headers"] = {"Host": "127.0.0.1:8080"}

html_path = _generate_case_html(tmp_path, "curl_upstream", (current_record, historical_record, reverse_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.evaluate("filtered = entries.slice()")
assert page.evaluate("filtered.length") == 3
for index in range(3):
page.evaluate("index => { activeIdx = index; renderDetail(filtered[index]); }", index)
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 copied[2].startswith("curl -X POST 'https://api.anthropic.com/v1/responses'")
assert "127.0.0.1:8080" not in copied[2]
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 @@ -2879,6 +2934,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 @@ -2976,6 +3032,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