From 0e2be0348e51a386023615241f576b444b0ffae5 Mon Sep 17 00:00:00 2001 From: Jonas Korani <88804223+Therealkorris@users.noreply.github.com> Date: Fri, 15 May 2026 21:04:01 +0200 Subject: [PATCH] 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 --- .../agentChildProcessPreflight.test.ts | 159 +++++++++++++++ .../runtime/agentChildProcessPreflight.ts | 192 ++++++++++++++++++ .../services/team/TeamProvisioningService.ts | 5 + 3 files changed, 356 insertions(+) create mode 100644 src/main/services/runtime/agentChildProcessPreflight.test.ts create mode 100644 src/main/services/runtime/agentChildProcessPreflight.ts diff --git a/src/main/services/runtime/agentChildProcessPreflight.test.ts b/src/main/services/runtime/agentChildProcessPreflight.test.ts new file mode 100644 index 00000000..6ed09514 --- /dev/null +++ b/src/main/services/runtime/agentChildProcessPreflight.test.ts @@ -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')); + }); +}); diff --git a/src/main/services/runtime/agentChildProcessPreflight.ts b/src/main/services/runtime/agentChildProcessPreflight.ts new file mode 100644 index 00000000..cd68b7c1 --- /dev/null +++ b/src/main/services/runtime/agentChildProcessPreflight.ts @@ -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 { + 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 { + 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)}`, + }; + } +} diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 94fa5f64..4842161e 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -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,