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;
|
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(
|
async applyConfiguredConnectionEnv(
|
||||||
env: NodeJS.ProcessEnv,
|
env: NodeJS.ProcessEnv,
|
||||||
providerId: CliProviderId,
|
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[];
|
type JsonArray = unknown[];
|
||||||
|
|
||||||
|
|
@ -6,7 +6,7 @@ function isJsonObject(value: unknown): value is JsonObject {
|
||||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseJsonSettingsObject(raw: string): JsonObject | null {
|
export function parseJsonSettingsObject(raw: string): JsonObject | null {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(raw) as unknown;
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
return isJsonObject(parsed) ? parsed : null;
|
return isJsonObject(parsed) ? parsed : null;
|
||||||
|
|
@ -73,7 +73,7 @@ function mergeHooksObject(target: JsonObject, source: JsonObject): JsonObject {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (isJsonObject(currentValue) && isJsonObject(sourceValue)) {
|
if (isJsonObject(currentValue) && isJsonObject(sourceValue)) {
|
||||||
merged[hookName] = deepMergeJsonObjects(currentValue, sourceValue);
|
merged[hookName] = mergeJsonSettingsObjects(currentValue, sourceValue);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
merged[hookName] = sourceValue;
|
merged[hookName] = sourceValue;
|
||||||
|
|
@ -81,7 +81,7 @@ function mergeHooksObject(target: JsonObject, source: JsonObject): JsonObject {
|
||||||
return merged;
|
return merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
function deepMergeJsonObjects(target: JsonObject, source: JsonObject): JsonObject {
|
export function mergeJsonSettingsObjects(target: JsonObject, source: JsonObject): JsonObject {
|
||||||
const merged: JsonObject = { ...target };
|
const merged: JsonObject = { ...target };
|
||||||
for (const [key, value] of Object.entries(source)) {
|
for (const [key, value] of Object.entries(source)) {
|
||||||
const current = merged[key];
|
const current = merged[key];
|
||||||
|
|
@ -90,7 +90,7 @@ function deepMergeJsonObjects(target: JsonObject, source: JsonObject): JsonObjec
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (isJsonObject(current) && isJsonObject(value)) {
|
if (isJsonObject(current) && isJsonObject(value)) {
|
||||||
merged[key] = deepMergeJsonObjects(current, value);
|
merged[key] = mergeJsonSettingsObjects(current, value);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
merged[key] = value;
|
merged[key] = value;
|
||||||
|
|
@ -120,7 +120,7 @@ export function mergeJsonSettingsArgs(args: string[]): string[] {
|
||||||
if (firstSettingsIndex === null) {
|
if (firstSettingsIndex === null) {
|
||||||
firstSettingsIndex = output.length;
|
firstSettingsIndex = output.length;
|
||||||
}
|
}
|
||||||
mergedSettings = deepMergeJsonObjects(mergedSettings ?? {}, parsed);
|
mergedSettings = mergeJsonSettingsObjects(mergedSettings ?? {}, parsed);
|
||||||
i += 2;
|
i += 2;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -137,7 +137,7 @@ export function mergeJsonSettingsArgs(args: string[]): string[] {
|
||||||
if (firstSettingsIndex === null) {
|
if (firstSettingsIndex === null) {
|
||||||
firstSettingsIndex = output.length;
|
firstSettingsIndex = output.length;
|
||||||
}
|
}
|
||||||
mergedSettings = deepMergeJsonObjects(mergedSettings ?? {}, parsed);
|
mergedSettings = mergeJsonSettingsObjects(mergedSettings ?? {}, parsed);
|
||||||
i += 1;
|
i += 1;
|
||||||
continue;
|
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 pidusage from 'pidusage';
|
||||||
import * as readline from 'readline';
|
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 {
|
import {
|
||||||
type GeminiRuntimeAuthState,
|
type GeminiRuntimeAuthState,
|
||||||
resolveGeminiRuntimeAuth,
|
resolveGeminiRuntimeAuth,
|
||||||
|
|
@ -123,6 +136,11 @@ import {
|
||||||
normalizeProviderModelProbeFailureReason,
|
normalizeProviderModelProbeFailureReason,
|
||||||
} from '../runtime/providerModelProbe';
|
} from '../runtime/providerModelProbe';
|
||||||
import { resolveTeamProviderId } from '../runtime/providerRuntimeEnv';
|
import { resolveTeamProviderId } from '../runtime/providerRuntimeEnv';
|
||||||
|
import {
|
||||||
|
materializeTeamRuntimeSettingsBundle,
|
||||||
|
splitSettingsJsonArgs,
|
||||||
|
type TeamRuntimeSettingsJson,
|
||||||
|
} from '../runtime/teamRuntimeSettingsBundle';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createOpenCodePromptDeliveryLedgerStore,
|
createOpenCodePromptDeliveryLedgerStore,
|
||||||
|
|
@ -678,6 +696,9 @@ const DIRECT_TMUX_RESTART_ENV_KEYS = [
|
||||||
'CLAUDE_CODE_ENTRY_PROVIDER',
|
'CLAUDE_CODE_ENTRY_PROVIDER',
|
||||||
'CLAUDE_CODE_GEMINI_BACKEND',
|
'CLAUDE_CODE_GEMINI_BACKEND',
|
||||||
'CLAUDE_CODE_CODEX_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_BASE_URL',
|
||||||
'ANTHROPIC_API_KEY',
|
'ANTHROPIC_API_KEY',
|
||||||
'ANTHROPIC_AUTH_TOKEN',
|
'ANTHROPIC_AUTH_TOKEN',
|
||||||
|
|
@ -809,7 +830,7 @@ function getDirectRestartEntryProvider(providerId: TeamProviderId): string {
|
||||||
return providerId === 'codex' || providerId === 'gemini' ? providerId : 'anthropic';
|
return providerId === 'codex' || providerId === 'gemini' ? providerId : 'anthropic';
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildDirectTmuxRestartEnvAssignments(
|
export function buildDirectTmuxRestartEnvAssignments(
|
||||||
env: NodeJS.ProcessEnv,
|
env: NodeJS.ProcessEnv,
|
||||||
providerId: TeamProviderId
|
providerId: TeamProviderId
|
||||||
): string {
|
): string {
|
||||||
|
|
@ -829,6 +850,22 @@ function buildDirectTmuxRestartEnvAssignments(
|
||||||
}
|
}
|
||||||
assignments.set('CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST', '1');
|
assignments.set('CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST', '1');
|
||||||
assignments.set('CLAUDE_CODE_ENTRY_PROVIDER', getDirectRestartEntryProvider(providerId));
|
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(' ');
|
return [...assignments.entries()].map(([key, value]) => `${key}=${shellQuote(value)}`).join(' ');
|
||||||
}
|
}
|
||||||
|
|
@ -1012,15 +1049,15 @@ function resolveCodexSelectionFromFacts(params: {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildAnthropicSettingsArgs(
|
function buildAnthropicSettingsObject(
|
||||||
providerId: TeamProviderId,
|
providerId: TeamProviderId,
|
||||||
launchIdentity?: ProviderModelLaunchIdentity | null
|
launchIdentity?: ProviderModelLaunchIdentity | null
|
||||||
): string[] {
|
): TeamRuntimeSettingsJson | null {
|
||||||
if (providerId !== 'anthropic' || typeof launchIdentity?.resolvedFastMode !== 'boolean') {
|
if (providerId !== 'anthropic' || typeof launchIdentity?.resolvedFastMode !== 'boolean') {
|
||||||
return [];
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const settings = launchIdentity.resolvedFastMode
|
return launchIdentity.resolvedFastMode
|
||||||
? {
|
? {
|
||||||
fastMode: true,
|
fastMode: true,
|
||||||
fastModePerSessionOptIn: false,
|
fastModePerSessionOptIn: false,
|
||||||
|
|
@ -1028,6 +1065,16 @@ function buildAnthropicSettingsArgs(
|
||||||
: {
|
: {
|
||||||
fastMode: false,
|
fastMode: false,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAnthropicSettingsArgs(
|
||||||
|
providerId: TeamProviderId,
|
||||||
|
launchIdentity?: ProviderModelLaunchIdentity | null
|
||||||
|
): string[] {
|
||||||
|
const settings = buildAnthropicSettingsObject(providerId, launchIdentity);
|
||||||
|
if (!settings) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
return ['--settings', JSON.stringify(settings)];
|
return ['--settings', JSON.stringify(settings)];
|
||||||
}
|
}
|
||||||
|
|
@ -1045,6 +1092,52 @@ function buildProviderFastModeArgs(
|
||||||
return [];
|
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 {
|
function isProbeTimeoutMessage(message: string): boolean {
|
||||||
const lower = message.toLowerCase();
|
const lower = message.toLowerCase();
|
||||||
return (
|
return (
|
||||||
|
|
@ -1543,6 +1636,8 @@ interface ProvisioningRun {
|
||||||
env: NodeJS.ProcessEnv;
|
env: NodeJS.ProcessEnv;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
} | null;
|
} | 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). */
|
/** Pending tool approval requests awaiting user response (control_request protocol). */
|
||||||
pendingApprovals: Map<string, ToolApprovalRequest>;
|
pendingApprovals: Map<string, ToolApprovalRequest>;
|
||||||
/** Teammate permission_request IDs already intercepted (prevents re-processing read messages). */
|
/** Teammate permission_request IDs already intercepted (prevents re-processing read messages). */
|
||||||
|
|
@ -1661,6 +1756,7 @@ function createUnexpectedMixedSecondaryLaneFailureResult(input: {
|
||||||
type LeadActivityState = 'active' | 'idle' | 'offline';
|
type LeadActivityState = 'active' | 'idle' | 'offline';
|
||||||
|
|
||||||
type ProvisioningAuthSource =
|
type ProvisioningAuthSource =
|
||||||
|
| 'anthropic_api_key_helper'
|
||||||
| 'anthropic_api_key'
|
| 'anthropic_api_key'
|
||||||
| 'anthropic_auth_token'
|
| 'anthropic_auth_token'
|
||||||
| 'configured_api_key_missing'
|
| 'configured_api_key_missing'
|
||||||
|
|
@ -1668,14 +1764,36 @@ type ProvisioningAuthSource =
|
||||||
| 'gemini_runtime'
|
| 'gemini_runtime'
|
||||||
| 'none';
|
| 'none';
|
||||||
|
|
||||||
|
interface TeamRuntimeAuthContext {
|
||||||
|
teamName?: string;
|
||||||
|
authMaterialId?: string;
|
||||||
|
allowAnthropicApiKeyHelper?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface ProvisioningEnvResolution {
|
interface ProvisioningEnvResolution {
|
||||||
env: NodeJS.ProcessEnv;
|
env: NodeJS.ProcessEnv;
|
||||||
authSource: ProvisioningAuthSource;
|
authSource: ProvisioningAuthSource;
|
||||||
geminiRuntimeAuth: GeminiRuntimeAuthState | null;
|
geminiRuntimeAuth: GeminiRuntimeAuthState | null;
|
||||||
providerArgs?: string[];
|
providerArgs?: string[];
|
||||||
|
anthropicApiKeyHelper?: AnthropicTeamApiKeyHelperMaterial | null;
|
||||||
warning?: string;
|
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 {
|
interface PromptSizeSummary {
|
||||||
chars: number;
|
chars: number;
|
||||||
lines: number;
|
lines: number;
|
||||||
|
|
@ -4871,6 +4989,16 @@ export class TeamProvisioningService {
|
||||||
this.transcriptProjectResolver = new TeamTranscriptProjectResolver({
|
this.transcriptProjectResolver = new TeamTranscriptProjectResolver({
|
||||||
getConfig: (teamName) => this.configReader.getConfigSnapshot(teamName),
|
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> {
|
private async readConfigSnapshot(teamName: string): Promise<TeamConfig | null> {
|
||||||
|
|
@ -5094,23 +5222,94 @@ export class TeamProvisioningService {
|
||||||
private async buildRuntimeTurnSettledHookSettingsArgs(
|
private async buildRuntimeTurnSettledHookSettingsArgs(
|
||||||
providerId: TeamProviderId
|
providerId: TeamProviderId
|
||||||
): Promise<string[]> {
|
): 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) {
|
if (providerId !== 'anthropic' || !this.runtimeTurnSettledHookSettingsProvider) {
|
||||||
return [];
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const settings = await this.runtimeTurnSettledHookSettingsProvider({ provider: 'claude' });
|
const settings = await this.runtimeTurnSettledHookSettingsProvider({ provider: 'claude' });
|
||||||
return settings ? ['--settings', JSON.stringify(settings)] : [];
|
return settings ?? null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`Failed to build member work sync Stop hook settings: ${
|
`Failed to build member work sync Stop hook settings: ${
|
||||||
error instanceof Error ? error.message : String(error)
|
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(
|
private async buildRuntimeTurnSettledEnvironment(
|
||||||
providerId: TeamProviderId
|
providerId: TeamProviderId
|
||||||
): Promise<Record<string, string>> {
|
): Promise<Record<string, string>> {
|
||||||
|
|
@ -5499,6 +5698,7 @@ export class TeamProvisioningService {
|
||||||
'providerId' | 'providerBackendId' | 'model' | 'effort' | 'fastMode' | 'limitContext'
|
'providerId' | 'providerBackendId' | 'model' | 'effort' | 'fastMode' | 'limitContext'
|
||||||
>;
|
>;
|
||||||
effectiveMembers: TeamCreateRequest['members'];
|
effectiveMembers: TeamCreateRequest['members'];
|
||||||
|
providerArgsByProvider?: Map<TeamProviderId, string[]>;
|
||||||
}): Promise<ProviderModelLaunchIdentity> {
|
}): Promise<ProviderModelLaunchIdentity> {
|
||||||
const leadProviderId = resolveTeamProviderId(params.request.providerId);
|
const leadProviderId = resolveTeamProviderId(params.request.providerId);
|
||||||
const factsByProvider = new Map<TeamProviderId, RuntimeProviderLaunchFacts>();
|
const factsByProvider = new Map<TeamProviderId, RuntimeProviderLaunchFacts>();
|
||||||
|
|
@ -5512,6 +5712,7 @@ export class TeamProvisioningService {
|
||||||
cwd: params.cwd,
|
cwd: params.cwd,
|
||||||
providerId,
|
providerId,
|
||||||
env: params.env,
|
env: params.env,
|
||||||
|
providerArgs: params.providerArgsByProvider?.get(providerId),
|
||||||
limitContext: params.request.limitContext,
|
limitContext: params.request.limitContext,
|
||||||
});
|
});
|
||||||
factsByProvider.set(providerId, facts);
|
factsByProvider.set(providerId, facts);
|
||||||
|
|
@ -11444,7 +11645,14 @@ export class TeamProvisioningService {
|
||||||
|
|
||||||
const provisioningEnv = await this.buildProvisioningEnv(
|
const provisioningEnv = await this.buildProvisioningEnv(
|
||||||
providerId,
|
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) {
|
if (provisioningEnv.warning) {
|
||||||
throw new Error(provisioningEnv.warning);
|
throw new Error(provisioningEnv.warning);
|
||||||
|
|
@ -11474,8 +11682,15 @@ export class TeamProvisioningService {
|
||||||
input.leadName
|
input.leadName
|
||||||
);
|
);
|
||||||
const bootstrapExpectedAfter = nowIso();
|
const bootstrapExpectedAfter = nowIso();
|
||||||
const runtimeTurnSettledHookArgs =
|
const runtimeArgsPlan = await this.buildTeamRuntimeLaunchArgsPlan({
|
||||||
await this.buildRuntimeTurnSettledHookSettingsArgs(providerId);
|
teamName: input.teamName,
|
||||||
|
providerId,
|
||||||
|
launchIdentity: null,
|
||||||
|
envResolution: provisioningEnv,
|
||||||
|
extraArgs: [],
|
||||||
|
includeAnthropicHelper: providerId === 'anthropic',
|
||||||
|
contextLabel: `Direct teammate restart (${input.configuredMember.name})`,
|
||||||
|
});
|
||||||
|
|
||||||
const runtimeArgs = mergeJsonSettingsArgs([
|
const runtimeArgs = mergeJsonSettingsArgs([
|
||||||
'--agent-id',
|
'--agent-id',
|
||||||
|
|
@ -11501,8 +11716,10 @@ export class TeamProvisioningService {
|
||||||
: ['--permission-prompt-tool', 'stdio', '--permission-mode', 'default']),
|
: ['--permission-prompt-tool', 'stdio', '--permission-mode', 'default']),
|
||||||
...(input.configuredMember.model ? ['--model', input.configuredMember.model] : []),
|
...(input.configuredMember.model ? ['--model', input.configuredMember.model] : []),
|
||||||
...(input.configuredMember.effort ? ['--effort', input.configuredMember.effort] : []),
|
...(input.configuredMember.effort ? ['--effort', input.configuredMember.effort] : []),
|
||||||
...runtimeTurnSettledHookArgs,
|
...runtimeArgsPlan.fastModeArgs,
|
||||||
...(provisioningEnv.providerArgs ?? []),
|
...runtimeArgsPlan.runtimeTurnSettledHookArgs,
|
||||||
|
...runtimeArgsPlan.providerArgs,
|
||||||
|
...runtimeArgsPlan.settingsArgs,
|
||||||
]);
|
]);
|
||||||
const command = buildDirectTmuxRestartCommand({
|
const command = buildDirectTmuxRestartCommand({
|
||||||
cwd,
|
cwd,
|
||||||
|
|
@ -13438,6 +13655,7 @@ export class TeamProvisioningService {
|
||||||
};
|
};
|
||||||
primaryProviderId?: TeamProviderId;
|
primaryProviderId?: TeamProviderId;
|
||||||
primaryEnv?: ProvisioningEnvResolution;
|
primaryEnv?: ProvisioningEnvResolution;
|
||||||
|
teamRuntimeAuth?: TeamRuntimeAuthContext;
|
||||||
limitContext?: boolean;
|
limitContext?: boolean;
|
||||||
}): Promise<TeamCreateRequest['members']> {
|
}): Promise<TeamCreateRequest['members']> {
|
||||||
const envByProvider = new Map<TeamProviderId, Promise<ProvisioningEnvResolution>>();
|
const envByProvider = new Map<TeamProviderId, Promise<ProvisioningEnvResolution>>();
|
||||||
|
|
@ -13454,7 +13672,9 @@ export class TeamProvisioningService {
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
const created = this.buildProvisioningEnv(providerId);
|
const created = this.buildProvisioningEnv(providerId, undefined, {
|
||||||
|
teamRuntimeAuth: params.teamRuntimeAuth,
|
||||||
|
});
|
||||||
envByProvider.set(providerId, created);
|
envByProvider.set(providerId, created);
|
||||||
return created;
|
return created;
|
||||||
};
|
};
|
||||||
|
|
@ -14481,10 +14701,16 @@ export class TeamProvisioningService {
|
||||||
throw new Error('Claude CLI not found; install it or provide a valid path');
|
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(
|
const provisioningEnv = await this.buildProvisioningEnv(
|
||||||
request.providerId,
|
request.providerId,
|
||||||
request.providerBackendId,
|
request.providerBackendId,
|
||||||
{ includeCodexTeammateAuth: teamRequestIncludesCodexMember(request) }
|
{ includeCodexTeammateAuth: teamRequestIncludesCodexMember(request), teamRuntimeAuth }
|
||||||
);
|
);
|
||||||
const {
|
const {
|
||||||
env: shellEnv,
|
env: shellEnv,
|
||||||
|
|
@ -14506,6 +14732,7 @@ export class TeamProvisioningService {
|
||||||
},
|
},
|
||||||
primaryProviderId: request.providerId,
|
primaryProviderId: request.providerId,
|
||||||
primaryEnv: provisioningEnv,
|
primaryEnv: provisioningEnv,
|
||||||
|
teamRuntimeAuth,
|
||||||
limitContext: request.limitContext,
|
limitContext: request.limitContext,
|
||||||
});
|
});
|
||||||
const allEffectiveMemberSpecs = await this.resolveOpenCodeMemberWorkspacesForRuntime({
|
const allEffectiveMemberSpecs = await this.resolveOpenCodeMemberWorkspacesForRuntime({
|
||||||
|
|
@ -14526,12 +14753,29 @@ export class TeamProvisioningService {
|
||||||
const effectiveMemberSpecs = allEffectiveMemberSpecs.filter((member) =>
|
const effectiveMemberSpecs = allEffectiveMemberSpecs.filter((member) =>
|
||||||
primaryMemberNames.has(member.name)
|
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({
|
const launchIdentity = await this.resolveAndValidateLaunchIdentity({
|
||||||
claudePath,
|
claudePath,
|
||||||
cwd: request.cwd,
|
cwd: request.cwd,
|
||||||
env: shellEnv,
|
env: shellEnv,
|
||||||
request,
|
request,
|
||||||
effectiveMembers: effectiveMemberSpecs,
|
effectiveMembers: effectiveMemberSpecs,
|
||||||
|
providerArgsByProvider,
|
||||||
});
|
});
|
||||||
const runId = randomUUID();
|
const runId = randomUUID();
|
||||||
const startedAt = nowIso();
|
const startedAt = nowIso();
|
||||||
|
|
@ -14600,6 +14844,7 @@ export class TeamProvisioningService {
|
||||||
authFailureRetried: false,
|
authFailureRetried: false,
|
||||||
authRetryInProgress: false,
|
authRetryInProgress: false,
|
||||||
spawnContext: null,
|
spawnContext: null,
|
||||||
|
anthropicApiKeyHelper: provisioningEnv.anthropicApiKeyHelper ?? null,
|
||||||
pendingApprovals: new Map(),
|
pendingApprovals: new Map(),
|
||||||
processedPermissionRequestIds: new Set(),
|
processedPermissionRequestIds: new Set(),
|
||||||
pendingPostCompactReminder: false,
|
pendingPostCompactReminder: false,
|
||||||
|
|
@ -14684,6 +14929,11 @@ export class TeamProvisioningService {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.runs.delete(runId);
|
this.runs.delete(runId);
|
||||||
this.provisioningRunByTeam.delete(request.teamName);
|
this.provisioningRunByTeam.delete(request.teamName);
|
||||||
|
if (provisioningEnv.anthropicApiKeyHelper) {
|
||||||
|
await cleanupAnthropicTeamApiKeyHelperMaterial({
|
||||||
|
directory: provisioningEnv.anthropicApiKeyHelper.directory,
|
||||||
|
}).catch(() => undefined);
|
||||||
|
}
|
||||||
await removeDeterministicBootstrapSpecFile(run.bootstrapSpecPath).catch(() => {});
|
await removeDeterministicBootstrapSpecFile(run.bootstrapSpecPath).catch(() => {});
|
||||||
run.bootstrapSpecPath = null;
|
run.bootstrapSpecPath = null;
|
||||||
await removeDeterministicBootstrapUserPromptFile(run.bootstrapUserPromptPath).catch(
|
await removeDeterministicBootstrapUserPromptFile(run.bootstrapUserPromptPath).catch(
|
||||||
|
|
@ -14697,10 +14947,16 @@ export class TeamProvisioningService {
|
||||||
request.model,
|
request.model,
|
||||||
launchIdentity
|
launchIdentity
|
||||||
);
|
);
|
||||||
const resolvedProviderId = resolveTeamProviderId(request.providerId);
|
const extraCliArgs = parseCliArgs(request.extraCliArgs);
|
||||||
const providerFastModeArgs = buildProviderFastModeArgs(resolvedProviderId, launchIdentity);
|
const runtimeArgsPlan = await this.buildTeamRuntimeLaunchArgsPlan({
|
||||||
const runtimeTurnSettledHookArgs =
|
teamName: request.teamName,
|
||||||
await this.buildRuntimeTurnSettledHookSettingsArgs(resolvedProviderId);
|
providerId: resolvedProviderId,
|
||||||
|
launchIdentity,
|
||||||
|
envResolution: provisioningEnv,
|
||||||
|
extraArgs: extraCliArgs,
|
||||||
|
includeAnthropicHelper: resolvedProviderId === 'anthropic',
|
||||||
|
contextLabel: 'Team create launch',
|
||||||
|
});
|
||||||
const spawnArgs = mergeJsonSettingsArgs([
|
const spawnArgs = mergeJsonSettingsArgs([
|
||||||
'--input-format',
|
'--input-format',
|
||||||
'stream-json',
|
'stream-json',
|
||||||
|
|
@ -14725,12 +14981,14 @@ export class TeamProvisioningService {
|
||||||
: ['--permission-prompt-tool', 'stdio', '--permission-mode', 'default']),
|
: ['--permission-prompt-tool', 'stdio', '--permission-mode', 'default']),
|
||||||
...(launchModelArg ? ['--model', launchModelArg] : []),
|
...(launchModelArg ? ['--model', launchModelArg] : []),
|
||||||
...(launchIdentity.resolvedEffort ? ['--effort', launchIdentity.resolvedEffort] : []),
|
...(launchIdentity.resolvedEffort ? ['--effort', launchIdentity.resolvedEffort] : []),
|
||||||
...providerFastModeArgs,
|
...runtimeArgsPlan.fastModeArgs,
|
||||||
...runtimeTurnSettledHookArgs,
|
...runtimeArgsPlan.runtimeTurnSettledHookArgs,
|
||||||
...(request.worktree ? ['--worktree', request.worktree] : []),
|
...(request.worktree ? ['--worktree', request.worktree] : []),
|
||||||
...buildDesktopTeammateModeCliArgs(teammateModeDecision),
|
...buildDesktopTeammateModeCliArgs(teammateModeDecision),
|
||||||
...parseCliArgs(request.extraCliArgs),
|
...runtimeArgsPlan.extraArgs,
|
||||||
...providerArgs,
|
...runtimeArgsPlan.providerArgs,
|
||||||
|
...runtimeArgsPlan.settingsArgs,
|
||||||
|
...crossProviderMemberArgs.args,
|
||||||
]);
|
]);
|
||||||
const runtimeWarning = buildRuntimeLaunchWarning(request, shellEnv, {
|
const runtimeWarning = buildRuntimeLaunchWarning(request, shellEnv, {
|
||||||
geminiRuntimeAuth,
|
geminiRuntimeAuth,
|
||||||
|
|
@ -14812,6 +15070,11 @@ export class TeamProvisioningService {
|
||||||
await this.mcpConfigBuilder.removeConfigFile(run.mcpConfigPath).catch(() => {});
|
await this.mcpConfigBuilder.removeConfigFile(run.mcpConfigPath).catch(() => {});
|
||||||
run.mcpConfigPath = null;
|
run.mcpConfigPath = null;
|
||||||
}
|
}
|
||||||
|
if (provisioningEnv.anthropicApiKeyHelper) {
|
||||||
|
await cleanupAnthropicTeamApiKeyHelperMaterial({
|
||||||
|
directory: provisioningEnv.anthropicApiKeyHelper.directory,
|
||||||
|
}).catch(() => undefined);
|
||||||
|
}
|
||||||
this.runs.delete(runId);
|
this.runs.delete(runId);
|
||||||
this.provisioningRunByTeam.delete(request.teamName);
|
this.provisioningRunByTeam.delete(request.teamName);
|
||||||
throw error;
|
throw error;
|
||||||
|
|
@ -15587,11 +15850,16 @@ export class TeamProvisioningService {
|
||||||
const teamsBasePathsToProbe = getTeamsBasePathsToProbe();
|
const teamsBasePathsToProbe = getTeamsBasePathsToProbe();
|
||||||
const runId = randomUUID();
|
const runId = randomUUID();
|
||||||
const startedAt = nowIso();
|
const startedAt = nowIso();
|
||||||
|
const teamRuntimeAuth: TeamRuntimeAuthContext = {
|
||||||
|
teamName: request.teamName,
|
||||||
|
authMaterialId: runId,
|
||||||
|
allowAnthropicApiKeyHelper: true,
|
||||||
|
};
|
||||||
|
|
||||||
const provisioningEnv = await this.buildProvisioningEnv(
|
const provisioningEnv = await this.buildProvisioningEnv(
|
||||||
request.providerId,
|
request.providerId,
|
||||||
request.providerBackendId,
|
request.providerBackendId,
|
||||||
{ includeCodexTeammateAuth: teamRequestIncludesCodexMember(request) }
|
{ includeCodexTeammateAuth: teamRequestIncludesCodexMember(request), teamRuntimeAuth }
|
||||||
);
|
);
|
||||||
const {
|
const {
|
||||||
env: shellEnv,
|
env: shellEnv,
|
||||||
|
|
@ -15614,6 +15882,7 @@ export class TeamProvisioningService {
|
||||||
},
|
},
|
||||||
primaryProviderId: request.providerId,
|
primaryProviderId: request.providerId,
|
||||||
primaryEnv: provisioningEnv,
|
primaryEnv: provisioningEnv,
|
||||||
|
teamRuntimeAuth,
|
||||||
limitContext: request.limitContext,
|
limitContext: request.limitContext,
|
||||||
});
|
});
|
||||||
const allEffectiveMemberSpecs = await this.resolveOpenCodeMemberWorkspacesForRuntime({
|
const allEffectiveMemberSpecs = await this.resolveOpenCodeMemberWorkspacesForRuntime({
|
||||||
|
|
@ -15635,12 +15904,29 @@ export class TeamProvisioningService {
|
||||||
primaryMemberNames.has(member.name)
|
primaryMemberNames.has(member.name)
|
||||||
);
|
);
|
||||||
const expectedMembers = effectiveMemberSpecs.map((member) => 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({
|
const launchIdentity = await this.resolveAndValidateLaunchIdentity({
|
||||||
claudePath,
|
claudePath,
|
||||||
cwd: request.cwd,
|
cwd: request.cwd,
|
||||||
env: shellEnv,
|
env: shellEnv,
|
||||||
request,
|
request,
|
||||||
effectiveMembers: effectiveMemberSpecs,
|
effectiveMembers: effectiveMemberSpecs,
|
||||||
|
providerArgsByProvider,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Build a synthetic TeamCreateRequest for reuse by shared infrastructure
|
// Build a synthetic TeamCreateRequest for reuse by shared infrastructure
|
||||||
|
|
@ -15734,6 +16020,7 @@ export class TeamProvisioningService {
|
||||||
authFailureRetried: false,
|
authFailureRetried: false,
|
||||||
authRetryInProgress: false,
|
authRetryInProgress: false,
|
||||||
spawnContext: null,
|
spawnContext: null,
|
||||||
|
anthropicApiKeyHelper: provisioningEnv.anthropicApiKeyHelper ?? null,
|
||||||
pendingApprovals: new Map(),
|
pendingApprovals: new Map(),
|
||||||
processedPermissionRequestIds: new Set(),
|
processedPermissionRequestIds: new Set(),
|
||||||
pendingPostCompactReminder: false,
|
pendingPostCompactReminder: false,
|
||||||
|
|
@ -15842,6 +16129,11 @@ export class TeamProvisioningService {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.runs.delete(runId);
|
this.runs.delete(runId);
|
||||||
this.provisioningRunByTeam.delete(request.teamName);
|
this.provisioningRunByTeam.delete(request.teamName);
|
||||||
|
if (provisioningEnv.anthropicApiKeyHelper) {
|
||||||
|
await cleanupAnthropicTeamApiKeyHelperMaterial({
|
||||||
|
directory: provisioningEnv.anthropicApiKeyHelper.directory,
|
||||||
|
}).catch(() => undefined);
|
||||||
|
}
|
||||||
await removeDeterministicBootstrapSpecFile(run.bootstrapSpecPath).catch(() => {});
|
await removeDeterministicBootstrapSpecFile(run.bootstrapSpecPath).catch(() => {});
|
||||||
run.bootstrapSpecPath = null;
|
run.bootstrapSpecPath = null;
|
||||||
await removeDeterministicBootstrapUserPromptFile(run.bootstrapUserPromptPath).catch(
|
await removeDeterministicBootstrapUserPromptFile(run.bootstrapUserPromptPath).catch(
|
||||||
|
|
@ -15885,35 +16177,38 @@ export class TeamProvisioningService {
|
||||||
request.model,
|
request.model,
|
||||||
launchIdentity
|
launchIdentity
|
||||||
);
|
);
|
||||||
const resolvedProviderId = resolveTeamProviderId(request.providerId);
|
const extraCliArgs = parseCliArgs(request.extraCliArgs);
|
||||||
const providerFastModeArgs = buildProviderFastModeArgs(resolvedProviderId, launchIdentity);
|
const runtimeArgsPlan = await this.buildTeamRuntimeLaunchArgsPlan({
|
||||||
const runtimeTurnSettledHookArgs =
|
teamName: request.teamName,
|
||||||
await this.buildRuntimeTurnSettledHookSettingsArgs(resolvedProviderId);
|
providerId: resolvedProviderId,
|
||||||
|
launchIdentity,
|
||||||
|
envResolution: provisioningEnv,
|
||||||
|
extraArgs: extraCliArgs,
|
||||||
|
includeAnthropicHelper: resolvedProviderId === 'anthropic',
|
||||||
|
contextLabel: 'Team launch',
|
||||||
|
});
|
||||||
if (launchModelArg) {
|
if (launchModelArg) {
|
||||||
launchArgs.push('--model', launchModelArg);
|
launchArgs.push('--model', launchModelArg);
|
||||||
}
|
}
|
||||||
if (launchIdentity.resolvedEffort) {
|
if (launchIdentity.resolvedEffort) {
|
||||||
launchArgs.push('--effort', launchIdentity.resolvedEffort);
|
launchArgs.push('--effort', launchIdentity.resolvedEffort);
|
||||||
}
|
}
|
||||||
launchArgs.push(...providerFastModeArgs);
|
launchArgs.push(...runtimeArgsPlan.fastModeArgs);
|
||||||
launchArgs.push(...runtimeTurnSettledHookArgs);
|
launchArgs.push(...runtimeArgsPlan.runtimeTurnSettledHookArgs);
|
||||||
if (request.worktree) {
|
if (request.worktree) {
|
||||||
launchArgs.push('--worktree', request.worktree);
|
launchArgs.push('--worktree', request.worktree);
|
||||||
}
|
}
|
||||||
launchArgs.push(...buildDesktopTeammateModeCliArgs(teammateModeDecision));
|
launchArgs.push(...buildDesktopTeammateModeCliArgs(teammateModeDecision));
|
||||||
launchArgs.push(...parseCliArgs(request.extraCliArgs));
|
launchArgs.push(...runtimeArgsPlan.extraArgs);
|
||||||
launchArgs.push(...providerArgs);
|
launchArgs.push(...runtimeArgsPlan.providerArgs);
|
||||||
|
launchArgs.push(...runtimeArgsPlan.settingsArgs);
|
||||||
// When the lead uses a different provider than some teammates (e.g., anthropic lead
|
// 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
|
// with codex teammates), the lead needs the teammate provider's launch args so they
|
||||||
// can be inherited by the teammate subprocess via buildInheritedCliFlags.
|
// can be inherited by the teammate subprocess via buildInheritedCliFlags.
|
||||||
// Without this, a codex teammate spawned from an anthropic lead has no way to learn
|
// 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.
|
// about the required forced_login_method (chatgpt/api) and fails to start.
|
||||||
emitProvisioningCheckpoint(run, 'Resolving cross-provider member launch args');
|
emitProvisioningCheckpoint(run, 'Resolving cross-provider member launch args');
|
||||||
const crossProviderMemberArgs = await this.buildCrossProviderMemberArgs(
|
launchArgs.push(...crossProviderMemberArgs.args);
|
||||||
resolvedProviderId,
|
|
||||||
effectiveMemberSpecs
|
|
||||||
);
|
|
||||||
launchArgs.push(...crossProviderMemberArgs);
|
|
||||||
const finalLaunchArgs = mergeJsonSettingsArgs(launchArgs);
|
const finalLaunchArgs = mergeJsonSettingsArgs(launchArgs);
|
||||||
const runtimeWarning = buildRuntimeLaunchWarning(request, shellEnv, {
|
const runtimeWarning = buildRuntimeLaunchWarning(request, shellEnv, {
|
||||||
geminiRuntimeAuth,
|
geminiRuntimeAuth,
|
||||||
|
|
@ -15988,6 +16283,11 @@ export class TeamProvisioningService {
|
||||||
() => {}
|
() => {}
|
||||||
);
|
);
|
||||||
run.bootstrapUserPromptPath = null;
|
run.bootstrapUserPromptPath = null;
|
||||||
|
if (provisioningEnv.anthropicApiKeyHelper) {
|
||||||
|
await cleanupAnthropicTeamApiKeyHelperMaterial({
|
||||||
|
directory: provisioningEnv.anthropicApiKeyHelper.directory,
|
||||||
|
}).catch(() => undefined);
|
||||||
|
}
|
||||||
this.runs.delete(runId);
|
this.runs.delete(runId);
|
||||||
this.provisioningRunByTeam.delete(request.teamName);
|
this.provisioningRunByTeam.delete(request.teamName);
|
||||||
await this.restorePrelaunchConfig(request.teamName);
|
await this.restorePrelaunchConfig(request.teamName);
|
||||||
|
|
@ -21863,6 +22163,7 @@ export class TeamProvisioningService {
|
||||||
if (this.hasSecondaryRuntimeRuns(teamName)) {
|
if (this.hasSecondaryRuntimeRuns(teamName)) {
|
||||||
await this.stopMixedSecondaryRuntimeLanes(teamName);
|
await this.stopMixedSecondaryRuntimeLanes(teamName);
|
||||||
}
|
}
|
||||||
|
await this.cleanupAnthropicApiKeyHelperMaterialForStoppedTeam(teamName);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const run = this.runs.get(runId);
|
const run = this.runs.get(runId);
|
||||||
|
|
@ -21870,6 +22171,7 @@ export class TeamProvisioningService {
|
||||||
const runtimeProgress = this.runtimeAdapterProgressByRunId.get(runId);
|
const runtimeProgress = this.runtimeAdapterProgressByRunId.get(runId);
|
||||||
if (runtimeProgress && this.isCancellableRuntimeAdapterProgress(runtimeProgress)) {
|
if (runtimeProgress && this.isCancellableRuntimeAdapterProgress(runtimeProgress)) {
|
||||||
await this.cancelRuntimeAdapterProvisioning(runId, runtimeProgress);
|
await this.cancelRuntimeAdapterProvisioning(runId, runtimeProgress);
|
||||||
|
await this.cleanupAnthropicApiKeyHelperMaterialForStoppedTeam(teamName);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const runtimeRun = this.runtimeAdapterRunByTeam.get(teamName);
|
const runtimeRun = this.runtimeAdapterRunByTeam.get(teamName);
|
||||||
|
|
@ -21880,6 +22182,7 @@ export class TeamProvisioningService {
|
||||||
await this.stopOpenCodeRuntimeAdapterTeam(teamName, runId);
|
await this.stopOpenCodeRuntimeAdapterTeam(teamName, runId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
await this.cleanupAnthropicApiKeyHelperMaterialForStoppedTeam(teamName);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (this.hasSecondaryRuntimeRuns(teamName)) {
|
if (this.hasSecondaryRuntimeRuns(teamName)) {
|
||||||
|
|
@ -21887,12 +22190,14 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
this.provisioningRunByTeam.delete(teamName);
|
this.provisioningRunByTeam.delete(teamName);
|
||||||
this.aliveRunByTeam.delete(teamName);
|
this.aliveRunByTeam.delete(teamName);
|
||||||
|
await this.cleanupAnthropicApiKeyHelperMaterialForStoppedTeam(teamName);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (run.processKilled || run.cancelRequested) {
|
if (run.processKilled || run.cancelRequested) {
|
||||||
if (this.hasSecondaryRuntimeRuns(teamName)) {
|
if (this.hasSecondaryRuntimeRuns(teamName)) {
|
||||||
await this.stopMixedSecondaryRuntimeLanes(teamName);
|
await this.stopMixedSecondaryRuntimeLanes(teamName);
|
||||||
}
|
}
|
||||||
|
await this.cleanupAnthropicApiKeyHelperMaterialForStoppedTeam(teamName);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
run.processKilled = true;
|
run.processKilled = true;
|
||||||
|
|
@ -21906,6 +22211,7 @@ export class TeamProvisioningService {
|
||||||
this.cleanupRun(run);
|
this.cleanupRun(run);
|
||||||
logger.info(`[${teamName}] Process stopped (SIGKILL)`);
|
logger.info(`[${teamName}] Process stopped (SIGKILL)`);
|
||||||
await stopSecondaryRuntimeLanes;
|
await stopSecondaryRuntimeLanes;
|
||||||
|
await this.cleanupAnthropicApiKeyHelperMaterialForStoppedTeam(teamName);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getShutdownTrackedTeamNames(): string[] {
|
private getShutdownTrackedTeamNames(): string[] {
|
||||||
|
|
@ -22179,6 +22485,23 @@ export class TeamProvisioningService {
|
||||||
this.killOrphanedTeamAgentProcesses(teamName);
|
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 {
|
private readPersistedTeamProjectPath(teamName: string): string | null {
|
||||||
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
|
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
|
||||||
try {
|
try {
|
||||||
|
|
@ -22329,6 +22652,7 @@ export class TeamProvisioningService {
|
||||||
logger.info(`Cleaning up persisted teammate runtimes on shutdown: ${orphanOnly.join(', ')}`);
|
logger.info(`Cleaning up persisted teammate runtimes on shutdown: ${orphanOnly.join(', ')}`);
|
||||||
for (const teamName of orphanOnly) {
|
for (const teamName of orphanOnly) {
|
||||||
this.stopPersistentTeamMembers(teamName);
|
this.stopPersistentTeamMembers(teamName);
|
||||||
|
await this.cleanupAnthropicApiKeyHelperMaterialForStoppedTeam(teamName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -22512,6 +22836,18 @@ export class TeamProvisioningService {
|
||||||
});
|
});
|
||||||
if (shouldCleanupUnconfirmedLaunchRuntimes) {
|
if (shouldCleanupUnconfirmedLaunchRuntimes) {
|
||||||
this.stopPersistentTeamMembers(run.teamName);
|
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;
|
run.processKilled = true;
|
||||||
killTeamProcess(run.child);
|
killTeamProcess(run.child);
|
||||||
|
|
@ -25496,7 +25832,10 @@ export class TeamProvisioningService {
|
||||||
private async buildProvisioningEnv(
|
private async buildProvisioningEnv(
|
||||||
providerId: TeamProviderId | undefined = 'anthropic',
|
providerId: TeamProviderId | undefined = 'anthropic',
|
||||||
providerBackendId?: string | null,
|
providerBackendId?: string | null,
|
||||||
options?: { includeCodexTeammateAuth?: boolean }
|
options?: {
|
||||||
|
includeCodexTeammateAuth?: boolean;
|
||||||
|
teamRuntimeAuth?: TeamRuntimeAuthContext;
|
||||||
|
}
|
||||||
): Promise<ProvisioningEnvResolution> {
|
): Promise<ProvisioningEnvResolution> {
|
||||||
const shellEnv = await resolveInteractiveShellEnv();
|
const shellEnv = await resolveInteractiveShellEnv();
|
||||||
// getHomeDir() uses Electron's app.getPath('home') which handles Unicode
|
// 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
|
// 1. Explicit ANTHROPIC_API_KEY — works with `-p` mode directly
|
||||||
if (
|
if (
|
||||||
typeof providerEnv.ANTHROPIC_API_KEY === 'string' &&
|
typeof providerEnv.ANTHROPIC_API_KEY === 'string' &&
|
||||||
|
|
@ -25653,8 +26040,9 @@ export class TeamProvisioningService {
|
||||||
|
|
||||||
private async buildCrossProviderMemberArgs(
|
private async buildCrossProviderMemberArgs(
|
||||||
primaryProviderId: TeamProviderId,
|
primaryProviderId: TeamProviderId,
|
||||||
memberSpecs: TeamCreateRequest['members']
|
memberSpecs: TeamCreateRequest['members'],
|
||||||
): Promise<string[]> {
|
options?: { teamRuntimeAuth?: TeamRuntimeAuthContext }
|
||||||
|
): Promise<CrossProviderMemberArgsResult> {
|
||||||
const crossProviderIds = new Set<TeamProviderId>();
|
const crossProviderIds = new Set<TeamProviderId>();
|
||||||
for (const member of memberSpecs) {
|
for (const member of memberSpecs) {
|
||||||
const memberId = resolveTeamProviderId(
|
const memberId = resolveTeamProviderId(
|
||||||
|
|
@ -25665,12 +26053,27 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const args: string[] = [];
|
const args: string[] = [];
|
||||||
|
const providerArgsByProvider = new Map<TeamProviderId, string[]>();
|
||||||
|
const envPatch: NodeJS.ProcessEnv = {};
|
||||||
|
let usesAnthropicApiKeyHelper = false;
|
||||||
for (const providerId of crossProviderIds) {
|
for (const providerId of crossProviderIds) {
|
||||||
try {
|
try {
|
||||||
const env = await this.buildProvisioningEnv(providerId);
|
const env = await this.buildProvisioningEnv(providerId, undefined, {
|
||||||
|
teamRuntimeAuth: options?.teamRuntimeAuth,
|
||||||
|
});
|
||||||
args.push(...(await this.buildRuntimeTurnSettledHookSettingsArgs(providerId)));
|
args.push(...(await this.buildRuntimeTurnSettledHookSettingsArgs(providerId)));
|
||||||
if (env.providerArgs) {
|
const providerArgs = env.providerArgs ?? [];
|
||||||
args.push(...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) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
|
|
@ -25680,7 +26083,7 @@ export class TeamProvisioningService {
|
||||||
// Best-effort: don't block launch if cross-provider env resolution fails
|
// 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> {
|
private async resolveControlApiBaseUrl(): Promise<string | null> {
|
||||||
|
|
|
||||||
|
|
@ -995,4 +995,65 @@ describe('ProviderConnectionService', () => {
|
||||||
source: 'app-server',
|
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 os from 'node:os';
|
||||||
import * as path from 'node:path';
|
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 { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService';
|
||||||
import { setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder';
|
import { setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder';
|
||||||
|
|
@ -12,6 +12,14 @@ import type {
|
||||||
TeamProvisioningProgress,
|
TeamProvisioningProgress,
|
||||||
} from '../../../../src/shared/types';
|
} from '../../../../src/shared/types';
|
||||||
|
|
||||||
|
vi.mock('../../../../src/main/services/infrastructure/NotificationManager', () => ({
|
||||||
|
NotificationManager: {
|
||||||
|
getInstance: () => ({
|
||||||
|
addTeamNotification: vi.fn(async () => undefined),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
const liveDescribe =
|
const liveDescribe =
|
||||||
process.env.ANTHROPIC_RUNTIME_MEMORY_LIVE === '1' && process.env.ANTHROPIC_API_KEY?.trim()
|
process.env.ANTHROPIC_RUNTIME_MEMORY_LIVE === '1' && process.env.ANTHROPIC_API_KEY?.trim()
|
||||||
? describe
|
? 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(),
|
...createStableBridgeEnv(),
|
||||||
PATH: withBunOnPath(process.env.PATH ?? ''),
|
PATH: withBunOnPath(process.env.PATH ?? ''),
|
||||||
XDG_DATA_HOME: path.join(tempDir, 'xdg-data-single'),
|
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_COMMAND: mcpLaunchSpec.command,
|
||||||
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: mcpLaunchSpec.args[0] ?? '',
|
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: mcpLaunchSpec.args[0] ?? '',
|
||||||
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON: JSON.stringify(mcpLaunchSpec.args),
|
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON: JSON.stringify(mcpLaunchSpec.args),
|
||||||
|
|
@ -119,7 +120,7 @@ liveDescribe('OpenCode mixed recovery live e2e', () => {
|
||||||
});
|
});
|
||||||
launchedLanes.push(launchInput);
|
launchedLanes.push(launchInput);
|
||||||
const launchResult = await adapter.launch(launchInput);
|
const launchResult = await adapter.launch(launchInput);
|
||||||
expect(launchResult.teamLaunchState).toBe('clean_success');
|
expectCleanOpenCodeLaunch(launchResult);
|
||||||
expect(launchResult.members.bob).toMatchObject({
|
expect(launchResult.members.bob).toMatchObject({
|
||||||
launchState: 'confirmed_alive',
|
launchState: 'confirmed_alive',
|
||||||
runtimeAlive: true,
|
runtimeAlive: true,
|
||||||
|
|
@ -181,6 +182,7 @@ liveDescribe('OpenCode mixed recovery live e2e', () => {
|
||||||
...createStableBridgeEnv(),
|
...createStableBridgeEnv(),
|
||||||
PATH: withBunOnPath(process.env.PATH ?? ''),
|
PATH: withBunOnPath(process.env.PATH ?? ''),
|
||||||
XDG_DATA_HOME: path.join(tempDir, 'xdg-data-multi'),
|
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_COMMAND: mcpLaunchSpec.command,
|
||||||
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: mcpLaunchSpec.args[0] ?? '',
|
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: mcpLaunchSpec.args[0] ?? '',
|
||||||
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON: JSON.stringify(mcpLaunchSpec.args),
|
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON: JSON.stringify(mcpLaunchSpec.args),
|
||||||
|
|
@ -225,7 +227,7 @@ liveDescribe('OpenCode mixed recovery live e2e', () => {
|
||||||
});
|
});
|
||||||
launchedLanes.push(launchInput);
|
launchedLanes.push(launchInput);
|
||||||
const launchResult = await adapter.launch(launchInput);
|
const launchResult = await adapter.launch(launchInput);
|
||||||
expect(launchResult.teamLaunchState).toBe('clean_success');
|
expectCleanOpenCodeLaunch(launchResult);
|
||||||
expect(launchResult.members[memberName]).toMatchObject({
|
expect(launchResult.members[memberName]).toMatchObject({
|
||||||
launchState: 'confirmed_alive',
|
launchState: 'confirmed_alive',
|
||||||
runtimeAlive: true,
|
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: {
|
async function writeMixedRecoveryFixtures(input: {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,7 @@ liveDescribe('OpenCode team provisioning live e2e', () => {
|
||||||
...createStableBridgeEnv(),
|
...createStableBridgeEnv(),
|
||||||
PATH: withBunOnPath(process.env.PATH ?? ''),
|
PATH: withBunOnPath(process.env.PATH ?? ''),
|
||||||
XDG_DATA_HOME: path.join(tempDir, 'xdg-data'),
|
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_COMMAND: mcpLaunchSpec.command,
|
||||||
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: mcpLaunchSpec.args[0] ?? '',
|
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: mcpLaunchSpec.args[0] ?? '',
|
||||||
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON: JSON.stringify(mcpLaunchSpec.args),
|
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON: JSON.stringify(mcpLaunchSpec.args),
|
||||||
|
|
|
||||||
|
|
@ -95,7 +95,10 @@ vi.mock('@main/utils/childProcess', () => ({
|
||||||
killProcessTree: vi.fn(),
|
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 { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
|
||||||
import { TeamRuntimeAdapterRegistry } from '@main/services/team/runtime';
|
import { TeamRuntimeAdapterRegistry } from '@main/services/team/runtime';
|
||||||
import { ProviderConnectionService } from '@main/services/runtime/ProviderConnectionService';
|
import { ProviderConnectionService } from '@main/services/runtime/ProviderConnectionService';
|
||||||
|
|
@ -347,6 +350,91 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
||||||
delete process.env.ANTHROPIC_AUTH_TOKEN;
|
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 () => {
|
afterEach(async () => {
|
||||||
await removeTempRoot(tempRoot);
|
await removeTempRoot(tempRoot);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue