Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 63 additions & 1 deletion apps/evi/agent/lib/capture.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { browserContext, browserSandbox, CAPTURE_MARK, captureAttestation, captureMarkdown, describeTarget, escapeInline, markdownUrl, readTargetProbe, resolveTargetExpression, sensitiveCaptureReason, unresolvedTargetMessage, validateCaptureUrl } from './capture'
import { browserContext, browserSandbox, captureAttestation, captureFrame, SCREENSHOT_DIR, CAPTURE_MARK, captureMarkdown, describeTarget, escapeInline, markdownUrl, readTargetProbe, resolveTargetExpression, sensitiveCaptureReason, unresolvedTargetMessage, validateCaptureUrl } from './capture'

describe('validateCaptureUrl', () => {
it('accepts evlog surfaces, previews, and local dev servers', () => {
Expand Down Expand Up @@ -235,3 +235,65 @@ describe('browserSandbox', () => {
expect(first).toMatchObject({ id: 'sess_1' })
})
})

describe('captureFrame', () => {
function fakeRun(evalPayload: unknown) {
return vi.fn(async ({ command }: { command: string }) => ({
exitCode: 0,
stdout: /\beval\b/.test(command) ? JSON.stringify(evalPayload) : '',
stderr: '',
}))
}

it('drives the browser through viewport, open, settle, probe, scroll, and screenshot', async () => {
const run = fakeRun({ data: { found: true, how: 'selector', hooks: [], headings: [] } })
const frame = await captureFrame(browserContext({ run }, 'sess_1'), {
side: 'before',
target: { selector: '.hero' },
url: 'http://localhost:3000',
viewport: 'desktop',
})

expect(frame.how).toBe('selector')
expect(frame.path).toMatch(/^\/workspace\/screenshots\/before-\d+\.png$/)

const commands = run.mock.calls.map(([arg]) => arg.command)
expect(commands[0]).toContain('set viewport 1280 800')
expect(commands[1]).toContain('open http://localhost:3000')
expect(commands[2]).toContain('wait 5000')
expect(commands.some(c => /\beval\b/.test(c))).toBe(true)
expect(commands.at(-3)).toContain('scrollintoview')
expect(commands.at(-3)).toContain(`[${CAPTURE_MARK}]`)
expect(commands.at(-1)).toContain(`screenshot ${frame.path}`)
})

it('captures the full viewport when no target is given', async () => {
const run = fakeRun({ data: { found: false, how: null, hooks: [], headings: [] } })
const frame = await captureFrame(browserContext({ run }, 'sess_1'), {
side: 'after',
target: null,
url: 'https://evlog.dev',
viewport: 'mobile',
})

expect(frame.how).toBeNull()
const commands = run.mock.calls.map(([arg]) => arg.command)
expect(commands[0]).toContain('set viewport 375 812')
expect(commands.some(c => /\beval\b/.test(c))).toBe(false)
expect(commands.at(-1)).toContain(`screenshot ${frame.path}`)
})

it('rejects with the page hooks and headings when the target matches nothing', async () => {
const run = fakeRun({ data: { found: false, how: null, hooks: ['landing-faq'], headings: ['FAQ'] } })
await expect(captureFrame(browserContext({ run }, 'sess_1'), {
side: 'before',
target: { text: 'missing copy' },
url: 'https://evlog.dev',
viewport: 'desktop',
})).rejects.toMatchObject({ code: 'evi.CAPTURE_TARGET_UNRESOLVED' })
})

it('frames land in SCREENSHOT_DIR', () => {
expect(SCREENSHOT_DIR).toBe('/workspace/screenshots')
})
})
39 changes: 38 additions & 1 deletion apps/evi/agent/lib/capture.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { EveSandboxSession, EveToolContext } from '@agent-browser/eve/sandbox'
import { runAgentBrowser, type EveSandboxSession, type EveToolContext } from '@agent-browser/eve/sandbox'
import type { SandboxSession } from 'eve/sandbox'
import { eviErrors } from './errors'

Expand Down Expand Up @@ -199,6 +199,43 @@ export function captureAttestation(input: AttestationInput): string {
return `captured by agent-browser · ${markdownUrl(input.beforeUrl)} → ${markdownUrl(input.afterUrl)} · ${input.viewport} · ${escapeInline(input.frame)} · ${input.capturedAt}`
}

/** Frames land here before the Blob upload reads them back. */
export const SCREENSHOT_DIR = '/workspace/screenshots'

interface FrameRequest {
readonly side: 'before' | 'after'
readonly target: CaptureTarget | null
readonly url: string
readonly viewport: CaptureViewport
}

/**
* The per-frame capture sequence: set the viewport, open the page, settle,
* resolve the target, scroll it into view, screenshot. Raises
* CAPTURE_TARGET_UNRESOLVED with the page's own hooks and headings when the
* target matches nothing, so the next attempt is a correction.
*/
export async function captureFrame(
ctx: EveToolContext,
{ side, target, url, viewport }: FrameRequest,
): Promise<{ path: string, how: 'selector' | 'text' | null }> {
const { width, height } = CAPTURE_VIEWPORTS[viewport]
const path = `${SCREENSHOT_DIR}/${side}-${Date.now()}.png`
await runAgentBrowser(ctx, ['set', 'viewport', String(width), String(height)])
await runAgentBrowser(ctx, ['open', url])
await runAgentBrowser(ctx, ['wait', String(CAPTURE_SETTLE_MS)])
if (target === null) {
await runAgentBrowser(ctx, ['screenshot', path])
return { path, how: null }
}
const probe = readTargetProbe((await runAgentBrowser(ctx, ['eval', resolveTargetExpression(target)])).json)
if (!probe.found) throw eviErrors.CAPTURE_TARGET_UNRESOLVED({ message: unresolvedTargetMessage(target, probe) })
await runAgentBrowser(ctx, ['scrollintoview', `[${CAPTURE_MARK}]`])
await runAgentBrowser(ctx, ['wait', '500'])
await runAgentBrowser(ctx, ['screenshot', path])
return { path, how: probe.how }
}

/**
* agent-browser still expects the pre-0.64 eve session shape, an `id` beside
* `run`; it only uses the id to name the browser session, so any stable
Expand Down
22 changes: 22 additions & 0 deletions apps/evi/agent/lib/gateway.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ async function loadGateway(env: Record<string, string>) {

beforeEach(() => {
vi.unstubAllEnvs()
vi.unstubAllGlobals()
getVercelOidcToken.mockReset()
})

Expand Down Expand Up @@ -146,3 +147,24 @@ describe('scopedReport', () => {
expect(report.scope.note).toContain('Do not quote account-wide totals')
})
})

describe('gatewayFetch', () => {
it('sends the query params and the bearer token, and returns the parsed body', async () => {
const fetchMock = vi.fn(async (_url: URL, _init: RequestInit) => new Response(JSON.stringify({ balance: 1 }), { status: 200 }))
vi.stubGlobal('fetch', fetchMock)
const { gatewayFetch } = await loadGateway({ AI_GATEWAY_API_KEY: ' vck_key ' })
await expect(gatewayFetch('/report', { start_date: '2026-01-01', group_by: undefined })).resolves.toEqual({ balance: 1 })
const [url, init] = fetchMock.mock.calls[0]
expect(String(url)).toBe('https://ai-gateway.vercel.sh/v1/report?start_date=2026-01-01')
expect(init.headers).toEqual({ Authorization: 'Bearer vck_key' })
})

it('maps a non-ok response to AI_GATEWAY_REQUEST_FAILED with the body in internal', async () => {
vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(new Response('boom', { status: 500 }))))
const { gatewayFetch } = await loadGateway({ AI_GATEWAY_API_KEY: 'key' })
await expect(gatewayFetch('/credits')).rejects.toMatchObject({
code: 'evi.AI_GATEWAY_REQUEST_FAILED',
internal: { body: 'boom' },
})
})
})
24 changes: 24 additions & 0 deletions apps/evi/agent/lib/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,30 @@ export async function gatewayToken(): Promise<string> {
}
}

const BASE_URL = 'https://ai-gateway.vercel.sh/v1'
const FETCH_TIMEOUT_MS = 10_000

/**
* The reporting API call behind every gateway tool: builds the URL, sends the
* bearer token from {@link gatewayToken}, and maps a non-ok response to an
* AI_GATEWAY_REQUEST_FAILED error that keeps the body in `internal`, which
* never reaches a drain or a tool result.
*/
export async function gatewayFetch(path: string, params: Record<string, string | undefined> = {}): Promise<unknown> {
const url = new URL(`${BASE_URL}${path}`)
for (const [key, value] of Object.entries(params)) {
if (value !== undefined) url.searchParams.set(key, value)
}
const response = await fetch(url, {
headers: { Authorization: `Bearer ${await gatewayToken()}` },
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
})
if (!response.ok) {
throw eviErrors.AI_GATEWAY_REQUEST_FAILED({ responseStatus: response.status, internal: { body: await response.text() } })
}
return await response.json()
}

/**
* Routing shared by every gateway call. Schedule turns answer to nobody in
* real time, so they sort on cost; every other surface has someone waiting and
Expand Down
20 changes: 1 addition & 19 deletions apps/evi/agent/tools/ai-gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,9 @@ import { useLogger } from 'evlog/eve'
import { defineDynamic, defineTool } from 'eve/tools'
import { z } from 'zod'
import { eviErrors, refusal } from '../lib/errors'
import { defaultReportTag, gatewayToken, reportQuery, scopedReport } from '../lib/gateway'
import { defaultReportTag, gatewayFetch, reportQuery, scopedReport } from '../lib/gateway'
import { canAccessAdminTools } from '../lib/trust'

const BASE_URL = 'https://ai-gateway.vercel.sh/v1'
const FETCH_TIMEOUT_MS = 10_000

async function gatewayFetch(path: string, params: Record<string, string | undefined> = {}): Promise<unknown> {
const url = new URL(`${BASE_URL}${path}`)
for (const [key, value] of Object.entries(params)) {
if (value !== undefined) url.searchParams.set(key, value)
}
const response = await fetch(url, {
headers: { Authorization: `Bearer ${await gatewayToken()}` },
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
})
if (!response.ok) {
throw eviErrors.AI_GATEWAY_REQUEST_FAILED({ responseStatus: response.status, internal: { body: await response.text() } })
}
return await response.json()
}

function notAvailable(tool: string) {
return refusal(eviErrors.TOOL_NOT_AVAILABLE({ tool, message: 'AI Gateway reporting is not available in this session.' }))
}
Expand Down
33 changes: 1 addition & 32 deletions apps/evi/agent/tools/capture.ts
Original file line number Diff line number Diff line change
@@ -1,43 +1,12 @@
import { runAgentBrowser, type EveToolContext } from '@agent-browser/eve/sandbox'
import type { EvlogError } from 'evlog'
import { useLogger } from 'evlog/eve'
import { defineDynamic, defineTool } from 'eve/tools'
import { z } from 'zod'
import { missingBlobTokenError, uploadSandboxImage } from '../lib/blob'
import { browserContext, CAPTURE_MARK, CAPTURE_SETTLE_MS, CAPTURE_VIEWPORTS, captureMarkdown, describeTarget, readTargetProbe, resolveTargetExpression, sensitiveCaptureReason, unresolvedTargetMessage, validateCaptureUrl, type CaptureTarget, type CaptureViewport } from '../lib/capture'
import { browserContext, captureFrame, SCREENSHOT_DIR, captureMarkdown, describeTarget, sensitiveCaptureReason, validateCaptureUrl, type CaptureTarget } from '../lib/capture'
import { eviErrors, refusal } from '../lib/errors'
import { canAccessAdminTools } from '../lib/trust'

const SCREENSHOT_DIR = '/workspace/screenshots'

interface FrameRequest {
readonly side: 'before' | 'after'
readonly target: CaptureTarget | null
readonly url: string
readonly viewport: CaptureViewport
}

async function captureFrame(
ctx: EveToolContext,
{ side, target, url, viewport }: FrameRequest,
): Promise<{ path: string, how: 'selector' | 'text' | null }> {
const { width, height } = CAPTURE_VIEWPORTS[viewport]
const path = `${SCREENSHOT_DIR}/${side}-${Date.now()}.png`
await runAgentBrowser(ctx, ['set', 'viewport', String(width), String(height)])
await runAgentBrowser(ctx, ['open', url])
await runAgentBrowser(ctx, ['wait', String(CAPTURE_SETTLE_MS)])
if (target === null) {
await runAgentBrowser(ctx, ['screenshot', path])
return { path, how: null }
}
const probe = readTargetProbe((await runAgentBrowser(ctx, ['eval', resolveTargetExpression(target)])).json)
if (!probe.found) throw eviErrors.CAPTURE_TARGET_UNRESOLVED({ message: unresolvedTargetMessage(target, probe) })
await runAgentBrowser(ctx, ['scrollintoview', `[${CAPTURE_MARK}]`])
await runAgentBrowser(ctx, ['wait', '500'])
await runAgentBrowser(ctx, ['screenshot', path])
return { path, how: probe.how }
}

// Frames publish to public URLs the moment the tool runs: autonomous turns
// never see it. Keep executes inline in the resolver (docs/notes.md).
export default defineDynamic({
Expand Down
17 changes: 6 additions & 11 deletions apps/evi/agent/tools/git.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import type { SessionAuthContext } from 'eve/context'
import { useLogger } from 'evlog/eve'
import type { DynamicResolveContext } from 'eve/tools'
import { defineDynamic, defineTool } from 'eve/tools'
Expand All @@ -7,15 +6,10 @@ import { eviErrors, refusal, type ToolRefusal } from '../lib/errors'
import { repositoryToken } from '../lib/github/credentials'
import { isValidRefName, pushBrokerPolicy, validatePushBranch } from '../lib/github/push'
import { cloneUrl, homeRepository, parseRepository, type Repository, repositorySlug } from '../lib/repo'
import { isMaintainer, isScheduleAppAuth } from '../lib/trust'
import { canAccessAdminTools } from '../lib/trust'
import { checkoutDir, installCommand, REPO_DIR, runOutput } from '../lib/workspace'
import { environment } from '../sandbox'

/** Maintainer and schedule-app turns ship code; nothing else reaches git over the network. */
function canShip(auth: SessionAuthContext | null): boolean {
return isMaintainer(auth) || isScheduleAppAuth(auth)
}

function notAllowed(tool: string) {
return refusal(eviErrors.TOOL_NOT_AVAILABLE({ tool, message: 'Only maintainer and schedule-app sessions may use git over the network.' }))
}
Expand All @@ -29,8 +23,9 @@ function notInstalled(repository: Repository) {
}

// Executes stay inline in the resolver (docs/notes.md).
// Maintainer and schedule-app turns ship code; nothing else reaches git over the network.
const resolveGitTools = (_event: unknown, ctx: DynamicResolveContext) => {
if (!canShip(ctx.session.auth.current)) return null
if (!canAccessAdminTools(ctx.session.auth.current)) return null
const home = repositorySlug(homeRepository())
return {
git__checkout: defineTool({
Expand All @@ -40,7 +35,7 @@ const resolveGitTools = (_event: unknown, ctx: DynamicResolveContext) => {
ref: z.string().optional().describe('Branch or commit to check out'),
}),
async execute(input, toolCtx) {
if (!canShip(toolCtx.session.auth.current)) return notAllowed('git__checkout')
if (!canAccessAdminTools(toolCtx.session.auth.current)) return notAllowed('git__checkout')
const log = useLogger(toolCtx)
const repository = parseRepository(input.repository)
if (repository === null) return notSlug(input.repository)
Expand Down Expand Up @@ -92,7 +87,7 @@ const resolveGitTools = (_event: unknown, ctx: DynamicResolveContext) => {
repository: z.string().min(1).describe('owner/repo already checked out'),
}),
async execute(input, toolCtx) {
if (!canShip(toolCtx.session.auth.current)) return notAllowed('git__install')
if (!canAccessAdminTools(toolCtx.session.auth.current)) return notAllowed('git__install')
const log = useLogger(toolCtx)
const repository = parseRepository(input.repository)
if (repository === null) return notSlug(input.repository)
Expand All @@ -116,7 +111,7 @@ const resolveGitTools = (_event: unknown, ctx: DynamicResolveContext) => {
repository: z.string().optional().describe(`owner/repo to push to; defaults to ${home}`),
}),
async execute(input, toolCtx) {
if (!canShip(toolCtx.session.auth.current)) return notAllowed('git__push')
if (!canAccessAdminTools(toolCtx.session.auth.current)) return notAllowed('git__push')
const log = useLogger(toolCtx)
const refuse = (refused: ToolRefusal) => {
log.set({ git: { branch: input.branch, pushed: false, reason: refused.code } })
Expand Down
Loading