Skip to content
Closed
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: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ Run the complete prebuilt stack with one command - no checkout, no build:
docker compose -f oci://ghcr.io/chatbotkit/platform-community:latest up
```

Open <http://localhost:3000>. Sign in with any email address and read the
Open <http://cbk.localhost:3000>. Sign in with any email address and read the
six-digit code from the platform container log:

```bash
Expand Down
29 changes: 15 additions & 14 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,11 +69,11 @@ services:
args:
# @note override for a real deployment: the site url is baked into the
# build by Next.js
SITE_URL: ${SITE_URL:-http://localhost:3000}
SPACE_APEX: ${SPACE_APEX:-space.localhost}
PORTAL_APEX: ${PORTAL_APEX:-portal.localhost}
APP_MAIN_ORIGIN: ${APP_MAIN_ORIGIN:-http://apps.localhost:3000}
APP_LABS_ORIGIN: ${APP_LABS_ORIGIN:-http://labs.localhost:3000}
SITE_URL: ${SITE_URL:-http://cbk.localhost:3000}
SPACE_APEX: ${SPACE_APEX:-cbk-space.localhost}
PORTAL_APEX: ${PORTAL_APEX:-cbk-portal.localhost}
APP_MAIN_ORIGIN: ${APP_MAIN_ORIGIN:-http://cbk-apps.localhost:3000}
APP_LABS_ORIGIN: ${APP_LABS_ORIGIN:-http://cbk-labs.localhost:3000}
ports:
- '3000:3000'
# @note the built-in realtime relay - see RELAY_URL below
Expand All @@ -82,19 +82,20 @@ services:
<<: *storage-env
NODE_ENV: production
PORT: 3000
SITE_URL: ${SITE_URL:-http://localhost:3000}
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000}
SITE_URL: ${SITE_URL:-http://cbk.localhost:3000}
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://cbk.localhost:3000}
# @note realtime channels meet at a relay the platform process hosts on
# RELAY_PORT - see docker/distro/community/compose.yml
RELAY_PORT: 3001
RELAY_URL: ${RELAY_URL:-http://localhost:3001}
SPACE_APEX: ${SPACE_APEX:-space.localhost}
PORTAL_APEX: ${PORTAL_APEX:-portal.localhost}
RELAY_URL: ${RELAY_URL:-http://cbk-relay.localhost:3001}
SPACE_APEX: ${SPACE_APEX:-cbk-space.localhost}
PORTAL_APEX: ${PORTAL_APEX:-cbk-portal.localhost}
# @note the app shells, baked the same way: the main shell at
# `apps.localhost:3000`, the labs shell at `labs.localhost:3000`. Cookies
# do not cross hosts, so sign in on the shell host itself
APP_MAIN_ORIGIN: ${APP_MAIN_ORIGIN:-http://apps.localhost:3000}
APP_LABS_ORIGIN: ${APP_LABS_ORIGIN:-http://labs.localhost:3000}
# `cbk-apps.localhost:3000`, the labs shell at
# `cbk-labs.localhost:3000`. Cookies do not cross hosts, so sign in on
# the shell host itself
APP_MAIN_ORIGIN: ${APP_MAIN_ORIGIN:-http://cbk-apps.localhost:3000}
APP_LABS_ORIGIN: ${APP_LABS_ORIGIN:-http://cbk-labs.localhost:3000}
# @note left empty, the image generates these secrets on first boot and
# persists them in the platform-data volume - see docker/entrypoint.sh;
# set explicitly to override
Expand Down
18 changes: 9 additions & 9 deletions docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -92,30 +92,30 @@ ENV NODE_OPTIONS="--max-old-space-size=$NODE_HEAP_MB --require /app/platform/scr

# @note the build asserts a site url; override for a real deployment. Secrets
# should NOT be passed as build args - use BuildKit secret mounts.
ARG SITE_URL=http://localhost:3000
ARG SITE_URL=http://cbk.localhost:3000
ENV SITE_URL=$SITE_URL

# @note apex host rewrites are generated at build time, so the image bakes
# `.localhost` names browsers resolve to loopback without DNS: space sites at
# `<slug>.space.localhost`, portals at `<slug>.portal.localhost`. The runtime
# environment must name the same apexes (the compose files do).
# `<slug>.cbk-space.localhost`, portals at `<slug>.cbk-portal.localhost`.
# The runtime environment must name the same apexes (the compose files do).
# @todo move the apex host rewrites out of next.config.d into a runtime proxy
# so one image digest serves any domain without a rebuild
ARG SPACE_APEX=space.localhost
ARG SPACE_APEX=cbk-space.localhost
ENV SPACE_APEX=$SPACE_APEX
ARG PORTAL_APEX=portal.localhost
ARG PORTAL_APEX=cbk-portal.localhost
ENV PORTAL_APEX=$PORTAL_APEX
ARG APP_APEX=
ENV APP_APEX=$APP_APEX
ARG PARTNERS_APEX=
ENV PARTNERS_APEX=$PARTNERS_APEX

# @note the app-shell hosts are rewrites of the same kind: the main shell at
# `apps.localhost`, the labs shell at `labs.localhost`. The runtime origins
# must match these too
ARG APP_MAIN_ORIGIN=http://apps.localhost:3000
# `cbk-apps.localhost`, the labs shell at `cbk-labs.localhost`. The runtime
# origins must match these too
ARG APP_MAIN_ORIGIN=http://cbk-apps.localhost:3000
ENV APP_MAIN_ORIGIN=$APP_MAIN_ORIGIN
ARG APP_LABS_ORIGIN=http://labs.localhost:3000
ARG APP_LABS_ORIGIN=http://cbk-labs.localhost:3000
ENV APP_LABS_ORIGIN=$APP_LABS_ORIGIN

# @note source maps ship without source content by default; pass 'full'
Expand Down
21 changes: 11 additions & 10 deletions docker/distro/community/compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,24 +61,25 @@ services:
<<: *storage-env
NODE_ENV: production
PORT: 3000
SITE_URL: ${SITE_URL:-http://localhost:3000}
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000}
SITE_URL: ${SITE_URL:-http://cbk.localhost:3000}
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://cbk.localhost:3000}
# @note realtime channels (voice, avatars) meet at a relay the platform
# process hosts itself on RELAY_PORT. Both that process and a host
# browser dial RELAY_URL, so loopback serves both; a browser elsewhere
# needs an address it can reach instead (and TLS if the site has it)
RELAY_PORT: 3001
RELAY_URL: ${RELAY_URL:-http://localhost:3001}
RELAY_URL: ${RELAY_URL:-http://cbk-relay.localhost:3001}
# @note deployment-issued subdomains; must match the apexes baked into
# the image (docker/Dockerfile). Browsers resolve `*.localhost` to
# loopback, so `acme.space.localhost:3000` works with no DNS setup
SPACE_APEX: ${SPACE_APEX:-space.localhost}
PORTAL_APEX: ${PORTAL_APEX:-portal.localhost}
# loopback, so `acme.cbk-space.localhost:3000` works with no DNS setup
SPACE_APEX: ${SPACE_APEX:-cbk-space.localhost}
PORTAL_APEX: ${PORTAL_APEX:-cbk-portal.localhost}
# @note the app shells, baked the same way: the main shell at
# `apps.localhost:3000`, the labs shell at `labs.localhost:3000`. Cookies
# do not cross hosts, so sign in on the shell host itself
APP_MAIN_ORIGIN: ${APP_MAIN_ORIGIN:-http://apps.localhost:3000}
APP_LABS_ORIGIN: ${APP_LABS_ORIGIN:-http://labs.localhost:3000}
# `cbk-apps.localhost:3000`, the labs shell at
# `cbk-labs.localhost:3000`. Cookies do not cross hosts, so sign in on
# the shell host itself
APP_MAIN_ORIGIN: ${APP_MAIN_ORIGIN:-http://cbk-apps.localhost:3000}
APP_LABS_ORIGIN: ${APP_LABS_ORIGIN:-http://cbk-labs.localhost:3000}
# @note left empty, the image generates these secrets on first boot and
# persists them in the platform-data volume; set explicitly to override
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:-}
Expand Down
6 changes: 3 additions & 3 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ PARTNERS_APEX=example.partners

The apex host rewrites are generated when Next builds, so the runtime values
must match the ones the image was built with. The community image bakes
`SPACE_APEX=space.localhost` and `PORTAL_APEX=portal.localhost`; see
`SPACE_APEX=cbk-space.localhost` and `PORTAL_APEX=cbk-portal.localhost`; see
[Deployment](./deployment.md#production-boundary).

## App shell origins
Expand All @@ -232,8 +232,8 @@ APP_LABS_ORIGIN=https://labs.example.com

Like the apexes, the shell host rewrites are generated when Next builds, so
the runtime origins must match the build. The community image bakes
`APP_MAIN_ORIGIN=http://apps.localhost:3000` and
`APP_LABS_ORIGIN=http://labs.localhost:3000`.
`APP_MAIN_ORIGIN=http://cbk-apps.localhost:3000` and
`APP_LABS_ORIGIN=http://cbk-labs.localhost:3000`.

## `HOSTS_CONFIG`

Expand Down
13 changes: 7 additions & 6 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,13 +213,14 @@ parts of host and subscription configuration, is therefore not baked into the
and keep secrets out of image layers.

The current community image deliberately bakes the neutral single-host
topology: `SITE_URL=http://localhost:3000`, with no external zones. Two apexes
are baked alongside it so deployment-issued subdomains work out of the box:
`SPACE_APEX=space.localhost` and `PORTAL_APEX=portal.localhost`, and the two
app shells answer at `http://apps.localhost:3000` and
`http://labs.localhost:3000` through `APP_MAIN_ORIGIN` and `APP_LABS_ORIGIN`.
topology: `SITE_URL=http://cbk.localhost:3000`, with no external zones. Two
apexes are baked alongside it so deployment-issued subdomains work out of the
box: `SPACE_APEX=cbk-space.localhost` and `PORTAL_APEX=cbk-portal.localhost`,
and the two app shells answer at `http://cbk-apps.localhost:3000` and
`http://cbk-labs.localhost:3000` through `APP_MAIN_ORIGIN` and
`APP_LABS_ORIGIN`.
Browsers resolve any `*.localhost` name to loopback, so a space site published
as `acme` answers at `http://acme.space.localhost:3000` with no DNS or
as `acme` answers at `http://acme.cbk-space.localhost:3000` with no DNS or
hosts-file setup (`curl` needs `--resolve`). The runtime apexes and shell
origins must name the same hosts as the build, which the compose files ensure;
a different host needs a rebuild with the matching build arguments. Runtime service variables
Expand Down
12 changes: 6 additions & 6 deletions docs/sdks.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,12 @@ Go and Terraform. It is not an SDK reference; each SDK's README and
Every deployment serves the API on its own origin under `/api/v1`, with no
configuration. The origin depends on how you run the platform:

| How it runs | Origin |
| -------------------------------------------- | ----------------------- |
| Host-side `pnpm dev` | `http://127.0.0.1:8080` |
| `docker compose up` in a checkout | `http://127.0.0.1:8080` |
| Prebuilt community stack | `http://localhost:3000` |
| A deployment with `API_URL` set | that URL |
| How it runs | Origin |
| -------------------------------------------- | --------------------------- |
| Host-side `pnpm dev` | `http://127.0.0.1:8080` |
| `docker compose up` in a checkout | `http://127.0.0.1:8080` |
| Prebuilt community stack | `http://cbk.localhost:3000` |
| A deployment with `API_URL` set | that URL |

The two entry points are:

Expand Down
29 changes: 23 additions & 6 deletions packages/sandbox/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ community image.
command and a real directory on the host, mounted read-write, so what an
agent writes or installs there survives the VM being reaped and the
application restarting.
- **The platform's stores, live.** When the platform asks for a space at
`/space` or a conversation's files at `/conversation`, a host-side driver
serves that path straight from the object store through the storage
contract: an object is a file, a prefix is a directory, an empty directory
is the marker the space browser writes. What the agent writes is what the
platform reads back, at once, and no store credential ever enters the VM.
`mountedPaths` reports exactly these paths.
- **`runCode` sessions that carry state.** A `runCode` session names a live
interpreter context, so a binding made by one call is there on the next.
Shell `exec` does not: each command runs in a fresh process (see below).
Expand All @@ -34,16 +41,25 @@ community image.
- **Python.** AgentOS documents CPython through Pyodide, but the published
sidecar builds at the pinned version ship without it. The package probes
once and reports Python as `UNSUPPORTED_OPERATION` with a message saying so,
rather than shelling out to something approximate. Bumping the runtime the
day it ships turns Python on with no change here.
rather than shelling out to something approximate, and removes the
runtime's empty `python`/`python3` placeholder stubs from each VM so a
`python3` in a shell command fails with `command not found` instead of
running nothing and exiting 0. Bumping the runtime the day it ships turns
Python on with no change here.
- **`git`, `curl` and the other registry command packages.** They resolve and
project into the VM, but their binaries arrive without the executable bit at
this version and refuse to run. Node's `fetch` and `npx` cover most of what
agents reached for them for.
- **Storage mounts.** `mountedPaths` is always empty and the mount plan's
`resolve()` is never called, so no scoped credentials are minted for a mount
that will not happen, and the platform does not offer the model a `/space`
that is not there.
- **Storage mounts with the store's own semantics.** See below: they are
served by a driver in this process, so they are as slow as the store and as
plain as an object store is - no symlinks, no partial writes, a rename is a
copy and a delete.
- **Fast pipelines.** A pipe between two commands can stall for the
runtime's ten-second blocking-read limit at end of stream, once per stage.
Upstream defect, tracked in
[rivet-dev/agentos#1959](https://github.com/rivet-dev/agentos/issues/1959).
@todo check the ticket when bumping the runtime and drop this entry once
a release fixes it.
- **A clean `ls -la` of `/workspace`.** The listing prints, then the command
exits 1 with `Invalid argument` from the mount's directory entries at this
version. `ls -l` is unaffected.
Expand All @@ -60,6 +76,7 @@ community image.
| files elsewhere (`/tmp`, `$HOME`) | across calls, until the VM is reaped |
| `cd` and shell variables between `exec` calls | no - each command is a fresh process |
| interpreter bindings in a `runCode` session | until the VM is reaped |
| `/space` and `/conversation` | in the object store, shared with the platform |

A VM is reaped after fifteen minutes without a call. A workspace nobody has
used for thirty days is removed from disk.
Expand Down
2 changes: 2 additions & 0 deletions packages/sandbox/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
"types": "./types/src/index.d.ts",
"dependencies": {
"@chatbotkit-dev/sandbox-spec": "workspace:*",
"@chatbotkit-dev/storage": "workspace:*",
"@chatbotkit-dev/storage-spec": "workspace:*",
"@rivet-dev/agentos-core": "0.2.19"
},
"devDependencies": {
Expand Down
99 changes: 86 additions & 13 deletions packages/sandbox/src/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,22 @@ import { join } from 'node:path'

import { jest } from '@jest/globals'

import { createFakeStore } from './store.fake.js'

const dataDir = mkdtempSync(join(tmpdir(), 'sandbox-test-'))

process.env.SANDBOX_DATA_DIR = dataDir

// @note the storage module is replaced before the provider loads it - lazily,
// on the first call that asks for a store - so the mounts below are served
// from memory rather than from whichever store this environment has

const fakeStore = createFakeStore({
'spaces/s1/data/readme.txt': 'from the space\n',
})

jest.unstable_mockModule('@chatbotkit-dev/storage', () => fakeStore)

const { default: provider, reset } = await import('./index.ts')

jest.setTimeout(120_000)
Expand Down Expand Up @@ -229,24 +241,65 @@ describe('timeouts', () => {
})

describe('mounts', () => {
// @note the platform is told the truth about what it can reach. A backend
// that cannot mount reports nothing mounted, and never asks storage to mint
// credentials for a mount that is not going to happen.
// @note the platform is told the truth about what it can reach: the paths a
// driver in this process is behind, and no others. Credentials are never
// asked for, because the driver speaks the storage contract itself.

const plan = () => ({
requests: [{ path: '/space', scope: 'space', prefix: 'spaces/s1/data' }],
resolve: jest.fn(),
})

it('reports nothing mounted when nothing was asked for', async () => {
const result = await provider.exec({ sandboxId: 'm', cmd: 'echo hello' })

expect(result.mountedPaths).toEqual([])
})

it('reports nothing mounted and never resolves credentials', async () => {
const resolve = jest.fn()
it('serves a requested store at its path without minting credentials', async () => {
const mounts = plan()

const result = await provider.exec({
sandboxId: 'a',
cmd: 'echo hello',
mounts: {
requests: [{ path: '/space', scope: 'space', prefix: 'spaces/1' }],
resolve,
},
sandboxId: 'm',
cmd: 'cat /space/readme.txt',
mounts,
})

expect(result.mountedPaths).toEqual([])
expect(resolve).not.toHaveBeenCalled()
expect(result.exitCode).toBe(0)
expect(result.stdout).toBe('from the space\n')
expect(result.mountedPaths).toEqual(['/space'])
expect(mounts.resolve).not.toHaveBeenCalled()
})

it('writes through to the store', async () => {
await provider.exec({
sandboxId: 'm',
cmd: 'echo agent > /space/note.txt',
mounts: plan(),
})

expect(fakeStore.text('spaces/s1/data/note.txt')).toBe('agent\n')
})

it('keeps the mount for a call that asks for nothing', async () => {
const result = await provider.exec({
sandboxId: 'm',
cmd: 'cat /space/note.txt',
})

expect(result.stdout).toBe('agent\n')
expect(result.mountedPaths).toEqual(['/space'])
})

it('reaches the store through the file operations too', async () => {
const result = await provider.readFile({
sandboxId: 'm',
path: '/space/readme.txt',
mounts: plan(),
})

expect(result.contents).toBe('from the space\n')
expect(result.mountedPaths).toEqual(['/space'])
})
})

Expand Down Expand Up @@ -375,6 +428,26 @@ describe('runCode', () => {
})
})

describe('python in a shell command', () => {
// @note whichever way the probe goes, `python3` in a shell must never be a
// silent no-op: the runtime's placeholder stub runs nothing and exits 0,
// which is the one outcome an agent cannot detect.

it('either runs python or fails with command not found', async () => {
const result = await provider.exec({
sandboxId: 'a',
cmd: "python3 -c 'print(42)'",
})

if (result.exitCode === 0) {
expect(result.stdout.trim()).toBe('42')
} else {
expect(result.exitCode).toBe(127)
expect(result.stderr).toMatch(/command not found/)
}
})
})

describe('network', () => {
it('refuses the host', async () => {
const result = await provider.exec({
Expand Down
Loading