fix(runtime): harden local launch plumbing
This commit is contained in:
parent
6fb0c714ef
commit
5551eea482
9 changed files with 191 additions and 1 deletions
|
|
@ -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' ||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 })),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue