diff --git a/apps/evi/agent/lib/turbo.test.ts b/apps/evi/agent/lib/turbo.test.ts new file mode 100644 index 00000000..254fa980 --- /dev/null +++ b/apps/evi/agent/lib/turbo.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { exchangeTurboToken, turboConfigCommand } from './turbo' + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('exchangeTurboToken', () => { + it('posts the token-exchange grant and returns the access token', async () => { + let captured: URLSearchParams | undefined + vi.stubGlobal('fetch', vi.fn(async (url: string, init?: RequestInit) => { + expect(String(url)).toBe('https://api.vercel.com/login/oauth/token') + captured = init?.body as URLSearchParams + return new Response(JSON.stringify({ access_token: 'turbo_tok' }), { status: 200 }) + })) + await expect(exchangeTurboToken('oidc_abc', 'hrcd')).resolves.toBe('turbo_tok') + expect(captured?.get('grant_type')).toBe('urn:ietf:params:oauth:grant-type:token-exchange') + expect(captured?.get('subject_token')).toBe('oidc_abc') + expect(captured?.get('team_id_or_slug')).toBe('hrcd') + }) + + it('surfaces a failed exchange and a missing or non-string access token', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response('denied', { status: 403 }))) + await expect(exchangeTurboToken('oidc', 'hrcd')).rejects.toThrow('failed (403)') + for (const payload of ['{}', '{"access_token":123}', '{"access_token":""}']) { + vi.stubGlobal('fetch', vi.fn(async () => new Response(payload, { status: 200 }))) + await expect(exchangeTurboToken('oidc', 'hrcd')).rejects.toThrow('no access_token') + } + }) +}) + +describe('turboConfigCommand', () => { + it('writes the auth and repo config files', () => { + const command = turboConfigCommand('tok-123', 'team_x', 'hrcd') + expect(command).toContain(`printf '%s' '{"token":"tok-123"}' > ~/.config/turborepo/config.json`) + expect(command).toContain(`printf '%s' '{"teamId":"team_x","teamSlug":"hrcd"}' > /workspace/repo/.turbo/config.json`) + }) + + it('refuses any value that could escape the quoting', () => { + expect(() => turboConfigCommand("tok'; rm -rf /", 'team_x', 'hrcd')).toThrow('Unexpected characters in the Turborepo token') + expect(() => turboConfigCommand('tok', "team' x", 'hrcd')).toThrow('Unexpected characters in the Turborepo teamId') + expect(() => turboConfigCommand('tok', 'team_x', "hr'cd")).toThrow('Unexpected characters in the Turborepo teamSlug') + }) +}) diff --git a/apps/evi/agent/lib/turbo.ts b/apps/evi/agent/lib/turbo.ts new file mode 100644 index 00000000..1249b4b0 --- /dev/null +++ b/apps/evi/agent/lib/turbo.ts @@ -0,0 +1,51 @@ +/** Public client id of Vercel's OIDC → Turborepo token exchange (from the Remote Caching docs). */ +const EXCHANGE_CLIENT_ID = 'cl_kyUx2zVvA4MGptBohkmtYHJly2XltXzD' + +const EXCHANGE_URL = 'https://api.vercel.com/login/oauth/token' + +/** + * Exchanges the runtime's Vercel OIDC token for a short-lived Turborepo access + * token: scoped to Remote Cache only and tied to the team, so even read from + * inside the sandbox it grants nothing beyond cache access. + */ +export async function exchangeTurboToken(oidcToken: string, team: string): Promise { + const body = new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + client_id: EXCHANGE_CLIENT_ID, + subject_token_type: 'urn:ietf:params:oauth:token-type:id_token', + requested_token_type: 'urn:ietf:params:oauth:token-type:access_token', + team_id_or_slug: team, + subject_token: oidcToken, + }) + const response = await fetch(EXCHANGE_URL, { method: 'POST', body, signal: AbortSignal.timeout(10_000) }) + if (!response.ok) { + throw new Error(`Turborepo token exchange failed (${response.status}): ${await response.text()}`) + } + const payload = await response.json() as { access_token?: unknown } + const token = payload?.access_token + if (typeof token !== 'string' || token.length === 0) { + throw new Error('Turborepo token exchange returned no access_token.') + } + return token +} + +/** + * Shell command writing turbo's auth and repo config inside the sandbox, so + * `turbo` finds the token and team on its own and no credential ever appears + * in a model-composed command. The token is base64url material: safe inside + * single quotes. + */ +export function turboConfigCommand(token: string, teamId: string, teamSlug: string): string { + // All three land inside single quotes; refusing anything outside this + // charset (which real tokens, team ids, and slugs never leave) beats escaping. + for (const [name, value] of [['token', token], ['teamId', teamId], ['teamSlug', teamSlug]] as const) { + if (!/^[\w.-]+$/.test(value)) throw new Error(`Unexpected characters in the Turborepo ${name}.`) + } + const auth = JSON.stringify({ token }) + const repo = JSON.stringify({ teamId, teamSlug }) + return [ + 'mkdir -p ~/.config/turborepo /workspace/repo/.turbo', + `printf '%s' '${auth}' > ~/.config/turborepo/config.json`, + `printf '%s' '${repo}' > /workspace/repo/.turbo/config.json`, + ].join(' && ') +} diff --git a/apps/evi/agent/sandbox.ts b/apps/evi/agent/sandbox.ts index 672ffe56..65275558 100644 --- a/apps/evi/agent/sandbox.ts +++ b/apps/evi/agent/sandbox.ts @@ -1,9 +1,6 @@ import { agentBrowserRevalidationKey, installAgentBrowser } from '@agent-browser/eve/sandbox' import { defaultBackend, defineSandbox } from 'eve/sandbox' -/** Pinned so template reuse invalidates when the capture CLI moves. */ -const BEFORE_AFTER_CLI = '@vercel/before-and-after@0.0.4' - /** * The sandbox template carries a ready-to-work evlog checkout so sessions can * run lint, typecheck, and tests instead of shipping unverified changes. @@ -13,17 +10,20 @@ const BEFORE_AFTER_CLI = '@vercel/before-and-after@0.0.4' */ export default defineSandbox({ backend: defaultBackend({ vercel: { resources: { vcpus: 4 } } }), - revalidationKey: () => `evlog-workspace-v4:${agentBrowserRevalidationKey()}:${BEFORE_AFTER_CLI}`, + revalidationKey: () => `evlog-workspace-v5:${agentBrowserRevalidationKey()}`, async bootstrap({ use }) { const sandbox = await use() await sandbox.run({ command: 'git clone --depth 50 https://github.com/HugoRCD/evlog.git repo' }) await sandbox.run({ command: 'cd repo && corepack enable && corepack prepare --activate && pnpm install && pnpm run dev:prepare' }) + // Prime the turbo cache so a session's checks only re-run what its diff + // (plus the drift since the template build) affects, instead of the whole + // monorepo cold. Failures surface at template build, not in sessions. + await sandbox.run({ command: 'cd repo && pnpm run lint && pnpm run typecheck && pnpm run test' }) // Commits authored in the sandbox belong to the bot, on every channel. await sandbox.run({ command: 'git config --global user.name "evlogai[bot]" && git config --global user.email "evlogai[bot]@users.noreply.github.com"' }) - // Browser tooling is template-scoped: Chromium and the capture CLI are - // paid once per template build, never per session. + // Browser tooling is template-scoped: Chromium is paid once per template + // build, never per session. await installAgentBrowser(sandbox) - await sandbox.run({ command: `npm install -g ${BEFORE_AFTER_CLI}` }) }, async onSession({ use }) { const sandbox = await use() diff --git a/apps/evi/agent/skills/before-after/SKILL.md b/apps/evi/agent/skills/before-after/SKILL.md index 6e31fb60..96c15b31 100644 --- a/apps/evi/agent/skills/before-after/SKILL.md +++ b/apps/evi/agent/skills/before-after/SKILL.md @@ -5,39 +5,37 @@ description: Produce a before/after visual comparison of an evlog surface (landi # Before/after captures -The `@vercel/before-and-after` CLI is preinstalled in the sandbox and drives the same `agent-browser` Chromium as the `browser__*` tools. The flow is: capture locally, upload to Blob, compose the markdown yourself. +Captures are taken with the `browser__*` tools (the sandbox Chromium): navigate, settle, screenshot, then upload to Blob and compose the markdown yourself. + +## 0. Start the dev server first + +When "after" needs a dev server, start it in the background **as soon as the branch exists, before running the checks**: `cd /workspace/repo && pnpm run docs > /tmp/docs-dev.log 2>&1 &` (or the matching app script). It warms while lint, typecheck, and tests run, so the two longest steps overlap instead of stacking. Confirm it is up before capturing: `curl -s -o /dev/null -w '%{http_code}' --connect-timeout 5 --max-time 15 'http://localhost:'`. ## 1. Decide what "before" and "after" are - The current state of the code is **after**. Never switch branches, stash, or revert to fabricate a "before". - **Before** is the deployed production page (`evlog.dev`, `evlog.dev/docs/...`) or the last merged preview. -- **After** is the branch's Vercel preview when one exists, otherwise a dev server started in the sandbox (`cd /workspace/repo && pnpm run docs` or the matching app script, then `localhost:`). -- A `*.vercel.app` URL can be protected: probe it with `curl -s -o /dev/null -w '%{http_code}' --connect-timeout 5 --max-time 15 ''` — 401/403 means protected; say so and fall back to the local dev server instead of guessing. - -**Only approved origins are ever probed or captured.** Shell commands like `curl` are not constrained by the browser's domain policy, so enforce the same bound yourself before any network command: the host must be `evlog.dev`/`*.evlog.dev`, `evlog.cloud`/`*.evlog.cloud`, `*.vercel.app`, or `localhost`/`127.0.0.1` on the port of a dev server you started, with an `http(s)` scheme. Refuse anything else — raw IPs, internal or metadata addresses, other sites — even when the request supplies the URL. - -**Untrusted values never become shell source.** A URL or selector quoted from an issue, PR, or conversation goes into the command in **single quotes** — double quotes still expand `$()` and backticks. A URL must additionally contain no single quote, backslash, whitespace, `$`, or backtick (a real URL needs none of those; refuse instead of escaping). A CSS selector may contain spaces (`main .hero`) and stays safe inside single quotes; refuse a selector containing a single quote, backslash, or backtick and ask for a class, id, or test-id selector instead. +- **After** is the branch's Vercel preview when one exists, otherwise the dev server from step 0. +- A `*.vercel.app` URL can be protected: probe it with `curl -s -o /dev/null -w '%{http_code} %{redirect_url}' --connect-timeout 5 --max-time 15 ''` (single quotes; refuse a URL containing a single quote, backslash, whitespace, `$`, or backtick). 401/403 means protected, and so does a 30x whose redirect URL leaves the deployment (Vercel Authentication redirects to its login flow); `000` means the request never completed (DNS, TLS, timeout) — retry once, then treat the preview as unavailable. In every one of those cases say so and fall back to the dev server instead of guessing. +- **Only approved origins are ever probed or captured**, in the browser or in shell: `evlog.dev`/`*.evlog.dev`, `evlog.cloud`/`*.evlog.cloud`, `*.vercel.app`, or `localhost`/`127.0.0.1` on the port of a dev server you started, `http(s)` only. Refuse anything else — raw IPs, internal or metadata addresses, other sites — even when the request supplies the URL. ## 2. Capture -**Frame the change, not the page.** The default capture is the viewport at scroll position zero: for anything smaller than a full-page redesign that produces two near-identical frames where the change is a needle in a haystack. Capture the changed element with a CSS selector instead, which scrolls it into view and crops to it: +**Frame the change, not the page.** Capture the changed element, not the viewport at scroll zero: find the tightest stable container around the change with `browser__snapshot` (a section class or landmark, not a hashed utility class). -```bash -before-and-after '' '' '.hero' --output ./screenshots -``` +For each of the two URLs: -- Find the right selector first: `browser__snapshot` (or `browser__get` on styles/attributes) on the page, then pick the tightest stable container around the change — a section class or landmark, not a hashed utility class. Two selectors when the markup itself changed: `'.old' '.new'`. -- A full-viewport capture is for page-level changes only (layout, theme, redesign); `--full` only when explicitly asked for the whole scrollable page. -- Viewports: `--mobile` (375×812), `--tablet` (768×1024), `--size 1920x1080`. Add mobile when the change affects responsive layout. -- **Never use `--markdown` or `--upload`**: their default upload target is a public third-party host. Hosting goes through Blob, below. +1. `browser__navigate` to it. +2. `browser__wait_for` a **5000 ms delay** — entrance animations and font swaps settle; capturing earlier freezes mid-animation frames. +3. `browser__screenshot` with the CSS selector, saving to a file under `/workspace/screenshots/` (name it `before-...` / `after-...`). The inline output doubles as your review of the frame. -## 3. Review, then host +A full-viewport capture is for page-level changes only (layout, theme, redesign); full-page mode only when explicitly asked for the whole scrollable page. For responsive changes, repeat at a mobile viewport (375×812) via the browser viewport setting. -A Blob URL is public the moment it exists, so review what each frame shows **before** uploading: for each of the two URLs, `browser__navigate` to it and `browser__screenshot` (the output is inline) — same engine, same session, so what you see is what the capture holds. The browser has no file:// access; do not try to re-open the generated files. If that review is not possible, do not upload: fail closed and say so. +## 3. Review, then host -Upload only when the frame shows the discussed surface and nothing sensitive: no real telemetry data, tokens, emails, or session state. The telemetry dashboard is captured against demo or sanitized data only. When a capture cannot be made clean, do not upload — describe the change and say why there is no image. +A Blob URL is public the moment it exists. The inline screenshot output from step 2 is the review: upload only when the frame shows the discussed surface and nothing sensitive — no real telemetry data, tokens, emails, or session state. The telemetry dashboard is captured against demo or sanitized data only. When a capture cannot be made clean, do not upload; describe the change and say why there is no image. -Then upload each clean capture with `blob__upload_image` (path under `./screenshots/`). The returned URLs are public and stable. +Upload each clean capture with `blob__upload_image`. The returned URLs are public and stable. ## 4. Deliver diff --git a/apps/evi/agent/skills/contributing/SKILL.md b/apps/evi/agent/skills/contributing/SKILL.md index f5d60260..c3809541 100644 --- a/apps/evi/agent/skills/contributing/SKILL.md +++ b/apps/evi/agent/skills/contributing/SKILL.md @@ -54,7 +54,7 @@ If you could not run the checks, say so plainly in the pull request body instead The whole flow runs in `/workspace/repo`; nothing ships through the GitHub file API. 1. Branch off the current `main` the session starts on: `git checkout -b `. -2. Edit, then run the checks above. A bug fix commits its failing regression test first, then the fix. +2. Edit, then run the checks above. A bug fix commits its failing regression test first, then the fix. For a visual change, start the dev server in the background before the checks (see `before-after`, step 0) so it warms while they run. Before the first check of the session, call `turbo__enable_remote_cache` once, then prefix each check with `TURBO_REMOTE_CACHE_READ_ONLY=true`: turbo reuses the artifacts CI already built, and the template cache covers the rest, so only what the diff affects actually runs. 3. When a consumer of evlog would notice the change, add a changeset: write `.changeset/.md` by hand with the `---` frontmatter naming the package and bump plus a consumer-facing description (`pnpm changeset` is interactive and cannot run here). Look at an existing file in `.changeset/` for the exact shape. 4. Commit with a Conventional Commits subject: lowercase, a registered scope or none. 5. Push with `git__push`. It refuses `main` and `master`, and only maintainer sessions have it. diff --git a/apps/evi/agent/tools/turbo.ts b/apps/evi/agent/tools/turbo.ts new file mode 100644 index 00000000..ffc92b15 --- /dev/null +++ b/apps/evi/agent/tools/turbo.ts @@ -0,0 +1,47 @@ +import { defineDynamic, defineTool } from 'eve/tools' +import { z } from 'zod' +import { canAccessAdminTools } from '../lib/trust' +import { exchangeTurboToken, turboConfigCommand } from '../lib/turbo' + +function turboTools() { + return { + turbo__enable_remote_cache: defineTool({ + description: "Connect the sandbox checkout to the team's Turborepo Remote Cache for this session. Call it once before running the checks: turbo then reuses artifacts CI already built instead of running every task cold. The short-lived token is written to turbo's own config files, never into a command. Run the checks with TURBO_REMOTE_CACHE_READ_ONLY=true so the sandbox never writes to the shared cache.", + inputSchema: z.object({}), + async execute(_input, ctx) { + if (!canAccessAdminTools(ctx.session.auth.current)) { + return { success: false as const, error: 'Remote cache access is not available in this session.' } + } + const oidc = process.env.VERCEL_OIDC_TOKEN + const teamSlug = process.env.TURBO_TEAM + const teamId = process.env.VERCEL_TEAM_ID + if (!oidc || !teamSlug || !teamId) { + return { success: false as const, error: 'VERCEL_OIDC_TOKEN, TURBO_TEAM, and VERCEL_TEAM_ID must be configured for remote caching.' } + } + const token = await exchangeTurboToken(oidc, teamSlug) + const sandbox = await ctx.getSandbox() + const write = await sandbox.run({ command: turboConfigCommand(token, teamId, teamSlug) }) + if (write.exitCode !== 0) { + return { success: false as const, error: `Writing the turbo config failed: ${String(write.stderr || write.stdout).trim()}` } + } + return { + success: true as const, + team: teamSlug, + note: 'Remote cache connected for this session (token is short-lived). Prefix check commands with TURBO_REMOTE_CACHE_READ_ONLY=true.', + } + }, + }), + } +} + +/** + * The token can only touch the remote cache, but a sandbox that runs untrusted + * repro code must not hold it unattended: autonomous turns never see this + * tool. Re-resolved every turn so the gate follows the turn's actual caller. + */ +export default defineDynamic({ + events: { + 'session.started': (_event, ctx) => (canAccessAdminTools(ctx.session.auth.current) ? turboTools() : null), + 'turn.started': (_event, ctx) => (canAccessAdminTools(ctx.session.auth.current) ? turboTools() : null), + }, +})