Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .changeset/harden-tenant-isolation.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ Tenant-isolation hardening, a type-safe reactive search index, and a consistent
- 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 })`).
- 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.
- 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.
- The eve sandbox no longer silently drops Eve's per-domain network rules. Box's network policy is a plain domain/CIDR allow-list, so a policy carrying `transform` (firewall header injection / credential brokering) or `forwardURL` now **throws** instead of being quietly reduced to a bare allow-list (which would send the request unauthenticated). For credential brokering, set Box's `attachHeaders` at backend creation via `upstash({ attachHeaders })`.
- `createRateLimit`'s `redis` is now optional and defaults to `Redis.fromEnv()`, matching the "`redis` defaults everywhere" convention — previously it was the one feature that required an explicit client.

**Reactive search index**

Expand Down
4 changes: 2 additions & 2 deletions packages/ai-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ before the model and short-circuit when over the limit.
```ts
import { createRateLimit, Ratelimit } from "@upstash/agentkit-ai-sdk";

const ratelimit = createRateLimit({ redis, limiter: Ratelimit.slidingWindow(20, "1 m") });
const ratelimit = createRateLimit({ limiter: Ratelimit.slidingWindow(20, "1 m") });

const { success } = await ratelimit.limit(userId);
if (!success) throw new Error("rate limited"); // or return a 429 from your route
Expand All @@ -154,7 +154,7 @@ if (!success) throw new Error("rate limited"); // or return a 429 from your rout
<summary>Options</summary>

- **`limiter`** _(required)_ — e.g. `Ratelimit.slidingWindow(20, "1 m")` or `fixedWindow(...)`.
- `redis` — the Upstash Redis client backing the limiter.
- `redis` — defaults to `Redis.fromEnv()`.
- `prefix` — base key prefix; keys are `<prefix>:<identifier>` (default `agentkit:rateLimit`).

There is no model wrapper; pass a per-user `identifier` to `.limit()` to throttle per user.
Expand Down
39 changes: 39 additions & 0 deletions packages/eve/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,45 @@ readable by code running in the box; don't pass secrets you wouldn't want it to

</details>

<details>
<summary>Brokering credentials (injecting headers)</summary>

Box network policies are plain domain/CIDR allow-lists. Eve's per-domain firewall rules (`transform`
header injection, `forwardURL`) have no Box equivalent, so passing them in `use({ networkPolicy })`
**throws** rather than silently sending the request unauthenticated:

```ts
// ❌ throws — Box can't inject headers via a per-session policy
export default defineSandbox({
backend: upstash({ runtime: "node" }),
async onSession({ use }) {
await use({
networkPolicy: {
allow: { "api.example.com": [{ transform: [{ headers: { authorization: "Bearer …" } }] }] },
},
});
},
});
```

Broker credentials with Box's `attachHeaders` instead (set at backend creation; a proxy on the box
injects them), and open the domain with a plain allow-list:

```ts
// ✅ headers injected at the firewall; the secret never enters the box
export default defineSandbox({
backend: upstash({
runtime: "node",
attachHeaders: { "api.example.com": { Authorization: "Bearer …" } },
}),
async onSession({ use }) {
await use({ networkPolicy: { allow: ["api.example.com"] } });
},
});
```

</details>

<details>
<summary>Lifecycle: one box per conversation</summary>

Expand Down
103 changes: 102 additions & 1 deletion packages/eve/src/sandbox.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { config } from "dotenv";
import { describe, expect, it } from "vitest";
import { Box } from "@upstash/box";
import { rewriteWorkspacePaths, toBoxPath, upstash } from "./sandbox.js";
import { rewriteWorkspacePaths, toBoxNetworkPolicy, toBoxPath, upstash } from "./sandbox.js";
import { hasRedisCreds, testRedis, uniquePrefix } from "./test-support.js";

config(); // load repo-root .env for UPSTASH_BOX_API_KEY
Expand Down Expand Up @@ -45,6 +45,28 @@ describe("upstash() backend (offline)", () => {
);
expect(rewriteWorkspacePaths("cat ./workspace/x")).toBe("cat ./workspace/x"); // relative, untouched
});

