fix(workspace-trust): harden Claude preflight for live teams

This commit is contained in:
777genius 2026-05-20 02:41:20 +03:00
parent 3d0ee71d09
commit 3908b5eb25
4 changed files with 101 additions and 11 deletions

View file

@ -11,6 +11,12 @@ import type {
} from './ports'; } from './ports';
const WORKSPACE_TRUST_RAW_TAIL_LIMIT = 4096; const WORKSPACE_TRUST_RAW_TAIL_LIMIT = 4096;
const CLAUDE_WORKSPACE_TRUST_PREFLIGHT_TIMEOUT_MS = 60_000;
const CLAUDE_WORKSPACE_TRUST_CONFIRM_TIMEOUT_MS = 5_000;
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export interface ClaudePtyWorkspaceTrustStrategyInput { export interface ClaudePtyWorkspaceTrustStrategyInput {
claudePath: string; claudePath: string;
@ -45,6 +51,27 @@ function buildRawTail(snapshot: TerminalSnapshot | undefined): string | undefine
return normalized.slice(-WORKSPACE_TRUST_RAW_TAIL_LIMIT); return normalized.slice(-WORKSPACE_TRUST_RAW_TAIL_LIMIT);
} }
async function waitForTrustedState(input: {
stateProbe: ProviderStateProbe;
workspace: WorkspaceTrustWorkspace;
isCancelled(): boolean;
timeoutMs: number;
pollIntervalMs: number;
}): Promise<Awaited<ReturnType<ProviderStateProbe['readTrustState']>>> {
const pollIntervalMs = Math.max(1, input.pollIntervalMs);
const deadline = Date.now() + input.timeoutMs;
let last = await input.stateProbe.readTrustState(input.workspace);
while (last.status !== 'trusted' && !input.isCancelled()) {
const remainingMs = deadline - Date.now();
if (remainingMs <= 0) {
break;
}
await sleep(Math.min(pollIntervalMs, remainingMs));
last = await input.stateProbe.readTrustState(input.workspace);
}
return last;
}
function worseStatus( function worseStatus(
current: WorkspaceTrustDiagnosticStrategyResult['status'], current: WorkspaceTrustDiagnosticStrategyResult['status'],
next: WorkspaceTrustDiagnosticStrategyResult['status'] next: WorkspaceTrustDiagnosticStrategyResult['status']
@ -152,13 +179,22 @@ export class ClaudePtyWorkspaceTrustStrategy {
session: spawnResult.session, session: spawnResult.session,
detect: detectClaudeStartupState, detect: detectClaudeStartupState,
isCancelled: input.isCancelled, isCancelled: input.isCancelled,
timeoutMs: input.timeoutMs, timeoutMs: input.timeoutMs ?? CLAUDE_WORKSPACE_TRUST_PREFLIGHT_TIMEOUT_MS,
pollIntervalMs: input.pollIntervalMs, pollIntervalMs: input.pollIntervalMs,
afterDialogAction: async ({ ruleId }) => { afterDialogAction: async ({ ruleId }) => {
if (ruleId !== 'claude.workspace_trust') { if (ruleId !== 'claude.workspace_trust') {
return { action: 'continue' }; return { action: 'continue' };
} }
const after = await stateProbe.readTrustState(workspace); const after = await waitForTrustedState({
stateProbe,
workspace,
isCancelled: input.isCancelled,
timeoutMs: Math.min(
CLAUDE_WORKSPACE_TRUST_CONFIRM_TIMEOUT_MS,
input.timeoutMs ?? CLAUDE_WORKSPACE_TRUST_CONFIRM_TIMEOUT_MS
),
pollIntervalMs: input.pollIntervalMs ?? 100,
});
if (after.status === 'trusted') { if (after.status === 'trusted') {
evidence.push(...after.evidence); evidence.push(...after.evidence);
return { action: 'stop', reason: 'workspace_trust_persisted' }; return { action: 'stop', reason: 'workspace_trust_persisted' };
@ -218,7 +254,7 @@ export class ClaudePtyWorkspaceTrustStrategy {
workspaceIds, workspaceIds,
matchedRuleIds: [...new Set(matchedRuleIds)], matchedRuleIds: [...new Set(matchedRuleIds)],
actions, actions,
evidence, evidence: [...new Set(evidence)],
elapsedMs: Date.now() - startedAt, elapsedMs: Date.now() - startedAt,
errorCode, errorCode,
errorMessage, errorMessage,

View file

@ -1,3 +1,5 @@
import { createRequire } from 'node:module';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import type { import type {
@ -13,6 +15,7 @@ import type * as NodePty from 'node-pty';
const logger = createLogger('WorkspaceTrustNodePtyProcessAdapter'); const logger = createLogger('WorkspaceTrustNodePtyProcessAdapter');
const MAX_TRANSCRIPT_CHARS = 64 * 1024; const MAX_TRANSCRIPT_CHARS = 64 * 1024;
const requireNativeAddon = createRequire(import.meta.url);
type NodePtyModule = typeof NodePty; type NodePtyModule = typeof NodePty;
@ -23,8 +26,7 @@ function loadNodePty(): NodePtyModule | null {
return nodePty; return nodePty;
} }
try { try {
// eslint-disable-next-line @typescript-eslint/no-require-imports -- node-pty is optional native addon nodePty = requireNativeAddon('node-pty') as NodePtyModule;
nodePty = require('node-pty') as NodePtyModule;
} catch (error) { } catch (error) {
logger.warn(`node-pty unavailable for workspace trust preflight: ${String(error)}`); logger.warn(`node-pty unavailable for workspace trust preflight: ${String(error)}`);
nodePty = null; nodePty = null;

View file

@ -1,7 +1,6 @@
import { describe, expect, it } from 'vitest';
import { ClaudePtyWorkspaceTrustStrategy } from '@features/workspace-trust/core/application'; import { ClaudePtyWorkspaceTrustStrategy } from '@features/workspace-trust/core/application';
import { buildWorkspaceTrustPathCandidates } from '@features/workspace-trust/core/domain'; import { buildWorkspaceTrustPathCandidates } from '@features/workspace-trust/core/domain';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { import type {
ProviderStateProbe, ProviderStateProbe,
@ -83,6 +82,10 @@ function workspace(cwd = '/tmp/project') {
} }
describe('ClaudePtyWorkspaceTrustStrategy', () => { describe('ClaudePtyWorkspaceTrustStrategy', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('skips PTY when the state probe already reports trusted', async () => { it('skips PTY when the state probe already reports trusted', async () => {
const pty = new FakePtyProcess(); const pty = new FakePtyProcess();
const result = await new ClaudePtyWorkspaceTrustStrategy().execute({ const result = await new ClaudePtyWorkspaceTrustStrategy().execute({
@ -172,15 +175,17 @@ describe('ClaudePtyWorkspaceTrustStrategy', () => {
it('accepts the trust dialog, verifies persisted trust, kills PTY, and cleans temp MCP config', async () => { it('accepts the trust dialog, verifies persisted trust, kills PTY, and cleans temp MCP config', async () => {
const pty = new FakePtyProcess(); const pty = new FakePtyProcess();
const tempStore = new FakeTempStore(); const tempStore = new FakeTempStore();
const stateProbe = new FakeStateProbe([
{ status: 'untrusted' },
{ status: 'untrusted' },
{ status: 'trusted', evidence: ['trusted project key: /tmp/project'] },
]);
const result = await new ClaudePtyWorkspaceTrustStrategy().execute({ const result = await new ClaudePtyWorkspaceTrustStrategy().execute({
claudePath: '/usr/local/bin/claude', claudePath: '/usr/local/bin/claude',
workspaces: [workspace()], workspaces: [workspace()],
env: { HOME: '/Users/tester', PATH: '/usr/local/bin', OPTIONAL_EMPTY: undefined }, env: { HOME: '/Users/tester', PATH: '/usr/local/bin', OPTIONAL_EMPTY: undefined },
ptyProcess: pty, ptyProcess: pty,
stateProbe: new FakeStateProbe([ stateProbe,
{ status: 'untrusted' },
{ status: 'trusted', evidence: ['trusted project key: /tmp/project'] },
]),
tempEmptyMcpConfigStore: tempStore, tempEmptyMcpConfigStore: tempStore,
isCancelled: () => false, isCancelled: () => false,
timeoutMs: 100, timeoutMs: 100,
@ -190,6 +195,7 @@ describe('ClaudePtyWorkspaceTrustStrategy', () => {
expect(result.status).toBe('ok'); expect(result.status).toBe('ok');
expect(result.matchedRuleIds).toEqual(['claude.workspace_trust']); expect(result.matchedRuleIds).toEqual(['claude.workspace_trust']);
expect(result.actions).toEqual(['claude.workspace_trust:enter']); expect(result.actions).toEqual(['claude.workspace_trust:enter']);
expect(result.evidence).toEqual(['trusted project key: /tmp/project']);
expect(pty.spawnInputs[0]).toMatchObject({ expect(pty.spawnInputs[0]).toMatchObject({
command: '/usr/local/bin/claude', command: '/usr/local/bin/claude',
cwd: '/tmp/project', cwd: '/tmp/project',
@ -203,6 +209,33 @@ describe('ClaudePtyWorkspaceTrustStrategy', () => {
expect(pty.session?.actions.map((action) => action.id)).toEqual(['enter']); expect(pty.session?.actions.map((action) => action.id)).toEqual(['enter']);
expect(pty.session?.killed).toBe(true); expect(pty.session?.killed).toBe(true);
expect(tempStore.cleaned).toBe(true); expect(tempStore.cleaned).toBe(true);
expect(stateProbe.calls).toBe(4);
});
it('keeps the default Claude preflight alive long enough for slow startup trust prompts', async () => {
const pty = new FakePtyProcess();
const session = new FakeSession(['Starting Claude...', 'Quick safety check\nYes, I trust this folder']);
pty.spawnResult = { ok: true, session };
const nowValues = [0, 0, 0, 0, 46_000, 46_000, 46_000];
vi.spyOn(Date, 'now').mockImplementation(() => nowValues.shift() ?? 46_000);
const result = await new ClaudePtyWorkspaceTrustStrategy().execute({
claudePath: '/usr/local/bin/claude',
workspaces: [workspace()],
env: { HOME: '/Users/tester' },
ptyProcess: pty,
stateProbe: new FakeStateProbe([
{ status: 'untrusted' },
{ status: 'trusted', evidence: ['trusted project key: /tmp/project'] },
]),
tempEmptyMcpConfigStore: new FakeTempStore(),
isCancelled: () => false,
pollIntervalMs: 1,
});
expect(result.status).toBe('ok');
expect(result.matchedRuleIds).toEqual(['claude.workspace_trust']);
expect(session.actions.map((action) => action.id)).toEqual(['enter']);
}); });
it('soft-fails when node-pty is unavailable instead of throwing', async () => { it('soft-fails when node-pty is unavailable instead of throwing', async () => {

View file

@ -5,10 +5,14 @@ import * as path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createWorkspaceTrustCoordinator } from '../../../../src/features/workspace-trust/main';
import { TeamDataService } from '../../../../src/main/services/team/TeamDataService'; import { TeamDataService } from '../../../../src/main/services/team/TeamDataService';
import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService'; import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService';
import { TeamTaskReader } from '../../../../src/main/services/team/TeamTaskReader'; import { TeamTaskReader } from '../../../../src/main/services/team/TeamTaskReader';
import { import {
getAutoDetectedClaudeBasePath,
getClaudeBasePath,
getHomeDir,
getTasksBasePath, getTasksBasePath,
getTeamsBasePath, getTeamsBasePath,
setClaudeBasePathOverride, setClaudeBasePathOverride,
@ -236,6 +240,7 @@ async function runProviderStressScenario(
throw error; throw error;
} }
const svc = harness?.svc ?? new TeamProvisioningService(); const svc = harness?.svc ?? new TeamProvisioningService();
configureWorkspaceTrustCoordinator(svc);
const active: ActiveScenario = { scenario, teamName, svc, harness, codexCleanup, failed: false }; const active: ActiveScenario = { scenario, teamName, svc, harness, codexCleanup, failed: false };
activeScenarios.push(active); activeScenarios.push(active);
@ -296,6 +301,20 @@ async function runProviderStressScenario(
} }
} }
function configureWorkspaceTrustCoordinator(svc: TeamProvisioningService): void {
svc.setWorkspaceTrustCoordinator(
createWorkspaceTrustCoordinator({
claudeConfigDir: () => getClaudeBasePath(),
globalConfigFilePath: () => {
const claudeBasePath = getClaudeBasePath();
return claudeBasePath !== getAutoDetectedClaudeBasePath()
? path.join(claudeBasePath, '.claude.json')
: path.join(getHomeDir(), '.claude.json');
},
})
);
}
async function runRestartStressChecks( async function runRestartStressChecks(
active: ActiveScenario, active: ActiveScenario,
expectedMembers: string[], expectedMembers: string[],