feat: add workspace trust preflight
This commit is contained in:
parent
3f3569e1ae
commit
29ea1ae724
56 changed files with 10267 additions and 87 deletions
237
docs/team-management/tmux-vs-process-runtime-rationale.md
Normal file
237
docs/team-management/tmux-vs-process-runtime-rationale.md
Normal 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.
|
||||
4227
docs/team-management/workspace-trust-host-preflight-plan.md
Normal file
4227
docs/team-management/workspace-trust-host-preflight-plan.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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' };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
]
|
||||
|
|
|
|||
1
src/features/workspace-trust/contracts/index.ts
Normal file
1
src/features/workspace-trust/contracts/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export type * from '../core/domain/WorkspaceTrustTypes';
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
142
src/features/workspace-trust/core/application/PtyDialogEngine.ts
Normal file
142
src/features/workspace-trust/core/application/PtyDialogEngine.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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' };
|
||||
}
|
||||
|
|
@ -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],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
7
src/features/workspace-trust/core/application/index.ts
Normal file
7
src/features/workspace-trust/core/application/index.ts
Normal 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';
|
||||
54
src/features/workspace-trust/core/application/ports.ts
Normal file
54
src/features/workspace-trust/core/application/ports.ts
Normal 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>;
|
||||
}
|
||||
|
|
@ -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) : [];
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
253
src/features/workspace-trust/core/domain/WorkspaceTrustPath.ts
Normal file
253
src/features/workspace-trust/core/domain/WorkspaceTrustPath.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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>;
|
||||
};
|
||||
5
src/features/workspace-trust/core/domain/index.ts
Normal file
5
src/features/workspace-trust/core/domain/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export * from './CodexWorkspaceTrustSettings';
|
||||
export * from './WorkspaceTrustArgPatchApplier';
|
||||
export * from './WorkspaceTrustDiagnosticsBudget';
|
||||
export * from './WorkspaceTrustPath';
|
||||
export type * from './WorkspaceTrustTypes';
|
||||
1
src/features/workspace-trust/index.ts
Normal file
1
src/features/workspace-trust/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './contracts';
|
||||
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
})
|
||||
);
|
||||
}
|
||||
29
src/features/workspace-trust/main/index.ts
Normal file
29
src/features/workspace-trust/main/index.ts
Normal 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';
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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> = {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"');
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
167
test/features/workspace-trust/core/PtyDialogEngine.test.ts
Normal file
167
test/features/workspace-trust/core/PtyDialogEngine.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
121
test/features/workspace-trust/core/WorkspaceTrustPath.test.ts
Normal file
121
test/features/workspace-trust/core/WorkspaceTrustPath.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
53
test/features/workspace-trust/main/ClaudeStateProbe.test.ts
Normal file
53
test/features/workspace-trust/main/ClaudeStateProbe.test.ts
Normal 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'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in a new issue