Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,32 @@ PASSKEY_ORIGIN=http://localhost:5003
# VAPID_PRIVATE_KEY=
# VAPID_SUBJECT=mailto:you@yourdomain.com

# ============================================
# Agent Sandboxing (microsandbox)
# Sandboxed agent commands run inside a microVM managed by msb. Linux host
# with /dev/kvm is required; enable the sandbox overlay to grant the container
# KVM access and persist sandbox state:
# docker compose -f docker-compose.yml -f docker-compose.sandbox.yml up -d
# ============================================
# OCI image the microVM boots from (guest default user: node, uid 1000)
# SANDBOX_IMAGE=node:24
# MicroVM memory (e.g. 4G)
# SANDBOX_MEMORY=4G
# MicroVM CPU count
# SANDBOX_CPUS=2
# Guest identity sandboxed commands run as: a numeric uid, a numeric uid:gid,
# or a guest username. Defaults to PUID so the guest identity always matches
# the workspace owner; a guest username is resolved to the Manager's uid:gid.
# When a configured numeric identity cannot match the workspace owner,
# enforcement is reported unavailable.
# SANDBOX_EXEC_USER=${PUID:-1000}
# Network mode for the microVM (public or private)
# SANDBOX_NET=public
# Timeout for microVM startup, in milliseconds
# SANDBOX_START_TIMEOUT_MS=300000
# Timeout for a single sandboxed command, in milliseconds
# SANDBOX_EXEC_TIMEOUT_MS=600000

