diff --git a/.env.example b/.env.example index 658b6678..d963a48b 100644 --- a/.env.example +++ b/.env.example @@ -37,6 +37,12 @@ ENCRYPTION_KEY="ZGV2LW9ubHktZW5jcnlwdGlvbi1rZXktMzItYnl0ZXM=" # Who can create an account: addresses and domains, comma-separated. Unset, anyone can. # ALLOWED_SIGNUP_EMAILS="" +# The sandboxes agents can ask for: off, or remote with the sandbox service's address and token, +# or dev, a directory on this machine with no isolation, for trying things out. +# SANDBOX_BACKEND="dev" +# SANDBOX_SERVICE_URL="" +# SANDBOX_SERVICE_TOKEN="" + # Self-hosting reads more; every variable is in the docs' Environment variables page, built from # packages/api/src/utils/env-schema.ts. `bun run dev` sets NODE_ENV=development on the api. diff --git a/Dockerfile b/Dockerfile index bd5f9f11..bebef6c1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,6 +23,7 @@ WORKDIR /app RUN apt-get update && apt-get install -y --no-install-recommends openssl && rm -rf /var/lib/apt/lists/* COPY package.json bun.lock ./ COPY packages/api/package.json packages/api/ +COPY packages/backends/package.json packages/backends/ COPY packages/contracts/package.json packages/contracts/ COPY packages/db/package.json packages/db/ COPY packages/db/prisma packages/db/prisma @@ -37,6 +38,7 @@ RUN bun --bun packages/db/node_modules/.bin/prisma generate --schema packages/db COPY packages/api/src packages/api/src COPY packages/api/tsconfig.json packages/api/ +COPY packages/backends/src packages/backends/src COPY packages/contracts/src packages/contracts/src COPY packages/db/src packages/db/src COPY packages/integrations/src packages/integrations/src diff --git a/bun.lock b/bun.lock index 6adeac43..a6067193 100644 --- a/bun.lock +++ b/bun.lock @@ -12,6 +12,7 @@ "name": "@proxy/api", "version": "0.0.0", "dependencies": { + "@proxy/backends": "workspace:*", "@proxy/contracts": "workspace:*", "@proxy/db": "workspace:*", "@proxy/integrations": "workspace:*", @@ -38,6 +39,20 @@ "typescript": "~6.0.2", }, }, + "packages/backends": { + "name": "@proxy/backends", + "version": "0.0.0", + "dependencies": { + "@proxy/contracts": "workspace:*", + "@proxy/utils": "workspace:*", + "ts-results-es": "^7.1.0", + "zod": "^4.5.4", + }, + "devDependencies": { + "@types/bun": "latest", + "typescript": "~6.0.2", + }, + }, "packages/contracts": { "name": "@proxy/contracts", "version": "0.0.0", @@ -602,6 +617,8 @@ "@proxy/api": ["@proxy/api@workspace:packages/api"], + "@proxy/backends": ["@proxy/backends@workspace:packages/backends"], + "@proxy/contracts": ["@proxy/contracts@workspace:packages/contracts"], "@proxy/db": ["@proxy/db@workspace:packages/db"], diff --git a/docker-compose.yml b/docker-compose.yml index 4a6345b3..faa75b4e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -42,6 +42,9 @@ services: EMAIL_FROM: ${EMAIL_FROM:-} TYPESAFE_API_KEY: ${TYPESAFE_API_KEY:-} OPENROUTER_KEY: ${OPENROUTER_KEY:-} + SANDBOX_BACKEND: ${SANDBOX_BACKEND:-} + SANDBOX_SERVICE_URL: ${SANDBOX_SERVICE_URL:-} + SANDBOX_SERVICE_TOKEN: ${SANDBOX_SERVICE_TOKEN:-} TELEMETRY_ENABLED: ${TELEMETRY_ENABLED:-} logging: *logging diff --git a/packages/api/package.json b/packages/api/package.json index 4ab81aba..2bf154e3 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -14,6 +14,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@proxy/backends": "workspace:*", "@proxy/contracts": "workspace:*", "@proxy/db": "workspace:*", "@proxy/integrations": "workspace:*", diff --git a/packages/api/src/backends/backends.ts b/packages/api/src/backends/backends.ts new file mode 100644 index 00000000..afede813 --- /dev/null +++ b/packages/api/src/backends/backends.ts @@ -0,0 +1,42 @@ +import {join} from 'node:path'; +import {tmpdir} from 'node:os'; + +import type {SandboxBackend} from '@proxy/backends/sandbox/backend'; +import {remoteSandboxBackend} from '@proxy/backends/sandbox/remote'; +import {tempDirSandboxBackend} from '@proxy/backends/sandbox/temp-dir'; + +import {log} from '../observability/log'; +import {env} from '../utils/env'; + +/** + * The backends this server runs with: what runs the machines a capability of Personal Agent + * Proxy's own needs, the sandboxes agents ask for first, each behind the contract + * `@proxy/backends` owns and chosen by the environment. `off` is no backend, and the capability + * with it, which the catalog then leaves out; `remote` a service elsewhere that speaks the + * protocol, hosted Personal Agent Proxy's own among them; `dev` the one the tests run on, a + * directory on this machine with no isolation at all, which only a development server takes. + * Read once as the server starts, as the environment is; a choice that can't be made is logged + * and is none. + */ + +export type Backends = {sandbox: SandboxBackend | null}; + +function sandboxBackend(): SandboxBackend | null { + if (env.SANDBOX_BACKEND === 'off') { + return null; + } + if (env.SANDBOX_BACKEND === 'dev') { + if (env.NODE_ENV !== 'development') { + log.error('SANDBOX_BACKEND=dev runs commands on this machine with no isolation, so only a development server takes it; sandboxes are off'); + return null; + } + return tempDirSandboxBackend(join(tmpdir(), 'proxy-sandboxes')); + } + if (!env.SANDBOX_SERVICE_URL || !env.SANDBOX_SERVICE_TOKEN) { + log.error('SANDBOX_BACKEND=remote needs SANDBOX_SERVICE_URL and SANDBOX_SERVICE_TOKEN; sandboxes are off'); + return null; + } + return remoteSandboxBackend({url: env.SANDBOX_SERVICE_URL, token: env.SANDBOX_SERVICE_TOKEN}); +} + +export const backends: Backends = {sandbox: sandboxBackend()}; diff --git a/packages/api/src/utils/env-schema.ts b/packages/api/src/utils/env-schema.ts index ec8d3202..343536d4 100644 --- a/packages/api/src/utils/env-schema.ts +++ b/packages/api/src/utils/env-schema.ts @@ -99,6 +99,20 @@ export const envSchema = z.object({ description: 'Which of Plaid’s environments to use: `sandbox` connects only Plaid’s test banks, signed in to with `user_good` and `pass_good`.', }), + SANDBOX_BACKEND: documented(z.enum(['off', 'remote', 'dev']).default('off'), { + group: 'Connections', + description: + 'What runs the sandboxes agents can ask for, computers of their own to work on: `off` offers none; `remote` a sandbox service reached at `SANDBOX_SERVICE_URL` with `SANDBOX_SERVICE_TOKEN`, speaking the protocol in `@proxy/contracts/sandbox`, as hosted Personal Agent Proxy runs; `dev` a directory on this server with no isolation at all, which only `bun run dev` takes.', + }), + SANDBOX_SERVICE_URL: documented(originSchema.optional(), { + group: 'Connections', + description: 'Where the sandbox service is, for `SANDBOX_BACKEND=remote`, such as `https://sandboxes.example.com`.', + }), + SANDBOX_SERVICE_TOKEN: documented(z.string().min(1).optional(), { + group: 'Connections', + description: 'What the sandbox service takes as the bearer token, for `SANDBOX_BACKEND=remote`: the same long random string on both sides.', + }), + TYPESAFE_API_KEY: documented(z.string().min(1).optional(), { group: 'Core', description: diff --git a/packages/api/src/utils/env.ts b/packages/api/src/utils/env.ts index dba05cc2..d7bc38f9 100644 --- a/packages/api/src/utils/env.ts +++ b/packages/api/src/utils/env.ts @@ -22,6 +22,9 @@ export const env = envSchema.parse({ GOOGLE_CLIENT_SECRET: read('GOOGLE_CLIENT_SECRET'), RESEND_KEY: read('RESEND_KEY'), EMAIL_FROM: read('EMAIL_FROM'), + SANDBOX_BACKEND: read('SANDBOX_BACKEND'), + SANDBOX_SERVICE_URL: read('SANDBOX_SERVICE_URL'), + SANDBOX_SERVICE_TOKEN: read('SANDBOX_SERVICE_TOKEN'), TYPESAFE_API_KEY: read('TYPESAFE_API_KEY'), OPENROUTER_KEY: read('OPENROUTER_KEY'), ALLOWED_SIGNUP_EMAILS: read('ALLOWED_SIGNUP_EMAILS'), diff --git a/packages/backends/.prettierrc.json b/packages/backends/.prettierrc.json new file mode 100644 index 00000000..4d039c27 --- /dev/null +++ b/packages/backends/.prettierrc.json @@ -0,0 +1,10 @@ +{ + "useTabs": true, + "semi": true, + "singleQuote": true, + "quoteProps": "as-needed", + "trailingComma": "all", + "bracketSpacing": false, + "printWidth": 200, + "embeddedLanguageFormatting": "off" +} diff --git a/packages/backends/README.md b/packages/backends/README.md new file mode 100644 index 00000000..85141031 --- /dev/null +++ b/packages/backends/README.md @@ -0,0 +1,10 @@ +# @proxy/backends + +The backends: what runs the machines Personal Agent Proxy needs for a capability of its own, +behind a contract this package owns. One directory per capability, the sandboxes agents ask for +first (`src/sandbox`): the contract (`backend.ts`), the tests every implementation passes +(`conformance.ts`), the implementation the tests run on (`temp-dir.ts`), a client for one run as +a service elsewhere (`remote.ts`) and what puts any implementation behind that protocol +(`serve.ts`), which is in `@proxy/contracts/sandbox`. The api picks one by its environment +(`packages/api/src/backends`); hosted Personal Agent Proxy runs its own behind the protocol, +outside this repository, as anyone may. diff --git a/packages/backends/package.json b/packages/backends/package.json new file mode 100644 index 00000000..6e549d42 --- /dev/null +++ b/packages/backends/package.json @@ -0,0 +1,23 @@ +{ + "name": "@proxy/backends", + "private": true, + "version": "0.0.0", + "type": "module", + "exports": { + "./*": "./src/*.ts" + }, + "scripts": { + "test": "bun test", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@proxy/contracts": "workspace:*", + "@proxy/utils": "workspace:*", + "ts-results-es": "^7.1.0", + "zod": "^4.5.4" + }, + "devDependencies": { + "@types/bun": "latest", + "typescript": "~6.0.2" + } +} diff --git a/packages/api/src/backends/sandbox/backend.ts b/packages/backends/src/sandbox/backend.ts similarity index 80% rename from packages/api/src/backends/sandbox/backend.ts rename to packages/backends/src/sandbox/backend.ts index 5397cead..fb121921 100644 --- a/packages/api/src/backends/sandbox/backend.ts +++ b/packages/backends/src/sandbox/backend.ts @@ -16,10 +16,15 @@ import type {ApiError} from '@proxy/utils'; export type SandboxRef = string; -/** A command for the sandbox's shell, run in `cwd` or the sandbox's home, fed `stdin` where given, and stopped after `timeoutMs`. */ +/** + * A command for the sandbox's shell, run in `cwd` or the sandbox's home, fed `stdin` where given + * and nothing otherwise, and stopped after `timeoutMs`. A command is one sitting: what it leaves + * running in the background may not outlive it, as it doesn't on every backend, so long work is + * done within the timeout, or in parts. + */ export type SandboxCommand = {command: string; stdin?: string; cwd?: string; timeoutMs: number}; -/** What a command wrote and how it ended: `exitCode` null where it was stopped, `timedOut` whether by the timeout. */ +/** What a command wrote and how it ended: `exitCode` null where it was stopped, `timedOut` whether by the timeout; what it wrote before being stopped as far as the backend kept it. */ export type SandboxOutput = {stdout: string; stderr: string; exitCode: number | null; timedOut: boolean}; export type SandboxBackend = { @@ -29,7 +34,7 @@ export type SandboxBackend = { create: (sandbox: {tenant: string; sandboxId: string}) => Promise>; /** Runs a command in a running sandbox; conflict for one asleep, not found for a ref it doesn't know. */ exec: (ref: SandboxRef, command: SandboxCommand) => Promise>; - /** Stops the sandbox's compute, keeping its disk, and gives its ref from now on. What was running is gone. */ + /** Stops the sandbox's compute, keeping its disk, and gives its ref from now on. Whatever a command left running is gone. */ sleep: (ref: SandboxRef) => Promise>; /** Starts an asleep sandbox again, its disk as it was, and gives its ref from now on. */ wake: (ref: SandboxRef) => Promise>; diff --git a/packages/api/src/backends/sandbox/conformance.ts b/packages/backends/src/sandbox/conformance.ts similarity index 82% rename from packages/api/src/backends/sandbox/conformance.ts rename to packages/backends/src/sandbox/conformance.ts index 91b8fe18..77321f72 100644 --- a/packages/api/src/backends/sandbox/conformance.ts +++ b/packages/backends/src/sandbox/conformance.ts @@ -58,21 +58,6 @@ export function sandboxBackendTests(make: () => Promise): void { expect(await exec(woken, 'cat kept.txt')).toMatchObject({stdout: 'kept\n', exitCode: 0}); }); - test('sleeping ends what the sandbox was running', async () => { - const ref = await create('busy'); - await exec(ref, 'nohup sleep 300 >/dev/null 2>&1 & echo $! > pid'); - expect(await exec(ref, 'kill -0 "$(cat pid)" && echo alive')).toMatchObject({stdout: 'alive\n'}); - const woken = (await backend.wake((await backend.sleep(ref)).unwrap())).unwrap(); - made.push(woken); - - // A process just killed may be a moment from being reaped, which kill -0 still counts. - let last = ''; - for (let tries = 0; tries < 20 && last !== 'gone\n'; tries++) { - last = (await exec(woken, 'kill -0 "$(cat pid)" 2>/dev/null && echo alive || echo gone')).stdout; - } - expect(last).toBe('gone\n'); - }); - test('sandboxes keep apart, of one tenant and of two', async () => { const one = await create('one'); const two = await create('two'); @@ -87,7 +72,8 @@ export function sandboxBackendTests(make: () => Promise): void { const ref = await create('slow'); const started = Date.now(); - expect((await backend.exec(ref, {command: 'echo begun; sleep 20; echo done', timeoutMs: 500})).unwrap()).toMatchObject({stdout: 'begun\n', exitCode: null, timedOut: true}); + // What it wrote before being stopped is kept where the backend can: not promised. + expect((await backend.exec(ref, {command: 'echo begun; sleep 20; echo done', timeoutMs: 500})).unwrap()).toMatchObject({exitCode: null, timedOut: true}); expect(Date.now() - started).toBeLessThan(10_000); }); diff --git a/packages/backends/src/sandbox/remote.test.ts b/packages/backends/src/sandbox/remote.test.ts new file mode 100644 index 00000000..8bee69be --- /dev/null +++ b/packages/backends/src/sandbox/remote.test.ts @@ -0,0 +1,52 @@ +import {afterAll, describe, expect, test} from 'bun:test'; +import {mkdtempSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; + +import {sandboxBackendTests} from './conformance'; +import {remoteSandboxBackend} from './remote'; +import {serveSandboxBackend} from './serve'; +import {tempDirSandboxBackend} from './temp-dir'; + +// The protocol, both ends: the temp-dir backend behind the server, the remote client in front. +const TOKEN = 'a-token-long-enough-for-the-tests-to-use'; +const servers: Array> = []; + +function serve(token = TOKEN): string { + const backend = tempDirSandboxBackend(mkdtempSync(join(tmpdir(), 'proxy-sandboxes-'))); + const server = Bun.serve({port: 0, fetch: serveSandboxBackend(backend, {token})}); + servers.push(server); + return `http://localhost:${server.port}`; +} + +afterAll(async () => { + await Promise.all(servers.map((server) => server.stop(true))); +}); + +sandboxBackendTests(async () => remoteSandboxBackend({url: serve(), token: TOKEN})); + +describe('over the protocol', () => { + test('a wrong token is the service being unavailable to this server, and nothing is run', async () => { + const remote = remoteSandboxBackend({url: serve(), token: 'another'}); + expect((await remote.create({tenant: 'org-1', sandboxId: 'one'})).unwrapErr()).toMatchObject({kind: 'unavailable'}); + }); + + test('the health check takes no token', async () => { + const response = await fetch(`${serve()}/health`); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ok: true}); + }); + + test('a service that cannot be reached is a provider out of reach', async () => { + const remote = remoteSandboxBackend({url: 'http://localhost:1', token: TOKEN}); + expect((await remote.wake('nothing')).unwrapErr()).toMatchObject({kind: 'provider_unreachable'}); + }); + + test('what the service refuses comes back as its kind, with its words', async () => { + const url = serve(); + const response = await fetch(`${url}/sandboxes`, {method: 'POST', headers: {authorization: `Bearer ${TOKEN}`, 'content-type': 'application/json'}, body: '{"tenant": ""}'}); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({error: {kind: 'parse_error'}}); + expect((await fetch(`${url}/nothing`, {headers: {authorization: `Bearer ${TOKEN}`}})).status).toBe(404); + }); +}); diff --git a/packages/backends/src/sandbox/remote.ts b/packages/backends/src/sandbox/remote.ts new file mode 100644 index 00000000..d8190e69 --- /dev/null +++ b/packages/backends/src/sandbox/remote.ts @@ -0,0 +1,90 @@ +import type {Result} from 'ts-results-es'; +import type {z} from 'zod'; + +import {ApiErr, type ApiError, type FetchError, httpRequest} from '@proxy/utils'; +import { + createSandboxBodySchema, + execSandboxBodySchema, + sandboxDestroyedSchema, + sandboxErrorSchema, + sandboxOutputSchema, + sandboxRefSchema, + type CreateSandboxBody, + type ExecSandboxBody, +} from '@proxy/contracts/sandbox'; + +import type {SandboxBackend} from './backend'; + +/** + * A sandbox backend run as a service elsewhere, reached over the protocol + * (`@proxy/contracts/sandbox`): hosted Personal Agent Proxy's own, or anyone's that speaks it. + * What the service says went wrong comes back as the api's own kind of error; a service that + * can't be reached, or answers with something else, is a provider out of reach, as any + * provider is. + */ + +// How long a request may take beyond what the command itself may. +const REQUEST_MS = 30_000; + +export type RemoteSandboxOptions = {url: string; token: string}; + +const SERVICE_UNREACHABLE = 'The sandbox service could not be reached. Try again in a moment.'; + +// The service's error as the api's: its kind where the api has it, with what the service said. +function toApiError(error: FetchError): ApiError { + if (error.kind !== 'http') { + return ApiErr.providerUnreachable(error, SERVICE_UNREACHABLE); + } + const parsed = sandboxErrorSchema.safeParse(error.body); + if (!parsed.success) { + return ApiErr.providerUnreachable(error, SERVICE_UNREACHABLE); + } + const {kind, message} = parsed.data.error; + if (kind === 'not_found') { + return ApiErr.notFound('sandbox', undefined, message); + } + if (kind === 'conflict') { + return ApiErr.conflict(message ?? 'The sandbox is busy'); + } + if (kind === 'validation_error' || kind === 'parse_error') { + return ApiErr.validationError(message ?? 'The sandbox service refused the request'); + } + if (kind === 'forbidden' || kind === 'unauthenticated') { + return ApiErr.unavailable('The sandbox service refused this server’s token'); + } + return ApiErr.providerUnreachable(error, message ?? SERVICE_UNREACHABLE); +} + +/** A backend at `url`, every request carrying `token`. */ +export function remoteSandboxBackend({url, token}: RemoteSandboxOptions): SandboxBackend { + const base = url.replace(/\/+$/, ''); + + const call = async ( + method: 'POST' | 'DELETE', + path: string, + body: CreateSandboxBody | ExecSandboxBody | null, + schema: S, + timeoutMs = REQUEST_MS, + ): Promise, ApiError>> => { + const answer = await httpRequest( + `${base}${path}`, + { + method, + headers: {authorization: `Bearer ${token}`, ...(body === null ? {} : {'content-type': 'application/json'})}, + ...(body === null ? {} : {body: JSON.stringify(body)}), + }, + {schema, timeoutMs}, + ); + return answer.mapErr(toApiError); + }; + const at = (ref: string, action = ''): string => `/sandboxes/${encodeURIComponent(ref)}${action}`; + + return { + id: 'remote', + create: async (sandbox) => (await call('POST', '/sandboxes', createSandboxBodySchema.parse(sandbox), sandboxRefSchema)).map(({ref}) => ref), + exec: (ref, command) => call('POST', at(ref, '/exec'), execSandboxBodySchema.parse(command), sandboxOutputSchema, command.timeoutMs + REQUEST_MS), + sleep: async (ref) => (await call('POST', at(ref, '/sleep'), null, sandboxRefSchema)).map((answer) => answer.ref), + wake: async (ref) => (await call('POST', at(ref, '/wake'), null, sandboxRefSchema)).map((answer) => answer.ref), + destroy: async (ref) => (await call('DELETE', at(ref), null, sandboxDestroyedSchema)).map(() => undefined), + }; +} diff --git a/packages/backends/src/sandbox/serve.ts b/packages/backends/src/sandbox/serve.ts new file mode 100644 index 00000000..7098fd62 --- /dev/null +++ b/packages/backends/src/sandbox/serve.ts @@ -0,0 +1,117 @@ +import {createHash, timingSafeEqual} from 'node:crypto'; + +import {Err, Result} from 'ts-results-es'; +import type {z} from 'zod'; + +import {ApiErr, type ApiError, parseSchema} from '@proxy/utils'; +import {createSandboxBodySchema, execSandboxBodySchema, type SandboxError} from '@proxy/contracts/sandbox'; + +import type {SandboxBackend} from './backend'; + +/** + * Any sandbox backend behind the protocol (`@proxy/contracts/sandbox`), as a handler of + * requests for `Bun.serve` or anything that takes one: what hosted Personal Agent Proxy runs its + * own behind, and the conformance tests run the client against. Every request but the health + * check carries the token; what a backend says went wrong goes out as the api's kind of error, + * with its status, and without its cause, which is the service's own. + */ + +export type ServeSandboxOptions = {token: string}; + +export type RequestHandler = (request: Request) => Promise; + +function sha256(text: string): Buffer { + return createHash('sha256').update(text).digest(); +} + +// Both sides hashed first, so the comparison takes the same time whatever was sent. +function hasToken(request: Request, token: string): boolean { + const header = request.headers.get('authorization'); + if (!header?.startsWith('Bearer ')) { + return false; + } + return timingSafeEqual(sha256(header.slice('Bearer '.length)), sha256(token)); +} + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), {status, headers: {'content-type': 'application/json'}}); +} + +// What the client is told: the kind, and the message where the error is one that carries words +// for the caller; never a cause. +function failure(error: ApiError): Response { + const message = 'message' in error ? error.message : undefined; + const body: SandboxError = {error: {kind: error.kind, ...(message === undefined ? {} : {message})}}; + return json(body, error.statusCode); +} + +function answer(result: Result): Response { + if (result.isErr()) { + return failure(result.error); + } + return json(result.value); +} + +async function readBody(request: Request, schema: z.ZodType): Promise> { + const text = await Result.wrapAsync(() => request.text()); + if (text.isErr()) { + return Err(ApiErr.validationError('The body could not be read')); + } + const value = Result.wrap((): unknown => JSON.parse(text.value || '{}')); + if (value.isErr()) { + return Err(ApiErr.validationError('The body is not JSON')); + } + return parseSchema(schema, value.value); +} + +// `/sandboxes/` and `/sandboxes//`: the ref, decoded, and the action if any. +function sandboxPath(pathname: string): {ref: string; action: string | null} | null { + const match = /^\/sandboxes\/([^/]+)(?:\/(exec|sleep|wake))?$/.exec(pathname); + if (!match?.[1]) { + return null; + } + const decoded = Result.wrap(() => decodeURIComponent(match[1] ?? '')); + if (decoded.isErr()) { + return null; + } + return {ref: decoded.value, action: match[2] ?? null}; +} + +/** The handler: `backend` behind the protocol, taking only requests that carry `token`. */ +export function serveSandboxBackend(backend: SandboxBackend, {token}: ServeSandboxOptions): RequestHandler { + return async (request) => { + const {pathname} = new URL(request.url); + if (request.method === 'GET' && pathname === '/health') { + return json({ok: true}); + } + if (!hasToken(request, token)) { + return failure(ApiErr.unauthenticated()); + } + if (request.method === 'POST' && pathname === '/sandboxes') { + const body = await readBody(request, createSandboxBodySchema); + if (body.isErr()) { + return failure(body.error); + } + return answer((await backend.create(body.value)).map((ref) => ({ref}))); + } + const path = sandboxPath(pathname); + if (!path) { + return failure(ApiErr.notFound('route', pathname)); + } + if (request.method === 'DELETE' && path.action === null) { + return answer((await backend.destroy(path.ref)).map(() => ({}))); + } + if (request.method !== 'POST' || path.action === null) { + return failure(ApiErr.notFound('route', pathname)); + } + if (path.action === 'exec') { + const body = await readBody(request, execSandboxBodySchema); + if (body.isErr()) { + return failure(body.error); + } + return answer(await backend.exec(path.ref, body.value)); + } + const moved = path.action === 'sleep' ? await backend.sleep(path.ref) : await backend.wake(path.ref); + return answer(moved.map((ref) => ({ref}))); + }; +} diff --git a/packages/api/src/backends/sandbox/temp-dir.test.ts b/packages/backends/src/sandbox/temp-dir.test.ts similarity index 81% rename from packages/api/src/backends/sandbox/temp-dir.test.ts rename to packages/backends/src/sandbox/temp-dir.test.ts index 3ac0b5d3..8b4a1155 100644 --- a/packages/api/src/backends/sandbox/temp-dir.test.ts +++ b/packages/backends/src/sandbox/temp-dir.test.ts @@ -1,13 +1,18 @@ import {describe, expect, test} from 'bun:test'; +import {mkdtempSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; import {sandboxBackendTests} from './conformance'; import {tempDirSandboxBackend} from './temp-dir'; -sandboxBackendTests(tempDirSandboxBackend); +const fresh = () => tempDirSandboxBackend(mkdtempSync(join(tmpdir(), 'proxy-sandboxes-'))); + +sandboxBackendTests(async () => fresh()); describe('tempDirSandboxBackend', () => { test('gives a command nothing of this process’s environment, and keeps only so much of what it writes', async () => { - const backend = await tempDirSandboxBackend(); + const backend = fresh(); const ref = (await backend.create({tenant: 'org-1', sandboxId: 'env'})).unwrap(); const seen = (await backend.exec(ref, {command: 'env | cut -d= -f1 | sort | tr "\\n" " "', timeoutMs: 10_000})).unwrap(); @@ -23,7 +28,7 @@ describe('tempDirSandboxBackend', () => { }); test('a ref outside its own directory is not found, however the path is written', async () => { - const backend = await tempDirSandboxBackend(); + const backend = fresh(); expect((await backend.exec('/tmp', {command: 'true', timeoutMs: 1_000})).unwrapErr()).toMatchObject({kind: 'not_found'}); expect((await backend.exec('/tmp/../tmp', {command: 'true', timeoutMs: 1_000})).unwrapErr()).toMatchObject({kind: 'not_found'}); }); diff --git a/packages/api/src/backends/sandbox/temp-dir.ts b/packages/backends/src/sandbox/temp-dir.ts similarity index 80% rename from packages/api/src/backends/sandbox/temp-dir.ts rename to packages/backends/src/sandbox/temp-dir.ts index 80febe6e..ba2f7031 100644 --- a/packages/api/src/backends/sandbox/temp-dir.ts +++ b/packages/backends/src/sandbox/temp-dir.ts @@ -1,6 +1,5 @@ import {spawn} from 'node:child_process'; -import {mkdtemp, mkdir, rm, stat} from 'node:fs/promises'; -import {tmpdir} from 'node:os'; +import {mkdir, rm, stat} from 'node:fs/promises'; import {join, resolve} from 'node:path'; import {Err, Result} from 'ts-results-es'; @@ -10,12 +9,13 @@ import {ApiErr, type ApiError, Do} from '@proxy/utils'; import type {SandboxBackend, SandboxCommand, SandboxOutput, SandboxRef} from './backend'; /** - * The backend the tests run against: a directory under the temporary directory for each sandbox, - * commands run in it by this machine's own shell, as this process, with nothing but a path and a - * locale in their environment. Nothing is isolated: a command reaches the whole machine, so this - * is never a backend for people, only what the conformance tests, and the tests of what uses - * sandboxes, run on. A ref is the sandbox's directory, the same asleep as running; sleeping ends - * what the sandbox was running, as it does on any backend. + * The backend the tests run against, and a developer's own machine with `SANDBOX_BACKEND=dev`: + * a directory under `root` for each sandbox, commands run in it by this machine's own shell, as + * this process, with nothing but a path and a locale in their environment. Nothing is isolated: + * a command reaches the whole machine, so this is never a backend for people, only what the + * conformance tests, the tests of what uses sandboxes, and a developer trying things run on. A + * ref is the sandbox's directory, the same asleep as running; sleeping ends what the sandbox was + * running, as it does on any backend. */ // How much of what a command wrote is kept. @@ -39,7 +39,8 @@ function killGroup(group: number, signal: 'TERM' | 'KILL'): Promise { } // Runs the command in the directory, in a process group of its own, which `groups` notes so the -// sandbox's sleep can end what the command left running; killed as a whole on timeout. +// sandbox's sleep can end what the command left running; killed as a whole on timeout. What a +// command leaves running outlives it here, which the contract doesn't promise. function run(directory: string, command: SandboxCommand, groups: Set): Promise { return new Promise((resolveOutput) => { const child = spawn('/bin/sh', ['-c', command.command], { @@ -85,9 +86,9 @@ function run(directory: string, command: SandboxCommand, groups: Set): P }); } -/** A backend with its sandboxes under a fresh directory of the temporary directory. */ -export async function tempDirSandboxBackend(): Promise { - const root = await mkdtemp(join(tmpdir(), 'proxy-sandboxes-')); +/** A backend with its sandboxes under `root`, a directory of this machine, made as the first sandbox is. */ +export function tempDirSandboxBackend(root: string): SandboxBackend { + const base = resolve(root); // The process groups each sandbox's commands ran in, by its directory, until it sleeps. const running = new Map>(); @@ -95,7 +96,7 @@ export async function tempDirSandboxBackend(): Promise { const requireDirectory = (ref: SandboxRef): Promise> => Do(async ($) => { const directory = resolve(ref); - const found = directory.startsWith(`${root}/`) ? await Result.wrapAsync(() => stat(directory)) : null; + const found = directory.startsWith(`${base}/`) ? await Result.wrapAsync(() => stat(directory)) : null; if (!found?.isOk() || !found.value.isDirectory()) { return $(Err(notFound(ref))); } @@ -111,7 +112,7 @@ export async function tempDirSandboxBackend(): Promise { id: 'temp-dir', create: ({tenant, sandboxId}) => Do(async ($) => { - const directory = join(root, tenant, sandboxId); + const directory = join(base, tenant, sandboxId); $((await Result.wrapAsync(() => mkdir(directory, {recursive: true}))).mapErr(ApiErr.internalError)); return directory; }), diff --git a/packages/backends/tsconfig.json b/packages/backends/tsconfig.json new file mode 100644 index 00000000..5b18d1bb --- /dev/null +++ b/packages/backends/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "es2023", + "lib": ["ES2023", "DOM"], + "module": "esnext", + "moduleResolution": "bundler", + "types": ["bun"], + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "skipLibCheck": true, + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noUncheckedIndexedAccess": true + }, + "include": ["src"] +} diff --git a/packages/contracts/src/sandbox.ts b/packages/contracts/src/sandbox.ts new file mode 100644 index 00000000..e62cab91 --- /dev/null +++ b/packages/contracts/src/sandbox.ts @@ -0,0 +1,53 @@ +import {z} from 'zod'; + +/** + * The sandbox protocol: how the api reaches a sandbox backend run as a service elsewhere + * (`@proxy/backends/sandbox/remote`), and how such a service answers (`…/serve`), one route per + * method of the contract (`…/backend`), each a JSON body in and out, with + * `Authorization: Bearer `: + * - `POST /sandboxes` with `createSandboxBodySchema`: a new sandbox, running; its ref. + * - `POST /sandboxes/:ref/exec` with `execSandboxBodySchema`: a command run; what it wrote. + * - `POST /sandboxes/:ref/sleep` and `…/wake`, no body: the ref from then on. + * - `DELETE /sandboxes/:ref`: gone; an empty object. + * - `GET /health`, without the token: `{ok: true}`. + * A ref goes in the path URL-encoded. What went wrong comes as `sandboxErrorSchema` with the + * status the error's kind has in the api (`ApiError`); the client takes it back to that kind. + */ + +export const createSandboxBodySchema = z.object({ + /** Whose the sandbox is: the organization's id. */ + tenant: z.string().min(1), + /** The id the api gave the sandbox. */ + sandboxId: z.string().min(1), +}); + +export type CreateSandboxBody = z.infer; + +export const sandboxRefSchema = z.object({ref: z.string().min(1)}); + +export const execSandboxBodySchema = z.object({ + command: z.string(), + stdin: z.string().optional(), + cwd: z.string().optional(), + timeoutMs: z.number().int().positive(), +}); + +export type ExecSandboxBody = z.infer; + +export const sandboxOutputSchema = z.object({ + stdout: z.string(), + stderr: z.string(), + exitCode: z.number().int().nullable(), + timedOut: z.boolean(), +}); + +export const sandboxDestroyedSchema = z.object({}); + +export const sandboxHealthSchema = z.object({ok: z.literal(true)}); + +/** What went wrong, by the api's kind of error, with what to do next where the service says. */ +export const sandboxErrorSchema = z.object({ + error: z.object({kind: z.string(), message: z.string().optional()}), +}); + +export type SandboxError = z.infer; diff --git a/render.yaml b/render.yaml index e38533e6..9c94b3e3 100644 --- a/render.yaml +++ b/render.yaml @@ -33,6 +33,7 @@ services: - package.json - bun.lock - packages/api/** + - packages/backends/** - packages/db/** - packages/integrations/** - packages/ui/**