// Box's policy is a plain domain/CIDR allow-list. It must not silently drop Eve's per-domain
// credential-brokering rules (transform / forwardURL); those throw instead.
it("maps plain network policies and throws on per-domain rules", () => {
expect(toBoxNetworkPolicy("deny-all")).toEqual({ mode: "deny-all" });
expect(toBoxNetworkPolicy("allow-all")).toEqual({ mode: "allow-all" });
expect(toBoxNetworkPolicy({ allow: ["github.com"] } as never)).toEqual({
mode: "custom",
allowedDomains: ["github.com"],
});
// Empty rule arrays just allow the domains — fine.
expect(toBoxNetworkPolicy({ allow: { "github.com": [], "*": [] } } as never)).toEqual({
mode: "custom",
allowedDomains: ["github.com", "*"],
});
// A transform rule (firewall header injection) has no dynamic Box equivalent — throw, don't drop.
expect(() =>
toBoxNetworkPolicy({
allow: { "github.com": [{ transform: [{ headers: { authorization: "Basic x" } }] }] },
} as never),
).toThrow(/attachHeaders/);
});
});

describe.skipIf(!hasBoxCreds)("upstash() backend (live Upstash Box)", () => {
Expand Down Expand Up @@ -111,6 +133,85 @@ describe.skipIf(!hasBoxCreds)("upstash() backend (live Upstash Box)", () => {
await Box.delete({ boxIds: handle.session.id }).catch(() => {});
}
}, 180_000);

/**
* Users don't call `create` — they pass the backend to `defineSandbox`, and eve calls `create`, then
* drives `useSessionFn` (its implementation of the `use({ networkPolicy })` you write in
* `bootstrap`/`onSession`) and the session's `setNetworkPolicy` (the agent changing policy mid-run).
*
* The user code that hits this error — a per-domain `transform` (firewall header injection) in `use()`:
*
* ```ts
* // agent/sandbox.ts
* export default defineSandbox({
* backend: upstash({ runtime: "node" }),
* async onSession({ use }) {
* await use({
* networkPolicy: {
* allow: { "api.example.com": [{ transform: [{ headers: { authorization: "Bearer …" } }] }] },
* },
* }); // throws: Box can't inject headers via a per-session policy
* },
* });
* ```
*
* What to do instead — broker credentials with Box's `attachHeaders` (set at backend creation), and
* just open the domain per session:
*
* ```ts
* export default defineSandbox({
* backend: upstash({
* runtime: "node",
* attachHeaders: { "api.example.com": { Authorization: "Bearer …" } }, // injected by Box's proxy
* }),
* async onSession({ use }) {
* await use({ networkPolicy: { allow: ["api.example.com"] } }); // plain allow-list, no rules
* },
* });
* ```
*
* The test drives the same backend methods eve calls (`useSessionFn` = `use()`, plus `setNetworkPolicy`),
* since standing up the full eve runtime in a unit test isn't practical.
*/
it("rejects a per-session transform rule on use()/setNetworkPolicy; a plain allow-list works", async () => {
const backend = upstash({ runtime: "node" });
const handle = await backend.create(createInput); // eve does this for you
const transformPolicy = {
allow: { "api.example.com": [{ transform: [{ headers: { authorization: "Bearer x" } }] }] },
} as never;
try {
// The user's `use({ networkPolicy })` (eve → useSessionFn): errors, pointing to attachHeaders.
await expect(handle.useSessionFn({ networkPolicy: transformPolicy })).rejects.toThrow(
/attachHeaders/,
);
// The agent's `session.setNetworkPolicy(...)`: same guard.
await expect(handle.session.setNetworkPolicy(transformPolicy)).rejects.toThrow(
/attachHeaders/,
);

// Works instead: a bare domain allow-list (no per-domain rules).
await expect(
handle.useSessionFn({ networkPolicy: ["api.example.com"] as never }),
).resolves.toBeDefined();
} finally {
await Box.delete({ boxIds: handle.session.id }).catch(() => {});
}
}, 120_000);

// The supported credential-brokering path: Box's `attachHeaders`, set at backend creation. We can't
// observe injection without an echo endpoint, but the box must at least be created with it and run.
it("accepts attachHeaders at backend creation (credential brokering)", async () => {
const backend = upstash({
runtime: "node",
attachHeaders: { "api.example.com": { Authorization: "Bearer test" } },
});
const handle = await backend.create(createInput);
try {
expect((await handle.session.run({ command: "echo ok" })).stdout).toContain("ok");
} finally {
await Box.delete({ boxIds: handle.session.id }).catch(() => {});
}
}, 120_000);
});

