Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ import { installAssistantWorkspace } from './services/assistant-mode'
import { detectSandboxCapability } from './services/sandbox/capability'
import { SandboxRuntimeService, stopWorkspaceSandboxOnShutdown } from './services/sandbox/runtime'
import { getOpenCodeImportStatus, syncOpenCodeImport } from './services/opencode-import'
import { readOpenCodeConfigFile, writeOpenCodeConfigFile, OPENCODE_CONFIG_SEED } from './services/opencode-config-file'
import { readOpenCodeConfigFile } from './services/opencode-config-file'
import { seedOpenCodeConfigFile } from './services/opencode-config-apply'
import { OpenCodeSupervisor } from './services/opencode-supervisor'
import { OpenCodeRestartCoordinator } from './services/opencode-restart-coordinator'
import { setOpenCodeRestartCoordinator } from './services/opencode-restart'
Expand Down Expand Up @@ -133,7 +134,7 @@ async function ensureOpenCodeConfigFileExists(): Promise<void> {
}
}

await writeOpenCodeConfigFile(OPENCODE_CONFIG_SEED)
await seedOpenCodeConfigFile()
logger.info('Created minimal seed config')
}

Expand Down
47 changes: 43 additions & 4 deletions backend/src/routes/opencode-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,14 @@ import { Hono } from 'hono'
import { z } from 'zod'
import { UpdateOpenCodeConfigRequestSchema } from '@opencode-manager/shared/schemas'
import type { SettingsService } from '../services/settings'
import type { OpenCodeClient } from '../services/opencode/client'
import { readOpenCodeConfigFile } from '../services/opencode-config-file'
import { UpstreamError, type OpenCodeClient } from '../services/opencode/client'
import {
OpenCodeConfigConflictError,
OpenCodeConfigShadowedRemovalError,
OpenCodeConfigSourceInvalidError,
readOpenCodeConfigFile,
withOpenCodeConfigLock,
} from '../services/opencode-config-file'
import { applyOpenCodeConfigUpdate, toOpenCodeConfigApplyResponse } from '../services/opencode-config-apply'
import { logger } from '../utils/logger'

