From fe7884c0a413834e35a717cbec359bb179a61c8a Mon Sep 17 00:00:00 2001 From: kelmith Date: Tue, 1 Sep 2026 12:40:42 +0530 Subject: [PATCH 1/2] feat: add Attestor browser acceptance-criteria verification module --- .../migration.sql | 2 + prisma/schema.prisma | 1 + .../[featureId]/verify/callback/route.ts | 149 ++++++++++ .../api/features/[featureId]/verify/route.ts | 81 ++++++ .../[featureId]/components/VerifyPanel.tsx | 262 ++++++++++++------ src/services/attestor/derive.ts | 62 +++++ src/services/attestor/trigger.ts | 180 ++++++++++++ src/services/attestor/types.ts | 56 ++++ 8 files changed, 703 insertions(+), 90 deletions(-) create mode 100644 prisma/migrations/20260901000000_add_feature_verify_callback_key/migration.sql create mode 100644 src/app/api/features/[featureId]/verify/callback/route.ts create mode 100644 src/app/api/features/[featureId]/verify/route.ts create mode 100644 src/services/attestor/derive.ts create mode 100644 src/services/attestor/trigger.ts create mode 100644 src/services/attestor/types.ts diff --git a/prisma/migrations/20260901000000_add_feature_verify_callback_key/migration.sql b/prisma/migrations/20260901000000_add_feature_verify_callback_key/migration.sql new file mode 100644 index 0000000000..220888ce35 --- /dev/null +++ b/prisma/migrations/20260901000000_add_feature_verify_callback_key/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "features" ADD COLUMN "verify_callback_key" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 152d202d0d..dbb52becbc 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -920,6 +920,7 @@ model Feature { isFastTrack Boolean @default(false) @map("is_fast_track") planUpdatedAt DateTime? @map("plan_updated_at") model String? + verifyCallbackKey String? @map("verify_callback_key") milestoneId String? @map("milestone_id") initiativeId String? @map("initiative_id") // Cuids of features that must reach completion before this feature diff --git a/src/app/api/features/[featureId]/verify/callback/route.ts b/src/app/api/features/[featureId]/verify/callback/route.ts new file mode 100644 index 0000000000..13fa5ed905 --- /dev/null +++ b/src/app/api/features/[featureId]/verify/callback/route.ts @@ -0,0 +1,149 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db } from "@/lib/db"; +import { EncryptionService, timingSafeEqual } from "@/lib/encryption"; +import { ChatRole, ChatStatus, ArtifactType, WorkflowStatus, NotificationTriggerType } from "@prisma/client"; +import { createAndSendNotification } from "@/services/notifications"; +import { pusherServer, getFeatureChannelName, PUSHER_EVENTS } from "@/lib/pusher"; +import type { VerifyCallbackPayload } from "@/services/attestor/types"; + +export const fetchCache = "force-no-store"; + +const encryptionService = EncryptionService.getInstance(); + +export async function POST(request: NextRequest, { params }: { params: Promise<{ featureId: string }> }) { + try { + const { featureId } = await params; + + if (!featureId) { + return NextResponse.json({ error: "Feature ID required" }, { status: 400 }); + } + + const apiKey = request.headers.get("x-api-key"); + if (!apiKey) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const feature = await db.feature.findUnique({ + where: { id: featureId, deleted: false }, + select: { + id: true, + title: true, + verifyCallbackKey: true, + assigneeId: true, + createdById: true, + workspaceId: true, + workspace: { select: { slug: true } }, + }, + }); + + if (!feature) { + return NextResponse.json({ error: "Feature not found" }, { status: 404 }); + } + + if (!feature.verifyCallbackKey) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + let decryptedKey: string; + try { + const encryptedData = JSON.parse(feature.verifyCallbackKey); + decryptedKey = encryptionService.decryptField("agentPassword", encryptedData); + } catch (error) { + console.error("[attestor] Failed to decrypt verifyCallbackKey:", error); + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + if (!timingSafeEqual(apiKey, decryptedKey)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + let payload: VerifyCallbackPayload; + try { + payload = (await request.json()) as VerifyCallbackPayload; + } catch (error) { + console.error("[attestor] Failed to parse callback payload:", error); + return NextResponse.json({ error: "Invalid payload" }, { status: 400 }); + } + + const passed = payload.overall === "passed"; + const workflowStatus = passed ? WorkflowStatus.COMPLETED : WorkflowStatus.FAILED; + + const chatMessage = await db.chatMessage.create({ + data: { + featureId, + message: passed ? "Verification passed" : "Verification needs review", + role: ChatRole.ASSISTANT, + status: ChatStatus.SENT, + artifacts: { + create: [ + { + type: ArtifactType.VERIFY, + content: payload as unknown as object, + icon: "verify", + }, + ], + }, + }, + include: { artifacts: true }, + }); + + await db.feature.update({ + where: { id: featureId }, + data: { + workflowStatus, + workflowCompletedAt: new Date(), + verifyCallbackKey: null, + }, + }); + + if (!passed) { + const targetUserId = feature.assigneeId ?? feature.createdById; + const featureUrl = `${process.env.NEXTAUTH_URL}/w/${feature.workspace.slug}/plan/${featureId}`; + void (async () => { + try { + const targetUser = await db.user.findUnique({ + where: { id: targetUserId }, + select: { sphinxAlias: true, name: true }, + }); + const alias = targetUser?.sphinxAlias ?? targetUser?.name ?? "User"; + await createAndSendNotification({ + targetUserId, + featureId, + workspaceId: feature.workspaceId, + notificationType: NotificationTriggerType.WORKFLOW_HALTED, + message: `@${alias} — Verification of '${feature.title}' needs review: ${featureUrl}`, + }); + } catch (notifError) { + console.error("[attestor] Error firing WORKFLOW_HALTED notification:", notifError); + } + })(); + } + + try { + void pusherServer + .trigger(getFeatureChannelName(featureId), PUSHER_EVENTS.WORKFLOW_STATUS_UPDATE, { + featureId, + workflowStatus, + overall: payload.overall, + at: Date.now(), + }) + .catch((err) => { + console.error("[attestor] Pusher broadcast failed (non-fatal):", err); + }); + } catch (err) { + console.error("[attestor] Pusher broadcast threw (non-fatal):", err); + } + + return NextResponse.json({ + success: true, + data: { + messageId: chatMessage.id, + artifactIds: chatMessage.artifacts.map((a) => a.id), + workflowStatus, + }, + }); + } catch (error) { + console.error("[attestor] Unexpected error in verify callback:", error); + return NextResponse.json({ error: "Internal error" }, { status: 500 }); + } +} diff --git a/src/app/api/features/[featureId]/verify/route.ts b/src/app/api/features/[featureId]/verify/route.ts new file mode 100644 index 0000000000..df6f3bd1db --- /dev/null +++ b/src/app/api/features/[featureId]/verify/route.ts @@ -0,0 +1,81 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db } from "@/lib/db"; +import { ArtifactType } from "@prisma/client"; +import { + resolveWorkspaceAccess, + requireMemberAccess, + requireReadAccess, +} from "@/lib/auth/workspace-access"; +import { startVerification } from "@/services/attestor/trigger"; + +export const fetchCache = "force-no-store"; + +export async function POST(request: NextRequest, { params }: { params: Promise<{ featureId: string }> }) { + try { + const { featureId } = await params; + + const feature = await db.feature.findUnique({ + where: { id: featureId, deleted: false }, + select: { workspaceId: true }, + }); + + if (!feature) { + return NextResponse.json({ error: "Feature not found" }, { status: 404 }); + } + + const access = await resolveWorkspaceAccess(request, { workspaceId: feature.workspaceId }); + const member = requireMemberAccess(access); + if (member instanceof NextResponse) return member; + + const result = await startVerification(featureId, member.userId); + + return NextResponse.json({ success: true, data: result }); + } catch (error) { + console.error("[attestor] verify trigger failed:", error); + return NextResponse.json( + { + error: "Failed to start verification", + details: error instanceof Error ? error.message : "Unknown error", + }, + { status: 500 }, + ); + } +} + +export async function GET(request: NextRequest, { params }: { params: Promise<{ featureId: string }> }) { + try { + const { featureId } = await params; + + const feature = await db.feature.findUnique({ + where: { id: featureId, deleted: false }, + select: { workspaceId: true, workflowStatus: true }, + }); + + if (!feature) { + return NextResponse.json({ error: "Feature not found" }, { status: 404 }); + } + + const access = await resolveWorkspaceAccess(request, { workspaceId: feature.workspaceId }); + const ok = requireReadAccess(access); + if (ok instanceof NextResponse) return ok; + + const artifact = await db.artifact.findFirst({ + where: { + type: ArtifactType.VERIFY, + message: { featureId }, + }, + orderBy: { createdAt: "desc" }, + select: { id: true, content: true, createdAt: true }, + }); + + return NextResponse.json({ + workflowStatus: feature.workflowStatus, + checklist: artifact?.content ?? null, + artifactId: artifact?.id ?? null, + createdAt: artifact?.createdAt ?? null, + }); + } catch (error) { + console.error("[attestor] verify fetch failed:", error); + return NextResponse.json({ error: "Internal error" }, { status: 500 }); + } +} diff --git a/src/app/w/[slug]/plan/[featureId]/components/VerifyPanel.tsx b/src/app/w/[slug]/plan/[featureId]/components/VerifyPanel.tsx index 71f3558dd1..77c1a01f6f 100644 --- a/src/app/w/[slug]/plan/[featureId]/components/VerifyPanel.tsx +++ b/src/app/w/[slug]/plan/[featureId]/components/VerifyPanel.tsx @@ -1,10 +1,13 @@ "use client"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { ScreenshotModal } from "@/components/ScreenshotModal"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; import type { Screenshot } from "@/types/common"; import type { FeatureDetail } from "@/types/roadmap"; -import { ExternalLink } from "lucide-react"; +import type { ChecklistItem, VerifyCallbackPayload, VerifyOverall } from "@/services/attestor/types"; +import { Check, ExternalLink, Loader2, Minus, X } from "lucide-react"; interface VerifyPanelProps { feature: FeatureDetail; @@ -17,29 +20,56 @@ interface GroupedScreenshots { screenshots: Screenshot[]; } +const OVERALL_VARIANT: Record = { + passed: "default", + failed: "destructive", + pending: "secondary", +}; + +function StatusIcon({ status }: { status: ChecklistItem["status"] }) { + if (status === "met") { + return ; + } + if (status === "not_met") { + return ; + } + return ; +} + export function VerifyPanel({ feature, workspaceId }: VerifyPanelProps) { const [loading, setLoading] = useState(true); - const [groupedScreenshots, setGroupedScreenshots] = useState< - GroupedScreenshots[] - >([]); - const [selectedScreenshot, setSelectedScreenshot] = - useState(null); + const [groupedScreenshots, setGroupedScreenshots] = useState([]); + const [selectedScreenshot, setSelectedScreenshot] = useState(null); const [isModalOpen, setIsModalOpen] = useState(false); const [allScreenshots, setAllScreenshots] = useState([]); + const [checklist, setChecklist] = useState(null); + const [verifying, setVerifying] = useState(false); + const [verifyError, setVerifyError] = useState(null); + + const fetchChecklist = useCallback(async () => { + try { + const response = await fetch(`/api/features/${feature.id}/verify`, { + credentials: "include", + }); + if (!response.ok) return; + const data = await response.json(); + setChecklist((data.checklist as VerifyCallbackPayload | null) ?? null); + } catch (error) { + console.error("Error fetching verification checklist:", error); + } + }, [feature.id]); useEffect(() => { async function fetchScreenshots() { setLoading(true); try { - // Fetch all image attachments for this feature in a single request - const response = await fetch( - `/api/features/${feature.id}/attachments`, - { credentials: 'include' } - ); + const response = await fetch(`/api/features/${feature.id}/attachments`, { + credentials: "include", + }); if (!response.ok) { - console.error('Error fetching attachments:', response.statusText); + console.error("Error fetching attachments:", response.statusText); setLoading(false); return; } @@ -47,11 +77,8 @@ export function VerifyPanel({ feature, workspaceId }: VerifyPanelProps) { const data = await response.json(); const attachments: any[] = data.attachments || []; - // Group attachments by task const grouped: GroupedScreenshots[] = []; const flat: Screenshot[] = []; - - // Create a map to group attachments by taskId const attachmentsByTask = new Map(); attachments.forEach((a: any) => { @@ -62,45 +89,60 @@ export function VerifyPanel({ feature, workspaceId }: VerifyPanelProps) { } }); - // Build grouped data for attachments with taskId attachmentsByTask.forEach((taskAttachments, taskId) => { - // Use taskTitle from attachment data (already included in API response) const taskTitle = taskAttachments[0]?.taskTitle || "Untitled Task"; - // Normalize and sort attachments const normalizedScreenshots: Screenshot[] = taskAttachments .map((a: any, index: number) => ({ id: a.id, - actionIndex: index, // Use index within this task group - dataUrl: a.url, // Presigned S3 URL + actionIndex: index, + dataUrl: a.url, timestamp: a.createdAt, - url: a.filename, // Display filename + url: a.filename, s3Key: undefined, s3Url: a.url, hash: undefined, })) .sort((a, b) => a.actionIndex - b.actionIndex); - grouped.push({ - taskId, - taskTitle, - screenshots: normalizedScreenshots, - }); - + grouped.push({ taskId, taskTitle, screenshots: normalizedScreenshots }); flat.push(...normalizedScreenshots); }); setGroupedScreenshots(grouped); setAllScreenshots(flat); } catch (error) { - console.error('Error fetching screenshots:', error); + console.error("Error fetching screenshots:", error); } finally { setLoading(false); } } fetchScreenshots(); - }, [feature.id, workspaceId]); + fetchChecklist(); + }, [feature.id, workspaceId, fetchChecklist]); + + const handleVerify = async () => { + setVerifying(true); + setVerifyError(null); + try { + const response = await fetch(`/api/features/${feature.id}/verify`, { + method: "POST", + credentials: "include", + }); + if (!response.ok) { + const data = await response.json().catch(() => ({})); + setVerifyError(data.details || data.error || "Failed to start verification"); + return; + } + await fetchChecklist(); + } catch (error) { + console.error("Error starting verification:", error); + setVerifyError("Failed to start verification"); + } finally { + setVerifying(false); + } + }; const handleScreenshotClick = (screenshot: Screenshot) => { setSelectedScreenshot(screenshot); @@ -116,76 +158,116 @@ export function VerifyPanel({ feature, workspaceId }: VerifyPanelProps) { setSelectedScreenshot(screenshot); }; - if (loading && groupedScreenshots.length === 0) { - return ( -
- {[1, 2].map((i) => ( -
-
-
- {[1, 2, 3].map((j) => ( -
- ))} + return ( + <> +
+
+
+
+

Acceptance criteria

+ {checklist && ( + {checklist.overall} + )}
+
- ))} -
- ); - } - if (groupedScreenshots.length === 0) { - return ( -
-
-

- No screenshots yet -

-

- Screenshots will appear here once an agent has run a task -

-
-
- ); - } + {verifyError &&

{verifyError}

} - return ( - <> -
- {groupedScreenshots.map((group) => ( -
-

{group.taskTitle}

-
- {group.screenshots.map((screenshot) => ( - +
))}
+ ) : ( +

+ No verification run yet. Click Verify to check this feature against its acceptance criteria. +

+ )} +
+ + {loading && groupedScreenshots.length === 0 ? ( +
+ {[1, 2].map((i) => ( +
+
+
+ {[1, 2, 3].map((j) => ( +
+ ))} +
+
+ ))}
- ))} + ) : groupedScreenshots.length === 0 ? ( +
+

No screenshots yet

+

+ Screenshots will appear here once an agent has run a task +

+
+ ) : ( + groupedScreenshots.map((group) => ( +
+

{group.taskTitle}

+
+ {group.screenshots.map((screenshot) => ( + + ))} +
+
+ )) + )}
0) { + parts.push(`Personas:\n${feature.personas.join("\n")}`); + } + if (feature.userStories && feature.userStories.length > 0) { + parts.push(`User stories:\n${feature.userStories.map((s) => `- ${s.title}`).join("\n")}`); + } + return parts.join("\n\n"); +} + +export async function deriveCriteria(feature: DeriveCriteriaFeature): Promise { + const provider: Provider = "anthropic"; + const apiKey = getApiKeyForProvider(provider); + const model = getModel(provider, apiKey, feature.workspaceSlug); + + const result = await generateObject({ + model, + schema: criteriaSchema, + prompt: buildPrompt(feature), + system: SYSTEM_PROMPT, + temperature: 0.4, + }); + + const texts = (result.object as { criteria: string[] }).criteria; + return texts.map((text, index) => ({ id: `c${index + 1}`, text })); +} diff --git a/src/services/attestor/trigger.ts b/src/services/attestor/trigger.ts new file mode 100644 index 0000000000..953fae90fd --- /dev/null +++ b/src/services/attestor/trigger.ts @@ -0,0 +1,180 @@ +import crypto from "crypto"; +import { db } from "@/lib/db"; +import { claimPodAndGetFrontend, POD_PORTS } from "@/lib/pods"; +import { updatePodRepositories } from "@/lib/pods/utils"; +import { EncryptionService } from "@/lib/encryption"; +import { getBifrostForLLM } from "@/services/bifrost"; +import { deriveCriteria } from "./derive"; +import type { StartVerificationResult, VerifyHints, VerifyModel, VerifyRequest } from "./types"; + +const encryptionService = EncryptionService.getInstance(); + +const DEFAULT_LOGIN_HINT = + "Open the app; if a login screen appears use the dev/mock login (any username) to get in."; + +export async function startVerification(featureId: string, userId: string): Promise { + const feature = await db.feature.findUnique({ + where: { id: featureId, deleted: false }, + include: { + userStories: { orderBy: { order: "asc" }, select: { title: true } }, + workspace: { + include: { + repositories: true, + swarm: true, + }, + }, + }, + }); + + if (!feature) { + throw new Error("Feature not found"); + } + + const repository = feature.workspace.repositories[0]; + if (!repository) { + throw new Error("No repository configured for workspace"); + } + + const customStakLinkUrl = process.env.CUSTOM_STAKLINK_URL; + let controlUrl: string; + let podPassword = ""; + let frontendUrl: string | null = null; + let podStatus: "claimed" | "local"; + + if (customStakLinkUrl) { + controlUrl = customStakLinkUrl; + frontendUrl = customStakLinkUrl; + podStatus = "local"; + } else { + if (!feature.workspace.swarm) { + throw new Error("No swarm found for this workspace"); + } + if (!feature.workspace.swarm.id || !feature.workspace.swarm.poolApiKey) { + throw new Error("Swarm not properly configured with pool information"); + } + + const poolId = feature.workspace.swarm.id || feature.workspace.swarm.poolName; + const poolApiKeyPlain = encryptionService.decryptField("poolApiKey", feature.workspace.swarm.poolApiKey); + + const services = feature.workspace.swarm.services as + | Array<{ name: string; port: number; scripts?: Record }> + | null + | undefined; + + const podResult = await claimPodAndGetFrontend(poolId as string, poolApiKeyPlain, services || undefined); + + controlUrl = podResult.workspace.portMappings[POD_PORTS.CONTROL]; + podPassword = podResult.workspace.password; + frontendUrl = podResult.frontend; + podStatus = "claimed"; + + if (!controlUrl) { + throw new Error("Control port not available on claimed pod"); + } + + const repositories = feature.workspace.repositories.map((r) => ({ url: r.repositoryUrl })); + try { + await updatePodRepositories(controlUrl, podPassword, repositories); + } catch (error) { + console.error("[attestor] Failed to update pod repositories (non-fatal):", error); + } + } + + const criteria = await deriveCriteria({ + title: feature.title, + brief: feature.brief, + requirements: feature.requirements, + architecture: feature.architecture, + personas: feature.personas, + userStories: feature.userStories, + workspaceSlug: feature.workspace.slug, + }); + + const hints: VerifyHints = { + login: DEFAULT_LOGIN_HINT, + startPath: "/", + }; + + const callbackApiKey = crypto.randomBytes(32).toString("hex"); + const encryptedApiKey = encryptionService.encryptField("agentPassword", callbackApiKey); + + await db.feature.update({ + where: { id: featureId }, + data: { verifyCallbackKey: JSON.stringify(encryptedApiKey) }, + }); + + let bifrost: Awaited> = undefined; + try { + bifrost = await getBifrostForLLM( + { + workspaceId: feature.workspaceId, + workspaceSlug: feature.workspace.slug, + userId, + }, + { agentName: "browser-agent" }, + ); + } catch (error) { + console.error("[attestor] Failed to resolve Bifrost credentials (non-fatal):", error); + } + + const model: VerifyModel = { + apiKey: bifrost?.apiKey ?? process.env.ANTHROPIC_API_KEY ?? "", + provider: "anthropic", + model: "claude-3-7-sonnet-latest", + ...(bifrost?.baseUrl ? { host: bifrost.baseUrl } : {}), + }; + + const baseUrl = process.env.NEXTAUTH_URL || "http://localhost:3000"; + const responseUrl = `${baseUrl}/api/features/${featureId}/verify/callback`; + + const verifyPayload: VerifyRequest = { + featureId, + frontendUrl: frontendUrl ?? "", + criteria, + hints, + model, + responseUrl, + callbackApiKey, + }; + + try { + const headers: Record = { "Content-Type": "application/json" }; + if (podPassword) { + headers.Authorization = `Bearer ${podPassword}`; + } + + const verifyResponse = await fetch(`${controlUrl}/verify`, { + method: "POST", + headers, + body: JSON.stringify(verifyPayload), + }); + + if (!verifyResponse.ok) { + const errorText = await verifyResponse.text(); + throw new Error(`Pod returned ${verifyResponse.status}: ${errorText}`); + } + } catch (error) { + try { + await db.feature.update({ + where: { id: featureId }, + data: { workflowStatus: "ERROR", verifyCallbackKey: null }, + }); + } catch (dbError) { + console.error("[attestor] Failed to update feature status to ERROR:", dbError); + } + throw error; + } + + await db.feature.update({ + where: { id: featureId }, + data: { workflowStatus: "IN_PROGRESS", workflowStartedAt: new Date() }, + }); + + return { + featureId, + status: "running", + criteriaCount: criteria.length, + frontendUrl, + podStatus, + }; +} diff --git a/src/services/attestor/types.ts b/src/services/attestor/types.ts new file mode 100644 index 0000000000..849501f955 --- /dev/null +++ b/src/services/attestor/types.ts @@ -0,0 +1,56 @@ +export interface Criterion { + id: string; + text: string; +} + +export type CriterionStatus = "met" | "not_met" | "pending"; + +export type VerifyOverall = "passed" | "failed" | "pending"; + +export interface ChecklistItem { + id: string; + text: string; + status: CriterionStatus; + evidence: string | null; + cause: string | null; +} + +export interface VerifyHints { + login?: string; + startPath?: string; + notes?: string; +} + +export interface VerifyModel { + apiKey: string; + host?: string; + provider?: string; + model?: string; +} + +export interface VerifyRequest { + featureId: string; + frontendUrl: string; + criteria: Criterion[]; + hints: VerifyHints; + model: VerifyModel; + responseUrl: string; + callbackApiKey: string; +} + +export interface VerifyCallbackPayload { + featureId: string; + overall: VerifyOverall; + checklist: ChecklistItem[]; + startedAt: string; + finishedAt: string; + error?: string; +} + +export interface StartVerificationResult { + featureId: string; + status: "running"; + criteriaCount: number; + frontendUrl: string | null; + podStatus: "claimed" | "local"; +} From 979b0bbbe0570f6a8852ac8bcb468bad7ddc2d66 Mon Sep 17 00:00:00 2001 From: kelmith Date: Tue, 1 Sep 2026 15:27:58 +0530 Subject: [PATCH 2/2] fix: point Attestor verifier at app URL in local mode, add OpenRouter model, allow callback through webhook middleware --- src/config/middleware.ts | 1 + src/services/attestor/trigger.ts | 22 +++++++++++++++------- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/config/middleware.ts b/src/config/middleware.ts index d1024b3512..6b7f80d49c 100644 --- a/src/config/middleware.ts +++ b/src/config/middleware.ts @@ -168,6 +168,7 @@ export const ROUTE_POLICIES: ReadonlyArray = [ { path: "/api/ec2/alerts", strategy: "prefix", access: "webhook" }, { path: "/api/tasks/*/title", strategy: "pattern", access: "webhook" }, { path: "/api/tasks/*/recording", strategy: "pattern", access: "webhook" }, + { path: "/api/features/*/verify/callback", strategy: "pattern", access: "webhook" }, { path: "/api/tasks/*/webhook", strategy: "pattern", access: "webhook" }, { path: "/api/webhook/pool-manager", strategy: "prefix", access: "webhook" }, { path: "/api/w/*/pool/workspaces", strategy: "pattern", access: "webhook" }, diff --git a/src/services/attestor/trigger.ts b/src/services/attestor/trigger.ts index 953fae90fd..79ff29f501 100644 --- a/src/services/attestor/trigger.ts +++ b/src/services/attestor/trigger.ts @@ -43,7 +43,7 @@ export async function startVerification(featureId: string, userId: string): Prom if (customStakLinkUrl) { controlUrl = customStakLinkUrl; - frontendUrl = customStakLinkUrl; + frontendUrl = process.env.VERIFY_APP_URL ?? process.env.NEXTAUTH_URL ?? customStakLinkUrl; podStatus = "local"; } else { if (!feature.workspace.swarm) { @@ -117,12 +117,20 @@ export async function startVerification(featureId: string, userId: string): Prom console.error("[attestor] Failed to resolve Bifrost credentials (non-fatal):", error); } - const model: VerifyModel = { - apiKey: bifrost?.apiKey ?? process.env.ANTHROPIC_API_KEY ?? "", - provider: "anthropic", - model: "claude-3-7-sonnet-latest", - ...(bifrost?.baseUrl ? { host: bifrost.baseUrl } : {}), - }; + const openrouterKey = process.env.OPENROUTER_API_KEY; + const model: VerifyModel = openrouterKey + ? { + apiKey: openrouterKey, + provider: "openai", + model: "openai/gpt-4o", + host: "https://openrouter.ai/api/v1", + } + : { + apiKey: bifrost?.apiKey ?? process.env.ANTHROPIC_API_KEY ?? "", + provider: "anthropic", + model: "claude-3-7-sonnet-latest", + ...(bifrost?.baseUrl ? { host: bifrost.baseUrl } : {}), + }; const baseUrl = process.env.NEXTAUTH_URL || "http://localhost:3000"; const responseUrl = `${baseUrl}/api/features/${featureId}/verify/callback`;