Skip to content

feat(proxy): keep the processes a session starts off the forward proxy (child proxy floor) - #384

Closed
codeslake wants to merge 22 commits into
cnighswonger:mainfrom
codeslake:ccf/T2135
Closed

codeslake wants to merge 22 commits into
cnighswonger:mainfrom
codeslake:ccf/T2135

Conversation

@codeslake

Copy link
Copy Markdown
Contributor

Problem

In forward-proxy mode Claude Code is pointed at cache-fix with HTTPS_PROXY=http://127.0.0.1:<port>. Every process the session starts inherits that variable: Bash-tool commands (aws, git, pip, curl), command hooks, stdio MCP servers and the statusLine command. Bulk transfers that have nothing to do with the Anthropic API then run through the proxy.

Change

  • In forward-proxy mode the proxy publishes the hop it dials through to ${CLAUDE_CONFIG_DIR:-~/.claude}/proxy-floor/<port>. The file holds a URL when the HTTPS walk lands on a hop, and is rewritten when that hop changes. It is empty when no upstream proxy is configured at all, which means direct. It is absent when the hop is unknown: before the first walk, when a configured chain has no reachable hop, when the value is unusable, or when the hop URL carries credentials. The directory is product-neutral, so any local proxy can publish there.
  • bin/child-proxy-floor.sh is a CLAUDE_CODE_SHELL_PREFIX script. When a loopback port named by one of HTTPS_PROXY, https_proxy, HTTP_PROXY, http_proxy, ALL_PROXY or all_proxy has a floor file, the script exports that floor to all six variables inside the command's -c string, so a shell startup file cannot undo it. Bash-tool commands run under the bash or zsh that Claude Code chose. Hooks, stdio MCP servers and the statusLine run under /bin/sh, as they do without a prefix. NO_PROXY is never touched. Without a floor file the command runs unchanged.
  • Docs: docs/child-proxy-floor.md and a README subsection. They recommend wiring cache-fix through the settings.json env block. A claude started from a Bash command then re-wires itself at boot: measured on Claude Code 2.1.296, a settings env HTTPS_PROXY drew 29 of 29 connections against 0 for an inherited one.
  • Not covered: apiKeyHelper, otelHeadersHelper, Claude Code's own git/gh, and LSP servers. A non-Anthropic CONNECT from those still reaches the proxy, and the proxy tunnels it blind to its hop.

Tests

