From 08ab7c6b6d24fd6452c2cfd8db88879774e505e8 Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 8 May 2026 09:28:28 +0300 Subject: [PATCH] fix(team): harden process bootstrap and codex auth --- .github/workflows/landing.yml | 6 +- .github/workflows/release.yml | 32 +- README.md | 24 +- bun.lock | 2 +- docs/RELEASE.md | 56 +- ...kend-bootstrap-transport-hardening-plan.md | 141 ++++- landing/composables/useGithubRepo.ts | 2 +- landing/composables/useReleaseDownloads.ts | 2 +- landing/nuxt.config.ts | 4 +- landing/product-docs/.vitepress/config.ts | 4 +- .../.vitepress/theme/InstallBlock.vue | 2 +- landing/product-docs/guide/installation.md | 6 +- landing/product-docs/ru/guide/installation.md | 6 +- landing/server/routes/robots.txt.ts | 2 +- landing/server/routes/sitemap.xml.ts | 2 +- package.json | 18 +- runtime.lock.json | 2 +- .../composition/createCodexAccountFeature.ts | 42 +- .../detectCodexLocalAccountArtifacts.ts | 234 +++++++- src/main/index.ts | 9 +- .../extensions/apikeys/ApiKeyService.ts | 27 +- .../infrastructure/updaterReleaseMetadata.ts | 21 +- .../runtime/ProviderConnectionService.ts | 108 +++- .../team/ProcessBootstrapTransportEvidence.ts | 217 +++++++ .../services/team/TeamProvisioningService.ts | 558 ++++++++++++++++-- src/main/utils/electronUserDataMigration.ts | 163 ++++- .../components/common/UpdateDialog.tsx | 2 +- .../components/layout/TabBarActions.tsx | 4 +- .../components/team/members/MemberCard.tsx | 18 +- src/renderer/utils/bugReportUtils.ts | 2 +- .../main/createCodexAccountFeature.test.ts | 96 ++- .../detectCodexLocalAccountArtifacts.test.ts | 181 +++++- .../services/extensions/ApiKeyService.test.ts | 38 ++ .../updaterReleaseMetadata.test.ts | 42 +- .../runtime/ProviderConnectionService.test.ts | 143 +++++ .../ProcessBootstrapTransportEvidence.test.ts | 135 +++++ .../team/TeamProvisioningService.test.ts | 376 ++++++++++++ .../TeamProvisioningServicePrepare.test.ts | 22 + .../utils/electronUserDataMigration.test.ts | 167 +++++- .../team/members/MemberCard.test.ts | 42 ++ 40 files changed, 2707 insertions(+), 251 deletions(-) create mode 100644 src/main/services/team/ProcessBootstrapTransportEvidence.ts create mode 100644 test/main/services/team/ProcessBootstrapTransportEvidence.test.ts diff --git a/.github/workflows/landing.yml b/.github/workflows/landing.yml index 777604bc..51d075da 100644 --- a/.github/workflows/landing.yml +++ b/.github/workflows/landing.yml @@ -32,9 +32,9 @@ jobs: - name: Generate static site working-directory: landing env: - NUXT_APP_BASE_URL: /claude_agent_teams_ui/ - NUXT_PUBLIC_SITE_URL: https://777genius.github.io/claude_agent_teams_ui - NUXT_PUBLIC_GITHUB_REPO: 777genius/claude_agent_teams_ui + NUXT_APP_BASE_URL: /agent-teams-ai/ + NUXT_PUBLIC_SITE_URL: https://777genius.github.io/agent-teams-ai + NUXT_PUBLIC_GITHUB_REPO: 777genius/agent-teams-ai run: npm run generate:all - uses: actions/configure-pages@v5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4f6edcfd..1ab97a28 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -497,13 +497,13 @@ jobs: trap 'rm -rf "$TMP_DIR"' EXIT declare -A FILES=( - ["Claude-Agent-Teams-UI-arm64.dmg"]="Claude.Agent.Teams.UI-${VERSION}-arm64.dmg" - ["Claude-Agent-Teams-UI-x64.dmg"]="Claude.Agent.Teams.UI-${VERSION}-x64.dmg" - ["Claude-Agent-Teams-UI-Setup.exe"]="Claude.Agent.Teams.UI.Setup.${VERSION}.exe" - ["Claude-Agent-Teams-UI.AppImage"]="Claude.Agent.Teams.UI-${VERSION}.AppImage" - ["Claude-Agent-Teams-UI-amd64.deb"]="claude-agent-teams-ui_${VERSION}_amd64.deb" - ["Claude-Agent-Teams-UI-x86_64.rpm"]="claude-agent-teams-ui-${VERSION}.x86_64.rpm" - ["Claude-Agent-Teams-UI.pacman"]="claude-agent-teams-ui-${VERSION}.pacman" + ["Claude-Agent-Teams-UI-arm64.dmg"]="Agent.Teams.AI-${VERSION}-arm64.dmg" + ["Claude-Agent-Teams-UI-x64.dmg"]="Agent.Teams.AI-${VERSION}-x64.dmg" + ["Claude-Agent-Teams-UI-Setup.exe"]="Agent.Teams.AI.Setup.${VERSION}.exe" + ["Claude-Agent-Teams-UI.AppImage"]="Agent.Teams.AI-${VERSION}.AppImage" + ["Claude-Agent-Teams-UI-amd64.deb"]="agent-teams-ai_${VERSION}_amd64.deb" + ["Claude-Agent-Teams-UI-x86_64.rpm"]="agent-teams-ai-${VERSION}.x86_64.rpm" + ["Claude-Agent-Teams-UI.pacman"]="agent-teams-ai-${VERSION}.pacman" ) # Download versioned files and re-upload with stable names @@ -574,22 +574,22 @@ jobs: # electron-updater on GitHub still consumes a single latest-mac.yml, so we # publish the Apple Silicon feed here and suppress Intel auto-update in-app # until we switch to universal packaging or an arch-aware provider. - download_asset "Claude.Agent.Teams.UI-${VERSION}-arm64-mac.zip" - download_asset "Claude.Agent.Teams.UI-${VERSION}-arm64.dmg" - MAC_ZIP_SHA="$(sha512_base64 Claude.Agent.Teams.UI-${VERSION}-arm64-mac.zip)" - MAC_ZIP_SIZE="$(file_size Claude.Agent.Teams.UI-${VERSION}-arm64-mac.zip)" - MAC_DMG_SHA="$(sha512_base64 Claude.Agent.Teams.UI-${VERSION}-arm64.dmg)" - MAC_DMG_SIZE="$(file_size Claude.Agent.Teams.UI-${VERSION}-arm64.dmg)" + download_asset "Agent.Teams.AI-${VERSION}-arm64-mac.zip" + download_asset "Agent.Teams.AI-${VERSION}-arm64.dmg" + MAC_ZIP_SHA="$(sha512_base64 Agent.Teams.AI-${VERSION}-arm64-mac.zip)" + MAC_ZIP_SIZE="$(file_size Agent.Teams.AI-${VERSION}-arm64-mac.zip)" + MAC_DMG_SHA="$(sha512_base64 Agent.Teams.AI-${VERSION}-arm64.dmg)" + MAC_DMG_SIZE="$(file_size Agent.Teams.AI-${VERSION}-arm64.dmg)" cat > latest-mac.yml <Settings

-

Agent Teams

+

Agent Teams

You're the CTO, agents are your team. They handle tasks themselves, message each other, review each other. You just look at the kanban board and drink coffee.

- Latest Release  - CI Status  + Latest Release  + CI Status  Discord

