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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 2 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
17 changes: 17 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions packages/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@proxy/backends": "workspace:*",
"@proxy/contracts": "workspace:*",
"@proxy/db": "workspace:*",
"@proxy/integrations": "workspace:*",
Expand Down
42 changes: 42 additions & 0 deletions packages/api/src/backends/backends.ts
Original file line number Diff line number Diff line change
@@ -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()};
14 changes: 14 additions & 0 deletions packages/api/src/utils/env-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions packages/api/src/utils/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
10 changes: 10 additions & 0 deletions packages/backends/.prettierrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"useTabs": true,
"semi": true,
"singleQuote": true,
"quoteProps": "as-needed",
"trailingComma": "all",
"bracketSpacing": false,
"printWidth": 200,
"embeddedLanguageFormatting": "off"
}
10 changes: 10 additions & 0 deletions packages/backends/README.md
Original file line number Diff line number Diff line change
@@ -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.
23 changes: 23 additions & 0 deletions packages/backends/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -29,7 +34,7 @@ export type SandboxBackend = {
create: (sandbox: {tenant: string; sandboxId: string}) => Promise<Result<SandboxRef, ApiError>>;
/** 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<Result<SandboxOutput, ApiError>>;
/** 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<Result<SandboxRef, ApiError>>;
/** Starts an asleep sandbox again, its disk as it was, and gives its ref from now on. */
wake: (ref: SandboxRef) => Promise<Result<SandboxRef, ApiError>>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,21 +58,6 @@ export function sandboxBackendTests(make: () => Promise<SandboxBackend>): 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');
Expand All @@ -87,7 +72,8 @@ export function sandboxBackendTests(make: () => Promise<SandboxBackend>): 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);
});

Expand Down
52 changes: 52 additions & 0 deletions packages/backends/src/sandbox/remote.test.ts
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof Bun.serve>> = [];

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);
});
});
Loading
Loading