mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-03-30 13:43:26 +08:00
Merge pull request #903 from affaan-m/fix/session-manager-843-supersede-853
fix: fold blocker-lane session and hook hardening into one PR
This commit is contained in:
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@@ -175,6 +175,10 @@ jobs:
|
||||
run: node scripts/ci/validate-skills.js
|
||||
continue-on-error: false
|
||||
|
||||
- name: Validate install manifests
|
||||
run: node scripts/ci/validate-install-manifests.js
|
||||
continue-on-error: false
|
||||
|
||||
- name: Validate rules
|
||||
run: node scripts/ci/validate-rules.js
|
||||
continue-on-error: false
|
||||
|
||||
3
.github/workflows/reusable-validate.yml
vendored
3
.github/workflows/reusable-validate.yml
vendored
@@ -39,5 +39,8 @@ jobs:
|
||||
- name: Validate skills
|
||||
run: node scripts/ci/validate-skills.js
|
||||
|
||||
- name: Validate install manifests
|
||||
run: node scripts/ci/validate-install-manifests.js
|
||||
|
||||
- name: Validate rules
|
||||
run: node scripts/ci/validate-rules.js
|
||||
|
||||
@@ -142,7 +142,7 @@ Troubleshoot failures: check test isolation → verify mocks → fix implementat
|
||||
|
||||
```
|
||||
agents/ — 28 specialized subagents
|
||||
skills/ — 117 workflow skills and domain knowledge
|
||||
skills/ — 125 workflow skills and domain knowledge
|
||||
commands/ — 60 slash commands
|
||||
hooks/ — Trigger-based automations
|
||||
rules/ — Always-follow guidelines (common + per-language)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
description: Load the most recent session file from ~/.claude/sessions/ and resume work with full context from where the last session ended.
|
||||
description: Load the most recent session file from ~/.claude/session-data/ and resume work with full context from where the last session ended.
|
||||
---
|
||||
|
||||
# Resume Session Command
|
||||
@@ -17,10 +17,10 @@ This command is the counterpart to `/save-session`.
|
||||
## Usage
|
||||
|
||||
```
|
||||
/resume-session # loads most recent file in ~/.claude/sessions/
|
||||
/resume-session # loads most recent file in ~/.claude/session-data/
|
||||
/resume-session 2024-01-15 # loads most recent session for that date
|
||||
/resume-session ~/.claude/sessions/2024-01-15-session.tmp # loads a specific legacy-format file
|
||||
/resume-session ~/.claude/sessions/2024-01-15-abc123de-session.tmp # loads a current short-id session file
|
||||
/resume-session ~/.claude/session-data/2024-01-15-abc123de-session.tmp # loads a current short-id session file
|
||||
/resume-session ~/.claude/sessions/2024-01-15-session.tmp # loads a specific legacy-format file
|
||||
```
|
||||
|
||||
## Process
|
||||
@@ -29,19 +29,20 @@ This command is the counterpart to `/save-session`.
|
||||
|
||||
If no argument provided:
|
||||
|
||||
1. Check `~/.claude/sessions/`
|
||||
1. Check `~/.claude/session-data/`
|
||||
2. Pick the most recently modified `*-session.tmp` file
|
||||
3. If the folder does not exist or has no matching files, tell the user:
|
||||
```
|
||||
No session files found in ~/.claude/sessions/
|
||||
No session files found in ~/.claude/session-data/
|
||||
Run /save-session at the end of a session to create one.
|
||||
```
|
||||
Then stop.
|
||||
|
||||
If an argument is provided:
|
||||
|
||||
- If it looks like a date (`YYYY-MM-DD`), search `~/.claude/sessions/` for files matching
|
||||
`YYYY-MM-DD-session.tmp` (legacy format) or `YYYY-MM-DD-<shortid>-session.tmp` (current format)
|
||||
- If it looks like a date (`YYYY-MM-DD`), search `~/.claude/session-data/` first, then the legacy
|
||||
`~/.claude/sessions/`, for files matching `YYYY-MM-DD-session.tmp` (legacy format) or
|
||||
`YYYY-MM-DD-<shortid>-session.tmp` (current format)
|
||||
and load the most recently modified variant for that date
|
||||
- If it looks like a file path, read that file directly
|
||||
- If not found, report clearly and stop
|
||||
@@ -114,7 +115,7 @@ Report: "Session file found but appears empty or unreadable. You may need to cre
|
||||
## Example Output
|
||||
|
||||
```
|
||||
SESSION LOADED: /Users/you/.claude/sessions/2024-01-15-abc123de-session.tmp
|
||||
SESSION LOADED: /Users/you/.claude/session-data/2024-01-15-abc123de-session.tmp
|
||||
════════════════════════════════════════════════
|
||||
|
||||
PROJECT: my-app — JWT Authentication
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
description: Save current session state to a dated file in ~/.claude/sessions/ so work can be resumed in a future session with full context.
|
||||
description: Save current session state to a dated file in ~/.claude/session-data/ so work can be resumed in a future session with full context.
|
||||
---
|
||||
|
||||
# Save Session Command
|
||||
@@ -29,19 +29,19 @@ Before writing the file, collect:
|
||||
Create the canonical sessions folder in the user's Claude home directory:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.claude/sessions
|
||||
mkdir -p ~/.claude/session-data
|
||||
```
|
||||
|
||||
### Step 3: Write the session file
|
||||
|
||||
Create `~/.claude/sessions/YYYY-MM-DD-<short-id>-session.tmp`, using today's actual date and a short-id that satisfies the rules enforced by `SESSION_FILENAME_REGEX` in `session-manager.js`:
|
||||
Create `~/.claude/session-data/YYYY-MM-DD-<short-id>-session.tmp`, using today's actual date and a short-id that satisfies the rules enforced by `SESSION_FILENAME_REGEX` in `session-manager.js`:
|
||||
|
||||
- Allowed characters: lowercase `a-z`, digits `0-9`, hyphens `-`
|
||||
- Minimum length: 8 characters
|
||||
- No uppercase letters, no underscores, no spaces
|
||||
- Compatibility characters: letters `a-z` / `A-Z`, digits `0-9`, hyphens `-`, underscores `_`
|
||||
- Compatibility minimum length: 1 character
|
||||
- Recommended style for new files: lowercase letters, digits, and hyphens with 8+ characters to avoid collisions
|
||||
|
||||
Valid examples: `abc123de`, `a1b2c3d4`, `frontend-worktree-1`
|
||||
Invalid examples: `ABC123de` (uppercase), `short` (under 8 chars), `test_id1` (underscore)
|
||||
Valid examples: `abc123de`, `a1b2c3d4`, `frontend-worktree-1`, `ChezMoi_2`
|
||||
Avoid for new files: `A`, `test_id1`, `ABC123de`
|
||||
|
||||
Full valid filename example: `2024-01-15-abc123de-session.tmp`
|
||||
|
||||
@@ -271,5 +271,5 @@ Then test with Postman — the response should include a `Set-Cookie` header.
|
||||
- The "What Did NOT Work" section is the most critical — future sessions will blindly retry failed approaches without it
|
||||
- If the user asks to save mid-session (not just at the end), save what's known so far and mark in-progress items clearly
|
||||
- The file is meant to be read by Claude at the start of the next session via `/resume-session`
|
||||
- Use the canonical global session store: `~/.claude/sessions/`
|
||||
- Use the canonical global session store: `~/.claude/session-data/`
|
||||
- Prefer the short-id filename form (`YYYY-MM-DD-<short-id>-session.tmp`) for any new session file
|
||||
|
||||
@@ -4,7 +4,7 @@ description: Manage Claude Code session history, aliases, and session metadata.
|
||||
|
||||
# Sessions Command
|
||||
|
||||
Manage Claude Code session history - list, load, alias, and edit sessions stored in `~/.claude/sessions/`.
|
||||
Manage Claude Code session history - list, load, alias, and edit sessions stored in `~/.claude/session-data/` with legacy reads from `~/.claude/sessions/`.
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -89,7 +89,7 @@ const size = sm.getSessionSize(session.sessionPath);
|
||||
const aliases = aa.getAliasesForSession(session.filename);
|
||||
|
||||
console.log('Session: ' + session.filename);
|
||||
console.log('Path: ~/.claude/sessions/' + session.filename);
|
||||
console.log('Path: ' + session.sessionPath);
|
||||
console.log('');
|
||||
console.log('Statistics:');
|
||||
console.log(' Lines: ' + stats.lineCount);
|
||||
@@ -327,7 +327,7 @@ $ARGUMENTS:
|
||||
|
||||
## Notes
|
||||
|
||||
- Sessions are stored as markdown files in `~/.claude/sessions/`
|
||||
- Sessions are stored as markdown files in `~/.claude/session-data/` with legacy reads from `~/.claude/sessions/`
|
||||
- Aliases are stored in `~/.claude/session-aliases.json`
|
||||
- Session IDs can be shortened (first 4-8 characters usually unique enough)
|
||||
- Use aliases for frequently referenced sessions
|
||||
|
||||
@@ -136,7 +136,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash -lc 'input=$(cat); for root in \"${CLAUDE_PLUGIN_ROOT:-}\" \"$HOME/.claude/plugins/everything-claude-code\" \"$HOME/.claude/plugins/everything-claude-code@everything-claude-code\" \"$HOME/.claude/plugins/marketplace/everything-claude-code\"; do if [ -n \"$root\" ] && [ -f \"$root/scripts/hooks/run-with-flags.js\" ]; then printf \"%s\" \"$input\" | node \"$root/scripts/hooks/run-with-flags.js\" \"session:start\" \"scripts/hooks/session-start.js\" \"minimal,standard,strict\"; exit $?; fi; done; for parent in \"$HOME/.claude/plugins\" \"$HOME/.claude/plugins/marketplace\"; do if [ -d \"$parent\" ]; then candidate=$(find \"$parent\" -maxdepth 2 -type f -path \"*/scripts/hooks/run-with-flags.js\" 2>/dev/null | head -n 1); if [ -n \"$candidate\" ]; then root=$(dirname \"$(dirname \"$(dirname \"$candidate\")\")\"); printf \"%s\" \"$input\" | node \"$root/scripts/hooks/run-with-flags.js\" \"session:start\" \"scripts/hooks/session-start.js\" \"minimal,standard,strict\"; exit $?; fi; fi; done; echo \"[SessionStart] WARNING: could not resolve ECC plugin root; skipping session-start hook\" >&2; printf \"%s\" \"$input\"; exit 0'"
|
||||
"command": "node -e \"const fs=require('fs');const path=require('path');const {spawnSync}=require('child_process');const raw=fs.readFileSync(0,'utf8');const rel=path.join('scripts','hooks','run-with-flags.js');const hasRunnerRoot=candidate=>{const value=typeof candidate==='string'?candidate.trim():'';return value.length>0&&fs.existsSync(path.join(path.resolve(value),rel));};const root=(()=>{const envRoot=process.env.CLAUDE_PLUGIN_ROOT||'';if(hasRunnerRoot(envRoot))return path.resolve(envRoot.trim());const home=require('os').homedir();const claudeDir=path.join(home,'.claude');if(hasRunnerRoot(claudeDir))return claudeDir;for(const candidate of [path.join(claudeDir,'plugins','everything-claude-code'),path.join(claudeDir,'plugins','everything-claude-code@everything-claude-code'),path.join(claudeDir,'plugins','marketplace','everything-claude-code')]){if(hasRunnerRoot(candidate))return candidate;}try{const cacheBase=path.join(claudeDir,'plugins','cache','everything-claude-code');for(const org of fs.readdirSync(cacheBase,{withFileTypes:true})){if(!org.isDirectory())continue;for(const version of fs.readdirSync(path.join(cacheBase,org.name),{withFileTypes:true})){if(!version.isDirectory())continue;const candidate=path.join(cacheBase,org.name,version.name);if(hasRunnerRoot(candidate))return candidate;}}}catch{}return claudeDir;})();const script=path.join(root,rel);if(fs.existsSync(script)){const result=spawnSync(process.execPath,[script,'session:start','scripts/hooks/session-start.js','minimal,standard,strict'],{input:raw,encoding:'utf8',env:process.env,cwd:process.cwd(),timeout:30000});const stdout=typeof result.stdout==='string'?result.stdout:'';if(stdout)process.stdout.write(stdout);else process.stdout.write(raw);if(result.stderr)process.stderr.write(result.stderr);if(result.error||result.status===null||result.signal){const reason=result.error?result.error.message:(result.signal?'signal '+result.signal:'missing exit status');process.stderr.write('[SessionStart] ERROR: session-start hook failed: '+reason+String.fromCharCode(10));process.exit(1);}process.exit(Number.isInteger(result.status)?result.status:0);}process.stderr.write('[SessionStart] WARNING: could not resolve ECC plugin root; skipping session-start hook'+String.fromCharCode(10));process.stdout.write(raw);\""
|
||||
}
|
||||
],
|
||||
"description": "Load previous context and detect package manager on new session"
|
||||
|
||||
@@ -63,7 +63,8 @@
|
||||
"description": "Runtime hook configs and hook script helpers.",
|
||||
"paths": [
|
||||
"hooks",
|
||||
"scripts/hooks"
|
||||
"scripts/hooks",
|
||||
"scripts/lib"
|
||||
],
|
||||
"targets": [
|
||||
"claude",
|
||||
|
||||
@@ -24,9 +24,11 @@ log() {
|
||||
|
||||
run_or_echo() {
|
||||
if [[ "$MODE" == "dry-run" ]]; then
|
||||
printf '[dry-run] %s\n' "$*"
|
||||
printf '[dry-run]'
|
||||
printf ' %q' "$@"
|
||||
printf '\n'
|
||||
else
|
||||
eval "$*"
|
||||
"$@"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -41,14 +43,14 @@ log "Global hooks destination: $DEST_DIR"
|
||||
|
||||
if [[ -d "$DEST_DIR" ]]; then
|
||||
log "Backing up existing hooks directory to $BACKUP_DIR"
|
||||
run_or_echo "mkdir -p \"$BACKUP_DIR\""
|
||||
run_or_echo "cp -R \"$DEST_DIR\" \"$BACKUP_DIR/hooks\""
|
||||
run_or_echo mkdir -p "$BACKUP_DIR"
|
||||
run_or_echo cp -R "$DEST_DIR" "$BACKUP_DIR/hooks"
|
||||
fi
|
||||
|
||||
run_or_echo "mkdir -p \"$DEST_DIR\""
|
||||
run_or_echo "cp \"$SOURCE_DIR/pre-commit\" \"$DEST_DIR/pre-commit\""
|
||||
run_or_echo "cp \"$SOURCE_DIR/pre-push\" \"$DEST_DIR/pre-push\""
|
||||
run_or_echo "chmod +x \"$DEST_DIR/pre-commit\" \"$DEST_DIR/pre-push\""
|
||||
run_or_echo mkdir -p "$DEST_DIR"
|
||||
run_or_echo cp "$SOURCE_DIR/pre-commit" "$DEST_DIR/pre-commit"
|
||||
run_or_echo cp "$SOURCE_DIR/pre-push" "$DEST_DIR/pre-push"
|
||||
run_or_echo chmod +x "$DEST_DIR/pre-commit" "$DEST_DIR/pre-push"
|
||||
|
||||
if [[ "$MODE" == "apply" ]]; then
|
||||
prev_hooks_path="$(git config --global core.hooksPath || true)"
|
||||
@@ -56,7 +58,7 @@ if [[ "$MODE" == "apply" ]]; then
|
||||
log "Previous global hooksPath: $prev_hooks_path"
|
||||
fi
|
||||
fi
|
||||
run_or_echo "git config --global core.hooksPath \"$DEST_DIR\""
|
||||
run_or_echo git config --global core.hooksPath "$DEST_DIR"
|
||||
|
||||
log "Installed ECC global git hooks."
|
||||
log "Disable per repo by creating .ecc-hooks-disable in project root."
|
||||
|
||||
@@ -61,11 +61,34 @@ const PROTECTED_FILES = new Set([
|
||||
'.markdownlintrc',
|
||||
]);
|
||||
|
||||
function parseInput(inputOrRaw) {
|
||||
if (typeof inputOrRaw === 'string') {
|
||||
try {
|
||||
return inputOrRaw.trim() ? JSON.parse(inputOrRaw) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
return inputOrRaw && typeof inputOrRaw === 'object' ? inputOrRaw : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Exportable run() for in-process execution via run-with-flags.js.
|
||||
* Avoids the ~50-100ms spawnSync overhead when available.
|
||||
*/
|
||||
function run(input) {
|
||||
function run(inputOrRaw, options = {}) {
|
||||
if (options.truncated) {
|
||||
return {
|
||||
exitCode: 2,
|
||||
stderr:
|
||||
`BLOCKED: Hook input exceeded ${options.maxStdin || MAX_STDIN} bytes. ` +
|
||||
'Refusing to bypass config-protection on a truncated payload. ' +
|
||||
'Retry with a smaller edit or disable the config-protection hook temporarily.'
|
||||
};
|
||||
}
|
||||
|
||||
const input = parseInput(inputOrRaw);
|
||||
const filePath = input?.tool_input?.file_path || input?.tool_input?.file || '';
|
||||
if (!filePath) return { exitCode: 0 };
|
||||
|
||||
@@ -75,9 +98,9 @@ function run(input) {
|
||||
exitCode: 2,
|
||||
stderr:
|
||||
`BLOCKED: Modifying ${basename} is not allowed. ` +
|
||||
`Fix the source code to satisfy linter/formatter rules instead of ` +
|
||||
`weakening the config. If this is a legitimate config change, ` +
|
||||
`disable the config-protection hook temporarily.`,
|
||||
'Fix the source code to satisfy linter/formatter rules instead of ' +
|
||||
'weakening the config. If this is a legitimate config change, ' +
|
||||
'disable the config-protection hook temporarily.',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -87,7 +110,7 @@ function run(input) {
|
||||
module.exports = { run };
|
||||
|
||||
// Stdin fallback for spawnSync execution
|
||||
let truncated = false;
|
||||
let truncated = /^(1|true|yes)$/i.test(String(process.env.ECC_HOOK_INPUT_TRUNCATED || ''));
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', chunk => {
|
||||
if (raw.length < MAX_STDIN) {
|
||||
@@ -100,25 +123,17 @@ process.stdin.on('data', chunk => {
|
||||
});
|
||||
|
||||
process.stdin.on('end', () => {
|
||||
// If stdin was truncated, the JSON is likely malformed. Fail open but
|
||||
// log a warning so the issue is visible. The run() path (used by
|
||||
// run-with-flags.js in-process) is not affected by this.
|
||||
if (truncated) {
|
||||
process.stderr.write('[config-protection] Warning: stdin exceeded 1MB, skipping check\n');
|
||||
process.stdout.write(raw);
|
||||
return;
|
||||
const result = run(raw, {
|
||||
truncated,
|
||||
maxStdin: Number(process.env.ECC_HOOK_INPUT_MAX_BYTES) || MAX_STDIN,
|
||||
});
|
||||
|
||||
if (result.stderr) {
|
||||
process.stderr.write(result.stderr + '\n');
|
||||
}
|
||||
|
||||
try {
|
||||
const input = raw.trim() ? JSON.parse(raw) : {};
|
||||
const result = run(input);
|
||||
|
||||
if (result.exitCode === 2) {
|
||||
process.stderr.write(result.stderr + '\n');
|
||||
process.exit(2);
|
||||
}
|
||||
} catch {
|
||||
// Keep hook non-blocking on parse errors.
|
||||
if (result.exitCode === 2) {
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
process.stdout.write(raw);
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
* - policy_violation: Actions that violate configured policies
|
||||
* - security_finding: Security-relevant tool invocations
|
||||
* - approval_requested: Operations requiring explicit approval
|
||||
* - hook_input_truncated: Hook input exceeded the safe inspection limit
|
||||
*
|
||||
* Enable: Set ECC_GOVERNANCE_CAPTURE=1
|
||||
* Configure session: Set ECC_SESSION_ID for session correlation
|
||||
@@ -101,6 +102,37 @@ function detectSensitivePath(filePath) {
|
||||
return SENSITIVE_PATHS.some(pattern => pattern.test(filePath));
|
||||
}
|
||||
|
||||
function fingerprintCommand(command) {
|
||||
if (!command || typeof command !== 'string') return null;
|
||||
return crypto.createHash('sha256').update(command).digest('hex').slice(0, 12);
|
||||
}
|
||||
|
||||
function summarizeCommand(command) {
|
||||
if (!command || typeof command !== 'string') {
|
||||
return {
|
||||
commandName: null,
|
||||
commandFingerprint: null,
|
||||
};
|
||||
}
|
||||
|
||||
const trimmed = command.trim();
|
||||
if (!trimmed) {
|
||||
return {
|
||||
commandName: null,
|
||||
commandFingerprint: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
commandName: trimmed.split(/\s+/)[0] || null,
|
||||
commandFingerprint: fingerprintCommand(trimmed),
|
||||
};
|
||||
}
|
||||
|
||||
function emitGovernanceEvent(event) {
|
||||
process.stderr.write(`[governance] ${JSON.stringify(event)}\n`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze a hook input payload and return governance events to capture.
|
||||
*
|
||||
@@ -146,6 +178,7 @@ function analyzeForGovernanceEvents(input, context = {}) {
|
||||
if (toolName === 'Bash') {
|
||||
const command = toolInput.command || '';
|
||||
const approvalFindings = detectApprovalRequired(command);
|
||||
const commandSummary = summarizeCommand(command);
|
||||
|
||||
if (approvalFindings.length > 0) {
|
||||
events.push({
|
||||
@@ -155,7 +188,7 @@ function analyzeForGovernanceEvents(input, context = {}) {
|
||||
payload: {
|
||||
toolName,
|
||||
hookPhase,
|
||||
command: command.slice(0, 200),
|
||||
...commandSummary,
|
||||
matchedPatterns: approvalFindings.map(f => f.pattern),
|
||||
severity: 'high',
|
||||
},
|
||||
@@ -188,6 +221,7 @@ function analyzeForGovernanceEvents(input, context = {}) {
|
||||
if (SECURITY_RELEVANT_TOOLS.has(toolName) && hookPhase === 'post') {
|
||||
const command = toolInput.command || '';
|
||||
const hasElevated = /sudo\s/.test(command) || /chmod\s/.test(command) || /chown\s/.test(command);
|
||||
const commandSummary = summarizeCommand(command);
|
||||
|
||||
if (hasElevated) {
|
||||
events.push({
|
||||
@@ -197,7 +231,7 @@ function analyzeForGovernanceEvents(input, context = {}) {
|
||||
payload: {
|
||||
toolName,
|
||||
hookPhase,
|
||||
command: command.slice(0, 200),
|
||||
...commandSummary,
|
||||
reason: 'elevated_privilege_command',
|
||||
severity: 'medium',
|
||||
},
|
||||
@@ -216,16 +250,32 @@ function analyzeForGovernanceEvents(input, context = {}) {
|
||||
* @param {string} rawInput - Raw JSON string from stdin
|
||||
* @returns {string} The original input (pass-through)
|
||||
*/
|
||||
function run(rawInput) {
|
||||
function run(rawInput, options = {}) {
|
||||
// Gate on feature flag
|
||||
if (String(process.env.ECC_GOVERNANCE_CAPTURE || '').toLowerCase() !== '1') {
|
||||
return rawInput;
|
||||
}
|
||||
|
||||
const sessionId = process.env.ECC_SESSION_ID || null;
|
||||
const hookPhase = process.env.CLAUDE_HOOK_EVENT_NAME || 'unknown';
|
||||
|
||||
if (options.truncated) {
|
||||
emitGovernanceEvent({
|
||||
id: generateEventId(),
|
||||
sessionId,
|
||||
eventType: 'hook_input_truncated',
|
||||
payload: {
|
||||
hookPhase: hookPhase.startsWith('Pre') ? 'pre' : 'post',
|
||||
sizeLimitBytes: options.maxStdin || MAX_STDIN,
|
||||
severity: 'warning',
|
||||
},
|
||||
resolvedAt: null,
|
||||
resolution: null,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const input = JSON.parse(rawInput);
|
||||
const sessionId = process.env.ECC_SESSION_ID || null;
|
||||
const hookPhase = process.env.CLAUDE_HOOK_EVENT_NAME || 'unknown';
|
||||
|
||||
const events = analyzeForGovernanceEvents(input, {
|
||||
sessionId,
|
||||
@@ -233,13 +283,8 @@ function run(rawInput) {
|
||||
});
|
||||
|
||||
if (events.length > 0) {
|
||||
// Write events to stderr as JSON-lines for the caller to capture.
|
||||
// The state store write is async and handled by a separate process
|
||||
// to avoid blocking the hook pipeline.
|
||||
for (const event of events) {
|
||||
process.stderr.write(
|
||||
`[governance] ${JSON.stringify(event)}\n`
|
||||
);
|
||||
emitGovernanceEvent(event);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -252,16 +297,25 @@ function run(rawInput) {
|
||||
// ── stdin entry point ────────────────────────────────
|
||||
if (require.main === module) {
|
||||
let raw = '';
|
||||
let truncated = /^(1|true|yes)$/i.test(String(process.env.ECC_HOOK_INPUT_TRUNCATED || ''));
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', chunk => {
|
||||
if (raw.length < MAX_STDIN) {
|
||||
const remaining = MAX_STDIN - raw.length;
|
||||
raw += chunk.substring(0, remaining);
|
||||
if (chunk.length > remaining) {
|
||||
truncated = true;
|
||||
}
|
||||
} else {
|
||||
truncated = true;
|
||||
}
|
||||
});
|
||||
|
||||
process.stdin.on('end', () => {
|
||||
const result = run(raw);
|
||||
const result = run(raw, {
|
||||
truncated,
|
||||
maxStdin: Number(process.env.ECC_HOOK_INPUT_MAX_BYTES) || MAX_STDIN,
|
||||
});
|
||||
process.stdout.write(result);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -99,15 +99,21 @@ function saveState(filePath, state) {
|
||||
function readRawStdin() {
|
||||
return new Promise(resolve => {
|
||||
let raw = '';
|
||||
let truncated = /^(1|true|yes)$/i.test(String(process.env.ECC_HOOK_INPUT_TRUNCATED || ''));
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', chunk => {
|
||||
if (raw.length < MAX_STDIN) {
|
||||
const remaining = MAX_STDIN - raw.length;
|
||||
raw += chunk.substring(0, remaining);
|
||||
if (chunk.length > remaining) {
|
||||
truncated = true;
|
||||
}
|
||||
} else {
|
||||
truncated = true;
|
||||
}
|
||||
});
|
||||
process.stdin.on('end', () => resolve(raw));
|
||||
process.stdin.on('error', () => resolve(raw));
|
||||
process.stdin.on('end', () => resolve({ raw, truncated }));
|
||||
process.stdin.on('error', () => resolve({ raw, truncated }));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -155,6 +161,18 @@ function extractMcpTarget(input) {
|
||||
};
|
||||
}
|
||||
|
||||
function extractMcpTargetFromRaw(raw) {
|
||||
const toolNameMatch = raw.match(/"(?:tool_name|name)"\s*:\s*"([^"]+)"/);
|
||||
const serverMatch = raw.match(/"(?:server|mcp_server|connector)"\s*:\s*"([^"]+)"/);
|
||||
const toolMatch = raw.match(/"(?:tool|mcp_tool)"\s*:\s*"([^"]+)"/);
|
||||
|
||||
return extractMcpTarget({
|
||||
tool_name: toolNameMatch ? toolNameMatch[1] : '',
|
||||
server: serverMatch ? serverMatch[1] : undefined,
|
||||
tool: toolMatch ? toolMatch[1] : undefined
|
||||
});
|
||||
}
|
||||
|
||||
function resolveServerConfig(serverName) {
|
||||
for (const filePath of configPaths()) {
|
||||
const data = readJsonFile(filePath);
|
||||
@@ -559,9 +577,9 @@ async function handlePostToolUseFailure(rawInput, input, target, statePathValue,
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const rawInput = await readRawStdin();
|
||||
const { raw: rawInput, truncated } = await readRawStdin();
|
||||
const input = safeParse(rawInput);
|
||||
const target = extractMcpTarget(input);
|
||||
const target = extractMcpTarget(input) || (truncated ? extractMcpTargetFromRaw(rawInput) : null);
|
||||
|
||||
if (!target) {
|
||||
process.stdout.write(rawInput);
|
||||
@@ -569,6 +587,19 @@ async function main() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (truncated) {
|
||||
const limit = Number(process.env.ECC_HOOK_INPUT_MAX_BYTES) || MAX_STDIN;
|
||||
const logs = [
|
||||
shouldFailOpen()
|
||||
? `[MCPHealthCheck] Hook input exceeded ${limit} bytes while checking ${target.server}; allowing ${target.tool || 'tool'} because fail-open mode is enabled`
|
||||
: `[MCPHealthCheck] Hook input exceeded ${limit} bytes while checking ${target.server}; blocking ${target.tool || 'tool'} to avoid bypassing MCP health checks`
|
||||
];
|
||||
emitLogs(logs);
|
||||
process.stdout.write(rawInput);
|
||||
process.exit(shouldFailOpen() ? 0 : 2);
|
||||
return;
|
||||
}
|
||||
|
||||
const eventName = process.env.CLAUDE_HOOK_EVENT_NAME || 'PreToolUse';
|
||||
const now = Date.now();
|
||||
const statePathValue = stateFilePath();
|
||||
|
||||
@@ -18,18 +18,66 @@ const MAX_STDIN = 1024 * 1024;
|
||||
function readStdinRaw() {
|
||||
return new Promise(resolve => {
|
||||
let raw = '';
|
||||
let truncated = false;
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', chunk => {
|
||||
if (raw.length < MAX_STDIN) {
|
||||
const remaining = MAX_STDIN - raw.length;
|
||||
raw += chunk.substring(0, remaining);
|
||||
if (chunk.length > remaining) {
|
||||
truncated = true;
|
||||
}
|
||||
} else {
|
||||
truncated = true;
|
||||
}
|
||||
});
|
||||
process.stdin.on('end', () => resolve(raw));
|
||||
process.stdin.on('error', () => resolve(raw));
|
||||
process.stdin.on('end', () => resolve({ raw, truncated }));
|
||||
process.stdin.on('error', () => resolve({ raw, truncated }));
|
||||
});
|
||||
}
|
||||
|
||||
function writeStderr(stderr) {
|
||||
if (typeof stderr !== 'string' || stderr.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
process.stderr.write(stderr.endsWith('\n') ? stderr : `${stderr}\n`);
|
||||
}
|
||||
|
||||
function emitHookResult(raw, output) {
|
||||
if (typeof output === 'string' || Buffer.isBuffer(output)) {
|
||||
process.stdout.write(String(output));
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (output && typeof output === 'object') {
|
||||
writeStderr(output.stderr);
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(output, 'stdout')) {
|
||||
process.stdout.write(String(output.stdout ?? ''));
|
||||
} else if (!Number.isInteger(output.exitCode) || output.exitCode === 0) {
|
||||
process.stdout.write(raw);
|
||||
}
|
||||
|
||||
return Number.isInteger(output.exitCode) ? output.exitCode : 0;
|
||||
}
|
||||
|
||||
process.stdout.write(raw);
|
||||
return 0;
|
||||
}
|
||||
|
||||
function writeLegacySpawnOutput(raw, result) {
|
||||
const stdout = typeof result.stdout === 'string' ? result.stdout : '';
|
||||
if (stdout) {
|
||||
process.stdout.write(stdout);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Number.isInteger(result.status) && result.status === 0) {
|
||||
process.stdout.write(raw);
|
||||
}
|
||||
}
|
||||
|
||||
function getPluginRoot() {
|
||||
if (process.env.CLAUDE_PLUGIN_ROOT && process.env.CLAUDE_PLUGIN_ROOT.trim()) {
|
||||
return process.env.CLAUDE_PLUGIN_ROOT;
|
||||
@@ -39,7 +87,7 @@ function getPluginRoot() {
|
||||
|
||||
async function main() {
|
||||
const [, , hookId, relScriptPath, profilesCsv] = process.argv;
|
||||
const raw = await readStdinRaw();
|
||||
const { raw, truncated } = await readStdinRaw();
|
||||
|
||||
if (!hookId || !relScriptPath) {
|
||||
process.stdout.write(raw);
|
||||
@@ -89,8 +137,8 @@ async function main() {
|
||||
|
||||
if (hookModule && typeof hookModule.run === 'function') {
|
||||
try {
|
||||
const output = hookModule.run(raw);
|
||||
if (output !== null && output !== undefined) process.stdout.write(output);
|
||||
const output = hookModule.run(raw, { truncated, maxStdin: MAX_STDIN });
|
||||
process.exit(emitHookResult(raw, output));
|
||||
} catch (runErr) {
|
||||
process.stderr.write(`[Hook] run() error for ${hookId}: ${runErr.message}\n`);
|
||||
process.stdout.write(raw);
|
||||
@@ -99,19 +147,32 @@ async function main() {
|
||||
}
|
||||
|
||||
// Legacy path: spawn a child Node process for hooks without run() export
|
||||
const result = spawnSync('node', [scriptPath], {
|
||||
const result = spawnSync(process.execPath, [scriptPath], {
|
||||
input: raw,
|
||||
encoding: 'utf8',
|
||||
env: process.env,
|
||||
env: {
|
||||
...process.env,
|
||||
ECC_HOOK_INPUT_TRUNCATED: truncated ? '1' : '0',
|
||||
ECC_HOOK_INPUT_MAX_BYTES: String(MAX_STDIN)
|
||||
},
|
||||
cwd: process.cwd(),
|
||||
timeout: 30000
|
||||
});
|
||||
|
||||
if (result.stdout) process.stdout.write(result.stdout);
|
||||
writeLegacySpawnOutput(raw, result);
|
||||
if (result.stderr) process.stderr.write(result.stderr);
|
||||
|
||||
const code = Number.isInteger(result.status) ? result.status : 0;
|
||||
process.exit(code);
|
||||
if (result.error || result.signal || result.status === null) {
|
||||
const failureDetail = result.error
|
||||
? result.error.message
|
||||
: result.signal
|
||||
? `terminated by signal ${result.signal}`
|
||||
: 'missing exit status';
|
||||
writeStderr(`[Hook] legacy hook execution failed for ${hookId}: ${failureDetail}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.exit(Number.isInteger(result.status) ? result.status : 0);
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
|
||||
@@ -11,28 +11,59 @@
|
||||
|
||||
const {
|
||||
getSessionsDir,
|
||||
getSessionSearchDirs,
|
||||
getLearnedSkillsDir,
|
||||
findFiles,
|
||||
ensureDir,
|
||||
readFile,
|
||||
stripAnsi,
|
||||
log,
|
||||
output
|
||||
log
|
||||
} = require('../lib/utils');
|
||||
const { getPackageManager, getSelectionPrompt } = require('../lib/package-manager');
|
||||
const { listAliases } = require('../lib/session-aliases');
|
||||
const { detectProjectType } = require('../lib/project-detect');
|
||||
const path = require('path');
|
||||
|
||||
function dedupeRecentSessions(searchDirs) {
|
||||
const recentSessionsByName = new Map();
|
||||
|
||||
for (const [dirIndex, dir] of searchDirs.entries()) {
|
||||
const matches = findFiles(dir, '*-session.tmp', { maxAge: 7 });
|
||||
|
||||
for (const match of matches) {
|
||||
const basename = path.basename(match.path);
|
||||
const current = {
|
||||
...match,
|
||||
basename,
|
||||
dirIndex,
|
||||
};
|
||||
const existing = recentSessionsByName.get(basename);
|
||||
|
||||
if (
|
||||
!existing
|
||||
|| current.mtime > existing.mtime
|
||||
|| (current.mtime === existing.mtime && current.dirIndex < existing.dirIndex)
|
||||
) {
|
||||
recentSessionsByName.set(basename, current);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(recentSessionsByName.values())
|
||||
.sort((left, right) => right.mtime - left.mtime || left.dirIndex - right.dirIndex);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const sessionsDir = getSessionsDir();
|
||||
const learnedDir = getLearnedSkillsDir();
|
||||
const additionalContextParts = [];
|
||||
|
||||
// Ensure directories exist
|
||||
ensureDir(sessionsDir);
|
||||
ensureDir(learnedDir);
|
||||
|
||||
// Check for recent session files (last 7 days)
|
||||
const recentSessions = findFiles(sessionsDir, '*-session.tmp', { maxAge: 7 });
|
||||
const recentSessions = dedupeRecentSessions(getSessionSearchDirs());
|
||||
|
||||
if (recentSessions.length > 0) {
|
||||
const latest = recentSessions[0];
|
||||
@@ -43,7 +74,7 @@ async function main() {
|
||||
const content = stripAnsi(readFile(latest.path));
|
||||
if (content && !content.includes('[Session context goes here]')) {
|
||||
// Only inject if the session has actual content (not the blank template)
|
||||
output(`Previous session summary:\n${content}`);
|
||||
additionalContextParts.push(`Previous session summary:\n${content}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,15 +115,49 @@ async function main() {
|
||||
parts.push(`frameworks: ${projectInfo.frameworks.join(', ')}`);
|
||||
}
|
||||
log(`[SessionStart] Project detected — ${parts.join('; ')}`);
|
||||
output(`Project type: ${JSON.stringify(projectInfo)}`);
|
||||
additionalContextParts.push(`Project type: ${JSON.stringify(projectInfo)}`);
|
||||
} else {
|
||||
log('[SessionStart] No specific project type detected');
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
await writeSessionStartPayload(additionalContextParts.join('\n\n'));
|
||||
}
|
||||
|
||||
function writeSessionStartPayload(additionalContext) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const payload = JSON.stringify({
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'SessionStart',
|
||||
additionalContext
|
||||
}
|
||||
});
|
||||
|
||||
const handleError = (err) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (err) {
|
||||
log(`[SessionStart] stdout write error: ${err.message}`);
|
||||
}
|
||||
reject(err || new Error('stdout stream error'));
|
||||
};
|
||||
|
||||
process.stdout.once('error', handleError);
|
||||
process.stdout.write(payload, (err) => {
|
||||
process.stdout.removeListener('error', handleError);
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (err) {
|
||||
log(`[SessionStart] stdout write error: ${err.message}`);
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('[SessionStart] Error:', err.message);
|
||||
process.exit(0); // Don't block on errors
|
||||
process.exitCode = 0; // Don't block on errors
|
||||
});
|
||||
|
||||
@@ -10,8 +10,9 @@ const os = require('os');
|
||||
* Tries, in order:
|
||||
* 1. CLAUDE_PLUGIN_ROOT env var (set by Claude Code for hooks, or by user)
|
||||
* 2. Standard install location (~/.claude/) — when scripts exist there
|
||||
* 3. Plugin cache auto-detection — scans ~/.claude/plugins/cache/everything-claude-code/
|
||||
* 4. Fallback to ~/.claude/ (original behaviour)
|
||||
* 3. Exact legacy plugin roots under ~/.claude/plugins/
|
||||
* 4. Plugin cache auto-detection — scans ~/.claude/plugins/cache/everything-claude-code/
|
||||
* 5. Fallback to ~/.claude/ (original behaviour)
|
||||
*
|
||||
* @param {object} [options]
|
||||
* @param {string} [options.homeDir] Override home directory (for testing)
|
||||
@@ -38,6 +39,20 @@ function resolveEccRoot(options = {}) {
|
||||
return claudeDir;
|
||||
}
|
||||
|
||||
// Exact legacy plugin install locations. These preserve backwards
|
||||
// compatibility without scanning arbitrary plugin trees.
|
||||
const legacyPluginRoots = [
|
||||
path.join(claudeDir, 'plugins', 'everything-claude-code'),
|
||||
path.join(claudeDir, 'plugins', 'everything-claude-code@everything-claude-code'),
|
||||
path.join(claudeDir, 'plugins', 'marketplace', 'everything-claude-code')
|
||||
];
|
||||
|
||||
for (const candidate of legacyPluginRoots) {
|
||||
if (fs.existsSync(path.join(candidate, probe))) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
// Plugin cache — Claude Code stores marketplace plugins under
|
||||
// ~/.claude/plugins/cache/<plugin-name>/<org>/<version>/
|
||||
try {
|
||||
@@ -81,7 +96,7 @@ function resolveEccRoot(options = {}) {
|
||||
* const _r = <paste INLINE_RESOLVE>;
|
||||
* const sm = require(_r + '/scripts/lib/session-manager');
|
||||
*/
|
||||
const INLINE_RESOLVE = `(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;try{var b=p.join(d,'plugins','cache','everything-claude-code');for(var o of f.readdirSync(b))for(var v of f.readdirSync(p.join(b,o))){var c=p.join(b,o,v);if(f.existsSync(p.join(c,q)))return c}}catch(x){}return d})()`;
|
||||
const INLINE_RESOLVE = `(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var l of [p.join(d,'plugins','everything-claude-code'),p.join(d,'plugins','everything-claude-code@everything-claude-code'),p.join(d,'plugins','marketplace','everything-claude-code')])if(f.existsSync(p.join(l,q)))return l;try{var b=p.join(d,'plugins','cache','everything-claude-code');for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}catch(x){}return d})()`;
|
||||
|
||||
module.exports = {
|
||||
resolveEccRoot,
|
||||
|
||||
3
scripts/lib/session-manager.d.ts
vendored
3
scripts/lib/session-manager.d.ts
vendored
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Session Manager Library for Claude Code.
|
||||
* Provides CRUD operations for session files stored as markdown in ~/.claude/sessions/.
|
||||
* Provides CRUD operations for session files stored as markdown in
|
||||
* ~/.claude/session-data/ with legacy read compatibility for ~/.claude/sessions/.
|
||||
*/
|
||||
|
||||
/** Parsed metadata from a session filename */
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* Session Manager Library for Claude Code
|
||||
* Provides core session CRUD operations for listing, loading, and managing sessions
|
||||
*
|
||||
* Sessions are stored as markdown files in ~/.claude/sessions/ with format:
|
||||
* Sessions are stored as markdown files in ~/.claude/session-data/ with
|
||||
* legacy read compatibility for ~/.claude/sessions/:
|
||||
* - YYYY-MM-DD-session.tmp (old format)
|
||||
* - YYYY-MM-DD-<short-id>-session.tmp (new format)
|
||||
*/
|
||||
@@ -12,6 +13,7 @@ const path = require('path');
|
||||
|
||||
const {
|
||||
getSessionsDir,
|
||||
getSessionSearchDirs,
|
||||
readFile,
|
||||
log
|
||||
} = require('./utils');
|
||||
@@ -30,6 +32,7 @@ const SESSION_FILENAME_REGEX = /^(\d{4}-\d{2}-\d{2})(?:-([a-zA-Z0-9_][a-zA-Z0-9_
|
||||
* @returns {object|null} Parsed metadata or null if invalid
|
||||
*/
|
||||
function parseSessionFilename(filename) {
|
||||
if (!filename || typeof filename !== 'string') return null;
|
||||
const match = filename.match(SESSION_FILENAME_REGEX);
|
||||
if (!match) return null;
|
||||
|
||||
@@ -66,6 +69,145 @@ function getSessionPath(filename) {
|
||||
return path.join(getSessionsDir(), filename);
|
||||
}
|
||||
|
||||
function getSessionCandidates(options = {}) {
|
||||
const {
|
||||
date = null,
|
||||
search = null
|
||||
} = options;
|
||||
|
||||
const candidates = [];
|
||||
|
||||
for (const sessionsDir of getSessionSearchDirs()) {
|
||||
if (!fs.existsSync(sessionsDir)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(sessionsDir, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
log(`[SessionManager] Error reading sessions directory ${sessionsDir}: ${error.message}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !entry.name.endsWith('.tmp')) continue;
|
||||
|
||||
const filename = entry.name;
|
||||
const metadata = parseSessionFilename(filename);
|
||||
|
||||
if (!metadata) continue;
|
||||
if (date && metadata.date !== date) continue;
|
||||
if (search && !metadata.shortId.includes(search)) continue;
|
||||
|
||||
const sessionPath = path.join(sessionsDir, filename);
|
||||
|
||||
let stats;
|
||||
try {
|
||||
stats = fs.statSync(sessionPath);
|
||||
} catch (error) {
|
||||
log(`[SessionManager] Error stating session ${sessionPath}: ${error.message}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
candidates.push({
|
||||
...metadata,
|
||||
sessionPath,
|
||||
hasContent: stats.size > 0,
|
||||
size: stats.size,
|
||||
modifiedTime: stats.mtime,
|
||||
createdTime: stats.birthtime || stats.ctime
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const deduped = [];
|
||||
const seenFilenames = new Set();
|
||||
|
||||
for (const session of candidates) {
|
||||
if (seenFilenames.has(session.filename)) {
|
||||
continue;
|
||||
}
|
||||
seenFilenames.add(session.filename);
|
||||
deduped.push(session);
|
||||
}
|
||||
|
||||
deduped.sort((a, b) => b.modifiedTime - a.modifiedTime);
|
||||
return deduped;
|
||||
}
|
||||
|
||||
function buildSessionRecord(sessionPath, metadata) {
|
||||
let stats;
|
||||
try {
|
||||
stats = fs.statSync(sessionPath);
|
||||
} catch (error) {
|
||||
log(`[SessionManager] Error stating session ${sessionPath}: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...metadata,
|
||||
sessionPath,
|
||||
hasContent: stats.size > 0,
|
||||
size: stats.size,
|
||||
modifiedTime: stats.mtime,
|
||||
createdTime: stats.birthtime || stats.ctime
|
||||
};
|
||||
}
|
||||
|
||||
function sessionMatchesId(metadata, normalizedSessionId) {
|
||||
const filename = metadata.filename;
|
||||
const shortIdMatch = metadata.shortId !== 'no-id' && metadata.shortId.startsWith(normalizedSessionId);
|
||||
const filenameMatch = filename === normalizedSessionId || filename === `${normalizedSessionId}.tmp`;
|
||||
const noIdMatch = metadata.shortId === 'no-id' && filename === `${normalizedSessionId}-session.tmp`;
|
||||
|
||||
return shortIdMatch || filenameMatch || noIdMatch;
|
||||
}
|
||||
|
||||
function getMatchingSessionCandidates(normalizedSessionId) {
|
||||
const matches = [];
|
||||
const seenFilenames = new Set();
|
||||
|
||||
for (const sessionsDir of getSessionSearchDirs()) {
|
||||
if (!fs.existsSync(sessionsDir)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(sessionsDir, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
log(`[SessionManager] Error reading sessions directory ${sessionsDir}: ${error.message}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !entry.name.endsWith('.tmp')) continue;
|
||||
|
||||
const metadata = parseSessionFilename(entry.name);
|
||||
if (!metadata || !sessionMatchesId(metadata, normalizedSessionId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (seenFilenames.has(metadata.filename)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sessionPath = path.join(sessionsDir, metadata.filename);
|
||||
const sessionRecord = buildSessionRecord(sessionPath, metadata);
|
||||
if (!sessionRecord) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenFilenames.add(metadata.filename);
|
||||
matches.push(sessionRecord);
|
||||
}
|
||||
}
|
||||
|
||||
matches.sort((a, b) => b.modifiedTime - a.modifiedTime);
|
||||
return matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and parse session markdown content
|
||||
* @param {string} sessionPath - Full path to session file
|
||||
@@ -228,58 +370,12 @@ function getAllSessions(options = {}) {
|
||||
const limitNum = Number(rawLimit);
|
||||
const limit = Number.isNaN(limitNum) ? 50 : Math.max(1, Math.floor(limitNum));
|
||||
|
||||
const sessionsDir = getSessionsDir();
|
||||
const sessions = getSessionCandidates({ date, search });
|
||||
|
||||
if (!fs.existsSync(sessionsDir)) {
|
||||
if (sessions.length === 0) {
|
||||
return { sessions: [], total: 0, offset, limit, hasMore: false };
|
||||
}
|
||||
|
||||
const entries = fs.readdirSync(sessionsDir, { withFileTypes: true });
|
||||
const sessions = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
// Skip non-files (only process .tmp files)
|
||||
if (!entry.isFile() || !entry.name.endsWith('.tmp')) continue;
|
||||
|
||||
const filename = entry.name;
|
||||
const metadata = parseSessionFilename(filename);
|
||||
|
||||
if (!metadata) continue;
|
||||
|
||||
// Apply date filter
|
||||
if (date && metadata.date !== date) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Apply search filter (search in short ID)
|
||||
if (search && !metadata.shortId.includes(search)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sessionPath = path.join(sessionsDir, filename);
|
||||
|
||||
// Get file stats (wrapped in try-catch to handle TOCTOU race where
|
||||
// file is deleted between readdirSync and statSync)
|
||||
let stats;
|
||||
try {
|
||||
stats = fs.statSync(sessionPath);
|
||||
} catch {
|
||||
continue; // File was deleted between readdir and stat
|
||||
}
|
||||
|
||||
sessions.push({
|
||||
...metadata,
|
||||
sessionPath,
|
||||
hasContent: stats.size > 0,
|
||||
size: stats.size,
|
||||
modifiedTime: stats.mtime,
|
||||
createdTime: stats.birthtime || stats.ctime
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by modified time (newest first)
|
||||
sessions.sort((a, b) => b.modifiedTime - a.modifiedTime);
|
||||
|
||||
// Apply pagination
|
||||
const paginatedSessions = sessions.slice(offset, offset + limit);
|
||||
|
||||
@@ -299,55 +395,28 @@ function getAllSessions(options = {}) {
|
||||
* @returns {object|null} Session object or null if not found
|
||||
*/
|
||||
function getSessionById(sessionId, includeContent = false) {
|
||||
const sessionsDir = getSessionsDir();
|
||||
|
||||
if (!fs.existsSync(sessionsDir)) {
|
||||
if (typeof sessionId !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entries = fs.readdirSync(sessionsDir, { withFileTypes: true });
|
||||
const normalizedSessionId = sessionId.trim();
|
||||
if (!normalizedSessionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !entry.name.endsWith('.tmp')) continue;
|
||||
const sessions = getMatchingSessionCandidates(normalizedSessionId);
|
||||
|
||||
const filename = entry.name;
|
||||
const metadata = parseSessionFilename(filename);
|
||||
|
||||
if (!metadata) continue;
|
||||
|
||||
// Check if session ID matches (short ID or full filename without .tmp)
|
||||
const shortIdMatch = sessionId.length > 0 && metadata.shortId !== 'no-id' && metadata.shortId.startsWith(sessionId);
|
||||
const filenameMatch = filename === sessionId || filename === `${sessionId}.tmp`;
|
||||
const noIdMatch = metadata.shortId === 'no-id' && filename === `${sessionId}-session.tmp`;
|
||||
|
||||
if (!shortIdMatch && !filenameMatch && !noIdMatch) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sessionPath = path.join(sessionsDir, filename);
|
||||
let stats;
|
||||
try {
|
||||
stats = fs.statSync(sessionPath);
|
||||
} catch {
|
||||
return null; // File was deleted between readdir and stat
|
||||
}
|
||||
|
||||
const session = {
|
||||
...metadata,
|
||||
sessionPath,
|
||||
size: stats.size,
|
||||
modifiedTime: stats.mtime,
|
||||
createdTime: stats.birthtime || stats.ctime
|
||||
};
|
||||
for (const session of sessions) {
|
||||
const sessionRecord = { ...session };
|
||||
|
||||
if (includeContent) {
|
||||
session.content = getSessionContent(sessionPath);
|
||||
session.metadata = parseSessionMetadata(session.content);
|
||||
sessionRecord.content = getSessionContent(sessionRecord.sessionPath);
|
||||
sessionRecord.metadata = parseSessionMetadata(sessionRecord.content);
|
||||
// Pass pre-read content to avoid a redundant disk read
|
||||
session.stats = getSessionStats(session.content || '');
|
||||
sessionRecord.stats = getSessionStats(sessionRecord.content || '');
|
||||
}
|
||||
|
||||
return session;
|
||||
return sessionRecord;
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
17
scripts/lib/utils.d.ts
vendored
17
scripts/lib/utils.d.ts
vendored
@@ -18,9 +18,15 @@ export function getHomeDir(): string;
|
||||
/** Get the Claude config directory (~/.claude) */
|
||||
export function getClaudeDir(): string;
|
||||
|
||||
/** Get the sessions directory (~/.claude/sessions) */
|
||||
/** Get the canonical ECC sessions directory (~/.claude/session-data) */
|
||||
export function getSessionsDir(): string;
|
||||
|
||||
/** Get the legacy Claude-managed sessions directory (~/.claude/sessions) */
|
||||
export function getLegacySessionsDir(): string;
|
||||
|
||||
/** Get session directories to search, with canonical storage first and legacy fallback second */
|
||||
export function getSessionSearchDirs(): string[];
|
||||
|
||||
/** Get the learned skills directory (~/.claude/skills/learned) */
|
||||
export function getLearnedSkillsDir(): string;
|
||||
|
||||
@@ -47,9 +53,16 @@ export function getDateTimeString(): string;
|
||||
|
||||
// --- Session/Project ---
|
||||
|
||||
/**
|
||||
* Sanitize a string for use as a session filename segment.
|
||||
* Replaces invalid characters, strips leading dots, and returns null when
|
||||
* nothing meaningful remains. Non-ASCII names are hashed for stability.
|
||||
*/
|
||||
export function sanitizeSessionId(raw: string | null | undefined): string | null;
|
||||
|
||||
/**
|
||||
* Get short session ID from CLAUDE_SESSION_ID environment variable.
|
||||
* Returns last 8 characters, falls back to project name then the provided fallback.
|
||||
* Returns last 8 characters, falls back to a sanitized project name then the provided fallback.
|
||||
*/
|
||||
export function getSessionIdShort(fallback?: string): string;
|
||||
|
||||
|
||||
@@ -6,12 +6,20 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const crypto = require('crypto');
|
||||
const { execSync, spawnSync } = require('child_process');
|
||||
|
||||
// Platform detection
|
||||
const isWindows = process.platform === 'win32';
|
||||
const isMacOS = process.platform === 'darwin';
|
||||
const isLinux = process.platform === 'linux';
|
||||
const SESSION_DATA_DIR_NAME = 'session-data';
|
||||
const LEGACY_SESSIONS_DIR_NAME = 'sessions';
|
||||
const WINDOWS_RESERVED_SESSION_IDS = new Set([
|
||||
'CON', 'PRN', 'AUX', 'NUL',
|
||||
'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9',
|
||||
'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9'
|
||||
]);
|
||||
|
||||
/**
|
||||
* Get the user's home directory (cross-platform)
|
||||
@@ -31,7 +39,21 @@ function getClaudeDir() {
|
||||
* Get the sessions directory
|
||||
*/
|
||||
function getSessionsDir() {
|
||||
return path.join(getClaudeDir(), 'sessions');
|
||||
return path.join(getClaudeDir(), SESSION_DATA_DIR_NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the legacy sessions directory used by older ECC installs
|
||||
*/
|
||||
function getLegacySessionsDir() {
|
||||
return path.join(getClaudeDir(), LEGACY_SESSIONS_DIR_NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all session directories to search, in canonical-first order
|
||||
*/
|
||||
function getSessionSearchDirs() {
|
||||
return Array.from(new Set([getSessionsDir(), getLegacySessionsDir()]));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -107,16 +129,52 @@ function getProjectName() {
|
||||
return path.basename(process.cwd()) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a string for use as a session filename segment.
|
||||
* Replaces invalid characters with hyphens, collapses runs, strips
|
||||
* leading/trailing hyphens, and removes leading dots so hidden-dir names
|
||||
* like ".claude" map cleanly to "claude".
|
||||
*
|
||||
* Pure non-ASCII inputs get a stable 8-char hash so distinct names do not
|
||||
* collapse to the same fallback session id. Mixed-script inputs retain their
|
||||
* ASCII part and gain a short hash suffix for disambiguation.
|
||||
*/
|
||||
function sanitizeSessionId(raw) {
|
||||
if (!raw || typeof raw !== 'string') return null;
|
||||
|
||||
const hasNonAscii = Array.from(raw).some(char => char.codePointAt(0) > 0x7f);
|
||||
const normalized = raw.replace(/^\.+/, '');
|
||||
const sanitized = normalized
|
||||
.replace(/[^a-zA-Z0-9_-]/g, '-')
|
||||
.replace(/-{2,}/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
|
||||
if (sanitized.length > 0) {
|
||||
const suffix = crypto.createHash('sha256').update(normalized).digest('hex').slice(0, 6);
|
||||
if (WINDOWS_RESERVED_SESSION_IDS.has(sanitized.toUpperCase())) {
|
||||
return `${sanitized}-${suffix}`;
|
||||
}
|
||||
if (!hasNonAscii) return sanitized;
|
||||
return `${sanitized}-${suffix}`;
|
||||
}
|
||||
|
||||
const meaningful = normalized.replace(/[\s\p{P}]/gu, '');
|
||||
if (meaningful.length === 0) return null;
|
||||
|
||||
return crypto.createHash('sha256').update(normalized).digest('hex').slice(0, 8);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get short session ID from CLAUDE_SESSION_ID environment variable
|
||||
* Returns last 8 characters, falls back to project name then 'default'
|
||||
* Returns last 8 characters, falls back to a sanitized project name then 'default'.
|
||||
*/
|
||||
function getSessionIdShort(fallback = 'default') {
|
||||
const sessionId = process.env.CLAUDE_SESSION_ID;
|
||||
if (sessionId && sessionId.length > 0) {
|
||||
return sessionId.slice(-8);
|
||||
const sanitized = sanitizeSessionId(sessionId.slice(-8));
|
||||
if (sanitized) return sanitized;
|
||||
}
|
||||
return getProjectName() || fallback;
|
||||
return sanitizeSessionId(getProjectName()) || sanitizeSessionId(fallback) || 'default';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -525,6 +583,8 @@ module.exports = {
|
||||
getHomeDir,
|
||||
getClaudeDir,
|
||||
getSessionsDir,
|
||||
getLegacySessionsDir,
|
||||
getSessionSearchDirs,
|
||||
getLearnedSkillsDir,
|
||||
getTempDir,
|
||||
ensureDir,
|
||||
@@ -535,6 +595,7 @@ module.exports = {
|
||||
getDateTimeString,
|
||||
|
||||
// Session/Project
|
||||
sanitizeSessionId,
|
||||
getSessionIdShort,
|
||||
getGitRepoName,
|
||||
getProjectName,
|
||||
|
||||
@@ -43,9 +43,11 @@ log() { printf '[ecc-sync] %s\n' "$*"; }
|
||||
|
||||
run_or_echo() {
|
||||
if [[ "$MODE" == "dry-run" ]]; then
|
||||
printf '[dry-run] %s\n' "$*"
|
||||
printf '[dry-run]'
|
||||
printf ' %q' "$@"
|
||||
printf '\n'
|
||||
else
|
||||
eval "$@"
|
||||
"$@"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -149,10 +151,10 @@ log "Repo root: $REPO_ROOT"
|
||||
log "Codex home: $CODEX_HOME"
|
||||
|
||||
log "Creating backup folder: $BACKUP_DIR"
|
||||
run_or_echo "mkdir -p \"$BACKUP_DIR\""
|
||||
run_or_echo "cp \"$CONFIG_FILE\" \"$BACKUP_DIR/config.toml\""
|
||||
run_or_echo mkdir -p "$BACKUP_DIR"
|
||||
run_or_echo cp "$CONFIG_FILE" "$BACKUP_DIR/config.toml"
|
||||
if [[ -f "$AGENTS_FILE" ]]; then
|
||||
run_or_echo "cp \"$AGENTS_FILE\" \"$BACKUP_DIR/AGENTS.md\""
|
||||
run_or_echo cp "$AGENTS_FILE" "$BACKUP_DIR/AGENTS.md"
|
||||
fi
|
||||
|
||||
ECC_BEGIN_MARKER="<!-- BEGIN ECC -->"
|
||||
@@ -234,19 +236,19 @@ else
|
||||
fi
|
||||
|
||||
log "Syncing ECC Codex skills"
|
||||
run_or_echo "mkdir -p \"$SKILLS_DEST\""
|
||||
run_or_echo mkdir -p "$SKILLS_DEST"
|
||||
skills_count=0
|
||||
for skill_dir in "$SKILLS_SRC"/*; do
|
||||
[[ -d "$skill_dir" ]] || continue
|
||||
skill_name="$(basename "$skill_dir")"
|
||||
dest="$SKILLS_DEST/$skill_name"
|
||||
run_or_echo "rm -rf \"$dest\""
|
||||
run_or_echo "cp -R \"$skill_dir\" \"$dest\""
|
||||
run_or_echo rm -rf "$dest"
|
||||
run_or_echo cp -R "$skill_dir" "$dest"
|
||||
skills_count=$((skills_count + 1))
|
||||
done
|
||||
|
||||
log "Generating prompt files from ECC commands"
|
||||
run_or_echo "mkdir -p \"$PROMPTS_DEST\""
|
||||
run_or_echo mkdir -p "$PROMPTS_DEST"
|
||||
manifest="$PROMPTS_DEST/ecc-prompts-manifest.txt"
|
||||
if [[ "$MODE" == "dry-run" ]]; then
|
||||
printf '[dry-run] > %s\n' "$manifest"
|
||||
|
||||
@@ -55,15 +55,18 @@ analyze_observations() {
|
||||
# Sample recent observations instead of loading the entire file (#521).
|
||||
# This prevents multi-MB payloads from being passed to the LLM.
|
||||
MAX_ANALYSIS_LINES="${ECC_OBSERVER_MAX_ANALYSIS_LINES:-500}"
|
||||
analysis_file="$(mktemp "${TMPDIR:-/tmp}/ecc-observer-analysis.XXXXXX.jsonl")"
|
||||
observer_tmp_dir="${PROJECT_DIR}/.observer-tmp"
|
||||
mkdir -p "$observer_tmp_dir"
|
||||
analysis_file="$(mktemp "${observer_tmp_dir}/ecc-observer-analysis.XXXXXX.jsonl")"
|
||||
tail -n "$MAX_ANALYSIS_LINES" "$OBSERVATIONS_FILE" > "$analysis_file"
|
||||
analysis_count=$(wc -l < "$analysis_file" 2>/dev/null || echo 0)
|
||||
echo "[$(date)] Using last $analysis_count of $obs_count observations for analysis" >> "$LOG_FILE"
|
||||
|
||||
prompt_file="$(mktemp "${TMPDIR:-/tmp}/ecc-observer-prompt.XXXXXX")"
|
||||
prompt_file="$(mktemp "${observer_tmp_dir}/ecc-observer-prompt.XXXXXX")"
|
||||
cat > "$prompt_file" <<PROMPT
|
||||
Read ${analysis_file} and identify patterns for the project ${PROJECT_NAME} (user corrections, error resolutions, repeated workflows, tool preferences).
|
||||
If you find 3+ occurrences of the same pattern, create an instinct file in ${INSTINCTS_DIR}/<id>.md.
|
||||
If you find 3+ occurrences of the same pattern, you MUST write an instinct file directly to ${INSTINCTS_DIR}/<id>.md using the Write tool.
|
||||
Do NOT ask for permission to write files, do NOT describe what you would write, and do NOT stop at analysis when a qualifying pattern exists.
|
||||
|
||||
CRITICAL: Every instinct file MUST use this exact format:
|
||||
|
||||
@@ -92,6 +95,7 @@ Rules:
|
||||
- Be conservative, only clear patterns with 3+ observations
|
||||
- Use narrow, specific triggers
|
||||
- Never include actual code snippets, only describe patterns
|
||||
- When a qualifying pattern exists, write or update the instinct file in this run instead of asking for confirmation
|
||||
- If a similar instinct already exists in ${INSTINCTS_DIR}/, update it instead of creating a duplicate
|
||||
- The YAML frontmatter (between --- markers) with id field is MANDATORY
|
||||
- If a pattern seems universal (not project-specific), set scope to global instead of project
|
||||
|
||||
157
tests/hooks/config-protection.test.js
Normal file
157
tests/hooks/config-protection.test.js
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Tests for scripts/hooks/config-protection.js via run-with-flags.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const runner = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'run-with-flags.js');
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` ✗ ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runHook(input, env = {}) {
|
||||
const rawInput = typeof input === 'string' ? input : JSON.stringify(input);
|
||||
const result = spawnSync('node', [runner, 'pre:config-protection', 'scripts/hooks/config-protection.js', 'standard,strict'], {
|
||||
input: rawInput,
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
ECC_HOOK_PROFILE: 'standard',
|
||||
...env
|
||||
},
|
||||
timeout: 15000,
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
return {
|
||||
code: Number.isInteger(result.status) ? result.status : 1,
|
||||
stdout: result.stdout || '',
|
||||
stderr: result.stderr || ''
|
||||
};
|
||||
}
|
||||
|
||||
function runCustomHook(pluginRoot, hookId, relScriptPath, input, env = {}) {
|
||||
const rawInput = typeof input === 'string' ? input : JSON.stringify(input);
|
||||
const result = spawnSync('node', [runner, hookId, relScriptPath, 'standard,strict'], {
|
||||
input: rawInput,
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
CLAUDE_PLUGIN_ROOT: pluginRoot,
|
||||
ECC_HOOK_PROFILE: 'standard',
|
||||
...env
|
||||
},
|
||||
timeout: 15000,
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
return {
|
||||
code: Number.isInteger(result.status) ? result.status : 1,
|
||||
stdout: result.stdout || '',
|
||||
stderr: result.stderr || ''
|
||||
};
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing config-protection ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (test('blocks protected config file edits through run-with-flags', () => {
|
||||
const input = {
|
||||
tool_name: 'Write',
|
||||
tool_input: {
|
||||
file_path: '.eslintrc.js',
|
||||
content: 'module.exports = {};'
|
||||
}
|
||||
};
|
||||
|
||||
const result = runHook(input);
|
||||
assert.strictEqual(result.code, 2, 'Expected protected config edit to be blocked');
|
||||
assert.strictEqual(result.stdout, '', 'Blocked hook should not echo raw input');
|
||||
assert.ok(result.stderr.includes('BLOCKED: Modifying .eslintrc.js is not allowed.'), `Expected block message, got: ${result.stderr}`);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('passes through safe file edits unchanged', () => {
|
||||
const input = {
|
||||
tool_name: 'Write',
|
||||
tool_input: {
|
||||
file_path: 'src/index.js',
|
||||
content: 'console.log("ok");'
|
||||
}
|
||||
};
|
||||
|
||||
const rawInput = JSON.stringify(input);
|
||||
const result = runHook(input);
|
||||
assert.strictEqual(result.code, 0, 'Expected safe file edit to pass');
|
||||
assert.strictEqual(result.stdout, rawInput, 'Expected exact raw JSON passthrough');
|
||||
assert.strictEqual(result.stderr, '', 'Expected no stderr for safe edits');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('blocks truncated protected config payloads instead of failing open', () => {
|
||||
const rawInput = JSON.stringify({
|
||||
tool_name: 'Write',
|
||||
tool_input: {
|
||||
file_path: '.eslintrc.js',
|
||||
content: 'x'.repeat(1024 * 1024 + 2048)
|
||||
}
|
||||
});
|
||||
|
||||
const result = runHook(rawInput);
|
||||
assert.strictEqual(result.code, 2, 'Expected truncated protected payload to be blocked');
|
||||
assert.strictEqual(result.stdout, '', 'Blocked truncated payload should not echo raw input');
|
||||
assert.ok(result.stderr.includes('Hook input exceeded 1048576 bytes'), `Expected size warning, got: ${result.stderr}`);
|
||||
assert.ok(result.stderr.includes('truncated payload'), `Expected truncated payload warning, got: ${result.stderr}`);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('legacy hooks do not echo raw input when they fail without stdout', () => {
|
||||
const pluginRoot = path.join(__dirname, '..', `tmp-runner-plugin-${Date.now()}`);
|
||||
const scriptDir = path.join(pluginRoot, 'scripts', 'hooks');
|
||||
const scriptPath = path.join(scriptDir, 'legacy-block.js');
|
||||
|
||||
try {
|
||||
fs.mkdirSync(scriptDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
scriptPath,
|
||||
'#!/usr/bin/env node\nprocess.stderr.write("blocked by legacy hook\\n");\nprocess.exit(2);\n'
|
||||
);
|
||||
|
||||
const rawInput = JSON.stringify({
|
||||
tool_name: 'Write',
|
||||
tool_input: {
|
||||
file_path: '.eslintrc.js',
|
||||
content: 'module.exports = {};'
|
||||
}
|
||||
});
|
||||
|
||||
const result = runCustomHook(pluginRoot, 'pre:legacy-block', 'scripts/hooks/legacy-block.js', rawInput);
|
||||
assert.strictEqual(result.code, 2, 'Expected failing legacy hook exit code to propagate');
|
||||
assert.strictEqual(result.stdout, '', 'Expected failing legacy hook to avoid raw passthrough');
|
||||
assert.ok(result.stderr.includes('blocked by legacy hook'), `Expected legacy hook stderr, got: ${result.stderr}`);
|
||||
} finally {
|
||||
try {
|
||||
fs.rmSync(pluginRoot, { recursive: true, force: true });
|
||||
} catch {
|
||||
// best-effort cleanup
|
||||
}
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -156,6 +156,35 @@ async function runTests() {
|
||||
assert.strictEqual(approvalEvent.payload.severity, 'high');
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (await test('approval events fingerprint commands instead of storing raw command text', async () => {
|
||||
const command = 'git push origin main --force';
|
||||
const events = analyzeForGovernanceEvents({
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command },
|
||||
});
|
||||
|
||||
const approvalEvent = events.find(e => e.eventType === 'approval_requested');
|
||||
assert.ok(approvalEvent);
|
||||
assert.strictEqual(approvalEvent.payload.commandName, 'git');
|
||||
assert.ok(/^[a-f0-9]{12}$/.test(approvalEvent.payload.commandFingerprint), 'Expected short command fingerprint');
|
||||
assert.ok(!Object.prototype.hasOwnProperty.call(approvalEvent.payload, 'command'), 'Should not store raw command text');
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (await test('security findings fingerprint elevated commands instead of storing raw command text', async () => {
|
||||
const command = 'sudo chmod 600 ~/.ssh/id_rsa';
|
||||
const events = analyzeForGovernanceEvents({
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command },
|
||||
}, {
|
||||
hookPhase: 'post',
|
||||
});
|
||||
|
||||
const securityEvent = events.find(e => e.eventType === 'security_finding');
|
||||
assert.ok(securityEvent);
|
||||
assert.strictEqual(securityEvent.payload.commandName, 'sudo');
|
||||
assert.ok(/^[a-f0-9]{12}$/.test(securityEvent.payload.commandFingerprint), 'Expected short command fingerprint');
|
||||
assert.ok(!Object.prototype.hasOwnProperty.call(securityEvent.payload, 'command'), 'Should not store raw command text');
|
||||
})) passed += 1; else failed += 1;
|
||||
if (await test('analyzeForGovernanceEvents detects sensitive file access', async () => {
|
||||
const events = analyzeForGovernanceEvents({
|
||||
tool_name: 'Edit',
|
||||
@@ -273,6 +302,43 @@ async function runTests() {
|
||||
}
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (await test('run() emits hook_input_truncated event without logging raw command text', async () => {
|
||||
const original = process.env.ECC_GOVERNANCE_CAPTURE;
|
||||
const originalHookEvent = process.env.CLAUDE_HOOK_EVENT_NAME;
|
||||
const originalWrite = process.stderr.write;
|
||||
const stderr = [];
|
||||
process.env.ECC_GOVERNANCE_CAPTURE = '1';
|
||||
process.env.CLAUDE_HOOK_EVENT_NAME = 'PreToolUse';
|
||||
process.stderr.write = (chunk, encoding, callback) => {
|
||||
stderr.push(String(chunk));
|
||||
if (typeof encoding === 'function') encoding();
|
||||
if (typeof callback === 'function') callback();
|
||||
return true;
|
||||
};
|
||||
|
||||
try {
|
||||
const input = JSON.stringify({ tool_name: 'Bash', tool_input: { command: 'rm -rf /tmp/important' } });
|
||||
const result = run(input, { truncated: true, maxStdin: 1024 });
|
||||
assert.strictEqual(result, input);
|
||||
} finally {
|
||||
process.stderr.write = originalWrite;
|
||||
if (original !== undefined) {
|
||||
process.env.ECC_GOVERNANCE_CAPTURE = original;
|
||||
} else {
|
||||
delete process.env.ECC_GOVERNANCE_CAPTURE;
|
||||
}
|
||||
if (originalHookEvent !== undefined) {
|
||||
process.env.CLAUDE_HOOK_EVENT_NAME = originalHookEvent;
|
||||
} else {
|
||||
delete process.env.CLAUDE_HOOK_EVENT_NAME;
|
||||
}
|
||||
}
|
||||
|
||||
const combined = stderr.join('');
|
||||
assert.ok(combined.includes('"eventType":"hook_input_truncated"'), 'Should emit truncation event');
|
||||
assert.ok(combined.includes('"sizeLimitBytes":1024'), 'Should record the truncation limit');
|
||||
assert.ok(!combined.includes('rm -rf /tmp/important'), 'Should not leak raw command text to governance logs');
|
||||
})) passed += 1; else failed += 1;
|
||||
if (await test('run() can detect multiple event types in one input', async () => {
|
||||
// Bash command with force push AND secret in command
|
||||
const events = analyzeForGovernanceEvents({
|
||||
|
||||
@@ -82,6 +82,22 @@ function sleepMs(ms) {
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
||||
}
|
||||
|
||||
function getCanonicalSessionsDir(homeDir) {
|
||||
return path.join(homeDir, '.claude', 'session-data');
|
||||
}
|
||||
|
||||
function getLegacySessionsDir(homeDir) {
|
||||
return path.join(homeDir, '.claude', 'sessions');
|
||||
}
|
||||
|
||||
function getSessionStartAdditionalContext(stdout) {
|
||||
assert.ok(stdout.trim(), 'Expected SessionStart hook to emit stdout payload');
|
||||
const payload = JSON.parse(stdout);
|
||||
assert.strictEqual(payload.hookSpecificOutput?.hookEventName, 'SessionStart', 'Should emit SessionStart hook payload');
|
||||
assert.strictEqual(typeof payload.hookSpecificOutput?.additionalContext, 'string', 'Should include additionalContext text');
|
||||
return payload.hookSpecificOutput.additionalContext;
|
||||
}
|
||||
|
||||
// Test helper
|
||||
function test(name, fn) {
|
||||
try {
|
||||
@@ -336,7 +352,7 @@ async function runTests() {
|
||||
if (
|
||||
await asyncTest('exits 0 even with isolated empty HOME', async () => {
|
||||
const isoHome = path.join(os.tmpdir(), `ecc-iso-start-${Date.now()}`);
|
||||
fs.mkdirSync(path.join(isoHome, '.claude', 'sessions'), { recursive: true });
|
||||
fs.mkdirSync(getCanonicalSessionsDir(isoHome), { recursive: true });
|
||||
fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true });
|
||||
try {
|
||||
const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', {
|
||||
@@ -364,7 +380,7 @@ async function runTests() {
|
||||
if (
|
||||
await asyncTest('skips template session content', async () => {
|
||||
const isoHome = path.join(os.tmpdir(), `ecc-tpl-start-${Date.now()}`);
|
||||
const sessionsDir = path.join(isoHome, '.claude', 'sessions');
|
||||
const sessionsDir = getLegacySessionsDir(isoHome);
|
||||
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||
fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true });
|
||||
|
||||
@@ -378,8 +394,8 @@ async function runTests() {
|
||||
USERPROFILE: isoHome
|
||||
});
|
||||
assert.strictEqual(result.code, 0);
|
||||
// stdout should NOT contain the template content
|
||||
assert.ok(!result.stdout.includes('Previous session summary'), 'Should not inject template session content');
|
||||
const additionalContext = getSessionStartAdditionalContext(result.stdout);
|
||||
assert.ok(!additionalContext.includes('Previous session summary'), 'Should not inject template session content');
|
||||
} finally {
|
||||
fs.rmSync(isoHome, { recursive: true, force: true });
|
||||
}
|
||||
@@ -391,7 +407,7 @@ async function runTests() {
|
||||
if (
|
||||
await asyncTest('injects real session content', async () => {
|
||||
const isoHome = path.join(os.tmpdir(), `ecc-real-start-${Date.now()}`);
|
||||
const sessionsDir = path.join(isoHome, '.claude', 'sessions');
|
||||
const sessionsDir = getLegacySessionsDir(isoHome);
|
||||
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||
fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true });
|
||||
|
||||
@@ -405,8 +421,47 @@ async function runTests() {
|
||||
USERPROFILE: isoHome
|
||||
});
|
||||
assert.strictEqual(result.code, 0);
|
||||
assert.ok(result.stdout.includes('Previous session summary'), 'Should inject real session content');
|
||||
assert.ok(result.stdout.includes('authentication refactor'), 'Should include session content text');
|
||||
const additionalContext = getSessionStartAdditionalContext(result.stdout);
|
||||
assert.ok(additionalContext.includes('Previous session summary'), 'Should inject real session content');
|
||||
assert.ok(additionalContext.includes('authentication refactor'), 'Should include session content text');
|
||||
} finally {
|
||||
fs.rmSync(isoHome, { recursive: true, force: true });
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
await asyncTest('prefers canonical session-data content over legacy duplicates', async () => {
|
||||
const isoHome = path.join(os.tmpdir(), `ecc-canonical-start-${Date.now()}`);
|
||||
const canonicalDir = getCanonicalSessionsDir(isoHome);
|
||||
const legacyDir = getLegacySessionsDir(isoHome);
|
||||
const now = new Date();
|
||||
const filename = `${now.toISOString().slice(0, 10)}-dupe1234-session.tmp`;
|
||||
const canonicalFile = path.join(canonicalDir, filename);
|
||||
const legacyFile = path.join(legacyDir, filename);
|
||||
const canonicalTime = new Date(now.getTime() - 60 * 1000);
|
||||
const legacyTime = new Date(canonicalTime.getTime());
|
||||
|
||||
fs.mkdirSync(canonicalDir, { recursive: true });
|
||||
fs.mkdirSync(legacyDir, { recursive: true });
|
||||
fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true });
|
||||
|
||||
fs.writeFileSync(canonicalFile, '# Canonical Session\n\nUse the canonical session-data copy.\n');
|
||||
fs.writeFileSync(legacyFile, '# Legacy Session\n\nDo not prefer the legacy duplicate.\n');
|
||||
fs.utimesSync(canonicalFile, canonicalTime, canonicalTime);
|
||||
fs.utimesSync(legacyFile, legacyTime, legacyTime);
|
||||
|
||||
try {
|
||||
const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', {
|
||||
HOME: isoHome,
|
||||
USERPROFILE: isoHome
|
||||
});
|
||||
assert.strictEqual(result.code, 0);
|
||||
const additionalContext = getSessionStartAdditionalContext(result.stdout);
|
||||
assert.ok(additionalContext.includes('canonical session-data copy'));
|
||||
assert.ok(!additionalContext.includes('legacy duplicate'));
|
||||
} finally {
|
||||
fs.rmSync(isoHome, { recursive: true, force: true });
|
||||
}
|
||||
@@ -418,7 +473,7 @@ async function runTests() {
|
||||
if (
|
||||
await asyncTest('strips ANSI escape codes from injected session content', async () => {
|
||||
const isoHome = path.join(os.tmpdir(), `ecc-ansi-start-${Date.now()}`);
|
||||
const sessionsDir = path.join(isoHome, '.claude', 'sessions');
|
||||
const sessionsDir = getLegacySessionsDir(isoHome);
|
||||
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||
fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true });
|
||||
|
||||
@@ -434,9 +489,10 @@ async function runTests() {
|
||||
USERPROFILE: isoHome
|
||||
});
|
||||
assert.strictEqual(result.code, 0);
|
||||
assert.ok(result.stdout.includes('Previous session summary'), 'Should inject real session content');
|
||||
assert.ok(result.stdout.includes('Windows terminal handling'), 'Should preserve sanitized session text');
|
||||
assert.ok(!result.stdout.includes('\x1b['), 'Should not emit ANSI escape codes');
|
||||
const additionalContext = getSessionStartAdditionalContext(result.stdout);
|
||||
assert.ok(additionalContext.includes('Previous session summary'), 'Should inject real session content');
|
||||
assert.ok(additionalContext.includes('Windows terminal handling'), 'Should preserve sanitized session text');
|
||||
assert.ok(!additionalContext.includes('\x1b['), 'Should not emit ANSI escape codes');
|
||||
} finally {
|
||||
fs.rmSync(isoHome, { recursive: true, force: true });
|
||||
}
|
||||
@@ -450,7 +506,7 @@ async function runTests() {
|
||||
const isoHome = path.join(os.tmpdir(), `ecc-skills-start-${Date.now()}`);
|
||||
const learnedDir = path.join(isoHome, '.claude', 'skills', 'learned');
|
||||
fs.mkdirSync(learnedDir, { recursive: true });
|
||||
fs.mkdirSync(path.join(isoHome, '.claude', 'sessions'), { recursive: true });
|
||||
fs.mkdirSync(getCanonicalSessionsDir(isoHome), { recursive: true });
|
||||
|
||||
// Create learned skill files
|
||||
fs.writeFileSync(path.join(learnedDir, 'testing-patterns.md'), '# Testing');
|
||||
@@ -548,7 +604,7 @@ async function runTests() {
|
||||
// Check if session file was created
|
||||
// Note: Without CLAUDE_SESSION_ID, falls back to project/worktree name (not 'default')
|
||||
// Use local time to match the script's getDateString() function
|
||||
const sessionsDir = path.join(isoHome, '.claude', 'sessions');
|
||||
const sessionsDir = getCanonicalSessionsDir(isoHome);
|
||||
const now = new Date();
|
||||
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
||||
|
||||
@@ -581,7 +637,7 @@ async function runTests() {
|
||||
|
||||
// Check if session file was created with session ID
|
||||
// Use local time to match the script's getDateString() function
|
||||
const sessionsDir = path.join(isoHome, '.claude', 'sessions');
|
||||
const sessionsDir = getCanonicalSessionsDir(isoHome);
|
||||
const now = new Date();
|
||||
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
||||
const sessionFile = path.join(sessionsDir, `${today}-${expectedShortId}-session.tmp`);
|
||||
@@ -614,7 +670,7 @@ async function runTests() {
|
||||
|
||||
const now = new Date();
|
||||
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
||||
const sessionFile = path.join(isoHome, '.claude', 'sessions', `${today}-${expectedShortId}-session.tmp`);
|
||||
const sessionFile = path.join(getCanonicalSessionsDir(isoHome), `${today}-${expectedShortId}-session.tmp`);
|
||||
const content = fs.readFileSync(sessionFile, 'utf8');
|
||||
|
||||
assert.ok(content.includes(`**Project:** ${project}`), 'Should persist project metadata');
|
||||
@@ -652,7 +708,7 @@ async function runTests() {
|
||||
if (
|
||||
await asyncTest('creates compaction log', async () => {
|
||||
await runScript(path.join(scriptsDir, 'pre-compact.js'));
|
||||
const logFile = path.join(os.homedir(), '.claude', 'sessions', 'compaction-log.txt');
|
||||
const logFile = path.join(getCanonicalSessionsDir(os.homedir()), 'compaction-log.txt');
|
||||
assert.ok(fs.existsSync(logFile), 'Compaction log should exist');
|
||||
})
|
||||
)
|
||||
@@ -662,7 +718,7 @@ async function runTests() {
|
||||
if (
|
||||
await asyncTest('annotates active session file with compaction marker', async () => {
|
||||
const isoHome = path.join(os.tmpdir(), `ecc-compact-annotate-${Date.now()}`);
|
||||
const sessionsDir = path.join(isoHome, '.claude', 'sessions');
|
||||
const sessionsDir = getCanonicalSessionsDir(isoHome);
|
||||
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||
|
||||
// Create an active .tmp session file
|
||||
@@ -688,7 +744,7 @@ async function runTests() {
|
||||
if (
|
||||
await asyncTest('compaction log contains timestamp', async () => {
|
||||
const isoHome = path.join(os.tmpdir(), `ecc-compact-ts-${Date.now()}`);
|
||||
const sessionsDir = path.join(isoHome, '.claude', 'sessions');
|
||||
const sessionsDir = getCanonicalSessionsDir(isoHome);
|
||||
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||
|
||||
try {
|
||||
@@ -1544,7 +1600,7 @@ async function runTests() {
|
||||
assert.strictEqual(result.code, 0, 'Should handle backticks without crash');
|
||||
|
||||
// Find the session file in the temp HOME
|
||||
const claudeDir = path.join(testDir, '.claude', 'sessions');
|
||||
const claudeDir = getCanonicalSessionsDir(testDir);
|
||||
if (fs.existsSync(claudeDir)) {
|
||||
const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp'));
|
||||
if (files.length > 0) {
|
||||
@@ -1579,7 +1635,7 @@ async function runTests() {
|
||||
});
|
||||
assert.strictEqual(result.code, 0);
|
||||
|
||||
const claudeDir = path.join(testDir, '.claude', 'sessions');
|
||||
const claudeDir = getCanonicalSessionsDir(testDir);
|
||||
if (fs.existsSync(claudeDir)) {
|
||||
const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp'));
|
||||
if (files.length > 0) {
|
||||
@@ -1613,7 +1669,7 @@ async function runTests() {
|
||||
});
|
||||
assert.strictEqual(result.code, 0);
|
||||
|
||||
const claudeDir = path.join(testDir, '.claude', 'sessions');
|
||||
const claudeDir = getCanonicalSessionsDir(testDir);
|
||||
if (fs.existsSync(claudeDir)) {
|
||||
const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp'));
|
||||
if (files.length > 0) {
|
||||
@@ -1648,7 +1704,7 @@ async function runTests() {
|
||||
});
|
||||
assert.strictEqual(result.code, 0);
|
||||
|
||||
const claudeDir = path.join(testDir, '.claude', 'sessions');
|
||||
const claudeDir = getCanonicalSessionsDir(testDir);
|
||||
if (fs.existsSync(claudeDir)) {
|
||||
const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp'));
|
||||
if (files.length > 0) {
|
||||
@@ -1686,7 +1742,7 @@ async function runTests() {
|
||||
});
|
||||
assert.strictEqual(result.code, 0);
|
||||
|
||||
const claudeDir = path.join(testDir, '.claude', 'sessions');
|
||||
const claudeDir = getCanonicalSessionsDir(testDir);
|
||||
if (fs.existsSync(claudeDir)) {
|
||||
const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp'));
|
||||
if (files.length > 0) {
|
||||
@@ -1723,7 +1779,7 @@ async function runTests() {
|
||||
});
|
||||
assert.strictEqual(result.code, 0);
|
||||
|
||||
const claudeDir = path.join(testDir, '.claude', 'sessions');
|
||||
const claudeDir = getCanonicalSessionsDir(testDir);
|
||||
if (fs.existsSync(claudeDir)) {
|
||||
const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp'));
|
||||
if (files.length > 0) {
|
||||
@@ -1757,7 +1813,7 @@ async function runTests() {
|
||||
});
|
||||
assert.strictEqual(result.code, 0);
|
||||
|
||||
const claudeDir = path.join(testDir, '.claude', 'sessions');
|
||||
const claudeDir = getCanonicalSessionsDir(testDir);
|
||||
if (fs.existsSync(claudeDir)) {
|
||||
const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp'));
|
||||
if (files.length > 0) {
|
||||
@@ -1800,7 +1856,7 @@ async function runTests() {
|
||||
});
|
||||
assert.strictEqual(result.code, 0);
|
||||
|
||||
const claudeDir = path.join(testDir, '.claude', 'sessions');
|
||||
const claudeDir = getCanonicalSessionsDir(testDir);
|
||||
if (fs.existsSync(claudeDir)) {
|
||||
const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp'));
|
||||
if (files.length > 0) {
|
||||
@@ -1873,9 +1929,8 @@ async function runTests() {
|
||||
const isNpx = hook.command.startsWith('npx ');
|
||||
const isSkillScript = hook.command.includes('/skills/') && (/^(bash|sh)\s/.test(hook.command) || hook.command.startsWith('${CLAUDE_PLUGIN_ROOT}/skills/'));
|
||||
const isHookShellWrapper = /^(bash|sh)\s+["']?\$\{CLAUDE_PLUGIN_ROOT\}\/scripts\/hooks\/run-with-flags-shell\.sh/.test(hook.command);
|
||||
const isSessionStartFallback = hook.command.startsWith('bash -lc') && hook.command.includes('run-with-flags.js');
|
||||
assert.ok(
|
||||
isNode || isNpx || isSkillScript || isHookShellWrapper || isSessionStartFallback,
|
||||
isNode || isNpx || isSkillScript || isHookShellWrapper,
|
||||
`Hook command should use node or approved shell wrapper: ${hook.command.substring(0, 100)}...`
|
||||
);
|
||||
}
|
||||
@@ -1892,7 +1947,25 @@ async function runTests() {
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('script references use CLAUDE_PLUGIN_ROOT variable (except SessionStart fallback)', () => {
|
||||
test('SessionStart hook uses safe inline resolver without plugin-tree scanning', () => {
|
||||
const hooksPath = path.join(__dirname, '..', '..', 'hooks', 'hooks.json');
|
||||
const hooks = JSON.parse(fs.readFileSync(hooksPath, 'utf8'));
|
||||
const sessionStartHook = hooks.hooks.SessionStart?.[0]?.hooks?.[0];
|
||||
|
||||
assert.ok(sessionStartHook, 'Should define a SessionStart hook');
|
||||
assert.ok(sessionStartHook.command.startsWith('node -e "'), 'SessionStart should use inline node resolver');
|
||||
assert.ok(sessionStartHook.command.includes('session:start'), 'SessionStart should invoke the session:start profile');
|
||||
assert.ok(sessionStartHook.command.includes('run-with-flags.js'), 'SessionStart should resolve the runner script');
|
||||
assert.ok(sessionStartHook.command.includes('CLAUDE_PLUGIN_ROOT'), 'SessionStart should consult CLAUDE_PLUGIN_ROOT');
|
||||
assert.ok(sessionStartHook.command.includes('plugins'), 'SessionStart should probe known plugin roots');
|
||||
assert.ok(!sessionStartHook.command.includes('find '), 'Should not scan arbitrary plugin paths with find');
|
||||
assert.ok(!sessionStartHook.command.includes('head -n 1'), 'Should not pick the first matching plugin path');
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
if (
|
||||
test('script references use CLAUDE_PLUGIN_ROOT variable or safe SessionStart inline resolver', () => {
|
||||
const hooksPath = path.join(__dirname, '..', '..', 'hooks', 'hooks.json');
|
||||
const hooks = JSON.parse(fs.readFileSync(hooksPath, 'utf8'));
|
||||
|
||||
@@ -1901,8 +1974,8 @@ async function runTests() {
|
||||
for (const hook of entry.hooks) {
|
||||
if (hook.type === 'command' && hook.command.includes('scripts/hooks/')) {
|
||||
// Check for the literal string "${CLAUDE_PLUGIN_ROOT}" in the command
|
||||
const isSessionStartFallback = hook.command.startsWith('bash -lc') && hook.command.includes('run-with-flags.js');
|
||||
const hasPluginRoot = hook.command.includes('${CLAUDE_PLUGIN_ROOT}') || isSessionStartFallback;
|
||||
const isSessionStartInlineResolver = hook.command.startsWith('node -e') && hook.command.includes('session:start') && hook.command.includes('run-with-flags.js');
|
||||
const hasPluginRoot = hook.command.includes('${CLAUDE_PLUGIN_ROOT}') || isSessionStartInlineResolver;
|
||||
assert.ok(hasPluginRoot, `Script paths should use CLAUDE_PLUGIN_ROOT: ${hook.command.substring(0, 80)}...`);
|
||||
}
|
||||
}
|
||||
@@ -2766,7 +2839,7 @@ async function runTests() {
|
||||
if (
|
||||
await asyncTest('updates Last Updated timestamp in existing session file', async () => {
|
||||
const testDir = createTestDir();
|
||||
const sessionsDir = path.join(testDir, '.claude', 'sessions');
|
||||
const sessionsDir = getCanonicalSessionsDir(testDir);
|
||||
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||
|
||||
// Get the expected filename
|
||||
@@ -2798,7 +2871,7 @@ async function runTests() {
|
||||
if (
|
||||
await asyncTest('normalizes existing session headers with project, branch, and worktree metadata', async () => {
|
||||
const testDir = createTestDir();
|
||||
const sessionsDir = path.join(testDir, '.claude', 'sessions');
|
||||
const sessionsDir = getCanonicalSessionsDir(testDir);
|
||||
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||
|
||||
const utils = require('../../scripts/lib/utils');
|
||||
@@ -2831,7 +2904,7 @@ async function runTests() {
|
||||
if (
|
||||
await asyncTest('replaces blank template with summary when updating existing file', async () => {
|
||||
const testDir = createTestDir();
|
||||
const sessionsDir = path.join(testDir, '.claude', 'sessions');
|
||||
const sessionsDir = getCanonicalSessionsDir(testDir);
|
||||
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||
|
||||
const utils = require('../../scripts/lib/utils');
|
||||
@@ -2869,7 +2942,7 @@ async function runTests() {
|
||||
if (
|
||||
await asyncTest('always updates session summary content on session end', async () => {
|
||||
const testDir = createTestDir();
|
||||
const sessionsDir = path.join(testDir, '.claude', 'sessions');
|
||||
const sessionsDir = getCanonicalSessionsDir(testDir);
|
||||
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||
|
||||
const utils = require('../../scripts/lib/utils');
|
||||
@@ -2906,7 +2979,7 @@ async function runTests() {
|
||||
if (
|
||||
await asyncTest('only annotates *-session.tmp files, not other .tmp files', async () => {
|
||||
const isoHome = path.join(os.tmpdir(), `ecc-compact-glob-${Date.now()}`);
|
||||
const sessionsDir = path.join(isoHome, '.claude', 'sessions');
|
||||
const sessionsDir = getCanonicalSessionsDir(isoHome);
|
||||
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||
|
||||
// Create a session .tmp file and a non-session .tmp file
|
||||
@@ -2937,7 +3010,7 @@ async function runTests() {
|
||||
if (
|
||||
await asyncTest('handles no active session files gracefully', async () => {
|
||||
const isoHome = path.join(os.tmpdir(), `ecc-compact-nosession-${Date.now()}`);
|
||||
const sessionsDir = path.join(isoHome, '.claude', 'sessions');
|
||||
const sessionsDir = getCanonicalSessionsDir(isoHome);
|
||||
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||
|
||||
try {
|
||||
@@ -2976,7 +3049,7 @@ async function runTests() {
|
||||
assert.strictEqual(result.code, 0);
|
||||
|
||||
// With no user messages, extractSessionSummary returns null → blank template
|
||||
const claudeDir = path.join(testDir, '.claude', 'sessions');
|
||||
const claudeDir = getCanonicalSessionsDir(testDir);
|
||||
if (fs.existsSync(claudeDir)) {
|
||||
const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp'));
|
||||
if (files.length > 0) {
|
||||
@@ -3016,7 +3089,7 @@ async function runTests() {
|
||||
});
|
||||
assert.strictEqual(result.code, 0);
|
||||
|
||||
const claudeDir = path.join(testDir, '.claude', 'sessions');
|
||||
const claudeDir = getCanonicalSessionsDir(testDir);
|
||||
if (fs.existsSync(claudeDir)) {
|
||||
const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp'));
|
||||
if (files.length > 0) {
|
||||
@@ -3192,7 +3265,7 @@ async function runTests() {
|
||||
if (
|
||||
await asyncTest('exits 0 with empty sessions directory (no recent sessions)', async () => {
|
||||
const isoHome = path.join(os.tmpdir(), `ecc-start-empty-${Date.now()}`);
|
||||
fs.mkdirSync(path.join(isoHome, '.claude', 'sessions'), { recursive: true });
|
||||
fs.mkdirSync(getCanonicalSessionsDir(isoHome), { recursive: true });
|
||||
fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true });
|
||||
try {
|
||||
const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', {
|
||||
@@ -3201,7 +3274,8 @@ async function runTests() {
|
||||
});
|
||||
assert.strictEqual(result.code, 0, 'Should exit 0 with no sessions');
|
||||
// Should NOT inject any previous session data (stdout should be empty or minimal)
|
||||
assert.ok(!result.stdout.includes('Previous session summary'), 'Should not inject when no sessions');
|
||||
const additionalContext = getSessionStartAdditionalContext(result.stdout);
|
||||
assert.ok(!additionalContext.includes('Previous session summary'), 'Should not inject when no sessions');
|
||||
} finally {
|
||||
fs.rmSync(isoHome, { recursive: true, force: true });
|
||||
}
|
||||
@@ -3213,7 +3287,7 @@ async function runTests() {
|
||||
if (
|
||||
await asyncTest('does not inject blank template session into context', async () => {
|
||||
const isoHome = path.join(os.tmpdir(), `ecc-start-blank-${Date.now()}`);
|
||||
const sessionsDir = path.join(isoHome, '.claude', 'sessions');
|
||||
const sessionsDir = getCanonicalSessionsDir(isoHome);
|
||||
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||
fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true });
|
||||
|
||||
@@ -3229,7 +3303,8 @@ async function runTests() {
|
||||
});
|
||||
assert.strictEqual(result.code, 0);
|
||||
// Should NOT inject blank template
|
||||
assert.ok(!result.stdout.includes('Previous session summary'), 'Should skip blank template sessions');
|
||||
const additionalContext = getSessionStartAdditionalContext(result.stdout);
|
||||
assert.ok(!additionalContext.includes('Previous session summary'), 'Should skip blank template sessions');
|
||||
} finally {
|
||||
fs.rmSync(isoHome, { recursive: true, force: true });
|
||||
}
|
||||
@@ -3825,7 +3900,7 @@ async function runTests() {
|
||||
if (
|
||||
await asyncTest('annotates only the newest session file when multiple exist', async () => {
|
||||
const isoHome = path.join(os.tmpdir(), `ecc-compact-multi-${Date.now()}`);
|
||||
const sessionsDir = path.join(isoHome, '.claude', 'sessions');
|
||||
const sessionsDir = getCanonicalSessionsDir(isoHome);
|
||||
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||
|
||||
// Create two session files with different mtimes
|
||||
@@ -3877,7 +3952,7 @@ async function runTests() {
|
||||
assert.strictEqual(result.code, 0);
|
||||
|
||||
// Find the session file and verify newlines were collapsed
|
||||
const claudeDir = path.join(testDir, '.claude', 'sessions');
|
||||
const claudeDir = getCanonicalSessionsDir(testDir);
|
||||
if (fs.existsSync(claudeDir)) {
|
||||
const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp'));
|
||||
if (files.length > 0) {
|
||||
@@ -3903,7 +3978,7 @@ async function runTests() {
|
||||
if (
|
||||
await asyncTest('does not inject empty session file content into context', async () => {
|
||||
const isoHome = path.join(os.tmpdir(), `ecc-start-empty-file-${Date.now()}`);
|
||||
const sessionsDir = path.join(isoHome, '.claude', 'sessions');
|
||||
const sessionsDir = getCanonicalSessionsDir(isoHome);
|
||||
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||
fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true });
|
||||
|
||||
@@ -3919,7 +3994,8 @@ async function runTests() {
|
||||
});
|
||||
assert.strictEqual(result.code, 0, 'Should exit 0 with empty session file');
|
||||
// readFile returns '' (falsy) → the if (content && ...) guard skips injection
|
||||
assert.ok(!result.stdout.includes('Previous session summary'), 'Should NOT inject empty string into context');
|
||||
const additionalContext = getSessionStartAdditionalContext(result.stdout);
|
||||
assert.ok(!additionalContext.includes('Previous session summary'), 'Should NOT inject empty string into context');
|
||||
} finally {
|
||||
fs.rmSync(isoHome, { recursive: true, force: true });
|
||||
}
|
||||
@@ -3963,7 +4039,7 @@ async function runTests() {
|
||||
if (
|
||||
await asyncTest('summary omits Files Modified and Tools Used when none found', async () => {
|
||||
const isoHome = path.join(os.tmpdir(), `ecc-notools-${Date.now()}`);
|
||||
const sessionsDir = path.join(isoHome, '.claude', 'sessions');
|
||||
const sessionsDir = getCanonicalSessionsDir(isoHome);
|
||||
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||
|
||||
const testDir = createTestDir();
|
||||
@@ -4001,7 +4077,7 @@ async function runTests() {
|
||||
if (
|
||||
await asyncTest('reports available session aliases on startup', async () => {
|
||||
const isoHome = path.join(os.tmpdir(), `ecc-start-alias-${Date.now()}`);
|
||||
fs.mkdirSync(path.join(isoHome, '.claude', 'sessions'), { recursive: true });
|
||||
fs.mkdirSync(getCanonicalSessionsDir(isoHome), { recursive: true });
|
||||
fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true });
|
||||
|
||||
// Pre-populate the aliases file
|
||||
@@ -4038,7 +4114,7 @@ async function runTests() {
|
||||
if (
|
||||
await asyncTest('parallel compaction runs all append to log without loss', async () => {
|
||||
const isoHome = path.join(os.tmpdir(), `ecc-compact-par-${Date.now()}`);
|
||||
const sessionsDir = path.join(isoHome, '.claude', 'sessions');
|
||||
const sessionsDir = getCanonicalSessionsDir(isoHome);
|
||||
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||
|
||||
try {
|
||||
@@ -4073,7 +4149,7 @@ async function runTests() {
|
||||
const isoHome = path.join(os.tmpdir(), `ecc-start-blocked-${Date.now()}`);
|
||||
fs.mkdirSync(path.join(isoHome, '.claude'), { recursive: true });
|
||||
// Block sessions dir creation by placing a file at that path
|
||||
fs.writeFileSync(path.join(isoHome, '.claude', 'sessions'), 'blocked');
|
||||
fs.writeFileSync(getCanonicalSessionsDir(isoHome), 'blocked');
|
||||
|
||||
try {
|
||||
const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', {
|
||||
@@ -4136,7 +4212,7 @@ async function runTests() {
|
||||
if (
|
||||
await asyncTest('excludes session files older than 7 days', async () => {
|
||||
const isoHome = path.join(os.tmpdir(), `ecc-start-7day-${Date.now()}`);
|
||||
const sessionsDir = path.join(isoHome, '.claude', 'sessions');
|
||||
const sessionsDir = getCanonicalSessionsDir(isoHome);
|
||||
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||
fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true });
|
||||
|
||||
@@ -4159,8 +4235,9 @@ async function runTests() {
|
||||
});
|
||||
assert.strictEqual(result.code, 0);
|
||||
assert.ok(result.stderr.includes('1 recent session'), `Should find 1 recent session (6.9-day included, 8-day excluded), stderr: ${result.stderr}`);
|
||||
assert.ok(result.stdout.includes('RECENT CONTENT HERE'), 'Should inject the 6.9-day-old session content');
|
||||
assert.ok(!result.stdout.includes('OLD CONTENT SHOULD NOT APPEAR'), 'Should NOT inject the 8-day-old session content');
|
||||
const additionalContext = getSessionStartAdditionalContext(result.stdout);
|
||||
assert.ok(additionalContext.includes('RECENT CONTENT HERE'), 'Should inject the 6.9-day-old session content');
|
||||
assert.ok(!additionalContext.includes('OLD CONTENT SHOULD NOT APPEAR'), 'Should NOT inject the 8-day-old session content');
|
||||
} finally {
|
||||
fs.rmSync(isoHome, { recursive: true, force: true });
|
||||
}
|
||||
@@ -4174,7 +4251,7 @@ async function runTests() {
|
||||
if (
|
||||
await asyncTest('injects newest session when multiple recent sessions exist', async () => {
|
||||
const isoHome = path.join(os.tmpdir(), `ecc-start-multi-${Date.now()}`);
|
||||
const sessionsDir = path.join(isoHome, '.claude', 'sessions');
|
||||
const sessionsDir = getCanonicalSessionsDir(isoHome);
|
||||
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||
fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true });
|
||||
|
||||
@@ -4198,7 +4275,8 @@ async function runTests() {
|
||||
assert.strictEqual(result.code, 0);
|
||||
assert.ok(result.stderr.includes('2 recent session'), `Should find 2 recent sessions, stderr: ${result.stderr}`);
|
||||
// Should inject the NEWER session, not the older one
|
||||
assert.ok(result.stdout.includes('NEWER_CONTEXT_MARKER'), 'Should inject the newest session content');
|
||||
const additionalContext = getSessionStartAdditionalContext(result.stdout);
|
||||
assert.ok(additionalContext.includes('NEWER_CONTEXT_MARKER'), 'Should inject the newest session content');
|
||||
} finally {
|
||||
fs.rmSync(isoHome, { recursive: true, force: true });
|
||||
}
|
||||
@@ -4305,7 +4383,7 @@ async function runTests() {
|
||||
return;
|
||||
}
|
||||
const isoHome = path.join(os.tmpdir(), `ecc-start-unreadable-${Date.now()}`);
|
||||
const sessionsDir = path.join(isoHome, '.claude', 'sessions');
|
||||
const sessionsDir = getCanonicalSessionsDir(isoHome);
|
||||
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||
|
||||
// Create a session file with real content, then make it unreadable
|
||||
@@ -4320,7 +4398,8 @@ async function runTests() {
|
||||
});
|
||||
assert.strictEqual(result.code, 0, 'Should exit 0 even with unreadable session file');
|
||||
// readFile returns null for unreadable files → content is null → no injection
|
||||
assert.ok(!result.stdout.includes('Sensitive session content'), 'Should NOT inject content from unreadable file');
|
||||
const additionalContext = getSessionStartAdditionalContext(result.stdout);
|
||||
assert.ok(!additionalContext.includes('Sensitive session content'), 'Should NOT inject content from unreadable file');
|
||||
} finally {
|
||||
try {
|
||||
fs.chmodSync(sessionFile, 0o644);
|
||||
@@ -4366,7 +4445,7 @@ async function runTests() {
|
||||
return;
|
||||
}
|
||||
const isoHome = path.join(os.tmpdir(), `ecc-compact-ro-${Date.now()}`);
|
||||
const sessionsDir = path.join(isoHome, '.claude', 'sessions');
|
||||
const sessionsDir = getCanonicalSessionsDir(isoHome);
|
||||
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||
|
||||
// Create a session file then make it read-only
|
||||
@@ -4407,7 +4486,7 @@ async function runTests() {
|
||||
if (
|
||||
await asyncTest('logs warning when existing session file lacks Last Updated field', async () => {
|
||||
const isoHome = path.join(os.tmpdir(), `ecc-end-nots-${Date.now()}`);
|
||||
const sessionsDir = path.join(isoHome, '.claude', 'sessions');
|
||||
const sessionsDir = getCanonicalSessionsDir(isoHome);
|
||||
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||
|
||||
// Create transcript with a user message so a summary is produced
|
||||
@@ -4498,7 +4577,7 @@ async function runTests() {
|
||||
if (
|
||||
await asyncTest('extracts user messages from role-only format (no type field)', async () => {
|
||||
const isoHome = path.join(os.tmpdir(), `ecc-role-only-${Date.now()}`);
|
||||
const sessionsDir = path.join(isoHome, '.claude', 'sessions');
|
||||
const sessionsDir = getCanonicalSessionsDir(isoHome);
|
||||
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||
|
||||
const testDir = createTestDir();
|
||||
@@ -4534,7 +4613,7 @@ async function runTests() {
|
||||
if (
|
||||
await asyncTest('logs "Transcript not found" for nonexistent transcript_path', async () => {
|
||||
const isoHome = path.join(os.tmpdir(), `ecc-notfound-${Date.now()}`);
|
||||
const sessionsDir = path.join(isoHome, '.claude', 'sessions');
|
||||
const sessionsDir = getCanonicalSessionsDir(isoHome);
|
||||
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||
|
||||
const stdinJson = JSON.stringify({ transcript_path: '/tmp/nonexistent-transcript-99999.jsonl' });
|
||||
@@ -4563,7 +4642,7 @@ async function runTests() {
|
||||
if (
|
||||
await asyncTest('extracts tool name and file path from entry.name/entry.input (not tool_name/tool_input)', async () => {
|
||||
const isoHome = path.join(os.tmpdir(), `ecc-r70-entryname-${Date.now()}`);
|
||||
const sessionsDir = path.join(isoHome, '.claude', 'sessions');
|
||||
const sessionsDir = getCanonicalSessionsDir(isoHome);
|
||||
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||
const transcriptPath = path.join(isoHome, 'transcript.jsonl');
|
||||
|
||||
@@ -4611,7 +4690,7 @@ async function runTests() {
|
||||
await asyncTest('shows selection prompt when no package manager preference found (default source)', async () => {
|
||||
const isoHome = path.join(os.tmpdir(), `ecc-r71-ss-default-${Date.now()}`);
|
||||
const isoProject = path.join(isoHome, 'project');
|
||||
fs.mkdirSync(path.join(isoHome, '.claude', 'sessions'), { recursive: true });
|
||||
fs.mkdirSync(getCanonicalSessionsDir(isoHome), { recursive: true });
|
||||
fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true });
|
||||
fs.mkdirSync(isoProject, { recursive: true });
|
||||
// No package.json, no lock files, no package-manager.json — forces default source
|
||||
@@ -4758,7 +4837,7 @@ async function runTests() {
|
||||
if (
|
||||
await asyncTest('extracts user messages from entries where only message.role is user (not type or role)', async () => {
|
||||
const isoHome = path.join(os.tmpdir(), `ecc-msgrole-${Date.now()}`);
|
||||
const sessionsDir = path.join(isoHome, '.claude', 'sessions');
|
||||
const sessionsDir = getCanonicalSessionsDir(isoHome);
|
||||
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||
|
||||
const testDir = createTestDir();
|
||||
@@ -4825,7 +4904,7 @@ async function runTests() {
|
||||
// session-end.js line 50-55: rawContent is checked for string, then array, else ''
|
||||
// When content is a number (42), neither branch matches, text = '', message is skipped.
|
||||
const isoHome = path.join(os.tmpdir(), `ecc-r81-numcontent-${Date.now()}`);
|
||||
const sessionsDir = path.join(isoHome, '.claude', 'sessions');
|
||||
const sessionsDir = getCanonicalSessionsDir(isoHome);
|
||||
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||
const transcriptPath = path.join(isoHome, 'transcript.jsonl');
|
||||
|
||||
@@ -4874,7 +4953,7 @@ async function runTests() {
|
||||
if (
|
||||
await asyncTest('collects tool name from entry with tool_name but non-tool_use type', async () => {
|
||||
const isoHome = path.join(os.tmpdir(), `ecc-r82-toolname-${Date.now()}`);
|
||||
const sessionsDir = path.join(isoHome, '.claude', 'sessions');
|
||||
const sessionsDir = getCanonicalSessionsDir(isoHome);
|
||||
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||
|
||||
const transcriptPath = path.join(isoHome, 'transcript.jsonl');
|
||||
@@ -4912,7 +4991,7 @@ async function runTests() {
|
||||
if (
|
||||
await asyncTest('preserves file when marker present but regex does not match corrupted template', async () => {
|
||||
const isoHome = path.join(os.tmpdir(), `ecc-r82-tmpl-${Date.now()}`);
|
||||
const sessionsDir = path.join(isoHome, '.claude', 'sessions');
|
||||
const sessionsDir = getCanonicalSessionsDir(isoHome);
|
||||
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
@@ -5072,7 +5151,7 @@ Some random content without the expected ### Context to Load section
|
||||
assert.strictEqual(result.code, 0, 'Should exit 0');
|
||||
|
||||
// Read the session file to verify tool names and file paths were extracted
|
||||
const claudeDir = path.join(testDir, '.claude', 'sessions');
|
||||
const claudeDir = getCanonicalSessionsDir(testDir);
|
||||
if (fs.existsSync(claudeDir)) {
|
||||
const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp'));
|
||||
if (files.length > 0) {
|
||||
@@ -5193,7 +5272,7 @@ Some random content without the expected ### Context to Load section
|
||||
});
|
||||
assert.strictEqual(result.code, 0, 'Should exit 0');
|
||||
|
||||
const claudeDir = path.join(testDir, '.claude', 'sessions');
|
||||
const claudeDir = getCanonicalSessionsDir(testDir);
|
||||
if (fs.existsSync(claudeDir)) {
|
||||
const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp'));
|
||||
if (files.length > 0) {
|
||||
|
||||
@@ -79,6 +79,25 @@ function runHook(input, env = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function runRawHook(rawInput, env = {}) {
|
||||
const result = spawnSync('node', [script], {
|
||||
input: rawInput,
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
ECC_HOOK_PROFILE: 'standard',
|
||||
...env
|
||||
},
|
||||
timeout: 15000,
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
return {
|
||||
code: result.status || 0,
|
||||
stdout: result.stdout || '',
|
||||
stderr: result.stderr || ''
|
||||
};
|
||||
}
|
||||
async function runTests() {
|
||||
console.log('\n=== Testing mcp-health-check.js ===\n');
|
||||
|
||||
@@ -95,6 +114,19 @@ async function runTests() {
|
||||
assert.strictEqual(result.stderr, '', 'Expected no stderr for non-MCP tool');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('blocks truncated MCP hook input by default', () => {
|
||||
const rawInput = JSON.stringify({ tool_name: 'mcp__flaky__search', tool_input: {} });
|
||||
const result = runRawHook(rawInput, {
|
||||
CLAUDE_HOOK_EVENT_NAME: 'PreToolUse',
|
||||
ECC_HOOK_INPUT_TRUNCATED: '1',
|
||||
ECC_HOOK_INPUT_MAX_BYTES: '512'
|
||||
});
|
||||
|
||||
assert.strictEqual(result.code, 2, 'Expected truncated MCP input to block by default');
|
||||
assert.strictEqual(result.stdout, rawInput, 'Expected raw input passthrough on stdout');
|
||||
assert.ok(result.stderr.includes('Hook input exceeded 512 bytes'), `Expected size warning, got: ${result.stderr}`);
|
||||
assert.ok(/blocking search/i.test(result.stderr), `Expected blocking message, got: ${result.stderr}`);
|
||||
})) passed++; else failed++;
|
||||
if (await asyncTest('marks healthy command MCP servers and allows the tool call', async () => {
|
||||
const tempDir = createTempDir();
|
||||
const configPath = path.join(tempDir, 'claude.json');
|
||||
|
||||
@@ -148,6 +148,24 @@ test('analysis temp file is created and cleaned up', () => {
|
||||
assert.ok(content.includes('rm -f "$prompt_file" "$analysis_file"'), 'Should clean up both prompt and analysis temp files');
|
||||
});
|
||||
|
||||
test('observer-loop uses project-local temp directory for analysis artifacts', () => {
|
||||
const content = fs.readFileSync(observerLoopPath, 'utf8');
|
||||
assert.ok(content.includes('observer_tmp_dir="${PROJECT_DIR}/.observer-tmp"'), 'Should keep observer temp files inside the project');
|
||||
assert.ok(content.includes('mktemp "${observer_tmp_dir}/ecc-observer-analysis.'), 'Analysis temp file should use the project temp dir');
|
||||
assert.ok(content.includes('mktemp "${observer_tmp_dir}/ecc-observer-prompt.'), 'Prompt temp file should use the project temp dir');
|
||||
});
|
||||
|
||||
test('observer-loop prompt requires direct instinct writes without asking permission', () => {
|
||||
const content = fs.readFileSync(observerLoopPath, 'utf8');
|
||||
const heredocStart = content.indexOf('cat > "$prompt_file" <<PROMPT');
|
||||
const heredocEnd = content.indexOf('\nPROMPT', heredocStart + 1);
|
||||
assert.ok(heredocStart > 0, 'Should find prompt heredoc start');
|
||||
assert.ok(heredocEnd > heredocStart, 'Should find prompt heredoc end');
|
||||
const promptSection = content.substring(heredocStart, heredocEnd);
|
||||
assert.ok(promptSection.includes('MUST write an instinct file directly'), 'Prompt should require direct file creation');
|
||||
assert.ok(promptSection.includes('Do NOT ask for permission'), 'Prompt should forbid permission-seeking');
|
||||
assert.ok(promptSection.includes('write or update the instinct file in this run'), 'Prompt should require same-run writes');
|
||||
});
|
||||
test('prompt references analysis_file not full OBSERVATIONS_FILE', () => {
|
||||
const content = fs.readFileSync(observerLoopPath, 'utf8');
|
||||
// The prompt heredoc should reference analysis_file for the Read instruction.
|
||||
|
||||
@@ -54,47 +54,56 @@ function getCounterFilePath(sessionId) {
|
||||
return path.join(os.tmpdir(), `claude-tool-count-${sessionId}`);
|
||||
}
|
||||
|
||||
let counterContextSeq = 0;
|
||||
|
||||
function createCounterContext(prefix = 'test-compact') {
|
||||
counterContextSeq += 1;
|
||||
const sessionId = `${prefix}-${Date.now()}-${counterContextSeq}`;
|
||||
const counterFile = getCounterFilePath(sessionId);
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
counterFile,
|
||||
cleanup() {
|
||||
try {
|
||||
fs.unlinkSync(counterFile);
|
||||
} catch (_err) {
|
||||
// Ignore missing temp files between runs
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing suggest-compact.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
// Use a unique session ID per test run to avoid collisions
|
||||
const testSession = `test-compact-${Date.now()}`;
|
||||
const counterFile = getCounterFilePath(testSession);
|
||||
|
||||
// Cleanup helper
|
||||
function cleanupCounter() {
|
||||
try {
|
||||
fs.unlinkSync(counterFile);
|
||||
} catch (_err) {
|
||||
// Ignore error
|
||||
}
|
||||
}
|
||||
|
||||
// Basic functionality
|
||||
console.log('Basic counter functionality:');
|
||||
|
||||
if (test('creates counter file on first run', () => {
|
||||
cleanupCounter();
|
||||
const result = runCompact({ CLAUDE_SESSION_ID: testSession });
|
||||
const { sessionId, counterFile, cleanup } = createCounterContext();
|
||||
cleanup();
|
||||
const result = runCompact({ CLAUDE_SESSION_ID: sessionId });
|
||||
assert.strictEqual(result.code, 0, 'Should exit 0');
|
||||
assert.ok(fs.existsSync(counterFile), 'Counter file should be created');
|
||||
const count = parseInt(fs.readFileSync(counterFile, 'utf8').trim(), 10);
|
||||
assert.strictEqual(count, 1, 'Counter should be 1 after first run');
|
||||
cleanupCounter();
|
||||
cleanup();
|
||||
})) passed++;
|
||||
else failed++;
|
||||
|
||||
if (test('increments counter on subsequent runs', () => {
|
||||
cleanupCounter();
|
||||
runCompact({ CLAUDE_SESSION_ID: testSession });
|
||||
runCompact({ CLAUDE_SESSION_ID: testSession });
|
||||
runCompact({ CLAUDE_SESSION_ID: testSession });
|
||||
const { sessionId, counterFile, cleanup } = createCounterContext();
|
||||
cleanup();
|
||||
runCompact({ CLAUDE_SESSION_ID: sessionId });
|
||||
runCompact({ CLAUDE_SESSION_ID: sessionId });
|
||||
runCompact({ CLAUDE_SESSION_ID: sessionId });
|
||||
const count = parseInt(fs.readFileSync(counterFile, 'utf8').trim(), 10);
|
||||
assert.strictEqual(count, 3, 'Counter should be 3 after three runs');
|
||||
cleanupCounter();
|
||||
cleanup();
|
||||
})) passed++;
|
||||
else failed++;
|
||||
|
||||
@@ -102,28 +111,30 @@ function runTests() {
|
||||
console.log('\nThreshold suggestion:');
|
||||
|
||||
if (test('suggests compact at threshold (COMPACT_THRESHOLD=3)', () => {
|
||||
cleanupCounter();
|
||||
const { sessionId, cleanup } = createCounterContext();
|
||||
cleanup();
|
||||
// Run 3 times with threshold=3
|
||||
runCompact({ CLAUDE_SESSION_ID: testSession, COMPACT_THRESHOLD: '3' });
|
||||
runCompact({ CLAUDE_SESSION_ID: testSession, COMPACT_THRESHOLD: '3' });
|
||||
const result = runCompact({ CLAUDE_SESSION_ID: testSession, COMPACT_THRESHOLD: '3' });
|
||||
runCompact({ CLAUDE_SESSION_ID: sessionId, COMPACT_THRESHOLD: '3' });
|
||||
runCompact({ CLAUDE_SESSION_ID: sessionId, COMPACT_THRESHOLD: '3' });
|
||||
const result = runCompact({ CLAUDE_SESSION_ID: sessionId, COMPACT_THRESHOLD: '3' });
|
||||
assert.ok(
|
||||
result.stderr.includes('3 tool calls reached') || result.stderr.includes('consider /compact'),
|
||||
`Should suggest compact at threshold. Got stderr: ${result.stderr}`
|
||||
);
|
||||
cleanupCounter();
|
||||
cleanup();
|
||||
})) passed++;
|
||||
else failed++;
|
||||
|
||||
if (test('does NOT suggest compact before threshold', () => {
|
||||
cleanupCounter();
|
||||
runCompact({ CLAUDE_SESSION_ID: testSession, COMPACT_THRESHOLD: '5' });
|
||||
const result = runCompact({ CLAUDE_SESSION_ID: testSession, COMPACT_THRESHOLD: '5' });
|
||||
const { sessionId, cleanup } = createCounterContext();
|
||||
cleanup();
|
||||
runCompact({ CLAUDE_SESSION_ID: sessionId, COMPACT_THRESHOLD: '5' });
|
||||
const result = runCompact({ CLAUDE_SESSION_ID: sessionId, COMPACT_THRESHOLD: '5' });
|
||||
assert.ok(
|
||||
!result.stderr.includes('StrategicCompact'),
|
||||
'Should NOT suggest compact before threshold'
|
||||
);
|
||||
cleanupCounter();
|
||||
cleanup();
|
||||
})) passed++;
|
||||
else failed++;
|
||||
|
||||
@@ -131,18 +142,19 @@ function runTests() {
|
||||
console.log('\nInterval suggestion:');
|
||||
|
||||
if (test('suggests at threshold + 25 interval', () => {
|
||||
cleanupCounter();
|
||||
const { sessionId, counterFile, cleanup } = createCounterContext();
|
||||
cleanup();
|
||||
// Set counter to threshold+24 (so next run = threshold+25)
|
||||
// threshold=3, so we need count=28 → 25 calls past threshold
|
||||
// Write 27 to the counter file, next run will be 28 = 3 + 25
|
||||
fs.writeFileSync(counterFile, '27');
|
||||
const result = runCompact({ CLAUDE_SESSION_ID: testSession, COMPACT_THRESHOLD: '3' });
|
||||
const result = runCompact({ CLAUDE_SESSION_ID: sessionId, COMPACT_THRESHOLD: '3' });
|
||||
// count=28, threshold=3, 28-3=25, 25 % 25 === 0 → should suggest
|
||||
assert.ok(
|
||||
result.stderr.includes('28 tool calls') || result.stderr.includes('checkpoint'),
|
||||
`Should suggest at threshold+25 interval. Got stderr: ${result.stderr}`
|
||||
);
|
||||
cleanupCounter();
|
||||
cleanup();
|
||||
})) passed++;
|
||||
else failed++;
|
||||
|
||||
@@ -150,42 +162,45 @@ function runTests() {
|
||||
console.log('\nEnvironment variable handling:');
|
||||
|
||||
if (test('uses default threshold (50) when COMPACT_THRESHOLD is not set', () => {
|
||||
cleanupCounter();
|
||||
const { sessionId, counterFile, cleanup } = createCounterContext();
|
||||
cleanup();
|
||||
// Write counter to 49, next run will be 50 = default threshold
|
||||
fs.writeFileSync(counterFile, '49');
|
||||
const result = runCompact({ CLAUDE_SESSION_ID: testSession });
|
||||
const result = runCompact({ CLAUDE_SESSION_ID: sessionId });
|
||||
// Remove COMPACT_THRESHOLD from env
|
||||
assert.ok(
|
||||
result.stderr.includes('50 tool calls reached'),
|
||||
`Should use default threshold of 50. Got stderr: ${result.stderr}`
|
||||
);
|
||||
cleanupCounter();
|
||||
cleanup();
|
||||
})) passed++;
|
||||
else failed++;
|
||||
|
||||
if (test('ignores invalid COMPACT_THRESHOLD (negative)', () => {
|
||||
cleanupCounter();
|
||||
const { sessionId, counterFile, cleanup } = createCounterContext();
|
||||
cleanup();
|
||||
fs.writeFileSync(counterFile, '49');
|
||||
const result = runCompact({ CLAUDE_SESSION_ID: testSession, COMPACT_THRESHOLD: '-5' });
|
||||
const result = runCompact({ CLAUDE_SESSION_ID: sessionId, COMPACT_THRESHOLD: '-5' });
|
||||
// Invalid threshold falls back to 50
|
||||
assert.ok(
|
||||
result.stderr.includes('50 tool calls reached'),
|
||||
`Should fallback to 50 for negative threshold. Got stderr: ${result.stderr}`
|
||||
);
|
||||
cleanupCounter();
|
||||
cleanup();
|
||||
})) passed++;
|
||||
else failed++;
|
||||
|
||||
if (test('ignores non-numeric COMPACT_THRESHOLD', () => {
|
||||
cleanupCounter();
|
||||
const { sessionId, counterFile, cleanup } = createCounterContext();
|
||||
cleanup();
|
||||
fs.writeFileSync(counterFile, '49');
|
||||
const result = runCompact({ CLAUDE_SESSION_ID: testSession, COMPACT_THRESHOLD: 'abc' });
|
||||
const result = runCompact({ CLAUDE_SESSION_ID: sessionId, COMPACT_THRESHOLD: 'abc' });
|
||||
// NaN falls back to 50
|
||||
assert.ok(
|
||||
result.stderr.includes('50 tool calls reached'),
|
||||
`Should fallback to 50 for non-numeric threshold. Got stderr: ${result.stderr}`
|
||||
);
|
||||
cleanupCounter();
|
||||
cleanup();
|
||||
})) passed++;
|
||||
else failed++;
|
||||
|
||||
@@ -193,38 +208,41 @@ function runTests() {
|
||||
console.log('\nCorrupted counter file:');
|
||||
|
||||
if (test('resets counter on corrupted file content', () => {
|
||||
cleanupCounter();
|
||||
const { sessionId, counterFile, cleanup } = createCounterContext();
|
||||
cleanup();
|
||||
fs.writeFileSync(counterFile, 'not-a-number');
|
||||
const result = runCompact({ CLAUDE_SESSION_ID: testSession });
|
||||
const result = runCompact({ CLAUDE_SESSION_ID: sessionId });
|
||||
assert.strictEqual(result.code, 0);
|
||||
// Corrupted file → parsed is NaN → falls back to count=1
|
||||
const count = parseInt(fs.readFileSync(counterFile, 'utf8').trim(), 10);
|
||||
assert.strictEqual(count, 1, 'Should reset to 1 on corrupted file');
|
||||
cleanupCounter();
|
||||
cleanup();
|
||||
})) passed++;
|
||||
else failed++;
|
||||
|
||||
if (test('resets counter on extremely large value', () => {
|
||||
cleanupCounter();
|
||||
const { sessionId, counterFile, cleanup } = createCounterContext();
|
||||
cleanup();
|
||||
// Value > 1000000 should be clamped
|
||||
fs.writeFileSync(counterFile, '9999999');
|
||||
const result = runCompact({ CLAUDE_SESSION_ID: testSession });
|
||||
const result = runCompact({ CLAUDE_SESSION_ID: sessionId });
|
||||
assert.strictEqual(result.code, 0);
|
||||
const count = parseInt(fs.readFileSync(counterFile, 'utf8').trim(), 10);
|
||||
assert.strictEqual(count, 1, 'Should reset to 1 for value > 1000000');
|
||||
cleanupCounter();
|
||||
cleanup();
|
||||
})) passed++;
|
||||
else failed++;
|
||||
|
||||
if (test('handles empty counter file', () => {
|
||||
cleanupCounter();
|
||||
const { sessionId, counterFile, cleanup } = createCounterContext();
|
||||
cleanup();
|
||||
fs.writeFileSync(counterFile, '');
|
||||
const result = runCompact({ CLAUDE_SESSION_ID: testSession });
|
||||
const result = runCompact({ CLAUDE_SESSION_ID: sessionId });
|
||||
assert.strictEqual(result.code, 0);
|
||||
// Empty file → bytesRead=0 → count starts at 1
|
||||
const count = parseInt(fs.readFileSync(counterFile, 'utf8').trim(), 10);
|
||||
assert.strictEqual(count, 1, 'Should start at 1 for empty file');
|
||||
cleanupCounter();
|
||||
cleanup();
|
||||
})) passed++;
|
||||
else failed++;
|
||||
|
||||
@@ -255,10 +273,11 @@ function runTests() {
|
||||
console.log('\nExit code:');
|
||||
|
||||
if (test('always exits 0 (never blocks Claude)', () => {
|
||||
cleanupCounter();
|
||||
const result = runCompact({ CLAUDE_SESSION_ID: testSession });
|
||||
const { sessionId, cleanup } = createCounterContext();
|
||||
cleanup();
|
||||
const result = runCompact({ CLAUDE_SESSION_ID: sessionId });
|
||||
assert.strictEqual(result.code, 0, 'Should always exit 0');
|
||||
cleanupCounter();
|
||||
cleanup();
|
||||
})) passed++;
|
||||
else failed++;
|
||||
|
||||
@@ -266,48 +285,52 @@ function runTests() {
|
||||
console.log('\nThreshold boundary values:');
|
||||
|
||||
if (test('rejects COMPACT_THRESHOLD=0 (falls back to 50)', () => {
|
||||
cleanupCounter();
|
||||
const { sessionId, counterFile, cleanup } = createCounterContext();
|
||||
cleanup();
|
||||
fs.writeFileSync(counterFile, '49');
|
||||
const result = runCompact({ CLAUDE_SESSION_ID: testSession, COMPACT_THRESHOLD: '0' });
|
||||
const result = runCompact({ CLAUDE_SESSION_ID: sessionId, COMPACT_THRESHOLD: '0' });
|
||||
// 0 is invalid (must be > 0), falls back to 50, count becomes 50 → should suggest
|
||||
assert.ok(
|
||||
result.stderr.includes('50 tool calls reached'),
|
||||
`Should fallback to 50 for threshold=0. Got stderr: ${result.stderr}`
|
||||
);
|
||||
cleanupCounter();
|
||||
cleanup();
|
||||
})) passed++;
|
||||
else failed++;
|
||||
|
||||
if (test('accepts COMPACT_THRESHOLD=10000 (boundary max)', () => {
|
||||
cleanupCounter();
|
||||
const { sessionId, counterFile, cleanup } = createCounterContext();
|
||||
cleanup();
|
||||
fs.writeFileSync(counterFile, '9999');
|
||||
const result = runCompact({ CLAUDE_SESSION_ID: testSession, COMPACT_THRESHOLD: '10000' });
|
||||
const result = runCompact({ CLAUDE_SESSION_ID: sessionId, COMPACT_THRESHOLD: '10000' });
|
||||
// count becomes 10000, threshold=10000 → should suggest
|
||||
assert.ok(
|
||||
result.stderr.includes('10000 tool calls reached'),
|
||||
`Should accept threshold=10000. Got stderr: ${result.stderr}`
|
||||
);
|
||||
cleanupCounter();
|
||||
cleanup();
|
||||
})) passed++;
|
||||
else failed++;
|
||||
|
||||
if (test('rejects COMPACT_THRESHOLD=10001 (falls back to 50)', () => {
|
||||
cleanupCounter();
|
||||
const { sessionId, counterFile, cleanup } = createCounterContext();
|
||||
cleanup();
|
||||
fs.writeFileSync(counterFile, '49');
|
||||
const result = runCompact({ CLAUDE_SESSION_ID: testSession, COMPACT_THRESHOLD: '10001' });
|
||||
const result = runCompact({ CLAUDE_SESSION_ID: sessionId, COMPACT_THRESHOLD: '10001' });
|
||||
// 10001 > 10000, invalid, falls back to 50, count becomes 50 → should suggest
|
||||
assert.ok(
|
||||
result.stderr.includes('50 tool calls reached'),
|
||||
`Should fallback to 50 for threshold=10001. Got stderr: ${result.stderr}`
|
||||
);
|
||||
cleanupCounter();
|
||||
cleanup();
|
||||
})) passed++;
|
||||
else failed++;
|
||||
|
||||
if (test('rejects float COMPACT_THRESHOLD (e.g. 3.5)', () => {
|
||||
cleanupCounter();
|
||||
const { sessionId, counterFile, cleanup } = createCounterContext();
|
||||
cleanup();
|
||||
fs.writeFileSync(counterFile, '49');
|
||||
const result = runCompact({ CLAUDE_SESSION_ID: testSession, COMPACT_THRESHOLD: '3.5' });
|
||||
const result = runCompact({ CLAUDE_SESSION_ID: sessionId, COMPACT_THRESHOLD: '3.5' });
|
||||
// parseInt('3.5') = 3, which is valid (> 0 && <= 10000)
|
||||
// count becomes 50, threshold=3, 50-3=47, 47%25≠0 and 50≠3 → no suggestion
|
||||
assert.strictEqual(result.code, 0);
|
||||
@@ -316,28 +339,30 @@ function runTests() {
|
||||
!result.stderr.includes('StrategicCompact'),
|
||||
'Float threshold should be parseInt-ed to 3, no suggestion at count=50'
|
||||
);
|
||||
cleanupCounter();
|
||||
cleanup();
|
||||
})) passed++;
|
||||
else failed++;
|
||||
|
||||
if (test('counter value at exact boundary 1000000 is valid', () => {
|
||||
cleanupCounter();
|
||||
const { sessionId, counterFile, cleanup } = createCounterContext();
|
||||
cleanup();
|
||||
fs.writeFileSync(counterFile, '999999');
|
||||
runCompact({ CLAUDE_SESSION_ID: testSession, COMPACT_THRESHOLD: '3' });
|
||||
runCompact({ CLAUDE_SESSION_ID: sessionId, COMPACT_THRESHOLD: '3' });
|
||||
// 999999 is valid (> 0, <= 1000000), count becomes 1000000
|
||||
const count = parseInt(fs.readFileSync(counterFile, 'utf8').trim(), 10);
|
||||
assert.strictEqual(count, 1000000, 'Counter at 1000000 boundary should be valid');
|
||||
cleanupCounter();
|
||||
cleanup();
|
||||
})) passed++;
|
||||
else failed++;
|
||||
|
||||
if (test('counter value at 1000001 is clamped (reset to 1)', () => {
|
||||
cleanupCounter();
|
||||
const { sessionId, counterFile, cleanup } = createCounterContext();
|
||||
cleanup();
|
||||
fs.writeFileSync(counterFile, '1000001');
|
||||
runCompact({ CLAUDE_SESSION_ID: testSession });
|
||||
runCompact({ CLAUDE_SESSION_ID: sessionId });
|
||||
const count = parseInt(fs.readFileSync(counterFile, 'utf8').trim(), 10);
|
||||
assert.strictEqual(count, 1, 'Counter > 1000000 should be reset to 1');
|
||||
cleanupCounter();
|
||||
cleanup();
|
||||
})) passed++;
|
||||
else failed++;
|
||||
|
||||
|
||||
@@ -90,6 +90,14 @@ function runHookWithInput(scriptPath, input = {}, env = {}, timeoutMs = 10000) {
|
||||
});
|
||||
}
|
||||
|
||||
function getSessionStartPayload(stdout) {
|
||||
assert.ok(stdout.trim(), 'Expected SessionStart hook to emit stdout payload');
|
||||
const payload = JSON.parse(stdout);
|
||||
assert.strictEqual(payload.hookSpecificOutput?.hookEventName, 'SessionStart');
|
||||
assert.strictEqual(typeof payload.hookSpecificOutput?.additionalContext, 'string');
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a hook command string exactly as declared in hooks.json.
|
||||
* Supports wrapped node script commands and shell wrappers.
|
||||
@@ -249,11 +257,14 @@ async function runTests() {
|
||||
// ==========================================
|
||||
console.log('\nHook Output Format:');
|
||||
|
||||
if (await asyncTest('hooks output messages to stderr (not stdout)', async () => {
|
||||
if (await asyncTest('session-start logs diagnostics to stderr and emits structured stdout when context exists', async () => {
|
||||
const result = await runHookWithInput(path.join(scriptsDir, 'session-start.js'), {});
|
||||
// Session-start should write info to stderr
|
||||
assert.ok(result.stderr.length > 0, 'Should have stderr output');
|
||||
assert.ok(result.stderr.includes('[SessionStart]'), 'Should have [SessionStart] prefix');
|
||||
const payload = getSessionStartPayload(result.stdout);
|
||||
assert.ok(payload.hookSpecificOutput, 'Should include hookSpecificOutput');
|
||||
assert.strictEqual(payload.hookSpecificOutput.hookEventName, 'SessionStart');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await asyncTest('PreCompact hook logs to stderr', async () => {
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
* Covers the ECC root resolution fallback chain:
|
||||
* 1. CLAUDE_PLUGIN_ROOT env var
|
||||
* 2. Standard install (~/.claude/)
|
||||
* 3. Plugin cache auto-detection
|
||||
* 4. Fallback to ~/.claude/
|
||||
* 3. Exact legacy plugin roots under ~/.claude/plugins/
|
||||
* 4. Plugin cache auto-detection
|
||||
* 5. Fallback to ~/.claude/
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
@@ -39,6 +40,13 @@ function setupStandardInstall(homeDir) {
|
||||
return claudeDir;
|
||||
}
|
||||
|
||||
function setupLegacyPluginInstall(homeDir, segments) {
|
||||
const legacyDir = path.join(homeDir, '.claude', 'plugins', ...segments);
|
||||
const scriptDir = path.join(legacyDir, 'scripts', 'lib');
|
||||
fs.mkdirSync(scriptDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(scriptDir, 'utils.js'), '// stub');
|
||||
return legacyDir;
|
||||
}
|
||||
function setupPluginCache(homeDir, orgName, version) {
|
||||
const cacheDir = path.join(
|
||||
homeDir, '.claude', 'plugins', 'cache',
|
||||
@@ -103,6 +111,50 @@ function runTests() {
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('finds exact legacy plugin install at ~/.claude/plugins/everything-claude-code', () => {
|
||||
const homeDir = createTempDir();
|
||||
try {
|
||||
const expected = setupLegacyPluginInstall(homeDir, ['everything-claude-code']);
|
||||
const result = resolveEccRoot({ envRoot: '', homeDir });
|
||||
assert.strictEqual(result, expected);
|
||||
} finally {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('finds exact legacy plugin install at ~/.claude/plugins/everything-claude-code@everything-claude-code', () => {
|
||||
const homeDir = createTempDir();
|
||||
try {
|
||||
const expected = setupLegacyPluginInstall(homeDir, ['everything-claude-code@everything-claude-code']);
|
||||
const result = resolveEccRoot({ envRoot: '', homeDir });
|
||||
assert.strictEqual(result, expected);
|
||||
} finally {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('finds marketplace legacy plugin install at ~/.claude/plugins/marketplace/everything-claude-code', () => {
|
||||
const homeDir = createTempDir();
|
||||
try {
|
||||
const expected = setupLegacyPluginInstall(homeDir, ['marketplace', 'everything-claude-code']);
|
||||
const result = resolveEccRoot({ envRoot: '', homeDir });
|
||||
assert.strictEqual(result, expected);
|
||||
} finally {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('prefers exact legacy plugin install over plugin cache', () => {
|
||||
const homeDir = createTempDir();
|
||||
try {
|
||||
const expected = setupLegacyPluginInstall(homeDir, ['marketplace', 'everything-claude-code']);
|
||||
setupPluginCache(homeDir, 'everything-claude-code', '1.8.0');
|
||||
const result = resolveEccRoot({ envRoot: '', homeDir });
|
||||
assert.strictEqual(result, expected);
|
||||
} finally {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
// ─── Plugin Cache Auto-Detection ───
|
||||
|
||||
if (test('discovers plugin root from cache directory', () => {
|
||||
@@ -207,6 +259,22 @@ function runTests() {
|
||||
assert.strictEqual(result, '/inline/test/root');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('INLINE_RESOLVE discovers exact legacy plugin root when env var is unset', () => {
|
||||
const homeDir = createTempDir();
|
||||
try {
|
||||
const expected = setupLegacyPluginInstall(homeDir, ['marketplace', 'everything-claude-code']);
|
||||
const { execFileSync } = require('child_process');
|
||||
const result = execFileSync('node', [
|
||||
'-e', `console.log(${INLINE_RESOLVE})`,
|
||||
], {
|
||||
env: { PATH: process.env.PATH, HOME: homeDir, USERPROFILE: homeDir },
|
||||
encoding: 'utf8',
|
||||
}).trim();
|
||||
assert.strictEqual(result, expected);
|
||||
} finally {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
if (test('INLINE_RESOLVE discovers plugin cache when env var is unset', () => {
|
||||
const homeDir = createTempDir();
|
||||
try {
|
||||
|
||||
@@ -341,8 +341,10 @@ src/main.ts
|
||||
// Override HOME to a temp dir for isolated getAllSessions/getSessionById tests
|
||||
// On Windows, os.homedir() uses USERPROFILE, not HOME — set both for cross-platform
|
||||
const tmpHome = path.join(os.tmpdir(), `ecc-session-mgr-test-${Date.now()}`);
|
||||
const tmpSessionsDir = path.join(tmpHome, '.claude', 'sessions');
|
||||
fs.mkdirSync(tmpSessionsDir, { recursive: true });
|
||||
const tmpCanonicalSessionsDir = path.join(tmpHome, '.claude', 'session-data');
|
||||
const tmpLegacySessionsDir = path.join(tmpHome, '.claude', 'sessions');
|
||||
fs.mkdirSync(tmpCanonicalSessionsDir, { recursive: true });
|
||||
fs.mkdirSync(tmpLegacySessionsDir, { recursive: true });
|
||||
const origHome = process.env.HOME;
|
||||
const origUserProfile = process.env.USERPROFILE;
|
||||
|
||||
@@ -355,7 +357,10 @@ src/main.ts
|
||||
{ name: '2026-02-10-session.tmp', content: '# Old format session' },
|
||||
];
|
||||
for (let i = 0; i < testSessions.length; i++) {
|
||||
const filePath = path.join(tmpSessionsDir, testSessions[i].name);
|
||||
const targetDir = testSessions[i].name === '2026-02-10-session.tmp'
|
||||
? tmpLegacySessionsDir
|
||||
: tmpCanonicalSessionsDir;
|
||||
const filePath = path.join(targetDir, testSessions[i].name);
|
||||
fs.writeFileSync(filePath, testSessions[i].content);
|
||||
// Stagger modification times so sort order is deterministic
|
||||
const mtime = new Date(Date.now() - (testSessions.length - i) * 60000);
|
||||
@@ -399,6 +404,23 @@ src/main.ts
|
||||
assert.strictEqual(result.sessions[0].shortId, 'abcd1234');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('getAllSessions prefers canonical session-data duplicates over newer legacy copies', () => {
|
||||
const duplicateName = '2026-01-15-abcd1234-session.tmp';
|
||||
const legacyDuplicatePath = path.join(tmpLegacySessionsDir, duplicateName);
|
||||
const legacyMtime = new Date(Date.now() + 60000);
|
||||
|
||||
try {
|
||||
fs.writeFileSync(legacyDuplicatePath, '# Legacy duplicate');
|
||||
fs.utimesSync(legacyDuplicatePath, legacyMtime, legacyMtime);
|
||||
|
||||
const result = sessionManager.getAllSessions({ search: 'abcd', limit: 100 });
|
||||
assert.strictEqual(result.total, 1, 'Duplicate filenames should be deduped');
|
||||
assert.ok(result.sessions[0].sessionPath.includes('session-data'), 'Canonical session-data copy should win');
|
||||
} finally {
|
||||
fs.rmSync(legacyDuplicatePath, { force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('getAllSessions returns sorted by newest first', () => {
|
||||
const result = sessionManager.getAllSessions({ limit: 100 });
|
||||
for (let i = 1; i < result.sessions.length; i++) {
|
||||
@@ -423,8 +445,8 @@ src/main.ts
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('getAllSessions ignores non-.tmp files', () => {
|
||||
fs.writeFileSync(path.join(tmpSessionsDir, 'notes.txt'), 'not a session');
|
||||
fs.writeFileSync(path.join(tmpSessionsDir, 'compaction-log.txt'), 'log');
|
||||
fs.writeFileSync(path.join(tmpCanonicalSessionsDir, 'notes.txt'), 'not a session');
|
||||
fs.writeFileSync(path.join(tmpCanonicalSessionsDir, 'compaction-log.txt'), 'log');
|
||||
const result = sessionManager.getAllSessions({ limit: 100 });
|
||||
assert.strictEqual(result.total, 5, 'Should only count .tmp session files');
|
||||
})) passed++; else failed++;
|
||||
@@ -444,6 +466,23 @@ src/main.ts
|
||||
assert.strictEqual(result.shortId, 'abcd1234');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('getSessionById prefers canonical session-data duplicates over newer legacy copies', () => {
|
||||
const duplicateName = '2026-01-15-abcd1234-session.tmp';
|
||||
const legacyDuplicatePath = path.join(tmpLegacySessionsDir, duplicateName);
|
||||
const legacyMtime = new Date(Date.now() + 120000);
|
||||
|
||||
try {
|
||||
fs.writeFileSync(legacyDuplicatePath, '# Legacy duplicate');
|
||||
fs.utimesSync(legacyDuplicatePath, legacyMtime, legacyMtime);
|
||||
|
||||
const result = sessionManager.getSessionById('abcd1234');
|
||||
assert.ok(result, 'Should still resolve the duplicate session');
|
||||
assert.ok(result.sessionPath.includes('session-data'), 'Canonical session-data copy should win');
|
||||
} finally {
|
||||
fs.rmSync(legacyDuplicatePath, { force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('getSessionById finds by full filename', () => {
|
||||
const result = sessionManager.getSessionById('2026-01-15-abcd1234-session.tmp');
|
||||
assert.ok(result, 'Should find session by full filename');
|
||||
@@ -477,6 +516,12 @@ src/main.ts
|
||||
assert.strictEqual(result, null, 'Empty string should not match any session');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('getSessionById returns null for non-string IDs', () => {
|
||||
assert.strictEqual(sessionManager.getSessionById(null), null);
|
||||
assert.strictEqual(sessionManager.getSessionById(undefined), null);
|
||||
assert.strictEqual(sessionManager.getSessionById(42), null);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('getSessionById metadata and stats populated when includeContent=true', () => {
|
||||
const result = sessionManager.getSessionById('abcd1234', true);
|
||||
assert.ok(result, 'Should find session');
|
||||
@@ -990,7 +1035,7 @@ src/main.ts
|
||||
assert.ok(result.endsWith(filename), `Path should end with filename, got: ${result}`);
|
||||
// Since HOME is overridden, sessions dir should be under tmpHome
|
||||
assert.ok(result.includes('.claude'), 'Path should include .claude directory');
|
||||
assert.ok(result.includes('sessions'), 'Path should include sessions directory');
|
||||
assert.ok(result.includes('session-data'), 'Path should use canonical session-data directory');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 66: getSessionById noIdMatch path (date-only string for old format) ──
|
||||
@@ -1601,18 +1646,13 @@ src/main.ts
|
||||
'Null search should return sessions (confirming they exist but space filtered them)');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 98: getSessionById with null sessionId throws TypeError ──
|
||||
console.log('\nRound 98: getSessionById (null sessionId — crashes at line 297):');
|
||||
// ── Round 98: getSessionById with null sessionId returns null ──
|
||||
console.log('\nRound 98: getSessionById (null sessionId — guarded null return):');
|
||||
|
||||
if (test('getSessionById(null) throws TypeError when session files exist', () => {
|
||||
// session-manager.js line 297: `sessionId.length > 0` — calling .length on null
|
||||
// throws TypeError because there's no early guard for null/undefined input.
|
||||
// This only surfaces when valid .tmp files exist in the sessions directory.
|
||||
assert.throws(
|
||||
() => sessionManager.getSessionById(null),
|
||||
{ name: 'TypeError' },
|
||||
'null.length should throw TypeError (no input guard at function entry)'
|
||||
);
|
||||
if (test('getSessionById(null) returns null when session files exist', () => {
|
||||
// Keep a populated sessions directory so the early input guard is exercised even when
|
||||
// candidate files are present.
|
||||
assert.strictEqual(sessionManager.getSessionById(null), null);
|
||||
})) passed++; else failed++;
|
||||
|
||||
// Cleanup test environment for Rounds 95-98 that needed sessions
|
||||
@@ -1629,18 +1669,13 @@ src/main.ts
|
||||
// best-effort
|
||||
}
|
||||
|
||||
// ── Round 98: parseSessionFilename with null input throws TypeError ──
|
||||
console.log('\nRound 98: parseSessionFilename (null input — crashes at line 30):');
|
||||
// ── Round 98: parseSessionFilename with null input returns null ──
|
||||
console.log('\nRound 98: parseSessionFilename (null input is safely rejected):');
|
||||
|
||||
if (test('parseSessionFilename(null) throws TypeError because null has no .match()', () => {
|
||||
// session-manager.js line 30: `filename.match(SESSION_FILENAME_REGEX)`
|
||||
// When filename is null, null.match() throws TypeError.
|
||||
// Function lacks a type guard like `if (!filename || typeof filename !== 'string')`.
|
||||
assert.throws(
|
||||
() => sessionManager.parseSessionFilename(null),
|
||||
{ name: 'TypeError' },
|
||||
'null.match() should throw TypeError (no type guard on filename parameter)'
|
||||
);
|
||||
if (test('parseSessionFilename(null) returns null instead of throwing', () => {
|
||||
assert.strictEqual(sessionManager.parseSessionFilename(null), null);
|
||||
assert.strictEqual(sessionManager.parseSessionFilename(undefined), null);
|
||||
assert.strictEqual(sessionManager.parseSessionFilename(123), null);
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 99: writeSessionContent with null path returns false (error caught) ──
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
const assert = require('assert');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
// Import the module
|
||||
const utils = require('../../scripts/lib/utils');
|
||||
@@ -68,7 +69,13 @@ function runTests() {
|
||||
const sessionsDir = utils.getSessionsDir();
|
||||
const claudeDir = utils.getClaudeDir();
|
||||
assert.ok(sessionsDir.startsWith(claudeDir), 'Sessions should be under Claude dir');
|
||||
assert.ok(sessionsDir.includes('sessions'), 'Should contain sessions');
|
||||
assert.ok(sessionsDir.endsWith(path.join('.claude', 'session-data')) || sessionsDir.endsWith('/.claude/session-data'), 'Should use canonical session-data directory');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('getSessionSearchDirs includes canonical and legacy paths', () => {
|
||||
const searchDirs = utils.getSessionSearchDirs();
|
||||
assert.strictEqual(searchDirs[0], utils.getSessionsDir(), 'Canonical session dir should be searched first');
|
||||
assert.strictEqual(searchDirs[1], utils.getLegacySessionsDir(), 'Legacy session dir should be searched second');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('getTempDir returns valid temp directory', () => {
|
||||
@@ -118,17 +125,94 @@ function runTests() {
|
||||
assert.ok(name && name.length > 0);
|
||||
})) passed++; else failed++;
|
||||
|
||||
// sanitizeSessionId tests
|
||||
console.log('\nsanitizeSessionId:');
|
||||
|
||||
if (test('sanitizeSessionId strips leading dots', () => {
|
||||
assert.strictEqual(utils.sanitizeSessionId('.claude'), 'claude');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('sanitizeSessionId replaces dots and spaces', () => {
|
||||
assert.strictEqual(utils.sanitizeSessionId('my.project'), 'my-project');
|
||||
assert.strictEqual(utils.sanitizeSessionId('my project'), 'my-project');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('sanitizeSessionId replaces special chars and collapses runs', () => {
|
||||
assert.strictEqual(utils.sanitizeSessionId('project@v2'), 'project-v2');
|
||||
assert.strictEqual(utils.sanitizeSessionId('a...b'), 'a-b');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('sanitizeSessionId preserves valid chars', () => {
|
||||
assert.strictEqual(utils.sanitizeSessionId('my-project_123'), 'my-project_123');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('sanitizeSessionId appends hash suffix for all Windows reserved device names', () => {
|
||||
for (const reservedName of ['CON', 'prn', 'Aux', 'nul', 'COM1', 'lpt9']) {
|
||||
const sanitized = utils.sanitizeSessionId(reservedName);
|
||||
assert.ok(sanitized, `Expected sanitized output for ${reservedName}`);
|
||||
assert.notStrictEqual(sanitized.toUpperCase(), reservedName.toUpperCase());
|
||||
assert.ok(/-[a-f0-9]{6}$/i.test(sanitized), `Expected deterministic hash suffix for ${reservedName}, got ${sanitized}`);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('sanitizeSessionId returns null for empty or punctuation-only values', () => {
|
||||
assert.strictEqual(utils.sanitizeSessionId(''), null);
|
||||
assert.strictEqual(utils.sanitizeSessionId(null), null);
|
||||
assert.strictEqual(utils.sanitizeSessionId(undefined), null);
|
||||
assert.strictEqual(utils.sanitizeSessionId('...'), null);
|
||||
assert.strictEqual(utils.sanitizeSessionId('…'), null);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('sanitizeSessionId returns stable hashes for non-ASCII values', () => {
|
||||
const chinese = utils.sanitizeSessionId('我的项目');
|
||||
const cyrillic = utils.sanitizeSessionId('проект');
|
||||
const emoji = utils.sanitizeSessionId('🚀🎉');
|
||||
assert.ok(/^[a-f0-9]{8}$/.test(chinese), `Expected 8-char hash, got: ${chinese}`);
|
||||
assert.ok(/^[a-f0-9]{8}$/.test(cyrillic), `Expected 8-char hash, got: ${cyrillic}`);
|
||||
assert.ok(/^[a-f0-9]{8}$/.test(emoji), `Expected 8-char hash, got: ${emoji}`);
|
||||
assert.notStrictEqual(chinese, cyrillic);
|
||||
assert.notStrictEqual(chinese, emoji);
|
||||
assert.strictEqual(utils.sanitizeSessionId('日本語プロジェクト'), utils.sanitizeSessionId('日本語プロジェクト'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('sanitizeSessionId disambiguates mixed-script names from pure ASCII', () => {
|
||||
const mixed = utils.sanitizeSessionId('我的app');
|
||||
const mixedTwo = utils.sanitizeSessionId('他的app');
|
||||
const pure = utils.sanitizeSessionId('app');
|
||||
assert.strictEqual(pure, 'app');
|
||||
assert.ok(mixed.startsWith('app-'), `Expected mixed-script prefix, got: ${mixed}`);
|
||||
assert.notStrictEqual(mixed, pure);
|
||||
assert.notStrictEqual(mixed, mixedTwo);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('sanitizeSessionId is idempotent', () => {
|
||||
for (const input of ['.claude', 'my.project', 'project@v2', 'a...b', 'my-project_123']) {
|
||||
const once = utils.sanitizeSessionId(input);
|
||||
const twice = utils.sanitizeSessionId(once);
|
||||
assert.strictEqual(once, twice, `Expected idempotent result for ${input}`);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('sanitizeSessionId preserves readable prefixes for Windows reserved device names', () => {
|
||||
const con = utils.sanitizeSessionId('CON');
|
||||
const aux = utils.sanitizeSessionId('aux');
|
||||
assert.ok(con.startsWith('CON-'), `Expected CON to get a suffix, got: ${con}`);
|
||||
assert.ok(aux.startsWith('aux-'), `Expected aux to get a suffix, got: ${aux}`);
|
||||
assert.notStrictEqual(utils.sanitizeSessionId('COM1'), 'COM1');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// Session ID tests
|
||||
console.log('\nSession ID Functions:');
|
||||
|
||||
if (test('getSessionIdShort falls back to project name', () => {
|
||||
if (test('getSessionIdShort falls back to sanitized project name', () => {
|
||||
const original = process.env.CLAUDE_SESSION_ID;
|
||||
delete process.env.CLAUDE_SESSION_ID;
|
||||
try {
|
||||
const shortId = utils.getSessionIdShort();
|
||||
assert.strictEqual(shortId, utils.getProjectName());
|
||||
assert.strictEqual(shortId, utils.sanitizeSessionId(utils.getProjectName()));
|
||||
} finally {
|
||||
if (original) process.env.CLAUDE_SESSION_ID = original;
|
||||
if (original !== undefined) process.env.CLAUDE_SESSION_ID = original;
|
||||
else delete process.env.CLAUDE_SESSION_ID;
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
@@ -154,6 +238,28 @@ function runTests() {
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('getSessionIdShort sanitizes explicit fallback parameter', () => {
|
||||
if (process.platform === 'win32') {
|
||||
console.log(' (skipped — root CWD differs on Windows)');
|
||||
return true;
|
||||
}
|
||||
|
||||
const utilsPath = path.join(__dirname, '..', '..', 'scripts', 'lib', 'utils.js');
|
||||
const script = `
|
||||
const utils = require('${utilsPath.replace(/'/g, "\\'")}');
|
||||
process.stdout.write(utils.getSessionIdShort('my.fallback'));
|
||||
`;
|
||||
const result = spawnSync('node', ['-e', script], {
|
||||
encoding: 'utf8',
|
||||
cwd: '/',
|
||||
env: { ...process.env, CLAUDE_SESSION_ID: '' },
|
||||
timeout: 10000
|
||||
});
|
||||
|
||||
assert.strictEqual(result.status, 0, `Expected exit 0, got ${result.status}. stderr: ${result.stderr}`);
|
||||
assert.strictEqual(result.stdout, 'my-fallback');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// File operations tests
|
||||
console.log('\nFile Operations:');
|
||||
|
||||
@@ -1415,25 +1521,26 @@ function runTests() {
|
||||
// ── Round 97: getSessionIdShort with whitespace-only CLAUDE_SESSION_ID ──
|
||||
console.log('\nRound 97: getSessionIdShort (whitespace-only session ID):');
|
||||
|
||||
if (test('getSessionIdShort returns whitespace when CLAUDE_SESSION_ID is all spaces', () => {
|
||||
// utils.js line 116: if (sessionId && sessionId.length > 0) — ' ' is truthy
|
||||
// and has length > 0, so it passes the check instead of falling back.
|
||||
const original = process.env.CLAUDE_SESSION_ID;
|
||||
try {
|
||||
process.env.CLAUDE_SESSION_ID = ' '; // 10 spaces
|
||||
const result = utils.getSessionIdShort('fallback');
|
||||
// slice(-8) on 10 spaces returns 8 spaces — not the expected fallback
|
||||
assert.strictEqual(result, ' ',
|
||||
'Whitespace-only ID should return 8 trailing spaces (no trim check)');
|
||||
assert.strictEqual(result.trim().length, 0,
|
||||
'Result should be entirely whitespace (demonstrating the missing trim)');
|
||||
} finally {
|
||||
if (original !== undefined) {
|
||||
process.env.CLAUDE_SESSION_ID = original;
|
||||
} else {
|
||||
delete process.env.CLAUDE_SESSION_ID;
|
||||
}
|
||||
if (test('getSessionIdShort sanitizes whitespace-only CLAUDE_SESSION_ID to fallback', () => {
|
||||
if (process.platform === 'win32') {
|
||||
console.log(' (skipped — root CWD differs on Windows)');
|
||||
return true;
|
||||
}
|
||||
|
||||
const utilsPath = path.join(__dirname, '..', '..', 'scripts', 'lib', 'utils.js');
|
||||
const script = `
|
||||
const utils = require('${utilsPath.replace(/'/g, "\\'")}');
|
||||
process.stdout.write(utils.getSessionIdShort('fallback'));
|
||||
`;
|
||||
const result = spawnSync('node', ['-e', script], {
|
||||
encoding: 'utf8',
|
||||
cwd: '/',
|
||||
env: { ...process.env, CLAUDE_SESSION_ID: ' ' },
|
||||
timeout: 10000
|
||||
});
|
||||
|
||||
assert.strictEqual(result.status, 0, `Expected exit 0, got ${result.status}. stderr: ${result.stderr}`);
|
||||
assert.strictEqual(result.stdout, 'fallback');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 97: countInFile with same RegExp object called twice (lastIndex reuse) ──
|
||||
|
||||
94
tests/scripts/codex-hooks.test.js
Normal file
94
tests/scripts/codex-hooks.test.js
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Tests for Codex shell helpers.
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const repoRoot = path.join(__dirname, '..', '..');
|
||||
const installScript = path.join(repoRoot, 'scripts', 'codex', 'install-global-git-hooks.sh');
|
||||
const installSource = fs.readFileSync(installScript, 'utf8');
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` ✗ ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function createTempDir(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function cleanup(dirPath) {
|
||||
fs.rmSync(dirPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function toBashPath(filePath) {
|
||||
if (process.platform !== 'win32') {
|
||||
return filePath;
|
||||
}
|
||||
|
||||
return String(filePath)
|
||||
.replace(/^([A-Za-z]):/, (_, driveLetter) => `/${driveLetter.toLowerCase()}`)
|
||||
.replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
function runBash(scriptPath, args = [], env = {}, cwd = repoRoot) {
|
||||
return spawnSync('bash', [toBashPath(scriptPath), ...args], {
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
...env
|
||||
},
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
}
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (
|
||||
test('install-global-git-hooks.sh does not use eval and executes argv directly', () => {
|
||||
assert.ok(!installSource.includes('eval "$*"'), 'Expected installer to avoid eval');
|
||||
assert.ok(installSource.includes(' "$@"'), 'Expected installer to execute argv directly');
|
||||
assert.ok(installSource.includes(`printf ' %q' "$@"`), 'Expected dry-run logging to shell-escape argv');
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('install-global-git-hooks.sh handles shell-sensitive hook paths without shell injection', () => {
|
||||
const homeDir = createTempDir('codex-hooks-home-');
|
||||
const weirdHooksDir = path.join(homeDir, "git-hooks 'quoted' & spaced");
|
||||
|
||||
try {
|
||||
const result = runBash(installScript, [], {
|
||||
HOME: toBashPath(homeDir),
|
||||
ECC_GLOBAL_HOOKS_DIR: toBashPath(weirdHooksDir)
|
||||
});
|
||||
|
||||
assert.strictEqual(result.status, 0, result.stderr || result.stdout);
|
||||
assert.ok(fs.existsSync(path.join(weirdHooksDir, 'pre-commit')));
|
||||
assert.ok(fs.existsSync(path.join(weirdHooksDir, 'pre-push')));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
console.log(`\nPassed: ${passed}`);
|
||||
console.log(`Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
@@ -94,6 +94,7 @@ function runTests() {
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'rules', 'typescript', 'testing.md')));
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'commands', 'plan.md')));
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'scripts', 'hooks', 'session-end.js')));
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'scripts', 'lib', 'utils.js')));
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'skills', 'tdd-workflow', 'SKILL.md')));
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'skills', 'coding-standards', 'SKILL.md')));
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'plugin.json')));
|
||||
@@ -132,6 +133,7 @@ function runTests() {
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'commands', 'plan.md')));
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'hooks.json')));
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'hooks', 'session-start.js')));
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'scripts', 'lib', 'utils.js')));
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'skills', 'tdd-workflow', 'SKILL.md')));
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'skills', 'coding-standards', 'SKILL.md')));
|
||||
|
||||
@@ -239,6 +241,7 @@ function runTests() {
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'commands', 'plan.md')));
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'hooks', 'hooks.json')));
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'scripts', 'hooks', 'session-end.js')));
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'scripts', 'lib', 'session-manager.js')));
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'plugin.json')));
|
||||
|
||||
const state = readJson(path.join(claudeRoot, 'ecc', 'install-state.json'));
|
||||
|
||||
80
tests/scripts/sync-ecc-to-codex.test.js
Normal file
80
tests/scripts/sync-ecc-to-codex.test.js
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Source-level tests for scripts/sync-ecc-to-codex.sh
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const scriptPath = path.join(__dirname, '..', '..', 'scripts', 'sync-ecc-to-codex.sh');
|
||||
const source = fs.readFileSync(scriptPath, 'utf8');
|
||||
const normalizedSource = source.replace(/\r\n/g, '\n');
|
||||
const runOrEchoSource = (() => {
|
||||
const start = normalizedSource.indexOf('run_or_echo() {');
|
||||
if (start < 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let depth = 0;
|
||||
let bodyStart = normalizedSource.indexOf('{', start);
|
||||
if (bodyStart < 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
for (let i = bodyStart; i < normalizedSource.length; i++) {
|
||||
const char = normalizedSource[i];
|
||||
if (char === '{') {
|
||||
depth += 1;
|
||||
} else if (char === '}') {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
return normalizedSource.slice(start, i + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
})();
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` ✗ ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing sync-ecc-to-codex.sh ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (test('run_or_echo does not use eval', () => {
|
||||
assert.ok(runOrEchoSource, 'Expected to locate run_or_echo function body');
|
||||
assert.ok(!runOrEchoSource.includes('eval "$@"'), 'run_or_echo should not execute through eval');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('run_or_echo executes argv directly', () => {
|
||||
assert.ok(runOrEchoSource.includes(' "$@"'), 'run_or_echo should execute the argv vector directly');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('dry-run output shell-escapes argv', () => {
|
||||
assert.ok(runOrEchoSource.includes(`printf ' %q' "$@"`), 'Dry-run mode should print shell-escaped argv');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('filesystem-changing calls use argv-form run_or_echo invocations', () => {
|
||||
assert.ok(source.includes('run_or_echo mkdir -p "$BACKUP_DIR"'), 'mkdir should use argv form');
|
||||
assert.ok(source.includes('run_or_echo rm -rf "$dest"'), 'rm should use argv form');
|
||||
assert.ok(source.includes('run_or_echo cp -R "$skill_dir" "$dest"'), 'recursive copy should use argv form');
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
Reference in New Issue
Block a user