fix: patch 7 critical bugs affecting all non-dev-machine users and Windows

1. Fix esbuild inlining build-machine __dirname as string literal — use
   CJS-compatible runtime banner with require("node:url").fileURLToPath
   across worker-service, mcp-server, and context-generator builds.

2. Fix isMainModule check missing .cjs extension and Windows backslash
   path normalization.

3. Wrap extractLastMessage in try-catch to prevent infinite Stop hook
   feedback loop on malformed transcripts (exit 0 instead of exit 2).

4. Replace heavy SessionEnd hook (Node→Bun→1.7MB CJS→HTTP) with
   lightweight inline node -e one-liner (~200ms vs >1s).

5. Add 7 Gemini/OpenRouter error patterns to unrecoverablePatterns
   circuit breaker to prevent 77K+ retry loops on expired API keys.

6. Preserve CLAUDE_CODE_OAUTH_TOKEN and CLAUDE_CODE_GIT_BASH_PATH in
   sanitizeEnv instead of stripping them with the CLAUDE_CODE_ prefix.

7. Use PowerShell -EncodedCommand for spawnDaemon to fix path quoting
   when Windows usernames contain spaces.

Closes #1515, #1495, #1475, #1465, #1500, #1513, #1512, #1450, #1460,
#1486, #1449, #1481, #1451, #1480, #1453, #1445

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alex Newman
2026-03-28 15:20:29 -07:00
parent a656af2bff
commit 07ab7000a8
11 changed files with 185 additions and 92 deletions

View File

@@ -74,8 +74,8 @@
"hooks": [
{
"type": "command",
"command": "_R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" hook claude-code session-complete",
"timeout": 30
"command": "node -e \"let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{const{sessionId:s}=JSON.parse(d);if(!s){process.exit(0)}const r=require('http').request({hostname:'127.0.0.1',port:37777,path:'/api/sessions/complete',method:'POST',headers:{'Content-Type':'application/json'}},()=>process.exit(0));r.on('error',()=>process.exit(0));r.end(JSON.stringify({contentSessionId:s}));setTimeout(()=>process.exit(0),3000)}catch{process.exit(0)}})\"",
"timeout": 5
}
]
}

View File

@@ -1,3 +1,5 @@
var __filename = require("node:url").fileURLToPath(import.meta.url);
var __dirname = require("node:path").dirname(__filename);
"use strict";var Tt=Object.create;var U=Object.defineProperty;var gt=Object.getOwnPropertyDescriptor;var ft=Object.getOwnPropertyNames;var bt=Object.getPrototypeOf,St=Object.prototype.hasOwnProperty;var ht=(r,e)=>{for(var t in e)U(r,t,{get:e[t],enumerable:!0})},de=(r,e,t,s)=>{if(e&&typeof e=="object"||typeof e=="function")for(let n of ft(e))!St.call(r,n)&&n!==t&&U(r,n,{get:()=>e[n],enumerable:!(s=gt(e,n))||s.enumerable});return r};var y=(r,e,t)=>(t=r!=null?Tt(bt(r)):{},de(e||!r||!r.__esModule?U(t,"default",{value:r,enumerable:!0}):t,r)),Ot=r=>de(U({},"__esModule",{value:!0}),r);var Pt={};ht(Pt,{generateContext:()=>ae});module.exports=Ot(Pt);var _t=y(require("path"),1),ut=require("os"),lt=require("fs");var Te=require("bun:sqlite");var S=require("path"),K=require("os"),k=require("fs");var me=require("url");var A=require("fs"),v=require("path"),pe=require("os"),q=(o=>(o[o.DEBUG=0]="DEBUG",o[o.INFO=1]="INFO",o[o.WARN=2]="WARN",o[o.ERROR=3]="ERROR",o[o.SILENT=4]="SILENT",o))(q||{}),ce=(0,v.join)((0,pe.homedir)(),".claude-mem"),V=class{level=null;useColor;logFilePath=null;logFileInitialized=!1;constructor(){this.useColor=process.stdout.isTTY??!1}ensureLogFileInitialized(){if(!this.logFileInitialized){this.logFileInitialized=!0;try{let e=(0,v.join)(ce,"logs");(0,A.existsSync)(e)||(0,A.mkdirSync)(e,{recursive:!0});let t=new Date().toISOString().split("T")[0];this.logFilePath=(0,v.join)(e,`claude-mem-${t}.log`)}catch(e){console.error("[LOGGER] Failed to initialize log file:",e),this.logFilePath=null}}}getLevel(){if(this.level===null)try{let e=(0,v.join)(ce,"settings.json");if((0,A.existsSync)(e)){let t=(0,A.readFileSync)(e,"utf-8"),n=(JSON.parse(t).CLAUDE_MEM_LOG_LEVEL||"INFO").toUpperCase();this.level=q[n]??1}else this.level=1}catch{this.level=1}return this.level}correlationId(e,t){return`obs-${e}-${t}`}sessionId(e){return`session-${e}`}formatData(e){if(e==null)return"";if(typeof e=="string")return e;if(typeof e=="number"||typeof e=="boolean")return e.toString();if(typeof e=="object"){if(e instanceof Error)return this.getLevel()===0?`${e.message}
${e.stack}`:e.message;if(Array.isArray(e))return`[${e.length} items]`;let t=Object.keys(e);return t.length===0?"{}":t.length<=3?JSON.stringify(e):`{${t.length} keys: ${t.slice(0,3).join(", ")}...}`}return String(e)}formatTool(e,t){if(!t)return e;let s=t;if(typeof t=="string")try{s=JSON.parse(t)}catch{s=t}if(e==="Bash"&&s.command)return`${e}(${s.command})`;if(s.file_path)return`${e}(${s.file_path})`;if(s.notebook_path)return`${e}(${s.notebook_path})`;if(e==="Glob"&&s.pattern)return`${e}(${s.pattern})`;if(e==="Grep"&&s.pattern)return`${e}(${s.pattern})`;if(s.url)return`${e}(${s.url})`;if(s.query)return`${e}(${s.query})`;if(e==="Task"){if(s.subagent_type)return`${e}(${s.subagent_type})`;if(s.description)return`${e}(${s.description})`}return e==="Skill"&&s.skill?`${e}(${s.skill})`:e==="LSP"&&s.operation?`${e}(${s.operation})`:e}formatTimestamp(e){let t=e.getFullYear(),s=String(e.getMonth()+1).padStart(2,"0"),n=String(e.getDate()).padStart(2,"0"),o=String(e.getHours()).padStart(2,"0"),i=String(e.getMinutes()).padStart(2,"0"),a=String(e.getSeconds()).padStart(2,"0"),d=String(e.getMilliseconds()).padStart(3,"0");return`${t}-${s}-${n} ${o}:${i}:${a}.${d}`}log(e,t,s,n,o){if(e<this.getLevel())return;this.ensureLogFileInitialized();let i=this.formatTimestamp(new Date),a=q[e].padEnd(5),d=t.padEnd(6),p="";n?.correlationId?p=`[${n.correlationId}] `:n?.sessionId&&(p=`[session-${n.sessionId}] `);let _="";o!=null&&(o instanceof Error?_=this.getLevel()===0?`
${o.message}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -116,7 +116,11 @@ async function buildHooks() {
'__DEFAULT_PACKAGE_VERSION__': `"${version}"`
},
banner: {
js: '#!/usr/bin/env bun'
js: [
'#!/usr/bin/env bun',
'var __filename = require("node:url").fileURLToPath(import.meta.url);',
'var __dirname = require("node:path").dirname(__filename);'
].join('\n')
}
});
@@ -153,7 +157,11 @@ async function buildHooks() {
'__DEFAULT_PACKAGE_VERSION__': `"${version}"`
},
banner: {
js: '#!/usr/bin/env node'
js: [
'#!/usr/bin/env node',
'var __filename = require("node:url").fileURLToPath(import.meta.url);',
'var __dirname = require("node:path").dirname(__filename);'
].join('\n')
}
});
@@ -176,6 +184,12 @@ async function buildHooks() {
external: ['bun:sqlite'],
define: {
'__DEFAULT_PACKAGE_VERSION__': `"${version}"`
},
banner: {
js: [
'var __filename = require("node:url").fileURLToPath(import.meta.url);',
'var __dirname = require("node:path").dirname(__filename);'
].join('\n')
}
});

View File

@@ -35,7 +35,13 @@ export const summarizeHandler: EventHandler = {
// Extract last assistant message from transcript (the work Claude did)
// Note: "user" messages in transcripts are mostly tool_results, not actual user input.
// The user's original request is already stored in user_prompts table.
const lastAssistantMessage = extractLastMessage(transcriptPath, 'assistant', true);
let lastAssistantMessage = '';
try {
lastAssistantMessage = extractLastMessage(transcriptPath, 'assistant', true);
} catch (err) {
logger.warn('HOOK', `Failed to extract last assistant message: ${err instanceof Error ? err.message : err}`);
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
}
logger.dataIn('HOOK', 'Stop: Requesting summary', {
hasLastAssistantMessage: !!lastAssistantMessage

View File

@@ -646,12 +646,12 @@ export function spawnDaemon(
return undefined;
}
const escapedRuntimePath = runtimePath.replace(/'/g, "''");
const escapedScriptPath = scriptPath.replace(/'/g, "''");
const psCommand = `Start-Process -FilePath '${escapedRuntimePath}' -ArgumentList '${escapedScriptPath}','--daemon' -WindowStyle Hidden`;
// Use -EncodedCommand to avoid all shell quoting issues with spaces in paths
const psScript = `Start-Process -FilePath '${runtimePath.replace(/'/g, "''")}' -ArgumentList @('${scriptPath.replace(/'/g, "''")}','--daemon') -WindowStyle Hidden`;
const encodedCommand = Buffer.from(psScript, 'utf16le').toString('base64');
try {
execSync(`powershell -NoProfile -Command "${psCommand}"`, {
execSync(`powershell -NoProfile -EncodedCommand ${encodedCommand}`, {
stdio: 'ignore',
windowsHide: true,
env

View File

@@ -578,6 +578,13 @@ export class WorkerService {
'ENOENT',
'spawn',
'Invalid API key',
'API_KEY_INVALID',
'API key expired',
'API key not valid',
'PERMISSION_DENIED',
'Gemini API error: 400',
'Gemini API error: 401',
'Gemini API error: 403',
'FOREIGN KEY constraint failed',
];
if (unrecoverablePatterns.some(pattern => errorMessage.includes(pattern))) {
@@ -1262,7 +1269,10 @@ async function main() {
// Check if running as main module in both ESM and CommonJS
const isMainModule = typeof require !== 'undefined' && typeof module !== 'undefined'
? require.main === module || !module.parent
: import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith('worker-service');
: import.meta.url === `file://${process.argv[1]}`
|| process.argv[1]?.endsWith('worker-service')
|| process.argv[1]?.endsWith('worker-service.cjs')
|| process.argv[1]?.replaceAll('\\', '/') === __filename?.replaceAll('\\', '/');
if (isMainModule) {
main().catch((error) => {

View File

@@ -6,11 +6,18 @@ export const ENV_EXACT_MATCHES = new Set([
'MCP_SESSION_ID',
]);
/** Vars that start with CLAUDE_CODE_ but must be preserved for subprocess auth/tooling */
export const ENV_PRESERVE = new Set([
'CLAUDE_CODE_OAUTH_TOKEN',
'CLAUDE_CODE_GIT_BASH_PATH',
]);
export function sanitizeEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
const sanitized: NodeJS.ProcessEnv = {};
for (const [key, value] of Object.entries(env)) {
if (value === undefined) continue;
if (ENV_PRESERVE.has(key)) { sanitized[key] = value; continue; }
if (ENV_EXACT_MATCHES.has(key)) continue;
if (ENV_PREFIXES.some(prefix => key.startsWith(prefix))) continue;
sanitized[key] = value;

View File

@@ -67,11 +67,14 @@ describe('Plugin Distribution - hooks.json Integrity', () => {
expect(parsed.hooks).toBeDefined();
});
it('should reference CLAUDE_PLUGIN_ROOT in all hook commands', () => {
it('should reference CLAUDE_PLUGIN_ROOT in all hook commands (except inline hooks)', () => {
const hooksPath = path.join(projectRoot, 'plugin/hooks/hooks.json');
const parsed = JSON.parse(readFileSync(hooksPath, 'utf-8'));
// SessionEnd uses a lightweight inline node -e command (no plugin root needed)
const inlineHookEvents = new Set(['SessionEnd']);
for (const [eventName, matchers] of Object.entries(parsed.hooks)) {
if (inlineHookEvents.has(eventName)) continue;
for (const matcher of matchers as any[]) {
for (const hook of matcher.hooks) {
if (hook.type === 'command') {
@@ -82,12 +85,14 @@ describe('Plugin Distribution - hooks.json Integrity', () => {
}
});
it('should include CLAUDE_PLUGIN_ROOT fallback in all hook commands (#1215)', () => {
it('should include CLAUDE_PLUGIN_ROOT fallback in all hook commands except inline hooks (#1215)', () => {
const hooksPath = path.join(projectRoot, 'plugin/hooks/hooks.json');
const parsed = JSON.parse(readFileSync(hooksPath, 'utf-8'));
const expectedFallbackPath = '$HOME/.claude/plugins/marketplaces/thedotmack/plugin';
const inlineHookEvents = new Set(['SessionEnd']);
for (const [eventName, matchers] of Object.entries(parsed.hooks)) {
if (inlineHookEvents.has(eventName)) continue;
for (const matcher of matchers as any[]) {
for (const hook of matcher.hooks) {
if (hook.type === 'command') {
@@ -97,6 +102,18 @@ describe('Plugin Distribution - hooks.json Integrity', () => {
}
}
});
it('should use lightweight inline node command for SessionEnd hook', () => {
const hooksPath = path.join(projectRoot, 'plugin/hooks/hooks.json');
const parsed = JSON.parse(readFileSync(hooksPath, 'utf-8'));
const sessionEndHooks = parsed.hooks.SessionEnd;
expect(sessionEndHooks).toBeDefined();
expect(sessionEndHooks.length).toBe(1);
const command = sessionEndHooks[0].hooks[0].command;
expect(command).toContain('node -e');
expect(command).toContain('/api/sessions/complete');
expect(sessionEndHooks[0].hooks[0].timeout).toBeLessThanOrEqual(10);
});
});
describe('Plugin Distribution - package.json Files Field', () => {

View File

@@ -14,7 +14,7 @@ describe('sanitizeEnv', () => {
expect(result.PATH).toBe('/usr/bin');
});
it('strips variables with CLAUDE_CODE_ prefix', () => {
it('strips variables with CLAUDE_CODE_ prefix but preserves allowed ones', () => {
const result = sanitizeEnv({
CLAUDE_CODE_BAR: 'baz',
CLAUDE_CODE_OAUTH_TOKEN: 'token',
@@ -22,7 +22,7 @@ describe('sanitizeEnv', () => {
});
expect(result.CLAUDE_CODE_BAR).toBeUndefined();
expect(result.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined();
expect(result.CLAUDE_CODE_OAUTH_TOKEN).toBe('token');
expect(result.HOME).toBe('/home/user');
});
@@ -115,9 +115,42 @@ describe('sanitizeEnv', () => {
expect(result.CLAUDECODE).toBeUndefined();
expect(result.CLAUDECODE_FOO).toBeUndefined();
expect(result.CLAUDE_CODE_BAR).toBeUndefined();
expect(result.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined();
expect(result.CLAUDE_CODE_OAUTH_TOKEN).toBe('oauth-token');
expect(result.CLAUDE_CODE_SESSION).toBeUndefined();
expect(result.CLAUDE_CODE_ENTRYPOINT).toBeUndefined();
expect(result.MCP_SESSION_ID).toBeUndefined();
});
it('preserves CLAUDE_CODE_GIT_BASH_PATH through sanitization', () => {
const result = sanitizeEnv({
CLAUDE_CODE_GIT_BASH_PATH: 'C:\\Program Files\\Git\\bin\\bash.exe',
PATH: '/usr/bin',
HOME: '/home/user'
});
expect(result.CLAUDE_CODE_GIT_BASH_PATH).toBe('C:\\Program Files\\Git\\bin\\bash.exe');
expect(result.PATH).toBe('/usr/bin');
expect(result.HOME).toBe('/home/user');
});
it('selectively preserves only allowed CLAUDE_CODE_* vars while stripping others', () => {
const result = sanitizeEnv({
CLAUDE_CODE_OAUTH_TOKEN: 'my-oauth-token',
CLAUDE_CODE_GIT_BASH_PATH: '/usr/bin/bash',
CLAUDE_CODE_RANDOM_OTHER: 'should-be-stripped',
CLAUDE_CODE_INTERNAL_FLAG: 'should-be-stripped',
PATH: '/usr/bin'
});
// Preserved: explicitly allowed CLAUDE_CODE_* vars
expect(result.CLAUDE_CODE_OAUTH_TOKEN).toBe('my-oauth-token');
expect(result.CLAUDE_CODE_GIT_BASH_PATH).toBe('/usr/bin/bash');
// Stripped: all other CLAUDE_CODE_* vars
expect(result.CLAUDE_CODE_RANDOM_OTHER).toBeUndefined();
expect(result.CLAUDE_CODE_INTERNAL_FLAG).toBeUndefined();
// Preserved: normal env vars
expect(result.PATH).toBe('/usr/bin');
});
});