fix(team): stabilize mixed provider runtime auth

This commit is contained in:
777genius 2026-05-03 23:51:27 +03:00
parent e500d26a34
commit eda62c3ab3
14 changed files with 2012 additions and 56 deletions

View file

@ -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 <project>`;
- incremental анализ через `codeboarding incremental --local <project>`;
- partial обновление компонента через `codeboarding partial --local <project> --component-id <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 <project>`.
7. App читает `.codeboarding/analysis.json`.
8. Показывает diagram/docs.
9. Когда агент меняет файлы, app быстро подсвечивает affected components по baseline mapping.
10. После debounce или завершения task запускает `codeboarding incremental --local <project>`.
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 должен быть нашим.

View file

@ -175,6 +175,20 @@ export class ProviderConnectionService {
return null;
}
async getConfiguredAnthropicApiKeyForTeamRuntime(env: NodeJS.ProcessEnv): Promise<string | null> {
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,

View file

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

View file

@ -1,4 +1,4 @@
type JsonObject = Record<string, unknown>;
export type JsonObject = Record<string, unknown>;
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;
}

View file

@ -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<string, unknown>;
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<string, unknown>) };
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<void> {
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<TeamRuntimeSettingsJson | null | undefined>;
anthropicHelper?: AnthropicTeamApiKeyHelperMaterial | null;
}): Promise<TeamRuntimeSettingsBundle | null> {
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<TeamRuntimeSettingsJson>(
(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],
};
}

View file

@ -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<string, ToolApprovalRequest>;
/** 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<TeamProviderId, string[]>;
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<TeamConfig | null> {
@ -5094,23 +5222,94 @@ export class TeamProvisioningService {
private async buildRuntimeTurnSettledHookSettingsArgs(
providerId: TeamProviderId
): Promise<string[]> {
const settings = await this.buildRuntimeTurnSettledHookSettingsObject(providerId);
return settings ? ['--settings', JSON.stringify(settings)] : [];
}
private async buildRuntimeTurnSettledHookSettingsObject(
providerId: TeamProviderId
): Promise<TeamRuntimeSettingsJson | null> {
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<TeamRuntimeLaunchArgsPlan> {
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<Record<string, string>> {
@ -5499,6 +5698,7 @@ export class TeamProvisioningService {
'providerId' | 'providerBackendId' | 'model' | 'effort' | 'fastMode' | 'limitContext'
>;
effectiveMembers: TeamCreateRequest['members'];
providerArgsByProvider?: Map<TeamProviderId, string[]>;
}): Promise<ProviderModelLaunchIdentity> {
const leadProviderId = resolveTeamProviderId(params.request.providerId);
const factsByProvider = new Map<TeamProviderId, RuntimeProviderLaunchFacts>();
@ -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<TeamCreateRequest['members']> {
const envByProvider = new Map<TeamProviderId, Promise<ProvisioningEnvResolution>>();
@ -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<TeamProviderId, string[]>([
[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<TeamProviderId, string[]>([
[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<void> {
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<ProvisioningEnvResolution> {
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<string[]> {
memberSpecs: TeamCreateRequest['members'],
options?: { teamRuntimeAuth?: TeamRuntimeAuthContext }
): Promise<CrossProviderMemberArgsResult> {
const crossProviderIds = new Set<TeamProviderId>();
for (const member of memberSpecs) {
const memberId = resolveTeamProviderId(
@ -25665,12 +26053,27 @@ export class TeamProvisioningService {
}
}
const args: string[] = [];
const providerArgsByProvider = new Map<TeamProviderId, string[]>();
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<string | null> {

View file

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

View file

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

View file

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

View file

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

View file

@ -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<unknown>; dispose(): Promise<void> } | null;
let providerConnectionService: {
setCodexAccountFeature(feature: { getSnapshot(): Promise<unknown> } | 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<void> {
await fs.access(filePath, fsConstants.R_OK);
}
async function writeTrustedClaudeConfig(configDir: string, projectPath: string): Promise<void> {
const normalizedProjectPath = path.normalize(projectPath).replace(/\\/g, '/');
const approvedApiKeySuffix = process.env.ANTHROPIC_API_KEY?.trim().slice(-20);
const config: {
projects: Record<string, { hasTrustDialogAccepted: true }>;
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<void> {
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, '<redacted-anthropic-key>')
.replace(/\b(?:sk|ak)-[A-Za-z0-9_-]{20,}\b/g, '<redacted-api-key>');
}
async function waitUntilWithDiagnostics(
predicate: () => Promise<boolean>,
timeoutMs: number,
describeState: () => Promise<string>,
pollMs = 1_000
): Promise<void> {
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<string> {
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
)
);
}

View file

@ -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<ReturnType<OpenCodeTeamRuntimeAdapter['launch']>>
): 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;

View file

@ -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),

View file

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