From 869a4432557c474d047762eb37552647d70e8fc5 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 9 May 2026 04:41:31 +0300 Subject: [PATCH] fix(attachments): persist opencode missing payload failures --- docs/team-management/agent-attachments.md | 10 +- .../services/team/TeamProvisioningService.ts | 57 +++++ .../team/MixedProviderTeamLaunch.live.test.ts | 210 +++++++++++++++++- .../team/TeamProvisioningServiceRelay.test.ts | 11 +- 4 files changed, 271 insertions(+), 17 deletions(-) diff --git a/docs/team-management/agent-attachments.md b/docs/team-management/agent-attachments.md index ad01df30..fe5b702f 100644 --- a/docs/team-management/agent-attachments.md +++ b/docs/team-management/agent-attachments.md @@ -100,11 +100,15 @@ Latest local verification: 2026-05-09. | --- | --- | --- | --- | | Claude visual transport | `claude-subscription-streaming` | passed | Real Claude CLI `stream-json` run answered `red` for generated PNG. | | Codex visual transport | `codex-native-gpt-5-4-mini` | passed | Real Codex native `--image` run answered `red` for generated PNG. | -| OpenCode OpenAI visual transport | `opencode-openai-gpt-5-4-mini` | passed | Real OpenCode file attachment run answered `red` for generated PNG. | +| OpenCode OpenAI visual transport | `opencode-openai-gpt-5-4-mini` | blocked locally | The current local OpenCode OpenAI OAuth token is invalidated. The attachment path reached provider execution, but provider auth returned 401. | +| OpenRouter Kimi visual transport | `opencode-openrouter-kimi-k2-6` | passed | Real OpenCode file attachment run through OpenRouter answered `red` for generated PNG. | +| OpenRouter GLM vision transport | `opencode-openrouter-glm-4-5v` | passed | Real OpenCode file attachment run through OpenRouter answered `red` for generated PNG. | +| OpenRouter GLM non-vision guard | `opencode-openrouter-glm-5-1-negative` | passed as guard | Model responded that it cannot process images. The app policy blocks this model for image attachments. | | CLI process launch | `scripts/prove-agent-cli-launch.mjs` | passed | Real `opencode`, `codex`, and `claude` binaries launched through `execCli` and `spawnCli`. | | OpenCode team provisioning | `scripts/prove-opencode-team-provisioning.mjs` with `OPENCODE_E2E_MODEL=openai/gpt-5.4-mini` | passed | Real pure OpenCode team created through `TeamProvisioningService`, live members verified, then stopped. | -| OpenRouter Kimi/GLM visual transports | OpenRouter smoke cases | skipped | `OPENROUTER_API_KEY` was not present in the shell environment. | -| Mixed Anthropic + Codex + OpenCode team launch | `MixedProviderTeamLaunch.live.test.ts` | not run | Requires `ANTHROPIC_API_KEY` and real app credentials in env. Do not paste secrets into commands or logs. | +| Mixed Anthropic + Codex + OpenCode team launch | `MixedProviderTeamLaunch.live.test.ts` | passed | Real mixed team launch passed with Claude subscription auth, Codex subscription auth, and OpenCode. | + +`--all` can return non-zero when it includes a locally invalid provider auth case or an unsupported-model negative case. Treat the per-case rows above as the release signal. ## Release checklist diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 0c9d0e30..8d4431bd 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -20471,11 +20471,68 @@ export class TeamProvisioningService { message, }); if (!attachmentPayloads.ok) { + let failedRecord: OpenCodePromptDeliveryLedgerRecord | null = null; + try { + const markedAt = nowIso(); + const pendingRecord = + existingRecord ?? + (await promptLedger.ensurePending({ + teamName, + memberName: memberIdentity.canonicalMemberName, + laneId: memberIdentity.laneId, + runId: await this.resolveCurrentOpenCodeRuntimeRunId( + teamName, + memberIdentity.laneId + ), + inboxMessageId: message.messageId, + inboxTimestamp: message.timestamp, + source: effectiveSource, + replyRecipient: effectiveReplyRecipient, + actionMode: effectiveActionMode ?? null, + messageKind: message.messageKind ?? null, + taskRefs: effectiveTaskRefs, + payloadHash: hashOpenCodePromptDeliveryPayload({ + text: message.text, + replyRecipient: effectiveReplyRecipient, + actionMode: effectiveActionMode ?? null, + taskRefs: effectiveTaskRefs, + attachments: message.attachments, + source: effectiveSource, + }), + now: markedAt, + })); + if (pendingRecord.createdAt === markedAt) { + this.logOpenCodePromptDeliveryEvent( + 'opencode_prompt_delivery_ledger_created', + pendingRecord + ); + } + failedRecord = await promptLedger.markFailedTerminal({ + id: pendingRecord.id, + reason: attachmentPayloads.reason, + diagnostics: attachmentPayloads.diagnostics, + failedAt: nowIso(), + }); + this.logOpenCodePromptDeliveryEvent( + 'opencode_prompt_delivery_response_observed', + failedRecord, + { attachmentPayloadUnavailable: true } + ); + } catch (error) { + const diagnostic = `opencode_inbox_attachment_terminal_ledger_failed: ${getErrorMessage( + error + )}`; + result.diagnostics = [...(result.diagnostics ?? []), diagnostic]; + } result.failed += 1; result.diagnostics = [...(result.diagnostics ?? []), ...attachmentPayloads.diagnostics]; result.lastDelivery = { delivered: false, reason: attachmentPayloads.reason, + accepted: false, + ledgerStatus: failedRecord?.status, + ledgerRecordId: failedRecord?.id, + laneId: memberIdentity.laneId, diagnostics: attachmentPayloads.diagnostics, }; break; diff --git a/test/main/services/team/MixedProviderTeamLaunch.live.test.ts b/test/main/services/team/MixedProviderTeamLaunch.live.test.ts index aacd1001..d6515e2b 100644 --- a/test/main/services/team/MixedProviderTeamLaunch.live.test.ts +++ b/test/main/services/team/MixedProviderTeamLaunch.live.test.ts @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { readOpenCodeRuntimeLaneIndex } from '../../../../src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader'; import { + getTasksBasePath, getTeamsBasePath, setClaudeBasePathOverride, } from '../../../../src/main/utils/pathDecoder'; @@ -30,7 +31,7 @@ const liveDescribe = process.env.MIXED_PROVIDER_TEAM_LIVE === '1' && process.env.OPENCODE_E2E === '1' && process.env.OPENCODE_E2E_USE_REAL_APP_CREDENTIALS === '1' && - Boolean(process.env.ANTHROPIC_API_KEY?.trim()) + (Boolean(process.env.ANTHROPIC_API_KEY?.trim()) || shouldUseAnthropicSubscriptionAuth()) ? describe : describe.skip; @@ -49,6 +50,9 @@ liveDescribe('Mixed provider team launch live e2e', () => { let previousCodexHome: string | undefined; let previousHome: string | undefined; let previousUserProfile: string | undefined; + let previousAnthropicApiKey: string | undefined; + let previousAnthropicAuthToken: string | undefined; + let previousClaudeJsonConfig: string | null | undefined; let previousNodeEnv: string | undefined; let previousDisableAppBootstrap: string | undefined; let previousDisableRuntimeBootstrap: string | undefined; @@ -58,13 +62,19 @@ liveDescribe('Mixed provider team launch live e2e', () => { let providerConnectionService: { setCodexAccountFeature(feature: { getSnapshot(): Promise } | null): void; } | null; + let usingAnthropicSubscriptionAuth = false; beforeEach(async () => { + usingAnthropicSubscriptionAuth = shouldUseAnthropicSubscriptionAuth(); tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mixed-provider-team-live-')); - tempClaudeRoot = path.join(tempDir, '.claude'); + tempClaudeRoot = usingAnthropicSubscriptionAuth + ? os.userInfo().homedir + : path.join(tempDir, '.claude'); tempHome = path.join(tempDir, 'home'); projectPath = path.join(tempDir, 'project'); - await fs.mkdir(tempClaudeRoot, { recursive: true }); + if (!usingAnthropicSubscriptionAuth) { + await fs.mkdir(tempClaudeRoot, { recursive: true }); + } await fs.mkdir(tempHome, { recursive: true }); await fs.mkdir(projectPath, { recursive: true }); await fs.writeFile( @@ -72,14 +82,28 @@ liveDescribe('Mixed provider team launch live e2e', () => { '# Mixed provider team live e2e\n\nThis project is intentionally tiny.\n', 'utf8' ); - await writeTrustedClaudeConfig(tempClaudeRoot, projectPath); - setClaudeBasePathOverride(tempClaudeRoot); + if (usingAnthropicSubscriptionAuth) { + // Claude subscription/OAuth is tied to the user's normal Claude config/keychain namespace. + // Do not point CLAUDE_CONFIG_DIR at an isolated temp dir in this mode or the live smoke + // will test a different auth namespace than the app/runtime actually uses. + setClaudeBasePathOverride(null); + previousClaudeJsonConfig = await upsertTrustedClaudeProjectConfig( + tempClaudeRoot, + projectPath + ); + } else { + await writeTrustedClaudeConfig(tempClaudeRoot, projectPath); + setClaudeBasePathOverride(tempClaudeRoot); + previousClaudeJsonConfig = undefined; + } previousCliPath = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH; previousCliFlavor = process.env.CLAUDE_TEAM_CLI_FLAVOR; previousCodexHome = process.env.CODEX_HOME; previousHome = process.env.HOME; previousUserProfile = process.env.USERPROFILE; + previousAnthropicApiKey = process.env.ANTHROPIC_API_KEY; + previousAnthropicAuthToken = process.env.ANTHROPIC_AUTH_TOKEN; previousNodeEnv = process.env.NODE_ENV; previousDisableAppBootstrap = process.env.CLAUDE_APP_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP; previousDisableRuntimeBootstrap = process.env.CLAUDE_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP; @@ -88,8 +112,12 @@ liveDescribe('Mixed provider team launch live e2e', () => { process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI; process.env.CLAUDE_TEAM_CLI_FLAVOR = 'agent_teams_orchestrator'; process.env.CODEX_HOME = resolveConnectedCodexHome(previousCodexHome); - process.env.HOME = tempHome; - process.env.USERPROFILE = tempHome; + process.env.HOME = usingAnthropicSubscriptionAuth ? os.userInfo().homedir : tempHome; + process.env.USERPROFILE = usingAnthropicSubscriptionAuth ? os.userInfo().homedir : tempHome; + if (usingAnthropicSubscriptionAuth) { + delete process.env.ANTHROPIC_API_KEY; + delete process.env.ANTHROPIC_AUTH_TOKEN; + } process.env.NODE_ENV = 'production'; delete process.env.CLAUDE_APP_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP; delete process.env.CLAUDE_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP; @@ -106,11 +134,18 @@ liveDescribe('Mixed provider team launch live e2e', () => { await harness.svc.stopTeam(teamName).catch(() => undefined); await waitForOpenCodeLanesStopped(teamName, 90_000).catch(() => undefined); } + if (!keepProcesses && usingAnthropicSubscriptionAuth && teamName) { + await fs.rm(path.join(getTeamsBasePath(), teamName), { recursive: true, force: true }); + await fs.rm(path.join(getTasksBasePath(), teamName), { recursive: true, force: true }); + } providerConnectionService?.setCodexAccountFeature(null); await codexAccountFeature?.dispose().catch(() => undefined); if (!keepProcesses) { await harness?.dispose().catch(() => undefined); } + if (usingAnthropicSubscriptionAuth && previousClaudeJsonConfig !== undefined) { + await restoreClaudeJsonConfig(tempClaudeRoot, previousClaudeJsonConfig); + } setClaudeBasePathOverride(null); restoreEnv('CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH', previousCliPath); @@ -118,6 +153,8 @@ liveDescribe('Mixed provider team launch live e2e', () => { restoreEnv('CODEX_HOME', previousCodexHome); restoreEnv('HOME', previousHome); restoreEnv('USERPROFILE', previousUserProfile); + restoreEnv('ANTHROPIC_API_KEY', previousAnthropicApiKey); + restoreEnv('ANTHROPIC_AUTH_TOKEN', previousAnthropicAuthToken); restoreEnv('NODE_ENV', previousNodeEnv); restoreEnv('CLAUDE_APP_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP', previousDisableAppBootstrap); restoreEnv('CLAUDE_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP', previousDisableRuntimeBootstrap); @@ -135,7 +172,7 @@ liveDescribe('Mixed provider team launch live e2e', () => { const orchestratorCli = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim(); expect(orchestratorCli).toBeTruthy(); await assertExecutable(orchestratorCli!); - await assertExecutable(path.join(process.env.CODEX_HOME!, 'auth.json')); + await assertCodexSubscriptionAuthAvailable(process.env.CODEX_HOME!); const anthropicModel = process.env.MIXED_PROVIDER_TEAM_ANTHROPIC_MODEL?.trim() || DEFAULT_ANTHROPIC_MODEL; @@ -278,10 +315,104 @@ function restoreEnv(name: string, previous: string | undefined): void { } } +function shouldUseAnthropicSubscriptionAuth(): boolean { + const mode = process.env.MIXED_PROVIDER_TEAM_ANTHROPIC_AUTH?.trim().toLowerCase(); + return mode === 'subscription' || mode === 'oauth'; +} + async function assertExecutable(filePath: string): Promise { await fs.access(filePath, fsConstants.R_OK); } +async function assertCodexSubscriptionAuthAvailable(codexHome: string): Promise { + const legacyAuthPath = path.join(codexHome, 'auth.json'); + if (await pathReadable(legacyAuthPath)) { + const legacyAuth = await readJsonObject(legacyAuthPath); + if (isCodexChatGptSubscriptionAuth(legacyAuth)) { + return; + } + } + + const accountsDir = path.join(codexHome, 'accounts'); + const registryPath = path.join(accountsDir, 'registry.json'); + const registry = await readJsonObject(registryPath).catch(() => null); + const activeAccountId = + readStringProperty(registry, 'active_account_id') ?? + readStringProperty(registry, 'activeAccountId') ?? + readStringProperty(registry, 'current_account_id') ?? + readStringProperty(registry, 'currentAccountId'); + + const candidates = new Set(); + if (activeAccountId) { + candidates.add(path.join(accountsDir, `${activeAccountId}.auth.json`)); + candidates.add(path.join(accountsDir, activeAccountId)); + } + const entries = await fs.readdir(accountsDir).catch(() => []); + for (const entry of entries) { + if (entry.endsWith('.auth.json')) { + candidates.add(path.join(accountsDir, entry)); + } + } + + for (const candidate of candidates) { + const auth = await readJsonObject(candidate).catch(() => null); + if (isCodexChatGptSubscriptionAuth(auth)) { + return; + } + } + + throw new Error( + `Codex subscription auth not found in ${codexHome}. Expected auth.json or accounts/*.auth.json with a refresh token.` + ); +} + +async function pathReadable(filePath: string): Promise { + try { + await fs.access(filePath, fsConstants.R_OK); + return true; + } catch { + return false; + } +} + +async function readJsonObject(filePath: string): Promise> { + const parsed = JSON.parse(await fs.readFile(filePath, 'utf8')); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`Expected JSON object in ${filePath}`); + } + return parsed as Record; +} + +function readStringProperty(source: Record | null, key: string): string | null { + const value = source?.[key]; + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +function hasCodexRefreshToken(source: Record | null): boolean { + const direct = readStringProperty(source, 'refresh_token'); + const tokens = source?.tokens; + const nested = + tokens && typeof tokens === 'object' && !Array.isArray(tokens) + ? readStringProperty(tokens as Record, 'refresh_token') + : null; + return Boolean(direct || nested); +} + +function isCodexChatGptSubscriptionAuth(source: Record | null): boolean { + if (!source || !hasCodexRefreshToken(source)) { + return false; + } + const authMode = + readStringProperty(source, 'auth_mode') ?? + readStringProperty(source, 'authMode') ?? + readStringProperty(source, 'mode'); + if (!authMode) { + // New account files may omit an explicit mode. A refresh token is the stable OAuth signal. + return true; + } + return authMode.toLowerCase() === 'chatgpt'; +} + async function writeTrustedClaudeConfig(configDir: string, projectPath: string): Promise { const canonicalProjectPath = await fs.realpath(projectPath).catch(() => projectPath); const normalizedProjectPath = path.normalize(canonicalProjectPath).replace(/\\/g, '/'); @@ -309,6 +440,69 @@ async function writeTrustedClaudeConfig(configDir: string, projectPath: 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 resolveConnectedCodexHome(previousCodexHome: string | undefined): string { const explicit = process.env.MIXED_PROVIDER_TEAM_CODEX_HOME?.trim(); if (explicit) { diff --git a/test/main/services/team/TeamProvisioningServiceRelay.test.ts b/test/main/services/team/TeamProvisioningServiceRelay.test.ts index d79e30c5..0a20d8a3 100644 --- a/test/main/services/team/TeamProvisioningServiceRelay.test.ts +++ b/test/main/services/team/TeamProvisioningServiceRelay.test.ts @@ -2249,7 +2249,7 @@ Messages: expect(rows.map((row: { read?: boolean }) => row.read)).toEqual([false, true]); }); - it('fails OpenCode secondary rows with attachments terminally without text-only delivery', async () => { + it('fails OpenCode secondary rows with missing attachment payloads without text-only delivery', async () => { const service = new TeamProvisioningService(); const teamName = 'my-team'; hoisted.files.set( @@ -2313,6 +2313,7 @@ Messages: const deliverSpy = vi.spyOn(service, 'deliverOpenCodeMemberMessage'); const relay = await service.relayOpenCodeMemberInboxMessages(teamName, 'jack'); + const expectedReason = 'opencode_inbox_attachment_payload_unavailable: att-1'; expect(relay).toMatchObject({ relayed: 0, @@ -2321,20 +2322,18 @@ Messages: failed: 1, lastDelivery: { delivered: false, - reason: 'opencode_attachments_not_supported_for_secondary_runtime', + reason: expectedReason, }, }); expect(deliverSpy).not.toHaveBeenCalled(); - expect(vi.mocked(console.warn).mock.calls[0]?.join(' ')).toContain( - 'opencode_attachments_not_supported_for_secondary_runtime' - ); + expect(relay.diagnostics?.join('\n')).toContain(expectedReason); vi.mocked(console.warn).mockClear(); const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'); expect(rows[0].read).toBe(false); expect(records[0]).toMatchObject({ inboxMessageId: 'opencode-attachment-1', status: 'failed_terminal', - lastReason: 'opencode_attachments_not_supported_for_secondary_runtime', + lastReason: expectedReason, }); });