feat: add workspace trust preflight

This commit is contained in:
777genius 2026-05-13 17:56:00 +03:00
parent 3f3569e1ae
commit 29ea1ae724
56 changed files with 10267 additions and 87 deletions

View file

@ -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:<pid>` 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.

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

@ -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.',
]

View file

@ -0,0 +1 @@
export type * from '../core/domain/WorkspaceTrustTypes';

View file

@ -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<ClaudePreflightCommandCapabilities>;
}): ClaudePreflightCommandResult {
const capabilities = {
...DEFAULT_CLAUDE_PREFLIGHT_COMMAND_CAPABILITIES,
...(input.capabilities ?? {}),
};
const requiredProtectedFlags: Array<keyof ClaudePreflightCommandCapabilities> = [
'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 };
}

View file

@ -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<string, string | undefined>;
ptyProcess?: PtyProcessPort;
stateProbe?: ProviderStateProbe;
tempEmptyMcpConfigStore?: TempEmptyMcpConfigStore;
isCancelled(): boolean;
timeoutMs?: number;
pollIntervalMs?: number;
};
function toPtyEnv(env: Record<string, string | undefined>): Record<string, string> {
const output: Record<string, string> = {};
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<WorkspaceTrustDiagnosticStrategyResult['status'], number> = {
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<WorkspaceTrustDiagnosticStrategyResult> {
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<ReturnType<TempEmptyMcpConfigStore['create']>> | 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,
};
}
}

View file

@ -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<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export async function runPtyDialogEngine(
input: PtyDialogEngineInput
): Promise<PtyDialogEngineResult> {
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<string>();
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 };
}

View file

@ -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<string, PtyKeyAction>;
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' };
}

View file

@ -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<string, string | undefined>;
featureFlags: WorkspaceTrustFeatureFlags;
isCancelled(): boolean;
};
export type WorkspaceTrustExecutionResult = Awaited<
ReturnType<ClaudePtyWorkspaceTrustStrategy['execute']>
>;
export interface WorkspaceTrustCoordinator {
planArgsOnly(
request: WorkspaceTrustArgsOnlyPlanRequest
): Promise<WorkspaceTrustArgsOnlyPlanResult>;
planFull(request: WorkspaceTrustFullPlanRequest): Promise<WorkspaceTrustFullPlanResult>;
execute(plan: WorkspaceTrustExecutionPlan): Promise<WorkspaceTrustExecutionResult>;
}
const DEFAULT_CODEX_TARGET_SURFACES: WorkspaceTrustLaunchArgTargetSurface[] = [
'primary_provider_args',
'cross_provider_member_args',
'provider_facts_probe',
'default_model_probe',
];
function providerSet(providers: WorkspaceTrustProvider[]): Set<WorkspaceTrustProvider> {
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<WorkspaceTrustArgsOnlyPlanResult> {
return {
launchArgPatches: buildCodexPatches(request),
};
}
async planFull(request: WorkspaceTrustFullPlanRequest): Promise<WorkspaceTrustFullPlanResult> {
return {
workspaces: request.workspaces,
launchArgPatches: buildCodexPatches(request),
};
}
async execute(plan: WorkspaceTrustExecutionPlan): Promise<WorkspaceTrustExecutionResult> {
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],
};
}
}
}

View file

@ -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<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function waitForLockTurn(
previous: Promise<void>,
lockKey: string,
options: WorkspaceTrustLockOptions
): Promise<void> {
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<string, Promise<void>>();
async withWorkspaceLock<T>(
lockKey: string,
options: WorkspaceTrustLockOptions,
fn: () => Promise<T>
): Promise<T> {
const previous = this.tails.get(lockKey) ?? Promise.resolve();
let release!: () => void;
const current = new Promise<void>((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<T>(
lockKeys: string[],
options: WorkspaceTrustLockOptions,
fn: () => Promise<T>
): Promise<T> {
const uniqueKeys = [...new Set(lockKeys)].sort();
const acquire = (index: number): Promise<T> => {
const lockKey = uniqueKeys[index];
if (!lockKey) {
return fn();
}
return this.withWorkspaceLock(lockKey, options, () => acquire(index + 1));
};
return acquire(0);
}
}

View file

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

View file

@ -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<string, string>;
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<TerminalSnapshot | null>;
writeAction(action: PtyKeyAction): Promise<void>;
kill(): Promise<void>;
}
export interface PtyProcessPort {
spawn(input: PtySpawnInput): Promise<PtySpawnResult>;
}
export type ProviderTrustState =
| { status: 'trusted'; evidence: string[] }
| { status: 'untrusted'; evidence?: string[] }
| { status: 'unknown'; evidence?: string[]; errorMessage?: string };
export interface ProviderStateProbe {
readTrustState(workspace: WorkspaceTrustWorkspace): Promise<ProviderTrustState>;
}
export type TempEmptyMcpConfigHandle = {
path: string;
cleanup(): Promise<void>;
};
export interface TempEmptyMcpConfigStore {
create(): Promise<TempEmptyMcpConfigHandle>;
}

View file

@ -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<string>();
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<string>();
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<string, unknown>)[CODEX_WORKSPACE_TRUST_SETTINGS_ROOT];
if (typeof codex !== 'object' || codex === null || Array.isArray(codex)) {
return [];
}
const workspaceTrust = (codex as Record<string, unknown>)[CODEX_WORKSPACE_TRUST_SETTINGS_KEY];
if (
typeof workspaceTrust !== 'object' ||
workspaceTrust === null ||
Array.isArray(workspaceTrust)
) {
return [];
}
const overrides = (workspaceTrust as Record<string, unknown>)[
CODEX_WORKSPACE_TRUST_CONFIG_OVERRIDES_KEY
];
return Array.isArray(overrides) ? normalizeCodexWorkspaceTrustConfigOverrides(overrides) : [];
}

View file

@ -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<string, unknown> | null {
try {
const parsed = JSON.parse(value) as unknown;
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: null;
} catch {
return null;
}
}
function collectSettingsObjectsFromArgs(args: string[]): Record<string, unknown>[] {
const settings: Record<string, unknown>[] = [];
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<string>();
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,
};
}

View file

@ -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<WorkspaceTrustDiagnosticsBudgetLimits> = {}
): WorkspaceTrustDiagnosticsManifest {
const effectiveLimits = {
...DEFAULT_WORKSPACE_TRUST_DIAGNOSTICS_BUDGET,
...limits,
};
const omittedCounts: Record<string, number> = { ...(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,
};
}

View file

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

View file

@ -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<string, number>;
};

View file

@ -0,0 +1,5 @@
export * from './CodexWorkspaceTrustSettings';
export * from './WorkspaceTrustArgPatchApplier';
export * from './WorkspaceTrustDiagnosticsBudget';
export * from './WorkspaceTrustPath';
export type * from './WorkspaceTrustTypes';

View file

@ -0,0 +1 @@
export * from './contracts';

View file

@ -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<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function isRecord(value: unknown): value is Record<string, unknown> {
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<ProviderTrustState> {
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<string>();
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];
}
}

View file

@ -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<TerminalSnapshot | null> {
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<void> {
this.pty.write(action.sequence);
}
async kill(): Promise<void> {
try {
this.pty.kill();
} catch {
/* already exited */
}
}
}
export class NodePtyProcessAdapter implements PtyProcessPort {
async spawn(input: PtySpawnInput): Promise<PtySpawnResult> {
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),
};
}
}
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,74 @@
import { createLogger } from '@shared/utils/logger';
import type { WorkspaceTrustFeatureFlags } from '../../core/domain';
const logger = createLogger('WorkspaceTrustFeatureFlags');
const warnedMalformedFlags = new Set<string>();
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,
};
}

View file

@ -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<string, string | undefined>
): Record<string, string | undefined> {
const output: Record<string, string | undefined> = { ...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;
}

View file

@ -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<void> {
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<void> {
},
],
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: {

View file

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

View file

@ -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<string>();
private runtimeAdapterRegistry: TeamRuntimeAdapterRegistry | null = null;
private controlApiBaseUrlResolver: (() => Promise<string | null>) | null = null;
private workspaceTrustCoordinator: WorkspaceTrustCoordinator | null = null;
private runtimeTurnSettledHookSettingsProvider:
| ((input: { provider: RuntimeTurnSettledProvider }) => Promise<Record<string, unknown> | 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<WorkspaceTrustProvider>(['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<string | null> {
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<WorkspaceTrustWorkspace[]> {
const homeDir = getHomeDir();
const candidates: WorkspaceTrustWorkspace[] = [];
const gitRootCache = new Map<string, string | null>();
const addPath = async (
cwd: string,
source: WorkspaceTrustWorkspace['source'],
memberId?: string
): Promise<void> => {
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<string>();
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<WorkspaceTrustArgsOnlyPlanResult> {
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<WorkspaceTrustFullPlanResult | null> {
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<string[]> {
@ -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<void> {
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<never> {
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<never> {
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<TeamRuntimeSettingsJson | null> {
@ -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<TeamCreateRequest['members']> {
const envByProvider = new Map<TeamProviderId, Promise<ProvisioningEnvResolution>>();
const defaultModelByProvider = new Map<TeamProviderId, Promise<string>>();
@ -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<TeamProviderId, string[]>([
[resolvedProviderId, providerArgs],
const providerArgsByProvider = new Map<TeamProviderId, string[]>();
for (const [providerId, args] of new Map<TeamProviderId, string[]>([
[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<TeamProviderId, string[]>([
[resolvedProviderId, providerArgs],
const providerArgsByProvider = new Map<TeamProviderId, string[]>();
for (const [providerId, args] of new Map<TeamProviderId, string[]>([
[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:

View file

@ -574,14 +574,24 @@ export const ProvisioningProgressBlock = ({
</button>
<Button
type="button"
variant="ghost"
variant={isError ? 'outline' : 'ghost'}
size="sm"
className="h-6 shrink-0 gap-1 px-2 text-[11px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
className={cn(
'shrink-0 gap-1',
isError
? 'h-8 animate-pulse border-red-500/60 bg-red-500/15 px-3 text-xs font-medium text-[var(--step-error-text)] shadow-[0_0_0_1px_rgba(248,113,113,0.25)] hover:bg-red-500/20 hover:text-red-100'
: 'h-6 px-2 text-[11px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]',
isError && diagnosticsCopied && 'animate-none'
)}
title={diagnosticsCopied ? 'Diagnostics copied' : 'Copy diagnostics'}
aria-label={diagnosticsCopied ? 'Diagnostics copied' : 'Copy diagnostics'}
onClick={() => void copyDiagnostics()}
>
{diagnosticsCopied ? <Check size={12} /> : <ClipboardList size={12} />}
{diagnosticsCopied ? (
<Check size={isError ? 14 : 12} />
) : (
<ClipboardList size={isError ? 14 : 12} />
)}
<span>{diagnosticsCopied ? 'Copied' : 'Copy diagnostics'}</span>
</Button>
</div>

View file

@ -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<AppState, [], [], TeamSlice> = (set,
if (isDuplicateProgress && currentRunId === progress.runId) {
return;
}
if (
existingProgress &&
currentRunId === progress.runId &&
shouldIgnoreProvisioningProgressRegression(existingProgress.state, progress.state)
) {
return;
}
set((state) => {
const nextRuns: Record<string, TeamProvisioningProgress> = {

View file

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

View file

@ -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> = {}
): 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"');
});
});

View file

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

View file

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

View file

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

View file

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

View file

@ -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<TerminalSnapshot | null> {
return {
text: this.snapshots.shift() ?? '',
capturedAtMs: Date.now(),
};
}
async writeAction(action: PtyKeyAction): Promise<void> {
this.actions.push(action);
}
async kill(): Promise<void> {
this.killed = true;
}
}
class FakePtyProcess implements PtyProcessPort {
readonly spawnInputs: PtySpawnInput[] = [];
session: FakeSession | null = null;
spawnResult: PtySpawnResult | null = null;
async spawn(input: PtySpawnInput): Promise<PtySpawnResult> {
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<ProviderTrustState> {
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<TempEmptyMcpConfigHandle> {
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);
});
});

View file

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

View file

@ -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<TerminalSnapshot | null> {
const text = this.snapshots.shift() ?? this.snapshots.at(-1) ?? '';
return { text, capturedAtMs: Date.now() };
}
async writeAction(action: PtyKeyAction): Promise<void> {
this.actions.push(action);
}
async kill(): Promise<void> {
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([]);
});
});

View file

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

View file

@ -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<string, unknown>[] {
const output: Record<string, unknown>[] = [];
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<string, unknown>);
}
}
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]);
});
});

View file

@ -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<void> {
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<WorkspaceTrustDiagnosticStrategyResult> {
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<WorkspaceTrustDiagnosticStrategyResult> {
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<void>((resolve) => {
enteredFirst = resolve;
});
const firstReleased = new Promise<void>((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<void>((resolve) => {
enteredFirst = resolve;
});
const firstReleased = new Promise<void>((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;
});
});

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<Record<WorkspaceTrustTestEnvName, string | undefined>>;
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<string> {
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<void> {
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<string> {
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<WorkspaceTrustCoordinator['planArgsOnly']>[0]) => ({
launchArgPatches: [],
})
);
const planFull = vi.fn(async (request: Parameters<WorkspaceTrustCoordinator['planFull']>[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<Record<string, any>> {
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<string, any>;
}
function snapshotWorkspaceTrustTestEnv(): Partial<
Record<WorkspaceTrustTestEnvName, string | undefined>
> {
return Object.fromEntries(
WORKSPACE_TRUST_TEST_ENV_NAMES.map((name) => [name, process.env[name]])
) as Partial<Record<WorkspaceTrustTestEnvName, string | undefined>>;
}
function restoreOptionalEnvValue(name: string, value: string | undefined): void {
if (value === undefined) {
delete process.env[name];
return;
}
process.env[name] = value;
}
function restoreWorkspaceTrustTestEnv(
snapshot: Partial<Record<WorkspaceTrustTestEnvName, string | undefined>>
): 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: {

View file

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

View file

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

View file

@ -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<HTMLButtonElement> & {
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');