fix(team): make Windows child env setup fail open
Prepare writable Windows temp/cache env for agent child processes without blocking team launch.\n\n- fail open if cache roots cannot be created or written\n- keep hard child-process preflight out of launch path\n- cover Windows env setup and fail-open behavior
This commit is contained in:
parent
1376017aa9
commit
0e2be0348e
3 changed files with 356 additions and 0 deletions
159
src/main/services/runtime/agentChildProcessPreflight.test.ts
Normal file
159
src/main/services/runtime/agentChildProcessPreflight.test.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
// @vitest-environment node
|
||||
import {
|
||||
applyAgentChildProcessWritableEnv,
|
||||
prepareAgentChildProcessWritableEnv,
|
||||
} from '@main/services/runtime/agentChildProcessPreflight';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
function setPlatform(value: string): void {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
|
||||
describe('agent child-process writable env', () => {
|
||||
let tmpRoot: string;
|
||||
|
||||
beforeEach(() => {
|
||||
setPlatform('win32');
|
||||
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-child-env-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
setPlatform(originalPlatform);
|
||||
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('does not mutate env on non-Windows platforms', async () => {
|
||||
setPlatform('darwin');
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
TEMP: path.join(tmpRoot, 'existing-temp'),
|
||||
};
|
||||
|
||||
const result = await prepareAgentChildProcessWritableEnv(env, { home: tmpRoot });
|
||||
|
||||
expect(result).toEqual({ applied: false });
|
||||
expect(env).toEqual({
|
||||
TEMP: path.join(tmpRoot, 'existing-temp'),
|
||||
});
|
||||
});
|
||||
|
||||
it('prepares stable writable cache and temp env for Windows agents', async () => {
|
||||
const home = path.join(tmpRoot, 'home');
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
COMSPEC: 'cmd.exe',
|
||||
LOCALAPPDATA: path.join(home, 'AppData', 'Local'),
|
||||
};
|
||||
|
||||
const result = await prepareAgentChildProcessWritableEnv(env, { home });
|
||||
|
||||
const expectedBase = path.join(home, 'AppData', 'Local', 'AgentStudio', 'runner-cache');
|
||||
expect(result).toEqual({ applied: true, cacheBase: expectedBase });
|
||||
expect(env.TEMP).toBe(path.join(expectedBase, 'tmp'));
|
||||
expect(env.TMP).toBe(path.join(expectedBase, 'tmp'));
|
||||
expect(env.TMPDIR).toBe(path.join(expectedBase, 'tmp'));
|
||||
expect(env.npm_config_cache).toBe(path.join(expectedBase, 'npm-cache'));
|
||||
expect(env.NPM_CONFIG_CACHE).toBe(path.join(expectedBase, 'npm-cache'));
|
||||
expect(env.GRADLE_USER_HOME).toBe(path.join(expectedBase, 'gradle-home'));
|
||||
expect(env.ANDROID_USER_HOME).toBe(path.join(expectedBase, 'android-home'));
|
||||
expect(env.ANDROID_SDK_HOME).toBe(path.join(expectedBase, 'android-home'));
|
||||
expect(env.npm_config_script_shell).toBe('cmd.exe');
|
||||
expect(env.AGENT_STUDIO_NPM_CMD).toBe('npm.cmd');
|
||||
expect(env.AGENT_STUDIO_NPX_CMD).toBe('npx.cmd');
|
||||
expect(env.GRADLE_OPTS).toContain('-Djava.io.tmpdir=');
|
||||
expect(env.JAVA_TOOL_OPTIONS).toContain('-Djava.io.tmpdir=');
|
||||
|
||||
await expect(fs.promises.access(path.join(expectedBase, 'tmp'))).resolves.toBeUndefined();
|
||||
await expect(fs.promises.access(path.join(expectedBase, 'npm-cache'))).resolves.toBeUndefined();
|
||||
await expect(
|
||||
fs.promises.access(path.join(expectedBase, 'gradle-home'))
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
fs.promises.access(path.join(expectedBase, 'android-home'))
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
for (const dirName of ['tmp', 'npm-cache', 'gradle-home', 'android-home']) {
|
||||
const entries = await fs.promises.readdir(path.join(expectedBase, dirName));
|
||||
const probeEntries = entries.filter((entry) =>
|
||||
entry.startsWith('.agent-studio-write-probe-')
|
||||
);
|
||||
expect(probeEntries).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('fails open and leaves existing env untouched when cache dirs cannot be created', async () => {
|
||||
const home = path.join(tmpRoot, 'home');
|
||||
const originalTemp = path.join(tmpRoot, 'existing-temp');
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
COMSPEC: 'cmd.exe',
|
||||
LOCALAPPDATA: path.join(home, 'AppData', 'Local'),
|
||||
TEMP: originalTemp,
|
||||
TMP: originalTemp,
|
||||
};
|
||||
vi.spyOn(fs.promises, 'mkdir').mockRejectedValueOnce(new Error('EACCES'));
|
||||
|
||||
const result = await prepareAgentChildProcessWritableEnv(env, { home });
|
||||
|
||||
expect(result.applied).toBe(false);
|
||||
expect(result.cacheBase).toBe(
|
||||
path.join(home, 'AppData', 'Local', 'AgentStudio', 'runner-cache')
|
||||
);
|
||||
expect(result.warning).toContain('keeping existing temp/cache env');
|
||||
expect(env).toEqual({
|
||||
COMSPEC: 'cmd.exe',
|
||||
LOCALAPPDATA: path.join(home, 'AppData', 'Local'),
|
||||
TEMP: originalTemp,
|
||||
TMP: originalTemp,
|
||||
});
|
||||
});
|
||||
|
||||
it('fails open and leaves existing env untouched when cache dirs are not writable', async () => {
|
||||
const home = path.join(tmpRoot, 'home');
|
||||
const originalTemp = path.join(tmpRoot, 'existing-temp');
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
COMSPEC: 'cmd.exe',
|
||||
LOCALAPPDATA: path.join(home, 'AppData', 'Local'),
|
||||
TEMP: originalTemp,
|
||||
TMP: originalTemp,
|
||||
};
|
||||
vi.spyOn(fs.promises, 'writeFile').mockRejectedValueOnce(new Error('EPERM'));
|
||||
|
||||
const result = await prepareAgentChildProcessWritableEnv(env, { home });
|
||||
|
||||
expect(result.applied).toBe(false);
|
||||
expect(result.cacheBase).toBe(
|
||||
path.join(home, 'AppData', 'Local', 'AgentStudio', 'runner-cache')
|
||||
);
|
||||
expect(result.warning).toContain('failed writable check');
|
||||
expect(result.warning).toContain('EPERM');
|
||||
expect(env).toEqual({
|
||||
COMSPEC: 'cmd.exe',
|
||||
LOCALAPPDATA: path.join(home, 'AppData', 'Local'),
|
||||
TEMP: originalTemp,
|
||||
TMP: originalTemp,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the synchronous env applicator available for prepared directories', () => {
|
||||
const home = path.join(tmpRoot, 'home');
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
COMSPEC: 'cmd.exe',
|
||||
LOCALAPPDATA: path.join(home, 'AppData', 'Local'),
|
||||
};
|
||||
|
||||
applyAgentChildProcessWritableEnv(env, { home });
|
||||
|
||||
const expectedBase = path.join(home, 'AppData', 'Local', 'AgentStudio', 'runner-cache');
|
||||
expect(env.TEMP).toBe(path.join(expectedBase, 'tmp'));
|
||||
expect(env.TMP).toBe(path.join(expectedBase, 'tmp'));
|
||||
expect(env.TMPDIR).toBe(path.join(expectedBase, 'tmp'));
|
||||
});
|
||||
});
|
||||
192
src/main/services/runtime/agentChildProcessPreflight.ts
Normal file
192
src/main/services/runtime/agentChildProcessPreflight.ts
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
export interface AgentChildProcessEnvOptions {
|
||||
home?: string;
|
||||
}
|
||||
|
||||
export interface AgentChildProcessWritableEnvResult {
|
||||
applied: boolean;
|
||||
cacheBase?: string;
|
||||
warning?: string;
|
||||
}
|
||||
|
||||
interface AgentChildProcessWritableEnvPaths {
|
||||
cacheBase: string;
|
||||
tempRoot: string;
|
||||
npmCache: string;
|
||||
gradleHome: string;
|
||||
androidHome: string;
|
||||
commandShell: string;
|
||||
}
|
||||
|
||||
interface AgentChildProcessWritableDirectory {
|
||||
label: string;
|
||||
dir: string;
|
||||
}
|
||||
|
||||
function firstNonEmpty(...values: (string | undefined | null)[]): string | undefined {
|
||||
for (const value of values) {
|
||||
const trimmed = value?.trim();
|
||||
if (trimmed) {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function setPathEnv(env: NodeJS.ProcessEnv, key: string, value: string): string {
|
||||
env[key] = value;
|
||||
return value;
|
||||
}
|
||||
|
||||
function appendJavaTmpDirOption(current: string | undefined, tempRoot: string): string {
|
||||
if (current?.includes('-Djava.io.tmpdir=')) {
|
||||
return current;
|
||||
}
|
||||
const escapedTempRoot = tempRoot.replace(/"/g, '\\"');
|
||||
const prefix = current?.trim() ? `${current.trim()} ` : '';
|
||||
return `${prefix}-Djava.io.tmpdir="${escapedTempRoot}"`;
|
||||
}
|
||||
|
||||
function getRuntimeCacheBase(env: NodeJS.ProcessEnv, home: string): string {
|
||||
const explicit = firstNonEmpty(env.AGENT_STUDIO_RUNNER_CACHE_ROOT, env.STUDIO_AGENT_CACHE_ROOT);
|
||||
if (explicit) {
|
||||
return path.resolve(explicit);
|
||||
}
|
||||
|
||||
const localAppData = firstNonEmpty(env.LOCALAPPDATA) ?? path.join(home, 'AppData', 'Local');
|
||||
return path.join(localAppData, 'AgentStudio', 'runner-cache');
|
||||
}
|
||||
|
||||
function resolveWritableEnvPaths(
|
||||
env: NodeJS.ProcessEnv,
|
||||
options: AgentChildProcessEnvOptions
|
||||
): AgentChildProcessWritableEnvPaths {
|
||||
const home = firstNonEmpty(options.home, env.USERPROFILE, env.HOME, os.homedir()) ?? os.tmpdir();
|
||||
const cacheBase = getRuntimeCacheBase(env, home);
|
||||
const tempRoot = path.join(cacheBase, 'tmp');
|
||||
const commandShell =
|
||||
firstNonEmpty(env.ComSpec, env.COMSPEC, process.env.ComSpec, process.env.COMSPEC) ?? 'cmd.exe';
|
||||
|
||||
return {
|
||||
cacheBase,
|
||||
tempRoot,
|
||||
npmCache: path.join(cacheBase, 'npm-cache'),
|
||||
gradleHome: path.join(cacheBase, 'gradle-home'),
|
||||
androidHome: path.join(cacheBase, 'android-home'),
|
||||
commandShell,
|
||||
};
|
||||
}
|
||||
|
||||
function applyResolvedWritableEnv(
|
||||
env: NodeJS.ProcessEnv,
|
||||
paths: AgentChildProcessWritableEnvPaths
|
||||
): NodeJS.ProcessEnv {
|
||||
setPathEnv(env, 'TEMP', paths.tempRoot);
|
||||
setPathEnv(env, 'TMP', paths.tempRoot);
|
||||
setPathEnv(env, 'TMPDIR', paths.tempRoot);
|
||||
setPathEnv(env, 'npm_config_cache', paths.npmCache);
|
||||
setPathEnv(env, 'NPM_CONFIG_CACHE', paths.npmCache);
|
||||
setPathEnv(env, 'GRADLE_USER_HOME', paths.gradleHome);
|
||||
setPathEnv(env, 'ANDROID_USER_HOME', paths.androidHome);
|
||||
setPathEnv(env, 'ANDROID_SDK_HOME', paths.androidHome);
|
||||
setPathEnv(env, 'npm_config_script_shell', paths.commandShell);
|
||||
setPathEnv(env, 'AGENT_STUDIO_NPM_CMD', 'npm.cmd');
|
||||
setPathEnv(env, 'AGENT_STUDIO_NPX_CMD', 'npx.cmd');
|
||||
|
||||
env.GRADLE_OPTS = appendJavaTmpDirOption(env.GRADLE_OPTS, paths.tempRoot);
|
||||
env.JAVA_TOOL_OPTIONS = appendJavaTmpDirOption(env.JAVA_TOOL_OPTIONS, paths.tempRoot);
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
async function ensureWritableDirectory(root: AgentChildProcessWritableDirectory): Promise<void> {
|
||||
try {
|
||||
await fs.promises.mkdir(root.dir, { recursive: true });
|
||||
|
||||
const probePath = path.join(
|
||||
root.dir,
|
||||
`.agent-studio-write-probe-${process.pid}-${Date.now()}-${Math.random()
|
||||
.toString(36)
|
||||
.slice(2)}.tmp`
|
||||
);
|
||||
let probeCreated = false;
|
||||
let cleanupError: unknown;
|
||||
|
||||
try {
|
||||
await fs.promises.writeFile(probePath, 'ok', 'utf8');
|
||||
probeCreated = true;
|
||||
|
||||
const written = await fs.promises.readFile(probePath, 'utf8');
|
||||
if (written !== 'ok') {
|
||||
throw new Error('write probe read back unexpected content');
|
||||
}
|
||||
} finally {
|
||||
if (probeCreated) {
|
||||
try {
|
||||
await fs.promises.unlink(probePath);
|
||||
} catch (error) {
|
||||
cleanupError = error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (cleanupError) {
|
||||
throw new Error(`write probe cleanup failed: ${errorMessage(cleanupError)}`);
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`${root.label} at ${root.dir} failed writable check: ${errorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function applyAgentChildProcessWritableEnv(
|
||||
env: NodeJS.ProcessEnv,
|
||||
options: AgentChildProcessEnvOptions = {}
|
||||
): NodeJS.ProcessEnv {
|
||||
if (process.platform !== 'win32') {
|
||||
return env;
|
||||
}
|
||||
|
||||
return applyResolvedWritableEnv(env, resolveWritableEnvPaths(env, options));
|
||||
}
|
||||
|
||||
export async function prepareAgentChildProcessWritableEnv(
|
||||
env: NodeJS.ProcessEnv,
|
||||
options: AgentChildProcessEnvOptions = {}
|
||||
): Promise<AgentChildProcessWritableEnvResult> {
|
||||
if (process.platform !== 'win32') {
|
||||
return { applied: false };
|
||||
}
|
||||
|
||||
const paths = resolveWritableEnvPaths(env, options);
|
||||
const dirs: AgentChildProcessWritableDirectory[] = [
|
||||
{ label: 'TEMP/TMP/TMPDIR', dir: paths.tempRoot },
|
||||
{ label: 'npm cache', dir: paths.npmCache },
|
||||
{ label: 'Gradle home', dir: paths.gradleHome },
|
||||
{ label: 'Android home', dir: paths.androidHome },
|
||||
];
|
||||
try {
|
||||
const checks = await Promise.allSettled(dirs.map((dir) => ensureWritableDirectory(dir)));
|
||||
const failed = checks.find((check) => check.status === 'rejected');
|
||||
if (failed?.status === 'rejected') {
|
||||
throw failed.reason;
|
||||
}
|
||||
|
||||
applyResolvedWritableEnv(env, paths);
|
||||
return { applied: true, cacheBase: paths.cacheBase };
|
||||
} catch (error) {
|
||||
return {
|
||||
applied: false,
|
||||
cacheBase: paths.cacheBase,
|
||||
warning:
|
||||
`Windows agent writable cache setup skipped for ${paths.cacheBase}; ` +
|
||||
`keeping existing temp/cache env. Details: ${errorMessage(error)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -51,6 +51,7 @@ import {
|
|||
} from '@features/workspace-trust/main';
|
||||
import { ConfigManager } from '@main/services/infrastructure/ConfigManager';
|
||||
import { NotificationManager } from '@main/services/infrastructure/NotificationManager';
|
||||
import { prepareAgentChildProcessWritableEnv } from '@main/services/runtime/agentChildProcessPreflight';
|
||||
import { getAppIconPath } from '@main/utils/appIcon';
|
||||
import {
|
||||
execCli,
|
||||
|
|
@ -33350,6 +33351,10 @@ export class TeamProvisioningService {
|
|||
});
|
||||
const providerConnectionIssue = providerEnvResult.connectionIssues[resolvedProviderId];
|
||||
const providerEnv = providerEnvResult.env;
|
||||
const writableEnvResult = await prepareAgentChildProcessWritableEnv(providerEnv, { home });
|
||||
if (writableEnvResult.warning) {
|
||||
logger.warn(`[TeamProvisioningService] ${writableEnvResult.warning}`);
|
||||
}
|
||||
if (options?.includeCodexTeammateAuth && resolvedProviderId !== 'codex') {
|
||||
await this.providerConnectionService.augmentConfiguredConnectionEnv(
|
||||
providerEnv,
|
||||
|
|
|
|||
Loading…
Reference in a new issue