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:
Jonas Korani 2026-05-15 21:04:01 +02:00 committed by GitHub
parent 1376017aa9
commit 0e2be0348e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 356 additions and 0 deletions

View 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'));
});
});

View 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)}`,
};
}
}

View file

@ -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,