Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
aa3fb48
feat(sandbox): add KVM-backed agent sandboxing via microsandbox
chriswritescode-dev Aug 13, 2026
ae117d6
feat(sandbox): route host-shell surfaces into microVM via shell wrapper
chriswritescode-dev Aug 14, 2026
f315dda
feat(sandbox): route host-shell surfaces into microVM via shell wrapper
chriswritescode-dev Aug 20, 2026
0481160
fix(sandbox): harden enforcement validation and backup handling
chriswritescode-dev Aug 20, 2026
0215de9
fix(sandbox): bound lifecycle queue and harden install/backup paths
chriswritescode-dev Aug 22, 2026
aab80f9
refactor(sandbox): remove OpenCode version allowlist gating under enf…
chriswritescode-dev Aug 22, 2026
c976420
fix(settings): use resolved opencode binary and verify installed version
chriswritescode-dev Aug 22, 2026
af01ddf
refactor(sandbox): scope enforcement to agent bash tool only
chriswritescode-dev Aug 22, 2026
cadc283
fix(sandbox): plan bash against factory directory and attest runtime …
chriswritescode-dev Aug 22, 2026
f2d0ae0
fix(sandbox): pin neutral entrypoint and persist opencode upgrades ac…
chriswritescode-dev Aug 23, 2026
3791a74
fix(sandbox): align canonical spec with msb 0.6.8 runtime defaults
chriswritescode-dev Aug 23, 2026
681181e
feat(sandbox): show unwrapped sandboxed bash commands in message UI
chriswritescode-dev Aug 23, 2026
098816f
refactor(sandbox): pin shell shim to sandbox agent bash without rewri…
chriswritescode-dev Aug 23, 2026
f6c3682
feat(sandbox): route notifications through a host-side manager tool
chriswritescode-dev Aug 24, 2026
62cf04b
feat(sandbox): route all assistant capabilities through the ocm tool
chriswritescode-dev Aug 25, 2026
9cbe787
fix(sidebar): wire new-session and new-schedule actions to every page
chriswritescode-dev Aug 25, 2026
19b91ac
feat(sandbox): forward git credentials into the microVM and add sandb…
chriswritescode-dev Aug 25, 2026
d7f4f2f
feat(sandbox): add git credential forwarding toggle to settings
chriswritescode-dev Aug 26, 2026
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
29 changes: 29 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,35 @@ 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. The default ships gh, a Playwright Chromium,
# and the node:24 toolchain; any image you point this at must contain every tool
# the agent expects to run and a shell at /bin/sh.
# SANDBOX_IMAGE=docker.io/cstechdev/ocm-sandbox:latest
# 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, private, or host, or a comma-separated
# composition (for example public,host)
# 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
10 changes: 6 additions & 4 deletions .github/workflows/docker-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,16 @@ jobs:
- name: Checkout
uses: actions/checkout@v4

- name: Get latest tool versions
- name: Resolve tool versions (bundled OpenCode and microsandbox for reproducibility)
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 "Versions: uv=${UV_VERSION} (latest), opencode=${OPENCODE_VERSION} (bundled default), microsandbox=${MICROSANDBOX_VERSION} (pinned)"

- name: Docker meta
id: meta
Expand Down Expand Up @@ -60,8 +62,8 @@ 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


92 changes: 92 additions & 0 deletions .github/workflows/sandbox-image.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
name: Sandbox Image

on:
release:
types: [published]
workflow_dispatch:
inputs:
image:
description: 'Fully qualified image name to push (registry/namespace/name)'
required: false
default: 'docker.io/cstechdev/ocm-sandbox'
playwright_version:
description: 'Playwright version providing the bundled Chromium'
required: false
default: '1.56.0'

permissions:
contents: read
packages: write

jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Resolve build inputs
id: resolve
env:
REGISTRY_USERNAME: ${{ secrets.SANDBOX_REGISTRY_USERNAME }}
REGISTRY_TOKEN: ${{ secrets.SANDBOX_REGISTRY_TOKEN }}
run: |
IMAGE="${{ github.event.inputs.image }}"
[ -n "$IMAGE" ] || IMAGE="docker.io/cstechdev/ocm-sandbox"
PLAYWRIGHT_VERSION="${{ github.event.inputs.playwright_version }}"
[ -n "$PLAYWRIGHT_VERSION" ] || PLAYWRIGHT_VERSION="1.56.0"
REGISTRY="${IMAGE%%/*}"
case "$REGISTRY" in
*.*|*:*|localhost) ;;
*) echo "ERROR: image '$IMAGE' must be fully qualified with a registry host" >&2; exit 1 ;;
esac
if [ "$REGISTRY" != "ghcr.io" ] && { [ -z "$REGISTRY_USERNAME" ] || [ -z "$REGISTRY_TOKEN" ]; }; then
echo "ERROR: pushing to $REGISTRY requires the SANDBOX_REGISTRY_USERNAME and SANDBOX_REGISTRY_TOKEN repository secrets" >&2
echo "GITHUB_TOKEN only authenticates against ghcr.io" >&2
exit 1
fi
echo "image=${IMAGE}" >> $GITHUB_OUTPUT
echo "registry=${REGISTRY}" >> $GITHUB_OUTPUT
echo "playwright=${PLAYWRIGHT_VERSION}" >> $GITHUB_OUTPUT
echo "Building ${IMAGE} on ${REGISTRY} with playwright=${PLAYWRIGHT_VERSION}"

- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ steps.resolve.outputs.image }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest

- name: Set up QEMU
uses: docker/setup-qemu-action@v3

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Login to registry
uses: docker/login-action@v3
with:
registry: ${{ steps.resolve.outputs.registry }}
username: ${{ secrets.SANDBOX_REGISTRY_USERNAME || github.actor }}
password: ${{ secrets.SANDBOX_REGISTRY_TOKEN || secrets.GITHUB_TOKEN }}

- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
file: Dockerfile.sandbox
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
PLAYWRIGHT_VERSION=${{ steps.resolve.outputs.playwright }}
cache-from: type=gha
cache-to: type=gha,mode=max

- name: Report digest
run: echo "Published ${{ steps.meta.outputs.tags }}"
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
38 changes: 38 additions & 0 deletions Dockerfile.sandbox
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Guest image for the agent sandbox microVM (SANDBOX_IMAGE).
#
# node:24 is buildpack-deps based, so gcc/g++/make/ld/pkg-config, glib-2.0,
# git, ssh, python3 and curl are already present. This image only adds the
# GitHub CLI and a Playwright-managed Chromium.
FROM node:24

ARG PLAYWRIGHT_VERSION=1.56.0
# Bump TOOLS_CACHEBUST (e.g. via --build-arg) to force a fresh gh/Playwright
# install without invalidating the rest of the build cache.
ARG TOOLS_CACHEBUST=0

# Shared browser location so the sandbox exec user (SANDBOX_EXEC_USER, default
# "node") can read browsers installed at build time by root.
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
# Lets agent code `require("playwright")` from any working directory without a
# project-local install. Only a resolution fallback; local node_modules wins.
ENV NODE_PATH=/usr/local/lib/node_modules

RUN echo "Installing gh (cachebust=${TOOLS_CACHEBUST})" && \
apt-get update && \
apt-get install -y --no-install-recommends ca-certificates curl && \
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
-o /usr/share/keyrings/githubcli-archive-keyring.gpg && \
chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg && \
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
> /etc/apt/sources.list.d/github-cli.list && \
apt-get update && \
apt-get install -y --no-install-recommends gh && \
rm -rf /var/lib/apt/lists/* && \
gh --version

RUN echo "Installing playwright=${PLAYWRIGHT_VERSION} chromium (cachebust=${TOOLS_CACHEBUST})" && \
npm install -g "playwright@${PLAYWRIGHT_VERSION}" && \
npx --yes "playwright@${PLAYWRIGHT_VERSION}" install --with-deps chromium && \
chmod -R a+rX "${PLAYWRIGHT_BROWSERS_PATH}" && \
rm -rf /var/lib/apt/lists/* /root/.npm && \
npx --yes "playwright@${PLAYWRIGHT_VERSION}" --version
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
12 changes: 12 additions & 0 deletions backend/src/db/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ interface RepoRow {

const REPO_GIT_CREDENTIAL_SETTING_KEY = 'gitCredentialId'

const REPO_SANDBOX_GIT_CREDENTIALS_SETTING_KEY = 'sandboxGitCredentials'

function rowToRepo(row: RepoRow): Repo {
const fullPath = row.source_path || path.join(getReposPath(), row.local_path)

Expand Down Expand Up @@ -82,6 +84,16 @@ export function setRepoGitCredentialId(db: Database, repoId: number, credentialI
setRepoSetting(db, repoId, REPO_GIT_CREDENTIAL_SETTING_KEY, credentialId)
}

export function getRepoSandboxGitCredentials(db: Database, repoId: number): boolean | null {
const value = getRepoSetting(db, repoId, REPO_SANDBOX_GIT_CREDENTIALS_SETTING_KEY)
if (value === null) return null
return value === 'true'
}

export function setRepoSandboxGitCredentials(db: Database, repoId: number, allowed: boolean | null): void {
setRepoSetting(db, repoId, REPO_SANDBOX_GIT_CREDENTIALS_SETTING_KEY, allowed === null ? null : String(allowed))
}

export function getRepoByDirectory(db: Database, directory: string): Repo | null {
const resolvedDirectory = path.resolve(directory)
const repos = listRepos(db)
Expand Down
36 changes: 16 additions & 20 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,8 @@ import { CredentialProvider } from './services/credential-provider'
import { ScheduleWorktreeManager } from './services/schedule-worktree'
import { migrateGlobalSkills } from './services/skills'
import { installAssistantWorkspace } from './services/assistant-mode'
import { detectSandboxCapability } from './services/sandbox/capability'
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 +107,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 @@ -279,10 +285,7 @@ try {

await migrateGlobalSkills()

await installAssistantWorkspace({
db,
apiBaseUrl: `http://localhost:${PORT}/api/internal`,
})
await installAssistantWorkspace({ db })
logger.info('Assistant workspace installed')

ipcServer = await createIPCServer(process.env.STORAGE_PATH || undefined)
Expand All @@ -292,6 +295,7 @@ try {
await syncAdminFromEnv(auth, db)

opencodeServerManager.setDatabase(db)
detectSandboxCapability()
const openCodeStatus = await openCodeSupervisor.start()
if (openCodeStatus.healthy) {
logger.info(`OpenCode server running on port ${openCodeStatus.port}`)
Expand Down Expand Up @@ -373,21 +377,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 +473,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
Loading