From 7eaacfdfe0775da2621cfd807eb322e20c15ca5b Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Thu, 27 Aug 2026 00:57:45 -0400 Subject: [PATCH 1/4] fix(sandbox): guard enable path and harden UI rendering performance --- .github/workflows/docker-build.yml | 2 +- Dockerfile | 2 +- backend/src/routes/settings.ts | 13 ++ backend/src/services/credential-provider.ts | 103 ++++++++++------ backend/test/routes/settings.test.ts | 115 ++++++++++++++++++ backend/test/scripts/docker-config.test.ts | 4 +- .../test/services/credential-provider.test.ts | 15 ++- backend/test/services/sandbox/runtime.test.ts | 6 +- .../components/message/MessagePart.test.tsx | 76 ++++++++++++ .../src/components/message/ToolCallPart.tsx | 62 +++++++--- frontend/src/contexts/EventContext.test.tsx | 45 +++++++ frontend/src/contexts/EventContext.tsx | 13 +- 12 files changed, 394 insertions(+), 62 deletions(-) diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index b761fecd..528c42b6 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -21,7 +21,7 @@ jobs: 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=1.18.16 - MICROSANDBOX_VERSION=0.6.8 + MICROSANDBOX_VERSION=0.6.15 echo "uv=${UV_VERSION}" >> $GITHUB_OUTPUT echo "opencode=${OPENCODE_VERSION}" >> $GITHUB_OUTPUT echo "microsandbox=${MICROSANDBOX_VERSION}" >> $GITHUB_OUTPUT diff --git a/Dockerfile b/Dockerfile index 6542ef28..d30d323d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -60,7 +60,7 @@ FROM base AS runner ARG UV_VERSION=latest ARG OPENCODE_VERSION=1.18.16 -ARG MICROSANDBOX_VERSION=0.6.8 +ARG MICROSANDBOX_VERSION=0.6.15 # 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 diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index 9c26e7a2..8205f68d 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -32,6 +32,8 @@ import { opencodeServerManager, ConfigReloadError, resolveOpenCodeExecutable } f import { getOrCreateInternalToken, rotateInternalToken } from '../services/internal-token' import { sseAggregator } from '../services/sse-aggregator' import type { OpenCodeSupervisor } from '../services/opencode-supervisor' +import { detectSandboxCapability } from '../services/sandbox/capability' +import { resolveProcessIdentityProvider } from '../services/opencode/process-identity' import { restartOpenCode, restartOpenCodeAfterCommit, reloadOpenCodeConfig, getOpenCodeRestartCoordinator } from '../services/opencode-restart' import type { GitAuthService } from '../services/git-auth' import { DEFAULT_AGENTS_MD } from '../constants' @@ -394,6 +396,17 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic } const currentSettings = settingsService.getSettings(userId) + + if (currentSettings.preferences.sandbox?.enabled !== true && validated.preferences.sandbox?.enabled === true) { + const capability = detectSandboxCapability() + if (capability.available === false) { + return c.json({ error: `Cannot enable sandboxing: ${capability.reason}` }, 400) + } + if (resolveProcessIdentityProvider().attested === false) { + return c.json({ error: 'Cannot enable sandboxing: process identity attestation is unavailable on this platform (Linux /proc is required)' }, 400) + } + } + const settings = settingsService.updateSettings(validated.preferences, userId) const sandboxChanged = sandboxEnforcementChanged(currentSettings.preferences.sandbox, validated.preferences.sandbox) diff --git a/backend/src/services/credential-provider.ts b/backend/src/services/credential-provider.ts index a5bd26b6..5e1817cf 100644 --- a/backend/src/services/credential-provider.ts +++ b/backend/src/services/credential-provider.ts @@ -1,5 +1,6 @@ import type { Database } from 'bun:sqlite' import type { GitCredential, Repo } from '@opencode-manager/shared' +import type { UserPreferences } from '../types/settings' import { SettingsService } from './settings' import { findPatCredentialForHost, @@ -22,6 +23,12 @@ interface CredentialResolutionOptions { repoId?: number } +interface CredentialResolutionContext { + preferences: UserPreferences + credentials: GitCredential[] + repo: Repo | null +} + export class CredentialProvider { private settingsService: SettingsService private database: Database @@ -32,8 +39,7 @@ export class CredentialProvider { } getGitCredentials(): GitCredential[] { - const settings = this.settingsService.getSettings('default') - return (settings.preferences.gitCredentials || []) as GitCredential[] + return this.getCredentials(this.getPreferences()) } getGitCredentialById(credentialId: string | undefined): GitCredential | null { @@ -42,10 +48,10 @@ export class CredentialProvider { } getPatCredentialForHost(hostname: string, options: CredentialResolutionOptions = {}): ResolvedGitCredential | null { - const credentials = this.getGitCredentials() - const selectedCredential = this.getSelectedCredential(options, credentials) + const context = this.resolveContext(options) + const selectedCredential = this.getSelectedCredential(context) const selectedMatch = selectedCredential ? findPatCredentialForHost([selectedCredential], hostname) : null - return selectedMatch ?? findPatCredentialForHost(credentials, hostname) + return selectedMatch ?? findPatCredentialForHost(context.credentials, hostname) } getSshCredentialsForHost(host: string): GitCredential[] { @@ -57,24 +63,18 @@ export class CredentialProvider { } getGitEnv(options: CredentialResolutionOptions = {}): Record { - const credentials = this.getGitCredentials() - return createGitEnv(credentials, this.getSelectedCredential(options, credentials)) + return this.getGitEnvForContext(this.resolveContext(options)) } isSandboxGitCredentialsAllowed(options: CredentialResolutionOptions = {}): boolean { - const repo = this.resolveRepo(options) - if (repo) { - const repoOverride = getRepoSandboxGitCredentials(this.database, repo.id) - if (repoOverride !== null) return repoOverride - } - - return this.settingsService.getSettings('default').preferences.sandbox?.gitCredentials === true + return this.getSandboxGitCredentialsAllowed(this.resolveContext(options)) } getSandboxGitEnv(options: CredentialResolutionOptions = {}): Record { - if (!this.isSandboxGitCredentialsAllowed(options)) return {} + const context = this.resolveContext(options) + if (!this.getSandboxGitCredentialsAllowed(context)) return {} - const gitEnv = this.getGitEnv(options) + const gitEnv = this.getGitEnvForContext(context) if (gitEnv.GIT_CONFIG_COUNT === '0') return {} const { env, dropped } = limitForwardedGitConfigs(gitEnv) @@ -84,44 +84,75 @@ export class CredentialProvider { ) } - return { ...env, ...this.getGhCliEnv(options) } + return { ...env, ...this.getGhCliEnvForContext(context) } } - private resolveRepo(options: CredentialResolutionOptions): Repo | null { - if (options.repoId !== undefined) { - return listRepos(this.database).find((repo) => repo.id === options.repoId) ?? null + getGhCliEnv(options: CredentialResolutionOptions = {}): Record { + return this.getGhCliEnvForContext(this.resolveContext(options)) + } + + private resolveContext(options: CredentialResolutionOptions): CredentialResolutionContext { + const preferences = this.getPreferences() + return { + preferences, + credentials: this.getCredentials(preferences), + repo: this.resolveRepo(options), } - return options.cwd ? getRepoByDirectory(this.database, options.cwd) : null } - getGhCliEnv(options: CredentialResolutionOptions = {}): Record { - const credential = this.getGhCliCredential(options) + private getPreferences(): UserPreferences { + return this.settingsService.getSettings('default').preferences + } + + private getCredentials(preferences: UserPreferences): GitCredential[] { + return (preferences.gitCredentials || []) as GitCredential[] + } + + private getGitEnvForContext(context: CredentialResolutionContext): Record { + return createGitEnv(context.credentials, this.getSelectedCredential(context)) + } + + private getGhCliEnvForContext(context: CredentialResolutionContext): Record { + const credential = this.getGhCliCredential(context) if (!credential?.token) return {} return { GH_TOKEN: credential.token, GITHUB_TOKEN: credential.token } } - private getGhCliCredential(options: CredentialResolutionOptions): GitCredential | null { - const credentials = this.getGitCredentials() - const selectedCredential = this.getSelectedCredential(options, credentials) + private getSandboxGitCredentialsAllowed(context: CredentialResolutionContext): boolean { + if (context.repo) { + const repoOverride = getRepoSandboxGitCredentials(this.database, context.repo.id) + if (repoOverride !== null) return repoOverride + } + + return context.preferences.sandbox?.gitCredentials === true + } + + private resolveRepo(options: CredentialResolutionOptions): Repo | null { + if (options.repoId !== undefined) { + return listRepos(this.database).find((repo) => repo.id === options.repoId) ?? null + } + return options.cwd ? getRepoByDirectory(this.database, options.cwd) : null + } + + private getGhCliCredential(context: CredentialResolutionContext): GitCredential | null { + const selectedCredential = this.getSelectedCredential(context) if (this.isGithubPatCredential(selectedCredential)) return selectedCredential - return findGitHubCredential(credentials) + return findGitHubCredential(context.credentials) } - private getSelectedCredential(options: CredentialResolutionOptions, credentials: GitCredential[]): GitCredential | null { - const repoCredential = this.getRepoCredential(options, credentials) + private getSelectedCredential(context: CredentialResolutionContext): GitCredential | null { + const repoCredential = this.getRepoCredential(context) if (repoCredential) return repoCredential - const settings = this.settingsService.getSettings('default') - return credentials.find((credential) => credential.id === settings.preferences.defaultGitCredentialId) ?? null + return context.credentials.find((credential) => credential.id === context.preferences.defaultGitCredentialId) ?? null } - private getRepoCredential(options: CredentialResolutionOptions, credentials: GitCredential[]): GitCredential | null { - const repo = this.resolveRepo(options) - if (!repo) return null + private getRepoCredential(context: CredentialResolutionContext): GitCredential | null { + if (!context.repo) return null - const credentialId = getRepoGitCredentialId(this.database, repo.id) - return credentials.find((credential) => credential.id === credentialId) ?? null + const credentialId = getRepoGitCredentialId(this.database, context.repo.id) + return context.credentials.find((credential) => credential.id === credentialId) ?? null } private isGithubPatCredential(credential: GitCredential | null): credential is GitCredential { diff --git a/backend/test/routes/settings.test.ts b/backend/test/routes/settings.test.ts index ee526ae0..c5ac86a7 100644 --- a/backend/test/routes/settings.test.ts +++ b/backend/test/routes/settings.test.ts @@ -145,6 +145,15 @@ vi.mock('../../src/services/sandbox/runtime', () => ({ SandboxRuntimeService: sandboxRuntimeServiceMock.SandboxRuntimeService, })) +const capabilityMock = vi.hoisted(() => ({ + detectSandboxCapability: vi.fn(), +})) + +vi.mock('../../src/services/sandbox/capability', () => ({ + detectSandboxCapability: capabilityMock.detectSandboxCapability, + resetSandboxCapabilityCache: vi.fn(), +})) + vi.mock('@opencode-manager/shared/config/env', () => ({ getWorkspacePath: vi.fn(() => '/tmp/test-workspace'), getReposPath: vi.fn(() => '/tmp/test-repos'), @@ -176,6 +185,8 @@ import { getImportedSessionDirectories, getOpenCodeImportStatus, OpenCodeImportP import { relinkReposFromSessionDirectories } from '../../src/services/repo' import { opencodeServerManager, ConfigReloadError } from '../../src/services/opencode-single-server' import { patchConfigWithRecovery } from '../../src/services/opencode/config-recovery' +import { detectSandboxCapability } from '../../src/services/sandbox/capability' +import { forceProcessAttestation } from '../../src/services/opencode/process-identity' const mockSpawnSync = spawnSync as ReturnType const mockGetVersion = opencodeServerManager.getVersion as ReturnType @@ -191,6 +202,7 @@ const mockGetImportedSessionDirectories = getImportedSessionDirectories as Retur const mockRelinkReposFromSessionDirectories = relinkReposFromSessionDirectories as ReturnType const mockWriteFileContent = writeFileContent as ReturnType const mockPatchConfigWithRecovery = patchConfigWithRecovery as ReturnType +const mockDetectSandboxCapability = detectSandboxCapability as ReturnType describe('Settings Routes - OpenCode Upgrade', () => { let settingsApp: ReturnType @@ -219,6 +231,9 @@ describe('Settings Routes - OpenCode Upgrade', () => { mockRelinkReposFromSessionDirectories.mockReset() mockWriteFileContent.mockReset() mockPatchConfigWithRecovery.mockReset() + mockDetectSandboxCapability.mockReset() + mockDetectSandboxCapability.mockReturnValue({ available: true, msbVersion: 'msb 1.0.0' }) + forceProcessAttestation(true) sandboxRuntimeServiceMock.SandboxRuntimeService.mockReset() sandboxRuntimeServiceMock.SandboxRuntimeService.mockImplementation(() => ({ isEnabled: () => false, @@ -1848,6 +1863,106 @@ describe('Settings Routes - OpenCode Upgrade', () => { }) }) + describe('PATCH / - sandbox enable guard', () => { + afterEach(() => { + forceProcessAttestation(null) + }) + + it('rejects enabling sandboxing with 400 when sandbox capability is unavailable and does not persist settings', async () => { + mockDetectSandboxCapability.mockReturnValue({ + available: false, + reason: '/dev/kvm is not available or not writable; pass --device /dev/kvm and run on a KVM-capable Linux host', + }) + mockGetSettings.mockReturnValue({ + preferences: { sandbox: { enabled: false } }, + updatedAt: 1, + }) + + const req = new Request('http://localhost/', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ preferences: { sandbox: { enabled: true } } }), + }) + const res = await settingsApp.fetch(req) + const json = await res.json() as { error: string } + + expect(res.status).toBe(400) + expect(json.error).toBe('Cannot enable sandboxing: /dev/kvm is not available or not writable; pass --device /dev/kvm and run on a KVM-capable Linux host') + expect(mockUpdateSettings).not.toHaveBeenCalled() + expect(opencodeServerManager.markRestartPending).not.toHaveBeenCalled() + }) + + it('rejects enabling sandboxing with 400 when capability is available but process identity is not attested', async () => { + forceProcessAttestation(false) + mockGetSettings.mockReturnValue({ + preferences: { sandbox: { enabled: false } }, + updatedAt: 1, + }) + + const req = new Request('http://localhost/', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ preferences: { sandbox: { enabled: true } } }), + }) + const res = await settingsApp.fetch(req) + const json = await res.json() as { error: string } + + expect(res.status).toBe(400) + expect(json.error).toBe('Cannot enable sandboxing: process identity attestation is unavailable on this platform (Linux /proc is required)') + expect(mockUpdateSettings).not.toHaveBeenCalled() + expect(opencodeServerManager.markRestartPending).not.toHaveBeenCalled() + }) + + it('enables sandboxing with 200 when capability is available and process identity is attested', async () => { + mockGetSettings.mockReturnValue({ + preferences: { sandbox: { enabled: false } }, + updatedAt: 1, + }) + mockUpdateSettings.mockReturnValue({ + preferences: { sandbox: { enabled: true } }, + updatedAt: 2, + }) + + const req = new Request('http://localhost/', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ preferences: { sandbox: { enabled: true } } }), + }) + const res = await settingsApp.fetch(req) + + expect(res.status).toBe(200) + expect(mockUpdateSettings).toHaveBeenCalledWith({ sandbox: { enabled: true } }, 'default') + expect(opencodeServerManager.markRestartPending).toHaveBeenCalledTimes(1) + }) + + it('allows disabling sandboxing with 200 even when capability is unavailable and identity is not attested', async () => { + mockDetectSandboxCapability.mockReturnValue({ + available: false, + reason: '/dev/kvm is not available or not writable; pass --device /dev/kvm and run on a KVM-capable Linux host', + }) + forceProcessAttestation(false) + mockGetSettings.mockReturnValue({ + preferences: { sandbox: { enabled: true } }, + updatedAt: 1, + }) + mockUpdateSettings.mockReturnValue({ + preferences: { sandbox: { enabled: false } }, + updatedAt: 2, + }) + + const req = new Request('http://localhost/', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ preferences: { sandbox: { enabled: false } } }), + }) + const res = await settingsApp.fetch(req) + + expect(res.status).toBe(200) + expect(mockUpdateSettings).toHaveBeenCalledWith({ sandbox: { enabled: false } }, 'default') + expect(opencodeServerManager.markRestartPending).toHaveBeenCalledTimes(1) + }) + }) + describe('DELETE / - sandbox preference restart pending', () => { it('marks the OpenCode server restart pending when resetting disables sandboxing', async () => { mockGetSettings.mockReturnValue({ diff --git a/backend/test/scripts/docker-config.test.ts b/backend/test/scripts/docker-config.test.ts index cb611664..3501494a 100644 --- a/backend/test/scripts/docker-config.test.ts +++ b/backend/test/scripts/docker-config.test.ts @@ -85,7 +85,7 @@ describe('microsandbox runtime install', () => { const dockerfile = read(dockerfilePath) it('declares MICROSANDBOX_VERSION next to the other tool args', () => { - expect(dockerfile).toMatch(/ARG MICROSANDBOX_VERSION=0\.6\.8/) + expect(dockerfile).toMatch(/ARG MICROSANDBOX_VERSION=0\.6\.15/) }) it('resolves the release URL from MICROSANDBOX_VERSION, not only the log message', () => { @@ -103,7 +103,7 @@ describe('microsandbox runtime install', () => { it('passes the same MICROSANDBOX_VERSION from the docker-build workflow', () => { const workflow = read(join(repoRoot, '.github/workflows/docker-build.yml')) - expect(workflow).toContain('MICROSANDBOX_VERSION=0.6.8') + expect(workflow).toContain('MICROSANDBOX_VERSION=0.6.15') expect(workflow).toContain('MICROSANDBOX_VERSION=${{ steps.versions.outputs.microsandbox }}') }) diff --git a/backend/test/services/credential-provider.test.ts b/backend/test/services/credential-provider.test.ts index 3c9ec865..379bab97 100644 --- a/backend/test/services/credential-provider.test.ts +++ b/backend/test/services/credential-provider.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach } from 'vitest' +import { describe, it, expect, beforeEach, vi } from 'vitest' import { Database } from 'bun:sqlite' import { migrate } from '../../src/db/migration-runner' import { allMigrations } from '../../src/db/migrations' @@ -296,5 +296,18 @@ describe('CredentialProvider', () => { expect(env.GIT_CONFIG_COUNT).toBe('1') expect(env.GIT_CONFIG_KEY_0).toBe('http.https://github.com/.extraheader') }) + + it('resolves settings once per getSandboxGitEnv invocation', () => { + settingsService.updateSettings({ sandbox: { enabled: true, gitCredentials: true } }) + const repo = createGithubRepo() + + const getSettingsSpy = vi.spyOn(SettingsService.prototype, 'getSettings') + try { + provider.getSandboxGitEnv({ cwd: repo.fullPath }) + expect(getSettingsSpy).toHaveBeenCalledTimes(1) + } finally { + getSettingsSpy.mockRestore() + } + }) }) }) diff --git a/backend/test/services/sandbox/runtime.test.ts b/backend/test/services/sandbox/runtime.test.ts index 52deab2f..105cf8a7 100644 --- a/backend/test/services/sandbox/runtime.test.ts +++ b/backend/test/services/sandbox/runtime.test.ts @@ -78,7 +78,7 @@ describe('SandboxRuntimeService', () => { function enableEnforcement(): void { settingsService.updateSettings({ sandbox: { enabled: true } }) - mockDetectSandboxCapability.mockReturnValue({ available: true, msbVersion: 'msb 0.6.8' }) + mockDetectSandboxCapability.mockReturnValue({ available: true, msbVersion: 'msb 0.6.15' }) } function memoryMib(): number { @@ -980,7 +980,7 @@ describe('SandboxRuntimeService', () => { expect(mockExecuteCommand.mock.calls.filter((call) => call[0].includes('run'))).toHaveLength(0) }) - it('accepts the full image-resolved v0.6.8 config shape without recreating the sandbox', async () => { + it('accepts the full image-resolved v0.6.15 config shape without recreating the sandbox', async () => { enableEnforcement() const resolvedConfig = realInspectConfig({ image: { @@ -1942,7 +1942,7 @@ describe('SandboxRuntimeService', () => { it('fails closed when the capability becomes unavailable after the toggle was enabled', async () => { settingsService.updateSettings({ sandbox: { enabled: true } }) - mockDetectSandboxCapability.mockReturnValue({ available: true, msbVersion: 'msb 0.6.8' }) + mockDetectSandboxCapability.mockReturnValue({ available: true, msbVersion: 'msb 0.6.15' }) mockExecuteCommand.mockImplementation(async (args: string[]) => { if (args.includes('inspect')) return { exitCode: 0, stdout: runningInspectOutput(realInspectConfig()), stderr: '' } return { exitCode: 0, stdout: '[]', stderr: '' } diff --git a/frontend/src/components/message/MessagePart.test.tsx b/frontend/src/components/message/MessagePart.test.tsx index 107dd603..bc6bb689 100644 --- a/frontend/src/components/message/MessagePart.test.tsx +++ b/frontend/src/components/message/MessagePart.test.tsx @@ -321,6 +321,82 @@ describe('MessagePart', () => { }) }) + describe('tool output clamping', () => { + const createBashPartWithOutput = (output: string): MessagePartType => ({ + type: 'tool', + tool: 'bash', + sessionID: 'test-session', + state: { + status: 'completed', + input: { command: 'echo big' }, + output, + time: { start: Date.now(), end: Date.now() + 100 }, + }, + }) + + const expandTool = () => { + fireEvent.click(screen.getByRole('button')) + } + + it('renders small output in full without omission marker', () => { + setup() + const output = Array.from({ length: 200 }, (_, i) => `line ${i}`).join('\n') + render() + + expandTool() + + expect(output).not.toContain('omitted') + const pre = document.querySelector('pre')! + expect(pre.textContent).toBe(output) + expect(screen.queryByText(/omitted/)).toBeNull() + }) + + it('clamps very large output with a marker and keeps head and tail', () => { + setup() + const lines: string[] = [] + for (let i = 0; lines.join('\n').length < 200_000; i++) { + lines.push(`line-${i} ${'x'.repeat(80)} marker-start-${i === 0 ? 'FIRST' : ''}${i === 0 ? 'FIRST-marker-end' : ''}`) + } + const output = lines.join('\n') + const startMarker = 'marker-start-FIRST' + const endContent = `line-${lines.length - 1}` + + render() + + expandTool() + + expect(screen.queryByText(/omitted/)).not.toBeNull() + const pre = document.querySelector('pre')! + const rendered = pre.textContent ?? '' + expect(rendered).toContain('omitted — use the copy button for the full output') + expect(rendered.length).toBeLessThan(35_000) + expect(output.startsWith(rendered.split('\n')[0])).toBe(true) + expect(rendered).toContain(endContent) + expect(rendered).toContain(startMarker) + }) + + it('copies the full unclamped output via the copy button', async () => { + setup() + const writeText = vi.fn().mockResolvedValue(undefined) + Object.assign(navigator, { clipboard: { writeText } }) + + const lines: string[] = [] + for (let i = 0; lines.join('\n').length < 200_000; i++) { + lines.push(`line-${i} ${'y'.repeat(80)}`) + } + const output = lines.join('\n') + + render() + + expandTool() + + const copyButton = screen.getByTitle('Copy output') + fireEvent.click(copyButton) + + await expect(vi.waitFor(() => writeText.mock.calls[0]?.[0])).resolves.toBe(output) + }) + }) + describe('simpleChatMode', () => { const createToolPart = (): MessagePartType => ({ type: 'tool', diff --git a/frontend/src/components/message/ToolCallPart.tsx b/frontend/src/components/message/ToolCallPart.tsx index 0e0f502a..4670b025 100644 --- a/frontend/src/components/message/ToolCallPart.tsx +++ b/frontend/src/components/message/ToolCallPart.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect } from 'react' +import { useState, useRef, useEffect, useMemo, memo } from 'react' import { unwrapSandboxExecCommand } from '@opencode-manager/shared/utils' import type { components } from '@/api/opencode-types' import { useSettings } from '@/hooks/useSettings' @@ -13,6 +13,33 @@ import { getToolSpecificRender } from './FileToolRender' type ToolPart = components['schemas']['ToolPart'] +const DISPLAY_LIMIT = 30_000 +const DISPLAY_HEAD_LENGTH = 20_000 +const DISPLAY_TAIL_LENGTH = 10_000 + +function formatOmittedSize(size: number): string { + return size < 1024 * 1024 + ? `${(size / 1024).toFixed(1)} KB` + : `${(size / (1024 * 1024)).toFixed(1)} MB` +} + +function clampDisplayText(text: string): string { + if (text.length <= DISPLAY_LIMIT) return text + const headCut = text.lastIndexOf('\n', DISPLAY_HEAD_LENGTH) + const head = headCut === -1 ? text.slice(0, DISPLAY_HEAD_LENGTH) : text.slice(0, headCut) + const tailFrom = text.length - DISPLAY_TAIL_LENGTH + const tailCut = text.indexOf('\n', tailFrom) + const tail = tailCut === -1 ? text.slice(tailFrom) : text.slice(tailCut + 1) + const omitted = formatOmittedSize(text.length - head.length - tail.length) + const marker = `\n[… ${omitted} omitted — use the copy button for the full output …]\n` + return head + marker + tail +} + +function BoundedPre({ content, className }: { content: string; className: string }) { + const clamped = useMemo(() => clampDisplayText(content), [content]) + return
{clamped}
+} + interface ToolCallPartProps { part: ToolPart onFileClick?: (filePath: string, lineNumber?: number) => void @@ -29,8 +56,8 @@ function getTaskSessionId(part: ToolPart): string | undefined { } function ClickableJson({ json, onFileClick }: { json: unknown; onFileClick?: (filePath: string) => void }) { - const jsonString = JSON.stringify(json, null, 2) - const references = detectFileReferences(jsonString) + const jsonString = useMemo(() => JSON.stringify(json, null, 2), [json]) + const references = useMemo(() => detectFileReferences(jsonString), [jsonString]) if (references.length === 0) { return
{jsonString}
@@ -68,7 +95,7 @@ function ClickableJson({ json, onFileClick }: { json: unknown; onFileClick?: (fi return
{parts}
} -export function ToolCallPart({ part, onFileClick, onChildSessionClick }: ToolCallPartProps) { +export const ToolCallPart = memo(function ToolCallPart({ part, onFileClick, onChildSessionClick }: ToolCallPartProps) { const { preferences } = useSettings() const { userBashCommands } = useUserBash() const taskSessionId = part.tool === 'task' ? getTaskSessionId(part) : undefined @@ -79,7 +106,10 @@ export function ToolCallPart({ part, onFileClick, onChildSessionClick }: ToolCal const rawCommand = part.tool === 'bash' && typeof part.state.input?.command === 'string' ? part.state.input.command : undefined - const displayCommand = rawCommand === undefined ? undefined : unwrapSandboxExecCommand(rawCommand) + const displayCommand = useMemo( + () => (rawCommand === undefined ? undefined : unwrapSandboxExecCommand(rawCommand)), + [rawCommand] + ) const isSandboxedCommand = rawCommand !== undefined && ( displayCommand !== rawCommand || (part.state.status === 'completed' && (part.state.metadata as Record | undefined)?.sandbox === true) @@ -260,10 +290,8 @@ export function ToolCallPart({ part, onFileClick, onChildSessionClick }: ToolCal )}
-
-            {output}
-          
- + +
) @@ -401,9 +429,10 @@ export function ToolCallPart({ part, onFileClick, onChildSessionClick }: ToolCal
Output:
-
-                    {part.state.status === 'completed' ? part.state.output : ''}
-                  
+ {part.state.status === 'completed' && part.state.output && ( )} @@ -420,13 +449,14 @@ export function ToolCallPart({ part, onFileClick, onChildSessionClick }: ToolCal {part.state.status === 'error' && (
Error:
-
-                {part.state.error}
-              
+
)}
)}
) -} +}) diff --git a/frontend/src/contexts/EventContext.test.tsx b/frontend/src/contexts/EventContext.test.tsx index 1e0c0eba..9a9210df 100644 --- a/frontend/src/contexts/EventContext.test.tsx +++ b/frontend/src/contexts/EventContext.test.tsx @@ -528,4 +528,49 @@ describe('EventProvider questions', () => { expect(screen.getByTestId('healthy')).toHaveTextContent('false') }) }) + + it('does not re-render consumers for health notifications that only change lastEventAt', async () => { + let onHealthChange: (state: { isConnected: boolean; isHealthy: boolean; lastEventAt: number | null; isStalled: boolean }) => void = () => {} + mocks.subscribeGlobalMonitor.mockImplementation(({ onHealthChange: handler }) => { + onHealthChange = handler + return { + dispose: vi.fn(), + updateDirectories: vi.fn(), + reconnect: vi.fn(), + reportVisibility: vi.fn(), + } + }) + + let renderCount = 0 + + const Probe = () => { + renderCount += 1 + const { isConnected } = useSSEHealth() + return
{String(isConnected)}
+ } + + render(, { wrapper: createWrapper() }) + + await waitFor(() => { + expect(screen.getByTestId('probe-connected')).toHaveTextContent('false') + }) + const initialRenderCount = renderCount + + act(() => { + onHealthChange({ isConnected: true, isHealthy: true, lastEventAt: 1, isStalled: false }) + }) + + await waitFor(() => { + expect(screen.getByTestId('probe-connected')).toHaveTextContent('true') + }) + const afterBooleanChange = renderCount + + act(() => { + onHealthChange({ isConnected: true, isHealthy: true, lastEventAt: 2, isStalled: false }) + onHealthChange({ isConnected: true, isHealthy: true, lastEventAt: 3, isStalled: false }) + }) + + expect(afterBooleanChange).toBe(initialRenderCount + 1) + expect(renderCount).toBe(afterBooleanChange) + }) }) diff --git a/frontend/src/contexts/EventContext.tsx b/frontend/src/contexts/EventContext.tsx index b3a012b6..5a0575aa 100644 --- a/frontend/src/contexts/EventContext.tsx +++ b/frontend/src/contexts/EventContext.tsx @@ -187,6 +187,15 @@ export function EventProvider({ children }: { children: React.ReactNode }) { } }, []) + const handleHealthChange = useCallback((next: EventStreamHealthState) => { + setSseHealth((prev) => { + if (prev.isConnected === next.isConnected && prev.isHealthy === next.isHealthy && prev.isStalled === next.isStalled) { + return prev + } + return next + }) + }, []) + const [permissionsBySession, setPermissionsBySession] = useState({}) const [questionsBySession, setQuestionsBySession] = useState({}) const [showPermissionDialog, setShowPermissionDialog] = useState(true) @@ -552,7 +561,7 @@ export function EventProvider({ children }: { children: React.ReactNode }) { directories: initialDirectories, onEvent: handleSSEMessage, onStatusChange: handleStatusChange, - onHealthChange: setSseHealth, + onHealthChange: handleHealthChange, }) subscriptionRef.current = subscription @@ -560,7 +569,7 @@ export function EventProvider({ children }: { children: React.ReactNode }) { subscription.dispose() subscriptionRef.current = null } - }, [addPermission, removePermission, addQuestion, removeQuestion, rememberSessionDirectory, fetchInitialPendingData, queryClient, setSseHealth]) + }, [addPermission, removePermission, addQuestion, removeQuestion, rememberSessionDirectory, fetchInitialPendingData, queryClient, handleHealthChange]) useEffect(() => { reposRef.current = repos From bb90a03d0ff1976defcd75ccc36edc3a7202f714 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Thu, 27 Aug 2026 01:01:10 -0400 Subject: [PATCH 2/4] chore(deps): allow esbuild postinstall in pnpm onlyBuiltDependencies --- pnpm-workspace.yaml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index aae1f235..3a3dcf14 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,10 +1,14 @@ packages: - - 'shared' - - 'backend' - - 'frontend' - - 'ocm-cli' + - shared + - backend + - frontend + - ocm-cli - '!workspace/**' +onlyBuiltDependencies: + - better-sqlite3 + - esbuild + packageExtensions: '@hookform/resolvers': peerDependencies: From 8635b333e8b2d9c09fe179f58d56fda606490fe2 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:01:18 +0000 Subject: [PATCH 3/4] fix(sandbox): align availability reporting and address PR review feedback --- backend/src/routes/settings.ts | 7 ++-- backend/src/services/credential-provider.ts | 32 +++++++++---------- .../src/services/opencode/process-identity.ts | 6 ++++ backend/src/services/sandbox/runtime.ts | 7 ++-- .../test/services/credential-provider.test.ts | 10 ++++-- backend/test/services/sandbox/runtime.test.ts | 17 ++++++++++ .../test/services/sandbox/shell-shim.test.ts | 15 ++++++--- docs/features/sandboxing.md | 2 +- .../settings/SandboxSettings.test.tsx | 19 +++++++++++ .../components/settings/SandboxSettings.tsx | 5 +-- frontend/src/contexts/EventContext.tsx | 12 ++++--- 11 files changed, 97 insertions(+), 35 deletions(-) diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index 8205f68d..bae0fc1d 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -33,7 +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 { resolveProcessIdentityProvider } from '../services/opencode/process-identity' +import { getProcessIdentityAttestationError } from '../services/opencode/process-identity' import { restartOpenCode, restartOpenCodeAfterCommit, reloadOpenCodeConfig, getOpenCodeRestartCoordinator } from '../services/opencode-restart' import type { GitAuthService } from '../services/git-auth' import { DEFAULT_AGENTS_MD } from '../constants' @@ -402,8 +402,9 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic if (capability.available === false) { return c.json({ error: `Cannot enable sandboxing: ${capability.reason}` }, 400) } - if (resolveProcessIdentityProvider().attested === false) { - return c.json({ error: 'Cannot enable sandboxing: process identity attestation is unavailable on this platform (Linux /proc is required)' }, 400) + const attestationError = getProcessIdentityAttestationError() + if (attestationError !== null) { + return c.json({ error: `Cannot enable sandboxing: ${attestationError}` }, 400) } } diff --git a/backend/src/services/credential-provider.ts b/backend/src/services/credential-provider.ts index 5e1817cf..611b34b2 100644 --- a/backend/src/services/credential-provider.ts +++ b/backend/src/services/credential-provider.ts @@ -1,11 +1,11 @@ import type { Database } from 'bun:sqlite' -import type { GitCredential, Repo } from '@opencode-manager/shared' -import type { UserPreferences } from '../types/settings' +import type { GitCredential, Repo, UserPreferences } from '@opencode-manager/shared' import { SettingsService } from './settings' import { findPatCredentialForHost, getSSHCredentialsForHost, createGitEnv, + createGhCliEnv, findGitHubCredential, type ResolvedGitCredential, } from '../utils/git-auth' @@ -67,12 +67,16 @@ export class CredentialProvider { } isSandboxGitCredentialsAllowed(options: CredentialResolutionOptions = {}): boolean { - return this.getSandboxGitCredentialsAllowed(this.resolveContext(options)) + return this.getSandboxGitCredentialsAllowed(options) } getSandboxGitEnv(options: CredentialResolutionOptions = {}): Record { - const context = this.resolveContext(options) - if (!this.getSandboxGitCredentialsAllowed(context)) return {} + const repo = this.resolveRepo(options) + const repoOverride = repo ? getRepoSandboxGitCredentials(this.database, repo.id) : null + if (repoOverride === false) return {} + + const context = this.resolveContext(options, repo) + if (repoOverride !== true && context.preferences.sandbox?.gitCredentials !== true) return {} const gitEnv = this.getGitEnvForContext(context) if (gitEnv.GIT_CONFIG_COUNT === '0') return {} @@ -91,12 +95,12 @@ export class CredentialProvider { return this.getGhCliEnvForContext(this.resolveContext(options)) } - private resolveContext(options: CredentialResolutionOptions): CredentialResolutionContext { + private resolveContext(options: CredentialResolutionOptions, repo = this.resolveRepo(options)): CredentialResolutionContext { const preferences = this.getPreferences() return { preferences, credentials: this.getCredentials(preferences), - repo: this.resolveRepo(options), + repo, } } @@ -114,17 +118,13 @@ export class CredentialProvider { private getGhCliEnvForContext(context: CredentialResolutionContext): Record { const credential = this.getGhCliCredential(context) - if (!credential?.token) return {} - return { GH_TOKEN: credential.token, GITHUB_TOKEN: credential.token } + return createGhCliEnv(credential ? [credential] : []) } - private getSandboxGitCredentialsAllowed(context: CredentialResolutionContext): boolean { - if (context.repo) { - const repoOverride = getRepoSandboxGitCredentials(this.database, context.repo.id) - if (repoOverride !== null) return repoOverride - } - - return context.preferences.sandbox?.gitCredentials === true + private getSandboxGitCredentialsAllowed(options: CredentialResolutionOptions): boolean { + const repo = this.resolveRepo(options) + const repoOverride = repo ? getRepoSandboxGitCredentials(this.database, repo.id) : null + return repoOverride ?? (this.getPreferences().sandbox?.gitCredentials === true) } private resolveRepo(options: CredentialResolutionOptions): Repo | null { diff --git a/backend/src/services/opencode/process-identity.ts b/backend/src/services/opencode/process-identity.ts index d68785d2..3e357834 100644 --- a/backend/src/services/opencode/process-identity.ts +++ b/backend/src/services/opencode/process-identity.ts @@ -77,6 +77,12 @@ export function resolveProcessIdentityProvider(): ProcessIdentityProvider { return cachedProvider } +export function getProcessIdentityAttestationError(): string | null { + return resolveProcessIdentityProvider().attested + ? null + : 'process identity attestation is unavailable on this platform (Linux /proc is required)' +} + export function resetProcessIdentityProvider(): void { cachedProvider = null } diff --git a/backend/src/services/sandbox/runtime.ts b/backend/src/services/sandbox/runtime.ts index 2e43f8f9..fd9368fb 100644 --- a/backend/src/services/sandbox/runtime.ts +++ b/backend/src/services/sandbox/runtime.ts @@ -7,6 +7,7 @@ import { mkdirSafe } from '../../utils/fs-safe' import { logger } from '../../utils/logger' import { SettingsService } from '../settings' import { CredentialProvider } from '../credential-provider' +import { getProcessIdentityAttestationError } from '../opencode/process-identity' import { detectSandboxCapability } from './capability' import { WORKSPACE_SANDBOX_NAME, @@ -624,10 +625,12 @@ export class SandboxRuntimeService { getStatus(): SandboxStatus { const capability = detectSandboxCapability() + const attestationError = capability.available ? getProcessIdentityAttestationError() : null + const reason = capability.reason ?? attestationError return { - available: capability.available, + available: capability.available && attestationError === null, enabled: this.isEnabled(), - ...(capability.reason !== undefined ? { reason: capability.reason } : {}), + ...(reason !== null && reason !== undefined ? { reason } : {}), ...(capability.msbVersion !== undefined ? { msbVersion: capability.msbVersion } : {}), } } diff --git a/backend/test/services/credential-provider.test.ts b/backend/test/services/credential-provider.test.ts index 379bab97..14096f95 100644 --- a/backend/test/services/credential-provider.test.ts +++ b/backend/test/services/credential-provider.test.ts @@ -275,8 +275,14 @@ describe('CredentialProvider', () => { const repo = createGithubRepo() setRepoSandboxGitCredentials(db, repo.id, false) - expect(provider.isSandboxGitCredentialsAllowed({ cwd: repo.fullPath })).toBe(false) - expect(provider.getSandboxGitEnv({ cwd: repo.fullPath })).toEqual({}) + const getSettingsSpy = vi.spyOn(SettingsService.prototype, 'getSettings') + try { + expect(provider.isSandboxGitCredentialsAllowed({ cwd: repo.fullPath })).toBe(false) + expect(provider.getSandboxGitEnv({ cwd: repo.fullPath })).toEqual({}) + expect(getSettingsSpy).not.toHaveBeenCalled() + } finally { + getSettingsSpy.mockRestore() + } }) it('lets a per-repo override grant credentials while the global toggle is off', () => { diff --git a/backend/test/services/sandbox/runtime.test.ts b/backend/test/services/sandbox/runtime.test.ts index 105cf8a7..22cdeca3 100644 --- a/backend/test/services/sandbox/runtime.test.ts +++ b/backend/test/services/sandbox/runtime.test.ts @@ -12,6 +12,7 @@ import { SandboxRuntimeService, resetSandboxRuntimeState, stopWorkspaceSandboxOn import { executeCommand } from '../../../src/utils/process' import { detectSandboxCapability } from '../../../src/services/sandbox/capability' import { logger } from '../../../src/utils/logger' +import { forceProcessAttestation } from '../../../src/services/opencode/process-identity' vi.mock('../../../src/utils/process', () => ({ executeCommand: vi.fn(), @@ -1940,6 +1941,22 @@ describe('SandboxRuntimeService', () => { expect(service.getStatus()).toEqual({ available: false, enabled: true, reason: '/dev/kvm is not available' }) }) + it('reports sandboxing unavailable when process identity cannot be attested', () => { + mockDetectSandboxCapability.mockReturnValue({ available: true, msbVersion: 'msb 0.6.15' }) + forceProcessAttestation(false) + + try { + expect(service.getStatus()).toEqual({ + available: false, + enabled: false, + reason: 'process identity attestation is unavailable on this platform (Linux /proc is required)', + msbVersion: 'msb 0.6.15', + }) + } finally { + forceProcessAttestation(null) + } + }) + it('fails closed when the capability becomes unavailable after the toggle was enabled', async () => { settingsService.updateSettings({ sandbox: { enabled: true } }) mockDetectSandboxCapability.mockReturnValue({ available: true, msbVersion: 'msb 0.6.15' }) diff --git a/backend/test/services/sandbox/shell-shim.test.ts b/backend/test/services/sandbox/shell-shim.test.ts index bdd4cb13..46c86264 100644 --- a/backend/test/services/sandbox/shell-shim.test.ts +++ b/backend/test/services/sandbox/shell-shim.test.ts @@ -33,6 +33,12 @@ function writeArgvCapturingFakeMsb(msbPath: string, captureFile: string): void { ) } +function sandboxShellEnv(overrides: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const env = { ...process.env } + for (const name of SANDBOX_FORWARDED_ENV_NAMES) delete env[name] + return { ...env, ...overrides } +} + describe('sandbox shell shim', () => { it('execs msb through the shim with the working directory and a byte-for-byte guest payload', async () => { const fakeBin = mkdtempSync(path.join(tmpdir(), 'ocm-shim-msb-')) @@ -54,7 +60,7 @@ describe('sandbox shell shim', () => { const command = 'echo "it\'s a test" && echo line2 | tr a-z A-Z\necho after-newline' const result = spawnSync(shimPath, ['-c', command], { encoding: 'utf8', - env: { ...process.env, [SANDBOX_SHELL_ENV_WORKDIR]: directory }, + env: sandboxShellEnv({ [SANDBOX_SHELL_ENV_WORKDIR]: directory }), }) expect(result.status).toBe(0) expect(result.stdout).toBe("it's a test\nLINE2\nafter-newline\n") @@ -107,14 +113,13 @@ describe('sandbox shell shim', () => { const extraheaderValue = 'AUTHORIZATION: basic eDphYmMgZGVm' const result = spawnSync(shimPath, ['-c', 'echo -e sentinel'], { encoding: 'utf8', - env: { - ...process.env, + env: sandboxShellEnv({ [SANDBOX_SHELL_ENV_WORKDIR]: directory, GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'http.https://github.com/.extraheader', GIT_CONFIG_VALUE_0: extraheaderValue, OCM_INTERNAL_TOKEN: 'must-not-be-forwarded', - }, + }), }) expect(result.status).toBe(0) @@ -168,7 +173,7 @@ describe('sandbox shell shim', () => { const command = 'echo hostile-ok' const result = spawnSync(shimPath, ['-c', command], { encoding: 'utf8', - env: { ...process.env, [SANDBOX_SHELL_ENV_WORKDIR]: directory }, + env: sandboxShellEnv({ [SANDBOX_SHELL_ENV_WORKDIR]: directory }), }) expect(result.status).toBe(0) expect(result.stdout).toBe('hostile-ok\n') diff --git a/docs/features/sandboxing.md b/docs/features/sandboxing.md index 7821183d..e63782c3 100644 --- a/docs/features/sandboxing.md +++ b/docs/features/sandboxing.md @@ -55,7 +55,7 @@ The OpenCode server binds to the configured `OPENCODE_HOST` regardless of enforc ## Host Requirements -Sandboxing requires KVM on a Linux host. Start the Manager with the sandbox overlay: +Sandboxing requires KVM on a Linux host and access to Linux `/proc` for process identity attestation. Start the Manager with the sandbox overlay: ```bash docker compose -f docker-compose.yml -f docker-compose.sandbox.yml up -d diff --git a/frontend/src/components/settings/SandboxSettings.test.tsx b/frontend/src/components/settings/SandboxSettings.test.tsx index 4c93b12b..631904c3 100644 --- a/frontend/src/components/settings/SandboxSettings.test.tsx +++ b/frontend/src/components/settings/SandboxSettings.test.tsx @@ -5,6 +5,7 @@ import { SandboxSettings } from './SandboxSettings' import { useSettings } from '@/hooks/useSettings' import { useServerHealth } from '@/hooks/useServerHealth' import { showToast } from '@/lib/toast' +import { FetchError } from '@/api/fetchWrapper' vi.mock('@/hooks/useSettings') vi.mock('@/hooks/useServerHealth') @@ -132,6 +133,24 @@ describe('SandboxSettings', () => { expect(vi.mocked(showToast.error)).toHaveBeenCalledWith('Failed to update sandbox preference') }) + it('shows the backend error when enabling sandboxing is rejected', async () => { + const user = userEvent.setup() + mockUseSettings({ + updateSettingsAsync: vi.fn().mockRejectedValue( + new FetchError('Cannot enable sandboxing: process identity attestation is unavailable', 400), + ), + }) + mockHealth({ available: true, enforced: false }) + + render() + + await user.click(screen.getByRole('switch', { name: 'Toggle sandbox' })) + + expect(vi.mocked(showToast.error)).toHaveBeenCalledWith( + 'Cannot enable sandboxing: process identity attestation is unavailable', + ) + }) + it('preserves the git credential preference when the sandbox toggle changes', async () => { const user = userEvent.setup() const { updateSettingsAsync } = mockUseSettings({ diff --git a/frontend/src/components/settings/SandboxSettings.tsx b/frontend/src/components/settings/SandboxSettings.tsx index f6c5c7d5..a1ff6d91 100644 --- a/frontend/src/components/settings/SandboxSettings.tsx +++ b/frontend/src/components/settings/SandboxSettings.tsx @@ -5,6 +5,7 @@ import { Alert, AlertDescription } from '@/components/ui/alert' import { Badge } from '@/components/ui/badge' import { Box, RotateCcw } from 'lucide-react' import { showToast } from '@/lib/toast' +import { FetchError } from '@/api/fetchWrapper' export function SandboxSettings() { const { preferences, updateSettingsAsync, isUpdating } = useSettings() @@ -19,8 +20,8 @@ export function SandboxSettings() { try { await updateSettingsAsync({ sandbox: next }) showToast.success(message) - } catch { - showToast.error('Failed to update sandbox preference') + } catch (error) { + showToast.error(error instanceof FetchError ? error.message : 'Failed to update sandbox preference') } } diff --git a/frontend/src/contexts/EventContext.tsx b/frontend/src/contexts/EventContext.tsx index 5a0575aa..c250fcfa 100644 --- a/frontend/src/contexts/EventContext.tsx +++ b/frontend/src/contexts/EventContext.tsx @@ -13,6 +13,7 @@ import { invalidateRepoGitCachesDebounced } from '@/lib/queryInvalidation' type PermissionsBySession = Record type QuestionsBySession = Record +type SSEHealthState = Pick type SessionScopedItem = { id: string; sessionID: string } @@ -163,7 +164,7 @@ interface EventContextValue { navigateToCurrent: () => void syncForSession: (directory: string, sessionID: string) => Promise } - sseHealth: EventStreamHealthState + sseHealth: SSEHealthState getRepoIdForSession: (sessionID: string) => number | null getClient: (sessionID: string) => OpenCodeClient | null } @@ -175,7 +176,10 @@ export function EventProvider({ children }: { children: React.ReactNode }) { const navigate = useNavigate() const [sshHostKeyRequest, setSSHHostKeyRequest] = useState(null) - const [sseHealth, setSseHealth] = useState(() => openCodeEventStream.getHealth()) + const [sseHealth, setSseHealth] = useState(() => { + const { isConnected, isHealthy, isStalled } = openCodeEventStream.getHealth() + return { isConnected, isHealthy, isStalled } + }) const respondToSSHHostKey = useCallback(async (requestId: string, approved: boolean) => { try { @@ -192,7 +196,7 @@ export function EventProvider({ children }: { children: React.ReactNode }) { if (prev.isConnected === next.isConnected && prev.isHealthy === next.isHealthy && prev.isStalled === next.isStalled) { return prev } - return next + return { isConnected: next.isConnected, isHealthy: next.isHealthy, isStalled: next.isStalled } }) }, []) @@ -667,6 +671,6 @@ export function useQuestions() { return questions } -export function useSSEHealth(): EventStreamHealthState { +export function useSSEHealth(): SSEHealthState { return useEventContext().sseHealth } From 2e79be54fee3b32ae7a13b402d892bf8a4737ea9 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:55:16 +0000 Subject: [PATCH 4/4] fix(sandbox): block planning without process attestation --- backend/src/services/sandbox/runtime.ts | 4 ++++ backend/test/services/sandbox/runtime.test.ts | 17 +++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/backend/src/services/sandbox/runtime.ts b/backend/src/services/sandbox/runtime.ts index fd9368fb..75d0a244 100644 --- a/backend/src/services/sandbox/runtime.ts +++ b/backend/src/services/sandbox/runtime.ts @@ -643,6 +643,10 @@ export class SandboxRuntimeService { if (!capability.available) { return { mode: 'blocked', reason: capability.reason ?? 'Sandbox capability is unavailable' } } + const attestationError = getProcessIdentityAttestationError() + if (attestationError !== null) { + return { mode: 'blocked', reason: attestationError } + } const workDirectory = await resolveSandboxWorkDirectory(directory) if (workDirectory === null) { return { diff --git a/backend/test/services/sandbox/runtime.test.ts b/backend/test/services/sandbox/runtime.test.ts index 22cdeca3..58ae5dc1 100644 --- a/backend/test/services/sandbox/runtime.test.ts +++ b/backend/test/services/sandbox/runtime.test.ts @@ -1904,6 +1904,23 @@ describe('SandboxRuntimeService', () => { expect(mockExecuteCommand).not.toHaveBeenCalled() }) + it('blocks an enforced request when process identity cannot be attested', async () => { + mockDetectSandboxCapability.mockReturnValue({ available: true, msbVersion: 'msb 0.6.15' }) + forceProcessAttestation(false) + + try { + const plan = await service.planShell(repoADir, true) + + expect(plan).toEqual({ + mode: 'blocked', + reason: 'process identity attestation is unavailable on this platform (Linux /proc is required)', + }) + expect(mockExecuteCommand).not.toHaveBeenCalled() + } finally { + forceProcessAttestation(null) + } + }) + it('uses a directory created after boot without recreating the sandbox', async () => { enableEnforcement() mockExecuteCommand.mockImplementation(async (args: string[]) => {