Skip to content

Commit ab02b35

Browse files
committed
bugfix: add claude-code-cache-fix support to ClaudeRunner
1 parent 3907063 commit ab02b35

22 files changed

Lines changed: 1213 additions & 229 deletions

File tree

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
2-
"status": "open",
3-
"dependencies": [],
4-
"blocks": [],
5-
"target_branch": "v2.1"
2+
"status" : "closed",
3+
"resolution" : "implemented",
4+
"dependencies" : [ ],
5+
"blocks" : [ ],
6+
"target_branch" : "v2.1"
67
}

client/src/main/java/io/github/cowwoc/cat/claude/hook/ClaudePluginScope.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,4 +73,14 @@ public interface ClaudePluginScope extends JvmScope
7373
* @throws IllegalStateException if this scope is closed
7474
*/
7575
DisplayUtils getDisplayUtils();
76+
77+
/**
78+
* Returns the Anthropic API base URL from the {@code ANTHROPIC_BASE_URL} environment variable.
79+
* <p>
80+
* Returns empty string if the variable is not set.
81+
*
82+
* @return the base URL, or empty string if not set
83+
* @throws IllegalStateException if this scope is closed
84+
*/
85+
String getAnthropicBaseUrl();
7686
}

client/src/main/java/io/github/cowwoc/cat/claude/hook/MainClaudeHook.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,14 @@ public final class MainClaudeHook extends AbstractClaudeHook
3434
return "UTC";
3535
return tzValue;
3636
});
37+
private final ConcurrentLazyReference<String> anthropicBaseUrl =
38+
ConcurrentLazyReference.create(() ->
39+
{
40+
String value = System.getenv("ANTHROPIC_BASE_URL");
41+
if (value == null || value.isBlank())
42+
return "";
43+
return value;
44+
});
3745

