From 6b8ab1041413ec25a6219800a985f935b44fa103 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 28 May 2026 16:39:04 +0300 Subject: [PATCH 01/48] fix(opencode): stabilize runtime message delivery --- src/main/ipc/teams.ts | 190 ++++++++++-- .../services/team/TeamProvisioningService.ts | 27 +- .../OpenCodeManagedHostProcessCleanup.ts | 8 +- test/main/ipc/teams.test.ts | 281 +++++++++++++++++- .../OpenCodeManagedHostProcessCleanup.test.ts | 29 +- .../team/TeamProvisioningServiceRelay.test.ts | 62 ++++ 6 files changed, 570 insertions(+), 27 deletions(-) diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 64898634..fc32c7f8 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -242,7 +242,17 @@ import type { import type { CliArgsValidationResult } from '@shared/utils/cliArgsParser'; const logger = createLogger('IPC:teams'); -const OPENCODE_RUNTIME_DELIVERY_UI_TIMEOUT_MS = 12_000; +// Runtime relay continues in the background after this race; keep sendMessage IPC off the +// 25s OpenCode turn-settled guard while still giving prompt acceptance/reconcile time. +const OPENCODE_RUNTIME_DELIVERY_UI_TIMEOUT_MS = 6_000; +const OPENCODE_RUNTIME_DELIVERY_STATUS_AFTER_UI_TIMEOUT_MS = 1_000; +const OPENCODE_RUNTIME_DELIVERY_UI_TIMEOUT_PENDING_REASON = + 'opencode_runtime_delivery_ui_timeout_pending'; + +type OpenCodeMemberInboxRelayResult = Awaited< + ReturnType +>; +type OpenCodeMemberInboxDelivery = NonNullable; type VisibleDirectReplyProtocol = 'send_message' | 'agent_teams_message_send'; @@ -320,6 +330,158 @@ async function withTimeoutValue( } } +async function waitForOpenCodeRuntimeRelayForUi(input: { + provisioning: TeamProvisioningService; + teamName: string; + memberName: string; + messageId: string; + relayPromise: Promise; + timeoutMs?: number; +}): Promise { + let timer: ReturnType | null = null; + let timedOut = false; + + void input.relayPromise.then( + (relay) => { + if (!timedOut) return; + const delivery = relay.lastDelivery; + if (delivery && !delivery.delivered && delivery.reason !== 'recipient_is_not_opencode') { + logger.warn( + `OpenCode runtime delivery after sendMessage completed after UI timeout for teammate "${input.memberName}" with failure: ${ + delivery.reason ?? 'unknown error' + }` + ); + } + }, + (error: unknown) => { + if (!timedOut) return; + logger.warn( + `OpenCode runtime delivery after sendMessage rejected after UI timeout for teammate "${input.memberName}": ${getErrorMessage(error)}` + ); + } + ); + + try { + const outcome = await Promise.race< + { kind: 'relay'; relay: OpenCodeMemberInboxRelayResult } | { kind: 'timeout' } + >([ + input.relayPromise.then((relay) => ({ kind: 'relay' as const, relay })), + new Promise<{ kind: 'timeout' }>((resolve) => { + timer = setTimeout(() => { + timedOut = true; + resolve({ kind: 'timeout' }); + }, input.timeoutMs ?? OPENCODE_RUNTIME_DELIVERY_UI_TIMEOUT_MS); + timer.unref?.(); + }), + ]); + + if (outcome.kind === 'relay') { + return outcome.relay; + } + + try { + const status = await withTimeoutValue( + input.provisioning.getOpenCodeRuntimeDeliveryStatus(input.teamName, input.messageId), + OPENCODE_RUNTIME_DELIVERY_STATUS_AFTER_UI_TIMEOUT_MS, + null + ); + if (status) { + return openCodeRuntimeDeliveryStatusToRelayResult(status); + } + } catch (error) { + const reason = getErrorMessage(error); + logger.warn( + `OpenCode runtime delivery status after UI timeout failed for teammate "${input.memberName}": ${reason}` + ); + return buildOpenCodeRuntimeDeliveryUiTimeoutRelayResult([ + `${OPENCODE_RUNTIME_DELIVERY_UI_TIMEOUT_PENDING_REASON}: status lookup failed: ${reason}`, + ]); + } + + return buildOpenCodeRuntimeDeliveryUiTimeoutRelayResult(); + } finally { + if (timer) { + clearTimeout(timer); + } + } +} + +function openCodeRuntimeDeliveryStatusToRelayResult( + status: OpenCodeRuntimeDeliveryStatus +): OpenCodeMemberInboxRelayResult { + const lastDelivery: OpenCodeMemberInboxDelivery = { + delivered: status.delivered, + ...(typeof status.responsePending === 'boolean' + ? { responsePending: status.responsePending } + : {}), + ...(typeof status.acceptanceUnknown === 'boolean' + ? { acceptanceUnknown: status.acceptanceUnknown } + : {}), + ...(status.responseState ? { responseState: status.responseState } : {}), + ...(status.ledgerStatus ? { ledgerStatus: status.ledgerStatus } : {}), + ...(status.visibleReplyMessageId + ? { visibleReplyMessageId: status.visibleReplyMessageId } + : {}), + ...(status.visibleReplyCorrelation + ? { visibleReplyCorrelation: status.visibleReplyCorrelation } + : {}), + ...(status.queuedBehindMessageId + ? { queuedBehindMessageId: status.queuedBehindMessageId } + : {}), + ...(status.reason ? { reason: status.reason } : {}), + ...(status.diagnostics ? { diagnostics: status.diagnostics } : {}), + ...(shouldPreserveOpenCodeRuntimeDeliveryStatusImpact(status) + ? { userVisibleImpact: status.userVisibleImpact } + : {}), + }; + return { + relayed: 0, + attempted: 1, + delivered: status.delivered && status.responsePending !== true ? 1 : 0, + failed: status.delivered ? 0 : 1, + lastDelivery, + diagnostics: status.diagnostics, + }; +} + +function shouldPreserveOpenCodeRuntimeDeliveryStatusImpact( + status: OpenCodeRuntimeDeliveryStatus +): boolean { + if (!status.userVisibleImpact) { + return false; + } + if ( + status.userVisibleImpact.state === 'none' && + (status.responsePending === true || + status.acceptanceUnknown === true || + Boolean(status.queuedBehindMessageId)) + ) { + return false; + } + return true; +} + +function buildOpenCodeRuntimeDeliveryUiTimeoutRelayResult( + extraDiagnostics: string[] = [] +): OpenCodeMemberInboxRelayResult { + const diagnostics = [OPENCODE_RUNTIME_DELIVERY_UI_TIMEOUT_PENDING_REASON, ...extraDiagnostics]; + return { + relayed: 0, + attempted: 1, + delivered: 0, + failed: 1, + lastDelivery: { + delivered: true, + accepted: false, + responsePending: true, + acceptanceUnknown: true, + responseState: 'not_observed', + reason: OPENCODE_RUNTIME_DELIVERY_UI_TIMEOUT_PENDING_REASON, + diagnostics, + }, + }; +} + function noteHeavyTeamDataWorkerFallback(operation: string): void { if (!app.isPackaged) { return; @@ -3077,8 +3239,12 @@ async function handleSendMessage( // } if (isOpenCodeRecipient) { try { - const relay = await withTimeoutValue( - provisioning.relayOpenCodeMemberInboxMessages(tn, memberName, { + const relay = await waitForOpenCodeRuntimeRelayForUi({ + provisioning, + teamName: tn, + memberName, + messageId: result.messageId, + relayPromise: provisioning.relayOpenCodeMemberInboxMessages(tn, memberName, { onlyMessageId: result.messageId, source: 'ui-send', deliveryMetadata: { @@ -3087,23 +3253,7 @@ async function handleSendMessage( taskRefs: validatedTaskRefs.value, }, }), - OPENCODE_RUNTIME_DELIVERY_UI_TIMEOUT_MS, - { - relayed: 0, - attempted: 1, - delivered: 0, - failed: 1, - lastDelivery: { - delivered: true, - accepted: false, - responsePending: true, - acceptanceUnknown: true, - responseState: 'not_observed', - reason: 'opencode_runtime_delivery_ui_timeout_pending', - diagnostics: ['opencode_runtime_delivery_ui_timeout_pending'], - }, - } - ); + }); const delivery = relay.lastDelivery ?? { delivered: relay.relayed > 0, reason: relay.relayed > 0 ? undefined : 'opencode_message_delivery_not_attempted', diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 045d3392..4dae1a17 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -8540,14 +8540,23 @@ export class TeamProvisioningService { } if (active && active.inboxMessageId !== messageId) { const activeDueMs = active.nextAttemptAt ? Date.parse(active.nextAttemptAt) : NaN; + const activeRetryDelayMs = Number.isFinite(activeDueMs) + ? Math.max(500, activeDueMs - Date.now()) + : OPENCODE_PROMPT_DELIVERY_OBSERVE_DELAY_MS; this.scheduleOpenCodePromptDeliveryWatchdog({ teamName, memberName: canonicalMemberName, messageId: active.inboxMessageId, - delayMs: Number.isFinite(activeDueMs) - ? Math.max(500, activeDueMs - Date.now()) - : OPENCODE_PROMPT_DELIVERY_OBSERVE_DELAY_MS, + delayMs: activeRetryDelayMs, }); + if (messageId) { + this.scheduleOpenCodeMemberInboxDeliveryWake({ + teamName, + memberName: canonicalMemberName, + messageId, + delayMs: activeRetryDelayMs + 500, + }); + } return { delivered: true, accepted: false, @@ -22892,6 +22901,18 @@ export class TeamProvisioningService { break; } if (delivery.responsePending) { + if ( + typeof delivery.queuedBehindMessageId === 'string' && + delivery.queuedBehindMessageId.trim() && + delivery.queuedBehindMessageId !== message.messageId + ) { + this.scheduleOpenCodeMemberInboxDeliveryWake({ + teamName, + memberName, + messageId: message.messageId, + delayMs: OPENCODE_PROMPT_DELIVERY_OBSERVE_DELAY_MS, + }); + } result.diagnostics = [ ...(result.diagnostics ?? []), ...(delivery.diagnostics ?? [delivery.reason ?? 'opencode_delivery_response_pending']), diff --git a/src/main/services/team/opencode/bridge/OpenCodeManagedHostProcessCleanup.ts b/src/main/services/team/opencode/bridge/OpenCodeManagedHostProcessCleanup.ts index 70ff30b4..c5b03107 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeManagedHostProcessCleanup.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeManagedHostProcessCleanup.ts @@ -48,6 +48,11 @@ const MANAGED_ENV_IDENTITY_MARKERS = [ 'CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY=', 'CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL=', ] as const; +const MANAGED_INLINE_OPENCODE_CONFIG_PATTERNS = [ + /OPENCODE_CONFIG_CONTENT=[\s\S]*"mcp"\s*:\s*\{[\s\S]*"agent-teams(?:-runtime-\d+)?"/i, + /OPENCODE_CONFIG_CONTENT=[\s\S]*"claude-multimodel runtime orchestration"/i, + /OPENCODE_CONFIG_CONTENT=[\s\S]*"(?:agent-teams|agent_teams|mcp__agent-teams|mcp__agent_teams)_\*"/i, +] as const; export async function cleanupManagedOpenCodeServeProcesses( options: OpenCodeManagedHostProcessCleanupOptions @@ -202,7 +207,8 @@ export function isAppManagedWindowsOpenCodeServeCommand(command: string): boolea export function isManagedOpenCodeServeProcessDetails(details: string): boolean { return ( processDetailsIncludeMarkers(details, MANAGED_ENV_MARKERS) && - MANAGED_ENV_IDENTITY_MARKERS.some((marker) => processDetailsIncludeMarker(details, marker)) + (MANAGED_ENV_IDENTITY_MARKERS.some((marker) => processDetailsIncludeMarker(details, marker)) || + MANAGED_INLINE_OPENCODE_CONFIG_PATTERNS.some((pattern) => pattern.test(details))) ); } diff --git a/test/main/ipc/teams.test.ts b/test/main/ipc/teams.test.ts index 3b39d005..4a43277a 100644 --- a/test/main/ipc/teams.test.ts +++ b/test/main/ipc/teams.test.ts @@ -12,6 +12,7 @@ import type { BoardTaskLogStreamResponse, InboxMessage, MessagesPage, + OpenCodeRuntimeDeliveryStatus, SendMessageResult, TeamCreateRequest, TeamLaunchRequest, @@ -318,6 +319,7 @@ describe('ipc teams handlers', () => { } | undefined, })), + getOpenCodeRuntimeDeliveryStatus: vi.fn(async () => null as OpenCodeRuntimeDeliveryStatus | null), buildOpenCodeRuntimeDeliveryUserVisibleImpact: vi.fn(() => ({ state: 'none' })), getLiveLeadProcessMessages: vi.fn(() => [] as InboxMessage[]), getCurrentLeadSessionId: vi.fn(() => null as string | null), @@ -868,6 +870,7 @@ describe('ipc teams handlers', () => { attempted: true, delivered: true, }); + expect(provisioningService.getOpenCodeRuntimeDeliveryStatus).not.toHaveBeenCalled(); }); it('returns runtimeDelivery failure without hiding the persisted OpenCode message', async () => { @@ -950,6 +953,7 @@ describe('ipc teams handlers', () => { provisioningService.relayOpenCodeMemberInboxMessages.mockReturnValueOnce( new Promise(() => undefined) ); + provisioningService.getOpenCodeRuntimeDeliveryStatus.mockResolvedValueOnce(null); const sendHandler = handlers.get(TEAM_SEND_MESSAGE); expect(sendHandler).toBeDefined(); @@ -958,7 +962,7 @@ describe('ipc teams handlers', () => { text: 'Ping bob', }) as Promise<{ success: boolean; data?: SendMessageResult }>; - await vi.advanceTimersByTimeAsync(12_000); + await vi.advanceTimersByTimeAsync(6_000); const result = await resultPromise; expect(result.success).toBe(true); @@ -971,6 +975,281 @@ describe('ipc teams handlers', () => { responseState: 'not_observed', reason: 'opencode_runtime_delivery_ui_timeout_pending', }); + expect(provisioningService.getOpenCodeRuntimeDeliveryStatus).toHaveBeenCalledWith( + 'my-team', + result.data?.messageId + ); + } finally { + vi.useRealTimers(); + } + }); + + it('uses durable OpenCode delivery status when UI relay timeout fires after prompt acceptance', async () => { + vi.useFakeTimers(); + try { + provisioningService.resolveRuntimeRecipientProviderId.mockResolvedValueOnce('opencode'); + provisioningService.relayOpenCodeMemberInboxMessages.mockReturnValueOnce( + new Promise(() => undefined) + ); + provisioningService.getOpenCodeRuntimeDeliveryStatus.mockResolvedValueOnce({ + messageId: 'msg-123', + providerId: 'opencode', + attempted: true, + delivered: true, + responsePending: true, + responseState: 'not_observed', + ledgerStatus: 'pending', + reason: 'opencode_delivery_response_pending', + diagnostics: ['prompt accepted'], + userVisibleImpact: { state: 'none' }, + }); + provisioningService.buildOpenCodeRuntimeDeliveryUserVisibleImpact.mockReturnValueOnce({ + state: 'checking', + }); + const sendHandler = handlers.get(TEAM_SEND_MESSAGE); + expect(sendHandler).toBeDefined(); + + const resultPromise = sendHandler!({} as never, 'my-team', { + member: 'bob', + text: 'Ping bob', + }) as Promise<{ success: boolean; data?: SendMessageResult }>; + + await vi.advanceTimersByTimeAsync(6_000); + const result = await resultPromise; + + expect(result.success).toBe(true); + expect(result.data?.runtimeDelivery).toMatchObject({ + providerId: 'opencode', + attempted: true, + delivered: true, + responsePending: true, + responseState: 'not_observed', + ledgerStatus: 'pending', + reason: 'opencode_delivery_response_pending', + diagnostics: ['prompt accepted'], + userVisibleImpact: { state: 'checking' }, + }); + expect(result.data?.runtimeDelivery?.acceptanceUnknown).toBeUndefined(); + const impactCalls = provisioningService.buildOpenCodeRuntimeDeliveryUserVisibleImpact.mock + .calls as unknown as Array<[Partial>]>; + const impactInput = impactCalls.at(-1)?.[0]; + expect(impactInput).toMatchObject({ + delivered: true, + responsePending: true, + }); + expect(impactInput).not.toHaveProperty('userVisibleImpact'); + } finally { + vi.useRealTimers(); + } + }); + + it('preserves terminal durable OpenCode delivery status after UI relay timeout', async () => { + vi.useFakeTimers(); + try { + provisioningService.resolveRuntimeRecipientProviderId.mockResolvedValueOnce('opencode'); + provisioningService.relayOpenCodeMemberInboxMessages.mockReturnValueOnce( + new Promise(() => undefined) + ); + provisioningService.getOpenCodeRuntimeDeliveryStatus.mockResolvedValueOnce({ + messageId: 'msg-responded', + providerId: 'opencode', + attempted: true, + delivered: true, + responsePending: false, + responseState: 'responded_visible_message', + ledgerStatus: 'responded', + visibleReplyMessageId: 'reply-1', + visibleReplyCorrelation: 'relayOfMessageId', + acceptanceUnknown: false, + diagnostics: [], + userVisibleImpact: { state: 'none' }, + }); + const sendHandler = handlers.get(TEAM_SEND_MESSAGE); + expect(sendHandler).toBeDefined(); + + const resultPromise = sendHandler!({} as never, 'my-team', { + member: 'bob', + text: 'Ping bob', + }) as Promise<{ success: boolean; data?: SendMessageResult }>; + + await vi.advanceTimersByTimeAsync(6_000); + const result = await resultPromise; + + expect(result.success).toBe(true); + expect(result.data?.runtimeDelivery).toMatchObject({ + providerId: 'opencode', + attempted: true, + delivered: true, + responsePending: false, + responseState: 'responded_visible_message', + ledgerStatus: 'responded', + visibleReplyMessageId: 'reply-1', + visibleReplyCorrelation: 'relayOfMessageId', + acceptanceUnknown: false, + userVisibleImpact: { state: 'none' }, + }); + expect(provisioningService.buildOpenCodeRuntimeDeliveryUserVisibleImpact).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it('does not let a slow OpenCode delivery status lookup extend the UI timeout indefinitely', async () => { + vi.useFakeTimers(); + try { + provisioningService.resolveRuntimeRecipientProviderId.mockResolvedValueOnce('opencode'); + provisioningService.relayOpenCodeMemberInboxMessages.mockReturnValueOnce( + new Promise(() => undefined) + ); + provisioningService.getOpenCodeRuntimeDeliveryStatus.mockReturnValueOnce( + new Promise(() => undefined) + ); + const sendHandler = handlers.get(TEAM_SEND_MESSAGE); + expect(sendHandler).toBeDefined(); + + const resultPromise = sendHandler!({} as never, 'my-team', { + member: 'bob', + text: 'Ping bob', + }) as Promise<{ success: boolean; data?: SendMessageResult }>; + + await vi.advanceTimersByTimeAsync(6_000); + await flushMicrotasks(); + let settled = false; + void resultPromise.then(() => { + settled = true; + }); + await flushMicrotasks(); + expect(settled).toBe(false); + + await vi.advanceTimersByTimeAsync(1_000); + const result = await resultPromise; + + expect(result.success).toBe(true); + expect(result.data?.runtimeDelivery).toMatchObject({ + providerId: 'opencode', + attempted: true, + delivered: true, + responsePending: true, + acceptanceUnknown: true, + reason: 'opencode_runtime_delivery_ui_timeout_pending', + }); + } finally { + vi.useRealTimers(); + } + }); + + it('falls back to acceptance-unknown when OpenCode delivery status lookup rejects after UI timeout', async () => { + vi.useFakeTimers(); + try { + provisioningService.resolveRuntimeRecipientProviderId.mockResolvedValueOnce('opencode'); + provisioningService.relayOpenCodeMemberInboxMessages.mockReturnValueOnce( + new Promise(() => undefined) + ); + provisioningService.getOpenCodeRuntimeDeliveryStatus.mockRejectedValueOnce( + new Error('status read failed') + ); + const sendHandler = handlers.get(TEAM_SEND_MESSAGE); + expect(sendHandler).toBeDefined(); + + const resultPromise = sendHandler!({} as never, 'my-team', { + member: 'bob', + text: 'Ping bob', + }) as Promise<{ success: boolean; data?: SendMessageResult }>; + + await vi.advanceTimersByTimeAsync(6_000); + const result = await resultPromise; + + expect(result.success).toBe(true); + expect(result.data?.runtimeDelivery).toMatchObject({ + providerId: 'opencode', + attempted: true, + delivered: true, + responsePending: true, + acceptanceUnknown: true, + reason: 'opencode_runtime_delivery_ui_timeout_pending', + diagnostics: [ + 'opencode_runtime_delivery_ui_timeout_pending', + 'opencode_runtime_delivery_ui_timeout_pending: status lookup failed: status read failed', + ], + }); + expect(vi.mocked(console.warn).mock.calls.some((call) => + call.join(' ').includes('status after UI timeout failed') + )).toBe(true); + vi.mocked(console.warn).mockClear(); + } finally { + vi.useRealTimers(); + } + }); + + it('logs OpenCode relay rejection that happens after the UI timeout fallback', async () => { + vi.useFakeTimers(); + try { + const deferredRelay = createDeferred + >>(); + provisioningService.resolveRuntimeRecipientProviderId.mockResolvedValueOnce('opencode'); + provisioningService.relayOpenCodeMemberInboxMessages.mockReturnValueOnce(deferredRelay.promise); + provisioningService.getOpenCodeRuntimeDeliveryStatus.mockResolvedValueOnce(null); + const sendHandler = handlers.get(TEAM_SEND_MESSAGE); + expect(sendHandler).toBeDefined(); + + const resultPromise = sendHandler!({} as never, 'my-team', { + member: 'bob', + text: 'Ping bob', + }) as Promise<{ success: boolean; data?: SendMessageResult }>; + + await vi.advanceTimersByTimeAsync(6_000); + await expect(resultPromise).resolves.toMatchObject({ success: true }); + + deferredRelay.reject(new Error('late bridge failure')); + await flushMicrotasks(); + + expect(vi.mocked(console.warn).mock.calls.some((call) => + call.join(' ').includes('rejected after UI timeout') + )).toBe(true); + vi.mocked(console.warn).mockClear(); + } finally { + vi.useRealTimers(); + } + }); + + it('logs OpenCode relay failure result that resolves after the UI timeout fallback', async () => { + vi.useFakeTimers(); + try { + const deferredRelay = createDeferred + >>(); + provisioningService.resolveRuntimeRecipientProviderId.mockResolvedValueOnce('opencode'); + provisioningService.relayOpenCodeMemberInboxMessages.mockReturnValueOnce(deferredRelay.promise); + provisioningService.getOpenCodeRuntimeDeliveryStatus.mockResolvedValueOnce(null); + const sendHandler = handlers.get(TEAM_SEND_MESSAGE); + expect(sendHandler).toBeDefined(); + + const resultPromise = sendHandler!({} as never, 'my-team', { + member: 'bob', + text: 'Ping bob', + }) as Promise<{ success: boolean; data?: SendMessageResult }>; + + await vi.advanceTimersByTimeAsync(6_000); + await expect(resultPromise).resolves.toMatchObject({ success: true }); + + deferredRelay.resolve({ + relayed: 0, + attempted: 1, + delivered: 0, + failed: 1, + lastDelivery: { + delivered: false, + reason: 'late_runtime_failure', + diagnostics: ['late_runtime_failure'], + }, + }); + await flushMicrotasks(); + + expect(vi.mocked(console.warn).mock.calls.some((call) => + call.join(' ').includes('completed after UI timeout') + )).toBe(true); + vi.mocked(console.warn).mockClear(); } finally { vi.useRealTimers(); } diff --git a/test/main/services/team/OpenCodeManagedHostProcessCleanup.test.ts b/test/main/services/team/OpenCodeManagedHostProcessCleanup.test.ts index 803d33cf..61e860c0 100644 --- a/test/main/services/team/OpenCodeManagedHostProcessCleanup.test.ts +++ b/test/main/services/team/OpenCodeManagedHostProcessCleanup.test.ts @@ -1,5 +1,3 @@ -import { describe, expect, it, vi } from 'vitest'; - import { cleanupManagedOpenCodeServeProcesses, getOpenCodeServeLoopbackBaseUrl, @@ -7,6 +5,7 @@ import { isManagedOpenCodeServeProcessDetails, isOpenCodeServeCommand, } from '@main/services/team/opencode/bridge/OpenCodeManagedHostProcessCleanup'; +import { describe, expect, it, vi } from 'vitest'; const MANAGED_DETAILS = [ '/opt/homebrew/bin/opencode serve --hostname 127.0.0.1 --port 54171', @@ -27,6 +26,16 @@ const MANAGED_DETAILS_WITH_WORKSPACE_MCP = [ 'OPENCODE_CONFIG_CONTENT={}', 'AGENT_TEAMS_MCP_CLAUDE_DIR=/tmp/claude', ].join(' '); +const MANAGED_DETAILS_WITH_INLINE_OPENCODE_CONFIG_MCP = [ + '/opt/homebrew/bin/opencode serve --hostname 127.0.0.1 --port 54171', + 'CLAUDE_MULTIMODEL_DATA_HOME=/tmp/agent-teams-runtime', + 'OPENCODE_CONFIG_CONTENT={"mcp":{"agent-teams":{"type":"local","command":["node","mcp-server/dist/index.js"],"environment":{"AGENT_TEAMS_MCP_CLAUDE_DIR":"/tmp/claude"},"enabled":true}}}', +].join(' '); +const MANAGED_DETAILS_WITH_INLINE_OPENCODE_AGENT_PERMISSIONS = [ + '/opt/homebrew/bin/opencode serve --hostname 127.0.0.1 --port 54171', + 'CLAUDE_MULTIMODEL_DATA_HOME=/tmp/agent-teams-runtime', + 'OPENCODE_CONFIG_CONTENT={"agent":{"teammate":{"description":"Managed teammate agent for claude-multimodel runtime orchestration.","permission":{"agent-teams_*":"allow","mcp__agent-teams__*":"allow"}}}}', +].join(' '); function resolved(value: T): Promise { return Promise.resolve(value); @@ -63,6 +72,12 @@ describe('OpenCodeManagedHostProcessCleanup', () => { expect(isManagedOpenCodeServeProcessDetails(MANAGED_DETAILS)).toBe(true); expect(isManagedOpenCodeServeProcessDetails(MANAGED_DETAILS_WITH_REMOTE_MCP)).toBe(true); expect(isManagedOpenCodeServeProcessDetails(MANAGED_DETAILS_WITH_WORKSPACE_MCP)).toBe(true); + expect(isManagedOpenCodeServeProcessDetails(MANAGED_DETAILS_WITH_INLINE_OPENCODE_CONFIG_MCP)).toBe( + true + ); + expect( + isManagedOpenCodeServeProcessDetails(MANAGED_DETAILS_WITH_INLINE_OPENCODE_AGENT_PERMISSIONS) + ).toBe(true); expect( isManagedOpenCodeServeProcessDetails( 'opencode serve CLAUDE_MULTIMODEL_DATA_HOME=/tmp OPENCODE_CONFIG_CONTENT={}' @@ -78,6 +93,16 @@ describe('OpenCodeManagedHostProcessCleanup', () => { 'opencode serve NOT_CLAUDE_MULTIMODEL_DATA_HOME=/tmp OPENCODE_CONFIG_CONTENT={} AGENT_TEAMS_MCP_CLAUDE_DIR=/tmp/claude' ) ).toBe(false); + expect( + isManagedOpenCodeServeProcessDetails( + 'opencode serve OPENCODE_CONFIG_CONTENT={"mcp":{"agent-teams":{"enabled":true}}}' + ) + ).toBe(false); + expect( + isManagedOpenCodeServeProcessDetails( + 'opencode serve OPENCODE_CONFIG_CONTENT={"agent":{"teammate":{"permission":{"agent-teams_*":"allow"}}}}' + ) + ).toBe(false); }); it('extracts only loopback OpenCode serve base URLs for disposal', () => { diff --git a/test/main/services/team/TeamProvisioningServiceRelay.test.ts b/test/main/services/team/TeamProvisioningServiceRelay.test.ts index 288bc228..3588f7e1 100644 --- a/test/main/services/team/TeamProvisioningServiceRelay.test.ts +++ b/test/main/services/team/TeamProvisioningServiceRelay.test.ts @@ -3721,6 +3721,68 @@ Messages: } }); + it('reschedules a queued OpenCode inbox row when delivery is blocked behind an active prompt', async () => { + const service = new TeamProvisioningService(); + const teamName = 'my-team'; + hoisted.files.set( + `/mock/teams/${teamName}/config.json`, + JSON.stringify({ + name: teamName, + projectPath: '/mock/my-team', + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'jack', role: 'developer', providerId: 'opencode', model: 'openrouter/test' }, + ], + }) + ); + seedMemberInbox(teamName, 'jack', [ + { + from: 'user', + to: 'jack', + text: 'Queued UI message.', + timestamp: '2026-02-23T17:00:01.000Z', + read: false, + messageId: 'opencode-queued-current', + }, + ]); + const wakeSpy = vi + .spyOn(service, 'scheduleOpenCodeMemberInboxDeliveryWake') + .mockImplementation(() => undefined); + vi.spyOn(service, 'deliverOpenCodeMemberMessage').mockResolvedValue({ + delivered: true, + accepted: false, + responsePending: true, + queuedBehindMessageId: 'opencode-active-blocker', + reason: 'opencode_delivery_response_pending', + diagnostics: ['OpenCode delivery is queued behind opencode-active-blocker.'], + }); + + const relay = await service.relayOpenCodeMemberInboxMessages(teamName, 'jack', { + onlyMessageId: 'opencode-queued-current', + source: 'watchdog', + deliveryMetadata: { replyRecipient: 'user' }, + }); + + expect(relay).toMatchObject({ + attempted: 1, + delivered: 0, + failed: 0, + lastDelivery: { + delivered: true, + responsePending: true, + queuedBehindMessageId: 'opencode-active-blocker', + }, + }); + expect(wakeSpy).toHaveBeenCalledWith({ + teamName, + memberName: 'jack', + messageId: 'opencode-queued-current', + delayMs: 3_000, + }); + const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'); + expect(rows[0]).toMatchObject({ messageId: 'opencode-queued-current', read: false }); + }); + it('treats an already-read specific OpenCode inbox row as delivered for UI-send relay', async () => { const service = new TeamProvisioningService(); const teamName = 'my-team'; From d35f03b3962c17ddb9dc6d4a5c834272b9f6f1c0 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 28 May 2026 18:36:06 +0300 Subject: [PATCH 02/48] fix(runtime): pass stored Codex key to status probes Refs #189 --- .../runtime/ClaudeMultimodelBridgeService.ts | 20 +++- .../CliProviderModelAvailabilityService.ts | 10 +- .../services/runtime/providerAwareCliEnv.ts | 22 ++++ .../ClaudeMultimodelBridgeService.test.ts | 68 +++++++++++ ...liProviderModelAvailabilityService.test.ts | 27 +++++ .../runtime/ProviderConnectionService.test.ts | 109 ++++++++++++++++++ .../runtime/providerAwareCliEnv.test.ts | 34 ++++++ 7 files changed, 281 insertions(+), 9 deletions(-) diff --git a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts index 2e4b3cca..36eef0d1 100644 --- a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts +++ b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts @@ -10,7 +10,11 @@ import { tmpdir } from 'os'; import path from 'path'; import { resolveGeminiRuntimeAuth } from './geminiRuntimeAuth'; -import { buildProviderAwareCliEnv } from './providerAwareCliEnv'; +import { + buildProviderAwareCliEnv, + getAggregateProviderStatusStoredCredentialAllowlist, + getProviderStatusStoredCredentialAllowlist, +} from './providerAwareCliEnv'; import { providerConnectionService } from './ProviderConnectionService'; import type { @@ -838,12 +842,15 @@ export class ClaudeMultimodelBridgeService { } private async buildCliEnv( - binaryPath: string + binaryPath: string, + options: { allowedStoredApiKeyEnvVarNames?: readonly string[] } = {} ): Promise>> { return buildProviderAwareCliEnv({ binaryPath, allowStoredApiKeyDecryption: false, - allowedStoredApiKeyEnvVarNames: ['ANTHROPIC_AUTH_TOKEN'], + allowedStoredApiKeyEnvVarNames: options.allowedStoredApiKeyEnvVarNames ?? [ + 'ANTHROPIC_AUTH_TOKEN', + ], }); } @@ -855,8 +862,7 @@ export class ClaudeMultimodelBridgeService { binaryPath, providerId, allowStoredApiKeyDecryption: false, - allowedStoredApiKeyEnvVarNames: - providerId === 'anthropic' ? ['ANTHROPIC_AUTH_TOKEN'] : undefined, + allowedStoredApiKeyEnvVarNames: getProviderStatusStoredCredentialAllowlist(providerId), }); } @@ -1744,7 +1750,9 @@ export class ClaudeMultimodelBridgeService { ); } - const { env, connectionIssues } = await this.buildCliEnv(binaryPath); + const { env, connectionIssues } = await this.buildCliEnv(binaryPath, { + allowedStoredApiKeyEnvVarNames: getAggregateProviderStatusStoredCredentialAllowlist(), + }); try { const { stdout } = await execCli(binaryPath, ['runtime', 'status', '--json'], { diff --git a/src/main/services/runtime/CliProviderModelAvailabilityService.ts b/src/main/services/runtime/CliProviderModelAvailabilityService.ts index b9c8b520..4c23438c 100644 --- a/src/main/services/runtime/CliProviderModelAvailabilityService.ts +++ b/src/main/services/runtime/CliProviderModelAvailabilityService.ts @@ -3,7 +3,10 @@ import { getErrorMessage } from '@shared/utils/errorHandling'; import { createLogger } from '@shared/utils/logger'; import { filterVisibleProviderRuntimeModels } from '@shared/utils/providerModelVisibility'; -import { buildProviderAwareCliEnv } from './providerAwareCliEnv'; +import { + buildProviderAwareCliEnv, + getProviderStatusStoredCredentialAllowlist, +} from './providerAwareCliEnv'; import { buildProviderModelProbeArgs, classifyProviderModelProbeFailure, @@ -194,8 +197,9 @@ export class CliProviderModelAvailabilityService { binaryPath: context.binaryPath, providerId: context.provider.providerId, allowStoredApiKeyDecryption: false, - allowedStoredApiKeyEnvVarNames: - context.provider.providerId === 'anthropic' ? ['ANTHROPIC_AUTH_TOKEN'] : undefined, + allowedStoredApiKeyEnvVarNames: getProviderStatusStoredCredentialAllowlist( + context.provider.providerId + ), }).then((result) => ({ env: result.env, providerArgs: result.providerArgs ?? [], diff --git a/src/main/services/runtime/providerAwareCliEnv.ts b/src/main/services/runtime/providerAwareCliEnv.ts index a6bdd0ae..7dfa53dd 100644 --- a/src/main/services/runtime/providerAwareCliEnv.ts +++ b/src/main/services/runtime/providerAwareCliEnv.ts @@ -12,6 +12,14 @@ import type { CliProviderId, TeamProviderId } from '@shared/types'; type ProviderEnvTargetId = CliProviderId | TeamProviderId | undefined; const ELECTRON_RUN_AS_NODE_ENV = 'ELECTRON_RUN_AS_NODE'; +const PROVIDER_STATUS_STORED_CREDENTIAL_ALLOWLIST = { + anthropic: ['ANTHROPIC_AUTH_TOKEN'], + codex: ['OPENAI_API_KEY'], +} as const; +const AGGREGATE_PROVIDER_STATUS_STORED_CREDENTIAL_ALLOWLIST = [ + 'ANTHROPIC_AUTH_TOKEN', + 'OPENAI_API_KEY', +] as const; export interface ProviderAwareCliEnvOptions { binaryPath?: string | null; @@ -30,6 +38,20 @@ export interface ProviderAwareCliEnvResult { providerArgs: string[]; } +export function getProviderStatusStoredCredentialAllowlist( + providerId: ProviderEnvTargetId +): readonly string[] | undefined { + if (providerId === 'anthropic' || providerId === 'codex') { + return PROVIDER_STATUS_STORED_CREDENTIAL_ALLOWLIST[providerId]; + } + + return undefined; +} + +export function getAggregateProviderStatusStoredCredentialAllowlist(): readonly string[] { + return AGGREGATE_PROVIDER_STATUS_STORED_CREDENTIAL_ALLOWLIST; +} + function removeGlobalElectronRunAsNodeEnv(env: NodeJS.ProcessEnv): void { delete env[ELECTRON_RUN_AS_NODE_ENV]; } diff --git a/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts b/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts index 856e0eb8..1a2daa5a 100644 --- a/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts +++ b/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts @@ -57,6 +57,16 @@ vi.mock('@main/services/runtime/ProviderConnectionService', () => ({ vi.mock('@main/services/runtime/providerAwareCliEnv', () => ({ buildProviderAwareCliEnv: (...args: Parameters) => buildProviderAwareCliEnvMock(...args), + getAggregateProviderStatusStoredCredentialAllowlist: () => [ + 'ANTHROPIC_AUTH_TOKEN', + 'OPENAI_API_KEY', + ], + getProviderStatusStoredCredentialAllowlist: (providerId?: string) => + providerId === 'anthropic' + ? ['ANTHROPIC_AUTH_TOKEN'] + : providerId === 'codex' + ? ['OPENAI_API_KEY'] + : undefined, })); describe('ClaudeMultimodelBridgeService', () => { @@ -249,6 +259,14 @@ describe('ClaudeMultimodelBridgeService', () => { projectId: 'demo-project', }, }); + + const aggregateEnvBuild = buildProviderAwareCliEnvMock.mock.calls.find( + ([options]) => options.providerId === undefined + ); + expect(aggregateEnvBuild?.[0]).toMatchObject({ + allowStoredApiKeyDecryption: false, + allowedStoredApiKeyEnvVarNames: ['ANTHROPIC_AUTH_TOKEN', 'OPENAI_API_KEY'], + }); }); it('falls back to provider-scoped full runtime status without probing Gemini', async () => { @@ -950,6 +968,18 @@ describe('ClaudeMultimodelBridgeService', () => { modelCatalogRefreshState: 'ready', }); expect(hydratedCodex?.modelCatalog?.models.map((model) => model.id)).toEqual(['gpt-5.4']); + + const codexEnvBuilds = buildProviderAwareCliEnvMock.mock.calls.filter( + ([options]) => options.providerId === 'codex' + ); + expect(codexEnvBuilds.length).toBeGreaterThanOrEqual(2); + for (const [options] of codexEnvBuilds) { + expect(options).toMatchObject({ + providerId: 'codex', + allowStoredApiKeyDecryption: false, + allowedStoredApiKeyEnvVarNames: ['OPENAI_API_KEY'], + }); + } }); it('promotes OpenCode auth when full catalog hydration proves built-in free access', async () => { @@ -1839,6 +1869,44 @@ describe('ClaudeMultimodelBridgeService', () => { ); }); + it('allows the stored Codex API key for Codex status checks', async () => { + execCliMock.mockResolvedValue({ + stdout: JSON.stringify({ + providers: { + codex: { + supported: true, + authenticated: true, + authMethod: 'api_key', + verificationState: 'verified', + canLoginFromUi: true, + capabilities: { teamLaunch: true, oneShot: true }, + }, + }, + }), + stderr: '', + exitCode: 0, + }); + + const { ClaudeMultimodelBridgeService } = + await import('@main/services/runtime/ClaudeMultimodelBridgeService'); + const service = new ClaudeMultimodelBridgeService(); + + const provider = await service.getProviderStatus('/mock/agent_teams_orchestrator', 'codex'); + + expect(provider).toMatchObject({ + providerId: 'codex', + authenticated: true, + authMethod: 'api_key', + }); + expect(buildProviderAwareCliEnvMock).toHaveBeenCalledWith( + expect.objectContaining({ + providerId: 'codex', + allowStoredApiKeyDecryption: false, + allowedStoredApiKeyEnvVarNames: ['OPENAI_API_KEY'], + }) + ); + }); + it('falls back conservatively when the runtime omits extension capability metadata', async () => { execCliMock.mockResolvedValue({ stdout: JSON.stringify({ diff --git a/test/main/services/runtime/CliProviderModelAvailabilityService.test.ts b/test/main/services/runtime/CliProviderModelAvailabilityService.test.ts index 541cebb7..74b37ebb 100644 --- a/test/main/services/runtime/CliProviderModelAvailabilityService.test.ts +++ b/test/main/services/runtime/CliProviderModelAvailabilityService.test.ts @@ -11,6 +11,12 @@ vi.mock('@main/utils/childProcess', () => ({ vi.mock('@main/services/runtime/providerAwareCliEnv', () => ({ buildProviderAwareCliEnv: (...args: Parameters) => buildProviderAwareCliEnvMock(...args), + getProviderStatusStoredCredentialAllowlist: (providerId?: string) => + providerId === 'anthropic' + ? ['ANTHROPIC_AUTH_TOKEN'] + : providerId === 'codex' + ? ['OPENAI_API_KEY'] + : undefined, })); import { @@ -186,4 +192,25 @@ describe('CliProviderModelAvailabilityService', () => { ); }); }); + + it('allows stored Codex API-key access for model probes', async () => { + buildProviderAwareCliEnvMock.mockResolvedValue({ + env: { HOME: '/Users/tester' }, + connectionIssues: {}, + }); + execCliMock.mockResolvedValue({ stdout: 'PONG', stderr: '' }); + + const service = new CliProviderModelAvailabilityService(); + service.getSnapshot(createContext(['gpt-5.4'])); + + await vi.waitFor(() => { + expect(buildProviderAwareCliEnvMock).toHaveBeenCalledWith( + expect.objectContaining({ + providerId: 'codex', + allowStoredApiKeyDecryption: false, + allowedStoredApiKeyEnvVarNames: ['OPENAI_API_KEY'], + }) + ); + }); + }); }); diff --git a/test/main/services/runtime/ProviderConnectionService.test.ts b/test/main/services/runtime/ProviderConnectionService.test.ts index 2edded8e..3060ac23 100644 --- a/test/main/services/runtime/ProviderConnectionService.test.ts +++ b/test/main/services/runtime/ProviderConnectionService.test.ts @@ -1200,6 +1200,115 @@ describe('ProviderConnectionService', () => { expect(result.CODEX_API_KEY).toBe('openai-stored-key'); }); + it('mirrors a stored Codex OpenAI key when metadata-only status checks allow it', async () => { + const lookupPreferred = vi.fn().mockResolvedValue({ + envVarName: 'OPENAI_API_KEY', + value: 'openai-stored-key', + }); + const hasPreferred = vi.fn().mockResolvedValue(true); + const { ProviderConnectionService } = + await import('@main/services/runtime/ProviderConnectionService'); + + const service = new ProviderConnectionService( + { + hasPreferred, + lookupPreferred, + } as never, + { + getConfig: () => createConfig('auto'), + } as never + ); + + const result = await service.applyConfiguredConnectionEnv({}, 'codex', undefined, { + allowStoredApiKeyDecryption: false, + allowedStoredApiKeyEnvVarNames: ['OPENAI_API_KEY'], + }); + + expect(hasPreferred).toHaveBeenCalledWith('OPENAI_API_KEY'); + expect(lookupPreferred).toHaveBeenCalledWith('OPENAI_API_KEY'); + expect(result.OPENAI_API_KEY).toBe('openai-stored-key'); + expect(result.CODEX_API_KEY).toBe('openai-stored-key'); + }); + + it('does not mirror a stored Codex OpenAI key during metadata-only checks without allowlist', async () => { + const lookupPreferred = vi.fn().mockResolvedValue({ + envVarName: 'OPENAI_API_KEY', + value: 'openai-stored-key', + }); + const hasPreferred = vi.fn().mockResolvedValue(true); + const { ProviderConnectionService } = + await import('@main/services/runtime/ProviderConnectionService'); + + const service = new ProviderConnectionService( + { + hasPreferred, + lookupPreferred, + } as never, + { + getConfig: () => createConfig('auto'), + } as never + ); + + const result = await service.applyConfiguredConnectionEnv({}, 'codex', undefined, { + allowStoredApiKeyDecryption: false, + }); + + expect(hasPreferred).toHaveBeenCalledWith('OPENAI_API_KEY'); + expect(lookupPreferred).not.toHaveBeenCalled(); + expect(result.OPENAI_API_KEY).toBeUndefined(); + expect(result.CODEX_API_KEY).toBeUndefined(); + }); + + it('applies aggregate metadata-only allowlist without decrypting unrelated provider keys', async () => { + const lookupPreferred = vi.fn(async (envVarName: string) => { + if (envVarName === 'ANTHROPIC_AUTH_TOKEN') { + return { envVarName, value: 'anthropic-compatible-token' }; + } + if (envVarName === 'OPENAI_API_KEY') { + return { envVarName, value: 'openai-stored-key' }; + } + if (envVarName === 'GEMINI_API_KEY') { + return { envVarName, value: 'gemini-stored-key' }; + } + return null; + }); + const hasPreferred = vi.fn(async (envVarName: string) => envVarName === 'OPENAI_API_KEY'); + const { ProviderConnectionService } = + await import('@main/services/runtime/ProviderConnectionService'); + + const service = new ProviderConnectionService( + { + hasPreferred, + lookupPreferred, + } as never, + { + getConfig: () => + createConfig('auto', { + enabled: true, + baseUrl: 'http://localhost:1234', + }), + } as never + ); + + const result = await service.applyAllConfiguredConnectionEnv( + {}, + { + allowStoredApiKeyDecryption: false, + allowedStoredApiKeyEnvVarNames: ['ANTHROPIC_AUTH_TOKEN', 'OPENAI_API_KEY'], + } + ); + + expect(result.ANTHROPIC_BASE_URL).toBe('http://localhost:1234'); + expect(result.ANTHROPIC_AUTH_TOKEN).toBe('anthropic-compatible-token'); + expect(result.OPENAI_API_KEY).toBe('openai-stored-key'); + expect(result.CODEX_API_KEY).toBe('openai-stored-key'); + expect(result.GEMINI_API_KEY).toBeUndefined(); + expect(lookupPreferred).toHaveBeenCalledWith('ANTHROPIC_AUTH_TOKEN'); + expect(lookupPreferred).toHaveBeenCalledWith('OPENAI_API_KEY'); + expect(lookupPreferred).not.toHaveBeenCalledWith('GEMINI_API_KEY'); + expect(lookupPreferred).not.toHaveBeenCalledWith('ANTHROPIC_API_KEY'); + }); + it('keeps ambient OpenAI credentials for native Codex launches', async () => { const { ProviderConnectionService } = await import('@main/services/runtime/ProviderConnectionService'); diff --git a/test/main/services/runtime/providerAwareCliEnv.test.ts b/test/main/services/runtime/providerAwareCliEnv.test.ts index 7d4c3265..13870574 100644 --- a/test/main/services/runtime/providerAwareCliEnv.test.ts +++ b/test/main/services/runtime/providerAwareCliEnv.test.ts @@ -107,6 +107,25 @@ describe('buildProviderAwareCliEnv', () => { }); }); + it('returns narrow provider status stored credential allowlists', async () => { + const { + getAggregateProviderStatusStoredCredentialAllowlist, + getProviderStatusStoredCredentialAllowlist, + } = await import('../../../../src/main/services/runtime/providerAwareCliEnv'); + + expect(getProviderStatusStoredCredentialAllowlist('anthropic')).toEqual([ + 'ANTHROPIC_AUTH_TOKEN', + ]); + expect(getProviderStatusStoredCredentialAllowlist('codex')).toEqual(['OPENAI_API_KEY']); + expect(getProviderStatusStoredCredentialAllowlist('gemini')).toBeUndefined(); + expect(getProviderStatusStoredCredentialAllowlist('opencode')).toBeUndefined(); + expect(getProviderStatusStoredCredentialAllowlist(undefined)).toBeUndefined(); + expect(getAggregateProviderStatusStoredCredentialAllowlist()).toEqual([ + 'ANTHROPIC_AUTH_TOKEN', + 'OPENAI_API_KEY', + ]); + }); + it('builds provider-pinned CLI env and returns provider-specific issues', async () => { getConfiguredConnectionIssuesMock.mockResolvedValue({ anthropic: 'missing key', @@ -234,6 +253,21 @@ describe('buildProviderAwareCliEnv', () => { expect(applyAllConfiguredConnectionEnvMock).not.toHaveBeenCalled(); }); + it('passes a stored API key decrypt allowlist through shared env building', async () => { + const { buildProviderAwareCliEnv } = + await import('../../../../src/main/services/runtime/providerAwareCliEnv'); + await buildProviderAwareCliEnv({ + allowStoredApiKeyDecryption: false, + allowedStoredApiKeyEnvVarNames: ['ANTHROPIC_AUTH_TOKEN', 'OPENAI_API_KEY'], + }); + + expect(applyAllConfiguredConnectionEnvMock).toHaveBeenCalledWith(expect.any(Object), { + allowStoredApiKeyDecryption: false, + allowedStoredApiKeyEnvVarNames: ['ANTHROPIC_AUTH_TOKEN', 'OPENAI_API_KEY'], + }); + expect(applyConfiguredConnectionEnvMock).not.toHaveBeenCalled(); + }); + it('builds shared env for generic CLI launches when no provider is specified', async () => { const { buildProviderAwareCliEnv } = await import('../../../../src/main/services/runtime/providerAwareCliEnv'); From 371bf948c290075cbcef0baba4bcfd3eec14ddd2 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 28 May 2026 18:36:34 +0300 Subject: [PATCH 03/48] fix(i18n): cover missing ui translations --- .../components/hero/CyberHeroFeatureStrip.vue | 4 +- landing/components/hero/CyberHeroRobot.vue | 4 +- .../components/hero/CyberHeroVideoFrame.vue | 9 +- landing/components/layout/AppFooter.vue | 2 +- landing/components/layout/AppHeader.vue | 15 ++- .../components/sections/DownloadSection.vue | 2 +- landing/components/sections/HeroSection.vue | 16 +-- .../sections/ScreenshotsSection.vue | 4 +- landing/components/ui/HeroDemo.vue | 51 +++++---- landing/components/ui/HeroDemoVideo.vue | 10 +- landing/locales/ar.json | 54 +++++++++- landing/locales/bn.json | 54 +++++++++- landing/locales/de.json | 54 +++++++++- landing/locales/en.json | 54 +++++++++- landing/locales/es.json | 54 +++++++++- landing/locales/fr.json | 54 +++++++++- landing/locales/hi.json | 54 +++++++++- landing/locales/id.json | 54 +++++++++- landing/locales/ja.json | 54 +++++++++- landing/locales/ko.json | 54 +++++++++- landing/locales/pt.json | 54 +++++++++- landing/locales/ru.json | 54 +++++++++- landing/locales/ur.json | 54 +++++++++- landing/locales/zh.json | 54 +++++++++- .../renderer/adapters/TeamGraphAdapter.ts | 20 ++-- .../renderer/hooks/useTeamGraphAdapter.ts | 13 ++- .../renderer/ui/GraphBlockingEdgePopover.tsx | 54 +++++++--- .../renderer/ui/GraphMemberLogPreviewHud.tsx | 94 ++++++++++++---- .../renderer/locales/ar/extensions.json | 6 +- .../renderer/locales/ar/settings.json | 19 +++- .../renderer/locales/ar/team.json | 76 +++++++++++-- .../renderer/locales/bn/extensions.json | 6 +- .../renderer/locales/bn/settings.json | 19 +++- .../renderer/locales/bn/team.json | 76 +++++++++++-- .../renderer/locales/de/extensions.json | 6 +- .../renderer/locales/de/settings.json | 19 +++- .../renderer/locales/de/team.json | 76 +++++++++++-- .../renderer/locales/en/extensions.json | 6 +- .../renderer/locales/en/settings.json | 3 +- .../renderer/locales/en/team.json | 76 +++++++++++-- .../renderer/locales/es/extensions.json | 6 +- .../renderer/locales/es/settings.json | 19 +++- .../renderer/locales/es/team.json | 76 +++++++++++-- .../renderer/locales/fr/extensions.json | 6 +- .../renderer/locales/fr/settings.json | 19 +++- .../renderer/locales/fr/team.json | 76 +++++++++++-- .../renderer/locales/hi/extensions.json | 6 +- .../renderer/locales/hi/settings.json | 19 +++- .../renderer/locales/hi/team.json | 76 +++++++++++-- .../renderer/locales/id/extensions.json | 6 +- .../renderer/locales/id/settings.json | 19 +++- .../renderer/locales/id/team.json | 76 +++++++++++-- .../renderer/locales/ja/extensions.json | 6 +- .../renderer/locales/ja/settings.json | 19 +++- .../renderer/locales/ja/team.json | 76 +++++++++++-- .../renderer/locales/ko/extensions.json | 6 +- .../renderer/locales/ko/settings.json | 19 +++- .../renderer/locales/ko/team.json | 76 +++++++++++-- .../renderer/locales/pt/extensions.json | 6 +- .../renderer/locales/pt/settings.json | 19 +++- .../renderer/locales/pt/team.json | 76 +++++++++++-- .../renderer/locales/ru/extensions.json | 6 +- .../renderer/locales/ru/settings.json | 3 +- .../renderer/locales/ru/team.json | 76 +++++++++++-- .../renderer/locales/ur/extensions.json | 6 +- .../renderer/locales/ur/settings.json | 19 +++- .../renderer/locales/ur/team.json | 76 +++++++++++-- .../renderer/locales/zh/extensions.json | 6 +- .../renderer/locales/zh/settings.json | 19 +++- .../renderer/locales/zh/team.json | 76 +++++++++++-- .../localization/renderer/resources.d.ts | 59 ++++++++++ .../ui/MemberRuntimeProcessLogsPanel.tsx | 27 +++-- .../adapters/RunningTeamsSectionAdapter.ts | 36 +++++-- .../renderer/hooks/useRunningTeamsSection.ts | 12 ++- .../components/dashboard/CliStatusBanner.tsx | 12 ++- .../extensions/apikeys/ApiKeyCard.tsx | 8 +- .../ProviderRuntimeBackendSelector.tsx | 101 ++++++++++++++++-- .../runtime/ProviderRuntimeSettingsDialog.tsx | 8 +- .../settings/sections/CliStatusSection.tsx | 12 ++- .../components/team/ClaudeLogsSection.tsx | 9 +- .../components/team/TeamDetailView.tsx | 12 ++- .../team/activity/ActiveTasksBlock.tsx | 11 +- .../components/team/activity/ActivityItem.tsx | 14 +-- .../team/dialogs/CodexReconnectPrompt.tsx | 6 +- .../members/MemberLaunchDiagnosticsButton.tsx | 4 +- .../team/members/MemberRoleEditor.tsx | 8 +- .../components/team/teamLogSources.ts | 16 ++- 87 files changed, 2362 insertions(+), 429 deletions(-) diff --git a/landing/components/hero/CyberHeroFeatureStrip.vue b/landing/components/hero/CyberHeroFeatureStrip.vue index 2e7eab41..7f1dc4a4 100644 --- a/landing/components/hero/CyberHeroFeatureStrip.vue +++ b/landing/components/hero/CyberHeroFeatureStrip.vue @@ -20,10 +20,10 @@ const props = defineProps<{ reducedMotion?: boolean; }>(); -const { locale } = useI18n(); +const { t, locale } = useI18n(); const localizedHeroFeatureRail = computed(() => getLocalizedHeroFeatureRail(locale.value)); const localizedHeroReviewerFeatureCard = computed(() => getLocalizedHeroReviewerFeatureCard(locale.value)); -const statusLabel = computed(() => locale.value === "ru" ? "Статус:" : "Status:"); +const statusLabel = computed(() => t("common.statusLabel")); const icons = [ mdiRobotOutline, diff --git a/landing/components/hero/CyberHeroRobot.vue b/landing/components/hero/CyberHeroRobot.vue index d6547404..0490bd18 100644 --- a/landing/components/hero/CyberHeroRobot.vue +++ b/landing/components/hero/CyberHeroRobot.vue @@ -7,12 +7,12 @@ const props = defineProps<{ activeReceiver?: HeroAgentRole | "video" | null; }>(); -const { locale } = useI18n(); +const { t } = useI18n(); const isSender = computed(() => props.activeSender === props.agent.id); const isReceiver = computed(() => props.activeReceiver === props.agent.id); const imageLoading = computed(() => (props.agent.priority ? "eager" : "lazy")); const imageFetchPriority = computed(() => (props.agent.priority ? "high" : "auto")); -const statusLabel = computed(() => locale.value === "ru" ? "Статус:" : "Status:"); +const statusLabel = computed(() => t("common.statusLabel")); const rootStyle = computed(() => ({ "--agent-x": String(props.agent.desktop.x), diff --git a/landing/components/hero/CyberHeroVideoFrame.vue b/landing/components/hero/CyberHeroVideoFrame.vue index 89724f91..68cbb030 100644 --- a/landing/components/hero/CyberHeroVideoFrame.vue +++ b/landing/components/hero/CyberHeroVideoFrame.vue @@ -1,6 +1,5 @@