From eda62c3ab36f78365ee209e5616a532896a0897e Mon Sep 17 00:00:00 2001 From: 777genius Date: Sun, 3 May 2026 23:51:27 +0300 Subject: [PATCH] fix(team): stabilize mixed provider runtime auth --- docs/ideas/codeboarding-integration.md | 273 ++++++++++ .../runtime/ProviderConnectionService.ts | 14 + .../runtime/anthropicTeamApiKeyHelper.ts | 353 +++++++++++++ src/main/services/runtime/cliSettingsArgs.ts | 14 +- .../runtime/teamRuntimeSettingsBundle.ts | 160 ++++++ .../services/team/TeamProvisioningService.ts | 493 ++++++++++++++++-- .../runtime/ProviderConnectionService.test.ts | 61 +++ .../runtime/anthropicTeamApiKeyHelper.test.ts | 90 ++++ .../runtime/teamRuntimeSettingsBundle.test.ts | 74 +++ .../team/AnthropicRuntimeMemory.live.test.ts | 10 +- .../team/MixedProviderTeamLaunch.live.test.ts | 414 +++++++++++++++ .../team/OpenCodeMixedRecovery.live.test.ts | 21 +- .../OpenCodeTeamProvisioning.live.test.ts | 1 + .../TeamProvisioningServicePrepare.test.ts | 90 +++- 14 files changed, 2012 insertions(+), 56 deletions(-) create mode 100644 docs/ideas/codeboarding-integration.md create mode 100644 src/main/services/runtime/anthropicTeamApiKeyHelper.ts create mode 100644 src/main/services/runtime/teamRuntimeSettingsBundle.ts create mode 100644 test/main/services/runtime/anthropicTeamApiKeyHelper.test.ts create mode 100644 test/main/services/runtime/teamRuntimeSettingsBundle.test.ts create mode 100644 test/main/services/team/MixedProviderTeamLaunch.live.test.ts diff --git a/docs/ideas/codeboarding-integration.md b/docs/ideas/codeboarding-integration.md new file mode 100644 index 00000000..9de4df84 --- /dev/null +++ b/docs/ideas/codeboarding-integration.md @@ -0,0 +1,273 @@ +# CodeBoarding Integration Idea + +Дата проверки: 2026-05-03. + +## Короткий вывод + +CodeBoarding полезен для Agent Teams как опциональная визуализация архитектурного влияния агентских изменений. Он не выглядит как готовый embeddable real-time daemon для нашего Electron UI, но у него есть достаточная база для near real-time режима: + +- baseline анализ через `codeboarding full --local `; +- incremental анализ через `codeboarding incremental --local `; +- partial обновление компонента через `codeboarding partial --local --component-id `; +- выходные артефакты в `.codeboarding/`, включая `analysis.json`, Markdown и Mermaid; +- method/component change tracking в VS Code extension. + +Практичный продуктовый вариант: делаем CodeBoarding optional dependency, даём пользователю install/detect/setup в UI, запускаем full один раз, а дальше показываем live-ish overlay по изменениям агентов. Быструю подсветку делаем сами по git diff/task change ledger, а CodeBoarding incremental используем как более точный фоновый refresh. + +## Что проверено + +- GitHub repo: [CodeBoarding/CodeBoarding](https://github.com/CodeBoarding/CodeBoarding) +- Website: [codeboarding.org](https://www.codeboarding.org/) +- PyPI JSON: [pypi.org/pypi/codeboarding/json](https://pypi.org/pypi/codeboarding/json) +- Release: [v0.11.0](https://github.com/CodeBoarding/CodeBoarding/releases/tag/v0.11.0) +- VS Code Marketplace: [CodeBoarding extension](https://marketplace.visualstudio.com/items?itemName=Codeboarding.codeboarding) +- MCP repo: [CodeBoarding/CodeBoarding-MCP](https://github.com/CodeBoarding/CodeBoarding-MCP) + +На момент проверки: + +- Latest GitHub release: `v0.11.0`, published 2026-04-29. +- Latest PyPI version: `0.11.0`, requires Python `>=3.12,<3.14`. +- License: MIT. +- Repo активный: последний push был 2026-05-03. +- Основной стек CodeBoarding: Python CLI, static analysis, LSP, tree-sitter, LLM providers. +- Поддерживаемые языки из README/PyPI: Python, TypeScript, JavaScript, Java, Go, PHP, Rust, C#. +- LLM providers из README/PyPI: OpenAI, Anthropic, Google, Vercel AI Gateway, AWS Bedrock, Ollama, OpenRouter и другие. + +## Что CodeBoarding умеет + +Из README и CLI: + +- генерирует high-level architecture diagrams; +- генерирует deeper component diagrams; +- пишет Markdown документацию в `.codeboarding/`; +- пишет Mermaid output, который удобно показывать в нашем Markdown/Mermaid viewer; +- умеет incremental updates, когда есть предыдущий analysis; +- умеет partial update одного component id; +- для private repos использует `GITHUB_TOKEN`; +- конфиг LLM ключей хранит в `~/.codeboarding/config.toml`, но env vars имеют приоритет. + +Публичные команды CLI: + +```bash +codeboarding full --local /path/to/repo +codeboarding incremental --local /path/to/repo +codeboarding partial --local /path/to/repo --component-id "1.2" +``` + +Установка из README/PyPI: + +```bash +pipx install codeboarding --python python3.12 +codeboarding-setup +codeboarding full --local /path/to/repo +``` + +Важно: `codeboarding-setup` скачивает language server binaries в `~/.codeboarding/servers/`. Node.js/npm нужен для Python, TypeScript, JavaScript и PHP language servers; если Node/npm не найден, CodeBoarding может скачать pinned Node runtime в `~/.codeboarding/servers/nodeenv/`. + +## Real-time оценка + +В VS Code Marketplace заявлено: + +- `Realtime Component Change tracking` - можно видеть, в каких компонентах есть file edits. +- `0.11.0` - Git Commit Diff View, timeline slider, подсветка components/files/methods по recent commits. +- `0.11.0` - Faster Incremental Analysis, refresh переиспользует прошлые результаты и анализирует только затронутое. +- `0.10.0` - Method-Level Change Tracking. +- `0.10.0` - Real-time Method Updates. +- `0.10.2` - Smoother Real-time Updates. + +Но в open-source CLI я не нашёл отдельного публичного `watch`/daemon режима. В коде есть incremental pipeline, worktree diff, `incrementalDelta`, method-level statuses, comments про IDE/wrapper integration и snapshot target refs, но публичный CLI остаётся командным. + +Вывод: CodeBoarding позволяет сделать near real-time визуализацию, но real-time orchestration надо делать нам: + +1. watcher ловит изменения файлов от агента; +2. debounce, например 2-10 секунд; +3. быстрый overlay строится по git diff/task change ledger и текущему `.codeboarding/analysis.json`; +4. CodeBoarding incremental запускается фоном реже или на завершение task; +5. UI обновляет Mermaid/architecture map и affected components. + +## Что можно показать пользователю + +Хорошо подходит: + +- 🟢 новый файл попал в конкретный компонент; +- 🟡 метод или файл изменён внутри компонента; +- 🔴 файл/метод удалён; +- какие компоненты трогает конкретный агент; +- какие компоненты трогает конкретная task; +- архитектурный контекст рядом с code review; +- diff timeline по commits или task snapshots; +- Markdown/Mermaid docs прямо в нашем Project Editor/Review UI. + +Сложно или рискованно: + +- мгновенная перестройка диаграммы на каждый символ; +- точная визуализация rename/copy, потому что incremental pipeline сейчас может требовать full analysis для rename/copy; +- стабильная работа на очень больших репах без очереди, debounce и cancellation; +- full/incremental анализ без настроенного LLM provider; +- автоматическая установка Python 3.12/3.13 на всех OS без отдельного installer UX. + +## Варианты интеграции + +### 1. Optional CLI Runner + просмотр `.codeboarding/` + +🎯 9 🛡️ 8 🧠 4 +Примерно `250-450` строк. + +Суть: в Settings/Integrations добавляем CodeBoarding detect/install/run. Первый MVP только запускает `full`/`incremental`, показывает статус, открывает `.codeboarding/analysis.json` и Markdown/Mermaid в существующем viewer. + +Плюсы: + +- быстро проверить реальную пользу; +- почти не вмешивается в team/review lifecycle; +- опирается на уже существующие Markdown/Mermaid возможности; +- безопаснее, потому что dependency optional. + +Минусы: + +- это не live UX; +- пользователь сам интерпретирует изменения; +- нет красивой связки с задачами агентов. + +Когда выбирать: если хотим дешёвый probe перед большой фичей. + +### 2. Live-ish Overlay поверх baseline анализа + +🎯 9 🛡️ 8 🧠 5 +Примерно `900-1400` строк. + +Суть: CodeBoarding делает baseline `.codeboarding/analysis.json`. Дальше наш watcher/git status/task change ledger быстро мапит изменённые файлы на компоненты из baseline и подсвечивает affected components почти в реальном времени. CodeBoarding `incremental` запускается фоном по debounce, на завершение task или по кнопке refresh. + +Плюсы: + +- даёт пользователю ощущение real-time; +- не заставляет LLM работать на каждое маленькое изменение; +- хорошо ложится на агентские изменения и task review; +- можно показывать impact до завершения задачи. + +Минусы: + +- нужна собственная модель overlay state; +- baseline mapping может быть устаревшим до следующего incremental; +- для новых файлов компонент может определяться эвристикой до refresh. + +Когда выбирать: лучший первый продуктовый вариант. + +### 3. Architecture Review per Task + +🎯 8 🛡️ 7 🧠 8 +Примерно `1600-2500` строк. + +Суть: связываем CodeBoarding с review flow. Для каждой task показываем impacted components, changed methods, old/new architecture map, summary риска и ссылки на файлы. Можно добавить отдельную вкладку в task detail или review dialog. + +Плюсы: + +- максимальная ценность для Agent Teams; +- помогает ревьюить AI-generated changes не только по diff, но и по архитектурному влиянию; +- можно использовать как сильный selling point. + +Минусы: + +- крупная фича; +- нужны тесты на task-change mapping, IPC, persistence и UI; +- есть риск перегрузить review screen. + +Когда выбирать: после MVP и подтверждения, что карты реально помогают пользователям. + +## Варианты установки optional dependency + +### A. pipx install в user environment + +🎯 8 🛡️ 7 🧠 4 +Примерно `350-600` строк. + +UI проверяет `python3.12`/`python3.13`, `pipx`, `codeboarding`. Если нет, предлагает install через `pipx install codeboarding --python python3.12`, затем `codeboarding-setup`. + +Плюсы: соответствует README, изолированная среда, меньше конфликтов с системным Python. + +Минусы: надо отдельно вести UX для отсутствующего Python/pipx. + +### B. Скачать packaged binary из GitHub Release + +🎯 7 🛡️ 7 🧠 6 +Примерно `600-1000` строк. + +У CodeBoarding release `v0.11.0` содержит assets для macOS/Linux/Windows. Можно скачивать бинарь под OS, проверять sha256 asset и хранить в app-managed tools dir. + +Плюсы: меньше зависимости от Python/pipx у пользователя. + +Минусы: нужно аккуратно делать download, checksum, permissions, updates, notarization/security prompts. + +### C. Встроить Python package в наш app bundle + +🎯 4 🛡️ 5 🧠 9 +Примерно `1200-2200` строк. + +Пакуем CodeBoarding и Python runtime вместе с приложением. + +Плюсы: самый гладкий UX после установки. + +Минусы: тяжёлый bundle, OS-specific packaging, LSP binaries, security/update burden. Для optional feature это слишком дорого. + +Рекомендация: начать с A, потом рассмотреть B для packaged app. + +## Как это ложится на нашу архитектуру + +Так как фича пересекает main/preload/renderer и запускает внешний инструмент, её лучше делать по `docs/FEATURE_ARCHITECTURE_STANDARD.md`: + +```text +src/features/codeboarding/ + contracts/ + core/ + main/ + adapters/ + infrastructure/ + preload/ + renderer/ +``` + +Основные части: + +- contracts: DTO для status, install state, run request, run result, affected components; +- core: правила выбора режима `full`/`incremental`, debounce policy, overlay merge policy; +- main/infrastructure: binary detection, installer, command runner, output parser, `.codeboarding` reader; +- main/adapters/input: IPC handlers; +- preload: bridge; +- renderer: settings panel, project action, architecture map panel, task/review badges. + +Надо использовать path validation и не давать CodeBoarding работать вне выбранного project root. + +## MVP flow + +1. Пользователь открывает project. +2. UI показывает “Enable CodeBoarding architecture map”. +3. App проверяет наличие `codeboarding`. +4. Если нет, предлагает install. +5. После install запускает `codeboarding-setup`. +6. Первый запуск: `codeboarding full --local `. +7. App читает `.codeboarding/analysis.json`. +8. Показывает diagram/docs. +9. Когда агент меняет файлы, app быстро подсвечивает affected components по baseline mapping. +10. После debounce или завершения task запускает `codeboarding incremental --local `. +11. Если incremental возвращает `requiresFullAnalysis`, UI предлагает full refresh. + +## Риски + +- 🟠 LLM keys: без provider key full/incremental может не пройти. Нужен понятный setup и read-only detect. +- 🟠 Performance: full analysis может быть долгим. Нужны cancellation, queue, progress, timeout. +- 🟠 Dirty worktree: incremental умеет работать с worktree, но target refs и snapshots надо использовать аккуратно. +- 🟠 Cost: LLM вызовы могут стоить денег. Нужен явный opt-in и возможно “run on task complete” вместо постоянного refresh. +- 🟡 Security: не отправлять код в неизвестный сервис. CodeBoarding заявляет local processing plus direct provider API calls, но UX должен прямо показывать выбранный provider. +- 🟡 Generated files: `.codeboarding/` не всегда надо коммитить. Нужно дать настройку ignore/commit. +- 🟡 MCP: CodeBoarding-MCP выглядит сырым, поэтому не стоит брать его как основную интеграцию. + +## Рекомендация + +Делать поэтапно: + +1. MVP optional CLI runner и viewer. +2. Live-ish overlay на базе нашего task change ledger и CodeBoarding baseline. +3. Background incremental refresh. +4. Architecture Review per Task. +5. Только потом MCP/context tools для агентов. + +Самое ценное для пользователя: видеть не “агент изменил 12 файлов”, а “агент сейчас меняет Auth Runtime Detection и это затрагивает Provider Connection + Team Provisioning”. CodeBoarding может дать основу для такой карты, но realtime UX должен быть нашим. diff --git a/src/main/services/runtime/ProviderConnectionService.ts b/src/main/services/runtime/ProviderConnectionService.ts index dadd89ab..67ac3e0b 100644 --- a/src/main/services/runtime/ProviderConnectionService.ts +++ b/src/main/services/runtime/ProviderConnectionService.ts @@ -175,6 +175,20 @@ export class ProviderConnectionService { return null; } + async getConfiguredAnthropicApiKeyForTeamRuntime(env: NodeJS.ProcessEnv): Promise { + if (this.getConfiguredAuthMode('anthropic') !== 'api_key') { + return null; + } + + const storedKey = await this.apiKeyService.lookupPreferred('ANTHROPIC_API_KEY'); + if (storedKey?.value.trim()) { + return storedKey.value.trim(); + } + + const envKey = env.ANTHROPIC_API_KEY?.trim(); + return envKey || null; + } + async applyConfiguredConnectionEnv( env: NodeJS.ProcessEnv, providerId: CliProviderId, diff --git a/src/main/services/runtime/anthropicTeamApiKeyHelper.ts b/src/main/services/runtime/anthropicTeamApiKeyHelper.ts new file mode 100644 index 00000000..db9388d8 --- /dev/null +++ b/src/main/services/runtime/anthropicTeamApiKeyHelper.ts @@ -0,0 +1,353 @@ +import { execFile, execFileSync } from 'child_process'; +import crypto from 'crypto'; +import fs from 'fs'; +import path from 'path'; +import { promisify } from 'util'; + +const execFileAsync = promisify(execFile); + +export const CLAUDE_TEAM_ANTHROPIC_AUTH_MODE_ENV = 'CLAUDE_TEAM_ANTHROPIC_AUTH_MODE'; +export const CLAUDE_TEAM_ANTHROPIC_AUTH_MODE_API_KEY_HELPER = 'api_key_helper'; +export const CLAUDE_TEAM_ANTHROPIC_API_KEY_HELPER_SETTINGS_PATH_ENV = + 'CLAUDE_TEAM_ANTHROPIC_API_KEY_HELPER_SETTINGS_PATH'; +export const DISABLE_ANTHROPIC_TEAM_API_KEY_HELPER_ENV = + 'CLAUDE_TEAM_DISABLE_ANTHROPIC_API_KEY_HELPER'; + +export const ANTHROPIC_HELPER_MODE_COMPETING_AUTH_ENV_KEYS = [ + 'ANTHROPIC_API_KEY', + 'ANTHROPIC_AUTH_TOKEN', + 'CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR', + 'CLAUDE_CODE_OAUTH_TOKEN', + 'CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR', +] as const; + +export interface AnthropicTeamApiKeyHelperMaterial { + teamName: string; + directory: string; + helperPath: string; + keyPath: string; + settingsPath: string; + settingsObject: { apiKeyHelper: string }; + settingsArgs: string[]; + envPatch: NodeJS.ProcessEnv; +} + +function shellQuote(value: string): string { + if (value.length === 0) { + return "''"; + } + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +function isOwnedPathSegment(value: string): boolean { + return /^[a-zA-Z0-9._-]{1,128}$/.test(value) && value !== '.' && value !== '..'; +} + +function safePathSegment(value: string): string { + const trimmed = value.trim(); + if (isOwnedPathSegment(trimmed)) { + return trimmed; + } + return crypto.createHash('sha256').update(value).digest('hex').slice(0, 32); +} + +export function buildAnthropicTeamAuthDirectoryName(teamName: string): string { + const slug = + teamName + .normalize('NFKD') + .replace(/[^a-zA-Z0-9._-]+/g, '_') + .replace(/^_+|_+$/g, '') + .slice(0, 80) || 'team'; + const hash = crypto.createHash('sha256').update(teamName).digest('hex').slice(0, 12); + return `${slug}-${hash}`; +} + +function resolveInside(basePath: string, ...segments: string[]): string { + const resolvedBase = path.resolve(basePath); + const resolvedPath = path.resolve(resolvedBase, ...segments); + const relative = path.relative(resolvedBase, resolvedPath); + if (relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))) { + return resolvedPath; + } + throw new Error('Refusing to write Anthropic team auth material outside the auth root'); +} + +async function ensureOwnedDirectory(dirPath: string): Promise { + try { + const stat = await fs.promises.lstat(dirPath); + if (!stat.isDirectory() || stat.isSymbolicLink()) { + throw new Error(`Unsafe Anthropic team auth directory: ${dirPath}`); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + await fs.promises.mkdir(dirPath, { recursive: true, mode: 0o700 }); + } + if (process.platform !== 'win32') { + await fs.promises.chmod(dirPath, 0o700).catch(() => undefined); + } +} + +async function assertRegularOwnedFile(filePath: string, mode: number): Promise { + const stat = await fs.promises.lstat(filePath); + if (!stat.isFile() || stat.isSymbolicLink()) { + throw new Error(`Unsafe Anthropic team auth file: ${filePath}`); + } + if (process.platform !== 'win32') { + await fs.promises.chmod(filePath, mode).catch(() => undefined); + } +} + +function readLiveProcessCommandsForReferenceCheck(): string | null { + if (process.platform === 'win32') { + return null; + } + try { + return execFileSync('ps', ['-ax', '-o', 'command='], { + encoding: 'utf8', + timeout: 2000, + maxBuffer: 5 * 1024 * 1024, + }); + } catch { + return null; + } +} + +function liveProcessMayReferencePath(targetPath: string, processCommands?: string | null): boolean { + const output = + processCommands !== undefined ? processCommands : readLiveProcessCommandsForReferenceCheck(); + return typeof output === 'string' && output.includes(targetPath); +} + +async function writeFileAtomic(filePath: string, contents: string, mode: number): Promise { + const dir = path.dirname(filePath); + await ensureOwnedDirectory(dir); + const existing = await fs.promises.lstat(filePath).catch((error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') return null; + throw error; + }); + if (existing?.isSymbolicLink()) { + throw new Error(`Refusing to replace symlinked Anthropic team auth file: ${filePath}`); + } + const tmpPath = path.join(dir, `.tmp.${crypto.randomUUID()}`); + try { + await fs.promises.writeFile(tmpPath, contents, { encoding: 'utf8', mode }); + if (process.platform !== 'win32') { + await fs.promises.chmod(tmpPath, mode).catch(() => undefined); + } + await fs.promises.rename(tmpPath, filePath); + await assertRegularOwnedFile(filePath, mode); + } catch (error) { + await fs.promises.rm(tmpPath, { force: true }).catch(() => undefined); + throw error; + } +} + +function buildHelperScript(keyPath: string): string { + return [ + '#!/bin/sh', + 'set -eu', + `KEY_FILE=${shellQuote(keyPath)}`, + 'if [ ! -r "$KEY_FILE" ]; then', + " echo 'app-managed Anthropic API key is unavailable' >&2", + ' exit 1', + 'fi', + 'key="$(cat "$KEY_FILE")"', + 'if [ -z "$key" ]; then', + " echo 'app-managed Anthropic API key is empty' >&2", + ' exit 1', + 'fi', + 'printf \'%s\\n\' "$key"', + '', + ].join('\n'); +} + +function buildAuthMaterialPaths(input: { + teamName: string; + authMaterialId: string; + baseClaudeDir: string; +}): { authRoot: string; teamDir: string; runDir: string } { + const authRoot = path.resolve(input.baseClaudeDir, 'team-runtime-auth'); + const teamDirName = buildAnthropicTeamAuthDirectoryName(input.teamName); + const authMaterialSegment = safePathSegment(input.authMaterialId); + const teamDir = resolveInside(authRoot, teamDirName); + const runDir = resolveInside(authRoot, teamDirName, 'runs', authMaterialSegment); + return { authRoot, teamDir, runDir }; +} + +export async function materializeAnthropicTeamApiKeyHelper(input: { + teamName: string; + authMaterialId: string; + apiKey: string; + baseClaudeDir: string; +}): Promise { + const normalizedApiKey = input.apiKey.trim(); + if (!normalizedApiKey) { + throw new Error('Cannot materialize Anthropic team API-key helper without an API key'); + } + + const { authRoot, teamDir, runDir } = buildAuthMaterialPaths(input); + await ensureOwnedDirectory(authRoot); + await ensureOwnedDirectory(teamDir); + await ensureOwnedDirectory(path.join(teamDir, 'runs')); + await ensureOwnedDirectory(runDir); + + const keyPath = path.join(runDir, 'key'); + const helperPath = path.join(runDir, 'helper.sh'); + const settingsPath = path.join(runDir, 'settings.json'); + const settingsObject = { apiKeyHelper: shellQuote(helperPath) }; + + await writeFileAtomic(keyPath, `${normalizedApiKey}\n`, 0o600); + await writeFileAtomic(helperPath, buildHelperScript(keyPath), 0o700); + await writeFileAtomic(settingsPath, `${JSON.stringify(settingsObject, null, 2)}\n`, 0o600); + + return { + teamName: input.teamName, + directory: runDir, + helperPath, + keyPath, + settingsPath, + settingsObject, + settingsArgs: ['--settings', settingsPath], + envPatch: { + [CLAUDE_TEAM_ANTHROPIC_AUTH_MODE_ENV]: CLAUDE_TEAM_ANTHROPIC_AUTH_MODE_API_KEY_HELPER, + [CLAUDE_TEAM_ANTHROPIC_API_KEY_HELPER_SETTINGS_PATH_ENV]: settingsPath, + }, + }; +} + +export async function verifyAnthropicTeamApiKeyHelperMaterial(input: { + helperPath: string; + expectedApiKey: string; + timeoutMs?: number; +}): Promise { + const result = await execFileAsync('/bin/sh', ['-c', shellQuote(input.helperPath)], { + timeout: input.timeoutMs ?? 5000, + maxBuffer: 1024 * 1024, + }); + if (result.stdout.trim() !== input.expectedApiKey.trim()) { + throw new Error('App-managed Anthropic API-key helper verification failed'); + } +} + +export async function cleanupAnthropicTeamApiKeyHelperMaterial(input: { + directory: string; + skipIfLiveProcessReferences?: boolean; +}): Promise { + if (input.skipIfLiveProcessReferences === true && liveProcessMayReferencePath(input.directory)) { + return; + } + const entries = await fs.promises + .readdir(input.directory, { withFileTypes: true }) + .catch(() => []); + const expected = new Set(['helper.sh', 'key', 'settings.json']); + for (const entry of entries) { + if (!entry.isFile()) { + continue; + } + const fileName = entry.name; + const isExpected = + expected.has(fileName) || /^runtime-settings-[a-zA-Z0-9._-]+\.json$/.test(fileName); + if (!isExpected) { + continue; + } + const filePath = path.join(input.directory, fileName); + const stat = await fs.promises.lstat(filePath).catch(() => null); + if (!stat || stat.isSymbolicLink() || !stat.isFile()) { + continue; + } + await fs.promises.rm(filePath, { force: true }).catch(() => undefined); + } + await fs.promises.rmdir(input.directory).catch(() => undefined); +} + +export async function cleanupAnthropicTeamApiKeyHelperForTeam(input: { + teamName: string; + baseClaudeDir: string; +}): Promise { + const { teamDir } = buildAuthMaterialPaths({ + teamName: input.teamName, + authMaterialId: 'cleanup-placeholder', + baseClaudeDir: input.baseClaudeDir, + }); + const stat = await fs.promises.lstat(teamDir).catch(() => null); + if (!stat || stat.isSymbolicLink() || !stat.isDirectory()) { + return; + } + const processCommands = readLiveProcessCommandsForReferenceCheck(); + const runsDir = path.join(teamDir, 'runs'); + const runsStat = await fs.promises.lstat(runsDir).catch(() => null); + if (runsStat?.isDirectory() && !runsStat.isSymbolicLink()) { + const entries = await fs.promises.readdir(runsDir, { withFileTypes: true }).catch(() => []); + for (const entry of entries) { + if (!entry.isDirectory() || !isOwnedPathSegment(entry.name)) { + continue; + } + const runDir = path.join(runsDir, entry.name); + const runStat = await fs.promises.lstat(runDir).catch(() => null); + if (!runStat?.isDirectory() || runStat.isSymbolicLink()) { + continue; + } + if (liveProcessMayReferencePath(runDir, processCommands)) { + continue; + } + await cleanupAnthropicTeamApiKeyHelperMaterial({ directory: runDir }); + } + await fs.promises.rmdir(runsDir).catch(() => undefined); + } + await fs.promises.rmdir(teamDir).catch(() => undefined); +} + +export async function cleanupStaleAnthropicTeamApiKeyHelpers(input: { + baseClaudeDir: string; + maxAgeMs: number; +}): Promise { + const authRoot = path.resolve(input.baseClaudeDir, 'team-runtime-auth'); + const rootStat = await fs.promises.lstat(authRoot).catch(() => null); + if (!rootStat?.isDirectory() || rootStat.isSymbolicLink()) { + return; + } + + const now = Date.now(); + const processCommands = readLiveProcessCommandsForReferenceCheck(); + if (processCommands === null) { + return; + } + const teamEntries = await fs.promises.readdir(authRoot, { withFileTypes: true }).catch(() => []); + for (const teamEntry of teamEntries) { + if (!teamEntry.isDirectory() || !isOwnedPathSegment(teamEntry.name)) { + continue; + } + const teamDir = path.join(authRoot, teamEntry.name); + const teamStat = await fs.promises.lstat(teamDir).catch(() => null); + if (!teamStat?.isDirectory() || teamStat.isSymbolicLink()) { + continue; + } + const runsDir = path.join(teamDir, 'runs'); + const runsStat = await fs.promises.lstat(runsDir).catch(() => null); + if (!runsStat?.isDirectory() || runsStat.isSymbolicLink()) { + continue; + } + const runEntries = await fs.promises.readdir(runsDir, { withFileTypes: true }).catch(() => []); + for (const runEntry of runEntries) { + if (!runEntry.isDirectory() || !isOwnedPathSegment(runEntry.name)) { + continue; + } + const runDir = path.join(runsDir, runEntry.name); + const runStat = await fs.promises.lstat(runDir).catch(() => null); + if (!runStat?.isDirectory() || runStat.isSymbolicLink()) { + continue; + } + if (now - runStat.mtimeMs < input.maxAgeMs) { + continue; + } + if (liveProcessMayReferencePath(runDir, processCommands)) { + continue; + } + await cleanupAnthropicTeamApiKeyHelperMaterial({ directory: runDir }); + } + await fs.promises.rmdir(runsDir).catch(() => undefined); + await fs.promises.rmdir(teamDir).catch(() => undefined); + } +} diff --git a/src/main/services/runtime/cliSettingsArgs.ts b/src/main/services/runtime/cliSettingsArgs.ts index 6b75b7ad..9184ecaa 100644 --- a/src/main/services/runtime/cliSettingsArgs.ts +++ b/src/main/services/runtime/cliSettingsArgs.ts @@ -1,4 +1,4 @@ -type JsonObject = Record; +export type JsonObject = Record; type JsonArray = unknown[]; @@ -6,7 +6,7 @@ function isJsonObject(value: unknown): value is JsonObject { return typeof value === 'object' && value !== null && !Array.isArray(value); } -function parseJsonSettingsObject(raw: string): JsonObject | null { +export function parseJsonSettingsObject(raw: string): JsonObject | null { try { const parsed = JSON.parse(raw) as unknown; return isJsonObject(parsed) ? parsed : null; @@ -73,7 +73,7 @@ function mergeHooksObject(target: JsonObject, source: JsonObject): JsonObject { continue; } if (isJsonObject(currentValue) && isJsonObject(sourceValue)) { - merged[hookName] = deepMergeJsonObjects(currentValue, sourceValue); + merged[hookName] = mergeJsonSettingsObjects(currentValue, sourceValue); continue; } merged[hookName] = sourceValue; @@ -81,7 +81,7 @@ function mergeHooksObject(target: JsonObject, source: JsonObject): JsonObject { return merged; } -function deepMergeJsonObjects(target: JsonObject, source: JsonObject): JsonObject { +export function mergeJsonSettingsObjects(target: JsonObject, source: JsonObject): JsonObject { const merged: JsonObject = { ...target }; for (const [key, value] of Object.entries(source)) { const current = merged[key]; @@ -90,7 +90,7 @@ function deepMergeJsonObjects(target: JsonObject, source: JsonObject): JsonObjec continue; } if (isJsonObject(current) && isJsonObject(value)) { - merged[key] = deepMergeJsonObjects(current, value); + merged[key] = mergeJsonSettingsObjects(current, value); continue; } merged[key] = value; @@ -120,7 +120,7 @@ export function mergeJsonSettingsArgs(args: string[]): string[] { if (firstSettingsIndex === null) { firstSettingsIndex = output.length; } - mergedSettings = deepMergeJsonObjects(mergedSettings ?? {}, parsed); + mergedSettings = mergeJsonSettingsObjects(mergedSettings ?? {}, parsed); i += 2; continue; } @@ -137,7 +137,7 @@ export function mergeJsonSettingsArgs(args: string[]): string[] { if (firstSettingsIndex === null) { firstSettingsIndex = output.length; } - mergedSettings = deepMergeJsonObjects(mergedSettings ?? {}, parsed); + mergedSettings = mergeJsonSettingsObjects(mergedSettings ?? {}, parsed); i += 1; continue; } diff --git a/src/main/services/runtime/teamRuntimeSettingsBundle.ts b/src/main/services/runtime/teamRuntimeSettingsBundle.ts new file mode 100644 index 00000000..3a893530 --- /dev/null +++ b/src/main/services/runtime/teamRuntimeSettingsBundle.ts @@ -0,0 +1,160 @@ +import fs from 'fs'; +import path from 'path'; +import { randomUUID } from 'crypto'; + +import { mergeJsonSettingsObjects, parseJsonSettingsObject } from './cliSettingsArgs'; + +import type { AnthropicTeamApiKeyHelperMaterial } from './anthropicTeamApiKeyHelper'; +import type { TeamProviderId } from '@shared/types'; + +export type TeamRuntimeSettingsJson = Record; + +export interface TeamRuntimeSettingsBundle { + settingsPath: string; + settingsObject: TeamRuntimeSettingsJson; + args: string[]; +} + +export interface SplitSettingsJsonArgsResult { + settingsFragments: TeamRuntimeSettingsJson[]; + passthroughArgs: string[]; +} + +export function splitSettingsJsonArgs(args: string[]): SplitSettingsJsonArgsResult { + const settingsFragments: TeamRuntimeSettingsJson[] = []; + const passthroughArgs: string[] = []; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === '--settings') { + const value = args[index + 1]; + if (typeof value === 'string') { + const parsed = parseJsonSettingsObject(value); + if (parsed) { + settingsFragments.push(parsed); + index += 1; + continue; + } + passthroughArgs.push(arg, value); + index += 1; + continue; + } + } + + const settingsPrefix = '--settings='; + if (arg.startsWith(settingsPrefix)) { + const value = arg.slice(settingsPrefix.length); + const parsed = parseJsonSettingsObject(value); + if (parsed) { + settingsFragments.push(parsed); + continue; + } + } + + passthroughArgs.push(arg); + } + + return { settingsFragments, passthroughArgs }; +} + +function sanitizeProviderId(providerId: TeamProviderId): string { + return providerId.replace(/[^a-zA-Z0-9._-]+/g, '_') || 'provider'; +} + +function stripCompetingAnthropicEnv(settings: TeamRuntimeSettingsJson): TeamRuntimeSettingsJson { + const env = settings.env; + if (!env || typeof env !== 'object' || Array.isArray(env)) { + return settings; + } + const nextEnv = { ...(env as Record) }; + delete nextEnv.ANTHROPIC_API_KEY; + delete nextEnv.ANTHROPIC_AUTH_TOKEN; + delete nextEnv.CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR; + delete nextEnv.CLAUDE_CODE_OAUTH_TOKEN; + delete nextEnv.CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR; + return { ...settings, env: nextEnv }; +} + +async function writeSettingsFile( + filePath: string, + settings: TeamRuntimeSettingsJson +): Promise { + const dir = path.dirname(filePath); + await fs.promises.mkdir(dir, { recursive: true, mode: 0o700 }); + if (process.platform !== 'win32') { + await fs.promises.chmod(dir, 0o700).catch(() => undefined); + } + const existing = await fs.promises.lstat(filePath).catch((error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') { + return null; + } + throw error; + }); + if (existing?.isSymbolicLink()) { + throw new Error(`Refusing to replace symlinked team runtime settings file: ${filePath}`); + } + const tmpPath = path.join(dir, `.tmp.settings.${randomUUID()}`); + try { + await fs.promises.writeFile(tmpPath, `${JSON.stringify(settings, null, 2)}\n`, { + encoding: 'utf8', + mode: 0o600, + }); + if (process.platform !== 'win32') { + await fs.promises.chmod(tmpPath, 0o600).catch(() => undefined); + } + await fs.promises.rename(tmpPath, filePath); + const written = await fs.promises.lstat(filePath); + if (!written.isFile() || written.isSymbolicLink()) { + throw new Error(`Unsafe team runtime settings file: ${filePath}`); + } + if (process.platform !== 'win32') { + await fs.promises.chmod(filePath, 0o600).catch(() => undefined); + } + } catch (error) { + await fs.promises.rm(tmpPath, { force: true }).catch(() => undefined); + throw error; + } +} + +export async function materializeTeamRuntimeSettingsBundle(input: { + teamName: string; + providerId: TeamProviderId; + baseSettings?: Array; + anthropicHelper?: AnthropicTeamApiKeyHelperMaterial | null; +}): Promise { + const fragments = [...(input.baseSettings ?? [])].filter( + (fragment): fragment is TeamRuntimeSettingsJson => + !!fragment && typeof fragment === 'object' && !Array.isArray(fragment) + ); + if (input.anthropicHelper) { + fragments.push(input.anthropicHelper.settingsObject); + } + if (fragments.length === 0) { + return null; + } + + const settingsObject = stripCompetingAnthropicEnv( + fragments.reduce( + (merged, fragment) => mergeJsonSettingsObjects(merged, fragment), + {} + ) + ); + if (Object.keys(settingsObject).length === 0) { + return null; + } + + const baseDirectory = input.anthropicHelper?.directory; + if (!baseDirectory) { + return null; + } + const settingsPath = path.join( + baseDirectory, + `runtime-settings-${sanitizeProviderId(input.providerId)}.json` + ); + await writeSettingsFile(settingsPath, settingsObject); + return { + settingsPath, + settingsObject, + args: ['--settings', settingsPath], + }; +} diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 33aa255d..50eda0a4 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -109,7 +109,20 @@ import * as path from 'path'; import pidusage from 'pidusage'; import * as readline from 'readline'; -import { mergeJsonSettingsArgs } from '../runtime/cliSettingsArgs'; +import { mergeJsonSettingsArgs, parseJsonSettingsObject } from '../runtime/cliSettingsArgs'; +import { + ANTHROPIC_HELPER_MODE_COMPETING_AUTH_ENV_KEYS, + CLAUDE_TEAM_ANTHROPIC_API_KEY_HELPER_SETTINGS_PATH_ENV, + CLAUDE_TEAM_ANTHROPIC_AUTH_MODE_API_KEY_HELPER, + CLAUDE_TEAM_ANTHROPIC_AUTH_MODE_ENV, + DISABLE_ANTHROPIC_TEAM_API_KEY_HELPER_ENV, + cleanupAnthropicTeamApiKeyHelperForTeam, + cleanupAnthropicTeamApiKeyHelperMaterial, + cleanupStaleAnthropicTeamApiKeyHelpers, + materializeAnthropicTeamApiKeyHelper, + verifyAnthropicTeamApiKeyHelperMaterial, + type AnthropicTeamApiKeyHelperMaterial, +} from '../runtime/anthropicTeamApiKeyHelper'; import { type GeminiRuntimeAuthState, resolveGeminiRuntimeAuth, @@ -123,6 +136,11 @@ import { normalizeProviderModelProbeFailureReason, } from '../runtime/providerModelProbe'; import { resolveTeamProviderId } from '../runtime/providerRuntimeEnv'; +import { + materializeTeamRuntimeSettingsBundle, + splitSettingsJsonArgs, + type TeamRuntimeSettingsJson, +} from '../runtime/teamRuntimeSettingsBundle'; import { createOpenCodePromptDeliveryLedgerStore, @@ -678,6 +696,9 @@ const DIRECT_TMUX_RESTART_ENV_KEYS = [ 'CLAUDE_CODE_ENTRY_PROVIDER', 'CLAUDE_CODE_GEMINI_BACKEND', 'CLAUDE_CODE_CODEX_BACKEND', + 'CODEX_HOME', + CLAUDE_TEAM_ANTHROPIC_AUTH_MODE_ENV, + CLAUDE_TEAM_ANTHROPIC_API_KEY_HELPER_SETTINGS_PATH_ENV, 'ANTHROPIC_BASE_URL', 'ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', @@ -809,7 +830,7 @@ function getDirectRestartEntryProvider(providerId: TeamProviderId): string { return providerId === 'codex' || providerId === 'gemini' ? providerId : 'anthropic'; } -function buildDirectTmuxRestartEnvAssignments( +export function buildDirectTmuxRestartEnvAssignments( env: NodeJS.ProcessEnv, providerId: TeamProviderId ): string { @@ -829,6 +850,22 @@ function buildDirectTmuxRestartEnvAssignments( } assignments.set('CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST', '1'); assignments.set('CLAUDE_CODE_ENTRY_PROVIDER', getDirectRestartEntryProvider(providerId)); + if ( + providerId === 'anthropic' && + env[CLAUDE_TEAM_ANTHROPIC_AUTH_MODE_ENV] === CLAUDE_TEAM_ANTHROPIC_AUTH_MODE_API_KEY_HELPER + ) { + assignments.set( + CLAUDE_TEAM_ANTHROPIC_AUTH_MODE_ENV, + CLAUDE_TEAM_ANTHROPIC_AUTH_MODE_API_KEY_HELPER + ); + const settingsPath = env[CLAUDE_TEAM_ANTHROPIC_API_KEY_HELPER_SETTINGS_PATH_ENV]; + if (typeof settingsPath === 'string') { + assignments.set(CLAUDE_TEAM_ANTHROPIC_API_KEY_HELPER_SETTINGS_PATH_ENV, settingsPath); + } + for (const key of ANTHROPIC_HELPER_MODE_COMPETING_AUTH_ENV_KEYS) { + assignments.set(key, ''); + } + } return [...assignments.entries()].map(([key, value]) => `${key}=${shellQuote(value)}`).join(' '); } @@ -1012,15 +1049,15 @@ function resolveCodexSelectionFromFacts(params: { }); } -function buildAnthropicSettingsArgs( +function buildAnthropicSettingsObject( providerId: TeamProviderId, launchIdentity?: ProviderModelLaunchIdentity | null -): string[] { +): TeamRuntimeSettingsJson | null { if (providerId !== 'anthropic' || typeof launchIdentity?.resolvedFastMode !== 'boolean') { - return []; + return null; } - const settings = launchIdentity.resolvedFastMode + return launchIdentity.resolvedFastMode ? { fastMode: true, fastModePerSessionOptIn: false, @@ -1028,6 +1065,16 @@ function buildAnthropicSettingsArgs( : { fastMode: false, }; +} + +function buildAnthropicSettingsArgs( + providerId: TeamProviderId, + launchIdentity?: ProviderModelLaunchIdentity | null +): string[] { + const settings = buildAnthropicSettingsObject(providerId, launchIdentity); + if (!settings) { + return []; + } return ['--settings', JSON.stringify(settings)]; } @@ -1045,6 +1092,52 @@ function buildProviderFastModeArgs( return []; } +function filterOutSettingsPathArgs( + args: string[], + settingsPath: string | null | undefined +): string[] { + if (!settingsPath) { + return [...args]; + } + const filtered: string[] = []; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === '--settings' && args[index + 1] === settingsPath) { + index += 1; + continue; + } + if (arg === `--settings=${settingsPath}`) { + continue; + } + filtered.push(arg); + } + return filtered; +} + +function hasPathBasedSettingsArgs(args: string[]): boolean { + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === '--settings') { + const value = args[index + 1]; + if (typeof value === 'string') { + if (!parseJsonSettingsObject(value)) { + return true; + } + index += 1; + } + if (typeof value !== 'string') { + return true; + } + continue; + } + const prefix = '--settings='; + if (arg.startsWith(prefix) && !parseJsonSettingsObject(arg.slice(prefix.length))) { + return true; + } + } + return false; +} + function isProbeTimeoutMessage(message: string): boolean { const lower = message.toLowerCase(); return ( @@ -1543,6 +1636,8 @@ interface ProvisioningRun { env: NodeJS.ProcessEnv; prompt: string; } | null; + /** Run-scoped helper material used by Anthropic API-key team runtimes. */ + anthropicApiKeyHelper: AnthropicTeamApiKeyHelperMaterial | null; /** Pending tool approval requests awaiting user response (control_request protocol). */ pendingApprovals: Map; /** Teammate permission_request IDs already intercepted (prevents re-processing read messages). */ @@ -1661,6 +1756,7 @@ function createUnexpectedMixedSecondaryLaneFailureResult(input: { type LeadActivityState = 'active' | 'idle' | 'offline'; type ProvisioningAuthSource = + | 'anthropic_api_key_helper' | 'anthropic_api_key' | 'anthropic_auth_token' | 'configured_api_key_missing' @@ -1668,14 +1764,36 @@ type ProvisioningAuthSource = | 'gemini_runtime' | 'none'; +interface TeamRuntimeAuthContext { + teamName?: string; + authMaterialId?: string; + allowAnthropicApiKeyHelper?: boolean; +} + interface ProvisioningEnvResolution { env: NodeJS.ProcessEnv; authSource: ProvisioningAuthSource; geminiRuntimeAuth: GeminiRuntimeAuthState | null; providerArgs?: string[]; + anthropicApiKeyHelper?: AnthropicTeamApiKeyHelperMaterial | null; warning?: string; } +interface TeamRuntimeLaunchArgsPlan { + settingsArgs: string[]; + fastModeArgs: string[]; + runtimeTurnSettledHookArgs: string[]; + providerArgs: string[]; + extraArgs: string[]; +} + +interface CrossProviderMemberArgsResult { + args: string[]; + providerArgsByProvider: Map; + envPatch: NodeJS.ProcessEnv; + usesAnthropicApiKeyHelper: boolean; +} + interface PromptSizeSummary { chars: number; lines: number; @@ -4871,6 +4989,16 @@ export class TeamProvisioningService { this.transcriptProjectResolver = new TeamTranscriptProjectResolver({ getConfig: (teamName) => this.configReader.getConfigSnapshot(teamName), }); + void cleanupStaleAnthropicTeamApiKeyHelpers({ + baseClaudeDir: getClaudeBasePath(), + maxAgeMs: 14 * 24 * 60 * 60 * 1000, + }).catch((error: unknown) => { + logger.warn( + `Failed to cleanup stale Anthropic team API-key helper material: ${ + error instanceof Error ? error.message : String(error) + }` + ); + }); } private async readConfigSnapshot(teamName: string): Promise { @@ -5094,23 +5222,94 @@ export class TeamProvisioningService { private async buildRuntimeTurnSettledHookSettingsArgs( providerId: TeamProviderId ): Promise { + const settings = await this.buildRuntimeTurnSettledHookSettingsObject(providerId); + return settings ? ['--settings', JSON.stringify(settings)] : []; + } + + private async buildRuntimeTurnSettledHookSettingsObject( + providerId: TeamProviderId + ): Promise { if (providerId !== 'anthropic' || !this.runtimeTurnSettledHookSettingsProvider) { - return []; + return null; } try { const settings = await this.runtimeTurnSettledHookSettingsProvider({ provider: 'claude' }); - return settings ? ['--settings', JSON.stringify(settings)] : []; + return settings ?? null; } catch (error) { logger.warn( `Failed to build member work sync Stop hook settings: ${ error instanceof Error ? error.message : String(error) }` ); - return []; + return null; } } + private async buildTeamRuntimeLaunchArgsPlan(input: { + teamName: string; + providerId: TeamProviderId; + launchIdentity?: ProviderModelLaunchIdentity | null; + envResolution: ProvisioningEnvResolution; + extraArgs?: string[]; + includeAnthropicHelper: boolean; + contextLabel: string; + }): Promise { + const resolvedProviderId = resolveTeamProviderId(input.providerId); + const helper = + input.includeAnthropicHelper && resolvedProviderId === 'anthropic' + ? (input.envResolution.anthropicApiKeyHelper ?? null) + : null; + const rawProviderArgs = input.envResolution.providerArgs ?? []; + const rawExtraArgs = input.extraArgs ?? []; + + if (!helper) { + return { + settingsArgs: [], + fastModeArgs: buildProviderFastModeArgs(resolvedProviderId, input.launchIdentity), + runtimeTurnSettledHookArgs: + await this.buildRuntimeTurnSettledHookSettingsArgs(resolvedProviderId), + providerArgs: rawProviderArgs, + extraArgs: rawExtraArgs, + }; + } + + const providerArgsWithoutHelper = filterOutSettingsPathArgs( + rawProviderArgs, + helper.settingsPath + ); + const splitProviderArgs = splitSettingsJsonArgs(providerArgsWithoutHelper); + const splitExtraArgs = splitSettingsJsonArgs(rawExtraArgs); + if ( + hasPathBasedSettingsArgs(splitProviderArgs.passthroughArgs) || + hasPathBasedSettingsArgs(splitExtraArgs.passthroughArgs) + ) { + throw new Error( + `${input.contextLabel}: app-managed Anthropic API-key helper cannot be combined with path-based --settings. Use inline JSON settings or remove the custom --settings path.` + ); + } + + const settingsBundle = await materializeTeamRuntimeSettingsBundle({ + teamName: input.teamName, + providerId: resolvedProviderId, + baseSettings: [ + buildAnthropicSettingsObject(resolvedProviderId, input.launchIdentity), + await this.buildRuntimeTurnSettledHookSettingsObject(resolvedProviderId), + ...splitProviderArgs.settingsFragments, + ...splitExtraArgs.settingsFragments, + ], + anthropicHelper: helper, + }); + + return { + settingsArgs: settingsBundle?.args ?? [], + fastModeArgs: [], + runtimeTurnSettledHookArgs: [], + providerArgs: splitProviderArgs.passthroughArgs, + extraArgs: splitExtraArgs.passthroughArgs, + }; + } + private async buildRuntimeTurnSettledEnvironment( providerId: TeamProviderId ): Promise> { @@ -5499,6 +5698,7 @@ export class TeamProvisioningService { 'providerId' | 'providerBackendId' | 'model' | 'effort' | 'fastMode' | 'limitContext' >; effectiveMembers: TeamCreateRequest['members']; + providerArgsByProvider?: Map; }): Promise { const leadProviderId = resolveTeamProviderId(params.request.providerId); const factsByProvider = new Map(); @@ -5512,6 +5712,7 @@ export class TeamProvisioningService { cwd: params.cwd, providerId, env: params.env, + providerArgs: params.providerArgsByProvider?.get(providerId), limitContext: params.request.limitContext, }); factsByProvider.set(providerId, facts); @@ -11444,7 +11645,14 @@ export class TeamProvisioningService { const provisioningEnv = await this.buildProvisioningEnv( providerId, - input.configuredMember.providerBackendId + input.configuredMember.providerBackendId, + { + teamRuntimeAuth: { + teamName: input.teamName, + authMaterialId: `${input.run.runId}-direct-${input.configuredMember.name}-${randomUUID()}`, + allowAnthropicApiKeyHelper: true, + }, + } ); if (provisioningEnv.warning) { throw new Error(provisioningEnv.warning); @@ -11474,8 +11682,15 @@ export class TeamProvisioningService { input.leadName ); const bootstrapExpectedAfter = nowIso(); - const runtimeTurnSettledHookArgs = - await this.buildRuntimeTurnSettledHookSettingsArgs(providerId); + const runtimeArgsPlan = await this.buildTeamRuntimeLaunchArgsPlan({ + teamName: input.teamName, + providerId, + launchIdentity: null, + envResolution: provisioningEnv, + extraArgs: [], + includeAnthropicHelper: providerId === 'anthropic', + contextLabel: `Direct teammate restart (${input.configuredMember.name})`, + }); const runtimeArgs = mergeJsonSettingsArgs([ '--agent-id', @@ -11501,8 +11716,10 @@ export class TeamProvisioningService { : ['--permission-prompt-tool', 'stdio', '--permission-mode', 'default']), ...(input.configuredMember.model ? ['--model', input.configuredMember.model] : []), ...(input.configuredMember.effort ? ['--effort', input.configuredMember.effort] : []), - ...runtimeTurnSettledHookArgs, - ...(provisioningEnv.providerArgs ?? []), + ...runtimeArgsPlan.fastModeArgs, + ...runtimeArgsPlan.runtimeTurnSettledHookArgs, + ...runtimeArgsPlan.providerArgs, + ...runtimeArgsPlan.settingsArgs, ]); const command = buildDirectTmuxRestartCommand({ cwd, @@ -13438,6 +13655,7 @@ export class TeamProvisioningService { }; primaryProviderId?: TeamProviderId; primaryEnv?: ProvisioningEnvResolution; + teamRuntimeAuth?: TeamRuntimeAuthContext; limitContext?: boolean; }): Promise { const envByProvider = new Map>(); @@ -13454,7 +13672,9 @@ export class TeamProvisioningService { return cached; } - const created = this.buildProvisioningEnv(providerId); + const created = this.buildProvisioningEnv(providerId, undefined, { + teamRuntimeAuth: params.teamRuntimeAuth, + }); envByProvider.set(providerId, created); return created; }; @@ -14481,10 +14701,16 @@ export class TeamProvisioningService { throw new Error('Claude CLI not found; install it or provide a valid path'); } + const runtimeAuthMaterialId = randomUUID(); + const teamRuntimeAuth: TeamRuntimeAuthContext = { + teamName: request.teamName, + authMaterialId: runtimeAuthMaterialId, + allowAnthropicApiKeyHelper: true, + }; const provisioningEnv = await this.buildProvisioningEnv( request.providerId, request.providerBackendId, - { includeCodexTeammateAuth: teamRequestIncludesCodexMember(request) } + { includeCodexTeammateAuth: teamRequestIncludesCodexMember(request), teamRuntimeAuth } ); const { env: shellEnv, @@ -14506,6 +14732,7 @@ export class TeamProvisioningService { }, primaryProviderId: request.providerId, primaryEnv: provisioningEnv, + teamRuntimeAuth, limitContext: request.limitContext, }); const allEffectiveMemberSpecs = await this.resolveOpenCodeMemberWorkspacesForRuntime({ @@ -14526,12 +14753,29 @@ export class TeamProvisioningService { const effectiveMemberSpecs = allEffectiveMemberSpecs.filter((member) => primaryMemberNames.has(member.name) ); + const resolvedProviderId = resolveTeamProviderId(request.providerId); + const crossProviderMemberArgs = await this.buildCrossProviderMemberArgs( + resolvedProviderId, + effectiveMemberSpecs, + { teamRuntimeAuth } + ); + Object.assign(shellEnv, crossProviderMemberArgs.envPatch); + if (crossProviderMemberArgs.usesAnthropicApiKeyHelper) { + for (const key of ANTHROPIC_HELPER_MODE_COMPETING_AUTH_ENV_KEYS) { + delete shellEnv[key]; + } + } + const providerArgsByProvider = new Map([ + [resolvedProviderId, providerArgs], + ...crossProviderMemberArgs.providerArgsByProvider, + ]); const launchIdentity = await this.resolveAndValidateLaunchIdentity({ claudePath, cwd: request.cwd, env: shellEnv, request, effectiveMembers: effectiveMemberSpecs, + providerArgsByProvider, }); const runId = randomUUID(); const startedAt = nowIso(); @@ -14600,6 +14844,7 @@ export class TeamProvisioningService { authFailureRetried: false, authRetryInProgress: false, spawnContext: null, + anthropicApiKeyHelper: provisioningEnv.anthropicApiKeyHelper ?? null, pendingApprovals: new Map(), processedPermissionRequestIds: new Set(), pendingPostCompactReminder: false, @@ -14684,6 +14929,11 @@ export class TeamProvisioningService { } catch (error) { this.runs.delete(runId); this.provisioningRunByTeam.delete(request.teamName); + if (provisioningEnv.anthropicApiKeyHelper) { + await cleanupAnthropicTeamApiKeyHelperMaterial({ + directory: provisioningEnv.anthropicApiKeyHelper.directory, + }).catch(() => undefined); + } await removeDeterministicBootstrapSpecFile(run.bootstrapSpecPath).catch(() => {}); run.bootstrapSpecPath = null; await removeDeterministicBootstrapUserPromptFile(run.bootstrapUserPromptPath).catch( @@ -14697,10 +14947,16 @@ export class TeamProvisioningService { request.model, launchIdentity ); - const resolvedProviderId = resolveTeamProviderId(request.providerId); - const providerFastModeArgs = buildProviderFastModeArgs(resolvedProviderId, launchIdentity); - const runtimeTurnSettledHookArgs = - await this.buildRuntimeTurnSettledHookSettingsArgs(resolvedProviderId); + const extraCliArgs = parseCliArgs(request.extraCliArgs); + const runtimeArgsPlan = await this.buildTeamRuntimeLaunchArgsPlan({ + teamName: request.teamName, + providerId: resolvedProviderId, + launchIdentity, + envResolution: provisioningEnv, + extraArgs: extraCliArgs, + includeAnthropicHelper: resolvedProviderId === 'anthropic', + contextLabel: 'Team create launch', + }); const spawnArgs = mergeJsonSettingsArgs([ '--input-format', 'stream-json', @@ -14725,12 +14981,14 @@ export class TeamProvisioningService { : ['--permission-prompt-tool', 'stdio', '--permission-mode', 'default']), ...(launchModelArg ? ['--model', launchModelArg] : []), ...(launchIdentity.resolvedEffort ? ['--effort', launchIdentity.resolvedEffort] : []), - ...providerFastModeArgs, - ...runtimeTurnSettledHookArgs, + ...runtimeArgsPlan.fastModeArgs, + ...runtimeArgsPlan.runtimeTurnSettledHookArgs, ...(request.worktree ? ['--worktree', request.worktree] : []), ...buildDesktopTeammateModeCliArgs(teammateModeDecision), - ...parseCliArgs(request.extraCliArgs), - ...providerArgs, + ...runtimeArgsPlan.extraArgs, + ...runtimeArgsPlan.providerArgs, + ...runtimeArgsPlan.settingsArgs, + ...crossProviderMemberArgs.args, ]); const runtimeWarning = buildRuntimeLaunchWarning(request, shellEnv, { geminiRuntimeAuth, @@ -14812,6 +15070,11 @@ export class TeamProvisioningService { await this.mcpConfigBuilder.removeConfigFile(run.mcpConfigPath).catch(() => {}); run.mcpConfigPath = null; } + if (provisioningEnv.anthropicApiKeyHelper) { + await cleanupAnthropicTeamApiKeyHelperMaterial({ + directory: provisioningEnv.anthropicApiKeyHelper.directory, + }).catch(() => undefined); + } this.runs.delete(runId); this.provisioningRunByTeam.delete(request.teamName); throw error; @@ -15587,11 +15850,16 @@ export class TeamProvisioningService { const teamsBasePathsToProbe = getTeamsBasePathsToProbe(); const runId = randomUUID(); const startedAt = nowIso(); + const teamRuntimeAuth: TeamRuntimeAuthContext = { + teamName: request.teamName, + authMaterialId: runId, + allowAnthropicApiKeyHelper: true, + }; const provisioningEnv = await this.buildProvisioningEnv( request.providerId, request.providerBackendId, - { includeCodexTeammateAuth: teamRequestIncludesCodexMember(request) } + { includeCodexTeammateAuth: teamRequestIncludesCodexMember(request), teamRuntimeAuth } ); const { env: shellEnv, @@ -15614,6 +15882,7 @@ export class TeamProvisioningService { }, primaryProviderId: request.providerId, primaryEnv: provisioningEnv, + teamRuntimeAuth, limitContext: request.limitContext, }); const allEffectiveMemberSpecs = await this.resolveOpenCodeMemberWorkspacesForRuntime({ @@ -15635,12 +15904,29 @@ export class TeamProvisioningService { primaryMemberNames.has(member.name) ); const expectedMembers = effectiveMemberSpecs.map((member) => member.name); + const resolvedProviderId = resolveTeamProviderId(request.providerId); + const crossProviderMemberArgs = await this.buildCrossProviderMemberArgs( + resolvedProviderId, + effectiveMemberSpecs, + { teamRuntimeAuth } + ); + Object.assign(shellEnv, crossProviderMemberArgs.envPatch); + if (crossProviderMemberArgs.usesAnthropicApiKeyHelper) { + for (const key of ANTHROPIC_HELPER_MODE_COMPETING_AUTH_ENV_KEYS) { + delete shellEnv[key]; + } + } + const providerArgsByProvider = new Map([ + [resolvedProviderId, providerArgs], + ...crossProviderMemberArgs.providerArgsByProvider, + ]); const launchIdentity = await this.resolveAndValidateLaunchIdentity({ claudePath, cwd: request.cwd, env: shellEnv, request, effectiveMembers: effectiveMemberSpecs, + providerArgsByProvider, }); // Build a synthetic TeamCreateRequest for reuse by shared infrastructure @@ -15734,6 +16020,7 @@ export class TeamProvisioningService { authFailureRetried: false, authRetryInProgress: false, spawnContext: null, + anthropicApiKeyHelper: provisioningEnv.anthropicApiKeyHelper ?? null, pendingApprovals: new Map(), processedPermissionRequestIds: new Set(), pendingPostCompactReminder: false, @@ -15842,6 +16129,11 @@ export class TeamProvisioningService { } catch (error) { this.runs.delete(runId); this.provisioningRunByTeam.delete(request.teamName); + if (provisioningEnv.anthropicApiKeyHelper) { + await cleanupAnthropicTeamApiKeyHelperMaterial({ + directory: provisioningEnv.anthropicApiKeyHelper.directory, + }).catch(() => undefined); + } await removeDeterministicBootstrapSpecFile(run.bootstrapSpecPath).catch(() => {}); run.bootstrapSpecPath = null; await removeDeterministicBootstrapUserPromptFile(run.bootstrapUserPromptPath).catch( @@ -15885,35 +16177,38 @@ export class TeamProvisioningService { request.model, launchIdentity ); - const resolvedProviderId = resolveTeamProviderId(request.providerId); - const providerFastModeArgs = buildProviderFastModeArgs(resolvedProviderId, launchIdentity); - const runtimeTurnSettledHookArgs = - await this.buildRuntimeTurnSettledHookSettingsArgs(resolvedProviderId); + const extraCliArgs = parseCliArgs(request.extraCliArgs); + const runtimeArgsPlan = await this.buildTeamRuntimeLaunchArgsPlan({ + teamName: request.teamName, + providerId: resolvedProviderId, + launchIdentity, + envResolution: provisioningEnv, + extraArgs: extraCliArgs, + includeAnthropicHelper: resolvedProviderId === 'anthropic', + contextLabel: 'Team launch', + }); if (launchModelArg) { launchArgs.push('--model', launchModelArg); } if (launchIdentity.resolvedEffort) { launchArgs.push('--effort', launchIdentity.resolvedEffort); } - launchArgs.push(...providerFastModeArgs); - launchArgs.push(...runtimeTurnSettledHookArgs); + launchArgs.push(...runtimeArgsPlan.fastModeArgs); + launchArgs.push(...runtimeArgsPlan.runtimeTurnSettledHookArgs); if (request.worktree) { launchArgs.push('--worktree', request.worktree); } launchArgs.push(...buildDesktopTeammateModeCliArgs(teammateModeDecision)); - launchArgs.push(...parseCliArgs(request.extraCliArgs)); - launchArgs.push(...providerArgs); + launchArgs.push(...runtimeArgsPlan.extraArgs); + launchArgs.push(...runtimeArgsPlan.providerArgs); + launchArgs.push(...runtimeArgsPlan.settingsArgs); // When the lead uses a different provider than some teammates (e.g., anthropic lead // with codex teammates), the lead needs the teammate provider's launch args so they // can be inherited by the teammate subprocess via buildInheritedCliFlags. // Without this, a codex teammate spawned from an anthropic lead has no way to learn // about the required forced_login_method (chatgpt/api) and fails to start. emitProvisioningCheckpoint(run, 'Resolving cross-provider member launch args'); - const crossProviderMemberArgs = await this.buildCrossProviderMemberArgs( - resolvedProviderId, - effectiveMemberSpecs - ); - launchArgs.push(...crossProviderMemberArgs); + launchArgs.push(...crossProviderMemberArgs.args); const finalLaunchArgs = mergeJsonSettingsArgs(launchArgs); const runtimeWarning = buildRuntimeLaunchWarning(request, shellEnv, { geminiRuntimeAuth, @@ -15988,6 +16283,11 @@ export class TeamProvisioningService { () => {} ); run.bootstrapUserPromptPath = null; + if (provisioningEnv.anthropicApiKeyHelper) { + await cleanupAnthropicTeamApiKeyHelperMaterial({ + directory: provisioningEnv.anthropicApiKeyHelper.directory, + }).catch(() => undefined); + } this.runs.delete(runId); this.provisioningRunByTeam.delete(request.teamName); await this.restorePrelaunchConfig(request.teamName); @@ -21863,6 +22163,7 @@ export class TeamProvisioningService { if (this.hasSecondaryRuntimeRuns(teamName)) { await this.stopMixedSecondaryRuntimeLanes(teamName); } + await this.cleanupAnthropicApiKeyHelperMaterialForStoppedTeam(teamName); return; } const run = this.runs.get(runId); @@ -21870,6 +22171,7 @@ export class TeamProvisioningService { const runtimeProgress = this.runtimeAdapterProgressByRunId.get(runId); if (runtimeProgress && this.isCancellableRuntimeAdapterProgress(runtimeProgress)) { await this.cancelRuntimeAdapterProvisioning(runId, runtimeProgress); + await this.cleanupAnthropicApiKeyHelperMaterialForStoppedTeam(teamName); return; } const runtimeRun = this.runtimeAdapterRunByTeam.get(teamName); @@ -21880,6 +22182,7 @@ export class TeamProvisioningService { await this.stopOpenCodeRuntimeAdapterTeam(teamName, runId); } }); + await this.cleanupAnthropicApiKeyHelperMaterialForStoppedTeam(teamName); return; } if (this.hasSecondaryRuntimeRuns(teamName)) { @@ -21887,12 +22190,14 @@ export class TeamProvisioningService { } this.provisioningRunByTeam.delete(teamName); this.aliveRunByTeam.delete(teamName); + await this.cleanupAnthropicApiKeyHelperMaterialForStoppedTeam(teamName); return; } if (run.processKilled || run.cancelRequested) { if (this.hasSecondaryRuntimeRuns(teamName)) { await this.stopMixedSecondaryRuntimeLanes(teamName); } + await this.cleanupAnthropicApiKeyHelperMaterialForStoppedTeam(teamName); return; } run.processKilled = true; @@ -21906,6 +22211,7 @@ export class TeamProvisioningService { this.cleanupRun(run); logger.info(`[${teamName}] Process stopped (SIGKILL)`); await stopSecondaryRuntimeLanes; + await this.cleanupAnthropicApiKeyHelperMaterialForStoppedTeam(teamName); } private getShutdownTrackedTeamNames(): string[] { @@ -22179,6 +22485,23 @@ export class TeamProvisioningService { this.killOrphanedTeamAgentProcesses(teamName); } + private async cleanupAnthropicApiKeyHelperMaterialForStoppedTeam( + teamName: string + ): Promise { + try { + await cleanupAnthropicTeamApiKeyHelperForTeam({ + teamName, + baseClaudeDir: getClaudeBasePath(), + }); + } catch (error) { + logger.warn( + `[${teamName}] Failed to cleanup Anthropic team API-key helper material: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + } + private readPersistedTeamProjectPath(teamName: string): string | null { const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); try { @@ -22329,6 +22652,7 @@ export class TeamProvisioningService { logger.info(`Cleaning up persisted teammate runtimes on shutdown: ${orphanOnly.join(', ')}`); for (const teamName of orphanOnly) { this.stopPersistentTeamMembers(teamName); + await this.cleanupAnthropicApiKeyHelperMaterialForStoppedTeam(teamName); } } } @@ -22512,6 +22836,18 @@ export class TeamProvisioningService { }); if (shouldCleanupUnconfirmedLaunchRuntimes) { this.stopPersistentTeamMembers(run.teamName); + if (run.anthropicApiKeyHelper) { + void cleanupAnthropicTeamApiKeyHelperMaterial({ + directory: run.anthropicApiKeyHelper.directory, + skipIfLiveProcessReferences: true, + }).catch((error: unknown) => { + logger.warn( + `[${run.teamName}] Failed to cleanup failed-run Anthropic API-key helper material: ${ + error instanceof Error ? error.message : String(error) + }` + ); + }); + } } run.processKilled = true; killTeamProcess(run.child); @@ -25496,7 +25832,10 @@ export class TeamProvisioningService { private async buildProvisioningEnv( providerId: TeamProviderId | undefined = 'anthropic', providerBackendId?: string | null, - options?: { includeCodexTeammateAuth?: boolean } + options?: { + includeCodexTeammateAuth?: boolean; + teamRuntimeAuth?: TeamRuntimeAuthContext; + } ): Promise { const shellEnv = await resolveInteractiveShellEnv(); // getHomeDir() uses Electron's app.getPath('home') which handles Unicode @@ -25610,6 +25949,54 @@ export class TeamProvisioningService { }; } + const teamRuntimeAuth = options?.teamRuntimeAuth; + const helperAllowed = + resolvedProviderId === 'anthropic' && + teamRuntimeAuth?.allowAnthropicApiKeyHelper === true && + typeof teamRuntimeAuth.teamName === 'string' && + teamRuntimeAuth.teamName.trim().length > 0 && + typeof teamRuntimeAuth.authMaterialId === 'string' && + teamRuntimeAuth.authMaterialId.trim().length > 0 && + !isWindows && + process.env[DISABLE_ANTHROPIC_TEAM_API_KEY_HELPER_ENV] !== '1'; + + if (helperAllowed) { + const apiKey = + await this.providerConnectionService.getConfiguredAnthropicApiKeyForTeamRuntime( + providerEnv + ); + if (apiKey) { + const helper = await materializeAnthropicTeamApiKeyHelper({ + teamName: teamRuntimeAuth.teamName!, + authMaterialId: teamRuntimeAuth.authMaterialId!, + apiKey, + baseClaudeDir: getClaudeBasePath(), + }); + try { + await verifyAnthropicTeamApiKeyHelperMaterial({ + helperPath: helper.helperPath, + expectedApiKey: apiKey, + }); + } catch (error) { + await cleanupAnthropicTeamApiKeyHelperMaterial({ directory: helper.directory }); + throw error; + } + + for (const key of ANTHROPIC_HELPER_MODE_COMPETING_AUTH_ENV_KEYS) { + delete providerEnv[key]; + } + Object.assign(providerEnv, helper.envPatch); + + return { + env: providerEnv, + authSource: 'anthropic_api_key_helper', + geminiRuntimeAuth: null, + providerArgs: [...(providerEnvResult.providerArgs ?? []), ...helper.settingsArgs], + anthropicApiKeyHelper: helper, + }; + } + } + // 1. Explicit ANTHROPIC_API_KEY — works with `-p` mode directly if ( typeof providerEnv.ANTHROPIC_API_KEY === 'string' && @@ -25653,8 +26040,9 @@ export class TeamProvisioningService { private async buildCrossProviderMemberArgs( primaryProviderId: TeamProviderId, - memberSpecs: TeamCreateRequest['members'] - ): Promise { + memberSpecs: TeamCreateRequest['members'], + options?: { teamRuntimeAuth?: TeamRuntimeAuthContext } + ): Promise { const crossProviderIds = new Set(); for (const member of memberSpecs) { const memberId = resolveTeamProviderId( @@ -25665,12 +26053,27 @@ export class TeamProvisioningService { } } const args: string[] = []; + const providerArgsByProvider = new Map(); + const envPatch: NodeJS.ProcessEnv = {}; + let usesAnthropicApiKeyHelper = false; for (const providerId of crossProviderIds) { try { - const env = await this.buildProvisioningEnv(providerId); + const env = await this.buildProvisioningEnv(providerId, undefined, { + teamRuntimeAuth: options?.teamRuntimeAuth, + }); args.push(...(await this.buildRuntimeTurnSettledHookSettingsArgs(providerId))); - if (env.providerArgs) { - args.push(...env.providerArgs); + const providerArgs = env.providerArgs ?? []; + providerArgsByProvider.set(providerId, providerArgs); + if (env.anthropicApiKeyHelper) { + usesAnthropicApiKeyHelper = true; + Object.assign(envPatch, env.anthropicApiKeyHelper.envPatch); + } + const flattenedArgs = + providerId === 'anthropic' && env.anthropicApiKeyHelper + ? filterOutSettingsPathArgs(providerArgs, env.anthropicApiKeyHelper.settingsPath) + : providerArgs; + if (flattenedArgs.length > 0) { + args.push(...flattenedArgs); } } catch (error) { console.error( @@ -25680,7 +26083,7 @@ export class TeamProvisioningService { // Best-effort: don't block launch if cross-provider env resolution fails } } - return args; + return { args, providerArgsByProvider, envPatch, usesAnthropicApiKeyHelper }; } private async resolveControlApiBaseUrl(): Promise { diff --git a/test/main/services/runtime/ProviderConnectionService.test.ts b/test/main/services/runtime/ProviderConnectionService.test.ts index e4e7292b..1cd8813c 100644 --- a/test/main/services/runtime/ProviderConnectionService.test.ts +++ b/test/main/services/runtime/ProviderConnectionService.test.ts @@ -995,4 +995,65 @@ describe('ProviderConnectionService', () => { source: 'app-server', }); }); + + it('returns the stored Anthropic API key for team helper mode only in api_key auth mode', async () => { + const lookupPreferred = vi.fn().mockResolvedValue({ + envVarName: 'ANTHROPIC_API_KEY', + value: 'stored-team-key', + }); + const { ProviderConnectionService } = + await import('@main/services/runtime/ProviderConnectionService'); + + const service = new ProviderConnectionService( + { + lookupPreferred, + } as never, + { + getConfig: () => createConfig('api_key'), + } as never + ); + + await expect( + service.getConfiguredAnthropicApiKeyForTeamRuntime({ + ANTHROPIC_API_KEY: 'env-team-key', + }) + ).resolves.toBe('stored-team-key'); + expect(lookupPreferred).toHaveBeenCalledWith('ANTHROPIC_API_KEY'); + }); + + it('does not use token-only or OAuth credentials for Anthropic team helper mode', async () => { + const { ProviderConnectionService } = + await import('@main/services/runtime/ProviderConnectionService'); + + const oauthService = new ProviderConnectionService( + { + lookupPreferred: vi.fn().mockResolvedValue({ + envVarName: 'ANTHROPIC_API_KEY', + value: 'stored-team-key', + }), + } as never, + { + getConfig: () => createConfig('oauth'), + } as never + ); + const apiKeyService = new ProviderConnectionService( + { + lookupPreferred: vi.fn().mockResolvedValue(null), + } as never, + { + getConfig: () => createConfig('api_key'), + } as never + ); + + await expect( + oauthService.getConfiguredAnthropicApiKeyForTeamRuntime({ + ANTHROPIC_API_KEY: 'env-team-key', + }) + ).resolves.toBeNull(); + await expect( + apiKeyService.getConfiguredAnthropicApiKeyForTeamRuntime({ + ANTHROPIC_AUTH_TOKEN: 'proxy-token-only', + }) + ).resolves.toBeNull(); + }); }); diff --git a/test/main/services/runtime/anthropicTeamApiKeyHelper.test.ts b/test/main/services/runtime/anthropicTeamApiKeyHelper.test.ts new file mode 100644 index 00000000..a374f05c --- /dev/null +++ b/test/main/services/runtime/anthropicTeamApiKeyHelper.test.ts @@ -0,0 +1,90 @@ +// @vitest-environment node +import { mkdtemp, readFile, rm, stat } from 'fs/promises'; +import { execFile } from 'child_process'; +import { tmpdir } from 'os'; +import path from 'path'; +import { promisify } from 'util'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { + buildAnthropicTeamAuthDirectoryName, + cleanupAnthropicTeamApiKeyHelperMaterial, + materializeAnthropicTeamApiKeyHelper, + verifyAnthropicTeamApiKeyHelperMaterial, +} from '@main/services/runtime/anthropicTeamApiKeyHelper'; + +const execFileAsync = promisify(execFile); + +describe('anthropicTeamApiKeyHelper', () => { + const tempRoots: string[] = []; + + async function createTempRoot(): Promise { + const dir = await mkdtemp(path.join(tmpdir(), 'anthropic-team-helper-')); + tempRoots.push(dir); + return dir; + } + + afterEach(async () => { + await Promise.all(tempRoots.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); + }); + + it('uses slug plus hash to avoid unsafe-name collisions', () => { + const one = buildAnthropicTeamAuthDirectoryName('team/a'); + const two = buildAnthropicTeamAuthDirectoryName('team:a'); + + expect(one).not.toBe(two); + expect(one).toMatch(/^[a-zA-Z0-9._-]+-[a-f0-9]{12}$/); + expect(two).toMatch(/^[a-zA-Z0-9._-]+-[a-f0-9]{12}$/); + }); + + it('materializes helper settings without writing the raw key into args or settings', async () => { + const root = await mkdtemp(path.join(tmpdir(), "anthropic team helper ' ")); + tempRoots.push(root); + const apiKey = 'sk-ant-test-secret-value'; + const material = await materializeAnthropicTeamApiKeyHelper({ + teamName: 'secure team', + authMaterialId: 'run-123', + apiKey, + baseClaudeDir: root, + }); + + const settingsRaw = await readFile(material.settingsPath, 'utf8'); + const helperRaw = await readFile(material.helperPath, 'utf8'); + + expect(material.settingsArgs).toEqual(['--settings', material.settingsPath]); + expect(material.settingsArgs.join(' ')).not.toContain(apiKey); + expect(settingsRaw).toContain('apiKeyHelper'); + expect(settingsRaw).not.toContain(apiKey); + expect(helperRaw).toContain('KEY_FILE='); + expect(helperRaw).not.toContain(apiKey); + const parsedSettings = JSON.parse(settingsRaw) as { apiKeyHelper: string }; + const shellResult = await execFileAsync('/bin/sh', ['-c', parsedSettings.apiKeyHelper]); + expect(shellResult.stdout.trim()).toBe(apiKey); + + if (process.platform !== 'win32') { + expect((await stat(material.keyPath)).mode & 0o777).toBe(0o600); + expect((await stat(material.helperPath)).mode & 0o777).toBe(0o700); + expect((await stat(material.settingsPath)).mode & 0o777).toBe(0o600); + } + + await verifyAnthropicTeamApiKeyHelperMaterial({ + helperPath: material.helperPath, + expectedApiKey: apiKey, + }); + }); + + it('cleans only owned helper material files', async () => { + const root = await createTempRoot(); + const material = await materializeAnthropicTeamApiKeyHelper({ + teamName: 'cleanup team', + authMaterialId: 'run-456', + apiKey: 'sk-ant-test-cleanup', + baseClaudeDir: root, + }); + + await cleanupAnthropicTeamApiKeyHelperMaterial({ directory: material.directory }); + + await expect(stat(material.directory)).rejects.toMatchObject({ code: 'ENOENT' }); + }); +}); diff --git a/test/main/services/runtime/teamRuntimeSettingsBundle.test.ts b/test/main/services/runtime/teamRuntimeSettingsBundle.test.ts new file mode 100644 index 00000000..c1a63348 --- /dev/null +++ b/test/main/services/runtime/teamRuntimeSettingsBundle.test.ts @@ -0,0 +1,74 @@ +// @vitest-environment node +import { mkdtemp, readFile, rm } from 'fs/promises'; +import { tmpdir } from 'os'; +import path from 'path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { materializeTeamRuntimeSettingsBundle } from '@main/services/runtime/teamRuntimeSettingsBundle'; + +describe('teamRuntimeSettingsBundle', () => { + const tempRoots: string[] = []; + + async function createTempRoot(): Promise { + const dir = await mkdtemp(path.join(tmpdir(), 'team-runtime-settings-')); + tempRoots.push(dir); + return dir; + } + + afterEach(async () => { + await Promise.all(tempRoots.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); + }); + + it('merges app settings and helper settings into one provider settings file', async () => { + const dir = await createTempRoot(); + const helper = { + teamName: 'bundle-team', + directory: dir, + helperPath: path.join(dir, 'helper.sh'), + keyPath: path.join(dir, 'key'), + settingsPath: path.join(dir, 'settings.json'), + settingsObject: { apiKeyHelper: "'/tmp/helper.sh'" }, + settingsArgs: ['--settings', path.join(dir, 'settings.json')], + envPatch: {}, + }; + + const bundle = await materializeTeamRuntimeSettingsBundle({ + teamName: 'bundle-team', + providerId: 'anthropic', + anthropicHelper: helper, + baseSettings: [ + { fastMode: false }, + { + env: { + ANTHROPIC_API_KEY: 'must-not-survive', + ANTHROPIC_AUTH_TOKEN: 'must-not-survive', + SAFE_VALUE: 'keep', + }, + }, + { + hooks: { + Stop: [ + { + matcher: '', + hooks: [{ type: 'command', command: '/bin/sh app-stop.sh' }], + }, + ], + }, + }, + ], + }); + + expect(bundle?.args).toEqual(['--settings', bundle?.settingsPath]); + const settings = JSON.parse(await readFile(bundle!.settingsPath, 'utf8')); + + expect(settings).toMatchObject({ + fastMode: false, + apiKeyHelper: "'/tmp/helper.sh'", + env: { SAFE_VALUE: 'keep' }, + }); + expect(settings.env.ANTHROPIC_API_KEY).toBeUndefined(); + expect(settings.env.ANTHROPIC_AUTH_TOKEN).toBeUndefined(); + expect(settings.hooks.Stop).toHaveLength(1); + }); +}); diff --git a/test/main/services/team/AnthropicRuntimeMemory.live.test.ts b/test/main/services/team/AnthropicRuntimeMemory.live.test.ts index 1c05b0e7..19a0f5cd 100644 --- a/test/main/services/team/AnthropicRuntimeMemory.live.test.ts +++ b/test/main/services/team/AnthropicRuntimeMemory.live.test.ts @@ -2,7 +2,7 @@ import { constants as fsConstants, promises as fs } from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService'; import { setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder'; @@ -12,6 +12,14 @@ import type { TeamProvisioningProgress, } from '../../../../src/shared/types'; +vi.mock('../../../../src/main/services/infrastructure/NotificationManager', () => ({ + NotificationManager: { + getInstance: () => ({ + addTeamNotification: vi.fn(async () => undefined), + }), + }, +})); + const liveDescribe = process.env.ANTHROPIC_RUNTIME_MEMORY_LIVE === '1' && process.env.ANTHROPIC_API_KEY?.trim() ? describe diff --git a/test/main/services/team/MixedProviderTeamLaunch.live.test.ts b/test/main/services/team/MixedProviderTeamLaunch.live.test.ts new file mode 100644 index 00000000..daad294e --- /dev/null +++ b/test/main/services/team/MixedProviderTeamLaunch.live.test.ts @@ -0,0 +1,414 @@ +import { constants as fsConstants, promises as fs } from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { readOpenCodeRuntimeLaneIndex } from '../../../../src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader'; +import { + getTeamsBasePath, + setClaudeBasePathOverride, +} from '../../../../src/main/utils/pathDecoder'; +import { + createOpenCodeLiveHarness, + waitForOpenCodeLanesStopped, + waitUntil, + type OpenCodeLiveHarness, +} from './openCodeLiveTestHarness'; + +import type { TeamProvisioningProgress } from '../../../../src/shared/types'; + +vi.mock('../../../../src/main/services/infrastructure/NotificationManager', () => ({ + NotificationManager: { + getInstance: () => ({ + addTeamNotification: vi.fn(async () => undefined), + }), + }, +})); + +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()) + ? describe + : describe.skip; + +const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli'; +const DEFAULT_ANTHROPIC_MODEL = 'haiku'; +const DEFAULT_CODEX_MODEL = 'gpt-5.4-mini'; +const DEFAULT_OPENCODE_MODEL = 'openai/gpt-5.4-mini'; + +liveDescribe('Mixed provider team launch live e2e', () => { + let tempDir: string; + let tempClaudeRoot: string; + let tempHome: string; + let projectPath: string; + let previousCliPath: string | undefined; + let previousCliFlavor: string | undefined; + let previousNudgeFlag: string | undefined; + let previousCodexHome: string | undefined; + let previousHome: string | undefined; + let previousUserProfile: string | undefined; + let previousNodeEnv: string | undefined; + let previousDisableAppBootstrap: string | undefined; + let previousDisableRuntimeBootstrap: string | undefined; + let harness: OpenCodeLiveHarness | null; + let teamName: string | null; + let codexAccountFeature: { getSnapshot(): Promise; dispose(): Promise } | null; + let providerConnectionService: { + setCodexAccountFeature(feature: { getSnapshot(): Promise } | null): void; + } | null; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mixed-provider-team-live-')); + tempClaudeRoot = path.join(tempDir, '.claude'); + tempHome = path.join(tempDir, 'home'); + projectPath = path.join(tempDir, 'project'); + await fs.mkdir(tempClaudeRoot, { recursive: true }); + await fs.mkdir(tempHome, { recursive: true }); + await fs.mkdir(projectPath, { recursive: true }); + await fs.writeFile( + path.join(projectPath, 'README.md'), + '# Mixed provider team live e2e\n\nThis project is intentionally tiny.\n', + 'utf8' + ); + await writeTrustedClaudeConfig(tempClaudeRoot, projectPath); + setClaudeBasePathOverride(tempClaudeRoot); + + previousCliPath = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH; + previousCliFlavor = process.env.CLAUDE_TEAM_CLI_FLAVOR; + previousNudgeFlag = process.env.CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED; + previousCodexHome = process.env.CODEX_HOME; + previousHome = process.env.HOME; + previousUserProfile = process.env.USERPROFILE; + previousNodeEnv = process.env.NODE_ENV; + previousDisableAppBootstrap = process.env.CLAUDE_APP_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP; + previousDisableRuntimeBootstrap = process.env.CLAUDE_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP; + + process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH = + process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI; + process.env.CLAUDE_TEAM_CLI_FLAVOR = 'agent_teams_orchestrator'; + process.env.CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED = '0'; + process.env.CODEX_HOME = resolveConnectedCodexHome(previousCodexHome); + process.env.HOME = tempHome; + process.env.USERPROFILE = tempHome; + process.env.NODE_ENV = 'production'; + delete process.env.CLAUDE_APP_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP; + delete process.env.CLAUDE_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP; + + harness = null; + teamName = null; + codexAccountFeature = null; + providerConnectionService = null; + }); + + afterEach(async () => { + const keepProcesses = process.env.MIXED_PROVIDER_TEAM_LIVE_KEEP_PROCESSES === '1'; + if (!keepProcesses && harness && teamName) { + await harness.svc.stopTeam(teamName).catch(() => undefined); + await waitForOpenCodeLanesStopped(teamName, 90_000).catch(() => undefined); + } + providerConnectionService?.setCodexAccountFeature(null); + await codexAccountFeature?.dispose().catch(() => undefined); + if (!keepProcesses) { + await harness?.dispose().catch(() => undefined); + } + setClaudeBasePathOverride(null); + + restoreEnv('CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH', previousCliPath); + restoreEnv('CLAUDE_TEAM_CLI_FLAVOR', previousCliFlavor); + restoreEnv('CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED', previousNudgeFlag); + restoreEnv('CODEX_HOME', previousCodexHome); + restoreEnv('HOME', previousHome); + restoreEnv('USERPROFILE', previousUserProfile); + restoreEnv('NODE_ENV', previousNodeEnv); + restoreEnv('CLAUDE_APP_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP', previousDisableAppBootstrap); + restoreEnv('CLAUDE_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP', previousDisableRuntimeBootstrap); + + if (process.env.MIXED_PROVIDER_TEAM_LIVE_KEEP_TEMP === '1') { + process.stderr.write(`[MixedProviderTeamLaunch.live] preserved temp dir: ${tempDir}\n`); + } else { + await removeTempDirWithRetries(tempDir); + } + }, 180_000); + + it( + 'launches Anthropic, Codex subscription, and OpenCode teammates in one mixed team', + async () => { + 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')); + + const anthropicModel = + process.env.MIXED_PROVIDER_TEAM_ANTHROPIC_MODEL?.trim() || DEFAULT_ANTHROPIC_MODEL; + const codexModel = process.env.MIXED_PROVIDER_TEAM_CODEX_MODEL?.trim() || DEFAULT_CODEX_MODEL; + const codexEffort = + (process.env.MIXED_PROVIDER_TEAM_CODEX_EFFORT?.trim() as + | 'low' + | 'medium' + | 'high' + | 'xhigh' + | undefined) || 'low'; + const openCodeModel = + process.env.MIXED_PROVIDER_TEAM_OPENCODE_MODEL?.trim() || DEFAULT_OPENCODE_MODEL; + + const [ + { ProviderConnectionService }, + { createCodexAccountFeature }, + ] = await Promise.all([ + import('../../../../src/main/services/runtime/ProviderConnectionService'), + import('../../../../src/features/codex-account/main/composition/createCodexAccountFeature'), + ]); + + codexAccountFeature = createCodexAccountFeature({ + logger: { + info: () => undefined, + warn: () => undefined, + error: () => undefined, + }, + configManager: { + getConfig: () => ({ + providerConnections: { + codex: { + preferredAuthMode: 'chatgpt' as const, + }, + }, + }), + }, + }); + providerConnectionService = ProviderConnectionService.getInstance(); + providerConnectionService.setCodexAccountFeature(codexAccountFeature); + + harness = await createOpenCodeLiveHarness({ + tempDir, + selectedModel: openCodeModel, + projectPath, + }); + + teamName = `mixed-provider-live-${Date.now()}`; + const progressEvents: TeamProvisioningProgress[] = []; + + await harness.svc.createTeam( + { + teamName, + cwd: projectPath, + providerId: 'anthropic', + model: anthropicModel, + skipPermissions: true, + prompt: 'Keep the team idle after bootstrap. Do not start extra work.', + members: [ + { + name: 'alice', + role: 'Developer', + providerId: 'anthropic', + model: anthropicModel, + }, + { + name: 'cody', + role: 'Developer', + providerId: 'codex', + model: codexModel, + effort: codexEffort, + }, + { + name: 'oscar', + role: 'Developer', + providerId: 'opencode', + model: openCodeModel, + }, + ], + }, + (progress) => { + progressEvents.push(progress); + } + ); + + await waitUntil(async () => { + const last = progressEvents.at(-1); + if (last?.state === 'failed') { + throw new Error(formatProgressDump(progressEvents)); + } + return last?.state === 'ready'; + }, 360_000); + + await waitUntilWithDiagnostics(async () => { + const status = await harness!.svc.getMemberSpawnStatuses(teamName!); + if ( + status.teamLaunchState === 'failed' || + status.teamLaunchState === 'partial_failure' + ) { + throw new Error(await formatMixedLaunchDiagnostics(harness!, teamName!, progressEvents)); + } + for (const memberName of ['alice', 'cody', 'oscar'] as const) { + const member = status.statuses[memberName]; + if ( + member?.status !== 'online' || + member.launchState !== 'confirmed_alive' || + member.bootstrapConfirmed !== true + ) { + return false; + } + } + return true; + }, 180_000, () => formatMixedLaunchDiagnostics(harness!, teamName!, progressEvents)); + + await waitUntilWithDiagnostics(async () => { + const snapshot = await harness!.svc.getTeamAgentRuntimeSnapshot(teamName!); + return ( + snapshot.members.alice?.providerId === 'anthropic' && + snapshot.members.alice.alive === true && + snapshot.members.cody?.providerId === 'codex' && + snapshot.members.cody.alive === true && + snapshot.members.oscar?.providerId === 'opencode' && + snapshot.members.oscar.alive === true + ); + }, 180_000, () => formatMixedLaunchDiagnostics(harness!, teamName!, progressEvents)); + + const laneIndex = await readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName); + expect( + Object.values(laneIndex.lanes).some( + (lane) => lane.state === 'active' && lane.memberName === 'oscar' + ) + ).toBe(true); + }, + 480_000 + ); +}); + +function restoreEnv(name: string, previous: string | undefined): void { + if (previous === undefined) { + delete process.env[name]; + } else { + process.env[name] = previous; + } +} + +async function assertExecutable(filePath: string): Promise { + await fs.access(filePath, fsConstants.R_OK); +} + +async function writeTrustedClaudeConfig(configDir: string, projectPath: string): Promise { + const normalizedProjectPath = path.normalize(projectPath).replace(/\\/g, '/'); + const approvedApiKeySuffix = process.env.ANTHROPIC_API_KEY?.trim().slice(-20); + const config: { + projects: Record; + customApiKeyResponses?: { approved: string[]; rejected: string[] }; + } = { + projects: { + [normalizedProjectPath]: { + hasTrustDialogAccepted: true, + }, + }, + }; + if (approvedApiKeySuffix) { + config.customApiKeyResponses = { + approved: [approvedApiKeySuffix], + rejected: [], + }; + } + await fs.writeFile( + path.join(configDir, '.claude.json'), + `${JSON.stringify(config, null, 2)}\n`, + 'utf8' + ); +} + +function resolveConnectedCodexHome(previousCodexHome: string | undefined): string { + const explicit = process.env.MIXED_PROVIDER_TEAM_CODEX_HOME?.trim(); + if (explicit) { + return path.resolve(explicit); + } + const previous = previousCodexHome?.trim(); + if (previous) { + return path.resolve(previous); + } + return path.join(os.userInfo().homedir, '.codex'); +} + +async function removeTempDirWithRetries(dirPath: string): Promise { + const attempts = process.platform === 'win32' ? 20 : 1; + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + await fs.rm(dirPath, { recursive: true, force: true }); + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if ((code !== 'EBUSY' && code !== 'EPERM') || attempt === attempts) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } +} + +function formatProgressDump(progressEvents: TeamProvisioningProgress[]): string { + return redactSecrets( + progressEvents + .map((progress) => + [ + progress.state, + progress.message, + progress.messageSeverity, + progress.error, + progress.cliLogsTail, + ] + .filter(Boolean) + .join(' | ') + ) + .join('\n') + ); +} + +function redactSecrets(text: string): string { + return text + .replace(/sk-ant-api03-[A-Za-z0-9_-]+/g, '') + .replace(/\b(?:sk|ak)-[A-Za-z0-9_-]{20,}\b/g, ''); +} + +async function waitUntilWithDiagnostics( + predicate: () => Promise, + timeoutMs: number, + describeState: () => Promise, + pollMs = 1_000 +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await predicate()) { + return; + } + await new Promise((resolve) => setTimeout(resolve, pollMs)); + } + throw new Error(`Timed out after ${timeoutMs}ms waiting for condition.\n${await describeState()}`); +} + +async function formatMixedLaunchDiagnostics( + harness: OpenCodeLiveHarness, + teamName: string, + progressEvents: TeamProvisioningProgress[] +): Promise { + const [spawnStatuses, runtimeSnapshot, laneIndex] = await Promise.all([ + harness.svc.getMemberSpawnStatuses(teamName).catch((error) => ({ + error: String(error), + })), + harness.svc.getTeamAgentRuntimeSnapshot(teamName).catch((error) => ({ + error: String(error), + })), + readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName).catch((error) => ({ + error: String(error), + })), + ]); + return redactSecrets( + JSON.stringify( + { + progress: formatProgressDump(progressEvents), + spawnStatuses, + runtimeSnapshot, + laneIndex, + }, + null, + 2 + ) + ); +} diff --git a/test/main/services/team/OpenCodeMixedRecovery.live.test.ts b/test/main/services/team/OpenCodeMixedRecovery.live.test.ts index 4a34dfaf..841334c7 100644 --- a/test/main/services/team/OpenCodeMixedRecovery.live.test.ts +++ b/test/main/services/team/OpenCodeMixedRecovery.live.test.ts @@ -77,6 +77,7 @@ liveDescribe('OpenCode mixed recovery live e2e', () => { ...createStableBridgeEnv(), PATH: withBunOnPath(process.env.PATH ?? ''), XDG_DATA_HOME: path.join(tempDir, 'xdg-data-single'), + AGENT_TEAMS_MCP_CLAUDE_DIR: tempClaudeRoot, CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND: mcpLaunchSpec.command, CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: mcpLaunchSpec.args[0] ?? '', CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON: JSON.stringify(mcpLaunchSpec.args), @@ -119,7 +120,7 @@ liveDescribe('OpenCode mixed recovery live e2e', () => { }); launchedLanes.push(launchInput); const launchResult = await adapter.launch(launchInput); - expect(launchResult.teamLaunchState).toBe('clean_success'); + expectCleanOpenCodeLaunch(launchResult); expect(launchResult.members.bob).toMatchObject({ launchState: 'confirmed_alive', runtimeAlive: true, @@ -181,6 +182,7 @@ liveDescribe('OpenCode mixed recovery live e2e', () => { ...createStableBridgeEnv(), PATH: withBunOnPath(process.env.PATH ?? ''), XDG_DATA_HOME: path.join(tempDir, 'xdg-data-multi'), + AGENT_TEAMS_MCP_CLAUDE_DIR: tempClaudeRoot, CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND: mcpLaunchSpec.command, CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: mcpLaunchSpec.args[0] ?? '', CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON: JSON.stringify(mcpLaunchSpec.args), @@ -225,7 +227,7 @@ liveDescribe('OpenCode mixed recovery live e2e', () => { }); launchedLanes.push(launchInput); const launchResult = await adapter.launch(launchInput); - expect(launchResult.teamLaunchState).toBe('clean_success'); + expectCleanOpenCodeLaunch(launchResult); expect(launchResult.members[memberName]).toMatchObject({ launchState: 'confirmed_alive', runtimeAlive: true, @@ -310,6 +312,21 @@ function createSecondaryLaneLaunchInput(input: { }; } +function expectCleanOpenCodeLaunch( + launchResult: Awaited> +): void { + if (launchResult.teamLaunchState !== 'clean_success') { + throw new Error( + `Expected OpenCode launch to be clean_success, received ${launchResult.teamLaunchState}:\n${JSON.stringify( + launchResult, + null, + 2 + )}` + ); + } + expect(launchResult.teamLaunchState).toBe('clean_success'); +} + async function writeMixedRecoveryFixtures(input: { teamName: string; projectPath: string; diff --git a/test/main/services/team/OpenCodeTeamProvisioning.live.test.ts b/test/main/services/team/OpenCodeTeamProvisioning.live.test.ts index 0058beff..f7c0f2d9 100644 --- a/test/main/services/team/OpenCodeTeamProvisioning.live.test.ts +++ b/test/main/services/team/OpenCodeTeamProvisioning.live.test.ts @@ -67,6 +67,7 @@ liveDescribe('OpenCode team provisioning live e2e', () => { ...createStableBridgeEnv(), PATH: withBunOnPath(process.env.PATH ?? ''), XDG_DATA_HOME: path.join(tempDir, 'xdg-data'), + AGENT_TEAMS_MCP_CLAUDE_DIR: tempClaudeRoot, CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND: mcpLaunchSpec.command, CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: mcpLaunchSpec.args[0] ?? '', CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON: JSON.stringify(mcpLaunchSpec.args), diff --git a/test/main/services/team/TeamProvisioningServicePrepare.test.ts b/test/main/services/team/TeamProvisioningServicePrepare.test.ts index 53cddd7f..a392f1d0 100644 --- a/test/main/services/team/TeamProvisioningServicePrepare.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrepare.test.ts @@ -95,7 +95,10 @@ vi.mock('@main/utils/childProcess', () => ({ killProcessTree: vi.fn(), })); -import { TeamProvisioningService } from '@main/services/team/TeamProvisioningService'; +import { + TeamProvisioningService, + buildDirectTmuxRestartEnvAssignments, +} from '@main/services/team/TeamProvisioningService'; import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver'; import { TeamRuntimeAdapterRegistry } from '@main/services/team/runtime'; import { ProviderConnectionService } from '@main/services/runtime/ProviderConnectionService'; @@ -347,6 +350,91 @@ describe('TeamProvisioningService prepare/auth behavior', () => { delete process.env.ANTHROPIC_AUTH_TOKEN; }); + it('blanks Anthropic auth carriers for direct tmux restart in helper mode', () => { + const assignments = buildDirectTmuxRestartEnvAssignments( + { + CLAUDE_TEAM_ANTHROPIC_AUTH_MODE: 'api_key_helper', + CLAUDE_TEAM_ANTHROPIC_API_KEY_HELPER_SETTINGS_PATH: + '/tmp/team-runtime-auth/demo/runtime-settings-anthropic.json', + ANTHROPIC_API_KEY: 'sk-ant-direct-restart-should-not-leak', + ANTHROPIC_AUTH_TOKEN: 'direct-restart-token-should-not-leak', + CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR: '3', + CLAUDE_CODE_OAUTH_TOKEN: 'direct-restart-oauth-token-should-not-leak', + CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR: '4', + }, + 'anthropic' + ); + + expect(assignments).toContain("CLAUDE_TEAM_ANTHROPIC_AUTH_MODE='api_key_helper'"); + expect(assignments).toContain( + "CLAUDE_TEAM_ANTHROPIC_API_KEY_HELPER_SETTINGS_PATH='/tmp/team-runtime-auth/demo/runtime-settings-anthropic.json'" + ); + expect(assignments).toContain("ANTHROPIC_API_KEY=''"); + expect(assignments).toContain("ANTHROPIC_AUTH_TOKEN=''"); + expect(assignments).toContain("CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR=''"); + expect(assignments).toContain("CLAUDE_CODE_OAUTH_TOKEN=''"); + expect(assignments).toContain("CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR=''"); + expect(assignments).not.toContain('sk-ant-direct-restart-should-not-leak'); + expect(assignments).not.toContain('direct-restart-token-should-not-leak'); + expect(assignments).not.toContain('direct-restart-oauth-token-should-not-leak'); + }); + + it('preserves CODEX_HOME for direct tmux restart even when Codex API keys are blanked', () => { + const assignments = buildDirectTmuxRestartEnvAssignments( + { + CODEX_HOME: '/tmp/codex-connected-home', + CODEX_API_KEY: '', + OPENAI_API_KEY: '', + }, + 'codex' + ); + + expect(assignments).toContain("CODEX_HOME='/tmp/codex-connected-home'"); + }); + + it('does not flatten Anthropic helper settings into non-Anthropic lead cross-provider args', async () => { + const svc = new TeamProvisioningService(); + const helperSettingsPath = path.join(tempRoot, 'team-runtime-auth', 'helper-settings.json'); + vi.spyOn(svc as any, 'buildProvisioningEnv').mockResolvedValue({ + env: { + CLAUDE_TEAM_ANTHROPIC_AUTH_MODE: 'api_key_helper', + CLAUDE_TEAM_ANTHROPIC_API_KEY_HELPER_SETTINGS_PATH: helperSettingsPath, + }, + authSource: 'anthropic_api_key_helper', + geminiRuntimeAuth: null, + providerArgs: ['--settings', helperSettingsPath, '--anthropic-safe-passthrough'], + anthropicApiKeyHelper: { + teamName: 'mixed-team', + directory: path.dirname(helperSettingsPath), + helperPath: path.join(tempRoot, 'helper.sh'), + keyPath: path.join(tempRoot, 'key'), + settingsPath: helperSettingsPath, + settingsObject: { apiKeyHelper: "'/tmp/helper.sh'" }, + settingsArgs: ['--settings', helperSettingsPath], + envPatch: { + CLAUDE_TEAM_ANTHROPIC_AUTH_MODE: 'api_key_helper', + CLAUDE_TEAM_ANTHROPIC_API_KEY_HELPER_SETTINGS_PATH: helperSettingsPath, + }, + }, + }); + + const result = await (svc as any).buildCrossProviderMemberArgs( + 'codex', + [{ name: 'alice', providerId: 'anthropic', model: 'opus' }], + { teamRuntimeAuth: { teamName: 'mixed-team', authMaterialId: 'run-1' } } + ); + + expect(result.usesAnthropicApiKeyHelper).toBe(true); + expect(result.envPatch.CLAUDE_TEAM_ANTHROPIC_AUTH_MODE).toBe('api_key_helper'); + expect(result.args).toContain('--anthropic-safe-passthrough'); + expect(result.args).not.toContain(helperSettingsPath); + expect(result.providerArgsByProvider.get('anthropic')).toEqual([ + '--settings', + helperSettingsPath, + '--anthropic-safe-passthrough', + ]); + }); + afterEach(async () => { await removeTempRoot(tempRoot); });