Skip to content

Commit 0a24f8b

Browse files
ThomasK33claude
andauthored
fix(terminal): exclude loopback from no_proxy so Claude's IDE socket isn't proxied (#70) (#268)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Thomas Kosiewski <tk@coder.com>
1 parent 38b7084 commit 0a24f8b

8 files changed

Lines changed: 663 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
### Bug Fixes
1111

12+
- The Claude terminal now adds the loopback hosts (`localhost`, `127.0.0.1`, `::1`) to `no_proxy`/`NO_PROXY`, so a configured `http_proxy`/`all_proxy` no longer tunnels Claude's `ws://127.0.0.1` IDE connection and causes queued @ mentions to time out. Existing `no_proxy` exclusions are preserved. ([#70](https://github.com/coder/claudecode.nvim/issues/70))
1213
- `focus_after_send = true` no longer fails silently with `terminal.provider = "none"`/`"external"`: those providers run Claude outside Neovim, so focus cannot move there. A one-time warning is now emitted at setup pointing to the new `User ClaudeCodeSendComplete` autocmd, which you can hook to focus your own terminal. (`focus_after_send` still only auto-focuses the in-editor providers.) ([#228](https://github.com/coder/claudecode.nvim/issues/228))
1314
- Rejecting a Claude diff with `:q` (or `:close` / `<C-w>c` / closing the tab) now resolves it as rejected, matching the documented behavior. The proposed buffer is a scratch buffer that `:q` only hides, so the existing `BufDelete`/`BufUnload`/`BufWipeout` autocmds never fired; a `WinClosed` autocmd now handles window-close rejection. ([#238](https://github.com/coder/claudecode.nvim/issues/238))
1415
- Push quickly-made visual selections to Claude reliably. Selections made and released faster than the selection-tracking debounce were never broadcast, and any selection was wiped shortly after leaving visual mode when Claude runs in an external terminal (the `/ide` flow) — so single-line selections in particular often never reached Claude. Selections are now flushed synchronously on visual-mode exit (from the `'<`/`'>` marks) and persist until the cursor actually moves; a single-line linewise `V` made right after a charwise selection is also no longer mis-extracted to a single character. ([#246](https://github.com/coder/claudecode.nvim/issues/246))

fixtures/issue-70/README.md

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
# Issue #70 — "Sending files, current buffer, or lines to claude doesn't work"
2+
3+
> Source: https://github.com/coder/claudecode.nvim/issues/70
4+
>
5+
> Symptom: `[ClaudeCode] [queue] [ERROR] Connection timeout - clearing N queued @ mentions`
6+
7+
## The one fact behind every report
8+
9+
`:ClaudeCodeSend` (and the file/buffer/tree senders) call `send_at_mention`. When
10+
Claude is not connected, the mention is **queued** and a `connection_timeout`
11+
timer (default **10s**, `lua/claudecode/init.lua`) is armed. If Claude has not
12+
opened a WebSocket back to the plugin's server by then, the queue is cleared with
13+
the error above (`start_connection_timeout_if_needed`).
14+
15+
So the bug is never really "send is broken" — it is always **"the Claude CLI that
16+
the plugin launched never connected back to the plugin's WebSocket server."** The
17+
interesting part is _why_ Claude doesn't connect, and the issue thread contains
18+
several distinct causes that all surface as this one error.
19+
20+
## How the plugin expects Claude to connect
21+
22+
1. The plugin starts a WebSocket server on `127.0.0.1:<port>` and writes
23+
`~/.claude/ide/<port>.lock` containing `{ pid, workspaceFolders, ideName:
24+
"Neovim", transport: "ws", authToken }` (`lua/claudecode/lockfile.lua`).
25+
2. The terminal provider launches Claude with `CLAUDE_CODE_SSE_PORT=<port>` and
26+
`ENABLE_IDE_INTEGRATION=true` in its environment (`lua/claudecode/terminal.lua`).
27+
3. Claude reads `CLAUDE_CODE_SSE_PORT`, looks up the matching lock file for the
28+
auth token, and connects to `ws://127.0.0.1:<port>`.
29+
30+
## What was reproduced (claude 2.1.168, nvim 0.13, macOS)
31+
32+
Driven with the real `claude` CLI in a PTY (agent-tty) against the real plugin
33+
server. A headless probe (`scripts/repro_issue_70_probe.lua`) reports whether
34+
Claude actually opened a socket.
35+
36+
| # | environment | result |
37+
| ------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
38+
| **Baseline** | `CLAUDE_CODE_SSE_PORT` set, no proxy | **connects** (`client_count: 1`), `@` mention delivered |
39+
| **Proxy (the bug)** | `http_proxy`/`all_proxy` set, **no** `no_proxy` | **never connects**; `/ide`_"Failed to connect to Neovim"_; plugin shows `Connection timeout - clearing 1 queued @ mentions` |
40+
| **Proxy + fix** | same proxy **+** `no_proxy=localhost,127.0.0.1,::1` | **connects** again |
41+
42+
The baseline connects even with several _other_ `~/.claude/ide/*.lock` files
43+
present, so `CLAUDE_CODE_SSE_PORT` reliably disambiguates — the proxy is the only
44+
thing that changes the outcome. This matches a `no_proxy` workaround reported in
45+
the issue thread:
46+
47+
```sh
48+
export no_proxy=localhost,127.0.0.1,::1 # their fix
49+
```
50+
51+
Claude's WebSocket client honors the lowercase `http_proxy`/`all_proxy`
52+
(`proxy-from-env` semantics) and, with no localhost exclusion, tunnels even
53+
`ws://127.0.0.1:<port>` through the proxy — which cannot reach a loopback server.
54+
55+
### Secondary cause seen in the thread (multi-instance / discovery)
56+
57+
Most thread reports ("the 2nd instance fails", "the send is caught by the other
58+
terminal", "only works when Claude is opened in another terminal") come from the
59+
_discovery fallback_ used when `CLAUDE_CODE_SSE_PORT` does **not** reach Claude
60+
(external-terminal provider, shell/tmux wrappers that reset env, older versions):
61+
62+
- With the env var **absent**, current Claude falls back to scanning
63+
`~/.claude/ide/*.lock` and **filters by workspace** (`/ide` literally prints
64+
_"Found N other running IDE(s). However, their workspace/project directories do
65+
not match the current cwd."_).
66+
- With **exactly one** workspace-matching lock file it auto-connects; with **two**
67+
(two Neovim instances in the same project, or a stale lock file) it connects to
68+
**neither** — reproducing the timeout.
69+
- Auto-connect via the env var (`CLAUDE_CODE_SSE_PORT`) or a _single_ unambiguous
70+
workspace match still happens automatically; but the ambiguous-discovery path
71+
surfaces a `/ide` tip — _"You can enable auto-connect to IDE in /config or with
72+
the --ide flag"_ — i.e. discovery-only auto-connect is heuristic/opt-in. A
73+
Claude-side change to that heuristic is a plausible (unverified) explanation for
74+
the "worked, then a Claude update broke it" / "lock file exists but no socket"
75+
reports (one such report cites CC 2.0.42), and is a question for the
76+
deep-research follow-up.
77+
78+
These discovery paths touch global `~/.claude/ide` state shared with other live
79+
sessions, so they are documented here rather than automated. The deterministic,
80+
side-effect-free repro below targets the proxy cause.
81+
82+
## Reproduce it
83+
84+
### Automated (proxy cause, real Claude)
85+
86+
```sh
87+
# from repo root; needs nvim + a logged-in `claude` + agent-tty + jq
88+
bash scripts/repro_issue_70.sh
89+
```
90+
91+
Expected tail:
92+
93+
```
94+
[A_baseline] PASS (... expected=connect, got=connect)
95+
[B_proxy] PASS (... expected=noconnect, got=noconnect)
96+
[C_no_proxy] PASS (... expected=connect, got=connect)
97+
98+
PASS issue #70 reproduced: a proxy with no localhost exclusion blocks Claude's
99+
IDE WebSocket (B), while baseline (A) and the no_proxy fix (C) connect.
100+
```
101+
102+
### Interactive (in the real plugin)
103+
104+
> **Note on branch state:** this fixture loads the plugin from the repo, so its
105+
> behavior depends on whether the fix is present. On a branch/commit that
106+
> **includes the fix**, the plugin injects `no_proxy` into the Claude terminal, so
107+
> the steps below now **connect** (the `@` mention is delivered, no timeout) — that
108+
> is the fix working. To watch the **original failure**, run the same steps against
109+
> **pre-fix code** (check out the parent commit, or temporarily revert the
110+
> `lua/claudecode/terminal.lua` change).
111+
112+
```sh
113+
# proxy set, localhost NOT excluded
114+
export http_proxy=http://127.0.0.1:1 https_proxy=http://127.0.0.1:1 all_proxy=http://127.0.0.1:1
115+
unset no_proxy NO_PROXY
116+
117+
source fixtures/nvim-aliases.sh && vv issue-70 # or the explicit form below
118+
# NVIM_APPNAME=issue-70 XDG_CONFIG_HOME="$PWD/fixtures" nvim fixtures/issue-70/sample.txt
119+
```
120+
121+
Then run `:Issue70Send` (or `<leader>s`). The plugin launches Claude and queues
122+
`sample.txt`.
123+
124+
- **Pre-fix:** Claude cannot connect through the dead proxy; after ~10s the queue
125+
clears with `[ClaudeCode] [queue] [ERROR] Connection timeout - clearing 1 queued @ mentions`.
126+
- **With the fix:** the plugin adds `localhost` to `no_proxy`, so Claude connects and
127+
the `@` mention is delivered — no timeout.
128+
129+
Unlike this interactive path, the automated `scripts/repro_issue_70.sh` is
130+
**unaffected by the fix**: it launches Claude with its own environment, bypassing the
131+
plugin's env injection, so it reproduces the root cause at the Claude level on any
132+
checkout (and `export no_proxy=localhost,127.0.0.1,::1` is what makes it connect).
133+
134+
> Set `ISSUE70_LOG=/path/to/log` before launching to also tee the plugin's
135+
> notifications (including the ERROR) to a file for scripted assertions.
136+
137+
## Workarounds (today, no code change)
138+
139+
- `export no_proxy=localhost,127.0.0.1,::1` (and `NO_PROXY=...`) in the
140+
environment Neovim is launched from.
141+
- Avoid two Neovim instances sharing one project dir; clean stale
142+
`~/.claude/ide/*.lock` files.
143+
- Ensure `CLAUDE_CODE_SSE_PORT` actually reaches Claude (avoid env-stripping
144+
terminal wrappers).
145+
146+
## Pointers for a fix (deep-research follow-up)
147+
148+
- The plugin could inject a localhost `NO_PROXY`/`no_proxy` into the env table it
149+
passes to the Claude terminal (`get_claude_command_and_env` in
150+
`lua/claudecode/terminal.lua`) so the loopback IDE socket is never proxied —
151+
with care not to clobber a user's existing `no_proxy`.
152+
- The `Connection timeout` error is generic; surfacing _why_ (proxy set / no
153+
client handshake / multiple matching lock files) would make this class of
154+
report self-diagnosing.

fixtures/issue-70/init.lua

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
-- Fixture for issue #70:
2+
-- "[BUG] Sending files, current buffer, or lines to claude doesn't work."
3+
-- symptom: [ClaudeCode] [queue] [ERROR] Connection timeout - clearing N queued @ mentions
4+
-- https://github.com/coder/claudecode.nvim/issues/70
5+
--
6+
-- The symptom is downstream of ONE thing: the Claude CLI that the plugin launches
7+
-- never opens a WebSocket connection back to the plugin's server, so every queued
8+
-- @ mention sits in the queue until `connection_timeout` (default 10s) elapses and
9+
-- the queue is cleared with the error above.
10+
--
11+
-- This fixture launches the REAL plugin with the native terminal provider so the
12+
-- Claude CLI runs inside Neovim. Because it loads the plugin from the repo, the
13+
-- outcome depends on the branch state (see fixtures/issue-70/README.md):
14+
--
15+
-- export http_proxy=http://127.0.0.1:1 all_proxy=http://127.0.0.1:1
16+
-- unset no_proxy NO_PROXY
17+
-- ISSUE70_LOG=/tmp/issue70.log \
18+
-- NVIM_APPNAME=issue-70 XDG_CONFIG_HOME="$PWD/fixtures" \
19+
-- nvim fixtures/issue-70/sample.txt
20+
-- :Issue70Send " launches Claude and queues sample.txt
21+
--
22+
-- PRE-FIX (parent commit / terminal.lua reverted): Claude cannot connect through the
23+
-- dead proxy -> after ~10s the Connection timeout ERROR notification appears.
24+
-- WITH THE FIX (this branch): the plugin injects `no_proxy=localhost,...` so Claude
25+
-- connects and the @ mention is delivered -- no timeout. That contrast is the bug/fix.
26+
27+
local config_dir = vim.fn.stdpath("config")
28+
local repo_root = vim.fn.fnamemodify(config_dir, ":h:h")
29+
vim.opt.rtp:prepend(repo_root)
30+
31+
vim.g.mapleader = " "
32+
vim.g.maplocalleader = "\\"
33+
vim.o.laststatus = 2
34+
35+
-- Tee every vim.notify (the plugin logs through it) to ISSUE70_LOG so the
36+
-- "Connection timeout" ERROR can be asserted deterministically from a script,
37+
-- not just scraped off the TUI.
38+
local log_path = vim.env.ISSUE70_LOG
39+
if log_path and log_path ~= "" then
40+
local orig_notify = vim.notify
41+
vim.notify = function(msg, level, opts) -- luacheck: ignore
42+
pcall(function()
43+
local fh = io.open(log_path, "a")
44+
if fh then
45+
fh:write(("[notify lvl=%s] %s\n"):format(tostring(level), tostring(msg)))
46+
fh:close()
47+
end
48+
end)
49+
return orig_notify(msg, level, opts)
50+
end
51+
end
52+
53+
local ok, claudecode = pcall(require, "claudecode")
54+
assert(ok, "Failed to load claudecode.nvim from repo root: " .. tostring(claudecode))
55+
56+
claudecode.setup({
57+
auto_start = true, -- start the WebSocket server immediately
58+
log_level = "debug",
59+
terminal = {
60+
provider = "native", -- run Claude inside Neovim so one PTY drives everything
61+
auto_close = false,
62+
},
63+
-- defaults: connection_timeout = 10000, queue_timeout = 5000
64+
})
65+
66+
local banner = {
67+
"claudecode.nvim -- issue #70 reproduction fixture",
68+
"",
69+
"Symptom: [ClaudeCode] [queue] [ERROR] Connection timeout - clearing N queued @ mentions",
70+
"",
71+
"Run :Issue70Send (or <leader>s) to launch Claude and queue this file as an",
72+
"@ mention. If the launched Claude cannot connect back to the plugin's server",
73+
"(e.g. a proxy is set with no localhost exclusion), the queue clears after",
74+
"~10s with the Connection timeout ERROR.",
75+
"",
76+
"Server port: (see :ClaudeCodeStatus)",
77+
}
78+
vim.api.nvim_buf_set_lines(0, 0, -1, false, banner)
79+
vim.bo.modifiable = false
80+
vim.bo.modified = false
81+
82+
-- Queue THIS fixture's sample file as an @ mention via the real public API.
83+
local function issue70_send()
84+
local sample = repo_root .. "/fixtures/issue-70/sample.txt"
85+
if vim.fn.filereadable(sample) == 0 then
86+
sample = vim.fn.expand("%:p")
87+
end
88+
local okk, err = require("claudecode").send_at_mention(sample, nil, nil, "issue70")
89+
vim.api.nvim_echo({
90+
{
91+
("Issue70Send: queued %s (ok=%s%s)"):format(
92+
vim.fn.fnamemodify(sample, ":t"),
93+
tostring(okk),
94+
err and (" err=" .. err) or ""
95+
),
96+
"MoreMsg",
97+
},
98+
}, false, {})
99+
end
100+
101+
vim.api.nvim_create_user_command("Issue70Send", issue70_send, { desc = "Repro #70: queue sample.txt as @ mention" })
102+
vim.keymap.set("n", "<leader>s", issue70_send, { desc = "Repro #70 send" })

fixtures/issue-70/sample.txt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
sample file for issue #70 reproduction
2+
3+
These lines are what the fixture sends to Claude as an @ mention via
4+
:Issue70Send. When the launched Claude cannot connect back to the plugin's
5+
WebSocket server, this mention is queued and then cleared after ~10s with:
6+
7+
[ClaudeCode] [queue] [ERROR] Connection timeout - clearing 1 queued @ mentions

lua/claudecode/terminal.lua

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,41 @@ local function is_terminal_visible(bufnr)
286286
return bufinfo and #bufinfo > 0 and #bufinfo[1].windows > 0
287287
end
288288

289+
---Builds a no_proxy value that is guaranteed to exclude the loopback hosts
290+
---(localhost, 127.0.0.1, ::1) from any proxy, merging the given existing values
291+
---(each a comma-separated list, nils allowed) order-preserving and de-duplicated.
292+
---See issue #70: Claude must never proxy its loopback IDE WebSocket connection.
293+
---@param ... string? Existing no_proxy/NO_PROXY values to merge ahead of the loopback hosts
294+
---@return string combined The merged no_proxy value with loopback hosts guaranteed present
295+
local function no_proxy_with_loopback(...)
296+
local entries = {}
297+
local seen = {}
298+
299+
local function add_entry(entry)
300+
entry = entry:gsub("^%s+", ""):gsub("%s+$", "")
301+
if entry ~= "" and not seen[entry] then
302+
seen[entry] = true
303+
entries[#entries + 1] = entry
304+
end
305+
end
306+
307+
-- select() (not ipairs over {...}) so a nil source does not truncate the rest.
308+
for i = 1, select("#", ...) do
309+
local value = select(i, ...)
310+
if type(value) == "string" then
311+
for entry in value:gmatch("[^,]+") do
312+
add_entry(entry)
313+
end
314+
end
315+
end
316+
317+
for _, host in ipairs({ "localhost", "127.0.0.1", "::1" }) do
318+
add_entry(host)
319+
end
320+
321+
return table.concat(entries, ",")
322+
end
323+
289324
---Gets the claude command string and necessary environment variables
290325
---@param cmd_args string? Optional arguments to append to the command
291326
---@return string cmd_string The command string
@@ -322,6 +357,18 @@ local function get_claude_command_and_env(cmd_args)
322357
env_table[key] = value
323358
end
324359

360+
-- Issue #70: Claude honors http_proxy/all_proxy (proxy-from-env semantics) and, without a
361+
-- localhost exclusion, tunnels even its ws://127.0.0.1:<port> IDE connection through the
362+
-- proxy, so the handshake never reaches our server and queued @ mentions time out. Guarantee
363+
-- the loopback hosts bypass the proxy. This runs LAST -- after the config merge above and
364+
-- regardless of the inherited env (termopen layers env_table over the parent env) -- so the
365+
-- loopback exclusion always holds. We merge, rather than clobber, every existing source: the
366+
-- inherited shell no_proxy/NO_PROXY and any value the user set via the `env` config option.
367+
local combined_no_proxy =
368+
no_proxy_with_loopback(os.getenv("no_proxy"), os.getenv("NO_PROXY"), env_table["no_proxy"], env_table["NO_PROXY"])
369+
env_table["no_proxy"] = combined_no_proxy
370+
env_table["NO_PROXY"] = combined_no_proxy
371+
325372
return cmd_string, env_table
326373
end
327374

0 commit comments

Comments
 (0)