Skip to content

Commit d63198e

Browse files
fix(sandbox): guard enable path and harden UI rendering performance (#341)
* fix(sandbox): guard enable path and harden UI rendering performance * chore(deps): allow esbuild postinstall in pnpm onlyBuiltDependencies * fix(sandbox): align availability reporting and address PR review feedback * fix(sandbox): block planning without process attestation
1 parent ba8e8f4 commit d63198e

19 files changed

Lines changed: 501 additions & 82 deletions

File tree

‎.github/workflows/docker-build.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ jobs:
2121
run: |
2222
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\///')
2323
OPENCODE_VERSION=1.18.16
24-
MICROSANDBOX_VERSION=0.6.8
24+
MICROSANDBOX_VERSION=0.6.15
2525
echo "uv=${UV_VERSION}" >> $GITHUB_OUTPUT
2626
echo "opencode=${OPENCODE_VERSION}" >> $GITHUB_OUTPUT
2727
echo "microsandbox=${MICROSANDBOX_VERSION}" >> $GITHUB_OUTPUT

‎Dockerfile‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ FROM base AS runner
6060

6161
ARG UV_VERSION=latest
6262
ARG OPENCODE_VERSION=1.18.16
63-
ARG MICROSANDBOX_VERSION=0.6.8
63+
ARG MICROSANDBOX_VERSION=0.6.15
6464
# Bump TOOLS_CACHEBUST (e.g. via --build-arg) to force a fresh uv/opencode
6565
# install without invalidating the rest of the build cache.
6666
ARG TOOLS_CACHEBUST=0

‎backend/src/routes/settings.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ import { opencodeServerManager, ConfigReloadError, resolveOpenCodeExecutable } f
3232
import { getOrCreateInternalToken, rotateInternalToken } from '../services/internal-token'
3333
import { sseAggregator } from '../services/sse-aggregator'
3434
import type { OpenCodeSupervisor } from '../services/opencode-supervisor'
35+
import { detectSandboxCapability } from '../services/sandbox/capability'
36+
import { getProcessIdentityAttestationError } from '../services/opencode/process-identity'
3537
import { restartOpenCode, restartOpenCodeAfterCommit, reloadOpenCodeConfig, getOpenCodeRestartCoordinator } from '../services/opencode-restart'
3638
import type { GitAuthService } from '../services/git-auth'
3739
import { DEFAULT_AGENTS_MD } from '../constants'
@@ -394,6 +396,18 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic
394396
}
395397

396398
const currentSettings = settingsService.getSettings(userId)
399+
400+
if (currentSettings.preferences.sandbox?.enabled !== true && validated.preferences.sandbox?.enabled === true) {
401+
const capability = detectSandboxCapability()
402+
if (capability.available === false) {
403+
return c.json({ error: `Cannot enable sandboxing: ${capability.reason}` }, 400)
404+
}
405+
const attestationError = getProcessIdentityAttestationError()
406+
if (attestationError !== null) {
407+
return c.json({ error: `Cannot enable sandboxing: ${attestationError}` }, 400)
408+
}
409+
}
410+
397411
const settings = settingsService.updateSettings(validated.preferences, userId)
398412

399413
const sandboxChanged = sandboxEnforcementChanged(currentSettings.preferences.sandbox, validated.preferences.sandbox)

‎backend/src/services/credential-provider.ts‎

Lines changed: 68 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import type { Database } from 'bun:sqlite'
2-
import type { GitCredential, Repo } from '@opencode-manager/shared'
2+
import type { GitCredential, Repo, UserPreferences } from '@opencode-manager/shared'
33
import { SettingsService } from './settings'
44
import {
55
findPatCredentialForHost,
66
getSSHCredentialsForHost,
77
createGitEnv,
8+
createGhCliEnv,
89
findGitHubCredential,
910
type ResolvedGitCredential,
1011
} from '../utils/git-auth'
@@ -22,6 +23,12 @@ interface CredentialResolutionOptions {
2223
repoId?: number
2324
}
2425

