From 2ffce8c185ddbfa52b35aa8265e695edaca924eb Mon Sep 17 00:00:00 2001 From: Rishabh B <122753012+rishabhcli@users.noreply.github.com> Date: Tue, 17 Mar 2026 10:58:39 -0700 Subject: [PATCH 1/7] fix(verifier): prevent command injection in patch verification --- agents/verifier/index.ts | 60 ++++++++++++++++++++++++++++++++++------ 1 file changed, 51 insertions(+), 9 deletions(-) diff --git a/agents/verifier/index.ts b/agents/verifier/index.ts index 3462512..b90dea6 100644 --- a/agents/verifier/index.ts +++ b/agents/verifier/index.ts @@ -9,7 +9,7 @@ * Instrumented with W&B Weave for observability. */ -import { execSync } from 'child_process'; +import { execFileSync } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; import type { @@ -38,6 +38,8 @@ export class VerifierAgent implements IVerifierAgent { private autoCommit: boolean; private currentFailureReport?: FailureReport; + private static readonly COMMIT_MESSAGE_MAX_LENGTH = 200; + constructor(projectRoot: string = process.cwd(), options: VerifierOptions = {}) { this.projectRoot = projectRoot; this.useRedis = options.useRedis ?? true; @@ -177,9 +179,14 @@ export class VerifierAgent implements IVerifierAgent { */ private async commitFix(patch: Patch): Promise { try { - const commitMessage = `fix: ${patch.description}\n\nApplied by QAgent`; - execSync(`git add ${patch.file}`, { cwd: this.projectRoot, stdio: 'pipe' }); - execSync(`git commit -m "${commitMessage}"`, { + const safeFilePath = this.getValidatedFilePath(patch.file); + const commitMessage = `fix: ${this.sanitizeCommitDescription(patch.description)}\n\nApplied by QAgent`; + + execFileSync('git', ['add', '--', safeFilePath], { + cwd: this.projectRoot, + stdio: 'pipe', + }); + execFileSync('git', ['commit', '-m', commitMessage], { cwd: this.projectRoot, stdio: 'pipe', }); @@ -195,7 +202,7 @@ export class VerifierAgent implements IVerifierAgent { */ private async applyPatch(patch: Patch): Promise { try { - const fullPath = path.join(this.projectRoot, patch.file); + const fullPath = this.getValidatedFilePath(patch.file); const sourceCode = fs.readFileSync(fullPath, 'utf-8'); // Parse the diff to get the change details @@ -242,7 +249,7 @@ export class VerifierAgent implements IVerifierAgent { * Create a backup of a file */ private async backupFile(filePath: string): Promise { - const fullPath = path.join(this.projectRoot, filePath); + const fullPath = this.getValidatedFilePath(filePath); const backupPath = `${fullPath}.backup.${Date.now()}`; fs.copyFileSync(fullPath, backupPath); return backupPath; @@ -252,7 +259,7 @@ export class VerifierAgent implements IVerifierAgent { * Restore a file from backup */ private async restoreFile(filePath: string, backupPath: string): Promise { - const fullPath = path.join(this.projectRoot, filePath); + const fullPath = this.getValidatedFilePath(filePath); fs.copyFileSync(backupPath, fullPath); this.cleanupBackup(backupPath); } @@ -273,7 +280,7 @@ export class VerifierAgent implements IVerifierAgent { */ private async validateSyntax(filePath: string): Promise { try { - const fullPath = path.join(this.projectRoot, filePath); + const fullPath = this.getValidatedFilePath(filePath); const content = fs.readFileSync(fullPath, 'utf-8'); // Basic bracket balance check @@ -300,7 +307,7 @@ export class VerifierAgent implements IVerifierAgent { // Try to run TypeScript check using project config try { // Use project's tsconfig to ensure JSX and other settings are applied - execSync(`npx tsc --noEmit --project tsconfig.json 2>&1`, { + execFileSync('npx', ['tsc', '--noEmit', '--project', 'tsconfig.json'], { cwd: this.projectRoot, stdio: 'pipe', }); @@ -370,6 +377,41 @@ export class VerifierAgent implements IVerifierAgent { console.error('Error recording fix in knowledge base:', error); } } + + /** + * Resolve and validate that patch file paths stay within the repository. + */ + private getValidatedFilePath(filePath: string): string { + if (!filePath || typeof filePath !== 'string') { + throw new Error('Invalid patch file path'); + } + + const normalizedPath = path.normalize(filePath); + if (path.isAbsolute(normalizedPath)) { + throw new Error('Absolute file paths are not allowed in patches'); + } + + const resolvedPath = path.resolve(this.projectRoot, normalizedPath); + const rootPath = path.resolve(this.projectRoot); + + if (resolvedPath !== rootPath && !resolvedPath.startsWith(`${rootPath}${path.sep}`)) { + throw new Error('Patch file path resolves outside project root'); + } + + return resolvedPath; + } + + /** + * Keep commit subjects safe, readable, and bounded. + */ + private sanitizeCommitDescription(description: string): string { + const fallback = 'Bug fix'; + const normalized = description.trim().replace(/[\r\n]+/g, ' '); + if (!normalized) { + return fallback; + } + return normalized.slice(0, VerifierAgent.COMMIT_MESSAGE_MAX_LENGTH); + } } export default VerifierAgent; From 3bba8bc760fc88dc6c545401988aa9bf0ff93341 Mon Sep 17 00:00:00 2001 From: Rishabh B <122753012+rishabhcli@users.noreply.github.com> Date: Tue, 17 Mar 2026 10:59:24 -0700 Subject: [PATCH 2/7] Harden code-first analysis against untrusted script execution --- agents/analyzer/index.ts | 12 +++++++++--- app/api/runs/route.ts | 2 +- lib/git/clone.ts | 5 ++++- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/agents/analyzer/index.ts b/agents/analyzer/index.ts index de78c63..cc40eb8 100644 --- a/agents/analyzer/index.ts +++ b/agents/analyzer/index.ts @@ -40,9 +40,11 @@ export interface AnalysisResult { export class CodeAnalyzerAgent { private projectRoot: string; + private allowBuildScripts: boolean; - constructor(projectRoot: string) { + constructor(projectRoot: string, allowBuildScripts: boolean = true) { this.projectRoot = projectRoot; + this.allowBuildScripts = allowBuildScripts; } /** @@ -63,8 +65,12 @@ export class CodeAnalyzerAgent { issues.push(...eslintIssues); // 3. Build check - const buildIssues = await this.runBuildCheck(); - issues.push(...buildIssues); + if (this.allowBuildScripts) { + const buildIssues = await this.runBuildCheck(); + issues.push(...buildIssues); + } else { + console.log('[CodeAnalyzer] Build check skipped for untrusted repository'); + } const errors = issues.filter(i => i.severity === 'error').length; const warnings = issues.filter(i => i.severity === 'warning').length; diff --git a/app/api/runs/route.ts b/app/api/runs/route.ts index eec8cdb..c944a38 100644 --- a/app/api/runs/route.ts +++ b/app/api/runs/route.ts @@ -235,7 +235,7 @@ async function runCodeFirstOrchestrator( emitAgentStarted(runId, 'tester'); updateRunAgent(runId, 'tester'); - const analyzer = new CodeAnalyzerAgent(clonedRepo.repoPath); + const analyzer = new CodeAnalyzerAgent(clonedRepo.repoPath, false); const analysisResult = await analyzer.analyze(); // eslint-disable-next-line no-console diff --git a/lib/git/clone.ts b/lib/git/clone.ts index d7240c6..be5a251 100644 --- a/lib/git/clone.ts +++ b/lib/git/clone.ts @@ -113,7 +113,10 @@ export async function installDependencies( const pm = detectPackageManager(repoPath); console.log(`[GitClone] Installing dependencies with ${pm}...`); - const installCommand = pm === 'npm' ? 'npm install' : `${pm} install`; + const installCommand = + pm === 'npm' + ? 'npm install --ignore-scripts' + : `${pm} install --ignore-scripts`; try { execSync(installCommand, { From 5df59876b8d89f7d04fa3a49cc15fc663bd82e67 Mon Sep 17 00:00:00 2001 From: Rishabh B <122753012+rishabhcli@users.noreply.github.com> Date: Tue, 17 Mar 2026 10:59:28 -0700 Subject: [PATCH 3/7] fix: enforce run ownership for session debug access --- app/api/runs/[runId]/session/route.ts | 7 +++++++ app/api/runs/analyze/route.ts | 17 ++++------------- app/api/runs/route.ts | 12 +++++++++++- lib/dashboard/run-store.ts | 2 ++ lib/types.ts | 1 + 5 files changed, 25 insertions(+), 14 deletions(-) diff --git a/app/api/runs/[runId]/session/route.ts b/app/api/runs/[runId]/session/route.ts index 80c248d..6771205 100644 --- a/app/api/runs/[runId]/session/route.ts +++ b/app/api/runs/[runId]/session/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { getRunAsync } from '@/lib/dashboard/run-store'; +import { getSession } from '@/lib/auth/session'; import Browserbase from '@browserbasehq/sdk'; // GET /api/runs/[runId]/session - Get Browserbase session debug URLs @@ -8,12 +9,18 @@ export async function GET( { params }: { params: Promise<{ runId: string }> } ) { const { runId } = await params; + const session = await getSession(); const run = await getRunAsync(runId); if (!run) { return NextResponse.json({ error: 'Run not found' }, { status: 404 }); } + const requesterId = session?.user?.id; + if (requesterId === undefined || run.ownerId !== requesterId) { + return NextResponse.json({ error: 'Run not found' }, { status: 404 }); + } + if (!run.sessionId) { return NextResponse.json({ hasSession: false, diff --git a/app/api/runs/analyze/route.ts b/app/api/runs/analyze/route.ts index 3694739..56472c2 100644 --- a/app/api/runs/analyze/route.ts +++ b/app/api/runs/analyze/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; -import { cookies } from 'next/headers'; +import { getSession } from '@/lib/auth/session'; import { createRun, updateRunStatus, @@ -42,18 +42,8 @@ export async function POST(request: NextRequest) { } // Get GitHub access token from session - const cookieStore = await cookies(); - const session = cookieStore.get('qagent_session'); - let githubToken: string | undefined; - - if (session) { - try { - const sessionData = JSON.parse(session.value); - githubToken = sessionData.accessToken; - } catch { - // Session parse error - } - } + const session = await getSession(); + const githubToken = session?.accessToken || undefined; if (!githubToken) { return NextResponse.json( @@ -64,6 +54,7 @@ export async function POST(request: NextRequest) { // Create the run const run = createRun({ + ownerId: session?.user?.id, repoId: repoId || repoName, repoName, testSpecs: [], // No test specs for code analysis diff --git a/app/api/runs/route.ts b/app/api/runs/route.ts index eec8cdb..d5ba98c 100644 --- a/app/api/runs/route.ts +++ b/app/api/runs/route.ts @@ -35,6 +35,9 @@ import type { AgentType, Patch, DiagnosisReport, ClonedRepo } from '@/lib/types' // GET /api/runs - List all runs with optional pagination and filtering export async function GET(request: NextRequest) { + const session = await getSession(); + const userId = session?.user?.id; + const { searchParams } = request.nextUrl; const page = Math.max(1, parseInt(searchParams.get('page') || '1', 10)); const limit = Math.min(100, Math.max(1, parseInt(searchParams.get('limit') || '20', 10))); @@ -44,6 +47,10 @@ export async function GET(request: NextRequest) { let runs = getAllRuns(); + if (userId !== undefined) { + runs = runs.filter((r) => r.ownerId === userId); + } + if (statusFilter) { runs = runs.filter((r) => r.status === statusFilter); } @@ -114,10 +121,12 @@ export async function POST(request: NextRequest) { ); } + // Get current user session + const session = await getSession(); + // Get GitHub access token from session for cloud mode let githubToken: string | undefined; if (cloudMode) { - const session = await getSession(); if (session?.accessToken) { githubToken = session.accessToken; // eslint-disable-next-line no-console @@ -129,6 +138,7 @@ export async function POST(request: NextRequest) { } const run = createRun({ + ownerId: session?.user?.id, repoId: repoId || 'local', repoName: repoName || 'Demo App', testSpecs: testSpecs || [], diff --git a/lib/dashboard/run-store.ts b/lib/dashboard/run-store.ts index 8bf91d6..e6a9c50 100644 --- a/lib/dashboard/run-store.ts +++ b/lib/dashboard/run-store.ts @@ -16,6 +16,7 @@ const runs = globalForRuns.runsMap ?? new Map(); globalForRuns.runsMap = runs; export function createRun(data: { + ownerId?: number; repoId: string; repoName: string; testSpecs: TestSpec[]; @@ -23,6 +24,7 @@ export function createRun(data: { }): Run { const run: Run = { id: crypto.randomUUID(), + ownerId: data.ownerId, repoId: data.repoId, repoName: data.repoName, status: 'pending', diff --git a/lib/types.ts b/lib/types.ts index 89aa12d..538446c 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -208,6 +208,7 @@ export type AgentType = 'tester' | 'triage' | 'fixer' | 'verifier'; export interface Run { id: string; + ownerId?: number; repoId: string; repoName: string; status: RunStatus; From 8a1568958b2da26c5d252e29e5929713ed419159 Mon Sep 17 00:00:00 2001 From: Rishabh B <122753012+rishabhcli@users.noreply.github.com> Date: Tue, 17 Mar 2026 10:59:33 -0700 Subject: [PATCH 4/7] fix: enforce PKCE params in mobile oauth exchange --- app/api/auth/mobile/exchange/route.ts | 21 +++++++++++++++++---- lib/auth/github.ts | 14 ++++++++++++-- mobile/lib/auth/context.tsx | 21 ++++++++++++++++----- 3 files changed, 45 insertions(+), 11 deletions(-) diff --git a/app/api/auth/mobile/exchange/route.ts b/app/api/auth/mobile/exchange/route.ts index 49cb182..5485bb6 100644 --- a/app/api/auth/mobile/exchange/route.ts +++ b/app/api/auth/mobile/exchange/route.ts @@ -12,17 +12,30 @@ import { createRefreshToken } from '@/lib/auth/token-store'; */ export async function POST(request: NextRequest) { try { - const { code, redirectUri } = await request.json(); + const { code, redirectUri, codeVerifier, state } = await request.json(); - if (!code) { + if (!code || !redirectUri || !codeVerifier || !state) { return NextResponse.json( - { error: 'Missing authorization code' }, + { error: 'Missing required OAuth parameters' }, + { status: 400 } + ); + } + + const parsedRedirectUri = new URL(redirectUri); + if (parsedRedirectUri.protocol !== 'qagent:') { + return NextResponse.json( + { error: 'Invalid redirect URI' }, { status: 400 } ); } // Exchange code for GitHub access token - const accessToken = await exchangeCodeForToken(code); + const accessToken = await exchangeCodeForToken({ + code, + redirectUri, + codeVerifier, + state, + }); if (!accessToken) { return NextResponse.json( diff --git a/lib/auth/github.ts b/lib/auth/github.ts index 5d2946f..aba704c 100644 --- a/lib/auth/github.ts +++ b/lib/auth/github.ts @@ -15,7 +15,14 @@ export function getGitHubAuthUrl(state: string): string { return `https://github.com/login/oauth/authorize?${params.toString()}`; } -export async function exchangeCodeForToken(code: string): Promise { +export async function exchangeCodeForToken( + params: string | { code: string; redirectUri?: string; codeVerifier?: string; state?: string } +): Promise { + const requestParams = + typeof params === 'string' + ? { code: params } + : params; + const response = await fetch('https://github.com/login/oauth/access_token', { method: 'POST', headers: { @@ -25,7 +32,10 @@ export async function exchangeCodeForToken(code: string): Promise { body: JSON.stringify({ client_id: GITHUB_CLIENT_ID, client_secret: GITHUB_CLIENT_SECRET, - code, + code: requestParams.code, + ...(requestParams.redirectUri ? { redirect_uri: requestParams.redirectUri } : {}), + ...(requestParams.codeVerifier ? { code_verifier: requestParams.codeVerifier } : {}), + ...(requestParams.state ? { state: requestParams.state } : {}), }), }); diff --git a/mobile/lib/auth/context.tsx b/mobile/lib/auth/context.tsx index 744338b..83e260f 100644 --- a/mobile/lib/auth/context.tsx +++ b/mobile/lib/auth/context.tsx @@ -61,13 +61,20 @@ export function AuthProvider({ children }: { children: ReactNode }) { // Handle OAuth response useEffect(() => { if (response?.type === 'success') { - const { code } = response.params; - exchangeCodeForToken(code); + const { code, state } = response.params; + + if (!request?.codeVerifier || !request?.state || state !== request.state) { + console.error('OAuth state/PKCE validation failed'); + setIsLoading(false); + return; + } + + exchangeCodeForToken(code, request.codeVerifier, request.state); } else if (response?.type === 'error') { console.error('OAuth error:', response.error); setIsLoading(false); } - }, [response]); + }, [request, response]); const loadStoredSession = async () => { try { @@ -103,7 +110,11 @@ export function AuthProvider({ children }: { children: ReactNode }) { } }; - const exchangeCodeForToken = async (code: string) => { + const exchangeCodeForToken = async ( + code: string, + codeVerifier: string, + state: string + ) => { try { setIsLoading(true); @@ -111,7 +122,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { const res = await fetch(`${API_URL}/api/auth/mobile/exchange`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ code, redirectUri }), + body: JSON.stringify({ code, redirectUri, codeVerifier, state }), }); if (!res.ok) { From ca98e68064ae2a2e5088ef62b6bd38aed2c99d5d Mon Sep 17 00:00:00 2001 From: Rishabh B <122753012+rishabhcli@users.noreply.github.com> Date: Tue, 17 Mar 2026 10:59:34 -0700 Subject: [PATCH 5/7] fix(auth): disable insecure mobile oauth code exchange endpoint --- app/api/auth/mobile/exchange/route.ts | 70 +++------------------------ 1 file changed, 8 insertions(+), 62 deletions(-) diff --git a/app/api/auth/mobile/exchange/route.ts b/app/api/auth/mobile/exchange/route.ts index 49cb182..82fa0e5 100644 --- a/app/api/auth/mobile/exchange/route.ts +++ b/app/api/auth/mobile/exchange/route.ts @@ -1,7 +1,4 @@ import { NextRequest, NextResponse } from 'next/server'; -import { exchangeCodeForToken, getGitHubUser } from '@/lib/auth/github'; -import { encrypt } from '@/lib/auth/session'; -import { createRefreshToken } from '@/lib/auth/token-store'; /** * Mobile OAuth code exchange endpoint @@ -11,64 +8,13 @@ import { createRefreshToken } from '@/lib/auth/token-store'; * and returns a session token that the mobile app can use for API requests. */ export async function POST(request: NextRequest) { - try { - const { code, redirectUri } = await request.json(); + void request; - if (!code) { - return NextResponse.json( - { error: 'Missing authorization code' }, - { status: 400 } - ); - } - - // Exchange code for GitHub access token - const accessToken = await exchangeCodeForToken(code); - - if (!accessToken) { - return NextResponse.json( - { error: 'Failed to exchange code for token' }, - { status: 400 } - ); - } - - // Get user info - const user = await getGitHubUser(accessToken); - - if (!user) { - return NextResponse.json( - { error: 'Failed to get user info' }, - { status: 400 } - ); - } - - // Create session token (24h) and refresh token (7d) - const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); - const sessionToken = await encrypt({ - accessToken, - user, - expiresAt: expiresAt.toISOString(), - }); - const refreshToken = await createRefreshToken( - user.id, - JSON.stringify({ accessToken, user }) - ); - - return NextResponse.json({ - token: sessionToken, - refreshToken, - user: { - id: user.id, - login: user.login, - name: user.name, - avatarUrl: user.avatarUrl, - }, - expiresAt: expiresAt.toISOString(), - }); - } catch (error) { - console.error('Mobile OAuth exchange error:', error); - return NextResponse.json( - { error: 'Authentication failed' }, - { status: 500 } - ); - } + return NextResponse.json( + { + error: + 'Direct mobile code exchange is disabled. Use /api/auth/github to initiate the OAuth flow.' + }, + { status: 410 } + ); } From 6d5a61d6a980a16eb31643aea0b7ab5aac4bd1a8 Mon Sep 17 00:00:00 2001 From: Rishabh B <122753012+rishabhcli@users.noreply.github.com> Date: Tue, 17 Mar 2026 10:59:39 -0700 Subject: [PATCH 6/7] fix(auth): remove predictable JWT fallback secret --- lib/auth/session-secret.ts | 27 +++++++++++++++++++++++++++ lib/auth/session.ts | 4 ++-- middleware.ts | 7 ++----- 3 files changed, 31 insertions(+), 7 deletions(-) create mode 100644 lib/auth/session-secret.ts diff --git a/lib/auth/session-secret.ts b/lib/auth/session-secret.ts new file mode 100644 index 0000000..0d06b56 --- /dev/null +++ b/lib/auth/session-secret.ts @@ -0,0 +1,27 @@ +const DEV_SESSION_SECRET_GLOBAL_KEY = '__PATCHPILOT_DEV_SESSION_SECRET__'; + +type GlobalWithSecret = typeof globalThis & { + [DEV_SESSION_SECRET_GLOBAL_KEY]?: string; +}; + +function getDevSessionSecret(): string { + const globalWithSecret = globalThis as GlobalWithSecret; + if (!globalWithSecret[DEV_SESSION_SECRET_GLOBAL_KEY]) { + globalWithSecret[DEV_SESSION_SECRET_GLOBAL_KEY] = crypto.randomUUID(); + } + + return globalWithSecret[DEV_SESSION_SECRET_GLOBAL_KEY]; +} + +export function getSessionSecret(): string { + const envSecret = process.env.SESSION_SECRET; + if (envSecret) { + return envSecret; + } + + if (process.env.NODE_ENV === 'production') { + throw new Error('SESSION_SECRET environment variable is required in production'); + } + + return getDevSessionSecret(); +} diff --git a/lib/auth/session.ts b/lib/auth/session.ts index d92aba6..81d4be1 100644 --- a/lib/auth/session.ts +++ b/lib/auth/session.ts @@ -1,10 +1,10 @@ import { cookies } from 'next/headers'; import { SignJWT, jwtVerify } from 'jose'; import type { Session, GitHubUser, GitHubRepo, SessionPayload } from '@/lib/types'; +import { getSessionSecret } from '@/lib/auth/session-secret'; const SESSION_COOKIE = 'qagent_session'; -const SECRET_KEY = process.env.SESSION_SECRET || 'default-dev-secret-do-not-use-in-prod'; -const key = new TextEncoder().encode(SECRET_KEY); +const key = new TextEncoder().encode(getSessionSecret()); export async function encrypt(payload: SessionPayload): Promise { const jti = payload.jti || crypto.randomUUID(); diff --git a/middleware.ts b/middleware.ts index 6b53ce1..7e3f450 100644 --- a/middleware.ts +++ b/middleware.ts @@ -2,13 +2,10 @@ import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; import { jwtVerify } from 'jose'; import type { SessionPayload } from '@/lib/types'; +import { getSessionSecret } from '@/lib/auth/session-secret'; const SESSION_COOKIE = 'qagent_session'; -const SECRET_KEY = process.env.SESSION_SECRET; -if (!SECRET_KEY && process.env.NODE_ENV === 'production') { - throw new Error('SESSION_SECRET environment variable is required in production'); -} -const key = new TextEncoder().encode(SECRET_KEY || 'default-dev-secret-do-not-use-in-prod'); +const key = new TextEncoder().encode(getSessionSecret()); export async function middleware(request: NextRequest) { const path = request.nextUrl.pathname; From 70eaae5d7669bf6fea1946e264da77114250e8ae Mon Sep 17 00:00:00 2001 From: Rishabh Bansal Date: Tue, 17 Mar 2026 11:49:26 -0700 Subject: [PATCH 7/7] fix: align verifier subprocess hardening with tests --- agents/verifier/index.ts | 2 +- tests/unit/verifier.test.ts | 30 ++++++++++++++++++++---------- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/agents/verifier/index.ts b/agents/verifier/index.ts index 59e636b..12e48c4 100644 --- a/agents/verifier/index.ts +++ b/agents/verifier/index.ts @@ -217,7 +217,7 @@ export class VerifierAgent implements IVerifierAgent { } private async deploy(): Promise { - execSync('git push', { + execFileSync('git', ['push'], { cwd: this.projectRoot, stdio: 'pipe', }); diff --git a/tests/unit/verifier.test.ts b/tests/unit/verifier.test.ts index bf47ec7..ac8bbe4 100644 --- a/tests/unit/verifier.test.ts +++ b/tests/unit/verifier.test.ts @@ -26,7 +26,7 @@ vi.mock('fs', () => ({ // Mock child_process vi.mock('child_process', () => ({ - execSync: vi.fn(), + execFileSync: vi.fn(), })); // Mock TesterAgent @@ -62,10 +62,10 @@ global.fetch = mockFetch; // Import after mocks import { VerifierAgent } from '@/agents/verifier'; import * as fs from 'fs'; -import { execSync } from 'child_process'; +import { execFileSync } from 'child_process'; -// Get mocked execSync -const mockExecSync = vi.mocked(execSync); +// Get mocked execFileSync +const mockExecFileSync = vi.mocked(execFileSync); describe('VerifierAgent', () => { let verifier: VerifierAgent; @@ -396,12 +396,19 @@ describe('VerifierAgent', () => { const result = await agent.verify(patch, testSpec); - expect(mockExecSync).toHaveBeenCalledWith( - expect.stringContaining('git add'), + expect(mockExecFileSync).toHaveBeenCalledWith( + 'git', + ['add', '--', expect.stringContaining('src/test.ts')], expect.any(Object) ); - expect(mockExecSync).toHaveBeenCalledWith( - expect.stringContaining('git commit'), + expect(mockExecFileSync).toHaveBeenCalledWith( + 'git', + ['commit', '-m', expect.stringContaining('fix:')], + expect.any(Object) + ); + expect(mockExecFileSync).toHaveBeenCalledWith( + 'git', + ['push'], expect.any(Object) ); expect(result.deploymentUrl).toBe('https://test-deploy.vercel.app'); @@ -411,8 +418,11 @@ describe('VerifierAgent', () => { process.env.VERCEL_TOKEN = 'test-token'; process.env.VERCEL_PROJECT_ID = 'test-project'; - mockExecSync.mockImplementation(() => { - throw new Error('git push failed'); + mockExecFileSync.mockImplementation((command, args) => { + if (command === 'git' && Array.isArray(args) && args[0] === 'push') { + throw new Error('git push failed'); + } + return Buffer.from(''); }); const agent = new VerifierAgent('/test', { useRedis: false });