3846
/**
3947
* Creates a new production hook scope.
@@ -121,4 +129,11 @@ public String getTimezone()
121129
ensureOpen();
122130
return tz.getValue();
123131
}
132+
133+
@Override
134+
public String getAnthropicBaseUrl()
135+
{
136+
ensureOpen();
137+
return anthropicBaseUrl.getValue();
138+
}
124139
}

client/src/main/java/io/github/cowwoc/cat/claude/hook/skills/ClaudeRunner.java

Lines changed: 95 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import io.github.cowwoc.cat.claude.tool.ClaudeTool;
1414
import io.github.cowwoc.cat.claude.hook.ClaudePluginScope;
1515
import io.github.cowwoc.cat.claude.tool.MainClaudeTool;
16+
import io.github.cowwoc.cat.claude.hook.util.FileUtils;
1617

1718
import io.github.cowwoc.pouch10.core.WrappedCheckedException;
1819

@@ -28,7 +29,6 @@
2829
import java.nio.file.Files;
2930
import java.nio.file.Path;
3031
import java.nio.file.SimpleFileVisitor;
31-
import java.nio.file.StandardCopyOption;
3232
import java.util.Arrays;
3333
import java.util.Objects;
3434
import org.slf4j.Logger;
@@ -47,16 +47,16 @@
4747
import tools.jackson.databind.node.ObjectNode;
4848

4949
/**
50-
* Launches Claude CLI processes with optional config directory isolation.
50+
* Launches Claude Code CLI processes with optional config directory isolation.
5151
* <p>
52-
* Handles building stream-json input, spawning the {@code claude} CLI process, parsing
53-
* stream-json output, and optionally creating an isolated config directory with updated
54-
* plugin cache.
52+
* Handles building stream-json input, spawning the Node.js process running cli.js,
53+
* parsing stream-json output, and optionally creating an isolated config directory
54+
* with updated plugin cache.
5555
*/
5656
public final class ClaudeRunner implements AutoCloseable
5757
{
5858
/**
59-
* Default timeout for the claude CLI process.
59+
* Default timeout for the Node.js process running cli.js.
6060
*/
6161
private static final Duration DEFAULT_TIMEOUT = Duration.ofMinutes(3);
6262
private final ClaudePluginScope scope;
@@ -105,7 +105,7 @@ public void createIsolatedConfig(Path sourceConfigDir, Path pluginSourceDir,
105105
isolatedConfigDir = Files.createTempDirectory("claude-isolated-config-");
106106

107107
// Copy entire config directory
108-
copyDirectoryRecursively(sourceConfigDir, isolatedConfigDir);
108+
FileUtils.copyDirectoryRecursively(sourceConfigDir, isolatedConfigDir);
109109

110110
// Update plugin cache with current plugin source files
111111
isolatedPluginRoot = isolatedConfigDir.resolve("plugins").resolve("cache").
@@ -115,14 +115,14 @@ public void createIsolatedConfig(Path sourceConfigDir, Path pluginSourceDir,
115115
deleteDirectoryContents(isolatedPluginRoot);
116116
else
117117
Files.createDirectories(isolatedPluginRoot);
118-
copyDirectoryRecursively(pluginSourceDir, isolatedPluginRoot);
118+
FileUtils.copyDirectoryRecursively(pluginSourceDir, isolatedPluginRoot);
119119

120120
// Update jlink binaries in the cache
121121
Path cacheBinDir = isolatedPluginRoot.resolve("client").resolve("bin");
122122
if (Files.isDirectory(jlinkBinDir))
123123
{
124124
Files.createDirectories(cacheBinDir);
125-
copyDirectoryRecursively(jlinkBinDir, cacheBinDir);
125+
FileUtils.copyDirectoryRecursively(jlinkBinDir, cacheBinDir);
126126
}
127127
}
128128

@@ -138,8 +138,62 @@ public String getIsolatedConfigDir()
138138
return isolatedConfigDir.toString();
139139
}
140140

141+
/**
142+
* Checks whether the cache-fix module is detected in the given {@code NODE_OPTIONS} string.
143+
* <p>
144+
* Returns {@code true} when {@code nodeOptions} contains {@code "claude-code-cache-fix"},
145+
* which indicates that the
146+
* <a href="https://github.com/cnighswonger/claude-code-cache-fix">claude-code-cache-fix</a> module
147+
* is loaded. Matches both the short form ({@code --import claude-code-cache-fix}) and the full
148+
* path form ({@code --import /path/to/claude-code-cache-fix/preload.mjs}).
149+
*
150+
* @param nodeOptions the {@code NODE_OPTIONS} environment variable value, or {@code null} if not
151+
* set
152+
* @return {@code true} if the cache-fix module is detected, {@code false} otherwise
153+
*/
154+
public static boolean isCacheFixDetected(String nodeOptions)
155+
{
156+
return nodeOptions != null && nodeOptions.contains("claude-code-cache-fix");
157+
}
158+
159+
/**
160+
* Resolves the Node.js executable path for launching nested Claude instances.
161+
* <p>
162+
* Checks whether the {@code NODE_OPTIONS} environment variable contains the cache-fix module.
163+
* If not detected, emits a warning to {@code stderr} suggesting how to enable it.
164+
* <p>
165+
* Returns the path to the Node.js executable from {@code CLAUDE_CODE_EXECPATH}, which is used
166+
* to run {@code cli.js} directly.
167+
*
168+
* @param stderr the stream to write the warning to when the cache-fix module is not detected
169+
* @return the path to the Node.js executable
170+
* @throws NullPointerException if {@code stderr} is null
171+
* @throws AssertionError if {@code CLAUDE_CODE_EXECPATH} is not set
172+
*/
173+
public static String resolveClaudeBinary(PrintStream stderr)
174+
{
175+
requireThat(stderr, "stderr").isNotNull();
176+
String nodeOptions = System.getenv("NODE_OPTIONS");
177+
if (!isCacheFixDetected(nodeOptions))
178+
{
179+
stderr.println("WARNING: claude-code-cache-fix not detected in NODE_OPTIONS. " +
180+
"Falling back to unpatched claude. " +
181+
"To enable cache fix, either: " +
182+
"(1) export NODE_OPTIONS=\"${NODE_OPTIONS} --import claude-code-cache-fix\", or " +
183+
"(2) use the wrapper script from https://github.com/cnighswonger/claude-code-cache-fix" +
184+
"#option-a-wrapper-script-recommended");
185+
}
186+
String nodeExec = System.getenv("CLAUDE_CODE_EXECPATH");
187+
if (nodeExec == null || nodeExec.isEmpty())
188+
throw new AssertionError("CLAUDE_CODE_EXECPATH environment variable is not set");
189+
return nodeExec;
190+
}
191+
141192
/**
142193
* Builds the claude CLI command with appropriate flags.
194+
* <p>
195+
* Constructs a command that runs {@code node cli.js} directly with the cache-fix module loaded
196+
* via {@code NODE_OPTIONS}. Emits a warning to stderr when the cache-fix module is not detected.
143197
*
144198
* @param model the model name (haiku, sonnet, or opus)
145199
* @param appendSystemPrompt the text to append to the system prompt via
@@ -150,6 +204,7 @@ public String getIsolatedConfigDir()
150204
* @throws NullPointerException if {@code model}, {@code appendSystemPrompt}, or {@code agent}
151205
* are null
152206
* @throws IllegalArgumentException if {@code model} is not in the allowed set
207+
* @throws AssertionError if {@code NPM_CONFIG_PREFIX} is not set
153208
*/
154209
public List<String> buildCommand(String model, String appendSystemPrompt, String agent)
155210
{
@@ -162,7 +217,11 @@ public List<String> buildCommand(String model, String appendSystemPrompt, String
162217
requireThat(appendSystemPrompt, "appendSystemPrompt").isNotNull();
163218
requireThat(agent, "agent").isNotNull();
164219
List<String> command = new ArrayList<>();
165-
command.add("claude");
220+
command.add(resolveClaudeBinary(System.err));
221+
String npmPrefix = System.getenv("NPM_CONFIG_PREFIX");
222+
if (npmPrefix == null || npmPrefix.isEmpty())
223+
throw new AssertionError("NPM_CONFIG_PREFIX environment variable is not set");
224+
command.add(npmPrefix + "/lib/node_modules/@anthropic-ai/claude-code/cli.js");
166225
command.add("-p");
167226
command.add("--model");
168227
command.add(model);
@@ -240,11 +299,14 @@ public String buildInput(List<PrimingMessage> primingMessages, List<String> prom
240299

241300
/**
242301
* Builds a {@link ProcessBuilder} configured with the correct environment for launching the
243-
* claude CLI.
302+
* Claude Code CLI.
244303
* <p>
245304
* Removes the {@code CLAUDECODE} env var so the spawned process does not inherit the
246305
* hook-suppression flag. Sets {@code CLAUDE_CONFIG_DIR} and {@code CLAUDE_PLUGIN_ROOT} when
247306
* isolation is active so that all consumers read from the isolated plugin copy.
307+
* <p>
308+
* When {@code claude-code-cache-fix} is detected, sets cache-fix-specific environment variables:
309+
* {@code CACHE_FIX_STRIP_GIT_STATUS=1} and {@code CACHE_FIX_TTL_SUBAGENT=5m}.
248310
*
249311
* @param command the command to execute
250312
* @param cwd the working directory
@@ -256,15 +318,27 @@ public ProcessBuilder buildProcessBuilder(List<String> command, Path cwd)
256318
ProcessBuilder pb = new ProcessBuilder(command);
257319
Map<String, String> env = pb.environment();
258320
env.remove("CLAUDECODE");
259-
// Override CLAUDE_PROJECT_DIR so the claude process and its subagents resolve relative file
260-
// paths against the runner worktree, not the main workspace.
321+
// Remove env vars that can be used to inject malicious code into the spawned process.
322+
env.remove("LD_PRELOAD");
323+
env.remove("LD_LIBRARY_PATH");
324+
env.remove("JAVA_TOOL_OPTIONS");
325+
// Override CLAUDE_PROJECT_DIR so the Claude Code process and its subagents resolve relative
326+
// file paths against the runner worktree, not the main workspace.
261327
env.put("CLAUDE_PROJECT_DIR", cwd.toAbsolutePath().toString());
262328

263329
// Inherit ANTHROPIC_BASE_URL if set in parent environment
264-
String anthropicBaseUrl = System.getenv("ANTHROPIC_BASE_URL");
265-
if (anthropicBaseUrl != null)
330+
String anthropicBaseUrl = scope.getAnthropicBaseUrl();
331+
if (!anthropicBaseUrl.isEmpty())
266332
env.put("ANTHROPIC_BASE_URL", anthropicBaseUrl);
267333

334+
// When using claude-code-cache-fix, set cache-fix-specific environment variables
335+
String nodeOptions = System.getenv("NODE_OPTIONS");
336+
if (isCacheFixDetected(nodeOptions))
337+
{
338+
env.put("CACHE_FIX_STRIP_GIT_STATUS", "1");
339+
env.put("CACHE_FIX_TTL_SUBAGENT", "5m");
340+
}
341+
268342
if (isolatedConfigDir != null)
269343
{
270344
env.put("CLAUDE_CONFIG_DIR", isolatedConfigDir.toString());
@@ -276,8 +350,8 @@ public ProcessBuilder buildProcessBuilder(List<String> command, Path cwd)
276350
}
277351

278352
/**
279-
* Executes the claude CLI process with the given input, streaming output line-by-line
280-
* to avoid buffering the full response in memory.
353+
* Executes the Node.js process running cli.js with the given input, streaming output
354+
* line-by-line to avoid buffering the full response in memory.
281355
* <p>
282356
* If an isolated config directory has been created via {@link #createIsolatedConfig},
283357
* the process will use it via the {@code CLAUDE_CONFIG_DIR} environment variable.
@@ -334,7 +408,7 @@ public ProcessResult executeProcess(List<String> command, String input, Path cwd
334408
/**
335409
* Parses stream-json output to extract assistant text blocks and tool uses.
336410
*
337-
* @param output the raw output from claude CLI
411+
* @param output the raw output from Claude Code CLI
338412
* @return the parsed output
339413
* @throws NullPointerException if {@code output} is null
340414
*/
@@ -473,7 +547,7 @@ public void close() throws IOException
473547
{
474548
if (isolatedConfigDir != null)
475549
{
476-
deleteDirectoryRecursively(isolatedConfigDir);
550+
FileUtils.deleteDirectoryRecursively(isolatedConfigDir);
477551
isolatedConfigDir = null;
478552
}
479553
}
@@ -548,37 +622,6 @@ private String makeToolResultMessage(String toolUseId, String toolOutput)
548622
return buildMessage("user", "user", content);
549623
}
550624

551-
/**
552-
* Copies a directory tree recursively.
553-
*
554-
* @param source the source directory
555-
* @param target the target directory
556-
* @throws IOException if the copy fails
557-
*/
558-
private static void copyDirectoryRecursively(Path source, Path target) throws IOException
559-
{
560-
Files.walkFileTree(source, new SimpleFileVisitor<>()
561-
{
562-
@Override
563-
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs)
564-
throws IOException
565-
{
566-
Path targetDir = target.resolve(source.relativize(dir));
567-
Files.createDirectories(targetDir);
568-
return FileVisitResult.CONTINUE;
569-
}
570-
571-
@Override
572-
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
573-
throws IOException
574-
{
575-
Files.copy(file, target.resolve(source.relativize(file)),
576-
StandardCopyOption.REPLACE_EXISTING);
577-
return FileVisitResult.CONTINUE;
578-
}
579-
});
580-
}
581-
582625
/**
583626
* Deletes all contents of a directory without deleting the directory itself.
584627
*
@@ -609,35 +652,7 @@ public FileVisitResult postVisitDirectory(Path directory, IOException exception)
609652
}
610653

611654
/**
612-
* Deletes a directory and all its contents recursively.
613-
*
614-
* @param dir the directory to delete
615-
* @throws IOException if the deletion fails
616-
*/
617-
private static void deleteDirectoryRecursively(Path dir) throws IOException
618-
{
619-
Files.walkFileTree(dir, new SimpleFileVisitor<>()
620-
{
621-
@Override
622-
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException
623-
{
624-
Files.delete(file);
625-
return FileVisitResult.CONTINUE;
626-
}
627-
628-
@Override
629-
public FileVisitResult postVisitDirectory(Path directory, IOException exception) throws IOException
630-
{
631-
if (exception != null)
632-
throw exception;
633-
Files.delete(directory);
634-
return FileVisitResult.CONTINUE;
635-
}
636-
});
637-
}
638-
639-
/**
640-
* Result of executing the claude CLI process.
655+
* Result of executing the Node.js process running cli.js.
641656
*
642657
* @param parsed the parsed output
643658
* @param elapsed the elapsed time in seconds
@@ -881,7 +896,7 @@ public static int run(ClaudeTool scope, String[] args, PrintStream out) throws I
881896
pluginVersion);
882897
}
883898

884-
// When --agent is specified, pass --agent <type> to the nested claude process.
899+
// When --agent is specified, pass --agent <type> to the nested Claude Code process.
885900
// The nested process has CLAUDE_CONFIG_DIR pointing to the isolated config directory
886901
// (when --plugin-source is provided), so --agent resolves from the candidate plugin's
887902
// agents/ directory, honoring all frontmatter settings (tools:, model:, etc.).

0 commit comments

Comments
 (0)