Skip to content
Merged
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
14 changes: 14 additions & 0 deletions backend/src/db/migrations/202610141200-session-sandbox-modes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { Migration } from '../migration-runner'
import { ensureSessionSandboxModesTable } from '../session-sandbox-modes'

const migration: Migration = {
id: '202610141200-session-sandbox-modes',
up(db) {
ensureSessionSandboxModesTable(db)
},
down(db) {
db.run('DROP TABLE IF EXISTS session_sandbox_modes')
},
}

export default migration
2 changes: 2 additions & 0 deletions backend/src/db/migrations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import migration202610061345 from './202610061345-schedule-workspace-mode'
import migration202610061700 from './202610061700-multi-run-fusions'
import migration202610061701 from './202610061701-change-walkthroughs'
import migration202610081200 from './202610081200-change-walkthrough-sources'
import migration202610141200 from './202610141200-session-sandbox-modes'

export const allMigrations: Migration[] = [
migration001,
Expand Down Expand Up @@ -63,4 +64,5 @@ export const allMigrations: Migration[] = [
migration202610061700,
migration202610061701,
migration202610081200,
migration202610141200,
]
107 changes: 107 additions & 0 deletions backend/src/db/session-sandbox-modes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import type { Database } from 'bun:sqlite'
import type { SessionSandboxResources, SessionSandboxSource } from '@opencode-manager/shared/schemas'

export interface SessionSandboxModeRow {
sandboxed: boolean
source: SessionSandboxSource
toggledAt: number
resources: SessionSandboxResources | null
}

interface SessionSandboxModeRawRow {
sandboxed: number
source: SessionSandboxSource
toggled_at: number
cpus: number | null
memory_mib: number | null
}

export function ensureSessionSandboxModesTable(db: Database): void {
db.run(`
CREATE TABLE IF NOT EXISTS session_sandbox_modes (
session_id TEXT PRIMARY KEY,
sandboxed INTEGER NOT NULL CHECK(sandboxed IN (0,1)),
source TEXT NOT NULL CHECK(source IN ('default','user')),
toggled_at INTEGER NOT NULL,
cpus INTEGER CHECK(cpus IS NULL OR cpus > 0),
memory_mib INTEGER CHECK(memory_mib IS NULL OR memory_mib > 0),
updated_at INTEGER NOT NULL
)
`)
}

export function getSessionSandboxMode(db: Database, sessionId: string): SessionSandboxModeRow | null {
const row = db
.prepare('SELECT sandboxed, source, toggled_at, cpus, memory_mib FROM session_sandbox_modes WHERE session_id = ?')
.get(sessionId) as SessionSandboxModeRawRow | undefined
if (!row) return null
return {
sandboxed: row.sandboxed === 1,
source: row.source,
toggledAt: row.toggled_at,
resources: row.cpus !== null && row.memory_mib !== null
? { cpus: row.cpus, memoryMib: row.memory_mib }
: null,
}
}

export function insertSessionSandboxModeIfAbsent(
db: Database,
sessionId: string,
mode: { sandboxed: boolean; source: SessionSandboxSource; resources: SessionSandboxResources | null },
): void {
const now = Date.now()
db.prepare(`
INSERT OR IGNORE INTO session_sandbox_modes(session_id, sandboxed, source, toggled_at, cpus, memory_mib, updated_at)
VALUES(?,?,?,?,?,?,?)
`).run(
sessionId,
mode.sandboxed ? 1 : 0,
mode.source,
now,
mode.resources?.cpus ?? null,
mode.resources?.memoryMib ?? null,
now,
)
}

export function setSessionSandboxed(db: Database, sessionId: string, sandboxed: boolean): void {
const now = Date.now()
db.prepare(`
INSERT INTO session_sandbox_modes(session_id, sandboxed, source, toggled_at, cpus, memory_mib, updated_at)
VALUES(?,?,'user',?,NULL,NULL,?)
ON CONFLICT(session_id) DO UPDATE SET
sandboxed=excluded.sandboxed,
source='user',
toggled_at=excluded.toggled_at,
updated_at=excluded.updated_at
`).run(sessionId, sandboxed ? 1 : 0, now, now)
}

export function setSessionSandboxResources(
db: Database,
sessionId: string,
resources: SessionSandboxResources | null,
defaultSandboxed: boolean,
): void {
const now = Date.now()
db.prepare(`
INSERT INTO session_sandbox_modes(session_id, sandboxed, source, toggled_at, cpus, memory_mib, updated_at)
VALUES(?,?,'default',?,?,?,?)
ON CONFLICT(session_id) DO UPDATE SET
cpus=excluded.cpus,
memory_mib=excluded.memory_mib,
updated_at=excluded.updated_at
`).run(
sessionId,
defaultSandboxed ? 1 : 0,
now,
resources?.cpus ?? null,
resources?.memoryMib ?? null,
now,
)
}

export function deleteSessionSandboxMode(db: Database, sessionId: string): void {
db.prepare('DELETE FROM session_sandbox_modes WHERE session_id = ?').run(sessionId)
}
27 changes: 19 additions & 8 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { createAuthMiddleware } from './auth/middleware'
import { createPromptTemplateRoutes } from './routes/prompt-templates'
import { createSessionPinRoutes } from './routes/session-pins'
import { createSessionPermissionModeRoutes } from './routes/session-permission-modes'
import { createSessionSandboxRoutes } from './routes/session-sandbox'
import { createSessionGoalRoutes } from './routes/session-goals'
import { createMultiRunRoutes } from './routes/multi-runs'
import { createChangeWalkthroughRoutes } from './routes/change-walkthroughs'
Expand All @@ -53,6 +54,8 @@ import { sseAggregator } from './services/sse-aggregator'
import { ensureDirectoryExists, writeFileContent, fileExists } from './services/file-operations'
import { SettingsService } from './services/settings'
import { SessionPermissionModeService } from './services/session-permission-modes'
import { SessionSandboxService } from './services/session-sandbox'
import { SessionAncestry } from './services/session-ancestry'
import { SessionGoalService } from './services/session-goals'
import { MultiRunService } from './services/multi-runs'
import { ChangeWalkthroughService } from './services/change-walkthroughs'
Expand All @@ -70,7 +73,7 @@ import { ScheduleWorktreeManager } from './services/schedule-worktree'
import { migrateGlobalSkills } from './services/skills'
import { installAssistantWorkspace } from './services/assistant-mode'
import { detectSandboxCapability } from './services/sandbox/capability'
import { SandboxRuntimeService, stopWorkspaceSandboxOnShutdown } from './services/sandbox/runtime'
import { SandboxRuntimeService, stopSandboxesOnShutdown } from './services/sandbox/runtime'
import { getOpenCodeImportStatus, syncOpenCodeImport } from './services/opencode-import'
import { readOpenCodeConfigFile, foldLegacyConfigJsonSource, withOpenCodeConfigLock } from './services/opencode-config-file'
import { seedOpenCodeConfigFile } from './services/opencode-config-apply'
Expand Down Expand Up @@ -225,8 +228,8 @@ try {

opencodeServerManager.setDatabase(db)
detectSandboxCapability()
void new SandboxRuntimeService(db).prepareWorkspaceSandboxOnBoot().catch((error) => {
logger.warn('Workspace sandbox preparation failed:', error)
void new SandboxRuntimeService(db).prepareSandboxOnBoot().catch((error) => {
logger.warn('Sandbox preparation failed:', error)
})
const openCodeStatus = await openCodeSupervisor.start()
if (openCodeStatus.healthy) {
Expand Down Expand Up @@ -260,7 +263,11 @@ const scheduleRunnerInstance = new ScheduleRunner(scheduleService)

const notificationService = new NotificationService(db)
const sessionSettingsService = new SettingsService(db)
const sessionPermissionModeService = new SessionPermissionModeService(db, openCodeClient, sessionSettingsService)
const sessionAncestry = new SessionAncestry(openCodeClient)
const sessionPermissionModeService = new SessionPermissionModeService(db, openCodeClient, sessionSettingsService, sessionAncestry)
const sessionSandboxService = new SessionSandboxService(db, sessionAncestry, sessionSettingsService, {
removeSessionSandbox: (rootSessionId) => new SandboxRuntimeService(db).removeSessionSandbox(rootSessionId),
})
const sessionGoalService = new SessionGoalService(db, openCodeClient, sessionSettingsService, {
onOutcome: (goal) => {
void notificationService.notifyGoalOutcome(goal).catch((error) => {
Expand All @@ -279,6 +286,9 @@ sseAggregator.onEvent((directory, event) => {
sessionPermissionModeService.handleEvent(directory, event).catch((err) => {
logger.error('Session permission mode event handling error:', err)
})
sessionSandboxService.handleEvent(directory, event).catch((err) => {
logger.error('Session sandbox event handling error:', err)
})
sessionGoalService.handleEvent(directory, event).catch((err) => {
logger.error('Session goal event handling error:', err)
})
Expand Down Expand Up @@ -341,7 +351,7 @@ app.route('/api/auth-info', createAuthInfoRoutes(auth, db))
app.route('/api/health', createHealthRoutes(db, openCodeSupervisor))

app.route('/api/mcp-oauth-proxy', createMcpOauthProxyRoutes(openCodeClient, requireAuth))
app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, sessionPermissionModeService, repoWorkspaces, gitAuthService, sessionGoalService, multiRunService, changeWalkthroughService))
app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, sessionPermissionModeService, repoWorkspaces, gitAuthService, sessionGoalService, multiRunService, changeWalkthroughService, sessionSandboxService))
app.route('/api/opencode-proxy', createOpenCodeProxyRoutes(db, settingsService, upgradeWebSocket))

const protectedApi = new Hono()
Expand All @@ -362,6 +372,7 @@ protectedApi.route('/notifications', createNotificationRoutes(notificationServic
protectedApi.route('/prompt-templates', createPromptTemplateRoutes(db))
protectedApi.route('/session-pins', createSessionPinRoutes(db))
protectedApi.route('/session-permission-modes', createSessionPermissionModeRoutes(sessionPermissionModeService))
protectedApi.route('/session-sandbox', createSessionSandboxRoutes(sessionSandboxService))
protectedApi.route('/session-goals', createSessionGoalRoutes(sessionGoalService))
protectedApi.route('/multi-runs', createMultiRunRoutes(multiRunService))
protectedApi.route('/change-walkthroughs', createChangeWalkthroughRoutes(changeWalkthroughService))
Expand Down Expand Up @@ -468,10 +479,10 @@ const shutdown = async (signal: string) => {
logger.error('Error during shutdown:', error)
}
try {
await stopWorkspaceSandboxOnShutdown(db)
logger.info('Workspace sandbox stopped')
await stopSandboxesOnShutdown(db)
logger.info('Sandbox microVMs stopped')
} catch (error) {
logger.error('Error stopping workspace sandbox:', error)
logger.error('Error stopping sandbox microVMs:', error)
}
process.exit(0)
}
Expand Down
4 changes: 3 additions & 1 deletion backend/src/routes/internal/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { createSessionGoalRoutes } from '../session-goals'
import { createMultiRunRoutes } from '../multi-runs'
import { createChangeWalkthroughRoutes } from '../change-walkthroughs'
import type { SessionPermissionModeService } from '../../services/session-permission-modes'
import type { SessionSandboxService } from '../../services/session-sandbox'
import type { RepoWorkspaceService } from '../../services/repo-workspace'
import type { GitAuthService } from '../../services/git-auth'
import type { SessionGoalService } from '../../services/session-goals'
Expand All @@ -40,6 +41,7 @@ export function createInternalRoutes(
sessionGoals: SessionGoalService,
multiRuns: MultiRunService,
changeWalkthroughService: ChangeWalkthroughService,
sessionSandbox: SessionSandboxService,
) {
const app = new Hono()
app.use('/*', createInternalTokenMiddleware(db))
Expand All @@ -61,6 +63,6 @@ export function createInternalRoutes(
app.route('/change-walkthroughs', createChangeWalkthroughRoutes(changeWalkthroughService))
app.route('/session-artifacts', createInternalSessionArtifactRoutes(openCodeClient))
app.route('/git-credentials', createInternalGitCredentialsRoutes(db))
app.route('/sandbox', createInternalSandboxRoutes(db))
app.route('/sandbox', createInternalSandboxRoutes(db, sessionSandbox))
return app
}
21 changes: 19 additions & 2 deletions backend/src/routes/internal/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,28 @@ import { Hono } from 'hono'
import { z } from 'zod'
import type { Database } from 'bun:sqlite'
import { SandboxRuntimeService } from '../../services/sandbox/runtime'
import type { SessionSandboxService } from '../../services/session-sandbox'
import { logger } from '../../utils/logger'

const SandboxShellRequestSchema = z.object({
directory: z.string().min(1),
sessionId: z.string().min(1),
locationDirectory: z.string().min(1),
enforced: z.boolean().optional(),
})

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

app.get('/sessions/:sessionId', async (c) => {
try {
return c.json(await sessionSandbox.getClaim(c.req.param('sessionId')))
} catch (error) {
logger.error('Failed to read session sandbox state', error)
return c.json({ error: error instanceof Error ? error.message : String(error) }, 500)
}
})

app.post('/shell', async (c) => {
let body: unknown
try {
Expand All @@ -26,8 +38,13 @@ export function createInternalSandboxRoutes(db: Database) {
}

try {
const target = await sessionSandbox.resolveShellTarget(parsed.data.sessionId)
return c.json(
await new SandboxRuntimeService(db).planShell(parsed.data.directory, parsed.data.enforced === true),
await new SandboxRuntimeService(db).planShell(
parsed.data.directory,
{ ...target, locationDirectory: parsed.data.locationDirectory },
parsed.data.enforced === true,
),
)
} catch (error) {
logger.error('Failed to plan the sandbox shell', error)
Expand Down
33 changes: 33 additions & 0 deletions backend/src/routes/session-sandbox.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Hono } from 'hono'
import { SetSessionSandboxRequestSchema } from '@opencode-manager/shared/schemas'
import { SessionSandboxError, type SessionSandboxService } from '../services/session-sandbox'
import { handleServiceError, parseJsonBody } from '../utils/route-helpers'

export function createSessionSandboxRoutes(service: SessionSandboxService) {
const app = new Hono()

app.get('/:sessionId', async (c) => {
try {
const state = await service.getState(c.req.param('sessionId'))
return c.json(state)
} catch (error) {
return handleServiceError(c, error, 'Failed to read session sandbox state', SessionSandboxError)
}
})

app.put('/:sessionId', async (c) => {
const parsed = await parseJsonBody(c, SetSessionSandboxRequestSchema)
if (!parsed.ok) {
return parsed.response
}

try {
const state = await service.update(c.req.param('sessionId'), parsed.data)
return c.json(state)
} catch (error) {
return handleServiceError(c, error, 'Failed to set session sandbox state', SessionSandboxError)
}
})

return app
}
12 changes: 12 additions & 0 deletions backend/src/routes/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { getOrCreateInternalToken, rotateInternalToken } from '../services/inter
import { sseAggregator } from '../services/sse-aggregator'
import type { OpenCodeSupervisor } from '../services/opencode-supervisor'
import { detectSandboxCapability } from '../services/sandbox/capability'
import { sandboxResourceLimitError } from '../services/sandbox/resources'
import { getProcessIdentityAttestationError } from '../services/opencode/process-identity'
import { restartOpenCode, assertValidOpenCodeConfig, listActiveUserSessions } from '../services/opencode-restart'
import type { GitAuthService } from '../services/git-auth'
Expand Down Expand Up @@ -377,6 +378,17 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic

const currentSettings = settingsService.getSettings(userId)

const requestedSandboxResources = validated.preferences.sandbox
if (
requestedSandboxResources !== undefined
&& (requestedSandboxResources.cpus !== undefined || requestedSandboxResources.memoryMib !== undefined)
) {
const limitError = sandboxResourceLimitError(requestedSandboxResources)
if (limitError !== null) {
return c.json({ error: limitError }, 400)
}
}

if (currentSettings.preferences.sandbox?.enabled !== true && validated.preferences.sandbox?.enabled === true) {
const capability = detectSandboxCapability()
if (capability.available === false) {
Expand Down
4 changes: 2 additions & 2 deletions backend/src/services/notification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,7 @@ export class NotificationService {
}

async handleSSEEvent(
directory: string,
directory: string | null,
event: SSEEvent
): Promise<void> {
const config = EVENT_CONFIG[event.type];
Expand All @@ -358,7 +358,7 @@ export class NotificationService {
repoName,
repoId,
sessionId,
directory,
directory: directory ?? undefined,
url,
});
if (!payload) return;
Expand Down
Loading
Loading