mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 06:16:37 +00:00
fix: support Claude subcommands and background sessions
Squash merge PR #1240.\n\nValidated locally from the PR head because trusted-author GitHub CI was skipped for this fork PR:\n- bun run validate\n- bun run build:all\n- bun run test:all\n- CCS_E2E_SKIP_BUILD=1 bun run test:e2e
This commit is contained in:
@@ -26,6 +26,8 @@ import { setupCleanupHandlers } from './session-bridge';
|
||||
import { resolveRuntimeQuotaMonitorProviders } from './account-resolution';
|
||||
import {
|
||||
isClaudeSubcommandInvocation,
|
||||
stripClaudeCodeFeatureBlockingEnv,
|
||||
stripClaudeSubcommandSessionArgs,
|
||||
stripSubcommandBlockingEnv,
|
||||
} from '../../utils/claude-subcommand-detector';
|
||||
|
||||
@@ -94,18 +96,22 @@ export async function launchClaude(context: ClaudeLaunchContext): Promise<ChildP
|
||||
? cfg.customSettingsPath.replace(/^~/, os.homedir())
|
||||
: getProviderSettingsPath(provider);
|
||||
|
||||
// Assemble final args: image analysis tools → browser tools → web search tools → settings
|
||||
const imageAnalysisArgs = imageAnalysisMcpReady
|
||||
? appendThirdPartyImageAnalysisToolArgs(claudeArgs)
|
||||
: claudeArgs;
|
||||
const browserArgs = browserRuntimeEnv
|
||||
? appendBrowserToolArgs(imageAnalysisArgs)
|
||||
: imageAnalysisArgs;
|
||||
// Claude subcommands (agents, doctor, mcp, ...) don't accept `--settings` —
|
||||
// its presence flips `agents` into list mode instead of opening the
|
||||
// interactive agent view. Provider routing still flows via env vars.
|
||||
// Issue #1218.
|
||||
const isSubcommand = isClaudeSubcommandInvocation(claudeArgs);
|
||||
const claudeSessionArgs = isSubcommand
|
||||
? stripClaudeSubcommandSessionArgs(claudeArgs)
|
||||
: claudeArgs;
|
||||
|
||||
// Assemble final args: image analysis tools → browser tools → web search tools → settings
|
||||
const imageAnalysisArgs = imageAnalysisMcpReady
|
||||
? appendThirdPartyImageAnalysisToolArgs(claudeSessionArgs)
|
||||
: claudeSessionArgs;
|
||||
const browserArgs = browserRuntimeEnv
|
||||
? appendBrowserToolArgs(imageAnalysisArgs)
|
||||
: imageAnalysisArgs;
|
||||
const launchArgs = isSubcommand
|
||||
? appendThirdPartyWebSearchToolArgs(browserArgs)
|
||||
: ['--settings', settingsPath, ...appendThirdPartyWebSearchToolArgs(browserArgs)];
|
||||
@@ -119,12 +125,11 @@ export async function launchClaude(context: ClaudeLaunchContext): Promise<ChildP
|
||||
settingsPath,
|
||||
claudeConfigDir: inheritedClaudeConfigDir,
|
||||
});
|
||||
const baseTracedEnv = stripClaudeCodeFeatureBlockingEnv({ ...env, ...traceEnv });
|
||||
// Strip telemetry-disable env vars for subcommands; otherwise Claude's
|
||||
// `agents`/`mcp`/... TUIs silently fall back to non-interactive list mode.
|
||||
// Issue #1218.
|
||||
const tracedEnv = isSubcommand
|
||||
? stripSubcommandBlockingEnv({ ...env, ...traceEnv })
|
||||
: { ...env, ...traceEnv };
|
||||
const tracedEnv = isSubcommand ? stripSubcommandBlockingEnv(baseTracedEnv) : baseTracedEnv;
|
||||
|
||||
// Spawn: Windows .cmd/.bat/.ps1 need shell escaping; all others spawn directly
|
||||
let claude: ChildProcess;
|
||||
@@ -158,6 +163,9 @@ export async function launchClaude(context: ClaudeLaunchContext): Promise<ChildP
|
||||
}
|
||||
}
|
||||
|
||||
const hasSessionProxy = Boolean(codexReasoningProxy || toolSanitizationProxy || httpsTunnel);
|
||||
const backgroundKeepAliveBaseUrl = hasSessionProxy ? tracedEnv.ANTHROPIC_BASE_URL : undefined;
|
||||
|
||||
// Wire cleanup handlers (process exit, SIGINT, SIGTERM, proxy teardown)
|
||||
setupCleanupHandlers(
|
||||
claude,
|
||||
@@ -166,7 +174,8 @@ export async function launchClaude(context: ClaudeLaunchContext): Promise<ChildP
|
||||
codexReasoningProxy,
|
||||
toolSanitizationProxy,
|
||||
httpsTunnel,
|
||||
verbose
|
||||
verbose,
|
||||
{ backgroundKeepAliveBaseUrl }
|
||||
);
|
||||
|
||||
return claude;
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* - Startup lock coordination
|
||||
*/
|
||||
|
||||
import { ChildProcess } from 'child_process';
|
||||
import { ChildProcess, execFileSync } from 'child_process';
|
||||
import { info, warn } from '../../utils/ui';
|
||||
import { getInstalledCliproxyVersion } from '../binary-manager';
|
||||
import { CLIProxyBackend } from '../types';
|
||||
@@ -33,6 +33,70 @@ export interface ProxySessionResult {
|
||||
shouldSpawn: boolean;
|
||||
}
|
||||
|
||||
export interface SessionProxyKeepAliveOptions {
|
||||
backgroundKeepAliveBaseUrl?: string;
|
||||
keepAlivePollIntervalMs?: number;
|
||||
hasBackgroundWorkerUsingBaseUrl?: (baseUrl: string) => boolean;
|
||||
}
|
||||
|
||||
export interface ShouldKeepSessionProxiesAliveOptions {
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
backgroundKeepAliveBaseUrl?: string;
|
||||
hasBackgroundWorkerUsingBaseUrl: (baseUrl: string) => boolean;
|
||||
}
|
||||
|
||||
const CLAUDE_BG_WORKER_ARGS = ['--bg-spare', '--bg-pty-host'] as const;
|
||||
|
||||
export function hasClaudeBackgroundWorkerUsingBaseUrlInProcessList(
|
||||
processList: string,
|
||||
baseUrl: string
|
||||
): boolean {
|
||||
const envNeedle = `ANTHROPIC_BASE_URL=${baseUrl}`;
|
||||
return processList
|
||||
.split(/\r?\n/)
|
||||
.some(
|
||||
(line) => line.includes(envNeedle) && CLAUDE_BG_WORKER_ARGS.some((arg) => line.includes(arg))
|
||||
);
|
||||
}
|
||||
|
||||
export function hasClaudeBackgroundWorkerUsingBaseUrl(baseUrl: string): boolean {
|
||||
if (process.platform === 'win32') {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const args of [['auxEww'], ['auxeww'], ['eww', '-axo', 'pid=,command=']]) {
|
||||
try {
|
||||
const processList = execFileSync('ps', args, {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
});
|
||||
if (hasClaudeBackgroundWorkerUsingBaseUrlInProcessList(processList, baseUrl)) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function shouldKeepSessionProxiesAlive(
|
||||
options: ShouldKeepSessionProxiesAliveOptions
|
||||
): boolean {
|
||||
if (options.code !== 0 || options.signal) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const baseUrl = options.backgroundKeepAliveBaseUrl;
|
||||
if (!baseUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return options.hasBackgroundWorkerUsingBaseUrl(baseUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for existing proxy and handle version mismatch, or determine if new spawn needed
|
||||
*/
|
||||
@@ -180,7 +244,8 @@ export function setupCleanupHandlers(
|
||||
codexReasoningProxy: unknown,
|
||||
toolSanitizationProxy: unknown,
|
||||
httpsTunnel: unknown,
|
||||
verbose: boolean
|
||||
verbose: boolean,
|
||||
keepAliveOptions: SessionProxyKeepAliveOptions = {}
|
||||
): void {
|
||||
const log = (msg: string) => {
|
||||
if (verbose) {
|
||||
@@ -188,66 +253,67 @@ export function setupCleanupHandlers(
|
||||
}
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
stopQuotaMonitor();
|
||||
log('Parent signal received, cleaning up');
|
||||
let resourcesStopped = false;
|
||||
|
||||
if (
|
||||
codexReasoningProxy &&
|
||||
typeof codexReasoningProxy === 'object' &&
|
||||
'stop' in codexReasoningProxy
|
||||
) {
|
||||
(codexReasoningProxy as { stop: () => void }).stop();
|
||||
const stopResource = (resource: unknown) => {
|
||||
if (resource && typeof resource === 'object' && 'stop' in resource) {
|
||||
(resource as { stop: () => void }).stop();
|
||||
}
|
||||
|
||||
if (
|
||||
toolSanitizationProxy &&
|
||||
typeof toolSanitizationProxy === 'object' &&
|
||||
'stop' in toolSanitizationProxy
|
||||
) {
|
||||
(toolSanitizationProxy as { stop: () => void }).stop();
|
||||
}
|
||||
|
||||
if (httpsTunnel && typeof httpsTunnel === 'object' && 'stop' in httpsTunnel) {
|
||||
(httpsTunnel as { stop: () => void }).stop();
|
||||
}
|
||||
|
||||
// Unregister session, proxy keeps running (local mode only)
|
||||
if (sessionId) {
|
||||
unregisterSession(sessionId, sessionPort);
|
||||
}
|
||||
claude.kill('SIGTERM');
|
||||
};
|
||||
|
||||
claude.on('exit', (code, signal) => {
|
||||
const stopSessionResources = () => {
|
||||
if (resourcesStopped) return;
|
||||
resourcesStopped = true;
|
||||
stopQuotaMonitor();
|
||||
log(`Claude exited: code=${code}, signal=${signal}`);
|
||||
stopResource(codexReasoningProxy);
|
||||
stopResource(toolSanitizationProxy);
|
||||
stopResource(httpsTunnel);
|
||||
|
||||
if (
|
||||
codexReasoningProxy &&
|
||||
typeof codexReasoningProxy === 'object' &&
|
||||
'stop' in codexReasoningProxy
|
||||
) {
|
||||
(codexReasoningProxy as { stop: () => void }).stop();
|
||||
}
|
||||
|
||||
if (
|
||||
toolSanitizationProxy &&
|
||||
typeof toolSanitizationProxy === 'object' &&
|
||||
'stop' in toolSanitizationProxy
|
||||
) {
|
||||
(toolSanitizationProxy as { stop: () => void }).stop();
|
||||
}
|
||||
|
||||
if (httpsTunnel && typeof httpsTunnel === 'object' && 'stop' in httpsTunnel) {
|
||||
(httpsTunnel as { stop: () => void }).stop();
|
||||
}
|
||||
|
||||
// Unregister this session (proxy keeps running for persistence) - only for local mode
|
||||
if (sessionId) {
|
||||
unregisterSession(sessionId, sessionPort);
|
||||
log(`Session ${sessionId} unregistered, proxy persists for other sessions or future use`);
|
||||
}
|
||||
};
|
||||
|
||||
const backgroundProbe =
|
||||
keepAliveOptions.hasBackgroundWorkerUsingBaseUrl ?? hasClaudeBackgroundWorkerUsingBaseUrl;
|
||||
|
||||
const startKeepAliveWatcher = (baseUrl: string) => {
|
||||
const timer = setInterval(() => {
|
||||
if (backgroundProbe(baseUrl)) return;
|
||||
log('No Claude bg worker is using the session proxy; cleaning up');
|
||||
stopSessionResources();
|
||||
process.exit(0);
|
||||
}, keepAliveOptions.keepAlivePollIntervalMs ?? 30000);
|
||||
timer.unref();
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
log('Parent signal received, cleaning up');
|
||||
stopSessionResources();
|
||||
claude.kill('SIGTERM');
|
||||
};
|
||||
|
||||
claude.on('exit', (code, signal) => {
|
||||
log(`Claude exited: code=${code}, signal=${signal}`);
|
||||
const backgroundKeepAliveBaseUrl = keepAliveOptions.backgroundKeepAliveBaseUrl;
|
||||
|
||||
if (
|
||||
shouldKeepSessionProxiesAlive({
|
||||
code,
|
||||
signal,
|
||||
backgroundKeepAliveBaseUrl,
|
||||
hasBackgroundWorkerUsingBaseUrl: backgroundProbe,
|
||||
}) &&
|
||||
backgroundKeepAliveBaseUrl
|
||||
) {
|
||||
stopQuotaMonitor();
|
||||
log(`Keeping session proxy alive for Claude bg worker: ${backgroundKeepAliveBaseUrl}`);
|
||||
startKeepAliveWatcher(backgroundKeepAliveBaseUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
stopSessionResources();
|
||||
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal as NodeJS.Signals);
|
||||
@@ -257,33 +323,8 @@ export function setupCleanupHandlers(
|
||||
});
|
||||
|
||||
claude.on('error', (error) => {
|
||||
stopQuotaMonitor();
|
||||
console.error(require('../../utils/ui').fail(`Claude CLI error: ${error}`));
|
||||
|
||||
if (
|
||||
codexReasoningProxy &&
|
||||
typeof codexReasoningProxy === 'object' &&
|
||||
'stop' in codexReasoningProxy
|
||||
) {
|
||||
(codexReasoningProxy as { stop: () => void }).stop();
|
||||
}
|
||||
|
||||
if (
|
||||
toolSanitizationProxy &&
|
||||
typeof toolSanitizationProxy === 'object' &&
|
||||
'stop' in toolSanitizationProxy
|
||||
) {
|
||||
(toolSanitizationProxy as { stop: () => void }).stop();
|
||||
}
|
||||
|
||||
if (httpsTunnel && typeof httpsTunnel === 'object' && 'stop' in httpsTunnel) {
|
||||
(httpsTunnel as { stop: () => void }).stop();
|
||||
}
|
||||
|
||||
// Unregister session, proxy keeps running (local mode only)
|
||||
if (sessionId) {
|
||||
unregisterSession(sessionId, sessionPort);
|
||||
}
|
||||
stopSessionResources();
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -44,7 +44,10 @@ import {
|
||||
normalizeDeprecatedGlmtEnv,
|
||||
} from '../../utils/glmt-deprecation';
|
||||
import { createOpenAICompatLaunchSettings } from '../../utils/openai-compat-launch-settings';
|
||||
import { isClaudeSubcommandInvocation } from '../../utils/claude-subcommand-detector';
|
||||
import {
|
||||
isClaudeSubcommandInvocation,
|
||||
stripClaudeSubcommandSessionArgs,
|
||||
} from '../../utils/claude-subcommand-detector';
|
||||
import {
|
||||
resolveDroidProvider,
|
||||
evaluateTargetRuntimeCompatibility,
|
||||
@@ -371,8 +374,11 @@ export async function runSettingsFlow(ctx: ProfileDispatchContext): Promise<void
|
||||
// Claude subcommands reject `--settings` (it flips `agents` to list mode).
|
||||
// Routing env vars still flow via proxyEnv. Issue #1218.
|
||||
const isSubcommand = isClaudeSubcommandInvocation(browserArgs);
|
||||
const subcommandArgs = isSubcommand
|
||||
? stripClaudeSubcommandSessionArgs(browserArgs)
|
||||
: browserArgs;
|
||||
const launchArgs = isSubcommand
|
||||
? appendThirdPartyWebSearchToolArgs(browserArgs)
|
||||
? appendThirdPartyWebSearchToolArgs(subcommandArgs)
|
||||
: [
|
||||
'--settings',
|
||||
launchSettings.settingsPath,
|
||||
@@ -393,8 +399,9 @@ export async function runSettingsFlow(ctx: ProfileDispatchContext): Promise<void
|
||||
// Skip `--settings` for Claude subcommands so the interactive agent view
|
||||
// works; env vars from the settings file still flow via envVars. Issue #1218.
|
||||
const isSubcommand = isClaudeSubcommandInvocation(browserArgs);
|
||||
const subcommandArgs = isSubcommand ? stripClaudeSubcommandSessionArgs(browserArgs) : browserArgs;
|
||||
const launchArgs = isSubcommand
|
||||
? appendThirdPartyWebSearchToolArgs(browserArgs)
|
||||
? appendThirdPartyWebSearchToolArgs(subcommandArgs)
|
||||
: ['--settings', expandedSettingsPath, ...appendThirdPartyWebSearchToolArgs(browserArgs)];
|
||||
const traceEnv = createWebSearchTraceContext({
|
||||
launcher: 'ccs.settings-profile',
|
||||
|
||||
@@ -80,9 +80,20 @@ const VALUE_TAKING_FLAGS = new Set<string>([
|
||||
'--setting-sources',
|
||||
'--settings',
|
||||
'--system-prompt',
|
||||
'--teammate-mode',
|
||||
'--tools',
|
||||
]);
|
||||
|
||||
const SUBCOMMAND_SESSION_ONLY_FLAGS = new Set<string>([
|
||||
'--allow-dangerously-skip-permissions',
|
||||
'--dangerously-skip-permissions',
|
||||
]);
|
||||
|
||||
const SUBCOMMAND_SESSION_ONLY_VALUE_FLAGS = new Set<string>([
|
||||
'--permission-mode',
|
||||
'--teammate-mode',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Returns true when `args` look like a Claude subcommand invocation.
|
||||
*
|
||||
@@ -119,27 +130,49 @@ export function isClaudeSubcommandInvocation(args: readonly string[]): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude env vars that disable interactive subcommand TUIs (e.g. the new
|
||||
* `claude agents` agent view) when set. CCS injects these as defaults to
|
||||
* silence telemetry/bug-report prompts in normal sessions, but they trip
|
||||
* subcommands into non-interactive list mode. Issue #1218.
|
||||
*
|
||||
* Strip only for confirmed Claude subcommand invocations — keep the user's
|
||||
* telemetry preference intact for everything else.
|
||||
*/
|
||||
const SUBCOMMAND_BLOCKING_ENV_KEYS = ['DISABLE_TELEMETRY'] as const;
|
||||
export function stripClaudeSubcommandSessionArgs(args: readonly string[]): string[] {
|
||||
if (!isClaudeSubcommandInvocation(args)) {
|
||||
return [...args];
|
||||
}
|
||||
|
||||
const out: string[] = [];
|
||||
for (let i = 0; i < args.length; i += 1) {
|
||||
const arg = args[i];
|
||||
if (SUBCOMMAND_SESSION_ONLY_FLAGS.has(arg)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith('--permission-mode=') || arg.startsWith('--teammate-mode=')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (SUBCOMMAND_SESSION_ONLY_VALUE_FLAGS.has(arg)) {
|
||||
const next = args[i + 1];
|
||||
if (next !== undefined && !next.startsWith('-')) {
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
out.push(arg);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a shallow copy of `env` with subcommand-blocking telemetry vars
|
||||
* removed. Caller is responsible for only invoking this when args are a
|
||||
* Claude subcommand invocation.
|
||||
* Claude Code treats DISABLE_TELEMETRY as a feature kill switch for agent-view
|
||||
* and background-session surfaces, not only as telemetry preference.
|
||||
*/
|
||||
export function stripSubcommandBlockingEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
const CLAUDE_CODE_FEATURE_BLOCKING_ENV_KEYS = ['DISABLE_TELEMETRY'] as const;
|
||||
|
||||
export function stripClaudeCodeFeatureBlockingEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
const out: NodeJS.ProcessEnv = {};
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
if (
|
||||
SUBCOMMAND_BLOCKING_ENV_KEYS.includes(key as (typeof SUBCOMMAND_BLOCKING_ENV_KEYS)[number])
|
||||
CLAUDE_CODE_FEATURE_BLOCKING_ENV_KEYS.includes(
|
||||
key as (typeof CLAUDE_CODE_FEATURE_BLOCKING_ENV_KEYS)[number]
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
@@ -147,3 +180,12 @@ export function stripSubcommandBlockingEnv(env: NodeJS.ProcessEnv): NodeJS.Proce
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a shallow copy of `env` with subcommand-blocking telemetry vars
|
||||
* removed. Caller is responsible for only invoking this when args are a
|
||||
* Claude subcommand invocation.
|
||||
*/
|
||||
export function stripSubcommandBlockingEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
return stripClaudeCodeFeatureBlockingEnv(env);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import { getWebSearchHookEnv } from './websearch-manager';
|
||||
import { wireChildProcessSignals } from './signal-forwarder';
|
||||
import {
|
||||
isClaudeSubcommandInvocation,
|
||||
stripClaudeCodeFeatureBlockingEnv,
|
||||
stripClaudeSubcommandSessionArgs,
|
||||
stripSubcommandBlockingEnv,
|
||||
} from './claude-subcommand-detector';
|
||||
|
||||
@@ -288,15 +290,19 @@ export function execClaude(
|
||||
? stripAnthropicRoutingEnv(mergedEnv, envVars ?? undefined)
|
||||
: mergedEnv;
|
||||
|
||||
const effectiveArgs = isClaudeSubcommandInvocation(args)
|
||||
? stripClaudeSubcommandSessionArgs(args)
|
||||
: args;
|
||||
|
||||
// Strip Claude Code nested session guard env var to allow CCS delegation
|
||||
// (Claude Code v2.1.39+ sets CLAUDECODE to detect nested sessions)
|
||||
let env = stripClaudeCodeEnv(effectiveMergedEnv);
|
||||
let env = stripClaudeCodeFeatureBlockingEnv(stripClaudeCodeEnv(effectiveMergedEnv));
|
||||
|
||||
// For Claude subcommand invocations (`agents`, `mcp`, `doctor`, ...) strip
|
||||
// telemetry-disable env vars that cause upstream Claude Code to fall back
|
||||
// to non-interactive list mode instead of opening the subcommand TUI.
|
||||
// Issue #1218.
|
||||
if (isClaudeSubcommandInvocation(args)) {
|
||||
if (isClaudeSubcommandInvocation(effectiveArgs)) {
|
||||
env = stripSubcommandBlockingEnv(env);
|
||||
}
|
||||
|
||||
@@ -316,7 +322,7 @@ export function execClaude(
|
||||
if (isPowerShellScript) {
|
||||
child = spawn(
|
||||
'powershell.exe',
|
||||
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', claudeCli, ...args],
|
||||
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', claudeCli, ...effectiveArgs],
|
||||
{
|
||||
stdio: 'inherit',
|
||||
windowsHide: true,
|
||||
@@ -325,7 +331,7 @@ export function execClaude(
|
||||
);
|
||||
} else if (needsShell) {
|
||||
// When shell needed: concatenate into string to avoid DEP0190 warning
|
||||
const cmdString = [claudeCli, ...args].map(escapeShellArg).join(' ');
|
||||
const cmdString = [claudeCli, ...effectiveArgs].map(escapeShellArg).join(' ');
|
||||
child = spawn(cmdString, {
|
||||
stdio: 'inherit',
|
||||
windowsHide: true,
|
||||
@@ -334,7 +340,7 @@ export function execClaude(
|
||||
});
|
||||
} else {
|
||||
// When no shell needed: use array form (faster, no shell overhead)
|
||||
child = spawn(claudeCli, args, {
|
||||
child = spawn(claudeCli, effectiveArgs, {
|
||||
stdio: 'inherit',
|
||||
windowsHide: true,
|
||||
env,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import {
|
||||
hasClaudeBackgroundWorkerUsingBaseUrlInProcessList,
|
||||
shouldKeepSessionProxiesAlive,
|
||||
} from '../../../src/cliproxy/executor/session-bridge';
|
||||
|
||||
describe('session bridge background proxy keepalive', () => {
|
||||
it('keeps per-session proxies alive after a clean Claude exit when a bg worker inherited the base URL', () => {
|
||||
expect(
|
||||
shouldKeepSessionProxiesAlive({
|
||||
code: 0,
|
||||
signal: null,
|
||||
backgroundKeepAliveBaseUrl: 'http://127.0.0.1:64314/api/provider/codex',
|
||||
hasBackgroundWorkerUsingBaseUrl: () => true,
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('cleans up per-session proxies when no bg worker inherited the base URL', () => {
|
||||
expect(
|
||||
shouldKeepSessionProxiesAlive({
|
||||
code: 0,
|
||||
signal: null,
|
||||
backgroundKeepAliveBaseUrl: 'http://127.0.0.1:64314/api/provider/codex',
|
||||
hasBackgroundWorkerUsingBaseUrl: () => false,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('cleans up per-session proxies for signaled Claude exits', () => {
|
||||
expect(
|
||||
shouldKeepSessionProxiesAlive({
|
||||
code: null,
|
||||
signal: 'SIGTERM',
|
||||
backgroundKeepAliveBaseUrl: 'http://127.0.0.1:64314/api/provider/codex',
|
||||
hasBackgroundWorkerUsingBaseUrl: () => true,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('detects Claude bg workers that inherited the exact base URL', () => {
|
||||
const baseUrl = 'http://127.0.0.1:64314/api/provider/codex';
|
||||
const processList = `
|
||||
123 /opt/claude/claude.exe --bg-spare /tmp/claim.sock ANTHROPIC_BASE_URL=${baseUrl} ANTHROPIC_AUTH_TOKEN=ccs-internal-managed
|
||||
456 /opt/claude/claude.exe --bg-spare /tmp/claim.sock ANTHROPIC_BASE_URL=http://127.0.0.1:62075/api/provider/codex
|
||||
789 /opt/claude/claude.exe --print ANTHROPIC_BASE_URL=${baseUrl}
|
||||
`;
|
||||
|
||||
expect(hasClaudeBackgroundWorkerUsingBaseUrlInProcessList(processList, baseUrl)).toBe(true);
|
||||
expect(
|
||||
hasClaudeBackgroundWorkerUsingBaseUrlInProcessList(
|
||||
processList,
|
||||
'http://127.0.0.1:65535/api/provider/codex'
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { isClaudeSubcommandInvocation } from '../../../src/utils/claude-subcommand-detector';
|
||||
import {
|
||||
isClaudeSubcommandInvocation,
|
||||
stripClaudeCodeFeatureBlockingEnv,
|
||||
stripClaudeSubcommandSessionArgs,
|
||||
} from '../../../src/utils/claude-subcommand-detector';
|
||||
import { appendThirdPartyWebSearchToolArgs } from '../../../src/utils/websearch/claude-tool-args';
|
||||
import { appendThirdPartyImageAnalysisToolArgs } from '../../../src/utils/image-analysis/claude-tool-args';
|
||||
import { appendBrowserToolArgs } from '../../../src/utils/browser/claude-tool-args';
|
||||
@@ -36,6 +40,14 @@ describe('isClaudeSubcommandInvocation', () => {
|
||||
it('skips past value-taking flags before the positional', () => {
|
||||
expect(isClaudeSubcommandInvocation(['--model', 'sonnet', 'agents'])).toBe(true);
|
||||
expect(isClaudeSubcommandInvocation(['--settings', '/tmp/s.json', 'doctor'])).toBe(true);
|
||||
expect(
|
||||
isClaudeSubcommandInvocation([
|
||||
'--dangerously-skip-permissions',
|
||||
'--teammate-mode',
|
||||
'in-process',
|
||||
'agents',
|
||||
])
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('does not treat a flag value matching a subcommand name as a subcommand', () => {
|
||||
@@ -57,6 +69,59 @@ describe('isClaudeSubcommandInvocation', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripClaudeCodeFeatureBlockingEnv', () => {
|
||||
it('removes telemetry disable env that blocks Claude Code background features', () => {
|
||||
const env = stripClaudeCodeFeatureBlockingEnv({
|
||||
DISABLE_TELEMETRY: '1',
|
||||
DISABLE_BUG_COMMAND: '1',
|
||||
DISABLE_ERROR_REPORTING: '1',
|
||||
ANTHROPIC_MODEL: 'gpt-5.5',
|
||||
});
|
||||
|
||||
expect(env.DISABLE_TELEMETRY).toBeUndefined();
|
||||
expect(env.DISABLE_BUG_COMMAND).toBe('1');
|
||||
expect(env.DISABLE_ERROR_REPORTING).toBe('1');
|
||||
expect(env.ANTHROPIC_MODEL).toBe('gpt-5.5');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripClaudeSubcommandSessionArgs', () => {
|
||||
it('removes session-only flags before a Claude subcommand', () => {
|
||||
expect(
|
||||
stripClaudeSubcommandSessionArgs([
|
||||
'--dangerously-skip-permissions',
|
||||
'--teammate-mode',
|
||||
'in-process',
|
||||
'agents',
|
||||
])
|
||||
).toEqual(['agents']);
|
||||
});
|
||||
|
||||
it('removes session-only flags after a Claude subcommand while preserving subcommand flags', () => {
|
||||
expect(
|
||||
stripClaudeSubcommandSessionArgs([
|
||||
'agents',
|
||||
'--dangerously-skip-permissions',
|
||||
'--teammate-mode',
|
||||
'in-process',
|
||||
'--setting-sources',
|
||||
'user',
|
||||
])
|
||||
).toEqual(['agents', '--setting-sources', 'user']);
|
||||
});
|
||||
|
||||
it('leaves non-subcommand interactive launches unchanged', () => {
|
||||
expect(
|
||||
stripClaudeSubcommandSessionArgs([
|
||||
'--dangerously-skip-permissions',
|
||||
'--teammate-mode',
|
||||
'in-process',
|
||||
'fix the bug',
|
||||
])
|
||||
).toEqual(['--dangerously-skip-permissions', '--teammate-mode', 'in-process', 'fix the bug']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('subcommand passthrough — injectors short-circuit', () => {
|
||||
it('appendThirdPartyWebSearchToolArgs returns args unchanged for subcommand invocations', () => {
|
||||
expect(appendThirdPartyWebSearchToolArgs(['agents'])).toEqual(['agents']);
|
||||
|
||||
Reference in New Issue
Block a user