feat: share preflight authenticated session across agents (#345)

* feat(auth): reuse preflight's authenticated session across agents

* fix(preflight): verify saved auth state parses and has cookies or origins

* fix(prompts): strip shared-session block when no auth is configured

* fix(shannon): store shared auth state in the per-session audit dir

* fix(prompts): write stub auth-state in pipeline-testing preflight

* fix(preflight): clear stale auth-state.json before validate-authentication

* fix(preflight): drop auth-state.json on workflow completion

* docs(claude): refresh auth-state.json description for new layout and cleanup

* refactor(prompts): drop unused PLAYWRIGHT_SESSION resolve in login instructions

* style(prompts): collapse verifySavedAuthState signature per biome

* refactor(prompts): require AUTH_STATE_FILE on authenticated runs

* style(prompts): trim numbered-step comments back to step headers
This commit is contained in:
ezl-keygraph
2026-05-28 03:23:09 +05:30
committed by GitHub
parent 8f5d639f0d
commit 7813baf16a
19 changed files with 148 additions and 8 deletions
+1 -1
View File
@@ -28,7 +28,7 @@ const sessionMutex = new SessionMutex();
* AuditSession - Main audit system facade
*/
export class AuditSession {
private sessionMetadata: SessionMetadata;
readonly sessionMetadata: SessionMetadata;
private sessionId: string;
private metricsTracker: MetricsTracker;
private workflowLogger: WorkflowLogger;
+8
View File
@@ -74,6 +74,14 @@ export function generateSessionJsonPath(sessionMetadata: SessionMetadata): strin
return path.join(auditPath, 'session.json');
}
/**
* Path to the shared authenticated browser session saved by the preflight
* validator and consumed by downstream agents via `_shared-session.txt`.
*/
export function authStateFile(sessionMetadata: SessionMetadata): string {
return path.join(generateAuditPath(sessionMetadata), 'auth-state.json');
}
/**
* Generate path to workflow.log file
*/
+2 -1
View File
@@ -25,6 +25,7 @@ import { fs, path } from 'zx';
import { type ClaudePromptResult, runClaudePrompt, validateAgentOutput } from '../ai/claude-executor.js';
import { getOutputFormat, getQueueFilename } from '../ai/queue-schemas.js';
import type { AuditSession } from '../audit/index.js';
import { authStateFile } from '../audit/utils.js';
import { AGENTS } from '../session-manager.js';
import type { ActivityLogger } from '../types/activity-logger.js';
import type { AgentName } from '../types/agents.js';
@@ -122,7 +123,7 @@ export class AgentExecutionService {
try {
prompt = await loadPrompt(
promptTemplate,
{ webUrl, repoPath },
{ webUrl, repoPath, AUTH_STATE_FILE: authStateFile(auditSession.sessionMetadata) },
distributedConfig,
pipelineTestingMode,
logger,
@@ -118,6 +118,7 @@ function renderReportFilterRules(report: ReportConfig | undefined): string {
interface PromptVariables {
webUrl: string;
repoPath: string;
AUTH_STATE_FILE: string;
PLAYWRIGHT_SESSION?: string;
}
@@ -321,6 +322,12 @@ async function interpolateVariables(
result = result.replace(/<rules_of_engagement>[\s\S]*?<\/rules_of_engagement>\s*/g, '');
}
if (!config?.authentication) {
result = result.replace(/<shared_authenticated_session>[\s\S]*?<\/shared_authenticated_session>\s*/g, '');
} else {
result = result.replace(/{{AUTH_STATE_FILE}}/g, variables.AUTH_STATE_FILE);
}
if (config?.authentication?.login_flow) {
const loginInstructions = await buildLoginInstructions(config.authentication, logger, promptsBaseDir);
result = result.replace(/{{LOGIN_INSTRUCTIONS}}/g, loginInstructions);
@@ -12,10 +12,12 @@
* pipeline burns hours on broken auth.
*/
import { readFile, rm } from 'node:fs/promises';
import type { JsonSchemaOutputFormat } from '@anthropic-ai/claude-agent-sdk';
import { z } from 'zod';
import { runClaudePrompt } from '../ai/claude-executor.js';
import type { AuditSession } from '../audit/index.js';
import { authStateFile } from '../audit/utils.js';
import type { ActivityLogger } from '../types/activity-logger.js';
import type { AgentEndResult } from '../types/audit.js';
import type { DistributedConfig, ProviderConfig } from '../types/config.js';
@@ -93,9 +95,12 @@ export async function validateAuthentication(input: ValidateAuthInput): Promise<
loginType: authentication.login_type,
});
const stateFile = authStateFile(auditSession.sessionMetadata);
await rm(stateFile, { force: true });
const prompt = await loadPrompt(
AGENT_NAME,
{ webUrl, repoPath },
{ webUrl, repoPath, AUTH_STATE_FILE: stateFile },
distributedConfig,
pipelineTestingMode ?? false,
logger,
@@ -120,7 +125,14 @@ export async function validateAuthentication(input: ValidateAuthInput): Promise<
providerConfig,
);
const classification = classifyResult(result, authentication);
let classification = classifyResult(result, authentication);
if (classification.ok) {
const sessionCheck = await verifySavedAuthState(stateFile, logger);
if (!sessionCheck.ok) {
classification = sessionCheck;
}
}
const endResult: AgentEndResult = {
attemptNumber,
@@ -135,6 +147,62 @@ export async function validateAuthentication(input: ValidateAuthInput): Promise<
return classification;
}
async function verifySavedAuthState(stateFile: string, logger: ActivityLogger): Promise<Result<void, PentestError>> {
let contents: string;
try {
contents = await readFile(stateFile, 'utf8');
} catch {
return err(
new PentestError(
`Preflight reported login success but did not save the authenticated session to ${stateFile}.`,
'validation',
true,
{ stateFile },
ErrorCode.AGENT_EXECUTION_FAILED,
),
);
}
let parsed: unknown;
try {
parsed = JSON.parse(contents);
} catch (parseErr) {
const detail = parseErr instanceof Error ? parseErr.message : String(parseErr);
return err(
new PentestError(
`Preflight saved an authenticated session to ${stateFile}, but the file is not valid JSON: ${detail}`,
'validation',
true,
{ stateFile, parseError: detail },
ErrorCode.AGENT_EXECUTION_FAILED,
),
);
}
const cookieCount = countStorageEntries(parsed, 'cookies');
const originCount = countStorageEntries(parsed, 'origins');
if (cookieCount === 0 && originCount === 0) {
return err(
new PentestError(
`Preflight saved an authenticated session to ${stateFile}, but it contains no cookies or origins — the browser was not actually logged in.`,
'validation',
true,
{ stateFile, cookieCount, originCount },
ErrorCode.AGENT_EXECUTION_FAILED,
),
);
}
logger.info('Preflight authenticated session saved', { stateFile, cookieCount, originCount });
return ok(undefined);
}
function countStorageEntries(parsed: unknown, key: 'cookies' | 'origins'): number {
if (typeof parsed !== 'object' || parsed === null) return 0;
const value = (parsed as Record<string, unknown>)[key];
return Array.isArray(value) ? value.length : 0;
}
function classifyResult(
result: import('../ai/claude-executor.js').ClaudePromptResult,
authentication: NonNullable<DistributedConfig['authentication']>,
+10 -2
View File
@@ -22,7 +22,7 @@ import { writePlaywrightStealthConfig } from '../ai/playwright-config-writer.js'
import { writeUserSettingsForCodePathAvoids } from '../ai/settings-writer.js';
import { AuditSession } from '../audit/index.js';
import type { ResumeAttempt } from '../audit/metrics-tracker.js';
import { generateSessionJsonPath, type SessionMetadata } from '../audit/utils.js';
import { authStateFile, generateSessionJsonPath, type SessionMetadata } from '../audit/utils.js';
import type { WorkflowSummary } from '../audit/workflow-logger.js';
import type { CheckpointContext } from '../interfaces/checkpoint-provider.js';
import { DEFAULT_DELIVERABLES_SUBDIR, deliverablesDir } from '../paths.js';
@@ -926,7 +926,15 @@ export async function logWorkflowComplete(input: ActivityInput, summary: Workflo
// 5. Write completion entry to workflow.log
await auditSession.logWorkflowComplete(cumulativeSummary);
// 6. Clean up container
// 6. Drop the authenticated browser session
try {
await fs.rm(authStateFile(sessionMetadata), { force: true });
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
console.warn(`Failed to clean up auth-state.json: ${detail}`);
}
// 7. Clean up container
removeContainer(workflowId);
}