Skip to content

Commit 39d2b98

Browse files
fix(settings): harden config directory replace and consolidate upload plumbing
Validate the config before any filesystem work and persist the default row only after the directory swap succeeds; reject oversize uploads before buffering and stream parts to staging; re-ensure AGENTS.md after a replace. Consolidate the upload contract in shared, unify the default-config upsert, single-own the browser directory-upload and restart state, and drop dead code.
1 parent b1bccb3 commit 39d2b98

36 files changed

Lines changed: 1094 additions & 506 deletions

‎backend/package.json‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@
2525
"croner": "^10.0.1",
2626
"eventsource": "^4.1.0",
2727
"hono": "^4.11.7",
28-
"jsonc-parser": "^3.3.1",
2928
"web-push": "^3.6.7",
3029
"zod": "^4.1.12"
3130
},

‎backend/src/index.ts‎

Lines changed: 6 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,9 @@ import { sweepStaleUploadSessions } from './routes/internal/repo-mirror-helpers'
4141
import { createOpenCodeProxyRoutes } from './routes/opencode-proxy'
4242
import { sseAggregator } from './services/sse-aggregator'
4343
import { ensureDirectoryExists, writeFileContent, fileExists, readFileContent } from './services/file-operations'
44-
import { SettingsService } from './services/settings'
44+
import { SettingsService, DEFAULT_SEED_OPENCODE_CONFIG } from './services/settings'
45+
import { sweepStaleOpenCodeConfigDirectoryDirs } from './services/opencode-config-directory'
46+
import { ensureDefaultAgentsMdExists } from './services/agents-md'
4547
import { opencodeServerManager } from './services/opencode-single-server'
4648
import { createOpenCodeClient } from './services/opencode/client'
4749
import { NotificationService } from './services/notification'
@@ -66,7 +68,6 @@ import {
6668
getReposPath,
6769
getConfigPath,
6870
getOpenCodeConfigFilePath,
69-
getAgentsMdPath,
7071
getDatabasePath,
7172
ENV
7273
} from '@opencode-manager/shared/config/env'
@@ -104,8 +105,6 @@ const auth = createAuth(db)
104105
const requireAuth = createAuthMiddleware(auth)
105106
const openCodeClient = createOpenCodeClient(() => new SettingsService(db).getOpenCodeServerPassword())
106107

107-
import { DEFAULT_AGENTS_MD } from './constants'
108-
109108
let ipcServer: IPCServer | undefined
110109
const gitAuthService = new GitAuthService()
111110
let openCodeSupervisor: OpenCodeSupervisor | undefined
@@ -150,13 +149,8 @@ async function ensureDefaultConfigExists(): Promise<void> {
150149
}
151150

152151
logger.info('No existing config found, creating minimal seed config')
153-
const seedConfig = JSON.stringify({ $schema: 'https://opencode.ai/config.json' }, null, 2)
154-
settingsService.createOpenCodeConfig({
155-
name: 'default',
156-
content: seedConfig,
157-
isDefault: true,
158-
})
159-
await writeFileContent(workspaceConfigPath, seedConfig)
152+
settingsService.upsertDefaultOpenCodeConfig(DEFAULT_SEED_OPENCODE_CONFIG)
153+
await writeFileContent(workspaceConfigPath, DEFAULT_SEED_OPENCODE_CONFIG)
160154
logger.info('Created minimal seed config')
161155
}
162156

@@ -216,16 +210,6 @@ async function ensureHomeStateImported(): Promise<void> {
216210
}
217211
}
218212