# ============================================
# Frontend Configuration (Vite)
# These are optional - frontend uses defaults if not set
Expand Down
7 changes: 5 additions & 2 deletions .github/workflows/docker-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,12 @@ jobs:
id: versions
run: |
UV_VERSION=$(git ls-remote --tags --sort=-v:refname https://github.com/astral-sh/uv.git 'refs/tags/[0-9]*' | head -1 | sed 's/.*refs\/tags\///')
OPENCODE_VERSION=$(git ls-remote --tags --sort=-v:refname https://github.com/anomalyco/opencode.git 'refs/tags/v[0-9]*' | head -1 | sed 's/.*refs\/tags\/v//')
OPENCODE_VERSION=1.18.16
MICROSANDBOX_VERSION=0.6.8
echo "uv=${UV_VERSION}" >> $GITHUB_OUTPUT
echo "opencode=${OPENCODE_VERSION}" >> $GITHUB_OUTPUT
echo "Detected versions: uv=${UV_VERSION}, opencode=${OPENCODE_VERSION}"
echo "microsandbox=${MICROSANDBOX_VERSION}" >> $GITHUB_OUTPUT
echo "Detected versions: uv=${UV_VERSION}, opencode=${OPENCODE_VERSION}, microsandbox=${MICROSANDBOX_VERSION}"

- name: Docker meta
id: meta
Expand Down Expand Up @@ -60,6 +62,7 @@ jobs:
build-args: |
UV_VERSION=${{ steps.versions.outputs.uv }}
OPENCODE_VERSION=${{ steps.versions.outputs.opencode }}
MICROSANDBOX_VERSION=${{ steps.versions.outputs.microsandbox }}
cache-from: type=gha
cache-to: type=gha,mode=max
target: runner
Expand Down
38 changes: 35 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM node:24.13.0 AS base
FROM node:24.13.0-trixie AS base

RUN apt-get update && apt-get install -y \
git \
Expand Down Expand Up @@ -59,7 +59,8 @@ RUN pnpm --filter frontend build
FROM base AS runner

ARG UV_VERSION=latest
ARG OPENCODE_VERSION=latest
ARG OPENCODE_VERSION=1.18.16
ARG MICROSANDBOX_VERSION=0.6.8
# Bump TOOLS_CACHEBUST (e.g. via --build-arg) to force a fresh uv/opencode
# install without invalidating the rest of the build cache.
ARG TOOLS_CACHEBUST=0
Expand Down Expand Up @@ -87,13 +88,44 @@ RUN echo "Installing uv=${UV_VERSION} opencode=${OPENCODE_VERSION} (cachebust=${
ln -s /opt/opencode/bin/opencode /usr/local/bin/opencode && \
echo "opencode ${OPENCODE_VERSION} installed successfully"

RUN echo "Installing microsandbox=${MICROSANDBOX_VERSION} (cachebust=${TOOLS_CACHEBUST})" && \
MSB_ARCH=$(uname -m) && \
if [ "$MSB_ARCH" = "x86_64" ] || [ "$MSB_ARCH" = "amd64" ]; then MSB_TARGET="x86_64"; \
elif [ "$MSB_ARCH" = "aarch64" ] || [ "$MSB_ARCH" = "arm64" ]; then MSB_TARGET="aarch64"; \
else echo "ERROR: microsandbox does not support architecture: $MSB_ARCH" >&2; exit 1; fi && \
MSB_BUNDLE="microsandbox-linux-${MSB_TARGET}.tar.gz" && \
case "${MICROSANDBOX_VERSION}" in v*) MSB_VERSION="${MICROSANDBOX_VERSION}" ;; *) MSB_VERSION="v${MICROSANDBOX_VERSION}" ;; esac && \
MSB_BASE_URL="https://github.com/superradcompany/microsandbox/releases/download/${MSB_VERSION}" && \
curl -fsSL "${MSB_BASE_URL}/${MSB_BUNDLE}" -o "/tmp/${MSB_BUNDLE}" && \
curl -fsSL "${MSB_BASE_URL}/checksums.sha256" -o /tmp/checksums.sha256 && \
cd /tmp && \
grep -F "${MSB_BUNDLE}" checksums.sha256 | sha256sum -c --quiet - && \
mkdir -p /opt/microsandbox/bin /opt/microsandbox/lib && \
tar -xzf "/tmp/${MSB_BUNDLE}" -C /tmp && \
install -m 755 /tmp/msb /opt/microsandbox/bin/msb && \
ln -sf msb /opt/microsandbox/bin/microsandbox && \
ln -s /opt/microsandbox/bin/msb /usr/local/bin/msb && \
MSB_LIB=$(find /tmp -maxdepth 1 -type f -name 'libkrunfw.so.*.*.*' | head -1) && \
MSB_LIB_NAME=$(basename "$MSB_LIB") && \
MSB_LIB_ABI=${MSB_LIB_NAME#libkrunfw.so.} && \
MSB_LIB_ABI=${MSB_LIB_ABI%%.*} && \
install -m 644 "$MSB_LIB" "/opt/microsandbox/lib/${MSB_LIB_NAME}" && \
ln -sf "$MSB_LIB_NAME" "/opt/microsandbox/lib/libkrunfw.so.${MSB_LIB_ABI}" && \
ln -sf "libkrunfw.so.${MSB_LIB_ABI}" /opt/microsandbox/lib/libkrunfw.so && \
rm -f "/tmp/${MSB_BUNDLE}" /tmp/checksums.sha256 /tmp/msb /tmp/libkrunfw.so.* && \
chmod -R a+rX /opt/microsandbox && \
msb --version

ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=5003
ENV OPENCODE_SERVER_PORT=5551
ENV DATABASE_PATH=/app/data/opencode.db
ENV WORKSPACE_PATH=/workspace
ENV XDG_CACHE_HOME=/home/node/.cache
ENV OPENCODE_BUNDLED_VERSION=${OPENCODE_VERSION}
ENV MSB_PATH=/usr/local/bin/msb
ENV MSB_LIBKRUNFW_PATH=/opt/microsandbox/lib/libkrunfw.so

COPY --from=deps --chown=node:node /app/node_modules ./node_modules
COPY --from=builder /app/shared ./shared
Expand All @@ -110,7 +142,7 @@ COPY scripts/lib/container-user.sh /usr/local/lib/ocm/container-user.sh
COPY scripts/docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh

RUN mkdir -p /workspace /app/data /home/node/.cache /home/node/.opencode && \
RUN mkdir -p /workspace /app/data /home/node/.cache /home/node/.opencode /home/node/.microsandbox && \
chown -R node:node /workspace /app/data /home/node

EXPOSE 5003 5100 5101 5102 5103
Expand Down
2 changes: 1 addition & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"build": "bun build src/index.ts --outdir=dist --target=bun",
"typecheck": "tsc --noEmit",
"test": "pnpm run test:bun && pnpm run test:vitest",
"test:bun": "bun test test/services/assistant-mode.test.ts test/services/internal-token.test.ts test/auth/internal-token-middleware.test.ts test/routes/internal-schedules.test.ts test/routes/internal-notifications.test.ts test/routes/internal-settings.test.ts test/routes/internal-repos.test.ts test/routes/internal-assistant.test.ts src/db/model-state.test.ts src/routes/providers.test.ts src/routes/repos.test.ts src/routes/session-pins.test.ts",
"test:bun": "bun test test/services/assistant-mode.test.ts test/services/internal-token.test.ts test/auth/internal-token-middleware.test.ts test/routes/internal-schedules.test.ts test/routes/internal-notifications.test.ts test/routes/internal-settings.test.ts test/routes/internal-repos.test.ts test/routes/internal-assistant.test.ts test/routes/internal-sandbox.test.ts src/db/model-state.test.ts src/routes/providers.test.ts src/routes/repos.test.ts src/routes/session-pins.test.ts",
"test:vitest": "vitest run",
"test:ui": "vitest --ui",
"test:watch": "vitest --watch",
Expand Down
30 changes: 14 additions & 16 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { createSessionPinRoutes } from './routes/session-pins'
import { createInternalRoutes } from './routes/internal'
import { sweepStaleUploadSessions } from './routes/internal/repo-mirror-helpers'
import { createOpenCodeProxyRoutes } from './routes/opencode-proxy'
import { createAuthenticatedOpenCodeProxyRoutes } from './routes/opencode-auth-proxy'
import { sseAggregator } from './services/sse-aggregator'
import { ensureDirectoryExists, writeFileContent, fileExists, readFileContent } from './services/file-operations'
import { SettingsService } from './services/settings'
Expand All @@ -50,6 +51,7 @@ import { CredentialProvider } from './services/credential-provider'
import { ScheduleWorktreeManager } from './services/schedule-worktree'
import { migrateGlobalSkills } from './services/skills'
import { installAssistantWorkspace } from './services/assistant-mode'
import { stopWorkspaceSandboxOnShutdown } from './services/sandbox/runtime'
import { getOpenCodeImportStatus, syncOpenCodeImport } from './services/opencode-import'
import { OpenCodeSupervisor } from './services/opencode-supervisor'
import { OpenCodeRestartCoordinator } from './services/opencode-restart-coordinator'
Expand Down Expand Up @@ -104,7 +106,10 @@ app.use('/*', cors({
const db = initializeDatabase(DB_PATH)
const auth = createAuth(db)
const requireAuth = createAuthMiddleware(auth)
const openCodeClient = createOpenCodeClient(() => new SettingsService(db).getOpenCodeServerPassword())
const openCodeClient = createOpenCodeClient(
() => new SettingsService(db).getOpenCodeServerPassword(),
() => opencodeServerManager.getEffectiveServerHost(),
)

import { DEFAULT_AGENTS_MD } from './constants'

Expand Down Expand Up @@ -293,6 +298,7 @@ try {

opencodeServerManager.setDatabase(db)
const openCodeStatus = await openCodeSupervisor.start()
opencodeServerManager.setLifecycleInitialized(openCodeStatus.healthy)
if (openCodeStatus.healthy) {
logger.info(`OpenCode server running on port ${openCodeStatus.port}`)
} else {
Expand Down Expand Up @@ -373,21 +379,7 @@ protectedApi.route('/schedules', createScheduleRoutes(scheduleService))

app.route('/api', protectedApi)

app.post('/api/opencode/mcp/:name/auth', requireAuth, async (c) => {
const serverName = c.req.param('name')
const directory = c.req.query('directory')
return openCodeClient.startMcpAuth(serverName, directory)
})

app.post('/api/opencode/mcp/:name/auth/authenticate', requireAuth, async (c) => {
const serverName = c.req.param('name')
const directory = c.req.query('directory')
return openCodeClient.authenticateMcp(serverName, directory)
})

app.all('/api/opencode/*', requireAuth, async (c) => {
return openCodeClient.forwardRaw(c.req.raw)
})
app.route('/api/opencode', createAuthenticatedOpenCodeProxyRoutes(openCodeClient, requireAuth))

const isProduction = ENV.SERVER.NODE_ENV === 'production'

Expand Down Expand Up @@ -483,6 +475,12 @@ const shutdown = async (signal: string) => {
} catch (error) {
logger.error('Error during shutdown:', error)
}
try {
await stopWorkspaceSandboxOnShutdown(db)
logger.info('Workspace sandbox stopped')
} catch (error) {
logger.error('Error stopping workspace sandbox:', error)
}
process.exit(0)
}

Expand Down
18 changes: 18 additions & 0 deletions backend/src/routes/health.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { opencodeServerManager } from '../services/opencode-single-server'
import type { OpenCodeSupervisor } from '../services/opencode-supervisor'
import { compareVersions } from '../utils/version-utils'
import { githubFetch } from '../utils/github'
import { logger } from '../utils/logger'
import { SandboxRuntimeService } from '../services/sandbox/runtime'

const GITHUB_REPO_OWNER = 'chriswritescode-dev'
const GITHUB_REPO_NAME = 'opencode-manager'
Expand Down Expand Up @@ -94,6 +96,22 @@ export function createHealthRoutes(db: Database, openCodeSupervisor?: OpenCodeSu
opencodeRestartPending: opencodeServerManager.isRestartPending(),
}

try {
const runtimeStatus = new SandboxRuntimeService(db).getStatus()
response.sandbox = {
...runtimeStatus,
enforced: opencodeServerManager.isSandboxEnforced(),
}
} catch (error) {
logger.error('Failed to collect sandbox status', error)
response.sandbox = {
available: false,
enabled: false,
enforced: opencodeServerManager.isSandboxEnforced(),
reason: error instanceof Error ? error.message : 'sandbox status unavailable',
}
}

if (lifecycle) {
response.opencodeLifecycle = lifecycle
}
Expand Down
2 changes: 2 additions & 0 deletions backend/src/routes/internal/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { createInternalRepoMirrorRoutes as mirrorRoutes } from './repo-mirror'
import { createInternalOpenCodeWorkspacesRoutes } from './opencode-workspaces'
import { createInternalAssistantRoutes } from './assistant'
import { createInternalGitCredentialsRoutes } from './git-credentials'
import { createInternalSandboxRoutes } from './sandbox'

export function createInternalRoutes(
db: Database,
Expand All @@ -36,5 +37,6 @@ export function createInternalRoutes(
app.route('/opencode-workspaces', createInternalOpenCodeWorkspacesRoutes(db))
app.route('/assistant', createInternalAssistantRoutes(openCodeClient))
app.route('/git-credentials', createInternalGitCredentialsRoutes(db))
app.route('/sandbox', createInternalSandboxRoutes(db))
return app
}
59 changes: 41 additions & 18 deletions backend/src/routes/internal/repo-mirror-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { existsSync, mkdtempSync, readdirSync, statSync } from 'fs'
import * as fsp from 'fs/promises'
import { createReadStream } from 'fs'
import { dirname, join } from 'path'
import { Readable } from 'stream'
import { pipeline } from 'stream/promises'
import { randomUUID } from 'crypto'
import { getReposPath } from '@opencode-manager/shared/config/env'
Expand Down Expand Up @@ -94,11 +95,23 @@ export interface ExtractResult {
staging: string
}

function isStdinClosedError(error: unknown): boolean {
if (typeof error !== 'object' || error === null) return false
const code = (error as { code?: unknown }).code
return code === 'ERR_STREAM_PREMATURE_CLOSE' || code === 'EPIPE'
}

export async function extractPartsToStaging(uploadId: string, totalParts: number, gzip: boolean): Promise<ExtractResult> {
if (!isValidTotalParts(totalParts)) {
throw new Error(TOTAL_PARTS_INVALID_MESSAGE)
}

for (let i = 0; i < totalParts; i++) {
if (!existsSync(getPartPath(uploadId, i))) {
throw new Error(`missing part ${i} for upload ${uploadId}`)
}
}

const stagingParent = getStagingRoot()
mkdirSyncSafe(stagingParent)
const staging = mkdtempSync(join(stagingParent, 'recv-'))
Expand All @@ -110,30 +123,40 @@ export async function extractPartsToStaging(uploadId: string, totalParts: number
const stderrChunks: Buffer[] = []
child.stderr.on('data', (chunk: Buffer) => stderrChunks.push(chunk))

const tarDone = new Promise<void>((resolve, reject) => {
child.on('close', (code) => {
if (code === 0) resolve()
else {
const stderr = Buffer.concat(stderrChunks).toString('utf-8').trim()
reject(new Error(`tar exited with code ${code}${stderr ? `: ${stderr}` : ''}`))
}
})
const tarDone = new Promise<number | null>((resolve, reject) => {
child.on('close', (code) => resolve(code))
child.on('error', reject)
})

try {
for (let i = 0; i < totalParts; i++) {
const partPath = getPartPath(uploadId, i)
if (!existsSync(partPath)) {
throw new Error(`missing part ${i} for upload ${uploadId}`)
tarDone.catch(() => {})

let writeError: unknown = null
const partStreams = Readable.from(
(async function* () {
for (let i = 0; i < totalParts; i++) {
for await (const chunk of createReadStream(getPartPath(uploadId, i))) {
yield chunk
}
}
await pipeline(createReadStream(partPath), child.stdin, { end: i === totalParts - 1 })
}
await tarDone
})(),
)
try {
await pipeline(partStreams, child.stdin, { end: true })
} catch (err) {
writeError = err
}

if (writeError && !isStdinClosedError(writeError)) {
if (!child.killed) child.kill('SIGKILL')
await fsp.rm(staging, { recursive: true, force: true }).catch(() => {})
throw err
throw writeError
}

const exitCode = await tarDone

if (exitCode !== 0) {
const stderr = Buffer.concat(stderrChunks).toString('utf-8').trim()
await fsp.rm(staging, { recursive: true, force: true }).catch(() => {})
throw new Error(`tar exited with code ${exitCode}${stderr ? `: ${stderr}` : ''}`)
}

let extractedRoot = staging
Expand Down
40 changes: 40 additions & 0 deletions backend/src/routes/internal/sandbox.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { Hono } from 'hono'
import { z } from 'zod'
import type { Database } from 'bun:sqlite'
import { SandboxRuntimeService } from '../../services/sandbox/runtime'
import { logger } from '../../utils/logger'

const SandboxCommandRequestSchema = z.object({
directory: z.string().min(1),
command: z.string().min(1),
enforced: z.boolean().optional(),
})

export function createInternalSandboxRoutes(db: Database) {
const app = new Hono()

app.post('/command', async (c) => {
let body: unknown
try {
body = await c.req.json()
} catch {
return c.json({ error: 'Invalid request' }, 400)
}

const parsed = SandboxCommandRequestSchema.safeParse(body)
if (!parsed.success) {
return c.json({ error: 'Invalid request' }, 400)
}

try {
return c.json(
await new SandboxRuntimeService(db).planCommand(parsed.data.directory, parsed.data.command, parsed.data.enforced === true),
)
} catch (error) {
logger.error('Failed to plan sandbox command', error)
return c.json({ mode: 'blocked', reason: error instanceof Error ? error.message : String(error) }, 500)
}
})

return app
}
Loading