diff --git a/.changeset/vercel-sandbox-user-agent.md b/.changeset/vercel-sandbox-user-agent.md new file mode 100644 index 000000000..1af7df9b1 --- /dev/null +++ b/.changeset/vercel-sandbox-user-agent.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-sandbox-vercel': patch +--- + +Vercel sandbox requests now append a `@tanstack/ai` token to the `user-agent`. diff --git a/packages/ai-sandbox-vercel/src/provider.ts b/packages/ai-sandbox-vercel/src/provider.ts index b172c0d65..c4da4beeb 100644 --- a/packages/ai-sandbox-vercel/src/provider.ts +++ b/packages/ai-sandbox-vercel/src/provider.ts @@ -1,5 +1,6 @@ import { APIError, Sandbox } from '@vercel/sandbox' import { VERCEL_CAPS, VercelHandle } from './handle' +import { withSandboxUserAgent } from './user-agent' import type { SandboxCapabilities, SandboxCreateInput, @@ -67,6 +68,8 @@ export function isDirAlreadyExistsError(error: unknown): boolean { class VercelProvider implements SandboxProvider { readonly name = 'vercel' + private readonly fetch = withSandboxUserAgent() + constructor(private readonly config: VercelSandboxConfig) {} capabilities(): SandboxCapabilities { @@ -101,6 +104,7 @@ class VercelProvider implements SandboxProvider { async create(input: SandboxCreateInput): Promise { const sandbox = await Sandbox.create({ ...this.auth(), + fetch: this.fetch, runtime: this.config.runtime ?? DEFAULT_RUNTIME, ...(this.config.timeout !== undefined ? { timeout: this.config.timeout } @@ -133,7 +137,11 @@ class VercelProvider implements SandboxProvider { async resume(input: SandboxResumeInput): Promise { try { - const sandbox = await Sandbox.get({ name: input.id, ...this.auth() }) + const sandbox = await Sandbox.get({ + name: input.id, + ...this.auth(), + fetch: this.fetch, + }) return new VercelHandle({ sandbox, workdir: this.workdir, @@ -147,7 +155,11 @@ class VercelProvider implements SandboxProvider { async destroy(input: SandboxDestroyInput): Promise { try { - const sandbox = await Sandbox.get({ name: input.id, ...this.auth() }) + const sandbox = await Sandbox.get({ + name: input.id, + ...this.auth(), + fetch: this.fetch, + }) await sandbox.stop() } catch { // Already stopped / gone. diff --git a/packages/ai-sandbox-vercel/src/user-agent.ts b/packages/ai-sandbox-vercel/src/user-agent.ts new file mode 100644 index 000000000..2a96f9077 --- /dev/null +++ b/packages/ai-sandbox-vercel/src/user-agent.ts @@ -0,0 +1,33 @@ +/** + * User-agent tagging for Vercel Sandbox traffic. + * + * The `@vercel/sandbox` SDK sends a `user-agent` of + * `vercel/sandbox/ (…)` on every API request. We wrap the SDK's + * `fetch` so each request also carries a `@tanstack/ai` token. + */ + +export const USER_AGENT_TOKEN = '@tanstack/ai' + +/** + * Wraps a `fetch` so every request's `user-agent` contains the package name + * (e.g. `vercel/sandbox/2.2.1 (…) @tanstack/ai`). When no + * `user-agent` is present, the token becomes the whole header. + */ +export function withSandboxUserAgent( + inner: typeof globalThis.fetch = globalThis.fetch, +): typeof globalThis.fetch { + return (input, init) => { + const headers = new Headers( + init?.headers ?? + (typeof input === 'object' && 'headers' in input + ? input.headers + : undefined), + ) + const existing = headers.get('user-agent') + headers.set( + 'user-agent', + existing ? `${existing} ${USER_AGENT_TOKEN}` : USER_AGENT_TOKEN, + ) + return inner(input, { ...init, headers }) + } +} diff --git a/packages/ai-sandbox-vercel/tests/user-agent.test.ts b/packages/ai-sandbox-vercel/tests/user-agent.test.ts new file mode 100644 index 000000000..e75c21c0e --- /dev/null +++ b/packages/ai-sandbox-vercel/tests/user-agent.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it, vi } from 'vitest' +import { USER_AGENT_TOKEN, withSandboxUserAgent } from '../src/user-agent' + +function userAgentOf(init: RequestInit | undefined): string | null { + return new Headers(init?.headers).get('user-agent') +} + +describe('withSandboxUserAgent', () => { + it('appends the token to an existing user-agent (as the SDK sends)', async () => { + const inner = vi + .fn() + .mockResolvedValue(new Response()) + const wrapped = withSandboxUserAgent(inner) + + await wrapped('https://api.vercel.com/v2/sandboxes', { + headers: { 'user-agent': 'vercel/sandbox/2.2.1' }, + }) + + const [, init] = inner.mock.calls[0]! + expect(userAgentOf(init)).toBe(`vercel/sandbox/2.2.1 ${USER_AGENT_TOKEN}`) + }) + + it('sets the token as the user-agent when none is present', async () => { + const inner = vi + .fn() + .mockResolvedValue(new Response()) + const wrapped = withSandboxUserAgent(inner) + + await wrapped('https://api.vercel.com/v2/sandboxes') + + const [, init] = inner.mock.calls[0]! + expect(userAgentOf(init)).toBe(USER_AGENT_TOKEN) + }) + + it('preserves other request init fields (method, body, signal)', async () => { + const inner = vi + .fn() + .mockResolvedValue(new Response()) + const wrapped = withSandboxUserAgent(inner) + const signal = new AbortController().signal + + await wrapped('https://api.vercel.com/v2/sandboxes', { + method: 'POST', + body: '{"ok":true}', + signal, + }) + + const [, init] = inner.mock.calls[0]! + expect(init?.method).toBe('POST') + expect(init?.body).toBe('{"ok":true}') + expect(init?.signal).toBe(signal) + }) + + it('reads the user-agent off a Request input when no init is given', async () => { + const inner = vi + .fn() + .mockResolvedValue(new Response()) + const wrapped = withSandboxUserAgent(inner) + + await wrapped( + new Request('https://api.vercel.com/v2/sandboxes', { + headers: { 'user-agent': 'vercel/sandbox/2.2.1' }, + }), + ) + + const [, init] = inner.mock.calls[0]! + expect(userAgentOf(init)).toBe(`vercel/sandbox/2.2.1 ${USER_AGENT_TOKEN}`) + }) + + it('defaults to globalThis.fetch when no inner fetch is supplied', () => { + expect(typeof withSandboxUserAgent()).toBe('function') + }) +})