219-
async function ensureDefaultAgentsMdExists(): Promise<void> {
220-
const agentsMdPath = getAgentsMdPath()
221-
const exists = await fileExists(agentsMdPath)
222-
223-
if (!exists) {
224-
await writeFileContent(agentsMdPath, DEFAULT_AGENTS_MD)
225-
logger.info(`Created default AGENTS.md at: ${agentsMdPath}`)
226-
}
227-
}
228-
229213
try {
230214
if (ENV.SERVER.NODE_ENV === 'production' && !ENV.AUTH.SECRET) {
231215
logger.error('AUTH_SECRET is required in production mode')
@@ -241,6 +225,7 @@ try {
241225

242226
await cleanupExpiredCache()
243227
await sweepStaleUploadSessions()
228+
await sweepStaleOpenCodeConfigDirectoryDirs()
244229

245230
await ensureDefaultConfigExists()
246231
await backfillOpenCodeModelStateFromFile()

‎backend/src/routes/settings.ts‎

Lines changed: 30 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Hono, type Context } from 'hono'
2+
import { bodyLimit } from 'hono/body-limit'
23
import { z } from 'zod'
34
import { execSync, spawnSync } from 'child_process'
45
import { randomUUID } from 'crypto'
@@ -39,7 +40,8 @@ import { encryptSecret } from '../utils/crypto'
3940
import { compareVersions, isValidVersion } from '../utils/version-utils'
4041
import { getImportedSessionDirectories, getOpenCodeImportStatus, OpenCodeImportProtectionError, syncOpenCodeImport } from '../services/opencode-import'
4142
import { relinkReposFromSessionDirectories } from '../services/repo'
42-
import { ENV } from '@opencode-manager/shared/config/env'
43+
import { ENV, FILE_LIMITS } from '@opencode-manager/shared/config/env'
44+
import { MAX_OPENCODE_CONFIG_DIRECTORY_FILES, OPENCODE_CONFIG_UPLOAD_ERRORS } from '@opencode-manager/shared/utils'
4345
import {
4446
listManagedSkills,
4547
getSkill,
@@ -50,14 +52,15 @@ import {
5052
installSkillFromUploadedFiles,
5153
} from '../services/skills'
5254
import { replaceOpenCodeConfigDirectory } from '../services/opencode-config-directory'
55+
import { ensureDefaultAgentsMdExists } from '../services/agents-md'
5356
import {
5457
installOpenCodeDirectoryFiles,
5558
listOpenCodeDirectoryFiles,
5659
getOpenCodeDirectoryFile,
5760
updateOpenCodeDirectoryFile,
5861
deleteOpenCodeDirectoryFile,
5962
} from '../services/opencode-directory-files'
60-
import { parseUploadManifest, readUploadedManifestFiles, UploadValidationError } from './upload-utils'
63+
import { parseUploadManifest, parseUploadPreamble, readUploadedManifestFiles, resolveUploadedManifestFiles, UploadValidationError, UPLOAD_PATH_ERROR_STATUS } from './upload-utils'
6164
import { getRepoById } from '../db/queries'
6265
import { githubFetch } from '../utils/github'
6366

@@ -147,25 +150,16 @@ async function reloadAfterSkillInstall(
147150

148151
const OPENCODE_DIRECTORY_UPLOAD_ERROR_STATUS: ReadonlyArray<readonly [string, 400]> = [
149152
['No markdown', 400],
150-
['Path must be relative', 400],
151-
['Path must not contain', 400],
152153
['Path must reference', 400],
153-
['escapes', 400],
154-
['Missing upload file', 400],
155-
['not a valid file', 400],
154+
...UPLOAD_PATH_ERROR_STATUS,
156155
]
157156

158157
const OPENCODE_CONFIG_DIRECTORY_REPLACE_ERROR_STATUS: ReadonlyArray<readonly [string, 400 | 413]> = [
159158
['No files were provided', 400],
160159
['must contain opencode.json', 400],
161160
['too many files', 400],
162-
['contains too many files', 400],
163161
['exceed maximum upload size', 413],
164-
['Path must be relative', 400],
165-
['Path must not contain', 400],
166-
['escapes', 400],
167-
['Missing upload file', 400],
168-
['not a valid file', 400],
162+
...UPLOAD_PATH_ERROR_STATUS,
169163
]
170164

171165
function matchErrorStatus<T extends number>(
@@ -204,14 +198,10 @@ const SKILL_INSTALL_ERROR_STATUS: ReadonlyArray<readonly [string, 400 | 404 | 40
204198
['Invalid skill name', 400],
205199
['Only one skill', 400],
206200
['Skill source must contain', 400],
207-
['Path must be relative', 400],
208-
['Path must not contain', 400],
209-
['escapes', 400],
210201
['no downloadable files', 400],
211202
['repoId is required', 400],
212-
['Missing upload file', 400],
213203
['Invalid repoId', 400],
214-
['not a valid file', 400],
204+
...UPLOAD_PATH_ERROR_STATUS,
215205
]
216206

217207
function didConfigFieldChange(
@@ -1280,15 +1270,9 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic
12801270

12811271
app.post('/opencode-directory-files/install', async (c) => {
12821272
try {
1283-
const contentType = c.req.header('content-type') || ''
1284-
if (!contentType.includes('multipart/form-data')) {
1285-
return c.json({ error: 'Unsupported content type. Use multipart/form-data' }, 400)
1286-
}
1287-
1288-
const formData = await c.req.parseBody({ all: true })
1273+
const { formData, manifest } = await parseUploadPreamble(c)
12891274
const kind = z.enum(['agents', 'commands']).parse(formData['kind'])
12901275

1291-
const manifest = parseUploadManifest(formData['fileManifest'])
12921276
const markdownManifest = getMarkdownUploadManifest(manifest)
12931277
if (markdownManifest.length === 0) {
12941278
return c.json({ error: `No markdown ${kind} files found` }, 400)
@@ -1322,40 +1306,50 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic
13221306
}
13231307
})
13241308

1309+
app.use('/opencode-config-directory/replace', bodyLimit({
1310+
maxSize: FILE_LIMITS.MAX_UPLOAD_SIZE_BYTES,
1311+
onError: (c) => c.json({ error: OPENCODE_CONFIG_UPLOAD_ERRORS.EXCEEDS_MAX_UPLOAD_SIZE }, 413),
1312+
}))
1313+
13251314
app.post('/opencode-config-directory/replace', async (c) => {
13261315
try {
1327-
const contentType = c.req.header('content-type') || ''
1328-
if (!contentType.includes('multipart/form-data')) {
1329-
return c.json({ error: 'Unsupported content type. Use multipart/form-data' }, 400)
1330-
}
1331-
1332-
const formData = await c.req.parseBody({ all: true })
13331316
const userId = c.req.query('userId') || 'default'
13341317

1335-
let manifest: ReturnType<typeof parseUploadManifest>
1318+
let preamble: Awaited<ReturnType<typeof parseUploadPreamble>>
13361319
try {
1337-
manifest = parseUploadManifest(formData['fileManifest'])
1320+
preamble = await parseUploadPreamble(c)
13381321
} catch (error) {
13391322
if (error instanceof z.ZodError) {
13401323
return c.json({ error: 'Invalid upload manifest', details: error.issues }, 400)
13411324
}
13421325
throw error
13431326
}
1327+
const { formData, manifest } = preamble
1328+
13441329
if (manifest.length === 0) {
13451330
return c.json({ error: 'fileManifest must contain at least one entry' }, 400)
13461331
}
1332+
if (manifest.length > MAX_OPENCODE_CONFIG_DIRECTORY_FILES) {
1333+
return c.json({ error: OPENCODE_CONFIG_UPLOAD_ERRORS.TOO_MANY_FILES }, 400)
1334+
}
13471335

1348-
const files = await readUploadedManifestFiles(formData, manifest)
1336+
const files = resolveUploadedManifestFiles(formData, manifest)
13491337

13501338
settingsService.saveLastKnownGoodConfig(userId)
13511339

13521340
const result = await replaceOpenCodeConfigDirectory(db, files, userId)
13531341

1342+
try {
1343+
await ensureDefaultAgentsMdExists()
1344+
} catch (error) {
1345+
logger.warn('Failed to ensure AGENTS.md after OpenCode config directory replace', error)
1346+
}
1347+
13541348
opencodeServerManager.markRestartPending()
13551349
opencodeServerManager.clearStartupError()
13561350
await restartOpenCodeSafe(openCodeSupervisor, 'OpenCode config directory replace')
13571351

1358-
return c.json({ ...result, restartRequired: true })
1352+
return c.json(result)
13591353
} catch (error) {
13601354
logger.error('Failed to replace OpenCode config directory:', error)
13611355

@@ -1457,14 +1451,12 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic
14571451
}
14581452

14591453
if (contentType.includes('multipart/form-data')) {
1460-
const formData = await c.req.parseBody({ all: true })
1454+
const { formData, manifest } = await parseUploadPreamble(c)
14611455

14621456
const scope = formData['scope']
14631457
const repoIdValue = formData['repoId']
14641458
const overwriteValue = formData['overwrite']
14651459

1466-
const manifest = parseUploadManifest(formData['fileManifest'])
1467-
14681460
const repoId = parseOptionalRepoId(repoIdValue as string | undefined)
14691461
const overwrite = parseBooleanFormValue(overwriteValue)
14701462

‎backend/src/routes/upload-utils.ts‎

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { InstallSkillUploadManifestEntrySchema, type InstallSkillUploadManifestEntry } from '@opencode-manager/shared'
2+
import type { Context } from 'hono'
23

34
export class UploadValidationError extends Error {}
45

@@ -19,23 +20,53 @@ export function parseUploadManifest(fileManifestRaw: unknown): InstallSkillUploa
1920
return InstallSkillUploadManifestEntrySchema.array().parse(manifestEntries)
2021
}
2122

22-
export async function readUploadedManifestFiles(
23+
export async function parseUploadPreamble(c: Context): Promise<{ formData: ParsedFormData; manifest: InstallSkillUploadManifestEntry[] }> {
24+
const contentType = c.req.header('content-type') || ''
25+
if (!contentType.includes('multipart/form-data')) {
26+
throw new UploadValidationError('Unsupported content type. Use multipart/form-data')
27+
}
28+
29+
const formData = await c.req.parseBody({ all: true })
30+
const manifest = parseUploadManifest(formData['fileManifest'])
31+
32+
return { formData, manifest }
33+
}
34+
35+
export const UPLOAD_PATH_ERROR_STATUS: ReadonlyArray<readonly [string, 400]> = [
36+
['Path must be relative', 400],
37+
['Path must not be empty', 400],
38+
['Path must not contain', 400],
39+
['escapes', 400],
40+
['not a valid file', 400],
41+
]
42+
43+
export function resolveUploadedManifestFiles(
2344
formData: ParsedFormData,
2445
manifest: InstallSkillUploadManifestEntry[],
25-
): Promise<{ relativePath: string; content: Buffer }[]> {
46+
): { relativePath: string; file: File }[] {
2647
const missingFields = manifest.filter((entry) => !formData[entry.fieldName])
2748
if (missingFields.length > 0) {
2849
throw new UploadValidationError(`Missing upload file(s): ${missingFields.map((e) => e.fieldName).join(', ')}`)
2950
}
3051

52+
return manifest.map((entry) => {
53+
const file = formData[entry.fieldName]
54+
if (!file || !(file instanceof File)) {
55+
throw new Error(`Field "${entry.fieldName}" is not a valid file`)
56+
}
57+
return { relativePath: entry.relativePath, file }
58+
})
59+
}
60+
61+
export async function readUploadedManifestFiles(
62+
formData: ParsedFormData,
63+
manifest: InstallSkillUploadManifestEntry[],
64+
): Promise<{ relativePath: string; content: Buffer }[]> {
65+
const files = resolveUploadedManifestFiles(formData, manifest)
3166
return Promise.all(
32-
manifest.map(async (entry) => {
33-
const file = formData[entry.fieldName]
34-
if (!file || !(file instanceof File)) {
35-
throw new Error(`Field "${entry.fieldName}" is not a valid file`)
36-
}
37-
const content = Buffer.from(await file.arrayBuffer())
38-
return { relativePath: entry.relativePath, content }
39-
}),
67+
files.map(async ({ relativePath, file }) => ({
68+
relativePath,
69+
content: Buffer.from(await file.arrayBuffer()),
70+
})),
4071
)
4172
}

‎backend/src/services/agents-md.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { getAgentsMdPath } from '@opencode-manager/shared/config/env'
2+
import { writeFileContent, fileExists } from './file-operations'
3+
import { DEFAULT_AGENTS_MD } from '../constants'
4+
import { logger } from '../utils/logger'
5+
6+
export async function ensureDefaultAgentsMdExists(): Promise<void> {
7+
const agentsMdPath = getAgentsMdPath()
8+
const exists = await fileExists(agentsMdPath)
9+
10+
if (!exists) {
11+
await writeFileContent(agentsMdPath, DEFAULT_AGENTS_MD)
12+
logger.info(`Created default AGENTS.md at: ${agentsMdPath}`)
13+
}
14+
}

0 commit comments

Comments
 (0)