mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-17 13:45:34 +02:00
Merge PR #3416: route hook state through Claude profiles
Preserve the explicit shared ~/.claude project root already shipped on main. Canonicalize macOS temporary workflow fixture paths.
This commit is contained in:
@@ -47,11 +47,29 @@ function envNumber(name, fallback) {
|
||||
return Number.isFinite(value) && value >= 0 ? value : fallback;
|
||||
}
|
||||
|
||||
// Resolve the Claude profile directory the same way everywhere.
|
||||
//
|
||||
// Duplicated rather than imported on purpose: hooks ship in managed installs
|
||||
// that carry top-level `scripts/` without `scripts/lib/` (see #3259), so a
|
||||
// require into lib/ would make the hook unloadable there. `~` is expanded the
|
||||
// way `scripts/lib/agent-data-home.js` expands it, so a profile written as
|
||||
// `~/.claude-work` resolves to one place rather than two.
|
||||
function claudeConfigDir() {
|
||||
const configured = process.env.CLAUDE_CONFIG_DIR;
|
||||
const trimmed = configured ? String(configured).trim() : '';
|
||||
if (!trimmed) return path.join(os.homedir(), '.claude');
|
||||
if (trimmed.startsWith('~')) {
|
||||
const remainder = trimmed.slice(1).replace(/^[/\\]+/, '');
|
||||
return remainder ? path.join(os.homedir(), remainder) : os.homedir();
|
||||
}
|
||||
return path.resolve(trimmed);
|
||||
}
|
||||
|
||||
function stateFilePath() {
|
||||
if (process.env.ECC_MCP_HEALTH_STATE_PATH) {
|
||||
return path.resolve(process.env.ECC_MCP_HEALTH_STATE_PATH);
|
||||
}
|
||||
return path.join(os.homedir(), '.claude', 'mcp-health-cache.json');
|
||||
return path.join(claudeConfigDir(), 'mcp-health-cache.json');
|
||||
}
|
||||
|
||||
function configPaths() {
|
||||
|
||||
@@ -34,10 +34,28 @@ const path = require('path');
|
||||
const SERVER_TIMEOUT_MS = 1000;
|
||||
const MAX_ITEMS_REPORTED = 20;
|
||||
|
||||
// Resolve the Claude profile directory the same way everywhere.
|
||||
//
|
||||
// Duplicated rather than imported on purpose: hooks ship in managed installs
|
||||
// that carry top-level `scripts/` without `scripts/lib/` (see #3259), so a
|
||||
// require into lib/ would make the hook unloadable there. `~` is expanded the
|
||||
// way `scripts/lib/agent-data-home.js` expands it, so a profile written as
|
||||
// `~/.claude-work` resolves to one place rather than two.
|
||||
function claudeConfigDir() {
|
||||
const configured = process.env.CLAUDE_CONFIG_DIR;
|
||||
const trimmed = configured ? String(configured).trim() : '';
|
||||
if (!trimmed) return path.join(os.homedir(), '.claude');
|
||||
if (trimmed.startsWith('~')) {
|
||||
const remainder = trimmed.slice(1).replace(/^[/\\]+/, '');
|
||||
return remainder ? path.join(os.homedir(), remainder) : os.homedir();
|
||||
}
|
||||
return path.resolve(trimmed);
|
||||
}
|
||||
|
||||
function stateDir() {
|
||||
const override = process.env.ECC_PLAN_CANVAS_STATE_DIR;
|
||||
if (override && override.trim()) return path.resolve(override.trim());
|
||||
return path.join(os.homedir(), '.claude', 'plan-canvas');
|
||||
return path.join(claudeConfigDir(), 'plan-canvas');
|
||||
}
|
||||
|
||||
function readState() {
|
||||
|
||||
@@ -19,10 +19,28 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
// Resolve the Claude profile directory the same way everywhere.
|
||||
//
|
||||
// Duplicated rather than imported on purpose: hooks ship in managed installs
|
||||
// that carry top-level `scripts/` without `scripts/lib/` (see #3259), so a
|
||||
// require into lib/ would make the hook unloadable there. `~` is expanded the
|
||||
// way `scripts/lib/agent-data-home.js` expands it, so a profile written as
|
||||
// `~/.claude-work` resolves to one place rather than two.
|
||||
function claudeConfigDir() {
|
||||
const configured = process.env.CLAUDE_CONFIG_DIR;
|
||||
const trimmed = configured ? String(configured).trim() : '';
|
||||
if (!trimmed) return path.join(os.homedir(), '.claude');
|
||||
if (trimmed.startsWith('~')) {
|
||||
const remainder = trimmed.slice(1).replace(/^[/\\]+/, '');
|
||||
return remainder ? path.join(os.homedir(), remainder) : os.homedir();
|
||||
}
|
||||
return path.resolve(trimmed);
|
||||
}
|
||||
|
||||
function stateDir() {
|
||||
const override = process.env.ECC_PLAN_CANVAS_STATE_DIR;
|
||||
if (override && override.trim()) return path.resolve(override.trim());
|
||||
return path.join(os.homedir(), '.claude', 'plan-canvas');
|
||||
return path.join(claudeConfigDir(), 'plan-canvas');
|
||||
}
|
||||
|
||||
function openSessions() {
|
||||
|
||||
@@ -38,6 +38,24 @@ function appendLine(filePath, line) {
|
||||
fs.appendFileSync(filePath, `${line}\n`, 'utf8');
|
||||
}
|
||||
|
||||
// Resolve the Claude profile directory the same way everywhere.
|
||||
//
|
||||
// Duplicated rather than imported on purpose: hooks ship in managed installs
|
||||
// that carry top-level `scripts/` without `scripts/lib/` (see #3259), so a
|
||||
// require into lib/ would make the hook unloadable there. `~` is expanded the
|
||||
// way `scripts/lib/agent-data-home.js` expands it, so a profile written as
|
||||
// `~/.claude-work` resolves to one place rather than two.
|
||||
function claudeConfigDir() {
|
||||
const configured = process.env.CLAUDE_CONFIG_DIR;
|
||||
const trimmed = configured ? String(configured).trim() : '';
|
||||
if (!trimmed) return path.join(os.homedir(), '.claude');
|
||||
if (trimmed.startsWith('~')) {
|
||||
const remainder = trimmed.slice(1).replace(/^[/\\]+/, '');
|
||||
return remainder ? path.join(os.homedir(), remainder) : os.homedir();
|
||||
}
|
||||
return path.resolve(trimmed);
|
||||
}
|
||||
|
||||
function run(rawInput, mode = 'audit') {
|
||||
const config = MODE_CONFIG[mode];
|
||||
|
||||
@@ -45,7 +63,7 @@ function run(rawInput, mode = 'audit') {
|
||||
if (config) {
|
||||
const input = String(rawInput || '').trim() ? JSON.parse(String(rawInput)) : {};
|
||||
const command = sanitizeCommand(input.tool_input?.command || '?');
|
||||
appendLine(path.join(os.homedir(), '.claude', config.fileName), config.format(command));
|
||||
appendLine(path.join(claudeConfigDir(), config.fileName), config.format(command));
|
||||
}
|
||||
} catch {
|
||||
// Logging must never block the calling hook.
|
||||
|
||||
@@ -17,6 +17,7 @@ const path = require('path');
|
||||
const { assertWithinTrustedRoot } = require('./path-safety');
|
||||
|
||||
const AGENT_DATA_HOME_ENV = 'ECC_AGENT_DATA_HOME';
|
||||
const CLAUDE_CONFIG_DIR_ENV = 'CLAUDE_CONFIG_DIR';
|
||||
const DEFAULT_CLAUDE_DIR_NAME = '.claude';
|
||||
const DEFAULT_CURSOR_ECC_DIR_SEGMENTS = ['.cursor', 'ecc'];
|
||||
const PROJECT_CONFIG_RELATIVE = path.join('.cursor', 'ecc-agent-data.json');
|
||||
@@ -92,7 +93,9 @@ function getDefaultCursorAgentDataHome() {
|
||||
}
|
||||
|
||||
function getDefaultClaudeAgentDataHome() {
|
||||
const fromClaudeConfigDir = expandHomePath(process.env.CLAUDE_CONFIG_DIR);
|
||||
// Launcher-owned CLAUDE_CONFIG_DIR moves the Claude trusted root with its
|
||||
// profile. Project-provided paths must still remain inside that root.
|
||||
const fromClaudeConfigDir = expandHomePath(process.env[CLAUDE_CONFIG_DIR_ENV]);
|
||||
if (fromClaudeConfigDir) return fromClaudeConfigDir;
|
||||
return path.join(getHomeDirFromEnv(), DEFAULT_CLAUDE_DIR_NAME);
|
||||
}
|
||||
@@ -232,6 +235,7 @@ function getCursorSessionEnvPayload(options = {}) {
|
||||
|
||||
module.exports = {
|
||||
AGENT_DATA_HOME_ENV,
|
||||
CLAUDE_CONFIG_DIR_ENV,
|
||||
DEFAULT_CLAUDE_DIR_NAME,
|
||||
DEFAULT_CURSOR_ECC_DIR_SEGMENTS,
|
||||
PROJECT_CONFIG_RELATIVE,
|
||||
|
||||
@@ -16,10 +16,26 @@ const path = require('path');
|
||||
const FEEDBACK_KINDS = new Set(['chat', 'annotation', 'verdict']);
|
||||
const VERDICTS = new Set(['approve', 'request-changes']);
|
||||
|
||||
// The producer and the SessionStart/Stop hooks must land on one directory. The
|
||||
// hooks resolve CLAUDE_CONFIG_DIR (they cannot require this file: managed
|
||||
// installs ship scripts/ without scripts/lib/, see #3259), so writing to
|
||||
// ~/.claude here regardless would hide queued feedback from them whenever a
|
||||
// session runs under a second profile.
|
||||
function resolveClaudeConfigDir(env = process.env) {
|
||||
const configured = env.CLAUDE_CONFIG_DIR;
|
||||
const trimmed = configured ? String(configured).trim() : '';
|
||||
if (!trimmed) return path.join(os.homedir(), '.claude');
|
||||
if (trimmed.startsWith('~')) {
|
||||
const remainder = trimmed.slice(1).replace(/^[/\\]+/, '');
|
||||
return remainder ? path.join(os.homedir(), remainder) : os.homedir();
|
||||
}
|
||||
return path.resolve(trimmed);
|
||||
}
|
||||
|
||||
function resolveStateDir(env = process.env) {
|
||||
const override = env.ECC_PLAN_CANVAS_STATE_DIR;
|
||||
if (override && String(override).trim()) return path.resolve(String(override).trim());
|
||||
return path.join(os.homedir(), '.claude', 'plan-canvas');
|
||||
return path.join(resolveClaudeConfigDir(env), 'plan-canvas');
|
||||
}
|
||||
|
||||
// Canonicalize so `./plan.md`, symlinks, and absolute paths all land on the
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* Tests that the hooks route profile state through CLAUDE_CONFIG_DIR
|
||||
*
|
||||
* Run with: node tests/hooks/claude-config-dir-routing.test.js
|
||||
*
|
||||
* These spawn the hooks rather than calling the shared resolver. The four hooks
|
||||
* below cannot require scripts/lib/agent-data-home.js: managed installs ship a
|
||||
* top-level scripts/ without scripts/lib/ (#3259), so each carries its own copy
|
||||
* of the resolver. A resolver test therefore cannot show where a hook writes.
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const hooksDir = path.join(__dirname, '..', '..', 'scripts', 'hooks');
|
||||
const libSessions = path.join(__dirname, '..', '..', 'scripts', 'lib', 'plan-canvas', 'sessions.js');
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.log(` ✗ ${name}`);
|
||||
console.log(` Error: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function makeTempDir() {
|
||||
return fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'config-dir-routing-')));
|
||||
}
|
||||
|
||||
/**
|
||||
* A profile that is not the home directory, so a hook that ignored
|
||||
* CLAUDE_CONFIG_DIR would write somewhere this returns.
|
||||
*/
|
||||
function makeProfile() {
|
||||
const root = makeTempDir();
|
||||
return {
|
||||
home: path.join(root, 'home'),
|
||||
profile: path.join(root, 'profile', '.claude-work'),
|
||||
};
|
||||
}
|
||||
|
||||
// No ECC_* override is passed anywhere here: an override would route the hook
|
||||
// before it reached the CLAUDE_CONFIG_DIR fallback under test.
|
||||
function runHook(hookFile, { profile, home, stdin = '{}', args = [], env = {} }) {
|
||||
fs.mkdirSync(home, { recursive: true });
|
||||
return spawnSync('node', [path.join(hooksDir, hookFile), ...args], {
|
||||
input: stdin,
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: home,
|
||||
USERPROFILE: home,
|
||||
CLAUDE_CONFIG_DIR: profile,
|
||||
ECC_PLAN_CANVAS_STATE_DIR: undefined,
|
||||
ECC_MCP_HEALTH_STATE_PATH: undefined,
|
||||
...env,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* plan-canvas-pending.js exports run() and is required by the lifecycle hook
|
||||
* bootstrap rather than spawned, so it is driven here in a child process. The
|
||||
* child is what makes CLAUDE_CONFIG_DIR meaningful: requiring it in-process,
|
||||
* the way tests/hooks/plan-canvas-pending-hook.test.js does with
|
||||
* ECC_PLAN_CANVAS_STATE_DIR, cannot reach the fallback under test.
|
||||
*/
|
||||
function runPendingHook({ profile, home, payload }) {
|
||||
fs.mkdirSync(home, { recursive: true });
|
||||
const result = spawnSync('node', ['-e', `
|
||||
require(${JSON.stringify(path.join(hooksDir, 'plan-canvas-pending.js'))})
|
||||
.run(${JSON.stringify(JSON.stringify(payload))})
|
||||
.then(out => process.stdout.write(out.stdout));
|
||||
`], {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: home,
|
||||
USERPROFILE: home,
|
||||
CLAUDE_CONFIG_DIR: profile,
|
||||
ECC_PLAN_CANVAS_STATE_DIR: undefined,
|
||||
},
|
||||
});
|
||||
assert.strictEqual(result.status, 0, `pending hook failed: ${result.stderr}`);
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
function writeSessions(stateDir, sessions) {
|
||||
fs.mkdirSync(stateDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(stateDir, 'sessions.json'), JSON.stringify({ sessions }, null, 2));
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n🧪 Testing CLAUDE_CONFIG_DIR routing in hooks\n');
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
const record = ok => { if (ok) passed++; else failed++; };
|
||||
|
||||
console.log('post-bash-command-log.js');
|
||||
|
||||
record(test('writes the audit log under CLAUDE_CONFIG_DIR', () => {
|
||||
const { home, profile } = makeProfile();
|
||||
runHook('post-bash-command-log.js', {
|
||||
home,
|
||||
profile,
|
||||
args: ['audit'],
|
||||
stdin: JSON.stringify({ tool_name: 'Bash', tool_input: { command: 'echo routed' } }),
|
||||
});
|
||||
const log = path.join(profile, 'bash-commands.log');
|
||||
assert.ok(fs.existsSync(log), `expected ${log} to exist`);
|
||||
assert.match(fs.readFileSync(log, 'utf8'), /echo routed/);
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(home, '.claude', 'bash-commands.log')),
|
||||
'the log must not also land in ~/.claude'
|
||||
);
|
||||
}));
|
||||
|
||||
record(test('writes the cost log under CLAUDE_CONFIG_DIR', () => {
|
||||
const { home, profile } = makeProfile();
|
||||
runHook('post-bash-command-log.js', {
|
||||
home,
|
||||
profile,
|
||||
args: ['cost'],
|
||||
stdin: JSON.stringify({ tool_name: 'Bash', tool_input: { command: 'echo costed' } }),
|
||||
});
|
||||
assert.match(fs.readFileSync(path.join(profile, 'cost-tracker.log'), 'utf8'), /echo costed/);
|
||||
}));
|
||||
|
||||
record(test('falls back to ~/.claude when CLAUDE_CONFIG_DIR is unset', () => {
|
||||
const { home } = makeProfile();
|
||||
fs.mkdirSync(home, { recursive: true });
|
||||
spawnSync('node', [path.join(hooksDir, 'post-bash-command-log.js'), 'audit'], {
|
||||
input: JSON.stringify({ tool_name: 'Bash', tool_input: { command: 'echo default' } }),
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, HOME: home, USERPROFILE: home, CLAUDE_CONFIG_DIR: undefined },
|
||||
});
|
||||
assert.match(
|
||||
fs.readFileSync(path.join(home, '.claude', 'bash-commands.log'), 'utf8'),
|
||||
/echo default/
|
||||
);
|
||||
}));
|
||||
|
||||
record(test('expands a tilde profile instead of creating a "~" directory', () => {
|
||||
// path.resolve('~/.claude-work') would make ./~/.claude-work under the cwd,
|
||||
// splitting the hook off from scripts/lib/agent-data-home.js, which expands.
|
||||
const { home } = makeProfile();
|
||||
runHook('post-bash-command-log.js', {
|
||||
home,
|
||||
profile: '~/.claude-work',
|
||||
args: ['audit'],
|
||||
stdin: JSON.stringify({ tool_name: 'Bash', tool_input: { command: 'echo tilde' } }),
|
||||
});
|
||||
assert.match(
|
||||
fs.readFileSync(path.join(home, '.claude-work', 'bash-commands.log'), 'utf8'),
|
||||
/echo tilde/
|
||||
);
|
||||
}));
|
||||
|
||||
console.log('\nmcp-health-check.js');
|
||||
|
||||
record(test('writes the health cache under CLAUDE_CONFIG_DIR', () => {
|
||||
const { home, profile } = makeProfile();
|
||||
fs.mkdirSync(home, { recursive: true });
|
||||
// Without a configured server the hook skips the probe and never writes.
|
||||
// The command does not exist, so the probe fails and the result is cached.
|
||||
fs.writeFileSync(path.join(home, '.claude.json'), JSON.stringify({
|
||||
mcpServers: { routing_probe: { command: 'ecc-no-such-mcp-binary', args: [] } },
|
||||
}));
|
||||
runHook('mcp-health-check.js', {
|
||||
home,
|
||||
profile,
|
||||
stdin: JSON.stringify({ tool_name: 'mcp__routing_probe__ping', tool_input: {} }),
|
||||
});
|
||||
const cache = path.join(profile, 'mcp-health-cache.json');
|
||||
assert.ok(fs.existsSync(cache), `expected ${cache} to exist`);
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(home, '.claude', 'mcp-health-cache.json')),
|
||||
'the cache must not also land in ~/.claude'
|
||||
);
|
||||
}));
|
||||
|
||||
console.log('\nplan-canvas-sessions.js (SessionStart)');
|
||||
|
||||
record(test('reports open sessions recorded under CLAUDE_CONFIG_DIR', () => {
|
||||
const { home, profile } = makeProfile();
|
||||
writeSessions(path.join(profile, 'plan-canvas'), {
|
||||
k1: { file: '/work/plan.md', status: 'open', pendingFeedback: [] },
|
||||
});
|
||||
const result = runHook('plan-canvas-sessions.js', { home, profile });
|
||||
assert.match(result.stdout, /\/work\/plan\.md/);
|
||||
}));
|
||||
|
||||
record(test('does not read ~/.claude when a profile is set', () => {
|
||||
const { home, profile } = makeProfile();
|
||||
// Only the home copy holds a session. A hook that ignored the profile
|
||||
// would announce it; the right answer here is silence.
|
||||
writeSessions(path.join(home, '.claude', 'plan-canvas'), {
|
||||
k1: { file: '/work/leaked.md', status: 'open', pendingFeedback: [] },
|
||||
});
|
||||
const result = runHook('plan-canvas-sessions.js', { home, profile });
|
||||
// Silence is the assertion here, and a hook that crashed before writing is
|
||||
// also silent, so the exit status has to be checked first.
|
||||
assert.strictEqual(result.status, 0, `SessionStart failed: ${result.stderr}`);
|
||||
assert.strictEqual(result.stdout.trim(), '');
|
||||
}));
|
||||
|
||||
console.log('\nplan-canvas-pending.js (Stop)');
|
||||
|
||||
record(test('blocks on feedback queued under CLAUDE_CONFIG_DIR', () => {
|
||||
const { home, profile } = makeProfile();
|
||||
const cwd = makeTempDir();
|
||||
const artifact = path.join(cwd, 'plan.md');
|
||||
fs.writeFileSync(artifact, '# plan\n');
|
||||
writeSessions(path.join(profile, 'plan-canvas'), {
|
||||
k1: {
|
||||
key: 'k1',
|
||||
file: artifact,
|
||||
status: 'feedback',
|
||||
pendingFeedback: [{ kind: 'verdict', verdict: 'request-changes', text: 'tighten step 2' }],
|
||||
},
|
||||
});
|
||||
const decision = JSON.parse(runPendingHook({ home, profile, payload: { cwd } }));
|
||||
assert.strictEqual(decision.decision, 'block');
|
||||
assert.match(decision.reason, /tighten step 2/);
|
||||
}));
|
||||
|
||||
console.log('\nproducer to consumer');
|
||||
|
||||
record(test('a session the library records is found by both hooks', () => {
|
||||
// The regression this guards: the library wrote ~/.claude/plan-canvas while
|
||||
// the hooks read CLAUDE_CONFIG_DIR/plan-canvas, so under a second profile
|
||||
// SessionStart missed open reviews and Stop let a turn end with feedback
|
||||
// still queued. Both ends are exercised with no state-directory override.
|
||||
const { home, profile } = makeProfile();
|
||||
const cwd = makeTempDir();
|
||||
const artifact = path.join(cwd, 'design.md');
|
||||
fs.writeFileSync(artifact, '# design\n');
|
||||
fs.mkdirSync(home, { recursive: true });
|
||||
|
||||
const producer = spawnSync('node', ['-e', `
|
||||
const store = require(${JSON.stringify(libSessions)}).createSessionStore();
|
||||
const { session } = store.open(${JSON.stringify(artifact)});
|
||||
store.queueFeedback(session.key, [{ kind: 'verdict', verdict: 'request-changes', text: 'needs a rollback plan' }]);
|
||||
`], {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: home,
|
||||
USERPROFILE: home,
|
||||
CLAUDE_CONFIG_DIR: profile,
|
||||
ECC_PLAN_CANVAS_STATE_DIR: undefined,
|
||||
},
|
||||
});
|
||||
assert.strictEqual(producer.status, 0, `producer failed: ${producer.stderr}`);
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(profile, 'plan-canvas', 'sessions.json')),
|
||||
'the library must record the session under the profile'
|
||||
);
|
||||
|
||||
const sessionStart = runHook('plan-canvas-sessions.js', { home, profile });
|
||||
assert.match(sessionStart.stdout, /design\.md/, 'SessionStart must see the open review');
|
||||
|
||||
const decision = JSON.parse(runPendingHook({ home, profile, payload: { cwd } }));
|
||||
assert.strictEqual(decision.decision, 'block');
|
||||
assert.match(decision.reason, /needs a rollback plan/);
|
||||
}));
|
||||
|
||||
console.log(`\n${'='.repeat(50)}`);
|
||||
console.log(`Passed: ${passed}`);
|
||||
console.log(`Failed: ${failed}`);
|
||||
console.log('='.repeat(50));
|
||||
if (failed > 0) process.exit(1);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -570,6 +570,107 @@ function runTests() {
|
||||
});
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('getDefaultClaudeAgentDataHome follows CLAUDE_CONFIG_DIR', () => {
|
||||
// Claude Code points every profile-local path at CLAUDE_CONFIG_DIR. Ignoring
|
||||
// it writes this profile's session data, logs and metrics into the default
|
||||
// ~/.claude, where another profile reads them back.
|
||||
withIsolatedCwd(() => {
|
||||
withEnv({
|
||||
CLAUDE_CONFIG_DIR: path.join(os.tmpdir(), 'ecc-profile-x'),
|
||||
ECC_AGENT_DATA_HOME: undefined,
|
||||
}, () => {
|
||||
const agentDataHome = require('../../scripts/lib/agent-data-home');
|
||||
assert.strictEqual(
|
||||
agentDataHome.getDefaultClaudeAgentDataHome(),
|
||||
path.join(os.tmpdir(), 'ecc-profile-x')
|
||||
);
|
||||
});
|
||||
});
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('getDefaultClaudeAgentDataHome falls back to ~/.claude without the env', () => {
|
||||
withIsolatedCwd(() => {
|
||||
withEnv({ CLAUDE_CONFIG_DIR: undefined, ECC_AGENT_DATA_HOME: undefined }, () => {
|
||||
const agentDataHome = require('../../scripts/lib/agent-data-home');
|
||||
assert.strictEqual(
|
||||
agentDataHome.getDefaultClaudeAgentDataHome(),
|
||||
path.join(os.homedir(), '.claude')
|
||||
);
|
||||
});
|
||||
});
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('a blank CLAUDE_CONFIG_DIR is ignored rather than resolving to cwd', () => {
|
||||
// An empty or whitespace value would otherwise resolve to the process cwd and
|
||||
// scatter profile data through whatever directory the session started in.
|
||||
withIsolatedCwd(() => {
|
||||
withEnv({ CLAUDE_CONFIG_DIR: ' ', ECC_AGENT_DATA_HOME: undefined }, () => {
|
||||
const agentDataHome = require('../../scripts/lib/agent-data-home');
|
||||
assert.strictEqual(
|
||||
agentDataHome.getDefaultClaudeAgentDataHome(),
|
||||
path.join(os.homedir(), '.claude')
|
||||
);
|
||||
});
|
||||
});
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('security: CLAUDE_CONFIG_DIR adds a trusted profile root while preserving explicit sharing', () => {
|
||||
// getDefaultClaudeAgentDataHome() is not only a default: it is one of the two
|
||||
// roots resolveAllowedProjectConfigHome() will accept. Following
|
||||
// CLAUDE_CONFIG_DIR moves that boundary with the profile, so the containment
|
||||
// it enforces has to hold at the new location and nowhere else.
|
||||
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-agent-data-home-cfgdir-'));
|
||||
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-agent-data-home-cfgdir-user-'));
|
||||
const profileDir = path.join(homeDir, '.claude-profile-x');
|
||||
const configPath = path.join(projectDir, '.cursor', 'ecc-agent-data.json');
|
||||
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
||||
|
||||
try {
|
||||
withEnv({
|
||||
ECC_AGENT_DATA_HOME: undefined,
|
||||
CLAUDE_CONFIG_DIR: profileDir,
|
||||
HOME: homeDir,
|
||||
USERPROFILE: undefined,
|
||||
}, () => {
|
||||
const agentDataHome = require('../../scripts/lib/agent-data-home');
|
||||
|
||||
// Inside the relocated root: accepted.
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({ agentDataHome: path.join(profileDir, 'nested') }),
|
||||
'utf8'
|
||||
);
|
||||
// readProjectConfigAt canonicalizes, and macOS reaches os.tmpdir()
|
||||
// through a symlink, so compare against the resolved home.
|
||||
assert.strictEqual(
|
||||
agentDataHome.readProjectConfigAt(configPath),
|
||||
path.join(fs.realpathSync(homeDir), '.claude-profile-x', 'nested')
|
||||
);
|
||||
|
||||
// Preserve the explicit ~/.claude sharing root shipped in #3479.
|
||||
// The profile root adds a trusted location; arbitrary home paths and
|
||||
// traversal out of that profile still stay rejected.
|
||||
fs.writeFileSync(configPath, JSON.stringify({ agentDataHome: path.join(homeDir, '.claude') }), 'utf8');
|
||||
assert.strictEqual(agentDataHome.readProjectConfigAt(configPath), path.join(fs.realpathSync(homeDir), '.claude'));
|
||||
const outside = [
|
||||
path.join(profileDir, '..', 'escaped'),
|
||||
path.join(homeDir, 'arbitrary-agent-data'),
|
||||
];
|
||||
for (const candidate of outside) {
|
||||
fs.writeFileSync(configPath, JSON.stringify({ agentDataHome: candidate }), 'utf8');
|
||||
const { result, messages } = captureConsoleErrors(
|
||||
() => agentDataHome.readProjectConfigAt(configPath)
|
||||
);
|
||||
assert.strictEqual(result, null, `expected rejection for ${candidate}`);
|
||||
assert.ok(messages.some(message => message.includes('Ignoring unsafe agent data project config')));
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(projectDir, { recursive: true, force: true });
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\n=== Test Results ===\nPassed: ${passed}\nFailed: ${failed}\n`);
|
||||
if (failed > 0) process.exit(1);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user