test/child-proxy-floor.test.mjs covers:

  • the three file states, a write on hop change, and no write at listen before a walk;
  • a regular file where the floor directory belongs;
  • zsh, bash and /bin/sh, including a .zshenv that re-exports the proxy port;
  • quoting (', $(...), newlines, a leading -);
  • Bash-tool command detection, with or without a snapshot and with clauses ahead of the setup clause.

The full suite passes: 1736 tests, 1735 pass, 1 skipped.

codeslake and others added 22 commits October 11, 2026 02:24
…ff the forward proxy

In forward-proxy mode every process the session starts inherits
HTTPS_PROXY=http://127.0.0.1:<port>, so aws/git/pip/curl run from the Bash
tool ride cache-fix. The hook appends the six proxy exports to the session
env file with the hop cache-fix itself forwards to (/health https_proxy), or
'' when it has none. CACHE_FIX_CHILD_PROXY=keep opts out.

Co-Authored-By: Claude <noreply@anthropic.com>
…PROXY cannot route it through CCF

fetch honours NODE_USE_ENV_PROXY in Node 24 and the hook hung past its own
timeout when the env named CCF as the proxy. node:http with agent:false is
direct. Tests now bound the hook's exit and cover a /health that stalls.

Co-Authored-By: Claude <noreply@anthropic.com>
CLAUDE_ENV_FILE is sourced by the Bash tool alone, so the hook left stdio MCP
servers, command hooks and the statusLine command on the forward proxy.
CLAUDE_CODE_SHELL_PREFIX reaches all of them; the replacement follows.

Co-Authored-By: Claude <noreply@anthropic.com>
…a CLAUDE_CODE_SHELL_PREFIX

In forward-proxy mode every process a session starts inherits
HTTPS_PROXY=<cache-fix>. CLAUDE_ENV_FILE reaches only the Bash tool;
CLAUDE_CODE_SHELL_PREFIX also wraps command hooks, stdio MCP servers and the
statusLine command.

The proxy writes the hop it dials to <claude home>/cache-fix-child-proxy-<port>
at listen and whenever the hop changes (atomic; empty = direct; absent =
unknown; absent under CACHE_FIX_REQUIRE_HOP with no hop, since direct is
forbidden there). bin/child-proxy-floor.sh reads it and re-exports the six
proxy variables inside the shell's -c string, after the shell's own startup
files have run.

Co-Authored-By: Claude <noreply@anthropic.com>
…d proxies out of the real Claude home

The floor file is written under the Claude config dir, which a spawned
forward-mode proxy inherits from the test process: four test files that launch
real proxies now point CLAUDE_CONFIG_DIR at a scratch dir, and a port of 0
publishes nothing.

Co-Authored-By: Claude <noreply@anthropic.com>
… file

Same behaviour, fewer lines: the found flag, the cat and the trailing branch
collapse into the loop.

Co-Authored-By: Claude <noreply@anthropic.com>
…e home

Co-Authored-By: Claude <noreply@anthropic.com>
…ly its exit

Co-Authored-By: Claude <noreply@anthropic.com>
…/<port> directory

The script looks in CLAUDE_CONFIG_DIR/proxy-floor, then $HOME/.claude/proxy-floor,
for any loopback port among the six keys, so a proxy chained in front of
cache-fix that publishes to the same directory is handled whichever prefix
holds the CLAUDE_CODE_SHELL_PREFIX slot.

Co-Authored-By: Claude <noreply@anthropic.com>
…therwise

A chain that is configured but wholly refused, a value that is no http(s) URL
and a hop URL carrying credentials now remove the floor file (unknown) instead
of publishing direct or a credential-stripped URL. Only the HTTPS hop walk
drives the file, so an http/https alternation no longer rewrites it, and an
empty-chain walk resets that state, which makes the first floor test
independent of the order the cases run in.

Co-Authored-By: Claude <noreply@anthropic.com>
…the floor is unknown

Co-Authored-By: Claude <noreply@anthropic.com>
…does, and ends -c options

SHELL as given may be fish or unset. The script now takes CLAUDE_CODE_SHELL, then
SHELL (each only if it names bash or zsh and is executable), else zsh then bash
from PATH and the usual directories, else /bin/sh. -c is followed by --, so a
command string that begins with a dash is a command and not an option cluster.

Co-Authored-By: Claude <noreply@anthropic.com>
…lobal path, unknown states

The floor is a convention any local proxy may publish to, one slot works with any
prefix that honours it. Direct is published only when nothing is configured;
credentials in a hop URL are never published (stripped, the hop would answer 407).
The prefix runs commands under bash or zsh, not /bin/sh.

Co-Authored-By: Claude <noreply@anthropic.com>
…k and a restated comment

Co-Authored-By: Claude <noreply@anthropic.com>
… shell

Co-Authored-By: Claude <noreply@anthropic.com>
…he first configured candidate

At listen the file got an unchecked candidate, which may be the hop that is
down. Only answers that need no walk are written there (no chain: direct, or
removed under REQUIRE_HOP; configured but unusable: removed). A configured
chain leaves a predecessor's file alone and starts one walk; the hop-change
listener writes when it lands. A failed rename also removes its temp file.

fix(bin): child-proxy-floor.sh runs only the Bash tool under bash or zsh

Claude Code's shell choice applies to the Bash tool alone. Hooks, stdio MCP
servers and the statusLine are spawned under /bin/sh -c and now stay there.
The Bash tool is told by the shell snapshot it sources (snapshot-zsh-,
snapshot-bash-), or by its cwd tail when no snapshot could be made.

Co-Authored-By: Claude <noreply@anthropic.com>
…he session; a front proxy takes only the prefix line; hooks stay under /bin/sh

Co-Authored-By: Claude <noreply@anthropic.com>
…s unknown; docs measure hooks and MCP under /bin/sh

Co-Authored-By: Claude <noreply@anthropic.com>
…olveHop() from the test

Co-Authored-By: Claude <noreply@anthropic.com>
…he shell from the Bash tool's own clauses

A walk at listen with every hop down stamped direct_last, logged "dialling direct" with no dial made, and removed a handover predecessor's good floor file. With a chain configured the file is now left as it is until the first real HTTPS walk. A failed write's temp-file cleanup can no longer throw out of startProxy. The script reads the snapshot clause up to its first ` 2>/dev/null` (or the setup clause Claude Code adds when a prefix is set), so a command that names a snapshot path or `&& pwd -P >| ` keeps its shell. Docs re-measured on Claude Code 2.1.296.

Co-Authored-By: Claude <noreply@anthropic.com>
…se, re-measure the Bash-tool and statusLine rows

Co-Authored-By: Claude <noreply@anthropic.com>
…t stands among Claude Code's clauses

With no shell snapshot Claude Code can put clauses ahead of the setup clause (sandbox
TMPDIR export, session env script, PATH export), so the position-0 anchor sent such a
Bash-tool command to /bin/sh. Match the setup clause as one of the && -joined clauses
with && eval after it. Docs: say so, name the one string this cannot tell apart, and
note the prefix path must not contain a space followed by a dash.

Co-Authored-By: Claude <noreply@anthropic.com>
@codeslake
codeslake marked this pull request as ready for review October 11, 2026 09:45
@codeslake

Copy link
Copy Markdown
Contributor Author

Closing unmerged: this approach is withdrawn. The same result needs no product code, only a CLAUDE_CODE_SHELL_PREFIX set by the user; a README note may follow separately.

@codeslake codeslake closed this Oct 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant