fix: centralize Claude CLI invocation (#680)
* fix: centralize Claude CLI invocation Use shared resolver and PATH prepending for CLI calls. Add tests to cover resolver behavior and handler usage. * fix: harden Claude CLI auth checks Handle PATH edge cases and Windows matching in CLI resolver. Add auth error scenarios and CLI command escaping in env handlers. Extend tests for resolver and auth error coverage. * test: extend Claude CLI handler coverage Cover Windows PATH case-insensitive behavior and session state assertions. * test: cover invokeClaude profile flows Add temp token, config dir, and profile switch assertions. * test: assert oauth token file write * test: cover claude invoke error paths * test: streamline claude terminal mocks * fix: track claude profile usage * test: cover windows path normalization * chore: align claude invoke spacing * fix: harden Claude CLI invocation handling * test: align Claude CLI PATH handling --------- Co-authored-by: StillKnotKnown <stillknotknown@users.noreply.github.com> Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
import path from 'path';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mockGetToolPath = vi.fn<() => string>();
|
||||
const mockGetAugmentedEnv = vi.fn<() => Record<string, string>>();
|
||||
|
||||
vi.mock('../cli-tool-manager', () => ({
|
||||
getToolPath: mockGetToolPath,
|
||||
}));
|
||||
|
||||
vi.mock('../env-utils', () => ({
|
||||
getAugmentedEnv: mockGetAugmentedEnv,
|
||||
}));
|
||||
|
||||
describe('claude-cli-utils', () => {
|
||||
beforeEach(() => {
|
||||
mockGetToolPath.mockReset();
|
||||
mockGetAugmentedEnv.mockReset();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('prepends the CLI directory to PATH when the command is absolute', async () => {
|
||||
const command = process.platform === 'win32'
|
||||
? 'C:\\Tools\\claude\\claude.exe'
|
||||
: '/opt/claude/bin/claude';
|
||||
const env = {
|
||||
PATH: process.platform === 'win32'
|
||||
? 'C:\\Windows\\System32'
|
||||
: '/usr/bin',
|
||||
HOME: '/tmp',
|
||||
};
|
||||
mockGetToolPath.mockReturnValue(command);
|
||||
mockGetAugmentedEnv.mockReturnValue(env);
|
||||
|
||||
const { getClaudeCliInvocation } = await import('../claude-cli-utils');
|
||||
const result = getClaudeCliInvocation();
|
||||
|
||||
const separator = process.platform === 'win32' ? ';' : ':';
|
||||
expect(result.command).toBe(command);
|
||||
expect(result.env.PATH.split(separator)[0]).toBe(path.dirname(command));
|
||||
expect(result.env.HOME).toBe(env.HOME);
|
||||
});
|
||||
|
||||
it('sets PATH to the command directory when PATH is empty', async () => {
|
||||
const command = process.platform === 'win32'
|
||||
? 'C:\\Tools\\claude\\claude.exe'
|
||||
: '/opt/claude/bin/claude';
|
||||
const env = { PATH: '' };
|
||||
mockGetToolPath.mockReturnValue(command);
|
||||
mockGetAugmentedEnv.mockReturnValue(env);
|
||||
|
||||
const { getClaudeCliInvocation } = await import('../claude-cli-utils');
|
||||
const result = getClaudeCliInvocation();
|
||||
|
||||
expect(result.env.PATH).toBe(path.dirname(command));
|
||||
});
|
||||
|
||||
it('sets PATH to the command directory when PATH is missing', async () => {
|
||||
const command = process.platform === 'win32'
|
||||
? 'C:\\Tools\\claude\\claude.exe'
|
||||
: '/opt/claude/bin/claude';
|
||||
const env = {};
|
||||
mockGetToolPath.mockReturnValue(command);
|
||||
mockGetAugmentedEnv.mockReturnValue(env);
|
||||
|
||||
const { getClaudeCliInvocation } = await import('../claude-cli-utils');
|
||||
const result = getClaudeCliInvocation();
|
||||
|
||||
expect(result.env.PATH).toBe(path.dirname(command));
|
||||
});
|
||||
|
||||
it('keeps PATH unchanged when the command is not absolute', async () => {
|
||||
const env = {
|
||||
PATH: process.platform === 'win32'
|
||||
? 'C:\\Windows;C:\\Windows\\System32'
|
||||
: '/usr/bin:/bin',
|
||||
};
|
||||
mockGetToolPath.mockReturnValue('claude');
|
||||
mockGetAugmentedEnv.mockReturnValue(env);
|
||||
|
||||
const { getClaudeCliInvocation } = await import('../claude-cli-utils');
|
||||
const result = getClaudeCliInvocation();
|
||||
|
||||
expect(result.command).toBe('claude');
|
||||
expect(result.env.PATH).toBe(env.PATH);
|
||||
});
|
||||
|
||||
it('does not duplicate the command directory in PATH', async () => {
|
||||
const command = process.platform === 'win32'
|
||||
? 'C:\\Tools\\claude\\claude.exe'
|
||||
: '/opt/claude/bin/claude';
|
||||
const commandDir = path.dirname(command);
|
||||
const separator = process.platform === 'win32' ? ';' : ':';
|
||||
const env = { PATH: `${commandDir}${separator}/usr/bin` };
|
||||
|
||||
mockGetToolPath.mockReturnValue(command);
|
||||
mockGetAugmentedEnv.mockReturnValue(env);
|
||||
|
||||
const { getClaudeCliInvocation } = await import('../claude-cli-utils');
|
||||
const result = getClaudeCliInvocation();
|
||||
|
||||
expect(result.env.PATH).toBe(env.PATH);
|
||||
});
|
||||
|
||||
it('treats PATH entries case-insensitively on Windows', async () => {
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||
|
||||
try {
|
||||
const command = 'C:\\Tools\\claude\\claude.exe';
|
||||
const env = { PATH: 'c:\\tools\\claude;C:\\Windows' };
|
||||
|
||||
mockGetToolPath.mockReturnValue(command);
|
||||
mockGetAugmentedEnv.mockReturnValue(env);
|
||||
|
||||
const { getClaudeCliInvocation } = await import('../claude-cli-utils');
|
||||
const result = getClaudeCliInvocation();
|
||||
|
||||
expect(result.env.PATH).toBe(env.PATH);
|
||||
} finally {
|
||||
if (originalPlatform) {
|
||||
Object.defineProperty(process, 'platform', originalPlatform);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import path from 'path';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { IPC_CHANNELS } from '../../shared/constants';
|
||||
const {
|
||||
mockGetClaudeCliInvocation,
|
||||
mockGetProject,
|
||||
spawnMock,
|
||||
mockIpcMain,
|
||||
} = vi.hoisted(() => {
|
||||
const ipcMain = new (class {
|
||||
handlers = new Map<string, Function>();
|
||||
|
||||
handle(channel: string, handler: Function): void {
|
||||
this.handlers.set(channel, handler);
|
||||
}
|
||||
|
||||
getHandler(channel: string): Function | undefined {
|
||||
return this.handlers.get(channel);
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
mockGetClaudeCliInvocation: vi.fn(),
|
||||
mockGetProject: vi.fn(),
|
||||
spawnMock: vi.fn(),
|
||||
mockIpcMain: ipcMain,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../claude-cli-utils', () => ({
|
||||
getClaudeCliInvocation: mockGetClaudeCliInvocation,
|
||||
}));
|
||||
|
||||
vi.mock('../project-store', () => ({
|
||||
projectStore: {
|
||||
getProject: mockGetProject,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('child_process', () => ({
|
||||
spawn: spawnMock,
|
||||
}));
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: vi.fn((name: string) => {
|
||||
if (name === 'userData') return path.join('/tmp', 'userData');
|
||||
return '/tmp';
|
||||
}),
|
||||
},
|
||||
ipcMain: mockIpcMain,
|
||||
}));
|
||||
|
||||
import { registerEnvHandlers } from '../ipc-handlers/env-handlers';
|
||||
|
||||
function createProc(): EventEmitter & { stdout?: EventEmitter; stderr?: EventEmitter } {
|
||||
const proc = new EventEmitter() as EventEmitter & {
|
||||
stdout?: EventEmitter;
|
||||
stderr?: EventEmitter;
|
||||
};
|
||||
proc.stdout = new EventEmitter();
|
||||
proc.stderr = new EventEmitter();
|
||||
return proc;
|
||||
}
|
||||
|
||||
describe('env-handlers Claude CLI usage', () => {
|
||||
beforeEach(() => {
|
||||
mockGetClaudeCliInvocation.mockReset();
|
||||
mockGetProject.mockReset();
|
||||
spawnMock.mockReset();
|
||||
});
|
||||
|
||||
it('uses resolved Claude CLI path/env for auth checks', async () => {
|
||||
const claudeEnv = { PATH: '/opt/claude/bin:/usr/bin' };
|
||||
const command = '/opt/claude/bin/claude';
|
||||
mockGetClaudeCliInvocation.mockReturnValue({
|
||||
command,
|
||||
env: claudeEnv,
|
||||
});
|
||||
mockGetProject.mockReturnValue({ id: 'p1', path: '/tmp/project' });
|
||||
|
||||
const procs: ReturnType<typeof createProc>[] = [];
|
||||
spawnMock.mockImplementation(() => {
|
||||
const proc = createProc();
|
||||
procs.push(proc);
|
||||
return proc;
|
||||
});
|
||||
|
||||
registerEnvHandlers(() => null);
|
||||
const handler = mockIpcMain.getHandler(IPC_CHANNELS.ENV_CHECK_CLAUDE_AUTH);
|
||||
if (!handler) {
|
||||
throw new Error('ENV_CHECK_CLAUDE_AUTH handler not registered');
|
||||
}
|
||||
|
||||
const resultPromise = handler({}, 'p1');
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1);
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
command,
|
||||
['--version'],
|
||||
expect.objectContaining({ cwd: '/tmp/project', env: claudeEnv, shell: false })
|
||||
);
|
||||
|
||||
procs[0].emit('close', 0);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledTimes(2);
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
command,
|
||||
['api', '--help'],
|
||||
expect.objectContaining({ cwd: '/tmp/project', env: claudeEnv, shell: false })
|
||||
);
|
||||
|
||||
procs[1].emit('close', 0);
|
||||
|
||||
const result = await resultPromise;
|
||||
expect(result).toEqual({ success: true, data: { success: true, authenticated: true } });
|
||||
});
|
||||
|
||||
it('uses resolved Claude CLI path/env for setup-token', async () => {
|
||||
const claudeEnv = { PATH: '/opt/claude/bin:/usr/bin' };
|
||||
const command = '/opt/claude/bin/claude';
|
||||
mockGetClaudeCliInvocation.mockReturnValue({
|
||||
command,
|
||||
env: claudeEnv,
|
||||
});
|
||||
mockGetProject.mockReturnValue({ id: 'p2', path: '/tmp/project' });
|
||||
|
||||
const proc = createProc();
|
||||
spawnMock.mockReturnValue(proc);
|
||||
|
||||
registerEnvHandlers(() => null);
|
||||
const handler = mockIpcMain.getHandler(IPC_CHANNELS.ENV_INVOKE_CLAUDE_SETUP);
|
||||
if (!handler) {
|
||||
throw new Error('ENV_INVOKE_CLAUDE_SETUP handler not registered');
|
||||
}
|
||||
|
||||
const resultPromise = handler({}, 'p2');
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
command,
|
||||
['setup-token'],
|
||||
expect.objectContaining({
|
||||
cwd: '/tmp/project',
|
||||
env: claudeEnv,
|
||||
shell: false,
|
||||
stdio: 'inherit'
|
||||
})
|
||||
);
|
||||
|
||||
proc.emit('close', 0);
|
||||
const result = await resultPromise;
|
||||
expect(result).toEqual({ success: true, data: { success: true, authenticated: true } });
|
||||
});
|
||||
|
||||
it('returns an error when Claude CLI resolution throws', async () => {
|
||||
mockGetClaudeCliInvocation.mockImplementation(() => {
|
||||
throw new Error('Claude CLI exploded');
|
||||
});
|
||||
mockGetProject.mockReturnValue({ id: 'p3', path: '/tmp/project' });
|
||||
|
||||
registerEnvHandlers(() => null);
|
||||
const handler = mockIpcMain.getHandler(IPC_CHANNELS.ENV_CHECK_CLAUDE_AUTH);
|
||||
if (!handler) {
|
||||
throw new Error('ENV_CHECK_CLAUDE_AUTH handler not registered');
|
||||
}
|
||||
|
||||
const result = await handler({}, 'p3');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Claude CLI exploded');
|
||||
expect(spawnMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns an error when Claude CLI command is missing', async () => {
|
||||
mockGetClaudeCliInvocation.mockReturnValue({ command: '', env: {} });
|
||||
mockGetProject.mockReturnValue({ id: 'p4', path: '/tmp/project' });
|
||||
|
||||
registerEnvHandlers(() => null);
|
||||
const handler = mockIpcMain.getHandler(IPC_CHANNELS.ENV_CHECK_CLAUDE_AUTH);
|
||||
if (!handler) {
|
||||
throw new Error('ENV_CHECK_CLAUDE_AUTH handler not registered');
|
||||
}
|
||||
|
||||
const result = await handler({}, 'p4');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Claude CLI path not resolved');
|
||||
expect(spawnMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns an error when Claude CLI exits with a non-zero code', async () => {
|
||||
const claudeEnv = { PATH: '/opt/claude/bin:/usr/bin' };
|
||||
const command = '/opt/claude/bin/claude';
|
||||
mockGetClaudeCliInvocation.mockReturnValue({
|
||||
command,
|
||||
env: claudeEnv,
|
||||
});
|
||||
mockGetProject.mockReturnValue({ id: 'p5', path: '/tmp/project' });
|
||||
|
||||
const proc = createProc();
|
||||
spawnMock.mockReturnValue(proc);
|
||||
|
||||
registerEnvHandlers(() => null);
|
||||
const handler = mockIpcMain.getHandler(IPC_CHANNELS.ENV_CHECK_CLAUDE_AUTH);
|
||||
if (!handler) {
|
||||
throw new Error('ENV_CHECK_CLAUDE_AUTH handler not registered');
|
||||
}
|
||||
|
||||
const resultPromise = handler({}, 'p5');
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
command,
|
||||
['--version'],
|
||||
expect.objectContaining({ cwd: '/tmp/project', env: claudeEnv, shell: false })
|
||||
);
|
||||
proc.emit('close', 1);
|
||||
|
||||
const result = await resultPromise;
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Claude CLI not found');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import path from 'path';
|
||||
import { getAugmentedEnv } from './env-utils';
|
||||
import { getToolPath } from './cli-tool-manager';
|
||||
|
||||
export type ClaudeCliInvocation = {
|
||||
command: string;
|
||||
env: Record<string, string>;
|
||||
};
|
||||
|
||||
function ensureCommandDirInPath(command: string, env: Record<string, string>): Record<string, string> {
|
||||
if (!path.isAbsolute(command)) {
|
||||
return env;
|
||||
}
|
||||
|
||||
const pathSeparator = process.platform === 'win32' ? ';' : ':';
|
||||
const commandDir = path.dirname(command);
|
||||
const currentPath = env.PATH || '';
|
||||
const pathEntries = currentPath.split(pathSeparator);
|
||||
const normalizedCommandDir = path.normalize(commandDir);
|
||||
const hasCommandDir = process.platform === 'win32'
|
||||
? pathEntries
|
||||
.map((entry) => path.normalize(entry).toLowerCase())
|
||||
.includes(normalizedCommandDir.toLowerCase())
|
||||
: pathEntries
|
||||
.map((entry) => path.normalize(entry))
|
||||
.includes(normalizedCommandDir);
|
||||
|
||||
if (hasCommandDir) {
|
||||
return env;
|
||||
}
|
||||
|
||||
return {
|
||||
...env,
|
||||
PATH: [commandDir, currentPath].filter(Boolean).join(pathSeparator),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Claude CLI command path and an environment with PATH updated to include the CLI directory.
|
||||
*/
|
||||
export function getClaudeCliInvocation(): ClaudeCliInvocation {
|
||||
const command = getToolPath('claude');
|
||||
const env = getAugmentedEnv();
|
||||
|
||||
return {
|
||||
command,
|
||||
env: ensureCommandDirInPath(command, env),
|
||||
};
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import { existsSync, readFileSync, writeFileSync } from 'fs';
|
||||
import { spawn } from 'child_process';
|
||||
import { projectStore } from '../project-store';
|
||||
import { parseEnvFile } from './utils';
|
||||
import { getClaudeCliInvocation } from '../claude-cli-utils';
|
||||
import { debugError } from '../../shared/utils/debug-logger';
|
||||
|
||||
// GitLab environment variable keys
|
||||
const GITLAB_ENV_KEYS = {
|
||||
@@ -25,6 +27,25 @@ function envLine(vars: Record<string, string>, key: string, defaultVal: string =
|
||||
return vars[key] ? `${key}=${vars[key]}` : `# ${key}=${defaultVal}`;
|
||||
}
|
||||
|
||||
type ResolvedClaudeCliInvocation =
|
||||
| { command: string; env: Record<string, string> }
|
||||
| { error: string };
|
||||
|
||||
function resolveClaudeCliInvocation(): ResolvedClaudeCliInvocation {
|
||||
try {
|
||||
const invocation = getClaudeCliInvocation();
|
||||
if (!invocation?.command) {
|
||||
throw new Error('Claude CLI path not resolved');
|
||||
}
|
||||
return { command: invocation.command, env: invocation.env };
|
||||
} catch (error) {
|
||||
debugError('[IPC] Failed to resolve Claude CLI path:', error);
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Failed to resolve Claude CLI path',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Register all env-related IPC handlers
|
||||
@@ -552,13 +573,20 @@ ${existingVars['GRAPHITI_DB_PATH'] ? `GRAPHITI_DB_PATH=${existingVars['GRAPHITI_
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
const resolved = resolveClaudeCliInvocation();
|
||||
if ('error' in resolved) {
|
||||
return { success: false, error: resolved.error };
|
||||
}
|
||||
const claudeCmd = resolved.command;
|
||||
const claudeEnv = resolved.env;
|
||||
|
||||
try {
|
||||
// Check if Claude CLI is available and authenticated
|
||||
const result = await new Promise<ClaudeAuthResult>((resolve) => {
|
||||
const proc = spawn('claude', ['--version'], {
|
||||
const proc = spawn(claudeCmd, ['--version'], {
|
||||
cwd: project.path,
|
||||
env: { ...process.env },
|
||||
shell: true
|
||||
env: claudeEnv,
|
||||
shell: false
|
||||
});
|
||||
|
||||
let _stdout = '';
|
||||
@@ -576,10 +604,10 @@ ${existingVars['GRAPHITI_DB_PATH'] ? `GRAPHITI_DB_PATH=${existingVars['GRAPHITI_
|
||||
if (code === 0) {
|
||||
// Claude CLI is available, check if authenticated
|
||||
// Run a simple command that requires auth
|
||||
const authCheck = spawn('claude', ['api', '--help'], {
|
||||
const authCheck = spawn(claudeCmd, ['api', '--help'], {
|
||||
cwd: project.path,
|
||||
env: { ...process.env },
|
||||
shell: true
|
||||
env: claudeEnv,
|
||||
shell: false
|
||||
});
|
||||
|
||||
authCheck.on('close', (authCode: number | null) => {
|
||||
@@ -614,6 +642,9 @@ ${existingVars['GRAPHITI_DB_PATH'] ? `GRAPHITI_DB_PATH=${existingVars['GRAPHITI_
|
||||
});
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Failed to check Claude auth' };
|
||||
}
|
||||
return { success: true, data: result };
|
||||
} catch (error) {
|
||||
return {
|
||||
@@ -632,13 +663,20 @@ ${existingVars['GRAPHITI_DB_PATH'] ? `GRAPHITI_DB_PATH=${existingVars['GRAPHITI_
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
const resolved = resolveClaudeCliInvocation();
|
||||
if ('error' in resolved) {
|
||||
return { success: false, error: resolved.error };
|
||||
}
|
||||
const claudeCmd = resolved.command;
|
||||
const claudeEnv = resolved.env;
|
||||
|
||||
try {
|
||||
// Run claude setup-token which will open browser for OAuth
|
||||
const result = await new Promise<ClaudeAuthResult>((resolve) => {
|
||||
const proc = spawn('claude', ['setup-token'], {
|
||||
const proc = spawn(claudeCmd, ['setup-token'], {
|
||||
cwd: project.path,
|
||||
env: { ...process.env },
|
||||
shell: true,
|
||||
env: claudeEnv,
|
||||
shell: false,
|
||||
stdio: 'inherit' // This allows the terminal to handle the interactive auth
|
||||
});
|
||||
|
||||
@@ -666,6 +704,9 @@ ${existingVars['GRAPHITI_DB_PATH'] ? `GRAPHITI_DB_PATH=${existingVars['GRAPHITI_
|
||||
});
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Failed to invoke Claude setup' };
|
||||
}
|
||||
return { success: true, data: result };
|
||||
} catch (error) {
|
||||
return {
|
||||
|
||||
@@ -304,9 +304,10 @@ ${existingVars['GRAPHITI_DATABASE'] ? `GRAPHITI_DATABASE=${existingVars['GRAPHIT
|
||||
try {
|
||||
// Check if Claude CLI is available and authenticated
|
||||
const result = await new Promise<ClaudeAuthResult>((resolve) => {
|
||||
const proc = spawn('claude', ['--version'], {
|
||||
const { command: claudeCmd, env: claudeEnv } = getClaudeCliInvocation();
|
||||
const proc = spawn(claudeCmd, ['--version'], {
|
||||
cwd: project.path,
|
||||
env: { ...process.env },
|
||||
env: claudeEnv,
|
||||
shell: true
|
||||
});
|
||||
|
||||
@@ -325,9 +326,9 @@ ${existingVars['GRAPHITI_DATABASE'] ? `GRAPHITI_DATABASE=${existingVars['GRAPHIT
|
||||
if (code === 0) {
|
||||
// Claude CLI is available, check if authenticated
|
||||
// Run a simple command that requires auth
|
||||
const authCheck = spawn('claude', ['api', '--help'], {
|
||||
const authCheck = spawn(claudeCmd, ['api', '--help'], {
|
||||
cwd: project.path,
|
||||
env: { ...process.env },
|
||||
env: claudeEnv,
|
||||
shell: true
|
||||
});
|
||||
|
||||
@@ -384,9 +385,10 @@ ${existingVars['GRAPHITI_DATABASE'] ? `GRAPHITI_DATABASE=${existingVars['GRAPHIT
|
||||
try {
|
||||
// Run claude setup-token which will open browser for OAuth
|
||||
const result = await new Promise<ClaudeAuthResult>((resolve) => {
|
||||
const proc = spawn('claude', ['setup-token'], {
|
||||
const { command: claudeCmd, env: claudeEnv } = getClaudeCliInvocation();
|
||||
const proc = spawn(claudeCmd, ['setup-token'], {
|
||||
cwd: project.path,
|
||||
env: { ...process.env },
|
||||
env: claudeEnv,
|
||||
shell: true,
|
||||
stdio: 'inherit' // This allows the terminal to handle the interactive auth
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import { projectStore } from '../project-store';
|
||||
import { terminalNameGenerator } from '../terminal-name-generator';
|
||||
import { debugLog, debugError } from '../../shared/utils/debug-logger';
|
||||
import { escapeShellArg, escapeShellArgWindows } from '../../shared/utils/shell-escape';
|
||||
import { getClaudeCliInvocation } from '../claude-cli-utils';
|
||||
|
||||
|
||||
/**
|
||||
@@ -345,20 +346,30 @@ export function registerTerminalHandlers(
|
||||
// Build the login command with the profile's config dir
|
||||
// Use platform-specific syntax and escaping for environment variables
|
||||
let loginCommand: string;
|
||||
const { command: claudeCmd, env: claudeEnv } = getClaudeCliInvocation();
|
||||
const pathPrefix = claudeEnv.PATH
|
||||
? (process.platform === 'win32'
|
||||
? `set "PATH=${escapeShellArgWindows(claudeEnv.PATH)}" && `
|
||||
: `export PATH=${escapeShellArg(claudeEnv.PATH)} && `)
|
||||
: '';
|
||||
const shellClaudeCmd = process.platform === 'win32'
|
||||
? `"${escapeShellArgWindows(claudeCmd)}"`
|
||||
: escapeShellArg(claudeCmd);
|
||||
|
||||
if (!profile.isDefault && profile.configDir) {
|
||||
if (process.platform === 'win32') {
|
||||
// SECURITY: Use Windows-specific escaping for cmd.exe
|
||||
const escapedConfigDir = escapeShellArgWindows(profile.configDir);
|
||||
// Windows cmd.exe syntax: set "VAR=value" with %VAR% for expansion
|
||||
loginCommand = `set "CLAUDE_CONFIG_DIR=${escapedConfigDir}" && echo Config dir: %CLAUDE_CONFIG_DIR% && claude setup-token`;
|
||||
loginCommand = `${pathPrefix}set "CLAUDE_CONFIG_DIR=${escapedConfigDir}" && echo Config dir: %CLAUDE_CONFIG_DIR% && ${shellClaudeCmd} setup-token`;
|
||||
} else {
|
||||
// SECURITY: Use POSIX escaping for bash/zsh
|
||||
const escapedConfigDir = escapeShellArg(profile.configDir);
|
||||
// Unix/Mac bash/zsh syntax: export VAR=value with $VAR for expansion
|
||||
loginCommand = `export CLAUDE_CONFIG_DIR=${escapedConfigDir} && echo "Config dir: $CLAUDE_CONFIG_DIR" && claude setup-token`;
|
||||
loginCommand = `${pathPrefix}export CLAUDE_CONFIG_DIR=${escapedConfigDir} && echo "Config dir: $CLAUDE_CONFIG_DIR" && ${shellClaudeCmd} setup-token`;
|
||||
}
|
||||
} else {
|
||||
loginCommand = 'claude setup-token';
|
||||
loginCommand = `${pathPrefix}${shellClaudeCmd} setup-token`;
|
||||
}
|
||||
|
||||
debugLog('[IPC] Sending login command to terminal:', loginCommand);
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
import { writeFileSync } from 'fs';
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import type * as pty from '@lydell/node-pty';
|
||||
import type { TerminalProcess } from '../types';
|
||||
|
||||
const mockGetClaudeCliInvocation = vi.fn();
|
||||
const mockGetClaudeProfileManager = vi.fn();
|
||||
const mockPersistSession = vi.fn();
|
||||
const mockReleaseSessionId = vi.fn();
|
||||
|
||||
const createMockDisposable = (): pty.IDisposable => ({ dispose: vi.fn() });
|
||||
|
||||
const createMockPty = (): pty.IPty => ({
|
||||
pid: 123,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
process: 'bash',
|
||||
handleFlowControl: false,
|
||||
onData: vi.fn(() => createMockDisposable()),
|
||||
onExit: vi.fn(() => createMockDisposable()),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
pause: vi.fn(),
|
||||
resume: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
});
|
||||
|
||||
const createMockTerminal = (overrides: Partial<TerminalProcess> = {}): TerminalProcess => ({
|
||||
id: 'term-1',
|
||||
pty: createMockPty(),
|
||||
outputBuffer: '',
|
||||
isClaudeMode: false,
|
||||
claudeSessionId: undefined,
|
||||
claudeProfileId: undefined,
|
||||
title: 'Claude',
|
||||
cwd: '/tmp/project',
|
||||
projectPath: '/tmp/project',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
vi.mock('../../claude-cli-utils', () => ({
|
||||
getClaudeCliInvocation: mockGetClaudeCliInvocation,
|
||||
}));
|
||||
|
||||
vi.mock('../../claude-profile-manager', () => ({
|
||||
getClaudeProfileManager: mockGetClaudeProfileManager,
|
||||
}));
|
||||
|
||||
vi.mock('fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('fs')>();
|
||||
return {
|
||||
...actual,
|
||||
writeFileSync: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../session-handler', () => ({
|
||||
persistSession: mockPersistSession,
|
||||
releaseSessionId: mockReleaseSessionId,
|
||||
}));
|
||||
|
||||
describe('claude-integration-handler', () => {
|
||||
beforeEach(() => {
|
||||
mockGetClaudeCliInvocation.mockClear();
|
||||
mockGetClaudeProfileManager.mockClear();
|
||||
mockPersistSession.mockClear();
|
||||
mockReleaseSessionId.mockClear();
|
||||
vi.mocked(writeFileSync).mockClear();
|
||||
});
|
||||
|
||||
it('uses the resolved CLI path and PATH prefix when invoking Claude', async () => {
|
||||
mockGetClaudeCliInvocation.mockReturnValue({
|
||||
command: "/opt/claude bin/claude's",
|
||||
env: { PATH: '/opt/claude/bin:/usr/bin' },
|
||||
});
|
||||
const profileManager = {
|
||||
getActiveProfile: vi.fn(() => ({ id: 'default', name: 'Default', isDefault: true })),
|
||||
getProfile: vi.fn(),
|
||||
getProfileToken: vi.fn(() => null),
|
||||
markProfileUsed: vi.fn(),
|
||||
};
|
||||
mockGetClaudeProfileManager.mockReturnValue(profileManager);
|
||||
|
||||
const terminal = createMockTerminal();
|
||||
|
||||
const { invokeClaude } = await import('../claude-integration-handler');
|
||||
invokeClaude(terminal, '/tmp/project', undefined, () => null, vi.fn());
|
||||
|
||||
const written = vi.mocked(terminal.pty.write).mock.calls[0][0] as string;
|
||||
expect(written).toContain("cd '/tmp/project' && ");
|
||||
expect(written).toContain("PATH='/opt/claude/bin:/usr/bin' ");
|
||||
expect(written).toContain("'/opt/claude bin/claude'\\''s'");
|
||||
expect(mockReleaseSessionId).toHaveBeenCalledWith('term-1');
|
||||
expect(mockPersistSession).toHaveBeenCalledWith(terminal);
|
||||
expect(profileManager.getActiveProfile).toHaveBeenCalled();
|
||||
expect(profileManager.markProfileUsed).toHaveBeenCalledWith('default');
|
||||
});
|
||||
|
||||
it('converts Windows PATH separators to colons for bash invocations', async () => {
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||
|
||||
try {
|
||||
mockGetClaudeCliInvocation.mockReturnValue({
|
||||
command: 'C:\\Tools\\claude\\claude.exe',
|
||||
env: { PATH: 'C:\\Tools\\claude;C:\\Windows' },
|
||||
});
|
||||
const profileManager = {
|
||||
getActiveProfile: vi.fn(() => ({ id: 'default', name: 'Default', isDefault: true })),
|
||||
getProfile: vi.fn(),
|
||||
getProfileToken: vi.fn(() => null),
|
||||
markProfileUsed: vi.fn(),
|
||||
};
|
||||
mockGetClaudeProfileManager.mockReturnValue(profileManager);
|
||||
|
||||
const terminal = createMockTerminal();
|
||||
|
||||
const { invokeClaude } = await import('../claude-integration-handler');
|
||||
invokeClaude(terminal, '/tmp/project', undefined, () => null, vi.fn());
|
||||
|
||||
const written = vi.mocked(terminal.pty.write).mock.calls[0][0] as string;
|
||||
expect(written).toContain("PATH='C:\\Tools\\claude:C:\\Windows' ");
|
||||
expect(written).not.toContain('C:\\Tools\\claude;C:\\Windows');
|
||||
} finally {
|
||||
if (originalPlatform) {
|
||||
Object.defineProperty(process, 'platform', originalPlatform);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('throws when invokeClaude cannot resolve the CLI invocation', async () => {
|
||||
mockGetClaudeCliInvocation.mockImplementation(() => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
const profileManager = {
|
||||
getActiveProfile: vi.fn(() => ({ id: 'default', name: 'Default', isDefault: true })),
|
||||
getProfile: vi.fn(),
|
||||
getProfileToken: vi.fn(() => null),
|
||||
markProfileUsed: vi.fn(),
|
||||
};
|
||||
mockGetClaudeProfileManager.mockReturnValue(profileManager);
|
||||
|
||||
const terminal = createMockTerminal({ id: 'term-err' });
|
||||
|
||||
const { invokeClaude } = await import('../claude-integration-handler');
|
||||
expect(() => invokeClaude(terminal, '/tmp/project', undefined, () => null, vi.fn())).toThrow('boom');
|
||||
expect(mockReleaseSessionId).toHaveBeenCalledWith('term-err');
|
||||
expect(terminal.pty.write).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws when resumeClaude cannot resolve the CLI invocation', async () => {
|
||||
mockGetClaudeCliInvocation.mockImplementation(() => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
|
||||
const terminal = createMockTerminal({
|
||||
id: 'term-err-2',
|
||||
cwd: undefined,
|
||||
projectPath: '/tmp/project',
|
||||
});
|
||||
|
||||
const { resumeClaude } = await import('../claude-integration-handler');
|
||||
expect(() => resumeClaude(terminal, 'abc123', () => null)).toThrow('boom');
|
||||
expect(terminal.pty.write).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws when writing the OAuth token temp file fails', async () => {
|
||||
mockGetClaudeCliInvocation.mockReturnValue({
|
||||
command: '/opt/claude/bin/claude',
|
||||
env: { PATH: '/opt/claude/bin:/usr/bin' },
|
||||
});
|
||||
const profileManager = {
|
||||
getActiveProfile: vi.fn(),
|
||||
getProfile: vi.fn(() => ({
|
||||
id: 'prof-err',
|
||||
name: 'Work',
|
||||
isDefault: false,
|
||||
oauthToken: 'token-value',
|
||||
})),
|
||||
getProfileToken: vi.fn(() => 'token-value'),
|
||||
markProfileUsed: vi.fn(),
|
||||
};
|
||||
mockGetClaudeProfileManager.mockReturnValue(profileManager);
|
||||
vi.mocked(writeFileSync).mockImplementationOnce(() => {
|
||||
throw new Error('disk full');
|
||||
});
|
||||
|
||||
const terminal = createMockTerminal({ id: 'term-err-3' });
|
||||
|
||||
const { invokeClaude } = await import('../claude-integration-handler');
|
||||
expect(() => invokeClaude(terminal, '/tmp/project', 'prof-err', () => null, vi.fn())).toThrow('disk full');
|
||||
expect(terminal.pty.write).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the temp token flow when the active profile has an oauth token', async () => {
|
||||
const command = '/opt/claude/bin/claude';
|
||||
const profileManager = {
|
||||
getActiveProfile: vi.fn(),
|
||||
getProfile: vi.fn(() => ({
|
||||
id: 'prof-1',
|
||||
name: 'Work',
|
||||
isDefault: false,
|
||||
oauthToken: 'token-value',
|
||||
})),
|
||||
getProfileToken: vi.fn(() => 'token-value'),
|
||||
markProfileUsed: vi.fn(),
|
||||
};
|
||||
|
||||
mockGetClaudeCliInvocation.mockReturnValue({
|
||||
command,
|
||||
env: { PATH: '/opt/claude/bin:/usr/bin' },
|
||||
});
|
||||
mockGetClaudeProfileManager.mockReturnValue(profileManager);
|
||||
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1234);
|
||||
|
||||
const terminal = createMockTerminal({ id: 'term-3' });
|
||||
|
||||
const { invokeClaude } = await import('../claude-integration-handler');
|
||||
invokeClaude(terminal, '/tmp/project', 'prof-1', () => null, vi.fn());
|
||||
|
||||
const tokenPath = vi.mocked(writeFileSync).mock.calls[0]?.[0] as string;
|
||||
const tokenContents = vi.mocked(writeFileSync).mock.calls[0]?.[1] as string;
|
||||
expect(tokenPath).toMatch(/^\/tmp\/\.claude-token-1234-[0-9a-f]{16}$/);
|
||||
expect(tokenContents).toBe("export CLAUDE_CODE_OAUTH_TOKEN='token-value'\n");
|
||||
const written = vi.mocked(terminal.pty.write).mock.calls[0][0] as string;
|
||||
expect(written).toContain("HISTFILE= HISTCONTROL=ignorespace ");
|
||||
expect(written).toContain(`source '${tokenPath}'`);
|
||||
expect(written).toContain(`rm -f '${tokenPath}'`);
|
||||
expect(written).toContain(`exec '${command}'`);
|
||||
expect(profileManager.getProfile).toHaveBeenCalledWith('prof-1');
|
||||
expect(mockPersistSession).toHaveBeenCalledWith(terminal);
|
||||
|
||||
nowSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('prefers the temp token flow when profile has both oauth token and config dir', async () => {
|
||||
const command = '/opt/claude/bin/claude';
|
||||
const profileManager = {
|
||||
getActiveProfile: vi.fn(),
|
||||
getProfile: vi.fn(() => ({
|
||||
id: 'prof-both',
|
||||
name: 'Work',
|
||||
isDefault: false,
|
||||
oauthToken: 'token-value',
|
||||
configDir: '/tmp/claude-config',
|
||||
})),
|
||||
getProfileToken: vi.fn(() => 'token-value'),
|
||||
markProfileUsed: vi.fn(),
|
||||
};
|
||||
|
||||
mockGetClaudeCliInvocation.mockReturnValue({
|
||||
command,
|
||||
env: { PATH: '/opt/claude/bin:/usr/bin' },
|
||||
});
|
||||
mockGetClaudeProfileManager.mockReturnValue(profileManager);
|
||||
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(5678);
|
||||
|
||||
const terminal = createMockTerminal({ id: 'term-both' });
|
||||
|
||||
const { invokeClaude } = await import('../claude-integration-handler');
|
||||
invokeClaude(terminal, '/tmp/project', 'prof-both', () => null, vi.fn());
|
||||
|
||||
const tokenPath = vi.mocked(writeFileSync).mock.calls[0]?.[0] as string;
|
||||
const tokenContents = vi.mocked(writeFileSync).mock.calls[0]?.[1] as string;
|
||||
expect(tokenPath).toMatch(/^\/tmp\/\.claude-token-5678-[0-9a-f]{16}$/);
|
||||
expect(tokenContents).toBe("export CLAUDE_CODE_OAUTH_TOKEN='token-value'\n");
|
||||
const written = vi.mocked(terminal.pty.write).mock.calls[0][0] as string;
|
||||
expect(written).toContain(`source '${tokenPath}'`);
|
||||
expect(written).toContain(`rm -f '${tokenPath}'`);
|
||||
expect(written).toContain(`exec '${command}'`);
|
||||
expect(written).not.toContain('CLAUDE_CONFIG_DIR=');
|
||||
expect(profileManager.getProfile).toHaveBeenCalledWith('prof-both');
|
||||
expect(mockPersistSession).toHaveBeenCalledWith(terminal);
|
||||
expect(profileManager.markProfileUsed).toHaveBeenCalledWith('prof-both');
|
||||
|
||||
nowSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('handles missing profiles by falling back to the default command', async () => {
|
||||
const command = '/opt/claude/bin/claude';
|
||||
const profileManager = {
|
||||
getActiveProfile: vi.fn(),
|
||||
getProfile: vi.fn(() => undefined),
|
||||
getProfileToken: vi.fn(() => null),
|
||||
markProfileUsed: vi.fn(),
|
||||
};
|
||||
|
||||
mockGetClaudeCliInvocation.mockReturnValue({
|
||||
command,
|
||||
env: { PATH: '/opt/claude/bin:/usr/bin' },
|
||||
});
|
||||
mockGetClaudeProfileManager.mockReturnValue(profileManager);
|
||||
|
||||
const terminal = createMockTerminal({ id: 'term-6' });
|
||||
|
||||
const { invokeClaude } = await import('../claude-integration-handler');
|
||||
invokeClaude(terminal, '/tmp/project', 'missing', () => null, vi.fn());
|
||||
|
||||
const written = vi.mocked(terminal.pty.write).mock.calls[0][0] as string;
|
||||
expect(written).toContain(`'${command}'`);
|
||||
expect(profileManager.getProfile).toHaveBeenCalledWith('missing');
|
||||
expect(profileManager.markProfileUsed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the config dir flow when the active profile has a config dir', async () => {
|
||||
const command = '/opt/claude/bin/claude';
|
||||
const profileManager = {
|
||||
getActiveProfile: vi.fn(),
|
||||
getProfile: vi.fn(() => ({
|
||||
id: 'prof-2',
|
||||
name: 'Work',
|
||||
isDefault: false,
|
||||
configDir: '/tmp/claude-config',
|
||||
})),
|
||||
getProfileToken: vi.fn(() => null),
|
||||
markProfileUsed: vi.fn(),
|
||||
};
|
||||
|
||||
mockGetClaudeCliInvocation.mockReturnValue({
|
||||
command,
|
||||
env: { PATH: '/opt/claude/bin:/usr/bin' },
|
||||
});
|
||||
mockGetClaudeProfileManager.mockReturnValue(profileManager);
|
||||
|
||||
const terminal = createMockTerminal({ id: 'term-4' });
|
||||
|
||||
const { invokeClaude } = await import('../claude-integration-handler');
|
||||
invokeClaude(terminal, '/tmp/project', 'prof-2', () => null, vi.fn());
|
||||
|
||||
const written = vi.mocked(terminal.pty.write).mock.calls[0][0] as string;
|
||||
expect(written).toContain("HISTFILE= HISTCONTROL=ignorespace ");
|
||||
expect(written).toContain("CLAUDE_CONFIG_DIR='/tmp/claude-config'");
|
||||
expect(written).toContain(`exec '${command}'`);
|
||||
expect(profileManager.getProfile).toHaveBeenCalledWith('prof-2');
|
||||
expect(profileManager.markProfileUsed).toHaveBeenCalledWith('prof-2');
|
||||
expect(mockPersistSession).toHaveBeenCalledWith(terminal);
|
||||
});
|
||||
|
||||
it('uses profile switching when a non-default profile is requested', async () => {
|
||||
const command = '/opt/claude/bin/claude';
|
||||
const profileManager = {
|
||||
getActiveProfile: vi.fn(),
|
||||
getProfile: vi.fn(() => ({
|
||||
id: 'prof-3',
|
||||
name: 'Team',
|
||||
isDefault: false,
|
||||
})),
|
||||
getProfileToken: vi.fn(() => null),
|
||||
markProfileUsed: vi.fn(),
|
||||
};
|
||||
|
||||
mockGetClaudeCliInvocation.mockReturnValue({
|
||||
command,
|
||||
env: { PATH: '/opt/claude/bin:/usr/bin' },
|
||||
});
|
||||
mockGetClaudeProfileManager.mockReturnValue(profileManager);
|
||||
|
||||
const terminal = createMockTerminal({ id: 'term-5' });
|
||||
|
||||
const { invokeClaude } = await import('../claude-integration-handler');
|
||||
invokeClaude(terminal, '/tmp/project', 'prof-3', () => null, vi.fn());
|
||||
|
||||
const written = vi.mocked(terminal.pty.write).mock.calls[0][0] as string;
|
||||
expect(written).toContain(`'${command}'`);
|
||||
expect(written).toContain("PATH='/opt/claude/bin:/usr/bin' ");
|
||||
expect(profileManager.getProfile).toHaveBeenCalledWith('prof-3');
|
||||
expect(profileManager.markProfileUsed).toHaveBeenCalledWith('prof-3');
|
||||
expect(mockPersistSession).toHaveBeenCalledWith(terminal);
|
||||
});
|
||||
|
||||
it('uses the resolved CLI path for resume and continue', async () => {
|
||||
mockGetClaudeCliInvocation.mockReturnValue({
|
||||
command: '/opt/claude/bin/claude',
|
||||
env: { PATH: '/opt/claude/bin:/usr/bin' },
|
||||
});
|
||||
|
||||
const terminal = createMockTerminal({
|
||||
id: 'term-2',
|
||||
cwd: undefined,
|
||||
projectPath: '/tmp/project',
|
||||
});
|
||||
|
||||
const { resumeClaude } = await import('../claude-integration-handler');
|
||||
resumeClaude(terminal, 'abc123', () => null);
|
||||
|
||||
const resumeCall = vi.mocked(terminal.pty.write).mock.calls[0][0] as string;
|
||||
expect(resumeCall).toContain("PATH='/opt/claude/bin:/usr/bin' ");
|
||||
expect(resumeCall).toContain("'/opt/claude/bin/claude' --resume 'abc123'");
|
||||
expect(terminal.claudeSessionId).toBe('abc123');
|
||||
expect(terminal.isClaudeMode).toBe(true);
|
||||
expect(mockPersistSession).toHaveBeenCalledWith(terminal);
|
||||
|
||||
vi.mocked(terminal.pty.write).mockClear();
|
||||
mockPersistSession.mockClear();
|
||||
terminal.projectPath = undefined;
|
||||
terminal.claudeSessionId = undefined;
|
||||
terminal.isClaudeMode = false;
|
||||
resumeClaude(terminal, undefined, () => null);
|
||||
const continueCall = vi.mocked(terminal.pty.write).mock.calls[0][0] as string;
|
||||
expect(continueCall).toContain("'/opt/claude/bin/claude' --continue");
|
||||
expect(terminal.isClaudeMode).toBe(true);
|
||||
expect(terminal.claudeSessionId).toBeUndefined();
|
||||
expect(mockPersistSession).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -6,12 +6,14 @@
|
||||
import * as os from 'os';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as crypto from 'crypto';
|
||||
import { IPC_CHANNELS } from '../../shared/constants';
|
||||
import { getClaudeProfileManager } from '../claude-profile-manager';
|
||||
import * as OutputParser from './output-parser';
|
||||
import * as SessionHandler from './session-handler';
|
||||
import { debugLog, debugError } from '../../shared/utils/debug-logger';
|
||||
import { escapeShellArg, buildCdCommand } from '../../shared/utils/shell-escape';
|
||||
import { getClaudeCliInvocation } from '../claude-cli-utils';
|
||||
import type {
|
||||
TerminalProcess,
|
||||
WindowGetter,
|
||||
@@ -19,6 +21,10 @@ import type {
|
||||
OAuthTokenEvent
|
||||
} from './types';
|
||||
|
||||
function normalizePathForBash(envPath: string): string {
|
||||
return process.platform === 'win32' ? envPath.replace(/;/g, ':') : envPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle rate limit detection and profile switching
|
||||
*/
|
||||
@@ -236,6 +242,11 @@ export function invokeClaude(
|
||||
|
||||
// Use safe shell escaping to prevent command injection
|
||||
const cwdCommand = buildCdCommand(cwd);
|
||||
const { command: claudeCmd, env: claudeEnv } = getClaudeCliInvocation();
|
||||
const escapedClaudeCmd = escapeShellArg(claudeCmd);
|
||||
const pathPrefix = claudeEnv.PATH
|
||||
? `PATH=${escapeShellArg(normalizePathForBash(claudeEnv.PATH))} `
|
||||
: '';
|
||||
const needsEnvOverride = profileId && profileId !== previousProfileId;
|
||||
|
||||
debugLog('[ClaudeIntegration:invokeClaude] Environment override check:', {
|
||||
@@ -252,9 +263,15 @@ export function invokeClaude(
|
||||
});
|
||||
|
||||
if (token) {
|
||||
const tempFile = path.join(os.tmpdir(), `.claude-token-${Date.now()}`);
|
||||
const nonce = crypto.randomBytes(8).toString('hex');
|
||||
const tempFile = path.join(os.tmpdir(), `.claude-token-${Date.now()}-${nonce}`);
|
||||
const escapedTempFile = escapeShellArg(tempFile);
|
||||
debugLog('[ClaudeIntegration:invokeClaude] Writing token to temp file:', tempFile);
|
||||
fs.writeFileSync(tempFile, `export CLAUDE_CODE_OAUTH_TOKEN="${token}"\n`, { mode: 0o600 });
|
||||
fs.writeFileSync(
|
||||
tempFile,
|
||||
`export CLAUDE_CODE_OAUTH_TOKEN=${escapeShellArg(token)}\n`,
|
||||
{ mode: 0o600 }
|
||||
);
|
||||
|
||||
// Clear terminal and run command without adding to shell history:
|
||||
// - HISTFILE= disables history file writing for the current command
|
||||
@@ -262,9 +279,10 @@ export function invokeClaude(
|
||||
// - Leading space ensures the command is ignored even if HISTCONTROL was already set
|
||||
// - Uses subshell (...) to isolate environment changes
|
||||
// This prevents temp file paths from appearing in shell history
|
||||
const command = `clear && ${cwdCommand} HISTFILE= HISTCONTROL=ignorespace bash -c 'source "${tempFile}" && rm -f "${tempFile}" && exec claude'\r`;
|
||||
const command = `clear && ${cwdCommand}HISTFILE= HISTCONTROL=ignorespace ${pathPrefix}bash -c "source ${escapedTempFile} && rm -f ${escapedTempFile} && exec ${escapedClaudeCmd}"\r`;
|
||||
debugLog('[ClaudeIntegration:invokeClaude] Executing command (temp file method, history-safe)');
|
||||
terminal.pty.write(command);
|
||||
profileManager.markProfileUsed(activeProfile.id);
|
||||
|
||||
// Update terminal title and persist session
|
||||
const title = `Claude (${activeProfile.name})`;
|
||||
@@ -288,9 +306,10 @@ export function invokeClaude(
|
||||
// SECURITY: Use escapeShellArg for configDir to prevent command injection
|
||||
// Set CLAUDE_CONFIG_DIR as env var before bash -c to avoid embedding user input in the command string
|
||||
const escapedConfigDir = escapeShellArg(activeProfile.configDir);
|
||||
const command = `clear && ${cwdCommand}HISTFILE= HISTCONTROL=ignorespace CLAUDE_CONFIG_DIR=${escapedConfigDir} bash -c 'exec claude'\r`;
|
||||
const command = `clear && ${cwdCommand}HISTFILE= HISTCONTROL=ignorespace CLAUDE_CONFIG_DIR=${escapedConfigDir} ${pathPrefix}bash -c "exec ${escapedClaudeCmd}"\r`;
|
||||
debugLog('[ClaudeIntegration:invokeClaude] Executing command (configDir method, history-safe)');
|
||||
terminal.pty.write(command);
|
||||
profileManager.markProfileUsed(activeProfile.id);
|
||||
|
||||
// Update terminal title and persist session
|
||||
const title = `Claude (${activeProfile.name})`;
|
||||
@@ -317,7 +336,7 @@ export function invokeClaude(
|
||||
debugLog('[ClaudeIntegration:invokeClaude] Using terminal environment for non-default profile:', activeProfile.name);
|
||||
}
|
||||
|
||||
const command = `${cwdCommand}claude\r`;
|
||||
const command = `${cwdCommand}${pathPrefix}${escapedClaudeCmd}\r`;
|
||||
debugLog('[ClaudeIntegration:invokeClaude] Executing command (default method):', command);
|
||||
terminal.pty.write(command);
|
||||
|
||||
@@ -356,14 +375,21 @@ export function resumeClaude(
|
||||
getWindow: WindowGetter
|
||||
): void {
|
||||
terminal.isClaudeMode = true;
|
||||
SessionHandler.releaseSessionId(terminal.id);
|
||||
|
||||
const { command: claudeCmd, env: claudeEnv } = getClaudeCliInvocation();
|
||||
const escapedClaudeCmd = escapeShellArg(claudeCmd);
|
||||
const pathPrefix = claudeEnv.PATH
|
||||
? `PATH=${escapeShellArg(normalizePathForBash(claudeEnv.PATH))} `
|
||||
: '';
|
||||
|
||||
let command: string;
|
||||
if (sessionId) {
|
||||
// SECURITY: Escape sessionId to prevent command injection
|
||||
command = `claude --resume ${escapeShellArg(sessionId)}`;
|
||||
command = `${pathPrefix}${escapedClaudeCmd} --resume ${escapeShellArg(sessionId)}`;
|
||||
terminal.claudeSessionId = sessionId;
|
||||
} else {
|
||||
command = 'claude --continue';
|
||||
command = `${pathPrefix}${escapedClaudeCmd} --continue`;
|
||||
}
|
||||
|
||||
terminal.pty.write(`${command}\r`);
|
||||
|
||||
Reference in New Issue
Block a user