Merge remote-tracking branch 'origin/dev' into dev

This commit is contained in:
777genius 2026-04-21 22:23:14 +03:00
commit db94a08712
11 changed files with 199 additions and 32 deletions

View file

@ -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<TeamRuntimeAdapterRegistry> {
const binaryPath = await ClaudeBinaryResolver.resolve();
if (!binaryPath) {
@ -218,7 +208,7 @@ async function createOpenCodeRuntimeAdapterRegistry(): Promise<TeamRuntimeAdapte
return new TeamRuntimeAdapterRegistry();
}
const bridgeEnv = { ...process.env };
const bridgeEnv = applyOpenCodeAutoUpdatePolicy({ ...process.env });
try {
const mcpLaunchSpec = await resolveAgentTeamsMcpLaunchSpec();
const mcpEntry = mcpLaunchSpec.args[0];

View file

@ -3,6 +3,7 @@ import { getShellPreferredHome } from '@main/utils/shellEnv';
import { configManager } from '../infrastructure/ConfigManager';
import { applyOpenCodeAutoUpdatePolicy } from './openCodeAutoUpdatePolicy';
import {
applyConfiguredRuntimeBackendsEnv,
applyProviderRuntimeEnv,
@ -42,6 +43,11 @@ export function buildRuntimeBaseEnv(options: BuildRuntimeBaseEnvOptions = {}): {
applyConfiguredRuntimeBackendsEnv(env, configManager.getConfig().runtime);
Object.assign(env, options.env ?? {});
const policyAppliedEnv = applyOpenCodeAutoUpdatePolicy(env);
if (policyAppliedEnv.OPENCODE_DISABLE_AUTOUPDATE === undefined) {
delete env.OPENCODE_DISABLE_AUTOUPDATE;
}
Object.assign(env, policyAppliedEnv);
const explicitHome = getFirstNonEmptyEnvValue(options.env?.HOME, options.env?.USERPROFILE);
const fallbackHome = getFirstNonEmptyEnvValue(

View file

@ -0,0 +1,22 @@
export const OPENCODE_DISABLE_AUTOUPDATE_ENV = 'OPENCODE_DISABLE_AUTOUPDATE';
export const CLAUDE_TEAM_OPENCODE_ALLOW_AUTOUPDATE_ENV = 'CLAUDE_TEAM_OPENCODE_ALLOW_AUTOUPDATE';
const ENABLED_VALUES = new Set(['1', 'true', 'yes', 'on']);
export function isOpenCodeAutoUpdateAllowed(env: NodeJS.ProcessEnv = process.env): boolean {
const raw = env[CLAUDE_TEAM_OPENCODE_ALLOW_AUTOUPDATE_ENV]?.trim().toLowerCase();
return raw ? ENABLED_VALUES.has(raw) : false;
}
export function applyOpenCodeAutoUpdatePolicy<T extends Record<string, string | undefined>>(
env: T,
policyEnv: NodeJS.ProcessEnv = env as NodeJS.ProcessEnv
): T & NodeJS.ProcessEnv {
const next: Record<string, string | undefined> = { ...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;
}

View file

@ -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;
}

View file

@ -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';
}

View file

@ -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<string, string>;
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<OpenCodeBehaviorSource[]> {
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<void> => {
if (results.length >= this.maxDirectoryFiles) {

View file

@ -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);
});
});

View file

@ -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 () => {

View file

@ -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];

View file

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

View file

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