From 602db09ed1493b9e6aac73a5f15c60d0f84b2170 Mon Sep 17 00:00:00 2001 From: Anthony Shew Date: Mon, 10 Aug 2026 20:46:05 -0600 Subject: [PATCH] fix: Enable Eve examples pull requests --- apps/agents/agent/channels/github.ts | 5 +- apps/agents/agent/instructions.md | 1 + apps/agents/agent/lib/github.ts | 66 +++ apps/agents/agent/lib/repo.ts | 296 ++++++----- apps/agents/agent/sandbox.ts | 48 ++ .../agent/schedules/weekly_examples_audit.md | 4 +- .../agent/skills/examples_maintenance.md | 2 + .../agents/agent/tools/audit_example_tasks.ts | 13 +- .../agent/tools/audit_example_versions.ts | 21 +- .../agents/agent/tools/create_pull_request.ts | 468 +++++++++++------- .../agent/tools/find_package_versions.ts | 10 +- .../agent/tools/find_versioned_references.ts | 61 +-- apps/agents/agent/tools/inspect_example.ts | 32 +- apps/agents/agent/tools/list_examples.ts | 7 +- apps/agents/agent/tools/read_examples_file.ts | 21 +- apps/agents/agent/tools/run_example_script.ts | 9 +- .../agent/tools/update_example_lockfile.ts | 9 +- .../agents/agent/tools/write_examples_file.ts | 4 +- apps/agents/package.json | 1 + pnpm-lock.yaml | 3 + 20 files changed, 661 insertions(+), 420 deletions(-) create mode 100644 apps/agents/agent/lib/github.ts create mode 100644 apps/agents/agent/sandbox.ts diff --git a/apps/agents/agent/channels/github.ts b/apps/agents/agent/channels/github.ts index 7785b98135df3..84e7f01bce125 100644 --- a/apps/agents/agent/channels/github.ts +++ b/apps/agents/agent/channels/github.ts @@ -1,5 +1,8 @@ import { githubChannel } from "eve/channels/github"; +import { githubCredentials } from "../lib/github.js"; + export default githubChannel({ - botName: process.env.GITHUB_APP_SLUG + botName: process.env.GITHUB_APP_SLUG ?? "turborepo-eve-agent", + credentials: githubCredentials }); diff --git a/apps/agents/agent/instructions.md b/apps/agents/agent/instructions.md index 8a6192e0049e6..6b025ee1e3afb 100644 --- a/apps/agents/agent/instructions.md +++ b/apps/agents/agent/instructions.md @@ -15,3 +15,4 @@ You are the Turborepo examples maintenance agent. Your job is to keep the exampl - Do not downgrade or hold a direct dependency below the latest stable registry version because of compatibility concerns. If latest breaks, migrate the example until latest works. - Version bumps are not enough. When upgrading a framework, toolchain, or library, migrate the example to that ecosystem's current best-practice configuration and APIs instead of preserving deprecated patterns. - Do not stop with checkpoint summaries, partial progress reports, or "I'll continue" messages. For broad examples updates, keep working until every example has been updated, lockfiles are regenerated, and relevant non-persistent validation tasks have passed or produced a concrete external blocker. +- When an automated schedule produces changes, create a draft pull request with `create_pull_request`. Use an `agents/weekly-examples-YYYY-MM-DD` branch, a Conventional Commit title, and include the validation results in the pull request body. diff --git a/apps/agents/agent/lib/github.ts b/apps/agents/agent/lib/github.ts new file mode 100644 index 0000000000000..9297be46788cd --- /dev/null +++ b/apps/agents/agent/lib/github.ts @@ -0,0 +1,66 @@ +import { getVercelOidcToken } from "@vercel/oidc"; +import type { GitHubChannelCredentials } from "eve/channels/github"; + +const TOKEN_EXPIRY_SAFETY_WINDOW_MS = 30_000; + +interface GitHubStsResponse { + token: string; + expires_at: string; +} + +let cachedToken: { token: string; expiresAt: number } | null = null; + +export const githubCredentials: GitHubChannelCredentials = { + installationToken: getGitHubToken +}; + +export async function getGitHubToken(): Promise { + if ( + cachedToken !== null && + cachedToken.expiresAt - TOKEN_EXPIRY_SAFETY_WINDOW_MS > Date.now() + ) { + return cachedToken.token; + } + + const oidcToken = await getVercelOidcToken(); + const tokenExchangeUrl = process.env.GITHUB_TOKEN_EXCHANGE_URL; + if (!tokenExchangeUrl) { + throw new Error("GITHUB_TOKEN_EXCHANGE_URL is not configured."); + } + const response = await fetch(tokenExchangeUrl, { + method: "POST", + headers: { + authorization: `Bearer ${oidcToken}`, + "content-type": "application/json" + }, + body: JSON.stringify({ + owner: "vercel", + repo: "turborepo", + permissions: { + contents: "write", + pull_requests: "write" + } + }) + }); + + if (!response.ok) { + const detail = await response.text().catch(() => ""); + throw new Error( + `GitHub token exchange failed (${response.status} ${response.statusText})${detail === "" ? "" : `: ${detail}`}` + ); + } + + const body = (await response.json()) as Partial; + if (typeof body.token !== "string" || body.token === "") { + throw new Error("GitHub token exchange did not include a token."); + } + const expiresAt = Date.parse(String(body.expires_at)); + if (Number.isNaN(expiresAt)) { + throw new Error( + `GitHub token exchange had invalid expires_at: ${String(body.expires_at)}` + ); + } + + cachedToken = { token: body.token, expiresAt }; + return body.token; +} diff --git a/apps/agents/agent/lib/repo.ts b/apps/agents/agent/lib/repo.ts index 1ae1c12986a2f..fe7255825b8ce 100644 --- a/apps/agents/agent/lib/repo.ts +++ b/apps/agents/agent/lib/repo.ts @@ -1,19 +1,24 @@ -import { spawn } from "node:child_process"; -import { existsSync } from "node:fs"; -import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises"; -import path from "node:path"; +import path from "node:path/posix"; + +import type { SandboxSession } from "eve/sandbox"; export type JsonObject = Record; export interface CommandResult { command: string; cwd: string; - exitCode: number | null; + exitCode: number; timedOut: boolean; stdout: string; stderr: string; } +export interface DirectoryEntry { + name: string; + type: "directory" | "file" | "other"; +} + +const REPO_ROOT = "turborepo"; const MAX_OUTPUT_LENGTH = 20_000; const lockfileNames = new Set([ "bun.lock", @@ -23,66 +28,58 @@ const lockfileNames = new Set([ "yarn.lock" ]); -export async function getRepoRoot(): Promise { - if (process.env.TURBO_REPO_ROOT) { - return process.env.TURBO_REPO_ROOT; - } - - let current = process.cwd(); - while (true) { - const packageJsonPath = path.join(current, "package.json"); - const examplesPath = path.join(current, "examples"); - if (existsSync(packageJsonPath) && existsSync(examplesPath)) { - const packageJson = await readJsonFile(packageJsonPath); - if (packageJson.name === "turbo-monorepo") { - return current; - } - } - - const parent = path.dirname(current); - if (parent === current) { - throw new Error("Could not locate the Turborepo repository root."); - } - current = parent; +export async function getRepoRoot(sandbox: SandboxSession): Promise { + const result = await sandbox.run({ + command: "test -f package.json && test -d examples", + workingDirectory: REPO_ROOT + }); + if (result.exitCode !== 0) { + throw new Error("The Turborepo sandbox checkout is unavailable."); } + return REPO_ROOT; } -export async function listExampleNames(): Promise { - const repoRoot = await getRepoRoot(); - const examplesRoot = path.join(repoRoot, "examples"); - const entries = await readdir(examplesRoot, { withFileTypes: true }); - const names = await Promise.all( - entries - .filter((entry) => entry.isDirectory()) - .map(async (entry) => { - const packageJsonPath = path.join( - examplesRoot, - entry.name, - "package.json" - ); - return existsSync(packageJsonPath) ? entry.name : null; +export async function listExampleNames( + sandbox: SandboxSession +): Promise { + const repoRoot = await getRepoRoot(sandbox); + const files = await listTrackedFiles(sandbox, "examples"); + return [ + ...new Set( + files.flatMap((file) => { + const segments = file.split("/"); + return segments.length === 3 && segments[2] === "package.json" + ? [segments[1] as string] + : []; }) - ); - - return names.filter((name): name is string => name !== null).sort(); + ) + ].sort(); } -export async function getExamplePath(example: string): Promise { +export async function getExamplePath( + sandbox: SandboxSession, + example: string +): Promise { if (example.includes("/") || example.includes("\\") || example === "..") { throw new Error(`Invalid example name: ${example}`); } - const examples = await listExampleNames(); + const examples = await listExampleNames(sandbox); if (!examples.includes(example)) { throw new Error(`Unknown example: ${example}`); } - const repoRoot = await getRepoRoot(); - return path.join(repoRoot, "examples", example); + return path.join(await getRepoRoot(sandbox), "examples", example); } -export async function readJsonFile(filePath: string): Promise { - const content = await readFile(filePath, "utf8"); +export async function readJsonFile( + sandbox: SandboxSession, + filePath: string +): Promise { + const content = await sandbox.readTextFile({ path: filePath }); + if (content === null) { + throw new Error(`${filePath} does not exist.`); + } const value: unknown = JSON.parse(content); if (!isJsonObject(value)) { throw new Error(`${filePath} must contain a JSON object.`); @@ -91,144 +88,156 @@ export async function readJsonFile(filePath: string): Promise { } export async function readTextIfExists( + sandbox: SandboxSession, filePath: string, maxLines = 120 ): Promise { - if (!existsSync(filePath)) { - return null; - } + return sandbox.readTextFile({ + path: filePath, + startLine: 1, + endLine: maxLines + }); +} - const content = await readFile(filePath, "utf8"); - return content.split("\n").slice(0, maxLines).join("\n"); -} - -export async function findPackageJsonFiles(root: string): Promise { - const results: string[] = []; - - async function walk(directory: string): Promise { - const entries = await readdir(directory, { withFileTypes: true }); - await Promise.all( - entries.map(async (entry) => { - if ( - entry.name === "node_modules" || - entry.name === ".turbo" || - entry.name === "dist" - ) { - return; - } - - const entryPath = path.join(directory, entry.name); - if (entry.isDirectory()) { - await walk(entryPath); - return; - } - - if (entry.isFile() && entry.name === "package.json") { - results.push(entryPath); - } - }) - ); - } +export async function findPackageJsonFiles( + sandbox: SandboxSession, + root: string +): Promise { + const repoRoot = await getRepoRoot(sandbox); + const relativeRoot = path.relative(repoRoot, root); + const files = await listTrackedFiles(sandbox, relativeRoot); + return files + .filter((file) => path.basename(file) === "package.json") + .map((file) => path.join(repoRoot, file)); +} - await walk(root); - return results.sort(); +export async function listTrackedFiles( + sandbox: SandboxSession, + relativeRoot: string +): Promise { + const repoRoot = await getRepoRoot(sandbox); + const result = await sandbox.run({ + command: `git ls-files --cached --others --exclude-standard -- ${shellQuote(relativeRoot)}`, + workingDirectory: repoRoot + }); + assertCommandSucceeded(result, "List tracked repository files"); + const deleted = await sandbox.run({ + command: `git ls-files --deleted -- ${shellQuote(relativeRoot)}`, + workingDirectory: repoRoot + }); + assertCommandSucceeded(deleted, "List deleted repository files"); + const deletedFiles = new Set(deleted.stdout.split("\n").filter(Boolean)); + return result.stdout + .split("\n") + .filter((file) => file !== "" && !deletedFiles.has(file)) + .sort(); } -export function packageManagerName(packageManager: unknown): string | null { +export async function listDirectory( + sandbox: SandboxSession, + directory: string +): Promise { + const result = await sandbox.run({ + command: "find . -mindepth 1 -maxdepth 1 -printf '%f\\t%y\\n'", + workingDirectory: directory + }); + assertCommandSucceeded(result, `List ${directory}`); + return result.stdout + .split("\n") + .filter(Boolean) + .map((line) => { + const [name = "", type = ""] = line.split("\t"); + return { + name, + type: type === "d" ? "directory" : type === "f" ? "file" : "other" + }; + }); +} + +export async function fileExists( + sandbox: SandboxSession, + filePath: string +): Promise { + const result = await sandbox.run({ + command: `test -f ${shellQuote(filePath)}` + }); + return result.exitCode === 0; +} + +export function packageManagerName( + packageManager: unknown +): "bun" | "npm" | "pnpm" | "yarn" | null { if (typeof packageManager !== "string") { return null; } - return packageManager.split("@")[0] ?? null; + const name = packageManager.split("@")[0]; + return name === "bun" || name === "npm" || name === "pnpm" || name === "yarn" + ? name + : null; } export async function resolveExamplesFile( + sandbox: SandboxSession, relativePath: string ): Promise { - const repoRoot = await getRepoRoot(); + const repoRoot = await getRepoRoot(sandbox); if (path.isAbsolute(relativePath)) { throw new Error("Use a repository-relative path under examples/."); } - const resolved = path.resolve(repoRoot, relativePath); - const relative = path.relative(repoRoot, resolved); - if (relative.startsWith("..") || path.isAbsolute(relative)) { + const normalized = path.normalize(relativePath); + if (normalized === ".." || normalized.startsWith("../")) { throw new Error("Path must stay inside the repository."); } - if (relative !== "examples" && !relative.startsWith(`examples${path.sep}`)) { + if (normalized !== "examples" && !normalized.startsWith("examples/")) { throw new Error( "Only files under examples/ can be modified by this agent." ); } - return resolved; + return path.join(repoRoot, normalized); } export async function writeExamplesFile( + sandbox: SandboxSession, relativePath: string, content: string ): Promise<{ path: string; bytes: number }> { - const filePath = await resolveExamplesFile(relativePath); - const fileStat = existsSync(filePath) ? await stat(filePath) : null; - if (fileStat?.isDirectory()) { - throw new Error("Cannot overwrite a directory."); - } + const filePath = await resolveExamplesFile(sandbox, relativePath); if (lockfileNames.has(path.basename(filePath))) { throw new Error( "Lockfiles must be updated by running the package manager, not by direct writes." ); } - await mkdir(path.dirname(filePath), { recursive: true }); - await writeFile(filePath, content, "utf8"); + await sandbox.writeTextFile({ path: filePath, content }); return { - path: path.relative(await getRepoRoot(), filePath), + path: path.relative(await getRepoRoot(sandbox), filePath), bytes: Buffer.byteLength(content) }; } export async function runCommand( + sandbox: SandboxSession, command: string, args: string[], cwd: string, timeoutMs: number ): Promise { - return new Promise((resolve, reject) => { - const child = spawn(command, args, { cwd, shell: false }); - const commandLine = [command, ...args].join(" "); - let stdout = ""; - let stderr = ""; - let timedOut = false; - - const timeout = setTimeout(() => { - timedOut = true; - child.kill("SIGTERM"); - }, timeoutMs); - - child.stdout.on("data", (chunk: Buffer) => { - stdout = truncateOutput(stdout + chunk.toString("utf8")); - }); - - child.stderr.on("data", (chunk: Buffer) => { - stderr = truncateOutput(stderr + chunk.toString("utf8")); - }); - - child.on("error", (error) => { - clearTimeout(timeout); - reject(error); - }); - - child.on("close", (exitCode) => { - clearTimeout(timeout); - resolve({ - command: commandLine, - cwd, - exitCode, - timedOut, - stdout, - stderr - }); - }); + const timeoutSeconds = Math.max(1, Math.ceil(timeoutMs / 1_000)); + const commandLine = [command, ...args].map(shellQuote).join(" "); + const result = await sandbox.run({ + command: `timeout --signal=TERM ${timeoutSeconds}s ${commandLine}`, + workingDirectory: cwd }); + return { + command: commandLine, + cwd, + exitCode: result.exitCode, + timedOut: result.exitCode === 124, + stdout: truncateOutput(result.stdout), + stderr: truncateOutput(result.stderr) + }; } export function isJsonObject(value: unknown): value is JsonObject { @@ -239,6 +248,19 @@ export function pickJsonObject(value: unknown): JsonObject | null { return isJsonObject(value) ? value : null; } +function shellQuote(value: string): string { + return `'${value.replaceAll("'", `'\\''`)}'`; +} + +function assertCommandSucceeded( + result: { exitCode: number; stderr: string }, + operation: string +): void { + if (result.exitCode !== 0) { + throw new Error(`${operation} failed: ${result.stderr}`); + } +} + function truncateOutput(output: string): string { if (output.length <= MAX_OUTPUT_LENGTH) { return output; diff --git a/apps/agents/agent/sandbox.ts b/apps/agents/agent/sandbox.ts new file mode 100644 index 0000000000000..e1cd501ef75c6 --- /dev/null +++ b/apps/agents/agent/sandbox.ts @@ -0,0 +1,48 @@ +import { defineSandbox, type SandboxSession } from "eve/sandbox"; + +const repository = "https://github.com/vercel/turborepo.git"; +const checkout = "turborepo"; + +async function runOrThrow(sandbox: SandboxSession, command: string) { + const result = await sandbox.run({ command }); + if (result.exitCode !== 0) { + throw new Error(`${command} failed: ${result.stderr}`); + } +} + +export default defineSandbox({ + revalidationKey: () => "turborepo-main-v1", + async bootstrap({ use }) { + const sandbox = await use(); + await runOrThrow( + sandbox, + `git clone --depth=1 --branch=main ${repository} ${checkout}` + ); + }, + async onSession({ use }) { + const sandbox = await use(); + await runOrThrow( + sandbox, + `git -C ${checkout} fetch --depth=1 origin main && git -C ${checkout} reset --hard FETCH_HEAD && git -C ${checkout} clean -ffd` + ); + if (process.env.VERCEL) { + await sandbox.setNetworkPolicy({ + allow: ["*"], + subnets: { + deny: [ + "0.0.0.0/8", + "10.0.0.0/8", + "100.64.0.0/10", + "127.0.0.0/8", + "169.254.0.0/16", + "172.16.0.0/12", + "192.168.0.0/16", + "::1/128", + "fc00::/7", + "fe80::/10" + ] + } + }); + } + } +}); diff --git a/apps/agents/agent/schedules/weekly_examples_audit.md b/apps/agents/agent/schedules/weekly_examples_audit.md index 560f636662f50..01e9b8c58e5e3 100644 --- a/apps/agents/agent/schedules/weekly_examples_audit.md +++ b/apps/agents/agent/schedules/weekly_examples_audit.md @@ -2,4 +2,6 @@ cron: "0 14 * * 1" --- -Audit the Turborepo examples for stale dependency versions, README instructions that do not match package scripts, missing or inconsistent `turbo.json` tasks, and package-manager drift. Produce a concise report with the examples inspected, findings, and recommended follow-up changes. Do not write files during the scheduled audit unless a human asks for a specific fix later. +Maintain every Turborepo example. Audit and update all stale dependencies, package-manager pins, Node engines, README instructions, versioned references, and inconsistent `turbo.json` tasks. Use exact latest stable versions, apply required best-practice migrations, regenerate lockfiles with each example's package manager, and run every relevant non-persistent validation task. Fix validation failures rather than stopping at an audit report. + +When changes exist, call `create_pull_request` to open a draft pull request against `vercel/turborepo`. Summarize changed examples and validation results in the body. The tool supplies the scheduled branch, required Conventional Commit title, and commit message. If there are no changes, finish without creating a pull request. diff --git a/apps/agents/agent/skills/examples_maintenance.md b/apps/agents/agent/skills/examples_maintenance.md index e79410b80f99c..68f0752286d3c 100644 --- a/apps/agents/agent/skills/examples_maintenance.md +++ b/apps/agents/agent/skills/examples_maintenance.md @@ -76,6 +76,7 @@ Use this workflow for any request to inspect, update, modernize, validate, or re 6. Use `write_examples_file` for non-lockfile example changes. 7. Use `update_example_lockfile` after dependency or package-manager changes. 8. Use `run_example_script` for each relevant non-persistent validation task. +9. Use `create_pull_request` after scheduled maintenance when the sandbox contains changes. It automatically includes every changed file under `examples/` and returns without creating a pull request when there are no changes. ## Reporting @@ -84,3 +85,4 @@ Use this workflow for any request to inspect, update, modernize, validate, or re - Do not ask the user to choose safe vs full updates. The default is full latest exact pins across all examples. - Do not report “latest-compatible” fallbacks as completion. Completion requires exact latest direct pins or a true external availability blocker. - Report only after the completion contract is satisfied. Avoid interim status updates unless a tool or channel requires visible progress. +- For scheduled runs, open a draft pull request on an `agents/weekly-examples-YYYY-MM-DD` branch with a Conventional Commit title and the validation results in its body. diff --git a/apps/agents/agent/tools/audit_example_tasks.ts b/apps/agents/agent/tools/audit_example_tasks.ts index 6f5c2907e0a77..0a5b94820b2c8 100644 --- a/apps/agents/agent/tools/audit_example_tasks.ts +++ b/apps/agents/agent/tools/audit_example_tasks.ts @@ -1,4 +1,4 @@ -import path from "node:path"; +import path from "node:path/posix"; import { defineTool } from "eve/tools"; import { z } from "zod"; @@ -29,13 +29,18 @@ export default defineTool({ .min(1) .describe("Directory name under examples/, for example 'basic'.") }), - async execute({ example }) { - const examplePath = await getExamplePath(example); + async execute({ example }, ctx) { + const sandbox = await ctx.getSandbox(); + const examplePath = await getExamplePath(sandbox, example); const packageJson = await readJsonFile( + sandbox, path.join(examplePath, "package.json") ); const scripts = pickJsonObject(packageJson.scripts) ?? {}; - const turboJson = await readJsonFile(path.join(examplePath, "turbo.json")); + const turboJson = await readJsonFile( + sandbox, + path.join(examplePath, "turbo.json") + ); const tasks = collectTasks(turboJson); const scriptNames = Object.keys(scripts).sort(); const turboTasks = Object.entries(tasks).map( diff --git a/apps/agents/agent/tools/audit_example_versions.ts b/apps/agents/agent/tools/audit_example_versions.ts index e44a6894d949c..02719f580de19 100644 --- a/apps/agents/agent/tools/audit_example_versions.ts +++ b/apps/agents/agent/tools/audit_example_versions.ts @@ -1,4 +1,4 @@ -import path from "node:path"; +import path from "node:path/posix"; import { defineTool } from "eve/tools"; import { z } from "zod"; @@ -77,24 +77,23 @@ export default defineTool({ "Whether to include the latest active Node LTS version for engines.node review." ) }), - async execute({ - example, - includeDependencies, - includePackageManager, - includeNode - }) { - const repoRoot = await getRepoRoot(); + async execute( + { example, includeDependencies, includePackageManager, includeNode }, + ctx + ) { + const sandbox = await ctx.getSandbox(); + const repoRoot = await getRepoRoot(sandbox); const auditRoot = example - ? await getExamplePath(example) + ? await getExamplePath(sandbox, example) : path.join(repoRoot, "examples"); - const packageJsonFiles = await findPackageJsonFiles(auditRoot); + const packageJsonFiles = await findPackageJsonFiles(sandbox, auditRoot); const latestNodeLts = includeNode ? await fetchLatestNodeLts() : null; const dependencies: PackageVersionFinding[] = []; const packageManagers: PackageManagerFinding[] = []; const nodeEngines: NodeFinding[] = []; for (const packageJsonFile of packageJsonFiles) { - const packageJson = await readJsonFile(packageJsonFile); + const packageJson = await readJsonFile(sandbox, packageJsonFile); const relativePath = path.relative(repoRoot, packageJsonFile); if (includePackageManager) { diff --git a/apps/agents/agent/tools/create_pull_request.ts b/apps/agents/agent/tools/create_pull_request.ts index 0e6ac98b4c8cb..b10576ed5e8c5 100644 --- a/apps/agents/agent/tools/create_pull_request.ts +++ b/apps/agents/agent/tools/create_pull_request.ts @@ -1,49 +1,40 @@ -import { createSign } from "node:crypto"; +import type { SandboxSession } from "eve/sandbox"; import { defineTool } from "eve/tools"; -import { always } from "eve/tools/approval"; import { z } from "zod"; -const filePathSchema = z - .string() - .min(1) - .refine((path) => !path.startsWith("/") && !path.includes(".."), { - message: "Use a relative path without '..'." - }); +import { getGitHubToken } from "../lib/github.js"; + +const owner = "vercel"; +const repo = "turborepo"; +const baseBranch = "main"; +const checkout = "turborepo"; const inputSchema = z.object({ - owner: z.string().min(1), - repo: z.string().min(1), - baseBranch: z.string().min(1).default("main"), branchName: z .string() - .regex(/^agents\/[A-Za-z0-9._/-]+$/, "Branch must start with agents/"), - title: z.string().min(1), - body: z.string().default(""), - commitMessage: z.string().min(1), - draft: z.boolean().default(false), - files: z - .array( - z.object({ - path: filePathSchema.describe( - "Path to write in the GitHub repository." - ), - sandboxPath: filePathSchema - .optional() - .describe("Path to read from the sandbox. Defaults to path.") - }) - ) - .min(1) + .regex(/^agents\/[A-Za-z0-9._/-]+$/, "Branch must start with agents/") + .optional() + .describe( + "Branch for an interactive run. Scheduled runs always use the current UTC date." + ), + body: z.string().default("") }); type RefResponse = { object?: { sha?: string } }; type CommitResponse = { tree?: { sha?: string } }; +type CompareResponse = { merge_base_commit?: { sha?: string } }; type ShaResponse = { sha?: string }; -type InstallationTokenResponse = { expires_at?: string; token?: string }; -type PullRequestResponse = { html_url?: string; number?: number }; - -let cachedInstallationToken: - | { expiresAt: number; installationId: number; token: string } - | undefined; +type PullRequestResponse = { + head?: { sha?: string }; + html_url?: string; + number?: number; +}; +type TreeEntry = { + path: string; + mode: "100644" | "100755"; + type: "blob"; + sha: string | null; +}; class GitHubApiError extends Error { constructor( @@ -54,22 +45,6 @@ class GitHubApiError extends Error { } } -function installationId() { - const value = process.env.GITHUB_INSTALLATION_ID; - if (!value) { - throw new Error( - "Set GITHUB_INSTALLATION_ID before creating pull requests." - ); - } - - const parsed = Number(value); - if (!Number.isInteger(parsed)) { - throw new TypeError("GITHUB_INSTALLATION_ID must be an integer."); - } - - return parsed; -} - function repoPath(owner: string, repo: string, path: string) { return `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}${path}`; } @@ -79,77 +54,6 @@ function requireSha(value: string | undefined, label: string) { return value; } -function requireEnv(name: string) { - const value = process.env[name]; - if (!value) throw new Error(`Set ${name} before creating pull requests.`); - return value; -} - -function base64Url(value: Buffer | string) { - return Buffer.from(value) - .toString("base64") - .replace(/[=]/g, "") - .replace(/\+/g, "-") - .replace(/\//g, "_"); -} - -function createGitHubAppJwt() { - const now = Math.floor(Date.now() / 1000); - const privateKey = requireEnv("GITHUB_APP_PRIVATE_KEY").replace(/\\n/g, "\n"); - const payload = { - iat: now - 60, - exp: now + 9 * 60, - iss: requireEnv("GITHUB_APP_ID") - }; - const unsigned = `${base64Url(JSON.stringify({ alg: "RS256", typ: "JWT" }))}.${base64Url(JSON.stringify(payload))}`; - const signature = createSign("RSA-SHA256").update(unsigned).sign(privateKey); - - return `${unsigned}.${base64Url(signature)}`; -} - -async function getInstallationToken() { - const id = installationId(); - if ( - cachedInstallationToken?.installationId === id && - cachedInstallationToken.expiresAt > Date.now() + 60_000 - ) { - return cachedInstallationToken.token; - } - - const response = await fetch( - `https://api.github.com/app/installations/${id}/access_tokens`, - { - method: "POST", - headers: { - accept: "application/vnd.github+json", - authorization: `Bearer ${createGitHubAppJwt()}`, - "x-github-api-version": "2022-11-28" - } - } - ); - - if (!response.ok) { - throw new Error( - `GitHub token request failed with ${response.status}: ${await response.text()}` - ); - } - - const body = (await response.json()) as InstallationTokenResponse; - if (!body.token || !body.expires_at) { - throw new Error( - "GitHub token response did not include token and expires_at." - ); - } - - cachedInstallationToken = { - expiresAt: Date.parse(body.expires_at), - installationId: id, - token: body.token - }; - - return body.token; -} - async function github(input: { body?: unknown; method: "GET" | "PATCH" | "POST"; @@ -163,7 +67,7 @@ async function github(input: { method: input.method, headers: { accept: "application/vnd.github+json", - authorization: `Bearer ${await getInstallationToken()}`, + authorization: `Bearer ${await getGitHubToken()}`, "content-type": "application/json", "x-github-api-version": "2022-11-28" }, @@ -183,69 +87,86 @@ async function github(input: { export default defineTool({ description: - "Create a GitHub pull request from selected sandbox files. Use only after editing files in the sandbox and choosing an agents/* branch name.", + "Create a vercel/turborepo pull request containing every sandbox change under examples/. Returns without creating a PR when there are no changes.", inputSchema, - approval: always(), + approval: ({ session }) => { + return isAppPrincipal(session.auth.current) + ? "not-applicable" + : "user-approval"; + }, async execute(input, ctx) { const sandbox = await ctx.getSandbox(); - const baseRef = await github({ + const branchName = isAppPrincipal(ctx.session.auth.current) + ? scheduledBranchName() + : input.branchName; + if (!branchName) { + throw new Error("Interactive pull requests require an agents/* branch."); + } + const changedFiles = await listChangedFiles(sandbox); + if (changedFiles.length === 0) { + return { created: false, reason: "No changes under examples/." }; + } + + const existingPullRequests = await github({ method: "GET", - owner: input.owner, - repo: input.repo, - path: `/git/ref/heads/${input.baseBranch}` + owner, + repo, + path: `/pulls?state=open&head=${owner}%3A${encodeURIComponent(branchName)}` }); - const baseCommitSha = requireSha(baseRef.object?.sha, "base ref SHA"); + const existingPullRequest = existingPullRequests[0]; - let branchCommitSha = baseCommitSha; - try { - const branchRef = await github({ - method: "GET", - owner: input.owner, - repo: input.repo, - path: `/git/ref/heads/${input.branchName}` - }); - branchCommitSha = requireSha(branchRef.object?.sha, "branch ref SHA"); - } catch (error) { - if (!(error instanceof GitHubApiError) || error.status !== 404) { - throw error; - } - - await github({ - method: "POST", - owner: input.owner, - repo: input.repo, - path: "/git/refs", - body: { - ref: `refs/heads/${input.branchName}`, - sha: baseCommitSha - } - }); + const checkoutSha = (await runGit(sandbox, "git rev-parse HEAD")).trim(); + const comparison = await github({ + method: "GET", + owner, + repo, + path: `/compare/${checkoutSha}...${baseBranch}` + }); + if (comparison.merge_base_commit?.sha !== checkoutSha) { + throw new Error( + "The sandbox checkout is not based on vercel/turborepo main." + ); } const branchCommit = await github({ method: "GET", - owner: input.owner, - repo: input.repo, - path: `/git/commits/${branchCommitSha}` + owner, + repo, + path: `/git/commits/${checkoutSha}` }); const baseTreeSha = requireSha(branchCommit.tree?.sha, "base tree SHA"); - const tree = await Promise.all( - input.files.map(async (file) => { - const content = await sandbox.readTextFile({ - path: file.sandboxPath ?? file.path + const tree: TreeEntry[] = await Promise.all( + changedFiles.map(async (file) => { + if (file.deleted) { + return { + path: file.path, + mode: file.mode, + type: "blob", + sha: null + }; + } + + const content = await sandbox.readBinaryFile({ + path: `${checkout}/${file.path}` }); + if (content === null) { + throw new Error(`Changed file ${file.path} does not exist.`); + } const blob = await github({ method: "POST", - owner: input.owner, - repo: input.repo, + owner, + repo, path: "/git/blobs", - body: { content, encoding: "utf-8" } + body: { + content: Buffer.from(content).toString("base64"), + encoding: "base64" + } }); return { path: file.path, - mode: "100644", + mode: file.mode, type: "blob", sha: requireSha(blob.sha, "blob SHA") }; @@ -254,8 +175,8 @@ export default defineTool({ const newTree = await github({ method: "POST", - owner: input.owner, - repo: input.repo, + owner, + repo, path: "/git/trees", body: { base_tree: baseTreeSha, @@ -263,46 +184,209 @@ export default defineTool({ } }); - const commit = await github({ - method: "POST", - owner: input.owner, - repo: input.repo, - path: "/git/commits", - body: { - message: input.commitMessage, - tree: requireSha(newTree.sha, "tree SHA"), - parents: [branchCommitSha] + const newTreeSha = requireSha(newTree.sha, "tree SHA"); + if (newTreeSha === baseTreeSha) { + return { + created: false, + reason: "No effective changes under examples/." + }; + } + + if (existingPullRequest) { + const existingCommit = await github({ + method: "GET", + owner, + repo, + path: `/git/commits/${requireSha(existingPullRequest.head?.sha, "pull request head SHA")}` + }); + if (existingCommit.tree?.sha !== newTreeSha) { + throw new Error( + `Pull request ${existingPullRequest.html_url ?? existingPullRequest.number} already uses ${branchName} with different changes.` + ); } - }); - const newCommitSha = requireSha(commit.sha, "commit SHA"); - - await github({ - method: "PATCH", - owner: input.owner, - repo: input.repo, - path: `/git/refs/heads/${input.branchName}`, - body: { sha: newCommitSha, force: false } - }); + return { + created: false, + existing: true, + number: existingPullRequest.number, + url: existingPullRequest.html_url, + branch: branchName, + commit: existingPullRequest.head?.sha + }; + } + + let newCommitSha: string; + try { + const branchRef = await github({ + method: "GET", + owner, + repo, + path: `/git/ref/heads/${branchName}` + }); + newCommitSha = requireSha(branchRef.object?.sha, "branch ref SHA"); + const existingCommit = await github({ + method: "GET", + owner, + repo, + path: `/git/commits/${newCommitSha}` + }); + if (existingCommit.tree?.sha !== newTreeSha) { + throw new Error( + `Branch ${branchName} already exists with different changes.` + ); + } + } catch (error) { + if (!(error instanceof GitHubApiError) || error.status !== 404) { + throw error; + } + + const commit = await github({ + method: "POST", + owner, + repo, + path: "/git/commits", + body: { + message: "chore: Update Turborepo examples", + tree: newTreeSha, + parents: [checkoutSha] + } + }); + newCommitSha = requireSha(commit.sha, "commit SHA"); + + await github({ + method: "POST", + owner, + repo, + path: "/git/refs", + body: { + ref: `refs/heads/${branchName}`, + sha: newCommitSha + } + }); + } const pullRequest = await github({ method: "POST", - owner: input.owner, - repo: input.repo, + owner, + repo, path: "/pulls", body: { - title: input.title, + title: "chore: Update Turborepo examples", body: input.body, - head: input.branchName, - base: input.baseBranch, - draft: input.draft + head: branchName, + base: baseBranch, + draft: true } }); return { + created: true, number: pullRequest.number, url: pullRequest.html_url, - branch: input.branchName, + branch: branchName, commit: newCommitSha }; } }); + +async function listChangedFiles( + sandbox: SandboxSession +): Promise< + Array<{ path: string; deleted: boolean; mode: "100644" | "100755" }> +> { + const modified = await runGit( + sandbox, + "git diff --no-renames --name-only --diff-filter=ACMRTUXB HEAD -- examples" + ); + const deleted = await runGit( + sandbox, + "git diff --no-renames --name-only --diff-filter=D HEAD -- examples" + ); + const untracked = await runGit( + sandbox, + "git ls-files --others --exclude-standard -- examples" + ); + const deletedFiles = new Set(deleted.split("\n").filter(Boolean)); + const paths = [ + ...new Set( + [modified, deleted, untracked].flatMap((output) => + output.split("\n").filter(Boolean) + ) + ) + ].sort(); + + return Promise.all( + paths.map(async (file) => ({ + path: file, + deleted: deletedFiles.has(file), + mode: await fileMode(sandbox, file) + })) + ); +} + +async function fileMode( + sandbox: SandboxSession, + file: string +): Promise<"100644" | "100755"> { + const deleted = await runGit( + sandbox, + `git diff --no-renames --name-only --diff-filter=D HEAD -- ${shellQuote(file)}` + ); + if (deleted.trim() === "") { + const validation = await sandbox.run({ + command: `resolved=$(realpath -- ${shellQuote(file)}) && case "$resolved" in /workspace/turborepo/examples/*) test -f "$resolved" && test ! -L ${shellQuote(file)} ;; *) exit 1 ;; esac`, + workingDirectory: checkout + }); + if (validation.exitCode !== 0) { + throw new Error(`Changed path ${file} is not a regular examples file.`); + } + const executable = await sandbox.run({ + command: `test -x ${shellQuote(file)}`, + workingDirectory: checkout + }); + return executable.exitCode === 0 ? "100755" : "100644"; + } + const output = await runGit( + sandbox, + `git ls-files --stage -- ${shellQuote(file)}` + ); + if (output.startsWith("120000 ")) { + throw new Error(`Changed path ${file} is a symbolic link.`); + } + return output.startsWith("100755 ") ? "100755" : "100644"; +} + +async function runGit( + sandbox: SandboxSession, + command: string +): Promise { + const result = await sandbox.run({ command, workingDirectory: checkout }); + if (result.exitCode !== 0) { + throw new Error(`${command} failed: ${result.stderr}`); + } + return result.stdout; +} + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", `'\\''`)}'`; +} + +function scheduledBranchName(): string { + return `agents/weekly-examples-${new Date().toISOString().slice(0, 10)}`; +} + +function isAppPrincipal( + auth: + | { + authenticator: string; + principalId: string; + principalType: string; + } + | null + | undefined +): boolean { + return ( + auth?.authenticator === "app" && + auth.principalId === "eve:app" && + auth.principalType === "runtime" + ); +} diff --git a/apps/agents/agent/tools/find_package_versions.ts b/apps/agents/agent/tools/find_package_versions.ts index 94919fa91c548..fe4d950116844 100644 --- a/apps/agents/agent/tools/find_package_versions.ts +++ b/apps/agents/agent/tools/find_package_versions.ts @@ -1,4 +1,4 @@ -import path from "node:path"; +import path from "node:path/posix"; import { defineTool } from "eve/tools"; import { z } from "zod"; @@ -26,15 +26,17 @@ export default defineTool({ .min(1) .describe("Package name to search for, for example 'turbo'.") }), - async execute({ packageName }) { - const repoRoot = await getRepoRoot(); + async execute({ packageName }, ctx) { + const sandbox = await ctx.getSandbox(); + const repoRoot = await getRepoRoot(sandbox); const packageJsonFiles = await findPackageJsonFiles( + sandbox, path.join(repoRoot, "examples") ); const matches: Array<{ path: string; field: string; version: string }> = []; for (const packageJsonFile of packageJsonFiles) { - const packageJson = await readJsonFile(packageJsonFile); + const packageJson = await readJsonFile(sandbox, packageJsonFile); for (const field of dependencyFields) { const dependencies = packageJson[field]; if (!isJsonObject(dependencies)) { diff --git a/apps/agents/agent/tools/find_versioned_references.ts b/apps/agents/agent/tools/find_versioned_references.ts index 4fd8c1f0a37a8..d332d3da72161 100644 --- a/apps/agents/agent/tools/find_versioned_references.ts +++ b/apps/agents/agent/tools/find_versioned_references.ts @@ -1,11 +1,9 @@ -import { existsSync } from "node:fs"; -import { readdir, readFile } from "node:fs/promises"; -import path from "node:path"; +import path from "node:path/posix"; import { defineTool } from "eve/tools"; import { z } from "zod"; -import { getExamplePath, getRepoRoot } from "../lib/repo.js"; +import { getExamplePath, getRepoRoot, listTrackedFiles } from "../lib/repo.js"; interface VersionReference { path: string; @@ -54,16 +52,23 @@ export default defineTool({ "Optional directory name under examples/. Omit to scan every example." ) }), - async execute({ example }) { - const repoRoot = await getRepoRoot(); + async execute({ example }, ctx) { + const sandbox = await ctx.getSandbox(); + const repoRoot = await getRepoRoot(sandbox); const scanRoot = example - ? await getExamplePath(example) + ? await getExamplePath(sandbox, example) : path.join(repoRoot, "examples"); - const files = await findTextFiles(scanRoot); + const relativeRoot = path.relative(repoRoot, scanRoot); + const files = (await listTrackedFiles(sandbox, relativeRoot)) + .filter(isTextFile) + .map((file) => path.join(repoRoot, file)); const references: VersionReference[] = []; for (const file of files) { - const content = await readFile(file, "utf8"); + const content = await sandbox.readTextFile({ path: file }); + if (content === null) { + continue; + } const relativePath = path.relative(repoRoot, file); const lines = content.split("\n"); for (const [index, line] of lines.entries()) { @@ -75,38 +80,14 @@ export default defineTool({ } }); -async function findTextFiles(directory: string): Promise { - if (!existsSync(directory)) { - return []; - } - - const results: string[] = []; - const entries = await readdir(directory, { withFileTypes: true }); - await Promise.all( - entries.map(async (entry) => { - if (skippedDirectories.has(entry.name) || skippedFiles.has(entry.name)) { - return; - } - - const entryPath = path.join(directory, entry.name); - if (entry.isDirectory()) { - results.push(...(await findTextFiles(entryPath))); - return; - } - - if (!entry.isFile()) { - return; - } - - if ( - entry.name === "Dockerfile" || - textFileExtensions.has(path.extname(entry.name)) - ) { - results.push(entryPath); - } - }) +function isTextFile(file: string): boolean { + const parts = file.split("/"); + return ( + !parts.some((part) => skippedDirectories.has(part)) && + !skippedFiles.has(path.basename(file)) && + (path.basename(file) === "Dockerfile" || + textFileExtensions.has(path.extname(file))) ); - return results.sort(); } function findReferencesInLine( diff --git a/apps/agents/agent/tools/inspect_example.ts b/apps/agents/agent/tools/inspect_example.ts index d580c8249dfeb..7a51edfcdb980 100644 --- a/apps/agents/agent/tools/inspect_example.ts +++ b/apps/agents/agent/tools/inspect_example.ts @@ -1,12 +1,13 @@ -import { existsSync } from "node:fs"; -import { readdir } from "node:fs/promises"; -import path from "node:path"; +import path from "node:path/posix"; import { defineTool } from "eve/tools"; import { z } from "zod"; import { + fileExists, getExamplePath, + getRepoRoot, + listDirectory, packageManagerName, pickJsonObject, readJsonFile, @@ -22,31 +23,38 @@ export default defineTool({ .min(1) .describe("Directory name under examples/, for example 'basic'.") }), - async execute({ example }) { - const examplePath = await getExamplePath(example); + async execute({ example }, ctx) { + const sandbox = await ctx.getSandbox(); + const examplePath = await getExamplePath(sandbox, example); + const repoRoot = await getRepoRoot(sandbox); const packageJson = await readJsonFile( + sandbox, path.join(examplePath, "package.json") ); const turboJsonPath = path.join(examplePath, "turbo.json"); - const entries = await readdir(examplePath, { withFileTypes: true }); + const entries = await listDirectory(sandbox, examplePath); return { example, - path: path.relative(process.cwd(), examplePath), + path: path.relative(repoRoot, examplePath), packageManager: packageJson.packageManager, packageManagerName: packageManagerName(packageJson.packageManager), scripts: pickJsonObject(packageJson.scripts), dependencies: pickJsonObject(packageJson.dependencies), devDependencies: pickJsonObject(packageJson.devDependencies), workspaces: packageJson.workspaces ?? null, - turboJson: existsSync(turboJsonPath) - ? await readJsonFile(turboJsonPath) + turboJson: (await fileExists(sandbox, turboJsonPath)) + ? await readJsonFile(sandbox, turboJsonPath) : null, - readme: await readTextIfExists(path.join(examplePath, "README.md"), 80), + readme: await readTextIfExists( + sandbox, + path.join(examplePath, "README.md"), + 80 + ), lockfiles: entries .filter( (entry) => - entry.isFile() && + entry.type === "file" && ["package-lock.json", "pnpm-lock.yaml", "yarn.lock"].includes( entry.name ) @@ -54,7 +62,7 @@ export default defineTool({ .map((entry) => entry.name) .sort(), topLevelDirectories: entries - .filter((entry) => entry.isDirectory()) + .filter((entry) => entry.type === "directory") .map((entry) => entry.name) .sort() }; diff --git a/apps/agents/agent/tools/list_examples.ts b/apps/agents/agent/tools/list_examples.ts index 1e12015b7ea38..977e10c404052 100644 --- a/apps/agents/agent/tools/list_examples.ts +++ b/apps/agents/agent/tools/list_examples.ts @@ -7,10 +7,11 @@ export default defineTool({ description: "List Turborepo examples available under the repository's examples/ directory.", inputSchema: z.object({}), - async execute() { + async execute(_input, ctx) { + const sandbox = await ctx.getSandbox(); return { - repoRoot: await getRepoRoot(), - examples: await listExampleNames() + repoRoot: await getRepoRoot(sandbox), + examples: await listExampleNames(sandbox) }; } }); diff --git a/apps/agents/agent/tools/read_examples_file.ts b/apps/agents/agent/tools/read_examples_file.ts index f47b062a88567..8098814ac6927 100644 --- a/apps/agents/agent/tools/read_examples_file.ts +++ b/apps/agents/agent/tools/read_examples_file.ts @@ -1,5 +1,4 @@ -import { readFile } from "node:fs/promises"; -import path from "node:path"; +import path from "node:path/posix"; import { defineTool } from "eve/tools"; import { z } from "zod"; @@ -16,13 +15,21 @@ export default defineTool({ .describe("Repository-relative file path under examples/."), maxLines: z.number().int().positive().max(1_000).default(200) }), - async execute({ path: relativePath, maxLines }) { - const repoRoot = await getRepoRoot(); - const filePath = await resolveExamplesFile(relativePath); - const content = await readFile(filePath, "utf8"); + async execute({ path: relativePath, maxLines }, ctx) { + const sandbox = await ctx.getSandbox(); + const repoRoot = await getRepoRoot(sandbox); + const filePath = await resolveExamplesFile(sandbox, relativePath); + const content = await sandbox.readTextFile({ + path: filePath, + startLine: 1, + endLine: maxLines + }); + if (content === null) { + throw new Error(`${relativePath} does not exist.`); + } return { path: path.relative(repoRoot, filePath), - content: content.split("\n").slice(0, maxLines).join("\n") + content }; } }); diff --git a/apps/agents/agent/tools/run_example_script.ts b/apps/agents/agent/tools/run_example_script.ts index d770e5f91dc9e..e5fe033191d9a 100644 --- a/apps/agents/agent/tools/run_example_script.ts +++ b/apps/agents/agent/tools/run_example_script.ts @@ -1,4 +1,4 @@ -import path from "node:path"; +import path from "node:path/posix"; import { defineTool } from "eve/tools"; import { z } from "zod"; @@ -27,9 +27,11 @@ export default defineTool({ ), timeoutSeconds: z.number().int().positive().max(600).default(120) }), - async execute({ example, script, timeoutSeconds }) { - const examplePath = await getExamplePath(example); + async execute({ example, script, timeoutSeconds }, ctx) { + const sandbox = await ctx.getSandbox(); + const examplePath = await getExamplePath(sandbox, example); const packageJson = await readJsonFile( + sandbox, path.join(examplePath, "package.json") ); const scripts = pickJsonObject(packageJson.scripts); @@ -41,6 +43,7 @@ export default defineTool({ const manager = packageManagerName(packageJson.packageManager) ?? "pnpm"; return runCommand( + sandbox, manager, ["run", script], examplePath, diff --git a/apps/agents/agent/tools/update_example_lockfile.ts b/apps/agents/agent/tools/update_example_lockfile.ts index 9139acfcad00f..276904681030a 100644 --- a/apps/agents/agent/tools/update_example_lockfile.ts +++ b/apps/agents/agent/tools/update_example_lockfile.ts @@ -1,4 +1,4 @@ -import path from "node:path"; +import path from "node:path/posix"; import { defineTool } from "eve/tools"; import { z } from "zod"; @@ -20,13 +20,16 @@ export default defineTool({ .describe("Directory name under examples/, for example 'basic'."), timeoutSeconds: z.number().int().positive().max(1200).default(300) }), - async execute({ example, timeoutSeconds }) { - const examplePath = await getExamplePath(example); + async execute({ example, timeoutSeconds }, ctx) { + const sandbox = await ctx.getSandbox(); + const examplePath = await getExamplePath(sandbox, example); const packageJson = await readJsonFile( + sandbox, path.join(examplePath, "package.json") ); const manager = packageManagerName(packageJson.packageManager) ?? "pnpm"; return runCommand( + sandbox, manager, ["install"], examplePath, diff --git a/apps/agents/agent/tools/write_examples_file.ts b/apps/agents/agent/tools/write_examples_file.ts index c24fd93579cf6..a588601b8b459 100644 --- a/apps/agents/agent/tools/write_examples_file.ts +++ b/apps/agents/agent/tools/write_examples_file.ts @@ -13,7 +13,7 @@ export default defineTool({ .describe("Repository-relative file path under examples/."), content: z.string().describe("Complete file contents to write.") }), - async execute({ path, content }) { - return writeExamplesFile(path, content); + async execute({ path, content }, ctx) { + return writeExamplesFile(await ctx.getSandbox(), path, content); } }); diff --git a/apps/agents/package.json b/apps/agents/package.json index 39cb139f7566a..0d1be9f33b7a4 100644 --- a/apps/agents/package.json +++ b/apps/agents/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@vercel/connect": "0.4.2", + "@vercel/oidc": "3.8.0", "ai": "7.0.0-beta.178", "eve": "^0.22.5", "zod": "4.4.3" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5fe1eeb73a26c..d5c175c14bafd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: '@vercel/connect': specifier: 0.4.2 version: 0.4.2(ai@7.0.0-beta.178(zod@4.4.3))(eve@0.22.5(@azure/identity@4.13.1)(@opentelemetry/api@1.9.0)(@vercel/functions@3.4.4)(ai@7.0.0-beta.178(zod@4.4.3))(chokidar@5.0.0)(dotenv@16.0.3)(lru-cache@11.3.3)) + '@vercel/oidc': + specifier: 3.8.0 + version: 3.8.0 ai: specifier: 7.0.0-beta.178 version: 7.0.0-beta.178(zod@4.4.3)