Expand All @@ -12,7 +18,7 @@ export function createOpenCodeConfigRoutes(settingsService: SettingsService, ope

app.get('/', async (c) => {
try {
const config = await readOpenCodeConfigFile()
const config = await withOpenCodeConfigLock(readOpenCodeConfigFile)
if (!config) {
return c.json({ error: 'No OpenCode config file found' }, 404)
}
Expand All @@ -23,6 +29,22 @@ export function createOpenCodeConfigRoutes(settingsService: SettingsService, ope
}
})

app.get('/effective', async (c) => {
try {
const config = await openCodeClient.getJson<Record<string, unknown>>('/global/config')
return c.json(config)
} catch (error) {
logger.error('Failed to get effective OpenCode config:', error)
if (error instanceof UpstreamError) {
if (error.status === 502) {
return c.json({ error: 'OpenCode server unavailable' }, 503)
}
return c.json({ error: 'Failed to get effective OpenCode config' }, 502)
}
return c.json({ error: 'Failed to get effective OpenCode config' }, 500)
}
})

app.put('/', async (c) => {
let body: unknown
try {
Expand All @@ -39,16 +61,33 @@ export function createOpenCodeConfigRoutes(settingsService: SettingsService, ope
try {
const result = await applyOpenCodeConfigUpdate({
content: parsed.data.content,
openCodeClient,
source: parsed.data.source,
expectedRevision: parsed.data.expectedRevision,
settingsService,
})
const { status, body: responseBody } = toOpenCodeConfigApplyResponse(result)
return c.json(responseBody, status)
} catch (error) {
logger.error('Failed to update OpenCode config:', error)
if (error instanceof OpenCodeConfigConflictError) {
return c.json({
error: error.message,
expectedRevision: error.expectedRevision,
actualRevision: error.actualRevision,
}, 409)
}
if (error instanceof OpenCodeConfigSourceInvalidError) {
return c.json({ error: error.message, sources: error.sources }, 400)
}
if (error instanceof OpenCodeConfigShadowedRemovalError) {
return c.json({ error: error.message, paths: error.paths, sources: error.sources }, 409)
}
if (error instanceof z.ZodError) {
return c.json({ error: 'Invalid config data', details: error.issues }, 400)
}
if (error instanceof SyntaxError) {
return c.json({ error: 'Invalid config data', details: error.message }, 400)
}
return c.json({ error: 'Failed to update OpenCode config' }, 500)
}
})
Expand Down
12 changes: 8 additions & 4 deletions backend/src/routes/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { resolve, dirname } from 'path'
import type { Database } from 'bun:sqlite'
import { SettingsService } from '../services/settings'
import { writeFileContent, readFileContent, fileExists } from '../services/file-operations'
import { deleteOpenCodeConfigFile } from '../services/opencode-config-file'
import { archiveBrokenOpenCodeConfigFile, deleteOpenCodeConfigFile } from '../services/opencode-config-file'
import { restoreLastKnownGoodOpenCodeConfig } from '../services/opencode-config-apply'
import { createOpenCodeConfigRoutes } from './opencode-config'
import type { OpenCodeClient } from '../services/opencode/client'
Expand Down Expand Up @@ -517,8 +517,12 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic
app.post('/opencode-reload', async (c) => {
try {
logger.info('OpenCode configuration reload requested')
await reloadOpenCodeConfig(openCodeSupervisor)
return c.json({ success: true, message: 'OpenCode configuration reloaded successfully' })
const { resumedSessionIDs } = await reloadOpenCodeConfig(openCodeSupervisor)
return c.json({
success: true,
message: 'OpenCode server restarted with the current configuration',
resumedSessions: resumedSessionIDs,
})
} catch (error) {
logger.error('Failed to reload OpenCode config:', error)
if (error instanceof ConfigReloadError) {
Expand All @@ -529,7 +533,6 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic
error: error.message,
details,
validationIssues: error.validationIssues,
removedFields: error.removedFields
}, 500)
}
return c.json({
Expand All @@ -555,6 +558,7 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic
} catch (reloadError) {
logger.error('Rollback config reload failed, attempting restart:', reloadError)

await archiveBrokenOpenCodeConfigFile()
const deleted = await deleteOpenCodeConfigFile()
if (deleted) {
logger.info('Deleted filesystem config, attempting restart with fallback')
Expand Down
29 changes: 17 additions & 12 deletions backend/src/services/assistant-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -715,21 +715,23 @@ Reload the assistant workspace by disposing the current OpenCode instance. Use t

## OpenCode Configuration

The OpenCode configuration file on disk is the source of truth, and this endpoint is the only supported way to change it. Never edit \`opencode.json\` directly.
The global configuration files on disk are the source of truth. Use the \`ocm\` tool's \`request\` action with the endpoints below to read or change them; never edit the files directly. Global sources merge in order: \`config.json\`, \`opencode.json\`, then \`opencode.jsonc\`.

### GET /opencode-config

Read the current configuration file. Returns \`404\` when no config file exists yet.
Read the merged persisted global configuration and its source files. Returns \`404\` when no source exists. This is not the running instance configuration: project overrides and expanded environment values are not included. \`GET /opencode-config/effective\` reads the running server's effective global configuration separately; never copy that response into a save.

**Response (\`OpenCodeConfigFile\`):**
\`\`\`ts
{
path: string // Absolute path of the configuration file
content: object // Parsed configuration
rawContent: string // Raw file content, including comments
isValid: boolean // Whether the file passes schema validation
path: string
content: object
rawContent: string
sources: Array<{ name: string, path: string, rawContent: string, content: object, isValid: boolean }>
revision: string
isValid: boolean
validationIssues?: Array<{ path: string, message: string }>
updatedAt: number // Unix timestamp of the last write
updatedAt: number
}
\`\`\`

Expand All @@ -746,11 +748,13 @@ Read the current configuration file. Returns \`404\` when no config file exists

### PUT /opencode-config

Persist a complete configuration. Read the file first, change only the keys the user asked for, and send the complete object back.
Read the merged persisted configuration first, change only the keys the user asked for, and send the complete object back with its revision. Only changed fields are patched into the preferred existing source: JSONC, JSON, then legacy config.json. New installations use opencode.jsonc. Unchanged inherited values and comments are preserved. Removing a field removes only its override in the write target; a lower-priority value can reappear.

For a raw edit, send a string with the exact source name from \`sources\`. Never send merged JSON as raw source text. A \`409\` means the source files changed: read again and reconcile rather than retrying stale content.

**Request Body:**
\`\`\`ts
{ content: object } // The complete configuration to persist
{ content: object | string, expectedRevision: string, source?: "config.json" | "opencode.json" | "opencode.jsonc" }
\`\`\`

**Example:**
Expand All @@ -761,6 +765,7 @@ Persist a complete configuration. Read the file first, change only the keys the
"method": "PUT",
"path": "/opencode-config",
"body": {
"expectedRevision": "revision-from-get",
"content": {
"theme": "dark"
}
Expand All @@ -770,16 +775,16 @@ Persist a complete configuration. Read the file first, change only the keys the
\`\`\`

**Response:**
Returns the written \`OpenCodeConfigFile\`. Adds \`restartRequired: true\` when the change needs an OpenCode server restart, and \`removedFields\` when OpenCode dropped fields it does not accept.
Returns the refreshed merged configuration and source files. Adds \`restartRequired: true\` for semantic configuration changes, except changes limited to \`mcp\`, which are saved without it; to make an MCP change take effect immediately, tell the user to reconnect or reload the server from Settings → MCP. Comment-only changes do not require a restart. Saving never silently drops unsupported fields.

Returns \`400\` with \`validationIssues\` when OpenCode rejects the configuration.
Returns \`400\` for invalid configuration and \`409\` for a stale revision.

When the response contains \`restartRequired: true\`, tell the user to restart the OpenCode server from Settings. Never attempt the restart yourself: it would terminate your own session.

## Safety

- The settings PATCH endpoint rejects any attempt to modify credentials, API keys, or other sensitive settings; guide the user to the full UI for Git, TTS, and STT credentials
- PUT /opencode-config writes the complete OpenCode configuration, including \`plugin\`, \`mcp\`, and \`provider\` entries; change only the keys the user explicitly asked for and never add plugins, MCP servers, or provider credentials the user did not request
- PUT /opencode-config patches changed global settings, including \`plugin\`, \`mcp\`, and \`provider\` entries; change only the keys the user explicitly asked for and never add plugins, MCP servers, or provider credentials the user did not request
- The settings PATCH endpoint does NOT trigger OpenCode reload or restart
`
}
Expand Down
130 changes: 61 additions & 69 deletions backend/src/services/opencode-config-apply.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,35 @@
import { OpenCodeConfigSchema } from '@opencode-manager/shared/schemas'
import { parseJsonc } from '@opencode-manager/shared/utils'
import type { OpenCodeConfigFile, OpenCodeConfigInput } from '../types/settings'
import { OPENCODE_CONFIG_SEED, normalizeOpenCodeConfigContent, readOpenCodeConfigFile, withOpenCodeConfigLock, writeOpenCodeConfigFile } from './opencode-config-file'
import { patchConfigWithRecovery, type PatchConfigValidationIssue } from './opencode/config-recovery'
import type { OpenCodeClient } from './opencode/client'
import { isDeepStrictEqual } from 'node:util'
import type {
OpenCodeConfigFile,
OpenCodeConfigSourceName,
} from '../types/settings'
import {
buildOpenCodeConfigSeedSnapshot,
readOpenCodeConfigFile,
readOpenCodeConfigSnapshot,
restoreOpenCodeConfigSnapshot,
serializeOpenCodeConfigSnapshot,
updateOpenCodeConfigFile,
withOpenCodeConfigLock,
} from './opencode-config-file'
import { opencodeServerManager } from './opencode-single-server'
import type { SettingsService } from './settings'

export type ApplyOpenCodeConfigResult =
| { status: 'restart_pending'; config: OpenCodeConfigFile }
| { status: 'applied'; config: OpenCodeConfigFile; removedFields: string[] }
| { status: 'rejected'; error: string; validationIssues: PatchConfigValidationIssue[]; removedFields: string[] }
| { status: 'applied'; config: OpenCodeConfigFile }

export interface ApplyOpenCodeConfigInput {
content: OpenCodeConfigInput | string
openCodeClient: OpenCodeClient
content: Record<string, unknown> | string
source?: OpenCodeConfigSourceName
expectedRevision?: string
settingsService: SettingsService
}

export async function captureLastKnownGoodOpenCodeConfig(settingsService: SettingsService): Promise<OpenCodeConfigFile | null> {
const previous = await readOpenCodeConfigFile()
if (previous?.isValid) {
settingsService.saveLastKnownGoodConfig(previous.rawContent)
settingsService.saveLastKnownGoodConfig(serializeOpenCodeConfigSnapshot(previous))
}
return previous
}
Expand All @@ -32,90 +40,74 @@ export async function restoreLastKnownGoodOpenCodeConfig(settingsService: Settin
return null
}

const config = await withOpenCodeConfigLock(() => writeOpenCodeConfigFile(lastGood))
const config = await withOpenCodeConfigLock(() => restoreOpenCodeConfigSnapshot(lastGood))
opencodeServerManager.clearStartupError()
return config
}

export async function seedOpenCodeConfigFile(): Promise<OpenCodeConfigFile> {
return withOpenCodeConfigLock(() => writeOpenCodeConfigFile(OPENCODE_CONFIG_SEED))
}

function didConfigFieldChange(
previous: Record<string, unknown> | undefined,
next: Record<string, unknown> | undefined,
field: string,
): boolean {
return JSON.stringify(previous?.[field]) !== JSON.stringify(next?.[field])
}

function needsOpenCodeRestart(
previous: Record<string, unknown> | undefined,
next: Record<string, unknown> | undefined,
): boolean {
return ['agent', 'plugin', 'skills', 'provider'].some((field) => didConfigFieldChange(previous, next, field))
return withOpenCodeConfigLock(async () => {
const config = await restoreOpenCodeConfigSnapshot(buildOpenCodeConfigSeedSnapshot())
if (!config) {
throw new Error('Failed to seed OpenCode config')
}
return config
})
}

export function toOpenCodeConfigApplyResponse(
result: ApplyOpenCodeConfigResult,
): { status: 200 | 400; body: Record<string, unknown> } {
if (result.status === 'restart_pending') {
return { status: 200, body: { ...result.config, restartRequired: true } }
): { status: 200; body: Record<string, unknown> } {
return {
status: 200,
body: result.status === 'restart_pending'
? { ...result.config, restartRequired: true }
: { ...result.config },
}
}

function requiresOpenCodeRestart(previous: OpenCodeConfigFile | null, next: OpenCodeConfigFile): boolean {
if (previous?.isValid !== next.isValid) {
return true
}

if (result.status === 'applied') {
return {
status: 200,
body: result.removedFields.length > 0
? { ...result.config, removedFields: result.removedFields }
: { ...result.config },
const previousContent = previous?.content ?? {}
const keys = new Set([...Object.keys(previousContent), ...Object.keys(next.content)])
for (const key of keys) {
if (!isDeepStrictEqual(previousContent[key], next.content[key]) && key !== 'mcp') {
return true
}
}

return {
status: 400,
body: {
error: 'Config validation failed',
details: result.error,
validationIssues: result.validationIssues,
removedFields: result.removedFields,
},
}
return false
}

export async function applyOpenCodeConfigUpdate(
input: ApplyOpenCodeConfigInput,
): Promise<ApplyOpenCodeConfigResult> {
return withOpenCodeConfigLock(async () => {
const { content, openCodeClient, settingsService } = input
const { content, source, expectedRevision, settingsService } = input

const rawContent = normalizeOpenCodeConfigContent(content)
const nextContent = OpenCodeConfigSchema.parse(parseJsonc(rawContent))
const snapshot = await readOpenCodeConfigSnapshot()
const previous = await readOpenCodeConfigFile(snapshot)

const previous = await captureLastKnownGoodOpenCodeConfig(settingsService)
const next = await updateOpenCodeConfigFile(content, { source, expectedRevision, snapshot })

if (needsOpenCodeRestart(previous?.content, nextContent)) {
const config = await writeOpenCodeConfigFile(rawContent)
opencodeServerManager.markRestartPending()
return { status: 'restart_pending', config }
}

const patchResult = await patchConfigWithRecovery(openCodeClient, nextContent)
if (!patchResult.success) {
return {
status: 'rejected',
error: patchResult.error ?? 'Config validation failed',
validationIssues: patchResult.details ?? [],
removedFields: patchResult.removedFields ?? [],
if (previous?.isValid) {
const snapshot = serializeOpenCodeConfigSnapshot(previous)
try {
settingsService.saveLastKnownGoodConfig(snapshot)
} catch (error) {
await restoreOpenCodeConfigSnapshot(snapshot)
throw error
}
}

const removedFields = patchResult.removedFields ?? []
const contentToWrite = removedFields.length > 0
? JSON.stringify(patchResult.appliedConfig ?? nextContent, null, 2)
: rawContent
const config = await writeOpenCodeConfigFile(contentToWrite)
if (requiresOpenCodeRestart(previous, next)) {
opencodeServerManager.markRestartPending()
return { status: 'restart_pending', config: next }
}

return { status: 'applied', config, removedFields }
return { status: 'applied', config: next }
})
}
Loading