From 29ea1ae724217c745c1322dc7cd2e4802e391db8 Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 13 May 2026 17:56:00 +0300 Subject: [PATCH] feat: add workspace trust preflight --- .../tmux-vs-process-runtime-rationale.md | 237 + .../workspace-trust-host-preflight-plan.md | 4227 +++++++++++++++++ .../MemberWorkSyncNudgeActivationPolicy.ts | 48 +- .../MemberWorkSyncNudgeAgendaPredicates.ts | 18 + .../MemberWorkSyncTargetedRecoveryPolicy.ts | 69 + .../core/application/index.ts | 2 + .../core/domain/MemberWorkSyncNudge.ts | 10 + .../workspace-trust/contracts/index.ts | 1 + .../application/ClaudePreflightCommand.ts | 71 + .../ClaudePtyWorkspaceTrustStrategy.ts | 228 + .../core/application/PtyDialogEngine.ts | 142 + .../core/application/StartupDialogRules.ts | 148 + .../application/WorkspaceTrustCoordinator.ts | 190 + .../core/application/WorkspaceTrustLocks.ts | 100 + .../workspace-trust/core/application/index.ts | 7 + .../workspace-trust/core/application/ports.ts | 54 + .../domain/CodexWorkspaceTrustSettings.ts | 166 + .../domain/WorkspaceTrustArgPatchApplier.ts | 150 + .../domain/WorkspaceTrustDiagnosticsBudget.ts | 95 + .../core/domain/WorkspaceTrustPath.ts | 253 + .../core/domain/WorkspaceTrustTypes.ts | 80 + .../workspace-trust/core/domain/index.ts | 5 + src/features/workspace-trust/index.ts | 1 + .../main/adapters/output/ClaudeStateProbe.ts | 111 + .../adapters/output/NodePtyProcessAdapter.ts | 100 + .../output/TempEmptyMcpConfigStore.ts | 21 + .../createWorkspaceTrustCoordinator.ts | 28 + src/features/workspace-trust/main/index.ts | 29 + .../WorkspaceTrustFeatureFlags.ts | 74 + .../workspaceTrustPreflightEnv.ts | 39 + src/main/index.ts | 51 +- .../team/TeamLaunchFailureArtifactPack.ts | 2 +- .../services/team/TeamProvisioningService.ts | 670 ++- .../team/ProvisioningProgressBlock.tsx | 16 +- src/renderer/store/slices/teamSlice.ts | 24 + src/shared/types/team.ts | 1 + .../core/MemberWorkSyncNudge.test.ts | 89 + ...emberWorkSyncNudgeActivationPolicy.test.ts | 32 +- ...mberWorkSyncTargetedRecoveryPolicy.test.ts | 117 + .../main/createMemberWorkSyncFeature.test.ts | 128 +- .../core/ClaudePreflightCommand.test.ts | 75 + .../ClaudePtyWorkspaceTrustStrategy.test.ts | 279 ++ .../core/CodexWorkspaceTrustSettings.test.ts | 57 + .../core/PtyDialogEngine.test.ts | 167 + .../core/StartupDialogRules.test.ts | 91 + .../WorkspaceTrustArgPatchApplier.test.ts | 155 + .../core/WorkspaceTrustCoordinator.test.ts | 267 ++ .../WorkspaceTrustDiagnosticsBudget.test.ts | 53 + .../core/WorkspaceTrustPath.test.ts | 121 + .../main/ClaudeStateProbe.test.ts | 53 + .../main/WorkspaceTrustFeatureFlags.test.ts | 88 + .../main/workspaceTrustPreflightEnv.test.ts | 63 + .../TeamAgentLaunchMatrix.safe-e2e.test.ts | 460 ++ .../TeamLaunchFailureArtifactPack.test.ts | 46 + .../team/TeamProvisioningService.test.ts | 500 +- .../team/ProvisioningProgressBlock.test.tsx | 45 +- 56 files changed, 10267 insertions(+), 87 deletions(-) create mode 100644 docs/team-management/tmux-vs-process-runtime-rationale.md create mode 100644 docs/team-management/workspace-trust-host-preflight-plan.md create mode 100644 src/features/member-work-sync/core/application/MemberWorkSyncNudgeAgendaPredicates.ts create mode 100644 src/features/member-work-sync/core/application/MemberWorkSyncTargetedRecoveryPolicy.ts create mode 100644 src/features/workspace-trust/contracts/index.ts create mode 100644 src/features/workspace-trust/core/application/ClaudePreflightCommand.ts create mode 100644 src/features/workspace-trust/core/application/ClaudePtyWorkspaceTrustStrategy.ts create mode 100644 src/features/workspace-trust/core/application/PtyDialogEngine.ts create mode 100644 src/features/workspace-trust/core/application/StartupDialogRules.ts create mode 100644 src/features/workspace-trust/core/application/WorkspaceTrustCoordinator.ts create mode 100644 src/features/workspace-trust/core/application/WorkspaceTrustLocks.ts create mode 100644 src/features/workspace-trust/core/application/index.ts create mode 100644 src/features/workspace-trust/core/application/ports.ts create mode 100644 src/features/workspace-trust/core/domain/CodexWorkspaceTrustSettings.ts create mode 100644 src/features/workspace-trust/core/domain/WorkspaceTrustArgPatchApplier.ts create mode 100644 src/features/workspace-trust/core/domain/WorkspaceTrustDiagnosticsBudget.ts create mode 100644 src/features/workspace-trust/core/domain/WorkspaceTrustPath.ts create mode 100644 src/features/workspace-trust/core/domain/WorkspaceTrustTypes.ts create mode 100644 src/features/workspace-trust/core/domain/index.ts create mode 100644 src/features/workspace-trust/index.ts create mode 100644 src/features/workspace-trust/main/adapters/output/ClaudeStateProbe.ts create mode 100644 src/features/workspace-trust/main/adapters/output/NodePtyProcessAdapter.ts create mode 100644 src/features/workspace-trust/main/adapters/output/TempEmptyMcpConfigStore.ts create mode 100644 src/features/workspace-trust/main/composition/createWorkspaceTrustCoordinator.ts create mode 100644 src/features/workspace-trust/main/index.ts create mode 100644 src/features/workspace-trust/main/infrastructure/WorkspaceTrustFeatureFlags.ts create mode 100644 src/features/workspace-trust/main/infrastructure/workspaceTrustPreflightEnv.ts create mode 100644 test/features/member-work-sync/core/MemberWorkSyncNudge.test.ts create mode 100644 test/features/member-work-sync/core/application/MemberWorkSyncTargetedRecoveryPolicy.test.ts create mode 100644 test/features/workspace-trust/core/ClaudePreflightCommand.test.ts create mode 100644 test/features/workspace-trust/core/ClaudePtyWorkspaceTrustStrategy.test.ts create mode 100644 test/features/workspace-trust/core/CodexWorkspaceTrustSettings.test.ts create mode 100644 test/features/workspace-trust/core/PtyDialogEngine.test.ts create mode 100644 test/features/workspace-trust/core/StartupDialogRules.test.ts create mode 100644 test/features/workspace-trust/core/WorkspaceTrustArgPatchApplier.test.ts create mode 100644 test/features/workspace-trust/core/WorkspaceTrustCoordinator.test.ts create mode 100644 test/features/workspace-trust/core/WorkspaceTrustDiagnosticsBudget.test.ts create mode 100644 test/features/workspace-trust/core/WorkspaceTrustPath.test.ts create mode 100644 test/features/workspace-trust/main/ClaudeStateProbe.test.ts create mode 100644 test/features/workspace-trust/main/WorkspaceTrustFeatureFlags.test.ts create mode 100644 test/features/workspace-trust/main/workspaceTrustPreflightEnv.test.ts diff --git a/docs/team-management/tmux-vs-process-runtime-rationale.md b/docs/team-management/tmux-vs-process-runtime-rationale.md new file mode 100644 index 00000000..d932cdbd --- /dev/null +++ b/docs/team-management/tmux-vs-process-runtime-rationale.md @@ -0,0 +1,237 @@ +# Runtime backend rationale: process by default, tmux as debug/manual mode + +Date: 2026-05-13 + +Status: informational note, not a normative architecture spec. + +This document captures the reasoning discussed during launch-runtime stabilization work. It may contain small inaccuracies or outdated external-project details, especially about third-party projects. Treat it as context and rationale, not as the source of truth. Current implementation, tests, and upstream project docs remain authoritative. + +## Short version + +We intentionally moved the desktop app toward **process backend by default** for app-launched teammates, while keeping **tmux as an explicit debug/manual mode**. + +The reason is not that tmux is bad. The reason is that our product is not primarily a terminal multiplexer. It is an app-owned team runtime with UI state, launch diagnostics, restart/retry controls, provider auth handling, bootstrap proofs, notifications, and artifact packs. + +For that product shape, the default runtime should be controlled by the app, not by a human attaching to panes. + +## What tmux gives + +tmux is useful when the product expects live terminal sessions: + +- A human can attach to a pane and see exactly what the CLI sees. +- If the CLI asks for input, the user can manually press Enter or answer prompts. +- Panes can survive some app restarts. +- TTY behavior is closer to running the CLI manually. +- Debugging auth/login/TTY problems is easier because the terminal is visible. + +This is why tmux is a natural default for terminal-first systems. + +## Why not tmux like gastown/gascity + +Based on the external-project research snapshot from this thread, `gastown` and `gascity` appear to be more terminal/session-oriented. This is an interpretation of their public docs/issues at the time of research, not a maintained compatibility claim: + +- Their interaction model leans heavily on attachable sessions. +- Their session layer historically expects pane-like targets and terminal observation. +- In `gascity`, tmux appears as a default provider in session configuration. +- They use tmux because their flow values live interactive sessions, attach/revive/nudge, and human terminal control. + +That is a valid design for a terminal-first product. + +It is not automatically the best default for us because our desktop app has different ownership boundaries: + +- We need reliable UI state for each member. +- We need deterministic launch success/failure state. +- We need structured diagnostics, not only "look at the pane". +- We need restart/retry/cleanup to be owned by the app. +- We need provider auth and tool approval to be modeled explicitly. +- We need headless teammate behavior to work without a terminal being open. + +tmux also has known operational costs in this class of products: + +- zombie sessions; +- broken pane targets; +- socket/version split-brain after upgrades; +- platform limitations, especially Windows; +- ambiguity between "pane exists" and "agent is actually ready"; +- harder cleanup when app state and terminal state diverge. + +So the difference is product shape: + +- `gastown/gascity`: terminal/session-first, so tmux default is understandable. +- `claude_team`: desktop/app-owned lifecycle-first, so process default is more aligned. + +## What process backend gives us + +The process backend lets the app own the lifecycle: + +- Runtime identity is represented as process metadata, not only pane id. +- `backendType: process` and `tmuxPaneId: process:` preserve compatibility with older shapes while making the backend explicit. +- Launch state can distinguish `spawned`, `bootstrap_submitted`, `bootstrap_confirmed`, `failed_to_start`, `bootstrap_stalled`, and provider failures. +- Diagnostics can be surfaced in member cards, notifications, launch summaries, and artifact packs. +- Restart and cleanup can target launch-owned processes instead of broad terminal state. +- App-managed bootstrap can avoid relying on the model to manually discover and call setup tools. + +This is a better foundation for stable desktop launches than treating a pane as the primary runtime truth. + +## Interactive prompts are still real + +The main argument for tmux is valid: real CLIs sometimes ask interactive questions. + +Examples: + +- "Press Enter to continue" +- "Do you want to proceed? [y/N]" +- "Enter API key" +- "Please login" +- OAuth token expired +- provider quota or key limit prompt +- tool approval prompt + +Our answer should not be "ignore all interaction". The correct answer is to split interaction into categories. + +## How our architecture should handle interaction + +### Structured approvals + +Tool approvals should use structured protocol: + +- CLI emits a `control_request`; +- app shows an approval UI or notification; +- app sends `control_response` through the owned channel; +- decision is persisted in runtime state. + +This is better than asking the user to attach to tmux and press a key manually. + +### Auth and login prompts + +Auth/login prompts should usually be handled before launch: + +- preflight provider auth; +- validate subscription/API-key mode; +- validate required settings/env; +- fail fast with actionable UI if auth is missing or expired. + +Hidden teammate processes should not block waiting for a browser login or secret input. + +### Safe known prompts + +Some prompts can be handled through an allowlisted interactive prompt gate: + +- exact "Press Enter to continue" style prompt; +- exact yes/no confirmation where the action is known and safe; +- one prompt at a time per process; +- timeout if user does not respond; +- event recorded in diagnostics/artifact pack. + +For a lead process, the desktop app already owns `child.stdin`, so writing a newline is technically possible. + +For teammate process backend, the desktop app may not directly own the child handle. The robust design is: + +- detect prompt in process backend/orchestrator; +- surface structured prompt state to desktop; +- user chooses action in UI; +- the runtime owner writes to the teammate stdin; +- event is persisted. + +Do not blindly write to arbitrary process stdin by PID. + +### Unknown prompts + +Unknown prompts should not be answered automatically. + +Correct behavior: + +- mark the member as waiting/blocked with a diagnostic; +- show the relevant output excerpt; +- suggest fixing auth/settings or using tmux debug mode; +- avoid sending random newline/yes/no input. + +This prevents dangerous accidental confirmation and avoids hiding provider setup bugs. + +## Why tmux remains useful + +tmux should stay available as an explicit mode: + +```bash +CLAUDE_TEAM_TEAMMATE_MODE=tmux pnpm dev +``` + +or via extra CLI args: + +```bash +--teammate-mode tmux +``` + +Use it for: + +- debugging unknown TTY behavior; +- reproducing provider CLI prompts manually; +- investigating strange live CLI output; +- cases where human terminal control matters more than app-owned lifecycle. + +tmux is an escape hatch, not the production default. + +## Why not full arbitrary terminal emulation + +Trying to support all possible interactive terminal behavior inside process backend would be risky. + +Problems: + +- prompts are provider-specific and change over time; +- pressing Enter may be safe in one context and dangerous in another; +- stdin might be structured JSON, not text; +- a newline can land during an active model turn; +- secrets should not be requested through generic stdin; +- the app can accidentally mask auth or provider integration failures. + +The safer contract is: + +- app-managed launch should be non-interactive by default; +- known safe prompts may be handled through structured UI; +- auth/setup should be preflighted; +- unknown TTY needs tmux/manual debug mode. + +## Current strategic choice + +Recommended runtime policy: + +1. Production default: process backend. +2. Provider setup: preflight and actionable diagnostics. +3. Tool approvals: structured app UI. +4. Known safe prompts: bounded interactive prompt gate. +5. Unknown prompts: fail/block visibly with diagnostics. +6. Debug/manual: explicit tmux mode. + +This keeps the app in control of lifecycle state while preserving tmux where it is genuinely useful. + +## Tradeoff summary + +### Process default + tmux debug mode + +Confidence: 9.3/10 +Reliability: 9/10 +Complexity: 6/10 + +Best fit for desktop/app-owned agent teams. Requires strong diagnostics and provider preflight. + +### tmux default + process fallback + +Confidence: 6.5/10 +Reliability: 6.5/10 +Complexity: 4/10 + +Good for terminal-first workflows. Less aligned with deterministic app-owned launch state. + +### Fully abstract runtime providers + +Confidence: 7/10 +Reliability: 7.5/10 +Complexity: 9/10 + +Potentially useful later, but too broad as a launch-stability fix. + +## Bottom line + +We did not reject tmux entirely. We rejected tmux as the default runtime truth for app-launched teams. + +The desktop product should make teammate launch reliable through app-owned process lifecycle, structured evidence, diagnostics, and controlled recovery. tmux remains valuable for debug/manual sessions, especially when an unknown CLI prompt requires a real terminal. diff --git a/docs/team-management/workspace-trust-host-preflight-plan.md b/docs/team-management/workspace-trust-host-preflight-plan.md new file mode 100644 index 00000000..930d66f5 --- /dev/null +++ b/docs/team-management/workspace-trust-host-preflight-plan.md @@ -0,0 +1,4227 @@ +# Workspace Trust Host-Preflight Plan + +## Goal + +Make team launch work in newly selected workspaces without forcing the user to open Claude Code or Codex manually, while keeping the runtime safe, testable, and easy to extend. + +Chosen approach: **Host-preflight + runtime contract**. + +Rating: + +- Option: Host-preflight + runtime contract +- Confidence: 8/10 +- Reliability: 9/10 +- Complexity: 8/10 +- Estimated change size: 950-1450 lines in the desktop app, plus 50-180 lines if the orchestrator contract is hardened in the sibling runtime repo. + +This plan intentionally avoids changing process launch semantics, permission mode, cleanup, tmux lifecycle, or provider auth. It only prepares exact user-selected workspaces for provider trust gates and improves recovery when a trust gate still blocks launch. + +Important 2026-05-13 update: the safest Claude preflight is no longer plain `claude`. Use a protected interactive Claude command that still shows the workspace trust prompt but suppresses project MCP, project/local settings, hooks, and built-in tools as much as Claude Code allows. + +## Problem Summary + +Recent failures show this class of launch error: + +```text +Teammate "alice-reviewer" cannot start in headless process runtime because workspace trust is not accepted for "[path]". +Open that workspace once interactively and accept trust, then launch the team again. +``` + +The current UI explains the failure, but the product goal is stronger: + +- The user selects a project in the frontend. +- The app should prepare that exact project for team launch. +- The user should not need to manually open the workspace in Claude Code or Codex. +- The fix must not weaken provider security globally or trust arbitrary paths. + +Important finding: this is not just a Codex issue. The `agent_teams_orchestrator` process runtime checks Claude Code workspace trust before spawning headless teammates, including Codex teammates. So a Codex teammate can fail because the Claude/orchestrator workspace trust state is missing. + +## Prototype Findings + +Local versions used during compatibility checks: + +- Claude Code: `2.1.119` +- Codex CLI: `0.125.0` +- node-pty: `1.1.0` +- Platform: macOS arm64 + +### Claude Findings + +Fresh workspace with seeded Claude profile: + +```text +Quick safety check: Is this a project you created or one you trust? +Yes, I trust this folder +Enter to confirm +``` + +After pressing Enter, Claude writes: + +```json +{ + "projects": { + "/private/.../workspace": { + "hasTrustDialogAccepted": true, + "projectOnboardingSeenCount": 1 + } + } +} +``` + +Second launch in the same folder skips the trust prompt. + +Additional observed Claude startup chains: + +```text +trust -> main +trust -> bypass permissions -> main +trust -> custom API key confirmation -> main +empty profile onboarding -> no trust yet +``` + +Important detail: `hasCompletedProjectOnboarding` can remain `false`, but `hasTrustDialogAccepted: true` is enough for the existing `isPathTrusted()` gate. + +### Claude Protected Preflight Findings + +Local `claude --help` for `2.1.119` exposes these important flags: + +- `--bare` - minimal mode that skips hooks, LSP, plugin sync, attribution, auto-memory, background prefetches, keychain reads, and CLAUDE.md auto-discovery. +- `--strict-mcp-config` plus `--mcp-config` - only load MCP servers from the provided config. +- `--setting-sources user` - load user settings without project/local settings. +- `--settings '{"disableAllHooks":true}'` - explicitly disables hooks for this session. +- `--tools ""` - disables built-in tools for this session. +- `-p`/`--print` is not suitable because help says workspace trust is skipped in print mode. +- `doctor` is not suitable because help says workspace trust is skipped and `.mcp.json` stdio servers can be spawned for health checks. + +PTY smoke without pressing Enter confirmed these variants still show the workspace trust prompt in a new folder: + +```text +claude +claude --bare +claude --bare --strict-mcp-config --mcp-config +claude --strict-mcp-config --mcp-config --setting-sources user --settings '{"disableAllHooks":true}' +claude --bare --strict-mcp-config --mcp-config --setting-sources user --settings '{"disableAllHooks":true}' --tools "" +``` + +The empty MCP config must be: + +```json +{"mcpServers":{}} +``` + +Plain `{}` is rejected by Claude Code as invalid MCP config. + +Recommended candidate command for v1: + +```text +claude --bare --strict-mcp-config --mcp-config --setting-sources user --settings '{"disableAllHooks":true}' --tools "" +``` + +This still needs one final smoke before default-on: press Enter on the trust prompt in a temp workspace and verify `hasTrustDialogAccepted: true` is persisted in the same state file the orchestrator reads. + +### External Research Update + +Relevant external facts checked on 2026-05-13: + +- Official Claude Code CLI reference documents `--bare`, `--strict-mcp-config`, `--mcp-config`, `--setting-sources`, `--settings`, `--tools`, and warns that `-p` skips workspace trust: [Claude Code CLI reference](https://code.claude.com/docs/en/cli-reference). +- Official Claude Code settings and hooks docs document `disableAllHooks`, managed/user/project/local setting sources, and hook disable behavior: [Claude Code settings](https://code.claude.com/docs/en/settings), [Claude Code hooks](https://code.claude.com/docs/en/hooks). +- Official Claude Code security docs say first-time codebase runs and new MCP servers require trust verification, and that MCP configuration can live in source-controlled project settings: [Claude Code security](https://docs.anthropic.com/en/docs/claude-code/security). +- Recent security research around "TrustFall" argues that accepting folder trust can enable project-defined MCP execution in agentic CLIs. We should not rely on full normal Claude startup for preflight if a protected interactive command works: [Adversa AI TrustFall report](https://adversa.ai/blog/trustfall-coding-agent-security-flaw-rce-claude-cursor-gemini-cli-copilot/). +- A recent Codex issue reports that `-c projects."".trust_level="trusted"` behavior may not be purely ephemeral in affected versions. Treat Codex native config overrides as app-scoped intent, not a hard guarantee that no user config changes can happen: [openai/codex issue #18475](https://github.com/openai/codex/issues/18475). +- Sibling runtime check: Claude CLI uses `-c` for `--continue`, while Codex native exec uses `configOverrides: string[]` and turns those into `-c` only when spawning the Codex binary. Therefore desktop must not append Codex `-c` pairs to Claude/orchestrator launch argv. + +### Codex Findings + +Codex TUI with real profile in a new workspace: + +```text +Update available +Skip +Do you trust the contents of this directory? +Yes, continue +``` + +The order matters. After skipping the update prompt, the workspace trust prompt appears. This matches GasCity's tested sequence: + +```text +Down, Enter, Enter +``` + +Codex direct CLI with per-launch override: + +```bash +codex \ + -c 'projects."/path".trust_level="trusted"' \ + -c 'projects."/realpath".trust_level="trusted"' +``` + +The trust prompt is skipped for that direct Codex launch. Path keys with spaces, brackets, and quotes work when serialized as quoted TOML keys. + +Important refinement: the reusable value is the dotted config override string, not necessarily the CLI `-c` flag. In Agent Teams deterministic launch, pass those values to the sibling runtime through a typed settings/runtime contract, and let Codex native exec convert them to `-c` only at the direct Codex binary boundary. + +Codex with an isolated `CODEX_HOME` and no auth shows an auth picker first, not a trust prompt. This must be handled as provider auth required, not workspace trust. + +### GasCity Prior Art + +GasCity has a battle-tested startup dialog sequence in `internal/runtime/dialog.go`: + +1. Claude resume selector - `Down`, `Enter` +2. Codex update dialog - `Down`, `Enter` +3. Workspace trust dialog - `Enter` +4. Bypass permissions warning - `Down`, `Enter` +5. Claude custom API key confirmation - `Up`, `Enter` +6. Rate limit dialog + +Relevant behavior from GasCity tests: + +- Detects Claude trust: `Quick safety check`, `trust this folder` +- Detects Codex trust: `Do you trust the contents of this directory?` +- Detects Gemini trust: `Do you trust the files in this folder?` +- Peeks deep enough to catch a late trust dialog below prompt text +- Handles stale update snapshots before moving to trust +- Waits a short grace period after an apparent prompt because a dialog can arrive in the next terminal snapshot + +We should copy the state-machine idea, not the tmux dependency. + +### Other Project Lessons + +GasTown older implementation: + +- polls tmux pane content after startup +- accepts workspace trust before bypass permission warning +- checks trust text before prompt detection because Codex trust screens can contain a leading `>` line +- has a blind dismiss helper, but only for remediation of already-stalled sessions + +What to copy: + +- trust-before-prompt matching order +- polling with timeout +- idempotent no-dialog behavior + +What not to copy into v1: + +- blind key sequences on a healthy launch +- tmux dependency +- generic prompt suffix readiness + +Overstory newer implementation: + +- provider runtime owns `detectReady(content)` and returns `dialog`, `ready`, or `loading` +- `waitForTuiReady()` calls a provider callback instead of hardcoding Claude only +- tracks handled dialog actions so trust `Enter` is not sent repeatedly +- retries typed dialogs like bypass confirmation after a delay if the dialog persists +- declares Claude ready only after prompt marker plus status bar marker + +What to copy: + +- provider-owned detection callback shape +- handled-action memory +- two-signal readiness +- dead-session/exit awareness + +What to adapt: + +- our PTY engine should use `PtyProcessPort`, not tmux +- our v1 preflight can stop after trust persistence, not full TUI readiness + +## Second-Pass Codebase Findings + +This section records the high-risk integration findings from the current app codebase. + +### Deterministic Path Shape + +The legacy deterministic paths are in `TeamProvisioningService._createTeamInner` and +`TeamProvisioningService._launchTeamInner`. + +Important order today: + +1. Acquire per-team lock. +2. Normalize `config.json` and update project path. +3. Resolve Claude binary. +4. Build provider-aware env. +5. Materialize effective member specs. +6. Resolve OpenCode member workspaces. +7. Build cross-provider args. +8. Build `providerArgsByProvider`. +9. Resolve and validate launch identity. +10. Create `ProvisioningRun`. +11. Build bootstrap spec, prompt file, MCP config. +12. Build final launch args. +13. Spawn CLI process. + +The trust work cannot be inserted as a single block without risk. It needs three phases: + +- **Early settings-only plan phase** immediately after `buildProvisioningEnv`, because Codex provider settings can affect default model resolution inside `materializeEffectiveTeamMemberSpecs()`. +- **Full plan phase** before launch identity validation, because Codex runtime-trust settings must affect `readRuntimeProviderLaunchFacts()` and final runtime settings. +- **Execute phase** after `ProvisioningRun` exists, because Claude PTY warmup can take seconds and should report progress/diagnostics. + +### Provider Args Risk + +Codex provider settings/args are used in several places: + +- Primary provider args from `buildProvisioningEnv`. +- Cross-provider args from `buildCrossProviderMemberArgs`. +- `providerArgsByProvider` passed into `resolveAndValidateLaunchIdentity`. +- Final `launchArgs`. +- Flattened cross-provider member args pushed after primary runtime args. + +Risk: if Codex is a **secondary teammate** under an Anthropic lead, adding trust only to primary provider args will not reach the spawned Codex teammate. The opposite mistake is worse: appending Codex native `-c` to Claude launch argv makes Claude interpret `-c` as `--continue`. + +Rule: apply Codex trust intent only through surfaces that are known to carry Codex settings or Codex native config overrides: + +- `providerArgsByProvider.get('codex')` +- primary `runtimeArgsPlan.providerArgs` when lead provider is Codex +- flattened cross-provider settings when Codex is a cross-provider teammate +- sibling runtime Codex native `configOverrides` after validating the override values +- any future one-shot runtime probe that takes Codex provider settings + +### Progress And Artifact Risk + +Before `ProvisioningRun` exists, failures can throw without launch progress/artifact context. After `ProvisioningRun` exists, the service can: + +- update progress +- append provisioning trace lines +- include diagnostics in launch failure artifacts +- clean up generated bootstrap files if a later step fails + +Rule: the plan phase must not throw for normal trust problems. It should return diagnostics and arg patches. The execute phase should also prefer diagnostics over throw, except for deterministic local errors such as invalid cwd. + +### PTY Pattern Already Exists + +The app already has `ClaudeDoctorProbe` and `PtyTerminalService` patterns: + +- `node-pty` is an optional native dependency. +- imports must be graceful. +- PTY startup can throw and must become a diagnostic, not app crash. +- output should be bounded. +- `pty.kill()` is best effort. + +The workspace trust PTY adapter should reuse those patterns rather than adding direct `require('node-pty')` calls in the launch service. + +### OpenCode Boundary + +`_createTeamInner` and `_launchTeamInner` do not handle pure OpenCode teams routed through the runtime adapter. Mixed OpenCode secondary lanes are handled later. V1 should target the legacy deterministic create/launch paths and exact workspaces in `allEffectiveMemberSpecs`. + +Do not pull OpenCode runtime adapter launch into this change unless a separate OpenCode trust gate appears. That keeps the blast radius small. + +## Third-Pass Integration Findings + +This section records additional constraints found after reading the current app and sibling runtime code more closely. + +### Service Injection Constraint + +`TeamProvisioningService` has a large positional constructor used by many tests. Adding another constructor parameter is technically possible, but it increases test churn and makes dependency order more brittle. + +Safer integration: + +- add a private `workspaceTrustCoordinator` field or lazy getter +- add `setWorkspaceTrustCoordinator(coordinator: WorkspaceTrustCoordinator | null): void` +- follow the existing setter pattern used by `setRuntimeAdapterRegistry`, `setControlApiBaseUrlResolver`, and runtime turn-settled providers +- keep constructor signature unchanged + +Rating: 🎯 9 🛡️ 9 🧠 3, ~20-35 LOC. + +Avoid in v1: + +- converting the whole service constructor to an options object +- threading the coordinator through every existing test constructor call +- importing `node-pty` from `TeamProvisioningService` + +### Progress Schema Constraint + +`TeamLaunchDiagnosticItem.code` is a fixed TypeScript union. Adding `workspace_trust_preflight` there is a schema change and should come with renderer tests. + +V1 should avoid that surface: + +- use `progress.message` for temporary live status +- use `progress.warnings` for short non-blocking warning strings +- store structured trust preflight data on `run.workspaceTrustDiagnostics` +- copy structured trust preflight data into artifact `flags.workspaceTrustPreflight` + +Only add UI diagnostic rows later if needed, with: + +- `TeamLaunchDiagnosticItem.code` union extension +- renderer copy-diagnostics test +- member card/detail diagnostic rendering test + +Rating: 🎯 9 🛡️ 10 🧠 3, ~25-45 LOC for v1 artifact-only diagnostics. + +### Artifact Pack Constraint + +`writeLaunchFailureArtifactPackBestEffort()` already has a flexible `flags` object in the manifest. That is the safest place to put structured workspace trust preflight data in v1. + +Add: + +```ts +flags: { + ...existingFlags, + workspaceTrustPreflight: run.workspaceTrustDiagnostics ?? null, +} +``` + +Do not add raw terminal transcripts here. Store bounded, redacted facts only: + +- provider +- workspace path or hash according to existing path exposure policy +- status +- matched dialog ids +- actions +- elapsedMs +- error code + +### Runtime Security Constraint + +The sibling `agent_teams_orchestrator` uses Claude workspace trust as a security gate for more than teammate spawn: + +- headless teammate process startup checks `isPathTrusted(workingDir)` +- hooks are skipped until workspace trust is accepted +- auth helpers are guarded by `checkHasTrustDialogAccepted()` +- MCP headers helpers are guarded by trust + +So the host must not disable the runtime gate. The host should prepare trust through the provider-owned flow, then let the runtime keep enforcing trust. + +This confirms the v1 rule: no global bypass flag and no direct config write as the normal path. + +### Provider Args Constraint + +`buildTeamRuntimeLaunchArgsPlan()` reads `providerArgs` from `envResolution.providerArgs`. + +Safer than changing the method signature: + +```ts +const envResolutionForLaunch = { + ...provisioningEnv, + providerArgs: providerArgsForLaunch, +} +``` + +Then pass `envResolutionForLaunch` into `buildTeamRuntimeLaunchArgsPlan()`. + +`mergeJsonSettingsArgs()` only merges `--settings` JSON args. For deterministic Agent Teams launch, Codex trust should normally be represented as JSON settings that the sibling runtime consumes, not as direct `-c` pairs on the Claude CLI command. + +Arg ordering rule: + +- final launch currently pushes extra args before provider args +- app-managed Codex trust settings should live in app-managed provider settings so `mergeJsonSettingsArgs()` can combine them with existing Codex settings +- direct Codex CLI `-c` is allowed only at the Codex binary boundary, not at the Claude launch boundary +- if Codex native config override precedence changes, switch the sibling runtime adapter to explicit validated merge of user `projects` entries plus app-owned workspace keys + +Probe ordering rule: + +- `readRuntimeProviderLaunchFacts()` receives only provider args, not user extra args +- therefore Codex trust settings must be patched into `providerArgsByProvider` before launch identity validation +- do not rely on final `launchArgs` for provider facts + +### Runtime Path Matching Constraint + +The runtime `isPathTrusted(dir)` walks parent directories from `resolve(dir)`. The host `ClaudeStateProbe` should mirror this: + +- check exact resolved cwd +- check exact realpath cwd +- check parent directories up to root +- treat a trusted parent as trusted for a child +- do not write trust to a parent unless the provider itself chooses that key + +This lets us skip PTY when the user already trusted a repository root and then launches a subdirectory. + +## Fourth-Pass Failure Scope Findings + +The most dangerous integration bug is not prompt matching. It is inserting preflight after `ProvisioningRun` is created but outside the existing cleanup scopes. + +Current launch shape: + +- `run` is inserted into `runs` and `provisioningRunByTeam` +- progress trace is initialized +- persisted launch state is cleared +- bootstrap files are written inside a local `try/catch` +- spawn is inside another local `try/catch` +- `cleanupRun()` writes artifacts and removes run tracking, but does not restore the prelaunch config backup + +If workspace trust `execute()` throws in the wrong place, the service can leave: + +- `provisioningRunByTeam` pointing at a dead run +- normalized config not restored +- Anthropic helper material not cleaned +- progress retained without a failure artifact +- stale launch state cleared without a corresponding launch attempt + +### Pre-Spawn Failure Helper + +Add a small helper for failures after `run` exists and before `spawnCli()` succeeds: + +```ts +private async failDeterministicRunBeforeSpawn( + run: ProvisioningRun, + input: { + mode: 'create' | 'launch' + message: string + error: string + provisioningEnv: ProvisioningEnvResolution + cleanupPolicy: DeterministicPreSpawnCleanupPolicy + } +): Promise +``` + +Responsibilities: + +1. assign bounded `run.workspaceTrustDiagnostics` if the failure came from preflight +2. `updateProgress(run, 'failed', input.message, { error: input.error, warnings: run.progress.warnings })` +3. `run.onProgress(run.progress)` +4. cleanup Anthropic helper material if present +5. apply the typed create/launch cleanup policy +6. call `cleanupRun(run)` so artifacts and retained progress are handled consistently +7. throw an `Error` with the same message + +The diagnostics assignment must happen before `cleanupRun(run)`, because the failure artifact writer is idempotent per run and may run during cleanup. + +Use this helper for workspace trust `blocked` results and unexpected preflight exceptions after run creation. + +Do not manually duplicate `this.runs.delete(...)` and `this.provisioningRunByTeam.delete(...)` in the new preflight path. The existing code already has several manual cleanup branches, and adding one more is how subtle lifecycle drift happens. + +```mermaid +flowchart TD + A["ProvisioningRun created"] --> B["WorkspaceTrustCoordinator.execute"] + B -->|"ok"| C["clearPersistedLaunchState"] + B -->|"soft_failed"| D["merge warning"] + D --> C + C --> K["launchStateClearedForRun = true"] + B -->|"blocked or unexpected error"| E["failDeterministicRunBeforeSpawn"] + E --> F["assign diagnostics then updateProgress failed"] + F --> G["cleanup helper material"] + G --> H["apply create/launch cleanup policy"] + H --> I["cleanupRun, guarded by launchStateClearedForRun"] + I --> J["artifact flags.workspaceTrustPreflight"] +``` + +### Exact Execute Placement + +Recommended v1 placement: + +1. create `run` +2. insert `run` into maps +3. initialize provisioning trace and emit initial progress +4. run `WorkspaceTrustCoordinator.execute()` inside a guarded pre-spawn block +5. if blocked, call `failDeterministicRunBeforeSpawn(...)` +6. if soft-failed, merge warning and continue +7. then clear persisted launch state and continue current bootstrap flow + +Reason for running before `clearPersistedLaunchState`: if preflight blocks before any runtime launch starts, the app should not erase the previous launch snapshot as if a new launch had begun. + +Required guard: initialize `run.launchStateClearedForRun = false` and set it to `true` only after `clearPersistedLaunchState()` succeeds. `cleanupRun()` must use this flag before persisting failed launch snapshots or finalizing unconfirmed bootstrap members for a launch run. Otherwise a preflight-blocked relaunch can still overwrite the previous launch snapshot even though no runtime process was spawned. + +Create mode detail: run the helper before team meta/tasks directories are created. That keeps create cleanup minimal and avoids deleting any pre-existing unrelated team data if a future branch changes existence checks. + +Launch mode detail: run the helper after `normalizeTeamConfigForLaunch()` and `updateConfigProjectPath()` because the launch flow already mutates config before run creation. A blocked launch preflight must restore the prelaunch config through the typed cleanup policy. + +If later product behavior wants clearing earlier, it should be a deliberate change with a test that covers stale launch state UI. + +### Preflight Env Constraint + +Claude PTY preflight should not use the final runtime env blindly. + +Use a derived env: + +```ts +const trustPreflightEnv = buildWorkspaceTrustPreflightEnv(shellEnv) +``` + +Keep: + +- `HOME` +- `USERPROFILE` +- `PATH` +- `SHELL` / `COMSPEC` +- `TERM` +- `CLAUDE_CONFIG_DIR` +- normal user auth env already present in the shell + +Remove: + +- `CLAUDE_ENABLE_DETERMINISTIC_TEAM_BOOTSTRAP` +- `CLAUDE_TEAM_CONTROL_URL` +- app-managed Anthropic helper env vars +- runtime turn-settled hook env vars +- transient team launch nonce/env vars + +Reason: preflight is not a team runtime. It should only let Claude Code show and persist workspace trust. It should not execute app-managed helper paths or runtime hooks before trust is established. + +Rating: 🎯 8 🛡️ 9 🧠 5, ~40-80 LOC with tests. + +## Fifth-Pass Reliability Findings + +This pass focuses on the parts that can still fail after the high-level design is correct. + +### Concurrency Scope + +`TeamProvisioningService.withTeamLock()` serializes launches per team name, not per workspace. Two different teams can launch against the same selected workspace at the same time. + +Add `WorkspaceTrustLockRegistry` in the workspace-trust feature: + +- lock key: `${provider}:${normalizedRealpath}` +- in-process promise chaining for normal app usage +- optional file lock using the existing `withFileLock` helper for cross-window or duplicate app instances +- acquire timeout: 5 seconds for plan-free locks, 20 seconds for active PTY preflight +- stale timeout: 30 seconds +- waiting for a lock should be cancellable + +Behavior: + +- if another launch is already preparing the same workspace, wait for it +- after lock wait, re-run the state probe before spawning PTY +- if the other launch already accepted trust, return `already_trusted_after_wait` +- if lock acquisition times out, return `soft_failed` and let runtime classification handle any remaining trust issue + +Rating: 🎯 9 🛡️ 9 🧠 5, ~70-120 LOC. + +### Cancellation Contract + +Preflight must be cancellable because it runs after `ProvisioningRun` exists. + +Add to `WorkspaceTrustExecutionPlan`: + +```ts +isCancelled(): boolean +onProgress(event: WorkspaceTrustProgressEvent): void +``` + +Rules: + +- check cancellation before acquiring lock +- check before spawning PTY +- check after every terminal snapshot +- check before every key action +- if cancelled, kill PTY and return `cancelled` +- `TeamProvisioningService` maps `cancelled` to the existing launch cancellation path, not a trust failure + +Do not use a long uninterruptible `Promise.race` around the whole preflight. The engine should poll in small intervals and notice cancellation. + +### Claude PTY Command Shape + +Default Claude preflight command should be protected, not just boring: + +```text +claude --bare --strict-mcp-config --mcp-config --setting-sources user --settings '{"disableAllHooks":true}' --tools "" +``` + +Do not pass: + +- team bootstrap args +- `--team-bootstrap-spec` +- `--mcp-config` +- `--dangerously-skip-permissions` +- user `extraCliArgs` +- model/effort args + +Reason: the goal is only to let Claude Code persist workspace trust for the selected cwd. Passing launch args can trigger extra provider setup, hooks, MCP, permission prompts, or model-specific behavior. + +The protected command also reduces the risk that accepting trust immediately loads project MCP, project/local settings, hooks, or tools before the preflight can kill the PTY. The dialog engine may still know about bypass/custom API prompts because users can have global settings that surface them, but the strategy should not intentionally create those prompts. + +Command fallback order: + +1. Protected modern command - 🎯 9 🛡️ 9 🧠 6, ~80-140 LOC including flag detection and temp MCP cleanup. Chosen for v1 if the final persistence smoke passes. +2. Strict MCP/settings command without `--bare` - 🎯 8 🛡️ 8 🧠 5, ~60-110 LOC. Fallback for Claude builds that do not support `--bare`. +3. Plain `claude` PTY auto-accept - 🎯 8 🛡️ 5 🧠 4, ~40-80 LOC. Do not default now because it can load more project/user startup behavior after trust. + +Flag compatibility rule: + +- Discover support with cached `claude --help` text or optimistic spawn diagnostics. +- If protected flags are unsupported, return `preflight_unavailable_or_unprotected` as a soft failure unless an explicit experimental env flag allows the lower-safety fallback. +- Do not silently downgrade to plain `claude` in production defaults. + +### Post-Trust Exit Rule + +After a trust action, success probing outranks dialog clearing. + +Flow: + +1. detect workspace trust prompt +2. send allowlisted `Enter` +3. wait a short settle delay +4. probe Claude state +5. if trust is persisted, kill PTY immediately and return success + +Do not wait for full TUI readiness after trust is persisted. + +Reason: after trust is accepted, Claude may begin loading project config, MCP, hooks, or auth helpers. The selected workspace is now trusted, but preflight should still minimize side effects by exiting as soon as the trust bit is durable. + +### Path Canonicalization Contract + +Use two path forms everywhere: + +- `displayCwd`: what the user selected and what diagnostics can show +- `configKeyCwd`: path normalized like the runtime config key + +Rules: + +- `realCwd` uses `fs.promises.realpath` when available +- `configKeyCwd` uses path normalize plus backslash-to-forward-slash, matching sibling runtime `normalizePathForConfigKey` +- comparison key uses `normalizePathForComparison` so Windows drive letter and separator case do not duplicate locks +- diagnostics include both display and real path when existing launch diagnostics already expose paths +- do not lowercase POSIX paths +- do not trust parent by writing parent key; only treat a persisted trusted parent as covering the child + +Tests: + +- macOS `/var` and `/private/var` +- symlinked workspace +- Windows `C:\Repo` and `c:/repo` +- UNC path +- path with trailing slash +- deleted workspace during plan + +### Feature Flag Semantics + +Use a single parsed config object: + +```ts +type WorkspaceTrustFeatureFlags = { + enabled: boolean + claudePty: boolean + codexArgs: boolean + retry: boolean + fileLock: boolean +} +``` + +Parsing rules: + +- only `'0'`, `'false'`, and `'off'` disable +- only `'1'`, `'true'`, and `'on'` enable explicit experimental flags +- malformed values fall back to default and emit a diagnostic once +- include effective flags in artifact `flags.workspaceTrustPreflight.featureFlags` + +Default v1: + +- `enabled: true` +- `claudePty: true` +- `codexArgs: true` +- `retry: false` +- `fileLock: true` if lock path can be created, otherwise degrade to in-process lock + +### Diagnostics Redaction + +PTY raw output can contain account names, emails, org names, or snippets of provider setup text. + +V1 diagnostics should store structured facts by default: + +- status +- provider +- workspace id/source +- matched rule ids +- action names +- elapsedMs +- bounded error code/message + +Only store `rawTail` when: + +- preflight fails +- feature flag `AGENT_TEAMS_WORKSPACE_TRUST_DEBUG=1` is set +- the tail has passed the same secret redaction used by launch failure artifacts + +Even then, cap raw tail to 8 KiB. + +## Sixth-Pass Lifecycle Findings + +This pass focuses on integration bugs that can happen even when prompt handling is correct. + +### Launch Cancellation State + +`ProvisioningRun` is created with progress state `validating`, but `cancelProvisioning()` only allows cancellation in `spawning`, `configuring`, `assembling`, `finalizing`, or `verifying`. + +If Claude PTY preflight runs for several seconds while progress remains `validating`, the UI can show an active run that cannot be cancelled through the existing cancel API. + +Rule: + +- before `WorkspaceTrustCoordinator.execute()`, update progress to cancellable state `spawning` with message `Preparing workspace trust` +- after execute returns `ok` or `soft_failed`, continue current flow +- if execute returns `cancelled`, do not call `failDeterministicRunBeforeSpawn(...)` as a failure; follow existing cancellation semantics +- after execute returns, check `run.cancelRequested`, `run.processKilled`, `stopAllTeamsGeneration`, and whether the run is still current before continuing to `clearPersistedLaunchState` + +Rating: 🎯 9 🛡️ 9 🧠 4, ~25-45 LOC plus tests. + +### Stop/Shutdown Race + +`stopTeam()` can call `cleanupRun(run)` while `_createTeamInner()` or `_launchTeamInner()` is still inside the preflight await. That means the code after `execute()` must tolerate a run that was already cleaned up by user stop or app shutdown. + +Add a stale-run guard: + +```ts +private isLaunchRunStillCurrent(run: ProvisioningRun): boolean { + return this.runs.get(run.runId) === run && + this.provisioningRunByTeam.get(run.teamName) === run.runId && + !run.cancelRequested && + !run.processKilled +} +``` + +Use it after trust preflight and before every subsequent pre-spawn block. + +If the run is stale: + +- kill any preflight PTY through coordinator cancellation +- do not restore config twice +- do not write a second failure artifact +- throw a cancellation-shaped error only after the existing stop cleanup has already updated progress + +### Main Composition Boundary + +`TeamProvisioningService` is constructed in `src/main/index.ts` and then configured through setter methods. + +V1 wiring: + +```ts +teamProvisioningService = new TeamProvisioningService() +teamProvisioningService.setWorkspaceTrustCoordinator(createWorkspaceTrustCoordinator(...)) +``` + +Rules: + +- expose main-only composition from `src/features/workspace-trust/main` +- keep `node-pty`, filesystem config probes, temp MCP files, and file locks inside main adapters +- keep root `src/features/workspace-trust/index.ts` free of Electron/native imports +- tests can inject fake coordinator without loading `node-pty` + +### Coordinator Shutdown Ownership + +The coordinator owns transient PTY sessions. The service owns team launch cancellation. + +Contract: + +- `WorkspaceTrustCoordinator.execute()` receives `isCancelled` +- `stopTeam()` and `stopAllTeams()` set existing run cancellation flags +- coordinator checks cancellation in small polling intervals and kills active PTY +- optional `dispose()` kills any sessions not tied to a current run +- `TeamProvisioningService.stopAllTeams()` does not need a new broad process killer for preflight; it should only trigger cancellation and let the coordinator clean its own sessions + +This avoids adding preflight Claude PIDs to generic team runtime cleanup, where it would be easy to kill unrelated user Claude sessions. + +### Provider State Probe Races + +Claude may write `.claude.json` while `ClaudeStateProbe` reads it. + +Rules: + +- read with a small max byte limit +- on JSON parse failure, retry 2-3 times with a short delay +- never log the raw file +- if still unreadable, return `unknown` and let the strategy decide soft failure +- mirror runtime path matching, including parent directory trust + +This is especially important on Windows and OneDrive-style filesystems where atomic rename and file visibility can lag. + +### Claude Binary Resolution Gap + +Local prototype showed `claude` was not in this shell PATH, while `/Users/belief/.local/bin/claude` exists and works. The strategy must use `ClaudeBinaryResolver.resolve()` output, not a hardcoded `claude` command name. + +The PTY env can add `path.dirname(claudePath)` to PATH for child tools only if needed, but the spawned executable should be the resolved absolute path. + +### PTY Ownership Is Not ChildProcess Ownership + +Existing `transientProbeProcesses` tracks `child_process.spawn()` probes. `node-pty` returns `IPty`, not a `ChildProcess`, so it must not be stuffed into that set. + +Rules: + +- `NodePtyProcessAdapter` owns `IPty` lifecycle. +- `WorkspaceTrustCoordinator` owns the active preflight session registry. +- `TeamProvisioningService` cancels by setting run flags and passing `isCancelled`. +- app shutdown can call optional coordinator `dispose()` through the service setter-owned dependency. +- do not add preflight PTYs to global CLI process tracking, because those helpers are designed for normal child processes and can over-kill. + +### Shell Env And Keychain Boundary + +`buildEnrichedEnv()` only sets `CLAUDE_CONFIG_DIR` when the user configured a custom Claude base path. Setting it to the default path can break macOS Keychain namespace lookup. + +Preflight env rule: + +- start from the same enriched env family as runtime, using the resolved `claudePath` +- preserve `HOME`, `USERPROFILE`, `USER`, `LOGNAME`, `PATH`, and custom `CLAUDE_CONFIG_DIR` if present +- do not force `CLAUDE_CONFIG_DIR` to the default `~/.claude` +- strip team runtime env and app-managed helper env after enrichment + +## Seventh-Pass Flow Integration Findings + +This pass found the biggest missing piece in the earlier plan: the app has two legacy deterministic flows, not one. + +### Create And Launch Must Both Be Covered + +`createTeam()` and `launchTeam()` have parallel legacy deterministic paths: + +- both build provider env +- both materialize member specs +- both resolve OpenCode member workspaces +- both build cross-provider args +- both validate launch identity +- both create `ProvisioningRun` +- both write bootstrap/MCP files +- both spawn Claude CLI + +If preflight is added only to `_launchTeamInner`, the very first team creation for a new project can still hit the same trust gate. + +V1 scope should be: + +- legacy deterministic `createTeam` +- legacy deterministic `launchTeam` +- not pure OpenCode runtime adapter path +- mixed OpenCode side lanes only insofar as their workspaces flow through legacy deterministic launch + +Top 3 integration options: + +1. Shared helper used by create and launch - 🎯 9 🛡️ 9 🧠 7, ~180-280 integration LOC. Chosen because it prevents drift between the two flows. +2. Implement launch first, create later - 🎯 6 🛡️ 6 🧠 5, ~90-160 LOC. Lower initial work, but leaves first-run new-project failures. +3. Inline duplicate preflight blocks in both flows - 🎯 5 🛡️ 5 🧠 4, ~120-220 LOC. Fast but likely to diverge during future launch fixes. + +Recommended helper shape: + +```ts +private async prepareWorkspaceTrustForDeterministicRun(input: { + mode: 'create' | 'launch' + run: ProvisioningRun + request: TeamCreateRequest | TeamLaunchRequest + claudePath: string + shellEnv: NodeJS.ProcessEnv + stopAllGenerationAtStart: number + workspaceTrustPlan: WorkspaceTrustFullPlanResult + cleanupPolicy: DeterministicPreSpawnCleanupPolicy +}): Promise +``` + +The helper owns: + +- progress transition to cancellable `spawning` +- `WorkspaceTrustCoordinator.execute()` +- stale-run guard after await +- result mapping: `ok`, `soft_failed`, `blocked`, `cancelled` +- calling the correct pre-spawn failure cleanup helper + +It must not own: + +- provider arg patch computation +- bootstrap spec generation +- MCP runtime config generation +- process spawn + +### Different Cleanup Policies For Create And Launch + +Create and launch clean up different things. + +Launch cleanup: + +- restore prelaunch `config.json` +- cleanup Anthropic helper material +- remove generated bootstrap files if already present +- preserve existing team data +- do not clear previous launch state if preflight blocks before runtime launch + +Create cleanup: + +- there may be no previous team files yet +- if preflight runs before team meta/tasks dirs are created, cleanup should only remove run tracking and Anthropic helper material +- if a later shared helper is reused after meta dirs are written, it must delete the create-owned team dir/tasks dir exactly like current create spawn-failure cleanup +- never call `restorePrelaunchConfig()` for create mode + +Do not use one boolean like `restorePrelaunchConfig`. Use a typed cleanup policy: + +```ts +type DeterministicPreSpawnCleanupPolicy = + | { mode: 'launch'; restorePrelaunchConfig: true; cleanupCreatedTeamArtifacts?: false } + | { mode: 'create'; restorePrelaunchConfig?: false; cleanupCreatedTeamArtifacts: boolean } +``` + +### Early Provider Args Patch Before Default Model Resolution + +`materializeEffectiveTeamMemberSpecs()` can call `resolveProviderDefaultModel()` for non-Anthropic members that do not specify a model. That command receives `envResolution.providerArgs` before the full `WorkspaceTrustCoordinator.planFull()` placement. + +This matters for Codex because provider args already carry runtime auth intent, and the new trust settings should be consistently present for all provider fact/model probes. + +Use two planning phases: + +1. **Early settings-only phase** + - after `buildProvisioningEnv` + - before `materializeEffectiveTeamMemberSpecs` + - workspaces: `request.cwd` and `realpath(request.cwd)` + - providers: request lead provider plus explicit member provider ids + - output: Codex provider settings patches only + - no PTY, no locks, no config writes + +2. **Full workspace phase** + - after `resolveOpenCodeMemberWorkspacesForRuntime` + - workspaces: request cwd plus resolved member/worktree cwd values + - output: final Codex settings patches plus Claude execution plan + - dedupe early patches so the final settings contain one app-owned Codex trust override set + +Integration rule: + +- pass the early provider settings patches into `materializeEffectiveTeamMemberSpecs()` through an optional parameter, or refactor default model resolution to call a provider-arg resolver callback +- do not let `materializeEffectiveTeamMemberSpecs()` import workspace-trust feature internals +- keep the dependency direction as `TeamProvisioningService -> WorkspaceTrustCoordinator port` + +Rating: 🎯 8 🛡️ 9 🧠 7, ~80-140 LOC plus tests. + +### Progress State Options + +The preflight needs to be cancellable. Options: + +1. Set progress to existing `spawning` before PTY preflight - 🎯 9 🛡️ 8 🧠 3, ~15-30 LOC. Chosen for v1 because it uses existing cancel states and avoids schema changes. +2. Add `validating` to cancellable states - 🎯 7 🛡️ 7 🧠 3, ~10-25 LOC. Could change semantics for other validation waits and existing tests. +3. Add a new state like `preparing_workspace_trust` - 🎯 6 🛡️ 6 🧠 6, ~60-120 LOC. Cleaner UI but requires shared/renderer state updates. + +V1 uses option 1 with message `Preparing workspace trust`. + +### Artifact Flags Need Their Own Size Cap + +`writeTeamLaunchFailureArtifactPack()` redacts JSON recursively, but `flags` is otherwise flexible. Workspace trust diagnostics must self-limit before being assigned to `run.workspaceTrustDiagnostics`. + +Rules: + +- max strategy results: 20 +- max workspaces per strategy result: 20 +- max evidence strings per result: 5 +- max evidence string length: 600 chars +- max raw tail: 8 KiB and only under debug/failure +- no env values +- no full provider config file content + +## Eighth-Pass Integration Hardening Findings + +This pass re-checked the exact current create/launch code paths instead of only the target architecture. + +### Default Model Resolution Must Use A Provider Args Resolver + +`materializeEffectiveTeamMemberSpecs()` calls `resolveProviderDefaultModel()` for non-Anthropic members without explicit models. It obtains secondary provider env through its local `getProvisioningEnv()` closure, which means a simple patch to the primary `provisioningEnv.providerArgs` is not enough. + +Recommended signature change: + +```ts +private async materializeEffectiveTeamMemberSpecs(params: { + ... + providerArgsResolver?: (input: { + providerId: TeamProviderId + providerArgs: string[] + phase: 'default-model-resolution' + }) => string[] +}): Promise +``` + +Rules: + +- default implementation returns the input args unchanged +- workspace-trust integration passes a resolver created from `planArgsOnly()` +- the resolver is pure and does not import workspace-trust internals into materialization +- only provider args are patched; env objects and auth helper material are not mutated + +Why this matters: without this resolver, a Codex secondary member can fail during model probing before the full workspace plan has a chance to patch cross-provider launch args. + +Rating: 🎯 8 🛡️ 9 🧠 6, ~45-90 LOC plus tests. + +### Cross-Provider Arg Patching Must Happen After Env Resolution + +`buildCrossProviderMemberArgs()` builds secondary provider env internally and returns: + +- flattened inherited args +- `providerArgsByProvider` +- `envPatch` +- `usesAnthropicApiKeyHelper` + +Do not push workspace-trust logic into this method. Instead: + +1. Let it return current provider args unchanged. +2. Run `planFull()` using the resolved member workspaces and returned cross-provider provider args. +3. Apply final patches to both `crossProviderMemberArgs.args` and `crossProviderMemberArgs.providerArgsByProvider`. +4. Rebuild `providerArgsByProviderForLaunch` from patched primary and patched cross-provider args. + +This keeps `buildCrossProviderMemberArgs()` responsible for provider auth/env only, while workspace trust owns launch arg policy. + +Rating: 🎯 9 🛡️ 9 🧠 5, ~55-100 LOC plus tests. + +### Pre-Run Disk Artifacts Must Not Increase + +Current `buildProvisioningEnv()` can materialize Anthropic API-key helper files before `ProvisioningRun` exists. That is an existing lifecycle risk. The workspace-trust feature should not add any new pre-run disk artifacts. + +V1 rules: + +- `planArgsOnly()` writes nothing +- `planFull()` writes nothing +- temp empty MCP config is created only inside Claude strategy `execute()`, after `run` exists +- if touching pre-run env failure paths, add best-effort helper cleanup in a separate, focused hardening commit with tests + +Do not mix a broad helper-material lifecycle refactor into the first workspace-trust PR unless tests prove the current leak can be fixed narrowly. + +Rating: 🎯 8 🛡️ 8 🧠 6, ~0 LOC for v1 scope control, ~40-80 LOC only if separately hardening. + +### Launch Config Mutation Window Is Intentional + +Launch mode normalizes `config.json` and updates `projectPath` before `ProvisioningRun` is created. Workspace trust execution will therefore happen after those launch mutations. + +Required behavior: + +- blocked launch preflight restores prelaunch config +- cancelled launch preflight should follow existing stop cleanup without double restore +- create mode must never call launch config restore +- tests should assert restore calls by mode, not just final progress state + +This is why the cleanup policy must be typed by mode instead of a loose boolean. + +Rating: 🎯 8 🛡️ 9 🧠 5, ~35-70 LOC plus tests. + +### Naming Should Avoid Launch-Only Semantics + +Names like `failLaunchBeforeSpawn` are easy to misuse from create mode. Use deterministic-run terminology for shared helpers: + +- `prepareWorkspaceTrustForDeterministicRun` +- `failDeterministicRunBeforeSpawn` +- `DeterministicPreSpawnCleanupPolicy` +- `WorkspaceTrustLaunchArgContext` can remain launch-arg-specific because it describes CLI args, not the workflow mode + +Rating: 🎯 9 🛡️ 8 🧠 2, ~10-25 LOC. + +## Ninth-Pass Runtime Contract Findings + +This pass checked the sibling orchestrator implementation, not just the desktop app. + +### Runtime Trust Key Is Not Always The Exact Cwd + +The sibling runtime uses `normalizePathForConfigKey(path)` as: + +- Node `path.normalize(...)` +- then backslash-to-forward-slash conversion + +It also computes the primary project config key as: + +- canonical git root when the cwd is inside a git repo +- otherwise the resolved original cwd + +`isPathTrusted(dir)` then walks from `resolve(dir)` to parents and returns true if any ancestor key has `hasTrustDialogAccepted`. + +Host probe rule: + +- check exact `request.cwd` +- check `realpath(request.cwd)` +- check canonical git root when cheap and available +- check parent directories up to filesystem root +- normalize keys exactly like runtime: path normalize, then backslash-to-forward-slash +- never lowercase POSIX paths +- on Windows, compare case-insensitively for dedupe/locks but preserve the serialized key casing + +Do not guess a parent key to write. Let Claude decide where to persist trust. The host only probes all keys the runtime might later accept. + +Tests to add: + +- selected subdirectory inside a trusted git root skips PTY +- trust accepted from a git subdirectory is observed through the git-root key +- symlinked repo path is checked through original path, realpath, and parent walk +- Windows drive letter case dedupes lock keys but preserves config key text + +Rating: 🎯 8 🛡️ 9 🧠 6, ~50-100 LOC plus tests. + +### Home Directory Trust Is Not Persisted Reliably + +The sibling runtime comments state that when running from the home directory, trust can be session-only. Headless teammate startup uses `isPathTrusted(dir)`, which checks persisted config and does not consult the interactive session latch. + +V1 behavior: + +- detect `realpath(cwd) === realpath(home)` before Claude PTY +- do not auto-trust home, root, or broad parent directories +- return a blocked diagnostic like `workspace_trust_not_persistable_home` +- keep the provider's exact runtime error if launch still proceeds through a soft-failure path + +Top 3 options: + +1. Block home-dir preflight with clear diagnostic - 🎯 9 🛡️ 10 🧠 3, ~20-40 LOC. Chosen for v1 because it avoids broad trust. +2. Continue launch and rely on runtime classification - 🎯 6 🛡️ 8 🧠 2, ~5-15 LOC. Less invasive, but user sees the same failure after waiting. +3. Write persisted trust for home directly - 🎯 3 🛡️ 2 🧠 5, ~60-120 LOC. Too broad and security-sensitive. + +### Provider Config Writes Need Provider Locks + +Claude's own `saveCurrentProjectConfig()` uses a file lock and auth-loss guard before writing `~/.claude.json`. This reinforces the v1 decision not to direct-write Claude trust files. + +If a future emergency fallback writes provider config directly, it must: + +- take the provider-compatible lock +- re-read before merge +- preserve auth and onboarding state +- create backups according to provider conventions +- use the same config-key normalization +- be behind a separate feature flag + +Do not implement that fallback in the first PR. + +Rating: 🎯 7 🛡️ 6 🧠 8, 140-260 LOC if ever implemented. + +### Competitor Lesson: Dialog Beats Ready + +Gastown polls tmux panes and intentionally checks trust dialog text before prompt detection because Codex trust screens can include a leading prompt-looking marker. Overstory tests the same class of precedence: trust/bypass dialogs must win over ready indicators. + +Adopt: + +- dialog phase has priority over prompt/ready phase +- polling handles render races +- tests replay multiple snapshots where prompt-looking text and dialog text coexist + +Do not adopt: + +- blind startup dismiss by default +- tmux as the transport +- "prompt suffix means ready" as a trust-preflight success condition + +Rating: 🎯 9 🛡️ 9 🧠 4, ~40-80 LOC in dialog-engine tests. + +### ProvisioningRun Needs Explicit Trust Fields + +`ProvisioningRun` currently has no workspace trust fields, and `TeamProvisioningProgress` should not grow a structured trust payload in v1. + +Add only main-process run fields: + +```ts +launchStateClearedForRun: boolean +workspaceTrustPlan?: WorkspaceTrustFullPlanResult | null +workspaceTrustExecution?: WorkspaceTrustExecutionResult | null +workspaceTrustDiagnostics?: WorkspaceTrustDiagnosticsManifest | null +workspaceTrustRetryAttempted?: boolean +``` + +Rules: + +- these fields are not sent over IPC as progress +- `launchStateClearedForRun` is a lifecycle guard, not user-facing diagnostics +- `workspaceTrustDiagnostics` is already budgeted/redacted before assignment +- `writeLaunchFailureArtifactPackBestEffort()` copies only `workspaceTrustDiagnostics` into `flags.workspaceTrustPreflight` +- retained progress does not need these fields + +This keeps the renderer schema stable and makes the artifact pack the structured diagnostics surface. + +Rating: 🎯 9 🛡️ 9 🧠 4, ~25-50 LOC plus artifact tests. + +## Tenth-Pass Runtime Lane And Provider Args Findings + +This pass re-checked OpenCode routing and provider CLI arg construction. It found two places where a correct preflight feature could still miss real launches. + +### Pure OpenCode Runtime Adapter Is Out Of V1 + +Pure OpenCode teams can be routed through the runtime adapter before the legacy deterministic create/launch flow. That path is a different runtime ownership model and should not call the workspace-trust coordinator in v1. + +V1 boundary: + +- legacy deterministic `createTeam` and `launchTeam`: use workspace trust preflight +- mixed OpenCode secondary lanes inside a deterministic launch: include their resolved workspace paths +- pure OpenCode runtime adapter launch: do not call workspace trust preflight +- future OpenCode-native trust gate: add a separate strategy and adapter-owned contract + +Tests to add: + +- pure OpenCode runtime-adapter launch with a fake coordinator asserts coordinator was not called +- mixed OpenCode side lane under a deterministic lead includes the generated side-lane cwd in `planFull()` workspace input +- deterministic create and deterministic launch both pass `allEffectiveMemberSpecs`, not only filtered primary members, into workspace collection + +Top 3 options: + +1. Keep v1 scoped to deterministic paths and include mixed side-lane workspaces - 🎯 9 🛡️ 9 🧠 5, ~50-90 LOC plus tests. Chosen because it matches current ownership boundaries. +2. Add preflight to pure OpenCode adapter now - 🎯 5 🛡️ 6 🧠 7, ~120-220 LOC. Too easy to mix runtime-adapter and desktop UX policy before a real OpenCode trust failure exists. +3. Ignore OpenCode workspaces completely - 🎯 4 🛡️ 5 🧠 2, ~0-10 LOC. Simple but can miss mixed side-lane generated worktrees. + +### Workspace Collection Must Use All Effective Members + +The deterministic flow creates `allEffectiveMemberSpecs`, plans runtime lanes, then filters `effectiveMemberSpecs` to primary-lane members. Workspaces must be collected before that filter loses side-lane information. + +Rule: + +- use `allEffectiveMemberSpecs` for workspace collection and artifact diagnostics +- use `effectiveMemberSpecs` for primary runtime bootstrap semantics exactly as today +- include side-lane OpenCode workspaces only when they flow through this deterministic path +- dedupe by comparison key, but retain `source` and `memberId` evidence for diagnostics + +This is a Clean Architecture boundary: workspace trust collection is launch preparation policy, not lane execution policy. It consumes the lane plan output but does not decide lane ownership. + +Rating: 🎯 9 🛡️ 9 🧠 5, ~35-70 LOC plus mixed-lane tests. + +### Provider CLI Args Accept Early Patches + +The current helper shape is: + +```ts +function buildProviderCliCommandArgs(providerArgs: string[], args: string[]): string[] { + return mergeJsonSettingsArgs([...providerArgs, ...args]) +} +``` + +That means app-managed provider settings can be applied before provider fact commands such as `model list` and `runtime status`. This is good: Codex trust intent belongs in provider settings, not in final launch-only raw argv. + +Rules: + +- patch provider settings before default-model resolution +- patch `providerArgsByProvider` before `resolveAndValidateLaunchIdentity` +- keep command args like `model list` and `runtime status` after provider args +- characterize that `mergeJsonSettingsArgs()` merges app-owned Codex workspace-trust settings with existing forced-login settings +- do not move trust overrides into user `extraCliArgs` + +Rating: 🎯 9 🛡️ 9 🧠 4, ~30-60 LOC plus characterization tests. + +### Provider Presence Decides Codex Patches, Not Workspace Presence + +`planFull()` should always receive the deterministic workspace set because Claude/orchestrator trust applies to every teammate workspace. Codex trust settings, however, should be produced only when Codex is actually present in one of the provider surfaces. + +Provider detection inputs: + +- resolved lead provider id +- requested member provider ids before materialization +- materialized member provider ids after defaults +- cross-provider args map from `buildCrossProviderMemberArgs()` +- provider facts/default-model probe surface for Codex + +Rules: + +- Anthropic-only team: no Codex trust settings +- Codex lead: patch primary provider settings and provider facts settings +- Anthropic lead with Codex teammate: patch cross-provider Codex settings and default-model probe settings +- OpenCode-only pure adapter: no v1 patch because this path is outside deterministic launch +- unknown/future provider ids: ignore for Codex strategy and keep diagnostics non-throwing + +Rating: 🎯 9 🛡️ 9 🧠 5, ~35-75 LOC plus provider-matrix tests. + +### Codex Native Overrides Should Be Repeatable Dotted Values + +The earlier plan used one `-c projects={...}` blob. That is riskier than necessary because it replaces the whole `projects` value at the config override layer and creates awkward merging with user-provided project config. + +Use one override value per trusted path: + +```text +projects."".trust_level="trusted" +projects."".trust_level="trusted" +``` + +Builder contract: + +- output `CodexConfigOverride[]`, not one TOML table string +- each item is a value that the sibling Codex native adapter can pass as a single `-c` flag only when spawning Codex +- quoted key segment uses TOML basic-string escaping +- preserve slash/backslash text after runtime-compatible normalization +- dedupe exact config keys after canonicalization +- append app-owned overrides after existing Codex native config overrides inside the sibling adapter where the CLI uses later-wins semantics +- do not parse and rewrite arbitrary user Codex config overrides unless a future test proves CLI precedence changed + +Example output: + +```ts +[ + 'projects."/tmp/project".trust_level="trusted"', + 'projects."/private/tmp/project".trust_level="trusted"', +] +``` + +Why this is safer: + +- it preserves unrelated `projects` entries +- it aligns with Codex CLI `-c ` syntax at the Codex binary boundary +- it matches the sibling runtime's native `configOverrides: string[]` contract +- it makes order and rollback easier to test + +Tests to add: + +- quoted path segment with spaces, brackets, quotes, and backslashes +- two repeated override values are preserved by the sibling Codex native adapter +- forced-login `--settings` JSON remains separate from native Codex trust override values +- app overrides are appended after existing sibling Codex native config overrides +- no single `projects={...}` blob is produced by v1 code +- no Codex native override is emitted as `-c` in Claude launch argv + +Top 3 Codex override encodings: + +1. Repeatable dotted override values passed through typed runtime contract to sibling `configOverrides` - 🎯 9 🛡️ 9 🧠 6, ~90-180 LOC across desktop and sibling runtime. Chosen because it avoids Claude `-c` ambiguity and uses the existing Codex native boundary. +2. Direct Codex CLI `-c projects."".trust_level="trusted"` from desktop - 🎯 6 🛡️ 6 🧠 4, ~45-90 LOC. Valid only for direct Codex binary surfaces, not deterministic Claude/orchestrator launch. +3. Single `-c projects={...}` table blob - 🎯 6 🛡️ 5 🧠 5, ~50-100 LOC. Works in prototypes, but has more merge/clobber risk. + +## Eleventh-Pass Cleanup And Restart Findings + +This pass checked what happens if preflight blocks after `ProvisioningRun` exists but before the current launch flow calls `clearPersistedLaunchState()`. + +### `cleanupRun()` Needs A Launch-State Guard + +Current `cleanupRun()` treats any failed `run.isLaunch && !run.provisioningComplete && !run.cancelRequested` run as a launch that already entered runtime bootstrap cleanup. It finalizes unconfirmed members and persists a failed launch snapshot. + +That is correct after runtime launch has begun. It is wrong for a workspace-trust preflight that blocks before `clearPersistedLaunchState()`: the previous launch snapshot should remain untouched because no new runtime launch actually started. + +Add a narrow run flag: + +```ts +interface ProvisioningRun { + launchStateClearedForRun: boolean +} +``` + +Rules: + +- initialize as `false` +- set to `true` immediately after `clearPersistedLaunchState()` succeeds +- gate launch cleanup finalization on `run.launchStateClearedForRun === true` +- failure artifacts should still be written for failed preflight runs +- retained progress should still be stored +- previous launch snapshot must not be overwritten when preflight blocks before clear + +Target cleanup condition: + +```ts +const shouldFinalizeFailedLaunchSnapshot = + !hasNewerTrackedRun && + run.isLaunch && + run.launchStateClearedForRun && + !run.provisioningComplete && + !run.cancelRequested +``` + +This is a small lifecycle hardening that makes the planned preflight placement safe. Without it, the plan's "run before clearing persisted launch state" guarantee is false. + +Top 3 options: + +1. Add `launchStateClearedForRun` and gate only launch-state finalization - 🎯 9 🛡️ 10 🧠 3, ~20-40 LOC plus tests. Chosen because it is precise and preserves current post-clear behavior. +2. Avoid `cleanupRun()` for preflight-blocked launches - 🎯 6 🛡️ 6 🧠 5, ~40-80 LOC. Risky because it duplicates run-map/progress/artifact cleanup. +3. Move preflight after `clearPersistedLaunchState()` - 🎯 7 🛡️ 5 🧠 2, ~5-15 LOC. Simpler but loses the main safety property and can erase the previous launch snapshot on trust setup failures. + +### Preflight Failure Artifact Ordering + +`writeLaunchFailureArtifactPackBestEffort()` is idempotent per run. If workspace-trust diagnostics are assigned after the first artifact write, Copy diagnostics will miss the most useful facts. + +Ordering rule for `failDeterministicRunBeforeSpawn()`: + +1. assign `run.workspaceTrustDiagnostics` +2. update progress to `failed` +3. call `run.onProgress(...)` +4. cleanup helper material and mode-specific disk artifacts +5. call `cleanupRun(run)` + +Do not call `writeLaunchFailureArtifactPackBestEffort()` directly from the preflight helper unless the helper also owns the idempotence key and can prove `cleanupRun()` will not write first. The cleaner v1 path is to let `cleanupRun()` write exactly once after diagnostics are already on the run. + +Tests to add: + +- blocked launch preflight before `clearPersistedLaunchState()` writes artifact with `workspaceTrustPreflight` +- blocked launch preflight before clear does not call `persistLaunchStateSnapshot` +- blocked launch preflight after a simulated clear uses existing failed-launch cleanup behavior +- failed artifact is written once even if the preflight helper and cleanup path both observe failure + +Rating: 🎯 9 🛡️ 9 🧠 4, ~35-70 LOC plus tests. + +### Direct Teammate Restart Is A Separate Workflow + +The code also has direct teammate restart paths: + +- `launchDirectTmuxMemberRestart(...)` +- `launchDirectProcessMemberRestart(...)` + +Those paths can spawn provider runtimes with their own cwd after a team is already alive. They are not part of issue #100 first-launch/relaunch, and pulling them into the first PR would widen the blast radius. + +V1 rule: + +- do not wire workspace trust preflight into direct restart paths +- keep direct restart failures classified through existing runtime diagnostics +- make `WorkspaceTrustCoordinator` reusable so a later `prepareWorkspaceTrustForDirectRestart(...)` can call the same port +- document direct restart as a phase 2 extension, not a hidden TODO inside launch integration + +Future direct restart helper should be tiny: + +```ts +prepareWorkspaceTrustForDirectRestart({ + teamName, + memberName, + providerId, + cwd, + shellEnv, + claudePath, +}) +``` + +Top 3 direct-restart options: + +1. Exclude from v1 but keep coordinator reusable - 🎯 9 🛡️ 9 🧠 3, ~0-15 LOC now. Chosen because launch reliability is the current bug. +2. Add direct process restart preflight only - 🎯 6 🛡️ 7 🧠 6, ~80-140 LOC. Covers one restart path but creates tmux/process asymmetry. +3. Add all restart preflight now - 🎯 5 🛡️ 6 🧠 8, ~150-260 LOC. Too much workflow risk for the first PR. + +### Post-Trust Provider Screens Must Not Become New Automation + +The sibling Claude startup flow shows trust before several other screens and side-effectful setup steps: MCP server approvals, external CLAUDE.md include warnings, telemetry/env initialization, Grove policy, and custom API key prompts. + +V1 rule: + +- after pressing workspace-trust `Enter`, probe persisted trust immediately +- if trust is persisted, kill PTY and return success +- do not automate MCP approvals, external include dialogs, Grove policy, or provider onboarding +- bypass/custom API key rules exist only for observed screens that can appear before trust persistence is readable or in compatibility smoke, not as a reason to keep the PTY alive longer + +This mirrors the security intent in the sibling runtime: accept trust for the exact selected folder, then minimize additional provider startup side effects. + +Rating: 🎯 9 🛡️ 10 🧠 4, ~20-45 LOC in strategy/engine tests. + +### Pending-Key And No-Run Phase Invariant + +Both create and launch set a pending provisioning key before the run object exists. Early `planArgsOnly()` happens in that no-run window. + +Invariant: + +- no-run phase may compute pure arg patches and diagnostics +- no-run phase must not spawn PTY +- no-run phase must not write temp files +- no-run phase must not create provider config sentinels +- any no-run exception must be caught by the existing pending-key cleanup path + +This keeps the TOCTOU guard intact without introducing a second run lifecycle before `ProvisioningRun` exists. + +Rating: 🎯 9 🛡️ 9 🧠 3, ~10-25 LOC plus tests. + +## Twelfth-Pass Feature Boundary, Arg Dialect, And PTY Adapter Findings + +This pass focused on the remaining places where a clean plan could still become risky during implementation: feature imports, `node-pty` ownership, Codex/Claude argument dialects, duplicate fallback patches, and diagnostic redaction. + +### Feature Boundary Must Be Public And Narrow + +The workspace trust feature should be a feature module, not a hidden subfolder of `TeamProvisioningService`. + +Allowed imports from team launch code: + +```ts +import { + createWorkspaceTrustCoordinator, + type WorkspaceTrustCoordinator, + type WorkspaceTrustFullPlanResult, +} from '@features/workspace-trust/main' +``` + +Forbidden imports from team launch code: + +```ts +// forbidden +import { NodePtyProcessAdapter } from '@features/workspace-trust/main/adapters/output/NodePtyProcessAdapter' +import { StartupDialogRules } from '@features/workspace-trust/main/infrastructure/StartupDialogRules' +import { CodexConfigOverrideBuilder } from '@features/workspace-trust/core/domain/CodexConfigOverride' +``` + +Reason: + +- `TeamProvisioningService` should orchestrate launch lifecycle only. +- prompt matching should change because provider startup changes, not because team launch flow changes. +- Codex settings/runtime-contract syntax should change because Codex runtime syntax changes, not because launch cleanup changes. +- `node-pty` optional native loading should stay behind a port and never leak into tests that only launch fake teams. + +Feature import boundary: + +```mermaid +flowchart TD + TPS["TeamProvisioningService"] --> PUB["@features/workspace-trust/main public facade"] + PUB --> APP["core/application use cases"] + APP --> DOM["core/domain pure policy"] + APP --> PORTS["core/application ports"] + PUB --> COMP["main/composition"] + COMP --> ADAPT["main/adapters"] + ADAPT --> NPTY["optional node-pty"] + ADAPT --> FS["filesystem/provider state"] + + TPS -. forbidden .-> ADAPT + TPS -. forbidden .-> DOM +``` + +Top 3 boundary options: + +1. Public feature facade only - 🎯 9 🛡️ 10 🧠 5, ~45-90 LOC of feature shell and exports. Chosen because it keeps launch lifecycle separate from provider automation. +2. Local `team/workspaceTrust` folder with internal exports - 🎯 7 🛡️ 7 🧠 4, ~25-60 LOC. Faster, but makes future provider growth easier to couple into launch service. +3. Direct imports from adapters/domain inside `TeamProvisioningService` - 🎯 4 🛡️ 4 🧠 2, ~10-25 LOC. Too easy to violate SRP and hard to test without native dependencies. + +### Do Not Reuse `PtyTerminalService` + +`PtyTerminalService` already handles app terminals and optional `node-pty`, but it is the wrong abstraction for trust preflight. + +Why not reuse it: + +- it is renderer-terminal-facing and tied to `BrowserWindow`/IPC behavior. +- it owns interactive terminal sessions, not short provider startup probes. +- it may keep session state that is useful for UI terminals but undesirable for headless preflight. +- workspace trust preflight needs bounded raw-tail capture, allowlisted key actions, and immediate cleanup. + +Use a new adapter: + +```ts +export class NodePtyProcessAdapter implements PtyProcessPort { + async spawn(input: PtySpawnInput): Promise { + const pty = loadOptionalNodePty() + if (!pty.ok) { + return skippedPtySession('node_pty_unavailable', pty.error) + } + return spawnBoundedProviderProbe(pty.module, input) + } +} +``` + +Rules: + +- adapter may use the same optional `require('node-pty')` pattern as terminal service. +- adapter must not import `BrowserWindow`. +- adapter must not register app terminal sessions. +- missing native addon returns `pty_unavailable` diagnostic, not a thrown launch crash. +- test core with fake `PtyProcessPort`; test adapter with one small optional-load unit. + +Top 3 PTY ownership options: + +1. Dedicated `NodePtyProcessAdapter` behind `PtyProcessPort` - 🎯 9 🛡️ 9 🧠 5, ~80-150 LOC. Chosen because it isolates native/process lifecycle. +2. Wrap `PtyTerminalService` from workspace trust feature - 🎯 6 🛡️ 6 🧠 4, ~40-80 LOC. Reuses code but leaks renderer terminal semantics into launch preflight. +3. Spawn normal `child_process` and hope prompts print enough - 🎯 4 🛡️ 4 🧠 3, ~30-60 LOC. Not reliable for TUIs and misses the core issue. + +### Launch Arg Patches Need Dialect, Owner, And Dedupe + +The earlier type was too raw: + +```ts +type WorkspaceTrustLaunchArgPatch = { + provider: WorkspaceTrustProvider + args: string[] + appliesTo: 'primary' | 'cross_provider' | 'provider_facts' | 'default_model_probe' + reason: string +} +``` + +That is not enough to prevent mistakes. A patch must say which CLI dialect consumes it and where it is safe to apply. + +Safer contract: + +```ts +export type WorkspaceTrustLaunchArgPatch = { + id: string + owner: 'workspace-trust' + targetProvider: WorkspaceTrustProvider + targetSurface: + | 'primary_provider_args' + | 'cross_provider_member_args' + | 'provider_facts_probe' + | 'default_model_probe' + dialect: + | 'codex-native-config-override' + | 'claude-codex-runtime-settings' + | 'codex-direct-cli-config' + args: string[] + dedupeKey: string + sourceWorkspaceIds: string[] + reason: string +} +``` + +Application rules: + +- `claude-codex-runtime-settings` is the v1 desktop-to-sibling contract for deterministic Agent Teams launch. +- `codex-native-config-override` may only be applied inside the sibling Codex native adapter before spawning the Codex binary. +- `codex-direct-cli-config` is allowed only for future direct Codex CLI probes, not for Claude/orchestrator launch. +- no Codex dialect may append `-c` to pure Anthropic Claude CLI args. +- unknown target surface returns a skipped diagnostic, not a best-effort append. +- patch applier is pure: input args in, derived args out, no mutation. +- exact app-owned override sequence is not appended twice. +- never remove arbitrary user-provided Codex config overrides. + +This is the narrowest way to support fallback/future phases without mixing provider syntax into launch orchestration. + +Top 3 arg patch models: + +1. Typed patch with dialect, owner, target surface, and dedupe key - 🎯 9 🛡️ 10 🧠 5, ~90-160 LOC plus tests. Chosen because it prevents wrong-provider args and duplicate fallback patches. +2. Raw `args: string[]` append helper - 🎯 6 🛡️ 5 🧠 2, ~25-50 LOC. Simple but fragile during retries and cross-provider launch. +3. Full parser/merger for all provider args - 🎯 6 🛡️ 7 🧠 9, ~250-450 LOC. Too broad for v1 and likely to create parser bugs. + +### Codex Arg Dialect Boundary + +There are at least two Codex-related launch surfaces in this repo family: + +- direct Codex binary style, where `-c key=value` is native to Codex. +- Claude/orchestrator multimodel style, where Codex preferences are passed through Claude `--settings` JSON and later consumed by runtime code. +- sibling Codex native executor style, where `configOverrides: string[]` is converted to direct Codex `-c` only at the Codex binary boundary. + +Do not assume every place that mentions Codex can consume the same argument shape. + +V1 rule: + +- desktop deterministic launch emits Codex trust as `--settings` JSON, not direct `-c`. +- sibling runtime validates the settings payload and appends matching values to Codex native `configOverrides`. +- do not append Codex native `-c` to primary Anthropic provider args or any Claude CLI argv. +- characterize `buildInheritedCliFlags` in the sibling runtime to prove app-owned settings survive Anthropic lead -> Codex teammate. +- characterize desktop `buildProviderCliCommandArgs(...)` so provider facts/default model probes keep existing order and merge app trust settings. +- if a Codex surface cannot be proven, emit `codex_trust_settings_surface_unknown` diagnostic and leave current behavior intact. + +Required tests: + +- Anthropic-only team receives no Codex trust settings. +- Codex lead receives app-owned Codex workspace-trust settings. +- Anthropic lead with Codex teammate receives app-owned settings that `buildInheritedCliFlags` preserves for the Codex teammate. +- default model probe for Codex member receives merged app-owned settings. +- provider facts probe for Codex receives merged app-owned settings. +- pure Claude primary provider args do not receive Codex native `-c`. +- repeated application of the same patch does not duplicate override values. +- existing sibling Codex native `configOverrides` remain in place. + +### Borrow From GasCity, But Do Not Copy Its Coupling + +What to borrow: + +- PTY automation is valid for real TUI startup screens. +- update dialogs and trust dialogs can appear in sequence. +- stale terminal text must not cause repeated key presses. +- tests should be snapshot-driven and replayable. + +What to improve: + +- use provider state probe as the success condition, not only screen text. +- kill provider PTY as soon as trust state persists. +- use a protected Claude command so project MCP/hooks/tools are not loaded. +- keep PTY automation in desktop host, not in the headless runtime executor. +- keep fallback as typed diagnostics, not blind extra key presses. + +Rating: 🎯 9 🛡️ 9 🧠 5, mostly tests and architecture boundaries. + +### Diagnostic Redaction Should Reuse Existing Launch Artifact Policy + +Workspace trust should not invent a second redaction system. + +Rules: + +- structured diagnostics by default. +- raw PTY tail only when debug flag is on and status is failed/blocked. +- raw tail must pass through the same redaction helper used for launch artifacts or a shared redactor extracted from it. +- diagnostic budget is applied before assigning to `run.workspaceTrustDiagnostics`. +- artifact manifest stores effective feature flags, omitted counts, statuses, and evidence tokens, not full config files or env. + +Top 3 redaction options: + +1. Extract/reuse launch artifact redaction helper - 🎯 9 🛡️ 9 🧠 4, ~40-90 LOC. Chosen because diagnostics stay consistent. +2. New workspace-trust-only redactor - 🎯 7 🛡️ 6 🧠 4, ~40-80 LOC. Easier locally, but policy drift is likely. +3. Do not include any PTY tail ever - 🎯 7 🛡️ 10 🧠 1, ~5-10 LOC. Safest but weak for field debugging. + +## Thirteenth-Pass Sibling Runtime Contract And Launch Lifecycle Findings + +This pass rechecked the sibling runtime and the current `TeamProvisioningService` integration points. It changes the Codex part of the plan: direct Codex `-c` is valid, but not on Claude/orchestrator argv. + +### Claude `-c` Collision Is A Hard Boundary + +Sibling runtime facts: + +- `src/main.tsx` defines Claude `-c` as `--continue`. +- sibling Codex native `execRunner` accepts `configOverrides: string[]` and converts each value into direct Codex `-c`. +- `buildInheritedCliFlags()` propagates `--settings` inline JSON, but it does not propagate arbitrary Codex `-c` pairs. +- cross-provider inherited flags strip model/effort only; settings survive provider boundary. + +Therefore: + +- desktop must not append `-c projects...` to Claude launch args. +- desktop should send app-owned Codex workspace trust as inline settings JSON. +- sibling runtime should validate that settings payload and append values to Codex native `configOverrides`. +- direct Codex `-c` remains valid only for direct Codex PTY/probe/future direct binary surfaces. + +Suggested settings shape: + +```json +{ + "codex": { + "agent_teams_workspace_trust": { + "config_overrides": [ + "projects.\"/path\".trust_level=\"trusted\"", + "projects.\"/private/path\".trust_level=\"trusted\"" + ] + } + } +} +``` + +Sibling validation rules: + +- accept only an object at `codex.agent_teams_workspace_trust`. +- accept only a bounded string array at `config_overrides`. +- accept only override values matching `projects."".trust_level="trusted"`. +- reject values containing NUL, newline, unrelated keys, or non-string entries. +- bound count and total bytes. +- append after existing `buildCodexNativeMcpConfigOverrides()` values. +- log structured diagnostic if settings are malformed, then skip trust overrides. + +Top 3 Codex contract options: + +1. Inline settings contract -> sibling `configOverrides` - 🎯 9 🛡️ 9 🧠 6, ~120-220 LOC across both repos. Chosen because it survives teammate inheritance and avoids Claude `-c`. +2. New env var with JSON override values - 🎯 7 🛡️ 7 🧠 5, ~90-160 LOC. Simpler to parse, but tmux/env forwarding and security naming need more care. +3. Desktop direct `-c` append - 🎯 4 🛡️ 3 🧠 3, ~40-80 LOC. Invalid for Claude launch because `-c` means continue. + +### Launch Lifecycle Has Four Trust-Safe Windows + +Current create path: + +1. pending key +2. cwd + Claude binary +3. provisioning env +4. materialization/default model probes +5. worktree resolution +6. runtime lane and cross-provider args +7. launch identity validation +8. create `ProvisioningRun` +9. clear persisted launch state +10. write team meta, members, bootstrap spec, prompt, MCP config +11. validate MCP runtime +12. spawn + +Current launch path: + +1. pending key +2. read config +3. compatibility repair +4. normalize config and backup/restore boundary +5. update project path +6. cwd + Claude binary +7. provisioning env +8. materialization/default model probes +9. worktree resolution +10. runtime lane and cross-provider args +11. launch identity validation +12. create `ProvisioningRun` +13. clear persisted launch state +14. publish mixed secondary lane status +15. write bootstrap/MCP files +16. write meta/members +17. spawn + +Trust-safe windows: + +- `planEarly`: after provisioning env, before materialization. Pure settings patches only. +- `planFull`: after `allEffectiveMemberSpecs` and cross-provider args, before launch identity validation. Pure settings patches only. +- `executePreflight`: after `ProvisioningRun`, before `clearPersistedLaunchState`. Claude PTY only, cancellable. +- `spawn`: after clear/write/validate. No new trust side effects. + +Do not move Claude PTY before run creation: progress/cancel/artifacts would be weak. Do not move it after `clearPersistedLaunchState`: a blocked trust setup could wipe the previous launch snapshot. + +Top 3 placement options: + +1. Pure plans before validation, PTY execute after run and before clear - 🎯 9 🛡️ 10 🧠 7, ~160-260 LOC integration. Chosen because it respects both provider probes and launch snapshot safety. +2. PTY before `ProvisioningRun` - 🎯 6 🛡️ 5 🧠 4, ~80-150 LOC. Less lifecycle state, but poor progress/cancel/artifacts. +3. PTY after `clearPersistedLaunchState` - 🎯 7 🛡️ 5 🧠 3, ~60-120 LOC. Simpler, but can erase previous launch state before trust is ready. + +### Create And Launch Need Different Failure Policies + +Create failures before metadata writes should not delete unrelated existing files. Launch failures after config normalization must restore prelaunch config. + +Typed cleanup policy: + +```ts +type WorkspaceTrustPreSpawnFailurePolicy = + | { + mode: 'create' + teamName: string + createdTeamDirectories: false + cleanupAnthropicHelper: boolean + } + | { + mode: 'launch' + teamName: string + restorePrelaunchConfig: true + launchStateClearedForRun: boolean + } +``` + +Rules: + +- `create` preflight block before meta writes removes run tracking and helper material only. +- `create` preflight block must not `rm -rf` team/task directories unless this run created them. +- `launch` preflight block restores prelaunch config because normalization/projectPath update already happened. +- `launch` preflight block before clear must not persist a new failed launch snapshot. +- both modes assign workspace trust diagnostics before `cleanupRun()`. + +Rating: 🎯 9 🛡️ 10 🧠 6, ~70-130 LOC plus tests. + +### Sibling Runtime Changes Are Now Explicit V1 Scope + +Old assumption: desktop can solve Codex with launch args only. + +Updated assumption: desktop solves Claude/orchestrator trust alone, but Codex-native trust needs a small sibling runtime contract if we want zero user friction for Codex-native turns. + +V1 sibling scope: + +- add `getCodexWorkspaceTrustConfigOverrides(settingsInline?)`. +- validate and bound settings payload. +- append validated values to `runExec({ configOverrides })` in `turnExecutor`. +- add tests in sibling runtime for valid, malformed, over-limit, and inherited settings. +- keep runtime `isPathTrusted()` gate unchanged. + +Out of v1 sibling scope: + +- modifying Claude trust persistence. +- accepting trust in headless runtime. +- changing `isPathTrusted()`. +- global Codex config writes. + +Rating: 🎯 9 🛡️ 9 🧠 6, ~50-120 sibling LOC plus tests. + +## Non-Goals + +Do not do these in v1: + +- Do not add tmux as a requirement. +- Do not auto-edit Claude trust files as the primary path. +- Do not globally trust parent directories, home directories, or recent projects. +- Do not scan project files to decide trust. +- Do not mutate provider auth state. +- Do not accept unknown prompts. +- Do not change teammate permission mode. +- Do not change process cleanup or lifecycle. +- Do not treat Codex auth picker as a trust prompt. +- Do not hide the exact provider error if preflight fails. + +## Architecture + +The desktop app owns UX policy. The runtime owns process execution. + +```mermaid +flowchart TD + UI["Frontend project selection"] --> TPS["TeamProvisioningService"] + TPS --> WTC["WorkspaceTrustCoordinator"] + WTC --> PLAN["WorkspaceTrustPlan"] + PLAN --> CWS["ClaudePtyWorkspaceTrustStrategy"] + PLAN --> CXS["CodexLaunchArgsTrustStrategy"] + CWS --> PDE["PtyDialogEngine"] + PDE --> NPA["NodePtyProcessAdapter"] + CXS --> ARGS["Codex provider args patch"] + WTC --> DIAG["TrustPreflightDiagnostics"] + TPS --> RUNTIME["agent_teams_orchestrator launch"] + ARGS --> RUNTIME + RUNTIME --> BOOT["Bootstrap events"] + BOOT --> RETRY["Optional one trust retry if workspace_trust_required"] + RETRY --> WTC +``` + +### Detailed Runtime Placement + +```mermaid +flowchart TD + A["createTeam or launchTeam legacy deterministic path"] --> B["buildProvisioningEnv"] + B --> C["WorkspaceTrustCoordinator.planArgsOnly"] + C --> D["apply early Codex settings patches"] + D --> E["materializeEffectiveTeamMemberSpecs"] + E --> F["resolveOpenCodeMemberWorkspacesForRuntime"] + F --> LANE["plan runtime lanes, retain allEffectiveMemberSpecs"] + LANE --> G["buildCrossProviderMemberArgs for primary-lane members"] + G --> H["WorkspaceTrustCoordinator.planFull with allEffectiveMemberSpecs"] + H --> I["apply final provider settings patches"] + I --> J["resolveAndValidateLaunchIdentity"] + J --> K["create ProvisioningRun"] + K --> L["prepareWorkspaceTrustForDeterministicRun"] + L --> M["clearPersistedLaunchState"] + M --> N["build bootstrap spec and MCP config"] + N --> O["build final launch args"] + O --> P["spawnCli"] +``` + +The ordering is intentional: + +- early Codex settings patches must exist before default model resolution inside `materializeEffectiveTeamMemberSpecs`. +- final Codex settings patches must exist before `resolveAndValidateLaunchIdentity`. +- Claude PTY warmup should happen after `ProvisioningRun` exists so progress and diagnostics are retained. +- Claude PTY warmup should happen before `clearPersistedLaunchState` so a blocked preflight does not erase the previous launch snapshot. +- Bootstrap files and MCP config should be written after trust warmup, so preflight failures do not leave unnecessary temporary launch files. +- create and launch must use the same helper so first-run project creation and later relaunches do not drift. + +### Critical Sequence + +```mermaid +sequenceDiagram + participant TPS as TeamProvisioningService + participant WTC as WorkspaceTrustCoordinator + participant MAT as Member Materialization + participant VAL as Launch Identity Validation + participant RUN as ProvisioningRun + participant PTY as Claude PTY Strategy + participant RUNTIME as agent_teams_orchestrator + + TPS->>WTC: planArgsOnly(request cwd, providers, provider settings) + WTC-->>TPS: early Codex settings patches, diagnostics + TPS->>MAT: materialize members with providerArgsResolver + MAT-->>TPS: effective members and default models + TPS->>WTC: planFull(all deterministic workspaces, cross-provider settings) + WTC-->>TPS: final settings patches and Claude execution plan + TPS->>VAL: validate with patched providerArgsByProvider + VAL-->>TPS: launch identity + TPS->>RUN: create run and emit progress + TPS->>WTC: execute(plan, isCancelled, onProgress) + WTC->>PTY: protected interactive command + PTY-->>WTC: accepted, already trusted, blocked, soft_failed, or cancelled + WTC-->>TPS: bounded diagnostics + alt blocked + TPS->>RUN: failDeterministicRunBeforeSpawn(policy) + else cancelled or stale + TPS->>RUN: existing cancellation cleanup, no spawn + else ok or soft_failed + TPS->>RUNTIME: spawn with patched settings/args + RUNTIME-->>TPS: bootstrap events or workspace_trust_required fallback + end +``` + +### Dependency Direction + +```mermaid +flowchart LR + TPS["TeamProvisioningService"] --> WTC["WorkspaceTrustCoordinator port"] + WTC --> WTP["WorkspaceTrustPlanner"] + WTC --> CTS["ClaudeTrustStrategy port"] + WTC --> CXS["CodexTrustStrategy port"] + CTS --> PTE["PtyDialogEngine"] + CTS --> CSP["ClaudeStateProbe port"] + PTE --> PTP["PtyProcessPort"] + PTP --> NPA["NodePtyProcessAdapter"] + CSP --> FS["File system adapter"] + CXS --> CCB["CodexConfigOverrideBuilder"] +``` + +Allowed dependencies: + +- `TeamProvisioningService` depends only on coordinator types. +- coordinator depends on strategy ports and pure domain helpers. +- strategies depend on provider-specific ports. +- adapters depend on filesystem, `node-pty`, and provider config syntax. + +Forbidden dependencies: + +- `TeamProvisioningService` -> `node-pty` +- `TeamProvisioningService` -> prompt regexes +- `PtyDialogEngine` -> team launch state +- Codex strategy -> Claude state file +- Claude strategy -> Codex config builder + +### Three-Stage Coordinator + +```ts +export interface WorkspaceTrustCoordinator { + planArgsOnly(request: WorkspaceTrustArgsOnlyPlanRequest): Promise + planFull(request: WorkspaceTrustFullPlanRequest): Promise + execute(plan: WorkspaceTrustExecutionPlan): Promise +} +``` + +`planArgsOnly()`: + +- pure or near-pure +- runs after `buildProvisioningEnv` +- sees `request.cwd`, requested providers, provider args, shell env, and `claudePath` +- computes Codex settings patches needed by default model and provider fact probes +- does not inspect member worktrees because they are not resolved yet +- does not spawn PTY +- does not write config or temp files +- should not throw for provider trust setup problems + +`planFull()`: + +- pure or near-pure +- computes unique workspaces +- computes provider list +- computes final Codex settings patches for primary and cross-provider launch args +- computes Claude execution work +- does not spawn PTY +- does not write trust files +- dedupes early Codex patches so the final launch receives one app-owned override set +- should not throw for provider trust setup problems + +`execute()`: + +- runs side-effectful strategies +- currently Claude PTY warmup +- updates diagnostics +- uses bounded timeouts +- observes cancellation +- returns `ok`, `soft_failed`, `blocked`, or `cancelled` + +This split keeps launch identity validation correct without forcing a long PTY warmup before the UI has a visible run. + +### Strategy Ratings After Code Review + +| Area | Rating | Reason | +| --- | --- | --- | +| Claude PTY warmup | 🎯 9 / 🛡️ 8 / 🧠 6 | Prototype confirmed it writes `hasTrustDialogAccepted`; risk is prompt matching and cleanup. | +| Codex per-launch args | 🎯 8 / 🛡️ 8 / 🧠 5 | Best scoped intent, but recent Codex issue means we must verify/diagnose possible provider-side config mutation. | +| Three-stage coordinator | 🎯 8 / 🛡️ 9 / 🧠 8 | Best fit for current create/launch order; risk is integration complexity. | +| Direct `.claude.json` write fallback | 🎯 5 / 🛡️ 5 / 🧠 5 | Useful emergency lever but private format and file race risk. Keep out of v1. | +| Codex PTY fallback | 🎯 7 / 🛡️ 7 / 🧠 6 | GasCity-proven for TUI, but not needed for issue #100 path if settings -> native override contract works. Keep engine-ready, do not default. | + +### Responsibility Boundaries + +`TeamProvisioningService`: + +- Knows when a team launch is about to start. +- Knows requested members, providers, cwd, worktrees, and launch args. +- Does not know prompt text or PTY details. + +`WorkspaceTrustCoordinator`: + +- Builds the trust plan for all launch workspaces. +- Executes provider strategies. +- Produces diagnostics and launch argument patches. +- Owns idempotence and retry policy. + +`WorkspaceTrustStrategy`: + +- Provider-specific behavior behind one interface. +- Claude strategy handles PTY warmup. +- Codex strategy handles per-launch config override. +- Future Gemini/OpenCode strategies can be added without modifying the coordinator. + +`PtyDialogEngine`: + +- Generic terminal snapshot state machine. +- Owns prompt detection and key actions. +- Does not know team launch, providers, or config files. + +`NodePtyProcessAdapter`: + +- Thin adapter around `node-pty`. +- Handles process spawn, snapshot collection, key writes, timeout, and cleanup. + +`Runtime contract`: + +- Runtime receives prepared workspace and provider args. +- Runtime still enforces trust gates. +- Runtime reports typed trust failure if trust is still missing. + +## Clean Architecture Shape + +Suggested desktop app layout: + +```text +src/features/workspace-trust/ + index.ts + contracts/ + index.ts + core/ + domain/ + WorkspaceTrustTypes.ts + WorkspaceTrustPolicy.ts + WorkspaceTrustPath.ts + CodexConfigOverride.ts + WorkspaceTrustDiagnosticsBudget.ts + application/ + WorkspaceTrustCoordinator.ts + WorkspaceTrustPlanner.ts + WorkspaceTrustArgPatchApplier.ts + WorkspaceTrustDiagnostics.ts + WorkspaceTrustLocks.ts + ClaudePreflightCommand.ts + ports.ts + main/ + index.ts + composition/ + createWorkspaceTrustCoordinator.ts + adapters/ + output/ + ClaudeStateProbe.ts + NodePtyProcessAdapter.ts + FileWorkspaceTrustLockStore.ts + TempEmptyMcpConfigStore.ts + infrastructure/ + PtyDialogEngine.ts + StartupDialogRules.ts + WorkspaceTrustFeatureFlags.ts +``` + +Small pure helpers that are only needed by `TeamProvisioningService` can live near the service, but the feature logic should use the canonical `src/features/` shape from `docs/FEATURE_ARCHITECTURE_STANDARD.md`. + +Top 3 placement options: + +1. `src/features/workspace-trust` with core/application/main layers - 🎯 9 🛡️ 9 🧠 8, ~700-1050 LOC. Best long-term architecture and easiest provider growth. +2. `src/main/services/team/workspaceTrust` local module - 🎯 8 🛡️ 8 🧠 6, ~550-850 LOC. Less churn, but weaker boundary and easier to couple back into team service. +3. Inline helpers inside `TeamProvisioningService` - 🎯 4 🛡️ 4 🧠 3, ~180-300 LOC. Fast, but violates SRP and makes prompt/PTY bugs likely. + +Chosen: option 1. + +SOLID mapping: + +- Single Responsibility - prompt rules, PTY IO, provider strategy, and launch orchestration are separate. +- Open/Closed - new provider support adds a new strategy and rules, not changes in `TeamProvisioningService`. +- Liskov - every strategy returns the same result contract and can be skipped safely. +- Interface Segregation - Codex settings strategy does not depend on PTY; Claude PTY strategy does not depend on Codex config override syntax. +- Dependency Inversion - coordinator depends on ports, not `node-pty` directly. + +## Core Types + +```ts +export type WorkspaceTrustProvider = 'claude' | 'codex' | 'gemini' | 'opencode' + +export type WorkspaceTrustWorkspace = { + id: string + displayCwd: string + cwd: string + realCwd: string + configKeyCwd: string + gitRootConfigKey?: string + comparisonKey: string + source: 'team-root' | 'member-worktree' | 'member-cwd' | 'git-root' + memberId?: string + persistable: boolean + nonPersistableReason?: 'home_directory' | 'filesystem_root' | 'unavailable' +} + +export type WorkspaceTrustRequest = { + teamName: string + launchId: string + mode: 'create' | 'launch' + providers: WorkspaceTrustProvider[] + workspaces: WorkspaceTrustWorkspace[] + shellEnv: NodeJS.ProcessEnv + trustPreflightEnv: NodeJS.ProcessEnv + claudePath?: string + codexPath?: string + policy: WorkspaceTrustPolicy + featureFlags: WorkspaceTrustFeatureFlags + isCancelled: () => boolean + onProgress?: (event: WorkspaceTrustProgressEvent) => void +} + +export type WorkspaceTrustResult = { + ok: boolean + stage: 'args_only_plan' | 'full_plan' | 'execute' + provider: WorkspaceTrustProvider + workspaceIds: string[] + actions: WorkspaceTrustAction[] + launchArgPatches?: WorkspaceTrustLaunchArgPatch[] + diagnostics: WorkspaceTrustDiagnostic[] + error?: string +} + +export type WorkspaceTrustExecutionStatus = + | 'ok' + | 'soft_failed' + | 'blocked' + | 'cancelled' + +export type WorkspaceTrustLaunchArgPatch = { + id: string + owner: 'workspace-trust' + targetProvider: WorkspaceTrustProvider + targetSurface: + | 'primary_provider_args' + | 'cross_provider_member_args' + | 'provider_facts_probe' + | 'default_model_probe' + dialect: + | 'codex-native-config-override' + | 'claude-codex-runtime-settings' + | 'codex-direct-cli-config' + args: string[] + dedupeKey: string + sourceWorkspaceIds: string[] + reason: string +} + +export type WorkspaceTrustDiagnosticsManifest = { + attempt: number + featureFlags: WorkspaceTrustFeatureFlags + strategyResults: WorkspaceTrustDiagnosticStrategyResult[] + omittedCounts?: Record +} +``` + +The important design choice: strategies can either prepare state with side effects, or return typed launch argument patches. That keeps Codex native config overrides out of Claude argv, makes duplicate fallback patches testable, and keeps Claude PTY warmup isolated. + +## Ports And Adapters Contract + +Keep the core coordinator free from native dependencies and provider file formats. + +Core ports: + +```ts +export interface WorkspaceTrustStrategy { + provider: WorkspaceTrustProvider + planArgsOnly?(request: WorkspaceTrustRequest): Promise + planFull(request: WorkspaceTrustRequest): Promise + execute?(request: WorkspaceTrustRequest): Promise +} + +export interface PtyProcessPort { + spawn(input: PtySpawnInput): Promise +} + +export interface PtySessionPort { + readSnapshot(timeoutMs: number): Promise + writeAction(action: PtyKeyAction): Promise + kill(): Promise +} + +export interface ProviderStateProbe { + readTrustState(workspace: WorkspaceTrustWorkspace): Promise +} + +export interface WorkspaceTrustLockPort { + withWorkspaceLock( + key: string, + options: { timeoutMs: number; isCancelled: () => boolean }, + fn: () => Promise, + ): Promise +} +``` + +Adapter mapping: + +- `NodePtyProcessAdapter` implements `PtyProcessPort`. +- `ClaudeStateProbe` implements `ProviderStateProbe`. +- `WorkspaceTrustLockRegistry` implements `WorkspaceTrustLockPort`. +- `CodexConfigOverrideBuilder` is a pure adapter for Codex native config override values. Only the sibling Codex native adapter turns those values into `-c` flags. +- `TeamProvisioningService` only consumes `WorkspaceTrustCoordinator`. + +Testing rule: every provider strategy must be testable without a real provider binary. Real Claude/Codex checks belong in manual smoke scripts, not unit tests. + +### Dialog Detection Contract + +Use the Overstory shape, not a pile of regexes inside the launch service: + +```ts +export type StartupReadinessState = + | { phase: 'dialog'; ruleId: string; actions: PtyKeyAction[]; retryPolicy: 'once' | 'typed_retry' } + | { phase: 'ready'; evidence: string[] } + | { phase: 'setup_required'; code: string; evidence: string[] } + | { phase: 'loading'; evidence?: string[] } +``` + +Provider strategy owns `detect(snapshot)`. The engine owns polling, action memory, retry delays, timeout, and cleanup. + +Action memory: + +- `Enter` trust actions are sent once per dialog rule unless a fresh, clearly new trust screen appears +- typed actions like bypass confirmation may retry after a short delay if the same dialog persists +- unknown screens never receive actions + +This is more robust than GasTown's simple sequential polling and avoids blind key sequences. + +## Fallback Ladder + +The product goal is "launch from frontend with no manual trust prompt". The engineering goal is "do that without silently trusting the wrong thing". + +### Claude Ladder + +1. Probe state. + - If exact `cwd`, `realCwd`, git root, or a parent key is already trusted, skip PTY. +2. Non-persistable guard. + - If the workspace is home/root, return a clear blocked diagnostic instead of broad trust. +3. PTY warmup. + - Use protected interactive Claude args, not normal startup. + - Accept only allowlisted workspace trust prompt. + - Handle known post-trust prompts like bypass permissions and custom API key. +4. Verify state. + - Check `hasTrustDialogAccepted` through exact, realpath, git-root, and parent candidate keys after PTY action. +5. Soft failure. + - If PTY unavailable, timeout, or unknown screen appears, continue launch with diagnostics unless the screen is known setup-required. +6. Runtime fallback. + - If runtime still fails, keep `workspace_trust_required` classification and exact provider error. +7. Optional later retry. + - Run only once and only after normal cleanup. + +Do not use direct `.claude.json` writes in v1. It is tempting because it is simple, but it bypasses the provider's own persistence path and risks config races. + +### Codex Ladder + +1. Build scoped native config override values. + - Add repeatable dotted `projects."".trust_level="trusted"` values for exact workspace paths. +2. Carry those values through app-owned Codex settings. + - Primary Codex launch settings. + - Secondary Codex teammate settings under another lead provider. + - Provider facts validation settings. + - Default model resolution probe settings. +3. Apply direct `-c` only inside sibling Codex native exec. + - Validate settings payload. + - Append trusted project override values to `configOverrides`. + - Let `execRunner` turn values into Codex binary `-c` flags. +4. Keep Codex PTY rules tested but inactive by default. + - Useful for future direct Codex TUI flows. +5. Auth/setup fallback. + - If Codex shows auth picker, return `provider_auth_required`. + - Do not press Enter. + +Do not write `~/.codex/config.toml` in v1. Per-launch override is safer because it is scoped to the app launch and easy to roll back. + +### Fallback Ratings + +| Fallback | Use in v1 | Rating | Notes | +| --- | --- | --- | --- | +| Claude state probe | yes | 🎯 9 / 🛡️ 10 / 🧠 3 | read-only and cheap | +| Non-persistable home/root guard | yes | 🎯 9 / 🛡️ 10 / 🧠 3 | avoids broad unsafe trust | +| Claude PTY warmup | yes | 🎯 9 / 🛡️ 8 / 🧠 6 | provider-owned persistence, bounded risk | +| Runtime typed/text failure | yes | 🎯 9 / 🛡️ 10 / 🧠 2 | already protected by current diagnostics | +| Optional relaunch retry | later | 🎯 7 / 🛡️ 7 / 🧠 8 | useful but lifecycle-sensitive | +| Direct Claude config write | no | 🎯 5 / 🛡️ 5 / 🧠 5 | future emergency flag only | +| Codex settings -> native `configOverrides` | yes, with sentinel | 🎯 9 / 🛡️ 9 / 🧠 6 | safest deterministic-launch path because Claude argv never receives Codex `-c` | +| Direct Codex per-launch dotted `-c` | future direct Codex only | 🎯 7 / 🛡️ 7 / 🧠 4 | valid at direct Codex binary boundary, unsafe on Claude argv | +| Codex PTY fallback | later | 🎯 7 / 🛡️ 7 / 🧠 6 | useful if direct TUI path appears | +| Codex global config write | no | 🎯 4 / 🛡️ 5 / 🧠 4 | unnecessary with `-c` | + +## Runtime Flow + +```mermaid +sequenceDiagram + participant UI as User/UI + participant TPS as TeamProvisioningService + participant WTC as WorkspaceTrustCoordinator + participant CP as Claude PTY Strategy + participant CX as Codex Args Strategy + participant RT as Orchestrator Runtime + + UI->>TPS: Launch team for selected project + TPS->>WTC: prepareWorkspaceTrust(request) + WTC->>CP: ensure Claude workspace trust for exact workspaces + CP->>CP: run protected node-pty warmup and accept allowlisted dialogs + WTC->>CX: build Codex workspace-trust settings + CX-->>WTC: typed settings patch + WTC-->>TPS: diagnostics + args patches + TPS->>RT: launch with patched args + RT-->>TPS: bootstrap events + alt optional Phase 4b workspace_trust_required retry + TPS->>WTC: retry preflight once + TPS->>RT: relaunch once + else success + RT-->>TPS: members joined + end +``` + +## Dialog State Machine + +The PTY engine should model startup as phases. It should not classify against a single ever-growing transcript. + +```mermaid +stateDiagram-v2 + [*] --> Start + Start --> ClaudeResume: resume selector + Start --> CodexUpdate: update available + Start --> WorkspaceTrust: trust prompt + Start --> Ready: ready prompt + Start --> AuthRequired: auth picker + Start --> ProviderSetupRequired: onboarding/theme + ClaudeResume --> CodexUpdate: Down + Enter + ClaudeResume --> WorkspaceTrust: Down + Enter + CodexUpdate --> WorkspaceTrust: Down + Enter + CodexUpdate --> Ready: Down + Enter + WorkspaceTrust --> BypassPermissions: Enter + WorkspaceTrust --> CustomApiKey: Enter + WorkspaceTrust --> Ready: Enter + BypassPermissions --> Ready: Down + Enter + CustomApiKey --> Ready: Up + Enter + Ready --> [*] + AuthRequired --> [*] + ProviderSetupRequired --> [*] +``` + +Important behavior: + +- After an action, the engine should wait for a fresh snapshot or action-settle delay. +- Previously matched stale text must not trigger the same rule again forever. +- A prompt-looking screen should get a short grace window before declaring ready. +- Unknown text should never receive `Enter`. + +## Claude Implementation Plan + +### What We Need To Solve + +Claude/orchestrator trust is the workspace trust gate that blocks headless process teammates. + +Current runtime behavior in the sibling orchestrator: + +- `isPathTrusted(workingDir)` checks Claude global project trust. +- If false, teammate spawn fails before the provider-specific process starts. +- This affects Codex teammates too. + +Therefore, Claude workspace trust preflight must run for every workspace used by a team launch, even if no teammate uses Anthropic as its model provider. + +### Claude Strategy + +`ClaudePtyWorkspaceTrustStrategy` should: + +1. Resolve Claude binary with the same resolver used for launch. +2. For each exact workspace: + - use `cwd` + - compute `realCwd` + - run a short PTY warmup in that cwd with the protected interactive Claude command + - detect allowlisted startup dialogs + - press only the required keys + - stop immediately once trust state is persisted or the workspace is already trusted +3. Record diagnostics without secrets. + +The strategy should not send any user prompt to Claude. It starts interactive Claude only long enough to clear startup dialogs. + +The strategy should not pass runtime launch args. In particular, do not pass team bootstrap args, MCP config, user extra CLI args, model args, or `--dangerously-skip-permissions`. + +Protected command builder: + +```ts +buildClaudeWorkspaceTrustPreflightArgs({ + emptyMcpConfigPath, + supportsBare, + supportsStrictMcpConfig, + supportsSettingSources, + supportsTools, +}) +``` + +Expected modern args: + +```text +--bare +--strict-mcp-config +--mcp-config +--setting-sources user +--settings {"disableAllHooks":true} +--tools "" +``` + +The temp empty MCP file is app-owned, contains `{"mcpServers":{}}`, and is removed in `finally`. + +### Claude Success Criteria + +Success if any is true: + +- Claude reaches a trusted normal prompt without showing trust. +- Claude trust dialog was accepted and state now contains `hasTrustDialogAccepted: true` for `realCwd` or an ancestor. +- The workspace was already trusted before preflight. +- Another concurrent preflight accepted trust while this launch was waiting for the workspace lock. + +Do not require: + +- `hasCompletedProjectOnboarding: true` +- a model call +- a session to remain open +- a successful plugin marketplace install + +### Claude Dialog Rules + +Required rules: + +```text +Claude theme onboarding: + detect: Choose the text style that looks best with your terminal + action: none in v1 + result: provider_setup_required + +Claude workspace trust: + detect: Quick safety check + detect: trust this folder + detect: Yes, I trust this folder + action: Enter + success check: state file has hasTrustDialogAccepted true + +Claude bypass permissions: + detect: Bypass Permissions mode + action: Down, Enter + +Claude custom API key: + detect: Detected a custom API key in your environment + detect: Do you want to use this API key? + action: Up, Enter + +Claude ready: + detect: prompt marker plus status/readiness marker + action: none +``` + +Do not treat `ClaudeCode v2.x` alone as ready. Prototype showed that string appears on splash/onboarding before the app is actually ready. + +Do not treat a single prompt marker as ready. GasTown's prompt-suffix exit is useful for detached tmux sessions, but Overstory's two-signal readiness is safer for a provider TUI. For our v1 Claude preflight, readiness is secondary anyway: trust persistence is the success condition. + +### Claude State Probe + +`ClaudeStateProbe` should read only the minimal state needed: + +- `$HOME/.claude.json` +- `$CLAUDE_CONFIG_DIR/.claude.json` if present +- only `projects` keys and trust booleans + +Do not log: + +- OAuth account data +- API keys +- MCP configs +- full config JSON + +Path matching: + +- check exact `cwd` +- check exact `realCwd` +- check parent directories like orchestrator `isPathTrusted` +- normalize macOS `/var` to `/private/var` by checking both original and realpath +- preserve Windows drive paths if running on Windows +- skip PTY if a trusted parent already covers the workspace + +### Claude Edge Cases + +| Case | Expected behavior | +| --- | --- | +| Claude binary missing | skip PTY, return `provider_binary_missing`, launch should fail with existing diagnostics | +| `node-pty` unavailable | skip PTY, return `preflight_unavailable`, no crash | +| Protected Claude flags unsupported | soft-fail by default; do not silently downgrade to plain `claude` | +| Empty MCP config file invalid | treat as implementation error in tests; runtime returns soft-failed diagnostic | +| Empty Claude profile shows onboarding | return `provider_setup_required`, do not auto-select theme in v1 | +| Trust prompt appears | press Enter only if allowlisted prompt matches | +| Bypass permissions appears after trust | probe trust state first; if persisted, kill PTY and return success | +| Custom API key prompt appears after trust | probe trust state first; if persisted, kill PTY and return success | +| Claude hangs on irrelevant output | timeout and return diagnostic | +| Trust accepted but UI not ready | success if trust state is persisted | +| Trust accepted then another dialog appears | success if trust state is persisted | +| Workspace missing | do not run PTY, keep existing cwd-unavailable error | +| Multiple team worktrees | run per unique realpath | +| Concurrent launch same workspace | serialize by provider + realpath lock | + +### Claude Integration Detail + +Claude PTY preflight should run for all deterministic team launch workspaces, not only Anthropic launches. + +Reason: the sibling orchestrator checks Claude workspace trust before spawning headless process teammates. A Codex teammate can therefore fail before Codex starts. + +Workspace collection: + +- always include `request.cwd` +- include `member.cwd` from `allEffectiveMemberSpecs` when present +- include generated OpenCode worktree paths only when they flow through the legacy deterministic launch path +- include `request.worktree` only if it resolves to an actual cwd path, not the worktree name string +- dedupe by `realpath` +- keep both display path and realpath in diagnostics so Windows/macOS path normalization bugs are visible +- treat trusted parent directories as covering child workspaces, matching runtime `isPathTrusted` + +Placement: + +- collect workspaces after `resolveOpenCodeMemberWorkspacesForRuntime` +- run Claude PTY after `ProvisioningRun` is created and before `clearPersistedLaunchState` +- keep this preflight outside `buildProvisioningEnv`, because env resolution should not spawn UI processes +- run under `failDeterministicRunBeforeSpawn(...)` cleanup if it blocks + +Progress: + +- start: `Preparing workspace trust` +- action: `Accepted Claude workspace trust` +- soft failure: warning diagnostic, continue launch +- blocking setup: `Claude setup required` only if the runtime would otherwise be guaranteed to fail + +The safer v1 default is to continue launch on soft preflight failures and let the existing runtime failure path classify `workspace_trust_required`. This avoids converting recoverable launch paths into new pre-spawn hard failures. + +## Codex Implementation Plan + +### What We Need To Solve + +Codex has two separate trust-related surfaces: + +1. The orchestrator's Claude workspace trust gate before spawning headless teammates. +2. Codex CLI's own workspace trust prompt in TUI/direct Codex flows. + +For issue #100 class failures, the first surface is the blocker. That is solved by Claude workspace preflight. + +For direct Codex launch and future Codex-native flows, use native config override values as the app-owned intent instead of directly writing global config. Recent Codex issue data means we must not promise that Codex itself will never persist a project trust entry. + +### Codex Strategy + +`CodexWorkspaceTrustSettingsStrategy` should: + +1. Compute trusted path keys for every workspace: + - original `cwd` + - `realpath(cwd)` + - normalized config key candidates that match Codex path syntax +2. Build repeatable dotted native override values: + +```text +projects."/path".trust_level="trusted" +projects."/realpath".trust_level="trusted" +``` + +3. Return them as typed settings patches for Codex provider invocations. +4. In the sibling runtime, validate the settings payload and append values to Codex native `configOverrides`. + +Do not write `~/.codex/config.toml` in v1. + +Do not claim "no config writes" in diagnostics. The correct claim is: + +- the desktop app does not directly write Codex global config +- Codex native receives scoped config override values for the selected launch workspaces +- current Codex behavior must be smoke-tested for whether it persists trust despite native config overrides +- Claude/orchestrator launch argv never receives Codex native `-c` + +### Codex Config Override Builder + +The override builder must not concatenate unsafe raw path strings or emit a single `projects={...}` blob. + +Rules: + +- quote each path as a TOML basic-string dotted-key segment +- support spaces +- support quotes +- support brackets +- support backslashes +- include original and realpath +- deduplicate exact strings +- return one override per path +- do not parse or rewrite unrelated user Codex config overrides in v1 + +Example: + +```ts +buildCodexTrustedProjectOverrides([ + '/tmp/project', + '/private/tmp/project', +]) +``` + +Output: + +```ts +[ + 'projects."/tmp/project".trust_level="trusted"', + 'projects."/private/tmp/project".trust_level="trusted"', +] +``` + +### Codex PTY Fallback + +Codex PTY fallback is optional in v1, but the shared `PtyDialogEngine` should support it because: + +- GasCity has proven the update-to-trust order in real user environments. +- Direct Codex TUI can show update before trust. +- It is useful for future direct runtime flows. + +Codex rules: + +```text +Codex update: + detect: Update available + detect: Skip until next version + detect: Press enter to continue + action: Down, Enter + +Codex workspace trust: + detect: Do you trust the contents of this directory? + detect: Working with untrusted contents + detect: Trusting the directory allows project-local config + action: Enter + +Codex auth picker: + detect: Sign in with ChatGPT + detect: Provide your own API key + action: none + result: provider_auth_required + +Codex ready: + detect: Ask Codex + detect: model/status line + action: none +``` + +### Codex Edge Cases + +| Case | Expected behavior | +| --- | --- | +| Codex auth picker appears | return `provider_auth_required`, do not press Enter | +| Update prompt appears | press Down, Enter | +| Trust appears after update | press Enter | +| Stale update snapshot remains in buffer | state machine must move to next phase and reclassify fresh snapshots | +| Per-launch native override values present | trust prompt should not appear | +| Path has spaces/quotes | config override builder escapes TOML dotted-key segments correctly | +| Codex `exec` does not show trust | no problem, override is harmless and scoped to launch | +| Codex mutates `~/.codex/config.toml` after native override | record provider-side mutation diagnostic; keep rollback flag | +| Global config write fails | app does not direct-write in v1; provider-side failures surface through Codex/runtime diagnostics | + +### Codex Config Mutation Sentinel + +Because a recent Codex issue reports unexpected persistence from project trust native overrides, add a small sentinel for manual smoke and optional debug diagnostics. + +Sentinel behavior: + +- before launch, read only `stat` plus a content hash of `~/.codex/config.toml` if the file exists +- do not log file content +- after a Codex launch failure or debug-enabled smoke, re-check stat/hash +- if changed, record `codex_config_changed_during_trust_override` +- never restore user config automatically + +This is not a blocker for issue #100 because Claude/orchestrator trust is the main gate there. It is a guardrail so we do not silently rely on a false "ephemeral override" assumption. + +Top 3 Codex trust options after the external finding and sibling code review: + +1. Desktop `--settings` contract -> sibling Codex native `configOverrides` plus mutation sentinel - 🎯 9 🛡️ 9 🧠 6, ~120-220 LOC across both repos. Best v1 because Claude argv never receives Codex `-c`. +2. App-owned atomic write to `~/.codex/config.toml` with backup - 🎯 7 🛡️ 6 🧠 7, ~140-220 LOC. More deterministic but we own user config risk. +3. Codex PTY accept trust - 🎯 7 🛡️ 7 🧠 6, ~120-200 LOC. Provider-owned persistence, but more prompt automation and update/auth handling. + +Chosen: option 1 for v1, with a feature flag to disable Codex trust settings if mutation behavior is worse than expected. + +### Codex Integration Detail + +Codex settings patch must be typed and applied immutably. + +Do not mutate `provisioningEnv.providerArgs` or `crossProviderMemberArgs` in place. Instead derive: + +- `providerArgsForLaunch` +- `providerArgsByProviderForLaunch` +- `crossProviderMemberArgsForLaunch` + +Consumers that must receive patched Codex settings: + +- `providerArgsByProvider.get('codex')` before `resolveAndValidateLaunchIdentity` +- `runtimeArgsPlan.providerArgs` when the lead provider is Codex +- `crossProviderMemberArgs.args` as merged `--settings` JSON when Codex is a secondary provider +- launch snapshot and spawn context through the final merged args + +Surfaces that must not receive Codex native `-c`: + +- pure Anthropic primary provider args +- Claude PTY trust preflight command args +- runtime bootstrap args that are not provider CLI args +- OpenCode pure runtime adapter args in v1 + +Special cases: + +- Anthropic API-key helper strips path-based `--settings`; app-owned Codex trust settings must be inline JSON settings so they survive. +- `mergeJsonSettingsArgs` should still run after adding patches, and must deep-merge app-owned Codex workspace-trust settings with forced-login settings. +- Existing user Codex settings should not be blindly overwritten. App-owned workspace-trust keys should live under a dedicated namespace such as `codex.agent_teams_workspace_trust`. +- Sibling runtime Codex native path already models `configOverrides` as repeatable `-c` values. Keep our desktop settings payload compatible with that contract instead of putting raw `-c` in Claude argv. +- Every patch must carry `targetSurface`, `dialect`, `owner`, and `dedupeKey`. +- Applying the same patch twice must be a no-op for exact app-owned dotted overrides. +- If a target surface cannot prove that app-owned settings reach the Codex teammate, do not append the patch. Emit `codex_trust_settings_surface_unknown`. + +Required v1 hardening: + +- Pass Codex trust settings into default model resolution for non-primary Codex members. This is required because default model resolution can call provider runtime commands before final launch args exist. +- Add a typed `CodexConfigOverride` model and typed `WorkspaceTrustLaunchArgPatch` from the start instead of raw string arrays. +- Characterize current `buildInheritedCliFlags` behavior so Anthropic lead -> Codex teammate keeps inline app-owned settings intact. +- Characterize current `buildProviderCliCommandArgs(...)` order for provider facts/default model probes. +- Keep all Codex trust settings behind `AGENT_TEAMS_WORKSPACE_TRUST_CODEX_SETTINGS` so a runtime contract regression can be rolled back without disabling Claude PTY trust preflight. + +## PTY Dialog Engine + +The dialog engine is the most important reliability piece. + +It must be a phase-aware state machine, not a single regex over all accumulated output. + +Why: + +- Codex update output can remain visible after pressing Skip. +- Claude/Codex TUI rendering can remove or collapse whitespace. +- Dialogs can appear one frame after a prompt-looking screen. +- Some startup screens include provider names before the actual prompt is ready. + +### Engine Inputs + +```ts +export type TerminalSnapshot = { + raw: string + normalized: string + lines: string[] + elapsedMs: number +} + +export type StartupDialogRule = { + id: string + phase: StartupDialogPhase + priority: number + match(snapshot: TerminalSnapshot): boolean + actions: PtyKeyAction[] + afterAction?: 'continue' | 'success' | 'fail' + successProbe?: StartupSuccessProbe +} +``` + +### Normalization + +Use a shared normalizer: + +- strip ANSI escape sequences +- convert `\r` to `\n` +- keep both line-based text and compact text +- compact text lowercases and removes whitespace for TUI-collapsed matching + +Example: + +```text +Quick safety check +QuickSafetyCheck +Quicksafetycheck: +``` + +All must match the same trust rule. + +### Required Phase Order + +```text +claude_resume +codex_update +workspace_trust +bypass_permissions +custom_api_key +rate_limit +ready +``` + +The phase order should be configurable by strategy, but shared rules can live in one registry. + +### Action Safety + +Only these actions are allowed: + +- `Enter` +- `Down` +- `Up` +- `Escape` only for explicit abort paths + +Do not send text input. +Do not send shell commands. +Do not press Enter on unknown screens. + +Allowed exception: + +- typed confirmation can be modeled as `type:2` only for an exact Claude bypass confirmation screen that contains the full warning and both numbered choices +- this must be behind its own rule id and tests, not a general text-entry action + +### Timeouts + +Suggested defaults: + +- per phase: 8 seconds +- action settle delay: 200-500 ms +- post-prompt grace: 100-250 ms +- total PTY warmup cap per workspace: 15 seconds + +These should be constants with tests, not magic numbers in strategies. + +### Cleanup + +Always kill the PTY child at the end of preflight. + +On POSIX: + +- kill process group if possible +- fall back to direct child kill + +On Windows: + +- use existing process tree kill helper if available + +Do not kill unrelated Claude, Codex, tmux, or OpenCode processes. + +### Snapshot Memory Model + +Use two bounded transcript buffers: + +- `rawTail`: last N bytes for diagnostics +- `phaseWindow`: text observed since the previous accepted action + +Rule matching should use `phaseWindow`, not the full lifetime buffer. This is the GasCity lesson that matters most for Codex: after pressing Skip on an update prompt, stale update text can remain visible while the trust prompt appears below it. + +After every accepted action: + +- record the action and matched rule +- clear `phaseWindow` +- keep `rawTail` +- wait for an action-settle delay +- require a fresh snapshot timestamp before matching the same phase again + +This prevents loops like: + +```text +see update -> Down Enter -> stale update still visible -> Down Enter forever +``` + +It also lets tests replay exact terminal snapshots without a real PTY. + +## Runtime Contract + +The runtime should remain the authority for process execution and bootstrap events. + +The host should prepare trust before launch and pass explicit settings/contracts. The runtime should still fail safely if trust is missing. + +### Host To Runtime + +The desktop app may pass: + +- prepared workspace cwd +- existing provider launch args +- app-owned Codex workspace-trust settings containing validated native config override values +- a diagnostic marker like `AGENT_TEAMS_WORKSPACE_TRUST_PREFLIGHT=1` + +Avoid: + +- passing raw provider config JSON outside the typed settings contract +- passing secrets +- passing global trust flags +- disabling runtime checks + +### Runtime To Host + +Runtime failure should be typed when possible: + +```json +{ + "code": "workspace_trust_required", + "provider": "claude", + "workspace": "/path", + "message": "workspace trust is not accepted" +} +``` + +Current text classification already catches this class. Keep it as fallback even if typed events are added. + +## Precise Integration Plan In `TeamProvisioningService` + +This is the safest wiring plan after reviewing `_createTeamInner` and `_launchTeamInner`. + +1. Add an optional `workspaceTrustCoordinator` dependency to `TeamProvisioningService`. + - Keep the constructor unchanged. + - Default production value is created lazily by a private getter. + - Tests can inject a fake coordinator through `setWorkspaceTrustCoordinator(...)`. + - This follows existing service setter patterns and avoids churn across many `new TeamProvisioningService(...)` tests. + - The service should depend on the coordinator interface, not on `node-pty`, dialog rules, or Codex config override builders. + +2. Add optional fields to `ProvisioningRun`. + - `workspaceTrustPlan` + - `workspaceTrustExecution` + - `workspaceTrustRetryAttempted` + - `workspaceTrustDiagnostics` + +3. Insert early settings-only planning after `buildProvisioningEnv`. + - Inputs: `request.cwd`, resolved/requested provider ids, `providerArgs`, `shellEnv`, `claudePath`. + - Output: Codex settings patches for provider probes/default model resolution. + - No PTY, no locks, no config writes, no prompt actions. + - Pass the resulting provider arg patch resolver into `materializeEffectiveTeamMemberSpecs()` without importing workspace-trust internals there. + +4. Insert full `planFull()` after `buildCrossProviderMemberArgs`. + - Inputs: `request.cwd`, `allEffectiveMemberSpecs`, `effectiveMemberSpecs`, `resolvedProviderId`, `providerArgs`, `crossProviderMemberArgs`, `shellEnv`, `claudePath`. + - Output: unique workspaces, strategy plan, Codex settings patches, non-blocking diagnostics. + - No PTY, no config writes, no prompt actions. + - Workspace collection must use `allEffectiveMemberSpecs`; provider runtime/bootstrap behavior can still use filtered `effectiveMemberSpecs`. + +5. Derive patched settings/args immutably. + - `providerArgsForLaunch = applyProviderSettingPatches(providerArgs, plan.patches, resolvedProviderId)` + - `crossProviderMemberArgsForLaunch = applyCrossProviderSettingPatches(crossProviderMemberArgs, plan.patches)` + - `providerArgsByProviderForLaunch = buildProviderArgsByProvider(...)` + - Do not mutate `provisioningEnv`. + - Do not mutate the original `crossProviderMemberArgs`. + +6. Pass `providerArgsByProviderForLaunch` into `resolveAndValidateLaunchIdentity`. + - This is required because `readRuntimeProviderLaunchFacts()` receives provider args. + - Missing this step can leave Codex validation/probes using untrusted default args. + +7. Create `ProvisioningRun`, then run the shared execution helper. + - Before `execute()`, update progress to cancellable state `spawning` with message `Preparing workspace trust`. + - Emit progress before and after. + - Store diagnostics on `run`. + - Use `progress.warnings` for short live warnings. + - Do not add workspace trust entries to `progress.launchDiagnostics` in v1 because its `code` field is a fixed union. + - If execution returns `blocked`, fail with a clear setup message and restore prelaunch config through the existing cleanup path. + - If execution returns `soft_failed`, keep launching and let runtime classification handle any remaining trust failure. + - If execution returns `cancelled`, follow existing cancellation semantics and do not create a trust failure. + - After `execute()`, check the run is still current before continuing. User stop or shutdown may have cleaned it up while the PTY was running. + - Run this before `clearPersistedLaunchState`. + - Use a typed cleanup policy so create mode deletes only create-owned artifacts and launch mode restores prelaunch config. + +8. Use patched args in final launch args. + - Build `envResolutionForLaunch = { ...provisioningEnv, providerArgs: providerArgsForLaunch }`. + - `buildTeamRuntimeLaunchArgsPlan` should receive `envResolutionForLaunch`. + - `launchArgs.push(...runtimeArgsPlan.providerArgs)` should use patched primary args. + - `launchArgs.push(...crossProviderMemberArgsForLaunch.args)` should use patched secondary args. + +9. Copy diagnostics into failure artifacts. + - Add `workspaceTrustPreflight` under artifact manifest `flags`. + - Keep it bounded and redacted. + - Do not store full PTY transcripts. + +10. Keep preflight env separate from runtime env. + - Build `trustPreflightEnv` from `shellEnv`. + - Strip team runtime env and app-managed Anthropic helper env. + - Preserve `CLAUDE_CONFIG_DIR` and normal shell auth env. + +11. Keep temp preflight files out of run cleanup. + - Temp empty MCP config belongs to `ClaudePtyWorkspaceTrustStrategy`. + - The strategy removes it in `finally`. + - Do not store it in `run.mcpConfigPath`, because that field is for team runtime MCP config and existing cleanup removes it as launch-owned state. + +12. Use the same shared helper in create and launch. + - `_createTeamInner` and `_launchTeamInner` should call the same workspace-trust integration helper. + - The only difference should be the cleanup policy and user-facing progress wording. + +Preferred helper shape: + +```ts +type WorkspaceTrustLaunchArgContext = { + primaryProviderId: TeamProviderId + primaryProviderArgs: string[] + crossProviderArgs: CrossProviderMemberArgsResult + providerArgsByProvider: Map +} + +type WorkspaceTrustLaunchArgContextWithPatches = WorkspaceTrustLaunchArgContext & { + diagnostics: WorkspaceTrustDiagnostic[] +} +``` + +The helper should be pure and covered by tests. Most regressions here would be invisible until mixed-provider launch, so this logic should not live inline inside `_createTeamInner` or `_launchTeamInner`. + +## Retry Policy + +Recommendation after code review: ship preflight first, ship automatic relaunch retry only as a separate sub-phase behind a feature flag. + +Reason: `handleDeterministicBootstrapEvent()` already owns failure cleanup, progress state, retained logs, artifact writing, member spawn statuses, and config restoration. Relaunching from inside that path is higher risk than preparing trust before the first launch. + +```mermaid +flowchart TD + A["Launch requested"] --> B["Run trust preflight"] + B --> C["Launch runtime"] + C --> D{"Failure classification"} + D -->|"workspace_trust_required and retry not used"| E["Run preflight again"] + E --> F["Relaunch once"] + F --> G{"Still trust failure?"} + G -->|"yes"| H["Show Workspace trust required diagnostics"] + G -->|"no"| I["Continue"] + D -->|"other failure"| J["Keep original failure path"] +``` + +Rules: + +- Retry only once. +- Retry disabled by default until Phase 4b smoke tests pass. +- Retry only for `workspace_trust_required`. +- Do not retry for auth, missing binary, permission denied, model errors, or cwd unavailable. +- Preserve original error text in diagnostics. +- Record both preflight attempts. +- Do not relaunch directly inside `handleDeterministicBootstrapEvent`. +- If retry is enabled later, cleanup the failed run first, then call the normal launch entrypoint with a retry marker. + +## Diagnostics + +Add a compact diagnostics object to launch failure artifacts and progress trace. + +Example: + +```json +{ + "workspaceTrustPreflight": { + "attempt": 1, + "strategyResults": [ + { + "provider": "claude", + "workspace": "/private/tmp/project", + "status": "accepted", + "matchedDialogs": ["workspace_trust"], + "actions": ["Enter"], + "elapsedMs": 532, + "lockWaitMs": 0, + "rawTailIncluded": false + }, + { + "provider": "codex", + "status": "args_patch_added", + "pathsCount": 2 + } + ] + } +} +``` + +Redaction rules: + +- Do not log env values. +- Do not log full `.claude.json`. +- Do not log OAuth/account details. +- Do not log API keys. +- Log path and provider only if existing diagnostics already expose the workspace path. +- Do not log PTY raw tail unless failure plus debug flag. +- Redact raw tail before writing artifact. + +### Progress Diagnostics Without UI Schema Change + +No renderer schema change is needed in v1. + +Use existing surfaces: + +- `progress.message` for high-level state: + - `Preparing workspace trust` + - `Workspace trust prepared` + - `Claude setup required` +- `progress.warnings` for non-blocking trust preflight warnings. +- artifact manifest `flags.workspaceTrustPreflight` for structured details. +- launch failure classification remains `workspace_trust_required` when runtime still fails. + +User-facing rule: + +- If preflight succeeds, do not show extra UI noise. +- If preflight soft-fails but launch succeeds, keep the warning in diagnostics only. +- If launch fails with trust, show the existing `Workspace trust required` message and include preflight evidence in Copy diagnostics. + +Do not use `progress.launchDiagnostics` for trust preflight in v1 unless `TeamLaunchDiagnosticItem.code` is explicitly extended and renderer tests are added. + +## Integration Points + +### Desktop App + +Likely touch points: + +- `TeamProvisioningService` + - before `spawnCli(...)` + - after cwd, worktrees, provider mix, shell env, and provider args are resolved + - before final launch args are frozen +- `TeamLaunchFailureArtifactPack` + - include trust preflight diagnostics + - keep `workspace_trust_required` classification first +- Tests under: + - `test/main/services/team/` + - new `workspaceTrust` unit tests + +### Sibling Orchestrator Repo + +The sibling runtime already has: + +- `isPathTrusted(dir)` +- `checkHasTrustDialogAccepted()` +- headless process failure when trust is missing +- Codex native `configOverrides` +- hooks/auth helpers/MCP helper guards that depend on workspace trust + +Preferred contract changes: + +- Keep `isPathTrusted` gate. +- Add typed failure metadata if not already available through events. +- Accept Codex `configOverrides` from host without special casing. +- Do not add PTY prompt handling inside the orchestrator in v1. + +This keeps the orchestrator as runtime, not UX policy owner. + +Security rule: do not weaken these runtime checks. The desktop host prepares the selected workspace, but the runtime still decides whether a given headless process is allowed to start. + +## Implementation Phases + +### Revised Low-Risk Implementation Order + +Do not start with the full preflight pipeline. Build in this order: + +1. Domain and pure helpers. +2. Path canonicalization, feature flags, and lock registry. +3. Dialog engine with fake terminal snapshots. +4. Codex settings patching with pure integration helpers. +5. Early settings-only plan integration for provider/default-model probes. +6. Claude PTY strategy behind a fake `PtyProcessPort`. +7. Shared create/launch `TeamProvisioningService` full plan integration. +8. Shared create/launch execute integration behind feature flags. +9. Protected Claude command smoke with real profile in temp workspaces. +10. Codex mutation sentinel smoke with real profile or isolated `CODEX_HOME`. +11. Enable default preflight. +12. Add automatic relaunch retry only after preflight is stable. + +Top 3 implementation approaches: + +1. Three-stage coordinator: early settings, full plan, execute - 🎯 8 🛡️ 9 🧠 8, ~950-1450 LOC desktop plus 50-180 sibling runtime LOC. Best v1 after create/launch and default-model gaps. +2. Two-stage coordinator: full plan and execute only - 🎯 7 🛡️ 8 🧠 7, ~650-950 LOC. Simpler, but misses early default-model/provider probe arg consistency. +3. Full preflight plus automatic retry in one PR - 🎯 6 🛡️ 6 🧠 9, ~1000-1400 LOC. More complete, but too much launch lifecycle risk for first rollout. + +### Phase 0 - Keep Current Diagnostics + +Already done or in progress: + +- classify `workspace_trust_required` +- show `Workspace trust required` for deterministic bootstrap failure +- preserve exact provider error as `error` + +Value: + +- users see the true cause even before auto-preflight exists +- tests protect the current behavior + +### Phase 1 - Domain And Dialog Engine + +Add: + +- workspace trust domain types +- path canonicalization helper +- feature flag parser +- workspace trust lock registry +- Codex config override builder +- terminal normalizer +- startup dialog rules +- PTY dialog engine with fake adapter tests + +No live process changes yet. + +Tests: + +- Claude trust compact text matches +- Codex update matches +- Codex stale update then trust sends `Down, Enter, Enter` +- stale `rawTail` does not rematch update after phase transition +- `phaseWindow` resets after accepted action +- Bypass sends `Down`, then `Enter` with delay +- Custom API key sends `Up`, then `Enter` +- Unknown screen sends no keys +- Claude `ClaudeCode v2.1.119` splash alone is not ready +- Claude single prompt marker alone is not ready +- Claude trust dialog takes precedence over prompt-looking `>` text +- Claude trust dialog takes precedence over ready indicators and bypass text +- Codex trust dialog takes precedence over prompt-looking update/banner text +- Codex auth picker sends no keys +- PTY unavailable returns skipped diagnostic +- cancellation stops PTY and returns non-throwing diagnostic +- Prompt-looking screen waits grace for delayed dialog +- Timeout returns a non-throwing diagnostic +- Windows drive and UNC paths dedupe correctly +- trusted parent path covers child workspace +- lock wait re-probes state before spawning PTY +- malformed feature flag value emits one diagnostic and uses default + +### Phase 2 - Claude PTY Strategy + +Add: + +- `ClaudePtyWorkspaceTrustStrategy` +- `ClaudeStateProbe` +- `ClaudePreflightCommandBuilder` +- temporary empty MCP config writer +- process cleanup +- per-workspace lock +- stripped `trustPreflightEnv` +- diagnostics + +Run it behind a feature flag first: + +```text +AGENT_TEAMS_WORKSPACE_TRUST_PREFLIGHT=1 +``` + +Success criteria: + +- fresh workspace gets `hasTrustDialogAccepted` +- repeated preflight is no-op +- empty profile returns setup required +- protected Claude command is used with no runtime launch args +- `-p` and `doctor` are not used for workspace trust preflight +- temp MCP config is `{"mcpServers":{}}`, not plain `{}` +- project/local settings are not loaded in the protected command +- built-in tools are disabled with `--tools ""` +- trust action is followed by state probe and immediate PTY kill on success +- follow-up bypass/custom API dialogs are ignored if trust has already persisted +- preflight env strips app-managed team helper env +- trusted parent path skips PTY for child workspace +- selected home directory returns non-persistable-home blocked diagnostic +- selected git subdirectory observes trust persisted at git-root key +- symlinked git workspace observes trust through original path, realpath, and git-root candidates + +### Phase 3 - Codex Settings Strategy + +Add: + +- `CodexWorkspaceTrustSettingsStrategy` +- `CodexConfigOverrideBuilder` +- provider settings patch application +- early settings-only plan for `request.cwd` +- sibling runtime settings reader for `codex.agent_teams_workspace_trust.config_overrides` + +Success criteria: + +- settings contain repeatable dotted `projects."".trust_level="trusted"` override values +- overrides are represented as typed patches with `dialect: 'claude-codex-runtime-settings'` on desktop and `codex-native-config-override` in sibling runtime +- original and realpath included +- quoted paths are valid +- app does not directly write global Codex config +- existing Codex forced-login settings stay stable +- Codex primary lead gets the trust settings +- Anthropic lead with Codex teammate gets inherited trust settings via `--settings` JSON +- Anthropic primary provider args do not get Codex native `-c` +- `providerArgsByProvider.get('codex')` includes trust settings before launch identity validation +- Anthropic args are unchanged when no Codex provider is present +- applying early and full-plan patches does not duplicate exact app-owned overrides +- duplicate path keys are removed +- `mergeJsonSettingsArgs` deep-merges the trust settings with forced-login settings +- `buildTeamRuntimeLaunchArgsPlan` receives a cloned env resolution with patched `providerArgs` +- original `provisioningEnv.providerArgs` remains unchanged +- user extra args before provider args cannot remove app-managed Codex trust settings +- provider facts probes receive patched Codex settings even before final launch args exist +- default model resolution receives patched Codex settings for non-Anthropic members without explicit model +- sibling Codex native `turnExecutor` appends validated settings values to `configOverrides` +- no v1 deterministic launch path emits Codex native `-c` into Claude argv +- optional mutation sentinel records config hash changes without logging config content +- no v1 code path emits a single `projects={...}` table blob + +### Phase 4a - Create/Launch Integration Without Retry + +Integrate coordinator into `TeamProvisioningService`. + +Flow: + +1. In create and launch, run early settings-only plan after `buildProvisioningEnv`. +2. Materialize members/default models with early patched provider settings. +3. Resolve OpenCode workspaces, retain `allEffectiveMemberSpecs`, and plan runtime lanes. +4. Build cross-provider args for primary-lane members, then run full `WorkspaceTrustCoordinator.planFull()` with the all-effective workspace set. +5. Apply final provider settings patches immutably. +6. Resolve and validate launch identity with patched provider settings. +7. Create `ProvisioningRun`. +8. Set progress to cancellable `spawning` and run shared `prepareWorkspaceTrustForDeterministicRun()`. +9. Clear persisted launch state only if execute did not block/cancel. +10. Set `run.launchStateClearedForRun = true` only after clear succeeds. +11. Launch runtime. +12. On failure, artifact manifest includes `flags.workspaceTrustPreflight`. + +Guardrails: + +- feature flag default can be off for first internal smoke +- if preflight fails, launch can still proceed unless the failure is deterministic setup-required +- all failures are diagnostic, not crashes +- no launch retry in this phase +- no new `TeamLaunchDiagnosticItem.code` in this phase +- blocked preflight uses typed create/launch cleanup policy +- blocked launch preflight restores prelaunch config and writes failure artifact +- blocked create preflight removes run tracking and create-owned helper material without deleting unrelated team data +- blocked preflight does not clear the previous launch snapshot +- blocked launch preflight does not persist a synthetic failed launch snapshot +- soft-failed preflight clears persisted launch state only after deciding to continue into bootstrap +- cancelled preflight maps to existing launch cancellation semantics +- stopped/shutdown run after preflight does not continue into `clearPersistedLaunchState` +- trust lock timeout is soft failure, not a launch crash + +### Phase 4b - Optional Trust Retry + +Add controlled retry only after Phase 4a passes manual smoke. + +Flow: + +1. Existing launch fails with `workspace_trust_required`. +2. Current run completes its normal cleanup path. +3. Service schedules one relaunch with `workspaceTrustRetryAttempted: true`. +4. Coordinator runs preflight again. +5. Relaunch once. + +Guardrails: + +- behind `AGENT_TEAMS_WORKSPACE_TRUST_RETRY=1` +- no retry for non-trust failures +- no retry if launch was cancelled +- no retry if preflight returned `provider_setup_required` +- preserve original failure in artifacts + +### Phase 5 - Default On And Cleanup + +After smoke tests: + +- enable by default +- remove temporary feature flag if desired +- update debugging runbook +- add launch artifact examples + +## Test Plan + +### Unit Tests + +Dialog engine: + +- `matchesClaudeTrustDialogWithCollapsedWhitespace` +- `matchesCodexTrustDialog` +- `skipsCodexUpdateThenAcceptsTrust` +- `doesNotPressEnterOnCodexAuthPicker` +- `acceptsClaudeBypassAfterTrust` +- `acceptsClaudeCustomApiKeyAfterTrust` +- `stopsAfterTrustStatePersistsBeforeFollowUpDialog` +- `usesProtectedClaudeCommandForTrustPreflight` +- `doesNotUsePrintOrDoctorForTrustPreflight` +- `writesValidEmptyMcpConfigShape` +- `removesTempEmptyMcpConfigOnSuccessAndFailure` +- `waitsGraceAfterPromptLookingSnapshot` +- `timesOutOnIrrelevantSnapshots` +- `cancelsBeforeWritingKeyAction` + +Codex config overrides: + +- simple absolute path +- `/var` and `/private/var` +- path with spaces +- path with quotes +- Windows drive path +- Windows drive path case dedupe +- UNC path +- trailing slash dedupe +- duplicate path dedupe +- one dotted override per trusted path +- no single `projects={...}` table blob +- app-owned settings deep-merge with forced-login settings +- direct Codex `-c` never appears in Claude launch argv +- `WorkspaceTrustLaunchArgPatch` requires owner, dialect, target surface, and dedupe key +- applying the same app-owned dotted override twice does not duplicate settings values +- pure Anthropic primary provider args never receive Codex native `-c` +- unknown Codex target surface emits `codex_trust_settings_surface_unknown` +- `buildProviderCliCommandArgs(...)` preserves provider facts/default model probe order with merged settings +- sibling `buildInheritedCliFlags` preserves app-owned settings for Anthropic lead -> Codex teammate +- sibling Codex native settings reader validates and bounds `config_overrides` + +Claude state probe: + +- reads `$HOME/.claude.json` +- reads `$CLAUDE_CONFIG_DIR/.claude.json` +- trusts exact path +- trusts parent path +- trusts git root path for selected subdirectory +- mirrors runtime config key normalization +- detects non-persistable home directory +- ignores missing state file +- retries transient JSON parse failure during provider write +- does not expose sensitive keys in diagnostics + +Coordinator: + +- dedupes workspaces +- runs Claude strategy for Codex-only team because orchestrator gate needs it +- returns Codex settings patch only when Codex provider is present +- serializes concurrent preflights for same workspace +- re-probes trust after waiting for same-workspace lock +- lock timeout returns soft failure +- cancellation while waiting for lock returns cancelled +- retry only on `workspace_trust_required` +- can be injected with `setWorkspaceTrustCoordinator` +- dispose kills active PTYs without touching unrelated provider processes +- plan diagnostics do not throw for PTY/provider setup problems +- feature flag parser accepts on/off variants +- malformed feature flag falls back to default with diagnostic +- `planArgsOnly()` and `planFull()` never throw after provider helper material may have been created +- `workspaceTrustDiagnostics` is budgeted before assignment to `ProvisioningRun` +- pure OpenCode runtime-adapter launch does not call the coordinator in v1 +- mixed OpenCode side-lane workspaces are included when the launch still uses the deterministic path +- feature public facade is the only import used by `TeamProvisioningService` +- fake `PtyProcessPort` drives strategy tests without loading `node-pty` +- `NodePtyProcessAdapter` missing module returns skipped diagnostic instead of throwing +- workspace trust feature does not import renderer terminal services or `BrowserWindow` + +Create/launch integration: + +- constructor signature stays unchanged +- fake coordinator receives expected workspaces from `request.cwd` and `member.cwd` in both create and launch +- fake coordinator receives workspaces from `allEffectiveMemberSpecs`, including side-lane/generated worktrees, before filtering primary-lane `effectiveMemberSpecs` +- both `_createTeamInner` and `_launchTeamInner` use the same `prepareWorkspaceTrustForDeterministicRun()` helper +- early settings-only plan runs after `buildProvisioningEnv` and before `materializeEffectiveTeamMemberSpecs` +- default model resolution receives patched Codex settings through a provider args resolver +- Codex settings patch is visible to `resolveAndValidateLaunchIdentity` +- cross-provider Codex settings patch reaches inherited teammate settings +- `progress.launchDiagnostics` is unchanged by preflight in v1 +- preflight progress uses a cancellable state before awaiting PTY +- failure artifact flags include bounded `workspaceTrustPreflight` +- artifact `workspaceTrustPreflight` drops excess workspaces/evidence before manifest write +- artifact flags include no raw env values or unredacted PTY output +- blocked preflight assigns diagnostics before `cleanupRun()` writes artifacts +- cancellation before PTY execute skips preflight and launch cleanly +- cancellation during PTY execute does not continue into `clearPersistedLaunchState` +- `stopTeam()` during PTY execute does not restore config twice or write a second artifact +- blocked launch preflight calls restore-prelaunch-config path +- blocked create preflight does not call restore-prelaunch-config +- blocked create preflight before meta writes does not delete unrelated existing team data +- blocked preflight removes run tracking and retains failed progress +- blocked preflight artifact includes preflight diagnostics +- soft-failed preflight continues to bootstrap writes +- preflight execute does not run with `CLAUDE_ENABLE_DETERMINISTIC_TEAM_BOOTSTRAP` +- preflight execute does not run with app-managed Anthropic helper env +- preflight execute preserves custom `CLAUDE_CONFIG_DIR` but does not force default `~/.claude` +- preflight PTY sessions are not added to `transientProbeProcesses` +- preflight blocked before `clearPersistedLaunchState` preserves previous launch snapshot +- preflight blocked before `clearPersistedLaunchState` does not call `persistLaunchStateSnapshot` +- cleanupRun finalizes failed launch snapshots only when `launchStateClearedForRun` is true +- direct teammate restart paths do not call workspace trust preflight in v1 + +### Focused Existing Tests + +Keep existing focused tests: + +```bash +pnpm vitest run test/main/services/team/TeamLaunchFailureArtifactPack.test.ts +pnpm vitest run test/main/services/team/TeamProvisioningService.test.ts -t "workspace trust" +``` + +Add new focused tests: + +```bash +pnpm vitest run test/main/services/team/workspaceTrust +pnpm vitest run test/main/services/team/TeamProvisioningService.test.ts -t "trust preflight" +``` + +### Manual Compatibility Scripts + +Keep scripts under temp-only probes or document commands. Do not commit user profile mutations. + +Manual scenarios: + +- Claude fresh temp workspace with real profile +- Claude isolated seeded config +- Claude isolated empty config +- Claude real profile with runtime launch using `--dangerously-skip-permissions` after preflight +- Claude with dummy `ANTHROPIC_API_KEY` in shell env +- two teams launching the same fresh workspace concurrently +- Codex TUI real profile new workspace +- Codex TUI per-launch trusted override +- Codex isolated `CODEX_HOME` auth picker +- Codex path escaping with spaces and quotes + +## Risk Register + +| Risk | Severity | Mitigation | +| --- | --- | --- | +| Pressing Enter on unknown prompt | High | allowlisted rules only, no generic Enter | +| Stale terminal snapshots cause wrong phase | High | phase state machine, replayable snapshots, action settle delay | +| Trust accepted for wrong path | High | exact cwd + realpath only, no parent auto-trust | +| Provider auth picker misclassified | Medium | explicit auth rules return `provider_auth_required` with no action | +| Empty Claude onboarding blocks preflight | Medium | setup-required diagnostic, no auto-theme selection in v1 | +| PTY process leak | High | strict timeout and process tree cleanup | +| Windows path serialization bug | Medium | config override builder tests for drive, backslash, quotes | +| OneDrive/EPERM write issue | Medium | do not direct-write trust files in v1; rely on provider write path | +| Multiple launches race trust state | Medium | per provider + realpath lock | +| Retry loops | Medium | one retry only | +| Hidden regression in launch args | High | settings patch tests and narrow integration point | +| Constructor/test churn | Medium | keep constructor unchanged; inject coordinator through setter | +| Progress diagnostic schema drift | Medium | keep trust details out of `TeamLaunchDiagnosticItem` in v1 | +| Runtime security bypass | High | never disable `isPathTrusted`; use provider-owned trust persistence | +| Pre-spawn failure leak | High | centralized `failDeterministicRunBeforeSpawn(...)` helper | +| Preflight runs with runtime env | High | build stripped `trustPreflightEnv` and test removed env keys | +| Same-workspace concurrent launches | Medium | lock by provider + normalized realpath, re-probe after wait | +| Post-trust project side effects | Medium | kill PTY immediately after trust state persists | +| Feature flag drift | Medium | central parser and artifact effective-flags record | +| Raw PTY output leaks account info | Medium | structured diagnostics by default, raw tail only debug + failure + redaction | +| Normal Claude startup executes project MCP after trust | High | protected interactive command with strict empty MCP, user settings only, hooks disabled, tools disabled | +| User cancel unavailable during preflight | Medium | set cancellable progress state before awaiting PTY | +| Stop/shutdown cleans run while preflight awaits | High | cancellation checks and stale-run guard before continuing launch | +| Codex native override persists trust unexpectedly | Medium | mutation sentinel, feature flag rollback, no app-owned restore | +| Create path missed | High | shared deterministic helper and create-specific tests | +| Default model probe uses unpatched Codex settings | High | early settings-only plan and provider args resolver in materialization | +| Cross-provider env helper mixes trust policy | Medium | patch returned args after `buildCrossProviderMemberArgs`, keep env helper unchanged | +| PTY lifecycle treated like `ChildProcess` | High | coordinator-owned `IPty` registry, no `transientProbeProcesses` use | +| Workspace trust flags grow unbounded | Medium | diagnostics budget before assigning to `run.workspaceTrustDiagnostics` | +| Default `CLAUDE_CONFIG_DIR` breaks Keychain auth | High | preserve custom config dir only, never force auto-detected default | +| Pre-run helper material lifecycle widened | Medium | no trust temp files before `ProvisioningRun`; separate helper cleanup hardening if needed | +| Git-root trust key missed | High | probe exact cwd, realpath, git root, and parents with runtime key normalization | +| Home directory trust not persisted | Medium | block home/root broad trust with explicit diagnostic | +| Competitor blind dismiss copied accidentally | High | allowlisted phase engine only; blind dismiss remains out of v1 | +| Preflight-blocked launch overwrites previous snapshot | High | `launchStateClearedForRun` guard before failed launch snapshot finalization | +| Artifact written before trust diagnostics assigned | Medium | assign budgeted diagnostics before failed progress and `cleanupRun()` | +| Direct restart scope creep | Medium | exclude direct restart from v1, keep coordinator reusable through a port | +| Post-trust screens automated accidentally | High | kill PTY after trust persistence; do not automate MCP/include/Grove setup | +| Pending-key no-run phase gets side effects | Medium | `planArgsOnly()` remains pure, no PTY/temp files/sentinels before run exists | +| Codex `-c` passed to Claude CLI | High | typed patch dialect and target surface checks; no Codex native flags on Claude argv | +| Duplicate trust args on fallback/retry | Medium | app-owned `dedupeKey` and exact-sequence dedupe in pure patch applier | +| Deep feature imports from team service | Medium | public facade only, import-boundary test or dependency lint | +| Reusing terminal UI PTY service | Medium | dedicated `NodePtyProcessAdapter`; no `BrowserWindow` or terminal session registry imports | +| Arg dialect drift in sibling runtime | High | characterization tests for `buildInheritedCliFlags` and `buildProviderCliCommandArgs` before default-on | +| Redaction policy drift | Medium | reuse/extract launch artifact redactor and budget before manifest assignment | + +## Highest-Risk Areas After Code Review + +| Area | Why risky | Mitigation | Score | +| --- | --- | --- | --- | +| Cross-provider Codex settings | Codex teammate under Anthropic lead inherits inline settings, not primary provider args | pure patch helper plus sibling inherited-settings tests | 🎯 9 / 🛡️ 9 / 🧠 6 | +| Create vs launch drift | first team creation and later relaunch have similar but separate code paths | shared helper plus typed cleanup policy tests | 🎯 9 / 🛡️ 9 / 🧠 7 | +| Default model probe order | `materializeEffectiveTeamMemberSpecs()` can call provider commands before final plan exists | `planArgsOnly()` provider args resolver | 🎯 8 / 🛡️ 9 / 🧠 6 | +| Launch identity validation order | `readRuntimeProviderLaunchFacts()` runs before final launch args are built | run `planFull()` and apply provider settings patches before validation | 🎯 8 / 🛡️ 9 / 🧠 6 | +| Preflight before `ProvisioningRun` exists | failures before run creation lose progress/artifact context | make planning non-throwing; run PTY only after run creation | 🎯 9 / 🛡️ 9 / 🧠 5 | +| Relaunch retry | failure handler already owns cleanup and retained state | keep retry out of v1 default; implement later via normal launch entrypoint | 🎯 7 / 🛡️ 7 / 🧠 8 | +| Claude empty onboarding | auto-selecting theme is not workspace trust and can surprise users | return `provider_setup_required`; do not press keys | 🎯 8 / 🛡️ 9 / 🧠 4 | +| Optional `node-pty` | native dependency may fail to load on some systems | adapter import guard and skipped diagnostic | 🎯 9 / 🛡️ 8 / 🧠 4 | +| Direct config writes | private state formats can change and writes can race provider process | no direct `.claude.json` or Codex config writes in v1 | 🎯 8 / 🛡️ 9 / 🧠 3 | +| Progress schema | fixed diagnostic code union can break TS/UI if extended casually | artifact `flags` first, UI rows later | 🎯 9 / 🛡️ 10 / 🧠 3 | +| Service constructor | many tests use positional constructor args | setter/lazy default instead of constructor param | 🎯 9 / 🛡️ 9 / 🧠 3 | +| Pre-spawn cleanup | new failure point after run creation can leak config/run state | `failDeterministicRunBeforeSpawn(...)` plus tests | 🎯 8 / 🛡️ 9 / 🧠 6 | +| Runtime env contamination | PTY preflight could see team runtime helper env | stripped preflight env | 🎯 8 / 🛡️ 9 / 🧠 5 | +| Same-workspace lock | team lock is per team, not per cwd | workspace trust lock registry | 🎯 9 / 🛡️ 9 / 🧠 5 | +| Post-trust exit | provider may continue into project setup after trust | success probe then immediate kill | 🎯 8 / 🛡️ 9 / 🧠 5 | +| Project MCP execution after trust | normal Claude startup can load project MCP/settings after trust | protected command: `--bare`, strict empty MCP, user settings only, hooks off, tools off | 🎯 8 / 🛡️ 9 / 🧠 6 | +| Cancel blocked during preflight | run starts in `validating`, which current cancel API rejects | switch to cancellable `spawning` before PTY await | 🎯 9 / 🛡️ 9 / 🧠 4 | +| Stop/shutdown race after cleanup | `stopTeam()` can cleanup run while create/launch awaits preflight | stale-run guard after execute and before clearing launch state | 🎯 8 / 🛡️ 9 / 🧠 5 | +| Codex native override mutates config | recent Codex issue reports non-ephemeral project trust override behavior | mutation sentinel, feature flag, no app direct write | 🎯 7 / 🛡️ 8 / 🧠 5 | +| PTY ownership | `node-pty` returns `IPty`, not a normal spawned child | adapter-owned lifecycle and coordinator session registry | 🎯 9 / 🛡️ 9 / 🧠 5 | +| Artifact flag size | flexible manifest flags can accidentally carry too much data | pre-budget diagnostics before artifact write | 🎯 9 / 🛡️ 9 / 🧠 3 | +| Claude config env boundary | forcing default `CLAUDE_CONFIG_DIR` can break OAuth Keychain lookup | preserve custom only, test preflight env builder | 🎯 8 / 🛡️ 9 / 🧠 4 | +| Runtime key mismatch | Claude may persist trust at git root while selected cwd is a child | mirror runtime key normalization and parent walk | 🎯 8 / 🛡️ 9 / 🧠 6 | +| Home/root workspace | provider may not persist home trust and broad trust would be unsafe | explicit non-persistable diagnostic, no parent auto-trust | 🎯 9 / 🛡️ 10 / 🧠 3 | +| Dialog vs ready precedence | competitor tests show trust text can coexist with ready-looking text | dialog-first phase machine and snapshot replay tests | 🎯 9 / 🛡️ 9 / 🧠 4 | +| Codex contract dialect | Codex `-c` is valid only at the Codex binary boundary, while deterministic launch uses Claude settings | typed patch dialect plus sibling settings contract tests | 🎯 9 / 🛡️ 9 / 🧠 6 | +| Patch idempotency | early plan, full plan, and future retry can append the same override more than once | owner/dedupeKey and exact-sequence dedupe | 🎯 9 / 🛡️ 9 / 🧠 4 | +| Feature boundary | easy to deep-import adapters from a large service during implementation pressure | public facade and import-boundary test | 🎯 9 / 🛡️ 9 / 🧠 4 | +| PTY adapter boundary | existing terminal service looks reusable but owns renderer session semantics | dedicated adapter and fake port tests | 🎯 9 / 🛡️ 9 / 🧠 5 | +| Pure OpenCode adapter boundary | adding desktop preflight there can mix runtime-adapter ownership with legacy deterministic launch | v1 coordinator only in deterministic create/launch, adapter test asserts not called | 🎯 9 / 🛡️ 9 / 🧠 5 | +| Side-lane workspace omission | filtering to `effectiveMemberSpecs` can drop mixed OpenCode generated worktrees | collect from `allEffectiveMemberSpecs` before lane filtering | 🎯 9 / 🛡️ 9 / 🧠 5 | +| Codex projects table clobber | one `projects={...}` override can replace unrelated project config at the override layer | repeatable dotted overrides only | 🎯 9 / 🛡️ 9 / 🧠 4 | +| Launch snapshot guard | current `cleanupRun()` can persist a failed launch snapshot for a preflight-only failure | add and test `launchStateClearedForRun` | 🎯 9 / 🛡️ 10 / 🧠 3 | +| Direct restart exclusion | process/tmux restart paths are separate from create/launch but can look similar | document as v2 and assert v1 coordinator not called there | 🎯 9 / 🛡️ 9 / 🧠 3 | + +## Risk Burn-Down Plan + +This is the concrete plan for reducing the remaining risk before default-on. Do not treat the feature as done just because unit tests pass. Each gate below must pass in order. + +### Gate 0 - Freeze Current Failure Diagnostics + +Goal: preserve today's behavior when auto-preflight is disabled. + +Actions: + +- keep `workspace_trust_required` classification first in artifact pack classification. +- keep deterministic bootstrap title `Workspace trust required`. +- keep exact runtime error in `progress.error`. +- add no renderer schema changes in this gate. + +Required tests: + +- issue #100 text classifies as `workspace_trust_required`. +- issue #104 text still classifies as `workspace_trust_required`. +- disabling all workspace-trust flags produces the same launch args and progress shape as before. + +Stop rule: + +- if disabled flags change launch behavior, stop and fix before adding PTY or settings contract. + +Rating: 🎯 10 🛡️ 10 🧠 2, ~20-60 LOC. + +### Gate 1 - Pure Core Only + +Goal: prove path, settings, diagnostics, and patch logic before any process is spawned. + +Actions: + +- implement path canonicalization and trust workspace collection as pure functions. +- implement `CodexConfigOverrideBuilder` as pure value builder, not CLI argv builder. +- implement `WorkspaceTrustLaunchArgPatch` with owner, dialect, target surface, and dedupe key. +- implement settings patch applier as a pure function. +- implement diagnostics budget as a pure function. + +Required tests: + +- path with spaces, quotes, brackets, backslashes, Windows drive, UNC, symlink, git child directory. +- duplicate cwd/realpath/git-root candidates dedupe predictably. +- app-owned settings deep-merge with existing Codex forced-login settings. +- exact same patch applied twice produces one settings payload. +- Anthropic-only provider matrix produces no Codex settings. +- malformed patch surface returns diagnostic, not throw. + +Stop rule: + +- if a pure helper needs `TeamProvisioningService`, filesystem side effects, or provider processes, move it behind a port before continuing. + +Rating: 🎯 9 🛡️ 10 🧠 4, ~180-320 LOC plus tests. + +### Gate 2 - Sibling Codex Settings Contract + +Goal: make the Codex path safe before desktop uses it. + +Actions: + +- add sibling runtime reader for `codex.agent_teams_workspace_trust.config_overrides`. +- validate only bounded strings matching `projects."".trust_level="trusted"`. +- append validated values to Codex native `configOverrides` inside `turnExecutor`. +- never pass direct Codex `-c` through Claude/orchestrator argv. +- keep malformed settings as skipped diagnostics. + +Required tests in sibling runtime: + +- valid settings become `configOverrides`. +- malformed values are ignored: newline, NUL, unrelated key, non-string, over-limit. +- existing MCP config overrides remain. +- existing forced-login settings remain. +- `buildInheritedCliFlags()` preserves inline settings from Anthropic lead to Codex teammate. +- Claude argv snapshot contains no Codex native `-c`. + +Stop rule: + +- if settings do not survive Anthropic lead -> Codex teammate inheritance, keep `AGENT_TEAMS_WORKSPACE_TRUST_CODEX_SETTINGS=0`. + +Rating: 🎯 9 🛡️ 9 🧠 6, ~50-120 sibling LOC plus tests. + +### Gate 3 - Claude PTY Strategy In Isolation + +Goal: prove prompt automation without touching team launch. + +Actions: + +- implement `PtyDialogEngine` with fake snapshots. +- implement `NodePtyProcessAdapter` behind `PtyProcessPort`. +- implement protected command builder: + `claude --bare --strict-mcp-config --mcp-config --setting-sources user --settings '{"disableAllHooks":true}' --tools ""` +- implement state probe that mirrors sibling runtime parent walk. +- kill PTY immediately after trust persists. + +Required tests: + +- unknown screen sends no keys. +- trust screen sends one Enter only. +- stale update/trust output cannot repeat actions forever. +- auth picker and onboarding return setup/auth required with no key action. +- `node-pty` missing returns skipped diagnostic. +- cancellation kills PTY and returns cancelled. +- temp MCP config is removed on success, failure, timeout, and cancellation. +- raw tail is absent unless debug + failed/blocked + redacted. + +Manual smoke: + +- fresh temp git repo with real Claude profile accepts trust and writes `hasTrustDialogAccepted`. +- repeated run is no-op. +- selected git subdirectory is recognized through git-root key. +- home directory returns non-persistable diagnostic. + +Stop rule: + +- if any unknown prompt receives Enter, disable Claude PTY and fix the rule set before launch integration. + +Rating: 🎯 8 🛡️ 9 🧠 6, ~250-420 LOC plus tests. + +### Gate 4 - Create/Launch Integration Behind Flags + +Goal: wire into `TeamProvisioningService` without changing normal lifecycle semantics. + +Actions: + +- inject coordinator through setter/lazy getter, not constructor. +- keep `planArgsOnly()` side-effect free before run creation. +- run PTY execute only after `ProvisioningRun` exists. +- set progress to cancellable `spawning` before awaiting PTY. +- execute before `clearPersistedLaunchState`. +- set `launchStateClearedForRun = true` immediately after clear succeeds. +- assign budgeted diagnostics before failed progress and before `cleanupRun()`. +- use typed cleanup policy for create vs launch. +- keep pure OpenCode adapter and direct restart paths out of v1. + +Required tests: + +- create path calls shared helper once. +- launch path calls shared helper once. +- pure OpenCode adapter path does not call coordinator. +- direct tmux/process restart paths do not call coordinator. +- blocked create before meta writes does not remove unrelated team/task dirs. +- blocked launch restores prelaunch config. +- blocked launch before clear does not persist failed launch snapshot. +- cancellation during PTY does not continue into clear/spawn. +- stop/shutdown during PTY does not restore twice or write two artifacts. +- artifact includes `workspaceTrustPreflight` when blocked. + +Stop rule: + +- if any blocked preflight can erase previous launch state, do not ship. + +Rating: 🎯 8 🛡️ 9 🧠 7, ~220-380 LOC plus tests. + +### Gate 5 - Real Smoke Matrix + +Goal: verify real provider behavior that unit tests cannot prove. + +Run with feature flags on and retry off: + +```text +AGENT_TEAMS_WORKSPACE_TRUST_PREFLIGHT=1 +AGENT_TEAMS_WORKSPACE_TRUST_CLAUDE_PTY=1 +AGENT_TEAMS_WORKSPACE_TRUST_CODEX_SETTINGS=1 +AGENT_TEAMS_WORKSPACE_TRUST_RETRY=0 +``` + +Smoke cases: + +- Claude fresh temp git repo, Anthropic-only team. +- Claude fresh temp git repo, Codex-only team, because orchestrator trust still gates headless teammates. +- Anthropic lead with Codex teammate. +- Codex lead with Anthropic teammate. +- selected subdirectory inside git repo. +- symlinked workspace. +- two concurrent launches for same fresh workspace. +- cancel during preflight. +- stop team during preflight. +- missing optional `node-pty` simulation. +- isolated `CODEX_HOME` with auth picker. +- Codex config mutation sentinel before/after hash. + +Pass criteria: + +- no issue #100 trust error on fresh trusted profile cases. +- no direct Codex `-c` in Claude argv snapshots. +- no unknown prompts receive keys. +- no previous launch snapshot is overwritten by blocked preflight. +- no unredacted env/config/raw account data appears in artifact. + +Stop rule: + +- if smoke fails only in Codex settings contract, ship Claude PTY with `AGENT_TEAMS_WORKSPACE_TRUST_CODEX_SETTINGS=0`. +- if smoke fails in Claude PTY prompt automation, ship diagnostics-only with PTY disabled. + +Rating: 🎯 8 🛡️ 10 🧠 5, mostly test/smoke time. + +### Gate 6 - Default-On Criteria + +Do not enable by default until all are true: + +- Gate 0-5 pass on macOS. +- Windows path unit tests pass even if live Windows smoke is not available. +- sibling runtime contract tests pass. +- artifact copy has enough data to diagnose field failures without raw secrets. +- feature flags can disable Claude PTY and Codex settings independently. +- existing focused team provisioning tests pass. + +Minimum verification before default-on: + +```bash +pnpm vitest run test/main/services/runtime/cliSettingsArgs.test.ts +pnpm vitest run test/main/services/team/TeamLaunchFailureArtifactPack.test.ts +pnpm vitest run test/main/services/team/TeamProvisioningService.test.ts -t "workspace trust" +pnpm vitest run test/main/services/team/workspaceTrust +``` + +Sibling runtime minimum: + +```bash +bun test src/utils/swarm/spawnUtils.test.ts +bun test src/services/codexNative/mcpConfigBridge.test.ts +bun test src/services/codexNative/execRunner.test.ts +``` + +Stop rule: + +- no default-on without a documented smoke artifact from at least one fresh workspace run. + +Rating: 🎯 9 🛡️ 10 🧠 4, mostly verification. + +## Residual Risk Playbook + +Use this table when something fails during implementation or field testing. + +| Symptom | Likely cause | Immediate action | Long-term fix | +| --- | --- | --- | --- | +| Claude trust prompt still appears in runtime | PTY did not persist trust or path key mismatch | keep runtime failure classification, inspect `workspaceTrustPreflight` | add path candidate or state probe test | +| Claude opens onboarding/theme screen | profile setup missing, not workspace trust | return `provider_setup_required`, no key action | optional future UI setup guidance | +| Unknown PTY screen | provider version changed | no action, timeout/soft fail | add snapshot test only after human review | +| Previous launch state disappears after blocked preflight | lifecycle guard missed | block release | fix `launchStateClearedForRun` and cleanup tests | +| Create deletes existing team files | create cleanup policy too broad | block release | track create-owned directories only | +| Codex teammate still prompts trust | settings did not reach sibling native exec | disable Codex settings flag | fix inherited settings contract and sibling validator | +| Claude argv contains Codex `-c` | wrong dialect applied | block release | fix patch target surface and add snapshot test | +| Duplicate settings payload | early/full patch dedupe failed | keep feature off | fix `dedupeKey` applier | +| Raw account/config data in artifact | redaction path drifted | block release | reuse/extract launch artifact redactor | +| PTY process remains alive | adapter cleanup bug | disable Claude PTY | add finally kill/process-tree tests | +| User cannot cancel preflight | progress state not cancellable | block release | set `spawning` before await and test IPC cancel | + +## Final Confidence Gates + +Top 3 hard gates before implementation can be considered low-risk: + +1. Lifecycle gate: blocked preflight cannot clear or overwrite previous launch state - 🎯 9 🛡️ 10 🧠 6, ~40-90 LOC plus tests. +2. Dialect gate: Codex native `-c` appears only inside sibling Codex native exec - 🎯 9 🛡️ 10 🧠 5, ~60-120 LOC plus tests. +3. Prompt gate: unknown PTY screens never receive key actions - 🎯 8 🛡️ 10 🧠 6, ~120-220 LOC plus snapshot tests. + +If any of these three gates fails, keep the feature behind flags and ship only improved diagnostics. + +## Implementation Slicing And Freeze Rules + +This feature should not be implemented as one large diff. Split the work so every slice can be reviewed and rolled back independently. + +### Slice A - Diagnostics Baseline + +Scope: + +- keep existing `workspace_trust_required` classification stable. +- keep deterministic bootstrap title stable. +- add no new launch behavior. + +Allowed files: + +- launch failure artifact pack +- deterministic bootstrap event handling tests +- docs/runbook if needed + +Forbidden: + +- no `node-pty` +- no launch arg/settings mutation +- no `TeamProvisioningService` lifecycle edits except tests for current behavior + +Exit criteria: + +- focused tests pass. +- disabled-feature behavior is identical to current behavior. + +Rating: 🎯 10 🛡️ 10 🧠 2, ~20-60 LOC. + +### Slice B - Sibling Codex Contract + +Scope: + +- add sibling runtime settings reader. +- validate `codex.agent_teams_workspace_trust.config_overrides`. +- append validated values to Codex native `configOverrides`. +- prove `buildInheritedCliFlags()` preserves inline settings. + +Allowed files: + +- sibling runtime Codex native settings/turn executor utilities +- sibling runtime tests + +Forbidden: + +- no desktop launch integration yet +- no direct Codex global config writes +- no Claude CLI argv `-c` + +Exit criteria: + +- sibling tests pass. +- malformed settings skip safely. +- valid settings reach Codex native `execRunner`. + +Rating: 🎯 9 🛡️ 9 🧠 6, ~50-120 sibling LOC. + +### Slice C - Desktop Pure Workspace-Trust Core + +Scope: + +- feature shell and contracts. +- pure path/canonicalization helpers. +- pure Codex settings patch builder. +- diagnostics budget/redaction adapter. +- fake-only coordinator planning tests. + +Allowed files: + +- `src/features/workspace-trust/**` +- focused unit tests +- no integration into launch service except type-only public facade exports + +Forbidden: + +- no `node-pty` +- no `TeamProvisioningService` behavior change +- no real filesystem writes except temp test fixtures + +Exit criteria: + +- all pure tests pass. +- no import from feature internals by team service. +- path edge cases pass. + +Rating: 🎯 9 🛡️ 10 🧠 5, ~250-450 LOC. + +### Slice D - Claude PTY Strategy Isolated + +Scope: + +- `PtyDialogEngine` +- `NodePtyProcessAdapter` +- protected Claude command builder +- temp empty MCP config store +- Claude state probe +- fake PTY tests and optional manual smoke + +Allowed files: + +- workspace-trust feature adapters +- isolated workspace-trust tests + +Forbidden: + +- no create/launch integration yet +- no blind prompt dismissal +- no direct provider state file writes + +Exit criteria: + +- unknown screens never receive key actions. +- temp files are removed in all paths. +- manual smoke can accept a fresh trusted temp workspace. + +Rating: 🎯 8 🛡️ 9 🧠 6, ~300-520 LOC. + +### Slice E - TeamProvisioningService Integration Behind Flags + +Scope: + +- setter/lazy coordinator. +- early settings-only plan. +- full plan. +- execute preflight after `ProvisioningRun`, before clear. +- `launchStateClearedForRun`. +- typed create/launch failure policy. +- artifact flags. + +Allowed files: + +- `TeamProvisioningService` +- focused team provisioning tests +- feature facade composition + +Forbidden: + +- no automatic retry. +- no UI schema changes. +- no direct restart integration. +- no pure OpenCode runtime adapter integration. + +Exit criteria: + +- all Gate 4 tests pass. +- feature disabled restores current behavior. +- blocked preflight cannot erase previous launch state. + +Rating: 🎯 8 🛡️ 9 🧠 8, ~250-450 LOC. + +### Slice F - Smoke, Rollout, And Default-On + +Scope: + +- real smoke matrix. +- artifact sample review. +- feature flag defaults. +- docs/runbook update. + +Forbidden: + +- no retry default-on. +- no fallback direct config write. +- no default-on until fresh workspace smoke is documented. + +Exit criteria: + +- Gate 5 and Gate 6 pass. +- rollback flags verified. + +Rating: 🎯 8 🛡️ 10 🧠 4, mostly verification. + +### Merge Freeze Rules + +Do not merge a slice if any of these are true: + +- it changes launch behavior when all workspace trust flags are disabled. +- it adds Codex native `-c` to Claude argv. +- it makes `TeamProvisioningService` import feature internals instead of public facade. +- it presses a key on an unknown PTY screen. +- it writes `.claude.json` or `~/.codex/config.toml` directly. +- it expands `TeamLaunchDiagnosticItem.code` without renderer tests. +- it changes pure OpenCode adapter launch behavior. +- it makes direct teammate restart call the coordinator in v1. +- it leaves a PTY process running after cancellation/timeout tests. +- it can clear persisted launch state before preflight success. + +### Review Order + +Review in this order to catch expensive mistakes early: + +1. sibling Codex contract and no-Claude-`-c` proof. +2. pure workspace/path/settings tests. +3. PTY prompt state machine snapshots. +4. `TeamProvisioningService` lifecycle placement. +5. artifact redaction and diagnostics. +6. real smoke artifacts. + +Top 3 implementation strategies: + +1. Strict slices A-F with freeze rules - 🎯 9 🛡️ 10 🧠 7, ~1000-1600 desktop LOC plus sibling LOC. Chosen because every risky behavior has a gate. +2. One feature branch with all code behind flags - 🎯 7 🛡️ 7 🧠 6, same LOC. Faster locally, but review risk is much higher. +3. Diagnostics-only plus manual user instruction - 🎯 6 🛡️ 9 🧠 2, ~40-80 LOC. Very safe but does not solve the product goal. + +## Feature Flags And Rollback + +Use independent switches so a bad provider path can be disabled without reverting the whole feature. + +```text +AGENT_TEAMS_WORKSPACE_TRUST_PREFLIGHT=0 +AGENT_TEAMS_WORKSPACE_TRUST_CODEX_SETTINGS=0 +AGENT_TEAMS_WORKSPACE_TRUST_CLAUDE_PTY=0 +AGENT_TEAMS_WORKSPACE_TRUST_ALLOW_UNPROTECTED_CLAUDE=0 +AGENT_TEAMS_WORKSPACE_TRUST_RETRY=0 +AGENT_TEAMS_WORKSPACE_TRUST_FILE_LOCK=0 +AGENT_TEAMS_WORKSPACE_TRUST_DEBUG=0 +``` + +Default rollout: + +- internal smoke: preflight on, retry off +- first release: Claude PTY on, Codex settings contract on after sibling tests, retry off +- later release: retry on only if artifact data shows remaining trust failures are recoverable + +Rollback behavior: + +- disabling Claude PTY keeps current launch path and diagnostics +- disabling Codex settings removes only app-owned Codex workspace-trust settings and sibling native config overrides +- disabling unprotected Claude fallback is the default; keep it disabled unless debugging a specific old Claude build +- disabling retry keeps first-launch preflight and existing failure classification +- disabling file lock keeps in-process lock behavior +- disabling debug suppresses raw PTY tails even on failure + +## Recommended V1 Defaults + +1. Always run Claude workspace trust preflight for team launch workspaces. +2. Use the protected Claude interactive command, not plain `claude`, for PTY preflight. +3. Add Codex workspace-trust settings when Codex provider is present, and let sibling runtime convert validated values to Codex native `configOverrides`. +4. Keep Codex PTY fallback implemented in rules/tests but not necessarily used in the main path unless direct Codex TUI launch needs it. +5. Do not direct-write `.claude.json` or `~/.codex/config.toml`. +6. Keep automatic retry off in v1 default; add it later only for `workspace_trust_required`. +7. Keep current user-visible diagnostics if all preflight paths fail. +8. Use workspace trust locks by provider + realpath. +9. Kill Claude PTY as soon as trust state persists. +10. Treat home/root workspaces as non-persistable for v1, not as broad trust targets. +11. Apply Codex trust settings only through typed dialect-aware patches. +12. Keep `node-pty` behind a dedicated adapter, not behind terminal UI services. + +## V1 Cut Line + +Ship in first implementation PR: + +- `src/features/workspace-trust` feature shell +- domain types +- path canonicalization, git-root candidate detection, and parent trust matching +- workspace trust lock registry +- feature flag parser +- Codex config override builder +- Codex config mutation sentinel +- pure provider settings patch helpers +- typed `WorkspaceTrustLaunchArgPatch` with owner, dialect, target surface, and dedupe key +- settings dialect/surface resolver for Codex-consuming invocation surfaces +- early settings-only provider args resolver for default model probes +- full plan patching for provider facts, primary settings, and cross-provider settings +- PTY dialog engine with fake terminal tests +- dedicated `NodePtyProcessAdapter` behind `PtyProcessPort` +- Claude state probe +- Claude protected command builder +- temp empty MCP config writer and cleanup +- Claude PTY strategy behind feature flag +- `TeamProvisioningService` create/launch plan/execute integration without retry +- `prepareWorkspaceTrustForDeterministicRun(...)` shared helper +- `failDeterministicRunBeforeSpawn(...)` cleanup helper +- typed create/launch cleanup policy +- stale-run guard after preflight execute +- `launchStateClearedForRun` cleanup guard +- stripped `trustPreflightEnv` +- artifact `flags.workspaceTrustPreflight` +- workspace trust diagnostics budget before assigning artifact flags +- non-persistable home/root workspace diagnostic +- raw PTY tail disabled by default +- shared or extracted launch artifact redaction helper for workspace trust diagnostics +- public feature facade import boundary +- sibling runtime settings reader that validates Codex workspace-trust config override values +- sibling runtime tests for inherited settings and native `configOverrides` +- focused tests for primary Codex and cross-provider Codex settings +- focused tests for Codex settings dialect, duplicate patch dedupe, and Anthropic-only no-op behavior +- focused tests for `node-pty` unavailable behavior +- focused tests for pure OpenCode adapter boundary and mixed side-lane workspace inclusion + +Do not ship in first implementation PR: + +- automatic relaunch retry +- direct `.claude.json` writes +- direct `~/.codex/config.toml` writes +- UI diagnostic row code changes +- OpenCode runtime adapter changes +- direct teammate restart preflight +- tmux dependency +- blind dialog dismiss on healthy launches + +Definition of done: + +- issue #100 text no longer appears for a fresh workspace when Claude profile is otherwise set up, for both first create and relaunch +- issue #104-like failures still classify as `workspace_trust_required` if preflight cannot clear trust +- Codex teammates under Anthropic lead receive scoped app-owned Codex workspace-trust settings +- sibling Codex native executor receives validated `projects."".trust_level="trusted"` values through `configOverrides` +- Codex teammates without explicit model receive patched settings during default model resolution +- pure Anthropic provider args never receive Codex native `-c` overrides +- repeated early/full/future retry patch application does not duplicate app-owned Codex trust settings +- disabling all workspace trust feature flags returns to current launch behavior +- no new prompt receives keys unless its rule is allowlisted and tested +- two concurrent launches for the same fresh workspace result in one PTY accept and one `already_trusted_after_wait` +- Claude preflight command is protected and does not use `-p`, `doctor`, runtime bootstrap args, project/local settings, or project MCP +- stopping launch during preflight does not clear previous launch state or continue into spawn +- blocked launch preflight does not persist a new failed launch snapshot when `clearPersistedLaunchState()` was not reached +- blocked create preflight before meta writes leaves no partial team metadata/tasks directories +- selected git subdirectory works when Claude persists trust at the git-root key +- selected home directory never causes the app to trust home/root automatically +- missing optional `node-pty` produces a diagnostic and keeps existing runtime fallback behavior + +## Open Questions + +1. Should empty Claude onboarding be auto-accepted later? + - Recommendation: no for v1. It is provider setup, not workspace trust. + +2. Should we add direct `.claude.json` fallback? + - Recommendation: no for v1. Keep it as a documented emergency fallback behind a future feature flag only. + +3. Should Codex PTY fallback run automatically? + - Recommendation: not for the current team launch path if settings -> sibling native config override contract is available. Keep the engine capable of it for future direct Codex TUI flows. + +4. Should we trust all member worktrees? + - Recommendation: yes, but only exact selected/generated worktree paths used by the launch. + +5. Should plain `claude` fallback be allowed when protected flags are missing? + - Recommendation: no by default. Add an explicit experimental flag because normal startup can load project behavior after trust. + +## Final Decision + +Implement **Host-preflight + runtime contract**. + +The desktop app prepares exact deterministic launch workspaces. The orchestrator remains the runtime executor and keeps its trust gate. Claude workspace trust is prepared through a bounded protected `node-pty` warmup using a dedicated adapter, `--bare`, strict empty MCP config, user settings only, hooks disabled, and tools disabled, then exits as soon as trust persists. Codex receives scoped app-owned workspace-trust settings through typed dialect-aware patches with dedupe; the sibling runtime validates those settings and converts them to Codex native `configOverrides` only at the Codex binary boundary. Pure OpenCode runtime-adapter launch and direct teammate restart stay out of v1. Launch retries stay off in v1 and remain narrowly limited to trust failures later. `launchStateClearedForRun` prevents a preflight-only failure from overwriting the previous launch snapshot. diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.ts b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.ts index 0115c116..49439b5c 100644 --- a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.ts +++ b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.ts @@ -1,8 +1,17 @@ +import { + isReviewPickupAgenda, + isStrictReviewPickupItem, +} from './MemberWorkSyncNudgeAgendaPredicates'; +import { + decideMemberWorkSyncTargetedRecovery, + type MemberWorkSyncTargetedRecoveryReason, +} from './MemberWorkSyncTargetedRecoveryPolicy'; + import type { MemberWorkSyncStatus, MemberWorkSyncTeamMetrics } from '../../contracts'; export type MemberWorkSyncNudgeActivationReason = | 'shadow_ready' - | 'opencode_targeted_shadow_collecting' + | MemberWorkSyncTargetedRecoveryReason | 'review_pickup_required' | 'status_not_nudgeable' | 'blocking_metrics' @@ -23,31 +32,6 @@ function hasBlockingMetrics(metrics: MemberWorkSyncTeamMetrics): boolean { return metrics.phase2Readiness.reasons.some((reason) => BLOCKING_PHASE2_REASONS.has(reason)); } -function isOpenCodeTargetedCandidate(status: MemberWorkSyncStatus): boolean { - return ( - status.providerId === 'opencode' && - status.state === 'needs_sync' && - status.agenda.items.length > 0 && - !isReviewPickupAgenda(status) && - status.shadow?.wouldNudge === true - ); -} - -function isStrictReviewPickupItem(item: MemberWorkSyncStatus['agenda']['items'][number]): boolean { - return ( - item.kind === 'review' && - item.evidence.reviewObligation === 'review_pickup_required' && - item.evidence.canBypassPhase2 === true && - typeof item.evidence.reviewRequestEventId === 'string' && - item.evidence.reviewRequestEventId.length > 0 && - (item.evidence.reviewDiagnostics?.length ?? 0) === 0 - ); -} - -function isReviewPickupAgenda(status: MemberWorkSyncStatus): boolean { - return status.agenda.items.length > 0 && status.agenda.items.every(isStrictReviewPickupItem); -} - function isReviewPickupRequiredCandidate(status: MemberWorkSyncStatus): boolean { return ( status.state === 'needs_sync' && @@ -72,6 +56,11 @@ export function decideMemberWorkSyncNudgeActivation(input: { return { active: true, reason: 'review_pickup_required' }; } + const targetedRecovery = decideMemberWorkSyncTargetedRecovery(input.status); + if (targetedRecovery.active) { + return { active: true, reason: targetedRecovery.reason }; + } + if (hasBlockingMetrics(input.metrics)) { return { active: false, reason: 'blocking_metrics' }; } @@ -84,12 +73,5 @@ export function decideMemberWorkSyncNudgeActivation(input: { return { active: true, reason: 'shadow_ready' }; } - if ( - input.metrics.phase2Readiness.state === 'collecting_shadow_data' && - isOpenCodeTargetedCandidate(input.status) - ) { - return { active: true, reason: 'opencode_targeted_shadow_collecting' }; - } - return { active: false, reason: 'phase2_not_ready' }; } diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeAgendaPredicates.ts b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeAgendaPredicates.ts new file mode 100644 index 00000000..c8abacf4 --- /dev/null +++ b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeAgendaPredicates.ts @@ -0,0 +1,18 @@ +import type { MemberWorkSyncStatus } from '../../contracts'; + +export function isStrictReviewPickupItem( + item: MemberWorkSyncStatus['agenda']['items'][number] +): boolean { + return ( + item.kind === 'review' && + item.evidence.reviewObligation === 'review_pickup_required' && + item.evidence.canBypassPhase2 === true && + typeof item.evidence.reviewRequestEventId === 'string' && + item.evidence.reviewRequestEventId.length > 0 && + (item.evidence.reviewDiagnostics?.length ?? 0) === 0 + ); +} + +export function isReviewPickupAgenda(status: MemberWorkSyncStatus): boolean { + return status.agenda.items.length > 0 && status.agenda.items.every(isStrictReviewPickupItem); +} diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncTargetedRecoveryPolicy.ts b/src/features/member-work-sync/core/application/MemberWorkSyncTargetedRecoveryPolicy.ts new file mode 100644 index 00000000..bec5c21c --- /dev/null +++ b/src/features/member-work-sync/core/application/MemberWorkSyncTargetedRecoveryPolicy.ts @@ -0,0 +1,69 @@ +import { isReviewPickupAgenda } from './MemberWorkSyncNudgeAgendaPredicates'; + +import type { MemberWorkSyncStatus } from '../../contracts'; + +export type MemberWorkSyncTargetedRecoveryReason = + | 'opencode_targeted_shadow_collecting' + | 'lead_targeted_shadow_collecting'; + +export type MemberWorkSyncTargetedRecoveryCapability = + | 'opencode_runtime_delivery' + | 'lead_inbox_relay'; + +export type MemberWorkSyncTargetedRecoveryDecision = + | { + active: true; + reason: MemberWorkSyncTargetedRecoveryReason; + capability: MemberWorkSyncTargetedRecoveryCapability; + } + | { active: false }; + +function isLeadLikeMemberName(memberName: string): boolean { + const normalized = memberName + .trim() + .toLowerCase() + .replace(/[\s_]+/g, '-'); + return ( + normalized === 'lead' || + normalized === 'team-lead' || + normalized === 'teamlead' || + normalized === 'team-leader' + ); +} + +function resolveTargetedRecoveryCapability(status: MemberWorkSyncStatus): { + capability: MemberWorkSyncTargetedRecoveryCapability; + reason: MemberWorkSyncTargetedRecoveryReason; +} | null { + if (status.providerId === 'opencode') { + return { + capability: 'opencode_runtime_delivery', + reason: 'opencode_targeted_shadow_collecting', + }; + } + + if (isLeadLikeMemberName(status.memberName)) { + return { + capability: 'lead_inbox_relay', + reason: 'lead_targeted_shadow_collecting', + }; + } + + return null; +} + +export function decideMemberWorkSyncTargetedRecovery( + status: MemberWorkSyncStatus +): MemberWorkSyncTargetedRecoveryDecision { + if ( + status.state !== 'needs_sync' || + status.shadow?.wouldNudge !== true || + status.agenda.items.length === 0 || + isReviewPickupAgenda(status) + ) { + return { active: false }; + } + + const target = resolveTargetedRecoveryCapability(status); + return target ? { active: true, ...target } : { active: false }; +} diff --git a/src/features/member-work-sync/core/application/index.ts b/src/features/member-work-sync/core/application/index.ts index b1f2c6d6..8232db8a 100644 --- a/src/features/member-work-sync/core/application/index.ts +++ b/src/features/member-work-sync/core/application/index.ts @@ -2,11 +2,13 @@ export * from './MemberWorkSyncAudit'; export * from './MemberWorkSyncDiagnosticsReader'; export * from './MemberWorkSyncMetricsReader'; export * from './MemberWorkSyncNudgeActivationPolicy'; +export * from './MemberWorkSyncNudgeAgendaPredicates'; export * from './MemberWorkSyncNudgeDispatcher'; export * from './MemberWorkSyncNudgeOutboxPlanner'; export * from './MemberWorkSyncPendingReportIntentReplayer'; export * from './MemberWorkSyncReconciler'; export * from './MemberWorkSyncReporter'; +export * from './MemberWorkSyncTargetedRecoveryPolicy'; export type * from './ports'; export * from './RuntimeTurnSettledIngestor'; export type * from './RuntimeTurnSettledPorts'; diff --git a/src/features/member-work-sync/core/domain/MemberWorkSyncNudge.ts b/src/features/member-work-sync/core/domain/MemberWorkSyncNudge.ts index b6d9145f..17a05cca 100644 --- a/src/features/member-work-sync/core/domain/MemberWorkSyncNudge.ts +++ b/src/features/member-work-sync/core/domain/MemberWorkSyncNudge.ts @@ -88,6 +88,12 @@ function buildAgendaPreview(status: MemberWorkSyncStatus): string { .join('; '); } +function hasLeadClarificationItem(status: MemberWorkSyncStatus): boolean { + return status.agenda.items.some( + (item) => item.kind === 'clarification' && item.evidence.needsClarification === 'lead' + ); +} + function buildReviewPickupNudgePayload(status: MemberWorkSyncStatus): MemberWorkSyncNudgePayload { const taskRefs = buildTaskRefs(status); const preview = buildAgendaPreview(status); @@ -133,6 +139,7 @@ export function buildMemberWorkSyncNudgePayload( .map((item) => `${item.displayId ?? item.taskId.slice(0, 8)} ${item.subject}`) .join('; '); const taskIds = status.agenda.items.map((item) => item.taskId).filter(Boolean); + const hasLeadClarification = hasLeadClarificationItem(status); return { from: 'system', @@ -151,6 +158,9 @@ export function buildMemberWorkSyncNudgePayload( : '', `Do not use provider names, runtime names, or team names as memberName; use exactly "${status.memberName}".`, 'If you are still working, report state "still_working"; if you are blocked, report state "blocked" and record the blocker on the task.', + hasLeadClarification + ? 'If a lead clarification was already escalated to the user, update the task board first with task_set_clarification value "user"; do not rely on a message alone.' + : '', 'Continue concrete task work, report a real blocker with task tools, or sync your current fingerprint before going idle.', 'Do not reply only with acknowledgement.', ] diff --git a/src/features/workspace-trust/contracts/index.ts b/src/features/workspace-trust/contracts/index.ts new file mode 100644 index 00000000..ed49efa3 --- /dev/null +++ b/src/features/workspace-trust/contracts/index.ts @@ -0,0 +1 @@ +export type * from '../core/domain/WorkspaceTrustTypes'; diff --git a/src/features/workspace-trust/core/application/ClaudePreflightCommand.ts b/src/features/workspace-trust/core/application/ClaudePreflightCommand.ts new file mode 100644 index 00000000..050384a2 --- /dev/null +++ b/src/features/workspace-trust/core/application/ClaudePreflightCommand.ts @@ -0,0 +1,71 @@ +export type ClaudePreflightCommandCapabilities = { + bare: boolean; + strictMcpConfig: boolean; + mcpConfig: boolean; + settingSources: boolean; + inlineSettings: boolean; + tools: boolean; +}; + +export type ClaudePreflightCommandResult = + | { ok: true; args: string[]; omittedFlags: string[] } + | { ok: false; code: 'preflight_unavailable_or_unprotected'; message: string }; + +export const DEFAULT_CLAUDE_PREFLIGHT_COMMAND_CAPABILITIES: ClaudePreflightCommandCapabilities = { + bare: true, + strictMcpConfig: true, + mcpConfig: true, + settingSources: true, + inlineSettings: true, + tools: true, +}; + +export function buildClaudeWorkspaceTrustPreflightArgs(input: { + emptyMcpConfigPath: string; + capabilities?: Partial; +}): ClaudePreflightCommandResult { + const capabilities = { + ...DEFAULT_CLAUDE_PREFLIGHT_COMMAND_CAPABILITIES, + ...(input.capabilities ?? {}), + }; + + const requiredProtectedFlags: Array = [ + 'strictMcpConfig', + 'mcpConfig', + 'settingSources', + 'inlineSettings', + 'tools', + ]; + const missing = requiredProtectedFlags.filter((flag) => !capabilities[flag]); + if (missing.length > 0) { + return { + ok: false, + code: 'preflight_unavailable_or_unprotected', + message: `Claude workspace trust preflight is unavailable because protected flags are missing: ${missing.join( + ', ' + )}`, + }; + } + + const args: string[] = []; + const omittedFlags: string[] = []; + if (capabilities.bare) { + args.push('--bare'); + } else { + omittedFlags.push('--bare'); + } + + args.push( + '--strict-mcp-config', + '--mcp-config', + input.emptyMcpConfigPath, + '--setting-sources', + 'user', + '--settings', + JSON.stringify({ disableAllHooks: true }), + '--tools', + '' + ); + + return { ok: true, args, omittedFlags }; +} diff --git a/src/features/workspace-trust/core/application/ClaudePtyWorkspaceTrustStrategy.ts b/src/features/workspace-trust/core/application/ClaudePtyWorkspaceTrustStrategy.ts new file mode 100644 index 00000000..ac3402cb --- /dev/null +++ b/src/features/workspace-trust/core/application/ClaudePtyWorkspaceTrustStrategy.ts @@ -0,0 +1,228 @@ +import { buildClaudeWorkspaceTrustPreflightArgs } from './ClaudePreflightCommand'; +import { runPtyDialogEngine } from './PtyDialogEngine'; +import { detectClaudeStartupState, normalizeTerminalText } from './StartupDialogRules'; + +import type { + ProviderStateProbe, + PtyProcessPort, + TerminalSnapshot, + TempEmptyMcpConfigStore, +} from './ports'; +import type { WorkspaceTrustDiagnosticStrategyResult, WorkspaceTrustWorkspace } from '../domain'; + +const WORKSPACE_TRUST_RAW_TAIL_LIMIT = 4096; + +export type ClaudePtyWorkspaceTrustStrategyInput = { + claudePath: string; + workspaces: WorkspaceTrustWorkspace[]; + env: Record; + ptyProcess?: PtyProcessPort; + stateProbe?: ProviderStateProbe; + tempEmptyMcpConfigStore?: TempEmptyMcpConfigStore; + isCancelled(): boolean; + timeoutMs?: number; + pollIntervalMs?: number; +}; + +function toPtyEnv(env: Record): Record { + const output: Record = {}; + for (const [key, value] of Object.entries(env)) { + if (typeof value === 'string') { + output[key] = value; + } + } + return output; +} + +function buildRawTail(snapshot: TerminalSnapshot | undefined): string | undefined { + if (!snapshot) { + return undefined; + } + const normalized = normalizeTerminalText(snapshot.text).trim(); + if (!normalized) { + return undefined; + } + return normalized.slice(-WORKSPACE_TRUST_RAW_TAIL_LIMIT); +} + +function worseStatus( + current: WorkspaceTrustDiagnosticStrategyResult['status'], + next: WorkspaceTrustDiagnosticStrategyResult['status'] +): WorkspaceTrustDiagnosticStrategyResult['status'] { + const rank: Record = { + skipped: 0, + ok: 1, + soft_failed: 2, + blocked: 3, + cancelled: 4, + }; + return rank[next] > rank[current] ? next : current; +} + +export class ClaudePtyWorkspaceTrustStrategy { + constructor( + private readonly defaults: { + ptyProcess?: PtyProcessPort; + stateProbe?: ProviderStateProbe; + tempEmptyMcpConfigStore?: TempEmptyMcpConfigStore; + } = {} + ) {} + + async execute( + input: ClaudePtyWorkspaceTrustStrategyInput + ): Promise { + const ptyProcess = input.ptyProcess ?? this.defaults.ptyProcess; + const stateProbe = input.stateProbe ?? this.defaults.stateProbe; + const tempEmptyMcpConfigStore = + input.tempEmptyMcpConfigStore ?? this.defaults.tempEmptyMcpConfigStore; + if (!ptyProcess || !stateProbe || !tempEmptyMcpConfigStore) { + return { + id: 'claude-pty-workspace-trust', + provider: 'claude', + status: 'soft_failed', + workspaceIds: input.workspaces.map((workspace) => workspace.id), + errorCode: 'workspace_trust_strategy_not_configured', + errorMessage: 'Claude workspace trust strategy ports are not configured.', + }; + } + + const startedAt = Date.now(); + const workspaceIds: string[] = []; + const matchedRuleIds: string[] = []; + const actions: string[] = []; + const evidence: string[] = []; + let status: WorkspaceTrustDiagnosticStrategyResult['status'] = 'ok'; + let errorCode: string | undefined; + let errorMessage: string | undefined; + let rawTail: string | undefined; + + for (const workspace of input.workspaces) { + workspaceIds.push(workspace.id); + if (input.isCancelled()) { + status = 'cancelled'; + break; + } + + if (!workspace.persistable) { + status = worseStatus(status, 'blocked'); + errorCode = `workspace_trust_not_persistable_${workspace.nonPersistableReason ?? 'unknown'}`; + evidence.push(`${workspace.id}:${errorCode}`); + continue; + } + + const before = await stateProbe.readTrustState(workspace); + if (before.status === 'trusted') { + evidence.push(...before.evidence); + continue; + } + + let mcpConfigHandle: Awaited> | null = null; + try { + mcpConfigHandle = await tempEmptyMcpConfigStore.create(); + const command = buildClaudeWorkspaceTrustPreflightArgs({ + emptyMcpConfigPath: mcpConfigHandle.path, + }); + if (!command.ok) { + status = worseStatus(status, 'soft_failed'); + errorCode = command.code; + errorMessage = command.message; + evidence.push(command.message); + continue; + } + + const spawnResult = await ptyProcess.spawn({ + command: input.claudePath, + args: command.args, + cwd: workspace.cwd, + env: toPtyEnv(input.env), + cols: 120, + rows: 36, + name: 'xterm-256color', + }); + if (!spawnResult.ok) { + status = worseStatus(status, 'soft_failed'); + errorCode = spawnResult.code; + errorMessage = spawnResult.message; + evidence.push(spawnResult.message); + continue; + } + + try { + const engineResult = await runPtyDialogEngine({ + session: spawnResult.session, + detect: detectClaudeStartupState, + isCancelled: input.isCancelled, + timeoutMs: input.timeoutMs, + pollIntervalMs: input.pollIntervalMs, + afterDialogAction: async ({ ruleId }) => { + if (ruleId !== 'claude.workspace_trust') { + return { action: 'continue' }; + } + const after = await stateProbe.readTrustState(workspace); + if (after.status === 'trusted') { + evidence.push(...after.evidence); + return { action: 'stop', reason: 'workspace_trust_persisted' }; + } + return { action: 'continue' }; + }, + }); + matchedRuleIds.push(...engineResult.matchedRuleIds); + actions.push(...engineResult.actions); + if (engineResult.status !== 'ok') { + rawTail = buildRawTail(engineResult.lastSnapshot) ?? rawTail; + } + + if (engineResult.status === 'cancelled') { + status = 'cancelled'; + break; + } + if (engineResult.status === 'blocked') { + // Dialog-engine blocks are preflight uncertainty; only non-persistable paths block launch. + status = worseStatus(status, 'soft_failed'); + errorCode = engineResult.code; + errorMessage = engineResult.evidence[0] ?? engineResult.code; + evidence.push(...engineResult.evidence); + continue; + } + + const after = await stateProbe.readTrustState(workspace); + if (after.status === 'trusted') { + evidence.push(...after.evidence); + continue; + } + + status = worseStatus(status, 'soft_failed'); + errorCode = + engineResult.status === 'timeout' + ? 'workspace_trust_preflight_timeout' + : 'workspace_trust_preflight_not_confirmed'; + errorMessage = `Claude workspace trust was not confirmed for ${workspace.configKeyCwd}`; + evidence.push(errorMessage); + } finally { + await spawnResult.session.kill().catch(() => undefined); + } + } catch (error) { + status = worseStatus(status, 'soft_failed'); + errorCode = 'workspace_trust_preflight_error'; + errorMessage = error instanceof Error ? error.message : String(error); + evidence.push(errorMessage); + } finally { + await mcpConfigHandle?.cleanup().catch(() => undefined); + } + } + + return { + id: 'claude-pty-workspace-trust', + provider: 'claude', + status, + workspaceIds, + matchedRuleIds: [...new Set(matchedRuleIds)], + actions, + evidence, + elapsedMs: Date.now() - startedAt, + errorCode, + errorMessage, + rawTail, + }; + } +} diff --git a/src/features/workspace-trust/core/application/PtyDialogEngine.ts b/src/features/workspace-trust/core/application/PtyDialogEngine.ts new file mode 100644 index 00000000..5d8a9692 --- /dev/null +++ b/src/features/workspace-trust/core/application/PtyDialogEngine.ts @@ -0,0 +1,142 @@ +import type { PtyKeyAction, PtySessionPort, TerminalSnapshot } from './ports'; +import type { StartupReadinessState } from './StartupDialogRules'; + +export type PtyDialogEngineResult = + | { + status: 'ok'; + reason: string; + matchedRuleIds: string[]; + actions: string[]; + lastSnapshot?: TerminalSnapshot; + } + | { + status: 'ready'; + matchedRuleIds: string[]; + actions: string[]; + lastSnapshot?: TerminalSnapshot; + } + | { + status: 'blocked'; + code: string; + evidence: string[]; + matchedRuleIds: string[]; + actions: string[]; + lastSnapshot?: TerminalSnapshot; + } + | { + status: 'timeout' | 'cancelled'; + matchedRuleIds: string[]; + actions: string[]; + lastSnapshot?: TerminalSnapshot; + }; + +export type PtyDialogEngineInput = { + session: PtySessionPort; + detect(snapshotText: string): StartupReadinessState; + isCancelled(): boolean; + timeoutMs?: number; + pollIntervalMs?: number; + settleDelayMs?: number; + maxActions?: number; + afterDialogAction?: (input: { + ruleId: string; + actions: PtyKeyAction[]; + snapshot: TerminalSnapshot; + }) => Promise<{ action: 'continue' } | { action: 'stop'; reason: string }>; +}; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export async function runPtyDialogEngine( + input: PtyDialogEngineInput +): Promise { + const timeoutMs = input.timeoutMs ?? 15_000; + const pollIntervalMs = input.pollIntervalMs ?? 100; + const settleDelayMs = input.settleDelayMs ?? 250; + const maxActions = input.maxActions ?? 12; + const deadline = Date.now() + timeoutMs; + const handledOnceRules = new Set(); + const matchedRuleIds: string[] = []; + const actions: string[] = []; + let lastSnapshot: TerminalSnapshot | undefined; + + while (Date.now() <= deadline) { + if (input.isCancelled()) { + return { status: 'cancelled', matchedRuleIds, actions, lastSnapshot }; + } + + const snapshot = await input.session.readSnapshot(pollIntervalMs); + if (!snapshot) { + continue; + } + if (snapshot.text.trim().length > 0 || !lastSnapshot) { + lastSnapshot = snapshot; + } + const state = input.detect(snapshot.text); + + if (state.phase === 'dialog') { + if (!matchedRuleIds.includes(state.ruleId)) { + matchedRuleIds.push(state.ruleId); + } + if (state.retryPolicy === 'once' && handledOnceRules.has(state.ruleId)) { + await sleep(pollIntervalMs); + continue; + } + if (actions.length + state.actions.length > maxActions) { + return { + status: 'blocked', + code: 'workspace_trust_too_many_dialog_actions', + evidence: [`action limit ${maxActions} exceeded`], + matchedRuleIds, + actions, + lastSnapshot, + }; + } + + handledOnceRules.add(state.ruleId); + for (const action of state.actions) { + if (input.isCancelled()) { + return { status: 'cancelled', matchedRuleIds, actions, lastSnapshot }; + } + await input.session.writeAction(action); + actions.push(`${state.ruleId}:${action.id}`); + } + + const afterAction = await input.afterDialogAction?.({ + ruleId: state.ruleId, + actions: state.actions, + snapshot, + }); + if (afterAction?.action === 'stop') { + return { + status: 'ok', + reason: afterAction.reason, + matchedRuleIds, + actions, + lastSnapshot, + }; + } + await sleep(settleDelayMs); + continue; + } + + if (state.phase === 'setup_required') { + return { + status: 'blocked', + code: state.code, + evidence: state.evidence, + matchedRuleIds, + actions, + lastSnapshot, + }; + } + + if (state.phase === 'ready') { + return { status: 'ready', matchedRuleIds, actions, lastSnapshot }; + } + } + + return { status: 'timeout', matchedRuleIds, actions, lastSnapshot }; +} diff --git a/src/features/workspace-trust/core/application/StartupDialogRules.ts b/src/features/workspace-trust/core/application/StartupDialogRules.ts new file mode 100644 index 00000000..60c2a0fa --- /dev/null +++ b/src/features/workspace-trust/core/application/StartupDialogRules.ts @@ -0,0 +1,148 @@ +import type { PtyKeyAction } from './ports'; + +export type StartupReadinessState = + | { + phase: 'dialog'; + ruleId: string; + actions: PtyKeyAction[]; + retryPolicy: 'once' | 'typed_retry'; + evidence: string[]; + } + | { phase: 'ready'; evidence: string[] } + | { phase: 'setup_required'; code: string; evidence: string[] } + | { phase: 'loading'; evidence?: string[] }; + +export const PTY_KEY_ACTIONS = { + enter: { id: 'enter', label: 'Enter', sequence: '\r' }, + down: { id: 'down', label: 'Down', sequence: '\u001b[B' }, + up: { id: 'up', label: 'Up', sequence: '\u001b[A' }, +} satisfies Record; + +export function stripAnsiSequences(value: string): string { + return value + .replace(/\u001B\][^\u0007]*(?:\u0007|\u001B\\)/g, '') + .replace(/\u001B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, '') + .replace(/\u009B[0-?]*[ -/]*[@-~]/g, ''); +} + +export function normalizeTerminalText(value: string): string { + return stripAnsiSequences(value) + .replace(/\r\n/g, '\n') + .replace(/\r/g, '\n') + .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, ''); +} + +function containsAll(value: string, patterns: RegExp[]): boolean { + return patterns.every((pattern) => pattern.test(value)); +} + +function compactForTuiMatch(value: string): string { + return value.replace(/[\s'",.:;!?()[\]{}<>|·•\-_]+/g, ''); +} + +function hasClaudeWorkspaceTrustPrompt(lower: string, compact: string): boolean { + const knownPrompt = + containsAll(lower, [ + /quick safety check|project you created|workspace trust/, + /trust this folder/, + ]) || + (/(quicksafetycheck|projectyoucreated|workspacetrust)/.test(compact) && + /trustthisfolder/.test(compact)); + if (knownPrompt) { + return true; + } + + const hasClaudeSpecificContext = + /claude code|quick safety|accessing workspace|project you created|workspace trust|read,\s*edit,\s*and\s*execute files/.test( + lower + ) || + /claudecode|quicksafety|accessingworkspace|projectyoucreated|workspacetrust|readeditandexecutefiles/.test( + compact + ); + if (!hasClaudeSpecificContext) { + return false; + } + + const hasTrustQuestion = + /(do you trust|trust.*(?:folder|workspace|project|directory)|(?:folder|workspace|project|directory).*trust|created.*trust)/.test( + lower + ) || + /(doyoutrust|trust(?:this)?(?:folder|workspace|project|directory)|(?:folder|workspace|project|directory).*trust|created.*trust)/.test( + compact + ); + const hasTrustAction = + /(yes.*trust|i trust|trust this (?:folder|workspace|project|directory)|continue)/.test(lower) || + /(yes.*trust|itrust|trustthis(?:folder|workspace|project|directory)|yescontinue)/.test(compact); + + return hasTrustQuestion && hasTrustAction; +} + +export function detectClaudeStartupState(snapshotText: string): StartupReadinessState { + const normalized = normalizeTerminalText(snapshotText); + const lower = normalized.toLowerCase(); + const compact = compactForTuiMatch(lower); + + if (hasClaudeWorkspaceTrustPrompt(lower, compact)) { + return { + phase: 'dialog', + ruleId: 'claude.workspace_trust', + actions: [PTY_KEY_ACTIONS.enter], + retryPolicy: 'once', + evidence: ['claude workspace trust prompt'], + }; + } + + if (/do you trust the contents of this directory\?/i.test(normalized)) { + return { + phase: 'dialog', + ruleId: 'codex.workspace_trust', + actions: [PTY_KEY_ACTIONS.enter], + retryPolicy: 'once', + evidence: ['codex workspace trust prompt'], + }; + } + + if (/update available/i.test(normalized) && /\bskip\b/i.test(normalized)) { + return { + phase: 'dialog', + ruleId: 'codex.update_available', + actions: [PTY_KEY_ACTIONS.down, PTY_KEY_ACTIONS.enter], + retryPolicy: 'once', + evidence: ['codex update prompt'], + }; + } + + if (/bypass permissions|dangerously skip permissions/i.test(normalized)) { + return { + phase: 'dialog', + ruleId: 'claude.bypass_permissions', + actions: [PTY_KEY_ACTIONS.down, PTY_KEY_ACTIONS.enter], + retryPolicy: 'typed_retry', + evidence: ['claude bypass permissions prompt'], + }; + } + + if (/custom api key|use.*api key|api key.*confirmation/i.test(normalized)) { + return { + phase: 'dialog', + ruleId: 'claude.custom_api_key_confirmation', + actions: [PTY_KEY_ACTIONS.up, PTY_KEY_ACTIONS.enter], + retryPolicy: 'typed_retry', + evidence: ['claude custom api key confirmation'], + }; + } + + if (/log in to claude|not logged in|api key required|choose.*login|sign in/i.test(normalized)) { + return { + phase: 'setup_required', + code: 'provider_auth_required', + evidence: ['provider auth required prompt'], + }; + } + + if (/>\s*$/.test(normalized) && /claude/i.test(normalized)) { + return { phase: 'ready', evidence: ['claude prompt marker'] }; + } + + return { phase: 'loading' }; +} diff --git a/src/features/workspace-trust/core/application/WorkspaceTrustCoordinator.ts b/src/features/workspace-trust/core/application/WorkspaceTrustCoordinator.ts new file mode 100644 index 00000000..d45456cd --- /dev/null +++ b/src/features/workspace-trust/core/application/WorkspaceTrustCoordinator.ts @@ -0,0 +1,190 @@ +import { + buildCodexTrustedProjectConfigOverrides, + buildCodexWorkspaceTrustSettingsArgs, + type WorkspaceTrustFeatureFlags, + type WorkspaceTrustLaunchArgPatch, + type WorkspaceTrustLaunchArgTargetSurface, + type WorkspaceTrustProvider, + type WorkspaceTrustWorkspace, +} from '../domain'; + +import type { ClaudePtyWorkspaceTrustStrategy } from './ClaudePtyWorkspaceTrustStrategy'; +import { + WorkspaceTrustLockCancelledError, + WorkspaceTrustLockRegistry, + WorkspaceTrustLockTimeoutError, +} from './WorkspaceTrustLocks'; + +export type WorkspaceTrustArgsOnlyPlanRequest = { + providers: WorkspaceTrustProvider[]; + workspaces: WorkspaceTrustWorkspace[]; + targetSurfaces?: WorkspaceTrustLaunchArgTargetSurface[]; + featureFlags: WorkspaceTrustFeatureFlags; +}; + +export type WorkspaceTrustArgsOnlyPlanResult = { + launchArgPatches: WorkspaceTrustLaunchArgPatch[]; +}; + +export type WorkspaceTrustFullPlanRequest = WorkspaceTrustArgsOnlyPlanRequest; + +export type WorkspaceTrustFullPlanResult = WorkspaceTrustArgsOnlyPlanResult & { + workspaces: WorkspaceTrustWorkspace[]; +}; + +export type WorkspaceTrustExecutionPlan = { + claudePath: string; + workspaces: WorkspaceTrustWorkspace[]; + env: Record; + featureFlags: WorkspaceTrustFeatureFlags; + isCancelled(): boolean; +}; + +export type WorkspaceTrustExecutionResult = Awaited< + ReturnType +>; + +export interface WorkspaceTrustCoordinator { + planArgsOnly( + request: WorkspaceTrustArgsOnlyPlanRequest + ): Promise; + planFull(request: WorkspaceTrustFullPlanRequest): Promise; + execute(plan: WorkspaceTrustExecutionPlan): Promise; +} + +const DEFAULT_CODEX_TARGET_SURFACES: WorkspaceTrustLaunchArgTargetSurface[] = [ + 'primary_provider_args', + 'cross_provider_member_args', + 'provider_facts_probe', + 'default_model_probe', +]; + +function providerSet(providers: WorkspaceTrustProvider[]): Set { + return new Set(providers.map((provider) => (provider === 'anthropic' ? 'claude' : provider))); +} + +function buildCodexPatches(input: { + providers: WorkspaceTrustProvider[]; + workspaces: WorkspaceTrustWorkspace[]; + targetSurfaces?: WorkspaceTrustLaunchArgTargetSurface[]; + featureFlags: WorkspaceTrustFeatureFlags; +}): WorkspaceTrustLaunchArgPatch[] { + if (!input.featureFlags.enabled || !input.featureFlags.codexArgs) { + return []; + } + if (!providerSet(input.providers).has('codex')) { + return []; + } + + const configKeys = input.workspaces.flatMap((workspace) => [ + workspace.configKeyCwd, + workspace.realCwd, + ...(workspace.gitRootConfigKey ? [workspace.gitRootConfigKey] : []), + ]); + const overrides = buildCodexTrustedProjectConfigOverrides(configKeys); + const args = buildCodexWorkspaceTrustSettingsArgs(overrides); + if (args.length === 0) { + return []; + } + + const workspaceIds = input.workspaces.map((workspace) => workspace.id); + const surfaces = input.targetSurfaces ?? DEFAULT_CODEX_TARGET_SURFACES; + return surfaces.map((surface) => ({ + id: `workspace-trust:codex:${surface}`, + owner: 'workspace-trust' as const, + targetProvider: 'codex' as const, + targetSurface: surface, + dialect: 'claude-codex-runtime-settings' as const, + args, + dedupeKey: `workspace-trust:codex:${surface}:${overrides.join('|')}`, + sourceWorkspaceIds: workspaceIds, + reason: 'Carry app-owned Codex workspace trust overrides through sibling runtime settings.', + })); +} + +export class DefaultWorkspaceTrustCoordinator implements WorkspaceTrustCoordinator { + constructor( + private readonly claudeStrategy: ClaudePtyWorkspaceTrustStrategy, + private readonly lockRegistry: WorkspaceTrustLockRegistry = new WorkspaceTrustLockRegistry() + ) {} + + async planArgsOnly( + request: WorkspaceTrustArgsOnlyPlanRequest + ): Promise { + return { + launchArgPatches: buildCodexPatches(request), + }; + } + + async planFull(request: WorkspaceTrustFullPlanRequest): Promise { + return { + workspaces: request.workspaces, + launchArgPatches: buildCodexPatches(request), + }; + } + + async execute(plan: WorkspaceTrustExecutionPlan): Promise { + if ( + !plan.featureFlags.enabled || + !plan.featureFlags.claudePty || + plan.workspaces.length === 0 + ) { + return { + id: 'claude-pty-workspace-trust', + provider: 'claude', + status: 'skipped', + workspaceIds: plan.workspaces.map((workspace) => workspace.id), + evidence: ['workspace trust Claude PTY preflight disabled'], + }; + } + + const lockKeys = plan.workspaces.map((workspace) => `claude:${workspace.comparisonKey}`); + try { + return await this.lockRegistry.withWorkspaceLocks( + lockKeys, + { + timeoutMs: 20_000, + isCancelled: plan.isCancelled, + }, + () => + this.claudeStrategy.execute({ + claudePath: plan.claudePath, + workspaces: plan.workspaces, + env: plan.env, + isCancelled: plan.isCancelled, + }) + ); + } catch (error) { + if (error instanceof WorkspaceTrustLockCancelledError) { + return { + id: 'claude-pty-workspace-trust', + provider: 'claude', + status: 'cancelled', + workspaceIds: plan.workspaces.map((workspace) => workspace.id), + errorCode: 'workspace_trust_lock_cancelled', + errorMessage: error.message, + }; + } + if (error instanceof WorkspaceTrustLockTimeoutError) { + return { + id: 'claude-pty-workspace-trust', + provider: 'claude', + status: 'soft_failed', + workspaceIds: plan.workspaces.map((workspace) => workspace.id), + errorCode: 'workspace_trust_lock_timeout', + errorMessage: error.message, + }; + } + const message = error instanceof Error ? error.message : String(error); + return { + id: 'claude-pty-workspace-trust', + provider: 'claude', + status: 'soft_failed', + workspaceIds: plan.workspaces.map((workspace) => workspace.id), + errorCode: 'workspace_trust_preflight_error', + errorMessage: message, + evidence: [message], + }; + } + } +} diff --git a/src/features/workspace-trust/core/application/WorkspaceTrustLocks.ts b/src/features/workspace-trust/core/application/WorkspaceTrustLocks.ts new file mode 100644 index 00000000..89295363 --- /dev/null +++ b/src/features/workspace-trust/core/application/WorkspaceTrustLocks.ts @@ -0,0 +1,100 @@ +export class WorkspaceTrustLockTimeoutError extends Error { + constructor(readonly lockKey: string) { + super(`Timed out waiting for workspace trust lock: ${lockKey}`); + this.name = 'WorkspaceTrustLockTimeoutError'; + } +} + +export class WorkspaceTrustLockCancelledError extends Error { + constructor(readonly lockKey: string) { + super(`Workspace trust lock wait cancelled: ${lockKey}`); + this.name = 'WorkspaceTrustLockCancelledError'; + } +} + +export type WorkspaceTrustLockOptions = { + timeoutMs: number; + pollIntervalMs?: number; + isCancelled(): boolean; +}; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function waitForLockTurn( + previous: Promise, + lockKey: string, + options: WorkspaceTrustLockOptions +): Promise { + const startedAt = Date.now(); + const pollIntervalMs = options.pollIntervalMs ?? 50; + + while (true) { + if (options.isCancelled()) { + throw new WorkspaceTrustLockCancelledError(lockKey); + } + const elapsedMs = Date.now() - startedAt; + if (elapsedMs >= options.timeoutMs) { + throw new WorkspaceTrustLockTimeoutError(lockKey); + } + + const waitMs = Math.min(pollIntervalMs, options.timeoutMs - elapsedMs); + const result = await Promise.race([ + previous.then( + () => 'released' as const, + () => 'released' as const + ), + sleep(waitMs).then(() => 'poll' as const), + ]); + if (result === 'released') { + return; + } + } +} + +export class WorkspaceTrustLockRegistry { + private readonly tails = new Map>(); + + async withWorkspaceLock( + lockKey: string, + options: WorkspaceTrustLockOptions, + fn: () => Promise + ): Promise { + const previous = this.tails.get(lockKey) ?? Promise.resolve(); + let release!: () => void; + const current = new Promise((resolve) => { + release = resolve; + }); + const tail = previous.catch(() => undefined).then(() => current); + this.tails.set(lockKey, tail); + + try { + await waitForLockTurn(previous, lockKey, options); + return await fn(); + } finally { + release(); + void tail.finally(() => { + if (this.tails.get(lockKey) === tail) { + this.tails.delete(lockKey); + } + }); + } + } + + async withWorkspaceLocks( + lockKeys: string[], + options: WorkspaceTrustLockOptions, + fn: () => Promise + ): Promise { + const uniqueKeys = [...new Set(lockKeys)].sort(); + const acquire = (index: number): Promise => { + const lockKey = uniqueKeys[index]; + if (!lockKey) { + return fn(); + } + return this.withWorkspaceLock(lockKey, options, () => acquire(index + 1)); + }; + return acquire(0); + } +} diff --git a/src/features/workspace-trust/core/application/index.ts b/src/features/workspace-trust/core/application/index.ts new file mode 100644 index 00000000..c02ef797 --- /dev/null +++ b/src/features/workspace-trust/core/application/index.ts @@ -0,0 +1,7 @@ +export * from './ClaudePreflightCommand'; +export * from './ClaudePtyWorkspaceTrustStrategy'; +export * from './PtyDialogEngine'; +export * from './StartupDialogRules'; +export * from './WorkspaceTrustCoordinator'; +export * from './WorkspaceTrustLocks'; +export type * from './ports'; diff --git a/src/features/workspace-trust/core/application/ports.ts b/src/features/workspace-trust/core/application/ports.ts new file mode 100644 index 00000000..0b94df05 --- /dev/null +++ b/src/features/workspace-trust/core/application/ports.ts @@ -0,0 +1,54 @@ +import type { WorkspaceTrustWorkspace } from '../domain'; + +export type TerminalSnapshot = { + text: string; + capturedAtMs: number; +}; + +export type PtyKeyAction = { + id: string; + label: string; + sequence: string; +}; + +export type PtySpawnInput = { + command: string; + args: string[]; + cwd: string; + env: Record; + cols?: number; + rows?: number; + name?: string; +}; + +export type PtySpawnResult = + | { ok: true; session: PtySessionPort } + | { ok: false; code: string; message: string }; + +export interface PtySessionPort { + readSnapshot(timeoutMs: number): Promise; + writeAction(action: PtyKeyAction): Promise; + kill(): Promise; +} + +export interface PtyProcessPort { + spawn(input: PtySpawnInput): Promise; +} + +export type ProviderTrustState = + | { status: 'trusted'; evidence: string[] } + | { status: 'untrusted'; evidence?: string[] } + | { status: 'unknown'; evidence?: string[]; errorMessage?: string }; + +export interface ProviderStateProbe { + readTrustState(workspace: WorkspaceTrustWorkspace): Promise; +} + +export type TempEmptyMcpConfigHandle = { + path: string; + cleanup(): Promise; +}; + +export interface TempEmptyMcpConfigStore { + create(): Promise; +} diff --git a/src/features/workspace-trust/core/domain/CodexWorkspaceTrustSettings.ts b/src/features/workspace-trust/core/domain/CodexWorkspaceTrustSettings.ts new file mode 100644 index 00000000..3670da5a --- /dev/null +++ b/src/features/workspace-trust/core/domain/CodexWorkspaceTrustSettings.ts @@ -0,0 +1,166 @@ +import { normalizeWorkspaceTrustConfigKey } from './WorkspaceTrustPath'; + +import type { WorkspaceTrustPathOptions } from './WorkspaceTrustPath'; + +export const CODEX_WORKSPACE_TRUST_SETTINGS_ROOT = 'codex'; +export const CODEX_WORKSPACE_TRUST_SETTINGS_KEY = 'agent_teams_workspace_trust'; +export const CODEX_WORKSPACE_TRUST_CONFIG_OVERRIDES_KEY = 'config_overrides'; + +const CODEX_WORKSPACE_TRUST_OVERRIDE_PATTERN = + /^projects\."(?:[^"\\\x00-\x1F]|\\["\\bfnrt]|\\u[0-9a-fA-F]{4}|\\U[0-9a-fA-F]{8})+"\.trust_level="trusted"$/; + +export type CodexWorkspaceTrustSettingsObject = { + codex: { + agent_teams_workspace_trust: { + config_overrides: string[]; + }; + }; +}; + +function toHex(value: number, width: number): string { + return value.toString(16).padStart(width, '0').toUpperCase(); +} + +export function escapeTomlBasicStringSegment(value: string): string { + let output = ''; + for (const char of value) { + const codePoint = char.codePointAt(0) ?? 0; + if (char === '"') { + output += '\\"'; + } else if (char === '\\') { + output += '\\\\'; + } else if (char === '\b') { + output += '\\b'; + } else if (char === '\t') { + output += '\\t'; + } else if (char === '\n') { + output += '\\n'; + } else if (char === '\f') { + output += '\\f'; + } else if (char === '\r') { + output += '\\r'; + } else if (codePoint < 0x20) { + output += `\\u${toHex(codePoint, 4)}`; + } else { + output += char; + } + } + return output; +} + +export function buildCodexTrustedProjectConfigOverride(configKey: string): string | null { + const trimmed = configKey.trim(); + if (!trimmed) { + return null; + } + return `projects."${escapeTomlBasicStringSegment(trimmed)}".trust_level="trusted"`; +} + +export function isCodexWorkspaceTrustConfigOverride(value: unknown): value is string { + return ( + typeof value === 'string' && + value.length <= 1024 && + !value.includes('\0') && + !value.includes('\n') && + !value.includes('\r') && + CODEX_WORKSPACE_TRUST_OVERRIDE_PATTERN.test(value) + ); +} + +export function buildCodexTrustedProjectConfigOverrides( + configKeys: string[], + options?: WorkspaceTrustPathOptions & { maxOverrides?: number } +): string[] { + const maxOverrides = Math.max(0, options?.maxOverrides ?? 64); + const output: string[] = []; + const seen = new Set(); + + for (const key of configKeys) { + if (output.length >= maxOverrides) { + break; + } + const normalizedKey = normalizeWorkspaceTrustConfigKey(key, options); + if (!normalizedKey || seen.has(normalizedKey)) { + continue; + } + seen.add(normalizedKey); + const override = buildCodexTrustedProjectConfigOverride(normalizedKey); + if (override && isCodexWorkspaceTrustConfigOverride(override)) { + output.push(override); + } + } + + return output; +} + +export function normalizeCodexWorkspaceTrustConfigOverrides( + overrides: readonly unknown[], + options?: { maxOverrides?: number; maxTotalLength?: number } +): string[] { + const maxOverrides = Math.max(0, options?.maxOverrides ?? 64); + const maxTotalLength = Math.max(0, options?.maxTotalLength ?? 16384); + const output: string[] = []; + const seen = new Set(); + let totalLength = 0; + + for (const override of overrides) { + if (output.length >= maxOverrides) { + break; + } + if (!isCodexWorkspaceTrustConfigOverride(override) || seen.has(override)) { + continue; + } + const nextLength = totalLength + override.length; + if (nextLength > maxTotalLength) { + break; + } + seen.add(override); + output.push(override); + totalLength = nextLength; + } + + return output; +} + +export function buildCodexWorkspaceTrustSettings( + overrides: readonly unknown[] +): CodexWorkspaceTrustSettingsObject | null { + const safeOverrides = normalizeCodexWorkspaceTrustConfigOverrides(overrides); + if (safeOverrides.length === 0) { + return null; + } + return { + codex: { + agent_teams_workspace_trust: { + config_overrides: safeOverrides, + }, + }, + }; +} + +export function buildCodexWorkspaceTrustSettingsArgs(overrides: readonly unknown[]): string[] { + const settings = buildCodexWorkspaceTrustSettings(overrides); + return settings ? ['--settings', JSON.stringify(settings)] : []; +} + +export function readCodexWorkspaceTrustConfigOverridesFromSettings(settings: unknown): string[] { + if (typeof settings !== 'object' || settings === null || Array.isArray(settings)) { + return []; + } + const codex = (settings as Record)[CODEX_WORKSPACE_TRUST_SETTINGS_ROOT]; + if (typeof codex !== 'object' || codex === null || Array.isArray(codex)) { + return []; + } + const workspaceTrust = (codex as Record)[CODEX_WORKSPACE_TRUST_SETTINGS_KEY]; + if ( + typeof workspaceTrust !== 'object' || + workspaceTrust === null || + Array.isArray(workspaceTrust) + ) { + return []; + } + const overrides = (workspaceTrust as Record)[ + CODEX_WORKSPACE_TRUST_CONFIG_OVERRIDES_KEY + ]; + return Array.isArray(overrides) ? normalizeCodexWorkspaceTrustConfigOverrides(overrides) : []; +} diff --git a/src/features/workspace-trust/core/domain/WorkspaceTrustArgPatchApplier.ts b/src/features/workspace-trust/core/domain/WorkspaceTrustArgPatchApplier.ts new file mode 100644 index 00000000..93924f53 --- /dev/null +++ b/src/features/workspace-trust/core/domain/WorkspaceTrustArgPatchApplier.ts @@ -0,0 +1,150 @@ +import { + buildCodexWorkspaceTrustSettingsArgs, + readCodexWorkspaceTrustConfigOverridesFromSettings, +} from './CodexWorkspaceTrustSettings'; + +import type { + WorkspaceTrustLaunchArgPatch, + WorkspaceTrustLaunchArgTargetSurface, + WorkspaceTrustProvider, +} from './WorkspaceTrustTypes'; + +export type WorkspaceTrustLaunchArgPatchSkipReason = + | 'owner_mismatch' + | 'provider_mismatch' + | 'surface_mismatch' + | 'unsupported_dialect' + | 'empty_patch' + | 'malformed_patch_settings'; + +export type WorkspaceTrustLaunchArgPatchApplication = { + args: string[]; + appliedPatchIds: string[]; + skippedPatches: Array<{ id: string; reason: WorkspaceTrustLaunchArgPatchSkipReason }>; + addedWorkspaceTrustOverrideCount: number; +}; + +function parseJsonObject(value: string): Record | null { + try { + const parsed = JSON.parse(value) as unknown; + return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) + ? (parsed as Record) + : null; + } catch { + return null; + } +} + +function collectSettingsObjectsFromArgs(args: string[]): Record[] { + const settings: Record[] = []; + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (arg === '--settings') { + const value = args[i + 1]; + if (typeof value === 'string') { + const parsed = parseJsonObject(value); + if (parsed) { + settings.push(parsed); + } + } + continue; + } + const prefix = '--settings='; + if (arg.startsWith(prefix)) { + const parsed = parseJsonObject(arg.slice(prefix.length)); + if (parsed) { + settings.push(parsed); + } + } + } + return settings; +} + +function collectWorkspaceTrustOverridesFromSettingsArgs(args: string[]): string[] { + const output: string[] = []; + const seen = new Set(); + for (const settings of collectSettingsObjectsFromArgs(args)) { + for (const override of readCodexWorkspaceTrustConfigOverridesFromSettings(settings)) { + if (seen.has(override)) { + continue; + } + seen.add(override); + output.push(override); + } + } + return output; +} + +function collectWorkspaceTrustOverridesFromPatch(patch: WorkspaceTrustLaunchArgPatch): string[] { + return collectWorkspaceTrustOverridesFromSettingsArgs(patch.args); +} + +export function applyWorkspaceTrustLaunchArgPatches(input: { + args: string[]; + patches: WorkspaceTrustLaunchArgPatch[]; + targetProvider: WorkspaceTrustProvider; + targetSurface: WorkspaceTrustLaunchArgTargetSurface; +}): WorkspaceTrustLaunchArgPatchApplication { + const skippedPatches: WorkspaceTrustLaunchArgPatchApplication['skippedPatches'] = []; + const appliedPatchIds: string[] = []; + const existingOverrides = collectWorkspaceTrustOverridesFromSettingsArgs(input.args); + const outputOverrides = [...existingOverrides]; + const seenOverrides = new Set(existingOverrides); + + for (const patch of input.patches) { + if (patch.owner !== 'workspace-trust') { + skippedPatches.push({ id: patch.id, reason: 'owner_mismatch' }); + continue; + } + if (patch.targetProvider !== input.targetProvider) { + skippedPatches.push({ id: patch.id, reason: 'provider_mismatch' }); + continue; + } + if (patch.targetSurface !== input.targetSurface) { + skippedPatches.push({ id: patch.id, reason: 'surface_mismatch' }); + continue; + } + if (patch.dialect !== 'claude-codex-runtime-settings') { + skippedPatches.push({ id: patch.id, reason: 'unsupported_dialect' }); + continue; + } + + const patchOverrides = collectWorkspaceTrustOverridesFromPatch(patch); + if (patchOverrides.length === 0) { + skippedPatches.push({ + id: patch.id, + reason: patch.args.length === 0 ? 'empty_patch' : 'malformed_patch_settings', + }); + continue; + } + + let changed = false; + for (const override of patchOverrides) { + if (seenOverrides.has(override)) { + continue; + } + seenOverrides.add(override); + outputOverrides.push(override); + changed = true; + } + if (changed) { + appliedPatchIds.push(patch.id); + } + } + + if (outputOverrides.length === existingOverrides.length) { + return { + args: [...input.args], + appliedPatchIds, + skippedPatches, + addedWorkspaceTrustOverrideCount: 0, + }; + } + + return { + args: [...input.args, ...buildCodexWorkspaceTrustSettingsArgs(outputOverrides)], + appliedPatchIds, + skippedPatches, + addedWorkspaceTrustOverrideCount: outputOverrides.length - existingOverrides.length, + }; +} diff --git a/src/features/workspace-trust/core/domain/WorkspaceTrustDiagnosticsBudget.ts b/src/features/workspace-trust/core/domain/WorkspaceTrustDiagnosticsBudget.ts new file mode 100644 index 00000000..5aa531db --- /dev/null +++ b/src/features/workspace-trust/core/domain/WorkspaceTrustDiagnosticsBudget.ts @@ -0,0 +1,95 @@ +import type { + WorkspaceTrustDiagnosticStrategyResult, + WorkspaceTrustDiagnosticsManifest, +} from './WorkspaceTrustTypes'; + +export type WorkspaceTrustDiagnosticsBudgetLimits = { + maxStrategyResults: number; + maxWorkspaceIdsPerResult: number; + maxEvidencePerResult: number; + maxEvidenceLength: number; + maxRawTailLength: number; +}; + +export const DEFAULT_WORKSPACE_TRUST_DIAGNOSTICS_BUDGET: WorkspaceTrustDiagnosticsBudgetLimits = { + maxStrategyResults: 20, + maxWorkspaceIdsPerResult: 20, + maxEvidencePerResult: 5, + maxEvidenceLength: 600, + maxRawTailLength: 8192, +}; + +function truncate(value: string, maxLength: number): string { + if (value.length <= maxLength) { + return value; + } + return `${value.slice(0, Math.max(0, maxLength - 16))}[truncated]`; +} + +function budgetStringArray( + values: string[] | undefined, + limit: number, + maxStringLength?: number +): { values: string[] | undefined; omitted: number } { + if (!values || values.length === 0) { + return { values: undefined, omitted: 0 }; + } + const limited = values.slice(0, limit); + const mapped = + typeof maxStringLength === 'number' + ? limited.map((value) => truncate(value, maxStringLength)) + : limited; + return { values: mapped, omitted: Math.max(0, values.length - limited.length) }; +} + +export function budgetWorkspaceTrustDiagnosticsManifest( + manifest: WorkspaceTrustDiagnosticsManifest, + limits: Partial = {} +): WorkspaceTrustDiagnosticsManifest { + const effectiveLimits = { + ...DEFAULT_WORKSPACE_TRUST_DIAGNOSTICS_BUDGET, + ...limits, + }; + const omittedCounts: Record = { ...(manifest.omittedCounts ?? {}) }; + const results = manifest.strategyResults.slice(0, effectiveLimits.maxStrategyResults); + + const strategyResultOmitted = manifest.strategyResults.length - results.length; + if (strategyResultOmitted > 0) { + omittedCounts.strategyResults = (omittedCounts.strategyResults ?? 0) + strategyResultOmitted; + } + + const budgetedResults: WorkspaceTrustDiagnosticStrategyResult[] = results.map((result) => { + const workspaceIds = budgetStringArray( + result.workspaceIds, + effectiveLimits.maxWorkspaceIdsPerResult + ); + if (workspaceIds.omitted > 0) { + omittedCounts.workspaceIds = (omittedCounts.workspaceIds ?? 0) + workspaceIds.omitted; + } + + const evidence = budgetStringArray( + result.evidence, + effectiveLimits.maxEvidencePerResult, + effectiveLimits.maxEvidenceLength + ); + if (evidence.omitted > 0) { + omittedCounts.evidence = (omittedCounts.evidence ?? 0) + evidence.omitted; + } + + return { + ...result, + workspaceIds: workspaceIds.values ?? [], + evidence: evidence.values, + rawTail: + typeof result.rawTail === 'string' + ? truncate(result.rawTail, effectiveLimits.maxRawTailLength) + : undefined, + }; + }); + + return { + ...manifest, + strategyResults: budgetedResults, + omittedCounts: Object.keys(omittedCounts).length > 0 ? omittedCounts : undefined, + }; +} diff --git a/src/features/workspace-trust/core/domain/WorkspaceTrustPath.ts b/src/features/workspace-trust/core/domain/WorkspaceTrustPath.ts new file mode 100644 index 00000000..8cb80a20 --- /dev/null +++ b/src/features/workspace-trust/core/domain/WorkspaceTrustPath.ts @@ -0,0 +1,253 @@ +import path from 'node:path'; + +import type { + WorkspaceTrustNonPersistableReason, + WorkspaceTrustWorkspace, + WorkspaceTrustWorkspaceSource, +} from './WorkspaceTrustTypes'; + +export type WorkspaceTrustPathPlatform = 'posix' | 'win32'; + +export type WorkspaceTrustPathOptions = { + platform?: WorkspaceTrustPathPlatform; +}; + +export type BuildWorkspaceTrustPathCandidatesInput = WorkspaceTrustPathOptions & { + cwd: string; + realCwd?: string | null; + gitRoot?: string | null; + homeDir?: string | null; + source?: WorkspaceTrustWorkspaceSource; + memberId?: string; +}; + +const WORKSPACE_ID_PREFIX = 'workspace-trust'; + +function defaultPlatform(): WorkspaceTrustPathPlatform { + return process.platform === 'win32' ? 'win32' : 'posix'; +} + +function pathForPlatform(platform: WorkspaceTrustPathPlatform): typeof path.posix { + return platform === 'win32' ? path.win32 : path.posix; +} + +function withPlatform(options?: WorkspaceTrustPathOptions): WorkspaceTrustPathPlatform { + return options?.platform ?? defaultPlatform(); +} + +function isBlank(value: string | null | undefined): value is '' | null | undefined { + return typeof value !== 'string' || value.trim().length === 0; +} + +function trimTrailingSeparators(value: string, platform: WorkspaceTrustPathPlatform): string { + const pathApi = pathForPlatform(platform); + const root = pathApi.parse(value).root; + let output = value; + while (output.length > root.length && /[\\/]$/.test(output)) { + output = output.slice(0, -1); + } + return output; +} + +export function normalizeWorkspaceTrustConfigKey( + value: string, + options?: WorkspaceTrustPathOptions +): string { + if (isBlank(value)) { + return ''; + } + const platform = withPlatform(options); + const pathApi = pathForPlatform(platform); + const normalized = trimTrailingSeparators(pathApi.normalize(value), platform); + return normalized.replace(/\\/g, '/'); +} + +export function normalizeWorkspaceTrustComparisonKey( + value: string, + options?: WorkspaceTrustPathOptions +): string { + const platform = withPlatform(options); + const configKey = normalizeWorkspaceTrustConfigKey(value, { platform }); + return platform === 'win32' ? configKey.toLowerCase() : configKey; +} + +export function collectWorkspaceTrustParentConfigKeys( + value: string, + options?: WorkspaceTrustPathOptions +): string[] { + if (isBlank(value)) { + return []; + } + + const platform = withPlatform(options); + const pathApi = pathForPlatform(platform); + const keys: string[] = []; + const seen = new Set(); + let current = trimTrailingSeparators(pathApi.normalize(value), platform); + + while (current) { + const configKey = normalizeWorkspaceTrustConfigKey(current, { platform }); + if (!seen.has(configKey)) { + seen.add(configKey); + keys.push(configKey); + } + + const parent = pathApi.dirname(current); + if (parent === current) { + break; + } + current = parent; + } + + return keys; +} + +export function isFilesystemRootWorkspacePath( + value: string, + options?: WorkspaceTrustPathOptions +): boolean { + if (isBlank(value)) { + return false; + } + const platform = withPlatform(options); + const pathApi = pathForPlatform(platform); + const normalized = trimTrailingSeparators(pathApi.normalize(value), platform); + return normalized === pathApi.parse(normalized).root; +} + +export function getWorkspaceTrustNonPersistableReason( + value: string, + options?: WorkspaceTrustPathOptions & { homeDir?: string | null } +): WorkspaceTrustNonPersistableReason | undefined { + if (isBlank(value)) { + return 'unavailable'; + } + const platform = withPlatform(options); + if (isFilesystemRootWorkspacePath(value, { platform })) { + return 'filesystem_root'; + } + const homeDir = options?.homeDir; + if (!isBlank(homeDir)) { + const valueKey = normalizeWorkspaceTrustComparisonKey(value, { platform }); + const homeKey = normalizeWorkspaceTrustComparisonKey(homeDir, { platform }); + if (valueKey === homeKey) { + return 'home_directory'; + } + } + return undefined; +} + +function stableWorkspaceId( + source: WorkspaceTrustWorkspaceSource, + comparisonKey: string, + memberId?: string +): string { + const owner = memberId ? `${source}:${memberId}` : source; + return `${WORKSPACE_ID_PREFIX}:${owner}:${comparisonKey}`; +} + +function buildWorkspace( + input: BuildWorkspaceTrustPathCandidatesInput & { + cwd: string; + displayCwd: string; + source: WorkspaceTrustWorkspaceSource; + gitRootConfigKey?: string; + } +): WorkspaceTrustWorkspace | null { + const platform = withPlatform(input); + if (isBlank(input.cwd)) { + return null; + } + + const configKeyCwd = normalizeWorkspaceTrustConfigKey(input.cwd, { platform }); + const comparisonKey = normalizeWorkspaceTrustComparisonKey(input.cwd, { platform }); + const reason = getWorkspaceTrustNonPersistableReason(input.cwd, { + platform, + homeDir: input.homeDir, + }); + + return { + id: stableWorkspaceId(input.source, comparisonKey, input.memberId), + displayCwd: input.displayCwd, + cwd: input.cwd, + realCwd: input.realCwd || input.cwd, + configKeyCwd, + gitRootConfigKey: input.gitRootConfigKey, + comparisonKey, + source: input.source, + memberId: input.memberId, + persistable: !reason, + nonPersistableReason: reason, + }; +} + +export function dedupeWorkspaceTrustWorkspaces( + workspaces: WorkspaceTrustWorkspace[] +): WorkspaceTrustWorkspace[] { + const output: WorkspaceTrustWorkspace[] = []; + const seen = new Set(); + for (const workspace of workspaces) { + if (seen.has(workspace.comparisonKey)) { + continue; + } + seen.add(workspace.comparisonKey); + output.push(workspace); + } + return output; +} + +export function buildWorkspaceTrustPathCandidates( + input: BuildWorkspaceTrustPathCandidatesInput +): WorkspaceTrustWorkspace[] { + const platform = withPlatform(input); + const source = input.source ?? 'team-root'; + const gitRootConfigKey = isBlank(input.gitRoot) + ? undefined + : normalizeWorkspaceTrustConfigKey(input.gitRoot, { platform }); + const candidates: WorkspaceTrustWorkspace[] = []; + + const primary = buildWorkspace({ + ...input, + platform, + cwd: input.cwd, + displayCwd: input.cwd, + realCwd: input.realCwd || input.cwd, + source, + gitRootConfigKey, + }); + if (primary) { + candidates.push(primary); + } + + if (!isBlank(input.realCwd)) { + const real = buildWorkspace({ + ...input, + platform, + cwd: input.realCwd, + displayCwd: input.cwd, + realCwd: input.realCwd, + source, + gitRootConfigKey, + }); + if (real) { + candidates.push(real); + } + } + + if (!isBlank(input.gitRoot)) { + const gitRoot = buildWorkspace({ + ...input, + platform, + cwd: input.gitRoot, + displayCwd: input.gitRoot, + realCwd: input.gitRoot, + source: 'git-root', + gitRootConfigKey, + }); + if (gitRoot) { + candidates.push(gitRoot); + } + } + + return dedupeWorkspaceTrustWorkspaces(candidates); +} diff --git a/src/features/workspace-trust/core/domain/WorkspaceTrustTypes.ts b/src/features/workspace-trust/core/domain/WorkspaceTrustTypes.ts new file mode 100644 index 00000000..c27b634f --- /dev/null +++ b/src/features/workspace-trust/core/domain/WorkspaceTrustTypes.ts @@ -0,0 +1,80 @@ +export type WorkspaceTrustProvider = 'claude' | 'anthropic' | 'codex' | 'gemini' | 'opencode'; + +export type WorkspaceTrustWorkspaceSource = + | 'team-root' + | 'member-worktree' + | 'member-cwd' + | 'git-root'; + +export type WorkspaceTrustNonPersistableReason = + | 'home_directory' + | 'filesystem_root' + | 'unavailable'; + +export type WorkspaceTrustWorkspace = { + id: string; + displayCwd: string; + cwd: string; + realCwd: string; + configKeyCwd: string; + gitRootConfigKey?: string; + comparisonKey: string; + source: WorkspaceTrustWorkspaceSource; + memberId?: string; + persistable: boolean; + nonPersistableReason?: WorkspaceTrustNonPersistableReason; +}; + +export type WorkspaceTrustFeatureFlags = { + enabled: boolean; + claudePty: boolean; + codexArgs: boolean; + retry: boolean; + fileLock: boolean; +}; + +export type WorkspaceTrustLaunchArgTargetSurface = + | 'primary_provider_args' + | 'cross_provider_member_args' + | 'provider_facts_probe' + | 'default_model_probe'; + +export type WorkspaceTrustLaunchArgDialect = + | 'codex-native-config-override' + | 'claude-codex-runtime-settings' + | 'codex-direct-cli-config'; + +export type WorkspaceTrustLaunchArgPatch = { + id: string; + owner: 'workspace-trust'; + targetProvider: WorkspaceTrustProvider; + targetSurface: WorkspaceTrustLaunchArgTargetSurface; + dialect: WorkspaceTrustLaunchArgDialect; + args: string[]; + dedupeKey: string; + sourceWorkspaceIds: string[]; + reason: string; +}; + +export type WorkspaceTrustExecutionStatus = 'ok' | 'soft_failed' | 'blocked' | 'cancelled'; + +export type WorkspaceTrustDiagnosticStrategyResult = { + id: string; + provider: WorkspaceTrustProvider; + status: WorkspaceTrustExecutionStatus | 'skipped'; + workspaceIds: string[]; + matchedRuleIds?: string[]; + actions?: string[]; + evidence?: string[]; + elapsedMs?: number; + errorCode?: string; + errorMessage?: string; + rawTail?: string; +}; + +export type WorkspaceTrustDiagnosticsManifest = { + attempt: number; + featureFlags: WorkspaceTrustFeatureFlags; + strategyResults: WorkspaceTrustDiagnosticStrategyResult[]; + omittedCounts?: Record; +}; diff --git a/src/features/workspace-trust/core/domain/index.ts b/src/features/workspace-trust/core/domain/index.ts new file mode 100644 index 00000000..6318fe5f --- /dev/null +++ b/src/features/workspace-trust/core/domain/index.ts @@ -0,0 +1,5 @@ +export * from './CodexWorkspaceTrustSettings'; +export * from './WorkspaceTrustArgPatchApplier'; +export * from './WorkspaceTrustDiagnosticsBudget'; +export * from './WorkspaceTrustPath'; +export type * from './WorkspaceTrustTypes'; diff --git a/src/features/workspace-trust/index.ts b/src/features/workspace-trust/index.ts new file mode 100644 index 00000000..c7041c4c --- /dev/null +++ b/src/features/workspace-trust/index.ts @@ -0,0 +1 @@ +export * from './contracts'; diff --git a/src/features/workspace-trust/main/adapters/output/ClaudeStateProbe.ts b/src/features/workspace-trust/main/adapters/output/ClaudeStateProbe.ts new file mode 100644 index 00000000..b286ed6b --- /dev/null +++ b/src/features/workspace-trust/main/adapters/output/ClaudeStateProbe.ts @@ -0,0 +1,111 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { + collectWorkspaceTrustParentConfigKeys, + normalizeWorkspaceTrustConfigKey, + type WorkspaceTrustPathPlatform, +} from '../../../core/domain'; + +import type { ProviderStateProbe, ProviderTrustState } from '../../../core/application'; +import type { WorkspaceTrustWorkspace } from '../../../core/domain'; + +const DEFAULT_MAX_CONFIG_BYTES = 1024 * 1024; +const DEFAULT_READ_ATTEMPTS = 3; +const DEFAULT_RETRY_DELAY_MS = 40; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function hasTrustDialogAccepted(value: unknown): boolean { + return isRecord(value) && value.hasTrustDialogAccepted === true; +} + +export class FileClaudeStateProbe implements ProviderStateProbe { + constructor( + private readonly options: { + claudeConfigDir?: string; + globalConfigFilePath?: string | (() => string); + platform?: WorkspaceTrustPathPlatform; + maxConfigBytes?: number; + readAttempts?: number; + retryDelayMs?: number; + } + ) {} + + async readTrustState(workspace: WorkspaceTrustWorkspace): Promise { + const configPath = + (typeof this.options.globalConfigFilePath === 'function' + ? this.options.globalConfigFilePath() + : this.options.globalConfigFilePath) ?? + path.join(this.options.claudeConfigDir ?? process.cwd(), '.claude.json'); + const attempts = this.options.readAttempts ?? DEFAULT_READ_ATTEMPTS; + const retryDelayMs = this.options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS; + let lastError: string | undefined; + + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + const stat = await fs.stat(configPath); + if (stat.size > (this.options.maxConfigBytes ?? DEFAULT_MAX_CONFIG_BYTES)) { + return { + status: 'unknown', + errorMessage: `Claude state file exceeds ${this.options.maxConfigBytes ?? DEFAULT_MAX_CONFIG_BYTES} bytes.`, + }; + } + + const raw = await fs.readFile(configPath, 'utf8'); + const parsed = JSON.parse(raw) as unknown; + if (!isRecord(parsed) || !isRecord(parsed.projects)) { + return { status: 'untrusted', evidence: ['claude state has no projects map'] }; + } + + for (const key of this.buildCandidateConfigKeys(workspace)) { + if (hasTrustDialogAccepted(parsed.projects[key])) { + return { status: 'trusted', evidence: [`trusted project key: ${key}`] }; + } + } + return { status: 'untrusted', evidence: ['no trusted project key matched'] }; + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') { + return { status: 'untrusted', evidence: ['claude state file missing'] }; + } + lastError = error instanceof Error ? error.message : String(error); + if (attempt < attempts) { + await sleep(retryDelayMs); + } + } + } + + return { + status: 'unknown', + errorMessage: lastError ?? 'Claude state file could not be read.', + }; + } + + private buildCandidateConfigKeys(workspace: WorkspaceTrustWorkspace): string[] { + const keys = new Set(); + const platform = this.options.platform; + const addParents = (value: string | undefined): void => { + if (!value) { + return; + } + for (const key of collectWorkspaceTrustParentConfigKeys(value, { platform })) { + keys.add(key); + } + }; + + addParents(workspace.cwd); + addParents(workspace.realCwd); + addParents(workspace.configKeyCwd); + if (workspace.gitRootConfigKey) { + keys.add(normalizeWorkspaceTrustConfigKey(workspace.gitRootConfigKey, { platform })); + addParents(workspace.gitRootConfigKey); + } + return [...keys]; + } +} diff --git a/src/features/workspace-trust/main/adapters/output/NodePtyProcessAdapter.ts b/src/features/workspace-trust/main/adapters/output/NodePtyProcessAdapter.ts new file mode 100644 index 00000000..f3ba877f --- /dev/null +++ b/src/features/workspace-trust/main/adapters/output/NodePtyProcessAdapter.ts @@ -0,0 +1,100 @@ +import { createLogger } from '@shared/utils/logger'; + +import type { IPty } from 'node-pty'; +import type * as NodePty from 'node-pty'; +import type { + PtyKeyAction, + PtyProcessPort, + PtySessionPort, + PtySpawnInput, + PtySpawnResult, + TerminalSnapshot, +} from '../../../core/application'; + +const logger = createLogger('WorkspaceTrustNodePtyProcessAdapter'); +const MAX_TRANSCRIPT_CHARS = 64 * 1024; + +type NodePtyModule = typeof NodePty; + +let nodePty: NodePtyModule | null | undefined; + +function loadNodePty(): NodePtyModule | null { + if (nodePty !== undefined) { + return nodePty; + } + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports -- node-pty is optional native addon + nodePty = require('node-pty') as NodePtyModule; + } catch (error) { + logger.warn(`node-pty unavailable for workspace trust preflight: ${String(error)}`); + nodePty = null; + } + return nodePty; +} + +class NodePtySession implements PtySessionPort { + #transcript = ''; + #exited = false; + + constructor(private readonly pty: IPty) { + this.pty.onData((chunk) => { + this.#transcript = (this.#transcript + chunk).slice(-MAX_TRANSCRIPT_CHARS); + }); + this.pty.onExit(() => { + this.#exited = true; + }); + } + + async readSnapshot(timeoutMs: number): Promise { + await new Promise((resolve) => setTimeout(resolve, timeoutMs)); + if (!this.#transcript && this.#exited) { + return null; + } + return { + text: this.#transcript, + capturedAtMs: Date.now(), + }; + } + + async writeAction(action: PtyKeyAction): Promise { + this.pty.write(action.sequence); + } + + async kill(): Promise { + try { + this.pty.kill(); + } catch { + /* already exited */ + } + } +} + +export class NodePtyProcessAdapter implements PtyProcessPort { + async spawn(input: PtySpawnInput): Promise { + const ptyModule = loadNodePty(); + if (!ptyModule) { + return { + ok: false, + code: 'node_pty_unavailable', + message: 'node-pty is unavailable for workspace trust preflight.', + }; + } + + try { + const pty = ptyModule.spawn(input.command, input.args, { + name: input.name ?? 'xterm-256color', + cols: input.cols ?? 120, + rows: input.rows ?? 36, + cwd: input.cwd, + env: input.env, + }); + return { ok: true, session: new NodePtySession(pty) }; + } catch (error) { + return { + ok: false, + code: 'node_pty_spawn_failed', + message: error instanceof Error ? error.message : String(error), + }; + } + } +} diff --git a/src/features/workspace-trust/main/adapters/output/TempEmptyMcpConfigStore.ts b/src/features/workspace-trust/main/adapters/output/TempEmptyMcpConfigStore.ts new file mode 100644 index 00000000..3007044d --- /dev/null +++ b/src/features/workspace-trust/main/adapters/output/TempEmptyMcpConfigStore.ts @@ -0,0 +1,21 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import type { TempEmptyMcpConfigHandle, TempEmptyMcpConfigStore } from '../../../core/application'; + +export class FileTempEmptyMcpConfigStore implements TempEmptyMcpConfigStore { + constructor(private readonly rootDir: string = os.tmpdir()) {} + + async create(): Promise { + const dir = await fs.mkdtemp(path.join(this.rootDir, 'agent-teams-workspace-trust-')); + const filePath = path.join(dir, 'empty-mcp.json'); + await fs.writeFile(filePath, `${JSON.stringify({ mcpServers: {} })}\n`, 'utf8'); + return { + path: filePath, + cleanup: async () => { + await fs.rm(dir, { recursive: true, force: true }); + }, + }; + } +} diff --git a/src/features/workspace-trust/main/composition/createWorkspaceTrustCoordinator.ts b/src/features/workspace-trust/main/composition/createWorkspaceTrustCoordinator.ts new file mode 100644 index 00000000..932ecbe6 --- /dev/null +++ b/src/features/workspace-trust/main/composition/createWorkspaceTrustCoordinator.ts @@ -0,0 +1,28 @@ +import { + ClaudePtyWorkspaceTrustStrategy, + DefaultWorkspaceTrustCoordinator, +} from '../../core/application'; +import { FileClaudeStateProbe } from '../adapters/output/ClaudeStateProbe'; +import { NodePtyProcessAdapter } from '../adapters/output/NodePtyProcessAdapter'; +import { FileTempEmptyMcpConfigStore } from '../adapters/output/TempEmptyMcpConfigStore'; + +import type { WorkspaceTrustCoordinator } from '../../core/application'; + +export function createWorkspaceTrustCoordinator(input: { + claudeConfigDir?: string | (() => string); + globalConfigFilePath: string | (() => string); +}): WorkspaceTrustCoordinator { + return new DefaultWorkspaceTrustCoordinator( + new ClaudePtyWorkspaceTrustStrategy({ + ptyProcess: new NodePtyProcessAdapter(), + stateProbe: new FileClaudeStateProbe({ + claudeConfigDir: + typeof input.claudeConfigDir === 'function' + ? input.claudeConfigDir() + : input.claudeConfigDir, + globalConfigFilePath: input.globalConfigFilePath, + }), + tempEmptyMcpConfigStore: new FileTempEmptyMcpConfigStore(), + }) + ); +} diff --git a/src/features/workspace-trust/main/index.ts b/src/features/workspace-trust/main/index.ts new file mode 100644 index 00000000..dc8357d9 --- /dev/null +++ b/src/features/workspace-trust/main/index.ts @@ -0,0 +1,29 @@ +export { + ClaudePtyWorkspaceTrustStrategy, + buildClaudeWorkspaceTrustPreflightArgs, + runPtyDialogEngine, +} from '../core/application'; +export { + applyWorkspaceTrustLaunchArgPatches, + budgetWorkspaceTrustDiagnosticsManifest, + buildCodexTrustedProjectConfigOverride, + buildCodexTrustedProjectConfigOverrides, + buildCodexWorkspaceTrustSettings, + buildCodexWorkspaceTrustSettingsArgs, + buildWorkspaceTrustPathCandidates, + collectWorkspaceTrustParentConfigKeys, + dedupeWorkspaceTrustWorkspaces, + getWorkspaceTrustNonPersistableReason, + isCodexWorkspaceTrustConfigOverride, + isFilesystemRootWorkspacePath, + normalizeWorkspaceTrustComparisonKey, + normalizeWorkspaceTrustConfigKey, +} from '../core/domain'; +export { FileClaudeStateProbe } from './adapters/output/ClaudeStateProbe'; +export { NodePtyProcessAdapter } from './adapters/output/NodePtyProcessAdapter'; +export { FileTempEmptyMcpConfigStore } from './adapters/output/TempEmptyMcpConfigStore'; +export { createWorkspaceTrustCoordinator } from './composition/createWorkspaceTrustCoordinator'; +export { resolveWorkspaceTrustFeatureFlags } from './infrastructure/WorkspaceTrustFeatureFlags'; +export { buildWorkspaceTrustPreflightEnv } from './infrastructure/workspaceTrustPreflightEnv'; +export type * from '../core/application'; +export type * from '../core/domain/WorkspaceTrustTypes'; diff --git a/src/features/workspace-trust/main/infrastructure/WorkspaceTrustFeatureFlags.ts b/src/features/workspace-trust/main/infrastructure/WorkspaceTrustFeatureFlags.ts new file mode 100644 index 00000000..c2ecab06 --- /dev/null +++ b/src/features/workspace-trust/main/infrastructure/WorkspaceTrustFeatureFlags.ts @@ -0,0 +1,74 @@ +import { createLogger } from '@shared/utils/logger'; + +import type { WorkspaceTrustFeatureFlags } from '../../core/domain'; + +const logger = createLogger('WorkspaceTrustFeatureFlags'); +const warnedMalformedFlags = new Set(); + +function warnMalformedFlagOnce(name: string, value: string, defaultLabel: 'on' | 'off'): void { + if (warnedMalformedFlags.has(name)) { + return; + } + warnedMalformedFlags.add(name); + logger.warn( + `Ignoring malformed workspace trust feature flag ${name}=${JSON.stringify( + value + )}; using default ${defaultLabel}.` + ); +} + +function parseDefaultOn(name: string, value: string | undefined): boolean { + const normalized = value?.trim().toLowerCase(); + if (!normalized || normalized === '1' || normalized === 'true' || normalized === 'on') { + return true; + } + if (normalized === '0' || normalized === 'false' || normalized === 'off') { + return false; + } + warnMalformedFlagOnce(name, value ?? '', 'on'); + return true; +} + +function parseDefaultOff(name: string, value: string | undefined): boolean { + const normalized = value?.trim().toLowerCase(); + if (!normalized || normalized === '0' || normalized === 'false' || normalized === 'off') { + return false; + } + if (normalized === '1' || normalized === 'true' || normalized === 'on') { + return true; + } + warnMalformedFlagOnce(name, value ?? '', 'off'); + return false; +} + +export function resolveWorkspaceTrustFeatureFlags( + env: NodeJS.ProcessEnv = process.env +): WorkspaceTrustFeatureFlags { + const enabledFlagName = + env.AGENT_TEAMS_WORKSPACE_TRUST_PREFLIGHT !== undefined + ? 'AGENT_TEAMS_WORKSPACE_TRUST_PREFLIGHT' + : 'AGENT_TEAMS_WORKSPACE_TRUST'; + const enabledFlag = env.AGENT_TEAMS_WORKSPACE_TRUST_PREFLIGHT ?? env.AGENT_TEAMS_WORKSPACE_TRUST; + const enabled = parseDefaultOn(enabledFlagName, enabledFlag); + const fileLockEnabled = false; + const codexSettingsFlagName = + env.AGENT_TEAMS_WORKSPACE_TRUST_CODEX_SETTINGS !== undefined + ? 'AGENT_TEAMS_WORKSPACE_TRUST_CODEX_SETTINGS' + : 'AGENT_TEAMS_WORKSPACE_TRUST_CODEX_ARGS'; + const codexSettingsFlag = + env.AGENT_TEAMS_WORKSPACE_TRUST_CODEX_SETTINGS ?? env.AGENT_TEAMS_WORKSPACE_TRUST_CODEX_ARGS; + return { + enabled, + claudePty: + enabled && + parseDefaultOn( + 'AGENT_TEAMS_WORKSPACE_TRUST_CLAUDE_PTY', + env.AGENT_TEAMS_WORKSPACE_TRUST_CLAUDE_PTY + ), + codexArgs: enabled && parseDefaultOn(codexSettingsFlagName, codexSettingsFlag), + retry: + enabled && + parseDefaultOff('AGENT_TEAMS_WORKSPACE_TRUST_RETRY', env.AGENT_TEAMS_WORKSPACE_TRUST_RETRY), + fileLock: enabled && fileLockEnabled, + }; +} diff --git a/src/features/workspace-trust/main/infrastructure/workspaceTrustPreflightEnv.ts b/src/features/workspace-trust/main/infrastructure/workspaceTrustPreflightEnv.ts new file mode 100644 index 00000000..b1c523f6 --- /dev/null +++ b/src/features/workspace-trust/main/infrastructure/workspaceTrustPreflightEnv.ts @@ -0,0 +1,39 @@ +const EXACT_STRIP_ENV_KEYS = new Set([ + 'CLAUDE_ENABLE_DETERMINISTIC_TEAM_BOOTSTRAP', + 'CLAUDE_TEAM_CONTROL_URL', + 'CLAUDE_TEAM_ANTHROPIC_AUTH_MODE', + 'CLAUDE_TEAM_ANTHROPIC_AUTH_MODE_API_KEY_HELPER', + 'CLAUDE_TEAM_ANTHROPIC_API_KEY_HELPER_SETTINGS_PATH', + 'CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST', + 'CLAUDE_CODE_ENTRY_PROVIDER', + 'CLAUDE_CODE_USE_OPENAI', + 'CLAUDE_CODE_USE_BEDROCK', + 'CLAUDE_CODE_USE_VERTEX', + 'CLAUDE_CODE_USE_FOUNDRY', + 'CLAUDE_CODE_USE_GEMINI', + 'CLAUDE_CODE_GEMINI_BACKEND', + 'CLAUDE_CODE_CODEX_BACKEND', + 'CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH', + 'CODEX_HOME', +]); + +const STRIP_ENV_PREFIXES = [ + 'AGENT_TEAMS_RUNTIME_TURN_SETTLED_', + 'AGENT_TEAMS_MCP_', + 'CLAUDE_TEAM_BOOTSTRAP_', +]; + +export function buildWorkspaceTrustPreflightEnv( + env: Record +): Record { + const output: Record = { ...env }; + for (const key of Object.keys(output)) { + if ( + EXACT_STRIP_ENV_KEYS.has(key) || + STRIP_ENV_PREFIXES.some((prefix) => key.startsWith(prefix)) + ) { + delete output[key]; + } + } + return output; +} diff --git a/src/main/index.ts b/src/main/index.ts index 4c914fc7..643ea821 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -56,6 +56,7 @@ import { removeRuntimeProviderManagementIpc, type RuntimeProviderManagementFeatureFacade, } from '@features/runtime-provider-management/main'; +import { createWorkspaceTrustCoordinator } from '@features/workspace-trust/main'; import { ClaudeMultimodelBridgeService } from '@main/services/runtime/ClaudeMultimodelBridgeService'; import { applyOpenCodeAutoUpdatePolicy } from '@main/services/runtime/openCodeAutoUpdatePolicy'; import { providerConnectionService } from '@main/services/runtime/ProviderConnectionService'; @@ -160,7 +161,9 @@ import { import { TeamSentMessagesStore } from './services/team/TeamSentMessagesStore'; import { getAppIconPath } from './utils/appIcon'; import { + getAutoDetectedClaudeBasePath, getClaudeBasePath, + getHomeDir, getProjectsBasePath, getTeamsBasePath, getTodosBasePath, @@ -1359,6 +1362,17 @@ async function initializeServices(): Promise { teamDataService = new TeamDataService(); teamDataService.setMemberRuntimeAdvisoryService(teamMemberRuntimeAdvisoryService); teamProvisioningService = new TeamProvisioningService(); + teamProvisioningService.setWorkspaceTrustCoordinator( + createWorkspaceTrustCoordinator({ + claudeConfigDir: () => getClaudeBasePath(), + globalConfigFilePath: () => { + const claudeBasePath = getClaudeBasePath(); + return claudeBasePath !== getAutoDetectedClaudeBasePath() + ? join(claudeBasePath, '.claude.json') + : join(getHomeDir(), '.claude.json'); + }, + }) + ); teamProvisioningService.setMemberRuntimeAdvisoryInvalidator((teamName, memberName) => { teamDataService?.invalidateMemberRuntimeAdvisory(teamName, memberName); getTeamDataWorkerClient().invalidateMemberRuntimeAdvisory(teamName, memberName); @@ -1610,16 +1624,37 @@ async function initializeServices(): Promise { }, ], nudgeDeliveryWake: { - schedule: (input) => { - if (input.providerId !== 'opencode') { + schedule: async (input) => { + if (input.providerId === 'opencode') { + teamProvisioningService.scheduleOpenCodeMemberInboxDeliveryWake({ + teamName: input.teamName, + memberName: input.memberName, + messageId: input.messageId, + delayMs: input.delayMs, + }); return; } - teamProvisioningService.scheduleOpenCodeMemberInboxDeliveryWake({ - teamName: input.teamName, - memberName: input.memberName, - messageId: input.messageId, - delayMs: input.delayMs, - }); + + const leadName = await teamDataService.getLeadMemberName(input.teamName).catch(() => null); + if (leadName?.trim().toLowerCase() !== input.memberName.trim().toLowerCase()) { + return; + } + + const timer = setTimeout( + () => { + void teamProvisioningService + .relayLeadInboxMessages(input.teamName) + .catch((error: unknown) => + logger.warn( + `[${input.teamName}] member-work-sync lead nudge relay wake failed: ${String( + error + )}` + ) + ); + }, + Math.max(0, input.delayMs ?? 0) + ); + timer.unref?.(); }, }, reviewPickupDelivery: { diff --git a/src/main/services/team/TeamLaunchFailureArtifactPack.ts b/src/main/services/team/TeamLaunchFailureArtifactPack.ts index 4d01ee8d..b6f0595b 100644 --- a/src/main/services/team/TeamLaunchFailureArtifactPack.ts +++ b/src/main/services/team/TeamLaunchFailureArtifactPack.ts @@ -217,7 +217,7 @@ function firstEvidence(parts: readonly string[], pattern: RegExp): string[] { } const WORKSPACE_TRUST_FAILURE_PATTERN = - /workspace trust is not accepted|cannot start in headless process runtime because workspace trust|open that workspace once interactively and accept trust/i; + /workspace trust is not accepted|cannot start in headless process runtime because workspace trust|open that workspace once interactively and accept trust|workspace_trust_preflight_not_confirmed|workspace trust was not confirmed|workspace trust preflight blocked launch/i; export function isWorkspaceTrustLaunchFailureText(value: string): boolean { return WORKSPACE_TRUST_FAILURE_PATTERN.test(value); diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 004c8de0..a7cd4f70 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -30,6 +30,25 @@ import { sendKeysToTmuxPaneForCurrentPlatform, type TmuxPaneRuntimeInfo, } from '@features/tmux-installer/main'; +import { + applyWorkspaceTrustLaunchArgPatches, + budgetWorkspaceTrustDiagnosticsManifest, + buildWorkspaceTrustPathCandidates, + buildWorkspaceTrustPreflightEnv, + resolveWorkspaceTrustFeatureFlags, + type WorkspaceTrustCoordinator, + type WorkspaceTrustArgsOnlyPlanRequest, + type WorkspaceTrustArgsOnlyPlanResult, + type WorkspaceTrustDiagnosticsManifest, + type WorkspaceTrustExecutionResult, + type WorkspaceTrustFeatureFlags, + type WorkspaceTrustFullPlanRequest, + type WorkspaceTrustFullPlanResult, + type WorkspaceTrustLaunchArgPatch, + type WorkspaceTrustLaunchArgTargetSurface, + type WorkspaceTrustProvider, + type WorkspaceTrustWorkspace, +} from '@features/workspace-trust/main'; import { ConfigManager } from '@main/services/infrastructure/ConfigManager'; import { NotificationManager } from '@main/services/infrastructure/NotificationManager'; import { getAppIconPath } from '@main/utils/appIcon'; @@ -124,7 +143,7 @@ import { parseAgentToolResultStatus, } from '@shared/utils/toolSummary'; import * as agentTeamsControllerModule from 'agent-teams-controller'; -import { type ChildProcess, execFileSync, type spawn } from 'child_process'; +import { type ChildProcess, execFile, execFileSync, type spawn } from 'child_process'; import { randomUUID } from 'crypto'; import * as fs from 'fs'; import * as os from 'os'; @@ -1681,6 +1700,19 @@ function isTerminalFailureProvisioningState(state: TeamProvisioningProgress['sta return state === 'failed' || state === 'cancelled' || state === 'disconnected'; } +function shouldIgnoreProvisioningProgressRegression( + currentState: TeamProvisioningProgress['state'], + nextState: TeamProvisioningProgress['state'] +): boolean { + if (currentState === 'ready') { + return nextState !== 'ready' && nextState !== 'disconnected'; + } + if (isTerminalFailureProvisioningState(currentState)) { + return nextState !== currentState; + } + return false; +} + interface ProvisioningRun { runId: string; teamName: string; @@ -1756,7 +1788,12 @@ interface ProvisioningRun { /** Path to the deferred first-user-task file consumed by runtime after bootstrap. */ bootstrapUserPromptPath: string | null; isLaunch: boolean; + launchStateClearedForRun: boolean; deterministicBootstrap: boolean; + workspaceTrustPlan?: WorkspaceTrustFullPlanResult | null; + workspaceTrustExecution?: WorkspaceTrustExecutionResult | null; + workspaceTrustDiagnostics?: WorkspaceTrustDiagnosticsManifest | null; + workspaceTrustRetryAttempted?: boolean; leadRelayCapture: { leadName: string; startedAt: string; @@ -4805,6 +4842,10 @@ function updateProgress( | 'launchDiagnostics' > ): TeamProvisioningProgress { + if (shouldIgnoreProvisioningProgressRegression(run.progress.state, state)) { + return run.progress; + } + // Cap assistant output on every progress tick. `updateProgress` is invoked // from ~20 event-driven sites (auth retries, stall warnings, spawn events), // and an unbounded `provisioningOutputParts.join` was part of the same OOM @@ -4968,6 +5009,47 @@ function buildLaunchDiagnosticsFromRun( return items.length > 0 ? items : undefined; } +function buildWorkspaceTrustPreflightLaunchDiagnostic( + execution: WorkspaceTrustExecutionResult +): TeamLaunchDiagnosticItem | null { + if (execution.status === 'cancelled') { + return null; + } + + const severity = + execution.status === 'blocked' + ? 'error' + : execution.status === 'soft_failed' + ? 'warning' + : 'info'; + const label = + execution.status === 'blocked' + ? 'Workspace trust preflight blocked launch' + : execution.status === 'soft_failed' + ? 'Workspace trust preflight could not verify trust' + : 'Workspace trust preflight completed'; + const detail = + execution.errorMessage?.trim() || + execution.errorCode?.trim() || + execution.evidence?.find((item) => item.trim().length > 0)?.trim(); + + return { + id: 'workspace-trust:preflight', + severity, + code: 'workspace_trust_preflight', + label, + ...(detail ? { detail } : {}), + observedAt: nowIso(), + }; +} + +function mergeLaunchDiagnosticItem( + items: readonly TeamLaunchDiagnosticItem[] | undefined, + item: TeamLaunchDiagnosticItem +): TeamLaunchDiagnosticItem[] { + return [...(items ?? []).filter((candidate) => candidate.id !== item.id), item]; +} + function buildCombinedLogs( stdoutBuffer: string | undefined, stderrBuffer: string | undefined @@ -5545,6 +5627,7 @@ export class TeamProvisioningService { private inFlightResponses = new Set(); private runtimeAdapterRegistry: TeamRuntimeAdapterRegistry | null = null; private controlApiBaseUrlResolver: (() => Promise) | null = null; + private workspaceTrustCoordinator: WorkspaceTrustCoordinator | null = null; private runtimeTurnSettledHookSettingsProvider: | ((input: { provider: RuntimeTurnSettledProvider }) => Promise | null>) | null = null; @@ -5659,6 +5742,7 @@ export class TeamProvisioningService { isLaunch: run.isLaunch, provisioningComplete: run.provisioningComplete, deterministicBootstrap: run.deterministicBootstrap, + workspaceTrustPreflight: run.workspaceTrustDiagnostics ?? null, processKilled: run.processKilled, finalizingByTimeout: run.finalizingByTimeout, cancelRequested: run.cancelRequested, @@ -5907,6 +5991,10 @@ export class TeamProvisioningService { this.controlApiBaseUrlResolver = resolver; } + setWorkspaceTrustCoordinator(coordinator: WorkspaceTrustCoordinator | null): void { + this.workspaceTrustCoordinator = coordinator; + } + setRuntimeTurnSettledHookSettingsProvider( provider: | ((input: { @@ -5927,6 +6015,171 @@ export class TeamProvisioningService { this.runtimeTurnSettledEnvironmentProvider = provider; } + private toWorkspaceTrustProvider(providerId: TeamProviderId): WorkspaceTrustProvider { + return providerId === 'anthropic' ? 'claude' : providerId; + } + + private collectWorkspaceTrustProviders(input: { + leadProviderId?: TeamProviderId; + members: TeamCreateRequest['members']; + }): WorkspaceTrustProvider[] { + const providers = new Set(['claude']); + providers.add(this.toWorkspaceTrustProvider(resolveTeamProviderId(input.leadProviderId))); + for (const member of input.members) { + const providerId = + normalizeTeamMemberProviderId(member.providerId) ?? + normalizeTeamMemberProviderId((member as { provider?: unknown }).provider); + if (providerId) { + providers.add(this.toWorkspaceTrustProvider(providerId)); + } + } + return [...providers]; + } + + private resolveWorkspaceTrustGitRoot(cwd: string): Promise { + const normalizedCwd = cwd.trim(); + if (!normalizedCwd) { + return Promise.resolve(null); + } + return new Promise((resolve) => { + execFile( + 'git', + ['-C', normalizedCwd, 'rev-parse', '--show-toplevel'], + { + encoding: 'utf8', + maxBuffer: 16 * 1024, + timeout: 1000, + }, + (error, stdout) => { + if (error) { + resolve(null); + return; + } + const gitRoot = stdout.trim(); + resolve(gitRoot && path.isAbsolute(gitRoot) ? gitRoot : null); + } + ); + }); + } + + private async collectWorkspaceTrustWorkspaces(input: { + cwd: string; + members: TeamCreateRequest['members']; + }): Promise { + const homeDir = getHomeDir(); + const candidates: WorkspaceTrustWorkspace[] = []; + const gitRootCache = new Map(); + const addPath = async ( + cwd: string, + source: WorkspaceTrustWorkspace['source'], + memberId?: string + ): Promise => { + const realCwd = await fs.promises.realpath(cwd).catch(() => null); + let gitRoot = gitRootCache.get(cwd); + if (gitRoot === undefined) { + const resolvedGitRoot = await this.resolveWorkspaceTrustGitRoot(cwd); + gitRoot = resolvedGitRoot + ? await fs.promises.realpath(resolvedGitRoot).catch(() => resolvedGitRoot) + : null; + gitRootCache.set(cwd, gitRoot); + } + candidates.push( + ...buildWorkspaceTrustPathCandidates({ + cwd, + realCwd, + gitRoot, + homeDir, + source, + memberId, + platform: process.platform === 'win32' ? 'win32' : 'posix', + }) + ); + }; + + await addPath(input.cwd, 'team-root'); + for (const member of input.members) { + const memberCwd = member.cwd?.trim(); + if (!memberCwd) { + continue; + } + await addPath( + memberCwd, + member.isolation === 'worktree' ? 'member-worktree' : 'member-cwd', + member.name + ); + } + const seen = new Set(); + return candidates.filter((workspace) => { + if (seen.has(workspace.comparisonKey)) { + return false; + } + seen.add(workspace.comparisonKey); + return true; + }); + } + + private applyWorkspaceTrustArgPatches(input: { + args: string[]; + patches: WorkspaceTrustLaunchArgPatch[]; + targetProvider: TeamProviderId; + targetSurface: WorkspaceTrustLaunchArgTargetSurface; + }): string[] { + if (input.patches.length === 0) { + return input.args; + } + return applyWorkspaceTrustLaunchArgPatches({ + args: input.args, + patches: input.patches, + targetProvider: this.toWorkspaceTrustProvider(input.targetProvider), + targetSurface: input.targetSurface, + }).args; + } + + private async planWorkspaceTrustArgsOnlySafely( + request: WorkspaceTrustArgsOnlyPlanRequest + ): Promise { + if (!this.workspaceTrustCoordinator) { + return { launchArgPatches: [] }; + } + try { + return await this.workspaceTrustCoordinator.planArgsOnly(request); + } catch (error) { + logger.warn( + `Workspace trust args-only planning failed; continuing without trust arg patches: ${ + error instanceof Error ? error.message : String(error) + }` + ); + return { launchArgPatches: [] }; + } + } + + private async planWorkspaceTrustFullSafely( + request: WorkspaceTrustFullPlanRequest + ): Promise { + if (!this.workspaceTrustCoordinator) { + return null; + } + try { + return await this.workspaceTrustCoordinator.planFull(request); + } catch (error) { + logger.warn( + `Workspace trust full planning failed; continuing without trust arg patches: ${ + error instanceof Error ? error.message : String(error) + }` + ); + return { workspaces: request.workspaces, launchArgPatches: [] }; + } + } + + private isLaunchRunStillCurrent(run: ProvisioningRun): boolean { + return ( + this.runs.get(run.runId) === run && + this.provisioningRunByTeam.get(run.teamName) === run.runId && + !run.cancelRequested && + !run.processKilled + ); + } + private async buildRuntimeTurnSettledHookSettingsArgs( providerId: TeamProviderId ): Promise { @@ -5934,6 +6187,175 @@ export class TeamProvisioningService { return settings ? ['--settings', JSON.stringify(settings)] : []; } + private async prepareWorkspaceTrustForDeterministicRun(input: { + mode: 'create' | 'launch'; + run: ProvisioningRun; + claudePath: string; + shellEnv: NodeJS.ProcessEnv; + stopAllGenerationAtStart: number; + workspaceTrustPlan: WorkspaceTrustFullPlanResult | null; + featureFlags: WorkspaceTrustFeatureFlags; + provisioningEnv: ProvisioningEnvResolution; + }): Promise { + if ( + !this.workspaceTrustCoordinator || + !input.workspaceTrustPlan || + !input.featureFlags.enabled + ) { + return; + } + + input.run.workspaceTrustPlan = input.workspaceTrustPlan; + updateProgress(input.run, 'spawning', 'Preparing workspace trust', { + warnings: input.run.progress.warnings, + }); + input.run.onProgress(input.run.progress); + + let execution: WorkspaceTrustExecutionResult; + try { + execution = await this.workspaceTrustCoordinator.execute({ + claudePath: input.claudePath, + workspaces: input.workspaceTrustPlan.workspaces, + env: buildWorkspaceTrustPreflightEnv(input.shellEnv), + featureFlags: input.featureFlags, + isCancelled: () => + input.run.cancelRequested || + input.run.processKilled || + this.stopAllTeamsGeneration !== input.stopAllGenerationAtStart, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + execution = { + id: 'workspace-trust-coordinator', + provider: 'claude', + status: 'soft_failed', + workspaceIds: input.workspaceTrustPlan.workspaces.map((workspace) => workspace.id), + errorCode: 'workspace_trust_preflight_error', + errorMessage: message, + evidence: [message], + }; + } + input.run.workspaceTrustExecution = execution; + input.run.workspaceTrustDiagnostics = budgetWorkspaceTrustDiagnosticsManifest({ + attempt: 1, + featureFlags: input.featureFlags, + strategyResults: [execution], + }); + const workspaceTrustLaunchDiagnostic = buildWorkspaceTrustPreflightLaunchDiagnostic(execution); + const workspaceTrustLaunchDiagnostics = workspaceTrustLaunchDiagnostic + ? boundLaunchDiagnostics( + mergeLaunchDiagnosticItem( + input.run.progress.launchDiagnostics, + workspaceTrustLaunchDiagnostic + ) + ) + : input.run.progress.launchDiagnostics; + + if (!this.isLaunchRunStillCurrent(input.run)) { + if (this.runs.get(input.run.runId) === input.run) { + await this.cancelDeterministicRunBeforeSpawn(input.run, { + mode: input.mode, + provisioningEnv: input.provisioningEnv, + }); + } + throw new Error('Team launch cancelled by app shutdown'); + } + + if (execution.status === 'cancelled') { + await this.cancelDeterministicRunBeforeSpawn(input.run, { + mode: input.mode, + provisioningEnv: input.provisioningEnv, + }); + } + + if (execution.status === 'blocked') { + await this.failDeterministicRunBeforeSpawn(input.run, { + mode: input.mode, + message: 'Workspace trust required', + error: + execution.errorMessage || + execution.errorCode || + 'Workspace trust preflight blocked this launch.', + launchDiagnostics: workspaceTrustLaunchDiagnostics, + provisioningEnv: input.provisioningEnv, + }); + } + + if (execution.status === 'soft_failed') { + const warning = + execution.errorMessage || + execution.errorCode || + 'Workspace trust preflight could not verify trust before launch.'; + input.run.progress = { + ...input.run.progress, + warnings: mergeProvisioningWarnings(input.run.progress.warnings, warning), + launchDiagnostics: workspaceTrustLaunchDiagnostics, + }; + input.run.onProgress(input.run.progress); + } else if (workspaceTrustLaunchDiagnostics) { + input.run.progress = { + ...input.run.progress, + updatedAt: nowIso(), + launchDiagnostics: workspaceTrustLaunchDiagnostics, + }; + input.run.onProgress(input.run.progress); + } + } + + private async failDeterministicRunBeforeSpawn( + run: ProvisioningRun, + input: { + mode: 'create' | 'launch'; + message: string; + error: string; + launchDiagnostics?: TeamLaunchDiagnosticItem[]; + provisioningEnv: ProvisioningEnvResolution; + } + ): Promise { + updateProgress(run, 'failed', input.message, { + error: input.error, + warnings: run.progress.warnings, + launchDiagnostics: input.launchDiagnostics, + }); + run.onProgress(run.progress); + + if (input.provisioningEnv.anthropicApiKeyHelper) { + await cleanupAnthropicTeamApiKeyHelperMaterial({ + directory: input.provisioningEnv.anthropicApiKeyHelper.directory, + }).catch(() => undefined); + } + if (input.mode === 'launch') { + await this.restorePrelaunchConfig(run.teamName).catch(() => undefined); + } + this.cleanupRun(run); + throw new Error(input.error); + } + + private async cancelDeterministicRunBeforeSpawn( + run: ProvisioningRun, + input: { + mode: 'create' | 'launch'; + provisioningEnv: ProvisioningEnvResolution; + } + ): Promise { + updateProgress(run, 'cancelled', 'Team launch cancelled', { + warnings: run.progress.warnings, + }); + run.cancelRequested = true; + run.onProgress(run.progress); + + if (input.provisioningEnv.anthropicApiKeyHelper) { + await cleanupAnthropicTeamApiKeyHelperMaterial({ + directory: input.provisioningEnv.anthropicApiKeyHelper.directory, + }).catch(() => undefined); + } + if (input.mode === 'launch') { + await this.restorePrelaunchConfig(run.teamName).catch(() => undefined); + } + this.cleanupRun(run); + throw new Error('Team launch cancelled by app shutdown'); + } + private async buildRuntimeTurnSettledHookSettingsObject( providerId: TeamProviderId ): Promise { @@ -17526,6 +17948,11 @@ export class TeamProvisioningService { primaryEnv?: ProvisioningEnvResolution; teamRuntimeAuth?: TeamRuntimeAuthContext; limitContext?: boolean; + providerArgsResolver?: (input: { + providerId: TeamProviderId; + providerArgs: string[]; + phase: 'default-model-resolution'; + }) => string[]; }): Promise { const envByProvider = new Map>(); const defaultModelByProvider = new Map>(); @@ -17566,7 +17993,13 @@ export class TeamProvisioningService { params.cwd, providerId, envResolution.env, - envResolution.providerArgs, + params.providerArgsResolver?.({ + providerId, + providerArgs: envResolution.providerArgs ?? [], + phase: 'default-model-resolution', + }) ?? + envResolution.providerArgs ?? + [], params.limitContext === true ); const normalized = resolvedDefaultModel?.trim(); @@ -18659,6 +19092,38 @@ export class TeamProvisioningService { if (envWarning) { throw new Error(envWarning); } + const workspaceTrustFeatureFlags = resolveWorkspaceTrustFeatureFlags(); + const workspaceTrustProviders = workspaceTrustFeatureFlags.enabled + ? this.collectWorkspaceTrustProviders({ + leadProviderId: request.providerId, + members: request.members, + }) + : []; + const workspaceTrustEarlyWorkspaces = workspaceTrustFeatureFlags.enabled + ? await this.collectWorkspaceTrustWorkspaces({ + cwd: request.cwd, + members: [], + }) + : []; + const workspaceTrustEarlyPlan = workspaceTrustFeatureFlags.enabled + ? await this.planWorkspaceTrustArgsOnlySafely({ + providers: workspaceTrustProviders, + workspaces: workspaceTrustEarlyWorkspaces, + targetSurfaces: ['default_model_probe'], + featureFlags: workspaceTrustFeatureFlags, + }) + : { launchArgPatches: [] }; + const workspaceTrustProviderArgsResolver = (input: { + providerId: TeamProviderId; + providerArgs: string[]; + phase: 'default-model-resolution'; + }): string[] => + this.applyWorkspaceTrustArgPatches({ + args: input.providerArgs, + patches: workspaceTrustEarlyPlan.launchArgPatches, + targetProvider: input.providerId, + targetSurface: 'default_model_probe', + }); const materializedMemberSpecs = await this.materializeEffectiveTeamMemberSpecs({ claudePath, cwd: request.cwd, @@ -18672,6 +19137,7 @@ export class TeamProvisioningService { primaryEnv: provisioningEnv, teamRuntimeAuth, limitContext: request.limitContext, + providerArgsResolver: workspaceTrustProviderArgsResolver, }); const allEffectiveMemberSpecs = await this.resolveOpenCodeMemberWorkspacesForRuntime({ teamName: request.teamName, @@ -18697,16 +19163,62 @@ export class TeamProvisioningService { effectiveMemberSpecs, { teamRuntimeAuth } ); + const workspaceTrustFullWorkspaces = workspaceTrustFeatureFlags.enabled + ? await this.collectWorkspaceTrustWorkspaces({ + cwd: request.cwd, + members: allEffectiveMemberSpecs, + }) + : []; + const workspaceTrustFullPlan = workspaceTrustFeatureFlags.enabled + ? await this.planWorkspaceTrustFullSafely({ + providers: this.collectWorkspaceTrustProviders({ + leadProviderId: request.providerId, + members: allEffectiveMemberSpecs, + }), + workspaces: workspaceTrustFullWorkspaces, + featureFlags: workspaceTrustFeatureFlags, + }) + : null; + const workspaceTrustPatches = workspaceTrustFullPlan?.launchArgPatches ?? []; + const providerArgsForLaunch = this.applyWorkspaceTrustArgPatches({ + args: providerArgs, + patches: workspaceTrustPatches, + targetProvider: resolvedProviderId, + targetSurface: 'primary_provider_args', + }); + const crossProviderArgsForLaunch = crossProviderMemberArgs.providerArgsByProvider.has('codex') + ? this.applyWorkspaceTrustArgPatches({ + args: crossProviderMemberArgs.args, + patches: workspaceTrustPatches, + targetProvider: 'codex', + targetSurface: 'cross_provider_member_args', + }) + : crossProviderMemberArgs.args; + const crossProviderMemberArgsForLaunch = { + ...crossProviderMemberArgs, + args: crossProviderArgsForLaunch, + }; Object.assign(shellEnv, crossProviderMemberArgs.envPatch); if (crossProviderMemberArgs.usesAnthropicApiKeyHelper) { for (const key of ANTHROPIC_HELPER_MODE_COMPETING_AUTH_ENV_KEYS) { delete shellEnv[key]; } } - const providerArgsByProvider = new Map([ - [resolvedProviderId, providerArgs], + const providerArgsByProvider = new Map(); + for (const [providerId, args] of new Map([ + [resolvedProviderId, providerArgsForLaunch], ...crossProviderMemberArgs.providerArgsByProvider, - ]); + ])) { + providerArgsByProvider.set( + providerId, + this.applyWorkspaceTrustArgPatches({ + args, + patches: workspaceTrustPatches, + targetProvider: providerId, + targetSurface: 'provider_facts_probe', + }) + ); + } const launchIdentity = await this.resolveAndValidateLaunchIdentity({ claudePath, cwd: request.cwd, @@ -18760,7 +19272,12 @@ export class TeamProvisioningService { bootstrapSpecPath: null, bootstrapUserPromptPath: null, isLaunch: false, + launchStateClearedForRun: false, deterministicBootstrap: true, + workspaceTrustPlan: workspaceTrustFullPlan, + workspaceTrustExecution: null, + workspaceTrustDiagnostics: null, + workspaceTrustRetryAttempted: false, fsPhase: 'waiting_config', leadRelayCapture: null, activeCrossTeamReplyHints: [], @@ -18818,8 +19335,19 @@ export class TeamProvisioningService { this.provisioningRunByTeam.set(request.teamName, runId); initializeProvisioningTrace(run); run.onProgress(run.progress); + await this.prepareWorkspaceTrustForDeterministicRun({ + mode: 'create', + run, + claudePath, + shellEnv, + stopAllGenerationAtStart, + workspaceTrustPlan: workspaceTrustFullPlan, + featureFlags: workspaceTrustFeatureFlags, + provisioningEnv, + }); emitProvisioningCheckpoint(run, 'Clearing persisted launch state'); - await this.clearPersistedLaunchState(request.teamName); + await this.clearPersistedLaunchState(request.teamName, { expectedRunId: run.runId }); + run.launchStateClearedForRun = true; const initialUserPrompt = request.prompt?.trim() ?? ''; const promptSize = getPromptSizeSummary(initialUserPrompt); @@ -18945,7 +19473,7 @@ export class TeamProvisioningService { teamName: request.teamName, providerId: resolvedProviderId, launchIdentity, - envResolution: provisioningEnv, + envResolution: { ...provisioningEnv, providerArgs: providerArgsForLaunch }, extraArgs: extraCliArgs, includeAnthropicHelper: resolvedProviderId === 'anthropic', contextLabel: 'Team create launch', @@ -18981,7 +19509,7 @@ export class TeamProvisioningService { ...runtimeArgsPlan.extraArgs, ...runtimeArgsPlan.providerArgs, ...runtimeArgsPlan.settingsArgs, - ...crossProviderMemberArgs.args, + ...crossProviderMemberArgsForLaunch.args, ]); const runtimeWarning = buildRuntimeLaunchWarning(request, shellEnv, { geminiRuntimeAuth, @@ -19811,6 +20339,38 @@ export class TeamProvisioningService { if (envWarning) { throw new Error(envWarning); } + const workspaceTrustFeatureFlags = resolveWorkspaceTrustFeatureFlags(); + const workspaceTrustProviders = workspaceTrustFeatureFlags.enabled + ? this.collectWorkspaceTrustProviders({ + leadProviderId: request.providerId, + members: expectedMemberSpecs, + }) + : []; + const workspaceTrustEarlyWorkspaces = workspaceTrustFeatureFlags.enabled + ? await this.collectWorkspaceTrustWorkspaces({ + cwd: request.cwd, + members: [], + }) + : []; + const workspaceTrustEarlyPlan = workspaceTrustFeatureFlags.enabled + ? await this.planWorkspaceTrustArgsOnlySafely({ + providers: workspaceTrustProviders, + workspaces: workspaceTrustEarlyWorkspaces, + targetSurfaces: ['default_model_probe'], + featureFlags: workspaceTrustFeatureFlags, + }) + : { launchArgPatches: [] }; + const workspaceTrustProviderArgsResolver = (input: { + providerId: TeamProviderId; + providerArgs: string[]; + phase: 'default-model-resolution'; + }): string[] => + this.applyWorkspaceTrustArgPatches({ + args: input.providerArgs, + patches: workspaceTrustEarlyPlan.launchArgPatches, + targetProvider: input.providerId, + targetSurface: 'default_model_probe', + }); const materializedMemberSpecs = await this.materializeEffectiveTeamMemberSpecs({ claudePath, @@ -19825,6 +20385,7 @@ export class TeamProvisioningService { primaryEnv: provisioningEnv, teamRuntimeAuth, limitContext: request.limitContext, + providerArgsResolver: workspaceTrustProviderArgsResolver, }); const allEffectiveMemberSpecs = await this.resolveOpenCodeMemberWorkspacesForRuntime({ teamName: request.teamName, @@ -19851,16 +20412,62 @@ export class TeamProvisioningService { effectiveMemberSpecs, { teamRuntimeAuth } ); + const workspaceTrustFullWorkspaces = workspaceTrustFeatureFlags.enabled + ? await this.collectWorkspaceTrustWorkspaces({ + cwd: request.cwd, + members: allEffectiveMemberSpecs, + }) + : []; + const workspaceTrustFullPlan = workspaceTrustFeatureFlags.enabled + ? await this.planWorkspaceTrustFullSafely({ + providers: this.collectWorkspaceTrustProviders({ + leadProviderId: request.providerId, + members: allEffectiveMemberSpecs, + }), + workspaces: workspaceTrustFullWorkspaces, + featureFlags: workspaceTrustFeatureFlags, + }) + : null; + const workspaceTrustPatches = workspaceTrustFullPlan?.launchArgPatches ?? []; + const providerArgsForLaunch = this.applyWorkspaceTrustArgPatches({ + args: providerArgs, + patches: workspaceTrustPatches, + targetProvider: resolvedProviderId, + targetSurface: 'primary_provider_args', + }); + const crossProviderArgsForLaunch = crossProviderMemberArgs.providerArgsByProvider.has('codex') + ? this.applyWorkspaceTrustArgPatches({ + args: crossProviderMemberArgs.args, + patches: workspaceTrustPatches, + targetProvider: 'codex', + targetSurface: 'cross_provider_member_args', + }) + : crossProviderMemberArgs.args; + const crossProviderMemberArgsForLaunch = { + ...crossProviderMemberArgs, + args: crossProviderArgsForLaunch, + }; Object.assign(shellEnv, crossProviderMemberArgs.envPatch); if (crossProviderMemberArgs.usesAnthropicApiKeyHelper) { for (const key of ANTHROPIC_HELPER_MODE_COMPETING_AUTH_ENV_KEYS) { delete shellEnv[key]; } } - const providerArgsByProvider = new Map([ - [resolvedProviderId, providerArgs], + const providerArgsByProvider = new Map(); + for (const [providerId, args] of new Map([ + [resolvedProviderId, providerArgsForLaunch], ...crossProviderMemberArgs.providerArgsByProvider, - ]); + ])) { + providerArgsByProvider.set( + providerId, + this.applyWorkspaceTrustArgPatches({ + args, + patches: workspaceTrustPatches, + targetProvider: providerId, + targetSurface: 'provider_facts_probe', + }) + ); + } const launchIdentity = await this.resolveAndValidateLaunchIdentity({ claudePath, cwd: request.cwd, @@ -19939,7 +20546,12 @@ export class TeamProvisioningService { bootstrapSpecPath: null, bootstrapUserPromptPath: null, isLaunch: true, + launchStateClearedForRun: false, deterministicBootstrap: true, + workspaceTrustPlan: workspaceTrustFullPlan, + workspaceTrustExecution: null, + workspaceTrustDiagnostics: null, + workspaceTrustRetryAttempted: false, fsPhase: 'waiting_members', leadRelayCapture: null, activeCrossTeamReplyHints: [], @@ -20003,8 +20615,19 @@ export class TeamProvisioningService { this.provisioningRunByTeam.set(request.teamName, runId); initializeProvisioningTrace(run); run.onProgress(run.progress); + await this.prepareWorkspaceTrustForDeterministicRun({ + mode: 'launch', + run, + claudePath, + shellEnv, + stopAllGenerationAtStart, + workspaceTrustPlan: workspaceTrustFullPlan, + featureFlags: workspaceTrustFeatureFlags, + provisioningEnv, + }); emitProvisioningCheckpoint(run, 'Clearing persisted launch state'); - await this.clearPersistedLaunchState(request.teamName); + await this.clearPersistedLaunchState(request.teamName, { expectedRunId: run.runId }); + run.launchStateClearedForRun = true; emitProvisioningCheckpoint(run, 'Publishing mixed secondary lane status'); for (const lane of run.mixedSecondaryLanes ?? []) { await this.publishMixedSecondaryLaneStatusChange(run, lane); @@ -20137,7 +20760,7 @@ export class TeamProvisioningService { teamName: request.teamName, providerId: resolvedProviderId, launchIdentity, - envResolution: provisioningEnv, + envResolution: { ...provisioningEnv, providerArgs: providerArgsForLaunch }, extraArgs: extraCliArgs, includeAnthropicHelper: resolvedProviderId === 'anthropic', contextLabel: 'Team launch', @@ -20163,7 +20786,7 @@ export class TeamProvisioningService { // Without this, a codex teammate spawned from an anthropic lead has no way to learn // about the required forced_login_method (chatgpt/api) and fails to start. emitProvisioningCheckpoint(run, 'Resolving cross-provider member launch args'); - launchArgs.push(...crossProviderMemberArgs.args); + launchArgs.push(...crossProviderMemberArgsForLaunch.args); const finalLaunchArgs = mergeJsonSettingsArgs(launchArgs); const runtimeWarning = buildRuntimeLaunchWarning(request, shellEnv, { geminiRuntimeAuth, @@ -23525,7 +24148,9 @@ export class TeamProvisioningService { : undefined; const status = this.shouldPreferCurrentLaunchMemberStatus(trackedStatus, launchStatus) ? launchStatus - : (trackedStatus ?? adapterStatus ?? launchStatus); + : this.shouldPreferCurrentLaunchMemberStatus(trackedStatus, adapterStatus) + ? adapterStatus + : (trackedStatus ?? adapterStatus ?? launchStatus); const resolved = resolveTeamMemberRuntimeLiveness({ teamName, memberName, @@ -23660,7 +24285,7 @@ export class TeamProvisioningService { return true; } const trackedRunId = this.getTrackedRunId(teamName); - if (trackedRunId && trackedRunId !== expectedRunId) { + if (trackedRunId !== expectedRunId) { return false; } const lastWrittenRunId = this.launchStateWrittenRunIdByTeam.get(teamName); @@ -31053,7 +31678,13 @@ export class TeamProvisioningService { peekAutoResumeService()?.cancelPendingAutoResume(run.teamName); } - if (!hasNewerTrackedRun && run.isLaunch && !run.provisioningComplete && !run.cancelRequested) { + if ( + !hasNewerTrackedRun && + run.isLaunch && + run.launchStateClearedForRun !== false && + !run.provisioningComplete && + !run.cancelRequested + ) { const cleanupReason = typeof run.progress.error === 'string' && run.progress.error.trim() ? run.progress.error.trim() @@ -31078,7 +31709,10 @@ export class TeamProvisioningService { if ( !hasNewerTrackedRun && (run.progress.state === 'failed' || - (run.isLaunch && !run.provisioningComplete && !run.cancelRequested)) + (run.isLaunch && + run.launchStateClearedForRun !== false && + !run.provisioningComplete && + !run.cancelRequested)) ) { this.writeLaunchFailureArtifactPackBestEffort(run, { reason: diff --git a/src/renderer/components/team/ProvisioningProgressBlock.tsx b/src/renderer/components/team/ProvisioningProgressBlock.tsx index 91e235f1..b5b4f83b 100644 --- a/src/renderer/components/team/ProvisioningProgressBlock.tsx +++ b/src/renderer/components/team/ProvisioningProgressBlock.tsx @@ -574,14 +574,24 @@ export const ProvisioningProgressBlock = ({ diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 25db5d3b..2527be90 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -759,6 +759,23 @@ const ACTIVE_PROVISIONING_STATES = new Set([ ]); const TERMINAL_PROVISIONING_STATES = new Set(['ready', 'failed', 'disconnected', 'cancelled']); +function shouldIgnoreProvisioningProgressRegression( + currentState: TeamProvisioningProgress['state'], + nextState: TeamProvisioningProgress['state'] +): boolean { + if (currentState === 'ready') { + return nextState !== 'ready' && nextState !== 'disconnected'; + } + if ( + currentState === 'failed' || + currentState === 'cancelled' || + currentState === 'disconnected' + ) { + return nextState !== currentState; + } + return false; +} + function isPendingProvisioningRunId(runId: string): boolean { return runId.startsWith('pending:'); } @@ -5805,6 +5822,13 @@ export const createTeamSlice: StateCreator = (set, if (isDuplicateProgress && currentRunId === progress.runId) { return; } + if ( + existingProgress && + currentRunId === progress.runId && + shouldIgnoreProvisioningProgressRegression(existingProgress.state, progress.state) + ) { + return; + } set((state) => { const nextRuns: Record = { diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 59435726..1abf4fd4 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -1472,6 +1472,7 @@ export interface TeamLaunchDiagnosticItem { | 'permission_pending' | 'bootstrap_confirmed' | 'bootstrap_stalled' + | 'workspace_trust_preflight' | 'stale_runtime_event_rejected' | 'process_table_unavailable'; label: string; diff --git a/test/features/member-work-sync/core/MemberWorkSyncNudge.test.ts b/test/features/member-work-sync/core/MemberWorkSyncNudge.test.ts new file mode 100644 index 00000000..86287706 --- /dev/null +++ b/test/features/member-work-sync/core/MemberWorkSyncNudge.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; + +import { buildMemberWorkSyncNudgePayload } from '@features/member-work-sync/core/domain'; +import type { MemberWorkSyncStatus } from '@features/member-work-sync/contracts'; + +function makeStatus( + overrides: Partial = {} +): MemberWorkSyncStatus { + return { + teamName: 'sable-ops', + memberName: 'team-lead', + state: 'needs_sync', + agenda: { + teamName: 'sable-ops', + memberName: 'team-lead', + generatedAt: '2026-05-13T13:02:44.263Z', + fingerprint: 'agenda:v1:test', + diagnostics: [], + items: [ + { + taskId: 'task-review-path', + displayId: 'c3add790', + subject: 'Проверить калькулятор и дать ревью', + assignee: 'team-lead', + kind: 'clarification', + priority: 'needs_clarification', + reason: 'task_needs_lead_clarification', + evidence: { + status: 'in_progress', + owner: 'alice', + needsClarification: 'lead', + }, + }, + ], + }, + evaluatedAt: '2026-05-13T13:02:44.291Z', + diagnostics: ['no_current_report'], + shadow: { + reconciledBy: 'queue', + wouldNudge: true, + fingerprintChanged: false, + }, + providerId: 'codex', + ...overrides, + }; +} + +describe('MemberWorkSyncNudge', () => { + it('tells lead to move escalated clarification to user on the board', () => { + const payload = buildMemberWorkSyncNudgePayload(makeStatus()); + + expect(payload.text).toContain( + 'update the task board first with task_set_clarification value "user"' + ); + expect(payload.text).toContain('do not rely on a message alone'); + }); + + it('does not add clarification board-transition guidance for normal work', () => { + const payload = buildMemberWorkSyncNudgePayload( + makeStatus({ + memberName: 'bob', + agenda: { + teamName: 'sable-ops', + memberName: 'bob', + generatedAt: '2026-05-13T13:02:44.263Z', + fingerprint: 'agenda:v1:work', + diagnostics: [], + items: [ + { + taskId: 'task-work', + displayId: 'c76d04cc', + subject: 'Создать каркас калькулятора', + assignee: 'bob', + kind: 'work', + priority: 'normal', + reason: 'owned_pending_task', + evidence: { + status: 'pending', + owner: 'bob', + }, + }, + ], + }, + }) + ); + + expect(payload.text).not.toContain('task_set_clarification value "user"'); + }); +}); diff --git a/test/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.test.ts b/test/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.test.ts index 876fed3f..74883e6a 100644 --- a/test/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.test.ts +++ b/test/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.test.ts @@ -304,10 +304,40 @@ describe('MemberWorkSyncNudgeActivationPolicy', () => { ).toEqual({ active: true, reason: 'review_pickup_required' }); }); - it('does not activate when blocking safety metrics are present', () => { + it('activates targeted OpenCode nudges even when global blocking metrics are noisy', () => { expect( decideMemberWorkSyncNudgeActivation({ status: status(), + metrics: metrics({ + phase2Readiness: { + ...metrics().phase2Readiness, + state: 'blocked', + reasons: ['would_nudge_rate_high', 'fingerprint_churn_high'], + }, + }), + }) + ).toEqual({ active: true, reason: 'opencode_targeted_shadow_collecting' }); + }); + + it('activates targeted lead nudges even when global blocking metrics are noisy', () => { + expect( + decideMemberWorkSyncNudgeActivation({ + status: status({ providerId: 'codex', memberName: 'team-lead' }), + metrics: metrics({ + phase2Readiness: { + ...metrics().phase2Readiness, + state: 'blocked', + reasons: ['would_nudge_rate_high', 'fingerprint_churn_high'], + }, + }), + }) + ).toEqual({ active: true, reason: 'lead_targeted_shadow_collecting' }); + }); + + it('does not activate non-OpenCode nudges when blocking safety metrics are present', () => { + expect( + decideMemberWorkSyncNudgeActivation({ + status: status({ providerId: 'codex' }), metrics: metrics({ phase2Readiness: { ...metrics().phase2Readiness, diff --git a/test/features/member-work-sync/core/application/MemberWorkSyncTargetedRecoveryPolicy.test.ts b/test/features/member-work-sync/core/application/MemberWorkSyncTargetedRecoveryPolicy.test.ts new file mode 100644 index 00000000..2a6d33db --- /dev/null +++ b/test/features/member-work-sync/core/application/MemberWorkSyncTargetedRecoveryPolicy.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest'; + +import { decideMemberWorkSyncTargetedRecovery } from '@features/member-work-sync/core/application'; + +import type { MemberWorkSyncStatus } from '@features/member-work-sync/contracts'; + +function status(overrides: Partial = {}): MemberWorkSyncStatus { + return { + teamName: 'team-a', + memberName: 'alice', + state: 'needs_sync', + agenda: { + teamName: 'team-a', + memberName: 'alice', + generatedAt: '2026-05-06T00:00:00.000Z', + fingerprint: 'agenda:v1:test', + items: [ + { + taskId: 'task-1', + displayId: '#1', + subject: 'Do work', + kind: 'work', + assignee: 'alice', + priority: 'normal', + reason: 'assigned', + evidence: { status: 'pending' }, + }, + ], + diagnostics: [], + }, + shadow: { + reconciledBy: 'queue', + wouldNudge: true, + fingerprintChanged: false, + }, + evaluatedAt: '2026-05-06T00:00:00.000Z', + diagnostics: [], + providerId: 'opencode', + ...overrides, + }; +} + +describe('MemberWorkSyncTargetedRecoveryPolicy', () => { + it('allows OpenCode recovery through runtime delivery capability', () => { + expect(decideMemberWorkSyncTargetedRecovery(status())).toEqual({ + active: true, + capability: 'opencode_runtime_delivery', + reason: 'opencode_targeted_shadow_collecting', + }); + }); + + it('allows lead recovery through lead inbox relay capability', () => { + expect( + decideMemberWorkSyncTargetedRecovery( + status({ memberName: 'team-lead', providerId: 'codex' }) + ) + ).toEqual({ + active: true, + capability: 'lead_inbox_relay', + reason: 'lead_targeted_shadow_collecting', + }); + }); + + it('does not allow non-lead native teammates through targeted recovery', () => { + expect(decideMemberWorkSyncTargetedRecovery(status({ providerId: 'codex' }))).toEqual({ + active: false, + }); + }); + + it('does not treat review pickup as generic targeted recovery', () => { + expect( + decideMemberWorkSyncTargetedRecovery( + status({ + agenda: { + ...status().agenda, + items: [ + { + taskId: 'task-review', + displayId: '#2', + subject: 'Review current request', + kind: 'review', + assignee: 'alice', + priority: 'review_requested', + reason: 'current_cycle_review_assigned', + evidence: { + status: 'completed', + owner: 'bob', + reviewer: 'alice', + reviewState: 'review', + reviewCycleId: 'evt-review-request', + reviewRequestEventId: 'evt-review-request', + reviewObligation: 'review_pickup_required', + canBypassPhase2: true, + historyEventIds: ['evt-review-request'], + }, + }, + ], + }, + }) + ) + ).toEqual({ active: false }); + }); + + it('requires shadow would-nudge evidence before targeted recovery', () => { + expect( + decideMemberWorkSyncTargetedRecovery( + status({ + shadow: { + reconciledBy: 'queue', + wouldNudge: false, + fingerprintChanged: false, + }, + }) + ) + ).toEqual({ active: false }); + }); +}); diff --git a/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts b/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts index 9516ed14..a03e04c9 100644 --- a/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts +++ b/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts @@ -729,7 +729,7 @@ describe('createMemberWorkSyncFeature composition', () => { } }); - it('blocks targeted OpenCode nudges when phase2 metrics are unsafe', async () => { + it('delivers targeted OpenCode nudges even when global phase2 metrics are noisy', async () => { const claudeRoot = makeTempRoot(); setClaudeBasePathOverride(claudeRoot); const teamsBasePath = getTeamsBasePath(); @@ -751,7 +751,7 @@ describe('createMemberWorkSyncFeature composition', () => { { id: 'task-1', displayId: '11111111', - subject: 'Do not nudge when metrics are unsafe', + subject: 'Nudge OpenCode despite noisy global metrics', status: 'pending', owner: memberName, }, @@ -778,9 +778,20 @@ describe('createMemberWorkSyncFeature composition', () => { await waitForAssertion(async () => { expect(feature.getQueueDiagnostics()).toMatchObject({ reconciled: 1 }); - expect(await readInboxMessages({ teamsBasePath, teamName, memberName })).toEqual([]); - expect(await readMemberOutboxItems({ teamsBasePath, teamName, memberName })).toEqual({}); - expect(nudgeDeliveryWake.schedule).not.toHaveBeenCalled(); + const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter( + (message) => message.messageKind === 'member_work_sync_nudge' + ); + expect(nudges).toHaveLength(1); + expect(nudges[0]?.text).toContain('11111111'); + expect(nudgeDeliveryWake.schedule).toHaveBeenCalledTimes(1); + expect(nudgeDeliveryWake.schedule).toHaveBeenCalledWith({ + teamName, + memberName, + messageId: nudges[0]?.messageId, + providerId: 'opencode', + reason: 'member_work_sync_nudge_inserted', + delayMs: 500, + }); await expect(feature.getMetrics({ teamName })).resolves.toMatchObject({ phase2Readiness: { reasons: expect.arrayContaining(['would_nudge_rate_high']), @@ -799,15 +810,102 @@ describe('createMemberWorkSyncFeature composition', () => { ), 'utf8' ); - expect(journal).toContain('"event":"nudge_skipped"'); - expect(journal).toContain('"reason":"blocking_metrics"'); - expect(journal).not.toContain('"event":"nudge_delivered"'); + expect(journal).toContain('"event":"nudge_delivered"'); + expect(journal).not.toContain('"reason":"blocking_metrics"'); } finally { await feature.dispose(); } }); - it('recovers targeted OpenCode nudge delivery after unsafe metrics become ready', async () => { + it('delivers targeted lead nudges even when global phase2 metrics are noisy', async () => { + const claudeRoot = makeTempRoot(); + setClaudeBasePathOverride(claudeRoot); + const teamsBasePath = getTeamsBasePath(); + const teamName = 'team-lead-blocking-metrics'; + const memberName = 'team-lead'; + const nudgeDeliveryWake = { + schedule: vi.fn(async () => undefined), + }; + const feature = createMemberWorkSyncFeature({ + teamsBasePath, + configReader: { + getConfig: vi.fn(async () => ({ + name: teamName, + members: [{ name: memberName, providerId: 'codex', agentType: 'team-lead' }], + })), + } as never, + taskReader: { + getTasks: vi.fn(async () => [ + { + id: 'task-1', + displayId: '11111111', + subject: 'Resolve lead clarification', + status: 'pending', + owner: memberName, + }, + ]), + } as never, + kanbanManager: { + getState: vi.fn(async () => ({ + teamName, + reviewers: [], + tasks: {}, + })), + } as never, + membersMetaStore: { + getMembers: vi.fn(async () => []), + } as never, + isTeamActive: vi.fn(async () => true), + nudgeDeliveryWake, + queueQuietWindowMs: 1, + }); + + try { + await seedBlockingShadowCollectingMetrics({ teamsBasePath, teamName, memberName }); + feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never); + + await waitForAssertion(async () => { + expect(feature.getQueueDiagnostics()).toMatchObject({ reconciled: 1 }); + const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter( + (message) => message.messageKind === 'member_work_sync_nudge' + ); + expect(nudges).toHaveLength(1); + expect(nudges[0]?.text).toContain('11111111'); + expect(nudgeDeliveryWake.schedule).toHaveBeenCalledTimes(1); + expect(nudgeDeliveryWake.schedule).toHaveBeenCalledWith({ + teamName, + memberName, + messageId: nudges[0]?.messageId, + providerId: 'codex', + reason: 'member_work_sync_nudge_inserted', + delayMs: 500, + }); + await expect(feature.getMetrics({ teamName })).resolves.toMatchObject({ + phase2Readiness: { + reasons: expect.arrayContaining(['would_nudge_rate_high']), + }, + }); + }); + + const journal = await fs.promises.readFile( + path.join( + teamsBasePath, + teamName, + 'members', + memberName, + '.member-work-sync', + 'journal.jsonl' + ), + 'utf8' + ); + expect(journal).toContain('"event":"nudge_delivered"'); + expect(journal).not.toContain('"reason":"blocking_metrics"'); + } finally { + await feature.dispose(); + } + }); + + it('keeps targeted OpenCode nudge idempotent after noisy metrics become ready', async () => { const claudeRoot = makeTempRoot(); setClaudeBasePathOverride(claudeRoot); const teamsBasePath = getTeamsBasePath(); @@ -829,7 +927,7 @@ describe('createMemberWorkSyncFeature composition', () => { { id: 'task-1', displayId: '11111111', - subject: 'Recover OpenCode nudge after metrics ready', + subject: 'Keep OpenCode nudge idempotent after metrics ready', status: 'pending', owner: memberName, }, @@ -856,9 +954,11 @@ describe('createMemberWorkSyncFeature composition', () => { await waitForAssertion(async () => { expect(feature.getQueueDiagnostics()).toMatchObject({ reconciled: 1 }); - expect(await readInboxMessages({ teamsBasePath, teamName, memberName })).toEqual([]); - expect(await readMemberOutboxItems({ teamsBasePath, teamName, memberName })).toEqual({}); - expect(nudgeDeliveryWake.schedule).not.toHaveBeenCalled(); + const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter( + (message) => message.messageKind === 'member_work_sync_nudge' + ); + expect(nudges).toHaveLength(1); + expect(nudgeDeliveryWake.schedule).toHaveBeenCalledTimes(1); }); await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName }); @@ -871,7 +971,7 @@ describe('createMemberWorkSyncFeature composition', () => { expect(nudges).toHaveLength(1); expect(nudges[0]?.text).toContain('11111111'); expect(nudgeDeliveryWake.schedule).toHaveBeenCalledTimes(1); - expect(nudgeDeliveryWake.schedule).toHaveBeenCalledWith({ + expect(nudgeDeliveryWake.schedule).toHaveBeenLastCalledWith({ teamName, memberName, messageId: nudges[0]?.messageId, diff --git a/test/features/workspace-trust/core/ClaudePreflightCommand.test.ts b/test/features/workspace-trust/core/ClaudePreflightCommand.test.ts new file mode 100644 index 00000000..c7ec70c3 --- /dev/null +++ b/test/features/workspace-trust/core/ClaudePreflightCommand.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; + +import { buildClaudeWorkspaceTrustPreflightArgs } from '@features/workspace-trust/core/application'; + +describe('ClaudePreflightCommand', () => { + it('builds the protected modern Claude workspace trust command args', () => { + const result = buildClaudeWorkspaceTrustPreflightArgs({ + emptyMcpConfigPath: '/tmp/empty-mcp.json', + }); + + expect(result).toEqual({ + ok: true, + args: [ + '--bare', + '--strict-mcp-config', + '--mcp-config', + '/tmp/empty-mcp.json', + '--setting-sources', + 'user', + '--settings', + '{"disableAllHooks":true}', + '--tools', + '', + ], + omittedFlags: [], + }); + }); + + it('allows the strict protected fallback without bare but never falls back to plain Claude', () => { + const result = buildClaudeWorkspaceTrustPreflightArgs({ + emptyMcpConfigPath: '/tmp/empty-mcp.json', + capabilities: { bare: false }, + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.args).not.toContain('--bare'); + expect(result.args).toContain('--strict-mcp-config'); + expect(result.args).toContain('--setting-sources'); + expect(result.omittedFlags).toEqual(['--bare']); + } + }); + + it('returns a soft unavailable result when protected flags are missing', () => { + const result = buildClaudeWorkspaceTrustPreflightArgs({ + emptyMcpConfigPath: '/tmp/empty-mcp.json', + capabilities: { strictMcpConfig: false }, + }); + + expect(result).toEqual({ + ok: false, + code: 'preflight_unavailable_or_unprotected', + message: + 'Claude workspace trust preflight is unavailable because protected flags are missing: strictMcpConfig', + }); + }); + + it('does not build a command when hook and tool isolation flags are unavailable', () => { + const result = buildClaudeWorkspaceTrustPreflightArgs({ + emptyMcpConfigPath: '/tmp/empty-mcp.json', + capabilities: { + settingSources: false, + inlineSettings: false, + tools: false, + }, + }); + + expect(result).toEqual({ + ok: false, + code: 'preflight_unavailable_or_unprotected', + message: + 'Claude workspace trust preflight is unavailable because protected flags are missing: settingSources, inlineSettings, tools', + }); + }); +}); diff --git a/test/features/workspace-trust/core/ClaudePtyWorkspaceTrustStrategy.test.ts b/test/features/workspace-trust/core/ClaudePtyWorkspaceTrustStrategy.test.ts new file mode 100644 index 00000000..c4353765 --- /dev/null +++ b/test/features/workspace-trust/core/ClaudePtyWorkspaceTrustStrategy.test.ts @@ -0,0 +1,279 @@ +import { describe, expect, it } from 'vitest'; + +import { ClaudePtyWorkspaceTrustStrategy } from '@features/workspace-trust/core/application'; +import { buildWorkspaceTrustPathCandidates } from '@features/workspace-trust/core/domain'; + +import type { + ProviderStateProbe, + ProviderTrustState, + PtyKeyAction, + PtyProcessPort, + PtySessionPort, + PtySpawnInput, + PtySpawnResult, + TempEmptyMcpConfigHandle, + TempEmptyMcpConfigStore, + TerminalSnapshot, +} from '@features/workspace-trust/core/application'; + +class FakeSession implements PtySessionPort { + readonly actions: PtyKeyAction[] = []; + killed = false; + + constructor(private readonly snapshots: string[]) {} + + async readSnapshot(): Promise { + return { + text: this.snapshots.shift() ?? '', + capturedAtMs: Date.now(), + }; + } + + async writeAction(action: PtyKeyAction): Promise { + this.actions.push(action); + } + + async kill(): Promise { + this.killed = true; + } +} + +class FakePtyProcess implements PtyProcessPort { + readonly spawnInputs: PtySpawnInput[] = []; + session: FakeSession | null = null; + spawnResult: PtySpawnResult | null = null; + + async spawn(input: PtySpawnInput): Promise { + this.spawnInputs.push(input); + if (this.spawnResult) { + return this.spawnResult; + } + this.session = new FakeSession(['Quick safety check\nYes, I trust this folder']); + return { ok: true, session: this.session }; + } +} + +class FakeStateProbe implements ProviderStateProbe { + calls = 0; + + constructor(private readonly states: ProviderTrustState[]) {} + + async readTrustState(): Promise { + const state = this.states[Math.min(this.calls, this.states.length - 1)]; + this.calls += 1; + return state; + } +} + +class FakeTempStore implements TempEmptyMcpConfigStore { + cleaned = false; + + async create(): Promise { + return { + path: '/tmp/empty-mcp.json', + cleanup: async () => { + this.cleaned = true; + }, + }; + } +} + +function workspace(cwd = '/tmp/project') { + return buildWorkspaceTrustPathCandidates({ cwd, platform: 'posix' })[0]; +} + +describe('ClaudePtyWorkspaceTrustStrategy', () => { + it('skips PTY when the state probe already reports trusted', async () => { + const pty = new FakePtyProcess(); + const result = await new ClaudePtyWorkspaceTrustStrategy().execute({ + claudePath: '/usr/local/bin/claude', + workspaces: [workspace()], + env: { HOME: '/Users/tester' }, + ptyProcess: pty, + stateProbe: new FakeStateProbe([{ status: 'trusted', evidence: ['trusted project key'] }]), + tempEmptyMcpConfigStore: new FakeTempStore(), + isCancelled: () => false, + }); + + expect(result.status).toBe('ok'); + expect(result.evidence).toEqual(['trusted project key']); + expect(pty.spawnInputs).toEqual([]); + }); + + it('blocks non-persistable home and root workspaces without spawning PTY', async () => { + const pty = new FakePtyProcess(); + const homeWorkspace = buildWorkspaceTrustPathCandidates({ + cwd: '/Users/tester', + homeDir: '/Users/tester', + platform: 'posix', + })[0]; + const result = await new ClaudePtyWorkspaceTrustStrategy().execute({ + claudePath: '/usr/local/bin/claude', + workspaces: [homeWorkspace], + env: { HOME: '/Users/tester' }, + ptyProcess: pty, + stateProbe: new FakeStateProbe([{ status: 'untrusted' }]), + tempEmptyMcpConfigStore: new FakeTempStore(), + isCancelled: () => false, + }); + + expect(result.status).toBe('blocked'); + expect(result.errorCode).toBe('workspace_trust_not_persistable_home_directory'); + expect(pty.spawnInputs).toEqual([]); + }); + + it('preserves trusted evidence before blocking a later non-persistable workspace', async () => { + const pty = new FakePtyProcess(); + const trustedWorkspace = workspace('/tmp/project'); + const homeWorkspace = buildWorkspaceTrustPathCandidates({ + cwd: '/Users/tester', + homeDir: '/Users/tester', + platform: 'posix', + })[0]; + const result = await new ClaudePtyWorkspaceTrustStrategy().execute({ + claudePath: '/usr/local/bin/claude', + workspaces: [trustedWorkspace, homeWorkspace], + env: { HOME: '/Users/tester' }, + ptyProcess: pty, + stateProbe: new FakeStateProbe([{ status: 'trusted', evidence: ['trusted project key'] }]), + tempEmptyMcpConfigStore: new FakeTempStore(), + isCancelled: () => false, + }); + + expect(result.status).toBe('blocked'); + expect(result.workspaceIds).toEqual([trustedWorkspace.id, homeWorkspace.id]); + expect(result.evidence).toEqual([ + 'trusted project key', + `${homeWorkspace.id}:workspace_trust_not_persistable_home_directory`, + ]); + expect(pty.spawnInputs).toEqual([]); + }); + + it('cancels before probing or spawning when launch cancellation is already requested', async () => { + const pty = new FakePtyProcess(); + const stateProbe = new FakeStateProbe([{ status: 'untrusted' }]); + const targetWorkspace = workspace(); + const result = await new ClaudePtyWorkspaceTrustStrategy().execute({ + claudePath: '/usr/local/bin/claude', + workspaces: [targetWorkspace], + env: { HOME: '/Users/tester' }, + ptyProcess: pty, + stateProbe, + tempEmptyMcpConfigStore: new FakeTempStore(), + isCancelled: () => true, + }); + + expect(result.status).toBe('cancelled'); + expect(result.workspaceIds).toEqual([targetWorkspace.id]); + expect(stateProbe.calls).toBe(0); + expect(pty.spawnInputs).toEqual([]); + }); + + it('accepts the trust dialog, verifies persisted trust, kills PTY, and cleans temp MCP config', async () => { + const pty = new FakePtyProcess(); + const tempStore = new FakeTempStore(); + const result = await new ClaudePtyWorkspaceTrustStrategy().execute({ + claudePath: '/usr/local/bin/claude', + workspaces: [workspace()], + env: { HOME: '/Users/tester', PATH: '/usr/local/bin', OPTIONAL_EMPTY: undefined }, + ptyProcess: pty, + stateProbe: new FakeStateProbe([ + { status: 'untrusted' }, + { status: 'trusted', evidence: ['trusted project key: /tmp/project'] }, + ]), + tempEmptyMcpConfigStore: tempStore, + isCancelled: () => false, + timeoutMs: 100, + pollIntervalMs: 1, + }); + + expect(result.status).toBe('ok'); + expect(result.matchedRuleIds).toEqual(['claude.workspace_trust']); + expect(result.actions).toEqual(['claude.workspace_trust:enter']); + expect(pty.spawnInputs[0]).toMatchObject({ + command: '/usr/local/bin/claude', + cwd: '/tmp/project', + }); + expect(pty.spawnInputs[0].args).toContain('--strict-mcp-config'); + expect(pty.spawnInputs[0].env).toMatchObject({ + HOME: '/Users/tester', + PATH: '/usr/local/bin', + }); + expect(pty.spawnInputs[0].env.OPTIONAL_EMPTY).toBeUndefined(); + expect(pty.session?.actions.map((action) => action.id)).toEqual(['enter']); + expect(pty.session?.killed).toBe(true); + expect(tempStore.cleaned).toBe(true); + }); + + it('soft-fails when node-pty is unavailable instead of throwing', async () => { + const pty = new FakePtyProcess(); + pty.spawnResult = { + ok: false, + code: 'node_pty_unavailable', + message: 'node-pty unavailable', + }; + const result = await new ClaudePtyWorkspaceTrustStrategy().execute({ + claudePath: '/usr/local/bin/claude', + workspaces: [workspace()], + env: { HOME: '/Users/tester' }, + ptyProcess: pty, + stateProbe: new FakeStateProbe([{ status: 'untrusted' }]), + tempEmptyMcpConfigStore: new FakeTempStore(), + isCancelled: () => false, + }); + + expect(result.status).toBe('soft_failed'); + expect(result.errorCode).toBe('node_pty_unavailable'); + }); + + it('soft-fails provider auth prompts instead of blocking the launch', async () => { + const pty = new FakePtyProcess(); + const session = new FakeSession(['Log in to Claude']); + pty.spawnResult = { ok: true, session }; + const result = await new ClaudePtyWorkspaceTrustStrategy().execute({ + claudePath: '/usr/local/bin/claude', + workspaces: [workspace()], + env: { HOME: '/Users/tester' }, + ptyProcess: pty, + stateProbe: new FakeStateProbe([{ status: 'untrusted' }]), + tempEmptyMcpConfigStore: new FakeTempStore(), + isCancelled: () => false, + timeoutMs: 100, + pollIntervalMs: 1, + }); + + expect(result.status).toBe('soft_failed'); + expect(result.errorCode).toBe('provider_auth_required'); + expect(result.errorMessage).toBe('provider auth required prompt'); + expect(result.evidence).toContain('provider auth required prompt'); + expect(session.actions).toEqual([]); + expect(session.killed).toBe(true); + }); + + it('includes the last unknown terminal snapshot when preflight times out', async () => { + const pty = new FakePtyProcess(); + const session = new FakeSession([ + '\u001b[31mUnexpected Claude startup screen\u001b[0m', + '', + '', + ]); + pty.spawnResult = { ok: true, session }; + const result = await new ClaudePtyWorkspaceTrustStrategy().execute({ + claudePath: '/usr/local/bin/claude', + workspaces: [workspace()], + env: { HOME: '/Users/tester' }, + ptyProcess: pty, + stateProbe: new FakeStateProbe([{ status: 'untrusted' }]), + tempEmptyMcpConfigStore: new FakeTempStore(), + isCancelled: () => false, + timeoutMs: 20, + pollIntervalMs: 1, + }); + + expect(result.status).toBe('soft_failed'); + expect(result.errorCode).toBe('workspace_trust_preflight_timeout'); + expect(result.rawTail).toBe('Unexpected Claude startup screen'); + expect(session.actions).toEqual([]); + expect(session.killed).toBe(true); + }); +}); diff --git a/test/features/workspace-trust/core/CodexWorkspaceTrustSettings.test.ts b/test/features/workspace-trust/core/CodexWorkspaceTrustSettings.test.ts new file mode 100644 index 00000000..1813b119 --- /dev/null +++ b/test/features/workspace-trust/core/CodexWorkspaceTrustSettings.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildCodexTrustedProjectConfigOverride, + buildCodexTrustedProjectConfigOverrides, + buildCodexWorkspaceTrustSettings, + buildCodexWorkspaceTrustSettingsArgs, + isCodexWorkspaceTrustConfigOverride, + readCodexWorkspaceTrustConfigOverridesFromSettings, +} from '@features/workspace-trust/core/domain'; + +describe('CodexWorkspaceTrustSettings', () => { + it('builds repeatable dotted project overrides with TOML basic string escaping', () => { + const override = buildCodexTrustedProjectConfigOverride('/tmp/Project "Q"[1]'); + + expect(override).toBe('projects."/tmp/Project \\"Q\\"[1]".trust_level="trusted"'); + expect(override).not.toContain('projects={'); + expect(isCodexWorkspaceTrustConfigOverride(override)).toBe(true); + }); + + it('normalizes and dedupes path keys before building override values', () => { + const overrides = buildCodexTrustedProjectConfigOverrides( + ['C:\\Repo With Space\\quote"name', 'c:/repo with space/quote"name/'], + { platform: 'win32' } + ); + + expect(overrides).toEqual([ + 'projects."C:/Repo With Space/quote\\"name".trust_level="trusted"', + 'projects."c:/repo with space/quote\\"name".trust_level="trusted"', + ]); + }); + + it('builds app-owned inline settings and rejects malformed override payloads', () => { + const valid = 'projects."/tmp/project".trust_level="trusted"'; + const settings = buildCodexWorkspaceTrustSettings([ + valid, + valid, + 'projects."/tmp/project".trust_level="untrusted"', + 'forced_login_method="chatgpt"', + 'projects={}', + 'projects."/tmp/other".trust_level="trusted"\nforced_login_method="api"', + ]); + + expect(settings).toEqual({ + codex: { + agent_teams_workspace_trust: { + config_overrides: [valid], + }, + }, + }); + expect(readCodexWorkspaceTrustConfigOverridesFromSettings(settings)).toEqual([valid]); + }); + + it('returns no settings args when no safe overrides exist', () => { + expect(buildCodexWorkspaceTrustSettingsArgs(['projects={bad=true}'])).toEqual([]); + }); +}); diff --git a/test/features/workspace-trust/core/PtyDialogEngine.test.ts b/test/features/workspace-trust/core/PtyDialogEngine.test.ts new file mode 100644 index 00000000..f96f8e95 --- /dev/null +++ b/test/features/workspace-trust/core/PtyDialogEngine.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it } from 'vitest'; + +import { PTY_KEY_ACTIONS, runPtyDialogEngine } from '@features/workspace-trust/core/application'; + +import type { + PtyKeyAction, + PtySessionPort, + TerminalSnapshot, +} from '@features/workspace-trust/core/application'; + +class FakePtySession implements PtySessionPort { + readonly actions: PtyKeyAction[] = []; + + constructor(private readonly snapshots: string[]) {} + + async readSnapshot(): Promise { + const text = this.snapshots.shift() ?? this.snapshots.at(-1) ?? ''; + return { text, capturedAtMs: Date.now() }; + } + + async writeAction(action: PtyKeyAction): Promise { + this.actions.push(action); + } + + async kill(): Promise { + return undefined; + } +} + +describe('PtyDialogEngine', () => { + it('sends allowlisted dialog actions once and stops when post-action verification succeeds', async () => { + const session = new FakePtySession(['Quick safety check\ntrust this folder']); + const result = await runPtyDialogEngine({ + session, + timeoutMs: 200, + pollIntervalMs: 1, + isCancelled: () => false, + detect: () => ({ + phase: 'dialog', + ruleId: 'claude.workspace_trust', + actions: [PTY_KEY_ACTIONS.enter], + retryPolicy: 'once', + evidence: ['trust prompt'], + }), + afterDialogAction: async () => ({ action: 'stop', reason: 'workspace_trust_persisted' }), + }); + + expect(result).toMatchObject({ + status: 'ok', + reason: 'workspace_trust_persisted', + actions: ['claude.workspace_trust:enter'], + }); + expect(session.actions).toEqual([PTY_KEY_ACTIONS.enter]); + }); + + it('does not repeat once-only actions against stale terminal text', async () => { + const session = new FakePtySession(['trust', 'trust', 'trust']); + const result = await runPtyDialogEngine({ + session, + timeoutMs: 20, + pollIntervalMs: 1, + settleDelayMs: 1, + isCancelled: () => false, + detect: () => ({ + phase: 'dialog', + ruleId: 'claude.workspace_trust', + actions: [PTY_KEY_ACTIONS.enter], + retryPolicy: 'once', + evidence: ['trust prompt'], + }), + }); + + expect(result.status).toBe('timeout'); + expect(session.actions).toEqual([PTY_KEY_ACTIONS.enter]); + }); + + it('blocks when retryable dialogs exceed the configured action budget', async () => { + const session = new FakePtySession(['confirm', 'confirm', 'confirm']); + const result = await runPtyDialogEngine({ + session, + timeoutMs: 100, + pollIntervalMs: 1, + settleDelayMs: 1, + maxActions: 2, + isCancelled: () => false, + detect: () => ({ + phase: 'dialog', + ruleId: 'claude.bypass_permissions', + actions: [PTY_KEY_ACTIONS.down, PTY_KEY_ACTIONS.enter], + retryPolicy: 'typed_retry', + evidence: ['bypass prompt'], + }), + }); + + expect(result).toMatchObject({ + status: 'blocked', + code: 'workspace_trust_too_many_dialog_actions', + actions: ['claude.bypass_permissions:down', 'claude.bypass_permissions:enter'], + }); + expect(session.actions).toEqual([PTY_KEY_ACTIONS.down, PTY_KEY_ACTIONS.enter]); + }); + + it('returns cancelled before writing actions when cancellation is requested after detection', async () => { + const session = new FakePtySession(['Quick safety check\ntrust this folder']); + let cancelled = false; + const result = await runPtyDialogEngine({ + session, + timeoutMs: 100, + pollIntervalMs: 1, + isCancelled: () => { + const current = cancelled; + cancelled = true; + return current; + }, + detect: () => ({ + phase: 'dialog', + ruleId: 'claude.workspace_trust', + actions: [PTY_KEY_ACTIONS.enter], + retryPolicy: 'once', + evidence: ['trust prompt'], + }), + }); + + expect(result).toMatchObject({ + status: 'cancelled', + matchedRuleIds: ['claude.workspace_trust'], + actions: [], + }); + expect(session.actions).toEqual([]); + }); + + it('keeps the last non-empty terminal snapshot for timeout diagnostics', async () => { + const session = new FakePtySession(['Unknown Claude startup screen', '', '']); + const result = await runPtyDialogEngine({ + session, + timeoutMs: 20, + pollIntervalMs: 1, + settleDelayMs: 1, + isCancelled: () => false, + detect: () => ({ phase: 'loading' }), + }); + + expect(result.status).toBe('timeout'); + expect(result.lastSnapshot?.text).toBe('Unknown Claude startup screen'); + }); + + it('blocks setup-required screens without sending actions', async () => { + const session = new FakePtySession(['Log in to Claude']); + const result = await runPtyDialogEngine({ + session, + timeoutMs: 20, + pollIntervalMs: 1, + isCancelled: () => false, + detect: () => ({ + phase: 'setup_required', + code: 'provider_auth_required', + evidence: ['auth'], + }), + }); + + expect(result).toMatchObject({ + status: 'blocked', + code: 'provider_auth_required', + }); + expect(session.actions).toEqual([]); + }); +}); diff --git a/test/features/workspace-trust/core/StartupDialogRules.test.ts b/test/features/workspace-trust/core/StartupDialogRules.test.ts new file mode 100644 index 00000000..70002a87 --- /dev/null +++ b/test/features/workspace-trust/core/StartupDialogRules.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest'; + +import { detectClaudeStartupState } from '@features/workspace-trust/core/application'; + +describe('StartupDialogRules', () => { + it('detects Claude workspace trust before a prompt-looking screen can be treated as ready', () => { + const state = detectClaudeStartupState(` + > + Quick safety check: Is this a project you created or one you trust? + Yes, I trust this folder + `); + + expect(state).toMatchObject({ + phase: 'dialog', + ruleId: 'claude.workspace_trust', + }); + }); + + it('detects Claude workspace trust when the TUI collapses prompt spacing', () => { + const state = detectClaudeStartupState(` + Accessingworkspace: + /private/var/folders/project + Quicksafetycheck:Isthisaprojectyoucreatedoroneyoutrust? + ClaudeCode'llbeabletoread,edit,andexecutefileshere. + ❯1.Yes,Itrustthisfolder + 2.No,exit + Entertoconfirm·Esctocancel + `); + + expect(state).toMatchObject({ + phase: 'dialog', + ruleId: 'claude.workspace_trust', + }); + }); + + it('detects Claude workspace trust through conservative fuzzy wording', () => { + const state = detectClaudeStartupState(` + Claude Code can read, edit, and execute files here. + Do you trust this workspace? + 1. Yes, trust this workspace + 2. No, exit + `); + + expect(state).toMatchObject({ + phase: 'dialog', + ruleId: 'claude.workspace_trust', + }); + }); + + it('does not classify generic trust copy as Claude trust without Claude-specific context', () => { + expect( + detectClaudeStartupState(` + Do you trust this folder? + Yes, trust this folder + `) + ).toEqual({ phase: 'loading' }); + }); + + it('detects the Claude prompt marker only after trust/auth prompts have been ruled out', () => { + expect(detectClaudeStartupState('Claude Code\n>')).toEqual({ + phase: 'ready', + evidence: ['claude prompt marker'], + }); + }); + + it('detects Codex update before Codex workspace trust in the known startup chain', () => { + const update = detectClaudeStartupState('Update available\nSkip'); + const trust = detectClaudeStartupState( + 'Do you trust the contents of this directory?\nYes, continue' + ); + + expect(update).toMatchObject({ + phase: 'dialog', + ruleId: 'codex.update_available', + }); + expect(trust).toMatchObject({ + phase: 'dialog', + ruleId: 'codex.workspace_trust', + }); + }); + + it('classifies auth prompts as setup required and does not return actions', () => { + const state = detectClaudeStartupState('Log in to Claude to continue'); + + expect(state).toEqual({ + phase: 'setup_required', + code: 'provider_auth_required', + evidence: ['provider auth required prompt'], + }); + }); +}); diff --git a/test/features/workspace-trust/core/WorkspaceTrustArgPatchApplier.test.ts b/test/features/workspace-trust/core/WorkspaceTrustArgPatchApplier.test.ts new file mode 100644 index 00000000..3cc2ba2f --- /dev/null +++ b/test/features/workspace-trust/core/WorkspaceTrustArgPatchApplier.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from 'vitest'; + +import { + applyWorkspaceTrustLaunchArgPatches, + buildCodexWorkspaceTrustSettingsArgs, + readCodexWorkspaceTrustConfigOverridesFromSettings, + type WorkspaceTrustLaunchArgPatch, +} from '@features/workspace-trust/core/domain'; + +function settingsObjects(args: string[]): Record[] { + const output: Record[] = []; + for (let i = 0; i < args.length; i += 1) { + if (args[i] === '--settings' && typeof args[i + 1] === 'string') { + output.push(JSON.parse(args[i + 1]) as Record); + } + } + return output; +} + +function patch(id: string, overrides: string[]): WorkspaceTrustLaunchArgPatch { + return { + id, + owner: 'workspace-trust', + targetProvider: 'codex', + targetSurface: 'primary_provider_args', + dialect: 'claude-codex-runtime-settings', + args: buildCodexWorkspaceTrustSettingsArgs(overrides), + dedupeKey: id, + sourceWorkspaceIds: ['workspace-1'], + reason: 'Codex native trust is carried through sibling runtime settings.', + }; +} + +describe('WorkspaceTrustArgPatchApplier', () => { + it('applies Codex workspace trust settings without replacing existing provider settings', () => { + const override = 'projects."/tmp/project".trust_level="trusted"'; + const result = applyWorkspaceTrustLaunchArgPatches({ + args: ['--settings', '{"codex":{"forced_login_method":"chatgpt"}}'], + patches: [patch('codex-trust', [override])], + targetProvider: 'codex', + targetSurface: 'primary_provider_args', + }); + + expect(result.appliedPatchIds).toEqual(['codex-trust']); + expect(result.addedWorkspaceTrustOverrideCount).toBe(1); + expect(result.args).not.toContain('-c'); + expect(settingsObjects(result.args)[0]).toEqual({ + codex: { + forced_login_method: 'chatgpt', + }, + }); + expect( + readCodexWorkspaceTrustConfigOverridesFromSettings(settingsObjects(result.args).at(-1)) + ).toEqual([override]); + }); + + it('dedupes exact app-owned override values across repeated applications', () => { + const override = 'projects."/tmp/project".trust_level="trusted"'; + const first = applyWorkspaceTrustLaunchArgPatches({ + args: [], + patches: [patch('codex-trust', [override])], + targetProvider: 'codex', + targetSurface: 'primary_provider_args', + }); + const second = applyWorkspaceTrustLaunchArgPatches({ + args: first.args, + patches: [patch('codex-trust', [override])], + targetProvider: 'codex', + targetSurface: 'primary_provider_args', + }); + + expect(first.addedWorkspaceTrustOverrideCount).toBe(1); + expect(second.addedWorkspaceTrustOverrideCount).toBe(0); + expect(second.args).toEqual(first.args); + }); + + it('skips wrong providers, wrong surfaces, and direct Codex native dialects', () => { + const nativePatch: WorkspaceTrustLaunchArgPatch = { + ...patch('native-codex', ['projects."/tmp/project".trust_level="trusted"']), + dialect: 'codex-native-config-override', + args: ['projects."/tmp/project".trust_level="trusted"'], + }; + const result = applyWorkspaceTrustLaunchArgPatches({ + args: [], + patches: [ + patch('provider-mismatch', ['projects."/tmp/a".trust_level="trusted"']), + { + ...patch('surface-mismatch', ['projects."/tmp/b".trust_level="trusted"']), + targetSurface: 'provider_facts_probe', + }, + nativePatch, + ], + targetProvider: 'anthropic', + targetSurface: 'primary_provider_args', + }); + + expect(result.args).toEqual([]); + expect(result.appliedPatchIds).toEqual([]); + expect(result.skippedPatches.map((item) => item.reason)).toEqual([ + 'provider_mismatch', + 'provider_mismatch', + 'provider_mismatch', + ]); + }); + + it('reports non-provider skip reasons without mutating args', () => { + const unsupportedDialectPatch: WorkspaceTrustLaunchArgPatch = { + ...patch('unsupported-dialect', ['projects."/tmp/project".trust_level="trusted"']), + dialect: 'codex-direct-cli-config', + args: ['projects."/tmp/project".trust_level="trusted"'], + }; + const result = applyWorkspaceTrustLaunchArgPatches({ + args: ['--existing'], + patches: [ + { + ...patch('surface-mismatch', ['projects."/tmp/surface".trust_level="trusted"']), + targetSurface: 'default_model_probe', + }, + unsupportedDialectPatch, + { ...patch('empty', []), args: [] }, + { ...patch('malformed', []), args: ['--settings', '{nope'] }, + ], + targetProvider: 'codex', + targetSurface: 'primary_provider_args', + }); + + expect(result.args).toEqual(['--existing']); + expect(result.appliedPatchIds).toEqual([]); + expect(result.addedWorkspaceTrustOverrideCount).toBe(0); + expect(result.skippedPatches).toEqual([ + { id: 'surface-mismatch', reason: 'surface_mismatch' }, + { id: 'unsupported-dialect', reason: 'unsupported_dialect' }, + { id: 'empty', reason: 'empty_patch' }, + { id: 'malformed', reason: 'malformed_patch_settings' }, + ]); + }); + + it('merges existing --settings= overrides and dedupes duplicates inside a patch', () => { + const existing = 'projects."/tmp/already".trust_level="trusted"'; + const next = 'projects."/tmp/next".trust_level="trusted"'; + const [settingsFlag, settingsJson] = buildCodexWorkspaceTrustSettingsArgs([existing]); + const result = applyWorkspaceTrustLaunchArgPatches({ + args: [`${settingsFlag}=${settingsJson}`], + patches: [patch('codex-trust', [existing, next, next])], + targetProvider: 'codex', + targetSurface: 'primary_provider_args', + }); + + expect(result.appliedPatchIds).toEqual(['codex-trust']); + expect(result.addedWorkspaceTrustOverrideCount).toBe(1); + expect( + readCodexWorkspaceTrustConfigOverridesFromSettings(settingsObjects(result.args).at(-1)) + ).toEqual([existing, next]); + }); +}); diff --git a/test/features/workspace-trust/core/WorkspaceTrustCoordinator.test.ts b/test/features/workspace-trust/core/WorkspaceTrustCoordinator.test.ts new file mode 100644 index 00000000..aa089aa0 --- /dev/null +++ b/test/features/workspace-trust/core/WorkspaceTrustCoordinator.test.ts @@ -0,0 +1,267 @@ +import { describe, expect, it } from 'vitest'; + +import { + ClaudePtyWorkspaceTrustStrategy, + DefaultWorkspaceTrustCoordinator, + WorkspaceTrustLockCancelledError, + WorkspaceTrustLockRegistry, + WorkspaceTrustLockTimeoutError, +} from '@features/workspace-trust/core/application'; +import { + buildWorkspaceTrustPathCandidates, + type WorkspaceTrustDiagnosticStrategyResult, + type WorkspaceTrustWorkspace, +} from '@features/workspace-trust/core/domain'; + +const featureFlags = { + enabled: true, + claudePty: true, + codexArgs: true, + retry: false, + fileLock: true, +}; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function workspace(): WorkspaceTrustWorkspace { + return buildWorkspaceTrustPathCandidates({ + cwd: '/tmp/project', + realCwd: '/private/tmp/project', + platform: 'posix', + })[0]; +} + +class RecordingClaudeStrategy extends ClaudePtyWorkspaceTrustStrategy { + active = 0; + maxActive = 0; + calls = 0; + + override async execute(): Promise { + this.calls += 1; + this.active += 1; + this.maxActive = Math.max(this.maxActive, this.active); + await sleep(10); + this.active -= 1; + return { + id: 'claude-pty-workspace-trust', + provider: 'claude', + status: 'ok', + workspaceIds: ['workspace'], + }; + } +} + +class ThrowingClaudeStrategy extends ClaudePtyWorkspaceTrustStrategy { + override async execute(): Promise { + throw new Error('pty unavailable'); + } +} + +describe('WorkspaceTrustCoordinator', () => { + it('plans Codex trust as settings patches instead of direct native -c args', async () => { + const coordinator = new DefaultWorkspaceTrustCoordinator(new ClaudePtyWorkspaceTrustStrategy()); + const workspaces = buildWorkspaceTrustPathCandidates({ + cwd: '/tmp/project', + realCwd: '/private/tmp/project', + platform: 'posix', + }); + + const plan = await coordinator.planFull({ + providers: ['claude', 'codex'], + workspaces, + featureFlags, + }); + + expect(plan.launchArgPatches).toHaveLength(4); + expect(plan.launchArgPatches.every((patch) => patch.targetProvider === 'codex')).toBe(true); + expect( + plan.launchArgPatches.every((patch) => patch.dialect === 'claude-codex-runtime-settings') + ).toBe(true); + expect(plan.launchArgPatches.flatMap((patch) => patch.args)).not.toContain('-c'); + expect(plan.launchArgPatches[0].args.join(' ')).toContain('agent_teams_workspace_trust'); + }); + + it('does not emit Codex settings patches for Anthropic-only launches', async () => { + const coordinator = new DefaultWorkspaceTrustCoordinator(new ClaudePtyWorkspaceTrustStrategy()); + const plan = await coordinator.planArgsOnly({ + providers: ['claude'], + workspaces: buildWorkspaceTrustPathCandidates({ cwd: '/tmp/project', platform: 'posix' }), + featureFlags, + }); + + expect(plan.launchArgPatches).toEqual([]); + }); + + it('limits Codex settings patches to requested target surfaces', async () => { + const coordinator = new DefaultWorkspaceTrustCoordinator(new ClaudePtyWorkspaceTrustStrategy()); + const plan = await coordinator.planArgsOnly({ + providers: ['anthropic', 'codex'], + workspaces: buildWorkspaceTrustPathCandidates({ cwd: '/tmp/project', platform: 'posix' }), + targetSurfaces: ['provider_facts_probe'], + featureFlags, + }); + + expect(plan.launchArgPatches).toHaveLength(1); + expect(plan.launchArgPatches[0]).toMatchObject({ + targetProvider: 'codex', + targetSurface: 'provider_facts_probe', + dialect: 'claude-codex-runtime-settings', + }); + }); + + it('does not plan Codex patches when Codex arg propagation is disabled', async () => { + const coordinator = new DefaultWorkspaceTrustCoordinator(new ClaudePtyWorkspaceTrustStrategy()); + + await expect( + coordinator.planFull({ + providers: ['codex'], + workspaces: buildWorkspaceTrustPathCandidates({ cwd: '/tmp/project', platform: 'posix' }), + featureFlags: { ...featureFlags, codexArgs: false }, + }) + ).resolves.toMatchObject({ launchArgPatches: [] }); + }); + + it('does not plan Codex patches or execute Claude PTY when workspace trust is disabled', async () => { + const strategy = new RecordingClaudeStrategy(); + const coordinator = new DefaultWorkspaceTrustCoordinator(strategy); + const disabledFlags = { + ...featureFlags, + enabled: false, + claudePty: false, + codexArgs: false, + }; + const workspaces = buildWorkspaceTrustPathCandidates({ + cwd: '/tmp/project', + realCwd: '/private/tmp/project', + platform: 'posix', + }); + + await expect( + coordinator.planFull({ + providers: ['claude', 'codex'], + workspaces, + featureFlags: disabledFlags, + }) + ).resolves.toEqual({ workspaces, launchArgPatches: [] }); + + await expect( + coordinator.execute({ + claudePath: '/usr/local/bin/claude', + workspaces, + env: {}, + featureFlags: disabledFlags, + isCancelled: () => false, + }) + ).resolves.toMatchObject({ status: 'skipped' }); + expect(strategy.calls).toBe(0); + }); + + it('serializes Claude preflights for the same workspace', async () => { + const strategy = new RecordingClaudeStrategy(); + const coordinator = new DefaultWorkspaceTrustCoordinator(strategy); + const plan = { + claudePath: '/usr/local/bin/claude', + workspaces: [workspace()], + env: {}, + featureFlags, + isCancelled: () => false, + }; + + await Promise.all([coordinator.execute(plan), coordinator.execute(plan)]); + + expect(strategy.calls).toBe(2); + expect(strategy.maxActive).toBe(1); + }); + + it('returns a soft failure when the Claude strategy throws unexpectedly', async () => { + const coordinator = new DefaultWorkspaceTrustCoordinator(new ThrowingClaudeStrategy()); + + const result = await coordinator.execute({ + claudePath: '/usr/local/bin/claude', + workspaces: [workspace()], + env: {}, + featureFlags, + isCancelled: () => false, + }); + + expect(result).toMatchObject({ + status: 'soft_failed', + errorCode: 'workspace_trust_preflight_error', + errorMessage: 'pty unavailable', + }); + }); + + it('times out lock waits without blocking later waiters', async () => { + const locks = new WorkspaceTrustLockRegistry(); + let releaseFirst!: () => void; + let enteredFirst!: () => void; + const firstEntered = new Promise((resolve) => { + enteredFirst = resolve; + }); + const firstReleased = new Promise((resolve) => { + releaseFirst = resolve; + }); + const first = locks.withWorkspaceLock( + 'claude:/tmp/project', + { timeoutMs: 1000, pollIntervalMs: 1, isCancelled: () => false }, + async () => { + enteredFirst(); + await firstReleased; + } + ); + await firstEntered; + + await expect( + locks.withWorkspaceLock( + 'claude:/tmp/project', + { timeoutMs: 5, pollIntervalMs: 1, isCancelled: () => false }, + async () => undefined + ) + ).rejects.toBeInstanceOf(WorkspaceTrustLockTimeoutError); + + releaseFirst(); + await first; + await expect( + locks.withWorkspaceLock( + 'claude:/tmp/project', + { timeoutMs: 50, pollIntervalMs: 1, isCancelled: () => false }, + async () => 'ok' + ) + ).resolves.toBe('ok'); + }); + + it('cancels lock waits without running the protected section', async () => { + const locks = new WorkspaceTrustLockRegistry(); + let releaseFirst!: () => void; + let enteredFirst!: () => void; + const firstEntered = new Promise((resolve) => { + enteredFirst = resolve; + }); + const firstReleased = new Promise((resolve) => { + releaseFirst = resolve; + }); + const first = locks.withWorkspaceLock( + 'claude:/tmp/project', + { timeoutMs: 1000, pollIntervalMs: 1, isCancelled: () => false }, + async () => { + enteredFirst(); + await firstReleased; + } + ); + await firstEntered; + const protectedSection = async () => 'should-not-run'; + + await expect( + locks.withWorkspaceLock( + 'claude:/tmp/project', + { timeoutMs: 1000, pollIntervalMs: 1, isCancelled: () => true }, + protectedSection + ) + ).rejects.toBeInstanceOf(WorkspaceTrustLockCancelledError); + + releaseFirst(); + await first; + }); +}); diff --git a/test/features/workspace-trust/core/WorkspaceTrustDiagnosticsBudget.test.ts b/test/features/workspace-trust/core/WorkspaceTrustDiagnosticsBudget.test.ts new file mode 100644 index 00000000..b58de17a --- /dev/null +++ b/test/features/workspace-trust/core/WorkspaceTrustDiagnosticsBudget.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; + +import { budgetWorkspaceTrustDiagnosticsManifest } from '@features/workspace-trust/core/domain'; + +describe('WorkspaceTrustDiagnosticsBudget', () => { + it('caps strategy results, workspace ids, evidence, and raw tails before artifact use', () => { + const manifest = budgetWorkspaceTrustDiagnosticsManifest( + { + attempt: 1, + featureFlags: { + enabled: true, + claudePty: true, + codexArgs: true, + retry: false, + fileLock: true, + }, + strategyResults: [ + { + id: 'claude-1', + provider: 'claude', + status: 'blocked', + workspaceIds: ['w1', 'w2', 'w3'], + evidence: ['x'.repeat(20), 'second', 'third'], + rawTail: 'r'.repeat(30), + }, + { + id: 'codex-1', + provider: 'codex', + status: 'ok', + workspaceIds: ['w4'], + }, + ], + }, + { + maxStrategyResults: 1, + maxWorkspaceIdsPerResult: 2, + maxEvidencePerResult: 2, + maxEvidenceLength: 12, + maxRawTailLength: 10, + } + ); + + expect(manifest.strategyResults).toHaveLength(1); + expect(manifest.strategyResults[0].workspaceIds).toEqual(['w1', 'w2']); + expect(manifest.strategyResults[0].evidence).toEqual(['[truncated]', 'second']); + expect(manifest.strategyResults[0].rawTail).toBe('[truncated]'); + expect(manifest.omittedCounts).toEqual({ + strategyResults: 1, + workspaceIds: 1, + evidence: 1, + }); + }); +}); diff --git a/test/features/workspace-trust/core/WorkspaceTrustPath.test.ts b/test/features/workspace-trust/core/WorkspaceTrustPath.test.ts new file mode 100644 index 00000000..bb373acb --- /dev/null +++ b/test/features/workspace-trust/core/WorkspaceTrustPath.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildWorkspaceTrustPathCandidates, + collectWorkspaceTrustParentConfigKeys, + getWorkspaceTrustNonPersistableReason, + normalizeWorkspaceTrustComparisonKey, + normalizeWorkspaceTrustConfigKey, +} from '@features/workspace-trust/core/domain'; + +describe('WorkspaceTrustPath', () => { + it('normalizes runtime-compatible config keys without lowercasing POSIX paths', () => { + expect(normalizeWorkspaceTrustConfigKey('/Tmp/Repo/', { platform: 'posix' })).toBe('/Tmp/Repo'); + expect(normalizeWorkspaceTrustComparisonKey('/Tmp/Repo', { platform: 'posix' })).toBe( + '/Tmp/Repo' + ); + expect(normalizeWorkspaceTrustComparisonKey('/tmp/repo', { platform: 'posix' })).not.toBe( + normalizeWorkspaceTrustComparisonKey('/Tmp/Repo', { platform: 'posix' }) + ); + }); + + it('dedupes Windows drive-letter and separator variants for comparison only', () => { + expect(normalizeWorkspaceTrustConfigKey('C:\\Repo\\Sub\\', { platform: 'win32' })).toBe( + 'C:/Repo/Sub' + ); + expect(normalizeWorkspaceTrustConfigKey('\\\\server\\share\\Repo', { platform: 'win32' })).toBe( + '//server/share/Repo' + ); + expect(normalizeWorkspaceTrustComparisonKey('C:\\Repo', { platform: 'win32' })).toBe( + normalizeWorkspaceTrustComparisonKey('c:/repo/', { platform: 'win32' }) + ); + }); + + it('normalizes OneDrive-style Windows paths while preserving config-key casing', () => { + const workspaces = buildWorkspaceTrustPathCandidates({ + cwd: 'C:\\Users\\vilok\\OneDrive\\Desktop\\Safar 0.1\\', + realCwd: 'c:\\Users\\vilok\\OneDrive\\Desktop\\Safar 0.1', + gitRoot: 'C:\\Users\\vilok\\OneDrive\\Desktop\\Safar 0.1', + homeDir: 'C:\\Users\\vilok', + platform: 'win32', + }); + + expect(workspaces).toHaveLength(1); + expect(workspaces[0]).toMatchObject({ + configKeyCwd: 'C:/Users/vilok/OneDrive/Desktop/Safar 0.1', + comparisonKey: 'c:/users/vilok/onedrive/desktop/safar 0.1', + gitRootConfigKey: 'C:/Users/vilok/OneDrive/Desktop/Safar 0.1', + persistable: true, + }); + }); + + it('collects exact and parent config keys using runtime key normalization', () => { + expect(collectWorkspaceTrustParentConfigKeys('/tmp/repo/app', { platform: 'posix' })).toEqual([ + '/tmp/repo/app', + '/tmp/repo', + '/tmp', + '/', + ]); + expect(collectWorkspaceTrustParentConfigKeys('C:\\Repo\\app', { platform: 'win32' })).toEqual([ + 'C:/Repo/app', + 'C:/Repo', + 'C:/', + ]); + expect( + collectWorkspaceTrustParentConfigKeys('\\\\server\\share\\Repo\\App', { platform: 'win32' }) + ).toEqual(['//server/share/Repo/App', '//server/share/Repo', '//server/share/']); + }); + + it('builds cwd, realpath, and git-root candidates without duplicate comparison keys', () => { + const workspaces = buildWorkspaceTrustPathCandidates({ + cwd: '/var/folders/project', + realCwd: '/private/var/folders/project', + gitRoot: '/private/var/folders/project', + source: 'member-worktree', + memberId: 'alice-reviewer', + platform: 'posix', + }); + + expect(workspaces).toHaveLength(2); + expect(workspaces.map((workspace) => workspace.configKeyCwd)).toEqual([ + '/var/folders/project', + '/private/var/folders/project', + ]); + expect(workspaces[0]).toMatchObject({ + displayCwd: '/var/folders/project', + source: 'member-worktree', + memberId: 'alice-reviewer', + gitRootConfigKey: '/private/var/folders/project', + persistable: true, + }); + }); + + it('marks home, root, and missing paths as non-persistable', () => { + expect( + getWorkspaceTrustNonPersistableReason('/Users/belief', { + homeDir: '/Users/belief/', + platform: 'posix', + }) + ).toBe('home_directory'); + expect(getWorkspaceTrustNonPersistableReason('/', { platform: 'posix' })).toBe( + 'filesystem_root' + ); + expect(getWorkspaceTrustNonPersistableReason('', { platform: 'posix' })).toBe('unavailable'); + }); + + it('marks Windows home, drive root, UNC share root, and missing paths as non-persistable', () => { + expect( + getWorkspaceTrustNonPersistableReason('C:\\Users\\vilok\\', { + homeDir: 'c:/users/vilok', + platform: 'win32', + }) + ).toBe('home_directory'); + expect(getWorkspaceTrustNonPersistableReason('C:\\', { platform: 'win32' })).toBe( + 'filesystem_root' + ); + expect( + getWorkspaceTrustNonPersistableReason('\\\\server\\share\\', { platform: 'win32' }) + ).toBe('filesystem_root'); + expect(getWorkspaceTrustNonPersistableReason(' ', { platform: 'win32' })).toBe('unavailable'); + }); +}); diff --git a/test/features/workspace-trust/main/ClaudeStateProbe.test.ts b/test/features/workspace-trust/main/ClaudeStateProbe.test.ts new file mode 100644 index 00000000..da6a6400 --- /dev/null +++ b/test/features/workspace-trust/main/ClaudeStateProbe.test.ts @@ -0,0 +1,53 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { FileClaudeStateProbe } from '@features/workspace-trust/main/adapters/output/ClaudeStateProbe'; +import { buildWorkspaceTrustPathCandidates } from '@features/workspace-trust/core/domain'; + +let tmpDir: string | null = null; + +async function makeTmpDir(): Promise { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'workspace-trust-probe-')); + return tmpDir; +} + +afterEach(async () => { + if (tmpDir) { + await fs.rm(tmpDir, { recursive: true, force: true }); + tmpDir = null; + } +}); + +describe('FileClaudeStateProbe', () => { + it('reads the explicit global config file path used by the runtime default profile', async () => { + const dir = await makeTmpDir(); + const globalConfigFilePath = path.join(dir, '.claude.json'); + await fs.writeFile( + globalConfigFilePath, + JSON.stringify({ + projects: { + '/tmp/project': { + hasTrustDialogAccepted: true, + }, + }, + }), + 'utf8' + ); + + const workspace = buildWorkspaceTrustPathCandidates({ + cwd: '/tmp/project/app', + platform: 'posix', + })[0]; + const result = await new FileClaudeStateProbe({ globalConfigFilePath }).readTrustState( + workspace + ); + + expect(result).toEqual({ + status: 'trusted', + evidence: ['trusted project key: /tmp/project'], + }); + }); +}); diff --git a/test/features/workspace-trust/main/WorkspaceTrustFeatureFlags.test.ts b/test/features/workspace-trust/main/WorkspaceTrustFeatureFlags.test.ts new file mode 100644 index 00000000..c9789c17 --- /dev/null +++ b/test/features/workspace-trust/main/WorkspaceTrustFeatureFlags.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { resolveWorkspaceTrustFeatureFlags } from '@features/workspace-trust/main'; + +describe('WorkspaceTrustFeatureFlags', () => { + it('keeps workspace trust on by default without claiming file-lock support', () => { + expect(resolveWorkspaceTrustFeatureFlags({} as NodeJS.ProcessEnv)).toEqual({ + enabled: true, + claudePty: true, + codexArgs: true, + retry: false, + fileLock: false, + }); + }); + + it('does not enable the reserved file lock flag through env yet', () => { + expect( + resolveWorkspaceTrustFeatureFlags({ + AGENT_TEAMS_WORKSPACE_TRUST_FILE_LOCK: 'true', + } as NodeJS.ProcessEnv).fileLock + ).toBe(false); + }); + + it('uses the plan-name preflight flag before the legacy feature flag', () => { + expect( + resolveWorkspaceTrustFeatureFlags({ + AGENT_TEAMS_WORKSPACE_TRUST_PREFLIGHT: 'false', + AGENT_TEAMS_WORKSPACE_TRUST: 'true', + } as NodeJS.ProcessEnv) + ).toMatchObject({ + enabled: false, + claudePty: false, + codexArgs: false, + }); + }); + + it('uses the plan-name Codex settings flag before the legacy args alias', () => { + expect( + resolveWorkspaceTrustFeatureFlags({ + AGENT_TEAMS_WORKSPACE_TRUST_CODEX_SETTINGS: 'false', + AGENT_TEAMS_WORKSPACE_TRUST_CODEX_ARGS: 'true', + } as NodeJS.ProcessEnv).codexArgs + ).toBe(false); + }); + + it('keeps malformed default-on flags enabled and malformed default-off retry disabled', () => { + expect( + resolveWorkspaceTrustFeatureFlags({ + AGENT_TEAMS_WORKSPACE_TRUST_PREFLIGHT: 'wat', + AGENT_TEAMS_WORKSPACE_TRUST_CLAUDE_PTY: 'maybe', + AGENT_TEAMS_WORKSPACE_TRUST_CODEX_SETTINGS: '???', + AGENT_TEAMS_WORKSPACE_TRUST_RETRY: 'later', + } as NodeJS.ProcessEnv) + ).toEqual({ + enabled: true, + claudePty: true, + codexArgs: true, + retry: false, + fileLock: false, + }); + expect(vi.mocked(console.warn).mock.calls.map((call) => call.join(' '))).toEqual( + expect.arrayContaining([ + expect.stringContaining('AGENT_TEAMS_WORKSPACE_TRUST_PREFLIGHT'), + expect.stringContaining('AGENT_TEAMS_WORKSPACE_TRUST_CLAUDE_PTY'), + expect.stringContaining('AGENT_TEAMS_WORKSPACE_TRUST_CODEX_SETTINGS'), + expect.stringContaining('AGENT_TEAMS_WORKSPACE_TRUST_RETRY'), + ]) + ); + vi.mocked(console.warn).mockClear(); + }); + + it('keeps child capabilities off when the main preflight flag is disabled', () => { + expect( + resolveWorkspaceTrustFeatureFlags({ + AGENT_TEAMS_WORKSPACE_TRUST_PREFLIGHT: 'off', + AGENT_TEAMS_WORKSPACE_TRUST_CLAUDE_PTY: 'on', + AGENT_TEAMS_WORKSPACE_TRUST_CODEX_SETTINGS: 'on', + AGENT_TEAMS_WORKSPACE_TRUST_RETRY: 'on', + } as NodeJS.ProcessEnv) + ).toEqual({ + enabled: false, + claudePty: false, + codexArgs: false, + retry: false, + fileLock: false, + }); + }); +}); diff --git a/test/features/workspace-trust/main/workspaceTrustPreflightEnv.test.ts b/test/features/workspace-trust/main/workspaceTrustPreflightEnv.test.ts new file mode 100644 index 00000000..4d6eacda --- /dev/null +++ b/test/features/workspace-trust/main/workspaceTrustPreflightEnv.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; + +import { buildWorkspaceTrustPreflightEnv } from '@features/workspace-trust/main'; + +describe('workspaceTrustPreflightEnv', () => { + it('strips team runtime and provider-routing env while preserving user auth env', () => { + const env = buildWorkspaceTrustPreflightEnv({ + HOME: '/Users/tester', + PATH: '/usr/local/bin', + CLAUDE_CONFIG_DIR: '/Users/tester/.claude-custom', + ANTHROPIC_API_KEY: 'user-anthropic-key', + ANTHROPIC_AUTH_TOKEN: 'user-oauth-token', + OPENAI_API_KEY: 'user-openai-key', + CLAUDE_ENABLE_DETERMINISTIC_TEAM_BOOTSTRAP: '1', + CLAUDE_TEAM_CONTROL_URL: 'http://127.0.0.1:1234', + CLAUDE_TEAM_ANTHROPIC_AUTH_MODE: 'api_key_helper', + CLAUDE_TEAM_ANTHROPIC_AUTH_MODE_API_KEY_HELPER: '1', + CLAUDE_TEAM_ANTHROPIC_API_KEY_HELPER_SETTINGS_PATH: '/tmp/helper-settings.json', + CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST: '1', + CLAUDE_CODE_ENTRY_PROVIDER: 'codex', + CLAUDE_CODE_USE_OPENAI: '1', + CLAUDE_CODE_USE_BEDROCK: '1', + CLAUDE_CODE_USE_VERTEX: '1', + CLAUDE_CODE_USE_FOUNDRY: '1', + CLAUDE_CODE_USE_GEMINI: '1', + CLAUDE_CODE_CODEX_BACKEND: 'codex-native', + CLAUDE_CODE_GEMINI_BACKEND: 'api', + CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH: '/tmp/opencode', + CODEX_HOME: '/tmp/codex-home', + AGENT_TEAMS_RUNTIME_TURN_SETTLED_SPOOL_ROOT: '/tmp/spool', + AGENT_TEAMS_MCP_CLAUDE_DIR: '/tmp/claude-dir', + CLAUDE_TEAM_BOOTSTRAP_TOKEN: 'bootstrap-token', + }); + + expect(env).toMatchObject({ + HOME: '/Users/tester', + PATH: '/usr/local/bin', + CLAUDE_CONFIG_DIR: '/Users/tester/.claude-custom', + ANTHROPIC_API_KEY: 'user-anthropic-key', + ANTHROPIC_AUTH_TOKEN: 'user-oauth-token', + OPENAI_API_KEY: 'user-openai-key', + }); + expect(env.CLAUDE_ENABLE_DETERMINISTIC_TEAM_BOOTSTRAP).toBeUndefined(); + expect(env.CLAUDE_TEAM_CONTROL_URL).toBeUndefined(); + expect(env.CLAUDE_TEAM_ANTHROPIC_AUTH_MODE).toBeUndefined(); + expect(env.CLAUDE_TEAM_ANTHROPIC_AUTH_MODE_API_KEY_HELPER).toBeUndefined(); + expect(env.CLAUDE_TEAM_ANTHROPIC_API_KEY_HELPER_SETTINGS_PATH).toBeUndefined(); + expect(env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST).toBeUndefined(); + expect(env.CLAUDE_CODE_ENTRY_PROVIDER).toBeUndefined(); + expect(env.CLAUDE_CODE_USE_OPENAI).toBeUndefined(); + expect(env.CLAUDE_CODE_USE_BEDROCK).toBeUndefined(); + expect(env.CLAUDE_CODE_USE_VERTEX).toBeUndefined(); + expect(env.CLAUDE_CODE_USE_FOUNDRY).toBeUndefined(); + expect(env.CLAUDE_CODE_USE_GEMINI).toBeUndefined(); + expect(env.CLAUDE_CODE_CODEX_BACKEND).toBeUndefined(); + expect(env.CLAUDE_CODE_GEMINI_BACKEND).toBeUndefined(); + expect(env.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBeUndefined(); + expect(env.CODEX_HOME).toBeUndefined(); + expect(env.AGENT_TEAMS_RUNTIME_TURN_SETTLED_SPOOL_ROOT).toBeUndefined(); + expect(env.AGENT_TEAMS_MCP_CLAUDE_DIR).toBeUndefined(); + expect(env.CLAUDE_TEAM_BOOTSTRAP_TOKEN).toBeUndefined(); + }); +}); diff --git a/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts b/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts index 8c345400..5a3d8e2f 100644 --- a/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts +++ b/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts @@ -4,6 +4,11 @@ import * as path from 'path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { + WorkspaceTrustCoordinator, + WorkspaceTrustExecutionPlan, +} from '../../../../src/features/workspace-trust/core/application/WorkspaceTrustCoordinator'; +import { ClaudeBinaryResolver } from '../../../../src/main/services/team/ClaudeBinaryResolver'; import { TeamConfigReader } from '../../../../src/main/services/team/TeamConfigReader'; import { getMixedLaunchFallbackRecoveryError, @@ -51,14 +56,29 @@ import { import type { InboxMessage, TaskRef, TeamProvisioningProgress } from '../../../../src/shared/types'; const LAUNCH_MATRIX_SAFE_E2E_TIMEOUT_MS = 60_000; +const WORKSPACE_TRUST_TEST_ENV_NAMES = [ + 'AGENT_TEAMS_WORKSPACE_TRUST', + 'AGENT_TEAMS_WORKSPACE_TRUST_PREFLIGHT', + 'AGENT_TEAMS_WORKSPACE_TRUST_CLAUDE_PTY', + 'AGENT_TEAMS_WORKSPACE_TRUST_CODEX_ARGS', + 'AGENT_TEAMS_WORKSPACE_TRUST_CODEX_SETTINGS', + 'AGENT_TEAMS_WORKSPACE_TRUST_RETRY', +] as const; + +type WorkspaceTrustTestEnvName = (typeof WORKSPACE_TRUST_TEST_ENV_NAMES)[number]; describe('Team agent launch matrix safe e2e', () => { let tempDir: string; let tempClaudeRoot: string; let projectPath: string; + let originalClaudeCliPath: string | undefined; + let originalWorkspaceTrustEnv: Partial>; beforeEach(async () => { TeamConfigReader.clearCacheForTests(); + ClaudeBinaryResolver.clearCache(); + originalClaudeCliPath = process.env.CLAUDE_CLI_PATH; + originalWorkspaceTrustEnv = snapshotWorkspaceTrustTestEnv(); tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-launch-matrix-e2e-')); tempClaudeRoot = path.join(tempDir, '.claude'); projectPath = path.join(tempDir, 'project'); @@ -69,6 +89,9 @@ describe('Team agent launch matrix safe e2e', () => { afterEach(async () => { TeamConfigReader.clearCacheForTests(); + restoreOptionalEnvValue('CLAUDE_CLI_PATH', originalClaudeCliPath); + restoreWorkspaceTrustTestEnv(originalWorkspaceTrustEnv); + ClaudeBinaryResolver.clearCache(); setClaudeBasePathOverride(null); await removeTempDirWithRetries(tempDir); }); @@ -337,6 +360,193 @@ describe('Team agent launch matrix safe e2e', () => { expect(statuses.summary?.pendingCount).toBe(1); }); + it('blocks createTeam at workspace trust preflight before spawn and preserves existing launch state', async () => { + forceWorkspaceTrustPreflightEnv(); + process.env.CLAUDE_CLI_PATH = await writeFakeClaudeCli(tempDir); + ClaudeBinaryResolver.clearCache(); + + const teamName = 'workspace-trust-create-blocked-safe-e2e'; + const staleLaunchStatePath = path.join(getTeamsBasePath(), teamName, 'launch-state.json'); + const staleLaunchState = { + version: 2, + teamName, + updatedAt: '2026-05-13T00:00:00.000Z', + leadSessionId: 'previous-lead-session', + launchPhase: 'finished', + expectedMembers: ['alice'], + bootstrapExpectedMembers: ['alice'], + members: {}, + summary: { + confirmedCount: 1, + pendingCount: 0, + failedCount: 0, + runtimeAlivePendingCount: 0, + shellOnlyPendingCount: 0, + runtimeProcessPendingCount: 0, + runtimeCandidatePendingCount: 0, + noRuntimePendingCount: 0, + permissionPendingCount: 0, + }, + teamLaunchState: 'clean_success', + }; + await writeJsonFile(staleLaunchStatePath, staleLaunchState); + + const errorMessage = `Claude workspace trust was not confirmed for ${projectPath}`; + const { coordinator, execute, planFull } = createBlockedWorkspaceTrustCoordinator({ + errorMessage, + rawTail: 'Unexpected Claude startup screen', + }); + const svc = new TeamProvisioningService(); + svc.setWorkspaceTrustCoordinator(coordinator); + const progressEvents: TeamProvisioningProgress[] = []; + + await expect( + svc.createTeam( + { + teamName, + cwd: projectPath, + providerId: 'anthropic', + model: 'sonnet', + skipPermissions: true, + members: [{ name: 'alice', role: 'Reviewer', providerId: 'anthropic', model: 'haiku' }], + }, + (progress) => progressEvents.push(progress) + ) + ).rejects.toThrow(errorMessage); + + expect(progressEvents.map((progress) => progress.message)).toContain( + 'Preparing workspace trust' + ); + expect(progressEvents.at(-1)).toMatchObject({ + state: 'failed', + message: 'Workspace trust required', + error: errorMessage, + }); + expect(progressEvents.at(-1)?.launchDiagnostics).toEqual([ + expect.objectContaining({ + severity: 'error', + code: 'workspace_trust_preflight', + label: 'Workspace trust preflight blocked launch', + detail: errorMessage, + }), + ]); + expect(planFull).toHaveBeenCalledTimes(1); + expect(execute).toHaveBeenCalledTimes(1); + const executePlan = execute.mock.calls[0]?.[0]; + expect(executePlan).toBeDefined(); + expect(executePlan?.workspaces.map((workspace) => workspace.cwd)).toContain(projectPath); + + await expect(fs.readFile(staleLaunchStatePath, 'utf8')).resolves.toBe( + `${JSON.stringify(staleLaunchState, null, 2)}\n` + ); + await expect( + fs.access(path.join(getTeamsBasePath(), teamName, 'config.json')) + ).rejects.toThrow(); + + const manifest = await readLatestLaunchFailureManifest(teamName); + expect(manifest).toMatchObject({ + reason: 'launch_progress_failed', + classification: { code: 'workspace_trust_required' }, + progress: { + state: 'failed', + message: 'Workspace trust required', + error: errorMessage, + }, + flags: { + isLaunch: false, + workspaceTrustPreflight: { + strategyResults: [ + expect.objectContaining({ + status: 'blocked', + errorCode: 'workspace_trust_preflight_not_confirmed', + errorMessage, + rawTail: 'Unexpected Claude startup screen', + }), + ], + }, + }, + }); + expect(manifest.launchDiagnostics).toEqual([ + expect.objectContaining({ + code: 'workspace_trust_preflight', + severity: 'error', + detail: errorMessage, + }), + ]); + }); + + it('blocks launchTeam at workspace trust preflight and restores the prelaunch config backup', async () => { + forceWorkspaceTrustPreflightEnv(); + process.env.CLAUDE_CLI_PATH = await writeFakeClaudeCli(tempDir); + ClaudeBinaryResolver.clearCache(); + + const teamName = 'workspace-trust-launch-blocked-safe-e2e'; + const originalProjectPath = path.join(tempDir, 'original-project'); + const nextProjectPath = path.join(tempDir, 'next-project'); + await fs.mkdir(originalProjectPath, { recursive: true }); + await fs.mkdir(nextProjectPath, { recursive: true }); + const originalConfig = await writeAnthropicTeamConfig({ + teamName, + projectPath: originalProjectPath, + members: ['alice', 'bob'], + }); + + const errorMessage = `Claude workspace trust was not confirmed for ${nextProjectPath}`; + const { coordinator, execute } = createBlockedWorkspaceTrustCoordinator({ + errorMessage, + evidence: ['workspace trust preflight blocked launch'], + }); + const svc = new TeamProvisioningService(); + svc.setWorkspaceTrustCoordinator(coordinator); + const progressEvents: TeamProvisioningProgress[] = []; + + await expect( + svc.launchTeam( + { + teamName, + cwd: nextProjectPath, + providerId: 'anthropic', + model: 'sonnet', + skipPermissions: true, + }, + (progress) => progressEvents.push(progress) + ) + ).rejects.toThrow(errorMessage); + + expect(execute).toHaveBeenCalledTimes(1); + expect(progressEvents.at(-1)).toMatchObject({ + state: 'failed', + message: 'Workspace trust required', + error: errorMessage, + }); + await expect( + fs.readFile(path.join(getTeamsBasePath(), teamName, 'config.json'), 'utf8') + ).resolves.toBe(originalConfig); + + const manifest = await readLatestLaunchFailureManifest(teamName); + expect(manifest).toMatchObject({ + classification: { code: 'workspace_trust_required' }, + flags: { + isLaunch: true, + workspaceTrustPreflight: { + strategyResults: [ + expect.objectContaining({ + status: 'blocked', + errorMessage, + }), + ], + }, + }, + }); + expect(manifest.progress.launchDiagnostics).toEqual([ + expect.objectContaining({ + code: 'workspace_trust_preflight', + severity: 'error', + detail: errorMessage, + }), + ]); + }); + it('preserves mixed OpenCode per-member outcomes after a partial runtime adapter launch', async () => { const adapter = new FakeOpenCodeRuntimeAdapter('partial_failure', { alice: 'confirmed', @@ -17896,6 +18106,256 @@ async function writeOpenCodeTeamConfig(input: { ); } +async function writeAnthropicTeamConfig(input: { + teamName: string; + projectPath: string; + members: string[]; +}): Promise { + const teamDir = path.join(getTeamsBasePath(), input.teamName); + const config = { + name: input.teamName, + projectPath: input.projectPath, + color: 'blue', + members: [ + { + name: 'team-lead', + agentType: 'team-lead', + providerId: 'anthropic', + model: 'sonnet', + }, + ...input.members.map((name) => ({ + name, + role: 'Developer', + providerId: 'anthropic', + model: name === 'alice' ? 'haiku' : 'sonnet', + })), + ], + }; + const raw = `${JSON.stringify(config, null, 2)}\n`; + await fs.mkdir(teamDir, { recursive: true }); + await fs.writeFile(path.join(teamDir, 'config.json'), raw, 'utf8'); + return raw; +} + +async function writeJsonFile(filePath: string, value: unknown): Promise { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); +} + +async function writeFakeClaudeCli(rootDir: string): Promise { + const binDir = path.join(rootDir, 'fake-bin'); + const cliPath = path.join(binDir, process.platform === 'win32' ? 'claude.cmd' : 'claude'); + const script = `#!/usr/bin/env node +const args = process.argv.slice(2); +const providerIndex = args.lastIndexOf('--provider'); +const provider = providerIndex >= 0 ? args[providerIndex + 1] : 'anthropic'; + +function hasCommand(...parts) { + return parts.every((part) => args.includes(part)); +} + +function modelCatalog(providerId) { + const base = { + schemaVersion: 1, + providerId, + source: 'runtime', + status: 'ready', + fetchedAt: '2026-05-13T00:00:00.000Z', + staleAt: '2026-05-13T01:00:00.000Z', + diagnostics: { + configReadState: 'ready', + appServerState: 'healthy', + }, + }; + if (providerId === 'anthropic') { + return { + ...base, + defaultModelId: 'sonnet', + defaultLaunchModel: 'sonnet', + models: [ + { + id: 'sonnet', + launchModel: 'sonnet', + displayName: 'Sonnet', + supportedReasoningEfforts: ['low', 'medium', 'high'], + defaultReasoningEffort: 'medium', + supportsFastMode: false, + }, + { + id: 'haiku', + launchModel: 'haiku', + displayName: 'Haiku', + supportedReasoningEfforts: ['low', 'medium'], + defaultReasoningEffort: 'medium', + supportsFastMode: false, + }, + ], + }; + } + return { + ...base, + defaultModelId: 'gpt-5.5', + defaultLaunchModel: 'gpt-5.5', + models: [ + { + id: 'gpt-5.5', + launchModel: 'gpt-5.5', + displayName: 'GPT-5.5', + supportedReasoningEfforts: ['low', 'medium', 'high'], + defaultReasoningEffort: 'medium', + }, + ], + }; +} + +if (hasCommand('model', 'list')) { + const catalog = modelCatalog(provider); + console.log(JSON.stringify({ + schemaVersion: 1, + providers: { + [provider]: { + defaultModel: catalog.defaultLaunchModel, + models: catalog.models.map((model) => ({ id: model.launchModel, label: model.displayName })), + }, + }, + })); + process.exit(0); +} + +if (hasCommand('runtime', 'status')) { + const catalog = modelCatalog(provider); + console.log(JSON.stringify({ + providers: { + [provider]: { + providerId: provider, + displayName: provider, + supported: true, + authenticated: true, + authMethod: 'test', + verificationState: 'verified', + models: catalog.models.map((model) => model.launchModel), + modelCatalog: catalog, + runtimeCapabilities: { + modelCatalog: { dynamic: false, source: 'runtime' }, + reasoningEffort: { + supported: true, + values: ['low', 'medium', 'high'], + configPassthrough: true, + }, + fastMode: { + supported: true, + available: false, + reason: 'test runtime', + source: 'runtime', + }, + }, + canLoginFromUi: false, + capabilities: { + teamLaunch: true, + oneShot: true, + extensions: {}, + }, + }, + }, + })); + process.exit(0); +} + +console.log(JSON.stringify({ ok: true })); +`; + await fs.mkdir(binDir, { recursive: true }); + await fs.writeFile(cliPath, script, 'utf8'); + if (process.platform !== 'win32') { + await fs.chmod(cliPath, 0o755); + } + return cliPath; +} + +function createBlockedWorkspaceTrustCoordinator(input: { + errorMessage: string; + evidence?: string[]; + rawTail?: string; +}) { + const planArgsOnly = vi.fn( + async (_request: Parameters[0]) => ({ + launchArgPatches: [], + }) + ); + const planFull = vi.fn(async (request: Parameters[0]) => ({ + workspaces: request.workspaces, + launchArgPatches: [], + })); + const execute = vi.fn(async (_plan: WorkspaceTrustExecutionPlan) => ({ + id: 'claude-pty-workspace-trust', + provider: 'claude' as const, + status: 'blocked' as const, + workspaceIds: ['workspace-trust-1'], + errorCode: 'workspace_trust_preflight_not_confirmed', + errorMessage: input.errorMessage, + evidence: input.evidence ?? ['workspace trust was not confirmed'], + ...(input.rawTail ? { rawTail: input.rawTail } : {}), + })); + const coordinator: WorkspaceTrustCoordinator = { planArgsOnly, planFull, execute }; + + return { + coordinator, + planArgsOnly, + planFull, + execute, + }; +} + +async function readLatestLaunchFailureManifest(teamName: string): Promise> { + const latestPath = path.join( + getTeamsBasePath(), + teamName, + 'launch-failure-artifacts', + 'latest.json' + ); + await waitForCondition(async () => { + try { + await fs.access(latestPath); + return true; + } catch { + return false; + } + }); + const latest = JSON.parse(await fs.readFile(latestPath, 'utf8')) as { manifestPath?: string }; + expect(latest.manifestPath).toEqual(expect.any(String)); + return JSON.parse(await fs.readFile(latest.manifestPath!, 'utf8')) as Record; +} + +function snapshotWorkspaceTrustTestEnv(): Partial< + Record +> { + return Object.fromEntries( + WORKSPACE_TRUST_TEST_ENV_NAMES.map((name) => [name, process.env[name]]) + ) as Partial>; +} + +function restoreOptionalEnvValue(name: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[name]; + return; + } + process.env[name] = value; +} + +function restoreWorkspaceTrustTestEnv( + snapshot: Partial> +): void { + for (const name of WORKSPACE_TRUST_TEST_ENV_NAMES) { + restoreOptionalEnvValue(name, snapshot[name]); + } +} + +function forceWorkspaceTrustPreflightEnv(): void { + process.env.AGENT_TEAMS_WORKSPACE_TRUST_PREFLIGHT = '1'; + process.env.AGENT_TEAMS_WORKSPACE_TRUST_CLAUDE_PTY = '1'; + process.env.AGENT_TEAMS_WORKSPACE_TRUST_CODEX_SETTINGS = '1'; + process.env.AGENT_TEAMS_WORKSPACE_TRUST_RETRY = '0'; +} + async function writeOpenCodeMembersMeta( teamName: string, options: { diff --git a/test/main/services/team/TeamLaunchFailureArtifactPack.test.ts b/test/main/services/team/TeamLaunchFailureArtifactPack.test.ts index 4d5e253f..2d0572be 100644 --- a/test/main/services/team/TeamLaunchFailureArtifactPack.test.ts +++ b/test/main/services/team/TeamLaunchFailureArtifactPack.test.ts @@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { classifyLaunchFailureArtifact, extractLaunchBootstrapTransportBreadcrumb, + isWorkspaceTrustLaunchFailureText, readTeamLaunchFailureDiagnosticsBundle, redactLaunchFailureArtifactText, writeTeamLaunchFailureArtifactPack, @@ -210,6 +211,51 @@ describe('TeamLaunchFailureArtifactPack', () => { expect(classification.evidence.join('\n')).toContain('workspace trust is not accepted'); }); + it('classifies workspace trust preflight blocks separately', () => { + const classification = classifyLaunchFailureArtifact({ + teamName: 'artifact-team', + runId: 'run-workspace-trust-preflight', + reason: 'Claude workspace trust was not confirmed for /tmp/project', + launchDiagnostics: [ + { + id: 'workspace-trust:preflight', + severity: 'error', + code: 'workspace_trust_preflight', + label: 'Workspace trust preflight blocked launch', + detail: 'Claude workspace trust was not confirmed for /tmp/project', + observedAt: '2026-05-13T00:00:00.000Z', + }, + ], + }); + + expect(classification.code).toBe('workspace_trust_required'); + expect(classification.evidence.join('\n')).toContain('workspace trust was not confirmed'); + }); + + it('prioritizes workspace trust over auth and transport-looking fallback text', () => { + const classification = classifyLaunchFailureArtifact({ + teamName: 'artifact-team', + runId: 'run-workspace-trust-priority', + reason: + 'Token refresh failed after bootstrap_submit_rejected, but Claude workspace trust was not confirmed for /tmp/project', + progressTraceLines: [ + '401 Unauthorized', + 'workspace_trust_preflight_not_confirmed', + 'last transport stage: bootstrap_submit_rejected retryable=true', + ], + }); + + expect(classification.code).toBe('workspace_trust_required'); + expect(classification.confidence).toBeGreaterThan(0.9); + }); + + it('matches only explicit workspace trust failure text', () => { + expect( + isWorkspaceTrustLaunchFailureText('Claude workspace trust was not confirmed for /tmp/project') + ).toBe(true); + expect(isWorkspaceTrustLaunchFailureText('workspace trust preflight disabled')).toBe(false); + }); + it.each([ { name: 'stdin warning', diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index d40bf4ed..33b9c5ea 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -6,6 +6,7 @@ import * as os from 'os'; import * as path from 'path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { buildWorkspaceTrustPathCandidates } from '@features/workspace-trust/main'; const hoisted = vi.hoisted(() => ({ paths: { @@ -771,8 +772,7 @@ describe('TeamProvisioningService', () => { teamEventType: 'team_launched', teamName: 'late-all-joined-team', dedupeKey: 'team_launched:late-all-joined-team:run-late-all-joined', - body: - 'Team "late-all-joined-team" has been launched - all 2 teammates joined and are ready for tasks.', + body: 'Team "late-all-joined-team" has been launched - all 2 teammates joined and are ready for tasks.', }) ); @@ -2004,6 +2004,17 @@ describe('TeamProvisioningService', () => { }); describe('launch-state no-op persistence guard', () => { + it('does not clear persisted launch state for an expected run after tracking is gone', () => { + const svc = new TeamProvisioningService(); + + expect( + (svc as any).canClearPersistedLaunchStateForRun( + 'workspace-trust-stale-clear-team', + 'run-stale' + ) + ).toBe(false); + }); + it('does not rewrite launch-state or invalidate runtime cache for a recent semantic no-op', async () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-05-02T10:00:05.000Z')); @@ -8199,10 +8210,10 @@ describe('TeamProvisioningService', () => { })); await configureOpenCodeBobDeliveryService({ svc, sendMessageToMember }); - await expect( - svc.deliverOpenCodeMemberMessage('team-a', { - memberName: 'bob', - text: 'Work sync check for #task-1.', + await expect( + svc.deliverOpenCodeMemberMessage('team-a', { + memberName: 'bob', + text: 'Work sync check for #task-1.', messageId: 'msg-work-sync-report', replyRecipient: 'team-lead', actionMode: 'do', @@ -13261,7 +13272,9 @@ describe('TeamProvisioningService', () => { }); if (adapterLaunch.mock.calls.length === 1) { - throw new Error('OpenCode bridge failed: Bridge server runtime manifest high watermark is stale'); + throw new Error( + 'OpenCode bridge failed: Bridge server runtime manifest high watermark is stale' + ); } await writeCommittedOpenCodeSessionStore({ @@ -17609,6 +17622,479 @@ describe('TeamProvisioningService', () => { }); }); + it('includes legacy member provider fields when planning workspace trust providers', () => { + const svc = new TeamProvisioningService(); + + expect( + (svc as any).collectWorkspaceTrustProviders({ + leadProviderId: 'anthropic', + members: [{ name: 'alice', provider: 'codex' }], + }) + ).toEqual(['claude', 'codex']); + }); + + it('dedupes workspace trust providers across lead, member providerId, and legacy provider fields', () => { + const svc = new TeamProvisioningService(); + + expect( + (svc as any).collectWorkspaceTrustProviders({ + leadProviderId: 'codex', + members: [ + { name: 'alice', providerId: 'anthropic' }, + { name: 'bob', providerId: 'codex' }, + { name: 'cara', provider: 'gemini' }, + { name: 'drew', providerId: 'opencode' }, + ], + }) + ).toEqual(['claude', 'codex', 'gemini', 'opencode']); + }); + + it('degrades workspace trust planning failures without blocking launch preparation', async () => { + const svc = new TeamProvisioningService(); + const workspaces = buildWorkspaceTrustPathCandidates({ + cwd: '/tmp/workspace-trust-planning-fallback', + platform: 'posix', + }); + svc.setWorkspaceTrustCoordinator({ + planArgsOnly: vi.fn(async () => { + throw new Error('args planning crashed'); + }), + planFull: vi.fn(async () => { + throw new Error('full planning crashed'); + }), + execute: vi.fn(), + } as any); + + await expect( + (svc as any).planWorkspaceTrustArgsOnlySafely({ + providers: ['claude', 'codex'], + workspaces, + featureFlags: { + enabled: true, + claudePty: true, + codexArgs: true, + retry: false, + fileLock: false, + }, + }) + ).resolves.toEqual({ launchArgPatches: [] }); + + await expect( + (svc as any).planWorkspaceTrustFullSafely({ + providers: ['claude', 'codex'], + workspaces, + featureFlags: { + enabled: true, + claudePty: true, + codexArgs: true, + retry: false, + fileLock: false, + }, + }) + ).resolves.toEqual({ workspaces, launchArgPatches: [] }); + expect(vi.mocked(console.warn).mock.calls.map((call) => call.join(' '))).toEqual([ + expect.stringContaining( + 'Workspace trust args-only planning failed; continuing without trust arg patches' + ), + expect.stringContaining( + 'Workspace trust full planning failed; continuing without trust arg patches' + ), + ]); + vi.mocked(console.warn).mockClear(); + }); + + it('keeps launch moving with info diagnostics when workspace trust preflight succeeds', async () => { + const progressUpdates: any[] = []; + const run = createMemberSpawnRun({ + runId: 'run-workspace-trust-preflight-ok', + teamName: 'workspace-trust-preflight-ok-team', + expectedMembers: ['alice'], + }); + Object.assign(run, { + cancelRequested: false, + processKilled: false, + progress: { + runId: run.runId, + teamName: run.teamName, + state: 'validating', + message: 'Validating launch', + warnings: [], + startedAt: '2026-05-12T10:00:00.000Z', + updatedAt: '2026-05-12T10:00:00.000Z', + }, + onProgress: (progress: any) => { + progressUpdates.push(progress); + }, + }); + const execute = vi.fn(async () => ({ + id: 'claude-pty-workspace-trust', + provider: 'claude', + status: 'ok', + workspaceIds: ['workspace-trust-1'], + evidence: ['trusted project key'], + })); + const svc = new TeamProvisioningService(); + svc.setWorkspaceTrustCoordinator({ + planArgsOnly: vi.fn(), + planFull: vi.fn(), + execute, + } as any); + (svc as any).runs.set(run.runId, run); + (svc as any).provisioningRunByTeam.set(run.teamName, run.runId); + + await (svc as any).prepareWorkspaceTrustForDeterministicRun({ + mode: 'create', + run, + claudePath: '/usr/local/bin/claude', + shellEnv: {}, + stopAllGenerationAtStart: (svc as any).stopAllTeamsGeneration, + workspaceTrustPlan: { + launchArgPatches: [], + workspaces: buildWorkspaceTrustPathCandidates({ + cwd: '/tmp/workspace-trust-preflight-ok-team', + platform: 'posix', + }), + }, + featureFlags: { + enabled: true, + claudePty: true, + codexArgs: true, + retry: false, + fileLock: false, + }, + provisioningEnv: { + anthropicApiKeyHelper: null, + }, + }); + + expect(execute).toHaveBeenCalledTimes(1); + expect(run.workspaceTrustExecution).toMatchObject({ status: 'ok' }); + expect(run.progress.warnings).toEqual([]); + expect(progressUpdates.at(-1).launchDiagnostics).toEqual([ + expect.objectContaining({ + severity: 'info', + code: 'workspace_trust_preflight', + label: 'Workspace trust preflight completed', + detail: 'trusted project key', + }), + ]); + }); + + it('keeps launch alive with diagnostics when workspace trust preflight throws', async () => { + const progressUpdates: any[] = []; + const run = createMemberSpawnRun({ + runId: 'run-workspace-trust-preflight-throw', + teamName: 'workspace-trust-preflight-team', + expectedMembers: ['alice'], + }); + Object.assign(run, { + cancelRequested: false, + processKilled: false, + progress: { + runId: run.runId, + teamName: run.teamName, + state: 'validating', + message: 'Validating launch', + warnings: [], + startedAt: '2026-05-12T10:00:00.000Z', + updatedAt: '2026-05-12T10:00:00.000Z', + }, + onProgress: (progress: any) => { + progressUpdates.push(progress); + }, + }); + const svc = new TeamProvisioningService(); + svc.setWorkspaceTrustCoordinator({ + planArgsOnly: vi.fn(), + planFull: vi.fn(), + execute: vi.fn(async () => { + throw new Error('preflight adapter crashed'); + }), + } as any); + (svc as any).runs.set(run.runId, run); + (svc as any).provisioningRunByTeam.set(run.teamName, run.runId); + + await (svc as any).prepareWorkspaceTrustForDeterministicRun({ + mode: 'create', + run, + claudePath: '/usr/local/bin/claude', + shellEnv: { + CLAUDE_ENABLE_DETERMINISTIC_TEAM_BOOTSTRAP: '1', + CLAUDE_TEAM_ANTHROPIC_API_KEY_HELPER_SETTINGS_PATH: '/tmp/helper.json', + }, + stopAllGenerationAtStart: (svc as any).stopAllTeamsGeneration, + workspaceTrustPlan: { + launchArgPatches: [], + workspaces: buildWorkspaceTrustPathCandidates({ + cwd: '/tmp/workspace-trust-preflight-team', + platform: 'posix', + }), + }, + featureFlags: { + enabled: true, + claudePty: true, + codexArgs: true, + retry: false, + fileLock: false, + }, + provisioningEnv: { + anthropicApiKeyHelper: null, + }, + }); + + expect(run.workspaceTrustExecution).toMatchObject({ + status: 'soft_failed', + errorCode: 'workspace_trust_preflight_error', + errorMessage: 'preflight adapter crashed', + }); + expect(run.workspaceTrustDiagnostics).toMatchObject({ + attempt: 1, + strategyResults: [ + expect.objectContaining({ + status: 'soft_failed', + errorMessage: 'preflight adapter crashed', + }), + ], + }); + expect(run.progress.warnings).toContain('preflight adapter crashed'); + expect(progressUpdates.at(0)).toMatchObject({ + state: 'spawning', + message: 'Preparing workspace trust', + }); + expect(progressUpdates.at(-1).warnings).toContain('preflight adapter crashed'); + expect(progressUpdates.at(-1).launchDiagnostics).toEqual([ + expect.objectContaining({ + severity: 'warning', + code: 'workspace_trust_preflight', + label: 'Workspace trust preflight could not verify trust', + detail: 'preflight adapter crashed', + }), + ]); + }); + + it('blocks launch with structured workspace trust diagnostics when preflight is blocked', async () => { + const progressUpdates: any[] = []; + const run = createMemberSpawnRun({ + runId: 'run-workspace-trust-preflight-blocked', + teamName: 'workspace-trust-preflight-blocked-team', + expectedMembers: ['alice'], + }); + Object.assign(run, { + cancelRequested: false, + processKilled: false, + progress: { + runId: run.runId, + teamName: run.teamName, + state: 'validating', + message: 'Validating launch', + warnings: [], + startedAt: '2026-05-12T10:00:00.000Z', + updatedAt: '2026-05-12T10:00:00.000Z', + }, + onProgress: (progress: any) => { + progressUpdates.push(progress); + }, + }); + const svc = new TeamProvisioningService(); + svc.setWorkspaceTrustCoordinator({ + planArgsOnly: vi.fn(), + planFull: vi.fn(), + execute: vi.fn(async () => ({ + id: 'claude-pty-workspace-trust', + provider: 'claude', + status: 'blocked', + workspaceIds: ['workspace-trust-1'], + errorCode: 'workspace_trust_preflight_not_confirmed', + errorMessage: 'Claude workspace trust was not confirmed for /tmp/project', + evidence: ['claude workspace trust prompt'], + })), + } as any); + vi.spyOn(svc as any, 'cleanupRun').mockImplementation(() => {}); + (svc as any).runs.set(run.runId, run); + (svc as any).provisioningRunByTeam.set(run.teamName, run.runId); + + await expect( + (svc as any).prepareWorkspaceTrustForDeterministicRun({ + mode: 'create', + run, + claudePath: '/usr/local/bin/claude', + shellEnv: {}, + stopAllGenerationAtStart: (svc as any).stopAllTeamsGeneration, + workspaceTrustPlan: { + launchArgPatches: [], + workspaces: buildWorkspaceTrustPathCandidates({ + cwd: '/tmp/project', + platform: 'posix', + }), + }, + featureFlags: { + enabled: true, + claudePty: true, + codexArgs: true, + retry: false, + fileLock: false, + }, + provisioningEnv: { + anthropicApiKeyHelper: null, + }, + }) + ).rejects.toThrow('Claude workspace trust was not confirmed for /tmp/project'); + + expect(progressUpdates.at(-1)).toMatchObject({ + state: 'failed', + message: 'Workspace trust required', + error: 'Claude workspace trust was not confirmed for /tmp/project', + }); + expect(progressUpdates.at(-1).launchDiagnostics).toEqual([ + expect.objectContaining({ + severity: 'error', + code: 'workspace_trust_preflight', + label: 'Workspace trust preflight blocked launch', + detail: 'Claude workspace trust was not confirmed for /tmp/project', + }), + ]); + }); + + it('cancels launch before spawn when workspace trust preflight is cancelled', async () => { + const progressUpdates: any[] = []; + const run = createMemberSpawnRun({ + runId: 'run-workspace-trust-preflight-cancelled', + teamName: 'workspace-trust-preflight-cancelled-team', + expectedMembers: ['alice'], + }); + Object.assign(run, { + cancelRequested: false, + processKilled: false, + progress: { + runId: run.runId, + teamName: run.teamName, + state: 'validating', + message: 'Validating launch', + warnings: [], + startedAt: '2026-05-12T10:00:00.000Z', + updatedAt: '2026-05-12T10:00:00.000Z', + }, + onProgress: (progress: any) => { + progressUpdates.push(progress); + }, + }); + const svc = new TeamProvisioningService(); + svc.setWorkspaceTrustCoordinator({ + planArgsOnly: vi.fn(), + planFull: vi.fn(), + execute: vi.fn(async () => ({ + id: 'claude-pty-workspace-trust', + provider: 'claude', + status: 'cancelled', + workspaceIds: ['workspace-trust-1'], + errorCode: 'workspace_trust_lock_cancelled', + })), + } as any); + vi.spyOn(svc as any, 'cleanupRun').mockImplementation(() => {}); + (svc as any).runs.set(run.runId, run); + (svc as any).provisioningRunByTeam.set(run.teamName, run.runId); + + await expect( + (svc as any).prepareWorkspaceTrustForDeterministicRun({ + mode: 'create', + run, + claudePath: '/usr/local/bin/claude', + shellEnv: {}, + stopAllGenerationAtStart: (svc as any).stopAllTeamsGeneration, + workspaceTrustPlan: { + launchArgPatches: [], + workspaces: buildWorkspaceTrustPathCandidates({ + cwd: '/tmp/workspace-trust-preflight-cancelled-team', + platform: 'posix', + }), + }, + featureFlags: { + enabled: true, + claudePty: true, + codexArgs: true, + retry: false, + fileLock: false, + }, + provisioningEnv: { + anthropicApiKeyHelper: null, + }, + }) + ).rejects.toThrow('Team launch cancelled'); + + expect(run.cancelRequested).toBe(true); + expect(progressUpdates.at(-1)).toMatchObject({ + state: 'cancelled', + message: 'Team launch cancelled', + }); + expect(progressUpdates.at(-1).launchDiagnostics).toBeUndefined(); + }); + + it('does not execute workspace trust preflight when the feature is disabled', async () => { + const progressUpdates: any[] = []; + const run = createMemberSpawnRun({ + runId: 'run-workspace-trust-disabled', + teamName: 'workspace-trust-disabled-team', + expectedMembers: ['alice'], + }); + Object.assign(run, { + cancelRequested: false, + processKilled: false, + progress: { + runId: run.runId, + teamName: run.teamName, + state: 'validating', + message: 'Validating launch', + warnings: [], + startedAt: '2026-05-12T10:00:00.000Z', + updatedAt: '2026-05-12T10:00:00.000Z', + }, + onProgress: (progress: any) => { + progressUpdates.push(progress); + }, + }); + const execute = vi.fn(); + const svc = new TeamProvisioningService(); + svc.setWorkspaceTrustCoordinator({ + planArgsOnly: vi.fn(), + planFull: vi.fn(), + execute, + } as any); + + await (svc as any).prepareWorkspaceTrustForDeterministicRun({ + mode: 'create', + run, + claudePath: '/usr/local/bin/claude', + shellEnv: {}, + stopAllGenerationAtStart: (svc as any).stopAllTeamsGeneration, + workspaceTrustPlan: { + launchArgPatches: [], + workspaces: buildWorkspaceTrustPathCandidates({ + cwd: '/tmp/workspace-trust-disabled-team', + platform: 'posix', + }), + }, + featureFlags: { + enabled: false, + claudePty: false, + codexArgs: false, + retry: false, + fileLock: false, + }, + provisioningEnv: { + anthropicApiKeyHelper: null, + }, + }); + + expect(execute).not.toHaveBeenCalled(); + expect(progressUpdates).toEqual([]); + expect(run.workspaceTrustExecution).toBeUndefined(); + expect(run.workspaceTrustDiagnostics).toBeUndefined(); + expect(run.progress).toMatchObject({ + state: 'validating', + message: 'Validating launch', + }); + }); + it('clears stale failed_to_start state when live runtime metadata proves the teammate is alive', async () => { const svc = new TeamProvisioningService(); (svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn( diff --git a/test/renderer/components/team/ProvisioningProgressBlock.test.tsx b/test/renderer/components/team/ProvisioningProgressBlock.test.tsx index 44cb9da9..daeb3617 100644 --- a/test/renderer/components/team/ProvisioningProgressBlock.test.tsx +++ b/test/renderer/components/team/ProvisioningProgressBlock.test.tsx @@ -14,8 +14,15 @@ vi.mock('@renderer/api', () => ({ })); vi.mock('@renderer/components/ui/button', () => ({ - Button: ({ children, onClick }: { children: React.ReactNode; onClick?: () => void }) => - React.createElement('button', { type: 'button', onClick }, children), + Button: ({ + children, + variant: _variant, + size: _size, + ...props + }: React.ButtonHTMLAttributes & { + variant?: string; + size?: string; + }) => React.createElement('button', { type: 'button', ...props }, children), })); vi.mock('@renderer/components/chat/viewers/MarkdownViewer', () => ({ @@ -281,6 +288,40 @@ describe('ProvisioningProgressBlock', () => { }); }); + it('emphasizes the copy diagnostics CTA when launch has failed', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(ProvisioningProgressBlock, { + title: 'Workspace trust required', + message: 'Claude workspace trust was not confirmed', + tone: 'error', + messageSeverity: 'error', + currentStepIndex: -1, + loading: false, + defaultLiveOutputOpen: false, + }) + ); + await Promise.resolve(); + }); + + const button = host.querySelector('[aria-label="Copy diagnostics"]'); + expect(button).toBeTruthy(); + expect(button?.className).toContain('h-8'); + expect(button?.className).toContain('border-red-500/60'); + expect(button?.className).toContain('bg-red-500/15'); + expect(button?.className).toContain('animate-pulse'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('renders multi-line status messages and opens links externally', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); const host = document.createElement('div');