fix(runtime): harden local launch plumbing

This commit is contained in:
777genius 2026-05-22 15:42:51 +03:00
parent 6fb0c714ef
commit 5551eea482
9 changed files with 191 additions and 1 deletions

View file

@ -435,6 +435,10 @@ export class FileWatcher extends EventEmitter {
// Guard: stop() may have been called while awaiting pathExists
if (!this.isWatching) return;
// Teams deliberately use TeamTaskWatchRegistry instead of recursive fs.watch.
// Linux recursive watching expands across the whole team runtime tree and can
// hit EMFILE/ENOSPC. The registry keeps the watched surface aligned with
// processTeamsChange(): team root JSON files plus inbox JSON files only.
const registry = new TeamTaskWatchRegistry({
kind: 'teams',
rootPath: this.teamsPath,
@ -479,6 +483,8 @@ export class FileWatcher extends EventEmitter {
// Guard: stop() may have been called while awaiting pathExists
if (!this.isWatching) return;
// Tasks share the same shallow registry rule as teams. Keep polling out of
// the normal path here; it is only the known-error fallback below.
const registry = new TeamTaskWatchRegistry({
kind: 'tasks',
rootPath: this.tasksPath,
@ -647,6 +653,9 @@ export class FileWatcher extends EventEmitter {
error: unknown,
watcher?: CloseableWatcher
): boolean {
// Polling fallback is intentionally narrow. Projects/todos keep their native
// watcher retry behavior, while teams/tasks can switch to scoped polling only
// after known OS watcher-limit or platform errors from Chokidar/fs.watch.
if ((watcherType !== 'teams' && watcherType !== 'tasks') || !this.isWatchLimitError(error)) {
return false;
}
@ -721,6 +730,8 @@ export class FileWatcher extends EventEmitter {
};
runPoll();
// This is fallback content polling after watcher failure, not the default mode.
// Keep intervals conservative and scoped to the same shallow artifacts as the registry.
const timer = setInterval(runPoll, this.getTeamTaskPollIntervalMs(watcherType));
timer.unref();
@ -799,6 +810,8 @@ export class FileWatcher extends EventEmitter {
const snapshot = new Map<string, string>();
const teamEntries = await this.safeReadDir(this.teamsPath);
// Fallback polling mirrors TeamTaskWatchRegistry. Do not recurse into members,
// runtime, .opencode-runtime, logs, or other deep trees from here.
for (const teamEntry of teamEntries) {
if (!teamEntry.isDirectory()) {
continue;
@ -825,6 +838,8 @@ export class FileWatcher extends EventEmitter {
const snapshot = new Map<string, string>();
const teamEntries = await this.safeReadDir(this.tasksPath);
// Keep task fallback scoped to tasks/<team>/*.json. Hidden files and nested
// runtime directories are intentionally outside the public team-change surface.
for (const teamEntry of teamEntries) {
if (!teamEntry.isDirectory()) {
continue;
@ -1351,6 +1366,9 @@ export class FileWatcher extends EventEmitter {
return;
}
// Keep this classifier in lockstep with TeamTaskWatchRegistry.shouldEmit().
// If a path is emitted by the registry but ignored here, the UI will miss it.
// If a path is added here but not emitted there, Chokidar mode will never see it.
if (relative === 'processes.json') {
const event: TeamChangeEvent = { type: 'process', teamName, detail: relative };
this.emit('team-change', event);
@ -1414,6 +1432,8 @@ export class FileWatcher extends EventEmitter {
return;
}
// Keep this in sync with the tasks registry and fallback polling filters:
// only tasks/<team>/*.json is a user-visible task event.
// Ignore known non-task files in ~/.claude/tasks
if (
relative === '.lock' ||

View file

@ -33,6 +33,9 @@ export async function ensureAgentTeamsMcpLocalLaunchEnv(
return;
}
for (const [key, value] of Object.entries(launchSpec.env ?? {})) {
env[key] = value;
}
env[MCP_COMMAND_ENV] = command;
env[MCP_ENTRY_ENV] = entry;
env[MCP_ARGS_JSON_ENV] = JSON.stringify(launchSpec.args);

View file

@ -1095,6 +1095,7 @@ export class AgentTeamsMcpHttpServer {
};
const childEnv = applyAgentTeamsIdentityEnv({
...process.env,
...launchSpec.env,
AGENT_TEAMS_MCP_CLAUDE_DIR: getClaudeBasePath(),
AGENT_TEAMS_MCP_TRANSPORT: 'httpStream',
AGENT_TEAMS_MCP_HTTP_HOST: MCP_HTTP_HOST,

View file

@ -21,6 +21,7 @@ import type { TeamMemberMcpPolicy, TeamMemberMcpScope } from '@shared/types';
export interface McpLaunchSpec {
command: string;
args: string[];
env?: Record<string, string>;
}
export interface McpLaunchSpecResolveProgress {
@ -40,6 +41,7 @@ interface WriteMcpConfigOptions {
const MCP_SERVER_NAME = 'agent-teams';
const MCP_CLAUDE_DIR_ENV = 'AGENT_TEAMS_MCP_CLAUDE_DIR';
const MCP_CONTROL_URL_ENV = 'CLAUDE_TEAM_CONTROL_URL';
const ELECTRON_RUN_AS_NODE_ENV = 'ELECTRON_RUN_AS_NODE';
const logger = createLogger('Service:TeamMcpConfigBuilder');
const MCP_CONFIG_PREFIX = 'agent-teams-mcp-';
const MCP_CONFIG_REMOVE_RETRY_DELAYS_MS = [25, 75, 150] as const;
@ -58,6 +60,7 @@ const MCP_CONFIG_SCOPE_PRECEDENCE: readonly TeamMemberMcpScope[] = ['user', 'pro
function isPackagedApp(): boolean {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { app } = require('electron') as typeof import('electron');
return app.isPackaged;
} catch {
@ -88,6 +91,25 @@ function getWorkspaceRoot(): string {
return process.cwd();
}
function shouldUsePackagedElectronNodeRuntime(): boolean {
return (
isPackagedApp() &&
process.platform === 'linux' &&
typeof process.execPath === 'string' &&
process.execPath.trim().length > 0
);
}
function buildPackagedElectronNodeLaunchSpec(entry: string): McpLaunchSpec {
return {
command: process.execPath,
args: [entry],
env: {
[ELECTRON_RUN_AS_NODE_ENV]: '1',
},
};
}
function getWorkspaceMcpServerDir(): string {
return path.join(getWorkspaceRoot(), 'mcp-server');
}
@ -450,6 +472,14 @@ export async function resolveAgentTeamsMcpLaunchSpec(
const packagedEntry = await resolvePackagedServerEntry(options);
checked.push(packagedEntry);
if (await pathExists(packagedEntry)) {
if (shouldUsePackagedElectronNodeRuntime()) {
emitProgress(
options,
'electron-node-runtime-found',
'Using bundled Electron Node runtime...'
);
return buildPackagedElectronNodeLaunchSpec(packagedEntry);
}
return {
command: await resolveNodePath(options),
args: [packagedEntry],
@ -520,6 +550,7 @@ export class TeamMcpConfigBuilder {
args: launchSpec.args,
enabled: true,
env: {
...launchSpec.env,
[MCP_CLAUDE_DIR_ENV]: getClaudeBasePath(),
...(controlApiBaseUrl ? { [MCP_CONTROL_URL_ENV]: controlApiBaseUrl } : {}),
},

View file

@ -231,6 +231,20 @@ function resolveNpmNodeShim(content: string, launcherDir: string): DirectWindows
};
}
function resolveNpmNativeShim(content: string, launcherDir: string): DirectWindowsLauncher | null {
const nativeTarget = /(?:^|[&|])\s*"([^"]+\.(?:exe|com))"\s+%\*/im.exec(content)?.[1];
if (!nativeTarget) {
return null;
}
const target = resolveCmdPathTemplate(nativeTarget, launcherDir);
if (!existsSync(target)) {
return null;
}
return { command: target, argsPrefix: [] };
}
/**
* Some Windows launchers are thin wrappers around a real JS entrypoint.
* Running that entrypoint directly with an argv array avoids cmd.exe's
@ -245,7 +259,9 @@ function resolveDirectWindowsLauncher(binaryPath: string): DirectWindowsLauncher
const content = readFileSync(binaryPath, 'utf8');
const launcherDir = path.dirname(binaryPath);
return (
resolveGeneratedBunLauncher(content, launcherDir) ?? resolveNpmNodeShim(content, launcherDir)
resolveGeneratedBunLauncher(content, launcherDir) ??
resolveNpmNodeShim(content, launcherDir) ??
resolveNpmNativeShim(content, launcherDir)
);
} catch {
return null;

View file

@ -276,6 +276,25 @@ describe('buildProviderAwareCliEnv', () => {
);
});
it('propagates Agent Teams MCP launch env overrides for OpenCode provider commands', async () => {
resolveAgentTeamsMcpLaunchSpecMock.mockResolvedValue({
command: '/opt/Agent Teams AI/agent-teams-ai',
args: ['/app/mcp-server/index.js'],
env: { ELECTRON_RUN_AS_NODE: '1' },
});
const { buildProviderAwareCliEnv } =
await import('../../../../src/main/services/runtime/providerAwareCliEnv');
const result = await buildProviderAwareCliEnv({
providerId: 'opencode',
});
expect(result.env.ELECTRON_RUN_AS_NODE).toBe('1');
expect(result.env.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND).toBe(
'/opt/Agent Teams AI/agent-teams-ai'
);
});
it('preserves explicit local Agent Teams MCP launch env for OpenCode provider commands', async () => {
const { buildProviderAwareCliEnv } =
await import('../../../../src/main/services/runtime/providerAwareCliEnv');

View file

@ -127,6 +127,7 @@ describe('AgentTeamsMcpHttpServer', () => {
resolveLaunchSpec: async () => ({
command: 'node',
args: ['mcp-server/dist/index.js'],
env: { ELECTRON_RUN_AS_NODE: '1' },
}),
allocatePort: async () => 41001,
spawnProcess,
@ -167,6 +168,7 @@ describe('AgentTeamsMcpHttpServer', () => {
'/mcp',
],
expect.objectContaining({
ELECTRON_RUN_AS_NODE: '1',
AGENT_TEAMS_MCP_TRANSPORT: 'httpStream',
AGENT_TEAMS_MCP_HTTP_HOST: '127.0.0.1',
AGENT_TEAMS_MCP_HTTP_PORT: '41001',
@ -521,6 +523,7 @@ describe('AgentTeamsMcpHttpServer', () => {
allocatePort,
spawnProcess,
waitForPort,
canListenOnPort: async () => true,
probeHealth: vi.fn(async () => ({ healthy: false, statusCode: null, identity: null })),
});

View file

@ -60,6 +60,7 @@ vi.mock('@main/utils/shellEnv', async (importOriginal) => {
import {
clearResolvedNodePathForTests,
resolveAgentTeamsMcpLaunchSpec,
TeamMcpConfigBuilder,
} from '@main/services/team/TeamMcpConfigBuilder';
import { setAppDataBasePath, setClaudeBasePathOverride } from '@main/utils/pathDecoder';
@ -354,6 +355,53 @@ describe('TeamMcpConfigBuilder', () => {
expect(hoisted.resolveInteractiveShellEnvMock).not.toHaveBeenCalled();
});
it('uses the packaged Electron Node runtime for Linux packaged MCP launches', async () => {
const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
const execPathDescriptor = Object.getOwnPropertyDescriptor(process, 'execPath');
const electronBinary = '/opt/Agent Teams AI/agent-teams-ai';
setPackagedMode(true, '3.0.0');
const resourcesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-resources-'));
createdDirs.push(resourcesDir);
createPackagedServerBundle(resourcesDir, '// packaged linux server');
setResourcesPath(resourcesDir);
Object.defineProperty(process, 'platform', {
value: 'linux',
configurable: true,
});
Object.defineProperty(process, 'execPath', {
value: electronBinary,
configurable: true,
writable: true,
});
try {
const launchSpec = await resolveAgentTeamsMcpLaunchSpec();
const builder = new TeamMcpConfigBuilder();
const configPath = await builder.writeConfigFile();
createdPaths.push(configPath);
const server = readGeneratedServer(configPath);
const expectedEntry = path.join(tempAppData, 'mcp-server', '3.0.0', 'index.js');
expect(launchSpec).toEqual({
command: electronBinary,
args: [expectedEntry],
env: { ELECTRON_RUN_AS_NODE: '1' },
});
expect(server?.command).toBe(electronBinary);
expect(server?.args).toEqual([expectedEntry]);
expect(server?.env?.ELECTRON_RUN_AS_NODE).toBe('1');
expect(hoisted.execCliMock).not.toHaveBeenCalled();
} finally {
if (platformDescriptor) {
Object.defineProperty(process, 'platform', platformDescriptor);
}
if (execPathDescriptor) {
Object.defineProperty(process, 'execPath', execPathDescriptor);
}
}
});
it('falls back to strict shell env lookup when fast Node lookup cannot resolve Node', async () => {
mockBuiltWorkspaceEntryAvailable();
const previousNodeBinary = process.env.NODE_BINARY;

View file

@ -102,6 +102,36 @@ function createExtensionlessNpmNodeLauncher(): {
return { dir, launcher, target };
}
function createNpmNativeExeLauncher(): {
dir: string;
launcher: string;
target: string;
} {
const dir = mkdtempSync(path.join(tmpdir(), 'cat-cli-native-launcher-'));
const targetDir = path.join(dir, 'node_modules', 'opencode-ai', 'bin');
mkdirSync(targetDir, { recursive: true });
const target = path.join(targetDir, 'opencode.exe');
writeFileSync(target, '', 'utf8');
const launcher = path.join(dir, 'opencode.cmd');
writeFileSync(
launcher,
[
'@ECHO off',
'GOTO start',
':find_dp0',
'SET dp0=%~dp0',
'EXIT /b',
':start',
'SETLOCAL',
'CALL :find_dp0',
'endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%dp0%\\node_modules\\opencode-ai\\bin\\opencode.exe" %*',
'',
].join('\r\n'),
'utf8'
);
return { dir, launcher, target };
}
describe('cli child process helpers', () => {
beforeEach(() => {
vi.resetAllMocks();
@ -220,6 +250,25 @@ describe('cli child process helpers', () => {
}
});
it('runs npm native exe cmd launchers directly', () => {
setPlatform('win32');
const fake = new EventEmitter() as ReturnType<typeof spawnCli>;
const spawnMock = child.spawn as unknown as Mock;
spawnMock.mockReturnValue(fake);
const { dir, launcher, target } = createNpmNativeExeLauncher();
try {
const result = spawnCli(launcher, ['serve', '--hostname', '127.0.0.1']);
expect(spawnMock).toHaveBeenCalledTimes(1);
expect(spawnMock.mock.calls[0][0]).toBe(target);
expect(spawnMock.mock.calls[0][1]).toEqual(['serve', '--hostname', '127.0.0.1']);
expect(spawnMock.mock.calls[0][2]).not.toHaveProperty('shell');
expect(spawnMock.mock.calls[0][2]).toMatchObject({ windowsHide: true });
expect(result).toBe(fake);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it('uses shell directly when path contains non-ASCII on windows', () => {
setPlatform('win32');
const fake = {} as any;