From ae87e59af2660bb4c505319991e5594aa324cad4 Mon Sep 17 00:00:00 2001 From: Phase3 <77866949+phase3dev@users.noreply.github.com> Date: Mon, 8 Jun 2026 22:59:22 -1000 Subject: [PATCH 1/2] Harden launchers and patchers (Codex audit fixes) Fixes from an external audit of the workaround launchers and patchers: - Windows wrappers no longer shell out to claude.cmd/.bat. They resolve the npm shim to node + cli.js and spawn with shell:false, failing closed if it cannot be resolved, so prompt/arg metacharacters (& | < > ^ % ") can no longer be interpreted locally. - Thinking launchers (bash + win.js) handle --flag=value forms and validate CC_THINKING_DISPLAY (summarized|omitted). - Context-icon patchers skip ambiguous multi-match bundles instead of replacing globally, matching fix-context-icon.py. - proxy.js strips hop-by-hop headers on both request and response paths and validates CC_THINKING_DISPLAY. - fix-context-icon.py writes via a same-directory temp + os.replace, preserving owner/group/mode, instead of truncate-in-place. - patch-extension.sh drops the Bash 4 mapfile for a portable read loop. - test-thinking-display.sh uses mktemp + trap and optional timeout. - Add tests/test_regressions.py (10 tests) covering the above, including the context-icon single-match happy path; ignore Python bytecode. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 1 + claude-context | 7 +- claude-context.win.js | 96 ++++++++- claude-think | 21 +- claude-think.win.js | 115 ++++++++++- claudemax | 28 ++- claudemax.win.js | 120 +++++++++++- fix-context-icon.py | 44 ++++- patch-extension.sh | 5 +- proxy.js | 166 +++++++++++----- test-thinking-display.sh | 30 ++- tests/test_regressions.py | 403 ++++++++++++++++++++++++++++++++++++++ 12 files changed, 945 insertions(+), 91 deletions(-) create mode 100644 tests/test_regressions.py diff --git a/.gitignore b/.gitignore index 4e6069f..2c3d0a4 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ node_modules/ # Python bytecode (e.g. from running fix-context-icon.py) __pycache__/ *.pyc +*.py[cod] # Editor / OS noise *.bak.* diff --git a/claude-context b/claude-context index aa4c9e7..c41b769 100755 --- a/claude-context +++ b/claude-context @@ -115,10 +115,11 @@ fi CC_PATCH_CONTEXT_ICON="${CC_PATCH_CONTEXT_ICON:-1}" _cc_patch_index_js() { - local f="$1" tmp tmp2 + local f="$1" tmp tmp2 old_count [ -f "$f" ] && [ -w "$f" ] || return 0 if grep -q '>=101)return null}' "$f" 2>/dev/null; then return 0; fi # already patched - if ! grep -q '>=50)return null}' "$f" 2>/dev/null; then return 0; fi # gate absent (version changed) + old_count="$( (grep -o '>=50)return null}' "$f" 2>/dev/null || true) | wc -l | tr -d ' ')" + if [ "$old_count" != "1" ]; then return 0; fi # absent or ambiguous (version changed) if [ ! -e "$f.bak-context-icon" ]; then cp -p "$f" "$f.bak-context-icon" 2>/dev/null || true; fi tmp="${f}.ccpatch.$$" tmp2="${f}.ccpatch2.$$" @@ -127,7 +128,7 @@ _cc_patch_index_js() { # second temp, copy that back over the metadata-preserving temp, then atomically # replace the original. A failed/partial step leaves the original untouched. cp -p "$f" "$tmp" 2>/dev/null || { rm -f "$tmp" 2>/dev/null || true; return 0; } - if sed 's/>=50)return null}/>=101)return null}/g' "$f" > "$tmp2" 2>/dev/null \ + if sed 's/>=50)return null}/>=101)return null}/' "$f" > "$tmp2" 2>/dev/null \ && [ -s "$tmp2" ] \ && grep -q '>=101)return null}' "$tmp2" 2>/dev/null; then cat "$tmp2" > "$tmp" && mv -f "$tmp" "$f" || true diff --git a/claude-context.win.js b/claude-context.win.js index 7d7e94d..e7bfb4b 100644 --- a/claude-context.win.js +++ b/claude-context.win.js @@ -82,6 +82,89 @@ function findClaude() { return null; } +function findExecutableOnPath(name) { + const lookup = process.platform === "win32" ? "where" : "which"; + try { + const out = execFileSync(lookup, [name], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); + const hit = out + .split(/\r?\n/) + .map((s) => s.trim()) + .find((s) => s && fs.existsSync(s)); + if (hit) return hit; + } catch (_) { + /* not on PATH */ + } + return null; +} + +function expandShimPath(raw, shimDir) { + let s = raw.trim().replace(/^["']|["']$/g, ""); + s = s.replace(/%~?dp0%?/gi, shimDir + path.sep); + s = s.replace(/%([^%]+)%/g, (m, name) => process.env[name] || m); + return path.isAbsolute(s) ? s : path.resolve(shimDir, s); +} + +function resolveShimEntrypoint(shim) { + const shimDir = path.dirname(path.resolve(shim)); + const candidates = [ + path.join(shimDir, "node_modules", "@anthropic-ai", "claude-code", "cli.js"), + path.resolve(shimDir, "..", "@anthropic-ai", "claude-code", "cli.js"), + path.resolve( + shimDir, + "..", + "node_modules", + "@anthropic-ai", + "claude-code", + "cli.js" + ), + ]; + for (const c of candidates) { + if (fs.existsSync(c)) return c; + } + try { + const data = fs.readFileSync(shim, "utf8"); + const matches = data.matchAll( + /(?:"([^"]+?\.js)"|'([^']+?\.js)'|([^\s"']+?\.js))/gi + ); + for (const m of matches) { + const hit = expandShimPath(m[1] || m[2] || m[3], shimDir); + if (fs.existsSync(hit)) return hit; + } + } catch (_) { + /* unreadable shim */ + } + return null; +} + +function resolveNodeForShim(shim) { + const shimDir = path.dirname(path.resolve(shim)); + const candidates = [ + process.env.CC_NODE_BIN, + path.join(shimDir, "node.exe"), + path.join(shimDir, "node"), + process.pkg ? null : process.execPath, + findExecutableOnPath("node"), + ].filter(Boolean); + for (const c of candidates) { + if (fs.existsSync(c)) return c; + } + return null; +} + +function resolveClaudeInvocation(command, args) { + if (!/\.(cmd|bat)$/i.test(command)) return { command, args }; + const cli = resolveShimEntrypoint(command); + const node = resolveNodeForShim(command); + if (cli && node) return { command: node, args: [cli, ...args] }; + console.error( + "claude-context: refusing to launch unresolved .cmd/.bat shim without a shell; set CLAUDE_REAL_BIN to claude.exe or CC_NODE_BIN to node.exe" + ); + return null; +} + // Process-wrapper convention: the official VS Code extension invokes the wrapper // as , passing the real CLI ahead of the // args. is either a single native-binary path (".../claude.exe") @@ -151,7 +234,8 @@ function ccPatchIndexJs(file) { return; // not readable } if (data.indexOf(ICON_NEW) !== -1) return; // already patched - if (data.indexOf(ICON_OLD) === -1) return; // gate absent (version changed) + const oldMatches = data.split(ICON_OLD).length - 1; + if (oldMatches !== 1) return; // gate absent or ambiguous (version changed) const bak = file + ".bak-context-icon"; if (!fs.existsSync(bak)) { try { @@ -160,7 +244,7 @@ function ccPatchIndexJs(file) { /* best-effort backup */ } } - const patched = data.split(ICON_OLD).join(ICON_NEW); + const patched = data.replace(ICON_OLD, ICON_NEW); if (patched.indexOf(ICON_NEW) === -1) return; // sanity: substitution took const tmp = file + ".ccpatch." + process.pid; try { @@ -238,11 +322,11 @@ restoreContextIcon(wrapperBin); // This variant injects nothing into the args - it only patches the webview, then // forwards every argument through to the real claude unchanged. -// .cmd/.bat (npm install) need a shell; .exe (native install) is exec'd directly. -const useShell = /\.(cmd|bat)$/i.test(claude); -const res = spawnSync(claude, argv, { +const invocation = resolveClaudeInvocation(claude, argv); +if (!invocation) process.exit(1); +const res = spawnSync(invocation.command, invocation.args, { stdio: "inherit", env: process.env, - shell: useShell, + shell: false, }); process.exit(res.status == null ? 1 : res.status); diff --git a/claude-think b/claude-think index 827d2da..c80ed44 100755 --- a/claude-think +++ b/claude-think @@ -90,6 +90,13 @@ fi # Set CC_THINKING_DISPLAY=omitted to hide thinking; default shows summaries. DISPLAY_VALUE="${CC_THINKING_DISPLAY:-summarized}" +case "$DISPLAY_VALUE" in + summarized|omitted) ;; + *) + echo "claude-think: invalid CC_THINKING_DISPLAY=$DISPLAY_VALUE; using summarized" >&2 + DISPLAY_VALUE="summarized" + ;; +esac # --- Optional customizations ------------------------------------------------ # @@ -129,9 +136,21 @@ prev="" for a in "$@"; do case "$a" in - --thinking-display) + --thinking-display|--thinking-display=*) have_display=true ;; + --thinking=adaptive|--thinking=enabled) + thinking_adaptive=true + ;; + --thinking=disabled) + thinking_disabled=true + ;; + --max-thinking-tokens=*) + v="${a#*=}" + if [ -n "$v" ] && [ "$v" != "0" ]; then + max_thinking_on=true + fi + ;; -p|--print) print_mode=true ;; diff --git a/claude-think.win.js b/claude-think.win.js index 1d0472e..bf99748 100644 --- a/claude-think.win.js +++ b/claude-think.win.js @@ -75,6 +75,97 @@ function findClaude() { return null; } +function findExecutableOnPath(name) { + const lookup = process.platform === "win32" ? "where" : "which"; + try { + const out = execFileSync(lookup, [name], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); + const hit = out + .split(/\r?\n/) + .map((s) => s.trim()) + .find((s) => s && fs.existsSync(s)); + if (hit) return hit; + } catch (_) { + /* not on PATH */ + } + return null; +} + +function expandShimPath(raw, shimDir) { + let s = raw.trim().replace(/^["']|["']$/g, ""); + s = s.replace(/%~?dp0%?/gi, shimDir + path.sep); + s = s.replace(/%([^%]+)%/g, (m, name) => process.env[name] || m); + return path.isAbsolute(s) ? s : path.resolve(shimDir, s); +} + +function resolveShimEntrypoint(shim) { + const shimDir = path.dirname(path.resolve(shim)); + const candidates = [ + path.join(shimDir, "node_modules", "@anthropic-ai", "claude-code", "cli.js"), + path.resolve(shimDir, "..", "@anthropic-ai", "claude-code", "cli.js"), + path.resolve( + shimDir, + "..", + "node_modules", + "@anthropic-ai", + "claude-code", + "cli.js" + ), + ]; + for (const c of candidates) { + if (fs.existsSync(c)) return c; + } + try { + const data = fs.readFileSync(shim, "utf8"); + const matches = data.matchAll( + /(?:"([^"]+?\.js)"|'([^']+?\.js)'|([^\s"']+?\.js))/gi + ); + for (const m of matches) { + const hit = expandShimPath(m[1] || m[2] || m[3], shimDir); + if (fs.existsSync(hit)) return hit; + } + } catch (_) { + /* unreadable shim */ + } + return null; +} + +function resolveNodeForShim(shim) { + const shimDir = path.dirname(path.resolve(shim)); + const candidates = [ + process.env.CC_NODE_BIN, + path.join(shimDir, "node.exe"), + path.join(shimDir, "node"), + process.pkg ? null : process.execPath, + findExecutableOnPath("node"), + ].filter(Boolean); + for (const c of candidates) { + if (fs.existsSync(c)) return c; + } + return null; +} + +function resolveClaudeInvocation(command, args) { + if (!/\.(cmd|bat)$/i.test(command)) return { command, args }; + const cli = resolveShimEntrypoint(command); + const node = resolveNodeForShim(command); + if (cli && node) return { command: node, args: [cli, ...args] }; + console.error( + "claude-think: refusing to launch unresolved .cmd/.bat shim without a shell; set CLAUDE_REAL_BIN to claude.exe or CC_NODE_BIN to node.exe" + ); + return null; +} + +function normalizeDisplayValue(value) { + if (value === "summarized" || value === "omitted") return value; + console.error( + `claude-think: invalid CC_THINKING_DISPLAY=${value}; using summarized` + ); + return "summarized"; +} + // Process-wrapper convention: the official VS Code extension invokes the wrapper // as , passing the real CLI ahead of the // args. is either a single native-binary path (".../claude.exe") @@ -119,7 +210,9 @@ if (!claude) { // --- Behavior -------------------------------------------------------------- // Set CC_THINKING_DISPLAY=omitted to hide thinking; default shows summaries. -const displayValue = process.env.CC_THINKING_DISPLAY || "summarized"; +const displayValue = normalizeDisplayValue( + process.env.CC_THINKING_DISPLAY || "summarized" +); // --- Optional customizations ----------------------------------------------- // @@ -150,8 +243,18 @@ let haveDisplay = false, maxThinkingOn = false; for (let i = 0; i < argv.length; i++) { const a = argv[i]; - if (a === "--thinking-display") haveDisplay = true; + if (a === "--thinking-display" || a.startsWith("--thinking-display=")) { + haveDisplay = true; + } if (a === "-p" || a === "--print") printMode = true; + if (a === "--thinking=adaptive" || a === "--thinking=enabled") { + thinkingAdaptive = true; + } + if (a === "--thinking=disabled") thinkingDisabled = true; + if (a.startsWith("--max-thinking-tokens=")) { + const v = a.slice("--max-thinking-tokens=".length); + if (v && v !== "0") maxThinkingOn = true; + } if (a === "--max-thinking-tokens") { const v = argv[i + 1]; if (v && v !== "0") maxThinkingOn = true; @@ -171,11 +274,11 @@ if ( args.push("--thinking-display", displayValue); } -// .cmd/.bat (npm install) need a shell; .exe (native install) is exec'd directly. -const useShell = /\.(cmd|bat)$/i.test(claude); -const res = spawnSync(claude, args, { +const invocation = resolveClaudeInvocation(claude, args); +if (!invocation) process.exit(1); +const res = spawnSync(invocation.command, invocation.args, { stdio: "inherit", env: process.env, - shell: useShell, + shell: false, }); process.exit(res.status == null ? 1 : res.status); diff --git a/claudemax b/claudemax index 5a63391..be87ce0 100755 --- a/claudemax +++ b/claudemax @@ -98,6 +98,13 @@ fi # Set CC_THINKING_DISPLAY=omitted to hide thinking; default shows summaries. DISPLAY_VALUE="${CC_THINKING_DISPLAY:-summarized}" +case "$DISPLAY_VALUE" in + summarized|omitted) ;; + *) + echo "claudemax: invalid CC_THINKING_DISPLAY=$DISPLAY_VALUE; using summarized" >&2 + DISPLAY_VALUE="summarized" + ;; +esac # --- Optional customizations ------------------------------------------------ # @@ -137,9 +144,21 @@ prev="" for a in "$@"; do case "$a" in - --thinking-display) + --thinking-display|--thinking-display=*) have_display=true ;; + --thinking=adaptive|--thinking=enabled) + thinking_adaptive=true + ;; + --thinking=disabled) + thinking_disabled=true + ;; + --max-thinking-tokens=*) + v="${a#*=}" + if [ -n "$v" ] && [ "$v" != "0" ]; then + max_thinking_on=true + fi + ;; -p|--print) print_mode=true ;; @@ -193,10 +212,11 @@ fi CC_PATCH_CONTEXT_ICON="${CC_PATCH_CONTEXT_ICON:-1}" _cc_patch_index_js() { - local f="$1" tmp tmp2 + local f="$1" tmp tmp2 old_count [ -f "$f" ] && [ -w "$f" ] || return 0 if grep -q '>=101)return null}' "$f" 2>/dev/null; then return 0; fi # already patched - if ! grep -q '>=50)return null}' "$f" 2>/dev/null; then return 0; fi # gate absent (version changed) + old_count="$( (grep -o '>=50)return null}' "$f" 2>/dev/null || true) | wc -l | tr -d ' ')" + if [ "$old_count" != "1" ]; then return 0; fi # absent or ambiguous (version changed) if [ ! -e "$f.bak-context-icon" ]; then cp -p "$f" "$f.bak-context-icon" 2>/dev/null || true; fi tmp="${f}.ccpatch.$$" tmp2="${f}.ccpatch2.$$" @@ -205,7 +225,7 @@ _cc_patch_index_js() { # second temp, copy that back over the metadata-preserving temp, then atomically # replace the original. A failed/partial step leaves the original untouched. cp -p "$f" "$tmp" 2>/dev/null || { rm -f "$tmp" 2>/dev/null || true; return 0; } - if sed 's/>=50)return null}/>=101)return null}/g' "$f" > "$tmp2" 2>/dev/null \ + if sed 's/>=50)return null}/>=101)return null}/' "$f" > "$tmp2" 2>/dev/null \ && [ -s "$tmp2" ] \ && grep -q '>=101)return null}' "$tmp2" 2>/dev/null; then cat "$tmp2" > "$tmp" && mv -f "$tmp" "$f" || true diff --git a/claudemax.win.js b/claudemax.win.js index f842c13..ded0b44 100644 --- a/claudemax.win.js +++ b/claudemax.win.js @@ -84,6 +84,97 @@ function findClaude() { return null; } +function findExecutableOnPath(name) { + const lookup = process.platform === "win32" ? "where" : "which"; + try { + const out = execFileSync(lookup, [name], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); + const hit = out + .split(/\r?\n/) + .map((s) => s.trim()) + .find((s) => s && fs.existsSync(s)); + if (hit) return hit; + } catch (_) { + /* not on PATH */ + } + return null; +} + +function expandShimPath(raw, shimDir) { + let s = raw.trim().replace(/^["']|["']$/g, ""); + s = s.replace(/%~?dp0%?/gi, shimDir + path.sep); + s = s.replace(/%([^%]+)%/g, (m, name) => process.env[name] || m); + return path.isAbsolute(s) ? s : path.resolve(shimDir, s); +} + +function resolveShimEntrypoint(shim) { + const shimDir = path.dirname(path.resolve(shim)); + const candidates = [ + path.join(shimDir, "node_modules", "@anthropic-ai", "claude-code", "cli.js"), + path.resolve(shimDir, "..", "@anthropic-ai", "claude-code", "cli.js"), + path.resolve( + shimDir, + "..", + "node_modules", + "@anthropic-ai", + "claude-code", + "cli.js" + ), + ]; + for (const c of candidates) { + if (fs.existsSync(c)) return c; + } + try { + const data = fs.readFileSync(shim, "utf8"); + const matches = data.matchAll( + /(?:"([^"]+?\.js)"|'([^']+?\.js)'|([^\s"']+?\.js))/gi + ); + for (const m of matches) { + const hit = expandShimPath(m[1] || m[2] || m[3], shimDir); + if (fs.existsSync(hit)) return hit; + } + } catch (_) { + /* unreadable shim */ + } + return null; +} + +function resolveNodeForShim(shim) { + const shimDir = path.dirname(path.resolve(shim)); + const candidates = [ + process.env.CC_NODE_BIN, + path.join(shimDir, "node.exe"), + path.join(shimDir, "node"), + process.pkg ? null : process.execPath, + findExecutableOnPath("node"), + ].filter(Boolean); + for (const c of candidates) { + if (fs.existsSync(c)) return c; + } + return null; +} + +function resolveClaudeInvocation(command, args) { + if (!/\.(cmd|bat)$/i.test(command)) return { command, args }; + const cli = resolveShimEntrypoint(command); + const node = resolveNodeForShim(command); + if (cli && node) return { command: node, args: [cli, ...args] }; + console.error( + "claudemax: refusing to launch unresolved .cmd/.bat shim without a shell; set CLAUDE_REAL_BIN to claude.exe or CC_NODE_BIN to node.exe" + ); + return null; +} + +function normalizeDisplayValue(value) { + if (value === "summarized" || value === "omitted") return value; + console.error( + `claudemax: invalid CC_THINKING_DISPLAY=${value}; using summarized` + ); + return "summarized"; +} + // Process-wrapper convention: the official VS Code extension invokes the wrapper // as , passing the real CLI ahead of the // args. is either a single native-binary path (".../claude.exe") @@ -153,7 +244,8 @@ function ccPatchIndexJs(file) { return; // not readable } if (data.indexOf(ICON_NEW) !== -1) return; // already patched - if (data.indexOf(ICON_OLD) === -1) return; // gate absent (version changed) + const oldMatches = data.split(ICON_OLD).length - 1; + if (oldMatches !== 1) return; // gate absent or ambiguous (version changed) const bak = file + ".bak-context-icon"; if (!fs.existsSync(bak)) { try { @@ -162,7 +254,7 @@ function ccPatchIndexJs(file) { /* best-effort backup */ } } - const patched = data.split(ICON_OLD).join(ICON_NEW); + const patched = data.replace(ICON_OLD, ICON_NEW); if (patched.indexOf(ICON_NEW) === -1) return; // sanity: substitution took const tmp = file + ".ccpatch." + process.pid; try { @@ -237,7 +329,9 @@ function restoreContextIcon(binPath) { // --- Behavior -------------------------------------------------------------- // Set CC_THINKING_DISPLAY=omitted to hide thinking; default shows summaries. -const displayValue = process.env.CC_THINKING_DISPLAY || "summarized"; +const displayValue = normalizeDisplayValue( + process.env.CC_THINKING_DISPLAY || "summarized" +); // --- Optional customizations ----------------------------------------------- // @@ -268,8 +362,18 @@ let haveDisplay = false, maxThinkingOn = false; for (let i = 0; i < argv.length; i++) { const a = argv[i]; - if (a === "--thinking-display") haveDisplay = true; + if (a === "--thinking-display" || a.startsWith("--thinking-display=")) { + haveDisplay = true; + } if (a === "-p" || a === "--print") printMode = true; + if (a === "--thinking=adaptive" || a === "--thinking=enabled") { + thinkingAdaptive = true; + } + if (a === "--thinking=disabled") thinkingDisabled = true; + if (a.startsWith("--max-thinking-tokens=")) { + const v = a.slice("--max-thinking-tokens=".length); + if (v && v !== "0") maxThinkingOn = true; + } if (a === "--max-thinking-tokens") { const v = argv[i + 1]; if (v && v !== "0") maxThinkingOn = true; @@ -292,11 +396,11 @@ if ( // Patch the webview before handing off (best-effort; never throws). restoreContextIcon(wrapperBin); -// .cmd/.bat (npm install) need a shell; .exe (native install) is exec'd directly. -const useShell = /\.(cmd|bat)$/i.test(claude); -const res = spawnSync(claude, args, { +const invocation = resolveClaudeInvocation(claude, args); +if (!invocation) process.exit(1); +const res = spawnSync(invocation.command, invocation.args, { stdio: "inherit", env: process.env, - shell: useShell, + shell: false, }); process.exit(res.status == null ? 1 : res.status); diff --git a/fix-context-icon.py b/fix-context-icon.py index a96b020..8539e65 100755 --- a/fix-context-icon.py +++ b/fix-context-icon.py @@ -22,14 +22,16 @@ if(c>=50)return null -> if(c>=101)return null (c maxes at 100, so never hides) -The (t===0) guard is left intact, so nothing is shown before the first response -sets the context window. After the first turn the icon stays visible. +The (t===0) guard is left intact. In a resumed window, the webview can still show +a transient 0% before the first fresh response updates context metadata. After +that first response the icon stays visible. SAFE & REVERSIBLE ----------------- * Backs up each file once to index.js.bak-context-icon before editing. -* Writes in place (truncate-in-place) so file OWNER/permissions are preserved - even when this runs as root against a user-owned file. +* Writes through a same-directory temp file and atomic replace. Owner/group/mode + are copied back onto the temp file before replacement, preserving root-patches + against user-owned installs while avoiding truncate-in-place corruption. * Idempotent: re-running detects an already-patched file and does nothing. * No integrity/hash check exists on the webview bundle, so the edit loads fine. @@ -46,7 +48,9 @@ """ import glob import os +import shutil import sys +import tempfile OLD = ">=50)return null}" NEW = ">=101)return null}" @@ -70,10 +74,30 @@ def discover(): return sorted(found) -def write_in_place(path, text): - """Truncate-in-place write: preserves the file's owner/group/mode.""" - with open(path, "w", encoding="utf-8", newline="") as f: - f.write(text) +def write_atomic_preserving_metadata(path, text): + """Atomic same-directory replacement that preserves owner/group/mode.""" + st = os.stat(path) + directory = os.path.dirname(path) or "." + basename = os.path.basename(path) + fd, tmp = tempfile.mkstemp(prefix=f".{basename}.", suffix=".tmp", dir=directory) + try: + with os.fdopen(fd, "w", encoding="utf-8", newline="") as f: + f.write(text) + f.flush() + os.fsync(f.fileno()) + try: + os.chown(tmp, st.st_uid, st.st_gid) + except (AttributeError, PermissionError, OSError): + pass + shutil.copystat(path, tmp) + os.replace(tmp, path) + tmp = None + finally: + if tmp is not None: + try: + os.unlink(tmp) + except FileNotFoundError: + pass def patch_file(path): @@ -90,7 +114,7 @@ def patch_file(path): if not os.path.exists(backup): with open(backup, "w", encoding="utf-8", newline="") as b: b.write(data) - write_in_place(path, data.replace(OLD, NEW, 1)) + write_atomic_preserving_metadata(path, data.replace(OLD, NEW, 1)) return "PATCHED" @@ -100,7 +124,7 @@ def revert_file(path): return "no-backup" with open(backup, "r", encoding="utf-8", newline="") as b: data = b.read() - write_in_place(path, data) + write_atomic_preserving_metadata(path, data) return "REVERTED" diff --git a/patch-extension.sh b/patch-extension.sh index 1b738dc..265406d 100755 --- a/patch-extension.sh +++ b/patch-extension.sh @@ -27,7 +27,10 @@ BASE_DIRS=( "$HOME/.config/Code/User/extensions" ) -mapfile -t TARGETS < <( +TARGETS=() +while IFS= read -r target; do + TARGETS+=("$target") +done < <( for b in "${BASE_DIRS[@]}"; do [ -d "$b" ] || continue # Match any arch/platform build (e.g. -linux-x64, -darwin-arm64, -win32-x64). diff --git a/proxy.js b/proxy.js index db72966..0e1c319 100644 --- a/proxy.js +++ b/proxy.js @@ -28,54 +28,128 @@ const https = require("https"); const UPSTREAM = "api.anthropic.com"; const PORT = parseInt(process.env.CC_PROXY_PORT || "8788", 10); -const DISPLAY = process.env.CC_THINKING_DISPLAY || "summarized"; // or "omitted" +const HOP_BY_HOP_HEADERS = new Set([ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", +]); -http - .createServer((req, res) => { - const chunks = []; - req.on("data", (c) => chunks.push(c)); - req.on("end", () => { - let body = Buffer.concat(chunks); +function normalizeDisplay(value) { + if (value === "summarized" || value === "omitted") return value; + console.error( + `proxy.js: invalid CC_THINKING_DISPLAY=${value}; using summarized` + ); + return "summarized"; +} - if (req.method === "POST" && req.url.startsWith("/v1/messages")) { - try { - const j = JSON.parse(body.toString("utf8")); - const t = j.thinking; - if ( - t && - (t.type === "adaptive" || t.type === "enabled") && - t.display == null - ) { - t.display = DISPLAY; - body = Buffer.from(JSON.stringify(j)); - } - } catch { - /* not JSON we understand - pass the body through untouched */ - } - } +const DISPLAY = normalizeDisplay( + process.env.CC_THINKING_DISPLAY || "summarized" +); - const headers = { - ...req.headers, - host: UPSTREAM, - "content-length": Buffer.byteLength(body), - }; - const up = https.request( - { host: UPSTREAM, path: req.url, method: req.method, headers }, - (r) => { - res.writeHead(r.statusCode, r.headers); - r.pipe(res); // stream the SSE response straight back, unbuffered - } - ); - up.on("error", (e) => { - res.writeHead(502); - res.end(String(e)); - }); - up.end(body); - }); - }) - // Bind to 127.0.0.1 ONLY - never 0.0.0.0. This process sees your live token. - .listen(PORT, "127.0.0.1", () => - console.error( - `thinking-display proxy on http://127.0.0.1:${PORT} (injecting display="${DISPLAY}")` +function connectionHeaderTokens(headers) { + const value = headers.connection; + if (!value) return []; + const joined = Array.isArray(value) ? value.join(",") : String(value); + return joined + .split(",") + .map((s) => s.trim().toLowerCase()) + .filter(Boolean); +} + +function filterProxyHeaders(headers, extraRemove = []) { + const remove = new Set( + [...HOP_BY_HOP_HEADERS, ...connectionHeaderTokens(headers), ...extraRemove].map( + (s) => s.toLowerCase() ) ); + const out = {}; + for (const [name, value] of Object.entries(headers || {})) { + const lower = name.toLowerCase(); + if (remove.has(lower) || lower.startsWith("proxy-")) continue; + out[lower] = value; + } + return out; +} + +function headersForUpstream(inboundHeaders, bodyLength) { + return { + ...filterProxyHeaders(inboundHeaders, ["host", "content-length"]), + host: UPSTREAM, + "content-length": bodyLength, + }; +} + +function headersForClient(upstreamHeaders) { + return filterProxyHeaders(upstreamHeaders); +} + +function injectThinkingDisplay(body, display = DISPLAY) { + try { + const j = JSON.parse(body.toString("utf8")); + const t = j.thinking; + if ( + t && + (t.type === "adaptive" || t.type === "enabled") && + t.display == null + ) { + t.display = display; + return Buffer.from(JSON.stringify(j)); + } + } catch { + /* not JSON we understand - pass the body through untouched */ + } + return body; +} + +function startServer(port = PORT) { + return http + .createServer((req, res) => { + const chunks = []; + req.on("data", (c) => chunks.push(c)); + req.on("end", () => { + let body = Buffer.concat(chunks); + + if (req.method === "POST" && req.url.startsWith("/v1/messages")) { + body = injectThinkingDisplay(body); + } + + const headers = headersForUpstream(req.headers, Buffer.byteLength(body)); + const up = https.request( + { host: UPSTREAM, path: req.url, method: req.method, headers }, + (r) => { + res.writeHead(r.statusCode || 502, headersForClient(r.headers)); + r.pipe(res); // stream the SSE response straight back, unbuffered + } + ); + up.on("error", (e) => { + res.writeHead(502); + res.end(String(e)); + }); + up.end(body); + }); + }) + // Bind to 127.0.0.1 ONLY - never 0.0.0.0. This process sees your live token. + .listen(port, "127.0.0.1", () => + console.error( + `thinking-display proxy on http://127.0.0.1:${port} (injecting display="${DISPLAY}")` + ) + ); +} + +if (require.main === module) { + startServer(); +} + +module.exports = { + filterProxyHeaders, + headersForUpstream, + headersForClient, + injectThinkingDisplay, + normalizeDisplay, + startServer, +}; diff --git a/test-thinking-display.sh b/test-thinking-display.sh index 89ec95f..5f20c7c 100755 --- a/test-thinking-display.sh +++ b/test-thinking-display.sh @@ -10,6 +10,24 @@ CLAUDE="${CLAUDE_REAL_BIN:-$(command -v claude 2>/dev/null || echo "$HOME/.local [ -x "$CLAUDE" ] || { echo "could not find 'claude'; set CLAUDE_REAL_BIN" >&2; exit 1; } PROMPT='If all Bloops are Razzies, and all Razzies are Lazzies, are all Bloops necessarily Lazzies? Reason through it carefully step by step, then give a one-word answer.' +A_JSONL="$(mktemp "${TMPDIR:-/tmp}/cc-thinking-a.XXXXXX")" +B_JSONL="$(mktemp "${TMPDIR:-/tmp}/cc-thinking-b.XXXXXX")" +cleanup() { + rm -f "$A_JSONL" "$B_JSONL" +} +trap cleanup EXIT + +run_with_timeout() { + local seconds="$1" + shift + if command -v timeout >/dev/null 2>&1; then + timeout "$seconds" "$@" + elif command -v gtimeout >/dev/null 2>&1; then + gtimeout "$seconds" "$@" + else + "$@" + fi +} inspect() { # $1 = jsonl file python3 - "$1" <<'PY' @@ -34,14 +52,14 @@ PY } echo "### A: --thinking-display summarized" -timeout 150 "$CLAUDE" -p "$PROMPT" --model opus --thinking-display summarized \ - --output-format stream-json --verbose --max-turns 1 /tmp/cc_t_a.jsonl 2>/dev/null -inspect /tmp/cc_t_a.jsonl +run_with_timeout 150 "$CLAUDE" -p "$PROMPT" --model opus --thinking-display summarized \ + --output-format stream-json --verbose --max-turns 1 "$A_JSONL" 2>/dev/null +inspect "$A_JSONL" echo "### B: no flag (relies on showThinkingSummaries setting)" -timeout 150 "$CLAUDE" -p "$PROMPT" --model opus \ - --output-format stream-json --verbose --max-turns 1 /tmp/cc_t_b.jsonl 2>/dev/null -inspect /tmp/cc_t_b.jsonl +run_with_timeout 150 "$CLAUDE" -p "$PROMPT" --model opus \ + --output-format stream-json --verbose --max-turns 1 "$B_JSONL" 2>/dev/null +inspect "$B_JSONL" echo echo "Expected: A populated (len>0), B empty (len=0) on Opus 4.7 / 4.8." diff --git a/tests/test_regressions.py b/tests/test_regressions.py new file mode 100644 index 0000000..36f2245 --- /dev/null +++ b/tests/test_regressions.py @@ -0,0 +1,403 @@ +#!/usr/bin/env python3 +import importlib.util +import json +import os +import pathlib +import stat +import subprocess +import sys +import tempfile +import textwrap +import unittest + + +REPO = pathlib.Path(__file__).resolve().parents[1] +OLD_ICON = ">=50)return null}" +NEW_ICON = ">=101)return null}" + + +def run(cmd, *, env=None, cwd=REPO, timeout=10): + merged_env = os.environ.copy() + if env: + merged_env.update(env) + return subprocess.run( + cmd, + cwd=cwd, + env=merged_env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=timeout, + ) + + +def make_fake_claude(directory): + capture = pathlib.Path(directory) / "args.json" + fake = pathlib.Path(directory) / "claude" + fake.write_text( + "#!/usr/bin/env bash\n" + "python3 - \"$@\" <<'PY'\n" + "import json, os, sys\n" + "open(os.environ['CAPTURE_ARGS'], 'w').write(json.dumps(sys.argv[1:]))\n" + "PY\n", + encoding="utf-8", + ) + fake.chmod(fake.stat().st_mode | stat.S_IXUSR) + return fake, capture + + +def make_fake_node_cli(directory): + temp = pathlib.Path(directory) + capture = temp / "args.json" + cli = temp / "cli.js" + cli.write_text( + "const fs = require('fs');\n" + "fs.writeFileSync(process.env.CAPTURE_ARGS, JSON.stringify(process.argv.slice(2)));\n", + encoding="utf-8", + ) + return cli, capture + + +def make_fake_cmd_shim(directory, cli): + shim = pathlib.Path(directory) / "claude.cmd" + shim.write_text(f'@ECHO off\nnode "{cli}" %*\n', encoding="utf-8") + return shim + + +def captured_args(path): + return json.loads(path.read_text(encoding="utf-8")) + + +class LauncherRegressionTests(unittest.TestCase): + @unittest.skipIf(os.name == "nt", "POSIX Bash launcher test") + def test_bash_thinking_launchers_parse_equals_flags_and_validate_display(self): + for launcher in ("claude-think", "claudemax"): + with self.subTest(launcher=launcher): + with tempfile.TemporaryDirectory() as td: + fake, capture = make_fake_claude(td) + env = { + "CLAUDE_REAL_BIN": str(fake), + "CAPTURE_ARGS": str(capture), + "CC_PATCH_CONTEXT_ICON": "0", + } + + res = run([str(REPO / launcher), "--thinking=adaptive"], env=env) + self.assertEqual(res.returncode, 0, res.stderr) + self.assertEqual( + captured_args(capture), + ["--thinking=adaptive", "--thinking-display", "summarized"], + ) + + capture.unlink() + res = run( + [str(REPO / launcher), "--max-thinking-tokens=123"], + env=env, + ) + self.assertEqual(res.returncode, 0, res.stderr) + self.assertEqual( + captured_args(capture), + [ + "--max-thinking-tokens=123", + "--thinking-display", + "summarized", + ], + ) + + capture.unlink() + res = run( + [ + str(REPO / launcher), + "--thinking", + "adaptive", + "--thinking-display=omitted", + ], + env=env, + ) + self.assertEqual(res.returncode, 0, res.stderr) + self.assertEqual( + captured_args(capture), + ["--thinking", "adaptive", "--thinking-display=omitted"], + ) + + capture.unlink() + bad_env = dict(env) + bad_env["CC_THINKING_DISPLAY"] = "bogus" + res = run( + [str(REPO / launcher), "--thinking=adaptive"], + env=bad_env, + ) + self.assertEqual(res.returncode, 0, res.stderr) + self.assertIn("invalid CC_THINKING_DISPLAY", res.stderr) + self.assertEqual( + captured_args(capture), + ["--thinking=adaptive", "--thinking-display", "summarized"], + ) + + # --thinking=disabled (equals form) must suppress injection even + # when a trigger like --print is present. + capture.unlink() + res = run( + [str(REPO / launcher), "--print", "--thinking=disabled"], + env=env, + ) + self.assertEqual(res.returncode, 0, res.stderr) + self.assertEqual( + captured_args(capture), + ["--print", "--thinking=disabled"], + ) + + def test_windows_thinking_launchers_resolve_cmd_shims_without_shell(self): + for launcher in ("claude-think.win.js", "claudemax.win.js"): + with self.subTest(launcher=launcher): + with tempfile.TemporaryDirectory() as td: + cli, capture = make_fake_node_cli(td) + shim = make_fake_cmd_shim(td, cli) + + env = { + "CLAUDE_REAL_BIN": str(shim), + "CAPTURE_ARGS": str(capture), + "CC_PATCH_CONTEXT_ICON": "0", + } + res = run( + [ + "node", + str(REPO / launcher), + "--thinking=adaptive", + "literal&arg", + "%PATH%", + 'quoted"arg', + "caret^arg", + ], + env=env, + ) + self.assertEqual(res.returncode, 0, res.stderr) + self.assertEqual( + captured_args(capture), + [ + "--thinking=adaptive", + "literal&arg", + "%PATH%", + 'quoted"arg', + "caret^arg", + "--thinking-display", + "summarized", + ], + ) + + @unittest.skipIf(os.name == "nt", "POSIX Bash launcher test") + def test_bash_context_icon_launchers_skip_ambiguous_files(self): + launchers = ("claude-context", "claudemax") + for launcher in launchers: + with self.subTest(launcher=launcher): + with tempfile.TemporaryDirectory() as td: + temp = pathlib.Path(td) + fake, capture = make_fake_claude(td) + index = ( + temp + / ".vscode" + / "extensions" + / "anthropic.claude-code-test" + / "webview" + / "index.js" + ) + index.parent.mkdir(parents=True) + original = f"first {OLD_ICON} second {OLD_ICON}" + index.write_text(original, encoding="utf-8") + + res = run( + [str(REPO / launcher)], + env={ + "HOME": str(temp), + "CLAUDE_REAL_BIN": str(fake), + "CAPTURE_ARGS": str(capture), + }, + ) + self.assertEqual(res.returncode, 0, res.stderr) + self.assertEqual(index.read_text(encoding="utf-8"), original) + + @unittest.skipIf(os.name == "nt", "POSIX Bash launcher test") + def test_bash_context_icon_launchers_patch_single_match(self): + for launcher in ("claude-context", "claudemax"): + with self.subTest(launcher=launcher): + with tempfile.TemporaryDirectory() as td: + temp = pathlib.Path(td) + fake, capture = make_fake_claude(td) + index = ( + temp + / ".vscode" + / "extensions" + / "anthropic.claude-code-test" + / "webview" + / "index.js" + ) + index.parent.mkdir(parents=True) + index.write_text(f"before {OLD_ICON} after", encoding="utf-8") + index.chmod(0o640) + + res = run( + [str(REPO / launcher)], + env={ + "HOME": str(temp), + "CLAUDE_REAL_BIN": str(fake), + "CAPTURE_ARGS": str(capture), + }, + ) + self.assertEqual(res.returncode, 0, res.stderr) + self.assertEqual( + index.read_text(encoding="utf-8"), f"before {NEW_ICON} after" + ) + backup = index.with_name(index.name + ".bak-context-icon") + self.assertTrue(backup.exists()) + self.assertEqual(stat.S_IMODE(index.stat().st_mode), 0o640) + + def test_windows_context_icon_launchers_skip_ambiguous_files(self): + win_launchers = ("claude-context.win.js", "claudemax.win.js") + for launcher in win_launchers: + with self.subTest(launcher=launcher): + with tempfile.TemporaryDirectory() as td: + temp = pathlib.Path(td) + cli, capture = make_fake_node_cli(td) + shim = make_fake_cmd_shim(td, cli) + index = ( + temp + / ".vscode" + / "extensions" + / "anthropic.claude-code-test" + / "webview" + / "index.js" + ) + index.parent.mkdir(parents=True) + original = f"first {OLD_ICON} second {OLD_ICON}" + index.write_text(original, encoding="utf-8") + + res = run( + ["node", str(REPO / launcher)], + env={ + "HOME": str(temp), + "USERPROFILE": str(temp), + "CLAUDE_REAL_BIN": str(shim), + "CAPTURE_ARGS": str(capture), + }, + ) + self.assertEqual(res.returncode, 0, res.stderr) + self.assertEqual(index.read_text(encoding="utf-8"), original) + + def test_windows_context_icon_launchers_patch_single_match(self): + for launcher in ("claude-context.win.js", "claudemax.win.js"): + with self.subTest(launcher=launcher): + with tempfile.TemporaryDirectory() as td: + temp = pathlib.Path(td) + cli, capture = make_fake_node_cli(td) + shim = make_fake_cmd_shim(td, cli) + index = ( + temp + / ".vscode" + / "extensions" + / "anthropic.claude-code-test" + / "webview" + / "index.js" + ) + index.parent.mkdir(parents=True) + index.write_text(f"before {OLD_ICON} after", encoding="utf-8") + + res = run( + ["node", str(REPO / launcher)], + env={ + "HOME": str(temp), + "USERPROFILE": str(temp), + "CLAUDE_REAL_BIN": str(shim), + "CAPTURE_ARGS": str(capture), + }, + ) + self.assertEqual(res.returncode, 0, res.stderr) + self.assertEqual( + index.read_text(encoding="utf-8"), f"before {NEW_ICON} after" + ) + backup = index.with_name(index.name + ".bak-context-icon") + self.assertTrue(backup.exists()) + + +class ProxyRegressionTests(unittest.TestCase): + def test_proxy_exports_header_filters_that_strip_hop_by_hop_headers(self): + script = textwrap.dedent( + """ + const assert = require('assert'); + const { headersForUpstream, headersForClient } = require('./proxy.js'); + const inbound = { + host: '127.0.0.1:8788', + connection: 'keep-alive, x-remove-me', + 'x-remove-me': '1', + 'transfer-encoding': 'chunked', + upgrade: 'websocket', + 'proxy-authorization': 'secret', + 'content-length': '999', + 'x-custom': 'ok' + }; + const upstream = headersForUpstream(inbound, 12); + assert.strictEqual(upstream.host, 'api.anthropic.com'); + assert.strictEqual(upstream['content-length'], 12); + assert.strictEqual(upstream['x-custom'], 'ok'); + for (const name of ['connection', 'x-remove-me', 'transfer-encoding', 'upgrade', 'proxy-authorization']) { + assert.strictEqual(upstream[name], undefined, name); + } + const client = headersForClient({ + connection: 'close', + 'transfer-encoding': 'chunked', + trailer: 'x-trailer', + 'content-type': 'text/event-stream' + }); + assert.deepStrictEqual(client, {'content-type': 'text/event-stream'}); + """ + ) + res = run(["node", "-e", script], timeout=5) + self.assertEqual(res.returncode, 0, res.stderr) + + +class PatcherRegressionTests(unittest.TestCase): + def test_fix_context_icon_atomic_replace_preserves_metadata_and_docs_limitation(self): + source = (REPO / "fix-context-icon.py").read_text(encoding="utf-8") + self.assertIn("os.replace", source) + self.assertIn("copystat", source) + self.assertIn("transient 0%", source) + + spec = importlib.util.spec_from_file_location( + "fix_context_icon", REPO / "fix-context-icon.py" + ) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + + with tempfile.TemporaryDirectory() as td: + target = pathlib.Path(td) / "index.js" + target.write_text(f"before {OLD_ICON} after", encoding="utf-8") + target.chmod(0o640) + before = target.stat() + + self.assertEqual(mod.patch_file(str(target)), "PATCHED") + after = target.stat() + if os.name != "nt": + self.assertEqual( + stat.S_IMODE(after.st_mode), stat.S_IMODE(before.st_mode) + ) + self.assertEqual(after.st_uid, before.st_uid) + self.assertEqual(after.st_gid, before.st_gid) + self.assertEqual( + target.read_text(encoding="utf-8"), f"before {NEW_ICON} after" + ) + self.assertTrue((pathlib.Path(str(target) + mod.BACKUP_SUFFIX)).exists()) + + def test_patch_extension_avoids_bash4_mapfile(self): + source = (REPO / "patch-extension.sh").read_text(encoding="utf-8") + self.assertNotIn("mapfile", source) + self.assertIn("while IFS= read -r", source) + + def test_live_ab_script_uses_temp_files_and_optional_timeout(self): + source = (REPO / "test-thinking-display.sh").read_text(encoding="utf-8") + self.assertIn("mktemp", source) + self.assertIn("trap", source) + self.assertNotIn("/tmp/cc_t_a.jsonl", source) + self.assertNotIn("/tmp/cc_t_b.jsonl", source) + self.assertIn("run_with_timeout", source) + + +if __name__ == "__main__": + unittest.main() From c092942ba3fda8fe70527169ec5d98759e2040c9 Mon Sep 17 00:00:00 2001 From: Phase3 <77866949+phase3dev@users.noreply.github.com> Date: Mon, 8 Jun 2026 23:10:04 -1000 Subject: [PATCH 2/2] Add CI workflow and note untested cross-owner path - .github/workflows/ci.yml runs bash -n, ShellCheck (severity=warning), node --check, py_compile, and the regression suite on push and PRs. ShellCheck gates at warning severity: the only findings are info-level SC2015 (A && B || C) notes where the C branch is the intended best-effort behavior. - Note in the #5 patcher test that the root-patches-user-owned-file chown path is not exercised by the single-user test and is verified by inspection only. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 43 +++++++++++++++++++++++++++++++++++++++ tests/test_regressions.py | 6 ++++++ 2 files changed, 49 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0605b24 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,43 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + checks: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Bash syntax check (bash -n) + run: bash -n claude-think claude-context claudemax patch-extension.sh test-thinking-display.sh + + - name: ShellCheck + # ubuntu-latest ships shellcheck. Gate at warning severity: the only + # findings are info-level SC2015 (A && B || C) notes in the launchers, + # where the C branch firing is the intended best-effort behavior. + run: shellcheck --severity=warning claude-think claude-context claudemax patch-extension.sh test-thinking-display.sh + + - name: Node syntax check (node --check) + run: | + for f in claude-think.win.js claude-context.win.js claudemax.win.js proxy.js; do + node --check "$f" + done + + - name: Python compile + run: python3 -m py_compile fix-context-icon.py tests/test_regressions.py + + - name: Regression tests + run: python3 -m unittest discover -s tests -v diff --git a/tests/test_regressions.py b/tests/test_regressions.py index 36f2245..f72f12a 100644 --- a/tests/test_regressions.py +++ b/tests/test_regressions.py @@ -378,6 +378,12 @@ def test_fix_context_icon_atomic_replace_preserves_metadata_and_docs_limitation( self.assertEqual( stat.S_IMODE(after.st_mode), stat.S_IMODE(before.st_mode) ) + # NOTE: this test runs as a single user, so the temp file's owner + # already matches the target and the os.chown() in + # write_atomic_preserving_metadata is effectively a no-op. The + # cross-owner case that actually exercises the chown (root patching a + # user-owned bundle) requires two UIDs and is NOT covered here; that + # path is verified by inspection only. self.assertEqual(after.st_uid, before.st_uid) self.assertEqual(after.st_gid, before.st_gid) self.assertEqual(