fix(team): stabilize mixed provider runtime auth
This commit is contained in:
parent
e500d26a34
commit
eda62c3ab3
14 changed files with 2012 additions and 56 deletions
273
docs/ideas/codeboarding-integration.md
Normal file
273
docs/ideas/codeboarding-integration.md
Normal 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 должен быть нашим.
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
353
src/main/services/runtime/anthropicTeamApiKeyHelper.ts
Normal file
353
src/main/services/runtime/anthropicTeamApiKeyHelper.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
160
src/main/services/runtime/teamRuntimeSettingsBundle.ts
Normal file
160
src/main/services/runtime/teamRuntimeSettingsBundle.ts
Normal 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],
|
||||
};
|
||||
}
|
||||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
90
test/main/services/runtime/anthropicTeamApiKeyHelper.test.ts
Normal file
90
test/main/services/runtime/anthropicTeamApiKeyHelper.test.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
74
test/main/services/runtime/teamRuntimeSettingsBundle.test.ts
Normal file
74
test/main/services/runtime/teamRuntimeSettingsBundle.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
414
test/main/services/team/MixedProviderTeamLaunch.live.test.ts
Normal file
414
test/main/services/team/MixedProviderTeamLaunch.live.test.ts
Normal 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
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue