diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index b6779c3a..33daae73 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -1600,6 +1600,15 @@ function buildRuntimeSettingsTempDirectory(teamName: string): string { ); } +function normalizeTeamRuntimeNodeEnv(env: NodeJS.ProcessEnv): void { + // Vitest sets NODE_ENV=test in the desktop parent process. Real team runtime + // children must run the CLI normally, otherwise source launches can take + // test-only startup paths and exit before deterministic bootstrap starts. + if (env.NODE_ENV === 'test') { + env.NODE_ENV = 'development'; + } +} + function buildProviderFastModeArgs( providerId: TeamProviderId, launchIdentity?: ProviderModelLaunchIdentity | null @@ -22033,6 +22042,7 @@ export class TeamProvisioningService { contextLabel: 'Team create launch', }); const spawnArgs = mergeJsonSettingsArgs([ + '--print', '--input-format', 'stream-json', '--output-format', @@ -23302,6 +23312,7 @@ export class TeamProvisioningService { throw error; } const launchArgs = [ + '--print', '--input-format', 'stream-json', '--output-format', @@ -36072,6 +36083,7 @@ export class TeamProvisioningService { : {}), CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1', }; + normalizeTeamRuntimeNodeEnv(env); const resolvedProviderId = resolveTeamProviderId(providerId); const providerEnvResult = await buildProviderAwareCliEnv({ providerId, diff --git a/test/main/services/team/MemberWorkSyncClaudeStopHook.live.test.ts b/test/main/services/team/MemberWorkSyncClaudeStopHook.live.test.ts index bb211e99..5c043588 100644 --- a/test/main/services/team/MemberWorkSyncClaudeStopHook.live.test.ts +++ b/test/main/services/team/MemberWorkSyncClaudeStopHook.live.test.ts @@ -15,6 +15,7 @@ import { TeamMembersMetaStore } from '../../../../src/main/services/team/TeamMem import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService'; import { TeamTaskReader } from '../../../../src/main/services/team/TeamTaskReader'; import { + getTasksBasePath, getTeamsBasePath, setClaudeBasePathOverride, } from '../../../../src/main/utils/pathDecoder'; @@ -51,7 +52,6 @@ const liveDescribe = const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli-source'; const DEFAULT_MODEL = 'sonnet'; -const DEFAULT_EFFORT = 'low' as const; type ClaudeStopHookLiveScenarioState = 'still_working' | 'caught_up'; @@ -98,17 +98,16 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => { let previousHome: string | undefined; let previousHistFile: string | undefined; let previousUserProfile: string | undefined; + let claudeJsonConfigRoot: string; + let previousClaudeJsonConfig: string | null | undefined; let svc: TeamProvisioningService | null; let feature: MemberWorkSyncFeatureFacade | null; let controlServer: MemberWorkSyncLiveControlServer | null; let teamName: string | null; + let usingConnectedClaudeAccount = false; beforeEach(async () => { tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'member-work-sync-claude-stop-live-')); - tempClaudeRoot = path.join(tempDir, '.claude'); - await fs.mkdir(tempClaudeRoot, { recursive: true }); - setClaudeBasePathOverride(tempClaudeRoot); - previousCliPath = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH; previousCliFlavor = process.env.CLAUDE_TEAM_CLI_FLAVOR; previousControlUrl = process.env.CLAUDE_TEAM_CONTROL_URL; @@ -117,12 +116,24 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => { previousHome = process.env.HOME; previousHistFile = process.env.HISTFILE; previousUserProfile = process.env.USERPROFILE; + previousClaudeJsonConfig = undefined; - const shouldUseConnectedAccountHome = allowConnectedClaudeAccount && !hasLiveAnthropicApiKey(); - tempHome = shouldUseConnectedAccountHome - ? resolveConnectedClaudeHome(previousHome) + usingConnectedClaudeAccount = allowConnectedClaudeAccount && !hasLiveAnthropicApiKey(); + tempHome = usingConnectedClaudeAccount + ? resolveConnectedClaudeHome() : path.join(tempDir, 'home'); + tempClaudeRoot = usingConnectedClaudeAccount + ? path.join(tempHome, '.claude') + : path.join(tempDir, '.claude'); + claudeJsonConfigRoot = usingConnectedClaudeAccount ? tempHome : tempClaudeRoot; await fs.mkdir(tempHome, { recursive: true }); + await fs.mkdir(tempClaudeRoot, { recursive: true }); + + if (usingConnectedClaudeAccount) { + setClaudeBasePathOverride(null); + } else { + setClaudeBasePathOverride(tempClaudeRoot); + } process.env.HOME = tempHome; process.env.HISTFILE = '/dev/null'; @@ -140,6 +151,7 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => { }); afterEach(async () => { + const keepTemp = process.env.MEMBER_WORK_SYNC_CLAUDE_KEEP_TEMP === '1'; if (svc && teamName) { await svc.stopTeam(teamName).catch(() => undefined); } @@ -148,6 +160,14 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => { svc?.setRuntimeTurnSettledHookSettingsProvider(null); await feature?.dispose().catch(() => undefined); await controlServer?.close().catch(() => undefined); + discardKnownClaudeStopHookWarnings(); + if (!keepTemp && usingConnectedClaudeAccount && teamName) { + await fs.rm(path.join(getTeamsBasePath(), teamName), { recursive: true, force: true }); + await fs.rm(path.join(getTasksBasePath(), teamName), { recursive: true, force: true }); + } + if (usingConnectedClaudeAccount && previousClaudeJsonConfig !== undefined) { + await restoreClaudeJsonConfig(claudeJsonConfigRoot, previousClaudeJsonConfig); + } restoreEnv('CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH', previousCliPath); restoreEnv('CLAUDE_TEAM_CLI_FLAVOR', previousCliFlavor); @@ -158,7 +178,7 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => { restoreEnv('HISTFILE', previousHistFile); restoreEnv('USERPROFILE', previousUserProfile); setClaudeBasePathOverride(null); - if (process.env.MEMBER_WORK_SYNC_CLAUDE_KEEP_TEMP === '1') { + if (keepTemp) { console.info(`[MemberWorkSyncClaudeStopHook.live] preserved temp dir: ${tempDir}`); } else { await removeTempDirAfterLateShellWrites(tempDir); @@ -186,7 +206,14 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => { teamName = `member-work-sync-claude-stop-${scenario.markerSuffix}-${startedAt}`; const projectPath = path.join(tempDir, 'project'); await fs.mkdir(projectPath, { recursive: true }); - await writeTrustedClaudeConfig(tempClaudeRoot, projectPath); + if (usingConnectedClaudeAccount) { + previousClaudeJsonConfig = await upsertTrustedClaudeProjectConfig( + claudeJsonConfigRoot, + projectPath + ); + } else { + await writeTrustedClaudeConfig(claudeJsonConfigRoot, projectPath); + } await fs.writeFile( path.join(projectPath, 'README.md'), '# Member work sync Claude Stop hook live e2e\n\nKeep this project intentionally tiny.\n', @@ -251,7 +278,9 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => { cwd: projectPath, providerId: 'anthropic', model, - effort: DEFAULT_EFFORT, + ...(process.env.MEMBER_WORK_SYNC_CLAUDE_EXTRA_CLI_ARGS?.trim() + ? { extraCliArgs: process.env.MEMBER_WORK_SYNC_CLAUDE_EXTRA_CLI_ARGS.trim() } + : {}), skipPermissions: true, prompt: [ 'Keep launch work minimal and wait for the explicit live-test instruction.', @@ -263,7 +292,6 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => { role: 'Developer', providerId: 'anthropic', model, - effort: DEFAULT_EFFORT, }, ], }, @@ -282,6 +310,8 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => { await throwIfClaudeTranscriptApiError({ claudeRoot: tempClaudeRoot, context: 'Claude team launch', + projectPath, + sinceMs: startedAt, }); await expect( @@ -339,6 +369,8 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => { await throwIfClaudeTranscriptApiError({ claudeRoot: tempClaudeRoot, context: 'Claude validation turn', + projectPath, + sinceMs: startedAt, }); await feature!.replayPendingReports([teamName!]); const [status, metrics, tasks] = await Promise.all([ @@ -377,6 +409,8 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => { await throwIfClaudeTranscriptApiError({ claudeRoot: tempClaudeRoot, context: 'Claude Stop hook turn', + projectPath, + sinceMs: startedAt, }); await feature!.drainRuntimeTurnSettledEvents(); const metas = await readRuntimeTurnSettledProcessedMetas(getTeamsBasePath()); @@ -562,6 +596,26 @@ function hasLiveAnthropicApiKey(): boolean { return Boolean(process.env.ANTHROPIC_API_KEY?.trim()); } +function discardKnownClaudeStopHookWarnings(): void { + const warn = vi.mocked(console.warn); + if (!warn.mock) return; + const calls = warn.mock.calls; + for (let index = calls.length - 1; index >= 0; index -= 1) { + const text = calls[index]?.map((value) => String(value)).join(' ') ?? ''; + if (text.includes('Failed to resolve login shell env: shell env resolve timeout')) { + calls.splice(index, 1); + continue; + } + if (text.includes('Failed to resolve interactive shell env: shell env resolve timeout')) { + calls.splice(index, 1); + continue; + } + if (text.includes('Failed to parse runtime model list for launch validation')) { + calls.splice(index, 1); + } + } +} + async function writeTrustedClaudeConfig(configDir: string, projectPath: string): Promise { const canonicalProjectPath = await fs.realpath(projectPath).catch(() => projectPath); const normalizedProjectPath = path.normalize(canonicalProjectPath).replace(/\\/g, '/'); @@ -589,14 +643,71 @@ async function writeTrustedClaudeConfig(configDir: string, projectPath: string): ); } -function resolveConnectedClaudeHome(previousHome: string | undefined): string { +async function upsertTrustedClaudeProjectConfig( + configDir: string, + projectPath: string +): Promise { + const configPath = path.join(configDir, '.claude.json'); + const previous = await fs.readFile(configPath, 'utf8').catch((error) => { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw error; + }); + const existing = parseJsonObject(previous) ?? {}; + const canonicalProjectPath = await fs.realpath(projectPath).catch(() => projectPath); + const normalizedProjectPath = path.normalize(canonicalProjectPath).replace(/\\/g, '/'); + const projects = + existing.projects && typeof existing.projects === 'object' && !Array.isArray(existing.projects) + ? { ...(existing.projects as Record) } + : {}; + const currentProject = + projects[normalizedProjectPath] && + typeof projects[normalizedProjectPath] === 'object' && + !Array.isArray(projects[normalizedProjectPath]) + ? (projects[normalizedProjectPath] as Record) + : {}; + projects[normalizedProjectPath] = { + ...currentProject, + hasTrustDialogAccepted: true, + }; + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile( + configPath, + `${JSON.stringify( + { + ...existing, + projects, + }, + null, + 2 + )}\n`, + 'utf8' + ); + return previous; +} + +async function restoreClaudeJsonConfig(configDir: string, previous: string | null): Promise { + const configPath = path.join(configDir, '.claude.json'); + if (previous === null) { + await fs.rm(configPath, { force: true }); + return; + } + await fs.writeFile(configPath, previous, 'utf8'); +} + +function parseJsonObject(raw: string | null): Record | null { + if (!raw) { + return null; + } + const parsed = JSON.parse(raw); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : null; +} + +function resolveConnectedClaudeHome(): string { const explicit = process.env.MEMBER_WORK_SYNC_CLAUDE_CONNECTED_HOME?.trim(); if (explicit) { return path.resolve(explicit); } - const previous = previousHome?.trim(); - if (previous) { - return path.resolve(previous); - } return os.userInfo().homedir; } diff --git a/test/main/services/team/TeamProvisioningServicePrepare.test.ts b/test/main/services/team/TeamProvisioningServicePrepare.test.ts index b7e2a31c..45404b49 100644 --- a/test/main/services/team/TeamProvisioningServicePrepare.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrepare.test.ts @@ -2850,6 +2850,34 @@ describe('TeamProvisioningService prepare/auth behavior', () => { expect(result.env.ANTHROPIC_API_KEY).toBe('real-key'); }); + it('does not leak Vitest NODE_ENV into real team runtime children', async () => { + const previousNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'test'; + try { + const svc = new TeamProvisioningService(); + const buildProvisioningEnv = ( + svc as unknown as { + buildProvisioningEnv(): Promise<{ env: NodeJS.ProcessEnv }>; + } + ).buildProvisioningEnv.bind(svc); + + const result = await buildProvisioningEnv(); + + expect(result.env.NODE_ENV).toBe('development'); + expect(buildProviderAwareCliEnvMock).toHaveBeenLastCalledWith( + expect.objectContaining({ + env: expect.objectContaining({ NODE_ENV: 'development' }), + }) + ); + } finally { + if (previousNodeEnv === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = previousNodeEnv; + } + } + }); + it('adds member-work-sync turn-settled spool env for Codex provisioning', async () => { const svc = new TeamProvisioningService(); svc.setRuntimeTurnSettledEnvironmentProvider(async ({ provider }) => diff --git a/test/main/services/team/memberWorkSyncLiveHarness.test.ts b/test/main/services/team/memberWorkSyncLiveHarness.test.ts new file mode 100644 index 00000000..3bd481d8 --- /dev/null +++ b/test/main/services/team/memberWorkSyncLiveHarness.test.ts @@ -0,0 +1,119 @@ +import { promises as fs } from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { encodePath } from '../../../../src/main/utils/pathDecoder'; + +import { throwIfClaudeTranscriptApiError } from './memberWorkSyncLiveHarness'; + +const tempDirs: string[] = []; + +describe('memberWorkSyncLiveHarness', () => { + afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); + }); + + it('scopes Claude API error checks to the current project transcripts', async () => { + const claudeRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'member-work-sync-harness-')); + tempDirs.push(claudeRoot); + const projectPath = path.join(claudeRoot, 'project'); + await fs.mkdir(projectPath, { recursive: true }); + + const projectTranscriptDir = path.join( + claudeRoot, + 'projects', + encodePath(await fs.realpath(projectPath)) + ); + const unrelatedTranscriptDir = path.join( + claudeRoot, + 'projects', + encodePath('/Users/example/other-project') + ); + await fs.mkdir(projectTranscriptDir, { recursive: true }); + await fs.mkdir(unrelatedTranscriptDir, { recursive: true }); + await fs.writeFile( + path.join(unrelatedTranscriptDir, 'unrelated.jsonl'), + `${JSON.stringify(buildApiErrorRecord('wrong project'))}\n`, + 'utf8' + ); + + await expect( + throwIfClaudeTranscriptApiError({ + claudeRoot, + context: 'live check', + projectPath, + }) + ).resolves.toBeUndefined(); + + await fs.writeFile( + path.join(projectTranscriptDir, 'current.jsonl'), + `${JSON.stringify(buildApiErrorRecord('right project'))}\n`, + 'utf8' + ); + + await expect( + throwIfClaudeTranscriptApiError({ + claudeRoot, + context: 'live check', + projectPath, + }) + ).rejects.toThrow(/right project/); + }); + + it('ignores stale Claude API errors before the live check start time', async () => { + const claudeRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'member-work-sync-harness-')); + tempDirs.push(claudeRoot); + const projectPath = path.join(claudeRoot, 'project'); + await fs.mkdir(projectPath, { recursive: true }); + + const projectTranscriptDir = path.join( + claudeRoot, + 'projects', + encodePath(await fs.realpath(projectPath)) + ); + await fs.mkdir(projectTranscriptDir, { recursive: true }); + const sinceMs = Date.now(); + await fs.writeFile( + path.join(projectTranscriptDir, 'current.jsonl'), + `${JSON.stringify(buildApiErrorRecord('old error', sinceMs - 1_000))}\n`, + 'utf8' + ); + + await expect( + throwIfClaudeTranscriptApiError({ + claudeRoot, + context: 'live check', + projectPath, + sinceMs, + }) + ).resolves.toBeUndefined(); + + await fs.appendFile( + path.join(projectTranscriptDir, 'current.jsonl'), + `${JSON.stringify(buildApiErrorRecord('new error', sinceMs + 1_000))}\n`, + 'utf8' + ); + + await expect( + throwIfClaudeTranscriptApiError({ + claudeRoot, + context: 'live check', + projectPath, + sinceMs, + }) + ).rejects.toThrow(/new error/); + }); +}); + +function buildApiErrorRecord(text: string, timestampMs = Date.now()): Record { + return { + isApiErrorMessage: true, + error: 'unknown', + timestamp: new Date(timestampMs).toISOString(), + message: { + content: [{ type: 'text', text }], + }, + }; +} diff --git a/test/main/services/team/memberWorkSyncLiveHarness.ts b/test/main/services/team/memberWorkSyncLiveHarness.ts index 74716a5a..df295ff3 100644 --- a/test/main/services/team/memberWorkSyncLiveHarness.ts +++ b/test/main/services/team/memberWorkSyncLiveHarness.ts @@ -2,9 +2,10 @@ import { constants as fsConstants, promises as fs } from 'node:fs'; import * as http from 'node:http'; import * as path from 'node:path'; +import { encodePath } from '../../../../src/main/utils/pathDecoder'; + import type { MemberWorkSyncReportRequest } from '../../../../src/features/member-work-sync/contracts'; import type { MemberWorkSyncFeatureFacade } from '../../../../src/features/member-work-sync/main'; - import type { TeamProvisioningProgress } from '../../../../src/shared/types'; export class FatalWaitError extends Error { @@ -199,8 +200,11 @@ export async function formatMemberWorkSyncDiagnostics(input: { export async function throwIfClaudeTranscriptApiError(input: { claudeRoot: string; context: string; + projectPath?: string; + sinceMs?: number; }): Promise { - const transcriptFiles = await findJsonlFiles(path.join(input.claudeRoot, 'projects')); + const transcriptRoots = await resolveClaudeTranscriptRoots(input.claudeRoot, input.projectPath); + const transcriptFiles = (await Promise.all(transcriptRoots.map(findJsonlFiles))).flat(); const apiErrors: Array<{ filePath: string; error: string; text: string }> = []; for (const filePath of transcriptFiles) { const raw = await fs.readFile(filePath, 'utf8').catch(() => ''); @@ -218,6 +222,9 @@ export async function throwIfClaudeTranscriptApiError(input: { } catch { continue; } + if (input.sinceMs !== undefined && isTranscriptRecordBefore(parsed, input.sinceMs)) { + continue; + } if (parsed.isApiErrorMessage !== true && typeof parsed.error !== 'string') { continue; } @@ -247,6 +254,46 @@ export async function throwIfClaudeTranscriptApiError(input: { ); } +async function resolveClaudeTranscriptRoots( + claudeRoot: string, + projectPath: string | undefined +): Promise { + const projectsRoot = path.join(claudeRoot, 'projects'); + if (!projectPath) { + return [projectsRoot]; + } + + const candidateRoots = new Set(); + const addCandidate = (candidatePath: string) => { + candidateRoots.add(path.join(projectsRoot, encodePath(candidatePath))); + }; + addCandidate(path.resolve(projectPath)); + const realProjectPath = await fs.realpath(projectPath).catch(() => null); + if (realProjectPath) { + addCandidate(realProjectPath); + } + + const existingRoots: string[] = []; + for (const candidateRoot of candidateRoots) { + const stats = await fs.stat(candidateRoot).catch(() => null); + if (stats?.isDirectory()) { + existingRoots.push(candidateRoot); + } + } + return existingRoots; +} + +function isTranscriptRecordBefore(record: Record, sinceMs: number): boolean { + const timestamp = record.timestamp; + const timestampMs = + typeof timestamp === 'string' + ? Date.parse(timestamp) + : typeof timestamp === 'number' + ? timestamp + : Number.NaN; + return Number.isFinite(timestampMs) && timestampMs < sinceMs; +} + export async function readRuntimeTurnSettledProcessedMetas(teamsBasePath: string): Promise< Array<{ filePath: string;