diff --git a/README.md b/README.md index e108ad4..8db0a68 100644 --- a/README.md +++ b/README.md @@ -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 . Sign in with any email address and read the +Open . Sign in with any email address and read the six-digit code from the platform container log: ```bash diff --git a/docker-compose.yml b/docker-compose.yml index fbf601b..cd5934d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 @@ -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 diff --git a/docker/Dockerfile b/docker/Dockerfile index c75ba7c..8753c3e 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -92,18 +92,18 @@ 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 -# `.space.localhost`, portals at `.portal.localhost`. The runtime -# environment must name the same apexes (the compose files do). +# `.cbk-space.localhost`, portals at `.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 @@ -111,11 +111,11 @@ 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' diff --git a/docker/distro/community/compose.yml b/docker/distro/community/compose.yml index 913fda2..883350f 100644 --- a/docker/distro/community/compose.yml +++ b/docker/distro/community/compose.yml @@ -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:-} diff --git a/docs/configuration.md b/docs/configuration.md index b08051b..331a0e2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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 @@ -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` diff --git a/docs/deployment.md b/docs/deployment.md index e08d17f..954bd9e 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -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 diff --git a/docs/sdks.md b/docs/sdks.md index 4ecf95e..4b653f7 100644 --- a/docs/sdks.md +++ b/docs/sdks.md @@ -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: diff --git a/packages/sandbox/README.md b/packages/sandbox/README.md index 8ae3850..3c0bb47 100644 --- a/packages/sandbox/README.md +++ b/packages/sandbox/README.md @@ -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). @@ -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. @@ -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. diff --git a/packages/sandbox/package.json b/packages/sandbox/package.json index a51e538..c2e5f81 100644 --- a/packages/sandbox/package.json +++ b/packages/sandbox/package.json @@ -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": { diff --git a/packages/sandbox/src/index.test.js b/packages/sandbox/src/index.test.js index 1533462..b8b6812 100644 --- a/packages/sandbox/src/index.test.js +++ b/packages/sandbox/src/index.test.js @@ -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) @@ -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']) }) }) @@ -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({ diff --git a/packages/sandbox/src/index.ts b/packages/sandbox/src/index.ts index 01e147a..ed6c8ba 100644 --- a/packages/sandbox/src/index.ts +++ b/packages/sandbox/src/index.ts @@ -12,10 +12,14 @@ // ephemeral overlay per VM. The working directory, `/workspace`, is a real // directory on the host under `SANDBOX_DATA_DIR`, mounted read-write, so what // an agent writes or installs there survives the VM being reaped for idleness -// and the application restarting. Nothing else does: each command runs in a -// fresh process, so a `cd` or a shell variable ends with the command that made -// it, and a `runCode` binding ends with the call - the same shape the previous -// in-process default had, and pinned by the tests. +// and the application restarting. The stores the platform asks for - a space +// at `/space`, a conversation's files at `/conversation` - are served live +// from the object store by a host-side driver (see mount.ts), so they are as +// durable as the store and visible to the rest of the platform at once. +// Nothing else does: each command runs in a fresh process, so a `cd` or a +// shell variable ends with the command that made it, and a `runCode` binding +// ends with the call - the same shape the previous in-process default had, +// and pinned by the tests. // // Two things about the runtime shape the code more than the contract does, and // both are here rather than discovered: @@ -35,6 +39,8 @@ import type { SandboxErrorLike, SandboxExecOptions, SandboxExecResult, + SandboxMountPlan, + SandboxMountRequest, SandboxProvider, SandboxReadFileOptions, SandboxReadFileResult, @@ -48,6 +54,8 @@ import type { import type { AgentOs } from '@rivet-dev/agentos-core' import type * as AgentOsNamespace from '@rivet-dev/agentos-core' +import { createStorageDriver } from './mount' + import { createHash } from 'node:crypto' import { mkdirSync, readdirSync, rmSync, statSync, utimesSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -211,22 +219,46 @@ interface Entry { vm: Promise workspace: string contexts: Set + /** The stores this VM was created with, by `mountKey`. */ + mounts: Set + /** The guest paths those stores appear at. */ + mountedPaths: string[] /** Serializes operations; see the module header on why overlap is a hang. */ queue: Promise timer?: ReturnType } +function mountKey(request: SandboxMountRequest): string { + return `${request.path}\0${request.scope}\0${request.prefix}` +} + const entries = new Map() +/** + * @note the store mounts are host-side drivers, so they exist only for the + * life of the VM and are declared at its creation - the runtime has no way to + * attach one later. That is why a call asking for a store the VM lacks + * replaces the VM (see `getEntry`) rather than adding to it. + */ async function createVm( workspace: string, - resources: SandboxResources | undefined + resources: SandboxResources | undefined, + requests: SandboxMountRequest[] ): Promise { const { AgentOs, createHostDirBackend } = await load() + const uid = process.getuid?.() ?? 1000 + const gid = process.getgid?.() ?? 1000 + + // @note the storage module is whichever the deployment installed; the driver + // needs the contract and never a credential + + const store = + requests.length > 0 ? await import('@chatbotkit-dev/storage') : undefined + mkdirSync(workspace, { recursive: true }) - return await AgentOs.create({ + const vm = await AgentOs.create({ permissions: PERMISSIONS, // @note the guest runs as the same uid and gid as this process. The @@ -234,9 +266,7 @@ async function createVm( // non-root process cannot hand a file to any uid but its own - so with // the runtime's default of 1000, every write from a container running as // another user is created empty and then refused - ...(process.getuid && process.getgid - ? { user: { uid: process.getuid(), gid: process.getgid() } } - : {}), + user: { uid, gid }, mounts: [ { @@ -244,6 +274,20 @@ async function createVm( plugin: createHostDirBackend({ hostPath: workspace, readOnly: false }), readOnly: false, }, + + ...requests.map((request) => ({ + path: request.path, + driver: createStorageDriver({ + store: store!, + scope: request.scope, + prefix: request.prefix, + uid, + gid, + }), + guestFstype: 'chatbotkit', + guestSource: `${request.scope}:${request.prefix}`, + readOnly: false, + })), ], // @note advisory in the contract, honoured where the runtime has a knob: @@ -260,6 +304,10 @@ async function createVm( : {}), }, }) + + await hideAbsentPython(vm) + + return vm } function touch(sandboxId: string, entry: Entry): void { @@ -284,10 +332,27 @@ function touch(sandboxId: string, entry: Entry): void { function getEntry( sandboxId: string, - resources: SandboxResources | undefined + resources: SandboxResources | undefined, + plan: SandboxMountPlan | undefined ): Entry { + const requests = plan?.requests ?? [] + let entry = entries.get(sandboxId) + // @note a VM that lacks a store this call asks for is replaced by one that + // has it. The workspace is on the host and the stores are live, so nothing + // the agent kept is lost; interpreter contexts are, which is the same as a + // reap. A call asking for nothing keeps whatever the VM already has. + + if ( + entry && + requests.some((request) => !entry!.mounts.has(mountKey(request))) + ) { + void disposeEntry(sandboxId) + + entry = undefined + } + if (!entry) { const dataDir = getDataDir() @@ -296,9 +361,11 @@ function getEntry( const workspace = toWorkspacePath(sandboxId) entry = { - vm: createVm(workspace, resources), + vm: createVm(workspace, resources, requests), workspace, contexts: new Set(), + mounts: new Set(requests.map(mountKey)), + mountedPaths: requests.map((request) => request.path), queue: Promise.resolve(), } @@ -407,13 +474,17 @@ function toSandboxError( * VM, so two commands never have processes alive at once. */ function withVm( - options: { sandboxId: string; resources?: SandboxResources }, + options: { + sandboxId: string + resources?: SandboxResources + mounts?: SandboxMountPlan + }, fallback: SandboxErrorCode, fn: (vm: AgentOs, entry: Entry) => Promise ): Promise { - const { sandboxId, resources } = options + const { sandboxId, resources, mounts } = options - const entry = getEntry(sandboxId, resources) + const entry = getEntry(sandboxId, resources, mounts) const run = async (): Promise => { try { @@ -502,10 +573,8 @@ let pythonAvailable: Promise | undefined * day the sidecar ships its Python runtime nothing here has to change. A probe * that fails for a reason other than the runtime being absent is not cached, * since that is the sidecar having a bad moment rather than a fact about it. - * - * @throws UNSUPPORTED_OPERATION when the sidecar has no Python runtime */ -async function assertPython(vm: AgentOs): Promise { +function isPythonAvailable(vm: AgentOs): Promise { if (!pythonAvailable) { pythonAvailable = vm.python .execute('print(1)', { output: { capture: 'all' }, timeoutMs: 60_000 }) @@ -526,11 +595,40 @@ async function assertPython(vm: AgentOs): Promise { }) } - if (!(await pythonAvailable)) { + return pythonAvailable +} + +/** + * @throws UNSUPPORTED_OPERATION when the sidecar has no Python runtime + */ +async function assertPython(vm: AgentOs): Promise { + if (!(await isPythonAvailable(vm))) { throw new SandboxError('UNSUPPORTED_OPERATION', PYTHON_UNAVAILABLE_MESSAGE) } } +/** + * Removes the `python` and `python3` names from a VM whose sidecar has no + * Python runtime. + * + * @note the runtime installs those names as empty shell stubs so a future + * interpreter can be routed to them. Without the interpreter, `python3 script.py` + * in a shell command runs nothing and exits 0 - an agent's script silently + * produces no file and no error, which is worse than any failure. Gone, the + * shell says `command not found` and `command -v python3` is false, which the + * model can act on. + */ +async function hideAbsentPython(vm: AgentOs): Promise { + if (await isPythonAvailable(vm)) { + return + } + + await vm.process.exec('rm -f /bin/python /bin/python3', { + output: { capture: 'none' }, + timeoutMs: 10_000, + }) +} + async function ensureContext( vm: AgentOs, entry: Entry, @@ -626,16 +724,16 @@ async function exec(options: SandboxExecOptions): Promise { env, files, resources, + mounts, } = options - // @note `options.mounts` is deliberately never read. Nothing is mounted and - // nothing pretends to be: `mountedPaths` comes back empty, which is how the - // platform knows not to tell the model about a `/space` that is not there, - // and `resolve` is never called, so no credentials are minted for a mount - // that will not happen. + // @note the plan's `resolve` is never called. The stores are served by a + // driver in this process through the storage contract, so no scoped + // credential is ever minted, and `mountedPaths` reports exactly the paths + // that driver is behind. return await withVm( - { sandboxId, resources }, + { sandboxId, resources, mounts }, 'EXEC_FAILED', async (vm, entry) => { if (files) { @@ -656,7 +754,7 @@ async function exec(options: SandboxExecOptions): Promise { timeout, }) - return { ...result, mountedPaths: [] } + return { ...result, mountedPaths: entry.mountedPaths } } // @note `sessionId` shares the filesystem and nothing else: the command @@ -672,7 +770,7 @@ async function exec(options: SandboxExecOptions): Promise { ...(timeout ? { timeoutMs: timeout } : {}), }) - return { ...toRunResult(result, timeout), mountedPaths: [] } + return { ...toRunResult(result, timeout), mountedPaths: entry.mountedPaths } } ) } @@ -680,11 +778,19 @@ async function exec(options: SandboxExecOptions): Promise { async function runCode( options: SandboxRunCodeOptions ): Promise { - const { sandboxId, sessionId, code, language, timeout, env, resources } = - options + const { + sandboxId, + sessionId, + code, + language, + timeout, + env, + resources, + mounts, + } = options return await withVm( - { sandboxId, resources }, + { sandboxId, resources, mounts }, 'EXEC_FAILED', async (vm, entry) => { const result = await interpret(vm, entry, { @@ -699,7 +805,7 @@ async function runCode( exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr, - mountedPaths: [], + mountedPaths: entry.mountedPaths, } } ) @@ -708,17 +814,17 @@ async function runCode( async function readFile( options: SandboxReadFileOptions ): Promise { - const { sandboxId, path, resources } = options + const { sandboxId, path, resources, mounts } = options return await withVm( - { sandboxId, resources }, + { sandboxId, resources, mounts }, 'FILE_READ_FAILED', - async (vm) => { + async (vm, entry) => { const contents = new TextDecoder().decode( await vm.filesystem.readFile(path) ) - return { contents, mountedPaths: [] } + return { contents, mountedPaths: entry.mountedPaths } } ) } @@ -726,19 +832,19 @@ async function readFile( async function writeFile( options: SandboxWriteFileOptions ): Promise { - const { sandboxId, path, contents, resources } = options + const { sandboxId, path, contents, resources, mounts } = options // @note `mode` and `owner` are accepted and ignored. Every process in the VM // runs as its one user, so honouring them would mean inventing a permission // model the guest does not enforce. return await withVm( - { sandboxId, resources }, + { sandboxId, resources, mounts }, 'FILE_WRITE_FAILED', - async (vm) => { + async (vm, entry) => { await vm.filesystem.writeFile(path, contents) - return { mountedPaths: [] } + return { mountedPaths: entry.mountedPaths } } ) } diff --git a/packages/sandbox/src/mount.test.js b/packages/sandbox/src/mount.test.js new file mode 100644 index 0000000..614af1e --- /dev/null +++ b/packages/sandbox/src/mount.test.js @@ -0,0 +1,221 @@ +// @note the driver is exercised through a real VM rather than by calling its +// methods: what matters is what `ls`, `cat`, a redirect and `node` see, and +// the kernel's own sequence of calls - `lstat` per entry after a listing, +// 64KB `pread` slices, a truncating `writeFile` before the real one - is what +// the caches exist for. + +import { AgentOs } from '@rivet-dev/agentos-core' + +import { createStorageDriver } from './mount.ts' +import { createFakeStore } from './store.fake.js' + +const PREFIX = 'spaces/s1/data' + +const PERMISSIONS = { + fs: 'allow', + childProcess: 'allow', + process: 'allow', + env: 'allow', + binding: 'allow', + network: 'allow', +} + +let store +let vm + +beforeAll(async () => { + store = createFakeStore({ + [`${PREFIX}/hello.txt`]: 'hi there\n', + [`${PREFIX}/sub/a.txt`]: 'A\n', + [`${PREFIX}/sub/deeper/b.txt`]: 'B\n', + [`${PREFIX}/empty/`]: '', + }) + + vm = await AgentOs.create({ + permissions: PERMISSIONS, + user: { uid: process.getuid(), gid: process.getgid() }, + mounts: [ + { + path: '/space', + driver: createStorageDriver({ + store, + scope: 'space', + prefix: PREFIX, + uid: process.getuid(), + gid: process.getgid(), + }), + readOnly: false, + }, + ], + }) +}, 60_000) + +afterAll(async () => { + await vm?.dispose() +}) + +const sh = async (cmd) => { + const result = await vm.process.exec(cmd, { + cwd: '/space', + output: { capture: 'all' }, + timeoutMs: 30_000, + }) + + return { code: result.exitCode ?? 0, out: result.stdout ?? '', err: result.stderr ?? '' } +} + +describe('reading', () => { + it('lists objects as files and prefixes as directories', async () => { + const { code, out } = await sh('ls /space') + + expect(code).toBe(0) + expect(out.trim().split('\n').sort()).toEqual(['empty', 'hello.txt', 'sub']) + }) + + it('shows an empty directory marker as a directory', async () => { + const { out } = await sh('[ -d /space/empty ] && echo dir') + + expect(out.trim()).toBe('dir') + }) + + it('reads a file', async () => { + const { out } = await sh('cat /space/hello.txt') + + expect(out).toBe('hi there\n') + }) + + it('walks nested directories', async () => { + const { out } = await sh('find /space -type f | sort') + + expect(out.trim().split('\n')).toEqual([ + '/space/hello.txt', + '/space/sub/a.txt', + '/space/sub/deeper/b.txt', + ]) + }) + + it('reports a missing file the way a shell expects', async () => { + const { code, err } = await sh('cat /space/missing.txt') + + expect(code).not.toBe(0) + expect(err).toMatch(/No such file/) + }) + + it('serves a large file through pread slices', async () => { + store.objects.set(`${PREFIX}/big.bin`, { + bytes: new Uint8Array(200_000).fill(7), + updatedAt: new Date(), + }) + + const { out } = await sh( + `node -e "const b=require('fs').readFileSync('/space/big.bin');console.log(b.length, b[0], b[199999])"` + ) + + expect(out.trim()).toBe('200000 7 7') + }) +}) + +describe('writing', () => { + it('stores a redirect as an object the platform reads back', async () => { + await sh('echo written > /space/new.txt') + + expect(store.text(`${PREFIX}/new.txt`)).toBe('written\n') + }) + + it('appends', async () => { + await sh('echo more >> /space/new.txt') + + expect(store.text(`${PREFIX}/new.txt`)).toBe('written\nmore\n') + }) + + it('creates a directory as a marker object', async () => { + const { code } = await sh('mkdir /space/made && [ -d /space/made ]') + + expect(code).toBe(0) + expect(store.objects.has(`${PREFIX}/made/`)).toBe(true) + }) + + it('writes into a directory it made', async () => { + await sh('echo inner > /space/made/inner.txt') + + expect(store.text(`${PREFIX}/made/inner.txt`)).toBe('inner\n') + }) + + it('moves a file', async () => { + const { code } = await sh('mv /space/new.txt /space/made/moved.txt') + + expect(code).toBe(0) + expect(store.objects.has(`${PREFIX}/new.txt`)).toBe(false) + expect(store.text(`${PREFIX}/made/moved.txt`)).toBe('written\nmore\n') + }) + + it('moves a directory tree', async () => { + const { code } = await sh('mv /space/made /space/renamed && cat /space/renamed/inner.txt') + + expect(code).toBe(0) + expect(store.text(`${PREFIX}/renamed/inner.txt`)).toBe('inner\n') + expect([...store.objects.keys()].some((key) => key.startsWith(`${PREFIX}/made/`))).toBe(false) + }) + + it('removes a file', async () => { + await sh('rm /space/renamed/moved.txt') + + expect(store.objects.has(`${PREFIX}/renamed/moved.txt`)).toBe(false) + }) + + it('refuses to remove a directory that is not empty', async () => { + const { code } = await sh('rmdir /space/renamed') + + expect(code).not.toBe(0) + expect(store.text(`${PREFIX}/renamed/inner.txt`)).toBe('inner\n') + }) + + it('removes a tree', async () => { + const { code } = await sh('rm -r /space/renamed && [ ! -e /space/renamed ]') + + expect(code).toBe(0) + expect([...store.objects.keys()].some((key) => key.startsWith(`${PREFIX}/renamed`))).toBe(false) + }) + + it('writes binary content from node', async () => { + const { out } = await sh( + `node -e "const fs=require('fs');fs.writeFileSync('/space/n.bin',Buffer.alloc(70000,1));console.log(fs.statSync('/space/n.bin').size)"` + ) + + expect(out.trim()).toBe('70000') + expect(store.objects.get(`${PREFIX}/n.bin`).bytes.byteLength).toBe(70000) + }) + + it('sees an object the platform wrote after the fact', async () => { + store.objects.set(`${PREFIX}/late.txt`, { + bytes: new TextEncoder().encode('late\n'), + updatedAt: new Date(), + }) + + // @note stats are trusted for a few seconds; a fresh name has none + + const { out } = await sh('cat /space/late.txt') + + expect(out).toBe('late\n') + }) +}) + +describe('boundaries', () => { + it('cannot reach outside the prefix', async () => { + store.objects.set('spaces/other/data/secret.txt', { + bytes: new TextEncoder().encode('no\n'), + updatedAt: new Date(), + }) + + const { code } = await sh('cat /space/../../other/data/secret.txt') + + expect(code).not.toBe(0) + expect([...store.objects.keys()].filter((key) => !key.startsWith(PREFIX)).length).toBe(1) + }) + + it('refuses symlinks', async () => { + const { code } = await sh('ln -s /space/hello.txt /space/link') + + expect(code).not.toBe(0) + }) +}) diff --git a/packages/sandbox/src/mount.ts b/packages/sandbox/src/mount.ts new file mode 100644 index 0000000..836dd7a --- /dev/null +++ b/packages/sandbox/src/mount.ts @@ -0,0 +1,627 @@ +// @note a storage scope, served to the guest as a directory. +// +// AgentOS lets a mount be backed by a plain object on the host: every +// filesystem call the guest makes on that path arrives here as a method call, +// in this process. That is what a FUSE filesystem is, minus the kernel, and +// it is how a space or a conversation's files reach an agent without the +// object store's credentials ever entering the VM - the driver holds nothing +// but a scope and a prefix, and speaks the storage contract like any other +// platform code. +// +// The mapping is the obvious one and deliberately nothing cleverer: an object +// is a file, a key prefix is a directory, and an empty directory is the same +// trailing-slash marker object the platform's own space browser writes. What +// the guest writes is what the platform reads back, as it is. +// +// Two things about the guest's access pattern shape the caches. `ls` asks for +// a directory listing and then stats every entry, so a listing seeds the stat +// cache with what it already knows. Reads arrive as 64KB `pread` slices, so +// the first slice fetches the object whole and the rest are served from +// memory; a write drops the copy. + +import type { + StorageListingItem, + StorageProvider, + StorageScope, +} from '@chatbotkit-dev/storage-spec' + +import type * as AgentOsNamespace from '@rivet-dev/agentos-core' + +type AgentOsCreateOptions = NonNullable< + Parameters<(typeof AgentOsNamespace.AgentOs)['create']>[0] +> + +type MountConfig = NonNullable[number] + +/** The host-side driver a guest path can be backed by. */ +export type VirtualFileSystem = Extract['driver'] + +type VirtualStat = Awaited> + +type VirtualDirEntry = Awaited< + ReturnType +>[number] + +/** The part of the storage contract a mount needs. */ +export type MountStore = Pick< + StorageProvider, + | 'listObjects' + | 'headObject' + | 'getObject' + | 'putObject' + | 'moveObject' + | 'deleteObject' +> + +export interface StorageDriverOptions { + store: MountStore + scope: StorageScope + /** The key prefix the mount is rooted at, without a trailing slash. */ + prefix: string + /** The identity every entry is reported as owned by. */ + uid: number + gid: number +} + +/** How long a stat is trusted before the store is asked again. */ +const STAT_TTL_MS = 3_000 + +/** The most file content kept in memory per mount. */ +const CONTENT_CACHE_BYTES = 64 * 1024 * 1024 + +const LIST_PAGE = 1000 + +const DIRECTORY_MODE = 0o40755 +const FILE_MODE = 0o100644 + +/** + * @note the guest kernel reads `code` and the message prefix; both are set so + * that neither convention is a guess. The codes are the POSIX ones a shell + * turns into its usual sentences. + */ +class FsError extends Error { + readonly code: string + + constructor(code: string, path: string, detail: string) { + super(`${code}: ${detail}, '${path}'`) + + this.name = 'FsError' + this.code = code + } +} + +const enoent = (path: string) => + new FsError('ENOENT', path, 'no such file or directory') + +const eisdir = (path: string) => new FsError('EISDIR', path, 'is a directory') + +const enotdir = (path: string) => new FsError('ENOTDIR', path, 'not a directory') + +const enotempty = (path: string) => + new FsError('ENOTEMPTY', path, 'directory not empty') + +const enotsup = (path: string, what: string) => + new FsError('ENOTSUP', path, `${what} is not supported on this mount`) + +function isEnoent(error: unknown): boolean { + return error instanceof FsError && error.code === 'ENOENT' +} + +/** + * Normalises a guest path to a key-relative one: no leading slash, no empty + * or dot segments, and an empty string for the mount root. + * + * @note `..` is refused rather than resolved. The guest kernel resolves paths + * before they get here, so a `..` reaching this point is not a path, and the + * store's own guard rejects it a second time anyway. + * + * @throws ENOENT when the path climbs above the mount root + */ +function toRelative(path: string): string { + const segments = path.split('/').filter((segment) => segment && segment !== '.') + + if (segments.includes('..')) { + throw enoent(path) + } + + return segments.join('/') +} + +function parentOf(relative: string): string { + const index = relative.lastIndexOf('/') + + return index < 0 ? '' : relative.slice(0, index) +} + +/** + * @note inode numbers are expected to be stable for a path across calls and + * distinct across paths; a hash of the path is both, and needs no state. + */ +function toInode(relative: string): number { + let hash = 0x811c9dc5 + + for (let i = 0; i < relative.length; i++) { + hash ^= relative.charCodeAt(i) + hash = Math.imul(hash, 0x01000193) + } + + return (hash >>> 0) || 1 +} + +function toBytes(content: string | Uint8Array): Uint8Array { + return typeof content === 'string' ? new TextEncoder().encode(content) : content +} + +export function createStorageDriver( + options: StorageDriverOptions +): VirtualFileSystem { + const { store, scope, prefix, uid, gid } = options + + const stats = new Map() + + const contents = new Map() + + let contentBytes = 0 + + // --- keys --- + + const keyOf = (relative: string) => (relative ? `${prefix}/${relative}` : prefix) + + const directoryKeyOf = (relative: string) => `${keyOf(relative)}/` + + // --- stats --- + + function makeStat( + relative: string, + kind: 'file' | 'directory', + size: number, + updatedAt: Date | undefined + ): VirtualStat { + const time = (updatedAt ?? new Date()).getTime() + + return { + mode: kind === 'directory' ? DIRECTORY_MODE : FILE_MODE, + size, + blocks: Math.ceil(size / 512), + dev: 0, + rdev: 0, + isDirectory: kind === 'directory', + isSymbolicLink: false, + atimeMs: time, + mtimeMs: time, + ctimeMs: time, + birthtimeMs: time, + ino: toInode(relative), + nlink: 1, + uid, + gid, + } + } + + function remember(relative: string, stat: VirtualStat): VirtualStat { + stats.set(relative, { stat, expires: Date.now() + STAT_TTL_MS }) + + return stat + } + + function forget(relative: string): void { + stats.delete(relative) + + const cached = contents.get(relative) + + if (cached) { + contents.delete(relative) + + contentBytes -= cached.byteLength + } + } + + function forgetAll(): void { + stats.clear() + contents.clear() + + contentBytes = 0 + } + + function rememberContent(relative: string, bytes: Uint8Array): void { + if (bytes.byteLength > CONTENT_CACHE_BYTES) { + return + } + + forget(relative) + + // @note oldest-first eviction is enough here: the cache exists so the + // slices of one read hit the store once, not to hold a working set + + for (const [key, value] of contents) { + if (contentBytes + bytes.byteLength <= CONTENT_CACHE_BYTES) { + break + } + + contents.delete(key) + + contentBytes -= value.byteLength + } + + contents.set(relative, bytes) + + contentBytes += bytes.byteLength + } + + /** + * @note a directory in an object store is a prefix something lives under, or + * the marker object the platform writes for an empty one. Both answer a + * one-item listing, which is the cheapest question the store can be asked. + */ + async function isDirectory(relative: string): Promise { + const listing = await store.listObjects(scope, directoryKeyOf(relative), { + maxKeys: 1, + }) + + return listing.items.length > 0 || listing.prefixes.length > 0 + } + + async function stat(path: string): Promise { + const relative = toRelative(path) + + if (!relative) { + return makeStat('', 'directory', 0, undefined) + } + + const cached = stats.get(relative) + + if (cached && cached.expires > Date.now()) { + return cached.stat + } + + try { + const info = await store.headObject(scope, keyOf(relative)) + + return remember( + relative, + makeStat(relative, 'file', info.size ?? 0, info.updatedAt) + ) + } catch { + // @note not an object; a directory is the other thing it can be + } + + if (await isDirectory(relative)) { + return remember(relative, makeStat(relative, 'directory', 0, undefined)) + } + + throw enoent(path) + } + + // --- listings --- + + async function listDirectory(path: string): Promise { + const relative = toRelative(path) + + if (relative && !(await stat(path)).isDirectory) { + throw enotdir(path) + } + + const directoryKey = directoryKeyOf(relative) + + const entries = new Map() + + const seed = (item: StorageListingItem) => { + const name = item.key.slice(directoryKey.length) + + // @note the directory's own marker lists as an empty name + + if (!name) { + return + } + + const child = relative ? `${relative}/${name}` : name + + remember(child, makeStat(child, 'file', item.size, item.updatedAt)) + + entries.set(name, { name, isDirectory: false }) + } + + let continuationToken: string | undefined + + do { + const listing = await store.listObjects(scope, directoryKey, { + delimiter: '/', + maxKeys: LIST_PAGE, + ...(continuationToken ? { continuationToken } : {}), + }) + + for (const item of listing.items) { + seed(item) + } + + for (const childPrefix of listing.prefixes) { + const name = childPrefix.slice(directoryKey.length).replace(/\/$/, '') + + if (!name) { + continue + } + + const child = relative ? `${relative}/${name}` : name + + remember(child, makeStat(child, 'directory', 0, undefined)) + + entries.set(name, { name, isDirectory: true }) + } + + continuationToken = listing.truncated ? listing.nextToken : undefined + } while (continuationToken) + + return [...entries.values()].sort((a, b) => a.name.localeCompare(b.name)) + } + + // --- contents --- + + async function readBytes(path: string): Promise { + const relative = toRelative(path) + + const cached = contents.get(relative) + + if (cached) { + return cached + } + + const current = await stat(path) + + if (current.isDirectory) { + throw eisdir(path) + } + + const object = await store.getObject(scope, keyOf(relative)) + + const bytes = object.body + ? new Uint8Array(await object.body.arrayBuffer()) + : new Uint8Array() + + rememberContent(relative, bytes) + + return bytes + } + + async function writeBytes(path: string, bytes: Uint8Array): Promise { + const relative = toRelative(path) + + if (!relative) { + throw eisdir(path) + } + + await store.putObject(scope, keyOf(relative), bytes) + + rememberContent(relative, bytes) + + remember(relative, makeStat(relative, 'file', bytes.byteLength, undefined)) + + stats.delete(parentOf(relative)) + } + + // --- moves --- + + async function moveTree(fromRelative: string, toRelative: string) { + const fromKey = directoryKeyOf(fromRelative) + const toKey = directoryKeyOf(toRelative) + + let continuationToken: string | undefined + + do { + const listing = await store.listObjects(scope, fromKey, { + maxKeys: LIST_PAGE, + ...(continuationToken ? { continuationToken } : {}), + }) + + for (const item of listing.items) { + await store.moveObject( + scope, + item.key, + `${toKey}${item.key.slice(fromKey.length)}` + ) + } + + continuationToken = listing.truncated ? listing.nextToken : undefined + } while (continuationToken) + } + + // --- the driver --- + + return { + stat, + + lstat: stat, + + async exists(path) { + try { + await stat(path) + + return true + } catch (error) { + if (isEnoent(error)) { + return false + } + + throw error + } + }, + + async realpath(path) { + return `/${toRelative(path)}` + }, + + async readDir(path) { + return (await listDirectory(path)).map((entry) => entry.name) + }, + + readDirWithTypes: listDirectory, + + readFile: readBytes, + + async readTextFile(path) { + return new TextDecoder().decode(await readBytes(path)) + }, + + async pread(path, offset, length) { + const bytes = await readBytes(path) + + return bytes.slice(offset, offset + length) + }, + + async writeFile(path, content) { + await writeBytes(path, toBytes(content)) + }, + + async pwrite(path, offset, data) { + let current: Uint8Array + + try { + current = await readBytes(path) + } catch (error) { + if (!isEnoent(error)) { + throw error + } + + current = new Uint8Array() + } + + const next = new Uint8Array( + Math.max(current.byteLength, offset + data.byteLength) + ) + + next.set(current) + next.set(data, offset) + + await writeBytes(path, next) + }, + + async truncate(path, length) { + const current = await readBytes(path) + + const next = new Uint8Array(length) + + next.set(current.subarray(0, length)) + + await writeBytes(path, next) + }, + + async mkdir(path) { + const relative = toRelative(path) + + if (!relative) { + return + } + + // @note the same marker the platform's space browser writes, so an empty + // directory made here is an empty directory there + + await store.putObject(scope, directoryKeyOf(relative), new Uint8Array()) + + remember(relative, makeStat(relative, 'directory', 0, undefined)) + + stats.delete(parentOf(relative)) + }, + + async createDir(path) { + await this.mkdir(path) + }, + + async removeFile(path) { + const relative = toRelative(path) + + if (!relative) { + throw eisdir(path) + } + + if ((await stat(path)).isDirectory) { + throw eisdir(path) + } + + await store.deleteObject(scope, keyOf(relative)) + + forget(relative) + + stats.delete(parentOf(relative)) + }, + + async removeDir(path) { + const relative = toRelative(path) + + if (!relative) { + throw new FsError('EBUSY', path, 'resource busy or locked') + } + + if (!(await stat(path)).isDirectory) { + throw enotdir(path) + } + + const directoryKey = directoryKeyOf(relative) + + const listing = await store.listObjects(scope, directoryKey, { maxKeys: 2 }) + + if ( + listing.prefixes.length > 0 || + listing.items.some((item) => item.key !== directoryKey) + ) { + throw enotempty(path) + } + + // @note the marker may not exist, when the directory was implicit and + // its last file has just gone; either way it is gone now + + try { + await store.deleteObject(scope, directoryKey) + } catch { + // @note already absent is the outcome wanted here + } + + forget(relative) + + stats.delete(parentOf(relative)) + }, + + async rename(oldPath, newPath) { + const fromRelative = toRelative(oldPath) + const toRelativePath = toRelative(newPath) + + if (!fromRelative || !toRelativePath) { + throw new FsError('EBUSY', oldPath, 'resource busy or locked') + } + + const current = await stat(oldPath) + + if (current.isDirectory) { + await moveTree(fromRelative, toRelativePath) + } else { + await store.moveObject(scope, keyOf(fromRelative), keyOf(toRelativePath)) + } + + // @note a move touches the source, the destination and both parents; + // dropping everything is simpler than being clever about four entries + + forgetAll() + }, + + async symlink(_target, linkPath) { + throw enotsup(linkPath, 'symlink') + }, + + async readlink(path) { + throw enotsup(path, 'readlink') + }, + + async link(_oldPath, newPath) { + throw enotsup(newPath, 'link') + }, + + // @note ownership, permissions and timestamps are not properties an object + // store has; the guest asks after every write and is told it succeeded, + // because the alternative is every `cp` and `mkdir` reporting a failure + // for something that did work + + async chmod() {}, + + async chown() {}, + + async utimes() {}, + } +} diff --git a/packages/sandbox/src/store.fake.js b/packages/sandbox/src/store.fake.js new file mode 100644 index 0000000..0565887 --- /dev/null +++ b/packages/sandbox/src/store.fake.js @@ -0,0 +1,123 @@ +// @note an in-memory stand-in for the storage contract, for the tests. It +// keeps the shape a real object store has - flat keys, a delimiter that +// groups them into "directories", paging - because those are the seams the +// mount driver is written against. + +/** + * @param {Record} [initial] keys to contents, per scope-less key + */ +export function createFakeStore(initial = {}) { + /** @type {Map} */ + const objects = new Map() + + const put = (key, content) => { + objects.set(key, { + bytes: + typeof content === 'string' + ? new TextEncoder().encode(content) + : new Uint8Array(content), + updatedAt: new Date(), + }) + } + + for (const [key, content] of Object.entries(initial)) { + put(key, content) + } + + const missing = (key) => { + const error = new Error(`NoSuchKey: ${key}`) + + error.name = 'NoSuchKey' + + return error + } + + return { + objects, + + async listObjects(_scope, prefix, options = {}) { + const { delimiter, maxKeys = 1000, continuationToken } = options + + const keys = [...objects.keys()].filter((key) => key.startsWith(prefix)).sort() + + const items = [] + const prefixes = new Set() + + for (const key of keys) { + const rest = key.slice(prefix.length) + + if (delimiter && rest.includes(delimiter)) { + prefixes.add(prefix + rest.slice(0, rest.indexOf(delimiter) + 1)) + } else { + items.push({ key, size: objects.get(key).bytes.byteLength, updatedAt: objects.get(key).updatedAt }) + } + } + + const start = continuationToken ? Number(continuationToken) : 0 + const page = items.slice(start, start + maxKeys) + const truncated = start + maxKeys < items.length + + return { + items: page, + prefixes: start === 0 ? [...prefixes] : [], + truncated, + nextToken: truncated ? String(start + maxKeys) : undefined, + } + }, + + async headObject(_scope, key) { + const object = objects.get(key) + + if (!object) { + throw missing(key) + } + + return { size: object.bytes.byteLength, updatedAt: object.updatedAt } + }, + + async getObject(_scope, key) { + const object = objects.get(key) + + if (!object) { + throw missing(key) + } + + const bytes = object.bytes + + return { + size: bytes.byteLength, + updatedAt: object.updatedAt, + body: { + arrayBuffer: async () => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), + text: async () => new TextDecoder().decode(bytes), + stream: () => new Blob([bytes]).stream(), + }, + } + }, + + async putObject(_scope, key, body) { + put(key, body) + }, + + async moveObject(_scope, sourceKey, destinationKey) { + const object = objects.get(sourceKey) + + if (!object) { + throw missing(sourceKey) + } + + objects.set(destinationKey, object) + objects.delete(sourceKey) + }, + + async deleteObject(_scope, key) { + objects.delete(key) + }, + + text(key) { + const object = objects.get(key) + + return object ? new TextDecoder().decode(object.bytes) : undefined + }, + } +} diff --git a/platform/lib/security.headers.js b/platform/lib/security.headers.js index 15a24cd..c712c4b 100644 --- a/platform/lib/security.headers.js +++ b/platform/lib/security.headers.js @@ -1,4 +1,6 @@ +/* eslint-disable import/extensions -- loaded by next.config.d at build time, where the alias does not resolve */ // @ts-check +import { siteUrl } from '../config/site.js' /** * @type {string} @@ -31,10 +33,22 @@ const ALLOWED_FONTS = ['https:', 'blob:', 'data:'].join(' ') const ALLOWED_MEDIA = ['https:', 'blob:', 'data:'].join(' ') /** - * @type {string} + * @note `http:` and `ws:` join the list only when the site itself is served + * without TLS. Such a deployment has no transport security for the policy to + * preserve, and its local relay (RELAY_URL) speaks plain websockets. An + * https site never gets them - browsers refuse that mixed content anyway. * + * @type {string} */ -const ALLOWED_CONNECTS = ['https:', 'blob:', 'data:', 'wss:'].join(' ') +const ALLOWED_CONNECTS = [ + 'https:', + 'wss:', + + ...(new URL(siteUrl).protocol === 'http:' ? ['http:', 'ws:'] : []), + + 'blob:', + 'data:', +].join(' ') /** * @type {string} diff --git a/platform/lib/security.headers.utest.js b/platform/lib/security.headers.utest.js index f6a49da..08a78d3 100644 --- a/platform/lib/security.headers.utest.js +++ b/platform/lib/security.headers.utest.js @@ -7,6 +7,8 @@ import { buildOriginRestrictedCsp, } from '@/lib/security.headers' +import { siteUrl } from '@/config/site' + describe('Security Headers Configuration', () => { describe('DEFAULT_SECURITY_HEADERS', () => { it('should be defined as an object with security properties', () => { @@ -81,6 +83,15 @@ describe('Security Headers Configuration', () => { expect(rest).toEqual(defaultRest) }) + it('allows plain websockets only on a site served without TLS', () => { + const connects = directives['connect-src'].split(' ') + + expect(connects).toContain('wss:') + expect(connects.includes('ws:')).toBe( + new URL(siteUrl).protocol === 'http:' + ) + }) + it('constrains scripts, connections, forms and base URL', () => { expect(directives['default-src']).toBe("'self'") expect(directives['script-src']).toMatch(/^'self'/) diff --git a/platform/lib/usage.get.ts b/platform/lib/usage.get.ts index c1c5203..20e2d0e 100644 --- a/platform/lib/usage.get.ts +++ b/platform/lib/usage.get.ts @@ -189,7 +189,8 @@ export async function getUsageSeriesNow( // @ts-ignore tokens .map(({ total, date, type, ...rest }) => { - total = total.toNumber?.() ?? total + // @note MySQL returns SUM() as Decimal, SQLite as BigInt + total = toNumber(total) total = getBaseLanguageModelTokenCount( useTypeToLanguageModelMapping[type], @@ -298,7 +299,8 @@ export async function getUsageForPeriod( // @ts-ignore tokens .map(({ total, date, type, ...rest }) => { - total = total.toNumber?.() ?? total + // @note MySQL returns SUM() as Decimal, SQLite as BigInt + total = toNumber(total) total = getBaseLanguageModelTokenCount( useTypeToLanguageModelMapping[type], diff --git a/platform/lib/usage.get.utest.js b/platform/lib/usage.get.utest.js index 0f0749e..88c8f84 100644 --- a/platform/lib/usage.get.utest.js +++ b/platform/lib/usage.get.utest.js @@ -284,6 +284,31 @@ describe('usage.get', () => { expect(result.conversations[0].total).toBe(10) expect(result.messages[0].total).toBe(25) }) + + it('should sum BigInt token totals as returned by SQLite', async () => { + const mockTokenData = [ + { date: new Date('2023-01-01'), type: 'OPENAI_GPT_4_TOKEN', total: 100n }, + { + date: new Date('2023-01-01'), + type: 'OPENAI_GPT_3_5_TURBO_TOKEN', + total: 50n, + }, + ] + + prisma.$queryRaw.mockResolvedValueOnce(mockTokenData) + prisma.$queryRawTyped + .mockResolvedValueOnce([{ date: new Date('2023-01-01'), total: 7n }]) + .mockResolvedValueOnce([{ date: new Date('2023-01-01'), total: 9n }]) + + const result = await getUsageSeries('user123') + + expect(getBaseLanguageModelTokenCount).toHaveBeenCalledWith('gpt-4', 100) + expect(result.tokens).toEqual([ + { date: new Date('2023-01-01'), total: 225 }, + ]) + expect(result.conversations[0].total).toBe(7) + expect(result.messages[0].total).toBe(9) + }) }) describe('getUsageSeriesFromDate', () => { @@ -385,6 +410,20 @@ describe('usage.get', () => { expect(prisma.$queryRaw).toHaveBeenCalled() }) + it('should sum BigInt token totals as returned by SQLite', async () => { + prisma.$queryRaw.mockResolvedValueOnce([ + { date: new Date('2023-01-15'), type: 'OPENAI_GPT_4_TOKEN', total: 40n }, + { date: new Date('2023-01-15'), type: 'OPENAI_GPT_4_TOKEN', total: 20n }, + ]) + prisma.$queryRawTyped.mockResolvedValueOnce([]).mockResolvedValueOnce([]) + + const result = await getUsageForPeriod('user123', new Date(), new Date()) + + expect(result.tokens).toEqual([ + { date: new Date('2023-01-15'), total: 90 }, + ]) + }) + it('should group token data by date correctly', async () => { const mockTokenData = [ { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1628f86..b20f981 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2293,6 +2293,12 @@ importers: '@chatbotkit-dev/sandbox-spec': specifier: workspace:* version: link:../sandbox-spec + '@chatbotkit-dev/storage': + specifier: workspace:* + version: link:../storage + '@chatbotkit-dev/storage-spec': + specifier: workspace:* + version: link:../storage-spec '@rivet-dev/agentos-core': specifier: 0.2.19 version: 0.2.19