diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6fea7428b..01ab10a08 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -220,6 +220,16 @@ jobs: - run: pnpm --filter @sentry/junior-github test - run: pnpm --filter @sentry/junior-vercel test + sandbox-image: + name: test / sandbox image + runs-on: ubuntu-latest + needs: changes + if: github.event_name == 'push' || needs.changes.outputs.ci == 'true' || needs.changes.outputs.core == 'true' || needs.changes.outputs.example == 'true' || needs.changes.outputs.plugins == 'true' || needs.changes.outputs.full == 'true' + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup-node-pnpm + - run: pnpm sandbox:image:test + dashboard-tests: name: test / dashboard runs-on: ubuntu-latest diff --git a/package.json b/package.json index 599efa39c..0553828e7 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,8 @@ "docs:dev": "pnpm --filter @sentry/junior-docs dev", "docs:build": "pnpm --filter @sentry/junior-docs build", "docs:check": "pnpm --filter @sentry/junior-docs check", + "sandbox:image:profile": "pnpm --filter @sentry/junior sandbox:image:profile", + "sandbox:image:test": "pnpm --filter @sentry/junior sandbox:image:test", "package:lint": "for pkg in packages/junior packages/junior-plugin-api packages/junior-scheduler packages/junior-memory packages/junior-dashboard packages/junior-github packages/junior-agent-browser packages/junior-amplitude packages/junior-cloudflare packages/junior-datadog packages/junior-hex packages/junior-linear packages/junior-maintenance packages/junior-notion packages/junior-sentry packages/junior-vercel; do pnpm exec publint \"$pkg\" || exit $?; done", "release:check": "node scripts/check-release-config.mjs", "start": "pnpm --filter @sentry/junior-example dev", diff --git a/packages/junior-agent-browser/plugin.yaml b/packages/junior-agent-browser/plugin.yaml index 05bab14d7..18f6ade32 100644 --- a/packages/junior-agent-browser/plugin.yaml +++ b/packages/junior-agent-browser/plugin.yaml @@ -5,6 +5,7 @@ description: Browser automation workflows powered by agent-browser CLI runtime-dependencies: - type: npm package: agent-browser + version: 0.27.0 - type: system package: alsa-lib - type: system diff --git a/packages/junior/package.json b/packages/junior/package.json index e1657b7c4..1e00c5e7b 100644 --- a/packages/junior/package.json +++ b/packages/junior/package.json @@ -55,6 +55,8 @@ "prepack": "pnpm run build", "build": "tsup && tsc -p tsconfig.build.json --emitDeclarationOnly", "db:generate": "pnpm exec drizzle-kit generate --config drizzle.config.ts", + "sandbox:image:profile": "node --import tsx scripts/generate-sandbox-image-profile.ts", + "sandbox:image:test": "node --import tsx scripts/generate-sandbox-image-profile.ts --check && docker build --platform linux/amd64 --tag junior-sandbox-snapshot:ci sandbox", "lint": "oxlint --config .oxlintrc.json --deny-warnings src tests scripts bin tsup.config.ts && depcruise --config .dependency-cruiser.mjs src/chat", "lint:fix": "oxlint --config .oxlintrc.json --deny-warnings --fix src tests scripts bin tsup.config.ts", "test": "vitest run --maxWorkers=4", diff --git a/packages/junior/sandbox/Dockerfile b/packages/junior/sandbox/Dockerfile new file mode 100644 index 000000000..e8ccf1278 --- /dev/null +++ b/packages/junior/sandbox/Dockerfile @@ -0,0 +1,46 @@ +ARG AMAZON_LINUX_IMAGE=public.ecr.aws/amazonlinux/amazonlinux:2023 +FROM ${AMAZON_LINUX_IMAGE} + +RUN dnf install -y \ + ca-certificates \ + git \ + gzip \ + nodejs22 \ + nodejs22-npm \ + shadow-utils \ + sudo \ + tar \ + && dnf clean all + +ARG RIPGREP_VERSION=15.1.0 +ARG RIPGREP_SHA256=1c9297be4a084eea7ecaedf93eb03d058d6faae29bbc57ecdaf5063921491599 +RUN curl -fsSL \ + "https://github.com/BurntSushi/ripgrep/releases/download/${RIPGREP_VERSION}/ripgrep-${RIPGREP_VERSION}-x86_64-unknown-linux-musl.tar.gz" \ + -o /tmp/ripgrep.tar.gz \ + && printf '%s %s\n' "${RIPGREP_SHA256}" /tmp/ripgrep.tar.gz | sha256sum -c - \ + && tar -xzf /tmp/ripgrep.tar.gz -C /tmp \ + && install -m 0755 \ + "/tmp/ripgrep-${RIPGREP_VERSION}-x86_64-unknown-linux-musl/rg" \ + /usr/local/bin/rg \ + && rm -rf /tmp/ripgrep.tar.gz \ + "/tmp/ripgrep-${RIPGREP_VERSION}-x86_64-unknown-linux-musl" + +RUN useradd --create-home --shell /bin/bash vercel-sandbox \ + && install -d -o vercel-sandbox -g vercel-sandbox \ + /vercel/sandbox \ + /vercel/sandbox/.junior \ + /vercel/sandbox/.junior/bin \ + && printf 'vercel-sandbox ALL=(ALL) NOPASSWD: ALL\n' > /etc/sudoers.d/vercel-sandbox \ + && chmod 0440 /etc/sudoers.d/vercel-sandbox + +ENV HOME=/home/vercel-sandbox +ENV PATH=/vercel/sandbox/.junior/bin:${PATH} +WORKDIR /vercel/sandbox + +COPY install-runtime-profile.mjs runtime-profile.json /tmp/ + +USER vercel-sandbox +RUN node /tmp/install-runtime-profile.mjs /tmp/runtime-profile.json \ + && sudo rm /tmp/install-runtime-profile.mjs /tmp/runtime-profile.json + +CMD ["/bin/bash"] diff --git a/packages/junior/sandbox/README.md b/packages/junior/sandbox/README.md new file mode 100644 index 000000000..0c2e2d5f7 --- /dev/null +++ b/packages/junior/sandbox/README.md @@ -0,0 +1,32 @@ +# Sandbox Compatibility Image + +This image smoke-tests the example app's sandbox dependency snapshot on the +same documented operating-system family as Vercel Sandbox: + +- Amazon Linux 2023 +- Node.js 22 +- Git and ripgrep, which Junior expects from the prepared runtime +- the `vercel-sandbox` user with passwordless `sudo` +- `/vercel/sandbox` as the working directory +- plugin runtime dependencies and postinstall commands + +The stock Amazon Linux image does not package `ripgrep`, so the compatibility +base installs that CLI before applying the generated profile. + +`runtime-profile.json` is generated from the plugins enabled in +`apps/example/plugins.ts`. Regenerate it after changing plugin runtime +dependencies: + +```sh +pnpm sandbox:image:profile +``` + +Build and validate the image locally: + +```sh +pnpm sandbox:image:test +``` + +The Docker build validates package availability and installation behavior. It +does not reproduce Vercel's Firecracker kernel, network policy, credential +proxy, resource limits, or nested Docker runtime. diff --git a/packages/junior/sandbox/install-runtime-profile.mjs b/packages/junior/sandbox/install-runtime-profile.mjs new file mode 100644 index 000000000..3b1127e80 --- /dev/null +++ b/packages/junior/sandbox/install-runtime-profile.mjs @@ -0,0 +1,134 @@ +import fs from "node:fs"; +import { spawnSync } from "node:child_process"; + +const [profilePath] = process.argv.slice(2); +if (!profilePath) { + throw new Error("Usage: install-runtime-profile.mjs "); +} + +const profile = JSON.parse(fs.readFileSync(profilePath, "utf8")); +if (profile.version !== 1 || profile.runtime !== "node22") { + throw new Error("Unsupported sandbox image profile"); +} + +function run(cmd, args = [], options = {}) { + const command = options.sudo ? "sudo" : cmd; + const commandArgs = options.sudo ? [cmd, ...args] : args; + const result = spawnSync(command, commandArgs, { + env: process.env, + stdio: "inherit", + }); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + throw new Error(`${[command, ...commandArgs].join(" ")} failed`); + } +} + +function tryRun(cmd, args = [], options = {}) { + const command = options.sudo ? "sudo" : cmd; + const commandArgs = options.sudo ? [cmd, ...args] : args; + const result = spawnSync(command, commandArgs, { + env: process.env, + stdio: "inherit", + }); + return !result.error && result.status === 0; +} + +function installGh() { + if (tryRun("dnf", ["install", "-y", "gh"], { sudo: true })) { + return; + } + + const repoUrl = "https://cli.github.com/packages/rpm/gh-cli.repo"; + if ( + !tryRun( + "dnf", + ["config-manager", "addrepo", `--from-repofile=${repoUrl}`], + { sudo: true }, + ) + ) { + run("dnf", ["install", "-y", "dnf-command(config-manager)"], { + sudo: true, + }); + run("dnf", ["config-manager", "--add-repo", repoUrl], { sudo: true }); + } + + run("dnf", ["install", "-y", "gh", "--repo", "gh-cli"], { sudo: true }); +} + +function installUrlDependency(dependency) { + const rpmPath = `/tmp/junior-runtime-${dependency.sha256.slice(0, 12)}.rpm`; + run("curl", ["-fsSL", dependency.url, "-o", rpmPath]); + run("bash", [ + "-lc", + `printf '%s %s\\n' "$1" "$2" | sha256sum -c -`, + "verify-runtime-rpm", + dependency.sha256, + rpmPath, + ]); + run("dnf", ["install", "-y", rpmPath], { sudo: true }); + run("rm", [rpmPath]); +} + +const npmPackages = []; +for (const dependency of profile.dependencies) { + if (dependency.type === "npm") { + npmPackages.push(`${dependency.package}@${dependency.version}`); + continue; + } + if ("url" in dependency) { + installUrlDependency(dependency); + continue; + } + if (dependency.package === "gh") { + installGh(); + continue; + } + if (dependency.package === "ripgrep" && tryRun("rg", ["--version"])) { + continue; + } + run("dnf", ["install", "-y", dependency.package], { sudo: true }); +} + +if (npmPackages.length > 0) { + run("npm", [ + "install", + "--global", + "--prefix", + "/vercel/sandbox/.junior", + ...npmPackages, + ]); +} + +for (const command of profile.postinstall) { + run(command.cmd, command.args ?? [], { sudo: command.sudo }); +} + +const dependencyChecks = new Map([ + ["agent-browser", ["agent-browser", "--version"]], + ["docker", ["docker", "--version"]], + ["gh", ["gh", "--version"]], + ["jq", ["jq", "--version"]], + ["ripgrep", ["rg", "--version"]], + ["sentry", ["sentry", "--version"]], + ["vercel", ["vercel", "--version"]], +]); +for (const dependency of profile.dependencies) { + const [cmd, ...args] = dependencyChecks.get(dependency.package) ?? []; + if (cmd) { + run(cmd, args); + } +} + +run("bash", [ + "-lc", + [ + 'test "$(id -un)" = vercel-sandbox', + 'test "$PWD" = /vercel/sandbox', + "node --version", + "npm --version", + "dnf --version", + ].join(" && "), +]); diff --git a/packages/junior/sandbox/runtime-profile.json b/packages/junior/sandbox/runtime-profile.json new file mode 100644 index 000000000..ff1a7ecb5 --- /dev/null +++ b/packages/junior/sandbox/runtime-profile.json @@ -0,0 +1,146 @@ +{ + "version": 1, + "runtime": "node22", + "dependencies": [ + { + "type": "system", + "package": "docker" + }, + { + "type": "system", + "package": "ripgrep" + }, + { + "type": "npm", + "package": "agent-browser", + "version": "0.27.0" + }, + { + "type": "npm", + "package": "sentry", + "version": "latest" + }, + { + "type": "npm", + "package": "vercel", + "version": "latest" + }, + { + "type": "system", + "package": "alsa-lib" + }, + { + "type": "system", + "package": "at-spi2-atk" + }, + { + "type": "system", + "package": "at-spi2-core" + }, + { + "type": "system", + "package": "atk" + }, + { + "type": "system", + "package": "cairo" + }, + { + "type": "system", + "package": "cups-libs" + }, + { + "type": "system", + "package": "dbus-libs" + }, + { + "type": "system", + "package": "gh" + }, + { + "type": "system", + "package": "glib2" + }, + { + "type": "system", + "package": "gtk3" + }, + { + "type": "system", + "package": "jq" + }, + { + "type": "system", + "package": "libdrm" + }, + { + "type": "system", + "package": "libX11" + }, + { + "type": "system", + "package": "libX11-xcb" + }, + { + "type": "system", + "package": "libxcb" + }, + { + "type": "system", + "package": "libXcomposite" + }, + { + "type": "system", + "package": "libXdamage" + }, + { + "type": "system", + "package": "libXext" + }, + { + "type": "system", + "package": "libXfixes" + }, + { + "type": "system", + "package": "libxkbcommon" + }, + { + "type": "system", + "package": "libXrandr" + }, + { + "type": "system", + "package": "libxshmfence" + }, + { + "type": "system", + "package": "mesa-libgbm" + }, + { + "type": "system", + "package": "nspr" + }, + { + "type": "system", + "package": "nss" + }, + { + "type": "system", + "package": "pango" + } + ], + "postinstall": [ + { + "cmd": "agent-browser", + "args": ["install"] + }, + { + "cmd": "bash", + "args": [ + "-lc", + "set -euo pipefail\nversion=0.58.5\narchive=\"pup_${version}_Linux_x86_64.tar.gz\"\nurl=\"https://github.com/DataDog/pup/releases/download/v${version}/${archive}\"\nsha256=\"9543d968a6bd3b00da7ef20053717494beba7962e6cea01368d82857c8ea926b\"\ntmp=\"$(mktemp -d)\"\ntrap 'rm -rf \"$tmp\"' EXIT\ncurl -fsSL \"$url\" -o \"$tmp/$archive\"\necho \"${sha256} $tmp/$archive\" | sha256sum -c -\ntar -xzf \"$tmp/$archive\" -C \"$tmp\"\nmkdir -p /vercel/sandbox/.junior/bin\ninstall -m 0755 \"$tmp/pup\" /vercel/sandbox/.junior/bin/pup\npup --version" + ] + } + ] +} diff --git a/packages/junior/scripts/generate-sandbox-image-profile.ts b/packages/junior/scripts/generate-sandbox-image-profile.ts new file mode 100644 index 000000000..5edd1838f --- /dev/null +++ b/packages/junior/scripts/generate-sandbox-image-profile.ts @@ -0,0 +1,75 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { isDeepStrictEqual } from "node:util"; +import { fileURLToPath } from "node:url"; +import { createPluginCatalogRuntime } from "../src/chat/plugins/registry"; +import { GLOBAL_RUNTIME_DEPENDENCIES } from "../src/chat/sandbox/runtime-dependencies"; +import { pluginCatalogConfigFromPluginSet } from "../src/plugins"; + +const packageDir = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", +); +const exampleAppDir = path.resolve(packageDir, "../../apps/example"); +const profilePath = path.join(packageDir, "sandbox/runtime-profile.json"); + +interface SandboxImageProfile { + version: 1; + runtime: "node22"; + dependencies: ReturnType< + ReturnType["getRuntimeDependencies"] + >; + postinstall: ReturnType< + ReturnType["getRuntimePostinstall"] + >; +} + +function renderProfile(profile: SandboxImageProfile): string { + return `${JSON.stringify(profile, null, 2)}\n`; +} + +async function buildProfile(): Promise { + const previousCwd = process.cwd(); + process.chdir(exampleAppDir); + try { + process.env.DATABASE_URL ??= + "postgres://sandbox-image-profile:unused@localhost/unused"; + const { plugins } = await import("../../../apps/example/plugins"); + const catalog = createPluginCatalogRuntime(); + catalog.setConfig(pluginCatalogConfigFromPluginSet(plugins)); + return { + version: 1, + runtime: "node22", + dependencies: [ + ...GLOBAL_RUNTIME_DEPENDENCIES, + ...catalog.getRuntimeDependencies(), + ], + postinstall: catalog.getRuntimePostinstall(), + }; + } finally { + process.chdir(previousCwd); + } +} + +async function main(): Promise { + const profile = await buildProfile(); + const rendered = renderProfile(profile); + if (process.argv.includes("--check")) { + const committed = await fs + .readFile(profilePath, "utf8") + .then((raw) => JSON.parse(raw) as unknown) + .catch(() => undefined); + if (!isDeepStrictEqual(committed, profile)) { + throw new Error( + "Sandbox image profile is stale. Run `pnpm sandbox:image:profile`.", + ); + } + console.log("Sandbox image profile is current."); + return; + } + + await fs.writeFile(profilePath, rendered); + console.log(`Wrote ${path.relative(process.cwd(), profilePath)}`); +} + +await main();