From 6f41c49e4fc8faab22e3185d8bc993fce9d7dde9 Mon Sep 17 00:00:00 2001 From: Aleksandr Filippov Date: Tue, 30 Jun 2026 13:49:56 +0300 Subject: [PATCH 1/2] fix: preserve ${VAR} placeholders in MCP server headers for runtime expansion configure_mcp_server() interpolated the MCP --header (and the Windows --env) into the claude mcp add bash command using double quotes, so a ${VAR} placeholder in an Authorization header was expanded by the installer shell at setup time, baking the resolved token (or an empty string when the variable was unset) into the config instead of preserving the placeholder. Switch these interpolations to shlex.quote, matching the Unix --env path that already did so, so the literal ${VAR} reaches claude mcp add and is stored verbatim; Claude Code then expands it from the environment at runtime, making env-var-referencing MCP headers work at user, local, and project scope, deterministically and independent of OS or shell. Invert two tests that encoded the old double-quote behavior, add a Windows sibling unit test and a real cross-OS e2e test that drives the actual platform shell via a fake claude recorder, and document the runtime ${VAR} expansion, shell/OS independence, and the must-be-set / ${VAR:-default} caveat. --- README.md | 3 + docs/environment-configuration-guide.md | 5 +- scripts/setup_environment.py | 23 +++- tests/e2e/test_mcp_argument_ordering.py | 152 ++++++++++++++++++--- tests/test_setup_environment.py | 63 +++++++-- tests/test_setup_environment_additional.py | 11 +- 6 files changed, 216 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 3e1699a..c40450b 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,9 @@ mcp-servers: - name: "context-server" transport: "http" url: "http://localhost:8000/mcp" + # ${VAR} in a header is stored as-is and expanded by Claude Code at runtime, + # so the token stays in your environment, never in the config file. + header: "Authorization: Bearer ${CONTEXT_SERVER_TOKEN}" model: "sonnet" effort-level: "high" diff --git a/docs/environment-configuration-guide.md b/docs/environment-configuration-guide.md index 88a0be9..a1c267c 100644 --- a/docs/environment-configuration-guide.md +++ b/docs/environment-configuration-guide.md @@ -524,9 +524,12 @@ mcp-servers: transport: "http" url: "http://localhost:3000/api" header: "Authorization: Bearer ${MY_TOKEN}" - env: "MY_TOKEN" ``` +**Environment-variable header values (`${VAR}`):** A `${VAR}` (or `${VAR:-default}`) reference inside `header` is preserved literally in the Claude Code configuration and expanded from the environment by Claude Code **at runtime**, when a session starts -- the secret itself is never written into any configuration file, only the placeholder is stored. The setup script preserves the placeholder verbatim when it registers the server (it does not expand it at install time), so the behavior is identical on every operating system and shell. This is the recommended way to configure an authenticated remote MCP server: each user sets the variable (for example `MY_TOKEN`) as a real environment variable on their own machine, and only this placeholder configuration is shared. Do not add `env: "MY_TOKEN"` for this purpose -- `env` configures variables passed to a server and is unrelated to where the header reads its value. + +> **The variable must be set when Claude Code launches.** If a `${VAR}` reference has no value and no default, Claude Code fails to parse the MCP configuration. Ensure the variable is exported before launching Claude Code, or provide a fallback with `${VAR:-default}`. + #### SSE Transport Uses the same fields as HTTP transport with `transport: "sse"`. diff --git a/scripts/setup_environment.py b/scripts/setup_environment.py index cf56b28..c212fb2 100644 --- a/scripts/setup_environment.py +++ b/scripts/setup_environment.py @@ -8833,9 +8833,15 @@ def configure_mcp_server( path_preview = explicit_path[:200] + '...' if len(explicit_path) > 200 else explicit_path debug_log(f'unix_explicit_path: {path_preview}') - env_flags = ' '.join(f'--env "{e}"' for e in env_list) if env_list else '' + # Single-quote --env/--header (via shlex.quote) so a ${VAR} placeholder is + # passed to `claude mcp add` literally and stored verbatim in the config; + # Claude Code expands it from the environment at runtime. Double-quoting + # would let Git Bash expand ${VAR} at setup time, baking the resolved value + # (or an empty string when the variable is unset) into the config instead. + # This matches the Unix branch and escapes any embedded special characters. + env_flags = ' '.join(f'--env {shlex.quote(e)}' for e in env_list) if env_list else '' env_part = f' {env_flags}' if env_flags else '' - header_part = f' --header "{header}"' if header else '' + header_part = f' --header {shlex.quote(header)}' if header else '' bash_cmd = ( f'"{env.unix_claude_cmd}" mcp add --scope {scope}{env_part} ' @@ -8859,8 +8865,11 @@ def configure_mcp_server( parent_dir = Path(claude_cmd).parent env_flags = ' '.join(f'--env {shlex.quote(e)}' for e in env_list) if env_list else '' env_part = f' {env_flags}' if env_flags else '' - # Use double quotes for header to allow ${VAR} expansion in bash - header_part = f' --header "{header}"' if header else '' + # Single-quote the header (via shlex.quote) so a ${VAR} placeholder is + # passed to `claude mcp add` literally and stored verbatim; Claude Code + # expands it at runtime. Double-quoting would let bash expand ${VAR} at + # setup time, baking the resolved value (or an empty string) into the config. + header_part = f' --header {shlex.quote(header)}' if header else '' bash_cmd = ( f'{shlex.quote(str(claude_cmd))} mcp add --scope {shlex.quote(scope)}{env_part} ' f'--transport {shlex.quote(transport)} {shlex.quote(name)} {shlex.quote(url)}{header_part}' @@ -8905,7 +8914,11 @@ def configure_mcp_server( path_preview = explicit_path[:200] + '...' if len(explicit_path) > 200 else explicit_path debug_log(f'unix_explicit_path: {path_preview}') - env_flags = ' '.join(f'--env "{e}"' for e in env_list) if env_list else '' + # Single-quote --env (via shlex.quote) so a ${VAR} placeholder in an env + # value is passed literally and expanded by Claude Code at runtime, matching + # the Unix stdio path which passes --env as argv. Double-quoting would let + # Git Bash expand ${VAR} at setup time. + env_flags = ' '.join(f'--env {shlex.quote(e)}' for e in env_list) if env_list else '' env_part = f' {env_flags}' if env_flags else '' # Build command string for STDIO diff --git a/tests/e2e/test_mcp_argument_ordering.py b/tests/e2e/test_mcp_argument_ordering.py index f3eb12e..708fc5a 100644 --- a/tests/e2e/test_mcp_argument_ordering.py +++ b/tests/e2e/test_mcp_argument_ordering.py @@ -12,12 +12,16 @@ from __future__ import annotations +import shutil import subprocess +import sys from pathlib import Path from typing import Any from unittest.mock import MagicMock from unittest.mock import patch +import pytest + from scripts import setup_environment from scripts.setup_environment import configure_all_mcp_servers @@ -39,6 +43,16 @@ def _find_mcp_add_call(call_args_list: list[Any], server_name: str) -> str | Non return None +# Whether a usable bash is unavailable on this host, evaluated once at collection time. +# The real-shell MCP test needs Git Bash on Windows (run_bash_command) or native bash on +# Unix; skip it cleanly when neither is present rather than failing. +_BASH_UNAVAILABLE: bool = ( + setup_environment.find_bash_windows() is None + if sys.platform == 'win32' + else shutil.which('bash') is None +) + + class TestMCPArgumentOrdering: """E2E tests for CLI argument ordering in MCP server configuration.""" @@ -303,20 +317,20 @@ def test_combined_scope_dispatches_user_and_profile( ) -class TestMCPHeaderEnvVarExpansion: - """E2E tests for header environment variable expansion using double quotes.""" +class TestMCPHeaderEnvVarPlaceholder: + """E2E tests: a ${VAR} header placeholder is preserved literally for runtime expansion. + + The setup script must NOT expand ${VAR} at install time. It single-quotes the header so + `claude mcp add` stores the literal placeholder; Claude Code expands it from the + environment when a session starts. These tests cover both the generated command string + and -- on the real platform shell -- the value that actually reaches `claude mcp add`. + """ - def test_http_header_with_env_var_uses_double_quotes_unix( + def test_http_header_env_var_placeholder_single_quoted_unix( self, e2e_isolated_home: dict[str, Path], - golden_config: dict[str, Any], ) -> None: - """Verify Unix HTTP transport wraps header in double quotes for ${VAR} expansion. - - Single quotes (from shlex.quote) prevent bash variable expansion. - Header values containing ${VAR} patterns must use double quotes so - bash resolves environment variables at runtime. - """ + """The generated Unix bash command single-quotes a ${VAR} header (no setup-time expansion).""" mock_bash = MagicMock( return_value=subprocess.CompletedProcess([], 0, '', ''), ) @@ -324,6 +338,14 @@ def test_http_header_with_env_var_uses_double_quotes_unix( return_value=subprocess.CompletedProcess([], 0, '', ''), ) + server = { + 'name': 'e2e-envheader-server', + 'scope': 'user', + 'transport': 'http', + 'url': 'https://api.example.com/mcp', + 'header': 'Authorization: Bearer ${E2E_YT_TOKEN}', + } + with ( patch('platform.system', return_value='Linux'), patch.object( @@ -335,23 +357,113 @@ def test_http_header_with_env_var_uses_double_quotes_unix( ): profile_mcp_path = e2e_isolated_home['claude_dir'] / 'mcp.json' configure_all_mcp_servers( - servers=golden_config.get('mcp-servers', []), + servers=[server], profile_mcp_config_path=profile_mcp_path, ) - # e2e-http-server has header "X-API-Key: test-key" - bash_cmd = _find_mcp_add_call(mock_bash.call_args_list, 'e2e-http-server') + bash_cmd = _find_mcp_add_call(mock_bash.call_args_list, 'e2e-envheader-server') assert bash_cmd is not None, ( - 'No mcp add command found for e2e-http-server in run_bash_command calls' + 'No mcp add command found for e2e-envheader-server in run_bash_command calls' ) - # Header must be wrapped in double quotes (not single quotes) - assert '--header "' in bash_cmd, ( - f'Header must use double quotes for ${{VAR}} expansion, got: {bash_cmd}' + # The placeholder must be single-quoted (literal), never double-quoted (which would + # let bash expand ${E2E_YT_TOKEN} at setup time instead of preserving it for Claude). + assert "--header 'Authorization: Bearer ${E2E_YT_TOKEN}'" in bash_cmd, ( + f'Header must be single-quoted to preserve the placeholder, got: {bash_cmd}' + ) + assert '--header "' not in bash_cmd, ( + f'Header must NOT be double-quoted (would expand ${{VAR}} at setup time), got: {bash_cmd}' ) - # Verify single quotes are NOT used around the header value - assert "--header '" not in bash_cmd, ( - 'Header must NOT use single quotes (blocks ${VAR} expansion)' + assert '${E2E_YT_TOKEN}' in bash_cmd, ( + 'The literal ${VAR} placeholder must be present in the generated command' + ) + + @pytest.mark.skipif(_BASH_UNAVAILABLE, reason='bash/Git Bash not available on this host') + def test_http_header_env_var_placeholder_survives_real_shell( + self, + e2e_isolated_home: dict[str, Path], + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Real cross-OS: the literal ${VAR} placeholder reaches `claude mcp add` through the actual shell. + + Drives configure_mcp_server end-to-end through the real run_bash_command on whatever + OS the test runs (native bash on Linux/macOS, Git Bash on Windows). A fake `claude` + records the exact argv it receives. With the auth env var SET to a sentinel at setup + time, the recorded --header must still be the literal ${VAR} placeholder -- proving the + installer shell does not expand it (Claude Code expands it later, at session start). + """ + # A fake `claude` that records the exact argv of every invocation, then succeeds. + local_bin = e2e_isolated_home['local_bin'] + fake_claude = local_bin / 'claude' + fake_claude.write_text( + '#!/usr/bin/env bash\n' + 'for arg in "$@"; do printf "%s\\n" "$arg" >> "$CLAUDE_E2E_RECORD"; done\n' + 'printf "INVOCATION_END\\n" >> "$CLAUDE_E2E_RECORD"\n' + 'exit 0\n', + encoding='utf-8', + ) + fake_claude.chmod(0o755) + + record_path = e2e_isolated_home['home'] / 'mcp_add_record.txt' + # Git Bash needs a POSIX-style path for the redirection target. + if sys.platform == 'win32': + record_env_value = setup_environment.convert_to_unix_path(str(record_path)) + else: + record_env_value = str(record_path) + monkeypatch.setenv('CLAUDE_E2E_RECORD', record_env_value) + + # Set the auth variable at SETUP time. If the installer wrongly expanded ${VAR} + # (the old double-quote bug), this sentinel would leak into the recorded header. + monkeypatch.setenv('E2E_YT_TOKEN', 'LEAKED_AT_SETUP') + + # Point find_command('claude') at the fake recorder, overriding the autouse mock + # (which returns None). Other command lookups pass through. + original_find = setup_environment.find_command + + def fake_find(cmd: str) -> str | None: + if cmd == 'claude': + return str(fake_claude) + return original_find(cmd) + + monkeypatch.setattr(setup_environment, 'find_command', fake_find) + + server = { + 'name': 'e2e-envheader-server', + 'scope': 'user', + 'transport': 'http', + 'url': 'https://api.example.com/mcp', + 'header': 'Authorization: Bearer ${E2E_YT_TOKEN}', + } + + # No platform.system / run_bash_command mocking: exercise the real OS shell. + result = setup_environment.configure_mcp_server(server) + assert result is True, ( + 'configure_mcp_server should succeed with the fake claude recorder' + ) + + assert record_path.exists(), ( + f'Fake claude recorded nothing at {record_path}; the add command did not run' + ) + recorded = record_path.read_text(encoding='utf-8').splitlines() + + # `claude mcp add ... --header ` -> --header and its value are separate argv + # elements, so the value is the line immediately after the --header line. + header_value = None + for i, line in enumerate(recorded): + if line == '--header' and i + 1 < len(recorded): + header_value = recorded[i + 1] + break + + assert header_value is not None, ( + f'No --header argument reached claude. Recorded argv: {recorded}' + ) + assert header_value == 'Authorization: Bearer ${E2E_YT_TOKEN}', ( + 'The real shell must pass the literal ${VAR} placeholder to claude, ' + f'got: {header_value!r}' + ) + assert 'LEAKED_AT_SETUP' not in header_value, ( + 'The setup-time value of E2E_YT_TOKEN must NOT leak into the stored header ' + '(it must be expanded by Claude Code at runtime, not by the installer shell)' ) diff --git a/tests/test_setup_environment.py b/tests/test_setup_environment.py index 4a6b213..8c3e28d 100644 --- a/tests/test_setup_environment.py +++ b/tests/test_setup_environment.py @@ -2962,12 +2962,13 @@ def test_configure_mcp_server_http_with_env_and_header_argument_order(self, mock @patch('setup_environment.run_bash_command') @patch('setup_environment.find_command') @patch('setup_environment.run_command') - def test_configure_mcp_server_http_header_env_var_expansion_unix(self, mock_run, mock_find, mock_bash): - """Test Unix HTTP transport wraps header in double quotes to allow ${VAR} expansion. + def test_configure_mcp_server_http_header_env_var_placeholder_preserved_unix(self, mock_run, mock_find, mock_bash): + """Unix HTTP transport single-quotes the header so a ${VAR} placeholder is preserved literally. - Single quotes (from shlex.quote) prevent bash variable expansion. - Header values containing ${VAR} patterns must use double quotes so - bash resolves environment variables at runtime. + The placeholder must reach `claude mcp add` verbatim and be stored as-is in the + config; Claude Code expands ${VAR} from the environment at runtime. Double quoting + would let bash expand ${VAR} at setup time, baking the resolved value (or an empty + string when the variable is unset) into the config instead of the placeholder. """ mock_find.return_value = '/usr/local/bin/claude' mock_run.return_value = subprocess.CompletedProcess([], 0, '', '') @@ -2987,14 +2988,52 @@ def test_configure_mcp_server_http_header_env_var_expansion_unix(self, mock_run, assert result is True bash_cmd = mock_bash.call_args[0][0] - # Header must be wrapped in double quotes (not single quotes) - # to allow bash ${VAR} expansion at runtime - assert '--header "Authorization:${MY_AUTH_TOKEN}"' in bash_cmd, ( - f'Header must use double quotes for ${{VAR}} expansion, got: {bash_cmd}' + # Header must be single-quoted so bash does NOT expand ${VAR} at setup time; + # the literal placeholder is stored and Claude Code expands it at runtime. + assert "--header 'Authorization:${MY_AUTH_TOKEN}'" in bash_cmd, ( + f'Header must be single-quoted to preserve the ${{VAR}} placeholder, got: {bash_cmd}' ) - # Verify single quotes are NOT used around the header value - assert "--header 'Authorization:${MY_AUTH_TOKEN}'" not in bash_cmd, ( - 'Header must NOT use single quotes (blocks ${VAR} expansion)' + # Double quotes around the header would trigger setup-time bash expansion. + assert '--header "Authorization:${MY_AUTH_TOKEN}"' not in bash_cmd, ( + 'Header must NOT use double quotes (would expand ${VAR} at setup time)' + ) + + @patch('platform.system', return_value='Windows') + @patch('setup_environment.run_bash_command') + @patch('setup_environment.run_command') + @patch('setup_environment.find_command') + def test_configure_mcp_server_http_header_env_var_placeholder_preserved_windows( + self, mock_find, mock_run_cmd, mock_bash_cmd, mock_system, + ): + """Windows HTTP transport single-quotes the header so a ${VAR} placeholder is preserved literally. + + Git Bash must receive the literal ${VAR} placeholder and pass it to `claude mcp add` + verbatim; Claude Code expands it at runtime. Double quoting would let Git Bash expand + ${VAR} at setup time, baking the resolved value (or an empty string) into the config. + """ + del mock_system # Unused but required for patch + mock_find.return_value = 'C:\\Users\\Test\\AppData\\Roaming\\npm\\claude.CMD' + mock_run_cmd.return_value = subprocess.CompletedProcess([], 0, '', '') + mock_bash_cmd.return_value = subprocess.CompletedProcess([], 0, '', '') + + server = { + 'name': 'env-header-server', + 'scope': 'user', + 'transport': 'http', + 'url': 'https://api.example.com/mcp', + 'header': 'Authorization:${MY_AUTH_TOKEN}', + } + + result = setup_environment.configure_mcp_server(server) + assert result is True + + # The last bash call is the `mcp add` (preceded by 3 best-effort removes). + bash_cmd = mock_bash_cmd.call_args[0][0] + assert "--header 'Authorization:${MY_AUTH_TOKEN}'" in bash_cmd, ( + f'Header must be single-quoted to preserve the ${{VAR}} placeholder on Windows, got: {bash_cmd}' + ) + assert '--header "Authorization:${MY_AUTH_TOKEN}"' not in bash_cmd, ( + 'Header must NOT use double quotes on Windows (would expand ${VAR} at setup time)' ) @patch('platform.system', return_value='Windows') diff --git a/tests/test_setup_environment_additional.py b/tests/test_setup_environment_additional.py index 9ea2b8d..3bf3cde 100644 --- a/tests/test_setup_environment_additional.py +++ b/tests/test_setup_environment_additional.py @@ -1225,6 +1225,7 @@ def test_configure_mcp_server_http_with_env_list(self, mock_run, mock_bash, mock 'env': [ 'AUTH_TOKEN=token123', 'REGION=us-west', + 'TOKEN=${SECRET_TOKEN}', ], } @@ -1235,10 +1236,14 @@ def test_configure_mcp_server_http_with_env_list(self, mock_run, mock_bash, mock assert mock_run.call_count == 0 assert mock_bash.call_count == 4 - # Check bash command contains env flags (last bash call is add) + # Check bash command contains env flags (last bash call is add). Env values are + # quoted via shlex.quote (matching the Unix path): safe values need no quotes, + # while a ${VAR} placeholder is single-quoted so Git Bash does not expand it at + # setup time (Claude Code expands it from the environment at runtime). bash_cmd = mock_bash.call_args[0][0] - assert '--env "AUTH_TOKEN=token123"' in bash_cmd - assert '--env "REGION=us-west"' in bash_cmd + assert '--env AUTH_TOKEN=token123' in bash_cmd + assert '--env REGION=us-west' in bash_cmd + assert "--env 'TOKEN=${SECRET_TOKEN}'" in bash_cmd class TestCreateSettingsComplex: From 0fa2a153e52d5e77f0fb8a226d7b0702d5d63bdb Mon Sep 17 00:00:00 2001 From: Aleksandr Filippov Date: Tue, 30 Jun 2026 13:50:16 +0300 Subject: [PATCH 2/2] test: replace stale install_claude.py size xfail with download-to-file guard test_install_claude_below_error_threshold asserted install_claude.py stays under a 120 KiB ceiling (Linux MAX_ARG_STRLEN/E2BIG), but the file is ~182 KiB, so it had been marked xfail. That ceiling is obsolete: the Linux/macOS bootstrap scripts download install_claude.py to a file (curl -o) and run it via uv run , never passing it inline as an execve() argument, so file size no longer maps to a real failure mode. Replace the xfail'd hard gate (and the now-unused ERROR_THRESHOLD_BYTES) with a parametrized test asserting the real invariant -- every Linux/macOS bootstrap downloads install_claude.py to a file and never pipes a fetched script into uv/python -- while keeping the informational size warning. --- tests/test_script_size_limits.py | 91 +++++++++++++++++++++++--------- 1 file changed, 66 insertions(+), 25 deletions(-) diff --git a/tests/test_script_size_limits.py b/tests/test_script_size_limits.py index 7d61030..75d99c7 100644 --- a/tests/test_script_size_limits.py +++ b/tests/test_script_size_limits.py @@ -1,13 +1,21 @@ -"""Tests enforcing script size limits to prevent E2BIG regressions. +"""Tests guarding against E2BIG ('Argument list too long') regressions in the installer. -The Linux kernel's MAX_ARG_STRLEN limit (131,072 bytes / 128 KiB) causes -'Argument list too long' (E2BIG) errors when scripts are passed as single -execve() arguments. Bootstrap scripts that download-to-file avoid this, -but monitoring ensures the limit is never silently exceeded. +The Linux kernel's MAX_ARG_STRLEN limit (131,072 bytes / 128 KiB) causes E2BIG when a +script is passed as a single execve() argument -- for example when piped via stdin and run +as `python -c ""`. The bootstrap scripts avoid this by downloading +install_claude.py to a file and running `uv run `, which is safe at any size. + +Two guards live here: +- The real invariant: every Linux/macOS bootstrap script materializes install_claude.py to + a file (curl -o ...) and never pipes it inline into an interpreter. This holds at any + script size, so it is the authoritative protection -- not a byte ceiling. +- A size warning: install_claude.py size is monitored and warns past a comfortable + threshold, since smaller scripts download faster and keep headroom under the kernel limit. See: https://github.com/astral-sh/uv/issues/11220 """ +import re import warnings from pathlib import Path @@ -16,33 +24,66 @@ SCRIPTS_DIR = Path(__file__).parent.parent / 'scripts' INSTALL_CLAUDE = SCRIPTS_DIR / 'install_claude.py' -# Linux kernel MAX_ARG_STRLEN = 131,072 bytes (128 KiB) -# Warn threshold: 100 KiB (provides 28 KiB headroom) -# Error threshold: 120 KiB (provides 8 KiB headroom) -WARN_THRESHOLD_BYTES = 100 * 1024 # 102,400 -ERROR_THRESHOLD_BYTES = 120 * 1024 # 122,880 +# Linux kernel MAX_ARG_STRLEN = 131,072 bytes (128 KiB). It binds only when a script is +# passed inline as an execve() argument; the download-to-file bootstrap avoids it entirely. MAX_ARG_STRLEN = 128 * 1024 # 131,072 +# Warn threshold: 100 KiB (smaller scripts download faster and keep headroom). +WARN_THRESHOLD_BYTES = 100 * 1024 # 102,400 +# Bootstrap shell scripts that fetch and use install_claude.py. On Linux/macOS each must +# download it to a FILE and run that file, never pipe the script content into an interpreter +# (which would make it one execve() argument and risk E2BIG). This invariant holds at any +# script size, so it -- not a byte ceiling -- is the meaningful regression guard. +BOOTSTRAP_SHELL_SCRIPTS = ( + SCRIPTS_DIR / 'linux' / 'install-claude-linux.sh', + SCRIPTS_DIR / 'macos' / 'install-claude-macos.sh', + SCRIPTS_DIR / 'linux' / 'setup-environment.sh', + SCRIPTS_DIR / 'macos' / 'setup-environment.sh', +) -class TestScriptSizeLimits: - """Enforce script size limits to prevent E2BIG regressions on Linux.""" - @pytest.mark.xfail( - reason='install_claude.py is currently ~157 KiB, exceeding 120 KiB limit. ' - 'Bootstrap scripts now use download-to-file, but size should be reduced.', - strict=False, +def _strip_comments(shell_source: str) -> str: + """Drop full-line shell comments so explanatory text (e.g. a 'python -c' example) is ignored.""" + return '\n'.join( + line for line in shell_source.splitlines() if not line.lstrip().startswith('#') ) - def test_install_claude_below_error_threshold(self) -> None: - """install_claude.py MUST stay below 120 KiB to maintain Linux compatibility.""" - size = INSTALL_CLAUDE.stat().st_size - assert size < ERROR_THRESHOLD_BYTES, ( - f'install_claude.py is {size:,} bytes ({size / 1024:.1f} KiB), ' - f'which exceeds the error threshold of {ERROR_THRESHOLD_BYTES:,} bytes ' - f'({ERROR_THRESHOLD_BYTES / 1024:.0f} KiB). ' - f'The Linux kernel MAX_ARG_STRLEN limit is {MAX_ARG_STRLEN:,} bytes (128 KiB). ' - f'Reduce the script size to prevent E2BIG errors on Linux/WSL.' + + +class TestInstallClaudeNotPipedInline: + """The installer must reach the interpreter as a file, never as an inline execve() argument.""" + + @pytest.mark.parametrize( + 'script_path', + BOOTSTRAP_SHELL_SCRIPTS, + ids=lambda p: f'{p.parent.name}/{p.name}', + ) + def test_bootstrap_downloads_install_claude_to_file(self, script_path: Path) -> None: + """Each bootstrap downloads install_claude.py to a file and never pipes it into an interpreter. + + Passing the script inline as a single execve() argument is what risks E2BIG on Linux + (MAX_ARG_STRLEN). Downloading to a file and running `uv run ` avoids that at any + size, so this is the invariant worth guarding instead of a byte ceiling. + """ + assert script_path.exists(), f'Bootstrap script missing: {script_path}' + code = _strip_comments(script_path.read_text(encoding='utf-8')) + + # install_claude.py must be written to a file via `curl ... -o install_claude.py`. + assert re.search(r'-o\s+\S*install_claude\.py', code), ( + f'{script_path} must download install_claude.py to a file ' + '(curl -o ...install_claude.py) rather than piping it inline, to avoid E2BIG on Linux.' + ) + + # It must never pipe a fetched script straight into an interpreter (the inline + # anti-pattern that passes the whole script as one execve() argument). + assert not re.search(r'\|\s*(?:uv run|python[0-9.]*)\b', code), ( + f'{script_path} must not pipe a downloaded script into uv/python; that risks E2BIG. ' + 'Download to a file and run `uv run ` instead.' ) + +class TestScriptSizeLimits: + """Monitor install_claude.py size (informational; the download-to-file guard is authoritative).""" + def test_install_claude_below_warn_threshold(self) -> None: """install_claude.py SHOULD stay below 100 KiB for comfortable headroom.""" size = INSTALL_CLAUDE.stat().st_size