Skip to content

ANTHROPIC_BASE_URL inherited by unrelated child processes breaks third-party CLI compatibility probes #111

Description

@SiNaPsEr0x

What happens

Setting ANTHROPIC_BASE_URL to point at pxpipe (as documented) makes every child process spawned from that shell inherit the variable — not just the intended AI client. In my case, claude-mem (a Claude Code memory plugin) spawns claude.exe as a subprocess to run a CLI-compatibility probe and internal SDK queries. Those subprocess calls got silently routed through pxpipe too.

The probe's request through the proxy failed in a way that made claude-mem misdiagnose it as "CLI too old — rejects flags every memory agent spawn requires", even though the exact same CLI binary worked perfectly when called directly (bypassing pxpipe). This cost significant time to trace back to pxpipe, since the error message pointed entirely at the wrong component.

Repro

  1. ANTHROPIC_BASE_URL=http://127.0.0.1:47821 + npx pxpipe-proxy
  2. Run any tool that spawns its own claude CLI subprocess for internal purposes (health checks, version probes, etc.) — it silently inherits the proxy.
  3. If that tool's probe logic isn't tolerant of proxy-mediated responses, it fails and misattributes the cause.

Why this happens, concretely (claude-mem example)

claude-mem reads a CLAUDE_CODE_PATH setting from its own config file (on Windows: ~/.claude-mem/settings.json, a flat JSON key alongside its other env-style settings) that tells it which claude executable to spawn whenever it needs to run a CLI-compatibility probe or start a memory-agent SDK query. By default this points straight at the real CLI binary, e.g.:

{
  "CLAUDE_CODE_PATH": "C:\\Users\\you\\.local\\bin\\claude.exe"
}

claude-mem's worker process is a child of your main Claude Code session. Since ANTHROPIC_BASE_URL lives in that session's environment (required to route through pxpipe), the worker — and anything it spawns, including the claude.exe probe above — inherits it silently. claude-mem never asked for this and has no way to know its internal CLI spawn is being proxied; it just sees the probe fail and reports a misleading "CLI too old" diagnosis.

The fix is repointing that one CLAUDE_CODE_PATH value at a wrapper instead of the real binary:

{
  "CLAUDE_CODE_PATH": "C:\\path\\to\\claude-noproxy.cmd"
}

The wrapper (full script below) does exactly one thing: clears ANTHROPIC_BASE_URL before exec'ing the real CLI, so claude-mem's internal spawns go straight to api.anthropic.com, while your main Claude Code session — started separately, with the proxy env set only in that process — keeps going through pxpipe as intended. After changing the setting, restart claude-mem's worker process (kill it, let it respawn) for the new path to take effect.

This is almost certainly not specific to claude-mem — any plugin/tool that spawns its own claude CLI subprocess for internal control-plane purposes will hit the same silent-inheritance issue, just with whatever error message that particular tool happens to surface when its request is unexpectedly proxied.

Workaround scripts (Windows / PowerShell)

claude-noproxy.cmd — pointed to by claude-mem's CLAUDE_CODE_PATH setting, so its internal probes/spawns bypass pxpipe entirely
@echo off
rem Wrapper for third-party tools that spawn claude.exe internally (e.g. claude-mem):
rem strips ANTHROPIC_BASE_URL so those calls go straight to api.anthropic.com,
rem instead of silently inheriting the pxpipe proxy from the parent shell.
set ANTHROPIC_BASE_URL=
"C:\path\to\real\claude.exe" %*
claude-pxpipe.ps1 — starts pxpipe-proxy, launches Claude Code with the proxy env scoped to that process only, and kills the whole proxy process tree (via a Windows Job Object, KILL_ON_JOB_CLOSE) whenever the session ends, however it ends
[CmdletBinding()]
param(
    [string]$ProxyHost      = "127.0.0.1",
    [int]   $ProxyPort      = 47821,
    [int]   $TimeoutSeconds = 60,
    [string]$PxpipeModels   = ""
)

$ErrorActionPreference = "Stop"
$proxyUrl = "http://{0}:{1}" -f $ProxyHost, $ProxyPort

