Skip to content

Commit 4b294fb

Browse files
feat(opencode-config): redact secrets and add MCP listing to the internal config API
1 parent 5bd688d commit 4b294fb

31 files changed

Lines changed: 1238 additions & 181 deletions

‎README.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,21 +64,23 @@ For local development setup, see the [Development Guide](https://chriswritescode
6464
- **Skills** — Extend agent capabilities with shareable, scoped skill definitions
6565
- **Notifications** — Push notifications for session events, questions, errors, and completions
6666
- **Audio** — Text-to-speech and speech-to-text (browser native and OpenAI-compatible APIs)
67+
- **Themes** — Light/dark/system appearance plus a color theme picker with the Manager default and 36 bundled OpenCode palettes
6768
- **Mobile & PWA** — Responsive mobile-first UI, installable on any device, iOS-optimized
6869

6970
## Architecture
7071

71-
OpenCode Manager is a pnpm workspace with three TypeScript packages:
72+
OpenCode Manager is a pnpm workspace with four TypeScript packages:
7273

7374
- `backend/` — Bun + Hono API server with Better Auth, SQLite migrations, OpenCode process management, SSE, schedules, and push notifications.
7475
- `frontend/` — React + Vite SPA using React Router, TanStack Query, Radix UI/Tailwind, service worker support, and mobile-first navigation.
7576
- `shared/` — shared Zod schemas, config helpers, types, and utilities consumed by both backend and frontend.
77+
- `ocm-cli/` — `ocm` CLI that attaches your local OpenCode TUI to a repo hosted on the Manager.
7678

7779
A MkDocs Material site (`docs/`) provides guides, feature docs, configuration, and troubleshooting.
7880

7981
## Development
8082

81-
This repo uses pnpm workspaces for `shared`, `backend`, and `frontend`.
83+
This repo uses pnpm workspaces for `shared`, `backend`, `frontend`, and `ocm-cli`.
8284

8385
```bash
8486
pnpm install

‎backend/src/routes/internal/index.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export function createInternalRoutes(
2929
app.route('/schedules', createScheduleRoutes(scheduleService))
3030
app.route('/notifications', createInternalNotificationRoutes(notificationService))
3131
app.route('/settings', createInternalSettingsRoutes(settingsService))
32-
app.route('/opencode-config', createOpenCodeConfigRoutes(settingsService, openCodeClient))
32+
app.route('/opencode-config', createOpenCodeConfigRoutes(settingsService, openCodeClient, { redactSecrets: true }))
3333
const repos = new Hono()
3434
repos.route('/', createInternalRepoRoutes(db, settingsService))
3535
repos.route('/:id/schedules', createScheduleRoutes(scheduleService))
Lines changed: 147 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,63 @@
1-
import { Hono } from 'hono'
1+
import { Hono, type Context } from 'hono'
22
import { z } from 'zod'
3-
import { UpdateOpenCodeConfigRequestSchema } from '@opencode-manager/shared/schemas'
3+
import {
4+
UpdateOpenCodeConfigPatchRequestSchema,
5+
UpdateOpenCodeConfigRequestSchema,
6+
} from '@opencode-manager/shared/schemas'
47
import { getWorkspacePath } from '@opencode-manager/shared/config/env'
5-
import { ClientError, openCodeLocation } from '@opencode-manager/shared/opencode'
8+
import {
9+
ClientError,
10+
mcpServerViewsFromConfig,
11+
mcpStatusByName,
12+
openCodeLocation,
13+
type McpServerView,
14+
type McpStatusMap,
15+
} from '@opencode-manager/shared/opencode'
616
import type { SettingsService } from '../services/settings'
717
import type { OpenCodeClient } from '../services/opencode/client'
818
import {
919
OpenCodeConfigConflictError,
20+
OpenCodeConfigRedactedValueError,
1021
OpenCodeConfigShadowedRemovalError,
1122
OpenCodeConfigSourceInvalidError,
1223
readOpenCodeConfigFile,
1324
withOpenCodeConfigLock,
1425
} from '../services/opencode-config-file'
1526
import { applyOpenCodeConfigUpdate, toOpenCodeConfigApplyResponse } from '../services/opencode-config-apply'
27+
import { redactOpenCodeConfigContent, redactOpenCodeConfigFile } from '../services/opencode-config-redact'
1628
import { logger } from '../utils/logger'
1729

18-
export function createOpenCodeConfigRoutes(settingsService: SettingsService, openCodeClient: OpenCodeClient) {
30+
interface OpenCodeConfigRoutesOptions {
31+
redactSecrets?: boolean
32+
}
33+
34+
async function readMcpStatus(openCodeClient: OpenCodeClient): Promise<McpStatusMap> {
35+
try {
36+
const servers = await openCodeClient.api.mcp.list(openCodeLocation(getWorkspacePath()))
37+
return mcpStatusByName(servers.data)
38+
} catch (error) {
39+
logger.warn('Failed to read live MCP server status:', error)
40+
return {}
41+
}
42+
}
43+
44+
function mergeMcpServerStatus(views: McpServerView[], status: McpStatusMap) {
45+
return views.map((view) => {
46+
const live = status[view.name]
47+
if (!live) return view
48+
return {
49+
...view,
50+
status: live.status,
51+
...('error' in live && live.error ? { error: live.error } : {}),
52+
}
53+
})
54+
}
55+
56+
export function createOpenCodeConfigRoutes(
57+
settingsService: SettingsService,
58+
openCodeClient: OpenCodeClient,
59+
options: OpenCodeConfigRoutesOptions = {},
60+
) {
1961
const app = new Hono()
2062

2163
app.get('/', async (c) => {
@@ -24,7 +66,7 @@ export function createOpenCodeConfigRoutes(settingsService: SettingsService, ope
2466
if (!config) {
2567
return c.json({ error: 'No OpenCode config file found' }, 404)
2668
}
27-
return c.json(config)
69+
return c.json(options.redactSecrets ? redactOpenCodeConfigFile(config) : config)
2870
} catch (error) {
2971
logger.error('Failed to get OpenCode config:', error)
3072
return c.json({ error: 'Failed to get OpenCode config' }, 500)
@@ -34,7 +76,18 @@ export function createOpenCodeConfigRoutes(settingsService: SettingsService, ope
3476
app.get('/effective', async (c) => {
3577
try {
3678
const entries = await openCodeClient.api.config.get(openCodeLocation(getWorkspacePath()))
37-
return c.json({ entries })
79+
return c.json({
80+
entries: options.redactSecrets
81+
? entries.map((entry) =>
82+
entry.type === 'document'
83+
? {
84+
...entry,
85+
info: redactOpenCodeConfigContent(entry.info).content as typeof entry.info,
86+
}
87+
: entry,
88+
)
89+
: entries,
90+
})
3891
} catch (error) {
3992
logger.error('Failed to get effective OpenCode config:', error)
4093
if (error instanceof ClientError) {
@@ -47,6 +100,23 @@ export function createOpenCodeConfigRoutes(settingsService: SettingsService, ope
47100
}
48101
})
49102

103+
app.get('/mcp', async (c) => {
104+
try {
105+
const [config, status] = await Promise.all([
106+
withOpenCodeConfigLock(readOpenCodeConfigFile),
107+
readMcpStatus(openCodeClient),
108+
])
109+
const views = mcpServerViewsFromConfig(config?.content.mcp)
110+
return c.json({
111+
revision: config?.revision ?? null,
112+
servers: mergeMcpServerStatus(views, status),
113+
})
114+
} catch (error) {
115+
logger.error('Failed to get OpenCode MCP servers:', error)
116+
return c.json({ error: 'Failed to get OpenCode MCP servers' }, 500)
117+
}
118+
})
119+
50120
app.put('/', async (c) => {
51121
let body: unknown
52122
try {
@@ -60,40 +130,80 @@ export function createOpenCodeConfigRoutes(settingsService: SettingsService, ope
60130
return c.json({ error: 'Invalid config data', details: parsed.error.issues }, 400)
61131
}
62132

133+
return applyUpdate(c, {
134+
content: parsed.data.content,
135+
source: parsed.data.source,
136+
expectedRevision: parsed.data.expectedRevision,
137+
settingsService,
138+
openCodeClient,
139+
}, options.redactSecrets)
140+
})
141+
142+
app.patch('/', async (c) => {
143+
let body: unknown
63144
try {
64-
const result = await applyOpenCodeConfigUpdate({
65-
content: parsed.data.content,
66-
source: parsed.data.source,
67-
expectedRevision: parsed.data.expectedRevision,
68-
settingsService,
69-
openCodeClient,
70-
})
71-
const { status, body: responseBody } = toOpenCodeConfigApplyResponse(result)
72-
return c.json(responseBody, status)
73-
} catch (error) {
74-
logger.error('Failed to update OpenCode config:', error)
75-
if (error instanceof OpenCodeConfigConflictError) {
76-
return c.json({
77-
error: error.message,
78-
expectedRevision: error.expectedRevision,
79-
actualRevision: error.actualRevision,
80-
}, 409)
81-
}
82-
if (error instanceof OpenCodeConfigSourceInvalidError) {
83-
return c.json({ error: error.message, sources: error.sources }, 400)
84-
}
85-
if (error instanceof OpenCodeConfigShadowedRemovalError) {
86-
return c.json({ error: error.message, paths: error.paths, sources: error.sources }, 409)
87-
}
88-
if (error instanceof z.ZodError) {
89-
return c.json({ error: 'Invalid config data', details: error.issues }, 400)
90-
}
91-
if (error instanceof SyntaxError) {
92-
return c.json({ error: 'Invalid config data', details: error.message }, 400)
93-
}
94-
return c.json({ error: 'Failed to update OpenCode config' }, 500)
145+
body = await c.req.json()
146+
} catch {
147+
return c.json({ error: 'Invalid JSON' }, 400)
148+
}
149+
150+
const parsed = UpdateOpenCodeConfigPatchRequestSchema.safeParse(body)
151+
if (!parsed.success) {
152+
return c.json({ error: 'Invalid config data', details: parsed.error.issues }, 400)
95153
}
154+
155+
return applyUpdate(c, {
156+
content: parsed.data.patch,
157+
source: parsed.data.source,
158+
expectedRevision: parsed.data.expectedRevision,
159+
mode: 'merge',
160+
settingsService,
161+
openCodeClient,
162+
}, options.redactSecrets)
96163
})
97164

98165
return app
99166
}
167+
168+
async function applyUpdate(
169+
c: Context,
170+
input: Parameters<typeof applyOpenCodeConfigUpdate>[0],
171+
redactSecrets = false,
172+
) {
173+
try {
174+
const result = await applyOpenCodeConfigUpdate(input)
175+
const response = toOpenCodeConfigApplyResponse(result)
176+
if (!redactSecrets) {
177+
return c.json(response.body, response.status)
178+
}
179+
return c.json({
180+
...redactOpenCodeConfigFile(result.config),
181+
...(result.status === 'restart_pending' ? { restartRequired: true } : {}),
182+
}, response.status)
183+
} catch (error) {
184+
logger.error('Failed to update OpenCode config:', error)
185+
if (error instanceof OpenCodeConfigConflictError) {
186+
return c.json({
187+
error: error.message,
188+
expectedRevision: error.expectedRevision,
189+
actualRevision: error.actualRevision,
190+
}, 409)
191+
}
192+
if (error instanceof OpenCodeConfigSourceInvalidError) {
193+
return c.json({ error: error.message, sources: error.sources }, 400)
194+
}
195+
if (error instanceof OpenCodeConfigShadowedRemovalError) {
196+
return c.json({ error: error.message, paths: error.paths, sources: error.sources }, 409)
197+
}
198+
if (error instanceof OpenCodeConfigRedactedValueError) {
199+
return c.json({ error: error.message, paths: error.paths }, 400)
200+
}
201+
if (error instanceof z.ZodError) {
202+
return c.json({ error: 'Invalid config data', details: error.issues }, 400)
203+
}
204+
if (error instanceof SyntaxError) {
205+
return c.json({ error: 'Invalid config data', details: error.message }, 400)
206+
}
207+
return c.json({ error: 'Failed to update OpenCode config' }, 500)
208+
}
209+
}

‎backend/src/services/assistant-mode.ts‎

Lines changed: 45 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -721,19 +721,19 @@ The global configuration files on disk are the source of truth. Use the \`ocm\`
721721
722722
### GET /opencode-config
723723
724-
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 configuration separately as \`entries\`: the configuration documents and discovery directories in precedence order, lowest first, each shaped as \`{ type: 'document', path, info }\` or \`{ type: 'directory', path }\`. Its \`info\` values are expanded for the running server; never copy this response into a save.
724+
Read the merged persisted global configuration and its source files. Returns \`404\` when no source exists. Secret values are replaced with \`<redacted>\` and the raw source text is omitted, so a read never returns credentials; \`redactedPaths\` lists the hidden paths. 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 configuration separately as \`entries\`: the configuration documents and discovery directories in precedence order, lowest first, each shaped as \`{ type: 'document', path, info }\` or \`{ type: 'directory', path }\`. Its \`info\` values are expanded for the running server, with secrets redacted; never copy this response into a save.
725725
726-
**Response (\`OpenCodeConfigFile\`):**
726+
**Response:**
727727
\`\`\`ts
728728
{
729729
path: string
730-
content: object
731-
rawContent: string
732-
sources: Array<{ name: string, path: string, rawContent: string, content: object, isValid: boolean }>
733-
revision: string
730+
content: object // secrets replaced with "<redacted>"
734731
isValid: boolean
735732
validationIssues?: Array<{ path: string, message: string }>
736733
updatedAt: number
734+
sources: Array<{ name: string, path: string, content: object, isValid: boolean, validationIssues?: Array<{ path: string, message: string }>, updatedAt: number }>
735+
revision: string
736+
redactedPaths: string[]
737737
}
738738
\`\`\`
739739
@@ -748,40 +748,66 @@ Read the merged persisted global configuration and its source files. Returns \`4
748748
}
749749
\`\`\`
750750
751-
### PUT /opencode-config
751+
### PATCH /opencode-config
752752
753-
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, then 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.
753+
Change only the paths the user asked for. Send a nested \`patch\` object naming those paths and the values to set; every path you do not name is left untouched, and a \`null\` value removes a path. Only changed fields are patched into the preferred existing source: JSONC, then JSON. New installations use opencode.jsonc. Unchanged inherited values and comments are preserved.
754754
755-
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.
755+
Send \`expectedRevision\` from a read. A \`409\` means the source files changed: read again and reconcile rather than retrying stale content.
756756
757757
**Request Body:**
758758
\`\`\`ts
759-
{ content: object | string, expectedRevision: string, source?: "opencode.json" | "opencode.jsonc" }
759+
{ patch: object, expectedRevision?: string, source?: "opencode.json" | "opencode.jsonc" }
760760
\`\`\`
761761
762-
**Example:**
762+
**Example** — change one MCP server's bearer token without reading the file:
763763
\`\`\`json
764764
{
765765
"action": "request",
766766
"params": {
767-
"method": "PUT",
767+
"method": "PATCH",
768768
"path": "/opencode-config",
769769
"body": {
770-
"expectedRevision": "revision-from-get",
771-
"content": {
772-
"theme": "dark"
770+
"patch": {
771+
"mcp": {
772+
"servers": {
773+
"linear": { "headers": { "Authorization": "Bearer <token>" } }
774+
}
775+
}
773776
}
774777
}
775778
}
776779
}
777780
\`\`\`
778781
779782
**Response:**
780-
Returns the refreshed merged configuration and source files. A semantic change is applied automatically: the Manager reloads OpenCode without restarting the server, so agents, permissions, providers, models, and plugins take effect on the next message and running sessions keep running. Changes limited to \`mcp\` are saved without a reload; to make an MCP change take effect immediately, tell the user to reconnect the server from Settings → MCP. Comment-only changes do nothing. Saving never silently drops unsupported fields.
783+
Returns the refreshed merged configuration and source files, with secrets redacted. A semantic change is applied automatically: the Manager reloads OpenCode without restarting the server, so agents, permissions, providers, models, and plugins take effect on the next message and running sessions keep running. An \`mcp\` change is applied the same way, and only the servers whose configuration changed reconnect. Comment-only changes do nothing. Saving never silently drops unsupported fields.
781784
782785
The response adds \`restartRequired: true\` only when the automatic reload failed, for example because the OpenCode server is unavailable.
783786
784-
Returns \`400\` for invalid configuration and \`409\` for a stale revision.
787+
Returns \`400\` for invalid configuration or a \`<redacted>\` value (\`paths\` lists them), and \`409\` for a stale revision.
788+
789+
### GET /opencode-config/mcp
790+
791+
List the configured MCP servers with their stored shape, enabled state, and live connection status. Use this to answer questions about MCP servers instead of reading the whole configuration. Header and environment values are never returned.
792+
793+
\`type\` is \`local\` or \`remote\`, and \`command\` or \`url\` is included for the matching type. \`enabled\` reflects the stored flag. \`shape\` is \`servers\` for a native \`mcp.servers.<name>\` entry or \`legacy\` for a flat \`mcp.<name>\` entry, so you can address the entry by the path it actually uses. \`status\` is the live OpenCode status (\`connected\`, \`pending\`, \`disabled\`, \`failed\`, or \`needs_auth\`) when the server is reachable, and \`error\` carries the failure reason.
794+
795+
**Response:**
796+
\`\`\`ts
797+
{
798+
revision: string | null
799+
servers: Array<{
800+
name: string
801+
type: 'local' | 'remote'
802+
command?: string[]
803+
url?: string
804+
enabled: boolean
805+
shape: 'servers' | 'legacy'
806+
status?: 'connected' | 'pending' | 'disabled' | 'failed' | 'needs_auth'
807+
error?: string
808+
}>
809+
}
810+
\`\`\`
785811
786812
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.
787813
@@ -790,7 +816,8 @@ Only changes to how the OpenCode process is launched need a user restart from Se
790816
## Safety
791817
792818
- 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
793-
- 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
819+
- PATCH /opencode-config patches only the paths you name, 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
820+
- GET /opencode-config redacts secrets; never reconstruct a secret you did not read, and never send a \`<redacted>\` value back
794821
- The settings PATCH endpoint does NOT trigger an OpenCode reload or restart
795822
`
796823
}

0 commit comments

Comments
 (0)