diff --git a/CHANGELOG.md b/CHANGELOG.md index 81430007..944cfd77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ### Bug Fixes +- 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)) - `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)) - Rejecting a Claude diff with `:q` (or `:close` / `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)) - 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)) diff --git a/fixtures/issue-70/README.md b/fixtures/issue-70/README.md new file mode 100644 index 00000000..2b846312 --- /dev/null +++ b/fixtures/issue-70/README.md @@ -0,0 +1,154 @@ +# Issue #70 — "Sending files, current buffer, or lines to claude doesn't work" + +> Source: https://github.com/coder/claudecode.nvim/issues/70 +> +> Symptom: `[ClaudeCode] [queue] [ERROR] Connection timeout - clearing N queued @ mentions` + +## The one fact behind every report + +`:ClaudeCodeSend` (and the file/buffer/tree senders) call `send_at_mention`. When +Claude is not connected, the mention is **queued** and a `connection_timeout` +timer (default **10s**, `lua/claudecode/init.lua`) is armed. If Claude has not +opened a WebSocket back to the plugin's server by then, the queue is cleared with +the error above (`start_connection_timeout_if_needed`). + +So the bug is never really "send is broken" — it is always **"the Claude CLI that +the plugin launched never connected back to the plugin's WebSocket server."** The +interesting part is _why_ Claude doesn't connect, and the issue thread contains +several distinct causes that all surface as this one error. + +## How the plugin expects Claude to connect + +1. The plugin starts a WebSocket server on `127.0.0.1:` and writes + `~/.claude/ide/.lock` containing `{ pid, workspaceFolders, ideName: +"Neovim", transport: "ws", authToken }` (`lua/claudecode/lockfile.lua`). +2. The terminal provider launches Claude with `CLAUDE_CODE_SSE_PORT=` and + `ENABLE_IDE_INTEGRATION=true` in its environment (`lua/claudecode/terminal.lua`). +3. Claude reads `CLAUDE_CODE_SSE_PORT`, looks up the matching lock file for the + auth token, and connects to `ws://127.0.0.1:`. + +## What was reproduced (claude 2.1.168, nvim 0.13, macOS) + +Driven with the real `claude` CLI in a PTY (agent-tty) against the real plugin +server. A headless probe (`scripts/repro_issue_70_probe.lua`) reports whether +Claude actually opened a socket. + +| # | environment | result | +| ------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| **Baseline** | `CLAUDE_CODE_SSE_PORT` set, no proxy | **connects** (`client_count: 1`), `@` mention delivered | +| **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` | +| **Proxy + fix** | same proxy **+** `no_proxy=localhost,127.0.0.1,::1` | **connects** again | + +The baseline connects even with several _other_ `~/.claude/ide/*.lock` files +present, so `CLAUDE_CODE_SSE_PORT` reliably disambiguates — the proxy is the only +thing that changes the outcome. This matches a `no_proxy` workaround reported in +the issue thread: + +```sh +export no_proxy=localhost,127.0.0.1,::1 # their fix +``` + +Claude's WebSocket client honors the lowercase `http_proxy`/`all_proxy` +(`proxy-from-env` semantics) and, with no localhost exclusion, tunnels even +`ws://127.0.0.1:` through the proxy — which cannot reach a loopback server. + +### Secondary cause seen in the thread (multi-instance / discovery) + +Most thread reports ("the 2nd instance fails", "the send is caught by the other +terminal", "only works when Claude is opened in another terminal") come from the +_discovery fallback_ used when `CLAUDE_CODE_SSE_PORT` does **not** reach Claude +(external-terminal provider, shell/tmux wrappers that reset env, older versions): + +- With the env var **absent**, current Claude falls back to scanning + `~/.claude/ide/*.lock` and **filters by workspace** (`/ide` literally prints + _"Found N other running IDE(s). However, their workspace/project directories do + not match the current cwd."_). +- With **exactly one** workspace-matching lock file it auto-connects; with **two** + (two Neovim instances in the same project, or a stale lock file) it connects to + **neither** — reproducing the timeout. +- Auto-connect via the env var (`CLAUDE_CODE_SSE_PORT`) or a _single_ unambiguous + workspace match still happens automatically; but the ambiguous-discovery path + surfaces a `/ide` tip — _"You can enable auto-connect to IDE in /config or with + the --ide flag"_ — i.e. discovery-only auto-connect is heuristic/opt-in. A + Claude-side change to that heuristic is a plausible (unverified) explanation for + the "worked, then a Claude update broke it" / "lock file exists but no socket" + reports (one such report cites CC 2.0.42), and is a question for the + deep-research follow-up. + +These discovery paths touch global `~/.claude/ide` state shared with other live +sessions, so they are documented here rather than automated. The deterministic, +side-effect-free repro below targets the proxy cause. + +## Reproduce it + +### Automated (proxy cause, real Claude) + +```sh +# from repo root; needs nvim + a logged-in `claude` + agent-tty + jq +bash scripts/repro_issue_70.sh +``` + +Expected tail: + +``` + [A_baseline] PASS (... expected=connect, got=connect) + [B_proxy] PASS (... expected=noconnect, got=noconnect) + [C_no_proxy] PASS (... expected=connect, got=connect) + +PASS issue #70 reproduced: a proxy with no localhost exclusion blocks Claude's + IDE WebSocket (B), while baseline (A) and the no_proxy fix (C) connect. +``` + +### Interactive (in the real plugin) + +> **Note on branch state:** this fixture loads the plugin from the repo, so its +> behavior depends on whether the fix is present. On a branch/commit that +> **includes the fix**, the plugin injects `no_proxy` into the Claude terminal, so +> the steps below now **connect** (the `@` mention is delivered, no timeout) — that +> is the fix working. To watch the **original failure**, run the same steps against +> **pre-fix code** (check out the parent commit, or temporarily revert the +> `lua/claudecode/terminal.lua` change). + +```sh +# proxy set, localhost NOT excluded +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 +unset no_proxy NO_PROXY + +source fixtures/nvim-aliases.sh && vv issue-70 # or the explicit form below +# NVIM_APPNAME=issue-70 XDG_CONFIG_HOME="$PWD/fixtures" nvim fixtures/issue-70/sample.txt +``` + +Then run `:Issue70Send` (or `s`). The plugin launches Claude and queues +`sample.txt`. + +- **Pre-fix:** Claude cannot connect through the dead proxy; after ~10s the queue + clears with `[ClaudeCode] [queue] [ERROR] Connection timeout - clearing 1 queued @ mentions`. +- **With the fix:** the plugin adds `localhost` to `no_proxy`, so Claude connects and + the `@` mention is delivered — no timeout. + +Unlike this interactive path, the automated `scripts/repro_issue_70.sh` is +**unaffected by the fix**: it launches Claude with its own environment, bypassing the +plugin's env injection, so it reproduces the root cause at the Claude level on any +checkout (and `export no_proxy=localhost,127.0.0.1,::1` is what makes it connect). + +> Set `ISSUE70_LOG=/path/to/log` before launching to also tee the plugin's +> notifications (including the ERROR) to a file for scripted assertions. + +## Workarounds (today, no code change) + +- `export no_proxy=localhost,127.0.0.1,::1` (and `NO_PROXY=...`) in the + environment Neovim is launched from. +- Avoid two Neovim instances sharing one project dir; clean stale + `~/.claude/ide/*.lock` files. +- Ensure `CLAUDE_CODE_SSE_PORT` actually reaches Claude (avoid env-stripping + terminal wrappers). + +## Pointers for a fix (deep-research follow-up) + +- The plugin could inject a localhost `NO_PROXY`/`no_proxy` into the env table it + passes to the Claude terminal (`get_claude_command_and_env` in + `lua/claudecode/terminal.lua`) so the loopback IDE socket is never proxied — + with care not to clobber a user's existing `no_proxy`. +- The `Connection timeout` error is generic; surfacing _why_ (proxy set / no + client handshake / multiple matching lock files) would make this class of + report self-diagnosing. diff --git a/fixtures/issue-70/init.lua b/fixtures/issue-70/init.lua new file mode 100644 index 00000000..23111d1e --- /dev/null +++ b/fixtures/issue-70/init.lua @@ -0,0 +1,102 @@ +-- Fixture for issue #70: +-- "[BUG] Sending files, current buffer, or lines to claude doesn't work." +-- symptom: [ClaudeCode] [queue] [ERROR] Connection timeout - clearing N queued @ mentions +-- https://github.com/coder/claudecode.nvim/issues/70 +-- +-- The symptom is downstream of ONE thing: the Claude CLI that the plugin launches +-- never opens a WebSocket connection back to the plugin's server, so every queued +-- @ mention sits in the queue until `connection_timeout` (default 10s) elapses and +-- the queue is cleared with the error above. +-- +-- This fixture launches the REAL plugin with the native terminal provider so the +-- Claude CLI runs inside Neovim. Because it loads the plugin from the repo, the +-- outcome depends on the branch state (see fixtures/issue-70/README.md): +-- +-- export http_proxy=http://127.0.0.1:1 all_proxy=http://127.0.0.1:1 +-- unset no_proxy NO_PROXY +-- ISSUE70_LOG=/tmp/issue70.log \ +-- NVIM_APPNAME=issue-70 XDG_CONFIG_HOME="$PWD/fixtures" \ +-- nvim fixtures/issue-70/sample.txt +-- :Issue70Send " launches Claude and queues sample.txt +-- +-- PRE-FIX (parent commit / terminal.lua reverted): Claude cannot connect through the +-- dead proxy -> after ~10s the Connection timeout ERROR notification appears. +-- WITH THE FIX (this branch): the plugin injects `no_proxy=localhost,...` so Claude +-- connects and the @ mention is delivered -- no timeout. That contrast is the bug/fix. + +local config_dir = vim.fn.stdpath("config") +local repo_root = vim.fn.fnamemodify(config_dir, ":h:h") +vim.opt.rtp:prepend(repo_root) + +vim.g.mapleader = " " +vim.g.maplocalleader = "\\" +vim.o.laststatus = 2 + +-- Tee every vim.notify (the plugin logs through it) to ISSUE70_LOG so the +-- "Connection timeout" ERROR can be asserted deterministically from a script, +-- not just scraped off the TUI. +local log_path = vim.env.ISSUE70_LOG +if log_path and log_path ~= "" then + local orig_notify = vim.notify + vim.notify = function(msg, level, opts) -- luacheck: ignore + pcall(function() + local fh = io.open(log_path, "a") + if fh then + fh:write(("[notify lvl=%s] %s\n"):format(tostring(level), tostring(msg))) + fh:close() + end + end) + return orig_notify(msg, level, opts) + end +end + +local ok, claudecode = pcall(require, "claudecode") +assert(ok, "Failed to load claudecode.nvim from repo root: " .. tostring(claudecode)) + +claudecode.setup({ + auto_start = true, -- start the WebSocket server immediately + log_level = "debug", + terminal = { + provider = "native", -- run Claude inside Neovim so one PTY drives everything + auto_close = false, + }, + -- defaults: connection_timeout = 10000, queue_timeout = 5000 +}) + +local banner = { + "claudecode.nvim -- issue #70 reproduction fixture", + "", + "Symptom: [ClaudeCode] [queue] [ERROR] Connection timeout - clearing N queued @ mentions", + "", + "Run :Issue70Send (or s) to launch Claude and queue this file as an", + "@ mention. If the launched Claude cannot connect back to the plugin's server", + "(e.g. a proxy is set with no localhost exclusion), the queue clears after", + "~10s with the Connection timeout ERROR.", + "", + "Server port: (see :ClaudeCodeStatus)", +} +vim.api.nvim_buf_set_lines(0, 0, -1, false, banner) +vim.bo.modifiable = false +vim.bo.modified = false + +-- Queue THIS fixture's sample file as an @ mention via the real public API. +local function issue70_send() + local sample = repo_root .. "/fixtures/issue-70/sample.txt" + if vim.fn.filereadable(sample) == 0 then + sample = vim.fn.expand("%:p") + end + local okk, err = require("claudecode").send_at_mention(sample, nil, nil, "issue70") + vim.api.nvim_echo({ + { + ("Issue70Send: queued %s (ok=%s%s)"):format( + vim.fn.fnamemodify(sample, ":t"), + tostring(okk), + err and (" err=" .. err) or "" + ), + "MoreMsg", + }, + }, false, {}) +end + +vim.api.nvim_create_user_command("Issue70Send", issue70_send, { desc = "Repro #70: queue sample.txt as @ mention" }) +vim.keymap.set("n", "s", issue70_send, { desc = "Repro #70 send" }) diff --git a/fixtures/issue-70/sample.txt b/fixtures/issue-70/sample.txt new file mode 100644 index 00000000..ca96bafa --- /dev/null +++ b/fixtures/issue-70/sample.txt @@ -0,0 +1,7 @@ +sample file for issue #70 reproduction + +These lines are what the fixture sends to Claude as an @ mention via +:Issue70Send. When the launched Claude cannot connect back to the plugin's +WebSocket server, this mention is queued and then cleared after ~10s with: + + [ClaudeCode] [queue] [ERROR] Connection timeout - clearing 1 queued @ mentions diff --git a/lua/claudecode/terminal.lua b/lua/claudecode/terminal.lua index c0245dd2..5ed370a6 100644 --- a/lua/claudecode/terminal.lua +++ b/lua/claudecode/terminal.lua @@ -286,6 +286,41 @@ local function is_terminal_visible(bufnr) return bufinfo and #bufinfo > 0 and #bufinfo[1].windows > 0 end +---Builds a no_proxy value that is guaranteed to exclude the loopback hosts +---(localhost, 127.0.0.1, ::1) from any proxy, merging the given existing values +---(each a comma-separated list, nils allowed) order-preserving and de-duplicated. +---See issue #70: Claude must never proxy its loopback IDE WebSocket connection. +---@param ... string? Existing no_proxy/NO_PROXY values to merge ahead of the loopback hosts +---@return string combined The merged no_proxy value with loopback hosts guaranteed present +local function no_proxy_with_loopback(...) + local entries = {} + local seen = {} + + local function add_entry(entry) + entry = entry:gsub("^%s+", ""):gsub("%s+$", "") + if entry ~= "" and not seen[entry] then + seen[entry] = true + entries[#entries + 1] = entry + end + end + + -- select() (not ipairs over {...}) so a nil source does not truncate the rest. + for i = 1, select("#", ...) do + local value = select(i, ...) + if type(value) == "string" then + for entry in value:gmatch("[^,]+") do + add_entry(entry) + end + end + end + + for _, host in ipairs({ "localhost", "127.0.0.1", "::1" }) do + add_entry(host) + end + + return table.concat(entries, ",") +end + ---Gets the claude command string and necessary environment variables ---@param cmd_args string? Optional arguments to append to the command ---@return string cmd_string The command string @@ -322,6 +357,18 @@ local function get_claude_command_and_env(cmd_args) env_table[key] = value end + -- Issue #70: Claude honors http_proxy/all_proxy (proxy-from-env semantics) and, without a + -- localhost exclusion, tunnels even its ws://127.0.0.1: IDE connection through the + -- proxy, so the handshake never reaches our server and queued @ mentions time out. Guarantee + -- the loopback hosts bypass the proxy. This runs LAST -- after the config merge above and + -- regardless of the inherited env (termopen layers env_table over the parent env) -- so the + -- loopback exclusion always holds. We merge, rather than clobber, every existing source: the + -- inherited shell no_proxy/NO_PROXY and any value the user set via the `env` config option. + local combined_no_proxy = + no_proxy_with_loopback(os.getenv("no_proxy"), os.getenv("NO_PROXY"), env_table["no_proxy"], env_table["NO_PROXY"]) + env_table["no_proxy"] = combined_no_proxy + env_table["NO_PROXY"] = combined_no_proxy + return cmd_string, env_table end diff --git a/scripts/repro_issue_70.sh b/scripts/repro_issue_70.sh new file mode 100755 index 00000000..c0186a9d --- /dev/null +++ b/scripts/repro_issue_70.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +# +# Reproduce issue #70: "[BUG] Sending files, current buffer, or lines to claude +# doesn't work." -> [ClaudeCode] [queue] [ERROR] Connection timeout - clearing N +# queued @ mentions. +# +# https://github.com/coder/claudecode.nvim/issues/70 +# +# The symptom is always the same single fact: the Claude CLI never opens a +# WebSocket connection back to the plugin's server, so queued @ mentions expire. +# This harness isolates ONE currently-live, plugin-relevant root cause -- an +# HTTP(S)/ALL proxy in the environment that has no localhost exclusion, which +# Claude honors for its `ws://127.0.0.1:` IDE connection (matching the +# `no_proxy=localhost,127.0.0.1,::1` workaround reported on the issue). +# +# It starts the REAL plugin server (scripts/repro_issue_70_probe.lua), launches +# the REAL Claude CLI against it under three environments via agent-tty, and +# asserts whether Claude actually connected: +# +# A baseline (no proxy) -> EXPECT connect +# B proxy set, no localhost exclusion -> EXPECT no-connect (the bug) +# C proxy set + no_proxy=localhost,127.0.0.1,::1 -> EXPECT connect (the fix) +# +# Requirements: nvim, the `claude` CLI (logged in), `agent-tty`, and `jq`. +# This harness only ever sets env vars + points CLAUDE_CODE_SSE_PORT at its own +# throwaway server; it never touches other ~/.claude/ide lock files. + +set -uo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PROBE_LUA="$REPO_ROOT/scripts/repro_issue_70_probe.lua" +WORK="$(mktemp -d "${TMPDIR:-/tmp}/issue70.XXXXXX")" +AGENT_HOME="$WORK/att-home" +DEAD_PROXY="http://127.0.0.1:1" # closed port -> any proxied connection fails fast +WAIT_CONNECT_S=14 + +mkdir -p "$AGENT_HOME" + +for bin in nvim claude agent-tty jq; do + command -v "$bin" >/dev/null 2>&1 || { + echo "MISSING dependency: $bin" >&2 + exit 3 + } +done + +PROBE_PIDS=() +# shellcheck disable=SC2329 # invoked indirectly via `trap cleanup EXIT` +cleanup() { + for s in $(agent-tty --home "$AGENT_HOME" list --json 2>/dev/null | jq -r '.result.sessions[]?.sessionId // empty' 2>/dev/null); do + agent-tty --home "$AGENT_HOME" destroy "$s" --json >/dev/null 2>&1 + done + for p in "${PROBE_PIDS[@]:-}"; do [ -n "$p" ] && kill "$p" 2>/dev/null; done + pkill -f "repro_issue_70_probe.lua $REPO_ROOT" 2>/dev/null + rm -rf "$WORK" +} +trap cleanup EXIT + +# start_probe -> echoes the port +start_probe() { + local status="$1" stop="$2" + rm -f "$status" "$stop" + nvim --headless -u NONE -l "$PROBE_LUA" "$REPO_ROOT" "$status" "$stop" 120000 >/dev/null 2>&1 & + PROBE_PIDS+=("$!") + local port="" + for _ in $(seq 1 50); do + [ -f "$status" ] && port="$(jq -r '.port // empty' "$status" 2>/dev/null)" && [ -n "$port" ] && break + sleep 0.2 + done + echo "$port" +} + +# run_scenario +run_scenario() { + local name="$1" expect="$2" envsetup="$3" + local status="$WORK/$name.json" stop="$WORK/$name.stop" + local port + port="$(start_probe "$status" "$stop")" + if [ -z "$port" ]; then + echo " [$name] FAIL: probe did not start" + touch "$stop" + return 1 + fi + + local sid + sid="$(agent-tty --home "$AGENT_HOME" create --json --cols 200 --rows 50 -- /bin/bash | jq -r '.result.sessionId')" + agent-tty --home "$AGENT_HOME" run "$sid" "cd '$REPO_ROOT'; unset http_proxy https_proxy all_proxy HTTP_PROXY HTTPS_PROXY ALL_PROXY no_proxy NO_PROXY; export ENABLE_IDE_INTEGRATION=true CLAUDE_CODE_SSE_PORT=$port; $envsetup; echo SCN_READY" --json >/dev/null 2>&1 + agent-tty --home "$AGENT_HOME" wait "$sid" --text 'SCN_READY' --timeout-ms 5000 --json >/dev/null 2>&1 + agent-tty --home "$AGENT_HOME" run "$sid" 'claude' --no-wait --json >/dev/null 2>&1 + # best-effort: dismiss a first-run trust prompt if one appears + sleep 1 + agent-tty --home "$AGENT_HOME" send-keys "$sid" Enter --json >/dev/null 2>&1 + + local connected="false" + for _ in $(seq 1 "$WAIT_CONNECT_S"); do + connected="$(jq -r '.connected // false' "$status" 2>/dev/null)" + [ "$connected" = "true" ] && break + sleep 1 + done + + # tear down this scenario's claude + server before reporting + agent-tty --home "$AGENT_HOME" type "$sid" '/quit' --json >/dev/null 2>&1 + agent-tty --home "$AGENT_HOME" send-keys "$sid" Enter --json >/dev/null 2>&1 + touch "$stop" + agent-tty --home "$AGENT_HOME" destroy "$sid" --json >/dev/null 2>&1 + + local got="noconnect" + [ "$connected" = "true" ] && got="connect" + if [ "$got" = "$expect" ]; then + echo " [$name] PASS (port $port: expected=$expect, got=$got)" + return 0 + else + echo " [$name] FAIL (port $port: expected=$expect, got=$got)" + return 1 + fi +} + +echo "issue #70 connection repro (claude: $(claude --version 2>/dev/null | head -1))" +echo "repo: $REPO_ROOT" +echo + +fails=0 +run_scenario "A_baseline" "connect" ":" || fails=$((fails + 1)) +run_scenario "B_proxy" "noconnect" "export http_proxy=$DEAD_PROXY https_proxy=$DEAD_PROXY all_proxy=$DEAD_PROXY" || fails=$((fails + 1)) +run_scenario "C_no_proxy" "connect" "export http_proxy=$DEAD_PROXY https_proxy=$DEAD_PROXY all_proxy=$DEAD_PROXY no_proxy=localhost,127.0.0.1,::1 NO_PROXY=localhost,127.0.0.1,::1" || fails=$((fails + 1)) + +echo +if [ "$fails" -eq 0 ]; then + echo "PASS issue #70 reproduced: a proxy with no localhost exclusion blocks Claude's" + echo " IDE WebSocket (B), while baseline (A) and the no_proxy fix (C) connect." + exit 0 +else + echo "INCONCLUSIVE: $fails scenario(s) did not match expectation (see above)." + echo " Most often this means the local 'claude' is not logged in / cannot reach" + echo " the IDE socket for an unrelated reason. Re-run after 'claude' works manually." + exit 1 +fi diff --git a/scripts/repro_issue_70_probe.lua b/scripts/repro_issue_70_probe.lua new file mode 100644 index 00000000..2ce33849 --- /dev/null +++ b/scripts/repro_issue_70_probe.lua @@ -0,0 +1,85 @@ +-- Issue #70 connection probe. +-- +-- Starts the REAL claudecode WebSocket server (terminal provider "none", so this +-- process never launches Claude itself) and keeps it listening for the full +-- window, continuously writing its connection state to as JSON. +-- Stops early if appears. +-- +-- A separate harness (scripts/repro_issue_70.sh) launches the real Claude CLI +-- against the port reported here, under different environments, and reads the +-- status file to decide whether Claude actually connected. This isolates the bug +-- in issue #70 to a single observable fact: did Claude open a WebSocket back to +-- the plugin's server, or not? +-- +-- Run: nvim --headless -u NONE -l scripts/repro_issue_70_probe.lua \ +-- + +local repo = arg[1] +local status_file = arg[2] +local stop_file = arg[3] +local wait_ms = tonumber(arg[4] or "120000") + +package.path = repo .. "/lua/?.lua;" .. repo .. "/lua/?/init.lua;" .. package.path + +local function write_status(tbl) + local f = assert(io.open(status_file, "w")) + f:write(vim.json.encode(tbl)) + f:close() +end + +local function file_exists(p) + local fd = io.open(p, "r") + if fd then + fd:close() + return true + end + return false +end + +local claudecode = require("claudecode") +claudecode.setup({ auto_start = true, terminal = { provider = "none" }, log_level = "debug" }) + +local port = claudecode.state and claudecode.state.port +if not port then + write_status({ phase = "error", error = "server did not start / no port" }) + os.exit(1) +end + +local config_dir = os.getenv("CLAUDE_CONFIG_DIR") +local lock_dir = (config_dir and config_dir ~= "" and (config_dir .. "/ide")) or (os.getenv("HOME") .. "/.claude/ide") +local lock_path = lock_dir .. "/" .. port .. ".lock" + +io.stderr:write( + ("PROBE listening port=%d lock=%s exists=%s\n"):format(port, lock_path, tostring(file_exists(lock_path))) +) +write_status({ phase = "listening", port = port, lock_path = lock_path, lock_exists = file_exists(lock_path) }) + +local server_module = require("claudecode.server.init") +local start = vim.loop.now() +local connected_ever = false +while (vim.loop.now() - start) < wait_ms do + vim.wait(500, function() + return false + end) -- pump the libuv loop for ~500ms + local st = server_module.get_status() + local conn = claudecode.is_claude_connected() + if conn then + connected_ever = true + end + write_status({ + phase = conn and "connected" or "listening", + port = port, + lock_path = lock_path, + lock_exists = file_exists(lock_path), + connected = conn, + connected_ever = connected_ever, + client_count = st.client_count, + }) + if file_exists(stop_file) then + break + end +end + +io.stderr:write("PROBE done connected_ever=" .. tostring(connected_ever) .. "\n") +claudecode.stop() -- removes the lock file +os.exit(connected_ever and 0 or 2) diff --git a/tests/unit/terminal_spec.lua b/tests/unit/terminal_spec.lua index cd61b70a..f15b13e6 100644 --- a/tests/unit/terminal_spec.lua +++ b/tests/unit/terminal_spec.lua @@ -462,6 +462,137 @@ describe("claudecode.terminal (wrapper for Snacks.nvim)", function() end ) + it("should add loopback hosts to no_proxy and NO_PROXY (issue #70)", function() + terminal_wrapper.open() + + mock_snacks_provider.open:was_called(1) + local env_arg = mock_snacks_provider.open:get_call(1).refs[2] + + assert.is_table(env_arg) + for _, host in ipairs({ "localhost", "127.0.0.1", "::1" }) do + assert.is_truthy(env_arg.no_proxy:find(host, 1, true)) + assert.is_truthy(env_arg.NO_PROXY:find(host, 1, true)) + end + end) + + it("should preserve and dedupe a pre-existing no_proxy exclusion (issue #70)", function() + local ffi = require("ffi") + -- pcall: setenv/unsetenv may already be declared by a sibling test in this Lua state. + pcall( + ffi.cdef, + [[ + int setenv(const char *name, const char *value, int overwrite); + int unsetenv(const char *name); + ]] + ) + -- Control BOTH vars: production reads no_proxy AND NO_PROXY, so an inherited NO_PROXY on + -- the host (e.g. "127.0.0.10") must not bleed into the exact-count assertion below. + local prev_lower, prev_upper = os.getenv("no_proxy"), os.getenv("NO_PROXY") + ffi.C.setenv("no_proxy", "corp.internal,127.0.0.1", 1) + ffi.C.unsetenv("NO_PROXY") + + local ok, err = pcall(function() + terminal_wrapper.open() + + mock_snacks_provider.open:was_called(1) + local env_arg = mock_snacks_provider.open:get_call(1).refs[2] + + assert.is_table(env_arg) + assert.is_truthy(env_arg.no_proxy:find("corp.internal", 1, true)) + assert.is_truthy(env_arg.no_proxy:find("localhost", 1, true)) + + -- 127.0.0.1 must appear exactly once even though it was already present. Count exact + -- comma-delimited entries, not a substring match (which would also catch 127.0.0.10). + local count = 0 + for entry in env_arg.no_proxy:gmatch("[^,]+") do + if entry == "127.0.0.1" then + count = count + 1 + end + end + assert.are.equal(1, count) + end) + + -- Restore both vars so this test cannot leak into others or delete a real no_proxy. + if prev_lower then + ffi.C.setenv("no_proxy", prev_lower, 1) + else + ffi.C.unsetenv("no_proxy") + end + if prev_upper then + ffi.C.setenv("NO_PROXY", prev_upper, 1) + else + ffi.C.unsetenv("NO_PROXY") + end + + assert.is_true(ok, tostring(err)) + end) + + it("should merge exclusions from BOTH no_proxy and NO_PROXY when they differ (issue #70)", function() + local ffi = require("ffi") + pcall( + ffi.cdef, + [[ + int setenv(const char *name, const char *value, int overwrite); + int unsetenv(const char *name); + ]] + ) + local prev_lower, prev_upper = os.getenv("no_proxy"), os.getenv("NO_PROXY") + ffi.C.setenv("no_proxy", "lower.example", 1) + ffi.C.setenv("NO_PROXY", "upper.example", 1) + + local ok, err = pcall(function() + terminal_wrapper.open() + + mock_snacks_provider.open:was_called(1) + local env_arg = mock_snacks_provider.open:get_call(1).refs[2] + + assert.is_table(env_arg) + -- An exclusion set in EITHER variable must survive (not just the one we happened to read). + assert.is_truthy(env_arg.no_proxy:find("lower.example", 1, true)) + assert.is_truthy(env_arg.no_proxy:find("upper.example", 1, true)) + assert.is_truthy(env_arg.no_proxy:find("localhost", 1, true)) + end) + + if prev_lower then + ffi.C.setenv("no_proxy", prev_lower, 1) + else + ffi.C.unsetenv("no_proxy") + end + if prev_upper then + ffi.C.setenv("NO_PROXY", prev_upper, 1) + else + ffi.C.unsetenv("NO_PROXY") + end + + assert.is_true(ok, tostring(err)) + end) + + it("should keep loopback hosts even when no_proxy is set via the env config (issue #70)", function() + -- A user routing their own no_proxy through the plugin's `env` config must NOT lose the + -- loopback exclusion (that would silently re-break #70). The injection runs after the + -- config merge and folds the config-supplied value in rather than being overwritten by it. + terminal_wrapper.setup({}, nil, { no_proxy = "config.example" }) + + local ok, err = pcall(function() + terminal_wrapper.open() + + mock_snacks_provider.open:was_called(1) + local env_arg = mock_snacks_provider.open:get_call(1).refs[2] + + assert.is_table(env_arg) + assert.is_truthy(env_arg.no_proxy:find("config.example", 1, true)) + for _, host in ipairs({ "localhost", "127.0.0.1", "::1" }) do + assert.is_truthy(env_arg.no_proxy:find(host, 1, true)) + assert.is_truthy(env_arg.NO_PROXY:find(host, 1, true)) + end + end) + + -- Reset defaults.env so this config cannot leak into sibling tests. + terminal_wrapper.setup({}) + + assert.is_true(ok, tostring(err)) + end) + it("should call Snacks.terminal.open with terminal_cmd from main config", function() vim.g.claudecode_user_config = { terminal_cmd = "my_claude_cli" } mock_claudecode_config_module.apply = spy.new(function()