From 3ad69cf26a061afb89e80647e03b48326b10fb0e Mon Sep 17 00:00:00 2001 From: Annactswell Date: Thu, 9 Jul 2026 16:06:49 +0800 Subject: [PATCH 001/133] Executors move files through in-container exec so symlinks cannot reach the host; 12 contract questions --- packages/server/src/executor/contract.ts | 212 ++++++++++++++++++++++- packages/server/src/executor/docker.ts | 211 +++++++++++++++++++--- packages/server/src/executor/executor.ts | 33 ++++ packages/server/src/executor/fake.ts | 83 ++++++++- 4 files changed, 504 insertions(+), 35 deletions(-) diff --git a/packages/server/src/executor/contract.ts b/packages/server/src/executor/contract.ts index 2da84cf..3c2f7e5 100644 --- a/packages/server/src/executor/contract.ts +++ b/packages/server/src/executor/contract.ts @@ -1,7 +1,16 @@ import { randomUUID } from 'node:crypto'; -import { EXEC_OUTPUT_LIMIT_BYTES } from '@dormice/shared'; +import { + EXEC_OUTPUT_LIMIT_BYTES, + FILE_SIZE_LIMIT_BYTES, +} from '@dormice/shared'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import type { ContainerState, Executor } from './executor'; +import { + type ContainerState, + type Executor, + FileNotFoundError, + FileTooLargeError, + NotAFileError, +} from './executor'; /** * What a contract run needs beyond the executor itself: a way to stage the @@ -376,5 +385,204 @@ export function describeExecutorContract( }, timeoutMs, ); + + it( + 'writeFiles then readFile round-trips text', + async () => { + const id = await fresh(); + await executor.writeFiles(id, [ + { path: '/home/user/hello.txt', content: Buffer.from('hello\n') }, + ]); + const read = await executor.readFile(id, '/home/user/hello.txt'); + expect(read.toString('utf8')).toBe('hello\n'); + }, + timeoutMs, + ); + + it( + 'file content round-trips byte-exact, binary included', + async () => { + const id = await fresh(); + // Every byte value, repeated — any encoding sloppiness (utf8 coercion, + // base64 mangling, CR/LF translation) breaks the exact comparison. + const bytes = Buffer.alloc(256 * 64); + for (let i = 0; i < bytes.length; i++) bytes[i] = i % 256; + await executor.writeFiles(id, [ + { path: '/home/user/blob.bin', content: bytes }, + ]); + const read = await executor.readFile(id, '/home/user/blob.bin'); + expect(read.equals(bytes)).toBe(true); + }, + timeoutMs, + ); + + it( + 'writeFiles writes the whole batch', + async () => { + const id = await fresh(); + await executor.writeFiles(id, [ + { path: '/home/user/a.txt', content: Buffer.from('a') }, + { path: '/home/user/b.txt', content: Buffer.from('b') }, + { path: '/home/user/c.txt', content: Buffer.from('c') }, + ]); + expect( + (await executor.readFile(id, '/home/user/a.txt')).toString(), + ).toBe('a'); + expect( + (await executor.readFile(id, '/home/user/b.txt')).toString(), + ).toBe('b'); + expect( + (await executor.readFile(id, '/home/user/c.txt')).toString(), + ).toBe('c'); + }, + timeoutMs, + ); + + it( + 'relative paths resolve against /home/user', + async () => { + const id = await fresh(); + await executor.writeFiles(id, [ + { path: 'rel.txt', content: Buffer.from('via relative') }, + ]); + const read = await executor.readFile(id, '/home/user/rel.txt'); + expect(read.toString('utf8')).toBe('via relative'); + }, + timeoutMs, + ); + + it( + 'writeFiles creates missing parent directories', + async () => { + const id = await fresh(); + await executor.writeFiles(id, [ + { + path: '/home/user/deep/er/nested.txt', + content: Buffer.from('deep'), + }, + ]); + const read = await executor.readFile( + id, + '/home/user/deep/er/nested.txt', + ); + expect(read.toString('utf8')).toBe('deep'); + }, + timeoutMs, + ); + + it( + 'writing an existing path overwrites it', + async () => { + const id = await fresh(); + await executor.writeFiles(id, [ + { path: '/home/user/v.txt', content: Buffer.from('first') }, + ]); + await executor.writeFiles(id, [ + { path: '/home/user/v.txt', content: Buffer.from('second') }, + ]); + const read = await executor.readFile(id, '/home/user/v.txt'); + expect(read.toString('utf8')).toBe('second'); + }, + timeoutMs, + ); + + it( + 'files live on the disk: they survive stop, start, even a vanished container', + async () => { + const id = await fresh(); + await executor.writeFiles(id, [ + { path: '/home/user/keep.txt', content: Buffer.from('still here') }, + ]); + await executor.freeze(id); + await executor.stop(id); + // The container object itself is lost (a prune) — the disk, which + // holds the files, is the sandbox's actual body. + await subject.vanishContainer(id); + await executor.start(id); + const read = await executor.readFile(id, '/home/user/keep.txt'); + expect(read.toString('utf8')).toBe('still here'); + }, + timeoutMs, + ); + + it( + 'readFile on a missing path throws FileNotFoundError', + async () => { + const id = await fresh(); + const missing = '/home/user/absent.txt'; + const error = await executor.readFile(id, missing).catch((e) => e); + expect(error).toBeInstanceOf(FileNotFoundError); + expect(error.message).toBe(`no such file: ${missing}`); + }, + timeoutMs, + ); + + it( + 'readFile on a directory throws NotAFileError', + async () => { + const id = await fresh(); + // /home/user exists as a directory in every sandbox by construction. + const error = await executor.readFile(id, '/home/user').catch((e) => e); + expect(error).toBeInstanceOf(NotAFileError); + expect(error.message).toBe('not a regular file: /home/user'); + }, + timeoutMs, + ); + + it( + 'writeFiles onto a directory throws NotAFileError', + async () => { + const id = await fresh(); + const error = await executor + .writeFiles(id, [{ path: '/home/user', content: Buffer.from('x') }]) + .catch((e) => e); + expect(error).toBeInstanceOf(NotAFileError); + expect(error.message).toBe('not a regular file: /home/user'); + }, + timeoutMs, + ); + + it( + 'readFile refuses an over-limit file with its actual size, never truncates', + async () => { + const id = await fresh(); + // One byte over the line. The executor's write path is deliberately + // uncapped (the protocol schema is the write-cap adjudicator), which + // is exactly what lets the exam stage an over-limit file to read. + const size = FILE_SIZE_LIMIT_BYTES + 1; + await executor.writeFiles(id, [ + { path: '/home/user/big.bin', content: Buffer.alloc(size) }, + ]); + const error = await executor + .readFile(id, '/home/user/big.bin') + .catch((e) => e); + expect(error).toBeInstanceOf(FileTooLargeError); + expect(error.message).toBe( + `file too large: /home/user/big.bin is ${size} bytes, limit ${FILE_SIZE_LIMIT_BYTES}`, + ); + }, + timeoutMs * 4, + ); + + it( + 'rejects file operations on a container that is not running', + async () => { + const paused = await fresh(); + await executor.freeze(paused); + await expect( + executor.writeFiles(paused, [ + { path: 'x.txt', content: Buffer.from('x') }, + ]), + ).rejects.toThrow(/is paused, expected running/); + await expect(executor.readFile(paused, 'x.txt')).rejects.toThrow( + /is paused, expected running/, + ); + + await expect(executor.readFile(randomUUID(), 'x.txt')).rejects.toThrow( + /is absent, expected running/, + ); + }, + timeoutMs, + ); }); } diff --git a/packages/server/src/executor/docker.ts b/packages/server/src/executor/docker.ts index 9a4ebbb..a923782 100644 --- a/packages/server/src/executor/docker.ts +++ b/packages/server/src/executor/docker.ts @@ -9,14 +9,22 @@ import { } from 'node:fs/promises'; import path from 'node:path'; import { Writable } from 'node:stream'; -import { EXEC_OUTPUT_LIMIT_BYTES } from '@dormice/shared'; +import { + EXEC_OUTPUT_LIMIT_BYTES, + FILE_SIZE_LIMIT_BYTES, + resolveSandboxPath, +} from '@dormice/shared'; import Docker from 'dockerode'; import { execa } from 'execa'; -import type { - ContainerState, - ExecOptions, - ExecResult, - Executor, +import { + type ContainerState, + type ExecOptions, + type ExecResult, + type Executor, + FileNotFoundError, + FileTooLargeError, + type FileToWrite, + NotAFileError, } from './executor'; /** @@ -45,6 +53,45 @@ export function containerName(sandboxId: string): string { return `sbx-${sandboxId}`; } +/** + * In-container deadline for file operations, same mechanism as exec's + * (host-side disconnects cannot kill an in-container process). Not a user + * knob: 16 MiB on a local ext4 is subsecond work — this guard only exists + * so a pathological target cannot hang the daemon's request forever. + */ +const FILE_OP_TIMEOUT_SECONDS = 60; + +/** + * The file-op scripts talk back through exit codes of our choosing — private + * numbers between the daemon and its own script, no user command runs inside. + * They map 1:1 onto the typed file errors both executors must throw. + */ +const NO_SUCH_FILE_EXIT = 44; +const NOT_A_FILE_EXIT = 45; +const TOO_LARGE_EXIT = 46; + +/** + * $1 = absolute path. Refuses a target that exists but is not a regular + * file (directory, FIFO — `cat >` into a FIFO would block forever), creates + * the parents, then streams stdin into the file. Runs as uid 1000, so + * in-sandbox permissions apply honestly, and path resolution — symlinks + * included — happens inside the container, where there is no host to + * escape to. + */ +const WRITE_FILE_SCRIPT = [ + `[ ! -e "$1" ] || [ -f "$1" ] || exit ${NOT_A_FILE_EXIT}`, + 'mkdir -p -- "$(dirname -- "$1")" && exec cat > "$1"', +].join('\n'); + +/** $1 = absolute path, $2 = size limit in bytes. Size gate before content: an over-limit file is refused, never truncated. */ +const READ_FILE_SCRIPT = [ + `[ -e "$1" ] || exit ${NO_SUCH_FILE_EXIT}`, + `[ -f "$1" ] || exit ${NOT_A_FILE_EXIT}`, + 'size=$(stat -c %s -- "$1") || exit 1', + `[ "$size" -le "$2" ] || { echo "$size" >&2; exit ${TOO_LARGE_EXIT}; }`, + 'exec cat -- "$1"', +].join('\n'); + /** * Docker reports seven statuses; the executor's contract knows three. With * RestartPolicy "no" a container never restarts on its own, so everything @@ -94,8 +141,12 @@ class CappedBuffer extends Writable { callback(); } + bytes(): Buffer { + return Buffer.concat(this.chunks); + } + text(): string { - return Buffer.concat(this.chunks).toString('utf8'); + return this.bytes().toString('utf8'); } } @@ -269,8 +320,8 @@ export class DockerExecutor implements Executor { // The deadline lives in-container, via GNU timeout: closing the // host-side stream cannot kill the in-container process (measured in // the predecessor system); only an in-container SIGKILL can. 137 = killed. - const exec = await container.exec({ - Cmd: [ + const run = await this.runInContainer(container, sandboxId, { + cmd: [ 'timeout', '--signal=KILL', String(opts.timeoutSeconds), @@ -278,28 +329,140 @@ export class DockerExecutor implements Executor { '-c', opts.command, ], + outputCap: EXEC_OUTPUT_LIMIT_BYTES, + workingDir: opts.cwd, + env: opts.env, + }); + return { + exitCode: run.exitCode, + stdout: run.stdout.text(), + stderr: run.stderr.text(), + stdoutTruncated: run.stdout.truncated, + stderrTruncated: run.stderr.truncated, + }; + } + + async writeFiles(sandboxId: string, files: FileToWrite[]): Promise { + const containerId = await this.expectState(sandboxId, 'running'); + const container = this.docker.getContainer(containerId); + // In array order, failing fast — the batch saves round-trips, it is not + // a transaction; earlier files stay written, as the protocol documents. + for (const file of files) { + const path = resolveSandboxPath(file.path); + const run = await this.runInContainer(container, sandboxId, { + cmd: [ + 'timeout', + '--signal=KILL', + String(FILE_OP_TIMEOUT_SECONDS), + 'bash', + '-c', + WRITE_FILE_SCRIPT, + 'bash', + path, + ], + outputCap: EXEC_OUTPUT_LIMIT_BYTES, + stdin: file.content, + }); + if (run.exitCode === NOT_A_FILE_EXIT) { + throw new NotAFileError(`not a regular file: ${path}`); + } + if (run.exitCode !== 0) { + throw new Error( + `writing ${path} in ${sandboxId} failed (exit ${run.exitCode}): ${run.stderr.text().trim()}`, + ); + } + } + } + + async readFile(sandboxId: string, path: string): Promise { + const resolved = resolveSandboxPath(path); + const containerId = await this.expectState(sandboxId, 'running'); + const container = this.docker.getContainer(containerId); + const run = await this.runInContainer(container, sandboxId, { + cmd: [ + 'timeout', + '--signal=KILL', + String(FILE_OP_TIMEOUT_SECONDS), + 'bash', + '-c', + READ_FILE_SCRIPT, + 'bash', + resolved, + String(FILE_SIZE_LIMIT_BYTES), + ], + outputCap: FILE_SIZE_LIMIT_BYTES, + }); + if (run.exitCode === NO_SUCH_FILE_EXIT) { + throw new FileNotFoundError(`no such file: ${resolved}`); + } + if (run.exitCode === NOT_A_FILE_EXIT) { + throw new NotAFileError(`not a regular file: ${resolved}`); + } + if (run.exitCode === TOO_LARGE_EXIT) { + const size = Number(run.stderr.text().trim()); + throw new FileTooLargeError( + `file too large: ${resolved} is ${size} bytes, limit ${FILE_SIZE_LIMIT_BYTES}`, + ); + } + if (run.exitCode !== 0) { + throw new Error( + `reading ${resolved} in ${sandboxId} failed (exit ${run.exitCode}): ${run.stderr.text().trim()}`, + ); + } + if (run.stdout.truncated) { + // The size gate passed but the file grew past the cap before cat + // finished — rare, but delivering a silently cut file is never right. + throw new FileTooLargeError( + `file too large: ${resolved} exceeds limit ${FILE_SIZE_LIMIT_BYTES}`, + ); + } + return run.stdout.bytes(); + } + + /** + * The one exec pipeline: start, demux, optionally feed stdin (ending the + * stream is what delivers EOF to the in-container reader), wait for the + * stream, then poll for the exit code — the engine records it a beat + * after the stream ends, the same measured lag as kill vs exited in + * stop(). Tty stays off: the multiplexed stream is what demuxStream can + * split back into distinct stdout and stderr. + */ + private async runInContainer( + container: Docker.Container, + sandboxId: string, + spec: { + cmd: string[]; + outputCap: number; + stdin?: Buffer; + workingDir?: string; + env?: Record; + }, + ): Promise<{ exitCode: number; stdout: CappedBuffer; stderr: CappedBuffer }> { + const exec = await container.exec({ + Cmd: spec.cmd, + AttachStdin: spec.stdin !== undefined, AttachStdout: true, AttachStderr: true, - WorkingDir: opts.cwd, - Env: opts.env - ? Object.entries(opts.env).map(([key, value]) => `${key}=${value}`) + WorkingDir: spec.workingDir, + Env: spec.env + ? Object.entries(spec.env).map(([key, value]) => `${key}=${value}`) : undefined, User: 'user', }); - // Tty stays off: the multiplexed stream is what demuxStream can split - // back into distinct stdout and stderr. - const stream = await exec.start({}); - const stdout = new CappedBuffer(EXEC_OUTPUT_LIMIT_BYTES); - const stderr = new CappedBuffer(EXEC_OUTPUT_LIMIT_BYTES); + const stream = await exec.start( + spec.stdin !== undefined ? { hijack: true, stdin: true } : {}, + ); + const stdout = new CappedBuffer(spec.outputCap); + const stderr = new CappedBuffer(spec.outputCap); this.docker.modem.demuxStream(stream, stdout, stderr); + if (spec.stdin !== undefined) { + stream.end(spec.stdin); + } await new Promise((resolve, reject) => { stream.on('end', resolve); stream.on('close', resolve); stream.on('error', reject); }); - // The engine records the exit code a beat after the stream ends — the - // same measured lag as kill vs exited in stop(). Poll briefly instead - // of reading a null. let info = await exec.inspect(); for (let i = 0; info.Running || info.ExitCode === null; i++) { if (i >= 20) { @@ -310,13 +473,7 @@ export class DockerExecutor implements Executor { await new Promise((resolve) => setTimeout(resolve, 50)); info = await exec.inspect(); } - return { - exitCode: info.ExitCode, - stdout: stdout.text(), - stderr: stderr.text(), - stdoutTruncated: stdout.truncated, - stderrTruncated: stderr.truncated, - }; + return { exitCode: info.ExitCode, stdout, stderr }; } private imagePath(sandboxId: string): string { diff --git a/packages/server/src/executor/executor.ts b/packages/server/src/executor/executor.ts index 3963e22..b85dd98 100644 --- a/packages/server/src/executor/executor.ts +++ b/packages/server/src/executor/executor.ts @@ -28,6 +28,21 @@ export interface ExecResult { stderrTruncated: boolean; } +export interface FileToWrite { + /** Absolute, or relative to /home/user — resolveSandboxPath's rules. */ + path: string; + content: Buffer; +} + +/** + * The file-op error taxonomy, shared by both executors down to the message + * (the contract exam holds them to it) and mapped to HTTP statuses by the + * route: not found -> 404, not a regular file -> 400, too large -> 413. + */ +export class FileNotFoundError extends Error {} +export class NotAFileError extends Error {} +export class FileTooLargeError extends Error {} + /** * The executor is where lifecycle decisions become physical reality: * containers created, paused, stopped. The daemon's decision logic (the @@ -86,4 +101,22 @@ export interface Executor { * drained and dropped, and the truncation reported. */ exec(sandboxId: string, opts: ExecOptions): Promise; + /** + * Writes every file in the batch, in order, failing fast — earlier files + * stay written (a batch saves round-trips, it is not a transaction). + * Parent directories are created; existing files are overwritten. Requires + * state `running`, like exec. Paths resolve inside the container — a + * symlink planted by the sandbox can only point at the sandbox's own view, + * never the host's; this is why file I/O goes through the container and + * not through the host-side mount. No size check here: the protocol + * schema is the single write-cap adjudicator. + */ + writeFiles(sandboxId: string, files: FileToWrite[]): Promise; + /** + * Returns one file's bytes. Requires state `running`. Throws the typed + * file errors above; a file over FILE_SIZE_LIMIT_BYTES is refused, never + * truncated — a truncated file is a corrupt file. The read cap lives here + * and not in the schema because only the executor can observe the size. + */ + readFile(sandboxId: string, path: string): Promise; } diff --git a/packages/server/src/executor/fake.ts b/packages/server/src/executor/fake.ts index 37d2d55..4f54d93 100644 --- a/packages/server/src/executor/fake.ts +++ b/packages/server/src/executor/fake.ts @@ -1,11 +1,26 @@ -import { EXEC_OUTPUT_LIMIT_BYTES } from '@dormice/shared'; -import type { - ContainerState, - ExecOptions, - ExecResult, - Executor, +import { + EXEC_OUTPUT_LIMIT_BYTES, + FILE_SIZE_LIMIT_BYTES, + resolveSandboxPath, +} from '@dormice/shared'; +import { + type ContainerState, + type ExecOptions, + type ExecResult, + type Executor, + FileNotFoundError, + FileTooLargeError, + type FileToWrite, + NotAFileError, } from './executor'; +/** + * Directories the base image guarantees in every sandbox. The fake's + * filesystem is otherwise implicit — a directory exists exactly when some + * file lives under it, which is all mkdir -p semantics leave observable. + */ +const BUILTIN_DIRS = new Set(['/', '/home', '/home/user', '/tmp']); + /** * In-memory stand-in for the Docker+gVisor executor. Not a throwaway: it is * the permanent test double — unit tests and local development run on it; @@ -18,6 +33,13 @@ import type { export class FakeExecutor implements Executor { private readonly containers = new Map(); private readonly disks = new Set(); + /** + * Keyed like disks, not containers: files are the disk's content, so they + * survive stop, start, and a vanished container object, and die only when + * the disk does. That is the "the disk is the sandbox's body" invariant, + * modeled where it physically lives. + */ + private readonly files = new Map>(); /** Test hook: what does "reality" say about this sandbox? */ stateOf(sandboxId: string): ContainerState | undefined { @@ -48,6 +70,7 @@ export class FakeExecutor implements Executor { throw new Error(`container ${sandboxId} already exists`); } this.disks.add(sandboxId); + this.files.set(sandboxId, new Map()); this.containers.set(sandboxId, 'running'); } @@ -90,6 +113,7 @@ export class FakeExecutor implements Executor { // hearing. const hadContainer = this.containers.delete(sandboxId); const hadDisk = this.disks.delete(sandboxId); + this.files.delete(sandboxId); if (!hadContainer && !hadDisk) { throw new Error(`container ${sandboxId} is absent, cannot destroy`); } @@ -107,6 +131,7 @@ export class FakeExecutor implements Executor { async removeDisk(sandboxId: string): Promise { // Idempotent by contract: an absent disk already is the goal state. this.disks.delete(sandboxId); + this.files.delete(sandboxId); } async exec(sandboxId: string, opts: ExecOptions): Promise { @@ -127,6 +152,52 @@ export class FakeExecutor implements Executor { } } + async writeFiles(sandboxId: string, files: FileToWrite[]): Promise { + this.expect(sandboxId, 'running'); + const disk = this.files.get(sandboxId) ?? new Map(); + this.files.set(sandboxId, disk); + for (const file of files) { + const path = resolveSandboxPath(file.path); + if (this.isDirectory(disk, path)) { + throw new NotAFileError(`not a regular file: ${path}`); + } + // Copy on the way in: the caller's buffer is theirs to reuse. + disk.set(path, Buffer.from(file.content)); + } + } + + async readFile(sandboxId: string, path: string): Promise { + this.expect(sandboxId, 'running'); + const disk = this.files.get(sandboxId) ?? new Map(); + const resolved = resolveSandboxPath(path); + const content = disk.get(resolved); + if (content !== undefined) { + if (content.length > FILE_SIZE_LIMIT_BYTES) { + throw new FileTooLargeError( + `file too large: ${resolved} is ${content.length} bytes, limit ${FILE_SIZE_LIMIT_BYTES}`, + ); + } + return Buffer.from(content); + } + if (this.isDirectory(disk, resolved)) { + throw new NotAFileError(`not a regular file: ${resolved}`); + } + throw new FileNotFoundError(`no such file: ${resolved}`); + } + + /** + * A path is a directory when the image ships it or some file lives under + * it — the same observable truth mkdir -p leaves behind on a real disk. + */ + private isDirectory(disk: Map, path: string): boolean { + if (BUILTIN_DIRS.has(path)) return true; + const prefix = path === '/' ? '/' : `${path}/`; + for (const existing of disk.keys()) { + if (existing.startsWith(prefix)) return true; + } + return false; + } + private expect(sandboxId: string, wanted: ContainerState): void { const actual = this.containers.get(sandboxId); if (actual !== wanted) { From c5fc5eb0e07dd5947608679cc2195dece3b1108b Mon Sep 17 00:00:00 2001 From: Annactswell Date: Thu, 9 Jul 2026 16:06:49 +0800 Subject: [PATCH 002/133] POST /writeFiles and /readFile run inside the key's slot, typed file errors map onto 404/400/413 --- packages/server/src/app.test.ts | 170 +++++++++++++++++++++++- packages/server/src/routes/sandboxes.ts | 118 ++++++++++++++-- 2 files changed, 273 insertions(+), 15 deletions(-) diff --git a/packages/server/src/app.test.ts b/packages/server/src/app.test.ts index dc1ef39..853c99b 100644 --- a/packages/server/src/app.test.ts +++ b/packages/server/src/app.test.ts @@ -1,5 +1,8 @@ import { fileURLToPath } from 'node:url'; -import { DEFAULT_LIFECYCLE_POLICY } from '@dormice/shared'; +import { + DEFAULT_LIFECYCLE_POLICY, + FILE_SIZE_LIMIT_BYTES, +} from '@dormice/shared'; import { describe, expect, it } from 'vitest'; import { buildApp } from './app'; import { loadConfig } from './config'; @@ -545,6 +548,171 @@ describe('POST /execCommand', () => { }); }); +describe('POST /writeFiles and /readFile', () => { + it('round-trips content through base64, resolving paths to absolute', async () => { + const { app } = testApp(); + await acquire(app, { userKey: 'alice' }); + // Every byte value: any utf8 coercion or base64 sloppiness breaks this. + const bytes = Buffer.alloc(256); + for (let i = 0; i < 256; i++) bytes[i] = i; + + const write = await rpc(app, '/writeFiles', { + userKey: 'alice', + files: [ + { + path: 'notes.txt', + contentBase64: Buffer.from('hi\n').toString('base64'), + }, + { + path: '/home/user/blob.bin', + contentBase64: bytes.toString('base64'), + }, + ], + }); + expect(write.statusCode).toBe(200); + expect(write.json()).toEqual({ + files: [ + { path: '/home/user/notes.txt' }, + { path: '/home/user/blob.bin' }, + ], + }); + + const read = await rpc(app, '/readFile', { + userKey: 'alice', + path: 'blob.bin', + }); + expect(read.statusCode).toBe(200); + expect(read.json().path).toBe('/home/user/blob.bin'); + expect(Buffer.from(read.json().contentBase64, 'base64').equals(bytes)).toBe( + true, + ); + }); + + it('answers an unknown key with a 404 on both verbs, never a silent create', async () => { + const { app } = testApp(); + const write = await rpc(app, '/writeFiles', { + userKey: 'nobody', + files: [{ path: 'x', contentBase64: 'eA==' }], + }); + expect(write.statusCode).toBe(404); + const read = await rpc(app, '/readFile', { userKey: 'nobody', path: 'x' }); + expect(read.statusCode).toBe(404); + expect(read.json().message).toMatch(/no sandbox for key/); + }); + + it('wakes a frozen sandbox before touching files', async () => { + const { app, db, executor, locks } = testApp(); + const created = (await acquire(app, { userKey: 'alice' })).json(); + await scanOnce( + db, + executor, + locks, + after( + created.sandbox.lastActiveAt, + DEFAULT_LIFECYCLE_POLICY.freezeAfterSeconds, + ), + ); + expect(executor.stateOf(created.sandbox.sandboxId)).toBe('paused'); + + const res = await rpc(app, '/writeFiles', { + userKey: 'alice', + files: [{ path: 'woke.txt', contentBase64: 'eA==' }], + }); + expect(res.statusCode).toBe(200); + expect(executor.stateOf(created.sandbox.sandboxId)).toBe('running'); + }); + + it('maps the typed file errors onto 404, 400 and 413', async () => { + const { app, executor } = testApp(); + const created = (await acquire(app, { userKey: 'alice' })).json(); + + const missing = await rpc(app, '/readFile', { + userKey: 'alice', + path: 'absent.txt', + }); + expect(missing.statusCode).toBe(404); + expect(missing.json().message).toBe('no such file: /home/user/absent.txt'); + + const directory = await rpc(app, '/readFile', { + userKey: 'alice', + path: '/home/user', + }); + expect(directory.statusCode).toBe(400); + expect(directory.json().message).toBe('not a regular file: /home/user'); + + // Staged straight through the executor: its write path is deliberately + // uncapped (the schema is the write-cap adjudicator), which is what + // lets an over-limit file exist to be read. + const size = FILE_SIZE_LIMIT_BYTES + 1; + await executor.writeFiles(created.sandbox.sandboxId, [ + { path: 'big.bin', content: Buffer.alloc(size) }, + ]); + const big = await rpc(app, '/readFile', { + userKey: 'alice', + path: 'big.bin', + }); + expect(big.statusCode).toBe(413); + expect(big.json().message).toBe( + `file too large: /home/user/big.bin is ${size} bytes, limit ${FILE_SIZE_LIMIT_BYTES}`, + ); + }); + + it('rejects an over-limit write with a 400 from the schema, not a body-limit 413', async () => { + // One byte over, as base64 — ~21 MiB of body. Passing the raised route + // bodyLimit and failing the per-file refine proves both gates sit where + // they should: total bytes at the body limit, per-file size in the schema. + const { app } = testApp(); + await acquire(app, { userKey: 'alice' }); + const res = await rpc(app, '/writeFiles', { + userKey: 'alice', + files: [ + { + path: 'big.bin', + contentBase64: Buffer.alloc(FILE_SIZE_LIMIT_BYTES + 3).toString( + 'base64', + ), + }, + ], + }); + expect(res.statusCode).toBe(400); + expect(res.json().message).toMatch(/exceeds the \d+-byte limit/); + }); + + it('rejects malformed requests with the protocol {message} shape', async () => { + const { app } = testApp(); + await acquire(app, { userKey: 'alice' }); + const bad = [ + ['/writeFiles', { userKey: 'alice', files: [] }], + ['/writeFiles', { userKey: 'alice' }], + [ + '/writeFiles', + { userKey: 'alice', files: [{ path: '', contentBase64: 'eA==' }] }, + ], + [ + '/writeFiles', + { + userKey: 'alice', + files: [{ path: 'a\0b', contentBase64: 'eA==' }], + }, + ], + [ + '/writeFiles', + { + userKey: 'alice', + files: [{ path: 'x', contentBase64: '!!not-b64' }], + }, + ], + ['/readFile', { userKey: 'alice' }], + ['/readFile', { userKey: 'alice', path: '' }], + ] as const; + for (const [url, payload] of bad) { + const res = await rpc(app, url, payload as Record); + expect(res.statusCode).toBe(400); + expect(Object.keys(res.json())).toEqual(['message']); + } + }); +}); + describe('POST /listSandboxes', () => { it('requires a token', async () => { const res = await testApp().app.inject({ diff --git a/packages/server/src/routes/sandboxes.ts b/packages/server/src/routes/sandboxes.ts index 15761a5..ba346df 100644 --- a/packages/server/src/routes/sandboxes.ts +++ b/packages/server/src/routes/sandboxes.ts @@ -6,9 +6,15 @@ import { execCommandResponseSchema, type LifecyclePolicy, listSandboxesResponseSchema, + readFileRequestSchema, + readFileResponseSchema, releaseSandboxRequestSchema, releaseSandboxResponseSchema, + resolveSandboxPath, type Sandbox, + WRITE_FILES_BODY_LIMIT_BYTES, + writeFilesRequestSchema, + writeFilesResponseSchema, } from '@dormice/shared'; import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'; import { ZodError, z } from 'zod'; @@ -22,7 +28,12 @@ import { touch, } from '../db/ledger'; import type { SandboxRow } from '../db/schema'; -import type { Executor } from '../executor/executor'; +import { + type Executor, + FileNotFoundError, + FileTooLargeError, + NotAFileError, +} from '../executor/executor'; import { httpError } from '../http-error'; import type { KeyedQueue } from '../keyed-queue'; import { releaseSandbox, wakeSandbox } from '../lifecycle'; @@ -132,6 +143,23 @@ export const sandboxRoutes: FastifyPluginAsyncZod< }); } + // Shared by every verb that uses an existing sandbox (exec, file ops): + // 404 for an unknown key — these verbs are not creators, an unknown key + // is more likely a typo than an intent to build a sandbox as a side + // effect — then wake whatever cold state the sandbox is in and refresh + // its idle clock. Must be called while holding the key's queue slot. + async function wakeForUse(userKey: string): Promise { + const existing = findByUserKey(db, userKey); + if (!existing) { + throw httpError( + 404, + `no sandbox for key "${userKey}" — acquire it first`, + ); + } + const awake = await wakeSandbox(db, executor, existing); + return touch(db, awake.sandboxId); + } + // Native API convention: RPC style — every operation is a POST to a // camelCase verb route, input and output entirely in the body, route name // identical to the SDK method name. By construction this can never collide @@ -208,19 +236,7 @@ export const sandboxRoutes: FastifyPluginAsyncZod< // whole duration. The heartbeat keeps the scanner away; a concurrent // release mid-exec destroys the container and this exec fails with // the executor's honest error — accepted, not defended against. - const row = await locks.run(userKey, async () => { - const existing = findByUserKey(db, userKey); - if (!existing) { - // exec is not a creator: an unknown key here is more likely a - // typo than an intent to build a sandbox as a side effect. - throw httpError( - 404, - `no sandbox for key "${userKey}" — acquire it first`, - ); - } - const awake = await wakeSandbox(db, executor, existing); - return touch(db, awake.sandboxId); - }); + const row = await locks.run(userKey, () => wakeForUse(userKey)); const stopHeartbeat = startExecHeartbeat( db, @@ -247,6 +263,80 @@ export const sandboxRoutes: FastifyPluginAsyncZod< }, ); + // Maps the executor's typed file errors onto HTTP, message untouched. + function throwFileHttpError(error: unknown): never { + if (error instanceof FileNotFoundError) throw httpError(404, error.message); + if (error instanceof NotAFileError) throw httpError(400, error.message); + if (error instanceof FileTooLargeError) throw httpError(413, error.message); + throw error; + } + + // Both file verbs run ENTIRELY inside the key's queue slot, unlike exec: + // a file operation's work is bounded (16 MiB against a local ext4 — + // seconds at worst), so holding the slot is cheap and buys the same + // guarantees acquire enjoys — the scanner cannot freeze the sandbox + // mid-write and a concurrent release queues up behind us instead of + // destroying the container under our feet. No heartbeat needed. + app.post( + '/writeFiles', + { + // The one total gate for a batch; per-file size is the schema's job. + bodyLimit: WRITE_FILES_BODY_LIMIT_BYTES, + schema: { + body: writeFilesRequestSchema, + response: { 200: writeFilesResponseSchema }, + }, + }, + async (request) => { + const { userKey, files } = request.body; + return locks.run(userKey, async () => { + const row = await wakeForUse(userKey); + try { + await executor.writeFiles( + row.sandboxId, + files.map((file) => ({ + path: file.path, + content: Buffer.from(file.contentBase64, 'base64'), + })), + ); + } catch (error) { + throwFileHttpError(error); + } + touch(db, row.sandboxId); + return { + files: files.map((file) => ({ path: resolveSandboxPath(file.path) })), + }; + }); + }, + ); + + app.post( + '/readFile', + { + schema: { + body: readFileRequestSchema, + response: { 200: readFileResponseSchema }, + }, + }, + async (request) => { + const { userKey, path } = request.body; + return locks.run(userKey, async () => { + const row = await wakeForUse(userKey); + let content: Buffer; + try { + content = await executor.readFile(row.sandboxId, path); + } catch (error) { + throwFileHttpError(error); + } + touch(db, row.sandboxId); + return { + path: resolveSandboxPath(path), + contentBase64: content.toString('base64'), + }; + }); + }, + ); + app.post( '/releaseSandbox', { From 4a537a002cc4391138d43068f4bfb0176e2062a7 Mon Sep 17 00:00:00 2001 From: Annactswell Date: Thu, 9 Jul 2026 16:06:49 +0800 Subject: [PATCH 003/133] SDK writeFiles/readFile: strings travel as UTF-8, bytes byte-exact --- e2e/src/native.test.ts | 69 +++++++++++++++++++++++++++++++++ packages/sdk/src/client.test.ts | 34 ++++++++++++++++ packages/sdk/src/client.ts | 54 ++++++++++++++++++++++++++ packages/sdk/src/index.ts | 2 + 4 files changed, 159 insertions(+) diff --git a/e2e/src/native.test.ts b/e2e/src/native.test.ts index b946f02..173e215 100644 --- a/e2e/src/native.test.ts +++ b/e2e/src/native.test.ts @@ -203,4 +203,73 @@ describe('native API over a real daemon', () => { expect(woken.sandbox.sandboxId).toBe(created.sandbox.sandboxId); expect(woken.sandbox.state).toBe('active'); }); + + it('writes files in and reads them back byte-exact', async () => { + await client().acquireSandbox('files-key'); + const bytes = new Uint8Array(256); + for (let i = 0; i < 256; i++) bytes[i] = i; + + const written = await client().writeFiles('files-key', [ + { path: 'hello.txt', content: 'hello from e2e\n' }, + // A nested relative path: parents are created, /home/user is the base. + { path: 'nested/dir/blob.bin', content: bytes }, + ]); + expect(written.files).toEqual([ + { path: '/home/user/hello.txt' }, + { path: '/home/user/nested/dir/blob.bin' }, + ]); + + const text = await client().readFile('files-key', '/home/user/hello.txt'); + expect(new TextDecoder().decode(text.content)).toBe('hello from e2e\n'); + const blob = await client().readFile('files-key', 'nested/dir/blob.bin'); + expect(blob.content).toEqual(bytes); + }); + + it('surfaces the honest errors: missing file 404, directory 400', async () => { + await client().acquireSandbox('files-err-key'); + await expect( + client().readFile('files-err-key', 'absent.txt'), + ).rejects.toMatchObject({ + status: 404, + message: 'no such file: /home/user/absent.txt', + }); + await expect( + client().readFile('files-err-key', '/home/user'), + ).rejects.toMatchObject({ + status: 400, + message: 'not a regular file: /home/user', + }); + }); + + it('reading a file wakes a frozen sandbox, and the file survived the cold', async () => { + await client().acquireSandbox('files-wake-key', { + freezeAfterSeconds: 1, + stopAfterSeconds: null, + archiveAfterSeconds: null, + }); + await client().writeFiles('files-wake-key', [ + { path: 'keep.txt', content: 'still here' }, + ]); + // Watch it actually freeze from outside, on real wall-clock time. + const deadline = Date.now() + 15_000; + for (;;) { + const cold = (await client().listSandboxes()).find( + (s) => s.userKey === 'files-wake-key', + ); + if (cold?.state === 'frozen') break; + if (Date.now() > deadline) { + throw new Error( + `sandbox never reached frozen; last observed: ${cold?.state}`, + ); + } + await sleep(0.25); + } + + const read = await client().readFile('files-wake-key', 'keep.txt'); + expect(new TextDecoder().decode(read.content)).toBe('still here'); + const observed = (await client().listSandboxes()).find( + (s) => s.userKey === 'files-wake-key', + ); + expect(observed?.state).toBe('active'); + }); }); diff --git a/packages/sdk/src/client.test.ts b/packages/sdk/src/client.test.ts index 3cb60b9..994a502 100644 --- a/packages/sdk/src/client.test.ts +++ b/packages/sdk/src/client.test.ts @@ -212,6 +212,40 @@ describe('Dormice.acquireSandbox over real HTTP', () => { ); }); + it('writes files — string and bytes — and reads them back exact', async () => { + await client.acquireSandbox('files-key'); + const bytes = new Uint8Array(256); + for (let i = 0; i < 256; i++) bytes[i] = i; + + const written = await client.writeFiles('files-key', [ + // Multi-byte text: proves strings go through as UTF-8, not mangled. + { path: 'notes.txt', content: 'hello 文件\n' }, + { path: '/home/user/blob.bin', content: bytes }, + ]); + expect(written.files).toEqual([ + { path: '/home/user/notes.txt' }, + { path: '/home/user/blob.bin' }, + ]); + + const text = await client.readFile('files-key', 'notes.txt'); + expect(text.path).toBe('/home/user/notes.txt'); + expect(new TextDecoder().decode(text.content)).toBe('hello 文件\n'); + + const blob = await client.readFile('files-key', 'blob.bin'); + expect(blob.content).toEqual(bytes); + }); + + it('surfaces the 404 for a missing file', async () => { + await client.acquireSandbox('files-key'); + await expect( + client.readFile('files-key', 'no-such.txt'), + ).rejects.toMatchObject({ + name: 'DormiceApiError', + status: 404, + message: 'no such file: /home/user/no-such.txt', + }); + }); + it('releases a sandbox and reports idempotently', async () => { const created = await client.acquireSandbox('frank'); expect(await client.releaseSandbox('frank')).toEqual({ released: true }); diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index dff788b..4346046 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -7,8 +7,11 @@ import { type LifecyclePolicyOverride, listSandboxesResponseSchema, type ReleaseSandboxResponse, + readFileResponseSchema, releaseSandboxResponseSchema, type Sandbox, + type WriteFilesResponse, + writeFilesResponseSchema, } from '@dormice/shared'; import { Agent, fetch, type Response } from 'undici'; @@ -32,6 +35,20 @@ export interface DormiceOptions { timeoutMs?: number; } +export interface FileToWrite { + /** Absolute, or relative to /home/user. */ + path: string; + /** A string is written as its UTF-8 bytes; a Uint8Array is written as-is. */ + content: string | Uint8Array; +} + +export interface ReadFileResult { + /** The path as resolved inside the sandbox, always absolute. */ + path: string; + /** The file's exact bytes. Text? `new TextDecoder().decode(content)`. */ + content: Uint8Array; +} + export interface ExecCommandOptions { /** In-container deadline; on expiry the command is SIGKILLed (exit 137). */ timeoutSeconds?: number; @@ -126,6 +143,43 @@ export class Dormice { return execCommandResponseSchema.parse(data); } + /** + * Writes a batch of files into the sandbox behind a user key, waking a + * cold sandbox first. Parents are created, existing files overwritten. + * Per-file cap is 16 MiB — content travels as base64 inside JSON; a big + * batch on a slow link may need a larger client `timeoutMs`. + */ + async writeFiles( + userKey: string, + files: FileToWrite[], + ): Promise { + const data = await this.rpc('writeFiles', { + userKey, + files: files.map((file) => ({ + path: file.path, + contentBase64: Buffer.from( + typeof file.content === 'string' + ? new TextEncoder().encode(file.content) + : file.content, + ).toString('base64'), + })), + }); + return writeFilesResponseSchema.parse(data); + } + + /** + * Reads one file's bytes out of the sandbox. A file over the 16 MiB cap + * is refused by the daemon (413), never truncated. + */ + async readFile(userKey: string, path: string): Promise { + const data = await this.rpc('readFile', { userKey, path }); + const parsed = readFileResponseSchema.parse(data); + return { + path: parsed.path, + content: new Uint8Array(Buffer.from(parsed.contentBase64, 'base64')), + }; + } + /** Native API convention: every operation is POST /, body in, body out. */ private async rpc( method: string, diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 828851b..5f1cda1 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -7,4 +7,6 @@ export { DormiceApiError, type DormiceOptions, type ExecCommandOptions, + type FileToWrite, + type ReadFileResult, } from './client'; From c450f473422c407b6eaa7d9c724909d1032fc4cd Mon Sep 17 00:00:00 2001 From: Annactswell Date: Thu, 9 Jul 2026 16:06:49 +0800 Subject: [PATCH 004/133] CLI: dor sandbox push and pull move one file each way, pull prints raw bytes --- e2e/src/cli.test.ts | 24 +++++++++++++++ packages/cli/src/commands.test.ts | 29 ++++++++++++++++++ packages/cli/src/commands.ts | 42 +++++++++++++++++++++++++- packages/cli/src/main.ts | 49 +++++++++++++++++++++++++++++++ 4 files changed, 143 insertions(+), 1 deletion(-) diff --git a/e2e/src/cli.test.ts b/e2e/src/cli.test.ts index 09afcd3..3416576 100644 --- a/e2e/src/cli.test.ts +++ b/e2e/src/cli.test.ts @@ -1,5 +1,8 @@ import { execFile } from 'node:child_process'; import { existsSync } from 'node:fs'; +import { mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { promisify } from 'node:util'; import { Dormice } from '@dormice/sdk'; @@ -71,6 +74,27 @@ describe('dor CLI against a real daemon', () => { ).rejects.toMatchObject({ code: 3 }); }); + it('sandbox push and pull move a file in and back out through the real binary', async () => { + const sdk = new Dormice({ + endpoint: inject('dormiceEndpoint'), + token: inject('dormiceToken'), + }); + await sdk.acquireSandbox('cli-files-key'); + const local = path.join( + await mkdtemp(path.join(tmpdir(), 'dor-e2e-')), + 'in.txt', + ); + await writeFile(local, 'through the CLI\n'); + + const pushed = await cli('sandbox', 'push', 'cli-files-key', local); + // No remotePath given: the local file name lands under /home/user. + expect(pushed.stdout).toContain('Wrote /home/user/in.txt (16 bytes).'); + + // No localPath given: raw bytes to stdout, untouched. + const pulled = await cli('sandbox', 'pull', 'cli-files-key', 'in.txt'); + expect(pulled.stdout).toBe('through the CLI\n'); + }); + it('fails honestly when the environment is missing', async () => { await expect( run('node', [CLI, 'sandbox', 'ls'], { diff --git a/packages/cli/src/commands.test.ts b/packages/cli/src/commands.test.ts index 29bab27..e95794f 100644 --- a/packages/cli/src/commands.test.ts +++ b/packages/cli/src/commands.test.ts @@ -11,8 +11,11 @@ import { import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { clientFromEnv, + pullSavedMessage, sandboxExec, sandboxLs, + sandboxPull, + sandboxPush, sandboxRelease, } from './commands'; @@ -125,6 +128,32 @@ describe('sandbox commands over real HTTP', () => { await client.releaseSandbox('dave'); }); + it('push reports the resolved path and byte count', async () => { + await client.acquireSandbox('pusher'); + const message = await sandboxPush( + client, + 'pusher', + new TextEncoder().encode('hi'), + 'notes.txt', + ); + expect(message).toBe('Wrote /home/user/notes.txt (2 bytes).'); + await client.releaseSandbox('pusher'); + }); + + it('pull hands back the exact bytes; the save message is separate', async () => { + await client.acquireSandbox('puller'); + const bytes = new Uint8Array([0, 1, 254, 255]); + await client.writeFiles('puller', [{ path: 'data.bin', content: bytes }]); + + const result = await sandboxPull(client, 'puller', 'data.bin'); + expect(result.path).toBe('/home/user/data.bin'); + expect(result.content).toEqual(bytes); + expect(pullSavedMessage(result, 'local.bin')).toBe( + 'Pulled /home/user/data.bin -> local.bin (4 bytes).', + ); + await client.releaseSandbox('puller'); + }); + it('release reports both outcomes of the idempotent destroy', async () => { await client.acquireSandbox('carol'); expect(await sandboxRelease(client, 'carol')).toBe( diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index d1ba2c4..36d3210 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -1,4 +1,4 @@ -import { Dormice, type Sandbox } from '@dormice/sdk'; +import { Dormice, type ReadFileResult, type Sandbox } from '@dormice/sdk'; /** * Builds the API client from the environment. The daemon's address and @@ -97,6 +97,46 @@ export async function sandboxExec( return { stdout: result.stdout, stderr, exitCode: result.exitCode }; } +/** + * `dor sandbox push [remotePath]`: one local file into + * the sandbox. The bytes arrive here already read — main.ts owns the + * filesystem, this function owns the API call and the message. + */ +export async function sandboxPush( + client: Dormice, + userKey: string, + content: Uint8Array, + remotePath: string, +): Promise { + const { files } = await client.writeFiles(userKey, [ + { path: remotePath, content }, + ]); + const written = files[0]?.path ?? remotePath; + return `Wrote ${printable(written)} (${content.length} bytes).`; +} + +/** + * `dor sandbox pull [localPath]`: one file out of the + * sandbox. Returns the exact bytes; main.ts decides between a local file and + * raw stdout — raw on purpose, the same rule as exec output: these are the + * operator's own bytes, not a place to strip control characters. + */ +export async function sandboxPull( + client: Dormice, + userKey: string, + remotePath: string, +): Promise { + return client.readFile(userKey, remotePath); +} + +/** The message printed instead of raw bytes when pull saves to a local file. */ +export function pullSavedMessage( + result: ReadFileResult, + localPath: string, +): string { + return `Pulled ${printable(result.path)} -> ${localPath} (${result.content.length} bytes).`; +} + /** `dor sandbox release `: destroy the sandbox behind a key, idempotently. */ export async function sandboxRelease( client: Dormice, diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index c1f8ce5..be41fd0 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -3,11 +3,16 @@ // guess) and `dor` (the short name people actually type). It only wires // commander to the functions in commands.ts and prints their output — // everything testable lives there. +import { readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; import { Command } from 'commander'; import { clientFromEnv, + pullSavedMessage, sandboxExec, sandboxLs, + sandboxPull, + sandboxPush, sandboxRelease, } from './commands'; @@ -55,6 +60,50 @@ sandbox }, ); +sandbox + .command('push') + .description( + 'Copy a local file into the sandbox behind a key (wakes it first)', + ) + .argument('', 'the user key whose sandbox receives the file') + .argument('', 'local file to send') + .argument( + '[remotePath]', + 'destination inside the sandbox; relative paths land under /home/user (default: the local file name)', + ) + .action(async (userKey: string, localPath: string, remotePath?: string) => { + const content = await readFile(localPath); + console.log( + await sandboxPush( + clientFromEnv(process.env), + userKey, + content, + remotePath ?? path.basename(localPath), + ), + ); + }); + +sandbox + .command('pull') + .description('Copy a file out of the sandbox behind a key (wakes it first)') + .argument('', 'the user key whose sandbox holds the file') + .argument('', 'file inside the sandbox; relative to /home/user') + .argument('[localPath]', 'where to save it; omitted = raw bytes to stdout') + .action(async (userKey: string, remotePath: string, localPath?: string) => { + const result = await sandboxPull( + clientFromEnv(process.env), + userKey, + remotePath, + ); + if (localPath === undefined) { + // Raw on purpose, like exec output: the operator's own file's bytes. + process.stdout.write(result.content); + return; + } + await writeFile(localPath, result.content); + console.log(pullSavedMessage(result, localPath)); + }); + sandbox .command('release') .description('Destroy the sandbox behind a user key (idempotent)') From ab272b57ef771f283f1123d9dd18d50832a1cf4e Mon Sep 17 00:00:00 2001 From: Annactswell Date: Thu, 9 Jul 2026 18:52:54 +0800 Subject: [PATCH 005/133] Executors learn the E2B verbs: streaming exec, dir metadata family, uncapped file streams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit execStream delivers output chunk by chunk through callbacks and returns a handle (wait for the exit code) — deliberately handle-shaped so process management verbs can grow on it later. The buffered exec is now the same pipeline with capped sinks; the fake's pocket interpreter gained ';' sequencing so the contract can time real chunk gaps, and a timeout now keeps already-delivered chunks, matching the real executor. listDir/statEntry/makeDir/move/remove run in-container like every file verb (find -printf and stat --printf, private exit codes for the typed errors); readFileStream/writeFileStream are the uncapped paths — the disk quota is the only ceiling, backpressure travels through the hijacked stream into the container. The fake's disk model materializes directories (and ships lost+found, the mkfs artifact every real disk root carries). Contract grows 32 -> 44 questions; all pass on the fake, real-machine exam pending. --- packages/server/src/executor/contract.ts | 289 ++++++++++++++ packages/server/src/executor/docker.ts | 484 +++++++++++++++++++++-- packages/server/src/executor/executor.ts | 126 +++++- packages/server/src/executor/fake.ts | 452 +++++++++++++++++---- 4 files changed, 1245 insertions(+), 106 deletions(-) diff --git a/packages/server/src/executor/contract.ts b/packages/server/src/executor/contract.ts index 3c2f7e5..3d2385f 100644 --- a/packages/server/src/executor/contract.ts +++ b/packages/server/src/executor/contract.ts @@ -1,4 +1,5 @@ import { randomUUID } from 'node:crypto'; +import { Readable } from 'node:stream'; import { EXEC_OUTPUT_LIMIT_BYTES, FILE_SIZE_LIMIT_BYTES, @@ -9,6 +10,7 @@ import { type Executor, FileNotFoundError, FileTooLargeError, + NotADirectoryError, NotAFileError, } from './executor'; @@ -386,6 +388,68 @@ export function describeExecutorContract( timeoutMs, ); + it( + 'execStream delivers output live, chunk by chunk', + async () => { + const id = await fresh(); + const chunks: Array<{ text: string; at: number }> = []; + const handle = await executor.execStream(id, { + command: 'echo first; sleep 1; echo second', + timeoutSeconds: 30, + onStdout: (c) => { + chunks.push({ text: c.toString('utf8'), at: Date.now() }); + }, + onStderr: () => {}, + }); + const { exitCode } = await handle.wait(); + expect(exitCode).toBe(0); + expect(chunks.map((c) => c.text).join('')).toBe('first\nsecond\n'); + // The anti-buffering assertion: with the sleep between the echoes, + // live delivery shows a real gap between the first and last chunk. + // A buffered implementation delivers everything at once — gap zero. + const at = chunks.map((c) => c.at); + expect(Math.max(...at) - Math.min(...at)).toBeGreaterThanOrEqual(500); + }, + timeoutMs, + ); + + it( + 'execStream reports a nonzero exit through wait, not an error', + async () => { + const id = await fresh(); + const handle = await executor.execStream(id, { + command: 'exit 7', + timeoutSeconds: 30, + onStdout: () => {}, + onStderr: () => {}, + }); + await expect(handle.wait()).resolves.toEqual({ exitCode: 7 }); + }, + timeoutMs, + ); + + it( + 'execStream timeout kills in-container; chunks already delivered stay delivered', + async () => { + const id = await fresh(); + const seen: string[] = []; + const before = Date.now(); + const handle = await executor.execStream(id, { + command: 'echo early; sleep 5', + timeoutSeconds: 1, + onStdout: (c) => { + seen.push(c.toString('utf8')); + }, + onStderr: () => {}, + }); + const { exitCode } = await handle.wait(); + expect(Date.now() - before).toBeLessThan(4_000); + expect(exitCode).toBe(137); + expect(seen.join('')).toBe('early\n'); + }, + timeoutMs, + ); + it( 'writeFiles then readFile round-trips text', async () => { @@ -584,5 +648,230 @@ export function describeExecutorContract( }, timeoutMs, ); + + it( + 'statEntry reports a file with its real metadata', + async () => { + const id = await fresh(); + await executor.writeFiles(id, [ + { path: '/home/user/s.txt', content: Buffer.from('12345') }, + ]); + // Relative path resolves like every other file verb. + const entry = await executor.statEntry(id, 's.txt'); + expect(entry).toMatchObject({ + name: 's.txt', + path: '/home/user/s.txt', + type: 'file', + sizeBytes: 5, + mode: 0o644, + owner: 'user', + group: 'user', + }); + // Written moments ago; both executors report a live clock. + expect( + Math.abs(Date.parse(entry.modifiedTime) - Date.now()), + ).toBeLessThan(60_000); + }, + timeoutMs, + ); + + it( + 'statEntry reports directories and refuses missing paths', + async () => { + const id = await fresh(); + const home = await executor.statEntry(id, '/home/user'); + expect(home).toMatchObject({ + name: 'user', + path: '/home/user', + type: 'dir', + mode: 0o755, + owner: 'user', + }); + + const missing = '/home/user/nope'; + const error = await executor.statEntry(id, missing).catch((e) => e); + expect(error).toBeInstanceOf(FileNotFoundError); + expect(error.message).toBe(`no such file: ${missing}`); + }, + timeoutMs, + ); + + it( + 'listDir walks exactly as deep as asked, sorted by path', + async () => { + const id = await fresh(); + await executor.writeFiles(id, [ + { path: '/home/user/a.txt', content: Buffer.from('a') }, + { path: '/home/user/sub/b.txt', content: Buffer.from('b') }, + ]); + // Depth 1: the file, the created dir — and lost+found, the mkfs + // artifact every real disk root carries; the listing shows reality. + const one = await executor.listDir(id, '/home/user', 1); + expect(one.map((e) => [e.path, e.type])).toEqual([ + ['/home/user/a.txt', 'file'], + ['/home/user/lost+found', 'dir'], + ['/home/user/sub', 'dir'], + ]); + const two = await executor.listDir(id, '/home/user', 2); + expect(two.map((e) => e.path)).toEqual([ + '/home/user/a.txt', + '/home/user/lost+found', + '/home/user/sub', + '/home/user/sub/b.txt', + ]); + }, + timeoutMs, + ); + + it( + 'listDir refuses files and missing paths with the right errors', + async () => { + const id = await fresh(); + await executor.writeFiles(id, [ + { path: '/home/user/plain.txt', content: Buffer.from('x') }, + ]); + const onFile = await executor + .listDir(id, '/home/user/plain.txt', 1) + .catch((e) => e); + expect(onFile).toBeInstanceOf(NotADirectoryError); + expect(onFile.message).toBe('not a directory: /home/user/plain.txt'); + + const onMissing = await executor + .listDir(id, '/home/user/void', 1) + .catch((e) => e); + expect(onMissing).toBeInstanceOf(FileNotFoundError); + expect(onMissing.message).toBe('no such file: /home/user/void'); + }, + timeoutMs, + ); + + it( + 'makeDir creates with parents, and reports "already there" as false', + async () => { + const id = await fresh(); + expect(await executor.makeDir(id, '/home/user/mk/deep')).toBe(true); + expect( + await executor.statEntry(id, '/home/user/mk/deep'), + ).toMatchObject({ type: 'dir', owner: 'user' }); + // Again: already exists — false, not an error, whatever is there. + expect(await executor.makeDir(id, '/home/user/mk/deep')).toBe(false); + await executor.writeFiles(id, [ + { path: '/home/user/mk/file.txt', content: Buffer.from('f') }, + ]); + expect(await executor.makeDir(id, '/home/user/mk/file.txt')).toBe( + false, + ); + }, + timeoutMs, + ); + + it( + 'move renames a file and refuses a missing source', + async () => { + const id = await fresh(); + await executor.writeFiles(id, [ + { path: '/home/user/m1.txt', content: Buffer.from('payload') }, + ]); + const moved = await executor.move( + id, + '/home/user/m1.txt', + '/home/user/m2.txt', + ); + expect(moved).toMatchObject({ + path: '/home/user/m2.txt', + type: 'file', + sizeBytes: 7, + }); + const gone = await executor + .readFile(id, '/home/user/m1.txt') + .catch((e) => e); + expect(gone).toBeInstanceOf(FileNotFoundError); + expect( + (await executor.readFile(id, '/home/user/m2.txt')).toString(), + ).toBe('payload'); + + const missing = await executor + .move(id, '/home/user/void.txt', '/home/user/x.txt') + .catch((e) => e); + expect(missing).toBeInstanceOf(FileNotFoundError); + expect(missing.message).toBe('no such file: /home/user/void.txt'); + }, + timeoutMs, + ); + + it( + 'remove takes a file, takes a tree, refuses what is not there', + async () => { + const id = await fresh(); + await executor.writeFiles(id, [ + { path: '/home/user/r/a.txt', content: Buffer.from('a') }, + { path: '/home/user/r/sub/b.txt', content: Buffer.from('b') }, + { path: '/home/user/single.txt', content: Buffer.from('s') }, + ]); + await executor.remove(id, '/home/user/single.txt'); + await executor.remove(id, '/home/user/r'); + const statR = await executor + .statEntry(id, '/home/user/r') + .catch((e) => e); + expect(statR).toBeInstanceOf(FileNotFoundError); + + const again = await executor.remove(id, '/home/user/r').catch((e) => e); + expect(again).toBeInstanceOf(FileNotFoundError); + expect(again.message).toBe('no such file: /home/user/r'); + }, + timeoutMs, + ); + + it( + 'the streaming file path is the uncapped one: over-limit content round-trips byte-exact', + async () => { + const id = await fresh(); + // Past the buffered API's 16 MiB line — only the stream may carry it. + const size = FILE_SIZE_LIMIT_BYTES + 5; + const big = Buffer.alloc(size); + for (let i = 0; i < size; i += 4096) big[i] = i % 251; + const half = Math.floor(size / 2); + await executor.writeFileStream( + id, + '/home/user/big-stream.bin', + Readable.from([big.subarray(0, half), big.subarray(half)]), + ); + const chunks: Buffer[] = []; + await executor.readFileStream(id, '/home/user/big-stream.bin', (c) => { + chunks.push(Buffer.from(c)); + }); + expect(Buffer.concat(chunks).equals(big)).toBe(true); + // The buffered read still refuses it: two paths, two contracts. + await expect( + executor.readFile(id, '/home/user/big-stream.bin'), + ).rejects.toThrow(FileTooLargeError); + }, + timeoutMs * 4, + ); + + it( + 'streaming file verbs throw the same typed errors as the buffered ones', + async () => { + const id = await fresh(); + const missing = await executor + .readFileStream(id, '/home/user/void.bin', () => {}) + .catch((e) => e); + expect(missing).toBeInstanceOf(FileNotFoundError); + expect(missing.message).toBe('no such file: /home/user/void.bin'); + + const onDir = await executor + .readFileStream(id, '/home/user', () => {}) + .catch((e) => e); + expect(onDir).toBeInstanceOf(NotAFileError); + expect(onDir.message).toBe('not a regular file: /home/user'); + + const writeDir = await executor + .writeFileStream(id, '/home/user', Readable.from([Buffer.from('x')])) + .catch((e) => e); + expect(writeDir).toBeInstanceOf(NotAFileError); + expect(writeDir.message).toBe('not a regular file: /home/user'); + }, + timeoutMs, + ); }); } diff --git a/packages/server/src/executor/docker.ts b/packages/server/src/executor/docker.ts index a923782..de87865 100644 --- a/packages/server/src/executor/docker.ts +++ b/packages/server/src/executor/docker.ts @@ -18,13 +18,18 @@ import Docker from 'dockerode'; import { execa } from 'execa'; import { type ContainerState, + DiskFullError, type ExecOptions, type ExecResult, + type ExecStreamHandle, + type ExecStreamOptions, type Executor, FileNotFoundError, FileTooLargeError, type FileToWrite, + NotADirectoryError, NotAFileError, + type SandboxEntry, } from './executor'; /** @@ -92,6 +97,100 @@ const READ_FILE_SCRIPT = [ 'exec cat -- "$1"', ].join('\n'); +const NOT_A_DIR_EXIT = 47; +const ALREADY_EXISTS_EXIT = 48; + +/** + * Streaming file transfers have no size cap, so their in-container deadline + * must fit a quota-sized file crawling to a slow client — generous, but + * still a bound so a wedged transfer cannot hold an exec forever. + */ +const STREAM_FILE_OP_TIMEOUT_SECONDS = 3600; + +/** Ceiling for one directory listing; past it the listing errors instead of silently losing entries. */ +const LIST_OUTPUT_LIMIT_BYTES = 8 * 1024 * 1024; + +/** $1 = absolute path. READ_FILE_SCRIPT without the size gate — the streaming read is the uncapped path. */ +const READ_FILE_STREAM_SCRIPT = [ + `[ -e "$1" ] || exit ${NO_SUCH_FILE_EXIT}`, + `[ -f "$1" ] || exit ${NOT_A_FILE_EXIT}`, + 'exec cat -- "$1"', +].join('\n'); + +/** + * $1 = absolute dir, $2 = depth. One NUL-terminated record per entry, tab + * separated with the path last, so a path containing tabs still parses + * (nothing else can contain a tab, and a path cannot contain a NUL). + */ +const LIST_DIR_SCRIPT = [ + `[ -e "$1" ] || exit ${NO_SUCH_FILE_EXIT}`, + `[ -d "$1" ] || exit ${NOT_A_DIR_EXIT}`, + `exec find "$1" -mindepth 1 -maxdepth "$2" -printf '%y\\t%s\\t%T@\\t%m\\t%u\\t%g\\t%p\\0'`, +].join('\n'); + +/** $1 = absolute path. --printf, not -c: only --printf interprets \t. */ +const STAT_SCRIPT = [ + `[ -e "$1" ] || exit ${NO_SUCH_FILE_EXIT}`, + `exec stat --printf '%F\\t%s\\t%Y\\t%a\\t%U\\t%G' -- "$1"`, +].join('\n'); + +/** $1 = absolute path. Exists (whatever it is) -> "already there", else mkdir -p. */ +const MAKE_DIR_SCRIPT = [ + `[ ! -e "$1" ] || exit ${ALREADY_EXISTS_EXIT}`, + 'exec mkdir -p -- "$1"', +].join('\n'); + +/** $1 = source, $2 = destination. -T = rename(2) semantics: never "move into". */ +const MOVE_SCRIPT = [ + `[ -e "$1" ] || exit ${NO_SUCH_FILE_EXIT}`, + 'exec mv -T -- "$1" "$2"', +].join('\n'); + +const REMOVE_SCRIPT = [ + `[ -e "$1" ] || exit ${NO_SUCH_FILE_EXIT}`, + 'exec rm -rf -- "$1"', +].join('\n'); + +/** One `find -printf` record (see LIST_DIR_SCRIPT) -> entry. */ +function entryFromFindRecord(record: string): SandboxEntry { + const fields = record.split('\t'); + const [kind = '', size = '', mtime = '', mode = '', owner = '', group = ''] = + fields; + // The path is everything after the sixth tab — its own tabs survive. + const path = fields.slice(6).join('\t'); + return { + name: path.slice(path.lastIndexOf('/') + 1) || '/', + path, + type: kind === 'f' ? 'file' : kind === 'd' ? 'dir' : 'other', + sizeBytes: Number(size), + modifiedTime: new Date(Number(mtime) * 1000).toISOString(), + mode: Number.parseInt(mode, 8), + owner, + group, + }; +} + +/** One `stat --printf` line (see STAT_SCRIPT) -> entry. */ +function entryFromStatLine(resolved: string, line: string): SandboxEntry { + const [kind = '', size = '', mtime = '', mode = '', owner = '', group = ''] = + line.split('\t'); + return { + name: resolved.slice(resolved.lastIndexOf('/') + 1) || '/', + path: resolved, + // %F says "regular file" or "regular empty file" — both are files. + type: kind.startsWith('regular') + ? 'file' + : kind === 'directory' + ? 'dir' + : 'other', + sizeBytes: Number(size), + modifiedTime: new Date(Number(mtime) * 1000).toISOString(), + mode: Number.parseInt(mode, 8), + owner, + group, + }; +} + /** * Docker reports seven statuses; the executor's contract knows three. With * RestartPolicy "no" a container never restarts on its own, so everything @@ -150,6 +249,33 @@ class CappedBuffer extends Writable { } } +/** + * A Writable that hands each chunk to a callback — the streaming sink for + * exec output and file downloads. When the callback returns a promise it is + * awaited before the next chunk is accepted: that is how a slow consumer's + * backpressure travels through demux all the way to the in-container writer. + */ +class CallbackSink extends Writable { + constructor( + private readonly onChunk: (chunk: Buffer) => void | Promise, + ) { + super(); + } + + override _write( + chunk: Buffer, + _encoding: BufferEncoding, + callback: (error?: Error | null) => void, + ): void { + Promise.resolve() + .then(() => this.onChunk(chunk)) + .then( + () => callback(), + (err) => callback(err instanceof Error ? err : new Error(String(err))), + ); + } +} + interface DockerApiError { statusCode: number; } @@ -342,6 +468,29 @@ export class DockerExecutor implements Executor { }; } + async execStream( + sandboxId: string, + opts: ExecStreamOptions, + ): Promise { + const containerId = await this.expectState(sandboxId, 'running'); + const container = this.docker.getContainer(containerId); + const shell = opts.loginShell ? ['bash', '-l', '-c'] : ['bash', '-c']; + const wait = await this.startInContainer(container, sandboxId, { + cmd: [ + 'timeout', + '--signal=KILL', + String(opts.timeoutSeconds), + ...shell, + opts.command, + ], + stdout: new CallbackSink(opts.onStdout), + stderr: new CallbackSink(opts.onStderr), + workingDir: opts.cwd, + env: opts.env, + }); + return { wait: async () => ({ exitCode: await wait() }) }; + } + async writeFiles(sandboxId: string, files: FileToWrite[]): Promise { const containerId = await this.expectState(sandboxId, 'running'); const container = this.docker.getContainer(containerId); @@ -419,25 +568,291 @@ export class DockerExecutor implements Executor { return run.stdout.bytes(); } - /** - * The one exec pipeline: start, demux, optionally feed stdin (ending the - * stream is what delivers EOF to the in-container reader), wait for the - * stream, then poll for the exit code — the engine records it a beat - * after the stream ends, the same measured lag as kill vs exited in - * stop(). Tty stays off: the multiplexed stream is what demuxStream can - * split back into distinct stdout and stderr. - */ + async readFileStream( + sandboxId: string, + path: string, + onChunk: (chunk: Buffer) => void | Promise, + ): Promise { + const resolved = resolveSandboxPath(path); + const containerId = await this.expectState(sandboxId, 'running'); + const container = this.docker.getContainer(containerId); + const stderr = new CappedBuffer(EXEC_OUTPUT_LIMIT_BYTES); + const wait = await this.startInContainer(container, sandboxId, { + cmd: [ + 'timeout', + '--signal=KILL', + String(STREAM_FILE_OP_TIMEOUT_SECONDS), + 'bash', + '-c', + READ_FILE_STREAM_SCRIPT, + 'bash', + resolved, + ], + stdout: new CallbackSink(onChunk), + stderr, + }); + const exitCode = await wait(); + if (exitCode === NO_SUCH_FILE_EXIT) { + throw new FileNotFoundError(`no such file: ${resolved}`); + } + if (exitCode === NOT_A_FILE_EXIT) { + throw new NotAFileError(`not a regular file: ${resolved}`); + } + if (exitCode !== 0) { + throw new Error( + `reading ${resolved} in ${sandboxId} failed (exit ${exitCode}): ${stderr.text().trim()}`, + ); + } + } + + async writeFileStream( + sandboxId: string, + path: string, + content: NodeJS.ReadableStream, + ): Promise { + const resolved = resolveSandboxPath(path); + const containerId = await this.expectState(sandboxId, 'running'); + const container = this.docker.getContainer(containerId); + const run = await this.runInContainer(container, sandboxId, { + cmd: [ + 'timeout', + '--signal=KILL', + String(STREAM_FILE_OP_TIMEOUT_SECONDS), + 'bash', + '-c', + WRITE_FILE_SCRIPT, + 'bash', + resolved, + ], + outputCap: EXEC_OUTPUT_LIMIT_BYTES, + stdin: content, + }); + if (run.exitCode === NOT_A_FILE_EXIT) { + throw new NotAFileError(`not a regular file: ${resolved}`); + } + if (run.exitCode !== 0) { + const message = run.stderr.text().trim(); + if (/no space left/i.test(message)) { + throw new DiskFullError(`no space left on device: ${resolved}`); + } + throw new Error( + `writing ${resolved} in ${sandboxId} failed (exit ${run.exitCode}): ${message}`, + ); + } + } + + async listDir( + sandboxId: string, + path: string, + depth: number, + ): Promise { + const resolved = resolveSandboxPath(path); + const containerId = await this.expectState(sandboxId, 'running'); + const container = this.docker.getContainer(containerId); + const run = await this.runInContainer(container, sandboxId, { + cmd: [ + 'timeout', + '--signal=KILL', + String(FILE_OP_TIMEOUT_SECONDS), + 'bash', + '-c', + LIST_DIR_SCRIPT, + 'bash', + resolved, + String(depth), + ], + outputCap: LIST_OUTPUT_LIMIT_BYTES, + }); + if (run.exitCode === NO_SUCH_FILE_EXIT) { + throw new FileNotFoundError(`no such file: ${resolved}`); + } + if (run.exitCode === NOT_A_DIR_EXIT) { + throw new NotADirectoryError(`not a directory: ${resolved}`); + } + if (run.exitCode !== 0) { + throw new Error( + `listing ${resolved} in ${sandboxId} failed (exit ${run.exitCode}): ${run.stderr.text().trim()}`, + ); + } + if (run.stdout.truncated) { + // Losing entries silently would make the listing a lie. + throw new Error( + `listing ${resolved} in ${sandboxId} exceeded ${LIST_OUTPUT_LIMIT_BYTES} bytes — use a smaller depth`, + ); + } + return run.stdout + .bytes() + .toString('utf8') + .split('\0') + .filter((record) => record.length > 0) + .map(entryFromFindRecord) + .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)); + } + + async statEntry(sandboxId: string, path: string): Promise { + const resolved = resolveSandboxPath(path); + const containerId = await this.expectState(sandboxId, 'running'); + const container = this.docker.getContainer(containerId); + const run = await this.runInContainer(container, sandboxId, { + cmd: [ + 'timeout', + '--signal=KILL', + String(FILE_OP_TIMEOUT_SECONDS), + 'bash', + '-c', + STAT_SCRIPT, + 'bash', + resolved, + ], + outputCap: EXEC_OUTPUT_LIMIT_BYTES, + }); + if (run.exitCode === NO_SUCH_FILE_EXIT) { + throw new FileNotFoundError(`no such file: ${resolved}`); + } + if (run.exitCode !== 0) { + throw new Error( + `stat ${resolved} in ${sandboxId} failed (exit ${run.exitCode}): ${run.stderr.text().trim()}`, + ); + } + return entryFromStatLine(resolved, run.stdout.text()); + } + + async makeDir(sandboxId: string, path: string): Promise { + const resolved = resolveSandboxPath(path); + const containerId = await this.expectState(sandboxId, 'running'); + const container = this.docker.getContainer(containerId); + const run = await this.runInContainer(container, sandboxId, { + cmd: [ + 'timeout', + '--signal=KILL', + String(FILE_OP_TIMEOUT_SECONDS), + 'bash', + '-c', + MAKE_DIR_SCRIPT, + 'bash', + resolved, + ], + outputCap: EXEC_OUTPUT_LIMIT_BYTES, + }); + if (run.exitCode === ALREADY_EXISTS_EXIT) { + return false; + } + if (run.exitCode !== 0) { + throw new Error( + `mkdir ${resolved} in ${sandboxId} failed (exit ${run.exitCode}): ${run.stderr.text().trim()}`, + ); + } + return true; + } + + async move( + sandboxId: string, + from: string, + to: string, + ): Promise { + const source = resolveSandboxPath(from); + const destination = resolveSandboxPath(to); + const containerId = await this.expectState(sandboxId, 'running'); + const container = this.docker.getContainer(containerId); + const run = await this.runInContainer(container, sandboxId, { + cmd: [ + 'timeout', + '--signal=KILL', + String(FILE_OP_TIMEOUT_SECONDS), + 'bash', + '-c', + MOVE_SCRIPT, + 'bash', + source, + destination, + ], + outputCap: EXEC_OUTPUT_LIMIT_BYTES, + }); + if (run.exitCode === NO_SUCH_FILE_EXIT) { + throw new FileNotFoundError(`no such file: ${source}`); + } + if (run.exitCode !== 0) { + throw new Error( + `moving ${source} to ${destination} in ${sandboxId} failed (exit ${run.exitCode}): ${run.stderr.text().trim()}`, + ); + } + return this.statEntry(sandboxId, destination); + } + + async remove(sandboxId: string, path: string): Promise { + const resolved = resolveSandboxPath(path); + const containerId = await this.expectState(sandboxId, 'running'); + const container = this.docker.getContainer(containerId); + const run = await this.runInContainer(container, sandboxId, { + cmd: [ + 'timeout', + '--signal=KILL', + String(FILE_OP_TIMEOUT_SECONDS), + 'bash', + '-c', + REMOVE_SCRIPT, + 'bash', + resolved, + ], + outputCap: EXEC_OUTPUT_LIMIT_BYTES, + }); + if (run.exitCode === NO_SUCH_FILE_EXIT) { + throw new FileNotFoundError(`no such file: ${resolved}`); + } + if (run.exitCode !== 0) { + throw new Error( + `removing ${resolved} in ${sandboxId} failed (exit ${run.exitCode}): ${run.stderr.text().trim()}`, + ); + } + } + + /** The buffered face of the exec pipeline: capped sinks, awaited to the end. */ private async runInContainer( container: Docker.Container, sandboxId: string, spec: { cmd: string[]; outputCap: number; - stdin?: Buffer; + stdin?: Buffer | NodeJS.ReadableStream; workingDir?: string; env?: Record; }, ): Promise<{ exitCode: number; stdout: CappedBuffer; stderr: CappedBuffer }> { + const stdout = new CappedBuffer(spec.outputCap); + const stderr = new CappedBuffer(spec.outputCap); + const wait = await this.startInContainer(container, sandboxId, { + cmd: spec.cmd, + stdout, + stderr, + stdin: spec.stdin, + workingDir: spec.workingDir, + env: spec.env, + }); + return { exitCode: await wait(), stdout, stderr }; + } + + /** + * The one exec pipeline: start, demux into the caller's sinks, optionally + * feed stdin (ending the stream is what delivers EOF to the in-container + * reader), then — inside the returned wait — wait for the stream and poll + * for the exit code: the engine records it a beat after the stream ends, + * the same measured lag as kill vs exited in stop(). Tty stays off: the + * multiplexed stream is what demuxStream can split back into distinct + * stdout and stderr. Resolving means the command has started; everything + * after start is the wait's business. + */ + private async startInContainer( + container: Docker.Container, + sandboxId: string, + spec: { + cmd: string[]; + stdout: Writable; + stderr: Writable; + stdin?: Buffer | NodeJS.ReadableStream; + workingDir?: string; + env?: Record; + }, + ): Promise<() => Promise> { const exec = await container.exec({ Cmd: spec.cmd, AttachStdin: spec.stdin !== undefined, @@ -452,28 +867,41 @@ export class DockerExecutor implements Executor { const stream = await exec.start( spec.stdin !== undefined ? { hijack: true, stdin: true } : {}, ); - const stdout = new CappedBuffer(spec.outputCap); - const stderr = new CappedBuffer(spec.outputCap); - this.docker.modem.demuxStream(stream, stdout, stderr); + this.docker.modem.demuxStream(stream, spec.stdout, spec.stderr); if (spec.stdin !== undefined) { - stream.end(spec.stdin); - } - await new Promise((resolve, reject) => { - stream.on('end', resolve); - stream.on('close', resolve); - stream.on('error', reject); - }); - let info = await exec.inspect(); - for (let i = 0; info.Running || info.ExitCode === null; i++) { - if (i >= 20) { - throw new Error( - `exec on ${sandboxId} ended but no exit code was recorded`, - ); + if (Buffer.isBuffer(spec.stdin)) { + stream.end(spec.stdin); + } else { + // pipe() forwards the source's end as the exec's stdin EOF. A source + // error would otherwise leave the in-container reader waiting for + // the timeout wrapper to kill it — destroying the stream surfaces + // the failure now instead. + spec.stdin.on('error', (err) => stream.destroy(err)); + spec.stdin.pipe(stream); } - await new Promise((resolve) => setTimeout(resolve, 50)); - info = await exec.inspect(); } - return { exitCode: info.ExitCode, stdout, stderr }; + const finished = (async () => { + await new Promise((resolve, reject) => { + stream.on('end', resolve); + stream.on('close', resolve); + stream.on('error', reject); + }); + let info = await exec.inspect(); + for (let i = 0; info.Running || info.ExitCode === null; i++) { + if (i >= 20) { + throw new Error( + `exec on ${sandboxId} ended but no exit code was recorded`, + ); + } + await new Promise((resolve) => setTimeout(resolve, 50)); + info = await exec.inspect(); + } + return info.ExitCode; + })(); + // A failure before anyone calls wait must not crash the daemon as an + // unhandled rejection; wait() still observes it through the same promise. + finished.catch(() => {}); + return () => finished; } private imagePath(sandboxId: string): string { diff --git a/packages/server/src/executor/executor.ts b/packages/server/src/executor/executor.ts index b85dd98..7130a33 100644 --- a/packages/server/src/executor/executor.ts +++ b/packages/server/src/executor/executor.ts @@ -28,20 +28,79 @@ export interface ExecResult { stderrTruncated: boolean; } +export interface ExecStreamOptions { + /** A shell string, executed as `bash -c ` inside the sandbox. */ + command: string; + /** Same in-container deadline as ExecOptions; on expiry exit 137. */ + timeoutSeconds: number; + cwd?: string; + env?: Record; + /** + * Run under a login shell (`bash -l -c`), which loads the image's profile + * — the E2B surface's habit. The native API keeps plain `bash -c`; each + * protocol stays true to its own contract. + */ + loginShell?: boolean; + /** + * Called with each output chunk as it arrives. No cap: nothing accumulates + * server-side. A returned promise MAY be awaited before the next chunk + * (the real executor does — backpressure travels to the container; the + * fake's in-memory source has nothing to press back on). + */ + onStdout: (chunk: Buffer) => void | Promise; + onStderr: (chunk: Buffer) => void | Promise; +} + +/** + * A started command. Obtaining the handle means the exec is running; wait() + * is its result. Deliberately handle-shaped (not a plain promise): process + * management verbs — kill, stdin, reconnect — will grow here later without + * reshaping every caller. + */ +export interface ExecStreamHandle { + wait(): Promise<{ exitCode: number }>; +} + export interface FileToWrite { /** Absolute, or relative to /home/user — resolveSandboxPath's rules. */ path: string; content: Buffer; } +/** What the filesystem says about one path — the shape stat and listDir speak. */ +export interface SandboxEntry { + /** Basename; '/' for the root itself. */ + name: string; + /** Absolute resolved path. */ + path: string; + /** 'other' covers symlinks, FIFOs and friends — observed, not modeled. */ + type: 'file' | 'dir' | 'other'; + /** Bytes for files; whatever the filesystem reports for directories. */ + sizeBytes: number; + /** ISO 8601 UTC. */ + modifiedTime: string; + /** Permission bits, e.g. 0o644. */ + mode: number; + owner: string; + group: string; +} + /** * The file-op error taxonomy, shared by both executors down to the message * (the contract exam holds them to it) and mapped to HTTP statuses by the - * route: not found -> 404, not a regular file -> 400, too large -> 413. + * routes: not found -> 404, not a regular file / not a directory -> 400, + * too large -> 413, disk full -> 507. */ export class FileNotFoundError extends Error {} export class NotAFileError extends Error {} +export class NotADirectoryError extends Error {} export class FileTooLargeError extends Error {} +/** + * The sandbox disk ran out of room mid-write. Only the real executor can + * produce it (the fake's memory disk has no edge), so unlike the rest of the + * taxonomy its message is not contract-pinned. + */ +export class DiskFullError extends Error {} /** * The executor is where lifecycle decisions become physical reality: @@ -101,6 +160,17 @@ export interface Executor { * drained and dropped, and the truncation reported. */ exec(sandboxId: string, opts: ExecOptions): Promise; + /** + * Runs a shell command, delivering output live through the callbacks + * instead of buffering — chunk boundaries follow the command's own writes. + * The returned promise resolves once the command has started (a start + * failure rejects here); handle.wait() resolves with the honest exit code. + * Same running-state requirement and in-container deadline as exec. + */ + execStream( + sandboxId: string, + opts: ExecStreamOptions, + ): Promise; /** * Writes every file in the batch, in order, failing fast — earlier files * stay written (a batch saves round-trips, it is not a transaction). @@ -119,4 +189,58 @@ export interface Executor { * and not in the schema because only the executor can observe the size. */ readFile(sandboxId: string, path: string): Promise; + /** + * Streams one file's bytes through the callback, uncapped — nothing + * accumulates server-side, so the disk quota is the only ceiling (the E2B + * surface's contract; the native API's 16 MiB cap is the base64-JSON + * shape's own rule and does not reach here). A returned promise from the + * callback is awaited before the next chunk: backpressure travels through + * to the in-container reader. Errors as readFile, minus the size gate. + */ + readFileStream( + sandboxId: string, + path: string, + onChunk: (chunk: Buffer) => void | Promise, + ): Promise; + /** + * Streams content into one file, uncapped, parents created, overwriting — + * writeFiles' semantics without materializing the bytes. A full disk + * throws DiskFullError. + */ + writeFileStream( + sandboxId: string, + path: string, + content: NodeJS.ReadableStream, + ): Promise; + /** + * Entries under a directory, depth ≥ 1 levels down, sorted by path. + * Throws FileNotFoundError for a missing path, NotADirectoryError for a + * file. Requires state `running`, like every file verb. + */ + listDir( + sandboxId: string, + path: string, + depth: number, + ): Promise; + /** One path's entry. FileNotFoundError when nothing is there. */ + statEntry(sandboxId: string, path: string): Promise; + /** + * mkdir -p. True when created; false when the path already exists — + * whatever it is: claiming "created" over an existing file would be a lie, + * and the caller's next stat tells the truth either way. + */ + makeDir(sandboxId: string, path: string): Promise; + /** + * rename(2) semantics (`mv -T`): an existing destination file is + * replaced, a destination directory is not merged into. The source must + * exist (FileNotFoundError); parents of the destination are not created. + * Returns the destination's entry. + */ + move(sandboxId: string, from: string, to: string): Promise; + /** + * rm -rf: file or directory tree. FileNotFoundError when nothing is there + * — removing nothing is a caller's confusion worth reporting, not a goal + * state (unlike removeDisk, whose caller is reconciliation). + */ + remove(sandboxId: string, path: string): Promise; } diff --git a/packages/server/src/executor/fake.ts b/packages/server/src/executor/fake.ts index 4f54d93..a93df0e 100644 --- a/packages/server/src/executor/fake.ts +++ b/packages/server/src/executor/fake.ts @@ -7,19 +7,73 @@ import { type ContainerState, type ExecOptions, type ExecResult, + type ExecStreamHandle, + type ExecStreamOptions, type Executor, FileNotFoundError, FileTooLargeError, type FileToWrite, + NotADirectoryError, NotAFileError, + type SandboxEntry, } from './executor'; /** - * Directories the base image guarantees in every sandbox. The fake's - * filesystem is otherwise implicit — a directory exists exactly when some - * file lives under it, which is all mkdir -p semantics leave observable. + * Directories every real sandbox is born with, with the owner and mode + * reality gives them: the image's skeleton plus lost+found — the mkfs.ext4 + * artifact every disk carries in its root (/home/user), which a directory + * listing must therefore show on the fake too. */ -const BUILTIN_DIRS = new Set(['/', '/home', '/home/user', '/tmp']); +const BUILTIN_DIRS: Record = { + '/': { owner: 'root', mode: 0o755 }, + '/home': { owner: 'root', mode: 0o755 }, + '/home/user': { owner: 'user', mode: 0o755 }, + '/home/user/lost+found': { owner: 'root', mode: 0o700 }, + '/tmp': { owner: 'root', mode: 0o1777 }, +}; + +/** + * The fake disk's content: one node per path, directories materialized — + * mkdir -p on a real disk leaves real directories behind, so the model + * does too (an earlier implicit-directory model could not answer makeDir + * on an empty directory or list one). + */ +type FakeNode = + | { type: 'file'; content: Buffer; modifiedTime: string } + | { type: 'dir'; modifiedTime: string }; + +function seededDisk(): Map { + const now = new Date().toISOString(); + return new Map( + Object.keys(BUILTIN_DIRS).map((path) => [ + path, + { type: 'dir' as const, modifiedTime: now }, + ]), + ); +} + +/** + * Node -> entry, with the metadata reality would report: files land as + * uid 1000 with the default umask (644), created directories as 755, and + * the built-in skeleton keeps the image's own owners and modes. + */ +function entryFor(path: string, node: FakeNode): SandboxEntry { + const meta = + node.type === 'dir' + ? (BUILTIN_DIRS[path] ?? { owner: 'user', mode: 0o755 }) + : { owner: 'user', mode: 0o644 }; + return { + name: path.slice(path.lastIndexOf('/') + 1) || '/', + path, + type: node.type, + // 4096 = one ext4 block, what a real directory stats as. + sizeBytes: node.type === 'file' ? node.content.length : 4096, + modifiedTime: node.modifiedTime, + mode: meta.mode, + owner: meta.owner, + group: meta.owner, + }; +} /** * In-memory stand-in for the Docker+gVisor executor. Not a throwaway: it is @@ -39,7 +93,7 @@ export class FakeExecutor implements Executor { * the disk does. That is the "the disk is the sandbox's body" invariant, * modeled where it physically lives. */ - private readonly files = new Map>(); + private readonly fs = new Map>(); /** Test hook: what does "reality" say about this sandbox? */ stateOf(sandboxId: string): ContainerState | undefined { @@ -70,7 +124,7 @@ export class FakeExecutor implements Executor { throw new Error(`container ${sandboxId} already exists`); } this.disks.add(sandboxId); - this.files.set(sandboxId, new Map()); + this.fs.set(sandboxId, seededDisk()); this.containers.set(sandboxId, 'running'); } @@ -113,7 +167,7 @@ export class FakeExecutor implements Executor { // hearing. const hadContainer = this.containers.delete(sandboxId); const hadDisk = this.disks.delete(sandboxId); - this.files.delete(sandboxId); + this.fs.delete(sandboxId); if (!hadContainer && !hadDisk) { throw new Error(`container ${sandboxId} is absent, cannot destroy`); } @@ -131,71 +185,276 @@ export class FakeExecutor implements Executor { async removeDisk(sandboxId: string): Promise { // Idempotent by contract: an absent disk already is the goal state. this.disks.delete(sandboxId); - this.files.delete(sandboxId); + this.fs.delete(sandboxId); } async exec(sandboxId: string, opts: ExecOptions): Promise { - this.expect(sandboxId, 'running'); - // The timeout races the command, same outcome as the real executor's - // in-container `timeout --signal=KILL`: exit 137, partial output dropped. - let timer: NodeJS.Timeout | undefined; - const deadline = new Promise((resolve) => { - timer = setTimeout( - () => resolve(fakeResult(137)), - opts.timeoutSeconds * 1000, - ); + const stdout = new CappedText(EXEC_OUTPUT_LIMIT_BYTES); + const stderr = new CappedText(EXEC_OUTPUT_LIMIT_BYTES); + const handle = await this.execStream(sandboxId, { + command: opts.command, + timeoutSeconds: opts.timeoutSeconds, + cwd: opts.cwd, + env: opts.env, + onStdout: (chunk) => stdout.push(chunk), + onStderr: (chunk) => stderr.push(chunk), }); - try { - return await Promise.race([interpret(opts), deadline]); - } finally { - clearTimeout(timer); - } + const { exitCode } = await handle.wait(); + return { + exitCode, + stdout: stdout.text, + stderr: stderr.text, + stdoutTruncated: stdout.truncated, + stderrTruncated: stderr.truncated, + }; + } + + async execStream( + sandboxId: string, + opts: ExecStreamOptions, + ): Promise { + this.expect(sandboxId, 'running'); + // The timeout races the interpreter, same outcome as the real executor's + // in-container `timeout --signal=KILL`: exit 137. Chunks delivered before + // the kill stay delivered — an emitted chunk cannot be unsent — and the + // flag silences anything the losing interpreter emits afterwards. + let timedOut = false; + const emit = { + stdout: (text: string) => { + if (!timedOut) opts.onStdout(Buffer.from(text, 'utf8')); + }, + stderr: (text: string) => { + if (!timedOut) opts.onStderr(Buffer.from(text, 'utf8')); + }, + }; + const done = (async () => { + let timer: NodeJS.Timeout | undefined; + const deadline = new Promise((resolve) => { + timer = setTimeout(() => { + timedOut = true; + resolve(137); + }, opts.timeoutSeconds * 1000); + }); + try { + return { + exitCode: await Promise.race([interpret(opts, emit), deadline]), + }; + } finally { + clearTimeout(timer); + } + })(); + // A failure before anyone calls wait must not become an unhandled + // rejection; wait() still observes it through the same promise. + done.catch(() => {}); + return { wait: () => done }; } async writeFiles(sandboxId: string, files: FileToWrite[]): Promise { this.expect(sandboxId, 'running'); - const disk = this.files.get(sandboxId) ?? new Map(); - this.files.set(sandboxId, disk); + const disk = this.disk(sandboxId); for (const file of files) { - const path = resolveSandboxPath(file.path); - if (this.isDirectory(disk, path)) { - throw new NotAFileError(`not a regular file: ${path}`); - } // Copy on the way in: the caller's buffer is theirs to reuse. - disk.set(path, Buffer.from(file.content)); + this.writeNode( + sandboxId, + disk, + resolveSandboxPath(file.path), + Buffer.from(file.content), + ); } } async readFile(sandboxId: string, path: string): Promise { + const node = this.fileNode(sandboxId, resolveSandboxPath(path)); + if (node.content.length > FILE_SIZE_LIMIT_BYTES) { + throw new FileTooLargeError( + `file too large: ${resolveSandboxPath(path)} is ${node.content.length} bytes, limit ${FILE_SIZE_LIMIT_BYTES}`, + ); + } + return Buffer.from(node.content); + } + + async readFileStream( + sandboxId: string, + path: string, + onChunk: (chunk: Buffer) => void | Promise, + ): Promise { + // The uncapped path: no size gate, one chunk (the fake has no reason to + // slice; chunking is the real pipe's business, not the contract's). + const node = this.fileNode(sandboxId, resolveSandboxPath(path)); + await onChunk(Buffer.from(node.content)); + } + + async writeFileStream( + sandboxId: string, + path: string, + content: NodeJS.ReadableStream, + ): Promise { this.expect(sandboxId, 'running'); - const disk = this.files.get(sandboxId) ?? new Map(); + const disk = this.disk(sandboxId); const resolved = resolveSandboxPath(path); - const content = disk.get(resolved); - if (content !== undefined) { - if (content.length > FILE_SIZE_LIMIT_BYTES) { - throw new FileTooLargeError( - `file too large: ${resolved} is ${content.length} bytes, limit ${FILE_SIZE_LIMIT_BYTES}`, - ); - } - return Buffer.from(content); + const chunks: Buffer[] = []; + for await (const chunk of content) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + this.writeNode(sandboxId, disk, resolved, Buffer.concat(chunks)); + } + + async listDir( + sandboxId: string, + path: string, + depth: number, + ): Promise { + this.expect(sandboxId, 'running'); + const disk = this.disk(sandboxId); + const base = resolveSandboxPath(path); + const node = disk.get(base); + if (!node) throw new FileNotFoundError(`no such file: ${base}`); + if (node.type !== 'dir') { + throw new NotADirectoryError(`not a directory: ${base}`); + } + const prefix = base === '/' ? '/' : `${base}/`; + const entries: SandboxEntry[] = []; + for (const [p, n] of disk) { + if (p === base || !p.startsWith(prefix)) continue; + if (p.slice(prefix.length).split('/').length > depth) continue; + entries.push(entryFor(p, n)); + } + return entries.sort((a, b) => + a.path < b.path ? -1 : a.path > b.path ? 1 : 0, + ); + } + + async statEntry(sandboxId: string, path: string): Promise { + this.expect(sandboxId, 'running'); + const resolved = resolveSandboxPath(path); + const node = this.disk(sandboxId).get(resolved); + if (!node) throw new FileNotFoundError(`no such file: ${resolved}`); + return entryFor(resolved, node); + } + + async makeDir(sandboxId: string, path: string): Promise { + this.expect(sandboxId, 'running'); + const disk = this.disk(sandboxId); + const resolved = resolveSandboxPath(path); + if (disk.has(resolved)) return false; + const now = new Date().toISOString(); + this.ensureParents(sandboxId, disk, resolved); + disk.set(resolved, { type: 'dir', modifiedTime: now }); + return true; + } + + async move( + sandboxId: string, + from: string, + to: string, + ): Promise { + this.expect(sandboxId, 'running'); + const disk = this.disk(sandboxId); + const source = resolveSandboxPath(from); + const destination = resolveSandboxPath(to); + const node = disk.get(source); + if (!node) throw new FileNotFoundError(`no such file: ${source}`); + if (BUILTIN_DIRS[source]) { + // Reality refuses too (mount points, root-owned); wording untested. + throw new Error(`moving ${source} failed: built-in directory`); } - if (this.isDirectory(disk, resolved)) { + const destNode = disk.get(destination); + if (destNode && (node.type === 'dir' || destNode.type === 'dir')) { + // rename(2) only replaces a file with a file; everything else errors + // on the real executor too (mv wording differs, untested by contract). + throw new Error( + `moving ${source} to ${destination} failed: destination exists`, + ); + } + const oldPrefix = `${source}/`; + for (const [p, n] of [...disk]) { + if (p !== source && !p.startsWith(oldPrefix)) continue; + disk.delete(p); + disk.set(destination + p.slice(source.length), n); + } + return entryFor(destination, node); + } + + async remove(sandboxId: string, path: string): Promise { + this.expect(sandboxId, 'running'); + const disk = this.disk(sandboxId); + const resolved = resolveSandboxPath(path); + const node = disk.get(resolved); + if (!node) throw new FileNotFoundError(`no such file: ${resolved}`); + if (BUILTIN_DIRS[resolved]) { + // rm refuses / outright and cannot rmdir root-owned mount points; + // wording differs from the real stderr, untested by contract. + throw new Error(`removing ${resolved} failed: built-in directory`); + } + const prefix = `${resolved}/`; + for (const p of [...disk.keys()]) { + if (p === resolved || p.startsWith(prefix)) disk.delete(p); + } + } + + private disk(sandboxId: string): Map { + const disk = this.fs.get(sandboxId) ?? seededDisk(); + this.fs.set(sandboxId, disk); + return disk; + } + + /** The write path shared by buffered and streaming writes. */ + private writeNode( + sandboxId: string, + disk: Map, + resolved: string, + content: Buffer, + ): void { + const existing = disk.get(resolved); + if (existing?.type === 'dir') { throw new NotAFileError(`not a regular file: ${resolved}`); } - throw new FileNotFoundError(`no such file: ${resolved}`); + this.ensureParents(sandboxId, disk, resolved); + disk.set(resolved, { + type: 'file', + content, + modifiedTime: new Date().toISOString(), + }); } - /** - * A path is a directory when the image ships it or some file lives under - * it — the same observable truth mkdir -p leaves behind on a real disk. - */ - private isDirectory(disk: Map, path: string): boolean { - if (BUILTIN_DIRS.has(path)) return true; - const prefix = path === '/' ? '/' : `${path}/`; - for (const existing of disk.keys()) { - if (existing.startsWith(prefix)) return true; + /** mkdir -p leaves real directories behind; so does the fake. */ + private ensureParents( + sandboxId: string, + disk: Map, + resolved: string, + ): void { + const segments = resolved.split('/').filter((s) => s !== ''); + let parent = ''; + for (const segment of segments.slice(0, -1)) { + parent += `/${segment}`; + const node = disk.get(parent); + if (node === undefined) { + disk.set(parent, { + type: 'dir', + modifiedTime: new Date().toISOString(), + }); + } else if (node.type !== 'dir') { + // mkdir -p over a file fails on the real executor too (its stderr + // wording differs; the contract does not pin this path). + throw new Error( + `cannot create parent directory ${parent} in ${sandboxId}: not a directory`, + ); + } + } + } + + /** Node with the running-state and is-a-file checks every read shares. */ + private fileNode( + sandboxId: string, + resolved: string, + ): Extract { + this.expect(sandboxId, 'running'); + const node = this.disk(sandboxId).get(resolved); + if (!node) throw new FileNotFoundError(`no such file: ${resolved}`); + if (node.type !== 'file') { + throw new NotAFileError(`not a regular file: ${resolved}`); } - return false; + return node; } private expect(sandboxId: string, wanted: ContainerState): void { @@ -209,54 +468,93 @@ export class FakeExecutor implements Executor { } /** - * Truncation lives in this single exit so every interpreted command obeys - * the protocol cap. The interpreter only ever emits ASCII, so string length - * equals byte length and slice() is an honest byte cap. + * Truncation lives in this single sink so the buffered exec obeys the + * protocol cap however the interpreter chunks its output. The interpreter + * only ever emits ASCII, so string length equals byte length and slice() + * is an honest byte cap. */ -function fakeResult(exitCode: number, stdout = '', stderr = ''): ExecResult { - return { - exitCode, - stdout: stdout.slice(0, EXEC_OUTPUT_LIMIT_BYTES), - stderr: stderr.slice(0, EXEC_OUTPUT_LIMIT_BYTES), - stdoutTruncated: stdout.length > EXEC_OUTPUT_LIMIT_BYTES, - stderrTruncated: stderr.length > EXEC_OUTPUT_LIMIT_BYTES, - }; +class CappedText { + text = ''; + truncated = false; + + constructor(private readonly cap: number) {} + + push(chunk: Buffer): void { + const s = chunk.toString('utf8'); + const room = this.cap - this.text.length; + if (room > 0) this.text += s.slice(0, room); + if (s.length > room) this.truncated = true; + } +} + +interface Emit { + stdout: (text: string) => void; + stderr: (text: string) => void; } /** - * A six-verb pocket bash: exactly what the contract exam and the e2e suite - * need to exercise exec through the same questions the real executor - * answers, nothing more. Not a shell — an unknown verb gets bash's honest - * 127. If the real bash's wording ever proves different on the test - * machine, this string yields: reality wins. + * A pocket bash: the six verbs plus `;` sequencing — exactly what the + * contract exam and the e2e suite need to exercise exec through the same + * questions the real executor answers, nothing more. Each verb's output is + * its own live chunk (the contract's streaming questions time the gaps). + * Not a shell — an unknown verb gets bash's honest 127 and, like bash, the + * sequence carries on; `exit` ends it. If the real bash's wording ever + * proves different on the test machine, this string yields: reality wins. */ -async function interpret(opts: ExecOptions): Promise { - const command = opts.command.trim(); +async function interpret( + opts: { command: string; cwd?: string; env?: Record }, + emit: Emit, +): Promise { + let exitCode = 0; + for (const segment of opts.command.split(';')) { + const command = segment.trim(); + if (command === '') continue; + const exited = command.match(/^exit (\d+)$/)?.[1]; + if (exited !== undefined) return Number(exited); + exitCode = await interpretVerb(command, opts, emit); + } + return exitCode; +} + +async function interpretVerb( + command: string, + opts: { cwd?: string; env?: Record }, + emit: Emit, +): Promise { const echoed = command.match(/^echo (.*)$/s)?.[1]; - if (echoed !== undefined) return fakeResult(0, `${echoed}\n`); - const exitCode = command.match(/^exit (\d+)$/)?.[1]; - if (exitCode !== undefined) return fakeResult(Number(exitCode)); + if (echoed !== undefined) { + emit.stdout(`${echoed}\n`); + return 0; + } const napSeconds = command.match(/^sleep (\d+(?:\.\d+)?)$/)?.[1]; if (napSeconds !== undefined) { // A real timer on purpose: e2e races this against the daemon's - // wall-clock idle scanner to prove the exec heartbeat works. + // wall-clock idle scanner to prove the exec heartbeat works, and the + // contract's streaming questions time the chunk gap it creates. await new Promise((resolve) => setTimeout(resolve, Number(napSeconds) * 1000), ); - return fakeResult(0); + return 0; + } + if (command === 'pwd') { + emit.stdout(`${opts.cwd ?? '/home/user'}\n`); + return 0; } - if (command === 'pwd') return fakeResult(0, `${opts.cwd ?? '/home/user'}\n`); const envKey = command.match(/^printenv (\w+)$/)?.[1]; if (envKey !== undefined) { const value = opts.env?.[envKey]; - return value === undefined ? fakeResult(1) : fakeResult(0, `${value}\n`); + if (value === undefined) return 1; + emit.stdout(`${value}\n`); + return 0; } const seqEnd = command.match(/^seq 1 (\d+)$/)?.[1]; if (seqEnd !== undefined) { let out = ''; for (let i = 1; i <= Number(seqEnd); i++) out += `${i}\n`; - return fakeResult(0, out); + emit.stdout(out); + return 0; } const verb = command.split(/\s/)[0]; - return fakeResult(127, '', `bash: line 1: ${verb}: command not found\n`); + emit.stderr(`bash: line 1: ${verb}: command not found\n`); + return 127; } From b57606266ab529cee51e71f1fd5407ec4e84a2e1 Mon Sep 17 00:00:00 2001 From: Annactswell Date: Thu, 9 Jul 2026 18:53:15 +0800 Subject: [PATCH 006/133] The ledger learns E2B deadlines: an absolute clock beside the idle rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five columns, all inert for natively-acquired sandboxes: metadata and envs (persisted for list filtering and command env merging), deadlineAt plus onDeadline (the E2B timeout), and pausedByUser (explicit pause, cleared by every wake — an awake sandbox is not paused). The scanner enforces deadlines before idle cooling: kill destroys from any state, pause freezes once and leaves the row parked. Unlike the idle rules this clock is absolute — activity never extends it, only create/connect/setTimeout do, which is exactly E2B's semantics. e2bView() is the single arbiter of what the E2B surface reports: the LOGICAL state, never the physical one. A sandbox the idle scanner froze still reads running (passive freezing is an implementation detail, 50ms wake); paused is reserved for explicit pauses and expired pause-deadlines; an expired kill-deadline is protocol-dead immediately, physical teardown follows on the scanner's beat. The exec heartbeat moves to its own module: the compat layer's streaming exec and file transfers need the same scanner-repellent as native exec. --- packages/server/drizzle/0002_e2b-columns.sql | 5 + .../server/drizzle/meta/0002_snapshot.json | 135 ++++++++++++++ packages/server/drizzle/meta/_journal.json | 7 + packages/server/src/db/ledger.ts | 43 +++++ packages/server/src/db/schema.ts | 19 ++ packages/server/src/e2b/view.test.ts | 68 +++++++ packages/server/src/e2b/view.ts | 23 +++ packages/server/src/exec-heartbeat.ts | 27 +++ packages/server/src/lifecycle.ts | 18 +- packages/server/src/routes/sandboxes.ts | 26 +-- packages/server/src/scanner.test.ts | 173 +++++++++++++++++- packages/server/src/scanner.ts | 42 ++++- 12 files changed, 546 insertions(+), 40 deletions(-) create mode 100644 packages/server/drizzle/0002_e2b-columns.sql create mode 100644 packages/server/drizzle/meta/0002_snapshot.json create mode 100644 packages/server/src/e2b/view.test.ts create mode 100644 packages/server/src/e2b/view.ts create mode 100644 packages/server/src/exec-heartbeat.ts diff --git a/packages/server/drizzle/0002_e2b-columns.sql b/packages/server/drizzle/0002_e2b-columns.sql new file mode 100644 index 0000000..e2cf669 --- /dev/null +++ b/packages/server/drizzle/0002_e2b-columns.sql @@ -0,0 +1,5 @@ +ALTER TABLE `sandboxes` ADD `metadata` text;--> statement-breakpoint +ALTER TABLE `sandboxes` ADD `envs` text;--> statement-breakpoint +ALTER TABLE `sandboxes` ADD `deadline_at` text;--> statement-breakpoint +ALTER TABLE `sandboxes` ADD `on_deadline` text;--> statement-breakpoint +ALTER TABLE `sandboxes` ADD `paused_by_user` integer DEFAULT false NOT NULL; \ No newline at end of file diff --git a/packages/server/drizzle/meta/0002_snapshot.json b/packages/server/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..31f5472 --- /dev/null +++ b/packages/server/drizzle/meta/0002_snapshot.json @@ -0,0 +1,135 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "f741bfad-03cf-4605-a353-c4b409ddc2a0", + "prevId": "1aef50f2-e7f6-46b3-b2f6-7ce50166defc", + "tables": { + "sandboxes": { + "name": "sandboxes", + "columns": { + "sandbox_id": { + "name": "sandbox_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_key": { + "name": "user_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "node_id": { + "name": "node_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "freeze_after_seconds": { + "name": "freeze_after_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stop_after_seconds": { + "name": "stop_after_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archive_after_seconds": { + "name": "archive_after_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "envs": { + "name": "envs", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deadline_at": { + "name": "deadline_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "on_deadline": { + "name": "on_deadline", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "paused_by_user": { + "name": "paused_by_user", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "sandboxes_user_key_unique": { + "name": "sandboxes_user_key_unique", + "columns": [ + "user_key" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/server/drizzle/meta/_journal.json b/packages/server/drizzle/meta/_journal.json index c774636..624a755 100644 --- a/packages/server/drizzle/meta/_journal.json +++ b/packages/server/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1783538558684, "tag": "0001_stop-after-nullable", "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1783592514453, + "tag": "0002_e2b-columns", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/server/src/db/ledger.ts b/packages/server/src/db/ledger.ts index 3559322..af12a2a 100644 --- a/packages/server/src/db/ledger.ts +++ b/packages/server/src/db/ledger.ts @@ -29,6 +29,14 @@ export interface CreateSandboxInput { userKey: string; nodeId: string; policy: LifecyclePolicy; + /** E2B-surface extras; native acquire never sets them. */ + e2b?: { + /** JSON-serialized objects, stored verbatim. */ + metadata: string | null; + envs: string | null; + deadlineAt: string; + onDeadline: 'kill' | 'pause'; + }; } /** Inserts a new sandbox row in `active` state. Throws if the user key is taken. */ @@ -44,6 +52,11 @@ export function createSandbox(db: Db, input: CreateSandboxInput): SandboxRow { archiveAfterSeconds: input.policy.archiveAfterSeconds, createdAt: now, lastActiveAt: now, + metadata: input.e2b?.metadata ?? null, + envs: input.e2b?.envs ?? null, + deadlineAt: input.e2b?.deadlineAt ?? null, + onDeadline: input.e2b?.onDeadline ?? null, + pausedByUser: false, }; db.insert(sandboxes).values(row).run(); return row; @@ -110,6 +123,36 @@ export function overwriteState( .run(); } +/** + * Sets or clears the E2B deadline. Both travel together: a deadline without + * an action (or the reverse) would be a row the scanner cannot interpret. + */ +export function setDeadline( + db: Db, + sandboxId: string, + deadline: { deadlineAt: string; onDeadline: 'kill' | 'pause' } | null, +): void { + db.update(sandboxes) + .set({ + deadlineAt: deadline?.deadlineAt ?? null, + onDeadline: deadline?.onDeadline ?? null, + }) + .where(eq(sandboxes.sandboxId, sandboxId)) + .run(); +} + +/** Marks an explicit E2B pause; wakes clear it (an awake sandbox is not paused). */ +export function setPausedByUser( + db: Db, + sandboxId: string, + paused: boolean, +): void { + db.update(sandboxes) + .set({ pausedByUser: paused }) + .where(eq(sandboxes.sandboxId, sandboxId)) + .run(); +} + export function findByUserKey(db: Db, userKey: string): SandboxRow | undefined { return db .select() diff --git a/packages/server/src/db/schema.ts b/packages/server/src/db/schema.ts index f875db7..f61a6cc 100644 --- a/packages/server/src/db/schema.ts +++ b/packages/server/src/db/schema.ts @@ -23,6 +23,25 @@ export const sandboxes = sqliteTable('sandboxes', { archiveAfterSeconds: integer('archive_after_seconds'), createdAt: text('created_at').notNull(), lastActiveAt: text('last_active_at').notNull(), + /** + * The E2B surface's columns. All NULL / defaulted for natively-acquired + * sandboxes — the native lifecycle never reads them. + */ + /** JSON object; E2B metadata, persisted for list filtering and echo. */ + metadata: text('metadata'), + /** JSON object; sandbox-level default envs, merged under per-command envs. */ + envs: text('envs'), + /** ISO 8601; the E2B timeout's absolute deadline. NULL = no deadline. */ + deadlineAt: text('deadline_at'), + /** What the scanner does when deadlineAt passes. Non-null iff deadlineAt is. */ + onDeadline: text('on_deadline', { enum: ['kill', 'pause'] }), + /** + * Explicitly paused through the E2B surface and not woken since. Only + * consulted by the logical-state view; every wake back to active clears it. + */ + pausedByUser: integer('paused_by_user', { mode: 'boolean' }) + .notNull() + .default(false), }); export type SandboxRow = typeof sandboxes.$inferSelect; diff --git a/packages/server/src/e2b/view.test.ts b/packages/server/src/e2b/view.test.ts new file mode 100644 index 0000000..22cf17b --- /dev/null +++ b/packages/server/src/e2b/view.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import type { SandboxRow } from '../db/schema'; +import { e2bView } from './view'; + +function row(overrides: Partial): SandboxRow { + return { + sandboxId: 'sbx-1', + userKey: 'alice', + state: 'active', + nodeId: 'node-test', + freezeAfterSeconds: 60, + stopAfterSeconds: null, + archiveAfterSeconds: null, + createdAt: '2026-07-09T00:00:00.000Z', + lastActiveAt: '2026-07-09T00:00:00.000Z', + metadata: null, + envs: null, + deadlineAt: null, + onDeadline: null, + pausedByUser: false, + ...overrides, + }; +} + +const NOW = new Date('2026-07-09T12:00:00.000Z'); + +describe('e2bView', () => { + it('reports the logical state, not the physical one: frozen and stopped still read running', () => { + // Passive cooling is Dormice's implementation detail — until a deadline + // or an explicit pause says otherwise, the sandbox is protocol-alive. + expect(e2bView(row({ state: 'active' }), NOW)).toBe('running'); + expect(e2bView(row({ state: 'frozen' }), NOW)).toBe('running'); + expect(e2bView(row({ state: 'stopped' }), NOW)).toBe('running'); + }); + + it('a future deadline is still running; an expired one acts', () => { + const future = new Date(NOW.getTime() + 60_000).toISOString(); + const past = new Date(NOW.getTime() - 1).toISOString(); + expect(e2bView(row({ deadlineAt: future, onDeadline: 'kill' }), NOW)).toBe( + 'running', + ); + expect(e2bView(row({ deadlineAt: past, onDeadline: 'kill' }), NOW)).toBe( + 'dead', + ); + expect(e2bView(row({ deadlineAt: past, onDeadline: 'pause' }), NOW)).toBe( + 'paused', + ); + }); + + it('an explicit pause reads paused whatever the physical state', () => { + expect(e2bView(row({ pausedByUser: true, state: 'active' }), NOW)).toBe( + 'paused', + ); + expect(e2bView(row({ pausedByUser: true, state: 'frozen' }), NOW)).toBe( + 'paused', + ); + }); + + it('an expired kill deadline outranks an explicit pause: dead is dead', () => { + const past = new Date(NOW.getTime() - 1).toISOString(); + expect( + e2bView( + row({ deadlineAt: past, onDeadline: 'kill', pausedByUser: true }), + NOW, + ), + ).toBe('dead'); + }); +}); diff --git a/packages/server/src/e2b/view.ts b/packages/server/src/e2b/view.ts new file mode 100644 index 0000000..63ed292 --- /dev/null +++ b/packages/server/src/e2b/view.ts @@ -0,0 +1,23 @@ +import type { SandboxRow } from '../db/schema'; + +export type E2bState = 'running' | 'paused' | 'dead'; + +/** + * The single arbiter of what the E2B surface says about a sandbox. + * + * It reports the LOGICAL state, never the physical one: a sandbox the idle + * scanner froze behind the caller's back is still `running` here — passive + * freezing is Dormice's implementation detail (50ms wake, invisible to the + * caller), and protocol-wise the sandbox is alive until its deadline says + * otherwise. `paused` is reserved for what E2B means by it: an explicit + * pause, or an expired deadline whose action is pause. An expired kill-type + * deadline reports `dead` — the sandbox is protocol-gone the moment the + * deadline passes; the scanner's physical teardown follows on its own beat. + */ +export function e2bView(row: SandboxRow, now: Date): E2bState { + if (row.deadlineAt !== null && Date.parse(row.deadlineAt) <= now.getTime()) { + return row.onDeadline === 'pause' ? 'paused' : 'dead'; + } + if (row.pausedByUser) return 'paused'; + return 'running'; +} diff --git a/packages/server/src/exec-heartbeat.ts b/packages/server/src/exec-heartbeat.ts new file mode 100644 index 0000000..4fc82a9 --- /dev/null +++ b/packages/server/src/exec-heartbeat.ts @@ -0,0 +1,27 @@ +import type { Db } from './db/db'; +import { touch } from './db/ledger'; + +/** + * Keeps a sandbox's idle clock fresh while an exec (or a long file stream) + * is in flight, so the scanner never freezes a container that is + * mid-command. Half the freeze threshold (never above 10s) lands at least + * one touch inside every scan window, down to the schema's minimum + * freezeAfterSeconds of 1. A vanished row (released mid-exec) stops the + * timer — the exec itself will fail with its own honest error; an unhandled + * throw inside setInterval would take the daemon down instead. + */ +export function startExecHeartbeat( + db: Db, + sandboxId: string, + freezeAfterSeconds: number, +): () => void { + const intervalMs = Math.min((freezeAfterSeconds * 1000) / 2, 10_000); + const timer = setInterval(() => { + try { + touch(db, sandboxId); + } catch { + clearInterval(timer); + } + }, intervalMs); + return () => clearInterval(timer); +} diff --git a/packages/server/src/lifecycle.ts b/packages/server/src/lifecycle.ts index e5f5c65..419da29 100644 --- a/packages/server/src/lifecycle.ts +++ b/packages/server/src/lifecycle.ts @@ -1,5 +1,5 @@ import type { Db } from './db/db'; -import { deleteSandbox, transition } from './db/ledger'; +import { deleteSandbox, setPausedByUser, transition } from './db/ledger'; import type { SandboxRow } from './db/schema'; import type { Executor } from './executor/executor'; @@ -58,10 +58,10 @@ export async function wakeSandbox( return row; case 'frozen': await executor.unfreeze(row.sandboxId); - return transition(db, row.sandboxId, 'active'); + return awaken(db, row); case 'stopped': await executor.start(row.sandboxId); - return transition(db, row.sandboxId, 'active'); + return awaken(db, row); case 'archived': case 'restoring': // Unreachable today — nothing archives until the S3 archiver lands. @@ -71,3 +71,15 @@ export async function wakeSandbox( ); } } + +/** + * The ledger side of a wake. An awake sandbox is by definition not paused, + * so any explicit E2B pause mark is cleared along with the transition — + * ledger honesty, not an E2B-surface concern leaking in. + */ +function awaken(db: Db, row: SandboxRow): SandboxRow { + if (row.pausedByUser) { + setPausedByUser(db, row.sandboxId, false); + } + return { ...transition(db, row.sandboxId, 'active'), pausedByUser: false }; +} diff --git a/packages/server/src/routes/sandboxes.ts b/packages/server/src/routes/sandboxes.ts index ba346df..25fea15 100644 --- a/packages/server/src/routes/sandboxes.ts +++ b/packages/server/src/routes/sandboxes.ts @@ -28,6 +28,7 @@ import { touch, } from '../db/ledger'; import type { SandboxRow } from '../db/schema'; +import { startExecHeartbeat } from '../exec-heartbeat'; import { type Executor, FileNotFoundError, @@ -46,31 +47,6 @@ export interface SandboxRoutesOptions { locks: KeyedQueue; } -/** - * Keeps a sandbox's idle clock fresh while an exec is in flight, so the - * scanner never freezes a container that is mid-command. Half the freeze - * threshold (never above 10s) lands at least one touch inside every scan - * window, down to the schema's minimum freezeAfterSeconds of 1. A vanished - * row (released mid-exec) stops the timer — the exec itself will fail with - * its own honest error; an unhandled throw inside setInterval would take - * the daemon down instead. - */ -function startExecHeartbeat( - db: Db, - sandboxId: string, - freezeAfterSeconds: number, -): () => void { - const intervalMs = Math.min((freezeAfterSeconds * 1000) / 2, 10_000); - const timer = setInterval(() => { - try { - touch(db, sandboxId); - } catch { - clearInterval(timer); - } - }, intervalMs); - return () => clearInterval(timer); -} - /** Ledger row -> wire shape: nest the flat policy columns, attach the endpoint. */ function toSandbox(row: SandboxRow, endpoint: string): Sandbox { return { diff --git a/packages/server/src/scanner.test.ts b/packages/server/src/scanner.test.ts index bf58bc1..1f41050 100644 --- a/packages/server/src/scanner.test.ts +++ b/packages/server/src/scanner.test.ts @@ -6,7 +6,7 @@ import { } from '@dormice/shared'; import { describe, expect, it } from 'vitest'; import { type Db, migrateDb, openDb } from './db/db'; -import { createSandbox, findByUserKey } from './db/ledger'; +import { createSandbox, findByUserKey, setDeadline, touch } from './db/ledger'; import type { SandboxRow } from './db/schema'; import { FakeExecutor } from './executor/fake'; import { KeyedQueue } from './keyed-queue'; @@ -42,7 +42,12 @@ describe('idle scanner', () => { const { db, executor, locks } = setup(); const row = await seed(db, executor, 'alice'); const result = await scanOnce(db, executor, locks, after(row, 1)); - expect(result).toEqual({ frozen: 0, stopped: 0, failures: [] }); + expect(result).toEqual({ + frozen: 0, + stopped: 0, + expiredKilled: 0, + failures: [], + }); expect(findByUserKey(db, 'alice')?.state).toBe('active'); expect(executor.stateOf(row.sandboxId)).toBe('running'); }); @@ -56,7 +61,12 @@ describe('idle scanner', () => { locks, after(row, row.freezeAfterSeconds), ); - expect(result).toEqual({ frozen: 1, stopped: 0, failures: [] }); + expect(result).toEqual({ + frozen: 1, + stopped: 0, + expiredKilled: 0, + failures: [], + }); expect(findByUserKey(db, 'alice')?.state).toBe('frozen'); expect(executor.stateOf(row.sandboxId)).toBe('paused'); }); @@ -71,7 +81,12 @@ describe('idle scanner', () => { }); await scanOnce(db, executor, locks, after(row, 60)); const result = await scanOnce(db, executor, locks, after(row, 120)); - expect(result).toEqual({ frozen: 0, stopped: 1, failures: [] }); + expect(result).toEqual({ + frozen: 0, + stopped: 1, + expiredKilled: 0, + failures: [], + }); expect(findByUserKey(db, 'alice')?.state).toBe('stopped'); expect(executor.stateOf(row.sandboxId)).toBe('stopped'); }); @@ -83,12 +98,14 @@ describe('idle scanner', () => { expect(await scanOnce(db, executor, locks, yearLater)).toEqual({ frozen: 1, stopped: 0, + expiredKilled: 0, failures: [], }); expect(findByUserKey(db, 'alice')?.state).toBe('frozen'); expect(await scanOnce(db, executor, locks, yearLater)).toEqual({ frozen: 0, stopped: 1, + expiredKilled: 0, failures: [], }); expect(findByUserKey(db, 'alice')?.state).toBe('stopped'); @@ -102,7 +119,12 @@ describe('idle scanner', () => { }); const slow = await seed(db, executor, 'slow'); const result = await scanOnce(db, executor, locks, after(quick, 100)); - expect(result).toEqual({ frozen: 1, stopped: 0, failures: [] }); + expect(result).toEqual({ + frozen: 1, + stopped: 0, + expiredKilled: 0, + failures: [], + }); expect(findByUserKey(db, 'quick')?.state).toBe('frozen'); expect(findByUserKey(db, 'slow')?.state).toBe('active'); expect(executor.stateOf(slow.sandboxId)).toBe('running'); @@ -125,7 +147,12 @@ describe('idle scanner', () => { locks, after(row, 365 * 24 * 60 * 60), ); - expect(yearLater).toEqual({ frozen: 0, stopped: 0, failures: [] }); + expect(yearLater).toEqual({ + frozen: 0, + stopped: 0, + expiredKilled: 0, + failures: [], + }); expect(findByUserKey(db, 'resident')?.state).toBe('frozen'); }); @@ -182,7 +209,12 @@ describe('idle scanner vs the per-key queue', () => { locks, after(row, row.freezeAfterSeconds), ); - expect(result).toEqual({ frozen: 0, stopped: 0, failures: [] }); + expect(result).toEqual({ + frozen: 0, + stopped: 0, + expiredKilled: 0, + failures: [], + }); expect(findByUserKey(db, 'alice')?.state).toBe('active'); release(); @@ -194,7 +226,12 @@ describe('idle scanner vs the per-key queue', () => { locks, after(row, row.freezeAfterSeconds), ); - expect(next).toEqual({ frozen: 1, stopped: 0, failures: [] }); + expect(next).toEqual({ + frozen: 1, + stopped: 0, + expiredKilled: 0, + failures: [], + }); }); it('re-reads under the lock: a sandbox released mid-sweep is skipped, not failed', async () => { @@ -226,7 +263,125 @@ describe('idle scanner vs the per-key queue', () => { ); const [result] = await Promise.all([sweep, released]); - expect(result).toEqual({ frozen: 1, stopped: 0, failures: [] }); + expect(result).toEqual({ + frozen: 1, + stopped: 0, + expiredKilled: 0, + failures: [], + }); + expect(findByUserKey(db, 'doomed')).toBeUndefined(); + }); +}); + +describe('E2B deadline rule', () => { + async function seedWithDeadline( + db: Db, + executor: FakeExecutor, + userKey: string, + row: SandboxRow | null, + afterSeconds: number, + onDeadline: 'kill' | 'pause', + ): Promise { + const seeded = row ?? (await seed(db, executor, userKey)); + setDeadline(db, seeded.sandboxId, { + deadlineAt: after(seeded, afterSeconds).toISOString(), + onDeadline, + }); + return seeded; + } + + it('kills an expired kill-deadline sandbox: container, disk and row gone', async () => { + const { db, executor, locks } = setup(); + const row = await seedWithDeadline( + db, + executor, + 'doomed', + null, + 100, + 'kill', + ); + + // Before the deadline nothing happens (idle is far below freeze too). + const early = await scanOnce(db, executor, locks, after(row, 50)); + expect(early).toEqual({ + frozen: 0, + stopped: 0, + expiredKilled: 0, + failures: [], + }); + + const late = await scanOnce(db, executor, locks, after(row, 100)); + expect(late).toEqual({ + frozen: 0, + stopped: 0, + expiredKilled: 1, + failures: [], + }); expect(findByUserKey(db, 'doomed')).toBeUndefined(); + expect(executor.stateOf(row.sandboxId)).toBeUndefined(); + expect(await executor.listDisks()).not.toContain(row.sandboxId); + }); + + it('the deadline outranks idle cooling: due for both means dead, not frozen', async () => { + const { db, executor, locks } = setup(); + const row = await seedWithDeadline(db, executor, 'both', null, 10, 'kill'); + // Way past freezeAfterSeconds AND past the deadline. + const result = await scanOnce( + db, + executor, + locks, + after(row, row.freezeAfterSeconds + 1000), + ); + expect(result).toEqual({ + frozen: 0, + stopped: 0, + expiredKilled: 1, + failures: [], + }); + expect(findByUserKey(db, 'both')).toBeUndefined(); + }); + + it('activity does not extend a deadline — it is an absolute clock', async () => { + const { db, executor, locks } = setup(); + const row = await seedWithDeadline(db, executor, 'busy', null, 100, 'kill'); + // The sandbox stays busy right up to the deadline; E2B kills anyway — + // only create/connect/setTimeout move a deadline, never activity. + touch(db, row.sandboxId, after(row, 99).toISOString()); + const result = await scanOnce(db, executor, locks, after(row, 101)); + expect(result.expiredKilled).toBe(1); + expect(findByUserKey(db, 'busy')).toBeUndefined(); + }); + + it('parks an expired pause-deadline sandbox frozen, once, and keeps the row', async () => { + const { db, executor, locks } = setup(); + const row = await seedWithDeadline( + db, + executor, + 'parked', + null, + 100, + 'pause', + ); + + const late = await scanOnce(db, executor, locks, after(row, 100)); + expect(late).toEqual({ + frozen: 1, + stopped: 0, + expiredKilled: 0, + failures: [], + }); + expect(findByUserKey(db, 'parked')?.state).toBe('frozen'); + expect(executor.stateOf(row.sandboxId)).toBe('paused'); + + // A second sweep finds no physical work left: frozen already satisfies + // the pause action, and the logical view reads the deadline directly. + const again = await scanOnce(db, executor, locks, after(row, 200)); + expect(again).toEqual({ + frozen: 0, + stopped: 0, + expiredKilled: 0, + failures: [], + }); + expect(findByUserKey(db, 'parked')?.state).toBe('frozen'); }); }); diff --git a/packages/server/src/scanner.ts b/packages/server/src/scanner.ts index ac7546b..6c82fd5 100644 --- a/packages/server/src/scanner.ts +++ b/packages/server/src/scanner.ts @@ -3,15 +3,33 @@ import { findBySandboxId, listSandboxes } from './db/ledger'; import type { SandboxRow } from './db/schema'; import type { Executor } from './executor/executor'; import type { KeyedQueue } from './keyed-queue'; -import { freezeSandbox, stopSandbox } from './lifecycle'; +import { freezeSandbox, releaseSandbox, stopSandbox } from './lifecycle'; export interface ScanResult { frozen: number; stopped: number; + /** E2B deadlines whose action was kill, enforced this sweep: sandbox destroyed. */ + expiredKilled: number; /** Sandboxes whose transition failed this sweep; the caller logs them. */ failures: Array<{ sandboxId: string; message: string }>; } +/** + * The E2B deadline rule. Unlike the idle rules it is an absolute clock — + * E2B kills a sandbox at its deadline even mid-command; activity does not + * extend it, only create/connect/setTimeout do. `kill` applies from any + * state (release handles them all); `pause` only has physical work while + * the sandbox is still active — colder states already satisfy it, and the + * logical view reads the expired deadline directly. + */ +function dueDeadline(row: SandboxRow, now: Date): 'kill' | 'pause' | null { + if (row.deadlineAt === null || Date.parse(row.deadlineAt) > now.getTime()) { + return null; + } + if (row.onDeadline !== 'pause') return 'kill'; + return row.state === 'active' ? 'pause' : null; +} + /** * Which one-rung-colder move a row is due for, if any. One rung per sweep, * mirroring ALLOWED_TRANSITIONS — a long-dead sandbox freezes on this sweep @@ -65,9 +83,14 @@ export async function scanOnce( locks: KeyedQueue, now: Date, ): Promise { - const result: ScanResult = { frozen: 0, stopped: 0, failures: [] }; + const result: ScanResult = { + frozen: 0, + stopped: 0, + expiredKilled: 0, + failures: [], + }; for (const row of listSandboxes(db)) { - if (dueTransition(row, now) === null) { + if (dueDeadline(row, now) === null && dueTransition(row, now) === null) { continue; } try { @@ -76,6 +99,19 @@ export async function scanOnce( if (!fresh) { return; // Released since the sweep's snapshot. } + // The deadline outranks idle cooling: a row due for both dies (or + // parks) by its deadline, not one rung colder. + const deadline = dueDeadline(fresh, now); + if (deadline === 'kill') { + await releaseSandbox(db, executor, fresh.sandboxId); + result.expiredKilled += 1; + return; + } + if (deadline === 'pause') { + await freezeSandbox(db, executor, fresh.sandboxId); + result.frozen += 1; + return; + } const due = dueTransition(fresh, now); if (due === 'freeze') { await freezeSandbox(db, executor, fresh.sandboxId); From 58db3ac6715d08cd22722fd4f266f39fd2018798 Mon Sep 17 00:00:00 2001 From: Annactswell Date: Thu, 9 Jul 2026 18:53:38 +0800 Subject: [PATCH 007/133] E2B compatibility layer: control plane and envd on one origin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The official e2b package connects with two URLs — apiUrl -> /e2b/api, sandboxUrl -> /e2b/envd — plus e2b_ as its API key; it names the target sandbox in an E2b-Sandbox-Id header on every envd request, so a single origin serves every sandbox with no wildcard DNS. Faithful by default: create is fresh each call, the timeout kills at its deadline (autoPause parks instead), kill destroys container, disk and row. Dormice's immortality is opt-in — metadata.userKey makes create idempotent (an acquire in E2B clothes), and lifecycle verbs never impose a deadline on natively-acquired sandboxes. envd speaks Connect RPC in the JSON codec: unary filesystem verbs (Stat/ListDir/MakeDir/Move/Remove) run in the key's slot; Process/Start streams enveloped start/data/end events with keepalives, the daemon's own timer answering a blown connect-timeout-ms with deadline_exceeded — the in-container kill is just cleanup, never a 137 guessing game. /files uploads stream through multipart (preservePath: the filename IS the destination path) or octet-stream, downloads stream out with an exact content-length; no artificial size cap — the disk quota is the ceiling. Two error dialects, both verified against the SDK: the control plane's {code, message} carries the NUMERIC status (openapi-fetch checks error.code === 404 for kill-already-gone and 409 for pause-already- paused); Connect errors carry the protocol's string codes. What v1 does not support answers unimplemented with a hint: stdin, signals, process reconnect, PTY, watch. --- packages/server/package.json | 1 + packages/server/src/app.ts | 7 + packages/server/src/e2b/compat.test.ts | 615 ++++++++++++++++++++++ packages/server/src/e2b/control.ts | 377 ++++++++++++++ packages/server/src/e2b/deps.ts | 12 + packages/server/src/e2b/envd.ts | 686 +++++++++++++++++++++++++ packages/server/src/e2b/index.ts | 19 + packages/server/src/e2b/protocol.ts | 127 +++++ pnpm-lock.yaml | 247 +++++++++ 9 files changed, 2091 insertions(+) create mode 100644 packages/server/src/e2b/compat.test.ts create mode 100644 packages/server/src/e2b/control.ts create mode 100644 packages/server/src/e2b/deps.ts create mode 100644 packages/server/src/e2b/envd.ts create mode 100644 packages/server/src/e2b/index.ts create mode 100644 packages/server/src/e2b/protocol.ts diff --git a/packages/server/package.json b/packages/server/package.json index 6cade4d..5369c16 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -21,6 +21,7 @@ }, "dependencies": { "@dormice/shared": "workspace:*", + "@fastify/multipart": "^10.0.0", "better-sqlite3": "^12.11.1", "dockerode": "^5.0.1", "drizzle-orm": "^0.45.2", diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index c6fba1a..2f6f10a 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -9,6 +9,7 @@ import { z } from 'zod'; import { requireApiToken } from './auth'; import type { Config } from './config'; import type { Db } from './db/db'; +import { registerE2bCompat } from './e2b'; import type { Executor } from './executor/executor'; import type { KeyedQueue } from './keyed-queue'; import { sandboxRoutes } from './routes/sandboxes'; @@ -92,5 +93,11 @@ export function buildApp({ await api.register(sandboxRoutes, { config, db, executor, locks }); }); + // The E2B compatibility surface lives beside the native API with its own + // auth (X-API-KEY / X-Access-Token) and its own error dialect. + app.register(async (compat) => { + await registerE2bCompat(compat, { config, db, executor, locks }); + }); + return app; } diff --git a/packages/server/src/e2b/compat.test.ts b/packages/server/src/e2b/compat.test.ts new file mode 100644 index 0000000..d6f141a --- /dev/null +++ b/packages/server/src/e2b/compat.test.ts @@ -0,0 +1,615 @@ +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { buildApp } from '../app'; +import { loadConfig } from '../config'; +import { migrateDb, openDb } from '../db/db'; +import { findBySandboxId, setDeadline } from '../db/ledger'; +import { FakeExecutor } from '../executor/fake'; +import { KeyedQueue } from '../keyed-queue'; +import { scanOnce } from '../scanner'; +import { mintEnvdToken } from './protocol'; + +const MIGRATIONS = fileURLToPath(new URL('../../drizzle', import.meta.url)); +const TOKEN = 'test-token-test-token-test-token'; + +function testApp(executor: FakeExecutor = new FakeExecutor()) { + const db = openDb(':memory:'); + migrateDb(db, MIGRATIONS); + const config = loadConfig({ + DORMICE_DB_PATH: ':memory:', + DORMICE_NODE_ID: 'node-test', + DORMICE_API_TOKEN: TOKEN, + }); + const locks = new KeyedQueue(); + const app = buildApp({ config, db, executor, locks, logger: false }); + return { app, db, executor, locks }; +} + +type TestApp = ReturnType; + +const apiKey = { 'x-api-key': `e2b_${TOKEN}` }; + +function control( + t: TestApp, + method: 'POST' | 'GET' | 'DELETE', + url: string, + payload?: Record, +) { + return t.app.inject({ + method, + url: `/e2b/api${url}`, + headers: apiKey, + ...(payload === undefined ? {} : { payload }), + }); +} + +async function createSandbox( + t: TestApp, + payload: Record = {}, +): Promise<{ sandboxID: string; envdAccessToken: string }> { + const res = await control(t, 'POST', '/sandboxes', payload); + expect(res.statusCode).toBe(201); + return res.json(); +} + +function envdHeaders(sandboxID: string) { + return { + 'e2b-sandbox-id': sandboxID, + 'x-access-token': mintEnvdToken(TOKEN, sandboxID), + }; +} + +function envdRpc( + t: TestApp, + sandboxID: string, + url: string, + payload: Record, +) { + return t.app.inject({ + method: 'POST', + url: `/e2b/envd${url}`, + headers: envdHeaders(sandboxID), + payload, + }); +} + +/** 1 flag byte + 4-byte BE length + JSON, repeated — the Connect stream shape. */ +function parseEnvelopes( + payload: Buffer, +): Array<{ flags: number; json: Record }> { + const frames: Array<{ flags: number; json: Record }> = []; + let offset = 0; + while (offset + 5 <= payload.length) { + const flags = payload.readUInt8(offset); + const length = payload.readUInt32BE(offset + 1); + const body = payload.subarray(offset + 5, offset + 5 + length); + frames.push({ + flags, + json: body.length > 0 ? JSON.parse(body.toString('utf8')) : {}, + }); + offset += 5 + length; + } + return frames; +} + +function startCommand( + t: TestApp, + sandboxID: string, + command: string, + opts: { + envs?: Record; + headers?: Record; + } = {}, +) { + const message = { + process: { + cmd: '/bin/bash', + args: ['-l', '-c', command], + envs: opts.envs ?? {}, + }, + }; + const json = Buffer.from(JSON.stringify(message), 'utf8'); + const head = Buffer.alloc(5); + head.writeUInt8(0, 0); + head.writeUInt32BE(json.length, 1); + return t.app.inject({ + method: 'POST', + url: '/e2b/envd/process.Process/Start', + headers: { + ...envdHeaders(sandboxID), + 'content-type': 'application/connect+json', + ...opts.headers, + }, + payload: Buffer.concat([head, json]), + }); +} + +describe('E2B control plane', () => { + it('guards with X-API-KEY: e2b_-prefixed and bare tokens pass, others do not', async () => { + const t = testApp(); + const denied = await t.app.inject({ + method: 'POST', + url: '/e2b/api/sandboxes', + headers: { 'x-api-key': 'e2b_wrong' }, + payload: {}, + }); + expect(denied.statusCode).toBe(401); + expect(denied.json()).toEqual({ code: 401, message: 'invalid API key' }); + + const bare = await t.app.inject({ + method: 'POST', + url: '/e2b/api/sandboxes', + headers: { 'x-api-key': TOKEN }, + payload: {}, + }); + expect(bare.statusCode).toBe(201); + }); + + it('creates a fresh sandbox per call — E2B semantics, no key given', async () => { + const t = testApp(); + const first = await createSandbox(t); + const second = await createSandbox(t); + expect(first.sandboxID).not.toBe(second.sandboxID); + expect(first.envdAccessToken).toBe(mintEnvdToken(TOKEN, first.sandboxID)); + expect(t.executor.stateOf(first.sandboxID)).toBe('running'); + }); + + it('metadata.userKey is the Dormice extension: same key, same sandbox', async () => { + const t = testApp(); + const first = await createSandbox(t, { metadata: { userKey: 'agent-7' } }); + const second = await createSandbox(t, { metadata: { userKey: 'agent-7' } }); + expect(second.sandboxID).toBe(first.sandboxID); + }); + + it('getInfo echoes metadata and reports the deadline as endAt', async () => { + const t = testApp(); + const { sandboxID } = await createSandbox(t, { + timeout: 600, + metadata: { userKey: 'meta-echo', team: 'blue' }, + }); + const res = await control(t, 'GET', `/sandboxes/${sandboxID}`); + expect(res.statusCode).toBe(200); + const info = res.json(); + expect(info.state).toBe('running'); + expect(info.metadata).toEqual({ userKey: 'meta-echo', team: 'blue' }); + const endInMs = Date.parse(info.endAt) - Date.now(); + expect(endInMs).toBeGreaterThan(590_000); + expect(endInMs).toBeLessThan(610_000); + }); + + it('kill destroys for real: container, disk, row — and 404s after', async () => { + const t = testApp(); + const { sandboxID } = await createSandbox(t); + const killed = await control(t, 'DELETE', `/sandboxes/${sandboxID}`); + expect(killed.statusCode).toBe(204); + expect(t.executor.stateOf(sandboxID)).toBeUndefined(); + expect(await t.executor.listDisks()).not.toContain(sandboxID); + expect(findBySandboxId(t.db, sandboxID)).toBeUndefined(); + + const again = await control(t, 'DELETE', `/sandboxes/${sandboxID}`); + expect(again.statusCode).toBe(404); + // The SDK's kill() keys "already gone -> false" off this numeric code. + expect(again.json().code).toBe(404); + }); + + it('an expired kill-deadline is protocol-dead immediately, reaped by the scanner after', async () => { + const t = testApp(); + const { sandboxID } = await createSandbox(t, { timeout: 300 }); + const row = findBySandboxId(t.db, sandboxID); + expect(row?.onDeadline).toBe('kill'); + // Time-travel the deadline into the past instead of sleeping. + setDeadline(t.db, sandboxID, { + deadlineAt: new Date(Date.now() - 1000).toISOString(), + onDeadline: 'kill', + }); + + // The view answers dead before any physical teardown happened. + expect( + (await control(t, 'GET', `/sandboxes/${sandboxID}`)).statusCode, + ).toBe(404); + const connect = await control( + t, + 'POST', + `/sandboxes/${sandboxID}/connect`, + { + timeout: 300, + }, + ); + expect(connect.statusCode).toBe(404); + + // The scanner's sweep makes it physical. + const result = await scanOnce(t.db, t.executor, t.locks, new Date()); + expect(result.expiredKilled).toBe(1); + expect(t.executor.stateOf(sandboxID)).toBeUndefined(); + }); + + it('autoPause parks at the deadline and connect revives with a fresh TTL', async () => { + const t = testApp(); + const { sandboxID } = await createSandbox(t, { + timeout: 300, + autoPause: true, + }); + setDeadline(t.db, sandboxID, { + deadlineAt: new Date(Date.now() - 1000).toISOString(), + onDeadline: 'pause', + }); + + const paused = await control(t, 'GET', `/sandboxes/${sandboxID}`); + expect(paused.json().state).toBe('paused'); + + const connect = await control( + t, + 'POST', + `/sandboxes/${sandboxID}/connect`, + { + timeout: 300, + }, + ); + expect(connect.statusCode).toBe(201); // 201 = this connect resumed it + + const info = await control(t, 'GET', `/sandboxes/${sandboxID}`); + expect(info.json().state).toBe('running'); + const fresh = findBySandboxId(t.db, sandboxID); + expect(Date.parse(fresh?.deadlineAt ?? '')).toBeGreaterThan(Date.now()); + }); + + it('pause parks (frozen), reports paused, 409s a second pause, connect resumes', async () => { + const t = testApp(); + const { sandboxID } = await createSandbox(t); + const paused = await control( + t, + 'POST', + `/sandboxes/${sandboxID}/pause`, + {}, + ); + expect(paused.statusCode).toBe(204); + expect(t.executor.stateOf(sandboxID)).toBe('paused'); + expect( + (await control(t, 'GET', `/sandboxes/${sandboxID}`)).json().state, + ).toBe('paused'); + + const again = await control(t, 'POST', `/sandboxes/${sandboxID}/pause`, {}); + expect(again.statusCode).toBe(409); + + const connect = await control( + t, + 'POST', + `/sandboxes/${sandboxID}/connect`, + { + timeout: 300, + }, + ); + expect(connect.statusCode).toBe(201); + expect(t.executor.stateOf(sandboxID)).toBe('running'); + }); + + it('pause with memory:false maps to stopped — filesystem only, cold boot on resume', async () => { + const t = testApp(); + const { sandboxID } = await createSandbox(t); + const res = await control(t, 'POST', `/sandboxes/${sandboxID}/pause`, { + memory: false, + }); + expect(res.statusCode).toBe(204); + expect(t.executor.stateOf(sandboxID)).toBe('stopped'); + expect(findBySandboxId(t.db, sandboxID)?.state).toBe('stopped'); + }); + + it('lists with metadata and state filters, paginating via x-next-token', async () => { + const t = testApp(); + const blue1 = await createSandbox(t, { metadata: { team: 'blue' } }); + const blue2 = await createSandbox(t, { metadata: { team: 'blue' } }); + await createSandbox(t, { metadata: { team: 'red' } }); + + const blues = await control(t, 'GET', '/v2/sandboxes?metadata=team%3Dblue'); + expect(blues.statusCode).toBe(200); + expect( + blues + .json() + .map((s: { sandboxID: string }) => s.sandboxID) + .sort(), + ).toEqual([blue1.sandboxID, blue2.sandboxID].sort()); + + await control(t, 'POST', `/sandboxes/${blue1.sandboxID}/pause`, {}); + const pausedOnly = await control(t, 'GET', '/v2/sandboxes?state=paused'); + expect( + pausedOnly.json().map((s: { sandboxID: string }) => s.sandboxID), + ).toEqual([blue1.sandboxID]); + + const pageOne = await control(t, 'GET', '/v2/sandboxes?limit=2'); + expect(pageOne.json()).toHaveLength(2); + const token = pageOne.headers['x-next-token']; + expect(token).toBe('2'); + const pageTwo = await control( + t, + 'GET', + `/v2/sandboxes?limit=2&nextToken=${token}`, + ); + expect(pageTwo.json()).toHaveLength(1); + expect(pageTwo.headers['x-next-token']).toBeUndefined(); + }); + + it('never imposes a deadline on a natively-acquired sandbox', async () => { + const t = testApp(); + const native = await t.app.inject({ + method: 'POST', + url: '/acquireSandbox', + headers: { authorization: `Bearer ${TOKEN}` }, + payload: { userKey: 'native-immortal' }, + }); + const sandboxId = native.json().sandbox.sandboxId; + + const res = await control(t, 'POST', `/sandboxes/${sandboxId}/timeout`, { + timeout: 60, + }); + expect(res.statusCode).toBe(204); + // Accepted but not applied: immortality is the owner's choice. + expect(findBySandboxId(t.db, sandboxId)?.deadlineAt).toBeNull(); + }); +}); + +describe('E2B envd surface', () => { + it('health probes the logical state: running 204, paused 502, unknown 502', async () => { + const t = testApp(); + const { sandboxID } = await createSandbox(t); + const probe = (id: string) => + t.app.inject({ + method: 'GET', + url: '/e2b/envd/health', + headers: { 'e2b-sandbox-id': id }, + }); + + expect((await probe(sandboxID)).statusCode).toBe(204); + await control(t, 'POST', `/sandboxes/${sandboxID}/pause`, {}); + expect((await probe(sandboxID)).statusCode).toBe(502); + expect((await probe('no-such-sandbox')).statusCode).toBe(502); + }); + + it('rejects a wrong or missing envd access token', async () => { + const t = testApp(); + const { sandboxID } = await createSandbox(t); + const res = await t.app.inject({ + method: 'POST', + url: '/e2b/envd/filesystem.Filesystem/Stat', + headers: { 'e2b-sandbox-id': sandboxID, 'x-access-token': 'wrong' }, + payload: { path: '/home/user' }, + }); + expect(res.statusCode).toBe(401); + expect(res.json().code).toBe('unauthenticated'); + }); + + it('files round-trip over plain HTTP: multipart up, raw bytes down, content-length exact', async () => { + const t = testApp(); + const { sandboxID } = await createSandbox(t); + const content = 'streamed through multipart\n'; + const boundary = 'dormice-test-boundary'; + const body = [ + `--${boundary}`, + // A nested filename on purpose: busboy's default strips directories + // (preservePath pins the fix — a bare basename would pass by luck). + 'Content-Disposition: form-data; name="file"; filename="/home/user/nested/up.txt"', + 'Content-Type: application/octet-stream', + '', + content, + `--${boundary}--`, + '', + ].join('\r\n'); + const up = await t.app.inject({ + method: 'POST', + url: '/e2b/envd/files?username=user', + headers: { + ...envdHeaders(sandboxID), + 'content-type': `multipart/form-data; boundary=${boundary}`, + }, + payload: body, + }); + expect(up.statusCode).toBe(200); + expect(up.json()).toEqual([ + { name: 'up.txt', type: 'file', path: '/home/user/nested/up.txt' }, + ]); + + const down = await t.app.inject({ + method: 'GET', + url: '/e2b/envd/files?path=/home/user/nested/up.txt&username=user', + headers: envdHeaders(sandboxID), + }); + expect(down.statusCode).toBe(200); + expect(down.headers['content-length']).toBe(String(content.length)); + expect(down.body).toBe(content); + + const missing = await t.app.inject({ + method: 'GET', + url: '/e2b/envd/files?path=/home/user/void.txt', + headers: envdHeaders(sandboxID), + }); + expect(missing.statusCode).toBe(404); + expect(missing.json().code).toBe('not_found'); + }); + + it('accepts octet-stream uploads with the path in the query', async () => { + const t = testApp(); + const { sandboxID } = await createSandbox(t); + const res = await t.app.inject({ + method: 'POST', + url: '/e2b/envd/files?path=raw.bin&username=user', + headers: { + ...envdHeaders(sandboxID), + 'content-type': 'application/octet-stream', + }, + payload: Buffer.from([1, 2, 3, 250]), + }); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual([ + { name: 'raw.bin', type: 'file', path: '/home/user/raw.bin' }, + ]); + }); + + it('speaks the filesystem service: Stat, ListDir, MakeDir, Move, Remove', async () => { + const t = testApp(); + const { sandboxID } = await createSandbox(t); + await envdRpc(t, sandboxID, '/filesystem.Filesystem/MakeDir', { + path: '/home/user/dir', + }); + // MakeDir again: already_exists — the SDK reads it as false. + const dup = await envdRpc(t, sandboxID, '/filesystem.Filesystem/MakeDir', { + path: '/home/user/dir', + }); + expect(dup.statusCode).toBe(409); + expect(dup.json().code).toBe('already_exists'); + + await t.app.inject({ + method: 'POST', + url: '/e2b/envd/files?path=/home/user/dir/f.txt', + headers: { + ...envdHeaders(sandboxID), + 'content-type': 'application/octet-stream', + }, + payload: 'content!', + }); + + const stat = await envdRpc(t, sandboxID, '/filesystem.Filesystem/Stat', { + path: '/home/user/dir/f.txt', + }); + expect(stat.statusCode).toBe(200); + expect(stat.json().entry).toMatchObject({ + name: 'f.txt', + type: 'FILE_TYPE_FILE', + path: '/home/user/dir/f.txt', + size: '8', // proto int64 travels as a string + permissions: '-rw-r--r--', + owner: 'user', + }); + + const list = await envdRpc(t, sandboxID, '/filesystem.Filesystem/ListDir', { + path: '/home/user/dir', + depth: 1, + }); + expect(list.json().entries.map((e: { path: string }) => e.path)).toEqual([ + '/home/user/dir/f.txt', + ]); + + const moved = await envdRpc(t, sandboxID, '/filesystem.Filesystem/Move', { + source: '/home/user/dir/f.txt', + destination: '/home/user/dir/g.txt', + }); + expect(moved.json().entry.path).toBe('/home/user/dir/g.txt'); + + const removed = await envdRpc( + t, + sandboxID, + '/filesystem.Filesystem/Remove', + { + path: '/home/user/dir', + }, + ); + expect(removed.statusCode).toBe(200); + const gone = await envdRpc(t, sandboxID, '/filesystem.Filesystem/Stat', { + path: '/home/user/dir', + }); + expect(gone.statusCode).toBe(404); + expect(gone.json().code).toBe('not_found'); + }); + + it('streams Process/Start: start, live data, end with the honest exit code', async () => { + const t = testApp(); + const { sandboxID } = await createSandbox(t); + const res = await startCommand(t, sandboxID, 'echo hi'); + expect(res.statusCode).toBe(200); + expect(res.headers['content-type']).toBe('application/connect+json'); + + const frames = parseEnvelopes(res.rawPayload); + const events = frames + .filter((f) => f.flags === 0) + .map((f) => f.json.event as Record); + expect(events[0]).toHaveProperty('start'); + const data = events.find((e) => 'data' in e) as { + data: { stdout: string }; + }; + expect(Buffer.from(data.data.stdout, 'base64').toString('utf8')).toBe( + 'hi\n', + ); + const end = events.at(-1) as { end: { exitCode: number; exited: boolean } }; + expect(end.end).toMatchObject({ exitCode: 0, exited: true }); + // The closing frame: flags 0x02, empty object — a clean end of stream. + expect(frames.at(-1)).toEqual({ flags: 2, json: {} }); + }); + + it('a nonzero exit is a result on the wire, never an error frame', async () => { + const t = testApp(); + const { sandboxID } = await createSandbox(t); + const frames = parseEnvelopes( + (await startCommand(t, sandboxID, 'exit 7')).rawPayload, + ); + const end = frames + .filter((f) => f.flags === 0) + .map((f) => f.json.event as Record) + .find((e) => 'end' in e) as { end: { exitCode: number } }; + expect(end.end.exitCode).toBe(7); + expect(frames.at(-1)).toEqual({ flags: 2, json: {} }); + }); + + it('sandbox-level envs sit under per-command envs', async () => { + const t = testApp(); + const { sandboxID } = await createSandbox(t, { + envVars: { FROM_SANDBOX: 'base', SHADOWED: 'under' }, + }); + const one = parseEnvelopes( + (await startCommand(t, sandboxID, 'printenv FROM_SANDBOX')).rawPayload, + ) + .filter((f) => f.flags === 0) + .map((f) => f.json.event as { data?: { stdout?: string } }) + .find((e) => e.data?.stdout); + expect( + Buffer.from(one?.data?.stdout ?? '', 'base64').toString('utf8'), + ).toBe('base\n'); + + const two = parseEnvelopes( + ( + await startCommand(t, sandboxID, 'printenv SHADOWED', { + envs: { SHADOWED: 'over' }, + }) + ).rawPayload, + ) + .filter((f) => f.flags === 0) + .map((f) => f.json.event as { data?: { stdout?: string } }) + .find((e) => e.data?.stdout); + expect( + Buffer.from(two?.data?.stdout ?? '', 'base64').toString('utf8'), + ).toBe('over\n'); + }); + + it('answers a blown connect-timeout-ms deadline with deadline_exceeded, not an end event', async () => { + const t = testApp(); + const { sandboxID } = await createSandbox(t); + const res = await startCommand(t, sandboxID, 'sleep 2', { + headers: { 'connect-timeout-ms': '300' }, + }); + const last = parseEnvelopes(res.rawPayload).at(-1); + expect(last?.flags).toBe(2); + expect((last?.json.error as { code: string }).code).toBe( + 'deadline_exceeded', + ); + }); + + it('names what it does not support yet: unimplemented, with a hint', async () => { + const t = testApp(); + const { sandboxID } = await createSandbox(t); + const res = await envdRpc(t, sandboxID, '/process.Process/SendInput', {}); + expect(res.statusCode).toBe(501); + expect(res.json().code).toBe('unimplemented'); + expect(res.json().message).toContain('stdin'); + }); + + it('exec on a paused sandbox answers unavailable inside the stream — like a dead E2B box', async () => { + const t = testApp(); + const { sandboxID } = await createSandbox(t); + await control(t, 'POST', `/sandboxes/${sandboxID}/pause`, {}); + const res = await startCommand(t, sandboxID, 'echo hi'); + expect(res.statusCode).toBe(200); + const frames = parseEnvelopes(res.rawPayload); + expect(frames).toHaveLength(1); + expect(frames[0]?.flags).toBe(2); + expect((frames[0]?.json.error as { code: string }).code).toBe( + 'unavailable', + ); + }); +}); diff --git a/packages/server/src/e2b/control.ts b/packages/server/src/e2b/control.ts new file mode 100644 index 0000000..a2a3d88 --- /dev/null +++ b/packages/server/src/e2b/control.ts @@ -0,0 +1,377 @@ +import { randomUUID } from 'node:crypto'; +import type { FastifyError } from 'fastify'; +import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'; +import { z } from 'zod'; +import { + countSandboxes, + createSandbox, + findBySandboxId, + findByUserKey, + listSandboxes, + setDeadline, + setPausedByUser, + touch, +} from '../db/ledger'; +import type { SandboxRow } from '../db/schema'; +import { + freezeSandbox, + releaseSandbox, + stopSandbox, + wakeSandbox, +} from '../lifecycle'; +import { resolvePolicy } from '../policy'; +import type { E2bDeps } from './deps'; +import { + apiError, + E2bError, + ENVD_VERSION, + mintEnvdToken, + verifyApiKey, +} from './protocol'; +import { e2bView } from './view'; + +/** + * The E2B control plane: what the official SDK calls api.e2b.app for. + * Mounted under /e2b/api; the SDK reaches it through its `apiUrl` option. + * Faithful by default — timeouts kill, kill destroys; Dormice's immortality + * is opt-in via metadata.userKey (idempotent create) or autoPause. + */ + +/** The Dormice extension key: metadata.userKey makes create idempotent. */ +const USER_KEY_PATTERN = /^[a-zA-Z0-9._-]{1,64}$/; + +/** Seconds; E2B's default sandbox TTL. */ +const DEFAULT_TIMEOUT_SECONDS = 300; + +const timeoutSchema = z + .number() + .int() + .positive() + .max(30 * 24 * 60 * 60); + +// .loose(): the v2 SDK sends fields we deliberately ignore (secure, +// autoResume, network, ...) — tolerating them is what "two URLs and it +// works" requires; acting on them is tracked feature by feature. +const createBodySchema = z + .object({ + templateID: z.string().optional(), + timeout: timeoutSchema.optional(), + metadata: z.record(z.string(), z.string()).optional(), + envVars: z.record(z.string(), z.string()).optional(), + autoPause: z.boolean().optional(), + }) + .loose(); + +const timeoutBodySchema = z.object({ timeout: timeoutSchema }).loose(); + +const connectBodySchema = z + .object({ timeout: timeoutSchema.optional() }) + .loose(); + +const pauseBodySchema = z + .object({ memory: z.boolean().optional() }) + .loose() + .optional(); + +const listQuerySchema = z.object({ + metadata: z.string().optional(), + state: z.string().optional(), + limit: z.coerce.number().int().positive().max(1000).default(100), + nextToken: z.string().optional(), +}); + +const notFound = (sandboxId: string) => + apiError(404, `sandbox "${sandboxId}" not found`); + +export const e2bControlRoutes: FastifyPluginAsyncZod = async ( + app, + { config, db, executor, locks }, +) => { + app.addHook('onRequest', async (request, reply) => { + const presented = request.headers['x-api-key']; + const key = Array.isArray(presented) ? presented[0] : presented; + if (!verifyApiKey(config.DORMICE_API_TOKEN, key)) { + await reply.code(401).send({ code: 401, message: 'invalid API key' }); + } + }); + + // The E2B error dialect: every non-2xx body is { code, message } — the + // SDK's handleApiError reads both. Scoped here; the native { message } + // dialect stays untouched outside /e2b. + app.setErrorHandler((error: FastifyError | E2bError, request, reply) => { + if (error instanceof E2bError) { + return reply + .code(error.statusCode) + .send({ code: error.code, message: error.message }); + } + const status = error.statusCode ?? 500; + if (status >= 500) { + request.log.error(error, 'e2b control request failed'); + } + // openapi shape: the body's code mirrors the numeric status. + return reply.code(status).send({ code: status, message: error.message }); + }); + app.setNotFoundHandler((request, reply) => { + reply.code(404).send({ + code: 404, + message: `route ${request.method} ${request.url} not found`, + }); + }); + + /** What create and connect answer with. */ + function sessionView(row: SandboxRow) { + return { + sandboxID: row.sandboxId, + templateID: config.DORMICE_BASE_IMAGE ?? 'base', + alias: row.userKey, + envdVersion: ENVD_VERSION, + envdAccessToken: mintEnvdToken(config.DORMICE_API_TOKEN, row.sandboxId), + }; + } + + /** What getInfo and list answer with. */ + function infoView(row: SandboxRow, state: 'running' | 'paused') { + return { + sandboxID: row.sandboxId, + templateID: config.DORMICE_BASE_IMAGE ?? 'base', + alias: row.userKey, + metadata: row.metadata ? JSON.parse(row.metadata) : {}, + state, + startedAt: row.createdAt, + // No deadline (a natively-acquired immortal sandbox) reports a year + // out, so SDK-side "expired?" arithmetic never trips. + endAt: + row.deadlineAt ?? + new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(), + cpuCount: config.DORMICE_SANDBOX_CPUS, + memoryMB: Math.round(config.DORMICE_SANDBOX_MEMORY_GB * 1024), + envdVersion: ENVD_VERSION, + }; + } + + /** + * Looks a sandbox up as the protocol sees it. A row whose kill-deadline + * has passed is protocol-dead — 404, exactly what the SDK expects of a + * timed-out sandbox — even while the scanner's physical teardown is still + * a sweep away. + */ + function findLive(sandboxId: string): { + row: SandboxRow; + state: 'running' | 'paused'; + } { + const row = findBySandboxId(db, sandboxId); + if (!row) throw notFound(sandboxId); + const state = e2bView(row, new Date()); + if (state === 'dead') throw notFound(sandboxId); + return { row, state }; + } + + /** + * connect's TTL rule: only ever extended, never shortened. And only for + * sandboxes the E2B surface created (they always carry onDeadline): + * a natively-acquired sandbox is immortal by its owner's choice, and the + * compat surface must not quietly impose a deadline on it. + */ + function extendDeadline(row: SandboxRow, timeoutSeconds: number): void { + if (row.onDeadline === null) return; + const candidate = Date.now() + timeoutSeconds * 1000; + const current = row.deadlineAt ? Date.parse(row.deadlineAt) : 0; + if (candidate > current) { + setDeadline(db, row.sandboxId, { + deadlineAt: new Date(candidate).toISOString(), + onDeadline: row.onDeadline, + }); + } + } + + app.post( + '/sandboxes', + { schema: { body: createBodySchema } }, + async (request, reply) => { + const body = request.body; + const timeoutSeconds = body.timeout ?? DEFAULT_TIMEOUT_SECONDS; + const requestedKey = body.metadata?.userKey; + if (requestedKey !== undefined && !USER_KEY_PATTERN.test(requestedKey)) { + throw apiError( + 400, + `invalid metadata.userKey "${requestedKey}": expected 1-64 chars of [a-zA-Z0-9._-]`, + ); + } + const userKey = requestedKey ?? `e2b-${randomUUID()}`; + + const row = await locks.run(userKey, async () => { + const existing = findByUserKey(db, userKey); + if (existing && e2bView(existing, new Date()) !== 'dead') { + // The Dormice extension: same key, same sandbox — an acquire in + // E2B clothes. Stored metadata/envs stay (same principle as the + // native policy's "override applies at creation only"); the + // deadline is extended like a connect. + const awake = await wakeSandbox(db, executor, existing); + extendDeadline(awake, timeoutSeconds); + return touch(db, awake.sandboxId); + } + if (existing) { + // Protocol-dead but not yet reaped: E2B semantics say it is gone, + // so finish the job and build fresh under the same key. + await releaseSandbox(db, executor, existing.sandboxId); + } + + if (countSandboxes(db) >= config.DORMICE_MAX_SANDBOXES) { + throw apiError( + 429, + `sandbox limit reached (DORMICE_MAX_SANDBOXES=${config.DORMICE_MAX_SANDBOXES}) — release a sandbox or raise the limit`, + ); + } + const sandboxId = randomUUID(); + await executor.create(sandboxId); + return createSandbox(db, { + sandboxId, + userKey, + nodeId: config.DORMICE_NODE_ID, + policy: resolvePolicy(undefined), + e2b: { + metadata: body.metadata ? JSON.stringify(body.metadata) : null, + envs: + body.envVars && Object.keys(body.envVars).length > 0 + ? JSON.stringify(body.envVars) + : null, + deadlineAt: new Date( + Date.now() + timeoutSeconds * 1000, + ).toISOString(), + // Faithful default: E2B kills at the deadline unless the caller + // opted into pause (lifecycle.onTimeout='pause' on the wire). + onDeadline: body.autoPause ? 'pause' : 'kill', + }, + }); + }); + return reply.code(201).send(sessionView(row)); + }, + ); + + app.post( + '/sandboxes/:id/connect', + { schema: { body: connectBodySchema } }, + async (request, reply) => { + const { id } = request.params as { id: string }; + const before = findLive(id); + const row = await locks.run(before.row.userKey, async () => { + const fresh = findBySandboxId(db, id); + if (!fresh || e2bView(fresh, new Date()) === 'dead') { + throw notFound(id); + } + const awake = await wakeSandbox(db, executor, fresh); + extendDeadline(awake, request.body.timeout ?? DEFAULT_TIMEOUT_SECONDS); + return touch(db, awake.sandboxId); + }); + // 200 = it was already running, 201 = this connect resumed it. + return reply + .code(before.state === 'running' ? 200 : 201) + .send(sessionView(row)); + }, + ); + + app.get('/sandboxes/:id', async (request) => { + const { id } = request.params as { id: string }; + const { row, state } = findLive(id); + return infoView(row, state); + }); + + app.delete('/sandboxes/:id', async (request, reply) => { + const { id } = request.params as { id: string }; + // kill = release, for real: container, disk and row gone. Persistence + // on the E2B surface is "don't kill" (autoPause / userKey), never a + // kill that secretly keeps data. + const { row } = findLive(id); + await locks.run(row.userKey, async () => { + const fresh = findBySandboxId(db, id); + if (!fresh) throw notFound(id); + await releaseSandbox(db, executor, fresh.sandboxId); + }); + return reply.code(204).send(); + }); + + app.post( + '/sandboxes/:id/timeout', + { schema: { body: timeoutBodySchema } }, + async (request, reply) => { + const { id } = request.params as { id: string }; + const { row } = findLive(id); + // setTimeout overwrites in both directions, measured from now — but + // only for E2B-created sandboxes; native ones stay immortal. + if (row.onDeadline !== null) { + setDeadline(db, row.sandboxId, { + deadlineAt: new Date( + Date.now() + request.body.timeout * 1000, + ).toISOString(), + onDeadline: row.onDeadline, + }); + } + return reply.code(204).send(); + }, + ); + + app.post( + '/sandboxes/:id/pause', + { schema: { body: pauseBodySchema } }, + async (request, reply) => { + const { id } = request.params as { id: string }; + const before = findLive(id); + if (before.state === 'paused') { + // The SDK reads a 409 as "already paused" and answers false. + throw apiError(409, 'sandbox is already paused'); + } + await locks.run(before.row.userKey, async () => { + const fresh = findBySandboxId(db, id); + if (!fresh) throw notFound(id); + let current = fresh; + if (current.state === 'active') { + current = await freezeSandbox(db, executor, current.sandboxId); + } + // keepMemory:false maps to stopped: filesystem only, cold boot on + // resume — physically exactly what E2B promises for it. + if (request.body?.memory === false && current.state === 'frozen') { + await stopSandbox(db, executor, current.sandboxId); + } + setPausedByUser(db, fresh.sandboxId, true); + }); + return reply.code(204).send(); + }, + ); + + app.get( + '/v2/sandboxes', + { schema: { querystring: listQuerySchema } }, + async (request, reply) => { + const { metadata, state, limit, nextToken } = request.query; + const wanted = new Set( + (state ? state.split(',') : ['running', 'paused']).map((s) => s.trim()), + ); + const filters = [...new URLSearchParams(metadata ?? '')]; + + const now = new Date(); + const all = listSandboxes(db) + .map((row) => ({ row, state: e2bView(row, now) })) + .filter( + (item): item is { row: SandboxRow; state: 'running' | 'paused' } => { + if (item.state === 'dead' || !wanted.has(item.state)) return false; + if (filters.length === 0) return true; + const meta: Record = item.row.metadata + ? JSON.parse(item.row.metadata) + : {}; + return filters.every(([key, value]) => meta[key] === value); + }, + ) + // Newest first, like the E2B dashboard; stable under pagination. + .sort((a, b) => (a.row.createdAt < b.row.createdAt ? 1 : -1)); + + const offset = Number(nextToken ?? '0') || 0; + const page = all.slice(offset, offset + limit); + if (offset + limit < all.length) { + // The v2 pagination protocol: the cursor lives in this header; its + // absence is what means "no next page". + reply.header('x-next-token', String(offset + limit)); + } + return page.map((item) => infoView(item.row, item.state)); + }, + ); +}; diff --git a/packages/server/src/e2b/deps.ts b/packages/server/src/e2b/deps.ts new file mode 100644 index 0000000..d24f52a --- /dev/null +++ b/packages/server/src/e2b/deps.ts @@ -0,0 +1,12 @@ +import type { Config } from '../config'; +import type { Db } from '../db/db'; +import type { Executor } from '../executor/executor'; +import type { KeyedQueue } from '../keyed-queue'; + +/** What every compat plugin needs — the same four the native routes get. */ +export interface E2bDeps { + config: Config; + db: Db; + executor: Executor; + locks: KeyedQueue; +} diff --git a/packages/server/src/e2b/envd.ts b/packages/server/src/e2b/envd.ts new file mode 100644 index 0000000..50b86a4 --- /dev/null +++ b/packages/server/src/e2b/envd.ts @@ -0,0 +1,686 @@ +import type { Buffer } from 'node:buffer'; +import { resolveSandboxPath } from '@dormice/shared'; +import multipart from '@fastify/multipart'; +import type { FastifyReply, FastifyRequest } from 'fastify'; +import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'; +import { findBySandboxId, touch } from '../db/ledger'; +import type { SandboxRow } from '../db/schema'; +import { startExecHeartbeat } from '../exec-heartbeat'; +import { + DiskFullError, + FileNotFoundError, + NotADirectoryError, + NotAFileError, + type SandboxEntry, +} from '../executor/executor'; +import { wakeSandbox } from '../lifecycle'; +import type { E2bDeps } from './deps'; +import { + connectError, + E2bError, + envelope, + FLAG_END_STREAM, + FLAG_MESSAGE, + readFirstMessage, + verifyEnvdToken, +} from './protocol'; +import { e2bView } from './view'; + +/** + * The envd surface: what the official SDK reaches through its `sandboxUrl` + * option. One origin serves every sandbox — the SDK attaches an + * E2b-Sandbox-Id header to every request, so no wildcard DNS is needed. + * Connect RPC rides the JSON codec (the SDK sets useBinaryFormat: false); + * files ride plain HTTP. The daemon itself plays the role of envd — there + * is no agent inside the container. + */ + +/** In-flight command deadline backstop when the SDK sends no deadline header. */ +const MAX_EXEC_SECONDS = 24 * 60 * 60; + +/** The SDK asks for keepalives via this header; we honor it, capped. */ +const MAX_KEEPALIVE_SECONDS = 30; + +/** Effectively unbounded route body: the disk quota is the real gate. */ +const UNLIMITED_BODY_BYTES = Number.MAX_SAFE_INTEGER; + +/** Synthetic PIDs — the SDK only ever uses them as opaque handles. */ +let nextPid = 1000; + +interface StartRequest { + process?: { + cmd?: string; + args?: string[]; + envs?: Record; + cwd?: string; + }; + pty?: unknown; +} + +/** SandboxEntry -> proto3-JSON EntryInfo (int64 size travels as a string). */ +function entryInfoJson(entry: SandboxEntry) { + return { + name: entry.name, + type: + entry.type === 'file' + ? 'FILE_TYPE_FILE' + : entry.type === 'dir' + ? 'FILE_TYPE_DIRECTORY' + : 'FILE_TYPE_UNSPECIFIED', + path: entry.path, + size: String(entry.sizeBytes), + mode: entry.mode, + permissions: permissionString(entry), + owner: entry.owner, + group: entry.group, + modifiedTime: entry.modifiedTime, + }; +} + +/** Go fs.FileMode style: type char + rwx triplets, display only. */ +function permissionString(entry: SandboxEntry): string { + const chars = + entry.type === 'dir' ? ['d'] : entry.type === 'file' ? ['-'] : ['?']; + for (let shift = 6; shift >= 0; shift -= 3) { + const bits = (entry.mode >> shift) & 0o7; + chars.push( + bits & 4 ? 'r' : '-', + bits & 2 ? 'w' : '-', + bits & 1 ? 'x' : '-', + ); + } + return chars.join(''); +} + +/** Executor file errors -> the Connect codes the SDK maps back to its own taxonomy. */ +function toConnectError(error: unknown): unknown { + if (error instanceof FileNotFoundError) { + return connectError('not_found', error.message); + } + if (error instanceof NotAFileError || error instanceof NotADirectoryError) { + return connectError('invalid_argument', error.message); + } + if (error instanceof DiskFullError) { + return connectError('resource_exhausted', error.message); + } + return error; +} + +export const e2bEnvdRoutes: FastifyPluginAsyncZod = async ( + app, + { config, db, executor, locks }, +) => { + await app.register(multipart, { + limits: { + // The disk quota is the ceiling; a route-level cap would just be a + // second, dishonest one. parts/files stay bounded so a hostile form + // cannot spray unlimited entries. + fileSize: UNLIMITED_BODY_BYTES, + files: 1000, + parts: 2000, + fieldSize: 1024 * 1024, + }, + // busboy strips filenames to their basename by default (a browser-form + // safety net); here the filename IS the destination path — 'notes/x.txt' + // must keep its directory. Traversal is not a concern this opens: + // resolveSandboxPath clamps '..' at the container's own root anyway. + preservePath: true, + }); + + // Connect streaming requests arrive enveloped; keep the raw bytes. + app.addContentTypeParser( + 'application/connect+json', + { parseAs: 'buffer' }, + (_request, payload, done) => done(null, payload), + ); + // Octet-stream uploads pass through as a stream: nothing materializes. + app.addContentTypeParser( + 'application/octet-stream', + (_request, payload, done) => done(null, payload), + ); + + app.setErrorHandler((error, request, reply) => { + if (error instanceof E2bError) { + return reply + .code(error.statusCode) + .send({ code: error.code, message: error.message }); + } + const status = (error as { statusCode?: number }).statusCode ?? 500; + if (status >= 500) { + request.log.error(error, 'e2b envd request failed'); + } + return reply.code(status).send({ + code: status === 400 ? 'invalid_argument' : 'internal', + message: (error as Error).message, + }); + }); + app.setNotFoundHandler((request, reply) => { + reply.code(404).send({ + code: 'not_found', + message: `route ${request.method} ${request.url} not found`, + }); + }); + + /** Every envd request names its sandbox in this header. */ + function sandboxIdOf(request: FastifyRequest): string { + const header = request.headers['e2b-sandbox-id']; + const id = Array.isArray(header) ? header[0] : header; + if (!id) { + throw new E2bError( + 401, + 'unauthenticated', + 'missing E2b-Sandbox-Id header', + ); + } + return id; + } + + // Auth: X-Access-Token must be the HMAC minted for exactly this sandbox. + // /health stays open like real envd's — it is how isRunning() probes. + app.addHook('onRequest', async (request) => { + if (request.method === 'GET' && request.url.endsWith('/health')) return; + const sandboxId = sandboxIdOf(request); + const header = request.headers['x-access-token']; + const token = Array.isArray(header) ? header[0] : header; + if ( + !token || + !verifyEnvdToken(config.DORMICE_API_TOKEN, sandboxId, token) + ) { + throw new E2bError(401, 'unauthenticated', 'invalid envd access token'); + } + }); + + /** + * The protocol-liveness gate. Only a logically-running sandbox serves + * envd traffic: paused and dead answer 502, which the SDK triages into + * its "sandbox timed out / not running" errors — exactly what talking to + * a paused or killed E2B sandbox feels like. + */ + function requireRunningRow(sandboxId: string): SandboxRow { + const row = findBySandboxId(db, sandboxId); + if (!row) { + throw new E2bError(502, 'unavailable', 'sandbox not found'); + } + const state = e2bView(row, new Date()); + if (state !== 'running') { + throw new E2bError( + 502, + 'unavailable', + state === 'paused' ? 'sandbox is paused' : 'sandbox not found', + ); + } + return row; + } + + /** + * Wake under the key's slot, like every native verb: physical wake-ups + * take seconds and must not race the scanner or a release. + */ + async function wakeForUse(sandboxId: string): Promise { + const before = requireRunningRow(sandboxId); + return locks.run(before.userKey, async () => { + const fresh = requireRunningRow(sandboxId); + const awake = await wakeSandbox(db, executor, fresh); + return touch(db, awake.sandboxId); + }); + } + + app.get('/health', async (request, reply) => { + // No auth, no wake — a probe must stay cheap and honest. 502 = not + // running, the exact signal isRunning() keys on. + const header = request.headers['e2b-sandbox-id']; + const id = Array.isArray(header) ? header[0] : header; + const row = id ? findBySandboxId(db, id) : undefined; + if (!row || e2bView(row, new Date()) !== 'running') { + return reply + .code(502) + .send({ code: 'unavailable', message: 'sandbox is not running' }); + } + return reply.code(204).send(); + }); + + // ---- files over plain HTTP ------------------------------------------ + + app.get('/files', async (request, reply) => { + const query = request.query as { path?: string }; + if (!query.path) { + throw new E2bError(400, 'invalid_argument', 'missing path query'); + } + const row = await wakeForUse(sandboxIdOf(request)); + const stopHeartbeat = startExecHeartbeat( + db, + row.sandboxId, + row.freezeAfterSeconds, + ); + try { + let entry: SandboxEntry; + try { + entry = await executor.statEntry(row.sandboxId, query.path); + if (entry.type !== 'file') { + throw new NotAFileError(`not a regular file: ${entry.path}`); + } + } catch (error) { + if (error instanceof FileNotFoundError) { + throw new E2bError(404, 'not_found', error.message); + } + if (error instanceof NotAFileError) { + throw new E2bError(400, 'invalid_argument', error.message); + } + throw error; + } + // Size first, then stream: the SDK needs content-length (an empty + // file is detected by `content-length: 0`), and nothing buffers here. + reply.hijack(); + reply.raw.writeHead(200, { + 'content-type': 'application/octet-stream', + 'content-length': String(entry.sizeBytes), + }); + await executor.readFileStream(row.sandboxId, query.path, (chunk) => { + if (!reply.raw.write(chunk)) { + // Backpressure: the promise pauses the pipe all the way into the + // container until the client drains. + return new Promise((resolve) => + reply.raw.once('drain', resolve), + ); + } + }); + reply.raw.end(); + } catch (error) { + if (reply.raw.headersSent) { + // Mid-stream failure: the body length will not match the announced + // content-length — the client sees a broken transfer, honestly. + request.log.error(error, 'file download broke mid-stream'); + reply.raw.destroy(); + return; + } + throw error; + } finally { + stopHeartbeat(); + try { + touch(db, row.sandboxId); + } catch { + // Released mid-transfer; the transfer's own error tells the story. + } + } + }); + + app.post( + '/files', + { bodyLimit: UNLIMITED_BODY_BYTES }, + async (request, reply) => { + const query = request.query as { path?: string }; + const row = await wakeForUse(sandboxIdOf(request)); + const stopHeartbeat = startExecHeartbeat( + db, + row.sandboxId, + row.freezeAfterSeconds, + ); + try { + const written: Array<{ name: string; type: 'file'; path: string }> = []; + const writeOne = async ( + path: string, + content: NodeJS.ReadableStream, + ) => { + try { + await executor.writeFileStream(row.sandboxId, path, content); + } catch (error) { + if (error instanceof NotAFileError) { + throw new E2bError(400, 'invalid_argument', error.message); + } + if (error instanceof DiskFullError) { + throw new E2bError(507, 'not_enough_space', error.message); + } + throw error; + } + const resolved = resolveSandboxPath(path); + written.push({ + name: resolved.slice(resolved.lastIndexOf('/') + 1), + type: 'file', + path: resolved, + }); + }; + + if (request.isMultipart()) { + // The SDK's default upload shape: one part per file, field name + // `file`, the part's filename carrying the destination path. + for await (const part of request.parts()) { + if (part.type !== 'file') continue; + const destination = part.filename || query.path; + if (!destination) { + throw new E2bError( + 400, + 'invalid_argument', + 'multipart file part has no filename and no ?path= fallback', + ); + } + await writeOne(destination, part.file); + } + } else { + // Octet-stream (the SDK's streaming/gzip shape): path in the query. + if (!query.path) { + throw new E2bError(400, 'invalid_argument', 'missing path query'); + } + await writeOne(query.path, request.body as NodeJS.ReadableStream); + } + return await reply.code(200).send(written); + } finally { + stopHeartbeat(); + try { + touch(db, row.sandboxId); + } catch { + // Released mid-upload; the upload's own result tells the story. + } + } + }, + ); + + // ---- filesystem service (Connect RPC, unary, JSON codec) ------------ + + /** Unary filesystem RPCs run entirely in the key's slot, like native file verbs. */ + async function inSlot( + sandboxId: string, + work: (row: SandboxRow) => Promise, + ): Promise { + const before = requireRunningRow(sandboxId); + return locks.run(before.userKey, async () => { + const fresh = requireRunningRow(sandboxId); + const awake = await wakeSandbox(db, executor, fresh); + const row = touch(db, awake.sandboxId); + try { + return await work(row); + } catch (error) { + throw toConnectError(error); + } + }); + } + + app.post('/filesystem.Filesystem/Stat', async (request) => { + const body = request.body as { path?: string }; + if (!body.path) throw connectError('invalid_argument', 'missing path'); + const entry = await inSlot(sandboxIdOf(request), (row) => + executor.statEntry(row.sandboxId, body.path as string), + ); + return { entry: entryInfoJson(entry) }; + }); + + app.post('/filesystem.Filesystem/ListDir', async (request) => { + const body = request.body as { path?: string; depth?: number }; + if (!body.path) throw connectError('invalid_argument', 'missing path'); + const depth = body.depth ?? 1; + if (depth < 1) { + throw connectError('invalid_argument', 'depth should be at least one'); + } + const entries = await inSlot(sandboxIdOf(request), (row) => + executor.listDir(row.sandboxId, body.path as string, depth), + ); + return { entries: entries.map(entryInfoJson) }; + }); + + app.post('/filesystem.Filesystem/MakeDir', async (request) => { + const body = request.body as { path?: string }; + if (!body.path) throw connectError('invalid_argument', 'missing path'); + const entry = await inSlot(sandboxIdOf(request), async (row) => { + const created = await executor.makeDir( + row.sandboxId, + body.path as string, + ); + if (!created) { + // The SDK reads already_exists as makeDir() === false. + throw connectError( + 'already_exists', + `already exists: ${resolveSandboxPath(body.path as string)}`, + ); + } + return executor.statEntry(row.sandboxId, body.path as string); + }); + return { entry: entryInfoJson(entry) }; + }); + + app.post('/filesystem.Filesystem/Move', async (request) => { + const body = request.body as { source?: string; destination?: string }; + if (!body.source || !body.destination) { + throw connectError('invalid_argument', 'missing source or destination'); + } + const entry = await inSlot(sandboxIdOf(request), (row) => + executor.move( + row.sandboxId, + body.source as string, + body.destination as string, + ), + ); + return { entry: entryInfoJson(entry) }; + }); + + app.post('/filesystem.Filesystem/Remove', async (request) => { + const body = request.body as { path?: string }; + if (!body.path) throw connectError('invalid_argument', 'missing path'); + await inSlot(sandboxIdOf(request), (row) => + executor.remove(row.sandboxId, body.path as string), + ); + return {}; + }); + + // ---- process service ------------------------------------------------- + + app.post('/process.Process/List', async () => { + // No process table yet: honest empty, matching v1's supported surface. + return { processes: [] }; + }); + + app.post('/process.Process/Start', async (request, reply) => { + const message = readFirstMessage(request.body as Buffer) as StartRequest; + if (message.pty) { + return streamError( + reply, + 'unimplemented', + 'PTY is not supported yet — run plain commands via commands.run', + ); + } + const proc = message.process ?? {}; + const args = proc.args ?? []; + // The SDK always sends /bin/bash [-l] -c ; anything else is a + // shape this layer does not speak yet. + let command: string | undefined; + let loginShell = false; + if (args.length === 3 && args[0] === '-l' && args[1] === '-c') { + command = args[2]; + loginShell = true; + } else if (args.length === 2 && args[0] === '-c') { + command = args[1]; + } + if (!proc.cmd?.endsWith('bash') || command === undefined) { + return streamError( + reply, + 'invalid_argument', + 'only shell commands are supported: expected /bin/bash [-l] -c ', + ); + } + + let row: SandboxRow; + try { + row = await wakeForUse(sandboxIdOf(request)); + } catch (error) { + if (error instanceof E2bError) { + // Everything this surface throws carries a Connect string code; a + // numeric one would be a control-plane error that leaked — treat it + // as the sandbox being unreachable. + return streamError( + reply, + typeof error.code === 'string' ? error.code : 'unavailable', + error.message, + ); + } + throw error; + } + + // The wire deadline: the SDK's connect-timeout-ms header. The daemon's + // timer adjudicates the wire (deadline_exceeded, like real envd); the + // in-container `timeout` merely cleans up the process a beat later — + // no guessing whether a 137 "was" a timeout. + const headerMs = Number(request.headers['connect-timeout-ms']); + const deadlineMs = + Number.isFinite(headerMs) && headerMs > 0 + ? headerMs + : MAX_EXEC_SECONDS * 1000; + const timeoutSeconds = Math.min( + Math.ceil(deadlineMs / 1000), + MAX_EXEC_SECONDS, + ); + + reply.hijack(); + reply.raw.writeHead(200, { 'content-type': 'application/connect+json' }); + const write = (buf: Buffer) => + new Promise((resolve) => { + if (!reply.raw.write(buf)) reply.raw.once('drain', resolve); + else resolve(); + }); + + await write( + envelope(FLAG_MESSAGE, { event: { start: { pid: nextPid++ } } }), + ); + + const stopHeartbeat = startExecHeartbeat( + db, + row.sandboxId, + row.freezeAfterSeconds, + ); + // Keepalive events on the interval the SDK asked for — what keeps + // proxies from cutting a silent long command. + const keepaliveSeconds = Math.min( + Number(request.headers['keepalive-ping-interval']) || + MAX_KEEPALIVE_SECONDS, + MAX_KEEPALIVE_SECONDS, + ); + const keepalive = setInterval(() => { + reply.raw.write(envelope(FLAG_MESSAGE, { event: { keepalive: {} } })); + }, keepaliveSeconds * 1000); + + let deadlineTimer: NodeJS.Timeout | undefined; + const deadline = new Promise<'deadline'>((resolve) => { + deadlineTimer = setTimeout(() => resolve('deadline'), deadlineMs); + }); + + try { + const sandboxEnvs: Record = row.envs + ? JSON.parse(row.envs) + : {}; + const handle = await executor.execStream(row.sandboxId, { + command, + loginShell, + timeoutSeconds, + cwd: proc.cwd, + // Sandbox-level envs underneath, per-command envs on top. + env: { ...sandboxEnvs, ...(proc.envs ?? {}) }, + onStdout: (chunk) => + write( + envelope(FLAG_MESSAGE, { + event: { data: { stdout: chunk.toString('base64') } }, + }), + ), + onStderr: (chunk) => + write( + envelope(FLAG_MESSAGE, { + event: { data: { stderr: chunk.toString('base64') } }, + }), + ), + }); + const outcome = await Promise.race([handle.wait(), deadline]); + if (outcome === 'deadline') { + // Real envd answers a blown deadline with the Connect error, not an + // end event; the SDK turns it into its TimeoutError. + await write( + envelope(FLAG_END_STREAM, { + error: { + code: 'deadline_exceeded', + message: `command timed out after ${deadlineMs}ms`, + }, + }), + ); + return; + } + const { exitCode } = outcome; + await write( + envelope(FLAG_MESSAGE, { + event: { + end: { + exitCode, + exited: true, + status: exitCode === 137 ? 'killed' : 'exited', + ...(exitCode === 137 + ? { error: 'command killed (exit 137)' } + : {}), + }, + }, + }), + ); + await write(envelope(FLAG_END_STREAM, {})); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + reply.raw.write( + envelope(FLAG_END_STREAM, { error: { code: 'internal', message } }), + ); + } finally { + clearInterval(keepalive); + clearTimeout(deadlineTimer); + stopHeartbeat(); + try { + // The command itself was the activity: the idle countdown starts + // when it ends, not when it started. + touch(db, row.sandboxId); + } catch { + // Released mid-exec; the stream's own ending tells the story. + } + reply.raw.end(); + } + }); + + /** Streaming endpoints answer errors inside the stream: 200 + end-stream frame. */ + function streamError( + reply: FastifyReply, + code: string, + message: string, + ): void { + reply.hijack(); + reply.raw.writeHead(200, { 'content-type': 'application/connect+json' }); + reply.raw.write(envelope(FLAG_END_STREAM, { error: { code, message } })); + reply.raw.end(); + } + + // ---- honest unimplemented stubs -------------------------------------- + + const unimplemented: Array<[string, string]> = [ + [ + '/process.Process/SendInput', + 'stdin is not supported yet — pass data via files.write and read it from your command', + ], + ['/process.Process/SendSignal', 'signaling a process is not supported yet'], + ['/process.Process/CloseStdin', 'stdin is not supported yet'], + [ + '/process.Process/Connect', + 'reconnecting to a running command is not supported yet', + ], + ['/process.Process/Update', 'PTY is not supported yet'], + ['/process.Process/StreamInput', 'stdin is not supported yet'], + [ + '/filesystem.Filesystem/WatchDir', + 'directory watching is not supported yet — poll files.list instead', + ], + [ + '/filesystem.Filesystem/CreateWatcher', + 'directory watching is not supported yet — poll files.list instead', + ], + [ + '/filesystem.Filesystem/GetWatcherEvents', + 'directory watching is not supported yet', + ], + [ + '/filesystem.Filesystem/RemoveWatcher', + 'directory watching is not supported yet', + ], + ]; + for (const [path, hint] of unimplemented) { + app.post(path, async () => { + throw connectError('unimplemented', hint); + }); + } +}; diff --git a/packages/server/src/e2b/index.ts b/packages/server/src/e2b/index.ts new file mode 100644 index 0000000..24ae927 --- /dev/null +++ b/packages/server/src/e2b/index.ts @@ -0,0 +1,19 @@ +import type { FastifyInstance } from 'fastify'; +import { e2bControlRoutes } from './control'; +import type { E2bDeps } from './deps'; +import { e2bEnvdRoutes } from './envd'; + +/** + * The E2B compatibility layer: the official `e2b` package connects with two + * URLs — apiUrl -> /e2b/api, sandboxUrl -> /e2b/envd — plus + * `e2b_` as its API key. Both scopes carry their own + * auth and their own { code, message } error dialect; the native API's + * Bearer auth and { message } dialect stay outside, untouched. + */ +export async function registerE2bCompat( + app: FastifyInstance, + deps: E2bDeps, +): Promise { + await app.register(e2bControlRoutes, { ...deps, prefix: '/e2b/api' }); + await app.register(e2bEnvdRoutes, { ...deps, prefix: '/e2b/envd' }); +} diff --git a/packages/server/src/e2b/protocol.ts b/packages/server/src/e2b/protocol.ts new file mode 100644 index 0000000..b2b4a0d --- /dev/null +++ b/packages/server/src/e2b/protocol.ts @@ -0,0 +1,127 @@ +import { createHash, createHmac, timingSafeEqual } from 'node:crypto'; + +/** + * The envd version this compat layer claims. Deliberately 0.6.1: at or + * above 0.5.7 the SDK is willing to upload via octet-stream (we support + * it), below 0.6.2 it refuses file-metadata options client-side (we do not + * support xattr metadata) — the SDK blocks what we cannot do instead of us + * accepting data and silently dropping it. Raise only together with the + * features a higher number promises. + */ +export const ENVD_VERSION = '0.6.1'; + +/** + * Errors on the E2B surface carry the E2B wire shape `{ code, message }` — + * a different dialect from the native `{ message }`, adjudicated per scope + * by each compat plugin's own error handler. Two sub-dialects, both + * verified against the official SDK: the control plane's `code` is the + * NUMERIC status (openapi-fetch literally checks `error.code === 404`), + * Connect RPC's `code` is the protocol's string ('not_found', ...). + */ +export class E2bError extends Error { + constructor( + readonly statusCode: number, + readonly code: string | number, + message: string, + ) { + super(message); + } +} + +/** A control-plane error: numeric code mirroring the status, per E2B's openapi. */ +export function apiError(status: number, message: string): E2bError { + return new E2bError(status, status, message); +} + +/** + * Connect RPC error codes -> HTTP status, per the Connect protocol's unary + * mapping. Only the codes this layer actually emits. + */ +const CONNECT_STATUS: Record = { + invalid_argument: 400, + unauthenticated: 401, + not_found: 404, + already_exists: 409, + resource_exhausted: 429, + internal: 500, + unimplemented: 501, + unavailable: 502, +}; + +export function connectError(code: string, message: string): E2bError { + return new E2bError(CONNECT_STATUS[code] ?? 500, code, message); +} + +const sha256 = (value: string) => createHash('sha256').update(value).digest(); + +/** + * The per-sandbox envd access token: HMAC(API token, sandbox id). Stateless + * — any daemon holding the API token can verify it without a lookup, and + * the SDK treats the value as opaque (it just echoes what create returned). + */ +export function mintEnvdToken(apiToken: string, sandboxId: string): string { + return createHmac('sha256', apiToken) + .update(`envd:${sandboxId}`) + .digest('hex'); +} + +export function verifyEnvdToken( + apiToken: string, + sandboxId: string, + presented: string, +): boolean { + // Hash both sides so timingSafeEqual gets equal lengths, constant-time. + return timingSafeEqual( + sha256(presented), + sha256(mintEnvdToken(apiToken, sandboxId)), + ); +} + +/** + * X-API-KEY check. The official SDK formats keys as `e2b_` and our + * hex API token is compliant as `e2b_`; the bare token is accepted + * too — the prefix is the SDK's convention, not a secret. + */ +export function verifyApiKey( + apiToken: string, + presented: string | undefined, +): boolean { + const bare = presented?.startsWith('e2b_') ? presented.slice(4) : presented; + return timingSafeEqual(sha256(bare ?? ''), sha256(apiToken)); +} + +/** Connect streaming envelope flags: 0x00 = message, 0x02 = end of stream. */ +export const FLAG_MESSAGE = 0x00; +export const FLAG_END_STREAM = 0x02; + +/** + * Connect streaming envelope: 1 flag byte + 4-byte big-endian payload + * length + JSON payload. The JSON codec because the SDK is configured with + * useBinaryFormat: false — no protobuf wire format anywhere. + */ +export function envelope(flags: number, json: unknown): Buffer { + const payload = Buffer.from(JSON.stringify(json), 'utf8'); + const head = Buffer.alloc(5); + head.writeUInt8(flags, 0); + head.writeUInt32BE(payload.length, 1); + return Buffer.concat([head, payload]); +} + +/** + * Extracts the first enveloped message of a streaming request body — a + * Start request carries exactly one. + */ +export function readFirstMessage(body: Buffer): unknown { + if (body.length < 5) { + throw connectError('invalid_argument', 'truncated connect envelope'); + } + const length = body.readUInt32BE(1); + if (body.length < 5 + length) { + throw connectError('invalid_argument', 'truncated connect envelope'); + } + try { + return JSON.parse(body.subarray(5, 5 + length).toString('utf8')); + } catch { + throw connectError('invalid_argument', 'connect envelope is not JSON'); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c95d79d..755f11f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: '@dormice/sdk': specifier: workspace:* version: link:../packages/sdk + e2b: + specifier: ^2.31.0 + version: 2.31.0 packages/cli: dependencies: @@ -61,6 +64,9 @@ importers: '@dormice/shared': specifier: workspace:* version: link:../shared + '@fastify/multipart': + specifier: ^10.0.0 + version: 10.0.0 better-sqlite3: specifier: ^12.11.1 version: 12.11.1 @@ -166,6 +172,20 @@ packages: cpu: [x64] os: [win32] + '@bufbuild/protobuf@2.12.1': + resolution: {integrity: sha512-BvAMfS6LrgZiryOAZ4pBYucu4wG/Ei/9o9DZ9akbREnMLbPJiom2i8b9C8IsKErQoiKqVhrerzt3kOT/RrzLHg==} + + '@connectrpc/connect-web@2.0.0-rc.3': + resolution: {integrity: sha512-w88P8Lsn5CCsA7MFRl2e6oLY4J/5toiNtJns/YJrlyQaWOy3RO8pDgkz+iIkG98RPMhj2thuBvsd3Cn4DKKCkw==} + peerDependencies: + '@bufbuild/protobuf': ^2.2.0 + '@connectrpc/connect': 2.0.0-rc.3 + + '@connectrpc/connect@2.0.0-rc.3': + resolution: {integrity: sha512-ARBt64yEyKbanyRETTjcjJuHr2YXorzQo0etyS5+P6oSeW8xEuzajA9g+zDnMcj1hlX2dQE93foIWQGfpru7gQ==} + peerDependencies: + '@bufbuild/protobuf': ^2.2.0 + '@drizzle-team/brocli@0.10.2': resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} @@ -789,6 +809,12 @@ packages: '@fastify/ajv-compiler@4.0.5': resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} + '@fastify/busboy@3.2.0': + resolution: {integrity: sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==} + + '@fastify/deepmerge@3.2.1': + resolution: {integrity: sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==} + '@fastify/error@4.2.0': resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} @@ -801,6 +827,9 @@ packages: '@fastify/merge-json-schemas@0.2.1': resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} + '@fastify/multipart@10.0.0': + resolution: {integrity: sha512-pUx3Z1QStY7E7kwvDTIvB6P+rF5lzP+iqPgZyJyG3yBJVPvQaZxzDHYbQD89rbY0ciXrMOyGi8ezHDVexLvJDA==} + '@fastify/proxy-addr@5.1.0': resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} @@ -821,6 +850,14 @@ packages: engines: {node: '>=6'} hasBin: true + '@isaacs/cliui@9.0.0': + resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==} + engines: {node: '>=18'} + + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -1225,6 +1262,10 @@ packages: avvio@9.2.0: resolution: {integrity: sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -1241,6 +1282,10 @@ packages: bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + brace-expansion@5.0.7: + resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} + engines: {node: 18 || 20 || >=22} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -1265,6 +1310,10 @@ packages: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -1272,6 +1321,10 @@ packages: chownr@1.1.4: resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -1291,6 +1344,9 @@ packages: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} + compare-versions@6.1.1: + resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==} + confbox@0.1.8: resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} @@ -1342,6 +1398,9 @@ packages: resolution: {integrity: sha512-XJgGhoR/CLpqshm4d3L7rzH6t8NgDFUIIpztYlLHIApeJjMZKYJMz2zxPsYxnejq5h3ELYSw/RBsi3t5h7gNTA==} engines: {node: '>= 8.0'} + dockerfile-ast@0.7.1: + resolution: {integrity: sha512-oX/A4I0EhSkGqrFv0YuvPkBUSYp1XiY8O8zAKc8Djglx8ocz+JfOr8gP0ryRMC2myqvDLagmnZaU9ot1vG2ijw==} + dockerode@5.0.1: resolution: {integrity: sha512-avsq/xk4YPIrn0CgleX5bjT9Y8IT1p9PxrNQ++RBQ2WEyFfHCTDsT9kmyxz+H/axnjAwg8wJWEIuPGOUuNupiA==} engines: {node: '>= 14.17'} @@ -1442,6 +1501,10 @@ packages: sqlite3: optional: true + e2b@2.31.0: + resolution: {integrity: sha512-vXQomb16mOk+ufi3YFfN6eURm6eC0NZGMK49PXibfpsmy0azcvLbQuHxCkhgyAk5z5GVfrEUwc08QKVtt6KNFg==} + engines: {node: '>=20.18.1'} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -1545,6 +1608,10 @@ packages: fix-dts-default-cjs-exports@1.0.1: resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} @@ -1567,6 +1634,11 @@ packages: github-from-package@0.0.0: resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + glob@11.1.0: + resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} + engines: {node: 20 || >=22} + hasBin: true + human-signals@8.0.1: resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} engines: {node: '>=18.18.0'} @@ -1603,6 +1675,10 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + jackspeak@4.2.3: + resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==} + engines: {node: 20 || >=22} + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -1711,6 +1787,10 @@ packages: long@5.3.2: resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -1718,9 +1798,21 @@ packages: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} engines: {node: '>=10'} + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + minizlib@3.1.0: + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} + engines: {node: '>= 18'} + mkdirp-classic@0.5.3: resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} @@ -1767,9 +1859,18 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + openapi-fetch@0.14.1: + resolution: {integrity: sha512-l7RarRHxlEZYjMLd/PR0slfMVse2/vvIAGm75/F7J6MlQ8/b9uUQmUF2kCPrQhJqMXSxmYWObVgeYXbFYzZR+A==} + openapi-types@12.1.3: resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} + openapi-typescript-helpers@0.0.15: + resolution: {integrity: sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw==} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + parse-ms@4.0.0: resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} engines: {node: '>=18'} @@ -1782,6 +1883,10 @@ packages: resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} engines: {node: '>=12'} + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -1809,6 +1914,9 @@ packages: pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + platform@1.3.6: + resolution: {integrity: sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==} + postcss-load-config@6.0.1: resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} engines: {node: '>= 18'} @@ -2024,6 +2132,10 @@ packages: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} engines: {node: '>=6'} + tar@7.5.19: + resolution: {integrity: sha512-4LeEWl96twnS2Q7Bz4MGqgazLqO+hJN63GZxXoIqh1T3VweYD997gbU1ItNsQafqqXTXd5WFyFdReLtwvRBNiw==} + engines: {node: '>=18'} + thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -2111,6 +2223,10 @@ packages: undici-types@8.3.0: resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + undici@7.28.0: + resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} + engines: {node: '>=20.18.1'} + undici@8.7.0: resolution: {integrity: sha512-N7iQtfyLhIMOFgQubvmLV26svHpO0bqKnAiWotTQCVKCmWrcGbBotPuW1x+xwYZ2VHdSTVUfPQQnlEt1/LouTQ==} engines: {node: '>=22.19.0'} @@ -2206,6 +2322,12 @@ packages: jsdom: optional: true + vscode-languageserver-textdocument@1.0.12: + resolution: {integrity: sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==} + + vscode-languageserver-types@3.18.0: + resolution: {integrity: sha512-8TsGPNMIMiiBdkORgRSvLjuiEIiAFtO+KssmYWxQ+uSVvlf7RjK8YKCOjPzZ+YA04jXEV7+7LvkSmHkhpNS99g==} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -2227,6 +2349,10 @@ packages: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + yaml@2.9.0: resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} engines: {node: '>= 14.6'} @@ -2286,6 +2412,17 @@ snapshots: '@biomejs/cli-win32-x64@2.5.2': optional: true + '@bufbuild/protobuf@2.12.1': {} + + '@connectrpc/connect-web@2.0.0-rc.3(@bufbuild/protobuf@2.12.1)(@connectrpc/connect@2.0.0-rc.3(@bufbuild/protobuf@2.12.1))': + dependencies: + '@bufbuild/protobuf': 2.12.1 + '@connectrpc/connect': 2.0.0-rc.3(@bufbuild/protobuf@2.12.1) + + '@connectrpc/connect@2.0.0-rc.3(@bufbuild/protobuf@2.12.1)': + dependencies: + '@bufbuild/protobuf': 2.12.1 + '@drizzle-team/brocli@0.10.2': {} '@emnapi/core@1.11.1': @@ -2620,6 +2757,10 @@ snapshots: ajv-formats: 3.0.1(ajv@8.20.0) fast-uri: 3.1.3 + '@fastify/busboy@3.2.0': {} + + '@fastify/deepmerge@3.2.1': {} + '@fastify/error@4.2.0': {} '@fastify/fast-json-stringify-compiler@5.1.0': @@ -2632,6 +2773,14 @@ snapshots: dependencies: dequal: 2.0.3 + '@fastify/multipart@10.0.0': + dependencies: + '@fastify/busboy': 3.2.0 + '@fastify/deepmerge': 3.2.1 + '@fastify/error': 4.2.0 + fastify-plugin: 5.1.0 + secure-json-parse: 4.1.0 + '@fastify/proxy-addr@5.1.0': dependencies: '@fastify/forwarded': 3.0.1 @@ -2666,6 +2815,12 @@ snapshots: protobufjs: 7.6.5 yargs: 17.7.3 + '@isaacs/cliui@9.0.0': {} + + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.3 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -2963,6 +3118,8 @@ snapshots: '@fastify/error': 4.2.0 fastq: 1.20.1 + balanced-match@4.0.4: {} + base64-js@1.5.1: {} bcrypt-pbkdf@1.0.2: @@ -2984,6 +3141,10 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + brace-expansion@5.0.7: + dependencies: + balanced-match: 4.0.4 + buffer-from@1.1.2: {} buffer@5.7.1: @@ -3003,12 +3164,16 @@ snapshots: chai@6.2.2: {} + chalk@5.6.2: {} + chokidar@4.0.3: dependencies: readdirp: 4.1.2 chownr@1.1.4: {} + chownr@3.0.0: {} + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -3025,6 +3190,8 @@ snapshots: commander@4.1.1: {} + compare-versions@6.1.1: {} + confbox@0.1.8: {} consola@3.4.2: {} @@ -3068,6 +3235,11 @@ snapshots: transitivePeerDependencies: - supports-color + dockerfile-ast@0.7.1: + dependencies: + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.18.0 + dockerode@5.0.1: dependencies: '@balena/dockerignore': 1.0.2 @@ -3091,6 +3263,20 @@ snapshots: '@types/better-sqlite3': 7.6.13 better-sqlite3: 12.11.1 + e2b@2.31.0: + dependencies: + '@bufbuild/protobuf': 2.12.1 + '@connectrpc/connect': 2.0.0-rc.3(@bufbuild/protobuf@2.12.1) + '@connectrpc/connect-web': 2.0.0-rc.3(@bufbuild/protobuf@2.12.1)(@connectrpc/connect@2.0.0-rc.3(@bufbuild/protobuf@2.12.1)) + chalk: 5.6.2 + compare-versions: 6.1.1 + dockerfile-ast: 0.7.1 + glob: 11.1.0 + openapi-fetch: 0.14.1 + platform: 1.3.6 + tar: 7.5.19 + undici: 7.28.0 + emoji-regex@8.0.0: {} end-of-stream@1.4.5: @@ -3309,6 +3495,11 @@ snapshots: mlly: 1.8.2 rollup: 4.62.2 + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + fs-constants@1.0.0: {} fsevents@2.3.3: @@ -3327,6 +3518,15 @@ snapshots: github-from-package@0.0.0: {} + glob@11.1.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 4.2.3 + minimatch: 10.2.5 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 2.0.2 + human-signals@8.0.1: {} ieee754@1.2.1: {} @@ -3347,6 +3547,10 @@ snapshots: isexe@2.0.0: {} + jackspeak@4.2.3: + dependencies: + '@isaacs/cliui': 9.0.0 + joycon@3.1.1: {} json-schema-ref-resolver@3.0.0: @@ -3428,14 +3632,26 @@ snapshots: long@5.3.2: {} + lru-cache@11.5.2: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 mimic-response@3.1.0: {} + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.7 + minimist@1.2.8: {} + minipass@7.1.3: {} + + minizlib@3.1.0: + dependencies: + minipass: 7.1.3 + mkdirp-classic@0.5.3: {} mlly@1.8.2: @@ -3479,14 +3695,27 @@ snapshots: dependencies: wrappy: 1.0.2 + openapi-fetch@0.14.1: + dependencies: + openapi-typescript-helpers: 0.0.15 + openapi-types@12.1.3: {} + openapi-typescript-helpers@0.0.15: {} + + package-json-from-dist@1.0.1: {} + parse-ms@4.0.0: {} path-key@3.1.1: {} path-key@4.0.0: {} + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.2 + minipass: 7.1.3 + pathe@2.0.3: {} picocolors@1.1.1: {} @@ -3521,6 +3750,8 @@ snapshots: mlly: 1.8.2 pathe: 2.0.3 + platform@1.3.6: {} + postcss-load-config@6.0.1(postcss@8.5.16)(tsx@4.23.0)(yaml@2.9.0): dependencies: lilconfig: 3.1.3 @@ -3772,6 +4003,14 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + tar@7.5.19: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.3 + minizlib: 3.1.0 + yallist: 5.0.0 + thenify-all@1.6.0: dependencies: thenify: 3.3.1 @@ -3854,6 +4093,8 @@ snapshots: undici-types@8.3.0: {} + undici@7.28.0: {} + undici@8.7.0: {} unicorn-magic@0.3.0: {} @@ -3901,6 +4142,10 @@ snapshots: transitivePeerDependencies: - msw + vscode-languageserver-textdocument@1.0.12: {} + + vscode-languageserver-types@3.18.0: {} + which@2.0.2: dependencies: isexe: 2.0.0 @@ -3920,6 +4165,8 @@ snapshots: y18n@5.0.8: {} + yallist@5.0.0: {} + yaml@2.9.0: {} yargs-parser@21.1.1: {} From 062f2cba57149100d78c21f0e45b47d641758133 Mon Sep 17 00:00:00 2001 From: Annactswell Date: Thu, 9 Jul 2026 18:53:58 +0800 Subject: [PATCH 008/133] e2e drives the official e2b SDK against the daemon: 13 items green on the fake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The compatibility promise verified with the promise's own artifact: the official e2b package (2.31.0 — the supply-chain gate held back 2.32.0, released yesterday), pointed at the daemon by exactly two URLs. Commands with live streaming (timed chunk gap), CommandExitError on nonzero exits, files write/read/list/stat/rename/remove, byte-exact round-trips, metadata lookup through Sandbox.list, real timeout death, onTimeout: pause parking with connect revival, explicit pause/resume, setTimeout extension, and the idempotent-create extension. Two more items run only on the docker executor (whoami/uid 1000, login shell) — the pocket interpreter has no users; the real-machine exam covers them. --- e2e/package.json | 3 +- e2e/src/e2b.test.ts | 277 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 279 insertions(+), 1 deletion(-) create mode 100644 e2e/src/e2b.test.ts diff --git a/e2e/package.json b/e2e/package.json index 84c43f8..4f97a8c 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -10,6 +10,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@dormice/sdk": "workspace:*" + "@dormice/sdk": "workspace:*", + "e2b": "^2.31.0" } } diff --git a/e2e/src/e2b.test.ts b/e2e/src/e2b.test.ts new file mode 100644 index 0000000..a246b55 --- /dev/null +++ b/e2e/src/e2b.test.ts @@ -0,0 +1,277 @@ +import { CommandExitError, Sandbox } from 'e2b'; +import { describe, expect, inject, it } from 'vitest'; + +// The compatibility promise, verified with the promise's own artifact: the +// OFFICIAL e2b package, pointed at the daemon by exactly two URLs (plus its +// API key). Nothing in here imports Dormice code — if these tests pass, a +// real E2B application migrates by changing configuration, not code. +const connection = () => ({ + apiKey: `e2b_${inject('dormiceToken')}`, + apiUrl: `${inject('dormiceEndpoint')}/e2b/api`, + sandboxUrl: `${inject('dormiceEndpoint')}/e2b/envd`, +}); + +function sleep(seconds: number) { + return new Promise((resolve) => setTimeout(resolve, seconds * 1000)); +} + +describe('official e2b SDK against the daemon', () => { + it('creates a sandbox and runs a command', async () => { + const sbx = await Sandbox.create(connection()); + try { + expect(sbx.sandboxId).toBeTruthy(); + const result = await sbx.commands.run('echo hello-dormice'); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('hello-dormice\n'); + } finally { + await sbx.kill(); + } + }); + + it('throws CommandExitError on a nonzero exit — the SDK’s own contract', async () => { + const sbx = await Sandbox.create(connection()); + try { + const error = await sbx.commands.run('exit 3').catch((e) => e); + expect(error).toBeInstanceOf(CommandExitError); + expect(error.exitCode).toBe(3); + } finally { + await sbx.kill(); + } + }); + + it('streams output live through onStdout, not buffered', async () => { + const sbx = await Sandbox.create(connection()); + try { + const chunks: Array<{ text: string; at: number }> = []; + const result = await sbx.commands.run( + 'echo first; sleep 1; echo second', + { + onStdout: (text) => { + chunks.push({ text, at: Date.now() }); + }, + }, + ); + expect(result.stdout).toBe('first\nsecond\n'); + const at = chunks.map((c) => c.at); + // A real gap between chunks is what "streaming" means; a buffered + // implementation delivers everything at once. + expect(Math.max(...at) - Math.min(...at)).toBeGreaterThanOrEqual(500); + } finally { + await sbx.kill(); + } + }); + + it('honors cwd and envs per command, with sandbox envs underneath', async () => { + const sbx = await Sandbox.create({ + ...connection(), + envs: { FROM_SANDBOX: 'base-value' }, + }); + try { + const cwd = await sbx.commands.run('pwd', { cwd: '/tmp' }); + expect(cwd.stdout).toBe('/tmp\n'); + const fromSandbox = await sbx.commands.run('printenv FROM_SANDBOX'); + expect(fromSandbox.stdout).toBe('base-value\n'); + const shadowed = await sbx.commands.run('printenv FROM_SANDBOX', { + envs: { FROM_SANDBOX: 'per-command' }, + }); + expect(shadowed.stdout).toBe('per-command\n'); + } finally { + await sbx.kill(); + } + }); + + it('writes, reads, lists, stats, renames and removes files', async () => { + const sbx = await Sandbox.create(connection()); + try { + const entry = await sbx.files.write( + 'notes/hello.txt', + '你好,Dormice ✓\n', + ); + expect(entry.path).toBe('/home/user/notes/hello.txt'); + expect(await sbx.files.read('notes/hello.txt')).toBe('你好,Dormice ✓\n'); + + // The array form writes a batch. + await sbx.files.write([ + { path: 'notes/a.txt', data: 'a' }, + { path: 'notes/b.txt', data: 'b' }, + ]); + const names = (await sbx.files.list('notes')).map((e) => e.name).sort(); + expect(names).toEqual(['a.txt', 'b.txt', 'hello.txt']); + + expect(await sbx.files.exists('notes/a.txt')).toBe(true); + expect(await sbx.files.exists('notes/void.txt')).toBe(false); + + const info = await sbx.files.getInfo('notes/a.txt'); + expect(info.type).toBe('file'); + expect(Number(info.size)).toBe(1); + + const renamed = await sbx.files.rename('notes/a.txt', 'notes/z.txt'); + expect(renamed.path).toBe('/home/user/notes/z.txt'); + + expect(await sbx.files.makeDir('notes/deep/er')).toBe(true); + expect(await sbx.files.makeDir('notes/deep/er')).toBe(false); + + await sbx.files.remove('notes'); + expect(await sbx.files.exists('notes')).toBe(false); + } finally { + await sbx.kill(); + } + }); + + it('round-trips bytes exactly', async () => { + const sbx = await Sandbox.create(connection()); + try { + const bytes = new Uint8Array(512).map((_, i) => i % 256); + await sbx.files.write('blob.bin', bytes.buffer); + const back = await sbx.files.read('blob.bin', { format: 'bytes' }); + expect(Buffer.from(back).equals(Buffer.from(bytes))).toBe(true); + } finally { + await sbx.kill(); + } + }); + + it('reports info, appears in list, and can be found by metadata', async () => { + const sbx = await Sandbox.create({ + ...connection(), + metadata: { suite: 'e2b-e2e', run: 'metadata-lookup' }, + }); + try { + expect(await sbx.isRunning()).toBe(true); + const info = await sbx.getInfo(); + expect(info.state).toBe('running'); + expect(info.metadata).toMatchObject({ run: 'metadata-lookup' }); + + const found = await Sandbox.list({ + ...connection(), + query: { metadata: { run: 'metadata-lookup' } }, + }).nextItems(); + expect(found.map((s) => s.sandboxId)).toContain(sbx.sandboxId); + } finally { + await sbx.kill(); + } + }); + + it('kill destroys for real: gone from list, connect refuses, second kill is false', async () => { + const sbx = await Sandbox.create(connection()); + await sbx.files.write('doomed.txt', 'will not survive'); + expect(await sbx.kill()).toBe(true); + + expect(await sbx.isRunning()).toBe(false); + await expect(Sandbox.connect(sbx.sandboxId, connection())).rejects.toThrow( + /not found/i, + ); + expect(await Sandbox.kill(sbx.sandboxId, connection())).toBe(false); + }); + + it('a sandbox dies at its timeout — E2B semantics, honored for real', async () => { + const sbx = await Sandbox.create({ ...connection(), timeoutMs: 2_000 }); + expect(await sbx.isRunning()).toBe(true); + // Past the deadline plus a scanner sweep (the e2e daemon sweeps every + // second): the sandbox must be protocol-dead and physically reaped. + await sleep(3.5); + expect(await sbx.isRunning()).toBe(false); + await expect(Sandbox.connect(sbx.sandboxId, connection())).rejects.toThrow( + /not found/i, + ); + }); + + it('onTimeout pause parks the sandbox instead; connect revives it, files intact', async () => { + const sbx = await Sandbox.create({ + ...connection(), + timeoutMs: 2_000, + lifecycle: { onTimeout: 'pause' }, + }); + try { + await sbx.files.write('keep.txt', 'still here'); + await sleep(3.5); + expect(await sbx.isRunning()).toBe(false); + const info = await sbx.getInfo(); + expect(info.state).toBe('paused'); + + const revived = await Sandbox.connect(sbx.sandboxId, connection()); + expect(await revived.isRunning()).toBe(true); + expect(await revived.files.read('keep.txt')).toBe('still here'); + } finally { + await Sandbox.kill(sbx.sandboxId, connection()).catch(() => {}); + } + }, 20_000); + + it('pause and connect: explicit pause parks, connect resumes, files intact', async () => { + const sbx = await Sandbox.create(connection()); + try { + await sbx.files.write('nap.txt', 'through the nap'); + expect(await sbx.pause()).toBe(true); + expect(await sbx.isRunning()).toBe(false); + expect((await sbx.getInfo()).state).toBe('paused'); + // Pausing an already-paused sandbox reports false, not an error. + expect(await sbx.pause()).toBe(false); + + const revived = await Sandbox.connect(sbx.sandboxId, connection()); + expect((await revived.getInfo()).state).toBe('running'); + expect(await revived.files.read('nap.txt')).toBe('through the nap'); + } finally { + await Sandbox.kill(sbx.sandboxId, connection()).catch(() => {}); + } + }); + + it('setTimeout extends the lease', async () => { + const sbx = await Sandbox.create({ ...connection(), timeoutMs: 2_000 }); + try { + await sbx.setTimeout(600_000); + await sleep(3.5); + // Without the extension this sandbox would be dead by now. + expect(await sbx.isRunning()).toBe(true); + } finally { + await sbx.kill(); + } + }); + + it('the Dormice extension: metadata.userKey makes create idempotent, data persists', async () => { + const first = await Sandbox.create({ + ...connection(), + metadata: { userKey: 'e2e-agent-key' }, + }); + try { + await first.files.write('persistent.txt', 'same sandbox every time'); + const second = await Sandbox.create({ + ...connection(), + metadata: { userKey: 'e2e-agent-key' }, + }); + expect(second.sandboxId).toBe(first.sandboxId); + expect(await second.files.read('persistent.txt')).toBe( + 'same sandbox every time', + ); + } finally { + await first.kill(); + } + }); +}); + +// Identity and real-shell behavior only a real container can answer; the +// pocket interpreter has no users or profiles. The docker-mode e2e run on +// the test machine covers these. +describe.runIf(process.env.DORMICE_EXECUTOR === 'docker')( + 'official e2b SDK, docker executor only', + () => { + it('commands run as user (uid 1000), never root', async () => { + const sbx = await Sandbox.create(connection()); + try { + const result = await sbx.commands.run('whoami && id -u'); + expect(result.stdout).toBe('user\n1000\n'); + } finally { + await sbx.kill(); + } + }); + + it('runs a login shell: the image profile is loaded', async () => { + const sbx = await Sandbox.create(connection()); + try { + // `bash -l -c` sources the profile; $HOME comes from the user entry. + const result = await sbx.commands.run('echo $HOME'); + expect(result.stdout).toBe('/home/user\n'); + } finally { + await sbx.kill(); + } + }); + }, +); From ad7f7e69623cd877d99dfe441b96c9e7c29d8216 Mon Sep 17 00:00:00 2001 From: Annactswell Date: Thu, 9 Jul 2026 19:14:03 +0800 Subject: [PATCH 009/133] The disk is born fully owned: mkfs's root-owned lost+found blocked uid-1000 find --- packages/server/src/executor/docker.ts | 7 ++++++- packages/server/src/executor/fake.ts | 5 +++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/server/src/executor/docker.ts b/packages/server/src/executor/docker.ts index de87865..77e3c2a 100644 --- a/packages/server/src/executor/docker.ts +++ b/packages/server/src/executor/docker.ts @@ -981,8 +981,13 @@ export class DockerExecutor implements Executor { // interactive "proceed anyway?" prompt no one is there to answer. await execa('mkfs.ext4', ['-q', '-F', img]); await execa('mount', ['-o', 'loop,discard', img, mnt]); - // The mounted fs root must belong to the in-container user (uid 1000). + // Born fully owned by the in-container user (uid 1000): the fs root and + // mkfs's lost+found — root:0700 otherwise, which uid-1000 file plumbing + // cannot descend into (find at depth ≥ 2 exits 1). Only safe at birth; + // once a sandbox has run, disk content is sandbox-controlled and the + // host must not touch it (ensureMounted deliberately never chowns). await chown(mnt, 1000, 1000); + await chown(path.join(mnt, 'lost+found'), 1000, 1000); } /** Idempotent: mounts the sandbox disk unless it already is mounted. */ diff --git a/packages/server/src/executor/fake.ts b/packages/server/src/executor/fake.ts index a93df0e..1ba1d4d 100644 --- a/packages/server/src/executor/fake.ts +++ b/packages/server/src/executor/fake.ts @@ -22,13 +22,14 @@ import { * Directories every real sandbox is born with, with the owner and mode * reality gives them: the image's skeleton plus lost+found — the mkfs.ext4 * artifact every disk carries in its root (/home/user), which a directory - * listing must therefore show on the fake too. + * listing must therefore show on the fake too. lost+found belongs to the + * user because provisionDisk chowns it at disk birth. */ const BUILTIN_DIRS: Record = { '/': { owner: 'root', mode: 0o755 }, '/home': { owner: 'root', mode: 0o755 }, '/home/user': { owner: 'user', mode: 0o755 }, - '/home/user/lost+found': { owner: 'root', mode: 0o700 }, + '/home/user/lost+found': { owner: 'user', mode: 0o700 }, '/tmp': { owner: 'root', mode: 0o1777 }, }; From ad20a92e8d3e775c46c95ed2473d7b4ca140ae19 Mon Sep 17 00:00:00 2001 From: Annactswell Date: Thu, 9 Jul 2026 20:44:21 +0800 Subject: [PATCH 010/133] dor doctor: 19 read-only host checks, every rule a measured fact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Answers one question: can this host run the Dormice daemon? Checks gate on each other (non-Linux skips everything, no root skips iptables), read effective values over config files (the swappiness=0 factory-default lesson), and name fixes without ever applying them — fixing is install.sh's job. Three probes start a real runsc container by default (fake kernel, metadata BLOCKED, uid 1000); --quick skips them; doctor never pulls images. Any failure exits 1. --- packages/cli/src/doctor.test.ts | 331 ++++++++++++++++++ packages/cli/src/doctor.ts | 576 ++++++++++++++++++++++++++++++++ packages/cli/src/main.ts | 13 + 3 files changed, 920 insertions(+) create mode 100644 packages/cli/src/doctor.test.ts create mode 100644 packages/cli/src/doctor.ts diff --git a/packages/cli/src/doctor.test.ts b/packages/cli/src/doctor.test.ts new file mode 100644 index 0000000..87a3274 --- /dev/null +++ b/packages/cli/src/doctor.test.ts @@ -0,0 +1,331 @@ +import { describe, expect, it } from 'vitest'; +import { + type CheckResult, + type DoctorContext, + type RunResult, + runDoctor, +} from './doctor'; + +const IMAGE = 'dormice-base:20260708'; +const TOKEN = 'a'.repeat(64); + +const ok = (stdout: string): RunResult => ({ ok: true, stdout, stderr: '' }); +const boom = (stderr: string): RunResult => ({ ok: false, stdout: '', stderr }); + +const GOOD_DAEMON_JSON = JSON.stringify({ + 'registry-mirrors': [], + 'log-driver': 'json-file', + 'log-opts': { 'max-size': '10m', 'max-file': '3' }, + icc: false, +}); + +const IPTABLES_GOOD = [ + '-N DOCKER-USER', + '-A DOCKER-USER -d 100.100.100.200/32 -j DROP', + '-A DOCKER-USER -d 169.254.0.0/16 -j DROP', + '-A DOCKER-USER -j RETURN', +].join('\n'); + +const DF_ROOMY = + 'Filesystem 1024-blocks Used Available Capacity Mounted on\n' + + '/dev/vda3 200000000 50000000 150000000 25% /'; + +/** + * A whole healthy host, faked. Tests override single facts to break one + * check at a time — the doctor equivalent of the contract suite's "one + * behavior per question". + */ +function fakeHost( + overrides: { + platform?: string; + nodeVersion?: string; + uid?: number; + env?: Record; + files?: Record; + commands?: Record; + } = {}, +): DoctorContext & { calls: string[] } { + const files: Record = { + '/sys/fs/cgroup/cgroup.controllers': 'cpuset cpu io memory pids', + '/etc/docker/daemon.json': GOOD_DAEMON_JSON, + '/proc/meminfo': 'MemTotal: 30000000 kB\nSwapTotal: 16777212 kB', + '/proc/sys/vm/swappiness': '100\n', + '/etc/iptables/rules.v4': IPTABLES_GOOD, + ...overrides.files, + }; + const commands: Record = { + 'docker version --format {{.Server.Version}}': ok('29.6.1\n'), + 'docker info --format {{json .Runtimes}}': ok( + '{"runc":{"path":"runc"},"runsc":{"path":"/usr/local/bin/runsc"}}', + ), + 'iptables -S DOCKER-USER': ok(IPTABLES_GOOD), + [`docker image inspect ${IMAGE}`]: ok('[{}]'), + 'df -Pk /var/lib/dormice': ok(DF_ROOMY), + [`docker run --rm --runtime=runsc ${IMAGE} uname -r`]: + ok('4.19.0-gvisor\n'), + [`docker run --rm --runtime=runsc ${IMAGE} bash -c timeout 3 bash -c " { + const key = [cmd, ...args].join(' '); + calls.push(key); + return commands[key] ?? boom(`fake host: no answer for "${key}"`); + }, + readTextFile: async (path) => files[path], + }; +} + +const statusOf = (results: Record, id: string) => + results[id]?.status; + +describe('runDoctor on a healthy host', () => { + it('passes everything and declares the host ready', async () => { + const { results, report, failed } = await runDoctor(fakeHost()); + expect(failed).toBe(false); + const statuses = new Set( + Object.values(results).map((result) => result.status), + ); + expect(statuses).toEqual(new Set(['pass'])); + expect(report).toContain('This host looks ready'); + }); + + it('--quick skips the probes without starting any container', async () => { + const ctx = fakeHost(); + const { results, failed } = await runDoctor(ctx, { quick: true }); + expect(failed).toBe(false); + expect(statusOf(results, 'probe-gvisor')).toBe('skip'); + expect(statusOf(results, 'probe-metadata')).toBe('skip'); + expect(statusOf(results, 'probe-image-user')).toBe('skip'); + expect(ctx.calls.filter((call) => call.includes('docker run'))).toEqual([]); + }); +}); + +describe('platform gating', () => { + it('on macOS only the platform-independent checks run', async () => { + const { results, failed } = await runDoctor( + fakeHost({ platform: 'darwin' }), + ); + expect(failed).toBe(true); + expect(statusOf(results, 'os-linux')).toBe('fail'); + expect(statusOf(results, 'node-version')).toBe('pass'); + expect(statusOf(results, 'api-token')).toBe('pass'); + expect(statusOf(results, 'docker-daemon')).toBe('skip'); + expect(statusOf(results, 'probe-gvisor')).toBe('skip'); + }); + + it('without root, the iptables checks are skipped, not falsely red', async () => { + const { results } = await runDoctor(fakeHost({ uid: 1000 })); + expect(statusOf(results, 'root')).toBe('fail'); + expect(statusOf(results, 'metadata-firewall')).toBe('skip'); + expect(statusOf(results, 'metadata-persisted')).toBe('skip'); + }); +}); + +describe('freezing prerequisites', () => { + it('flags a wrong effective swappiness with the sysctl.d fix', async () => { + // The Alibaba Cloud factory setting: their own sysctl.d file ships 0. + const { results, report, failed } = await runDoctor( + fakeHost({ files: { '/proc/sys/vm/swappiness': '0\n' } }), + ); + expect(failed).toBe(true); + expect(results.swappiness).toMatchObject({ + status: 'fail', + fix: expect.stringContaining('99-dormice.conf'), + }); + expect(report).toContain('effective value is 0'); + }); + + it('flags a swapless host as a freezing failure', async () => { + const { results } = await runDoctor( + fakeHost({ files: { '/proc/meminfo': 'SwapTotal: 0 kB' } }), + ); + expect(results.swap).toMatchObject({ + status: 'fail', + detail: expect.stringContaining('nowhere to push'), + }); + }); +}); + +describe('docker configuration', () => { + it('a missing daemon.json fails both icc and log rotation', async () => { + const { results } = await runDoctor( + fakeHost({ files: { '/etc/docker/daemon.json': undefined } }), + ); + expect(statusOf(results, 'icc-disabled')).toBe('fail'); + expect(statusOf(results, 'log-rotation')).toBe('fail'); + }); + + it('accepts the "local" log driver, which rotates by default', async () => { + const { results } = await runDoctor( + fakeHost({ + files: { + '/etc/docker/daemon.json': JSON.stringify({ + icc: false, + 'log-driver': 'local', + }), + }, + }), + ); + expect(statusOf(results, 'log-rotation')).toBe('pass'); + }); +}); + +describe('metadata firewall', () => { + it('names the exact missing DROP rule', async () => { + const { results } = await runDoctor( + fakeHost({ + commands: { + 'iptables -S DOCKER-USER': ok( + '-N DOCKER-USER\n-A DOCKER-USER -d 169.254.0.0/16 -j DROP', + ), + }, + }), + ); + expect(results['metadata-firewall']).toMatchObject({ + status: 'fail', + detail: expect.stringContaining('100.100.100.200'), + }); + }); + + it('rules present but not persisted is a warning, not a failure', async () => { + const { results, failed } = await runDoctor( + fakeHost({ files: { '/etc/iptables/rules.v4': undefined } }), + ); + expect(results['metadata-persisted']).toMatchObject({ + status: 'warn', + fix: expect.stringContaining('iptables-persistent'), + }); + // Warnings alone never flip the exit code. + expect(failed).toBe(false); + }); +}); + +describe('daemon configuration', () => { + it('a missing token warns, a short token fails', async () => { + const unset = await runDoctor( + fakeHost({ env: { DORMICE_EXECUTOR: 'fake' } }), + ); + expect(statusOf(unset.results, 'api-token')).toBe('warn'); + + const short = await runDoctor( + fakeHost({ + env: { DORMICE_API_TOKEN: 'too-short', DORMICE_EXECUTOR: 'fake' }, + }), + ); + expect(short.results['api-token']).toMatchObject({ + status: 'fail', + detail: expect.stringContaining('9 characters'), + }); + }); + + it('docker mode with the default relative DB path fails ahead of the daemon', async () => { + const { results } = await runDoctor( + fakeHost({ + env: { + DORMICE_API_TOKEN: TOKEN, + DORMICE_EXECUTOR: 'docker', + DORMICE_BASE_IMAGE: IMAGE, + DORMICE_DATA_DIR: '/var/lib/dormice', + // DORMICE_DB_PATH unset: the default is relative. + }, + }), + ); + expect(results['absolute-paths']).toMatchObject({ + status: 'fail', + detail: expect.stringContaining('DORMICE_DB_PATH=data/dormice.db'), + }); + }); + + it('without a base image, image check and probes skip with directions', async () => { + const { results, failed } = await runDoctor( + fakeHost({ env: { DORMICE_API_TOKEN: TOKEN } }), + ); + expect(results['base-image']).toMatchObject({ + status: 'skip', + detail: expect.stringContaining('DORMICE_BASE_IMAGE'), + }); + expect(statusOf(results, 'probe-gvisor')).toBe('skip'); + expect(statusOf(results, 'absolute-paths')).toBe('skip'); + expect(failed).toBe(false); + }); + + it('scarce disk space warns with the measured number', async () => { + const { results } = await runDoctor( + fakeHost({ + commands: { + 'df -Pk /var/lib/dormice': ok( + 'Filesystem 1024-blocks Used Available Capacity Mounted on\n' + + '/dev/vda3 200000000 195000000 5242880 98% /', + ), + }, + }), + ); + expect(results['disk-space']).toMatchObject({ + status: 'warn', + detail: expect.stringContaining('5.0 GiB'), + }); + }); +}); + +describe('container probes', () => { + it('a real host kernel in the probe means gVisor is not isolating', async () => { + const { results, failed } = await runDoctor( + fakeHost({ + commands: { + [`docker run --rm --runtime=runsc ${IMAGE} uname -r`]: ok( + '6.8.0-124-generic\n', + ), + }, + }), + ); + expect(failed).toBe(true); + expect(results['probe-gvisor']).toMatchObject({ + status: 'fail', + detail: expect.stringContaining('6.8.0-124-generic'), + }); + }); + + it('LEAK from the metadata probe is a hard failure with the iptables fix', async () => { + const { results } = await runDoctor( + fakeHost({ + commands: { + [`docker run --rm --runtime=runsc ${IMAGE} bash -c timeout 3 bash -c " { + const { results } = await runDoctor( + fakeHost({ + commands: { + [`docker run --rm --runtime=runsc ${IMAGE} id -u`]: ok('0\n'), + }, + }), + ); + expect(results['probe-image-user']).toMatchObject({ + status: 'fail', + detail: expect.stringContaining('uid 0'), + }); + }); +}); diff --git a/packages/cli/src/doctor.ts b/packages/cli/src/doctor.ts new file mode 100644 index 0000000..8f2d6a0 --- /dev/null +++ b/packages/cli/src/doctor.ts @@ -0,0 +1,576 @@ +import { execFile } from 'node:child_process'; +import { readFile } from 'node:fs/promises'; +import { isAbsolute } from 'node:path'; + +/** + * `dor doctor` answers one question: can this host run the Dormice daemon? + * + * Every check below encodes a fact measured on a real machine (the pit + * list), not a hypothesis. Two disciplines: + * + * - Read-only. Doctor names the fix but never applies it — a checker that + * quietly mutates the system is the "silent self-healing" the design + * rules forbid. Fixing is install.sh's job. + * - Effective values over config files. The lesson: some clouds ship + * vm.swappiness=0 in their own sysctl.d file, so "our config file + * exists" proves nothing. Wherever the live value is readable, read it. + */ + +export interface RunResult { + ok: boolean; + stdout: string; + stderr: string; +} + +/** + * Everything a check may touch, injected so tests can fake a whole host. + * Same discipline as FakeExecutor: the fake is the permanent test double, + * the real machine is the final judge. + */ +export interface DoctorContext { + platform: string; + nodeVersion: string; + uid: number | undefined; + env: Record; + run( + cmd: string, + args: string[], + opts?: { timeoutMs?: number }, + ): Promise; + /** undefined when the file is missing or unreadable. */ + readTextFile(path: string): Promise; +} + +export function realDoctorContext(): DoctorContext { + return { + platform: process.platform, + nodeVersion: process.version, + uid: process.getuid?.(), + env: process.env, + run: (cmd, args, opts) => + new Promise((resolve) => { + execFile( + cmd, + args, + // SIGKILL on timeout: a hung dockerd must not hang doctor too. + { + timeout: opts?.timeoutMs ?? 10_000, + killSignal: 'SIGKILL', + maxBuffer: 1024 * 1024, + }, + (error, stdout, stderr) => { + resolve({ + ok: error === null, + stdout, + stderr: stderr || (error ? error.message : ''), + }); + }, + ); + }), + readTextFile: async (path) => { + try { + return await readFile(path, 'utf8'); + } catch { + return undefined; + } + }, + }; +} + +export type CheckStatus = 'pass' | 'fail' | 'warn' | 'skip'; + +export interface CheckResult { + status: CheckStatus; + detail: string; + fix?: string; +} + +interface DoctorCheck { + id: string; + title: string; + /** Checks that must pass first; otherwise this one is skipped. */ + needs?: string[]; + /** Starts a real container — skipped by --quick. */ + probe?: boolean; + run(ctx: DoctorContext): Promise; +} + +const pass = (detail: string): CheckResult => ({ status: 'pass', detail }); +const fail = (detail: string, fix?: string): CheckResult => ({ + status: 'fail', + detail, + fix, +}); +const warn = (detail: string, fix?: string): CheckResult => ({ + status: 'warn', + detail, + fix, +}); +const skip = (detail: string): CheckResult => ({ status: 'skip', detail }); + +const DAEMON_JSON = '/etc/docker/daemon.json'; +const RULES_V4 = '/etc/iptables/rules.v4'; +const METADATA_IP = '100.100.100.200'; +const METADATA_RANGE = '169.254.0.0/16'; + +async function daemonJson( + ctx: DoctorContext, +): Promise | undefined> { + const text = await ctx.readTextFile(DAEMON_JSON); + if (text === undefined) return undefined; + try { + return JSON.parse(text) as Record; + } catch { + return undefined; + } +} + +const baseImage = (ctx: DoctorContext) => ctx.env.DORMICE_BASE_IMAGE; + +const CHECKS: DoctorCheck[] = [ + { + id: 'os-linux', + title: 'operating system', + run: async (ctx) => + ctx.platform === 'linux' + ? pass('Linux') + : fail( + `the daemon needs Linux (loop mounts, cgroups, gVisor) — found "${ctx.platform}"`, + ), + }, + { + id: 'node-version', + title: 'Node.js version', + run: async (ctx) => { + const major = Number(ctx.nodeVersion.replace(/^v/, '').split('.')[0]); + return major >= 22 + ? pass(`${ctx.nodeVersion} (>= 22)`) + : fail(`${ctx.nodeVersion} is below the required Node 22`); + }, + }, + { + id: 'root', + title: 'running as root', + needs: ['os-linux'], + run: async (ctx) => + ctx.uid === 0 + ? pass('uid 0') + : fail( + `running as uid ${ctx.uid ?? 'unknown'} — loop mounts, mkfs and cgroup writes need root`, + 'run doctor and the daemon as root', + ), + }, + { + id: 'cgroup-v2', + title: 'cgroup v2 memory controller', + needs: ['os-linux'], + run: async (ctx) => { + const controllers = await ctx.readTextFile( + '/sys/fs/cgroup/cgroup.controllers', + ); + if (controllers === undefined) { + return fail( + 'cgroup v2 unified hierarchy is not mounted — freezing needs memory.reclaim, a cgroup v2 file', + 'boot with systemd.unified_cgroup_hierarchy=1 (default on Ubuntu 22.04+)', + ); + } + return controllers.includes('memory') + ? pass('memory controller available') + : fail('cgroup v2 is mounted but the memory controller is missing'); + }, + }, + { + id: 'docker-daemon', + title: 'Docker daemon', + needs: ['os-linux'], + run: async (ctx) => { + const res = await ctx.run('docker', [ + 'version', + '--format', + '{{.Server.Version}}', + ]); + return res.ok + ? pass(`server ${res.stdout.trim()}`) + : fail( + `dockerd is unreachable: ${res.stderr.trim()}`, + 'install Docker and start dockerd (https://docs.docker.com/engine/install/)', + ); + }, + }, + { + id: 'gvisor-runtime', + title: 'gVisor runtime (runsc)', + needs: ['docker-daemon'], + run: async (ctx) => { + const res = await ctx.run('docker', [ + 'info', + '--format', + '{{json .Runtimes}}', + ]); + if (!res.ok) return fail(`docker info failed: ${res.stderr.trim()}`); + return res.stdout.includes('"runsc"') + ? pass('runsc registered as a Docker runtime') + : fail( + 'runsc is not a registered Docker runtime — sandboxes would share the host kernel', + 'install gVisor, then `runsc install` and restart docker (https://gvisor.dev/docs/user_guide/install/)', + ); + }, + }, + { + id: 'icc-disabled', + title: 'inter-container traffic off', + needs: ['os-linux'], + run: async (ctx) => { + const config = await daemonJson(ctx); + if (config === undefined) { + return fail( + `${DAEMON_JSON} is missing or not valid JSON — Docker defaults to icc: true, so one sandbox can scan another`, + `set "icc": false in ${DAEMON_JSON} and restart docker`, + ); + } + return config.icc === false + ? pass(`"icc": false in ${DAEMON_JSON}`) + : fail( + `"icc" is not false in ${DAEMON_JSON} — one sandbox can scan another`, + `set "icc": false in ${DAEMON_JSON} and restart docker`, + ); + }, + }, + { + id: 'log-rotation', + title: 'container log rotation', + needs: ['os-linux'], + run: async (ctx) => { + const config = await daemonJson(ctx); + const logOpts = config?.['log-opts'] as + | Record + | undefined; + // The "local" driver rotates by default; json-file needs max-size. + if (config?.['log-driver'] === 'local' || logOpts?.['max-size']) { + return pass( + logOpts?.['max-size'] + ? `max-size ${logOpts['max-size']}` + : 'log-driver "local" rotates by default', + ); + } + return fail( + 'container stdout logs grow without bound — a long-lived sandbox fills the disk over months', + `set "log-driver": "json-file", "log-opts": {"max-size": "10m", "max-file": "3"} in ${DAEMON_JSON} and restart docker`, + ); + }, + }, + { + id: 'swap', + title: 'swap present', + needs: ['os-linux'], + run: async (ctx) => { + const meminfo = (await ctx.readTextFile('/proc/meminfo')) ?? ''; + const kb = Number(/^SwapTotal:\s*(\d+) kB/m.exec(meminfo)?.[1] ?? 0); + return kb > 0 + ? pass(`${(kb / 1024 / 1024).toFixed(1)} GiB`) + : fail( + 'no swap — freezing has nowhere to push sandbox memory (measured: 0 bytes reclaimed without it)', + "fallocate -l 16G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile, then add '/swapfile none swap sw 0 0' to /etc/fstab", + ); + }, + }, + { + id: 'swappiness', + title: 'vm.swappiness = 100', + needs: ['os-linux'], + run: async (ctx) => { + // The live value, on purpose: some clouds ship swappiness=0 in their + // own sysctl.d file, so a config file existing proves nothing. + const raw = await ctx.readTextFile('/proc/sys/vm/swappiness'); + const value = raw?.trim(); + if (value === undefined) + return fail('cannot read /proc/sys/vm/swappiness'); + return value === '100' + ? pass('100 (effective value)') + : fail( + `effective value is ${value} — below 100 the kernel refuses to swap gVisor's shmem-held sandbox memory (measured at 60: 0 bytes reclaimed)`, + "echo 'vm.swappiness=100' > /etc/sysctl.d/99-dormice.conf && sysctl --system", + ); + }, + }, + { + id: 'metadata-firewall', + title: 'cloud metadata firewall', + needs: ['root', 'docker-daemon'], + run: async (ctx) => { + const res = await ctx.run('iptables', ['-S', 'DOCKER-USER']); + if (!res.ok) { + return fail(`cannot read the DOCKER-USER chain: ${res.stderr.trim()}`); + } + const dropped = (needle: string) => + res.stdout + .split('\n') + .some( + (line) => line.includes(`-d ${needle}`) && line.includes('-j DROP'), + ); + const missing = [ + !dropped(METADATA_RANGE) && METADATA_RANGE, + !dropped(METADATA_IP) && !dropped(`${METADATA_IP}/32`) && METADATA_IP, + ].filter(Boolean); + return missing.length === 0 + ? pass(`DROP rules for ${METADATA_RANGE} and ${METADATA_IP}`) + : fail( + `no DROP rule for ${missing.join(' or ')} — a sandbox can steal cloud credentials from the metadata service`, + `iptables -I DOCKER-USER -d ${METADATA_RANGE} -j DROP && iptables -I DOCKER-USER -d ${METADATA_IP} -j DROP`, + ); + }, + }, + { + id: 'metadata-persisted', + title: 'firewall rules persisted', + needs: ['metadata-firewall'], + run: async (ctx) => { + const rules = await ctx.readTextFile(RULES_V4); + return rules?.includes(METADATA_IP) && rules.includes('169.254') + ? pass(`both rules in ${RULES_V4}`) + : warn( + 'the DROP rules live only in memory — a reboot silently drops them', + 'apt-get install -y iptables-persistent && netfilter-persistent save', + ); + }, + }, + { + id: 'api-token', + title: 'DORMICE_API_TOKEN', + run: async (ctx) => { + const token = ctx.env.DORMICE_API_TOKEN; + if (token === undefined || token === '') { + return warn( + 'not set in this shell — the daemon refuses to start without it', + 'export DORMICE_API_TOKEN=$(openssl rand -hex 32)', + ); + } + return token.length >= 32 + ? pass(`set (${token.length} characters)`) + : fail( + `set but only ${token.length} characters — the daemon requires at least 32`, + 'export DORMICE_API_TOKEN=$(openssl rand -hex 32)', + ); + }, + }, + { + id: 'base-image', + title: 'base image available', + needs: ['docker-daemon'], + run: async (ctx) => { + const image = baseImage(ctx); + if (!image) { + return skip( + 'DORMICE_BASE_IMAGE is not set — set it to check the image and enable the container probes', + ); + } + const res = await ctx.run('docker', ['image', 'inspect', image]); + return res.ok + ? pass(`${image} is present locally`) + : fail( + `${image} is not present locally`, + 'build it from images/Dockerfile — doctor never pulls images itself', + ); + }, + }, + { + id: 'absolute-paths', + title: 'docker-mode paths absolute', + run: async (ctx) => { + if (ctx.env.DORMICE_EXECUTOR !== 'docker') { + return skip('DORMICE_EXECUTOR is not "docker"'); + } + // Same resolution the daemon does: unset falls back to the default, + // and the default DB path is relative — which docker mode refuses. + const paths = { + DORMICE_DB_PATH: ctx.env.DORMICE_DB_PATH ?? 'data/dormice.db', + DORMICE_DATA_DIR: ctx.env.DORMICE_DATA_DIR ?? '/var/lib/dormice', + }; + const relative = Object.entries(paths) + .filter(([, value]) => !isAbsolute(value)) + .map(([name, value]) => `${name}=${value}`); + return relative.length === 0 + ? pass('DORMICE_DB_PATH and DORMICE_DATA_DIR are absolute') + : fail( + `${relative.join(', ')} — a relative path depends on the start directory, and a wrong start directory opens an empty ledger next to real sandboxes`, + 'export DORMICE_DB_PATH=/var/lib/dormice/dormice.db (or another absolute path)', + ); + }, + }, + { + id: 'disk-space', + title: 'free disk space', + needs: ['os-linux'], + run: async (ctx) => { + const dir = ctx.env.DORMICE_DATA_DIR ?? '/var/lib/dormice'; + let res = await ctx.run('df', ['-Pk', dir]); + let note = ''; + if (!res.ok) { + // The data dir may not exist before the first daemon start; the + // root filesystem is the honest stand-in. + res = await ctx.run('df', ['-Pk', '/']); + note = ` (${dir} does not exist yet; measured /)`; + } + const kb = Number(res.stdout.trim().split('\n').at(-1)?.split(/\s+/)[3]); + if (!res.ok || Number.isNaN(kb)) { + return warn(`cannot measure free space: ${res.stderr.trim()}`); + } + const gib = kb / 1024 / 1024; + return gib >= 10 + ? pass(`${gib.toFixed(1)} GiB free for ${dir}${note}`) + : warn( + `only ${gib.toFixed(1)} GiB free for ${dir}${note} — a full disk means even the ledger cannot write`, + ); + }, + }, + { + id: 'probe-gvisor', + title: 'probe: gVisor fake kernel', + needs: ['gvisor-runtime', 'base-image'], + probe: true, + run: async (ctx) => { + const image = baseImage(ctx) as string; + const res = await ctx.run( + 'docker', + ['run', '--rm', '--runtime=runsc', image, 'uname', '-r'], + { timeoutMs: 60_000 }, + ); + if (!res.ok) + return fail(`the probe container failed: ${res.stderr.trim()}`); + const kernel = res.stdout.trim(); + return kernel.includes('gvisor') + ? pass(`sandbox kernel: ${kernel}`) + : fail( + `sandbox reports kernel "${kernel}" — that is a real kernel, gVisor is not isolating`, + 'reinstall gVisor (`runsc install`) and restart docker', + ); + }, + }, + { + id: 'probe-metadata', + title: 'probe: metadata blocked inside', + needs: ['gvisor-runtime', 'base-image'], + probe: true, + run: async (ctx) => { + const res = await ctx.run( + 'docker', + [ + 'run', + '--rm', + '--runtime=runsc', + baseImage(ctx) as string, + 'bash', + '-c', + `timeout 3 bash -c " { + const res = await ctx.run( + 'docker', + [ + 'run', + '--rm', + '--runtime=runsc', + baseImage(ctx) as string, + 'id', + '-u', + ], + { timeoutMs: 60_000 }, + ); + if (!res.ok) + return fail(`the probe container failed: ${res.stderr.trim()}`); + const uid = res.stdout.trim(); + return uid === '1000' + ? pass('uid 1000 (non-root)') + : fail( + `the image runs as uid ${uid} — sandboxes must run as a non-root uid-1000 user`, + 'rebuild the image with a uid-1000 user (see images/Dockerfile)', + ); + }, + }, +]; + +const ICONS: Record = { + pass: '✔', + fail: '✖', + warn: '⚠', + skip: '·', +}; + +export interface DoctorReport { + results: Record; + report: string; + failed: boolean; +} + +export async function runDoctor( + ctx: DoctorContext, + opts: { quick?: boolean } = {}, +): Promise { + const results: Record = {}; + const titles: Record = {}; + for (const check of CHECKS) titles[check.id] = check.title; + + for (const check of CHECKS) { + if (opts.quick && check.probe) { + results[check.id] = skip('container probes skipped (--quick)'); + continue; + } + const blocker = check.needs?.find((id) => results[id]?.status !== 'pass'); + if (blocker !== undefined) { + results[check.id] = skip(`needs "${titles[blocker]}" to pass first`); + continue; + } + results[check.id] = await check.run(ctx); + } + + const width = Math.max(...CHECKS.map((check) => check.title.length)); + const lines = [ + 'Dormice doctor — can this host run the daemon? (read-only, fixes are named, never applied)', + '', + ]; + for (const check of CHECKS) { + const result = results[check.id] as CheckResult; + lines.push( + `${ICONS[result.status]} ${check.title.padEnd(width)} ${result.detail}`, + ); + if (result.fix && result.status !== 'pass') { + lines.push(` ${' '.repeat(width)} fix: ${result.fix}`); + } + } + + const count = (status: CheckStatus) => + Object.values(results).filter((result) => result.status === status).length; + const failed = count('fail') > 0; + lines.push( + '', + `${count('pass')} passed, ${count('fail')} failed, ${count('warn')} warnings, ${count('skip')} skipped.`, + ); + lines.push( + failed + ? 'Not ready — fix the ✖ items above and run doctor again.' + : count('warn') > 0 + ? 'No failures — review the ⚠ items above.' + : 'This host looks ready to run the Dormice daemon.', + ); + + return { results, report: lines.join('\n'), failed }; +} diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index be41fd0..6375798 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -15,11 +15,24 @@ import { sandboxPush, sandboxRelease, } from './commands'; +import { realDoctorContext, runDoctor } from './doctor'; const program = new Command('dor').description( 'Command-line tool for a Dormice daemon (also installed as `dormice`)', ); +program + .command('doctor') + .description('Check whether this host can run the Dormice daemon (read-only)') + .option('--quick', 'skip the probes that start a real sandbox container') + .action(async (opts: { quick?: boolean }) => { + const { report, failed } = await runDoctor(realDoctorContext(), { + quick: opts.quick, + }); + console.log(report); + if (failed) process.exitCode = 1; + }); + const sandbox = program .command('sandbox') .description('Inspect and manage sandboxes'); From 21105f78e7bbf90084b8489ede399c9828da9d28 Mon Sep 17 00:00:00 2001 From: Annactswell Date: Thu, 9 Jul 2026 21:22:16 +0800 Subject: [PATCH 011/133] install.sh turns a bare host into a running daemon, doctor has the last word deploy/install.sh automates every fix doctor names: pinned Node and gVisor (sha512 of the binaries, not the sidecar files), Docker with daemon.json merged key by key, swap and swappiness=100, the metadata DROP rules persisted, code cloned to /opt/dormice and built, the base image, /etc/dormice/env with a generated token that re-runs never rotate, and a crash-only systemd unit. Every step checks before it acts, so a re-run is an upgrade. The install only succeeds if the daemon answers /healthz and dor doctor exits green. CI now shellchecks the one script users pipe into bash. --- .github/workflows/ci.yml | 3 + README.md | 23 ++- deploy/dormice.service | 21 +++ deploy/install.sh | 355 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 398 insertions(+), 4 deletions(-) create mode 100644 deploy/dormice.service create mode 100644 deploy/install.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 50f83dc..6c01dfc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,6 +45,9 @@ jobs: - run: pnpm build - run: pnpm typecheck - run: pnpm lint + # The installer is the one shell script users pipe into bash — it gets + # the same static gate the TypeScript gets from Biome. + - run: shellcheck deploy/install.sh - run: pnpm test # This is a public repository: a leaked credential anywhere in git history diff --git a/README.md b/README.md index b09cc13..059f5b5 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **The SQLite of agent sandboxes** — a self-hosted sandbox platform for AI agents. One machine, sandboxes that live forever, idle costs nothing. -> **Status: early development.** The daemon, its lifecycle engine, the SDK, and the real Docker + gVisor executor work end to end — the full create → freeze → stop → wake cycle passes on real infrastructure. Running code inside a sandbox and the E2B-compatible API are the next milestones. Nothing here is ready for production yet. +> **Status: early development.** The daemon, its lifecycle engine, the SDK, the CLI, the real Docker + gVisor executor, and the E2B-compatible API work end to end — the full create → freeze → stop → wake cycle, command execution, file I/O, and the official `e2b` SDK against real infrastructure. Nothing here is ready for production yet. ## The idea @@ -11,14 +11,29 @@ Cloud sandbox platforms charge for every second a sandbox exists, so their sandb - **`acquireSandbox(userKey)` is the entire mental model.** Idempotent: the same key always comes back to the same sandbox, whatever state it was in. No sandbox → create; frozen → wake; stopped → start; archived → restore. - **Idle is free.** Sandboxes cool down on their own — `active → frozen → stopped → archived` — one rung at a time, and any acquire brings them back. - **Deploys like a single binary.** One daemon, one SQLite ledger, one port. No Kubernetes, no external database. -- **E2B compatibility is on the roadmap**: the goal is that the official `e2b` SDK works against Dormice by changing two URLs. +- **E2B compatible**: the official `e2b` SDK works against Dormice by changing two URLs (`apiUrl`, `sandboxUrl`). + +## Install + +One command on a bare Ubuntu/Debian x86_64 host (as root): + +```sh +curl -fsSL https://raw.githubusercontent.com/BitMiracle-AI/Dormice/main/deploy/install.sh | bash +``` + +Behind a slow connection to the usual sources, add `-s -- --mirror cn`. +The installer is idempotent — re-running it upgrades the code and repairs +drift, and never rotates your API token. It ends by running `dor doctor`, +19 read-only checks (including three that boot a real gVisor container) +that decide whether the install actually succeeded; `dor doctor` can be +re-run on its own at any time. ## Host prerequisites (docker executor) The daemon itself runs anywhere Node 22+ runs, with an in-memory fake executor for development. Running **real** sandboxes needs a Linux host -prepared ahead of time — an `install.sh` and a `dor doctor` will automate -these checks later, but today they are the operator's job: +prepared as above — `install.sh` automates all of it and `dor doctor` +verifies it, but these are the facts underneath: - **Docker + gVisor (`runsc`)**, and root (loop mounts, cgroup writes). - **Swap, and `vm.swappiness=100`.** Freezing squeezes an idle sandbox's diff --git a/deploy/dormice.service b/deploy/dormice.service new file mode 100644 index 0000000..5dd8225 --- /dev/null +++ b/deploy/dormice.service @@ -0,0 +1,21 @@ +# Dormice daemon. Installed by deploy/install.sh; configuration lives in +# /etc/dormice/env (full-line comments only there — systemd's EnvironmentFile +# treats an inline comment as part of the value). +[Unit] +Description=Dormice daemon (agent sandbox control plane) +Wants=network-online.target +After=network-online.target docker.service +Requires=docker.service + +[Service] +ExecStart=/usr/local/bin/node /opt/dormice/packages/server/dist/main.js +EnvironmentFile=/etc/dormice/env +# Crash-only: the daemon reconciles its ledger against Docker on every start, +# so restarting it is always safe — and always the right move. +Restart=always +RestartSec=3 +# One process, one writer. The SQLite ledger's exclusive lock enforces this; +# never wrap the daemon in a process manager that forks workers. + +[Install] +WantedBy=multi-user.target diff --git a/deploy/install.sh b/deploy/install.sh new file mode 100644 index 0000000..ebfa3d3 --- /dev/null +++ b/deploy/install.sh @@ -0,0 +1,355 @@ +#!/usr/bin/env bash +# Dormice installer: turns a bare Ubuntu/Debian x86_64 host into a running +# Dormice daemon, then proves it by running `dor doctor`. +# +# curl -fsSL https://raw.githubusercontent.com/BitMiracle-AI/Dormice/main/deploy/install.sh | bash +# +# Flags (pass after `bash -s --` when piping): +# --mirror cn use mainland-China mirrors for every download +# --swap-gb N size of the swapfile to create when the host has no swap +# (default 16 — the configuration freezing was measured on) +# +# Three promises, mirroring `dor doctor`: +# - Idempotent. Every step checks before it acts; a step whose outcome is +# already in place says [skip] and touches nothing. Re-running upgrades +# the code and repairs drift, and never rotates your API token. +# - Loud. Every step prints what it found and what it did. +# - Verified. The install has not succeeded until `dor doctor` says so — +# the same 19 checks, including the three real-container probes. +set -euo pipefail +export DEBIAN_FRONTEND=noninteractive + +# ---- pinned versions ------------------------------------------------------- +# The Node version and checksum must match images/Dockerfile: the host and +# the sandboxes run the same interpreter, verified against the same official +# SHASUMS256.txt, so a poisoned mirror cannot slip a different tarball in. +NODE_VERSION=v24.18.0 +NODE_SHA256=55aa7153f9d88f28d765fcdad5ae6945b5c0f98a36881703817e4c450fa76742 +# gVisor is pinned to the release proven on real hardware, with checksums of +# the binaries themselves — the .sha512 files published next to the binaries +# only guard transit, not a poisoned origin. +GVISOR_RELEASE=release-20260622.0 +RUNSC_SHA512=6df95d09363dbd9ee5d5c889c1549b457e1783b039ff60a8f9f16f8c94c774a2ca2eef5b1c370e36b863f6b0407b53ba3c69051c6ef051253843dabf89a6de4e +SHIM_SHA512=87c63197836574b7a2c057d2c0647d2badb679187f0b9175ecf78ac52207cdaa3f101629d3e5d165c95930ca35fe81bc26bb90fcf08e09b99c2ee047b6235ce2 + +REPO_URL=https://github.com/BitMiracle-AI/Dormice.git +INSTALL_DIR=/opt/dormice +ENV_FILE=/etc/dormice/env +DATA_DIR=/var/lib/dormice +DAEMON_JSON=/etc/docker/daemon.json +PORT=3676 + +# ---- flags ----------------------------------------------------------------- +MIRROR='' +SWAP_GB=16 +while [ $# -gt 0 ]; do + case "$1" in + --mirror) MIRROR="${2:?--mirror needs a value}"; shift 2 ;; + --mirror=*) MIRROR="${1#*=}"; shift ;; + --swap-gb) SWAP_GB="${2:?--swap-gb needs a value}"; shift 2 ;; + --swap-gb=*) SWAP_GB="${1#*=}"; shift ;; + *) echo "install.sh: unknown flag $1 (known: --mirror cn, --swap-gb N)" >&2; exit 1 ;; + esac +done +if [ -n "$MIRROR" ] && [ "$MIRROR" != cn ]; then + echo "install.sh: --mirror only knows \"cn\", got \"$MIRROR\"" >&2 + exit 1 +fi + +log() { printf '\n==> %s\n' "$*"; } +note() { printf ' %s\n' "$*"; } +die() { printf '\ninstall.sh: %s\n' "$*" >&2; exit 1; } + +# ---- preflight: the facts install.sh cannot fix ---------------------------- +log 'preflight' +[ "$(uname -s)" = Linux ] || die "the daemon needs Linux (loop mounts, cgroups, gVisor) — found $(uname -s)" +[ "$(uname -m)" = x86_64 ] || die "the pinned Node and gVisor binaries are x86_64 — found $(uname -m)" +[ "$(id -u)" = 0 ] || die 'run as root — loop mounts, mkfs and cgroup writes need it' +command -v apt-get >/dev/null || die 'this installer knows apt-based distros (Ubuntu/Debian) only' +grep -qw memory /sys/fs/cgroup/cgroup.controllers 2>/dev/null \ + || die 'cgroup v2 with the memory controller is required (default on Ubuntu 22.04+) — freezing writes memory.reclaim' +note "Linux x86_64, root, cgroup v2 — ok" + +# ---- base packages --------------------------------------------------------- +log 'base packages (git, curl, openssl)' +missing='' +for tool in git curl openssl; do + command -v "$tool" >/dev/null || missing="$missing $tool" +done +if [ -n "$missing" ]; then + apt-get update -q + # shellcheck disable=SC2086 # word splitting is the point + apt-get install -qy ca-certificates $missing + note "installed:$missing" +else + note '[skip] all present' +fi + +# ---- Node ------------------------------------------------------------------ +log "Node.js $NODE_VERSION" +if command -v node >/dev/null && [ "$(node -p 'process.version.slice(1).split(".")[0]')" -ge 22 ]; then + note "[skip] $(node --version) already satisfies >= 22" +else + node_dist=https://nodejs.org/dist + [ "$MIRROR" = cn ] && node_dist=https://npmmirror.com/mirrors/node + tarball="node-$NODE_VERSION-linux-x64.tar.xz" + curl -fsSL -o "/tmp/$tarball" "$node_dist/$NODE_VERSION/$tarball" + echo "$NODE_SHA256 /tmp/$tarball" | sha256sum -c - >/dev/null + tar -xJf "/tmp/$tarball" -C /opt && rm "/tmp/$tarball" + for b in node npm npx corepack; do + ln -sf "/opt/node-$NODE_VERSION-linux-x64/bin/$b" "/usr/local/bin/$b" + done + note "installed $(node --version) to /opt, linked into /usr/local/bin" +fi + +# ---- Docker ---------------------------------------------------------------- +log 'Docker' +if docker version --format '{{.Server.Version}}' >/dev/null 2>&1; then + note "[skip] dockerd $(docker version --format '{{.Server.Version}}') is running" +else + curl -fsSL -o /tmp/get-docker.sh https://get.docker.com + if [ "$MIRROR" = cn ]; then + sh /tmp/get-docker.sh --mirror Aliyun + else + sh /tmp/get-docker.sh + fi + rm /tmp/get-docker.sh + systemctl enable --now docker + note "installed dockerd $(docker version --format '{{.Server.Version}}')" +fi + +# ---- daemon.json: icc off + log rotation ----------------------------------- +# Merged key by key, never overwritten: the operator's registry mirrors and +# whatever `runsc install` wrote must survive. A daemon.json that exists but +# is not valid JSON is the operator's to fix — guessing would destroy it. +log 'Docker daemon.json (icc: false, log rotation)' +if [ -f "$DAEMON_JSON" ] && ! node -e "JSON.parse(require('fs').readFileSync('$DAEMON_JSON','utf8'))" 2>/dev/null; then + die "$DAEMON_JSON exists but is not valid JSON — fix it by hand, then re-run" +fi +mkdir -p /etc/docker +daemon_json_result=$(node - "$DAEMON_JSON" <<'EOF' +const fs = require('fs'); +const path = process.argv[2]; +let config = {}; +try { config = JSON.parse(fs.readFileSync(path, 'utf8')); } catch {} +const before = JSON.stringify(config); +config.icc = false; +const rotates = config['log-driver'] === 'local' || config['log-opts']?.['max-size']; +if (!rotates) { + config['log-driver'] = 'json-file'; + config['log-opts'] = { ...config['log-opts'], 'max-size': '10m', 'max-file': '3' }; +} +if (JSON.stringify(config) === before) { console.log('unchanged'); process.exit(0); } +fs.writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`); +console.log('changed'); +EOF +) +if [ "$daemon_json_result" = changed ]; then + note "updated $DAEMON_JSON — restarting docker (this stops running containers)" + systemctl restart docker +else + note '[skip] already configured' +fi + +# ---- gVisor ---------------------------------------------------------------- +log "gVisor ($GVISOR_RELEASE)" +if docker info --format '{{json .Runtimes}}' | grep -q '"runsc"'; then + note "[skip] runsc is a registered Docker runtime ($(runsc --version | head -1))" +else + if [ ! -x /usr/local/bin/runsc ]; then + gvisor_url="https://storage.googleapis.com/gvisor/releases/release/$GVISOR_RELEASE/x86_64" + for bin in runsc containerd-shim-runsc-v1; do + curl -fsSL -o "/tmp/$bin" "$gvisor_url/$bin" || die "cannot download $bin from $gvisor_url — + if this host cannot reach storage.googleapis.com, download runsc and + containerd-shim-runsc-v1 ($GVISOR_RELEASE, x86_64) on a machine that can, + copy them to /usr/local/bin/ here, then re-run this script: it verifies + their checksums and continues from where it left off" + done + echo "$RUNSC_SHA512 /tmp/runsc" | sha512sum -c - >/dev/null + echo "$SHIM_SHA512 /tmp/containerd-shim-runsc-v1" | sha512sum -c - >/dev/null + chmod a+rx /tmp/runsc /tmp/containerd-shim-runsc-v1 + mv /tmp/runsc /tmp/containerd-shim-runsc-v1 /usr/local/bin/ + fi + echo "$RUNSC_SHA512 /usr/local/bin/runsc" | sha512sum -c - >/dev/null \ + || die "/usr/local/bin/runsc does not match the pinned $GVISOR_RELEASE checksum" + /usr/local/bin/runsc install + systemctl restart docker + note "installed $(runsc --version | head -1), registered with Docker" +fi + +# ---- swap ------------------------------------------------------------------ +# Freezing squeezes sandbox memory out to swap; without swap the measured +# result is 0 bytes reclaimed. The swapfile goes on the root filesystem. +log 'swap' +swap_kb=$(awk '/^SwapTotal:/ {print $2}' /proc/meminfo) +if [ "$swap_kb" -gt 0 ]; then + note "[skip] $((swap_kb / 1024 / 1024)) GiB of swap already present" +else + fallocate -l "${SWAP_GB}G" /swapfile + chmod 600 /swapfile + mkswap /swapfile >/dev/null + swapon /swapfile + echo '/swapfile none swap sw 0 0' >>/etc/fstab + note "created a ${SWAP_GB} GiB /swapfile, persisted in /etc/fstab" +fi + +# ---- vm.swappiness = 100 ---------------------------------------------------- +# gVisor holds sandbox memory as shared memory, which the kernel refuses to +# swap below swappiness 100 (measured at 60: 0 bytes reclaimed). The file is +# named to sort after cloud-vendor sysctl.d files that ship swappiness=0. +log 'vm.swappiness = 100' +if [ "$(cat /proc/sys/vm/swappiness)" = 100 ]; then + note '[skip] effective value is already 100' +else + echo 'vm.swappiness=100' >/etc/sysctl.d/99-dormice.conf + sysctl --system >/dev/null + [ "$(cat /proc/sys/vm/swappiness)" = 100 ] \ + || die 'wrote /etc/sysctl.d/99-dormice.conf but the effective value still is not 100 — something later in sysctl order overrides it' + note 'set via /etc/sysctl.d/99-dormice.conf (survives reboot)' +fi + +# ---- cloud metadata firewall ------------------------------------------------- +# Sandboxes run untrusted code; on a cloud host with an attached role, one +# curl to the metadata service steals live credentials. gVisor blocks kernel +# attack surface, not network reachability — this must be firewalled. +log 'cloud metadata firewall (DOCKER-USER chain)' +docker_user=$(iptables -S DOCKER-USER 2>/dev/null) \ + || die 'the DOCKER-USER chain is missing — is dockerd running?' +added='' +for target in 169.254.0.0/16 100.100.100.200; do + if ! printf '%s\n' "$docker_user" | grep -q -- "-d ${target%/*}\(/[0-9]*\)\? .*-j DROP"; then + iptables -I DOCKER-USER -d "$target" -j DROP + added="$added $target" + fi +done +if [ -n "$added" ]; then note "added DROP rules:$added"; else note '[skip] both DROP rules present'; fi +if ! grep -qs 100.100.100.200 /etc/iptables/rules.v4; then + # Preseeded so apt never prompts; the explicit save below is what persists. + echo 'iptables-persistent iptables-persistent/autosave_v4 boolean true' | debconf-set-selections + echo 'iptables-persistent iptables-persistent/autosave_v6 boolean true' | debconf-set-selections + command -v netfilter-persistent >/dev/null || { apt-get update -q; apt-get install -qy iptables-persistent; } + netfilter-persistent save >/dev/null 2>&1 + note 'persisted to /etc/iptables/rules.v4 (survives reboot)' +else + note '[skip] rules already persisted' +fi + +# ---- Dormice code ----------------------------------------------------------- +log "Dormice code ($INSTALL_DIR)" +clone_url=$REPO_URL +[ "$MIRROR" = cn ] && clone_url="https://ghfast.top/$REPO_URL" +if [ -d "$INSTALL_DIR/.git" ]; then + git -C "$INSTALL_DIR" pull --ff-only -q + note "updated to $(git -C "$INSTALL_DIR" log --oneline -1)" +else + git clone -q "$clone_url" "$INSTALL_DIR" + note "cloned $(git -C "$INSTALL_DIR" log --oneline -1)" +fi + +log 'build' +pnpm_version=$(node -p "require('$INSTALL_DIR/package.json').packageManager.split('@')[1]") +if ! command -v pnpm >/dev/null || [ "$(pnpm --version)" != "$pnpm_version" ]; then + if [ "$MIRROR" = cn ]; then + npm install -g "pnpm@$pnpm_version" --registry=https://registry.npmmirror.com >/dev/null + else + npm install -g "pnpm@$pnpm_version" >/dev/null + fi + # npm -g installs into the active Node's prefix; when that is our /opt + # Node, the binary needs a link onto PATH. + if [ -x "/opt/node-$NODE_VERSION-linux-x64/bin/pnpm" ]; then + ln -sf "/opt/node-$NODE_VERSION-linux-x64/bin/pnpm" /usr/local/bin/pnpm + fi +fi +cd "$INSTALL_DIR" +if [ "$MIRROR" = cn ]; then + npm_config_registry=https://registry.npmmirror.com pnpm install --frozen-lockfile +else + pnpm install --frozen-lockfile +fi +pnpm build +ln -sf "$INSTALL_DIR/packages/cli/dist/main.js" /usr/local/bin/dormice +ln -sf "$INSTALL_DIR/packages/cli/dist/main.js" /usr/local/bin/dor +note "built; \`dormice\` and \`dor\` linked into /usr/local/bin" + +# ---- sandbox base image ------------------------------------------------------ +log 'sandbox base image' +existing_image='' +[ -f "$ENV_FILE" ] && existing_image=$(sed -n 's/^DORMICE_BASE_IMAGE=//p' "$ENV_FILE") +if [ -n "$existing_image" ] && docker image inspect "$existing_image" >/dev/null 2>&1; then + base_image=$existing_image + note "[skip] $base_image (from $ENV_FILE) is present" +else + base_image="dormice-base:$(date +%Y%m%d)" + if [ "$MIRROR" = cn ] && ! docker image inspect ubuntu:24.04 >/dev/null 2>&1; then + # Personal registry mirrors in mainland China often proxy only an image + # whitelist; daocloud + retag is the measured workaround. + docker pull -q docker.m.daocloud.io/library/ubuntu:24.04 + docker tag docker.m.daocloud.io/library/ubuntu:24.04 ubuntu:24.04 + docker rmi -f docker.m.daocloud.io/library/ubuntu:24.04 >/dev/null + fi + if [ "$MIRROR" = cn ]; then + docker build -t "$base_image" \ + --build-arg UBUNTU_MIRROR=https://mirrors.aliyun.com/ubuntu/ \ + --build-arg NODE_DIST=https://npmmirror.com/mirrors/node \ + --build-arg PIP_INDEX=https://mirrors.aliyun.com/pypi/simple/ \ + --build-arg NPM_REGISTRY=https://registry.npmmirror.com \ + "$INSTALL_DIR/images" + else + docker build -t "$base_image" "$INSTALL_DIR/images" + fi + note "built $base_image from images/Dockerfile" +fi + +# ---- daemon configuration ---------------------------------------------------- +log "daemon configuration ($ENV_FILE)" +install -d -m 700 "$DATA_DIR" +if [ -f "$ENV_FILE" ]; then + note "[skip] exists — kept as is (your API token is never rotated); delete it to regenerate" +else + install -d -m 755 /etc/dormice + # No inline comments below: systemd's EnvironmentFile takes the whole line + # as the value. Full-line comments are fine. + cat >"$ENV_FILE" </dev/null 2>&1 +# Restart, not start: a re-run just built fresh code, and the daemon is +# crash-only by design — restarting it is always safe. +systemctl restart dormice +note 'enabled and (re)started' + +# ---- verification: the install has not succeeded until doctor says so -------- +log 'verification' +for _ in $(seq 1 20); do + curl -fsS "http://127.0.0.1:$PORT/healthz" >/dev/null 2>&1 && break + sleep 0.5 +done +curl -fsS "http://127.0.0.1:$PORT/healthz" >/dev/null 2>&1 \ + || die "the daemon did not answer /healthz on 127.0.0.1:$PORT — check: journalctl -u dormice -n 50" +note "daemon is answering on 127.0.0.1:$PORT" +set -a +# shellcheck source=/dev/null +. "$ENV_FILE" +set +a +dor doctor + +printf '\nDormice is installed.\n' +printf ' API token: grep ^DORMICE_API_TOKEN %s\n' "$ENV_FILE" +printf ' daemon logs: journalctl -u dormice -f\n' +printf ' CLI: export DORMICE_ENDPOINT=http://127.0.0.1:%s DORMICE_API_TOKEN=; dor sandbox ls\n' "$PORT" +printf ' The daemon listens on 127.0.0.1 only, by design — exposing it is a reverse proxy'"'"'s job.\n' From adf455d14b0a78cff4f023a86b1f2b8ea3b92a93 Mon Sep 17 00:00:00 2001 From: Annactswell Date: Thu, 9 Jul 2026 21:29:11 +0800 Subject: [PATCH 012/133] The cn Ubuntu mirror must be http: the base image has no CA certs yet First real run caught it: layer 1 of images/Dockerfile is what installs ca-certificates, so an https apt mirror cannot handshake inside the bare ubuntu:24.04 image. apt integrity comes from GPG signatures, not TLS. --- deploy/install.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/deploy/install.sh b/deploy/install.sh index ebfa3d3..0c252aa 100644 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -288,8 +288,12 @@ else docker rmi -f docker.m.daocloud.io/library/ubuntu:24.04 >/dev/null fi if [ "$MIRROR" = cn ]; then + # http on purpose: the base image has no CA certificates until this very + # layer installs them, so an https mirror cannot even handshake. apt's + # integrity comes from GPG signatures, not TLS (the default + # archive.ubuntu.com is http too). docker build -t "$base_image" \ - --build-arg UBUNTU_MIRROR=https://mirrors.aliyun.com/ubuntu/ \ + --build-arg UBUNTU_MIRROR=http://mirrors.aliyun.com/ubuntu/ \ --build-arg NODE_DIST=https://npmmirror.com/mirrors/node \ --build-arg PIP_INDEX=https://mirrors.aliyun.com/pypi/simple/ \ --build-arg NPM_REGISTRY=https://registry.npmmirror.com \ From 2e6e93258f85bb43f3309b9f46a07ce0ab345fc6 Mon Sep 17 00:00:00 2001 From: Annactswell Date: Thu, 9 Jul 2026 21:57:45 +0800 Subject: [PATCH 013/133] Web console at /ui: a stateless session cookie opens the same native routes --- README.md | 14 +- e2e/src/ui.test.ts | 78 ++++ packages/server/package.json | 2 + packages/server/src/app.ts | 22 +- packages/server/src/auth.ts | 76 +++- packages/server/src/main.ts | 19 +- packages/server/src/routes/ui.test.ts | 199 ++++++++++ packages/server/src/routes/ui.ts | 102 +++++ packages/web/index.html | 12 + packages/web/package.json | 17 + packages/web/src/api.ts | 43 ++ packages/web/src/index.ts | 2 - packages/web/src/main.tsx | 51 +++ packages/web/src/pages/login.tsx | 53 +++ packages/web/src/pages/sandboxes.tsx | 204 ++++++++++ packages/web/src/styles.css | 1 + packages/web/tsconfig.json | 7 +- packages/web/vite.config.ts | 19 + pnpm-lock.yaml | 550 +++++++++++++++++++++++++- 19 files changed, 1441 insertions(+), 30 deletions(-) create mode 100644 e2e/src/ui.test.ts create mode 100644 packages/server/src/routes/ui.test.ts create mode 100644 packages/server/src/routes/ui.ts create mode 100644 packages/web/index.html create mode 100644 packages/web/src/api.ts delete mode 100644 packages/web/src/index.ts create mode 100644 packages/web/src/main.tsx create mode 100644 packages/web/src/pages/login.tsx create mode 100644 packages/web/src/pages/sandboxes.tsx create mode 100644 packages/web/src/styles.css create mode 100644 packages/web/vite.config.ts diff --git a/README.md b/README.md index 059f5b5..b7d3270 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **The SQLite of agent sandboxes** — a self-hosted sandbox platform for AI agents. One machine, sandboxes that live forever, idle costs nothing. -> **Status: early development.** The daemon, its lifecycle engine, the SDK, the CLI, the real Docker + gVisor executor, and the E2B-compatible API work end to end — the full create → freeze → stop → wake cycle, command execution, file I/O, and the official `e2b` SDK against real infrastructure. Nothing here is ready for production yet. +> **Status: early development.** The daemon, its lifecycle engine, the SDK, the CLI, the web console, the real Docker + gVisor executor, and the E2B-compatible API work end to end — the full create → freeze → stop → wake cycle, command execution, file I/O, and the official `e2b` SDK against real infrastructure. Nothing here is ready for production yet. ## The idea @@ -28,6 +28,16 @@ drift, and never rotates your API token. It ends by running `dor doctor`, that decide whether the install actually succeeded; `dor doctor` can be re-run on its own at any time. +## Web console + +The daemon serves a small web console at `http://127.0.0.1:3676/ui` — +sign in with the API token once and it becomes an httpOnly session cookie; +the token itself is never stored anywhere the page can read. The console +shows every sandbox with its live lifecycle state (the same +`/listSandboxes` the SDK sees) and can release sandboxes. Since the daemon +listens on 127.0.0.1 only, reach a remote host's console through an SSH +tunnel: `ssh -L 3676:127.0.0.1:3676 root@host`. + ## Host prerequisites (docker executor) The daemon itself runs anywhere Node 22+ runs, with an in-memory fake @@ -63,7 +73,7 @@ pnpm monorepo: | `packages/server` | The daemon: Fastify + SQLite ledger + lifecycle engine | | `packages/sdk` | `@dormice/sdk` — TypeScript client for the native API | | `packages/cli` | `dormice` command-line tool (`dor` for short) | -| `packages/web` | Web console (skeleton) | +| `packages/web` | Web console: React SPA, served by the daemon at `/ui` | | `e2e` | Black-box suite: boots the built daemon, drives it over the wire | ## Development diff --git a/e2e/src/ui.test.ts b/e2e/src/ui.test.ts new file mode 100644 index 0000000..f28a3ec --- /dev/null +++ b/e2e/src/ui.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, inject, it } from 'vitest'; + +// The web console, black-box: plain fetch against the built daemon, the +// same requests a browser would make. The daemon serves packages/web/dist +// (pnpm build ran before this suite), so this also proves the monorepo +// path hop in main.ts survives the dist layout. + +const endpoint = () => inject('dormiceEndpoint'); + +async function loginCookie(token: string): Promise { + const res = await fetch(`${endpoint()}/ui/auth/login`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ token }), + }); + expect(res.status).toBe(200); + const setCookie = res.headers.getSetCookie(); + expect(setCookie).toHaveLength(1); + // "name=value; Path=/; ..." -> "name=value", what a browser would send back. + const cookie = setCookie[0]?.split(';')[0]; + if (!cookie) throw new Error('no session cookie in login response'); + return cookie; +} + +describe('web console over a real daemon', () => { + it('serves the built SPA at /ui/, with assets under /ui/', async () => { + const res = await fetch(`${endpoint()}/ui/`); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('text/html'); + const html = await res.text(); + expect(html).toContain('Dormice'); + // The base path is baked in at build time — a bare /assets reference + // would 404 behind the daemon's /ui prefix. + expect(html).toContain('/ui/assets/'); + }); + + it('falls back to the SPA for client-side routes', async () => { + const res = await fetch(`${endpoint()}/ui/login`); + expect(res.status).toBe(200); + expect(await res.text()).toContain('Dormice'); + }); + + it('rejects a login with the wrong token', async () => { + const res = await fetch(`${endpoint()}/ui/auth/login`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ token: 'w'.repeat(64) }), + }); + expect(res.status).toBe(401); + expect(res.headers.getSetCookie()).toHaveLength(0); + }); + + it('login yields a session cookie that opens the native API', async () => { + const cookie = await loginCookie(inject('dormiceToken')); + const res = await fetch(`${endpoint()}/listSandboxes`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + cookie, + 'x-dormice-console': '1', + }, + body: '{}', + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { sandboxes: unknown[] }; + expect(Array.isArray(body.sandboxes)).toBe(true); + }); + + it('the cookie without the console header stays locked out', async () => { + const cookie = await loginCookie(inject('dormiceToken')); + const res = await fetch(`${endpoint()}/listSandboxes`, { + method: 'POST', + headers: { 'content-type': 'application/json', cookie }, + body: '{}', + }); + expect(res.status).toBe(401); + }); +}); diff --git a/packages/server/package.json b/packages/server/package.json index 5369c16..c3b9594 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -21,7 +21,9 @@ }, "dependencies": { "@dormice/shared": "workspace:*", + "@fastify/cookie": "^11.0.2", "@fastify/multipart": "^10.0.0", + "@fastify/static": "^9.1.3", "better-sqlite3": "^12.11.1", "dockerode": "^5.0.1", "drizzle-orm": "^0.45.2", diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index 2f6f10a..758e0ae 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -1,3 +1,4 @@ +import fastifyCookie from '@fastify/cookie'; import fastify, { type FastifyError } from 'fastify'; import { serializerCompiler, @@ -6,13 +7,14 @@ import { } from 'fastify-type-provider-zod'; import { type Logger, pino } from 'pino'; import { z } from 'zod'; -import { requireApiToken } from './auth'; +import { requireApiAuth } from './auth'; import type { Config } from './config'; import type { Db } from './db/db'; import { registerE2bCompat } from './e2b'; import type { Executor } from './executor/executor'; import type { KeyedQueue } from './keyed-queue'; import { sandboxRoutes } from './routes/sandboxes'; +import { uiRoutes } from './routes/ui'; export interface AppDeps { config: Config; @@ -30,6 +32,11 @@ export interface AppDeps { * before anything that needs it. */ logger?: boolean | Logger; + /** + * Where the built web console lives; main.ts resolves the monorepo + * layout, tests inject a fixture. Absent means /ui answers an honest 404. + */ + webDistDir?: string; } /** @@ -47,6 +54,7 @@ export function buildApp({ executor, locks, logger = true, + webDistDir, }: AppDeps) { // Always a pino instance (booleans are normalized into one): two fastify() // call shapes would give the instance two different types. @@ -88,11 +96,21 @@ export function buildApp({ async () => ({ status: 'ok' as const }), ); + // Cookie parsing app-wide: the auth arbiter reads the console's session + // cookie on the native routes, the /ui surface mints and clears it. + app.register(fastifyCookie); + app.register(async (api) => { - api.addHook('onRequest', requireApiToken(config.DORMICE_API_TOKEN)); + api.addHook('onRequest', requireApiAuth(config.DORMICE_API_TOKEN)); await api.register(sandboxRoutes, { config, db, executor, locks }); }); + // The web console: session endpoints (open — login carries the token + // itself) and the static SPA. Its API calls go through the routes above. + app.register(async (ui) => { + await ui.register(uiRoutes, { config, webDistDir }); + }); + // The E2B compatibility surface lives beside the native API with its own // auth (X-API-KEY / X-Access-Token) and its own error dialect. app.register(async (compat) => { diff --git a/packages/server/src/auth.ts b/packages/server/src/auth.ts index 5b62b9a..8f5579d 100644 --- a/packages/server/src/auth.ts +++ b/packages/server/src/auth.ts @@ -1,20 +1,76 @@ -import { createHash, timingSafeEqual } from 'node:crypto'; +import { createHash, createHmac, timingSafeEqual } from 'node:crypto'; import type { onRequestAsyncHookHandler } from 'fastify'; const sha256 = (value: string) => createHash('sha256').update(value).digest(); +/** Constant-time string comparison; both sides hashed so lengths never leak. */ +export function tokensEqual(presented: string, expected: string): boolean { + return timingSafeEqual(sha256(presented), sha256(expected)); +} + +/** + * The web console's session cookie. Stateless on purpose: the daemon is + * crash-only, and an in-memory session table would log every operator out + * on each restart. The value carries its own expiry and an HMAC over it — + * the same pattern as the envd access token — so a restart changes nothing + * and rotating the API token invalidates every session at once. + */ +export const SESSION_COOKIE = 'dormice_session'; +export const SESSION_TTL_SECONDS = 7 * 24 * 60 * 60; + /** - * Bearer-token check for all API routes (/healthz stays open — liveness - * probes have no secrets). Both sides are hashed before comparison so - * timingSafeEqual gets equal-length inputs and the comparison stays - * constant-time regardless of what the client sent. + * Cookie-authenticated requests must also carry this header. A cross-origin + * page cannot send a custom header without a CORS preflight, and the daemon + * answers no preflights — this closes the hole SameSite leaves open + * (SameSite ignores the port, so another local web app counts as same-site). */ -export function requireApiToken(token: string): onRequestAsyncHookHandler { - const expected = sha256(`Bearer ${token}`); +export const CONSOLE_HEADER = 'x-dormice-console'; + +function sessionHmac(apiToken: string, expiresAtSeconds: number): string { + return createHmac('sha256', apiToken) + .update(`ui-session:${expiresAtSeconds}`) + .digest('hex'); +} + +export function mintSession(apiToken: string, nowMs = Date.now()): string { + const expiresAt = Math.floor(nowMs / 1000) + SESSION_TTL_SECONDS; + return `${expiresAt}.${sessionHmac(apiToken, expiresAt)}`; +} + +export function verifySession( + apiToken: string, + value: string, + nowMs = Date.now(), +): boolean { + const dot = value.indexOf('.'); + if (dot < 0) return false; + const expiresAt = Number(value.slice(0, dot)); + // The expiry is plaintext in the cookie — nothing secret to compare in + // constant time. The HMAC comparison below is the constant-time one. + if (!Number.isInteger(expiresAt) || expiresAt * 1000 <= nowMs) return false; + return tokensEqual(value.slice(dot + 1), sessionHmac(apiToken, expiresAt)); +} + +/** + * The single arbiter of who may call the API (/healthz stays open — + * liveness probes have no secrets). Two credentials open the same door: + * the Bearer token (SDK, CLI, curl) and the web console's session cookie + * (which additionally requires the console header, see above). A second + * route surface with its own auth would be a second truth. + */ +export function requireApiAuth(token: string): onRequestAsyncHookHandler { return async (request, reply) => { - const presented = sha256(request.headers.authorization ?? ''); - if (!timingSafeEqual(presented, expected)) { - await reply.code(401).send({ message: 'missing or invalid API token' }); + if (tokensEqual(request.headers.authorization ?? '', `Bearer ${token}`)) { + return; + } + const cookie = request.cookies?.[SESSION_COOKIE]; + if ( + cookie && + request.headers[CONSOLE_HEADER] !== undefined && + verifySession(token, cookie) + ) { + return; } + await reply.code(401).send({ message: 'missing or invalid API token' }); }; } diff --git a/packages/server/src/main.ts b/packages/server/src/main.ts index 5a66b6e..08c9bca 100644 --- a/packages/server/src/main.ts +++ b/packages/server/src/main.ts @@ -1,3 +1,4 @@ +import { existsSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { pino } from 'pino'; import { buildApp } from './app'; @@ -66,7 +67,23 @@ const executor = buildExecutor(config, (msg) => log.info(msg)); // must share the same per-sandbox slots or the serialization means nothing. const locks = new KeyedQueue(); -const app = buildApp({ config, db, executor, locks, logger: log }); +// The web console ships beside the server in the monorepo; this file sits +// one level under packages/server both as src/main.ts and as dist/main.js, +// so the relative hop to packages/web/dist is the same either way. A +// missing dist is loud but not fatal: the API works without the console. +const webDistDir = fileURLToPath(new URL('../../web/dist', import.meta.url)); +if (!existsSync(webDistDir)) { + log.warn(`web console not found at ${webDistDir} — /ui disabled`); +} + +const app = buildApp({ + config, + db, + executor, + locks, + logger: log, + webDistDir: existsSync(webDistDir) ? webDistDir : undefined, +}); // Before trusting the pairing of this ledger and this reality, check it: // reconciliation destroys whatever the ledger disowns, so a daemon booted diff --git a/packages/server/src/routes/ui.test.ts b/packages/server/src/routes/ui.test.ts new file mode 100644 index 0000000..c99d36d --- /dev/null +++ b/packages/server/src/routes/ui.test.ts @@ -0,0 +1,199 @@ +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { buildApp } from '../app'; +import { + CONSOLE_HEADER, + mintSession, + SESSION_COOKIE, + SESSION_TTL_SECONDS, + verifySession, +} from '../auth'; +import { loadConfig } from '../config'; +import { migrateDb, openDb } from '../db/db'; +import { FakeExecutor } from '../executor/fake'; +import { KeyedQueue } from '../keyed-queue'; + +const MIGRATIONS = fileURLToPath(new URL('../../drizzle', import.meta.url)); +const TOKEN = 'test-token-test-token-test-token'; + +function testApp(webDistDir?: string) { + const db = openDb(':memory:'); + migrateDb(db, MIGRATIONS); + const config = loadConfig({ + DORMICE_DB_PATH: ':memory:', + DORMICE_API_TOKEN: TOKEN, + }); + return buildApp({ + config, + db, + executor: new FakeExecutor(), + locks: new KeyedQueue(), + logger: false, + webDistDir, + }); +} + +/** A minimal built console: an index.html and one hashed asset. */ +function fixtureDist(): string { + const dir = mkdtempSync(join(tmpdir(), 'dormice-webdist-')); + writeFileSync(join(dir, 'index.html'), 'dormice console'); + mkdirSync(join(dir, 'assets')); + writeFileSync(join(dir, 'assets', 'app-abc123.js'), 'console.log("ui")'); + return dir; +} + +async function login(app: ReturnType, token = TOKEN) { + return app.inject({ + method: 'POST', + url: '/ui/auth/login', + payload: { token }, + }); +} + +/** The Set-Cookie value for the session cookie, parsed by fastify's helper. */ +function sessionCookie(res: { cookies: Array> }) { + const cookie = res.cookies.find((c) => c.name === SESSION_COOKIE); + expect(cookie).toBeDefined(); + return cookie as { value: string } & Record; +} + +describe('session mint/verify', () => { + it('round-trips a fresh session', () => { + expect(verifySession(TOKEN, mintSession(TOKEN))).toBe(true); + }); + + it('rejects an expired session', () => { + const past = Date.now() - (SESSION_TTL_SECONDS + 10) * 1000; + expect(verifySession(TOKEN, mintSession(TOKEN, past))).toBe(false); + }); + + it('rejects a tampered expiry: the HMAC covers it', () => { + const value = mintSession(TOKEN); + const [exp, mac] = value.split('.'); + const later = `${Number(exp) + 3600}.${mac}`; + expect(verifySession(TOKEN, later)).toBe(false); + }); + + it('rejects garbage and sessions minted under another token', () => { + expect(verifySession(TOKEN, 'not-a-session')).toBe(false); + expect(verifySession(TOKEN, '')).toBe(false); + expect( + verifySession(TOKEN, mintSession('other-token-other-token-other-tk')), + ).toBe(false); + }); +}); + +describe('POST /ui/auth/login', () => { + it('rejects a wrong token without setting a cookie', async () => { + const res = await login(testApp(), 'wrong-token-wrong-token-wrong-tk'); + expect(res.statusCode).toBe(401); + expect(res.cookies).toHaveLength(0); + }); + + it('sets an httpOnly strict session cookie for the right token', async () => { + const res = await login(testApp()); + expect(res.statusCode).toBe(200); + const cookie = sessionCookie(res); + expect(cookie.httpOnly).toBe(true); + expect(cookie.sameSite).toBe('Strict'); + expect(cookie.path).toBe('/'); + expect(cookie.maxAge).toBe(SESSION_TTL_SECONDS); + expect(verifySession(TOKEN, cookie.value)).toBe(true); + }); +}); + +describe('cookie-authenticated API access', () => { + async function list( + app: ReturnType, + cookieValue: string, + headers: Record = { [CONSOLE_HEADER]: '1' }, + ) { + return app.inject({ + method: 'POST', + url: '/listSandboxes', + cookies: { [SESSION_COOKIE]: cookieValue }, + headers, + payload: {}, + }); + } + + it('a fresh session cookie opens the native API', async () => { + const app = testApp(); + const cookie = sessionCookie(await login(app)); + const res = await list(app, cookie.value); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ sandboxes: [] }); + }); + + it('the cookie alone is not enough: the console header is required', async () => { + const app = testApp(); + const cookie = sessionCookie(await login(app)); + const res = await list(app, cookie.value, {}); + expect(res.statusCode).toBe(401); + }); + + it('rejects a tampered cookie', async () => { + const res = await list(testApp(), `${mintSession(TOKEN)}ff`); + expect(res.statusCode).toBe(401); + }); + + it('rejects an expired cookie', async () => { + const past = Date.now() - (SESSION_TTL_SECONDS + 10) * 1000; + const res = await list(testApp(), mintSession(TOKEN, past)); + expect(res.statusCode).toBe(401); + }); + + it('does not open the E2B surface: that has its own auth', async () => { + const app = testApp(); + const cookie = sessionCookie(await login(app)); + const res = await app.inject({ + method: 'GET', + url: '/e2b/api/v2/sandboxes', + cookies: { [SESSION_COOKIE]: cookie.value }, + headers: { [CONSOLE_HEADER]: '1' }, + }); + expect(res.statusCode).toBe(401); + }); +}); + +describe('POST /ui/auth/logout', () => { + it('clears the session cookie', async () => { + const res = await testApp().inject({ + method: 'POST', + url: '/ui/auth/logout', + }); + expect(res.statusCode).toBe(200); + const cookie = sessionCookie(res); + expect(cookie.value).toBe(''); + }); +}); + +describe('static console at /ui', () => { + it('serves index.html and assets from the injected dist', async () => { + const app = testApp(fixtureDist()); + const index = await app.inject({ method: 'GET', url: '/ui/' }); + expect(index.statusCode).toBe(200); + expect(index.body).toContain('dormice console'); + const asset = await app.inject({ + method: 'GET', + url: '/ui/assets/app-abc123.js', + }); + expect(asset.statusCode).toBe(200); + }); + + it('falls back to index.html for client-side routes (SPA)', async () => { + const app = testApp(fixtureDist()); + const res = await app.inject({ method: 'GET', url: '/ui/sandboxes/deep' }); + expect(res.statusCode).toBe(200); + expect(res.body).toContain('dormice console'); + }); + + it('answers an honest 404 when the console is not built', async () => { + const res = await testApp().inject({ method: 'GET', url: '/ui' }); + expect(res.statusCode).toBe(404); + expect(res.json().message).toContain('pnpm build'); + }); +}); diff --git a/packages/server/src/routes/ui.ts b/packages/server/src/routes/ui.ts new file mode 100644 index 0000000..34a2157 --- /dev/null +++ b/packages/server/src/routes/ui.ts @@ -0,0 +1,102 @@ +import fastifyStatic from '@fastify/static'; +import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'; +import { z } from 'zod'; +import { + mintSession, + SESSION_COOKIE, + SESSION_TTL_SECONDS, + tokensEqual, +} from '../auth'; +import type { Config } from '../config'; + +export interface UiRoutesOptions { + config: Config; + /** + * Where the built web console lives (packages/web/dist). Injected so + * tests point it at a fixture and embedders can omit it; absent means + * /ui answers an honest 404 instead of guessing at paths. + */ + webDistDir?: string; +} + +// No Secure flag: the daemon speaks plain http on 127.0.0.1 by design, and +// behind a TLS reverse proxy the browser-facing side is the proxy's job. +const COOKIE_OPTIONS = { + httpOnly: true, + sameSite: 'strict', + path: '/', +} as const; + +/** + * The web console's own surface: session endpoints and the static SPA. + * Everything else the console does goes through the native RPC routes with + * the session cookie — same routes, same truth as the SDK and CLI. + */ +export const uiRoutes: FastifyPluginAsyncZod = async ( + app, + { config, webDistDir }, +) => { + app.post( + '/ui/auth/login', + { + schema: { + body: z.object({ token: z.string() }), + response: { + 200: z.object({ loggedIn: z.literal(true) }), + 401: z.object({ message: z.string() }), + }, + }, + }, + async (request, reply) => { + if (!tokensEqual(request.body.token, config.DORMICE_API_TOKEN)) { + return reply.code(401).send({ message: 'invalid API token' }); + } + reply.setCookie(SESSION_COOKIE, mintSession(config.DORMICE_API_TOKEN), { + ...COOKIE_OPTIONS, + // The cookie lives exactly as long as the HMAC inside it is valid. + maxAge: SESSION_TTL_SECONDS, + }); + return { loggedIn: true as const }; + }, + ); + + app.post( + '/ui/auth/logout', + { + schema: { + response: { 200: z.object({ loggedIn: z.literal(false) }) }, + }, + }, + async (_request, reply) => { + reply.clearCookie(SESSION_COOKIE, COOKIE_OPTIONS); + return { loggedIn: false as const }; + }, + ); + + if (webDistDir) { + await app.register( + async (ui) => { + await ui.register(fastifyStatic, { root: webDistDir }); + // SPA fallback: the router owns paths under /ui, so any GET that + // matches no file is a client-side route — serve the app and let + // it resolve. Everything else keeps the honest 404. + ui.setNotFoundHandler((request, reply) => { + if (request.method === 'GET') { + return reply.sendFile('index.html'); + } + reply.code(404).send({ + message: `route ${request.method} ${request.url} not found`, + }); + }); + }, + { prefix: '/ui' }, + ); + } else { + app.get('/ui', async (_request, reply) => + reply.code(404).send({ + message: + 'web console not available: packages/web/dist was not found at startup — run `pnpm build` first', + }), + ); + } +}; diff --git a/packages/web/index.html b/packages/web/index.html new file mode 100644 index 0000000..7918003 --- /dev/null +++ b/packages/web/index.html @@ -0,0 +1,12 @@ + + + + + + Dormice + + +
+ + + diff --git a/packages/web/package.json b/packages/web/package.json index 4f7c7d6..6a31fe6 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -6,6 +6,23 @@ "private": true, "type": "module", "scripts": { + "build": "vite build", + "dev": "vite", "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@tanstack/react-query": "^5.101.2", + "@tanstack/react-router": "^1.170.17", + "react": "^19.2.7", + "react-dom": "^19.2.7" + }, + "devDependencies": { + "@dormice/shared": "workspace:*", + "@tailwindcss/vite": "^4.3.2", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "tailwindcss": "^4.3.2", + "vite": "^8.1.3" } } diff --git a/packages/web/src/api.ts b/packages/web/src/api.ts new file mode 100644 index 0000000..6258c26 --- /dev/null +++ b/packages/web/src/api.ts @@ -0,0 +1,43 @@ +import type { Sandbox } from '@dormice/shared'; + +/** + * The console talks to the daemon's native RPC routes — the same routes, + * same truth as the SDK and CLI — authenticated by the httpOnly session + * cookie the browser attaches on its own. The custom header is the second + * half of the CSRF defense: a cross-origin page cannot send it without a + * CORS preflight, and the daemon answers no preflights. + */ +export class UnauthorizedError extends Error {} + +async function rpc(path: string, body: unknown = {}): Promise { + const res = await fetch(path, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-dormice-console': '1', + }, + body: JSON.stringify(body), + }); + if (res.status === 401) { + throw new UnauthorizedError('not logged in'); + } + if (!res.ok) { + const message = await res + .json() + .then((data: { message?: string }) => data.message) + .catch(() => undefined); + throw new Error(message ?? `${path} failed with ${res.status}`); + } + return res.json() as Promise; +} + +export const login = (token: string) => + rpc<{ loggedIn: true }>('/ui/auth/login', { token }); + +export const logout = () => rpc<{ loggedIn: false }>('/ui/auth/logout'); + +export const listSandboxes = () => + rpc<{ sandboxes: Sandbox[] }>('/listSandboxes'); + +export const releaseSandbox = (userKey: string) => + rpc<{ released: boolean }>('/releaseSandbox', { userKey }); diff --git a/packages/web/src/index.ts b/packages/web/src/index.ts deleted file mode 100644 index 4d2be72..0000000 --- a/packages/web/src/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Web console placeholder. Replaced by the Vite + React SPA scaffold in later steps. -export {}; diff --git a/packages/web/src/main.tsx b/packages/web/src/main.tsx new file mode 100644 index 0000000..f9f9ee9 --- /dev/null +++ b/packages/web/src/main.tsx @@ -0,0 +1,51 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { + createRootRoute, + createRoute, + createRouter, + Outlet, + RouterProvider, +} from '@tanstack/react-router'; +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { LoginPage } from './pages/login'; +import { SandboxesPage } from './pages/sandboxes'; +import './styles.css'; + +const rootRoute = createRootRoute({ component: Outlet }); + +const sandboxesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: SandboxesPage, +}); + +const loginRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/login', + component: LoginPage, +}); + +// The daemon serves the console under /ui; the router lives there too so +// server paths and client paths are the same strings everywhere. +const router = createRouter({ + routeTree: rootRoute.addChildren([sandboxesRoute, loginRoute]), + basepath: '/ui', +}); + +declare module '@tanstack/react-router' { + interface Register { + router: typeof router; + } +} + +const queryClient = new QueryClient(); + +// biome-ignore lint/style/noNonNullAssertion: index.html always has #root +createRoot(document.getElementById('root')!).render( + + + + + , +); diff --git a/packages/web/src/pages/login.tsx b/packages/web/src/pages/login.tsx new file mode 100644 index 0000000..436bb9f --- /dev/null +++ b/packages/web/src/pages/login.tsx @@ -0,0 +1,53 @@ +import { useMutation } from '@tanstack/react-query'; +import { useNavigate } from '@tanstack/react-router'; +import { useState } from 'react'; +import { login } from '../api'; + +/** + * One field, one button. The token is posted once and comes back as an + * httpOnly session cookie — it is never stored anywhere the page can read. + */ +export function LoginPage() { + const navigate = useNavigate(); + const [token, setToken] = useState(''); + const mutation = useMutation({ + mutationFn: login, + onSuccess: () => navigate({ to: '/' }), + }); + + return ( +
+
{ + event.preventDefault(); + mutation.mutate(token); + }} + > +

Dormice

+

+ Paste the daemon's API token to open the console. +

+ setToken(event.target.value)} + placeholder="DORMICE_API_TOKEN" + className="mt-6 w-full rounded-md border border-slate-700 bg-slate-950 px-3 py-2 font-mono text-sm placeholder:text-slate-600 focus:border-sky-500 focus:outline-none" + /> + {mutation.isError && ( +

{mutation.error.message}

+ )} + +
+
+ ); +} diff --git a/packages/web/src/pages/sandboxes.tsx b/packages/web/src/pages/sandboxes.tsx new file mode 100644 index 0000000..2077dcb --- /dev/null +++ b/packages/web/src/pages/sandboxes.tsx @@ -0,0 +1,204 @@ +import type { Sandbox, SandboxState } from '@dormice/shared'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useNavigate } from '@tanstack/react-router'; +import { useEffect, useState } from 'react'; +import { + listSandboxes, + logout, + releaseSandbox, + UnauthorizedError, +} from '../api'; + +const STATE_STYLES: Record = { + active: 'bg-emerald-500/15 text-emerald-400 border-emerald-500/30', + frozen: 'bg-sky-500/15 text-sky-400 border-sky-500/30', + stopped: 'bg-slate-500/15 text-slate-400 border-slate-500/30', + archived: 'bg-violet-500/15 text-violet-400 border-violet-500/30', + restoring: 'bg-amber-500/15 text-amber-400 border-amber-500/30', +}; + +function formatDuration(totalSeconds: number): string { + const s = Math.max(0, Math.floor(totalSeconds)); + if (s < 60) return `${s}s`; + if (s < 3600) return `${Math.floor(s / 60)}m ${s % 60}s`; + if (s < 86400) + return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`; + return `${Math.floor(s / 86400)}d ${Math.floor((s % 86400) / 3600)}h`; +} + +function since(iso: string): string { + return formatDuration((Date.now() - Date.parse(iso)) / 1000); +} + +/** The three knobs in one line: how this sandbox cools down when idle. */ +function policyLine(policy: Sandbox['policy']): string { + const step = (label: string, seconds: number | null) => + seconds === null ? `${label} never` : `${label} ${formatDuration(seconds)}`; + return [ + step('freeze', policy.freezeAfterSeconds), + step('stop', policy.stopAfterSeconds), + step('archive', policy.archiveAfterSeconds), + ].join(' · '); +} + +function ReleaseButton({ userKey }: { userKey: string }) { + const queryClient = useQueryClient(); + const [armed, setArmed] = useState(false); + const mutation = useMutation({ + mutationFn: releaseSandbox, + onSettled: () => queryClient.invalidateQueries({ queryKey: ['sandboxes'] }), + }); + + // Two clicks on purpose: release destroys the sandbox and its disk, and a + // modal library is more machinery than one armed state. + if (!armed) { + return ( + + ); + } + return ( + + + + + ); +} + +export function SandboxesPage() { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const query = useQuery({ + queryKey: ['sandboxes'], + queryFn: listSandboxes, + // The observation window observes: a short poll against a local SQLite + // read is effectively free, and 2s is faster than the human eye needs. + refetchInterval: 2000, + retry: false, + }); + + useEffect(() => { + if (query.error instanceof UnauthorizedError) { + navigate({ to: '/login' }); + } + }, [query.error, navigate]); + + const logoutMutation = useMutation({ + mutationFn: logout, + onSuccess: () => { + queryClient.clear(); + navigate({ to: '/login' }); + }, + }); + + const sandboxes = query.data?.sandboxes ?? []; + + return ( +
+
+
+

Dormice

+ + {sandboxes.length} sandbox{sandboxes.length === 1 ? '' : 'es'} + +
+ +
+ +
+ {query.isError && !(query.error instanceof UnauthorizedError) && ( +

+ {query.error.message} +

+ )} + + {query.isSuccess && sandboxes.length === 0 && ( +
+

No sandboxes yet

+

+ Acquire one via the SDK or{' '} + + POST /acquireSandbox + {' '} + — it will appear here within two seconds. +

+
+ )} + + {sandboxes.length > 0 && ( +
+ + + + + + + + + + + + + + {sandboxes.map((sandbox) => ( + + + + + + + + + + ))} + +
User keyStateIdleLifecycle policySandbox IDAgeActions
{sandbox.userKey} + + {sandbox.state} + + + {since(sandbox.lastActiveAt)} + + {policyLine(sandbox.policy)} + + {sandbox.sandboxId} + + {since(sandbox.createdAt)} + + +
+
+ )} +
+
+ ); +} diff --git a/packages/web/src/styles.css b/packages/web/src/styles.css new file mode 100644 index 0000000..f1d8c73 --- /dev/null +++ b/packages/web/src/styles.css @@ -0,0 +1 @@ +@import "tailwindcss"; diff --git a/packages/web/tsconfig.json b/packages/web/tsconfig.json index 564a599..2cf299c 100644 --- a/packages/web/tsconfig.json +++ b/packages/web/tsconfig.json @@ -1,4 +1,9 @@ { "extends": "../../tsconfig.base.json", - "include": ["src"] + "compilerOptions": { + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "types": ["vite/client"], + "jsx": "react-jsx" + }, + "include": ["src", "vite.config.ts"] } diff --git a/packages/web/vite.config.ts b/packages/web/vite.config.ts new file mode 100644 index 0000000..a0346ba --- /dev/null +++ b/packages/web/vite.config.ts @@ -0,0 +1,19 @@ +import tailwindcss from '@tailwindcss/vite'; +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + // The daemon serves the built console under /ui — assets must resolve there. + base: '/ui/', + plugins: [react(), tailwindcss()], + server: { + // Dev mode: Vite owns /ui, the daemon (fake executor is fine) owns the + // API — same paths the browser uses in production, so no API base knob. + proxy: { + '/ui/auth': 'http://127.0.0.1:3676', + '/listSandboxes': 'http://127.0.0.1:3676', + '/releaseSandbox': 'http://127.0.0.1:3676', + '/acquireSandbox': 'http://127.0.0.1:3676', + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 755f11f..2fbfec5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,13 +16,13 @@ importers: version: 26.1.0 tsup: specifier: ^8.5.1 - version: 8.5.1(postcss@8.5.16)(tsx@4.23.0)(typescript@6.0.3)(yaml@2.9.0) + version: 8.5.1(jiti@2.7.0)(postcss@8.5.16)(tsx@4.23.0)(typescript@6.0.3)(yaml@2.9.0) typescript: specifier: ^6.0.3 version: 6.0.3 vitest: specifier: ^4.1.10 - version: 4.1.10(@types/node@26.1.0)(vite@8.1.3(@types/node@26.1.0)(esbuild@0.27.7)(tsx@4.23.0)(yaml@2.9.0)) + version: 4.1.10(@types/node@26.1.0)(vite@8.1.3(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)) e2e: dependencies: @@ -64,9 +64,15 @@ importers: '@dormice/shared': specifier: workspace:* version: link:../shared + '@fastify/cookie': + specifier: ^11.0.2 + version: 11.0.2 '@fastify/multipart': specifier: ^10.0.0 version: 10.0.0 + '@fastify/static': + specifier: ^9.1.3 + version: 9.1.3 better-sqlite3: specifier: ^12.11.1 version: 12.11.1 @@ -108,7 +114,42 @@ importers: specifier: ^4.4.3 version: 4.4.3 - packages/web: {} + packages/web: + dependencies: + '@tanstack/react-query': + specifier: ^5.101.2 + version: 5.101.2(react@19.2.7) + '@tanstack/react-router': + specifier: ^1.170.17 + version: 1.170.17(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: + specifier: ^19.2.7 + version: 19.2.7 + react-dom: + specifier: ^19.2.7 + version: 19.2.7(react@19.2.7) + devDependencies: + '@dormice/shared': + specifier: workspace:* + version: link:../shared + '@tailwindcss/vite': + specifier: ^4.3.2 + version: 4.3.2(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)) + '@types/react': + specifier: ^19.2.17 + version: 19.2.17 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.17) + '@vitejs/plugin-react': + specifier: ^6.0.3 + version: 6.0.3(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)) + tailwindcss: + specifier: ^4.3.2 + version: 4.3.2 + vite: + specifier: ^8.1.3 + version: 8.1.3(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0) packages: @@ -806,12 +847,18 @@ packages: cpu: [x64] os: [win32] + '@fastify/accept-negotiator@2.0.1': + resolution: {integrity: sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==} + '@fastify/ajv-compiler@4.0.5': resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} '@fastify/busboy@3.2.0': resolution: {integrity: sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==} + '@fastify/cookie@11.0.2': + resolution: {integrity: sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==} + '@fastify/deepmerge@3.2.1': resolution: {integrity: sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==} @@ -833,6 +880,12 @@ packages: '@fastify/proxy-addr@5.1.0': resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} + '@fastify/send@4.1.0': + resolution: {integrity: sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==} + + '@fastify/static@9.1.3': + resolution: {integrity: sha512-aXrYtsiryLhRxRNaxNqsn7FUISeb7rB9q4eHUPIot5aeQBLNahnz1m6thzm7JWC1poSGXS9XrX8DvuMivp2hkQ==} + '@fastify/swagger@9.7.0': resolution: {integrity: sha512-Vp1SC1GC2Hrkd3faFILv86BzUNyFz5N4/xdExqtCgkGASOzn/x+eMe4qXIGq7cdT6wif/P/oa6r1Ruqx19paZA==} @@ -861,6 +914,9 @@ packages: '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -874,6 +930,10 @@ packages: '@js-sdsl/ordered-map@4.4.2': resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} + '@lukeed/ms@2.0.2': + resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} + engines: {node: '>=8'} + '@napi-rs/wasm-runtime@1.1.6': resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} peerDependencies: @@ -1159,6 +1219,132 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@tailwindcss/node@4.3.2': + resolution: {integrity: sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==} + + '@tailwindcss/oxide-android-arm64@4.3.2': + resolution: {integrity: sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.3.2': + resolution: {integrity: sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.3.2': + resolution: {integrity: sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.3.2': + resolution: {integrity: sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.2': + resolution: {integrity: sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.2': + resolution: {integrity: sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.3.2': + resolution: {integrity: sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.3.2': + resolution: {integrity: sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.3.2': + resolution: {integrity: sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.3.2': + resolution: {integrity: sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.2': + resolution: {integrity: sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.3.2': + resolution: {integrity: sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.3.2': + resolution: {integrity: sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.3.2': + resolution: {integrity: sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + + '@tanstack/history@1.162.0': + resolution: {integrity: sha512-79pf/RkhteYZTRgcR4F9kbk84P2N8rugQJswxfIqovlbRiT3yI7eBE+5QorIrZaOKktsgzRlXh1l/du/xpl4iA==} + engines: {node: '>=20.19'} + + '@tanstack/query-core@5.101.2': + resolution: {integrity: sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==} + + '@tanstack/react-query@5.101.2': + resolution: {integrity: sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==} + peerDependencies: + react: ^18 || ^19 + + '@tanstack/react-router@1.170.17': + resolution: {integrity: sha512-ppLkjCfSMaeug9rmFRYzOd4TIqWV+yTE7tzIny7alJsSnM7w4lzEZm6eqCehG0SPetpZ0R3K+UnanSmBgOAVcQ==} + engines: {node: '>=20.19'} + peerDependencies: + react: '>=18.0.0 || >=19.0.0' + react-dom: '>=18.0.0 || >=19.0.0' + + '@tanstack/react-store@0.9.3': + resolution: {integrity: sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/router-core@1.171.14': + resolution: {integrity: sha512-Mo3hwx0qB0cJsVYGDjG0+Ouf7VV74h/vsoDMGztdlyzDanp4gBA2s7IVvm6hFrmQM6GpD9F0Z7SqD7OldfLE7g==} + engines: {node: '>=20.19'} + + '@tanstack/store@0.9.3': + resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==} + '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} @@ -1186,9 +1372,30 @@ packages: '@types/node@26.1.0': resolution: {integrity: sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==} + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.17': + resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + '@types/ssh2@1.15.5': resolution: {integrity: sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==} + '@vitejs/plugin-react@6.0.3': + resolution: {integrity: sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + '@vitest/expect@4.1.10': resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} @@ -1354,9 +1561,16 @@ packages: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-es@3.1.1: + resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==} + cookie@1.1.1: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} @@ -1369,6 +1583,9 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1386,6 +1603,10 @@ packages: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -1511,6 +1732,10 @@ packages: end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + enhanced-resolve@5.21.6: + resolution: {integrity: sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==} + engines: {node: '>=10.13.0'} + es-module-lexer@2.3.0: resolution: {integrity: sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==} @@ -1538,6 +1763,9 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -1639,6 +1867,17 @@ packages: engines: {node: 20 || >=22} hasBin: true + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + human-signals@8.0.1: resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} engines: {node: '>=18.18.0'} @@ -1672,6 +1911,10 @@ packages: resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} engines: {node: '>=18'} + isbot@5.2.0: + resolution: {integrity: sha512-gbZiGCb4B5xaoxg9mS7koAyRdvJnArk10VLSHOgz6rtBG93/pi1xOFaVvXMKZ7JXgyZ8zAbNRK5uIBdIUTFSqw==} + engines: {node: '>=18'} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -1679,6 +1922,10 @@ packages: resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==} engines: {node: 20 || >=22} + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -1794,6 +2041,11 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + mime@3.0.0: + resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} + engines: {node: '>=10.0.0'} + hasBin: true + mimic-response@3.1.0: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} engines: {node: '>=10'} @@ -1969,6 +2221,15 @@ packages: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true + react-dom@19.2.7: + resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} + peerDependencies: + react: ^19.2.7 + + react@19.2.7: + resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} + engines: {node: '>=0.10.0'} + readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} @@ -2034,6 +2295,9 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + secure-json-parse@4.1.0: resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} @@ -2042,9 +2306,22 @@ packages: engines: {node: '>=10'} hasBin: true + seroval-plugins@1.5.4: + resolution: {integrity: sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + + seroval@1.5.4: + resolution: {integrity: sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw==} + engines: {node: '>=10'} + set-cookie-parser@2.7.2: resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -2098,6 +2375,10 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} @@ -2125,6 +2406,13 @@ packages: engines: {node: '>=16 || 14 >=14.17'} hasBin: true + tailwindcss@4.3.2: + resolution: {integrity: sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + tar-fs@2.1.5: resolution: {integrity: sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==} @@ -2169,6 +2457,10 @@ packages: resolution: {integrity: sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==} engines: {node: '>=20'} + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -2235,6 +2527,11 @@ packages: resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} engines: {node: '>=18'} + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -2751,6 +3048,8 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true + '@fastify/accept-negotiator@2.0.1': {} + '@fastify/ajv-compiler@4.0.5': dependencies: ajv: 8.20.0 @@ -2759,6 +3058,11 @@ snapshots: '@fastify/busboy@3.2.0': {} + '@fastify/cookie@11.0.2': + dependencies: + cookie: 1.1.1 + fastify-plugin: 5.1.0 + '@fastify/deepmerge@3.2.1': {} '@fastify/error@4.2.0': {} @@ -2786,6 +3090,23 @@ snapshots: '@fastify/forwarded': 3.0.1 ipaddr.js: 2.4.0 + '@fastify/send@4.1.0': + dependencies: + '@lukeed/ms': 2.0.2 + escape-html: 1.0.3 + fast-decode-uri-component: 1.0.1 + http-errors: 2.0.1 + mime: 3.0.0 + + '@fastify/static@9.1.3': + dependencies: + '@fastify/accept-negotiator': 2.0.1 + '@fastify/send': 4.1.0 + content-disposition: 1.1.0 + fastify-plugin: 5.1.0 + fastq: 1.20.1 + glob: 13.0.6 + '@fastify/swagger@9.7.0': dependencies: fastify-plugin: 5.1.0 @@ -2826,6 +3147,11 @@ snapshots: '@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/trace-mapping': 0.3.31 + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/sourcemap-codec@1.5.5': {} @@ -2837,6 +3163,8 @@ snapshots: '@js-sdsl/ordered-map@4.4.2': {} + '@lukeed/ms@2.0.2': {} + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: '@emnapi/core': 1.11.1 @@ -3000,6 +3328,108 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@tailwindcss/node@4.3.2': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.21.6 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.3.2 + + '@tailwindcss/oxide-android-arm64@4.3.2': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.3.2': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.3.2': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.3.2': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.2': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.2': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.3.2': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.3.2': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.3.2': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.3.2': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.2': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.3.2': + optional: true + + '@tailwindcss/oxide@4.3.2': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.2 + '@tailwindcss/oxide-darwin-arm64': 4.3.2 + '@tailwindcss/oxide-darwin-x64': 4.3.2 + '@tailwindcss/oxide-freebsd-x64': 4.3.2 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.2 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.2 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.2 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.2 + '@tailwindcss/oxide-linux-x64-musl': 4.3.2 + '@tailwindcss/oxide-wasm32-wasi': 4.3.2 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.2 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.2 + + '@tailwindcss/vite@4.3.2(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0))': + dependencies: + '@tailwindcss/node': 4.3.2 + '@tailwindcss/oxide': 4.3.2 + tailwindcss: 4.3.2 + vite: 8.1.3(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0) + + '@tanstack/history@1.162.0': {} + + '@tanstack/query-core@5.101.2': {} + + '@tanstack/react-query@5.101.2(react@19.2.7)': + dependencies: + '@tanstack/query-core': 5.101.2 + react: 19.2.7 + + '@tanstack/react-router@1.170.17(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@tanstack/history': 1.162.0 + '@tanstack/react-store': 0.9.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@tanstack/router-core': 1.171.14 + isbot: 5.2.0 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@tanstack/react-store@0.9.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@tanstack/store': 0.9.3 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + use-sync-external-store: 1.6.0(react@19.2.7) + + '@tanstack/router-core@1.171.14': + dependencies: + '@tanstack/history': 1.162.0 + cookie-es: 3.1.1 + seroval: 1.5.4 + seroval-plugins: 1.5.4(seroval@1.5.4) + + '@tanstack/store@0.9.3': {} + '@tybys/wasm-util@0.10.3': dependencies: tslib: 2.8.1 @@ -3037,10 +3467,23 @@ snapshots: dependencies: undici-types: 8.3.0 + '@types/react-dom@19.2.3(@types/react@19.2.17)': + dependencies: + '@types/react': 19.2.17 + + '@types/react@19.2.17': + dependencies: + csstype: 3.2.3 + '@types/ssh2@1.15.5': dependencies: '@types/node': 18.19.130 + '@vitejs/plugin-react@6.0.3(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.1.3(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0) + '@vitest/expect@4.1.10': dependencies: '@standard-schema/spec': 1.1.0 @@ -3050,13 +3493,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.10(vite@8.1.3(@types/node@26.1.0)(esbuild@0.27.7)(tsx@4.23.0)(yaml@2.9.0))': + '@vitest/mocker@4.1.10(vite@8.1.3(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.1.3(@types/node@26.1.0)(esbuild@0.27.7)(tsx@4.23.0)(yaml@2.9.0) + vite: 8.1.3(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0) '@vitest/pretty-format@4.1.10': dependencies: @@ -3196,8 +3639,12 @@ snapshots: consola@3.4.2: {} + content-disposition@1.1.0: {} + convert-source-map@2.0.0: {} + cookie-es@3.1.1: {} + cookie@1.1.1: {} cpu-features@0.0.10: @@ -3212,6 +3659,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + csstype@3.2.3: {} + debug@4.4.3: dependencies: ms: 2.1.3 @@ -3222,6 +3671,8 @@ snapshots: deep-extend@0.6.0: {} + depd@2.0.0: {} + dequal@2.0.3: {} detect-libc@2.1.2: {} @@ -3283,6 +3734,11 @@ snapshots: dependencies: once: 1.4.0 + enhanced-resolve@5.21.6: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + es-module-lexer@2.3.0: {} esbuild@0.18.20: @@ -3399,6 +3855,8 @@ snapshots: escalade@3.2.0: {} + escape-html@1.0.3: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 @@ -3527,6 +3985,22 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 2.0.2 + glob@13.0.6: + dependencies: + minimatch: 10.2.5 + minipass: 7.1.3 + path-scurry: 2.0.2 + + graceful-fs@4.2.11: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + human-signals@8.0.1: {} ieee754@1.2.1: {} @@ -3545,12 +4019,16 @@ snapshots: is-unicode-supported@2.1.0: {} + isbot@5.2.0: {} + isexe@2.0.0: {} jackspeak@4.2.3: dependencies: '@isaacs/cliui': 9.0.0 + jiti@2.7.0: {} + joycon@3.1.1: {} json-schema-ref-resolver@3.0.0: @@ -3638,6 +4116,8 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + mime@3.0.0: {} + mimic-response@3.1.0: {} minimatch@10.2.5: @@ -3752,10 +4232,11 @@ snapshots: platform@1.3.6: {} - postcss-load-config@6.0.1(postcss@8.5.16)(tsx@4.23.0)(yaml@2.9.0): + postcss-load-config@6.0.1(jiti@2.7.0)(postcss@8.5.16)(tsx@4.23.0)(yaml@2.9.0): dependencies: lilconfig: 3.1.3 optionalDependencies: + jiti: 2.7.0 postcss: 8.5.16 tsx: 4.23.0 yaml: 2.9.0 @@ -3817,6 +4298,13 @@ snapshots: minimist: 1.2.8 strip-json-comments: 2.0.1 + react-dom@19.2.7(react@19.2.7): + dependencies: + react: 19.2.7 + scheduler: 0.27.0 + + react@19.2.7: {} + readable-stream@3.6.2: dependencies: inherits: 2.0.4 @@ -3905,12 +4393,22 @@ snapshots: safer-buffer@2.1.2: {} + scheduler@0.27.0: {} + secure-json-parse@4.1.0: {} semver@7.8.5: {} + seroval-plugins@1.5.4(seroval@1.5.4): + dependencies: + seroval: 1.5.4 + + seroval@1.5.4: {} + set-cookie-parser@2.7.2: {} + setprototypeof@1.2.0: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -3958,6 +4456,8 @@ snapshots: stackback@0.0.2: {} + statuses@2.0.2: {} + std-env@4.1.0: {} string-width@4.2.3: @@ -3988,6 +4488,10 @@ snapshots: tinyglobby: 0.2.17 ts-interface-checker: 0.1.13 + tailwindcss@4.3.2: {} + + tapable@2.3.3: {} + tar-fs@2.1.5: dependencies: chownr: 1.1.4 @@ -4038,6 +4542,8 @@ snapshots: toad-cache@3.7.4: {} + toidentifier@1.0.1: {} + tree-kill@1.2.2: {} ts-interface-checker@0.1.13: {} @@ -4045,7 +4551,7 @@ snapshots: tslib@2.8.1: optional: true - tsup@8.5.1(postcss@8.5.16)(tsx@4.23.0)(typescript@6.0.3)(yaml@2.9.0): + tsup@8.5.1(jiti@2.7.0)(postcss@8.5.16)(tsx@4.23.0)(typescript@6.0.3)(yaml@2.9.0): dependencies: bundle-require: 5.1.0(esbuild@0.27.7) cac: 6.7.14 @@ -4056,7 +4562,7 @@ snapshots: fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(postcss@8.5.16)(tsx@4.23.0)(yaml@2.9.0) + postcss-load-config: 6.0.1(jiti@2.7.0)(postcss@8.5.16)(tsx@4.23.0)(yaml@2.9.0) resolve-from: 5.0.0 rollup: 4.62.2 source-map: 0.7.6 @@ -4099,9 +4605,13 @@ snapshots: unicorn-magic@0.3.0: {} + use-sync-external-store@1.6.0(react@19.2.7): + dependencies: + react: 19.2.7 + util-deprecate@1.0.2: {} - vite@8.1.3(@types/node@26.1.0)(esbuild@0.27.7)(tsx@4.23.0)(yaml@2.9.0): + vite@8.1.3(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.5 @@ -4112,13 +4622,29 @@ snapshots: '@types/node': 26.1.0 esbuild: 0.27.7 fsevents: 2.3.3 + jiti: 2.7.0 + tsx: 4.23.0 + yaml: 2.9.0 + + vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.5 + postcss: 8.5.16 + rolldown: 1.1.4 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.1.0 + esbuild: 0.28.1 + fsevents: 2.3.3 + jiti: 2.7.0 tsx: 4.23.0 yaml: 2.9.0 - vitest@4.1.10(@types/node@26.1.0)(vite@8.1.3(@types/node@26.1.0)(esbuild@0.27.7)(tsx@4.23.0)(yaml@2.9.0)): + vitest@4.1.10(@types/node@26.1.0)(vite@8.1.3(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@8.1.3(@types/node@26.1.0)(esbuild@0.27.7)(tsx@4.23.0)(yaml@2.9.0)) + '@vitest/mocker': 4.1.10(vite@8.1.3(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.10 '@vitest/runner': 4.1.10 '@vitest/snapshot': 4.1.10 @@ -4135,7 +4661,7 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.1.3(@types/node@26.1.0)(esbuild@0.27.7)(tsx@4.23.0)(yaml@2.9.0) + vite: 8.1.3(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 26.1.0 From 7a3ef439aa1f6c2a2696310226c5976d3fa4b3a1 Mon Sep 17 00:00:00 2001 From: Annactswell Date: Thu, 9 Jul 2026 22:56:47 +0800 Subject: [PATCH 014/133] The console rebuilt on shadcn and file routes: create and detail join release --- README.md | 8 +- biome.json | 10 +- packages/web/components.json | 25 + packages/web/package.json | 22 +- packages/web/src/api.ts | 43 - packages/web/src/components/ui/accordion.tsx | 76 + .../web/src/components/ui/alert-dialog.tsx | 187 + packages/web/src/components/ui/alert.tsx | 76 + .../web/src/components/ui/aspect-ratio.tsx | 22 + packages/web/src/components/ui/attachment.tsx | 207 ++ packages/web/src/components/ui/avatar.tsx | 107 + packages/web/src/components/ui/badge.tsx | 52 + packages/web/src/components/ui/breadcrumb.tsx | 125 + packages/web/src/components/ui/bubble.tsx | 128 + .../web/src/components/ui/button-group.tsx | 87 + packages/web/src/components/ui/button.tsx | 56 + packages/web/src/components/ui/calendar.tsx | 220 ++ packages/web/src/components/ui/card.tsx | 100 + packages/web/src/components/ui/carousel.tsx | 243 ++ packages/web/src/components/ui/chart.tsx | 373 ++ packages/web/src/components/ui/checkbox.tsx | 29 + .../web/src/components/ui/collapsible.tsx | 19 + packages/web/src/components/ui/combobox.tsx | 298 ++ packages/web/src/components/ui/command.tsx | 195 + .../web/src/components/ui/context-menu.tsx | 270 ++ packages/web/src/components/ui/dialog.tsx | 158 + packages/web/src/components/ui/direction.tsx | 4 + packages/web/src/components/ui/drawer.tsx | 228 ++ .../web/src/components/ui/dropdown-menu.tsx | 265 ++ packages/web/src/components/ui/empty.tsx | 104 + packages/web/src/components/ui/field.tsx | 238 ++ packages/web/src/components/ui/hover-card.tsx | 51 + .../web/src/components/ui/input-group.tsx | 155 + packages/web/src/components/ui/input-otp.tsx | 85 + packages/web/src/components/ui/input.tsx | 20 + packages/web/src/components/ui/item.tsx | 201 ++ packages/web/src/components/ui/kbd.tsx | 26 + packages/web/src/components/ui/label.tsx | 20 + packages/web/src/components/ui/marker.tsx | 71 + packages/web/src/components/ui/menubar.tsx | 277 ++ .../src/components/ui/message-scroller.tsx | 131 + packages/web/src/components/ui/message.tsx | 92 + .../web/src/components/ui/native-select.tsx | 62 + .../web/src/components/ui/navigation-menu.tsx | 169 + packages/web/src/components/ui/pagination.tsx | 130 + packages/web/src/components/ui/popover.tsx | 88 + packages/web/src/components/ui/progress.tsx | 83 + .../web/src/components/ui/radio-group.tsx | 36 + packages/web/src/components/ui/resizable.tsx | 50 + .../web/src/components/ui/scroll-area.tsx | 53 + packages/web/src/components/ui/select.tsx | 200 + packages/web/src/components/ui/separator.tsx | 23 + packages/web/src/components/ui/sheet.tsx | 136 + packages/web/src/components/ui/sidebar.tsx | 725 ++++ packages/web/src/components/ui/skeleton.tsx | 13 + packages/web/src/components/ui/slider.tsx | 52 + packages/web/src/components/ui/sonner.tsx | 50 + packages/web/src/components/ui/spinner.tsx | 14 + packages/web/src/components/ui/switch.tsx | 30 + packages/web/src/components/ui/table.tsx | 116 + packages/web/src/components/ui/tabs.tsx | 80 + packages/web/src/components/ui/textarea.tsx | 18 + .../web/src/components/ui/toggle-group.tsx | 89 + packages/web/src/components/ui/toggle.tsx | 45 + packages/web/src/components/ui/tooltip.tsx | 64 + .../web/src/features/auth/pages/LoginPage.tsx | 81 + .../components/CreateSandboxDialog.tsx | 183 + .../components/ReleaseSandboxButton.tsx | 68 + .../components/SandboxStateBadge.tsx | 24 + packages/web/src/features/sandboxes/format.ts | 30 + .../features/sandboxes/hooks/useSandboxes.ts | 44 + .../sandboxes/pages/SandboxDetailPage.tsx | 109 + .../sandboxes/pages/SandboxesPage.tsx | 127 + packages/web/src/hooks/use-mobile.ts | 19 + packages/web/src/index.css | 4 + packages/web/src/lib/api.ts | 79 + packages/web/src/lib/queryClient.ts | 3 + packages/web/src/lib/session.ts | 21 + packages/web/src/lib/utils.ts | 6 + packages/web/src/main.tsx | 46 +- packages/web/src/pages/login.tsx | 53 - packages/web/src/pages/sandboxes.tsx | 204 -- packages/web/src/routeTree.gen.ts | 118 + packages/web/src/routes/__root.tsx | 11 + packages/web/src/routes/_app.tsx | 18 + packages/web/src/routes/_app/index.tsx | 6 + .../src/routes/_app/sandboxes/$userKey.tsx | 6 + packages/web/src/routes/login.tsx | 30 + packages/web/src/shadcn.css | 130 + packages/web/src/styles.css | 1 - packages/web/tsconfig.json | 4 +- packages/web/vite.config.ts | 12 +- pnpm-lock.yaml | 3205 ++++++++++++++++- 93 files changed, 11721 insertions(+), 351 deletions(-) create mode 100644 packages/web/components.json delete mode 100644 packages/web/src/api.ts create mode 100644 packages/web/src/components/ui/accordion.tsx create mode 100644 packages/web/src/components/ui/alert-dialog.tsx create mode 100644 packages/web/src/components/ui/alert.tsx create mode 100644 packages/web/src/components/ui/aspect-ratio.tsx create mode 100644 packages/web/src/components/ui/attachment.tsx create mode 100644 packages/web/src/components/ui/avatar.tsx create mode 100644 packages/web/src/components/ui/badge.tsx create mode 100644 packages/web/src/components/ui/breadcrumb.tsx create mode 100644 packages/web/src/components/ui/bubble.tsx create mode 100644 packages/web/src/components/ui/button-group.tsx create mode 100644 packages/web/src/components/ui/button.tsx create mode 100644 packages/web/src/components/ui/calendar.tsx create mode 100644 packages/web/src/components/ui/card.tsx create mode 100644 packages/web/src/components/ui/carousel.tsx create mode 100644 packages/web/src/components/ui/chart.tsx create mode 100644 packages/web/src/components/ui/checkbox.tsx create mode 100644 packages/web/src/components/ui/collapsible.tsx create mode 100644 packages/web/src/components/ui/combobox.tsx create mode 100644 packages/web/src/components/ui/command.tsx create mode 100644 packages/web/src/components/ui/context-menu.tsx create mode 100644 packages/web/src/components/ui/dialog.tsx create mode 100644 packages/web/src/components/ui/direction.tsx create mode 100644 packages/web/src/components/ui/drawer.tsx create mode 100644 packages/web/src/components/ui/dropdown-menu.tsx create mode 100644 packages/web/src/components/ui/empty.tsx create mode 100644 packages/web/src/components/ui/field.tsx create mode 100644 packages/web/src/components/ui/hover-card.tsx create mode 100644 packages/web/src/components/ui/input-group.tsx create mode 100644 packages/web/src/components/ui/input-otp.tsx create mode 100644 packages/web/src/components/ui/input.tsx create mode 100644 packages/web/src/components/ui/item.tsx create mode 100644 packages/web/src/components/ui/kbd.tsx create mode 100644 packages/web/src/components/ui/label.tsx create mode 100644 packages/web/src/components/ui/marker.tsx create mode 100644 packages/web/src/components/ui/menubar.tsx create mode 100644 packages/web/src/components/ui/message-scroller.tsx create mode 100644 packages/web/src/components/ui/message.tsx create mode 100644 packages/web/src/components/ui/native-select.tsx create mode 100644 packages/web/src/components/ui/navigation-menu.tsx create mode 100644 packages/web/src/components/ui/pagination.tsx create mode 100644 packages/web/src/components/ui/popover.tsx create mode 100644 packages/web/src/components/ui/progress.tsx create mode 100644 packages/web/src/components/ui/radio-group.tsx create mode 100644 packages/web/src/components/ui/resizable.tsx create mode 100644 packages/web/src/components/ui/scroll-area.tsx create mode 100644 packages/web/src/components/ui/select.tsx create mode 100644 packages/web/src/components/ui/separator.tsx create mode 100644 packages/web/src/components/ui/sheet.tsx create mode 100644 packages/web/src/components/ui/sidebar.tsx create mode 100644 packages/web/src/components/ui/skeleton.tsx create mode 100644 packages/web/src/components/ui/slider.tsx create mode 100644 packages/web/src/components/ui/sonner.tsx create mode 100644 packages/web/src/components/ui/spinner.tsx create mode 100644 packages/web/src/components/ui/switch.tsx create mode 100644 packages/web/src/components/ui/table.tsx create mode 100644 packages/web/src/components/ui/tabs.tsx create mode 100644 packages/web/src/components/ui/textarea.tsx create mode 100644 packages/web/src/components/ui/toggle-group.tsx create mode 100644 packages/web/src/components/ui/toggle.tsx create mode 100644 packages/web/src/components/ui/tooltip.tsx create mode 100644 packages/web/src/features/auth/pages/LoginPage.tsx create mode 100644 packages/web/src/features/sandboxes/components/CreateSandboxDialog.tsx create mode 100644 packages/web/src/features/sandboxes/components/ReleaseSandboxButton.tsx create mode 100644 packages/web/src/features/sandboxes/components/SandboxStateBadge.tsx create mode 100644 packages/web/src/features/sandboxes/format.ts create mode 100644 packages/web/src/features/sandboxes/hooks/useSandboxes.ts create mode 100644 packages/web/src/features/sandboxes/pages/SandboxDetailPage.tsx create mode 100644 packages/web/src/features/sandboxes/pages/SandboxesPage.tsx create mode 100644 packages/web/src/hooks/use-mobile.ts create mode 100644 packages/web/src/index.css create mode 100644 packages/web/src/lib/api.ts create mode 100644 packages/web/src/lib/queryClient.ts create mode 100644 packages/web/src/lib/session.ts create mode 100644 packages/web/src/lib/utils.ts delete mode 100644 packages/web/src/pages/login.tsx delete mode 100644 packages/web/src/pages/sandboxes.tsx create mode 100644 packages/web/src/routeTree.gen.ts create mode 100644 packages/web/src/routes/__root.tsx create mode 100644 packages/web/src/routes/_app.tsx create mode 100644 packages/web/src/routes/_app/index.tsx create mode 100644 packages/web/src/routes/_app/sandboxes/$userKey.tsx create mode 100644 packages/web/src/routes/login.tsx create mode 100644 packages/web/src/shadcn.css delete mode 100644 packages/web/src/styles.css diff --git a/README.md b/README.md index b7d3270..0d9c7aa 100644 --- a/README.md +++ b/README.md @@ -34,9 +34,11 @@ The daemon serves a small web console at `http://127.0.0.1:3676/ui` — sign in with the API token once and it becomes an httpOnly session cookie; the token itself is never stored anywhere the page can read. The console shows every sandbox with its live lifecycle state (the same -`/listSandboxes` the SDK sees) and can release sandboxes. Since the daemon -listens on 127.0.0.1 only, reach a remote host's console through an SSH -tunnel: `ssh -L 3676:127.0.0.1:3676 root@host`. +`/listSandboxes` the SDK sees), opens a per-sandbox detail view, creates +sandboxes (the same idempotent `acquire`, with the lifecycle knobs), and +releases them. Since the daemon listens on 127.0.0.1 only, reach a remote +host's console through an SSH tunnel: +`ssh -L 3676:127.0.0.1:3676 root@host`. ## Host prerequisites (docker executor) diff --git a/biome.json b/biome.json index d2fe836..21514d9 100644 --- a/biome.json +++ b/biome.json @@ -1,7 +1,15 @@ { "$schema": "https://biomejs.dev/schemas/2.5.2/schema.json", "files": { - "includes": ["**", "!packages/*/drizzle"] + "includes": [ + "**", + "!packages/*/drizzle", + "!packages/web/src/components/ui", + "!packages/web/src/hooks/use-mobile.ts", + "!packages/web/src/lib/utils.ts", + "!packages/web/src/shadcn.css", + "!packages/web/src/routeTree.gen.ts" + ] }, "vcs": { "enabled": true, diff --git a/packages/web/components.json b/packages/web/components.json new file mode 100644 index 0000000..f9d6812 --- /dev/null +++ b/packages/web/components.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "base-rhea", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/shadcn.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "hugeicons", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "menuColor": "default-translucent", + "menuAccent": "subtle", + "registries": {} +} diff --git a/packages/web/package.json b/packages/web/package.json index 6a31fe6..ae33ed1 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -11,17 +11,37 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@base-ui/react": "^1.6.0", + "@fontsource-variable/inter": "^5.2.8", + "@hugeicons/core-free-icons": "^4.2.2", + "@hugeicons/react": "^1.1.9", + "@shadcn/react": "^0.2.1", "@tanstack/react-query": "^5.101.2", "@tanstack/react-router": "^1.170.17", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "date-fns": "^4.4.0", + "embla-carousel-react": "^8.6.0", + "input-otp": "^1.4.2", + "next-themes": "^0.4.6", "react": "^19.2.7", - "react-dom": "^19.2.7" + "react-day-picker": "^10.0.1", + "react-dom": "^19.2.7", + "react-resizable-panels": "^4.12.1", + "recharts": "3.8.0", + "sonner": "^2.0.7", + "tailwind-merge": "^3.6.0", + "tw-animate-css": "^1.4.0" }, "devDependencies": { "@dormice/shared": "workspace:*", "@tailwindcss/vite": "^4.3.2", + "@tanstack/router-plugin": "^1.168.19", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.3", + "shadcn": "^4.13.0", "tailwindcss": "^4.3.2", "vite": "^8.1.3" } diff --git a/packages/web/src/api.ts b/packages/web/src/api.ts deleted file mode 100644 index 6258c26..0000000 --- a/packages/web/src/api.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Sandbox } from '@dormice/shared'; - -/** - * The console talks to the daemon's native RPC routes — the same routes, - * same truth as the SDK and CLI — authenticated by the httpOnly session - * cookie the browser attaches on its own. The custom header is the second - * half of the CSRF defense: a cross-origin page cannot send it without a - * CORS preflight, and the daemon answers no preflights. - */ -export class UnauthorizedError extends Error {} - -async function rpc(path: string, body: unknown = {}): Promise { - const res = await fetch(path, { - method: 'POST', - headers: { - 'content-type': 'application/json', - 'x-dormice-console': '1', - }, - body: JSON.stringify(body), - }); - if (res.status === 401) { - throw new UnauthorizedError('not logged in'); - } - if (!res.ok) { - const message = await res - .json() - .then((data: { message?: string }) => data.message) - .catch(() => undefined); - throw new Error(message ?? `${path} failed with ${res.status}`); - } - return res.json() as Promise; -} - -export const login = (token: string) => - rpc<{ loggedIn: true }>('/ui/auth/login', { token }); - -export const logout = () => rpc<{ loggedIn: false }>('/ui/auth/logout'); - -export const listSandboxes = () => - rpc<{ sandboxes: Sandbox[] }>('/listSandboxes'); - -export const releaseSandbox = (userKey: string) => - rpc<{ released: boolean }>('/releaseSandbox', { userKey }); diff --git a/packages/web/src/components/ui/accordion.tsx b/packages/web/src/components/ui/accordion.tsx new file mode 100644 index 0000000..51081d0 --- /dev/null +++ b/packages/web/src/components/ui/accordion.tsx @@ -0,0 +1,76 @@ +import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion" + +import { cn } from "@/lib/utils" +import { HugeiconsIcon } from "@hugeicons/react" +import { ArrowDown01Icon, ArrowUp01Icon } from "@hugeicons/core-free-icons" + +function Accordion({ className, ...props }: AccordionPrimitive.Root.Props) { + return ( + + ) +} + +function AccordionItem({ className, ...props }: AccordionPrimitive.Item.Props) { + return ( + + ) +} + +function AccordionTrigger({ + className, + children, + ...props +}: AccordionPrimitive.Trigger.Props) { + return ( + + + {children} + + + + + ) +} + +function AccordionContent({ + className, + children, + ...props +}: AccordionPrimitive.Panel.Props) { + return ( + +
+ {children} +
+
+ ) +} + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/packages/web/src/components/ui/alert-dialog.tsx b/packages/web/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..d7f9f66 --- /dev/null +++ b/packages/web/src/components/ui/alert-dialog.tsx @@ -0,0 +1,187 @@ +"use client" + +import * as React from "react" +import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" + +function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) { + return +} + +function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) { + return ( + + ) +} + +function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) { + return ( + + ) +} + +function AlertDialogOverlay({ + className, + ...props +}: AlertDialogPrimitive.Backdrop.Props) { + return ( + + ) +} + +function AlertDialogContent({ + className, + size = "default", + ...props +}: AlertDialogPrimitive.Popup.Props & { + size?: "default" | "sm" +}) { + return ( + + + + + ) +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogMedia({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogAction({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CarouselNext({ + className, + variant = "outline", + size = "icon-sm", + ...props +}: React.ComponentProps) { + const { orientation, scrollNext, canScrollNext } = useCarousel() + + return ( + + ) +} + +export { + type CarouselApi, + Carousel, + CarouselContent, + CarouselItem, + CarouselPrevious, + CarouselNext, + useCarousel, +} diff --git a/packages/web/src/components/ui/chart.tsx b/packages/web/src/components/ui/chart.tsx new file mode 100644 index 0000000..17a5927 --- /dev/null +++ b/packages/web/src/components/ui/chart.tsx @@ -0,0 +1,373 @@ +"use client" + +import * as React from "react" +import * as RechartsPrimitive from "recharts" +import type { TooltipValueType } from "recharts" + +import { cn } from "@/lib/utils" + +// Format: { THEME_NAME: CSS_SELECTOR } +const THEMES = { light: "", dark: ".dark" } as const + +const INITIAL_DIMENSION = { width: 320, height: 200 } as const +type TooltipNameType = number | string + +export type ChartConfig = Record< + string, + { + label?: React.ReactNode + icon?: React.ComponentType + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record } + ) +> + +type ChartContextProps = { + config: ChartConfig +} + +const ChartContext = React.createContext(null) + +function useChart() { + const context = React.useContext(ChartContext) + + if (!context) { + throw new Error("useChart must be used within a ") + } + + return context +} + +function ChartContainer({ + id, + className, + children, + config, + initialDimension = INITIAL_DIMENSION, + ...props +}: React.ComponentProps<"div"> & { + config: ChartConfig + children: React.ComponentProps< + typeof RechartsPrimitive.ResponsiveContainer + >["children"] + initialDimension?: { + width: number + height: number + } +}) { + const uniqueId = React.useId() + const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}` + + return ( + +
+ + + {children} + +
+
+ ) +} + +const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { + const colorConfig = Object.entries(config).filter( + ([, config]) => config.theme ?? config.color + ) + + if (!colorConfig.length) { + return null + } + + return ( +