From 070cb2f729ca3e07c31308337ecfcc8f6000d1b5 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:49:38 -0400 Subject: [PATCH 1/3] feat(schedules): run scheduled jobs in a per-job sandbox microVM --- .../202610251900-schedule-sandbox-config.ts | 22 ++ backend/src/db/migrations/index.ts | 2 + backend/src/db/schedules.ts | 28 +- backend/src/index.ts | 18 +- backend/src/services/assistant-mode.ts | 2 + backend/src/services/schedule-config.ts | 4 + backend/src/services/schedules.ts | 137 ++++++++-- backend/src/services/session-sandbox.ts | 31 ++- backend/test/db/schedule-migrations.test.ts | 29 +++ backend/test/db/schedules.permission.test.ts | 22 ++ backend/test/db/schedules.test.ts | 4 + .../test/services/git/GitService.real.test.ts | 1 + backend/test/services/schedule-config.test.ts | 87 +++++++ .../services/schedules.permission.test.ts | 1 + backend/test/services/schedules.test.ts | 241 +++++++++++++++++- .../message/SessionSandboxResourcesDialog.tsx | 42 +-- .../sandbox/SandboxResourceInputs.tsx | 76 ++++++ .../src/components/schedules/GeneralTab.tsx | 54 ++++ .../src/components/schedules/JobDetailTab.tsx | 5 + .../schedules/RepoSchedulesContent.test.tsx | 1 + .../ScheduleJobDialog.sandbox.test.tsx | 213 ++++++++++++++++ .../ScheduleJobDialog.serverTarget.test.tsx | 1 + .../schedules/ScheduleJobDialog.tsx | 47 +++- .../components/schedules/schedule-utils.ts | 11 +- .../components/settings/SandboxSettings.tsx | 10 +- frontend/src/lib/sandboxResources.ts | 48 +++- shared/src/schemas/schedule.ts | 14 + shared/src/types/index.ts | 1 + 28 files changed, 1069 insertions(+), 83 deletions(-) create mode 100644 backend/src/db/migrations/202610251900-schedule-sandbox-config.ts create mode 100644 frontend/src/components/sandbox/SandboxResourceInputs.tsx create mode 100644 frontend/src/components/schedules/ScheduleJobDialog.sandbox.test.tsx diff --git a/backend/src/db/migrations/202610251900-schedule-sandbox-config.ts b/backend/src/db/migrations/202610251900-schedule-sandbox-config.ts new file mode 100644 index 000000000..0267924d3 --- /dev/null +++ b/backend/src/db/migrations/202610251900-schedule-sandbox-config.ts @@ -0,0 +1,22 @@ +import type { Migration } from '../migration-runner' + +interface ColumnInfo { + name: string +} + +const migration: Migration = { + id: '202610251900-schedule-sandbox-config', + + up(db) { + const columns = db.prepare('PRAGMA table_info(schedule_jobs)').all() as ColumnInfo[] + if (!columns.some((column) => column.name === 'sandbox_config')) { + db.run('ALTER TABLE schedule_jobs ADD COLUMN sandbox_config TEXT') + } + }, + + down(db) { + db.run('ALTER TABLE schedule_jobs DROP COLUMN sandbox_config') + }, +} + +export default migration diff --git a/backend/src/db/migrations/index.ts b/backend/src/db/migrations/index.ts index 813f8ea29..5a72487ca 100644 --- a/backend/src/db/migrations/index.ts +++ b/backend/src/db/migrations/index.ts @@ -41,6 +41,7 @@ import migration202610181400 from './202610181400-secrets' import { secretsGlobalScopeIndexMigration } from './202610181410-secrets-global-scope-index' import migration202610221800 from './202610221800-session-device-name' import migration202610231200 from './202610231200-push-subscription-session-id' +import migration202610251900 from './202610251900-schedule-sandbox-config' export const allMigrations: Migration[] = [ migration001, @@ -85,4 +86,5 @@ export const allMigrations: Migration[] = [ secretsGlobalScopeIndexMigration, migration202610221800, migration202610231200, + migration202610251900, ] diff --git a/backend/src/db/schedules.ts b/backend/src/db/schedules.ts index 1e677d10f..20f72ba72 100644 --- a/backend/src/db/schedules.ts +++ b/backend/src/db/schedules.ts @@ -5,6 +5,7 @@ import { ScheduleMcpServersSchema, SchedulePermissionConfigSchema, ScheduleRunSchema, + ScheduleSandboxConfigSchema, ScheduleSkillMetadataSchema, ScheduleWorkspaceModeSchema, type ScheduleJob, @@ -14,6 +15,7 @@ import { type ScheduleRun, type ScheduleRunStatus, type ScheduleRunTriggerSource, + type ScheduleSandboxConfig, type ScheduleWorkspaceMode, } from '@opencode-manager/shared/schemas' import { ASSISTANT_REPO_ID, ASSISTANT_REPO_NAME, ASSISTANT_REPO_PATH, getRepoDisplayName } from '@opencode-manager/shared/utils' @@ -39,6 +41,7 @@ interface ScheduleJobRow { mcp_servers: string | null branch: string | null workspace_mode: ScheduleWorkspaceMode | null + sandbox_config: string | null created_at: number updated_at: number last_run_at: number | null @@ -107,6 +110,19 @@ function parseMcpServers(raw: string | null): ScheduleMcpServer[] { } } +function parseSandboxConfig(raw: string | null): ScheduleSandboxConfig | null { + if (!raw) { + return null + } + + try { + const result = ScheduleSandboxConfigSchema.safeParse(JSON.parse(raw)) + return result.success ? result.data : null + } catch { + return null + } +} + function normalizeWorkspaceMode(raw: string | null): ScheduleWorkspaceMode { const result = ScheduleWorkspaceModeSchema.safeParse(raw) return result.success ? result.data : 'worktree' @@ -133,6 +149,7 @@ function rowToScheduleJob(row: ScheduleJobRow): ScheduleJob { mcpServers: parseMcpServers(row.mcp_servers), branch: row.branch, workspaceMode: normalizeWorkspaceMode(row.workspace_mode), + sandbox: parseSandboxConfig(row.sandbox_config), createdAt: row.created_at, updatedAt: row.updated_at, lastRunAt: row.last_run_at, @@ -183,6 +200,10 @@ function serializeMcpServers(mcpServers: ScheduleJobPersistenceInput['mcpServers return mcpServers.length > 0 ? JSON.stringify(mcpServers) : null } +function serializeSandboxConfig(sandbox: ScheduleJobPersistenceInput['sandbox']): string | null { + return sandbox ? JSON.stringify(sandbox) : null +} + export function listScheduleJobsByRepo(db: Database, repoId: number): ScheduleJob[] { const stmt = db.prepare('SELECT * FROM schedule_jobs WHERE repo_id IS ? ORDER BY created_at DESC') const rows = stmt.all(repoId) as ScheduleJobRow[] @@ -227,9 +248,10 @@ export function createScheduleJob( mcp_servers, branch, workspace_mode, + sandbox_config, created_at, updated_at, last_run_at, next_run_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `) const result = stmt.run( @@ -251,6 +273,7 @@ export function createScheduleJob( serializeMcpServers(input.mcpServers), input.branch, input.workspaceMode, + serializeSandboxConfig(input.sandbox), now, now, null, @@ -275,7 +298,7 @@ export function updateScheduleJob(db: Database, repoId: number | null, jobId: nu const stmt = db.prepare(` UPDATE schedule_jobs SET name = ?, description = ?, enabled = ?, schedule_mode = ?, interval_minutes = ?, cron_expression = ?, timezone = ?, - agent_slug = ?, prompt = ?, model = ?, skill_metadata = ?, permission_config = ?, mcp_servers = ?, branch = ?, workspace_mode = ?, directory = ?, updated_at = ?, next_run_at = ? + agent_slug = ?, prompt = ?, model = ?, skill_metadata = ?, permission_config = ?, mcp_servers = ?, branch = ?, workspace_mode = ?, sandbox_config = ?, directory = ?, updated_at = ?, next_run_at = ? WHERE repo_id IS ? AND id = ? `) @@ -295,6 +318,7 @@ export function updateScheduleJob(db: Database, repoId: number | null, jobId: nu serializeMcpServers(input.mcpServers), input.branch, input.workspaceMode, + serializeSandboxConfig(input.sandbox), input.directory ?? null, now, input.nextRunAt, diff --git a/backend/src/index.ts b/backend/src/index.ts index b81c6e602..b2b4da1bd 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -150,6 +150,7 @@ const gitService = createGitService(gitAuthService) const projectConfigService = new ProjectConfigService(db, gitService, gitAuthService) const secretsService = new SecretsService(db) const repoEnvService = new RepoEnvService(db, projectConfigService, secretsService) +const sandboxRuntimeService = new SandboxRuntimeService(db, repoEnvService) let openCodeSupervisor: OpenCodeSupervisor | undefined async function ensureOpenCodeConfigFileExists(): Promise { await withOpenCodeConfigLock(foldLegacyConfigJsonSource) @@ -248,7 +249,7 @@ try { opencodeServerManager.setDatabase(db) detectSandboxCapability() - void new SandboxRuntimeService(db, repoEnvService).prepareSandboxOnBoot().catch((error) => { + void sandboxRuntimeService.prepareSandboxOnBoot().catch((error) => { logger.warn('Sandbox preparation failed:', error) }) const openCodeStatus = await openCodeSupervisor.start() @@ -279,8 +280,16 @@ const resolveServerTerminalService = (serverId: string): TerminalService => { includeLocalEnv: false, }) } +const sessionSettingsService = new SettingsService(db) +const sessionAncestry = new SessionAncestry((serverId) => opencodeServerRegistry.getClient(serverId)) +const sessionSandboxService = new SessionSandboxService(db, sessionAncestry, sessionSettingsService, { + removeSessionSandbox: (rootSessionId) => sandboxRuntimeService.removeSessionSandbox(rootSessionId), +}) const scheduleWorktreeManager = new ScheduleWorktreeManager(gitAuthService, db) -const scheduleService = new ScheduleService(db, openCodeClient, scheduleWorktreeManager, opencodeServerRegistry) +const scheduleService = new ScheduleService(db, openCodeClient, scheduleWorktreeManager, opencodeServerRegistry, { + sessions: sessionSandboxService, + runtime: sandboxRuntimeService, +}) scheduleService.setWorktreeRemovedHandler(async (directory) => { try { await terminalService.removeAll(directory) @@ -292,12 +301,7 @@ const repoWorkspaces = new RepoWorkspaceService(db, openCodeClient, gitAuthServi const scheduleRunnerInstance = new ScheduleRunner(scheduleService) const notificationService = new NotificationService(db, opencodeServerRegistry) -const sessionSettingsService = new SettingsService(db) -const sessionAncestry = new SessionAncestry((serverId) => opencodeServerRegistry.getClient(serverId)) const sessionPermissionModeService = new SessionPermissionModeService(db, opencodeServerRegistry, sessionSettingsService, sessionAncestry) -const sessionSandboxService = new SessionSandboxService(db, sessionAncestry, sessionSettingsService, { - removeSessionSandbox: (rootSessionId) => new SandboxRuntimeService(db).removeSessionSandbox(rootSessionId), -}) const sessionGoalService = new SessionGoalService(db, opencodeServerRegistry, sessionSettingsService, { onOutcome: (goal) => { void notificationService.notifyGoalOutcome(goal).catch((error) => { diff --git a/backend/src/services/assistant-mode.ts b/backend/src/services/assistant-mode.ts index 23b494262..28c229d63 100644 --- a/backend/src/services/assistant-mode.ts +++ b/backend/src/services/assistant-mode.ts @@ -401,6 +401,8 @@ Body matches \`CreateScheduleJobRequest\` schema (discriminated union with \`sch Optional \`workspaceMode\` sets where runs execute: \`worktree\` (default, a fresh worktree removed after each run), \`kept-worktree\` (a fresh worktree kept after each run), or \`shared-worktree\` (one worktree and branch reused by every run). Kept and shared worktrees are listed with \`GET /repos/:repoId/schedules/:jobId/worktrees\`. Removing schedule worktrees is done by the user in the Manager UI. +Optional \`sandbox: { enabled, resources: { cpus, memoryMib } | null }\` runs the scheduled session and its subagents in a dedicated sandbox microVM with Docker available. It works only for schedules on the local OpenCode server, requires server sandboxing to be enabled in settings, and the VM and its Docker volume are removed after each run. The run fails if the VM cannot start. + \`\`\`json { "action": "request", diff --git a/backend/src/services/schedule-config.ts b/backend/src/services/schedule-config.ts index c6651b10f..034c15f12 100644 --- a/backend/src/services/schedule-config.ts +++ b/backend/src/services/schedule-config.ts @@ -5,6 +5,7 @@ import type { ScheduleMcpServer, ScheduleMode, SchedulePermissionConfig, + ScheduleSandboxConfig, ScheduleSkillMetadata, ScheduleWorkspaceMode, UpdateScheduleJobRequest, @@ -28,6 +29,7 @@ export interface ScheduleJobPersistenceInput { mcpServers: ScheduleMcpServer[] branch: string | null workspaceMode: ScheduleWorkspaceMode + sandbox: ScheduleSandboxConfig | null directory?: string | null nextRunAt: number | null } @@ -105,6 +107,7 @@ export function buildCreateSchedulePersistenceInput(input: CreateScheduleJobRequ skillMetadata: input.skillMetadata, branch: input.branch?.trim() || null, workspaceMode: input.workspaceMode ?? 'worktree', + sandbox: input.sandbox ?? null, ...(input.directory !== undefined ? { directory: input.directory?.trim() || null } : {}), } @@ -168,6 +171,7 @@ export function buildUpdatedSchedulePersistenceInput( mcpServers: input.mcpServers ?? existing.mcpServers, branch: input.branch === undefined ? existing.branch : (input.branch?.trim() || null), workspaceMode: input.workspaceMode ?? existing.workspaceMode, + sandbox: input.sandbox !== undefined ? input.sandbox : existing.sandbox, ...(input.directory !== undefined || existing.directory !== undefined ? { directory: input.directory === undefined ? (existing.directory ?? null) : (input.directory?.trim() || null) } : {}), diff --git a/backend/src/services/schedules.ts b/backend/src/services/schedules.ts index f1e9ca729..6f3073ca1 100644 --- a/backend/src/services/schedules.ts +++ b/backend/src/services/schedules.ts @@ -8,6 +8,7 @@ import { type ScheduleRun, type ScheduleRunTriggerSource, type ScheduleRunWorktreesMode, + type ScheduleSandboxConfig, type ScheduleWorkspaceMode, type ScheduleWorktree, type UpdateScheduleJobRequest, @@ -64,6 +65,8 @@ import { logger } from '../utils/logger' import { canonicalPathSync } from '../utils/fs-safe' import { buildAssistantRepo } from './assistant-mode' import { createScheduleWorktreeDescriber } from './schedule-worktree-paths' +import { SessionSandboxError, type SessionSandboxService } from './session-sandbox' +import type { SandboxRuntimeService } from './sandbox/runtime' import { ASSISTANT_REPO_ID } from '@opencode-manager/shared/utils' class ScheduleServiceError extends Error { @@ -264,6 +267,11 @@ interface ScheduleTargetContext { serverId: string } +export interface ScheduleSandboxDependencies { + sessions: Pick + runtime: Pick +} + export class ScheduleService { private static activeRuns = new Map() private static pendingCancels = new Map() @@ -276,6 +284,7 @@ export class ScheduleService { private readonly openCodeClient: OpenCodeClient, private readonly worktreeManager: ScheduleWorktreeManager, private readonly serverRegistry: OpenCodeServerRegistry | null = null, + private readonly sandbox: ScheduleSandboxDependencies | null = null, ) {} setJobChangeHandler(handler: ((job: ScheduleJob | null, jobId: number) => void) | null): void { @@ -482,6 +491,44 @@ export class ScheduleService { } } + private assertSandboxJobSupported(serverId: string, sandbox: ScheduleSandboxConfig | null | undefined): void { + if (sandbox?.enabled !== true) return + + if (serverId !== LOCAL_OPENCODE_SERVER_ID) { + throw new ScheduleServiceError('Sandboxed schedules run only on the local OpenCode server.', 400) + } + + const dependencies = this.sandbox + if (!dependencies) { + throw new ScheduleServiceError('Sandboxing is not available for schedules.', 409) + } + + try { + dependencies.sessions.assertSandboxRequestAllowed({ sandboxed: true, resources: sandbox.resources }) + } catch (error) { + if (error instanceof SessionSandboxError) { + throw new ScheduleServiceError(error.message, error.status) + } + throw error + } + } + + private async startRunSandbox(directory: string, sandbox: ScheduleSandboxConfig, sessionId: string): Promise { + const dependencies = this.sandbox + if (!dependencies) { + throw new ScheduleServiceError('Sandboxing is not available for schedules.', 409) + } + + await dependencies.sessions.update(sessionId, { sandboxed: true, resources: sandbox.resources }) + const target = await dependencies.sessions.resolveShellTarget(sessionId) + const plan = await dependencies.runtime.planShell(directory, { ...target, locationDirectory: directory }, true) + + if (plan.mode !== 'sandbox') { + const reason = plan.mode === 'blocked' ? plan.reason : 'the shell plan was not sandboxed' + throw new ScheduleServiceError(`Sandbox could not start: ${reason}`, 502) + } + } + listAllEnabledJobs(): ScheduleJob[] { return listEnabledScheduleJobs(this.db) } @@ -629,6 +676,7 @@ export class ScheduleService { private async createJobForTarget(target: ScheduleJobTarget, input: CreateScheduleJobRequest): Promise { this.assertInPlaceWorkspace(target, input.workspaceMode) const context = this.resolveTargetContext(target) + this.assertSandboxJobSupported(context.serverId, input.sandbox) await this.assertAgentAvailable(context.client, context.directory, input.agentSlug?.trim() || null) const request = target.kind === 'server' ? { ...input, workspaceMode: 'in-place' as const, directory: context.directory, branch: null } @@ -663,6 +711,7 @@ export class ScheduleService { const existing = this.loadJobForTarget(target, jobId) this.assertInPlaceWorkspace(target, input.workspaceMode) const context = this.resolveTargetContext(target, input.directory ?? existing.directory) + this.assertSandboxJobSupported(context.serverId, input.sandbox !== undefined ? input.sandbox : existing.sandbox) if (input.agentSlug !== undefined) { await this.assertAgentAvailable(context.client, context.directory, input.agentSlug?.trim() || null) @@ -902,10 +951,12 @@ export class ScheduleService { const abort = new AbortController() ScheduleService.activeRuns.set(jobId, { runId: run.id, abort }) let repo: Repo | null = null + let sessionId: string | null = null try { const context = this.resolveTargetContext(target, job.directory) repo = context.repo + this.assertSandboxJobSupported(context.serverId, job.sandbox) let runDirectory = context.directory if (repo) { @@ -956,6 +1007,8 @@ export class ScheduleService { throw new ScheduleServiceError(getErrorMessage(error) || 'Failed to create OpenCode session', 502) } + sessionId = session.id + if (abort.signal.aborted) { return this.abandonCancelledStartup(context.client, repoId, jobId, run, job, repo, session.id) } @@ -975,6 +1028,13 @@ export class ScheduleService { throw new ScheduleServiceError('Failed to attach session to run', 500) } + if (job.sandbox?.enabled === true) { + await this.startRunSandbox(runDirectory, job.sandbox, session.id) + if (abort.signal.aborted) { + return this.abandonCancelledStartup(context.client, repoId, jobId, run, job, repo, session.id) + } + } + const sessionMonitor = createSessionMonitor(runDirectory, session.id, context.serverId) ScheduleService.attachSessionMonitor(jobId, run.id, sessionMonitor) @@ -1002,6 +1062,7 @@ export class ScheduleService { run, job, repo, + sessionId ?? undefined, ) } @@ -1012,6 +1073,7 @@ export class ScheduleService { const failedRun = updateScheduleRun(this.db, repoId, jobId, run.id, { status: 'failed', finishedAt, + sessionId, errorText, logText: buildRunLog({ job, @@ -1030,7 +1092,7 @@ export class ScheduleService { logger.error(`Failed to update job state for job ${jobId}:`, updateError) } - await this.teardownWorktree(repoId, jobId, run.id, job, repo) + await this.teardownRunResources(repoId, jobId, run.id, job, repo, sessionId) ScheduleService.releaseActiveRun(jobId, run.id) @@ -1184,7 +1246,7 @@ export class ScheduleService { nextRunAt: job.nextRunAt, }) - await this.teardownWorktree(repoId, jobId, runId, job, repo) + await this.teardownRunResources(repoId, jobId, runId, job, repo, run.sessionId) ScheduleService.releaseActiveRun(jobId, runId) ScheduleService.resolveCancel(jobId, runId, 'cancelled') @@ -1282,7 +1344,7 @@ export class ScheduleService { } finally { await ScheduleService.awaitCancelDecision(input.job.id, input.runId) input.sessionMonitor.dispose() - await this.teardownWorktree(input.repoId, input.job.id, input.runId, input.job, input.repo) + await this.teardownRunResources(input.repoId, input.job.id, input.runId, input.job, input.repo, input.sessionId) ScheduleService.releaseActiveRun(input.job.id, input.runId) ScheduleService.clearCancel(input.job.id, input.runId) } @@ -1412,7 +1474,7 @@ export class ScheduleService { } finally { await ScheduleService.awaitCancelDecision(input.job.id, input.runId) input.sessionMonitor.dispose() - await this.teardownWorktree(input.repoId, input.job.id, input.runId, input.job, input.repo) + await this.teardownRunResources(input.repoId, input.job.id, input.runId, input.job, input.repo, input.sessionId) ScheduleService.releaseActiveRun(input.job.id, input.runId) ScheduleService.clearCancel(input.job.id, input.runId) } @@ -1514,7 +1576,7 @@ export class ScheduleService { nextRunAt: run.triggerSource === 'manual' ? job.nextRunAt : computeNextRunAtForJob(job, finishedAt), }) - await this.teardownWorktree(run.repoId, run.jobId, run.id, job, repo) + await this.teardownRunResources(run.repoId, run.jobId, run.id, job, repo, run.sessionId) ScheduleService.releaseActiveRun(job.id, run.id) } @@ -1543,38 +1605,69 @@ export class ScheduleService { await this.interruptCancelledSession(client, sessionId) } ScheduleService.clearCancel(jobId, run.id) - await this.teardownWorktree(repoId, jobId, run.id, job, repo) + await this.teardownRunResources(repoId, jobId, run.id, job, repo, sessionId ?? run.sessionId) ScheduleService.releaseActiveRun(jobId, run.id) return this.loadRun(repoId, jobId, run.id, run) } - private async teardownWorktree(repoId: number | null, jobId: number, runId: number, job: ScheduleJob, repo: Repo | null): Promise { - if (!repo) return + private async teardownRunResources( + repoId: number | null, + jobId: number, + runId: number, + job: ScheduleJob, + repo: Repo | null, + sessionId?: string | null, + ): Promise { const key = `${repoId}:${jobId}:${runId}` if (ScheduleService.activeTeardowns.has(key)) return ScheduleService.activeTeardowns.add(key) try { const fresh = getScheduleRunById(this.db, repoId, jobId, runId) - if (!fresh?.worktreePath) return - const worktreePath = fresh.worktreePath - const remainingWorktreePath = () => (existsSync(worktreePath) ? worktreePath : null) - try { - const { commitHash } = await this.worktreeManager.finalize(repo, job, { - id: runId, - worktreePath, - runBranch: fresh.runBranch, - triggerSource: fresh.triggerSource, - }) - updateScheduleRunWorktree(this.db, repoId, jobId, runId, { worktreePath: remainingWorktreePath(), commitHash }) - } catch (error) { - logger.error(`Failed to finalize worktree for run ${runId}:`, error) - updateScheduleRunWorktree(this.db, repoId, jobId, runId, { worktreePath: remainingWorktreePath() }) + if (repo && fresh?.worktreePath) { + await this.finalizeRunWorktree(repo, job, runId, fresh.worktreePath, fresh.runBranch, fresh.triggerSource, repoId, jobId) } + await this.removeRunSandbox(job, sessionId ?? fresh?.sessionId ?? null) } finally { ScheduleService.activeTeardowns.delete(key) } } + private async finalizeRunWorktree( + repo: Repo, + job: ScheduleJob, + runId: number, + worktreePath: string, + runBranch: string | null, + triggerSource: ScheduleRunTriggerSource, + repoId: number | null, + jobId: number, + ): Promise { + const remainingWorktreePath = () => (existsSync(worktreePath) ? worktreePath : null) + try { + const { commitHash } = await this.worktreeManager.finalize(repo, job, { + id: runId, + worktreePath, + runBranch, + triggerSource, + }) + updateScheduleRunWorktree(this.db, repoId, jobId, runId, { worktreePath: remainingWorktreePath(), commitHash }) + } catch (error) { + logger.error(`Failed to finalize worktree for run ${runId}:`, error) + updateScheduleRunWorktree(this.db, repoId, jobId, runId, { worktreePath: remainingWorktreePath() }) + } + } + + private async removeRunSandbox(job: ScheduleJob, sessionId: string | null): Promise { + if (job.sandbox?.enabled !== true || !sessionId) return + const runtime = this.sandbox?.runtime + if (!runtime) return + try { + await runtime.removeSessionSandbox(sessionId) + } catch (error) { + logger.warn(`Failed to remove the sandbox microVM for scheduled run session ${sessionId}: ${getErrorMessage(error)}`) + } + } + /** * An assistant message completing is not the end of a run: a multi-step agent * settles one message per tool call. Only a session that has gone idle has diff --git a/backend/src/services/session-sandbox.ts b/backend/src/services/session-sandbox.ts index c356f8196..e50b38b73 100644 --- a/backend/src/services/session-sandbox.ts +++ b/backend/src/services/session-sandbox.ts @@ -109,17 +109,7 @@ export class SessionSandboxService { throw new SessionSandboxError('Child sessions inherit sandboxing from their parent session', 400) } - const available = this.sandboxSupported() - if (request.sandboxed === true && !available) { - throw new SessionSandboxError('Sandboxing is not enabled for this server', 409) - } - - if (request.resources != null) { - const limitError = sandboxResourceLimitError(request.resources) - if (limitError !== null) { - throw new SessionSandboxError(limitError, 400) - } - } + this.assertSandboxRequestAllowed(request) const defaultSandboxed = this.defaultSandboxed() this.db.transaction(() => { @@ -131,13 +121,30 @@ export class SessionSandboxService { } })() - if (request.sandboxed === false && available) { + if (request.sandboxed === false && this.sandboxSupported()) { this.removeSandboxInBackground(sessionId, `session ${sessionId} after its sandbox was turned off`) } return this.getState(sessionId) } + /** + * Validates a sandbox change before it is stored or acted on: enabling sandboxing + * requires it to be enabled for the server, and the resources must be within limits. + */ + assertSandboxRequestAllowed(request: SetSessionSandboxRequest): void { + if (request.sandboxed === true && !this.sandboxSupported()) { + throw new SessionSandboxError('Sandboxing is not enabled for this server', 409) + } + + if (request.resources != null) { + const limitError = sandboxResourceLimitError(request.resources) + if (limitError !== null) { + throw new SessionSandboxError(limitError, 400) + } + } + } + async resolveShellTarget(sessionId: string): Promise<{ rootSessionId: string; resources: SessionSandboxResources }> { const rootSessionId = await this.ancestry.resolveRootSessionId(sessionId) const stored = getSessionSandboxMode(this.db, rootSessionId) diff --git a/backend/test/db/schedule-migrations.test.ts b/backend/test/db/schedule-migrations.test.ts index 1493d320f..94e32cd91 100644 --- a/backend/test/db/schedule-migrations.test.ts +++ b/backend/test/db/schedule-migrations.test.ts @@ -6,6 +6,7 @@ import migration015 from '../../src/db/migrations/015-schedule-worktree-isolatio import migration021 from '../../src/db/migrations/021-drop-schedule-run-workspace-id' import migration022 from '../../src/db/migrations/022-schedule-runs-session-index' import migration024 from '../../src/db/migrations/024-schedule-runs-viewed-at' +import migration202610251900 from '../../src/db/migrations/202610251900-schedule-sandbox-config' describe('schedule migrations', () => { it('creates schedule jobs with nullable interval minutes in v7', () => { @@ -288,3 +289,31 @@ describe('migration 024 - schedule runs viewed at', () => { db.close() }) }) + +describe('migration 202610251900 - schedule sandbox config', () => { + it('adds the nullable sandbox_config column and is idempotent', () => { + const db = new Database(':memory:') + db.run('CREATE TABLE schedule_jobs (id INTEGER PRIMARY KEY, name TEXT)') + + migration202610251900.up(db) + migration202610251900.up(db) + + const columns = (db.prepare('PRAGMA table_info(schedule_jobs)').all() as { name: string }[]).map((column) => column.name) + expect(columns).toContain('sandbox_config') + + db.close() + }) + + it('drops the column on rollback', () => { + const db = new Database(':memory:') + db.run('CREATE TABLE schedule_jobs (id INTEGER PRIMARY KEY, name TEXT)') + + migration202610251900.up(db) + migration202610251900.down(db) + + const columns = (db.prepare('PRAGMA table_info(schedule_jobs)').all() as { name: string }[]).map((column) => column.name) + expect(columns).not.toContain('sandbox_config') + + db.close() + }) +}) diff --git a/backend/test/db/schedules.permission.test.ts b/backend/test/db/schedules.permission.test.ts index 3bb6e6d7e..561f0dae7 100644 --- a/backend/test/db/schedules.permission.test.ts +++ b/backend/test/db/schedules.permission.test.ts @@ -36,6 +36,7 @@ describe('schedule permission config persistence', () => { mcpServers: [], branch: null, workspaceMode: 'worktree', + sandbox: null, nextRunAt: Date.now() + 3600000, ...overrides, }) @@ -108,4 +109,25 @@ describe('schedule permission config persistence', () => { expect(updated!.prompt).toBe('Updated prompt') expect(updated!.permissionConfig).toEqual(config) }) + + it('round-trips a sandbox config', () => { + const sandbox = { enabled: true, resources: { cpus: 4, memoryMib: 12288 } } + const created = createScheduleJob(db, 1, baseInput({ sandbox })) + + expect(created.sandbox).toEqual(sandbox) + + const fetched = getScheduleJobById(db, 1, created.id) + expect(fetched).not.toBeNull() + expect(fetched!.sandbox).toEqual(sandbox) + }) + + it('round-trips a null sandbox config', () => { + const created = createScheduleJob(db, 1, baseInput({ sandbox: null })) + + expect(created.sandbox).toBeNull() + + const fetched = getScheduleJobById(db, 1, created.id) + expect(fetched).not.toBeNull() + expect(fetched!.sandbox).toBeNull() + }) }) diff --git a/backend/test/db/schedules.test.ts b/backend/test/db/schedules.test.ts index 7f8e51e6c..05c83dcaa 100644 --- a/backend/test/db/schedules.test.ts +++ b/backend/test/db/schedules.test.ts @@ -117,6 +117,7 @@ describe('schedule database queries', () => { mcpServers: [], branch: null, workspaceMode: 'worktree', + sandbox: null, nextRunAt: Date.UTC(2026, 2, 9, 13, 0, 0), }) @@ -139,6 +140,7 @@ describe('schedule database queries', () => { null, null, 'worktree', + null, expect.any(Number), expect.any(Number), null, @@ -179,6 +181,7 @@ describe('schedule database queries', () => { mcpServers: [], branch: null, workspaceMode: 'worktree', + sandbox: null, nextRunAt: null, }) @@ -199,6 +202,7 @@ describe('schedule database queries', () => { null, 'worktree', null, + null, expect.any(Number), null, 42, diff --git a/backend/test/services/git/GitService.real.test.ts b/backend/test/services/git/GitService.real.test.ts index 894208764..ea7a14ba3 100644 --- a/backend/test/services/git/GitService.real.test.ts +++ b/backend/test/services/git/GitService.real.test.ts @@ -46,6 +46,7 @@ function scheduleJobInput(name: string, branch: string): ScheduleJobPersistenceI permissionConfig: null, mcpServers: [], workspaceMode: 'worktree', + sandbox: null, branch, nextRunAt: null, } diff --git a/backend/test/services/schedule-config.test.ts b/backend/test/services/schedule-config.test.ts index e57ec6c8e..8a7770222 100644 --- a/backend/test/services/schedule-config.test.ts +++ b/backend/test/services/schedule-config.test.ts @@ -37,6 +37,7 @@ describe('schedule-config', () => { mcpServers: [], branch: null, workspaceMode: 'worktree', + sandbox: null, nextRunAt: currentDate + 60 * 60_000, }) }) @@ -78,6 +79,7 @@ describe('schedule-config', () => { mcpServers: [], branch: null, workspaceMode: 'worktree', + sandbox: null, nextRunAt: Date.UTC(2026, 2, 9, 13, 0, 0), lastRunAt: Date.UTC(2026, 2, 9, 12, 0, 0), createdAt: Date.UTC(2026, 2, 8, 12, 0, 0), @@ -111,6 +113,7 @@ describe('schedule-config', () => { mcpServers: [], branch: null, workspaceMode: 'worktree', + sandbox: null, nextRunAt: Date.UTC(2026, 2, 9, 13, 0, 0), lastRunAt: Date.UTC(2026, 2, 9, 12, 0, 0), createdAt: Date.UTC(2026, 2, 8, 12, 0, 0), @@ -147,6 +150,7 @@ describe('schedule-config', () => { mcpServers: [], branch: 'feature/foo', workspaceMode: 'worktree', + sandbox: null, nextRunAt: Date.UTC(2026, 2, 9, 13, 0, 0), lastRunAt: null, createdAt: Date.UTC(2026, 2, 8, 12, 0, 0), @@ -179,6 +183,7 @@ describe('schedule-config', () => { mcpServers: [], branch: null, workspaceMode: 'worktree', + sandbox: null, nextRunAt: Date.UTC(2026, 2, 9, 13, 0, 0), lastRunAt: null, createdAt: Date.UTC(2026, 2, 8, 12, 0, 0), @@ -211,6 +216,7 @@ describe('schedule-config', () => { mcpServers: [], branch: 'feature/foo', workspaceMode: 'worktree', + sandbox: null, nextRunAt: Date.UTC(2026, 2, 9, 13, 0, 0), lastRunAt: null, createdAt: Date.UTC(2026, 2, 8, 12, 0, 0), @@ -243,6 +249,7 @@ describe('schedule-config', () => { mcpServers: [], branch: null, workspaceMode: 'worktree', + sandbox: null, nextRunAt: null, lastRunAt: null, createdAt: Date.UTC(2026, 2, 8, 12, 0, 0), @@ -288,6 +295,7 @@ describe('schedule-config', () => { mcpServers: [], branch: null, workspaceMode: 'worktree', + sandbox: null, nextRunAt: null, lastRunAt: null, createdAt: Date.UTC(2026, 2, 8, 12, 0, 0), @@ -296,4 +304,83 @@ describe('schedule-config', () => { expect(computeNextRunAtForJob(job, Date.UTC(2026, 2, 9, 12, 0, 0))).toBeNull() }) + + describe('sandbox config', () => { + const sandbox = { enabled: true, resources: { cpus: 4, memoryMib: 12288 } } + + const existingJob = (sandboxConfig: ScheduleJob['sandbox']): ScheduleJob => ({ + id: 14, + repoId: 42, + name: 'Sandbox job', + description: null, + enabled: true, + scheduleMode: 'interval', + intervalMinutes: 60, + cronExpression: null, + timezone: null, + agentSlug: null, + prompt: 'Prompt', + model: null, + skillMetadata: null, + permissionConfig: null, + mcpServers: [], + branch: null, + workspaceMode: 'worktree', + sandbox: sandboxConfig, + nextRunAt: Date.UTC(2026, 2, 9, 13, 0, 0), + lastRunAt: null, + createdAt: Date.UTC(2026, 2, 8, 12, 0, 0), + updatedAt: Date.UTC(2026, 2, 9, 12, 0, 0), + }) + + it('defaults sandbox to null when creating a schedule', () => { + const result = buildCreateSchedulePersistenceInput({ + name: 'Sandbox job', + enabled: true, + scheduleMode: 'interval', + intervalMinutes: 60, + prompt: 'Prompt', + }) + + expect(result.sandbox).toBeNull() + }) + + it('keeps the provided sandbox when creating a schedule', () => { + const result = buildCreateSchedulePersistenceInput({ + name: 'Sandbox job', + enabled: true, + scheduleMode: 'interval', + intervalMinutes: 60, + prompt: 'Prompt', + sandbox, + }) + + expect(result.sandbox).toEqual(sandbox) + }) + + it('keeps the existing sandbox when omitted from an update', () => { + const result = buildUpdatedSchedulePersistenceInput(existingJob(sandbox), { + prompt: 'Updated prompt', + }) + + expect(result.sandbox).toEqual(sandbox) + }) + + it('replaces the sandbox when provided in an update', () => { + const replacement = { enabled: true, resources: null } + const result = buildUpdatedSchedulePersistenceInput(existingJob(sandbox), { + sandbox: replacement, + }) + + expect(result.sandbox).toEqual(replacement) + }) + + it('clears the sandbox when explicitly set to null in an update', () => { + const result = buildUpdatedSchedulePersistenceInput(existingJob(sandbox), { + sandbox: null, + }) + + expect(result.sandbox).toBeNull() + }) + }) }) diff --git a/backend/test/services/schedules.permission.test.ts b/backend/test/services/schedules.permission.test.ts index 014017487..67d6d72b4 100644 --- a/backend/test/services/schedules.permission.test.ts +++ b/backend/test/services/schedules.permission.test.ts @@ -126,6 +126,7 @@ const baseJob: ScheduleJob = { mcpServers: [], branch: null, workspaceMode: 'worktree', + sandbox: null, nextRunAt: Date.UTC(2026, 2, 9, 13, 0, 0), lastRunAt: Date.UTC(2026, 2, 9, 12, 0, 0), createdAt: Date.UTC(2026, 2, 8, 12, 0, 0), diff --git a/backend/test/services/schedules.test.ts b/backend/test/services/schedules.test.ts index 4a463b508..b5f1126b0 100644 --- a/backend/test/services/schedules.test.ts +++ b/backend/test/services/schedules.test.ts @@ -36,6 +36,7 @@ const mocks = vi.hoisted(() => ({ onEvent: vi.fn(), onServerConnected: vi.fn(), loggerError: vi.fn(), + loggerWarn: vi.fn(), updateScheduleRunWorktree: vi.fn(), stubWorktreeManager: { prepare: vi.fn().mockResolvedValue(null), @@ -96,7 +97,7 @@ vi.mock('../../src/utils/logger', () => ({ logger: { error: mocks.loggerError, info: vi.fn(), - warn: vi.fn(), + warn: mocks.loggerWarn, }, })) @@ -112,6 +113,7 @@ vi.mock('croner', () => ({ })) import { ScheduleRunner, ScheduleService } from '../../src/services/schedules' +import { SessionSandboxError } from '../../src/services/session-sandbox' import { createStubOpenCodeClient } from '../helpers/stub-opencode-client' import { assistantMessage, createStubScheduleApi, skill } from '../helpers/stub-schedule-api' @@ -119,6 +121,32 @@ function makeService(api: OpenCodeApi): ScheduleService { return new ScheduleService({} as never, createStubOpenCodeClient({ api }), mocks.stubWorktreeManager as never) } +const SANDBOX_RESOURCES = { cpus: 4, memoryMib: 8192 } + +function makeSandboxService(api: OpenCodeApi) { + const sessions = { + assertSandboxRequestAllowed: vi.fn(), + update: vi.fn().mockResolvedValue({}), + resolveShellTarget: vi.fn().mockResolvedValue({ rootSessionId: 'ses-run-1', resources: SANDBOX_RESOURCES }), + } + const runtime = { + planShell: vi.fn().mockResolvedValue({ mode: 'sandbox', name: 'ocm-ses-run-1', workdir: repo.fullPath }), + removeSessionSandbox: vi.fn().mockResolvedValue(undefined), + } + const registry = { + get: vi.fn(() => ({ id: 'srv_remote', name: 'Remote server', enabled: true })), + getClient: vi.fn(() => createStubOpenCodeClient({ api })), + } + const service = new ScheduleService( + {} as never, + createStubOpenCodeClient({ api }), + mocks.stubWorktreeManager as never, + registry as never, + { sessions, runtime } as never, + ) + return { service, sessions, runtime } +} + function sessionIdleEvent(directory: string, sessionID: string) { return { id: `evt_${sessionID}`, @@ -167,6 +195,7 @@ const job: ScheduleJob = { mcpServers: [], branch: null, workspaceMode: 'worktree', + sandbox: null, nextRunAt: Date.UTC(2026, 2, 9, 13, 0, 0), lastRunAt: Date.UTC(2026, 2, 9, 12, 0, 0), createdAt: Date.UTC(2026, 2, 8, 12, 0, 0), @@ -1324,6 +1353,211 @@ describe('ScheduleService', () => { expect(stub.api.session.create).not.toHaveBeenCalled() }) }) + + describe('sandboxed runs', () => { + const sandboxJob: ScheduleJob = { ...job, sandbox: { enabled: true, resources: SANDBOX_RESOURCES } } + + function runWithSession(sessionId = 'ses-run-1'): ScheduleRun { + return { + ...baseRun, + sessionId, + sessionTitle: 'Scheduled: Weekly engineering summary', + logText: 'Run started. Waiting for assistant response...', + } + } + + it('boots the run sandbox with the job resources before the first prompt', async () => { + mocks.getScheduleJobById.mockReturnValue(sandboxJob) + const stub = createStubScheduleApi({ messages: [assistantMessage('Done.', { completed: true })] }) + const { service, sessions, runtime } = makeSandboxService(stub.api) + mocks.updateScheduleRunMetadata.mockReturnValue(runWithSession()) + mocks.getScheduleRunById.mockReturnValue(runWithSession()) + + await service.runJob(42, 7, 'manual') + await vi.waitFor(() => expect(stub.api.session.prompt).toHaveBeenCalledTimes(1)) + + expect(sessions.assertSandboxRequestAllowed).toHaveBeenCalledWith({ sandboxed: true, resources: SANDBOX_RESOURCES }) + expect(sessions.update).toHaveBeenCalledWith('ses-run-1', { sandboxed: true, resources: SANDBOX_RESOURCES }) + expect(runtime.planShell).toHaveBeenCalledWith( + repo.fullPath, + { rootSessionId: 'ses-run-1', resources: SANDBOX_RESOURCES, locationDirectory: repo.fullPath }, + true, + ) + + const [metadataOrder] = mocks.updateScheduleRunMetadata.mock.invocationCallOrder + const [updateOrder] = sessions.update.mock.invocationCallOrder + const [planOrder] = runtime.planShell.mock.invocationCallOrder + const [promptOrder] = vi.mocked(stub.api.session.prompt).mock.invocationCallOrder + expect(metadataOrder).toBeLessThan(updateOrder ?? 0) + expect(updateOrder).toBeLessThan(planOrder ?? 0) + expect(planOrder).toBeLessThan(promptOrder ?? 0) + }) + + it('fails the run without prompting when the sandbox cannot start', async () => { + mocks.getScheduleJobById.mockReturnValue(sandboxJob) + const stub = createStubScheduleApi() + const { service, runtime } = makeSandboxService(stub.api) + mocks.updateScheduleRunMetadata.mockReturnValue(runWithSession()) + mocks.getScheduleRunById.mockReturnValue(runWithSession()) + runtime.planShell.mockResolvedValue({ mode: 'blocked', reason: '2 sandbox VMs are already running' }) + mocks.updateScheduleRun.mockReturnValue({ + ...runWithSession(), + status: 'failed', + errorText: 'Sandbox could not start: 2 sandbox VMs are already running', + }) + + await expect(service.runJob(42, 7, 'manual')).rejects.toMatchObject({ + status: 502, + message: 'Sandbox could not start: 2 sandbox VMs are already running', + }) + + expect(stub.api.session.prompt).not.toHaveBeenCalled() + expect(mocks.updateScheduleRun).toHaveBeenCalledWith( + expect.anything(), + 42, + 7, + 5, + expect.objectContaining({ + status: 'failed', + sessionId: 'ses-run-1', + errorText: 'Sandbox could not start: 2 sandbox VMs are already running', + }), + ) + expect(runtime.removeSessionSandbox).toHaveBeenCalledWith('ses-run-1') + }) + + it('removes the sandbox after a completed run and survives a removal failure', async () => { + mocks.getScheduleJobById.mockReturnValue(sandboxJob) + const stub = createStubScheduleApi({ messages: [assistantMessage('Done.', { completed: true })] }) + const { service, runtime } = makeSandboxService(stub.api) + mocks.updateScheduleRunMetadata.mockReturnValue(runWithSession()) + mocks.getScheduleRunById.mockReturnValue(runWithSession()) + runtime.removeSessionSandbox.mockRejectedValue(new Error('msb rm failed')) + + await service.runJob(42, 7, 'manual') + + await vi.waitFor(() => { + expect(mocks.updateScheduleRun).toHaveBeenCalledWith( + expect.anything(), + 42, + 7, + 5, + expect.objectContaining({ status: 'completed' }), + ) + }) + await vi.waitFor(() => expect(runtime.removeSessionSandbox).toHaveBeenCalledWith('ses-run-1')) + await vi.waitFor(() => expect(mocks.loggerWarn).toHaveBeenCalled()) + expect(mocks.updateScheduleRun).not.toHaveBeenCalledWith( + expect.anything(), + 42, + 7, + 5, + expect.objectContaining({ status: 'failed' }), + ) + }) + + it('removes the sandbox of a cancelled run', async () => { + mocks.getScheduleJobById.mockReturnValue(sandboxJob) + const stub = createStubScheduleApi({ messages: [] }) + const { service, runtime } = makeSandboxService(stub.api) + const runningRun = runWithSession('ses-run-3') + const cancelledRun: ScheduleRun = { + ...runningRun, + status: 'cancelled', + finishedAt: Date.UTC(2026, 2, 9, 12, 10, 0), + errorText: 'Run cancelled by user.', + } + mocks.getScheduleRunById.mockReturnValue(runningRun) + mocks.updateScheduleRun.mockReturnValue(cancelledRun) + + const result = await service.cancelRun(42, 7, 5) + + expect(result).toEqual(cancelledRun) + expect(runtime.removeSessionSandbox).toHaveBeenCalledWith('ses-run-3') + }) + + it('leaves the sandbox dependencies untouched for a job without an enabled sandbox', async () => { + for (const sandbox of [null, { enabled: false, resources: SANDBOX_RESOURCES }] as const) { + mocks.getScheduleJobById.mockReturnValue({ ...job, sandbox }) + const stub = createStubScheduleApi({ messages: [assistantMessage('Done.', { completed: true })] }) + const { service, sessions, runtime } = makeSandboxService(stub.api) + mocks.updateScheduleRunMetadata.mockReturnValue(runWithSession()) + mocks.getScheduleRunById.mockReturnValue(runWithSession()) + + await service.runJob(42, 7, 'manual') + + await vi.waitFor(() => { + expect(mocks.updateScheduleRun).toHaveBeenCalledWith( + expect.anything(), + 42, + 7, + 5, + expect.objectContaining({ status: 'completed' }), + ) + }) + expect(sessions.update).not.toHaveBeenCalled() + expect(runtime.planShell).not.toHaveBeenCalled() + expect(runtime.removeSessionSandbox).not.toHaveBeenCalled() + await vi.waitFor(() => { + expect(Reflect.get(ScheduleService, 'activeRuns').has(7)).toBe(false) + }) + mocks.updateScheduleRun.mockClear() + } + }) + + it('validates sandbox settings when saving a local schedule', async () => { + const stub = createStubScheduleApi() + const { service, sessions } = makeSandboxService(stub.api) + mocks.buildCreateSchedulePersistenceInput.mockReturnValue({ name: 'Sandboxed job' }) + mocks.createScheduleJob.mockReturnValue(sandboxJob) + + await service.createJob(42, { + name: 'Sandboxed job', + enabled: true, + scheduleMode: 'interval', + intervalMinutes: 60, + prompt: 'Audit the project.', + sandbox: { enabled: true, resources: SANDBOX_RESOURCES }, + }) + + expect(sessions.assertSandboxRequestAllowed).toHaveBeenCalledWith({ sandboxed: true, resources: SANDBOX_RESOURCES }) + expect(mocks.createScheduleJob).toHaveBeenCalled() + }) + + it('rejects enabling the sandbox for a remote server schedule', async () => { + const stub = createStubScheduleApi({ agents: ['build'] }) + const { service } = makeSandboxService(stub.api) + + await expect(service.createServerJob('srv_remote', '/srv/audit', { + name: 'Remote audit', + enabled: true, + scheduleMode: 'interval', + intervalMinutes: 60, + prompt: 'Audit the project.', + sandbox: { enabled: true, resources: null }, + })).rejects.toMatchObject({ + status: 400, + message: 'Sandboxed schedules run only on the local OpenCode server.', + }) + + expect(mocks.createScheduleJob).not.toHaveBeenCalled() + }) + + it('maps a rejected sandbox request to the schedule error status', async () => { + const stub = createStubScheduleApi() + const { service, sessions } = makeSandboxService(stub.api) + sessions.assertSandboxRequestAllowed.mockImplementation(() => { + throw new SessionSandboxError('Sandboxing is not enabled for this server', 409) + }) + + await expect(service.updateJob(42, 7, { sandbox: { enabled: true, resources: null } })).rejects.toMatchObject({ + status: 409, + message: 'Sandboxing is not enabled for this server', + }) + + expect(mocks.updateScheduleJob).not.toHaveBeenCalled() + }) + }) }) describe('ScheduleService startup cancellation', () => { @@ -2261,6 +2495,7 @@ describe('ScheduleRunner', () => { mcpServers: [], branch: null, workspaceMode: 'worktree', + sandbox: null, nextRunAt: Date.now(), lastRunAt: null, createdAt: Date.now(), @@ -2300,6 +2535,7 @@ describe('ScheduleRunner', () => { mcpServers: [], branch: null, workspaceMode: 'worktree', + sandbox: null, nextRunAt: Date.now(), lastRunAt: null, createdAt: Date.now(), @@ -2337,6 +2573,7 @@ describe('ScheduleRunner', () => { mcpServers: [], branch: null, workspaceMode: 'worktree', + sandbox: null, nextRunAt: Date.now(), lastRunAt: null, createdAt: Date.now(), @@ -2373,6 +2610,7 @@ describe('ScheduleRunner', () => { mcpServers: [], branch: null, workspaceMode: 'worktree', + sandbox: null, nextRunAt: Date.now(), lastRunAt: null, createdAt: Date.now(), @@ -2409,6 +2647,7 @@ describe('ScheduleRunner', () => { mcpServers: [], branch: null, workspaceMode: 'worktree', + sandbox: null, nextRunAt: Date.now(), lastRunAt: null, createdAt: Date.now(), diff --git a/frontend/src/components/message/SessionSandboxResourcesDialog.tsx b/frontend/src/components/message/SessionSandboxResourcesDialog.tsx index 24d423178..b3b7b2485 100644 --- a/frontend/src/components/message/SessionSandboxResourcesDialog.tsx +++ b/frontend/src/components/message/SessionSandboxResourcesDialog.tsx @@ -1,8 +1,7 @@ import { useEffect, useRef, useState } from 'react' import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' +import { SandboxResourceInputs } from '@/components/sandbox/SandboxResourceInputs' import { useSetSessionSandbox } from '@/hooks/useSessionSandbox' import { FetchError } from '@/api/fetchWrapper' import { formatSandboxMemory } from '@/lib/sandboxResources' @@ -69,36 +68,15 @@ export function SessionSandboxResourcesDialog({ data volume is kept. -
-
- - setCpus(event.target.value)} - /> -
-
- - setMemoryGib(event.target.value)} - /> -
- {error ? ( -

- {error} -

- ) : null} -
+