// Bug fix: prewarm (build/startup) and create (per request) run in different processes, so the
Expand Down
33 changes: 30 additions & 3 deletions packages/eve/src/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,12 +145,39 @@ export function rewriteWorkspacePaths(command: string): string {
*/
const DEFAULT_NETWORK_POLICY: SandboxNetworkPolicy = "deny-all";

/** Map Eve's (Vercel-shaped) network policy onto Box's network policy. */
function toBoxNetworkPolicy(policy: SandboxNetworkPolicy): BoxNetworkPolicy {
/**
* Map Eve's (Vercel-shaped) network policy onto Box's. Box's policy is a plain domain/CIDR allow-list,
* so it can't honor Eve's per-domain firewall rules: `transform` (inject headers at the firewall to
* broker credentials so secrets never enter the box) or `forwardURL`. Silently dropping those would send
* the request unauthenticated, or push the model to embed the secret inside the box, so we **throw**
* rather than quietly downgrade a security control. (Plain allow-lists and empty rule arrays map fine.)
*
* For credential brokering on Box, set `attachHeaders` at backend creation instead:
* `upstash({ attachHeaders: { "api.example.com": { Authorization: "Bearer ..." } } })`.
*/
export function toBoxNetworkPolicy(policy: SandboxNetworkPolicy): BoxNetworkPolicy {
if (policy === "allow-all") return { mode: "allow-all" };
if (policy === "deny-all") return { mode: "deny-all" };
const allow = policy.allow;
const allowedDomains = Array.isArray(allow) ? allow : allow ? Object.keys(allow) : undefined;
let allowedDomains: string[] | undefined;
if (Array.isArray(allow)) {
allowedDomains = allow;
} else if (allow) {
for (const [domain, rules] of Object.entries(allow)) {
if (
Array.isArray(rules) &&
rules.some((r) => r && (r.transform || r.forwardURL || r.match))
) {
throw new Error(
`UpstashSandboxBackend: the Upstash Box backend can't honor per-domain network rules ` +
`(transform / forwardURL / match) for "${domain}"; its network policy is a plain ` +
`domain/CIDR allow-list. To inject credentials into outbound requests, set Box's ` +
`attachHeaders at backend creation: upstash({ attachHeaders: { "${domain}": { ... } } }).`,
);
}
}
allowedDomains = Object.keys(allow);
}
return {
mode: "custom",
...(allowedDomains ? { allowedDomains } : {}),
Expand Down
23 changes: 9 additions & 14 deletions packages/eve/src/search-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,30 +28,25 @@ function wrap(def: SearchToolDef): ToolDefinition {
}

/**
* Build eve `search` / `aggregate` / `count` tools over an Upstash Redis Search index the eve
* Build eve `search` / `aggregate` / `count` tools over an Upstash Redis Search index, the eve
* counterpart to the AI SDK adapter's `createSearchTools`. Returns a record of ready (already
* `defineTool`-branded) tools; the index is created on first use (reactively). `redis` defaults to env.
*
* eve is file-centric (filename = tool name), so build the set once in `agent/lib/` and re-export each
* tool from its own file:
* eve snapshots each tool file and resolves only **package** imports, so every `agent/tools/*.ts` file
* must be self-contained: call `defineSearchTools` in each file and export the member you want, repeating
* the same `schema` + `indexName` across them. Don't import a shared `agent/` module (e.g. a
* `../redis.js` or `../lib/book-search.js`) — it fails at the turn step with `Cannot find module`. Omit
* `redis` so it defaults to `Redis.fromEnv()`.
*
* ```ts
* // agent/lib/book-search.ts
* // agent/tools/search_books.ts
* import { s } from "@upstash/redis";
* import { defineSearchTools } from "@upstash/agentkit-eve";
* import { redis } from "../redis.js";
*
* export const bookSearch = defineSearchTools({
* export default defineSearchTools({
* schema: s.object({ title: s.string(), author: s.string().noTokenize(), year: s.number() }),
* indexName: "books",
* redis,
* });
* ```
*
* ```ts
* // agent/tools/search_books.ts
* import { bookSearch } from "../lib/book-search.js";
* export default bookSearch.search; // also: aggregate_books.ts → bookSearch.aggregate, etc.
* }).search; // aggregate_books.ts → .aggregate, count_books.ts → .count (repeat schema + indexName)
* ```
*/
export function defineSearchTools(config: DefineSearchToolsConfig): SearchToolSet {
Expand Down
4 changes: 2 additions & 2 deletions packages/sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ if (!success) throw new Error("rate limited");
<summary>Options</summary>

- **`limiter`** _(required)_ — e.g. `Ratelimit.slidingWindow(20, "1 m")` or `fixedWindow(...)`.
- `redis` — the Upstash Redis client backing the limiter.
- `redis` — the Upstash Redis client backing the limiter; defaults to `Redis.fromEnv()`.
- `prefix` — base key prefix; keys are `<prefix>:<identifier>` (default `agentkit:rateLimit`).

There is no model wrapper; pass a per-user `identifier` to `.limit()` to throttle per user.
Expand All @@ -168,7 +168,7 @@ Memoize deterministic tool results in Redis, keyed by user, then tool, then a ha

```ts
// `wrap` returns a memoized version of your execute, keyed by userId + "getWeather" + the args hash.
const getWeather = tools.wrap("user-123", "getWeather", (args) => fetchWeather(args));
const getWeather = cache.wrap("user-123", "getWeather", (args) => fetchWeather(args));
```

<details>
Expand Down
10 changes: 10 additions & 0 deletions packages/sdk/src/rate-limit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,14 @@ describe.skipIf(!hasRedisCreds)("createRateLimit (live Redis)", () => {
const second = await ratelimit.limit(id);
expect(second.success).toBe(false);
});

// `redis` is optional everywhere in AgentKit; createRateLimit falls back to Redis.fromEnv().
it("defaults redis to Redis.fromEnv() when omitted", async () => {
const ratelimit = createRateLimit({
limiter: Ratelimit.slidingWindow(1, "60 s"),
prefix: `${prefix}:fromenv`,
});
const result = await ratelimit.limit("user-2");
expect(result.success).toBe(true);
});
});
8 changes: 4 additions & 4 deletions packages/sdk/src/rate-limit.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Ratelimit, type Duration } from "@upstash/ratelimit";
import type { Redis } from "@upstash/redis";
import { Redis } from "@upstash/redis";

// Re-export the `@upstash/ratelimit` surface AgentKit users need so they never have to import from
// (or install) `@upstash/ratelimit` directly. `Ratelimit` is the class whose static helpers build a
Expand All @@ -12,8 +12,8 @@ type Limiter = ConstructorParameters<typeof Ratelimit>[0]["limiter"];

/** Configuration for {@link createRateLimit}. */
export interface RateLimitConfig {
/** Upstash Redis client used to back the limiter. */
redis: Redis;
/** Upstash Redis client backing the limiter. Defaults to `Redis.fromEnv()`. */
redis?: Redis;
/** The limiter algorithm, e.g. `Ratelimit.slidingWindow(10, "60 s")` or `Ratelimit.fixedWindow(...)`. */
limiter: Limiter;
/** Key prefix for the limiter. Defaults to `agentkit:rateLimit`; keys are `<prefix>:<identifier>`. */
Expand All @@ -33,7 +33,7 @@ export interface RateLimitConfig {
*/
export function createRateLimit(config: RateLimitConfig): Ratelimit {
return new Ratelimit({
redis: config.redis,
redis: config.redis ?? Redis.fromEnv(),
limiter: config.limiter,
prefix: config.prefix ?? "agentkit:rateLimit",
});
Expand Down
Loading
Loading