Skip to content

Commit 4e303e6

Browse files
authored
fix(eve)!: make the Upstash Box sandbox backend actually usable (#6)
* refactor(eve)!: sandbox config is Box's BoxConfig (drop vcpus/networkPolicy knobs) The `upstash()` sandbox backend invented Vercel-shaped config (`resources.vcpus`, runtime strings like "node24", an Eve-shaped `networkPolicy`) that didn't match Upstash Box. Take the real `@upstash/box` `BoxConfig` verbatim instead. - `UpstashBackendConfig = Omit<BoxConfig, "networkPolicy">` — pass `runtime`, `size`, `apiKey`, `keepAlive`, `initCommand`, `env`, `skills`, `mcpServers`, `timeout`, … exactly as you would to `Box.create({...})`. Removed the `resources.vcpus`→size mapping and the runtime-string coercion (use Box's `Runtime`/`BoxSize` directly). - `networkPolicy` is no longer a config knob: egress is enforced deny-all atomically at creation (folded into `boxConfig()`, dropping the extra post-create `updateNetworkPolicy` round-trips) and opened only per-session via Eve's `use({ networkPolicy })`. Reworked the live egress test to open via the session `use()` flow. - Updated the eve README, eve-demo sandbox, and docstring examples; folded into the pending harden-tenant-isolation changeset. * fix(eve): reuse prewarmed Box snapshots via Redis; bridge /workspace path Two sandbox bugs surfaced running an agent against Upstash Box. 1. Two boxes were created and the first (prewarmed) one was unused — its seed files never reached the session. `prewarm` (build/startup) recorded the template snapshot only in an in-memory map, which `create` (a different process, per request) can't see; Box has no static snapshot lookup, so create always fell back to a fresh, empty `Box.create`. Store `templateKey → snapshotId` in a durable Redis registry (`agentkit:sandbox:template:<name>:<templateKey>`, `redis` defaults to `Redis.fromEnv()`) so create restores the prewarmed snapshot. `prewarm` also no longer builds a throwaway box when there's nothing to bake (no seed files / bootstrap), and stale-snapshot restores fall back to a fresh box. 2. The agent ran `find /workspace …` but Box sessions live in `/workspace/home` (`/workspace` is off-limits). Eve hardcodes `/workspace` as its tool root, so the backend now bridges it to `/workspace/home` in both `resolvePath` (file ops) and raw commands (exported `toBoxPath` / `rewriteWorkspacePaths`). Config gains optional `redis`/`templatePrefix` (stripped before `Box.create`). Added offline path-bridge tests and a live Box+Redis test proving a second backend instance reuses the prewarmed snapshot. Updated README, docstrings, CLAUDE.md, and the changeset. * fix(eve): reuse one Box per session; fix dispose/keepAlive; URL-safe path rewrite Running an agent against Box created a new box on every session open (the logs showed three "opening sandbox session" per turn). - `create` now reattaches to the box from `input.existingMetadata.boxId` (`Box.get`) before falling back to the template snapshot or a fresh box. Eve re-opens a session many times per turn and hands back the box id we record in `captureState`, so without this every open spun a new box. - `dispose` is now a no-op (matching Eve's Vercel backend): the box must survive for the next open to reattach. The old `dispose` called `box.pause()`, which THROWS for keep-alive boxes ("Keep-alive boxes cannot be paused"), so it both failed and defeated reuse. - `keepAlive` now defaults to `false` (Box's pause-based idle lifecycle): idle boxes auto-pause and are reaped, so a no-op dispose doesn't leak. `true` opts into an always-running box the caller manages. - `rewriteWorkspacePaths` is now URL-safe: a lookbehind stops it rewriting `/workspace` inside URLs/relative paths (e.g. `curl host/workspace/x`), while still mapping genuine `/workspace` path tokens to `/workspace/home`. Added live tests for box reuse across opens and for the URL-safe rewrite; live tests now delete boxes explicitly since dispose no longer does. Updated README, docstrings, CLAUDE.md, and the changeset.
1 parent 391310e commit 4e303e6

6 files changed

Lines changed: 360 additions & 108 deletions

File tree

.changeset/harden-tenant-isolation.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ Tenant-isolation hardening, a type-safe reactive search index, and a consistent
1212
- `AgentMemory` requires a non-empty `userId` on every call (no silent shared bucket) and rejects a `:` in `userId`; `add`/`recall` take a single object param.
1313
- `ToolCache` keys are `<prefix>:<userId>:<toolName>:<hash>` — scoped per user, then per tool; `userId`/`toolName` are rejected if they contain `:`.
1414
- `createRateLimit`/`createRateLimitAuth` require an explicit `limiter` (removed `limit`/`window`); eve's `createRateLimitAuth` requires `identifier` (no implicit global bucket) and counts only `POST` requests, so a turn (a message `POST` plus its follow-up stream `GET`) is charged once, not twice.
15-
- The eve sandbox denies network egress by default.
15+
- The eve sandbox denies network egress by default. Its `upstash()` backend config is now the `@upstash/box` `BoxConfig` passed through verbatim (`runtime`/`size`/`apiKey`/`keepAlive`/`initCommand`/`env`/`skills`/…) plus an optional `redis`/`templatePrefix` — the invented `resources.vcpus` hint and runtime-string coercion (`"node24"`) are removed (use `runtime`/`size` as Box expects), and `networkPolicy` is no longer a config knob (egress is governed by the deny-all default plus per-session `use({ networkPolicy })`).
16+
- The eve sandbox now reuses prewarmed Box snapshots correctly: the `templateKey → snapshotId` map is stored in a durable Redis registry (Box has no static snapshot lookup, and `prewarm`/`create` run in different processes), so `create` restores the prewarmed template instead of spinning a fresh, empty box. `prewarm` builds no box when there's nothing to bake. It also bridges Eve's `/workspace` root to Box's `/workspace/home` working directory in both file ops and raw commands, so the agent's `find`/`grep`/file tools hit the right directory.
17+
- The eve sandbox now reuses one box per conversation instead of creating a new box on every session open: `create` reattaches to the box from `existingMetadata` (Eve re-opens a session many times per turn) and `dispose` no longer tears the box down. `keepAlive` defaults to `false` (Box's pause-based idle lifecycle), so idle boxes are auto-paused/reaped rather than leaked.
1618

1719
**Reactive search index**
1820

CLAUDE.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,22 @@ pnpm -r --filter "./examples/*" build # build both demo apps
232232
`create``Box.fromSnapshot` (or fresh `Box.create`), returning a `SandboxBackendHandle` whose
233233
`session` is a full `SandboxSession` built over Box (run/spawn/read*/write*/setNetworkPolicy/removePath).
234234
Typechecks against eve and the offline + live-Box `sandbox.test.ts` pass. `spawn` runs to completion
235-
then replays output as streams (Box has no detached-process primitive). Not exercised by `eve-demo`.
235+
then replays output as streams (Box has no detached-process primitive). Config is **`UpstashBackendConfig
236+
= Omit<BoxConfig, "networkPolicy"> & { redis?, templatePrefix? }`** — the real `@upstash/box` `BoxConfig`
237+
passed through verbatim (`runtime`/`size`/`apiKey`/`keepAlive`/`initCommand`/`env`/`skills`/…), **no**
238+
invented `resources.vcpus` hint or runtime-string coercion. `networkPolicy` is intentionally excluded:
239+
egress is enforced deny-all at creation (in `boxConfig()`) and opened only per-session via Eve's
240+
`use({ networkPolicy })`. **Template registry:** `prewarm` (build/startup) and `create` (per request)
241+
run in different processes, so the `templateKey → snapshotId` map lives in a **durable Redis registry**
242+
(`agentkit:sandbox:template:<name>:<templateKey>`, `redis` defaults to `Redis.fromEnv()`) — an in-memory
243+
map orphaned the prewarmed box (the old "two boxes, first unused" bug) and Box has no static snapshot
244+
lookup. `prewarm` builds **no** box when there's nothing to bake (no seed files/bootstrap). **Session
245+
reuse:** `create` reattaches to the box from `input.existingMetadata.boxId` (`Box.get`) — Eve re-opens a
246+
session many times and hands our captured `boxId` back, so without this every open spun a fresh box (the
247+
"3 boxes per turn" bug). `dispose` is a **no-op** (the box persists for reuse; Box's idle lifecycle reaps
248+
it), and `keepAlive` defaults to **false** (pause-based idle; `true` can't be paused and runs until
249+
deleted). **Path bridge:** Eve roots its tools at `/workspace` but Box sessions live in `/workspace/home`,
250+
so the backend remaps both `resolvePath` (file ops) and raw commands (`find /workspace …`
251+
`/workspace/home`, URL-safe via lookbehind) through the exported `toBoxPath`/`rewriteWorkspacePaths`.
236252
- `gpt-5.4-mini` (demo model) may not exist → demos build fine but can 404 at runtime. Swap if needed.
237253
- The `19.2.17` `@types/react` may linger as an unpruned orphan in `.pnpm`; harmless (nothing links it).

examples/eve-demo/agent/sandbox/sandbox.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,12 @@ import { upstash } from "@upstash/agentkit-eve/sandbox";
1414
export default defineSandbox({
1515
// `upstash()` is a drop-in replacement for eve's `vercel()` backend.
1616
backend: upstash({
17-
runtime: "node24", // optional: Box runtime; eve-style strings ("node24") map to Box's "node"
18-
resources: { vcpus: 2 }, // optional: vcpu hint mapped to a Box size (2 -> small)
19-
// optional: name, apiKey (defaults to UPSTASH_BOX_API_KEY), size, keepAlive,
20-
// initCommand, env, networkPolicy
17+
// The Upstash Box `BoxConfig`, verbatim (whatever you'd pass to `Box.create({...})`):
18+
runtime: "node", // optional: Box runtime (node | python | golang | ruby | rust)
19+
size: "small", // optional: Box resource size (small | medium | large)
20+
// optional: name, apiKey (defaults to UPSTASH_BOX_API_KEY), keepAlive,
21+
// initCommand, env, skills, mcpServers, timeout, … — all BoxConfig fields.
22+
// (networkPolicy is not a config knob — egress is deny-all by default, set per-session below.)
2123
}),
2224
// optional: durable-session-scoped, runs once per session. A good place to lock
2325
// down the network before the agent runs any commands. (Add a `bootstrap` hook

packages/eve/README.md

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -131,9 +131,12 @@ import { upstash } from "@upstash/agentkit-eve/sandbox"; // was: import { vercel
131131

132132
export default defineSandbox({
133133
backend: upstash({
134-
runtime: "node24", // the Upstash Box runtime (node | python | golang | ruby | rust)
135-
resources: { vcpus: 2 }, // optional: requested resources
134+
// The Upstash Box `BoxConfig`, verbatim — whatever you'd pass to `Box.create({...})`:
135+
runtime: "node", // the Box runtime (node | python | golang | ruby | rust)
136+
size: "medium", // optional: Box resource size (small | medium | large)
137+
// env: { ... }, initCommand, keepAlive, skills, mcpServers, timeout, … — all BoxConfig fields
136138
// apiKey, // optional: Upstash Box API key (defaults to UPSTASH_BOX_API_KEY)
139+
// (networkPolicy is NOT a config knob — egress is deny-all by default, opened per-session below)
137140
}),
138141
revalidationKey: () => "repo-bootstrap-v1",
139142
async bootstrap({ use }) {
@@ -149,13 +152,27 @@ export default defineSandbox({
149152

150153
> **Network egress is denied by default.** The sandbox runs untrusted, model-generated code, so open
151154
> egress would mean SSRF / data exfiltration / reaching your own infrastructure from inside the box.
152-
> Pass a `networkPolicy` (on the backend, in `bootstrap`'s `use(...)`, or in the session `use(...)`)
153-
> to allow it. Note that `env` passed to `upstash({ env })` is readable by code running in the box —
154-
> don't pass secrets you wouldn't want that code to see.
155+
> Open it per-session — in `bootstrap`'s `use(...)` or the session `use(...)` — never as a config knob.
156+
> Note that `env` passed to `upstash({ env })` is readable by code running in the box — don't pass
157+
> secrets you wouldn't want that code to see.
155158
156159
Set `UPSTASH_BOX_API_KEY` (or pass `apiKey`). `@upstash/box` is an optional peer dependency — only
157160
needed when you import `@upstash/agentkit-eve/sandbox`.
158161

162+
> **Template registry uses Redis.** Eve builds your sandbox template (seed files + `bootstrap`) at
163+
> build/startup via `prewarm`, but `create` runs per request in a different process — so the built
164+
> snapshot's id is stored in a durable Redis registry (`redis` defaults to `Redis.fromEnv()`; override
165+
> with `upstash({ redis })`). Without it, `create` couldn't find the prewarmed snapshot and would spin a
166+
> fresh, empty box every time (Box has no cross-process snapshot lookup). Eve roots its file/`find`/`grep`
167+
> tools at `/workspace`; a Box session lives at `/workspace/home`, and the backend bridges the two
168+
> automatically.
169+
170+
> **One box per conversation.** Eve re-opens a sandbox session several times per turn; the backend
171+
> reattaches to the same Box (via the box id it captured) instead of creating a new one each time. Boxes
172+
> default to Box's pause-based idle lifecycle (`keepAlive: false`) — auto-paused when idle, resumed on
173+
> reattach, reaped by Box — so nothing leaks. Pass `upstash({ keepAlive: true })` only if you want an
174+
> always-running box you manage yourself.
175+
159176
## Cached tools (`agent/tools/*.ts`)
160177

161178
`defineCachedTool` is like Eve's `defineTool`, but its result is memoized — pass a `toolName` and a

packages/eve/src/sandbox.test.ts

Lines changed: 120 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { config } from "dotenv";
22
import { describe, expect, it } from "vitest";
3-
import { upstash } from "./sandbox.js";
3+
import { Box } from "@upstash/box";
4+
import { rewriteWorkspacePaths, toBoxPath, upstash } from "./sandbox.js";
5+
import { hasRedisCreds, testRedis, uniquePrefix } from "./test-support.js";
46

57
config(); // load repo-root .env for UPSTASH_BOX_API_KEY
68
const hasBoxCreds = Boolean(process.env.UPSTASH_BOX_API_KEY);
@@ -13,52 +15,149 @@ const createInput = {
1315

1416
describe("upstash() backend (offline)", () => {
1517
it("implements Eve's two-phase SandboxBackend", () => {
16-
const backend = upstash({ runtime: "node24", resources: { vcpus: 2 } });
18+
const backend = upstash({ runtime: "node", size: "small" });
1719
expect(backend.name).toBe("upstash");
1820
expect(typeof backend.create).toBe("function");
1921
expect(typeof backend.prewarm).toBe("function");
2022
});
23+
24+
// Eve roots its tools at /workspace; a Box session lives in /workspace/home.
25+
it("bridges Eve's /workspace paths to Box's /workspace/home", () => {
26+
expect(toBoxPath("note.txt")).toBe("/workspace/home/note.txt"); // relative → under home
27+
expect(toBoxPath("/workspace")).toBe("/workspace/home"); // the root itself
28+
expect(toBoxPath("/workspace/sub/a.js")).toBe("/workspace/home/sub/a.js"); // nested
29+
expect(toBoxPath("/workspace/home/x")).toBe("/workspace/home/x"); // already box-rooted (no double-map)
30+
expect(toBoxPath("/tmp/x")).toBe("/tmp/x"); // unrelated absolute → untouched
31+
});
32+
33+
it("rewrites /workspace inside raw commands (Eve's find/grep tools)", () => {
34+
expect(rewriteWorkspacePaths("find /workspace -type f")).toBe("find /workspace/home -type f");
35+
expect(rewriteWorkspacePaths("node /workspace/app.js")).toBe("node /workspace/home/app.js");
36+
expect(rewriteWorkspacePaths("D=/workspace/x; echo $D")).toBe("D=/workspace/home/x; echo $D");
37+
expect(rewriteWorkspacePaths("ls /workspace/home")).toBe("ls /workspace/home"); // no double-map
38+
expect(rewriteWorkspacePaths("echo /workspaces")).toBe("echo /workspaces"); // word boundary respected
39+
});
40+
41+
it("does NOT rewrite /workspace mid-token (URLs, relative paths)", () => {
42+
// A `/workspace` inside a URL must be left alone — it's not a filesystem path.
43+
expect(rewriteWorkspacePaths("curl https://api.example.com/workspace/items")).toBe(
44+
"curl https://api.example.com/workspace/items",
45+
);
46+
expect(rewriteWorkspacePaths("cat ./workspace/x")).toBe("cat ./workspace/x"); // relative, untouched
47+
});
2148
});
2249

2350
describe.skipIf(!hasBoxCreds)("upstash() backend (live Upstash Box)", () => {
24-
it("creates a session, runs a command, round-trips a file, and disposes it", async () => {
25-
// keepAlive: false so dispose() deletes the box and cleans up after the test.
26-
const backend = upstash({ runtime: "node", resources: { vcpus: 2 }, keepAlive: false });
51+
it("creates a session, runs a command, round-trips a file", async () => {
52+
const backend = upstash({ runtime: "node", size: "small" });
2753
const handle = await backend.create(createInput);
2854
const session = handle.session;
2955
try {
3056
const result = await session.run({ command: "echo hello-box" });
3157
expect(result.exitCode).toBe(0);
3258
expect(result.stdout).toContain("hello-box");
3359

60+
// Bare commands run in Box's working dir, /workspace/home (not the off-limits /workspace).
61+
const pwd = await session.run({ command: "pwd" });
62+
expect(pwd.stdout.trim()).toBe("/workspace/home");
63+
64+
// A relative write lands under /workspace/home, and Eve's `find /workspace …` (rewritten to
65+
// /workspace/home) finds it — the end-to-end path bridge.
3466
await session.writeTextFile({ path: "note.txt", content: "agentkit" });
3567
expect(await session.readTextFile({ path: "note.txt" })).toContain("agentkit");
68+
const found = await session.run({ command: "find /workspace -name note.txt" });
69+
expect(found.stdout).toContain("/workspace/home/note.txt");
3670
} finally {
37-
await handle.dispose();
71+
// dispose() is a no-op (boxes persist for reuse), so delete explicitly to clean up the test.
72+
await Box.delete({ boxIds: handle.session.id }).catch(() => {});
3873
}
3974
}, 120_000);
4075

41-
// With no networkPolicy configured, egress is denied by default — model-generated code in the box
42-
// can't reach the network. Opening it explicitly (allow-all) lets the same call through.
43-
it("denies network egress by default, allows it when opened", async () => {
44-
const fetchCmd = `node -e "fetch('https://example.com').then(r=>process.exit(r.ok?0:9)).catch(()=>process.exit(7))"`;
45-
46-
const denied = upstash({ runtime: "node", keepAlive: false }); // no networkPolicy → deny-all
47-
const deniedHandle = await denied.create(createInput);
76+
// Eve re-opens a session several times per turn and hands back the box id we captured; `create` must
77+
// REATTACH to it, not spin a fresh box each time (the "three boxes per turn" bug).
78+
it("reuses the same box across opens via existingMetadata", async () => {
79+
const backend = upstash({ runtime: "node" });
80+
const first = await backend.create(createInput);
81+
const boxId = first.session.id;
82+
const state = await first.captureState();
83+
await first.dispose(); // no-op — the box persists for reuse
4884
try {
49-
const r = await deniedHandle.session.run({ command: fetchCmd });
50-
expect(r.exitCode).not.toBe(0); // egress blocked → fetch rejects
85+
const second = await backend.create({
86+
...createInput,
87+
existingMetadata: state.metadata,
88+
} as never);
89+
expect(second.session.id).toBe(boxId); // reattached to the SAME box, not a new one
90+
expect((await second.session.run({ command: "echo reused" })).stdout).toContain("reused");
5191
} finally {
52-
await deniedHandle.dispose();
92+
await Box.delete({ boxIds: boxId }).catch(() => {});
5393
}
94+
}, 120_000);
5495

55-
const open = upstash({ runtime: "node", keepAlive: false, networkPolicy: "allow-all" });
56-
const openHandle = await open.create(createInput);
96+
// Egress is denied by default — model-generated code in the box can't reach the network. Opening it
97+
// per-session via `use({ networkPolicy })` (Eve's flow) lets the same call through on the same box.
98+
it("denies network egress by default, allows it when opened", async () => {
99+
const fetchCmd = `node -e "fetch('https://example.com').then(r=>process.exit(r.ok?0:9)).catch(()=>process.exit(7))"`;
100+
101+
const backend = upstash({ runtime: "node" });
102+
const handle = await backend.create(createInput);
57103
try {
58-
const r = await openHandle.session.run({ command: fetchCmd });
59-
expect(r.exitCode).toBe(0); // egress allowed → fetch resolves
104+
const denied = await handle.session.run({ command: fetchCmd });
105+
expect(denied.exitCode).not.toBe(0); // egress blocked by default → fetch rejects
106+
107+
await handle.useSessionFn({ networkPolicy: "allow-all" }); // open egress for this session
108+
const allowed = await handle.session.run({ command: fetchCmd });
109+
expect(allowed.exitCode).toBe(0); // egress allowed → fetch resolves
60110
} finally {
61-
await openHandle.dispose();
111+
await Box.delete({ boxIds: handle.session.id }).catch(() => {});
62112
}
63113
}, 180_000);
64114
});
115+
116+
// Bug fix: prewarm (build/startup) and create (per request) run in different processes, so the
117+
// template snapshot is recorded in a durable Redis registry — a second backend INSTANCE must reuse it
118+
// rather than building a fresh, empty box (the "two boxes, first unused" + "missing seed files" bug).
119+
describe.skipIf(!hasBoxCreds || !hasRedisCreds)(
120+
"upstash() template registry (live Box + Redis)",
121+
() => {
122+
it("create reuses the snapshot prewarm stored in Redis, across instances", async () => {
123+
const redis = testRedis();
124+
const templatePrefix = uniquePrefix("sandboxtpl"); // unique so reruns don't collide
125+
const templateKey = "tmpl-1";
126+
const cfg = { runtime: "node" as const, redis, templatePrefix };
127+
const prewarmInput = {
128+
templateKey,
129+
seedFiles: [{ path: "seeded.txt", content: "from-template" }],
130+
runtimeContext: { appRoot: process.cwd() },
131+
};
132+
const regKey = `${templatePrefix}:upstash:${templateKey}`;
133+
134+
// One instance "prewarms" the template (bakes the seed file into a snapshot).
135+
const built = await upstash(cfg).prewarm(prewarmInput as never);
136+
expect(built.reused).toBe(false);
137+
const snapshotId = await redis.get<string>(regKey);
138+
expect(snapshotId).toBeTruthy();
139+
140+
const runner = upstash(cfg); // a SEPARATE instance — mimics the per-request process
141+
try {
142+
// It must find the snapshot via Redis (not rebuild, not start empty).
143+
expect((await runner.prewarm(prewarmInput as never)).reused).toBe(true);
144+
145+
const handle = await runner.create({
146+
templateKey,
147+
sessionKey: "s1",
148+
runtimeContext: { appRoot: process.cwd() },
149+
} as never);
150+
try {
151+
expect(await handle.session.readTextFile({ path: "seeded.txt" })).toContain(
152+
"from-template",
153+
);
154+
} finally {
155+
await Box.delete({ boxIds: handle.session.id }).catch(() => {});
156+
}
157+
} finally {
158+
if (snapshotId) await Box.deleteSnapshots({ snapshotIds: snapshotId }).catch(() => {});
159+
await redis.del(regKey).catch(() => {});
160+
}
161+
}, 240_000);
162+
},
163+
);

0 commit comments

Comments
 (0)