@@ -54,33 +54,33 @@ If you want the FRESHEST version, clone the repo and run it from the `dev` branc @@ -268,8 +268,8 @@ Electron 40, React 19, TypeScript 5, Tailwind CSS 3, Zustand 4. Data from `~/.cl **Prerequisites:** Node.js 20+, pnpm 10+ ```bash -git clone https://github.com/777genius/claude_agent_teams_ui.git -cd claude_agent_teams_ui +git clone https://github.com/777genius/agent-teams-ai.git +cd agent-teams-ai pnpm install pnpm dev ``` diff --git a/bun.lock b/bun.lock index 13c2badc..c33b6e46 100644 --- a/bun.lock +++ b/bun.lock @@ -3,7 +3,7 @@ "configVersion": 1, "workspaces": { "": { - "name": "claude-agent-teams-ui", + "name": "agent-teams-ai", "dependencies": { "@claude-teams/agent-graph": "workspace:*", "@codemirror/autocomplete": "^6.20.0", diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 825bb941..3fce2e34 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -15,7 +15,7 @@ Initial release: Agent Teams with reliable CLI detection in packaged builds (she After CI uploads artifacts, optional notes update: ```bash -gh release edit v1.0.0 --repo 777genius/claude_agent_teams_ui --notes "$(cat <<'EOF' +gh release edit v1.0.0 --repo 777genius/agent-teams-ai --notes "$(cat <<'EOF' ## Agent Teams v1.0.0 First stable build: CLI/auth reliability in packaged apps, IPC hardening, and platform packaging. @@ -37,33 +37,33 @@ First stable build: CLI/auth reliability in packaged apps, IPC hardening, and pl
- + macOS Apple Silicon
- + macOS Intel
- + Windows
May trigger SmartScreen — click "More info" → "Run anyway"
- + Linux AppImage
- + .deb   - + .rpm   - + .pacman
@@ -112,7 +112,7 @@ This triggers the `release.yml` GitHub Actions workflow which: After the workflow completes, edit the release notes: ```bash -gh release edit v --repo 777genius/claude_agent_teams_ui --notes "$(cat <<'EOF' +gh release edit v --repo 777genius/agent-teams-ai --notes "$(cat <<'EOF' EOF )" @@ -140,33 +140,33 @@ EOF
- + macOS Apple Silicon
- + macOS Intel
- + Windows
May trigger SmartScreen — click "More info" → "Run anyway"
- + Linux AppImage
- + .deb   - + .rpm   - + .pacman
@@ -196,15 +196,15 @@ electron-builder generates these artifacts per platform: | Platform | Versioned Name | Stable Name (for /latest/download) | |------------------|--------------------------------------------------|--------------------------------------------| -| macOS arm64 DMG | `Claude.Agent.Teams.UI--arm64.dmg` | `Claude-Agent-Teams-UI-arm64.dmg` | -| macOS x64 DMG | `Claude.Agent.Teams.UI--x64.dmg` | `Claude-Agent-Teams-UI-x64.dmg` | -| macOS arm64 ZIP | `Claude.Agent.Teams.UI--arm64-mac.zip` | - | -| macOS x64 ZIP | `Claude.Agent.Teams.UI--x64-mac.zip` | - | -| Windows | `Claude.Agent.Teams.UI.Setup..exe` | `Claude-Agent-Teams-UI-Setup.exe` | -| Linux AppImage | `Claude.Agent.Teams.UI-.AppImage` | `Claude-Agent-Teams-UI.AppImage` | -| Linux deb | `claude-agent-teams-ui__amd64.deb` | `Claude-Agent-Teams-UI-amd64.deb` | -| Linux rpm | `claude-agent-teams-ui-.x86_64.rpm` | `Claude-Agent-Teams-UI-x86_64.rpm` | -| Linux pacman | `claude-agent-teams-ui-.pacman` | `Claude-Agent-Teams-UI.pacman` | +| macOS arm64 DMG | `Agent.Teams.AI--arm64.dmg` | `Claude-Agent-Teams-UI-arm64.dmg` | +| macOS x64 DMG | `Agent.Teams.AI--x64.dmg` | `Claude-Agent-Teams-UI-x64.dmg` | +| macOS arm64 ZIP | `Agent.Teams.AI--arm64-mac.zip` | - | +| macOS x64 ZIP | `Agent.Teams.AI--x64-mac.zip` | - | +| Windows | `Agent.Teams.AI.Setup..exe` | `Claude-Agent-Teams-UI-Setup.exe` | +| Linux AppImage | `Agent.Teams.AI-.AppImage` | `Claude-Agent-Teams-UI.AppImage` | +| Linux deb | `agent-teams-ai__amd64.deb` | `Claude-Agent-Teams-UI-amd64.deb` | +| Linux rpm | `agent-teams-ai-.x86_64.rpm` | `Claude-Agent-Teams-UI-x86_64.rpm` | +| Linux pacman | `agent-teams-ai-.pacman` | `Claude-Agent-Teams-UI.pacman` | ## Stable Download Links @@ -214,7 +214,7 @@ It starts only after **release-mac** (two matrix jobs), **release-win**, and **r This enables permanent links in README that always point to the latest release: ``` -https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI-arm64.dmg +https://github.com/777genius/agent-teams-ai/releases/latest/download/Claude-Agent-Teams-UI-arm64.dmg ``` GitHub automatically redirects `/releases/latest/download/FILENAME` to the asset from the most recent release. No README updates needed when releasing a new version. @@ -251,10 +251,10 @@ git push origin v1.0.0 # Wait for CI to finish (~10 min), then update notes # Delete a release (if needed) -gh release delete v1.0.0 --repo 777genius/claude_agent_teams_ui --yes +gh release delete v1.0.0 --repo 777genius/agent-teams-ai --yes git tag -d v1.0.0 git push origin :refs/tags/v1.0.0 # Check workflow status -gh run list --repo 777genius/claude_agent_teams_ui --workflow release.yml --limit 3 +gh run list --repo 777genius/agent-teams-ai --workflow release.yml --limit 3 ``` diff --git a/docs/team-management/process-backend-bootstrap-transport-hardening-plan.md b/docs/team-management/process-backend-bootstrap-transport-hardening-plan.md index 0a472073..4f206caf 100644 --- a/docs/team-management/process-backend-bootstrap-transport-hardening-plan.md +++ b/docs/team-management/process-backend-bootstrap-transport-hardening-plan.md @@ -333,7 +333,7 @@ const runtimeFilePrefixCases = [ ['bob.team', 'bob-team'], ['bob_team', 'bob-team'], ['con', '_con'], - ['con.txt', '_con-txt'], + ['con.txt', 'con-txt'], ['aux', '_aux'], ['COM1', '_com1'], ['LPT9', '_lpt9'], @@ -425,6 +425,18 @@ These values are structured output and can be shown in diagnostics immediately. `clean_success` finished launches can clear persisted launch-state. This is fine. Transport diagnostics matter for failed/pending launches, not clean success. +Clean-success clear rule: + +- if the normalized snapshot is truly `clean_success` after proof/provider/transport overlays and `launchPhase !== 'active'`, preserve existing behavior and clear persisted launch-state; +- process transport enrichment must not keep a stale diagnostic alive after every expected member is confirmed/skipped according to existing summary semantics; +- if any process member is still `runtime_pending_bootstrap`, `failed_to_start`, or permission-pending, the snapshot is not clean success and must not be cleared; +- do not use runtime event absence to prevent clean-success clearing. Absence of a best-effort diagnostic file is not a reason to keep launch-state; +- `clearPersistedLaunchStateNow(...)` also clears bootstrap-state. Therefore clean-success clear must be fenced by current run identity immediately before clearing, not only before building the snapshot; +- a stale clean-success finalizer from an older run must not clear launch-state or bootstrap-state for a newer active/restarted run; +- if the current run identity cannot be proven at clear time, skip clear and keep the conservative persisted state; +- tests should cover clean-success launch with missing runtime event files and assert launch-state can still clear. +- tests should cover stale clean-success finalizer racing with a newer launch and assert neither launch-state nor bootstrap-state is cleared for the newer run. + ### 27. Shared/public launch status types must not expose transport internals `PersistedTeamLaunchMemberState`, `MemberSpawnStatusEntry`, and `TeamAgentRuntimeEntry` expose user-facing runtime/launch status fields like `runtimeDiagnostic`, `hardFailureReason`, `diagnostics`, `livenessKind`, and `runtimePid`. @@ -636,7 +648,11 @@ type LaunchTransitionReason = function mergeProcessBootstrapLaunchState( previous: PersistedTeamLaunchMemberState, evidence: ProcessBootstrapTransportEvidence, - context: { launchPhase: 'active' | 'terminal'; currentAttempt: boolean }, + context: { + launchPhase: PersistedTeamLaunchPhase + projectionPhase: 'active' | 'final' + currentAttempt: boolean + }, ): { next: PersistedTeamLaunchMemberState; changed: boolean; reason: LaunchTransitionReason } { // pure function, no filesystem, no process table, no renderer imports } @@ -644,6 +660,36 @@ function mergeProcessBootstrapLaunchState( Keep this transition helper pure and test it directly. Do not scatter transition checks across `TeamProvisioningService`, summary projection, and renderer helpers. +### Persisted launch phase compatibility + +Current shared type is: + +```ts +export type PersistedTeamLaunchPhase = 'active' | 'finished' | 'reconciled' +``` + +Do not add new persisted values like `finalizing` or `terminal` for this phase. If merge logic needs an internal terminal/final concept, derive it as a local non-persisted value: + +```ts +type ProcessTransportProjectionPhase = 'active' | 'final' + +function deriveProcessTransportProjectionPhase(input: { + launchPhase: PersistedTeamLaunchPhase + finalTimeoutReached?: boolean +}): ProcessTransportProjectionPhase { + if (input.launchPhase !== 'active') return 'final' + return input.finalTimeoutReached === true ? 'final' : 'active' +} +``` + +Rules: + +- persisted snapshots keep `launchPhase: 'active' | 'finished' | 'reconciled'` only; +- runner UI/progress step `finalizing` is not the same thing as persisted `launchPhase`; +- process transport merge can use internal `projectionPhase: 'final'` to decide timeout-to-failure behavior; +- do not cast new phase strings with `as PersistedTeamLaunchPhase`; +- tests must assert unknown phase strings are normalized by existing evaluator behavior, not introduced by this change. + ## Transport failure taxonomy Use typed failure categories internally. Do not decide terminal vs pending from arbitrary strings. @@ -1852,12 +1898,17 @@ Reader safety: - do not reuse `readBoundRegularUtf8File(...)` directly for runtime JSONL, because oversized runtime logs should be tailed rather than rejected; - use the opened file handle's `stat().size` as the source of truth for the tail range. If the file grows while reading, that is acceptable; read only the selected stable range and skip a final partial line; - tolerate corrupt/partial JSON lines by skipping only those lines; +- impose a per-line byte/char cap before `JSON.parse`, for example `16 KiB`. Oversized lines are treated as corrupt diagnostic noise and skipped; +- if a single oversized line consumes the whole tail slice, return an empty list and rely on timeout/fallback. Do not retry with a larger read just to recover diagnostics; +- parse at most a bounded number of lines/events from one file, for example last `256` candidate lines, after tail slicing; - normalize optional fields defensively: `retryable` is used only when it is a boolean, `attempt` only when it is a positive finite number, and unknown event types remain readable but are ignored by process-transport classifiers; - when reading a tail slice, drop the first line because it can be a partial middle-of-file line; - if the last line has no trailing newline, treat it as potentially partial and skip it unless it parses cleanly and has required event shape; - never throw from malformed runtime event content during launch finalization; - keep max bytes low enough for hot polling but high enough for bursty startup, default `256 KiB`. +Reader output must preserve append order among accepted parsed events. Skipped corrupt/oversized lines do not create placeholder events and do not affect stage rank except through absence. + ## Phase 4 - Add bootstrap submission outcome waiter Repo: `/Users/belief/dev/projects/claude/agent_teams_orchestrator` @@ -2608,7 +2659,8 @@ export interface ProcessBootstrapTransportMergeInput { evidence: ProcessBootstrapTransportEvidence diagnostic: ProcessBootstrapTransportDiagnostic attemptMatch: 'strict-current' | 'legacy-current' | 'diagnostic-only' | 'no-match' - launchPhase: 'active' | 'finalizing' | 'terminal' + launchPhase: PersistedTeamLaunchPhase + projectionPhase: 'active' | 'final' } ``` @@ -2618,40 +2670,92 @@ Merge helper rules: - `diagnostic-only` can append internal log/debug diagnostics but cannot change launch state; - `legacy-current` can only produce pending diagnostics, not terminal failure, unless the final timeout path also confirms the current launch boundary; - `strict-current` is required for immediate terminal transport failure; -- `launchPhase: 'active'` cannot convert retryable rejection into `failed_to_start`; -- `launchPhase: 'terminal'` can convert timeout kinds into `failed_to_start` if no higher-priority provider/root failure exists. +- `projectionPhase: 'active'` cannot convert retryable rejection or timeout-only pending stages into `failed_to_start`; +- `projectionPhase: 'final'` can convert timeout kinds into `failed_to_start` if no higher-priority provider/root failure exists. Projection coordinator shape: ```ts -async function enrichAndPersistCurrentLaunchSnapshot(run: MutableTeamRun): Promise { - const identitySnapshot = buildCurrentAttemptIdentities(run) - const evidenceSnapshot = await readProcessTransportEvidence(identitySnapshot) +async function writeLaunchStateSnapshotNow(teamName, snapshot, options) { + const previousSnapshot = await this.launchStateStore.read(teamName).catch(() => null) + const metaMembers = await this.membersMetaStore.getMembers(teamName).catch(() => []) - if (!isStillCurrentRun(run, identitySnapshot)) { - return + const openCodeOverlaid = await applyOpenCodeSecondaryEvidenceOverlay({ + teamName, + snapshot, + previousSnapshot, + metaMembers, + }) + + const processOverlaid = await applyProcessTransportEvidenceOverlay({ + teamName, + snapshot: openCodeOverlaid, + previousSnapshot, + metaMembers, + runIdentity: options?.runIdentity, + }) + + if (!isStillCurrentBeforeLaunchStateWrite(teamName, options?.runIdentity)) { + return { snapshot: previousSnapshot ?? processOverlaid, wrote: false, stale: true } } - const statuses = mergeProcessTransportEvidence(run.memberStatuses, evidenceSnapshot) - const snapshot = buildLiveLaunchSnapshotForRun({ ...run, memberStatuses: statuses }) - await this.teamLaunchStateStore.write(run.teamName, snapshot) + const normalizedSnapshot = + applyOpenCodeSecondaryBootstrapStallOverlay(processOverlaid) ?? processOverlaid + + if (await canSkipLaunchStateWriteAndSummaryIsFresh(previousSnapshot, normalizedSnapshot, options)) { + return { snapshot: previousSnapshot, wrote: false } + } + + if (normalizedSnapshot.teamLaunchState === 'clean_success' && normalizedSnapshot.launchPhase !== 'active') { + if (!isStillCurrentBeforeLaunchStateClear(teamName, options?.runIdentity)) { + return { snapshot: previousSnapshot ?? normalizedSnapshot, wrote: false, stale: true } + } + await clearPersistedLaunchStateNow(teamName) + return { snapshot: null, wrote: true, cleared: true } + } + + await this.launchStateStore.write(teamName, normalizedSnapshot) + return { snapshot: normalizedSnapshot, wrote: true } } ``` Rules: -- `identitySnapshot` is immutable for the projection pass; +- the existing per-team `enqueueLaunchStateStoreOperation(...)` remains the serialization boundary. Do not create a parallel writer or a second read/compare/write queue; +- `previousSnapshot` is read once inside the queued operation and passed into overlays. Do not let each overlay call `launchStateStore.read(...)` independently; +- `metaMembers` is read once inside the queued operation and passed into overlays. Do not let OpenCode and process overlays race on separate member metadata reads; +- `runIdentity` / attempt identity is immutable for the projection pass; - evidence read failures degrade to no enrichment, not launch crash; - evidence read budget exhaustion degrades remaining members to no enrichment and records internal debug/log detail only; - `isStillCurrentRun(...)` checks team name, run id, cancellation/killed state, and any restart generation if available; - `TeamLaunchStateStore.write(...)` remains the only persistence point for detailed and compact launch projections; -- never mutate `run.memberStatuses` while iterating over event files. Build a merged copy and persist it atomically through the store. +- never mutate `run.memberStatuses` while iterating over event files. Build a merged copy and persist it atomically through the store; - integrate this inside the existing `persistLaunchStateSnapshot(...)` / `enqueueLaunchStateStoreOperation(...)` flow; - do not add a second launch-state writer for transport enrichment; - if `TeamLaunchStateStore.write(...)` writes detail but summary write later fails, accept detailed state as truth and let existing stale-summary logic/list refresh recover. - account for existing `writeLaunchStateSnapshotNow(...)` overlays: OpenCode secondary overlays and bootstrap-stall overlays already run before normalized snapshot write. Process transport enrichment should compose with them without changing OpenCode behavior. - preferred order inside `writeLaunchStateSnapshotNow(...)`: previous snapshot read -> existing OpenCode overlays -> process transport enrichment for non-OpenCode process members -> bootstrap-stall/normalization -> no-op/summary repair check -> store write. - if this order conflicts with existing proof overlay in `persistLaunchStateSnapshotNow(...)`, preserve proof overlay as higher priority and document the exact order in code comments. +- do not evaluate clean-success clear before process transport enrichment and final current-run fence. A pre-enrichment clean-success check can erase evidence that would have kept a partial launch visible. + +Queue and IO budget: + +- bounded runtime event tail reads may happen inside the queued operation only if the total budget is small and deterministic. This keeps previous-snapshot comparison consistent; +- do not perform broad process table scans, project transcript scans, or network/provider checks inside the queued operation; +- if runtime event IO budget would be exceeded, stop reading more members and return no enrichment for the rest. Do not hold the queue for best-effort diagnostics; +- do not recursively call `persistLaunchStateSnapshot(...)` or `writeLaunchStateSnapshot(...)` from inside an overlay; +- overlay helpers return new snapshot objects. They do not write files, emit IPC, notify lead/user, or mutate live run maps. + +No-op skip and summary repair: + +- transport enrichment must be included before `areLaunchStateSnapshotsSemanticallyEqual(...)`; +- no-op skip is allowed only after checking whether `launch-summary.json` is present/fresh enough for the normalized detailed snapshot; +- if summary is missing/stale and detailed state is semantically unchanged, force `TeamLaunchStateStore.write(...)` to repair both files; +- the summary repair check must be bounded and must run inside the same serialized operation as the detailed-state comparison; +- do not direct-write `launch-summary.json`; +- do not update `launchStateWrittenRunIdByTeam` before the detailed + summary write path has succeeded or a verified no-op skip has occurred. +- clean-success clear is a write operation and must happen inside the same serialized queue with the same final run-identity fence as normal writes; +- because clear also removes bootstrap-state, it needs a stricter current-run check than ordinary diagnostic enrichment. Normalizer interaction hazard: @@ -3072,6 +3176,8 @@ Cases: - bounded reader reads tail and drops first partial line; - bounded reader skips corrupt and final partial JSONL lines without throwing; +- bounded reader skips oversized JSONL lines before parse and does not increase read budget to recover diagnostics; +- bounded reader caps candidate lines/events per file and preserves append order for accepted events; - missing event file returns empty list; - append order beats misleading future/past timestamps for stage selection; - low-value later heartbeat does not hide earlier submit/failure stage in timeout diagnostic; @@ -3220,6 +3326,10 @@ Cases: - no evidence still allows `never spawned`; - terminal launchPhase with `bootstrap_submitted` evidence does not trigger normalizer's `Teammate was never spawned during launch.`; - terminal launchPhase with only true missing member still triggers `Teammate was never spawned during launch.`; +- process transport merge uses internal `projectionPhase` and never writes persisted launchPhase values outside `active | finished | reconciled`; +- unknown persisted launchPhase strings continue to normalize through existing evaluator fallback and are not produced by new code; +- clean-success finished launch can still clear persisted launch-state even when runtime event files are missing/unreadable; +- partial/pending process member prevents clean-success clearing and keeps launch-state visible; - `bootstrap_submitted` yields pending/unconfirmed, not confirmed; - retryable rejection remains pending/warning during active launch; - parent failed event yields `failed_to_start` with exact reason; @@ -3360,6 +3470,7 @@ Use this checklist before committing implementation. - No selected user-facing transport diagnostic is stored only in `diagnostics[]`. - No pending transport state uses `runtimeDiagnosticSeverity: 'error'`. - No ordinary submitted/waiting transport state sets `bootstrapStalled`. +- No new persisted `launchPhase` string is introduced. Internal final/terminal concepts stay internal. - No stale finalizer/timeout path can persist after stop/cancel/restart identity changed. - No pending-only evidence clears provider/runtime hard failure. - No generic transport timeout overwrites provider/auth/quota/model root cause. diff --git a/landing/composables/useGithubRepo.ts b/landing/composables/useGithubRepo.ts index 1a6358ae..364c4e99 100644 --- a/landing/composables/useGithubRepo.ts +++ b/landing/composables/useGithubRepo.ts @@ -1,7 +1,7 @@ export const useGithubRepo = () => { const config = useRuntimeConfig(); const githubRepo = computed( - () => (config.public.githubRepo as string) || '777genius/claude_agent_teams_ui', + () => (config.public.githubRepo as string) || '777genius/agent-teams-ai', ); const repoUrl = computed(() => `https://github.com/${githubRepo.value}`); const releasesUrl = computed( diff --git a/landing/composables/useReleaseDownloads.ts b/landing/composables/useReleaseDownloads.ts index cc876b4d..70491956 100644 --- a/landing/composables/useReleaseDownloads.ts +++ b/landing/composables/useReleaseDownloads.ts @@ -111,7 +111,7 @@ function writeCache(data: DownloadsApiResponse): void { export const useReleaseDownloads = () => { const config = useRuntimeConfig(); - const githubRepo = (config.public.githubRepo as string) || "777genius/claude_agent_teams_ui"; + const githubRepo = (config.public.githubRepo as string) || "777genius/agent-teams-ai"; const fallbackUrl = (config.public.githubReleasesUrl as string) || diff --git a/landing/nuxt.config.ts b/landing/nuxt.config.ts index ae995584..8c5fe8d0 100644 --- a/landing/nuxt.config.ts +++ b/landing/nuxt.config.ts @@ -4,8 +4,8 @@ import { generateI18nRoutes, supportedLocales } from "./data/i18n"; // eslint-disable-next-line @typescript-eslint/no-explicit-any declare const process: any; -const siteUrl = process.env.NUXT_PUBLIC_SITE_URL || "https://777genius.github.io/claude_agent_teams_ui"; -const githubRepo = process.env.NUXT_PUBLIC_GITHUB_REPO || "777genius/claude_agent_teams_ui"; +const siteUrl = process.env.NUXT_PUBLIC_SITE_URL || "https://777genius.github.io/agent-teams-ai"; +const githubRepo = process.env.NUXT_PUBLIC_GITHUB_REPO || "777genius/agent-teams-ai"; const githubReleasesUrl = `https://github.com/${githubRepo}/releases`; const baseURL = process.env.NUXT_APP_BASE_URL || "/"; const basePrefixedDocsPath = `${baseURL.replace(/\/?$/, "/")}docs`; diff --git a/landing/product-docs/.vitepress/config.ts b/landing/product-docs/.vitepress/config.ts index e1c00dbf..bc860deb 100644 --- a/landing/product-docs/.vitepress/config.ts +++ b/landing/product-docs/.vitepress/config.ts @@ -8,7 +8,7 @@ import { fileURLToPath } from "node:url"; import { defineConfig, type DefaultTheme } from "vitepress"; import llmstxt, { copyOrDownloadAsMarkdownButtons } from "vitepress-plugin-llms"; -const REPO = "777genius/claude_agent_teams_ui"; +const REPO = "777genius/agent-teams-ai"; const SITE_TITLE = "Agent Teams Docs"; const SITE_DESCRIPTION = "Documentation for Agent Teams, a local desktop app for AI agent orchestration."; @@ -23,7 +23,7 @@ const withTrailingSlash = (value: string) => `${trimTrailingSlash(value)}/`; const appBase = normalizeBase(process.env.NUXT_APP_BASE_URL || "/"); const base = appBase === "/" ? "/docs/" : `${appBase}docs/`; const siteUrl = trimTrailingSlash( - process.env.NUXT_PUBLIC_SITE_URL || "https://777genius.github.io/claude_agent_teams_ui" + process.env.NUXT_PUBLIC_SITE_URL || "https://777genius.github.io/agent-teams-ai" ); const publicBaseUrl = appBase === "/" || siteUrl.endsWith(trimTrailingSlash(appBase)) diff --git a/landing/product-docs/.vitepress/theme/InstallBlock.vue b/landing/product-docs/.vitepress/theme/InstallBlock.vue index e9854148..d885d931 100644 --- a/landing/product-docs/.vitepress/theme/InstallBlock.vue +++ b/landing/product-docs/.vitepress/theme/InstallBlock.vue @@ -8,7 +8,7 @@ const props = withDefaults( copiedLabel?: string; }>(), { - command: "git clone https://github.com/777genius/claude_agent_teams_ui.git", + command: "git clone https://github.com/777genius/agent-teams-ai.git", label: "Click to copy", copiedLabel: "Copied" } diff --git a/landing/product-docs/guide/installation.md b/landing/product-docs/guide/installation.md index 301f5ad9..4835810d 100644 --- a/landing/product-docs/guide/installation.md +++ b/landing/product-docs/guide/installation.md @@ -28,11 +28,11 @@ For source development, use: ## Run from source - + ```bash -git clone https://github.com/777genius/claude_agent_teams_ui.git -cd claude_agent_teams_ui +git clone https://github.com/777genius/agent-teams-ai.git +cd agent-teams-ai pnpm install pnpm dev ``` diff --git a/landing/product-docs/ru/guide/installation.md b/landing/product-docs/ru/guide/installation.md index 5bf577c5..64993c7f 100644 --- a/landing/product-docs/ru/guide/installation.md +++ b/landing/product-docs/ru/guide/installation.md @@ -28,11 +28,11 @@ Agent Teams распространяется как desktop-приложение ## Запуск из исходников - + ```bash -git clone https://github.com/777genius/claude_agent_teams_ui.git -cd claude_agent_teams_ui +git clone https://github.com/777genius/agent-teams-ai.git +cd agent-teams-ai pnpm install pnpm dev ``` diff --git a/landing/server/routes/robots.txt.ts b/landing/server/routes/robots.txt.ts index 35860311..1a36226b 100644 --- a/landing/server/routes/robots.txt.ts +++ b/landing/server/routes/robots.txt.ts @@ -1,6 +1,6 @@ export default defineEventHandler((event) => { const config = useRuntimeConfig(); - const siteUrl = (config.public.siteUrl as string) || "https://777genius.github.io/claude_agent_teams_ui"; + const siteUrl = (config.public.siteUrl as string) || "https://777genius.github.io/agent-teams-ai"; setHeader(event, "content-type", "text/plain; charset=utf-8"); diff --git a/landing/server/routes/sitemap.xml.ts b/landing/server/routes/sitemap.xml.ts index c359fe7c..1108094e 100644 --- a/landing/server/routes/sitemap.xml.ts +++ b/landing/server/routes/sitemap.xml.ts @@ -12,7 +12,7 @@ const buildDate = new Date().toISOString().split("T")[0]; export default defineEventHandler((event) => { const config = useRuntimeConfig(); - const siteUrl = (config.public.siteUrl as string) || "https://777genius.github.io/claude_agent_teams_ui"; + const siteUrl = (config.public.siteUrl as string) || "https://777genius.github.io/agent-teams-ai"; setHeader(event, "content-type", "application/xml; charset=utf-8"); diff --git a/package.json b/package.json index 531038d2..2b07673f 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "claude-agent-teams-ui", + "name": "agent-teams-ai", "type": "module", "version": "1.3.0", "description": "Desktop app for managing AI agent teams, reviews, runtime logs, and provider-aware workflows", @@ -8,13 +8,13 @@ "name": "Илия (777genius)", "email": "quantjumppro@gmail.com" }, - "homepage": "https://github.com/777genius/claude_agent_teams_ui", + "homepage": "https://github.com/777genius/agent-teams-ai", "repository": { "type": "git", - "url": "https://github.com/777genius/claude_agent_teams_ui.git" + "url": "https://github.com/777genius/agent-teams-ai.git" }, "bugs": { - "url": "https://github.com/777genius/claude_agent_teams_ui/issues" + "url": "https://github.com/777genius/agent-teams-ai/issues" }, "main": "dist-electron/main/index.cjs", "scripts": { @@ -270,7 +270,7 @@ "main": "dist-electron/main/index.cjs" }, "mac": { - "artifactName": "Claude.Agent.Teams.UI-${version}-${arch}-mac.${ext}", + "artifactName": "Agent.Teams.AI-${version}-${arch}-mac.${ext}", "category": "public.app-category.developer-tools", "minimumSystemVersion": "12.0", "target": [ @@ -286,7 +286,7 @@ }, "dmg": { "sign": false, - "artifactName": "Claude.Agent.Teams.UI-${version}-${arch}.${ext}" + "artifactName": "Agent.Teams.AI-${version}-${arch}.${ext}" }, "win": { "target": [ @@ -305,13 +305,13 @@ "category": "Development" }, "appImage": { - "artifactName": "Claude.Agent.Teams.UI-${version}.${ext}" + "artifactName": "Agent.Teams.AI-${version}.${ext}" }, "deb": { "afterInstall": "resources/afterInstall.sh" }, "nsis": { - "artifactName": "Claude.Agent.Teams.UI.Setup.${version}.${ext}", + "artifactName": "Agent.Teams.AI.Setup.${version}.${ext}", "oneClick": false, "perMachine": false, "allowToChangeInstallationDirectory": true @@ -320,7 +320,7 @@ { "provider": "github", "owner": "777genius", - "repo": "claude_agent_teams_ui", + "repo": "agent-teams-ai", "releaseType": "release" } ] diff --git a/runtime.lock.json b/runtime.lock.json index 8f4467c8..c47e4b38 100644 --- a/runtime.lock.json +++ b/runtime.lock.json @@ -2,7 +2,7 @@ "version": "0.0.22", "sourceRef": "v0.0.22", "sourceRepository": "777genius/agent_teams_orchestrator", - "releaseRepository": "777genius/claude_agent_teams_ui", + "releaseRepository": "777genius/agent-teams-ai", "releaseTag": "v1.2.0", "assets": { "darwin-arm64": { diff --git a/src/features/codex-account/main/composition/createCodexAccountFeature.ts b/src/features/codex-account/main/composition/createCodexAccountFeature.ts index f42f1f46..4cf43dd5 100644 --- a/src/features/codex-account/main/composition/createCodexAccountFeature.ts +++ b/src/features/codex-account/main/composition/createCodexAccountFeature.ts @@ -28,7 +28,10 @@ import { CodexAccountSnapshotPresenter } from '../adapters/output/presenters/Cod import { CodexAccountAppServerClient } from '../infrastructure/CodexAccountAppServerClient'; import { CodexAccountEnvBuilder } from '../infrastructure/CodexAccountEnvBuilder'; import { CodexLoginSessionManager } from '../infrastructure/CodexLoginSessionManager'; -import { detectCodexLocalAccountState } from '../infrastructure/detectCodexLocalAccountArtifacts'; +import { + detectCodexLocalAccountState, + ensureCodexLegacyAuthFromActiveAccount, +} from '../infrastructure/detectCodexLocalAccountArtifacts'; import type { Logger } from '@shared/utils/logger'; import type { BrowserWindow } from 'electron'; @@ -47,6 +50,7 @@ interface CodexLastKnownAccount { interface CodexLastKnownRateLimits { payload: CodexAppServerGetAccountRateLimitsResponse; observedAt: number; + accountSignature: string | null; } interface CodexRuntimeContext { @@ -96,6 +100,20 @@ function asCodexManagedAccount( }; } +function getCodexAccountSignature( + account: CodexAppServerGetAccountResponse['account'] +): string | null { + if (!account) { + return null; + } + + if (account.type === 'apiKey') { + return 'api_key'; + } + + return `chatgpt:${account.email ?? 'unknown'}:${account.planType ?? 'unknown'}`; +} + function asRateLimitWindow( window: CodexAppServerRateLimitSnapshot['primary'] ): CodexRateLimitWindowDto | null { @@ -471,6 +489,14 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade { return snapshot; } + if (localActiveChatgptAccountPresent) { + await ensureCodexLegacyAuthFromActiveAccount().catch((error) => { + this.logger.warn('codex account legacy auth compatibility sync failed', { + error: error instanceof Error ? error.message : String(error), + }); + }); + } + const env = this.envBuilder.buildControlPlaneEnv({ binaryPath }); let appServerState: CodexAccountSnapshotDto['appServerState'] = 'healthy'; let appServerStatusMessage: string | null = null; @@ -497,7 +523,6 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade { }; } const canReuseLastKnownManagedAccount = - options?.forceRefreshToken !== true && localActiveChatgptAccountPresent && accountResult.account.account == null && accountResult.account.requiresOpenaiAuth === true && @@ -520,6 +545,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade { this.lastKnownRateLimits = { payload: accountResult.rateLimits.payload, observedAt: now, + accountSignature: getCodexAccountSignature(accountResult.account.account), }; } else if (accountResult.rateLimits) { rateLimitsReadFailure = accountResult.rateLimits.error; @@ -552,10 +578,15 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade { let rateLimits: CodexRateLimitSnapshotDto | null = null; const shouldLoadRateLimits = options?.includeRateLimits === true || this.hasFreshRateLimits(now); + const currentAccountSignature = getCodexAccountSignature(accountPayload?.account ?? null); + const reusableLastKnownRateLimits = + this.lastKnownRateLimits?.accountSignature === currentAccountSignature + ? this.lastKnownRateLimits + : null; if (shouldLoadRateLimits) { - if (this.hasFreshRateLimits(now) && this.lastKnownRateLimits) { - rateLimits = asRateLimits(this.lastKnownRateLimits.payload.rateLimits); + if (this.hasFreshRateLimits(now) && reusableLastKnownRateLimits) { + rateLimits = asRateLimits(reusableLastKnownRateLimits.payload.rateLimits); } else if (rateLimitsReadFailure) { this.logger.warn('codex account rate limits refresh failed', { error: @@ -563,6 +594,9 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade { ? rateLimitsReadFailure.message : String(rateLimitsReadFailure), }); + if (reusableLastKnownRateLimits) { + rateLimits = asRateLimits(reusableLastKnownRateLimits.payload.rateLimits); + } } } diff --git a/src/features/codex-account/main/infrastructure/detectCodexLocalAccountArtifacts.ts b/src/features/codex-account/main/infrastructure/detectCodexLocalAccountArtifacts.ts index 54d1854f..078a9c38 100644 --- a/src/features/codex-account/main/infrastructure/detectCodexLocalAccountArtifacts.ts +++ b/src/features/codex-account/main/infrastructure/detectCodexLocalAccountArtifacts.ts @@ -1,17 +1,24 @@ -import { promises as fs } from 'fs'; +import { promises as fs, type Dirent } from 'fs'; import os from 'os'; import path from 'path'; const CODEX_ACCOUNTS_DIR = path.join(os.homedir(), '.codex', 'accounts'); +const LEGACY_AUTH_SYNC_MARKER_FILE = '.agent-teams-legacy-auth-sync.json'; interface CodexAccountsRegistry { + active_account_id?: string | null; active_account_key?: string | null; + activeAccountId?: string | null; activeAccountKey?: string | null; } interface CodexAuthFile { auth_mode?: string | null; authMode?: string | null; + tokens?: { + refresh_token?: string | null; + refreshToken?: string | null; + } | null; } export interface CodexLocalAccountState { @@ -19,6 +26,25 @@ export interface CodexLocalAccountState { hasActiveChatgptAccount: boolean; } +export interface CodexActiveChatgptAuthFile { + codexHome: string; + authFilePath: string; + source: 'accounts' | 'legacy'; + activeAccountKey: string | null; +} + +export interface CodexLegacyAuthCompatibilityResult { + codexHome: string; + authFilePath: string; + source: 'accounts' | 'legacy'; + materializedLegacyAuth: boolean; +} + +interface LegacyAuthSyncMarker { + activeAccountKey?: string | null; + sourceAuthFilePath?: string | null; +} + function encodeAccountKeyForAuthFilename(accountKey: string): string { return Buffer.from(accountKey, 'utf8') .toString('base64') @@ -45,15 +71,66 @@ async function fileExists(filePath: string): Promise { } } +function hasChatgptRefreshToken(authFile: CodexAuthFile | null): boolean { + if (!authFile) { + return false; + } + + const authMode = authFile.auth_mode ?? authFile.authMode ?? null; + const refreshToken = authFile.tokens?.refresh_token ?? authFile.tokens?.refreshToken ?? null; + return ( + authMode === 'chatgpt' && typeof refreshToken === 'string' && refreshToken.trim().length > 0 + ); +} + +async function readCodexAuthFile(filePath: string): Promise { + return readJsonFile(filePath); +} + +function getLegacyAuthFilePath(accountsDir: string): string { + return path.join(path.dirname(accountsDir), 'auth.json'); +} + +function getActiveAccountKey(registry: CodexAccountsRegistry | null): string | null { + return ( + registry?.active_account_key?.trim() || + registry?.activeAccountKey?.trim() || + registry?.active_account_id?.trim() || + registry?.activeAccountId?.trim() || + null + ); +} + +function getActiveAccountAuthFileCandidates( + accountsDir: string, + activeAccountKey: string +): string[] { + const candidates = [ + path.join(accountsDir, `${encodeAccountKeyForAuthFilename(activeAccountKey)}.auth.json`), + ]; + if (!activeAccountKey.includes('/') && !activeAccountKey.includes('\\')) { + candidates.push(path.join(accountsDir, `${activeAccountKey}.auth.json`)); + } + return Array.from(new Set(candidates)); +} + export async function detectCodexLocalAccountState( accountsDir = CODEX_ACCOUNTS_DIR ): Promise { try { - const entries = await fs.readdir(accountsDir, { withFileTypes: true }); - const hasArtifacts = entries.some( + let entries: Dirent[] = []; + try { + entries = await fs.readdir(accountsDir, { withFileTypes: true }); + } catch { + entries = []; + } + const hasAccountsArtifacts = entries.some( (entry) => entry.isFile() && (entry.name === 'registry.json' || entry.name.endsWith('.auth.json')) ); + const legacyAuthFilePath = getLegacyAuthFilePath(accountsDir); + const hasLegacyAuthFile = await fileExists(legacyAuthFilePath); + const hasArtifacts = hasAccountsArtifacts || hasLegacyAuthFile; if (!hasArtifacts) { return { @@ -62,36 +139,9 @@ export async function detectCodexLocalAccountState( }; } - const registry = await readJsonFile( - path.join(accountsDir, 'registry.json') - ); - const activeAccountKey = - registry?.active_account_key?.trim() || registry?.activeAccountKey?.trim() || null; - - if (!activeAccountKey) { - return { - hasArtifacts: true, - hasActiveChatgptAccount: false, - }; - } - - const authFilePath = path.join( - accountsDir, - `${encodeAccountKeyForAuthFilename(activeAccountKey)}.auth.json` - ); - if (!(await fileExists(authFilePath))) { - return { - hasArtifacts: true, - hasActiveChatgptAccount: false, - }; - } - - const authFile = await readJsonFile(authFilePath); - const authMode = authFile?.auth_mode ?? authFile?.authMode ?? null; - return { hasArtifacts: true, - hasActiveChatgptAccount: authMode === 'chatgpt', + hasActiveChatgptAccount: (await resolveCodexActiveChatgptAuthFile(accountsDir)) !== null, }; } catch { return { @@ -101,6 +151,128 @@ export async function detectCodexLocalAccountState( } } +export async function resolveCodexActiveChatgptAuthFile( + accountsDir = CODEX_ACCOUNTS_DIR +): Promise { + const codexHome = path.dirname(accountsDir); + const legacyAuthFilePath = getLegacyAuthFilePath(accountsDir); + const registryPath = path.join(accountsDir, 'registry.json'); + const hasRegistry = await fileExists(registryPath); + + if (!hasRegistry) { + const legacyAuthFile = await readCodexAuthFile(legacyAuthFilePath); + return hasChatgptRefreshToken(legacyAuthFile) + ? { + codexHome, + authFilePath: legacyAuthFilePath, + source: 'legacy', + activeAccountKey: null, + } + : null; + } + + const registry = await readJsonFile(registryPath); + const activeAccountKey = getActiveAccountKey(registry); + if (!activeAccountKey) { + return null; + } + + for (const authFilePath of getActiveAccountAuthFileCandidates(accountsDir, activeAccountKey)) { + if (!(await fileExists(authFilePath))) { + continue; + } + if (hasChatgptRefreshToken(await readCodexAuthFile(authFilePath))) { + return { + codexHome, + authFilePath, + source: 'accounts', + activeAccountKey, + }; + } + } + + return null; +} + +async function readLegacyAuthSyncMarker(markerPath: string): Promise { + return readJsonFile(markerPath); +} + +async function getMtimeMs(filePath: string): Promise { + try { + return (await fs.stat(filePath)).mtimeMs; + } catch { + return null; + } +} + +async function writeFileAtomic(filePath: string, content: string, mode = 0o600): Promise { + const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; + await fs.writeFile(tempPath, content, { encoding: 'utf8', mode }); + await fs.rename(tempPath, filePath); + await fs.chmod(filePath, mode).catch(() => undefined); +} + +export async function ensureCodexLegacyAuthFromActiveAccount( + accountsDir = CODEX_ACCOUNTS_DIR +): Promise { + const activeAuth = await resolveCodexActiveChatgptAuthFile(accountsDir); + if (!activeAuth) { + return null; + } + + if (activeAuth.source === 'legacy') { + return { + codexHome: activeAuth.codexHome, + authFilePath: activeAuth.authFilePath, + source: activeAuth.source, + materializedLegacyAuth: false, + }; + } + + const legacyAuthFilePath = getLegacyAuthFilePath(accountsDir); + const markerPath = path.join(activeAuth.codexHome, LEGACY_AUTH_SYNC_MARKER_FILE); + const [sourceRaw, sourceMtimeMs, legacyMtimeMs, legacyAuthFile, marker] = await Promise.all([ + fs.readFile(activeAuth.authFilePath, 'utf8'), + getMtimeMs(activeAuth.authFilePath), + getMtimeMs(legacyAuthFilePath), + readCodexAuthFile(legacyAuthFilePath), + readLegacyAuthSyncMarker(markerPath), + ]); + + const legacyUsable = hasChatgptRefreshToken(legacyAuthFile); + const activeAccountChanged = + marker?.activeAccountKey !== activeAuth.activeAccountKey || + marker?.sourceAuthFilePath !== activeAuth.authFilePath; + const activeAuthNewerThanLegacy = + sourceMtimeMs !== null && (legacyMtimeMs === null || sourceMtimeMs > legacyMtimeMs + 1); + const shouldMaterialize = !legacyUsable || activeAccountChanged || activeAuthNewerThanLegacy; + + if (shouldMaterialize) { + await writeFileAtomic(legacyAuthFilePath, sourceRaw); + } + + await writeFileAtomic( + markerPath, + `${JSON.stringify( + { + activeAccountKey: activeAuth.activeAccountKey, + sourceAuthFilePath: activeAuth.authFilePath, + }, + null, + 2 + )}\n`, + 0o600 + ).catch(() => undefined); + + return { + codexHome: activeAuth.codexHome, + authFilePath: legacyAuthFilePath, + source: activeAuth.source, + materializedLegacyAuth: shouldMaterialize, + }; +} + export async function detectCodexLocalAccountArtifacts( accountsDir = CODEX_ACCOUNTS_DIR ): Promise { diff --git a/src/main/index.ts b/src/main/index.ts index 37e66234..fc20be26 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -18,6 +18,7 @@ process.env.UV_THREADPOOL_SIZE ??= '16'; // Keep userData stable before any integration can initialize Electron storage. // Sentry must stay near the top to capture early errors after storage migration. +import { earlyElectronUserDataMigrationResult } from './bootstrapUserDataMigration'; import './sentry'; import { @@ -166,7 +167,6 @@ import { markRendererUnavailable, safeSendToRenderer, } from './utils/safeWebContentsSend'; -import { earlyElectronUserDataMigrationResult } from './bootstrapUserDataMigration'; import { syncTelemetryFlag } from './sentry'; import { ActiveTeamRegistry, @@ -221,6 +221,13 @@ if ( logger.info( `Migrated Electron userData from ${earlyElectronUserDataMigrationResult.legacyPath} to ${earlyElectronUserDataMigrationResult.currentPath}` ); +} else if ( + earlyElectronUserDataMigrationResult.reason === 'legacy-reused' && + earlyElectronUserDataMigrationResult.legacyPath +) { + logger.info( + `Reusing legacy Electron userData at ${earlyElectronUserDataMigrationResult.legacyPath}` + ); } else if ( earlyElectronUserDataMigrationResult.fallbackToLegacy && earlyElectronUserDataMigrationResult.legacyPath diff --git a/src/main/services/extensions/apikeys/ApiKeyService.ts b/src/main/services/extensions/apikeys/ApiKeyService.ts index bb9280fa..5c21f514 100644 --- a/src/main/services/extensions/apikeys/ApiKeyService.ts +++ b/src/main/services/extensions/apikeys/ApiKeyService.ts @@ -57,6 +57,7 @@ export class ApiKeyService { private readonly filePath: string; private cache: StoredApiKey[] | null = null; private aesKey: Buffer | null = null; + private readonly reportedDecryptFailures = new Set(); private readonly originalProcessEnv = new Map(); constructor(claudeDir?: string) { @@ -288,7 +289,7 @@ export class ApiKeyService { return Buffer.from(stored.encryptedValue, 'base64').toString('utf-8'); } } catch (err) { - logger.error(`Failed to decrypt API key "${stored.name}":`, err); + this.reportDecryptFailure(stored, err); return ''; } } @@ -313,6 +314,30 @@ export class ApiKeyService { return matching.find((key) => key.scope === 'user') ?? null; } + private reportDecryptFailure(stored: StoredApiKey, err: unknown): void { + const method = this.resolveMethod(stored); + const failureKey = [stored.id, stored.updatedAt ?? stored.createdAt, method].join(':'); + + if (this.reportedDecryptFailures.has(failureKey)) { + return; + } + + this.reportedDecryptFailures.add(failureKey); + logger.debug( + [ + 'Stored API key could not be decrypted; ignoring it until it is saved again.', + `envVarName=${stored.envVarName}`, + `method=${method}`, + `reason=${this.getErrorMessage(err)}`, + ].join(' ') + ); + } + + private getErrorMessage(err: unknown): string { + const message = err instanceof Error ? err.message : String(err); + return message.replace(/\s+/g, ' ').trim() || 'unknown'; + } + // ── AES-256-GCM local encryption ─────────────────────────────────────── /** diff --git a/src/main/services/infrastructure/updaterReleaseMetadata.ts b/src/main/services/infrastructure/updaterReleaseMetadata.ts index 8b13107d..e1f26740 100644 --- a/src/main/services/infrastructure/updaterReleaseMetadata.ts +++ b/src/main/services/infrastructure/updaterReleaseMetadata.ts @@ -1,13 +1,13 @@ const REPO_OWNER = '777genius'; -const REPO_NAME = 'claude_agent_teams_ui'; -const PLANNED_REPO_NAME = 'agent-teams-ai'; +const REPO_NAME = 'agent-teams-ai'; +const LEGACY_REPO_NAME = 'claude_agent_teams_ui'; export function buildReleaseAssetBase(version: string, repoName = REPO_NAME): string { return `https://github.com/${REPO_OWNER}/${repoName}/releases/download/v${version}`; } export function buildReleaseAssetBases(version: string): readonly string[] { - return [buildReleaseAssetBase(version), buildReleaseAssetBase(version, PLANNED_REPO_NAME)]; + return [buildReleaseAssetBase(version), buildReleaseAssetBase(version, LEGACY_REPO_NAME)]; } export function getExpectedReleaseAssetUrl( @@ -20,12 +20,12 @@ export function getExpectedReleaseAssetUrl( switch (platform) { case 'darwin': return arch === 'arm64' - ? `${base}/Claude.Agent.Teams.UI-${version}-arm64.dmg` - : `${base}/Claude.Agent.Teams.UI-${version}-x64.dmg`; + ? `${base}/Agent.Teams.AI-${version}-arm64.dmg` + : `${base}/Agent.Teams.AI-${version}-x64.dmg`; case 'win32': - return `${base}/Claude.Agent.Teams.UI.Setup.${version}.exe`; + return `${base}/Agent.Teams.AI.Setup.${version}.exe`; case 'linux': - return `${base}/Claude.Agent.Teams.UI-${version}.AppImage`; + return `${base}/Agent.Teams.AI-${version}.AppImage`; default: return null; } @@ -58,11 +58,8 @@ export function getExpectedLatestMacArtifacts( arch: Extract ): readonly string[] { return arch === 'arm64' - ? [ - `Claude.Agent.Teams.UI-${version}-arm64-mac.zip`, - `Claude.Agent.Teams.UI-${version}-arm64.dmg`, - ] - : [`Claude.Agent.Teams.UI-${version}-x64-mac.zip`, `Claude.Agent.Teams.UI-${version}-x64.dmg`]; + ? [`Agent.Teams.AI-${version}-arm64-mac.zip`, `Agent.Teams.AI-${version}-arm64.dmg`] + : [`Agent.Teams.AI-${version}-x64-mac.zip`, `Agent.Teams.AI-${version}-x64.dmg`]; } function stripYamlScalar(rawValue: string): string { diff --git a/src/main/services/runtime/ProviderConnectionService.ts b/src/main/services/runtime/ProviderConnectionService.ts index 67ac3e0b..fb842d5a 100644 --- a/src/main/services/runtime/ProviderConnectionService.ts +++ b/src/main/services/runtime/ProviderConnectionService.ts @@ -1,3 +1,4 @@ +import { execFile } from 'node:child_process'; import path from 'node:path'; import { evaluateCodexLaunchReadiness } from '@features/codex-account'; @@ -70,6 +71,19 @@ const CODEX_CLI_PATH_ENV_VAR = 'CODEX_CLI_PATH'; const CODEX_HOME_ENV_VAR = 'CODEX_HOME'; const CODEX_FORCED_LOGIN_METHOD_ENV_VAR = 'CLAUDE_CODE_CODEX_FORCED_LOGIN_METHOD'; const CODEX_NATIVE_BACKEND_ID = 'codex-native'; +const CODEX_LOGIN_STATUS_TIMEOUT_MS = 5_000; + +type CodexCliLoginStatus = 'logged_in' | 'not_logged_in' | 'unknown'; + +interface CodexCliLoginStatusCheckResult { + status: CodexCliLoginStatus; + detail: string | null; +} + +type CodexCliLoginStatusChecker = (params: { + binaryPath: string | null; + env: NodeJS.ProcessEnv; +}) => Promise; function isCodexExecBinary(binaryPath?: string | null): boolean { const binaryName = path.basename(binaryPath?.trim() ?? '').toLowerCase(); @@ -119,6 +133,57 @@ function applyCodexForcedLoginMethodEnv( delete env[CODEX_FORCED_LOGIN_METHOD_ENV_VAR]; } +function sanitizeCodexLoginStatusDetail(detail: string): string { + return detail + .replace(/sk-[A-Za-z0-9_-]+/g, '[redacted-api-key]') + .replace( + /"?(access_token|refresh_token|id_token)"?\s*[:=]\s*"?[^"\s,}]+/gi, + '$1=[redacted-token]' + ) + .trim() + .slice(0, 500); +} + +async function checkCodexCliLoginStatus({ + binaryPath, + env, +}: { + binaryPath: string | null; + env: NodeJS.ProcessEnv; +}): Promise { + const executable = binaryPath?.trim() || 'codex'; + const args = [...buildCodexForcedLoginLaunchArgs(executable, 'chatgpt'), 'login', 'status']; + + return new Promise((resolve) => { + execFile( + executable, + args, + { + env, + timeout: CODEX_LOGIN_STATUS_TIMEOUT_MS, + windowsHide: true, + maxBuffer: 128 * 1024, + }, + (error, stdout, stderr) => { + const detail = sanitizeCodexLoginStatusDetail(`${stdout ?? ''}\n${stderr ?? ''}`); + if (!error) { + resolve({ status: 'logged_in', detail: detail || null }); + return; + } + + if (/not logged in/i.test(detail)) { + resolve({ status: 'not_logged_in', detail: detail || null }); + return; + } + + const fallback = + error instanceof Error ? sanitizeCodexLoginStatusDetail(error.message) : null; + resolve({ status: 'unknown', detail: detail || fallback || null }); + } + ); + }); +} + export class ProviderConnectionService { private static instance: ProviderConnectionService | null = null; private codexAccountFeature: Pick | null = null; @@ -127,7 +192,8 @@ export class ProviderConnectionService { constructor( private apiKeyService = new ApiKeyService(), - private readonly configManager = ConfigManager.getInstance() + private readonly configManager = ConfigManager.getInstance(), + private readonly codexCliLoginStatusChecker: CodexCliLoginStatusChecker = checkCodexCliLoginStatus ) {} static getInstance(): ProviderConnectionService { @@ -331,7 +397,7 @@ export class ProviderConnectionService { async getConfiguredConnectionIssue( env: NodeJS.ProcessEnv, providerId: CliProviderId, - _runtimeBackendOverride?: string | null + runtimeBackendOverride?: string | null ): Promise { if (providerId === 'anthropic') { if (this.getConfiguredAuthMode(providerId) !== 'api_key') { @@ -358,6 +424,8 @@ export class ProviderConnectionService { } const snapshot = this.mergeCodexApiKeyAvailability(await this.getCodexAccountSnapshot(), env); + const runtimeEnv = { ...env }; + applyCodexRuntimeContextEnv(runtimeEnv, snapshot); const readiness = evaluateCodexLaunchReadiness({ preferredAuthMode: snapshot.preferredAuthMode, managedAccount: snapshot.managedAccount, @@ -368,7 +436,41 @@ export class ProviderConnectionService { }); if (readiness.launchAllowed) { - return null; + if ( + readiness.effectiveAuthMode !== 'chatgpt' || + this.getConfiguredCodexRuntimeBackend(runtimeBackendOverride) !== CODEX_NATIVE_BACKEND_ID + ) { + return null; + } + + if (snapshot.appServerState === 'healthy' && snapshot.managedAccount?.type === 'chatgpt') { + return null; + } + + delete runtimeEnv.OPENAI_API_KEY; + delete runtimeEnv[CODEX_NATIVE_API_KEY_ENV_VAR]; + applyCodexForcedLoginMethodEnv(runtimeEnv, 'chatgpt'); + + const loginStatus = await this.codexCliLoginStatusChecker({ + binaryPath: snapshot.runtimeContext?.binaryPath?.trim() || null, + env: runtimeEnv, + }); + if (loginStatus.status === 'logged_in') { + return null; + } + + const base = + loginStatus.status === 'not_logged_in' + ? 'Codex ChatGPT account mode is selected, but the Codex CLI login status is not active for the launch runtime.' + : 'Codex ChatGPT account mode is selected, but the Codex CLI login status could not be verified for the launch runtime.'; + const reconnectHint = snapshot.localActiveChatgptAccountPresent + ? 'Reconnect ChatGPT to refresh the current Codex subscription session.' + : snapshot.localAccountArtifactsPresent + ? 'Local Codex account data exists, but the launch runtime cannot use it. Reconnect ChatGPT.' + : 'Connect ChatGPT again or switch Codex auth mode to API key.'; + return `${base} ${reconnectHint}${ + loginStatus.detail ? ` Details: ${loginStatus.detail}` : '' + }`; } if (readiness.state === 'missing_auth') { diff --git a/src/main/services/team/ProcessBootstrapTransportEvidence.ts b/src/main/services/team/ProcessBootstrapTransportEvidence.ts new file mode 100644 index 00000000..2d52b1c0 --- /dev/null +++ b/src/main/services/team/ProcessBootstrapTransportEvidence.ts @@ -0,0 +1,217 @@ +import type { PersistedTeamLaunchPhase } from '@shared/types'; + +export type ProcessBootstrapTransportEvent = Record; + +export type ProcessBootstrapTransportTerminalKind = + | 'non_retryable_submit_rejection' + | 'accepted_without_message_id' + | 'process_exited_before_confirmation' + | 'runtime_failed_before_confirmation'; + +export interface ProcessBootstrapTransportSummary { + lastStage?: string; + lastObservedAt?: string; + submitted: boolean; + hasProgress: boolean; + terminalFailure?: { + kind: ProcessBootstrapTransportTerminalKind; + reason: string; + observedAt?: string; + }; +} + +export type ProcessBootstrapTransportProjectionPhase = 'active' | 'final'; + +// These helpers intentionally summarize process transport only. They explain +// where bootstrap got stuck, but never prove teammate readiness by themselves. +const MAX_TRANSPORT_DETAIL_CHARS = 500; +const WINDOWS_RESERVED_BASENAMES = new Set([ + 'con', + 'prn', + 'aux', + 'nul', + 'com1', + 'com2', + 'com3', + 'com4', + 'com5', + 'com6', + 'com7', + 'com8', + 'com9', + 'lpt1', + 'lpt2', + 'lpt3', + 'lpt4', + 'lpt5', + 'lpt6', + 'lpt7', + 'lpt8', + 'lpt9', +]); + +const TRANSPORT_STAGE_LABELS: Record = { + process_spawned: 'process spawned', + stdout_attached: 'stdout attached', + cli_started: 'CLI started', + runtime_ready: 'runtime ready', + inbox_poller_ready: 'inbox poller ready', + mailbox_bootstrap_written: 'bootstrap mailbox row written', + bootstrap_prompt_observed: 'bootstrap prompt observed', + bootstrap_submit_attempted: 'bootstrap submit attempted', + bootstrap_submit_deferred: 'bootstrap submit deferred', + bootstrap_submit_rejected: 'bootstrap submit rejected', + bootstrap_submit_accepted_without_uuid: 'bootstrap submit accepted without message id', + bootstrap_submitted: 'bootstrap submitted', + failed: 'runtime failed', + exited: 'runtime exited', +}; + +export function sanitizeProcessRuntimeEventFilePrefix(value: string): string { + const normalized = String(value) + .replace(/[^a-zA-Z0-9]/g, '-') + .toLowerCase(); + const normalizedStem = + normalized + .trim() + .replace(/[. ]+$/g, '') + .split('.')[0] ?? normalized; + return normalizedStem && WINDOWS_RESERVED_BASENAMES.has(normalizedStem) + ? `_${normalized}` + : normalized; +} + +export function deriveProcessTransportProjectionPhase(input: { + launchPhase: PersistedTeamLaunchPhase; + finalTimeoutReached?: boolean; +}): ProcessBootstrapTransportProjectionPhase { + if (input.launchPhase !== 'active') { + return 'final'; + } + return input.finalTimeoutReached === true ? 'final' : 'active'; +} + +export function sanitizeProcessBootstrapTransportDetail(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + const sanitized = value + .replace(/\b(sk-[A-Za-z0-9_-]{12,}|[A-Za-z0-9_-]{32,})\b/g, '[redacted]') + .replace(/\/[^\s"'`]+/g, '[path]') + .replace(/\s+/g, ' ') + .trim() + .slice(0, MAX_TRANSPORT_DETAIL_CHARS); + return sanitized.length > 0 ? sanitized : undefined; +} + +function eventType(event: ProcessBootstrapTransportEvent): string { + return typeof event.type === 'string' ? event.type : ''; +} + +function eventTimestamp(event: ProcessBootstrapTransportEvent): string | undefined { + return typeof event.timestamp === 'string' && Number.isFinite(Date.parse(event.timestamp)) + ? event.timestamp + : undefined; +} + +function stageLabel(event: ProcessBootstrapTransportEvent): string | undefined { + const type = eventType(event); + const label = TRANSPORT_STAGE_LABELS[type]; + if (!label) { + return undefined; + } + const detail = sanitizeProcessBootstrapTransportDetail(event.detail); + if (type === 'process_spawned' || type === 'stdout_attached' || type === 'cli_started') { + return label; + } + return detail ? `${label}: ${detail}` : label; +} + +function terminalFailureForEvent( + event: ProcessBootstrapTransportEvent +): ProcessBootstrapTransportSummary['terminalFailure'] | undefined { + const type = eventType(event); + const label = stageLabel(event); + const observedAt = eventTimestamp(event); + if (type === 'failed') { + return { + kind: 'runtime_failed_before_confirmation', + reason: label ?? 'runtime failed before bootstrap confirmation', + observedAt, + }; + } + if (type === 'exited') { + return { + kind: 'process_exited_before_confirmation', + reason: label ?? 'runtime exited before bootstrap confirmation', + observedAt, + }; + } + if (type === 'bootstrap_submit_accepted_without_uuid') { + return { + kind: 'accepted_without_message_id', + reason: label ?? 'bootstrap submit accepted without message id', + observedAt, + }; + } + if (type === 'bootstrap_submit_rejected' && event.retryable === false) { + return { + kind: 'non_retryable_submit_rejection', + reason: label ?? 'bootstrap submit rejected', + observedAt, + }; + } + return undefined; +} + +export function summarizeProcessBootstrapTransportEvents( + events: readonly ProcessBootstrapTransportEvent[] +): ProcessBootstrapTransportSummary | null { + if (events.length === 0) { + return null; + } + let lastStage: string | undefined; + let lastObservedAt: string | undefined; + let submitted = false; + let terminalFailure: ProcessBootstrapTransportSummary['terminalFailure']; + + for (const event of events) { + const label = stageLabel(event); + if (!label) { + continue; + } + lastStage = label; + lastObservedAt = eventTimestamp(event) ?? lastObservedAt; + if (eventType(event) === 'bootstrap_submitted') { + submitted = true; + } + terminalFailure = terminalFailureForEvent(event) ?? terminalFailure; + } + + if (!lastStage && !terminalFailure) { + return null; + } + return { + ...(lastStage ? { lastStage } : {}), + ...(lastObservedAt ? { lastObservedAt } : {}), + submitted, + hasProgress: Boolean(lastStage), + ...(terminalFailure ? { terminalFailure } : {}), + }; +} + +export function buildProcessBootstrapPendingDiagnostic( + summary: ProcessBootstrapTransportSummary +): string { + return summary.lastStage + ? `Bootstrap transport reached ${summary.lastStage}; waiting for bootstrap confirmation.` + : 'Bootstrap transport is waiting for bootstrap confirmation.'; +} + +export function buildProcessBootstrapTimeoutDiagnostic( + summary: ProcessBootstrapTransportSummary +): string { + return summary.lastStage + ? `Teammate was registered but did not bootstrap-confirm before timeout. Last transport stage: ${summary.lastStage}` + : 'Teammate was registered but did not bootstrap-confirm before timeout.'; +} diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index be23428e..57f9bdbe 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -44,6 +44,7 @@ import { } from '@main/utils/pathDecoder'; import { isProcessAlive } from '@main/utils/processHealth'; import { killProcessByPid } from '@main/utils/processKill'; +import { isPathWithinRoot } from '@main/utils/pathValidation'; import { resolveInteractiveShellEnv } from '@main/utils/shellEnv'; import { shouldAutoAllow } from '@main/utils/toolApprovalRules'; import { @@ -156,6 +157,15 @@ import { parseBootstrapRuntimeProofDetail, validateBootstrapRuntimeProofEnvelope, } from './bootstrap/BootstrapProofValidation'; +import { + buildProcessBootstrapPendingDiagnostic, + buildProcessBootstrapTimeoutDiagnostic, + deriveProcessTransportProjectionPhase, + sanitizeProcessRuntimeEventFilePrefix, + summarizeProcessBootstrapTransportEvents, + type ProcessBootstrapTransportEvent, + type ProcessBootstrapTransportSummary, +} from './ProcessBootstrapTransportEvidence'; import { buildNativeAppManagedBootstrapSpecs, type NativeAppManagedBootstrapSpec, @@ -372,11 +382,46 @@ interface LaunchStateWriteResult { type BootstrapTranscriptSuccessSource = 'member_briefing' | 'assistant_text'; const BOOTSTRAP_RUNTIME_PROOF_TAIL_BYTES = 256 * 1024; +const BOOTSTRAP_RUNTIME_EVENT_MAX_LINES = 256; +const BOOTSTRAP_RUNTIME_EVENT_MAX_LINE_BYTES = 16 * 1024; -function sanitizeRuntimeEventFilePrefix(value: string): string { - return String(value || 'default') - .replace(/[^a-zA-Z0-9]/g, '-') - .toLowerCase(); +function getTeamRuntimeEventsDir(teamName: string): string { + return path.join(getTeamsBasePath(), teamName, 'runtime'); +} + +function isProcessBootstrapTransportDiagnostic(value: unknown): value is string { + return ( + typeof value === 'string' && + (value.startsWith('Bootstrap transport ') || + value.includes('Last transport stage:') || + value.startsWith('bootstrap submit ') || + value.startsWith('runtime failed') || + value.startsWith('runtime exited')) + ); +} + +function realpathIfExists(inputPath: string): string | null { + try { + return fs.realpathSync.native(inputPath); + } catch { + return null; + } +} + +function isContainedTeamRuntimeEventsPath(teamName: string, candidatePath: string): boolean { + const runtimeDir = getTeamRuntimeEventsDir(teamName); + const resolvedRuntimeDir = path.resolve(runtimeDir); + const resolvedCandidate = path.resolve(candidatePath); + if (!isPathWithinRoot(resolvedCandidate, resolvedRuntimeDir)) { + return false; + } + + const realRuntimeDir = realpathIfExists(resolvedRuntimeDir); + const realCandidate = realpathIfExists(resolvedCandidate); + if (realCandidate) { + return isPathWithinRoot(realCandidate, realRuntimeDir ?? resolvedRuntimeDir); + } + return true; } type BootstrapTranscriptOutcome = @@ -1543,6 +1588,12 @@ function looksLikeClaudeStdoutJsonFragment(text: string): boolean { ); } +const DETERMINISTIC_BOOTSTRAP_COMPLETION_RECOVERY_MS = 12_000; + +function isTerminalFailureProvisioningState(state: TeamProvisioningProgress['state']): boolean { + return state === 'failed' || state === 'cancelled' || state === 'disconnected'; +} + interface ProvisioningRun { runId: string; teamName: string; @@ -20660,7 +20711,14 @@ export class TeamProvisioningService { }; continue; } - const runtimeDiagnostic = buildRuntimeDiagnosticForSpawn(metadata); + const shouldPreserveProcessBootstrapTransportDiagnostic = + current.bootstrapConfirmed !== true && + (current.launchState === 'runtime_pending_bootstrap' || + current.launchState === 'failed_to_start') && + isProcessBootstrapTransportDiagnostic(current.runtimeDiagnostic); + const runtimeDiagnostic = shouldPreserveProcessBootstrapTransportDiagnostic + ? current.runtimeDiagnostic + : buildRuntimeDiagnosticForSpawn(metadata); const metadataLivenessKind = current.bootstrapConfirmed === true || current.launchState === 'confirmed_alive' ? metadata.livenessKind === 'runtime_process' || @@ -20673,9 +20731,11 @@ export class TeamProvisioningService { ...(metadata.model ? { runtimeModel: metadata.model } : {}), ...(metadataLivenessKind ? { livenessKind: metadataLivenessKind } : {}), ...(runtimeDiagnostic ? { runtimeDiagnostic } : {}), - ...(metadata.runtimeDiagnosticSeverity - ? { runtimeDiagnosticSeverity: metadata.runtimeDiagnosticSeverity } - : {}), + ...(shouldPreserveProcessBootstrapTransportDiagnostic + ? { runtimeDiagnosticSeverity: current.runtimeDiagnosticSeverity } + : metadata.runtimeDiagnosticSeverity + ? { runtimeDiagnosticSeverity: metadata.runtimeDiagnosticSeverity } + : {}), livenessLastCheckedAt: nowIso(), }; const failureReason = current.hardFailureReason ?? current.error; @@ -21583,6 +21643,28 @@ export class TeamProvisioningService { processTableAvailable: memberProcessTableAvailable, nowIso: nowIso(), }); + const bootstrapTransportDiagnostic = + status?.runtimeDiagnostic ?? launchMember?.runtimeDiagnostic; + const bootstrapTransportDiagnosticSeverity = + status?.runtimeDiagnosticSeverity ?? launchMember?.runtimeDiagnosticSeverity; + const bootstrapTransportLaunchState = status?.launchState ?? launchMember?.launchState; + const bootstrapTransportConfirmed = + status?.bootstrapConfirmed === true || launchMember?.bootstrapConfirmed === true; + const hasProcessBootstrapTransportDiagnostic = + (metadata.backendType === 'process' || metadata.tmuxPaneId?.startsWith('process:')) && + !bootstrapTransportConfirmed && + (bootstrapTransportLaunchState === 'runtime_pending_bootstrap' || + bootstrapTransportLaunchState === 'failed_to_start') && + isProcessBootstrapTransportDiagnostic(bootstrapTransportDiagnostic); + // Prefer bootstrap transport diagnostics over generic pid/liveness text + // while launch is unconfirmed, otherwise the UI hides the exact stage + // where process bootstrap got stuck. + const runtimeDiagnostic = hasProcessBootstrapTransportDiagnostic + ? bootstrapTransportDiagnostic + : resolved.runtimeDiagnostic; + const runtimeDiagnosticSeverity = hasProcessBootstrapTransportDiagnostic + ? (bootstrapTransportDiagnosticSeverity ?? resolved.runtimeDiagnosticSeverity) + : resolved.runtimeDiagnosticSeverity; metadataByMember.set(memberName, { ...metadata, alive: resolved.alive, @@ -21599,9 +21681,11 @@ export class TeamProvisioningService { ...(resolved.paneCurrentCommand ? { paneCurrentCommand: resolved.paneCurrentCommand } : {}), ...(resolved.runtimeSessionId ? { runtimeSessionId: resolved.runtimeSessionId } : {}), ...(resolved.runtimeLastSeenAt ? { runtimeLastSeenAt: resolved.runtimeLastSeenAt } : {}), - runtimeDiagnostic: resolved.runtimeDiagnostic, - runtimeDiagnosticSeverity: resolved.runtimeDiagnosticSeverity, - diagnostics: resolved.diagnostics, + runtimeDiagnostic, + runtimeDiagnosticSeverity, + diagnostics: hasProcessBootstrapTransportDiagnostic + ? mergeRuntimeDiagnostics(resolved.diagnostics, [bootstrapTransportDiagnostic]) + : resolved.diagnostics, }); } @@ -21659,13 +21743,43 @@ export class TeamProvisioningService { return rssBytesByPid; } - private async clearPersistedLaunchState(teamName: string): Promise { + private async clearPersistedLaunchState( + teamName: string, + options?: { expectedRunId?: string } + ): Promise { await this.enqueueLaunchStateStoreOperation(teamName, () => - this.clearPersistedLaunchStateNow(teamName) + this.clearPersistedLaunchStateNow(teamName, options) ); } - private async clearPersistedLaunchStateNow(teamName: string): Promise { + private canClearPersistedLaunchStateForRun( + teamName: string, + expectedRunId: string | undefined + ): boolean { + if (!expectedRunId) { + return true; + } + const trackedRunId = this.getTrackedRunId(teamName); + if (trackedRunId && trackedRunId !== expectedRunId) { + return false; + } + const lastWrittenRunId = this.launchStateWrittenRunIdByTeam.get(teamName); + if (lastWrittenRunId && lastWrittenRunId !== expectedRunId) { + return false; + } + return true; + } + + private async clearPersistedLaunchStateNow( + teamName: string, + options?: { expectedRunId?: string } + ): Promise { + if (!this.canClearPersistedLaunchStateForRun(teamName, options?.expectedRunId)) { + logger.debug( + `[${teamName}] Skipping stale launch-state clear for run ${options?.expectedRunId}` + ); + return; + } await this.launchStateStore.clear(teamName); this.launchStateWrittenRunIdByTeam.delete(teamName); await clearBootstrapState(teamName); @@ -22512,6 +22626,130 @@ export class TeamProvisioningService { } } + private scheduleDeterministicBootstrapCompletionRecovery(run: ProvisioningRun): void { + if (!run.deterministicBootstrap) { + return; + } + + const handle = setTimeout(() => { + void this.recoverDeterministicBootstrapCompletion(run).catch((error: unknown) => { + logger.warn( + `[${run.teamName}] Failed to recover completed deterministic bootstrap state: ${getErrorMessage( + error + )}` + ); + }); + }, DETERMINISTIC_BOOTSTRAP_COMPLETION_RECOVERY_MS); + handle.unref?.(); + } + + private async recoverDeterministicBootstrapCompletion(run: ProvisioningRun): Promise { + if ( + !run.provisioningComplete || + run.cancelRequested || + run.processKilled || + isTerminalFailureProvisioningState(run.progress.state) || + this.isProvisioningRunPromotedToAlive(run) || + this.provisioningRunByTeam.get(run.teamName) !== run.runId + ) { + return; + } + + if ((run.mixedSecondaryLanes ?? []).length > 0) { + return; + } + + const snapshot = await readBootstrapLaunchSnapshot(run.teamName).catch(() => null); + if ( + !snapshot || + (snapshot.launchPhase !== 'finished' && snapshot.launchPhase !== 'reconciled') + ) { + return; + } + + const runStartedAtMs = Date.parse(run.startedAt); + const snapshotUpdatedAtMs = Date.parse(snapshot.updatedAt); + if ( + Number.isFinite(runStartedAtMs) && + Number.isFinite(snapshotUpdatedAtMs) && + snapshotUpdatedAtMs < runStartedAtMs + ) { + return; + } + + const memberNames = this.getPersistedLaunchMemberNames(snapshot); + if (memberNames.length === 0) { + return; + } + + this.syncRunMemberSpawnStatusesFromSnapshot(run, snapshot); + await this.writeLaunchStateSnapshot(run.teamName, snapshot).catch((error: unknown) => { + logger.warn( + `[${run.teamName}] Failed to persist recovered deterministic bootstrap snapshot: ${getErrorMessage( + error + )}` + ); + }); + + const failedSpawnMembers = memberNames + .filter((memberName) => snapshot.members[memberName]?.launchState === 'failed_to_start') + .map((memberName) => ({ + name: memberName, + error: snapshot.members[memberName]?.hardFailureReason, + updatedAt: snapshot.members[memberName]?.lastEvaluatedAt ?? nowIso(), + })); + const launchSummary = snapshot.summary ?? this.getMemberLaunchSummary(run); + const hasSpawnFailures = failedSpawnMembers.length > 0; + const hasPendingBootstrap = + !hasSpawnFailures && this.hasPendingLaunchMembers(run, launchSummary, snapshot); + const messagePrefix = run.isLaunch ? 'Launch completed' : 'Team provisioned'; + const readyMessage = hasSpawnFailures + ? `${messagePrefix} with teammate errors - ${failedSpawnMembers + .map((member) => member.name) + .join(', ')} failed to start` + : hasPendingBootstrap + ? this.buildAggregatePendingLaunchMessage(messagePrefix, run, launchSummary, snapshot) + : run.isLaunch + ? 'Team launched - process alive and ready' + : 'Team provisioned - process alive and ready'; + + const progress = updateProgress(run, 'ready', readyMessage, { + cliLogsTail: extractCliLogsFromRun(run), + messageSeverity: hasSpawnFailures || hasPendingBootstrap ? 'warning' : undefined, + }); + run.onProgress(progress); + this.provisioningRunByTeam.delete(run.teamName); + this.aliveRunByTeam.set(run.teamName, run.runId); + logger.warn( + `[${run.teamName}] Recovered ready state from completed deterministic bootstrap snapshot after post-bootstrap finalization delay.` + ); + + this.teamChangeEmitter?.({ + type: 'lead-message', + teamName: run.teamName, + runId: run.runId, + detail: 'lead-session-sync', + }); + + if (!hasSpawnFailures && !hasPendingBootstrap) { + void this.fireTeamLaunchedNotification(run); + } else if (hasSpawnFailures) { + void this.fireTeamLaunchIncompleteNotification( + run, + failedSpawnMembers, + launchSummary, + snapshot + ); + } + } + + private isProvisioningRunPromotedToAlive(run: ProvisioningRun): boolean { + return ( + this.aliveRunByTeam.get(run.teamName) === run.runId && + this.provisioningRunByTeam.get(run.teamName) !== run.runId + ); + } + private syncRunMemberSpawnStatusesFromSnapshot( run: ProvisioningRun, snapshot: PersistedTeamLaunchSnapshot @@ -22995,7 +23233,7 @@ export class TeamProvisioningService { const snapshot = this.buildLiveLaunchSnapshotForRun(run, launchPhase); if (!snapshot) { if (run.isLaunch) { - await this.clearPersistedLaunchStateNow(run.teamName); + await this.clearPersistedLaunchStateNow(run.teamName, { expectedRunId: run.runId }); } return null; } @@ -23004,7 +23242,7 @@ export class TeamProvisioningService { const filteredSnapshot = this.filterRemovedMembersFromLaunchSnapshot(snapshot, metaMembers); if (filteredSnapshot.teamLaunchState === 'clean_success' && launchPhase !== 'active') { - await this.clearPersistedLaunchStateNow(run.teamName); + await this.clearPersistedLaunchStateNow(run.teamName, { expectedRunId: run.runId }); this.invalidateRuntimeSnapshotCaches(run.teamName); return null; } @@ -23943,11 +24181,11 @@ export class TeamProvisioningService { runtimeMember: PersistedRuntimeMemberLike | undefined ): string { const configuredPath = runtimeMember?.bootstrapRuntimeEventsPath?.trim(); - if (configuredPath) { + if (configuredPath && isContainedTeamRuntimeEventsPath(teamName, configuredPath)) { return configuredPath; } - const filePrefix = sanitizeRuntimeEventFilePrefix(runtimeMember?.name ?? memberName); - return path.join(getTeamsBasePath(), teamName, 'runtime', `${filePrefix}.runtime.jsonl`); + const filePrefix = sanitizeProcessRuntimeEventFilePrefix(runtimeMember?.name ?? memberName); + return path.join(getTeamRuntimeEventsDir(teamName), `${filePrefix}.runtime.jsonl`); } private async readRuntimeBootstrapProofEvents( @@ -23955,6 +24193,10 @@ export class TeamProvisioningService { ): Promise[]> { let handle: fs.promises.FileHandle | null = null; try { + const pathStat = await fs.promises.lstat(eventsPath); + if (!pathStat.isFile()) { + return []; + } handle = await fs.promises.open(eventsPath, 'r'); const stat = await handle.stat(); if (!stat.isFile() || stat.size <= 0) { @@ -23970,10 +24212,16 @@ export class TeamProvisioningService { if (start > 0) { lines.shift(); } + if (lines.length > BOOTSTRAP_RUNTIME_EVENT_MAX_LINES) { + lines.splice(0, lines.length - BOOTSTRAP_RUNTIME_EVENT_MAX_LINES); + } const events: Record[] = []; for (const rawLine of lines) { const line = rawLine.trim(); if (!line) continue; + if (Buffer.byteLength(line, 'utf8') > BOOTSTRAP_RUNTIME_EVENT_MAX_LINE_BYTES) { + continue; + } try { const parsed = JSON.parse(line) as unknown; if ( @@ -24077,6 +24325,216 @@ export class TeamProvisioningService { return latest; } + private isRuntimeBootstrapTransportEventCurrent(input: { + event: Record; + teamName: string; + memberName: string; + runtimeMember?: PersistedRuntimeMemberLike; + expectedPid?: number; + expectedBootstrapRunId?: string; + boundaryMs: number; + }): boolean { + const { event, teamName, memberName, runtimeMember, expectedPid, expectedBootstrapRunId } = + input; + const eventTeamName = typeof event.teamName === 'string' ? event.teamName.trim() : ''; + if (eventTeamName && eventTeamName !== teamName) { + return false; + } + const eventAgentId = typeof event.agentId === 'string' ? event.agentId.trim() : ''; + const expectedAgentId = runtimeMember?.agentId?.trim() ?? ''; + if (eventAgentId && expectedAgentId && eventAgentId !== expectedAgentId) { + return false; + } + const eventAgentName = typeof event.agentName === 'string' ? event.agentName.trim() : ''; + const runtimeName = runtimeMember?.name?.trim() ?? ''; + if ( + eventAgentName && + !matchesMemberNameOrBase(eventAgentName, memberName) && + !(runtimeName && matchesTeamMemberIdentity(eventAgentName, runtimeName)) + ) { + return false; + } + const eventBootstrapRunId = + typeof event.bootstrapRunId === 'string' ? event.bootstrapRunId.trim() : ''; + if ( + expectedBootstrapRunId && + eventBootstrapRunId && + eventBootstrapRunId !== expectedBootstrapRunId + ) { + return false; + } + const eventPid = typeof event.pid === 'number' && Number.isFinite(event.pid) ? event.pid : NaN; + if (typeof expectedPid === 'number' && expectedPid > 0 && eventPid !== expectedPid) { + return false; + } + if (Number.isFinite(input.boundaryMs)) { + const timestamp = typeof event.timestamp === 'string' ? event.timestamp : ''; + const timestampMs = Date.parse(timestamp); + if (!Number.isFinite(timestampMs) || timestampMs < input.boundaryMs) { + return false; + } + } + return true; + } + + private async readProcessBootstrapTransportSummary(input: { + teamName: string; + memberName: string; + member: PersistedTeamLaunchMemberState; + }): Promise { + const { teamName, memberName, member } = input; + const runtimeMember = this.resolveBootstrapRuntimeMember(teamName, memberName); + const memberRecord = member as unknown as Record; + const runtimeBackendType = + runtimeMember?.backendType?.trim() || + (typeof memberRecord.backendType === 'string' ? memberRecord.backendType.trim() : ''); + const processPaneId = + runtimeMember?.tmuxPaneId?.trim() || + (typeof memberRecord.tmuxPaneId === 'string' ? memberRecord.tmuxPaneId.trim() : ''); + if (runtimeBackendType !== 'process' && !processPaneId?.startsWith('process:')) { + return null; + } + const boundaryText = member.firstSpawnAcceptedAt ?? runtimeMember?.bootstrapExpectedAfter; + const boundaryMs = boundaryText ? Date.parse(boundaryText) : Number.NaN; + const expectedPid = + typeof member.runtimePid === 'number' && member.runtimePid > 0 + ? member.runtimePid + : typeof runtimeMember?.runtimePid === 'number' && runtimeMember.runtimePid > 0 + ? runtimeMember.runtimePid + : undefined; + const expectedBootstrapRunId = + runtimeMember?.bootstrapRunId?.trim() || + (typeof member.runtimeRunId === 'string' ? member.runtimeRunId.trim() : '') || + (typeof memberRecord.bootstrapRunId === 'string' ? memberRecord.bootstrapRunId.trim() : ''); + if (!expectedBootstrapRunId && !Number.isFinite(boundaryMs) && !expectedPid) { + return null; + } + + const eventsPath = this.getBootstrapRuntimeEventsPath(teamName, memberName, runtimeMember); + // Runtime event paths are persisted by process teammates. Keep both path + // containment and payload identity checks so stale or foreign JSONL cannot + // affect another team/member launch. + if (!isContainedTeamRuntimeEventsPath(teamName, eventsPath)) { + return null; + } + const events = await this.readRuntimeBootstrapProofEvents(eventsPath); + const currentEvents = events.filter((event) => + this.isRuntimeBootstrapTransportEventCurrent({ + event, + teamName, + memberName, + runtimeMember, + expectedPid, + expectedBootstrapRunId, + boundaryMs, + }) + ); + return summarizeProcessBootstrapTransportEvents( + currentEvents as ProcessBootstrapTransportEvent[] + ); + } + + private applyProcessBootstrapTransportOverlay(input: { + member: PersistedTeamLaunchMemberState; + summary: ProcessBootstrapTransportSummary | null; + launchPhase: PersistedTeamLaunchPhase; + finalTimeoutReached?: boolean; + }): PersistedTeamLaunchMemberState { + const { member, summary } = input; + if ( + !summary || + member.bootstrapConfirmed || + member.launchState === 'confirmed_alive' || + member.launchState === 'skipped_for_launch' || + member.launchState === 'runtime_pending_permission' || + member.skippedForLaunch === true + ) { + return member; + } + const existingFailure = member.hardFailureReason ?? member.runtimeDiagnostic; + if ( + member.launchState === 'failed_to_start' && + member.hardFailure === true && + !isAutoClearableLaunchFailureReason(existingFailure) + ) { + return member; + } + + const projectionPhase = deriveProcessTransportProjectionPhase({ + launchPhase: input.launchPhase, + finalTimeoutReached: input.finalTimeoutReached, + }); + const base: PersistedTeamLaunchMemberState = { + ...member, + agentToolAccepted: true, + lastEvaluatedAt: nowIso(), + }; + + if (summary.terminalFailure) { + // Terminal transport events are failures for bootstrap only. They are + // surfaced as hard failure text, but still do not create readiness proof. + const reason = summary.terminalFailure.reason; + return { + ...base, + launchState: 'failed_to_start', + bootstrapConfirmed: false, + hardFailure: true, + hardFailureReason: reason, + runtimeDiagnostic: reason, + runtimeDiagnosticSeverity: 'error', + diagnostics: mergeRuntimeDiagnostics(base.diagnostics, [reason, summary.lastStage]), + sources: { + ...(base.sources ?? {}), + hardFailureSignal: true, + }, + }; + } + + if (!summary.hasProgress) { + return member; + } + + if (projectionPhase === 'final') { + const reason = buildProcessBootstrapTimeoutDiagnostic(summary); + return { + ...base, + launchState: 'failed_to_start', + bootstrapConfirmed: false, + hardFailure: true, + hardFailureReason: reason, + runtimeDiagnostic: reason, + runtimeDiagnosticSeverity: 'error', + diagnostics: mergeRuntimeDiagnostics(base.diagnostics, [reason, summary.lastStage]), + sources: { + ...(base.sources ?? {}), + hardFailureSignal: true, + }, + }; + } + + const runtimeDiagnostic = buildProcessBootstrapPendingDiagnostic(summary); + // Active launch progress remains pending. A submitted bootstrap prompt is + // not enough for confirmed_alive; durable bootstrap proof is handled by + // the runtime/transcript evidence path above. + return { + ...base, + launchState: 'runtime_pending_bootstrap', + bootstrapConfirmed: false, + hardFailure: false, + hardFailureReason: undefined, + runtimeDiagnostic, + runtimeDiagnosticSeverity: summary.submitted ? 'info' : 'warning', + diagnostics: mergeRuntimeDiagnostics(base.diagnostics, [ + runtimeDiagnostic, + summary.lastStage, + ]), + sources: { + ...(base.sources ?? {}), + hardFailureSignal: undefined, + }, + }; + } + private async applyBootstrapTranscriptEvidenceOverlay( snapshot: PersistedTeamLaunchSnapshot | null ): Promise { @@ -24376,7 +24834,7 @@ export class TeamProvisioningService { const now = nowIso(); for (const expected of persistedMemberNames) { const bootstrapMember = bootstrapSnapshot?.members[expected]; - const current = nextMembers[expected] ?? { + let current = nextMembers[expected] ?? { name: expected, launchState: 'starting', agentToolAccepted: false, @@ -24514,9 +24972,19 @@ export class TeamProvisioningService { } } const graceExpired = - current.agentToolAccepted === true && - Number.isFinite(acceptedAtMs) && - Date.now() - acceptedAtMs >= MEMBER_LAUNCH_GRACE_MS; + Number.isFinite(acceptedAtMs) && Date.now() - acceptedAtMs >= MEMBER_LAUNCH_GRACE_MS; + if (!isOpenCodeSecondaryLaneMember) { + current = this.applyProcessBootstrapTransportOverlay({ + member: current, + summary: await this.readProcessBootstrapTransportSummary({ + teamName, + memberName: expected, + member: current, + }), + launchPhase: persistedWithCommittedEvidence.launchPhase, + finalTimeoutReached: graceExpired, + }); + } if ( isOpenCodeSecondaryLaneMember && shouldMarkPersistedOpenCodeBootstrapStalled(current, Date.now()) @@ -24540,6 +25008,7 @@ export class TeamProvisioningService { ]); } if ( + current.agentToolAccepted === true && !current.bootstrapConfirmed && !current.runtimeAlive && !current.hardFailure && @@ -27665,6 +28134,7 @@ export class TeamProvisioningService { } run.provisioningComplete = true; + this.scheduleDeterministicBootstrapCompletionRecovery(run); this.resetRuntimeToolActivity(run, this.getRunLeadName(run)); this.setLeadActivity(run, 'idle'); @@ -27743,6 +28213,9 @@ export class TeamProvisioningService { const hasPendingBootstrap = !hasSpawnFailures && this.hasPendingLaunchMembers(run, launchSummary, persistedLaunchSnapshot); + if (this.isProvisioningRunPromotedToAlive(run)) { + return; + } const readyMessage = hasSpawnFailures ? `Launch completed with teammate errors — ${failedSpawnMembers .map((member) => member.name) @@ -27923,6 +28396,9 @@ export class TeamProvisioningService { const hasPendingBootstrap = !hasSpawnFailures && this.hasPendingLaunchMembers(run, launchSummary, persistedLaunchSnapshot); + if (this.isProvisioningRunPromotedToAlive(run)) { + return; + } const progress = updateProgress( run, 'ready', @@ -29357,30 +29833,36 @@ export class TeamProvisioningService { const envPatch: NodeJS.ProcessEnv = {}; let usesAnthropicApiKeyHelper = false; for (const providerId of crossProviderIds) { + let env: ProvisioningEnvResolution; try { - const env = await this.buildProvisioningEnv(providerId, undefined, { + env = await this.buildProvisioningEnv(providerId, undefined, { teamRuntimeAuth: options?.teamRuntimeAuth, }); - args.push(...(await this.buildRuntimeTurnSettledHookSettingsArgs(providerId))); - const providerArgs = env.providerArgs ?? []; - providerArgsByProvider.set(providerId, providerArgs); - if (env.anthropicApiKeyHelper) { - usesAnthropicApiKeyHelper = true; - Object.assign(envPatch, env.anthropicApiKeyHelper.envPatch); - } - const flattenedArgs = - providerId === 'anthropic' && env.anthropicApiKeyHelper - ? filterOutSettingsPathArgs(providerArgs, env.anthropicApiKeyHelper.settingsPath) - : providerArgs; - if (flattenedArgs.length > 0) { - args.push(...flattenedArgs); - } } catch (error) { console.error( `[TeamProvisioningService] Failed to build cross-provider args for provider "${providerId}"`, error ); // Best-effort: don't block launch if cross-provider env resolution fails + // before the provider can report a concrete auth/readiness issue. + continue; + } + if (env.warning) { + throw new Error(`${getTeamProviderLabel(providerId)}: ${env.warning}`); + } + args.push(...(await this.buildRuntimeTurnSettledHookSettingsArgs(providerId))); + const providerArgs = env.providerArgs ?? []; + providerArgsByProvider.set(providerId, providerArgs); + if (env.anthropicApiKeyHelper) { + usesAnthropicApiKeyHelper = true; + Object.assign(envPatch, env.anthropicApiKeyHelper.envPatch); + } + const flattenedArgs = + providerId === 'anthropic' && env.anthropicApiKeyHelper + ? filterOutSettingsPathArgs(providerArgs, env.anthropicApiKeyHelper.settingsPath) + : providerArgs; + if (flattenedArgs.length > 0) { + args.push(...flattenedArgs); } } return { args, providerArgsByProvider, envPatch, usesAnthropicApiKeyHelper }; diff --git a/src/main/utils/electronUserDataMigration.ts b/src/main/utils/electronUserDataMigration.ts index 1e29fabe..fb9db857 100644 --- a/src/main/utils/electronUserDataMigration.ts +++ b/src/main/utils/electronUserDataMigration.ts @@ -4,6 +4,8 @@ import * as path from 'path'; const LEGACY_USER_DATA_DIR_NAMES = [ 'Claude Agent Teams UI', 'claude-agent-teams-ui', + 'agent-teams-ai', + 'Agent Teams UI', 'claude-devtools', 'claude-code-context', ] as const; @@ -20,6 +22,7 @@ export interface ElectronUserDataMigrationResult { fallbackToLegacy: boolean; reason: | 'migrated' + | 'legacy-reused' | 'current-populated' | 'current-path-exists' | 'legacy-missing' @@ -35,8 +38,42 @@ interface LoggerLike { interface ElectronUserDataMigrationOptions { logger?: LoggerLike; copyDirectory?: (sourcePath: string, targetPath: string) => void; + strategy?: 'reuse-legacy' | 'copy'; } +const TRANSIENT_CHROMIUM_DIRECTORY_NAMES = new Set([ + 'Cache', + 'Code Cache', + 'Crashpad', + 'Crash Reports', + 'DawnGraphiteCache', + 'DawnWebGPUCache', + 'GPUCache', + 'GrShaderCache', + 'ShaderCache', + 'Session Storage', + 'Shared Dictionary', + 'Service Worker', + 'VideoDecodeStats', + 'blob_storage', +]); + +const TRANSIENT_CHROMIUM_FILE_NAMES = new Set([ + 'DIPS', + 'DIPS-journal', + 'DIPS-wal', + 'LOCK', + 'Network Persistent State', + 'SingletonCookie', + 'SingletonLock', + 'SingletonSocket', + 'TransportSecurity', + 'Trust Tokens', + 'Trust Tokens-journal', +]); + +const STALE_MIGRATION_TEMP_MAX_AGE_MS = 60 * 60 * 1000; + export function getLegacyElectronUserDataCandidates(currentPath: string): string[] { const parent = path.dirname(currentPath); const normalizedCurrent = path.resolve(currentPath); @@ -55,6 +92,7 @@ export function migrateElectronUserDataDirectory( try { currentPath = app.getPath('userData'); + scheduleStaleMigrationTempCleanup(currentPath, logger); } catch (error) { logger?.warn(`Electron userData migration skipped: ${stringifyError(error)}`); return { @@ -66,7 +104,7 @@ export function migrateElectronUserDataDirectory( }; } - if (directoryExists(currentPath) && directoryHasEntries(currentPath)) { + if (directoryExists(currentPath) && directoryHasDurableUserDataEntries(currentPath)) { return { currentPath, legacyPath: null, @@ -98,6 +136,29 @@ export function migrateElectronUserDataDirectory( }; } + if ((options.strategy ?? 'reuse-legacy') === 'reuse-legacy') { + try { + setLegacyElectronPaths(app, legacyPath, logger); + logger?.info(`Reusing legacy Electron userData at ${legacyPath}`); + return { + currentPath, + legacyPath, + migrated: false, + fallbackToLegacy: false, + reason: 'legacy-reused', + }; + } catch (error) { + logger?.warn(`Electron userData legacy reuse failed: ${stringifyError(error)}`); + return { + currentPath, + legacyPath, + migrated: false, + fallbackToLegacy: false, + reason: 'error', + }; + } + } + const migrated = copyLegacyUserDataDirectory( legacyPath, currentPath, @@ -115,7 +176,7 @@ export function migrateElectronUserDataDirectory( }; } - if (directoryExists(currentPath) && directoryHasEntries(currentPath)) { + if (directoryExists(currentPath) && directoryHasDurableUserDataEntries(currentPath)) { return { currentPath, legacyPath: null, @@ -150,7 +211,10 @@ export function migrateElectronUserDataDirectory( function selectLegacyElectronUserDataPath(currentPath: string): string | null { const candidates = getLegacyElectronUserDataCandidates(currentPath).filter(directoryExists); return ( - candidates.find((candidatePath) => directoryHasEntries(candidatePath)) ?? candidates[0] ?? null + candidates.find((candidatePath) => directoryHasDurableUserDataEntries(candidatePath)) ?? + candidates.find((candidatePath) => directoryHasEntries(candidatePath)) ?? + candidates[0] ?? + null ); } @@ -212,9 +276,73 @@ function copyDirectorySync(sourcePath: string, targetPath: string): void { recursive: true, errorOnExist: false, force: false, + filter: (sourceEntryPath) => shouldCopyElectronUserDataEntry(sourcePath, sourceEntryPath), }); } +function scheduleStaleMigrationTempCleanup(currentPath: string, logger?: LoggerLike): void { + const parent = path.dirname(currentPath); + const prefix = `${path.basename(currentPath)}.migrating-`; + + const timeout = setTimeout(() => { + fs.readdir(parent, { withFileTypes: true }, (readError, entries) => { + if (readError) { + return; + } + + const now = Date.now(); + for (const entry of entries) { + if (!entry.isDirectory() || !entry.name.startsWith(prefix)) { + continue; + } + + const stalePath = path.join(parent, entry.name); + fs.stat(stalePath, (statError, stats) => { + if (statError || now - stats.mtimeMs < STALE_MIGRATION_TEMP_MAX_AGE_MS) { + return; + } + + fs.rm(stalePath, { recursive: true, force: true }, (removeError) => { + if (removeError) { + logger?.warn( + `Failed to remove stale Electron userData migration temp path: ${stringifyError( + removeError + )}` + ); + return; + } + logger?.info(`Removed stale Electron userData migration temp path: ${stalePath}`); + }); + }); + } + }); + }, 30_000); + + timeout.unref?.(); +} + +export function shouldCopyElectronUserDataEntry( + sourceRootPath: string, + sourceEntryPath: string +): boolean { + const relativePath = path.relative(sourceRootPath, sourceEntryPath); + if (!relativePath || relativePath === '.') { + return true; + } + + const segments = relativePath.split(path.sep).filter(Boolean); + if (segments.some((segment) => TRANSIENT_CHROMIUM_DIRECTORY_NAMES.has(segment))) { + return false; + } + + const basename = segments[segments.length - 1]; + if (TRANSIENT_CHROMIUM_FILE_NAMES.has(basename)) { + return false; + } + + return true; +} + function pathExists(targetPath: string): boolean { try { fs.accessSync(targetPath); @@ -240,6 +368,35 @@ function directoryHasEntries(targetPath: string): boolean { } } +function directoryHasDurableUserDataEntries(targetPath: string): boolean { + try { + return directoryHasDurableUserDataEntriesWithin(targetPath, targetPath); + } catch { + return false; + } +} + +function directoryHasDurableUserDataEntriesWithin(rootPath: string, targetPath: string): boolean { + const entries = fs.readdirSync(targetPath, { withFileTypes: true }); + + for (const entry of entries) { + const entryPath = path.join(targetPath, entry.name); + if (!shouldCopyElectronUserDataEntry(rootPath, entryPath)) { + continue; + } + + if (!entry.isDirectory()) { + return true; + } + + if (directoryHasDurableUserDataEntriesWithin(rootPath, entryPath)) { + return true; + } + } + + return false; +} + function stringifyError(error: unknown): string { return error instanceof Error ? error.message : String(error); } diff --git a/src/renderer/components/common/UpdateDialog.tsx b/src/renderer/components/common/UpdateDialog.tsx index 3b3b0afb..13138050 100644 --- a/src/renderer/components/common/UpdateDialog.tsx +++ b/src/renderer/components/common/UpdateDialog.tsx @@ -100,7 +100,7 @@ export const UpdateDialog = (): React.JSX.Element | null => { : releaseNotes; const releaseUrl = availableVersion - ? `https://github.com/777genius/claude_agent_teams_ui/releases/tag/v${availableVersion}` + ? `https://github.com/777genius/agent-teams-ai/releases/tag/v${availableVersion}` : null; const openReleaseOnGitHub = (): void => { diff --git a/src/renderer/components/layout/TabBarActions.tsx b/src/renderer/components/layout/TabBarActions.tsx index b0f6f75e..0789f35c 100644 --- a/src/renderer/components/layout/TabBarActions.tsx +++ b/src/renderer/components/layout/TabBarActions.tsx @@ -117,13 +117,13 @@ export const TabBarActions = (): React.JSX.Element => { onClick={async () => { if (isElectronMode()) { await window.electronAPI.openExternal( - 'https://github.com/777genius/claude_agent_teams_ui' + 'https://github.com/777genius/agent-teams-ai' ); return; } window.open( - 'https://github.com/777genius/claude_agent_teams_ui', + 'https://github.com/777genius/agent-teams-ai', '_blank', 'noopener,noreferrer' ); diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx index 8b7e02a1..f65650ff 100644 --- a/src/renderer/components/team/members/MemberCard.tsx +++ b/src/renderer/components/team/members/MemberCard.tsx @@ -106,13 +106,6 @@ function normalizeLaunchFailureReason(value: string | undefined): string | null return normalized && normalized.length > 0 ? normalized : null; } -function truncateLaunchFailureReason(value: string, maxLength = 220): string { - if (value.length <= maxLength) { - return value; - } - return `${value.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`; -} - function getLaunchFailureLinkLabel(url: string): string { try { const parsed = new URL(url); @@ -298,9 +291,6 @@ export const MemberCard = memo(function MemberCard({ const launchFailureReason = showFailedLaunchBadge ? normalizeLaunchFailureReason(rawLaunchFailureReason) : null; - const displayedLaunchFailureReason = launchFailureReason - ? truncateLaunchFailureReason(launchFailureReason) - : null; const hasLiveLaunchControls = isTeamAlive === true || isTeamProvisioning === true || isLaunchSettling === true; const hasRestartMemberControl = @@ -512,14 +502,14 @@ export const MemberCard = memo(function MemberCard({ ) : null} ) : null} - {displayedLaunchFailureReason ? ( + {launchFailureReason ? (
- - {renderLinkifiedText(displayedLaunchFailureReason, { + + {renderLinkifiedText(launchFailureReason, { linkClassName: 'underline underline-offset-2 hover:text-red-200', stopPropagation: true, getLinkLabel: getLaunchFailureLinkLabel, diff --git a/src/renderer/utils/bugReportUtils.ts b/src/renderer/utils/bugReportUtils.ts index ee66f3a3..6db1a81b 100644 --- a/src/renderer/utils/bugReportUtils.ts +++ b/src/renderer/utils/bugReportUtils.ts @@ -1,6 +1,6 @@ import packageJson from '../../../package.json'; -const GITHUB_BUG_REPORT_URL = 'https://github.com/777genius/claude_agent_teams_ui/issues/new'; +const GITHUB_BUG_REPORT_URL = 'https://github.com/777genius/agent-teams-ai/issues/new'; const MAX_TITLE_LENGTH = 120; const URL_MAX_STACK_LENGTH = 1800; const URL_MAX_COMPONENT_STACK_LENGTH = 1200; diff --git a/test/features/codex-account/main/createCodexAccountFeature.test.ts b/test/features/codex-account/main/createCodexAccountFeature.test.ts index e6926f33..5ad9c105 100644 --- a/test/features/codex-account/main/createCodexAccountFeature.test.ts +++ b/test/features/codex-account/main/createCodexAccountFeature.test.ts @@ -85,6 +85,7 @@ vi.mock( detectCodexLocalAccountState: detectLocalAccountStateMock, detectCodexLocalAccountArtifacts: async () => (await detectLocalAccountStateMock()).hasArtifacts, + ensureCodexLegacyAuthFromActiveAccount: vi.fn().mockResolvedValue(null), }) ); @@ -610,6 +611,99 @@ describe('createCodexAccountFeature', () => { } }); + it('keeps last known rate limits visible during a transient optional rate limit refresh failure', async () => { + readAccountMock.mockResolvedValue({ + account: createAccountResponse(), + initialize: { + codexHome: '/Users/test/.codex', + platformFamily: 'unix', + platformOs: 'macos', + }, + }); + readRateLimitsMock + .mockResolvedValueOnce(createRateLimitsResponse()) + .mockRejectedValueOnce(new Error('codex account authentication required to read rate limits')); + const logger = createLoggerPort(); + const feature = createCodexAccountFeature({ + logger, + configManager: createConfigManager('chatgpt'), + }); + const dateNowSpy = vi.spyOn(Date, 'now'); + + try { + dateNowSpy.mockReturnValue(1_776_000_000_000); + const firstSnapshot = await feature.refreshSnapshot({ includeRateLimits: true }); + dateNowSpy.mockReturnValue(1_776_000_060_000); + const secondSnapshot = await feature.refreshSnapshot({ includeRateLimits: true }); + + expect(firstSnapshot.rateLimits?.primary?.usedPercent).toBe(77); + expect(secondSnapshot.appServerState).toBe('healthy'); + expect(secondSnapshot.managedAccount?.email).toBe('user@example.com'); + expect(secondSnapshot.rateLimits?.primary?.usedPercent).toBe(77); + expect(logger.warn).toHaveBeenCalledWith('codex account rate limits refresh failed', { + error: 'codex account authentication required to read rate limits', + }); + } finally { + dateNowSpy.mockRestore(); + await feature.dispose(); + } + }); + + it('does not reuse stale rate limits after the active ChatGPT account changes', async () => { + readAccountMock + .mockResolvedValueOnce({ + account: createAccountResponse({ + account: { + type: 'chatgpt', + email: 'first@example.com', + planType: 'pro', + }, + }), + initialize: { + codexHome: '/Users/test/.codex', + platformFamily: 'unix', + platformOs: 'macos', + }, + }) + .mockResolvedValueOnce({ + account: createAccountResponse({ + account: { + type: 'chatgpt', + email: 'second@example.com', + planType: 'pro', + }, + }), + initialize: { + codexHome: '/Users/test/.codex', + platformFamily: 'unix', + platformOs: 'macos', + }, + }); + readRateLimitsMock + .mockResolvedValueOnce(createRateLimitsResponse()) + .mockRejectedValueOnce(new Error('rate limit service unavailable')); + const feature = createCodexAccountFeature({ + logger: createLoggerPort(), + configManager: createConfigManager('chatgpt'), + }); + const dateNowSpy = vi.spyOn(Date, 'now'); + + try { + dateNowSpy.mockReturnValue(1_776_000_000_000); + const firstSnapshot = await feature.refreshSnapshot({ includeRateLimits: true }); + dateNowSpy.mockReturnValue(1_776_000_060_000); + const secondSnapshot = await feature.refreshSnapshot({ includeRateLimits: true }); + + expect(firstSnapshot.managedAccount?.email).toBe('first@example.com'); + expect(firstSnapshot.rateLimits?.primary?.usedPercent).toBe(77); + expect(secondSnapshot.managedAccount?.email).toBe('second@example.com'); + expect(secondSnapshot.rateLimits).toBeNull(); + } finally { + dateNowSpy.mockRestore(); + await feature.dispose(); + } + }); + it('keeps the last known managed account during a transient degraded read', async () => { readAccountMock .mockResolvedValueOnce({ @@ -686,7 +780,7 @@ describe('createCodexAccountFeature', () => { dateNowSpy.mockReturnValue(1_776_000_000_000); const firstSnapshot = await feature.refreshSnapshot(); dateNowSpy.mockReturnValue(1_776_000_006_000); - const secondSnapshot = await feature.refreshSnapshot(); + const secondSnapshot = await feature.refreshSnapshot({ forceRefreshToken: true }); expect(firstSnapshot.managedAccount?.email).toBe('user@example.com'); expect(secondSnapshot.managedAccount).toMatchObject({ diff --git a/test/features/codex-account/main/infrastructure/detectCodexLocalAccountArtifacts.test.ts b/test/features/codex-account/main/infrastructure/detectCodexLocalAccountArtifacts.test.ts index 82dff65d..71cbd4b8 100644 --- a/test/features/codex-account/main/infrastructure/detectCodexLocalAccountArtifacts.test.ts +++ b/test/features/codex-account/main/infrastructure/detectCodexLocalAccountArtifacts.test.ts @@ -1,5 +1,5 @@ // @vitest-environment node -import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises'; +import { mkdtemp, mkdir, readFile, rm, utimes, writeFile } from 'fs/promises'; import os from 'os'; import path from 'path'; @@ -8,6 +8,8 @@ import { afterEach, describe, expect, it } from 'vitest'; import { detectCodexLocalAccountArtifacts, detectCodexLocalAccountState, + ensureCodexLegacyAuthFromActiveAccount, + resolveCodexActiveChatgptAuthFile, } from '../../../../../src/features/codex-account/main/infrastructure/detectCodexLocalAccountArtifacts'; const tempDirs: string[] = []; @@ -18,6 +20,13 @@ async function makeTempDir(): Promise { return dir; } +async function makeCodexHome(): Promise<{ codexHome: string; accountsDir: string }> { + const codexHome = await makeTempDir(); + const accountsDir = path.join(codexHome, 'accounts'); + await mkdir(accountsDir, { recursive: true }); + return { codexHome, accountsDir }; +} + afterEach(async () => { await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); }); @@ -55,7 +64,7 @@ describe('detectCodexLocalAccountArtifacts', () => { }); it('detects a locally selected ChatGPT account from the registry and active auth file', async () => { - const accountsDir = await makeTempDir(); + const { accountsDir } = await makeCodexHome(); const activeAccountKey = 'user-test::chatgpt-account'; await writeFile( path.join(accountsDir, 'registry.json'), @@ -64,7 +73,171 @@ describe('detectCodexLocalAccountArtifacts', () => { ); await writeFile( path.join(accountsDir, `${encodeAccountKeyForAuthFilename(activeAccountKey)}.auth.json`), - JSON.stringify({ auth_mode: 'chatgpt' }), + JSON.stringify({ auth_mode: 'chatgpt', tokens: { refresh_token: 'refresh-token' } }), + 'utf8' + ); + + await expect(detectCodexLocalAccountState(accountsDir)).resolves.toEqual({ + hasArtifacts: true, + hasActiveChatgptAccount: true, + }); + }); + + it('resolves the active accounts-format auth file before legacy auth when a registry exists', async () => { + const { codexHome, accountsDir } = await makeCodexHome(); + const activeAccountKey = 'user-active::chatgpt-account'; + await writeFile( + path.join(codexHome, 'auth.json'), + JSON.stringify({ auth_mode: 'chatgpt', tokens: { refresh_token: 'legacy-refresh-token' } }), + 'utf8' + ); + await writeFile( + path.join(accountsDir, 'registry.json'), + JSON.stringify({ active_account_key: activeAccountKey }), + 'utf8' + ); + const activeAuthPath = path.join( + accountsDir, + `${encodeAccountKeyForAuthFilename(activeAccountKey)}.auth.json` + ); + await writeFile( + activeAuthPath, + JSON.stringify({ auth_mode: 'chatgpt', tokens: { refresh_token: 'active-refresh-token' } }), + 'utf8' + ); + + await expect(resolveCodexActiveChatgptAuthFile(accountsDir)).resolves.toMatchObject({ + authFilePath: activeAuthPath, + source: 'accounts', + activeAccountKey, + }); + }); + + it('materializes active accounts-format auth into legacy auth.json for Codex CLI compatibility', async () => { + const { codexHome, accountsDir } = await makeCodexHome(); + const activeAccountKey = 'user-active::chatgpt-account'; + const authPayload = { + auth_mode: 'chatgpt', + tokens: { refresh_token: 'active-refresh-token', access_token: 'active-access-token' }, + }; + await writeFile( + path.join(accountsDir, 'registry.json'), + JSON.stringify({ active_account_key: activeAccountKey }), + 'utf8' + ); + await writeFile( + path.join(accountsDir, `${encodeAccountKeyForAuthFilename(activeAccountKey)}.auth.json`), + JSON.stringify(authPayload), + 'utf8' + ); + + const result = await ensureCodexLegacyAuthFromActiveAccount(accountsDir); + + expect(result).toMatchObject({ + codexHome, + authFilePath: path.join(codexHome, 'auth.json'), + source: 'accounts', + materializedLegacyAuth: true, + }); + await expect(readFile(path.join(codexHome, 'auth.json'), 'utf8')).resolves.toBe( + JSON.stringify(authPayload) + ); + }); + + it('does not overwrite a newer synced legacy auth file for the same active account', async () => { + const { codexHome, accountsDir } = await makeCodexHome(); + const activeAccountKey = 'user-active::chatgpt-account'; + const activeAuthPath = path.join( + accountsDir, + `${encodeAccountKeyForAuthFilename(activeAccountKey)}.auth.json` + ); + await writeFile( + path.join(accountsDir, 'registry.json'), + JSON.stringify({ active_account_key: activeAccountKey }), + 'utf8' + ); + await writeFile( + activeAuthPath, + JSON.stringify({ auth_mode: 'chatgpt', tokens: { refresh_token: 'first-refresh-token' } }), + 'utf8' + ); + await ensureCodexLegacyAuthFromActiveAccount(accountsDir); + + const refreshedLegacyPayload = JSON.stringify({ + auth_mode: 'chatgpt', + tokens: { refresh_token: 'runtime-refreshed-token' }, + }); + const legacyAuthPath = path.join(codexHome, 'auth.json'); + await writeFile(legacyAuthPath, refreshedLegacyPayload, 'utf8'); + const future = new Date(Date.now() + 60_000); + await utimes(legacyAuthPath, future, future); + + const result = await ensureCodexLegacyAuthFromActiveAccount(accountsDir); + + expect(result?.materializedLegacyAuth).toBe(false); + await expect(readFile(legacyAuthPath, 'utf8')).resolves.toBe(refreshedLegacyPayload); + }); + + it('refreshes legacy auth when the selected accounts-format account changes', async () => { + const { codexHome, accountsDir } = await makeCodexHome(); + const firstAccountKey = 'user-first::chatgpt-account'; + const secondAccountKey = 'user-second::chatgpt-account'; + await writeFile( + path.join(accountsDir, 'registry.json'), + JSON.stringify({ active_account_key: firstAccountKey }), + 'utf8' + ); + await writeFile( + path.join(accountsDir, `${encodeAccountKeyForAuthFilename(firstAccountKey)}.auth.json`), + JSON.stringify({ auth_mode: 'chatgpt', tokens: { refresh_token: 'first-refresh-token' } }), + 'utf8' + ); + await writeFile( + path.join(accountsDir, `${encodeAccountKeyForAuthFilename(secondAccountKey)}.auth.json`), + JSON.stringify({ auth_mode: 'chatgpt', tokens: { refresh_token: 'second-refresh-token' } }), + 'utf8' + ); + await ensureCodexLegacyAuthFromActiveAccount(accountsDir); + + await writeFile( + path.join(accountsDir, 'registry.json'), + JSON.stringify({ active_account_key: secondAccountKey }), + 'utf8' + ); + + const result = await ensureCodexLegacyAuthFromActiveAccount(accountsDir); + + expect(result?.materializedLegacyAuth).toBe(true); + await expect(readFile(path.join(codexHome, 'auth.json'), 'utf8')).resolves.toContain( + 'second-refresh-token' + ); + }); + + it('requires a ChatGPT refresh token for the selected account', async () => { + const { accountsDir } = await makeCodexHome(); + const activeAccountKey = 'user-test::chatgpt-account'; + await writeFile( + path.join(accountsDir, 'registry.json'), + JSON.stringify({ activeAccountId: activeAccountKey }), + 'utf8' + ); + await writeFile( + path.join(accountsDir, `${encodeAccountKeyForAuthFilename(activeAccountKey)}.auth.json`), + JSON.stringify({ auth_mode: 'chatgpt', tokens: { access_token: 'access-token' } }), + 'utf8' + ); + + await expect(detectCodexLocalAccountState(accountsDir)).resolves.toEqual({ + hasArtifacts: true, + hasActiveChatgptAccount: false, + }); + }); + + it('falls back to legacy auth.json when the accounts registry is absent', async () => { + const { codexHome, accountsDir } = await makeCodexHome(); + await writeFile( + path.join(codexHome, 'auth.json'), + JSON.stringify({ auth_mode: 'chatgpt', tokens: { refresh_token: 'legacy-refresh-token' } }), 'utf8' ); @@ -75,7 +248,7 @@ describe('detectCodexLocalAccountArtifacts', () => { }); it('keeps artifact detection true but selected-account detection false when the active auth file is missing', async () => { - const accountsDir = await makeTempDir(); + const { accountsDir } = await makeCodexHome(); await writeFile( path.join(accountsDir, 'registry.json'), JSON.stringify({ active_account_key: 'user-test::missing-auth' }), diff --git a/test/main/services/extensions/ApiKeyService.test.ts b/test/main/services/extensions/ApiKeyService.test.ts index 71e27ec9..894ed629 100644 --- a/test/main/services/extensions/ApiKeyService.test.ts +++ b/test/main/services/extensions/ApiKeyService.test.ts @@ -13,6 +13,8 @@ vi.mock('electron', () => ({ }, })); +import { safeStorage } from 'electron'; + import { ApiKeyService } from '@main/services/extensions/apikeys/ApiKeyService'; describe('ApiKeyService', () => { @@ -20,6 +22,11 @@ describe('ApiKeyService', () => { let service: ApiKeyService; beforeEach(async () => { + vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(false); + vi.mocked(safeStorage.getSelectedStorageBackend).mockReturnValue('basic_text'); + vi.mocked(safeStorage.encryptString).mockReset(); + vi.mocked(safeStorage.decryptString).mockReset(); + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'apikey-service-')); service = new ApiKeyService(tempDir); }); @@ -117,4 +124,35 @@ describe('ApiKeyService', () => { await expect(service.lookup(['TAVILY_API_KEY'])).resolves.toEqual([]); await expect(service.lookupPreferred('TAVILY_API_KEY')).resolves.toBeNull(); }); + + it('does not print decrypt failures to the normal console', async () => { + vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true); + vi.mocked(safeStorage.getSelectedStorageBackend).mockReturnValue('gnome_libsecret'); + vi.mocked(safeStorage.encryptString).mockReturnValue(Buffer.from('encrypted-value')); + vi.mocked(safeStorage.decryptString).mockImplementation(() => { + throw new Error('Error while decrypting the ciphertext provided to safeStorage.decryptString.'); + }); + + await service.save({ + name: 'Anthropic API Key', + envVarName: 'ANTHROPIC_API_KEY', + value: 'secret', + scope: 'user', + }); + + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + try { + await expect(service.lookupPreferred('ANTHROPIC_API_KEY')).resolves.toEqual({ + envVarName: 'ANTHROPIC_API_KEY', + value: '', + }); + expect(consoleError).not.toHaveBeenCalled(); + expect(consoleWarn).not.toHaveBeenCalled(); + } finally { + consoleError.mockRestore(); + consoleWarn.mockRestore(); + } + }); }); diff --git a/test/main/services/infrastructure/updaterReleaseMetadata.test.ts b/test/main/services/infrastructure/updaterReleaseMetadata.test.ts index 199fdc8a..327a9072 100644 --- a/test/main/services/infrastructure/updaterReleaseMetadata.test.ts +++ b/test/main/services/infrastructure/updaterReleaseMetadata.test.ts @@ -13,23 +13,23 @@ import { describe('updaterReleaseMetadata', () => { it('builds platform-specific asset URLs', () => { expect(getExpectedReleaseAssetUrl('1.2.3', 'darwin', 'arm64')).toBe( - 'https://github.com/777genius/claude_agent_teams_ui/releases/download/v1.2.3/Claude.Agent.Teams.UI-1.2.3-arm64.dmg' + 'https://github.com/777genius/agent-teams-ai/releases/download/v1.2.3/Agent.Teams.AI-1.2.3-arm64.dmg' ); expect(getExpectedReleaseAssetUrl('1.2.3', 'darwin', 'x64')).toBe( - 'https://github.com/777genius/claude_agent_teams_ui/releases/download/v1.2.3/Claude.Agent.Teams.UI-1.2.3-x64.dmg' + 'https://github.com/777genius/agent-teams-ai/releases/download/v1.2.3/Agent.Teams.AI-1.2.3-x64.dmg' ); expect(getExpectedReleaseAssetUrl('1.2.3', 'win32', 'x64')).toBe( - 'https://github.com/777genius/claude_agent_teams_ui/releases/download/v1.2.3/Claude.Agent.Teams.UI.Setup.1.2.3.exe' + 'https://github.com/777genius/agent-teams-ai/releases/download/v1.2.3/Agent.Teams.AI.Setup.1.2.3.exe' ); expect(getExpectedReleaseAssetUrl('1.2.3', 'linux', 'x64')).toBe( - 'https://github.com/777genius/claude_agent_teams_ui/releases/download/v1.2.3/Claude.Agent.Teams.UI-1.2.3.AppImage' + 'https://github.com/777genius/agent-teams-ai/releases/download/v1.2.3/Agent.Teams.AI-1.2.3.AppImage' ); }); - it('builds current and planned repo asset URLs while the GitHub repo rename is pending', () => { + it('builds primary and legacy repo asset URLs after the GitHub repo rename', () => { expect(getExpectedReleaseAssetUrls('1.2.3', 'darwin', 'arm64')).toEqual([ - 'https://github.com/777genius/claude_agent_teams_ui/releases/download/v1.2.3/Claude.Agent.Teams.UI-1.2.3-arm64.dmg', - 'https://github.com/777genius/agent-teams-ai/releases/download/v1.2.3/Claude.Agent.Teams.UI-1.2.3-arm64.dmg', + 'https://github.com/777genius/agent-teams-ai/releases/download/v1.2.3/Agent.Teams.AI-1.2.3-arm64.dmg', + 'https://github.com/777genius/claude_agent_teams_ui/releases/download/v1.2.3/Agent.Teams.AI-1.2.3-arm64.dmg', ]); }); @@ -37,19 +37,19 @@ describe('updaterReleaseMetadata', () => { const metadata = ` version: 1.2.3 files: - - url: "Claude.Agent.Teams.UI-1.2.3-arm64-mac.zip" + - url: "Agent.Teams.AI-1.2.3-arm64-mac.zip" sha512: abc size: 123 - - url: 'Claude.Agent.Teams.UI-1.2.3-arm64.dmg' + - url: 'Agent.Teams.AI-1.2.3-arm64.dmg' sha512: def size: 456 -path: Claude.Agent.Teams.UI-1.2.3-arm64-mac.zip +path: Agent.Teams.AI-1.2.3-arm64-mac.zip `; expect(parseReleaseMetadataAssetNames(metadata)).toEqual( new Set([ - 'Claude.Agent.Teams.UI-1.2.3-arm64-mac.zip', - 'Claude.Agent.Teams.UI-1.2.3-arm64.dmg', + 'Agent.Teams.AI-1.2.3-arm64-mac.zip', + 'Agent.Teams.AI-1.2.3-arm64.dmg', ]) ); }); @@ -59,29 +59,29 @@ path: Claude.Agent.Teams.UI-1.2.3-arm64-mac.zip const arm64Metadata = ` version: ${version} files: - - url: Claude.Agent.Teams.UI-${version}-arm64-mac.zip + - url: Agent.Teams.AI-${version}-arm64-mac.zip sha512: abc size: 123 - - url: Claude.Agent.Teams.UI-${version}-arm64.dmg + - url: Agent.Teams.AI-${version}-arm64.dmg sha512: def size: 456 -path: Claude.Agent.Teams.UI-${version}-arm64-mac.zip +path: Agent.Teams.AI-${version}-arm64-mac.zip `; expect(getExpectedLatestMacArtifacts(version, 'arm64')).toEqual([ - `Claude.Agent.Teams.UI-${version}-arm64-mac.zip`, - `Claude.Agent.Teams.UI-${version}-arm64.dmg`, + `Agent.Teams.AI-${version}-arm64-mac.zip`, + `Agent.Teams.AI-${version}-arm64.dmg`, ]); expect(getExpectedLatestMacArtifacts(version, 'x64')).toEqual([ - `Claude.Agent.Teams.UI-${version}-x64-mac.zip`, - `Claude.Agent.Teams.UI-${version}-x64.dmg`, + `Agent.Teams.AI-${version}-x64-mac.zip`, + `Agent.Teams.AI-${version}-x64.dmg`, ]); expect(getLatestMacMetadataUrl(version)).toBe( - `https://github.com/777genius/claude_agent_teams_ui/releases/download/v${version}/latest-mac.yml` + `https://github.com/777genius/agent-teams-ai/releases/download/v${version}/latest-mac.yml` ); expect(getLatestMacMetadataUrls(version)).toEqual([ - `https://github.com/777genius/claude_agent_teams_ui/releases/download/v${version}/latest-mac.yml`, `https://github.com/777genius/agent-teams-ai/releases/download/v${version}/latest-mac.yml`, + `https://github.com/777genius/claude_agent_teams_ui/releases/download/v${version}/latest-mac.yml`, ]); expect(isLatestMacMetadataCompatible(arm64Metadata, version, 'arm64')).toBe(true); expect(isLatestMacMetadataCompatible(arm64Metadata, version, 'x64')).toBe(false); diff --git a/test/main/services/runtime/ProviderConnectionService.test.ts b/test/main/services/runtime/ProviderConnectionService.test.ts index 1cd8813c..1c15659d 100644 --- a/test/main/services/runtime/ProviderConnectionService.test.ts +++ b/test/main/services/runtime/ProviderConnectionService.test.ts @@ -708,6 +708,149 @@ describe('ProviderConnectionService', () => { ); }); + it('does not block launch when the Codex app-server freshly verifies ChatGPT auth', async () => { + const { ProviderConnectionService } = + await import('@main/services/runtime/ProviderConnectionService'); + const loginStatusChecker = vi.fn().mockResolvedValue({ + status: 'not_logged_in', + detail: 'Not logged in', + }); + + const service = new ProviderConnectionService( + { + lookupPreferred: vi.fn().mockResolvedValue(null), + } as never, + { + getConfig: () => createConfig('auto'), + } as never, + loginStatusChecker + ); + + service.setCodexAccountFeature({ + getSnapshot: vi.fn().mockResolvedValue({ + preferredAuthMode: 'chatgpt', + effectiveAuthMode: 'chatgpt', + launchAllowed: true, + launchIssueMessage: null, + launchReadinessState: 'ready_chatgpt', + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: { + type: 'chatgpt', + email: 'user@example.com', + planType: 'pro', + }, + apiKey: { + available: false, + source: null, + sourceLabel: null, + }, + requiresOpenaiAuth: true, + localAccountArtifactsPresent: true, + localActiveChatgptAccountPresent: true, + runtimeContext: { + binaryPath: '/opt/codex/bin/codex', + codexHome: '/Users/tester/.codex-custom', + }, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: null, + updatedAt: '2026-04-20T00:00:00.000Z', + }), + } as never); + + await expect( + service.getConfiguredConnectionIssue( + { + OPENAI_API_KEY: 'ambient-openai-key', + CODEX_API_KEY: 'ambient-codex-key', + }, + 'codex' + ) + ).resolves.toBeNull(); + + expect(loginStatusChecker).not.toHaveBeenCalled(); + }); + + it('blocks launch when managed ChatGPT is selected but degraded exact runtime login is logged out', async () => { + const { ProviderConnectionService } = + await import('@main/services/runtime/ProviderConnectionService'); + const loginStatusChecker = vi.fn().mockResolvedValue({ + status: 'not_logged_in', + detail: 'Not logged in', + }); + + const service = new ProviderConnectionService( + { + lookupPreferred: vi.fn().mockResolvedValue(null), + } as never, + { + getConfig: () => createConfig('auto'), + } as never, + loginStatusChecker + ); + + service.setCodexAccountFeature({ + getSnapshot: vi.fn().mockResolvedValue({ + preferredAuthMode: 'chatgpt', + effectiveAuthMode: 'chatgpt', + launchAllowed: true, + launchIssueMessage: null, + launchReadinessState: 'warning_degraded_but_launchable', + appServerState: 'degraded', + appServerStatusMessage: 'Using cached ChatGPT account after transient app-server failure.', + managedAccount: { + type: 'chatgpt', + email: 'user@example.com', + planType: 'pro', + }, + apiKey: { + available: false, + source: null, + sourceLabel: null, + }, + requiresOpenaiAuth: true, + localAccountArtifactsPresent: true, + localActiveChatgptAccountPresent: true, + runtimeContext: { + binaryPath: '/opt/codex/bin/codex', + codexHome: '/Users/tester/.codex-custom', + }, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: null, + updatedAt: '2026-04-20T00:00:00.000Z', + }), + } as never); + + const issue = await service.getConfiguredConnectionIssue( + { + OPENAI_API_KEY: 'ambient-openai-key', + CODEX_API_KEY: 'ambient-codex-key', + }, + 'codex' + ); + + expect(issue).toContain('Codex CLI login status is not active'); + expect(issue).toContain('Reconnect ChatGPT'); + expect(loginStatusChecker).toHaveBeenCalledWith({ + binaryPath: '/opt/codex/bin/codex', + env: expect.objectContaining({ + CODEX_CLI_PATH: '/opt/codex/bin/codex', + CODEX_HOME: '/Users/tester/.codex-custom', + CLAUDE_CODE_CODEX_FORCED_LOGIN_METHOD: 'chatgpt', + }), + }); + expect(loginStatusChecker.mock.calls[0]?.[0].env.OPENAI_API_KEY).toBeUndefined(); + expect(loginStatusChecker.mock.calls[0]?.[0].env.CODEX_API_KEY).toBeUndefined(); + }); + it('reports a pinned Codex API-key mode as missing only the API key credential', async () => { const { ProviderConnectionService } = await import('@main/services/runtime/ProviderConnectionService'); diff --git a/test/main/services/team/ProcessBootstrapTransportEvidence.test.ts b/test/main/services/team/ProcessBootstrapTransportEvidence.test.ts new file mode 100644 index 00000000..929aa234 --- /dev/null +++ b/test/main/services/team/ProcessBootstrapTransportEvidence.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildProcessBootstrapPendingDiagnostic, + buildProcessBootstrapTimeoutDiagnostic, + deriveProcessTransportProjectionPhase, + sanitizeProcessRuntimeEventFilePrefix, + summarizeProcessBootstrapTransportEvents, +} from '@main/services/team/ProcessBootstrapTransportEvidence'; + +describe('ProcessBootstrapTransportEvidence', () => { + it('keeps retryable submit rejection non-terminal when a later submit succeeds', () => { + const summary = summarizeProcessBootstrapTransportEvents([ + { + type: 'runtime_ready', + timestamp: '2026-05-07T10:00:00.000Z', + detail: 'ready', + }, + { + type: 'bootstrap_submit_rejected', + timestamp: '2026-05-07T10:00:01.000Z', + detail: 'temporary backoff', + retryable: true, + }, + { + type: 'bootstrap_submitted', + timestamp: '2026-05-07T10:00:02.000Z', + detail: 'messageId=abc', + }, + ]); + + expect(summary).toMatchObject({ + submitted: true, + hasProgress: true, + }); + expect(summary?.terminalFailure).toBeUndefined(); + expect(summary?.lastStage).toContain('bootstrap submitted'); + }); + + it('treats non-retryable submit rejection as terminal', () => { + const summary = summarizeProcessBootstrapTransportEvents([ + { + type: 'bootstrap_submit_rejected', + timestamp: '2026-05-07T10:00:01.000Z', + detail: 'fatal submit rejection', + retryable: false, + }, + ]); + + expect(summary?.terminalFailure).toMatchObject({ + kind: 'non_retryable_submit_rejection', + reason: 'bootstrap submit rejected: fatal submit rejection', + }); + }); + + it('treats accepted submit without a message id as terminal', () => { + const summary = summarizeProcessBootstrapTransportEvents([ + { + type: 'bootstrap_submit_accepted_without_uuid', + timestamp: '2026-05-07T10:00:01.000Z', + detail: 'accepted but missing message id', + }, + ]); + + expect(summary?.terminalFailure).toMatchObject({ + kind: 'accepted_without_message_id', + reason: 'bootstrap submit accepted without message id: accepted but missing message id', + }); + }); + + it('redacts secrets and paths from transport diagnostics', () => { + const summary = summarizeProcessBootstrapTransportEvents([ + { + type: 'bootstrap_submit_rejected', + timestamp: '2026-05-07T10:00:01.000Z', + detail: + 'failed in /Users/belief/dev/project with token sk-ant-api03-abcdefghijklmnopqrstuvwxyz', + retryable: false, + }, + ]); + + expect(summary?.terminalFailure?.reason).toContain('[path]'); + expect(summary?.terminalFailure?.reason).toContain('[redacted]'); + expect(summary?.terminalFailure?.reason).not.toContain('/Users/belief'); + expect(summary?.terminalFailure?.reason).not.toContain('sk-ant-api03'); + }); + + it('does not surface raw command or cwd details for parent-owned process stages', () => { + const summary = summarizeProcessBootstrapTransportEvents([ + { + type: 'process_spawned', + timestamp: '2026-05-07T10:00:01.000Z', + detail: 'spawned /Users/belief/project with command secret', + }, + ]); + + expect(summary?.lastStage).toBe('process spawned'); + }); + + it('builds stable pending and timeout diagnostics from the last transport stage', () => { + const summary = summarizeProcessBootstrapTransportEvents([ + { + type: 'bootstrap_prompt_observed', + timestamp: '2026-05-07T10:00:01.000Z', + detail: 'prompt seen', + }, + ]); + + expect(summary).not.toBeNull(); + expect(buildProcessBootstrapPendingDiagnostic(summary!)).toBe( + 'Bootstrap transport reached bootstrap prompt observed: prompt seen; waiting for bootstrap confirmation.' + ); + expect(buildProcessBootstrapTimeoutDiagnostic(summary!)).toBe( + 'Teammate was registered but did not bootstrap-confirm before timeout. Last transport stage: bootstrap prompt observed: prompt seen' + ); + }); + + it('keeps active phase pending and turns final timeout into final projection', () => { + expect(deriveProcessTransportProjectionPhase({ launchPhase: 'active' })).toBe('active'); + expect( + deriveProcessTransportProjectionPhase({ + launchPhase: 'active', + finalTimeoutReached: true, + }) + ).toBe('final'); + expect(deriveProcessTransportProjectionPhase({ launchPhase: 'finished' })).toBe('final'); + }); + + it('matches orchestrator runtime-event filename sanitization for important names', () => { + expect(sanitizeProcessRuntimeEventFilePrefix('jack')).toBe('jack'); + expect(sanitizeProcessRuntimeEventFilePrefix('con.txt')).toBe('con-txt'); + expect(sanitizeProcessRuntimeEventFilePrefix('CON')).toBe('_con'); + expect(sanitizeProcessRuntimeEventFilePrefix('alice/bob')).toBe('alice-bob'); + }); +}); diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 4129aea7..dc72235f 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -12594,6 +12594,122 @@ describe('TeamProvisioningService', () => { await vi.waitFor(() => expect(complete).toHaveBeenCalledTimes(1)); }); + it('recovers ready progress when deterministic create finalization stalls after completed bootstrap-state', async () => { + allowConsoleLogs(); + vi.useFakeTimers(); + const teamName = 'create-completed-bootstrap-finalization-stall'; + const child = createRunningChild(); + vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude'); + vi.mocked(spawnCli).mockReturnValue(child as any); + + const mcpConfigBuilder = { + writeConfigFile: vi.fn(async () => '/mock/mcp-config-create.json'), + removeConfigFile: vi.fn(async () => {}), + }; + const membersMetaStore = { + writeMembers: vi.fn(async () => {}), + getMembers: vi.fn(async () => []), + }; + const teamMetaStore = { + writeMeta: vi.fn(async () => {}), + deleteMeta: vi.fn(async () => {}), + getMeta: vi.fn(async () => null), + }; + + const svc = new TeamProvisioningService( + undefined, + undefined, + membersMetaStore as any, + undefined, + mcpConfigBuilder as any, + teamMetaStore as any + ); + (svc as any).buildProvisioningEnv = vi.fn(async () => ({ + env: { CODEX_API_KEY: 'test' }, + authSource: 'codex_runtime', + })); + (svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {}); + (svc as any).pathExists = vi.fn(async () => false); + (svc as any).startFilesystemMonitor = vi.fn(); + (svc as any).startStallWatchdog = vi.fn(); + (svc as any).stopStallWatchdog = vi.fn(); + (svc as any).resolveAndValidateLaunchIdentity = vi.fn(async () => ({ + providerId: 'codex', + providerBackendId: 'codex-native', + selectedModel: 'gpt-5.5', + selectedModelKind: 'explicit', + resolvedLaunchModel: 'gpt-5.5', + catalogId: 'gpt-5.5', + catalogSource: 'test', + catalogFetchedAt: '2026-05-07T00:00:00.000Z', + selectedEffort: 'medium', + resolvedEffort: 'medium', + selectedFastMode: null, + resolvedFastMode: null, + fastResolutionReason: null, + })); + const waitForValidConfig = vi.fn(() => new Promise(() => {})); + (svc as any).waitForValidConfig = waitForValidConfig; + + const progressStates: string[] = []; + const { runId } = await svc.createTeam( + { + teamName, + cwd: tempClaudeRoot, + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.5', + members: [{ name: 'alice' }, { name: 'tom' }], + }, + (progress) => { + progressStates.push(progress.state); + } + ); + const run = (svc as any).runs.get(runId); + expect(run).toBeTruthy(); + run.deterministicBootstrap = true; + const scheduleRecovery = vi.spyOn( + svc as any, + 'scheduleDeterministicBootstrapCompletionRecovery' + ); + + writeBootstrapState( + teamName, + [ + { name: 'alice', status: 'bootstrap_confirmed' }, + { name: 'tom', status: 'bootstrap_confirmed' }, + ], + new Date(Date.now() + 1_000).toISOString() + ); + + child.stdout.emit( + 'data', + Buffer.from( + `${JSON.stringify({ + type: 'system', + subtype: 'team_bootstrap', + event: 'completed', + run_id: runId, + team_name: teamName, + seq: 1, + failed_members: [], + })}\n`, + 'utf8' + ) + ); + + await Promise.resolve(); + await Promise.resolve(); + expect(waitForValidConfig).toHaveBeenCalledTimes(1); + expect(scheduleRecovery).toHaveBeenCalledWith(run); + expect(progressStates.at(-1)).not.toBe('ready'); + + await (svc as any).recoverDeterministicBootstrapCompletion(run); + expect(progressStates.at(-1)).toBe('ready'); + expect((svc as any).provisioningRunByTeam.has(teamName)).toBe(false); + expect((svc as any).aliveRunByTeam.get(teamName)).toBe(runId); + }); + it('does not verify provisioning again after flushing a final newline-less error result', async () => { allowConsoleLogs(); const teamName = 'launch-close-flushes-final-error-team'; @@ -13437,6 +13553,266 @@ describe('TeamProvisioningService', () => { }); }); + it('keeps active process bootstrap transport progress pending without turning retryable rejection into failure', async () => { + allowConsoleLogs(); + const teamName = 'zz-unit-process-bootstrap-transport-pending'; + const leadSessionId = 'lead-session'; + const projectPath = '/Users/test/proj'; + const acceptedAt = new Date(Date.now() - 15_000).toISOString(); + const bootstrapRunId = 'run-process-transport-pending'; + const runtimePid = 1234; + const runtimeEventsPath = path.join(tempTeamsBase, teamName, 'runtime', 'jack.runtime.jsonl'); + + writeLaunchConfig(teamName, projectPath, leadSessionId, ['jack']); + const configPath = path.join(tempTeamsBase, teamName, 'config.json'); + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')) as { + members: Array>; + }; + config.members = config.members.map((member) => + member.name === 'jack' + ? { + ...member, + agentId: `jack@${teamName}`, + backendType: 'process', + tmuxPaneId: `process:${runtimePid}`, + runtimePid, + bootstrapExpectedAfter: acceptedAt, + bootstrapRunId, + bootstrapRuntimeEventsPath: runtimeEventsPath, + } + : member + ); + fs.writeFileSync(configPath, JSON.stringify(config), 'utf8'); + writeLaunchState( + teamName, + leadSessionId, + { + jack: { + launchState: 'runtime_pending_bootstrap', + agentToolAccepted: true, + runtimeAlive: true, + runtimePid, + runtimeRunId: bootstrapRunId, + tmuxPaneId: `process:${runtimePid}`, + backendType: 'process', + bootstrapConfirmed: false, + hardFailure: false, + hardFailureReason: undefined, + firstSpawnAcceptedAt: acceptedAt, + }, + }, + { launchPhase: 'active' } + ); + fs.mkdirSync(path.dirname(runtimeEventsPath), { recursive: true }); + fs.writeFileSync( + runtimeEventsPath, + [ + { + version: 1, + type: 'runtime_ready', + timestamp: acceptedAt, + pid: runtimePid, + teamName, + agentName: 'jack', + agentId: `jack@${teamName}`, + bootstrapRunId, + detail: 'ready', + }, + { + version: 1, + type: 'bootstrap_submit_rejected', + timestamp: new Date(Date.now() - 10_000).toISOString(), + pid: runtimePid, + teamName, + agentName: 'jack', + agentId: `jack@${teamName}`, + bootstrapRunId, + retryable: true, + detail: 'cooldown before retry', + }, + ] + .map((event) => JSON.stringify(event)) + .join('\n') + '\n', + 'utf8' + ); + + const svc = new TeamProvisioningService(); + const result = await svc.getMemberSpawnStatuses(teamName); + + expect(result.statuses.jack).toMatchObject({ + launchState: 'runtime_pending_bootstrap', + bootstrapConfirmed: false, + hardFailure: false, + runtimeDiagnosticSeverity: 'warning', + }); + expect(result.statuses.jack?.runtimeDiagnostic).toContain( + 'Bootstrap transport reached bootstrap submit rejected' + ); + }); + + it('uses the last process transport stage when active launch grace expires', async () => { + allowConsoleLogs(); + const teamName = 'zz-unit-process-bootstrap-transport-timeout'; + const leadSessionId = 'lead-session'; + const projectPath = '/Users/test/proj'; + const acceptedAt = new Date(Date.now() - 15 * 60_000).toISOString(); + const bootstrapRunId = 'run-process-transport-timeout'; + const runtimePid = 1235; + const runtimeEventsPath = path.join(tempTeamsBase, teamName, 'runtime', 'jack.runtime.jsonl'); + + writeLaunchConfig(teamName, projectPath, leadSessionId, ['jack']); + const configPath = path.join(tempTeamsBase, teamName, 'config.json'); + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')) as { + members: Array>; + }; + config.members = config.members.map((member) => + member.name === 'jack' + ? { + ...member, + agentId: `jack@${teamName}`, + backendType: 'process', + tmuxPaneId: `process:${runtimePid}`, + runtimePid, + bootstrapExpectedAfter: acceptedAt, + bootstrapRunId, + bootstrapRuntimeEventsPath: runtimeEventsPath, + } + : member + ); + fs.writeFileSync(configPath, JSON.stringify(config), 'utf8'); + writeLaunchState( + teamName, + leadSessionId, + { + jack: { + launchState: 'runtime_pending_bootstrap', + agentToolAccepted: true, + runtimeAlive: false, + runtimePid, + runtimeRunId: bootstrapRunId, + tmuxPaneId: `process:${runtimePid}`, + backendType: 'process', + bootstrapConfirmed: false, + hardFailure: false, + hardFailureReason: undefined, + firstSpawnAcceptedAt: acceptedAt, + }, + }, + { launchPhase: 'active' } + ); + fs.mkdirSync(path.dirname(runtimeEventsPath), { recursive: true }); + fs.writeFileSync( + runtimeEventsPath, + `${JSON.stringify({ + version: 1, + type: 'bootstrap_prompt_observed', + timestamp: new Date(Date.now() - 14 * 60_000).toISOString(), + pid: runtimePid, + teamName, + agentName: 'jack', + agentId: `jack@${teamName}`, + bootstrapRunId, + detail: 'prompt seen', + })}\n`, + 'utf8' + ); + + const svc = new TeamProvisioningService(); + const result = await svc.getMemberSpawnStatuses(teamName); + + expect(result.statuses.jack).toMatchObject({ + launchState: 'failed_to_start', + bootstrapConfirmed: false, + hardFailure: true, + runtimeDiagnosticSeverity: 'error', + }); + expect(result.statuses.jack?.hardFailureReason).toContain( + 'Last transport stage: bootstrap prompt observed: prompt seen' + ); + }); + + it('uses non-retryable process transport rejection as terminal launch failure', async () => { + allowConsoleLogs(); + const teamName = 'zz-unit-process-bootstrap-transport-terminal'; + const leadSessionId = 'lead-session'; + const projectPath = '/Users/test/proj'; + const acceptedAt = new Date(Date.now() - 15_000).toISOString(); + const bootstrapRunId = 'run-process-transport-terminal'; + const runtimePid = 1236; + const runtimeEventsPath = path.join(tempTeamsBase, teamName, 'runtime', 'jack.runtime.jsonl'); + + writeLaunchConfig(teamName, projectPath, leadSessionId, ['jack']); + const configPath = path.join(tempTeamsBase, teamName, 'config.json'); + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')) as { + members: Array>; + }; + config.members = config.members.map((member) => + member.name === 'jack' + ? { + ...member, + agentId: `jack@${teamName}`, + backendType: 'process', + tmuxPaneId: `process:${runtimePid}`, + runtimePid, + bootstrapExpectedAfter: acceptedAt, + bootstrapRunId, + bootstrapRuntimeEventsPath: runtimeEventsPath, + } + : member + ); + fs.writeFileSync(configPath, JSON.stringify(config), 'utf8'); + writeLaunchState( + teamName, + leadSessionId, + { + jack: { + launchState: 'runtime_pending_bootstrap', + agentToolAccepted: true, + runtimeAlive: true, + runtimePid, + runtimeRunId: bootstrapRunId, + tmuxPaneId: `process:${runtimePid}`, + backendType: 'process', + bootstrapConfirmed: false, + hardFailure: false, + hardFailureReason: undefined, + firstSpawnAcceptedAt: acceptedAt, + }, + }, + { launchPhase: 'active' } + ); + fs.mkdirSync(path.dirname(runtimeEventsPath), { recursive: true }); + fs.writeFileSync( + runtimeEventsPath, + `${JSON.stringify({ + version: 1, + type: 'bootstrap_submit_rejected', + timestamp: new Date(Date.now() - 10_000).toISOString(), + pid: runtimePid, + teamName, + agentName: 'jack', + agentId: `jack@${teamName}`, + bootstrapRunId, + retryable: false, + detail: 'fatal submit rejection', + })}\n`, + 'utf8' + ); + + const svc = new TeamProvisioningService(); + const result = await svc.getMemberSpawnStatuses(teamName); + + expect(result.statuses.jack).toMatchObject({ + launchState: 'failed_to_start', + bootstrapConfirmed: false, + hardFailure: true, + runtimeDiagnosticSeverity: 'error', + }); + expect(result.statuses.jack?.hardFailureReason).toBe( + 'bootstrap submit rejected: fatal submit rejection' + ); + }); + it('does not classify the bootstrap instruction prompt as a member launch failure', async () => { allowConsoleLogs(); const teamName = 'zz-unit-bootstrap-prompt-not-failure'; diff --git a/test/main/services/team/TeamProvisioningServicePrepare.test.ts b/test/main/services/team/TeamProvisioningServicePrepare.test.ts index a392f1d0..cfd0fb06 100644 --- a/test/main/services/team/TeamProvisioningServicePrepare.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrepare.test.ts @@ -2101,6 +2101,28 @@ describe('TeamProvisioningService prepare/auth behavior', () => { }); }); + it('blocks launch args when a secondary Codex provider reports a concrete auth issue', async () => { + const svc = new TeamProvisioningService(); + buildProviderAwareCliEnvMock.mockImplementation( + ({ providerId, env }: { providerId?: string; env: NodeJS.ProcessEnv }) => + Promise.resolve({ + env, + authSource: providerId === 'codex' ? 'configured_api_key_missing' : 'none', + geminiRuntimeAuth: null, + connectionIssues: + providerId === 'codex' ? { codex: 'Codex CLI login status is not active' } : {}, + warning: providerId === 'codex' ? 'Codex CLI login status is not active' : undefined, + }) + ); + + await expect( + (svc as any).buildCrossProviderMemberArgs('anthropic', [ + { name: 'alice', providerId: 'anthropic' }, + { name: 'jack', providerId: 'codex' }, + ]) + ).rejects.toThrow('Codex: Codex CLI login status is not active'); + }); + it('adds Codex turn-settled env when a secondary member infers Codex from model', async () => { const svc = new TeamProvisioningService(); svc.setRuntimeTurnSettledEnvironmentProvider(async ({ provider }) => diff --git a/test/main/utils/electronUserDataMigration.test.ts b/test/main/utils/electronUserDataMigration.test.ts index 2b0fe945..57192f64 100644 --- a/test/main/utils/electronUserDataMigration.test.ts +++ b/test/main/utils/electronUserDataMigration.test.ts @@ -16,6 +16,7 @@ import { import { getLegacyElectronUserDataCandidates, migrateElectronUserDataDirectory, + shouldCopyElectronUserDataEntry, type ElectronUserDataMigrationApp, } from '../../../src/main/utils/electronUserDataMigration'; @@ -75,12 +76,63 @@ describe('electron userData migration', () => { expect(getLegacyElectronUserDataCandidates(currentPath)).toEqual([ path.join(parentPath, 'Claude Agent Teams UI'), path.join(parentPath, 'claude-agent-teams-ui'), + path.join(parentPath, 'agent-teams-ai'), path.join(parentPath, 'claude-devtools'), path.join(parentPath, 'claude-code-context'), ]); }); - it('copies the complete legacy userData tree, including all current app-owned stores', async () => { + it('reuses populated legacy userData by default instead of copying it during startup', () => { + const root = createTempRoot(); + const legacyPath = path.join(root, 'claude-agent-teams-ui'); + const currentPath = path.join(root, 'agent-teams-ai'); + const app = new FakeElectronApp(currentPath); + + writeFile(legacyPath, 'data/attachments/team-a/legacy.txt', 'legacy'); + + const result = migrateElectronUserDataDirectory(app); + + expect(result).toMatchObject({ + currentPath, + legacyPath, + migrated: false, + fallbackToLegacy: false, + reason: 'legacy-reused', + }); + expect(app.setPathCalls).toEqual([ + { name: 'userData', value: legacyPath }, + { name: 'sessionData', value: legacyPath }, + ]); + expect(fs.existsSync(currentPath)).toBe(false); + }); + + it('does not treat a cache-only new userData directory as populated', () => { + const root = createTempRoot(); + const legacyPath = path.join(root, 'claude-agent-teams-ui'); + const currentPath = path.join(root, 'agent-teams-ai'); + const app = new FakeElectronApp(currentPath); + + writeFile(currentPath, 'Cache/Cache_Data/blob', 'cache'); + writeFile(currentPath, 'Code Cache/js/cache', 'code cache'); + writeFile(currentPath, 'Partitions/dev/Cache/Cache_Data/blob', 'partition cache'); + writeFile(legacyPath, 'data/attachments/team-a/legacy.txt', 'legacy'); + + const result = migrateElectronUserDataDirectory(app); + + expect(result).toMatchObject({ + currentPath, + legacyPath, + migrated: false, + fallbackToLegacy: false, + reason: 'legacy-reused', + }); + expect(app.setPathCalls).toEqual([ + { name: 'userData', value: legacyPath }, + { name: 'sessionData', value: legacyPath }, + ]); + }); + + it('copies legacy app-owned state and durable renderer storage without Chromium caches', async () => { const root = createTempRoot(); const legacyPath = path.join(root, 'Claude Agent Teams UI'); const currentPath = path.join(root, 'Agent Teams UI'); @@ -102,14 +154,43 @@ describe('electron userData migration', () => { ['opencode-bridge/command-leases.json', '{"leases":[]}'], ['logs/claude-cli-auth-diag.ndjson', '{"event":"auth"}\n'], ['Local Storage/leveldb/000003.log', 'renderer localStorage bytes'], + ['IndexedDB/http_localhost_5173.indexeddb.leveldb/000003.log', 'renderer indexeddb bytes'], + ['Partitions/dev/Local Storage/leveldb/000003.log', 'dev partition localStorage bytes'], + [ + 'Partitions/dev/IndexedDB/http_localhost_5173.indexeddb.leveldb/000003.log', + 'dev partition indexeddb bytes', + ], ['future-feature/state.json', '{"kept":true}'], ] as const; + const transientFiles = [ + ['Cache/Cache_Data/blob', 'http cache'], + ['Code Cache/js/cache', 'code cache'], + ['GPUCache/data_0', 'gpu cache'], + ['DawnGraphiteCache/data_0', 'graphite cache'], + ['DawnWebGPUCache/data_0', 'webgpu cache'], + ['Crashpad/settings.dat', 'crashpad state'], + ['Session Storage/000003.log', 'session storage'], + ['Local Storage/leveldb/LOCK', 'stale leveldb lock'], + ['IndexedDB/http_localhost_5173.indexeddb.leveldb/LOCK', 'stale indexeddb lock'], + ['Network Persistent State', 'network state'], + ['DIPS', 'tracking protection state'], + ['Trust Tokens', 'trust tokens'], + ['Partitions/dev/Cache/Cache_Data/blob', 'partition http cache'], + ['Partitions/dev/Code Cache/js/cache', 'partition code cache'], + ['Partitions/dev/GPUCache/data_0', 'partition gpu cache'], + ['Partitions/dev/Session Storage/000003.log', 'partition session storage'], + ] as const; for (const [relativePath, content] of knownFiles) { writeFile(legacyPath, relativePath, content); } + for (const [relativePath, content] of transientFiles) { + writeFile(legacyPath, relativePath, content); + } - const result = migrateElectronUserDataDirectory(new FakeElectronApp(currentPath)); + const result = migrateElectronUserDataDirectory(new FakeElectronApp(currentPath), { + strategy: 'copy', + }); expect(result).toMatchObject({ currentPath, @@ -122,6 +203,9 @@ describe('electron userData migration', () => { for (const [relativePath, content] of knownFiles) { expect(readFile(currentPath, relativePath)).toBe(content); } + for (const [relativePath] of transientFiles) { + expect(fs.existsSync(path.join(currentPath, relativePath))).toBe(false); + } setAppDataBasePath(currentPath); expect(getAppDataPath()).toBe(path.join(currentPath, 'data')); @@ -143,6 +227,39 @@ describe('electron userData migration', () => { ).resolves.toBe(Buffer.from('task attachment').toString('base64')); }); + it('keeps unknown durable state but skips transient Chromium cache entries', () => { + const root = createTempRoot(); + const legacyPath = path.join(root, 'Claude Agent Teams UI'); + + expect( + shouldCopyElectronUserDataEntry(legacyPath, path.join(legacyPath, 'data/state.json')) + ).toBe(true); + expect( + shouldCopyElectronUserDataEntry( + legacyPath, + path.join(legacyPath, 'future-feature/state.json') + ) + ).toBe(true); + expect( + shouldCopyElectronUserDataEntry( + legacyPath, + path.join(legacyPath, 'Partitions/dev/Local Storage/leveldb/000003.log') + ) + ).toBe(true); + expect( + shouldCopyElectronUserDataEntry( + legacyPath, + path.join(legacyPath, 'Partitions/dev/Cache/Cache_Data/blob') + ) + ).toBe(false); + expect( + shouldCopyElectronUserDataEntry( + legacyPath, + path.join(legacyPath, 'Local Storage/leveldb/LOCK') + ) + ).toBe(false); + }); + it('does not merge legacy data into an already populated new userData directory', () => { const root = createTempRoot(); const legacyPath = path.join(root, 'Claude Agent Teams UI'); @@ -172,6 +289,7 @@ describe('electron userData migration', () => { writeFile(legacyPath, 'data/attachments/team-a/legacy.txt', 'legacy'); const result = migrateElectronUserDataDirectory(app, { + strategy: 'copy', copyDirectory: () => { throw new Error('copy denied'); }, @@ -199,6 +317,7 @@ describe('electron userData migration', () => { writeFile(legacyPath, 'data/attachments/team-a/legacy.txt', 'legacy'); const result = migrateElectronUserDataDirectory(app, { + strategy: 'copy', copyDirectory: () => { writeFile(currentPath, 'data/attachments/team-a/current.txt', 'current'); throw new Error('destination appeared'); @@ -226,6 +345,7 @@ describe('electron userData migration', () => { writeFile(legacyPath, 'data/attachments/team-a/legacy.txt', 'legacy'); const result = migrateElectronUserDataDirectory(app, { + strategy: 'copy', copyDirectory: () => { throw new Error('copy denied'); }, @@ -270,16 +390,21 @@ describe('electron userData migration', () => { writeFile(legacyPath, 'mcp-configs/legacy.json', '{}'); - const result = migrateElectronUserDataDirectory(new FakeElectronApp(currentPath)); + const app = new FakeElectronApp(currentPath); + const result = migrateElectronUserDataDirectory(app); expect(result).toMatchObject({ currentPath, legacyPath, - migrated: true, + migrated: false, fallbackToLegacy: false, - reason: 'migrated', + reason: 'legacy-reused', }); - expect(readFile(currentPath, 'mcp-configs/legacy.json')).toBe('{}'); + expect(app.setPathCalls).toEqual([ + { name: 'userData', value: legacyPath }, + { name: 'sessionData', value: legacyPath }, + ]); + expect(fs.existsSync(path.join(currentPath, 'mcp-configs/legacy.json'))).toBe(false); }); it('prefers populated older legacy data over an empty newer legacy directory', () => { @@ -291,16 +416,23 @@ describe('electron userData migration', () => { fs.mkdirSync(emptyNewerLegacyPath, { recursive: true }); writeFile(populatedOlderLegacyPath, 'data/attachments/team-a/pre-release.txt', 'pre-release'); - const result = migrateElectronUserDataDirectory(new FakeElectronApp(currentPath)); + const app = new FakeElectronApp(currentPath); + const result = migrateElectronUserDataDirectory(app); expect(result).toMatchObject({ currentPath, legacyPath: populatedOlderLegacyPath, - migrated: true, + migrated: false, fallbackToLegacy: false, - reason: 'migrated', + reason: 'legacy-reused', }); - expect(readFile(currentPath, 'data/attachments/team-a/pre-release.txt')).toBe('pre-release'); + expect(app.setPathCalls).toEqual([ + { name: 'userData', value: populatedOlderLegacyPath }, + { name: 'sessionData', value: populatedOlderLegacyPath }, + ]); + expect(fs.existsSync(path.join(currentPath, 'data/attachments/team-a/pre-release.txt'))).toBe( + false + ); }); it('uses the pre-1.0 claude-devtools legacy directory when newer legacy data is absent', () => { @@ -310,15 +442,22 @@ describe('electron userData migration', () => { writeFile(legacyPath, 'data/attachments/team-a/pre-release.txt', 'pre-release'); - const result = migrateElectronUserDataDirectory(new FakeElectronApp(currentPath)); + const app = new FakeElectronApp(currentPath); + const result = migrateElectronUserDataDirectory(app); expect(result).toMatchObject({ currentPath, legacyPath, - migrated: true, + migrated: false, fallbackToLegacy: false, - reason: 'migrated', + reason: 'legacy-reused', }); - expect(readFile(currentPath, 'data/attachments/team-a/pre-release.txt')).toBe('pre-release'); + expect(app.setPathCalls).toEqual([ + { name: 'userData', value: legacyPath }, + { name: 'sessionData', value: legacyPath }, + ]); + expect(fs.existsSync(path.join(currentPath, 'data/attachments/team-a/pre-release.txt'))).toBe( + false + ); }); }); diff --git a/test/renderer/components/team/members/MemberCard.test.ts b/test/renderer/components/team/members/MemberCard.test.ts index ace72f25..3e05329b 100644 --- a/test/renderer/components/team/members/MemberCard.test.ts +++ b/test/renderer/components/team/members/MemberCard.test.ts @@ -837,6 +837,48 @@ describe('MemberCard starting-state visuals', () => { }); }); + it('does not truncate long failed launch reasons on the member row', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const reason = `APIError - ${'Codex runtime context includes missing login session. '.repeat( + 8 + )}final diagnostic marker`; + + await act(async () => { + root.render( + React.createElement(MemberCard, { + member, + memberColor: 'blue', + isTeamAlive: true, + isTeamProvisioning: false, + spawnStatus: 'error', + spawnLaunchState: 'failed_to_start', + spawnRuntimeAlive: false, + spawnError: reason, + spawnEntry: { + ...failedSpawnEntry, + hardFailureReason: reason, + runtimeDiagnostic: reason, + }, + onRestartMember: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + const failureReason = host.querySelector('[data-testid="member-launch-failure-reason"]'); + expect(failureReason?.textContent).toContain('final diagnostic marker'); + expect(failureReason?.querySelector('.line-clamp-2')).toBeNull(); + expect(failureReason?.textContent).not.toContain('...'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('renders Relaunch OpenCode for registered-only OpenCode teammates', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); const host = document.createElement('div');
- + macOS Apple Silicon
- + macOS Intel
- + Windows
May trigger SmartScreen — click "More info" → "Run anyway"
- + Linux AppImage
- + .deb   - + .rpm   - + .pacman