diff --git a/src/main/index.ts b/src/main/index.ts index 4de501e4..08f1ec86 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -35,6 +35,7 @@ import { registerRecentProjectsIpc, removeRecentProjectsIpc, } from '@features/recent-projects/main'; +import { applyOpenCodeAutoUpdatePolicy } from '@main/services/runtime/openCodeAutoUpdatePolicy'; import { providerConnectionService } from '@main/services/runtime/ProviderConnectionService'; import { JsonScheduleRepository } from '@main/services/schedule/JsonScheduleRepository'; import { ScheduledTaskExecutor } from '@main/services/schedule/ScheduledTaskExecutor'; @@ -113,6 +114,7 @@ import { OpenCodeBridgeCommandHandshakePort, } from './services/team/opencode/bridge/OpenCodeBridgeHandshakeClient'; import { OpenCodeStateChangingBridgeCommandService } from './services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService'; +import { resolveOpenCodeTeamLaunchModeFromEnv } from './services/team/opencode/config/OpenCodeLaunchModeEnv'; import { resolveOpenCodeProductionE2EEvidencePath } from './services/team/opencode/e2e/OpenCodeProductionE2EEvidencePath'; import { OpenCodeProductionE2EEvidenceStore } from './services/team/opencode/e2e/OpenCodeProductionE2EEvidenceStore'; import { OpenCodeRuntimeManifestEvidenceReader } from './services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader'; @@ -173,7 +175,6 @@ import { UpdaterService, } from './services'; -import type { OpenCodeTeamLaunchMode } from './services/team'; import type { FileChangeEvent } from '@main/types'; import type { TeamChangeEvent } from '@shared/types'; @@ -200,17 +201,6 @@ const INBOX_NOTIFY_DEBOUNCE_MS = 500; /** Messages sent from our UI (user_sent) — suppress notifications for these. */ const suppressedSources = new Set(['user_sent']); -function resolveOpenCodeTeamLaunchModeFromEnv(): OpenCodeTeamLaunchMode { - const raw = process.env.CLAUDE_TEAM_OPENCODE_LAUNCH_MODE?.trim().toLowerCase(); - if (raw === 'dogfood' || raw === 'production' || raw === 'disabled') { - return raw; - } - if (process.env.CLAUDE_TEAM_OPENCODE_DOGFOOD === '1') { - return 'dogfood'; - } - return 'disabled'; -} - async function createOpenCodeRuntimeAdapterRegistry(): Promise { const binaryPath = await ClaudeBinaryResolver.resolve(); if (!binaryPath) { @@ -218,7 +208,7 @@ async function createOpenCodeRuntimeAdapterRegistry(): Promise>( + env: T, + policyEnv: NodeJS.ProcessEnv = env as NodeJS.ProcessEnv +): T & NodeJS.ProcessEnv { + const next: Record = { ...env }; + if (isOpenCodeAutoUpdateAllowed(policyEnv)) { + delete next[OPENCODE_DISABLE_AUTOUPDATE_ENV]; + return next as T & NodeJS.ProcessEnv; + } + next[OPENCODE_DISABLE_AUTOUPDATE_ENV] = '1'; + return next as T & NodeJS.ProcessEnv; +} diff --git a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient.ts b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient.ts index fd55cca0..4ec07423 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient.ts @@ -1,20 +1,20 @@ +import { applyOpenCodeAutoUpdatePolicy } from '@main/services/runtime/openCodeAutoUpdatePolicy'; +import { execCli } from '@main/utils/childProcess'; import { randomUUID } from 'crypto'; import { promises as fs } from 'fs'; import * as path from 'path'; -import { execCli } from '@main/utils/childProcess'; - import { extractRunId, OPEN_CODE_BRIDGE_SCHEMA_VERSION, - parseSingleBridgeJsonResult, - validateBridgeResultEnvelope, type OpenCodeBridgeCommandEnvelope, type OpenCodeBridgeCommandName, type OpenCodeBridgeDiagnosticEvent, type OpenCodeBridgeFailure, type OpenCodeBridgeFailureKind, type OpenCodeBridgeResult, + parseSingleBridgeJsonResult, + validateBridgeResultEnvelope, } from './OpenCodeBridgeCommandContract'; export interface OpenCodeBridgeProcessRunInput { @@ -113,7 +113,7 @@ export class OpenCodeBridgeCommandClient { this.diagnosticIdFactory = options.diagnosticIdFactory ?? (() => `opencode-bridge-diagnostic-${randomUUID()}`); this.clock = options.clock ?? (() => new Date()); - this.env = options.env ?? process.env; + this.env = applyOpenCodeAutoUpdatePolicy(options.env ?? process.env); this.keepInputFile = options.keepInputFile ?? false; } diff --git a/src/main/services/team/opencode/config/OpenCodeLaunchModeEnv.ts b/src/main/services/team/opencode/config/OpenCodeLaunchModeEnv.ts new file mode 100644 index 00000000..ccabe61c --- /dev/null +++ b/src/main/services/team/opencode/config/OpenCodeLaunchModeEnv.ts @@ -0,0 +1,17 @@ +import type { OpenCodeTeamLaunchMode } from '../bridge/OpenCodeBridgeCommandContract'; + +export const CLAUDE_TEAM_OPENCODE_LAUNCH_MODE_ENV = 'CLAUDE_TEAM_OPENCODE_LAUNCH_MODE'; +export const CLAUDE_TEAM_OPENCODE_DOGFOOD_ENV = 'CLAUDE_TEAM_OPENCODE_DOGFOOD'; + +export function resolveOpenCodeTeamLaunchModeFromEnv( + env: NodeJS.ProcessEnv = process.env +): OpenCodeTeamLaunchMode { + const raw = env[CLAUDE_TEAM_OPENCODE_LAUNCH_MODE_ENV]?.trim().toLowerCase(); + if (raw === 'dogfood' || raw === 'production' || raw === 'disabled') { + return raw; + } + if (env[CLAUDE_TEAM_OPENCODE_DOGFOOD_ENV] === '1') { + return 'dogfood'; + } + return 'production'; +} diff --git a/src/main/services/team/opencode/config/OpenCodeManagedOverlay.ts b/src/main/services/team/opencode/config/OpenCodeManagedOverlay.ts index 3afa8f13..d333d859 100644 --- a/src/main/services/team/opencode/config/OpenCodeManagedOverlay.ts +++ b/src/main/services/team/opencode/config/OpenCodeManagedOverlay.ts @@ -1,3 +1,4 @@ +import { applyOpenCodeAutoUpdatePolicy } from '@main/services/runtime/openCodeAutoUpdatePolicy'; import { createHash } from 'crypto'; import { promises as fs } from 'fs'; import * as os from 'os'; @@ -33,7 +34,7 @@ export interface OpenCodeManagedOverlay { projectPath: string; env: { OPENCODE_CONFIG_CONTENT: string; - OPENCODE_DISABLE_AUTOUPDATE: '1'; + OPENCODE_DISABLE_AUTOUPDATE?: '1'; }; appMcpServerName: string; appMcpConfig: OpenCodeMcpServerConfig; @@ -48,6 +49,7 @@ export interface OpenCodeManagedOverlayBuilderInput { appMcpArgs: string[]; appMcpEnv: Record; mcpTimeoutMs?: number; + env?: NodeJS.ProcessEnv; } export interface OpenCodeBehaviorSourceScannerOptions { @@ -97,10 +99,12 @@ export class OpenCodeManagedOverlayBuilder { return { launchMode: 'project_root_with_inline_overlay', projectPath: input.projectPath, - env: { - OPENCODE_CONFIG_CONTENT: JSON.stringify(overlayConfig), - OPENCODE_DISABLE_AUTOUPDATE: '1', - }, + env: applyOpenCodeAutoUpdatePolicy( + { + OPENCODE_CONFIG_CONTENT: JSON.stringify(overlayConfig), + }, + input.env ?? process.env + ), appMcpServerName, appMcpConfig: overlayConfig.mcp[appMcpServerName], preservedSources, @@ -125,7 +129,7 @@ export class OpenCodeBehaviorSourceScanner { } async scan(projectPath: string): Promise { - const sourceSpecs: Array<{ kind: OpenCodeBehaviorSourceKind; targetPath: string }> = [ + const sourceSpecs: { kind: OpenCodeBehaviorSourceKind; targetPath: string }[] = [ { kind: 'global_config', targetPath: path.join(this.homePath, '.config/opencode/opencode.json'), @@ -224,19 +228,19 @@ export class OpenCodeBehaviorSourceScanner { } private async listDirectoryFiles(rootPath: string): Promise< - Array<{ + { relativePath: string; size: number; mtimeMs: number; contentHash: string; - }> + }[] > { - const results: Array<{ + const results: { relativePath: string; size: number; mtimeMs: number; contentHash: string; - }> = []; + }[] = []; const visit = async (directoryPath: string): Promise => { if (results.length >= this.maxDirectoryFiles) { diff --git a/test/main/services/runtime/openCodeAutoUpdatePolicy.test.ts b/test/main/services/runtime/openCodeAutoUpdatePolicy.test.ts new file mode 100644 index 00000000..e32e91b9 --- /dev/null +++ b/test/main/services/runtime/openCodeAutoUpdatePolicy.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; + +import { + applyOpenCodeAutoUpdatePolicy, + isOpenCodeAutoUpdateAllowed, +} from '../../../../src/main/services/runtime/openCodeAutoUpdatePolicy'; + +describe('openCodeAutoUpdatePolicy', () => { + it('disables OpenCode auto-update by default for app-managed envs', () => { + const input = { PATH: '/usr/bin' }; + + const result = applyOpenCodeAutoUpdatePolicy(input); + + expect(result).toEqual({ + PATH: '/usr/bin', + OPENCODE_DISABLE_AUTOUPDATE: '1', + }); + expect(input).toEqual({ PATH: '/usr/bin' }); + }); + + it('allows an explicit app override to remove inherited disable-auto-update', () => { + const result = applyOpenCodeAutoUpdatePolicy({ + CLAUDE_TEAM_OPENCODE_ALLOW_AUTOUPDATE: '1', + OPENCODE_DISABLE_AUTOUPDATE: '1', + }); + + expect(result.CLAUDE_TEAM_OPENCODE_ALLOW_AUTOUPDATE).toBe('1'); + expect(result.OPENCODE_DISABLE_AUTOUPDATE).toBeUndefined(); + expect(isOpenCodeAutoUpdateAllowed(result)).toBe(true); + }); + + it('treats non-enabled override values as fail-closed', () => { + const result = applyOpenCodeAutoUpdatePolicy({ + CLAUDE_TEAM_OPENCODE_ALLOW_AUTOUPDATE: '0', + }); + + expect(result.OPENCODE_DISABLE_AUTOUPDATE).toBe('1'); + expect(isOpenCodeAutoUpdateAllowed(result)).toBe(false); + }); +}); diff --git a/test/main/services/runtime/providerAwareCliEnv.test.ts b/test/main/services/runtime/providerAwareCliEnv.test.ts index 95ec8255..b5387255 100644 --- a/test/main/services/runtime/providerAwareCliEnv.test.ts +++ b/test/main/services/runtime/providerAwareCliEnv.test.ts @@ -132,6 +132,26 @@ describe('buildProviderAwareCliEnv', () => { ); expect(result.connectionIssues).toEqual({}); expect(result.providerArgs).toEqual([]); + expect(result.env.OPENCODE_DISABLE_AUTOUPDATE).toBe('1'); + }); + + it('allows OpenCode auto-update only behind an explicit app override', async () => { + buildEnrichedEnvMock.mockReturnValue({ + PATH: '/usr/bin', + OPENCODE_DISABLE_AUTOUPDATE: '1', + }); + const { buildProviderAwareCliEnv } = await import( + '../../../../src/main/services/runtime/providerAwareCliEnv' + ); + + const result = await buildProviderAwareCliEnv({ + env: { + CLAUDE_TEAM_OPENCODE_ALLOW_AUTOUPDATE: '1', + }, + }); + + expect(result.env.CLAUDE_TEAM_OPENCODE_ALLOW_AUTOUPDATE).toBe('1'); + expect(result.env.OPENCODE_DISABLE_AUTOUPDATE).toBeUndefined(); }); it('uses non-destructive credential augmentation for PTY-style envs', async () => { diff --git a/test/main/services/team/OpenCodeBridgeCommandClient.test.ts b/test/main/services/team/OpenCodeBridgeCommandClient.test.ts index 98d1dda2..f5daf35d 100644 --- a/test/main/services/team/OpenCodeBridgeCommandClient.test.ts +++ b/test/main/services/team/OpenCodeBridgeCommandClient.test.ts @@ -57,6 +57,9 @@ describe('OpenCodeBridgeCommandClient', () => { args: ['runtime', 'opencode-command', '--json', '--input', expect.any(String)], cwd: '/tmp/project', timeoutMs: 10_000, + env: expect.objectContaining({ + OPENCODE_DISABLE_AUTOUPDATE: '1', + }), }); const inputPath = runner.calls[0].args[4]; diff --git a/test/main/services/team/OpenCodeLaunchModeEnv.test.ts b/test/main/services/team/OpenCodeLaunchModeEnv.test.ts new file mode 100644 index 00000000..14b3b4a4 --- /dev/null +++ b/test/main/services/team/OpenCodeLaunchModeEnv.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveOpenCodeTeamLaunchModeFromEnv } from '../../../../src/main/services/team/opencode/config/OpenCodeLaunchModeEnv'; + +describe('resolveOpenCodeTeamLaunchModeFromEnv', () => { + it('defaults to production so OpenCode is visible while strict readiness remains authoritative', () => { + expect(resolveOpenCodeTeamLaunchModeFromEnv({})).toBe('production'); + }); + + it('preserves explicit launch mode overrides', () => { + expect( + resolveOpenCodeTeamLaunchModeFromEnv({ CLAUDE_TEAM_OPENCODE_LAUNCH_MODE: 'disabled' }) + ).toBe('disabled'); + expect( + resolveOpenCodeTeamLaunchModeFromEnv({ CLAUDE_TEAM_OPENCODE_LAUNCH_MODE: 'dogfood' }) + ).toBe('dogfood'); + expect( + resolveOpenCodeTeamLaunchModeFromEnv({ CLAUDE_TEAM_OPENCODE_LAUNCH_MODE: 'production' }) + ).toBe('production'); + }); + + it('keeps the legacy dogfood flag as an explicit opt-in', () => { + expect(resolveOpenCodeTeamLaunchModeFromEnv({ CLAUDE_TEAM_OPENCODE_DOGFOOD: '1' })).toBe( + 'dogfood' + ); + }); + + it('falls back to production for invalid launch mode values', () => { + expect( + resolveOpenCodeTeamLaunchModeFromEnv({ CLAUDE_TEAM_OPENCODE_LAUNCH_MODE: 'enabled' }) + ).toBe('production'); + }); +}); diff --git a/test/main/services/team/OpenCodeManagedOverlay.test.ts b/test/main/services/team/OpenCodeManagedOverlay.test.ts index d7f7ab0e..e11622f7 100644 --- a/test/main/services/team/OpenCodeManagedOverlay.test.ts +++ b/test/main/services/team/OpenCodeManagedOverlay.test.ts @@ -61,9 +61,33 @@ describe('OpenCodeManagedOverlay', () => { }); expect(overlay.env).not.toHaveProperty('OPENCODE_PURE'); expect(overlay.env).not.toHaveProperty('OPENCODE_DISABLE_PROJECT_CONFIG'); + expect(overlay.env.OPENCODE_DISABLE_AUTOUPDATE).toBe('1'); expect(config).not.toHaveProperty('plugin'); expect(config).not.toHaveProperty('model'); - expect(overlay.diagnostics).toContain('OpenCode managed overlay checked at 2026-04-21T12:00:00.000Z'); + expect(overlay.diagnostics).toContain( + 'OpenCode managed overlay checked at 2026-04-21T12:00:00.000Z' + ); + }); + + it('allows app-managed OpenCode auto-update only behind an explicit override', async () => { + const builder = new OpenCodeManagedOverlayBuilder( + new OpenCodeBehaviorSourceScanner({ homePath }) + ); + + const overlay = await builder.build({ + projectPath, + preferredMcpName: 'agent-teams', + appMcpCommand: 'node', + appMcpArgs: ['server.js'], + appMcpEnv: {}, + env: { + CLAUDE_TEAM_OPENCODE_ALLOW_AUTOUPDATE: '1', + OPENCODE_DISABLE_AUTOUPDATE: '1', + }, + }); + + expect(overlay.env.OPENCODE_CONFIG_CONTENT).toBeTruthy(); + expect(overlay.env.OPENCODE_DISABLE_AUTOUPDATE).toBeUndefined(); }); it('renames the app-owned MCP server when user config already declares the preferred name', async () => { @@ -73,7 +97,9 @@ describe('OpenCodeManagedOverlay', () => { 'agent-teams-runtime-1': { type: 'local', command: 'custom-2', enabled: true }, }, }); - const builder = new OpenCodeManagedOverlayBuilder(new OpenCodeBehaviorSourceScanner({ homePath })); + const builder = new OpenCodeManagedOverlayBuilder( + new OpenCodeBehaviorSourceScanner({ homePath }) + ); const overlay = await builder.build({ projectPath, @@ -99,10 +125,16 @@ describe('OpenCodeManagedOverlay', () => { }`, 'utf8' ); - await fs.writeFile(path.join(projectPath, '.opencode/plugins/example.ts'), 'export default {}', 'utf8'); + await fs.writeFile( + path.join(projectPath, '.opencode/plugins/example.ts'), + 'export default {}', + 'utf8' + ); const scanner = new OpenCodeBehaviorSourceScanner({ homePath }); - await expect(scanner.readDeclaredMcpNames(projectPath)).resolves.toEqual(new Set(['agent-teams'])); + await expect(scanner.readDeclaredMcpNames(projectPath)).resolves.toEqual( + new Set(['agent-teams']) + ); const sources = await scanner.scan(projectPath); expect(sources).toContainEqual(