26+
interface CredentialResolutionContext {
27+
preferences: UserPreferences
28+
credentials: GitCredential[]
29+
repo: Repo | null
30+
}
31+
2532
export class CredentialProvider {
2633
private settingsService: SettingsService
2734
private database: Database
@@ -32,8 +39,7 @@ export class CredentialProvider {
3239
}
3340

3441
getGitCredentials(): GitCredential[] {
35-
const settings = this.settingsService.getSettings('default')
36-
return (settings.preferences.gitCredentials || []) as GitCredential[]
42+
return this.getCredentials(this.getPreferences())
3743
}
3844

3945
getGitCredentialById(credentialId: string | undefined): GitCredential | null {
@@ -42,10 +48,10 @@ export class CredentialProvider {
4248
}
4349

4450
getPatCredentialForHost(hostname: string, options: CredentialResolutionOptions = {}): ResolvedGitCredential | null {
45-
const credentials = this.getGitCredentials()
46-
const selectedCredential = this.getSelectedCredential(options, credentials)
51+
const context = this.resolveContext(options)
52+
const selectedCredential = this.getSelectedCredential(context)
4753
const selectedMatch = selectedCredential ? findPatCredentialForHost([selectedCredential], hostname) : null
48-
return selectedMatch ?? findPatCredentialForHost(credentials, hostname)
54+
return selectedMatch ?? findPatCredentialForHost(context.credentials, hostname)
4955
}
5056

5157
getSshCredentialsForHost(host: string): GitCredential[] {
@@ -57,24 +63,22 @@ export class CredentialProvider {
5763
}
5864

5965
getGitEnv(options: CredentialResolutionOptions = {}): Record<string, string> {
60-
const credentials = this.getGitCredentials()
61-
return createGitEnv(credentials, this.getSelectedCredential(options, credentials))
66+
return this.getGitEnvForContext(this.resolveContext(options))
6267
}
6368

6469
isSandboxGitCredentialsAllowed(options: CredentialResolutionOptions = {}): boolean {
65-
const repo = this.resolveRepo(options)
66-
if (repo) {
67-
const repoOverride = getRepoSandboxGitCredentials(this.database, repo.id)
68-
if (repoOverride !== null) return repoOverride
69-
}
70-
71-
return this.settingsService.getSettings('default').preferences.sandbox?.gitCredentials === true
70+
return this.getSandboxGitCredentialsAllowed(options)
7271
}
7372

7473
getSandboxGitEnv(options: CredentialResolutionOptions = {}): Record<string, string> {
75-
if (!this.isSandboxGitCredentialsAllowed(options)) return {}
74+
const repo = this.resolveRepo(options)
75+
const repoOverride = repo ? getRepoSandboxGitCredentials(this.database, repo.id) : null
76+
if (repoOverride === false) return {}
77+
78+
const context = this.resolveContext(options, repo)
79+
if (repoOverride !== true && context.preferences.sandbox?.gitCredentials !== true) return {}
7680

77-
const gitEnv = this.getGitEnv(options)
81+
const gitEnv = this.getGitEnvForContext(context)
7882
if (gitEnv.GIT_CONFIG_COUNT === '0') return {}
7983

8084
const { env, dropped } = limitForwardedGitConfigs(gitEnv)
@@ -84,7 +88,43 @@ export class CredentialProvider {
8488
)
8589
}
8690

87-
return { ...env, ...this.getGhCliEnv(options) }
91+
return { ...env, ...this.getGhCliEnvForContext(context) }
92+
}
93+
94+
getGhCliEnv(options: CredentialResolutionOptions = {}): Record<string, string> {
95+
return this.getGhCliEnvForContext(this.resolveContext(options))
96+
}
97+
98+
private resolveContext(options: CredentialResolutionOptions, repo = this.resolveRepo(options)): CredentialResolutionContext {
99+
const preferences = this.getPreferences()
100+
return {
101+
preferences,
102+
credentials: this.getCredentials(preferences),
103+
repo,
104+
}
105+
}
106+
107+
private getPreferences(): UserPreferences {
108+
return this.settingsService.getSettings('default').preferences
109+
}
110+
111+
private getCredentials(preferences: UserPreferences): GitCredential[] {
112+
return (preferences.gitCredentials || []) as GitCredential[]
113+
}
114+
115+
private getGitEnvForContext(context: CredentialResolutionContext): Record<string, string> {
116+
return createGitEnv(context.credentials, this.getSelectedCredential(context))
117+
}
118+
119+
private getGhCliEnvForContext(context: CredentialResolutionContext): Record<string, string> {
120+
const credential = this.getGhCliCredential(context)
121+
return createGhCliEnv(credential ? [credential] : [])
122+
}
123+
124+
private getSandboxGitCredentialsAllowed(options: CredentialResolutionOptions): boolean {
125+
const repo = this.resolveRepo(options)
126+
const repoOverride = repo ? getRepoSandboxGitCredentials(this.database, repo.id) : null
127+
return repoOverride ?? (this.getPreferences().sandbox?.gitCredentials === true)
88128
}
89129

90130
private resolveRepo(options: CredentialResolutionOptions): Repo | null {
@@ -94,34 +134,25 @@ export class CredentialProvider {
94134
return options.cwd ? getRepoByDirectory(this.database, options.cwd) : null
95135
}
96136

97-
getGhCliEnv(options: CredentialResolutionOptions = {}): Record<string, string> {
98-
const credential = this.getGhCliCredential(options)
99-
if (!credential?.token) return {}
100-
return { GH_TOKEN: credential.token, GITHUB_TOKEN: credential.token }
101-
}
102-
103-
private getGhCliCredential(options: CredentialResolutionOptions): GitCredential | null {
104-
const credentials = this.getGitCredentials()
105-
const selectedCredential = this.getSelectedCredential(options, credentials)
137+
private getGhCliCredential(context: CredentialResolutionContext): GitCredential | null {
138+
const selectedCredential = this.getSelectedCredential(context)
106139
if (this.isGithubPatCredential(selectedCredential)) return selectedCredential
107140

108-
return findGitHubCredential(credentials)
141+
return findGitHubCredential(context.credentials)
109142
}
110143

111-
private getSelectedCredential(options: CredentialResolutionOptions, credentials: GitCredential[]): GitCredential | null {
112-
const repoCredential = this.getRepoCredential(options, credentials)
144+
private getSelectedCredential(context: CredentialResolutionContext): GitCredential | null {
145+
const repoCredential = this.getRepoCredential(context)
113146
if (repoCredential) return repoCredential
114147

115-
const settings = this.settingsService.getSettings('default')
116-
return credentials.find((credential) => credential.id === settings.preferences.defaultGitCredentialId) ?? null
148+
return context.credentials.find((credential) => credential.id === context.preferences.defaultGitCredentialId) ?? null
117149
}
118150

119-
private getRepoCredential(options: CredentialResolutionOptions, credentials: GitCredential[]): GitCredential | null {
120-
const repo = this.resolveRepo(options)
121-
if (!repo) return null
151+
private getRepoCredential(context: CredentialResolutionContext): GitCredential | null {
152+
if (!context.repo) return null
122153

123-
const credentialId = getRepoGitCredentialId(this.database, repo.id)
124-
return credentials.find((credential) => credential.id === credentialId) ?? null
154+
const credentialId = getRepoGitCredentialId(this.database, context.repo.id)
155+
return context.credentials.find((credential) => credential.id === credentialId) ?? null
125156
}
126157

127158
private isGithubPatCredential(credential: GitCredential | null): credential is GitCredential {

‎backend/src/services/opencode/process-identity.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,12 @@ export function resolveProcessIdentityProvider(): ProcessIdentityProvider {
7777
return cachedProvider
7878
}
7979

80+
export function getProcessIdentityAttestationError(): string | null {
81+
return resolveProcessIdentityProvider().attested
82+
? null
83+
: 'process identity attestation is unavailable on this platform (Linux /proc is required)'
84+
}
85+
8086
export function resetProcessIdentityProvider(): void {
8187
cachedProvider = null
8288
}

‎backend/src/services/sandbox/runtime.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { mkdirSafe } from '../../utils/fs-safe'
77
import { logger } from '../../utils/logger'
88
import { SettingsService } from '../settings'
99
import { CredentialProvider } from '../credential-provider'
10+
import { getProcessIdentityAttestationError } from '../opencode/process-identity'
1011
import { detectSandboxCapability } from './capability'
1112
import {
1213
WORKSPACE_SANDBOX_NAME,
@@ -624,10 +625,12 @@ export class SandboxRuntimeService {
624625

625626
getStatus(): SandboxStatus {
626627
const capability = detectSandboxCapability()
628+
const attestationError = capability.available ? getProcessIdentityAttestationError() : null
629+
const reason = capability.reason ?? attestationError
627630
return {
628-
available: capability.available,
631+
available: capability.available && attestationError === null,
629632
enabled: this.isEnabled(),
630-
...(capability.reason !== undefined ? { reason: capability.reason } : {}),
633+
...(reason !== null && reason !== undefined ? { reason } : {}),
631634
...(capability.msbVersion !== undefined ? { msbVersion: capability.msbVersion } : {}),
632635
}
633636
}
@@ -640,6 +643,10 @@ export class SandboxRuntimeService {
640643
if (!capability.available) {
641644
return { mode: 'blocked', reason: capability.reason ?? 'Sandbox capability is unavailable' }
642645
}
646+
const attestationError = getProcessIdentityAttestationError()
647+
if (attestationError !== null) {
648+
return { mode: 'blocked', reason: attestationError }
649+
}
643650
const workDirectory = await resolveSandboxWorkDirectory(directory)
644651
if (workDirectory === null) {
645652
return {

0 commit comments

Comments
 (0)