# Job Object with KILL_ON_JOB_CLOSE: guarantees the whole proxy process tree
# (cmd -> npx -> node) dies when this script/window terminates, in ANY way
# (normal exit, Ctrl+C, closing the window, crash) - not just the happy path.
$jobSource = @"
using System;
using System.Runtime.InteropServices;
public static class KillOnCloseJob {
    [DllImport("kernel32.dll")] static extern IntPtr CreateJobObject(IntPtr a, string n);
    [DllImport("kernel32.dll")] static extern bool SetInformationJobObject(IntPtr h, int t, IntPtr p, uint l);
    [DllImport("kernel32.dll")] public static extern bool AssignProcessToJobObject(IntPtr h, IntPtr p);
    [StructLayout(LayoutKind.Sequential)] struct BASIC { public long a,b; public uint LimitFlags; public UIntPtr c,d; public uint e; public UIntPtr f; public uint g,h2; }
    [StructLayout(LayoutKind.Sequential)] struct IOC { public ulong a,b,c,d,e,f; }
    [StructLayout(LayoutKind.Sequential)] struct EXT { public BASIC Basic; public IOC Io; public UIntPtr a,b,c,d; }
    const uint KILL_ON_CLOSE = 0x2000;
    public static IntPtr Create() {
        IntPtr job = CreateJobObject(IntPtr.Zero, null);
        var info = new EXT(); info.Basic.LimitFlags = KILL_ON_CLOSE;
        int len = Marshal.SizeOf(typeof(EXT));
        IntPtr p = Marshal.AllocHGlobal(len);
        Marshal.StructureToPtr(info, p, false);
        SetInformationJobObject(job, 9, p, (uint)len);
        Marshal.FreeHGlobal(p);
        return job;
    }
}
"@
Add-Type -TypeDefinition $jobSource -Language CSharp
$jobHandle = [KillOnCloseJob]::Create()

function Test-Port {
    param([string]$TargetHost, [int]$Port, [int]$TimeoutMs = 300)
    try {
        $c = New-Object System.Net.Sockets.TcpClient
        $a = $c.BeginConnect($TargetHost, $Port, $null, $null)
        $ok = $a.AsyncWaitHandle.WaitOne($TimeoutMs, $false) -and $c.Connected
        $c.Close(); return [bool]$ok
    } catch { return $false }
}

if (-not (Test-Port -TargetHost $ProxyHost -Port $ProxyPort)) {
    $cmdLine = "/c npx -y pxpipe-proxy"           # -y skips the first-run install prompt
    if ($PxpipeModels) { $cmdLine = "/c set PXPIPE_MODELS=$PxpipeModels&& npx -y pxpipe-proxy" }
    $proxyProc = Start-Process -FilePath "cmd.exe" -ArgumentList $cmdLine -WindowStyle Minimized -PassThru
    [KillOnCloseJob]::AssignProcessToJobObject($jobHandle, $proxyProc.Handle) | Out-Null

    $elapsed = 0
    while (-not (Test-Port -TargetHost $ProxyHost -Port $ProxyPort)) {
        Start-Sleep -Milliseconds 500; $elapsed += 500
        if ($elapsed -ge $TimeoutSeconds * 1000) { & taskkill /PID $proxyProc.Id /T /F | Out-Null; exit 1 }
    }
}

try {
    $env:ANTHROPIC_BASE_URL = $proxyUrl   # scoped to this process only
    & claude
} finally {
    Remove-Item Env:\ANTHROPIC_BASE_URL -ErrorAction SilentlyContinue
    if ($proxyProc -and -not $proxyProc.HasExited) { & taskkill /PID $proxyProc.Id /T /F | Out-Null }
    # if the block above doesn't run in time (window force-closed), the Job
    # Object still kills everything as soon as this process exits.
}

The key insight for the fix suggestions below: the wrapper only had to exist because there's no way to tell pxpipe "don't touch this particular subprocess's traffic" short of physically stripping the env var before it spawns.

Suggestions

  1. Docs: call out explicitly in the README that ANTHROPIC_BASE_URL is inherited by all child processes of the session, including helper/plugin subprocesses that spawn their own claude CLI — this is easy to miss and hard to trace when it bites.
  2. Optional bypass signal (nice-to-have, not trivial): some way for a subprocess to opt out — e.g. honoring a PXPIPE_BYPASS=1 env var or a request header, so tools that spawn their own CLI instances for internal control-plane calls can explicitly skip the proxy without a full wrapper script.
  3. Persist dashboard model selection across restarts: the opt-in model chips on the dashboard (for Sol, Opus 4.7/4.8, GPT 5.5, Grok, etc.) appear to live in memory only — every time the proxy is restarted (e.g. via npx pxpipe-proxy in a fresh session), the selection resets to default and has to be re-toggled by hand. Persisting the last-selected set to disk (e.g. alongside ~/.pxpipe/events.jsonl) and reloading it on startup would remove that friction for anyone who enables more than the default model.

Happy to test a fix if useful. Thanks for the tool — the compression numbers on the main session are genuinely great once this was sorted out.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions