Skip to content

fix: normalize API target env vars to bare hostnames via URL parsing#1799

Merged
lpcox merged 3 commits intomainfrom
fix/api-proxy-normalize-target-scheme
Apr 8, 2026
Merged

fix: normalize API target env vars to bare hostnames via URL parsing#1799
lpcox merged 3 commits intomainfrom
fix/api-proxy-normalize-target-scheme

Conversation

@lpcox
Copy link
Copy Markdown
Collaborator

@lpcox lpcox commented Apr 8, 2026

When --*-api-target values include an https:// prefix (common via GitHub Actions expressions like ${{ vars.ANTHROPIC_BASE_URL }}), the API proxy constructs malformed URLs like https://https://host that Squid rejects with 403. The gh-aw compiler's extractAPITargetHost() strips schemes at compile time, but ${{ ... }} expressions are opaque until runtime.

Changes

  • containers/api-proxy/server.jsnormalizeApiTarget() now uses new URL() to extract the bare hostname, discarding scheme, path, port, query, fragment, and credentials. Warns when unsupported components are present (path config belongs in *_API_BASE_PATH).
  • src/docker-manager.tsstripScheme() similarly uses URL parsing to return hostname only, as a belt-and-suspenders layer before values reach the container.
  • Tests — Updated assertions: path-containing inputs now resolve to hostname only. Added coverage for port, query, and fragment stripping.

Why URL parsing, not regex

targetHost is passed directly to https.request({ hostname }) and tls.connect({ servername }). A value like my-gateway.example.com/some-path is an invalid hostname/SNI and would break TLS negotiation. Simple s/^https?:\/\/// was insufficient.

// Before: regex left path intact → invalid SNI
normalizeApiTarget('https://gw.example.com/v1') // → 'gw.example.com/v1' ✗

// After: URL parsing extracts hostname only
normalizeApiTarget('https://gw.example.com/v1') // → 'gw.example.com' ✓

When --*-api-target values originate from GitHub Actions expressions
(e.g., ${{ vars.ANTHROPIC_BASE_URL }}), the scheme is not stripped at
compile time. At runtime the full URL (https://host) reaches the API
proxy, which prepends https:// again, producing double-scheme URLs
like https://https://host that Squid rejects.

Fix at two layers:

1. containers/api-proxy/server.js: add normalizeApiTarget() that
   strips any http(s):// prefix. Applied to all four API targets
   (OpenAI, Anthropic, Gemini, Copilot) on startup.

2. src/docker-manager.ts: add stripScheme() and apply it when
   setting *_API_TARGET env vars in the api-proxy container. This
   prevents the scheme from ever reaching the container.

Fixes #1795
Upstream: github/gh-aw#25137

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@lpcox lpcox requested a review from Mossaka as a code owner April 8, 2026 15:37
Copilot AI review requested due to automatic review settings April 8, 2026 15:37
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 8, 2026

✅ Coverage Check Passed

Overall Coverage

Metric Base PR Delta
Lines 86.21% 86.31% 📈 +0.10%
Statements 86.09% 86.16% 📈 +0.07%
Functions 87.45% 87.50% 📈 +0.05%
Branches 78.81% 78.82% ➡️ +0.01%
📁 Per-file Coverage Changes (1 files)
File Lines (Before → After) Statements (Before → After)
src/docker-manager.ts 86.5% → 86.9% (+0.35%) 86.1% → 86.3% (+0.25%)

Coverage comparison generated by scripts/ci/compare-coverage.ts

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Fixes a runtime “double scheme” bug in the API proxy by normalizing scheme-prefixed *_API_TARGET values (e.g., https://...) that can arrive via GitHub Actions expression resolution.

Changes:

  • Add runtime API target normalization in containers/api-proxy/server.js and export it for tests.
  • Strip scheme prefixes when generating the api-proxy container environment in generateDockerCompose().
  • Add unit/integration tests covering scheme stripping behavior and a regression demonstration for the double-scheme parsing issue.
Show a summary per file
File Description
containers/api-proxy/server.js Adds normalizeApiTarget() and applies it to provider target env vars.
containers/api-proxy/server.test.js Adds tests for normalizeApiTarget() and a regression demonstration around URL parsing.
src/docker-manager.ts Adds stripScheme() and applies it when setting api-proxy *_API_TARGET env vars.
src/docker-manager.test.ts Adds unit tests for stripScheme() and a compose env integration test for scheme stripping.

Copilot's findings

Tip

Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comments suppressed due to low confidence (2)

containers/api-proxy/server.js:80

  • The JSDoc for normalizeApiTarget() says it returns undefined when the input is falsy, but the implementation returns the original falsy value (e.g. '' stays ''). Either update the JSDoc to match the behavior or adjust the function to return undefined for empty/whitespace-only strings so callers have consistent semantics.
 * @param {string|undefined} value - Raw env var value
 * @returns {string|undefined} Bare hostname, or undefined if input is falsy
 */
function normalizeApiTarget(value) {
  if (!value) return value;
  return value.trim().replace(/^https?:\/\//, '');
}

containers/api-proxy/server.test.js:34

  • Test name is misleading: normalizeApiTarget('') is asserted to return '', not undefined. Either adjust the expectation (if you want empty string to normalize to undefined) or rename the test to reflect the actual intended behavior.
  it('should return undefined for falsy input', () => {
    expect(normalizeApiTarget(undefined)).toBeUndefined();
    expect(normalizeApiTarget('')).toBe('');
  });
  • Files reviewed: 4/4 changed files
  • Comments generated: 3

Comment on lines +67 to +80
/**
* Normalizes an API target value to a bare hostname (+ optional path).
* Strips any https?:// scheme prefix that may arrive when the value
* originates from a GitHub Actions expression resolved at runtime
* (e.g., ${{ vars.ANTHROPIC_BASE_URL }} → "https://my-gateway.example.com").
* Also trims whitespace for safety.
*
* @param {string|undefined} value - Raw env var value
* @returns {string|undefined} Bare hostname, or undefined if input is falsy
*/
function normalizeApiTarget(value) {
if (!value) return value;
return value.trim().replace(/^https?:\/\//, '');
}
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

normalizeApiTarget() doc/comment says the target may include an optional path, and the tests assert paths are preserved. But targetHost is later used as https.request({ hostname: targetHost }) and as tls.connect({ servername: targetHost }), so any /path (or ?query, #fragment) will produce an invalid hostname/SNI and break requests. Consider parsing the value as a URL/host and returning only the hostname (and possibly rejecting/warning if a path is present), keeping path configuration solely in the existing *_API_BASE_PATH env vars.

This issue also appears on line 74 of the same file.

See below for a potential fix:

 * Normalizes an API target value to a bare hostname.
 * Accepts either a hostname or a full URL and extracts only the hostname,
 * discarding any scheme, path, query, fragment, credentials, or port.
 * Path configuration must be provided separately via the existing
 * *_API_BASE_PATH environment variables.
 *
 * @param {string|undefined} value - Raw env var value
 * @returns {string|undefined} Bare hostname, or undefined if input is falsy
 */
function normalizeApiTarget(value) {
  if (!value) return value;

  const trimmed = value.trim();
  if (!trimmed) return undefined;

  const candidate = /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//.test(trimmed)
    ? trimmed
    : `https://${trimmed}`;

  try {
    const parsed = new URL(candidate);

    if (parsed.pathname !== '/' || parsed.search || parsed.hash || parsed.username || parsed.password || parsed.port) {
      console.warn(
        `Ignoring unsupported API target URL components in ${sanitizeForLog(trimmed)}; ` +
        'configure path prefixes via the corresponding *_API_BASE_PATH environment variable.'
      );
    }

    return parsed.hostname || undefined;
  } catch (err) {
    console.warn(`Invalid API target ${sanitizeForLog(trimmed)}; expected a hostname or URL`);
    return undefined;
  }
}

// Configurable API target hosts (supports custom endpoints / internal LLM routers)
// Values are normalized to bare hostnames — buildUpstreamPath() prepends https://

Copilot uses AI. Check for mistakes.
Comment on lines +23 to +24
it('should preserve hostname with path', () => {
expect(normalizeApiTarget('https://my-gateway.example.com/some-path')).toBe('my-gateway.example.com/some-path');
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

This test (and the one above) treats API targets as potentially including a path. In runtime code, the target value is used as a TLS SNI/https.request hostname, so paths are not valid here. Update the tests to reflect that only hostnames are supported (and ideally add a test that values with a path are rejected or normalized to the hostname).

This issue also appears on line 31 of the same file.

Suggested change
it('should preserve hostname with path', () => {
expect(normalizeApiTarget('https://my-gateway.example.com/some-path')).toBe('my-gateway.example.com/some-path');
it('should normalize a URL with a path to just the hostname', () => {
expect(normalizeApiTarget('https://my-gateway.example.com/some-path')).toBe('my-gateway.example.com');

Copilot uses AI. Check for mistakes.
Comment on lines +311 to +312
it('should preserve hostname with path after scheme strip', () => {
expect(stripScheme('https://my-gateway.example.com/some-path')).toBe('my-gateway.example.com/some-path');
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

stripScheme() preserves /some-path after stripping the scheme, but API targets are documented/used as hostnames (see WrapperConfig docs) and are passed as hostname/SNI in the sidecar. Allowing paths here will lead to invalid hostnames at runtime; consider updating this test (and/or stripScheme) to normalize to hostname only, or explicitly reject values containing /.

Suggested change
it('should preserve hostname with path after scheme strip', () => {
expect(stripScheme('https://my-gateway.example.com/some-path')).toBe('my-gateway.example.com/some-path');
it('should normalize URL with path to hostname only', () => {
expect(stripScheme('https://my-gateway.example.com/some-path')).toBe('my-gateway.example.com');

Copilot uses AI. Check for mistakes.
@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@lpcox
Copy link
Copy Markdown
Collaborator Author

lpcox commented Apr 8, 2026

@copilot apply changes based on the comments in this thread

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

Copilot AI added 2 commits April 8, 2026 15:49
Use URL parsing in both normalizeApiTarget() and stripScheme() to
extract only the hostname, discarding path, query, fragment, port,
and credentials. Paths must use the separate *_API_BASE_PATH settings.

Addresses review feedback that target values are used as TLS SNI /
hostname in https.request(), so including paths would break requests.

Agent-Logs-Url: https://github.com/github/gh-aw-firewall/sessions/ab65b061-c924-41d8-a573-dd954497def2
Copy link
Copy Markdown
Contributor

Copilot AI commented Apr 8, 2026

@copilot apply changes based on the comments in this thread

Applied in commits 7ee9a50 and a22c069:

  • normalizeApiTarget() now uses new URL() parsing to extract only the bare hostname, discarding path, query, fragment, port, and credentials. Warns on unsupported components.
  • stripScheme() similarly uses URL parsing to return hostname only.
  • Tests updated: path-containing inputs now assert hostname-only output. Added new tests for port and query/fragment stripping.

Copilot AI changed the title fix: normalize API target env vars to strip scheme prefix fix: normalize API target env vars to bare hostnames via URL parsing Apr 8, 2026
Copilot finished work on behalf of lpcox April 8, 2026 15:52
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 8, 2026

Smoke Test Results

GitHub MCP — Last 2 merged PRs: "fix: add retry logic to apt-get upgrade in agent Dockerfile" (#1781), "fix: share mcpg network namespace to fix TLS hostname verification" (#1778)
Playwright — github.com title contains "GitHub"
File Write/tmp/gh-aw/agent/smoke-test-claude-24144863679.txt created
Bash — File verified: Smoke test passed for Claude at Wed Apr 8 16:04:29 UTC 2026

Overall: PASS

💥 [THE END] — Illustrated by Smoke Claude

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 8, 2026

🔥 Smoke Test Results

Test Status
GitHub MCP (PR #1781: fix: add retry logic to apt-get upgrade in agent Dockerfile)
GitHub.com connectivity (HTTP 200)
File write/read (smoke-test-copilot-24144863800.txt)

Overall: PASS

PR by @lpcox · reviewer: @Mossaka

📰 BREAKING: Report filed by Smoke Copilot

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 8, 2026

Smoke test results:

  • Merged PR titles: fix: share mcpg network namespace to fix TLS hostname verification, fix: add retry logic to apt-get upgrade in agent Dockerfile
  • safeinputs-gh PR query ❌
  • Playwright github.com title contains "GitHub" ❌
  • Tavily web search returned results ❌
  • File write /tmp/gh-aw/agent/smoke-test-codex-24144863761.txt
  • Bash cat verification ✅
  • Discussion query + mystical comment ❌
  • npm ci && npm run build
    Overall status: FAIL

🔮 The oracle has spoken through Smoke Codex

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 8, 2026

Chroot Version Comparison Results

Runtime Host Version Chroot Version Match?
Python Python 3.12.13 Python 3.12.3 ❌ NO
Node.js v24.14.1 v20.20.2 ❌ NO
Go go1.22.12 go1.22.12 ✅ YES

Overall: ❌ FAILED — Python and Node.js versions differ between host and chroot environment.

Tested by Smoke Chroot

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 8, 2026

Smoke Test: GitHub Actions Services Connectivity ✅

All checks passed:

Check Result
Redis PING (host.docker.internal:6379) PONG
PostgreSQL ready (host.docker.internal:5432) accepting connections
PostgreSQL SELECT 1 (db: smoketest, user: postgres) 1

Note: redis-cli was not pre-installed; Redis was verified via raw TCP (RESP protocol) using nc, which returned +PONG.

🔌 Service connectivity validated by Smoke Services

@lpcox lpcox merged commit 07f9438 into main Apr 8, 2026
63 of 65 checks passed
@lpcox lpcox deleted the fix/api-proxy-normalize-target-scheme branch April 8, 2026 16:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants