From 6cd41995f4f2d36f69a20545559ff0719d1871ba Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:43:55 -0700 Subject: [PATCH] fix(hooks): attribute native activity to the registered ECC session Verify review discussion4419348312 against actual native producer/importer behavior. Prefer the independently sanitized ECC_SESSION_ID for the ECC-owned metrics feed, retain payload then legacy fallback for standalone hooks, and leave sibling hooks unchanged. Actual Node producer plus read-only cached native binary reproduces zero imported metrics before the fix and exactly one tool/file/log after repeated sessions/status commands. Four producer regression controls and normal/adversarial inherited-env suites pass22/22. Focused coverage96.47percent lines and91.47percent branches; parent independent source review passed. --- scripts/hooks/session-activity-tracker.js | 18 +++++----- tests/hooks/session-activity-tracker.test.js | 38 +++++++++++++++++++- 2 files changed, 45 insertions(+), 11 deletions(-) diff --git a/scripts/hooks/session-activity-tracker.js b/scripts/hooks/session-activity-tracker.js index c73003b29..f605bada6 100644 --- a/scripts/hooks/session-activity-tracker.js +++ b/scripts/hooks/session-activity-tracker.js @@ -562,9 +562,8 @@ function summarizeOutput(toolOutput) { } function buildActivityRow(input, env = process.env) { - // Same env-var-reliance bug as the session id below: Claude Code doesn't - // set CLAUDE_HOOK_EVENT_NAME for hook subprocesses either. Real hook stdin - // payloads carry hook_event_name as a JSON field -- prefer that. + // Claude Code does not set CLAUDE_HOOK_EVENT_NAME for hook subprocesses. + // Real hook stdin payloads carry hook_event_name as a JSON field. const hookEvent = String( input?.hook_event_name || env.CLAUDE_HOOK_EVENT_NAME || '' ).trim(); @@ -573,15 +572,14 @@ function buildActivityRow(input, env = process.env) { } const toolName = String(input?.tool_name || '').trim(); - // Claude Code does not set CLAUDE_SESSION_ID as an environment variable for - // hook subprocesses -- the session id is only available in the hook's own - // stdin JSON payload. Prefer that; keep the env vars as a fallback for any - // caller that does set them. Sanitized the same way as the sibling hooks - // (cost-tracker.js, ecc-context-monitor.js, ecc-metrics-bridge.js, - // gateguard-fact-force.js) that already use this exact fallback order. + // Native ECC workers set ECC_SESSION_ID to their registered session ID. + // This feed belongs to that ECC session, even when the harness payload + // carries a different conversation ID. Standalone hooks use their payload + // ID before the legacy Claude environment fallback. Sanitize each candidate + // separately so an invalid native ID cannot hide a usable payload ID. const sessionId = - sanitizeSessionId(input?.session_id) || sanitizeSessionId(env.ECC_SESSION_ID) || + sanitizeSessionId(input?.session_id) || sanitizeSessionId(env.CLAUDE_SESSION_ID) || ''; if (!toolName || !sessionId) { diff --git a/tests/hooks/session-activity-tracker.test.js b/tests/hooks/session-activity-tracker.test.js index d00ea4295..82f08e277 100644 --- a/tests/hooks/session-activity-tracker.test.js +++ b/tests/hooks/session-activity-tracker.test.js @@ -60,7 +60,11 @@ function runScript(input, envOverrides = {}, options = {}) { encoding: 'utf8', input: inputStr, timeout: 10000, - env: { ...process.env, ...ISOLATED_HOME_ENV, ...envOverrides }, + env: { + ...process.env, ...ISOLATED_HOME_ENV, + ECC_SESSION_ID: '', CLAUDE_SESSION_ID: '', + ...envOverrides, + }, cwd: options.cwd, }); return { code: result.status || 0, stdout: result.stdout || '', stderr: result.stderr || '' }; @@ -414,6 +418,38 @@ function runTests() { fs.rmSync(tmpHome, { recursive: true, force: true }); }) ? passed++ : failed++); + for (const [label, eccId, payloadId, legacyId, expectedId] of [ + ['native ECC identity wins over the distinct harness payload', 'ecc-native-owner', 'claude-conversation', 'stale-parent', 'ecc-native-owner'], + ['standalone payload wins over a stale legacy environment', '', 'claude-standalone', 'stale-parent', 'claude-standalone'], + ['invalid ECC identity falls back to the payload', '../invalid-owner', 'claude-standalone', 'stale-parent', 'claude-standalone'], + ['invalid ECC and payload identities fall back to the legacy environment', 'bad/owner', 'bad\\payload', 'claude-legacy', 'claude-legacy'], + ]) { + (test(`actual activity producer: ${label}`, () => { + const tmpHome = makeTempDir(); + try { + const input = { + hook_event_name: 'PostToolUse', session_id: payloadId, + tool_name: 'Read', tool_input: { file_path: 'src/identity.ts' }, + }; + const result = runScript(input, { + ...withTempHome(tmpHome), + ECC_AGENT_DATA_HOME: path.join(tmpHome, '.claude'), + CLAUDE_CONFIG_DIR: path.join(tmpHome, '.claude'), + ECC_SESSION_ID: eccId, CLAUDE_SESSION_ID: legacyId, + }); + assert.strictEqual(result.code, 0, result.stderr); + assert.strictEqual(result.stdout, JSON.stringify(input)); + const rows = readMetricRows(tmpHome); + assert.strictEqual(rows.length, 1); + assert.strictEqual(rows[0].session_id, expectedId); + assert.strictEqual(rows[0].tool_name, 'Read'); + assert.deepStrictEqual(rows[0].file_paths, ['src/identity.ts']); + } finally { + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }) ? passed++ : failed++); + } + (test('reads session_id from stdin JSON when no session env var is set', () => { // Claude Code does not set CLAUDE_SESSION_ID for hook subprocesses in // real invocations -- the session id only arrives via stdin JSON.