Skip to content
Draft
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
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/junior-agent-browser/plugin.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions packages/junior/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
46 changes: 46 additions & 0 deletions packages/junior/sandbox/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
32 changes: 32 additions & 0 deletions packages/junior/sandbox/README.md
Original file line number Diff line number Diff line change
@@ -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.
134 changes: 134 additions & 0 deletions packages/junior/sandbox/install-runtime-profile.mjs
Original file line number Diff line number Diff line change
@@ -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 <profile.json>");
}

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(" && "),
]);
Loading
Loading